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 (
+
+ );
+}
diff --git a/components/organisms/reels/ReelFeed.jsx b/components/organisms/reels/ReelFeed.jsx
index da967d9d..d0ff037c 100644
--- a/components/organisms/reels/ReelFeed.jsx
+++ b/components/organisms/reels/ReelFeed.jsx
@@ -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));
diff --git a/lib/actions/reels-action.ts b/lib/actions/reels-action.ts
index a852dad9..24f476b7 100644
--- a/lib/actions/reels-action.ts
+++ b/lib/actions/reels-action.ts
@@ -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 {
@@ -19,13 +21,24 @@ export interface FetchReelsResult {
hasMore: boolean;
}
-export const fetchReels = async ({ page, limit }: FetchReelsParams = {}): Promise => {
+export const fetchReels = async ({ page, limit, includeHidden = false }: FetchReelsParams = {}): Promise => {
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);
@@ -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 => {
+ 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 => { // TODO(types): Single reel detail
try {
const res = await axiosInstance.get(`/api/reels/${reelId}`);