diff --git a/README.md b/README.md index 6bb67f2..2d4d106 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,12 @@ no database, no data leaving your machine except the requests you send to Ollama **Capture & generate** - Drag & drop, file picker, **mobile camera capture** and **clipboard paste** (Ctrl/⌘+V) +- **Phones get a 3-step flow** — notes → options → summary — with a progress bar and a + floating Back / Next bar above the tab bar. Step 1 is just *Add photos or files*, + *Camera* and a *Paste text instead* field (no drop zone on touch screens); step 2 keeps + folder, difficulty and activity count at their defaults and tucks the topic hint and + pasted notes behind one collapsed row; step 3 is a single summary line with **Freeze my + deck**. Going Back keeps every input. The desktop page is unchanged. - **Photos and documents**: JPG/PNG/WEBP/HEIC images plus **PDF, DOCX, PPTX, ODT/ODP/ODS, TXT, MD, CSV** - Documents are read **in the browser**: PDF text layers are extracted, scanned PDF pages are rendered as images for the vision model, DOCX/PPTX text, speaker notes and embedded images are pulled out. @@ -85,6 +91,9 @@ no database, no data leaving your machine except the requests you send to Ollama file; photos go through the same vision model and language guard that notes do - Two modes: **Just make it** returns the finished homework (the answer only, ready to copy), and **Explain how to make this homework** turns it into a guided walkthrough +- On phones this is a **2-step flow**: the assignment first (same simplified input as the + deck flow), then the two mode cards under a short preview — tapping a card starts it + immediately, so there is no separate button. Desktop keeps the select-then-confirm layout. - The guide is one step per screen in the same snap-feed feel as the study feed: what to do and why, **three progressive hints** per step (nudge → hint → the full answer), and a **checkpoint** built from the normal activity components and graded through `/api/grade` @@ -120,6 +129,11 @@ no database, no data leaving your machine except the requests you send to Ollama - **Two pools, never mixed**: a **Study Decks** tab (the folder + card library) and a **Homework** tab with saved solutions and step-by-step guides — each with its own empty state and call to action, and the active tab lives in the URL (`/?tab=homework`) +- **Phone library, decluttered**: no stats line, the folder row and “Unorganized” heading only + appear once folders exist (the drawer has *New folder*), and Import is a small icon button in + the tab row. Deck cards shrink to title + one meta line (activities · difficulty · best + score) + a one-line description that expands on tap, with the whole card tappable and a small + **Study** pill; homework cards match. - Decks saved locally with title, subject, folder, activity count, date, play count and best score - **Folders**: create named groups ("History", "Biology", …) from the sidebar or the create screen. New decks freeze straight into the selected folder; anything unfiled lives in **Unorganized**. @@ -346,7 +360,10 @@ the two pipeline steps stay separate either way. ### Checking the connection - **In the app:** click the ⚙️ button — it shows mode, base URL, API key status, latency and - whether each configured model is available, with a hint for what to fix. + whether each configured model is available, with a hint for what to fix. On phones the sheet + is simplified: a **Connection** card with one status row (dot + “Connected · 2 models ready”) + and the key field, the raw health details live behind a **Details** expander, and the key can + be removed from a small danger button at the very bottom. - **From the shell:** `curl http://localhost:3207/api/health` - **Directly against Ollama:** `curl http://localhost:11434/api/tags` @@ -908,6 +925,15 @@ Things I chose while building, with the reasoning: second copy. Result records store the assignment text — the vision transcription when photos were used — so “Make it again” works without storing a single original file. +36. **The mobile declutter pass reshapes, it doesn't fork.** Phones (≤639px) get a 3-step deck + flow and a 2-step homework flow that share the exact state and handlers with the desktop + cards (one `UploadScreen` / one `HomeworkInput`, two layouts switched by `useIsPhone()`); + desktop markup is untouched. Library cards collapse to title + one meta line + one + description line (expand on tap) with the whole card tappable and a small Study pill, the + stats line and folder row only appear once folders exist, and the settings sheet trades the + health cards for a Connection status row plus a collapsed Details list. Decorative orbs were + pushed further off the phone edges so they never glow under CTAs or cards. + 14. **`gpt-oss:120b` does all content work — but it cannot read images** (`this model does not support image input`), so `VISION_MODEL` has to stay a multimodal model (`gemma4:31b-cloud`). Text-only inputs — pasted text, PDFs with a text layer, DOCX/PPTX/ODT — never touch the vision diff --git a/app/globals.css b/app/globals.css index f5b6826..0fb68aa 100644 --- a/app/globals.css +++ b/app/globals.css @@ -338,12 +338,12 @@ body { --ice-blob-opacity: 0.2; --ice-orb-opacity: 0.42; } - /* Tucked up behind the glass header: at top 8% it sat right behind the page - heading and subtitle. */ - .ice-orb-1 { width: 88px; height: 88px; top: -30px; left: -38px; } - .ice-orb-2 { width: 72px; height: 72px; left: -32px; } - /* Was sitting right behind the bottom-nav labels. */ - .ice-orb-3 { width: 116px; height: 116px; bottom: 15vh; right: -46px; } + /* Phones: orbs hug the screen edges so they can never sit under the + content column's cards or primary buttons (the lower-right one used to + glow right behind the main CTAs). */ + .ice-orb-1 { width: 96px; height: 96px; top: -44px; left: -48px; } + .ice-orb-2 { width: 72px; height: 72px; top: 60%; left: -44px; } + .ice-orb-3 { width: 124px; height: 124px; bottom: 12vh; right: -84px; } } @layer components { diff --git a/components/DeckCard.tsx b/components/DeckCard.tsx index a495ecb..bb9fb10 100644 --- a/components/DeckCard.tsx +++ b/components/DeckCard.tsx @@ -10,7 +10,7 @@ import { useI18n } from "@/lib/i18n"; 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, EllipsisIcon, FolderIcon, PlayIcon, RefreshIcon } from "@/components/icons"; +import { ChevronDownIcon, DownloadIcon, EllipsisIcon, FolderIcon, PlayIcon, RefreshIcon } from "@/components/icons"; import { Button, ProgressBar } from "@/components/ui"; type Busy = null | "more" | "regenerate"; @@ -33,6 +33,7 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str const [notice, setNotice] = useState(null); const [addCount, setAddCount] = useState(5); const [newFolder, setNewFolder] = useState(null); + const [expanded, setExpanded] = useState(false); const hasSource = deck.sourceText.trim().length > 20; @@ -110,13 +111,13 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str "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 ( -
  • - {/* Desktop: the whole card is a link; interactive children sit above it. */} +
  • + {/* Phones: the whole card is tappable too; interactive children sit above it. */} {/* header ------------------------------------------------------- */} @@ -154,7 +155,27 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str {truncate(deck.title, 90)} )} -

    + {/* Phones: one compact meta line. */} +

    + {plural(deck.activities.length, "deck.activities")} + · + + {deck.difficulty === "easy" + ? t("upload.easy") + : deck.difficulty === "hard" + ? t("upload.hard") + : t("upload.medium")} + + {deck.bestScore !== undefined ? ( + <> + · + + {t("deck.bestLabel")} {deck.bestScore}% + + + ) : null} +

    +

    {/* Phones keep the original date · activities · runs line. */} {formatDate(deck.updatedAt)} @@ -193,7 +214,7 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str {/* tags --------------------------------------------------------- */} -

    +
    {deck.subject ? {deck.subject} : null} @@ -225,9 +246,22 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str {/* summary ------------------------------------------------------ */} {deck.summary ? ( -

    - {truncate(deck.summary, 160)} -

    + <> + +

    + {truncate(deck.summary, 160)} +

    + ) : null} {/* status ------------------------------------------------------- */} @@ -256,8 +290,18 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str ) : null} {/* single action ------------------------------------------------ */} -
    - +
    + + + {t("deck.study")} + + {t("deck.study")} diff --git a/components/HomeworkCard.tsx b/components/HomeworkCard.tsx index f0af366..067c1dc 100644 --- a/components/HomeworkCard.tsx +++ b/components/HomeworkCard.tsx @@ -3,11 +3,11 @@ import Link from "next/link"; import { useState } from "react"; -import { BoltIcon, PlayIcon, XIcon } from "@/components/icons"; +import { BoltIcon, ChevronDownIcon, PlayIcon, XIcon } from "@/components/icons"; import { Button } from "@/components/ui"; import { useI18n } from "@/lib/i18n"; import type { HomeworkRecord } from "@/lib/schemas"; -import { formatDate, truncate } from "@/lib/utils"; +import { cn, formatDate, truncate } from "@/lib/utils"; /** * One saved homework result in the library's Homework tab: the finished @@ -16,6 +16,7 @@ import { formatDate, truncate } from "@/lib/utils"; export function HomeworkCard({ record, onDelete }: { record: HomeworkRecord; onDelete: (id: string) => void }) { const { t, plural } = useI18n(); const [confirmDelete, setConfirmDelete] = useState(false); + const [expanded, setExpanded] = useState(false); const href = `/homework?open=${record.id}`; const preview = @@ -27,9 +28,9 @@ export function HomeworkCard({ record, onDelete }: { record: HomeworkRecord; onD ); return ( -
  • - {/* Desktop: the whole card opens the result; the delete button sits above it. */} - +
  • + {/* Phones: the whole card opens the result too; the delete button sits above it. */} +
    @@ -65,10 +66,23 @@ export function HomeworkCard({ record, onDelete }: { record: HomeworkRecord; onD
    {preview ? ( -

    {preview}

    + <> + +

    {preview}

    + ) : null} -
    +
    {confirmDelete ? (
    {t("homework.history.deleteConfirm")} @@ -81,7 +95,14 @@ export function HomeworkCard({ record, onDelete }: { record: HomeworkRecord; onD
    ) : ( <> - + + + {t("homework.open")} + + {t("homework.open")}
    diff --git a/components/LibraryScreen.tsx b/components/LibraryScreen.tsx index 01181ed..384ba1c 100644 --- a/components/LibraryScreen.tsx +++ b/components/LibraryScreen.tsx @@ -85,10 +85,6 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" | return [...named, unorganized]; }, [folderList, selected, sorted, t]); - const totalActivities = decks.reduce((sum, deck) => sum + deck.activities.length, 0); - const bestEver = decks.reduce((best, deck) => Math.max(best, deck.bestScore ?? 0), 0); - const played = decks.some((deck) => deck.attempts > 0); - const handleImport = async (file: File | undefined) => { if (!file) return; try { @@ -126,9 +122,9 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |

    - {/* Import sits above the tabs on every tab, so the header never - shifts when you switch pools. */} -
    + {/* Desktop keeps Import above the tabs; phones get a small icon + button in the tab row instead. */} +
    - { - void handleImport(event.target.files?.[0]); - event.target.value = ""; - }} - />
    + { + void handleImport(event.target.files?.[0]); + event.target.value = ""; + }} + /> {/* Two pools, never mixed: study decks and saved homework. */} -
    - - {t("tabDecks")} - - - {t("tabHomework")} - +
    +
    + + {t("tabDecks")} + + + {t("tabHomework")} + +
    +
    - {/* Secondary stats folded into the header as quiet text on desktop, - where the sidebar already shows per-folder counts. */} - {tab === "decks" && decks.length > 0 ? ( -

    - {plural(decks.length, "stats.decks")} - · - {plural(folderList.length, "stats.folders")} - · - {plural(totalActivities, "stats.activities")} - {played ? ( - <> - · - {t("library.stats.best", { n: bestEver })} - - ) : null} -

    - ) : null} - - {/* Mobile folder switcher; the sidebar covers desktop. With an empty - library there is nothing to filter, and the pills would compete - with the empty state's single call to action. */} - {tab === "decks" && !isEmpty ? ( + {/* Mobile folder switcher; the sidebar covers desktop. Hidden while + there are no folders — the drawer's “New folder” is the way in. */} + {tab === "decks" && !isEmpty && hasFolders ? ( {/* group header -------------------------------------- */} -
    +
    {isEditing && editingFolder ? ( diff --git a/components/SettingsDialog.tsx b/components/SettingsDialog.tsx index 5e61da5..4483b18 100644 --- a/components/SettingsDialog.tsx +++ b/components/SettingsDialog.tsx @@ -14,6 +14,9 @@ import { ChevronDownIcon, ChevronRightIcon, DownloadIcon, + EyeIcon, + EyeOffIcon, + RefreshIcon, XCircleIcon, } from "@/components/icons"; import { Button, Spinner } from "@/components/ui"; @@ -31,7 +34,7 @@ function freshnessLabel(checkedAt: number, locale: Locale, t: (key: string, vars export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () => void }) { const { health, loading, error, refresh, apiKey, checkedAt } = useHealth(); const installAvailable = useInstallAvailable(); - const { locale, setLocale, t } = useI18n(); + const { locale, setLocale, t, plural } = useI18n(); const [keyDraft, setKeyDraft] = useState(apiKey); const [reveal, setReveal] = useState(false); const [saved, setSaved] = useState(false); @@ -106,6 +109,33 @@ export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () = const needsKeyMode = health?.authMode === "user-key"; + /* Phones: one status row instead of the whole health panel. */ + const modelsReady = health ? [health.vision.available, health.text.available].filter((value) => value === true).length : 0; + const statusText = !health + ? t("settings.checking") + : health.ok + ? `${t("settings.connected")} · ${plural(modelsReady, "settings.modelsReady")}` + : health.needsKey + ? t("health.needsKey") + : health.keyAccepted === false + ? t("health.rejected") + : health.reachable + ? t("health.modelMissing") + : t("health.unreachable"); + const statusDot = !health + ? "bg-[var(--text-subtle)]" + : health.ok + ? "bg-emerald-500" + : health.needsKey || health.reachable + ? "bg-amber-500" + : "bg-rose-500"; + const modelBadge = (model: { available: boolean | null } | undefined) => + !model || model.available === null + ? { text: t("health.unknown"), className: "bg-[var(--surface-sunken)] text-[var(--text-muted)]" } + : model.available + ? { text: t("health.available"), className: "bg-emerald-100 text-emerald-900 dark:bg-emerald-400/20 dark:text-emerald-200" } + : { text: t("health.notFound"), className: "bg-rose-50 text-rose-800 dark:bg-rose-500/15 dark:text-rose-200" }; + return ( {open ? ( @@ -149,11 +179,13 @@ export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () =

    {t("settings.title")}

    -

    - {t("settings.subtitle.before")}{" "} - .env.local{" "} - {t("settings.subtitle.after")} -

    + {!phone ? ( +

    + {t("settings.subtitle.before")}{" "} + .env.local{" "} + {t("settings.subtitle.after")} +

    + ) : null}
    + ))} +
    + + + {/* ------------------------------------------- connection */} +
    +
    +

    + {t("settings.connection")} +

    + +
    + +
    + + {statusText} +
    + +
    + + setKeyDraft(event.target.value)} + onKeyDown={(event) => { + event.stopPropagation(); + if (event.key === "Enter") save(); + }} + placeholder={t("settings.pasteKey")} + autoComplete="off" + spellCheck={false} + className="w-full rounded-xl border border-[var(--hairline)] bg-[var(--surface-strong)] py-3 pl-3 pr-12 font-mono text-xs text-frost-950 outline-none transition placeholder:font-sans placeholder:text-[var(--text-subtle)] focus:border-accent-500 dark:text-frost-50" + /> + +
    + + {keyDraft.trim() !== apiKey ? ( + + ) : null} + +
    + + {t("settings.privacyLine")} + + {t("common.more")} + + + +
    +

    {t("settings.keyNote")}

    +

    + {t("settings.localTipTitle")} {t("settings.localTipSet")}{" "} + OLLAMA_AUTH=local {t("settings.keyNoteAfter")} .env.local{" "} + {t("settings.keyNoteEnd")} ollama signin + {t("settings.keyNoteEnd2")} +

    +
    +
    +
    + + {/* ----------------------------------------------- install */} + {installAvailable ? ( +
    + +
    + ) : null} + + {error ? ( +
    + {error} +
    + ) : null} + + {/* ----------------------------------------------- details */} +
    + + {t("settings.details")} + + +
    + {loading ? ( +

    + {t("settings.checking")} +

    + ) : checkedAt ? ( +

    + {freshnessLabel(checkedAt, locale, t)} +

    + ) : null} +
    + + + + + + +
    + {health?.mode === "direct-cloud" && health.needsKey ? ( +

    + {t("health.createKey.before")}{" "} + + ollama.com/settings/keys + {" "} + {t("health.createKey.after")} ollama signin {t("health.createKey.end")}{" "} + OLLAMA_AUTH=local. +

    + ) : null} +
    +
    + + {/* ------------------------------- remove key (very bottom) */} + {apiKey ? ( + confirmRemove ? ( +
    +

    + {t("settings.removeKeyConfirm")} +

    +
    + + +
    +
    + ) : ( + + ) + ) : null} + +
    + + ) : ( + <> {/* --------------------------------------------- language */}

    @@ -375,6 +664,8 @@ export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () = {/* keeps real content out of the 36px fade strip at the bottom */}
    + + )}

    @@ -483,6 +774,32 @@ function HealthPanel({ health }: { health: ReturnType["health" ); } +function DetailRow({ + label, + value, + mono, + badge, +}: { + label: string; + value: string; + mono?: boolean; + badge?: { text: string; className: string }; +}) { + return ( +
    +
    {label}
    +
    + {value} + {badge ? ( + + {badge.text} + + ) : null} +
    +
    + ); +} + function Info({ label, value, mono }: { label: string; value: string; mono?: boolean }) { return (
    diff --git a/components/UploadScreen.tsx b/components/UploadScreen.tsx index 38c2d17..c16314b 100644 --- a/components/UploadScreen.tsx +++ b/components/UploadScreen.tsx @@ -1,6 +1,6 @@ "use client"; -import { motion } from "framer-motion"; +import { motion, useReducedMotion } from "framer-motion"; import { useRouter } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; @@ -31,8 +31,9 @@ import { useI18n } from "@/lib/i18n"; import { UNORGANIZED_ID, type Difficulty } from "@/lib/schemas"; import { upsertDeck } from "@/lib/storage"; import { openSettings } from "@/lib/ui-events"; +import { useIsPhone } from "@/lib/use-phone"; import { cn, uid } from "@/lib/utils"; -import { CameraIcon, FolderIcon, IceCubeIcon, PlusIcon, SnowflakeIcon, SparklesIcon, SunIcon } from "@/components/icons"; +import { CameraIcon, ChevronDownIcon, FolderIcon, IceCubeIcon, PlusIcon, SnowflakeIcon, SparklesIcon, SunIcon } from "@/components/icons"; import { Button, ProgressBar } from "@/components/ui"; type Phase = "idle" | "preparing" | "loading" | "error"; @@ -100,6 +101,12 @@ export function UploadScreen() { const [fileError, setFileError] = useState(null); const [dragging, setDragging] = useState(false); const [progress, setProgress] = useState({ stage: "vision", message: "" }); + /** Phones only: which step of the 3-step flow is showing. */ + const [step, setStep] = useState(1); + /** Phones only: the “paste text instead” field on step 1. */ + const [pasteOpen, setPasteOpen] = useState(false); + const isPhone = useIsPhone(); + const reduceMotion = useReducedMotion(); const folders = useSyncExternalStore( folderStore.subscribe, @@ -303,6 +310,452 @@ export function UploadScreen() { } }; + /* Shared controls: the desktop card and the phone step 2 render the same + folder / difficulty / count pickers, so their markup lives in one place. */ + const folderPicker = ( +
    + + {t("upload.freezeInto")} + +
    + {[{ id: UNORGANIZED_ID, name: t("common.unorganized") }, ...sortedFolders].map((folder) => { + const active = folderId === folder.id; + return ( + + ); + })} + {creatingFolder ? ( + + 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={t("common.newFolderName")} + placeholder={t("upload.folderPlaceholder")} + maxLength={60} + className="w-32 rounded-xl border border-[var(--hairline)] bg-[var(--surface-strong)] px-3 py-1.5 text-sm outline-none" + /> + + + ) : ( + + )} +
    +
    + ); + + const difficultyPicker = ( +
    + {DIFFICULTIES.map((option) => { + const selected = difficulty === option.value; + return ( + + ); + })} +
    + ); + + const countPicker = ( +
    + + {t("upload.activities")} + + {COUNT_OPTIONS.map((option) => ( + + ))} +
    + ); + + const errorCard = error ? ( +
    +

    + {error.kind === "needs_key" + ? t("upload.errNeedsKey") + : error.kind === "rate_limited" + ? t("upload.errRate") + : error.kind === "auth" + ? t("upload.errAuth") + : t("upload.errFailed")} +

    +

    {error.message}

    +
    + {error.kind === "needs_key" || error.kind === "auth" ? ( + + ) : null} + +
    +
    + ) : null; + + const phoneSummary = [ + text.trim() ? t("upload.summaryText") : "", + images.length > 0 ? plural(images.length, "upload.photoCount") : "", + documents.length > 0 ? plural(documents.length, "upload.docSummary") : "", + t(difficulty === "easy" ? "upload.easy" : difficulty === "hard" ? "upload.hard" : "upload.medium"), + plural(count, "deck.activities"), + folderId === UNORGANIZED_ID + ? t("common.unorganized") + : (sortedFolders.find((folder) => folder.id === folderId)?.name ?? t("common.unorganized")), + ] + .filter(Boolean) + .join(" · "); + const phoneHelper = documentsBusy + ? t("upload.readingDocs") + : hasContent + ? t("upload.ready") + : t("upload.addToStart"); + + /* Phones: the 3-step flow. Same state as the desktop card, different shape. */ + if (isPhone) { + const stepLabel = + step === 1 ? t("upload.phone.notes") : step === 2 ? t("upload.phone.options") : t("upload.phone.summary"); + const fileInput = ( + { + void addFiles(Array.from(event.target.files ?? [])); + event.target.value = ""; + }} + /> + ); + const cameraInput = ( + { + void addFiles(Array.from(event.target.files ?? [])); + event.target.value = ""; + }} + /> + ); + + return ( +
    +
    +

    + {t("upload.title")} +

    +

    {t("upload.introShort")}

    +
    + + {errorCard} + + {/* step indicator */} +
    +
    +

    {stepLabel}

    + {t("common.stepOf", { n: step, total: 3 })} +
    +
    +
    +
    +
    + + + {step === 1 ? ( +
    +
    + + +
    + + + {pasteOpen ? ( +