diff --git a/app/globals.css b/app/globals.css index 7df8640..8d06f36 100644 --- a/app/globals.css +++ b/app/globals.css @@ -90,6 +90,7 @@ html, body { height: 100%; + height: 100dvh; } body { @@ -101,6 +102,7 @@ body { line-height: 1.4; -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; + -webkit-tap-highlight-color: transparent; } button, @@ -148,10 +150,42 @@ button:disabled { display: flex; flex-direction: column; height: 100%; + height: 100dvh; overflow: hidden; background: var(--c-editor); color: var(--c-text); font-family: var(--c-ui-font); + padding: + env(safe-area-inset-top, 0) + env(safe-area-inset-right, 0) + env(safe-area-inset-bottom, 0) + env(safe-area-inset-left, 0); +} + +.zed-empty__actions { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 8px; +} + +.zed-notes-fab { + display: none; + position: fixed; + left: max(12px, env(safe-area-inset-left, 0px)); + bottom: max(12px, env(safe-area-inset-bottom, 0px)); + z-index: 20; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + padding: 0; + border-radius: 10px; + border: 1px solid var(--c-border); + background: color-mix(in srgb, var(--c-surface) 92%, transparent); + color: var(--c-text-muted); + backdrop-filter: blur(8px); } .zed-titlebar { @@ -413,10 +447,16 @@ button:disabled { color: var(--c-editor-fg); caret-color: var(--c-text-accent); tab-size: 4; - white-space: pre; + white-space: pre-wrap; + overflow-wrap: anywhere; overflow: auto; } +.zed-editor__body[data-wrap="false"] { + white-space: pre; + overflow-wrap: normal; +} + .zed-editor__body::placeholder { color: var(--c-text-placeholder); } @@ -448,10 +488,15 @@ button:disabled { /* Login — elevated surface dialog on editor canvas */ .zed-login { min-height: 100%; + min-height: 100dvh; display: flex; align-items: center; justify-content: center; - padding: 24px; + padding: + max(24px, env(safe-area-inset-top, 0px)) + max(24px, env(safe-area-inset-right, 0px)) + max(24px, env(safe-area-inset-bottom, 0px)) + max(24px, env(safe-area-inset-left, 0px)); background: var(--c-editor); } @@ -742,3 +787,29 @@ button:disabled { white-space: nowrap; border: 0; } + +@media (hover: none) { + .zed-note-item__delete { + display: inline-flex; + } +} + +@media (max-width: 480px) { + .zed-notes-fab { + display: inline-flex; + } + + .zed-field { + font-size: 16px; + } + + .zed-empty__hint { + display: none; + } +} + +@media (min-width: 481px) { + .zed-empty__actions .zed-btn:first-child { + display: none; + } +} diff --git a/app/layout.tsx b/app/layout.tsx index 78068f2..2115bb8 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,4 +1,4 @@ -import type { Metadata } from "next"; +import type { Metadata, Viewport } from "next"; import { ThemeBoot } from "@/components/theme-boot"; import { faviconBootScript } from "@/lib/themes"; import "./globals.css"; @@ -9,6 +9,21 @@ export const metadata: Metadata = { icons: { icon: [{ url: "/favicon.svg", type: "image/svg+xml" }], }, + appleWebApp: { + capable: true, + title: "memo", + statusBarStyle: "black-translucent", + }, +}; + +export const viewport: Viewport = { + width: "device-width", + initialScale: 1, + viewportFit: "cover", + themeColor: [ + { media: "(prefers-color-scheme: dark)", color: "#141414" }, + { media: "(prefers-color-scheme: light)", color: "#f3f3f3" }, + ], }; export default function RootLayout({ diff --git a/components/icons.tsx b/components/icons.tsx index 3bcb6e4..54a3535 100644 --- a/components/icons.tsx +++ b/components/icons.tsx @@ -23,6 +23,26 @@ export function PlusIcon({ size = 14, className }: IconProps) { ); } +export function MenuIcon({ size = 16, className }: IconProps) { + return ( + + + + ); +} + /** Heroicons outline `cog-6-tooth` (MIT) — https://heroicons.com */ export function SettingsIcon({ size = 16, className }: IconProps) { return ( diff --git a/components/memo-app.tsx b/components/memo-app.tsx index 29dba23..af06ba1 100644 --- a/components/memo-app.tsx +++ b/components/memo-app.tsx @@ -2,6 +2,16 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { Note } from "@/lib/types"; +import { + DEFAULT_WRAP, + WRAP_STORAGE_KEY, + isWrapPreference, +} from "@/lib/preferences"; +import { + createTabId, + openSyncChannel, + type SyncMessage, +} from "@/lib/tab-sync"; import { APPEARANCE_STORAGE_KEY, DEFAULT_APPEARANCE, @@ -13,18 +23,21 @@ import { type Appearance, type ThemeId, } from "@/lib/themes"; -import { - createTabId, - openSyncChannel, - type SyncMessage, -} from "@/lib/tab-sync"; -import { PlusIcon, SettingsIcon } from "./icons"; +import { MenuIcon, PlusIcon, SettingsIcon } from "./icons"; import { SettingsPanel } from "./settings-panel"; type SaveState = "saved" | "saving" | "dirty" | "error"; const POLL_MS = 1500; const DRAFT_BROADCAST_MS = 32; +/** Phone-width only — keep tablet/desktop browser windows on the desktop layout. */ +const NARROW_QUERY = "(max-width: 480px)"; + +function isNarrowViewport() { + return ( + typeof window !== "undefined" && window.matchMedia(NARROW_QUERY).matches + ); +} function previewTitle(note: Pick) { const fromTitle = note.title.trim(); @@ -105,6 +118,7 @@ export function MemoApp({ initialNotes }: { initialNotes: Note[] }) { const [themeId, setThemeId] = useState(DEFAULT_THEME_ID); const [appearance, setAppearance] = useState(DEFAULT_APPEARANCE); + const [wrap, setWrap] = useState(DEFAULT_WRAP); const [caret, setCaret] = useState(0); const saveTimer = useRef | null>(null); const skipNextSave = useRef(false); @@ -195,6 +209,9 @@ export function MemoApp({ initialNotes }: { initialNotes: Note[] }) { const savedAppearance = window.localStorage.getItem( APPEARANCE_STORAGE_KEY, ); + const savedWrap = isWrapPreference( + window.localStorage.getItem(WRAP_STORAGE_KEY), + ); const nextTheme = savedTheme && isThemeId(savedTheme) ? savedTheme : DEFAULT_THEME_ID; const nextAppearance = @@ -203,6 +220,7 @@ export function MemoApp({ initialNotes }: { initialNotes: Note[] }) { : DEFAULT_APPEARANCE; setThemeId(nextTheme); setAppearance(nextAppearance); + setWrap(savedWrap ?? DEFAULT_WRAP); applyTheme(nextTheme, nextAppearance); }, []); @@ -224,6 +242,11 @@ export function MemoApp({ initialNotes }: { initialNotes: Note[] }) { [themeId], ); + const selectWrap = useCallback((next: boolean) => { + setWrap(next); + window.localStorage.setItem(WRAP_STORAGE_KEY, String(next)); + }, []); + const sortedNotes = useMemo(() => sortNotesByRecent(notes), [notes]); const activeNote = useMemo( @@ -244,6 +267,7 @@ export function MemoApp({ initialNotes }: { initialNotes: Note[] }) { setBody(note.body); setSaveState("saved"); setCaret(0); + if (isNarrowViewport()) setSidebarOpen(false); requestAnimationFrame(() => bodyRef.current?.focus()); }, []); @@ -416,7 +440,7 @@ export function MemoApp({ initialNotes }: { initialNotes: Note[] }) { note: data.note, }); selectNote(data.note); - setSidebarOpen(true); + if (!isNarrowViewport()) setSidebarOpen(true); }, [selectNote]); async function removeNote(id: string) { @@ -476,7 +500,12 @@ export function MemoApp({ initialNotes }: { initialNotes: Note[] }) { return ( - + {!sidebarOpen ? ( + setSidebarOpen(true)} + aria-label="Open notes" + title="Notes" + > + + + ) : null} + setSettingsOpen(false)} onThemeChange={selectTheme} onAppearanceChange={selectAppearance} + onWrapChange={selectWrap} onLock={() => void logout()} /> diff --git a/components/settings-panel.tsx b/components/settings-panel.tsx index 7ccacf9..041c2f1 100644 --- a/components/settings-panel.tsx +++ b/components/settings-panel.tsx @@ -30,17 +30,21 @@ export function SettingsPanel({ open, themeId, appearance, + wrap, onClose, onThemeChange, onAppearanceChange, + onWrapChange, onLock, }: { open: boolean; themeId: ThemeId; appearance: Appearance; + wrap: boolean; onClose: () => void; onThemeChange: (id: ThemeId) => void; onAppearanceChange: (appearance: Appearance) => void; + onWrapChange: (wrap: boolean) => void; onLock: () => void; }) { useEffect(() => { @@ -97,6 +101,28 @@ export function SettingsPanel({ + + Editor + + onWrapChange(true)} + > + Wrap + + onWrapChange(false)} + > + No wrap + + + + Color system diff --git a/lib/preferences.ts b/lib/preferences.ts new file mode 100644 index 0000000..85ccd1d --- /dev/null +++ b/lib/preferences.ts @@ -0,0 +1,8 @@ +export const WRAP_STORAGE_KEY = "memo.wrap"; +export const DEFAULT_WRAP = true; + +export function isWrapPreference(value: string | null): boolean | null { + if (value === "true") return true; + if (value === "false") return false; + return null; +}