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
804
components/UploadScreen.tsx
Normal file
804
components/UploadScreen.tsx
Normal file
|
|
@ -0,0 +1,804 @@
|
|||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||
|
||||
import { ApiError, generateDeck, type GenerateProgress } from "@/lib/client-api";
|
||||
import {
|
||||
DOCUMENT_ACCEPT_ATTR,
|
||||
KIND_LABELS,
|
||||
describeDocumentError,
|
||||
documentKindOf,
|
||||
documentsToText,
|
||||
extractDocument,
|
||||
isDocumentFile,
|
||||
type DocumentKind,
|
||||
type ExtractedDocument,
|
||||
} from "@/lib/documents";
|
||||
import {
|
||||
ACCEPTED_TYPES,
|
||||
MAX_IMAGES,
|
||||
describeFileError,
|
||||
formatBytes,
|
||||
imageFilesFromDataTransfer,
|
||||
isImageFile,
|
||||
prepareImage,
|
||||
type PreparedImage,
|
||||
} from "@/lib/image";
|
||||
import { createFolder, folderStore } from "@/lib/folders";
|
||||
import { UNORGANIZED_ID, type Difficulty } from "@/lib/schemas";
|
||||
import { upsertDeck } from "@/lib/storage";
|
||||
import { openSettings } from "@/lib/ui-events";
|
||||
import { cn, uid } from "@/lib/utils";
|
||||
import { ApiKeyBanner } from "@/components/ApiKeyBanner";
|
||||
import { CameraIcon, FolderIcon, IceCubeIcon, PlusIcon, SnowflakeIcon, SunIcon } from "@/components/icons";
|
||||
import { Button, ProgressBar } from "@/components/ui";
|
||||
|
||||
type Phase = "idle" | "preparing" | "loading" | "error";
|
||||
|
||||
/** A document the user added, with its client-side extraction state. */
|
||||
type DocItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: DocumentKind;
|
||||
status: "extracting" | "ready" | "error";
|
||||
progress?: string;
|
||||
extracted?: ExtractedDocument;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const MAX_DOCUMENTS = 6;
|
||||
/** Photos + rendered PDF pages + images embedded in documents. */
|
||||
const MAX_IMAGES_TO_SEND = 12;
|
||||
/** Cap for the merged text sent to the API. */
|
||||
const MAX_TEXT_TO_SEND = 100_000;
|
||||
|
||||
function describeExtraction(document: ExtractedDocument): string {
|
||||
const parts = [
|
||||
`${document.words.toLocaleString()} word${document.words === 1 ? "" : "s"}`,
|
||||
document.pages ? `${document.pages} page${document.pages === 1 ? "" : "s"}` : "",
|
||||
document.images.length
|
||||
? `${document.images.length} page image${document.images.length === 1 ? "" : "s"}`
|
||||
: "",
|
||||
document.truncated ? "truncated" : "",
|
||||
];
|
||||
return parts.filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
const DIFFICULTIES: Array<{ value: Difficulty; label: string; icon: React.ReactNode; blurb: string }> = [
|
||||
{
|
||||
value: "easy",
|
||||
label: "Easy",
|
||||
icon: <SunIcon className="size-5" />,
|
||||
blurb: "Recall & recognition",
|
||||
},
|
||||
{ value: "medium", label: "Medium", icon: <SnowflakeIcon className="size-5" />, blurb: "Understanding & application" },
|
||||
{ value: "hard", label: "Hard", icon: <IceCubeIcon className="size-5" />, blurb: "Analysis & tricky edges" },
|
||||
];
|
||||
|
||||
const COUNT_OPTIONS = [8, 10, 12, 15];
|
||||
|
||||
const GENERATION_TIPS = [
|
||||
"Tip: swipe up and down through your deck like a feed.",
|
||||
"Tip: matching questions only give full XP for a perfect match.",
|
||||
"Tip: your decks are saved in this browser — no account needed.",
|
||||
"Tip: export a deck as JSON to share it with a friend.",
|
||||
];
|
||||
|
||||
export function UploadScreen() {
|
||||
const router = useRouter();
|
||||
const [images, setImages] = useState<PreparedImage[]>([]);
|
||||
const [documents, setDocuments] = useState<DocItem[]>([]);
|
||||
const [text, setText] = useState("");
|
||||
const [focus, setFocus] = useState("");
|
||||
const [difficulty, setDifficulty] = useState<Difficulty>("medium");
|
||||
const [folderId, setFolderId] = useState<string>(UNORGANIZED_ID);
|
||||
const [creatingFolder, setCreatingFolder] = useState(false);
|
||||
const [folderDraft, setFolderDraft] = useState("");
|
||||
const [count, setCount] = useState(10);
|
||||
const [phase, setPhase] = useState<Phase>("idle");
|
||||
const [error, setError] = useState<{ message: string; kind?: string } | null>(null);
|
||||
const [fileError, setFileError] = useState<string | null>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [progress, setProgress] = useState<GenerateProgress>({ stage: "vision", message: "Warming up…" });
|
||||
|
||||
const folders = useSyncExternalStore(
|
||||
folderStore.subscribe,
|
||||
folderStore.getSnapshot,
|
||||
folderStore.getServerSnapshot,
|
||||
);
|
||||
const sortedFolders = useMemo(
|
||||
() => [...folders].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" })),
|
||||
[folders],
|
||||
);
|
||||
const targetFolder = folderId === UNORGANIZED_ID ? undefined : folderId;
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const cameraInputRef = useRef<HTMLInputElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
/**
|
||||
* Mirrors of the current lists. React defers state updaters, so reading the
|
||||
* count from inside `setState` would silently skip work — always read these.
|
||||
*/
|
||||
const imagesRef = useRef<PreparedImage[]>([]);
|
||||
const documentsRef = useRef<DocItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
imagesRef.current = images;
|
||||
}, [images]);
|
||||
useEffect(() => {
|
||||
documentsRef.current = documents;
|
||||
}, [documents]);
|
||||
|
||||
const addFiles = useCallback(async (files: File[]) => {
|
||||
if (files.length === 0) return;
|
||||
setFileError(null);
|
||||
setPhase("preparing");
|
||||
|
||||
const problems: string[] = [];
|
||||
const imageFiles: File[] = [];
|
||||
const documentFiles: File[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (isImageFile(file)) {
|
||||
const problem = describeFileError(file);
|
||||
if (problem) problems.push(problem);
|
||||
else imageFiles.push(file);
|
||||
} else if (isDocumentFile(file)) {
|
||||
const problem = describeDocumentError(file);
|
||||
if (problem) problems.push(problem);
|
||||
else documentFiles.push(file);
|
||||
} else {
|
||||
problems.push(
|
||||
`"${file.name}" is not a supported format. Add photos or a PDF, DOCX, PPTX, ODT/ODP, TXT or MD file.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const documentRoom = Math.max(0, MAX_DOCUMENTS - documentsRef.current.length);
|
||||
const acceptedDocuments = documentFiles.slice(0, documentRoom);
|
||||
if (documentFiles.length > documentRoom) problems.push(`Up to ${MAX_DOCUMENTS} documents per deck.`);
|
||||
|
||||
// Documents are read asynchronously (pdf.js can take a while for scans).
|
||||
if (acceptedDocuments.length > 0) {
|
||||
for (const file of acceptedDocuments) {
|
||||
const kind = documentKindOf(file);
|
||||
if (!kind) continue;
|
||||
const item: DocItem = { id: uid("doc"), name: file.name, kind, status: "extracting" };
|
||||
documentsRef.current = [...documentsRef.current, item];
|
||||
setDocuments((current) => (current.some((entry) => entry.id === item.id) ? current : [...current, item]));
|
||||
|
||||
const update = (patch: Partial<DocItem>) =>
|
||||
setDocuments((current) =>
|
||||
current.map((entry) => (entry.id === item.id ? { ...entry, ...patch } : entry)),
|
||||
);
|
||||
|
||||
try {
|
||||
const extracted = await extractDocument(file, (progress) => update({ progress: progress.message }));
|
||||
update({ status: "ready", extracted, progress: undefined });
|
||||
} catch (error) {
|
||||
update({
|
||||
status: "error",
|
||||
error: error instanceof Error ? error.message : "Could not read this file.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const photoRoom = Math.max(0, MAX_IMAGES - imagesRef.current.length);
|
||||
const acceptedImages = imageFiles.slice(0, photoRoom);
|
||||
if (imageFiles.length > photoRoom) problems.push(`Up to ${MAX_IMAGES} photos per deck.`);
|
||||
|
||||
const prepared: PreparedImage[] = [];
|
||||
for (const file of acceptedImages) {
|
||||
try {
|
||||
prepared.push(await prepareImage(file));
|
||||
} catch {
|
||||
problems.push(`Could not read "${file.name}".`);
|
||||
}
|
||||
}
|
||||
if (prepared.length > 0) {
|
||||
imagesRef.current = [...imagesRef.current, ...prepared];
|
||||
setImages((current) => [...current, ...prepared]);
|
||||
}
|
||||
|
||||
if (problems.length) setFileError(problems.slice(0, 3).join(" "));
|
||||
setPhase("idle");
|
||||
}, []);
|
||||
|
||||
// Clipboard paste (Ctrl/⌘ + V) of screenshots or copied photos.
|
||||
useEffect(() => {
|
||||
const onPaste = (event: ClipboardEvent) => {
|
||||
if (phase === "loading") return;
|
||||
const target = event.target as HTMLElement | null;
|
||||
const isTextField = target && /^(INPUT|TEXTAREA)$/.test(target.tagName);
|
||||
const files = imageFilesFromDataTransfer(event.clipboardData);
|
||||
if (files.length > 0) {
|
||||
event.preventDefault();
|
||||
void addFiles(files);
|
||||
return;
|
||||
}
|
||||
if (!isTextField) {
|
||||
const pasted = event.clipboardData?.getData("text/plain")?.trim();
|
||||
if (pasted && pasted.length > 40) {
|
||||
event.preventDefault();
|
||||
setText((current) => (current ? `${current}\n${pasted}` : pasted));
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener("paste", onPaste);
|
||||
return () => window.removeEventListener("paste", onPaste);
|
||||
}, [addFiles, phase]);
|
||||
|
||||
const removeImage = (id: string) => {
|
||||
imagesRef.current = imagesRef.current.filter((image) => image.id !== id);
|
||||
setImages((current) => current.filter((image) => image.id !== id));
|
||||
};
|
||||
const removeDocument = (id: string) => {
|
||||
documentsRef.current = documentsRef.current.filter((entry) => entry.id !== id);
|
||||
setDocuments((current) => current.filter((entry) => entry.id !== id));
|
||||
};
|
||||
|
||||
const readyDocuments = documents.filter((entry) => entry.status === "ready" && entry.extracted);
|
||||
const documentsBusy = documents.some((entry) => entry.status === "extracting");
|
||||
const documentImages = readyDocuments.flatMap((entry) => entry.extracted!.images);
|
||||
const documentText = documentsToText(readyDocuments.map((entry) => entry.extracted!));
|
||||
|
||||
const totalBytes = images.reduce((sum, image) => sum + image.bytes, 0);
|
||||
const hasContent =
|
||||
images.length > 0 || documentImages.length > 0 || text.trim().length > 0 || documentText.trim().length > 0;
|
||||
const canGenerate = hasContent && !documentsBusy && phase !== "preparing";
|
||||
const imagesToSend = [...images.map((image) => image.dataUrl), ...documentImages.map((image) => image.dataUrl)].slice(
|
||||
0,
|
||||
MAX_IMAGES_TO_SEND,
|
||||
);
|
||||
const imagesSkipped =
|
||||
images.length + documentImages.length - imagesToSend.length;
|
||||
|
||||
const generate = async () => {
|
||||
if (!canGenerate) return;
|
||||
setError(null);
|
||||
setPhase("loading");
|
||||
setProgress({
|
||||
stage: imagesToSend.length ? "vision" : "generation",
|
||||
message: imagesToSend.length ? "Reading your notes…" : "Finding the key ideas…",
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
const mergedText = [text.trim(), documentText.trim()].filter(Boolean).join("\n\n").slice(0, MAX_TEXT_TO_SEND);
|
||||
|
||||
try {
|
||||
const { deck } = await generateDeck(
|
||||
{
|
||||
images: imagesToSend.length ? imagesToSend : undefined,
|
||||
text: mergedText || undefined,
|
||||
focus: focus.trim() || undefined,
|
||||
difficulty,
|
||||
count,
|
||||
},
|
||||
{ onProgress: setProgress, signal: controller.signal },
|
||||
);
|
||||
|
||||
const now = Date.now();
|
||||
const stored = {
|
||||
...deck,
|
||||
id: uid("deck"),
|
||||
folderId: targetFolder,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
attempts: 0,
|
||||
};
|
||||
upsertDeck(stored);
|
||||
router.push(`/deck/${stored.id}`);
|
||||
} catch (generateError) {
|
||||
setError({
|
||||
message:
|
||||
generateError instanceof ApiError
|
||||
? generateError.message
|
||||
: "Something went wrong while generating your deck. Check your Ollama settings and try again.",
|
||||
kind: generateError instanceof ApiError ? generateError.kind : undefined,
|
||||
});
|
||||
setPhase("error");
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
if (phase === "loading") {
|
||||
return <LoadingState progress={progress} onCancel={() => abortRef.current?.abort()} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="relative space-y-5">
|
||||
<section className="space-y-2">
|
||||
<h1 className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[28px] font-semibold leading-tight tracking-[-0.02em] text-[var(--text)] text-balance sm:text-[32px]">
|
||||
<SnowflakeIcon className="size-8 shrink-0 text-frost-500 dark:text-frost-200" />
|
||||
<span>Turn your notes into a study feed</span>
|
||||
</h1>
|
||||
<p className="max-w-xl text-[15px] text-frost-600 dark:text-frost-300">
|
||||
Snap or drop photos of your notes. Freezmo reads them with your own Ollama vision model
|
||||
and builds a swipeable deck of mini study games.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<ApiKeyBanner />
|
||||
|
||||
{error ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="animate-rise rounded-3xl border-2 border-rose-300 bg-rose-50/95 p-4 text-sm text-rose-900 dark:border-rose-400/40 dark:bg-rose-400/10 dark:text-rose-100"
|
||||
>
|
||||
<p className="font-bold">
|
||||
{error.kind === "needs_key"
|
||||
? "API key required"
|
||||
: error.kind === "rate_limited"
|
||||
? "Slow down a moment"
|
||||
: error.kind === "auth"
|
||||
? "Ollama rejected your key"
|
||||
: "Generation failed"}
|
||||
</p>
|
||||
<p className="mt-1">{error.message}</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{error.kind === "needs_key" || error.kind === "auth" ? (
|
||||
<Button size="sm" onClick={openSettings}>
|
||||
Open settings
|
||||
</Button>
|
||||
) : null}
|
||||
<Button size="sm" variant={error.kind === "needs_key" || error.kind === "auth" ? "secondary" : "primary"} onClick={() => void generate()}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* ---------------------------------------------------------- */}
|
||||
<section
|
||||
aria-label="Add photos of your notes"
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
setDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
setDragging(false);
|
||||
void addFiles(imageFilesFromDataTransfer(event.dataTransfer));
|
||||
}}
|
||||
className={cn(
|
||||
"card relative overflow-hidden p-4 transition-all sm:p-5",
|
||||
dragging && "border-frost-400 bg-frost-50 ring-4 ring-frost-200 dark:bg-frost-500/10 dark:ring-frost-500/30",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-[15px] font-semibold tracking-[-0.01em] text-[var(--text)]">1. Add your notes</h2>
|
||||
<p className="text-[12.5px] text-[var(--text-subtle)]">
|
||||
Drag & drop, paste a screenshot (Ctrl/⌘+V), or use the camera. Photos, PDF, Word,
|
||||
PowerPoint, OpenDocument and text files. Up to {MAX_IMAGES} photos and {MAX_DOCUMENTS}{" "}
|
||||
documents.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={() => fileInputRef.current?.click()}>
|
||||
<FolderIcon className="size-4" /> Choose files
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => cameraInputRef.current?.click()}>
|
||||
<CameraIcon className="size-4" /> Camera
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={`${ACCEPTED_TYPES.join(",")},${DOCUMENT_ACCEPT_ATTR}`}
|
||||
multiple
|
||||
className="sr-only"
|
||||
onChange={(event) => {
|
||||
void addFiles(Array.from(event.target.files ?? []));
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={cameraInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
className="sr-only"
|
||||
onChange={(event) => {
|
||||
void addFiles(Array.from(event.target.files ?? []));
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
|
||||
{images.length === 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="mt-4 flex w-full flex-col items-center gap-2 rounded-2xl border-2 border-dashed border-white/70 bg-white/40 px-4 py-10 text-center backdrop-blur-xl transition hover:border-accent-300 hover:bg-white/65 dark:border-white/15 dark:bg-white/[0.05] dark:hover:bg-white/[0.09]"
|
||||
>
|
||||
<span aria-hidden className="animate-float text-frost-500 dark:text-frost-200">
|
||||
<SnowflakeIcon className="size-12" />
|
||||
</span>
|
||||
<span className="text-[14px] font-medium text-[var(--text)]">
|
||||
Drop your notes here
|
||||
</span>
|
||||
<span className="text-[12.5px] text-[var(--text-subtle)]">
|
||||
Photos, PDF, DOCX, PPTX, ODT/ODP, TXT, MD · images resized to 1600px before sending
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<ul className="mt-4 grid grid-cols-3 gap-2 sm:grid-cols-4">
|
||||
{images.map((image) => (
|
||||
<li key={image.id} className="group relative overflow-hidden rounded-2xl border border-frost-200 dark:border-white/15">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={image.dataUrl}
|
||||
alt={image.name}
|
||||
className="aspect-square w-full object-cover"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeImage(image.id)}
|
||||
aria-label={`Remove ${image.name}`}
|
||||
className="absolute right-1 top-1 grid size-7 place-items-center rounded-full bg-frost-950/70 text-xs font-bold text-white transition hover:bg-rose-600"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<span className="absolute inset-x-0 bottom-0 bg-frost-950/60 px-2 py-1 text-[10px] font-semibold text-white">
|
||||
{formatBytes(image.bytes)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{images.length < MAX_IMAGES ? (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="grid aspect-square w-full place-items-center rounded-2xl border-2 border-dashed border-white/70 text-2xl text-accent-500 backdrop-blur-xl transition hover:border-accent-300 hover:bg-white/60 dark:border-white/15 dark:text-accent-200"
|
||||
aria-label="Add more photos"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{documents.length > 0 ? (
|
||||
<ul className="mt-3 space-y-2">
|
||||
{documents.map((entry) => (
|
||||
<li
|
||||
key={entry.id}
|
||||
className="flex items-start gap-3 rounded-2xl border border-white/60 bg-white/50 p-3 backdrop-blur-xl dark:border-white/12 dark:bg-white/[0.06]"
|
||||
>
|
||||
<span className="grid size-10 shrink-0 place-items-center rounded-xl bg-frost-100 text-[10px] font-extrabold uppercase tracking-wide text-frost-700 dark:bg-white/10 dark:text-frost-100">
|
||||
{KIND_LABELS[entry.kind]}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold text-[var(--text)]" title={entry.name}>
|
||||
{entry.name}
|
||||
</p>
|
||||
{entry.status === "extracting" ? (
|
||||
<p className="text-xs font-medium text-frost-700 dark:text-frost-200">
|
||||
{entry.progress ?? "Reading…"}
|
||||
</p>
|
||||
) : null}
|
||||
{entry.status === "error" ? (
|
||||
<p className="text-xs font-medium text-rose-600 dark:text-rose-300">{entry.error}</p>
|
||||
) : null}
|
||||
{entry.status === "ready" && entry.extracted ? (
|
||||
<>
|
||||
<p className="text-[12.5px] text-[var(--text-subtle)]">
|
||||
{describeExtraction(entry.extracted)}
|
||||
</p>
|
||||
{entry.extracted.words === 0 && entry.extracted.images.length === 0 ? (
|
||||
<p className="text-xs font-medium text-amber-700 dark:text-amber-300">
|
||||
No readable text — is this a scan without a text layer?
|
||||
</p>
|
||||
) : null}
|
||||
{entry.extracted.warnings.map((warning) => (
|
||||
<p key={warning} className="text-[12.5px] text-[var(--text-subtle)]">
|
||||
ⓘ {warning}
|
||||
</p>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeDocument(entry.id)}
|
||||
aria-label={`Remove ${entry.name}`}
|
||||
className="grid size-7 shrink-0 place-items-center rounded-full bg-frost-950/10 text-xs font-bold text-frost-900 transition hover:bg-rose-500 hover:text-white dark:bg-white/10 dark:text-frost-50"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{images.length > 0 || documents.length > 0 ? (
|
||||
<p className="mt-2 text-[12.5px] text-[var(--text-subtle)]">
|
||||
{images.length > 0
|
||||
? `${images.length} photo${images.length === 1 ? "" : "s"} · ${formatBytes(totalBytes)} after compression`
|
||||
: ""}
|
||||
{images.length > 0 && documents.length > 0 ? " · " : ""}
|
||||
{documents.length > 0
|
||||
? `${documents.length} document${documents.length === 1 ? "" : "s"}${
|
||||
documentImages.length ? ` (${documentImages.length} page image${documentImages.length === 1 ? "" : "s"})` : ""
|
||||
}`
|
||||
: ""}
|
||||
{imagesSkipped > 0
|
||||
? ` · ${imagesSkipped} image${imagesSkipped === 1 ? "" : "s"} will be skipped (max ${MAX_IMAGES_TO_SEND} per deck)`
|
||||
: ""}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{phase === "preparing" ? (
|
||||
<p className="mt-2 text-[12.5px] font-medium text-[var(--text-muted)]">Compressing photos…</p>
|
||||
) : null}
|
||||
{fileError ? (
|
||||
<p role="alert" className="mt-2 text-xs font-semibold text-rose-600 dark:text-rose-300">
|
||||
{fileError}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{/* ---------------------------------------------------------- */}
|
||||
<section className="card space-y-4 p-4 sm:p-5">
|
||||
<div>
|
||||
<h2 className="text-[15px] font-semibold tracking-[-0.01em] text-[var(--text)]">2. Optional extras</h2>
|
||||
<p className="text-[12.5px] text-[var(--text-subtle)]">
|
||||
Paste extra notes or give the AI a hint about what to focus on.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="notes-text" className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
|
||||
Pasted notes or text
|
||||
</label>
|
||||
<textarea
|
||||
id="notes-text"
|
||||
value={text}
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
rows={4}
|
||||
placeholder="Paste lecture text, a summary, or any extra facts here…"
|
||||
className="w-full resize-y rounded-2xl border border-white/60 bg-white/55 px-4 py-3 text-sm text-frost-950 outline-none backdrop-blur-xl transition placeholder:text-frost-400 focus:border-accent-400 dark:border-white/12 dark:bg-white/[0.07] dark:text-frost-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="focus" className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
|
||||
Topic hint (optional)
|
||||
</label>
|
||||
<input
|
||||
id="focus"
|
||||
value={focus}
|
||||
onChange={(event) => setFocus(event.target.value)}
|
||||
placeholder="e.g. focus on the formulas, or on dates"
|
||||
className="w-full rounded-2xl border border-white/60 bg-white/55 px-4 py-3 text-sm text-frost-950 outline-none backdrop-blur-xl transition placeholder:text-frost-400 focus:border-accent-400 dark:border-white/12 dark:bg-white/[0.07] dark:text-frost-50"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---------------------------------------------------------- */}
|
||||
<section className="card space-y-4 p-4 sm:p-5">
|
||||
<h2 className="text-[15px] font-semibold tracking-[-0.01em] text-[var(--text)]">3. Difficulty & size</h2>
|
||||
|
||||
<div className="space-y-2">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
|
||||
Freeze into folder
|
||||
</span>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{[{ id: UNORGANIZED_ID, name: "Unorganized" }, ...sortedFolders].map((folder) => {
|
||||
const active = folderId === folder.id;
|
||||
return (
|
||||
<button
|
||||
key={folder.id}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() => setFolderId(folder.id)}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full border px-3.5 py-1.5 text-[13px] font-medium transition",
|
||||
active
|
||||
? "border-accent-500 bg-accent-50 text-accent-800 dark:bg-accent-500/20 dark:text-accent-100"
|
||||
: "border-[var(--hairline)] text-frost-600 hover:text-frost-900 dark:text-frost-300 dark:hover:text-white",
|
||||
)}
|
||||
>
|
||||
<FolderIcon className="size-4" />
|
||||
{folder.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{creatingFolder ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<input
|
||||
autoFocus
|
||||
value={folderDraft}
|
||||
onChange={(event) => setFolderDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
if (event.key === "Escape") {
|
||||
setCreatingFolder(false);
|
||||
setFolderDraft("");
|
||||
}
|
||||
if (event.key === "Enter") {
|
||||
const created = createFolder(folderDraft);
|
||||
if ("folder" in created) {
|
||||
setFolderId(created.folder.id);
|
||||
setFolderDraft("");
|
||||
setCreatingFolder(false);
|
||||
} else {
|
||||
setFileError(created.error);
|
||||
}
|
||||
}
|
||||
}}
|
||||
aria-label="New folder name"
|
||||
placeholder="e.g. History"
|
||||
maxLength={60}
|
||||
className="w-32 rounded-xl border border-[var(--hairline)] bg-[var(--surface-strong)] px-3 py-1.5 text-sm outline-none"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const created = createFolder(folderDraft);
|
||||
if ("folder" in created) {
|
||||
setFolderId(created.folder.id);
|
||||
setFolderDraft("");
|
||||
setCreatingFolder(false);
|
||||
} else {
|
||||
setFileError(created.error);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreatingFolder(true)}
|
||||
className="inline-flex items-center gap-1.5 rounded-xl border border-dashed border-[var(--hairline)] px-3 py-1.5 text-sm font-semibold text-frost-500 transition-colors hover:text-accent-700 dark:text-frost-400 dark:hover:text-accent-200"
|
||||
>
|
||||
<PlusIcon className="size-4" /> New folder
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div role="radiogroup" aria-label="Difficulty" className="grid grid-cols-3 gap-2">
|
||||
{DIFFICULTIES.map((option) => {
|
||||
const selected = difficulty === option.value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
onClick={() => setDifficulty(option.value)}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-1 rounded-2xl border px-2 py-3 text-center backdrop-blur-xl transition-colors",
|
||||
selected
|
||||
? "border-accent-400 bg-accent-50/80 shadow-lift dark:bg-accent-500/20"
|
||||
: "border-white/60 bg-white/45 backdrop-blur-xl hover:bg-white/70 dark:border-white/12 dark:bg-white/[0.05]",
|
||||
)}
|
||||
>
|
||||
<span aria-hidden className="text-frost-600 dark:text-frost-200">
|
||||
{option.icon}
|
||||
</span>
|
||||
<span className="text-sm font-bold text-[var(--text)]">{option.label}</span>
|
||||
<span className="hidden text-[11px] text-slate-500 sm:block dark:text-slate-400">{option.blurb}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
|
||||
Activities
|
||||
</span>
|
||||
{COUNT_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
aria-pressed={count === option}
|
||||
onClick={() => setCount(option)}
|
||||
className={cn(
|
||||
"min-w-12 rounded-xl border-2 px-3 py-1.5 text-sm font-bold transition",
|
||||
count === option
|
||||
? "border-transparent bg-[var(--text)] text-white dark:bg-white dark:text-[#0d0d0d]"
|
||||
: "border-[var(--hairline)] text-[var(--text-muted)] hover:bg-[var(--hover)]",
|
||||
)}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="sticky bottom-[calc(env(safe-area-inset-bottom,0px)+4.6rem)] z-20 -mx-4 border-t border-[var(--hairline)] bg-white/65 px-4 py-3 backdrop-blur-2xl dark:bg-night-950/70 sm:bottom-0 sm:mx-0 sm:rounded-[1.35rem] sm:border sm:px-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-[12.5px] text-[var(--text-subtle)]">
|
||||
{documentsBusy
|
||||
? "Reading your documents…"
|
||||
: hasContent
|
||||
? "Ready when you are — generation can take a minute."
|
||||
: "Add a photo or a document (or some text) to start."}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="lg" onClick={() => void generate()} disabled={!canGenerate}>
|
||||
<SnowflakeIcon className="size-5" /> Freeze my deck
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Loading state */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const STAGE_STEPS: Array<{ stage: GenerateProgress["stage"]; messages: string[] }> = [
|
||||
{ stage: "vision", messages: ["Reading your notes…", "Transcribing handwriting…", "Spotting definitions…"] },
|
||||
{ stage: "generation", messages: ["Finding the key ideas…", "Writing good questions…", "Building your activities…"] },
|
||||
];
|
||||
|
||||
function LoadingState({ progress, onCancel }: { progress: GenerateProgress; onCancel: () => void }) {
|
||||
const [tick, setTick] = useState(0);
|
||||
const [tip, setTip] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setTick((value) => value + 1), 3200);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setTip(Math.floor(Math.random() * GENERATION_TIPS.length));
|
||||
}, []);
|
||||
|
||||
const activeIndex = progress.stage === "vision" ? 0 : 1;
|
||||
const messages = STAGE_STEPS[activeIndex].messages;
|
||||
const message = progress.message && tick === 0 ? progress.message : messages[tick % messages.length];
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-[70dvh] flex-col items-center justify-center gap-6 text-center">
|
||||
<motion.div
|
||||
aria-hidden
|
||||
animate={{ rotate: [0, 8, -8, 0], scale: [1, 1.08, 1] }}
|
||||
transition={{ duration: 3.2, repeat: Infinity, ease: "easeInOut" }}
|
||||
className="relative grid size-28 place-items-center rounded-[1.75rem] bg-accent-600 text-white shadow-lift"
|
||||
>
|
||||
<SnowflakeIcon className="size-16" />
|
||||
</motion.div>
|
||||
|
||||
<div className="relative w-full max-w-md space-y-3">
|
||||
<p className="text-xl font-extrabold text-[var(--text)]" aria-live="polite">
|
||||
{message}
|
||||
</p>
|
||||
<ProgressBar value={activeIndex + 1} max={2} label="Generation progress" />
|
||||
<ol className="space-y-1 text-left text-[14.5px] leading-relaxed text-[var(--text-muted)]">
|
||||
{STAGE_STEPS.map((step, index) => (
|
||||
<li key={step.stage} className="flex items-center gap-2">
|
||||
<span aria-hidden>
|
||||
{index < activeIndex ? "✅" : index === activeIndex ? "🌀" : "⬜"}
|
||||
</span>
|
||||
{step.messages[0]}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<p className="pt-2 text-[12.5px] text-[var(--text-subtle)]">{GENERATION_TIPS[tip]}</p>
|
||||
<Button variant="ghost" size="sm" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue