feat: rework the app as a windowed desktop-OS shell (#10)

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto
This commit is contained in:
2026-09-05 21:41:31 +02:00
parent 4acdb31b8a
commit 3f9676a2df
24 changed files with 1341 additions and 196 deletions

124
docs/DESKTOP_OS.md Normal file
View File

@@ -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/<app-path>`. `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.

View File

@@ -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() {
<ScrollToTop />
<Routes>
<Route path="/" element={<Index />} />
<Route path="/explore" element={<Explore />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/dashboard/events" element={<DashboardEvents />} />
<Route path="/dashboard/export" element={<DashboardExport />} />
{/* The desktop-OS shell (see docs/DESKTOP_OS.md). /os/<app-path> deep-links
into the shell with that app already open, then normalizes to /os. */}
<Route path="/os/*" element={<Desktop />} />
{/* Legacy page routes now open their app inside the desktop shell. */}
<Route path="/explore" element={<Navigate to="/os/explore" replace />} />
<Route path="/dashboard" element={<Navigate to="/os/dashboard" replace />} />
<Route path="/dashboard/events" element={<Navigate to="/os/dashboard/events" replace />} />
<Route path="/dashboard/export" element={<Navigate to="/os/dashboard/export" replace />} />
<Route path="/messages" element={<Navigate to="/os/messages" replace />} />
<Route path="/terms" element={<Terms />} />
<Route path="/privacy" element={<Privacy />} />
{/* NIP-19 route for npub1, note1, naddr1, nevent1, nprofile1 */}

View File

@@ -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 (
<Sidebar>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Navigation</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{navigationItems.map((item) => {
const isActive = location.pathname === item.url;
return (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild isActive={isActive}>
<Link to={item.url}>
<item.icon />
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<div className="p-2">
<LoginArea className="w-full" />
</div>
</SidebarFooter>
</Sidebar>
);
}

View File

@@ -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<RelayStatus>('connecting');
useEffect(() => {
if (!url) {
setStatus('offline');
return;
}
let cancelled = false;
let socket: WebSocket | undefined;
let retryTimer: ReturnType<typeof setTimeout> | 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 };
}

View File

@@ -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;
}
}

138
src/os/Desktop.tsx Normal file
View File

@@ -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 (
<div className="fixed inset-0 z-10">
{zOrder.map((appId) => {
const app = findAppById(appId);
const win = windows[appId];
if (!app || !win) return null;
const Component = app.Component;
return (
<Window key={appId} appId={appId} title={app.title} icon={app.icon}>
<Component />
</Window>
);
})}
</div>
);
}
function MobileDesktop() {
const { windows, zOrder } = useWindowManager();
const activeAppId = [...zOrder].reverse().find((id) => windows[id] && !windows[id].minimized);
return (
<div className="fixed inset-0 flex flex-col">
<MenuBar />
<div className="min-h-0 flex-1 overflow-hidden" style={{ marginTop: MENU_BAR_HEIGHT }}>
{activeAppId ? <MobileAppView appId={activeAppId} /> : <MobileHome />}
</div>
<Dock compact />
</div>
);
}
function DesktopShell() {
const isMobile = useIsMobile();
const { currentUser } = useLoggedInAccounts();
const [guestMode, setGuestMode] = useLocalStorage(GUEST_MODE_KEY, false);
const locked = !currentUser && !guestMode;
return (
<>
<Wallpaper />
<DeepLinkHandler />
<KeyboardShortcuts />
{isMobile ? (
<MobileDesktop />
) : (
<>
<MenuBar />
<DesktopWindows />
<Dock />
</>
)}
{locked && <LockScreen onContinueAsGuest={() => 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 (
<div className="fixed inset-0 overflow-hidden overscroll-none">
<WindowManagerProvider>
<DesktopShell />
</WindowManagerProvider>
</div>
);
}
export default Desktop;

92
src/os/apps/registry.ts Normal file
View File

@@ -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 };

78
src/os/shell/Dock.tsx Normal file
View File

@@ -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 (
<nav
aria-label="App dock"
className={cn(
'fixed inset-x-0 bottom-0 z-40 flex items-end justify-center',
compact ? 'pb-[env(safe-area-inset-bottom)]' : 'pb-2',
)}
>
<div
className={cn(
'flex items-end gap-1 rounded-2xl border border-border/60 bg-background/85 backdrop-blur-md shadow-lg',
compact ? 'w-full justify-around rounded-none border-x-0 border-b-0 py-2' : 'px-2 py-1.5 mb-2',
)}
>
{APPS.map((app, index) => {
const Icon = app.icon;
const isOpen = !!windows[app.id];
const isMinimized = windows[app.id]?.minimized;
return (
<Tooltip key={app.id}>
<TooltipTrigger asChild>
<button
type="button"
aria-label={isOpen ? `Switch to ${app.title}` : `Open ${app.title}`}
aria-pressed={isOpen && !isMinimized}
onClick={() => handleActivate(app.id, index)}
className={cn(
'group relative flex flex-col items-center justify-center rounded-xl transition-transform hover:-translate-y-1 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
compact ? 'h-12 w-14' : 'h-12 w-12 hover:bg-accent/50',
)}
>
<Icon className={cn('text-primary', compact ? 'h-6 w-6' : 'h-6 w-6')} aria-hidden="true" />
{compact && <span className="mt-0.5 text-[10px] text-muted-foreground">{app.title}</span>}
{isOpen && (
<span
className={cn(
'absolute rounded-full bg-primary',
compact ? 'bottom-0.5 h-1 w-1' : '-bottom-1 h-1 w-1',
)}
aria-hidden="true"
/>
)}
</button>
</TooltipTrigger>
{!compact && (
<TooltipContent side="top">
<p>{app.title}</p>
</TooltipContent>
)}
</Tooltip>
);
})}
</div>
</nav>
);
}

View File

@@ -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 (
<div
role="dialog"
aria-modal="true"
aria-label="Unlock LAYER.systems"
className="fixed inset-0 z-50 flex flex-col items-center justify-center gap-8 bg-background/80 backdrop-blur-xl px-4 text-center"
>
<div className="space-y-1">
<p className="text-6xl sm:text-7xl font-light tabular-nums">
{now.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}
</p>
<p className="text-sm text-muted-foreground">
{now.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric' })}
</p>
</div>
<div className="flex flex-col items-center gap-3">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
<Layers className="h-8 w-8 text-primary" aria-hidden="true" />
</div>
<h1 className="text-lg font-semibold">LAYER.systems</h1>
<p className="max-w-xs text-sm text-muted-foreground">
Unlock with your Nostr key to sync your dashboard, events, and messages.
</p>
<p className="text-xs text-muted-foreground">
Relay {status === 'online' ? 'connected' : status === 'connecting' ? 'connecting…' : 'offline'}
</p>
</div>
<LoginArea className="w-full max-w-xs" />
<button
type="button"
onClick={onContinueAsGuest}
className="text-xs text-muted-foreground underline underline-offset-4 hover:text-foreground"
>
Continue without an account
</button>
</div>
);
}

128
src/os/shell/MenuBar.tsx Normal file
View File

@@ -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 (
<span className="tabular-nums text-xs text-muted-foreground hidden sm:inline">
{now.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}
</span>
);
}
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 (
<header
role="banner"
style={{ height: MENU_BAR_HEIGHT }}
className="fixed inset-x-0 top-0 z-40 flex items-center justify-between border-b border-border/60 bg-background/85 px-3 backdrop-blur-md"
>
<div className="flex items-center gap-1 min-w-0">
<Link to="/" className="flex items-center gap-1.5 px-1.5 font-semibold tracking-tight shrink-0" aria-label="LAYER.systems home">
<Layers className="h-4 w-4 text-primary" aria-hidden="true" />
<span className="text-sm">LAYER</span>
</Link>
<DropdownMenu>
<DropdownMenuTrigger
className="flex items-center gap-1 rounded px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
aria-label="Open windows"
>
Windows
{openAppIds.length > 0 && (
<span className="rounded-full bg-primary/10 px-1.5 text-[10px] font-medium text-primary">
{openAppIds.length}
</span>
)}
<ChevronDown className="h-3 w-3" aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuLabel>Open apps</DropdownMenuLabel>
<DropdownMenuSeparator />
{openAppIds.length === 0 && (
<div className="px-2 py-1.5 text-xs text-muted-foreground">No apps open try the Dock</div>
)}
{openAppIds
.slice()
.reverse()
.map((appId) => {
const app = findAppById(appId);
if (!app) return null;
const win = windows[appId];
const Icon = app.icon;
return (
<DropdownMenuItem key={appId} onSelect={() => focusApp(appId)} className="gap-2">
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
<span className={cn('flex-1', focusedAppId === appId && 'font-medium')}>{app.title}</span>
{win.minimized && <span className="text-[10px] text-muted-foreground">minimized</span>}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex items-center gap-2 text-xs" aria-live="polite">
<span className={cn('h-2 w-2 rounded-full shrink-0', relayStatusColor(status))} aria-hidden="true" />
<span className="text-muted-foreground hidden sm:inline">
{relayStatusLabel(status)} · {relayHost}
</span>
</div>
<div className="flex items-center gap-3 shrink-0">
<Clock />
{/* The menu bar is compact real estate, so only show "Log in" here —
LoginDialog itself offers a path to sign up. */}
<LoginArea className="max-w-[140px] scale-90 origin-right [&_button:nth-of-type(2)]:hidden" />
</div>
</header>
);
}

View File

@@ -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 (
<div className="flex h-full w-full flex-col bg-background">
<div className="flex h-11 shrink-0 items-center gap-2 border-b px-2">
<button
type="button"
onClick={() => minimizeApp(appId)}
className="flex items-center gap-1 rounded px-2 py-1 text-sm text-primary focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
aria-label="Back to home screen"
>
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
Home
</button>
<div className="flex flex-1 items-center justify-center gap-1.5 pr-14 text-sm font-medium">
<Icon className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true" />
{app.title}
</div>
</div>
<div className="min-h-0 flex-1 overflow-hidden">
<Component />
</div>
</div>
);
}

View File

@@ -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 (
<div className="flex h-full w-full flex-col items-center justify-center gap-1 px-6">
<h1 className="mb-6 text-sm font-medium text-muted-foreground">Tap an app to open it</h1>
<div className="grid grid-cols-3 gap-x-6 gap-y-8">
{APPS.map((app, index) => {
const Icon = app.icon;
const isOpen = !!windows[app.id];
return (
<button
key={app.id}
type="button"
onClick={() => handleOpen(index)}
className="flex flex-col items-center gap-2 focus-visible:outline-none"
aria-label={isOpen ? `Resume ${app.title}` : `Open ${app.title}`}
>
<span className="relative flex h-16 w-16 items-center justify-center rounded-2xl border border-border/60 bg-card shadow-md">
<Icon className="h-7 w-7 text-primary" aria-hidden="true" />
{isOpen && (
<span className="absolute -bottom-1 h-1.5 w-1.5 rounded-full bg-primary" aria-hidden="true" />
)}
</span>
<span className="text-xs text-foreground">{app.title}</span>
</button>
);
})}
</div>
</div>
);
}

View File

@@ -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 (
<div className="fixed inset-0 z-0 overflow-hidden bg-background" aria-hidden="true">
<div className="absolute inset-0 bg-gradient-to-b from-background via-background to-muted/30" />
<div className="os-wallpaper-layer absolute -inset-x-1/4 top-[8%] h-40 rotate-[-4deg] bg-gradient-to-r from-primary/10 via-primary/5 to-transparent blur-2xl" />
<div className="os-wallpaper-layer os-wallpaper-layer-slow absolute -inset-x-1/4 top-[32%] h-56 rotate-[3deg] bg-gradient-to-r from-transparent via-primary/10 to-primary/5 blur-3xl" />
<div className="os-wallpaper-layer absolute -inset-x-1/4 top-[58%] h-48 rotate-[-2deg] bg-gradient-to-r from-primary/5 via-primary/10 to-transparent blur-2xl" />
<div className="os-wallpaper-layer os-wallpaper-layer-slow absolute -inset-x-1/4 top-[82%] h-40 rotate-[4deg] bg-gradient-to-r from-transparent via-primary/5 to-primary/10 blur-3xl" />
</div>
);
}

View File

@@ -0,0 +1,166 @@
import { useEffect, useRef } from 'react';
import type { PointerEvent as ReactPointerEvent, ReactNode } from 'react';
import type { LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useWindowManager } from './useWindowManager';
export const MENU_BAR_HEIGHT = 36;
export const DOCK_RESERVED_HEIGHT = 88;
interface WindowProps {
appId: string;
title: string;
icon: LucideIcon;
minWidth?: number;
minHeight?: number;
children: ReactNode;
}
export function Window({ appId, title, icon: Icon, minWidth = 320, minHeight = 240, children }: WindowProps) {
const { windows, focusedAppId, focusApp, closeApp, minimizeApp, toggleMaximize, moveApp, resizeApp, zIndexFor } =
useWindowManager();
const win = windows[appId];
const rootRef = useRef<HTMLDivElement>(null);
const isFocused = focusedAppId === appId;
useEffect(() => {
if (isFocused) {
rootRef.current?.focus({ preventScroll: true });
}
}, [isFocused]);
if (!win || win.minimized) return null;
const maximizedBounds = () => ({
x: 8,
y: MENU_BAR_HEIGHT + 8,
width: window.innerWidth - 16,
height: window.innerHeight - MENU_BAR_HEIGHT - DOCK_RESERVED_HEIGHT,
});
const handleTitleBarPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
if (win.maximized) return;
if ((e.target as HTMLElement).closest('button')) return;
focusApp(appId);
const startX = e.clientX;
const startY = e.clientY;
const originX = win.x;
const originY = win.y;
const target = e.currentTarget;
target.setPointerCapture(e.pointerId);
const handleMove = (moveEvent: PointerEvent) => {
const dx = moveEvent.clientX - startX;
const dy = moveEvent.clientY - startY;
const nextX = Math.max(-win.width + 120, originX + dx);
const nextY = Math.max(MENU_BAR_HEIGHT, originY + dy);
moveApp(appId, nextX, nextY);
};
const handleUp = () => {
target.releasePointerCapture(e.pointerId);
target.removeEventListener('pointermove', handleMove);
target.removeEventListener('pointerup', handleUp);
};
target.addEventListener('pointermove', handleMove);
target.addEventListener('pointerup', handleUp);
};
const handleResizePointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
if (win.maximized) return;
e.stopPropagation();
focusApp(appId);
const startX = e.clientX;
const startY = e.clientY;
const originWidth = win.width;
const originHeight = win.height;
const target = e.currentTarget;
target.setPointerCapture(e.pointerId);
const handleMove = (moveEvent: PointerEvent) => {
const dx = moveEvent.clientX - startX;
const dy = moveEvent.clientY - startY;
resizeApp(appId, {
x: win.x,
y: win.y,
width: Math.max(minWidth, originWidth + dx),
height: Math.max(minHeight, originHeight + dy),
});
};
const handleUp = () => {
target.releasePointerCapture(e.pointerId);
target.removeEventListener('pointermove', handleMove);
target.removeEventListener('pointerup', handleUp);
};
target.addEventListener('pointermove', handleMove);
target.addEventListener('pointerup', handleUp);
};
return (
<div
ref={rootRef}
role="dialog"
aria-label={title}
tabIndex={-1}
onPointerDown={() => !isFocused && focusApp(appId)}
className={cn(
'absolute flex flex-col rounded-lg border bg-card text-card-foreground shadow-2xl outline-none transition-shadow',
isFocused ? 'ring-1 ring-primary/40 shadow-primary/10' : 'opacity-95',
)}
style={{
left: win.x,
top: win.y,
width: win.width,
height: win.height,
zIndex: zIndexFor(appId),
}}
>
<div
onPointerDown={handleTitleBarPointerDown}
onDoubleClick={() => toggleMaximize(appId, maximizedBounds())}
className={cn(
'flex h-9 shrink-0 items-center gap-2 rounded-t-lg border-b px-3 select-none',
win.maximized ? '' : 'cursor-grab active:cursor-grabbing',
isFocused ? 'bg-muted/80' : 'bg-muted/40',
)}
>
<div className="flex items-center gap-1.5">
<button
type="button"
aria-label={`Close ${title}`}
onClick={() => closeApp(appId)}
className="h-3 w-3 rounded-full bg-destructive/80 hover:bg-destructive transition-colors"
/>
<button
type="button"
aria-label={`Minimize ${title}`}
onClick={() => minimizeApp(appId)}
className="h-3 w-3 rounded-full bg-yellow-500/80 hover:bg-yellow-500 transition-colors"
/>
<button
type="button"
aria-label={win.maximized ? `Restore ${title}` : `Maximize ${title}`}
onClick={() => toggleMaximize(appId, maximizedBounds())}
className="h-3 w-3 rounded-full bg-green-500/80 hover:bg-green-500 transition-colors"
/>
</div>
<Icon className="h-3.5 w-3.5 text-muted-foreground shrink-0" aria-hidden="true" />
<span className="truncate text-xs font-medium">{title}</span>
</div>
<div className="min-h-0 flex-1 overflow-hidden rounded-b-lg">{children}</div>
{!win.maximized && (
<div
onPointerDown={handleResizePointerDown}
role="presentation"
className="absolute bottom-0 right-0 h-4 w-4 cursor-nwse-resize touch-none"
>
<svg viewBox="0 0 16 16" className="h-full w-full text-muted-foreground/50">
<path d="M15 1 L1 15 M15 7 L7 15 M15 13 L13 15" stroke="currentColor" strokeWidth="1.5" fill="none" />
</svg>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,204 @@
import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react';
import type { ReactNode } from 'react';
import type { WindowBounds, WindowManagerSnapshot } from './types';
import { WindowManagerContext, type WindowManagerApi } from './context';
const STORAGE_KEY = 'nostr:os-window-layout';
const BASE_Z = 20;
type Action =
| { type: 'OPEN'; appId: string; defaultBounds: WindowBounds }
| { type: 'CLOSE'; appId: string }
| { type: 'FOCUS'; appId: string }
| { type: 'MINIMIZE'; appId: string }
| { type: 'TOGGLE_MAXIMIZE'; appId: string; viewport: WindowBounds }
| { type: 'MOVE'; appId: string; x: number; y: number }
| { type: 'RESIZE'; appId: string; bounds: WindowBounds }
| { type: 'RESTORE'; snapshot: WindowManagerSnapshot };
function reducer(state: WindowManagerSnapshot, action: Action): WindowManagerSnapshot {
switch (action.type) {
case 'RESTORE':
return action.snapshot;
case 'OPEN': {
const existing = state.windows[action.appId];
if (existing) {
return {
windows: {
...state.windows,
[action.appId]: { ...existing, minimized: false },
},
zOrder: [...state.zOrder.filter((id) => id !== action.appId), action.appId],
};
}
return {
windows: {
...state.windows,
[action.appId]: {
appId: action.appId,
...action.defaultBounds,
minimized: false,
maximized: false,
},
},
zOrder: [...state.zOrder, action.appId],
};
}
case 'CLOSE': {
if (!state.windows[action.appId]) return state;
const { [action.appId]: _removed, ...rest } = state.windows;
return {
windows: rest,
zOrder: state.zOrder.filter((id) => id !== action.appId),
};
}
case 'FOCUS': {
if (!state.windows[action.appId]) return state;
const win = state.windows[action.appId];
return {
windows: win.minimized
? { ...state.windows, [action.appId]: { ...win, minimized: false } }
: state.windows,
zOrder: [...state.zOrder.filter((id) => id !== action.appId), action.appId],
};
}
case 'MINIMIZE': {
const win = state.windows[action.appId];
if (!win) return state;
return {
...state,
windows: { ...state.windows, [action.appId]: { ...win, minimized: true } },
};
}
case 'TOGGLE_MAXIMIZE': {
const win = state.windows[action.appId];
if (!win) return state;
if (win.maximized) {
const restored = win.prevBounds ?? { x: 80, y: 80, width: 640, height: 480 };
return {
...state,
windows: {
...state.windows,
[action.appId]: { ...win, ...restored, maximized: false, prevBounds: undefined },
},
};
}
return {
...state,
windows: {
...state.windows,
[action.appId]: {
...win,
maximized: true,
prevBounds: { x: win.x, y: win.y, width: win.width, height: win.height },
...action.viewport,
},
},
};
}
case 'MOVE': {
const win = state.windows[action.appId];
if (!win || win.maximized) return state;
return {
...state,
windows: { ...state.windows, [action.appId]: { ...win, x: action.x, y: action.y } },
};
}
case 'RESIZE': {
const win = state.windows[action.appId];
if (!win) return state;
return {
...state,
windows: { ...state.windows, [action.appId]: { ...win, ...action.bounds, maximized: false, prevBounds: undefined } },
};
}
default:
return state;
}
}
export function WindowManagerProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(reducer, { windows: {}, zOrder: [] });
const hydrated = useRef(false);
useEffect(() => {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const snapshot = JSON.parse(raw) as WindowManagerSnapshot;
dispatch({ type: 'RESTORE', snapshot });
}
} catch (error) {
console.warn('Failed to restore OS window layout:', error);
} finally {
hydrated.current = true;
}
}, []);
useEffect(() => {
if (!hydrated.current) return;
const timer = setTimeout(() => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch (error) {
console.warn('Failed to persist OS window layout:', error);
}
}, 200);
return () => clearTimeout(timer);
}, [state]);
const openApp = useCallback((appId: string, defaultBounds: WindowBounds) => {
dispatch({ type: 'OPEN', appId, defaultBounds });
}, []);
const closeApp = useCallback((appId: string) => dispatch({ type: 'CLOSE', appId }), []);
const focusApp = useCallback((appId: string) => dispatch({ type: 'FOCUS', appId }), []);
const minimizeApp = useCallback((appId: string) => dispatch({ type: 'MINIMIZE', appId }), []);
const toggleMaximize = useCallback(
(appId: string, viewport: WindowBounds) => dispatch({ type: 'TOGGLE_MAXIMIZE', appId, viewport }),
[],
);
const moveApp = useCallback((appId: string, x: number, y: number) => dispatch({ type: 'MOVE', appId, x, y }), []);
const resizeApp = useCallback((appId: string, bounds: WindowBounds) => dispatch({ type: 'RESIZE', appId, bounds }), []);
const cycleFocus = useCallback(() => {
// Focusing the least-recently-used window moves it to the end of zOrder,
// so repeated calls round-robin through every open window (Cmd/Ctrl+` equivalent).
if (state.zOrder.length < 2) return;
dispatch({ type: 'FOCUS', appId: state.zOrder[0] });
}, [state.zOrder]);
const focusedAppId = state.zOrder[state.zOrder.length - 1];
const zIndexFor = useCallback(
(appId: string) => BASE_Z + Math.max(0, state.zOrder.indexOf(appId)),
[state.zOrder],
);
const value = useMemo<WindowManagerApi>(
() => ({
windows: state.windows,
zOrder: state.zOrder,
focusedAppId,
openApp,
closeApp,
focusApp,
minimizeApp,
toggleMaximize,
moveApp,
resizeApp,
cycleFocus,
zIndexFor,
}),
[state.windows, state.zOrder, focusedAppId, openApp, closeApp, focusApp, minimizeApp, toggleMaximize, moveApp, resizeApp, cycleFocus, zIndexFor],
);
return <WindowManagerContext.Provider value={value}>{children}</WindowManagerContext.Provider>;
}

View File

@@ -0,0 +1,19 @@
import { createContext } from 'react';
import type { WindowBounds, WindowState } from './types';
export interface WindowManagerApi {
windows: Record<string, WindowState>;
zOrder: string[];
focusedAppId: string | undefined;
openApp: (appId: string, defaultBounds: WindowBounds) => void;
closeApp: (appId: string) => void;
focusApp: (appId: string) => void;
minimizeApp: (appId: string) => void;
toggleMaximize: (appId: string, viewport: WindowBounds) => void;
moveApp: (appId: string, x: number, y: number) => void;
resizeApp: (appId: string, bounds: WindowBounds) => void;
cycleFocus: () => void;
zIndexFor: (appId: string) => number;
}
export const WindowManagerContext = createContext<WindowManagerApi | undefined>(undefined);

View File

@@ -0,0 +1,19 @@
export interface WindowBounds {
x: number;
y: number;
width: number;
height: number;
}
export interface WindowState extends WindowBounds {
appId: string;
minimized: boolean;
maximized: boolean;
/** Bounds to restore to when un-maximizing. */
prevBounds?: WindowBounds;
}
export interface WindowManagerSnapshot {
windows: Record<string, WindowState>;
zOrder: string[];
}

View File

@@ -0,0 +1,10 @@
import { useContext } from 'react';
import { WindowManagerContext, type WindowManagerApi } from './context';
export function useWindowManager(): WindowManagerApi {
const context = useContext(WindowManagerContext);
if (!context) {
throw new Error('useWindowManager must be used within a WindowManagerProvider');
}
return context;
}

View File

@@ -1,5 +1,3 @@
import { SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar';
import { AppSidebar } from '@/components/navigation/AppSidebar';
import { DashboardStats } from '@/components/dashboard/DashboardStats';
import { EventKindsChart } from '@/components/dashboard/EventKindsChart';
import { RecentActivityChart } from '@/components/dashboard/RecentActivityChart';
@@ -11,6 +9,7 @@ import { InfoIcon } from 'lucide-react';
import { Account, useLoggedInAccounts } from '@/hooks/useLoggedInAccounts';
import { genUserName } from '@/lib/genUserName';
/** The Dashboard ("Stats") app, rendered inside an OS window — see docs/DESKTOP_OS.md. */
export function Dashboard() {
const { user } = useCurrentUser();
const { currentUser } = useLoggedInAccounts();
@@ -20,54 +19,44 @@ export function Dashboard() {
}
return (
<SidebarProvider>
<div className="flex min-h-screen w-full overflow-x-hidden">
<AppSidebar />
<main className="flex-1 min-w-0">
<div className="sticky top-0 z-10 flex h-14 items-center gap-4 border-b bg-background px-4 lg:h-[60px] lg:px-6">
<SidebarTrigger />
<h1 className="text-lg font-semibold md:text-xl truncate">Dashboard</h1>
</div>
<div className="flex-1 space-y-6 p-4 md:p-6 lg:p-8 overflow-x-hidden">
{!user ? (
<Card className="border-dashed">
<CardContent className="py-12 px-8 text-center">
<div className="max-w-sm mx-auto space-y-4">
<Alert>
<InfoIcon className="h-4 w-4" />
<AlertDescription>
Please log in to view your dashboard and activity statistics.
</AlertDescription>
</Alert>
</div>
</CardContent>
</Card>
) : (
<>
<div className="space-y-2">
<h2 className="text-2xl md:text-3xl font-bold tracking-tight break-words">
Welcome back {currentUser ? getDisplayName(currentUser) : ''}!
</h2>
<p className="text-sm md:text-base text-muted-foreground">
Here's an overview of your Nostr activity and statistics.
</p>
</div>
<div className="flex h-full w-full flex-col overflow-y-auto">
<div className="flex-1 space-y-6 p-4 md:p-6 lg:p-8 overflow-x-hidden">
{!user ? (
<Card className="border-dashed">
<CardContent className="py-12 px-8 text-center">
<div className="max-w-sm mx-auto space-y-4">
<Alert>
<InfoIcon className="h-4 w-4" />
<AlertDescription>
Please log in to view your dashboard and activity statistics.
</AlertDescription>
</Alert>
</div>
</CardContent>
</Card>
) : (
<>
<div className="space-y-2">
<h2 className="text-2xl md:text-3xl font-bold tracking-tight break-words">
Welcome back {currentUser ? getDisplayName(currentUser) : ''}!
</h2>
<p className="text-sm md:text-base text-muted-foreground">
Here's an overview of your Nostr activity and statistics.
</p>
</div>
<DashboardStats pubkey={user.pubkey} />
<DashboardStats pubkey={user.pubkey} />
<RecentActivityChart />
<RecentActivityChart />
<div className="grid gap-6 md:grid-cols-2">
<EventKindsChart />
<RecentActivityList pubkey={user.pubkey} />
</div>
</>
)}
</div>
</main>
<div className="grid gap-6 md:grid-cols-2">
<EventKindsChart />
<RecentActivityList pubkey={user.pubkey} />
</div>
</>
)}
</div>
</SidebarProvider>
</div>
);
}

View File

@@ -1,57 +1,46 @@
import { SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar';
import { AppSidebar } from '@/components/navigation/AppSidebar';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { Card, CardContent } from '@/components/ui/card';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { InfoIcon } from 'lucide-react';
import { EventExplorer } from '@/components/dashboard/EventExplorer';
/** The My Events ("Finder") app, rendered inside an OS window — see docs/DESKTOP_OS.md. */
export function DashboardEvents() {
const { user } = useCurrentUser();
return (
<SidebarProvider>
<div className="flex min-h-screen w-full overflow-x-hidden">
<AppSidebar />
<main className="flex-1 min-w-0">
<div className="sticky top-0 z-10 flex h-14 items-center gap-4 border-b bg-background px-4 lg:h-[60px] lg:px-6">
<SidebarTrigger />
<h1 className="text-lg font-semibold md:text-xl truncate">My Events</h1>
</div>
<div className="flex-1 space-y-6 p-4 md:p-6 lg:p-8 overflow-x-hidden">
{!user ? (
<Card className="border-dashed">
<CardContent className="py-12 px-8 text-center">
<div className="max-w-sm mx-auto space-y-4">
<Alert>
<InfoIcon className="h-4 w-4" />
<AlertDescription>
Please log in to explore your events and manage deletion requests.
</AlertDescription>
</Alert>
</div>
</CardContent>
</Card>
) : (
<div className="space-y-4">
<div className="space-y-2">
<h2 className="text-2xl md:text-3xl font-bold tracking-tight break-words">
Your Nostr events
</h2>
<p className="text-sm md:text-base text-muted-foreground max-w-2xl">
Browse all events you have published on Nostr, search through their content,
and publish deletion requests when you want something removed.
</p>
</div>
<EventExplorer pubkey={user.pubkey} />
<div className="flex h-full w-full flex-col overflow-y-auto">
<div className="flex-1 space-y-6 p-4 md:p-6 lg:p-8 overflow-x-hidden">
{!user ? (
<Card className="border-dashed">
<CardContent className="py-12 px-8 text-center">
<div className="max-w-sm mx-auto space-y-4">
<Alert>
<InfoIcon className="h-4 w-4" />
<AlertDescription>
Please log in to explore your events and manage deletion requests.
</AlertDescription>
</Alert>
</div>
)}
</CardContent>
</Card>
) : (
<div className="space-y-4">
<div className="space-y-2">
<h2 className="text-2xl md:text-3xl font-bold tracking-tight break-words">
Your Nostr events
</h2>
<p className="text-sm md:text-base text-muted-foreground max-w-2xl">
Browse all events you have published on Nostr, search through their content,
and publish deletion requests when you want something removed.
</p>
</div>
<EventExplorer pubkey={user.pubkey} />
</div>
</main>
)}
</div>
</SidebarProvider>
</div>
);
}

View File

@@ -1,7 +1,5 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar';
import { AppSidebar } from '@/components/navigation/AppSidebar';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostr } from '@nostrify/react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -10,6 +8,7 @@ import { Button } from '@/components/ui/button';
import { InfoIcon, Download, Users, Calendar } from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton';
/** The Export Following ("Export") app, rendered inside an OS window — see docs/DESKTOP_OS.md. */
export function DashboardExport() {
const { user } = useCurrentUser();
const { nostr } = useNostr();
@@ -88,16 +87,8 @@ export function DashboardExport() {
};
return (
<SidebarProvider>
<div className="flex min-h-screen w-full overflow-x-hidden">
<AppSidebar />
<main className="flex-1 min-w-0">
<div className="sticky top-0 z-10 flex h-14 items-center gap-4 border-b bg-background px-4 lg:h-[60px] lg:px-6">
<SidebarTrigger />
<h1 className="text-lg font-semibold md:text-xl truncate">Export Following List</h1>
</div>
<div className="flex-1 space-y-6 p-4 md:p-6 lg:p-8 overflow-x-hidden">
<div className="flex h-full w-full flex-col overflow-y-auto">
<div className="flex-1 space-y-6 p-4 md:p-6 lg:p-8 overflow-x-hidden">
{!user ? (
<Card className="border-dashed">
<CardContent className="py-12 px-8 text-center">
@@ -243,9 +234,7 @@ export function DashboardExport() {
</Card>
</div>
)}
</div>
</main>
</div>
</SidebarProvider>
</div>
);
}

View File

@@ -156,13 +156,14 @@ function LoadingSkeleton() {
);
}
/** The Explore ("Browser") app, rendered inside an OS window — see docs/DESKTOP_OS.md. */
export function Explore() {
const [activeTab, setActiveTab] = useState('notes');
const { data, isLoading, isError } = useExploreEvents();
return (
<div className="min-h-screen bg-gradient-to-b from-background via-background to-muted/20">
<div className="container max-w-4xl mx-auto px-4 py-8 space-y-6">
<div className="h-full overflow-y-auto bg-gradient-to-b from-background via-background to-muted/20">
<div className="max-w-4xl mx-auto px-4 py-8 space-y-6">
{/* Header */}
<div className="space-y-2">
<h1 className="text-4xl sm:text-5xl font-bold bg-gradient-to-r from-primary to-primary/60 bg-clip-text text-transparent">

View File

@@ -1,8 +1,9 @@
import { useSeoMeta } from '@unhead/react';
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { CheckCircle2, Copy, Server, Gift, Users, Globe } from 'lucide-react';
import { CheckCircle2, Copy, Server, Gift, Users, Globe, LayoutGrid } from 'lucide-react';
import { useToast } from '@/hooks/useToast';
import { LoginArea } from '@/components/auth/LoginArea';
@@ -62,7 +63,13 @@ const Index = () => {
{/* Header */}
<header className="absolute top-0 left-0 right-0 z-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="flex justify-end">
<div className="flex justify-end items-center gap-3">
<Button asChild variant="outline" className="gap-2">
<Link to="/os">
<LayoutGrid className="w-4 h-4" />
<span>Open the Desktop</span>
</Link>
</Button>
<LoginArea className="max-w-60" />
</div>
</div>
@@ -98,6 +105,14 @@ const Index = () => {
<p className="text-base sm:text-lg text-muted-foreground/80 max-w-xl mx-auto">
A fast, reliable, and open Nostr relay connecting you to the future of social media
</p>
<div className="flex justify-center pt-2">
<Button asChild size="lg" className="gap-2 hover:scale-105 transition-transform">
<Link to="/os">
<LayoutGrid className="w-5 h-5" />
Launch the App
</Link>
</Button>
</div>
</div>
{/* Relay URL Card */}

View File

@@ -1,24 +1,25 @@
import { useSeoMeta } from '@unhead/react';
import { DMMessagingInterface } from '@/components/dm/DMMessagingInterface';
import { DMProvider } from '@/components/DMProvider';
import { useCurrentUser } from '@/hooks/useCurrentUser';
/** The Messages app, rendered inside an OS window — see docs/DESKTOP_OS.md. */
const Messages = () => {
useSeoMeta({
title: 'Messages',
description: 'Private encrypted messaging on Nostr',
});
return (
<div className="min-h-screen bg-background">
<div className="container mx-auto p-4 h-screen flex flex-col">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">Messages</h1>
</div>
const { user } = useCurrentUser();
return (
<DMProvider config={{ enabled: !!user }}>
<div className="h-full flex flex-col p-4">
<DMMessagingInterface className="flex-1" />
</div>
</div>
</DMProvider>
);
};
export default Messages;
export { Messages };