mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Add admin user details, pack notifications, remove crypto
- Add enabled packs and feature interests to admin user page - Add clickable links from responses to packs in admin - Add pack change notification edge function - Remove react-native-quick-crypto (unused E2EE) - Bump mobile version to 1.0.1 (versionCode 22) - Add EAS config and preview build scripts - Gitignore keystore and APK files Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a7a13c9614
commit
e671ae9d1c
18 files changed
+527
-188
No files matched your search
@@ -49,6 +49,7 @@ interface ResponseActivity {
|
||||
id: string;
|
||||
text: string;
|
||||
pack: {
|
||||
id: string;
|
||||
name: string;
|
||||
} | null;
|
||||
} | null;
|
||||
@@ -155,7 +156,7 @@ export function UserActivityPage() {
|
||||
profile:profiles!responses_user_id_fkey(id, name, email),
|
||||
question:questions(
|
||||
id, text,
|
||||
pack:question_packs(name)
|
||||
pack:question_packs(id, name)
|
||||
)
|
||||
`, { count: 'exact' })
|
||||
.order('created_at', { ascending: false })
|
||||
@@ -317,18 +318,22 @@ export function UserActivityPage() {
|
||||
const fetchSignups = useCallback(async () => {
|
||||
setLoading(prev => ({ ...prev, signups: true }));
|
||||
try {
|
||||
const from = (signupsPage - 1) * signupsPageSize;
|
||||
const to = from + signupsPageSize - 1;
|
||||
|
||||
const { data, error, count } = await supabase
|
||||
.from('profiles')
|
||||
.select('id, name, email, avatar_url, created_at, onboarding_completed, couple_id', { count: 'exact' })
|
||||
.order('created_at', { ascending: false })
|
||||
.range(from, to);
|
||||
// Use RPC to get profiles with auth info (includes email from auth.users)
|
||||
const { data: allData, error } = await supabase
|
||||
.rpc('get_profiles_with_auth_info');
|
||||
|
||||
if (error) throw error;
|
||||
setSignups(data || []);
|
||||
setSignupsTotal(count || 0);
|
||||
|
||||
// Sort by created_at descending and apply pagination
|
||||
const sorted = (allData || []).sort((a: SignupActivity, b: SignupActivity) =>
|
||||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||
);
|
||||
|
||||
const from = (signupsPage - 1) * signupsPageSize;
|
||||
const to = from + signupsPageSize;
|
||||
|
||||
setSignups(sorted.slice(from, to));
|
||||
setSignupsTotal(sorted.length);
|
||||
} catch (error) {
|
||||
console.error('Failed to load signups:', error);
|
||||
toast.error("Failed to load signups");
|
||||
@@ -502,14 +507,31 @@ export function UserActivityPage() {
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>{renderUserLink(item.profile)}</TableCell>
|
||||
<TableCell className="max-w-[300px]">
|
||||
<span className="truncate block">
|
||||
{item.question?.text || 'Unknown question'}
|
||||
</span>
|
||||
{item.question?.pack?.id ? (
|
||||
<Link
|
||||
to={`/packs/${item.question.pack.id}/questions`}
|
||||
className="truncate block text-primary hover:underline"
|
||||
>
|
||||
{item.question?.text || 'Unknown question'}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="truncate block">
|
||||
{item.question?.text || 'Unknown question'}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">
|
||||
{item.question?.pack?.name || 'Unknown'}
|
||||
</Badge>
|
||||
{item.question?.pack?.id ? (
|
||||
<Link to={`/packs/${item.question.pack.id}/questions`}>
|
||||
<Badge variant="outline" className="hover:bg-primary/10 cursor-pointer">
|
||||
{item.question.pack.name}
|
||||
</Badge>
|
||||
</Link>
|
||||
) : (
|
||||
<Badge variant="outline">
|
||||
{item.question?.pack?.name || 'Unknown'}
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{renderAnswerBadge(item.answer)}</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm whitespace-nowrap">
|
||||
|
||||
@@ -38,14 +38,17 @@ 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 } from 'lucide-react';
|
||||
import { Crown, Users, MessageCircle, ChevronRight, ThumbsUp, ThumbsDown, Minus, Gift, Image, Video as VideoIcon, Target, User, Eye, EyeOff, CheckCircle, Package, Heart, Sparkles } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { IconPreview } from '@/components/ui/icon-picker';
|
||||
|
||||
type UsageReason = 'improve_communication' | 'spice_up_intimacy' | 'deeper_connection' | 'have_fun' | 'strengthen_relationship';
|
||||
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,6 +61,7 @@ interface Profile {
|
||||
usage_reason: UsageReason | null;
|
||||
show_explicit_content: boolean | null;
|
||||
onboarding_completed: boolean | null;
|
||||
max_intensity: IntensityLevel | null;
|
||||
}
|
||||
|
||||
const usageReasonLabels: Record<UsageReason, { label: string; icon: string }> = {
|
||||
@@ -75,6 +79,14 @@ 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;
|
||||
@@ -88,6 +100,7 @@ interface Response {
|
||||
id: string;
|
||||
text: string;
|
||||
pack: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
@@ -105,6 +118,30 @@ interface Match {
|
||||
message_count?: number;
|
||||
}
|
||||
|
||||
interface EnabledPack {
|
||||
pack_id: string;
|
||||
enabled: boolean;
|
||||
created_at: string | null;
|
||||
pack: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
icon: string | null;
|
||||
is_premium: boolean;
|
||||
is_explicit: boolean;
|
||||
category: {
|
||||
id: string;
|
||||
name: string;
|
||||
} | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface FeatureInterest {
|
||||
id: string;
|
||||
feature_name: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const answerIcons = {
|
||||
yes: <ThumbsUp className="h-4 w-4 text-green-500" />,
|
||||
no: <ThumbsDown className="h-4 w-4 text-red-500" />,
|
||||
@@ -238,6 +275,8 @@ export function UserDetailPage() {
|
||||
const [responses, setResponses] = useState<Response[]>([]);
|
||||
const [matches, setMatches] = useState<Match[]>([]);
|
||||
const [mediaMessages, setMediaMessages] = useState<any[]>([]);
|
||||
const [enabledPacks, setEnabledPacks] = useState<EnabledPack[]>([]);
|
||||
const [featureInterests, setFeatureInterests] = useState<FeatureInterest[]>([]);
|
||||
const [responsesPage, setResponsesPage] = useState(1);
|
||||
const [responsesPageSize, setResponsesPageSize] = useState(10);
|
||||
const [responsesTotal, setResponsesTotal] = useState(0);
|
||||
@@ -311,6 +350,14 @@ export function UserDetailPage() {
|
||||
const { data: profileData } = await supabase.from('profiles').select('*').eq('id', userId).single();
|
||||
setProfile(profileData);
|
||||
|
||||
// Fetch feature interests for the user
|
||||
const { data: featureInterestsData } = await supabase
|
||||
.from('feature_interests')
|
||||
.select('id, feature_name, created_at')
|
||||
.eq('user_id', userId)
|
||||
.order('created_at', { ascending: false });
|
||||
setFeatureInterests((featureInterestsData || []) as FeatureInterest[]);
|
||||
|
||||
if (profileData?.couple_id) {
|
||||
const { data: partnerData } = await supabase.from('profiles').select('id, name').eq('couple_id', profileData.couple_id).neq('id', userId).single();
|
||||
setPartner(partnerData);
|
||||
@@ -323,7 +370,7 @@ export function UserDetailPage() {
|
||||
|
||||
const { data: responseData, count: responsesCount, error: responsesError } = await supabase
|
||||
.from('responses')
|
||||
.select('id, answer, created_at, question:questions(id, text, pack:question_packs(name))', { count: 'exact' })
|
||||
.select('id, answer, created_at, question:questions(id, text, pack:question_packs(id, name))', { count: 'exact' })
|
||||
.eq('user_id', userId)
|
||||
.order('created_at', { ascending: false })
|
||||
.range(responsesFrom, responsesTo);
|
||||
@@ -388,11 +435,39 @@ export function UserDetailPage() {
|
||||
setMediaMessages([]);
|
||||
setMediaTotal(0);
|
||||
}
|
||||
// Fetch enabled packs for the couple
|
||||
const { data: enabledPacksData, error: packsError } = await supabase
|
||||
.from('couple_packs')
|
||||
.select(`
|
||||
pack_id,
|
||||
enabled,
|
||||
created_at,
|
||||
pack:question_packs(
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
icon,
|
||||
is_premium,
|
||||
is_explicit,
|
||||
category:categories(id, name)
|
||||
)
|
||||
`)
|
||||
.eq('couple_id', profileData.couple_id)
|
||||
.eq('enabled', true)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (packsError) {
|
||||
console.error("Error fetching enabled packs:", packsError);
|
||||
setEnabledPacks([]);
|
||||
} else {
|
||||
setEnabledPacks((enabledPacksData || []) as unknown as EnabledPack[]);
|
||||
}
|
||||
} else {
|
||||
setMatches([]);
|
||||
setMatchesTotal(0);
|
||||
setMediaMessages([]);
|
||||
setMediaTotal(0);
|
||||
setEnabledPacks([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load user data:', error);
|
||||
@@ -456,20 +531,46 @@ 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-4">
|
||||
<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">
|
||||
<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.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>
|
||||
{/* Feature Opt-Ins */}
|
||||
{featureInterests.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5 text-primary" />
|
||||
Feature Opt-Ins
|
||||
</CardTitle>
|
||||
<CardDescription>Features the user has expressed interest in</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{featureInterests.map((interest) => (
|
||||
<Badge key={interest.id} variant="secondary" className="capitalize">
|
||||
{interest.feature_name}
|
||||
<span className="ml-1 text-xs text-muted-foreground">
|
||||
({format(new Date(interest.created_at), 'MMM d')})
|
||||
</span>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{/* Tabs */}
|
||||
<Tabs defaultValue={canViewResponses ? "responses" : canViewMatches ? "matches" : canViewMedia ? "media" : "responses"}>
|
||||
<TabsList>
|
||||
{canViewResponses && <TabsTrigger value="responses">Responses ({responsesTotal})</TabsTrigger>}
|
||||
{canViewMatches && <TabsTrigger value="matches" disabled={!profile.couple_id}>Matches ({matchesTotal})</TabsTrigger>}
|
||||
{canViewMedia && <TabsTrigger value="media">Media ({mediaTotal})</TabsTrigger>}
|
||||
<TabsTrigger value="packs" disabled={!profile.couple_id}>Enabled Packs ({enabledPacks.length})</TabsTrigger>
|
||||
</TabsList>
|
||||
{canViewResponses && <TabsContent value="responses" className="mt-4">{responses.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No responses yet</p></Card> : <div className="space-y-4"><div className="rounded-md border"><Table><TableHeader><TableRow><TableHead>Question</TableHead><TableHead>Pack</TableHead><TableHead className="w-24">Answer</TableHead><TableHead className="w-32">Date</TableHead></TableRow></TableHeader><TableBody>{responses.map((r) => (<TableRow key={r.id}><TableCell className="max-w-md"><span className="line-clamp-2">{r.question.text}</span></TableCell><TableCell className="text-muted-foreground">{r.question.pack?.name || '—'}</TableCell><TableCell><div className="flex items-center gap-2">{answerIcons[r.answer]}<span className="capitalize">{r.answer}</span></div></TableCell><TableCell className="text-muted-foreground text-sm">{r.created_at ? format(new Date(r.created_at), 'MMM d, yyyy') : '—'}</TableCell></TableRow>))}</TableBody></Table></div><PaginationControls page={responsesPage} pageSize={responsesPageSize} totalCount={responsesTotal} onPageChange={setResponsesPage} onPageSizeChange={(size) => { setResponsesPage(1); setResponsesPageSize(size); }} /></div>}</TabsContent>}
|
||||
{canViewResponses && <TabsContent value="responses" className="mt-4">{responses.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No responses yet</p></Card> : <div className="space-y-4"><div className="rounded-md border"><Table><TableHeader><TableRow><TableHead>Question</TableHead><TableHead>Pack</TableHead><TableHead className="w-24">Answer</TableHead><TableHead className="w-32">Date</TableHead></TableRow></TableHeader><TableBody>{responses.map((r) => (<TableRow key={r.id}><TableCell className="max-w-md">{r.question.pack?.id ? (<Link to={`/packs/${r.question.pack.id}/questions`} className="line-clamp-2 text-primary hover:underline cursor-pointer">{r.question.text}</Link>) : (<span className="line-clamp-2">{r.question.text}</span>)}</TableCell><TableCell>{r.question.pack?.id ? (<Link to={`/packs/${r.question.pack.id}/questions`} className="text-primary hover:underline">{r.question.pack.name}</Link>) : (<span className="text-muted-foreground">{r.question.pack?.name || '—'}</span>)}</TableCell><TableCell><div className="flex items-center gap-2">{answerIcons[r.answer]}<span className="capitalize">{r.answer}</span></div></TableCell><TableCell className="text-muted-foreground text-sm">{r.created_at ? format(new Date(r.created_at), 'MMM d, yyyy') : '—'}</TableCell></TableRow>))}</TableBody></Table></div><PaginationControls page={responsesPage} pageSize={responsesPageSize} totalCount={responsesTotal} onPageChange={setResponsesPage} onPageSizeChange={(size) => { setResponsesPage(1); setResponsesPageSize(size); }} /></div>}</TabsContent>}
|
||||
{canViewMatches && <TabsContent value="matches" className="mt-4">{matches.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No matches yet</p></Card> : <div className="space-y-4"><div className="grid gap-4 md:grid-cols-2">{matches.map((match) => (<Card key={match.id} className="hover:shadow-md transition-shadow"><CardHeader className="pb-2"><div className="flex items-start justify-between"><Badge variant="secondary">{matchTypeLabels[match.match_type]}</Badge>{match.is_new && <Badge variant="default" className="text-xs">New</Badge>}</div><CardDescription className="line-clamp-2 mt-2">{match.question?.text || 'Unknown question'}</CardDescription></CardHeader><CardContent><div className="flex items-center justify-between"><div className="flex items-center gap-1 text-sm text-muted-foreground"><MessageCircle className="h-4 w-4" />{match.message_count} message{match.message_count !== 1 ? 's' : ''}</div>{canViewChats && <Link to={`/users/${userId}/matches/${match.id}`}><Button variant="ghost" size="sm">View Chat<ChevronRight className="ml-1 h-4 w-4" /></Button></Link>}</div></CardContent></Card>))}</div><PaginationControls page={matchesPage} pageSize={matchesPageSize} totalCount={matchesTotal} onPageChange={setMatchesPage} onPageSizeChange={(size) => { setMatchesPage(1); setMatchesPageSize(size); }} /></div>}</TabsContent>}
|
||||
{canViewMedia && <TabsContent value="media" className="mt-4">{mediaMessages.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><Image className="h-12 w-12 text-muted-foreground mb-4" /><p className="text-muted-foreground">No media uploaded</p></Card> : <div className="space-y-4"><div className="grid gap-4 grid-cols-2 md:grid-cols-3 lg:grid-cols-4">{mediaMessages.map((message) => (<Card key={message.id} className="overflow-hidden"><div className="aspect-square bg-muted relative">
|
||||
{message.media_type === 'video' ? (
|
||||
@@ -478,6 +579,59 @@ export function UserDetailPage() {
|
||||
<AdminDecryptedImage messageId={message.id} alt={`Media from ${message.created_at}`} />
|
||||
)}
|
||||
</div><CardContent className="p-3"><div className="flex items-center justify-between text-sm"><div className="truncate text-muted-foreground">{message.media_type === 'video' ? 'Video' : 'Image'}</div>{canViewChats && message.match_id && <Link to={`/users/${userId}/matches/${message.match_id}`} className="text-primary hover:underline text-xs">View Chat</Link>}</div><div className="text-xs text-muted-foreground mt-1">{message.created_at ? format(new Date(message.created_at), 'MMM d, yyyy') : '—'}</div></CardContent></Card>))}</div><PaginationControls page={mediaPage} pageSize={mediaPageSize} totalCount={mediaTotal} onPageChange={setMediaPage} onPageSizeChange={(size) => { setMediaPage(1); setMediaPageSize(size); }} /></div>}</TabsContent>}
|
||||
<TabsContent value="packs" className="mt-4">
|
||||
{enabledPacks.length === 0 ? (
|
||||
<Card className="flex flex-col items-center justify-center py-12">
|
||||
<Package className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground">No packs enabled</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{enabledPacks.map((ep) => (
|
||||
<Link key={ep.pack_id} to={`/packs/${ep.pack.id}/questions`}>
|
||||
<Card className="hover:shadow-md transition-shadow cursor-pointer">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<IconPreview value={ep.pack.icon} fallback="heart-outline" className="text-2xl" />
|
||||
<CardTitle className="text-base">{ep.pack.name}</CardTitle>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{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 && (
|
||||
<CardDescription className="line-clamp-2 mt-2">
|
||||
{ep.pack.description}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{ep.pack.category?.name || 'Uncategorized'}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Enabled {ep.created_at ? format(new Date(ep.created_at), 'MMM d, yyyy') : '—'}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user