more brainstorming
This commit is contained in:
@@ -15,7 +15,6 @@
|
|||||||
// "postCreateCommand": "yarn install",
|
// "postCreateCommand": "yarn install",
|
||||||
"postCreateCommand": "cd client && npm install"
|
"postCreateCommand": "cd client && npm install"
|
||||||
|
|
||||||
|
|
||||||
// Configure tool-specific properties.
|
// Configure tool-specific properties.
|
||||||
// "customizations": {},
|
// "customizations": {},
|
||||||
|
|
||||||
|
|||||||
2210
client/package-lock.json
generated
2210
client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,22 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@carbon/icons-react": "^11.53.0",
|
"@carbon/icons-react": "^11.53.0",
|
||||||
|
"@internationalized/date": "^3.6.0",
|
||||||
|
"@nextui-org/button": "^2.2.9",
|
||||||
|
"@nextui-org/card": "^2.2.9",
|
||||||
|
"@nextui-org/checkbox": "^2.3.8",
|
||||||
|
"@nextui-org/date-picker": "^2.3.9",
|
||||||
|
"@nextui-org/divider": "^2.2.5",
|
||||||
|
"@nextui-org/input": "^2.4.8",
|
||||||
|
"@nextui-org/modal": "^2.2.7",
|
||||||
|
"@nextui-org/select": "^2.4.9",
|
||||||
|
"@nextui-org/system": "^2.4.6",
|
||||||
|
"@nextui-org/table": "^2.2.8",
|
||||||
|
"@nextui-org/theme": "^2.4.5",
|
||||||
|
"@nextui-org/tooltip": "^2.2.7",
|
||||||
|
"@react-aria/i18n": "^3.12.4",
|
||||||
|
"@react-stately/data": "^3.12.0",
|
||||||
|
"framer-motion": "^11.15.0",
|
||||||
"next": "15.1.3",
|
"next": "15.1.3",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
|||||||
76
client/src/Zustand.ts
Normal file
76
client/src/Zustand.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import {
|
||||||
|
DateFormatter as IntlDateFormatter,
|
||||||
|
parseZonedDateTime,
|
||||||
|
ZonedDateTime,
|
||||||
|
} from "@internationalized/date";
|
||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
export type Task = string;
|
||||||
|
|
||||||
|
export const Tasks: Task[] = [
|
||||||
|
"Audio",
|
||||||
|
"Livestream",
|
||||||
|
"Camera",
|
||||||
|
"Light",
|
||||||
|
"Stream Audio",
|
||||||
|
];
|
||||||
|
|
||||||
|
export type Availability = string;
|
||||||
|
|
||||||
|
export const Availabilities: Availability[] = ["yes", "maybe", "no"];
|
||||||
|
|
||||||
|
export interface EventData {
|
||||||
|
id: number;
|
||||||
|
date: ZonedDateTime;
|
||||||
|
tasks: Partial<Record<Task, string | undefined>>;
|
||||||
|
volunteers: Partial<Record<string, Availability>>;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Zustand {
|
||||||
|
events: EventData[];
|
||||||
|
addEvent: (event: EventData) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const zustand = create<Zustand>()((set) => ({
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
id: 0,
|
||||||
|
// date: parseDateTime("2025-01-05T11:00[Europe/Berlin]").toString(),
|
||||||
|
date: parseZonedDateTime("2025-01-05T11:00[Europe/Berlin]"),
|
||||||
|
tasks: {
|
||||||
|
Audio: "Mark",
|
||||||
|
Livestream: undefined,
|
||||||
|
"Stream Audio": undefined,
|
||||||
|
},
|
||||||
|
volunteers: { Mark: "yes", Simon: "maybe", Sophie: "no" },
|
||||||
|
description: "neuer Prädikant",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
date: parseZonedDateTime("2025-01-12T11:00[Europe/Berlin]"),
|
||||||
|
tasks: {
|
||||||
|
Audio: "Mark",
|
||||||
|
Livestream: undefined,
|
||||||
|
},
|
||||||
|
volunteers: { Mark: "yes", Simon: "maybe" },
|
||||||
|
description: "",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
addEvent: (event: EventData) =>
|
||||||
|
set((state) => ({ events: state.events.toSpliced(-1, 0, event) })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export class DateFormatter {
|
||||||
|
private formatter;
|
||||||
|
|
||||||
|
constructor(locale: string, options?: Intl.DateTimeFormatOptions) {
|
||||||
|
this.formatter = new IntlDateFormatter(locale, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
format(dt: Date) {
|
||||||
|
return this.formatter.format(dt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default zustand;
|
||||||
1
client/src/app/ManageEvents.tsx
Normal file
1
client/src/app/ManageEvents.tsx
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export default function ManageEvents() {}
|
||||||
46
client/src/app/Overview.tsx
Normal file
46
client/src/app/Overview.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Add } from "@carbon/icons-react";
|
||||||
|
import Event from "../components/Event/Event";
|
||||||
|
import { useState } from "react";
|
||||||
|
import AddEvent from "../components/Event/AddEvent";
|
||||||
|
import zustand from "../Zustand";
|
||||||
|
import { Button } from "@nextui-org/button";
|
||||||
|
|
||||||
|
export default function EventVolunteer() {
|
||||||
|
const [showAddItemDialogue, setShowAddItemDialogue] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex-1 p-4">
|
||||||
|
<h2 className="mb-4 text-center text-4xl">Overview</h2>
|
||||||
|
<div className="flex flex-wrap justify-center gap-4">
|
||||||
|
{zustand.getState().events.map((ee, ii) => (
|
||||||
|
<Event
|
||||||
|
key={ii}
|
||||||
|
date={ee.date}
|
||||||
|
description={ee.description}
|
||||||
|
id={ee.id}
|
||||||
|
tasks={ee.tasks}
|
||||||
|
volunteers={ee.volunteers}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
isIconOnly
|
||||||
|
radius="full"
|
||||||
|
className="absolute bottom-0 right-0"
|
||||||
|
onPress={() => setShowAddItemDialogue(true)}
|
||||||
|
>
|
||||||
|
<Add size={32} />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<AddEvent
|
||||||
|
className="border-2 border-accent-3"
|
||||||
|
isOpen={showAddItemDialogue}
|
||||||
|
onOpenChange={setShowAddItemDialogue}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
253
client/src/app/admin/page.tsx
Normal file
253
client/src/app/admin/page.tsx
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import AddEvent from "@/components/Event/AddEvent";
|
||||||
|
import LocalDate from "@/components/Event/LocalDate";
|
||||||
|
import zustand, { Availability, EventData, Task, Tasks } from "@/Zustand";
|
||||||
|
import { Add, Copy, Edit, TrashCan } from "@carbon/icons-react";
|
||||||
|
import { Button, ButtonGroup } from "@nextui-org/button";
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
ModalBody,
|
||||||
|
ModalContent,
|
||||||
|
ModalFooter,
|
||||||
|
ModalHeader,
|
||||||
|
} from "@nextui-org/modal";
|
||||||
|
import { Select, SelectItem } from "@nextui-org/select";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableColumn,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@nextui-org/table";
|
||||||
|
import { Tooltip } from "@nextui-org/tooltip";
|
||||||
|
import { useAsyncList } from "@react-stately/data";
|
||||||
|
import React, { Key, useState } from "react";
|
||||||
|
|
||||||
|
export default function AdminPanel() {
|
||||||
|
const tasks = [
|
||||||
|
{ key: "date", label: "Date" },
|
||||||
|
{ key: "description", label: "Description" },
|
||||||
|
...Tasks.map((task) => ({ label: task, key: task })),
|
||||||
|
{ key: "actions", label: "Action" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const list = useAsyncList({
|
||||||
|
async load() {
|
||||||
|
return {
|
||||||
|
items: [...zustand.getState().events],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async sort({ items, sortDescriptor }) {
|
||||||
|
return {
|
||||||
|
items: items.sort((a, b) => {
|
||||||
|
let cmp = 0;
|
||||||
|
|
||||||
|
// if it is the date-column, convert to a date
|
||||||
|
if (sortDescriptor.column === "date") {
|
||||||
|
const first = a[sortDescriptor.column];
|
||||||
|
const second = b[sortDescriptor.column];
|
||||||
|
|
||||||
|
cmp = first < second ? -1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sortDescriptor.direction === "descending") {
|
||||||
|
cmp *= -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmp;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function getKeyValue(event: EventData, key: Key): React.ReactNode {
|
||||||
|
switch (key) {
|
||||||
|
case "date":
|
||||||
|
return (
|
||||||
|
<LocalDate
|
||||||
|
options={{ dateStyle: "medium", timeStyle: "short" }}
|
||||||
|
className="font-bold"
|
||||||
|
>
|
||||||
|
{event[key].toDate()}
|
||||||
|
</LocalDate>
|
||||||
|
);
|
||||||
|
case "description":
|
||||||
|
return <span className="italic">{event[key]}</span>;
|
||||||
|
case "actions":
|
||||||
|
return (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<ButtonGroup isIconOnly variant="light" size="sm">
|
||||||
|
<Button>
|
||||||
|
<Tooltip content="Edit event">
|
||||||
|
<Edit />
|
||||||
|
</Tooltip>
|
||||||
|
</Button>
|
||||||
|
<Button>
|
||||||
|
<Tooltip content="Duplicate event">
|
||||||
|
<Copy />
|
||||||
|
</Tooltip>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="danger"
|
||||||
|
onPress={() => {
|
||||||
|
setActiveEvent(event);
|
||||||
|
setShowDeleteConfirm(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tooltip content="Delete event">
|
||||||
|
<TrashCan />
|
||||||
|
</Tooltip>
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
function availability2Tailwind(availability?: Availability) {
|
||||||
|
switch (availability) {
|
||||||
|
case "yes":
|
||||||
|
return "";
|
||||||
|
default:
|
||||||
|
return "italic";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function availability2Color(availability?: Availability) {
|
||||||
|
switch (availability) {
|
||||||
|
case "yes":
|
||||||
|
return "default";
|
||||||
|
case "maybe":
|
||||||
|
return "warning";
|
||||||
|
default:
|
||||||
|
return "danger";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
variant="underlined"
|
||||||
|
fullWidth
|
||||||
|
selectedKeys={new Set([event.tasks[key as Task] ?? ""])}
|
||||||
|
classNames={{
|
||||||
|
popoverContent: "w-fit",
|
||||||
|
value: "mr-6",
|
||||||
|
label: "mr-6",
|
||||||
|
}}
|
||||||
|
className="[&_*]:overflow-visible"
|
||||||
|
>
|
||||||
|
{Object.entries(event.volunteers).map(
|
||||||
|
([volunteer, availability]) => (
|
||||||
|
<SelectItem
|
||||||
|
key={volunteer}
|
||||||
|
color={availability2Color(availability)}
|
||||||
|
className={[
|
||||||
|
"text-" + availability2Color(availability),
|
||||||
|
availability2Tailwind(availability),
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{volunteer}
|
||||||
|
</SelectItem>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [showAddEvent, setShowAddEvent] = useState(false);
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
const [activeEvent, setActiveEvent] = useState(zustand.getState().events[0]);
|
||||||
|
|
||||||
|
const topContent = (
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
startContent={<Add size={32} />}
|
||||||
|
onPress={() => setShowAddEvent(true)}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex flex-col items-center">
|
||||||
|
<h2 className="mb-4 text-center text-4xl">Event Managment</h2>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
aria-label="Table with all the events"
|
||||||
|
shadow="none"
|
||||||
|
topContent={topContent}
|
||||||
|
topContentPlacement="outside"
|
||||||
|
isHeaderSticky
|
||||||
|
sortDescriptor={list.sortDescriptor}
|
||||||
|
onSortChange={list.sort}
|
||||||
|
classNames={{
|
||||||
|
wrapper: "bg-accent-4",
|
||||||
|
tr: "even:bg-accent-5 ",
|
||||||
|
th: "font-subheadline text-xl text-accent-1 bg-transparent ",
|
||||||
|
thead: "[&>tr]:first:!shadow-border",
|
||||||
|
}}
|
||||||
|
className="w-fit"
|
||||||
|
>
|
||||||
|
<TableHeader columns={tasks}>
|
||||||
|
{(task) => (
|
||||||
|
<TableColumn
|
||||||
|
allowsSorting={task.key === "date"}
|
||||||
|
key={task.key}
|
||||||
|
className=""
|
||||||
|
>
|
||||||
|
{task.label}
|
||||||
|
</TableColumn>
|
||||||
|
)}
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody items={list.items} emptyContent={"No events scheduled"}>
|
||||||
|
{(event) => (
|
||||||
|
<TableRow key={event.id}>
|
||||||
|
{(columnKey) => (
|
||||||
|
<TableCell>{getKeyValue(event, columnKey)}</TableCell>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
<AddEvent isOpen={showAddEvent} onOpenChange={setShowAddEvent} />
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={showDeleteConfirm}
|
||||||
|
onOpenChange={setShowDeleteConfirm}
|
||||||
|
shadow={"none" as "sm"}
|
||||||
|
backdrop="blur"
|
||||||
|
className="bg-accent-5"
|
||||||
|
>
|
||||||
|
<ModalContent>
|
||||||
|
<ModalHeader>
|
||||||
|
<h1 className="text-2xl">Confirm event deletion</h1>
|
||||||
|
</ModalHeader>
|
||||||
|
<ModalBody>
|
||||||
|
The event{" "}
|
||||||
|
<span className="font-numbers text-accent-1">
|
||||||
|
<LocalDate options={{ dateStyle: "long", timeStyle: "short" }}>
|
||||||
|
{activeEvent.date.toDate()}
|
||||||
|
</LocalDate>
|
||||||
|
</span>{" "}
|
||||||
|
will be deleted.
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button startContent={<TrashCan />} color="danger">
|
||||||
|
Delete event
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="bordered"
|
||||||
|
onPress={() => setShowDeleteConfirm(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import React, { MouseEventHandler } from "react";
|
|
||||||
|
|
||||||
export default function Button(props: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
className?: string;
|
|
||||||
onClick?: MouseEventHandler<HTMLDivElement>;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
onClick={props.onClick}
|
|
||||||
className={`${props.className ?? ""} inline-block cursor-pointer rounded-full bg-accent-2 p-2`}
|
|
||||||
>
|
|
||||||
{props.children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { CheckboxChecked, Checkbox } from "@carbon/icons-react";
|
|
||||||
import { MouseEventHandler } from "react";
|
|
||||||
|
|
||||||
export default function CheckBox(props: {
|
|
||||||
state: boolean;
|
|
||||||
onClick?: MouseEventHandler<HTMLDivElement>;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div onClick={props.onClick} className="inline-block cursor-pointer">
|
|
||||||
{props.state ? <CheckboxChecked /> : <Checkbox />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import CheckBox from "../CheckBox";
|
|
||||||
import { AddLarge } from "@carbon/icons-react";
|
|
||||||
import Button from "../Button";
|
|
||||||
import zustand, { EventData, ISODate, Task, Tasks } from "../Zustand";
|
|
||||||
|
|
||||||
interface state {
|
|
||||||
date: string;
|
|
||||||
description: string;
|
|
||||||
tasks: Task[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AddEvent(props: {
|
|
||||||
className?: string;
|
|
||||||
onClose: () => void;
|
|
||||||
}) {
|
|
||||||
const [state, setState] = useState<state>({
|
|
||||||
date: ISODate(new Date()),
|
|
||||||
description: "",
|
|
||||||
tasks: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
function toggleTask(task: Task) {
|
|
||||||
const new_tasks = state.tasks.slice();
|
|
||||||
|
|
||||||
const index = new_tasks.indexOf(task);
|
|
||||||
|
|
||||||
if (index != -1) {
|
|
||||||
new_tasks.splice(index, 1);
|
|
||||||
} else {
|
|
||||||
new_tasks.push(task);
|
|
||||||
}
|
|
||||||
|
|
||||||
setState({
|
|
||||||
...state,
|
|
||||||
tasks: new_tasks,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function addEvent() {
|
|
||||||
const eventData: EventData = {
|
|
||||||
date: state.date,
|
|
||||||
description: state.description,
|
|
||||||
id: zustand.getState().events.slice(-1)[0].id + 1,
|
|
||||||
tasks: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
// add all the tasks
|
|
||||||
state.tasks.forEach((task) => {
|
|
||||||
eventData.tasks[task] = undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
zustand.getState().addEvent(eventData);
|
|
||||||
|
|
||||||
props.onClose();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`${props.className ?? ""} flex w-64 flex-col gap-2 rounded-xl bg-accent-5 p-4`}
|
|
||||||
>
|
|
||||||
<h1 className="text-2xl">Add Event</h1>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={state.date}
|
|
||||||
onChange={(e) => console.log(e.target.value)}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Description"
|
|
||||||
value={state.description}
|
|
||||||
onChange={(e) => setState({ ...state, description: e.target.value })}
|
|
||||||
/>
|
|
||||||
{Tasks.map((task, ii) => (
|
|
||||||
<div
|
|
||||||
key={ii}
|
|
||||||
onClick={() => toggleTask(task)}
|
|
||||||
className="flex cursor-default items-center gap-2"
|
|
||||||
>
|
|
||||||
<CheckBox state={state.tasks.includes(task)} />
|
|
||||||
{task}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<Button
|
|
||||||
className="ml-auto flex w-fit items-center justify-center gap-2 pr-4"
|
|
||||||
onClick={addEvent}
|
|
||||||
>
|
|
||||||
<AddLarge size={32} />
|
|
||||||
Add
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import { EventData } from "../Zustand";
|
|
||||||
|
|
||||||
export default function Event(props: EventData) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={props.id}
|
|
||||||
className="flex w-64 flex-col gap-2 rounded-xl bg-accent-5 p-4"
|
|
||||||
>
|
|
||||||
<h3 className="bold mb-1 text-2xl">{props.date}</h3>
|
|
||||||
<div>{props.description}</div>
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<caption>
|
|
||||||
<h4>Task assignment</h4>
|
|
||||||
</caption>
|
|
||||||
<tbody>
|
|
||||||
{Object.entries(props.tasks).map(([task, person], ii) => (
|
|
||||||
<tr key={ii}>
|
|
||||||
<th className="pr-4 text-left">{task}</th>
|
|
||||||
<td>{person ?? <span className="text-primary">missing</span>}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { AddLarge, CloseLarge } from "@carbon/icons-react";
|
|
||||||
import Event from "./Event/Event";
|
|
||||||
import { useState } from "react";
|
|
||||||
import AddEvent from "./Event/AddEvent";
|
|
||||||
import Button from "./Button";
|
|
||||||
import zustand from "./Zustand";
|
|
||||||
|
|
||||||
export default function EventVolunteer() {
|
|
||||||
const [showAddItemDialogue, setShowAddItemDialogue] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative flex-1 p-4">
|
|
||||||
<h2 className="mb-4 text-center text-4xl">Overview</h2>
|
|
||||||
<div className="flex flex-wrap justify-center gap-4">
|
|
||||||
{zustand.getState().events.map((ee) => Event(ee))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
className="absolute bottom-0 right-0 aspect-square"
|
|
||||||
onClick={() => setShowAddItemDialogue(true)}
|
|
||||||
>
|
|
||||||
<AddLarge size={32} />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{showAddItemDialogue ? (
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 flex flex-col items-center backdrop-blur"
|
|
||||||
onClick={(e) => {
|
|
||||||
if (e.target === e.currentTarget) {
|
|
||||||
setShowAddItemDialogue(false);
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="relative">
|
|
||||||
<AddEvent
|
|
||||||
className="border-2 border-accent-3"
|
|
||||||
onClose={() => setShowAddItemDialogue(false)}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
className="absolute right-2 top-2 aspect-square"
|
|
||||||
onClick={() => setShowAddItemDialogue(false)}
|
|
||||||
>
|
|
||||||
<CloseLarge />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import { create } from "zustand";
|
|
||||||
|
|
||||||
export enum Task {
|
|
||||||
Audio = "Audio",
|
|
||||||
Livestream = "Livestream",
|
|
||||||
Camera = "Camera",
|
|
||||||
Light = "Light",
|
|
||||||
StreamAudio = "Stream Audio",
|
|
||||||
}
|
|
||||||
|
|
||||||
export type TaskKey = keyof typeof Task;
|
|
||||||
export const Tasks = Object.values(Task) as Task[];
|
|
||||||
|
|
||||||
export interface EventData {
|
|
||||||
id: number;
|
|
||||||
date: string;
|
|
||||||
tasks: Partial<Record<Task, string | undefined>>;
|
|
||||||
description: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Zustand {
|
|
||||||
events: EventData[];
|
|
||||||
addEvent: (event: EventData) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const zustand = create<Zustand>()((set) => ({
|
|
||||||
events: [
|
|
||||||
{
|
|
||||||
id: 0,
|
|
||||||
date: "2025-01-05",
|
|
||||||
tasks: {
|
|
||||||
Audio: "Mark",
|
|
||||||
Livestream: undefined,
|
|
||||||
"Stream Audio": undefined,
|
|
||||||
},
|
|
||||||
description: "neuer Prädikant",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
date: "2025-01-12",
|
|
||||||
tasks: {
|
|
||||||
Audio: "Mark",
|
|
||||||
Livestream: undefined,
|
|
||||||
},
|
|
||||||
description: "",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
addEvent: (event: EventData) =>
|
|
||||||
set((state) => ({ events: state.events.toSpliced(-1, 0, event) })),
|
|
||||||
}));
|
|
||||||
|
|
||||||
export function ISODate(dt: string | Date): string {
|
|
||||||
if (typeof dt === "string") {
|
|
||||||
dt = new Date(dt);
|
|
||||||
}
|
|
||||||
|
|
||||||
const year = String(dt.getFullYear()).padStart(4, "0");
|
|
||||||
const month = String(dt.getMonth() + 1).padStart(2, "0");
|
|
||||||
const day = String(dt.getDate()).padStart(2, "0");
|
|
||||||
|
|
||||||
const date = `${year}-${month}-${day}`;
|
|
||||||
|
|
||||||
console.debug(date);
|
|
||||||
|
|
||||||
return date;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default zustand;
|
|
||||||
@@ -31,17 +31,6 @@
|
|||||||
src: URL("/fonts/uncut-sans/Webfonts/UncutSans-Regular.woff2") format("woff2");
|
src: URL("/fonts/uncut-sans/Webfonts/UncutSans-Regular.woff2") format("woff2");
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
|
||||||
--primary: #ff5053;
|
|
||||||
--highlight: #fef2ff;
|
|
||||||
--accent-1: #b2aaff;
|
|
||||||
--accent-2: #6a5fdb;
|
|
||||||
--accent-3: #261a66;
|
|
||||||
--accent-4: #29114c;
|
|
||||||
--accent-5: #190b2f;
|
|
||||||
--background: #0f000a;
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
h1,
|
h1,
|
||||||
h2,
|
h2,
|
||||||
@@ -49,14 +38,10 @@
|
|||||||
h4,
|
h4,
|
||||||
h5,
|
h5,
|
||||||
h6 {
|
h6 {
|
||||||
@apply font-headline text-primary;
|
@apply font-headline text-highlight;
|
||||||
}
|
}
|
||||||
|
|
||||||
input {
|
input {
|
||||||
@apply border-2 border-accent-1 bg-transparent;
|
@apply border-2 border-accent-1 bg-transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
|
||||||
@apply bg-background font-body text-highlight;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,31 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
import { NextUIProvider } from "@nextui-org/system";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "Create Next App",
|
||||||
description: "Generated by create next app"
|
description: "Generated by create next app",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html>
|
||||||
<body className="antialiased">{children}</body>
|
<body className="bg-background text-foreground antialiased">
|
||||||
|
<NextUIProvider>
|
||||||
|
<div className="flex min-h-screen flex-col p-4">
|
||||||
|
<header>
|
||||||
|
<h1 className="text-center font-display-headline text-8xl">
|
||||||
|
Volunteer Scheduler
|
||||||
|
</h1>
|
||||||
|
</header>
|
||||||
|
<main className="flex min-h-full flex-1 flex-col">{children}</main>
|
||||||
|
</div>
|
||||||
|
</NextUIProvider>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
3
client/src/app/me/page.tsx
Normal file
3
client/src/app/me/page.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default function OverviewPersonal() {
|
||||||
|
return <div>foobar</div>;
|
||||||
|
}
|
||||||
@@ -1,16 +1,5 @@
|
|||||||
import EventVolunteer from "./components/Overview";
|
import EventVolunteer from "./Overview";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return <EventVolunteer />;
|
||||||
<div className="p-4 min-h-screen flex flex-col">
|
|
||||||
<header>
|
|
||||||
<h1 className="text-center text-8xl font-display-headline">
|
|
||||||
Volunteer schedluer
|
|
||||||
</h1>
|
|
||||||
</header>
|
|
||||||
<main className="min-h-full flex-1 flex">
|
|
||||||
<EventVolunteer />
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
106
client/src/components/Event/AddEvent.tsx
Normal file
106
client/src/components/Event/AddEvent.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Add } from "@carbon/icons-react";
|
||||||
|
import zustand, { EventData, Task, Tasks } from "../../Zustand";
|
||||||
|
import { Button } from "@nextui-org/button";
|
||||||
|
import { Checkbox, CheckboxGroup } from "@nextui-org/checkbox";
|
||||||
|
import { DatePicker } from "@nextui-org/date-picker";
|
||||||
|
import { getLocalTimeZone, now, ZonedDateTime } from "@internationalized/date";
|
||||||
|
import { Textarea } from "@nextui-org/input";
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
ModalBody,
|
||||||
|
ModalContent,
|
||||||
|
ModalFooter,
|
||||||
|
ModalHeader,
|
||||||
|
} from "@nextui-org/modal";
|
||||||
|
|
||||||
|
interface state {
|
||||||
|
date: ZonedDateTime;
|
||||||
|
description: string;
|
||||||
|
tasks: Task[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AddEvent(props: {
|
||||||
|
className?: string;
|
||||||
|
isOpen: boolean;
|
||||||
|
onOpenChange: (isOpen: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const [state, setState] = useState<state>({
|
||||||
|
date: now(getLocalTimeZone()),
|
||||||
|
description: "",
|
||||||
|
tasks: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
function addEvent() {
|
||||||
|
const eventData: EventData = {
|
||||||
|
date: state.date.toString(),
|
||||||
|
description: state.description,
|
||||||
|
id: zustand.getState().events.slice(-1)[0].id + 1,
|
||||||
|
tasks: {},
|
||||||
|
volunteers: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
// add all the tasks
|
||||||
|
state.tasks.forEach((task) => {
|
||||||
|
eventData.tasks[task] = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
zustand.getState().addEvent(eventData);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={props.isOpen}
|
||||||
|
shadow={"none" as "sm"} // somehow "none" isn't allowed
|
||||||
|
onOpenChange={props.onOpenChange}
|
||||||
|
backdrop="blur"
|
||||||
|
classNames={{
|
||||||
|
base: "bg-accent-5 ",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ModalContent>
|
||||||
|
<ModalHeader>
|
||||||
|
<h1 className="text-2xl">Add Event</h1>
|
||||||
|
</ModalHeader>
|
||||||
|
<ModalBody>
|
||||||
|
<DatePicker
|
||||||
|
label="Event date"
|
||||||
|
variant="bordered"
|
||||||
|
hideTimeZone
|
||||||
|
granularity="minute"
|
||||||
|
value={state.date}
|
||||||
|
onChange={(dt) => (!!dt ? setState({ ...state, date: dt }) : null)}
|
||||||
|
/>
|
||||||
|
<Textarea
|
||||||
|
variant="bordered"
|
||||||
|
placeholder="Description"
|
||||||
|
value={state.description}
|
||||||
|
onValueChange={(desc) => setState({ ...state, description: desc })}
|
||||||
|
/>
|
||||||
|
<CheckboxGroup
|
||||||
|
value={state.tasks}
|
||||||
|
onValueChange={(newTasks) =>
|
||||||
|
setState({ ...state, tasks: newTasks })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{Tasks.map((task, ii) => (
|
||||||
|
<div key={ii}>
|
||||||
|
<Checkbox value={task}>{task}</Checkbox>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CheckboxGroup>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
radius="full"
|
||||||
|
startContent={<Add size={32} />}
|
||||||
|
onPress={addEvent}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
client/src/components/Event/Event.tsx
Normal file
50
client/src/components/Event/Event.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { Card, CardBody, CardHeader } from "@nextui-org/card";
|
||||||
|
import { Divider } from "@nextui-org/divider";
|
||||||
|
import LocalDate from "./LocalDate";
|
||||||
|
import { EventData } from "@/Zustand";
|
||||||
|
|
||||||
|
export default function Event(props: EventData) {
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
classNames={{
|
||||||
|
base: "bg-accent-4 w-64",
|
||||||
|
body: "flex flex-col gap-4",
|
||||||
|
}}
|
||||||
|
shadow="none"
|
||||||
|
>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="bold mb-1 text-2xl">
|
||||||
|
<LocalDate
|
||||||
|
options={{
|
||||||
|
dateStyle: "short",
|
||||||
|
timeStyle: "short",
|
||||||
|
timeZone: "Europe/Berlin", // TODO: check with actual backend
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.date.toDate()}
|
||||||
|
</LocalDate>
|
||||||
|
</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<Divider />
|
||||||
|
<CardBody>
|
||||||
|
<div>{props.description}</div>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<caption>
|
||||||
|
<h4>Task assignment</h4>
|
||||||
|
</caption>
|
||||||
|
<tbody>
|
||||||
|
{Object.entries(props.tasks).map(([task, person], ii) => (
|
||||||
|
<tr key={ii}>
|
||||||
|
<th className="pr-4 text-left">{task}</th>
|
||||||
|
<td>
|
||||||
|
{person ?? <span className="text-highlight">missing</span>}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
client/src/components/Event/LocalDate.tsx
Normal file
16
client/src/components/Event/LocalDate.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
"use local";
|
||||||
|
|
||||||
|
import { DateFormatter } from "@/Zustand";
|
||||||
|
import { useLocale } from "@react-aria/i18n";
|
||||||
|
|
||||||
|
export default function LocalDate(props: {
|
||||||
|
children: Date;
|
||||||
|
className?: string;
|
||||||
|
options: Intl.DateTimeFormatOptions;
|
||||||
|
}) {
|
||||||
|
const formatter = new DateFormatter(useLocale().locale, props.options);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={props.className}>{formatter.format(props.children)}</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,31 +1,115 @@
|
|||||||
import type { Config } from "tailwindcss";
|
import type { Config } from "tailwindcss";
|
||||||
|
import { nextui } from "@nextui-org/theme";
|
||||||
|
|
||||||
|
const HIGHLIGHT = "#ff5053";
|
||||||
|
const FOREGROUND = "#fef2ff";
|
||||||
|
const ACCENT1 = "#b2aaff";
|
||||||
|
const ACCENT2 = "#6a5fdb";
|
||||||
|
const ACCENT3 = "#261a66";
|
||||||
|
const ACCENT4 = "#29114c";
|
||||||
|
const ACCENT5 = "#190b2f";
|
||||||
|
const BACKGROUND = "#0f000a";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
content: [
|
content: [
|
||||||
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
|
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
|
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
"./src/app/**/*.{js,ts,jsx,tsx,mdx}"
|
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
"./node_modules/@nextui-org/theme/dist/**/*.{js,ts,jsx,tsx}",
|
||||||
],
|
],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
background: "var(--background)",
|
highlight: HIGHLIGHT,
|
||||||
primary: "var(--primary)",
|
foreground: {
|
||||||
highlight: "var(--highlight)",
|
DEFAULT: FOREGROUND,
|
||||||
"accent-1": "var(--accent-1)",
|
"50": FOREGROUND,
|
||||||
"accent-2": "var(--accent-2)",
|
"100": "#fce8ff",
|
||||||
"accent-3": "var(--accent-3)",
|
"200": "#fad0fe",
|
||||||
"accent-4": "var(--accent-4)",
|
"300": "#f8abfc",
|
||||||
"accent-5": "var(--accent-5)"
|
"400": "#f579f9",
|
||||||
}
|
"500": "#eb46ef",
|
||||||
|
"600": "#d226d3",
|
||||||
|
"700": "#af1cad",
|
||||||
|
"800": "#8f198c",
|
||||||
|
"900": "#751a70",
|
||||||
|
"950": "#4e044a",
|
||||||
|
},
|
||||||
|
"accent-1": ACCENT1,
|
||||||
|
"accent-2": ACCENT2,
|
||||||
|
"accent-3": ACCENT3,
|
||||||
|
"accent-4": ACCENT4,
|
||||||
|
"accent-5": ACCENT5,
|
||||||
|
background: BACKGROUND,
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
border: `inset 0 0 0 2px ${ACCENT2}`,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
"display-headline": ["pilowlava"],
|
"display-headline": ["pilowlava"],
|
||||||
headline: ["spacegrotesk"],
|
headline: ["spacegrotesk"],
|
||||||
subheadline: ["uncut-sans"],
|
subheadline: ["uncut-sans"],
|
||||||
body: ["uncut-sans"],
|
body: ["uncut-sans"],
|
||||||
numbers: ["space-mono"]
|
numbers: ["space-mono"],
|
||||||
}
|
|
||||||
},
|
},
|
||||||
plugins: []
|
},
|
||||||
|
darkMode: "class",
|
||||||
|
plugins: [
|
||||||
|
nextui({
|
||||||
|
defaultTheme: "dark",
|
||||||
|
defaultExtendTheme: "dark",
|
||||||
|
themes: {
|
||||||
|
dark: {
|
||||||
|
colors: {
|
||||||
|
// default: {
|
||||||
|
// DEFAULT: ACCENT2,
|
||||||
|
// },
|
||||||
|
primary: {
|
||||||
|
DEFAULT: ACCENT2,
|
||||||
|
"50": "#39357a",
|
||||||
|
"100": "#42399a",
|
||||||
|
"200": "#5144be",
|
||||||
|
"300": ACCENT2,
|
||||||
|
"400": "#6f6ee6",
|
||||||
|
"500": "#8b91ee",
|
||||||
|
"600": "#acb7f5",
|
||||||
|
"700": "#cbd3fa",
|
||||||
|
"800": "#e2e7fd",
|
||||||
|
"900": "#eff3fe",
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: ACCENT3,
|
||||||
|
"50": "#3b288a",
|
||||||
|
"100": "#462fa8",
|
||||||
|
"200": "#5538c9",
|
||||||
|
"300": "#634add",
|
||||||
|
"400": "#776ae8",
|
||||||
|
"500": "#9a95f0",
|
||||||
|
"600": "#bdbcf6",
|
||||||
|
"700": "#dadbfa",
|
||||||
|
"800": "#ebebfc",
|
||||||
|
"900": "#f4f4fe",
|
||||||
|
},
|
||||||
|
// background: {
|
||||||
|
// DEFAULT: BACKGROUND,
|
||||||
|
// },
|
||||||
|
danger: {
|
||||||
|
DEFAULT: HIGHLIGHT,
|
||||||
|
"50": "#fff1f1",
|
||||||
|
"100": "#ffe1e2",
|
||||||
|
"200": "#ffc7c8",
|
||||||
|
"300": "#ffa0a2",
|
||||||
|
"400": HIGHLIGHT,
|
||||||
|
"500": "#f83b3e",
|
||||||
|
"600": "#e51d20",
|
||||||
|
"700": "#c11417",
|
||||||
|
"800": "#a01416",
|
||||||
|
"900": "#84181a",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
} satisfies Config;
|
} satisfies Config;
|
||||||
|
|||||||
Reference in New Issue
Block a user