feat(canary): manage dossier page + /m/:manageId route + 404 fallback
Manage route renders the per-token dossier — the "specimen catalog entry"
twin of the intake page. Same vector-minimalism mind: archive strip,
big-blocky headline, hairline-rule cards, mono labels, alarm-red only
when the trap is alive (eventsTotal > 0) or disabled.
Page composition:
- Top Strip (FIELD STATION / SECTION / SPECIES pill)
- Hero block — headline state-shifts: QUIET (no events, ink), TRAP TRIPPED
(alarm red), TRAP MUTED (ink-mute). Memo rendered as printer-block quote.
- Halftone separator
- SpecimenCard with armed status / trigger count / silenced (15m window) /
last seen / created / alert channel / filename — plus Trigger URL CopyField
- EventLogSection — cursor-paginated list of EventRow cards (timestamp +
source_ip + geo + ASN + UA + referer + notify_status pill + expandable
fingerprint extra). "Load older →" + "← back to most recent" controls.
- DeleteSection — two-step arm/confirm pattern (no native confirm() dialog),
alarm-tone Button on confirm, success → toast + navigate('/')
- Bottom Strip with "new specimen ↗" link
States: useManageToken loading | ApiError(NOT_FOUND) → ManageNotFound |
other error → ManageError with retry | success → ManageView.
Pagination policy per plan anti-relit: singular useQuery, NO useInfiniteQuery.
Each cursor change replaces the visible page (operator-friendly default).
Auto-refresh every 5s via QUERY_STRATEGIES.frequent (Phase 14 contract).
Routes:
- / → landing
- /m/:manageId → manage (new)
- * → notfound (new — replaces previous fallback-to-landing)
config.ts: ROUTES.MANAGE added; manageRoute(id) helper centralizes the
URI-encoded path so result.tsx and any future caller cannot drift.
notfound page: same ethos in miniature. 404 strip pill, big OFF-ROUTE
headline, link back to intake.
Gates green: typecheck + biome + stylelint + build (414ms, manage chunk
10.9KB / 3.7KB gzip, notfound chunk 1.0KB / 0.5KB gzip).
This commit is contained in:
parent
5562f82f34
commit
4a4f465d24
|
|
@ -5,10 +5,15 @@
|
|||
|
||||
export const ROUTES = {
|
||||
HOME: '/',
|
||||
MANAGE: '/m/:manageId',
|
||||
} as const
|
||||
|
||||
export type Route = typeof ROUTES
|
||||
|
||||
export function manageRoute(manageId: string): string {
|
||||
return `/m/${encodeURIComponent(manageId)}`
|
||||
}
|
||||
|
||||
export const QUERY_CONFIG = {
|
||||
STALE_TIME: {
|
||||
USER: 1000 * 60 * 5,
|
||||
|
|
|
|||
|
|
@ -15,9 +15,13 @@ const routes: RouteObject[] = [
|
|||
path: ROUTES.HOME,
|
||||
lazy: () => import('@/pages/landing'),
|
||||
},
|
||||
{
|
||||
path: ROUTES.MANAGE,
|
||||
lazy: () => import('@/pages/manage'),
|
||||
},
|
||||
{
|
||||
path: '*',
|
||||
lazy: () => import('@/pages/landing'),
|
||||
lazy: () => import('@/pages/notfound'),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
SpecimenCard,
|
||||
SpecimenCardSection,
|
||||
} from '@/components'
|
||||
import { manageRoute } from '@/config'
|
||||
import { ArtifactDisplay } from './artifact'
|
||||
import { ARTIFACT_LABEL, PAGE_COPY } from './copy'
|
||||
import styles from './landing.module.scss'
|
||||
|
|
@ -30,7 +31,7 @@ export function ResultView({
|
|||
filenameFallback,
|
||||
}: ResultViewProps): React.ReactElement {
|
||||
const { token, artifact } = data
|
||||
const managePath = `/m/${token.manage_id}`
|
||||
const managePath = manageRoute(token.manage_id)
|
||||
return (
|
||||
<div className={styles.result}>
|
||||
<header className={styles.resultHead}>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
// ===================
|
||||
// ©AngelaMos | 2026
|
||||
// copy.ts
|
||||
// ===================
|
||||
|
||||
export const MANAGE_COPY = {
|
||||
HEADLINE_QUIET: 'NO MOVEMENT YET',
|
||||
HEADLINE_LIVE: 'TRAP TRIPPED',
|
||||
HEADLINE_DISABLED: 'TRAP MUTED',
|
||||
PURPOSE_QUIET:
|
||||
'The specimen is in place. Nothing has touched it. Keep this page bookmarked — alerts will fire when something does.',
|
||||
PURPOSE_LIVE:
|
||||
'At least one visitor has tripped the trap. Below are the recorded events, newest first.',
|
||||
PURPOSE_DISABLED:
|
||||
'This specimen still records events for forensic continuity, but no alerts are dispatched. Re-enable from the operator console if needed.',
|
||||
EVENT_LOG_TITLE: 'EVENT LOG',
|
||||
EVENT_LOG_EMPTY:
|
||||
'No events recorded yet. When something touches the trigger URL, it will appear here.',
|
||||
EVENT_LOAD_MORE: 'Load older events',
|
||||
EVENT_LOADING: 'Loading next page…',
|
||||
DELETE_TITLE: 'TERMINATE SPECIMEN',
|
||||
DELETE_BODY:
|
||||
'Cascade-deletes this specimen and every event recorded against it. The trigger URL will no longer fire alerts. This action cannot be undone.',
|
||||
DELETE_CONFIRM: 'Terminate',
|
||||
DELETE_CANCEL: 'Keep alive',
|
||||
DELETE_ARM: 'Mark for termination',
|
||||
BACK_TO_INTAKE: 'New specimen',
|
||||
NOT_FOUND_HEADLINE: 'DOSSIER NOT FOUND',
|
||||
NOT_FOUND_BODY:
|
||||
'The manage ID does not correspond to an active specimen. It may have been terminated or the URL may be malformed.',
|
||||
ERROR_HEADLINE: 'CANNOT REACH ARCHIVE',
|
||||
ERROR_BODY: 'Something went wrong fetching this dossier.',
|
||||
} as const
|
||||
|
||||
export const NOTIFY_TONE = {
|
||||
sent: 'signal',
|
||||
pending: 'paper',
|
||||
failed: 'alarm',
|
||||
deduped: 'paper',
|
||||
} as const
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
// ===================
|
||||
// ©AngelaMos | 2026
|
||||
// event-row.tsx
|
||||
// ===================
|
||||
|
||||
import type { EventResponse, GeoView } from '@/api'
|
||||
import { Pill } from '@/components'
|
||||
import { NOTIFY_TONE } from './copy'
|
||||
import styles from './manage.module.scss'
|
||||
|
||||
type EventRowProps = {
|
||||
event: EventResponse
|
||||
index: number
|
||||
}
|
||||
|
||||
export function EventRow({ event, index }: EventRowProps): React.ReactElement {
|
||||
const tone = NOTIFY_TONE[event.notify_status] ?? 'paper'
|
||||
return (
|
||||
<article className={styles.eventRow}>
|
||||
<header className={styles.eventHead}>
|
||||
<span className={styles.eventIndex}>
|
||||
{String(index).padStart(4, '0')}
|
||||
</span>
|
||||
<time className={styles.eventTime} dateTime={event.triggered_at}>
|
||||
{formatTimestamp(event.triggered_at)}
|
||||
</time>
|
||||
<Pill tone={tone} size="sm">
|
||||
{event.notify_status}
|
||||
</Pill>
|
||||
</header>
|
||||
<div className={styles.eventGrid}>
|
||||
<EventField label="src ip" mono>
|
||||
{event.source_ip}
|
||||
</EventField>
|
||||
<EventField label="geo">{formatGeo(event.geo)}</EventField>
|
||||
{event.geo.asn ? (
|
||||
<EventField label="asn" mono>
|
||||
AS{event.geo.asn}
|
||||
{event.geo.asn_org ? ` · ${event.geo.asn_org}` : ''}
|
||||
</EventField>
|
||||
) : null}
|
||||
<EventField label="user agent" wide truncate>
|
||||
{event.user_agent ?? '—'}
|
||||
</EventField>
|
||||
{event.referer ? (
|
||||
<EventField label="referer" wide truncate>
|
||||
{event.referer}
|
||||
</EventField>
|
||||
) : null}
|
||||
{event.notified_at ? (
|
||||
<EventField label="notified" mono>
|
||||
{formatTimestamp(event.notified_at)}
|
||||
</EventField>
|
||||
) : null}
|
||||
</div>
|
||||
{hasExtraDetails(event.extra) ? (
|
||||
<details className={styles.eventExtra}>
|
||||
<summary>extra fingerprint</summary>
|
||||
<pre className={styles.eventExtraBody}>
|
||||
{JSON.stringify(event.extra, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
) : null}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
type EventFieldProps = React.PropsWithChildren<{
|
||||
label: string
|
||||
mono?: boolean
|
||||
wide?: boolean
|
||||
truncate?: boolean
|
||||
}>
|
||||
|
||||
function EventField({
|
||||
label,
|
||||
mono = false,
|
||||
wide = false,
|
||||
truncate = false,
|
||||
children,
|
||||
}: EventFieldProps): React.ReactElement {
|
||||
return (
|
||||
<div
|
||||
className={styles.eventField}
|
||||
data-mono={mono}
|
||||
data-wide={wide}
|
||||
data-truncate={truncate}
|
||||
>
|
||||
<span className={styles.eventLabel}>{label}</span>
|
||||
<span className={styles.eventValue}>{children}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatTimestamp(iso: string): string {
|
||||
try {
|
||||
const dt = new Date(iso)
|
||||
return dt
|
||||
.toISOString()
|
||||
.replace('T', ' ')
|
||||
.replace(/\.\d+Z$/, 'Z')
|
||||
} catch (_err) {
|
||||
return iso
|
||||
}
|
||||
}
|
||||
|
||||
function formatGeo(geo: GeoView): string {
|
||||
const parts: string[] = []
|
||||
if (geo.city) parts.push(geo.city)
|
||||
if (geo.region) parts.push(geo.region)
|
||||
if (geo.country) parts.push(geo.country)
|
||||
if (parts.length === 0) {
|
||||
return '—'
|
||||
}
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
function hasExtraDetails(extra: unknown): boolean {
|
||||
if (extra == null) return false
|
||||
if (typeof extra !== 'object') return false
|
||||
return Object.keys(extra as Record<string, unknown>).length > 0
|
||||
}
|
||||
|
|
@ -0,0 +1,425 @@
|
|||
// ===================
|
||||
// ©AngelaMos | 2026
|
||||
// index.tsx
|
||||
// ===================
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
type ManageResponse,
|
||||
type ManageTokenView,
|
||||
useDeleteToken,
|
||||
useManageToken,
|
||||
} from '@/api'
|
||||
import {
|
||||
Button,
|
||||
CopyField,
|
||||
DataRow,
|
||||
Halftone,
|
||||
Pill,
|
||||
SerialBar,
|
||||
SpecimenCard,
|
||||
SpecimenCardSection,
|
||||
Strip,
|
||||
StripItem,
|
||||
} from '@/components'
|
||||
import { ApiError, ApiErrorCode } from '@/core/api'
|
||||
import { MANAGE_COPY } from './copy'
|
||||
import { EventRow } from './event-row'
|
||||
import styles from './manage.module.scss'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
export function Component(): React.ReactElement {
|
||||
const params = useParams()
|
||||
const manageId = params.manageId ?? ''
|
||||
const [cursor, setCursor] = useState<string | undefined>(undefined)
|
||||
const query = useManageToken(manageId, { cursor, limit: PAGE_SIZE })
|
||||
|
||||
if (manageId.length === 0) {
|
||||
return <ManageNotFound />
|
||||
}
|
||||
if (query.isLoading) {
|
||||
return <ManageLoading />
|
||||
}
|
||||
if (query.error) {
|
||||
if (
|
||||
query.error instanceof ApiError &&
|
||||
query.error.code === ApiErrorCode.NOT_FOUND
|
||||
) {
|
||||
return <ManageNotFound />
|
||||
}
|
||||
return <ManageError onRetry={() => query.refetch()} />
|
||||
}
|
||||
if (!query.data) {
|
||||
return <ManageNotFound />
|
||||
}
|
||||
|
||||
return (
|
||||
<ManageView
|
||||
manageId={manageId}
|
||||
data={query.data}
|
||||
cursor={cursor}
|
||||
isFetchingNextPage={query.isFetching && cursor !== undefined}
|
||||
onLoadOlder={() => setCursor(query.data?.page.next_cursor)}
|
||||
onResetCursor={() => setCursor(undefined)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
Component.displayName = 'Manage'
|
||||
|
||||
type ManageViewProps = {
|
||||
manageId: string
|
||||
data: ManageResponse
|
||||
cursor: string | undefined
|
||||
isFetchingNextPage: boolean
|
||||
onLoadOlder: () => void
|
||||
onResetCursor: () => void
|
||||
}
|
||||
|
||||
function ManageView({
|
||||
manageId,
|
||||
data,
|
||||
cursor,
|
||||
isFetchingNextPage,
|
||||
onLoadOlder,
|
||||
onResetCursor,
|
||||
}: ManageViewProps): React.ReactElement {
|
||||
const { token, events, events_total, events_silenced_active, page } = data
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<Strip>
|
||||
<StripItem label="FIELD STATION">canary</StripItem>
|
||||
<StripItem label="SECTION">dossier</StripItem>
|
||||
<StripItem label="SPECIES" inverted>
|
||||
{token.type}
|
||||
</StripItem>
|
||||
</Strip>
|
||||
|
||||
<DossierHero token={token} eventsTotal={events_total} />
|
||||
|
||||
<Halftone density="sparse" height={18} />
|
||||
|
||||
<DossierCard
|
||||
token={token}
|
||||
eventsTotal={events_total}
|
||||
silenced={events_silenced_active}
|
||||
/>
|
||||
|
||||
<EventLogSection
|
||||
events={events}
|
||||
cursor={cursor}
|
||||
hasMore={page.has_more}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
onLoadOlder={onLoadOlder}
|
||||
onResetCursor={onResetCursor}
|
||||
/>
|
||||
|
||||
<DeleteSection manageId={manageId} memo={token.memo} />
|
||||
|
||||
<Strip align="left" border="top">
|
||||
<StripItem label="LINK">
|
||||
<Link to="/" className={styles.footerLink}>
|
||||
new specimen ↗
|
||||
</Link>
|
||||
</StripItem>
|
||||
<StripItem label="LICENSE">©AngelaMos 2026</StripItem>
|
||||
</Strip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type DossierHeroProps = {
|
||||
token: ManageTokenView
|
||||
eventsTotal: number
|
||||
}
|
||||
|
||||
function DossierHero({
|
||||
token,
|
||||
eventsTotal,
|
||||
}: DossierHeroProps): React.ReactElement {
|
||||
const state = resolveState(token, eventsTotal)
|
||||
const headline =
|
||||
state === 'live'
|
||||
? MANAGE_COPY.HEADLINE_LIVE
|
||||
: state === 'disabled'
|
||||
? MANAGE_COPY.HEADLINE_DISABLED
|
||||
: MANAGE_COPY.HEADLINE_QUIET
|
||||
const purpose =
|
||||
state === 'live'
|
||||
? MANAGE_COPY.PURPOSE_LIVE
|
||||
: state === 'disabled'
|
||||
? MANAGE_COPY.PURPOSE_DISABLED
|
||||
: MANAGE_COPY.PURPOSE_QUIET
|
||||
return (
|
||||
<header className={styles.hero}>
|
||||
<p className={styles.heroIndex}>DOSSIER · {token.type.toUpperCase()}</p>
|
||||
<h1 className={styles.headline} data-state={state}>
|
||||
{headline}
|
||||
</h1>
|
||||
<p className={styles.purpose}>{purpose}</p>
|
||||
<p className={styles.memo}>{token.memo || 'no memo recorded'}</p>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
type DossierCardProps = {
|
||||
token: ManageTokenView
|
||||
eventsTotal: number
|
||||
silenced: number
|
||||
}
|
||||
|
||||
function DossierCard({
|
||||
token,
|
||||
eventsTotal,
|
||||
silenced,
|
||||
}: DossierCardProps): React.ReactElement {
|
||||
return (
|
||||
<SpecimenCard
|
||||
tag={`SN · ${token.id}`}
|
||||
serial={<SerialBar value={token.id} prefix="SN" />}
|
||||
>
|
||||
<SpecimenCardSection label="STATUS">
|
||||
<DataRow label="armed">
|
||||
<Pill tone={token.enabled ? 'signal' : 'alarm'}>
|
||||
{token.enabled ? 'live' : 'disabled'}
|
||||
</Pill>
|
||||
</DataRow>
|
||||
<DataRow label="triggers" emphasize alarm={eventsTotal > 0}>
|
||||
{eventsTotal}
|
||||
</DataRow>
|
||||
<DataRow label="silenced (15m window)" mono>
|
||||
{silenced}
|
||||
</DataRow>
|
||||
<DataRow label="last seen" mono>
|
||||
{token.last_triggered ? formatTs(token.last_triggered) : 'never'}
|
||||
</DataRow>
|
||||
<DataRow label="created" mono>
|
||||
{formatTs(token.created_at)}
|
||||
</DataRow>
|
||||
<DataRow label="alert" emphasize>
|
||||
{token.alert_channel}
|
||||
</DataRow>
|
||||
{token.filename ? (
|
||||
<DataRow label="filename" mono>
|
||||
{token.filename}
|
||||
</DataRow>
|
||||
) : null}
|
||||
</SpecimenCardSection>
|
||||
|
||||
<SpecimenCardSection label="ENDPOINT">
|
||||
<CopyField label="TRIGGER" value={token.trigger_url} fullWidth />
|
||||
</SpecimenCardSection>
|
||||
</SpecimenCard>
|
||||
)
|
||||
}
|
||||
|
||||
type EventLogSectionProps = {
|
||||
events: ManageResponse['events']
|
||||
cursor: string | undefined
|
||||
hasMore: boolean
|
||||
isFetchingNextPage: boolean
|
||||
onLoadOlder: () => void
|
||||
onResetCursor: () => void
|
||||
}
|
||||
|
||||
function EventLogSection({
|
||||
events,
|
||||
cursor,
|
||||
hasMore,
|
||||
isFetchingNextPage,
|
||||
onLoadOlder,
|
||||
onResetCursor,
|
||||
}: EventLogSectionProps): React.ReactElement {
|
||||
return (
|
||||
<section className={styles.eventLog}>
|
||||
<header className={styles.eventLogHead}>
|
||||
<span className={styles.eventLogIndex}>06 / EVENT LOG</span>
|
||||
<span className={styles.eventLogRule} aria-hidden="true" />
|
||||
<span className={styles.eventLogCount}>
|
||||
{events.length} {events.length === 1 ? 'entry' : 'entries'}
|
||||
</span>
|
||||
</header>
|
||||
{events.length === 0 ? (
|
||||
<p className={styles.eventEmpty}>{MANAGE_COPY.EVENT_LOG_EMPTY}</p>
|
||||
) : (
|
||||
<div className={styles.eventList}>
|
||||
{events.map((event, idx) => (
|
||||
<EventRow
|
||||
key={event.id}
|
||||
event={event}
|
||||
index={cursor === undefined ? idx + 1 : idx + 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<footer className={styles.eventLogFoot}>
|
||||
{cursor !== undefined ? (
|
||||
<Button variant="ghost" size="sm" onClick={onResetCursor}>
|
||||
← back to most recent
|
||||
</Button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
{hasMore ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onLoadOlder}
|
||||
busy={isFetchingNextPage}
|
||||
>
|
||||
{MANAGE_COPY.EVENT_LOAD_MORE} →
|
||||
</Button>
|
||||
) : (
|
||||
<span className={styles.eventEnd}>end of log</span>
|
||||
)}
|
||||
</footer>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
type DeleteSectionProps = {
|
||||
manageId: string
|
||||
memo: string
|
||||
}
|
||||
|
||||
function DeleteSection({
|
||||
manageId,
|
||||
memo,
|
||||
}: DeleteSectionProps): React.ReactElement {
|
||||
const [armed, setArmed] = useState(false)
|
||||
const del = useDeleteToken()
|
||||
const navigate = useNavigate()
|
||||
|
||||
function confirm(): void {
|
||||
del.mutate(manageId, {
|
||||
onSuccess: () => {
|
||||
toast.success(`Terminated ${memo || 'unnamed specimen'}`)
|
||||
navigate('/')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={styles.deleteSection}>
|
||||
<header className={styles.deleteHead}>
|
||||
<span className={styles.deleteIndex}>07 / TERMINATION</span>
|
||||
<span className={styles.deleteRule} aria-hidden="true" />
|
||||
</header>
|
||||
<h2 className={styles.deleteTitle}>{MANAGE_COPY.DELETE_TITLE}</h2>
|
||||
<p className={styles.deleteBody}>{MANAGE_COPY.DELETE_BODY}</p>
|
||||
{armed ? (
|
||||
<div className={styles.deleteArmed}>
|
||||
<Button
|
||||
variant="alarm"
|
||||
onClick={confirm}
|
||||
busy={del.isPending}
|
||||
size="md"
|
||||
>
|
||||
{MANAGE_COPY.DELETE_CONFIRM}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setArmed(false)}
|
||||
disabled={del.isPending}
|
||||
size="md"
|
||||
>
|
||||
{MANAGE_COPY.DELETE_CANCEL}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button variant="ghost" onClick={() => setArmed(true)}>
|
||||
{MANAGE_COPY.DELETE_ARM}
|
||||
</Button>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ManageLoading(): React.ReactElement {
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<Strip>
|
||||
<StripItem label="FIELD STATION">canary</StripItem>
|
||||
<StripItem label="STATUS">loading dossier…</StripItem>
|
||||
</Strip>
|
||||
<div className={styles.statusBlock}>fetching specimen record…</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ManageNotFound(): React.ReactElement {
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<Strip>
|
||||
<StripItem label="FIELD STATION">canary</StripItem>
|
||||
<StripItem label="STATUS" inverted>
|
||||
unfound
|
||||
</StripItem>
|
||||
</Strip>
|
||||
<header className={styles.hero}>
|
||||
<p className={styles.heroIndex}>404 · DOSSIER</p>
|
||||
<h1 className={styles.headline}>{MANAGE_COPY.NOT_FOUND_HEADLINE}</h1>
|
||||
<p className={styles.purpose}>{MANAGE_COPY.NOT_FOUND_BODY}</p>
|
||||
</header>
|
||||
<Link to="/" className={styles.heroLink}>
|
||||
← {MANAGE_COPY.BACK_TO_INTAKE}
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type ManageErrorProps = {
|
||||
onRetry: () => void
|
||||
}
|
||||
|
||||
function ManageError({ onRetry }: ManageErrorProps): React.ReactElement {
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<Strip>
|
||||
<StripItem label="FIELD STATION">canary</StripItem>
|
||||
<StripItem label="STATUS" inverted>
|
||||
archive offline
|
||||
</StripItem>
|
||||
</Strip>
|
||||
<header className={styles.hero}>
|
||||
<p className={styles.heroIndex}>5xx · ARCHIVE</p>
|
||||
<h1 className={styles.headline}>{MANAGE_COPY.ERROR_HEADLINE}</h1>
|
||||
<p className={styles.purpose}>{MANAGE_COPY.ERROR_BODY}</p>
|
||||
</header>
|
||||
<div className={styles.heroActions}>
|
||||
<Button onClick={onRetry}>Try again</Button>
|
||||
<Link to="/" className={styles.heroLink}>
|
||||
new specimen
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function resolveState(
|
||||
token: ManageTokenView,
|
||||
eventsTotal: number
|
||||
): 'quiet' | 'live' | 'disabled' {
|
||||
if (!token.enabled) {
|
||||
return 'disabled'
|
||||
}
|
||||
if (eventsTotal > 0) {
|
||||
return 'live'
|
||||
}
|
||||
return 'quiet'
|
||||
}
|
||||
|
||||
function formatTs(iso: string): string {
|
||||
try {
|
||||
const dt = new Date(iso)
|
||||
return dt
|
||||
.toISOString()
|
||||
.replace('T', ' ')
|
||||
.replace(/\.\d+Z$/, 'Z')
|
||||
} catch (_err) {
|
||||
return iso
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,378 @@
|
|||
// ===================
|
||||
// ©AngelaMos | 2026
|
||||
// manage.module.scss
|
||||
// ===================
|
||||
|
||||
@use '../../styles/tokens' as *;
|
||||
@use '../../styles/fonts' as *;
|
||||
@use '../../styles/mixins' as *;
|
||||
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
inline-size: 100%;
|
||||
max-inline-size: 88rem;
|
||||
margin-inline: auto;
|
||||
padding-inline: $space-4;
|
||||
padding-block-end: $space-8;
|
||||
gap: $space-6;
|
||||
|
||||
@include breakpoint-up('md') {
|
||||
padding-inline: $space-8;
|
||||
gap: $space-8;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-3;
|
||||
padding-block-start: $space-6;
|
||||
max-inline-size: 56rem;
|
||||
|
||||
@include breakpoint-up('md') {
|
||||
padding-block-start: $space-10;
|
||||
}
|
||||
}
|
||||
|
||||
.heroIndex {
|
||||
font-family: $font-mono;
|
||||
font-size: $font-size-3xs;
|
||||
letter-spacing: $tracking-wider;
|
||||
text-transform: uppercase;
|
||||
color: $ink-mute;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.heroLink {
|
||||
font-family: $font-mono;
|
||||
font-size: $font-size-xs;
|
||||
letter-spacing: $tracking-wider;
|
||||
text-transform: uppercase;
|
||||
color: $ink;
|
||||
text-decoration: none;
|
||||
border-block-end: $hairline solid $ink;
|
||||
padding-block-end: $space-0-5;
|
||||
align-self: flex-start;
|
||||
transition: color $duration-fast $ease-out;
|
||||
|
||||
&:hover {
|
||||
color: $alarm;
|
||||
border-block-end-color: $alarm;
|
||||
}
|
||||
}
|
||||
|
||||
.heroActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $space-4;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.headline {
|
||||
font-family: $font-sans;
|
||||
font-weight: 900;
|
||||
font-size: clamp(2.25rem, 8vw, 6rem);
|
||||
letter-spacing: -0.05em;
|
||||
line-height: 0.9;
|
||||
text-transform: uppercase;
|
||||
color: $ink;
|
||||
margin: 0;
|
||||
|
||||
&[data-state='live'] {
|
||||
color: $alarm;
|
||||
}
|
||||
|
||||
&[data-state='disabled'] {
|
||||
color: $ink-mute;
|
||||
}
|
||||
}
|
||||
|
||||
.purpose {
|
||||
font-family: $font-sans;
|
||||
font-size: clamp(0.95rem, 1.5vw, 1.125rem);
|
||||
line-height: $line-height-snug;
|
||||
color: $ink;
|
||||
max-inline-size: 42rem;
|
||||
margin: 0;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.memo {
|
||||
font-family: $font-mono;
|
||||
font-size: $font-size-xs;
|
||||
letter-spacing: $tracking-wide;
|
||||
color: $ink-soft;
|
||||
padding-inline-start: $space-3;
|
||||
border-inline-start: $hairline-bold solid $rule;
|
||||
margin: 0;
|
||||
max-inline-size: 42rem;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// EVENT LOG
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
.eventLog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-4;
|
||||
}
|
||||
|
||||
.eventLogHead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $space-3;
|
||||
}
|
||||
|
||||
.eventLogIndex {
|
||||
font-family: $font-mono;
|
||||
font-size: $font-size-3xs;
|
||||
letter-spacing: $tracking-wider;
|
||||
text-transform: uppercase;
|
||||
color: $ink-mute;
|
||||
}
|
||||
|
||||
.eventLogRule {
|
||||
flex: 1 1 auto;
|
||||
block-size: $hairline;
|
||||
background-color: $rule-hair;
|
||||
}
|
||||
|
||||
.eventLogCount {
|
||||
font-family: $font-mono;
|
||||
font-size: $font-size-3xs;
|
||||
letter-spacing: $tracking-wider;
|
||||
text-transform: uppercase;
|
||||
color: $ink-soft;
|
||||
}
|
||||
|
||||
.eventList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-3;
|
||||
}
|
||||
|
||||
.eventEmpty {
|
||||
padding: $space-5;
|
||||
border: $hairline dashed $rule-soft;
|
||||
background-color: $paper;
|
||||
font-family: $font-mono;
|
||||
font-size: $font-size-xs;
|
||||
letter-spacing: $tracking-wide;
|
||||
color: $ink-soft;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.eventLogFoot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $space-3;
|
||||
padding-block-start: $space-3;
|
||||
border-block-start: $hairline solid $rule-hair;
|
||||
}
|
||||
|
||||
.eventEnd {
|
||||
font-family: $font-mono;
|
||||
font-size: $font-size-3xs;
|
||||
letter-spacing: $tracking-wider;
|
||||
text-transform: uppercase;
|
||||
color: $ink-mute;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// EVENT ROW
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
.eventRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-3;
|
||||
padding: $space-4;
|
||||
border: $hairline solid $rule;
|
||||
background-color: $paper;
|
||||
}
|
||||
|
||||
.eventHead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $space-3;
|
||||
flex-wrap: wrap;
|
||||
padding-block-end: $space-2;
|
||||
border-block-end: $hairline solid $rule-hair;
|
||||
}
|
||||
|
||||
.eventIndex {
|
||||
font-family: $font-mono;
|
||||
font-size: $font-size-2xs;
|
||||
letter-spacing: $tracking-wider;
|
||||
color: $ink-mute;
|
||||
}
|
||||
|
||||
.eventTime {
|
||||
font-family: $font-mono;
|
||||
font-size: $font-size-xs;
|
||||
letter-spacing: $tracking-wide;
|
||||
color: $ink;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.eventGrid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: $space-2;
|
||||
|
||||
@include breakpoint-up('sm') {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.eventField {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-0-5;
|
||||
min-inline-size: 0;
|
||||
|
||||
&[data-wide='true'] {
|
||||
@include breakpoint-up('sm') {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-mono='true'] .eventValue {
|
||||
font-family: $font-mono;
|
||||
font-size: $font-size-xs;
|
||||
letter-spacing: $tracking-wide;
|
||||
}
|
||||
|
||||
&[data-truncate='true'] .eventValue {
|
||||
@include truncate;
|
||||
}
|
||||
}
|
||||
|
||||
.eventLabel {
|
||||
font-family: $font-mono;
|
||||
font-size: $font-size-3xs;
|
||||
letter-spacing: $tracking-wider;
|
||||
text-transform: uppercase;
|
||||
color: $ink-mute;
|
||||
}
|
||||
|
||||
.eventValue {
|
||||
font-family: $font-sans;
|
||||
font-size: $font-size-sm;
|
||||
color: $ink;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.eventExtra {
|
||||
background-color: $paper-deep;
|
||||
padding: $space-3;
|
||||
font-family: $font-mono;
|
||||
font-size: $font-size-xs;
|
||||
letter-spacing: $tracking-wide;
|
||||
|
||||
summary {
|
||||
cursor: pointer;
|
||||
color: $ink-soft;
|
||||
font-size: $font-size-3xs;
|
||||
letter-spacing: $tracking-wider;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
.eventExtraBody {
|
||||
margin: $space-2 0 0;
|
||||
white-space: pre-wrap;
|
||||
color: $ink;
|
||||
font-size: $font-size-xs;
|
||||
overflow: auto;
|
||||
max-block-size: 16rem;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// DELETE
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
.deleteSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-3;
|
||||
padding-block-start: $space-6;
|
||||
}
|
||||
|
||||
.deleteHead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $space-3;
|
||||
}
|
||||
|
||||
.deleteIndex {
|
||||
font-family: $font-mono;
|
||||
font-size: $font-size-3xs;
|
||||
letter-spacing: $tracking-wider;
|
||||
text-transform: uppercase;
|
||||
color: $alarm-deep;
|
||||
}
|
||||
|
||||
.deleteRule {
|
||||
flex: 1 1 auto;
|
||||
block-size: $hairline;
|
||||
background-color: $alarm;
|
||||
}
|
||||
|
||||
.deleteTitle {
|
||||
font-family: $font-sans;
|
||||
font-weight: 800;
|
||||
font-size: clamp(1.25rem, 3vw, 1.75rem);
|
||||
letter-spacing: -0.02em;
|
||||
text-transform: uppercase;
|
||||
color: $alarm-deep;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.deleteBody {
|
||||
font-size: $font-size-sm;
|
||||
color: $ink-soft;
|
||||
max-inline-size: 38rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.deleteArmed {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $space-3;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// FOOTER
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
.footerLink {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
border-block-end: $hairline solid $ink;
|
||||
padding-block-end: $space-0-5;
|
||||
|
||||
&:hover {
|
||||
color: $alarm;
|
||||
border-block-end-color: $alarm;
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// LOADING / STATUS
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
.statusBlock {
|
||||
padding: $space-4;
|
||||
border: $hairline solid $rule-hair;
|
||||
background-color: $paper-deep;
|
||||
font-family: $font-mono;
|
||||
font-size: $font-size-xs;
|
||||
letter-spacing: $tracking-wide;
|
||||
color: $ink-soft;
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
// ===================
|
||||
// ©AngelaMos | 2026
|
||||
// index.tsx
|
||||
// ===================
|
||||
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Strip, StripItem } from '@/components'
|
||||
import styles from './notfound.module.scss'
|
||||
|
||||
export function Component(): React.ReactElement {
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<Strip>
|
||||
<StripItem label="FIELD STATION">canary</StripItem>
|
||||
<StripItem label="STATUS" inverted>
|
||||
off-route
|
||||
</StripItem>
|
||||
</Strip>
|
||||
<main className={styles.body}>
|
||||
<p className={styles.index}>404 · OFF-ROUTE</p>
|
||||
<h1 className={styles.headline}>NO SPECIMEN HERE</h1>
|
||||
<p className={styles.purpose}>
|
||||
This path is not part of the catalog. Maybe a typo, maybe a deleted
|
||||
dossier. The intake is still open.
|
||||
</p>
|
||||
<Link to="/" className={styles.link}>
|
||||
← back to intake
|
||||
</Link>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Component.displayName = 'NotFound'
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
// ===================
|
||||
// ©AngelaMos | 2026
|
||||
// notfound.module.scss
|
||||
// ===================
|
||||
|
||||
@use '../../styles/tokens' as *;
|
||||
@use '../../styles/fonts' as *;
|
||||
@use '../../styles/mixins' as *;
|
||||
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
inline-size: 100%;
|
||||
max-inline-size: 56rem;
|
||||
margin-inline: auto;
|
||||
padding-inline: $space-4;
|
||||
padding-block-end: $space-8;
|
||||
gap: $space-8;
|
||||
|
||||
@include breakpoint-up('md') {
|
||||
padding-inline: $space-8;
|
||||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-4;
|
||||
padding-block-start: $space-10;
|
||||
}
|
||||
|
||||
.index {
|
||||
font-family: $font-mono;
|
||||
font-size: $font-size-3xs;
|
||||
letter-spacing: $tracking-wider;
|
||||
text-transform: uppercase;
|
||||
color: $ink-mute;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.headline {
|
||||
font-family: $font-sans;
|
||||
font-weight: 900;
|
||||
font-size: clamp(2.25rem, 8vw, 5.5rem);
|
||||
letter-spacing: -0.05em;
|
||||
line-height: 0.9;
|
||||
text-transform: uppercase;
|
||||
color: $ink;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.purpose {
|
||||
font-family: $font-sans;
|
||||
font-size: clamp(0.95rem, 1.5vw, 1.125rem);
|
||||
color: $ink-soft;
|
||||
max-inline-size: 42rem;
|
||||
margin: 0;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.link {
|
||||
font-family: $font-mono;
|
||||
font-size: $font-size-xs;
|
||||
letter-spacing: $tracking-wider;
|
||||
text-transform: uppercase;
|
||||
color: $ink;
|
||||
text-decoration: none;
|
||||
border-block-end: $hairline solid $ink;
|
||||
padding-block-end: $space-0-5;
|
||||
align-self: flex-start;
|
||||
|
||||
&:hover {
|
||||
color: $alarm;
|
||||
border-block-end-color: $alarm;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue