Add the Install the app bottom sheet for mobile browsers

- New InstallSheet mounted in the root layout: appears ~1s after load on
  iOS/Android phones and tablets, over any screen including the key gate,
  with a dimmed backdrop. Never on desktop, never in standalone mode, and
  at most once per 7 days after a dismissal (localStorage).
- Platform/browser detection (lib/install.ts): iPhone/iPad incl. iPadOS
  reporting as Mac with touch, Android, Safari vs Chrome vs other vs
  in-app browsers, with step 1 adapted accordingly.
- iOS: numbered steps with chips (Safari, Share, ... , More, Add to Home
  Screen) and the accent Add pill. Android: captured beforeinstallprompt
  turns into a native Install button; fallback steps use Install app /
  Add to Home screen chips and an accent Install pill.
- Close via X, backdrop, Escape or swipe-down on the handle; the site is
  untouched afterwards. Anchored to 100dvh so browser toolbars never hide
  the last step; pb-safe respects the home indicator.
- Dark sheet by default; light only when the app theme was explicitly set
  to light. Settings gains a mobile-only Install app row that reopens it.
- The inline head script captures beforeinstallprompt before hydration and
  records appinstalled. Old InstallHint banner removed.
- Tests: new install-sheet suite (45 checks across iPhone Safari/Chrome/
  in-app, Android Chrome/Samsung, desktop, prompt path, dismissal, 7-day
  expiry, standalone, Settings row, both themes); pwa suite updated.
This commit is contained in:
Martin Dvořák 2026-10-07 08:27:43 +00:00
commit 26e36bc0c0
8 changed files with 632 additions and 79 deletions

View file

