Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ updates:
schedule:
interval: "weekly"
groups:
dependencies:
dependency-types: "production"
devDependencies:
dependency-types: "development"
dev-deps:
dependency-type: "development"
prod-deps:
dependency-type: "production"
36 changes: 29 additions & 7 deletions app/(cms)/cms.css
Original file line number Diff line number Diff line change
Expand Up @@ -54,36 +54,58 @@ 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
}

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 {
@apply font-medium
}

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 {
@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 {
@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 {
Expand Down
13 changes: 13 additions & 0 deletions app/(cms)/cms/api/people/add/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { NextRequest, NextResponse } from "next/server";
import { drizzle } from "#/lib/db/drizzle";
import { people } from "#/lib/db/schema";
import { revalidatePath } from "next/cache";

export async function POST(request: NextRequest) {
const newPerson = await drizzle
.insert(people)
.values({ id: crypto.randomUUID(), firstName: "", lastName: "", phone: "", email: "", image: "" })
.returning();
revalidatePath("/cms/people");
return NextResponse.json({ type: "success", person: { ...newPerson[0] } });
}
7 changes: 7 additions & 0 deletions app/(cms)/cms/api/people/roles/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
16 changes: 16 additions & 0 deletions app/(cms)/cms/api/people/route.ts
Original file line number Diff line number Diff line change
@@ -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 people = await drizzle.query.people.findMany({
with: {
peopleToRoles: {
with: {
roles: true,
},
},
},
});
return NextResponse.json<{ people: PersonWithRoles[] }>({ people });
}
14 changes: 14 additions & 0 deletions app/(cms)/cms/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
"use client";

import { Alert, AlertTitle } from "@mui/material";

export default function Error({ error }: { error: Error & { digest?: string }; reset: () => void }) {
return (
<div className="container">
<Alert severity="error">
<AlertTitle>Es ist ein Fehler aufgetreten</AlertTitle>
{error.message}
</Alert>
</div>
);
}
37 changes: 29 additions & 8 deletions app/(cms)/cms/layout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="@container w-screen h-screen flex bg-gray-50">
<Navigation open={navigationOpen} onClose={() => setNavigationOpen(false)} />
<div className="w-full h-full flex flex-col" onClick={navigationOpen ? () => setNavigationOpen(false) : () => {}}>
<Header onOpenMenu={() => setNavigationOpen(true)} />
<main className="w-full h-full overflow-y-auto p-4 @container">{children}</main>
<ThemeProvider theme={theme}>
<div className="@container w-screen h-screen flex bg-gray-50">
<Navigation open={navigationOpen} onClose={() => setNavigationOpen(false)} />
<div
className={`transition-all z-30 @5xl:hidden ${navigationOpen ? "fixed top-0 left-0 w-full h-full bg-neutral-500/50" : ""}`}
/>
className="w-full h-full flex flex-col"
onClick={navigationOpen ? () => setNavigationOpen(false) : () => {}}
>
<Header onOpenMenu={() => setNavigationOpen(true)} />
<main className="w-full h-full overflow-y-auto p-4 @container">{children}</main>
<div
className={`transition-all z-30 @5xl:hidden ${navigationOpen ? "fixed top-0 left-0 w-full h-full bg-neutral-500/50" : ""}`}
/>
</div>
</div>
</div>
</ThemeProvider>
);
});
9 changes: 9 additions & 0 deletions app/(cms)/cms/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { LinearProgress } from "@mui/material";

export default function Loading() {
return (
<div className="container">
<LinearProgress />
</div>
);
}
168 changes: 168 additions & 0 deletions app/(cms)/cms/people/PeopleList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
"use client";

import * as React 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";
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 { FaRegEdit } from "react-icons/fa";
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, LinearProgress } from "@mui/material";
import { TextField } from "#/components/cms/input/TextField";
import { deletePerson, readAllPeople } from "#/app/(cms)/cms/people/actions";
import { useRouter } from "next/navigation";
import { useHotkeys } from "react-hotkeys-hook";

const COLUMN_HELPER = createColumnHelper<PersonWithRoles>();

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,
});

export default function PeopleList() {
const [people, setPeople] = useState<PersonWithRoles[] | undefined>(undefined);

useEffect(() => {
async function fetchAllPeople() {
const response = await fetch("/cms/api/people");
const data = await response.json();
setPeople(data.people);
}
fetchAllPeople();
}, []);

const router = useRouter();

const [searchTerm, setSearchTerm] = useState<string | null>();
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<PersonWithRoles> = useMemo(
() => ({
data: filteredPeople,
columns: [
COLUMN_LASTNAME,
COLUMN_FIRSTNAME,
COLUMN_EMAIL,
COLUMN_PHONE,
COLUMN_ROLES,
COLUMN_HELPER.display({
header: "",
id: "actions",
cell: (props) => (
<Actions
{...props}
onDelete={(personId) => setPeople((prev) => prev?.filter((person) => person.id !== personId))}
/>
),
}),
],
initialState: {
sorting: [{ id: "lastName", desc: false }],
},
getRowId: (person) => person.id,
getCoreRowModel: getCoreRowModel(),
}),
[filteredPeople],
);

const onChange = useCallback(
debounce((value: string | null) => setSearchTerm(value?.trim().toLowerCase()), 500),
[],
);

const onNewPerson = useCallback(async () => {
router.push("/cms/people/add");
}, [router]);
useHotkeys("alt+n", onNewPerson);

const refSearch = useRef<HTMLDivElement>(null);
const focusSearch = useCallback(() => {
refSearch.current?.focus();
}, []);
useHotkeys("alt+s", focusSearch);

return (
<>
<CardToolbar>
<TextField inputRef={refSearch} fullWidth={false} onChange={onChange} label="Suche" StartIcon={LuSearch} />
<Button variant="contained" aria-label="Neue Person" startIcon={<MdOutlinePersonAdd />} href="/cms/people/add">
Neue Person
</Button>
</CardToolbar>
<Table options={options} loading={people === undefined} />
</>
);
}

function Actions<TData extends { id: string }>({
onDelete,
...cellContext
}: CellContext<TData, unknown> & { onDelete: (personId: string) => void }) {
const router = useRouter();
return (
<div className="w-full flex place-content-end gap-2">
<IconButton
size="small"
onClick={() => {
router.push(`/cms/people/${cellContext.row.original.id}`);
}}
>
<FaRegEdit />
</IconButton>
<IconButton
size="small"
onClick={async () => {
await deletePerson(cellContext.row.original.id);
onDelete(cellContext.row.original.id);
}}
>
<MdOutlineDelete />
</IconButton>
</div>
);
}
15 changes: 15 additions & 0 deletions app/(cms)/cms/people/PeopleListCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Card, CardContent, CardHeader, CardTitle } from "#/components/cms/card/Card";
import PeopleList from "#/app/(cms)/cms/people/PeopleList";

export async function PeopleListCard() {
return (
<Card>
<CardHeader>
<CardTitle>Personen</CardTitle>
</CardHeader>
<CardContent>
<PeopleList />
</CardContent>
</Card>
);
}
9 changes: 9 additions & 0 deletions app/(cms)/cms/people/[id]/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { LinearProgress } from "@mui/material";

export default function Loading() {
return (
<div className="container">
<LinearProgress />
</div>
);
}
Loading