Freezmo: self-hosted notes-to-activities study app
Next.js 15 app that turns photos, PDFs, DOCX/PPTX and text into a swipeable feed of AI-generated activities (flashcards, multiple choice, true/false, fill-in-the-blank, matching, ordering, short answer) using the user's own Ollama models. Highlights: - All AI calls run in server route handlers; the browser never talks to Ollama. - Bring-your-own-key mode (default): the key lives only in the browser and is sent per-request; server, local-daemon and shared-key modes also supported. - Client-side document extraction (pdf.js, mammoth, jszip) with scanned-PDF page rendering for the vision model; notes stay in their original language. - ChatGPT-style shell: collapsible sidebar / mobile drawer, neutral token-based light + dark themes, folder organisation, PWA (manifest, SW, offline page). - Strict JSON validation with zod + auto-repair, rate limiting, and request guards (cross-origin, body-size, API-key sanitising). - setup.sh bootstraps deps, .env.local and a production build. Gitignore excludes .env*, the generated pdf.js worker and local scratch files.
This commit is contained in:
parent
3baa0fa8ce
commit
3ea2cea100
83 changed files with 10909 additions and 165 deletions
407
components/DeckCard.tsx
Normal file
407
components/DeckCard.tsx
Normal file
|
|
@ -0,0 +1,407 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
import { ApiError, generateDeck } from "@/lib/client-api";
|
||||
import { activityTypes, materialiseActivities } from "@/lib/deck-ops";
|
||||
import { createFolder as makeFolder, folderName, listFolders } from "@/lib/folders";
|
||||
import { UNORGANIZED_ID, type Deck } from "@/lib/schemas";
|
||||
import { appendActivities, deleteDeck, downloadDeck, renameDeck, upsertDeck } from "@/lib/storage";
|
||||
import { cn, formatDate, truncate } from "@/lib/utils";
|
||||
import { DownloadIcon, FolderIcon, PlayIcon, RefreshIcon } from "@/components/icons";
|
||||
import { Button, ProgressBar } from "@/components/ui";
|
||||
|
||||
type Busy = null | "more" | "regenerate";
|
||||
|
||||
const ADD_OPTIONS = [3, 5, 8];
|
||||
|
||||
/**
|
||||
* One deck in the dashboard grid: title, muted metadata, tags, summary and a
|
||||
* prominent Study CTA with quiet secondary actions.
|
||||
*/
|
||||
export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: string | undefined) => void }) {
|
||||
const folders = listFolders();
|
||||
const [busy, setBusy] = useState<Busy>(null);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const [title, setTitle] = useState(deck.title);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [addCount, setAddCount] = useState(5);
|
||||
const [newFolder, setNewFolder] = useState<string | null>(null);
|
||||
|
||||
const hasSource = deck.sourceText.trim().length > 20;
|
||||
|
||||
const saveTitle = () => {
|
||||
const trimmed = title.trim();
|
||||
if (trimmed && trimmed !== deck.title) renameDeck(deck.id, trimmed);
|
||||
else setTitle(deck.title);
|
||||
setRenaming(false);
|
||||
setMenuOpen(false);
|
||||
};
|
||||
|
||||
const addMore = async () => {
|
||||
if (!hasSource) {
|
||||
setError("This deck has no stored source notes, so it can't be extended.");
|
||||
return;
|
||||
}
|
||||
setBusy("more");
|
||||
setError(null);
|
||||
try {
|
||||
const { deck: generated } = await generateDeck({
|
||||
text: deck.sourceText,
|
||||
difficulty: deck.difficulty,
|
||||
language: deck.language,
|
||||
count: addCount,
|
||||
mode: "more",
|
||||
existing: { title: deck.title, types: activityTypes(deck.activities), count: deck.activities.length },
|
||||
});
|
||||
appendActivities(deck.id, materialiseActivities(generated.activities, deck.difficulty));
|
||||
setNotice(`Added ${generated.activities.length} activities`);
|
||||
setMenuOpen(false);
|
||||
window.setTimeout(() => setNotice(null), 3000);
|
||||
} catch (addError) {
|
||||
setError(addError instanceof ApiError ? addError.message : "Could not add more activities.");
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const regenerate = async () => {
|
||||
if (!hasSource) {
|
||||
setError("This deck has no stored source notes, so it can't be regenerated.");
|
||||
return;
|
||||
}
|
||||
setBusy("regenerate");
|
||||
setError(null);
|
||||
try {
|
||||
const { deck: generated } = await generateDeck({
|
||||
text: deck.sourceText,
|
||||
difficulty: deck.difficulty,
|
||||
language: deck.language,
|
||||
count: Math.max(8, deck.activities.length),
|
||||
});
|
||||
upsertDeck({
|
||||
...deck,
|
||||
title: generated.title || deck.title,
|
||||
subject: generated.subject ?? deck.subject,
|
||||
summary: generated.summary ?? deck.summary,
|
||||
language: generated.language ?? deck.language,
|
||||
activities: materialiseActivities(generated.activities, deck.difficulty),
|
||||
attempts: 0,
|
||||
bestScore: undefined,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
setNotice("Deck regenerated");
|
||||
setMenuOpen(false);
|
||||
window.setTimeout(() => setNotice(null), 3000);
|
||||
} catch (regenerateError) {
|
||||
setError(regenerateError instanceof ApiError ? regenerateError.message : "Could not regenerate the deck.");
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const menuRow =
|
||||
"flex w-full items-center gap-2.5 rounded-xl px-3 py-2 text-left text-[13px] font-medium text-[var(--text)] transition hover:bg-[var(--hover)]";
|
||||
|
||||
return (
|
||||
<li className="card card-hover group relative flex flex-col p-5">
|
||||
{/* header ------------------------------------------------------- */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
{renaming ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="sr-only" htmlFor={`rename-${deck.id}`}>
|
||||
Deck title
|
||||
</label>
|
||||
<input
|
||||
id={`rename-${deck.id}`}
|
||||
value={title}
|
||||
autoFocus
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
if (event.key === "Enter") saveTitle();
|
||||
if (event.key === "Escape") {
|
||||
setTitle(deck.title);
|
||||
setRenaming(false);
|
||||
}
|
||||
}}
|
||||
className="min-w-0 flex-1 rounded-lg border border-[var(--hairline)] bg-[var(--surface-strong)] px-2.5 py-1.5 text-[15px] font-semibold text-[var(--text)] outline-none focus:border-accent-400"
|
||||
/>
|
||||
<Button size="sm" onClick={saveTitle}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<h3
|
||||
className="line-clamp-2 text-[15px] font-semibold leading-snug tracking-[-0.01em] text-[var(--text)]"
|
||||
title={deck.title}
|
||||
>
|
||||
{truncate(deck.title, 90)}
|
||||
</h3>
|
||||
)}
|
||||
<p className="mt-1 text-[12.5px] text-[var(--text-subtle)]">
|
||||
{formatDate(deck.updatedAt)}
|
||||
<span aria-hidden> · </span>
|
||||
{deck.activities.length} activities
|
||||
{deck.attempts > 0 ? (
|
||||
<>
|
||||
<span aria-hidden> · </span>
|
||||
{deck.attempts} {deck.attempts === 1 ? "run" : "runs"}
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Deck actions"
|
||||
aria-expanded={menuOpen}
|
||||
onClick={() => setMenuOpen((value) => !value)}
|
||||
className={cn(
|
||||
"grid size-8 shrink-0 place-items-center rounded-full text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]",
|
||||
menuOpen && "bg-[var(--hover)] text-[var(--text)]",
|
||||
)}
|
||||
>
|
||||
⋯
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* tags --------------------------------------------------------- */}
|
||||
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
||||
{deck.subject ? <Tag>{deck.subject}</Tag> : null}
|
||||
<Tag>
|
||||
<span className={difficultyClass(deck.difficulty)}>{deck.difficulty}</span>
|
||||
</Tag>
|
||||
{deck.folderId ? (
|
||||
<Tag>
|
||||
<FolderIcon className="size-3.5 opacity-70" />
|
||||
{folderName(deck.folderId)}
|
||||
</Tag>
|
||||
) : null}
|
||||
{deck.bestScore !== undefined ? (
|
||||
<Tag>
|
||||
Best <span className="text-emerald-700 dark:text-emerald-300">{deck.bestScore}%</span>
|
||||
</Tag>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* summary ------------------------------------------------------ */}
|
||||
{deck.summary ? (
|
||||
<p className="mt-3 line-clamp-2 text-[13.5px] leading-relaxed text-[var(--text-muted)]">
|
||||
{truncate(deck.summary, 160)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{/* status ------------------------------------------------------- */}
|
||||
{busy ? (
|
||||
<div className="mt-4 space-y-2">
|
||||
<ProgressBar value={1} max={2} label={busy === "more" ? "Adding activities" : "Regenerating deck"} />
|
||||
<p className="text-[12.5px] font-medium text-[var(--text-muted)]">
|
||||
{busy === "more" ? "Adding new activities…" : "Regenerating the whole deck…"}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{notice ? (
|
||||
<p className="mt-3 rounded-xl bg-emerald-500/10 px-3 py-2 text-[12.5px] font-medium text-emerald-700 dark:text-emerald-300">
|
||||
{notice}
|
||||
</p>
|
||||
) : null}
|
||||
{error ? (
|
||||
<p className="mt-3 rounded-xl bg-rose-500/10 px-3 py-2 text-[12.5px] font-medium text-rose-700 dark:text-rose-300">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{/* single action ------------------------------------------------ */}
|
||||
<div className="mt-5">
|
||||
<Link href={`/deck/${deck.id}`} className="btn-primary h-10 w-full px-4 text-sm">
|
||||
<PlayIcon className="size-4" />
|
||||
Study
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* actions panel ------------------------------------------------ */}
|
||||
{menuOpen ? (
|
||||
<div
|
||||
data-testid="deck-actions"
|
||||
className="animate-rise mt-3 space-y-1 border-t border-[var(--hairline)] pt-3"
|
||||
>
|
||||
<button type="button" className={menuRow} onClick={() => setRenaming(true)} disabled={Boolean(busy)}>
|
||||
<span aria-hidden className="w-4 text-center text-[var(--text-subtle)]">
|
||||
✎
|
||||
</span>
|
||||
Rename
|
||||
</button>
|
||||
<button type="button" className={menuRow} onClick={() => downloadDeck(deck)} disabled={Boolean(busy)}>
|
||||
<DownloadIcon className="size-4 text-[var(--text-subtle)]" />
|
||||
Export JSON
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={menuRow}
|
||||
onClick={() => void regenerate()}
|
||||
disabled={Boolean(busy) || !hasSource}
|
||||
>
|
||||
<RefreshIcon className="size-4 text-[var(--text-subtle)]" />
|
||||
Regenerate deck
|
||||
</button>
|
||||
|
||||
{confirmDelete ? (
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<Button size="sm" variant="danger" onClick={() => deleteDeck(deck.id)}>
|
||||
Really delete
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setConfirmDelete(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" className={cn(menuRow, "text-rose-600 dark:text-rose-300")} onClick={() => setConfirmDelete(true)}>
|
||||
<span aria-hidden className="w-4 text-center">
|
||||
⌫
|
||||
</span>
|
||||
Delete deck
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* folder ---------------------------------------------------- */}
|
||||
<div className="mt-2 rounded-xl bg-[var(--surface-sunken)] p-2.5">
|
||||
<p className="px-1 pb-2 text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
|
||||
Folder
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{[{ id: UNORGANIZED_ID, name: "Unorganized" }, ...folders].map((folder) => {
|
||||
const current = (deck.folderId ?? UNORGANIZED_ID) === folder.id;
|
||||
return (
|
||||
<button
|
||||
key={folder.id}
|
||||
type="button"
|
||||
aria-pressed={current}
|
||||
onClick={() => {
|
||||
onMove?.(folder.id === UNORGANIZED_ID ? undefined : folder.id);
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"rounded-full px-2.5 py-1 text-[12px] font-medium transition",
|
||||
current
|
||||
? "bg-[var(--text)] text-white dark:bg-white dark:text-[#0d0d0d]"
|
||||
: "text-[var(--text-muted)] hover:bg-[var(--hover)] hover:text-[var(--text)]",
|
||||
)}
|
||||
>
|
||||
{folder.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{newFolder === null ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNewFolder("")}
|
||||
className="rounded-full px-2.5 py-1 text-[12px] font-medium text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||
>
|
||||
+ New folder
|
||||
</button>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-[var(--hairline)] bg-[var(--surface-strong)] py-0.5 pl-2.5 pr-1">
|
||||
<input
|
||||
autoFocus
|
||||
value={newFolder}
|
||||
onChange={(event) => setNewFolder(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
if (event.key === "Escape") setNewFolder(null);
|
||||
if (event.key === "Enter" && newFolder.trim()) {
|
||||
const created = makeFolder(newFolder);
|
||||
if ("folder" in created) {
|
||||
onMove?.(created.folder.id);
|
||||
setNewFolder(null);
|
||||
setMenuOpen(false);
|
||||
} else {
|
||||
setError(created.error);
|
||||
}
|
||||
}
|
||||
}}
|
||||
aria-label="New folder name"
|
||||
placeholder="Folder name"
|
||||
maxLength={60}
|
||||
className="w-24 bg-transparent text-[12px] text-[var(--text)] outline-none placeholder:text-[var(--text-subtle)]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary h-6 px-2.5 text-[11px]"
|
||||
onClick={() => {
|
||||
const created = makeFolder(newFolder);
|
||||
if ("folder" in created) {
|
||||
onMove?.(created.folder.id);
|
||||
setNewFolder(null);
|
||||
setMenuOpen(false);
|
||||
} else {
|
||||
setError(created.error);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* add more -------------------------------------------------- */}
|
||||
<div className="mt-1 rounded-xl bg-[var(--surface-sunken)] p-2.5">
|
||||
<p className="px-1 pb-2 text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
|
||||
Add more activities
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{ADD_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
onClick={() => setAddCount(option)}
|
||||
aria-pressed={addCount === option}
|
||||
className={cn(
|
||||
"min-w-9 rounded-full px-2.5 py-1 text-[12px] font-medium transition",
|
||||
addCount === option
|
||||
? "bg-[var(--text)] text-white dark:bg-white dark:text-[#0d0d0d]"
|
||||
: "text-[var(--text-muted)] hover:bg-[var(--hover)] hover:text-[var(--text)]",
|
||||
)}
|
||||
>
|
||||
+{option}
|
||||
</button>
|
||||
))}
|
||||
<Button size="sm" onClick={() => void addMore()} disabled={Boolean(busy) || !hasSource} loading={busy === "more"}>
|
||||
Generate
|
||||
</Button>
|
||||
</div>
|
||||
{!hasSource ? (
|
||||
<p className="px-1 pt-2 text-[11px] text-amber-700 dark:text-amber-300">
|
||||
No stored source text for this deck (imported decks don't carry it).
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function difficultyClass(difficulty: Deck["difficulty"]): string {
|
||||
if (difficulty === "easy") return "diff-easy";
|
||||
if (difficulty === "hard") return "diff-hard";
|
||||
return "diff-medium";
|
||||
}
|
||||
|
||||
/** Neutral pill; colour is only ever used on the text inside it. */
|
||||
function Tag({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-[var(--surface-sunken)] px-2.5 py-1 text-[12px] font-medium leading-none text-[var(--text-muted)]">
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue