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
File diff suppressed because it is too large.
Load diff
@@ -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" />
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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(() => {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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