started "real work"

This commit is contained in:
z1glr
2025-01-10 14:06:53 +00:00
parent e2aa65b416
commit 45f600268f
30 changed files with 9811 additions and 8584 deletions

View File

@@ -1,5 +1,8 @@
"use client";
import { DateFormatter as IntlDateFormatter } from "@internationalized/date";
import { create } from "zustand";
import { persist } from "zustand/middleware";
export type Task = string;
@@ -19,43 +22,48 @@ export interface EventData {
id: number;
date: string;
tasks: Partial<Record<Task, string | undefined>>;
volunteers: Partial<Record<string, Availability>>;
description: string;
}
interface Zustand {
events: EventData[];
addEvent: (event: EventData) => void;
pendingEvents: number;
user: {
userName: string;
admin: boolean;
} | null;
setEvents: (events: EventData[]) => void;
reset: (zustand?: Partial<Zustand>) => void;
setPendingEvents: (c: number) => void;
}
const zustand = create<Zustand>()((set) => ({
events: [
const initialState = {
events: [],
user: null,
pendingEvents: 0,
};
const zustand = create<Zustand>()(
persist(
(set) => ({
...initialState,
setEvents: (events) => set({ events }),
reset: (newZustand) =>
set({
...initialState,
...newZustand,
}),
setPendingEvents: (c) => set(() => ({ pendingEvents: c })),
}),
{
id: 0,
// date: parseDateTime("2025-01-05T11:00[Europe/Berlin]").toString(),
date: "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",
name: "golunteer-storage",
partialize: (state) =>
Object.fromEntries(
Object.entries(state).filter(([key]) => !["events"].includes(key)),
),
},
{
id: 1,
date: "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;

View File

@@ -1,15 +1,11 @@
"use client";
import { Divider } from "@nextui-org/divider";
import { Link } from "@nextui-org/link";
import { usePathname } from "next/navigation";
import React from "react";
import { SiteLink } from "./layout";
import { Divider, Link } from "@nextui-org/react";
export default function Footer({
sites,
}: {
sites: { href: string; text: string }[];
}) {
export default function Footer({ sites }: { sites: SiteLink[] }) {
const pathname = usePathname();
return (

View File

@@ -1,11 +1,124 @@
import { Link } from "@nextui-org/link";
"use client";
import { apiCall } from "@/lib";
import { usePathname, useRouter } from "next/navigation";
import { User } from "@carbon/icons-react";
import {
Avatar,
Badge,
Dropdown,
DropdownItem,
DropdownMenu,
DropdownTrigger,
Link,
Navbar,
NavbarBrand,
NavbarContent,
NavbarItem,
} from "@nextui-org/react";
import zustand from "@/Zustand";
import { SiteLink } from "./layout";
import { useEffect } from "react";
export default function Header({ sites }: { sites: SiteLink[] }) {
const router = useRouter();
const user = zustand((state) => state.user);
const pendingEvents = zustand((state) => state.pendingEvents);
const pathname = usePathname();
async function logout() {
const result = await apiCall("GET", "logout");
// if the request was successfull, redirect to the login-page
if (result.ok) {
// clear the zustand
zustand.getState().reset();
router.push("/login");
}
}
// get the pending events for the counter
useEffect(() => {
(async () => {
const result = await apiCall<{ pendingEvents: number }>(
"GET",
"events/user/pending",
);
if (result.ok) {
const resultJson = await result.json();
zustand.getState().setPendingEvents(resultJson);
}
})();
}, []);
export default function Header() {
return (
<div className="flex justify-center">
<Link href="/" className="text-center text-8xl">
<h1 className="font-display-headline">Volunteer Scheduler</h1>
</Link>
<div>
<Navbar maxWidth="full">
<NavbarBrand onClick={() => router.push("/")}>
<h1 className="font-display-headline text-xl">Golunteer</h1>
</NavbarBrand>
{user !== null ? (
<>
<NavbarContent justify="center">
{sites.map((s) =>
// if the site is no admin-site or the user is an admin, render it
!s.admin || user.admin ? (
<NavbarItem key={s.href} isActive={pathname === s.href}>
<Link
href={s.href}
color={pathname === s.href ? "primary" : "foreground"}
>
{s.text}
</Link>
</NavbarItem>
) : null,
)}
</NavbarContent>
<NavbarContent justify="end">
<Dropdown placement="bottom-end">
<Badge
content={pendingEvents}
color="danger"
aria-label={`${pendingEvents} notifications`}
>
<DropdownTrigger>
<Avatar isBordered as="button" icon={<User size={32} />} />
</DropdownTrigger>
</Badge>
<DropdownMenu variant="flat">
<DropdownItem
key="profile"
className="h-14 gap-2"
textValue={`signed in as ${user.userName}`}
>
<p>Signed in as</p>
<p className="text-primary">{user.userName}</p>
</DropdownItem>
<DropdownItem
key="account"
onPress={() => router.push("/account")}
>
Account
</DropdownItem>
<DropdownItem
key="logout"
color="danger"
onPress={logout}
className="text-danger"
>
Log Out
</DropdownItem>
</DropdownMenu>
</Dropdown>
</NavbarContent>
</>
) : null}
</Navbar>
</div>
);
}

View File

@@ -1,49 +1,68 @@
"use client";
import { apiCall } from "@/lib";
import { Spinner } from "@nextui-org/spinner";
import zustand from "@/Zustand";
import { Spinner } from "@nextui-org/react";
import { usePathname, useRouter } from "next/navigation";
import React, { useEffect, useState } from "react";
enum AuthState {
Loading,
LoggedIn,
LoginScreen,
Unauthorized,
LoggedIn,
Loading,
}
export default function Main({ children }: { children: React.ReactNode }) {
const [status, setStatus] = useState(AuthState.Loading);
const [auth, setAuth] = useState(AuthState.Loading);
const router = useRouter();
const pathname = usePathname();
useEffect(() => {
void (async () => {
if (pathname === "/login") {
setStatus(AuthState.LoginScreen);
} else {
let loggedIn = false;
if (zustand.getState().user === null) {
const welcomeResult = await apiCall<{
userName: string;
loggedIn: boolean;
}>("GET", "welcome");
if (!welcomeResult.ok) {
router.push("/login");
} else {
const response = await welcomeResult.json();
if (welcomeResult.ok) {
try {
const response = await welcomeResult.json();
if (response.loggedIn) {
setStatus(AuthState.LoggedIn);
} else {
setStatus(AuthState.Unauthorized);
}
if (response.userName !== undefined && response.userName !== "") {
zustand.getState().reset({ user: response });
loggedIn = true;
}
} catch {}
} else {
}
} else {
loggedIn = true;
}
if (pathname === "/login") {
if (loggedIn) {
router.push("/");
} else {
setAuth(AuthState.LoginScreen);
}
} else {
if (loggedIn) {
setAuth(AuthState.LoggedIn);
} else {
setAuth(AuthState.Unauthorized);
router.push("/login");
}
}
})();
});
}, [pathname, router]);
switch (status) {
switch (auth) {
case AuthState.Loading:
return <Spinner label="Loading..." />;
case AuthState.LoggedIn:

View File

@@ -5,10 +5,10 @@ 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";
import AssignmentTable from "@/components/Event/AssignmentTable";
import { useAsyncList } from "@react-stately/data";
import { apiCall } from "@/lib";
import { Button } from "@nextui-org/react";
export default function EventVolunteer() {
const [showAddItemDialogue, setShowAddItemDialogue] = useState(false);
@@ -18,8 +18,6 @@ export default function EventVolunteer() {
load: async () => {
const data = await apiCall("GET", "events");
console.debug(await data.json());
return {
items: [],
};
@@ -27,7 +25,7 @@ export default function EventVolunteer() {
});
return (
<div className="relative flex-1 p-4">
<div className="relative flex-1">
<h2 className="mb-4 text-center text-4xl">Overview</h2>
<div className="flex flex-wrap justify-center gap-4">
{zustand.getState().events.map((ee) => (

View File

@@ -4,24 +4,24 @@ import AddEvent from "@/components/Event/AddEvent";
import LocalDate from "@/components/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 {
Button,
ButtonGroup,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
} from "@nextui-org/modal";
import { Select, SelectItem } from "@nextui-org/select";
import {
Select,
SelectItem,
Table,
TableBody,
TableCell,
TableColumn,
TableHeader,
TableRow,
} from "@nextui-org/table";
import { Tooltip } from "@nextui-org/tooltip";
Tooltip,
} from "@nextui-org/react";
import { useAsyncList } from "@react-stately/data";
import React, { Key, useState } from "react";

View File

@@ -0,0 +1,64 @@
"use client";
import AddEvent from "@/components/Event/AddEvent";
import AssignmentTable from "@/components/Event/AssignmentTable";
import Event from "@/components/Event/Event";
import { apiCall } from "@/lib";
import zustand, { EventData } from "@/Zustand";
import { Add } from "@carbon/icons-react";
import { Button } from "@nextui-org/react";
import { useEffect, useState } from "react";
export default function Events() {
const [showAddItemDialogue, setShowAddItemDialogue] = useState(false);
const events = zustand((state) => state.events);
const admin = zustand((state) => state.user?.admin);
useEffect(() => {
(async () => {
console.debug("query");
const data = await apiCall<EventData[]>("GET", "events/assignments");
if (data.ok) {
zustand.getState().setEvents(await data.json());
}
return {
items: [],
};
})();
}, []);
return (
<div className="relative flex-1">
<h2 className="mb-4 text-center text-4xl">Upcoming Events</h2>
<div className="flex flex-wrap justify-center gap-4">
{events.map((ee, ii) => (
<Event key={ii} event={ee}>
<AssignmentTable tasks={ee.tasks} />
</Event>
))}
</div>
{admin ? (
<>
<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}
/>
</>
) : null}
</div>
);
}

View File

@@ -11,12 +11,48 @@ export const metadata: Metadata = {
description: "Generated by create next app",
};
export interface SiteLink {
text: string;
href: string;
admin?: boolean;
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const footerSites = [
const headerSites: SiteLink[] = [
{
text: "Overview",
href: "/",
},
{
text: "Events",
href: "/events",
},
{
text: "Assignments",
href: "/assignments",
},
{
text: "Assign Tasks",
href: "/admin/assign",
admin: true,
},
{
text: "Users",
href: "/admin/users",
admin: true,
},
{
text: "Configuration",
href: "/admin/config",
admin: true,
},
];
const footerSites: SiteLink[] = [
{
text: "Impressum",
href: "/impressum",
@@ -33,9 +69,9 @@ export default function RootLayout({
<NextUIProvider>
<div className="flex min-h-screen flex-col p-4">
<header>
<Header />
<Header sites={headerSites} />
</header>
<main className="flex min-h-full flex-1 flex-col">
<main className="flex min-h-full flex-1 flex-col p-4">
<Main>{children}</Main>
</main>
<footer className="flex h-4 justify-center gap-4">

View File

@@ -1,22 +1,49 @@
"use client";
import { ViewFilled, ViewOffFilled } from "@carbon/icons-react";
import { Button } from "@nextui-org/button";
import { Form } from "@nextui-org/form";
import { Input } from "@nextui-org/input";
import { Switch } from "@nextui-org/switch";
import { useState } from "react";
import CheckboxIcon from "@/components/CheckboxIcon";
import { apiCall } from "@/lib";
import zustand from "@/Zustand";
import {
ViewFilled,
ViewOffFilled,
WarningHexFilled,
} from "@carbon/icons-react";
import { Alert, Button, Form, Input } from "@nextui-org/react";
import { useRouter } from "next/navigation";
import { FormEvent, useState } from "react";
export default function Login() {
const [visibility, setVisibility] = useState(false);
const [wrongPassword, setWrongPassword] = useState(false);
const router = useRouter();
// set login-request
async function sendLogin(e: FormEvent<HTMLFormElement>) {
const data = Object.fromEntries(new FormData(e.currentTarget));
const result = await apiCall("POST", "login", undefined, data);
if (result.ok) {
// add the user-info to the zustand
zustand.getState().reset({ user: await result.json() });
// redirect to the home-page
router.push("/");
} else {
setWrongPassword(true);
}
}
return (
<div>
<h2 className="mb-4 text-center text-4xl">Login</h2>
<Form
validationBehavior="native"
className="flex flex-col items-center gap-2"
onSubmit={(e) => e.preventDefault()}
className="mx-auto flex max-w-sm flex-col items-center gap-2"
onSubmit={(e) => {
e.preventDefault();
void sendLogin(e);
}}
>
<Input
isRequired
@@ -24,7 +51,6 @@ export default function Login() {
label="Name"
name="username"
variant="bordered"
className="max-w-xs"
/>
<Input
isRequired
@@ -32,7 +58,7 @@ export default function Login() {
name="password"
autoComplete="current-password"
endContent={
<Switch
<CheckboxIcon
className="my-auto"
startContent={<ViewFilled />}
endContent={<ViewOffFilled />}
@@ -42,9 +68,18 @@ export default function Login() {
}
type={visibility ? "text" : "password"}
variant="bordered"
className="max-w-xs"
/>
<Button className="w-full max-w-xs" color="primary" type="submit">
<Alert
title="Login failed"
description="Wrong username or password"
color="danger"
icon={<WarningHexFilled size={32} />}
hideIconWrapper
isClosable
isVisible={wrongPassword}
onVisibleChange={(v) => setWrongPassword(v)}
/>
<Button className="w-full" color="primary" type="submit">
Login
</Button>
</Form>

View File

@@ -1,42 +0,0 @@
import Event from "@/components/Event/Event";
import zustand, { Availabilities, Availability } from "@/Zustand";
import { Radio, RadioGroup } from "@nextui-org/radio";
function availability2Color(availability: Availability) {
switch (availability) {
case "yes":
return "success";
case "maybe":
return "warning";
default:
return "danger";
}
}
export default function OverviewPersonal() {
return (
<div>
<h2 className="mb-4 text-center text-4xl">Upcoming Events</h2>
<div className="flex flex-wrap justify-center gap-4">
{zustand.getState().events.map((ev) => (
<Event key={ev.id} event={ev}>
<RadioGroup className="mt-auto" orientation="horizontal">
{Availabilities.map((availability) => (
<Radio
value={availability}
key={availability}
color={availability2Color(availability)}
classNames={{
label: `text-${availability2Color(availability)}`,
}}
>
{availability}
</Radio>
))}
</RadioGroup>
</Event>
))}
</div>
</div>
);
}

View File

@@ -1,6 +1,5 @@
import OverviewPersonal from "./me/page";
import EventVolunteer from "./Overview";
export default function Home() {
// return <EventVolunteer />;
return <OverviewPersonal />;
return <EventVolunteer />;
}

View File

@@ -0,0 +1,33 @@
import { SwitchProps, useSwitch, VisuallyHidden } from "@nextui-org/react";
import React from "react";
export default function CheckboxIcon(props: SwitchProps) {
const {
Component,
slots,
isSelected,
getBaseProps,
getInputProps,
getWrapperProps,
} = useSwitch(props);
return (
<Component {...getBaseProps()}>
<VisuallyHidden>
<input {...getInputProps()} />
</VisuallyHidden>
<div
{...getWrapperProps()}
className={slots.wrapper({
class: [
"h-8 w-8",
"flex items-center justify-center",
"rounded-lg bg-default-100 hover:bg-default-200",
],
})}
>
{isSelected ? props.startContent : props.endContent}
</div>
</Component>
);
}

View File

@@ -1,18 +1,19 @@
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 {
Button,
Checkbox,
CheckboxGroup,
DatePicker,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
} from "@nextui-org/modal";
Textarea,
} from "@nextui-org/react";
interface state {
date: ZonedDateTime;

View File

@@ -1,9 +1,8 @@
"use client";
import { Card, CardBody, CardHeader } from "@nextui-org/card";
import { Divider } from "@nextui-org/divider";
import LocalDate from "../LocalDate";
import { EventData } from "@/Zustand";
import { Card, CardBody, CardHeader, Divider } from "@nextui-org/react";
import React from "react";
export default function Event({
@@ -27,7 +26,6 @@ export default function Event({
options={{
dateStyle: "short",
timeStyle: "short",
timeZone: "Europe/Berlin", // TODO: check with actual backend
}}
>
{event.date}

View File

@@ -1,7 +1,7 @@
"use local";
import { DateFormatter } from "@/Zustand";
import { parseZonedDateTime } from "@internationalized/date";
import { getLocalTimeZone, parseDateTime } from "@internationalized/date";
import { useLocale } from "@react-aria/i18n";
export default function LocalDate(props: {
@@ -13,7 +13,9 @@ export default function LocalDate(props: {
return (
<span className={props.className}>
{formatter.format(parseZonedDateTime(props.children).toDate())}
{formatter.format(
parseDateTime(props.children).toDate(getLocalTimeZone()),
)}
</span>
);
}