Skip to content

Commit 7edc68c

Browse files
committed
Better home page
1 parent 776fc47 commit 7edc68c

File tree

3 files changed

+263
-236
lines changed

3 files changed

+263
-236
lines changed

app/actions/deckInsights.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
'use server';
2+
3+
import { ObjectId } from 'mongodb';
4+
import { connectToDatabase } from '@/lib/db';
5+
import type { DeckDocument, ReviewEventDocument, CardDocument } from '@/types';
6+
7+
interface RecentDeckItem {
8+
deckId: string;
9+
deckName: string;
10+
lastPlayedAt: Date;
11+
}
12+
13+
interface RecentDecksResult {
14+
success: boolean;
15+
recents?: RecentDeckItem[];
16+
message?: string;
17+
}
18+
19+
export async function getRecentlyPlayedDecksAction(limit = 5): Promise<RecentDecksResult> {
20+
try {
21+
const { db } = await connectToDatabase();
22+
const reviews = db.collection<ReviewEventDocument>('review_events');
23+
const cards = db.collection<CardDocument>('cards');
24+
const decks = db.collection<DeckDocument>('decks');
25+
26+
// Aggregate latest review per deck via card join
27+
const pipeline = [
28+
// Join cards to get deck_id
29+
{
30+
$lookup: {
31+
from: 'cards',
32+
localField: 'card_id',
33+
foreignField: '_id',
34+
as: 'card',
35+
},
36+
},
37+
{ $unwind: '$card' },
38+
// Group by deck_id to get max timestamp
39+
{
40+
$group: {
41+
_id: '$card.deck_id',
42+
lastPlayedAt: { $max: '$timestamp' },
43+
},
44+
},
45+
{ $sort: { lastPlayedAt: -1 } },
46+
{ $limit: limit },
47+
];
48+
49+
const grouped = await reviews.aggregate<{ _id: ObjectId; lastPlayedAt: Date }>(pipeline).toArray();
50+
51+
if (grouped.length === 0) {
52+
return { success: true, recents: [] };
53+
}
54+
55+
const deckIds = grouped.map((g) => g._id);
56+
const deckDocs = await decks
57+
.find({ _id: { $in: deckIds } }, { projection: { name: 1 } })
58+
.toArray();
59+
const nameMap = new Map<string, string>();
60+
deckDocs.forEach((d) => nameMap.set(d._id.toString(), d.name));
61+
62+
const recents: RecentDeckItem[] = grouped.map((g) => ({
63+
deckId: g._id.toString(),
64+
deckName: nameMap.get(g._id.toString()) || 'Untitled Deck',
65+
lastPlayedAt: g.lastPlayedAt,
66+
}));
67+
68+
return { success: true, recents };
69+
} catch (err) {
70+
console.error('[Deck Insights] Failed to fetch recent decks', err);
71+
return { success: false, message: 'Failed to fetch recent decks.' };
72+
}
73+
}
74+

