Mobile declutter pass: step flows, compact cards, simplified settings

Phones only (<=639px); desktop markup is untouched.

- New deck is a 3-step flow: progress (1/3 + bar) and a fixed Back/Next
  bar above the tab bar. Step 1 = Add photos or files / Camera / Paste
  text instead (no drop zone or paste copy on touch), step 2 = folder,
  difficulty and count with defaults plus one collapsed focus/notes row,
  step 3 = one summary line (photos/docs/text · difficulty · count ·
  folder) with Freeze. Going Back keeps every input; step transitions
  respect prefers-reduced-motion.
- Make my homework is a 2-step flow sharing the same pattern; tapping a
  mode card starts it (no separate CTA on phones), with a short preview
  of the assignment on step 2. The hero is now 'Make my homework' /
  'Udělej mi úkol' everywhere ('Solve your homework' is gone) and the
  intro is one line.
- Library: stats line removed, folder row and 'Unorganized' heading
  hidden until folders exist (New folder lives in the drawer), Import is
  a small icon button beside the tabs. Deck and homework cards are
  compact on phones: title, one meta line (activities · difficulty ·
  best), a one-line description that expands on tap, the whole card
  tappable and a small Study/Open pill instead of the full-width button.
- Settings sheet on phones: no .env.local subtitle; Language stays first;
  one Connection card (status dot + 'Connected · 2 models ready', key
  field with the eye inside, Save only when edited, auto re-check on
  save, refresh icon instead of Test connection); privacy one-liner with
  a More expander (daemon tip inside); raw health rows behind one
  collapsed Details list; Remove key as a small danger button at the very
  bottom (same confirmation).
- Decorative orbs on phones moved further off the screen edges so they
  never glow under cards or primary buttons.
- Shared markup for folder/difficulty/count and mode cards keeps one
  source of state; new lib/use-phone.ts gates the layouts.

Verified: new declutter suite 116/116 (360 + 390, EN + CS, photo + PDF +
pasted text, Back keeps input, summaries, settings flow), regression
74/74, mobile 52/52, i18n 25/25. Lint + build clean. SW freezmo-v20.
This commit is contained in:
Martin Dvořák 2026-10-10 10:43:32 +00:00
commit 6564e88138
12 changed files with 1345 additions and 309 deletions

View file

@ -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

View file

@ -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 {

View file

@ -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<string | null>(null);
const [addCount, setAddCount] = useState(5);
const [newFolder, setNewFolder] = useState<string | null>(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 (
<li className="card card-hover group relative flex flex-col p-5">
{/* Desktop: the whole card is a link; interactive children sit above it. */}
<li className="card card-hover group relative flex flex-col p-4 sm:p-5">
{/* Phones: the whole card is tappable too; interactive children sit above it. */}
<Link
href={`/deck/${deck.id}`}
aria-hidden="true"
tabIndex={-1}
className="absolute inset-0 hidden rounded-[1.5rem] lg:block"
className="absolute inset-0 hidden rounded-[1.5rem] max-sm:block lg:block"
/>
{/* header ------------------------------------------------------- */}
@ -154,7 +155,27 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
{truncate(deck.title, 90)}
</h3>
)}
<p className="mt-1 text-[12.5px] text-[var(--text-subtle)] lg:mt-3">
{/* Phones: one compact meta line. */}
<p className="mt-1 text-[12.5px] text-[var(--text-subtle)] sm:hidden">
{plural(deck.activities.length, "deck.activities")}
<span aria-hidden> · </span>
<span className={difficultyClass(deck.difficulty)}>
{deck.difficulty === "easy"
? t("upload.easy")
: deck.difficulty === "hard"
? t("upload.hard")
: t("upload.medium")}
</span>
{deck.bestScore !== undefined ? (
<>
<span aria-hidden> · </span>
<span className="text-emerald-700 dark:text-emerald-300">
{t("deck.bestLabel")} {deck.bestScore}%
</span>
</>
) : null}
</p>
<p className="mt-1 hidden text-[12.5px] text-[var(--text-subtle)] sm:block lg:mt-3">
{/* Phones keep the original date · activities · runs line. */}
<span className="lg:hidden">
{formatDate(deck.updatedAt)}
@ -193,7 +214,7 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
</div>
{/* tags --------------------------------------------------------- */}
<div className="mt-3 flex flex-wrap items-center gap-1.5">
<div className="mt-3 hidden flex-wrap items-center gap-1.5 sm:flex">
{deck.subject ? <Tag>{deck.subject}</Tag> : null}
<Tag>
<span className={difficultyClass(deck.difficulty)}>
@ -225,9 +246,22 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
{/* summary ------------------------------------------------------ */}
{deck.summary ? (
<p className="mt-3 line-clamp-2 text-[13.5px] leading-relaxed text-[var(--text-muted)]">
<>
<button
type="button"
aria-expanded={expanded}
onClick={() => setExpanded((value) => !value)}
className="relative z-10 mt-2 flex w-full items-start gap-1 text-left text-[13px] leading-relaxed text-[var(--text-muted)] sm:hidden"
>
<span className={cn("min-w-0 flex-1", !expanded && "line-clamp-1")}>{deck.summary}</span>
<ChevronDownIcon
className={cn("mt-0.5 size-4 shrink-0 text-[var(--text-subtle)] transition-transform", expanded && "rotate-180")}
/>
</button>
<p className="mt-3 hidden line-clamp-2 text-[13.5px] leading-relaxed text-[var(--text-muted)] sm:block">
{truncate(deck.summary, 160)}
</p>
</>
) : null}
{/* status ------------------------------------------------------- */}
@ -256,8 +290,18 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
) : null}
{/* single action ------------------------------------------------ */}
<div className="mt-5 lg:mt-4">
<Link href={`/deck/${deck.id}`} className="btn-primary h-10 w-full px-4 text-sm lg:hidden">
<div className="mt-4 flex justify-end lg:mt-4">
<Link
href={`/deck/${deck.id}`}
className="btn-primary touch-target relative z-10 h-9 gap-1.5 px-3.5 text-[13px] sm:hidden"
>
<PlayIcon className="size-4" />
{t("deck.study")}
</Link>
<Link
href={`/deck/${deck.id}`}
className="btn-primary hidden h-10 w-full px-4 text-sm sm:flex lg:hidden"
>
<PlayIcon className="size-4" />
{t("deck.study")}
</Link>

View file

