From 16bb6793d9d702b3513d53d03cf6b1f5c8146340 Mon Sep 17 00:00:00 2001 From: Agent Date: Fri, 10 Jul 2026 15:39:03 +0000 Subject: [PATCH] turn client shell into interactive desktop --- src/components/navigation/OsShell.tsx | 197 ++++++++++++++------------ src/index.css | 92 ++++++++++++ src/lib/nostrApps.ts | 9 ++ src/pages/Index.tsx | 82 +++-------- 4 files changed, 228 insertions(+), 152 deletions(-) create mode 100644 src/lib/nostrApps.ts diff --git a/src/components/navigation/OsShell.tsx b/src/components/navigation/OsShell.tsx index fe23fee..407bbdc 100644 --- a/src/components/navigation/OsShell.tsx +++ b/src/components/navigation/OsShell.tsx @@ -1,107 +1,128 @@ -import type { ReactNode } from 'react'; -import { Activity, Archive, Compass, Grid2X2, Network, Radio, Sparkles } from 'lucide-react'; -import { Link, NavLink } from 'react-router-dom'; +import { useEffect, useMemo, useState, type ReactNode } from 'react'; +import { Command, Grid2X2, Maximize2, Minimize2, Radio, X } from 'lucide-react'; +import { Link, useLocation, useNavigate } from 'react-router-dom'; import { LoginArea } from '@/components/auth/LoginArea'; import { cn } from '@/lib/utils'; - -const apps = [ - { name: 'Home', path: '/', icon: Grid2X2, short: 'Home' }, - { name: 'Discover', path: '/explore', icon: Compass, short: 'Find' }, - { name: 'Activity', path: '/dashboard', icon: Activity, short: 'Me' }, - { name: 'Studio', path: '/dashboard/events', icon: Sparkles, short: 'Studio' }, - { name: 'Archive', path: '/dashboard/export', icon: Archive, short: 'Save' }, - { name: 'Network', path: '/network', icon: Network, short: 'Relay' }, -]; +import { nostrApps } from '@/lib/nostrApps'; interface OsShellProps { children: ReactNode; title: string; eyebrow?: string; className?: string; + desktop?: boolean; } -export function OsShell({ children, title, eyebrow = 'Nostr operating system', className }: OsShellProps) { +function useClock() { + const [time, setTime] = useState(() => new Date()); + + useEffect(() => { + const timer = window.setInterval(() => setTime(new Date()), 30_000); + return () => window.clearInterval(timer); + }, []); + + return new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(time); +} + +export function OsShell({ children, title, eyebrow = 'Nostr operating system', className, desktop = false }: OsShellProps) { + const location = useLocation(); + const navigate = useNavigate(); + const time = useClock(); + const [launcherOpen, setLauncherOpen] = useState(false); + const [minimized, setMinimized] = useState(false); + const [maximized, setMaximized] = useState(false); + const [position, setPosition] = useState({ x: 0, y: 0 }); + const currentApp = useMemo( + () => nostrApps.find((app) => location.pathname === app.path) ?? (desktop ? undefined : nostrApps[0]), + [desktop, location.pathname], + ); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') { + event.preventDefault(); + setLauncherOpen((open) => !open); + } + if (event.key === 'Escape') setLauncherOpen(false); + }; + + window.addEventListener('keydown', handleKeyDown); + const openLauncher = () => setLauncherOpen(true); + window.addEventListener('nostr-os:launcher', openLauncher); + return () => { + window.removeEventListener('keydown', handleKeyDown); + window.removeEventListener('nostr-os:launcher', openLauncher); + }; + }, []); + + const launch = (path: string) => { + setLauncherOpen(false); + setMinimized(false); + navigate(path); + }; + + const beginDrag = (event: React.PointerEvent) => { + if (event.button !== 0 || window.innerWidth < 1024 || maximized) return; + const start = { x: event.clientX, y: event.clientY, position }; + const move = (moveEvent: PointerEvent) => { + setPosition({ x: start.position.x + moveEvent.clientX - start.x, y: start.position.y + moveEvent.clientY - start.y }); + }; + const stop = () => { + window.removeEventListener('pointermove', move); + window.removeEventListener('pointerup', stop); + }; + window.addEventListener('pointermove', move); + window.addEventListener('pointerup', stop); + }; + return ( -
- +
relay.layer.systems{time}
+ -
-
-
-
-

{eyebrow}

-

{title}

-
-
- - - relay.layer.systems - - -
-
-
- -
{children}
+
+
+ {desktop ? ( + children + ) : minimized ? ( +

{title} is minimized

+ ) : ( +
+
+
{currentApp ? : }
{title}{eyebrow}
+
+
+
{children}
+
+ )} +
-
); } diff --git a/src/index.css b/src/index.css index 68e3d55..ec3ecc2 100644 --- a/src/index.css +++ b/src/index.css @@ -179,3 +179,95 @@ from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } + +@layer components { + .signal-wallpaper { + background: + radial-gradient(circle at 12% 18%, hsl(187 90% 55% / 0.17), transparent 28rem), + radial-gradient(circle at 85% 22%, hsl(39 96% 55% / 0.13), transparent 23rem), + radial-gradient(circle at 50% 105%, hsl(270 75% 58% / 0.14), transparent 35rem), + linear-gradient(145deg, #080b14 0%, #10152a 47%, #090d17 100%); + } + + .signal-grid { + position: absolute; + inset: 0; + opacity: 0.32; + background-image: + linear-gradient(hsl(0 0% 100% / 0.035) 1px, transparent 1px), + linear-gradient(90deg, hsl(0 0% 100% / 0.035) 1px, transparent 1px); + background-size: 42px 42px; + mask-image: linear-gradient(to bottom, black, transparent 80%); + } + + .signal-orb { + position: absolute; + border-radius: 999px; + filter: blur(1px); + opacity: 0.7; + animation: signal-drift 16s ease-in-out infinite alternate; + } + + .signal-orb-one { top: 18%; left: 37%; width: 7px; height: 7px; background: #fbbf24; box-shadow: 0 0 32px 12px hsl(42 100% 62% / 0.4); } + .signal-orb-two { top: 57%; right: 18%; width: 5px; height: 5px; background: #5eead4; box-shadow: 0 0 28px 10px hsl(172 75% 57% / 0.35); animation-delay: -7s; } + .signal-menubar { background: hsl(225 27% 8% / 0.78); backdrop-filter: blur(20px); } + .signal-workspace { min-height: calc(100dvh - 2.75rem); } + .signal-window { transition: transform 180ms ease, box-shadow 180ms ease; } + .signal-titlebar { cursor: grab; } + .signal-titlebar:active { cursor: grabbing; } + + .signal-home { + background: + radial-gradient(circle at 67% 22%, hsl(214 92% 62% / 0.16), transparent 22rem), + radial-gradient(circle at 14% 83%, hsl(38 96% 60% / 0.1), transparent 22rem), + hsl(225 23% 12% / 0.58); + backdrop-filter: blur(16px); + } + + .desktop-icon { display: block; opacity: 0; animation: desktop-icon-in 520ms cubic-bezier(0.16, 1, 0.3, 1) forwards; } + .desktop-icon-mark { display: grid; width: 68px; height: 68px; margin: 0 auto; place-items: center; border: 1px solid hsl(0 0% 100% / 0.16); border-radius: 21px; color: white; box-shadow: inset 0 1px 0 hsl(0 0% 100% / 0.22), 0 14px 28px hsl(225 40% 2% / 0.3); transition: transform 180ms ease, filter 180ms ease; } + .desktop-icon:hover .desktop-icon-mark, .desktop-icon:focus-visible .desktop-icon-mark { transform: translateY(-4px) scale(1.04); filter: brightness(1.1); } + .desktop-icon:focus-visible { outline: 2px solid hsl(var(--primary)); outline-offset: 6px; border-radius: 16px; } + .desktop-icon-cyan, .launcher-icon-cyan { background: linear-gradient(140deg, #14b8c5, #1d4ed8); } + .desktop-icon-amber, .launcher-icon-amber { background: linear-gradient(140deg, #fbbf24, #ea580c); } + .desktop-icon-violet, .launcher-icon-violet { background: linear-gradient(140deg, #c084fc, #6d28d9); } + .desktop-icon-emerald, .launcher-icon-emerald { background: linear-gradient(140deg, #34d399, #047857); } + .desktop-icon-rose, .launcher-icon-rose { background: linear-gradient(140deg, #fb7185, #be123c); } + + .signal-dock { min-height: 56px; } + .dock-app { position: relative; display: grid; width: 40px; height: 40px; flex: 0 0 auto; place-items: center; border-radius: 12px; color: hsl(220 13% 78%); transition: transform 150ms ease, background-color 150ms ease, color 150ms ease; } + .dock-app:hover, .dock-app:focus-visible { transform: translateY(-5px) scale(1.08); background: hsl(0 0% 100% / 0.11); color: white; outline: none; } + .dock-app-active { background: hsl(0 0% 100% / 0.14); color: hsl(var(--primary)); } + .dock-app-active::after { position: absolute; right: 50%; bottom: -3px; width: 4px; height: 4px; border-radius: 999px; background: hsl(var(--primary)); content: ''; transform: translateX(50%); } + .dock-tooltip { pointer-events: none; position: absolute; bottom: calc(100% + 10px); display: none; white-space: nowrap; border: 1px solid hsl(0 0% 100% / 0.12); border-radius: 8px; background: #151927; padding: 4px 7px; font-family: var(--font-mono); font-size: 10px; color: white; } + .dock-app:hover .dock-tooltip { display: block; } + + .launcher-app { display: flex; width: 100%; align-items: center; gap: 12px; border-radius: 15px; padding: 10px; text-align: left; color: white; transition: background-color 150ms ease, transform 150ms ease; } + .launcher-app:hover, .launcher-app:focus-visible { background: hsl(0 0% 100% / 0.09); transform: translateX(2px); outline: none; } + .launcher-app strong { display: block; font-size: 14px; font-weight: 600; } + .launcher-app small { display: block; margin-top: 2px; font-size: 11px; color: hsl(220 15% 70%); } + .launcher-icon { display: grid; width: 40px; height: 40px; flex: 0 0 auto; place-items: center; border: 1px solid hsl(0 0% 100% / 0.12); border-radius: 13px; color: white; } + .launcher-icon-slate { background: linear-gradient(140deg, #475569, #0f172a); } +} + +@media (max-width: 640px) { + .signal-dock { gap: 0; padding: 4px; } + .dock-app { width: 34px; height: 38px; } + .dock-app svg { width: 16px; height: 16px; } + .signal-home { border-radius: 16px; } +} + +@media (prefers-reduced-motion: reduce) { + .signal-orb, .desktop-icon { animation: none; opacity: 1; } + .signal-window, .desktop-icon-mark, .dock-app, .launcher-app { transition: none; } +} + +@keyframes signal-drift { + from { transform: translate3d(-12px, -8px, 0); } + to { transform: translate3d(18px, 15px, 0); } +} + +@keyframes desktop-icon-in { + from { opacity: 0; transform: translateY(10px) scale(0.96); } + to { opacity: 1; transform: translateY(0) scale(1); } +} diff --git a/src/lib/nostrApps.ts b/src/lib/nostrApps.ts new file mode 100644 index 0000000..cf7a9b3 --- /dev/null +++ b/src/lib/nostrApps.ts @@ -0,0 +1,9 @@ +import { Activity, Archive, Compass, Network, Sparkles } from 'lucide-react'; + +export const nostrApps = [ + { name: 'Discover', path: '/explore', icon: Compass, short: 'Find', detail: 'Explore the public network', hue: 'cyan' }, + { name: 'Activity', path: '/dashboard', icon: Activity, short: 'Me', detail: 'See your Nostr footprint', hue: 'amber' }, + { name: 'Studio', path: '/dashboard/events', icon: Sparkles, short: 'Studio', detail: 'Inspect your published events', hue: 'violet' }, + { name: 'Archive', path: '/dashboard/export', icon: Archive, short: 'Save', detail: 'Keep your social graph portable', hue: 'emerald' }, + { name: 'Network', path: '/network', icon: Network, short: 'Relay', detail: 'Manage signal routes', hue: 'rose' }, +] as const; diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index b998b5f..ad75266 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -1,85 +1,39 @@ import { useSeoMeta } from '@unhead/react'; -import { ArrowUpRight, Boxes, Copy, Fingerprint, Network, Orbit, Sparkles, Terminal, Waves } from 'lucide-react'; +import { ArrowUpRight, Command, Fingerprint, Radio, Sparkles } from 'lucide-react'; import { Link } from 'react-router-dom'; -import { useState } from 'react'; import { OsShell } from '@/components/navigation/OsShell'; -import { useToast } from '@/hooks/useToast'; import { useCurrentUser } from '@/hooks/useCurrentUser'; - -const workspaces = [ - { title: 'Discover', detail: 'Live notes & people', path: '/explore', icon: Orbit, tone: 'from-cyan-400/20 to-blue-500/5', index: '01' }, - { title: 'Activity', detail: 'Your Nostr footprint', path: '/dashboard', icon: Waves, tone: 'from-amber-300/20 to-orange-500/5', index: '02' }, - { title: 'Studio', detail: 'Browse your events', path: '/dashboard/events', icon: Sparkles, tone: 'from-fuchsia-400/20 to-violet-500/5', index: '03' }, - { title: 'Archive', detail: 'Export your follows', path: '/dashboard/export', icon: Boxes, tone: 'from-emerald-300/20 to-teal-500/5', index: '04' }, -]; +import { nostrApps } from '@/lib/nostrApps'; const Index = () => { - const [copied, setCopied] = useState(false); - const { toast } = useToast(); const { user } = useCurrentUser(); - const relayUrl = 'wss://relay.layer.systems'; useSeoMeta({ title: 'Nostr OS · Layer Systems', - description: 'A beautiful operating system for your Nostr identity.', + description: 'A real desktop environment for your Nostr identity.', }); - const copyRelay = async () => { - try { - await navigator.clipboard.writeText(relayUrl); - setCopied(true); - window.setTimeout(() => setCopied(false), 1600); - toast({ title: 'Relay address copied', description: 'Paste it into any Nostr client.' }); - } catch { - toast({ title: 'Could not copy relay address', description: relayUrl, variant: 'destructive' }); - } - }; - return ( - -
-
-
-
-
-
- Personal signal online -
-

One place for the network that belongs to you.

-

A calm control surface for your Nostr identity: discover public conversations, inspect your signal, and keep your network portable.

-
- - {user ? 'Open my activity' : 'Enter discover'} - - - Network routes - -
-
-
-
Identity
-

{user ? 'Signer connected' : 'Your Nostr key is waiting'}

-

{user ? `${user.pubkey.slice(0, 12)}…${user.pubkey.slice(-8)}` : 'Use a signer extension or nsec login to begin.'}

-
Your identity never leaves your signer
-
-
-
+ +
+
NSTR
+

Signal desk

Your independent social computer

-
-

Your desktop

Open an app

04 MODULES READY
-
- {workspaces.map((workspace) => ( - -
-
{workspace.index}

{workspace.title}

{workspace.detail}

+
+ {nostrApps.map((app, index) => ( + + + {app.name} + App ))}
-
-
-

Bring Layer Systems with you

A preferred relay, ready for any Nostr client.

{relayUrl}
-

Your route table

Control what carries your signal.

Manage read and write relays from one focused network app.

Open Network +
+

Your identity

{user ? 'Signer connected' : 'Ready when you are'}

{user ? `${user.pubkey.slice(0, 16)}…${user.pubkey.slice(-8)}` : 'Log in with a Nostr signer from the menu bar. Your key stays in your wallet or extension.'}

+
Network online

wss://relay.layer.systems

Preferred signal route

+
+
Make the network yours
);