fixing style
This commit is contained in:
parent
e454940dfa
commit
f40485842d
35 changed files with 16000 additions and 286 deletions
|
|
@ -3,8 +3,24 @@
|
|||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#1e1e2e" />
|
||||
<meta name="theme-color" content="#120826" />
|
||||
<title>Urpi's backlog</title>
|
||||
|
||||
<!--
|
||||
The shared styling contract and its two controls are added to this shell by the
|
||||
server, from CMS_THEME_BASE_URL. They are not written here because a build-time
|
||||
placeholder has to survive two things that both break quietly: the bundler tries
|
||||
to resolve a module URL at build time and fails the build, and an unset or
|
||||
whitespace-only environment variable produces a relative URL that 404s on every
|
||||
page load.
|
||||
|
||||
With the origin unset, nothing is added and the stylesheet's own fallbacks carry
|
||||
the application: every colour in styles.css is written as
|
||||
var(--bn-role, <the colour it used to be>), so the page renders exactly as it
|
||||
did before the contract existed.
|
||||
|
||||
See internal/web/handlers.go, applyThemeOrigin.
|
||||
-->
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
24
ui/app/package-lock.json
generated
24
ui/app/package-lock.json
generated
|
|
@ -1,13 +1,14 @@
|
|||
{
|
||||
"name": "cms-ui",
|
||||
"name": "go-tell-ui",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "cms-ui",
|
||||
"name": "go-tell-ui",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"lucide-react": "1.52.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-router-dom": "^7.9.1"
|
||||
|
|
@ -1266,7 +1267,7 @@
|
|||
"version": "19.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
|
||||
"integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
|
|
@ -1395,7 +1396,7 @@
|
|||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
|
|
@ -1561,6 +1562,21 @@
|
|||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.52.0.tgz",
|
||||
"integrity": "sha512-TUgPtl5ZI9WgxOcbu4ckaC5B3l1qxPQrCMgb6OfUB9owx/OvSJfcf93SMgUWAxRw5/1XDfh9uI5F5iNGXCcGDQ==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
|
|
|
|||
|
|
@ -6,9 +6,10 @@
|
|||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --build --force"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "1.52.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-router-dom": "^7.9.1"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import {useEffect, useRef, useState} from 'react'
|
||||
import {useEffect, useRef, useState, type ReactNode} from 'react'
|
||||
import {ArrowLeft, X} from 'lucide-react'
|
||||
import {Link, Navigate, Route, Routes, useParams} from 'react-router-dom'
|
||||
import {
|
||||
type Content,
|
||||
|
|
@ -12,6 +13,7 @@ import {
|
|||
import {useAuth} from './auth'
|
||||
import {ContentCard} from './components/ContentCard'
|
||||
import {ContentFilters} from './components/ContentFilters'
|
||||
import {Avatar, LoginGate} from './components/AccountMenu'
|
||||
import {Shell} from './components/Shell'
|
||||
import {type Badge, type Category} from './models'
|
||||
|
||||
|
|
@ -81,7 +83,7 @@ function ContentList() {
|
|||
return <Shell>
|
||||
<section className="content-page">
|
||||
<div className="page-heading">
|
||||
<div><span className="eyebrow">$ cd ~/backlog && ls</span></div>
|
||||
<div><span className="eyebrow">$ cd /dev/null && ls</span></div>
|
||||
</div>
|
||||
<ContentFilters categories={categories} badgeOptions={badgeOptions} category={category} status={status}
|
||||
badges={badges} badgeInput={badgeInput} onCategoryChange={setCategory} onStatusChange={setStatus}
|
||||
|
|
@ -136,8 +138,8 @@ function ContentDetail() {
|
|||
<div className="card"><p>Loading content…</p></div>
|
||||
</Shell>
|
||||
if (error || !item || !draft) return <Shell>
|
||||
<div className="card"><p className="error" role="alert">{error ?? 'Content not found.'}</p><Link to="/content">Back
|
||||
to content</Link></div>
|
||||
<div className="card"><p className="error" role="alert">{error ?? 'Content not found.'}</p><Link to="/dev/null">Back
|
||||
to /dev/null</Link></div>
|
||||
</Shell>
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
|
|
@ -161,7 +163,9 @@ function ContentDetail() {
|
|||
}
|
||||
return <Shell>
|
||||
<article className="detail-page">
|
||||
<Link className="back-link" to="/content">← All content</Link>
|
||||
<Link className="back-link" to="/dev/null">
|
||||
<ArrowLeft aria-hidden="true"/> All content (/dev/null)
|
||||
</Link>
|
||||
{error && <p className="error" role="alert">{error}</p>}
|
||||
{editing ? <div className="editor">
|
||||
<label>Title<input value={draft.title}
|
||||
|
|
@ -181,7 +185,7 @@ function ContentDetail() {
|
|||
<button type="button" aria-label={`Remove ${badge}`} onClick={() => setDraft({
|
||||
...draft,
|
||||
badges: (draft.badges ?? []).filter(value => value !== badge)
|
||||
})}>×</button></span>)}
|
||||
})}><X aria-hidden="true"/></button></span>)}
|
||||
<input list="detail-badge-options" value={badgeInput} placeholder="Type a badge and press Enter"
|
||||
onChange={e => setBadgeInput(e.target.value)} onKeyDown={e => {
|
||||
if (e.key !== 'Enter') return
|
||||
|
|
@ -215,21 +219,49 @@ function ContentDetail() {
|
|||
</Shell>
|
||||
}
|
||||
|
||||
import {BacklogRc} from './components/BacklogRc'
|
||||
|
||||
function RequireAuth({title, children}: {title: string; children: ReactNode}) {
|
||||
const {authenticated, loading} = useAuth()
|
||||
if (loading) return <Shell><p className="muted">Checking session…</p></Shell>
|
||||
if (!authenticated) return <Shell><LoginGate title={title}/></Shell>
|
||||
return children
|
||||
}
|
||||
|
||||
function ProfilePage() {
|
||||
const {avatarUrl, displayName, email, username} = useAuth()
|
||||
const name = (displayName || username || 'Profile').trim()
|
||||
return <Shell>
|
||||
<article className="detail-page profile-page">
|
||||
<span className="eyebrow">account</span>
|
||||
<div className="profile-card">
|
||||
<Avatar name={name} url={avatarUrl} size={72}/>
|
||||
<h1>{name}</h1>
|
||||
{email && <p className="muted">{email}</p>}
|
||||
</div>
|
||||
</article>
|
||||
</Shell>
|
||||
}
|
||||
|
||||
function ProtectedTest() {
|
||||
return <Shell>
|
||||
<section className="card protected-card"><span className="eyebrow">Owner session</span><h1>Protected
|
||||
content</h1><p>Your SSO proxy asserted the owner identity for this request.</p><Link
|
||||
className="primary-button inline-button" to="/content">Browse content</Link></section>
|
||||
content</h1><p>Your session is signed in. This page is not linked until you are.</p><Link
|
||||
className="primary-button inline-button" to="/dev/null">Browse /dev/null</Link></section>
|
||||
</Shell>
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return <Routes>
|
||||
<Route path="/" element={<Navigate to="/content" replace/>}/>
|
||||
<Route path="/protected" element={<ProtectedTest/>}/>
|
||||
<Route path="/test" element={<ProtectedTest/>}/>
|
||||
<Route path="/content" element={<ContentList/>}/>
|
||||
<Route path="/content/:id" element={<ContentDetail/>}/>
|
||||
<Route path="*" element={<Navigate to="/content" replace/>}/>
|
||||
<Route path="/" element={<Navigate to="/dev/null" replace/>}/>
|
||||
<Route path="/dev/null" element={<ContentList/>}/>
|
||||
<Route path="/dev/null/:id" element={<ContentDetail/>}/>
|
||||
<Route path="/etc/backlog.rc" element={<RequireAuth title="Backlog management"><BacklogRc/></RequireAuth>}/>
|
||||
<Route path="/profile" element={<RequireAuth title="Profile"><ProfilePage/></RequireAuth>}/>
|
||||
<Route path="/content" element={<Navigate to="/dev/null" replace/>}/>
|
||||
<Route path="/content/:id" element={<Navigate to="/dev/null" replace/>}/>
|
||||
<Route path="/protected" element={<RequireAuth title="Protected content"><ProtectedTest/></RequireAuth>}/>
|
||||
<Route path="/test" element={<RequireAuth title="Protected content"><ProtectedTest/></RequireAuth>}/>
|
||||
<Route path="*" element={<Navigate to="/dev/null" replace/>}/>
|
||||
</Routes>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import {type Badge, type Category, type Content, type ContentListResponse, type ContentUpdate} from './models'
|
||||
import {type Badge, type Category, type Content, type ContentListResponse, type ContentUpdate, type NewContent} from './models'
|
||||
import {type DemoUser, type SessionResponse} from './models'
|
||||
export type {Badge, Category, Content, ContentListResponse, ContentUpdate, DemoUser, SessionResponse}
|
||||
export type {Badge, Category, Content, ContentListResponse, ContentUpdate, NewContent, DemoUser, SessionResponse}
|
||||
|
||||
const baseUrl = (import.meta.env.VITE_API_BASE_URL ?? '').replace(/\/$/, '')
|
||||
export const apiConfig = {
|
||||
|
|
@ -139,4 +139,35 @@ export function updateContent(id: string, content: ContentUpdate): Promise<Conte
|
|||
method: 'PUT',
|
||||
body: JSON.stringify(content),
|
||||
}).then(normalizeContent)
|
||||
}
|
||||
|
||||
export function createContent(content: NewContent): Promise<Content> {
|
||||
return contentRequest<RawContent>('', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(content),
|
||||
}).then(normalizeContent)
|
||||
}
|
||||
|
||||
export async function deleteContent(idOrSlug: string): Promise<void> {
|
||||
const response = await fetch(apiUrl(`${apiConfig.content}/${encodeURIComponent(idOrSlug)}`), {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
headers: { Accept: 'application/json', ...demoAuthHeaders() },
|
||||
})
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) throw new Error('You must be signed in via SSO to delete content.')
|
||||
throw new Error((await response.text()) || 'Unable to delete content.')
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearContentTable(): Promise<void> {
|
||||
const response = await fetch(apiUrl('/api/admin/reset'), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { Accept: 'application/json', ...demoAuthHeaders() },
|
||||
})
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) throw new Error('You must be signed in via SSO to clear the table.')
|
||||
throw new Error((await response.text()) || 'Failed to clear table.')
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,10 @@ type AuthState = {
|
|||
authenticated: boolean
|
||||
canEdit: boolean
|
||||
username?: string
|
||||
displayName?: string
|
||||
email?: string
|
||||
avatarUrl?: string
|
||||
demo: boolean
|
||||
error: string | null
|
||||
refresh: () => Promise<void>
|
||||
canSignIn: boolean
|
||||
|
|
@ -25,6 +29,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
const [authenticated, setAuthenticated] = useState(false)
|
||||
const [canEdit, setCanEdit] = useState(false)
|
||||
const [username, setUsername] = useState<string | undefined>()
|
||||
const [displayName, setDisplayName] = useState<string | undefined>()
|
||||
const [email, setEmail] = useState<string | undefined>()
|
||||
const [avatarUrl, setAvatarUrl] = useState<string | undefined>()
|
||||
const [demo, setDemo] = useState(false)
|
||||
const [loginUrl, setLoginUrl] = useState('')
|
||||
const [logoutUrl, setLogoutUrl] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
|
@ -37,11 +45,18 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
setAuthenticated(session?.authenticated === true)
|
||||
setCanEdit(session?.canEdit === true)
|
||||
setUsername(session?.username)
|
||||
setDisplayName(session?.displayName)
|
||||
setEmail(session?.email)
|
||||
setAvatarUrl(session?.avatarUrl)
|
||||
setDemo(session?.demo === true)
|
||||
setLoginUrl(session?.loginUrl ?? '')
|
||||
setLogoutUrl(session?.logoutUrl ?? '')
|
||||
} catch (err) {
|
||||
setAuthenticated(false)
|
||||
setCanEdit(false)
|
||||
setDisplayName(undefined)
|
||||
setEmail(undefined)
|
||||
setAvatarUrl(undefined)
|
||||
setError(err instanceof Error ? err.message : 'Unable to check your session.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
|
|
@ -63,6 +78,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
setAuthenticated(true)
|
||||
setCanEdit(true)
|
||||
setUsername(result.user.username)
|
||||
setDisplayName(result.user.name)
|
||||
setEmail(result.user.username)
|
||||
}, [])
|
||||
const signOut = useCallback(() => {
|
||||
if (logoutUrl) {
|
||||
|
|
@ -73,13 +90,16 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
setAuthenticated(false)
|
||||
setCanEdit(false)
|
||||
setUsername(undefined)
|
||||
setDisplayName(undefined)
|
||||
setEmail(undefined)
|
||||
setAvatarUrl(undefined)
|
||||
}, [logoutUrl])
|
||||
const canSignIn = apiConfig.signIn !== '' || loginUrl !== ''
|
||||
const canSignOut = logoutUrl !== ''
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ loading, authenticated, canEdit, username, error, refresh, canSignIn, canSignOut, signIn, sudo, signOut }),
|
||||
[loading, authenticated, canEdit, username, error, refresh, canSignIn, canSignOut, signIn, sudo, signOut],
|
||||
() => ({ loading, authenticated, canEdit, username, displayName, email, avatarUrl, demo, error, refresh, canSignIn, canSignOut, signIn, sudo, signOut }),
|
||||
[loading, authenticated, canEdit, username, displayName, email, avatarUrl, demo, error, refresh, canSignIn, canSignOut, signIn, sudo, signOut],
|
||||
)
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||
}
|
||||
|
|
|
|||
47
ui/app/src/bananas-elements.d.ts
vendored
Normal file
47
ui/app/src/bananas-elements.d.ts
vendored
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
/**
|
||||
* Type declarations for the custom elements the theme service ships.
|
||||
*
|
||||
* React 19 passes unknown elements and their attributes through to the DOM, so the
|
||||
* elements work without a wrapper component. Without these declarations the tag names
|
||||
* are unknown to the JSX checker and typecheck fails, which is why the elements are
|
||||
* declared rather than wrapped.
|
||||
*
|
||||
* The augmentation targets `react`'s own JSX namespace rather than a global one:
|
||||
* React 19 removed the global `JSX` namespace, so augmenting it has no effect and the
|
||||
* tags stay unrecognised.
|
||||
*/
|
||||
|
||||
declare global {
|
||||
namespace React {
|
||||
namespace JSX {
|
||||
/** Reads the published palette list from a theme service. */
|
||||
interface BananasThemeSelectorProps extends HTMLAttributes<HTMLElement> {
|
||||
/**
|
||||
* The service runtime base. Optional: when omitted the control reads the origin
|
||||
* from the contract stylesheet already linked on the page, and falls back to
|
||||
* the same-origin path.
|
||||
*/
|
||||
base?: string
|
||||
/** Accessible label. */
|
||||
label?: string
|
||||
/** Omit the "no theme" entry. */
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
/** Admin chrome, light, or dark. */
|
||||
interface BananasAppearanceToggleProps extends HTMLAttributes<HTMLElement> {
|
||||
/** Accessible label. */
|
||||
label?: string
|
||||
/** Render the text labels rather than icons only. */
|
||||
showLabels?: boolean
|
||||
}
|
||||
|
||||
interface IntrinsicElements {
|
||||
'bananas-theme-selector': BananasThemeSelectorProps
|
||||
'bananas-appearance-toggle': BananasAppearanceToggleProps
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
113
ui/app/src/components/AccountMenu.tsx
Normal file
113
ui/app/src/components/AccountMenu.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import {useEffect, useId, useRef, useState} from 'react'
|
||||
import {LogOut, UserRound} from 'lucide-react'
|
||||
import {Link} from 'react-router-dom'
|
||||
import {listDemoUsers} from '../api'
|
||||
import {useAuth} from '../auth'
|
||||
import {type DemoUser} from '../models'
|
||||
import {SudoTerminal} from './SudoTerminal'
|
||||
|
||||
function initials(name: string) {
|
||||
const parts = name.split(/[\s@._-]+/).filter(Boolean).slice(0, 2)
|
||||
const value = parts.map(part => part[0]?.toUpperCase() ?? '').join('')
|
||||
return value || '?'
|
||||
}
|
||||
|
||||
export function Avatar({name, url, size = 24}: {name: string; url?: string; size?: number}) {
|
||||
if (url) {
|
||||
return <img className="avatar" src={url} alt="" width={size} height={size}/>
|
||||
}
|
||||
return <span className="avatar avatar-empty" style={{width: size, height: size}}>{initials(name)}</span>
|
||||
}
|
||||
|
||||
function LoginButton() {
|
||||
const {canSignIn, demo, signIn, sudo} = useAuth()
|
||||
const [sudoOpen, setSudoOpen] = useState(false)
|
||||
const [users, setUsers] = useState<DemoUser[]>([])
|
||||
|
||||
const start = () => {
|
||||
if (canSignIn || !demo) {
|
||||
signIn()
|
||||
return
|
||||
}
|
||||
setSudoOpen(true)
|
||||
void listDemoUsers().then(setUsers).catch(() => setUsers([]))
|
||||
}
|
||||
|
||||
return <>
|
||||
<button className="account-login" type="button" onClick={start}>
|
||||
<span aria-hidden="true">›</span> Log in
|
||||
</button>
|
||||
{sudoOpen && <SudoTerminal users={users} onClose={() => setSudoOpen(false)} onLogin={async (username, password) => {
|
||||
await sudo(username, password)
|
||||
setSudoOpen(false)
|
||||
}}/>}
|
||||
</>
|
||||
}
|
||||
|
||||
export function AccountMenu() {
|
||||
const {authenticated, loading, username, displayName, email, avatarUrl, canSignOut, signOut} = useAuth()
|
||||
const [open, setOpen] = useState(false)
|
||||
const container = useRef<HTMLDivElement>(null)
|
||||
const trigger = useRef<HTMLButtonElement>(null)
|
||||
const panelId = useId()
|
||||
const name = (displayName || username || email || 'Profile').trim()
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') return
|
||||
setOpen(false)
|
||||
trigger.current?.focus()
|
||||
}
|
||||
const onPointerDown = (event: MouseEvent) => {
|
||||
if (!container.current?.contains(event.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
document.addEventListener('mousedown', onPointerDown)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
document.removeEventListener('mousedown', onPointerDown)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
if (loading) return null
|
||||
if (!authenticated) return <LoginButton/>
|
||||
|
||||
return <div className="account" ref={container}>
|
||||
<button
|
||||
ref={trigger}
|
||||
type="button"
|
||||
className="account-trigger"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
aria-controls={panelId}
|
||||
onClick={() => setOpen(value => !value)}
|
||||
>
|
||||
<Avatar name={name} url={avatarUrl}/>
|
||||
<span className="account-name">{name}</span>
|
||||
<span className="account-caret" aria-hidden="true">▾</span>
|
||||
</button>
|
||||
{open && <div className="account-menu" id={panelId} role="menu" aria-label="Account">
|
||||
<div className="account-identity">
|
||||
<strong className="account-display-name">{name}</strong>
|
||||
{email && email !== name && <span className="account-email">{email}</span>}
|
||||
</div>
|
||||
<Link className="account-item" role="menuitem" to="/profile" onClick={() => setOpen(false)}>
|
||||
<UserRound aria-hidden="true"/> Profile
|
||||
</Link>
|
||||
<button className="account-item" type="button" role="menuitem" onClick={() => { setOpen(false); signOut() }}>
|
||||
<LogOut aria-hidden="true"/> Log out
|
||||
</button>
|
||||
{canSignOut && <p className="account-note">Signing out here also ends your sessions in the applications that use this sign-in.</p>}
|
||||
</div>}
|
||||
</div>
|
||||
}
|
||||
|
||||
export function LoginGate({title}: {title: string}) {
|
||||
return <section className="card protected-card">
|
||||
<span className="eyebrow">authentication required</span>
|
||||
<h1>{title}</h1>
|
||||
<p className="muted">This page stays hidden until you are signed in.</p>
|
||||
<LoginButton/>
|
||||
</section>
|
||||
}
|
||||
679
ui/app/src/components/BacklogRc.tsx
Normal file
679
ui/app/src/components/BacklogRc.tsx
Normal file
|
|
@ -0,0 +1,679 @@
|
|||
import {useEffect, useMemo, useState} from 'react'
|
||||
import {ArrowLeft, Check, Lock, Pencil, Plus, Trash2, TriangleAlert, X, Zap} from 'lucide-react'
|
||||
import {Link} from 'react-router-dom'
|
||||
import {
|
||||
clearContentTable,
|
||||
createContent,
|
||||
deleteContent,
|
||||
listBadges,
|
||||
listCategories,
|
||||
listContent,
|
||||
updateContent,
|
||||
} from '../api'
|
||||
import {useAuth} from '../auth'
|
||||
import {type Badge, type Category, type Content, type NewContent} from '../models'
|
||||
import {Shell} from './Shell'
|
||||
|
||||
export function BacklogRc() {
|
||||
const {authenticated, username, signIn} = useAuth()
|
||||
const [items, setItems] = useState<Content[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [notice, setNotice] = useState<string | null>(null)
|
||||
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [badges, setBadges] = useState<Badge[]>([])
|
||||
|
||||
// Filters
|
||||
const [search, setSearch] = useState('')
|
||||
const [selectedCategory, setSelectedCategory] = useState('')
|
||||
const [selectedStatus, setSelectedStatus] = useState('')
|
||||
|
||||
// Modals state
|
||||
const [showClearConfirm, setShowClearConfirm] = useState(false)
|
||||
const [clearing, setClearing] = useState(false)
|
||||
|
||||
const [itemToDelete, setItemToDelete] = useState<Content | null>(null)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
const [editorOpen, setEditorOpen] = useState(false)
|
||||
const [editingItem, setEditingItem] = useState<Content | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
// Form fields
|
||||
const [formTitle, setFormTitle] = useState('')
|
||||
const [formSlug, setFormSlug] = useState('')
|
||||
const [formCategory, setFormCategory] = useState('')
|
||||
const [formStatus, setFormStatus] = useState('published')
|
||||
const [formBadges, setFormBadges] = useState('')
|
||||
const [formSummary, setFormSummary] = useState('')
|
||||
const [formBody, setFormBody] = useState('')
|
||||
const [formAuthor, setFormAuthor] = useState('')
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const [contentRes, catRes, badgeRes] = await Promise.all([
|
||||
listContent(null, '', '', []),
|
||||
listCategories(),
|
||||
listBadges(),
|
||||
])
|
||||
const list = Array.isArray(contentRes)
|
||||
? contentRes
|
||||
: contentRes.items ?? contentRes.content ?? contentRes.data ?? []
|
||||
setItems(list)
|
||||
setCategories(catRes)
|
||||
setBadges(badgeRes)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load backlog data.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadData()
|
||||
}, [])
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
return items.filter(item => {
|
||||
if (selectedCategory && item.category !== selectedCategory) return false
|
||||
if (selectedStatus && item.status !== selectedStatus) return false
|
||||
if (search.trim()) {
|
||||
const query = search.toLowerCase()
|
||||
const matchesTitle = item.title.toLowerCase().includes(query)
|
||||
const matchesSlug = item.id.toLowerCase().includes(query)
|
||||
const matchesSummary = (item.summary ?? '').toLowerCase().includes(query)
|
||||
const matchesAuthor = (item.author ?? '').toLowerCase().includes(query)
|
||||
const matchesBadge = (item.badges ?? []).some(b => b.toLowerCase().includes(query))
|
||||
if (!matchesTitle && !matchesSlug && !matchesSummary && !matchesAuthor && !matchesBadge) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}, [items, search, selectedCategory, selectedStatus])
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const total = items.length
|
||||
const published = items.filter(i => i.status === 'published').length
|
||||
const draft = items.filter(i => i.status === 'draft').length
|
||||
const archived = items.filter(i => i.status === 'archived').length
|
||||
return {total, published, draft, archived}
|
||||
}, [items])
|
||||
|
||||
const openCreateModal = () => {
|
||||
setEditingItem(null)
|
||||
setFormTitle('')
|
||||
setFormSlug('')
|
||||
setFormCategory(categories[0]?.slug ?? 'engineering')
|
||||
setFormStatus('published')
|
||||
setFormBadges('')
|
||||
setFormSummary('')
|
||||
setFormBody('')
|
||||
setFormAuthor(username ?? 'sso-user')
|
||||
setEditorOpen(true)
|
||||
}
|
||||
|
||||
const openEditModal = (item: Content) => {
|
||||
setEditingItem(item)
|
||||
setFormTitle(item.title)
|
||||
setFormSlug(item.id)
|
||||
setFormCategory(item.category ?? '')
|
||||
setFormStatus(item.status || 'published')
|
||||
setFormBadges((item.badges ?? []).join(', '))
|
||||
setFormSummary(item.summary ?? '')
|
||||
setFormBody(item.body ?? '')
|
||||
setFormAuthor(item.author ?? username ?? '')
|
||||
setEditorOpen(true)
|
||||
}
|
||||
|
||||
const handleSavePost = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!formTitle.trim() || !formBody.trim()) {
|
||||
setError('Title and Body are required.')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
setNotice(null)
|
||||
try {
|
||||
const parsedBadges = formBadges
|
||||
.split(',')
|
||||
.map(b => b.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
|
||||
if (editingItem) {
|
||||
// Update
|
||||
await updateContent(editingItem.id, {
|
||||
title: formTitle.trim(),
|
||||
summary: formSummary.trim(),
|
||||
body: formBody.trim(),
|
||||
status: formStatus,
|
||||
badges: parsedBadges,
|
||||
category: formCategory.trim(),
|
||||
})
|
||||
setNotice(`Post "${formTitle}" updated successfully.`)
|
||||
} else {
|
||||
// Create
|
||||
const payload: NewContent = {
|
||||
title: formTitle.trim(),
|
||||
slug: formSlug.trim() || undefined,
|
||||
summary: formSummary.trim(),
|
||||
body: formBody.trim(),
|
||||
status: formStatus,
|
||||
category: formCategory.trim() || undefined,
|
||||
badges: parsedBadges,
|
||||
author: formAuthor.trim() || username || 'sso-user',
|
||||
}
|
||||
await createContent(payload)
|
||||
setNotice(`Post "${formTitle}" created successfully.`)
|
||||
}
|
||||
setEditorOpen(false)
|
||||
await loadData()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save post.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeletePost = async () => {
|
||||
if (!itemToDelete) return
|
||||
setDeleting(true)
|
||||
setError(null)
|
||||
setNotice(null)
|
||||
try {
|
||||
await deleteContent(itemToDelete.id)
|
||||
setNotice(`Post "${itemToDelete.title}" deleted successfully.`)
|
||||
setItemToDelete(null)
|
||||
await loadData()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete post.')
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearTable = async () => {
|
||||
setClearing(true)
|
||||
setError(null)
|
||||
setNotice(null)
|
||||
try {
|
||||
await clearContentTable()
|
||||
setShowClearConfirm(false)
|
||||
setNotice('Backlog table cleared successfully.')
|
||||
await loadData()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to clear backlog table.')
|
||||
} finally {
|
||||
setClearing(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Shell>
|
||||
<section className="crud-page">
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<span className="eyebrow">$ nano /etc/backlog.rc</span>
|
||||
<h1>Backlog Post Management</h1>
|
||||
<p className="muted">
|
||||
Interactive CRUD control plane for articles and posts. Authenticated via SSO.
|
||||
</p>
|
||||
</div>
|
||||
<div className="page-heading-actions">
|
||||
<Link to="/dev/null" className="secondary-button" style={{margin: 0, width: 'auto'}}>
|
||||
<ArrowLeft aria-hidden="true" /> View /dev/null
|
||||
</Link>
|
||||
<button className="primary-button" type="button" onClick={openCreateModal}>
|
||||
<Plus aria-hidden="true" /> New Post
|
||||
</button>
|
||||
<button
|
||||
className="danger-button"
|
||||
type="button"
|
||||
onClick={() => setShowClearConfirm(true)}
|
||||
title="Clear all records from the table"
|
||||
>
|
||||
<Trash2 aria-hidden="true" /> Clear Table
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!authenticated && (
|
||||
<div className="sso-warning-banner">
|
||||
<div>
|
||||
<strong>
|
||||
<Lock aria-hidden="true" /> SSO Authentication Required for Mutations
|
||||
</strong>
|
||||
<p className="small muted">
|
||||
You are currently in read-only mode. Sign in via SSO to create, update, delete, or clear posts.
|
||||
</p>
|
||||
</div>
|
||||
<button className="primary-button" type="button" onClick={signIn}>
|
||||
<Zap aria-hidden="true" /> Sign In with SSO
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notice && (
|
||||
<div className="success-banner" role="status">
|
||||
<span>
|
||||
<Check aria-hidden="true" /> {notice}
|
||||
</span>
|
||||
<button type="button" onClick={() => setNotice(null)} aria-label="Dismiss">
|
||||
<X aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="error" role="alert" style={{marginBottom: 20}}>
|
||||
<span>
|
||||
<TriangleAlert aria-hidden="true" /> {error}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setError(null)}
|
||||
aria-label="Dismiss"
|
||||
style={{float: 'right', background: 'none', border: 0, color: 'inherit', cursor: 'pointer'}}
|
||||
>
|
||||
<X aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats Row */}
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Total Posts</div>
|
||||
<div className="stat-value">{stats.total}</div>
|
||||
</div>
|
||||
<div className="stat-card stat-published">
|
||||
<div className="stat-label">Published</div>
|
||||
<div className="stat-value">{stats.published}</div>
|
||||
</div>
|
||||
<div className="stat-card stat-draft">
|
||||
<div className="stat-label">Drafts</div>
|
||||
<div className="stat-value">{stats.draft}</div>
|
||||
</div>
|
||||
<div className="stat-card stat-archived">
|
||||
<div className="stat-label">Archived</div>
|
||||
<div className="stat-value">{stats.archived}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search & Filter Toolbar */}
|
||||
<div className="table-toolbar">
|
||||
<div className="search-box">
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search posts by title, slug, summary, author..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="filter-group">
|
||||
<select
|
||||
value={selectedCategory}
|
||||
onChange={e => setSelectedCategory(e.target.value)}
|
||||
aria-label="Filter by category"
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
{categories.map(cat => (
|
||||
<option key={cat.id} value={cat.slug}>{cat.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={selectedStatus}
|
||||
onChange={e => setSelectedStatus(e.target.value)}
|
||||
aria-label="Filter by status"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="published">Published</option>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
{(search || selectedCategory || selectedStatus) && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-button"
|
||||
onClick={() => {
|
||||
setSearch('')
|
||||
setSelectedCategory('')
|
||||
setSelectedStatus('')
|
||||
}}
|
||||
>
|
||||
Reset Filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Posts Table */}
|
||||
<div className="table-container card" style={{maxWidth: 'none', padding: 0, margin: '0 0 40px'}}>
|
||||
{loading ? (
|
||||
<div style={{padding: 40, textAlign: 'center'}} className="muted">
|
||||
Loading posts from /etc/backlog.rc...
|
||||
</div>
|
||||
) : filteredItems.length === 0 ? (
|
||||
<div style={{padding: 50, textAlign: 'center'}}>
|
||||
<h2>No posts found</h2>
|
||||
<p className="muted">
|
||||
{items.length === 0
|
||||
? 'The backlog table is empty. Create a post to populate the database.'
|
||||
: 'No posts match your current search and filter criteria.'}
|
||||
</p>
|
||||
{items.length === 0 && (
|
||||
<button className="primary-button" type="button" onClick={openCreateModal} style={{marginTop: 16}}>
|
||||
<Plus aria-hidden="true" /> Create First Post
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<table className="crud-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{width: 60}}>#</th>
|
||||
<th>Title & Slug</th>
|
||||
<th style={{width: 130}}>Category</th>
|
||||
<th>Badges</th>
|
||||
<th style={{width: 110}}>Status</th>
|
||||
<th style={{width: 140}}>Author</th>
|
||||
<th style={{width: 130}}>Date</th>
|
||||
<th style={{width: 160, textAlign: 'right'}}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredItems.map((item, idx) => (
|
||||
<tr key={item.id}>
|
||||
<td className="muted font-mono">{idx + 1}</td>
|
||||
<td>
|
||||
<div className="post-title-cell">
|
||||
<Link to={`/dev/null/${item.id}`} className="post-link font-semibold">
|
||||
{item.title}
|
||||
</Link>
|
||||
<span className="post-slug font-mono">/{item.id}</span>
|
||||
</div>
|
||||
{item.summary && <div className="post-summary-preview muted">{item.summary}</div>}
|
||||
</td>
|
||||
<td>
|
||||
<span className="category-pill">{item.category || 'general'}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className="badge-list-inline">
|
||||
{(item.badges ?? []).map(b => (
|
||||
<span key={b} className="badge small">{b}</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`status-pill status-${item.status}`}>
|
||||
{item.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="muted small font-mono">{item.author || 'system'}</td>
|
||||
<td className="muted small">
|
||||
{item.updatedAt
|
||||
? new Date(item.updatedAt).toLocaleDateString()
|
||||
: item.createdAt
|
||||
? new Date(item.createdAt).toLocaleDateString()
|
||||
: '-'}
|
||||
</td>
|
||||
<td style={{textAlign: 'right'}}>
|
||||
<div className="action-buttons-cell">
|
||||
<button
|
||||
type="button"
|
||||
className="table-action-button"
|
||||
onClick={() => openEditModal(item)}
|
||||
title="Edit this post"
|
||||
>
|
||||
<Pencil aria-hidden="true" /> Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="table-action-button delete"
|
||||
onClick={() => setItemToDelete(item)}
|
||||
title="Delete this post"
|
||||
aria-label={`Delete ${item.title}`}
|
||||
>
|
||||
<Trash2 aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Confirmation Screen / Modal for Clear Table */}
|
||||
{showClearConfirm && (
|
||||
<div className="terminal-backdrop" role="dialog" aria-modal="true">
|
||||
<div className="terminal-window confirm-modal">
|
||||
<div className="terminal-titlebar">
|
||||
<span>
|
||||
<TriangleAlert aria-hidden="true" /> CONFIRMATION: CLEAR TABLE
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="terminal-close"
|
||||
onClick={() => setShowClearConfirm(false)}
|
||||
disabled={clearing}
|
||||
aria-label="Close"
|
||||
>
|
||||
<X aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="terminal-body" style={{padding: 28}}>
|
||||
<div className="warning-callout">
|
||||
<h3>Permanent Deletion Warning</h3>
|
||||
<p>
|
||||
You are about to clear all <strong>{items.length}</strong> posts from the backlog table.
|
||||
All articles, summaries, revisions, and content will be irreversibly removed.
|
||||
</p>
|
||||
<p className="muted small">
|
||||
SSO authentication will authorize this operation against <code>/api/admin/reset</code>.
|
||||
</p>
|
||||
</div>
|
||||
<div className="modal-actions" style={{display: 'flex', justifyContent: 'flex-end', gap: 12, marginTop: 18}}>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
style={{width: 'auto', margin: 0}}
|
||||
onClick={() => setShowClearConfirm(false)}
|
||||
disabled={clearing}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="danger-button"
|
||||
onClick={() => void handleClearTable()}
|
||||
disabled={clearing}
|
||||
>
|
||||
{clearing ? 'Clearing Table…' : 'Yes, Permanently Clear Table'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Single Delete Confirmation Modal */}
|
||||
{itemToDelete && (
|
||||
<div className="terminal-backdrop" role="dialog" aria-modal="true">
|
||||
<div className="terminal-window confirm-modal">
|
||||
<div className="terminal-titlebar">
|
||||
<span>CONFIRM DELETION</span>
|
||||
<button
|
||||
type="button"
|
||||
className="terminal-close"
|
||||
onClick={() => setItemToDelete(null)}
|
||||
disabled={deleting}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="terminal-body" style={{padding: 28}}>
|
||||
<p>
|
||||
Are you sure you want to delete post <strong>"{itemToDelete.title}"</strong> (<code>/{itemToDelete.id}</code>)?
|
||||
</p>
|
||||
<div className="modal-actions" style={{display: 'flex', justifyContent: 'flex-end', gap: 12, marginTop: 20}}>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
style={{width: 'auto', margin: 0}}
|
||||
onClick={() => setItemToDelete(null)}
|
||||
disabled={deleting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="danger-button"
|
||||
onClick={() => void handleDeletePost()}
|
||||
disabled={deleting}
|
||||
>
|
||||
{deleting ? 'Deleting…' : 'Delete Post'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create / Edit Post Modal */}
|
||||
{editorOpen && (
|
||||
<div className="terminal-backdrop" role="dialog" aria-modal="true">
|
||||
<div className="terminal-window editor-modal" style={{maxWidth: 750}}>
|
||||
<div className="terminal-titlebar">
|
||||
<span>{editingItem ? `EDIT POST: /${editingItem.id}` : 'NEW POST: /etc/backlog.rc'}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="terminal-close"
|
||||
onClick={() => setEditorOpen(false)}
|
||||
disabled={saving}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={e => void handleSavePost(e)} className="terminal-body editor-form" style={{padding: 28}}>
|
||||
<div className="form-grid">
|
||||
<label className="form-field full-width">
|
||||
<span>Title *</span>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="Enter article title"
|
||||
value={formTitle}
|
||||
onChange={e => setFormTitle(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{!editingItem && (
|
||||
<label className="form-field">
|
||||
<span>Custom Slug (Optional)</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. my-awesome-post"
|
||||
value={formSlug}
|
||||
onChange={e => setFormSlug(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<label className="form-field">
|
||||
<span>Category</span>
|
||||
<input
|
||||
list="crud-category-options"
|
||||
placeholder="Select or enter category"
|
||||
value={formCategory}
|
||||
onChange={e => setFormCategory(e.target.value)}
|
||||
/>
|
||||
<datalist id="crud-category-options">
|
||||
{categories.map(c => (
|
||||
<option key={c.id} value={c.slug}>{c.name}</option>
|
||||
))}
|
||||
</datalist>
|
||||
</label>
|
||||
|
||||
<label className="form-field">
|
||||
<span>Status</span>
|
||||
<select value={formStatus} onChange={e => setFormStatus(e.target.value)}>
|
||||
<option value="published">Published</option>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="form-field full-width">
|
||||
<span>Badges (comma separated)</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="go, rancher, kubernetes, sso"
|
||||
value={formBadges}
|
||||
onChange={e => setFormBadges(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="form-field full-width">
|
||||
<span>Summary</span>
|
||||
<textarea
|
||||
rows={2}
|
||||
placeholder="Brief description or teaser..."
|
||||
value={formSummary}
|
||||
onChange={e => setFormSummary(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="form-field full-width">
|
||||
<span>Body *</span>
|
||||
<textarea
|
||||
rows={8}
|
||||
required
|
||||
placeholder="Full article content in markdown or plaintext..."
|
||||
value={formBody}
|
||||
onChange={e => setFormBody(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="form-field">
|
||||
<span>Author</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Author name or email"
|
||||
value={formAuthor}
|
||||
onChange={e => setFormAuthor(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="modal-actions" style={{display: 'flex', justifyContent: 'flex-end', gap: 12, marginTop: 24}}>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
style={{width: 'auto', margin: 0}}
|
||||
onClick={() => setEditorOpen(false)}
|
||||
disabled={saving}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="primary-button"
|
||||
style={{width: 'auto', margin: 0}}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? 'Saving Post…' : editingItem ? 'Save Changes' : 'Create Post'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Shell>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,15 +1,18 @@
|
|||
import {ArrowRight} from 'lucide-react'
|
||||
import {Link} from 'react-router-dom'
|
||||
import {type Content} from '../models'
|
||||
|
||||
export function ContentCard({item}: {item: Content}) {
|
||||
return <article className="content-card">
|
||||
<span className="status">{item.status}</span>
|
||||
<h2><Link to={`/content/${item.id}`}>{item.title}</Link></h2>
|
||||
<h2><Link to={`/dev/null/${item.id}`}>{item.title}</Link></h2>
|
||||
<p>{item.summary}</p>
|
||||
<p className="content-meta">by {item.author ?? 'unknown'}
|
||||
{item.publishedAt && <> · published {new Date(item.publishedAt).toLocaleDateString()}</>}</p>
|
||||
{item.badges && item.badges.length > 0 &&
|
||||
<div className="badge-list">{item.badges.map(badge => <span className="badge" key={badge}>{badge}</span>)}</div>}
|
||||
<Link className="read-link" to={`/content/${item.id}`}>Read more <span aria-hidden="true">→</span></Link>
|
||||
<Link className="read-link" to={`/dev/null/${item.id}`}>
|
||||
Read more <ArrowRight aria-hidden="true" />
|
||||
</Link>
|
||||
</article>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import {X} from 'lucide-react'
|
||||
import {type Badge, type Category} from '../models'
|
||||
|
||||
type Props = {
|
||||
|
|
@ -36,7 +37,9 @@ export function ContentFilters(props: Props) {
|
|||
<div className="badge-input">
|
||||
{props.badges.map(badge => <span className="filter-pill" key={badge}>{badge}
|
||||
<button type="button" aria-label={`Remove ${badge}`}
|
||||
onClick={() => props.onBadgesChange(props.badges.filter(value => value !== badge))}>×</button></span>)}
|
||||
onClick={() => props.onBadgesChange(props.badges.filter(value => value !== badge))}>
|
||||
<X aria-hidden="true"/>
|
||||
</button></span>)}
|
||||
<input list="badge-options" value={props.badgeInput} placeholder="Type a badge and press Enter"
|
||||
onChange={event => props.onBadgeInputChange(event.target.value)}
|
||||
onKeyDown={event => { if (event.key === 'Enter') { event.preventDefault(); addBadge() } }}/>
|
||||
|
|
|
|||
|
|
@ -1,52 +1,146 @@
|
|||
import {useEffect, useState, type ReactNode} from 'react'
|
||||
import {Link} from 'react-router-dom'
|
||||
import {listDemoUsers} from '../api'
|
||||
import {Link, useLocation} from 'react-router-dom'
|
||||
import {useAuth} from '../auth'
|
||||
import {type DemoUser} from '../models'
|
||||
import {SudoTerminal} from './SudoTerminal'
|
||||
import {AccountMenu} from './AccountMenu'
|
||||
|
||||
/**
|
||||
* The palettes this application offers.
|
||||
*
|
||||
* The list is no longer the source of truth: the selector reads the published palettes
|
||||
* from the theme service, so a palette published there appears here with no change to
|
||||
* this file. These remain as the names this application's own rules key on, and as the
|
||||
* set the fallback below validates against.
|
||||
*/
|
||||
export type Theme = 'cyberpunk' | 'nordic' | 'obsidian' | 'terminal'
|
||||
|
||||
const THEMES: readonly string[] = ['cyberpunk', 'nordic', 'obsidian', 'terminal']
|
||||
|
||||
/** The palette used before the service has answered, and if it never does. */
|
||||
const DEFAULT_THEME = 'cyberpunk'
|
||||
|
||||
/**
|
||||
* Reads the palette from the shared contract.
|
||||
*
|
||||
* The control itself is a custom element shipped by the service, so it writes
|
||||
* `data-theme` on the root element directly. This function exists to answer "which
|
||||
* palette is current?" for the few places that need to know, without the component
|
||||
* holding a second copy of the state.
|
||||
*
|
||||
* @returns the current palette slug, or the default when none is set
|
||||
*/
|
||||
export function currentTheme(): string {
|
||||
return document.documentElement.dataset.theme || DEFAULT_THEME
|
||||
}
|
||||
|
||||
type Palette = {slug: string; name: string}
|
||||
|
||||
function ThemeDropdown() {
|
||||
const [themes, setThemes] = useState<Palette[]>(THEMES.map(slug => ({slug, name: slug})))
|
||||
const [value, setValue] = useState(DEFAULT_THEME)
|
||||
|
||||
useEffect(() => {
|
||||
setValue(currentTheme())
|
||||
const href = document.querySelector('link[data-theme-contract]')?.getAttribute('href') ?? ''
|
||||
const base = href.replace(/\/contract\.css(?:\?.*)?$/, '')
|
||||
if (!base) return
|
||||
void fetch(`${base}/contract.json`)
|
||||
.then(response => response.ok ? response.json() : Promise.reject(response.status))
|
||||
.then((payload: {themes?: Palette[]}) => {
|
||||
const published = (payload.themes ?? []).filter(theme => theme.slug)
|
||||
if (published.length > 0) setThemes(published)
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
return <label className="theme-dropdown-wrap">
|
||||
<select
|
||||
className="theme-dropdown"
|
||||
aria-label="Palette"
|
||||
value={value}
|
||||
onChange={event => {
|
||||
const slug = event.target.value
|
||||
setValue(slug)
|
||||
document.documentElement.dataset.theme = slug
|
||||
window.localStorage.setItem('urpi-theme', slug)
|
||||
}}
|
||||
>
|
||||
{themes.map(theme => <option key={theme.slug} value={theme.slug}>{theme.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
}
|
||||
|
||||
export function Shell({children}: {children: ReactNode}) {
|
||||
const {authenticated, loading, canSignIn, canSignOut, signIn, signOut, username, sudo} = useAuth()
|
||||
const [sudoOpen, setSudoOpen] = useState(false)
|
||||
const [helpOpen, setHelpOpen] = useState(false)
|
||||
const [users, setUsers] = useState<DemoUser[]>([])
|
||||
const [dark, setDark] = useState(() => window.localStorage.getItem('urpi-theme') !== 'light')
|
||||
const {authenticated, loading} = useAuth()
|
||||
const location = useLocation()
|
||||
|
||||
useEffect(() => { void listDemoUsers().then(setUsers) }, [])
|
||||
// No React state for the palette. The control is a custom element that mutates
|
||||
// `data-theme` on the root, and everything that depends on the palette is CSS
|
||||
// keyed on that attribute, so there is nothing for a re-render to update. Keeping a
|
||||
// copy in React would be a second source of truth that could disagree with the DOM.
|
||||
//
|
||||
// This effect only bridges the storage key: it migrates an existing choice forward
|
||||
// once, and mirrors later changes back so anything still reading `urpi-theme` — a
|
||||
// bookmarklet, a support script, a colleague's muscle memory — keeps working.
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.theme = dark ? 'dark' : 'light'
|
||||
window.localStorage.setItem('urpi-theme', dark ? 'dark' : 'light')
|
||||
}, [dark])
|
||||
// Establish a palette on first load. Without this the page starts with no
|
||||
// data-theme at all, so the contract matches no rule and the application renders
|
||||
// on the stylesheet's fallbacks. Those fallbacks happen to be cyberpunk, which is
|
||||
// why this was not obvious, but the palette would not actually be selected and the
|
||||
// appearance control would have nothing to switch from.
|
||||
const legacy = window.localStorage.getItem('urpi-theme')
|
||||
const stored = legacy && THEMES.includes(legacy) ? legacy : null
|
||||
if (!document.documentElement.dataset.theme) {
|
||||
document.documentElement.dataset.theme = stored || DEFAULT_THEME
|
||||
}
|
||||
|
||||
return <main className="shell">
|
||||
<header className="header">
|
||||
<Link className="brand" to="/content"><span className="brand-prompt">~/</span> Urpi's backlog
|
||||
<span className="brand-cursor">_</span><small>linux · go · java · ideas</small></Link>
|
||||
<div className="header-actions">
|
||||
{!loading && <button className="mode-badge" type="button" onClick={() => {
|
||||
if (authenticated) {
|
||||
if (canSignOut) signOut()
|
||||
return
|
||||
}
|
||||
if (users.length > 0) setSudoOpen(true)
|
||||
else if (canSignIn) signIn()
|
||||
}}>
|
||||
{authenticated ? `${username ?? 'Owner'} · sudo` : 'Anonymous · read-only'}
|
||||
</button>}
|
||||
<button className="theme-button" type="button" aria-label="Demo users"
|
||||
onClick={() => setHelpOpen(value => !value)}>?</button>
|
||||
<button className="theme-button" type="button" aria-label={`Switch to ${dark ? 'light' : 'dark'} theme`}
|
||||
onClick={() => setDark(value => !value)}>{dark ? '☀ Light' : '☾ Dark'}</button>
|
||||
</div>
|
||||
</header>
|
||||
{helpOpen && users.length > 0 && <aside className="card terminal-panel">
|
||||
<strong>sudo users</strong><p className="muted">Demo only: each password is the same as the username.</p>
|
||||
{users.map(item => <div key={item.username}><code>sudo {item.username}</code> · {item.name}</div>)}
|
||||
</aside>}
|
||||
{sudoOpen && <SudoTerminal users={users} onLogin={async (user, password) => {
|
||||
await sudo(user, password)
|
||||
setSudoOpen(false)
|
||||
}} onClose={() => setSudoOpen(false)}/>}
|
||||
{children}
|
||||
</main>
|
||||
const onChange = (event: Event) => {
|
||||
const slug = (event as CustomEvent<{slug: string | null}>).detail?.slug
|
||||
window.localStorage.setItem('urpi-theme', slug || DEFAULT_THEME)
|
||||
}
|
||||
document.addEventListener('bananas:theme', onChange)
|
||||
return () => document.removeEventListener('bananas:theme', onChange)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<main className="shell">
|
||||
<header className="header">
|
||||
<div className="brand-group">
|
||||
<Link className="brand" to="/dev/null">
|
||||
<span className="brand-prompt">~/</span> Urpi's backlog
|
||||
<span className="brand-cursor">_</span>
|
||||
<small>linux · go · java · ideas</small>
|
||||
</Link>
|
||||
<nav className="header-nav" aria-label="Main Navigation">
|
||||
<Link
|
||||
to="/dev/null"
|
||||
className={`nav-tab ${location.pathname.startsWith('/dev/null') || location.pathname === '/' ? 'active' : ''}`}
|
||||
>
|
||||
/dev/null
|
||||
</Link>
|
||||
{!loading && authenticated && <Link
|
||||
to="/etc/backlog.rc"
|
||||
className={`nav-tab ${location.pathname.startsWith('/etc/backlog.rc') ? 'active' : ''}`}
|
||||
>
|
||||
/etc/backlog.rc
|
||||
</Link>}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="header-actions">
|
||||
{/*
|
||||
The palette and appearance controls, injected rather than hand-written.
|
||||
They live in a shadow root, so this application's own CSS cannot restyle
|
||||
them, and they read the palettes the service publishes rather than a list
|
||||
duplicated here.
|
||||
*/}
|
||||
<ThemeDropdown />
|
||||
<bananas-appearance-toggle title="Appearance" />
|
||||
|
||||
<AccountMenu/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{children}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import {useRef, useState, type FormEvent, type KeyboardEvent} from 'react'
|
||||
import {X} from 'lucide-react'
|
||||
import {type DemoUser} from '../models'
|
||||
|
||||
type Props = {
|
||||
|
|
@ -46,9 +47,12 @@ export function SudoTerminal({users, onLogin, onClose}: Props) {
|
|||
<form ref={form} className="terminal-window" role="dialog" aria-modal="true" aria-labelledby="sudo-title" onSubmit={submit}>
|
||||
<div className="terminal-titlebar">
|
||||
<span id="sudo-title">urpi@backlog: ~</span>
|
||||
<button type="button" className="terminal-close" aria-label="Close sudo terminal" onClick={onClose}>×</button>
|
||||
<button type="button" className="terminal-close" aria-label="Close sudo terminal" onClick={onClose}>
|
||||
<X aria-hidden="true"/>
|
||||
</button>
|
||||
</div>
|
||||
<div className="terminal-body">
|
||||
{users.length > 0 && <p className="muted">users: {users.map(item => item.username).join(', ')} — password is the username</p>}
|
||||
<label><span className="terminal-prompt">$</span> sudo <input autoFocus value={user}
|
||||
onChange={event => setUser(event.target.value)} onKeyDown={handleUserKeyDown}
|
||||
placeholder="user" aria-label="Username"/></label>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,10 @@ export type SessionResponse = {
|
|||
username?: string
|
||||
email?: string
|
||||
displayName?: string
|
||||
avatarUrl?: string
|
||||
// Demo profile: the header login opens the local sudo terminal instead of
|
||||
// redirecting to Go Loose.
|
||||
demo?: boolean
|
||||
// Go Loose advertises its own login and logout endpoints; the UI follows them
|
||||
// instead of sending the browser to a single-sign-on proxy.
|
||||
loginUrl?: string
|
||||
|
|
|
|||
|
|
@ -12,7 +12,18 @@ export type Content = {
|
|||
publishedAt?: string
|
||||
}
|
||||
|
||||
export type ContentUpdate = Pick<Content, 'title' | 'summary' | 'body' | 'status' | 'badges'>
|
||||
export type ContentUpdate = Pick<Content, 'title' | 'summary' | 'body' | 'status' | 'badges'> & { category?: string }
|
||||
|
||||
export type NewContent = {
|
||||
title: string
|
||||
slug?: string
|
||||
summary?: string
|
||||
body: string
|
||||
status?: string
|
||||
category?: string
|
||||
badges?: string[]
|
||||
author?: string
|
||||
}
|
||||
|
||||
export type ContentListResponse = {
|
||||
items?: Content[]
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
export type {Badge, Category, Content, ContentListResponse, ContentUpdate} from './content'
|
||||
export type {Badge, Category, Content, ContentListResponse, ContentUpdate, NewContent} from './content'
|
||||
export type {DemoUser, SessionResponse, User} from './auth'
|
||||
|
|
|
|||
|
|
@ -1,204 +1,416 @@
|
|||
/* ==========================================================================
|
||||
THEME
|
||||
|
||||
Colours come from the shared styling contract, which is why this file has
|
||||
no palette of its own. Every value below is written as
|
||||
|
||||
var(--bn-<role>, <the value this file used to hardcode>)
|
||||
|
||||
The second argument is the colour that was there before, so a page that does not
|
||||
load contract.css still renders exactly as it did: the migration is reversible by
|
||||
deleting one <link>.
|
||||
|
||||
Selecting a palette writes data-theme on <html>; selecting an appearance writes
|
||||
data-mode. The contract supplies values for both, so this stylesheet is the same
|
||||
for every palette and both appearances.
|
||||
========================================================================== */
|
||||
|
||||
:root {
|
||||
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
|
||||
color: #cdd6f4;
|
||||
background: #1e1e2e;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
color: var(--bn-text, #cdd6f4);
|
||||
background: var(--bn-bg, #120826);
|
||||
font-synthesis: none;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
background: radial-gradient(circle at 85% -10%, #302b50 0, transparent 38rem), #1e1e2e;
|
||||
background: radial-gradient(circle at 85% -10%, var(--bn-accent, #2e1053) 0, transparent 38rem), var(--bn-bg, #120826);
|
||||
transition: background 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
button, a, input, textarea, select { font: inherit; }
|
||||
button, select { cursor: pointer; }
|
||||
.shell { min-height: 100vh; max-width: 1080px; margin: auto; padding: 0 28px 72px; }
|
||||
.header { height: 94px; display: flex; align-items: center; justify-content: space-between; }
|
||||
.header-actions { display: flex; align-items: center; gap: 8px; }
|
||||
.brand { position: relative; color: #cdd6f4; font-size: 1.15rem; font-weight: 750; text-decoration: none; letter-spacing: -.02em; }
|
||||
.brand:hover { color: #cba6f7; }
|
||||
.brand-prompt { color: #a6e3a1; font-family: ui-monospace, SFMono-Regular, monospace; }
|
||||
.brand-cursor { color: #f9e2af; font-family: ui-monospace, SFMono-Regular, monospace; animation: blink 1.2s steps(2, start) infinite; }
|
||||
.brand small { display: block; margin: 5px 0 0 22px; color: #7f849c; font-size: .68rem; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; }
|
||||
.shell { min-height: 100vh; max-width: 1140px; margin: auto; padding: 0 28px 72px; }
|
||||
|
||||
/* Header & Nav */
|
||||
.header { position: relative; z-index: 20; height: 94px; display: flex; align-items: center; justify-content: space-between; gap: 16px; border-bottom: 1px solid rgba(255,255,255,0.08); margin-bottom: 24px; }
|
||||
.brand-group { display: flex; align-items: center; gap: 28px; }
|
||||
.brand { position: relative; color: var(--bn-text, #f5e0e8); font-size: 1.15rem; font-weight: 750; text-decoration: none; letter-spacing: -.02em; }
|
||||
.brand:hover { color: var(--bn-secondary, #00f0ff); }
|
||||
.brand-prompt { color: var(--bn-secondary, #00f0ff); font-family: ui-monospace, SFMono-Regular, monospace; }
|
||||
.brand-cursor { color: var(--bn-accent, #ff2a85); font-family: ui-monospace, SFMono-Regular, monospace; animation: blink 1.2s steps(2, start) infinite; }
|
||||
.brand small { display: block; margin: 3px 0 0 22px; color: var(--bn-muted, #a6adc8); font-size: .68rem; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; }
|
||||
@keyframes blink { 50% { opacity: 0; } }
|
||||
.text-button { border: 1px solid #45475a; border-radius: 8px; background: #313244; color: #bac2de; cursor: pointer; padding: 8px 12px; }
|
||||
.text-button:hover { border-color: #89b4fa; color: #cdd6f4; }
|
||||
.theme-button { border: 1px solid #45475a; border-radius: 8px; background: #313244; color: #bac2de; padding: 8px 12px; }
|
||||
.theme-button:hover { border-color: #cba6f7; color: #f5e0e8; }
|
||||
.mode-badge { border: 1px solid #45475a; border-radius: 999px; padding: 6px 11px; color: #a6adc8; background: #242438; font-size: .7rem; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; }
|
||||
.mode-badge:hover { border-color: #cba6f7; color: #f5e0e8; }
|
||||
.google-button { display: inline-flex; align-items: center; gap: 8px; border: 1px solid #585b70; border-radius: 8px; background: #313244; color: #cdd6f4; cursor: pointer; padding: 8px 12px; font-weight: 650; }
|
||||
.google-button:hover { background: #45475a; border-color: #89b4fa; }
|
||||
.google-button span { color: #89b4fa; font-weight: 800; }
|
||||
.card { margin: 10vh auto 0; max-width: 500px; padding: 42px; background: #242438; border: 1px solid #45475a; border-radius: 16px; box-shadow: 0 18px 50px #11111b66; }
|
||||
h1 { margin: 10px 0 12px; color: #f5e0e8; font-size: clamp(1.7rem, 4vw, 2.4rem); letter-spacing: -.04em; }
|
||||
|
||||
.header-nav { display: flex; align-items: center; gap: 8px; }
|
||||
.nav-tab {
|
||||
font-family: ui-monospace, SFMono-Regular, monospace;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
color: var(--bn-muted, #a6adc8);
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.nav-tab:hover {
|
||||
color: var(--bn-inverted, #ffffff);
|
||||
border-color: rgba(255,255,255,0.2);
|
||||
}
|
||||
.nav-tab.active {
|
||||
color: var(--bn-secondary, #00f0ff);
|
||||
border-color: var(--bn-secondary, #00f0ff);
|
||||
background: rgba(0, 240, 255, 0.1);
|
||||
}
|
||||
|
||||
.header-actions { display: flex; align-items: center; gap: 12px; }
|
||||
|
||||
/* Theme Selector */
|
||||
.theme-dropdown-wrap { position: relative; display: inline-flex; }
|
||||
.theme-dropdown-wrap::after {
|
||||
content: "▾";
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
pointer-events: none;
|
||||
color: var(--bn-muted, #a6adc8);
|
||||
font-size: 10px;
|
||||
}
|
||||
.theme-dropdown {
|
||||
appearance: none;
|
||||
background: var(--bn-surface, #1e1338);
|
||||
color: var(--bn-text, #f5e0e8);
|
||||
border: 1px solid color-mix(in srgb, var(--bn-accent) 22%, transparent);
|
||||
border-radius: 8px;
|
||||
padding: 8px 28px 8px 14px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
max-width: 12rem;
|
||||
}
|
||||
.theme-dropdown:hover, .theme-dropdown:focus {
|
||||
border-color: var(--bn-secondary, #00f0ff);
|
||||
outline: none;
|
||||
box-shadow: 0 0 10px rgba(0, 240, 255, 0.25);
|
||||
}
|
||||
|
||||
.account { position: relative; }
|
||||
.account-login, .account-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 1px solid var(--bn-border, #3d2366);
|
||||
border-radius: 8px;
|
||||
background: var(--bn-surface, #1b1035);
|
||||
color: var(--bn-text, #f5e0e8);
|
||||
font: inherit;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.account-login {
|
||||
background: var(--bn-accent, #ff2a85);
|
||||
color: var(--bn-on-accent, #120826);
|
||||
border-color: transparent;
|
||||
}
|
||||
.account-login:hover, .account-trigger:hover {
|
||||
border-color: color-mix(in srgb, var(--bn-accent, #ff2a85) 45%, var(--bn-border, #3d2366));
|
||||
}
|
||||
.account-login:focus-visible, .account-trigger:focus-visible {
|
||||
outline: 2px solid var(--bn-focus, var(--bn-accent, #ff2a85));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.account-name { max-width: 11rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.account-caret { color: var(--bn-muted, #a6adc8); font-size: 10px; }
|
||||
.account-menu {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 8px);
|
||||
z-index: 40;
|
||||
min-width: 15rem;
|
||||
padding: 6px;
|
||||
background: var(--bn-surface-raised, var(--bn-surface, #1e1338));
|
||||
border: 1px solid var(--bn-border, #3d2366);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
.account-identity { padding: 9px 10px 11px; border-bottom: 1px solid var(--bn-border, #3d2366); margin-bottom: 6px; }
|
||||
.account-display-name { display: block; font-size: 13px; }
|
||||
.account-email { display: block; color: var(--bn-muted, #a6adc8); font-size: 11.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.account-item {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 8px 10px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
color: var(--bn-text, #f5e0e8);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.account-item:hover { background: color-mix(in srgb, var(--bn-accent, #ff2a85) 12%, var(--bn-surface, #1b1035)); }
|
||||
.account-item svg { width: 1rem; height: 1rem; }
|
||||
.account-note { padding: 7px 10px 4px; color: var(--bn-muted, #a6adc8); font-size: 11px; line-height: 1.45; }
|
||||
.avatar { border-radius: 50%; object-fit: cover; display: block; flex: none; }
|
||||
.avatar-empty {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
background: var(--bn-surface-sunken, #140929);
|
||||
border: 1px solid var(--bn-border, #3d2366);
|
||||
color: var(--bn-accent, #ff2a85);
|
||||
font: 700 11px ui-monospace, monospace;
|
||||
}
|
||||
.profile-card { display: flex; align-items: center; gap: 18px; margin-top: 18px; }
|
||||
.profile-card h1 { margin: 0; }
|
||||
.protected-card .account-login { margin-top: 8px; }
|
||||
|
||||
/* Common Components */
|
||||
.card { margin: 20px auto; padding: 32px; background: var(--bn-surface, #1b1035); border: 1px solid var(--bn-border, #3d2366); border-radius: 16px; box-shadow: 0 18px 50px rgba(0,0,0,0.4); }
|
||||
h1 { margin: 10px 0 12px; color: var(--bn-text, #f5e0e8); font-size: clamp(1.7rem, 4vw, 2.4rem); letter-spacing: -.04em; }
|
||||
p { line-height: 1.65; }
|
||||
.muted { color: #a6adc8; }
|
||||
.muted { color: var(--bn-muted, #a6adc8); }
|
||||
.small { font-size: .875rem; }
|
||||
.eyebrow { color: #cba6f7; font-size: .75rem; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }
|
||||
.secondary-button { width: 100%; min-height: 48px; margin-top: 20px; border: 0; border-radius: 9px; background: #45475a; color: #cdd6f4; cursor: pointer; font-weight: 650; }
|
||||
.error { padding: 10px 12px; border-radius: 8px; color: #f38ba8; background: #3b2634; font-size: .9rem; }
|
||||
.profile { margin-top: 24px; padding: 14px; border-radius: 8px; background: #313244; color: #a6adc8; }
|
||||
.content-page, .detail-page { padding-top: 5vh; }
|
||||
.page-heading { display: flex; justify-content: space-between; align-items: end; margin-bottom: 28px; }
|
||||
.page-heading h1 { color: #cdd6f4; }
|
||||
.filters { display: grid; grid-template-columns: minmax(150px, .25fr) minmax(180px, .35fr) 1fr; gap: 14px; margin-bottom: 24px; padding: 16px; background: #242438; border: 1px solid #45475a; border-radius: 14px; }
|
||||
.filters label { display: grid; gap: 7px; color: #bac2de; font-size: .85rem; font-weight: 650; }
|
||||
.filters select, .badge-input { width: 100%; min-height: 43px; border: 1px solid #585b70; border-radius: 8px; padding: 6px 9px; background: #313244; color: #cdd6f4; font: inherit; font-weight: 400; }
|
||||
.filters select:focus, .badge-input:focus-within { outline: 2px solid #89b4fa; outline-offset: 1px; }
|
||||
.eyebrow { color: var(--bn-accent, #ff2a85); font-size: .75rem; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; font-family: ui-monospace, SFMono-Regular, monospace; }
|
||||
|
||||
/* Buttons */
|
||||
.primary-button, .secondary-button, .danger-button {
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
min-height: 40px;
|
||||
padding: 0 18px;
|
||||
cursor: pointer;
|
||||
font-weight: 650;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.primary-button { background: var(--bn-accent, #ff2a85); color: var(--bn-inverted, #ffffff); box-shadow: 0 0 12px rgba(255, 42, 133, 0.3); }
|
||||
.primary-button:hover {
|
||||
background: var(--bn-accent-hover, #ff4d9a);
|
||||
box-shadow: 0 0 20px color-mix(in srgb, var(--bn-accent) 50%, transparent);
|
||||
}
|
||||
.primary-button:disabled { opacity: .6; cursor: wait; }
|
||||
.secondary-button { background: var(--bn-secondary, #2f1d4f); color: var(--bn-text, #cdd6f4); border: 1px solid var(--bn-border-strong, #4a2d7a); }
|
||||
.secondary-button:hover { background: var(--bn-secondary, #3d2566); color: var(--bn-inverted, #ffffff); }
|
||||
.danger-button { background: #d90429; color: var(--bn-inverted, #ffffff); box-shadow: 0 0 10px rgba(217, 4, 41, 0.3); }
|
||||
.danger-button:hover { background: var(--bn-danger, #ef233c); box-shadow: 0 0 18px rgba(239, 35, 60, 0.5); }
|
||||
/* Lucide icons inherit the text colour of whatever they sit in, and are sized from
|
||||
the font size of their container rather than carrying a per-call-site class. The
|
||||
alternative -- a className at all twelve call sites -- is a place for the sizes to
|
||||
drift apart, which is the problem glyph icons had. */
|
||||
svg { flex: none; }
|
||||
.primary-button svg, .secondary-button svg, .danger-button svg,
|
||||
.text-button svg, .table-action-button svg {
|
||||
width: 1.05em;
|
||||
height: 1.05em;
|
||||
}
|
||||
.read-link svg, .back-link svg, .page-heading-actions svg {
|
||||
width: 0.95em;
|
||||
height: 0.95em;
|
||||
vertical-align: -0.12em;
|
||||
}
|
||||
.success-banner svg, .error svg, .sso-warning-banner svg { width: 1em; height: 1em; }
|
||||
|
||||
.text-button { border: 1px solid var(--bn-border-strong, #4a2d7a); border-radius: 8px; background: var(--bn-surface-raised, #22153d); color: var(--bn-text, #bac2de); cursor: pointer; padding: 6px 12px; font-size: 0.85rem; }
|
||||
.text-button:hover { border-color: var(--bn-secondary, #00f0ff); color: var(--bn-secondary, #00f0ff); }
|
||||
|
||||
/* Notifications */
|
||||
.error { padding: 12px 16px; border-radius: 8px; color: #ff85a1; background: var(--bn-danger, #3d1424); border: 1px solid color-mix(in srgb, var(--bn-accent) 22%, transparent); font-size: .9rem; }
|
||||
.success-banner { display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; border-radius: 8px; color: var(--bn-secondary, #00f0ff); background: var(--bn-good, #0b343d); border: 1px solid color-mix(in srgb, var(--bn-secondary) 66%, transparent); margin-bottom: 20px; }
|
||||
.success-banner button {
|
||||
background: none;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.success-banner button svg { width: 1rem; height: 1rem; }
|
||||
.sso-warning-banner { display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 16px 20px; border-radius: 10px; background: var(--bn-warning, #2a164d); border: 1px solid color-mix(in srgb, var(--bn-accent) 88%, transparent); margin-bottom: 24px; }
|
||||
.sso-warning-banner p { margin: 4px 0 0; }
|
||||
|
||||
/* Content List and Detail */
|
||||
.content-page, .detail-page, .crud-page { padding-top: 2vh; }
|
||||
.page-heading { display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 24px; flex-wrap: wrap; gap: 16px; }
|
||||
.page-heading-actions { display: flex; align-items: center; gap: 10px; }
|
||||
.filters { display: grid; grid-template-columns: minmax(150px, .25fr) minmax(180px, .35fr) 1fr; gap: 14px; margin-bottom: 24px; padding: 16px; background: var(--bn-surface, #1b1035); border: 1px solid var(--bn-border, #3d2366); border-radius: 14px; }
|
||||
.filters label { display: grid; gap: 7px; color: var(--bn-text, #bac2de); font-size: .85rem; font-weight: 650; }
|
||||
.filters select, .badge-input { width: 100%; min-height: 43px; border: 1px solid var(--bn-border-strong, #4a2d7a); border-radius: 8px; padding: 6px 9px; background: var(--bn-surface-raised, #22153d); color: var(--bn-text, #cdd6f4); font: inherit; font-weight: 400; }
|
||||
.filters select:focus, .badge-input:focus-within { outline: 2px solid var(--bn-secondary, #00f0ff); outline-offset: 1px; }
|
||||
.badge-input { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; }
|
||||
.badge-input input { min-width: 180px; flex: 1; border: 0; outline: 0; padding: 5px; color: #cdd6f4; background: transparent; font: inherit; }
|
||||
.badge-input input::placeholder { color: #7f849c; }
|
||||
.filter-pill, .badge { display: inline-flex; align-items: center; gap: 4px; border-radius: 999px; padding: 4px 9px; color: #cba6f7; background: #3b3151; font-size: .78rem; font-weight: 650; }
|
||||
.filter-pill button { border: 0; padding: 0 2px; color: #cba6f7; background: transparent; cursor: pointer; font-size: 1rem; line-height: 1; }
|
||||
.badge-input input { min-width: 180px; flex: 1; border: 0; outline: 0; padding: 5px; color: var(--bn-text, #cdd6f4); background: transparent; font: inherit; }
|
||||
.badge-input input::placeholder { color: var(--bn-subtle, #7f849c); }
|
||||
.filter-pill, .badge { display: inline-flex; align-items: center; gap: 4px; border-radius: 999px; padding: 3px 9px; color: var(--bn-secondary, #00f0ff); background: color-mix(in srgb, var(--bn-secondary) 18%, transparent); border: 1px solid color-mix(in srgb, var(--bn-secondary) 33%, transparent); font-size: .75rem; font-weight: 650; }
|
||||
.filter-pill button {
|
||||
border: 0;
|
||||
padding: 0 2px;
|
||||
color: var(--bn-secondary, #00f0ff);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.filter-pill button svg { width: 0.75rem; height: 0.75rem; }
|
||||
.badge.small { font-size: 0.7rem; padding: 2px 7px; }
|
||||
.badge-list { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 18px; }
|
||||
.badge-list-inline { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.content-grid { display: grid; grid-template-columns: minmax(0, 1fr); gap: 18px; }
|
||||
.content-card { padding: 28px; background: #242438; border: 1px solid #45475a; border-radius: 14px; box-shadow: 0 12px 34px #11111b33; }
|
||||
.content-card:hover { border-color: #585b70; box-shadow: 0 15px 40px #11111b55; }
|
||||
.content-card { padding: 28px; background: var(--bn-surface, #1b1035); border: 1px solid var(--bn-border, #3d2366); border-radius: 14px; box-shadow: 0 12px 34px rgba(0,0,0,0.3); }
|
||||
.content-card:hover { border-color: color-mix(in srgb, var(--bn-secondary) 66%, transparent); box-shadow: 0 15px 40px rgba(0,240,255,0.1); }
|
||||
.content-card h2 { margin: 12px 0 8px; font-size: 1.35rem; letter-spacing: -.025em; }
|
||||
.content-card h2 a { color: #f5e0e8; text-decoration: none; }
|
||||
.content-card h2 a:hover, .read-link:hover { color: #89b4fa; }
|
||||
.content-card p { color: #a6adc8; margin: 0 0 18px; }
|
||||
.content-card .content-meta { margin-top: -8px; color: #7f849c; font-size: .82rem; }
|
||||
.status { display: inline-block; color: #94e2d5; background: #193d3a; border-radius: 999px; padding: 4px 9px; font-size: .72rem; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; }
|
||||
.read-link, .back-link { color: #89b4fa; font-weight: 650; text-decoration: none; }
|
||||
.content-card h2 a { color: var(--bn-text, #f5e0e8); text-decoration: none; }
|
||||
.content-card h2 a:hover, .read-link:hover { color: var(--bn-secondary, #00f0ff); }
|
||||
.content-card p { color: var(--bn-muted, #a6adc8); margin: 0 0 18px; }
|
||||
.content-card .content-meta { margin-top: -8px; color: var(--bn-subtle, #7f849c); font-size: .82rem; }
|
||||
.status { display: inline-block; color: var(--bn-secondary, #00f0ff); background: color-mix(in srgb, var(--bn-secondary) 22%, transparent); border: 1px solid color-mix(in srgb, var(--bn-secondary) 55%, transparent); border-radius: 999px; padding: 3px 10px; font-size: .72rem; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; }
|
||||
.read-link, .back-link { color: var(--bn-secondary, #00f0ff); font-weight: 650; text-decoration: none; }
|
||||
.list-status { text-align: center; min-height: 54px; padding: 14px; }
|
||||
.empty-state { max-width: none; text-align: center; margin-top: 0; }
|
||||
.detail-page { max-width: 760px; margin: auto; }
|
||||
.back-link { display: inline-block; margin-bottom: 32px; }
|
||||
.detail-heading { position: relative; padding-bottom: 26px; border-bottom: 1px solid #45475a; }
|
||||
.back-link { display: inline-block; margin-bottom: 24px; }
|
||||
.detail-heading { position: relative; padding-bottom: 26px; border-bottom: 1px solid var(--bn-border, #3d2366); }
|
||||
.detail-heading h1 { margin-top: 14px; }
|
||||
.detail-heading .muted { font-size: 1.1rem; }
|
||||
.edit-button { position: absolute; top: 0; right: 0; }
|
||||
.body-copy { white-space: pre-wrap; line-height: 1.8; padding-top: 28px; color: #bac2de; }
|
||||
.editor { display: grid; gap: 18px; }
|
||||
.editor label { display: grid; gap: 7px; color: #bac2de; font-size: .9rem; font-weight: 650; }
|
||||
.editor input, .editor textarea, .editor select { width: 100%; border: 1px solid #585b70; border-radius: 8px; padding: 11px 12px; background: #313244; color: #cdd6f4; font: inherit; font-weight: 400; }
|
||||
.editor input:focus, .editor textarea:focus, .editor select:focus { outline: 2px solid #89b4fa; outline-offset: 1px; }
|
||||
.editor textarea { resize: vertical; }
|
||||
.actions { display: flex; gap: 10px; margin-top: 4px; }
|
||||
.primary-button, .secondary-button { border: 0; border-radius: 8px; min-height: 42px; padding: 0 17px; cursor: pointer; font-weight: 650; text-decoration: none; display: inline-flex; align-items: center; justify-content: center; }
|
||||
.primary-button { background: #cba6f7; color: #1e1e2e; }
|
||||
.primary-button:hover { background: #d8b9ff; }
|
||||
.primary-button:disabled { opacity: .6; cursor: wait; }
|
||||
.actions .secondary-button { margin: 0; background: #45475a; color: #cdd6f4; }
|
||||
.inline-button { margin-top: 22px; }
|
||||
.protected-card { text-align: center; }
|
||||
.body-copy { white-space: pre-wrap; line-height: 1.8; padding-top: 28px; color: var(--bn-text, #bac2de); }
|
||||
|
||||
/* CRUD /etc/backlog.rc Styles */
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; margin-bottom: 24px; }
|
||||
.stat-card { background: var(--bn-surface, #1b1035); border: 1px solid var(--bn-border, #3d2366); border-radius: 12px; padding: 18px 22px; }
|
||||
.stat-label { font-size: 0.8rem; font-weight: 600; color: var(--bn-muted, #a6adc8); text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.stat-value { font-size: 1.8rem; font-weight: 800; margin-top: 4px; color: var(--bn-text, #f5e0e8); }
|
||||
.stat-published .stat-value { color: var(--bn-secondary, #00f0ff); }
|
||||
.stat-draft .stat-value { color: var(--bn-warning, #ffd166); }
|
||||
.stat-archived .stat-value { color: var(--bn-muted, #a6adc8); }
|
||||
|
||||
.table-toolbar { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-bottom: 16px; flex-wrap: wrap; }
|
||||
.search-box { flex: 1; min-width: 250px; }
|
||||
.search-box input { width: 100%; border: 1px solid var(--bn-border-strong, #4a2d7a); border-radius: 8px; padding: 10px 14px; background: var(--bn-surface, #1b1035); color: var(--bn-text, #cdd6f4); }
|
||||
.search-box input:focus { outline: 2px solid var(--bn-secondary, #00f0ff); border-color: var(--bn-secondary, #00f0ff); }
|
||||
.filter-group { display: flex; gap: 10px; align-items: center; }
|
||||
.filter-group select { border: 1px solid var(--bn-border-strong, #4a2d7a); border-radius: 8px; padding: 9px 12px; background: var(--bn-surface, #1b1035); color: var(--bn-text, #cdd6f4); font-size: 0.85rem; }
|
||||
|
||||
.table-container { overflow-x: auto; }
|
||||
.crud-table { width: 100%; border-collapse: collapse; text-align: left; font-size: 0.88rem; }
|
||||
.crud-table th, .crud-table td { padding: 14px 18px; border-bottom: 1px solid rgba(255,255,255,0.06); }
|
||||
.crud-table th { background: rgba(0, 0, 0, 0.2); font-weight: 700; color: var(--bn-muted, #a6adc8); text-transform: uppercase; font-size: 0.75rem; letter-spacing: 0.05em; }
|
||||
.crud-table tbody tr:hover { background: rgba(255,255,255,0.03); }
|
||||
|
||||
.post-title-cell { display: flex; flex-direction: column; gap: 3px; }
|
||||
.post-link { color: var(--bn-text, #f5e0e8); text-decoration: none; font-size: 0.95rem; }
|
||||
.post-link:hover { color: var(--bn-secondary, #00f0ff); }
|
||||
.post-slug { font-size: 0.75rem; color: var(--bn-muted, #a6adc8); }
|
||||
.post-summary-preview { font-size: 0.78rem; margin-top: 4px; max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.category-pill { font-size: 0.72rem; padding: 2px 8px; border-radius: 4px; background: var(--bn-secondary, #2f1d4f); color: var(--bn-accent, #cba6f7); border: 1px solid var(--bn-border-strong, #4a2d7a); }
|
||||
.status-pill { display: inline-block; font-size: 0.7rem; font-weight: 700; padding: 3px 8px; border-radius: 999px; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
.status-published { color: var(--bn-secondary, #00f0ff); background: color-mix(in srgb, var(--bn-secondary) 22%, transparent); border: 1px solid color-mix(in srgb, var(--bn-secondary) 55%, transparent); }
|
||||
.status-draft { color: var(--bn-warning, #ffd166); background: color-mix(in srgb, var(--bn-warning) 22%, transparent); border: 1px solid color-mix(in srgb, var(--bn-warning) 55%, transparent); }
|
||||
.status-archived { color: var(--bn-muted, #94a3b8); background: color-mix(in srgb, var(--bn-muted) 44%, transparent); border: 1px solid var(--bn-muted, #475569); }
|
||||
|
||||
.action-buttons-cell { display: flex; gap: 6px; justify-content: flex-end; }
|
||||
.table-action-button { border: 1px solid var(--bn-border-strong, #4a2d7a); background: var(--bn-surface-raised, #251642); color: var(--bn-text, #cdd6f4); border-radius: 6px; padding: 6px 10px; font-size: 0.78rem; font-weight: 600; }
|
||||
.table-action-button:hover { border-color: var(--bn-secondary, #00f0ff); color: var(--bn-inverted, #ffffff); }
|
||||
.table-action-button.delete:hover { border-color: var(--bn-danger, #ef233c); background: color-mix(in srgb, var(--bn-danger) 22%, transparent); color: var(--bn-danger, #ef233c); }
|
||||
|
||||
.font-mono { font-family: ui-monospace, SFMono-Regular, monospace; }
|
||||
.font-semibold { font-weight: 600; }
|
||||
|
||||
/* Modals & Terminal Windows */
|
||||
.terminal-backdrop {
|
||||
position: fixed;
|
||||
z-index: 20;
|
||||
z-index: 50;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: #000000b8;
|
||||
backdrop-filter: blur(3px);
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
.terminal-window {
|
||||
width: min(100%, 620px);
|
||||
width: min(100%, 650px);
|
||||
overflow: hidden;
|
||||
border: 1px solid #4d4d4d;
|
||||
border-radius: 8px;
|
||||
background: #000;
|
||||
color: #fff;
|
||||
box-shadow: 0 24px 90px #000;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
border: 1px solid color-mix(in srgb, var(--bn-accent) 88%, transparent);
|
||||
border-radius: 12px;
|
||||
background: var(--bn-surface-sunken, #140929);
|
||||
color: var(--bn-inverted, #fff);
|
||||
box-shadow: 0 24px 90px rgba(0, 0, 0, 0.7), 0 0 30px rgba(255, 42, 133, 0.2);
|
||||
}
|
||||
.terminal-titlebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 9px 14px;
|
||||
color: #fff;
|
||||
background: #252525;
|
||||
border-bottom: 1px solid #454545;
|
||||
font-size: .82rem;
|
||||
padding: 12px 18px;
|
||||
background: var(--bn-surface-raised, #200f3d);
|
||||
border-bottom: 1px solid var(--bn-border, #3d2366);
|
||||
font-family: ui-monospace, SFMono-Regular, monospace;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
color: var(--bn-secondary, #00f0ff);
|
||||
}
|
||||
.terminal-close {
|
||||
border: 0;
|
||||
color: #fff;
|
||||
color: var(--bn-muted, #a6adc8);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 1.35rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.terminal-close:hover { color: #ff6b6b; }
|
||||
.terminal-body { display: grid; gap: 14px; padding: 24px; }
|
||||
.terminal-body p { margin: 0; line-height: 1.5; }
|
||||
.terminal-body label {
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #fff;
|
||||
font: inherit;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.terminal-prompt { color: #fff; font-weight: 700; }
|
||||
.terminal-body input {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
padding: 0;
|
||||
color: #fff;
|
||||
background: #000;
|
||||
font: inherit;
|
||||
/* Sized from the button rather than from a glyph's em box, so the icon and the
|
||||
× it replaced occupy the same space and the titlebar does not shift. */
|
||||
.terminal-close svg { width: 1.15rem; height: 1.15rem; }
|
||||
.terminal-close:hover { color: var(--bn-accent, #ff2a85); }
|
||||
|
||||
.warning-callout {
|
||||
padding: 18px;
|
||||
border-radius: 8px;
|
||||
background: var(--bn-danger, #3d081b);
|
||||
border: 1px solid color-mix(in srgb, var(--bn-danger) 88%, transparent);
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.terminal-body input::placeholder { color: #777; }
|
||||
.terminal-submit {
|
||||
justify-self: start;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
color: #fff;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
.warning-callout h3 { margin: 0 0 8px; color: var(--bn-danger, #ef233c); font-size: 1.1rem; }
|
||||
.warning-callout p { margin: 0 0 6px; line-height: 1.5; color: var(--bn-text, #f5e0e8); }
|
||||
|
||||
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.form-field { display: flex; flex-direction: column; gap: 6px; }
|
||||
.form-field.full-width { grid-column: span 2; }
|
||||
.form-field span { font-size: 0.82rem; font-weight: 600; color: var(--bn-muted, #a6adc8); }
|
||||
.form-field input, .form-field select, .form-field textarea {
|
||||
border: 1px solid var(--bn-border-strong, #4a2d7a);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
background: var(--bn-surface-sunken, #1c0e38);
|
||||
color: var(--bn-text, #f5e0e8);
|
||||
}
|
||||
.terminal-submit:hover { color: #a6e3a1; }
|
||||
.terminal-error { color: #ff6b6b; }
|
||||
html[data-theme="light"] {
|
||||
color: #4c4f69;
|
||||
background: #eff1f5;
|
||||
.form-field input:focus, .form-field select:focus, .form-field textarea:focus {
|
||||
outline: 2px solid var(--bn-secondary, #00f0ff);
|
||||
border-color: var(--bn-secondary, #00f0ff);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
TERMINAL PALETTE: rules, not values
|
||||
|
||||
Two of this palette's traits are not colours, so they cannot be tokens and were
|
||||
not lost with the colour overrides. A token is a value; these are behaviour.
|
||||
========================================================================== */
|
||||
html[data-theme='terminal'],
|
||||
html[data-theme='terminal'] * {
|
||||
font-family: var(--bn-font-mono, 'IBM Plex Mono'), ui-monospace, SFMono-Regular, 'Courier New', Courier, monospace !important;
|
||||
}
|
||||
|
||||
html[data-theme='terminal'] .brand,
|
||||
html[data-theme='terminal'] .nav-tab.active,
|
||||
html[data-theme='terminal'] .post-link {
|
||||
text-shadow: 0 0 2px color-mix(in srgb, var(--bn-accent) 40%, transparent);
|
||||
}
|
||||
html[data-theme="light"] body { background: radial-gradient(circle at 85% -10%, #dce4f4 0, transparent 38rem), #eff1f5; }
|
||||
html[data-theme="light"] .brand { color: #4c4f69; }
|
||||
html[data-theme="light"] .brand:hover { color: #8839ef; }
|
||||
html[data-theme="light"] .brand-prompt { color: #40a02b; }
|
||||
html[data-theme="light"] .brand-cursor { color: #df8e1d; }
|
||||
html[data-theme="light"] .brand small { color: #8c8fa1; }
|
||||
html[data-theme="light"] .text-button,
|
||||
html[data-theme="light"] .theme-button { border-color: #ccd0da; background: #e6e9ef; color: #5c5f77; }
|
||||
html[data-theme="light"] .text-button:hover,
|
||||
html[data-theme="light"] .theme-button:hover { border-color: #8839ef; color: #4c4f69; }
|
||||
html[data-theme="light"] .mode-badge { border-color: #ccd0da; background: #e6e9ef; color: #6c6f85; }
|
||||
html[data-theme="light"] .google-button { border-color: #ccd0da; background: #e6e9ef; color: #5c5f77; }
|
||||
html[data-theme="light"] .google-button:hover { border-color: #1e66f5; background: #dce0e8; }
|
||||
html[data-theme="light"] .card,
|
||||
html[data-theme="light"] .filters,
|
||||
html[data-theme="light"] .content-card { background: #ffffff; border-color: #ccd0da; box-shadow: 0 12px 34px #4c4f6914; }
|
||||
html[data-theme="light"] h1,
|
||||
html[data-theme="light"] .content-card h2 a { color: #4c4f69; }
|
||||
html[data-theme="light"] .muted,
|
||||
html[data-theme="light"] .content-card p { color: #6c6f85; }
|
||||
html[data-theme="light"] .eyebrow { color: #8839ef; }
|
||||
html[data-theme="light"] .filters select,
|
||||
html[data-theme="light"] .badge-input,
|
||||
html[data-theme="light"] .editor input,
|
||||
html[data-theme="light"] .editor textarea,
|
||||
html[data-theme="light"] .editor select { border-color: #ccd0da; background: #eff1f5; color: #4c4f69; }
|
||||
html[data-theme="light"] .profile,
|
||||
html[data-theme="light"] .secondary-button,
|
||||
html[data-theme="light"] .actions .secondary-button { background: #e6e9ef; color: #5c5f77; }
|
||||
html[data-theme="light"] .status { color: #179299; background: #d9f1ed; }
|
||||
html[data-theme="light"] .filter-pill,
|
||||
html[data-theme="light"] .badge { color: #8839ef; background: #f0e5ff; }
|
||||
html[data-theme="light"] .body-copy,
|
||||
html[data-theme="light"] .editor label,
|
||||
html[data-theme="light"] .filters label { color: #5c5f77; }
|
||||
html[data-theme="light"] .detail-heading { border-color: #ccd0da; }
|
||||
html[data-theme="light"] .read-link,
|
||||
html[data-theme="light"] .back-link { color: #1e66f5; }
|
||||
html[data-theme="light"] .primary-button { background: #8839ef; color: #ffffff; }
|
||||
html[data-theme="light"] .primary-button:hover { background: #7c2fe0; }
|
||||
@media (max-width: 480px) { .shell { padding: 0 16px 56px; } .card { padding: 28px 22px; } .brand small { font-size: .58rem; } }
|
||||
@media (max-width: 640px) { .content-page, .detail-page { padding-top: 3vh; } .edit-button { position: static; margin-top: 10px; } .filters { grid-template-columns: 1fr; } }
|
||||
|
|
|
|||
|
|
@ -1,24 +1,41 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import {defineConfig, loadEnv, type Plugin} from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
// Build into the repository root; the Dockerfile and `make ui` copy the result
|
||||
// into the Go embed directory (cmd/server/web/dist).
|
||||
build: {
|
||||
outDir: '../dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/health': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
function themeContract(origin: string): Plugin {
|
||||
const base = origin.replace(/\/+$/, '')
|
||||
return {
|
||||
name: 'go-bananas-contract',
|
||||
transformIndexHtml(html) {
|
||||
if (!base) return html
|
||||
const tags = [
|
||||
`<link rel="stylesheet" href="${base}/rt/v1/contract.css" data-theme-contract>`,
|
||||
`<script type="module" src="${base}/rt/v1/components.js"></script>`,
|
||||
].join('\n ')
|
||||
return html.replace('</head>', ` ${tags}\n </head>`)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig(({mode}) => {
|
||||
const env = loadEnv(mode, '.', '')
|
||||
const themeOrigin = env.VITE_THEME_BASE_URL || ''
|
||||
return {
|
||||
plugins: [react(), themeContract(themeOrigin)],
|
||||
build: {
|
||||
outDir: '../dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/health': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue