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:
parent
461dd2ffcf
commit
8c44f47881
12 changed files with 295 additions and 162 deletions
|
|
@ -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**
|
||||
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
|
||||
- Results live in `freezmo.homework.v1` with a small history you can reopen or delete; **Make it
|
||||
again** re-runs from the stored assignment text — original files are never saved
|
||||
- Results live in `freezmo.homework.v1` — the homework screen's history and the library's
|
||||
**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
|
||||
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**
|
||||
|
||||
- **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
|
||||
- **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**.
|
||||
|
|
|
|||
|
|
@ -492,6 +492,28 @@ body {
|
|||
background-image: linear-gradient(135deg, #38bdf8 0%, #60a5fa 100%);
|
||||
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. */
|
||||
.grad-ice {
|
||||
|
|
@ -705,7 +727,8 @@ body {
|
|||
@media (max-width: 639px) {
|
||||
.touch-target { min-height: 44px; min-width: 44px; }
|
||||
.nav-row { min-height: 44px; }
|
||||
.segmented > button { min-height: 44px; }
|
||||
.segmented > button,
|
||||
.segmented > a { min-height: 44px; }
|
||||
.folder-pill { min-height: 44px; }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export default async function HomeworkPage({
|
|||
}: {
|
||||
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
|
||||
// link that points at the Study Deck side is handed back to /create.
|
||||
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">
|
||||
<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">
|
||||
<HomeworkScreen />
|
||||
<HomeworkScreen initialRecordId={typeof open === "string" ? open : undefined} />
|
||||
</div>
|
||||
<TabBar />
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,12 @@ import { LibraryScreen } from "@/components/LibraryScreen";
|
|||
import { MobileSidebar, Sidebar, SidebarOffset } from "@/components/Sidebar";
|
||||
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 (
|
||||
<ApiKeyGate>
|
||||
<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">
|
||||
<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">
|
||||
<LibraryScreen />
|
||||
<LibraryScreen initialTab={tab === "homework" ? "homework" : "decks"} />
|
||||
</div>
|
||||
<TabBar />
|
||||
</main>
|
||||
|
|
|
|||
97
components/HomeworkCard.tsx
Normal file
97
components/HomeworkCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,9 +5,11 @@ import { useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|||
|
||||
import { DeckCard } from "@/components/DeckCard";
|
||||
import { FolderBar } from "@/components/FolderBar";
|
||||
import { HomeworkCard } from "@/components/HomeworkCard";
|
||||
import { FolderIcon, SnowflakeIcon, SparklesIcon, UploadIcon } from "@/components/icons";
|
||||
import { Button, EmptyState } from "@/components/ui";
|
||||
import { folderStore, renameFolder } from "@/lib/folders";
|
||||
import { deleteHomework, homeworkStore, sortHomework } from "@/lib/homework";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { ALL_FOLDERS, folderSelection, useSelectedFolder } from "@/lib/nav";
|
||||
import { openSettings } from "@/lib/ui-events";
|
||||
|
|
@ -16,11 +18,19 @@ import { deckStore, deleteFolder, importDeck, sortDecks, upsertDeck } from "@/li
|
|||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Dashboard: page title, folder pills, then a spacious responsive grid of
|
||||
* deck cards grouped by folder.
|
||||
* Dashboard with two separate pools: study decks (folders + card grid) and
|
||||
* 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 homeworkRecords = useSyncExternalStore(
|
||||
homeworkStore.subscribe,
|
||||
homeworkStore.getSnapshot,
|
||||
homeworkStore.getServerSnapshot,
|
||||
);
|
||||
const homework = useMemo(() => sortHomework(homeworkRecords), [homeworkRecords]);
|
||||
const folders = useSyncExternalStore(
|
||||
folderStore.subscribe,
|
||||
folderStore.getSnapshot,
|
||||
|
|
@ -119,38 +129,57 @@ export function LibraryScreen() {
|
|||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* With nothing in the library, Import drops to a quiet text link
|
||||
so the single primary action is “Make your first deck”. */}
|
||||
{isEmpty ? (
|
||||
<button
|
||||
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>
|
||||
{/* Context action: Import for decks (a quiet link while the pool
|
||||
is empty), start-a-new-solve for homework. */}
|
||||
{tab === "decks" ? (
|
||||
isEmpty ? (
|
||||
<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"
|
||||
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
|
||||
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
|
||||
ref={importRef}
|
||||
type="file"
|
||||
|
|
@ -162,11 +191,21 @@ export function LibraryScreen() {
|
|||
}}
|
||||
/>
|
||||
</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>
|
||||
|
||||
{/* Secondary stats folded into the header as quiet text on desktop,
|
||||
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">
|
||||
{plural(decks.length, "stats.decks")}
|
||||
<span aria-hidden> · </span>
|
||||
|
|
@ -185,7 +224,7 @@ export function LibraryScreen() {
|
|||
{/* 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. */}
|
||||
{!isEmpty ? (
|
||||
{tab === "decks" && !isEmpty ? (
|
||||
<FolderBar
|
||||
className="lg:hidden"
|
||||
folders={folderList}
|
||||
|
|
@ -206,7 +245,7 @@ export function LibraryScreen() {
|
|||
) : null}
|
||||
|
||||
|
||||
{message ? (
|
||||
{tab === "decks" && message ? (
|
||||
<p
|
||||
role="status"
|
||||
className={cn(
|
||||
|
|
@ -219,43 +258,25 @@ export function LibraryScreen() {
|
|||
) : null}
|
||||
</section>
|
||||
|
||||
{/* deck grid ------------------------------------------------------ */}
|
||||
{isEmpty ? (
|
||||
{/* decks: folders + card grid, or a decks-only empty state --------- */}
|
||||
{tab === "decks" && isEmpty ? (
|
||||
<EmptyState
|
||||
title={t("library.noDecks")}
|
||||
action={
|
||||
/* Two ways in: build a study deck from notes, or hand over an
|
||||
assignment. Stacked on phones, side by side from 640px up. */
|
||||
<div className="grid w-[min(24rem,calc(100vw_-_3rem))] gap-3 sm:w-[36rem] sm:grid-cols-2">
|
||||
<Link
|
||||
href="/create?mode=deck"
|
||||
className="card card-hover touch-target flex items-center gap-3 border-accent-400/60 p-3.5 text-left sm:p-4"
|
||||
<Link
|
||||
href="/create?mode=deck"
|
||||
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]"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="grad-ice grid size-11 shrink-0 place-items-center rounded-2xl text-[#06111f]"
|
||||
>
|
||||
<SnowflakeIcon className="size-5" />
|
||||
</span>
|
||||
<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>
|
||||
<SnowflakeIcon className="size-5" />
|
||||
</span>
|
||||
<span className="min-w-0 text-[14px] font-semibold leading-snug text-[var(--text)]">
|
||||
{t("emptyStateCreateDeck")}
|
||||
</span>
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
{t("library.emptyBody")}
|
||||
|
|
@ -271,7 +292,10 @@ export function LibraryScreen() {
|
|||
.
|
||||
</span>
|
||||
</EmptyState>
|
||||
) : (
|
||||
) : null}
|
||||
|
||||
{/* deck grid (the decks tab, once there is something to show) ------- */}
|
||||
{tab === "decks" && !isEmpty ? (
|
||||
<div className="space-y-8">
|
||||
{groups.map((group) => {
|
||||
const isEditing = editingFolder?.id === group.id;
|
||||
|
|
@ -393,7 +417,40 @@ export function LibraryScreen() {
|
|||
);
|
||||
})}
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -33,7 +33,6 @@ import { upsertDeck } from "@/lib/storage";
|
|||
import { openSettings } from "@/lib/ui-events";
|
||||
import { cn, uid } from "@/lib/utils";
|
||||
import { CameraIcon, FolderIcon, IceCubeIcon, PlusIcon, SnowflakeIcon, SunIcon } from "@/components/icons";
|
||||
import { ModeSwitch } from "@/components/ModeSwitch";
|
||||
import { Button, ProgressBar } from "@/components/ui";
|
||||
|
||||
type Phase = "idle" | "preparing" | "loading" | "error";
|
||||
|
|
@ -321,9 +320,9 @@ export function UploadScreen() {
|
|||
</p>
|
||||
</section>
|
||||
|
||||
{/* Mode switcher: a compact segmented control. This page is the Study
|
||||
Deck side; the other segment opens the homework flow. */}
|
||||
<ModeSwitch current="deck" />
|
||||
{/* Dedicated flow: this page is the Study Deck uploader. Homework has
|
||||
its own screen (/homework), reached from the library tab, the
|
||||
drawer or the bottom bar — no mode picker here. */}
|
||||
|
||||
|
||||
{error ? (
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ export function HomeworkInput({
|
|||
<section className="space-y-2">
|
||||
<h1 className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[28px] font-semibold leading-tight tracking-[-0.02em] text-[var(--text)] text-balance sm:text-[32px]">
|
||||
<SparklesIcon className="size-8 shrink-0 text-frost-500 dark:text-frost-200" />
|
||||
<span>{t("homework.title")}</span>
|
||||
<span>{t("homeworkHeroTitle")}</span>
|
||||
</h1>
|
||||
<p className="max-w-xl text-[15px] text-frost-600 dark:text-frost-300">{t("homework.subtitle")}</p>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
"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 { ApiError, generateHomework, type HomeworkProgress } from "@/lib/client-api";
|
||||
import {
|
||||
deleteHomework,
|
||||
getHomework,
|
||||
homeworkStore,
|
||||
saveHomework,
|
||||
sortHomework,
|
||||
|
|
@ -28,7 +29,7 @@ const MAX_TEXT_TO_SEND = 100_000;
|
|||
type View = { kind: "start" } | { kind: "loading" } | { kind: "record"; record: HomeworkRecord };
|
||||
|
||||
/** 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 records = useSyncExternalStore(
|
||||
homeworkStore.subscribe,
|
||||
|
|
@ -49,6 +50,13 @@ export function HomeworkScreen() {
|
|||
const [againLoading, setAgainLoading] = useState(false);
|
||||
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. */
|
||||
const buildPayload = () => {
|
||||
const readyDocuments = intake.documents.filter((entry) => entry.status === "ready" && entry.extracted);
|
||||
|
|
|
|||
|
|
@ -169,7 +169,7 @@ const en: Record<string, string> = {
|
|||
|
||||
/* make my homework ------------------------------------------------- */
|
||||
"homework.nav": "Make my homework",
|
||||
"homework.title": "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.",
|
||||
"homework.source.title": "1. The assignment",
|
||||
|
|
@ -222,6 +222,16 @@ const en: Record<string, string> = {
|
|||
"homework.history.guide": "Guide",
|
||||
"homework.history.openTitle": "Open “{title}”",
|
||||
"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) ----------- */
|
||||
"emptyStateCreateDeck": "Study Deck",
|
||||
|
|
@ -753,7 +763,7 @@ const cs: Record<string, string> = {
|
|||
|
||||
/* make my homework ------------------------------------------------- */
|
||||
"homework.nav": "Udělej mi úkol",
|
||||
"homework.title": "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.",
|
||||
"homework.source.title": "1. Zadání",
|
||||
|
|
@ -806,6 +816,16 @@ const cs: Record<string, string> = {
|
|||
"homework.history.guide": "Návod",
|
||||
"homework.history.openTitle": "Otevřít „{title}“",
|
||||
"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) ----------- */
|
||||
"emptyStateCreateDeck": "Studijní balíček",
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
*
|
||||
* 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 ASSET_CACHE = `${CACHE_VERSION}-assets`;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue