diff --git a/README.md b/README.md index e212f77..6bb67f2 100644 --- a/README.md +++ b/README.md @@ -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**. diff --git a/app/globals.css b/app/globals.css index 9fe04c0..d2ae8e0 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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; } } diff --git a/app/homework/page.tsx b/app/homework/page.tsx index 619a213..8930e05 100644 --- a/app/homework/page.tsx +++ b/app/homework/page.tsx @@ -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({
- +
diff --git a/app/page.tsx b/app/page.tsx index e0721d3..cec60b5 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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 (
@@ -14,7 +19,7 @@ export default function HomePage() {
- +
diff --git a/components/HomeworkCard.tsx b/components/HomeworkCard.tsx new file mode 100644 index 0000000..f0af366 --- /dev/null +++ b/components/HomeworkCard.tsx @@ -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 ( +
  • + {/* Desktop: the whole card opens the result; the delete button sits above it. */} + + +
    +
    +

    + {truncate(record.title, 90)} +

    +

    + + {record.mode === "make" ? : } + {t(record.mode === "make" ? "homework.history.made" : "homework.history.guide")} + + · + {formatDate(record.updatedAt)} + {record.mode === "guide" ? ( + <> + · + {plural(record.steps.length, "homework.steps")} + + ) : null} +

    +
    + +
    + + {preview ? ( +

    {preview}

    + ) : null} + +
    + {confirmDelete ? ( +
    + {t("homework.history.deleteConfirm")} + + +
    + ) : ( + <> + + {t("homework.open")} + +
    + + {t("homework.open")} + +
    + + )} +
    +
  • + ); +} diff --git a/components/LibraryScreen.tsx b/components/LibraryScreen.tsx index 137033a..390050b 100644 --- a/components/LibraryScreen.tsx +++ b/components/LibraryScreen.tsx @@ -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() {
    - {/* With nothing in the library, Import drops to a quiet text link - so the single primary action is “Make your first deck”. */} - {isEmpty ? ( - - ) : ( - <> - + {/* Context action: Import for decks (a quiet link while the pool + is empty), start-a-new-solve for homework. */} + {tab === "decks" ? ( + isEmpty ? ( + ) : ( + <> + + + + ) + ) : homework.length > 0 ? ( + <> + + + {t("actionHomework")} + + + + {t("actionHomework")} + - )} + ) : null}
    + + {/* Two pools, never mixed: study decks and saved homework. */} +
    + + {t("tabDecks")} + + + {t("tabHomework")} + +
    {/* 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 ? (

    {plural(decks.length, "stats.decks")} · @@ -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 ? ( - {/* deck grid ------------------------------------------------------ */} - {isEmpty ? ( + {/* decks: folders + card grid, or a decks-only empty state --------- */} + {tab === "decks" && isEmpty ? ( - + - - - - - {t("emptyStateCreateDeck")} - - - - - - - - {t("emptyStateHomework")} - - - + + + + {t("emptyStateCreateDeck")} + + } > {t("library.emptyBody")} @@ -271,7 +292,10 @@ export function LibraryScreen() { . - ) : ( + ) : null} + + {/* deck grid (the decks tab, once there is something to show) ------- */} + {tab === "decks" && !isEmpty ? (

    {groups.map((group) => { const isEditing = editingFolder?.id === group.id; @@ -393,7 +417,40 @@ export function LibraryScreen() { ); })}
    - )} + ) : null} + + {/* homework: saved solutions and guides, or a homework-only empty state */} + {tab === "homework" && homework.length === 0 ? ( + + + + + + {t("emptyStateHomework")} + + + } + > + {t("homework.emptyBody")} + + ) : null} + + {tab === "homework" && homework.length > 0 ? ( + + ) : null} diff --git a/components/ModeSwitch.tsx b/components/ModeSwitch.tsx deleted file mode 100644 index 477f9b7..0000000 --- a/components/ModeSwitch.tsx +++ /dev/null @@ -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 = { - 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(null); - const timer = useRef(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) => ( - - ); - - return ( -
    - - {segment("deck", , t("actionStudyDeck"))} - {segment("homework", , t("actionHomework"))} -
    - ); -} diff --git a/components/UploadScreen.tsx b/components/UploadScreen.tsx index df8f927..79ac846 100644 --- a/components/UploadScreen.tsx +++ b/components/UploadScreen.tsx @@ -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() {

    - {/* Mode switcher: a compact segmented control. This page is the Study - Deck side; the other segment opens the homework flow. */} - + {/* 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 ? ( diff --git a/components/homework/HomeworkInput.tsx b/components/homework/HomeworkInput.tsx index 4afb90a..44ad645 100644 --- a/components/homework/HomeworkInput.tsx +++ b/components/homework/HomeworkInput.tsx @@ -104,7 +104,7 @@ export function HomeworkInput({

    - {t("homework.title")} + {t("homeworkHeroTitle")}

    {t("homework.subtitle")}

    diff --git a/components/homework/HomeworkScreen.tsx b/components/homework/HomeworkScreen.tsx index 2d8f1eb..702b55b 100644 --- a/components/homework/HomeworkScreen.tsx +++ b/components/homework/HomeworkScreen.tsx @@ -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(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); diff --git a/lib/i18n-dict.ts b/lib/i18n-dict.ts index a996d65..243fded 100644 --- a/lib/i18n-dict.ts +++ b/lib/i18n-dict.ts @@ -169,7 +169,7 @@ const en: Record = { /* 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 = { "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 = { /* 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 = { "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", diff --git a/public/sw.js b/public/sw.js index c165750..4ef9ccb 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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`;