From 3f9676a2df16d75bfc2c301c1f1e34cdba03a65a Mon Sep 17 00:00:00 2001 From: highperfocused Date: Sat, 5 Sep 2026 21:41:31 +0200 Subject: [PATCH] feat: rework the app as a windowed desktop-OS shell (#10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a macOS-like desktop metaphor at /os: a wallpaper, menu bar (wordmark/window-switcher, live relay-status indicator, clock, login), and a dock. Explore, Dashboard, My Events, Export Following and Messages are ported into windowed apps sharing one window-manager (drag/resize via Pointer Events, focus/z-order, minimize/maximize/ close, layout persisted to localStorage). Legacy routes now redirect into the shell with the matching app opened, so existing links keep working. On screens under 768px the same window-manager state renders as a phone-OS-style shell instead: a home-screen app grid, one full-screen app at a time, and the dock as a bottom tab bar — "going home" just minimizes the active app, so switching apps never loses state. Also: a Nostr-key lock screen gates the shell for logged-out users (with a guest bypass), Cmd/Ctrl+` cycles window focus, windows are role="dialog" with managed focus, and the previously-unrouted Messages app is wired up (it was missing its DMProvider, so it crashed on mount — fixed by scoping DMProvider to the Messages app). See docs/DESKTOP_OS.md for the architecture. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto --- docs/DESKTOP_OS.md | 124 +++++++++++ src/AppRouter.tsx | 20 +- src/components/navigation/AppSidebar.tsx | 73 ------- src/hooks/useRelayStatus.ts | 71 ++++++ src/index.css | 26 +++ src/os/Desktop.tsx | 138 ++++++++++++ src/os/apps/registry.ts | 92 ++++++++ src/os/shell/Dock.tsx | 78 +++++++ src/os/shell/LockScreen.tsx | 59 +++++ src/os/shell/MenuBar.tsx | 128 +++++++++++ src/os/shell/MobileAppView.tsx | 38 ++++ src/os/shell/MobileHome.tsx | 44 ++++ src/os/shell/Wallpaper.tsx | 16 ++ src/os/window-manager/Window.tsx | 166 ++++++++++++++ .../window-manager/WindowManagerContext.tsx | 204 ++++++++++++++++++ src/os/window-manager/context.ts | 19 ++ src/os/window-manager/types.ts | 19 ++ src/os/window-manager/useWindowManager.ts | 10 + src/pages/Dashboard.tsx | 81 +++---- src/pages/DashboardEvents.tsx | 71 +++--- src/pages/DashboardExport.tsx | 19 +- src/pages/Explore.tsx | 5 +- src/pages/Index.tsx | 19 +- src/pages/Messages.tsx | 17 +- 24 files changed, 1341 insertions(+), 196 deletions(-) create mode 100644 docs/DESKTOP_OS.md delete mode 100644 src/components/navigation/AppSidebar.tsx create mode 100644 src/hooks/useRelayStatus.ts create mode 100644 src/os/Desktop.tsx create mode 100644 src/os/apps/registry.ts create mode 100644 src/os/shell/Dock.tsx create mode 100644 src/os/shell/LockScreen.tsx create mode 100644 src/os/shell/MenuBar.tsx create mode 100644 src/os/shell/MobileAppView.tsx create mode 100644 src/os/shell/MobileHome.tsx create mode 100644 src/os/shell/Wallpaper.tsx create mode 100644 src/os/window-manager/Window.tsx create mode 100644 src/os/window-manager/WindowManagerContext.tsx create mode 100644 src/os/window-manager/context.ts create mode 100644 src/os/window-manager/types.ts create mode 100644 src/os/window-manager/useWindowManager.ts diff --git a/docs/DESKTOP_OS.md b/docs/DESKTOP_OS.md new file mode 100644 index 0000000..c61f411 --- /dev/null +++ b/docs/DESKTOP_OS.md @@ -0,0 +1,124 @@ +# Desktop-OS shell + +The site's tools (Explore, Dashboard, My Events, Export Following, Messages) are +presented as "apps" you open into windows on a desktop, with a menu bar and a +dock — see [issue #10](https://github.com/layer-systems/website/issues/10) for +the original design brief. This document explains how it's implemented, for +anyone extending it later. + +## Where things live + +``` +src/os/ + Desktop.tsx Entry point mounted at /os/* — wires everything together + window-manager/ + context.ts WindowManagerApi type + the React context object + WindowManagerContext.tsx Reducer + provider (state, persistence) + useWindowManager.ts Hook to read/dispatch window state + Window.tsx Window chrome: drag, resize, focus, traffic-light buttons + types.ts WindowBounds / WindowState / WindowManagerSnapshot + apps/ + registry.tsx The list of apps (id, title, icon, component, default size) + shell/ + Wallpaper.tsx Layered-strata background + MenuBar.tsx Wordmark, window switcher, relay status, clock, login + Dock.tsx App launcher (bottom dock on desktop, tab bar on mobile) + LockScreen.tsx Nostr-key "unlock" screen shown while logged out + MobileHome.tsx Small-screen home screen (app icon grid) + MobileAppView.tsx Small-screen full-screen app view +``` + +The apps themselves are **not** duplicated — `apps/registry.tsx` wraps the +existing page components (`src/pages/Dashboard.tsx`, `Explore.tsx`, etc.) with +an `embedded` prop that strips their standalone-page chrome (sidebar, sticky +header) so the same component works both inside a window and, if linked to +directly, as a full page. + +## Window manager + +`WindowManagerContext` holds one `WindowState` per open app (position, size, +minimized/maximized, previous bounds for restore) plus a `zOrder` array of app +ids — the last entry is always the focused/topmost window. All mutations go +through a reducer (`OPEN`, `CLOSE`, `FOCUS`, `MINIMIZE`, `TOGGLE_MAXIMIZE`, +`MOVE`, `RESIZE`), so window behavior is easy to reason about and test in +isolation. + +Layout is single-instance per app (opening an already-open app focuses it +rather than spawning a second window) and is persisted to +`localStorage["nostr:os-window-layout"]`, debounced by 200ms, and restored on +mount — so a reload (or the next visit) comes back with the same windows open +in the same place. + +`Window.tsx` implements dragging and resizing with native Pointer Events +(`setPointerCapture`), which works identically for mouse, trackpad, and touch +input — no separate touch handling was needed. Z-index is derived from +`zOrder`, and clicking anywhere in a window (or via the menu bar's "Windows" +switcher) calls `focusApp`, which moves it to the end of `zOrder`. + +## Mobile fallback + +Desktop-style overlapping, draggable windows don't make sense on a phone. +`Desktop.tsx` checks `useIsMobile()` (the existing 768px breakpoint hook) and +swaps the whole window-manager *rendering* — not its state — for a mobile +shell: + +- **`MobileHome`**: an icon-grid "home screen" shown when no app is active. +- **`MobileAppView`**: whichever app is topmost and not minimized renders + full-screen, with a "Home" button that calls `minimizeApp` (so switching + apps doesn't lose their state — same idea as backgrounding an app on iOS). +- **`Dock`** renders as a `compact` bottom tab bar instead of a floating dock. + +Because both layouts share the same `WindowManagerContext`, "minimize" on +mobile is exactly "go home while keeping the app running in the background", +and re-opening it from the dock or home screen resumes it where it left off. +Desktop-only affordances (drag, resize, maximize) simply aren't rendered on +mobile — there's no separate mobile state machine to keep in sync. + +## Deep links + +Legacy routes (`/dashboard`, `/dashboard/events`, `/dashboard/export`, +`/explore`, `/messages`) redirect to `/os/`. `Desktop.tsx`'s +`DeepLinkHandler` matches that path against the app registry, opens the +corresponding app if it isn't already open, and then normalizes the URL back +to `/os` (the window manager's own state is the source of truth for what's +open from then on, not the URL). The public marketing landing page at `/` +stays a normal page outside the shell, with a "Launch the App" / +"Open the Desktop" call to action linking into `/os`. + +## Accessibility + +- Each `Window` is `role="dialog"` with `aria-label` set to the app title, and + receives DOM focus (`tabIndex={-1}` + `.focus()`) whenever it becomes the + focused window, so keyboard/screen-reader users always land somewhere + sensible after switching apps. +- **Cmd/Ctrl+`** cycles focus through open windows (mirrors macOS's + "cycle through windows of the front app" shortcut; Cmd/Ctrl+Tab is reserved + by the OS/browser). +- The menu bar's "Windows" menu is a fully keyboard-navigable dropdown listing + every open app, so window switching never requires a mouse. +- Traffic-light buttons (close/minimize/maximize) all have explicit + `aria-label`s (e.g. "Close Messages") rather than relying on color alone. +- Dock buttons expose `aria-pressed` for their running/focused state. +- The relay-status indicator is in an `aria-live="polite"` region so + connect/disconnect changes are announced. +- The wallpaper's drift animation is disabled under `prefers-reduced-motion`. + +## Nostr touches + +- **Lock screen**: while logged out, `LockScreen` covers the desktop like a + macOS login screen, offering the existing `LoginArea` (Nostr key / + extension / bunker login) to "unlock", or "Continue without an account" to + browse read-only apps as a guest. The guest choice is remembered in + `localStorage["nostr:os-guest-mode"]`. +- **Relay status**: `useRelayStatus` (`src/hooks/useRelayStatus.ts`) opens a + small dedicated WebSocket to the configured relay purely to reflect + connecting/online/offline state in the menu bar (it doesn't participate in + the app's actual Nostr queries, which go through `NPool`/`NostrProvider` as + before). + +## Known v1 simplifications + +- Windows resize from the bottom-right corner only (no edge handles). +- Each app is single-instance (no "open two Explore windows"). +- The menu bar's relay indicator shows connection state only, not a live + event count. diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index e3bb12e..eab07d3 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -1,11 +1,8 @@ -import { BrowserRouter, Route, Routes } from "react-router-dom"; +import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"; import { ScrollToTop } from "./components/ScrollToTop"; import Index from "./pages/Index"; -import { Explore } from "./pages/Explore"; -import { Dashboard } from "./pages/Dashboard"; -import { DashboardEvents } from "./pages/DashboardEvents"; -import { DashboardExport } from "./pages/DashboardExport"; +import { Desktop } from "./os/Desktop"; import { NIP19Page } from "./pages/NIP19Page"; import { Terms } from "./pages/Terms"; import { Privacy } from "./pages/Privacy"; @@ -17,10 +14,15 @@ export function AppRouter() { } /> - } /> - } /> - } /> - } /> + {/* The desktop-OS shell (see docs/DESKTOP_OS.md). /os/ deep-links + into the shell with that app already open, then normalizes to /os. */} + } /> + {/* Legacy page routes now open their app inside the desktop shell. */} + } /> + } /> + } /> + } /> + } /> } /> } /> {/* NIP-19 route for npub1, note1, naddr1, nevent1, nprofile1 */} diff --git a/src/components/navigation/AppSidebar.tsx b/src/components/navigation/AppSidebar.tsx deleted file mode 100644 index 79481d4..0000000 --- a/src/components/navigation/AppSidebar.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { Home, LayoutDashboard, FileText, Download } from 'lucide-react'; -import { Link, useLocation } from 'react-router-dom'; -import { - Sidebar, - SidebarContent, - SidebarFooter, - SidebarGroup, - SidebarGroupContent, - SidebarGroupLabel, - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, -} from '@/components/ui/sidebar'; -import { LoginArea } from '@/components/auth/LoginArea'; - -const navigationItems = [ - { - title: 'Home', - url: '/', - icon: Home, - }, - { - title: 'Dashboard', - url: '/dashboard', - icon: LayoutDashboard, - }, - { - title: 'My Events', - url: '/dashboard/events', - icon: FileText, - }, - { - title: 'Export Following', - url: '/dashboard/export', - icon: Download, - }, -]; - -export function AppSidebar() { - const location = useLocation(); - - return ( - - - - Navigation - - - {navigationItems.map((item) => { - const isActive = location.pathname === item.url; - return ( - - - - - {item.title} - - - - ); - })} - - - - - -
- -
-
-
- ); -} diff --git a/src/hooks/useRelayStatus.ts b/src/hooks/useRelayStatus.ts new file mode 100644 index 0000000..f789b6f --- /dev/null +++ b/src/hooks/useRelayStatus.ts @@ -0,0 +1,71 @@ +import { useEffect, useState } from 'react'; +import { useAppContext } from './useAppContext'; + +export type RelayStatus = 'connecting' | 'online' | 'offline'; + +export interface RelayStatusInfo { + status: RelayStatus; + url?: string; +} + +const RETRY_DELAY_MS = 15000; + +/** + * Tracks the live connection state of the primary configured relay via a + * dedicated lightweight WebSocket, for display in the OS menu bar. + */ +export function useRelayStatus(): RelayStatusInfo { + const { config } = useAppContext(); + const url = config.relayMetadata.relays[0]?.url; + const [status, setStatus] = useState('connecting'); + + useEffect(() => { + if (!url) { + setStatus('offline'); + return; + } + + let cancelled = false; + let socket: WebSocket | undefined; + let retryTimer: ReturnType | undefined; + + const connect = () => { + if (cancelled) return; + setStatus('connecting'); + + try { + socket = new WebSocket(url); + } catch { + if (!cancelled) { + setStatus('offline'); + retryTimer = setTimeout(connect, RETRY_DELAY_MS); + } + return; + } + + socket.addEventListener('open', () => { + if (!cancelled) setStatus('online'); + }); + + socket.addEventListener('close', () => { + if (cancelled) return; + setStatus('offline'); + retryTimer = setTimeout(connect, RETRY_DELAY_MS); + }); + + socket.addEventListener('error', () => { + socket?.close(); + }); + }; + + connect(); + + return () => { + cancelled = true; + if (retryTimer) clearTimeout(retryTimer); + socket?.close(); + }; + }, [url]); + + return { status, url }; +} diff --git a/src/index.css b/src/index.css index 78a8649..1f85492 100644 --- a/src/index.css +++ b/src/index.css @@ -120,4 +120,30 @@ body { @apply bg-background text-foreground; } +} + +@layer components { + .os-wallpaper-layer { + animation: os-wallpaper-drift 40s ease-in-out infinite; + } + + .os-wallpaper-layer-slow { + animation-duration: 60s; + animation-direction: reverse; + } +} + +@keyframes os-wallpaper-drift { + 0%, 100% { + transform: translateX(-2%) rotate(var(--tw-rotate, 0deg)); + } + 50% { + transform: translateX(2%) rotate(var(--tw-rotate, 0deg)); + } +} + +@media (prefers-reduced-motion: reduce) { + .os-wallpaper-layer { + animation: none; + } } \ No newline at end of file diff --git a/src/os/Desktop.tsx b/src/os/Desktop.tsx new file mode 100644 index 0000000..0855611 --- /dev/null +++ b/src/os/Desktop.tsx @@ -0,0 +1,138 @@ +import { useEffect } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { useSeoMeta } from '@unhead/react'; +import { useIsMobile } from '@/hooks/useIsMobile'; +import { useLoggedInAccounts } from '@/hooks/useLoggedInAccounts'; +import { useLocalStorage } from '@/hooks/useLocalStorage'; +import { WindowManagerProvider } from './window-manager/WindowManagerContext'; +import { useWindowManager } from './window-manager/useWindowManager'; +import { Window, MENU_BAR_HEIGHT } from './window-manager/Window'; +import { Wallpaper } from './shell/Wallpaper'; +import { MenuBar } from './shell/MenuBar'; +import { Dock } from './shell/Dock'; +import { LockScreen } from './shell/LockScreen'; +import { MobileHome } from './shell/MobileHome'; +import { MobileAppView } from './shell/MobileAppView'; +import { findAppById, findAppByPath, defaultBoundsFor } from './apps/registry'; + +const GUEST_MODE_KEY = 'nostr:os-guest-mode'; + +function DeepLinkHandler() { + const location = useLocation(); + const navigate = useNavigate(); + const { windows, openApp } = useWindowManager(); + + useEffect(() => { + const subPath = location.pathname.replace(/^\/os\/?/, ''); + if (!subPath) return; + const app = findAppByPath(subPath); + if (app) { + if (!windows[app.id]) { + openApp(app.id, defaultBoundsFor(app, Object.keys(windows).length)); + } + navigate('/os', { replace: true }); + } + // Intentionally only reacts to the initial pathname for this deep link; + // once handled we normalize the URL back to /os. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [location.pathname]); + + return null; +} + +function KeyboardShortcuts() { + const { cycleFocus } = useWindowManager(); + + useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + // Cmd/Ctrl+` cycles focus between open windows, mirroring macOS's + // "cycle through windows of the front app" shortcut (Cmd+Tab is + // reserved by the OS/browser, so backtick is used instead). + if ((event.metaKey || event.ctrlKey) && event.key === '`') { + event.preventDefault(); + cycleFocus(); + } + } + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [cycleFocus]); + + return null; +} + +function DesktopWindows() { + const { windows, zOrder } = useWindowManager(); + + return ( +
+ {zOrder.map((appId) => { + const app = findAppById(appId); + const win = windows[appId]; + if (!app || !win) return null; + const Component = app.Component; + return ( + + + + ); + })} +
+ ); +} + +function MobileDesktop() { + const { windows, zOrder } = useWindowManager(); + const activeAppId = [...zOrder].reverse().find((id) => windows[id] && !windows[id].minimized); + + return ( +
+ +
+ {activeAppId ? : } +
+ +
+ ); +} + +function DesktopShell() { + const isMobile = useIsMobile(); + const { currentUser } = useLoggedInAccounts(); + const [guestMode, setGuestMode] = useLocalStorage(GUEST_MODE_KEY, false); + const locked = !currentUser && !guestMode; + + return ( + <> + + + + {isMobile ? ( + + ) : ( + <> + + + + + )} + {locked && setGuestMode(true)} />} + + ); +} + +export function Desktop() { + useSeoMeta({ + title: 'LAYER.systems — Desktop', + description: 'Your Nostr relay desktop: Explore, Stats, Finder, Export and Messages, all in one windowed workspace.', + }); + + return ( +
+ + + +
+ ); +} + +export default Desktop; diff --git a/src/os/apps/registry.ts b/src/os/apps/registry.ts new file mode 100644 index 0000000..a0c8dc3 --- /dev/null +++ b/src/os/apps/registry.ts @@ -0,0 +1,92 @@ +import { Compass, LayoutDashboard, FolderSearch, DownloadCloud, MessageCircle } from 'lucide-react'; +import type { LucideIcon } from 'lucide-react'; +import type { ComponentType } from 'react'; +import type { WindowBounds } from '../window-manager/types'; +import { Explore } from '@/pages/Explore'; +import { Dashboard } from '@/pages/Dashboard'; +import { DashboardEvents } from '@/pages/DashboardEvents'; +import { DashboardExport } from '@/pages/DashboardExport'; +import { Messages } from '@/pages/Messages'; + +export interface AppDefinition { + /** Stable identifier used as the window/dock key. */ + id: string; + /** Window title / dock tooltip. */ + title: string; + /** Short description used in the mobile home screen and dock tooltips. */ + description: string; + /** Path segment matched under /os/* for deep linking, e.g. "dashboard/events". */ + path: string; + icon: LucideIcon; + Component: ComponentType; + defaultSize: { width: number; height: number }; +} + +function defaultBoundsFor(app: AppDefinition, index: number): WindowBounds { + const offset = index * 28; + return { + x: 96 + offset, + y: 72 + offset, + width: app.defaultSize.width, + height: app.defaultSize.height, + }; +} + +export const APPS: AppDefinition[] = [ + { + id: 'explore', + title: 'Browser', + description: 'Discover notes and profiles on the relay', + path: 'explore', + icon: Compass, + Component: Explore, + defaultSize: { width: 720, height: 620 }, + }, + { + id: 'dashboard', + title: 'Stats', + description: 'Your Nostr activity at a glance', + path: 'dashboard', + icon: LayoutDashboard, + Component: Dashboard, + defaultSize: { width: 820, height: 640 }, + }, + { + id: 'events', + title: 'Finder', + description: 'Browse and manage your published events', + path: 'dashboard/events', + icon: FolderSearch, + Component: DashboardEvents, + defaultSize: { width: 780, height: 600 }, + }, + { + id: 'export', + title: 'Export', + description: 'Back up your following list', + path: 'dashboard/export', + icon: DownloadCloud, + Component: DashboardExport, + defaultSize: { width: 560, height: 620 }, + }, + { + id: 'messages', + title: 'Messages', + description: 'Private encrypted Nostr direct messages', + path: 'messages', + icon: MessageCircle, + Component: Messages, + defaultSize: { width: 760, height: 600 }, + }, +]; + +export function findAppById(appId: string): AppDefinition | undefined { + return APPS.find((app) => app.id === appId); +} + +export function findAppByPath(path: string): AppDefinition | undefined { + const normalized = path.replace(/^\/+|\/+$/g, ''); + return APPS.find((app) => app.path === normalized); +} + +export { defaultBoundsFor }; diff --git a/src/os/shell/Dock.tsx b/src/os/shell/Dock.tsx new file mode 100644 index 0000000..90bf265 --- /dev/null +++ b/src/os/shell/Dock.tsx @@ -0,0 +1,78 @@ +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; +import { useWindowManager } from '../window-manager/useWindowManager'; +import { APPS, defaultBoundsFor } from '../apps/registry'; + +interface DockProps { + /** Renders as a bottom tab bar with larger touch targets for small screens. */ + compact?: boolean; +} + +export function Dock({ compact = false }: DockProps) { + const { windows, openApp, focusApp } = useWindowManager(); + + const handleActivate = (appId: string, index: number) => { + if (windows[appId]) { + focusApp(appId); + } else { + const app = APPS[index]; + openApp(appId, defaultBoundsFor(app, Object.keys(windows).length)); + } + }; + + return ( + + ); +} diff --git a/src/os/shell/LockScreen.tsx b/src/os/shell/LockScreen.tsx new file mode 100644 index 0000000..7c374b8 --- /dev/null +++ b/src/os/shell/LockScreen.tsx @@ -0,0 +1,59 @@ +import { useEffect, useState } from 'react'; +import { Layers } from 'lucide-react'; +import { LoginArea } from '@/components/auth/LoginArea'; +import { useRelayStatus } from '@/hooks/useRelayStatus'; + +interface LockScreenProps { + onContinueAsGuest: () => void; +} + +export function LockScreen({ onContinueAsGuest }: LockScreenProps) { + const [now, setNow] = useState(() => new Date()); + const { status } = useRelayStatus(); + + useEffect(() => { + const interval = setInterval(() => setNow(new Date()), 30_000); + return () => clearInterval(interval); + }, []); + + return ( +
+
+

+ {now.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })} +

+

+ {now.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric' })} +

+
+ +
+
+
+

LAYER.systems

+

+ Unlock with your Nostr key to sync your dashboard, events, and messages. +

+

+ Relay {status === 'online' ? 'connected' : status === 'connecting' ? 'connecting…' : 'offline'} +

+
+ + + + +
+ ); +} diff --git a/src/os/shell/MenuBar.tsx b/src/os/shell/MenuBar.tsx new file mode 100644 index 0000000..9eacf4d --- /dev/null +++ b/src/os/shell/MenuBar.tsx @@ -0,0 +1,128 @@ +import { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { Layers, ChevronDown } from 'lucide-react'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { LoginArea } from '@/components/auth/LoginArea'; +import { useRelayStatus } from '@/hooks/useRelayStatus'; +import { cn } from '@/lib/utils'; +import { useWindowManager } from '../window-manager/useWindowManager'; +import { findAppById } from '../apps/registry'; +import { MENU_BAR_HEIGHT } from '../window-manager/Window'; + +function relayStatusLabel(status: 'connecting' | 'online' | 'offline') { + switch (status) { + case 'online': + return 'Connected'; + case 'connecting': + return 'Connecting…'; + case 'offline': + return 'Offline'; + } +} + +function relayStatusColor(status: 'connecting' | 'online' | 'offline') { + switch (status) { + case 'online': + return 'bg-green-500'; + case 'connecting': + return 'bg-yellow-500 animate-pulse'; + case 'offline': + return 'bg-destructive'; + } +} + +function Clock() { + const [now, setNow] = useState(() => new Date()); + + useEffect(() => { + const interval = setInterval(() => setNow(new Date()), 30_000); + return () => clearInterval(interval); + }, []); + + return ( + + {now.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })} + + ); +} + +export function MenuBar() { + const { windows, zOrder, focusedAppId, focusApp } = useWindowManager(); + const { status, url } = useRelayStatus(); + const openAppIds = zOrder.filter((id) => windows[id]); + const relayHost = url?.replace(/^wss?:\/\//, '') ?? 'no relay'; + + return ( +
+
+ +
+ +
+
+ +
+ + {/* The menu bar is compact real estate, so only show "Log in" here — + LoginDialog itself offers a path to sign up. */} + +
+
+ ); +} diff --git a/src/os/shell/MobileAppView.tsx b/src/os/shell/MobileAppView.tsx new file mode 100644 index 0000000..96ac24f --- /dev/null +++ b/src/os/shell/MobileAppView.tsx @@ -0,0 +1,38 @@ +import { ChevronLeft } from 'lucide-react'; +import { useWindowManager } from '../window-manager/useWindowManager'; +import { findAppById } from '../apps/registry'; + +interface MobileAppViewProps { + appId: string; +} + +export function MobileAppView({ appId }: MobileAppViewProps) { + const { minimizeApp } = useWindowManager(); + const app = findAppById(appId); + if (!app) return null; + const Icon = app.icon; + const Component = app.Component; + + return ( +
+
+ +
+
+
+
+ +
+
+ ); +} diff --git a/src/os/shell/MobileHome.tsx b/src/os/shell/MobileHome.tsx new file mode 100644 index 0000000..67464e7 --- /dev/null +++ b/src/os/shell/MobileHome.tsx @@ -0,0 +1,44 @@ +import { useWindowManager } from '../window-manager/useWindowManager'; +import { APPS, defaultBoundsFor } from '../apps/registry'; + +export function MobileHome() { + const { windows, openApp, focusApp } = useWindowManager(); + + const handleOpen = (index: number) => { + const app = APPS[index]; + if (windows[app.id]) { + focusApp(app.id); + } else { + openApp(app.id, defaultBoundsFor(app, Object.keys(windows).length)); + } + }; + + return ( +
+

Tap an app to open it

+
+ {APPS.map((app, index) => { + const Icon = app.icon; + const isOpen = !!windows[app.id]; + return ( + + ); + })} +
+
+ ); +} diff --git a/src/os/shell/Wallpaper.tsx b/src/os/shell/Wallpaper.tsx new file mode 100644 index 0000000..13ade08 --- /dev/null +++ b/src/os/shell/Wallpaper.tsx @@ -0,0 +1,16 @@ +/** + * Layered-strata wallpaper echoing the LAYER.systems brand motif: soft + * horizontal bands drifting slowly behind the desktop. Respects + * prefers-reduced-motion by disabling the drift animation via CSS. + */ +export function Wallpaper() { + return ( +