The daily reminder said one generic line to everyone, because the server
never sees a deck. Now the notification text is written in the browser
(lib/reminder.ts) and travels with the subscription:
- Personal: "3 cards are due in Sky deck", in the app's language, tapping
through to /review. Without notes (or with nothing due) it is the plain
nudge, translated in both languages — push.title / push.due.* / push.generic.
- POST /api/push accepts an optional message {title, body, url}; url must be
a path on this app, so a stolen subscription cannot make the notification
link elsewhere. The store keeps endpoint, keys, the message and createdAt
(a refresh no longer resets the timestamp).
- components/PushReminderSync.tsx refreshes the stored text when the app
opens and whenever the tab becomes visible (throttled to twice a day), so a
count is only ever sent while it is true.
- scripts/send-reminders.mjs sends each device its own text and falls back to
PUSH_REMINDER_TITLE/_BODY for a subscription that has none
(scripts/reminder-payload.mjs, unit-checked).
- /api/push/test previews the real reminder with a "Test:" prefix.
- deploy/freezmo-reminders.{service,timer}: the daily send as a systemd user
timer (18:00 local, Persistent, randomised), documented in the README.
- Settings copy, privacy page (en+cs) and .env.example say exactly what the
server now holds.
- phase2-test grew the whole path with a faked browser subscription: personal
text in en and cs, the generic fallback in both, the URL guard, and the
sender's preference order (81 checks).
Version 0.8.2, SW v104.
125 lines
6.6 KiB
TypeScript
125 lines
6.6 KiB
TypeScript
import type { Metadata, Viewport } from "next";
|
|
|
|
import { AeroMusic } from "@/components/AeroMusic";
|
|
import { IceBackdrop } from "@/components/IceBackdrop";
|
|
import { MotionPreference } from "@/components/MotionPreference";
|
|
import { CookieConsent } from "@/components/CookieConsent";
|
|
import { InstallSheet } from "@/components/InstallSheet";
|
|
import { OfflineBanner } from "@/components/OfflineBanner";
|
|
import { PushReminderSync } from "@/components/PushReminderSync";
|
|
import { ServiceWorkerRegistrar } from "@/components/ServiceWorkerRegistrar";
|
|
import { Toaster } from "@/components/Toaster";
|
|
import { IS_DEV_INSTANCE } from "@/lib/dev-mode";
|
|
|
|
const devInstanceJs = IS_DEV_INSTANCE ? "true" : "false";
|
|
import "./globals.css";
|
|
|
|
export const metadata: Metadata = {
|
|
title: {
|
|
default: "Freezmo — turn your notes into a study feed",
|
|
template: "%s · Freezmo",
|
|
},
|
|
description:
|
|
"Freezmo turns photos and documents of your notes into a swipeable feed of short interactive study activities, powered by your own Ollama models.",
|
|
applicationName: "Freezmo",
|
|
manifest: "/manifest.webmanifest",
|
|
appleWebApp: {
|
|
capable: true,
|
|
title: "Freezmo",
|
|
statusBarStyle: "default",
|
|
/* Startup images so launching from the home screen has no white flash.
|
|
Sized for the common iPhone logical sizes at their device pixel ratio. */
|
|
startupImage: [
|
|
{ url: "/icons/splash-750x1334.png", media: "(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2)" },
|
|
{ url: "/icons/splash-828x1792.png", media: "(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2)" },
|
|
{ url: "/icons/splash-1170x2532.png", media: "(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3)" },
|
|
{ url: "/icons/splash-1179x2556.png", media: "(device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3)" },
|
|
{ url: "/icons/splash-1284x2778.png", media: "(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3)" },
|
|
{ url: "/icons/splash-1290x2796.png", media: "(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3)" },
|
|
{ url: "/icons/splash-1536x2048.png", media: "(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2)" },
|
|
{ url: "/icons/splash-1668x2388.png", media: "(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2)" },
|
|
],
|
|
},
|
|
icons: {
|
|
icon: IS_DEV_INSTANCE
|
|
? // Dev wears its own favicon so the two instances are never confused.
|
|
[{ url: "/icons/dev-favicon.svg", type: "image/svg+xml" }]
|
|
: [
|
|
// ?v=2 busts browser favicon caches after the icon redesign.
|
|
{ url: "/icons/favicon-32.png?v=2", sizes: "32x32", type: "image/png" },
|
|
{ url: "/icons/icon-192.png?v=2", sizes: "192x192", type: "image/png" },
|
|
{ url: "/icons/icon-512.png?v=2", sizes: "512x512", type: "image/png" },
|
|
],
|
|
apple: [
|
|
{ url: "/icons/apple-touch-icon.png?v=2", sizes: "180x180" },
|
|
{ url: "/icons/apple-touch-icon-167.png?v=2", sizes: "167x167" },
|
|
{ url: "/icons/apple-touch-icon-152.png?v=2", sizes: "152x152" },
|
|
],
|
|
},
|
|
other: {
|
|
// iOS standalone niceties
|
|
"apple-mobile-web-app-capable": "yes",
|
|
"mobile-web-app-capable": "yes",
|
|
"format-detection": "telephone=no",
|
|
},
|
|
};
|
|
|
|
export const viewport: Viewport = {
|
|
width: "device-width",
|
|
initialScale: 1,
|
|
// Pinch-zoom stays available for accessibility (double-tap zoom is disabled
|
|
// by `touch-action: manipulation` on the chrome instead). The input
|
|
// font-size rule in globals.css stops iOS Safari's focus auto-zoom, which
|
|
// ignores the viewport meta anyway.
|
|
viewportFit: "cover",
|
|
themeColor: IS_DEV_INSTANCE
|
|
? [{ media: "(prefers-color-scheme: light)", color: "#b45309" }, { media: "(prefers-color-scheme: dark)", color: "#b45309" }]
|
|
: [
|
|
{ media: "(prefers-color-scheme: light)", color: "#f4f7fd" },
|
|
{ media: "(prefers-color-scheme: dark)", color: "#0e1424" },
|
|
],
|
|
};
|
|
|
|
/**
|
|
* Applies the saved theme before paint to avoid a flash, and keeps the
|
|
* `theme-color` meta in step with the *app* theme (the SSR metas follow the
|
|
* OS preference, which is wrong once the user has picked a theme here).
|
|
* The stored theme decides alone: aero is not dark, and the old ternary added
|
|
* `dark` alongside `aero` whenever the OS preferred dark — the dark palette
|
|
* on the bright wallpaper is what made the first load look washed out until
|
|
* a theme toggle re-applied the classes.
|
|
*/
|
|
const themeScript = `(function(){try{var DEV=${devInstanceJs};var s=localStorage.getItem('freezmo.theme');var a=s==='aero';var d=a?false:(s?s==='dark':window.matchMedia('(prefers-color-scheme: dark)').matches);document.documentElement.classList.toggle('dark',!!d);if(a)document.documentElement.classList.add('aero');var l=localStorage.getItem('freezmo.locale.v1');if(l==='cs'||l==='en')document.documentElement.lang=l;if(localStorage.getItem('freezmo.reduce-motion.v1')==='1')document.documentElement.classList.add('reduce-motion');var m=document.querySelector('meta[name="theme-color"][data-app]');if(!m){m=document.createElement('meta');m.setAttribute('name','theme-color');m.setAttribute('data-app','1');document.head.insertBefore(m,document.head.firstChild);}m.setAttribute('content',DEV?'#b45309':(a?'#2fa7e6':(d?'#0e1424':'#f4f7fd')));}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 }) {
|
|
return (
|
|
<html lang="en" suppressHydrationWarning>
|
|
<head>
|
|
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
|
|
<script dangerouslySetInnerHTML={{ __html: installScript }} />
|
|
</head>
|
|
<body className="min-h-full antialiased app-shell-in">
|
|
<MotionPreference>
|
|
<IceBackdrop />
|
|
{children}
|
|
<AeroMusic />
|
|
<InstallSheet />
|
|
<OfflineBanner />
|
|
<CookieConsent />
|
|
<Toaster />
|
|
<ServiceWorkerRegistrar />
|
|
<PushReminderSync />
|
|
</MotionPreference>
|
|
</body>
|
|
</html>
|
|
);
|
|
}
|