diff --git a/__tests__/reels/reels-action.test.ts b/__tests__/reels/reels-action.test.ts new file mode 100644 index 00000000..0b662709 --- /dev/null +++ b/__tests__/reels/reels-action.test.ts @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const getMock = vi.hoisted(() => vi.fn()); +const patchMock = vi.hoisted(() => vi.fn()); + +vi.mock("@/lib/config/axios.config", () => ({ + default: { get: getMock, patch: patchMock }, +})); + +import { fetchReels, updateReelVisibility } from "@/lib/actions/reels-action"; + +describe("reel visibility moderation", () => { + beforeEach(() => vi.clearAllMocks()); + + it("excludes hidden reels from the default learner-facing feed", async () => { + getMock.mockResolvedValue({ + data: { + success: true, + reels: [{ id: "visible" }, { id: "hidden", isHidden: true }], + }, + }); + + const result = await fetchReels(); + + expect(result.reels).toEqual([{ id: "visible" }]); + expect(getMock).toHaveBeenCalledWith("/api/reels", { + params: { page: 1, limit: 10 }, + }); + }); + + it("allows an admin consumer to request hidden reels", async () => { + getMock.mockResolvedValue({ data: { success: true, reels: [{ id: "hidden", isHidden: true }] } }); + + const result = await fetchReels({ includeHidden: true }); + + expect(result.reels).toHaveLength(1); + expect(getMock).toHaveBeenCalledWith("/api/reels", { + params: { page: 1, limit: 10, includeHidden: true }, + }); + }); + + it("sends the reason category and note when hiding a reel", async () => { + patchMock.mockResolvedValue({ data: { success: true } }); + + await updateReelVisibility("reel-101", { + hidden: true, + reasonCategory: "policy_violation", + reasonNote: "Repeatedly violates the community rules.", + }); + + expect(patchMock).toHaveBeenCalledWith("/api/admin/reels/reel-101/visibility", { + hidden: true, + reasonCategory: "policy_violation", + reasonNote: "Repeatedly violates the community rules.", + }); + }); + + it("requires a category and note before hiding a reel", async () => { + await expect(updateReelVisibility("reel-101", { hidden: true })).rejects.toThrow( + "reason category and moderation note" + ); + expect(patchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/app/[locale]/admin/reels/page.jsx b/app/[locale]/admin/reels/page.jsx index 8626ca89..1b0cf002 100644 --- a/app/[locale]/admin/reels/page.jsx +++ b/app/[locale]/admin/reels/page.jsx @@ -43,8 +43,11 @@ import { AlertTriangle, CheckCircle2, Filter, + EyeOff, } from "lucide-react"; import { CreatorReelsControlDialog } from "@/components/admin/CreatorReelsControlDialog"; +import { ReelVisibilityDialog } from "@/components/admin/ReelVisibilityDialog"; +import { updateReelVisibility } from "@/lib/actions/reels-action"; import { cn } from "@/lib/utils"; const SEED_CREATORS = [ @@ -107,6 +110,9 @@ const SEED_REELS = [ views: 1200, flagsCount: 4, status: "paused", + isHidden: true, + hiddenReasonCategory: "policy_violation", + hiddenReasonNote: "Pending moderation review after multiple reports.", createdAt: "2026-02-10T14:00:00Z", }, { @@ -131,6 +137,8 @@ export default function AdminReelsManagementPage() { const [selectedCreatorId, setSelectedCreatorId] = useState(creatorQuery || "all"); const [searchQuery, setSearchQuery] = useState(""); const [dialogOpen, setDialogOpen] = useState(false); + const [visibilityDialogReel, setVisibilityDialogReel] = useState(null); + const [visibilitySaving, setVisibilitySaving] = useState(false); const [notification, setNotification] = useState(null); useEffect(() => { @@ -182,6 +190,51 @@ export default function AdminReelsManagementPage() { setTimeout(() => setNotification(null), 5000); }; + const handleVisibilityChange = async ({ hidden, reasonCategory, reasonNote }) => { + const reel = visibilityDialogReel; + if (!reel || visibilitySaving) return; + + const previousReel = reel; + const optimisticReel = { + ...reel, + isHidden: hidden, + visibility: hidden ? "hidden" : "visible", + ...(hidden + ? { hiddenReasonCategory: reasonCategory, hiddenReasonNote: reasonNote } + : { hiddenReasonCategory: null, hiddenReasonNote: null }), + }; + + setVisibilitySaving(true); + setReels((previous) => previous.map((item) => item.id === reel.id ? optimisticReel : item)); + setVisibilityDialogReel(optimisticReel); + + try { + const response = await updateReelVisibility(reel.id, { + hidden, + reasonCategory, + reasonNote, + }); + if (response?.success === false) throw new Error(response.message || "Unable to update reel visibility."); + + setNotification({ + type: "success", + message: hidden ? "Reel hidden from learner-facing feeds." : "Reel restored to learner-facing feeds.", + }); + setVisibilityDialogReel(null); + setTimeout(() => setNotification(null), 5000); + } catch (error) { + setReels((previous) => previous.map((item) => item.id === reel.id ? previousReel : item)); + setVisibilityDialogReel(previousReel); + setNotification({ + type: "error", + message: error?.message || "Could not update reel visibility. Your change was reverted.", + }); + setTimeout(() => setNotification(null), 5000); + } finally { + setVisibilitySaving(false); + } + }; + const filteredReels = useMemo(() => { return reels.filter((r) => { if (selectedCreatorId !== "all" && r.creatorId !== selectedCreatorId) { @@ -207,8 +260,8 @@ export default function AdminReelsManagementPage() { /> {notification && ( -
- +
+ {notification.type === "error" ? : } {notification.message}
)} @@ -338,7 +391,7 @@ export default function AdminReelsManagementPage() { ) : ( filteredReels.map((reel) => ( - +
{reel.title} @@ -373,7 +426,16 @@ export default function AdminReelsManagementPage() { )} - {reel.status === "active" ? ( + {reel.isHidden ? ( +
+ + Hidden by moderation + +

+ {reel.hiddenReasonNote} +

+
+ ) : reel.status === "active" ? ( Active @@ -395,6 +457,14 @@ export default function AdminReelsManagementPage() { > Creator Controls +
)) @@ -413,6 +483,13 @@ export default function AdminReelsManagementPage() { isCurrentlyPaused={activeCreator?.isPaused || false} onConfirm={handleBulkCreatorAction} /> + !open && setVisibilityDialogReel(null)} + reel={visibilityDialogReel} + onConfirm={handleVisibilityChange} + loading={visibilitySaving} + /> ); } diff --git a/components/admin/ReelVisibilityDialog.jsx b/components/admin/ReelVisibilityDialog.jsx new file mode 100644 index 00000000..02b5e135 --- /dev/null +++ b/components/admin/ReelVisibilityDialog.jsx @@ -0,0 +1,94 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { REJECTION_REASON_CATEGORIES } from "@/lib/actions/admin-verifications"; + +export function ReelVisibilityDialog({ open, onOpenChange, reel, onConfirm, loading }) { + const isHidden = Boolean(reel?.isHidden); + const [reasonCategory, setReasonCategory] = useState(""); + const [reasonNote, setReasonNote] = useState(""); + + useEffect(() => { + if (open) { + setReasonCategory(""); + setReasonNote(""); + } + }, [open, reel?.id]); + + if (!reel) return null; + + const canSubmit = isHidden || (reasonCategory && reasonNote.trim()); + const submit = async () => { + if (!canSubmit) return; + await onConfirm({ + hidden: !isHidden, + ...(isHidden ? {} : { reasonCategory, reasonNote: reasonNote.trim() }), + }); + }; + + return ( + + + + {isHidden ? "Restore reel visibility" : "Hide reel from learners"} + + {isHidden + ? `Restore “${reel.title}” to learner-facing feeds.` + : `Hide “${reel.title}” everywhere learners can discover reels. The reel remains available to administrators.`} + + + + {!isHidden && ( +
+
+ + +
+
+ +