mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Add drag-and-drop reordering to categories page
- Replace up/down chevron buttons with @dnd-kit drag handles - Update pack generation and question review commands - Fix MCP bridge and add supabase package.json for Docker Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2e05105bb0
commit
3009e80dd4
13 files changed
+1125
-1281
No files matched your search
@@ -10,6 +10,9 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
"@radix-ui/react-avatar": "^1.0.4",
|
||||
|
||||
@@ -36,7 +36,7 @@ ${toneInstruction}
|
||||
</content_type>
|
||||
|
||||
<deletion_categories>
|
||||
- duplicate: same core action as another question (near-duplicate)
|
||||
- duplicate: same core action AND same initiator as another question (near-duplicate). Note: swapped perspectives (e.g., "give" vs "receive") are NOT duplicates.
|
||||
- redundant: minor variation with no added value
|
||||
- off-tone: violates explicit vs non-explicit rules
|
||||
- unsafe: coercion, minors, illegal acts, or consent violations
|
||||
@@ -50,6 +50,7 @@ ${toneInstruction}
|
||||
2. Duplicates: delete the weaker/less specific version and keep the clearer one.
|
||||
3. If duplicate, set duplicate_of_id to the kept question's id.
|
||||
4. Do NOT delete for minor wording issues or intensity mismatches.
|
||||
5. IMPORTANT: Asymmetric versions of the same activity are NOT duplicates. If one question has text="Do X to your partner" and another has text="Let your partner do X to you", these are DIFFERENT questions because they have different initiators. Only flag as duplicate if both questions have the SAME initiator doing the SAME action.
|
||||
</rules>
|
||||
|
||||
<questions_to_analyze>
|
||||
|
||||
@@ -22,10 +22,27 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Plus, Package, Pencil, Trash2, Sparkles, Loader2, ChevronUp, ChevronDown } from 'lucide-react';
|
||||
import { Plus, Package, Pencil, Trash2, Sparkles, Loader2, GripVertical } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog';
|
||||
import { AIPolishButton } from '@/components/ai/AIPolishButton';
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragEndEvent,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
rectSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
@@ -49,6 +66,108 @@ interface CategoryFormData {
|
||||
is_public: boolean;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Sortable Card Component
|
||||
// =============================================================================
|
||||
|
||||
interface SortableCategoryCardProps {
|
||||
category: Category;
|
||||
onEdit: (category: Category) => void;
|
||||
onDelete: (category: Category) => void;
|
||||
onToggleVisibility: (category: Category) => void;
|
||||
}
|
||||
|
||||
function SortableCategoryCard({ category, onEdit, onDelete, onToggleVisibility }: SortableCategoryCardProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: category.id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
zIndex: isDragging ? 1000 : 'auto',
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className="group flex flex-col hover:border-primary/50 transition-colors"
|
||||
>
|
||||
<CardHeader className="flex-row items-start justify-between space-y-0 pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="cursor-grab active:cursor-grabbing p-1 -ml-1 rounded hover:bg-muted opacity-0 group-hover:opacity-100 transition-opacity touch-none"
|
||||
aria-label="Drag to reorder"
|
||||
>
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
<div className="rounded-lg bg-primary/10 p-2.5">
|
||||
<IconPreview value={category.icon} className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
{category.is_public ? 'Public' : 'Hidden'}
|
||||
</span>
|
||||
<Switch
|
||||
checked={category.is_public}
|
||||
onCheckedChange={() => onToggleVisibility(category)}
|
||||
className="scale-75 origin-right"
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col gap-4">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-lg">{category.name}</CardTitle>
|
||||
<CardDescription className="line-clamp-2 h-10">
|
||||
{category.description || 'No description'}
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mt-auto">
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
{category.pack_count} pack{category.pack_count !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => onEdit(category)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => onDelete(category)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link to={`/categories/${category.id}/packs`}>
|
||||
<Button variant="outline" className="w-full">
|
||||
View Packs
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
@@ -202,39 +321,6 @@ export function CategoriesPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleMove = async (category: Category, direction: 'up' | 'down') => {
|
||||
const currentIndex = categories.findIndex(c => c.id === category.id);
|
||||
const targetIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
|
||||
|
||||
if (targetIndex < 0 || targetIndex >= categories.length) return;
|
||||
|
||||
const targetCategory = categories[targetIndex];
|
||||
|
||||
try {
|
||||
const currentOrder = category.sort_order ?? currentIndex;
|
||||
const targetOrder = targetCategory.sort_order ?? targetIndex;
|
||||
|
||||
const [currentUpdate, targetUpdate] = await Promise.all([
|
||||
auditedSupabase.update('categories', category.id, { sort_order: targetOrder }),
|
||||
auditedSupabase.update('categories', targetCategory.id, { sort_order: currentOrder }),
|
||||
]);
|
||||
|
||||
if (currentUpdate.error || targetUpdate.error) {
|
||||
throw currentUpdate.error ?? targetUpdate.error ?? new Error('Failed to update category order');
|
||||
}
|
||||
|
||||
// Optimistic update
|
||||
const newCategories = [...categories];
|
||||
newCategories[currentIndex] = { ...targetCategory, sort_order: currentOrder };
|
||||
newCategories[targetIndex] = { ...category, sort_order: targetOrder };
|
||||
setCategories(newCategories);
|
||||
} catch (error) {
|
||||
toast.error('Failed to reorder categories');
|
||||
console.error(error);
|
||||
fetchCategories();
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleVisibility = async (category: Category) => {
|
||||
try {
|
||||
// Optimistic update
|
||||
@@ -256,6 +342,52 @@ export function CategoriesPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Drag and Drop
|
||||
// =============================================================================
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
|
||||
const handleDragEnd = async (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
const oldIndex = categories.findIndex(c => c.id === active.id);
|
||||
const newIndex = categories.findIndex(c => c.id === over.id);
|
||||
|
||||
// Optimistic UI update
|
||||
const newCategories = arrayMove(categories, oldIndex, newIndex);
|
||||
setCategories(newCategories);
|
||||
|
||||
// Persist new order to database
|
||||
try {
|
||||
const updates = newCategories.map((category, index) =>
|
||||
auditedSupabase.update('categories', category.id, { sort_order: index })
|
||||
);
|
||||
|
||||
const results = await Promise.all(updates);
|
||||
const hasError = results.some(r => r.error);
|
||||
|
||||
if (hasError) {
|
||||
throw new Error('Failed to update some categories');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to save new order');
|
||||
console.error(error);
|
||||
fetchCategories(); // Revert on error
|
||||
}
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Render
|
||||
// =============================================================================
|
||||
@@ -403,86 +535,28 @@ export function CategoriesPage() {
|
||||
</Button>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{categories.map((category, index) => (
|
||||
<Card key={category.id} className="group flex flex-col hover:border-primary/50 transition-colors">
|
||||
<CardHeader className="flex-row items-start justify-between space-y-0 pb-2">
|
||||
<div className="rounded-lg bg-primary/10 p-2.5">
|
||||
<IconPreview value={category.icon} className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
{category.is_public ? 'Public' : 'Hidden'}
|
||||
</span>
|
||||
<Switch
|
||||
checked={category.is_public}
|
||||
onCheckedChange={() => handleToggleVisibility(category)}
|
||||
className="scale-75 origin-right"
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col gap-4">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-lg">{category.name}</CardTitle>
|
||||
<CardDescription className="line-clamp-2 h-10">
|
||||
{category.description || 'No description'}
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mt-auto">
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
{category.pack_count} pack{category.pack_count !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<div className="flex flex-col gap-0.5 mr-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 rounded-full"
|
||||
onClick={() => handleMove(category, 'up')}
|
||||
disabled={index === 0}
|
||||
>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 rounded-full"
|
||||
onClick={() => handleMove(category, 'down')}
|
||||
disabled={index === categories.length - 1}
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => form.openEdit(category)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => handleDelete(category)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link to={`/categories/${category.id}/packs`}>
|
||||
<Button variant="outline" className="w-full">
|
||||
View Packs
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={categories.map(c => c.id)}
|
||||
strategy={rectSortingStrategy}
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{categories.map((category) => (
|
||||
<SortableCategoryCard
|
||||
key={category.id}
|
||||
category={category}
|
||||
onEdit={form.openEdit}
|
||||
onDelete={handleDelete}
|
||||
onToggleVisibility={handleToggleVisibility}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
|
||||
{categories.length > 0 && (
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface Question {
|
||||
intensity: number;
|
||||
pack_id: string;
|
||||
created_at: string | null;
|
||||
deleted_at: string | null;
|
||||
}
|
||||
|
||||
export interface QuestionFormData {
|
||||
@@ -25,15 +26,21 @@ export interface QuestionFormData {
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Fetch all questions for a pack
|
||||
* Fetch all questions for a pack (excludes soft-deleted by default)
|
||||
*/
|
||||
export async function fetchQuestionsForPack(packId: string): Promise<Question[]> {
|
||||
const { data, error } = await supabase
|
||||
export async function fetchQuestionsForPack(packId: string, includeDeleted = false): Promise<Question[]> {
|
||||
let query = supabase
|
||||
.from('questions')
|
||||
.select('*')
|
||||
.eq('pack_id', packId)
|
||||
.order('created_at', { ascending: true });
|
||||
|
||||
if (!includeDeleted) {
|
||||
query = query.is('deleted_at', null);
|
||||
}
|
||||
|
||||
const { data, error } = await query;
|
||||
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
}
|
||||
@@ -53,13 +60,14 @@ export async function fetchQuestionById(id: string): Promise<Question | null> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch question count for a pack
|
||||
* Fetch question count for a pack (excludes soft-deleted)
|
||||
*/
|
||||
export async function fetchQuestionCount(packId: string): Promise<number> {
|
||||
const { count, error } = await supabase
|
||||
.from('questions')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('pack_id', packId);
|
||||
.eq('pack_id', packId)
|
||||
.is('deleted_at', null);
|
||||
|
||||
if (error) throw error;
|
||||
return count || 0;
|
||||
@@ -105,22 +113,36 @@ export async function updateQuestion(
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a question
|
||||
* Soft delete a question (sets deleted_at timestamp)
|
||||
*/
|
||||
export async function deleteQuestion(id: string): Promise<void> {
|
||||
const { error } = await auditedSupabase.delete('questions', id);
|
||||
const { error } = await auditedSupabase.update('questions', id, {
|
||||
deleted_at: new Date().toISOString(),
|
||||
});
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete multiple questions
|
||||
* Soft delete multiple questions
|
||||
*/
|
||||
export async function deleteQuestions(ids: string[]): Promise<void> {
|
||||
await Promise.all(
|
||||
ids.map(id => auditedSupabase.delete('questions', id))
|
||||
ids.map(id => auditedSupabase.update('questions', id, {
|
||||
deleted_at: new Date().toISOString(),
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a soft-deleted question
|
||||
*/
|
||||
export async function restoreQuestion(id: string): Promise<void> {
|
||||
const { error } = await auditedSupabase.update('questions', id, {
|
||||
deleted_at: null,
|
||||
});
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk create questions (for AI generation)
|
||||
*/
|
||||
|
||||
Reference in new issue
Block a user