@ -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 (
<li className="card card-hover group relative flex flex-col p-5">
{/* Desktop: the whole card opens the result; the delete button sits above it. */}
<Link href={href} aria-hidden="true" tabIndex={-1} className="absolute inset-0 hidden rounded-[1.5rem] lg:block" />
<li className="card card-hover group relative flex flex-col p-4 sm:p-5">
{/* Phones: the whole card opens the result too; the delete button sits above it. */}
<Link href={href} aria-hidden="true" tabIndex={-1} className="absolute inset-0 hidden rounded-[1.5rem] max-sm:block lg:block" />
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
@ -65,10 +66,23 @@ export function HomeworkCard({ record, onDelete }: { record: HomeworkRecord; onD
</div>
{preview ? (
<p className="mt-3 line-clamp-2 text-[13.5px] leading-relaxed text-[var(--text-muted)]">{preview}</p>
<>
<button
type="button"
aria-expanded={expanded}
onClick={() => setExpanded((value) => !value)}
className="relative z-10 mt-2 flex w-full items-start gap-1 text-left text-[13px] leading-relaxed text-[var(--text-muted)] sm:hidden"
>
<span className={cn("min-w-0 flex-1", !expanded && "line-clamp-1")}>{preview}</span>
<ChevronDownIcon
className={cn("mt-0.5 size-4 shrink-0 text-[var(--text-subtle)] transition-transform", expanded && "rotate-180")}
/>
</button>
<p className="mt-3 hidden line-clamp-2 text-[13.5px] leading-relaxed text-[var(--text-muted)] sm:block">{preview}</p>
</>
) : null}
<div className="mt-5 lg:mt-4">
<div className="mt-4 flex justify-end lg:mt-4">
{confirmDelete ? (
<div className="relative z-10 flex flex-wrap items-center gap-2">
<span className="text-[12.5px] text-[var(--text-muted)]">{t("homework.history.deleteConfirm")}</span>
@ -81,7 +95,14 @@ export function HomeworkCard({ record, onDelete }: { record: HomeworkRecord; onD
</div>
) : (
<>
<Link href={href} className="btn-primary touch-target h-10 w-full px-4 text-sm lg:hidden">
<Link
href={href}
className="btn-primary touch-target relative z-10 h-9 gap-1.5 px-3.5 text-[13px] sm:hidden"
>
<PlayIcon className="size-4" />
{t("homework.open")}
</Link>
<Link href={href} className="btn-primary hidden h-10 w-full px-4 text-sm sm:flex lg:hidden">
{t("homework.open")}
</Link>
<div className="relative z-10 hidden lg:flex lg:justify-end">

View file

@ -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" |
</p>
</div>
{/* Import sits above the tabs on every tab, so the header never
shifts when you switch pools. */}
<div className="flex flex-wrap items-center gap-2">
{/* Desktop keeps Import above the tabs; phones get a small icon
button in the tab row instead. */}
<div className="hidden flex-wrap items-center gap-2 sm:flex">
<Button
variant="secondary"
size="md"
@ -138,6 +134,7 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
<UploadIcon className="size-4" />
{t("library.import")}
</Button>
</div>
<input
ref={importRef}
type="file"
@ -148,10 +145,10 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
event.target.value = "";
}}
/>
</div>
{/* Two pools, never mixed: study decks and saved homework. */}
<div className="segmented w-full sm:max-w-sm">
<div className="flex items-center gap-2">
<div className="segmented min-w-0 flex-1 sm:max-w-sm">
<Link href="/" aria-current={tab === "decks" ? "page" : undefined}>
{t("tabDecks")}
</Link>
@ -159,29 +156,20 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
{t("tabHomework")}
</Link>
</div>
<button
type="button"
onClick={() => importRef.current?.click()}
aria-label={t("library.importAria")}
title={t("library.import")}
className="touch-target grid size-11 shrink-0 place-items-center rounded-2xl border border-[var(--glass-border)] bg-[var(--surface-strong)] text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)] sm:hidden"
>
<UploadIcon className="size-4" />
</button>
</div>
{/* Secondary stats folded into the header as quiet text on desktop,
where the sidebar already shows per-folder counts. */}
{tab === "decks" && decks.length > 0 ? (
<p className="text-[13px] text-[var(--text-subtle)] lg:hidden">
{plural(decks.length, "stats.decks")}
<span aria-hidden> · </span>
{plural(folderList.length, "stats.folders")}
<span aria-hidden> · </span>
{plural(totalActivities, "stats.activities")}
{played ? (
<>
<span aria-hidden> · </span>
{t("library.stats.best", { n: bestEver })}
</>
) : null}
</p>
) : 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 ? (
<FolderBar
className="lg:hidden"
folders={folderList}
@ -237,7 +225,7 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
return (
<section key={group.id} aria-label={group.name}>
{/* group header -------------------------------------- */}
<div className={cn("mb-4 flex items-center gap-2", !hasFolders && "lg:hidden")}>
<div className={cn("mb-4 flex items-center gap-2", !hasFolders && "hidden")}>
<FolderIcon className="size-4 shrink-0 text-[var(--text-subtle)]" />
{isEditing && editingFolder ? (

View file

@ -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 (
<AnimatePresence>
{open ? (
@ -149,11 +179,13 @@ export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () =
<h2 id="settings-title" className="text-lg font-semibold tracking-[-0.02em] text-frost-950 dark:text-frost-50">
{t("settings.title")}
</h2>
{!phone ? (
<p className="text-sm text-frost-600 dark:text-frost-300">
{t("settings.subtitle.before")}{" "}
<code className="rounded bg-frost-100 px-1 dark:bg-white/10">.env.local</code>{" "}
{t("settings.subtitle.after")}
</p>
) : null}
</div>
<Button
variant="ghost"
@ -174,6 +206,263 @@ export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () =
className="sheet-scroll thin-scroll min-h-0 flex-1 overflow-y-auto px-5 pb-safe"
>
<div ref={contentRef}>
{phone ? (
<>
{/* --------------------------------------------- language */}
<section className="mb-4 space-y-2 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
<h3 className="text-sm font-semibold text-frost-950 dark:text-frost-50">
{t("language.label")}
</h3>
<div className="segmented" role="group" aria-label={t("language.label")}>
{LOCALES.map((entry) => (
<button
key={entry.id}
type="button"
aria-pressed={locale === entry.id}
onClick={() => setLocale(entry.id)}
>
{entry.label}
</button>
))}
</div>
</section>
{/* ------------------------------------------- connection */}
<section className="mb-4 space-y-3 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-frost-950 dark:text-frost-50">
{t("settings.connection")}
</h3>
<button
type="button"
onClick={() => void refresh()}
aria-label={t("settings.recheck")}
title={t("settings.recheck")}
className="touch-target grid size-9 place-items-center rounded-full text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
>
<RefreshIcon className={cn("size-4", loading && "animate-spin")} />
</button>
</div>
<div className="flex items-center gap-2 text-[13px] font-medium" role="status">
<span aria-hidden className={cn("size-2.5 shrink-0 rounded-full", statusDot)} />
<span className="min-w-0 flex-1 text-[var(--text)]">{statusText}</span>
</div>
<div className="relative">
<label className="sr-only" htmlFor="ollama-api-key-phone">
{t("settings.apiKey")}
</label>
<input
id="ollama-api-key-phone"
type={reveal ? "text" : "password"}
value={keyDraft}
onChange={(event) => 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"
/>
<button
type="button"
onClick={() => setReveal((value) => !value)}
aria-label={reveal ? t("settings.hide") : t("settings.show")}
title={reveal ? t("settings.hide") : t("settings.show")}
className="absolute right-0.5 top-1/2 grid size-11 -translate-y-1/2 place-items-center rounded-lg text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
>
{reveal ? <EyeOffIcon className="size-4" /> : <EyeIcon className="size-4" />}
</button>
</div>
{keyDraft.trim() !== apiKey ? (
<Button size="sm" className="touch-target w-full" onClick={save}>
{t("common.save")}
</Button>
) : null}
<details className="group">
<summary className="touch-target flex cursor-pointer list-none flex-wrap items-center gap-1.5 text-[11px] font-medium text-[var(--text-muted)] [&::-webkit-details-marker]:hidden">
{t("settings.privacyLine")}
<span className="inline-flex items-center gap-0.5 font-semibold text-[var(--text)] underline underline-offset-2">
{t("common.more")}
<ChevronDownIcon className="size-3 transition-transform group-open:rotate-180" />
</span>
</summary>
<div className="space-y-1.5 pt-1.5">
<p className="text-[11px] leading-relaxed text-[var(--text-subtle)]">{t("settings.keyNote")}</p>
<p className="text-[11px] leading-relaxed text-[var(--text-subtle)]">
{t("settings.localTipTitle")} {t("settings.localTipSet")}{" "}
<code>OLLAMA_AUTH=local</code> {t("settings.keyNoteAfter")} <code>.env.local</code>{" "}
{t("settings.keyNoteEnd")} <code>ollama signin</code>
{t("settings.keyNoteEnd2")}
</p>
</div>
</details>
</section>
{/* ----------------------------------------------- install */}
{installAvailable ? (
<section className="mb-4 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-1.5">
<button
type="button"
onClick={() => {
onClose();
openInstallSheet();
}}
className="flex w-full items-center gap-3 rounded-xl px-2.5 py-2 text-left transition hover:bg-[var(--hover)]"
>
<span
aria-hidden
className="grid size-9 shrink-0 place-items-center rounded-xl border border-[var(--hairline)] bg-[var(--surface)] text-[var(--text-muted)]"
>
<DownloadIcon className="size-4" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-semibold text-frost-950 dark:text-frost-50">
{t("settings.installApp")}
</span>
<span className="block text-[12px] text-[var(--text-subtle)]">
{t("settings.addToHome")}
</span>
</span>
<ChevronRightIcon className="size-4 shrink-0 text-[var(--text-subtle)]" />
</button>
</section>
) : null}
{error ? (
<div className="mb-4 rounded-2xl bg-rose-100 p-3 text-sm text-rose-900 dark:bg-rose-500/15 dark:text-rose-200">
{error}
</div>
) : null}
{/* ----------------------------------------------- details */}
<details className="group mb-4 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
<summary className="touch-target flex cursor-pointer list-none items-center justify-between gap-2 text-sm font-semibold text-frost-950 dark:text-frost-50 [&::-webkit-details-marker]:hidden">
{t("settings.details")}
<ChevronDownIcon className="size-4 text-[var(--text-subtle)] transition-transform group-open:rotate-180" />
</summary>
<div className="pt-2">
{loading ? (
<p className="flex items-center gap-2 pb-2 text-[12px] text-[var(--text-muted)]">
<Spinner className="size-3.5" /> {t("settings.checking")}
</p>
) : checkedAt ? (
<p className="pb-2 text-[11px] font-medium text-[var(--text-subtle)]">
{freshnessLabel(checkedAt, locale, t)}
</p>
) : null}
<dl>
<DetailRow
label={t("health.auth")}
value={
health
? health.authMode === "user-key"
? t("health.authUserKey")
: health.authMode === "server"
? t("health.authServer")
: t("health.authLocal")
: "—"
}
/>
<DetailRow label={t("health.endpoint")} value={health?.baseUrl ?? "—"} mono />
<DetailRow
label={t("health.mode")}
value={
health
? health.mode === "direct-cloud"
? t("health.modeCloud")
: t("health.modeLocal")
: "—"
}
/>
<DetailRow
label={t("health.key")}
value={
health
? health.needsKey
? t("health.keyMissing")
: health.keyAccepted === true
? t("health.keyAccepted")
: health.keyAccepted === false
? t("health.keyRejected")
: health.authMode === "local"
? t("health.keyNotNeeded")
: t("health.keyUnknown")
: "—"
}
/>
<DetailRow
label={t("health.visionModel")}
value={health?.vision.resolved ?? "—"}
mono
badge={modelBadge(health?.vision)}
/>
<DetailRow
label={t("health.textModel")}
value={health?.text.resolved ?? "—"}
mono
badge={modelBadge(health?.text)}
/>
</dl>
{health?.mode === "direct-cloud" && health.needsKey ? (
<p className="pt-2 text-[11px] leading-relaxed text-[var(--text-subtle)]">
{t("health.createKey.before")}{" "}
<a
className="font-semibold underline underline-offset-2"
href="https://ollama.com/settings/keys"
target="_blank"
rel="noreferrer"
>
ollama.com/settings/keys
</a>{" "}
{t("health.createKey.after")} <code>ollama signin</code> {t("health.createKey.end")}{" "}
<code>OLLAMA_AUTH=local</code>.
</p>
) : null}
</div>
</details>
{/* ------------------------------- remove key (very bottom) */}
{apiKey ? (
confirmRemove ? (
<div className="rounded-xl border border-rose-300/70 bg-rose-50 p-3 dark:border-rose-400/25 dark:bg-rose-500/10">
<p className="text-[12px] leading-relaxed text-rose-900 dark:text-rose-100">
{t("settings.removeKeyConfirm")}
</p>
<div className="mt-1.5 flex flex-wrap gap-2">
<Button size="sm" variant="danger" className="touch-target" onClick={forget}>
{t("settings.removeKeyYes")}
</Button>
<Button
size="sm"
variant="ghost"
className="touch-target"
onClick={() => setConfirmRemove(false)}
>
{t("common.cancel")}
</Button>
</div>
</div>
) : (
<button
type="button"
onClick={() => setConfirmRemove(true)}
className="touch-target mx-auto block px-3 text-center text-[12.5px] font-medium text-rose-700 underline-offset-2 hover:underline dark:text-rose-300"
>
{t("settings.removeKey")}
</button>
)
) : null}
<div className="h-9" aria-hidden />
</>
) : (
<>
{/* --------------------------------------------- language */}
<section className="mb-4 space-y-2 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
<h3 className="text-sm font-semibold text-frost-950 dark:text-frost-50">
@ -375,6 +664,8 @@ export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () =
{/* keeps real content out of the 36px fade strip at the bottom */}
<div className="h-9" aria-hidden />
</>
)}
</div>
</div>
@ -483,6 +774,32 @@ function HealthPanel({ health }: { health: ReturnType<typeof useHealth>["health"
);
}
function DetailRow({
label,
value,
mono,
badge,
}: {
label: string;
value: string;
mono?: boolean;
badge?: { text: string; className: string };
}) {
return (
<div className="flex items-center justify-between gap-3 border-b border-[var(--hairline-soft)] py-1.5 last:border-0">
<dt className="shrink-0 text-[12px] text-[var(--text-subtle)]">{label}</dt>
<dd className={cn("flex min-w-0 items-center justify-end gap-1.5 text-right font-medium text-[var(--text)]", mono && "font-mono text-[11px]")}>
<span className="truncate">{value}</span>
{badge ? (
<span className={cn("shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-bold", badge.className)}>
{badge.text}
</span>
) : null}
</dd>
</div>
);
}
function Info({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div className="rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">

View file

@ -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<string | null>(null);
const [dragging, setDragging] = useState(false);
const [progress, setProgress] = useState<GenerateProgress>({ 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,29 +310,145 @@ export function UploadScreen() {
}
};
if (phase === "loading") {
return <LoadingState progress={progress} onCancel={() => abortRef.current?.abort()} />;
}
/* 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 = (
<div className="space-y-2">
<span className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
{t("upload.freezeInto")}
</span>
<div className="flex flex-wrap items-center gap-2">
{[{ id: UNORGANIZED_ID, name: t("common.unorganized") }, ...sortedFolders].map((folder) => {
const active = folderId === folder.id;
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>{t("upload.title")}</span>
</h1>
<p className="max-w-xl text-[15px] text-frost-600 dark:text-frost-300">
{t("upload.intro")}
</p>
</section>
<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={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"
/>
<Button
size="sm"
onClick={() => {
const created = createFolder(folderDraft);
if ("folder" in created) {
setFolderId(created.folder.id);
setFolderDraft("");
setCreatingFolder(false);
} else {
setFileError(created.error);
}
}}
>
{t("common.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-600 transition-colors hover:text-accent-700 dark:text-frost-400 dark:hover:text-accent-200"
>
<PlusIcon className="size-4" /> {t("common.newFolder")}
</button>
)}
</div>
</div>
);
{/* Dedicated flow: this page is the Study Deck uploader. Homework has
its own screen (/homework), reached from the library tab, the
drawer or the bottom bar — no mode picker here. */}
const difficultyPicker = (
<div role="radiogroup" aria-label={t("upload.difficultyAria")} 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)]">{t(option.labelKey)}</span>
<span className="hidden text-[11px] text-frost-600 sm:block dark:text-frost-400">{t(option.blurbKey)}</span>
</button>
);
})}
</div>
);
const countPicker = (
<div className="flex flex-wrap items-center gap-2">
<span className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
{t("upload.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
? "grad-ice border-transparent text-[#06111f]"
: "border-[var(--hairline)] text-[var(--text-muted)] hover:bg-[var(--hover)]",
)}
>
{option}
</button>
))}
</div>
);
{error ? (
const errorCard = 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"
@ -351,8 +474,312 @@ export function UploadScreen() {
</Button>
</div>
</div>
) : 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 = (
<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 = "";
}}
/>
);
const cameraInput = (
<input
ref={cameraInputRef}
type="file"
accept="image/*"
capture="environment"
className="sr-only"
onChange={(event) => {
void addFiles(Array.from(event.target.files ?? []));
event.target.value = "";
}}
/>
);
return (
<div className="relative pb-24">
<section className="space-y-1.5">
<h1 className="text-[24px] font-semibold leading-tight tracking-[-0.02em] text-[var(--text)]">
{t("upload.title")}
</h1>
<p className="text-[14px] text-frost-600 dark:text-frost-300">{t("upload.introShort")}</p>
</section>
{errorCard}
{/* step indicator */}
<div className="mt-5 space-y-2">
<div className="flex items-center justify-between gap-3 text-[12px] font-semibold text-[var(--text-subtle)]">
<h2 className="min-w-0 truncate">{stepLabel}</h2>
<span className="shrink-0 tabular-nums">{t("common.stepOf", { n: step, total: 3 })}</span>
</div>
<div
role="progressbar"
aria-label={stepLabel}
aria-valuemin={1}
aria-valuemax={3}
aria-valuenow={step}
className="h-1 overflow-hidden rounded-full bg-[var(--surface-sunken)]"
>
<div
className="grad-ice h-full rounded-full transition-[width] duration-300 motion-reduce:transition-none"
style={{ width: `${(step / 3) * 100}%` }}
/>
</div>
</div>
<motion.div
key={step}
initial={reduceMotion ? false : { opacity: 0, x: 18 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.24, ease: [0.22, 1, 0.36, 1] }}
className="mt-5"
>
{step === 1 ? (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-2">
<Button variant="secondary" size="lg" className="touch-target" onClick={() => fileInputRef.current?.click()}>
<FolderIcon className="size-4" /> {t("upload.addPhotosFiles")}
</Button>
<Button variant="secondary" size="lg" className="touch-target" onClick={() => cameraInputRef.current?.click()}>
<CameraIcon className="size-4" /> {t("upload.camera")}
</Button>
</div>
<button
type="button"
onClick={() => setPasteOpen((value) => !value)}
aria-expanded={pasteOpen}
className="text-[13px] font-semibold text-accent-700 dark:text-accent-200"
>
{t("upload.pasteInstead")}
</button>
{pasteOpen ? (
<textarea
value={text}
onChange={(event) => setText(event.target.value)}
rows={5}
placeholder={t("upload.pastedPlaceholder")}
aria-label={t("upload.pastedNotes")}
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"
/>
) : null}
{images.length > 0 || documents.length > 0 ? (
<ul className="flex flex-wrap items-center gap-2">
{images.map((image) => (
<li key={image.id} className="relative">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={image.dataUrl}
alt=""
className="size-14 rounded-xl border border-[var(--hairline)] object-cover"
/>
<button
type="button"
onClick={() => removeImage(image.id)}
aria-label={t("upload.remove", { name: image.name })}
className="absolute -right-1.5 -top-1.5 grid size-6 place-items-center rounded-full bg-frost-950/80 text-[11px] font-bold text-white"
>
✕
</button>
</li>
))}
{documents.map((entry) => (
<li
key={entry.id}
className="inline-flex max-w-full items-center gap-1.5 rounded-full border border-[var(--hairline)] bg-[var(--surface-strong)] py-1 pl-2 pr-1"
>
<span className="rounded-md bg-[var(--surface-sunken)] px-1.5 py-0.5 text-[10px] font-bold uppercase text-[var(--text-muted)]">
{KIND_LABELS[entry.kind]}
</span>
<span className="max-w-32 truncate text-[12.5px] font-medium text-[var(--text)]" title={entry.name}>
{entry.name}
</span>
{entry.status === "extracting" ? (
<span className="text-[11px] text-[var(--text-subtle)]" title={entry.progress ?? t("upload.reading")}>
…
</span>
) : null}
{entry.status === "error" ? (
<span className="text-[11px] font-bold text-rose-600 dark:text-rose-300" title={entry.error}>
!
</span>
) : null}
<button
type="button"
onClick={() => removeDocument(entry.id)}
aria-label={t("upload.remove", { name: entry.name })}
className="grid size-6 place-items-center rounded-full text-[11px] font-bold text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
>
✕
</button>
</li>
))}
</ul>
) : null}
{documents
.filter((entry) => entry.status === "error")
.map((entry) => (
<p key={entry.id} className="text-[12px] font-medium text-rose-600 dark:text-rose-300">
{entry.name}: {entry.error}
</p>
))}
{phase === "preparing" ? (
<p className="text-[12.5px] font-medium text-[var(--text-muted)]">{t("upload.compressing")}</p>
) : null}
{fileError ? (
<p role="alert" className="text-xs font-semibold text-rose-600 dark:text-rose-300">
{fileError}
</p>
) : null}
{fileInput}
{cameraInput}
</div>
) : null}
{step === 2 ? (
<div className="space-y-4">
{folderPicker}
{difficultyPicker}
{countPicker}
<details className="group rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
<summary className="flex cursor-pointer list-none items-center gap-2 text-[13px] font-semibold text-[var(--text)] [&::-webkit-details-marker]:hidden">
<ChevronDownIcon className="size-4 shrink-0 transition-transform group-open:rotate-180" />
{t("upload.focusExtra")}
</summary>
<div className="space-y-3 pt-3">
<div className="space-y-1.5">
<label
htmlFor="notes-text-phone"
className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]"
>
{t("upload.pastedNotes")}
</label>
<textarea
id="notes-text-phone"
value={text}
onChange={(event) => setText(event.target.value)}
rows={4}
placeholder={t("upload.pastedPlaceholder")}
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-phone"
className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]"
>
{t("upload.topicHint")}
</label>
<input
id="focus-phone"
value={focus}
onChange={(event) => setFocus(event.target.value)}
placeholder={t("upload.topicPlaceholder")}
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>
</div>
</details>
</div>
) : null}
{step === 3 ? (
<div className="space-y-2">
<p className="text-[16px] font-semibold leading-relaxed text-[var(--text)]">{phoneSummary}</p>
<p className="text-[12.5px] text-[var(--text-subtle)]">{phoneHelper}</p>
</div>
) : null}
</motion.div>
{/* Fixed step bar, floating just above the tab bar. */}
<div className="fixed inset-x-0 bottom-[calc(env(safe-area-inset-bottom,0px)+4.6rem)] z-20 px-4">
<div className="glass-panel mx-auto flex max-w-3xl items-center gap-2 p-2">
<Button
variant="secondary"
size="lg"
className="touch-target flex-1"
onClick={() => setStep((value) => Math.max(1, value - 1))}
disabled={step === 1}
>
{t("common.back")}
</Button>
{step < 3 ? (
<Button
size="lg"
className="touch-target flex-[1.5]"
onClick={() => setStep((value) => Math.min(3, value + 1))}
>
{t("common.next")}
</Button>
) : (
<Button size="lg" className="touch-target flex-[1.5]" onClick={() => void generate()} disabled={!canGenerate}>
<SparklesIcon className="size-5" /> {t("upload.freezeDeck")}
</Button>
)}
</div>
</div>
</div>
);
}
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>{t("upload.title")}</span>
</h1>
<p className="max-w-xl text-[15px] text-frost-600 dark:text-frost-300">
{t("upload.intro")}
</p>
</section>
{/* Dedicated flow: this page is the Study Deck uploader. Homework has
its own screen (/homework), reached from the library tab, the
drawer or the bottom bar — no mode picker here. */}
{errorCard}
{/* ---------------------------------------------------------- */}
<section
aria-label={t("upload.addPhotosAria")}
@ -587,136 +1014,9 @@ export function UploadScreen() {
{/* ---------------------------------------------------------- */}
<section className="card space-y-4 p-4 sm:p-5">
<h2 className="text-[15px] font-semibold tracking-[-0.01em] text-[var(--text)]">{t("upload.step3")}</h2>
<div className="space-y-2">
<span className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
{t("upload.freezeInto")}
</span>
<div className="flex flex-wrap items-center gap-2">
{[{ id: UNORGANIZED_ID, name: t("common.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={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"
/>
<Button
size="sm"
onClick={() => {
const created = createFolder(folderDraft);
if ("folder" in created) {
setFolderId(created.folder.id);
setFolderDraft("");
setCreatingFolder(false);
} else {
setFileError(created.error);
}
}}
>
{t("common.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-600 transition-colors hover:text-accent-700 dark:text-frost-400 dark:hover:text-accent-200"
>
<PlusIcon className="size-4" /> {t("common.newFolder")}
</button>
)}
</div>
</div>
<div role="radiogroup" aria-label={t("upload.difficultyAria")} 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)]">{t(option.labelKey)}</span>
<span className="hidden text-[11px] text-frost-600 sm:block dark:text-frost-400">{t(option.blurbKey)}</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)]">
{t("upload.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
? "grad-ice border-transparent text-[#06111f]"
: "border-[var(--hairline)] text-[var(--text-muted)] hover:bg-[var(--hover)]",
)}
>
{option}
</button>
))}
</div>
{folderPicker}
{difficultyPicker}
{countPicker}
</section>
{/* Same CTA block as the homework flow: full-width button with the

View file

@ -1,6 +1,7 @@
"use client";
import { useEffect, useRef } from "react";
import { motion, useReducedMotion } from "framer-motion";
import { useEffect, useRef, useState } from "react";
import { CameraIcon, FolderIcon, PlayIcon, PlusIcon, SparklesIcon } from "@/components/icons";
import { Button } from "@/components/ui";
@ -13,8 +14,9 @@ import type { HomeworkMode, HomeworkRecord } from "@/lib/schemas";
import { ACCEPTED_TYPES, formatBytes, imageFilesFromDataTransfer } from "@/lib/image";
import { useI18n } from "@/lib/i18n";
import type { useNotesIntake } from "@/lib/use-notes-intake";
import { useIsPhone } from "@/lib/use-phone";
import { openSettings } from "@/lib/ui-events";
import { cn } from "@/lib/utils";
import { cn, truncate } from "@/lib/utils";
import { HomeworkHistory } from "./HomeworkHistory";
@ -55,7 +57,8 @@ export function HomeworkInput({
text: string;
onTextChange: (text: string) => void;
intake: ReturnType<typeof useNotesIntake>;
onSubmit: () => void;
/** `modeOverride` lets the phone mode cards start the run they tapped. */
onSubmit: (modeOverride?: HomeworkMode) => void;
error: { message: string; kind?: string } | null;
history: HomeworkRecord[];
onOpen: (record: HomeworkRecord) => void;
@ -64,6 +67,10 @@ export function HomeworkInput({
const { t, plural } = useI18n();
const fileInputRef = useRef<HTMLInputElement>(null);
const cameraInputRef = useRef<HTMLInputElement>(null);
const [step, setStep] = useState(1);
const [pasteOpen, setPasteOpen] = useState(false);
const isPhone = useIsPhone();
const reduceMotion = useReducedMotion();
// Clipboard: screenshots become photos, long copied text becomes the assignment.
useEffect(() => {
@ -99,17 +106,7 @@ export function HomeworkInput({
const canSubmit = hasContent && !intake.busy && !documentsBusy;
const keyError = error?.kind === "needs_key" || error?.kind === "auth";
return (
<div className="relative space-y-6">
<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]">
<SparklesIcon className="size-8 shrink-0 text-frost-500 dark:text-frost-200" />
<span>{t("homeworkHeroTitle")}</span>
</h1>
<p className="max-w-xl text-[15px] text-frost-600 dark:text-frost-300">{t("homework.subtitle")}</p>
</section>
{error ? (
const errorCard = 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"
@ -122,13 +119,311 @@ export function HomeworkInput({
{t("upload.openSettings")}
</Button>
) : null}
<Button size="sm" variant={keyError ? "secondary" : "primary"} onClick={onSubmit}>
<Button size="sm" variant={keyError ? "secondary" : "primary"} onClick={() => onSubmit()}>
{t("common.retry")}
</Button>
</div>
</div>
) : null;
/* Shared mode card: desktop toggles the selection, the phone flow starts it. */
const modeCard = (option: (typeof MODES)[number], onPick: () => void) => {
const selected = mode === option.id;
return (
<button
key={option.id}
type="button"
aria-pressed={selected}
onClick={onPick}
className={cn(
"flex min-h-24 items-start gap-3 rounded-2xl border p-4 text-left transition",
selected
? "border-accent-400 bg-accent-50/70 ring-2 ring-accent-400/40 dark:bg-accent-500/15"
: "border-[var(--hairline)] bg-[var(--surface-strong)] hover:bg-[var(--hover)]",
)}
>
<span
aria-hidden
className={cn(
"grid size-10 shrink-0 place-items-center rounded-xl",
selected ? "grad-ice text-[#06111f]" : "bg-[var(--surface-sunken)] text-[var(--text-muted)]",
)}
>
{option.icon}
</span>
<span className="min-w-0">
<span className="block text-[15px] font-semibold leading-snug text-[var(--text)]">
{t(option.titleKey)}
</span>
<span className="mt-0.5 block text-[12.5px] leading-relaxed text-[var(--text-subtle)]">
{t(option.subtitleKey)}
</span>
</span>
</button>
);
};
const fileInput = (
<input
ref={fileInputRef}
type="file"
accept={`${ACCEPTED_TYPES.join(",")},${DOCUMENT_ACCEPT_ATTR}`}
multiple
className="sr-only"
onChange={(event) => {
void intake.addFiles(Array.from(event.target.files ?? []));
event.target.value = "";
}}
/>
);
const cameraInput = (
<input
ref={cameraInputRef}
type="file"
accept="image/*"
capture="environment"
className="sr-only"
onChange={(event) => {
void intake.addFiles(Array.from(event.target.files ?? []));
event.target.value = "";
}}
/>
);
/* Phones: the 2-step flow. Same intake and mode state as the desktop page. */
if (isPhone) {
const stepLabel = step === 1 ? t("homework.phone.assignment") : t("homework.phone.mode");
const fileSummary = [
intake.images.length > 0 ? plural(intake.images.length, "upload.photoCount") : "",
intake.documents.length > 0 ? plural(intake.documents.length, "upload.docSummary") : "",
]
.filter(Boolean)
.join(" · ");
const preview = text.trim()
? truncate(text.replace(/\s+/g, " ").trim(), 140)
: fileSummary || t("homework.source.need");
const showPaste = pasteOpen || text.trim().length > 0;
return (
<div className="relative pb-24">
<section className="space-y-1.5">
<h1 className="text-[24px] font-semibold leading-tight tracking-[-0.02em] text-[var(--text)]">
{t("homeworkHeroTitle")}
</h1>
<p className="text-[14px] text-frost-600 dark:text-frost-300">{t("homework.subtitle")}</p>
</section>
{errorCard}
<div className="mt-5 space-y-2">
<div className="flex items-center justify-between gap-3 text-[12px] font-semibold text-[var(--text-subtle)]">
<h2 className="min-w-0 truncate">{stepLabel}</h2>
<span className="shrink-0 tabular-nums">{t("common.stepOf", { n: step, total: 2 })}</span>
</div>
<div
role="progressbar"
aria-label={stepLabel}
aria-valuemin={1}
aria-valuemax={2}
aria-valuenow={step}
className="h-1 overflow-hidden rounded-full bg-[var(--surface-sunken)]"
>
<div
className="grad-ice h-full rounded-full transition-[width] duration-300 motion-reduce:transition-none"
style={{ width: `${(step / 2) * 100}%` }}
/>
</div>
</div>
<motion.div
key={step}
initial={reduceMotion ? false : { opacity: 0, x: 18 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.24, ease: [0.22, 1, 0.36, 1] }}
className="mt-5"
>
{step === 1 ? (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-2">
<Button
variant="secondary"
size="lg"
className="touch-target"
onClick={() => fileInputRef.current?.click()}
>
<FolderIcon className="size-4" /> {t("homework.source.add")}
</Button>
<Button
variant="secondary"
size="lg"
className="touch-target"
onClick={() => cameraInputRef.current?.click()}
>
<CameraIcon className="size-4" /> {t("upload.camera")}
</Button>
</div>
<button
type="button"
onClick={() => setPasteOpen((value) => !value)}
aria-expanded={showPaste}
className="text-[13px] font-semibold text-accent-700 dark:text-accent-200"
>
{t("upload.pasteInstead")}
</button>
{showPaste ? (
<textarea
id="homework-assignment-phone"
value={text}
onChange={(event) => onTextChange(event.target.value)}
onKeyDown={(event) => event.stopPropagation()}
rows={6}
placeholder={t("homework.source.placeholder")}
aria-label={t("homework.source.textLabel")}
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"
/>
) : null}
{intake.images.length > 0 ? (
<ul className="flex flex-wrap items-center gap-2">
{intake.images.map((image) => (
<li key={image.id} className="relative">
{/* eslint-disable-next-line @next/next/no-img-element -- local thumbnail, no optimisation needed */}
<img
src={image.dataUrl}
alt=""
className="size-14 rounded-xl border border-[var(--hairline)] object-cover"
/>
<button
type="button"
onClick={() => intake.removeImage(image.id)}
aria-label={t("upload.remove", { name: image.name })}
className="absolute -right-1.5 -top-1.5 grid size-6 place-items-center rounded-full bg-frost-950/80 text-[11px] font-bold text-white"
>
✕
</button>
</li>
))}
</ul>
) : null}
{intake.documents.length > 0 ? (
<ul className="flex flex-wrap items-center gap-2">
{intake.documents.map((entry) => (
<li
key={entry.id}
className="inline-flex max-w-full items-center gap-1.5 rounded-full border border-[var(--hairline)] bg-[var(--surface-strong)] py-1 pl-2 pr-1"
>
<span className="rounded-md bg-[var(--surface-sunken)] px-1.5 py-0.5 text-[10px] font-bold uppercase text-[var(--text-muted)]">
{entry.name.split(".").pop()?.slice(0, 4) ?? "doc"}
</span>
<span className="max-w-32 truncate text-[12.5px] font-medium text-[var(--text)]" title={entry.name}>
{entry.name}
</span>
{entry.status === "extracting" ? (
<span className="text-[11px] text-[var(--text-subtle)]" title={entry.progress ?? t("upload.reading")}>
…
</span>
) : null}
{entry.status === "error" ? (
<span className="text-[11px] font-bold text-rose-600 dark:text-rose-300" title={entry.error}>
!
</span>
) : null}
<button
type="button"
onClick={() => intake.removeDocument(entry.id)}
aria-label={t("upload.remove", { name: entry.name })}
className="grid size-6 place-items-center rounded-full text-[11px] font-bold text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
>
✕
</button>
</li>
))}
</ul>
) : null}
{intake.documents
.filter((entry) => entry.status === "error")
.map((entry) => (
<p key={entry.id} className="text-[12px] font-medium text-rose-600 dark:text-rose-300">
{entry.name}: {entry.error}
</p>
))}
{intake.busy ? (
<p className="text-[12.5px] font-medium text-[var(--text-muted)]">{t("upload.compressing")}</p>
) : null}
{intake.fileError ? (
<p role="alert" className="text-xs font-semibold text-rose-600 dark:text-rose-300">
{intake.fileError}
</p>
) : null}
{fileInput}
{cameraInput}
</div>
) : null}
{step === 2 ? (
<div className="space-y-3">
<div className="rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
<p className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
{t("homework.phone.preview")}
</p>
<p className="mt-1 text-[13px] leading-relaxed text-[var(--text)]">{preview}</p>
</div>
<div className="grid gap-3">
{MODES.map((option) =>
modeCard(option, () => {
if (!canSubmit) return;
onModeChange(option.id);
onSubmit(option.id);
}),
)}
</div>
<p className="text-center text-[12px] text-[var(--text-subtle)]">{t("homework.phone.startHint")}</p>
</div>
) : null}
</motion.div>
<HomeworkHistory records={history} onOpen={onOpen} onDelete={onDelete} />
{/* Fixed step bar, floating just above the tab bar. */}
<div className="fixed inset-x-0 bottom-[calc(env(safe-area-inset-bottom,0px)+4.6rem)] z-20 px-4">
<div className="glass-panel mx-auto flex max-w-3xl items-center gap-2 p-2">
<Button
variant="secondary"
size="lg"
className="touch-target flex-1"
onClick={() => setStep(1)}
disabled={step === 1}
>
{t("common.back")}
</Button>
{step === 1 ? (
<Button size="lg" className="touch-target flex-[1.5]" onClick={() => setStep(2)} disabled={!canSubmit}>
{t("common.next")}
</Button>
) : null}
</div>
</div>
</div>
);
}
return (
<div className="relative space-y-6">
<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]">
<SparklesIcon className="size-8 shrink-0 text-frost-500 dark:text-frost-200" />
<span>{t("homeworkHeroTitle")}</span>
</h1>
<p className="max-w-xl text-[15px] text-frost-600 dark:text-frost-300">{t("homework.subtitle")}</p>
</section>
{errorCard}
{/* ------------------------------------------------ the assignment */}
<section className="card p-4 sm:p-5">
<div className="flex flex-wrap items-center justify-between gap-3">
@ -285,47 +580,13 @@ export function HomeworkInput({
<section className="space-y-3">
<h2 className="text-[15px] font-semibold tracking-[-0.01em] text-[var(--text)]">{t("homework.mode.title")}</h2>
<div className="grid gap-3 sm:grid-cols-2">
{MODES.map((option) => {
const selected = mode === option.id;
return (
<button
key={option.id}
type="button"
aria-pressed={selected}
onClick={() => onModeChange(option.id)}
className={cn(
"flex min-h-24 items-start gap-3 rounded-2xl border p-4 text-left transition",
selected
? "border-accent-400 bg-accent-50/70 ring-2 ring-accent-400/40 dark:bg-accent-500/15"
: "border-[var(--hairline)] bg-[var(--surface-strong)] hover:bg-[var(--hover)]",
)}
>
<span
aria-hidden
className={cn(
"grid size-10 shrink-0 place-items-center rounded-xl",
selected ? "grad-ice text-[#06111f]" : "bg-[var(--surface-sunken)] text-[var(--text-muted)]",
)}
>
{option.icon}
</span>
<span className="min-w-0">
<span className="block text-[15px] font-semibold leading-snug text-[var(--text)]">
{t(option.titleKey)}
</span>
<span className="mt-0.5 block text-[12.5px] leading-relaxed text-[var(--text-subtle)]">
{t(option.subtitleKey)}
</span>
</span>
</button>
);
})}
{MODES.map((option) => modeCard(option, () => onModeChange(option.id)))}
</div>
</section>
{/* -------------------------------------------------------- CTA */}
<div className="space-y-2">
<Button size="lg" className="touch-target w-full" onClick={onSubmit} disabled={!canSubmit} loading={intake.busy}>
<Button size="lg" className="touch-target w-full" onClick={() => onSubmit()} disabled={!canSubmit} loading={intake.busy}>
<SparklesIcon className="size-5" />
{mode === "make" ? t("homework.start.make") : t("homework.start.guide")}
</Button>

View file

@ -58,7 +58,7 @@ export function HomeworkScreen({ initialRecordId }: { initialRecordId?: string }
}, [initialRecordId]);
/** Everything the API needs, built from the current input. */
const buildPayload = () => {
const buildPayload = (modeValue: HomeworkMode) => {
const readyDocuments = intake.documents.filter((entry) => entry.status === "ready" && entry.extracted);
const documentImages = readyDocuments.flatMap((entry) => entry.extracted!.images);
const documentText = documentsToText(readyDocuments.map((entry) => entry.extracted!));
@ -67,7 +67,7 @@ export function HomeworkScreen({ initialRecordId }: { initialRecordId?: string }
...intake.images.map((image) => image.dataUrl),
...documentImages.map((image) => image.dataUrl),
].slice(0, MAX_IMAGES_TO_SEND);
return { mode, images, text: mergedText };
return { mode: modeValue, images, text: mergedText };
};
const run = useCallback(
@ -138,8 +138,9 @@ export function HomeworkScreen({ initialRecordId }: { initialRecordId?: string }
[t],
);
const submit = () => {
const payload = buildPayload();
/** `modeOverride` lets the phone flow start straight from a tapped mode card. */
const submit = (modeOverride?: HomeworkMode) => {
const payload = buildPayload(modeOverride ?? mode);
void run(payload);
};

View file

@ -85,6 +85,11 @@ const en: Record<string, string> = {
"common.switchToLight": "Switch to light mode",
"common.switchToDark": "Switch to dark mode",
"common.menu": "Menu",
"common.back": "Back",
"common.next": "Next",
"common.more": "More",
"common.less": "Less",
"common.stepOf": "{n} / {total}",
/* header ---------------------------------------------------------- */
"header.openMenu": "Open menu",
@ -134,6 +139,14 @@ const en: Record<string, string> = {
"settings.installApp": "Install app",
"settings.addToHome": "Add Freezmo to your home screen",
"settings.checking": "Checking Ollama…",
"settings.connection": "Connection",
"settings.connected": "Connected",
"settings.modelsReady.one": "{n} model ready",
"settings.modelsReady.few": "{n} models ready",
"settings.modelsReady.many": "{n} models ready",
"settings.details": "Details",
"settings.privacyLine": "Your key stays in this browser.",
"settings.recheck": "Re-check connection",
/* health ---------------------------------------------------------- */
"health.ok": "Ollama is reachable and both models are available.",
@ -169,9 +182,12 @@ const en: Record<string, string> = {
/* make my homework ------------------------------------------------- */
"homework.nav": "Make my homework",
"homeworkHeroTitle": "Solve your homework",
"homework.subtitle":
"Paste the assignment, snap a photo or add a document — then choose whether I should just do it, or guide you through it.",
"homeworkHeroTitle": "Make my homework",
"homework.subtitle": "Paste it, photograph it or add a file — then pick how I should help.",
"homework.phone.assignment": "The assignment",
"homework.phone.mode": "How should I help?",
"homework.phone.preview": "Assignment preview",
"homework.phone.startHint": "Tap a card to start.",
"homework.source.title": "1. The assignment",
"homework.source.help": "Paste its text, drop photos, or add a document.",
"homework.source.textLabel": "Assignment text",
@ -235,12 +251,13 @@ const en: Record<string, string> = {
/* quick actions (library empty state + /create switcher) ----------- */
"emptyStateCreateDeck": "Create study deck",
"emptyStateHomework": "Solve homework",
"emptyStateHomework": "Make my homework",
/* library --------------------------------------------------------- */
"library.title": "Your icebox",
"library.subtitle": "Decks frozen from your notes, saved in this browser.",
"library.import": "Import JSON",
"library.importAria": "Import deck JSON",
"library.folderCreated": "Folder created — decks you freeze while it's selected land here.",
"library.imported": "Imported “{title}”.",
"library.couldNotRead": "Could not read that file.",
@ -303,8 +320,19 @@ const en: Record<string, string> = {
/* upload ---------------------------------------------------------- */
"upload.title": "Turn your notes into a study feed",
"upload.introShort": "Add your notes — I'll build the deck.",
"upload.intro":
"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.",
"upload.addPhotosFiles": "Add photos or files",
"upload.pasteInstead": "Paste text instead",
"upload.focusExtra": "Add a focus or extra notes (optional)",
"upload.phone.notes": "Your notes",
"upload.phone.options": "Options",
"upload.phone.summary": "Ready to freeze",
"upload.summaryText": "Text",
"upload.photoCount.one": "{n} photo",
"upload.photoCount.few": "{n} photos",
"upload.photoCount.many": "{n} photos",
"upload.errNeedsKey": "API key required",
"upload.errRate": "Slow down a moment",
"upload.errAuth": "Ollama rejected your key",
@ -675,6 +703,11 @@ const cs: Record<string, string> = {
"common.switchToLight": "Přepnout na světlý režim",
"common.switchToDark": "Přepnout na tmavý režim",
"common.menu": "Menu",
"common.back": "Zpět",
"common.next": "Další",
"common.more": "Více",
"common.less": "Méně",
"common.stepOf": "{n} / {total}",
/* header ---------------------------------------------------------- */
"header.openMenu": "Otevřít menu",
@ -724,6 +757,14 @@ const cs: Record<string, string> = {
"settings.installApp": "Nainstalovat aplikaci",
"settings.addToHome": "Přidat Freezmo na domovskou obrazovku",
"settings.checking": "Kontroluji Ollamu…",
"settings.connection": "Připojení",
"settings.connected": "Připojeno",
"settings.modelsReady.one": "{n} model připraven",
"settings.modelsReady.few": "{n} modely připraveny",
"settings.modelsReady.many": "{n} modelů připraveno",
"settings.details": "Podrobnosti",
"settings.privacyLine": "Tvůj klíč zůstává v tomto prohlížeči.",
"settings.recheck": "Zkontrolovat připojení",
/* health ---------------------------------------------------------- */
"health.ok": "Ollama je dostupná a oba modely jsou k dispozici.",
@ -759,9 +800,12 @@ const cs: Record<string, string> = {
/* make my homework ------------------------------------------------- */
"homework.nav": "Udělej mi úkol",
"homeworkHeroTitle": "Vyřeš svůj úkol",
"homework.subtitle":
"Vlož zadání, vyfoť ho nebo přidej dokument — a pak si vyber, jestli to mám rovnou udělat, nebo tě tím provést.",
"homeworkHeroTitle": "Udělej mi úkol",
"homework.subtitle": "Vlož ho, vyfoť nebo přidej soubor — a vyber, jak ti mám pomoct.",
"homework.phone.assignment": "Zadání",
"homework.phone.mode": "Jak ti mám pomoct?",
"homework.phone.preview": "Náhled zadání",
"homework.phone.startHint": "Klepni na kartu a začnu.",
"homework.source.title": "1. Zadání",
"homework.source.help": "Vlož jeho text, přetáhni fotky nebo přidej dokument.",
"homework.source.textLabel": "Text zadání",
@ -825,12 +869,13 @@ const cs: Record<string, string> = {
/* quick actions (library empty state + /create switcher) ----------- */
"emptyStateCreateDeck": "Vytvořit studijní balíček",
"emptyStateHomework": "Vyřešit úkol",
"emptyStateHomework": "Udělej mi úkol",
/* library --------------------------------------------------------- */
"library.title": "Tvoje mraznička",
"library.subtitle": "Sady zmrazené z tvých poznámek, uložené v tomto prohlížeči.",
"library.import": "Importovat JSON",
"library.importAria": "Importovat JSON sady",
"library.folderCreated": "Složka vytvořena — sady, které zmrazíš, když je vybraná, přistanou tady.",
"library.imported": "Importováno „{title}“.",
"library.couldNotRead": "Tento soubor se nepodařilo přečíst.",
@ -893,8 +938,19 @@ const cs: Record<string, string> = {
/* upload ---------------------------------------------------------- */
"upload.title": "Proměň poznámky ve studijní feed",
"upload.introShort": "Přidej poznámky — sadu postavím já.",
"upload.intro":
"Vyfoť nebo přetáhni fotky svých poznámek. Freezmo je přečte tvým vlastním vizuálním modelem Ollama a postaví prolistovatelnou sadu mini her.",
"upload.addPhotosFiles": "Přidat fotky nebo soubory",
"upload.pasteInstead": "Místo toho vložit text",
"upload.focusExtra": "Přidat zaměření nebo poznámky navíc (nepovinné)",
"upload.phone.notes": "Tvoje poznámky",
"upload.phone.options": "Možnosti",
"upload.phone.summary": "Připraveno ke zmrazení",
"upload.summaryText": "Text",
"upload.photoCount.one": "{n} fotka",
"upload.photoCount.few": "{n} fotky",
"upload.photoCount.many": "{n} fotek",
"upload.errNeedsKey": "Je vyžadován API klíč",
"upload.errRate": "Zpomal na chvíli",
"upload.errAuth": "Ollama odmítla tvůj klíč",

22
lib/use-phone.ts Normal file
View file

@ -0,0 +1,22 @@
"use client";
import { useEffect, useState } from "react";
/**
* True on phone-sized viewports (≤639px), false during SSR and on desktop.
* The mobile-only step flows switch on this; everything wider keeps the
* desktop layout untouched.
*/
export function useIsPhone(): boolean {
const [phone, setPhone] = useState(false);
useEffect(() => {
const query = window.matchMedia("(max-width: 639px)");
const sync = () => setPhone(query.matches);
sync();
query.addEventListener("change", sync);
return () => query.removeEventListener("change", sync);
}, []);
return phone;
}

View file

@ -9,7 +9,7 @@
*
* Bumping CACHE_VERSION is how a new deploy invalidates old assets.
*/
const CACHE_VERSION = "freezmo-v19";
const CACHE_VERSION = "freezmo-v20";
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const ASSET_CACHE = `${CACHE_VERSION}-assets`;