Add about page

This commit is contained in:
2026-02-05 15:13:57 +01:00
parent 2971fb298e
commit aa6fa39f6a
10 changed files with 296 additions and 2 deletions

View File

@ -0,0 +1,10 @@
-- CreateTable
CREATE TABLE "About" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"markdown" TEXT NOT NULL,
"version" SERIAL NOT NULL,
CONSTRAINT "About_pkey" PRIMARY KEY ("id")
);

View File

@ -524,6 +524,15 @@ model TermsOfService {
version Int @default(autoincrement()) version Int @default(autoincrement())
} }
model About {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
markdown String
version Int @default(autoincrement())
}
model User { model User {
id String @id id String @id
name String name String

View File

@ -0,0 +1,11 @@
"use server";
import { prisma } from "@/lib/prisma";
// Returns the most recent About Me markdown.
export async function getLatestAboutMe(): Promise<string | null> {
const about = await prisma.about.findFirst({
orderBy: { createdAt: "desc" },
});
return about?.markdown ?? null;
}

View File

@ -0,0 +1,55 @@
"use server";
import { s3 } from "@/lib/s3";
import { PutObjectCommand } from "@aws-sdk/client-s3";
const PREFIX = "about/images/";
function buildImageUrl(key: string) {
return `/api/image/${encodeURI(key)}`;
}
function sanitizeFilename(name: string) {
return name.replace(/[^a-zA-Z0-9._-]/g, "_");
}
export type AboutImageUpload = {
key: string;
url: string;
size: number;
lastModified: string;
};
export async function uploadAboutImage(
formData: FormData
): Promise<AboutImageUpload> {
const file = formData.get("file");
if (!(file instanceof File)) {
throw new Error("Missing file");
}
if (!file.type.startsWith("image/")) {
throw new Error("Only image uploads are allowed");
}
const safeName = sanitizeFilename(file.name || "about-image");
const key = `${PREFIX}${Date.now()}-${safeName}`;
const buffer = Buffer.from(await file.arrayBuffer());
await s3.send(
new PutObjectCommand({
Bucket: `${process.env.BUCKET_NAME}`,
Key: key,
Body: buffer,
ContentType: file.type,
})
);
return {
key,
url: buildImageUrl(key),
size: file.size,
lastModified: new Date().toISOString(),
};
}

View File

@ -0,0 +1,12 @@
"use server";
import { prisma } from "@/lib/prisma";
// Saves a new About Me version.
export async function saveAboutMeAction(markdown: string) {
await prisma.about.create({
data: {
markdown,
},
});
}

View File

@ -0,0 +1,18 @@
import { getLatestAboutMe } from "@/actions/about/getAbout";
import AboutEditor from "@/components/about/Editor";
// Admin page for editing About Me content.
export default async function AboutPage() {
const markdown = await getLatestAboutMe();
return (
<div>
<div className="flex gap-4 justify-between pb-8">
<h1 className="text-2xl font-bold mb-4">About Me</h1>
</div>
<div className="space-y-4 p-1 border rounded-xl bg-muted/20">
<AboutEditor markdown={markdown} />
</div>
</div>
);
}

View File

@ -0,0 +1,169 @@
"use client";
import { uploadAboutImage } from "@/actions/about/images";
import { saveAboutMeAction } from "@/actions/about/saveAbout";
import { BasicBlocksKit } from "@/components/editor/plugins/basic-blocks-kit";
import { BasicMarksKit } from "@/components/editor/plugins/basic-marks-kit";
import { CodeBlockKit } from "@/components/editor/plugins/code-block-kit";
import { ListKit } from "@/components/editor/plugins/list-kit";
import { MarkdownKit } from "@/components/editor/plugins/markdown-kit";
import { Button } from "@/components/ui/button";
import { Editor, EditorContainer } from "@/components/ui/editor";
import { FixedToolbar } from "@/components/ui/fixed-toolbar";
import { BulletedListToolbarButton, NumberedListToolbarButton } from "@/components/ui/list-toolbar-button";
import { MarkToolbarButton } from "@/components/ui/mark-toolbar-button";
import { ToolbarButton } from "@/components/ui/toolbar";
import {
Bold,
Braces,
Code,
Heading1,
Heading2,
Heading3,
Image,
Italic,
Quote,
Save,
Strikethrough,
Underline,
} from "lucide-react";
import type { Value } from "platejs";
import { Plate, usePlateEditor } from "platejs/react";
import { useEffect, useRef, useState, useTransition } from "react";
const initialValue: Value = [];
// Rich text editor for About Me content with image upload.
export default function AboutEditor({ markdown }: { markdown: string | null }) {
const [isPending, startTransition] = useTransition();
const [lastUploadUrl, setLastUploadUrl] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const editor = usePlateEditor({
plugins: [
...BasicBlocksKit,
...CodeBlockKit,
...ListKit,
...BasicMarksKit,
...MarkdownKit,
],
value: initialValue,
});
useEffect(() => {
if (markdown && editor.api.markdown.deserialize) {
const markdownValue = editor.api.markdown.deserialize(markdown);
editor.children = markdownValue;
}
}, [editor, markdown]);
const handleSave = async () => {
if (!editor.api.markdown.serialize) return;
const nextMarkdown = editor.api.markdown.serialize();
await saveAboutMeAction(nextMarkdown);
};
const insertImageMarkdown = (url: string, altText?: string) => {
const alt = altText?.trim() || "image";
const snippet = `\n\n![${alt}](${url})\n\n`;
if (editor.api.markdown.deserialize) {
const nodes = editor.api.markdown.deserialize(snippet) as Value;
if (nodes.length) {
editor.tf.insertNodes(nodes);
return;
}
}
editor.tf.insertText(snippet);
};
const handleUpload = (file: File) => {
const fd = new FormData();
fd.append("file", file);
startTransition(async () => {
const item = await uploadAboutImage(fd);
setLastUploadUrl(item.url);
insertImageMarkdown(item.url, file.name.replace(/\.[^.]+$/, ""));
});
};
return (
<Plate editor={editor}>
<div className="px-4 pt-4 flex flex-col gap-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleUpload(file);
e.currentTarget.value = "";
}}
/>
<Button
type="button"
variant="secondary"
onClick={() => fileInputRef.current?.click()}
disabled={isPending}
>
Upload image
</Button>
{lastUploadUrl ? (
<div className="text-xs text-muted-foreground">
Inserted image: {lastUploadUrl}
</div>
) : null}
</div>
</div>
<FixedToolbar className="justify-start rounded-t-lg">
{/* Blocks */}
<ToolbarButton onClick={() => editor.tf.h1.toggle()} tooltip="Heading 1">
<Heading1 className="w-4 h-4" />
</ToolbarButton>
<ToolbarButton onClick={() => editor.tf.h2.toggle()} tooltip="Heading 2">
<Heading2 className="w-4 h-4" />
</ToolbarButton>
<ToolbarButton onClick={() => editor.tf.h3.toggle()} tooltip="Heading 3">
<Heading3 className="w-4 h-4" />
</ToolbarButton>
<ToolbarButton onClick={() => editor.tf.blockquote.toggle()} tooltip="Blockquote">
<Quote className="w-4 h-4" />
</ToolbarButton>
<ToolbarButton onClick={() => editor.tf.code_block.toggle()} tooltip="Code Block">
<Braces className="w-4 h-4" />
</ToolbarButton>
<BulletedListToolbarButton />
<NumberedListToolbarButton />
{/* Mark Toolbar Buttons */}
<MarkToolbarButton nodeType="bold" tooltip="Bold">
<Bold className="w-4 h-4" />
</MarkToolbarButton>
<MarkToolbarButton nodeType="italic" tooltip="Italic">
<Italic className="w-4 h-4" />
</MarkToolbarButton>
<MarkToolbarButton nodeType="underline" tooltip="Underline">
<Underline className="w-4 h-4" />
</MarkToolbarButton>
<MarkToolbarButton nodeType="strikethrough" tooltip="Strikethrough">
<Strikethrough className="w-4 h-4" />
</MarkToolbarButton>
<MarkToolbarButton nodeType="code" tooltip="Code">
<Code className="w-4 h-4" />
</MarkToolbarButton>
{/* Image Upload */}
<ToolbarButton onClick={() => fileInputRef.current?.click()} tooltip="Insert Image">
<Image className="w-4 h-4" />
</ToolbarButton>
{/* Save Button */}
<ToolbarButton onClick={handleSave} tooltip="Save">
<Save className="w-4 h-4" />
</ToolbarButton>
</FixedToolbar>
<EditorContainer>
<Editor placeholder="Write your about me content..." />
</EditorContainer>
</Plate>
);
}

View File

@ -151,6 +151,12 @@ export default function TopNav() {
</NavigationMenuLink> </NavigationMenuLink>
</NavigationMenuItem> </NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
<Link href="/about">About Me</Link>
</NavigationMenuLink>
</NavigationMenuItem>
<NavigationMenuItem> <NavigationMenuItem>
<NavigationMenuTrigger>Users</NavigationMenuTrigger> <NavigationMenuTrigger>Users</NavigationMenuTrigger>
<NavigationMenuContent> <NavigationMenuContent>

View File

@ -54,5 +54,6 @@ export const adminNav: AdminNavGroup[] = [
], ],
}, },
{ type: "link", title: "Terms of Service", href: "/tos" }, { type: "link", title: "Terms of Service", href: "/tos" },
{ type: "link", title: "About Me", href: "/about" },
{ type: "link", title: "Users", href: "/users" }, { type: "link", title: "Users", href: "/users" },
]; ];

View File

@ -20,7 +20,9 @@ type ArtworksTableState = {
setFilters: ( setFilters: (
next: next:
| ArtworksTableState["filters"] | ArtworksTableState["filters"]
| ((prev: ArtworksTableState["filters"]) => ArtworksTableState["filters"]), | ((
prev: ArtworksTableState["filters"],
) => ArtworksTableState["filters"]),
) => void; ) => void;
reset: () => void; reset: () => void;
}; };
@ -40,7 +42,8 @@ export const useArtworksTableStore = create<ArtworksTableState>()(
setSorting: (next) => set({ sorting: next }), setSorting: (next) => set({ sorting: next }),
setPageIndex: (next) => setPageIndex: (next) =>
set((state) => { set((state) => {
const value = typeof next === "function" ? next(state.pageIndex) : next; const value =
typeof next === "function" ? next(state.pageIndex) : next;
return { pageIndex: Math.max(0, value) }; return { pageIndex: Math.max(0, value) };
}), }),
setPageSize: (next) => set({ pageSize: next }), setPageSize: (next) => set({ pageSize: next }),