From aa6fa39f6a73ad5b11a65c1cddc959ac07c8dbd6 Mon Sep 17 00:00:00 2001 From: Citali Date: Thu, 5 Feb 2026 15:13:57 +0100 Subject: [PATCH] Add about page --- .../20260205135425_about_01/migration.sql | 10 ++ prisma/schema.prisma | 9 + src/actions/about/getAbout.ts | 11 ++ src/actions/about/images.ts | 55 ++++++ src/actions/about/saveAbout.ts | 12 ++ src/app/(admin)/about/page.tsx | 18 ++ src/components/about/Editor.tsx | 169 ++++++++++++++++++ src/components/global/TopNav.tsx | 6 + src/components/global/nav.ts | 1 + src/stores/artworksTableStore.ts | 7 +- 10 files changed, 296 insertions(+), 2 deletions(-) create mode 100644 prisma/migrations/20260205135425_about_01/migration.sql create mode 100644 src/actions/about/getAbout.ts create mode 100644 src/actions/about/images.ts create mode 100644 src/actions/about/saveAbout.ts create mode 100644 src/app/(admin)/about/page.tsx create mode 100644 src/components/about/Editor.tsx diff --git a/prisma/migrations/20260205135425_about_01/migration.sql b/prisma/migrations/20260205135425_about_01/migration.sql new file mode 100644 index 0000000..7c0c49e --- /dev/null +++ b/prisma/migrations/20260205135425_about_01/migration.sql @@ -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") +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b2036e5..c20aa75 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -524,6 +524,15 @@ model TermsOfService { 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 { id String @id name String diff --git a/src/actions/about/getAbout.ts b/src/actions/about/getAbout.ts new file mode 100644 index 0000000..29d0c97 --- /dev/null +++ b/src/actions/about/getAbout.ts @@ -0,0 +1,11 @@ +"use server"; + +import { prisma } from "@/lib/prisma"; + +// Returns the most recent About Me markdown. +export async function getLatestAboutMe(): Promise { + const about = await prisma.about.findFirst({ + orderBy: { createdAt: "desc" }, + }); + return about?.markdown ?? null; +} diff --git a/src/actions/about/images.ts b/src/actions/about/images.ts new file mode 100644 index 0000000..69d89be --- /dev/null +++ b/src/actions/about/images.ts @@ -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 { + 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(), + }; +} diff --git a/src/actions/about/saveAbout.ts b/src/actions/about/saveAbout.ts new file mode 100644 index 0000000..4a186b1 --- /dev/null +++ b/src/actions/about/saveAbout.ts @@ -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, + }, + }); +} diff --git a/src/app/(admin)/about/page.tsx b/src/app/(admin)/about/page.tsx new file mode 100644 index 0000000..9aac3fd --- /dev/null +++ b/src/app/(admin)/about/page.tsx @@ -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 ( +
+
+

About Me

+
+
+ +
+
+ ); +} diff --git a/src/components/about/Editor.tsx b/src/components/about/Editor.tsx new file mode 100644 index 0000000..5b6d43a --- /dev/null +++ b/src/components/about/Editor.tsx @@ -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(null); + const fileInputRef = useRef(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 ( + +
+
+ { + const file = e.target.files?.[0]; + if (file) handleUpload(file); + e.currentTarget.value = ""; + }} + /> + + {lastUploadUrl ? ( +
+ Inserted image: {lastUploadUrl} +
+ ) : null} +
+
+ + {/* Blocks */} + editor.tf.h1.toggle()} tooltip="Heading 1"> + + + editor.tf.h2.toggle()} tooltip="Heading 2"> + + + editor.tf.h3.toggle()} tooltip="Heading 3"> + + + editor.tf.blockquote.toggle()} tooltip="Blockquote"> + + + editor.tf.code_block.toggle()} tooltip="Code Block"> + + + + + {/* Mark Toolbar Buttons */} + + + + + + + + + + + + + + + + {/* Image Upload */} + fileInputRef.current?.click()} tooltip="Insert Image"> + + + {/* Save Button */} + + + + + + + +
+ ); +} diff --git a/src/components/global/TopNav.tsx b/src/components/global/TopNav.tsx index 2c972b3..143daeb 100644 --- a/src/components/global/TopNav.tsx +++ b/src/components/global/TopNav.tsx @@ -151,6 +151,12 @@ export default function TopNav() { + + + About Me + + + Users diff --git a/src/components/global/nav.ts b/src/components/global/nav.ts index 9acc3a6..e45dfc4 100644 --- a/src/components/global/nav.ts +++ b/src/components/global/nav.ts @@ -54,5 +54,6 @@ export const adminNav: AdminNavGroup[] = [ ], }, { type: "link", title: "Terms of Service", href: "/tos" }, + { type: "link", title: "About Me", href: "/about" }, { type: "link", title: "Users", href: "/users" }, ]; diff --git a/src/stores/artworksTableStore.ts b/src/stores/artworksTableStore.ts index b158abd..996d493 100644 --- a/src/stores/artworksTableStore.ts +++ b/src/stores/artworksTableStore.ts @@ -20,7 +20,9 @@ type ArtworksTableState = { setFilters: ( next: | ArtworksTableState["filters"] - | ((prev: ArtworksTableState["filters"]) => ArtworksTableState["filters"]), + | (( + prev: ArtworksTableState["filters"], + ) => ArtworksTableState["filters"]), ) => void; reset: () => void; }; @@ -40,7 +42,8 @@ export const useArtworksTableStore = create()( setSorting: (next) => set({ sorting: next }), setPageIndex: (next) => 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) }; }), setPageSize: (next) => set({ pageSize: next }),