Arrange the rail: drag pages into your own order, kept with the account
The close cross on a detail pane is drawn, not a glyph, and sits in no box. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
b8ea7abcc7
commit
7e3e916f25
@@ -6,6 +6,7 @@ import { Dialogs } from './ui'
|
||||
import { applyTheme, currentThemeId } from './themes'
|
||||
import { getHistorySec, getRefreshMs, setHistorySec, setRefreshMs } from './prefs'
|
||||
import { setAccents } from './accents'
|
||||
import { setRailOrder } from './railorder'
|
||||
|
||||
export default function App() {
|
||||
const [me, setMe] = useState<Me | null | undefined>(undefined)
|
||||
@@ -17,6 +18,7 @@ export default function App() {
|
||||
if (p.theme && p.theme !== currentThemeId()) applyTheme(p.theme)
|
||||
if (p.refreshMs && p.refreshMs !== getRefreshMs()) setRefreshMs(p.refreshMs)
|
||||
if (p.accents && typeof p.accents === 'object') setAccents(p.accents, false)
|
||||
if (Array.isArray(p.rail)) setRailOrder(p.rail, false)
|
||||
}).catch(() => { /* keep local */ })
|
||||
// The graph window is the machines' own setting; the copy here only labels the curves
|
||||
// and spaces the live points, so take the hub's word for it at every login.
|
||||
|
||||
+4
-4
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { api, CronJob, CronOverview, CronRun, CronTimer, LogEntry } from './api'
|
||||
import { Logs } from './Services'
|
||||
import { Btn, Loading, Spinner, askYes, toast } from './ui'
|
||||
import { askYes, Btn, CloseBtn, Loading, Spinner, toast } from './ui'
|
||||
import { LIST_FLOOR, useRefreshMs } from './prefs'
|
||||
import { useHashSel } from './hashsel'
|
||||
|
||||
@@ -48,7 +48,7 @@ function SourceEditor({ name, onClose, onSaved }: { name: string; onClose: () =>
|
||||
}
|
||||
return (
|
||||
<div className="cron-detail">
|
||||
<button className="icon-btn detail-close" title="Close" aria-label="close" onClick={onClose}>×</button>
|
||||
<CloseBtn onClick={onClose} />
|
||||
<button className="btn back" onClick={onClose}>‹ Back</button>
|
||||
<h2>{srcLabel(name)}</h2>
|
||||
{!editable && <div className="notice">Read-only: writing this file needs passwordless sudo on the host.</div>}
|
||||
@@ -99,7 +99,7 @@ function JobDetail({ job, logging, onChanged, onBack, onEdit }: { job: CronJob;
|
||||
|
||||
return (
|
||||
<div className="cron-detail">
|
||||
<button className="icon-btn detail-close" title="Close" aria-label="close" onClick={onBack}>×</button>
|
||||
<CloseBtn onClick={onBack} />
|
||||
<button className="btn back" onClick={onBack}>‹ Cron</button>
|
||||
<h2>{job.command}</h2>
|
||||
<div className="props">
|
||||
@@ -152,7 +152,7 @@ function TimerDetail({ t, onChanged, onBack }: { t: CronTimer; onChanged: () =>
|
||||
const B = ({ a, label }: { a: string; label: string }) => <button className="btn" disabled={!!busy} onClick={() => act(a)}>{busy === a && <Spinner />}{label}</button>
|
||||
return (
|
||||
<div className="cron-detail">
|
||||
<button className="icon-btn detail-close" title="Close" aria-label="close" onClick={onBack}>×</button>
|
||||
<CloseBtn onClick={onBack} />
|
||||
<button className="btn back" onClick={onBack}>‹ Cron</button>
|
||||
<h2>{t.unit}</h2>
|
||||
<div className="muted">{t.description}</div>
|
||||
|
||||
+5
-5
@@ -8,7 +8,7 @@ import StackNew, { DupFrom } from './StackNew'
|
||||
import { Sensor, fmtValue, useSensorStream } from './sensors'
|
||||
import { useHashSel } from './hashsel'
|
||||
import { useRefreshMs } from './prefs'
|
||||
import { Btn, Loading, askText, askYes, setUnsaved, toast } from './ui'
|
||||
import { askText, askYes, Btn, CloseBtn, Loading, setUnsaved, toast } from './ui'
|
||||
|
||||
type Tab = 'containers' | 'stacks' | 'images' | 'volumes' | 'networks'
|
||||
const dot = (c: DockerContainer) => (c.state !== 'running' ? (c.state === 'exited' || c.state === 'dead' ? 'bad' : 'warn') : c.health === 'unhealthy' ? 'bad' : c.health === 'starting' ? 'warn' : 'ok')
|
||||
@@ -60,7 +60,7 @@ function Detail({ c, live, onBack, onChanged }: { c: DockerContainer; live: Reco
|
||||
const mounts = (info?.mounts as { Source: string; Destination: string; RW: boolean }[] | undefined) ?? []
|
||||
return (
|
||||
<div className="svc-detail">
|
||||
<button className="icon-btn detail-close" title="Close" aria-label="close" onClick={onBack}>×</button>
|
||||
<CloseBtn onClick={onBack} />
|
||||
<div className="svc-head"><button className="btn back" onClick={onBack}>‹ Back</button><span className={'sdot ' + dot(c)} /><span className="dname">{c.name}</span></div>
|
||||
<div className="props">
|
||||
<div><span className="k">Image</span><span className="mono small">{c.image}</span></div>
|
||||
@@ -141,11 +141,11 @@ function StackDetail({ name, startTask, onBack, onChanged, onDuplicate }: { name
|
||||
}
|
||||
const duplicate = async () => { if (!st) return; try { onDuplicate({ name, text: file === 0 ? orig : (await api.stackFile(name, 0)).text, hasEnv: !!st.has_env, files: st.host_files?.length ?? 1 }) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } }
|
||||
const run = (a: 'deploy' | 'update' | 'restart' | 'stop' | 'down') => async () => { try { setTask((await api.stackAction(name, a)).id) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } }
|
||||
if (err && !st) return <div className="svc-detail"><button className="icon-btn detail-close" title="Close" aria-label="close" onClick={onBack}>×</button><button className="btn back" onClick={onBack}>‹ Back</button><div className="notice">{err}</div></div>
|
||||
if (err && !st) return <div className="svc-detail"><CloseBtn onClick={onBack} /><button className="btn back" onClick={onBack}>‹ Back</button><div className="notice">{err}</div></div>
|
||||
if (!st) return <div className="svc-detail"><Loading label="Reading the compose file…" /></div>
|
||||
return (
|
||||
<div className="svc-detail">
|
||||
<button className="icon-btn detail-close" title="Close" aria-label="close" onClick={onBack}>×</button>
|
||||
<CloseBtn onClick={onBack} />
|
||||
<div className="svc-head"><button className="btn back" onClick={onBack}>‹ Back</button><span className={'sdot ' + (st.containers.length === 0 ? '' : st.running === st.containers.length ? 'ok' : st.running ? 'warn' : 'bad')} /><span className="dname">{st.name}</span><span className="muted small">{st.running}/{st.containers.length} running</span></div>
|
||||
<div className="muted small mono" style={{ overflowWrap: 'anywhere' }}>{file === 'env' ? (st.host_file.replace(/[^/]+$/, '') + '.env') : (st.host_files?.[file] ?? st.host_file)}{st.held_by && <span className="muted"> · kept on <b>{st.held_by}</b>, deployed to this machine over {st.remote}</span>}{st.via && <span className="muted"> · compose runs inside <b>{st.via}</b>, as it was deployed</span>}{st.has_env && ' · has a .env beside it'}</div>
|
||||
{((st.host_files?.length ?? 0) > 1 || st.has_env) && <div className="chips" title="this stack is deployed from several files, in this order; Deploy uses all of them">{st.host_files!.map((f, i) => <button key={f} className={'chip' + (i === file ? ' on' : '')} onClick={async () => { if (dirty && !(await askYes('Discard unsaved changes to this file?', 'Discard'))) return; setFile(i) }}>{f.split('/').pop()}</button>)}{st.has_env && <button className={'chip' + (file === 'env' ? ' on' : '')} title="the .env beside the compose file: variables compose substitutes" onClick={async () => { if (dirty && !(await askYes('Discard unsaved changes to this file?', 'Discard'))) return; setFile('env') }}>.env</button>}</div>}
|
||||
@@ -266,7 +266,7 @@ export default function Docker() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{shownTab === 'containers' && !cur && sel && !selStack && <div className="svc-detail"><button className="icon-btn detail-close" title="Close" aria-label="close" onClick={() => setSel('')}>×</button><button className="btn back" onClick={() => setSel('')}>‹ Back</button><div className="notice">There is no container called <b className="mono">{sel}</b> on this machine. It may have been removed, renamed or recreated under another name. <button className="link" onClick={() => setSel('')}>Show them all</button></div></div>}
|
||||
{shownTab === 'containers' && !cur && sel && !selStack && <div className="svc-detail"><CloseBtn onClick={() => setSel('')} /><button className="btn back" onClick={() => setSel('')}>‹ Back</button><div className="notice">There is no container called <b className="mono">{sel}</b> on this machine. It may have been removed, renamed or recreated under another name. <button className="link" onClick={() => setSel('')}>Show them all</button></div></div>}
|
||||
{shownTab === 'containers' && (cur ? <Detail c={cur} live={sensors} onBack={() => setSel('')} onChanged={load} /> : (sel && !selStack ? null : <div className="svc-empty muted">Select a container.</div>))}
|
||||
{making && <StackNew key={'new:' + (dup?.name ?? making)} mode={making} from={dup ?? undefined} networks={nets} taken={taken} onClose={() => { setMaking(''); setDup(null) }} onCreated={async (name, deploy) => { setMaking(''); setDup(null); setTab('stacks'); if (deploy) { try { setStartTask({ name, id: (await api.stackAction(name, 'deploy')).id }) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } } else setStartTask(null); await load(); setSel('stack:' + name) }} />}
|
||||
{shownTab === 'stacks' && (selStack ? <StackDetail key={selStack + (startTask?.name === selStack ? startTask.id : '')} name={selStack} startTask={startTask?.name === selStack ? startTask.id : undefined} onBack={() => setSel('')} onChanged={load} onDuplicate={(f) => { setDup(f); setMaking('stack') }} /> : <div className="svc-empty muted">Select a stack to edit its compose file.</div>)}
|
||||
|
||||
+44
-5
@@ -1,4 +1,5 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { ReactNode, useRef, useState } from 'react'
|
||||
import { orderBy, resetRailOrder, setRailOrder, useRailOrder } from './railorder'
|
||||
|
||||
// Every group is a page: clicking it opens it. `soon` groups are inert placeholders
|
||||
// for the roadmap.
|
||||
@@ -6,14 +7,48 @@ export interface Group { id: string; label: string; icon: ReactNode; soon?: bool
|
||||
|
||||
interface Props { groups: Group[]; view: string; header?: ReactNode; badges?: Record<string, number>; onView: (id: string) => void }
|
||||
|
||||
// The pages are in the order the account keeps (railorder.ts). "Arrange" at the foot puts a
|
||||
// handle on each; drag one up or down — pointer events, so a finger works as well as a mouse —
|
||||
// and the order is saved with the UI prefs when it is let go.
|
||||
export default function Rail({ groups, view, header, badges, onView }: Props) {
|
||||
const saved = useRailOrder()
|
||||
const [arranging, setArranging] = useState(false)
|
||||
const [drag, setDrag] = useState<{ id: string; order: string[] } | null>(null)
|
||||
const rows = useRef(new Map<string, HTMLElement>())
|
||||
const ordered = drag ? orderBy(groups, drag.order) : orderBy(groups, saved)
|
||||
|
||||
const start = (id: string) => (e: React.PointerEvent<HTMLElement>) => {
|
||||
e.preventDefault()
|
||||
e.currentTarget.setPointerCapture(e.pointerId)
|
||||
setDrag({ id, order: ordered.map((g) => g.id) })
|
||||
}
|
||||
const move = (e: React.PointerEvent<HTMLElement>) => {
|
||||
if (!drag) return
|
||||
// where the pointer is among the rows: the row whose middle it has crossed
|
||||
const y = e.clientY
|
||||
const order = drag.order.slice()
|
||||
const from = order.indexOf(drag.id)
|
||||
let to = from
|
||||
order.forEach((id, i) => {
|
||||
const el = rows.current.get(id)
|
||||
if (!el || id === drag.id) return
|
||||
const r = el.getBoundingClientRect()
|
||||
const mid = r.top + r.height / 2
|
||||
if (i < from && y < mid && i < to) to = i
|
||||
if (i > from && y > mid && i > to) to = i
|
||||
})
|
||||
if (to !== from) { order.splice(from, 1); order.splice(to, 0, drag.id); setDrag({ id: drag.id, order }) }
|
||||
}
|
||||
const end = () => { if (drag) { setRailOrder(drag.order); setDrag(null) } }
|
||||
|
||||
return (
|
||||
<nav className="rail">
|
||||
<nav className={'rail' + (arranging ? ' arranging' : '')}>
|
||||
{header}
|
||||
{groups.map((g) => (
|
||||
<div key={g.id} className={'group' + (g.soon ? ' soon' : '')}>
|
||||
{ordered.map((g) => (
|
||||
<div key={g.id} ref={(el) => { if (el) rows.current.set(g.id, el); else rows.current.delete(g.id) }} className={'group' + (g.soon ? ' soon' : '') + (drag?.id === g.id ? ' dragging' : '')}>
|
||||
<button className={'group-head' + (view === g.id ? ' active' : '')} disabled={g.soon}
|
||||
onClick={() => { if (!g.soon) onView(g.id) }}>
|
||||
onClick={() => { if (!g.soon && !arranging) onView(g.id) }}>
|
||||
<span className="handle" title="drag to move" onPointerDown={start(g.id)} onPointerMove={move} onPointerUp={end} onPointerCancel={end}>⋮⋮</span>
|
||||
<span className="gicon">{g.icon}</span>
|
||||
<span>{g.label}</span>
|
||||
{g.soon && <span className="badge">soon</span>}
|
||||
@@ -21,6 +56,10 @@ export default function Rail({ groups, view, header, badges, onView }: Props) {
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="rail-foot">
|
||||
<button className={arranging ? 'on' : ''} title="put the pages in your own order" onClick={() => setArranging(!arranging)}>{arranging ? 'Done' : '⇅ Arrange'}</button>
|
||||
{arranging && saved.length > 0 && <button onClick={() => resetRailOrder()}>Reset</button>}
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { api, LogEntry, ServiceDetail, ServiceUnit, wsURLPath } from './api'
|
||||
import { Btn, Loading, Spinner, askYes, toast } from './ui'
|
||||
import { askYes, Btn, CloseBtn, Loading, Spinner, toast } from './ui'
|
||||
import { LIST_FLOOR, useRefreshMs } from './prefs'
|
||||
import { useHashSel } from './hashsel'
|
||||
|
||||
@@ -137,7 +137,7 @@ function Detail({ name, onChanged, onBack }: { name: string; onChanged: () => vo
|
||||
|
||||
return (
|
||||
<div className="svc-detail">
|
||||
<button className="icon-btn detail-close" title="Close" aria-label="close" onClick={onBack}>×</button>
|
||||
<CloseBtn onClick={onBack} />
|
||||
<button className="btn back" onClick={onBack}>‹ Services</button>
|
||||
<div className="svc-head">
|
||||
<span className={'sdot ' + stateClass(d)} />
|
||||
|
||||
+2
-1
@@ -4,6 +4,7 @@
|
||||
import { api } from './api'
|
||||
import { currentThemeId } from './themes'
|
||||
import { getRefreshMs } from './prefs'
|
||||
import { getRailOrder } from './railorder'
|
||||
|
||||
export const ACCENTS = ['#e3a33b', '#3fb950', '#58a6ff', '#f778ba', '#ff7b72', '#a371f7', '#39c5cf', '#d4d44a', '#ff9e64', '#9aa7b4']
|
||||
const KEY = 'hamlab.accents'
|
||||
@@ -22,4 +23,4 @@ export function setAccents(all: Record<string, string>, save = true) {
|
||||
if (save) savePrefs()
|
||||
}
|
||||
// Everything the hub remembers about how the console looks, saved as one object.
|
||||
export function savePrefs() { return api.saveUIPrefs({ theme: currentThemeId(), refreshMs: getRefreshMs(), accents: getAccents() }).catch(() => { /* local still applied */ }) }
|
||||
export function savePrefs() { return api.saveUIPrefs({ theme: currentThemeId(), refreshMs: getRefreshMs(), accents: getAccents(), rail: getRailOrder() }).catch(() => { /* local still applied */ }) }
|
||||
|
||||
+3
-2
@@ -121,6 +121,7 @@ export interface AlertsInfo { rules: AlertRule[]; channels: AlertChannel[]; even
|
||||
export interface ZFSSnapshot { name: string; used: number; created: number }
|
||||
export interface ZFSInfo { available: boolean; message?: string; pools: { name: string; size: number; alloc: number; free: number; frag: number; cap: number; health: string; dedup: string; scan?: string; errors?: string; config?: string }[]; datasets: { name: string; type: string; used: number; avail: number; refer: number; used_by_snapshots: number; mount?: string; compressratio: string; snapshots: number }[] }
|
||||
|
||||
export interface UIPrefs { theme?: string; refreshMs?: number; accents?: Record<string, string>; rail?: string[] }
|
||||
export interface User { name: string; created: string; last_seen?: string; sessions: number; self: boolean }
|
||||
export interface Session { id: string; created: string; last_seen: string; expires: string; ip?: string; agent?: string; current: boolean }
|
||||
export interface TokenInfo { name: string; created: string; scope?: string; sensors?: string[]; issued_by?: string }
|
||||
@@ -234,8 +235,8 @@ export const api = {
|
||||
saveAlerts: (p: { rules?: AlertRule[]; channels?: AlertChannel[] }) => req<void>('PUT', '/api/alerts', p, true),
|
||||
testAlerts: () => req<void>('POST', '/api/alerts/test', undefined, true),
|
||||
revokeToken: (name: string) => req<void>('DELETE', `/api/tokens/${encodeURIComponent(name)}`, undefined, true),
|
||||
uiPrefs: () => req<{ theme?: string; refreshMs?: number; accents?: Record<string, string> }>('GET', '/api/prefs/ui', undefined, true),
|
||||
saveUIPrefs: (p: { theme?: string; refreshMs?: number; accents?: Record<string, string> }) => req<void>('PUT', '/api/prefs/ui', p, true),
|
||||
uiPrefs: () => req<UIPrefs>('GET', '/api/prefs/ui', undefined, true),
|
||||
saveUIPrefs: (p: UIPrefs) => req<void>('PUT', '/api/prefs/ui', p, true),
|
||||
removeMachine: (name: string) => req<void>('DELETE', `/api/nodes/${encodeURIComponent(name)}`, undefined, true),
|
||||
terminals: () => req<Term[]>('GET', '/api/terminals'),
|
||||
createTerminal: (name?: string) => req<{ id: string; name: string }>('POST', '/api/terminals', { name }),
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// The order of the pages in the rail is the account's: kept here for instant use and saved
|
||||
// with the UI prefs, so it follows the login. Pages the saved order doesn't name (added since)
|
||||
// go after the ones it does, in their built-in order.
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
const KEY = 'hamlab.rail'
|
||||
|
||||
export function getRailOrder(): string[] {
|
||||
try { const v = JSON.parse(localStorage.getItem(KEY) ?? '[]'); return Array.isArray(v) ? v.filter((x) => typeof x === 'string') : [] } catch { return [] }
|
||||
}
|
||||
|
||||
export function setRailOrder(ids: string[], save = true) {
|
||||
localStorage.setItem(KEY, JSON.stringify(ids))
|
||||
window.dispatchEvent(new Event('hamlab:rail'))
|
||||
if (save) import('./accents').then((m) => m.savePrefs())
|
||||
}
|
||||
|
||||
export function resetRailOrder() { localStorage.removeItem(KEY); window.dispatchEvent(new Event('hamlab:rail')); import('./accents').then((m) => m.savePrefs()) }
|
||||
|
||||
export function useRailOrder(): string[] {
|
||||
const [order, setOrder] = useState(getRailOrder)
|
||||
useEffect(() => {
|
||||
const h = () => setOrder(getRailOrder())
|
||||
window.addEventListener('hamlab:rail', h)
|
||||
return () => window.removeEventListener('hamlab:rail', h)
|
||||
}, [])
|
||||
return order
|
||||
}
|
||||
|
||||
export function orderBy<T extends { id: string }>(items: T[], order: string[]): T[] {
|
||||
const by = new Map(items.map((g) => [g.id, g]))
|
||||
const named = order.map((id) => by.get(id)).filter((g): g is T => !!g)
|
||||
const seen = new Set(named.map((g) => g.id))
|
||||
return [...named, ...items.filter((g) => !seen.has(g.id))]
|
||||
}
|
||||
+10
-2
@@ -152,8 +152,8 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
||||
/* The × that closes a detail pane on a wide screen, where the list stays beside it and there is
|
||||
no Back. Sits in the pane's top-right corner; the header keeps clear of it. */
|
||||
.svc-detail, .cron-detail { position: relative; }
|
||||
.detail-close { position: absolute; top: 8px; right: 10px; z-index: 1; width: 28px; height: 28px; line-height: 28px; font-size: 20px; color: var(--muted); border-radius: 6px; }
|
||||
.detail-close:hover { color: var(--fg); background: var(--bg3); }
|
||||
.detail-close { position: absolute; top: 10px; right: 10px; z-index: 1; width: 26px; height: 26px; padding: 0; display: inline-flex; align-items: center; justify-content: center; color: var(--muted); background: none; border: 0; border-radius: 50%; opacity: .7; }
|
||||
.detail-close:hover { color: var(--fg); background: var(--bg3); opacity: 1; }
|
||||
.svc-detail > .svc-head, .cron-detail > h2 { padding-right: 36px; }
|
||||
/* Nothing selected: the list has the window to itself rather than a 360px column beside an
|
||||
empty pane. The names, descriptions and commands that get an ellipsis are what it's for.
|
||||
@@ -517,6 +517,14 @@ a.btn { text-decoration: none; display: inline-flex; align-items: center; }
|
||||
.rule-cond { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } .rule-cond select, .rule-cond input, .chan-add select, .chan-add input { padding: 8px 9px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 14px; } .rule-cond input.mono { flex: 1 1 120px; min-width: 0; }
|
||||
.chan-add { display: flex; gap: 6px; margin-top: 8px; flex-wrap: wrap; } .chan-add input { flex: 1 1 220px; min-width: 0; }
|
||||
.ev { display: flex; align-items: baseline; gap: 8px; padding: 6px 0; border-top: 1px solid var(--border); font-size: 13px; } .ev:first-child { border-top: 0; } .ev-main { flex: 1; min-width: 0; overflow-wrap: anywhere; } .bad-text { color: var(--bad) !important; }
|
||||
/* Arranging the rail: a foot row toggles it; each page then carries a handle to drag by. */
|
||||
.rail-foot { margin-top: 8px; padding: 6px 12px; display: flex; gap: 10px; align-items: center; font-size: 12px; color: var(--muted); border-top: 1px solid var(--border); }
|
||||
.rail-foot button { color: var(--muted); } .rail-foot button:hover { color: var(--fg); } .rail-foot button.on { color: var(--accent); }
|
||||
.rail.arranging .group-head { cursor: default; } .rail.arranging .group-head:hover { background: none; }
|
||||
.rail .handle { display: none; width: 18px; margin: -4px 0 -4px -4px; align-self: stretch; align-items: center; justify-content: center; color: var(--muted); cursor: grab; touch-action: none; user-select: none; -webkit-user-select: none; font-size: 14px; letter-spacing: -2px; }
|
||||
.rail.arranging .handle { display: inline-flex; } .rail.arranging .handle:hover { color: var(--fg); }
|
||||
.rail .group.dragging .group-head { background: var(--bg3); opacity: .75; box-shadow: inset 3px 0 0 var(--accent2); }
|
||||
.rail.arranging .group { transition: transform .12s ease; }
|
||||
.rail-badge { margin-left: auto; min-width: 18px; padding: 0 5px; border-radius: 9px; background: var(--bad); color: #fff; font-size: 11px; font-weight: 700; text-align: center; line-height: 18px; }
|
||||
@media (pointer: coarse) { .toggle { width: 52px; height: 32px; border-radius: 16px; } .toggle i { width: 26px; height: 26px; } .toggle.on i { left: 22px; } .swatch { width: 34px; height: 34px; } .swatches input[type=color] { width: 36px; height: 36px; } }
|
||||
|
||||
|
||||
@@ -4,6 +4,16 @@ import { ReactNode, useEffect, useRef, useState } from 'react'
|
||||
export function Spinner({ size = 14 }: { size?: number }) {
|
||||
return <span className="spin" style={{ width: size, height: size }} aria-hidden="true" />
|
||||
}
|
||||
// The × in the corner of a detail pane. A drawn cross rather than the × glyph, which sits
|
||||
// differently in every font and theme.
|
||||
export function CloseBtn({ onClick, title = 'Close' }: { onClick: () => void; title?: string }) {
|
||||
return (
|
||||
<button type="button" className="detail-close" title={title} aria-label={title} onClick={onClick}>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M2 2l10 10M12 2L2 12" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" fill="none" /></svg>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function Loading({ label = 'Loading…' }: { label?: string }) {
|
||||
return <div className="center muted loading"><Spinner size={22} /><div>{label}</div></div>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user