Wizard polish: step headlines, gated Next, real summary, portal action bar
Mobile only (<=639px); desktop markup untouched. Bug fix: the Back/Next bar was fixed-positioned inside the screen entrance animation's transformed wrapper, so it painted under the content and jumped to the viewport bottom about a second in. It is now portalled to <body> and only fades in, pinned to the bottom from the first frame. The other fixed/sticky elements (tab bar, drawer, sheets, install sheet) were checked and live outside the animated wrappers. - The step name is now the large headline with a one-line description; the repeated page title is dropped on phones. Thin progress bar + 1/3. - Step 1: formats/limits hint, one big dashed Add photos or files area (plus icon), Camera and Paste text instead as secondaries, and a 'nothing added yet' placeholder that becomes the thumbnail/chip list. - Next is disabled until a photo, file or pasted text exists; step 3 can never be reached empty. - Step 1 shows Cancel (back to the Library) instead of a dead Back. - Step 3 is a real summary: thumbnail/chip strip plus Files, Difficulty, Activities and Folder rows, each with an Edit link to its step; Freeze is the single primary action. - Step 2 got a visible Difficulty label and a helper line under every group, defaults still preselected. - Bottom chrome: flat full-width Back/Next bar above the safe area, and the tab bar hides inside both wizards. The lower-right orb is hidden on phones so it can never glow behind the bar. - Make my homework matches: headline, hint, big add area, placeholder, Cancel, gated Next, flat bar. Verified: new wizard suite 80/80 (bar position sampled at 5 frames during the entrance from tab bar/drawer/empty state; 360 + 390, dark + light, EN + CS, reduced motion on/off; gating, summary Edit links, hidden tab bar and orb), declutter 116/116, regression 74/74, mobile 52/52, animation 8/8, i18n 25/25. Lint + build clean. SW freezmo-v22.
This commit is contained in:
parent
374752c59e
commit
e66141e9a0
9 changed files with 338 additions and 132 deletions
32
README.md
32
README.md
|
|
@ -43,12 +43,16 @@ no database, no data leaving your machine except the requests you send to Ollama
|
||||||
**Capture & generate**
|
**Capture & generate**
|
||||||
|
|
||||||
- Drag & drop, file picker, **mobile camera capture** and **clipboard paste** (Ctrl/⌘+V)
|
- 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
|
- **Phones get a 3-step flow** — notes → options → summary — with a thin progress bar and
|
||||||
floating Back / Next bar above the tab bar. Step 1 is just *Add photos or files*,
|
“1 / 3”. Step 1 leads with the step name as the headline, a formats/limits hint, one big
|
||||||
*Camera* and a *Paste text instead* field (no drop zone on touch screens); step 2 keeps
|
*Add photos or files* area plus *Camera* and *Paste text instead*, and a “nothing added
|
||||||
folder, difficulty and activity count at their defaults and tucks the topic hint and
|
yet” placeholder that becomes the thumbnail/chip list. Next stays disabled until something
|
||||||
pasted notes behind one collapsed row; step 3 is a single summary line with **Freeze my
|
is added. Step 2 keeps folder, difficulty and count at their defaults with a helper under
|
||||||
deck**. Going Back keeps every input. The desktop page is unchanged.
|
each group and tucks the topic hint and pasted notes behind one collapsed row. Step 3 is a
|
||||||
|
real summary — thumbnails/chips, files, difficulty, activities and folder, each with an
|
||||||
|
*Edit* link back to its step. A flat Back/Cancel + Next/Freeze bar sits at the bottom above
|
||||||
|
the safe area (the tab bar hides inside the wizard), and it is portalled to `<body>` so the
|
||||||
|
screen entrance animation can never displace it. The desktop page is unchanged.
|
||||||
- **Photos and documents**: JPG/PNG/WEBP/HEIC images plus **PDF, DOCX, PPTX, ODT/ODP/ODS, TXT, MD, CSV**
|
- **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
|
- 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.
|
as images for the vision model, DOCX/PPTX text, speaker notes and embedded images are pulled out.
|
||||||
|
|
@ -92,8 +96,10 @@ no database, no data leaving your machine except the requests you send to Ollama
|
||||||
- Two modes: **Just make it** returns the finished homework (the answer only, ready to copy), and
|
- 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
|
**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
|
- 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
|
deck flow, with its own step headline, formats hint, big add area and empty placeholder),
|
||||||
immediately, so there is no separate button. Desktop keeps the select-then-confirm layout.
|
then the two mode cards under a short preview — tapping a card starts it immediately, so
|
||||||
|
there is no separate button. Step 1 starts with *Cancel* and the flat action bar matches
|
||||||
|
the deck wizard. 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
|
- 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**
|
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`
|
built from the normal activity components and graded through `/api/grade`
|
||||||
|
|
@ -954,6 +960,16 @@ Things I chose while building, with the reasoning:
|
||||||
the same feature set the current `motion` import already pulls in. Verified with a
|
the same feature set the current `motion` import already pulls in. Verified with a
|
||||||
4x-CPU-throttled mobile trace (no long tasks, CLS 0) and a reduced-motion run.
|
4x-CPU-throttled mobile trace (no long tasks, CLS 0) and a reduced-motion run.
|
||||||
|
|
||||||
|
38. **Wizard chrome lives outside the animated tree.** The step name is the headline, the
|
||||||
|
action bar is a flat full-width bar at the bottom (the tab bar hides inside the wizard),
|
||||||
|
and the bar is rendered through a React portal to `<body>`. That last part is a real bug
|
||||||
|
fix: the screen entrance animation puts a transform on the content wrapper, and a
|
||||||
|
transform on an ancestor becomes the containing block for `position: fixed`, so the bar
|
||||||
|
used to paint inside the content and jump to the viewport bottom a second later. It now
|
||||||
|
only fades in, pinned to the bottom from its first frame — verified by sampling its
|
||||||
|
position across the entrance (5 frames, 60–450ms) from the tab bar, the drawer and the
|
||||||
|
empty state, at 360/390, both themes, both languages and with reduced motion.
|
||||||
|
|
||||||
14. **`gpt-oss:120b` does all content work — but it cannot read images** (`this model does not
|
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`).
|
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
|
Text-only inputs — pasted text, PDFs with a text layer, DOCX/PPTX/ODT — never touch the vision
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import { ApiKeyGate } from "@/components/ApiKeyGate";
|
||||||
import { AppHeader } from "@/components/AppHeader";
|
import { AppHeader } from "@/components/AppHeader";
|
||||||
import { RouteTransition } from "@/components/RouteTransition";
|
import { RouteTransition } from "@/components/RouteTransition";
|
||||||
import { MobileSidebar, Sidebar, SidebarOffset } from "@/components/Sidebar";
|
import { MobileSidebar, Sidebar, SidebarOffset } from "@/components/Sidebar";
|
||||||
import { TabBar } from "@/components/TabBar";
|
|
||||||
import { UploadScreen } from "@/components/UploadScreen";
|
import { UploadScreen } from "@/components/UploadScreen";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
|
|
@ -34,7 +33,6 @@ export default async function CreatePage({
|
||||||
<UploadScreen />
|
<UploadScreen />
|
||||||
</RouteTransition>
|
</RouteTransition>
|
||||||
</div>
|
</div>
|
||||||
<TabBar />
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</ApiKeyGate>
|
</ApiKeyGate>
|
||||||
|
|
|
||||||
|
|
@ -312,7 +312,7 @@ body {
|
||||||
.ice-blob { animation: none; }
|
.ice-blob { animation: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Phones: fewer, softer blobs so the drift animation stays cheap. Unlayered
|
/* Phones: fewer, softer blobs so the drift animation stays cheap. Unlayered
|
||||||
on purpose — layered rules would lose to the .ice-blob declarations above. */
|
on purpose — layered rules would lose to the .ice-blob declarations above. */
|
||||||
@media (max-width: 1023px) {
|
@media (max-width: 1023px) {
|
||||||
.ice-blob-c,
|
.ice-blob-c,
|
||||||
|
|
@ -339,11 +339,11 @@ body {
|
||||||
--ice-orb-opacity: 0.42;
|
--ice-orb-opacity: 0.42;
|
||||||
}
|
}
|
||||||
/* Phones: orbs hug the screen edges so they can never sit under the
|
/* 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
|
content column's cards or primary buttons. The lower-right orb still
|
||||||
glow right behind the main CTAs). */
|
read as a glow behind the wizard action bar, so phones drop it. */
|
||||||
.ice-orb-1 { width: 96px; height: 96px; top: -44px; left: -48px; }
|
.ice-orb-1 { width: 96px; height: 96px; top: -44px; left: -48px; }
|
||||||
.ice-orb-2 { width: 72px; height: 72px; top: 60%; left: -44px; }
|
.ice-orb-2 { width: 72px; height: 72px; top: 60%; left: -44px; }
|
||||||
.ice-orb-3 { width: 124px; height: 124px; bottom: 12vh; right: -84px; }
|
.ice-orb-3 { display: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import { AppHeader } from "@/components/AppHeader";
|
||||||
import { HomeworkScreen } from "@/components/homework/HomeworkScreen";
|
import { HomeworkScreen } from "@/components/homework/HomeworkScreen";
|
||||||
import { RouteTransition } from "@/components/RouteTransition";
|
import { RouteTransition } from "@/components/RouteTransition";
|
||||||
import { MobileSidebar, Sidebar, SidebarOffset } from "@/components/Sidebar";
|
import { MobileSidebar, Sidebar, SidebarOffset } from "@/components/Sidebar";
|
||||||
import { TabBar } from "@/components/TabBar";
|
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "Make my homework",
|
title: "Make my homework",
|
||||||
|
|
@ -34,7 +33,6 @@ export default async function HomeworkPage({
|
||||||
<HomeworkScreen initialRecordId={typeof open === "string" ? open : undefined} />
|
<HomeworkScreen initialRecordId={typeof open === "string" ? open : undefined} />
|
||||||
</RouteTransition>
|
</RouteTransition>
|
||||||
</div>
|
</div>
|
||||||
<TabBar />
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</ApiKeyGate>
|
</ApiKeyGate>
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ import { cn, uid } from "@/lib/utils";
|
||||||
import { CameraIcon, ChevronDownIcon, FolderIcon, IceCubeIcon, PlusIcon, SnowflakeIcon, SparklesIcon, SunIcon } from "@/components/icons";
|
import { CameraIcon, ChevronDownIcon, FolderIcon, IceCubeIcon, PlusIcon, SnowflakeIcon, SparklesIcon, SunIcon } from "@/components/icons";
|
||||||
import { SegmentedPill } from "@/components/SegmentedPill";
|
import { SegmentedPill } from "@/components/SegmentedPill";
|
||||||
import { Button, ProgressBar } from "@/components/ui";
|
import { Button, ProgressBar } from "@/components/ui";
|
||||||
|
import { WizardActionBar } from "@/components/WizardActionBar";
|
||||||
|
|
||||||
type Phase = "idle" | "preparing" | "loading" | "error";
|
type Phase = "idle" | "preparing" | "loading" | "error";
|
||||||
|
|
||||||
|
|
@ -492,18 +493,6 @@ export function UploadScreen() {
|
||||||
</div>
|
</div>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
const phoneSummary = [
|
|
||||||
text.trim() ? t("upload.summaryText") : "",
|
|
||||||
images.length > 0 ? plural(images.length, "upload.photoCount") : "",
|
|
||||||
documents.length > 0 ? plural(documents.length, "upload.docSummary") : "",
|
|
||||||
t(difficulty === "easy" ? "upload.easy" : difficulty === "hard" ? "upload.hard" : "upload.medium"),
|
|
||||||
plural(count, "deck.activities"),
|
|
||||||
folderId === UNORGANIZED_ID
|
|
||||||
? t("common.unorganized")
|
|
||||||
: (sortedFolders.find((folder) => folder.id === folderId)?.name ?? t("common.unorganized")),
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(" · ");
|
|
||||||
const phoneHelper = documentsBusy
|
const phoneHelper = documentsBusy
|
||||||
? t("upload.readingDocs")
|
? t("upload.readingDocs")
|
||||||
: hasContent
|
: hasContent
|
||||||
|
|
@ -514,6 +503,31 @@ export function UploadScreen() {
|
||||||
if (isPhone) {
|
if (isPhone) {
|
||||||
const stepLabel =
|
const stepLabel =
|
||||||
step === 1 ? t("upload.phone.notes") : step === 2 ? t("upload.phone.options") : t("upload.phone.summary");
|
step === 1 ? t("upload.phone.notes") : step === 2 ? t("upload.phone.options") : t("upload.phone.summary");
|
||||||
|
const stepDesc =
|
||||||
|
step === 1
|
||||||
|
? t("upload.phone.notesDesc", { images: MAX_IMAGES, documents: MAX_DOCUMENTS })
|
||||||
|
: step === 2
|
||||||
|
? t("upload.phone.optionsDesc")
|
||||||
|
: t("upload.phone.summaryDesc");
|
||||||
|
const filesSummary =
|
||||||
|
images.length > 0 || documents.length > 0
|
||||||
|
? [
|
||||||
|
images.length > 0 ? plural(images.length, "upload.photoCount") : "",
|
||||||
|
documents.length > 0 ? plural(documents.length, "upload.docSummary") : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ")
|
||||||
|
: t("upload.summaryText");
|
||||||
|
const difficultyLabel =
|
||||||
|
difficulty === "easy" ? t("upload.easy") : difficulty === "hard" ? t("upload.hard") : t("upload.medium");
|
||||||
|
const folderLabel =
|
||||||
|
folderId === UNORGANIZED_ID
|
||||||
|
? t("common.unorganized")
|
||||||
|
: (sortedFolders.find((folder) => folder.id === folderId)?.name ?? t("common.unorganized"));
|
||||||
|
const editStep = (target: 1 | 2 | 3) => {
|
||||||
|
setStepDir(target < step ? -1 : 1);
|
||||||
|
setStep(target);
|
||||||
|
};
|
||||||
const fileInput = (
|
const fileInput = (
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
|
|
@ -542,37 +556,35 @@ export function UploadScreen() {
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative pb-24">
|
<div className="relative">
|
||||||
<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}
|
{errorCard}
|
||||||
|
|
||||||
{/* step indicator */}
|
{/* progress: thin bar + “1 / 3” */}
|
||||||
<div className="mt-5 space-y-2">
|
<div className="mt-1 flex items-center gap-3">
|
||||||
<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
|
<div
|
||||||
role="progressbar"
|
role="progressbar"
|
||||||
aria-label={stepLabel}
|
aria-label={stepLabel}
|
||||||
aria-valuemin={1}
|
aria-valuemin={1}
|
||||||
aria-valuemax={3}
|
aria-valuemax={3}
|
||||||
aria-valuenow={step}
|
aria-valuenow={step}
|
||||||
className="h-1 overflow-hidden rounded-full bg-[var(--surface-sunken)]"
|
className="h-1 flex-1 overflow-hidden rounded-full bg-[var(--surface-sunken)]"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="grad-ice h-full rounded-full transition-[width] duration-300 motion-reduce:transition-none"
|
className="grad-ice h-full rounded-full transition-[width] duration-300 motion-reduce:transition-none"
|
||||||
style={{ width: `${(step / 3) * 100}%` }}
|
style={{ width: `${(step / 3) * 100}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<span className="shrink-0 text-[11px] font-semibold tabular-nums text-[var(--text-subtle)]">
|
||||||
|
{t("common.stepOf", { n: step, total: 3 })}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* the step name is the headline; the page title lives in the chrome */}
|
||||||
|
<h1 className="mt-4 text-[26px] font-semibold leading-tight tracking-[-0.02em] text-[var(--text)]">
|
||||||
|
{stepLabel}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-[14px] text-frost-600 dark:text-frost-300">{stepDesc}</p>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
key={step}
|
key={step}
|
||||||
initial="initial"
|
initial="initial"
|
||||||
|
|
@ -582,24 +594,35 @@ export function UploadScreen() {
|
||||||
>
|
>
|
||||||
{step === 1 ? (
|
{step === 1 ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="press flex min-h-32 w-full flex-col items-center justify-center gap-2 rounded-3xl border-2 border-dashed border-[var(--glass-border)] bg-[var(--surface-strong)] px-4 py-6 text-center transition hover:border-accent-400"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="grid size-12 place-items-center rounded-2xl bg-[var(--surface-sunken)] text-accent-600 dark:text-accent-200"
|
||||||
|
>
|
||||||
|
<PlusIcon className="size-6" />
|
||||||
|
</span>
|
||||||
|
<span className="text-[15px] font-semibold text-[var(--text)]">{t("upload.addPhotosFiles")}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<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()}>
|
<Button variant="secondary" size="lg" className="touch-target" onClick={() => cameraInputRef.current?.click()}>
|
||||||
<CameraIcon className="size-4" /> {t("upload.camera")}
|
<CameraIcon className="size-4" /> {t("upload.camera")}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="lg"
|
||||||
|
className="touch-target"
|
||||||
|
aria-expanded={pasteOpen || text.trim().length > 0}
|
||||||
|
onClick={() => setPasteOpen((value) => !value)}
|
||||||
|
>
|
||||||
|
{t("upload.pasteInstead")}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
{pasteOpen || text.trim().length > 0 ? (
|
||||||
<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
|
<textarea
|
||||||
value={text}
|
value={text}
|
||||||
onChange={(event) => setText(event.target.value)}
|
onChange={(event) => setText(event.target.value)}
|
||||||
|
|
@ -662,7 +685,11 @@ export function UploadScreen() {
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
) : null}
|
) : (
|
||||||
|
<p className="rounded-2xl border border-dashed border-[var(--hairline)] px-4 py-3 text-center text-[12.5px] text-[var(--text-subtle)]">
|
||||||
|
{t("upload.nothingAdded")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{documents
|
{documents
|
||||||
.filter((entry) => entry.status === "error")
|
.filter((entry) => entry.status === "error")
|
||||||
|
|
@ -687,10 +714,22 @@ export function UploadScreen() {
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{step === 2 ? (
|
{step === 2 ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-5">
|
||||||
{folderPicker}
|
<div className="space-y-2">
|
||||||
{difficultyPicker}
|
{folderPicker}
|
||||||
{countPicker}
|
<p className="text-[12px] text-[var(--text-subtle)]">{t("upload.phone.folderHelp")}</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
|
||||||
|
{t("upload.difficultyAria")}
|
||||||
|
</p>
|
||||||
|
{difficultyPicker}
|
||||||
|
<p className="text-[12px] text-[var(--text-subtle)]">{t("upload.phone.difficultyHelp")}</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{countPicker}
|
||||||
|
<p className="text-[12px] text-[var(--text-subtle)]">{t("upload.phone.activitiesHelp")}</p>
|
||||||
|
</div>
|
||||||
<details className="group rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
|
<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">
|
<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" />
|
<ChevronDownIcon className="size-4 shrink-0 transition-transform group-open:rotate-180" />
|
||||||
|
|
@ -734,16 +773,82 @@ export function UploadScreen() {
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{step === 3 ? (
|
{step === 3 ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-4">
|
||||||
<p className="text-[16px] font-semibold leading-relaxed text-[var(--text)]">{phoneSummary}</p>
|
{images.length > 0 || documents.length > 0 ? (
|
||||||
|
<ul className="flex flex-wrap items-center gap-2">
|
||||||
|
{images.map((image) => (
|
||||||
|
<li key={image.id}>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={image.dataUrl}
|
||||||
|
alt=""
|
||||||
|
className="size-12 rounded-xl border border-[var(--hairline)] object-cover"
|
||||||
|
/>
|
||||||
|
</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)] px-2.5 py-1.5"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<dl className="overflow-hidden rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] px-3">
|
||||||
|
{[
|
||||||
|
{ label: t("upload.summary.files"), value: filesSummary, target: 1 as const },
|
||||||
|
{ label: t("upload.difficultyAria"), value: difficultyLabel, target: 2 as const },
|
||||||
|
{ label: t("upload.activities"), value: plural(count, "deck.activities"), target: 2 as const },
|
||||||
|
{ label: t("upload.summary.folder"), value: folderLabel, target: 2 as const },
|
||||||
|
].map((row, index) => (
|
||||||
|
<div
|
||||||
|
key={row.label}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-between gap-3 py-2.5",
|
||||||
|
index > 0 && "border-t border-[var(--hairline-soft)]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<dt className="shrink-0 text-[12px] text-[var(--text-subtle)]">{row.label}</dt>
|
||||||
|
<dd className="flex min-w-0 items-center gap-2">
|
||||||
|
<span className="truncate text-[13px] font-medium text-[var(--text)]">{row.value}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => editStep(row.target)}
|
||||||
|
className="shrink-0 text-[12px] font-semibold text-accent-700 dark:text-accent-200"
|
||||||
|
>
|
||||||
|
{t("common.edit")}
|
||||||
|
</button>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
|
||||||
<p className="text-[12.5px] text-[var(--text-subtle)]">{phoneHelper}</p>
|
<p className="text-[12.5px] text-[var(--text-subtle)]">{phoneHelper}</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Fixed step bar, floating just above the tab bar. */}
|
{/* The action bar is portalled to <body> — never inside the animated
|
||||||
<div className="fixed inset-x-0 bottom-[calc(env(safe-area-inset-bottom,0px)+4.6rem)] z-20 px-4">
|
content wrapper, where a transform would break position: fixed. */}
|
||||||
<div className="glass-panel mx-auto flex max-w-3xl items-center gap-2 p-2">
|
<WizardActionBar>
|
||||||
|
{step === 1 ? (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="lg"
|
||||||
|
className="touch-target flex-1"
|
||||||
|
onClick={() => router.push("/")}
|
||||||
|
>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="lg"
|
size="lg"
|
||||||
|
|
@ -752,28 +857,28 @@ export function UploadScreen() {
|
||||||
setStepDir(-1);
|
setStepDir(-1);
|
||||||
setStep((value) => Math.max(1, value - 1));
|
setStep((value) => Math.max(1, value - 1));
|
||||||
}}
|
}}
|
||||||
disabled={step === 1}
|
|
||||||
>
|
>
|
||||||
{t("common.back")}
|
{t("common.back")}
|
||||||
</Button>
|
</Button>
|
||||||
{step < 3 ? (
|
)}
|
||||||
<Button
|
{step < 3 ? (
|
||||||
size="lg"
|
<Button
|
||||||
className="touch-target flex-[1.5]"
|
size="lg"
|
||||||
onClick={() => {
|
className="touch-target flex-[1.5]"
|
||||||
setStepDir(1);
|
onClick={() => {
|
||||||
setStep((value) => Math.min(3, value + 1));
|
setStepDir(1);
|
||||||
}}
|
setStep((value) => Math.min(3, value + 1));
|
||||||
>
|
}}
|
||||||
{t("common.next")}
|
disabled={!hasContent || documentsBusy}
|
||||||
</Button>
|
>
|
||||||
) : (
|
{t("common.next")}
|
||||||
<Button size="lg" className="touch-target flex-[1.5]" onClick={() => void generate()} disabled={!canGenerate}>
|
</Button>
|
||||||
<SparklesIcon className="size-5" /> {t("upload.freezeDeck")}
|
) : (
|
||||||
</Button>
|
<Button size="lg" className="touch-target flex-[1.5]" onClick={() => void generate()} disabled={!canGenerate}>
|
||||||
)}
|
<SparklesIcon className="size-5" /> {t("upload.freezeDeck")}
|
||||||
</div>
|
</Button>
|
||||||
</div>
|
)}
|
||||||
|
</WizardActionBar>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
39
components/WizardActionBar.tsx
Normal file
39
components/WizardActionBar.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion, useReducedMotion } from "framer-motion";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The wizard's Back / Next bar.
|
||||||
|
*
|
||||||
|
* Portalled to <body> on purpose: the page content animates its entrance with
|
||||||
|
* a transform, and a transform on an ancestor turns it into the containing
|
||||||
|
* block for `position: fixed` — the bar would briefly render inside the
|
||||||
|
* content and then jump to the viewport bottom. Rendering it outside every
|
||||||
|
* animated wrapper (and only fading it in, never transforming it) keeps it
|
||||||
|
* pinned to the bottom edge from the first frame.
|
||||||
|
*
|
||||||
|
* Flat, full-width and above the safe area. The wizard pages hide the tab
|
||||||
|
* bar, so this is the only bottom chrome while the flow is open.
|
||||||
|
*/
|
||||||
|
export function WizardActionBar({ children }: { children: React.ReactNode }) {
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const reduceMotion = useReducedMotion();
|
||||||
|
|
||||||
|
useEffect(() => setMounted(true), []);
|
||||||
|
if (!mounted) return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<motion.div
|
||||||
|
initial={reduceMotion ? false : { opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||||
|
className="fixed inset-x-0 bottom-0 z-40 border-t border-[var(--hairline)] bg-[color-mix(in_oklab,var(--page)_92%,transparent)] px-4 pb-safe pt-2 backdrop-blur-lg sm:hidden"
|
||||||
|
data-wizard-bar
|
||||||
|
>
|
||||||
|
<div className="mx-auto flex w-full max-w-3xl items-center gap-2">{children}</div>
|
||||||
|
</motion.div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { motion, useReducedMotion } from "framer-motion";
|
import { motion, useReducedMotion } from "framer-motion";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { CameraIcon, FolderIcon, PlayIcon, PlusIcon, SparklesIcon } from "@/components/icons";
|
import { CameraIcon, FolderIcon, PlayIcon, PlusIcon, SparklesIcon } from "@/components/icons";
|
||||||
import { Button } from "@/components/ui";
|
import { Button } from "@/components/ui";
|
||||||
|
import { WizardActionBar } from "@/components/WizardActionBar";
|
||||||
import {
|
import {
|
||||||
DOCUMENT_ACCEPT_ATTR,
|
DOCUMENT_ACCEPT_ATTR,
|
||||||
documentsToText,
|
documentsToText,
|
||||||
|
|
@ -66,6 +68,7 @@ export function HomeworkInput({
|
||||||
onDelete: (id: string) => void;
|
onDelete: (id: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t, plural } = useI18n();
|
const { t, plural } = useI18n();
|
||||||
|
const router = useRouter();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const cameraInputRef = useRef<HTMLInputElement>(null);
|
const cameraInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [step, setStep] = useState(1);
|
const [step, setStep] = useState(1);
|
||||||
|
|
@ -206,37 +209,41 @@ export function HomeworkInput({
|
||||||
: fileSummary || t("homework.source.need");
|
: fileSummary || t("homework.source.need");
|
||||||
const showPaste = pasteOpen || text.trim().length > 0;
|
const showPaste = pasteOpen || text.trim().length > 0;
|
||||||
|
|
||||||
return (
|
const stepDesc =
|
||||||
<div className="relative pb-24">
|
step === 1
|
||||||
<section className="space-y-1.5">
|
? t("upload.phone.notesDesc", { images: 8, documents: 6 })
|
||||||
<h1 className="text-[24px] font-semibold leading-tight tracking-[-0.02em] text-[var(--text)]">
|
: t("homework.phone.modeDesc");
|
||||||
{t("homeworkHeroTitle")}
|
|
||||||
</h1>
|
|
||||||
<p className="text-[14px] text-frost-600 dark:text-frost-300">{t("homework.subtitle")}</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
{errorCard}
|
{errorCard}
|
||||||
|
|
||||||
<div className="mt-5 space-y-2">
|
{/* progress: thin bar + “1 / 2” */}
|
||||||
<div className="flex items-center justify-between gap-3 text-[12px] font-semibold text-[var(--text-subtle)]">
|
<div className="mt-1 flex items-center gap-3">
|
||||||
<h2 className="min-w-0 truncate">{stepLabel}</h2>
|
|
||||||
<span className="shrink-0 tabular-nums">{t("common.stepOf", { n: step, total: 2 })}</span>
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
role="progressbar"
|
role="progressbar"
|
||||||
aria-label={stepLabel}
|
aria-label={stepLabel}
|
||||||
aria-valuemin={1}
|
aria-valuemin={1}
|
||||||
aria-valuemax={2}
|
aria-valuemax={2}
|
||||||
aria-valuenow={step}
|
aria-valuenow={step}
|
||||||
className="h-1 overflow-hidden rounded-full bg-[var(--surface-sunken)]"
|
className="h-1 flex-1 overflow-hidden rounded-full bg-[var(--surface-sunken)]"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="grad-ice h-full rounded-full transition-[width] duration-300 motion-reduce:transition-none"
|
className="grad-ice h-full rounded-full transition-[width] duration-300 motion-reduce:transition-none"
|
||||||
style={{ width: `${(step / 2) * 100}%` }}
|
style={{ width: `${(step / 2) * 100}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<span className="shrink-0 text-[11px] font-semibold tabular-nums text-[var(--text-subtle)]">
|
||||||
|
{t("common.stepOf", { n: step, total: 2 })}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* the step name is the headline; the page title lives in the chrome */}
|
||||||
|
<h1 className="mt-4 text-[26px] font-semibold leading-tight tracking-[-0.02em] text-[var(--text)]">
|
||||||
|
{stepLabel}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-[14px] text-frost-600 dark:text-frost-300">{stepDesc}</p>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
key={step}
|
key={step}
|
||||||
initial="initial"
|
initial="initial"
|
||||||
|
|
@ -246,15 +253,21 @@ export function HomeworkInput({
|
||||||
>
|
>
|
||||||
{step === 1 ? (
|
{step === 1 ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<button
|
||||||
<Button
|
type="button"
|
||||||
variant="secondary"
|
onClick={() => fileInputRef.current?.click()}
|
||||||
size="lg"
|
className="press flex min-h-32 w-full flex-col items-center justify-center gap-2 rounded-3xl border-2 border-dashed border-[var(--glass-border)] bg-[var(--surface-strong)] px-4 py-6 text-center transition hover:border-accent-400"
|
||||||
className="touch-target"
|
>
|
||||||
onClick={() => fileInputRef.current?.click()}
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="grid size-12 place-items-center rounded-2xl bg-[var(--surface-sunken)] text-accent-600 dark:text-accent-200"
|
||||||
>
|
>
|
||||||
<FolderIcon className="size-4" /> {t("homework.source.add")}
|
<PlusIcon className="size-6" />
|
||||||
</Button>
|
</span>
|
||||||
|
<span className="text-[15px] font-semibold text-[var(--text)]">{t("homework.source.add")}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="lg"
|
size="lg"
|
||||||
|
|
@ -263,16 +276,16 @@ export function HomeworkInput({
|
||||||
>
|
>
|
||||||
<CameraIcon className="size-4" /> {t("upload.camera")}
|
<CameraIcon className="size-4" /> {t("upload.camera")}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="lg"
|
||||||
|
className="touch-target"
|
||||||
|
aria-expanded={showPaste}
|
||||||
|
onClick={() => setPasteOpen((value) => !value)}
|
||||||
|
>
|
||||||
|
{t("upload.pasteInstead")}
|
||||||
|
</Button>
|
||||||
</div>
|
</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 ? (
|
{showPaste ? (
|
||||||
<textarea
|
<textarea
|
||||||
id="homework-assignment-phone"
|
id="homework-assignment-phone"
|
||||||
|
|
@ -345,6 +358,12 @@ export function HomeworkInput({
|
||||||
</ul>
|
</ul>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{intake.images.length === 0 && intake.documents.length === 0 ? (
|
||||||
|
<p className="rounded-2xl border border-dashed border-[var(--hairline)] px-4 py-3 text-center text-[12.5px] text-[var(--text-subtle)]">
|
||||||
|
{t("upload.nothingAdded")}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{intake.documents
|
{intake.documents
|
||||||
.filter((entry) => entry.status === "error")
|
.filter((entry) => entry.status === "error")
|
||||||
.map((entry) => (
|
.map((entry) => (
|
||||||
|
|
@ -391,9 +410,19 @@ export function HomeworkInput({
|
||||||
|
|
||||||
<HomeworkHistory records={history} onOpen={onOpen} onDelete={onDelete} />
|
<HomeworkHistory records={history} onOpen={onOpen} onDelete={onDelete} />
|
||||||
|
|
||||||
{/* Fixed step bar, floating just above the tab bar. */}
|
{/* The action bar is portalled to <body> — never inside the animated
|
||||||
<div className="fixed inset-x-0 bottom-[calc(env(safe-area-inset-bottom,0px)+4.6rem)] z-20 px-4">
|
content wrapper, where a transform would break position: fixed. */}
|
||||||
<div className="glass-panel mx-auto flex max-w-3xl items-center gap-2 p-2">
|
<WizardActionBar>
|
||||||
|
{step === 1 ? (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="lg"
|
||||||
|
className="touch-target flex-1"
|
||||||
|
onClick={() => router.push("/")}
|
||||||
|
>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="lg"
|
size="lg"
|
||||||
|
|
@ -402,25 +431,24 @@ export function HomeworkInput({
|
||||||
setStepDir(-1);
|
setStepDir(-1);
|
||||||
setStep(1);
|
setStep(1);
|
||||||
}}
|
}}
|
||||||
disabled={step === 1}
|
|
||||||
>
|
>
|
||||||
{t("common.back")}
|
{t("common.back")}
|
||||||
</Button>
|
</Button>
|
||||||
{step === 1 ? (
|
)}
|
||||||
<Button
|
{step === 1 ? (
|
||||||
size="lg"
|
<Button
|
||||||
className="touch-target flex-[1.5]"
|
size="lg"
|
||||||
onClick={() => {
|
className="touch-target flex-[1.5]"
|
||||||
setStepDir(1);
|
onClick={() => {
|
||||||
setStep(2);
|
setStepDir(1);
|
||||||
}}
|
setStep(2);
|
||||||
disabled={!canSubmit}
|
}}
|
||||||
>
|
disabled={!canSubmit}
|
||||||
{t("common.next")}
|
>
|
||||||
</Button>
|
{t("common.next")}
|
||||||
) : null}
|
</Button>
|
||||||
</div>
|
) : null}
|
||||||
</div>
|
</WizardActionBar>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,7 @@ const en: Record<string, string> = {
|
||||||
"common.next": "Next",
|
"common.next": "Next",
|
||||||
"common.more": "More",
|
"common.more": "More",
|
||||||
"common.less": "Less",
|
"common.less": "Less",
|
||||||
|
"common.edit": "Edit",
|
||||||
"common.stepOf": "{n} / {total}",
|
"common.stepOf": "{n} / {total}",
|
||||||
|
|
||||||
/* header ---------------------------------------------------------- */
|
/* header ---------------------------------------------------------- */
|
||||||
|
|
@ -186,6 +187,7 @@ const en: Record<string, string> = {
|
||||||
"homework.subtitle": "Paste it, photograph it or add a file — then pick how I should help.",
|
"homework.subtitle": "Paste it, photograph it or add a file — then pick how I should help.",
|
||||||
"homework.phone.assignment": "The assignment",
|
"homework.phone.assignment": "The assignment",
|
||||||
"homework.phone.mode": "How should I help?",
|
"homework.phone.mode": "How should I help?",
|
||||||
|
"homework.phone.modeDesc": "Pick how I should help with this assignment.",
|
||||||
"homework.phone.preview": "Assignment preview",
|
"homework.phone.preview": "Assignment preview",
|
||||||
"homework.phone.startHint": "Tap a card to start.",
|
"homework.phone.startHint": "Tap a card to start.",
|
||||||
"homework.source.title": "1. The assignment",
|
"homework.source.title": "1. The assignment",
|
||||||
|
|
@ -327,8 +329,17 @@ const en: Record<string, string> = {
|
||||||
"upload.pasteInstead": "Paste text instead",
|
"upload.pasteInstead": "Paste text instead",
|
||||||
"upload.focusExtra": "Add a focus or extra notes (optional)",
|
"upload.focusExtra": "Add a focus or extra notes (optional)",
|
||||||
"upload.phone.notes": "Your notes",
|
"upload.phone.notes": "Your notes",
|
||||||
|
"upload.phone.notesDesc": "Photos, PDF, Word or text — up to {images} photos and {documents} files.",
|
||||||
"upload.phone.options": "Options",
|
"upload.phone.options": "Options",
|
||||||
|
"upload.phone.optionsDesc": "Everything has a sensible default — change what you like.",
|
||||||
"upload.phone.summary": "Ready to freeze",
|
"upload.phone.summary": "Ready to freeze",
|
||||||
|
"upload.phone.summaryDesc": "Check what goes in, then freeze.",
|
||||||
|
"upload.phone.difficultyHelp": "How deep the questions go.",
|
||||||
|
"upload.phone.activitiesHelp": "How many activities to build.",
|
||||||
|
"upload.phone.folderHelp": "Where the deck lands in your library.",
|
||||||
|
"upload.nothingAdded": "Nothing added yet",
|
||||||
|
"upload.summary.files": "Files",
|
||||||
|
"upload.summary.folder": "Folder",
|
||||||
"upload.summaryText": "Text",
|
"upload.summaryText": "Text",
|
||||||
"upload.photoCount.one": "{n} photo",
|
"upload.photoCount.one": "{n} photo",
|
||||||
"upload.photoCount.few": "{n} photos",
|
"upload.photoCount.few": "{n} photos",
|
||||||
|
|
@ -707,6 +718,7 @@ const cs: Record<string, string> = {
|
||||||
"common.next": "Další",
|
"common.next": "Další",
|
||||||
"common.more": "Více",
|
"common.more": "Více",
|
||||||
"common.less": "Méně",
|
"common.less": "Méně",
|
||||||
|
"common.edit": "Upravit",
|
||||||
"common.stepOf": "{n} / {total}",
|
"common.stepOf": "{n} / {total}",
|
||||||
|
|
||||||
/* header ---------------------------------------------------------- */
|
/* header ---------------------------------------------------------- */
|
||||||
|
|
@ -804,6 +816,7 @@ const cs: Record<string, string> = {
|
||||||
"homework.subtitle": "Vlož ho, vyfoť nebo přidej soubor — a vyber, jak ti mám pomoct.",
|
"homework.subtitle": "Vlož ho, vyfoť nebo přidej soubor — a vyber, jak ti mám pomoct.",
|
||||||
"homework.phone.assignment": "Zadání",
|
"homework.phone.assignment": "Zadání",
|
||||||
"homework.phone.mode": "Jak ti mám pomoct?",
|
"homework.phone.mode": "Jak ti mám pomoct?",
|
||||||
|
"homework.phone.modeDesc": "Vyber, jak ti s tímto zadáním pomůžu.",
|
||||||
"homework.phone.preview": "Náhled zadání",
|
"homework.phone.preview": "Náhled zadání",
|
||||||
"homework.phone.startHint": "Klepni na kartu a začnu.",
|
"homework.phone.startHint": "Klepni na kartu a začnu.",
|
||||||
"homework.source.title": "1. Zadání",
|
"homework.source.title": "1. Zadání",
|
||||||
|
|
@ -945,8 +958,17 @@ const cs: Record<string, string> = {
|
||||||
"upload.pasteInstead": "Místo toho vložit text",
|
"upload.pasteInstead": "Místo toho vložit text",
|
||||||
"upload.focusExtra": "Přidat zaměření nebo poznámky navíc (nepovinné)",
|
"upload.focusExtra": "Přidat zaměření nebo poznámky navíc (nepovinné)",
|
||||||
"upload.phone.notes": "Tvoje poznámky",
|
"upload.phone.notes": "Tvoje poznámky",
|
||||||
|
"upload.phone.notesDesc": "Fotky, PDF, Word nebo text — až {images} fotek a {documents} souborů.",
|
||||||
"upload.phone.options": "Možnosti",
|
"upload.phone.options": "Možnosti",
|
||||||
|
"upload.phone.optionsDesc": "Vše má rozumné výchozí hodnoty — změň, co chceš.",
|
||||||
"upload.phone.summary": "Připraveno ke zmrazení",
|
"upload.phone.summary": "Připraveno ke zmrazení",
|
||||||
|
"upload.phone.summaryDesc": "Zkontroluj, co se použije, a zmraz.",
|
||||||
|
"upload.phone.difficultyHelp": "Jak hluboko otázky jdou.",
|
||||||
|
"upload.phone.activitiesHelp": "Kolik aktivit se vytvoří.",
|
||||||
|
"upload.phone.folderHelp": "Kam se sada v knihovně zařadí.",
|
||||||
|
"upload.nothingAdded": "Zatím nic přidáno",
|
||||||
|
"upload.summary.files": "Soubory",
|
||||||
|
"upload.summary.folder": "Složka",
|
||||||
"upload.summaryText": "Text",
|
"upload.summaryText": "Text",
|
||||||
"upload.photoCount.one": "{n} fotka",
|
"upload.photoCount.one": "{n} fotka",
|
||||||
"upload.photoCount.few": "{n} fotky",
|
"upload.photoCount.few": "{n} fotky",
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
*
|
*
|
||||||
* Bumping CACHE_VERSION is how a new deploy invalidates old assets.
|
* Bumping CACHE_VERSION is how a new deploy invalidates old assets.
|
||||||
*/
|
*/
|
||||||
const CACHE_VERSION = "freezmo-v21";
|
const CACHE_VERSION = "freezmo-v22";
|
||||||
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
|
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
|
||||||
const ASSET_CACHE = `${CACHE_VERSION}-assets`;
|
const ASSET_CACHE = `${CACHE_VERSION}-assets`;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue