Add about page
This commit is contained in:
10
prisma/migrations/20260205135425_about_01/migration.sql
Normal file
10
prisma/migrations/20260205135425_about_01/migration.sql
Normal 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")
|
||||||
|
);
|
||||||
@ -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
|
||||||
|
|||||||
11
src/actions/about/getAbout.ts
Normal file
11
src/actions/about/getAbout.ts
Normal 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;
|
||||||
|
}
|
||||||
55
src/actions/about/images.ts
Normal file
55
src/actions/about/images.ts
Normal 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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
12
src/actions/about/saveAbout.ts
Normal file
12
src/actions/about/saveAbout.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
18
src/app/(admin)/about/page.tsx
Normal file
18
src/app/(admin)/about/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
169
src/components/about/Editor.tsx
Normal file
169
src/components/about/Editor.tsx
Normal 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\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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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" },
|
||||||
];
|
];
|
||||||
|
|||||||
@ -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 }),
|
||||||
|
|||||||
Reference in New Issue
Block a user