Add “Make my homework”, and finish the mobile polish pass
Homework (/homework — entry points in the drawer and on the create screen) - Assignment intake reuses the study pipeline: pasted text, photos (camera/picker, compressed in the browser) and documents (PDF/DOCX/PPTX/ ODT/text) through lib/image + lib/documents, the same vision step and the same language guard. Raw files never leave the device — only text and JPEG data URLs reach the server. - Two large choice cards: “Just make it” returns the finished homework (answer only, plain text, copy + make it again); “Explain how to make this homework” builds an interactive guide. - The guide is one step per screen in the study-feed snap layout: explanation, three progressive hints (nudge -> hint -> the answer) and an optional checkpoint rendered by the existing activity components (graded through /api/grade). The last screen assembles the solution from the step answers, with a copy button. Checkpoints carry xp 0 so the guide stays free of XP/streak chrome. - Results live in freezmo.homework.v1 with a small history (open/delete); “Make it again” re-runs from the stored assignment text (the vision transcription when photos were used) — no original files are stored. - The answer and the guide are written in the assignment's language, never translated into the UI language. Server - POST /api/homework: NDJSON stage events like /api/generate, the same http-guard, auth checked before parsing, and its own RATE_LIMIT_HOMEWORK (documented in .env.example + the README table). - New prompts in lib/prompts.ts and zod schemas in lib/schemas.ts with the same single repair retry; TEXT_MODEL for content, VISION_MODEL only for photos. New shared client pieces: useNotesIntake, lib/homework store, lib/clipboard, and a shared NDJSON reader in lib/client-api. Also finishing the mobile polish pass from the previous request - Phone ice scene: the top-left orb moved behind the glass header (it sat behind the page heading/subtitle). The settings sheet's scroll cue is now a bare chevron inside a content fade (mask) instead of a chip over the status banner, and the API key card's note/disclosure/actions are tightened. - theme-color now follows the in-app theme (SSR metas still fall back to the OS preference); apple-mobile-web-app-status-bar-style stays “default” because iOS fixes the status-bar text colour at launch. Verified: 78/78 homework checks (text/photo/PDF, both modes, EN+CS, dark+light, 360/390px, copy, history persistence, storage privacy, API language, axe), plus the existing suites re-run (mobile 47/47, pixel contrast 0 below AA, i18n 27/27, desktop regression 72/72). Lint + build clean; service worker freezmo-v6.
This commit is contained in:
parent
9fb728d735
commit
ef2a6a3589
35 changed files with 2334 additions and 100 deletions
|
|
@ -257,23 +257,26 @@ export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () =
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] leading-relaxed text-[var(--text-subtle)]">
|
||||
{t("settings.keyNote")}
|
||||
</p>
|
||||
|
||||
<details className="group">
|
||||
<summary className="touch-target flex cursor-pointer list-none items-center gap-1.5 py-1 text-[11px] font-medium text-[var(--text-muted)] transition hover:text-[var(--text)] [&::-webkit-details-marker]:hidden">
|
||||
<ChevronDownIcon className="size-3.5 shrink-0 transition-transform group-open:rotate-180" />
|
||||
{t("settings.localTipTitle")}
|
||||
</summary>
|
||||
<p className="pt-1 text-[11px] leading-relaxed text-[var(--text-subtle)]">
|
||||
{t("settings.localTipSet")} <code>OLLAMA_AUTH=local</code> {t("settings.keyNoteAfter")}{" "}
|
||||
<code>.env.local</code> {t("settings.keyNoteEnd")} <code>ollama signin</code>
|
||||
{t("settings.keyNoteEnd2")}
|
||||
<div className="space-y-2">
|
||||
<p className="text-[11px] leading-relaxed text-[var(--text-subtle)]">
|
||||
{t("settings.keyNote")}
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 pt-0.5">
|
||||
<details className="group">
|
||||
<summary className="touch-target -mt-1 flex cursor-pointer list-none items-center gap-1.5 text-[11px] font-medium text-[var(--text-muted)] transition hover:text-[var(--text)] [&::-webkit-details-marker]:hidden">
|
||||
<ChevronDownIcon className="size-3.5 shrink-0 transition-transform group-open:rotate-180" />
|
||||
{t("settings.localTipTitle")}
|
||||
</summary>
|
||||
<p className="pt-0.5 text-[11px] leading-relaxed text-[var(--text-subtle)]">
|
||||
{t("settings.localTipSet")} <code>OLLAMA_AUTH=local</code>{" "}
|
||||
{t("settings.keyNoteAfter")} <code>.env.local</code> {t("settings.keyNoteEnd")}{" "}
|
||||
<code>ollama signin</code>
|
||||
{t("settings.keyNoteEnd2")}
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
|
|
@ -288,38 +291,37 @@ export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () =
|
|||
{t("settings.savedTesting")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{apiKey ? (
|
||||
confirmRemove ? (
|
||||
<div className="rounded-xl border border-rose-300/70 bg-rose-50 p-3 dark:border-rose-400/25 dark:bg-rose-500/10">
|
||||
<p className="text-[12px] leading-relaxed text-rose-900 dark:text-rose-100">
|
||||
{t("settings.removeKeyConfirm")}
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="danger" className="touch-target" onClick={forget}>
|
||||
{t("settings.removeKeyYes")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="touch-target"
|
||||
onClick={() => setConfirmRemove(false)}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
{apiKey && !confirmRemove ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="touch-target -ml-3 text-rose-700 hover:bg-rose-500/10 dark:text-rose-300"
|
||||
className="touch-target -ml-2.5 text-rose-700 hover:bg-rose-500/10 dark:text-rose-300"
|
||||
onClick={() => setConfirmRemove(true)}
|
||||
>
|
||||
{t("settings.removeKey")}
|
||||
</Button>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{apiKey && confirmRemove ? (
|
||||
<div className="rounded-xl border border-rose-300/70 bg-rose-50 p-3 dark:border-rose-400/25 dark:bg-rose-500/10">
|
||||
<p className="text-[12px] leading-relaxed text-rose-900 dark:text-rose-100">
|
||||
{t("settings.removeKeyConfirm")}
|
||||
</p>
|
||||
<div className="mt-1.5 flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="danger" className="touch-target" onClick={forget}>
|
||||
{t("settings.removeKeyYes")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="touch-target"
|
||||
onClick={() => setConfirmRemove(false)}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
|
|
@ -371,22 +373,21 @@ export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () =
|
|||
|
||||
{health ? <HealthPanel health={health} /> : null}
|
||||
|
||||
{/* breathing room for the scroll cue + safe area */}
|
||||
<div className="h-5" aria-hidden />
|
||||
{/* keeps real content out of the 36px fade strip at the bottom */}
|
||||
<div className="h-9" aria-hidden />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* visible scroll cue */}
|
||||
{/* scroll cue: a bare chevron inside the fade strip, so it never
|
||||
covers text (the .sheet-scroll mask fades what is underneath) */}
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-x-0 bottom-2 flex justify-center transition-opacity duration-200",
|
||||
"pointer-events-none absolute inset-x-0 bottom-0 flex h-9 items-end justify-center pb-1.5 transition-opacity duration-200",
|
||||
canScroll ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
>
|
||||
<span className="grid size-7 place-items-center rounded-full border border-[var(--hairline)] bg-[var(--surface-strong)] text-[var(--text-muted)] shadow-[0_4px_12px_-6px_rgb(14_21_40/0.5)] backdrop-blur-md">
|
||||
<ChevronDownIcon className="size-3.5" />
|
||||
</span>
|
||||
<ChevronDownIcon className="size-4 text-[var(--text-subtle)]" />
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
LayersIcon,
|
||||
MoonIcon,
|
||||
PlusIcon,
|
||||
SparklesIcon,
|
||||
SunIcon,
|
||||
} from "@/components/icons";
|
||||
import { useTheme } from "@/components/ThemeToggle";
|
||||
|
|
@ -146,6 +147,17 @@ function NavContent({
|
|||
{!collapsed ? <span className="flex-1 truncate text-left">{t("common.newDeck")}</span> : null}
|
||||
</Link>
|
||||
|
||||
{/* make my homework: same quiet row --------------------------------- */}
|
||||
<Link
|
||||
href="/homework"
|
||||
onClick={onNavigate}
|
||||
className={cn("nav-row mb-3", collapsed && "justify-center px-2")}
|
||||
title={t("homework.nav")}
|
||||
>
|
||||
<SparklesIcon className="size-4 shrink-0" />
|
||||
{!collapsed ? <span className="flex-1 truncate text-left">{t("homework.nav")}</span> : null}
|
||||
</Link>
|
||||
|
||||
{/* folders --------------------------------------------------------- */}
|
||||
{/* Mobile keeps the original layout: one Folders label above the list. */}
|
||||
{!desktop && !collapsed ? (
|
||||
|
|
|
|||
|
|
@ -8,6 +8,28 @@ import { MoonIcon, SunIcon } from "@/components/icons";
|
|||
|
||||
const STORAGE_KEY = "freezmo.theme";
|
||||
|
||||
const THEME_COLORS = { light: "#f4f7fd", dark: "#0e1424" } as const;
|
||||
|
||||
/**
|
||||
* Keeps the first `theme-color` meta (the one the app owns) in step with the
|
||||
* in-app theme. The SSR metas follow the OS preference; this one always wins
|
||||
* because it is media-less and first in the head.
|
||||
*/
|
||||
export function syncThemeColor(theme: "light" | "dark") {
|
||||
try {
|
||||
let meta = document.querySelector<HTMLMetaElement>('meta[name="theme-color"][data-app]');
|
||||
if (!meta) {
|
||||
meta = document.createElement("meta");
|
||||
meta.setAttribute("name", "theme-color");
|
||||
meta.setAttribute("data-app", "1");
|
||||
document.head.insertBefore(meta, document.head.firstChild);
|
||||
}
|
||||
meta.setAttribute("content", THEME_COLORS[theme]);
|
||||
} catch {
|
||||
/* private mode / odd embedded browsers */
|
||||
}
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const [theme, setTheme] = useState<"light" | "dark">("light");
|
||||
|
||||
|
|
@ -17,11 +39,13 @@ export function useTheme() {
|
|||
? stored === "dark"
|
||||
: window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
setTheme(isDark ? "dark" : "light");
|
||||
syncThemeColor(isDark ? "dark" : "light");
|
||||
}, []);
|
||||
|
||||
const apply = (next: "light" | "dark") => {
|
||||
setTheme(next);
|
||||
document.documentElement.classList.toggle("dark", next === "dark");
|
||||
syncThemeColor(next);
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, next);
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||
|
||||
|
|
@ -32,7 +33,7 @@ import { UNORGANIZED_ID, type Difficulty } from "@/lib/schemas";
|
|||
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 { CameraIcon, ChevronRightIcon, FolderIcon, IceCubeIcon, PlusIcon, SnowflakeIcon, SparklesIcon, SunIcon } from "@/components/icons";
|
||||
import { Button, ProgressBar } from "@/components/ui";
|
||||
|
||||
type Phase = "idle" | "preparing" | "loading" | "error";
|
||||
|
|
@ -320,6 +321,24 @@ export function UploadScreen() {
|
|||
</p>
|
||||
</section>
|
||||
|
||||
{/* The other thing you can bring here: an assignment. */}
|
||||
<Link
|
||||
href="/homework"
|
||||
className="card card-hover flex items-center gap-3 p-4 text-left"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="grid size-10 shrink-0 place-items-center rounded-xl bg-[var(--surface-sunken)] text-[var(--text-muted)]"
|
||||
>
|
||||
<SparklesIcon className="size-5" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-[14px] font-semibold text-[var(--text)]">{t("homework.entry")}</span>
|
||||
<span className="block text-[12.5px] text-[var(--text-subtle)]">{t("homework.nav")} →</span>
|
||||
</span>
|
||||
<ChevronRightIcon className="size-4 shrink-0 text-[var(--text-subtle)]" />
|
||||
</Link>
|
||||
|
||||
|
||||
{error ? (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ export function isFillBlankCorrect(userValue: string, answer: string): boolean {
|
|||
});
|
||||
}
|
||||
|
||||
export function FillBlank({ activity, active, onAnswered, onNext, isLast }: ActivityComponentProps<FillBlankActivity>) {
|
||||
export function FillBlank({ activity, active, onAnswered, onNext, isLast, nextLabel }: ActivityComponentProps<FillBlankActivity>) {
|
||||
const { t } = useI18n();
|
||||
const [value, setValue] = useState("");
|
||||
const [chosen, setChosen] = useState<string | null>(null);
|
||||
|
|
@ -185,6 +185,7 @@ export function FillBlank({ activity, active, onAnswered, onNext, isLast }: Acti
|
|||
explanation={activity.explanation}
|
||||
xp={activity.xp}
|
||||
isLast={isLast}
|
||||
nextLabel={nextLabel}
|
||||
onNext={onNext}
|
||||
detail={
|
||||
<span>
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import {
|
|||
|
||||
type FlashcardActivity = Extract<Activity, { type: "flashcard" }>;
|
||||
|
||||
export function Flashcard({ activity, onAnswered, onNext, isLast }: ActivityComponentProps<FlashcardActivity>) {
|
||||
export function Flashcard({ activity, onAnswered, onNext, isLast, nextLabel }: ActivityComponentProps<FlashcardActivity>) {
|
||||
const { t } = useI18n();
|
||||
const [flipped, setFlipped] = useState(false);
|
||||
const [result, setResult] = useState<boolean | null>(null);
|
||||
|
|
@ -85,6 +85,7 @@ export function Flashcard({ activity, onAnswered, onNext, isLast }: ActivityComp
|
|||
explanation={activity.explanation}
|
||||
xp={activity.xp}
|
||||
isLast={isLast}
|
||||
nextLabel={nextLabel}
|
||||
headline={result ? t("flashcard.frozenSolid") : t("flashcard.keepPile")}
|
||||
onNext={onNext}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import {
|
|||
|
||||
type MatchingActivity = Extract<Activity, { type: "matching" }>;
|
||||
|
||||
export function Matching({ activity, onAnswered, onNext, isLast }: ActivityComponentProps<MatchingActivity>) {
|
||||
export function Matching({ activity, onAnswered, onNext, isLast, nextLabel }: ActivityComponentProps<MatchingActivity>) {
|
||||
const { t, plural } = useI18n();
|
||||
const terms = useMemo(
|
||||
() => shuffle(activity.pairs.map((pair, index) => ({ index, text: pair.term }))),
|
||||
|
|
@ -137,6 +137,7 @@ export function Matching({ activity, onAnswered, onNext, isLast }: ActivityCompo
|
|||
explanation={activity.explanation}
|
||||
xp={activity.xp}
|
||||
isLast={isLast}
|
||||
nextLabel={nextLabel}
|
||||
onNext={onNext}
|
||||
headline={misses === 0 ? t("matching.perfect") : t("matching.slips")}
|
||||
detail={
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export function MultipleChoice({
|
|||
onAnswered,
|
||||
onNext,
|
||||
isLast,
|
||||
nextLabel,
|
||||
}: ActivityComponentProps<MultipleChoiceActivity>) {
|
||||
const { t } = useI18n();
|
||||
const [selected, setSelected] = useState<number | null>(null);
|
||||
|
|
@ -86,6 +87,7 @@ export function MultipleChoice({
|
|||
explanation={activity.explanation}
|
||||
xp={activity.xp}
|
||||
isLast={isLast}
|
||||
nextLabel={nextLabel}
|
||||
onNext={onNext}
|
||||
detail={
|
||||
selected === activity.correctIndex ? null : (
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import {
|
|||
|
||||
type OrderingActivity = Extract<Activity, { type: "ordering" }>;
|
||||
|
||||
export function Ordering({ activity, onAnswered, onNext, isLast }: ActivityComponentProps<OrderingActivity>) {
|
||||
export function Ordering({ activity, onAnswered, onNext, isLast, nextLabel }: ActivityComponentProps<OrderingActivity>) {
|
||||
const { t } = useI18n();
|
||||
const initial = useMemo(() => shuffle(activity.items), [activity]);
|
||||
const [items, setItems] = useState<string[]>(initial);
|
||||
|
|
@ -126,6 +126,7 @@ export function Ordering({ activity, onAnswered, onNext, isLast }: ActivityCompo
|
|||
explanation={activity.explanation}
|
||||
xp={activity.xp}
|
||||
isLast={isLast}
|
||||
nextLabel={nextLabel}
|
||||
onNext={onNext}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ type GradeState =
|
|||
| { status: "error"; message: string }
|
||||
| { status: "self-check" };
|
||||
|
||||
export function ShortAnswer({ activity, active, language, onAnswered, onNext, isLast }: ActivityComponentProps<ShortAnswerActivity>) {
|
||||
export function ShortAnswer({ activity, active, language, onAnswered, onNext, isLast, nextLabel }: ActivityComponentProps<ShortAnswerActivity>) {
|
||||
const { t } = useI18n();
|
||||
const [value, setValue] = useState("");
|
||||
const [state, setState] = useState<GradeState>({ status: "idle" });
|
||||
|
|
@ -182,6 +182,7 @@ export function ShortAnswer({ activity, active, language, onAnswered, onNext, is
|
|||
explanation={activity.explanation}
|
||||
xp={activity.xp}
|
||||
isLast={isLast}
|
||||
nextLabel={nextLabel}
|
||||
onNext={onNext}
|
||||
/>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import {
|
|||
|
||||
type TrueFalseActivity = Extract<Activity, { type: "true_false" }>;
|
||||
|
||||
export function TrueFalse({ activity, onAnswered, onNext, isLast }: ActivityComponentProps<TrueFalseActivity>) {
|
||||
export function TrueFalse({ activity, onAnswered, onNext, isLast, nextLabel }: ActivityComponentProps<TrueFalseActivity>) {
|
||||
const { t } = useI18n();
|
||||
const [result, setResult] = useState<boolean | null>(null);
|
||||
const x = useMotionValue(0);
|
||||
|
|
@ -84,6 +84,7 @@ export function TrueFalse({ activity, onAnswered, onNext, isLast }: ActivityComp
|
|||
explanation={activity.explanation}
|
||||
xp={activity.xp}
|
||||
isLast={isLast}
|
||||
nextLabel={nextLabel}
|
||||
onNext={onNext}
|
||||
detail={
|
||||
<span>
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ export type ActivityComponentProps<A extends Activity = Activity> = {
|
|||
/** Scroll the feed to the next slide. */
|
||||
onNext: () => void;
|
||||
isLast: boolean;
|
||||
/** Overrides the feedback button label (the homework guide uses this). */
|
||||
nextLabel?: string;
|
||||
};
|
||||
|
||||
export const TYPE_META: Record<Activity["type"], { labelKey: string; icon: string; tint: string }> = {
|
||||
|
|
@ -133,6 +135,7 @@ export function FeedbackPanel({
|
|||
xp,
|
||||
onNext,
|
||||
isLast,
|
||||
nextLabel,
|
||||
headline,
|
||||
detail,
|
||||
children,
|
||||
|
|
@ -142,6 +145,8 @@ export function FeedbackPanel({
|
|||
xp?: number;
|
||||
onNext: () => void;
|
||||
isLast?: boolean;
|
||||
/** Overrides the default “Next” / “See results” label. */
|
||||
nextLabel?: string;
|
||||
headline?: string;
|
||||
detail?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
|
|
@ -193,7 +198,7 @@ export function FeedbackPanel({
|
|||
|
||||
<div className="mt-4 flex items-center justify-end">
|
||||
<Button onClick={onNext} size="md" className="min-w-28">
|
||||
{isLast ? t("activity.seeResults") : t("activity.next")}
|
||||
{nextLabel ?? (isLast ? t("activity.seeResults") : t("activity.next"))}
|
||||
<span aria-hidden>→</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
33
components/homework/CopyButton.tsx
Normal file
33
components/homework/CopyButton.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui";
|
||||
import { copyText } from "@/lib/clipboard";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Copy-to-clipboard with a short “Copied!” state and a manual fallback note. */
|
||||
export function CopyButton({ text, className }: { text: string; className?: string }) {
|
||||
const { t } = useI18n();
|
||||
const [state, setState] = useState<"idle" | "copied" | "failed">("idle");
|
||||
|
||||
const copy = async () => {
|
||||
const ok = await copyText(text);
|
||||
setState(ok ? "copied" : "failed");
|
||||
window.setTimeout(() => setState("idle"), 2200);
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={cn("inline-flex flex-col items-start gap-1", className)}>
|
||||
<Button className="touch-target" onClick={() => void copy()}>
|
||||
{state === "copied" ? t("homework.result.copied") : t("homework.result.copy")}
|
||||
</Button>
|
||||
{state === "failed" ? (
|
||||
<span role="status" className="text-[12px] text-rose-700 dark:text-rose-300">
|
||||
{t("homework.result.copyFailed")}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
253
components/homework/HomeworkGuide.tsx
Normal file
253
components/homework/HomeworkGuide.tsx
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { ActivityRenderer } from "@/components/activities";
|
||||
import { PromptCard, QuestionText } from "@/components/activities/shared";
|
||||
import { XIcon } from "@/components/icons";
|
||||
import { Button, ProgressBar } from "@/components/ui";
|
||||
import { assembleSolution } from "@/lib/homework";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import type { HomeworkGuideRecord, HomeworkStep } from "@/lib/schemas";
|
||||
|
||||
import { CopyButton } from "./CopyButton";
|
||||
|
||||
/**
|
||||
* Mode 2: one step per screen in the same snap-feed as the study feed.
|
||||
* Each step explains what to do, reveals hints one at a time (nudge → hint →
|
||||
* the full answer), and can carry a checkpoint rendered by the shared activity
|
||||
* components. The last screen assembles the solution from the step answers.
|
||||
*/
|
||||
export function HomeworkGuide({
|
||||
record,
|
||||
onClose,
|
||||
onAgain,
|
||||
onNew,
|
||||
againLoading,
|
||||
}: {
|
||||
record: HomeworkGuideRecord;
|
||||
onClose: () => void;
|
||||
onAgain: () => void;
|
||||
onNew: () => void;
|
||||
againLoading: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const steps = record.steps;
|
||||
const slideCount = steps.length + 1; // + the solution slide
|
||||
const [index, setIndex] = useState(0);
|
||||
const [revealed, setRevealed] = useState<Record<number, number>>({});
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const goTo = useCallback(
|
||||
(target: number) => {
|
||||
const container = scrollRef.current;
|
||||
if (!container) return;
|
||||
const clamped = Math.max(0, Math.min(target, slideCount - 1));
|
||||
container.scrollTo({ top: clamped * container.clientHeight, behavior: "smooth" });
|
||||
},
|
||||
[slideCount],
|
||||
);
|
||||
|
||||
// Track the visible slide (wheel, touch and programmatic scroll).
|
||||
useEffect(() => {
|
||||
const container = scrollRef.current;
|
||||
if (!container) return;
|
||||
let frame = 0;
|
||||
const onScroll = () => {
|
||||
cancelAnimationFrame(frame);
|
||||
frame = requestAnimationFrame(() => {
|
||||
const height = container.clientHeight || 1;
|
||||
const current = Math.round(container.scrollTop / height);
|
||||
setIndex((previous) => (previous === current ? previous : current));
|
||||
});
|
||||
};
|
||||
container.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => {
|
||||
container.removeEventListener("scroll", onScroll);
|
||||
cancelAnimationFrame(frame);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Keyboard navigation, like the study feed.
|
||||
useEffect(() => {
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target && (/^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName) || target.isContentEditable)) return;
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
||||
if (event.key === "ArrowDown" || event.key === "PageDown" || event.key === "j") {
|
||||
event.preventDefault();
|
||||
goTo(index + 1);
|
||||
} else if (event.key === "ArrowUp" || event.key === "PageUp" || event.key === "k") {
|
||||
event.preventDefault();
|
||||
goTo(index - 1);
|
||||
} else if (event.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [goTo, index, onClose]);
|
||||
|
||||
const solution = useMemo(() => assembleSolution(record), [record]);
|
||||
const progressValue = Math.min(index + 1, slideCount);
|
||||
const onSolution = index >= steps.length;
|
||||
|
||||
const revealedCount = (stepIndex: number) => revealed[stepIndex] ?? 0;
|
||||
|
||||
const revealNext = (stepIndex: number, hints: string[]) =>
|
||||
setRevealed((current) => ({
|
||||
...current,
|
||||
[stepIndex]: Math.min((current[stepIndex] ?? 0) + 1, hints.length),
|
||||
}));
|
||||
|
||||
const hintButtonLabel = (step: HomeworkStep, stepIndex: number) => {
|
||||
const count = revealedCount(stepIndex);
|
||||
if (count === 0) return t("homework.guide.hint");
|
||||
if (count === step.hints.length - 1) return t("homework.guide.hintAnswer");
|
||||
return t("homework.guide.hintMore");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="frost-bg fixed inset-0 z-[45] flex flex-col">
|
||||
{/* chrome ------------------------------------------------------- */}
|
||||
<header className="pt-safe absolute inset-x-0 top-0 z-30 px-3">
|
||||
<div className="study-chrome mx-auto max-w-xl space-y-1.5 rounded-full px-3 py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t("homework.guide.close")}
|
||||
className="grid size-9 shrink-0 place-items-center rounded-xl bg-[var(--surface-sunken)] text-[var(--text)] transition hover:bg-[var(--hover)]"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<ProgressBar
|
||||
value={progressValue}
|
||||
max={slideCount}
|
||||
label={t("homework.guide.stepOf", { n: progressValue, total: slideCount })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 text-[11px] font-semibold text-[var(--text-muted)]">
|
||||
<span className="min-w-0 truncate">{record.title}</span>
|
||||
<span className="shrink-0">
|
||||
{onSolution ? t("homework.guide.solution") : t("homework.guide.stepOf", { n: index + 1, total: steps.length })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* slides ------------------------------------------------------- */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
data-testid="homework-guide"
|
||||
className="no-scrollbar h-full snap-y snap-mandatory overflow-y-scroll overscroll-y-contain [overflow-anchor:none]"
|
||||
>
|
||||
{steps.map((step, stepIndex) => (
|
||||
<section
|
||||
key={stepIndex}
|
||||
aria-label={t("homework.guide.stepOf", { n: stepIndex + 1, total: steps.length })}
|
||||
className="flex h-full snap-start snap-always items-stretch justify-center overflow-y-auto overscroll-y-contain"
|
||||
>
|
||||
<div className="mx-auto flex min-h-full w-full max-w-xl flex-col gap-4 px-4 pb-24 pt-24 [justify-content:safe_center]">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="tint-sky inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-semibold ring-1 ring-white/50 backdrop-blur-sm">
|
||||
{t("homework.guide.stepOf", { n: stepIndex + 1, total: steps.length })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<PromptCard>
|
||||
<QuestionText>{step.title}</QuestionText>
|
||||
<p className="mt-2 text-[15px] leading-relaxed text-frost-700 dark:text-frost-200">
|
||||
{step.explanation}
|
||||
</p>
|
||||
</PromptCard>
|
||||
|
||||
{/* progressive hints */}
|
||||
<div className="space-y-2">
|
||||
{step.hints.slice(0, revealedCount(stepIndex)).map((hint, hintIndex) => (
|
||||
<motion.div
|
||||
key={hintIndex}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="glass-chip rounded-2xl p-3 text-sm leading-relaxed text-[var(--text)]"
|
||||
>
|
||||
<span className="font-bold">{t("homework.guide.hintN", { n: hintIndex + 1 })}: </span>
|
||||
{hint}
|
||||
</motion.div>
|
||||
))}
|
||||
{revealedCount(stepIndex) < step.hints.length ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="touch-target"
|
||||
onClick={() => revealNext(stepIndex, step.hints)}
|
||||
>
|
||||
{hintButtonLabel(step, stepIndex)}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* checkpoint or a plain next button */}
|
||||
{step.checkpoint ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-[var(--text-subtle)]">
|
||||
{t("homework.guide.tryIt")}
|
||||
</p>
|
||||
<ActivityRenderer
|
||||
activity={step.checkpoint}
|
||||
active={index === stepIndex}
|
||||
language={record.language}
|
||||
isLast={stepIndex === steps.length - 1}
|
||||
nextLabel={
|
||||
stepIndex === steps.length - 1 ? t("homework.guide.showSolution") : t("homework.guide.next")
|
||||
}
|
||||
onAnswered={() => {
|
||||
/* checkpoints are for the student, not for scoring */
|
||||
}}
|
||||
onNext={() => goTo(stepIndex + 1)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Button className="touch-target self-start" onClick={() => goTo(stepIndex + 1)}>
|
||||
{stepIndex === steps.length - 1 ? t("homework.guide.showSolution") : t("homework.guide.next")}
|
||||
<span aria-hidden>→</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
{/* the assembled solution -------------------------------------- */}
|
||||
<section
|
||||
aria-label={t("homework.guide.solution")}
|
||||
className="h-full snap-start snap-always overflow-y-auto overscroll-y-contain"
|
||||
>
|
||||
<div className="mx-auto flex min-h-full w-full max-w-xl flex-col gap-4 px-4 pb-24 pt-24 [justify-content:safe_center]">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="tint-mint inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-semibold ring-1 ring-white/50 backdrop-blur-sm">
|
||||
{t("homework.guide.solution")}
|
||||
</span>
|
||||
</div>
|
||||
<PromptCard>
|
||||
<p className="whitespace-pre-wrap break-words text-[14.5px] leading-relaxed text-[var(--text)]">
|
||||
{solution}
|
||||
</p>
|
||||
</PromptCard>
|
||||
<p className="text-[12.5px] text-[var(--text-subtle)]">{t("homework.guide.solutionNote")}</p>
|
||||
<div className="flex flex-wrap items-start gap-2">
|
||||
<CopyButton text={solution} />
|
||||
<Button variant="secondary" className="touch-target" onClick={onAgain} loading={againLoading}>
|
||||
{t("homework.result.again")}
|
||||
</Button>
|
||||
<Button variant="ghost" className="touch-target" onClick={onNew}>
|
||||
{t("homework.result.new")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
89
components/homework/HomeworkHistory.tsx
Normal file
89
components/homework/HomeworkHistory.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { ChevronRightIcon, XIcon } from "@/components/icons";
|
||||
import { Button } from "@/components/ui";
|
||||
import { homeworkModeLabelKey } from "@/lib/homework";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import type { HomeworkRecord } from "@/lib/schemas";
|
||||
|
||||
/** Small history list under the input: open a past result or delete it. */
|
||||
export function HomeworkHistory({
|
||||
records,
|
||||
onOpen,
|
||||
onDelete,
|
||||
}: {
|
||||
records: HomeworkRecord[];
|
||||
onOpen: (record: HomeworkRecord) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}) {
|
||||
const { locale, t } = useI18n();
|
||||
const [confirmId, setConfirmId] = useState<string | null>(null);
|
||||
|
||||
if (records.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="space-y-2">
|
||||
<h2 className="text-[15px] font-semibold tracking-[-0.01em] text-[var(--text)]">
|
||||
{t("homework.history.title")}
|
||||
</h2>
|
||||
<ul className="list-group">
|
||||
{records.map((record) =>
|
||||
confirmId === record.id ? (
|
||||
<li key={record.id} className="flex flex-wrap items-center justify-between gap-2 px-3 py-2.5">
|
||||
<span className="text-[12.5px] font-medium text-[var(--text-muted)]">
|
||||
{t("homework.history.deleteConfirm")}
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
className="touch-target"
|
||||
onClick={() => {
|
||||
onDelete(record.id);
|
||||
setConfirmId(null);
|
||||
}}
|
||||
>
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="touch-target" onClick={() => setConfirmId(null)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</span>
|
||||
</li>
|
||||
) : (
|
||||
<li key={record.id} className="flex items-center gap-1 px-1.5 py-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(record)}
|
||||
aria-label={t("homework.history.openTitle", { title: record.title })}
|
||||
className="flex min-w-0 flex-1 items-center gap-3 rounded-xl px-2 py-1.5 text-left transition hover:bg-[var(--hover)]"
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[13.5px] font-medium text-[var(--text)]">{record.title}</span>
|
||||
<span className="block text-[11.5px] text-[var(--text-subtle)]">
|
||||
{t(homeworkModeLabelKey(record.mode))} ·{" "}
|
||||
{new Date(record.updatedAt).toLocaleDateString(locale === "cs" ? "cs-CZ" : "en-US", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronRightIcon className="size-4 shrink-0 text-[var(--text-subtle)]" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmId(record.id)}
|
||||
aria-label={`${t("common.delete")} — ${record.title}`}
|
||||
className="touch-target grid size-9 shrink-0 place-items-center rounded-lg text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-rose-600 dark:hover:text-rose-300"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</button>
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
339
components/homework/HomeworkInput.tsx
Normal file
339
components/homework/HomeworkInput.tsx
Normal file
|
|
@ -0,0 +1,339 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { CameraIcon, FolderIcon, PlayIcon, PlusIcon, SparklesIcon } from "@/components/icons";
|
||||
import { Button } from "@/components/ui";
|
||||
import {
|
||||
DOCUMENT_ACCEPT_ATTR,
|
||||
documentsToText,
|
||||
type ExtractedDocument,
|
||||
} from "@/lib/documents";
|
||||
import type { HomeworkMode, HomeworkRecord } from "@/lib/schemas";
|
||||
import { ACCEPTED_TYPES, formatBytes, imageFilesFromDataTransfer } from "@/lib/image";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import type { useNotesIntake } from "@/lib/use-notes-intake";
|
||||
import { openSettings } from "@/lib/ui-events";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { HomeworkHistory } from "./HomeworkHistory";
|
||||
|
||||
/** Same wording the create screen uses for an extracted document. */
|
||||
function describeExtraction(
|
||||
document: ExtractedDocument,
|
||||
tr: (key: string, vars?: Record<string, string | number>) => string,
|
||||
pl: (n: number, base: string, vars?: Record<string, string | number>) => string,
|
||||
): string {
|
||||
const parts = [
|
||||
pl(document.words, "upload.words", { n: document.words.toLocaleString() }),
|
||||
document.pages ? pl(document.pages, "upload.pages") : "",
|
||||
document.images.length ? pl(document.images.length, "upload.pageImages") : "",
|
||||
document.truncated ? tr("upload.truncated") : "",
|
||||
];
|
||||
return parts.filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
const MODES: Array<{ id: HomeworkMode; icon: React.ReactNode; titleKey: string; subtitleKey: string }> = [
|
||||
{ id: "make", icon: <SparklesIcon className="size-5" />, titleKey: "homework.mode.make.title", subtitleKey: "homework.mode.make.subtitle" },
|
||||
{ id: "guide", icon: <PlayIcon className="size-5" />, titleKey: "homework.mode.guide.title", subtitleKey: "homework.mode.guide.subtitle" },
|
||||
];
|
||||
|
||||
export function HomeworkInput({
|
||||
mode,
|
||||
onModeChange,
|
||||
text,
|
||||
onTextChange,
|
||||
intake,
|
||||
onSubmit,
|
||||
error,
|
||||
history,
|
||||
onOpen,
|
||||
onDelete,
|
||||
}: {
|
||||
mode: HomeworkMode;
|
||||
onModeChange: (mode: HomeworkMode) => void;
|
||||
text: string;
|
||||
onTextChange: (text: string) => void;
|
||||
intake: ReturnType<typeof useNotesIntake>;
|
||||
onSubmit: () => void;
|
||||
error: { message: string; kind?: string } | null;
|
||||
history: HomeworkRecord[];
|
||||
onOpen: (record: HomeworkRecord) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}) {
|
||||
const { t, plural } = useI18n();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const cameraInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Clipboard: screenshots become photos, long copied text becomes the assignment.
|
||||
useEffect(() => {
|
||||
const onPaste = (event: ClipboardEvent) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
const isTextField = target && /^(INPUT|TEXTAREA)$/.test(target.tagName);
|
||||
const files = imageFilesFromDataTransfer(event.clipboardData);
|
||||
if (files.length > 0) {
|
||||
event.preventDefault();
|
||||
void intake.addFiles(files);
|
||||
return;
|
||||
}
|
||||
if (!isTextField) {
|
||||
const pasted = event.clipboardData?.getData("text/plain")?.trim();
|
||||
if (pasted && pasted.length > 40) {
|
||||
event.preventDefault();
|
||||
onTextChange(text ? `${text}\n${pasted}` : pasted);
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener("paste", onPaste);
|
||||
return () => window.removeEventListener("paste", onPaste);
|
||||
}, [intake, onTextChange, text]);
|
||||
|
||||
const readyDocuments = intake.documents.filter((entry) => entry.status === "ready" && entry.extracted);
|
||||
const documentsBusy = intake.documents.some((entry) => entry.status === "extracting");
|
||||
const documentImages = readyDocuments.flatMap((entry) => entry.extracted!.images);
|
||||
const documentText = documentsToText(readyDocuments.map((entry) => entry.extracted!));
|
||||
const totalBytes = intake.images.reduce((sum, image) => sum + image.bytes, 0);
|
||||
|
||||
const hasContent =
|
||||
intake.images.length > 0 || documentImages.length > 0 || text.trim().length > 0 || documentText.trim().length > 0;
|
||||
const canSubmit = hasContent && !intake.busy && !documentsBusy;
|
||||
const keyError = error?.kind === "needs_key" || error?.kind === "auth";
|
||||
|
||||
return (
|
||||
<div className="relative space-y-6">
|
||||
<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>
|
||||
</h1>
|
||||
<p className="max-w-xl text-[15px] text-frost-600 dark:text-frost-300">{t("homework.subtitle")}</p>
|
||||
</section>
|
||||
|
||||
{error ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="animate-rise rounded-3xl border-2 border-rose-300 bg-rose-50/95 p-4 text-sm text-rose-900 dark:border-rose-400/40 dark:bg-rose-400/10 dark:text-rose-100"
|
||||
>
|
||||
<p className="font-bold">{keyError ? t("upload.errAuth") : t("homework.error.title")}</p>
|
||||
<p className="mt-1">{error.message || t("homework.error.body")}</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{keyError ? (
|
||||
<Button size="sm" onClick={openSettings}>
|
||||
{t("upload.openSettings")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button size="sm" variant={keyError ? "secondary" : "primary"} onClick={onSubmit}>
|
||||
{t("common.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* ------------------------------------------------ the assignment */}
|
||||
<section className="card p-4 sm:p-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-[15px] font-semibold tracking-[-0.01em] text-[var(--text)]">
|
||||
{t("homework.source.title")}
|
||||
</h2>
|
||||
<p className="text-[12.5px] text-[var(--text-subtle)]">{t("homework.source.help")}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="secondary" size="sm" className="touch-target" onClick={() => fileInputRef.current?.click()}>
|
||||
<FolderIcon className="size-4" /> {t("homework.source.add")}
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" className="touch-target" onClick={() => cameraInputRef.current?.click()}>
|
||||
<CameraIcon className="size-4" /> {t("upload.camera")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={`${ACCEPTED_TYPES.join(",")},${DOCUMENT_ACCEPT_ATTR}`}
|
||||
multiple
|
||||
className="sr-only"
|
||||
onChange={(event) => {
|
||||
void intake.addFiles(Array.from(event.target.files ?? []));
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={cameraInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
className="sr-only"
|
||||
onChange={(event) => {
|
||||
void intake.addFiles(Array.from(event.target.files ?? []));
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
|
||||
{intake.images.length === 0 && intake.documents.length === 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="mt-4 flex w-full flex-col items-center gap-2 rounded-2xl border-2 border-dashed border-white/70 bg-white/40 px-4 py-10 text-center backdrop-blur-xl transition hover:border-accent-300 hover:bg-white/65 dark:border-white/15 dark:bg-white/[0.05] dark:hover:bg-white/[0.09]"
|
||||
>
|
||||
<span aria-hidden className="text-frost-500 dark:text-frost-200">
|
||||
<PlusIcon className="size-10" />
|
||||
</span>
|
||||
<span className="text-[14px] font-medium text-[var(--text)]">{t("homework.source.drop")}</span>
|
||||
<span className="text-[12px] text-[var(--text-subtle)]">{t("homework.source.formats")}</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="mt-4 space-y-2">
|
||||
{intake.images.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{intake.images.map((image) => (
|
||||
<span key={image.id} className="relative">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- local thumbnail, no optimisation needed */}
|
||||
<img
|
||||
src={image.dataUrl}
|
||||
alt=""
|
||||
className="size-14 rounded-xl border border-[var(--hairline)] object-cover"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => intake.removeImage(image.id)}
|
||||
aria-label={t("upload.remove", { name: image.name })}
|
||||
className="absolute -right-1.5 -top-1.5 grid size-6 place-items-center rounded-full bg-frost-950/80 text-[11px] font-bold text-white"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{intake.documents.length > 0 ? (
|
||||
<ul className="space-y-2">
|
||||
{intake.documents.map((entry) => (
|
||||
<li
|
||||
key={entry.id}
|
||||
className="flex items-center gap-3 rounded-xl border border-[var(--hairline)] bg-[var(--surface-strong)] p-2.5"
|
||||
>
|
||||
<span className="rounded-md bg-[var(--surface-sunken)] px-1.5 py-0.5 text-[10px] font-bold uppercase text-[var(--text-muted)]">
|
||||
{entry.name.split(".").pop()?.slice(0, 4) ?? "doc"}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[13px] font-medium text-[var(--text)]">{entry.name}</span>
|
||||
<span className="block text-[11.5px] text-[var(--text-subtle)]">
|
||||
{entry.status === "extracting"
|
||||
? (entry.progress ?? t("upload.reading"))
|
||||
: entry.status === "error"
|
||||
? entry.error
|
||||
: entry.extracted
|
||||
? describeExtraction(entry.extracted, t, plural)
|
||||
: ""}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => intake.removeDocument(entry.id)}
|
||||
aria-label={t("upload.remove", { name: entry.name })}
|
||||
className="touch-target grid size-8 shrink-0 place-items-center rounded-lg text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label
|
||||
htmlFor="homework-assignment"
|
||||
className="mt-4 block text-[11px] font-semibold uppercase tracking-wider text-[var(--text-subtle)]"
|
||||
>
|
||||
{t("homework.source.textLabel")}
|
||||
</label>
|
||||
<textarea
|
||||
id="homework-assignment"
|
||||
value={text}
|
||||
onChange={(event) => onTextChange(event.target.value)}
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
placeholder={t("homework.source.placeholder")}
|
||||
rows={4}
|
||||
className="mt-1.5 w-full resize-y rounded-xl border border-[var(--hairline)] bg-[var(--surface-strong)] px-3 py-2.5 text-sm leading-relaxed text-[var(--text)] outline-none transition placeholder:text-[var(--text-subtle)] focus:border-accent-500"
|
||||
/>
|
||||
|
||||
{intake.images.length > 0 || documentImages.length > 0 ? (
|
||||
<p className="mt-2 text-[12px] text-[var(--text-subtle)]">
|
||||
{intake.images.length > 0
|
||||
? plural(intake.images.length, "upload.photoSummary", { size: formatBytes(totalBytes) })
|
||||
: ""}
|
||||
{intake.images.length > 0 && documentImages.length > 0 ? " · " : ""}
|
||||
{documentImages.length > 0 ? plural(documentImages.length, "upload.docImages") : ""}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{intake.busy ? (
|
||||
<p className="mt-2 text-[12.5px] font-medium text-[var(--text-muted)]">{t("upload.compressing")}</p>
|
||||
) : null}
|
||||
{intake.fileError ? (
|
||||
<p role="alert" className="mt-2 text-[12.5px] font-medium text-rose-700 dark:text-rose-300">
|
||||
{intake.fileError}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{/* ------------------------------------------------------- mode */}
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-[15px] font-semibold tracking-[-0.01em] text-[var(--text)]">{t("homework.mode.title")}</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{MODES.map((option) => {
|
||||
const selected = mode === option.id;
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
onClick={() => onModeChange(option.id)}
|
||||
className={cn(
|
||||
"flex min-h-24 items-start gap-3 rounded-2xl border p-4 text-left transition",
|
||||
selected
|
||||
? "border-accent-400 bg-accent-50/70 ring-2 ring-accent-400/40 dark:bg-accent-500/15"
|
||||
: "border-[var(--hairline)] bg-[var(--surface-strong)] hover:bg-[var(--hover)]",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"grid size-10 shrink-0 place-items-center rounded-xl",
|
||||
selected ? "grad-ice text-[#06111f]" : "bg-[var(--surface-sunken)] text-[var(--text-muted)]",
|
||||
)}
|
||||
>
|
||||
{option.icon}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-[15px] font-semibold leading-snug text-[var(--text)]">
|
||||
{t(option.titleKey)}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-[12.5px] leading-relaxed text-[var(--text-subtle)]">
|
||||
{t(option.subtitleKey)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* -------------------------------------------------------- CTA */}
|
||||
<div className="space-y-2">
|
||||
<Button size="lg" className="touch-target w-full" onClick={onSubmit} disabled={!canSubmit} loading={intake.busy}>
|
||||
{mode === "make" ? t("homework.start.make") : t("homework.start.guide")}
|
||||
</Button>
|
||||
<p className="text-center text-[12px] text-[var(--text-subtle)]">
|
||||
{canSubmit ? t("homework.source.ready") : t("homework.source.need")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<HomeworkHistory records={history} onOpen={onOpen} onDelete={onDelete} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
components/homework/HomeworkLoading.tsx
Normal file
51
components/homework/HomeworkLoading.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Button, ProgressBar, Spinner } from "@/components/ui";
|
||||
import type { HomeworkProgress } from "@/lib/client-api";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import type { HomeworkMode } from "@/lib/schemas";
|
||||
|
||||
/** Staged loading state while the assignment is read and answered. */
|
||||
export function HomeworkLoading({
|
||||
progress,
|
||||
mode,
|
||||
onCancel,
|
||||
}: {
|
||||
progress: HomeworkProgress;
|
||||
mode: HomeworkMode;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const vision = progress.stage === "vision";
|
||||
const keys = vision
|
||||
? ["homework.loading.reading", "homework.loading.vision2"]
|
||||
: mode === "make"
|
||||
? ["homework.loading.writing", "homework.loading.gen2", "homework.loading.gen3"]
|
||||
: ["homework.loading.planning", "homework.loading.gen2", "homework.loading.gen3"];
|
||||
const [tick, setTick] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => setTick((value) => value + 1), 2400);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const message = progress.message && tick === 0 ? progress.message : t(keys[tick % keys.length]);
|
||||
|
||||
return (
|
||||
<div className="animate-rise flex flex-col items-center gap-5 py-16 text-center sm:py-24">
|
||||
<Spinner className="size-8 text-accent-500" />
|
||||
<h1 className="text-[20px] font-semibold tracking-[-0.01em] text-[var(--text)]">{t("homework.loading.title")}</h1>
|
||||
<div className="w-full max-w-sm space-y-2">
|
||||
<ProgressBar value={vision ? 1 : 2} max={2} label={message} />
|
||||
<p className="text-[13.5px] font-medium text-[var(--text-muted)]" role="status" aria-live="polite">
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="touch-target" onClick={onCancel}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
components/homework/HomeworkResult.tsx
Normal file
51
components/homework/HomeworkResult.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import type { HomeworkMakeRecord } from "@/lib/schemas";
|
||||
|
||||
import { CopyButton } from "./CopyButton";
|
||||
|
||||
/** Mode 1 result: the finished homework, nothing else. */
|
||||
export function HomeworkResult({
|
||||
record,
|
||||
onAgain,
|
||||
onNew,
|
||||
againLoading,
|
||||
}: {
|
||||
record: HomeworkMakeRecord;
|
||||
onAgain: () => void;
|
||||
onNew: () => void;
|
||||
againLoading: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<header className="space-y-1">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-[var(--text-subtle)]">
|
||||
{t("homework.result.title")}
|
||||
</p>
|
||||
<h1 className="text-[22px] font-semibold leading-tight tracking-[-0.02em] text-[var(--text)] text-balance">
|
||||
{record.title}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<article className="card p-4 sm:p-5">
|
||||
<p className="whitespace-pre-wrap break-words text-[15px] leading-relaxed text-[var(--text)]">
|
||||
{record.answer}
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<div className="flex flex-wrap items-start gap-2">
|
||||
<CopyButton text={record.answer} />
|
||||
<Button variant="secondary" className="touch-target" onClick={onAgain} loading={againLoading}>
|
||||
{t("homework.result.again")}
|
||||
</Button>
|
||||
<Button variant="ghost" className="touch-target" onClick={onNew}>
|
||||
{t("homework.result.new")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
188
components/homework/HomeworkScreen.tsx
Normal file
188
components/homework/HomeworkScreen.tsx
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||
|
||||
import { documentsToText } from "@/lib/documents";
|
||||
import { ApiError, generateHomework, type HomeworkProgress } from "@/lib/client-api";
|
||||
import {
|
||||
deleteHomework,
|
||||
homeworkStore,
|
||||
saveHomework,
|
||||
sortHomework,
|
||||
updateHomework,
|
||||
type NewHomeworkResult,
|
||||
} from "@/lib/homework";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import type { HomeworkMode, HomeworkRecord } from "@/lib/schemas";
|
||||
import { useNotesIntake } from "@/lib/use-notes-intake";
|
||||
|
||||
import { HomeworkGuide } from "./HomeworkGuide";
|
||||
import { HomeworkInput } from "./HomeworkInput";
|
||||
import { HomeworkLoading } from "./HomeworkLoading";
|
||||
import { HomeworkResult } from "./HomeworkResult";
|
||||
|
||||
/** Photos + rendered PDF pages + images embedded in documents. */
|
||||
const MAX_IMAGES_TO_SEND = 12;
|
||||
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() {
|
||||
const { t } = useI18n();
|
||||
const records = useSyncExternalStore(
|
||||
homeworkStore.subscribe,
|
||||
homeworkStore.getSnapshot,
|
||||
homeworkStore.getServerSnapshot,
|
||||
);
|
||||
const history = useMemo(() => sortHomework(records), [records]);
|
||||
const intake = useNotesIntake({
|
||||
tooManyPhotosKey: "homework.source.tooManyPhotos",
|
||||
tooManyDocsKey: "homework.source.tooManyDocs",
|
||||
});
|
||||
|
||||
const [mode, setMode] = useState<HomeworkMode>("make");
|
||||
const [text, setText] = useState("");
|
||||
const [view, setView] = useState<View>({ kind: "start" });
|
||||
const [progress, setProgress] = useState<HomeworkProgress>({ stage: "vision", message: "" });
|
||||
const [error, setError] = useState<{ message: string; kind?: string } | null>(null);
|
||||
const [againLoading, setAgainLoading] = useState(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
/** Everything the API needs, built from the current input. */
|
||||
const buildPayload = () => {
|
||||
const readyDocuments = intake.documents.filter((entry) => entry.status === "ready" && entry.extracted);
|
||||
const documentImages = readyDocuments.flatMap((entry) => entry.extracted!.images);
|
||||
const documentText = documentsToText(readyDocuments.map((entry) => entry.extracted!));
|
||||
const mergedText = [text.trim(), documentText.trim()].filter(Boolean).join("\n\n").slice(0, MAX_TEXT_TO_SEND);
|
||||
const images = [
|
||||
...intake.images.map((image) => image.dataUrl),
|
||||
...documentImages.map((image) => image.dataUrl),
|
||||
].slice(0, MAX_IMAGES_TO_SEND);
|
||||
return { mode, images, text: mergedText };
|
||||
};
|
||||
|
||||
const run = useCallback(
|
||||
async (input: { mode: HomeworkMode; images: string[]; text: string; recordId?: string }) => {
|
||||
setError(null);
|
||||
setView({ kind: "loading" });
|
||||
const vision = input.images.length > 0;
|
||||
setProgress({
|
||||
stage: vision ? "vision" : "generation",
|
||||
message: vision
|
||||
? t("homework.loading.reading")
|
||||
: input.mode === "make"
|
||||
? t("homework.loading.writing")
|
||||
: t("homework.loading.planning"),
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
try {
|
||||
const { homework } = await generateHomework(
|
||||
{
|
||||
mode: input.mode,
|
||||
images: input.images.length > 0 ? input.images : undefined,
|
||||
text: input.text || undefined,
|
||||
},
|
||||
{ onProgress: setProgress, signal: controller.signal },
|
||||
);
|
||||
|
||||
// The server hands back the assignment as text (the transcription when
|
||||
// photos were used), so “Make it again” works without the files.
|
||||
const sourceText = homework.sourceText ?? input.text;
|
||||
const result: NewHomeworkResult =
|
||||
homework.mode === "make"
|
||||
? {
|
||||
mode: "make",
|
||||
title: homework.title,
|
||||
language: homework.language,
|
||||
sourceText,
|
||||
answer: homework.answer,
|
||||
}
|
||||
: {
|
||||
mode: "guide",
|
||||
title: homework.title,
|
||||
language: homework.language,
|
||||
sourceText,
|
||||
steps: homework.steps,
|
||||
};
|
||||
|
||||
const record = input.recordId ? updateHomework(input.recordId, result) : saveHomework(result);
|
||||
if (!record) throw new ApiError(t("homework.error.body"), "bad_response");
|
||||
setView({ kind: "record", record });
|
||||
} catch (generateError) {
|
||||
if (generateError instanceof ApiError && generateError.kind === "aborted") {
|
||||
setView({ kind: "start" });
|
||||
return;
|
||||
}
|
||||
setError({
|
||||
message: generateError instanceof ApiError ? generateError.message : t("homework.error.body"),
|
||||
kind: generateError instanceof ApiError ? generateError.kind : undefined,
|
||||
});
|
||||
setView({ kind: "start" });
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
setAgainLoading(false);
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const submit = () => {
|
||||
const payload = buildPayload();
|
||||
void run(payload);
|
||||
};
|
||||
|
||||
/** Re-run from the stored assignment text (files were never stored). */
|
||||
const again = (record: HomeworkRecord) => {
|
||||
setAgainLoading(true);
|
||||
void run({ mode: record.mode, images: [], text: record.sourceText, recordId: record.id });
|
||||
};
|
||||
|
||||
const newAssignment = () => {
|
||||
intake.clear();
|
||||
setText("");
|
||||
setError(null);
|
||||
setView({ kind: "start" });
|
||||
};
|
||||
|
||||
if (view.kind === "loading") {
|
||||
return <HomeworkLoading progress={progress} mode={mode} onCancel={() => abortRef.current?.abort()} />;
|
||||
}
|
||||
|
||||
if (view.kind === "record") {
|
||||
return view.record.mode === "make" ? (
|
||||
<HomeworkResult
|
||||
record={view.record}
|
||||
onAgain={() => again(view.record)}
|
||||
onNew={newAssignment}
|
||||
againLoading={againLoading}
|
||||
/>
|
||||
) : (
|
||||
<HomeworkGuide
|
||||
record={view.record}
|
||||
onClose={() => setView({ kind: "start" })}
|
||||
onAgain={() => again(view.record)}
|
||||
onNew={newAssignment}
|
||||
againLoading={againLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<HomeworkInput
|
||||
mode={mode}
|
||||
onModeChange={setMode}
|
||||
text={text}
|
||||
onTextChange={setText}
|
||||
intake={intake}
|
||||
onSubmit={submit}
|
||||
error={error}
|
||||
history={history}
|
||||
onOpen={(record) => setView({ kind: "record", record })}
|
||||
onDelete={deleteHomework}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue