Separate Study Decks and Homework in the library; drop the /create mode switch

Library ("Your icebox")
- Two tabs, never mixed: Study Decks (the folder + card library) and Homework
  (saved solutions and step-by-step guides). The active tab lives in the URL
  (/?tab=homework), so the sections are linkable and survive a reload.
- Decks tab keeps Import, the folder bar, stats and its own empty state — now
  a single decks-only CTA.
- Homework tab lists saved results as cards (mode icon, date, step count,
  preview) with Open and delete-with-confirm; its empty state has its own CTA
  ("Homework Solver") that opens the solver directly. Opening a card
  deep-links to /homework?open=<id>, which the screen opens on arrival.
- .segmented now also styles link segments (same control as settings).

/create
- The redundant "Study Deck | Homework Solver" toggle is gone: /create is the
  deck uploader only, and the bottom bar's + goes straight to it. Homework has
  its own screen, reached from the library tab, the drawer and CTAs.

Copy
- New keys (en + cs): tabDecks, tabHomework, homeworkHeroTitle, plus homework
  empty-state/open/step-count strings. The homework hero is now "Solve your
  homework" / "Vyřeš svůj úkol" — no study-deck text in the homework flow.
- Removed the now-unused ModeSwitch component and homework.title key.

Service worker: freezmo-v10.

Verified: library-tabs suite 37/37 (tab switching, context empty states,
deep-link opening, deletion, no switch on /create, heroes, redirects, desktop
grid, 360px + 44px targets, axe contrast en+cs light+dark), mobile 49/49,
i18n 27/27, desktop regression 74/74. Lint + build clean.
This commit is contained in:
Martin Dvořák 2026-10-08 21:26:08 +00:00
commit 8c44f47881
12 changed files with 295 additions and 162 deletions

View file

@ -89,8 +89,9 @@ no database, no data leaving your machine except the requests you send to Ollama
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`
- The last screen assembles the solution from the step answers, with a copy button - The last screen assembles the solution from the step answers, with a copy button
- Results live in `freezmo.homework.v1` with a small history you can reopen or delete; **Make it - Results live in `freezmo.homework.v1` — the homework screen's history and the library's
again** re-runs from the stored assignment text — original files are never saved **Homework** tab — where you can reopen or delete them; **Make it again** re-runs from the
stored assignment text, and original files are never saved
- The answer and the guide are always written in the **language of the assignment**, never - The answer and the guide are always written in the **language of the assignment**, never
translated into the interface language translated into the interface language
@ -116,6 +117,9 @@ no database, no data leaving your machine except the requests you send to Ollama
**Library & folders** **Library & folders**
- **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`)
- Decks saved locally with title, subject, folder, activity count, date, play count and best score - 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. - **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**. New decks freeze straight into the selected folder; anything unfiled lives in **Unorganized**.

View file

@ -492,6 +492,28 @@ body {
background-image: linear-gradient(135deg, #38bdf8 0%, #60a5fa 100%); background-image: linear-gradient(135deg, #38bdf8 0%, #60a5fa 100%);
color: #06111f; color: #06111f;
} }
/* Link segments (library tabs): same control, but links navigate. */
.segmented > a {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.375rem;
border-radius: 0.75rem;
padding: 0.42rem 0.5rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--color-frost-700);
transition: background-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
}
.dark .segmented > a {
color: var(--color-frost-300);
}
.segmented > a[aria-current="page"] {
background-image: linear-gradient(135deg, #38bdf8 0%, #60a5fa 100%);
color: #06111f;
box-shadow: 0 6px 16px -8px rgb(56 189 248 / 0.7);
}
/* Ice gradient for progress bars and selected states. */ /* Ice gradient for progress bars and selected states. */
.grad-ice { .grad-ice {
@ -705,7 +727,8 @@ body {
@media (max-width: 639px) { @media (max-width: 639px) {
.touch-target { min-height: 44px; min-width: 44px; } .touch-target { min-height: 44px; min-width: 44px; }
.nav-row { min-height: 44px; } .nav-row { min-height: 44px; }
.segmented > button { min-height: 44px; } .segmented > button,
.segmented > a { min-height: 44px; }
.folder-pill { min-height: 44px; } .folder-pill { min-height: 44px; }
} }

View file

@ -15,7 +15,7 @@ export default async function HomeworkPage({
}: { }: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>; searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) { }) {
const { mode } = await searchParams; const { mode, open } = await searchParams;
// The mode switch and the library quick actions deep-link into a mode; a // The mode switch and the library quick actions deep-link into a mode; a
// link that points at the Study Deck side is handed back to /create. // link that points at the Study Deck side is handed back to /create.
if (mode === "deck") redirect("/create?mode=deck"); if (mode === "deck") redirect("/create?mode=deck");
@ -29,7 +29,7 @@ export default async function HomeworkPage({
<main className="flex min-h-dvh min-w-0 flex-1 flex-col"> <main className="flex min-h-dvh min-w-0 flex-1 flex-col">
<AppHeader /> <AppHeader />
<div className="mx-auto w-full max-w-3xl flex-1 px-4 pb-tabbar lg:px-10 lg:pb-16 lg:pt-10"> <div className="mx-auto w-full max-w-3xl flex-1 px-4 pb-tabbar lg:px-10 lg:pb-16 lg:pt-10">
<HomeworkScreen /> <HomeworkScreen initialRecordId={typeof open === "string" ? open : undefined} />
</div> </div>
<TabBar /> <TabBar />
</main> </main>

View file

@ -4,7 +4,12 @@ import { LibraryScreen } from "@/components/LibraryScreen";
import { MobileSidebar, Sidebar, SidebarOffset } from "@/components/Sidebar"; import { MobileSidebar, Sidebar, SidebarOffset } from "@/components/Sidebar";
import { TabBar } from "@/components/TabBar"; import { TabBar } from "@/components/TabBar";
export default function HomePage() { export default async function HomePage({
searchParams,
}: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
const { tab } = await searchParams;
return ( return (
<ApiKeyGate> <ApiKeyGate>
<div className="flex min-h-dvh"> <div className="flex min-h-dvh">
@ -14,7 +19,7 @@ export default function HomePage() {
<main className="flex min-h-dvh min-w-0 flex-1 flex-col"> <main className="flex min-h-dvh min-w-0 flex-1 flex-col">
<AppHeader /> <AppHeader />
<div className="mx-auto w-full max-w-6xl flex-1 px-4 pb-tabbar lg:px-10 lg:pb-16 lg:pt-10"> <div className="mx-auto w-full max-w-6xl flex-1 px-4 pb-tabbar lg:px-10 lg:pb-16 lg:pt-10">
<LibraryScreen /> <LibraryScreen initialTab={tab === "homework" ? "homework" : "decks"} />
</div> </div>
<TabBar /> <TabBar />
</main> </main>

View file

@ -0,0 +1,97 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { BoltIcon, 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";
/**
* One saved homework result in the library's Homework tab: the finished
* solution or the step-by-step guide, with open + delete.
*/
export function HomeworkCard({ record, onDelete }: { record: HomeworkRecord; onDelete: (id: string) => void }) {
const { t, plural } = useI18n();
const [confirmDelete, setConfirmDelete] = useState(false);
const href = `/homework?open=${record.id}`;
const preview =
record.mode === "make"
? truncate(record.answer.replace(/\s+/g, " ").trim(), 150)
: truncate(
record.steps.map((step) => step.title).join(" · "),
150,
);
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" />
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<h3
className="line-clamp-2 text-[15px] font-semibold leading-snug tracking-[-0.01em] text-[var(--text)]"
title={record.title}
>
{truncate(record.title, 90)}
</h3>
<p className="mt-1 flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-[12.5px] text-[var(--text-subtle)]">
<span className="inline-flex items-center gap-1">
{record.mode === "make" ? <BoltIcon className="size-3.5" /> : <PlayIcon className="size-3.5" />}
{t(record.mode === "make" ? "homework.history.made" : "homework.history.guide")}
</span>
<span aria-hidden>·</span>
{formatDate(record.updatedAt)}
{record.mode === "guide" ? (
<>
<span aria-hidden>·</span>
{plural(record.steps.length, "homework.steps")}
</>
) : null}
</p>
</div>
<button
type="button"
aria-label={`${t("common.delete")} — ${record.title}`}
onClick={() => setConfirmDelete(true)}
className="touch-target relative z-10 grid size-8 shrink-0 place-items-center rounded-full text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-rose-600 dark:hover:text-rose-300"
>
<XIcon className="size-4" />
</button>
</div>
{preview ? (
<p className="mt-3 line-clamp-2 text-[13.5px] leading-relaxed text-[var(--text-muted)]">{preview}</p>
) : null}
<div className="mt-5 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>
<Button size="sm" variant="danger" onClick={() => onDelete(record.id)}>
{t("common.delete")}
</Button>
<Button size="sm" variant="ghost" onClick={() => setConfirmDelete(false)}>
{t("common.cancel")}
</Button>
</div>
) : (
<>
<Link href={href} className="btn-primary touch-target h-10 w-full px-4 text-sm lg:hidden">
{t("homework.open")}
</Link>
<div className="relative z-10 hidden lg:flex lg:justify-end">
<Link href={href} className="btn-secondary h-9 px-4 text-[13px]">
{t("homework.open")}
</Link>
</div>
</>
)}
</div>
</li>
);
}

View file

@ -5,9 +5,11 @@ import { useMemo, useRef, useState, useSyncExternalStore } from "react";
import { DeckCard } from "@/components/DeckCard"; import { DeckCard } from "@/components/DeckCard";
import { FolderBar } from "@/components/FolderBar"; import { FolderBar } from "@/components/FolderBar";
import { HomeworkCard } from "@/components/HomeworkCard";
import { FolderIcon, SnowflakeIcon, SparklesIcon, UploadIcon } from "@/components/icons"; import { FolderIcon, SnowflakeIcon, SparklesIcon, UploadIcon } from "@/components/icons";
import { Button, EmptyState } from "@/components/ui"; import { Button, EmptyState } from "@/components/ui";
import { folderStore, renameFolder } from "@/lib/folders"; import { folderStore, renameFolder } from "@/lib/folders";
import { deleteHomework, homeworkStore, sortHomework } from "@/lib/homework";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { ALL_FOLDERS, folderSelection, useSelectedFolder } from "@/lib/nav"; import { ALL_FOLDERS, folderSelection, useSelectedFolder } from "@/lib/nav";
import { openSettings } from "@/lib/ui-events"; import { openSettings } from "@/lib/ui-events";
@ -16,11 +18,19 @@ import { deckStore, deleteFolder, importDeck, sortDecks, upsertDeck } from "@/li
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
/** /**
* Dashboard: page title, folder pills, then a spacious responsive grid of * Dashboard with two separate pools: study decks (folders + card grid) and
* deck cards grouped by folder. * saved homework (solutions and step-by-step guides). The tab bar picks one,
* so the two never mix in a single list.
*/ */
export function LibraryScreen() { export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" | "homework" }) {
const tab = initialTab;
const decks = useSyncExternalStore(deckStore.subscribe, deckStore.getSnapshot, deckStore.getServerSnapshot); const decks = useSyncExternalStore(deckStore.subscribe, deckStore.getSnapshot, deckStore.getServerSnapshot);
const homeworkRecords = useSyncExternalStore(
homeworkStore.subscribe,
homeworkStore.getSnapshot,
homeworkStore.getServerSnapshot,
);
const homework = useMemo(() => sortHomework(homeworkRecords), [homeworkRecords]);
const folders = useSyncExternalStore( const folders = useSyncExternalStore(
folderStore.subscribe, folderStore.subscribe,
folderStore.getSnapshot, folderStore.getSnapshot,
@ -119,38 +129,57 @@ export function LibraryScreen() {
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{/* With nothing in the library, Import drops to a quiet text link {/* Context action: Import for decks (a quiet link while the pool
so the single primary action is “Make your first deck”. */} is empty), start-a-new-solve for homework. */}
{isEmpty ? ( {tab === "decks" ? (
<button isEmpty ? (
type="button"
onClick={() => importRef.current?.click()}
className="inline-flex items-center gap-1.5 text-[13px] font-medium text-[var(--text-muted)] transition hover:text-[var(--text)] hover:underline"
>
<UploadIcon className="size-4" />
{t("library.import")}
</button>
) : (
<>
<Button
variant="secondary"
size="md"
className="touch-target lg:hidden"
onClick={() => importRef.current?.click()}
>
<UploadIcon className="size-4" />
{t("library.import")}
</Button>
<button <button
type="button" type="button"
onClick={() => importRef.current?.click()} onClick={() => importRef.current?.click()}
className="hidden items-center gap-1.5 text-[13px] font-medium text-[var(--text-muted)] transition hover:text-[var(--text)] hover:underline lg:inline-flex" className="inline-flex items-center gap-1.5 text-[13px] font-medium text-[var(--text-muted)] transition hover:text-[var(--text)] hover:underline"
> >
<UploadIcon className="size-4" /> <UploadIcon className="size-4" />
{t("library.import")} {t("library.import")}
</button> </button>
) : (
<>
<Button
variant="secondary"
size="md"
className="touch-target lg:hidden"
onClick={() => importRef.current?.click()}
>
<UploadIcon className="size-4" />
{t("library.import")}
</Button>
<button
type="button"
onClick={() => importRef.current?.click()}
className="hidden items-center gap-1.5 text-[13px] font-medium text-[var(--text-muted)] transition hover:text-[var(--text)] hover:underline lg:inline-flex"
>
<UploadIcon className="size-4" />
{t("library.import")}
</button>
</>
)
) : homework.length > 0 ? (
<>
<Link
href="/homework?mode=homework"
className="btn-secondary touch-target h-10 px-4 text-sm lg:hidden"
>
<SparklesIcon className="size-4" />
{t("actionHomework")}
</Link>
<Link
href="/homework?mode=homework"
className="hidden items-center gap-1.5 text-[13px] font-medium text-[var(--text-muted)] transition hover:text-[var(--text)] hover:underline lg:inline-flex"
>
<SparklesIcon className="size-4" />
{t("actionHomework")}
</Link>
</> </>
)} ) : null}
<input <input
ref={importRef} ref={importRef}
type="file" type="file"
@ -162,11 +191,21 @@ export function LibraryScreen() {
}} }}
/> />
</div> </div>
{/* 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>
</div> </div>
{/* Secondary stats folded into the header as quiet text on desktop, {/* Secondary stats folded into the header as quiet text on desktop,
where the sidebar already shows per-folder counts. */} where the sidebar already shows per-folder counts. */}
{decks.length > 0 ? ( {tab === "decks" && decks.length > 0 ? (
<p className="text-[13px] text-[var(--text-subtle)] lg:hidden"> <p className="text-[13px] text-[var(--text-subtle)] lg:hidden">
{plural(decks.length, "stats.decks")} {plural(decks.length, "stats.decks")}
<span aria-hidden> · </span> <span aria-hidden> · </span>
@ -185,7 +224,7 @@ export function LibraryScreen() {
{/* Mobile folder switcher; the sidebar covers desktop. With an empty {/* Mobile folder switcher; the sidebar covers desktop. With an empty
library there is nothing to filter, and the pills would compete library there is nothing to filter, and the pills would compete
with the empty state's single call to action. */} with the empty state's single call to action. */}
{!isEmpty ? ( {tab === "decks" && !isEmpty ? (
<FolderBar <FolderBar
className="lg:hidden" className="lg:hidden"
folders={folderList} folders={folderList}
@ -206,7 +245,7 @@ export function LibraryScreen() {
) : null} ) : null}
{message ? ( {tab === "decks" && message ? (
<p <p
role="status" role="status"
className={cn( className={cn(
@ -219,43 +258,25 @@ export function LibraryScreen() {
) : null} ) : null}
</section> </section>
{/* deck grid ------------------------------------------------------ */} {/* decks: folders + card grid, or a decks-only empty state --------- */}
{isEmpty ? ( {tab === "decks" && isEmpty ? (
<EmptyState <EmptyState
title={t("library.noDecks")} title={t("library.noDecks")}
action={ action={
/* Two ways in: build a study deck from notes, or hand over an <Link
assignment. Stacked on phones, side by side from 640px up. */ href="/create?mode=deck"
<div className="grid w-[min(24rem,calc(100vw_-_3rem))] gap-3 sm:w-[36rem] sm:grid-cols-2"> className="card card-hover touch-target flex w-[min(24rem,calc(100vw_-_3rem))] items-center gap-3 border-accent-400/60 p-3.5 text-left sm:p-4"
<Link >
href="/create?mode=deck" <span
className="card card-hover touch-target flex items-center gap-3 border-accent-400/60 p-3.5 text-left sm:p-4" aria-hidden
className="grad-ice grid size-11 shrink-0 place-items-center rounded-2xl text-[#06111f]"
> >
<span <SnowflakeIcon className="size-5" />
aria-hidden </span>
className="grad-ice grid size-11 shrink-0 place-items-center rounded-2xl text-[#06111f]" <span className="min-w-0 text-[14px] font-semibold leading-snug text-[var(--text)]">
> {t("emptyStateCreateDeck")}
<SnowflakeIcon className="size-5" /> </span>
</span> </Link>
<span className="min-w-0 text-[14px] font-semibold leading-snug text-[var(--text)]">
{t("emptyStateCreateDeck")}
</span>
</Link>
<Link
href="/homework?mode=homework"
className="card card-hover touch-target flex items-center gap-3 p-3.5 text-left sm:p-4"
>
<span
aria-hidden
className="grid size-11 shrink-0 place-items-center rounded-2xl bg-[var(--surface-sunken)] text-[var(--text-muted)]"
>
<SparklesIcon className="size-5" />
</span>
<span className="min-w-0 text-[14px] font-semibold leading-snug text-[var(--text)]">
{t("emptyStateHomework")}
</span>
</Link>
</div>
} }
> >
{t("library.emptyBody")} {t("library.emptyBody")}
@ -271,7 +292,10 @@ export function LibraryScreen() {
. .
</span> </span>
</EmptyState> </EmptyState>
) : ( ) : null}
{/* deck grid (the decks tab, once there is something to show) ------- */}
{tab === "decks" && !isEmpty ? (
<div className="space-y-8"> <div className="space-y-8">
{groups.map((group) => { {groups.map((group) => {
const isEditing = editingFolder?.id === group.id; const isEditing = editingFolder?.id === group.id;
@ -393,7 +417,40 @@ export function LibraryScreen() {
); );
})} })}
</div> </div>
)} ) : null}
{/* homework: saved solutions and guides, or a homework-only empty state */}
{tab === "homework" && homework.length === 0 ? (
<EmptyState
title={t("homework.emptyTitle")}
action={
<Link
href="/homework?mode=homework"
className="card card-hover touch-target flex w-[min(24rem,calc(100vw_-_3rem))] items-center gap-3 border-accent-400/60 p-3.5 text-left sm:p-4"
>
<span
aria-hidden
className="grad-ice grid size-11 shrink-0 place-items-center rounded-2xl text-[#06111f]"
>
<SparklesIcon className="size-5" />
</span>
<span className="min-w-0 text-[14px] font-semibold leading-snug text-[var(--text)]">
{t("emptyStateHomework")}
</span>
</Link>
}
>
{t("homework.emptyBody")}
</EmptyState>
) : null}
{tab === "homework" && homework.length > 0 ? (
<ul className="grid list-none grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-4 lg:grid-cols-[repeat(auto-fill,minmax(320px,1fr))]">
{homework.map((record) => (
<HomeworkCard key={record.id} record={record} onDelete={deleteHomework} />
))}
</ul>
) : null}
</div> </div>

View file

@ -1,80 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { SnowflakeIcon, SparklesIcon } from "@/components/icons";
import { useI18n } from "@/lib/i18n";
import { cn } from "@/lib/utils";
export type StudyMode = "deck" | "homework";
/** Canonical URL per mode, so a deep link always lands on the right screen. */
export const MODE_HREF: Record<StudyMode, string> = {
deck: "/create?mode=deck",
homework: "/homework?mode=homework",
};
/** How long the ice pill takes to slide before the route follows. */
const SLIDE_MS = 180;
/**
* Compact two-segment mode switch (Study Deck / Homework Solver). The ice pill
* slides to the chosen segment and the router follows a moment later, so the
* toggle reads as one smooth transition instead of a hard jump. The current
* mode stays pressed; the other segment navigates.
*/
export function ModeSwitch({ current }: { current: StudyMode }) {
const { t } = useI18n();
const router = useRouter();
const [pending, setPending] = useState<StudyMode | null>(null);
const timer = useRef<number | null>(null);
useEffect(
() => () => {
if (timer.current !== null) window.clearTimeout(timer.current);
},
[],
);
const choose = (mode: StudyMode) => {
if (mode === current || pending !== null) return;
setPending(mode);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
timer.current = window.setTimeout(() => router.push(MODE_HREF[mode]), reduce ? 0 : SLIDE_MS + 10);
};
const shown = pending ?? current;
const segment = (mode: StudyMode, icon: React.ReactNode, label: string) => (
<button
type="button"
aria-pressed={current === mode}
onClick={() => choose(mode)}
className={cn(
"relative z-10 inline-flex min-h-11 items-center justify-center gap-1.5 rounded-xl px-2 text-[13.5px] font-semibold transition-colors",
shown === mode ? "text-[#06111f]" : "text-frost-700 hover:text-[var(--text)] dark:text-frost-300",
)}
>
{icon}
<span className="whitespace-nowrap">{label}</span>
</button>
);
return (
<div
data-testid="mode-switch"
className="relative grid grid-cols-2 rounded-[0.95rem] border border-[var(--hairline)] bg-[var(--surface-sunken)] p-[3px] backdrop-blur-sm"
>
<span
aria-hidden
className={cn(
"grad-ice pointer-events-none absolute inset-y-[3px] left-[3px] w-[calc(50%-3px)] rounded-xl shadow-[0_6px_16px_-8px_rgb(56_189_248/0.7)] transition-transform duration-[180ms] ease-out motion-reduce:transition-none",
shown === "homework" && "translate-x-full",
)}
/>
{segment("deck", <SnowflakeIcon className="size-4" />, t("actionStudyDeck"))}
{segment("homework", <SparklesIcon className="size-4" />, t("actionHomework"))}
</div>
);
}

View file

@ -33,7 +33,6 @@ import { upsertDeck } from "@/lib/storage";
import { openSettings } from "@/lib/ui-events"; import { openSettings } from "@/lib/ui-events";
import { cn, uid } from "@/lib/utils"; import { cn, uid } from "@/lib/utils";
import { CameraIcon, FolderIcon, IceCubeIcon, PlusIcon, SnowflakeIcon, SunIcon } from "@/components/icons"; import { CameraIcon, FolderIcon, IceCubeIcon, PlusIcon, SnowflakeIcon, SunIcon } from "@/components/icons";
import { ModeSwitch } from "@/components/ModeSwitch";
import { Button, ProgressBar } from "@/components/ui"; import { Button, ProgressBar } from "@/components/ui";
type Phase = "idle" | "preparing" | "loading" | "error"; type Phase = "idle" | "preparing" | "loading" | "error";
@ -321,9 +320,9 @@ export function UploadScreen() {
</p> </p>
</section> </section>
{/* Mode switcher: a compact segmented control. This page is the Study {/* Dedicated flow: this page is the Study Deck uploader. Homework has
Deck side; the other segment opens the homework flow. */} its own screen (/homework), reached from the library tab, the
<ModeSwitch current="deck" /> drawer or the bottom bar — no mode picker here. */}
{error ? ( {error ? (

View file

@ -104,7 +104,7 @@ export function HomeworkInput({
<section className="space-y-2"> <section className="space-y-2">
<h1 className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[28px] font-semibold leading-tight tracking-[-0.02em] text-[var(--text)] text-balance sm:text-[32px]"> <h1 className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[28px] font-semibold leading-tight tracking-[-0.02em] text-[var(--text)] text-balance sm:text-[32px]">
<SparklesIcon className="size-8 shrink-0 text-frost-500 dark:text-frost-200" /> <SparklesIcon className="size-8 shrink-0 text-frost-500 dark:text-frost-200" />
<span>{t("homework.title")}</span> <span>{t("homeworkHeroTitle")}</span>
</h1> </h1>
<p className="max-w-xl text-[15px] text-frost-600 dark:text-frost-300">{t("homework.subtitle")}</p> <p className="max-w-xl text-[15px] text-frost-600 dark:text-frost-300">{t("homework.subtitle")}</p>
</section> </section>

View file

@ -1,11 +1,12 @@
"use client"; "use client";
import { useCallback, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { documentsToText } from "@/lib/documents"; import { documentsToText } from "@/lib/documents";
import { ApiError, generateHomework, type HomeworkProgress } from "@/lib/client-api"; import { ApiError, generateHomework, type HomeworkProgress } from "@/lib/client-api";
import { import {
deleteHomework, deleteHomework,
getHomework,
homeworkStore, homeworkStore,
saveHomework, saveHomework,
sortHomework, sortHomework,
@ -28,7 +29,7 @@ const MAX_TEXT_TO_SEND = 100_000;
type View = { kind: "start" } | { kind: "loading" } | { kind: "record"; record: HomeworkRecord }; type View = { kind: "start" } | { kind: "loading" } | { kind: "record"; record: HomeworkRecord };
/** The /homework screen: give it an assignment, pick a mode, get a result. */ /** The /homework screen: give it an assignment, pick a mode, get a result. */
export function HomeworkScreen() { export function HomeworkScreen({ initialRecordId }: { initialRecordId?: string }) {
const { t } = useI18n(); const { t } = useI18n();
const records = useSyncExternalStore( const records = useSyncExternalStore(
homeworkStore.subscribe, homeworkStore.subscribe,
@ -49,6 +50,13 @@ export function HomeworkScreen() {
const [againLoading, setAgainLoading] = useState(false); const [againLoading, setAgainLoading] = useState(false);
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
// Deep link from the library's Homework tab: open the saved result directly.
useEffect(() => {
if (!initialRecordId) return;
const record = getHomework(initialRecordId);
if (record) setView({ kind: "record", record });
}, [initialRecordId]);
/** Everything the API needs, built from the current input. */ /** Everything the API needs, built from the current input. */
const buildPayload = () => { const buildPayload = () => {
const readyDocuments = intake.documents.filter((entry) => entry.status === "ready" && entry.extracted); const readyDocuments = intake.documents.filter((entry) => entry.status === "ready" && entry.extracted);

View file

@ -169,7 +169,7 @@ const en: Record<string, string> = {
/* make my homework ------------------------------------------------- */ /* make my homework ------------------------------------------------- */
"homework.nav": "Make my homework", "homework.nav": "Make my homework",
"homework.title": "Make my homework", "homeworkHeroTitle": "Solve your homework",
"homework.subtitle": "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.", "Paste the assignment, snap a photo or add a document — then choose whether I should just do it, or guide you through it.",
"homework.source.title": "1. The assignment", "homework.source.title": "1. The assignment",
@ -222,6 +222,16 @@ const en: Record<string, string> = {
"homework.history.guide": "Guide", "homework.history.guide": "Guide",
"homework.history.openTitle": "Open “{title}”", "homework.history.openTitle": "Open “{title}”",
"homework.history.deleteConfirm": "Delete this result?", "homework.history.deleteConfirm": "Delete this result?",
"homework.emptyTitle": "No homework yet",
"homework.emptyBody": "Solved assignments and step-by-step guides you save show up here.",
"homework.open": "Open",
"homework.steps.one": "{n} step",
"homework.steps.few": "{n} steps",
"homework.steps.many": "{n} steps",
/* library tabs ----------------------------------------------------- */
"tabDecks": "Study Decks",
"tabHomework": "Homework",
/* quick actions (library empty state + /create switcher) ----------- */ /* quick actions (library empty state + /create switcher) ----------- */
"emptyStateCreateDeck": "Study Deck", "emptyStateCreateDeck": "Study Deck",
@ -753,7 +763,7 @@ const cs: Record<string, string> = {
/* make my homework ------------------------------------------------- */ /* make my homework ------------------------------------------------- */
"homework.nav": "Udělej mi úkol", "homework.nav": "Udělej mi úkol",
"homework.title": "Udělej mi úkol", "homeworkHeroTitle": "Vyřeš svůj úkol",
"homework.subtitle": "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.", "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.",
"homework.source.title": "1. Zadání", "homework.source.title": "1. Zadání",
@ -806,6 +816,16 @@ const cs: Record<string, string> = {
"homework.history.guide": "Návod", "homework.history.guide": "Návod",
"homework.history.openTitle": "Otevřít „{title}“", "homework.history.openTitle": "Otevřít „{title}“",
"homework.history.deleteConfirm": "Smazat tento výsledek?", "homework.history.deleteConfirm": "Smazat tento výsledek?",
"homework.emptyTitle": "Zatím žádné úkoly",
"homework.emptyBody": "Uložená řešení úkolů a návody krok za krokem se objeví tady.",
"homework.open": "Otevřít",
"homework.steps.one": "{n} krok",
"homework.steps.few": "{n} kroky",
"homework.steps.many": "{n} kroků",
/* library tabs ----------------------------------------------------- */
"tabDecks": "Studijní balíčky",
"tabHomework": "Úkoly",
/* quick actions (library empty state + /create switcher) ----------- */ /* quick actions (library empty state + /create switcher) ----------- */
"emptyStateCreateDeck": "Studijní balíček", "emptyStateCreateDeck": "Studijní balíček",

View file

@ -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-v9"; const CACHE_VERSION = "freezmo-v10";
const SHELL_CACHE = `${CACHE_VERSION}-shell`; const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const ASSET_CACHE = `${CACHE_VERSION}-assets`; const ASSET_CACHE = `${CACHE_VERSION}-assets`;