mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-01 18:19:43 +00:00
feat(content): add catalogue review metadata
Add reversible review states, audit history, and super-admin controls for catalogue entities without changing current client visibility. Stage 30 high-risk production packs for archival review, cap generated content at non-explicit language, and preserve a checksummed pre-change production catalogue snapshot.
This commit is contained in:
1 parent
b43804d8fd
commit
94c76535a4
30 files changed
+46906
-128
No files matched your search
@@ -43,6 +43,8 @@ import {
|
||||
rectSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { ContentReviewControl } from '@/components/content/ContentReviewControl';
|
||||
import type { ContentReviewStatus } from '@sauci/shared';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
@@ -56,6 +58,8 @@ interface Category {
|
||||
sort_order: number | null;
|
||||
created_at: string | null;
|
||||
is_public: boolean;
|
||||
content_status?: ContentReviewStatus | null;
|
||||
content_review_reason?: string | null;
|
||||
question_pack_count?: number;
|
||||
dare_pack_count?: number;
|
||||
}
|
||||
@@ -76,9 +80,10 @@ interface SortableCategoryCardProps {
|
||||
onEdit: (category: Category) => void;
|
||||
onDelete: (category: Category) => void;
|
||||
onToggleVisibility: (category: Category) => void;
|
||||
onReviewChanged: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
function SortableCategoryCard({ category, onEdit, onDelete, onToggleVisibility }: SortableCategoryCardProps) {
|
||||
function SortableCategoryCard({ category, onEdit, onDelete, onToggleVisibility, onReviewChanged }: SortableCategoryCardProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
@@ -164,6 +169,16 @@ function SortableCategoryCard({ category, onEdit, onDelete, onToggleVisibility }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ContentReviewControl
|
||||
table="categories"
|
||||
entityId={category.id}
|
||||
entityLabel={`category “${category.name}”`}
|
||||
status={category.content_status}
|
||||
reason={category.content_review_reason}
|
||||
onChanged={onReviewChanged}
|
||||
compact
|
||||
/>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Link to={`/categories/${category.id}/packs`}>
|
||||
<Button variant="outline" className="w-full">
|
||||
@@ -579,6 +594,7 @@ export function CategoriesPage() {
|
||||
onEdit={form.openEdit}
|
||||
onDelete={handleDelete}
|
||||
onToggleVisibility={handleToggleVisibility}
|
||||
onReviewChanged={fetchCategories}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -31,6 +31,8 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { Plus, Zap, Pencil, Trash2, Loader2, Crown, Eye, EyeOff, ChevronUp, ChevronDown } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { ContentReviewControl } from '@/components/content/ContentReviewControl';
|
||||
import type { ContentReviewStatus } from '@sauci/shared';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
@@ -54,6 +56,8 @@ interface DarePack {
|
||||
category_id: string | null;
|
||||
created_at: string | null;
|
||||
dare_count?: number;
|
||||
content_status?: ContentReviewStatus | null;
|
||||
content_review_reason?: string | null;
|
||||
}
|
||||
|
||||
interface DarePackFormData {
|
||||
@@ -511,6 +515,15 @@ export function DarePacksPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<ContentReviewControl
|
||||
table="dare_packs"
|
||||
entityId={pack.id}
|
||||
entityLabel={`dare pack “${pack.name}”`}
|
||||
status={pack.content_status}
|
||||
reason={pack.content_review_reason}
|
||||
onChanged={fetchData}
|
||||
compact
|
||||
/>
|
||||
{pack.is_explicit && (
|
||||
<Badge variant="destructive">
|
||||
NSFW
|
||||
|
||||
@@ -37,6 +37,8 @@ import { Plus, Pencil, Trash2, Zap, Loader2, Clock } from 'lucide-react';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { toast } from 'sonner';
|
||||
import { IconPreview } from '@/components/ui/icon-picker';
|
||||
import { ContentReviewControl } from '@/components/content/ContentReviewControl';
|
||||
import type { ContentReviewStatus } from '@sauci/shared';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
@@ -56,6 +58,8 @@ interface Dare {
|
||||
intensity: number;
|
||||
suggested_duration_hours: number | null;
|
||||
created_at: string | null;
|
||||
content_status?: ContentReviewStatus | null;
|
||||
content_review_reason?: string | null;
|
||||
}
|
||||
|
||||
// Duration options for dares
|
||||
@@ -439,6 +443,7 @@ export function DaresPage() {
|
||||
</TableHead>
|
||||
<TableHead className="w-12">#</TableHead>
|
||||
<TableHead>Dare</TableHead>
|
||||
<TableHead className="w-40">Catalogue</TableHead>
|
||||
<TableHead className="w-24">Duration</TableHead>
|
||||
<TableHead className="w-24">Actions</TableHead>
|
||||
</TableRow>
|
||||
@@ -462,6 +467,17 @@ export function DaresPage() {
|
||||
<TableCell>
|
||||
<span className="line-clamp-2">{dare.text}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<ContentReviewControl
|
||||
table="dares"
|
||||
entityId={dare.id}
|
||||
entityLabel={`dare ${((page - 1) * pageSize) + index + 1}`}
|
||||
status={dare.content_status}
|
||||
reason={dare.content_review_reason}
|
||||
onChanged={fetchData}
|
||||
compact
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{dare.suggested_duration_hours ? (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
|
||||
@@ -14,6 +14,8 @@ import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog';
|
||||
import { ExtractTopicsDialog } from '@/components/content/ExtractTopicsDialog';
|
||||
import { PackFormDialog, type PackFormData } from '@/components/content/PackFormDialog';
|
||||
import { IconPreview } from '@/components/ui/icon-picker';
|
||||
import { ContentReviewControl } from '@/components/content/ContentReviewControl';
|
||||
import type { ContentReviewStatus } from '@sauci/shared';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
@@ -43,6 +45,8 @@ interface QuestionPack {
|
||||
created_at: string | null;
|
||||
scheduled_release_at?: string | null; // Optional for backwards compatibility
|
||||
release_notified?: boolean; // True if notifications have been sent or should be skipped
|
||||
content_status?: ContentReviewStatus | null;
|
||||
content_review_reason?: string | null;
|
||||
question_count?: number;
|
||||
topics?: Topic[];
|
||||
}
|
||||
@@ -513,6 +517,15 @@ export function PacksPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<ContentReviewControl
|
||||
table="question_packs"
|
||||
entityId={pack.id}
|
||||
entityLabel={`pack “${pack.name}”`}
|
||||
status={pack.content_status}
|
||||
reason={pack.content_review_reason}
|
||||
onChanged={fetchData}
|
||||
compact
|
||||
/>
|
||||
{pack.is_explicit && (
|
||||
<Badge variant="destructive">
|
||||
NSFW
|
||||
|
||||
@@ -43,6 +43,8 @@ import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog';
|
||||
import { AIPolishButton } from '@/components/ai/AIPolishButton';
|
||||
import { ReviewQuestionsDialog } from '@/components/content/ReviewQuestionsDialog';
|
||||
import { IconPreview } from '@/components/ui/icon-picker';
|
||||
import { ContentReviewControl } from '@/components/content/ContentReviewControl';
|
||||
import type { ContentReviewStatus } from '@sauci/shared';
|
||||
|
||||
interface QuestionPack {
|
||||
id: string;
|
||||
@@ -70,6 +72,8 @@ interface Question {
|
||||
created_at: string | null;
|
||||
question_type?: QuestionType | null;
|
||||
config?: QuestionConfig | null;
|
||||
content_status?: ContentReviewStatus | null;
|
||||
content_review_reason?: string | null;
|
||||
}
|
||||
|
||||
const DEFAULT_AUDIO_DURATION = 60;
|
||||
@@ -982,6 +986,7 @@ export function QuestionsPage() {
|
||||
</TableHead>
|
||||
<TableHead className="w-12">#</TableHead>
|
||||
<TableHead>Question</TableHead>
|
||||
<TableHead className="w-40">Catalogue</TableHead>
|
||||
<TableHead className="w-28">Type</TableHead>
|
||||
<TableHead className="w-20">Inverse</TableHead>
|
||||
<TableHead className="w-32">Partner Text</TableHead>
|
||||
@@ -1009,6 +1014,17 @@ export function QuestionsPage() {
|
||||
<TableCell>
|
||||
<span className="line-clamp-2">{question.text}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<ContentReviewControl
|
||||
table="questions"
|
||||
entityId={question.id}
|
||||
entityLabel={`question ${((page - 1) * pageSize) + index + 1}`}
|
||||
status={question.content_status}
|
||||
reason={question.content_review_reason}
|
||||
onChanged={fetchData}
|
||||
compact
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<Badge variant="secondary" className="text-[10px] uppercase tracking-wide">
|
||||
|
||||
Reference in new issue
Block a user