@ -395,13 +395,18 @@ Decks are organised into folders — the same way you'd file paper notes.
Freezmo ships as an installable Progressive Web App with iOS in mind. Freezmo ships as an installable Progressive Web App with iOS in mind.
**iOS / iPadOS (Safari):** open the app → **Share** → **Add to Home Screen**. The app shows a Phones and tablets get an **"Install the app" bottom sheet** about a second after load (never on
one-time hint explaining exactly this. It launches full-screen in standalone mode (no browser desktop, never when already installed, and at most once per 7 days after a dismissal). It is a
chrome), respects the notch/home-indicator safe areas, and has an iOS-style bottom tab bar native-feeling sheet with the Freezmo card, numbered steps and chips for the exact buttons to tap,
(Library · New deck · Settings) with sheet-style dialogs. plus an accent confirmation pill. Close it with the X, the backdrop, Escape, or a swipe down — the
site keeps working normally. On iOS the steps adapt to the browser (Safari, Chrome, in-app), and
on Android Chrome a captured `beforeinstallprompt` turns into a real **Install** button. The sheet
can be reopened any time from **Settings → Install app** (mobile only).
**Android (Chrome):** Chrome offers "Install app" from the ⋮ menu; Freezmo also works offline for It launches full-screen in standalone mode (no browser chrome), respects the notch/home-indicator
the parts that don't need a model. safe areas, and has an iOS-style bottom tab bar (Library · New deck · Settings) with sheet-style
dialogs. Android Chrome also offers "Install app" from the ⋮ menu; Freezmo works offline for the
parts that don't need a model.
What's included: What's included:
@ -430,8 +435,9 @@ self-hosted study app with no accounts, no database and no server-side state.
production `.next` output for a test key, and by restarting the server (which remembers nothing). production `.next` output for a test key, and by restarting the server (which remembers nothing).
- **No server-side injection surface.** The Ollama endpoint and model names come only from the - **No server-side injection surface.** The Ollama endpoint and model names come only from the
server's `.env`; the client cannot point the server at another host. Model output is validated with server's `.env`; the client cannot point the server at another host. Model output is validated with
zod and rendered as text — there is no `dangerouslySetInnerHTML` except one static theme script, zod and rendered as text — there is no `dangerouslySetInnerHTML` except two static inline scripts
so a deck cannot inject markup or script (React escapes everything). (the pre-paint theme choice and the `beforeinstallprompt` capture), so a deck cannot inject markup
or script (React escapes everything).
- **Key-header sanitisation.** Keys from the client are stripped of control characters/whitespace - **Key-header sanitisation.** Keys from the client are stripped of control characters/whitespace
and capped at 512 chars before they are used in an outbound header, so a crafted key cannot inject and capped at 512 chars before they are used in an outbound header, so a crafted key cannot inject
headers. Verified with a raw-socket CRLF attempt. headers. Verified with a raw-socket CRLF attempt.
@ -540,7 +546,7 @@ components/
Flashcard.tsx MultipleChoice.tsx TrueFalse.tsx FillBlank.tsx Flashcard.tsx MultipleChoice.tsx TrueFalse.tsx FillBlank.tsx
Matching.tsx Ordering.tsx ShortAnswer.tsx shared.tsx index.tsx Matching.tsx Ordering.tsx ShortAnswer.tsx shared.tsx index.tsx
UploadScreen.tsx StudyFeed.tsx ResultsScreen.tsx DeckCard.tsx ApiKeyGate.tsx UploadScreen.tsx StudyFeed.tsx ResultsScreen.tsx DeckCard.tsx ApiKeyGate.tsx
TabBar.tsx InstallHint.tsx ServiceWorkerRegistrar.tsx FolderBar.tsx InstallSheet.tsx TabBar.tsx ServiceWorkerRegistrar.tsx FolderBar.tsx
LibraryScreen.tsx SettingsDialog.tsx AppHeader.tsx ThemeToggle.tsx icons.tsx ui.tsx LibraryScreen.tsx SettingsDialog.tsx AppHeader.tsx ThemeToggle.tsx icons.tsx ui.tsx
lib/ lib/
ollama.ts server-only HTTP client (timeouts, auth, error mapping) ollama.ts server-only HTTP client (timeouts, auth, error mapping)
@ -550,6 +556,7 @@ lib/
config.ts env config, cloud/direct model-name normalisation config.ts env config, cloud/direct model-name normalisation
documents.ts client-side PDF/DOCX/PPTX/ODT/text extraction documents.ts client-side PDF/DOCX/PPTX/ODT/text extraction
folders.ts folder library (create/rename/delete) in localStorage folders.ts folder library (create/rename/delete) in localStorage
install.ts install-prompt platform/browser detection + 7-day dismissal
settings.ts browser-side settings (the user's API key) + auth headers settings.ts browser-side settings (the user's API key) + auth headers
use-health.ts client hook for /api/health, re-runs when the key changes use-health.ts client hook for /api/health, re-runs when the key changes
rate-limit.ts in-memory sliding-window limiter for the API routes rate-limit.ts in-memory sliding-window limiter for the API routes
@ -760,6 +767,12 @@ Things I chose while building, with the reasoning:
`/api/health`, so an invalid key and an unreachable Ollama are worded differently. The old `/api/health`, so an invalid key and an unreachable Ollama are worded differently. The old
dashboard banner is gone; key management stays in Settings, and removing the key brings the dashboard banner is gone; key management stays in Settings, and removing the key brings the
gate back. The gate covers `/`, `/create` and deck study, but never `/offline`. gate back. The gate covers `/`, `/create` and deck study, but never `/offline`.
29. **The install prompt is a teaching sheet, not a nag.** It appears once, phones/tablets only,
a second after load, and never in standalone mode; dismissing it silences it for 7 days. It
detects iOS vs Android and Safari vs Chrome vs an in-app browser so step 1 always matches the
device, uses Chrome's deferred `beforeinstallprompt` for a one-tap install where available, and
stays reachable from Settings. It is mounted in the root layout, so it can appear over any
screen — including the key gate — without blocking the page underneath.
14. **`gpt-oss:120b` does all content work — but it cannot read images** (`this model does not 14. **`gpt-oss:120b` does all content work — but it cannot read images** (`this model does not
support image input`), so `VISION_MODEL` has to stay a multimodal model (`gemma4:31b-cloud`). support image input`), so `VISION_MODEL` has to stay a multimodal model (`gemma4:31b-cloud`).
Text-only inputs — pasted text, PDFs with a text layer, DOCX/PPTX/ODT — never touch the vision Text-only inputs — pasted text, PDFs with a text layer, DOCX/PPTX/ODT — never touch the vision

View file

