Fix the mode-choice redundancy: standardized names, deep links, compact switch
Terminology (lib/i18n-dict.ts) - One name per mode everywhere: "Study Deck" / "Studijní balíček" and "Homework Solver" / "Řešitel úkolů". The library empty-state cards and the /create switcher now read the same, so the choice no longer changes name between screens. Deep links - Library quick actions carry the mode: /create?mode=deck and /homework?mode=homework. - Both pages canonicalize: /create?mode=homework redirects to the homework flow and /homework?mode=deck redirects to /create, so a mode link always opens that mode directly. /create - The two bulky square cards (106px tall, wrapping/clipping "Study Deck & Qu…" on phones) are replaced by a compact segmented control (52px) placed directly above "1. Add your notes": ice pill, snowflake/sparkles icons, 44px segments and one-line labels at 360px. The pill slides to the chosen segment and the route follows ~190ms later, so toggling reads as one smooth transition (immediate under prefers-reduced-motion). Service worker: freezmo-v9. Verified: mode-switch suite 26/26 (deep links, cross-mode redirects, 52px control, 44px segments, no clipping/wrapping at 360px, "1. Add your notes" at y=394 of 740 with the upload controls above the fold, pill slide, axe contrast, en+cs, phone+desktop), mobile 49/49, i18n 27/27, desktop regression 73/73. Lint + build clean.
This commit is contained in:
parent
22af888ec0
commit
461dd2ffcf
7 changed files with 118 additions and 46 deletions
|
|
@ -1,3 +1,5 @@
|
|||
import { redirect } from "next/navigation";
|
||||
|
||||
import { ApiKeyGate } from "@/components/ApiKeyGate";
|
||||
import { AppHeader } from "@/components/AppHeader";
|
||||
import { MobileSidebar, Sidebar, SidebarOffset } from "@/components/Sidebar";
|
||||
|
|
@ -8,7 +10,16 @@ export const metadata = {
|
|||
title: "New deck",
|
||||
};
|
||||
|
||||
export default function CreatePage() {
|
||||
export default async function CreatePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
}) {
|
||||
const { mode } = await searchParams;
|
||||
// The library's quick actions (and the mode switch) deep-link into a mode;
|
||||
// a link that points at the other side is handed to the page that owns it.
|
||||
if (mode === "homework") redirect("/homework?mode=homework");
|
||||
|
||||
return (
|
||||
<ApiKeyGate>
|
||||
<div className="flex min-h-dvh">
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { redirect } from "next/navigation";
|
||||
|
||||
import { ApiKeyGate } from "@/components/ApiKeyGate";
|
||||
import { AppHeader } from "@/components/AppHeader";
|
||||
import { HomeworkScreen } from "@/components/homework/HomeworkScreen";
|
||||
|
|
@ -8,7 +10,16 @@ export const metadata = {
|
|||
title: "Make my homework",
|
||||
};
|
||||
|
||||
export default function HomeworkPage() {
|
||||
export default async function HomeworkPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
}) {
|
||||
const { mode } = 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");
|
||||
|
||||
return (
|
||||
<ApiKeyGate>
|
||||
<div className="flex min-h-dvh">
|
||||
|
|
|
|||
|
|
@ -228,7 +228,7 @@ export function LibraryScreen() {
|
|||
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"
|
||||
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"
|
||||
>
|
||||
<span
|
||||
|
|
@ -242,7 +242,7 @@ export function LibraryScreen() {
|
|||
</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/homework"
|
||||
href="/homework?mode=homework"
|
||||
className="card card-hover touch-target flex items-center gap-3 p-3.5 text-left sm:p-4"
|
||||
>
|
||||
<span
|
||||
|
|
|
|||
80
components/ModeSwitch.tsx
Normal file
80
components/ModeSwitch.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
"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";
|
||||
|
||||
|
|
@ -33,7 +32,8 @@ 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, SparklesIcon, SunIcon } from "@/components/icons";
|
||||
import { CameraIcon, FolderIcon, IceCubeIcon, PlusIcon, SnowflakeIcon, SunIcon } from "@/components/icons";
|
||||
import { ModeSwitch } from "@/components/ModeSwitch";
|
||||
import { Button, ProgressBar } from "@/components/ui";
|
||||
|
||||
type Phase = "idle" | "preparing" | "loading" | "error";
|
||||
|
|
@ -321,39 +321,9 @@ export function UploadScreen() {
|
|||
</p>
|
||||
</section>
|
||||
|
||||
{/* Mode switcher: this page builds a study deck from notes; the other
|
||||
card hands the same intake over to the homework flow. */}
|
||||
<div className="grid grid-cols-2 gap-2.5 sm:gap-3">
|
||||
<Link
|
||||
href="/create"
|
||||
aria-current="page"
|
||||
className="card touch-target flex flex-col items-start gap-2 rounded-2xl border-accent-400/60 bg-accent-50/60 p-3 ring-2 ring-accent-400/30 sm:flex-row sm:items-center sm:gap-3 sm:p-3.5 dark:bg-accent-500/15"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="grad-ice grid size-9 shrink-0 place-items-center rounded-xl text-[#06111f]"
|
||||
>
|
||||
<SnowflakeIcon className="size-5" />
|
||||
</span>
|
||||
<span className="min-w-0 text-[13px] font-semibold leading-snug text-[var(--text)] sm:text-[13.5px]">
|
||||
{t("actionStudyDeck")}
|
||||
</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/homework"
|
||||
className="card card-hover touch-target flex flex-col items-start gap-2 rounded-2xl p-3 sm:flex-row sm:items-center sm:gap-3 sm:p-3.5"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="grid size-9 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 text-[13px] font-semibold leading-snug text-[var(--text)] sm:text-[13.5px]">
|
||||
{t("actionHomework")}
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
{/* Mode switcher: a compact segmented control. This page is the Study
|
||||
Deck side; the other segment opens the homework flow. */}
|
||||
<ModeSwitch current="deck" />
|
||||
|
||||
|
||||
{error ? (
|
||||
|
|
|
|||
|
|
@ -224,9 +224,9 @@ const en: Record<string, string> = {
|
|||
"homework.history.deleteConfirm": "Delete this result?",
|
||||
|
||||
/* quick actions (library empty state + /create switcher) ----------- */
|
||||
"emptyStateCreateDeck": "Create interactive test",
|
||||
"emptyStateHomework": "Help me with my homework",
|
||||
"actionStudyDeck": "Study Deck & Quizzes",
|
||||
"emptyStateCreateDeck": "Study Deck",
|
||||
"emptyStateHomework": "Homework Solver",
|
||||
"actionStudyDeck": "Study Deck",
|
||||
"actionHomework": "Homework Solver",
|
||||
|
||||
/* library --------------------------------------------------------- */
|
||||
|
|
@ -808,9 +808,9 @@ const cs: Record<string, string> = {
|
|||
"homework.history.deleteConfirm": "Smazat tento výsledek?",
|
||||
|
||||
/* quick actions (library empty state + /create switcher) ----------- */
|
||||
"emptyStateCreateDeck": "Vytvořit interaktivní test",
|
||||
"emptyStateHomework": "Pomoct s úkolem",
|
||||
"actionStudyDeck": "Studijní balíček a testy",
|
||||
"emptyStateCreateDeck": "Studijní balíček",
|
||||
"emptyStateHomework": "Řešitel úkolů",
|
||||
"actionStudyDeck": "Studijní balíček",
|
||||
"actionHomework": "Řešitel úkolů",
|
||||
|
||||
/* library --------------------------------------------------------- */
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
*
|
||||
* Bumping CACHE_VERSION is how a new deploy invalidates old assets.
|
||||
*/
|
||||
const CACHE_VERSION = "freezmo-v8";
|
||||
const CACHE_VERSION = "freezmo-v9";
|
||||
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
|
||||
const ASSET_CACHE = `${CACHE_VERSION}-assets`;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue