From eb28a00abdf13b5b3bf855960663d22794ab91c2 Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Fri, 12 Jun 2026 21:35:29 +0330 Subject: [PATCH] feat(blog): add queued asset uploads --- src/lib/api.ts | 46 +++++ src/views/AdminBlogAssets.tsx | 339 +++++++++++++++++++++++++++------- 2 files changed, 321 insertions(+), 64 deletions(-) diff --git a/src/lib/api.ts b/src/lib/api.ts index c14a4d8..b585cae 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -562,6 +562,52 @@ class ApiClient { return response.json() as Promise; } + 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((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(`/api/blog/admin/posts/${postId}/assets/${assetId}`, { method: 'DELETE', diff --git a/src/views/AdminBlogAssets.tsx b/src/views/AdminBlogAssets.tsx index 099a73f..a828710 100644 --- a/src/views/AdminBlogAssets.tsx +++ b/src/views/AdminBlogAssets.tsx @@ -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 = { + queued: "در صف", + uploading: "در حال آپلود", + uploaded: "آپلود شد", + failed: "ناموفق", +}; + export default function AdminBlogAssets({ postId }: Props) { const router = useRouter(); const { toast } = useToast(); const fileInputRef = useRef(null); + const processingRef = useRef(false); const [post, setPost] = useState(null); const [assets, setAssets] = useState([]); + const [queue, setQueue] = useState([]); const [loading, setLoading] = useState(true); - const [uploading, setUploading] = useState(false); const [deletingId, setDeletingId] = useState(null); + const [previewAsset, setPreviewAsset] = useState(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) => { - 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 (
@@ -111,7 +230,7 @@ export default function AdminBlogAssets({ postId }: Props) { return (
-
+

مرکز آپلود نوشته

@@ -124,56 +243,129 @@ export default function AdminBlogAssets({ postId }: Props) {

- - - آپلود فایل - تصاویر، ویدئوها، اسناد و فایل‌های فشرده مجاز هستند. لینک مارک‌داون هر فایل بعد از آپلود قابل کپی است. - - - - + + + + - {assets.length ? ( -
- {assets.map((asset) => ( -
-
-
-
- {asset.file_type} -

{asset.title}

-
-

- {asset.mime_type || "file"} · {Math.ceil(asset.size / 1024)} KB -

-
-
- + {queue.length ? ( +
+
+ + + {queueSummary.total} فایل · {queueSummary.uploaded} موفق · {queueSummary.failed} ناموفق + +
+ {queue.map((item) => { + const kind = fileKindFromFile(item.file); + const Icon = iconForKind(kind); + return ( +
+
+
+

{item.file.name}

+

{formatSize(item.file.size)}

+
+ + {statusLabel[item.status]} + +
+ +
+
+
+ {item.status === "uploaded" ? : null} + {item.status === "failed" ? : null} + +
+ {item.error ?

{item.error}

: null} +
+ ); + })} +
+ ) : null} + + {assets.length ? ( +
+ {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 ( +
+ +
+
+ + {asset.absolute_file_url ? ( + + ) : null} + +
+
+
+ {asset.file_type} +

{asset.title}

+
+

+ {asset.mime_type || "file"} · {formatSize(asset.size)} +

+
- {asset.absolute_preview_url ? ( - {asset.alt_text - ) : asset.absolute_file_url ? ( - - {asset.absolute_file_url} - - ) : null} -
- ))} + ); + })}
) : (
@@ -182,6 +374,25 @@ export default function AdminBlogAssets({ postId }: Props) { )} + + !open && setPreviewAsset(null)}> + + + {previewAsset?.title} + {previewAsset?.caption || previewAsset?.mime_type || "پیش‌نمایش فایل"} + + {previewAsset?.file_type === "image" ? ( + {previewAsset.alt_text + ) : null} + {previewAsset?.file_type === "video" && previewAsset.absolute_file_url ? ( + +
); }