diff --git a/README.md b/README.md index bdcbfbe..e742811 100644 --- a/README.md +++ b/README.md @@ -59,10 +59,10 @@ This writes an unpacked extension to `dist/extension/` and a zip to `dist/boost- 1. Open `chrome://extensions` (or `edge://extensions`) and turn on **Developer mode**. 2. Click **Load unpacked** and select `dist/extension`. -3. Click the toolbar icon to open the timer in a tab. Clicking again focuses the existing tab instead of opening a second timer. +3. Click the toolbar icon to open the timer in a **400 × 600 popup** (a compact layout of the same app). Use the ⤢ button in its top bar, or Alt+Enter, to open the **full page** in a tab; an existing tab is focused instead of opening a second one. 4. Optional: assign a global shortcut for **Open Boost Focus Timer** at `chrome://extensions/shortcuts`. -Extension data lives in the extension's own storage, separate from the web app's. Firefox is not supported yet. +A session keeps running when the popup closes: reopening it continues the countdown, and the extension shows the "Focus session complete" / "Break is over" notification even while the popup is closed (the end chime only plays if the app is open). Popup and full page share the same data, which lives in the extension's own storage, separate from the web app's. Firefox is not supported yet. ## Keyboard shortcuts diff --git a/extension/background.js b/extension/background.js index 992d06b..d532c57 100644 --- a/extension/background.js +++ b/extension/background.js @@ -1,18 +1,53 @@ -// Toolbar button (and its optional keyboard shortcut): focus the open timer tab, or open one. -// A single tab matters because two running timers would race on the same local data. +// Background worker. The toolbar button opens the popup (manifest `default_popup`); this worker +// opens the full-page tab on request and raises the end-of-session notification, because the +// popup is destroyed whenever it loses focus and cannot wait for a timer itself. const APP_URL = chrome.runtime.getURL("index.html"); +const ICON_URL = chrome.runtime.getURL("icons/icon-128.png"); +const END_ALARM = "session-end"; +const PENDING_KEY = "pendingNotification"; -async function findAppTab() { +/** One app tab only: two running timers would race on the same local data. */ +async function openApp() { const contexts = await chrome.runtime.getContexts({ contextTypes: ["TAB"], documentUrls: [APP_URL] }); - return contexts.find((context) => context.tabId !== -1) ?? null; -} - -chrome.action.onClicked.addListener(async () => { - const existing = await findAppTab(); + const existing = contexts.find((context) => context.tabId !== -1); if (!existing) { await chrome.tabs.create({ url: APP_URL }); return; } await chrome.tabs.update(existing.tabId, { active: true }); await chrome.windows.update(existing.windowId, { focused: true }); +} + +async function scheduleEnd({ at, title, body }) { + await chrome.storage.session.set({ [PENDING_KEY]: { title, body } }); + await chrome.alarms.create(END_ALARM, { when: at }); +} + +async function cancelEnd() { + await chrome.alarms.clear(END_ALARM); + await chrome.storage.session.remove(PENDING_KEY); +} + +chrome.runtime.onMessage.addListener((message) => { + const handlers = { "open-app": openApp, "schedule-end": scheduleEnd, "cancel-end": cancelEnd }; + handlers[message?.type]?.(message).catch((error) => console.error(`Failed to handle ${message.type}:`, error)); +}); + +chrome.alarms.onAlarm.addListener(async (alarm) => { + if (alarm.name !== END_ALARM) return; + const { [PENDING_KEY]: pending } = await chrome.storage.session.get(PENDING_KEY); + if (!pending) return; + await chrome.storage.session.remove(PENDING_KEY); + // A fresh id per notification: reusing one swaps the text in silently without a pop-up. + await chrome.notifications.create(`session-end-${Date.now()}`, { + type: "basic", + iconUrl: ICON_URL, + title: pending.title, + message: pending.body, + }); +}); + +chrome.notifications.onClicked.addListener((id) => { + chrome.notifications.clear(id); + openApp().catch((error) => console.error("Failed to open the app:", error)); }); diff --git a/scripts/build-extension.mjs b/scripts/build-extension.mjs index 9209a01..340d343 100644 --- a/scripts/build-extension.mjs +++ b/scripts/build-extension.mjs @@ -63,6 +63,13 @@ function externalizeInlineScripts() { } } +/** The toolbar popup is the same page, marked so the app switches to its compact 400x600 layout. */ +function writePopupPage() { + const html = readFileSync(join(EXTENSION_DIR, "index.html"), "utf8"); + if (!html.includes(" tag"); + writeFileSync(join(EXTENSION_DIR, "popup.html"), html.replace(/)/, ' [String(size), `icons/icon-${size}.png`])); const manifest = { @@ -72,9 +79,9 @@ function writeManifest() { version, description, icons, - // Lets the timer page show session-complete notifications without a prompt. - permissions: ["notifications"], - action: { default_title: "Open Boost Focus Timer", default_icon: icons }, + // notifications + alarms: the background worker announces a session's end even with the popup closed. + permissions: ["notifications", "alarms"], + action: { default_title: "Boost Focus Timer", default_icon: icons, default_popup: "popup.html" }, background: { service_worker: "background.js" }, // Unassigned by default; users pick a key at chrome://extensions/shortcuts. commands: { _execute_action: { description: "Open Boost Focus Timer" } }, @@ -98,6 +105,7 @@ function zipExtension() { exportStaticSite(); renameReservedPaths(); externalizeInlineScripts(); +writePopupPage(); writeManifest(); const zipPath = zipExtension(); if (!existsSync(join(EXTENSION_DIR, "index.html"))) throw new Error("Static export produced no index.html"); diff --git a/src/app/globals.css b/src/app/globals.css index 104c74c..ac9a6f6 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -6,6 +6,8 @@ * Classes v4 dropped (bg-opacity-*, flex-shrink-*, ...) must be rewritten in markup. */ @custom-variant wide (@media (min-width: 820px)); +/* The extension's toolbar popup: popup.html carries data-view="popup" (see scripts/build-extension.mjs). */ +@custom-variant popup (&:where([data-view="popup"] *)); @theme { --font-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", @@ -258,6 +260,16 @@ body { background-color: #111827; } + + /* + * Chrome caps extension popups at 800x600 and sizes them to the document. 400 wide matches the + * phone layout the app was designed at; 600 is the tallest Chrome allows. + */ + html[data-view="popup"], + html[data-view="popup"] body { + width: 400px; + height: 600px; + } } @utility force-scrollbar { diff --git a/src/components/BoostApp.tsx b/src/components/BoostApp.tsx index 7312b9a..601daa5 100644 --- a/src/components/BoostApp.tsx +++ b/src/components/BoostApp.tsx @@ -7,6 +7,7 @@ import { FocusTimer } from "./focus/FocusTimer"; import { askForNotificationsOnFirstOpen } from "./focus/notifications"; import { HotkeysProvider, useHotkey, useHotkeyMap } from "./hotkeys/HotkeysProvider"; import { databaseService } from "./lib/database-service"; +import { isPopupView } from "./lib/extension"; import { getLocalUserId } from "./lib/local-db"; import { getDisplayName, saveDisplayName } from "./lib/profile"; import { BottomNav } from "./shell/BottomNav"; @@ -55,6 +56,7 @@ function AppShell() { const timerRef = useRef(null); const isMobile = useMediaQuery(MOBILE_QUERY); const isShortViewport = useMediaQuery(SHORT_VIEWPORT_QUERY); + const [isPopup] = useState(isPopupView); useEffect(() => askForNotificationsOnFirstOpen(), []); @@ -103,9 +105,13 @@ function AppShell() { setPendingTodo(null); }; - const navHeight = isMobile ? "8rem" : MOBILE_NAV_OFFSET; - const paddingBottom = - tab === "timer" && !isShortViewport && !isMobile ? "0.25rem" : `calc(1rem + ${navHeight} + env(safe-area-inset-bottom, 0px))`; + // The popup keeps the bottom nav but has no phone home-indicator area, so it needs the desktop clearance. + const navHeight = isMobile && !isPopup ? "8rem" : MOBILE_NAV_OFFSET; + const paddingBottom = isPopup + ? navHeight + : tab === "timer" && !isShortViewport && !isMobile + ? "0.25rem" + : `calc(1rem + ${navHeight} + env(safe-area-inset-bottom, 0px))`; return ( <> @@ -117,7 +123,7 @@ function AppShell() { className={`bg-black wide:border wide:border-gray-800 wide:rounded-3xl wide:shadow-2xl p-4 sm:p-8 w-full max-w-2xl mx-auto ${ tab === "timer" ? "mt-auto" : "wide:mt-auto" }`} - style={{ paddingTop: isMobile ? "4rem" : undefined }} + style={{ paddingTop: isPopup ? "3rem" : isMobile ? "4rem" : undefined }} > {/* Kept mounted while hidden so a running session survives tab switches. */}
diff --git a/src/components/focus/FocusTimer.tsx b/src/components/focus/FocusTimer.tsx index 8f46455..bbae7ba 100644 --- a/src/components/focus/FocusTimer.tsx +++ b/src/components/focus/FocusTimer.tsx @@ -3,6 +3,7 @@ import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState, type ReactNode } from "react"; import { CheckCircle2, Clock, Pause, Play, RotateCcw, Target, X } from "../icons"; import { databaseService, type FocusSession, type Tag } from "../lib/database-service"; +import { clearRunningTimer, readRunningTimer, trackRunningTimer, type RunningTimer } from "../lib/extension"; import { useHotkey, useHotkeyLabel } from "../hotkeys/HotkeysProvider"; import type { FocusTimerHandle, FocusTimerProps } from "../types"; import { AnimationPicker } from "./AnimationPicker"; @@ -202,9 +203,48 @@ export const FocusTimer = forwardRef(function databaseService.updateUserSettings({ auto_shuffle_animations: next }).catch(logError("Failed to save user settings:")); }; + /** + * Extension only: the popup closes whenever it loses focus, so a countdown that was running when it + * closed keeps running (or has finished) instead of coming back paused. + */ + const continueRunningTimer = async (running: RunningTimer, latest: FocusSession): Promise => { + if (latest.id !== running.sessionId || latest.completed) return false; + const remaining = running.endAt - Date.now(); + const startedAt = running.endAt - running.totalMs; + if (running.kind === "focus") { + setGoal(latest.goal); + setInputMinutes(String(latest.planned_duration_minutes)); + setHasStarted(true); + setFocusTotalMs(running.totalMs); + if (remaining > 0) { + setSession(latest); + setFocusStart(startedAt); + setFocus({ ...IDLE, ...splitMs(remaining), isRunning: true }); + } else { + setSession(await databaseService.updateFocusSession(latest.id, { end_time: new Date(running.endAt).toISOString(), completed: true })); + setFocus({ ...IDLE, isCompleted: true }); + } + return true; + } + if (remaining > 0) { + setBreakSession(latest); + setIsOnBreak(true); + setHasStartedBreak(true); + setBreakTotalMs(running.totalMs); + setBreakStart(startedAt); + setBreakTimer({ ...IDLE, ...splitMs(remaining), isRunning: true }); + } else { + await databaseService.updateFocusSession(latest.id, { end_time: new Date(running.endAt).toISOString(), completed: true }); + setBreakTimer({ ...IDLE, isCompleted: true }); + } + return true; + }; + useEffect(() => { const restore = async () => { const [latest] = await databaseService.getUserFocusSessions(1); + const running = readRunningTimer(); + if (running && latest && (await continueRunningTimer(running, latest))) return; if (!latest || latest.completed || Date.now() - new Date(latest.created_at).getTime() >= RESTORE_WINDOW_MS) return; const restored = await restoreSession(latest); setSession(latest); @@ -220,6 +260,18 @@ export const FocusTimer = forwardRef(function .finally(() => setIsInitialized(true)); }, []); + // Keep the extension's running-timer record (and its background alarm) in step with the countdowns. + useEffect(() => { + if (!isInitialized) return; + if (focus.isRunning && focusStart !== null && session) { + trackRunningTimer({ kind: "focus", sessionId: session.id, endAt: focusStart + focusTotalMs, totalMs: focusTotalMs, goal }); + } else if (breakTimer.isRunning && breakStart !== null && breakSession) { + trackRunningTimer({ kind: "break", sessionId: breakSession.id, endAt: breakStart + breakTotalMs, totalMs: breakTotalMs, goal: "" }); + } else { + clearRunningTimer(); + } + }, [isInitialized, focus.isRunning, focusStart, focusTotalMs, session, goal, breakTimer.isRunning, breakStart, breakTotalMs, breakSession]); + const resetAll = () => { setFocus(IDLE); setBreakTimer(IDLE); @@ -542,7 +594,7 @@ export const FocusTimer = forwardRef(function type={selectedAnimation} frame={currentFrame(selectedAnimation, frameState)} label={`${metaOf(selectedAnimation).title} timer`} - className="w-64 h-64 sm:w-96 sm:h-96 mx-auto" + className="w-64 h-64 sm:w-96 sm:h-96 popup:w-40 popup:h-40 mx-auto" />
diff --git a/src/components/focus/notifications.ts b/src/components/focus/notifications.ts index 75e3f87..f505ce2 100644 --- a/src/components/focus/notifications.ts +++ b/src/components/focus/notifications.ts @@ -1,41 +1,18 @@ /* - * The browser permission is the only switch: the app asks Chrome directly on first open. - * Inside the extension the manifest grants it, so there is nothing to ask. + * The browser permission is the only switch: the app asks the browser directly on first open. + * Inside the extension the manifest grants it and the background worker shows the + * end-of-session notifications (see lib/extension.ts), so pages there never notify themselves. */ +import { isExtensionPage } from "../lib/extension"; -const ICON_PATH = "icons/icon-128.png"; -/* - * Each notification gets a fresh id and the previous one is closed first. Reusing a tag/id - * would make the browser swap the text into the old notification silently, without a pop-up. - */ -const NOTIFICATION_ID_PREFIX = "boost-focus-timer"; - -/** The slice of the extension `chrome` API used here; the web build never has it. */ -interface ExtensionApi { - runtime: { id?: string; getURL(path: string): string }; - notifications: { - create(id: string, options: { type: "basic"; iconUrl: string; title: string; message: string; silent?: boolean }): Promise; - clear(id: string): Promise; - onClicked: { addListener(listener: (id: string) => void): void }; - }; - tabs: { - getCurrent(): Promise<{ id?: number; windowId?: number } | undefined>; - update(tabId: number, properties: { active: boolean }): Promise; - }; - windows: { update(windowId: number, properties: { focused: boolean }): Promise }; -} - -function extensionApi(): ExtensionApi | null { - const candidate = (globalThis as { chrome?: Partial }).chrome; - return candidate?.runtime?.id && candidate.notifications ? (candidate as ExtensionApi) : null; -} +const ICON_PATH = "/icons/icon-128.png"; const hasWebNotifications = (): boolean => typeof window !== "undefined" && "Notification" in window; export type NotificationStatus = "unsupported" | NotificationPermission; export function notificationStatus(): NotificationStatus { - if (extensionApi()) return "granted"; + if (isExtensionPage()) return "granted"; return hasWebNotifications() ? Notification.permission : "unsupported"; } @@ -82,37 +59,15 @@ export function askForNotificationsOnFirstOpen(): () => void { }; } -let clickHandlerInstalled = false; -let previousExtensionId: string | null = null; -let previousWebNotification: Notification | null = null; - -/** Clicking an extension notification brings the timer tab and its window to the front. */ -function installExtensionClickHandler(api: ExtensionApi): void { - if (clickHandlerInstalled) return; - clickHandlerInstalled = true; - api.notifications.onClicked.addListener(async () => { - const tab = await api.tabs.getCurrent(); - if (tab?.id !== undefined) await api.tabs.update(tab.id, { active: true }); - if (tab?.windowId !== undefined) await api.windows.update(tab.windowId, { focused: true }); - }); -} +// The previous notification is closed first: a reused tag would swap the text in silently, without a pop-up. +let previousNotification: Notification | null = null; function show(title: string, body: string): void { - const api = extensionApi(); try { - if (api) { - installExtensionClickHandler(api); - if (previousExtensionId) void api.notifications.clear(previousExtensionId); - const id = `${NOTIFICATION_ID_PREFIX}-${Date.now()}`; - previousExtensionId = id; - const options = { type: "basic" as const, iconUrl: api.runtime.getURL(ICON_PATH), title, message: body, silent: true }; - api.notifications.create(id, options).catch((error: unknown) => console.error("Could not show notification:", error)); - return; - } - previousWebNotification?.close(); + previousNotification?.close(); // Silent because the end bell already plays; the notification is the visual cue. - const notification = new Notification(title, { body, icon: `/${ICON_PATH}`, silent: true }); - previousWebNotification = notification; + const notification = new Notification(title, { body, icon: ICON_PATH, silent: true }); + previousNotification = notification; notification.onclick = () => { window.focus(); notification.close(); @@ -122,7 +77,7 @@ function show(title: string, body: string): void { } } -/** Shows a notification when the browser allows it. */ +/** Shows a notification in the web app when the browser allows it; the extension's background worker handles its own. */ export function notify(title: string, body: string): void { - if (notificationStatus() === "granted") show(title, body); + if (!isExtensionPage() && notificationStatus() === "granted") show(title, body); } diff --git a/src/components/hotkeys/bindings.ts b/src/components/hotkeys/bindings.ts index ca64b7e..8eb5f7e 100644 --- a/src/components/hotkeys/bindings.ts +++ b/src/components/hotkeys/bindings.ts @@ -32,6 +32,7 @@ export const HOTKEY_ACTIONS: readonly HotkeyAction[] = [ { id: "boost.attach", group: "Lists", label: "Attach media to post", defaultCombo: "Alt+U" }, { id: "app.displayName", group: "App", label: "Edit display name", defaultCombo: "Alt+Shift+N" }, + { id: "app.fullPage", group: "App", label: "Open full page (extension popup)", defaultCombo: "Alt+Enter" }, { id: "app.shortcuts", group: "App", label: "Show or hide this list", defaultCombo: "Ctrl+/" }, ]; diff --git a/src/components/icons.ts b/src/components/icons.ts index 9971118..9124848 100644 --- a/src/components/icons.ts +++ b/src/components/icons.ts @@ -19,6 +19,7 @@ export const CircleUser = createIcon("CircleUser", [["circle",{cx:"12",cy:"12",r export const Circle = createIcon("Circle", [["circle",{cx:"12",cy:"12",r:"10"}]]); export const Clock = createIcon("Clock", [["circle",{cx:"12",cy:"12",r:"10"}],["polyline",{points:"12 6 12 12 16 14"}]]); export const Coffee = createIcon("Coffee", [["path",{d:"M17 8h1a4 4 0 1 1 0 8h-1"}],["path",{d:"M3 8h14v9a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4Z"}],["line",{x1:"6",x2:"6",y1:"2",y2:"4"}],["line",{x1:"10",x2:"10",y1:"2",y2:"4"}],["line",{x1:"14",x2:"14",y1:"2",y2:"4"}]]); +export const Expand = createIcon("Expand", [["path",{d:"m21 21-6-6m6 6v-4.8m0 4.8h-4.8"}],["path",{d:"M3 16.2V21m0 0h4.8M3 21l6-6"}],["path",{d:"M21 7.8V3m0 0h-4.8M21 3l-6 6"}],["path",{d:"M3 7.8V3m0 0h4.8M3 3l6 6"}]]); export const Globe = createIcon("Globe", [["circle",{cx:"12",cy:"12",r:"10"}],["path",{d:"M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"}],["path",{d:"M2 12h20"}]]); export const GripVertical = createIcon("GripVertical", [["circle",{cx:"9",cy:"12",r:"1"}],["circle",{cx:"9",cy:"5",r:"1"}],["circle",{cx:"9",cy:"19",r:"1"}],["circle",{cx:"15",cy:"12",r:"1"}],["circle",{cx:"15",cy:"5",r:"1"}],["circle",{cx:"15",cy:"19",r:"1"}]]); export const Handshake = createIcon("Handshake", [["path",{d:"m11 17 2 2a1 1 0 1 0 3-3"}],["path",{d:"m14 14 2.5 2.5a1 1 0 1 0 3-3l-3.88-3.88a3 3 0 0 0-4.24 0l-.88.88a1 1 0 1 1-3-3l2.81-2.81a5.79 5.79 0 0 1 7.06-.87l.47.28a2 2 0 0 0 1.42.25L21 4"}],["path",{d:"m21 3 1 11h-2"}],["path",{d:"M3 3 2 14l6.5 6.5a1 1 0 1 0 3-3"}],["path",{d:"M3 4h8"}]]); diff --git a/src/components/lib/extension.ts b/src/components/lib/extension.ts new file mode 100644 index 0000000..2819256 --- /dev/null +++ b/src/components/lib/extension.ts @@ -0,0 +1,81 @@ +/* + * Browser-extension glue. In the extension the app runs in a toolbar popup that is destroyed + * whenever it loses focus, so a running timer is recorded here and the background worker + * raises the end-of-session notification. Every function is a no-op in the web app. + */ + +const RUNNING_TIMER_KEY = "bft:runningTimer"; + +interface ExtensionRuntime { + runtime: { id?: string; sendMessage(message: BackgroundMessage): Promise }; +} + +export type BackgroundMessage = + | { type: "open-app" } + | { type: "schedule-end"; at: number; title: string; body: string } + | { type: "cancel-end" }; + +function runtime(): ExtensionRuntime["runtime"] | null { + const candidate = (globalThis as { chrome?: Partial }).chrome; + return candidate?.runtime?.id ? candidate.runtime : null; +} + +export const isExtensionPage = (): boolean => runtime() !== null; + +/** The build marks popup.html with `data-view="popup"`; the full-page tab has no marker. */ +export const isPopupView = (): boolean => + typeof document !== "undefined" && document.documentElement.dataset.view === "popup"; + +async function send(message: BackgroundMessage): Promise { + try { + await runtime()?.sendMessage(message); + } catch (error) { + console.error(`Background message "${message.type}" failed:`, error); + } +} + +/** Opens (or focuses) the full-page app tab, then closes the popup once the request has been delivered. */ +export async function openFullPage(): Promise { + await send({ type: "open-app" }); + if (isPopupView()) window.close(); +} + +export interface RunningTimer { + readonly kind: "focus" | "break"; + readonly sessionId: string; + /** Epoch ms when the countdown reaches zero. */ + readonly endAt: number; + readonly totalMs: number; + readonly goal: string; +} + +/** + * Records the running countdown so a reopened popup continues it, and has the background + * worker notify at `endAt` even when no app page is open. + */ +export function trackRunningTimer(timer: RunningTimer): void { + if (!isExtensionPage()) return; + localStorage.setItem(RUNNING_TIMER_KEY, JSON.stringify(timer)); + const notification = + timer.kind === "focus" + ? { title: "Focus session complete", body: `${timer.goal.trim() || "Nice work"} · time for a break.` } + : { title: "Break is over", body: "Ready for the next focus session?" }; + void send({ type: "schedule-end", at: timer.endAt, ...notification }); +} + +export function clearRunningTimer(): void { + if (!isExtensionPage() || localStorage.getItem(RUNNING_TIMER_KEY) === null) return; + localStorage.removeItem(RUNNING_TIMER_KEY); + void send({ type: "cancel-end" }); +} + +export function readRunningTimer(): RunningTimer | null { + if (!isExtensionPage()) return null; + try { + const stored = localStorage.getItem(RUNNING_TIMER_KEY); + return stored ? (JSON.parse(stored) as RunningTimer) : null; + } catch (error) { + console.error("Unreadable running timer record:", error); + return null; + } +} diff --git a/src/components/shell/StartMenu.tsx b/src/components/shell/StartMenu.tsx index c808c82..76159a6 100644 --- a/src/components/shell/StartMenu.tsx +++ b/src/components/shell/StartMenu.tsx @@ -2,9 +2,10 @@ import { useEffect, useRef, useState, type ComponentType } from "react"; import { notificationStatus, requestNotificationPermission, type NotificationStatus } from "../focus/notifications"; -import { Bell, ChevronDown, CircleUser, Menu, Settings, Tag, Volume2 } from "../icons"; +import { Bell, ChevronDown, CircleUser, Expand, Menu, Settings, Tag, Volume2 } from "../icons"; import { useHotkey, useHotkeyLabel } from "../hotkeys/HotkeysProvider"; import type { Tab } from "../types"; +import { isPopupView, openFullPage } from "../lib/extension"; import { SoundsPanel } from "./SoundsPanel"; import { MOBILE_NAV_OFFSET, MOBILE_QUERY, useMediaQuery } from "./use-media-query"; import { useDialog } from "../dialogs/DialogProvider"; @@ -56,8 +57,13 @@ export function StartMenu({ onNavigate }: { onNavigate(tab: Tab): void }) { const [isSoundsOpen, setIsSoundsOpen] = useState(false); const menuRef = useRef(null); const isMobile = useMediaQuery(MOBILE_QUERY); + // In the 400x600 popup a floating bottom-left button would cover the timer controls, + // so the menu moves into the top bar and opens downward. + const [isPopup] = useState(isPopupView); useHotkey("nav.menu", () => setIsOpen((open) => !open)); const menuCombo = useHotkeyLabel("nav.menu"); + const fullPageCombo = useHotkeyLabel("app.fullPage"); + useHotkey("app.fullPage", () => void openFullPage(), isPopup); useEffect(() => { if (!isOpen) return; @@ -74,12 +80,26 @@ export function StartMenu({ onNavigate }: { onNavigate(tab: Tab): void }) { }; const offset = isMobile ? ` + ${MOBILE_NAV_OFFSET}` : ""; + const position = isPopup + ? { top: "max(env(safe-area-inset-top), 1rem)", right: "1rem" } + : { bottom: `calc(1rem${offset} + env(safe-area-inset-bottom, 0px))`, left: "1rem" }; return ( -
+
{isOpen ? ( -
+
+ {isPopup ? ( + + ) : null} {isSoundsOpen ? setIsSoundsOpen(false)} /> : null}
diff --git a/src/components/shell/TopBar.tsx b/src/components/shell/TopBar.tsx index 5866b36..f0f4b26 100644 --- a/src/components/shell/TopBar.tsx +++ b/src/components/shell/TopBar.tsx @@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from "react"; import { useHotkeyLabel } from "../hotkeys/HotkeysProvider"; import { BarChart3, ChevronDown, Pencil, User } from "../icons"; +import { isPopupView } from "../lib/extension"; const SAFE_TOP = "max(env(safe-area-inset-top), 1rem)"; @@ -17,6 +18,7 @@ export function TopBar({ displayName, displayTime, onEditDisplayName, onOpenHist const [isDropdownOpen, setIsDropdownOpen] = useState(false); const dropdownRef = useRef(null); const historyCombo = useHotkeyLabel("nav.history"); + const [isPopup] = useState(isPopupView); useEffect(() => { if (!isDropdownOpen) return; @@ -57,7 +59,8 @@ export function TopBar({ displayName, displayTime, onEditDisplayName, onOpenHist
)}
-
+ {/* In the popup the menu button takes the top-right corner (see StartMenu). */} +