Add minimize group feature with local storage persistence
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m10s

This commit is contained in:
Elijah 2026-07-10 16:15:51 -07:00
parent 7af0935657
commit 3cb9634524
2 changed files with 112 additions and 50 deletions

View file

@ -174,6 +174,22 @@ export default function FlashcardsPage() {
const [newGroupName, setNewGroupName] = useState(""); const [newGroupName, setNewGroupName] = useState("");
const [editingGroupId, setEditingGroupId] = useState<string | null>(null); const [editingGroupId, setEditingGroupId] = useState<string | null>(null);
const [editGroupName, setEditGroupName] = useState(""); const [editGroupName, setEditGroupName] = useState("");
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
useEffect(() => {
const saved = localStorage.getItem('flashcards_collapsed_groups');
if (saved) {
try { setCollapsedGroups(JSON.parse(saved)); } catch(e) {}
}
}, []);
function toggleGroup(id: string) {
setCollapsedGroups(prev => {
const next = { ...prev, [id]: !prev[id] };
localStorage.setItem('flashcards_collapsed_groups', JSON.stringify(next));
return next;
});
}
const fetchAll = useCallback(async (cId: string) => { const fetchAll = useCallback(async (cId: string) => {
try { try {
@ -430,7 +446,7 @@ export default function FlashcardsPage() {
<div className="space-y-8"> <div className="space-y-8">
{groupedDecks.map(group => ( {groupedDecks.map(group => (
<div key={group.id} className="bg-bg-surface-alt/30 p-4 rounded-xl border border-border-light"> <div key={group.id} className="bg-bg-surface-alt/30 p-4 rounded-xl border border-border-light">
<div className="flex items-center justify-between mb-4"> <div className={`flex items-center justify-between ${collapsedGroups[group.id] ? "" : "mb-4"}`}>
{editingGroupId === group.id ? ( {editingGroupId === group.id ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input autoFocus value={editGroupName} onChange={e => setEditGroupName(e.target.value)} className="px-2 py-1 rounded border border-border bg-bg-surface text-text-heading focus:ring-1 focus:ring-primary" /> <input autoFocus value={editGroupName} onChange={e => setEditGroupName(e.target.value)} className="px-2 py-1 rounded border border-border bg-bg-surface text-text-heading focus:ring-1 focus:ring-primary" />
@ -438,7 +454,12 @@ export default function FlashcardsPage() {
<button onClick={() => setEditingGroupId(null)} className="text-xs px-2 py-1 bg-bg-surface text-text-heading rounded border border-border-light">Cancel</button> <button onClick={() => setEditingGroupId(null)} className="text-xs px-2 py-1 bg-bg-surface text-text-heading rounded border border-border-light">Cancel</button>
</div> </div>
) : ( ) : (
<h3 className="text-lg font-semibold text-text-heading">{group.name}</h3> <div className="flex items-center gap-2">
<button onClick={() => toggleGroup(group.id)} className="p-1 rounded text-text-muted hover:text-text-heading hover:bg-bg-surface transition-colors cursor-pointer" title={collapsedGroups[group.id] ? "Expand" : "Collapse"}>
<svg className={`w-5 h-5 transition-transform duration-200 ${collapsedGroups[group.id] ? '-rotate-90' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
</button>
<h3 className="text-lg font-semibold text-text-heading">{group.name}</h3>
</div>
)} )}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -448,34 +469,44 @@ export default function FlashcardsPage() {
</div> </div>
</div> </div>
<SortableContext id={group.id} items={group.decks.map(d => d.id)} strategy={verticalListSortingStrategy}> <div className={collapsedGroups[group.id] ? "hidden" : "block"}>
<DroppableContainer id={group.id}> <SortableContext id={group.id} items={group.decks.map(d => d.id)} strategy={verticalListSortingStrategy}>
{group.decks.length === 0 ? ( <DroppableContainer id={group.id}>
<div className="col-span-1 md:col-span-2 text-center py-4 text-text-muted text-sm border-2 border-dashed border-border-light rounded-lg">Drop decks here</div> {group.decks.length === 0 ? (
) : ( <div className="col-span-1 md:col-span-2 text-center py-4 text-text-muted text-sm border-2 border-dashed border-border-light rounded-lg">Drop decks here</div>
group.decks.map(deck => ( ) : (
<SortableDeckCard key={deck.id} deck={deck} onEdit={(d: DeckItem) => { setEditingId(d.id); setEditName(d.name); setEditDescription(d.description || ""); }} onDelete={handleDeleteDeck} classSlug={classSlug} /> group.decks.map(deck => (
)) <SortableDeckCard key={deck.id} deck={deck} onEdit={(d: DeckItem) => { setEditingId(d.id); setEditName(d.name); setEditDescription(d.description || ""); }} onDelete={handleDeleteDeck} classSlug={classSlug} />
)} ))
</DroppableContainer> )}
</SortableContext> </DroppableContainer>
</SortableContext>
</div>
</div> </div>
))} ))}
{/* Uncategorized */} {/* Uncategorized */}
<div className="bg-bg-surface-alt/30 p-4 rounded-xl border border-border-light"> <div className="bg-bg-surface-alt/30 p-4 rounded-xl border border-border-light">
<h3 className="text-lg font-semibold text-text-heading mb-4">Uncategorized</h3> <div className={`flex items-center gap-2 ${collapsedGroups["uncategorized"] ? "" : "mb-4"}`}>
<SortableContext id="uncategorized" items={uncategorizedDecks.map(d => d.id)} strategy={verticalListSortingStrategy}> <button onClick={() => toggleGroup("uncategorized")} className="p-1 rounded text-text-muted hover:text-text-heading hover:bg-bg-surface transition-colors cursor-pointer" title={collapsedGroups["uncategorized"] ? "Expand" : "Collapse"}>
<DroppableContainer id="uncategorized"> <svg className={`w-5 h-5 transition-transform duration-200 ${collapsedGroups["uncategorized"] ? '-rotate-90' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
{uncategorizedDecks.length === 0 ? ( </button>
<div className="col-span-1 md:col-span-2 text-center py-4 text-text-muted text-sm border-2 border-dashed border-border-light rounded-lg">No uncategorized decks</div> <h3 className="text-lg font-semibold text-text-heading">Uncategorized</h3>
) : ( </div>
uncategorizedDecks.map(deck => (
<SortableDeckCard key={deck.id} deck={deck} onEdit={(d: DeckItem) => { setEditingId(d.id); setEditName(d.name); setEditDescription(d.description || ""); }} onDelete={handleDeleteDeck} classSlug={classSlug} /> <div className={collapsedGroups["uncategorized"] ? "hidden" : "block"}>
)) <SortableContext id="uncategorized" items={uncategorizedDecks.map(d => d.id)} strategy={verticalListSortingStrategy}>
)} <DroppableContainer id="uncategorized">
</DroppableContainer> {uncategorizedDecks.length === 0 ? (
</SortableContext> <div className="col-span-1 md:col-span-2 text-center py-4 text-text-muted text-sm border-2 border-dashed border-border-light rounded-lg">No uncategorized decks</div>
) : (
uncategorizedDecks.map(deck => (
<SortableDeckCard key={deck.id} deck={deck} onEdit={(d: DeckItem) => { setEditingId(d.id); setEditName(d.name); setEditDescription(d.description || ""); }} onDelete={handleDeleteDeck} classSlug={classSlug} />
))
)}
</DroppableContainer>
</SortableContext>
</div>
</div> </div>
</div> </div>

View file

@ -130,6 +130,22 @@ export default function QuizzesPage() {
const [newGroupName, setNewGroupName] = useState(""); const [newGroupName, setNewGroupName] = useState("");
const [editingGroupId, setEditingGroupId] = useState<string | null>(null); const [editingGroupId, setEditingGroupId] = useState<string | null>(null);
const [editGroupName, setEditGroupName] = useState(""); const [editGroupName, setEditGroupName] = useState("");
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
useEffect(() => {
const saved = localStorage.getItem('quizzes_collapsed_groups');
if (saved) {
try { setCollapsedGroups(JSON.parse(saved)); } catch(e) {}
}
}, []);
function toggleGroup(id: string) {
setCollapsedGroups(prev => {
const next = { ...prev, [id]: !prev[id] };
localStorage.setItem('quizzes_collapsed_groups', JSON.stringify(next));
return next;
});
}
const fetchAll = useCallback(async (cId: string) => { const fetchAll = useCallback(async (cId: string) => {
try { try {
@ -403,7 +419,7 @@ export default function QuizzesPage() {
<div className="space-y-8"> <div className="space-y-8">
{groupedQuizzes.map(group => ( {groupedQuizzes.map(group => (
<div key={group.id} className="bg-bg-surface-alt/30 p-4 rounded-xl border border-border-light"> <div key={group.id} className="bg-bg-surface-alt/30 p-4 rounded-xl border border-border-light">
<div className="flex items-center justify-between mb-4"> <div className={`flex items-center justify-between ${collapsedGroups[group.id] ? "" : "mb-4"}`}>
{editingGroupId === group.id ? ( {editingGroupId === group.id ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input autoFocus value={editGroupName} onChange={e => setEditGroupName(e.target.value)} className="px-2 py-1 rounded border border-border bg-bg-surface text-text-heading focus:ring-1 focus:ring-primary" /> <input autoFocus value={editGroupName} onChange={e => setEditGroupName(e.target.value)} className="px-2 py-1 rounded border border-border bg-bg-surface text-text-heading focus:ring-1 focus:ring-primary" />
@ -411,7 +427,12 @@ export default function QuizzesPage() {
<button onClick={() => setEditingGroupId(null)} className="text-xs px-2 py-1 bg-bg-surface text-text-heading rounded border border-border-light">Cancel</button> <button onClick={() => setEditingGroupId(null)} className="text-xs px-2 py-1 bg-bg-surface text-text-heading rounded border border-border-light">Cancel</button>
</div> </div>
) : ( ) : (
<h3 className="text-lg font-semibold text-text-heading">{group.name}</h3> <div className="flex items-center gap-2">
<button onClick={() => toggleGroup(group.id)} className="p-1 rounded text-text-muted hover:text-text-heading hover:bg-bg-surface transition-colors cursor-pointer" title={collapsedGroups[group.id] ? "Expand" : "Collapse"}>
<svg className={`w-5 h-5 transition-transform duration-200 ${collapsedGroups[group.id] ? '-rotate-90' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
</button>
<h3 className="text-lg font-semibold text-text-heading">{group.name}</h3>
</div>
)} )}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -421,34 +442,44 @@ export default function QuizzesPage() {
</div> </div>
</div> </div>
<SortableContext id={group.id} items={group.quizzes.map(q => q.id)} strategy={verticalListSortingStrategy}> <div className={collapsedGroups[group.id] ? "hidden" : "block"}>
<DroppableContainer id={group.id}> <SortableContext id={group.id} items={group.quizzes.map(q => q.id)} strategy={verticalListSortingStrategy}>
{group.quizzes.length === 0 ? ( <DroppableContainer id={group.id}>
<div className="col-span-1 md:col-span-2 text-center py-4 text-text-muted text-sm border-2 border-dashed border-border-light rounded-lg">Drop quizzes here</div> {group.quizzes.length === 0 ? (
) : ( <div className="col-span-1 md:col-span-2 text-center py-4 text-text-muted text-sm border-2 border-dashed border-border-light rounded-lg">Drop quizzes here</div>
group.quizzes.map(quiz => ( ) : (
<SortableQuizCard key={quiz.id} quiz={quiz} onEdit={(q: QuizItem) => { setEditingId(q.id); setEditName(q.name); setEditDescription(q.description || ""); }} onDelete={handleDeleteQuiz} classSlug={classSlug} /> group.quizzes.map(quiz => (
)) <SortableQuizCard key={quiz.id} quiz={quiz} onEdit={(q: QuizItem) => { setEditingId(q.id); setEditName(q.name); setEditDescription(q.description || ""); }} onDelete={handleDeleteQuiz} classSlug={classSlug} />
)} ))
</DroppableContainer> )}
</SortableContext> </DroppableContainer>
</SortableContext>
</div>
</div> </div>
))} ))}
{/* Uncategorized */} {/* Uncategorized */}
<div className="bg-bg-surface-alt/30 p-4 rounded-xl border border-border-light"> <div className="bg-bg-surface-alt/30 p-4 rounded-xl border border-border-light">
<h3 className="text-lg font-semibold text-text-heading mb-4">Uncategorized</h3> <div className={`flex items-center gap-2 ${collapsedGroups["uncategorized"] ? "" : "mb-4"}`}>
<SortableContext id="uncategorized" items={uncategorizedQuizzes.map(q => q.id)} strategy={verticalListSortingStrategy}> <button onClick={() => toggleGroup("uncategorized")} className="p-1 rounded text-text-muted hover:text-text-heading hover:bg-bg-surface transition-colors cursor-pointer" title={collapsedGroups["uncategorized"] ? "Expand" : "Collapse"}>
<DroppableContainer id="uncategorized"> <svg className={`w-5 h-5 transition-transform duration-200 ${collapsedGroups["uncategorized"] ? '-rotate-90' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
{uncategorizedQuizzes.length === 0 ? ( </button>
<div className="col-span-1 md:col-span-2 text-center py-4 text-text-muted text-sm border-2 border-dashed border-border-light rounded-lg">No uncategorized quizzes</div> <h3 className="text-lg font-semibold text-text-heading">Uncategorized</h3>
) : ( </div>
uncategorizedQuizzes.map(quiz => (
<SortableQuizCard key={quiz.id} quiz={quiz} onEdit={(q: QuizItem) => { setEditingId(q.id); setEditName(q.name); setEditDescription(q.description || ""); }} onDelete={handleDeleteQuiz} classSlug={classSlug} /> <div className={collapsedGroups["uncategorized"] ? "hidden" : "block"}>
)) <SortableContext id="uncategorized" items={uncategorizedQuizzes.map(q => q.id)} strategy={verticalListSortingStrategy}>
)} <DroppableContainer id="uncategorized">
</DroppableContainer> {uncategorizedQuizzes.length === 0 ? (
</SortableContext> <div className="col-span-1 md:col-span-2 text-center py-4 text-text-muted text-sm border-2 border-dashed border-border-light rounded-lg">No uncategorized quizzes</div>
) : (
uncategorizedQuizzes.map(quiz => (
<SortableQuizCard key={quiz.id} quiz={quiz} onEdit={(q: QuizItem) => { setEditingId(q.id); setEditName(q.name); setEditDescription(q.description || ""); }} onDelete={handleDeleteQuiz} classSlug={classSlug} />
))
)}
</DroppableContainer>
</SortableContext>
</div>
</div> </div>
</div> </div>