This commit is contained in:
2026-09-02 11:56:19 +03:30
parent fdbf02bc07
commit e248943f4c
4 changed files with 340 additions and 92 deletions
+82 -5
View File
@@ -14,8 +14,9 @@ import {
Textarea,
Modal,
PageHeader,
Switch,
} from "@/components/ui";
import { EditIcon, PlusIcon, TrashIcon } from "@/components/icons";
import { EditIcon, EyeIcon, PlusIcon, TrashIcon } from "@/components/icons";
import { MediaPreview } from "@/components/MediaPreview";
import { pickUrl } from "@/lib/media";
import { toFa } from "@/lib/utils";
@@ -29,6 +30,8 @@ interface Category {
order?: number;
description?: string | null;
icon?: string | null;
is_deeplink_only?: boolean;
deep_link?: string | null;
subcategories_count?: number;
}
@@ -48,9 +51,16 @@ const ICON_KEYS = ["icon", "icon_url"];
export default function CategoriesPage() {
const [typeFilter, setTypeFilter] = useState<CategoryType>("media");
const { data, loading, error, reload } = useList<Category>("/categories", {
type: typeFilter,
});
// Deep-link-only categories are hidden from the normal listing, so they need
// their own view here — otherwise a category sent to deep links disappears
// from this page and can only be brought back from the deep-links screen.
const [showDeepLinks, setShowDeepLinks] = useState(false);
const { data, loading, error, reload } = useList<Category>(
"/categories",
// `deeplink_only` returns every hidden category regardless of type, so the
// type filter is not sent alongside it.
showDeepLinks ? { deeplink_only: 1 } : { type: typeFilter },
);
const toast = useToast();
const [editing, setEditing] = useState<Category | null>(null);
@@ -128,6 +138,25 @@ export default function CategoriesPage() {
}
}
// Move a category into or out of deep-link-only mode, both directions.
// Playlist categories live in music_categories, which has no such column.
async function toggleDeepLink(row: Category) {
try {
await apiFetch(`/categories/${row.id}`, {
method: "POST",
body: toFormData({ is_deeplink_only: row.is_deeplink_only ? 0 : 1 }),
});
toast.success(
row.is_deeplink_only
? "به فهرست عادی بازگشت."
: "به لینک مستقیم تبدیل شد.",
);
reload();
} catch (err) {
toast.error(err instanceof ApiError ? err.message : "خطا در ذخیره‌سازی");
}
}
async function confirmDelete() {
if (!deleting) return;
setRemoving(true);
@@ -174,6 +203,25 @@ export default function CategoriesPage() {
header: "توضیحات",
render: (r) => r.description || "—",
},
{
key: "deep_link",
header: "لینک مستقیم",
className: "w-56",
render: (r) =>
r.is_deeplink_only ? (
<a
href={r.deep_link ?? undefined}
target="_blank"
rel="noreferrer"
dir="ltr"
className="inline-block max-w-[200px] truncate text-xs text-primary underline"
>
{r.deep_link ?? "—"}
</a>
) : (
<span className="text-xs text-muted">—</span>
),
},
{
key: "subcategories_count",
header: "زیر‌دسته‌ها",
@@ -189,11 +237,17 @@ export default function CategoriesPage() {
subtitle="مدیریت دسته‌بندی‌های عمومی رسانه، پلی‌لیست و قالب تنفس"
action={
<div className="flex items-center gap-3">
<Switch
checked={showDeepLinks}
onChange={setShowDeepLinks}
label="فقط لینک‌های مستقیم"
/>
<Select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value as CategoryType)}
aria-label="نوع دسته‌بندی"
className="w-40"
disabled={showDeepLinks}
>
{TYPE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
@@ -214,7 +268,11 @@ export default function CategoriesPage() {
loading={loading}
error={error}
onRetry={reload}
emptyMessage="هنوز دسته‌بندی‌ای ثبت نشده است. اولین دسته‌بندی را بسازید."
emptyMessage={
showDeepLinks
? "هیچ دسته‌بندی لینک مستقیمی وجود ندارد."
: "هنوز دسته‌بندی‌ای ثبت نشده است. اولین دسته‌بندی را بسازید."
}
emptyAction={
<Button icon={<PlusIcon className="h-4 w-4" />} onClick={openCreate}>
دسته‌بندی جدید
@@ -225,6 +283,25 @@ export default function CategoriesPage() {
<Button variant="ghost" onClick={() => openEdit(row)} aria-label="ویرایش">
<EditIcon className="h-4 w-4" />
</Button>
{row.type !== "playlist" && (
<Button
variant="ghost"
onClick={() => toggleDeepLink(row)}
aria-label={
row.is_deeplink_only
? "بازگرداندن به فهرست"
: "تبدیل به لینک مستقیم"
}
title={
row.is_deeplink_only
? "بازگرداندن به فهرست"
: "تبدیل به لینک مستقیم"
}
className={row.is_deeplink_only ? "text-primary" : undefined}
>
<EyeIcon className="h-4 w-4" />
</Button>
)}
<Button
variant="ghost"
onClick={() => setDeleting(row)}