diff --git a/apps/web/src/app/App.tsx b/apps/web/src/app/App.tsx index d658026..7942268 100644 --- a/apps/web/src/app/App.tsx +++ b/apps/web/src/app/App.tsx @@ -1,6 +1,6 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { Routes, Route, Navigate, useNavigate } from 'react-router-dom'; -import { Pane, PaneBody, PaneHeader, SplitShell, Button } from '@ghostodon/ui'; +import { Pane, PaneBody, PaneHeader, Button, Container, Row, Column } from '@ghostodon/ui'; import LeftNav from '../components/LeftNav'; import TimelinePage from '../pages/TimelinePage'; import SearchPage from '../pages/SearchPage'; @@ -8,6 +8,9 @@ import MePage from '../pages/MePage'; import LoginPage from '../pages/LoginPage'; import RegisterPage from '../pages/RegisterPage'; import AuthCallbackPage from '../pages/AuthCallbackPage'; +import ComponentsPage from '../pages/ComponentsPage'; +import LayoutPage from '../pages/LayoutPage'; +import LayoutPrimitivesPage from '../pages/LayoutPrimitivesPage'; import Inspector from '../components/Inspector'; import StoryViewer from '../components/stories/StoryViewer'; import { useInspectorStore, useThemeStore, useSessionStore } from '@ghostodon/state'; @@ -18,6 +21,18 @@ export default function App() { const session = useSessionStore((s) => s.session); const theme = useThemeStore((s) => s.theme); const noise = useThemeStore((s) => s.noise); + const [leftOpen, setLeftOpen] = useState(true); + const [rightOpen, setRightOpen] = useState(true); + + const renderChevron = (direction: 'left' | 'right') => ( + + ); // Apply theme (HTML class) + effects (BODY class) useEffect(() => { @@ -87,51 +102,132 @@ export default function App() { return ( <> - - - - - - - } - center={ - - - - - - - } - /> - - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - Not found} /> - - - - } - right={} - /> + + + {leftOpen ? ( + + + setLeftOpen(false)} + > + {renderChevron('left')} + + } + /> + + + + + + ) : ( +
+ +
+ )} + + + + + + + + + } + /> + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + Not found} /> + + + + + + {rightOpen ? ( + + + setRightOpen(false)} + > + {renderChevron('right')} + + } + /> + + + + + + ) : ( +
+ +
+ )} +
+
); diff --git a/apps/web/src/components/LeftNav.tsx b/apps/web/src/components/LeftNav.tsx index 8c145a7..3bc4bf2 100644 --- a/apps/web/src/components/LeftNav.tsx +++ b/apps/web/src/components/LeftNav.tsx @@ -3,15 +3,17 @@ import { Link, NavLink } from 'react-router-dom'; import { Button, BrandLockup } from '@ghostodon/ui'; import { plugins } from '@ghostodon/plugins'; import { useInspectorStore, useSessionStore } from '@ghostodon/state'; +import SurfaceOverlay from './SurfaceOverlay'; function NavItem(props: { to: string; label: string; hint?: string; icon?: React.ReactNode }) { return ( - 'ghost-navitem ' + (isActive ? 'ghost-navitem--active' : '') + 'ghost-navitem relative overflow-hidden ' + (isActive ? 'ghost-navitem--active' : '') } > + {props.icon ? : null} {props.label} @@ -108,6 +110,26 @@ function UserPlusIcon() { ); } +function LayersIcon() { + return ( + + + + + + ); +} + +function LayoutIcon() { + return ( + + + + + + ); +} + export default function LeftNav() { const setInspector = useInspectorStore((s) => s.setInspector); const session = useSessionStore((s) => s.session); @@ -130,6 +152,9 @@ export default function LeftNav() {
Discovery
} /> + } /> + } /> + } /> } />
@@ -140,7 +165,8 @@ export default function LeftNav() { {extItems.length ? ( -
+
+
Extensions
{extItems.map((it) => @@ -149,9 +175,10 @@ export default function LeftNav() { ) : ( @@ -161,7 +188,8 @@ export default function LeftNav() {
) : null} -
+
+
Session
{session ? (
diff --git a/apps/web/src/components/SurfaceOverlay.tsx b/apps/web/src/components/SurfaceOverlay.tsx new file mode 100644 index 0000000..541e0c4 --- /dev/null +++ b/apps/web/src/components/SurfaceOverlay.tsx @@ -0,0 +1,34 @@ +import React from 'react'; + +export default function SurfaceOverlay() { + return ( + + } + code={`\n\n`} + /> + + {}} /> + } + code={` {}} />`} + /> + + + + + +
+ } + code={``} + /> + + {}} + /> + } + code={``} + /> + + + + + + } + onClick={() => {}} + /> + } + code={`Follow)} />`} + /> + + + System UI + Ghostodon Command Center + + Modern brutalist typography for dashboards, timelines, and control panels. + + Caption text for metadata and annotations. +
+ } + code={`System UI\nGhostodon Command Center\nModern brutalist typography.\nCaption text`} + /> +
+ ); +} diff --git a/apps/web/src/pages/LayoutPage.tsx b/apps/web/src/pages/LayoutPage.tsx new file mode 100644 index 0000000..ee5ddbf --- /dev/null +++ b/apps/web/src/pages/LayoutPage.tsx @@ -0,0 +1,164 @@ +import React, { useState } from 'react'; +import { + AvatarButton, + Button, + Column, + Container, + Row, + TextAreaField, + UserCard, +} from '@ghostodon/ui'; +import SurfaceOverlay from '../components/SurfaceOverlay'; + +export default function LayoutPage() { + const [leftOpen, setLeftOpen] = useState(true); + const [rightOpen, setRightOpen] = useState(true); + + return ( + + + {leftOpen ? ( + + + +
Components
+
+ + + + +
+
+ ) : ( +
+ +
+ )} + + +
+
+ +
+ Avatar cards +
+
+ {}} + /> + {}} + /> +
+
+ +
+ +
+ My post +
+
+ +
+ + +
+
+
+ +
+
+ Latest posts +
+ + + + + } + onClick={() => {}} + /> + + + + + } + onClick={() => {}} + /> +
+
+
+ + {rightOpen ? ( + + + +
Status
+
+
Live traffic
+
Mentions spiking in the last hour.
+ +
+
+ ) : ( +
+
+ +
+
+ )} +
+
+ ); +} diff --git a/apps/web/src/pages/LayoutPrimitivesPage.tsx b/apps/web/src/pages/LayoutPrimitivesPage.tsx new file mode 100644 index 0000000..804c567 --- /dev/null +++ b/apps/web/src/pages/LayoutPrimitivesPage.tsx @@ -0,0 +1,108 @@ +import React, { useState } from 'react'; +import { Button, Column, Container, Row } from '@ghostodon/ui'; +import SurfaceOverlay from '../components/SurfaceOverlay'; + +export default function LayoutPrimitivesPage() { + const [leftOpen, setLeftOpen] = useState(true); + const [rightOpen, setRightOpen] = useState(true); + + return ( + +
+ +
+ Layout primitives +
+
+ Container + Row + Column with resizable and collapsible sidebars. +
+
+ + + {leftOpen ? ( + + + +
Left column
+
+
Resizable
+
Collapsible
+
List of items
+
+
+ ) : ( +
+ +
+ )} + + +
+ +
+ Center column +
+
+ This column grows to fill available space. +
+
+
+ + {rightOpen ? ( + + + +
Right column
+
+
Status panel
+
Notifications
+
Filters
+
+
+ ) : ( +
+
+ +
+
+ )} +
+
+ ); +} diff --git a/apps/web/src/pages/LoginPage.tsx b/apps/web/src/pages/LoginPage.tsx index 71e1224..5a021e0 100644 --- a/apps/web/src/pages/LoginPage.tsx +++ b/apps/web/src/pages/LoginPage.tsx @@ -3,6 +3,7 @@ import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { auth } from '@ghostodon/core'; import { BrandLockup, Button, Input } from '@ghostodon/ui'; import { useSessionStore } from '@ghostodon/state'; +import SurfaceOverlay from '../components/SurfaceOverlay'; const OAUTH_KEY = 'ghostodon.oauth'; @@ -123,7 +124,8 @@ export default function LoginPage() {
-
+
+
Instance
We normalize to https://<host>.
@@ -174,10 +176,16 @@ export default function LoginPage() {
)} - {err ?
{err}
: null} + {err ? ( +
+ + {err} +
+ ) : null}
-
+
+
Why this client
Bird’s-eye dashboard first. Timelines are widgets. The UI is brutal, readable, and fast. diff --git a/apps/web/src/pages/MePage.tsx b/apps/web/src/pages/MePage.tsx index 1b8e7a0..341574b 100644 --- a/apps/web/src/pages/MePage.tsx +++ b/apps/web/src/pages/MePage.tsx @@ -5,6 +5,7 @@ import { useGhostodon } from '../lib/useClient'; import { useInspectorStore, useSessionStore, useStoriesStore } from '@ghostodon/state'; import StatusCardWithComments from '../components/StatusCardWithComments'; import { useAutoLoadMore } from '../lib/useAutoLoadMore'; +import SurfaceOverlay from '../components/SurfaceOverlay'; export default function MePage() { const { client, sessionKey } = useGhostodon(); @@ -58,7 +59,8 @@ export default function MePage() { if (!client || !session) { return ( -
+
+ Connect first.
); @@ -82,7 +84,8 @@ export default function MePage() {
{a ? ( -
+
+ {a.header ? (
@@ -98,15 +101,18 @@ export default function MePage() {
@{a.acct}
-
+
+
Posts
{a.statusesCount ?? '—'}
-
+
+
Following
{a.followingCount ?? '—'}
-
+
+
Followers
{a.followersCount ?? '—'}
diff --git a/apps/web/src/pages/RegisterPage.tsx b/apps/web/src/pages/RegisterPage.tsx index e5dd036..760d07b 100644 --- a/apps/web/src/pages/RegisterPage.tsx +++ b/apps/web/src/pages/RegisterPage.tsx @@ -1,6 +1,7 @@ import React, { useMemo, useState } from 'react'; import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { BrandLockup, Button, Input } from '@ghostodon/ui'; +import SurfaceOverlay from '../components/SurfaceOverlay'; function normalizeOrigin(raw: string): string { const v = raw.trim(); @@ -38,7 +39,8 @@ export default function RegisterPage() {
-
+
+
Instance
We normalize to https://<host>. Rules and moderation differ per instance.
@@ -74,7 +76,8 @@ export default function RegisterPage() { ) : null}
-
+
+
Picking an instance
  • Check rules + content policy. Moderation style varies.
  • diff --git a/apps/web/src/pages/SearchPage.tsx b/apps/web/src/pages/SearchPage.tsx index 6224a60..fce08a4 100644 --- a/apps/web/src/pages/SearchPage.tsx +++ b/apps/web/src/pages/SearchPage.tsx @@ -1,9 +1,10 @@ import React, { useMemo, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { Button, Input } from '@ghostodon/ui'; +import { Button, InfoCard, Input } from '@ghostodon/ui'; import { useGhostodon } from '../lib/useClient'; import { useInspectorStore, useStoriesStore } from '@ghostodon/state'; import StatusCardWithComments from '../components/StatusCardWithComments'; +import SurfaceOverlay from '../components/SurfaceOverlay'; type SearchTab = 'statuses' | 'accounts' | 'hashtags'; @@ -16,12 +17,54 @@ export default function SearchPage() { const [submitted, setSubmitted] = useState(''); const [tab, setTab] = useState('statuses'); - const canSearch = Boolean(client) && submitted.trim().length > 0; + const canSearch = submitted.trim().length > 0; const query = useQuery({ queryKey: ['search', submitted.trim(), tab, sessionKey], queryFn: async () => { - if (!client) throw new Error('Not connected'); + if (!client) { + const seed = submitted.trim() || 'ghostodon'; + return { + accounts: [ + { + id: `mock-${seed}-1`, + acct: `${seed}.studio`, + displayName: `${seed} Studio`, + avatar: 'https://images.unsplash.com/photo-1544723795-3fb6469f5b39?auto=format&fit=crop&w=200&q=80', + noteHtml: `

    Preview account for ${seed}.

    `, + }, + { + id: `mock-${seed}-2`, + acct: `${seed}.ops`, + displayName: `${seed} Ops`, + avatar: 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=200&q=80', + noteHtml: `

    Operations updates and workflow tips.

    `, + }, + ], + statuses: [ + { + id: `mock-status-${seed}-1`, + createdAt: new Date().toISOString(), + account: { + id: `mock-${seed}-acct`, + acct: `${seed}.studio`, + displayName: `${seed} Studio`, + avatar: 'https://images.unsplash.com/photo-1544723795-3fb6469f5b39?auto=format&fit=crop&w=200&q=80', + }, + contentHtml: `

    Preview post for ${seed} search.

    `, + repliesCount: 2, + reblogsCount: 1, + favouritesCount: 5, + media: [], + spoilerText: '', + }, + ], + hashtags: [ + { name: seed, url: '#', hint: 'Preview' }, + { name: `${seed}design`, url: '#', hint: 'Mock' }, + ], + } as typeof query.data; + } return client.search.query(submitted.trim(), { type: tab, limit: 20, @@ -55,7 +98,7 @@ export default function SearchPage() { }, [hashtags]); const hint = useMemo(() => { - if (!client) return 'Connect first (Login / manual token).'; + if (!client) return 'Showing preview results (connect to search).'; if (!submitted) return 'Type a query and hit Enter.'; if (query.isFetching) return 'Searching…'; if (query.isError) return (query.error as Error).message; @@ -67,7 +110,8 @@ export default function SearchPage() { return (
    -
    +
    +
    Search
    -
    +
    +
    Trends
    Live pulse from tags & conversations.
    {trendItems.map((trend) => ( +
    #{trend.name}
    {trend.hint}
    @@ -146,8 +192,33 @@ export default function SearchPage() {
    +
    + + + +
    + {!client ? ( -
    +
    +
    You are not connected.
    @@ -179,9 +250,10 @@ export default function SearchPage() { -
    -
    - - -
    - {s.createdAt ? new Date(s.createdAt).toLocaleString() : ''} -
    + /> +
    +
    +
    +
    + +
    + {rebloggedBy ? ( +
    + Boosted by @{rebloggedBy.acct}
    + ) : null} - {s.spoilerText ? ( -
    -
    CW
    -
    {s.spoilerText}
    +
    + +
    +
    + + +
    + {s.createdAt ? new Date(s.createdAt).toLocaleString() : ''} +
    - ) : null} -
    + {s.spoilerText ? ( +
    +
    CW
    +
    {s.spoilerText}
    +
    + ) : null} - {s.media && s.media.length > 0 ? (
    { - e.stopPropagation(); - }} - > - {s.media.slice(0, 4).map((m) => ( - e.stopPropagation()} - > - {m.description - - ))} -
    - ) : null} + className="ghost-content prose prose-invert prose-p:my-1 prose-a:text-[rgba(var(--g-accent),0.95)] max-w-none" + // Mastodon content is server-sanitized HTML. + dangerouslySetInnerHTML={{ __html: s.contentHtml }} + /> + + {s.media && s.media.length > 0 ? ( +
    { + e.stopPropagation(); + }} + > + {s.media.slice(0, 4).map((m) => ( + e.stopPropagation()} + > + {m.description + + ))} +
    + ) : null} {/* Comment preview (Facebook-ish affordance). IMPORTANT: Only render this block if the app layer wires lazy loading. @@ -283,6 +317,7 @@ export function StatusCard(props: {
    +
    ); } diff --git a/packages/ui/src/primitives/ActionPanel.tsx b/packages/ui/src/primitives/ActionPanel.tsx new file mode 100644 index 0000000..23baefc --- /dev/null +++ b/packages/ui/src/primitives/ActionPanel.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; +import { cn } from '../util/cn'; + +export function ActionPanel(props: { + title: string; + description?: string; + actions?: React.ReactNode; + onClick?: () => void; + className?: string; + children?: React.ReactNode; +}) { + return ( +
    +
    +
    +
    + {props.title} +
    + {props.description ? ( +
    {props.description}
    + ) : null} +
    + {props.actions ?
    {props.actions}
    : null} +
    + {props.children ?
    {props.children}
    : null} +
    + ); +} diff --git a/packages/ui/src/primitives/AvatarButton.tsx b/packages/ui/src/primitives/AvatarButton.tsx new file mode 100644 index 0000000..6453302 --- /dev/null +++ b/packages/ui/src/primitives/AvatarButton.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import { cn } from '../util/cn'; + +export function AvatarButton(props: { + src: string; + alt?: string; + label?: string; + meta?: string; + onClick?: () => void; + className?: string; +}) { + return ( + + ); +} diff --git a/packages/ui/src/primitives/DateField.tsx b/packages/ui/src/primitives/DateField.tsx new file mode 100644 index 0000000..ee431b0 --- /dev/null +++ b/packages/ui/src/primitives/DateField.tsx @@ -0,0 +1,17 @@ +import * as React from 'react'; +import { cn } from '../util/cn'; + +export function DateField(props: { + label: string; + hint?: string; + inputProps?: React.InputHTMLAttributes; + className?: string; +}) { + return ( + + ); +} diff --git a/packages/ui/src/primitives/InfoCard.tsx b/packages/ui/src/primitives/InfoCard.tsx new file mode 100644 index 0000000..1d33d41 --- /dev/null +++ b/packages/ui/src/primitives/InfoCard.tsx @@ -0,0 +1,62 @@ +import * as React from 'react'; +import { cn } from '../util/cn'; + +type CardTone = 'default' | 'success' | 'warning' | 'danger'; + +const toneMap: Record = { + default: 'rgba(var(--g-accent), 0.75)', + success: 'rgba(96, 255, 204, 0.85)', + warning: 'rgba(255, 198, 93, 0.9)', + danger: 'rgba(255, 110, 110, 0.9)', +}; + +export function InfoCard(props: { + title: string; + content: React.ReactNode; + status?: string; + tone?: CardTone; + hoverTone?: CardTone; + className?: string; + onClick?: () => void; +}) { + const tone = props.tone ?? 'default'; + const hover = props.hoverTone ?? tone; + const accent = toneMap[tone]; + const hoverAccent = toneMap[hover]; + + return ( +
    { + (e.currentTarget as HTMLElement).style.borderColor = hoverAccent; + }} + onMouseLeave={(e) => { + (e.currentTarget as HTMLElement).style.borderColor = accent; + }} + > +
    +
    + {props.title} +
    + {props.status ? ( + + {props.status} + + ) : null} +
    +
    {props.content}
    +
    + ); +} diff --git a/packages/ui/src/primitives/InputField.tsx b/packages/ui/src/primitives/InputField.tsx new file mode 100644 index 0000000..84a143b --- /dev/null +++ b/packages/ui/src/primitives/InputField.tsx @@ -0,0 +1,19 @@ +import * as React from 'react'; +import { cn } from '../util/cn'; + +export function InputField(props: { + label: string; + hint?: string; + error?: string; + inputProps?: React.InputHTMLAttributes; + className?: string; +}) { + return ( + + ); +} diff --git a/packages/ui/src/primitives/MediaCard.tsx b/packages/ui/src/primitives/MediaCard.tsx new file mode 100644 index 0000000..359945a --- /dev/null +++ b/packages/ui/src/primitives/MediaCard.tsx @@ -0,0 +1,33 @@ +import * as React from 'react'; +import { cn } from '../util/cn'; + +export function MediaCard(props: { + title: string; + description?: string; + imageUrl: string; + meta?: string; + onClick?: () => void; + className?: string; +}) { + return ( +
    +
    + +
    +
    +
    + {props.title} +
    + {props.description ?
    {props.description}
    : null} + {props.meta ?
    {props.meta}
    : null} +
    +
    + ); +} diff --git a/packages/ui/src/primitives/Skeleton.tsx b/packages/ui/src/primitives/Skeleton.tsx new file mode 100644 index 0000000..1391d91 --- /dev/null +++ b/packages/ui/src/primitives/Skeleton.tsx @@ -0,0 +1,13 @@ +import * as React from 'react'; +import { cn } from '../util/cn'; + +export function Skeleton(props: { className?: string }) { + return ( +
    + ); +} diff --git a/packages/ui/src/primitives/StatCard.tsx b/packages/ui/src/primitives/StatCard.tsx new file mode 100644 index 0000000..733a309 --- /dev/null +++ b/packages/ui/src/primitives/StatCard.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import { cn } from '../util/cn'; + +export function StatCard(props: { + label: string; + value: string; + helper?: string; + tone?: 'default' | 'success' | 'warning' | 'danger'; + onClick?: () => void; + className?: string; +}) { + const tone = props.tone ?? 'default'; + const toneMap: Record = { + default: 'rgba(var(--g-accent), 0.8)', + success: 'rgba(96, 255, 204, 0.9)', + warning: 'rgba(255, 198, 93, 0.9)', + danger: 'rgba(255, 110, 110, 0.9)', + }; + const accent = toneMap[tone]; + + return ( +
    +
    {props.label}
    +
    {props.value}
    + {props.helper ?
    {props.helper}
    : null} +
    + ); +} diff --git a/packages/ui/src/primitives/TextAreaField.tsx b/packages/ui/src/primitives/TextAreaField.tsx new file mode 100644 index 0000000..076e9fa --- /dev/null +++ b/packages/ui/src/primitives/TextAreaField.tsx @@ -0,0 +1,19 @@ +import * as React from 'react'; +import { cn } from '../util/cn'; + +export function TextAreaField(props: { + label: string; + hint?: string; + error?: string; + textareaProps?: React.TextareaHTMLAttributes; + className?: string; +}) { + return ( +