From 821b85d4fef6250ee8593129a15cf8f9e5efda75 Mon Sep 17 00:00:00 2001 From: AmirmahdiNourkazemi Date: Wed, 3 Jun 2026 03:19:46 +0330 Subject: [PATCH] fix: upload image --- app/dashboard/comments/page.tsx | 5 +- app/dashboard/media/page.tsx | 160 +++++++++++++++++++------- app/dashboard/music/tracks/page.tsx | 168 +++++++++++++++------------- components/ImagePicker.tsx | 156 ++++++++++++++++++++++++++ docs/API.md | 15 ++- 5 files changed, 373 insertions(+), 131 deletions(-) create mode 100644 components/ImagePicker.tsx diff --git a/app/dashboard/comments/page.tsx b/app/dashboard/comments/page.tsx index 4c90d0d..1d73731 100644 --- a/app/dashboard/comments/page.tsx +++ b/app/dashboard/comments/page.tsx @@ -59,10 +59,11 @@ export default function CommentsPage() { } async function confirmDelete() { - if (!deleting || !query) return; + if (!deleting || !query || deleting.id == null) return; setRemoving(true); try { - await apiFetch(`/comments/${query.type}/${query.id}`, { + // Delete targets the comment's own id: /comments/{type}/{id}/{commentId} + await apiFetch(`/comments/${query.type}/${query.id}/${deleting.id}`, { method: "DELETE", }); toast.success("نظر حذف شد."); diff --git a/app/dashboard/media/page.tsx b/app/dashboard/media/page.tsx index 6874c9a..f96f67a 100644 --- a/app/dashboard/media/page.tsx +++ b/app/dashboard/media/page.tsx @@ -19,6 +19,7 @@ import { import { EditIcon, PlusIcon, TrashIcon } from "@/components/icons"; import { formatDuration } from "@/lib/utils"; import { MediaPreview } from "@/components/MediaPreview"; +import { ImagePicker } from "@/components/ImagePicker"; import { pickUrl, SOUND_KEYS, VIDEO_KEYS, IMAGE_KEYS } from "@/lib/media"; interface Category { @@ -30,18 +31,58 @@ interface Media { id: number; title?: string; caption?: string; - category_id?: number; - category?: { id?: number; name?: string }; + // The API returns a `categories` array (many-to-many). + categories?: Category[]; + subcategories?: Category[]; + subCategories?: Category[]; type?: string; duration?: number; visibility?: string; is_premium?: boolean; + external_url?: string | null; + image_id?: number | null; +} + +// Compact multi-select rendered as toggle chips. +function ChipMultiSelect({ + options, + selected, + onToggle, +}: { + options: Category[]; + selected: number[]; + onToggle: (id: number) => void; +}) { + if (!options.length) + return

موردی برای انتخاب نیست.

; + return ( +
+ {options.map((o) => { + const on = selected.includes(o.id); + return ( + + ); + })} +
+ ); } export default function MediaPage() { const [search, setSearch] = useState(""); const { data, loading, error, reload } = useList("/media", { search }); const { data: categories } = useList("/categories"); + const { data: subCategories } = useList("/sub-categories"); const toast = useToast(); const [editing, setEditing] = useState(null); @@ -50,12 +91,16 @@ export default function MediaPage() { const [title, setTitle] = useState(""); const [caption, setCaption] = useState(""); - const [categoryId, setCategoryId] = useState(""); + const [categoryIds, setCategoryIds] = useState([]); + const [subcategoryIds, setSubcategoryIds] = useState([]); const [type, setType] = useState("audio"); const [duration, setDuration] = useState(""); const [visibility, setVisibility] = useState("public"); const [isPremium, setIsPremium] = useState(false); const [file, setFile] = useState(null); + const [externalUrl, setExternalUrl] = useState(""); + const [imageId, setImageId] = useState(null); + const [imageFile, setImageFile] = useState(null); const [deleting, setDeleting] = useState(null); const [removing, setRemoving] = useState(false); @@ -63,12 +108,16 @@ export default function MediaPage() { function resetForm() { setTitle(""); setCaption(""); - setCategoryId(""); + setCategoryIds([]); + setSubcategoryIds([]); setType("audio"); setDuration(""); setVisibility("public"); setIsPremium(false); setFile(null); + setExternalUrl(""); + setImageId(null); + setImageFile(null); } function openCreate() { @@ -81,21 +130,25 @@ export default function MediaPage() { setEditing(row); setTitle(row.title ?? ""); setCaption(row.caption ?? ""); - setCategoryId( - row.category_id != null - ? String(row.category_id) - : row.category?.id != null - ? String(row.category.id) - : "", + setCategoryIds((row.categories ?? []).map((c) => c.id)); + setSubcategoryIds( + (row.subcategories ?? row.subCategories ?? []).map((c) => c.id), ); setType(row.type ?? "audio"); setDuration(row.duration != null ? String(row.duration) : ""); setVisibility(row.visibility ?? "public"); setIsPremium(!!row.is_premium); setFile(null); + setExternalUrl(row.external_url ?? ""); + setImageId(row.image_id ?? null); + setImageFile(null); setOpen(true); } + function toggleId(list: number[], set: (v: number[]) => void, id: number) { + set(list.includes(id) ? list.filter((x) => x !== id) : [...list, id]); + } + async function save(e: React.FormEvent) { e.preventDefault(); setSaving(true); @@ -103,12 +156,16 @@ export default function MediaPage() { const body = toFormData({ title, caption, - category_id: categoryId, + category_ids: categoryIds, + subcategory_ids: subcategoryIds, type, duration, visibility, is_premium: isPremium, file, + external_url: externalUrl, + image_id: imageId, + image: imageFile, }); if (editing) { // File-bearing update: POST /media/:id (multipart, file optional) @@ -165,9 +222,12 @@ export default function MediaPage() { }, { key: "title", header: "عنوان", render: (r) => r.title ?? "—" }, { - key: "category", + key: "categories", header: "دسته‌بندی", - render: (r) => r.category?.name ?? "—", + render: (r) => + r.categories?.length + ? r.categories.map((c) => c.name).filter(Boolean).join("، ") + : "—", }, { key: "type", @@ -275,19 +335,30 @@ export default function MediaPage() { /> - - + + + + + + toggleId(categoryIds, setCategoryIds, id)} + /> + + + + toggleId(subcategoryIds, setSubcategoryIds, id)} + /> @@ -297,6 +368,26 @@ export default function MediaPage() { + + setFile(e.target.files?.[0] ?? null)} + /> + + + + setExternalUrl(e.target.value)} + placeholder="https://..." + /> + + - - setFile(e.target.files?.[0] ?? null)} - required={!editing} - /> - - (null); + const [imageFile, setImageFile] = useState(null); const [file, setFile] = useState(null); const [deleting, setDeleting] = useState(null); const [removing, setRemoving] = useState(false); - function openCreate() { - setEditing(null); + function resetForm() { setTitle(""); setArtist(""); - setType(""); + setType("public"); setDuration(""); setPlaylistId(""); - setImageId(""); - setOrder(""); + setImageId(null); + setImageFile(null); setFile(null); + } + + function openCreate() { + setEditing(null); + resetForm(); setOpen(true); } function openEdit(row: Track) { setEditing(row); setTitle(row.title ?? ""); setArtist(row.artist ?? ""); - setType(row.type ?? ""); + setType(row.type ?? "public"); setDuration(row.duration != null ? String(row.duration) : ""); setPlaylistId(row.playlist_id != null ? String(row.playlist_id) : ""); - setImageId(""); - setOrder(row.order != null ? String(row.order) : ""); + setImageId(row.image_id ?? null); + setImageFile(null); setFile(null); setOpen(true); } @@ -85,18 +90,21 @@ export default function MusicTracksPage() { setSaving(true); try { if (editing) { - // Update is JSON on /music/:id - await apiFetch(`/music/${editing.id}`, { - method: "PUT", - body: { - title, - artist, - order: order === "" ? undefined : Number(order), - }, + // The PUT /music/:id route can carry files, so we POST multipart with + // Laravel method spoofing (_method=PUT). + const body = toFormData({ + _method: "PUT", + title, + artist, + type, + duration, + image_id: imageId, + image: imageFile, + file, }); + await apiFetch(`/music/${editing.id}`, { method: "POST", body }); toast.success("آهنگ ویرایش شد."); } else { - // Create is multipart on /music await apiFetch("/music", { method: "POST", body: toFormData({ @@ -106,6 +114,7 @@ export default function MusicTracksPage() { duration, playlist_id: playlistId, image_id: imageId, + image: imageFile, file, }), }); @@ -159,7 +168,12 @@ export default function MusicTracksPage() { header: "مدت زمان", render: (r) => formatDuration(r.duration), }, - { key: "type", header: "نوع", render: (r) => r.type ?? "—" }, + { + key: "type", + header: "نوع", + render: (r) => + r.type === "private" ? "خصوصی" : r.type === "public" ? "عمومی" : (r.type ?? "—"), + }, ]; return ( @@ -228,6 +242,7 @@ export default function MusicTracksPage() { autoFocus /> + - {editing ? ( - - setOrder(e.target.value)} - dir="ltr" - /> + + + + + + + + + + setDuration(e.target.value)} + dir="ltr" + /> + + + {!editing && ( + + - ) : ( - <> - - setType(e.target.value)} - placeholder="مثلاً music" - /> - - - setDuration(e.target.value)} - dir="ltr" - /> - - - - - - setImageId(e.target.value)} - dir="ltr" - placeholder="image_id" - /> - - - setFile(e.target.files?.[0] ?? null)} - required - /> - - )} + + + setFile(e.target.files?.[0] ?? null)} + required={!editing} + /> + diff --git a/components/ImagePicker.tsx b/components/ImagePicker.tsx new file mode 100644 index 0000000..e15be17 --- /dev/null +++ b/components/ImagePicker.tsx @@ -0,0 +1,156 @@ +"use client"; + +// Image association control used by media, music, categories, etc. +// Lets the user either (a) pick an existing image from the /images library +// (sets `image_id`) or (b) upload a new image file (sets `image` File). Shows a +// live thumbnail of the current choice. + +import { useEffect, useMemo, useState } from "react"; +import { useList } from "@/lib/useResource"; +import { pickUrl, IMAGE_KEYS } from "@/lib/media"; +import { Button, Modal, Spinner, EmptyState } from "./ui"; +import { ImageIcon, CloseIcon } from "./icons"; + +interface LibImage { + id: number; + title?: string | null; +} + +export function ImagePicker({ + imageId, + onPickId, + file, + onPickFile, + existingUrl, +}: { + imageId: number | null; + onPickId: (id: number | null) => void; + file: File | null; + onPickFile: (f: File | null) => void; + // URL of the already-saved image (edit mode) when no new choice is made. + existingUrl?: string | null; +}) { + const [libOpen, setLibOpen] = useState(false); + const { data: images, loading } = useList( + libOpen ? "/images/all" : null, + ); + + // Object URL preview for a freshly uploaded file. + const [filePreview, setFilePreview] = useState(null); + useEffect(() => { + if (!file) { + setFilePreview(null); + return; + } + const url = URL.createObjectURL(file); + setFilePreview(url); + return () => URL.revokeObjectURL(url); + }, [file]); + + // Resolve the thumbnail for the currently chosen library image (if loaded). + const pickedLibUrl = useMemo(() => { + if (!imageId || !images.length) return null; + const found = images.find((i) => i.id === imageId); + return found ? pickUrl(found, IMAGE_KEYS) : null; + }, [imageId, images]); + + const preview = filePreview ?? pickedLibUrl ?? existingUrl ?? null; + + function clearAll() { + onPickId(null); + onPickFile(null); + } + + return ( +
+
+ {preview ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( + + )} +
+ +
+ + + + + {(preview || imageId || file) && ( + + )} +
+ + setLibOpen(false)} + title="انتخاب تصویر از کتابخانه" + > + {loading ? ( +
+ +
+ ) : !images.length ? ( + + ) : ( +
+ {images.map((img) => { + const url = pickUrl(img, IMAGE_KEYS); + const selected = img.id === imageId; + return ( + + ); + })} +
+ )} +
+
+ ); +} diff --git a/docs/API.md b/docs/API.md index 4b182ef..ee9ad2d 100644 --- a/docs/API.md +++ b/docs/API.md @@ -17,12 +17,11 @@ All meditation calls send `Authorization: Bearer ` and `Accept: ## Endpoints (path is after the meditation base `/api`) -### Media -- GET `/media` — list. query: `categories`, `tags`, `search` -- GET `/media/filters` -- GET `/media/:id` -- POST `/media` — multipart: title, caption, category_id, subcategory_id, file, duration, visibility, type -- POST `/media/:id` — update (multipart: is_premium; json: subcategory_ids[]) +### Media (verified against MediaController) +- GET `/media` — list (returns `categories[]`, nested `image`). query: `categories`, `subcategories`, `durations`, `search` +- GET `/media/filters`, `/media/:id` +- POST `/media` — multipart: title, caption, type(audio|video), `category_ids[]`, `subcategory_ids[]`, `image_id` or `image`(file), file(mp3/wav/mp4/mov) **or** `external_url`, duration, is_premium, visibility, tags[] +- POST `/media/:id` — update (same multipart fields). Playable URL is in **`external_url`**; thumbnail in nested **`image.url`**. - GET `/media/popular`, `/media/recently-played`, `/media/saved` - POST `/media/:id/play`, `/media/:id/save`, `/media/:id/note`, `/media/:id/feedback` (stars, content) - Categories: GET/POST/PUT/DELETE `/categories` (multipart name) @@ -31,7 +30,7 @@ All meditation calls send `Authorization: Bearer ` and `Accept: ### Track (music) - Categories: GET `/music-categories/:id`, POST `/music-categories`, PUT/DELETE `/music-categories/:id` (name, description, order, image_id, is_active) - Sub-categories: GET `/music-subcategories`, POST `/music-subcategories`, PUT/DELETE `/music-subcategories/:id` -- Music: GET `/music/:id`, POST `/music` (title, artist, file, playlist_id, type, duration, image_id), PUT/DELETE `/music/:id` +- Music: GET `/music` (list, `{data:[]}`), GET `/music/:id`, POST `/music` (multipart: title, artist, file, type(public|private), `image_id` or `image`(file), playlist_id, duration), PUT/DELETE `/music/:id`. PUT with a file → POST + `_method=PUT`. Audio URL in `url`, cover in `image_url`. - POST `/music/update-order`, GET `/music/playlist/:id`, POST `/music/:musicId/add-to-playlist`, DELETE `/music/:musicId/remove-from-playlist` - Playlists: GET `/music-playlists/:id?`, POST `/music-playlists`, PUT/DELETE `/music-playlists/:id` (name, description, category_ids[], subcategory_ids[]) @@ -71,7 +70,7 @@ All meditation calls send `Authorization: Bearer ` and `Accept: - GET `/worries`, POST `/worries` (title, note), PUT/DELETE `/worries/:id`, PATCH `/worries/:id/toggle` ### Comments / Likes / Saves / Ratings (by type+id, e.g. type=media|music|playlist) -- Comments: GET/POST/DELETE `/comments/:type/:id` (content) +- Comments: GET/POST `/comments/:type/:id` (content); PUT/DELETE `/comments/:type/:id/:commentId` - Likes: POST `/likes/toggle`, `/likes/like` (type, id), GET `/likes/my-liked?type=` - Saves: POST `/saves/toggle`, `/saves/save`, `/saves/unsave`, `/saves/check` (type, id), GET `/saves/my-saved?type=` - Ratings: GET/POST/DELETE `/ratings/:type/:id` (stars), GET `/ratings/:type/:id/user`