feat(admin): reorganize housekeeping and strengthen shared workflows
This commit is contained in:
1 parent
35f66d879f
commit
a070004e7b
35 files changed
+1667
-462
No files matched your search
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
import { Star, X } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { AdminNavItem } from "@/lib/admin-nav";
|
||||
import { parseFavoritePages } from "@/lib/admin-navigation";
|
||||
import { AdminNavLink } from "./admin-nav-link";
|
||||
|
||||
export function AdminFavorites({
|
||||
userId,
|
||||
items,
|
||||
}: {
|
||||
userId: number;
|
||||
items: AdminNavItem[];
|
||||
}) {
|
||||
const t = useTranslations("pages.admin.nav");
|
||||
const key = `admin-favorites:${userId}`;
|
||||
const [saved, setSaved] = useState<{ key: string; hrefs: string[] }>({
|
||||
key: "",
|
||||
hrefs: [],
|
||||
});
|
||||
const [storageError, setStorageError] = useState(false);
|
||||
useEffect(() => {
|
||||
try {
|
||||
setSaved({ key, hrefs: parseFavoritePages(localStorage.getItem(key)) });
|
||||
} catch {
|
||||
setSaved({ key, hrefs: [] });
|
||||
}
|
||||
}, [key]);
|
||||
const favorites =
|
||||
saved.key === key
|
||||
? saved.hrefs.flatMap((href) => items.find((i) => i.href === href) ?? [])
|
||||
: [];
|
||||
function save(hrefs: string[]) {
|
||||
setSaved({ key, hrefs });
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(hrefs));
|
||||
setStorageError(false);
|
||||
} catch {
|
||||
setStorageError(true);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<section
|
||||
className="mb-3 border-b border-[var(--admin-border)] pb-3"
|
||||
aria-label={t("favorites")}
|
||||
>
|
||||
<h2
|
||||
style={{ fontSize: ".75rem", margin: 0 }}
|
||||
className="flex items-center gap-2 px-2 py-2 text-xs font-semibold text-[var(--admin-sidebar-muted-readable)]"
|
||||
>
|
||||
<Star size={14} aria-hidden />
|
||||
{t("favorites")}
|
||||
</h2>
|
||||
{favorites.map((item) => (
|
||||
<div key={item.href} className="flex items-center">
|
||||
<div className="min-w-0 flex-1">
|
||||
<AdminNavLink {...item} icon={<item.icon size={16} />}>
|
||||
{t(item.labelKey)}
|
||||
</AdminNavLink>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
save(
|
||||
favorites
|
||||
.filter((i) => i.href !== item.href)
|
||||
.map((i) => i.href),
|
||||
)
|
||||
}
|
||||
aria-label={t("removeFavorite", { name: t(item.labelKey) })}
|
||||
className="grid h-9 w-9 shrink-0 place-items-center rounded focus-visible:ring-2 focus-visible:ring-[var(--admin-accent)]"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{favorites.length < 6 && (
|
||||
<select
|
||||
aria-label={t("addFavorite")}
|
||||
value=""
|
||||
disabled={saved.key !== key}
|
||||
onChange={(event) => {
|
||||
if (event.target.value)
|
||||
save([...favorites.map((i) => i.href), event.target.value]);
|
||||
}}
|
||||
className="mt-1 h-9 w-full min-w-0 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-sidebar-background)] px-2 text-xs text-[var(--admin-sidebar-text-readable)]"
|
||||
>
|
||||
<option value="">{t("addFavorite")}</option>
|
||||
{items
|
||||
.filter((item) => !favorites.some((f) => f.href === item.href))
|
||||
.map((item) => (
|
||||
<option key={item.href} value={item.href}>
|
||||
{t(item.labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{storageError && (
|
||||
<p role="status" className="px-2 text-xs">
|
||||
{t("favoriteStorageError")}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Menu, X } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
@@ -26,6 +27,7 @@ export function AdminMobileWrapper({
|
||||
sidebar: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const t = useTranslations("pages.admin.nav");
|
||||
const [open, setOpen] = useState(false);
|
||||
const close = useCallback(() => setOpen(false), []);
|
||||
const mobile = useMemo(() => ({ close }), [close]);
|
||||
@@ -57,6 +59,9 @@ export function AdminMobileWrapper({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
aria-label={t(open ? "closeSidebar" : "openSidebar")}
|
||||
aria-expanded={open}
|
||||
aria-controls="admin-sidebar-panel"
|
||||
className="flex items-center justify-center w-9 h-9 rounded-lg
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--admin-accent)] focus-visible:ring-offset-1 focus-visible:ring-offset-[var(--admin-sidebar-background)]"
|
||||
style={{ color: "var(--admin-sidebar-text-readable)" }}
|
||||
@@ -67,7 +72,7 @@ export function AdminMobileWrapper({
|
||||
className="text-sm font-bold"
|
||||
style={{ color: "var(--admin-sidebar-text-readable)" }}
|
||||
>
|
||||
Admin Panel
|
||||
{t("housekeeping")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -75,7 +80,7 @@ export function AdminMobileWrapper({
|
||||
{open && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close menu"
|
||||
aria-label={t("closeSidebar")}
|
||||
className="fixed inset-0 z-40 lg:hidden"
|
||||
onClick={() => setOpen(false)}
|
||||
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
|
||||
@@ -84,6 +89,7 @@ export function AdminMobileWrapper({
|
||||
|
||||
{/* Sidebar */}
|
||||
<div
|
||||
id="admin-sidebar-panel"
|
||||
className={`fixed inset-y-0 left-0 z-50 flex h-full w-[260px] flex-col overflow-hidden lg:!relative lg:shrink-0 lg:!translate-x-0 ${
|
||||
open ? "translate-x-0" : "-translate-x-full"
|
||||
}`}
|
||||
|
||||
@@ -11,16 +11,11 @@ import { usePathname } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { AdminNavLink } from "@/components/admin/admin-nav-link";
|
||||
import {
|
||||
ADMIN_NAV_GROUPS,
|
||||
type AdminNavItem,
|
||||
navItemIsAllowed,
|
||||
} from "@/lib/admin-nav";
|
||||
import {
|
||||
type AdminNavConfig,
|
||||
applyAdminNavConfig,
|
||||
} from "@/lib/admin-nav-config";
|
||||
import type { AdminNavItem } from "@/lib/admin-nav";
|
||||
import type { AdminNavConfig } from "@/lib/admin-nav-config";
|
||||
import { visibleAdminNavigation } from "@/lib/admin-navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AdminFavorites } from "./admin-favorites";
|
||||
|
||||
const STORAGE_KEY = "admin-nav-collapsed";
|
||||
|
||||
@@ -67,10 +62,12 @@ function persistCollapsed(next: Record<string, boolean>) {
|
||||
}
|
||||
|
||||
export function AdminSidebarNav({
|
||||
userId = 0,
|
||||
allowedPermissions = [],
|
||||
isSuperAdmin = false,
|
||||
navConfig = {},
|
||||
}: {
|
||||
userId?: number;
|
||||
/** ACL slugs the staff member holds (ignored when isSuperAdmin). */
|
||||
allowedPermissions?: string[];
|
||||
isSuperAdmin?: boolean;
|
||||
@@ -83,24 +80,12 @@ export function AdminSidebarNav({
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const allowed = useMemo(
|
||||
() => new Set(allowedPermissions),
|
||||
[allowedPermissions],
|
||||
const groups = useMemo(
|
||||
() =>
|
||||
visibleAdminNavigation({ allowedPermissions, isSuperAdmin, navConfig }),
|
||||
[allowedPermissions, isSuperAdmin, navConfig],
|
||||
);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const access = {
|
||||
isSuperAdmin,
|
||||
has: (slug: string) => allowed.has(slug),
|
||||
};
|
||||
return applyAdminNavConfig(ADMIN_NAV_GROUPS, navConfig)
|
||||
.map((group) => ({
|
||||
...group,
|
||||
items: group.items.filter((item) => navItemIsAllowed(item, access)),
|
||||
}))
|
||||
.filter((group) => group.items.length > 0);
|
||||
}, [allowed, isSuperAdmin, navConfig]);
|
||||
|
||||
const search = query.trim().toLocaleLowerCase();
|
||||
const visibleGroups = useMemo(
|
||||
() =>
|
||||
@@ -278,6 +263,12 @@ export function AdminSidebarNav({
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 py-3">
|
||||
{!search && (
|
||||
<AdminFavorites
|
||||
userId={userId}
|
||||
items={groups.flatMap((group) => group.items)}
|
||||
/>
|
||||
)}
|
||||
{visibleGroups.length === 0 && (
|
||||
<p
|
||||
role="status"
|
||||
|
||||
@@ -6,11 +6,14 @@ import { SearchDialog } from "@/components/admin/search-dialog";
|
||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||
import { ThemeSwitcher } from "@/components/theme-switcher";
|
||||
import { findAdminHub } from "@/lib/admin-nav";
|
||||
import type { AdminNavigationOptions } from "@/lib/admin-navigation";
|
||||
|
||||
export function AdminTopbar({
|
||||
staff,
|
||||
navigation,
|
||||
}: {
|
||||
staff: { id: number; username: string; rank: number };
|
||||
navigation: AdminNavigationOptions;
|
||||
}) {
|
||||
const tNav = useTranslations("pages.admin.nav");
|
||||
const tHubs = useTranslations("pages.admin.hubs");
|
||||
@@ -19,7 +22,7 @@ export function AdminTopbar({
|
||||
const title = hub ? tHubs(hub.titleKey) : tNav("housekeeping");
|
||||
|
||||
return (
|
||||
<header className="flex items-center justify-between gap-4 bg-[var(--admin-surface)] border border-[var(--admin-border)] rounded-2xl px-5 py-3 mb-5 shadow-card">
|
||||
<header className="flex items-center justify-between flex-wrap gap-3 bg-[var(--admin-surface)] border border-[var(--admin-border)] rounded-2xl px-5 py-3 mb-5 shadow-card">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="m-0 text-base font-extrabold text-[var(--admin-text)]">
|
||||
{title}
|
||||
@@ -30,7 +33,7 @@ export function AdminTopbar({
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<SearchDialog />
|
||||
<SearchDialog navigation={navigation} />
|
||||
<div className="flex items-center gap-1.5 lg:hidden">
|
||||
<LanguageSwitcher variant="admin" />
|
||||
<ThemeSwitcher variant="admin" />
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { count, eq, ne } from "drizzle-orm";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "@/components/link";
|
||||
import { db, WebsiteHelpCenterTickets, WebsiteTicket } from "@/lib/db";
|
||||
import { ErrorStore } from "@/lib/error-monitor";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { logServerError } from "@/lib/server-log";
|
||||
import { ImportJobStore } from "@/lib/services/furni-job-store";
|
||||
|
||||
export async function AdminWorkQueue() {
|
||||
const [{ session, permissions }, t] = await Promise.all([
|
||||
getAdminContext(),
|
||||
getTranslations("pages.admin.workQueue"),
|
||||
]);
|
||||
const allowed = (permission: string) =>
|
||||
canAccess(permissions, permission, session.user.rank);
|
||||
const entries: { key: string; href: string; load: () => Promise<number> }[] =
|
||||
[];
|
||||
if (allowed(PERMS.TICKETS_VIEW))
|
||||
entries.push({
|
||||
key: "tickets",
|
||||
href: "/admin/tickets",
|
||||
load: async () => {
|
||||
const [cms, help] = await Promise.all([
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(WebsiteTicket)
|
||||
.where(ne(WebsiteTicket.status, "closed")),
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(WebsiteHelpCenterTickets)
|
||||
.where(eq(WebsiteHelpCenterTickets.open, true)),
|
||||
]);
|
||||
return (cms[0]?.total ?? 0) + (help[0]?.total ?? 0);
|
||||
},
|
||||
});
|
||||
if (allowed(PERMS.ASSETS_IMPORT))
|
||||
entries.push({
|
||||
key: "imports",
|
||||
href: "/admin/studio/furni",
|
||||
load: async () =>
|
||||
(await new ImportJobStore().list()).filter(
|
||||
(job) =>
|
||||
job.userId === Number(session.user.id) &&
|
||||
(job.state === "interrupted" ||
|
||||
job.items.some((item) => item.state === "failed")),
|
||||
).length,
|
||||
});
|
||||
if (allowed(PERMS.DEVOPS_VIEW))
|
||||
entries.push({
|
||||
key: "errors",
|
||||
href: "/admin/devops/cms-errors?status=open",
|
||||
load: async () =>
|
||||
new Set(
|
||||
(await new ErrorStore().read()).records
|
||||
.filter((record) => !record.resolved)
|
||||
.map((record) => record.fingerprint),
|
||||
).size,
|
||||
});
|
||||
const cards = await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
try {
|
||||
return { ...entry, count: await entry.load() };
|
||||
} catch (error) {
|
||||
logServerError("admin.work_queue_failed", error, { queue: entry.key });
|
||||
return { ...entry, count: null };
|
||||
}
|
||||
}),
|
||||
);
|
||||
if (!cards.length) return null;
|
||||
return (
|
||||
<section className="mb-6" aria-labelledby="work-queue">
|
||||
<h2
|
||||
id="work-queue"
|
||||
className="mb-3 text-sm font-bold text-[var(--admin-text)]"
|
||||
>
|
||||
{t("title")}
|
||||
</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{cards.map((card) => (
|
||||
<Link
|
||||
href={card.href}
|
||||
key={card.key}
|
||||
className="admin-card flex items-center gap-4 no-underline focus-visible:ring-2 focus-visible:ring-[var(--admin-accent)]"
|
||||
>
|
||||
<span className="text-2xl font-bold tabular-nums text-[var(--admin-text)]">
|
||||
{card.count ?? "—"}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<strong className="block text-sm text-[var(--admin-text)]">
|
||||
{t(card.key)}
|
||||
</strong>
|
||||
<span className="text-xs text-[var(--admin-text-muted)]">
|
||||
{card.count === null ? t("unavailable") : t(`${card.key}Hint`)}
|
||||
</span>
|
||||
</span>
|
||||
<ArrowRight size={16} aria-hidden />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useFormStatus } from "react-dom";
|
||||
import { useMemo, useRef, useState, useTransition } from "react";
|
||||
import { useUnsavedChanges } from "@/hooks/use-unsaved-changes";
|
||||
import { slugify } from "@/lib/format";
|
||||
import { ArticlePreview, type ArticlePreviewData } from "./article-preview";
|
||||
import { MediaPicker } from "./media-picker";
|
||||
@@ -25,6 +25,17 @@ export function ArticleForm({
|
||||
};
|
||||
}) {
|
||||
const t = useTranslations("pages.admin.articles.form");
|
||||
const tAction = useTranslations("pages.admin.actions");
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [saveError, setSaveError] = useState(false);
|
||||
const saving = useRef(false);
|
||||
const editVersion = useRef(0);
|
||||
function markDirty() {
|
||||
editVersion.current++;
|
||||
setDirty(true);
|
||||
}
|
||||
useUnsavedChanges(dirty, tAction("leaveUnsaved"));
|
||||
const [title, setTitle] = useState(defaultValues?.title ?? "");
|
||||
const [slug, setSlug] = useState(defaultValues?.slug ?? "");
|
||||
const [slugTouched, setSlugTouched] = useState(Boolean(defaultValues?.slug));
|
||||
@@ -39,175 +50,212 @@ export function ArticleForm({
|
||||
return (
|
||||
<form
|
||||
ref={formRef}
|
||||
action={action}
|
||||
onChangeCapture={markDirty}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (saving.current) return;
|
||||
const data = new FormData(event.currentTarget);
|
||||
const submittedVersion = editVersion.current;
|
||||
saving.current = true;
|
||||
setSaveError(false);
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await action(data);
|
||||
if (editVersion.current === submittedVersion) setDirty(false);
|
||||
} catch (error) {
|
||||
if (
|
||||
error &&
|
||||
typeof error === "object" &&
|
||||
"digest" in error &&
|
||||
String(error.digest).startsWith("NEXT_REDIRECT")
|
||||
) {
|
||||
setDirty(false);
|
||||
throw error;
|
||||
}
|
||||
setSaveError(true);
|
||||
} finally {
|
||||
saving.current = false;
|
||||
}
|
||||
});
|
||||
}}
|
||||
className="admin-card"
|
||||
style={{ display: "grid", gap: "1rem" }}
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
|
||||
{t("title")}
|
||||
</span>
|
||||
<input
|
||||
name="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={t("titlePlaceholder")}
|
||||
required
|
||||
className="input input-bordered mt-1 w-full"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
|
||||
{t("slug")}
|
||||
</span>
|
||||
<input
|
||||
name="slug"
|
||||
value={slugTouched ? slug : suggestedSlug}
|
||||
onChange={(e) => {
|
||||
setSlug(e.target.value);
|
||||
setSlugTouched(true);
|
||||
}}
|
||||
placeholder={t("slugPlaceholder")}
|
||||
className="input input-bordered mt-1 w-full"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="block">
|
||||
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
|
||||
{t("image")}
|
||||
</span>
|
||||
<div className="mt-1 flex gap-2 items-start">
|
||||
<input
|
||||
aria-label={t("image")}
|
||||
name="image"
|
||||
value={image}
|
||||
onChange={(e) => {
|
||||
setImage(e.target.value);
|
||||
setImageError(false);
|
||||
}}
|
||||
placeholder={t("imagePlaceholder")}
|
||||
className="input input-bordered min-w-0 flex-1"
|
||||
/>
|
||||
<MediaPicker
|
||||
onSelect={(url) => {
|
||||
setImage(url);
|
||||
setImageError(false);
|
||||
}}
|
||||
current={image}
|
||||
/>
|
||||
</div>
|
||||
{image && !imageError ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={image}
|
||||
alt="preview"
|
||||
onError={() => setImageError(true)}
|
||||
className="mt-2 rounded-lg border border-[var(--admin-text-muted)]/14 object-cover"
|
||||
style={{ maxHeight: 160 }}
|
||||
/>
|
||||
) : null}
|
||||
{imageError ? (
|
||||
<p className="muted mt-1" style={{ fontSize: "0.78rem" }}>
|
||||
Image not found — enter a valid URL or browse.
|
||||
<fieldset disabled={pending} className="contents">
|
||||
{saveError && (
|
||||
<p role="alert" className="text-sm">
|
||||
{tAction("saveError")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
|
||||
{t("body")}
|
||||
</span>
|
||||
<textarea
|
||||
name="shortStory"
|
||||
defaultValue={defaultValues?.shortStory ?? ""}
|
||||
placeholder={t("bodyPlaceholder")}
|
||||
rows={2}
|
||||
className="input input-bordered mt-1 w-full resize-y"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="block">
|
||||
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
|
||||
{t("body")}
|
||||
</span>
|
||||
<RichText
|
||||
name="fullStory"
|
||||
defaultValue={defaultValues?.fullStory ?? ""}
|
||||
placeholder={t("bodyPlaceholder")}
|
||||
rows={12}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
|
||||
{t("status")}
|
||||
</span>
|
||||
<select
|
||||
name="status"
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
className="input input-bordered mt-1 w-full"
|
||||
>
|
||||
<option value="draft">{t("statusDraft")}</option>
|
||||
<option value="scheduled">{t("statusScheduled")}</option>
|
||||
<option value="published">{t("statusPublished")}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{status === "scheduled" ? (
|
||||
)}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
|
||||
{t("publishAt")}
|
||||
{t("title")}
|
||||
</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
name="publishAt"
|
||||
defaultValue={defaultValues?.publishAt ?? ""}
|
||||
name="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={t("titlePlaceholder")}
|
||||
required
|
||||
className="input input-bordered mt-1 w-full"
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<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)} />
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
|
||||
{t("slug")}
|
||||
</span>
|
||||
<input
|
||||
name="slug"
|
||||
value={slugTouched ? slug : suggestedSlug}
|
||||
onChange={(e) => {
|
||||
setSlug(e.target.value);
|
||||
setSlugTouched(true);
|
||||
}}
|
||||
placeholder={t("slugPlaceholder")}
|
||||
className="input input-bordered mt-1 w-full"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="block">
|
||||
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
|
||||
{t("image")}
|
||||
</span>
|
||||
<div className="mt-1 flex gap-2 items-start">
|
||||
<input
|
||||
aria-label={t("image")}
|
||||
name="image"
|
||||
value={image}
|
||||
onChange={(e) => {
|
||||
setImage(e.target.value);
|
||||
setImageError(false);
|
||||
}}
|
||||
placeholder={t("imagePlaceholder")}
|
||||
className="input input-bordered min-w-0 flex-1"
|
||||
/>
|
||||
<MediaPicker
|
||||
onSelect={(url) => {
|
||||
setImage(url);
|
||||
markDirty();
|
||||
setImageError(false);
|
||||
}}
|
||||
current={image}
|
||||
/>
|
||||
</div>
|
||||
{image && !imageError ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={image}
|
||||
alt="preview"
|
||||
onError={() => setImageError(true)}
|
||||
className="mt-2 rounded-lg border border-[var(--admin-text-muted)]/14 object-cover"
|
||||
style={{ maxHeight: 160 }}
|
||||
/>
|
||||
) : null}
|
||||
{imageError ? (
|
||||
<p className="muted mt-1" style={{ fontSize: "0.78rem" }}>
|
||||
Image not found — enter a valid URL or browse.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
|
||||
{t("body")}
|
||||
</span>
|
||||
<textarea
|
||||
name="shortStory"
|
||||
defaultValue={defaultValues?.shortStory ?? ""}
|
||||
placeholder={t("bodyPlaceholder")}
|
||||
rows={2}
|
||||
className="input input-bordered mt-1 w-full resize-y"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="block">
|
||||
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
|
||||
{t("body")}
|
||||
</span>
|
||||
<RichText
|
||||
onChange={markDirty}
|
||||
name="fullStory"
|
||||
defaultValue={defaultValues?.fullStory ?? ""}
|
||||
placeholder={t("bodyPlaceholder")}
|
||||
rows={12}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
|
||||
{t("status")}
|
||||
</span>
|
||||
<select
|
||||
name="status"
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
className="input input-bordered mt-1 w-full"
|
||||
>
|
||||
<option value="draft">{t("statusDraft")}</option>
|
||||
<option value="scheduled">{t("statusScheduled")}</option>
|
||||
<option value="published">{t("statusPublished")}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{status === "scheduled" ? (
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
|
||||
{t("publishAt")}
|
||||
</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
name="publishAt"
|
||||
defaultValue={defaultValues?.publishAt ?? ""}
|
||||
required
|
||||
className="input input-bordered mt-1 w-full"
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<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") ?? ""),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{tAction("preview")}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={pending}
|
||||
aria-busy={pending}
|
||||
>
|
||||
{pending ? tAction("saving") : t("save")}
|
||||
</button>
|
||||
{dirty && (
|
||||
<span
|
||||
role="status"
|
||||
className="self-center text-xs text-[var(--admin-text-muted)]"
|
||||
>
|
||||
{tAction("unsaved")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ArticlePreview data={preview} onClose={() => setPreview(null)} />
|
||||
</fieldset>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -12,8 +12,10 @@ import {
|
||||
SlidersHorizontal,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useRef, useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
@@ -38,8 +40,8 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useDebounce } from "@/hooks/use-debounce";
|
||||
import { PER_PAGE_OPTIONS } from "@/lib/admin-helpers";
|
||||
import { tablePageNumbers } from "@/lib/admin-table-state";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types/common";
|
||||
|
||||
/**
|
||||
@@ -68,29 +70,47 @@ interface DataTableProps<T extends { id: number | string }> {
|
||||
export function DataTable<T extends { id: number | string }>({
|
||||
data,
|
||||
columns,
|
||||
searchPlaceholder = "Search...",
|
||||
searchPlaceholder,
|
||||
bulkActions,
|
||||
onBulkAction,
|
||||
exportUrl,
|
||||
presets,
|
||||
}: DataTableProps<T>) {
|
||||
const t = useTranslations("pages.admin.table");
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number | string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [searchValue, setSearchValue] = useState(
|
||||
searchParams.get("search") || "",
|
||||
);
|
||||
|
||||
const scope = `${pathname}?${searchParams.toString()}:${data.rows.map((row) => row.id).join(",")}`;
|
||||
const [selection, setSelection] = useState<{
|
||||
scope: string;
|
||||
ids: Set<number | string>;
|
||||
}>({ scope, ids: new Set() });
|
||||
const selectedIds =
|
||||
selection.scope === scope ? selection.ids : new Set<number | string>();
|
||||
function setSelectedIds(
|
||||
value:
|
||||
| Set<number | string>
|
||||
| ((prev: Set<number | string>) => Set<number | string>),
|
||||
) {
|
||||
setSelection({
|
||||
scope,
|
||||
ids: typeof value === "function" ? value(selectedIds) : value,
|
||||
});
|
||||
}
|
||||
const urlSearch = searchParams.get("search") ?? "";
|
||||
const [draft, setDraft] = useState({ base: urlSearch, value: urlSearch });
|
||||
if (draft.base !== urlSearch) setDraft({ base: urlSearch, value: urlSearch });
|
||||
const searchValue = draft.base === urlSearch ? draft.value : urlSearch;
|
||||
const [bulkBusy, setBulkBusy] = useState(false);
|
||||
const bulkLock = useRef(false);
|
||||
|
||||
const [hiddenColumns, setHiddenColumns] = useState<Set<string>>(new Set());
|
||||
|
||||
const currentSort = searchParams.get("sort") || "";
|
||||
const currentOrder = searchParams.get("order") || "desc";
|
||||
|
||||
const debouncedSearch = useDebounce(searchValue, 300);
|
||||
const isFirstRender = useRef(true);
|
||||
|
||||
// Collect active filters from URL
|
||||
const filterColumns = columns.filter(
|
||||
(col) => col.filterOptions && col.filterOptions.length > 0,
|
||||
@@ -110,27 +130,31 @@ export function DataTable<T extends { id: number | string }>({
|
||||
else params.delete(key);
|
||||
}
|
||||
startTransition(() => {
|
||||
router.push(`?${params.toString()}`);
|
||||
router.push(`?${params.toString()}`, { scroll: false });
|
||||
});
|
||||
}
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
||||
useEffect(() => {
|
||||
if (isFirstRender.current) {
|
||||
isFirstRender.current = false;
|
||||
return;
|
||||
}
|
||||
updateParams({ search: debouncedSearch, page: "1" });
|
||||
}, [debouncedSearch]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (searchValue === urlSearch) return;
|
||||
const timer = setTimeout(() => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (searchValue) params.set("search", searchValue);
|
||||
else params.delete("search");
|
||||
params.set("page", "1");
|
||||
startTransition(() =>
|
||||
router.replace(`?${params.toString()}`, { scroll: false }),
|
||||
);
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchValue, urlSearch, searchParams, router]);
|
||||
function handleSearch(value: string) {
|
||||
setSearchValue(value);
|
||||
setDraft({ base: urlSearch, value });
|
||||
}
|
||||
|
||||
function handleSort(key: string) {
|
||||
const newOrder =
|
||||
currentSort === key && currentOrder === "asc" ? "desc" : "asc";
|
||||
updateParams({ sort: key, order: newOrder });
|
||||
updateParams({ sort: key, order: newOrder, page: "1" });
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
@@ -173,10 +197,33 @@ export function DataTable<T extends { id: number | string }>({
|
||||
}
|
||||
|
||||
async function handleBulkAction(action: string) {
|
||||
if (selectedIds.size === 0 || !onBulkAction) return;
|
||||
await onBulkAction(action, Array.from(selectedIds));
|
||||
setSelectedIds(new Set());
|
||||
router.refresh();
|
||||
if (
|
||||
selectedIds.size === 0 ||
|
||||
!onBulkAction ||
|
||||
bulkLock.current ||
|
||||
isPending
|
||||
)
|
||||
return;
|
||||
const definition = bulkActions?.find((item) => item.value === action);
|
||||
if (
|
||||
definition?.variant === "destructive" &&
|
||||
!window.confirm(
|
||||
t("confirmBulk", { action: definition.label, count: selectedIds.size }),
|
||||
)
|
||||
)
|
||||
return;
|
||||
bulkLock.current = true;
|
||||
setBulkBusy(true);
|
||||
try {
|
||||
await onBulkAction(action, Array.from(selectedIds));
|
||||
setSelectedIds(new Set());
|
||||
router.refresh();
|
||||
} catch {
|
||||
toast.error(t("bulkError"));
|
||||
} finally {
|
||||
bulkLock.current = false;
|
||||
setBulkBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function getSortIcon(key: string) {
|
||||
@@ -206,7 +253,7 @@ export function DataTable<T extends { id: number | string }>({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4 min-w-0" aria-busy={isPending || bulkBusy}>
|
||||
{/* Preset filter chips */}
|
||||
{presets && presets.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
@@ -236,9 +283,11 @@ export function DataTable<T extends { id: number | string }>({
|
||||
<div className="relative flex-1 min-w-[200px] max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={searchPlaceholder}
|
||||
placeholder={searchPlaceholder ?? t("search")}
|
||||
aria-label={searchPlaceholder ?? t("search")}
|
||||
value={searchValue}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
style={{ paddingLeft: 36 }}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
@@ -249,7 +298,7 @@ export function DataTable<T extends { id: number | string }>({
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<Filter className="h-4 w-4" />
|
||||
Filters
|
||||
{t("filters")}
|
||||
{activeFilterCount > 0 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
@@ -263,7 +312,7 @@ export function DataTable<T extends { id: number | string }>({
|
||||
<PopoverContent className="w-72" align="start">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium">Filters</h4>
|
||||
<h4 className="text-sm font-medium">{t("filters")}</h4>
|
||||
{activeFilterCount > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -271,7 +320,7 @@ export function DataTable<T extends { id: number | string }>({
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={clearAllFilters}
|
||||
>
|
||||
Clear all
|
||||
{t("clearAll")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -291,7 +340,7 @@ export function DataTable<T extends { id: number | string }>({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">All</SelectItem>
|
||||
<SelectItem value="__all__">{t("all")}</SelectItem>
|
||||
{col.filterOptions?.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
@@ -320,6 +369,7 @@ export function DataTable<T extends { id: number | string }>({
|
||||
{col.label}: {opt?.label || val}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("removeFilter", { name: col.label })}
|
||||
onClick={() => handleFilterChange(col, "__all__")}
|
||||
className="ml-0.5 rounded-full p-0.5 hover:bg-accent"
|
||||
>
|
||||
@@ -336,12 +386,12 @@ export function DataTable<T extends { id: number | string }>({
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<SlidersHorizontal className="h-4 w-4" />
|
||||
Columns
|
||||
{t("columns")}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-48" align="start">
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-sm font-medium mb-2">Toggle columns</h4>
|
||||
<h4 className="text-sm font-medium mb-2">{t("columns")}</h4>
|
||||
{columns.map((col) => (
|
||||
<div
|
||||
key={col.key}
|
||||
@@ -349,6 +399,7 @@ export function DataTable<T extends { id: number | string }>({
|
||||
>
|
||||
<Checkbox
|
||||
checked={!hiddenColumns.has(col.key)}
|
||||
aria-label={col.label}
|
||||
onCheckedChange={(checked) => {
|
||||
setHiddenColumns((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -375,7 +426,7 @@ export function DataTable<T extends { id: number | string }>({
|
||||
<SelectContent>
|
||||
{PER_PAGE_OPTIONS.map((n) => (
|
||||
<SelectItem key={n} value={String(n)}>
|
||||
{n} / page
|
||||
{t("perPage", { count: n })}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -393,13 +444,14 @@ export function DataTable<T extends { id: number | string }>({
|
||||
{bulkActions && selectedIds.size > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{selectedIds.size} selected
|
||||
{t("selected", { count: selectedIds.size })}
|
||||
</span>
|
||||
{bulkActions.map((action) => (
|
||||
<Button
|
||||
key={action.value}
|
||||
variant={action.variant || "default"}
|
||||
size="sm"
|
||||
disabled={bulkBusy || isPending}
|
||||
onClick={() => handleBulkAction(action.value)}
|
||||
>
|
||||
{action.label}
|
||||
@@ -409,7 +461,7 @@ export function DataTable<T extends { id: number | string }>({
|
||||
)}
|
||||
|
||||
<div className="ml-auto text-sm text-muted-foreground">
|
||||
{data.total} results
|
||||
{t("results", { count: data.total })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -425,6 +477,8 @@ export function DataTable<T extends { id: number | string }>({
|
||||
selectedIds.size === data.rows.length &&
|
||||
data.rows.length > 0
|
||||
}
|
||||
aria-label={t("selectAll")}
|
||||
disabled={isPending || bulkBusy}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
@@ -459,7 +513,7 @@ export function DataTable<T extends { id: number | string }>({
|
||||
}
|
||||
className="h-24 text-center text-muted-foreground"
|
||||
>
|
||||
No results found.
|
||||
{t("empty")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
@@ -473,6 +527,8 @@ export function DataTable<T extends { id: number | string }>({
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedIds.has(row.id)}
|
||||
aria-label={t("selectRow", { id: String(row.id) })}
|
||||
disabled={isPending || bulkBusy}
|
||||
onCheckedChange={() => toggleSelect(row.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
@@ -504,31 +560,25 @@ export function DataTable<T extends { id: number | string }>({
|
||||
{data.lastPage > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Page {data.page} of {data.lastPage}
|
||||
{t("pageOf", { page: data.page, last: data.lastPage })}
|
||||
</p>
|
||||
<div className="flex items-center gap-1 flex-wrap justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled={data.page <= 1}
|
||||
aria-label={t("previous")}
|
||||
disabled={data.page <= 1 || isPending}
|
||||
onClick={() => handlePageChange(data.page - 1)}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
{Array.from({ length: Math.min(data.lastPage, 5) }, (_, i) => {
|
||||
let pageNum: number;
|
||||
if (data.lastPage <= 7) {
|
||||
pageNum = i + 1;
|
||||
} else if (data.page <= 4) {
|
||||
pageNum = i + 1;
|
||||
} else if (data.page >= data.lastPage - 3) {
|
||||
pageNum = data.lastPage - 6 + i;
|
||||
} else {
|
||||
pageNum = data.page - 3 + i;
|
||||
}
|
||||
{tablePageNumbers(data.page, data.lastPage).map((pageNum) => {
|
||||
return (
|
||||
<Button
|
||||
key={pageNum}
|
||||
aria-label={t("page", { page: pageNum })}
|
||||
aria-current={pageNum === data.page ? "page" : undefined}
|
||||
disabled={isPending}
|
||||
variant={pageNum === data.page ? "default" : "outline"}
|
||||
size="icon"
|
||||
onClick={() => handlePageChange(pageNum)}
|
||||
@@ -540,7 +590,8 @@ export function DataTable<T extends { id: number | string }>({
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled={data.page >= data.lastPage}
|
||||
aria-label={t("next")}
|
||||
disabled={data.page >= data.lastPage || isPending}
|
||||
onClick={() => handlePageChange(data.page + 1)}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
|
||||
@@ -57,13 +57,17 @@ export function RichText({
|
||||
defaultValue = "",
|
||||
placeholder,
|
||||
rows = 10,
|
||||
onChange,
|
||||
}: {
|
||||
name: string;
|
||||
defaultValue?: string;
|
||||
placeholder?: string;
|
||||
rows?: number;
|
||||
onChange?: () => void;
|
||||
}) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const changeCallback = useRef(onChange);
|
||||
changeCallback.current = onChange;
|
||||
const reactId = useId();
|
||||
// Stable, querySelector-safe id (useId() contains ':').
|
||||
const editorId = `rt-${reactId.replace(/[^a-zA-Z0-9_-]/g, "")}`;
|
||||
@@ -94,11 +98,16 @@ export function RichText({
|
||||
on: (ev: string, cb: () => void) => void;
|
||||
getContent: () => string;
|
||||
}) => {
|
||||
const sync = () => {
|
||||
if (textareaRef.current)
|
||||
textareaRef.current.value = editor.getContent();
|
||||
const sync = (notify: boolean) => {
|
||||
const field = textareaRef.current;
|
||||
if (!field) return;
|
||||
const html = editor.getContent();
|
||||
const changed = field.value !== html;
|
||||
field.value = html;
|
||||
if (notify && changed) changeCallback.current?.();
|
||||
};
|
||||
editor.on("change keyup undo redo SetContent", sync);
|
||||
editor.on("SetContent", () => sync(false));
|
||||
editor.on("change keyup undo redo", () => sync(true));
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
|
||||
@@ -13,8 +13,17 @@ import {
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useDebounce } from "@/hooks/use-debounce";
|
||||
import {
|
||||
type AdminNavigationOptions,
|
||||
visibleAdminNavigation,
|
||||
} from "@/lib/admin-navigation";
|
||||
|
||||
type SearchResult = AdminSearchResult;
|
||||
|
||||
@@ -23,13 +32,30 @@ type SearchResponse = {
|
||||
results: SearchResult[];
|
||||
};
|
||||
|
||||
export function SearchDialog() {
|
||||
export function SearchDialog({
|
||||
navigation,
|
||||
}: {
|
||||
navigation: AdminNavigationOptions;
|
||||
}) {
|
||||
const tNav = useTranslations("pages.admin.nav");
|
||||
const getPages = (queryPlaceholder: string) =>
|
||||
visibleAdminNavigation(navigation)
|
||||
.flatMap((group) =>
|
||||
group.items.map((item) => ({ ...item, group: tNav(group.labelKey) })),
|
||||
)
|
||||
.filter((item) =>
|
||||
[tNav(item.labelKey), item.group, item.href]
|
||||
.join(" ")
|
||||
.toLocaleLowerCase()
|
||||
.includes(queryPlaceholder),
|
||||
);
|
||||
const t = useTranslations("pages.admin.search");
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const debouncedQuery = useDebounce(query, 250);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
@@ -44,6 +70,8 @@ export function SearchDialog() {
|
||||
|
||||
const fetchResults = useCallback(async (q: string) => {
|
||||
abortRef.current?.abort();
|
||||
setFailed(false);
|
||||
setResults([]);
|
||||
if (q.length < 2) {
|
||||
setResults([]);
|
||||
setLoading(false);
|
||||
@@ -56,21 +84,27 @@ export function SearchDialog() {
|
||||
const res = await fetch(`/api/admin/search?q=${encodeURIComponent(q)}`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) return;
|
||||
if (!res.ok) throw new Error("Search request failed");
|
||||
const data: SearchResponse = await res.json();
|
||||
if (abortRef.current !== controller) return;
|
||||
if (abortRef.current !== controller || controller.signal.aborted) return;
|
||||
if (data.ok) setResults(data.results);
|
||||
else setFailed(true);
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") return;
|
||||
if (abortRef.current === controller) setResults([]);
|
||||
if (abortRef.current === controller) {
|
||||
setResults([]);
|
||||
setFailed(true);
|
||||
}
|
||||
} finally {
|
||||
if (abortRef.current === controller) setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
fetchResults(debouncedQuery);
|
||||
}, [debouncedQuery, fetchResults]);
|
||||
return () => abortRef.current?.abort();
|
||||
}, [debouncedQuery, fetchResults, open]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => abortRef.current?.abort();
|
||||
@@ -94,13 +128,15 @@ export function SearchDialog() {
|
||||
router.push(url);
|
||||
}
|
||||
|
||||
const grouped = groupResults(results);
|
||||
const grouped = groupResults(query === debouncedQuery ? results : []);
|
||||
const pages = getPages(query.trim().toLocaleLowerCase()).slice(0, 8);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label={t("placeholder")}
|
||||
className="flex items-center gap-2 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-1.5 text-xs text-[var(--admin-text-muted)] hover:border-[var(--admin-accent)]/40 hover:text-[var(--admin-text)] transition-colors cursor-pointer"
|
||||
>
|
||||
<SearchIcon size={14} />
|
||||
@@ -110,6 +146,10 @@ export function SearchDialog() {
|
||||
</kbd>
|
||||
</button>
|
||||
<DialogContent className="sm:max-w-lg p-0 gap-0 overflow-hidden">
|
||||
<DialogTitle className="sr-only">{t("placeholder")}</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
{t("description")}
|
||||
</DialogDescription>
|
||||
<Command
|
||||
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground"
|
||||
shouldFilter={false}
|
||||
@@ -126,7 +166,31 @@ export function SearchDialog() {
|
||||
…
|
||||
</div>
|
||||
) : null}
|
||||
<CommandEmpty>{t("noResults")}</CommandEmpty>
|
||||
{!loading && !failed && (
|
||||
<CommandEmpty>{t("noResults")}</CommandEmpty>
|
||||
)}
|
||||
{failed && (
|
||||
<p role="alert" className="p-4 text-sm">
|
||||
{t("loadError")}
|
||||
</p>
|
||||
)}
|
||||
{pages.length > 0 && (
|
||||
<CommandGroup heading={t("pages")}>
|
||||
{pages.map((item) => (
|
||||
<CommandItem
|
||||
key={item.href}
|
||||
value={item.href}
|
||||
onSelect={() => handleSelect(item.href)}
|
||||
>
|
||||
<item.icon size={16} />
|
||||
<span>{tNav(item.labelKey)}</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{item.group}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{Object.entries(grouped).map(([group, items]) => (
|
||||
<CommandGroup key={group} heading={groupLabels[group] ?? group}>
|
||||
{items.map((item) => (
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
"use client";
|
||||
import { Search, X } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
type SearchInputProps = {
|
||||
@@ -15,21 +17,24 @@ export function SearchInput({
|
||||
placeholder,
|
||||
onClear,
|
||||
}: SearchInputProps) {
|
||||
const t = useTranslations("pages.admin.table");
|
||||
return (
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
aria-label={placeholder ?? t("search")}
|
||||
placeholder={placeholder ?? t("search")}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="pl-9 h-9"
|
||||
style={{ paddingLeft: 36, paddingRight: 40 }}
|
||||
className="pl-9 pr-10 h-9"
|
||||
/>
|
||||
{value && onClear && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Clear search"
|
||||
aria-label={t("clearSearch")}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
@@ -1,26 +1,29 @@
|
||||
"use client";
|
||||
import { useFormatter, useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { retryableJobItems } from "@/lib/furni/import-job-retry";
|
||||
import type { useFurnitureJobs } from "./use-furniture-jobs";
|
||||
export function FurnitureJobHistory({
|
||||
state,
|
||||
}: {
|
||||
state: ReturnType<typeof useFurnitureJobs>;
|
||||
}) {
|
||||
const t = useTranslations("pages.admin.importHistory");
|
||||
const format = useFormatter();
|
||||
return (
|
||||
<details className="shrink-0 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-4 py-2 text-sm">
|
||||
<summary className="cursor-pointer">
|
||||
Import history ·{" "}
|
||||
{
|
||||
state.jobs.filter(
|
||||
(j) => j.state === "queued" || j.state === "running",
|
||||
).length
|
||||
}{" "}
|
||||
active
|
||||
{t("summary", {
|
||||
count: state.jobs.filter(
|
||||
(job) => job.state === "queued" || job.state === "running",
|
||||
).length,
|
||||
})}
|
||||
</summary>
|
||||
<div className="max-h-72 space-y-2 overflow-auto py-2">
|
||||
{state.error && <p role="alert">{state.error}</p>}
|
||||
{!state.jobs.length && <p>No background imports yet.</p>}
|
||||
{!state.jobs.length && <p>{t("empty")}</p>}
|
||||
{state.jobs.map((job) => {
|
||||
const retryItems = retryableJobItems(job);
|
||||
const done = job.items.filter((i) => i.state === "done"),
|
||||
failed = job.items.filter((i) => i.state === "failed");
|
||||
return (
|
||||
@@ -29,14 +32,27 @@ export function FurnitureJobHistory({
|
||||
className="rounded border border-[var(--admin-border)] p-2"
|
||||
>
|
||||
<summary className="cursor-pointer">
|
||||
{new Date(job.createdAt).toLocaleString()} · {job.state} ·{" "}
|
||||
{done.length}/{job.items.length} imported · {failed.length}{" "}
|
||||
failed
|
||||
{format.dateTime(new Date(job.createdAt), {
|
||||
dateStyle: "short",
|
||||
timeStyle: "short",
|
||||
})}{" "}
|
||||
· {t(job.state)} ·{" "}
|
||||
{t("result", {
|
||||
done: done.length,
|
||||
total: job.items.length,
|
||||
failed: failed.length,
|
||||
})}
|
||||
</summary>
|
||||
<progress
|
||||
className="mt-2 h-2 w-full"
|
||||
value={done.length + failed.length}
|
||||
max={Math.max(1, job.items.length)}
|
||||
aria-label={t("progress")}
|
||||
/>
|
||||
<ul className="space-y-1 py-2">
|
||||
{job.items.map((item) => (
|
||||
<li key={item.classname}>
|
||||
<strong>{item.classname}</strong> · {item.state}
|
||||
<strong>{item.classname}</strong> · {t(item.state)}
|
||||
{item.itemId ? ` · ID ${item.itemId}` : ""}
|
||||
{item.error && (
|
||||
<p className="text-[var(--admin-warning)]">
|
||||
@@ -54,12 +70,12 @@ export function FurnitureJobHistory({
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{failed.length > 0 && job.state !== "running" && (
|
||||
{retryItems.length > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={state.busy}
|
||||
onClick={() =>
|
||||
void state.submit(failed, {
|
||||
void state.submit(retryItems, {
|
||||
sourceId: job.sourceId,
|
||||
mode: job.mode,
|
||||
translate: job.translate,
|
||||
@@ -67,7 +83,11 @@ export function FurnitureJobHistory({
|
||||
})
|
||||
}
|
||||
>
|
||||
Retry failed items only
|
||||
{t(
|
||||
job.state === "interrupted"
|
||||
? "retryUnfinished"
|
||||
: "retryFailed",
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</details>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useVisiblePolling } from "@/hooks/use-visible-polling";
|
||||
import { adminFetch } from "@/lib/admin-fetch";
|
||||
import type { ImportJob, ImportJobItem } from "@/lib/furni/import-job";
|
||||
export function useFurnitureJobs(onComplete: () => void) {
|
||||
@@ -12,7 +13,10 @@ export function useFurnitureJobs(onComplete: () => void) {
|
||||
callback.current = onComplete;
|
||||
const request = useRef<{ payload: string; id: string } | null>(null);
|
||||
const submitting = useRef(false);
|
||||
const refreshing = useRef(false);
|
||||
const refresh = useCallback(async () => {
|
||||
if (refreshing.current) return;
|
||||
refreshing.current = true;
|
||||
try {
|
||||
const response = await adminFetch("/api/admin/studio/import-jobs");
|
||||
const data = await response.json();
|
||||
@@ -36,13 +40,11 @@ export function useFurnitureJobs(onComplete: () => void) {
|
||||
setError(
|
||||
error instanceof Error ? error.message : "Cannot load import history",
|
||||
);
|
||||
} finally {
|
||||
refreshing.current = false;
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
const timer = setInterval(() => void refresh(), 5000);
|
||||
return () => clearInterval(timer);
|
||||
}, [refresh]);
|
||||
useVisiblePolling(refresh, 5000);
|
||||
const submit = async (
|
||||
items: ImportJobItem[],
|
||||
options: {
|
||||
|
||||
Reference in new issue
Block a user