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:
parent
0ee9e930f0
commit
6564e88138
12 changed files with 1345 additions and 309 deletions
28
README.md
28
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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)]">
|
||||
{truncate(deck.summary, 160)}
|
||||
</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")}>{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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,50 +134,42 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
|
|||
<UploadIcon className="size-4" />
|
||||
{t("library.import")}
|
||||
</Button>
|
||||
<input
|
||||
ref={importRef}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
className="sr-only"
|
||||
onChange={(event) => {
|
||||
void handleImport(event.target.files?.[0]);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
ref={importRef}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
className="sr-only"
|
||||
onChange={(event) => {
|
||||
void handleImport(event.target.files?.[0]);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Two pools, never mixed: study decks and saved homework. */}
|
||||
<div className="segmented w-full sm:max-w-sm">
|
||||
<Link href="/" aria-current={tab === "decks" ? "page" : undefined}>
|
||||
{t("tabDecks")}
|
||||
</Link>
|
||||
<Link href="/?tab=homework" aria-current={tab === "homework" ? "page" : undefined}>
|
||||
{t("tabHomework")}
|
||||
</Link>
|
||||
<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>
|
||||
<Link href="/?tab=homework" aria-current={tab === "homework" ? "page" : undefined}>
|
||||
{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 ? (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<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>
|
||||
{!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">
|
||||
|
|
|
|||
|
|
@ -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,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 = (
|
||||
<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>
|
||||
);
|
||||
|
||||
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>
|
||||
);
|
||||
|
||||
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"
|
||||
>
|
||||
<p className="font-bold">
|
||||
{error.kind === "needs_key"
|
||||
? t("upload.errNeedsKey")
|
||||
: error.kind === "rate_limited"
|
||||
? t("upload.errRate")
|
||||
: error.kind === "auth"
|
||||
? t("upload.errAuth")
|
||||
: t("upload.errFailed")}
|
||||
</p>
|
||||
<p className="mt-1">{error.message}</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{error.kind === "needs_key" || error.kind === "auth" ? (
|
||||
<Button size="sm" onClick={openSettings}>
|
||||
{t("upload.openSettings")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button size="sm" variant={error.kind === "needs_key" || error.kind === "auth" ? "secondary" : "primary"} onClick={() => void generate()}>
|
||||
{t("common.retry")}
|
||||
</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()} />;
|
||||
}
|
||||
|
|
@ -325,33 +778,7 @@ export function UploadScreen() {
|
|||
drawer or the bottom bar — no mode picker here. */}
|
||||
|
||||
|
||||
{error ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="animate-rise rounded-3xl border-2 border-rose-300 bg-rose-50/95 p-4 text-sm text-rose-900 dark:border-rose-400/40 dark:bg-rose-400/10 dark:text-rose-100"
|
||||
>
|
||||
<p className="font-bold">
|
||||
{error.kind === "needs_key"
|
||||
? t("upload.errNeedsKey")
|
||||
: error.kind === "rate_limited"
|
||||
? t("upload.errRate")
|
||||
: error.kind === "auth"
|
||||
? t("upload.errAuth")
|
||||
: t("upload.errFailed")}
|
||||
</p>
|
||||
<p className="mt-1">{error.message}</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{error.kind === "needs_key" || error.kind === "auth" ? (
|
||||
<Button size="sm" onClick={openSettings}>
|
||||
{t("upload.openSettings")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button size="sm" variant={error.kind === "needs_key" || error.kind === "auth" ? "secondary" : "primary"} onClick={() => void generate()}>
|
||||
{t("common.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{errorCard}
|
||||
|
||||
{/* ---------------------------------------------------------- */}
|
||||
<section
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,6 +106,312 @@ export function HomeworkInput({
|
|||
const canSubmit = hasContent && !intake.busy && !documentsBusy;
|
||||
const keyError = error?.kind === "needs_key" || error?.kind === "auth";
|
||||
|
||||
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"
|
||||
>
|
||||
<p className="font-bold">{keyError ? t("upload.errAuth") : t("homework.error.title")}</p>
|
||||
<p className="mt-1">{error.message || t("homework.error.body")}</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{keyError ? (
|
||||
<Button size="sm" onClick={openSettings}>
|
||||
{t("upload.openSettings")}
|
||||
</Button>
|
||||
) : null}
|
||||
<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">
|
||||
|
|
@ -109,25 +422,7 @@ export function HomeworkInput({
|
|||
<p className="max-w-xl text-[15px] text-frost-600 dark:text-frost-300">{t("homework.subtitle")}</p>
|
||||
</section>
|
||||
|
||||
{error ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="animate-rise rounded-3xl border-2 border-rose-300 bg-rose-50/95 p-4 text-sm text-rose-900 dark:border-rose-400/40 dark:bg-rose-400/10 dark:text-rose-100"
|
||||
>
|
||||
<p className="font-bold">{keyError ? t("upload.errAuth") : t("homework.error.title")}</p>
|
||||
<p className="mt-1">{error.message || t("homework.error.body")}</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{keyError ? (
|
||||
<Button size="sm" onClick={openSettings}>
|
||||
{t("upload.openSettings")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button size="sm" variant={keyError ? "secondary" : "primary"} onClick={onSubmit}>
|
||||
{t("common.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{errorCard}
|
||||
|
||||
{/* ------------------------------------------------ the assignment */}
|
||||
<section className="card p-4 sm:p-5">
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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
22
lib/use-phone.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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`;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue