Compare commits

...

11 Commits

19 changed files with 2664 additions and 288 deletions

239
package-lock.json generated
View File

@@ -8,7 +8,14 @@
"name": "guilan-ace-frontend",
"version": "0.0.0",
"dependencies": {
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/language": "^6.12.3",
"@codemirror/search": "^6.7.0",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.43.1",
"@hookform/resolvers": "^3.10.0",
"@lezer/highlight": "^1.2.3",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-aspect-ratio": "^1.1.7",
@@ -106,6 +113,147 @@
"node": ">=6.9.0"
}
},
"node_modules/@codemirror/autocomplete": {
"version": "6.20.3",
"resolved": "https://package-mirror.liara.ir/repository/npm/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
"integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/commands": {
"version": "6.10.3",
"resolved": "https://package-mirror.liara.ir/repository/npm/@codemirror/commands/-/commands-6.10.3.tgz",
"integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/lang-css": {
"version": "6.3.1",
"resolved": "https://package-mirror.liara.ir/repository/npm/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.2",
"@lezer/css": "^1.1.7"
}
},
"node_modules/@codemirror/lang-html": {
"version": "6.4.11",
"resolved": "https://package-mirror.liara.ir/repository/npm/@codemirror/lang-html/-/lang-html-6.4.11.tgz",
"integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/lang-css": "^6.0.0",
"@codemirror/lang-javascript": "^6.0.0",
"@codemirror/language": "^6.4.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/css": "^1.1.0",
"@lezer/html": "^1.3.12"
}
},
"node_modules/@codemirror/lang-javascript": {
"version": "6.2.5",
"resolved": "https://package-mirror.liara.ir/repository/npm/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz",
"integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.6.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/javascript": "^1.0.0"
}
},
"node_modules/@codemirror/lang-markdown": {
"version": "6.5.0",
"resolved": "https://package-mirror.liara.ir/repository/npm/@codemirror/lang-markdown/-/lang-markdown-6.5.0.tgz",
"integrity": "sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.7.1",
"@codemirror/lang-html": "^6.0.0",
"@codemirror/language": "^6.3.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/common": "^1.2.1",
"@lezer/markdown": "^1.0.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.12.3",
"resolved": "https://package-mirror.liara.ir/repository/npm/@codemirror/language/-/language-6.12.3.tgz",
"integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/lint": {
"version": "6.9.7",
"resolved": "https://package-mirror.liara.ir/repository/npm/@codemirror/lint/-/lint-6.9.7.tgz",
"integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.42.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/search": {
"version": "6.7.0",
"resolved": "https://package-mirror.liara.ir/repository/npm/@codemirror/search/-/search-6.7.0.tgz",
"integrity": "sha512-ZvGm99wc/s2cITtMT15LFdn8aH/aS+V+DqyGq/N5ZlV5vWtH+nILvC2nw0zX7ByNoHHDZ2IxxdW38O0tc5nVHg==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.37.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/state": {
"version": "6.6.0",
"resolved": "https://package-mirror.liara.ir/repository/npm/@codemirror/state/-/state-6.6.0.tgz",
"integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==",
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.43.1",
"resolved": "https://package-mirror.liara.ir/repository/npm/@codemirror/view/-/view-6.43.1.tgz",
"integrity": "sha512-+BIjw/AG3tDQ4pJgTLPYdAW25eDE66YsvM4LKyVPgGzVgZ4a9Wj1SRX8kPVKgBDdPt8oHtZ15F0qx7p0oOHdHw==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.6.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://package-mirror.liara.ir/repository/npm/@emnapi/runtime/-/runtime-1.10.0.tgz",
@@ -914,6 +1062,79 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@lezer/common": {
"version": "1.5.2",
"resolved": "https://package-mirror.liara.ir/repository/npm/@lezer/common/-/common-1.5.2.tgz",
"integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
"license": "MIT"
},
"node_modules/@lezer/css": {
"version": "1.3.3",
"resolved": "https://package-mirror.liara.ir/repository/npm/@lezer/css/-/css-1.3.3.tgz",
"integrity": "sha512-RzBo8r+/6QJeow7aPHIpGVIH59xTcJXp399820gZoMo9noQDRVpJLheIBUicYwKcsbOYoBRoLZlf2720dG/4Tg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/highlight": {
"version": "1.2.3",
"resolved": "https://package-mirror.liara.ir/repository/npm/@lezer/highlight/-/highlight-1.2.3.tgz",
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.3.0"
}
},
"node_modules/@lezer/html": {
"version": "1.3.13",
"resolved": "https://package-mirror.liara.ir/repository/npm/@lezer/html/-/html-1.3.13.tgz",
"integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/javascript": {
"version": "1.5.4",
"resolved": "https://package-mirror.liara.ir/repository/npm/@lezer/javascript/-/javascript-1.5.4.tgz",
"integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.1.3",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.10",
"resolved": "https://package-mirror.liara.ir/repository/npm/@lezer/lr/-/lr-1.4.10.tgz",
"integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@lezer/markdown": {
"version": "1.6.4",
"resolved": "https://package-mirror.liara.ir/repository/npm/@lezer/markdown/-/markdown-1.6.4.tgz",
"integrity": "sha512-N0SxazMj4k65DBfaf1azqtMZd6u7MqluP84/NZnB/io8Td9aleFmAhz9hcbvSfsxT5tdYlJ5qgv5aMJGY4zEtA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.2",
"resolved": "https://package-mirror.liara.ir/repository/npm/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"license": "MIT"
},
"node_modules/@next/env": {
"version": "15.5.18",
"resolved": "https://package-mirror.liara.ir/repository/npm/@next/env/-/env-15.5.18.tgz",
@@ -3494,6 +3715,12 @@
"url": "https://opencollective.com/core-js"
}
},
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://package-mirror.liara.ir/repository/npm/crelt/-/crelt-1.0.6.tgz",
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -7135,6 +7362,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/style-mod": {
"version": "4.1.3",
"resolved": "https://package-mirror.liara.ir/repository/npm/style-mod/-/style-mod-4.1.3.tgz",
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
"license": "MIT"
},
"node_modules/style-to-js": {
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.18.tgz",
@@ -7696,6 +7929,12 @@
"d3-timer": "^3.0.1"
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://package-mirror.liara.ir/repository/npm/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/web-namespaces": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",

View File

@@ -10,7 +10,14 @@
"start": "next start --port 3000"
},
"dependencies": {
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/language": "^6.12.3",
"@codemirror/search": "^6.7.0",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.43.1",
"@hookform/resolvers": "^3.10.0",
"@lezer/highlight": "^1.2.3",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-aspect-ratio": "^1.1.7",

View File

@@ -0,0 +1,5 @@
import AdminAuthorizations from "@/views/AdminAuthorizations";
export default function AdminAuthorizationsPage() {
return <AdminAuthorizations />;
}

View File

@@ -0,0 +1,5 @@
import AdminBlogCategories from "@/views/AdminBlogCategories";
export default function AdminBlogCategoriesPage() {
return <AdminBlogCategories />;
}

View File

@@ -0,0 +1,5 @@
import AdminBlogTags from "@/views/AdminBlogTags";
export default function AdminBlogTagsPage() {
return <AdminBlogTags />;
}

View File

@@ -0,0 +1,683 @@
"use client";
import { useEffect, useRef, useState, type ComponentType } from "react";
import { defaultKeymap, history, historyKeymap, redo, undo } from "@codemirror/commands";
import { markdown } from "@codemirror/lang-markdown";
import { bracketMatching, defaultHighlightStyle, HighlightStyle, syntaxHighlighting } from "@codemirror/language";
import { search, searchKeymap } from "@codemirror/search";
import { Compartment, EditorState, RangeSetBuilder, type Extension } from "@codemirror/state";
import { tags } from "@lezer/highlight";
import {
Decoration,
drawSelection,
dropCursor,
EditorView,
highlightActiveLine,
highlightSpecialChars,
keymap,
rectangularSelection,
} from "@codemirror/view";
import {
Bold,
Code2,
Heading1,
Heading2,
Heading3,
HelpCircle,
Image as ImageIcon,
IndentDecrease,
IndentIncrease,
Italic,
Link as LinkIcon,
List,
ListChecks,
ListOrdered,
Minus,
Quote,
Redo2,
Strikethrough,
Table2,
TextCursorInput,
Undo2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
export type MarkdownDirectionMode = "auto" | "rtl" | "ltr";
type MarkdownEditorProps = {
value: string;
onChange: (value: string) => void;
minHeight?: string;
directionMode?: MarkdownDirectionMode;
onDirectionModeChange?: (mode: MarkdownDirectionMode) => void;
onSave?: () => unknown | Promise<unknown>;
className?: string;
};
type ToolbarAction = {
key: string;
label: string;
icon: ComponentType<{ className?: string }>;
run: () => void;
};
type InsertDialogState =
| { type: "link"; from: number; to: number; selectedText: string }
| { type: "image"; from: number; to: number; selectedText: string }
| null;
const rtlStrongPattern = /[\u0590-\u08FF\uFB1D-\uFDFD\uFE70-\uFEFC]/;
const ltrStrongPattern = /[A-Za-z0-9]/;
const codeFontFamily =
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace';
const codeFontHighlightStyle = HighlightStyle.define([
{
tag: [tags.monospace, tags.processingInstruction],
fontFamily: codeFontFamily,
},
]);
function detectLineDirection(text: string): "rtl" | "ltr" {
for (const char of text.trimStart()) {
if (rtlStrongPattern.test(char)) return "rtl";
if (ltrStrongPattern.test(char)) return "ltr";
}
return "ltr";
}
function directionExtension(mode: MarkdownDirectionMode): Extension {
if (mode !== "auto") {
return [
EditorView.editorAttributes.of({ dir: mode }),
EditorView.contentAttributes.of({ dir: mode }),
EditorView.perLineTextDirection.of(false),
];
}
return [
EditorView.editorAttributes.of({ dir: "rtl" }),
EditorView.perLineTextDirection.of(true),
EditorView.decorations.compute(["doc"], (state) => {
const builder = new RangeSetBuilder<Decoration>();
for (let lineNumber = 1; lineNumber <= state.doc.lines; lineNumber += 1) {
const line = state.doc.line(lineNumber);
builder.add(
line.from,
line.from,
Decoration.line({ attributes: { dir: detectLineDirection(line.text) } }),
);
}
return builder.finish();
}),
];
}
function editorTheme(minHeight: string): Extension {
return EditorView.theme({
"&": {
minHeight,
backgroundColor: "hsl(var(--muted) / 0.28)",
color: "hsl(var(--foreground))",
fontSize: "14px",
direction: "rtl",
},
".cm-scroller": {
minHeight,
fontFamily: "inherit",
lineHeight: "1.9",
},
".cm-content": {
caretColor: "hsl(var(--primary))",
padding: "1rem",
backgroundColor: "hsl(var(--muted) / 0.16)",
},
".cm-line": {
padding: "0 0.25rem",
unicodeBidi: "plaintext",
},
".cm-monospace": {
fontFamily: codeFontFamily,
},
".cm-line[dir='rtl']": {
textAlign: "right",
},
".cm-line[dir='ltr']": {
textAlign: "left",
},
".cm-activeLine": {
backgroundColor: "hsl(var(--background) / 0.82)",
},
".cm-selectionBackground, &.cm-focused .cm-selectionBackground": {
backgroundColor: "hsl(var(--primary) / 0.2)",
},
"&.cm-focused": {
outline: "none",
},
".cm-cursor": {
borderLeftColor: "hsl(var(--primary))",
},
".cm-tooltip": {
borderRadius: "0.75rem",
borderColor: "hsl(var(--border))",
backgroundColor: "hsl(var(--popover))",
color: "hsl(var(--popover-foreground))",
},
".cm-gutters": {
display: "none",
},
});
}
function insertAtSelection(view: EditorView, text: string, cursorOffset = text.length) {
const selection = view.state.selection.main;
view.dispatch({
changes: { from: selection.from, to: selection.to, insert: text },
selection: { anchor: selection.from + cursorOffset },
scrollIntoView: true,
});
view.focus();
}
function wrapSelection(view: EditorView, before: string, after = before, placeholder = "متن") {
const selection = view.state.selection.main;
const selected = view.state.doc.sliceString(selection.from, selection.to) || placeholder;
const insert = `${before}${selected}${after}`;
view.dispatch({
changes: { from: selection.from, to: selection.to, insert },
selection: {
anchor: selection.from + before.length,
head: selection.from + before.length + selected.length,
},
scrollIntoView: true,
});
view.focus();
}
function selectedLines(view: EditorView) {
const selection = view.state.selection.main;
const startLine = view.state.doc.lineAt(selection.from);
const endLine = view.state.doc.lineAt(selection.to);
return { startLine, endLine };
}
function prefixLines(view: EditorView, makePrefix: (index: number) => string) {
const { startLine, endLine } = selectedLines(view);
const changes: Array<{ from: number; insert: string }> = [];
for (let lineNumber = startLine.number; lineNumber <= endLine.number; lineNumber += 1) {
const line = view.state.doc.line(lineNumber);
changes.push({ from: line.from, insert: makePrefix(lineNumber - startLine.number + 1) });
}
view.dispatch({ changes, scrollIntoView: true });
view.focus();
}
function indentListLines(view: EditorView) {
const { startLine, endLine } = selectedLines(view);
const changes: Array<{ from: number; insert: string }> = [];
for (let lineNumber = startLine.number; lineNumber <= endLine.number; lineNumber += 1) {
const line = view.state.doc.line(lineNumber);
if (/^\s*(?:[-*+]|\d+\.)(?:\s+\[[ xX]\])?\s+/.test(line.text)) {
changes.push({ from: line.from, insert: " " });
}
}
if (!changes.length) return false;
view.dispatch({ changes, scrollIntoView: true });
view.focus();
return true;
}
function outdentListLines(view: EditorView) {
const { startLine, endLine } = selectedLines(view);
const changes: Array<{ from: number; to: number; insert: string }> = [];
for (let lineNumber = startLine.number; lineNumber <= endLine.number; lineNumber += 1) {
const line = view.state.doc.line(lineNumber);
const match = line.text.match(/^(\t| {1,2})/);
if (match && /^\s*(?:[-*+]|\d+\.)(?:\s+\[[ xX]\])?\s+/.test(line.text)) {
changes.push({ from: line.from, to: line.from + match[1].length, insert: "" });
}
}
if (!changes.length) return false;
view.dispatch({ changes, scrollIntoView: true });
view.focus();
return true;
}
function setHeading(view: EditorView, level: 1 | 2 | 3) {
const { startLine, endLine } = selectedLines(view);
const changes: Array<{ from: number; to?: number; insert: string }> = [];
const marker = `${"#".repeat(level)} `;
for (let lineNumber = startLine.number; lineNumber <= endLine.number; lineNumber += 1) {
const line = view.state.doc.line(lineNumber);
const match = line.text.match(/^(\s{0,3})(#{1,6}\s+)/);
if (match) {
const prefixStart = line.from + match[1].length;
changes.push({ from: prefixStart, to: prefixStart + match[2].length, insert: marker });
} else {
changes.push({ from: line.from, insert: marker });
}
}
view.dispatch({ changes, scrollIntoView: true });
view.focus();
}
function wrapBlock(view: EditorView, before: string, after: string, placeholder: string) {
const selection = view.state.selection.main;
const selected = view.state.doc.sliceString(selection.from, selection.to) || placeholder;
const insert = `${before}${selected}${after}`;
view.dispatch({
changes: { from: selection.from, to: selection.to, insert },
selection: {
anchor: selection.from + before.length,
head: selection.from + before.length + selected.length,
},
scrollIntoView: true,
});
view.focus();
}
function getSelectedText(view: EditorView) {
const selection = view.state.selection.main;
return view.state.doc.sliceString(selection.from, selection.to);
}
function insertLinkAtSelection(view: EditorView, from: number, to: number, selectedText: string, text: string, url: string) {
const label = text.trim() || selectedText || "متن لینک";
view.dispatch({
changes: { from, to, insert: `[${label}](${url})` },
selection: { anchor: from + 1, head: from + 1 + label.length },
scrollIntoView: true,
});
view.focus();
}
function insertImageAtSelection(view: EditorView, from: number, to: number, selectedText: string, altText: string, url: string) {
const alt = altText.trim() || selectedText || "تصویر";
view.dispatch({
changes: { from, to, insert: `![${alt}](${url})` },
selection: { anchor: from + 2, head: from + 2 + alt.length },
scrollIntoView: true,
});
view.focus();
}
function runEditorCommand(view: EditorView | null, command: (view: EditorView) => void) {
if (!view) return;
command(view);
}
export default function MarkdownEditor({
value,
onChange,
minHeight = "520px",
directionMode = "auto",
onDirectionModeChange,
onSave,
className,
}: MarkdownEditorProps) {
const hostRef = useRef<HTMLDivElement | null>(null);
const viewRef = useRef<EditorView | null>(null);
const onChangeRef = useRef(onChange);
const onSaveRef = useRef(onSave);
const initialValueRef = useRef(value);
const initialDirectionModeRef = useRef(directionMode);
const directionCompartment = useRef(new Compartment());
const [guideOpen, setGuideOpen] = useState(false);
const [insertDialog, setInsertDialog] = useState<InsertDialogState>(null);
const [insertUrl, setInsertUrl] = useState("");
const [insertText, setInsertText] = useState("");
onChangeRef.current = onChange;
onSaveRef.current = onSave;
const openInsertDialog = (view: EditorView, type: "link" | "image") => {
const selection = view.state.selection.main;
const selectedText = getSelectedText(view);
setInsertDialog({ type, from: selection.from, to: selection.to, selectedText });
setInsertText(selectedText);
setInsertUrl("");
};
const closeInsertDialog = () => {
setInsertDialog(null);
setInsertText("");
setInsertUrl("");
viewRef.current?.focus();
};
const submitInsertDialog = () => {
const view = viewRef.current;
const dialog = insertDialog;
const url = insertUrl.trim();
if (!view || !dialog || !url) return;
if (dialog.type === "link") {
insertLinkAtSelection(view, dialog.from, dialog.to, dialog.selectedText, insertText, url);
} else {
insertImageAtSelection(view, dialog.from, dialog.to, dialog.selectedText, insertText, url);
}
closeInsertDialog();
};
useEffect(() => {
if (!hostRef.current) return undefined;
const extensions: Extension[] = [
highlightSpecialChars(),
history(),
drawSelection(),
dropCursor(),
rectangularSelection(),
bracketMatching(),
highlightActiveLine(),
markdown(),
search({ top: true }),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
syntaxHighlighting(codeFontHighlightStyle),
EditorView.lineWrapping,
EditorView.updateListener.of((update) => {
if (update.docChanged) {
onChangeRef.current(update.state.doc.toString());
}
}),
keymap.of([
{ key: "Mod-s", run: () => (void onSaveRef.current?.(), true) },
{ key: "Mod-b", run: (view) => (wrapSelection(view, "**"), true) },
{ key: "Mod-i", run: (view) => (wrapSelection(view, "_"), true) },
{ key: "Mod-k", run: (view) => (openInsertDialog(view, "link"), true) },
{ key: "Mod-/", run: () => (setGuideOpen(true), true) },
{ key: "Mod-Shift-7", run: (view) => (prefixLines(view, (index) => `${index}. `), true) },
{ key: "Mod-Shift-8", run: (view) => (prefixLines(view, () => "- "), true) },
{ key: "Mod-e", run: (view) => (wrapSelection(view, "`"), true) },
{ key: "Mod-Alt-c", run: (view) => (wrapBlock(view, "\n```text\n", "\n```\n", "code"), true) },
{ key: "Mod-Alt-i", run: (view) => (openInsertDialog(view, "image"), true) },
{ key: "Mod-Alt-1", run: (view) => (setHeading(view, 1), true) },
{ key: "Mod-Alt-2", run: (view) => (setHeading(view, 2), true) },
{ key: "Mod-Alt-3", run: (view) => (setHeading(view, 3), true) },
{
key: "Mod-Alt-t",
run: (view) => (
insertAtSelection(view, "\n| ستون اول | ستون دوم |\n| --- | --- |\n| مقدار | مقدار |\n"),
true
),
},
{ key: "Mod-Alt-x", run: (view) => (prefixLines(view, () => "- [ ] "), true) },
{ key: "Mod-Shift-x", run: (view) => (wrapSelection(view, "~~"), true) },
{ key: "Tab", run: indentListLines },
{ key: "Shift-Tab", run: outdentListLines },
...searchKeymap,
...defaultKeymap,
...historyKeymap,
]),
editorTheme(minHeight),
directionCompartment.current.of(directionExtension(initialDirectionModeRef.current)),
];
const state = EditorState.create({
doc: initialValueRef.current,
extensions,
});
const view = new EditorView({ state, parent: hostRef.current });
viewRef.current = view;
return () => {
view.destroy();
viewRef.current = null;
};
}, [minHeight]);
useEffect(() => {
const view = viewRef.current;
if (!view) return;
const current = view.state.doc.toString();
if (current === value) return;
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: value },
});
}, [value]);
useEffect(() => {
const view = viewRef.current;
if (!view) return;
view.dispatch({
effects: directionCompartment.current.reconfigure(directionExtension(directionMode)),
});
}, [directionMode]);
const setDirectionMode = (mode: MarkdownDirectionMode) => {
onDirectionModeChange?.(mode);
};
const actions: ToolbarAction[] = [
{ key: "bold", label: "درشت (Ctrl+B)", icon: Bold, run: () => runEditorCommand(viewRef.current, (view) => wrapSelection(view, "**")) },
{ key: "italic", label: "کج (Ctrl+I)", icon: Italic, run: () => runEditorCommand(viewRef.current, (view) => wrapSelection(view, "_")) },
{
key: "strike",
label: "خط‌خورده (Ctrl+Shift+X)",
icon: Strikethrough,
run: () => runEditorCommand(viewRef.current, (view) => wrapSelection(view, "~~")),
},
{ key: "h1", label: "تیتر ۱ (Ctrl+Alt+1)", icon: Heading1, run: () => runEditorCommand(viewRef.current, (view) => setHeading(view, 1)) },
{ key: "h2", label: "تیتر ۲ (Ctrl+Alt+2)", icon: Heading2, run: () => runEditorCommand(viewRef.current, (view) => setHeading(view, 2)) },
{ key: "h3", label: "تیتر ۳ (Ctrl+Alt+3)", icon: Heading3, run: () => runEditorCommand(viewRef.current, (view) => setHeading(view, 3)) },
{ key: "quote", label: "نقل قول", icon: Quote, run: () => runEditorCommand(viewRef.current, (view) => prefixLines(view, () => "> ")) },
{ key: "ul", label: "فهرست نقطه‌ای (Ctrl+Shift+8)", icon: List, run: () => runEditorCommand(viewRef.current, (view) => prefixLines(view, () => "- ")) },
{
key: "ol",
label: "فهرست شماره‌ای (Ctrl+Shift+7)",
icon: ListOrdered,
run: () => runEditorCommand(viewRef.current, (view) => prefixLines(view, (index) => `${index}. `)),
},
{
key: "task-list",
label: "فهرست کارها (Ctrl+Alt+X)",
icon: ListChecks,
run: () => runEditorCommand(viewRef.current, (view) => prefixLines(view, () => "- [ ] ")),
},
{ key: "link", label: "لینک (Ctrl+K)", icon: LinkIcon, run: () => runEditorCommand(viewRef.current, (view) => openInsertDialog(view, "link")) },
{ key: "image", label: "تصویر (Ctrl+Alt+I)", icon: ImageIcon, run: () => runEditorCommand(viewRef.current, (view) => openInsertDialog(view, "image")) },
{ key: "inline-code", label: "کد کوتاه (Ctrl+E)", icon: TextCursorInput, run: () => runEditorCommand(viewRef.current, (view) => wrapSelection(view, "`", "`", "code")) },
{
key: "code",
label: "بلوک کد (Ctrl+Alt+C)",
icon: Code2,
run: () => runEditorCommand(viewRef.current, (view) => wrapBlock(view, "\n```text\n", "\n```\n", "code")),
},
{
key: "table",
label: "جدول (Ctrl+Alt+T)",
icon: Table2,
run: () =>
runEditorCommand(viewRef.current, (view) =>
insertAtSelection(view, "\n| ستون اول | ستون دوم |\n| --- | --- |\n| مقدار | مقدار |\n"),
),
},
{ key: "hr", label: "خط جداکننده", icon: Minus, run: () => runEditorCommand(viewRef.current, (view) => insertAtSelection(view, "\n---\n")) },
{ key: "undo", label: "بازگردانی (Ctrl+Z)", icon: Undo2, run: () => runEditorCommand(viewRef.current, (view) => undo(view)) },
{ key: "redo", label: "انجام دوباره (Ctrl+Shift+Z)", icon: Redo2, run: () => runEditorCommand(viewRef.current, (view) => redo(view)) },
];
const shortcutSections = [
{
title: "قالب‌بندی متن",
items: [
["Ctrl/Cmd + B", "درشت کردن متن"],
["Ctrl/Cmd + I", "کج کردن متن"],
["Ctrl/Cmd + Shift + X", "خط‌خورده"],
["Ctrl/Cmd + E", "کد کوتاه درون‌خطی"],
["Ctrl/Cmd + Alt + 1/2/3", "تیترهای سطح ۱، ۲ و ۳"],
],
},
{
title: "بلوک‌ها و ساختار",
items: [
["Ctrl/Cmd + Shift + 8", "فهرست نقطه‌ای"],
["Ctrl/Cmd + Shift + 7", "فهرست شماره‌ای"],
["Ctrl/Cmd + Alt + X", "فهرست کارها"],
["Ctrl/Cmd + Alt + C", "بلوک کد"],
["Ctrl/Cmd + Alt + T", "جدول"],
["Tab / Shift + Tab", "تورفتگی یا خروج از تورفتگی برای آیتم‌های فهرست"],
],
},
{
title: "درج محتوا",
items: [
["Ctrl/Cmd + K", "لینک"],
["Ctrl/Cmd + Alt + I", "تصویر"],
],
},
{
title: "عملیات و راهنما",
items: [
["Ctrl/Cmd + S", "ذخیره پیش‌نویس"],
["Ctrl/Cmd + F", "جست‌وجو در متن ویرایشگر"],
["Ctrl/Cmd + Z", "بازگردانی"],
["Ctrl/Cmd + Shift + Z", "انجام دوباره"],
["Ctrl/Cmd + /", "باز کردن همین راهنما"],
],
},
];
return (
<div className={cn("overflow-hidden rounded-2xl border bg-muted/30 shadow-inner", className)} dir="rtl">
<TooltipProvider delayDuration={150}>
<div className="flex flex-wrap items-center justify-between gap-2 border-b bg-background/80 p-2">
<div className="flex flex-wrap items-center justify-start gap-1">
{actions.map((action) => {
const Icon = action.icon;
return (
<Tooltip key={action.key}>
<TooltipTrigger asChild>
<Button type="button" variant="ghost" size="icon" className="h-8 w-8" onClick={action.run}>
<Icon className="h-4 w-4" />
<span className="sr-only">{action.label}</span>
</Button>
</TooltipTrigger>
<TooltipContent>{action.label}</TooltipContent>
</Tooltip>
);
})}
</div>
<div className="flex items-center gap-1 rounded-full border bg-background/80 p-1">
{(["auto", "rtl", "ltr"] as const).map((mode) => (
<Button
key={mode}
type="button"
variant={directionMode === mode ? "default" : "ghost"}
size="sm"
className="h-7 rounded-full px-3 text-xs"
onClick={() => setDirectionMode(mode)}
>
{mode === "auto" ? "Auto" : mode.toUpperCase()}
</Button>
))}
<Tooltip>
<TooltipTrigger asChild>
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 rounded-full" onClick={() => setGuideOpen(true)}>
<HelpCircle className="h-3.5 w-3.5" />
<span className="sr-only">راهنمای میانبرها</span>
</Button>
</TooltipTrigger>
<TooltipContent>راهنمای میانبرها</TooltipContent>
</Tooltip>
</div>
</div>
</TooltipProvider>
<div ref={hostRef} className="text-left" />
<Dialog open={guideOpen} onOpenChange={setGuideOpen}>
<DialogContent className="max-h-[88vh] max-w-3xl overflow-y-auto rounded-3xl" dir="rtl">
<DialogHeader className="text-right md:text-right mt-6 mb-2">
<DialogTitle>راهنمای میانبرهای ویرایشگر مارکداون</DialogTitle>
<DialogDescription>
در ویندوز و لینوکس از Ctrl و در مک از Cmd استفاده کنید. میانبرها فقط وقتی ویرایشگر فعال است اجرا میشوند.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 md:grid-cols-2">
{shortcutSections.map((section) => (
<section key={section.title} className="rounded-2xl border bg-muted/20 p-4">
<h3 className="mb-3 text-right font-bold">{section.title}</h3>
<div className="space-y-2">
{section.items.map(([shortcut, description]) => (
<div key={`${section.title}-${shortcut}`} className="flex items-center justify-between gap-3 rounded-xl bg-background/80 px-3 py-2 text-sm">
<span className="text-right text-muted-foreground">{description}</span>
<kbd className="shrink-0 rounded-lg border bg-muted px-2 py-1 font-mono text-[11px] leading-none text-foreground">
{shortcut}
</kbd>
</div>
))}
</div>
</section>
))}
</div>
</DialogContent>
</Dialog>
<Dialog open={Boolean(insertDialog)} onOpenChange={(open) => (open ? undefined : closeInsertDialog())}>
<DialogContent className="max-w-lg rounded-3xl" dir="rtl">
<DialogHeader className="mt-6 text-right md:text-right">
<DialogTitle>{insertDialog?.type === "image" ? "درج تصویر" : "درج لینک"}</DialogTitle>
<DialogDescription>
{insertDialog?.type === "image"
? "آدرس تصویر و متن جایگزین را وارد کنید. برای فایل‌های آپلودشده می‌توانید لینک را از مرکز آپلود کپی کنید."
: "آدرس مقصد و متن لینک را وارد کنید. اگر متن انتخاب کرده باشید، به عنوان متن لینک استفاده می‌شود."}
</DialogDescription>
</DialogHeader>
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
submitInsertDialog();
}}
>
<div className="space-y-2">
<Label htmlFor="markdown-insert-url" className="block text-right">
آدرس
</Label>
<Input
id="markdown-insert-url"
value={insertUrl}
onChange={(event) => setInsertUrl(event.target.value)}
placeholder={insertDialog?.type === "image" ? "https://example.com/image.png" : "https://example.com"}
dir="ltr"
autoFocus
/>
</div>
<div className="space-y-2">
<Label htmlFor="markdown-insert-text" className="block text-right">
{insertDialog?.type === "image" ? "متن جایگزین" : "متن لینک"}
</Label>
<Input
id="markdown-insert-text"
value={insertText}
onChange={(event) => setInsertText(event.target.value)}
placeholder={insertDialog?.type === "image" ? "توضیح کوتاه تصویر" : "متن قابل کلیک"}
className="text-right"
/>
</div>
<div className="flex flex-wrap justify-start gap-2 pt-2">
<Button type="submit" disabled={!insertUrl.trim()}>
{insertDialog?.type === "image" ? "درج تصویر" : "درج لینک"}
</Button>
<Button type="button" variant="outline" onClick={closeInsertDialog}>
انصراف
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -2,7 +2,7 @@
import type { ReactNode } from "react";
import { useMemo } from "react";
import { LayoutDashboard, LogOut, PencilLine, RotateCcw, UserRound } from "lucide-react";
import { LayoutDashboard, LogOut, RotateCcw, UserRound } from "lucide-react";
import { Link, NavLink } from "@/lib/router";
import { useAuth } from "@/contexts/AuthContext";
import ModeToggle from "@/components/ModeToggle";
@@ -89,12 +89,6 @@ function ProfileAvatarMenu() {
<UserRound className="h-4 w-4" />
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild className="flex-row-reverse justify-end gap-2 rounded-xl">
<Link to="/profile?edit=1">
ویرایش پروفایل
<PencilLine className="h-4 w-4" />
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild className="flex-row-reverse justify-end gap-2 rounded-xl">
<Link to="/reset-password">
تغییر یا بازیابی رمز

View File

@@ -397,6 +397,21 @@ class ApiClient {
return this.request<Types.UserListSchema[]>(`/api/auth/users${query.toString() ? `?${query.toString()}` : ''}`);
}
async listAuthorizationRoles() {
return this.request<Types.AuthorizationRoleSchema[]>('/api/auth/roles');
}
async getUserAuthorization(userId: number) {
return this.request<Types.UserAuthorizationSchema>(`/api/auth/users/${userId}/authorization`);
}
async updateUserAuthorization(userId: number, data: Types.UserAuthorizationUpdateSchema) {
return this.request<Types.UserAuthorizationSchema>(`/api/auth/users/${userId}/authorization`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
// ============= Blog Endpoints =============
async getPosts(params?: {
@@ -547,6 +562,52 @@ class ApiClient {
return response.json() as Promise<Types.PostAssetSchema>;
}
uploadBlogPostAssetWithProgress(
postId: number,
file: File,
data: { title?: string; alt_text?: string; caption?: string } = {},
onProgress?: (progress: number) => void,
) {
const formData = new FormData();
formData.append('file', file);
formData.append('title', data.title ?? '');
formData.append('alt_text', data.alt_text ?? '');
formData.append('caption', data.caption ?? '');
const token = this.getStorageValue('access_token');
return new Promise<Types.PostAssetSchema>((resolve, reject) => {
const request = new XMLHttpRequest();
request.open('POST', `${this.baseUrl}/api/blog/admin/posts/${postId}/assets`);
if (token) {
request.setRequestHeader('Authorization', `Bearer ${token}`);
}
request.upload.onprogress = (event) => {
if (!event.lengthComputable) return;
onProgress?.(Math.round((event.loaded / event.total) * 100));
};
request.onload = () => {
let body: (Types.PostAssetSchema & ApiErrorBody) | null = null;
try {
body = request.responseText ? JSON.parse(request.responseText) as Types.PostAssetSchema & ApiErrorBody : null;
} catch {
body = null;
}
if (request.status >= 200 && request.status < 300 && body) {
onProgress?.(100);
resolve(body);
return;
}
reject(new Error(body?.error || body?.detail || 'Asset upload failed'));
};
request.onerror = () => reject(new Error('Asset upload failed'));
request.send(formData);
});
}
async deleteBlogPostAsset(postId: number, assetId: number) {
return this.request<Types.MessageSchema>(`/api/blog/admin/posts/${postId}/assets/${assetId}`, {
method: 'DELETE',
@@ -648,6 +709,30 @@ class ApiClient {
return this.request<Types.CategorySchema[]>('/api/blog/categories');
}
async listAdminCategories() {
return this.request<Types.AdminCategorySchema[]>('/api/blog/admin/categories');
}
async createCategory(data: Types.CategoryWriteSchema) {
return this.request<Types.AdminCategorySchema>('/api/blog/admin/categories', {
method: 'POST',
body: JSON.stringify(data),
});
}
async updateCategory(categoryId: number, data: Types.CategoryWriteSchema) {
return this.request<Types.AdminCategorySchema>(`/api/blog/admin/categories/${categoryId}`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
async deleteCategory(categoryId: number) {
return this.request<Types.MessageSchema>(`/api/blog/admin/categories/${categoryId}`, {
method: 'DELETE',
});
}
async getCategory(slug: string) {
return this.request<Types.CategorySchema>(`/api/blog/categories/${slug}`);
}
@@ -667,6 +752,30 @@ class ApiClient {
return this.request<Types.TagSchema[]>('/api/blog/tags');
}
async listAdminTags() {
return this.request<Types.AdminTagSchema[]>('/api/blog/admin/tags');
}
async createTag(data: Types.TagWriteSchema) {
return this.request<Types.AdminTagSchema>('/api/blog/admin/tags', {
method: 'POST',
body: JSON.stringify(data),
});
}
async updateTag(tagId: number, data: Types.TagWriteSchema) {
return this.request<Types.AdminTagSchema>(`/api/blog/admin/tags/${tagId}`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
async deleteTag(tagId: number) {
return this.request<Types.MessageSchema>(`/api/blog/admin/tags/${tagId}`, {
method: 'DELETE',
});
}
async getTag(slug: string) {
return this.request<Types.TagSchema>(`/api/blog/tags/${slug}`);
}

View File

@@ -68,6 +68,33 @@ export interface UserListSchema {
date_joined: string;
}
export interface AuthorizationRoleSchema {
key: string;
label: string;
description: string;
enabled: boolean;
locked: boolean;
}
export interface UserAuthorizationSchema {
id: number;
username: string;
email?: string | null;
mobile?: string | null;
first_name: string;
last_name: string;
is_active: boolean;
is_staff: boolean;
is_superuser: boolean;
groups: string[];
roles: AuthorizationRoleSchema[];
}
export interface UserAuthorizationUpdateSchema {
is_staff: boolean;
groups: string[];
}
export interface UserRegistrationSchema {
mobile: string;
code: string;
@@ -282,6 +309,7 @@ export interface PostListSchema {
export interface PostDetailSchema extends PostListSchema {
content: string;
content_html?: string;
review_note?: string;
og_image_url?: string | null;
views_count?: number;
assets?: PostAssetSchema[];
@@ -403,6 +431,17 @@ export interface CategorySchema {
created_at: string;
}
export interface AdminCategorySchema extends CategorySchema {
post_count: number;
}
export interface CategoryWriteSchema {
name: string;
slug?: string | null;
description?: string | null;
parent_id?: number | null;
}
export interface TagSchema {
id: number;
name: string;
@@ -410,6 +449,15 @@ export interface TagSchema {
created_at: string;
}
export interface AdminTagSchema extends TagSchema {
post_count: number;
}
export interface TagWriteSchema {
name: string;
slug?: string | null;
}
export interface BlogFilterCategory {
id: number;
name: string;

View File

@@ -0,0 +1,228 @@
"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Loader2, Search, ShieldCheck, UserCog } from "lucide-react";
import { useAuth } from "@/contexts/AuthContext";
import { api } from "@/lib/api";
import type * as Types from "@/lib/types";
import { cn, resolveErrorMessage } from "@/lib/utils";
import { useToast } from "@/hooks/use-toast";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
const PAGE_SIZE = 25;
function fullName(user: Pick<Types.UserListSchema, "first_name" | "last_name" | "username">) {
return [user.first_name, user.last_name].filter(Boolean).join(" ") || user.username;
}
export default function AdminAuthorizations() {
const { user } = useAuth();
const { toast } = useToast();
const [searchDraft, setSearchDraft] = useState("");
const [search, setSearch] = useState("");
const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
const [draftAuth, setDraftAuth] = useState<Types.UserAuthorizationUpdateSchema | null>(null);
const [saving, setSaving] = useState(false);
const usersQuery = useQuery({
queryKey: ["admin", "authorizations", "users", search],
queryFn: () => api.listUsers({ search: search || undefined, limit: PAGE_SIZE, offset: 0 }),
});
const authQuery = useQuery({
queryKey: ["admin", "authorizations", selectedUserId],
queryFn: () => api.getUserAuthorization(selectedUserId as number),
enabled: Boolean(selectedUserId),
});
const selectedAuth = authQuery.data;
const isSelf = Boolean(selectedAuth && user?.id === selectedAuth.id);
const effectiveDraft = draftAuth ?? (selectedAuth ? {
is_staff: selectedAuth.is_staff,
groups: selectedAuth.groups.filter((group) => ["blog_editor", "blog_supervisor", "association_admin"].includes(group)),
} : null);
const selectUser = async (target: Types.UserListSchema) => {
setSelectedUserId(target.id);
setDraftAuth(null);
};
const toggleGroup = (group: string, checked: boolean) => {
if (!effectiveDraft || isSelf) return;
setDraftAuth({
...effectiveDraft,
groups: checked
? Array.from(new Set([...effectiveDraft.groups, group]))
: effectiveDraft.groups.filter((item) => item !== group),
});
};
const toggleStaff = (checked: boolean) => {
if (!effectiveDraft || isSelf) return;
setDraftAuth({ ...effectiveDraft, is_staff: checked });
};
const saveAuthorization = async () => {
if (!selectedUserId || !effectiveDraft || isSelf) return;
try {
setSaving(true);
const updated = await api.updateUserAuthorization(selectedUserId, effectiveDraft);
setDraftAuth({
is_staff: updated.is_staff,
groups: updated.groups.filter((group) => ["blog_editor", "blog_supervisor", "association_admin"].includes(group)),
});
toast({ title: "دسترسی کاربر به‌روزرسانی شد", variant: "success" });
await usersQuery.refetch();
await authQuery.refetch();
} catch (error) {
toast({
title: "ذخیره دسترسی ناموفق بود",
description: resolveErrorMessage(error, "دوباره تلاش کنید"),
variant: "destructive",
});
} finally {
setSaving(false);
}
};
return (
<div className="space-y-6">
<div className="text-right">
<h2 className="text-2xl font-bold">مدیریت دسترسیها</h2>
<p className="mt-1 text-sm text-muted-foreground">تخصیص نقشهای امن و آماده به کاربران. مجوزهای مستقیم Django از این صفحه قابل تغییر نیستند.</p>
</div>
<div className="grid gap-6 xl:grid-cols-[380px_1fr]">
<Card className="h-fit">
<CardHeader className="text-right">
<CardTitle>جستجوی کاربر</CardTitle>
<CardDescription>نام، موبایل، ایمیل یا نام کاربری را جستجو کنید.</CardDescription>
</CardHeader>
<CardContent className="space-y-4" dir="ltr">
<div className="flex gap-2">
<Button type="button" onClick={() => setSearch(searchDraft.trim())} size="icon" aria-label="جستجو">
<Search className="h-5 w-5 bold" />
</Button>
<Input
value={searchDraft}
onChange={(event) => setSearchDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") setSearch(searchDraft.trim());
}}
placeholder="جستجو..."
className="text-right"
/>
</div>
{usersQuery.isLoading ? (
<div className="flex justify-center py-8 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
</div>
) : usersQuery.data?.length ? (
<div className="space-y-2">
{usersQuery.data.map((item) => (
<button
key={item.id}
type="button"
onClick={() => void selectUser(item)}
className={cn(
"w-full rounded-2xl border p-3 text-right transition hover:bg-muted/40",
selectedUserId === item.id ? "border-primary bg-primary/10" : "border-border/70 bg-background",
)}
>
<div className="flex items-center justify-between gap-2">
<Badge variant={item.is_superuser ? "default" : item.is_staff ? "secondary" : "outline"}>
{item.is_superuser ? "سوپریوزر" : item.is_staff ? "staff" : "کاربر"}
</Badge>
<span className="font-medium">{fullName(item)}</span>
</div>
<p className="mt-1 text-xs text-muted-foreground">{item.mobile || item.email || item.username}</p>
</button>
))}
</div>
) : (
<p className="rounded-2xl border border-dashed p-6 text-center text-sm text-muted-foreground">کاربری یافت نشد.</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="text-right">
<div className="flex items-center gap-2">
<UserCog className="h-5 w-5 text-primary" />
<CardTitle>نقشهای کاربر</CardTitle>
</div>
<CardDescription>فقط نقشهای آماده قابل تغییر هستند؛ سوپریوزر خواندنی است.</CardDescription>
</CardHeader>
<CardContent>
{!selectedUserId ? (
<div className="rounded-3xl border border-dashed p-10 text-center text-sm text-muted-foreground">یک کاربر را از لیست انتخاب کنید.</div>
) : authQuery.isLoading || !selectedAuth || !effectiveDraft ? (
<div className="flex justify-center py-12 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
</div>
) : (
<div className="space-y-5">
<div className="rounded-3xl border bg-muted/20 p-4 text-right">
<p className="text-lg font-bold">{fullName(selectedAuth)}</p>
<p className="mt-1 text-sm text-muted-foreground">{selectedAuth.mobile || selectedAuth.email || selectedAuth.username}</p>
{isSelf ? (
<p className="mt-3 rounded-2xl border border-amber-500/30 bg-amber-500/10 p-3 text-sm text-amber-700 dark:text-amber-300">
برای جلوگیری از قفل شدن حساب، نقشهای کاربر فعلی از این صفحه قابل تغییر نیست.
</p>
) : null}
</div>
<div className="space-y-3" dir="ltr">
{selectedAuth.roles.map((role) => {
const isStaffRole = role.key === "staff_admin";
const isSuperuserRole = role.key === "is_superuser";
const checked = isStaffRole
? effectiveDraft.is_staff
: isSuperuserRole
? selectedAuth.is_superuser
: effectiveDraft.groups.includes(role.key);
const disabled = role.locked || isSelf || saving;
return (
<div key={role.key} className="flex items-center justify-between gap-4 rounded-2xl border p-4">
<Switch
checked={checked}
disabled={disabled}
onCheckedChange={(value) => {
if (isStaffRole) toggleStaff(value);
else if (!isSuperuserRole) toggleGroup(role.key, value);
}}
/>
<div className="text-right">
<div className="flex items-center justify-end gap-2">
{role.locked ? <ShieldCheck className="h-4 w-4 text-primary" /> : null}
<p className="font-medium">{role.label}</p>
</div>
<p className="mt-1 text-sm text-muted-foreground">{role.description}</p>
</div>
</div>
);
})}
</div>
<div className="flex justify-start gap-2">
<Button variant="outline" onClick={() => setDraftAuth(null)} disabled={saving || isSelf}>
بازنشانی تغییرات
</Button>
<Button onClick={saveAuthorization} disabled={saving || isSelf}>
{saving ? <Loader2 className="ml-2 h-4 w-4 animate-spin" /> : null}
ذخیره دسترسیها
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -3,14 +3,18 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { BookOpenText, CheckCircle2, Clock3, Edit, Eye, Loader2, Plus, Send, XCircle } from "lucide-react";
import { Link } from "@/lib/router";
import BlogThumbnail from "@/components/BlogThumbnail";
import { useAuth } from "@/contexts/AuthContext";
import { api } from "@/lib/api";
import type * as Types from "@/lib/types";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useToast } from "@/hooks/use-toast";
import { formatJalali, resolveErrorMessage } from "@/lib/utils";
@@ -30,6 +34,8 @@ export default function AdminBlog() {
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(true);
const [actingId, setActingId] = useState<number | null>(null);
const [changesPost, setChangesPost] = useState<Types.PostListSchema | null>(null);
const [changesNote, setChangesNote] = useState("");
const canReview = Boolean(user?.is_staff || user?.is_superuser || user?.can_review_blog_posts);
@@ -77,10 +83,10 @@ export default function AdminBlog() {
}
};
const reviewPost = async (postId: number, action: Types.PostReviewSchema["action"]) => {
const reviewPost = async (postId: number, action: Types.PostReviewSchema["action"], note?: string) => {
setActingId(postId);
try {
await api.reviewBlogPost(postId, { action });
await api.reviewBlogPost(postId, { action, note });
await loadPosts();
toast({ title: action === "publish" ? "نوشته منتشر شد" : "درخواست اصلاح ثبت شد", variant: "success" });
} catch (error) {
@@ -90,38 +96,59 @@ export default function AdminBlog() {
}
};
const openChangesDialog = (post: Types.PostListSchema) => {
setChangesPost(post);
setChangesNote("");
};
const closeChangesDialog = () => {
if (actingId) return;
setChangesPost(null);
setChangesNote("");
};
const requestChanges = async () => {
if (!changesPost) return;
await reviewPost(changesPost.id, "request_changes", changesNote.trim() || undefined);
setChangesPost(null);
setChangesNote("");
};
return (
<div className="space-y-6" dir="rtl">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<Button asChild>
<Link to="/admin/blog/new/edit">
<Plus className="ml-2 h-4 w-4" />
نوشته جدید
</Link>
</Button>
<div className="text-right">
<h2 className="text-2xl font-bold">مدیریت بلاگ</h2>
<p className="mt-1 text-sm text-muted-foreground">
پیشنویسها، صف بررسی، انتشار و اصلاح نوشتهها.
</p>
</div>
<Button asChild>
<Link to="/admin/blog/new/edit">
<Plus className="ml-2 h-4 w-4" />
نوشته جدید
</Link>
</Button>
</div>
<div className="grid gap-4 md:grid-cols-4">
<Card><CardContent className="flex items-center justify-between p-4"><BookOpenText className="h-5 w-5 text-primary" /><span>کل: {posts.length}</span></CardContent></Card>
<Card><CardContent className="flex items-center justify-between p-4"><Clock3 className="h-5 w-5 text-amber-600" /><span>بررسی: {stats.submitted ?? 0}</span></CardContent></Card>
<Card><CardContent className="flex items-center justify-between p-4"><CheckCircle2 className="h-5 w-5 text-emerald-600" /><span>منتشر: {stats.published ?? 0}</span></CardContent></Card>
<Card><CardContent className="flex items-center justify-between p-4"><XCircle className="h-5 w-5 text-rose-600" /><span>اصلاح: {stats.changes_requested ?? 0}</span></CardContent></Card>
<Card><CardContent className="flex items-center flex-row gap-2 p-4"><BookOpenText className="h-5 w-5 text-primary" /><span>کل: {posts.length}</span></CardContent></Card>
<Card><CardContent className="flex items-center flex-row gap-2 p-4"><Clock3 className="h-5 w-5 text-amber-600" /><span>بررسی: {stats.submitted ?? 0}</span></CardContent></Card>
<Card><CardContent className="flex items-center flex-row gap-2 p-4"><CheckCircle2 className="h-5 w-5 text-emerald-600" /><span>منتشر: {stats.published ?? 0}</span></CardContent></Card>
<Card><CardContent className="flex items-center flex-row gap-2 p-4"><XCircle className="h-5 w-5 text-rose-600" /><span>اصلاح: {stats.changes_requested ?? 0}</span></CardContent></Card>
</div>
<Card>
<CardHeader>
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="flex gap-2">
<Button variant="outline" onClick={loadPosts}>جستجو</Button>
<Input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="جستجو..." className="w-64 text-right" />
<div className="text-right">
<CardTitle>نوشتهها</CardTitle>
<CardDescription>دسترسی نویسندهها به نوشتههای خودشان محدود میشود.</CardDescription>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="جستجو..." className="w-full text-right sm:w-64" />
<Select value={status} onValueChange={setStatus}>
<SelectTrigger className="w-48"><SelectValue /></SelectTrigger>
<SelectTrigger className="w-full sm:w-48"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">همه وضعیتها</SelectItem>
<SelectItem value="draft">پیشنویس</SelectItem>
@@ -132,10 +159,6 @@ export default function AdminBlog() {
</SelectContent>
</Select>
</div>
<div className="text-right">
<CardTitle>نوشتهها</CardTitle>
<CardDescription>دسترسی نویسندهها به نوشتههای خودشان محدود میشود.</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3">
@@ -143,35 +166,117 @@ export default function AdminBlog() {
<div className="flex justify-center py-10"><Loader2 className="h-5 w-5 animate-spin" /></div>
) : posts.length ? (
posts.map((post) => (
<div key={post.id} className="flex flex-col gap-3 rounded-2xl border p-4 md:flex-row md:items-center md:justify-between">
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" asChild>
<Link to={`/admin/blog/${post.id}/preview`}><Eye className="ml-2 h-4 w-4" />پیشنمایش</Link>
</Button>
<Button variant="secondary" size="sm" asChild>
<Link to={`/admin/blog/${post.id}/edit`}><Edit className="ml-2 h-4 w-4" />ویرایش</Link>
</Button>
<div key={post.id} className="flex flex-col gap-4 rounded-2xl border p-3 sm:p-4 md:flex-row md:items-center md:justify-between">
<div className="flex min-w-0 flex-1 items-start gap-3 md:gap-4">
<BlogThumbnail
post={post}
imageUrl={post.absolute_featured_image_thumbnail_url || post.absolute_featured_image_preview_url || post.absolute_featured_image_url || post.featured_image}
className="h-20 w-24 shrink-0 rounded-xl sm:h-24 sm:w-36 md:h-28 md:w-44"
imageClassName="group-hover:scale-100"
/>
<div className="min-w-0 flex-1 text-right">
<div className="flex flex-col-reverse flex-wrap items-start gap-2 sm:flex-row sm:items-center">
<h3 className="line-clamp-2 font-semibold leading-7">{post.title}</h3>
<Badge variant={post.status === "published" ? "default" : "secondary"}>{statusLabels[post.status] ?? post.status}</Badge>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{post.updated_at ? formatJalali(post.updated_at, false) : ""}
</p>
</div>
</div>
<div className="flex flex-wrap gap-2 md:grid md:grid-cols-2 md:grid-rows-2" dir="ltr">
<div className="flex flex-1 basis-[calc(33.333%-0.5rem)] md:col-start-1 md:row-start-1 md:block md:basis-auto">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="sm" asChild className="w-full">
<Link
to={`/admin/blog/${post.id}/preview`}
aria-label="پیش‌نمایش"
className="flex justify-center"
>
<Eye className="h-4 w-4" />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>پیشنمایش</TooltipContent>
</Tooltip>
</div>
<div className="flex flex-1 basis-[calc(33.333%-0.5rem)] md:col-start-1 md:row-start-2 md:block md:basis-auto">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="secondary" size="sm" asChild className="w-full">
<Link
to={`/admin/blog/${post.id}/edit`}
aria-label="ویرایش"
className="flex justify-center"
>
<Edit className="h-4 w-4" />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>ویرایش</TooltipContent>
</Tooltip>
</div>
{post.status === "draft" || post.status === "changes_requested" ? (
<Button size="sm" onClick={() => submitPost(post.id)} disabled={actingId === post.id}>
<Send className="ml-2 h-4 w-4" />ارسال برای بررسی
</Button>
<div className="flex flex-1 basis-[calc(33.333%-0.5rem)] md:col-start-2 md:row-start-1 md:block md:basis-auto">
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
onClick={() => submitPost(post.id)}
disabled={actingId === post.id}
aria-label="ارسال برای بررسی"
className="w-full"
>
<Send className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>ارسال برای بررسی</TooltipContent>
</Tooltip>
</div>
) : null}
{canReview && post.status === "submitted" ? (
<>
<Button size="sm" onClick={() => reviewPost(post.id, "publish")} disabled={actingId === post.id}>انتشار</Button>
<Button size="sm" variant="outline" onClick={() => reviewPost(post.id, "request_changes")} disabled={actingId === post.id}>درخواست اصلاح</Button>
<div className="flex flex-1 basis-[calc(33.333%-0.5rem)] md:col-start-2 md:row-start-1 md:block md:basis-auto">
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
onClick={() => reviewPost(post.id, "publish")}
disabled={actingId === post.id}
aria-label="انتشار"
className="w-full"
>
<CheckCircle2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>انتشار</TooltipContent>
</Tooltip>
</div>
<div className="flex flex-1 basis-[calc(33.333%-0.5rem)] md:col-start-2 md:row-start-2 md:block md:basis-auto">
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
onClick={() => openChangesDialog(post)}
disabled={actingId === post.id}
aria-label="درخواست اصلاح"
className="w-full"
>
<XCircle className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>درخواست اصلاح</TooltipContent>
</Tooltip>
</div>
</>
) : null}
</div>
<div className="text-right">
<div className="flex flex-wrap items-center justify-end gap-2">
<Badge variant={post.status === "published" ? "default" : "secondary"}>{statusLabels[post.status] ?? post.status}</Badge>
<h3 className="font-semibold">{post.title}</h3>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{post.updated_at ? formatJalali(post.updated_at, false) : ""}
</p>
</div>
</div>
))
) : (
@@ -181,6 +286,45 @@ export default function AdminBlog() {
)}
</CardContent>
</Card>
<Dialog open={Boolean(changesPost)} onOpenChange={(open) => (open ? undefined : closeChangesDialog())}>
<DialogContent className="max-w-xl rounded-3xl" dir="rtl">
<DialogHeader className="mt-6 text-right md:text-right">
<DialogTitle>درخواست اصلاح نوشته</DialogTitle>
<DialogDescription>
توضیح کوتاهی بنویسید تا نویسنده بداند چه چیزی باید در نوشته اصلاح شود.
</DialogDescription>
</DialogHeader>
<div className="rounded-2xl border bg-muted/30 p-3 text-right">
<p className="text-xs text-muted-foreground">نوشته</p>
<p className="mt-1 font-semibold">{changesPost?.title}</p>
</div>
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
void requestChanges();
}}
>
<Textarea
value={changesNote}
onChange={(event) => setChangesNote(event.target.value)}
placeholder="مثلاً: بخش مقدمه نیاز به منبع دارد، تیترها را واضح‌تر کنید، یا نمونه کد را اصلاح کنید..."
className="min-h-36 text-right leading-7"
autoFocus
/>
<div className="flex flex-wrap justify-start gap-2">
<Button type="submit" disabled={!changesPost || actingId === changesPost.id}>
{actingId === changesPost?.id ? <Loader2 className="ml-2 h-4 w-4 animate-spin" /> : null}
ثبت درخواست اصلاح
</Button>
<Button type="button" variant="outline" onClick={closeChangesDialog} disabled={Boolean(actingId)}>
انصراف
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -1,29 +1,95 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { ArrowRight, Copy, Loader2, Trash2, UploadCloud } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import {
AlertCircle,
ArrowRight,
CheckCircle2,
Copy,
ExternalLink,
File,
FileArchive,
FileText,
ImageIcon,
Loader2,
Trash2,
UploadCloud,
Video,
X,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Card, CardContent } from "@/components/ui/card";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Progress } from "@/components/ui/progress";
import { api } from "@/lib/api";
import type * as Types from "@/lib/types";
import { resolveErrorMessage } from "@/lib/utils";
import { cn, resolveErrorMessage } from "@/lib/utils";
import { useToast } from "@/hooks/use-toast";
type Props = {
postId: number;
};
type QueueStatus = "queued" | "uploading" | "uploaded" | "failed";
type QueueItem = {
id: string;
file: File;
progress: number;
status: QueueStatus;
error?: string;
};
const formatSize = (size: number) => {
if (size < 1024 * 1024) return `${Math.ceil(size / 1024)} KB`;
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
};
const fileKindFromAsset = (asset: Types.PostAssetSchema) => asset.file_type;
const fileKindFromFile = (file: File): Types.PostAssetSchema["file_type"] => {
if (file.type.startsWith("image/")) return "image";
if (file.type.startsWith("video/")) return "video";
if (file.type.includes("zip") || /\.(zip|rar|7z|tar|gz)$/i.test(file.name)) return "archive";
if (file.type || /\.(pdf|doc|docx|xls|xlsx|ppt|pptx|txt|md)$/i.test(file.name)) return "document";
return "other";
};
const iconForKind = (kind: Types.PostAssetSchema["file_type"]) => {
if (kind === "image") return ImageIcon;
if (kind === "video") return Video;
if (kind === "document") return FileText;
if (kind === "archive") return FileArchive;
return File;
};
const statusLabel: Record<QueueStatus, string> = {
queued: "در صف",
uploading: "در حال آپلود",
uploaded: "آپلود شد",
failed: "ناموفق",
};
export default function AdminBlogAssets({ postId }: Props) {
const router = useRouter();
const { toast } = useToast();
const fileInputRef = useRef<HTMLInputElement | null>(null);
const processingRef = useRef(false);
const [post, setPost] = useState<Types.PostDetailSchema | null>(null);
const [assets, setAssets] = useState<Types.PostAssetSchema[]>([]);
const [queue, setQueue] = useState<QueueItem[]>([]);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [deletingId, setDeletingId] = useState<number | null>(null);
const [previewAsset, setPreviewAsset] = useState<Types.PostAssetSchema | null>(null);
const hasActiveUpload = queue.some((item) => item.status === "uploading");
const queueSummary = useMemo(() => {
const uploaded = queue.filter((item) => item.status === "uploaded").length;
const failed = queue.filter((item) => item.status === "failed").length;
return { uploaded, failed, total: queue.length };
}, [queue]);
const loadData = async () => {
if (!Number.isFinite(postId)) {
@@ -55,27 +121,71 @@ export default function AdminBlogAssets({ postId }: Props) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [postId]);
const uploadAsset = async (file: File) => {
setUploading(true);
try {
const asset = await api.uploadBlogPostAsset(postId, file, { title: file.name });
setAssets((prev) => [asset, ...prev]);
toast({ title: "فایل آپلود شد", variant: "success" });
} catch (error) {
toast({
title: "آپلود ناموفق بود",
description: resolveErrorMessage(error, "دوباره تلاش کنید"),
variant: "destructive",
useEffect(() => {
const nextItem = queue.find((item) => item.status === "queued");
if (!nextItem || processingRef.current) return;
processingRef.current = true;
setQueue((current) =>
current.map((item) => (item.id === nextItem.id ? { ...item, status: "uploading", progress: 1 } : item)),
);
api.uploadBlogPostAssetWithProgress(
postId,
nextItem.file,
{ title: nextItem.file.name },
(progress) => {
setQueue((current) =>
current.map((item) => (item.id === nextItem.id ? { ...item, progress: Math.max(progress, item.progress) } : item)),
);
},
)
.then((asset) => {
setAssets((current) => [asset, ...current]);
setQueue((current) =>
current.map((item) => (item.id === nextItem.id ? { ...item, status: "uploaded", progress: 100 } : item)),
);
})
.catch((error) => {
setQueue((current) =>
current.map((item) =>
item.id === nextItem.id
? { ...item, status: "failed", error: resolveErrorMessage(error, "آپلود ناموفق بود") }
: item,
),
);
})
.finally(() => {
processingRef.current = false;
setQueue((current) => [...current]);
});
} finally {
setUploading(false);
}
}, [postId, queue]);
const addFilesToQueue = (files: FileList | File[]) => {
const nextFiles = Array.from(files);
if (!nextFiles.length) return;
setQueue((current) => [
...current,
...nextFiles.map((file) => ({
id: `${file.name}-${file.size}-${file.lastModified}-${crypto.randomUUID()}`,
file,
progress: 0,
status: "queued" as const,
})),
]);
};
const onFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (event.target.files) addFilesToQueue(event.target.files);
event.currentTarget.value = "";
if (file) void uploadAsset(file);
};
const removeQueueItem = (itemId: string) => {
setQueue((current) => current.filter((item) => item.id !== itemId || item.status === "uploading"));
};
const clearFinishedQueue = () => {
setQueue((current) => current.filter((item) => item.status === "queued" || item.status === "uploading"));
};
const copySnippet = async (asset: Types.PostAssetSchema) => {
@@ -101,6 +211,15 @@ export default function AdminBlogAssets({ postId }: Props) {
}
};
const openAssetPreview = (asset: Types.PostAssetSchema) => {
const fileUrl = asset.absolute_file_url;
if (asset.file_type === "image" || asset.file_type === "video") {
setPreviewAsset(asset);
return;
}
if (fileUrl) window.open(fileUrl, "_blank", "noopener,noreferrer");
};
if (loading) {
return (
<div className="flex min-h-[50vh] items-center justify-center" dir="rtl">
@@ -110,8 +229,8 @@ export default function AdminBlogAssets({ postId }: Props) {
}
return (
<div className="space-y-6" dir="rtl">
<div className="flex flex-col gap-4 md:flex-row-reverse md:items-center md:justify-between">
<div className="space-y-6">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="text-right">
<h2 className="text-2xl font-bold">مرکز آپلود نوشته</h2>
<p className="mt-1 text-sm text-muted-foreground">
@@ -124,56 +243,129 @@ export default function AdminBlogAssets({ postId }: Props) {
</Button>
</div>
<Card>
<CardHeader className="text-right">
<CardTitle>آپلود فایل</CardTitle>
<CardDescription>تصاویر، ویدئوها، اسناد و فایلهای فشرده مجاز هستند. لینک مارکداون هر فایل بعد از آپلود قابل کپی است.</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<input ref={fileInputRef} type="file" className="hidden" onChange={onFileChange} />
<Button variant="secondary" onClick={() => fileInputRef.current?.click()} disabled={uploading}>
{uploading ? <Loader2 className="ml-2 h-4 w-4 animate-spin" /> : <UploadCloud className="ml-2 h-4 w-4" />}
آپلود فایل
</Button>
<Card className="overflow-hidden">
<CardContent className="space-y-5 p-4 md:p-6">
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={onFileChange} />
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="flex min-h-48 w-full flex-col items-center justify-center rounded-2xl border border-dashed bg-muted/20 p-6 text-center transition hover:bg-muted/40"
>
<UploadCloud className="mb-3 h-12 w-12 text-muted-foreground" />
<span className="font-semibold">افزودن فایلهای بیشتر</span>
<span className="mt-1 text-sm text-muted-foreground">چند فایل را همزمان انتخاب کنید؛ فایلها یکییکی آپلود میشوند.</span>
</button>
{assets.length ? (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{assets.map((asset) => (
<div key={asset.id} className="rounded-2xl border p-3">
<div className="flex flex-row-reverse items-start justify-between gap-3">
<div className="text-right">
<div className="flex flex-wrap items-center justify-end gap-2">
<Badge variant="secondary">{asset.file_type}</Badge>
<p className="font-medium">{asset.title}</p>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{asset.mime_type || "file"} · {Math.ceil(asset.size / 1024)} KB
</p>
</div>
<div className="flex shrink-0 gap-1">
<Button variant="ghost" size="icon" onClick={() => copySnippet(asset)}>
<Copy className="h-4 w-4" />
</Button>
{queue.length ? (
<div className="space-y-3">
<div className="flex flex-col gap-2 text-right text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
<Button variant="outline" size="sm" onClick={clearFinishedQueue} disabled={hasActiveUpload}>
پاکسازی فایلهای تمامشده
</Button>
<span>
{queueSummary.total} فایل · {queueSummary.uploaded} موفق · {queueSummary.failed} ناموفق
</span>
</div>
{queue.map((item) => {
const kind = fileKindFromFile(item.file);
const Icon = iconForKind(kind);
return (
<div key={item.id} className="rounded-2xl border bg-background p-3">
<div className="flex items-center gap-3">
<Button
type="button"
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive"
onClick={() => deleteAsset(asset.id)}
disabled={deletingId === asset.id}
className="h-8 w-8 shrink-0 text-muted-foreground"
disabled={item.status === "uploading"}
onClick={() => removeQueueItem(item.id)}
>
{deletingId === asset.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
<X className="h-4 w-4" />
</Button>
<div className="min-w-0 flex-1 text-right">
<p className="truncate text-sm font-medium">{item.file.name}</p>
<p className="text-xs text-muted-foreground">{formatSize(item.file.size)}</p>
</div>
<Badge
variant={item.status === "failed" ? "destructive" : item.status === "uploaded" ? "default" : "secondary"}
className="shrink-0"
>
{statusLabel[item.status]}
</Badge>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-muted">
<Icon className="h-5 w-5 text-muted-foreground" />
</div>
</div>
<div className="mt-3 flex items-center gap-2">
{item.status === "uploaded" ? <CheckCircle2 className="h-4 w-4 text-emerald-600" /> : null}
{item.status === "failed" ? <AlertCircle className="h-4 w-4 text-destructive" /> : null}
<Progress value={item.progress} className="h-2" />
</div>
{item.error ? <p className="mt-2 text-right text-xs text-destructive">{item.error}</p> : null}
</div>
);
})}
</div>
) : null}
{assets.length ? (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{assets.map((asset) => {
const kind = fileKindFromAsset(asset);
const Icon = iconForKind(kind);
const previewUrl = asset.absolute_thumbnail_url || asset.absolute_preview_url || asset.absolute_file_url;
return (
<div key={asset.id} className="rounded-2xl border bg-background p-3">
<button
type="button"
onClick={() => openAssetPreview(asset)}
className={cn(
"flex aspect-video w-full items-center justify-center overflow-hidden rounded-xl bg-muted text-muted-foreground",
asset.absolute_file_url ? "cursor-pointer hover:bg-muted/70" : "cursor-default",
)}
>
{kind === "image" && previewUrl ? (
<img src={previewUrl} alt={asset.alt_text || asset.title} className="h-full w-full object-cover" />
) : (
<Icon className="h-10 w-10" />
)}
</button>
<div className="mt-3 flex items-start gap-3">
<div className="flex shrink-0 gap-1">
<Button variant="ghost" size="icon" onClick={() => copySnippet(asset)} aria-label="کپی مارک‌داون">
<Copy className="h-4 w-4" />
</Button>
{asset.absolute_file_url ? (
<Button variant="ghost" size="icon" asChild aria-label="باز کردن فایل">
<a href={asset.absolute_file_url} target="_blank" rel="noreferrer">
<ExternalLink className="h-4 w-4" />
</a>
</Button>
) : null}
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive"
onClick={() => deleteAsset(asset.id)}
disabled={deletingId === asset.id}
aria-label="حذف فایل"
>
{deletingId === asset.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
</Button>
</div>
<div className="min-w-0 flex-1 text-right">
<div className="flex flex-wrap items-center justify-end gap-2">
<Badge variant="secondary">{asset.file_type}</Badge>
<p className="truncate font-medium">{asset.title}</p>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{asset.mime_type || "file"} · {formatSize(asset.size)}
</p>
</div>
</div>
</div>
{asset.absolute_preview_url ? (
<img src={asset.absolute_preview_url} alt={asset.alt_text || asset.title} className="mt-3 aspect-video w-full rounded-xl object-cover" />
) : asset.absolute_file_url ? (
<a className="mt-3 block truncate rounded-xl bg-muted px-3 py-2 text-left text-xs underline" href={asset.absolute_file_url} target="_blank" rel="noreferrer">
{asset.absolute_file_url}
</a>
) : null}
</div>
))}
);
})}
</div>
) : (
<div className="rounded-2xl border border-dashed p-8 text-center text-sm text-muted-foreground">
@@ -182,6 +374,25 @@ export default function AdminBlogAssets({ postId }: Props) {
)}
</CardContent>
</Card>
<Dialog open={Boolean(previewAsset)} onOpenChange={(open) => !open && setPreviewAsset(null)}>
<DialogContent className="max-w-4xl" dir="rtl">
<DialogHeader className="text-right">
<DialogTitle>{previewAsset?.title}</DialogTitle>
<DialogDescription>{previewAsset?.caption || previewAsset?.mime_type || "پیش‌نمایش فایل"}</DialogDescription>
</DialogHeader>
{previewAsset?.file_type === "image" ? (
<img
src={previewAsset.absolute_preview_url || previewAsset.absolute_file_url || ""}
alt={previewAsset.alt_text || previewAsset.title}
className="max-h-[75vh] w-full rounded-2xl object-contain"
/>
) : null}
{previewAsset?.file_type === "video" && previewAsset.absolute_file_url ? (
<video src={previewAsset.absolute_file_url} className="max-h-[75vh] w-full rounded-2xl" controls />
) : null}
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,294 @@
"use client";
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Edit3, Loader2, Plus, RotateCcw, Trash2 } from "lucide-react";
import { useAuth } from "@/contexts/AuthContext";
import { api } from "@/lib/api";
import type * as Types from "@/lib/types";
import { resolveErrorMessage, toPersianDigits } from "@/lib/utils";
import { useToast } from "@/hooks/use-toast";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
type CategoryForm = {
name: string;
slug: string;
description: string;
parent_id: string;
};
const emptyForm: CategoryForm = {
name: "",
slug: "",
description: "",
parent_id: "none",
};
export default function AdminBlogCategories() {
const { user } = useAuth();
const { toast } = useToast();
const [search, setSearch] = useState("");
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<Types.AdminCategorySchema | null>(null);
const [form, setForm] = useState<CategoryForm>(emptyForm);
const [submitting, setSubmitting] = useState(false);
const canDelete = Boolean(user?.is_superuser);
const categoriesQuery = useQuery({
queryKey: ["admin", "blog", "categories"],
queryFn: () => api.listAdminCategories(),
});
const deletedQuery = useQuery({
queryKey: ["admin", "blog", "categories", "deleted"],
queryFn: () => api.listDeletedCategories(),
enabled: canDelete,
});
const categories = useMemo(() => categoriesQuery.data ?? [], [categoriesQuery.data]);
const rootCategories = useMemo(() => categories.filter((category) => !category.parent_id), [categories]);
const editingHasChildren = useMemo(
() => Boolean(editing && categories.some((category) => category.parent_id === editing.id)),
[categories, editing],
);
const visibleCategories = useMemo(() => {
const needle = search.trim().toLowerCase();
if (!needle) return categories;
return categories.filter((category) =>
[category.name, category.slug, category.description ?? ""].some((value) => value.toLowerCase().includes(needle)),
);
}, [categories, search]);
const openCreate = () => {
setEditing(null);
setForm(emptyForm);
setDialogOpen(true);
};
const openEdit = (category: Types.AdminCategorySchema) => {
setEditing(category);
setForm({
name: category.name,
slug: category.slug,
description: category.description ?? "",
parent_id: category.parent_id ? String(category.parent_id) : "none",
});
setDialogOpen(true);
};
const closeDialog = (force = false) => {
if (submitting && !force) return;
setDialogOpen(false);
setEditing(null);
setForm(emptyForm);
};
const saveCategory = async () => {
const payload: Types.CategoryWriteSchema = {
name: form.name.trim(),
slug: form.slug.trim() || null,
description: form.description,
parent_id: form.parent_id === "none" ? null : Number(form.parent_id),
};
try {
setSubmitting(true);
if (editing) {
await api.updateCategory(editing.id, payload);
} else {
await api.createCategory(payload);
}
toast({ title: editing ? "دسته‌بندی ویرایش شد" : "دسته‌بندی ساخته شد", variant: "success" });
await categoriesQuery.refetch();
closeDialog(true);
} catch (error) {
toast({
title: "ذخیره دسته‌بندی ناموفق بود",
description: resolveErrorMessage(error, "دوباره تلاش کنید"),
variant: "destructive",
});
} finally {
setSubmitting(false);
}
};
const deleteCategory = async (category: Types.AdminCategorySchema) => {
if (!window.confirm(`دسته‌بندی «${category.name}» حذف شود؟`)) return;
try {
await api.deleteCategory(category.id);
toast({ title: "دسته‌بندی حذف شد", variant: "success" });
await Promise.all([categoriesQuery.refetch(), deletedQuery.refetch()]);
} catch (error) {
toast({
title: "حذف دسته‌بندی ناموفق بود",
description: resolveErrorMessage(error, "دوباره تلاش کنید"),
variant: "destructive",
});
}
};
const restoreCategory = async (category: Types.CategorySchema) => {
try {
await api.restoreCategory(category.id);
toast({ title: "دسته‌بندی بازیابی شد", variant: "success" });
await Promise.all([categoriesQuery.refetch(), deletedQuery.refetch()]);
} catch (error) {
toast({
title: "بازیابی دسته‌بندی ناموفق بود",
description: resolveErrorMessage(error, "دوباره تلاش کنید"),
variant: "destructive",
});
}
};
return (
<div className="space-y-6">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="text-right">
<h2 className="text-2xl font-bold">دستهبندیهای بلاگ</h2>
<p className="mt-1 text-sm text-muted-foreground">ساخت و مدیریت دستهبندیهای تو در تو برای نوشتهها.</p>
</div>
<Button onClick={openCreate} className="gap-2">
<Plus className="h-4 w-4" />
دستهبندی جدید
</Button>
</div>
<Card>
<CardHeader className="text-right">
<CardTitle>لیست دستهبندیها</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="جستجو در نام، اسلاگ یا توضیح..." className="text-right" />
{categoriesQuery.isLoading ? (
<div className="flex justify-center py-10 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
</div>
) : visibleCategories.length ? (
<div className="overflow-x-auto rounded-2xl border">
<table className="w-full min-w-[760px] text-sm">
<thead className="bg-muted/40 text-muted-foreground">
<tr>
<th className="px-4 py-3 text-right">عنوان</th>
{/* <th className="px-4 py-3 text-right">اسلاگ</th> */}
<th className="px-4 py-3 text-right">والد</th>
<th className="px-4 py-3 text-right">تعداد نوشته</th>
<th className="px-4 py-3 text-right"></th>
</tr>
</thead>
<tbody>
{visibleCategories.map((category) => (
<tr key={category.id} className="border-t">
<td className="px-4 py-3 font-medium">{category.name}</td>
{/* <td className="px-4 py-3 text-muted-foreground">{category.slug}</td> */}
<td className="px-4 py-3 text-muted-foreground">
{categories.find((item) => item.id === category.parent_id)?.name ?? "—"}
</td>
<td className="px-4 py-3">{toPersianDigits(String(category.post_count))}</td>
<td className="px-4 py-3">
<div className="flex flex-wrap justify-end gap-2">
<Button size="sm" variant="outline" onClick={() => openEdit(category)}>
<Edit3 className="h-3.5 w-3.5" />
</Button>
{canDelete ? (
<Button size="sm" variant="outline" className="text-destructive hover:text-destructive" onClick={() => void deleteCategory(category)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
) : null}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className="rounded-2xl border border-dashed p-8 text-center text-sm text-muted-foreground">دستهبندیای یافت نشد.</p>
)}
</CardContent>
</Card>
{canDelete ? (
<Card>
<CardHeader className="text-right">
<CardTitle>دستهبندیهای حذفشده</CardTitle>
<CardDescription>بازیابی رکوردهای حذف شده.</CardDescription>
</CardHeader>
<CardContent>
{deletedQuery.data?.length ? (
<div className="flex flex-col gap-3">
{deletedQuery.data.map((category) => (
<div key={category.id} className="flex items-center justify-between rounded-2xl border p-3">
<span className="font-medium">{category.name}</span>
<Button size="sm" variant="outline" onClick={() => void restoreCategory(category)}>
<RotateCcw className="ml-1 h-3.5 w-3.5" />
بازیابی
</Button>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">مورد حذفشدهای وجود ندارد.</p>
)}
</CardContent>
</Card>
) : null}
<Dialog open={dialogOpen} onOpenChange={(open) => !open && closeDialog()}>
<DialogContent dir="rtl" className="text-right">
<DialogHeader>
<DialogTitle>{editing ? "ویرایش دسته‌بندی" : "دسته‌بندی جدید"}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label className="mb-2 block">نام</Label>
<Input value={form.name} onChange={(event) => setForm((prev) => ({ ...prev, name: event.target.value }))} />
</div>
<div>
<Label className="mb-2 block">اسلاگ اختیاری</Label>
<Input value={form.slug} onChange={(event) => setForm((prev) => ({ ...prev, slug: event.target.value }))} dir="ltr" />
</div>
<div>
<Label className="mb-2 block">والد</Label>
<Select
value={form.parent_id}
onValueChange={(value) => setForm((prev) => ({ ...prev, parent_id: value }))}
disabled={editingHasChildren}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="none">بدون والد</SelectItem>
{rootCategories.filter((item) => item.id !== editing?.id).map((category) => (
<SelectItem key={category.id} value={String(category.id)}>{category.name}</SelectItem>
))}
</SelectContent>
</Select>
{editingHasChildren ? (
<p className="mt-2 text-xs text-muted-foreground">
دستهبندیهایی که زیرمجموعه دارند باید در سطح ریشه باقی بمانند.
</p>
) : null}
</div>
<div>
<Label className="mb-2 block">توضیحات</Label>
<Textarea value={form.description} onChange={(event) => setForm((prev) => ({ ...prev, description: event.target.value }))} className="min-h-24" />
</div>
<div className="flex justify-start gap-2">
<Button variant="outline" onClick={() => closeDialog()} disabled={submitting}>انصراف</Button>
<Button onClick={saveCategory} disabled={submitting || !form.name.trim()}>
{submitting ? <Loader2 className="ml-2 h-4 w-4 animate-spin" /> : null}
ذخیره
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -1,10 +1,12 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { ArrowLeft, ArrowRight, FolderUp, ImageUp, Loader2, Save, Send, Trash2 } from "lucide-react";
import { AlertTriangle, ArrowLeft, ArrowRight, FolderUp, ImageUp, Loader2, Save, Send, Trash2 } from "lucide-react";
import { useRouter } from "next/navigation";
import Markdown from "@/components/Markdown";
import MarkdownEditor, { type MarkdownDirectionMode } from "@/components/MarkdownEditor";
import { useAuth } from "@/contexts/AuthContext";
import { Link } from "@/lib/router";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@@ -12,6 +14,7 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/lib/api";
import type * as Types from "@/lib/types";
@@ -53,11 +56,13 @@ export default function AdminBlogEditor({ postId }: Props) {
const [loading, setLoading] = useState(Boolean(postId));
const [saving, setSaving] = useState(false);
const [uploadingFeatured, setUploadingFeatured] = useState(false);
const [editorDirection, setEditorDirection] = useState<MarkdownDirectionMode>("auto");
const isNew = postId == null;
const featuredImage = post?.absolute_featured_image_preview_url || post?.absolute_featured_image_url || post?.featured_image;
const canPersistPost = form.title.trim() && form.content.trim();
const canAssignWriters = Boolean(user?.is_staff || user?.is_superuser || user?.can_review_blog_posts);
const reviewNote = post?.status === "changes_requested" ? post.review_note?.trim() : "";
useEffect(() => {
Promise.all([api.getCategories(), api.getTags()])
@@ -169,13 +174,6 @@ export default function AdminBlogEditor({ postId }: Props) {
return savePost();
};
const openUploadCenter = async () => {
const targetPost = await ensureSavedPost();
if (targetPost) {
router.push(`/admin/blog/${targetPost.id}/assets`);
}
};
const onFeaturedImageChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.currentTarget.value = "";
@@ -227,27 +225,54 @@ export default function AdminBlogEditor({ postId }: Props) {
}
return (
<div className="space-y-6" dir="rtl">
<div className="flex flex-col gap-4 md:flex-row-reverse md:items-center md:justify-between">
<div className="text-right">
<h2 className="text-2xl font-bold">{isNew ? "نوشته جدید" : "ویرایش نوشته"}</h2>
<p className="mt-1 text-sm text-muted-foreground">
متن را با مارکداون بنویسید، تصویر شاخص را تنظیم کنید و فایلهای داخل متن را از مرکز آپلود جداگانه مدیریت کنید.
</p>
<div className="space-y-6">
<div className="rounded-3xl border bg-background/90 p-4 shadow-sm">
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
<div className="text-right">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-2xl font-bold">{isNew ? "نوشته جدید" : "ویرایش نوشته"}</h2>
<Badge variant="outline">{form.status || "draft"}</Badge>
</div>
<p className="mt-1 text-sm text-muted-foreground">
نوشتن مارکداون، پیشنمایش زنده و تنظیمات انتشار در یک محیط مینیمال.
</p>
</div>
<div className="flex flex-wrap justify-start gap-2">
<Button variant="outline" onClick={() => router.push("/admin/blog")}>
<ArrowRight className="ml-2 h-4 w-4" />
بازگشت
</Button>
<Button variant="secondary" onClick={savePost} disabled={saving}>
{saving ? <Loader2 className="ml-2 h-4 w-4 animate-spin" /> : <Save className="ml-2 h-4 w-4" />}
ذخیره پیشنویس
</Button>
<Button onClick={submitForReview} disabled={saving || !canPersistPost}>
<Send className="ml-2 h-4 w-4" />
ارسال برای بررسی
</Button>
</div>
</div>
<Button variant="outline" onClick={() => router.push("/admin/blog")}>
<ArrowRight className="ml-2 h-4 w-4" />
بازگشت
</Button>
</div>
<div className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
<Card>
<CardHeader className="text-right">
<CardTitle>محتوا و سئو</CardTitle>
<CardDescription>عنوان، متن مارکداون و متادیتای موتورهای جستوجو.</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
{reviewNote ? (
<div className="rounded-3xl border border-amber-300/70 bg-amber-50 p-5 text-amber-950 shadow-sm dark:border-amber-500/40 dark:bg-amber-950/30 dark:text-amber-100">
<div className="flex items-start gap-3 text-right">
<AlertTriangle className="mt-1 h-5 w-5 shrink-0 text-amber-600 dark:text-amber-300" />
<div className="space-y-2">
<p className="font-bold">این نوشته نیازمند اصلاح است</p>
<p className="text-sm leading-7">{reviewNote}</p>
</div>
</div>
</div>
) : null}
<Card>
<CardHeader className="text-right">
<CardTitle>مشخصات نوشته</CardTitle>
<CardDescription>اطلاعات اصلی، دستهبندی، نویسندگان، تصویر شاخص و فایلهای ضمیمه.</CardDescription>
</CardHeader>
<CardContent className="grid gap-6 xl:grid-cols-[1fr_320px]">
<div className="space-y-5">
<div className="grid gap-4 md:grid-cols-2">
<div>
<Label className="mb-2 block text-right">عنوان</Label>
@@ -275,46 +300,33 @@ export default function AdminBlogEditor({ postId }: Props) {
<Textarea value={form.excerpt ?? ""} onChange={(event) => updateForm("excerpt", event.target.value)} className="min-h-20 text-right" />
</div>
<div>
<Label className="mb-2 block text-right">متن مارکداون</Label>
<Textarea
value={form.content}
onChange={(event) => updateForm("content", event.target.value)}
className="min-h-[420px] font-mono text-left"
dir="ltr"
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div>
<Label className="mb-2 block text-right">SEO Title</Label>
<Input value={form.seo_title ?? ""} onChange={(event) => updateForm("seo_title", event.target.value)} className="text-right" maxLength={70} />
</div>
<div>
<Label className="mb-2 block text-right">Focus Keyword</Label>
<Input value={form.focus_keyword ?? ""} onChange={(event) => updateForm("focus_keyword", event.target.value)} className="text-right" />
</div>
</div>
<div>
<Label className="mb-2 block text-right">SEO Description</Label>
<Textarea value={form.seo_description ?? ""} onChange={(event) => updateForm("seo_description", event.target.value)} className="min-h-20 text-right" maxLength={170} />
</div>
<div className="grid gap-4 md:grid-cols-2">
<div>
<Label className="mb-2 block text-right">Canonical URL</Label>
<Input value={form.canonical_url ?? ""} onChange={(event) => updateForm("canonical_url", event.target.value)} dir="ltr" />
<Label className="mb-2 block text-right">وضعیت</Label>
<Select
value={form.status || "draft"}
onValueChange={(value) => updateForm("status", value as Types.PostCreateSchema["status"])}
disabled={!canAssignWriters}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="draft">پیشنویس</SelectItem>
<SelectItem value="submitted">در انتظار بررسی</SelectItem>
<SelectItem value="changes_requested">نیازمند تغییر</SelectItem>
<SelectItem value="published">منتشر شده</SelectItem>
<SelectItem value="archived">آرشیو</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-end gap-2 pt-8">
<Label>noindex</Label>
<Checkbox checked={Boolean(form.noindex)} onCheckedChange={(checked) => updateForm("noindex", Boolean(checked))} />
<Label>نوشته ویژه</Label>
<Checkbox checked={Boolean(form.is_featured)} onCheckedChange={(checked) => updateForm("is_featured", Boolean(checked))} />
</div>
</div>
<div>
<Label className="mb-2 block text-right">برچسبها</Label>
<div className="flex flex-wrap justify-end gap-2">
<div className="flex flex-wrap gap-2">
{tags.map((tag) => {
const selected = selectedTagIds.includes(tag.id);
return (
@@ -323,12 +335,7 @@ export default function AdminBlogEditor({ postId }: Props) {
type="button"
size="sm"
variant={selected ? "default" : "outline"}
onClick={() => {
updateForm(
"tag_ids",
selected ? selectedTagIds.filter((id) => id !== tag.id) : [...selectedTagIds, tag.id],
);
}}
onClick={() => updateForm("tag_ids", selected ? selectedTagIds.filter((id) => id !== tag.id) : [...selectedTagIds, tag.id])}
>
{tag.name}
</Button>
@@ -340,7 +347,7 @@ export default function AdminBlogEditor({ postId }: Props) {
{canAssignWriters ? (
<div>
<Label className="mb-2 block text-right">نویسندگان</Label>
<div className="flex flex-wrap justify-end gap-2">
<div className="flex flex-wrap gap-2">
{users.map((writer) => {
const selected = selectedWriterIds.includes(writer.id);
const fullName = [writer.first_name, writer.last_name].filter(Boolean).join(" ") || writer.username;
@@ -350,12 +357,7 @@ export default function AdminBlogEditor({ postId }: Props) {
type="button"
size="sm"
variant={selected ? "default" : "outline"}
onClick={() => {
updateForm(
"writer_ids",
selected ? selectedWriterIds.filter((id) => id !== writer.id) : [...selectedWriterIds, writer.id],
);
}}
onClick={() => updateForm("writer_ids", selected ? selectedWriterIds.filter((id) => id !== writer.id) : [...selectedWriterIds, writer.id])}
>
{fullName}
</Button>
@@ -367,84 +369,139 @@ export default function AdminBlogEditor({ postId }: Props) {
</p>
</div>
) : null}
</CardContent>
</Card>
</div>
<div className="space-y-6">
<Card>
<CardHeader className="text-right">
<CardTitle>تصویر شاخص</CardTitle>
<CardDescription>این تصویر به عنوان تامبنیل کارتهای لیست بلاگ و کاور نوشته استفاده میشود.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<input ref={featuredInputRef} type="file" accept="image/*" className="hidden" onChange={onFeaturedImageChange} />
<div className="overflow-hidden rounded-2xl border bg-muted">
{featuredImage ? (
<img src={featuredImage} alt={post?.title || form.title || "thumbnail"} className="aspect-video w-full object-cover" />
) : (
<div className="flex aspect-video items-center justify-center text-sm text-muted-foreground">
تصویری انتخاب نشده است.
</div>
)}
</div>
<div className="flex flex-wrap justify-end gap-2">
{post?.featured_image || post?.absolute_featured_image_url ? (
<Button variant="outline" onClick={deleteFeaturedImage} disabled={uploadingFeatured}>
<Trash2 className="ml-2 h-4 w-4" />
حذف تصویر
</Button>
) : null}
<Button variant="secondary" onClick={() => featuredInputRef.current?.click()} disabled={uploadingFeatured || saving}>
{uploadingFeatured ? <Loader2 className="ml-2 h-4 w-4 animate-spin" /> : <ImageUp className="ml-2 h-4 w-4" />}
انتخاب تصویر
<div className="space-y-4">
<input ref={featuredInputRef} type="file" accept="image/*" className="hidden" onChange={onFeaturedImageChange} />
<div className="overflow-hidden rounded-2xl border bg-muted">
{featuredImage ? (
<img src={featuredImage} alt={post?.title || form.title || "thumbnail"} className="aspect-video w-full object-cover" />
) : (
<div className="flex aspect-video items-center justify-center text-sm text-muted-foreground">
تصویری انتخاب نشده است.
</div>
)}
</div>
<div className="flex flex-wrap justify-end gap-2">
{post?.featured_image || post?.absolute_featured_image_url ? (
<Button variant="outline" onClick={deleteFeaturedImage} disabled={uploadingFeatured}>
<Trash2 className="ml-2 h-4 w-4" />
حذف تصویر
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="text-right">
<CardTitle>مرکز آپلود</CardTitle>
<CardDescription>فایلهای داخل متن، تصاویر، اسناد و آرشیوها در صفحه جداگانه همین نوشته مدیریت میشوند.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Button className="w-full justify-center rounded-2xl py-6" variant="outline" onClick={openUploadCenter} disabled={saving}>
<FolderUp className="ml-2 h-4 w-4" />
رفتن به مرکز آپلود
<ArrowLeft className="mr-2 h-4 w-4" />
</Button>
{!post?.id ? (
<p className="text-right text-xs text-muted-foreground">
برای نوشته جدید، ابتدا پیشنویس ذخیره میشود و سپس مرکز آپلود باز خواهد شد.
</p>
) : null}
</CardContent>
</Card>
<Card>
<CardHeader className="text-right">
<CardTitle>پیشنمایش</CardTitle>
<CardDescription>همان متن مارکداون بدون ویرایش WYSIWYG.</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-2xl border bg-background p-4">
<Markdown content={form.content || "هنوز محتوایی نوشته نشده است."} justify size="base" />
<Button variant="secondary" onClick={() => featuredInputRef.current?.click()} disabled={uploadingFeatured || saving}>
{uploadingFeatured ? <Loader2 className="ml-2 h-4 w-4 animate-spin" /> : <ImageUp className="ml-2 h-4 w-4" />}
انتخاب تصویر شاخص
</Button>
</div>
{post?.id ? (
<Button asChild className="w-full justify-center rounded-2xl py-6" variant="outline">
<Link to={`/admin/blog/${post.id}/assets`}>
<FolderUp className="ml-2 h-4 w-4" />
رفتن به مرکز آپلود
<ArrowLeft className="mr-2 h-4 w-4" />
</Link>
</Button>
) : (
<div className="rounded-2xl border border-dashed p-4 text-right text-sm text-muted-foreground">
برای باز شدن مرکز آپلود، ابتدا نوشته را به عنوان پیشنویس ذخیره کنید.
</div>
</CardContent>
</Card>
</div>
</div>
)}
</div>
</CardContent>
</Card>
<div className="sticky bottom-4 z-20 flex flex-wrap justify-end gap-3 rounded-2xl border bg-background/90 p-3 shadow-lg backdrop-blur">
<Button variant="secondary" onClick={savePost} disabled={saving}>
{saving ? <Loader2 className="ml-2 h-4 w-4 animate-spin" /> : <Save className="ml-2 h-4 w-4" />}
ذخیره پیشنویس
</Button>
<Button onClick={submitForReview} disabled={saving || !canPersistPost}>
<Send className="ml-2 h-4 w-4" />
ارسال برای بررسی
</Button>
</div>
<Card>
<CardHeader className="text-right">
<CardTitle>متن نوشته</CardTitle>
<CardDescription>ویرایشگر مارکداون در کنار پیشنمایش زنده</CardDescription>
</CardHeader>
<CardContent>
<div className="hidden grid-cols-2 gap-0 overflow-hidden bg-muted/20 md:grid">
<div className="bg-background ">
{/* <div className="border-b px-4 py-3 text-right text-sm font-medium">متن مارک‌داون</div> */}
<MarkdownEditor
value={form.content}
onChange={(value) => updateForm("content", value)}
minHeight="620px"
directionMode={editorDirection}
onDirectionModeChange={setEditorDirection}
onSave={savePost}
className="rounded-none border-0"
/>
</div>
<div className="bg-background">
{/* <div className="border-b px-4 py-3 text-right text-sm font-medium">پیش‌نمایش</div> */}
<div className="min-h-[560px] p-5">
<Markdown content={form.content || "هنوز محتوایی نوشته نشده است."} justify={false} size="base" />
</div>
</div>
</div>
<Tabs defaultValue="editor" className="md:hidden" dir="rtl">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="editor">ویرایش</TabsTrigger>
<TabsTrigger value="preview">پیشنمایش</TabsTrigger>
</TabsList>
<TabsContent value="editor">
<MarkdownEditor
value={form.content}
onChange={(value) => updateForm("content", value)}
minHeight="520px"
directionMode={editorDirection}
onDirectionModeChange={setEditorDirection}
onSave={savePost}
/>
</TabsContent>
<TabsContent value="preview">
<div className="min-h-[520px] rounded-2xl border bg-background p-4">
<Markdown content={form.content || "هنوز محتوایی نوشته نشده است."} justify={false} size="base" />
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
<Card>
<CardHeader className="text-right">
<CardTitle>تنظیمات سئو</CardTitle>
{/* <CardDescription>این بخش جدا از متن اصلی است تا جریان نوشتن ساده و متمرکز بماند.</CardDescription> */}
</CardHeader>
<CardContent className="space-y-5">
<div className="grid gap-4 md:grid-cols-2">
<div>
<Label className="mb-2 block text-right">SEO Title</Label>
<Input value={form.seo_title ?? ""} onChange={(event) => updateForm("seo_title", event.target.value)} className="text-right" maxLength={70} />
</div>
<div>
<Label className="mb-2 block text-right">Focus Keyword</Label>
<Input value={form.focus_keyword ?? ""} onChange={(event) => updateForm("focus_keyword", event.target.value)} className="text-right" />
</div>
</div>
<div>
<Label className="mb-2 block text-right">SEO Description</Label>
<Textarea value={form.seo_description ?? ""} onChange={(event) => updateForm("seo_description", event.target.value)} className="min-h-20 text-right" maxLength={170} />
</div>
<div className="grid gap-4 md:grid-cols-2">
<div>
<Label className="mb-2 block text-right">Canonical URL</Label>
<Input value={form.canonical_url ?? ""} onChange={(event) => updateForm("canonical_url", event.target.value)} dir="ltr" />
</div>
<div>
<Label className="mb-2 block text-right">OG Title</Label>
<Input value={form.og_title ?? ""} onChange={(event) => updateForm("og_title", event.target.value)} className="text-right" />
</div>
</div>
<div>
<Label className="mb-2 block text-right">OG Description</Label>
<Textarea value={form.og_description ?? ""} onChange={(event) => updateForm("og_description", event.target.value)} className="min-h-20 text-right" />
</div>
<div className="flex items-center justify-end gap-2 rounded-2xl border p-4">
<Label>noindex</Label>
<Checkbox checked={Boolean(form.noindex)} onCheckedChange={(checked) => updateForm("noindex", Boolean(checked))} />
</div>
</CardContent>
</Card>
</div>
);
}

244
src/views/AdminBlogTags.tsx Normal file
View File

@@ -0,0 +1,244 @@
"use client";
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Edit3, Loader2, Plus, RotateCcw, Trash2 } from "lucide-react";
import { useAuth } from "@/contexts/AuthContext";
import { api } from "@/lib/api";
import type * as Types from "@/lib/types";
import { resolveErrorMessage, toPersianDigits } from "@/lib/utils";
import { useToast } from "@/hooks/use-toast";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
type TagForm = {
name: string;
slug: string;
};
const emptyForm: TagForm = {
name: "",
slug: "",
};
export default function AdminBlogTags() {
const { user } = useAuth();
const { toast } = useToast();
const [search, setSearch] = useState("");
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<Types.AdminTagSchema | null>(null);
const [form, setForm] = useState<TagForm>(emptyForm);
const [submitting, setSubmitting] = useState(false);
const canDelete = Boolean(user?.is_superuser);
const tagsQuery = useQuery({
queryKey: ["admin", "blog", "tags"],
queryFn: () => api.listAdminTags(),
});
const deletedQuery = useQuery({
queryKey: ["admin", "blog", "tags", "deleted"],
queryFn: () => api.listDeletedTags(),
enabled: canDelete,
});
const tags = useMemo(() => tagsQuery.data ?? [], [tagsQuery.data]);
const visibleTags = useMemo(() => {
const needle = search.trim().toLowerCase();
if (!needle) return tags;
return tags.filter((tag) => [tag.name, tag.slug].some((value) => value.toLowerCase().includes(needle)));
}, [search, tags]);
const openCreate = () => {
setEditing(null);
setForm(emptyForm);
setDialogOpen(true);
};
const openEdit = (tag: Types.AdminTagSchema) => {
setEditing(tag);
setForm({ name: tag.name, slug: tag.slug });
setDialogOpen(true);
};
const closeDialog = (force = false) => {
if (submitting && !force) return;
setDialogOpen(false);
setEditing(null);
setForm(emptyForm);
};
const saveTag = async () => {
const payload: Types.TagWriteSchema = {
name: form.name.trim(),
slug: form.slug.trim() || null,
};
try {
setSubmitting(true);
if (editing) {
await api.updateTag(editing.id, payload);
} else {
await api.createTag(payload);
}
toast({ title: editing ? "برچسب ویرایش شد" : "برچسب ساخته شد", variant: "success" });
await tagsQuery.refetch();
closeDialog(true);
} catch (error) {
toast({
title: "ذخیره برچسب ناموفق بود",
description: resolveErrorMessage(error, "دوباره تلاش کنید"),
variant: "destructive",
});
} finally {
setSubmitting(false);
}
};
const deleteTag = async (tag: Types.AdminTagSchema) => {
if (!window.confirm(`برچسب «${tag.name}» حذف شود؟`)) return;
try {
await api.deleteTag(tag.id);
toast({ title: "برچسب حذف شد", variant: "success" });
await Promise.all([tagsQuery.refetch(), deletedQuery.refetch()]);
} catch (error) {
toast({
title: "حذف برچسب ناموفق بود",
description: resolveErrorMessage(error, "دوباره تلاش کنید"),
variant: "destructive",
});
}
};
const restoreTag = async (tag: Types.TagSchema) => {
try {
await api.restoreTag(tag.id);
toast({ title: "برچسب بازیابی شد", variant: "success" });
await Promise.all([tagsQuery.refetch(), deletedQuery.refetch()]);
} catch (error) {
toast({
title: "بازیابی برچسب ناموفق بود",
description: resolveErrorMessage(error, "دوباره تلاش کنید"),
variant: "destructive",
});
}
};
return (
<div className="space-y-6">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="text-right">
<h2 className="text-2xl font-bold">برچسبهای بلاگ</h2>
<p className="mt-1 text-sm text-muted-foreground">مدیریت موضوعات و برچسبهایی که روی نوشتهها استفاده میشوند.</p>
</div>
<Button onClick={openCreate} className="gap-2">
<Plus className="h-4 w-4" />
برچسب جدید
</Button>
</div>
<Card>
<CardHeader className="text-right">
<CardTitle>لیست برچسبها</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="جستجو در نام یا اسلاگ..." className="text-right" />
{tagsQuery.isLoading ? (
<div className="flex justify-center py-10 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
</div>
) : visibleTags.length ? (
<div className="overflow-x-auto rounded-2xl border">
<table className="w-full min-w-[620px] text-sm">
<thead className="bg-muted/40 text-muted-foreground">
<tr>
<th className="px-4 py-3 text-right">عنوان</th>
{/* <th className="px-4 py-3 text-right">اسلاگ</th> */}
<th className="px-4 py-3 text-right">تعداد نوشته</th>
<th className="px-4 py-3 text-right"></th>
</tr>
</thead>
<tbody>
{visibleTags.map((tag) => (
<tr key={tag.id} className="border-t">
<td className="px-4 py-3 font-medium">{tag.name}</td>
{/* <td className="px-4 py-3 text-muted-foreground">{tag.slug}</td> */}
<td className="px-4 py-3">{toPersianDigits(String(tag.post_count))}</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-2 justify-end">
<Button size="sm" variant="outline" onClick={() => openEdit(tag)}>
<Edit3 className="h-3.5 w-3.5" />
</Button>
{canDelete ? (
<Button size="sm" variant="outline" className="text-destructive hover:text-destructive" onClick={() => void deleteTag(tag)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
) : null}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className="rounded-2xl border border-dashed p-8 text-center text-sm text-muted-foreground">برچسبی یافت نشد.</p>
)}
</CardContent>
</Card>
{canDelete ? (
<Card>
<CardHeader className="text-right">
<CardTitle>برچسبهای حذفشده</CardTitle>
<CardDescription>بازیابی رکوردهای حذف شده.</CardDescription>
</CardHeader>
<CardContent>
{deletedQuery.data?.length ? (
<div className="flex flex-col gap-3">
{deletedQuery.data.map((tag) => (
<div key={tag.id} className="flex items-center justify-between rounded-2xl border p-3">
<span className="font-medium">{tag.name}</span>
<Button size="sm" variant="outline" onClick={() => void restoreTag(tag)}>
<RotateCcw className="ml-1 h-3.5 w-3.5" />
بازیابی
</Button>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">مورد حذفشدهای وجود ندارد.</p>
)}
</CardContent>
</Card>
) : null}
<Dialog open={dialogOpen} onOpenChange={(open) => !open && closeDialog()}>
<DialogContent dir="rtl" className="text-right">
<DialogHeader>
<DialogTitle>{editing ? "ویرایش برچسب" : "برچسب جدید"}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label className="mb-2 block">نام</Label>
<Input value={form.name} onChange={(event) => setForm((prev) => ({ ...prev, name: event.target.value }))} />
</div>
<div>
<Label className="mb-2 block">اسلاگ اختیاری</Label>
<Input value={form.slug} onChange={(event) => setForm((prev) => ({ ...prev, slug: event.target.value }))} dir="ltr" />
</div>
<div className="flex justify-start gap-2">
<Button variant="outline" onClick={() => closeDialog()} disabled={submitting}>انصراف</Button>
<Button onClick={saveTag} disabled={submitting || !form.name.trim()}>
{submitting ? <Loader2 className="ml-2 h-4 w-4 animate-spin" /> : null}
ذخیره
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -102,7 +102,7 @@ const AdminEventsPage: React.FC = () => {
}, [eventsQuery.data, filters.sort]);
return (
<div className="space-y-6" dir="rtl">
<div className="space-y-6">
<div className="flex flex-col gap-1">
<h2 className="text-xl font-semibold">رویدادها</h2>
<p className="text-sm text-muted-foreground">مدیریت رویدادها، ثبتنامها و وضعیت انتشار</p>

View File

@@ -1,24 +1,53 @@
"use client";
import type { ReactNode } from "react";
import { useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import {
CalendarDays,
FileText,
FolderTree,
PanelRightClose,
PanelRightOpen,
ShieldCheck,
Tags,
UsersRound,
} from "lucide-react";
import { Navigate, NavLink, useLocation } from "@/lib/router";
import { useAuth } from "@/contexts/AuthContext";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
const navItems = [
{ to: "/admin/users", label: "مدیریت کاربران", requiresStaff: true },
{ to: "/admin/events", label: "مدیریت رویدادها", requiresStaff: true },
{ to: "/admin/blog", label: "مدیریت بلاگ", requiresStaff: false },
{ to: "/admin/users", label: "کاربران", icon: UsersRound, visibility: "staff" },
{ to: "/admin/events", label: "رویدادها", icon: CalendarDays, visibility: "staff" },
{ to: "/admin/blog", label: "نوشته‌های بلاگ", icon: FileText, visibility: "blog" },
{ to: "/admin/blog/categories", label: "دسته‌بندی‌ها", icon: FolderTree, visibility: "taxonomy" },
{ to: "/admin/blog/tags", label: "برچسب‌ها", icon: Tags, visibility: "taxonomy" },
{ to: "/admin/authorizations", label: "دسترسی‌ها", icon: ShieldCheck, visibility: "superuser" },
] as const;
export default function AdminLayout({ children }: { children: ReactNode }) {
const location = useLocation();
const { user, isAuthenticated, loading } = useAuth();
const [collapsed, setCollapsed] = useState(false);
const canAccessAdmin = useMemo(
() => isAuthenticated && Boolean(user?.is_staff || user?.is_superuser || user?.can_access_blog_admin),
[isAuthenticated, user?.can_access_blog_admin, user?.is_staff, user?.is_superuser],
);
useEffect(() => {
const saved = window.localStorage.getItem("admin-sidebar-collapsed");
if (saved) setCollapsed(saved === "true");
}, []);
const toggleCollapsed = () => {
setCollapsed((current) => {
const next = !current;
window.localStorage.setItem("admin-sidebar-collapsed", String(next));
return next;
});
};
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center text-muted-foreground" dir="rtl">
@@ -32,39 +61,107 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
}
const visibleNavItems = navItems.filter((item) => {
if (item.requiresStaff) {
return Boolean(user?.is_staff || user?.is_superuser);
}
if (item.visibility === "staff") return Boolean(user?.is_staff || user?.is_superuser);
if (item.visibility === "taxonomy") return Boolean(user?.is_staff || user?.is_superuser || user?.can_review_blog_posts);
if (item.visibility === "superuser") return Boolean(user?.is_superuser);
return Boolean(user?.is_staff || user?.is_superuser || user?.can_access_blog_admin);
});
const isItemActive = (to: string) => {
if (location.pathname === to) return true;
if (to === "/admin/blog") {
return /^\/admin\/blog\/(new|\d+)/.test(location.pathname ?? "");
}
return Boolean(location.pathname?.startsWith(`${to}/`));
};
return (
<div className="min-h-screen bg-background" dir="rtl">
<div className="border-b bg-muted/20">
<div className="container mx-auto flex items-center justify-between px-4 py-4 gap-4 flex-row-reverse md:flex-row">
<h1 className="text-2xl font-bold">پنل مدیریت</h1>
<div className="flex flex-wrap items-center gap-2">
{visibleNavItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
[
"rounded-full px-4 py-2 text-sm transition",
(isActive || location.pathname?.startsWith(item.to))
<div className="min-h-screen bg-muted/15" dir="rtl">
<div className="flex min-h-screen">
<aside
className={cn(
"sticky top-0 hidden h-screen shrink-0 border-l bg-background/95 shadow-sm backdrop-blur transition-[width] duration-300 ease-in-out lg:flex lg:flex-col",
collapsed ? "w-20" : "w-72",
)}
>
<div className="flex items-center justify-between gap-2 border-b p-4">
{!collapsed ? (
<div className="text-right">
<h1 className="text-lg font-bold">پنل مدیریت</h1>
<p className="text-xs text-muted-foreground">انجمن علمی مهندسی کامپیوتر</p>
</div>
) : null}
<Button variant="ghost" size="icon" onClick={toggleCollapsed} aria-label="باز و بسته کردن منوی مدیریت">
{collapsed ? <PanelRightOpen className="h-5 w-5" /> : <PanelRightClose className="h-5 w-5" />}
</Button>
</div>
<nav className="flex-1 space-y-2 p-3">
{visibleNavItems.map((item) => {
const Icon = item.icon;
const active = isItemActive(item.to);
return (
<NavLink
key={item.to}
to={item.to}
title={collapsed ? item.label : undefined}
className={cn(
"flex items-center gap-3 rounded-2xl px-3 py-3 text-sm transition",
collapsed ? "justify-center" : "justify-start",
active
? "bg-primary text-primary-foreground shadow"
: "bg-card text-muted-foreground hover:text-foreground border",
].join(" ")
}
>
{item.label}
</NavLink>
))}
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<Icon className="h-5 w-5 shrink-0" />
{!collapsed ? <span className="font-medium">{item.label}</span> : null}
</NavLink>
);
})}
</nav>
</aside>
<div className="min-w-0 flex-1">
<div className="border-b bg-background/90 lg:hidden">
<div className="px-4 py-3 text-right">
<h1 className="text-lg font-bold">پنل مدیریت</h1>
<p className="text-xs text-muted-foreground">مدیریت بخشهای سامانه</p>
</div>
</div>
<div className="container mx-auto min-w-0 px-3 pb-28 pt-4 sm:px-4 lg:py-6">
{children}
</div>
</div>
</div>
<div className="container mx-auto px-4 py-6">
{children}
<div
className="fixed inset-x-0 z-50 px-4 lg:hidden"
style={{ bottom: "calc(env(safe-area-inset-bottom) + 0.9rem)" }}
>
<nav
aria-label="Admin mobile navigation"
className="mx-auto flex w-full max-w-sm items-center justify-between rounded-[1.75rem] border border-white/20 bg-background/70 px-2 py-2 shadow-[0_18px_60px_rgba(15,23,42,0.18)] backdrop-blur-2xl dark:border-white/10 dark:bg-slate-950/65"
dir="rtl"
>
{visibleNavItems.map((item) => {
const Icon = item.icon;
const active = isItemActive(item.to);
return (
<NavLink
key={item.to}
to={item.to}
className={cn(
"flex min-w-0 flex-1 flex-col items-center justify-center gap-1 rounded-2xl px-2 py-2 text-[10px] font-medium transition-all",
active
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:bg-white/30 hover:text-foreground dark:hover:bg-white/10",
)}
aria-current={active ? "page" : undefined}
>
<Icon className={cn("h-5 w-5", active ? "scale-105" : "")} />
<span className="max-w-full truncate">{item.label}</span>
</NavLink>
);
})}
</nav>
</div>
</div>
);

View File

@@ -91,7 +91,7 @@ const AdminUsersPage: React.FC = () => {
};
return (
<div className="space-y-6" dir="rtl">
<div className="space-y-6">
<div>
<h2 className="text-xl font-semibold">کاربران</h2>
<p className="text-sm text-muted-foreground mt-1">مدیریت و جستجوی کاربران سامانه</p>

View File

@@ -545,6 +545,12 @@ export default function Profile() {
<>
<Card className="overflow-hidden rounded-[2rem] border border-border/70 shadow-lg">
<CardContent className="space-y-6 p-6 text-center">
<div className="flex justify-start">
<Button variant="outline" size="sm" className="rounded-full" onClick={() => setEditing(true)}>
<PencilLine className="ml-2 h-4 w-4" />
ویرایش
</Button>
</div>
<div className="flex justify-center">{renderAvatarControl()}</div>
<div>
<h1 className="text-2xl font-bold">{fullName}</h1>