From a775e860e5701f418e7e5b5d65d72dc43a5bccdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matth=C3=A4us=20Mayer?= <7984982+theMattCode@users.noreply.github.com> Date: Fri, 3 Jan 2025 09:32:44 +0100 Subject: [PATCH 01/16] Fix missing key in fragment #226 --- components/cms/navigation/Navigation.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/components/cms/navigation/Navigation.tsx b/components/cms/navigation/Navigation.tsx index 7e330f1..4e447c2 100644 --- a/components/cms/navigation/Navigation.tsx +++ b/components/cms/navigation/Navigation.tsx @@ -13,6 +13,7 @@ import { Item } from "#/components/cms/navigation/Item"; import { SVWIcon } from "#/components/cms/navigation/SVWIcon"; import { Group } from "#/components/cms/navigation/Group"; import { NavElement } from "#/components/cms/navigation/types"; +import { Fragment } from "react"; const elements: NavElement[] = [ { type: "item", title: "Dashboard", href: "/cms", Icon: MdOutlineDashboard }, @@ -46,12 +47,12 @@ export function Navigation({ open, onClose }: { open: boolean; onClose?: () => v element.type === "item" ? ( ) : ( - <> - + + {element.items.map((item) => ( ))} - + ), )} From fb41f10b10a81fc17b0e315e30b0d55ed44b1b7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matth=C3=A4us=20Mayer?= <7984982+theMattCode@users.noreply.github.com> Date: Fri, 3 Jan 2025 09:33:02 +0100 Subject: [PATCH 02/16] Navigation need not be a client component #226 --- components/cms/navigation/Navigation.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/components/cms/navigation/Navigation.tsx b/components/cms/navigation/Navigation.tsx index 4e447c2..695fa1f 100644 --- a/components/cms/navigation/Navigation.tsx +++ b/components/cms/navigation/Navigation.tsx @@ -1,5 +1,3 @@ -"use client"; - import Image from "next/image"; import { MdClose, From 5d157eb743401d7a4165d30341319c5998f99f41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matth=C3=A4us=20Mayer?= <7984982+theMattCode@users.noreply.github.com> Date: Fri, 3 Jan 2025 09:33:18 +0100 Subject: [PATCH 03/16] Make it shorter #226 --- components/cms/navigation/Navigation.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/cms/navigation/Navigation.tsx b/components/cms/navigation/Navigation.tsx index 695fa1f..39f36de 100644 --- a/components/cms/navigation/Navigation.tsx +++ b/components/cms/navigation/Navigation.tsx @@ -31,9 +31,9 @@ const elements: NavElement[] = [ export function Navigation({ open, onClose }: { open: boolean; onClose?: () => void }) { return (
-
+
SVW Emblem SVW CMS From 46e7383c62b1da2b729074c198829c952d1b8e27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matth=C3=A4us=20Mayer?= <7984982+theMattCode@users.noreply.github.com> Date: Fri, 3 Jan 2025 09:33:49 +0100 Subject: [PATCH 04/16] Implement basic people list #226 --- app/(cms)/cms.css | 6 ++-- app/(cms)/cms/people/PeopleTable.tsx | 45 ++++++++++++++++++++++++++++ app/(cms)/cms/people/page.tsx | 24 +++++++++++++-- components/cms/table/Table.tsx | 2 ++ components/cms/table/cell/Mail.tsx | 7 +++++ components/cms/table/cell/Phone.tsx | 7 +++++ components/cms/table/cell/Roles.tsx | 19 ++++++++++++ lib/types/people.ts | 16 ++++++++++ 8 files changed, 121 insertions(+), 5 deletions(-) create mode 100644 app/(cms)/cms/people/PeopleTable.tsx create mode 100644 components/cms/table/cell/Mail.tsx create mode 100644 components/cms/table/cell/Phone.tsx create mode 100644 components/cms/table/cell/Roles.tsx create mode 100644 lib/types/people.ts diff --git a/app/(cms)/cms.css b/app/(cms)/cms.css index 884cc59..456753c 100644 --- a/app/(cms)/cms.css +++ b/app/(cms)/cms.css @@ -59,11 +59,11 @@ table { } th { - @apply border-0 px-3 py-2 first:rounded-tl-md last:rounded-tr-md text-left text-sm uppercase + @apply border-0 p-2 first:rounded-tl-md last:rounded-tr-md text-left text-sm uppercase } tr { - @apply even:bg-gray-100 first:border-t border-0 border-gray-100 + @apply even:bg-gray-50 first:border-t border-0 border-gray-100 } thead > tr { @@ -71,7 +71,7 @@ thead > tr { } td { - @apply border-0 border-b border-gray-300 px-3 py-2 font-normal + @apply border-0 border-b border-gray-100 p-2 font-normal } .card-base { diff --git a/app/(cms)/cms/people/PeopleTable.tsx b/app/(cms)/cms/people/PeopleTable.tsx new file mode 100644 index 0000000..82653b1 --- /dev/null +++ b/app/(cms)/cms/people/PeopleTable.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { PersonWithRoles } from "#/lib/types/people"; +import { createColumnHelper, getCoreRowModel, TableOptions } from "@tanstack/table-core"; +import { Table } from "#/components/cms/table/Table"; +import { PhoneCell } from "#/components/cms/table/cell/Phone"; +import { MailCell } from "#/components/cms/table/cell/Mail"; +import { RolesCell } from "#/components/cms/table/cell/Roles"; + +const COLUMN_HELPER = createColumnHelper(); + +export default function PeopleTable({ data }: { data: PersonWithRoles[] }) { + const options: TableOptions = { + data, + columns: [ + COLUMN_HELPER.accessor("firstName", { + header: "Vorname", + id: "firstName", + }), + COLUMN_HELPER.accessor("lastName", { + header: "Nachname", + id: "lastName", + }), + COLUMN_HELPER.accessor("email", { + header: "E-Mail", + id: "email", + cell: MailCell, + }), + COLUMN_HELPER.accessor("phone", { + header: "Phone", + id: "phone", + cell: PhoneCell, + }), + COLUMN_HELPER.accessor((person) => person.peopleToRoles.map((p) => p.roles.name), { + header: "Rollen", + id: "roles", + cell: RolesCell, + }), + ], + getRowId: (person) => person.id, + getCoreRowModel: getCoreRowModel(), + }; + + return ; +} diff --git a/app/(cms)/cms/people/page.tsx b/app/(cms)/cms/people/page.tsx index 237b571..330ba88 100644 --- a/app/(cms)/cms/people/page.tsx +++ b/app/(cms)/cms/people/page.tsx @@ -1,9 +1,29 @@ -import { Card, CardTitle } from "#/components/cms/card/Card"; +import { Card, CardContent, CardTitle } from "#/components/cms/card/Card"; +import { drizzle } from "#/lib/db/drizzle"; +import { PersonWithRoles } from "#/lib/types/people"; +import PeopleTable from "#/app/(cms)/cms/people/PeopleTable"; + +async function getData(): Promise { + return await drizzle.query.people.findMany({ + with: { + peopleToRoles: { + with: { + roles: true, + }, + }, + }, + }); +} + +export default async function People() { + const data = await getData(); -export default function People() { return ( Personen + + + ); } diff --git a/components/cms/table/Table.tsx b/components/cms/table/Table.tsx index 35dbb9e..2d0e79e 100644 --- a/components/cms/table/Table.tsx +++ b/components/cms/table/Table.tsx @@ -1,3 +1,5 @@ +"use client"; + import { RowData, TableOptions } from "@tanstack/table-core"; import { TableHead } from "#/components/cms/table/TableHead"; import { useReactTable } from "@tanstack/react-table"; diff --git a/components/cms/table/cell/Mail.tsx b/components/cms/table/cell/Mail.tsx new file mode 100644 index 0000000..b344f6b --- /dev/null +++ b/components/cms/table/cell/Mail.tsx @@ -0,0 +1,7 @@ +import { CellContext, RowData } from "@tanstack/table-core"; +import Link from "next/link"; + +export function MailCell({ cell }: CellContext) { + const value = cell.getValue(); + return value ? {value} : null; +} diff --git a/components/cms/table/cell/Phone.tsx b/components/cms/table/cell/Phone.tsx new file mode 100644 index 0000000..de766c3 --- /dev/null +++ b/components/cms/table/cell/Phone.tsx @@ -0,0 +1,7 @@ +import { CellContext, RowData } from "@tanstack/table-core"; +import Link from "next/link"; + +export function PhoneCell({ cell }: CellContext) { + const value = cell.getValue(); + return value ? {value} : null; +} diff --git a/components/cms/table/cell/Roles.tsx b/components/cms/table/cell/Roles.tsx new file mode 100644 index 0000000..b5dbea9 --- /dev/null +++ b/components/cms/table/cell/Roles.tsx @@ -0,0 +1,19 @@ +import { CellContext, RowData } from "@tanstack/table-core"; + +export function RolesCell({ cell }: CellContext) { + const value = cell.getValue(); + + if (value) { + return ( + <> + {value.map((role) => ( + + {role} + + ))} + + ); + } + + return null; +} diff --git a/lib/types/people.ts b/lib/types/people.ts new file mode 100644 index 0000000..343c67d --- /dev/null +++ b/lib/types/people.ts @@ -0,0 +1,16 @@ +export type PersonWithRoles = { + id: string; + firstName: string; + lastName: string; + email: string | null; + phone: string | null; + image: string | null; + peopleToRoles: { + peopleId: string; + roleId: string; + roles: { + id: string; + name: string; + }; + }[]; +}; From 123b173f3ecb19aeb3e6aeeec83a7c4f1e557af3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matth=C3=A4us=20Mayer?= <7984982+theMattCode@users.noreply.github.com> Date: Tue, 7 Jan 2025 08:56:13 +0100 Subject: [PATCH 05/16] Implement basic people details/edit view #226 --- app/(cms)/cms.css | 2 +- app/(cms)/cms/people/PeopleList.tsx | 60 +++++++ app/(cms)/cms/people/PeopleListCard.tsx | 29 +++ app/(cms)/cms/people/PeopleTable.tsx | 45 ----- app/(cms)/cms/people/[id]/PersonEdit.tsx | 48 +++++ app/(cms)/cms/people/[id]/PersonalDetails.tsx | 18 ++ app/(cms)/cms/people/[id]/Picture.tsx | 34 ++++ app/(cms)/cms/people/[id]/Preview.tsx | 22 +++ app/(cms)/cms/people/[id]/Rollen.tsx | 5 + app/(cms)/cms/people/[id]/page.tsx | 41 +++++ app/(cms)/cms/people/page.tsx | 30 +--- app/(web)/article/[slug]/page.tsx | 6 +- app/(web)/event/[slug]/page.tsx | 6 +- components/cms/input/TextField.tsx | 69 ++++++++ lib/db/schema/people.ts | 6 +- lib/page.ts | 10 +- lib/types/people.ts | 6 +- migrations/0001_nullable_names.sql | 3 + migrations/meta/0001_snapshot.json | 167 ++++++++++++++++++ migrations/meta/_journal.json | 7 + next.config.js | 6 + 21 files changed, 532 insertions(+), 88 deletions(-) create mode 100644 app/(cms)/cms/people/PeopleList.tsx create mode 100644 app/(cms)/cms/people/PeopleListCard.tsx delete mode 100644 app/(cms)/cms/people/PeopleTable.tsx create mode 100644 app/(cms)/cms/people/[id]/PersonEdit.tsx create mode 100644 app/(cms)/cms/people/[id]/PersonalDetails.tsx create mode 100644 app/(cms)/cms/people/[id]/Picture.tsx create mode 100644 app/(cms)/cms/people/[id]/Preview.tsx create mode 100644 app/(cms)/cms/people/[id]/Rollen.tsx create mode 100644 app/(cms)/cms/people/[id]/page.tsx create mode 100644 components/cms/input/TextField.tsx create mode 100644 migrations/0001_nullable_names.sql create mode 100644 migrations/meta/0001_snapshot.json diff --git a/app/(cms)/cms.css b/app/(cms)/cms.css index 456753c..aa0d5c2 100644 --- a/app/(cms)/cms.css +++ b/app/(cms)/cms.css @@ -75,7 +75,7 @@ td { } .card-base { - @apply bg-white rounded-lg border border-neutral-200 flex flex-col + @apply bg-white rounded-lg border border-neutral-200 flex flex-col h-full } .card-content { diff --git a/app/(cms)/cms/people/PeopleList.tsx b/app/(cms)/cms/people/PeopleList.tsx new file mode 100644 index 0000000..dece537 --- /dev/null +++ b/app/(cms)/cms/people/PeopleList.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { PersonWithRoles } from "#/lib/types/people"; +import { createColumnHelper, getCoreRowModel, TableOptions } from "@tanstack/table-core"; +import { Table } from "#/components/cms/table/Table"; +import { PhoneCell } from "#/components/cms/table/cell/Phone"; +import { MailCell } from "#/components/cms/table/cell/Mail"; +import { RolesCell } from "#/components/cms/table/cell/Roles"; +import { useMemo } from "react"; +import { FaRegEdit } from "react-icons/fa"; +import Link from "next/link"; + +const COLUMN_HELPER = createColumnHelper(); + +export default function PeopleList({ data }: { data: PersonWithRoles[] }) { + const options: TableOptions = useMemo( + () => ({ + data, + columns: [ + COLUMN_HELPER.accessor("firstName", { + header: "Vorname", + id: "firstName", + }), + COLUMN_HELPER.accessor("lastName", { + header: "Nachname", + id: "lastName", + }), + COLUMN_HELPER.accessor("email", { + header: "E-Mail", + id: "email", + cell: MailCell, + }), + COLUMN_HELPER.accessor("phone", { + header: "Phone", + id: "phone", + cell: PhoneCell, + }), + COLUMN_HELPER.accessor((person) => person.peopleToRoles.map((p) => p.roles.name), { + header: "Rollen", + id: "roles", + cell: RolesCell, + }), + COLUMN_HELPER.display({ + header: "", + id: "actions", + cell: (cellContext) => ( + + + + ), + }), + ], + getRowId: (person) => person.id, + getCoreRowModel: getCoreRowModel(), + }), + [data], + ); + + return
; +} diff --git a/app/(cms)/cms/people/PeopleListCard.tsx b/app/(cms)/cms/people/PeopleListCard.tsx new file mode 100644 index 0000000..6edc9f3 --- /dev/null +++ b/app/(cms)/cms/people/PeopleListCard.tsx @@ -0,0 +1,29 @@ +import { Card, CardContent, CardTitle } from "#/components/cms/card/Card"; +import PeopleList from "#/app/(cms)/cms/people/PeopleList"; +import { PersonWithRoles } from "#/lib/types/people"; +import { drizzle } from "#/lib/db/drizzle"; + +async function getData(): Promise { + return await drizzle.query.people.findMany({ + with: { + peopleToRoles: { + with: { + roles: true, + }, + }, + }, + }); +} + +export async function PeopleListCard() { + const data = await getData(); + + return ( + + Personen + + + + + ); +} diff --git a/app/(cms)/cms/people/PeopleTable.tsx b/app/(cms)/cms/people/PeopleTable.tsx deleted file mode 100644 index 82653b1..0000000 --- a/app/(cms)/cms/people/PeopleTable.tsx +++ /dev/null @@ -1,45 +0,0 @@ -"use client"; - -import { PersonWithRoles } from "#/lib/types/people"; -import { createColumnHelper, getCoreRowModel, TableOptions } from "@tanstack/table-core"; -import { Table } from "#/components/cms/table/Table"; -import { PhoneCell } from "#/components/cms/table/cell/Phone"; -import { MailCell } from "#/components/cms/table/cell/Mail"; -import { RolesCell } from "#/components/cms/table/cell/Roles"; - -const COLUMN_HELPER = createColumnHelper(); - -export default function PeopleTable({ data }: { data: PersonWithRoles[] }) { - const options: TableOptions = { - data, - columns: [ - COLUMN_HELPER.accessor("firstName", { - header: "Vorname", - id: "firstName", - }), - COLUMN_HELPER.accessor("lastName", { - header: "Nachname", - id: "lastName", - }), - COLUMN_HELPER.accessor("email", { - header: "E-Mail", - id: "email", - cell: MailCell, - }), - COLUMN_HELPER.accessor("phone", { - header: "Phone", - id: "phone", - cell: PhoneCell, - }), - COLUMN_HELPER.accessor((person) => person.peopleToRoles.map((p) => p.roles.name), { - header: "Rollen", - id: "roles", - cell: RolesCell, - }), - ], - getRowId: (person) => person.id, - getCoreRowModel: getCoreRowModel(), - }; - - return
; -} diff --git a/app/(cms)/cms/people/[id]/PersonEdit.tsx b/app/(cms)/cms/people/[id]/PersonEdit.tsx new file mode 100644 index 0000000..fa79246 --- /dev/null +++ b/app/(cms)/cms/people/[id]/PersonEdit.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { PersonWithRoles } from "#/lib/types/people"; +import { useState } from "react"; +import { Card, CardContent, CardTitle } from "#/components/cms/card/Card"; +import { Picture } from "#/app/(cms)/cms/people/[id]/Picture"; +import { PersonalDetails } from "#/app/(cms)/cms/people/[id]/PersonalDetails"; +import { Rollen } from "#/app/(cms)/cms/people/[id]/Rollen"; +import { Preview } from "#/app/(cms)/cms/people/[id]/Preview"; + +export function PersonEdit({ person: initialPerson }: { person: PersonWithRoles }) { + const [person, setPerson] = useState(initialPerson); + + return ( +
+
+ + Picture + + + + +
+
+ + Details + + + + + + Rollen + + + + +
+
+ + Preview + + + + +
+
+ ); +} diff --git a/app/(cms)/cms/people/[id]/PersonalDetails.tsx b/app/(cms)/cms/people/[id]/PersonalDetails.tsx new file mode 100644 index 0000000..2d87382 --- /dev/null +++ b/app/(cms)/cms/people/[id]/PersonalDetails.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { PersonWithRoles } from "#/lib/types/people"; +import { TextField } from "#/components/cms/input/TextField"; +import { MdOutlineEmail, MdOutlinePhone } from "react-icons/md"; + +export function PersonalDetails({ person }: { person: PersonWithRoles }) { + return ( +
+
+ + +
+ + +
+ ); +} diff --git a/app/(cms)/cms/people/[id]/Picture.tsx b/app/(cms)/cms/people/[id]/Picture.tsx new file mode 100644 index 0000000..e0d3049 --- /dev/null +++ b/app/(cms)/cms/people/[id]/Picture.tsx @@ -0,0 +1,34 @@ +"use client"; + +import Image from "next/image"; +import { PersonWithRoles } from "#/lib/types/people"; +import { TextField } from "#/components/cms/input/TextField"; +import { Dispatch, SetStateAction, useState } from "react"; +import { BsPersonBoundingBox } from "react-icons/bs"; +import { SiCloudinary } from "react-icons/si"; + +export function Picture({ + person, + onPersonChange, +}: { + person: PersonWithRoles; + onPersonChange?: Dispatch>; +}) { + return ( +
+ {person.image ? ( + + ) : ( + + )} + { + onPersonChange?.((prev) => ({ ...prev, image: value })); + }} + /> +
+ ); +} diff --git a/app/(cms)/cms/people/[id]/Preview.tsx b/app/(cms)/cms/people/[id]/Preview.tsx new file mode 100644 index 0000000..252159d --- /dev/null +++ b/app/(cms)/cms/people/[id]/Preview.tsx @@ -0,0 +1,22 @@ +import { PersonWithRoles } from "#/lib/types/people"; +import { PersonCard } from "#/components/person/PersonCard"; +import { Person } from "#/content/people"; + +export function Preview({ person }: { person: PersonWithRoles }) { + const mappedPerson: Person = { + firstname: person.firstName ?? "", + lastname: person.lastName ?? "", + email: person.email ?? "", + image: person.image + ? { + src: person.image ?? "", + alt: `${person.firstName} ${person.lastName}`, + width: 100, + height: 100, + } + : undefined, + phone: [person.phone ?? ""], + tags: person.peopleToRoles.map((p) => p.roles.name ?? ""), + }; + return ; +} diff --git a/app/(cms)/cms/people/[id]/Rollen.tsx b/app/(cms)/cms/people/[id]/Rollen.tsx new file mode 100644 index 0000000..3c9ec83 --- /dev/null +++ b/app/(cms)/cms/people/[id]/Rollen.tsx @@ -0,0 +1,5 @@ +import { PersonWithRoles } from "#/lib/types/people"; + +export function Rollen({ person }: { person: PersonWithRoles }) { + return person.peopleToRoles.map((p) => p.roles.name).join(", "); +} diff --git a/app/(cms)/cms/people/[id]/page.tsx b/app/(cms)/cms/people/[id]/page.tsx new file mode 100644 index 0000000..9a7e371 --- /dev/null +++ b/app/(cms)/cms/people/[id]/page.tsx @@ -0,0 +1,41 @@ +import "server-only"; +import { PageProps } from "#/lib/page"; +import { drizzle } from "#/lib/db/drizzle"; +import { sql } from "drizzle-orm"; +import { PersonWithRoles } from "#/lib/types/people"; +import { cache } from "react"; +import { notFound } from "next/navigation"; +import { PersonEdit } from "#/app/(cms)/cms/people/[id]/PersonEdit"; +import placeholder = sql.placeholder; + +const preparedPersonStatement = drizzle.query.people + .findFirst({ + with: { + peopleToRoles: { + with: { roles: true }, + where: (peopleToRoles, { eq }) => eq(peopleToRoles.peopleId, placeholder("id")), + }, + }, + where: (person, { eq }) => eq(person.id, placeholder("id")), + }) + .prepare("get_person"); + +const getPerson = cache(async (id: string) => { + const person = await preparedPersonStatement.execute({ id }); + + if (!person) { + notFound(); + } + return person; +}); + +export default async function Page({ params }: PageProps<{ id: string }>) { + const { id } = await params; + const person: PersonWithRoles = await getPerson(id); + + return ( +
+ +
+ ); +} diff --git a/app/(cms)/cms/people/page.tsx b/app/(cms)/cms/people/page.tsx index 330ba88..95327d8 100644 --- a/app/(cms)/cms/people/page.tsx +++ b/app/(cms)/cms/people/page.tsx @@ -1,29 +1,5 @@ -import { Card, CardContent, CardTitle } from "#/components/cms/card/Card"; -import { drizzle } from "#/lib/db/drizzle"; -import { PersonWithRoles } from "#/lib/types/people"; -import PeopleTable from "#/app/(cms)/cms/people/PeopleTable"; +import { PeopleListCard } from "#/app/(cms)/cms/people/PeopleListCard"; -async function getData(): Promise { - return await drizzle.query.people.findMany({ - with: { - peopleToRoles: { - with: { - roles: true, - }, - }, - }, - }); -} - -export default async function People() { - const data = await getData(); - - return ( - - Personen - - - - - ); +export default async function PeoplePage() { + return ; } diff --git a/app/(web)/article/[slug]/page.tsx b/app/(web)/article/[slug]/page.tsx index 4aa4088..5613a90 100644 --- a/app/(web)/article/[slug]/page.tsx +++ b/app/(web)/article/[slug]/page.tsx @@ -1,13 +1,13 @@ import Article from "#/components/articles/Article"; import { PageBase } from "#/components/page/PageBase"; -import { getTitle, PageProps } from "#/lib/page"; +import { getTitle, PageProps, Slug } from "#/lib/page"; import { getAllArticleSlugs, getArticleBySlug } from "#/content/article"; import { Metadata } from "next"; import { club } from "#/content/club"; const ARTICLE_DIRECTORY = "public/content/article"; -export async function generateMetadata(props: PageProps): Promise { +export async function generateMetadata(props: PageProps): Promise { const params = await props.params; const article = getArticleBySlug(params.slug, ARTICLE_DIRECTORY); if (!article) return { title: "Artikel nicht gefunden" }; @@ -36,7 +36,7 @@ export async function generateMetadata(props: PageProps): Promise { }; } -export default async function Page(props: PageProps) { +export default async function Page(props: PageProps) { const params = await props.params; const article = getArticleBySlug(params.slug, ARTICLE_DIRECTORY); return ( diff --git a/app/(web)/event/[slug]/page.tsx b/app/(web)/event/[slug]/page.tsx index 0ad0ae3..09bc247 100644 --- a/app/(web)/event/[slug]/page.tsx +++ b/app/(web)/event/[slug]/page.tsx @@ -1,12 +1,12 @@ import { Metadata } from "next"; -import { getTitle, PageProps } from "#/lib/page"; +import { getTitle, PageProps, Slug } from "#/lib/page"; import { PageBase } from "#/components/page/PageBase"; import EventArticle from "#/components/articles/EventArticle"; import { getAllEventSlugs, getEventBySlug } from "#/content/events"; const EVENT_FOLDER = "public/content/event"; -export async function generateMetadata(props: PageProps): Promise { +export async function generateMetadata(props: PageProps): Promise { const params = await props.params; const article = getEventBySlug(params.slug, EVENT_FOLDER); return { @@ -14,7 +14,7 @@ export async function generateMetadata(props: PageProps): Promise { }; } -export default async function Page(props: PageProps) { +export default async function Page(props: PageProps) { const params = await props.params; const eventArticle = getEventBySlug(params.slug, EVENT_FOLDER); return ( diff --git a/components/cms/input/TextField.tsx b/components/cms/input/TextField.tsx new file mode 100644 index 0000000..b68b1c0 --- /dev/null +++ b/components/cms/input/TextField.tsx @@ -0,0 +1,69 @@ +"use client"; + +import React, { useState } from "react"; +import { IconType } from "react-icons"; + +export function TextField({ + label, + value: initialValue, + Icon, + save, +}: { + label: string; + value: string | null; + Icon?: IconType; + save?: (value: string | null) => void; +}) { + const [value, setValue] = useState(initialValue ?? ""); + return ( +
+ {Icon ? ( + + ) : ( +
+ )} + setValue(e.target.value)} + onBlur={() => { + save?.(value); + }} + onKeyDown={(e: React.KeyboardEvent) => { + if (e.key === "Enter") { + save?.(value); + } else if (e.key === "Escape") { + setValue(initialValue ?? ""); + } + }} + /> + + + + {/* This fieldset+legend is used for the border and notch transition */} +
+ + {label} + +
+ + {/*This fieldset+legend always has a notch and is shown when the input is filled, instead of the other, so the notch doesn't vanish when you unfocus the field */} +
+ {label} +
+
+ ); +} diff --git a/lib/db/schema/people.ts b/lib/db/schema/people.ts index ebc330f..30bbc20 100644 --- a/lib/db/schema/people.ts +++ b/lib/db/schema/people.ts @@ -3,8 +3,8 @@ import { relations } from "drizzle-orm"; export const people = pgTable("people", { id: uuid().primaryKey(), - firstName: text("first_name").notNull(), - lastName: text("last_name").notNull(), + firstName: text("first_name"), + lastName: text("last_name"), email: text(), phone: text(), image: text(), @@ -12,7 +12,7 @@ export const people = pgTable("people", { export const roles = pgTable("roles", { id: uuid().primaryKey(), - name: text().notNull(), + name: text(), }); export const peopleToRoles = pgTable( diff --git a/lib/page.ts b/lib/page.ts index 89edcfc..fe8c96a 100644 --- a/lib/page.ts +++ b/lib/page.ts @@ -1,9 +1,13 @@ import { club } from "#/content/club"; -export type PageProps = { - params: Promise<{ slug: string }>; +export interface Slug { + slug: string; +} + +export interface PageProps { + params: Promise; searchParams: Promise<{ [key: string]: string | string[] | undefined }>; -}; +} export function getTitle(prefix?: string) { return `${prefix ? `${prefix} - ` : ""}${club.short}`; diff --git a/lib/types/people.ts b/lib/types/people.ts index 343c67d..4a086b4 100644 --- a/lib/types/people.ts +++ b/lib/types/people.ts @@ -1,7 +1,7 @@ export type PersonWithRoles = { id: string; - firstName: string; - lastName: string; + firstName: string | null; + lastName: string | null; email: string | null; phone: string | null; image: string | null; @@ -10,7 +10,7 @@ export type PersonWithRoles = { roleId: string; roles: { id: string; - name: string; + name: string | null; }; }[]; }; diff --git a/migrations/0001_nullable_names.sql b/migrations/0001_nullable_names.sql new file mode 100644 index 0000000..5e7b6aa --- /dev/null +++ b/migrations/0001_nullable_names.sql @@ -0,0 +1,3 @@ +ALTER TABLE "people" ALTER COLUMN "first_name" DROP NOT NULL;--> statement-breakpoint +ALTER TABLE "people" ALTER COLUMN "last_name" DROP NOT NULL;--> statement-breakpoint +ALTER TABLE "roles" ALTER COLUMN "name" DROP NOT NULL; \ No newline at end of file diff --git a/migrations/meta/0001_snapshot.json b/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..0543dca --- /dev/null +++ b/migrations/meta/0001_snapshot.json @@ -0,0 +1,167 @@ +{ + "id": "8efaa223-9c4f-4f33-963e-df5579234258", + "prevId": "bbb6735e-83a9-4305-8e51-09aa86e1bde7", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.people": { + "name": "people", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.people_to_roles": { + "name": "people_to_roles", + "schema": "", + "columns": { + "people_id": { + "name": "people_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "pk": { + "name": "pk", + "columns": [ + { + "expression": "people_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "people_to_roles_people_id_people_id_fk": { + "name": "people_to_roles_people_id_people_id_fk", + "tableFrom": "people_to_roles", + "tableTo": "people", + "columnsFrom": [ + "people_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "people_to_roles_role_id_roles_id_fk": { + "name": "people_to_roles_role_id_roles_id_fk", + "tableFrom": "people_to_roles", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.roles": { + "name": "roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index 5615e1a..061dabd 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1734702523610, "tag": "0000_initial_people_and_roles", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1735905577738, + "tag": "0001_nullable_names", + "breakpoints": true } ] } \ No newline at end of file diff --git a/next.config.js b/next.config.js index 23f5964..4594cae 100644 --- a/next.config.js +++ b/next.config.js @@ -14,6 +14,12 @@ const nextConfig = { port: "", pathname: "/svwalddorf/**", }, + { + protocol: "https", + hostname: "res.cloudinary.com", + port: "", + pathname: "/svwalddorf/**", + }, ], }, webpack: (config) => { From 8f948a85f766f1e221e774b44e79add0351a58d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matth=C3=A4us=20Mayer?= <7984982+theMattCode@users.noreply.github.com> Date: Mon, 13 Jan 2025 07:54:39 +0100 Subject: [PATCH 06/16] Implement People CMS --- .github/dependabot.yml | 4 + app/(cms)/cms.css | 28 +- app/(cms)/cms/api/people/roles/route.ts | 7 + app/(cms)/cms/layout.tsx | 37 +- app/(cms)/cms/people/PeopleList.tsx | 162 +++++-- app/(cms)/cms/people/PeopleListCard.tsx | 7 +- app/(cms)/cms/people/[id]/Details.tsx | 56 +++ app/(cms)/cms/people/[id]/Edit.tsx | 48 +++ app/(cms)/cms/people/[id]/PersonEdit.tsx | 48 --- app/(cms)/cms/people/[id]/PersonalDetails.tsx | 18 - app/(cms)/cms/people/[id]/Picture.tsx | 55 ++- app/(cms)/cms/people/[id]/Roles.tsx | 80 ++++ app/(cms)/cms/people/[id]/Rollen.tsx | 5 - app/(cms)/cms/people/[id]/actions.ts | 53 +++ app/(cms)/cms/people/[id]/page.tsx | 4 +- components/cms/card/Card.tsx | 8 + components/cms/input/TextField.tsx | 128 +++--- components/cms/table/cell/Phone.tsx | 6 +- components/cms/table/cell/Roles.tsx | 11 +- components/person/PersonCard.tsx | 2 +- lib/action.ts | 39 ++ lib/db/schema/people.ts | 4 +- lib/types/people.ts | 15 +- migrations/0002_onDelete_cascade.sql | 6 + migrations/meta/0002_snapshot.json | 167 ++++++++ migrations/meta/_journal.json | 7 + package.json | 7 + pnpm-lock.yaml | 404 ++++++++++++++++++ 28 files changed, 1200 insertions(+), 216 deletions(-) create mode 100644 app/(cms)/cms/api/people/roles/route.ts create mode 100644 app/(cms)/cms/people/[id]/Details.tsx create mode 100644 app/(cms)/cms/people/[id]/Edit.tsx delete mode 100644 app/(cms)/cms/people/[id]/PersonEdit.tsx delete mode 100644 app/(cms)/cms/people/[id]/PersonalDetails.tsx create mode 100644 app/(cms)/cms/people/[id]/Roles.tsx delete mode 100644 app/(cms)/cms/people/[id]/Rollen.tsx create mode 100644 app/(cms)/cms/people/[id]/actions.ts create mode 100644 lib/action.ts create mode 100644 migrations/0002_onDelete_cascade.sql create mode 100644 migrations/meta/0002_snapshot.json diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a1f5ebd..bebfa9f 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -7,5 +7,9 @@ updates: groups: dependencies: dependency-types: "production" + patterns: + - "*" devDependencies: dependency-types: "development" + patterns: + - "*" diff --git a/app/(cms)/cms.css b/app/(cms)/cms.css index aa0d5c2..d4b80cf 100644 --- a/app/(cms)/cms.css +++ b/app/(cms)/cms.css @@ -54,6 +54,20 @@ p > ul > li { @apply ml-6 } +@container (max-width: 600px) { + table td:not(:nth-child(1)):not(:nth-child(2)):not(:last-child), + table th:not(:nth-child(1)):not(:nth-child(2)):not(:last-child) { + display: none; + } +} + +@container (max-width: 900px) { + table td:not(:nth-child(1)):not(:nth-child(2)):not(:nth-child(3)):not(:last-child), + table th:not(:nth-child(1)):not(:nth-child(2)):not(:nth-child(3)):not(:last-child) { + display: none; + } +} + table { @apply w-full font-light overflow-scroll } @@ -78,12 +92,20 @@ td { @apply bg-white rounded-lg border border-neutral-200 flex flex-col h-full } -.card-content { - @apply w-full p-4 +.card-header { + @apply w-full px-4 pt-4 flex justify-between } .card-title { - @apply w-full p-4 text-lg font-medium text-gray-700 + @apply text-lg font-medium text-gray-700 +} + +.card-toolbar { + @apply flex justify-end gap-4 items-center +} + +.card-content { + @apply w-full p-4 flex flex-col gap-4 @container } .news-title-shadow { diff --git a/app/(cms)/cms/api/people/roles/route.ts b/app/(cms)/cms/api/people/roles/route.ts new file mode 100644 index 0000000..f58a81b --- /dev/null +++ b/app/(cms)/cms/api/people/roles/route.ts @@ -0,0 +1,7 @@ +import { NextRequest, NextResponse } from "next/server"; +import { drizzle } from "#/lib/db/drizzle"; + +export async function GET(request: NextRequest) { + const roles = await drizzle.query.roles.findMany(); + return NextResponse.json({ roles }); +} diff --git a/app/(cms)/cms/layout.tsx b/app/(cms)/cms/layout.tsx index 06e9301..2ed2279 100644 --- a/app/(cms)/cms/layout.tsx +++ b/app/(cms)/cms/layout.tsx @@ -1,22 +1,43 @@ "use client"; +import "@fontsource/roboto/300.css"; +import "@fontsource/roboto/400.css"; +import "@fontsource/roboto/500.css"; +import "@fontsource/roboto/700.css"; import { PropsWithChildren, useState } from "react"; import { withPageAuthRequired } from "@auth0/nextjs-auth0/client"; import { Navigation } from "#/components/cms/navigation/Navigation"; import { Header } from "#/components/cms/header/Header"; +import { createTheme, ThemeProvider } from "@mui/material"; + +const theme = createTheme({ + palette: { + primary: { + light: "#3999d9", + main: "#057DB1", + dark: "#015d98", + contrastText: "#fff", + }, + }, +}); export default withPageAuthRequired(function CMSLayout({ children }: PropsWithChildren) { const [navigationOpen, setNavigationOpen] = useState(false); return ( -
- setNavigationOpen(false)} /> -
setNavigationOpen(false) : () => {}}> -
setNavigationOpen(true)} /> -
{children}
+ +
+ setNavigationOpen(false)} />
+ className="w-full h-full flex flex-col" + onClick={navigationOpen ? () => setNavigationOpen(false) : () => {}} + > +
setNavigationOpen(true)} /> +
{children}
+
+
-
+
); }); diff --git a/app/(cms)/cms/people/PeopleList.tsx b/app/(cms)/cms/people/PeopleList.tsx index dece537..5ca568f 100644 --- a/app/(cms)/cms/people/PeopleList.tsx +++ b/app/(cms)/cms/people/PeopleList.tsx @@ -1,60 +1,142 @@ "use client"; +import * as React from "react"; import { PersonWithRoles } from "#/lib/types/people"; -import { createColumnHelper, getCoreRowModel, TableOptions } from "@tanstack/table-core"; +import { CellContext, createColumnHelper, getCoreRowModel, Row, TableOptions } from "@tanstack/table-core"; import { Table } from "#/components/cms/table/Table"; import { PhoneCell } from "#/components/cms/table/cell/Phone"; import { MailCell } from "#/components/cms/table/cell/Mail"; import { RolesCell } from "#/components/cms/table/cell/Roles"; -import { useMemo } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { FaRegEdit } from "react-icons/fa"; -import Link from "next/link"; +import { CardToolbar } from "#/components/cms/card/Card"; +import { debounce } from "lodash"; +import { LuSearch } from "react-icons/lu"; +import { MdOutlineDelete, MdOutlinePersonAdd } from "react-icons/md"; +import { Button, IconButton } from "@mui/material"; +import { TextField } from "#/components/cms/input/TextField"; +import { createPerson, deletePerson } from "#/app/(cms)/cms/people/[id]/actions"; +import { useRouter } from "next/navigation"; +import { useHotkeys } from "react-hotkeys-hook"; const COLUMN_HELPER = createColumnHelper(); +const COLUMN_FIRSTNAME = COLUMN_HELPER.accessor("firstName", { + header: "Vorname", + id: "firstName", +}); + +const COLUMN_LASTNAME = COLUMN_HELPER.accessor("lastName", { + header: "Nachname", + id: "lastName", +}); + +const COLUMN_EMAIL = COLUMN_HELPER.accessor("email", { + header: "E-Mail", + id: "email", + cell: MailCell, +}); + +const COLUMN_PHONE = COLUMN_HELPER.accessor("phone", { + header: "Phone", + id: "phone", + cell: PhoneCell, +}); + +const COLUMN_ROLES = COLUMN_HELPER.accessor((person) => person.peopleToRoles.map((p) => p.roles.name), { + header: "Rollen", + id: "roles", + cell: RolesCell, +}); + +const COLUMN_ACTIONS = COLUMN_HELPER.display({ + header: "", + id: "actions", + cell: Actions, +}); + export default function PeopleList({ data }: { data: PersonWithRoles[] }) { + const router = useRouter(); + + const [searchTerm, setSearchTerm] = useState(); + const filteredData = useMemo( + () => + data.filter((person) => { + if (!searchTerm) return true; + + const firstName = person.firstName?.toLowerCase(); + const lastName = person.lastName?.toLowerCase(); + + if (firstName?.includes(searchTerm)) return true; + if (lastName?.includes(searchTerm)) return true; + if (`${firstName} ${lastName}`.includes(searchTerm)) return true; + if (person.email?.toLowerCase().includes(searchTerm)) return true; + return person.peopleToRoles.some((p) => p.roles.name?.toLowerCase().includes(searchTerm) ?? false); + }), + [searchTerm, data], + ); + const options: TableOptions = useMemo( () => ({ - data, - columns: [ - COLUMN_HELPER.accessor("firstName", { - header: "Vorname", - id: "firstName", - }), - COLUMN_HELPER.accessor("lastName", { - header: "Nachname", - id: "lastName", - }), - COLUMN_HELPER.accessor("email", { - header: "E-Mail", - id: "email", - cell: MailCell, - }), - COLUMN_HELPER.accessor("phone", { - header: "Phone", - id: "phone", - cell: PhoneCell, - }), - COLUMN_HELPER.accessor((person) => person.peopleToRoles.map((p) => p.roles.name), { - header: "Rollen", - id: "roles", - cell: RolesCell, - }), - COLUMN_HELPER.display({ - header: "", - id: "actions", - cell: (cellContext) => ( - - - - ), - }), - ], + data: filteredData, + columns: [COLUMN_FIRSTNAME, COLUMN_LASTNAME, COLUMN_EMAIL, COLUMN_PHONE, COLUMN_ROLES, COLUMN_ACTIONS], getRowId: (person) => person.id, getCoreRowModel: getCoreRowModel(), }), - [data], + [filteredData], + ); + + const onChange = useCallback( + debounce((value: string | null) => setSearchTerm(value?.trim().toLowerCase()), 500), + [], ); - return
; + const onNewPerson = useCallback(async () => { + const person = await createPerson(); + router.push(`/cms/people/${person.person.id}`); + }, [router]); + useHotkeys("alt+n", onNewPerson); + + const refSearch = useRef(null); + const focusSearch = useCallback(() => { + refSearch.current?.focus(); + }, []); + useHotkeys("alt+s", focusSearch); + + return ( + <> + + + + +
+ + ); +} + +function Actions(cellContext: CellContext) { + const router = useRouter(); + return ( +
+ { + router.push(`/cms/people/${cellContext.row.original.id}`); + }} + > + + + { + await deletePerson(cellContext.row.original.id); + router.push(`/cms/people`); + }} + > + + +
+ ); } diff --git a/app/(cms)/cms/people/PeopleListCard.tsx b/app/(cms)/cms/people/PeopleListCard.tsx index 6edc9f3..85d3257 100644 --- a/app/(cms)/cms/people/PeopleListCard.tsx +++ b/app/(cms)/cms/people/PeopleListCard.tsx @@ -1,7 +1,8 @@ -import { Card, CardContent, CardTitle } from "#/components/cms/card/Card"; +import { Card, CardContent, CardHeader, CardTitle, CardToolbar } from "#/components/cms/card/Card"; import PeopleList from "#/app/(cms)/cms/people/PeopleList"; import { PersonWithRoles } from "#/lib/types/people"; import { drizzle } from "#/lib/db/drizzle"; +import { TextField } from "#/components/cms/input/TextField"; async function getData(): Promise { return await drizzle.query.people.findMany({ @@ -20,7 +21,9 @@ export async function PeopleListCard() { return ( - Personen + + Personen + diff --git a/app/(cms)/cms/people/[id]/Details.tsx b/app/(cms)/cms/people/[id]/Details.tsx new file mode 100644 index 0000000..695c837 --- /dev/null +++ b/app/(cms)/cms/people/[id]/Details.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { PersonWithRoles } from "#/lib/types/people"; +import { MutateResult, TextField, TextFieldMutationVariables } from "#/components/cms/input/TextField"; +import { MdOutlineEmail, MdOutlinePhone } from "react-icons/md"; +import { mutatePerson } from "#/app/(cms)/cms/people/[id]/actions"; +import { useCallback } from "react"; +import { MutationFunction, useDebouncedMutation } from "#/lib/action"; +import { Alert, AlertTitle, Autocomplete, TextField as MuiTextField } from "@mui/material"; +import { Roles } from "#/app/(cms)/cms/people/[id]/Roles"; + +export function Details({ person }: { person: PersonWithRoles }) { + const preparedMutateFirstNameFn: MutationFunction = useCallback( + async ({ value }: TextFieldMutationVariables): Promise => + mutatePerson(person.id, { firstName: value ?? "" }), + [person.id], + ); + const mutateFirstName = useDebouncedMutation(preparedMutateFirstNameFn); + + const preparedMutateLastNameFn: MutationFunction = useCallback( + async ({ value }: TextFieldMutationVariables): Promise => + mutatePerson(person.id, { lastName: value ?? "" }), + [person.id], + ); + const mutateLastName = useDebouncedMutation(preparedMutateLastNameFn); + + const preparedMutateEmailFn: MutationFunction = useCallback( + async ({ value }: TextFieldMutationVariables): Promise => + mutatePerson(person.id, { email: value ?? "" }), + [person.id], + ); + const mutateEmail = useDebouncedMutation(preparedMutateEmailFn); + + const preparedMutatePhoneFn: MutationFunction = useCallback( + async ({ value }: TextFieldMutationVariables): Promise => + mutatePerson(person.id, { phone: value ?? "" }), + [person.id], + ); + const mutatePhone = useDebouncedMutation(preparedMutatePhoneFn); + + return ( +
+
+ + +
+ + + + + ID +
{person.id}
+
+
+ ); +} diff --git a/app/(cms)/cms/people/[id]/Edit.tsx b/app/(cms)/cms/people/[id]/Edit.tsx new file mode 100644 index 0000000..1a2c8c3 --- /dev/null +++ b/app/(cms)/cms/people/[id]/Edit.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { PersonWithRoles } from "#/lib/types/people"; +import { useState } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "#/components/cms/card/Card"; +import { Picture } from "#/app/(cms)/cms/people/[id]/Picture"; +import { Details } from "#/app/(cms)/cms/people/[id]/Details"; +import { Roles } from "#/app/(cms)/cms/people/[id]/Roles"; +import { Preview } from "#/app/(cms)/cms/people/[id]/Preview"; + +export function Edit({ person: initialPerson }: { person: PersonWithRoles }) { + const [person] = useState(initialPerson); + + return ( +
+
+ + + Picture + + + + + +
+
+ + + Details + + +
+ + +
+
+ + + Preview + + + + + +
+
+ ); +} diff --git a/app/(cms)/cms/people/[id]/PersonEdit.tsx b/app/(cms)/cms/people/[id]/PersonEdit.tsx deleted file mode 100644 index fa79246..0000000 --- a/app/(cms)/cms/people/[id]/PersonEdit.tsx +++ /dev/null @@ -1,48 +0,0 @@ -"use client"; - -import { PersonWithRoles } from "#/lib/types/people"; -import { useState } from "react"; -import { Card, CardContent, CardTitle } from "#/components/cms/card/Card"; -import { Picture } from "#/app/(cms)/cms/people/[id]/Picture"; -import { PersonalDetails } from "#/app/(cms)/cms/people/[id]/PersonalDetails"; -import { Rollen } from "#/app/(cms)/cms/people/[id]/Rollen"; -import { Preview } from "#/app/(cms)/cms/people/[id]/Preview"; - -export function PersonEdit({ person: initialPerson }: { person: PersonWithRoles }) { - const [person, setPerson] = useState(initialPerson); - - return ( -
-
- - Picture - - - - -
-
- - Details - - - - - - Rollen - - - - -
-
- - Preview - - - - -
-
- ); -} diff --git a/app/(cms)/cms/people/[id]/PersonalDetails.tsx b/app/(cms)/cms/people/[id]/PersonalDetails.tsx deleted file mode 100644 index 2d87382..0000000 --- a/app/(cms)/cms/people/[id]/PersonalDetails.tsx +++ /dev/null @@ -1,18 +0,0 @@ -"use client"; - -import { PersonWithRoles } from "#/lib/types/people"; -import { TextField } from "#/components/cms/input/TextField"; -import { MdOutlineEmail, MdOutlinePhone } from "react-icons/md"; - -export function PersonalDetails({ person }: { person: PersonWithRoles }) { - return ( -
-
- - -
- - -
- ); -} diff --git a/app/(cms)/cms/people/[id]/Picture.tsx b/app/(cms)/cms/people/[id]/Picture.tsx index e0d3049..df4b6ba 100644 --- a/app/(cms)/cms/people/[id]/Picture.tsx +++ b/app/(cms)/cms/people/[id]/Picture.tsx @@ -1,33 +1,56 @@ "use client"; -import Image from "next/image"; +import NextImage from "next/image"; import { PersonWithRoles } from "#/lib/types/people"; -import { TextField } from "#/components/cms/input/TextField"; -import { Dispatch, SetStateAction, useState } from "react"; +import { MutateResult, TextField, TextFieldMutationVariables } from "#/components/cms/input/TextField"; import { BsPersonBoundingBox } from "react-icons/bs"; import { SiCloudinary } from "react-icons/si"; +import { MutationFunction, useDebouncedMutation } from "#/lib/action"; +import { useCallback, useState } from "react"; +import { mutatePerson } from "#/app/(cms)/cms/people/[id]/actions"; -export function Picture({ - person, - onPersonChange, -}: { - person: PersonWithRoles; - onPersonChange?: Dispatch>; -}) { +function validateImageUrl(url: string): boolean { + try { + new URL(url); + return true; + } catch { + return false; + } +} + +export function Picture({ person }: { person: PersonWithRoles }) { + const [imageUrl, setImageUrl] = useState(person.image); + + const preparedMutatePictureFn: MutationFunction = useCallback( + async ({ value }: TextFieldMutationVariables): Promise => + mutatePerson(person.id, { image: value ?? "" }), + [person.id], + ); + const mutateFirstName = useDebouncedMutation((variables) => { + if (variables.value !== null) { + // synchronously load image and test if it is valid + } + return preparedMutatePictureFn(variables); + }); return (
- {person.image ? ( - + {imageUrl ? ( + ) : ( )} { - onPersonChange?.((prev) => ({ ...prev, image: value })); + defaultValue={imageUrl} + onChange={(value: string | null) => { + if (value === null || value.trim() === "") { + setImageUrl(null); + } else if (validateImageUrl(value)) { + setImageUrl(value); + } }} + mutate={mutateFirstName} + StartIcon={SiCloudinary} />
); diff --git a/app/(cms)/cms/people/[id]/Roles.tsx b/app/(cms)/cms/people/[id]/Roles.tsx new file mode 100644 index 0000000..38d18b6 --- /dev/null +++ b/app/(cms)/cms/people/[id]/Roles.tsx @@ -0,0 +1,80 @@ +import { PersonWithRoles, Role } from "#/lib/types/people"; +import { Autocomplete, CircularProgress, TextField } from "@mui/material"; +import { useState } from "react"; +import fetch from "node-fetch"; +import { assignRole, createRole, unassignRole } from "#/app/(cms)/cms/people/[id]/actions"; + +export function Roles({ person }: { person: PersonWithRoles }) { + const [open, setOpen] = useState(false); + const [options, setOptions] = useState(); + const [optionsLoading, setOptionsLoading] = useState(false); + + const onOpen = async () => { + setOpen(true); + if (!options) { + setOptionsLoading(true); + const response = await fetch("/cms/api/people/roles"); + const data = await response.json(); + setOptions([...data.roles].sort((a: Role, b: Role) => (a.name ?? "").localeCompare(b.name ?? ""))); + setOptionsLoading(false); + } + }; + + const [roles, setRoles] = useState(() => + person.peopleToRoles.map((p) => ({ id: p.roleId, name: p.roles.name })), + ); + + return ( + setOpen(false)} + loading={optionsLoading} + value={roles} + onChange={async (_, selectedRoles, reason, details) => { + if (reason === "createOption" && typeof details?.option === "string") { + const newRoleName: string = details.option; + if (!options?.some((r) => r.name?.toLowerCase() === newRoleName.toLowerCase())) { + const newRole = await createRole({ name: details.option }); + await assignRole(person.id, newRole.role.id); + setRoles(selectedRoles.map((r) => (typeof r === "string" ? newRole.role : r))); + } + } else if (reason === "selectOption" && details?.option.id) { + await assignRole(person.id, details?.option.id); + setRoles(selectedRoles as Role[]); + } else if (reason === "removeOption" && details?.option.id) { + await unassignRole(person.id, details?.option.id); + setRoles(selectedRoles as Role[]); + } + }} + renderInput={(params) => ( + + {optionsLoading ? : null} + {params.InputProps.endAdornment} + + ), + }, + }} + /> + )} + getOptionLabel={(option) => (typeof option === "string" ? option : (option.name ?? ""))} + options={options ?? []} + isOptionEqualToValue={(option, value) => option.id === value.id} + filterOptions={(options, params) => + options?.filter?.((option) => option.name?.toLowerCase().includes(params.inputValue.toLowerCase())) ?? [] + } + /> + ); +} diff --git a/app/(cms)/cms/people/[id]/Rollen.tsx b/app/(cms)/cms/people/[id]/Rollen.tsx deleted file mode 100644 index 3c9ec83..0000000 --- a/app/(cms)/cms/people/[id]/Rollen.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { PersonWithRoles } from "#/lib/types/people"; - -export function Rollen({ person }: { person: PersonWithRoles }) { - return person.peopleToRoles.map((p) => p.roles.name).join(", "); -} diff --git a/app/(cms)/cms/people/[id]/actions.ts b/app/(cms)/cms/people/[id]/actions.ts new file mode 100644 index 0000000..fbfdc73 --- /dev/null +++ b/app/(cms)/cms/people/[id]/actions.ts @@ -0,0 +1,53 @@ +"use server"; + +import { MutateResult } from "#/components/cms/input/TextField"; +import { drizzle } from "#/lib/db/drizzle"; +import { people, peopleToRoles, roles } from "#/lib/db/schema"; +import { and, eq } from "drizzle-orm"; +import { Person, Role } from "#/lib/types/people"; + +export const mutatePerson = async (id: string, person: Partial>): Promise => { + await drizzle.update(people).set(person).where(eq(people.id, id)); + return { type: "success" }; +}; + +export const createPerson = async ( + person: Omit = { firstName: "", lastName: "", image: "", phone: "", email: "" }, +): Promise => { + const newPerson = await drizzle + .insert(people) + .values({ + id: crypto.randomUUID(), + firstName: person.firstName, + lastName: person.lastName, + phone: person.phone, + email: person.email, + image: person.image, + }) + .returning(); + return { type: "success", person: { ...newPerson[0] } }; +}; + +export const deletePerson = async (id: string): Promise => { + await drizzle.delete(people).where(eq(people.id, id)); + return { type: "success" }; +}; + +export const getAllRoles = async () => await drizzle.query.roles.findMany(); + +export const createRole = async (role: Omit = { name: "" }): Promise => { + const newRole = await drizzle.insert(roles).values({ id: crypto.randomUUID(), name: role.name }).returning(); + return { type: "success", role: { ...newRole[0] } }; +}; + +export const assignRole = async (peopleId: string, roleId: string): Promise => { + await drizzle.insert(peopleToRoles).values({ peopleId, roleId }).onConflictDoNothing().returning(); + return { type: "success" }; +}; + +export const unassignRole = async (peopleId: string, roleId: string): Promise => { + await drizzle + .delete(peopleToRoles) + .where(and(eq(peopleToRoles.peopleId, peopleId), eq(peopleToRoles.roleId, roleId))); + return { type: "success" }; +}; diff --git a/app/(cms)/cms/people/[id]/page.tsx b/app/(cms)/cms/people/[id]/page.tsx index 9a7e371..471e993 100644 --- a/app/(cms)/cms/people/[id]/page.tsx +++ b/app/(cms)/cms/people/[id]/page.tsx @@ -5,7 +5,7 @@ import { sql } from "drizzle-orm"; import { PersonWithRoles } from "#/lib/types/people"; import { cache } from "react"; import { notFound } from "next/navigation"; -import { PersonEdit } from "#/app/(cms)/cms/people/[id]/PersonEdit"; +import { Edit } from "#/app/(cms)/cms/people/[id]/Edit"; import placeholder = sql.placeholder; const preparedPersonStatement = drizzle.query.people @@ -35,7 +35,7 @@ export default async function Page({ params }: PageProps<{ id: string }>) { return (
- +
); } diff --git a/components/cms/card/Card.tsx b/components/cms/card/Card.tsx index c2f7b72..510e9d0 100644 --- a/components/cms/card/Card.tsx +++ b/components/cms/card/Card.tsx @@ -4,6 +4,14 @@ export function Card({ children }: PropsWithChildren<{}>) { return
{children}
; } +export function CardHeader({ children }: PropsWithChildren<{}>) { + return
{children}
; +} + +export function CardToolbar({ children }: PropsWithChildren<{}>) { + return
{children}
; +} + export function CardContent({ children }: PropsWithChildren<{}>) { return
{children}
; } diff --git a/components/cms/input/TextField.tsx b/components/cms/input/TextField.tsx index b68b1c0..cb2ece4 100644 --- a/components/cms/input/TextField.tsx +++ b/components/cms/input/TextField.tsx @@ -1,69 +1,75 @@ -"use client"; - -import React, { useState } from "react"; +import * as React from "react"; +import { InputAdornment, TextField as MuiTextField, TextFieldProps as MuiTextFieldProps } from "@mui/material"; import { IconType } from "react-icons"; +import { ChangeEventHandler, useCallback, useState } from "react"; +import { MdCheck, MdError } from "react-icons/md"; + +export type TextFieldMutationVariables = { value: string | null }; +export type MutateResult = { type: "success" } | { type: "error"; message: string }; +export type MutateFn = (variables: TextFieldMutationVariables) => Promise; + +export interface TextFieldProps extends Pick { + StartIcon?: IconType; + label?: string; + mutate?: MutateFn; + onChange?: (value: string) => void; +} export function TextField({ + StartIcon, label, - value: initialValue, - Icon, - save, -}: { - label: string; - value: string | null; - Icon?: IconType; - save?: (value: string | null) => void; -}) { - const [value, setValue] = useState(initialValue ?? ""); - return ( -
- {Icon ? ( - - ) : ( -
- )} - setValue(e.target.value)} - onBlur={() => { - save?.(value); - }} - onKeyDown={(e: React.KeyboardEvent) => { - if (e.key === "Enter") { - save?.(value); - } else if (e.key === "Escape") { - setValue(initialValue ?? ""); - } - }} - /> - - + defaultValue, + mutate, + onChange, + fullWidth = true, + ref, + inputRef, +}: TextFieldProps) { + const [value, setValue] = useState(defaultValue ?? ""); + const [mutationResult, setMutationResult] = useState(); - {/* This fieldset+legend is used for the border and notch transition */} -
- - {label} - -
+ const onChangeHandler: ChangeEventHandler = useCallback( + async (event) => { + setMutationResult(undefined); + const newValue = event.target.value; + setValue(newValue); + onChange?.(newValue); + setMutationResult(await mutate?.({ value: newValue })); + }, + [mutate, onChange], + ); - {/*This fieldset+legend always has a notch and is shown when the input is filled, instead of the other, so the notch doesn't vanish when you unfocus the field */} -
- {label} -
-
+ return ( + { + if (event.key === "Escape") { + setValue(defaultValue ?? ""); + onChange?.(defaultValue?.toString() ?? ""); + } + }} + size="small" + fullWidth={fullWidth} + label={label} + aria-label={label} + slotProps={{ + input: { + startAdornment: StartIcon ? ( + + + + ) : null, + endAdornment: mutationResult && ( + + {mutationResult?.type === "success" && } + {mutationResult?.type === "error" && } + + ), + }, + }} + /> ); } diff --git a/components/cms/table/cell/Phone.tsx b/components/cms/table/cell/Phone.tsx index de766c3..b336c22 100644 --- a/components/cms/table/cell/Phone.tsx +++ b/components/cms/table/cell/Phone.tsx @@ -3,5 +3,9 @@ import Link from "next/link"; export function PhoneCell({ cell }: CellContext) { const value = cell.getValue(); - return value ? {value} : null; + return value ? ( + + {value} + + ) : null; } diff --git a/components/cms/table/cell/Roles.tsx b/components/cms/table/cell/Roles.tsx index b5dbea9..5b30b8d 100644 --- a/components/cms/table/cell/Roles.tsx +++ b/components/cms/table/cell/Roles.tsx @@ -1,17 +1,20 @@ import { CellContext, RowData } from "@tanstack/table-core"; -export function RolesCell({ cell }: CellContext) { +export function RolesCell({ cell }: CellContext) { const value = cell.getValue(); if (value) { return ( - <> +
{value.map((role) => ( - + {role} ))} - +
); } diff --git a/components/person/PersonCard.tsx b/components/person/PersonCard.tsx index 84398c5..d69c2f5 100644 --- a/components/person/PersonCard.tsx +++ b/components/person/PersonCard.tsx @@ -2,7 +2,7 @@ import Image from "next/image"; import Link from "next/link"; import { PropsWithChildren } from "react"; import { getPersonName, Person } from "#/content/people"; -import { GoPersonFill as Placeholder } from "react-icons/go"; +import { BsPersonBoundingBox as Placeholder } from "react-icons/bs"; import { calcImageDimensionsForWidth } from "#/lib/image"; type Props = { diff --git a/lib/action.ts b/lib/action.ts new file mode 100644 index 0000000..2d2b935 --- /dev/null +++ b/lib/action.ts @@ -0,0 +1,39 @@ +import { useCallback, useRef } from "react"; +import { debounce } from "lodash"; + +export type MutationFunction = (variables: TVariables) => Promise; + +export const DEBOUNCE_DEFAULT_WAIT = 500; + +export function useDebouncedMutation( + mutationFn: MutationFunction, +) { + const promiseRef = useRef<{ + resolve: (value: TResult) => void; + reject: (reason?: any) => void; + } | null>(null); + + const debouncedMutation = useCallback( + debounce(async (variables: TVariables) => { + try { + const result = await mutationFn(variables); + promiseRef.current?.resolve(result); + } catch (error) { + promiseRef.current?.reject(error); + } finally { + promiseRef.current = null; + } + }, DEBOUNCE_DEFAULT_WAIT), + [mutationFn], + ); + + return useCallback( + (variables: TVariables): Promise => { + return new Promise((resolve, reject) => { + promiseRef.current = { resolve, reject }; + debouncedMutation(variables); + }); + }, + [debouncedMutation], + ); +} diff --git a/lib/db/schema/people.ts b/lib/db/schema/people.ts index 30bbc20..a646bc5 100644 --- a/lib/db/schema/people.ts +++ b/lib/db/schema/people.ts @@ -20,10 +20,10 @@ export const peopleToRoles = pgTable( { peopleId: uuid("people_id") .notNull() - .references(() => people.id), + .references(() => people.id, { onDelete: "cascade" }), roleId: uuid("role_id") .notNull() - .references(() => roles.id), + .references(() => roles.id, { onDelete: "cascade" }), }, (t) => [index("pk").on(t.peopleId, t.roleId)], ); diff --git a/lib/types/people.ts b/lib/types/people.ts index 4a086b4..22dd8a2 100644 --- a/lib/types/people.ts +++ b/lib/types/people.ts @@ -1,16 +1,21 @@ -export type PersonWithRoles = { +export type Person = { id: string; firstName: string | null; lastName: string | null; email: string | null; phone: string | null; image: string | null; +}; + +export type Role = { + id: string; + name: string | null; +}; + +export type PersonWithRoles = Person & { peopleToRoles: { peopleId: string; roleId: string; - roles: { - id: string; - name: string | null; - }; + roles: Role; }[]; }; diff --git a/migrations/0002_onDelete_cascade.sql b/migrations/0002_onDelete_cascade.sql new file mode 100644 index 0000000..88ea2b2 --- /dev/null +++ b/migrations/0002_onDelete_cascade.sql @@ -0,0 +1,6 @@ +ALTER TABLE "people_to_roles" DROP CONSTRAINT "people_to_roles_people_id_people_id_fk"; +--> statement-breakpoint +ALTER TABLE "people_to_roles" DROP CONSTRAINT "people_to_roles_role_id_roles_id_fk"; +--> statement-breakpoint +ALTER TABLE "people_to_roles" ADD CONSTRAINT "people_to_roles_people_id_people_id_fk" FOREIGN KEY ("people_id") REFERENCES "public"."people"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "people_to_roles" ADD CONSTRAINT "people_to_roles_role_id_roles_id_fk" FOREIGN KEY ("role_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/migrations/meta/0002_snapshot.json b/migrations/meta/0002_snapshot.json new file mode 100644 index 0000000..1e35708 --- /dev/null +++ b/migrations/meta/0002_snapshot.json @@ -0,0 +1,167 @@ +{ + "id": "93b788b6-411f-4633-bafe-fcf6ac076350", + "prevId": "8efaa223-9c4f-4f33-963e-df5579234258", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.people": { + "name": "people", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.people_to_roles": { + "name": "people_to_roles", + "schema": "", + "columns": { + "people_id": { + "name": "people_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "pk": { + "name": "pk", + "columns": [ + { + "expression": "people_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "people_to_roles_people_id_people_id_fk": { + "name": "people_to_roles_people_id_people_id_fk", + "tableFrom": "people_to_roles", + "tableTo": "people", + "columnsFrom": [ + "people_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "people_to_roles_role_id_roles_id_fk": { + "name": "people_to_roles_role_id_roles_id_fk", + "tableFrom": "people_to_roles", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.roles": { + "name": "roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index 061dabd..59ca0b0 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1735905577738, "tag": "0001_nullable_names", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1738332589470, + "tag": "0002_onDelete_cascade", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package.json b/package.json index 69ff310..b73c9dd 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,10 @@ }, "dependencies": { "@auth0/nextjs-auth0": "^3.5.0", + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.0", + "@fontsource/roboto": "^5.1.1", + "@mui/material": "^6.4.0", "@neondatabase/serverless": "^0.10.4", "@next/mdx": "15.1.0", "@react-pdf-viewer/core": "^3.12.0", @@ -32,6 +36,7 @@ "dotenv": "^16.4.7", "drizzle-orm": "^0.38.2", "gray-matter": "^4.0.3", + "lodash": "^4.17.21", "next": "15.1.0", "next-sitemap": "^4.2.3", "node-fetch": "2.7.0", @@ -40,6 +45,7 @@ "react": "19.0.0", "react-big-calendar": "^1.17.0", "react-dom": "19.0.0", + "react-hotkeys-hook": "^4.6.1", "react-icons": "^5.0.1", "react-markdown": "^9.0.1", "rehype-raw": "^7.0.0", @@ -59,6 +65,7 @@ "@testing-library/jest-dom": "^6.4.2", "@testing-library/react": "^16.1.0", "@testing-library/user-event": "^14.5.2", + "@types/lodash": "^4.17.14", "@types/mdx": "^2.0.10", "@types/node": "^20.9.1", "@types/node-fetch": "^2.6.12", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1308610..e8f7923 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,18 @@ importers: '@auth0/nextjs-auth0': specifier: ^3.5.0 version: 3.5.0(next@15.1.0(@babel/core@7.26.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)) + '@emotion/react': + specifier: ^11.14.0 + version: 11.14.0(@types/react@19.0.1)(react@19.0.0) + '@emotion/styled': + specifier: ^11.14.0 + version: 11.14.0(@emotion/react@11.14.0(@types/react@19.0.1)(react@19.0.0))(@types/react@19.0.1)(react@19.0.0) + '@fontsource/roboto': + specifier: ^5.1.1 + version: 5.1.1 + '@mui/material': + specifier: ^6.4.0 + version: 6.4.0(@emotion/react@11.14.0(@types/react@19.0.1)(react@19.0.0))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@19.0.1)(react@19.0.0))(@types/react@19.0.1)(react@19.0.0))(@types/react@19.0.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) '@neondatabase/serverless': specifier: ^0.10.4 version: 0.10.4 @@ -50,6 +62,9 @@ importers: gray-matter: specifier: ^4.0.3 version: 4.0.3 + lodash: + specifier: ^4.17.21 + version: 4.17.21 next: specifier: 15.1.0 version: 15.1.0(@babel/core@7.26.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -74,6 +89,9 @@ importers: react-dom: specifier: 19.0.0 version: 19.0.0(react@19.0.0) + react-hotkeys-hook: + specifier: ^4.6.1 + version: 4.6.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0) react-icons: specifier: ^5.0.1 version: 5.4.0(react@19.0.0) @@ -126,6 +144,9 @@ importers: '@testing-library/user-event': specifier: ^14.5.2 version: 14.5.2(@testing-library/dom@10.4.0) + '@types/lodash': + specifier: ^4.17.14 + version: 4.17.14 '@types/mdx': specifier: ^2.0.10 version: 2.0.13 @@ -797,6 +818,60 @@ packages: '@emnapi/runtime@1.3.1': resolution: {integrity: sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw==} + '@emotion/babel-plugin@11.13.5': + resolution: {integrity: sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==} + + '@emotion/cache@11.14.0': + resolution: {integrity: sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==} + + '@emotion/hash@0.9.2': + resolution: {integrity: sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==} + + '@emotion/is-prop-valid@1.3.1': + resolution: {integrity: sha512-/ACwoqx7XQi9knQs/G0qKvv5teDMhD7bXYns9N/wM8ah8iNb8jZ2uNO0YOgiq2o2poIvVtJS2YALasQuMSQ7Kw==} + + '@emotion/memoize@0.9.0': + resolution: {integrity: sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==} + + '@emotion/react@11.14.0': + resolution: {integrity: sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==} + peerDependencies: + '@types/react': '*' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + + '@emotion/serialize@1.3.3': + resolution: {integrity: sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==} + + '@emotion/sheet@1.4.0': + resolution: {integrity: sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==} + + '@emotion/styled@11.14.0': + resolution: {integrity: sha512-XxfOnXFffatap2IyCeJyNov3kiDQWoR08gPUQxvbL7fxKryGBKUZUkG6Hz48DZwVrJSVh9sJboyV1Ds4OW6SgA==} + peerDependencies: + '@emotion/react': ^11.0.0-rc.0 + '@types/react': '*' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + + '@emotion/unitless@0.10.0': + resolution: {integrity: sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==} + + '@emotion/use-insertion-effect-with-fallbacks@1.2.0': + resolution: {integrity: sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==} + peerDependencies: + react: '>=16.8.0' + + '@emotion/utils@1.4.2': + resolution: {integrity: sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==} + + '@emotion/weak-memoize@0.4.0': + resolution: {integrity: sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==} + '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} deprecated: 'Merged into tsx: https://tsx.is' @@ -1247,6 +1322,9 @@ packages: resolution: {integrity: sha512-zSkKow6H5Kdm0ZUQUB2kV5JIXqoG0+uH5YADhaEHswm664N9Db8dXSi0nMJpacpMf+MyyglF1vnZohpEg5yUtg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@fontsource/roboto@5.1.1': + resolution: {integrity: sha512-XwVVXtERDQIM7HPUIbyDe0FP4SRovpjF7zMI8M7pbqFp3ahLJsJTd18h+E6pkar6UbV3btbwkKjYARr5M+SQow==} + '@hapi/hoek@9.3.0': resolution: {integrity: sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==} @@ -1413,6 +1491,86 @@ packages: '@types/react': 19.0.1 react: '>=16' + '@mui/core-downloads-tracker@6.4.0': + resolution: {integrity: sha512-6u74wi+9zeNlukrCtYYET8Ed/n9AS27DiaXCZKAD3TRGFaqiyYSsQgN2disW83pI/cM1Q2lJY1JX4YfwvNtlNw==} + + '@mui/material@6.4.0': + resolution: {integrity: sha512-hNIgwdM9U3DNmowZ8mU59oFmWoDKjc92FqQnQva3Pxh6xRKWtD2Ej7POUHMX8Dwr1OpcSUlT2+tEMeLb7WYsIg==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@emotion/react': ^11.5.0 + '@emotion/styled': ^11.3.0 + '@mui/material-pigment-css': ^6.4.0 + '@types/react': 19.0.1 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/react': + optional: true + '@emotion/styled': + optional: true + '@mui/material-pigment-css': + optional: true + '@types/react': + optional: true + + '@mui/private-theming@6.4.0': + resolution: {integrity: sha512-rNHci8MP6NOdEWAfZ/RBMO5Rhtp1T6fUDMSmingg9F1T6wiUeodIQ+NuTHh2/pMoUSeP9GdHdgMhMmfsXxOMuw==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@types/react': 19.0.1 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + '@mui/styled-engine@6.4.0': + resolution: {integrity: sha512-ek/ZrDujrger12P6o4luQIfRd2IziH7jQod2WMbLqGE03Iy0zUwYmckRTVhRQTLPNccpD8KXGcALJF+uaUQlbg==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@emotion/react': ^11.4.1 + '@emotion/styled': ^11.3.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/react': + optional: true + '@emotion/styled': + optional: true + + '@mui/system@6.4.0': + resolution: {integrity: sha512-wTDyfRlaZCo2sW2IuOsrjeE5dl0Usrs6J7DxE3GwNCVFqS5wMplM2YeNiV3DO7s53RfCqbho+gJY6xaB9KThUA==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@emotion/react': ^11.5.0 + '@emotion/styled': ^11.3.0 + '@types/react': 19.0.1 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/react': + optional: true + '@emotion/styled': + optional: true + '@types/react': + optional: true + + '@mui/types@7.2.21': + resolution: {integrity: sha512-6HstngiUxNqLU+/DPqlUJDIPbzUBxIVHb1MmXP0eTWDIROiCR2viugXpEif0PPe2mLqqakPzzRClWAnK+8UJww==} + peerDependencies: + '@types/react': 19.0.1 + peerDependenciesMeta: + '@types/react': + optional: true + + '@mui/utils@6.4.0': + resolution: {integrity: sha512-woOTATWNsTNR3YBh2Ixkj3l5RaxSiGoC9G8gOpYoFw1mZM77LWJeuMHFax7iIW4ahK0Cr35TF9DKtrafJmOmNQ==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@types/react': 19.0.1 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@neondatabase/serverless@0.10.4': resolution: {integrity: sha512-2nZuh3VUO9voBauuh+IGYRhGU/MskWHt1IuZvHcJw6GLjDgtqj/KViKo7SIrLdGLdot7vFbiRRw+BgEy3wT9HA==} @@ -2068,6 +2226,9 @@ packages: '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + '@types/lodash@4.17.14': + resolution: {integrity: sha512-jsxagdikDiDBeIRaPYtArcT8my4tN1og7MtMRquFT3XNA6axxyHDRUemqDz/taRDdOUn0GnGHRCuff4q48sW9A==} + '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -2106,6 +2267,11 @@ packages: peerDependencies: '@types/react': 19.0.1 + '@types/react-transition-group@4.4.12': + resolution: {integrity: sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==} + peerDependencies: + '@types/react': 19.0.1 + '@types/react@19.0.1': resolution: {integrity: sha512-YW6614BDhqbpR5KtUYzTA+zlA7nayzJRA9ljz9CQoxthR0sDisYZLuvSMsil36t4EH/uAt8T52Xb4sVw17G+SQ==} @@ -2494,6 +2660,10 @@ packages: '@babel/core': ^7.12.0 webpack: '>=5' + babel-plugin-macros@3.1.0: + resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==} + engines: {node: '>=10', npm: '>=6'} + babel-plugin-polyfill-corejs2@0.4.12: resolution: {integrity: sha512-CPWT6BwvhrTO2d8QVorhTCQw9Y43zOu7G9HigcfxvepOU6b8o3tcWad6oVgZIsZCTt42FFv97aA7ZJsbM4+8og==} peerDependencies: @@ -2702,6 +2872,10 @@ packages: resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==} engines: {node: '>=6'} + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -3430,6 +3604,9 @@ packages: resolution: {integrity: sha512-9ZonPT4ZAK4a+1pUPVPZJapbi7O5qbbJPdYw/NOQWZZbVLdDTYM3A4R9z/DpAM08IDaFGsvPgiGZ82WEwUDWjg==} engines: {node: '>=14.16'} + find-root@1.1.0: + resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==} + find-up@4.1.0: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} @@ -3638,6 +3815,9 @@ packages: hmac-drbg@1.0.1: resolution: {integrity: sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==} + hoist-non-react-statics@3.3.2: + resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + html-encoding-sniffer@4.0.0: resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} engines: {node: '>=18'} @@ -4847,6 +5027,12 @@ packages: peerDependencies: react: ^19.0.0 + react-hotkeys-hook@4.6.1: + resolution: {integrity: sha512-XlZpbKUj9tkfgPgT9gA+1p7Ey6vFIZHttUjPqpTdyT5nqQ8mHL7elxvSbaC+dpSiHUSmr21Ya1mDxBZG3aje4Q==} + peerDependencies: + react: '>=16.8.1' + react-dom: '>=16.8.1' + react-icons@5.4.0: resolution: {integrity: sha512-7eltJxgVt7X64oHh6wSWNwwbKTCtMfK35hcjvJS0yxEAhPM8oUKdS3+kqaW1vicIltw+kR2unHaa12S9pPALoQ==} peerDependencies: @@ -4858,6 +5044,9 @@ packages: react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-is@19.0.0: + resolution: {integrity: sha512-H91OHcwjZsbq3ClIDHMzBShc1rotbfACdWENsmEf0IFvZ3FgGPtdHMcsv45bQ1hAbgdfiA8SnxTKfDS+x/8m2g==} + react-lifecycles-compat@3.0.4: resolution: {integrity: sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==} @@ -4877,6 +5066,12 @@ packages: resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} engines: {node: '>=0.10.0'} + react-transition-group@4.4.5: + resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} + peerDependencies: + react: '>=16.6.0' + react-dom: '>=16.6.0' + react@18.3.1: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} @@ -5148,6 +5343,10 @@ packages: source-map-support@0.5.21: resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + source-map@0.5.7: + resolution: {integrity: sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==} + engines: {node: '>=0.10.0'} + source-map@0.6.1: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} @@ -5282,6 +5481,9 @@ packages: babel-plugin-macros: optional: true + stylis@4.2.0: + resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==} + sucrase@3.35.0: resolution: {integrity: sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==} engines: {node: '>=16 || 14 >=14.17'} @@ -6591,6 +6793,89 @@ snapshots: tslib: 2.8.1 optional: true + '@emotion/babel-plugin@11.13.5': + dependencies: + '@babel/helper-module-imports': 7.25.9 + '@babel/runtime': 7.26.0 + '@emotion/hash': 0.9.2 + '@emotion/memoize': 0.9.0 + '@emotion/serialize': 1.3.3 + babel-plugin-macros: 3.1.0 + convert-source-map: 1.9.0 + escape-string-regexp: 4.0.0 + find-root: 1.1.0 + source-map: 0.5.7 + stylis: 4.2.0 + transitivePeerDependencies: + - supports-color + + '@emotion/cache@11.14.0': + dependencies: + '@emotion/memoize': 0.9.0 + '@emotion/sheet': 1.4.0 + '@emotion/utils': 1.4.2 + '@emotion/weak-memoize': 0.4.0 + stylis: 4.2.0 + + '@emotion/hash@0.9.2': {} + + '@emotion/is-prop-valid@1.3.1': + dependencies: + '@emotion/memoize': 0.9.0 + + '@emotion/memoize@0.9.0': {} + + '@emotion/react@11.14.0(@types/react@19.0.1)(react@19.0.0)': + dependencies: + '@babel/runtime': 7.26.0 + '@emotion/babel-plugin': 11.13.5 + '@emotion/cache': 11.14.0 + '@emotion/serialize': 1.3.3 + '@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@19.0.0) + '@emotion/utils': 1.4.2 + '@emotion/weak-memoize': 0.4.0 + hoist-non-react-statics: 3.3.2 + react: 19.0.0 + optionalDependencies: + '@types/react': 19.0.1 + transitivePeerDependencies: + - supports-color + + '@emotion/serialize@1.3.3': + dependencies: + '@emotion/hash': 0.9.2 + '@emotion/memoize': 0.9.0 + '@emotion/unitless': 0.10.0 + '@emotion/utils': 1.4.2 + csstype: 3.1.3 + + '@emotion/sheet@1.4.0': {} + + '@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@19.0.1)(react@19.0.0))(@types/react@19.0.1)(react@19.0.0)': + dependencies: + '@babel/runtime': 7.26.0 + '@emotion/babel-plugin': 11.13.5 + '@emotion/is-prop-valid': 1.3.1 + '@emotion/react': 11.14.0(@types/react@19.0.1)(react@19.0.0) + '@emotion/serialize': 1.3.3 + '@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@19.0.0) + '@emotion/utils': 1.4.2 + react: 19.0.0 + optionalDependencies: + '@types/react': 19.0.1 + transitivePeerDependencies: + - supports-color + + '@emotion/unitless@0.10.0': {} + + '@emotion/use-insertion-effect-with-fallbacks@1.2.0(react@19.0.0)': + dependencies: + react: 19.0.0 + + '@emotion/utils@1.4.2': {} + + '@emotion/weak-memoize@0.4.0': {} + '@esbuild-kit/core-utils@3.3.2': dependencies: esbuild: 0.18.20 @@ -6846,6 +7131,8 @@ snapshots: dependencies: levn: 0.4.1 + '@fontsource/roboto@5.1.1': {} + '@hapi/hoek@9.3.0': {} '@hapi/topo@5.1.0': @@ -7000,6 +7287,83 @@ snapshots: react: 19.0.0 optional: true + '@mui/core-downloads-tracker@6.4.0': {} + + '@mui/material@6.4.0(@emotion/react@11.14.0(@types/react@19.0.1)(react@19.0.0))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@19.0.1)(react@19.0.0))(@types/react@19.0.1)(react@19.0.0))(@types/react@19.0.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@babel/runtime': 7.26.0 + '@mui/core-downloads-tracker': 6.4.0 + '@mui/system': 6.4.0(@emotion/react@11.14.0(@types/react@19.0.1)(react@19.0.0))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@19.0.1)(react@19.0.0))(@types/react@19.0.1)(react@19.0.0))(@types/react@19.0.1)(react@19.0.0) + '@mui/types': 7.2.21(@types/react@19.0.1) + '@mui/utils': 6.4.0(@types/react@19.0.1)(react@19.0.0) + '@popperjs/core': 2.11.8 + '@types/react-transition-group': 4.4.12(@types/react@19.0.1) + clsx: 2.1.1 + csstype: 3.1.3 + prop-types: 15.8.1 + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + react-is: 19.0.0 + react-transition-group: 4.4.5(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + optionalDependencies: + '@emotion/react': 11.14.0(@types/react@19.0.1)(react@19.0.0) + '@emotion/styled': 11.14.0(@emotion/react@11.14.0(@types/react@19.0.1)(react@19.0.0))(@types/react@19.0.1)(react@19.0.0) + '@types/react': 19.0.1 + + '@mui/private-theming@6.4.0(@types/react@19.0.1)(react@19.0.0)': + dependencies: + '@babel/runtime': 7.26.0 + '@mui/utils': 6.4.0(@types/react@19.0.1)(react@19.0.0) + prop-types: 15.8.1 + react: 19.0.0 + optionalDependencies: + '@types/react': 19.0.1 + + '@mui/styled-engine@6.4.0(@emotion/react@11.14.0(@types/react@19.0.1)(react@19.0.0))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@19.0.1)(react@19.0.0))(@types/react@19.0.1)(react@19.0.0))(react@19.0.0)': + dependencies: + '@babel/runtime': 7.26.0 + '@emotion/cache': 11.14.0 + '@emotion/serialize': 1.3.3 + '@emotion/sheet': 1.4.0 + csstype: 3.1.3 + prop-types: 15.8.1 + react: 19.0.0 + optionalDependencies: + '@emotion/react': 11.14.0(@types/react@19.0.1)(react@19.0.0) + '@emotion/styled': 11.14.0(@emotion/react@11.14.0(@types/react@19.0.1)(react@19.0.0))(@types/react@19.0.1)(react@19.0.0) + + '@mui/system@6.4.0(@emotion/react@11.14.0(@types/react@19.0.1)(react@19.0.0))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@19.0.1)(react@19.0.0))(@types/react@19.0.1)(react@19.0.0))(@types/react@19.0.1)(react@19.0.0)': + dependencies: + '@babel/runtime': 7.26.0 + '@mui/private-theming': 6.4.0(@types/react@19.0.1)(react@19.0.0) + '@mui/styled-engine': 6.4.0(@emotion/react@11.14.0(@types/react@19.0.1)(react@19.0.0))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@19.0.1)(react@19.0.0))(@types/react@19.0.1)(react@19.0.0))(react@19.0.0) + '@mui/types': 7.2.21(@types/react@19.0.1) + '@mui/utils': 6.4.0(@types/react@19.0.1)(react@19.0.0) + clsx: 2.1.1 + csstype: 3.1.3 + prop-types: 15.8.1 + react: 19.0.0 + optionalDependencies: + '@emotion/react': 11.14.0(@types/react@19.0.1)(react@19.0.0) + '@emotion/styled': 11.14.0(@emotion/react@11.14.0(@types/react@19.0.1)(react@19.0.0))(@types/react@19.0.1)(react@19.0.0) + '@types/react': 19.0.1 + + '@mui/types@7.2.21(@types/react@19.0.1)': + optionalDependencies: + '@types/react': 19.0.1 + + '@mui/utils@6.4.0(@types/react@19.0.1)(react@19.0.0)': + dependencies: + '@babel/runtime': 7.26.0 + '@mui/types': 7.2.21(@types/react@19.0.1) + '@types/prop-types': 15.7.14 + clsx: 2.1.1 + prop-types: 15.8.1 + react: 19.0.0 + react-is: 19.0.0 + optionalDependencies: + '@types/react': 19.0.1 + '@neondatabase/serverless@0.10.4': dependencies: '@types/pg': 8.11.6 @@ -7801,6 +8165,8 @@ snapshots: '@types/json5@0.0.29': {} + '@types/lodash@4.17.14': {} + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 @@ -7846,6 +8212,10 @@ snapshots: dependencies: '@types/react': 19.0.1 + '@types/react-transition-group@4.4.12(@types/react@19.0.1)': + dependencies: + '@types/react': 19.0.1 + '@types/react@19.0.1': dependencies: csstype: 3.1.3 @@ -8349,6 +8719,12 @@ snapshots: schema-utils: 4.2.0 webpack: 5.97.1(esbuild@0.21.5) + babel-plugin-macros@3.1.0: + dependencies: + '@babel/runtime': 7.26.0 + cosmiconfig: 7.1.0 + resolve: 1.22.8 + babel-plugin-polyfill-corejs2@0.4.12(@babel/core@7.26.0): dependencies: '@babel/compat-data': 7.26.3 @@ -8578,6 +8954,8 @@ snapshots: clsx@1.2.1: {} + clsx@2.1.1: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -9437,6 +9815,8 @@ snapshots: common-path-prefix: 3.0.0 pkg-dir: 7.0.0 + find-root@1.1.0: {} + find-up@4.1.0: dependencies: locate-path: 5.0.0 @@ -9734,6 +10114,10 @@ snapshots: minimalistic-assert: 1.0.1 minimalistic-crypto-utils: 1.0.1 + hoist-non-react-statics@3.3.2: + dependencies: + react-is: 16.13.1 + html-encoding-sniffer@4.0.0: dependencies: whatwg-encoding: 3.1.1 @@ -11190,6 +11574,11 @@ snapshots: react: 19.0.0 scheduler: 0.25.0 + react-hotkeys-hook@4.6.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0): + dependencies: + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + react-icons@5.4.0(react@19.0.0): dependencies: react: 19.0.0 @@ -11198,6 +11587,8 @@ snapshots: react-is@17.0.2: {} + react-is@19.0.0: {} + react-lifecycles-compat@3.0.4: {} react-markdown@9.0.1(@types/react@19.0.1)(react@19.0.0): @@ -11232,6 +11623,15 @@ snapshots: react-refresh@0.14.2: {} + react-transition-group@4.4.5(react-dom@19.0.0(react@19.0.0))(react@19.0.0): + dependencies: + '@babel/runtime': 7.26.0 + dom-helpers: 5.2.1 + loose-envify: 1.4.0 + prop-types: 15.8.1 + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + react@18.3.1: dependencies: loose-envify: 1.4.0 @@ -11609,6 +12009,8 @@ snapshots: buffer-from: 1.1.2 source-map: 0.6.1 + source-map@0.5.7: {} + source-map@0.6.1: {} source-map@0.7.4: {} @@ -11756,6 +12158,8 @@ snapshots: optionalDependencies: '@babel/core': 7.26.0 + stylis@4.2.0: {} + sucrase@3.35.0: dependencies: '@jridgewell/gen-mapping': 0.3.5 From 1f6fb93a089205c2d3edccba95167a39dc9d8077 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matth=C3=A4us=20Mayer?= <7984982+theMattCode@users.noreply.github.com> Date: Sat, 1 Feb 2025 14:09:33 +0100 Subject: [PATCH 07/16] Fix dependabot config --- .github/dependabot.yml | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index bebfa9f..5075d13 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,11 +5,7 @@ updates: schedule: interval: "weekly" groups: - dependencies: - dependency-types: "production" - patterns: - - "*" - devDependencies: - dependency-types: "development" - patterns: - - "*" + dev-deps: + dependency-type: "development" + prod-deps: + dependency-type: "production" From 72c32884fa97831216b139ea762c0be82f057008 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matth=C3=A4us=20Mayer?= <7984982+theMattCode@users.noreply.github.com> Date: Sat, 1 Feb 2025 21:26:35 +0100 Subject: [PATCH 08/16] Fetch people on client with a revalidation tag --- app/(cms)/cms/api/people/route.ts | 16 ++++++++++++++++ app/(cms)/cms/people/PeopleList.tsx | 18 +++++++++++++++--- app/(cms)/cms/people/PeopleListCard.tsx | 21 ++------------------- app/(cms)/cms/people/[id]/actions.ts | 2 -- 4 files changed, 33 insertions(+), 24 deletions(-) create mode 100644 app/(cms)/cms/api/people/route.ts diff --git a/app/(cms)/cms/api/people/route.ts b/app/(cms)/cms/api/people/route.ts new file mode 100644 index 0000000..aecff4e --- /dev/null +++ b/app/(cms)/cms/api/people/route.ts @@ -0,0 +1,16 @@ +import { NextRequest, NextResponse } from "next/server"; +import { drizzle } from "#/lib/db/drizzle"; +import { PersonWithRoles } from "#/lib/types/people"; + +export async function GET(request: NextRequest) { + const roles = await drizzle.query.people.findMany({ + with: { + peopleToRoles: { + with: { + roles: true, + }, + }, + }, + }); + return NextResponse.json<{ roles: PersonWithRoles[] }>({ roles }); +} diff --git a/app/(cms)/cms/people/PeopleList.tsx b/app/(cms)/cms/people/PeopleList.tsx index 5ca568f..8d01c65 100644 --- a/app/(cms)/cms/people/PeopleList.tsx +++ b/app/(cms)/cms/people/PeopleList.tsx @@ -7,7 +7,7 @@ import { Table } from "#/components/cms/table/Table"; import { PhoneCell } from "#/components/cms/table/cell/Phone"; import { MailCell } from "#/components/cms/table/cell/Mail"; import { RolesCell } from "#/components/cms/table/cell/Roles"; -import { useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { FaRegEdit } from "react-icons/fa"; import { CardToolbar } from "#/components/cms/card/Card"; import { debounce } from "lodash"; @@ -55,7 +55,19 @@ const COLUMN_ACTIONS = COLUMN_HELPER.display({ cell: Actions, }); -export default function PeopleList({ data }: { data: PersonWithRoles[] }) { +export default function PeopleList() { + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + async function fetchPeople() { + const res = await fetch("/cms/api/people", { next: { tags: ["cms/api/people"] } }); + const data = await res.json(); + setData(data.roles); + } + fetchPeople().finally(() => setLoading(false)); + }, []); + const router = useRouter(); const [searchTerm, setSearchTerm] = useState(); @@ -111,7 +123,7 @@ export default function PeopleList({ data }: { data: PersonWithRoles[] }) { Neue Person -
+
); } diff --git a/app/(cms)/cms/people/PeopleListCard.tsx b/app/(cms)/cms/people/PeopleListCard.tsx index 85d3257..e2e64a1 100644 --- a/app/(cms)/cms/people/PeopleListCard.tsx +++ b/app/(cms)/cms/people/PeopleListCard.tsx @@ -1,31 +1,14 @@ -import { Card, CardContent, CardHeader, CardTitle, CardToolbar } from "#/components/cms/card/Card"; +import { Card, CardContent, CardHeader, CardTitle } from "#/components/cms/card/Card"; import PeopleList from "#/app/(cms)/cms/people/PeopleList"; -import { PersonWithRoles } from "#/lib/types/people"; -import { drizzle } from "#/lib/db/drizzle"; -import { TextField } from "#/components/cms/input/TextField"; - -async function getData(): Promise { - return await drizzle.query.people.findMany({ - with: { - peopleToRoles: { - with: { - roles: true, - }, - }, - }, - }); -} export async function PeopleListCard() { - const data = await getData(); - return ( Personen - + ); diff --git a/app/(cms)/cms/people/[id]/actions.ts b/app/(cms)/cms/people/[id]/actions.ts index fbfdc73..c3a4f00 100644 --- a/app/(cms)/cms/people/[id]/actions.ts +++ b/app/(cms)/cms/people/[id]/actions.ts @@ -33,8 +33,6 @@ export const deletePerson = async (id: string): Promise => { return { type: "success" }; }; -export const getAllRoles = async () => await drizzle.query.roles.findMany(); - export const createRole = async (role: Omit = { name: "" }): Promise => { const newRole = await drizzle.insert(roles).values({ id: crypto.randomUUID(), name: role.name }).returning(); return { type: "success", role: { ...newRole[0] } }; From 9d2d4e8157ed66f9c4ee008c3dea4047ca0e1177 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matth=C3=A4us=20Mayer?= <7984982+theMattCode@users.noreply.github.com> Date: Sat, 1 Feb 2025 21:42:51 +0100 Subject: [PATCH 09/16] Fetch people on server with a revalidation tag --- app/(cms)/cms/people/PeopleList.tsx | 30 ++++++++----------------- app/(cms)/cms/people/PeopleListCard.tsx | 11 ++++++++- 2 files changed, 19 insertions(+), 22 deletions(-) diff --git a/app/(cms)/cms/people/PeopleList.tsx b/app/(cms)/cms/people/PeopleList.tsx index 8d01c65..faad525 100644 --- a/app/(cms)/cms/people/PeopleList.tsx +++ b/app/(cms)/cms/people/PeopleList.tsx @@ -1,13 +1,13 @@ "use client"; import * as React from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { PersonWithRoles } from "#/lib/types/people"; -import { CellContext, createColumnHelper, getCoreRowModel, Row, TableOptions } from "@tanstack/table-core"; +import { CellContext, createColumnHelper, getCoreRowModel, TableOptions } from "@tanstack/table-core"; import { Table } from "#/components/cms/table/Table"; import { PhoneCell } from "#/components/cms/table/cell/Phone"; import { MailCell } from "#/components/cms/table/cell/Mail"; import { RolesCell } from "#/components/cms/table/cell/Roles"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { FaRegEdit } from "react-icons/fa"; import { CardToolbar } from "#/components/cms/card/Card"; import { debounce } from "lodash"; @@ -55,25 +55,13 @@ const COLUMN_ACTIONS = COLUMN_HELPER.display({ cell: Actions, }); -export default function PeopleList() { - const [data, setData] = useState([]); - const [loading, setLoading] = useState(true); - - useEffect(() => { - async function fetchPeople() { - const res = await fetch("/cms/api/people", { next: { tags: ["cms/api/people"] } }); - const data = await res.json(); - setData(data.roles); - } - fetchPeople().finally(() => setLoading(false)); - }, []); - +export default function PeopleList({ people }: { people: PersonWithRoles[] }) { const router = useRouter(); const [searchTerm, setSearchTerm] = useState(); - const filteredData = useMemo( + const filteredPeople = useMemo( () => - data.filter((person) => { + people.filter((person) => { if (!searchTerm) return true; const firstName = person.firstName?.toLowerCase(); @@ -85,17 +73,17 @@ export default function PeopleList() { if (person.email?.toLowerCase().includes(searchTerm)) return true; return person.peopleToRoles.some((p) => p.roles.name?.toLowerCase().includes(searchTerm) ?? false); }), - [searchTerm, data], + [searchTerm, people], ); const options: TableOptions = useMemo( () => ({ - data: filteredData, + data: filteredPeople, columns: [COLUMN_FIRSTNAME, COLUMN_LASTNAME, COLUMN_EMAIL, COLUMN_PHONE, COLUMN_ROLES, COLUMN_ACTIONS], getRowId: (person) => person.id, getCoreRowModel: getCoreRowModel(), }), - [filteredData], + [filteredPeople], ); const onChange = useCallback( @@ -123,7 +111,7 @@ export default function PeopleList() { Neue Person -
+
); } diff --git a/app/(cms)/cms/people/PeopleListCard.tsx b/app/(cms)/cms/people/PeopleListCard.tsx index e2e64a1..4929152 100644 --- a/app/(cms)/cms/people/PeopleListCard.tsx +++ b/app/(cms)/cms/people/PeopleListCard.tsx @@ -1,14 +1,23 @@ import { Card, CardContent, CardHeader, CardTitle } from "#/components/cms/card/Card"; import PeopleList from "#/app/(cms)/cms/people/PeopleList"; +import { PersonWithRoles } from "#/lib/types/people"; + +async function getPeople(): Promise { + const res = await fetch("/cms/api/people", { next: { tags: ["cms/api/people"] } }); + const data = await res.json(); + return data.roles; +} export async function PeopleListCard() { + const people = await getPeople(); + return ( Personen - + ); From b55bd2cce4478c201e064fe788fa696c04dcae80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matth=C3=A4us=20Mayer?= <7984982+theMattCode@users.noreply.github.com> Date: Sat, 1 Feb 2025 21:59:26 +0100 Subject: [PATCH 10/16] Revalidate people list when person changes --- app/(cms)/cms/people/PeopleListCard.tsx | 11 ++++++++--- app/(cms)/cms/people/[id]/actions.ts | 4 ++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/app/(cms)/cms/people/PeopleListCard.tsx b/app/(cms)/cms/people/PeopleListCard.tsx index 4929152..9f7edcc 100644 --- a/app/(cms)/cms/people/PeopleListCard.tsx +++ b/app/(cms)/cms/people/PeopleListCard.tsx @@ -1,11 +1,16 @@ import { Card, CardContent, CardHeader, CardTitle } from "#/components/cms/card/Card"; import PeopleList from "#/app/(cms)/cms/people/PeopleList"; import { PersonWithRoles } from "#/lib/types/people"; +import { drizzle } from "#/lib/db/drizzle"; async function getPeople(): Promise { - const res = await fetch("/cms/api/people", { next: { tags: ["cms/api/people"] } }); - const data = await res.json(); - return data.roles; + return await drizzle.query.people.findMany({ + with: { + peopleToRoles: { + with: { roles: true }, + }, + }, + }); } export async function PeopleListCard() { diff --git a/app/(cms)/cms/people/[id]/actions.ts b/app/(cms)/cms/people/[id]/actions.ts index c3a4f00..d5f582e 100644 --- a/app/(cms)/cms/people/[id]/actions.ts +++ b/app/(cms)/cms/people/[id]/actions.ts @@ -5,6 +5,7 @@ import { drizzle } from "#/lib/db/drizzle"; import { people, peopleToRoles, roles } from "#/lib/db/schema"; import { and, eq } from "drizzle-orm"; import { Person, Role } from "#/lib/types/people"; +import { revalidatePath } from "next/cache"; export const mutatePerson = async (id: string, person: Partial>): Promise => { await drizzle.update(people).set(person).where(eq(people.id, id)); @@ -25,11 +26,13 @@ export const createPerson = async ( image: person.image, }) .returning(); + revalidatePath("/cms/people"); return { type: "success", person: { ...newPerson[0] } }; }; export const deletePerson = async (id: string): Promise => { await drizzle.delete(people).where(eq(people.id, id)); + revalidatePath("/cms/people"); return { type: "success" }; }; @@ -40,6 +43,7 @@ export const createRole = async (role: Omit = { name: "" }): Promise export const assignRole = async (peopleId: string, roleId: string): Promise => { await drizzle.insert(peopleToRoles).values({ peopleId, roleId }).onConflictDoNothing().returning(); + revalidatePath("/cms/people"); return { type: "success" }; }; From 73dfc4a00508356f09c774bd265d639f16e56be9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matth=C3=A4us=20Mayer?= <7984982+theMattCode@users.noreply.github.com> Date: Sat, 1 Feb 2025 22:08:01 +0100 Subject: [PATCH 11/16] Refresh people page on delete --- app/(cms)/cms/people/PeopleList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/(cms)/cms/people/PeopleList.tsx b/app/(cms)/cms/people/PeopleList.tsx index faad525..a43b877 100644 --- a/app/(cms)/cms/people/PeopleList.tsx +++ b/app/(cms)/cms/people/PeopleList.tsx @@ -132,7 +132,7 @@ function Actions(cellContext: CellContext { await deletePerson(cellContext.row.original.id); - router.push(`/cms/people`); + router.refresh(); }} > From 9d02630bec0a8497698de1dd071aca9f952158fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matth=C3=A4us=20Mayer?= <7984982+theMattCode@users.noreply.github.com> Date: Tue, 4 Feb 2025 07:51:46 +0100 Subject: [PATCH 12/16] Improve data fetching and adding people --- app/(cms)/cms/people/PeopleList.tsx | 7 +++---- app/(cms)/cms/people/PeopleListCard.tsx | 15 +-------------- app/(cms)/cms/people/{[id] => }/actions.ts | 1 - app/(cms)/cms/people/{ => add}/[id]/page.tsx | 3 +-- .../people/{[id] => add/[id]/section}/Details.tsx | 4 ++-- .../people/{[id] => add/[id]/section}/Edit.tsx | 8 ++++---- .../people/{[id] => add/[id]/section}/Picture.tsx | 2 +- .../people/{[id] => add/[id]/section}/Preview.tsx | 0 .../people/{[id] => add/[id]/section}/Roles.tsx | 2 +- app/(cms)/cms/people/add/loading.tsx | 9 +++++++++ app/(cms)/cms/people/add/page.tsx | 7 +++++++ app/(cms)/cms/people/loading.tsx | 9 +++++++++ app/(cms)/cms/people/page.tsx | 15 ++++++++++++++- 13 files changed, 52 insertions(+), 30 deletions(-) rename app/(cms)/cms/people/{[id] => }/actions.ts (98%) rename app/(cms)/cms/people/{ => add}/[id]/page.tsx (93%) rename app/(cms)/cms/people/{[id] => add/[id]/section}/Details.tsx (95%) rename app/(cms)/cms/people/{[id] => add/[id]/section}/Edit.tsx (80%) rename app/(cms)/cms/people/{[id] => add/[id]/section}/Picture.tsx (96%) rename app/(cms)/cms/people/{[id] => add/[id]/section}/Preview.tsx (100%) rename app/(cms)/cms/people/{[id] => add/[id]/section}/Roles.tsx (99%) create mode 100644 app/(cms)/cms/people/add/loading.tsx create mode 100644 app/(cms)/cms/people/add/page.tsx create mode 100644 app/(cms)/cms/people/loading.tsx diff --git a/app/(cms)/cms/people/PeopleList.tsx b/app/(cms)/cms/people/PeopleList.tsx index a43b877..21c05a5 100644 --- a/app/(cms)/cms/people/PeopleList.tsx +++ b/app/(cms)/cms/people/PeopleList.tsx @@ -15,7 +15,7 @@ import { LuSearch } from "react-icons/lu"; import { MdOutlineDelete, MdOutlinePersonAdd } from "react-icons/md"; import { Button, IconButton } from "@mui/material"; import { TextField } from "#/components/cms/input/TextField"; -import { createPerson, deletePerson } from "#/app/(cms)/cms/people/[id]/actions"; +import { deletePerson } from "#/app/(cms)/cms/people/actions"; import { useRouter } from "next/navigation"; import { useHotkeys } from "react-hotkeys-hook"; @@ -92,8 +92,7 @@ export default function PeopleList({ people }: { people: PersonWithRoles[] }) { ); const onNewPerson = useCallback(async () => { - const person = await createPerson(); - router.push(`/cms/people/${person.person.id}`); + router.push("/cms/people/add"); }, [router]); useHotkeys("alt+n", onNewPerson); @@ -107,7 +106,7 @@ export default function PeopleList({ people }: { people: PersonWithRoles[] }) { <> - diff --git a/app/(cms)/cms/people/PeopleListCard.tsx b/app/(cms)/cms/people/PeopleListCard.tsx index 9f7edcc..7b789ff 100644 --- a/app/(cms)/cms/people/PeopleListCard.tsx +++ b/app/(cms)/cms/people/PeopleListCard.tsx @@ -1,21 +1,8 @@ import { Card, CardContent, CardHeader, CardTitle } from "#/components/cms/card/Card"; import PeopleList from "#/app/(cms)/cms/people/PeopleList"; import { PersonWithRoles } from "#/lib/types/people"; -import { drizzle } from "#/lib/db/drizzle"; - -async function getPeople(): Promise { - return await drizzle.query.people.findMany({ - with: { - peopleToRoles: { - with: { roles: true }, - }, - }, - }); -} - -export async function PeopleListCard() { - const people = await getPeople(); +export async function PeopleListCard({ people }: { people: PersonWithRoles[] }) { return ( diff --git a/app/(cms)/cms/people/[id]/actions.ts b/app/(cms)/cms/people/actions.ts similarity index 98% rename from app/(cms)/cms/people/[id]/actions.ts rename to app/(cms)/cms/people/actions.ts index d5f582e..0abe9ab 100644 --- a/app/(cms)/cms/people/[id]/actions.ts +++ b/app/(cms)/cms/people/actions.ts @@ -26,7 +26,6 @@ export const createPerson = async ( image: person.image, }) .returning(); - revalidatePath("/cms/people"); return { type: "success", person: { ...newPerson[0] } }; }; diff --git a/app/(cms)/cms/people/[id]/page.tsx b/app/(cms)/cms/people/add/[id]/page.tsx similarity index 93% rename from app/(cms)/cms/people/[id]/page.tsx rename to app/(cms)/cms/people/add/[id]/page.tsx index 471e993..efe7e8e 100644 --- a/app/(cms)/cms/people/[id]/page.tsx +++ b/app/(cms)/cms/people/add/[id]/page.tsx @@ -5,7 +5,7 @@ import { sql } from "drizzle-orm"; import { PersonWithRoles } from "#/lib/types/people"; import { cache } from "react"; import { notFound } from "next/navigation"; -import { Edit } from "#/app/(cms)/cms/people/[id]/Edit"; +import { Edit } from "#/app/(cms)/cms/people/add/[id]/section/Edit"; import placeholder = sql.placeholder; const preparedPersonStatement = drizzle.query.people @@ -22,7 +22,6 @@ const preparedPersonStatement = drizzle.query.people const getPerson = cache(async (id: string) => { const person = await preparedPersonStatement.execute({ id }); - if (!person) { notFound(); } diff --git a/app/(cms)/cms/people/[id]/Details.tsx b/app/(cms)/cms/people/add/[id]/section/Details.tsx similarity index 95% rename from app/(cms)/cms/people/[id]/Details.tsx rename to app/(cms)/cms/people/add/[id]/section/Details.tsx index 695c837..a281287 100644 --- a/app/(cms)/cms/people/[id]/Details.tsx +++ b/app/(cms)/cms/people/add/[id]/section/Details.tsx @@ -3,11 +3,11 @@ import { PersonWithRoles } from "#/lib/types/people"; import { MutateResult, TextField, TextFieldMutationVariables } from "#/components/cms/input/TextField"; import { MdOutlineEmail, MdOutlinePhone } from "react-icons/md"; -import { mutatePerson } from "#/app/(cms)/cms/people/[id]/actions"; +import { mutatePerson } from "#/app/(cms)/cms/people/actions"; import { useCallback } from "react"; import { MutationFunction, useDebouncedMutation } from "#/lib/action"; import { Alert, AlertTitle, Autocomplete, TextField as MuiTextField } from "@mui/material"; -import { Roles } from "#/app/(cms)/cms/people/[id]/Roles"; +import { Roles } from "#/app/(cms)/cms/people/add/[id]/section/Roles"; export function Details({ person }: { person: PersonWithRoles }) { const preparedMutateFirstNameFn: MutationFunction = useCallback( diff --git a/app/(cms)/cms/people/[id]/Edit.tsx b/app/(cms)/cms/people/add/[id]/section/Edit.tsx similarity index 80% rename from app/(cms)/cms/people/[id]/Edit.tsx rename to app/(cms)/cms/people/add/[id]/section/Edit.tsx index 1a2c8c3..74fe279 100644 --- a/app/(cms)/cms/people/[id]/Edit.tsx +++ b/app/(cms)/cms/people/add/[id]/section/Edit.tsx @@ -3,10 +3,10 @@ import { PersonWithRoles } from "#/lib/types/people"; import { useState } from "react"; import { Card, CardContent, CardHeader, CardTitle } from "#/components/cms/card/Card"; -import { Picture } from "#/app/(cms)/cms/people/[id]/Picture"; -import { Details } from "#/app/(cms)/cms/people/[id]/Details"; -import { Roles } from "#/app/(cms)/cms/people/[id]/Roles"; -import { Preview } from "#/app/(cms)/cms/people/[id]/Preview"; +import { Picture } from "#/app/(cms)/cms/people/add/[id]/section/Picture"; +import { Details } from "#/app/(cms)/cms/people/add/[id]/section/Details"; +import { Roles } from "#/app/(cms)/cms/people/add/[id]/section/Roles"; +import { Preview } from "#/app/(cms)/cms/people/add/[id]/section/Preview"; export function Edit({ person: initialPerson }: { person: PersonWithRoles }) { const [person] = useState(initialPerson); diff --git a/app/(cms)/cms/people/[id]/Picture.tsx b/app/(cms)/cms/people/add/[id]/section/Picture.tsx similarity index 96% rename from app/(cms)/cms/people/[id]/Picture.tsx rename to app/(cms)/cms/people/add/[id]/section/Picture.tsx index df4b6ba..5c1a70c 100644 --- a/app/(cms)/cms/people/[id]/Picture.tsx +++ b/app/(cms)/cms/people/add/[id]/section/Picture.tsx @@ -7,7 +7,7 @@ import { BsPersonBoundingBox } from "react-icons/bs"; import { SiCloudinary } from "react-icons/si"; import { MutationFunction, useDebouncedMutation } from "#/lib/action"; import { useCallback, useState } from "react"; -import { mutatePerson } from "#/app/(cms)/cms/people/[id]/actions"; +import { mutatePerson } from "#/app/(cms)/cms/people/actions"; function validateImageUrl(url: string): boolean { try { diff --git a/app/(cms)/cms/people/[id]/Preview.tsx b/app/(cms)/cms/people/add/[id]/section/Preview.tsx similarity index 100% rename from app/(cms)/cms/people/[id]/Preview.tsx rename to app/(cms)/cms/people/add/[id]/section/Preview.tsx diff --git a/app/(cms)/cms/people/[id]/Roles.tsx b/app/(cms)/cms/people/add/[id]/section/Roles.tsx similarity index 99% rename from app/(cms)/cms/people/[id]/Roles.tsx rename to app/(cms)/cms/people/add/[id]/section/Roles.tsx index 38d18b6..56ecd3f 100644 --- a/app/(cms)/cms/people/[id]/Roles.tsx +++ b/app/(cms)/cms/people/add/[id]/section/Roles.tsx @@ -2,7 +2,7 @@ import { PersonWithRoles, Role } from "#/lib/types/people"; import { Autocomplete, CircularProgress, TextField } from "@mui/material"; import { useState } from "react"; import fetch from "node-fetch"; -import { assignRole, createRole, unassignRole } from "#/app/(cms)/cms/people/[id]/actions"; +import { assignRole, createRole, unassignRole } from "#/app/(cms)/cms/people/actions"; export function Roles({ person }: { person: PersonWithRoles }) { const [open, setOpen] = useState(false); diff --git a/app/(cms)/cms/people/add/loading.tsx b/app/(cms)/cms/people/add/loading.tsx new file mode 100644 index 0000000..470b3dc --- /dev/null +++ b/app/(cms)/cms/people/add/loading.tsx @@ -0,0 +1,9 @@ +import { LinearProgress } from "@mui/material"; + +export default function Loading() { + return ( +
+ +
+ ); +} diff --git a/app/(cms)/cms/people/add/page.tsx b/app/(cms)/cms/people/add/page.tsx new file mode 100644 index 0000000..6ad523e --- /dev/null +++ b/app/(cms)/cms/people/add/page.tsx @@ -0,0 +1,7 @@ +import { createPerson } from "#/app/(cms)/cms/people/actions"; +import { redirect } from "next/navigation"; + +export default async function CreatePersonPage() { + const response = await createPerson(); + redirect(`/cms/people/add/${response.person.id}`); +} diff --git a/app/(cms)/cms/people/loading.tsx b/app/(cms)/cms/people/loading.tsx new file mode 100644 index 0000000..470b3dc --- /dev/null +++ b/app/(cms)/cms/people/loading.tsx @@ -0,0 +1,9 @@ +import { LinearProgress } from "@mui/material"; + +export default function Loading() { + return ( +
+ +
+ ); +} diff --git a/app/(cms)/cms/people/page.tsx b/app/(cms)/cms/people/page.tsx index 95327d8..b28f61b 100644 --- a/app/(cms)/cms/people/page.tsx +++ b/app/(cms)/cms/people/page.tsx @@ -1,5 +1,18 @@ import { PeopleListCard } from "#/app/(cms)/cms/people/PeopleListCard"; +import { PersonWithRoles } from "#/lib/types/people"; +import { drizzle } from "#/lib/db/drizzle"; + +const getPeople = async (): Promise => { + return await drizzle.query.people.findMany({ + with: { + peopleToRoles: { + with: { roles: true }, + }, + }, + }); +}; export default async function PeoplePage() { - return ; + const people = await getPeople(); + return ; } From 3a2ba27d7b637688ea1a9da86018e21dcf9dee39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matth=C3=A4us=20Mayer?= <7984982+theMattCode@users.noreply.github.com> Date: Tue, 4 Feb 2025 08:24:26 +0100 Subject: [PATCH 13/16] Introduce general error page --- app/(cms)/cms/error.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 app/(cms)/cms/error.tsx diff --git a/app/(cms)/cms/error.tsx b/app/(cms)/cms/error.tsx new file mode 100644 index 0000000..745daba --- /dev/null +++ b/app/(cms)/cms/error.tsx @@ -0,0 +1,14 @@ +"use client"; + +import { Alert, AlertTitle } from "@mui/material"; + +export default function Error({ error }: { error: Error & { digest?: string }; reset: () => void }) { + return ( +
+ + Es ist ein Fehler aufgetreten + {error.message} + +
+ ); +} From e0c302f93adf57c8830c5c32b8be6ce8fe12a18d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matth=C3=A4us=20Mayer?= <7984982+theMattCode@users.noreply.github.com> Date: Tue, 4 Feb 2025 08:24:52 +0100 Subject: [PATCH 14/16] Improve structure to allow revalidation --- app/(cms)/cms/api/people/add/route.ts | 29 +++++++++++++++++++ app/(cms)/cms/{people => }/loading.tsx | 0 app/(cms)/cms/people/actions.ts | 18 ++++++++---- .../cms/people/add/[id]/section/Details.tsx | 10 +++---- .../cms/people/add/[id]/section/Picture.tsx | 4 +-- app/(cms)/cms/people/add/page.tsx | 11 +++++-- app/(cms)/cms/people/page.tsx | 15 ++-------- 7 files changed, 58 insertions(+), 29 deletions(-) create mode 100644 app/(cms)/cms/api/people/add/route.ts rename app/(cms)/cms/{people => }/loading.tsx (100%) diff --git a/app/(cms)/cms/api/people/add/route.ts b/app/(cms)/cms/api/people/add/route.ts new file mode 100644 index 0000000..3bfc56a --- /dev/null +++ b/app/(cms)/cms/api/people/add/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from "next/server"; +import { Person } from "#/lib/types/people"; +import { MutateResult } from "#/components/cms/input/TextField"; +import { drizzle } from "#/lib/db/drizzle"; +import { people } from "#/lib/db/schema"; +import { revalidatePath } from "next/cache"; + +export async function POST(request: NextRequest) { + const result = await createPerson(); + return NextResponse.json(result); +} + +const createPerson = async ( + person: Omit = { firstName: "", lastName: "", image: "", phone: "", email: "" }, +): Promise => { + const newPerson = await drizzle + .insert(people) + .values({ + id: crypto.randomUUID(), + firstName: person.firstName, + lastName: person.lastName, + phone: person.phone, + email: person.email, + image: person.image, + }) + .returning(); + revalidatePath("/cms/people"); + return { type: "success", person: { ...newPerson[0] } }; +}; diff --git a/app/(cms)/cms/people/loading.tsx b/app/(cms)/cms/loading.tsx similarity index 100% rename from app/(cms)/cms/people/loading.tsx rename to app/(cms)/cms/loading.tsx diff --git a/app/(cms)/cms/people/actions.ts b/app/(cms)/cms/people/actions.ts index 0abe9ab..35b1a4c 100644 --- a/app/(cms)/cms/people/actions.ts +++ b/app/(cms)/cms/people/actions.ts @@ -4,14 +4,9 @@ import { MutateResult } from "#/components/cms/input/TextField"; import { drizzle } from "#/lib/db/drizzle"; import { people, peopleToRoles, roles } from "#/lib/db/schema"; import { and, eq } from "drizzle-orm"; -import { Person, Role } from "#/lib/types/people"; +import { Person, PersonWithRoles, Role } from "#/lib/types/people"; import { revalidatePath } from "next/cache"; -export const mutatePerson = async (id: string, person: Partial>): Promise => { - await drizzle.update(people).set(person).where(eq(people.id, id)); - return { type: "success" }; -}; - export const createPerson = async ( person: Omit = { firstName: "", lastName: "", image: "", phone: "", email: "" }, ): Promise => { @@ -26,9 +21,20 @@ export const createPerson = async ( image: person.image, }) .returning(); + revalidatePath("/cms/people"); return { type: "success", person: { ...newPerson[0] } }; }; +export const readAllPeople = async (): Promise => { + return await drizzle.query.people.findMany({ with: { peopleToRoles: { with: { roles: true } } } }); +}; + +export const updatePerson = async (id: string, person: Partial>): Promise => { + await drizzle.update(people).set(person).where(eq(people.id, id)); + revalidatePath("/cms/people"); + return { type: "success" }; +}; + export const deletePerson = async (id: string): Promise => { await drizzle.delete(people).where(eq(people.id, id)); revalidatePath("/cms/people"); diff --git a/app/(cms)/cms/people/add/[id]/section/Details.tsx b/app/(cms)/cms/people/add/[id]/section/Details.tsx index a281287..180c9b8 100644 --- a/app/(cms)/cms/people/add/[id]/section/Details.tsx +++ b/app/(cms)/cms/people/add/[id]/section/Details.tsx @@ -3,7 +3,7 @@ import { PersonWithRoles } from "#/lib/types/people"; import { MutateResult, TextField, TextFieldMutationVariables } from "#/components/cms/input/TextField"; import { MdOutlineEmail, MdOutlinePhone } from "react-icons/md"; -import { mutatePerson } from "#/app/(cms)/cms/people/actions"; +import { updatePerson } from "#/app/(cms)/cms/people/actions"; import { useCallback } from "react"; import { MutationFunction, useDebouncedMutation } from "#/lib/action"; import { Alert, AlertTitle, Autocomplete, TextField as MuiTextField } from "@mui/material"; @@ -12,28 +12,28 @@ import { Roles } from "#/app/(cms)/cms/people/add/[id]/section/Roles"; export function Details({ person }: { person: PersonWithRoles }) { const preparedMutateFirstNameFn: MutationFunction = useCallback( async ({ value }: TextFieldMutationVariables): Promise => - mutatePerson(person.id, { firstName: value ?? "" }), + updatePerson(person.id, { firstName: value ?? "" }), [person.id], ); const mutateFirstName = useDebouncedMutation(preparedMutateFirstNameFn); const preparedMutateLastNameFn: MutationFunction = useCallback( async ({ value }: TextFieldMutationVariables): Promise => - mutatePerson(person.id, { lastName: value ?? "" }), + updatePerson(person.id, { lastName: value ?? "" }), [person.id], ); const mutateLastName = useDebouncedMutation(preparedMutateLastNameFn); const preparedMutateEmailFn: MutationFunction = useCallback( async ({ value }: TextFieldMutationVariables): Promise => - mutatePerson(person.id, { email: value ?? "" }), + updatePerson(person.id, { email: value ?? "" }), [person.id], ); const mutateEmail = useDebouncedMutation(preparedMutateEmailFn); const preparedMutatePhoneFn: MutationFunction = useCallback( async ({ value }: TextFieldMutationVariables): Promise => - mutatePerson(person.id, { phone: value ?? "" }), + updatePerson(person.id, { phone: value ?? "" }), [person.id], ); const mutatePhone = useDebouncedMutation(preparedMutatePhoneFn); diff --git a/app/(cms)/cms/people/add/[id]/section/Picture.tsx b/app/(cms)/cms/people/add/[id]/section/Picture.tsx index 5c1a70c..2802bdc 100644 --- a/app/(cms)/cms/people/add/[id]/section/Picture.tsx +++ b/app/(cms)/cms/people/add/[id]/section/Picture.tsx @@ -7,7 +7,7 @@ import { BsPersonBoundingBox } from "react-icons/bs"; import { SiCloudinary } from "react-icons/si"; import { MutationFunction, useDebouncedMutation } from "#/lib/action"; import { useCallback, useState } from "react"; -import { mutatePerson } from "#/app/(cms)/cms/people/actions"; +import { updatePerson } from "#/app/(cms)/cms/people/actions"; function validateImageUrl(url: string): boolean { try { @@ -23,7 +23,7 @@ export function Picture({ person }: { person: PersonWithRoles }) { const preparedMutatePictureFn: MutationFunction = useCallback( async ({ value }: TextFieldMutationVariables): Promise => - mutatePerson(person.id, { image: value ?? "" }), + updatePerson(person.id, { image: value ?? "" }), [person.id], ); const mutateFirstName = useDebouncedMutation((variables) => { diff --git a/app/(cms)/cms/people/add/page.tsx b/app/(cms)/cms/people/add/page.tsx index 6ad523e..2858ec2 100644 --- a/app/(cms)/cms/people/add/page.tsx +++ b/app/(cms)/cms/people/add/page.tsx @@ -1,7 +1,12 @@ -import { createPerson } from "#/app/(cms)/cms/people/actions"; import { redirect } from "next/navigation"; export default async function CreatePersonPage() { - const response = await createPerson(); - redirect(`/cms/people/add/${response.person.id}`); + const response = await fetch(`${process.env.SITE_URL}/cms/api/people/add`, { method: "POST", cache: "no-store" }); + + if (!response.ok) { + throw new Error("Person konnte nicht angelegt werden."); + } + + const result = await response.json(); + redirect(`/cms/people/add/${result.person.id}`); } diff --git a/app/(cms)/cms/people/page.tsx b/app/(cms)/cms/people/page.tsx index b28f61b..999997b 100644 --- a/app/(cms)/cms/people/page.tsx +++ b/app/(cms)/cms/people/page.tsx @@ -1,18 +1,7 @@ import { PeopleListCard } from "#/app/(cms)/cms/people/PeopleListCard"; -import { PersonWithRoles } from "#/lib/types/people"; -import { drizzle } from "#/lib/db/drizzle"; - -const getPeople = async (): Promise => { - return await drizzle.query.people.findMany({ - with: { - peopleToRoles: { - with: { roles: true }, - }, - }, - }); -}; +import { readAllPeople } from "#/app/(cms)/cms/people/actions"; export default async function PeoplePage() { - const people = await getPeople(); + const people = await readAllPeople(); return ; } From fb60f5ccdfea9a340e6792729db9612832a63c1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matth=C3=A4us=20Mayer?= <7984982+theMattCode@users.noreply.github.com> Date: Tue, 4 Feb 2025 08:40:08 +0100 Subject: [PATCH 15/16] Flatten API --- app/(cms)/cms/api/people/add/route.ts | 22 +++---------------- app/(cms)/cms/people/PeopleList.tsx | 5 ++++- app/(cms)/cms/people/{add => }/[id]/page.tsx | 3 ++- .../people/{add => }/[id]/section/Details.tsx | 2 +- .../people/{add => }/[id]/section/Edit.tsx | 7 +++--- .../people/{add => }/[id]/section/Picture.tsx | 0 .../people/{add => }/[id]/section/Preview.tsx | 0 .../people/{add => }/[id]/section/Roles.tsx | 0 app/(cms)/cms/people/actions.ts | 18 --------------- app/(cms)/cms/people/add/page.tsx | 5 +++-- 10 files changed, 16 insertions(+), 46 deletions(-) rename app/(cms)/cms/people/{add => }/[id]/page.tsx (93%) rename app/(cms)/cms/people/{add => }/[id]/section/Details.tsx (97%) rename app/(cms)/cms/people/{add => }/[id]/section/Edit.tsx (80%) rename app/(cms)/cms/people/{add => }/[id]/section/Picture.tsx (100%) rename app/(cms)/cms/people/{add => }/[id]/section/Preview.tsx (100%) rename app/(cms)/cms/people/{add => }/[id]/section/Roles.tsx (100%) diff --git a/app/(cms)/cms/api/people/add/route.ts b/app/(cms)/cms/api/people/add/route.ts index 3bfc56a..84b3697 100644 --- a/app/(cms)/cms/api/people/add/route.ts +++ b/app/(cms)/cms/api/people/add/route.ts @@ -1,29 +1,13 @@ import { NextRequest, NextResponse } from "next/server"; -import { Person } from "#/lib/types/people"; -import { MutateResult } from "#/components/cms/input/TextField"; import { drizzle } from "#/lib/db/drizzle"; import { people } from "#/lib/db/schema"; import { revalidatePath } from "next/cache"; export async function POST(request: NextRequest) { - const result = await createPerson(); - return NextResponse.json(result); -} - -const createPerson = async ( - person: Omit = { firstName: "", lastName: "", image: "", phone: "", email: "" }, -): Promise => { const newPerson = await drizzle .insert(people) - .values({ - id: crypto.randomUUID(), - firstName: person.firstName, - lastName: person.lastName, - phone: person.phone, - email: person.email, - image: person.image, - }) + .values({ id: crypto.randomUUID(), firstName: "", lastName: "", phone: "", email: "", image: "" }) .returning(); revalidatePath("/cms/people"); - return { type: "success", person: { ...newPerson[0] } }; -}; + return NextResponse.json({ type: "success", person: { ...newPerson[0] } }); +} diff --git a/app/(cms)/cms/people/PeopleList.tsx b/app/(cms)/cms/people/PeopleList.tsx index 21c05a5..13d8bd5 100644 --- a/app/(cms)/cms/people/PeopleList.tsx +++ b/app/(cms)/cms/people/PeopleList.tsx @@ -79,7 +79,10 @@ export default function PeopleList({ people }: { people: PersonWithRoles[] }) { const options: TableOptions = useMemo( () => ({ data: filteredPeople, - columns: [COLUMN_FIRSTNAME, COLUMN_LASTNAME, COLUMN_EMAIL, COLUMN_PHONE, COLUMN_ROLES, COLUMN_ACTIONS], + columns: [COLUMN_LASTNAME, COLUMN_FIRSTNAME, COLUMN_EMAIL, COLUMN_PHONE, COLUMN_ROLES, COLUMN_ACTIONS], + initialState: { + sorting: [{ id: "lastName", desc: false }], + }, getRowId: (person) => person.id, getCoreRowModel: getCoreRowModel(), }), diff --git a/app/(cms)/cms/people/add/[id]/page.tsx b/app/(cms)/cms/people/[id]/page.tsx similarity index 93% rename from app/(cms)/cms/people/add/[id]/page.tsx rename to app/(cms)/cms/people/[id]/page.tsx index efe7e8e..5f51637 100644 --- a/app/(cms)/cms/people/add/[id]/page.tsx +++ b/app/(cms)/cms/people/[id]/page.tsx @@ -1,11 +1,12 @@ import "server-only"; + import { PageProps } from "#/lib/page"; import { drizzle } from "#/lib/db/drizzle"; import { sql } from "drizzle-orm"; import { PersonWithRoles } from "#/lib/types/people"; import { cache } from "react"; import { notFound } from "next/navigation"; -import { Edit } from "#/app/(cms)/cms/people/add/[id]/section/Edit"; +import { Edit } from "#/app/(cms)/cms/people/[id]/section/Edit"; import placeholder = sql.placeholder; const preparedPersonStatement = drizzle.query.people diff --git a/app/(cms)/cms/people/add/[id]/section/Details.tsx b/app/(cms)/cms/people/[id]/section/Details.tsx similarity index 97% rename from app/(cms)/cms/people/add/[id]/section/Details.tsx rename to app/(cms)/cms/people/[id]/section/Details.tsx index 180c9b8..e83c19c 100644 --- a/app/(cms)/cms/people/add/[id]/section/Details.tsx +++ b/app/(cms)/cms/people/[id]/section/Details.tsx @@ -7,7 +7,7 @@ import { updatePerson } from "#/app/(cms)/cms/people/actions"; import { useCallback } from "react"; import { MutationFunction, useDebouncedMutation } from "#/lib/action"; import { Alert, AlertTitle, Autocomplete, TextField as MuiTextField } from "@mui/material"; -import { Roles } from "#/app/(cms)/cms/people/add/[id]/section/Roles"; +import { Roles } from "#/app/(cms)/cms/people/[id]/section/Roles"; export function Details({ person }: { person: PersonWithRoles }) { const preparedMutateFirstNameFn: MutationFunction = useCallback( diff --git a/app/(cms)/cms/people/add/[id]/section/Edit.tsx b/app/(cms)/cms/people/[id]/section/Edit.tsx similarity index 80% rename from app/(cms)/cms/people/add/[id]/section/Edit.tsx rename to app/(cms)/cms/people/[id]/section/Edit.tsx index 74fe279..a067960 100644 --- a/app/(cms)/cms/people/add/[id]/section/Edit.tsx +++ b/app/(cms)/cms/people/[id]/section/Edit.tsx @@ -3,10 +3,9 @@ import { PersonWithRoles } from "#/lib/types/people"; import { useState } from "react"; import { Card, CardContent, CardHeader, CardTitle } from "#/components/cms/card/Card"; -import { Picture } from "#/app/(cms)/cms/people/add/[id]/section/Picture"; -import { Details } from "#/app/(cms)/cms/people/add/[id]/section/Details"; -import { Roles } from "#/app/(cms)/cms/people/add/[id]/section/Roles"; -import { Preview } from "#/app/(cms)/cms/people/add/[id]/section/Preview"; +import { Picture } from "#/app/(cms)/cms/people/[id]/section/Picture"; +import { Details } from "#/app/(cms)/cms/people/[id]/section/Details"; +import { Preview } from "#/app/(cms)/cms/people/[id]/section/Preview"; export function Edit({ person: initialPerson }: { person: PersonWithRoles }) { const [person] = useState(initialPerson); diff --git a/app/(cms)/cms/people/add/[id]/section/Picture.tsx b/app/(cms)/cms/people/[id]/section/Picture.tsx similarity index 100% rename from app/(cms)/cms/people/add/[id]/section/Picture.tsx rename to app/(cms)/cms/people/[id]/section/Picture.tsx diff --git a/app/(cms)/cms/people/add/[id]/section/Preview.tsx b/app/(cms)/cms/people/[id]/section/Preview.tsx similarity index 100% rename from app/(cms)/cms/people/add/[id]/section/Preview.tsx rename to app/(cms)/cms/people/[id]/section/Preview.tsx diff --git a/app/(cms)/cms/people/add/[id]/section/Roles.tsx b/app/(cms)/cms/people/[id]/section/Roles.tsx similarity index 100% rename from app/(cms)/cms/people/add/[id]/section/Roles.tsx rename to app/(cms)/cms/people/[id]/section/Roles.tsx diff --git a/app/(cms)/cms/people/actions.ts b/app/(cms)/cms/people/actions.ts index 35b1a4c..1757378 100644 --- a/app/(cms)/cms/people/actions.ts +++ b/app/(cms)/cms/people/actions.ts @@ -7,24 +7,6 @@ import { and, eq } from "drizzle-orm"; import { Person, PersonWithRoles, Role } from "#/lib/types/people"; import { revalidatePath } from "next/cache"; -export const createPerson = async ( - person: Omit = { firstName: "", lastName: "", image: "", phone: "", email: "" }, -): Promise => { - const newPerson = await drizzle - .insert(people) - .values({ - id: crypto.randomUUID(), - firstName: person.firstName, - lastName: person.lastName, - phone: person.phone, - email: person.email, - image: person.image, - }) - .returning(); - revalidatePath("/cms/people"); - return { type: "success", person: { ...newPerson[0] } }; -}; - export const readAllPeople = async (): Promise => { return await drizzle.query.people.findMany({ with: { peopleToRoles: { with: { roles: true } } } }); }; diff --git a/app/(cms)/cms/people/add/page.tsx b/app/(cms)/cms/people/add/page.tsx index 2858ec2..975eb66 100644 --- a/app/(cms)/cms/people/add/page.tsx +++ b/app/(cms)/cms/people/add/page.tsx @@ -1,12 +1,13 @@ import { redirect } from "next/navigation"; +import { getURL } from "#/lib/url"; export default async function CreatePersonPage() { - const response = await fetch(`${process.env.SITE_URL}/cms/api/people/add`, { method: "POST", cache: "no-store" }); + const response = await fetch(`${getURL()}/cms/api/people/add`, { method: "POST", cache: "no-store" }); if (!response.ok) { throw new Error("Person konnte nicht angelegt werden."); } const result = await response.json(); - redirect(`/cms/people/add/${result.person.id}`); + redirect(`/cms/people/${result.person.id}`); } From f2a912cab779a0537a350e3ae1f9a29635232488 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matth=C3=A4us=20Mayer?= <7984982+theMattCode@users.noreply.github.com> Date: Wed, 5 Feb 2025 20:01:04 +0100 Subject: [PATCH 16/16] Load data via API and alter list on client --- app/(cms)/cms/api/people/route.ts | 4 +- app/(cms)/cms/people/PeopleList.tsx | 82 ++++++++++++++++--------- app/(cms)/cms/people/PeopleListCard.tsx | 5 +- app/(cms)/cms/people/[id]/loading.tsx | 9 +++ app/(cms)/cms/people/actions.ts | 6 +- app/(cms)/cms/people/page.tsx | 4 +- components/cms/table/TableBody.tsx | 5 +- 7 files changed, 74 insertions(+), 41 deletions(-) create mode 100644 app/(cms)/cms/people/[id]/loading.tsx diff --git a/app/(cms)/cms/api/people/route.ts b/app/(cms)/cms/api/people/route.ts index aecff4e..214124c 100644 --- a/app/(cms)/cms/api/people/route.ts +++ b/app/(cms)/cms/api/people/route.ts @@ -3,7 +3,7 @@ import { drizzle } from "#/lib/db/drizzle"; import { PersonWithRoles } from "#/lib/types/people"; export async function GET(request: NextRequest) { - const roles = await drizzle.query.people.findMany({ + const people = await drizzle.query.people.findMany({ with: { peopleToRoles: { with: { @@ -12,5 +12,5 @@ export async function GET(request: NextRequest) { }, }, }); - return NextResponse.json<{ roles: PersonWithRoles[] }>({ roles }); + return NextResponse.json<{ people: PersonWithRoles[] }>({ people }); } diff --git a/app/(cms)/cms/people/PeopleList.tsx b/app/(cms)/cms/people/PeopleList.tsx index 13d8bd5..952d498 100644 --- a/app/(cms)/cms/people/PeopleList.tsx +++ b/app/(cms)/cms/people/PeopleList.tsx @@ -1,7 +1,7 @@ "use client"; import * as React from "react"; -import { useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { PersonWithRoles } from "#/lib/types/people"; import { CellContext, createColumnHelper, getCoreRowModel, TableOptions } from "@tanstack/table-core"; import { Table } from "#/components/cms/table/Table"; @@ -13,9 +13,9 @@ import { CardToolbar } from "#/components/cms/card/Card"; import { debounce } from "lodash"; import { LuSearch } from "react-icons/lu"; import { MdOutlineDelete, MdOutlinePersonAdd } from "react-icons/md"; -import { Button, IconButton } from "@mui/material"; +import { Button, IconButton, LinearProgress } from "@mui/material"; import { TextField } from "#/components/cms/input/TextField"; -import { deletePerson } from "#/app/(cms)/cms/people/actions"; +import { deletePerson, readAllPeople } from "#/app/(cms)/cms/people/actions"; import { useRouter } from "next/navigation"; import { useHotkeys } from "react-hotkeys-hook"; @@ -49,37 +49,58 @@ const COLUMN_ROLES = COLUMN_HELPER.accessor((person) => person.peopleToRoles.map cell: RolesCell, }); -const COLUMN_ACTIONS = COLUMN_HELPER.display({ - header: "", - id: "actions", - cell: Actions, -}); +export default function PeopleList() { + const [people, setPeople] = useState(undefined); + + useEffect(() => { + async function fetchAllPeople() { + const response = await fetch("/cms/api/people"); + const data = await response.json(); + setPeople(data.people); + } + fetchAllPeople(); + }, []); -export default function PeopleList({ people }: { people: PersonWithRoles[] }) { const router = useRouter(); const [searchTerm, setSearchTerm] = useState(); - const filteredPeople = useMemo( - () => - people.filter((person) => { - if (!searchTerm) return true; - - const firstName = person.firstName?.toLowerCase(); - const lastName = person.lastName?.toLowerCase(); - - if (firstName?.includes(searchTerm)) return true; - if (lastName?.includes(searchTerm)) return true; - if (`${firstName} ${lastName}`.includes(searchTerm)) return true; - if (person.email?.toLowerCase().includes(searchTerm)) return true; - return person.peopleToRoles.some((p) => p.roles.name?.toLowerCase().includes(searchTerm) ?? false); - }), - [searchTerm, people], - ); + const filteredPeople = useMemo(() => { + if (people === undefined) return []; + + if (!searchTerm || searchTerm.trim() === "") return people; + + return people.filter((person) => { + const firstName = person.firstName?.toLowerCase(); + const lastName = person.lastName?.toLowerCase(); + + if (firstName?.includes(searchTerm)) return true; + if (lastName?.includes(searchTerm)) return true; + if (`${firstName} ${lastName}`.includes(searchTerm)) return true; + if (person.email?.toLowerCase().includes(searchTerm)) return true; + return person.peopleToRoles.some((p) => p.roles.name?.toLowerCase().includes(searchTerm) ?? false); + }); + }, [searchTerm, people]); const options: TableOptions = useMemo( () => ({ data: filteredPeople, - columns: [COLUMN_LASTNAME, COLUMN_FIRSTNAME, COLUMN_EMAIL, COLUMN_PHONE, COLUMN_ROLES, COLUMN_ACTIONS], + columns: [ + COLUMN_LASTNAME, + COLUMN_FIRSTNAME, + COLUMN_EMAIL, + COLUMN_PHONE, + COLUMN_ROLES, + COLUMN_HELPER.display({ + header: "", + id: "actions", + cell: (props) => ( + setPeople((prev) => prev?.filter((person) => person.id !== personId))} + /> + ), + }), + ], initialState: { sorting: [{ id: "lastName", desc: false }], }, @@ -113,12 +134,15 @@ export default function PeopleList({ people }: { people: PersonWithRoles[] }) { Neue Person -
+
); } -function Actions(cellContext: CellContext) { +function Actions({ + onDelete, + ...cellContext +}: CellContext & { onDelete: (personId: string) => void }) { const router = useRouter(); return (
@@ -134,7 +158,7 @@ function Actions(cellContext: CellContext { await deletePerson(cellContext.row.original.id); - router.refresh(); + onDelete(cellContext.row.original.id); }} > diff --git a/app/(cms)/cms/people/PeopleListCard.tsx b/app/(cms)/cms/people/PeopleListCard.tsx index 7b789ff..e2e64a1 100644 --- a/app/(cms)/cms/people/PeopleListCard.tsx +++ b/app/(cms)/cms/people/PeopleListCard.tsx @@ -1,15 +1,14 @@ import { Card, CardContent, CardHeader, CardTitle } from "#/components/cms/card/Card"; import PeopleList from "#/app/(cms)/cms/people/PeopleList"; -import { PersonWithRoles } from "#/lib/types/people"; -export async function PeopleListCard({ people }: { people: PersonWithRoles[] }) { +export async function PeopleListCard() { return ( Personen - + ); diff --git a/app/(cms)/cms/people/[id]/loading.tsx b/app/(cms)/cms/people/[id]/loading.tsx new file mode 100644 index 0000000..470b3dc --- /dev/null +++ b/app/(cms)/cms/people/[id]/loading.tsx @@ -0,0 +1,9 @@ +import { LinearProgress } from "@mui/material"; + +export default function Loading() { + return ( +
+ +
+ ); +} diff --git a/app/(cms)/cms/people/actions.ts b/app/(cms)/cms/people/actions.ts index 1757378..6b2e9d7 100644 --- a/app/(cms)/cms/people/actions.ts +++ b/app/(cms)/cms/people/actions.ts @@ -13,13 +13,13 @@ export const readAllPeople = async (): Promise => { export const updatePerson = async (id: string, person: Partial>): Promise => { await drizzle.update(people).set(person).where(eq(people.id, id)); - revalidatePath("/cms/people"); + revalidatePath("/cms/api/people"); return { type: "success" }; }; export const deletePerson = async (id: string): Promise => { await drizzle.delete(people).where(eq(people.id, id)); - revalidatePath("/cms/people"); + revalidatePath("/cms/api/people"); return { type: "success" }; }; @@ -30,7 +30,7 @@ export const createRole = async (role: Omit = { name: "" }): Promise export const assignRole = async (peopleId: string, roleId: string): Promise => { await drizzle.insert(peopleToRoles).values({ peopleId, roleId }).onConflictDoNothing().returning(); - revalidatePath("/cms/people"); + revalidatePath("/cms/api/people"); return { type: "success" }; }; diff --git a/app/(cms)/cms/people/page.tsx b/app/(cms)/cms/people/page.tsx index 999997b..95327d8 100644 --- a/app/(cms)/cms/people/page.tsx +++ b/app/(cms)/cms/people/page.tsx @@ -1,7 +1,5 @@ import { PeopleListCard } from "#/app/(cms)/cms/people/PeopleListCard"; -import { readAllPeople } from "#/app/(cms)/cms/people/actions"; export default async function PeoplePage() { - const people = await readAllPeople(); - return ; + return ; } diff --git a/components/cms/table/TableBody.tsx b/components/cms/table/TableBody.tsx index 78f9e22..a4db7bb 100644 --- a/components/cms/table/TableBody.tsx +++ b/components/cms/table/TableBody.tsx @@ -1,13 +1,16 @@ import { RowData } from "@tanstack/table-core"; import { PropsWithTable } from "#/components/cms/table/types"; import { TableBodyRow } from "#/components/cms/table/TableBodyRow"; +import { LinearProgress } from "@mui/material"; export function TableBody({ table, loading }: PropsWithTable & { loading: boolean }) { return (
{loading && ( - + )} {!loading && table.getRowModel().rows.map((row) => )}
Lädt... + +