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
+
- {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) && (
+ }
+ onClick={clearAll}
+ >
+ حذف
+
+ )}
+
+
+
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`