mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-01 18:19:43 +00:00
feat(admin): add NSFW flag to packs, remove intensity
Re-add is_explicit toggle on pack and dare pack forms labeled as "NSFW". Show NSFW badge on pack cards and user enabled packs. Remove all intensity/comfort level references from admin UI including form fields, analytics charts, review dialog columns, AI generation prompts/scoring, cherry-pick balancing, and settings toggles. The database columns remain unchanged.
This commit is contained in:
1 parent
14b0199625
commit
e380c0a31c
32 files changed
+948
-1075
No files matched your search
@@ -79,7 +79,6 @@ export function AiSettingsPage() {
|
||||
council_reviewer_model: config.council_reviewer_model || '',
|
||||
council_reviewer_temperature: config.council_reviewer_temperature,
|
||||
council_selection_mode: config.council_selection_mode || 'whole_set',
|
||||
cherry_pick_ensure_intensity_distribution: config.cherry_pick_ensure_intensity_distribution ?? true,
|
||||
classifier_enabled: config.classifier_enabled ?? true,
|
||||
classifier_model: config.classifier_model || 'openai/gpt-4o',
|
||||
classifier_temperature: config.classifier_temperature,
|
||||
@@ -183,7 +182,6 @@ export function AiSettingsPage() {
|
||||
council_reviewer_model: config.council_reviewer_model || '',
|
||||
council_reviewer_temperature: config.council_reviewer_temperature,
|
||||
council_selection_mode: config.council_selection_mode || 'whole_set',
|
||||
cherry_pick_ensure_intensity_distribution: config.cherry_pick_ensure_intensity_distribution ?? true,
|
||||
classifier_enabled: config.classifier_enabled ?? true,
|
||||
classifier_model: config.classifier_model || 'openai/gpt-4o',
|
||||
classifier_temperature: config.classifier_temperature,
|
||||
@@ -851,24 +849,6 @@ export function AiSettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cherry-pick intensity option */}
|
||||
{formData.council_selection_mode === 'cherry_pick' && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm">Ensure Intensity Variety</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Balance across intensity levels 1-5
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={formData.cherry_pick_ensure_intensity_distribution ?? true}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
handleChange('cherry_pick_ensure_intensity_distribution', checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Uses {generators.length + 1} API calls ({generators.length} generator{generators.length > 1 ? 's' : ''} + reviewer)
|
||||
</p>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useAuth, PERMISSION_KEYS } from '@/contexts/AuthContext';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
@@ -30,7 +29,6 @@ export function AppSettingsPage() {
|
||||
setFormData({
|
||||
answer_gap_threshold: config.answer_gap_threshold ?? 10,
|
||||
daily_response_limit: config.daily_response_limit ?? 0,
|
||||
couple_intensity_gate_enabled: config.couple_intensity_gate_enabled ?? false,
|
||||
});
|
||||
setHasChanges(false);
|
||||
}
|
||||
@@ -63,7 +61,6 @@ export function AppSettingsPage() {
|
||||
setFormData({
|
||||
answer_gap_threshold: config.answer_gap_threshold ?? 10,
|
||||
daily_response_limit: config.daily_response_limit ?? 0,
|
||||
couple_intensity_gate_enabled: config.couple_intensity_gate_enabled ?? false,
|
||||
});
|
||||
setHasChanges(false);
|
||||
}
|
||||
@@ -160,19 +157,6 @@ export function AppSettingsPage() {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Couple Comfort Zone Gate</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
When enabled, both recommended questions and answer-gap blocking use the lower of the two partners' comfort zones.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={Boolean(formData.couple_intensity_gate_enabled)}
|
||||
onCheckedChange={(checked) => handleChange('couple_intensity_gate_enabled', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="threshold">Answer Gap Threshold</Label>
|
||||
<Input
|
||||
|
||||
@@ -97,10 +97,10 @@ export function AuditLogsPage() {
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-40">Time</TableHead>
|
||||
<TableHead className="w-32">Admin</TableHead>
|
||||
<TableHead className="w-24">Action</TableHead>
|
||||
<TableHead>Table</TableHead>
|
||||
<TableHead>Changes</TableHead>
|
||||
<TableHead className="hidden md:table-cell w-32">Admin</TableHead>
|
||||
<TableHead className="hidden md:table-cell w-24">Action</TableHead>
|
||||
<TableHead className="hidden lg:table-cell">Table</TableHead>
|
||||
<TableHead className="hidden lg:table-cell">Changes</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -111,47 +111,63 @@ export function AuditLogsPage() {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
logs.map((log) => (
|
||||
<TableRow key={log.id}>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{format(new Date(log.created_at), 'MMM d, h:mm a')}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm font-medium">{log.admin_username || 'Unknown'}</div>
|
||||
<div className="text-xs text-muted-foreground capitalize">{log.admin_role?.replace('_', ' ')}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={`${actionColors[log.action] || 'bg-gray-500'} text-white`}>
|
||||
{log.action}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">
|
||||
{log.table_name}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-md">
|
||||
{log.action === 'DELETE' && log.old_values && (
|
||||
<code className="text-xs bg-red-500/20 text-red-300 px-2 py-1 rounded">
|
||||
Deleted: {JSON.stringify(log.old_values).substring(0, 100)}...
|
||||
</code>
|
||||
)}
|
||||
{log.action === 'INSERT' && log.new_values && (
|
||||
<code className="text-xs bg-green-500/20 text-green-300 px-2 py-1 rounded">
|
||||
Created: {JSON.stringify(log.new_values).substring(0, 100)}...
|
||||
</code>
|
||||
)}
|
||||
{log.action === 'UPDATE' && log.changed_fields && (
|
||||
<code className="text-xs bg-blue-500/20 text-blue-300 px-2 py-1 rounded">
|
||||
Changed: {log.changed_fields.join(', ')}
|
||||
</code>
|
||||
)}
|
||||
{log.action === 'UPDATE' && !log.changed_fields && (
|
||||
<code className="text-xs bg-blue-500/20 text-blue-300 px-2 py-1 rounded">
|
||||
Updated record
|
||||
</code>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
logs.map((log) => {
|
||||
const actionBadge = (
|
||||
<Badge className={`${actionColors[log.action] || 'bg-gray-500'} text-white`}>
|
||||
{log.action}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
const changesContent = log.action === 'DELETE' && log.old_values ? (
|
||||
<code className="text-xs bg-red-500/20 text-red-300 px-2 py-1 rounded">
|
||||
Deleted: {JSON.stringify(log.old_values).substring(0, 100)}...
|
||||
</code>
|
||||
) : log.action === 'INSERT' && log.new_values ? (
|
||||
<code className="text-xs bg-green-500/20 text-green-300 px-2 py-1 rounded">
|
||||
Created: {JSON.stringify(log.new_values).substring(0, 100)}...
|
||||
</code>
|
||||
) : log.action === 'UPDATE' && log.changed_fields ? (
|
||||
<code className="text-xs bg-blue-500/20 text-blue-300 px-2 py-1 rounded">
|
||||
Changed: {log.changed_fields.join(', ')}
|
||||
</code>
|
||||
) : log.action === 'UPDATE' ? (
|
||||
<code className="text-xs bg-blue-500/20 text-blue-300 px-2 py-1 rounded">
|
||||
Updated record
|
||||
</code>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<TableRow key={log.id}>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{format(new Date(log.created_at), 'MMM d, h:mm a')}
|
||||
<div className="mt-2 space-y-1 text-xs text-muted-foreground md:hidden">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-foreground">{log.admin_username || 'Unknown'}</span>
|
||||
<span className="capitalize">{log.admin_role?.replace('_', ' ')}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{actionBadge}
|
||||
<span className="font-mono">{log.table_name}</span>
|
||||
</div>
|
||||
{changesContent && <div>{changesContent}</div>}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
<div className="text-sm font-medium">{log.admin_username || 'Unknown'}</div>
|
||||
<div className="text-xs text-muted-foreground capitalize">{log.admin_role?.replace('_', ' ')}</div>
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
{actionBadge}
|
||||
</TableCell>
|
||||
<TableCell className="hidden lg:table-cell font-mono text-sm">
|
||||
{log.table_name}
|
||||
</TableCell>
|
||||
<TableCell className="hidden lg:table-cell max-w-md">
|
||||
{changesContent}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
@@ -239,13 +239,13 @@ export function FeedbackPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold tracking-tight">User Feedback</h1>
|
||||
<RealtimeStatusIndicator status={realtimeStatus} showLabel />
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold tracking-tight">User Feedback</h1>
|
||||
<RealtimeStatusIndicator status={realtimeStatus} showLabel />
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Review and respond to user feedback submissions
|
||||
</p>
|
||||
@@ -253,20 +253,20 @@ export function FeedbackPage() {
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search feedback..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="Type" />
|
||||
</SelectTrigger>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center">
|
||||
<div className="relative w-full md:flex-1 md:max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search feedback..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||
<SelectTrigger className="w-full md:w-[150px]">
|
||||
<SelectValue placeholder="Type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Types</SelectItem>
|
||||
<SelectItem value="bug">Bug</SelectItem>
|
||||
@@ -274,11 +274,11 @@ export function FeedbackPage() {
|
||||
<SelectItem value="general">General</SelectItem>
|
||||
<SelectItem value="question">Question</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
</Select>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-full md:w-[150px]">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Status</SelectItem>
|
||||
<SelectItem value="new">New</SelectItem>
|
||||
@@ -295,12 +295,12 @@ export function FeedbackPage() {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Type</TableHead>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="w-[100px]">Actions</TableHead>
|
||||
<TableHead className="hidden lg:table-cell">User</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Status</TableHead>
|
||||
<TableHead className="hidden lg:table-cell">Created</TableHead>
|
||||
<TableHead className="hidden md:table-cell w-[100px]">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -321,27 +321,35 @@ export function FeedbackPage() {
|
||||
) : (
|
||||
paginatedFeedback.map((item) => (
|
||||
<TableRow key={item.id} className="cursor-pointer hover:bg-muted/50" onClick={() => handleOpenDetail(item)}>
|
||||
<TableCell>{getTypeBadge(item.type)}</TableCell>
|
||||
<TableCell className="hidden md:table-cell">{getTypeBadge(item.type)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="max-w-[300px]">
|
||||
<div className="md:max-w-[300px]">
|
||||
<div className="font-medium truncate">{item.title}</div>
|
||||
<div className="text-sm text-muted-foreground truncate">
|
||||
{item.description.slice(0, 80)}
|
||||
{item.description.length > 80 && '...'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground md:hidden">
|
||||
{getTypeBadge(item.type)}
|
||||
{getStatusBadge(item.status)}
|
||||
<span className="truncate max-w-[220px]">
|
||||
{item.profile?.name || item.profile?.email || 'Unknown user'}
|
||||
</span>
|
||||
<span>{format(new Date(item.created_at), 'MMM d, yyyy')}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCell className="hidden lg:table-cell">
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">{item.profile?.name || 'Unknown'}</div>
|
||||
<div className="text-muted-foreground">{item.profile?.email}</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{getStatusBadge(item.status)}</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
<TableCell className="hidden md:table-cell">{getStatusBadge(item.status)}</TableCell>
|
||||
<TableCell className="hidden lg:table-cell text-muted-foreground text-sm">
|
||||
{format(new Date(item.created_at), 'MMM d, yyyy')}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); handleOpenDetail(item); }}>
|
||||
View
|
||||
</Button>
|
||||
|
||||
@@ -435,7 +435,7 @@ export function FlaggedMessagesPage() {
|
||||
<TableRow>
|
||||
<TableHead>Time</TableHead>
|
||||
<TableHead>Reason</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Type</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -460,48 +460,57 @@ export function FlaggedMessagesPage() {
|
||||
<Badge variant="destructive" className="bg-red-500/10 text-red-500 hover:bg-red-500/20 border-0">
|
||||
{message.flag_reason || 'Unknown'}
|
||||
</Badge>
|
||||
<div className="mt-2 flex items-center gap-2 text-xs text-muted-foreground md:hidden">
|
||||
{message.media_path ? (
|
||||
<Badge variant="outline">{message.media_type || 'Media'}</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Text</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
{message.media_path ? (
|
||||
<Badge variant="outline">{message.media_type || 'Media'}</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Text</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleViewAiMessage(message)}
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
Review
|
||||
</Button>
|
||||
{message.media_path && message.media_type !== 'video' && (
|
||||
<TableCell className="text-right">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDescribeInline(message)}
|
||||
disabled={describingInline === message.id}
|
||||
onClick={() => handleViewAiMessage(message)}
|
||||
>
|
||||
{describingInline === message.id ? (
|
||||
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Describe
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
Review
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-green-600 hover:text-green-700 hover:bg-green-50"
|
||||
onClick={() => handleMarkSafe(message.id)}
|
||||
disabled={processing === message.id}
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Mark Safe
|
||||
</Button>
|
||||
{message.media_path && message.media_type !== 'video' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDescribeInline(message)}
|
||||
disabled={describingInline === message.id}
|
||||
>
|
||||
{describingInline === message.id ? (
|
||||
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Describe
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-green-600 hover:text-green-700 hover:bg-green-50"
|
||||
onClick={() => handleMarkSafe(message.id)}
|
||||
disabled={processing === message.id}
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Mark Safe
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{inlineDescriptions[message.id] && (
|
||||
@@ -550,9 +559,9 @@ export function FlaggedMessagesPage() {
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Time</TableHead>
|
||||
<TableHead>Reporter</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Reporter</TableHead>
|
||||
<TableHead>Reason</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Type</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -572,50 +581,62 @@ export function FlaggedMessagesPage() {
|
||||
<TableCell className="font-mono text-sm">
|
||||
{format(new Date(report.created_at), 'MMM d, HH:mm')}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
<TableCell className="hidden md:table-cell text-sm">
|
||||
{report.reporter_profile?.name || report.reporter_profile?.email || 'Unknown'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="bg-orange-500/10 text-orange-600 border-orange-200">
|
||||
{REASON_LABELS[report.reason] || report.reason}
|
||||
</Badge>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground md:hidden">
|
||||
<span className="truncate max-w-[220px]">
|
||||
{report.reporter_profile?.name || report.reporter_profile?.email || 'Unknown'}
|
||||
</span>
|
||||
{report.message?.media_path ? (
|
||||
<Badge variant="outline">{report.message.media_type || 'Media'}</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Text</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
{report.message?.media_path ? (
|
||||
<Badge variant="outline">{report.message.media_type || 'Media'}</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Text</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleViewReport(report)}
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
Review
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50"
|
||||
onClick={() => handleDismissReport(report.id)}
|
||||
disabled={processing === report.id}
|
||||
>
|
||||
<XCircle className="h-4 w-4 mr-2" />
|
||||
Dismiss
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-green-600 hover:text-green-700 hover:bg-green-50"
|
||||
onClick={() => handleReviewReport(report.id)}
|
||||
disabled={processing === report.id}
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Mark Reviewed
|
||||
</Button>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleViewReport(report)}
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
Review
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50"
|
||||
onClick={() => handleDismissReport(report.id)}
|
||||
disabled={processing === report.id}
|
||||
>
|
||||
<XCircle className="h-4 w-4 mr-2" />
|
||||
Dismiss
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-green-600 hover:text-green-700 hover:bg-green-50"
|
||||
onClick={() => handleReviewReport(report.id)}
|
||||
disabled={processing === report.id}
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Mark Reviewed
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
|
||||
@@ -302,31 +302,31 @@ export function RedemptionCodesPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Redemption Codes</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Generate and manage codes that grant premium access
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative w-64">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search codes..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Redemption Codes</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Generate and manage codes that grant premium access
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="relative w-full sm:w-64">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search codes..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button onClick={handleOpenAddDialog}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Generate Code
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button onClick={handleOpenAddDialog} className="w-full sm:w-auto">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Generate Code
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Generate Redemption Code</DialogTitle>
|
||||
@@ -335,14 +335,14 @@ export function RedemptionCodesPage() {
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>Code</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={formData.code}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, code: e.target.value.toUpperCase() }))}
|
||||
placeholder="e.g., PREMIUM2024"
|
||||
className="font-mono"
|
||||
<div className="grid gap-2">
|
||||
<Label>Code</Label>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Input
|
||||
value={formData.code}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, code: e.target.value.toUpperCase() }))}
|
||||
placeholder="e.g., PREMIUM2024"
|
||||
className="font-mono"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -399,12 +399,12 @@ export function RedemptionCodesPage() {
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Code</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
<TableHead>Usage</TableHead>
|
||||
<TableHead>Expires</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="w-[150px]">Actions</TableHead>
|
||||
<TableHead className="hidden lg:table-cell">Description</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Usage</TableHead>
|
||||
<TableHead className="hidden lg:table-cell">Expires</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Status</TableHead>
|
||||
<TableHead className="hidden xl:table-cell">Created</TableHead>
|
||||
<TableHead className="hidden md:table-cell w-[150px]">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -421,70 +421,112 @@ export function RedemptionCodesPage() {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredCodes.map((code) => (
|
||||
<TableRow key={code.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="font-mono font-medium text-sm bg-muted px-2 py-1 rounded">
|
||||
{code.code}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => copyToClipboard(code.code)}
|
||||
title="Copy code"
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{code.description || '—'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="font-medium">{code.current_uses}</span>
|
||||
<span className="text-muted-foreground"> / {code.max_uses}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{code.expires_at
|
||||
? format(new Date(code.expires_at), 'MMM d, yyyy HH:mm')
|
||||
: 'Never'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{getStatusBadge(code)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{format(new Date(code.created_at), 'MMM d, yyyy')}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleViewRedemptions(code)}
|
||||
title="View redemptions"
|
||||
>
|
||||
<Users className="h-4 w-4" />
|
||||
</Button>
|
||||
<Switch
|
||||
checked={code.is_active}
|
||||
onCheckedChange={() => handleToggleActive(code)}
|
||||
title={code.is_active ? "Deactivate" : "Activate"}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => handleDeleteCode(code)}
|
||||
title="Delete code"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
filteredCodes.map((code) => {
|
||||
const statusBadge = getStatusBadge(code);
|
||||
const expiresLabel = code.expires_at
|
||||
? format(new Date(code.expires_at), 'MMM d, yyyy HH:mm')
|
||||
: 'Never';
|
||||
|
||||
return (
|
||||
<TableRow key={code.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="font-mono font-medium text-sm bg-muted px-2 py-1 rounded">
|
||||
{code.code}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => copyToClipboard(code.code)}
|
||||
title="Copy code"
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground md:hidden">
|
||||
{statusBadge}
|
||||
<span>
|
||||
{code.current_uses} / {code.max_uses} uses
|
||||
</span>
|
||||
<span>Expires {expiresLabel}</span>
|
||||
{code.description && <span className="truncate max-w-[220px]">{code.description}</span>}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 md:hidden">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleViewRedemptions(code)}
|
||||
>
|
||||
<Users className="h-3 w-3 mr-1" />
|
||||
Redemptions
|
||||
</Button>
|
||||
<div className="flex items-center gap-2 rounded-md border px-2 py-1">
|
||||
<Switch
|
||||
checked={code.is_active}
|
||||
onCheckedChange={() => handleToggleActive(code)}
|
||||
title={code.is_active ? "Deactivate" : "Activate"}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{code.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => handleDeleteCode(code)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3 mr-1" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="hidden lg:table-cell text-muted-foreground">
|
||||
{code.description || '—'}
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
<span className="font-medium">{code.current_uses}</span>
|
||||
<span className="text-muted-foreground"> / {code.max_uses}</span>
|
||||
</TableCell>
|
||||
<TableCell className="hidden lg:table-cell text-muted-foreground text-sm">
|
||||
{expiresLabel}
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
{statusBadge}
|
||||
</TableCell>
|
||||
<TableCell className="hidden xl:table-cell text-muted-foreground text-sm">
|
||||
{format(new Date(code.created_at), 'MMM d, yyyy')}
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleViewRedemptions(code)}
|
||||
title="View redemptions"
|
||||
>
|
||||
<Users className="h-4 w-4" />
|
||||
</Button>
|
||||
<Switch
|
||||
checked={code.is_active}
|
||||
onCheckedChange={() => handleToggleActive(code)}
|
||||
title={code.is_active ? "Deactivate" : "Activate"}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => handleDeleteCode(code)}
|
||||
title="Delete code"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
@@ -440,7 +440,7 @@ export function UserActivityPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
|
||||
@@ -457,7 +457,7 @@ export function UserActivityPage() {
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as ActivityTab)}>
|
||||
<TabsList className="grid w-full grid-cols-4 max-w-[600px]">
|
||||
<TabsList className="grid w-full grid-cols-2 sm:grid-cols-4 max-w-[600px]">
|
||||
<TabsTrigger value="responses" className="gap-2">
|
||||
<ThumbsUp className="h-4 w-4" />
|
||||
Responses
|
||||
|
||||
@@ -302,7 +302,7 @@ export function AdminsPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Admin Management</h1>
|
||||
@@ -312,8 +312,8 @@ export function AdminsPage() {
|
||||
Manage system administrators and their roles
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative w-64">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="relative w-full sm:w-64">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search admins..."
|
||||
@@ -509,10 +509,10 @@ export function AdminsPage() {
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Admin User</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Role / Permissions</TableHead>
|
||||
<TableHead>Added On</TableHead>
|
||||
<TableHead className="w-[120px]">Actions</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Email</TableHead>
|
||||
<TableHead className="hidden lg:table-cell">Role / Permissions</TableHead>
|
||||
<TableHead className="hidden xl:table-cell">Added On</TableHead>
|
||||
<TableHead className="hidden md:table-cell w-[120px]">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -529,82 +529,128 @@ export function AdminsPage() {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
paginatedAdmins.map((admin) => (
|
||||
<TableRow key={admin.user_id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-9 w-9">
|
||||
<AvatarImage src={admin.profile?.avatar_url || undefined} />
|
||||
<AvatarFallback>
|
||||
{admin.profile?.name?.charAt(0).toUpperCase() || 'A'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="font-medium">
|
||||
{admin.profile?.name || 'Unknown User'}
|
||||
</span>
|
||||
{admin.user_id === currentUser?.id && (
|
||||
<Badge variant="outline" className="ml-2">You</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{admin.profile?.email || '—'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{admin.role === 'super_admin' ? (
|
||||
<Badge variant="default">
|
||||
<Shield className="h-3 w-3 mr-1" />
|
||||
Super Admin
|
||||
</Badge>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{admin.permissions.length === 0 ? (
|
||||
<span className="text-muted-foreground text-sm">No permissions</span>
|
||||
) : (
|
||||
admin.permissions.slice(0, 3).map(perm => (
|
||||
<Badge key={perm} variant="secondary" className="text-xs">
|
||||
{PERMISSION_METADATA[perm as PermissionKey]?.label.replace('Can ', '') || perm}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
{admin.permissions.length > 3 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{admin.permissions.length - 3} more
|
||||
</Badge>
|
||||
paginatedAdmins.map((admin) => {
|
||||
const isSelf = admin.user_id === currentUser?.id;
|
||||
const mobileRoleBadge = admin.role === 'super_admin' ? (
|
||||
<Badge variant="default">
|
||||
<Shield className="h-3 w-3 mr-1" />
|
||||
Super Admin
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">
|
||||
{admin.permissions.length > 0
|
||||
? `${admin.permissions.length} permission${admin.permissions.length !== 1 ? 's' : ''}`
|
||||
: 'No permissions'}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
return (
|
||||
<TableRow key={admin.user_id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Avatar className="h-9 w-9">
|
||||
<AvatarImage src={admin.profile?.avatar_url || undefined} />
|
||||
<AvatarFallback>
|
||||
{admin.profile?.name?.charAt(0).toUpperCase() || 'A'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="font-medium truncate">
|
||||
{admin.profile?.name || 'Unknown User'}
|
||||
</span>
|
||||
{isSelf && (
|
||||
<Badge variant="outline" className="ml-2">You</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{admin.created_at ? format(new Date(admin.created_at), 'MMM d, yyyy') : '—'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
{admin.user_id !== currentUser?.id && (
|
||||
<>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground md:hidden">
|
||||
<span className="truncate max-w-[220px]">
|
||||
{admin.profile?.email || '—'}
|
||||
</span>
|
||||
{mobileRoleBadge}
|
||||
<span className="text-muted-foreground">
|
||||
Added {admin.created_at ? format(new Date(admin.created_at), 'MMM d, yyyy') : '—'}
|
||||
</span>
|
||||
</div>
|
||||
{!isSelf && (
|
||||
<div className="mt-3 flex flex-wrap gap-2 md:hidden">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleEditPermissions(admin)}
|
||||
title="Edit Permissions"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
<Settings className="h-3 w-3 mr-1" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => handleRemoveAdmin(admin)}
|
||||
title="Revoke Access"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<Trash2 className="h-3 w-3 mr-1" />
|
||||
Remove
|
||||
</Button>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell text-muted-foreground">
|
||||
{admin.profile?.email || '—'}
|
||||
</TableCell>
|
||||
<TableCell className="hidden lg:table-cell">
|
||||
{admin.role === 'super_admin' ? (
|
||||
<Badge variant="default">
|
||||
<Shield className="h-3 w-3 mr-1" />
|
||||
Super Admin
|
||||
</Badge>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{admin.permissions.length === 0 ? (
|
||||
<span className="text-muted-foreground text-sm">No permissions</span>
|
||||
) : (
|
||||
admin.permissions.slice(0, 3).map(perm => (
|
||||
<Badge key={perm} variant="secondary" className="text-xs">
|
||||
{PERMISSION_METADATA[perm as PermissionKey]?.label.replace('Can ', '') || perm}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
{admin.permissions.length > 3 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{admin.permissions.length - 3} more
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="hidden xl:table-cell text-muted-foreground text-sm">
|
||||
{admin.created_at ? format(new Date(admin.created_at), 'MMM d, yyyy') : '—'}
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
<div className="flex items-center gap-1">
|
||||
{!isSelf && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleEditPermissions(admin)}
|
||||
title="Edit Permissions"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => handleRemoveAdmin(admin)}
|
||||
title="Revoke Access"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Plus, Zap, Pencil, Trash2, Loader2, Crown, Eye, EyeOff, Flame, Heart, ChevronUp, ChevronDown } from 'lucide-react';
|
||||
import { Plus, Zap, Pencil, Trash2, Loader2, Crown, Eye, EyeOff, ChevronUp, ChevronDown } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// =============================================================================
|
||||
@@ -52,9 +52,6 @@ interface DarePack {
|
||||
is_explicit: boolean;
|
||||
sort_order: number;
|
||||
category_id: string | null;
|
||||
min_intensity: number | null;
|
||||
max_intensity: number | null;
|
||||
avg_intensity: number | null;
|
||||
created_at: string | null;
|
||||
dare_count?: number;
|
||||
}
|
||||
@@ -296,15 +293,6 @@ export function DarePacksPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const getIntensityColor = (avg: number | null) => {
|
||||
if (avg === null) return 'bg-muted';
|
||||
if (avg <= 1.5) return 'bg-green-500';
|
||||
if (avg <= 2.5) return 'bg-lime-500';
|
||||
if (avg <= 3.5) return 'bg-yellow-500';
|
||||
if (avg <= 4.5) return 'bg-orange-500';
|
||||
return 'bg-red-500';
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Render
|
||||
// =============================================================================
|
||||
@@ -446,9 +434,9 @@ export function DarePacksPage() {
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Explicit</Label>
|
||||
<Label>NSFW</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Contains adult/explicit content
|
||||
Contains adult or mature content
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
@@ -456,6 +444,7 @@ export function DarePacksPage() {
|
||||
onCheckedChange={(checked) => form.setField('is_explicit', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
@@ -522,6 +511,11 @@ export function DarePacksPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{pack.is_explicit && (
|
||||
<Badge variant="destructive">
|
||||
NSFW
|
||||
</Badge>
|
||||
)}
|
||||
{pack.is_premium && (
|
||||
<Badge variant="default" className="bg-amber-500">
|
||||
<Crown className="h-3 w-3 mr-1" />
|
||||
@@ -539,17 +533,6 @@ export function DarePacksPage() {
|
||||
Draft
|
||||
</Badge>
|
||||
)}
|
||||
{pack.is_explicit ? (
|
||||
<Badge variant="outline" className="border-red-500 text-red-400">
|
||||
<Flame className="h-3 w-3 mr-1" />
|
||||
Explicit
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="border-pink-400 text-pink-300">
|
||||
<Heart className="h-3 w-3 mr-1" />
|
||||
Safe
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start justify-between">
|
||||
@@ -574,17 +557,6 @@ export function DarePacksPage() {
|
||||
<CardDescription className="line-clamp-2">
|
||||
{pack.description || 'No description'}
|
||||
</CardDescription>
|
||||
{/* Intensity indicator */}
|
||||
{pack.avg_intensity !== null && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Badge className={`${getIntensityColor(pack.avg_intensity)} text-white`}>
|
||||
Avg: {pack.avg_intensity?.toFixed(1)}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Range: {pack.min_intensity ?? '?'} - {pack.max_intensity ?? '?'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -47,7 +47,6 @@ interface DarePack {
|
||||
name: string;
|
||||
description: string | null;
|
||||
icon: string | null;
|
||||
is_explicit: boolean;
|
||||
}
|
||||
|
||||
interface Dare {
|
||||
@@ -70,8 +69,6 @@ const DURATION_OPTIONS = [
|
||||
{ value: '168', label: '1 week' },
|
||||
];
|
||||
|
||||
const getDefaultIntensity = (isExplicit?: boolean) => (isExplicit ? 4 : 2);
|
||||
|
||||
// Format duration for display
|
||||
const formatDuration = (hours: number | null): string => {
|
||||
if (hours === null) return '—';
|
||||
@@ -101,11 +98,9 @@ export function DaresPage() {
|
||||
// Form state
|
||||
const [formData, setFormData] = useState<{
|
||||
text: string;
|
||||
intensity: number;
|
||||
suggested_duration_hours: string;
|
||||
}>({
|
||||
text: '',
|
||||
intensity: getDefaultIntensity(),
|
||||
suggested_duration_hours: '',
|
||||
});
|
||||
|
||||
@@ -117,7 +112,7 @@ export function DaresPage() {
|
||||
// Fetch pack info
|
||||
const { data: packData } = await supabase
|
||||
.from('dare_packs')
|
||||
.select('id, name, description, icon, is_explicit')
|
||||
.select('id, name, description, icon')
|
||||
.eq('id', packId)
|
||||
.single();
|
||||
|
||||
@@ -167,7 +162,7 @@ export function DaresPage() {
|
||||
|
||||
const openCreateDialog = () => {
|
||||
setEditingDare(null);
|
||||
setFormData({ text: '', intensity: getDefaultIntensity(pack?.is_explicit), suggested_duration_hours: '' });
|
||||
setFormData({ text: '', suggested_duration_hours: '' });
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
@@ -175,7 +170,6 @@ export function DaresPage() {
|
||||
setEditingDare(dare);
|
||||
setFormData({
|
||||
text: dare.text,
|
||||
intensity: dare.intensity,
|
||||
suggested_duration_hours: dare.suggested_duration_hours?.toString() || '',
|
||||
});
|
||||
setDialogOpen(true);
|
||||
@@ -191,7 +185,6 @@ export function DaresPage() {
|
||||
try {
|
||||
const dareData = {
|
||||
text: formData.text,
|
||||
intensity: formData.intensity,
|
||||
suggested_duration_hours: formData.suggested_duration_hours
|
||||
? parseInt(formData.suggested_duration_hours, 10)
|
||||
: null,
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { PaginationControls } from '@/components/ui/pagination';
|
||||
import { Plus, MessageCircle, Pencil, Trash2, Sparkles, Crown, Eye, EyeOff, Flame, Heart, Tags, ChevronUp, ChevronDown, Clock, BellOff } from 'lucide-react';
|
||||
import { Plus, MessageCircle, Pencil, Trash2, Sparkles, Crown, Eye, EyeOff, Tags, ChevronUp, ChevronDown, Clock, BellOff } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog';
|
||||
import { ExtractTopicsDialog } from '@/components/content/ExtractTopicsDialog';
|
||||
@@ -513,6 +513,11 @@ export function PacksPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{pack.is_explicit && (
|
||||
<Badge variant="destructive">
|
||||
NSFW
|
||||
</Badge>
|
||||
)}
|
||||
{pack.is_premium && (
|
||||
<Badge variant="default" className="bg-amber-500">
|
||||
<Crown className="h-3 w-3 mr-1" />
|
||||
@@ -542,17 +547,6 @@ export function PacksPage() {
|
||||
Draft
|
||||
</Badge>
|
||||
)}
|
||||
{pack.is_explicit ? (
|
||||
<Badge variant="outline" className="border-red-500 text-red-400">
|
||||
<Flame className="h-3 w-3 mr-1" />
|
||||
Explicit
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="border-pink-400 text-pink-300">
|
||||
<Heart className="h-3 w-3 mr-1" />
|
||||
Safe
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start justify-between">
|
||||
|
||||
@@ -62,9 +62,6 @@ interface ChartDatum {
|
||||
|
||||
type SegmentKey = 'all' | 'premium' | 'free';
|
||||
|
||||
const intensityPalette = ['#22c55e', '#84cc16', '#facc15', '#f97316', '#ef4444'];
|
||||
const intensityLabels = ['Gentle', 'Warm', 'Playful', 'Steamy', 'Intense'];
|
||||
|
||||
const QUESTION_TYPE_CONFIG: Record<QuestionType, { label: string; color: string }> = {
|
||||
swipe: { label: 'Swipe', color: '#38bdf8' },
|
||||
text_answer: { label: 'Text', color: '#f59e0b' },
|
||||
@@ -101,11 +98,6 @@ export function QuestionAnalyticsPage() {
|
||||
});
|
||||
const [premiumBreakdown, setPremiumBreakdown] = useState<ChartDatum[]>([]);
|
||||
const [questionTypeBreakdown, setQuestionTypeBreakdown] = useState<ChartDatum[]>([]);
|
||||
const [intensityBreakdown, setIntensityBreakdown] = useState<Record<SegmentKey, ChartDatum[]>>({
|
||||
all: [],
|
||||
premium: [],
|
||||
free: [],
|
||||
});
|
||||
const [topicBreakdown, setTopicBreakdown] = useState<Record<SegmentKey, BreakdownItem[]>>({
|
||||
all: [],
|
||||
premium: [],
|
||||
@@ -121,7 +113,6 @@ export function QuestionAnalyticsPage() {
|
||||
premium: [],
|
||||
free: [],
|
||||
});
|
||||
const [intensitySegment, setIntensitySegment] = useState<SegmentKey>('all');
|
||||
const [tagSegment, setTagSegment] = useState<SegmentKey>('all');
|
||||
const [categorySegment, setCategorySegment] = useState<SegmentKey>('all');
|
||||
const [packSegment, setPackSegment] = useState<SegmentKey>('all');
|
||||
@@ -215,12 +206,6 @@ export function QuestionAnalyticsPage() {
|
||||
packTopicsMap.set(relation.pack_id, topicsForPack);
|
||||
});
|
||||
|
||||
const emptyIntensityCounts = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 };
|
||||
const intensityCounts: Record<SegmentKey, Record<number, number>> = {
|
||||
all: { ...emptyIntensityCounts },
|
||||
premium: { ...emptyIntensityCounts },
|
||||
free: { ...emptyIntensityCounts },
|
||||
};
|
||||
const untaggedCounts: Record<SegmentKey, number> = { all: 0, premium: 0, free: 0 };
|
||||
const questionTypeCounts: Record<QuestionType, number> = {
|
||||
swipe: 0,
|
||||
@@ -262,10 +247,6 @@ export function QuestionAnalyticsPage() {
|
||||
freeCount += 1;
|
||||
}
|
||||
|
||||
const intensity = question.intensity ?? 1;
|
||||
intensityCounts.all[intensity] = (intensityCounts.all[intensity] || 0) + 1;
|
||||
intensityCounts[segmentKey][intensity] = (intensityCounts[segmentKey][intensity] || 0) + 1;
|
||||
|
||||
const updatePackCounts = (key: SegmentKey) => {
|
||||
const map = packCountsBySegment[key];
|
||||
const packEntry = map.get(question.pack_id) || {
|
||||
@@ -332,12 +313,6 @@ export function QuestionAnalyticsPage() {
|
||||
color: config.color,
|
||||
}));
|
||||
|
||||
const buildIntensityData = (key: SegmentKey) => intensityLabels.map((label, index) => ({
|
||||
name: label,
|
||||
value: intensityCounts[key][index + 1] || 0,
|
||||
color: intensityPalette[index],
|
||||
}));
|
||||
|
||||
const buildTopicData = (key: SegmentKey) => {
|
||||
const data = Array.from(topicCountsBySegment[key].values());
|
||||
if (untaggedCounts[key] > 0 || data.length === 0) {
|
||||
@@ -348,12 +323,6 @@ export function QuestionAnalyticsPage() {
|
||||
return data;
|
||||
};
|
||||
|
||||
const intensityData = {
|
||||
all: buildIntensityData('all'),
|
||||
premium: buildIntensityData('premium'),
|
||||
free: buildIntensityData('free'),
|
||||
};
|
||||
|
||||
const topicData = {
|
||||
all: buildTopicData('all'),
|
||||
premium: buildTopicData('premium'),
|
||||
@@ -385,7 +354,6 @@ export function QuestionAnalyticsPage() {
|
||||
});
|
||||
setPremiumBreakdown(premiumData);
|
||||
setQuestionTypeBreakdown(questionTypeData);
|
||||
setIntensityBreakdown(intensityData);
|
||||
setTopicBreakdown(topicData);
|
||||
setCategoryBreakdown(categoryData);
|
||||
setPackBreakdown(packData);
|
||||
@@ -427,7 +395,6 @@ export function QuestionAnalyticsPage() {
|
||||
free: stats.totalQuestions ? ((stats.freeQuestions / stats.totalQuestions) * 100).toFixed(0) : '0',
|
||||
};
|
||||
|
||||
const intensityData = intensityBreakdown[intensitySegment];
|
||||
const tagData = topicBreakdown[tagSegment];
|
||||
const categoryData = categoryBreakdown[categorySegment];
|
||||
const packData = packBreakdown[packSegment];
|
||||
@@ -447,7 +414,7 @@ export function QuestionAnalyticsPage() {
|
||||
Question Analytics
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Deep dive into premium coverage, tags, categories, and intensity distribution
|
||||
Deep dive into premium coverage, tags, categories, and question types
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -601,78 +568,6 @@ export function QuestionAnalyticsPage() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<CardTitle>Intensity Distribution</CardTitle>
|
||||
<CardDescription>
|
||||
How questions are spread across intensity levels
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-1 sm:items-end">
|
||||
<div className="flex items-center gap-2">
|
||||
{segmentOptions.map((option) => (
|
||||
<Button
|
||||
key={option.key}
|
||||
variant={intensitySegment === option.key ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setIntensitySegment(option.key)}
|
||||
className="rounded-full"
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{segmentLabelByKey[intensitySegment]}: {totalBySegment[intensitySegment].toLocaleString()} questions ({segmentShareByKey[intensitySegment]}%)
|
||||
</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Skeleton className="h-[300px] w-full" />
|
||||
) : totalBySegment[intensitySegment] === 0 ? (
|
||||
<div className="flex items-center justify-center h-[300px] text-muted-foreground">
|
||||
No questions in this segment yet
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={intensityData}>
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
fontSize={11}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
fontSize={11}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => `${value}`}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: 'rgba(255,255,255,0.03)' }}
|
||||
contentStyle={{
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e2e8f0',
|
||||
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="value" radius={[6, 6, 0, 0]}>
|
||||
{intensityData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
|
||||
@@ -49,7 +49,6 @@ interface QuestionPack {
|
||||
name: string;
|
||||
description: string | null;
|
||||
icon: string | null;
|
||||
is_explicit: boolean;
|
||||
}
|
||||
|
||||
type QuestionType = 'swipe' | 'text_answer' | 'audio' | 'photo' | 'who_likely';
|
||||
@@ -74,7 +73,6 @@ interface Question {
|
||||
}
|
||||
|
||||
const DEFAULT_AUDIO_DURATION = 60;
|
||||
const getDefaultIntensity = (isExplicit?: boolean) => (isExplicit ? 4 : 2);
|
||||
|
||||
const QUESTION_TYPE_OPTIONS: Array<{ value: QuestionType; label: string }> = [
|
||||
{ value: 'swipe', label: 'Swipe' },
|
||||
@@ -124,7 +122,6 @@ export function QuestionsPage() {
|
||||
const [formData, setFormData] = useState<{
|
||||
text: string;
|
||||
partner_text: string;
|
||||
intensity: number;
|
||||
allowed_couple_genders: string[];
|
||||
target_user_genders: string[];
|
||||
required_props: string;
|
||||
@@ -134,7 +131,6 @@ export function QuestionsPage() {
|
||||
}>({
|
||||
text: '',
|
||||
partner_text: '',
|
||||
intensity: getDefaultIntensity(),
|
||||
allowed_couple_genders: [],
|
||||
target_user_genders: [],
|
||||
required_props: '',
|
||||
@@ -156,7 +152,7 @@ export function QuestionsPage() {
|
||||
|
||||
const packPromise = supabase
|
||||
.from('question_packs')
|
||||
.select('id, name, description, icon, is_explicit')
|
||||
.select('id, name, description, icon')
|
||||
.eq('id', packId)
|
||||
.single();
|
||||
let questionsQuery = supabase
|
||||
@@ -272,7 +268,6 @@ export function QuestionsPage() {
|
||||
setFormData({
|
||||
text: '',
|
||||
partner_text: '',
|
||||
intensity: getDefaultIntensity(pack?.is_explicit),
|
||||
allowed_couple_genders: [],
|
||||
target_user_genders: [],
|
||||
required_props: '',
|
||||
@@ -289,7 +284,6 @@ export function QuestionsPage() {
|
||||
setFormData({
|
||||
text: question.text,
|
||||
partner_text: question.partner_text || '',
|
||||
intensity: question.intensity,
|
||||
allowed_couple_genders: question.allowed_couple_genders || [],
|
||||
target_user_genders: question.target_user_genders || [],
|
||||
required_props: question.required_props?.join(', ') ?? '',
|
||||
@@ -326,7 +320,6 @@ export function QuestionsPage() {
|
||||
const { error } = await auditedSupabase.update('questions', editingQuestion.id, {
|
||||
text: formData.text,
|
||||
partner_text: formData.partner_text || null,
|
||||
intensity: formData.intensity,
|
||||
allowed_couple_genders: formData.allowed_couple_genders.length > 0 ? formData.allowed_couple_genders : null,
|
||||
target_user_genders: formData.target_user_genders.length > 0 ? formData.target_user_genders : null,
|
||||
required_props: requiredPropsValue,
|
||||
@@ -342,7 +335,6 @@ export function QuestionsPage() {
|
||||
pack_id: packId,
|
||||
text: formData.text,
|
||||
partner_text: formData.partner_text || null,
|
||||
intensity: formData.intensity,
|
||||
allowed_couple_genders: formData.allowed_couple_genders.length > 0 ? formData.allowed_couple_genders : null,
|
||||
target_user_genders: formData.target_user_genders.length > 0 ? formData.target_user_genders : null,
|
||||
required_props: requiredPropsValue,
|
||||
@@ -384,7 +376,6 @@ export function QuestionsPage() {
|
||||
|
||||
const handleAiGenerated = (generatedQuestions: Array<{ text: string; partner_text?: string; intensity: number; inverse_pair_id?: string | null }>) => {
|
||||
const generatedQuestionType: QuestionType = 'swipe';
|
||||
const defaultIntensity = getDefaultIntensity(pack?.is_explicit);
|
||||
|
||||
// Bulk insert AI-generated questions with inverse_of linking
|
||||
const insertQuestions = async () => {
|
||||
@@ -411,7 +402,6 @@ export function QuestionsPage() {
|
||||
pack_id: packId,
|
||||
text: q.text,
|
||||
partner_text: q.partner_text || null,
|
||||
intensity: defaultIntensity,
|
||||
inverse_of: null,
|
||||
question_type: generatedQuestionType,
|
||||
config: null,
|
||||
@@ -431,7 +421,6 @@ export function QuestionsPage() {
|
||||
pack_id: packId,
|
||||
text: primary.text,
|
||||
partner_text: primary.partner_text || null,
|
||||
intensity: defaultIntensity,
|
||||
inverse_of: null,
|
||||
question_type: generatedQuestionType,
|
||||
config: null,
|
||||
@@ -449,7 +438,6 @@ export function QuestionsPage() {
|
||||
pack_id: packId,
|
||||
text: inverse.text,
|
||||
partner_text: inverse.partner_text || null,
|
||||
intensity: defaultIntensity,
|
||||
inverse_of: primaryId,
|
||||
question_type: generatedQuestionType,
|
||||
config: null,
|
||||
@@ -467,7 +455,6 @@ export function QuestionsPage() {
|
||||
pack_id: packId,
|
||||
text: extra.text,
|
||||
partner_text: extra.partner_text || null,
|
||||
intensity: defaultIntensity,
|
||||
inverse_of: null,
|
||||
question_type: generatedQuestionType,
|
||||
config: null,
|
||||
@@ -483,7 +470,6 @@ export function QuestionsPage() {
|
||||
pack_id: packId,
|
||||
text: pair[0].text,
|
||||
partner_text: pair[0].partner_text || null,
|
||||
intensity: defaultIntensity,
|
||||
inverse_of: null,
|
||||
question_type: generatedQuestionType,
|
||||
config: null,
|
||||
@@ -649,7 +635,6 @@ export function QuestionsPage() {
|
||||
<AIPolishButton
|
||||
text={formData.text}
|
||||
type="question"
|
||||
explicit={pack?.is_explicit ?? false}
|
||||
onPolished={(val) => setFormData(d => ({ ...d, text: val }))}
|
||||
/>
|
||||
</div>
|
||||
@@ -713,7 +698,6 @@ export function QuestionsPage() {
|
||||
<AIPolishButton
|
||||
text={formData.partner_text}
|
||||
type="partner_text"
|
||||
explicit={pack?.is_explicit ?? false}
|
||||
onPolished={(val) => setFormData(d => ({ ...d, partner_text: val }))}
|
||||
/>
|
||||
</div>
|
||||
@@ -926,7 +910,7 @@ export function QuestionsPage() {
|
||||
open={aiDialogOpen}
|
||||
onOpenChange={setAiDialogOpen}
|
||||
type="questions"
|
||||
context={{ packName: pack?.name, packDescription: pack?.description, isExplicit: pack?.is_explicit, existingQuestions: questions.map(q => q.text) }}
|
||||
context={{ packName: pack?.name, packDescription: pack?.description, existingQuestions: questions.map(q => q.text) }}
|
||||
onGenerated={handleAiGenerated}
|
||||
/>
|
||||
|
||||
@@ -934,7 +918,6 @@ export function QuestionsPage() {
|
||||
open={reviewOpen}
|
||||
onOpenChange={setReviewOpen}
|
||||
questions={reviewQuestions}
|
||||
isExplicit={pack?.is_explicit ?? false}
|
||||
onUpdated={fetchData}
|
||||
/>
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Crown, Users, MessageCircle, ChevronRight, ThumbsUp, ThumbsDown, Minus, Gift, Image, Video as VideoIcon, Target, User, Eye, EyeOff, CheckCircle, Package, Heart, Sparkles, Flame, Trophy, Calendar } from 'lucide-react';
|
||||
import { Crown, Users, MessageCircle, ChevronRight, ThumbsUp, ThumbsDown, Minus, Gift, Image, Video as VideoIcon, Target, User, Eye, EyeOff, CheckCircle, Package, Sparkles, Flame, Trophy, Calendar } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { IconPreview } from '@/components/ui/icon-picker';
|
||||
|
||||
@@ -46,8 +46,6 @@ type Gender = 'male' | 'female' | 'non-binary' | 'prefer-not-to-say';
|
||||
|
||||
// ... (interfaces and constants are the same)
|
||||
|
||||
type IntensityLevel = 1 | 2 | 3 | 4 | 5;
|
||||
|
||||
interface Profile {
|
||||
id: string;
|
||||
name: string | null;
|
||||
@@ -58,9 +56,8 @@ interface Profile {
|
||||
created_at: string | null;
|
||||
gender: Gender | null;
|
||||
usage_reason: UsageReason | null;
|
||||
show_explicit_content: boolean | null;
|
||||
hide_nsfw: boolean | null;
|
||||
onboarding_completed: boolean | null;
|
||||
max_intensity: IntensityLevel | null;
|
||||
}
|
||||
|
||||
const usageReasonLabels: Record<UsageReason, { label: string; icon: string }> = {
|
||||
@@ -78,14 +75,6 @@ const genderLabels: Record<Gender, string> = {
|
||||
'prefer-not-to-say': 'Prefer not to say',
|
||||
};
|
||||
|
||||
const intensityLabels: Record<IntensityLevel, { label: string; emoji: string }> = {
|
||||
1: { label: 'Gentle', emoji: '💭' },
|
||||
2: { label: 'Warm', emoji: '💕' },
|
||||
3: { label: 'Playful', emoji: '😏' },
|
||||
4: { label: 'Steamy', emoji: '🔥' },
|
||||
5: { label: 'Intense', emoji: '🌶️' },
|
||||
};
|
||||
|
||||
interface Partner {
|
||||
id: string;
|
||||
name: string | null;
|
||||
@@ -556,11 +545,10 @@ export function UserDetailPage() {
|
||||
</div>
|
||||
</div></CardContent></Card>
|
||||
{/* Onboarding Details */}
|
||||
<Card><CardHeader className="pb-3"><CardTitle className="text-lg flex items-center gap-2"><CheckCircle className="h-5 w-5 text-primary" />Onboarding Details</CardTitle><CardDescription>Information collected during user onboarding</CardDescription></CardHeader><CardContent><div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
|
||||
<Card><CardHeader className="pb-3"><CardTitle className="text-lg flex items-center gap-2"><CheckCircle className="h-5 w-5 text-primary" />Onboarding Details</CardTitle><CardDescription>Information collected during user onboarding</CardDescription></CardHeader><CardContent><div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="flex items-start gap-3"><div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-900"><User className="h-4 w-4 text-blue-600 dark:text-blue-400" /></div><div><p className="text-sm font-medium text-muted-foreground">Gender</p><p className="font-medium">{profile.gender ? genderLabels[profile.gender] : 'Not set'}</p></div></div>
|
||||
<div className="flex items-start gap-3"><div className="p-2 rounded-lg bg-purple-100 dark:bg-purple-900"><Target className="h-4 w-4 text-purple-600 dark:text-purple-400" /></div><div><p className="text-sm font-medium text-muted-foreground">Why using Sauci</p><p className="font-medium">{profile.usage_reason ? usageReasonLabels[profile.usage_reason]?.label : 'Not set'}</p></div></div>
|
||||
<div className="flex items-start gap-3"><div className="p-2 rounded-lg bg-pink-100 dark:bg-pink-900"><Heart className="h-4 w-4 text-pink-600 dark:text-pink-400" /></div><div><p className="text-sm font-medium text-muted-foreground">Comfort Zone</p><p className="font-medium">{profile.max_intensity ? `${intensityLabels[profile.max_intensity].emoji} ${intensityLabels[profile.max_intensity].label} (${profile.max_intensity})` : 'Not set'}</p></div></div>
|
||||
<div className="flex items-start gap-3"><div className={`p-2 rounded-lg ${profile.show_explicit_content ? 'bg-amber-100 dark:bg-amber-900' : 'bg-gray-100 dark:bg-gray-800'}`}>{profile.show_explicit_content ? <Eye className="h-4 w-4 text-amber-600 dark:text-amber-400" /> : <EyeOff className="h-4 w-4 text-gray-500 dark:text-gray-400" />}</div><div><p className="text-sm font-medium text-muted-foreground">Explicit Content</p><p className="font-medium">{profile.show_explicit_content === null ? 'Not set' : profile.show_explicit_content ? 'Enabled' : 'Disabled'}</p></div></div>
|
||||
<div className="flex items-start gap-3"><div className={`p-2 rounded-lg ${profile.hide_nsfw ? 'bg-gray-100 dark:bg-gray-800' : 'bg-amber-100 dark:bg-amber-900'}`}>{profile.hide_nsfw ? <EyeOff className="h-4 w-4 text-gray-500 dark:text-gray-400" /> : <Eye className="h-4 w-4 text-amber-600 dark:text-amber-400" />}</div><div><p className="text-sm font-medium text-muted-foreground">Hide NSFW</p><p className="font-medium">{profile.hide_nsfw === null ? 'Not set' : profile.hide_nsfw ? 'Yes' : 'No'}</p></div></div>
|
||||
<div className="flex items-start gap-3"><div className={`p-2 rounded-lg ${profile.onboarding_completed ? 'bg-green-100 dark:bg-green-900' : 'bg-red-100 dark:bg-red-900'}`}><CheckCircle className={`h-4 w-4 ${profile.onboarding_completed ? 'text-green-600 dark:text-green-400' : 'text-red-500 dark:text-red-400'}`} /></div><div><p className="text-sm font-medium text-muted-foreground">Onboarding</p><p className="font-medium">{profile.onboarding_completed ? 'Completed' : 'Not completed'}</p></div></div>
|
||||
</div></CardContent></Card>
|
||||
{/* Streak Info */}
|
||||
@@ -674,17 +662,17 @@ export function UserDetailPage() {
|
||||
<CardTitle className="text-base">{ep.pack.name}</CardTitle>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{ep.pack.is_explicit && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
NSFW
|
||||
</Badge>
|
||||
)}
|
||||
{ep.pack.is_premium && (
|
||||
<Badge className="bg-amber-500 text-xs">
|
||||
<Crown className="h-3 w-3 mr-1" />
|
||||
Premium
|
||||
</Badge>
|
||||
)}
|
||||
{ep.pack.is_explicit && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
18+
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{ep.pack.description && (
|
||||
|
||||
@@ -226,13 +226,13 @@ export function UsersPage() {
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Couple</TableHead>
|
||||
<TableHead>Storage</TableHead>
|
||||
<TableHead>Joined</TableHead>
|
||||
<TableHead>Last Active</TableHead>
|
||||
<TableHead className="w-12"></TableHead>
|
||||
<TableHead className="hidden md:table-cell">Email</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Status</TableHead>
|
||||
<TableHead className="hidden lg:table-cell">Couple</TableHead>
|
||||
<TableHead className="hidden lg:table-cell">Storage</TableHead>
|
||||
<TableHead className="hidden xl:table-cell">Joined</TableHead>
|
||||
<TableHead className="hidden xl:table-cell">Last Active</TableHead>
|
||||
<TableHead className="hidden md:table-cell w-12"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -243,102 +243,137 @@ export function UsersPage() {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
paginatedProfiles.map((profile) => (
|
||||
<TableRow key={profile.id}>
|
||||
<TableCell>
|
||||
<Link to={`/users/${profile.id}`} className="flex items-center gap-3 hover:opacity-80 transition-opacity">
|
||||
<Avatar className="h-9 w-9">
|
||||
<AvatarImage src={profile.avatar_url || undefined} />
|
||||
<AvatarFallback>
|
||||
{profile.name?.charAt(0).toUpperCase() || 'U'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="font-medium hover:text-primary transition-colors">
|
||||
{profile.name || 'Unnamed User'}
|
||||
</span>
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{profile.email ? (
|
||||
<>
|
||||
{profile.email}
|
||||
{!profile.email_confirmed_at && (
|
||||
<span title="Email not verified">
|
||||
<MailWarning className="h-3.5 w-3.5 text-amber-500" />
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Badge variant="secondary" className="bg-slate-100 text-slate-500 hover:bg-slate-200">
|
||||
Guest
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{profile.is_premium ? (
|
||||
<Badge className="bg-amber-500">
|
||||
<Crown className="h-3 w-3 mr-1" />
|
||||
Premium
|
||||
</Badge>
|
||||
) : profile.partner_is_premium ? (
|
||||
<Badge className="bg-amber-500/70">
|
||||
<Crown className="h-3 w-3 mr-1" />
|
||||
Partner
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="bg-slate-100 text-slate-500 hover:bg-slate-200">
|
||||
Free
|
||||
</Badge>
|
||||
paginatedProfiles.map((profile) => {
|
||||
const statusBadge = profile.is_premium ? (
|
||||
<Badge className="bg-amber-500">
|
||||
<Crown className="h-3 w-3 mr-1" />
|
||||
Premium
|
||||
</Badge>
|
||||
) : profile.partner_is_premium ? (
|
||||
<Badge className="bg-amber-500/70">
|
||||
<Crown className="h-3 w-3 mr-1" />
|
||||
Partner
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="bg-slate-100 text-slate-500 hover:bg-slate-200">
|
||||
Free
|
||||
</Badge>
|
||||
);
|
||||
|
||||
const coupleBadge = profile.couple_id ? (
|
||||
profile.partner_count && profile.partner_count >= 2 ? (
|
||||
<Badge variant="secondary" className="bg-green-100 text-green-700 hover:bg-green-200">
|
||||
<Users className="h-3 w-3 mr-1" />
|
||||
Paired
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="bg-amber-100 text-amber-700 hover:bg-amber-200">
|
||||
<Hourglass className="h-3 w-3 mr-1" />
|
||||
Pairing
|
||||
</Badge>
|
||||
)
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">Not paired</span>
|
||||
);
|
||||
|
||||
const emailContent = profile.email ? (
|
||||
<span className="flex items-center gap-1.5 truncate max-w-[220px]">
|
||||
<span className="truncate">{profile.email}</span>
|
||||
{!profile.email_confirmed_at && (
|
||||
<MailWarning className="h-3.5 w-3.5 text-amber-500" aria-label="Email not verified" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{profile.couple_id ? (
|
||||
profile.partner_count && profile.partner_count >= 2 ? (
|
||||
<Badge variant="secondary" className="bg-green-100 text-green-700 hover:bg-green-200">
|
||||
<Users className="h-3 w-3 mr-1" />
|
||||
Paired
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="bg-amber-100 text-amber-700 hover:bg-amber-200">
|
||||
<Hourglass className="h-3 w-3 mr-1" />
|
||||
Pairing
|
||||
</Badge>
|
||||
)
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{profile.storage_bytes && profile.storage_bytes > 0 ? (
|
||||
<div className="flex items-center gap-1 text-sm">
|
||||
<HardDrive className="h-3 w-3 text-muted-foreground" />
|
||||
{formatBytes(profile.storage_bytes)}
|
||||
</span>
|
||||
) : (
|
||||
<Badge variant="secondary" className="bg-slate-100 text-slate-500 hover:bg-slate-200">
|
||||
Guest
|
||||
</Badge>
|
||||
);
|
||||
|
||||
const storageLabel = profile.storage_bytes && profile.storage_bytes > 0
|
||||
? formatBytes(profile.storage_bytes)
|
||||
: '—';
|
||||
|
||||
const joinedLabel = profile.created_at
|
||||
? format(new Date(profile.created_at), 'MMM d, yyyy')
|
||||
: '—';
|
||||
|
||||
const lastActiveLabel = profile.last_sign_in_at
|
||||
? format(new Date(profile.last_sign_in_at), 'MMM d, yyyy h:mm a')
|
||||
: 'Never';
|
||||
|
||||
return (
|
||||
<TableRow key={profile.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to={`/users/${profile.id}`} className="flex items-center gap-3 min-w-0 hover:opacity-80 transition-opacity">
|
||||
<Avatar className="h-9 w-9">
|
||||
<AvatarImage src={profile.avatar_url || undefined} />
|
||||
<AvatarFallback>
|
||||
{profile.name?.charAt(0).toUpperCase() || 'U'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="font-medium truncate hover:text-primary transition-colors">
|
||||
{profile.name || 'Unnamed User'}
|
||||
</span>
|
||||
</Link>
|
||||
<div className="ml-auto md:hidden">
|
||||
<Link to={`/users/${profile.id}`}>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{profile.created_at
|
||||
? format(new Date(profile.created_at), 'MMM d, yyyy')
|
||||
: '—'}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{profile.last_sign_in_at
|
||||
? format(new Date(profile.last_sign_in_at), 'MMM d, yyyy h:mm a')
|
||||
: 'Never'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Link to={`/users/${profile.id}`}>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground md:hidden">
|
||||
{emailContent}
|
||||
{statusBadge}
|
||||
{coupleBadge}
|
||||
{storageLabel !== '—' && (
|
||||
<span className="flex items-center gap-1">
|
||||
<HardDrive className="h-3 w-3 text-muted-foreground" />
|
||||
{storageLabel}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-muted-foreground">Active {lastActiveLabel}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{emailContent}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
{statusBadge}
|
||||
</TableCell>
|
||||
<TableCell className="hidden lg:table-cell">
|
||||
{profile.couple_id ? coupleBadge : <span className="text-muted-foreground text-sm">—</span>}
|
||||
</TableCell>
|
||||
<TableCell className="hidden lg:table-cell">
|
||||
{profile.storage_bytes && profile.storage_bytes > 0 ? (
|
||||
<div className="flex items-center gap-1 text-sm">
|
||||
<HardDrive className="h-3 w-3 text-muted-foreground" />
|
||||
{storageLabel}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="hidden xl:table-cell text-muted-foreground text-sm">
|
||||
{joinedLabel}
|
||||
</TableCell>
|
||||
<TableCell className="hidden xl:table-cell text-muted-foreground text-sm">
|
||||
{lastActiveLabel}
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
<Link to={`/users/${profile.id}`}>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
Reference in new issue
Block a user