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 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 (
+
+
+
+
+ Details
+
+
+
+
+
+ Rollen
+
+
+
+
+
+
+
+ );
+}
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 ?? "");
+ }
+ }}
+ />
+
+
+ {label}
+
+
+ {/* 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 (
+ <>
+
+
+ } onClick={onNewPerson}>
+ Neue Person
+
+
+
+ >
+ );
+}
+
+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 (
-
-
-
-
- Details
-
-
-
-
-
- Rollen
-
-
-
-
-
-
-
- );
-}
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 ?? "");
- }
- }}
- />
-
-
- {label}
-
+ 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[] }) {
<>
- } onClick={onNewPerson}>
+ } href="/cms/people/add">
Neue Person
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 && (
- Lädt...
+
+
+
)}
{!loading && table.getRowModel().rows.map((row) => )}