@ -1,5 +1,6 @@
import type { Metadata, Viewport } from "next"; import type { Metadata, Viewport } from "next";
import { InstallSheet } from "@/components/InstallSheet";
import { ServiceWorkerRegistrar } from "@/components/ServiceWorkerRegistrar"; import { ServiceWorkerRegistrar } from "@/components/ServiceWorkerRegistrar";
import "./globals.css"; import "./globals.css";
@ -51,15 +52,26 @@ export const viewport: Viewport = {
/** Applies the saved theme before paint to avoid a flash. */ /** Applies the saved theme before paint to avoid a flash. */
const themeScript = `(function(){try{var s=localStorage.getItem('freezmo.theme');var d=s?s==='dark':window.matchMedia('(prefers-color-scheme: dark)').matches;if(d)document.documentElement.classList.add('dark');}catch(e){}})();`; const themeScript = `(function(){try{var s=localStorage.getItem('freezmo.theme');var d=s?s==='dark':window.matchMedia('(prefers-color-scheme: dark)').matches;if(d)document.documentElement.classList.add('dark');}catch(e){}})();`;
/**
* Captures Chrome's `beforeinstallprompt` (it can fire before React hydrates)
* and records an install so the prompt never comes back. Static, no user data.
*/
const installScript = `(function(){try{
window.addEventListener('beforeinstallprompt',function(e){e.preventDefault();window.__freezmoInstallPrompt=e;window.dispatchEvent(new Event('freezmo:installprompt'));});
window.addEventListener('appinstalled',function(){try{localStorage.setItem('freezmo.install.installed','1')}catch(e){}window.dispatchEvent(new Event('freezmo:installed'));});
}catch(e){}})();`;
export default function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="en" suppressHydrationWarning> <html lang="en" suppressHydrationWarning>
<head> <head>
<script dangerouslySetInnerHTML={{ __html: themeScript }} /> <script dangerouslySetInnerHTML={{ __html: themeScript }} />
<script dangerouslySetInnerHTML={{ __html: installScript }} />
</head> </head>
<body className="min-h-full antialiased"> <body className="min-h-full antialiased">
<div className="app-backdrop" aria-hidden /> <div className="app-backdrop" aria-hidden />
{children} {children}
<InstallSheet />
<ServiceWorkerRegistrar /> <ServiceWorkerRegistrar />
</body> </body>
</html> </html>

View file

@ -1,6 +1,5 @@
import { ApiKeyGate } from "@/components/ApiKeyGate"; import { ApiKeyGate } from "@/components/ApiKeyGate";
import { AppHeader } from "@/components/AppHeader"; import { AppHeader } from "@/components/AppHeader";
import { InstallHint } from "@/components/InstallHint";
import { LibraryScreen } from "@/components/LibraryScreen"; import { LibraryScreen } from "@/components/LibraryScreen";
import { MobileSidebar, Sidebar, SidebarOffset } from "@/components/Sidebar"; import { MobileSidebar, Sidebar, SidebarOffset } from "@/components/Sidebar";
import { TabBar } from "@/components/TabBar"; import { TabBar } from "@/components/TabBar";
@ -15,9 +14,6 @@ export default function HomePage() {
<main className="min-w-0 flex-1"> <main className="min-w-0 flex-1">
<AppHeader /> <AppHeader />
<div className="mx-auto w-full max-w-6xl px-4 pb-tabbar lg:px-10 lg:pb-16"> <div className="mx-auto w-full max-w-6xl px-4 pb-tabbar lg:px-10 lg:pb-16">
<div className="mb-6">
<InstallHint />
</div>
<LibraryScreen /> <LibraryScreen />
</div> </div>
<TabBar /> <TabBar />

View file

@ -1,64 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { ShareIcon } from "@/components/icons";
const DISMISS_KEY = "freezmo.installHintDismissed";
/**
* iOS has no `beforeinstallprompt`, so the only way to explain installing the
* PWA is to point at the Share sheet. Shown once, only in iOS Safari, only
* outside standalone mode.
*/
export function InstallHint() {
const [visible, setVisible] = useState(false);
useEffect(() => {
try {
if (window.localStorage.getItem(DISMISS_KEY) === "1") return;
const ua = window.navigator.userAgent;
const isIos = /iPad|iPhone|iPod/.test(ua) || (ua.includes("Macintosh") && "ontouchend" in document);
const isSafari = /Safari/.test(ua) && !/CriOS|FxiOS|EdgiOS|OPiOS/.test(ua);
const standalone =
window.matchMedia("(display-mode: standalone)").matches ||
(window.navigator as unknown as { standalone?: boolean }).standalone === true;
if (isIos && isSafari && !standalone) setVisible(true);
} catch {
/* private mode */
}
}, []);
if (!visible) return null;
const dismiss = () => {
setVisible(false);
try {
window.localStorage.setItem(DISMISS_KEY, "1");
} catch {
/* ignore */
}
};
return (
<div className="glass-panel animate-rise flex items-start gap-3 rounded-[1.35rem] p-3.5 text-sm">
<ShareIcon className="mt-0.5 size-5 shrink-0 text-accent-600 dark:text-accent-300" />
<div className="flex-1">
<p className="font-semibold text-frost-950 dark:text-frost-50">Install Freezmo</p>
<p className="text-frost-600 dark:text-frost-300">
Tap <span className="font-medium">Share</span> then{" "}
<span className="font-medium">Add to Home Screen</span> for a full-screen, app-like
experience that works without the browser chrome.
</p>
</div>
<button
type="button"
onClick={dismiss}
aria-label="Dismiss install hint"
className="rounded-lg px-2 py-1 text-frost-400 transition-colors hover:bg-[var(--surface-sunken)]"
>
✕
</button>
</div>
);
}

348
components/InstallSheet.tsx Normal file
View file

@ -0,0 +1,348 @@
"use client";
import { AnimatePresence, motion, useDragControls, useReducedMotion } from "framer-motion";
import { useCallback, useEffect, useState } from "react";
import {
AddToHomeIcon,
ChromeIcon,
CompassIcon,
DownloadIcon,
EllipsisIcon,
MoreIcon,
PlusIcon,
ShareIcon,
SnowflakeIcon,
XIcon,
} from "@/components/icons";
import { Spinner } from "@/components/ui";
import {
detectBrowser,
detectPlatform,
dismissInstallPrompt,
INSTALL_PROMPT_EVENT,
INSTALLED_EVENT,
installSheetTheme,
isDismissed,
isInstalled,
isStandalone,
markInstalled,
OPEN_INSTALL_EVENT,
type BeforeInstallPromptEvent,
type InstallBrowser,
type InstallPlatform,
} from "@/lib/install";
import { cn } from "@/lib/utils";
type SheetInfo = {
platform: InstallPlatform;
browser: InstallBrowser;
theme: "dark" | "light";
};
/**
* "Install the app" bottom sheet. Mounted once in the root layout so it can
* appear over any screen (including the API-key gate) about a second after
* load. Phones/tablets only, never when already installed, and at most once
* per 7 days after a dismissal. Closing it leaves the site exactly as it was.
*/
export function InstallSheet() {
const [info, setInfo] = useState<SheetInfo | null>(null);
const [open, setOpen] = useState(false);
const [promptEvent, setPromptEvent] = useState<BeforeInstallPromptEvent | null>(null);
const [installing, setInstalling] = useState(false);
const dragControls = useDragControls();
const reduceMotion = useReducedMotion();
const domain = typeof window === "undefined" ? "" : window.location.host;
/** Everything needed to render the sheet, or null if it shouldn't appear. */
const prepare = useCallback((): SheetInfo | null => {
const platform = detectPlatform();
if (platform === "other" || isInstalled()) return null;
return { platform, browser: detectBrowser(platform), theme: installSheetTheme() };
}, []);
// Automatic prompt: about a second after load, unless dismissed recently.
useEffect(() => {
if (isStandalone()) {
markInstalled();
return;
}
const next = prepare();
if (!next || isDismissed()) return;
const timer = window.setTimeout(() => {
setInfo(next);
setOpen(true);
}, 1000);
return () => window.clearTimeout(timer);
}, [prepare]);
// Manual open from the Settings row (ignores the 7-day window).
useEffect(() => {
const onOpen = () => {
const next = prepare();
if (!next) return;
setInfo(next);
setOpen(true);
};
window.addEventListener(OPEN_INSTALL_EVENT, onOpen);
return () => window.removeEventListener(OPEN_INSTALL_EVENT, onOpen);
}, [prepare]);
// Chrome's deferred install prompt + the "installed" signal.
useEffect(() => {
const sync = () => setPromptEvent(window.__freezmoInstallPrompt ?? null);
const onInstalled = () => {
markInstalled();
setPromptEvent(null);
setInstalling(false);
setOpen(false);
};
window.addEventListener(INSTALL_PROMPT_EVENT, sync);
window.addEventListener(INSTALLED_EVENT, onInstalled);
window.addEventListener("appinstalled", onInstalled);
sync();
return () => {
window.removeEventListener(INSTALL_PROMPT_EVENT, sync);
window.removeEventListener(INSTALLED_EVENT, onInstalled);
window.removeEventListener("appinstalled", onInstalled);
};
}, []);
const close = useCallback(() => {
dismissInstallPrompt();
setOpen(false);
setInstalling(false);
}, []);
// Escape closes, like every other sheet in the app.
useEffect(() => {
if (!open) return;
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") close();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, close]);
const runPrompt = async () => {
if (!promptEvent || installing) return;
setInstalling(true);
try {
await promptEvent.prompt();
const choice = await promptEvent.userChoice;
if (choice.outcome === "accepted") {
markInstalled();
setOpen(false);
setInstalling(false);
return;
}
} catch {
/* the native prompt can fail; the steps below still work */
}
setInstalling(false);
};
const steps: React.ReactNode[] = [];
if (info?.platform === "ios") {
if (info.browser !== "safari") {
steps.push(
<>
Open this page in <Chip icon={<CompassIcon className="size-3.5" />}>Safari</Chip>.
</>,
);
}
steps.push(
<>
Tap <Chip icon={<ShareIcon className="size-3.5" />}>Share</Chip> in the browser bar — in
some browsers it&rsquo;s under <Chip icon={<EllipsisIcon className="size-3.5" />}>⋯</Chip>.
</>,
);
steps.push(
<>
Tap <Chip icon={<MoreIcon className="size-3.5" />}>More</Chip> in the share sheet.
</>,
);
steps.push(
<>
Choose <Chip icon={<AddToHomeIcon className="size-3.5" />}>Add to Home Screen</Chip>.
</>,
);
steps.push(
<>
Tap <AccentPill icon={<PlusIcon className="size-3.5" />}>Add</AccentPill> to confirm.
</>,
);
} else if (info?.platform === "android") {
if (info.browser !== "chrome") {
steps.push(
<>
Open this page in <Chip icon={<ChromeIcon className="size-3.5" />}>Chrome</Chip>.
</>,
);
}
steps.push(
<>
Tap <Chip icon={<EllipsisIcon className="size-3.5" />}>⋯</Chip> in the top right of the
browser.
</>,
);
steps.push(
<>
Choose <Chip icon={<DownloadIcon className="size-3.5" />}>Install app</Chip> or{" "}
<Chip icon={<AddToHomeIcon className="size-3.5" />}>Add to Home screen</Chip>.
</>,
);
steps.push(
<>
Tap <AccentPill icon={<DownloadIcon className="size-3.5" />}>Install</AccentPill> to confirm.
</>,
);
}
const showNativePrompt = info?.platform === "android" && Boolean(promptEvent);
return (
// The sheet is dark by default; only an explicit light choice in the app
// switches the palette (the wrapper scopes the app's .dark tokens).
<div className={info?.theme === "light" ? undefined : "dark"}>
<AnimatePresence>
{open && info ? (
<div className="fixed inset-x-0 top-0 z-[60] h-dvh">
<motion.div
aria-hidden
onClick={close}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.25 }}
className="absolute inset-0 bg-black/45 backdrop-blur-[2px]"
/>
<motion.div
role="dialog"
aria-modal="true"
aria-labelledby="install-sheet-title"
drag="y"
dragControls={dragControls}
dragListener={false}
dragConstraints={{ top: 0, bottom: 0 }}
dragElastic={{ top: 0, bottom: 0.55 }}
onDragEnd={(_, gesture) => {
if (gesture.offset.y > 90 || gesture.delta.y > 140 || gesture.velocity.y > 700) close();
}}
initial={reduceMotion ? { opacity: 0 } : { y: "100%" }}
animate={reduceMotion ? { opacity: 1 } : { y: 0 }}
exit={reduceMotion ? { opacity: 0 } : { y: "100%" }}
transition={{ duration: 0.34, ease: [0.22, 1, 0.36, 1] }}
className="absolute inset-x-0 bottom-0 mx-auto w-full rounded-t-[28px] border border-b-0 border-[var(--hairline)] bg-[var(--surface)] shadow-[0_-20px_60px_-28px_rgb(0_0_0/0.5)] sm:max-w-xl"
>
<div className="thin-scroll max-h-[88dvh] overflow-y-auto px-5 pb-safe pt-2">
<div
aria-hidden
onPointerDown={(event) => dragControls.start(event)}
className="mx-auto flex h-6 w-full cursor-grab touch-none items-center justify-center active:cursor-grabbing"
>
<span className="h-1.5 w-10 rounded-full bg-[var(--text-subtle)] opacity-40" />
</div>
<div className="flex items-center justify-between gap-4">
<h2
id="install-sheet-title"
className="text-[17px] font-semibold tracking-[-0.01em] text-[var(--text)]"
>
{info.platform === "ios"
? "Install Freezmo on iPhone"
: "Install Freezmo on Android"}
</h2>
<button
type="button"
onClick={close}
aria-label="Close"
className="grid size-10 shrink-0 place-items-center rounded-full text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
>
<XIcon className="size-[18px]" />
</button>
</div>
{/* app card */}
<div className="mt-1 flex items-center gap-3 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
<span
aria-hidden
className="grid size-11 shrink-0 place-items-center rounded-xl bg-[var(--text)] text-[var(--page)]"
>
<SnowflakeIcon className="size-5" />
</span>
<span className="min-w-0">
<span className="block text-[15px] font-semibold leading-tight text-[var(--text)]">
Freezmo
</span>
<span className="block truncate text-[12.5px] text-[var(--text-subtle)]">
{domain}
</span>
</span>
</div>
{showNativePrompt ? (
<button
type="button"
onClick={() => void runPrompt()}
disabled={installing}
className="btn-primary mt-4 h-12 w-full rounded-[14px] text-[15px] font-medium disabled:cursor-not-allowed disabled:opacity-60"
>
{installing ? (
<>
<Spinner className="size-4" /> Installing…
</>
) : (
<>
<DownloadIcon className="size-4" /> Install
</>
)}
</button>
) : null}
{showNativePrompt ? (
<p className="mt-4 text-center text-[11.5px] text-[var(--text-subtle)]">
Or install manually:
</p>
) : null}
<ol className={cn("space-y-3", showNativePrompt ? "mt-3" : "mt-4")}>
{steps.map((step, index) => (
<li
key={index}
className="flex items-start gap-2.5 text-[13.5px] leading-relaxed text-[var(--text-muted)]"
>
<span className="font-bold tabular-nums text-[var(--text)]">{index + 1}.</span>
<span className="min-w-0 flex-1">{step}</span>
</li>
))}
</ol>
</div>
</motion.div>
</div>
) : null}
</AnimatePresence>
</div>
);
}
/** Small pill for a UI element the user has to tap. */
function Chip({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) {
return (
<span className="mx-0.5 inline-flex translate-y-[1px] items-center gap-1 rounded-full border border-[var(--field-border)] bg-[var(--field)] px-2 py-[3px] text-[12px] font-semibold leading-none text-[var(--text)]">
<span aria-hidden>{icon}</span>
{children}
</span>
);
}
/** The one coloured element: the final confirmation button. */
function AccentPill({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) {
return (
<span className="mx-0.5 inline-flex translate-y-[1px] items-center gap-1 rounded-full bg-accent-600 px-2.5 py-[3px] text-[12px] font-semibold leading-none text-white">
<span aria-hidden>{icon}</span>
{children}
</span>
);
}

View file

@ -4,13 +4,15 @@ import { AnimatePresence, motion } from "framer-motion";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { openInstallSheet, useInstallAvailable } from "@/lib/install";
import { clearApiKey, saveSettings } from "@/lib/settings"; import { clearApiKey, saveSettings } from "@/lib/settings";
import { useHealth } from "@/lib/use-health"; import { useHealth } from "@/lib/use-health";
import { AlertTriangleIcon, CheckCircleIcon, XCircleIcon } from "@/components/icons"; import { AlertTriangleIcon, CheckCircleIcon, ChevronRightIcon, DownloadIcon, XCircleIcon } from "@/components/icons";
import { Button, Spinner } from "@/components/ui"; import { Button, Spinner } from "@/components/ui";
export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () => void }) { export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
const { health, loading, error, refresh, apiKey } = useHealth(); const { health, loading, error, refresh, apiKey } = useHealth();
const installAvailable = useInstallAvailable();
const [keyDraft, setKeyDraft] = useState(apiKey); const [keyDraft, setKeyDraft] = useState(apiKey);
const [reveal, setReveal] = useState(false); const [reveal, setReveal] = useState(false);
const [saved, setSaved] = useState(false); const [saved, setSaved] = useState(false);
@ -145,6 +147,36 @@ export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () =
</div> </div>
</section> </section>
{/* ------------------------------------------------ install */}
{installAvailable ? (
<section className="mb-4 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-1.5">
<button
type="button"
onClick={() => {
onClose();
openInstallSheet();
}}
className="flex w-full items-center gap-3 rounded-xl px-2.5 py-2 text-left transition hover:bg-[var(--hover)]"
>
<span
aria-hidden
className="grid size-9 shrink-0 place-items-center rounded-xl border border-[var(--hairline)] bg-[var(--surface)] text-[var(--text-muted)]"
>
<DownloadIcon className="size-4" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-semibold text-frost-950 dark:text-frost-50">
Install app
</span>
<span className="block text-[12px] text-[var(--text-subtle)]">
Add Freezmo to your home screen
</span>
</span>
<ChevronRightIcon className="size-4 shrink-0 text-[var(--text-subtle)]" />
</button>
</section>
) : null}
{loading && !health ? ( {loading && !health ? (
<div className="flex items-center gap-3 py-6 text-sm text-slate-600 dark:text-slate-300"> <div className="flex items-center gap-3 py-6 text-sm text-slate-600 dark:text-slate-300">
<Spinner className="size-5" /> Checking Ollama… <Spinner className="size-5" /> Checking Ollama…

View file

@ -285,3 +285,54 @@ export function ExternalLinkIcon({ className }: IconProps) {
</Svg> </Svg>
); );
} }
/* --- Install-sheet chips ------------------------------------------------ */
export function CompassIcon({ className }: IconProps) {
return (
<Svg className={className}>
<circle cx="12" cy="12" r="9" />
<path d="m15.6 8.4-2.3 5.3-5.3 2.3 2.3-5.3 5.3-2.3Z" />
</Svg>
);
}
export function EllipsisIcon({ className }: IconProps) {
return (
<Svg className={className}>
<circle cx="5.5" cy="12" r="1.4" fill="currentColor" stroke="none" />
<circle cx="12" cy="12" r="1.4" fill="currentColor" stroke="none" />
<circle cx="18.5" cy="12" r="1.4" fill="currentColor" stroke="none" />
</Svg>
);
}
export function MoreIcon({ className }: IconProps) {
return (
<Svg className={className}>
<rect x="3.5" y="3.5" width="17" height="17" rx="5" />
<circle cx="8.4" cy="12" r="1.2" fill="currentColor" stroke="none" />
<circle cx="12" cy="12" r="1.2" fill="currentColor" stroke="none" />
<circle cx="15.6" cy="12" r="1.2" fill="currentColor" stroke="none" />
</Svg>
);
}
export function AddToHomeIcon({ className }: IconProps) {
return (
<Svg className={className}>
<rect x="4" y="4" width="16" height="16" rx="4.5" />
<path d="M12 8.8v6.4M8.8 12h6.4" />
</Svg>
);
}
export function ChromeIcon({ className }: IconProps) {
return (
<Svg className={className}>
<circle cx="12" cy="12" r="9" />
<circle cx="12" cy="12" r="3.4" />
<path d="M12 8.6V3M9.05 13.7 4.2 16.5M14.95 13.7l4.85 2.8" />
</Svg>
);
}

165
lib/install.ts Normal file
View file

@ -0,0 +1,165 @@
"use client";
import { useEffect, useState } from "react";
/**
* "Install the app" helpers: platform/browser detection plus the small amount
* of persistence the bottom sheet needs.
*
* Everything here is browser-only and side-effect free until called. The
* install prompt is never shown on desktop, never inside an installed app, and
* at most once per 7 days after a dismissal.
*/
export type InstallPlatform = "ios" | "android" | "other";
export type InstallBrowser = "safari" | "chrome" | "other" | "in-app";
/** Chrome's `beforeinstallprompt` event (not in the DOM typings). */
export type BeforeInstallPromptEvent = Event & {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: "accepted" | "dismissed"; platform: string }>;
};
declare global {
interface Window {
__freezmoInstallPrompt?: BeforeInstallPromptEvent;
}
}
const DISMISS_KEY = "freezmo.install.dismissedAt";
const INSTALLED_KEY = "freezmo.install.installed";
const THEME_KEY = "freezmo.theme";
/** How long a dismissal silences the automatic prompt. */
export const INSTALL_REMIND_MS = 7 * 24 * 60 * 60 * 1000;
/** Fired by Settings to open the sheet manually (ignores the dismissal). */
export const OPEN_INSTALL_EVENT = "freezmo:open-install";
/** Fired by the inline head script when Chrome offers an install prompt. */
export const INSTALL_PROMPT_EVENT = "freezmo:installprompt";
/** Fired by the inline head script when the app gets installed. */
export const INSTALLED_EVENT = "freezmo:installed";
/**
* iPhone/iPad (including iPadOS 13+, which reports itself as a Mac — a Mac
* with more than one touch point is really an iPad) or Android. Everything
* else is desktop/unknown and never sees the prompt.
*/
export function detectPlatform(
ua: string = typeof navigator === "undefined" ? "" : navigator.userAgent,
maxTouchPoints: number = typeof navigator === "undefined" ? 0 : navigator.maxTouchPoints || 0,
): InstallPlatform {
if (/iPhone|iPad|iPod/i.test(ua)) return "ios";
if (/Macintosh/i.test(ua) && maxTouchPoints > 1) return "ios";
if (/Android/i.test(ua)) return "android";
return "other";
}
/** Embedded webviews (Instagram, Facebook, …) can't install anything. */
const IN_APP_PATTERN =
/(FBAN|FBAV|FB_IAB|Instagram|Line\/|Twitter|TikTok|SnapChat|Pinterest|MicroMessenger|GSA\/|; wv\))/i;
/**
* Which browser is this? Used to adapt step 1 of the instructions:
* iOS outside Safari (and Android outside Chrome) has to switch browsers
* first, and in-app browsers can't install at all.
*/
export function detectBrowser(
platform: InstallPlatform,
ua: string = typeof navigator === "undefined" ? "" : navigator.userAgent,
): InstallBrowser {
if (IN_APP_PATTERN.test(ua)) return "in-app";
if (platform === "ios") {
const otherIos = /(CriOS|FxiOS|EdgiOS|OPiOS|DuckDuckGo)/.test(ua);
return /Safari\//.test(ua) && !otherIos ? "safari" : "other";
}
if (platform === "android") {
const otherAndroid = /(SamsungBrowser|OPR\/|Firefox|EdgA|DuckDuckGo|YaBrowser|Vivaldi)/.test(ua);
return /Chrome\//.test(ua) && !otherAndroid ? "chrome" : "other";
}
return "other";
}
/** True when the app is already running from the home screen / app switcher. */
export function isStandalone(): boolean {
if (typeof window === "undefined") return false;
try {
const displayMode = window.matchMedia("(display-mode: standalone)").matches;
const iosStandalone =
(window.navigator as Navigator & { standalone?: boolean }).standalone === true;
return displayMode || iosStandalone;
} catch {
return false;
}
}
export function markInstalled(): void {
try {
window.localStorage.setItem(INSTALLED_KEY, "1");
} catch {
/* private mode */
}
}
export function isMarkedInstalled(): boolean {
try {
return window.localStorage.getItem(INSTALLED_KEY) === "1";
} catch {
return false;
}
}
/** Installed now, or recorded as installed earlier in this browser. */
export function isInstalled(): boolean {
return isStandalone() || isMarkedInstalled();
}
/** True while a previous dismissal is still within its 7-day window. */
export function isDismissed(now: number = Date.now()): boolean {
try {
const raw = window.localStorage.getItem(DISMISS_KEY);
if (!raw) return false;
const at = Number.parseInt(raw, 10);
return Number.isFinite(at) && now - at < INSTALL_REMIND_MS;
} catch {
return false;
}
}
export function dismissInstallPrompt(now: number = Date.now()): void {
try {
window.localStorage.setItem(DISMISS_KEY, String(now));
} catch {
/* private mode */
}
}
/** Opens the sheet manually (used by the Settings row). */
export function openInstallSheet(): void {
if (typeof window === "undefined") return;
window.dispatchEvent(new CustomEvent(OPEN_INSTALL_EVENT));
}
/**
* The sheet follows the app's *explicit* theme choice: dark unless the user
* picked light in the app (a system preference alone doesn't count).
*/
export function installSheetTheme(): "dark" | "light" {
try {
return window.localStorage.getItem(THEME_KEY) === "light" ? "light" : "dark";
} catch {
return "dark";
}
}
/**
* Settings helper: is the "Install app" row relevant here? (Mobile platform
* and not installed yet.) Starts false so server rendering never mismatches.
*/
export function useInstallAvailable(): boolean {
const [available, setAvailable] = useState(false);
useEffect(() => {
setAvailable(detectPlatform() !== "other" && !isInstalled());
}, []);
return available;
}