mirror of
https://github.com/layer-systems/website.git
synced 2026-09-14 15:26:22 +02:00
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:
124
docs/DESKTOP_OS.md
Normal file
124
docs/DESKTOP_OS.md
Normal 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.
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
71
src/hooks/useRelayStatus.ts
Normal file
71
src/hooks/useRelayStatus.ts
Normal 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 };
|
||||
}
|
||||
@@ -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
138
src/os/Desktop.tsx
Normal 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
92
src/os/apps/registry.ts
Normal 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
78
src/os/shell/Dock.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
src/os/shell/LockScreen.tsx
Normal file
59
src/os/shell/LockScreen.tsx
Normal 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
128
src/os/shell/MenuBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
src/os/shell/MobileAppView.tsx
Normal file
38
src/os/shell/MobileAppView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
src/os/shell/MobileHome.tsx
Normal file
44
src/os/shell/MobileHome.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
src/os/shell/Wallpaper.tsx
Normal file
16
src/os/shell/Wallpaper.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
166
src/os/window-manager/Window.tsx
Normal file
166
src/os/window-manager/Window.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
204
src/os/window-manager/WindowManagerContext.tsx
Normal file
204
src/os/window-manager/WindowManagerContext.tsx
Normal 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>;
|
||||
}
|
||||
19
src/os/window-manager/context.ts
Normal file
19
src/os/window-manager/context.ts
Normal 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);
|
||||
19
src/os/window-manager/types.ts
Normal file
19
src/os/window-manager/types.ts
Normal 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[];
|
||||
}
|
||||
10
src/os/window-manager/useWindowManager.ts
Normal file
10
src/os/window-manager/useWindowManager.ts
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user