Native-feel pass: PWA integration, system back, offline, haptics, undo
Mobile only (<=639px / coarse pointers); desktop keeps its behaviour. System integration - Manifest: id, display_override, monochrome icon, Make my homework shortcut and a share target (text/URL via GET, files via POST). iOS startup images for the common iPhone sizes. Pinch-zoom is allowed again (double-tap zoom is disabled via touch-action instead). - Share target: /share chooser with New deck / Make my homework; POSTed files wait in a 10-minute in-memory store, reach the wizard through sessionStorage and never touch disk. - Web Share: Share rows for decks (JSON file where supported) and the homework result, with a clipboard fallback and a toast. - Offline: a quiet pill appears and Freeze / mode cards are disabled with a clear reason; input is never lost (error cards keep Retry). - State: wizard drafts (text, options, photos up to a size budget) survive reloads and OS kills and are cleared on success; re-tapping the active tab scrolls to the top; the deck page shows a skeleton instead of a spinner. Navigation and gestures - System Back closes the topmost layer first: a small history-entry stack (lib/use-back-close.ts) covers the drawer, Settings sheet, install sheet, deck actions panel and one wizard step; navigations are never rewound and stale entries are skipped. - Destructive-but-reversible actions use Undo toasts: deleting a deck (no confirm) and removing a file in the wizard. Irreversible ones keep their confirm. Input and feedback - Every phone input got inputmode / enterkeyhint / autocomplete / autocapitalize / spellcheck; Enter on the last field advances the wizard; a clipboard-paste button appears where the API exists. - Haptics behind a Settings toggle (on by default): tab switches, toggles, correct/wrong, completion; Vibration API + the iOS hidden switch tick, throttled and silent everywhere else. - Wake lock during the study feed and homework guide, released on leave and re-acquired when the tab returns. Verified: new native suite 35/35 (manifest/head/viewport, offline, share target GET + POST, back-gesture layers, undo, share fallback, tab re-tap, haptics persistence, draft restore), regression 74/74, mobile 52/52, wizard 80/80, declutter 116/116, animation 8/8, i18n 25/25. Lint + build clean. SW freezmo-v23. Needs a real device (documented in the README): iOS haptic tick, system share sheet entry, wake-lock grant, iPhone startup images, iOS edge-swipe back, install prompts. Pull-to-refresh and the app badge were skipped on purpose.
This commit is contained in:
parent
e66141e9a0
commit
4369ba1a17
34 changed files with 1354 additions and 65 deletions
|
|
@ -7,13 +7,17 @@ import { useState } from "react";
|
|||
import { ApiError, generateDeck } from "@/lib/client-api";
|
||||
import { activityTypes, materialiseActivities } from "@/lib/deck-ops";
|
||||
import { createFolder as makeFolder, listFolders } from "@/lib/folders";
|
||||
import { haptic } from "@/lib/haptics";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { springGentle } from "@/lib/motion";
|
||||
import { UNORGANIZED_ID, type Deck } from "@/lib/schemas";
|
||||
import { shareDeck } from "@/lib/share";
|
||||
import { appendActivities, deleteDeck, downloadDeck, renameDeck, upsertDeck } from "@/lib/storage";
|
||||
import { showToast } from "@/lib/toast";
|
||||
import { useBackClose } from "@/lib/use-back-close";
|
||||
import { useIsPhone } from "@/lib/use-phone";
|
||||
import { cn, formatDate, truncate } from "@/lib/utils";
|
||||
import { ChevronDownIcon, DownloadIcon, EllipsisIcon, FolderIcon, PlayIcon, RefreshIcon } from "@/components/icons";
|
||||
import { ChevronDownIcon, DownloadIcon, EllipsisIcon, FolderIcon, PlayIcon, RefreshIcon, ShareIcon } from "@/components/icons";
|
||||
import { Button, ProgressBar } from "@/components/ui";
|
||||
|
||||
type Busy = null | "more" | "regenerate";
|
||||
|
|
@ -41,13 +45,15 @@ export function DeckCard({
|
|||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const [title, setTitle] = useState(deck.title);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [addCount, setAddCount] = useState(5);
|
||||
const [newFolder, setNewFolder] = useState<string | null>(null);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
/* System Back closes the actions panel first. */
|
||||
useBackClose(isPhone && menuOpen, () => setMenuOpen(false));
|
||||
|
||||
const hasSource = deck.sourceText.trim().length > 20;
|
||||
|
||||
const saveTitle = () => {
|
||||
|
|
@ -230,7 +236,10 @@ export function DeckCard({
|
|||
type="button"
|
||||
aria-label={t("deck.actions")}
|
||||
aria-expanded={menuOpen}
|
||||
onClick={() => setMenuOpen((value) => !value)}
|
||||
onClick={() => {
|
||||
haptic("tap");
|
||||
setMenuOpen((value) => !value);
|
||||
}}
|
||||
className={cn(
|
||||
"relative z-10 grid size-8 shrink-0 place-items-center rounded-full text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)] lg:-mt-1.5",
|
||||
menuOpen && "bg-[var(--hover)] text-[var(--text)]",
|
||||
|
|
@ -357,6 +366,18 @@ export function DeckCard({
|
|||
<DownloadIcon className="size-4 text-[var(--text-subtle)]" />
|
||||
{t("deck.export")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={menuRow}
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
void shareDeck(deck);
|
||||
}}
|
||||
disabled={Boolean(busy)}
|
||||
>
|
||||
<ShareIcon className="size-4 text-[var(--text-subtle)]" />
|
||||
{t("deck.share")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={menuRow}
|
||||
|
|
@ -367,23 +388,25 @@ export function DeckCard({
|
|||
{t("deck.regenerate")}
|
||||
</button>
|
||||
|
||||
{confirmDelete ? (
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<Button size="sm" variant="danger" onClick={() => deleteDeck(deck.id)}>
|
||||
{t("deck.reallyDelete")}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setConfirmDelete(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" className={cn(menuRow, "text-rose-600 dark:text-rose-300")} onClick={() => setConfirmDelete(true)}>
|
||||
<span aria-hidden className="w-4 text-center">
|
||||
⌫
|
||||
</span>
|
||||
{t("deck.delete")}
|
||||
</button>
|
||||
)}
|
||||
{/* Deleting is reversible: no confirm, just an Undo toast. */}
|
||||
<button
|
||||
type="button"
|
||||
className={cn(menuRow, "text-rose-600 dark:text-rose-300")}
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
deleteDeck(deck.id);
|
||||
showToast({
|
||||
message: t("toast.deckDeleted"),
|
||||
actionLabel: t("toast.undo"),
|
||||
onAction: () => upsertDeck(deck),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span aria-hidden className="w-4 text-center">
|
||||
⌫
|
||||
</span>
|
||||
{t("deck.delete")}
|
||||
</button>
|
||||
|
||||
{/* folder ---------------------------------------------------- */}
|
||||
<div className="mt-2 rounded-xl bg-[var(--surface-sunken)] p-2.5">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue