refactor(admin): organize user actions and improve content editing UX
This commit is contained in:
1 parent
e17346ab9f
commit
c6b919c01d
10 files changed
+688
-458
No files matched your search
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useFormStatus } from "react-dom";
|
||||
import { slugify } from "@/lib/format";
|
||||
import { ArticlePreview, type ArticlePreviewData } from "./article-preview";
|
||||
import { MediaPicker } from "./media-picker";
|
||||
import { RichText } from "./rich-text";
|
||||
|
||||
@@ -30,10 +32,13 @@ export function ArticleForm({
|
||||
const [imageError, setImageError] = useState(false);
|
||||
const [status, setStatus] = useState(defaultValues?.status ?? "published");
|
||||
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const [preview, setPreview] = useState<ArticlePreviewData | null>(null);
|
||||
const suggestedSlug = useMemo(() => slugify(title), [title]);
|
||||
|
||||
return (
|
||||
<form
|
||||
ref={formRef}
|
||||
action={action}
|
||||
className="admin-card"
|
||||
style={{ display: "grid", gap: "1rem" }}
|
||||
@@ -76,6 +81,7 @@ export function ArticleForm({
|
||||
</span>
|
||||
<div className="mt-1 flex gap-2 items-start">
|
||||
<input
|
||||
aria-label={t("image")}
|
||||
name="image"
|
||||
value={image}
|
||||
onChange={(e) => {
|
||||
@@ -83,7 +89,7 @@ export function ArticleForm({
|
||||
setImageError(false);
|
||||
}}
|
||||
placeholder={t("imagePlaceholder")}
|
||||
className="input input-bordered flex-1"
|
||||
className="input input-bordered min-w-0 flex-1"
|
||||
/>
|
||||
<MediaPicker
|
||||
onSelect={(url) => {
|
||||
@@ -168,9 +174,40 @@ export function ArticleForm({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn btn-primary justify-self-start">
|
||||
{t("save")}
|
||||
</button>
|
||||
<div className="sticky bottom-0 flex flex-wrap gap-2 border-t border-[var(--admin-border)] bg-[var(--admin-surface)] py-3">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
onClick={() => {
|
||||
if (!formRef.current) return;
|
||||
const values = new FormData(formRef.current);
|
||||
setPreview({
|
||||
title: String(values.get("title") ?? ""),
|
||||
image: String(values.get("image") ?? ""),
|
||||
summary: String(values.get("shortStory") ?? ""),
|
||||
body: String(values.get("fullStory") ?? ""),
|
||||
});
|
||||
}}
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
<ArticleSaveButton label={t("save")} />
|
||||
</div>
|
||||
<ArticlePreview data={preview} onClose={() => setPreview(null)} />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function ArticleSaveButton({ label }: { label: string }) {
|
||||
const { pending } = useFormStatus();
|
||||
return (
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={pending}
|
||||
aria-busy={pending}
|
||||
>
|
||||
{pending ? "Saving…" : label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
function escapeHtml(value: string) {
|
||||
return value.replace(
|
||||
/[&<>"']/g,
|
||||
(c) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[
|
||||
c
|
||||
] ?? c,
|
||||
);
|
||||
}
|
||||
export interface ArticlePreviewData {
|
||||
title: string;
|
||||
image: string;
|
||||
summary: string;
|
||||
body: string;
|
||||
}
|
||||
export function ArticlePreview({
|
||||
data,
|
||||
onClose,
|
||||
}: {
|
||||
data: ArticlePreviewData | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const document = data
|
||||
? `<!doctype html><html><head><meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src https: http: data:; style-src 'unsafe-inline'"><meta name="viewport" content="width=device-width, initial-scale=1"><style>:root{color-scheme:light;background:Canvas;color:CanvasText}body{font:16px/1.6 system-ui;padding:20px;margin:auto;max-width:760px;overflow-wrap:anywhere}img{max-width:100%;height:auto}h1{line-height:1.2}pre{white-space:pre-wrap}</style></head><body><h1>${escapeHtml(data.title)}</h1>${data.image ? `<img src="${escapeHtml(data.image)}" alt="">` : ""}<p>${escapeHtml(data.summary)}</p>${data.body}</body></html>`
|
||||
: "";
|
||||
return (
|
||||
<Dialog
|
||||
open={!!data}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-4xl h-[85dvh] flex flex-col min-h-0">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Article preview</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Content preview. The published page applies the site theme.
|
||||
</p>
|
||||
<iframe
|
||||
title="Article content preview"
|
||||
sandbox=""
|
||||
srcDoc={document}
|
||||
className="min-h-0 flex-1 w-full bg-background rounded border"
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { uploadMediaAndReturn } from "@/actions/admin-media";
|
||||
|
||||
type MediaFile = { name: string; url: string };
|
||||
import { useState } from "react";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { useMediaLibrary } from "./use-media-library";
|
||||
|
||||
function validImageUrl(url: string): string {
|
||||
if (!url.startsWith("/")) return "";
|
||||
@@ -20,46 +19,8 @@ export function MediaPicker({
|
||||
current?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [files, setFiles] = useState<MediaFile[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/media");
|
||||
const data = await res.json();
|
||||
setFiles(data.files ?? []);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) load();
|
||||
}, [open, load]);
|
||||
|
||||
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const files = e.target.files;
|
||||
if (!files || files.length === 0) return;
|
||||
setUploading(true);
|
||||
const urls: string[] = [];
|
||||
for (const file of Array.from(files)) {
|
||||
const fd = new FormData();
|
||||
fd.set("file", file);
|
||||
const url = await uploadMediaAndReturn(fd);
|
||||
if (url) urls.push(url);
|
||||
}
|
||||
if (urls.length > 0) {
|
||||
await load();
|
||||
onSelect(urls[urls.length - 1]); // select the last one for backward compatibility
|
||||
setOpen(false);
|
||||
}
|
||||
setUploading(false);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
}
|
||||
const { files, loading, uploading, fileRef, error, load, handleUpload } =
|
||||
useMediaLibrary(open, setOpen, onSelect);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -88,104 +49,107 @@ export function MediaPicker({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
// biome-ignore lint/a11y/useSemanticElements: backdrop overlay uses role=button for click-to-close
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Close media picker"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-foreground/50"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) setOpen(false);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setOpen(false);
|
||||
}
|
||||
}}
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!uploading) setOpen(next);
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="flex flex-col p-0 gap-0 max-h-[80dvh] sm:max-w-[680px] overflow-hidden"
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className="bg-[var(--admin-surface)] text-[var(--admin-text)] rounded-2xl w-[min(90vw,680px)] max-h-[80vh] flex flex-col shadow-xl"
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--admin-text-muted)]/14">
|
||||
<strong className="text-base">Media Library</strong>
|
||||
<div className="flex gap-2 items-center">
|
||||
<label className="btn btn-primary text-sm cursor-pointer">
|
||||
{uploading ? "Uploading…" : "Upload"}
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
onChange={handleUpload}
|
||||
className="hidden"
|
||||
multiple
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="btn text-sm"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-3">
|
||||
{loading ? (
|
||||
<p className="text-xs text-[var(--admin-text-muted)] text-center py-8">
|
||||
Loading…
|
||||
</p>
|
||||
) : files.length === 0 ? (
|
||||
<p className="text-xs text-[var(--admin-text-muted)] text-center py-8">
|
||||
No images yet. Upload one above.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-2.5">
|
||||
{files.map((f) => (
|
||||
<button
|
||||
type="button"
|
||||
key={f.name}
|
||||
onClick={() => {
|
||||
onSelect(f.url);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="border-2 border-transparent rounded-xl overflow-hidden cursor-pointer bg-[var(--admin-canvas)] p-0 flex flex-col transition-all duration-150"
|
||||
style={{
|
||||
borderColor:
|
||||
f.url === current
|
||||
? "var(--admin-accent)"
|
||||
: "transparent",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (f.url !== current)
|
||||
e.currentTarget.style.borderColor =
|
||||
"color-mix(in srgb, var(--admin-accent) 40%, transparent)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (f.url !== current)
|
||||
e.currentTarget.style.borderColor = "transparent";
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={validImageUrl(f.url)}
|
||||
alt={f.name}
|
||||
className="w-full h-[100px] object-cover block"
|
||||
/>
|
||||
<span className="text-xs py-1 text-center overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{f.name}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--admin-text-muted)]/14">
|
||||
<DialogTitle className="text-base">Media Library</DialogTitle>
|
||||
<div className="flex gap-2 items-center">
|
||||
<label className="btn btn-primary text-sm cursor-pointer">
|
||||
{uploading ? "Uploading…" : "Upload"}
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
onChange={handleUpload}
|
||||
className="hidden"
|
||||
disabled={uploading}
|
||||
multiple
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
disabled={uploading}
|
||||
onClick={() => setOpen(false)}
|
||||
className="btn text-sm"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="min-h-0 flex-1 overflow-auto p-3">
|
||||
{error && (
|
||||
<div role="alert" className="p-2 text-sm">
|
||||
<p>{error}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
disabled={loading || uploading}
|
||||
onClick={() => void load()}
|
||||
>
|
||||
Retry loading
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{loading ? (
|
||||
<p className="text-xs text-[var(--admin-text-muted)] text-center py-8">
|
||||
Loading…
|
||||
</p>
|
||||
) : files.length === 0 ? (
|
||||
<p className="text-xs text-[var(--admin-text-muted)] text-center py-8">
|
||||
No images yet. Upload one above.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-2.5">
|
||||
{files.map((f) => (
|
||||
<button
|
||||
type="button"
|
||||
key={f.name}
|
||||
disabled={uploading}
|
||||
onClick={() => {
|
||||
onSelect(f.url);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="border-2 border-transparent rounded-xl overflow-hidden cursor-pointer bg-[var(--admin-canvas)] p-0 flex flex-col transition-all duration-150"
|
||||
style={{
|
||||
borderColor:
|
||||
f.url === current
|
||||
? "var(--admin-accent)"
|
||||
: "transparent",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (f.url !== current)
|
||||
e.currentTarget.style.borderColor =
|
||||
"color-mix(in srgb, var(--admin-accent) 40%, transparent)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (f.url !== current)
|
||||
e.currentTarget.style.borderColor = "transparent";
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={validImageUrl(f.url)}
|
||||
alt={f.name}
|
||||
className="w-full h-[100px] object-cover block"
|
||||
/>
|
||||
<span className="text-xs py-1 text-center overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{f.name}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { uploadMediaAndReturn } from "@/actions/admin-media";
|
||||
|
||||
type MediaFile = { name: string; url: string };
|
||||
export function useMediaLibrary(
|
||||
open: boolean,
|
||||
setOpen: (open: boolean) => void,
|
||||
onSelect: (url: string) => void,
|
||||
) {
|
||||
const [files, setFiles] = useState<MediaFile[]>([]);
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await fetch("/api/media");
|
||||
if (!res.ok) throw Error("Could not load media library");
|
||||
const data = await res.json();
|
||||
setFiles(data.files ?? []);
|
||||
} catch {
|
||||
setError("Could not load media library. Please retry.");
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) load();
|
||||
}, [open, load]);
|
||||
|
||||
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const chosen = Array.from(e.target.files ?? []);
|
||||
if (!chosen.length || uploading) return;
|
||||
setUploading(true);
|
||||
setError("");
|
||||
const urls: string[] = [];
|
||||
try {
|
||||
for (const file of chosen) {
|
||||
const fd = new FormData();
|
||||
fd.set("file", file);
|
||||
const url = await uploadMediaAndReturn(fd);
|
||||
if (!url)
|
||||
throw Error(
|
||||
`Could not upload ${file.name}. Check file type and size.`,
|
||||
);
|
||||
urls.push(url);
|
||||
}
|
||||
await load();
|
||||
onSelect(urls[urls.length - 1]);
|
||||
setOpen(false);
|
||||
} catch (err) {
|
||||
if (urls.length) await load();
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Upload failed. Please retry.",
|
||||
);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
return { files, loading, uploading, fileRef, error, load, handleUpload };
|
||||
}
|
||||
Reference in new issue
Block a user