Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions __tests__/reels/reels-action.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
85 changes: 81 additions & 4 deletions app/[locale]/admin/reels/page.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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",
},
{
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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) {
Expand All @@ -207,8 +260,8 @@ export default function AdminReelsManagementPage() {
/>

{notification && (
<div className="mb-4 flex items-center gap-2 rounded-lg border border-emerald-500/30 bg-emerald-500/10 p-4 text-sm font-medium text-emerald-600 dark:text-emerald-400">
<CheckCircle2 className="h-4 w-4 shrink-0" />
<div className={cn("mb-4 flex items-center gap-2 rounded-lg border p-4 text-sm font-medium", notification.type === "error" ? "border-destructive/30 bg-destructive/10 text-destructive" : "border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400")}>
{notification.type === "error" ? <AlertTriangle className="h-4 w-4 shrink-0" /> : <CheckCircle2 className="h-4 w-4 shrink-0" />}
<span>{notification.message}</span>
</div>
)}
Expand Down Expand Up @@ -338,7 +391,7 @@ export default function AdminReelsManagementPage() {
</TableRow>
) : (
filteredReels.map((reel) => (
<TableRow key={reel.id}>
<TableRow key={reel.id} className={reel.isHidden ? "bg-muted/50 opacity-75" : undefined}>
<TableCell>
<div className="flex flex-col">
<span className="font-medium text-foreground">{reel.title}</span>
Expand Down Expand Up @@ -373,7 +426,16 @@ export default function AdminReelsManagementPage() {
)}
</TableCell>
<TableCell>
{reel.status === "active" ? (
{reel.isHidden ? (
<div className="space-y-1">
<Badge variant="outline" className="border-destructive/30 bg-destructive/10 text-destructive gap-1">
<EyeOff className="h-3 w-3" /> Hidden by moderation
</Badge>
<p className="max-w-44 truncate text-xs text-muted-foreground" title={reel.hiddenReasonNote}>
{reel.hiddenReasonNote}
</p>
</div>
) : reel.status === "active" ? (
<Badge variant="outline" className="border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
Active
</Badge>
Expand All @@ -395,6 +457,14 @@ export default function AdminReelsManagementPage() {
>
Creator Controls
</Button>
<Button
size="sm"
variant={reel.isHidden ? "default" : "destructive"}
onClick={() => setVisibilityDialogReel(reel)}
className="ml-2 text-xs"
>
{reel.isHidden ? "Unhide" : "Hide"}
</Button>
</TableCell>
</TableRow>
))
Expand All @@ -413,6 +483,13 @@ export default function AdminReelsManagementPage() {
isCurrentlyPaused={activeCreator?.isPaused || false}
onConfirm={handleBulkCreatorAction}
/>
<ReelVisibilityDialog
open={Boolean(visibilityDialogReel)}
onOpenChange={(open) => !open && setVisibilityDialogReel(null)}
reel={visibilityDialogReel}
onConfirm={handleVisibilityChange}
loading={visibilitySaving}
/>
</PageShell>
);
}
94 changes: 94 additions & 0 deletions components/admin/ReelVisibilityDialog.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{isHidden ? "Restore reel visibility" : "Hide reel from learners"}</DialogTitle>
<DialogDescription>
{isHidden
? `Restore “${reel.title}” to learner-facing feeds.`
: `Hide “${reel.title}” everywhere learners can discover reels. The reel remains available to administrators.`}
</DialogDescription>
</DialogHeader>

{!isHidden && (
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label htmlFor="reel-hide-reason">Reason category</Label>
<Select value={reasonCategory} onValueChange={setReasonCategory}>
<SelectTrigger id="reel-hide-reason"><SelectValue placeholder="Select a reason category" /></SelectTrigger>
<SelectContent>
{REJECTION_REASON_CATEGORIES.map((reason) => (
<SelectItem key={reason.id} value={reason.id}>{reason.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="reel-hide-note">Moderation note</Label>
<Textarea
id="reel-hide-note"
value={reasonNote}
onChange={(event) => setReasonNote(event.target.value)}
placeholder="Explain why this reel is being hidden"
required
/>
</div>
</div>
)}

<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>Cancel</Button>
<Button variant={isHidden ? "default" : "destructive"} onClick={submit} disabled={!canSubmit || loading}>
{isHidden ? "Unhide reel" : "Hide reel"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
6 changes: 5 additions & 1 deletion components/organisms/reels/ReelFeed.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,11 @@ const ReelFeedInner = () => {
try {
const response = await fetchReels({ page: targetPage, limit: 5 });
if (response?.success) {
const incoming = response.reels || [];
// The API normally excludes moderated reels. Keep this guard as a
// defence in depth for cached or older API responses.
const incoming = (response.reels || []).filter(
(reel) => !reel.isHidden && reel.visibility !== "hidden"
);
setReels((prev) => {
if (!append) return incoming;
const existing = new Set(prev.map((item) => item.id));
Expand Down
38 changes: 37 additions & 1 deletion lib/actions/reels-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ const defaultPagination = {
export interface FetchReelsParams {
page?: number;
limit?: number;
/** Admin views include reels hidden by moderation. */
includeHidden?: boolean;
}

export interface FetchReelsResult {
Expand All @@ -19,13 +21,24 @@ export interface FetchReelsResult {
hasMore: boolean;
}

export const fetchReels = async ({ page, limit }: FetchReelsParams = {}): Promise<FetchReelsResult> => {
export const fetchReels = async ({ page, limit, includeHidden = false }: FetchReelsParams = {}): Promise<FetchReelsResult> => {
try {
const params = {
page: page ?? defaultPagination.page,
limit: limit ?? defaultPagination.limit,
...(includeHidden ? { includeHidden: true } : {}),
};
const res = await axiosInstance.get("/api/reels", { params });
// Do not let a stale cache or an older backend response expose moderated
// content on any learner-facing consumer of this shared action.
if (!includeHidden && Array.isArray(res.data?.reels)) {
return {
...res.data,
reels: res.data.reels.filter(
(reel: any) => !reel.isHidden && reel.visibility !== "hidden"
),
};
}
return res.data;
} catch (error) {
console.error("Error fetching reels:", error);
Expand All @@ -40,6 +53,29 @@ export const fetchReels = async ({ page, limit }: FetchReelsParams = {}): Promis
}
};

export interface UpdateReelVisibilityPayload {
hidden: boolean;
/** Required when hiding; uses the shared moderation reason categories. */
reasonCategory?: string;
/** Required when hiding so the moderation decision is auditable. */
reasonNote?: string;
}

/**
* Hides or restores a reel. The backend preserves the prior publication state
* and only removes a hidden reel from learner-facing responses.
*/
export const updateReelVisibility = async (
reelId: string,
payload: UpdateReelVisibilityPayload
): Promise<any> => {
if (payload.hidden && (!payload.reasonCategory || !payload.reasonNote?.trim())) {
throw new Error("A reason category and moderation note are required to hide a reel.");
}
const res = await axiosInstance.patch(`/api/admin/reels/${reelId}/visibility`, payload);
return res.data;
};

export const fetchReelById = async (reelId: string): Promise<any | null> => { // TODO(types): Single reel detail
try {
const res = await axiosInstance.get(`/api/reels/${reelId}`);
Expand Down
Loading