app/decks/page.tsx

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
'use client';
2+
3+
import React, { useState, useEffect } from 'react';
4+
import Link from 'next/link';
5+
import Button from '@/components/Button';
6+
import { useDecks, useImportDeckMutation } from '@/hooks/queryHooks';
7+
import type { Deck } from '@/types';
8+
import { getRecentlyPlayedDecksAction } from '@/actions/deckInsights';
9+
import { useAuth } from '@/context/useAuth';
10+
11+
interface ImportDeckModalProps {
12+
isOpen: boolean;
13+
onClose: () => void;
14+
onSubmit: (deckName: string, cardsData: { front: string; back: string }[]) => void;
15+
isLoading: boolean;
16+
}
17+
18+
function ImportDeckModal({ isOpen, onClose, onSubmit, isLoading }: ImportDeckModalProps) {
19+
const [deckName, setDeckName] = useState('');
20+
const [jsonInput, setJsonInput] = useState('');
21+
const [error, setError] = useState<string | null>(null);
22+
23+
const handleSubmit = (e: React.FormEvent) => {
24+
e.preventDefault();
25+
setError(null);
26+
if (!deckName.trim()) { setError('Deck name is required'); return; }
27+
let parsed: unknown;
28+
try { parsed = JSON.parse(jsonInput); } catch { setError('Invalid JSON'); return; }
29+
if (!Array.isArray(parsed)) { setError('JSON must be an array of cards'); return; }
30+
const cards = (parsed as any[]).map((c, i) => {
31+
if (!c || typeof c.front !== 'string' || typeof c.back !== 'string') {
32+
throw new Error(`Card ${i + 1} is missing front/back strings`);
33+
}
34+
return { front: c.front.trim(), back: c.back.trim() };
35+
});
36+
onSubmit(deckName.trim(), cards);
37+
};
38+
39+
const close = () => { setDeckName(''); setJsonInput(''); setError(null); onClose(); };
40+
if (!isOpen) return null;
41+
42+
return (
43+
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
44+
<div className="bg-white rounded-lg shadow-xl w-full max-w-lg p-6">
45+
<h2 className="text-lg font-semibold mb-4">Import Deck</h2>
46+
<form onSubmit={handleSubmit} className="space-y-3">
47+
<div>
48+
<label className="text-sm text-gray-700 mb-1 block">Deck Name</label>
49+
<input className="w-full border rounded px-3 py-2" value={deckName} onChange={(e) => setDeckName(e.target.value)} disabled={isLoading} />
50+
</div>
51+
<div>
52+
<label className="text-sm text-gray-700 mb-1 block">Cards JSON</label>
53+
<textarea className="w-full h-40 border rounded px-3 py-2 font-mono text-sm" value={jsonInput} onChange={(e) => setJsonInput(e.target.value)} disabled={isLoading} />
54+
<p className="text-xs text-gray-500 mt-1">[{`{ "front": "Question", "back": "Answer" }`}, ...]</p>
55+
</div>
56+
{error && <p className="text-sm text-red-600">{error}</p>}
57+
<div className="flex justify-end gap-2 pt-2">
58+
<Button type="button" variant="default" onClick={close} disabled={isLoading}>Cancel</Button>
59+
<Button type="submit" variant="primary" disabled={isLoading || !deckName.trim() || !jsonInput.trim()}>{isLoading ? 'Importing…' : 'Import'}</Button>
60+
</div>
61+
</form>
62+
</div>
63+
</div>
64+
);
65+
}
66+
67+
export default function DecksHomePage() {
68+
const { data: decks, isLoading } = useDecks();
69+
const importMutation = useImportDeckMutation();
70+
const { token } = useAuth();
71+
const [modalOpen, setModalOpen] = useState(false);
72+
const [recents, setRecents] = useState<{ deckId: string; deckName: string; lastPlayedAt: string }[]>([]);
73+
74+
useEffect(() => {
75+
(async () => {
76+
const res = await getRecentlyPlayedDecksAction(6);
77+
if (res.success && res.recents) {
78+
setRecents(res.recents.map(r => ({ ...r, lastPlayedAt: new Date(r.lastPlayedAt).toISOString() })));
79+
}
80+
})();
81+
}, []);
82+
83+
const handleImport = (deckName: string, cardsData: { front: string; back: string }[]) => {
84+
if (!token) return alert('Please login to import decks.');
85+
importMutation.mutate({ deckName, cardsData, token }, {
86+
onSuccess: () => { setModalOpen(false); },
87+
onError: (e) => alert(e.message || 'Import failed'),
88+
});
89+
};
90+
91+
return (
92+
<div className="space-y-8">
93+
<div className="flex items-center justify-between">
94+
<h1 className="text-3xl font-bold text-gray-800">Decks</h1>
95+
<div className="flex gap-2">
96+
<Link href="/play/select" passHref legacyBehavior>
97+
<Button as="a" variant="secondary">Play Selected</Button>
98+
</Link>
99+
<Link href="/deck/ai-generate" passHref legacyBehavior>
100+
<Button as="a" variant="secondary">Create with AI</Button>
101+
</Link>
102+
<Button variant="primary" onClick={() => setModalOpen(true)}>Import Deck</Button>
103+
</div>
104+
</div>
105+
106+
{recents.length > 0 && (
107+
<section>
108+
<h2 className="text-xl font-semibold mb-3">Recently Played</h2>
109+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
110+
{recents.map(r => (
111+
<div key={r.deckId} className="border rounded-lg p-4 bg-white shadow-sm flex items-center justify-between">
112+
<div>
113+
<div className="font-medium text-gray-800">{r.deckName}</div>
114+
<div className="text-xs text-gray-500">Last played: {new Date(r.lastPlayedAt).toLocaleString()}</div>
115+
</div>
116+
<Link href={`/deck/${r.deckId}/play`} passHref legacyBehavior>
117+
<Button as="a" variant="primary" size="sm">Play</Button>
118+
</Link>
119+
</div>
120+
))}
121+
</div>
122+
</section>
123+
)}
124+
125+
<section>
126+
<h2 className="text-xl font-semibold mb-3">All Decks</h2>
127+
{isLoading && <div className="text-gray-500">Loading decks…</div>}
128+
{!isLoading && decks && decks.length === 0 && (
129+
<div className="text-gray-500">No decks yet. Import or create one above.</div>
130+
)}
131+
{!isLoading && decks && decks.length > 0 && (
132+
<ul className="space-y-2">
133+
{decks.map((deck: Deck) => (
134+
<li key={deck.id} className="bg-white border rounded-lg shadow-sm p-4 flex items-center justify-between">
135+
<div className="font-medium text-gray-800 mr-4 truncate">{deck.name}</div>
136+
<div className="flex gap-2">
137+
<Link href={`/deck/${deck.id}/overview`} passHref legacyBehavior>
138+
<Button as="a" variant="default" size="sm">Open</Button>
139+
</Link>
140+
<Link href={`/deck/${deck.id}/play`} passHref legacyBehavior>
141+
<Button as="a" variant="primary" size="sm">Play</Button>
142+
</Link>
143+
</div>
144+
</li>
145+
))}
146+
</ul>
147+
)}
148+
</section>
149+
150+
<ImportDeckModal isOpen={modalOpen} onClose={() => setModalOpen(false)} onSubmit={handleImport} isLoading={importMutation.isPending} />
151+
</div>
152+
);
153+
}
154+

0 commit comments

Comments
 (0)