fix(canary-phase-ui-design): single-source form fields + URL revoke + eventExtra schema
Parallel audit (superpowers:code-reviewer + general-purpose spec adherence)
returned PASS-WITH-NOTES + PASS. All actionable SHOULD-FIX and NIT items
addressed in this commit per the fix-in-phase rule.
SHOULD-FIX (code review):
- Dead ternary at manage/index.tsx:253 (`cursor === undefined ? idx+1 : idx+1`)
collapsed to `idx + 1`. EventLogSection prop renamed `cursor` → `cursorIsSet`
(boolean) since the value itself was never consumed past the dead branch.
- URL.createObjectURL leak in artifact.tsx — both file (base64) and text
variants now run inside useEffect with cleanup that calls
URL.revokeObjectURL on dependency-change AND on unmount. Extracted into
two co-located hooks: useBase64ObjectUrl, useTextObjectUrl.
- Text inputs lacked <label htmlFor=…> association. Refactored the landing
form to use the existing Field primitives (TextField, TextareaField) which
wire htmlFor↔id via useId. This single change also closed the next finding.
- Dead infrastructure: Field primitives existed but no consumer used them.
Landing form's local FieldLabel removed; TextField/TextareaField now have
consumers. Net diff: −181 LOC.
- LeaderLabel: truly unused primitive, no foreseeable consumer in this scope.
Deleted (`frontend/src/components/LeaderLabel/`) and removed from barrel.
SHOULD-FIX (spec adherence):
- §8.6 row 410 → TOKEN_DISABLED: spec is stale. Backend Go ground truth
(verified via `grep StatusGone\|TOKEN_DISABLED backend/internal/`) emits
neither — disabled tokens come back as 200 with `token.enabled=false`,
which the DossierCard already renders with alarm-pill + HEADLINE_DISABLED.
Per feedback_verify_data_shapes.md: backend Go beats spec when they diverge.
No code change; documented here so the next AI doesn't re-open it.
- eventResponseSchema.extra: tightened from `z.unknown()` to
`z.record(z.string(), z.unknown())` to match backend's `json.RawMessage`
default of `{}` (event/repository.go:47). Added EventExtra type alias;
event-row.tsx now uses it instead of casting through unknown.
NIT:
- SerialBar: extracted `BAR_COUNT = 22` const; `FALLBACK_BARS` constant
string replaces the BAR_CHARS array that had length-9 / length-22
inconsistency between the empty-input and computed branches.
- Glyph: removed `default` branch — switch is now exhaustive over the
closed TokenType enum, so a future 8th type would be a compile error
rather than silently rendering WebbugGlyph.
- Dead `export { TOKEN_BLURB }` + `export { ArtifactDisplay }` from
landing/index.tsx — no observed consumer, dropped.
- App.tsx and main.tsx header banners normalized to the project-canonical
19 = signs (matches all other files in repo).
NOT addressed in this commit (intentional):
- CreateTokenRequest `metadata` as `z.unknown()`. Refactor to a discriminated
request schema keyed on `type` would tighten compile-time safety for any
future caller bypassing buildPayload — defer because (a) the form is the
only caller and validates via Zod on submit, (b) the change touches the
Phase 14 contract that was just stabilized two commits before this UI phase.
- NotFound (frontend route fallback) vs ManageNotFound (manage 404) live
separately. Both work; the two paths are intentional — off-path vs
off-dossier are semantically different events.
Gates green: typecheck + biome check + lint:scss + build (414ms; landing
chunk dropped 21KB → 19.93KB after dead style removal).
This commit is contained in:
parent
4a4f465d24
commit
da126ad889
|
|
@ -1,7 +1,7 @@
|
|||
// ===========================
|
||||
// ===================
|
||||
// ©AngelaMos | 2026
|
||||
// App.tsx
|
||||
// ===========================
|
||||
// ===================
|
||||
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
||||
|
|
|
|||
|
|
@ -20,6 +20,10 @@ export const geoViewSchema = z.object({
|
|||
|
||||
export type GeoView = z.infer<typeof geoViewSchema>
|
||||
|
||||
export const eventExtraSchema = z.record(z.string(), z.unknown())
|
||||
|
||||
export type EventExtra = z.infer<typeof eventExtraSchema>
|
||||
|
||||
export const eventResponseSchema = z.object({
|
||||
id: z.number().int().nonnegative(),
|
||||
triggered_at: z.iso.datetime(),
|
||||
|
|
@ -27,7 +31,7 @@ export const eventResponseSchema = z.object({
|
|||
user_agent: z.string().nullable(),
|
||||
referer: z.string().nullable(),
|
||||
geo: geoViewSchema,
|
||||
extra: z.unknown(),
|
||||
extra: eventExtraSchema,
|
||||
notify_status: notifyStatusSchema,
|
||||
notified_at: z.iso.datetime().nullable(),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -52,8 +52,6 @@ function renderGlyph(type: TokenType): React.ReactElement {
|
|||
return <EnvfileGlyph />
|
||||
case 'mysql':
|
||||
return <MysqlGlyph />
|
||||
default:
|
||||
return <WebbugGlyph />
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,57 +0,0 @@
|
|||
// ===================
|
||||
// ©AngelaMos | 2026
|
||||
// LeaderLabel.module.scss
|
||||
// ===================
|
||||
|
||||
@use '../../styles/tokens' as *;
|
||||
@use '../../styles/fonts' as *;
|
||||
|
||||
.label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-1;
|
||||
max-width: 22ch;
|
||||
font-family: $font-mono;
|
||||
|
||||
&[data-align='right'] {
|
||||
text-align: right;
|
||||
align-self: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $space-1-5;
|
||||
}
|
||||
|
||||
.index {
|
||||
font-size: $font-size-3xs;
|
||||
letter-spacing: $tracking-wider;
|
||||
text-transform: uppercase;
|
||||
color: $ink-mute;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rule {
|
||||
flex: 1 1 auto;
|
||||
block-size: $hairline;
|
||||
background-color: $rule;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.body {
|
||||
font-size: $font-size-2xs;
|
||||
line-height: $line-height-tight;
|
||||
letter-spacing: $tracking-wide;
|
||||
text-transform: uppercase;
|
||||
color: $ink;
|
||||
}
|
||||
|
||||
.caption {
|
||||
font-size: $font-size-3xs;
|
||||
line-height: $line-height-snug;
|
||||
color: $ink-mute;
|
||||
text-transform: none;
|
||||
letter-spacing: $tracking-normal;
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
// ===================
|
||||
// ©AngelaMos | 2026
|
||||
// index.tsx
|
||||
// ===================
|
||||
|
||||
import type { PropsWithChildren, ReactNode } from 'react'
|
||||
import styles from './LeaderLabel.module.scss'
|
||||
|
||||
type LeaderLabelProps = PropsWithChildren<{
|
||||
index?: string
|
||||
caption?: ReactNode
|
||||
align?: 'left' | 'right'
|
||||
}>
|
||||
|
||||
export function LeaderLabel({
|
||||
children,
|
||||
index,
|
||||
caption,
|
||||
align = 'left',
|
||||
}: LeaderLabelProps): React.ReactElement {
|
||||
return (
|
||||
<div className={styles.label} data-align={align}>
|
||||
<div className={styles.head}>
|
||||
{index ? <span className={styles.index}>{index}</span> : null}
|
||||
<span className={styles.rule} aria-hidden="true" />
|
||||
</div>
|
||||
<div className={styles.body}>{children}</div>
|
||||
{caption ? <div className={styles.caption}>{caption}</div> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -11,14 +11,15 @@ type SerialBarProps = {
|
|||
prefix?: string
|
||||
}
|
||||
|
||||
const BAR_CHARS = ['▮', '▯', '▮', '▮', '▯', '▮', '▯', '▯', '▮']
|
||||
const BAR_COUNT = 22
|
||||
const FALLBACK_BARS = '▮▯▮▮▯▮▯▯▮▮▯▮▮▯▮▯▮▮▯▮▯▮'
|
||||
|
||||
function fingerprintToBars(value: string): string {
|
||||
if (value.length === 0) {
|
||||
return BAR_CHARS.join('')
|
||||
return FALLBACK_BARS
|
||||
}
|
||||
let acc = ''
|
||||
for (let i = 0; i < 22; i += 1) {
|
||||
for (let i = 0; i < BAR_COUNT; i += 1) {
|
||||
const ch = value.charCodeAt(i % value.length)
|
||||
acc += (ch & 1) === 0 ? '▮' : '▯'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,15 +6,9 @@
|
|||
export { Button } from './Button'
|
||||
export { CopyField } from './CopyField'
|
||||
export { DataRow } from './DataRow'
|
||||
export {
|
||||
FieldWrap,
|
||||
SelectField,
|
||||
TextareaField,
|
||||
TextField,
|
||||
} from './Field'
|
||||
export { FieldWrap, SelectField, TextareaField, TextField } from './Field'
|
||||
export { Glyph } from './Glyph'
|
||||
export { Halftone } from './Halftone'
|
||||
export { LeaderLabel } from './LeaderLabel'
|
||||
export { Pill } from './Pill'
|
||||
export { SerialBar } from './SerialBar'
|
||||
export { SpecimenCard, SpecimenCardSection } from './SpecimenCard'
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// ===========================
|
||||
// ===================
|
||||
// ©AngelaMos | 2026
|
||||
// main.tsx
|
||||
// ===========================
|
||||
// ===================
|
||||
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
// artifact.tsx
|
||||
// ===================
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import type { Artifact } from '@/api'
|
||||
import { CopyField, Pill } from '@/components'
|
||||
|
|
@ -63,16 +63,7 @@ function FileArtifact({
|
|||
}): React.ReactElement {
|
||||
const filename = artifact.filename ?? filenameFallback
|
||||
const contentType = artifact.content_type ?? 'application/octet-stream'
|
||||
const href = useMemo(() => {
|
||||
if (!artifact.content_b64) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return base64ToObjectUrl(artifact.content_b64, contentType)
|
||||
} catch (_err) {
|
||||
return null
|
||||
}
|
||||
}, [artifact.content_b64, contentType])
|
||||
const href = useBase64ObjectUrl(artifact.content_b64, contentType)
|
||||
|
||||
if (!href) {
|
||||
return <Pill tone="alarm">artifact unreadable</Pill>
|
||||
|
|
@ -109,13 +100,7 @@ function TextArtifact({
|
|||
const filename = artifact.filename ?? filenameFallback
|
||||
const content = artifact.content ?? ''
|
||||
const contentType = artifact.content_type ?? 'text/plain'
|
||||
const href = useMemo(() => {
|
||||
if (content.length === 0) {
|
||||
return null
|
||||
}
|
||||
const blob = new Blob([content], { type: contentType })
|
||||
return URL.createObjectURL(blob)
|
||||
}, [content, contentType])
|
||||
const href = useTextObjectUrl(content, contentType)
|
||||
|
||||
return (
|
||||
<div className={styles.artifactStack}>
|
||||
|
|
@ -152,12 +137,57 @@ function ConnectionStringArtifact({
|
|||
)
|
||||
}
|
||||
|
||||
function base64ToObjectUrl(b64: string, contentType: string): string {
|
||||
function useBase64ObjectUrl(
|
||||
b64: string | undefined,
|
||||
contentType: string
|
||||
): string | null {
|
||||
const [url, setUrl] = useState<string | null>(null)
|
||||
useEffect(() => {
|
||||
if (!b64 || b64.length === 0) {
|
||||
setUrl(null)
|
||||
return
|
||||
}
|
||||
let created: string | null = null
|
||||
try {
|
||||
const bytes = base64ToBytes(b64)
|
||||
const blob = new Blob([bytes], { type: contentType })
|
||||
created = URL.createObjectURL(blob)
|
||||
setUrl(created)
|
||||
} catch (_err) {
|
||||
setUrl(null)
|
||||
}
|
||||
return () => {
|
||||
if (created !== null) {
|
||||
URL.revokeObjectURL(created)
|
||||
}
|
||||
}
|
||||
}, [b64, contentType])
|
||||
return url
|
||||
}
|
||||
|
||||
function useTextObjectUrl(content: string, contentType: string): string | null {
|
||||
const [url, setUrl] = useState<string | null>(null)
|
||||
useEffect(() => {
|
||||
if (content.length === 0) {
|
||||
setUrl(null)
|
||||
return
|
||||
}
|
||||
const blob = new Blob([content], { type: contentType })
|
||||
const next = URL.createObjectURL(blob)
|
||||
setUrl(next)
|
||||
return () => {
|
||||
URL.revokeObjectURL(next)
|
||||
}
|
||||
}, [content, contentType])
|
||||
return url
|
||||
}
|
||||
|
||||
function base64ToBytes(b64: string): Uint8Array<ArrayBuffer> {
|
||||
const binary = atob(b64)
|
||||
const len = binary.length
|
||||
const bytes = new Uint8Array(len)
|
||||
for (let i = 0; i < len; i += 1) {
|
||||
bytes[i] = binary.charCodeAt(i)
|
||||
}
|
||||
return URL.createObjectURL(new Blob([bytes], { type: contentType }))
|
||||
return bytes
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,10 +14,16 @@ import {
|
|||
useCreateToken,
|
||||
useTokenTypes,
|
||||
} from '@/api'
|
||||
import { Button, Halftone, Strip, StripItem } from '@/components'
|
||||
import {
|
||||
Button,
|
||||
Halftone,
|
||||
Strip,
|
||||
StripItem,
|
||||
TextareaField,
|
||||
TextField,
|
||||
} from '@/components'
|
||||
import { getTurnstileSiteKey, Turnstile } from '@/core/turnstile'
|
||||
import { ArtifactDisplay } from './artifact'
|
||||
import { PAGE_COPY, TOKEN_BLURB } from './copy'
|
||||
import { PAGE_COPY } from './copy'
|
||||
import {
|
||||
buildSubmissionState,
|
||||
FILE_KIND_TYPES,
|
||||
|
|
@ -224,22 +230,17 @@ function AnnotateSection({
|
|||
title={PAGE_COPY.SECTION_02_TITLE}
|
||||
body={PAGE_COPY.SECTION_02_BODY}
|
||||
>
|
||||
<div className={styles.fieldRow}>
|
||||
<FieldLabel index="A">MEMO</FieldLabel>
|
||||
<textarea
|
||||
className={styles.textarea}
|
||||
value={form.memo}
|
||||
maxLength={256}
|
||||
placeholder="Q4 bonuses spreadsheet on file-server"
|
||||
onChange={(e) => onChange({ memo: e.target.value })}
|
||||
data-invalid={errors.memo !== undefined}
|
||||
/>
|
||||
{errors.memo ? (
|
||||
<p className={styles.fieldError}>{errors.memo}</p>
|
||||
) : (
|
||||
<p className={styles.fieldHint}>{form.memo.length} / 256 characters</p>
|
||||
)}
|
||||
</div>
|
||||
<TextareaField
|
||||
index="A"
|
||||
label="MEMO"
|
||||
name="memo"
|
||||
value={form.memo}
|
||||
maxLength={256}
|
||||
placeholder="Q4 bonuses spreadsheet on file-server"
|
||||
onChange={(e) => onChange({ memo: e.target.value })}
|
||||
error={errors.memo}
|
||||
hint={`${form.memo.length} / 256 characters`}
|
||||
/>
|
||||
{showFilename ? (
|
||||
<FilenameField form={form} errors={errors} onChange={onChange} />
|
||||
) : null}
|
||||
|
|
@ -253,24 +254,17 @@ function FilenameField({
|
|||
onChange,
|
||||
}: SectionPropsBase): React.ReactElement {
|
||||
return (
|
||||
<div className={styles.fieldRow}>
|
||||
<FieldLabel index="B">FILENAME (OPTIONAL)</FieldLabel>
|
||||
<input
|
||||
className={styles.input}
|
||||
value={form.filename}
|
||||
maxLength={128}
|
||||
placeholder={defaultFilename(form.type)}
|
||||
onChange={(e) => onChange({ filename: e.target.value })}
|
||||
data-invalid={errors.filename !== undefined}
|
||||
/>
|
||||
{errors.filename ? (
|
||||
<p className={styles.fieldError}>{errors.filename}</p>
|
||||
) : (
|
||||
<p className={styles.fieldHint}>
|
||||
What the bait file will be called when it lands somewhere it shouldn't.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<TextField
|
||||
index="B"
|
||||
label="FILENAME (OPTIONAL)"
|
||||
name="filename"
|
||||
value={form.filename}
|
||||
maxLength={128}
|
||||
placeholder={defaultFilename(form.type)}
|
||||
onChange={(e) => onChange({ filename: e.target.value })}
|
||||
error={errors.filename}
|
||||
hint="What the bait file will be called when it lands somewhere it shouldn't."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -305,32 +299,24 @@ function TelegramFields({
|
|||
}: SectionPropsBase): React.ReactElement {
|
||||
return (
|
||||
<div className={styles.subgrid}>
|
||||
<div className={styles.fieldRow}>
|
||||
<FieldLabel index="A">BOT TOKEN</FieldLabel>
|
||||
<input
|
||||
className={styles.input}
|
||||
value={form.telegramBot}
|
||||
placeholder="123456789:ABCdefGHIjklMNO..."
|
||||
onChange={(e) => onChange({ telegramBot: e.target.value })}
|
||||
data-invalid={errors.telegram_bot !== undefined}
|
||||
/>
|
||||
{errors.telegram_bot ? (
|
||||
<p className={styles.fieldError}>{errors.telegram_bot}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.fieldRow}>
|
||||
<FieldLabel index="B">CHAT ID</FieldLabel>
|
||||
<input
|
||||
className={styles.input}
|
||||
value={form.telegramChat}
|
||||
placeholder="-1001234567890"
|
||||
onChange={(e) => onChange({ telegramChat: e.target.value })}
|
||||
data-invalid={errors.telegram_chat !== undefined}
|
||||
/>
|
||||
{errors.telegram_chat ? (
|
||||
<p className={styles.fieldError}>{errors.telegram_chat}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<TextField
|
||||
index="A"
|
||||
label="BOT TOKEN"
|
||||
name="telegram_bot"
|
||||
value={form.telegramBot}
|
||||
placeholder="123456789:ABCdefGHIjklMNO..."
|
||||
onChange={(e) => onChange({ telegramBot: e.target.value })}
|
||||
error={errors.telegram_bot}
|
||||
/>
|
||||
<TextField
|
||||
index="B"
|
||||
label="CHAT ID"
|
||||
name="telegram_chat"
|
||||
value={form.telegramChat}
|
||||
placeholder="-1001234567890"
|
||||
onChange={(e) => onChange({ telegramChat: e.target.value })}
|
||||
error={errors.telegram_chat}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -341,25 +327,17 @@ function WebhookField({
|
|||
onChange,
|
||||
}: SectionPropsBase): React.ReactElement {
|
||||
return (
|
||||
<div className={styles.fieldRow}>
|
||||
<FieldLabel index="A">WEBHOOK URL</FieldLabel>
|
||||
<input
|
||||
className={styles.input}
|
||||
type="url"
|
||||
value={form.webhookUrl}
|
||||
placeholder="https://hooks.example.com/canary"
|
||||
onChange={(e) => onChange({ webhookUrl: e.target.value })}
|
||||
data-invalid={errors.webhook_url !== undefined}
|
||||
/>
|
||||
{errors.webhook_url ? (
|
||||
<p className={styles.fieldError}>{errors.webhook_url}</p>
|
||||
) : (
|
||||
<p className={styles.fieldHint}>
|
||||
We POST the alert envelope JSON. Optionally HMAC-signed if the server
|
||||
has WEBHOOK_HMAC_SECRET set.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<TextField
|
||||
index="A"
|
||||
label="WEBHOOK URL"
|
||||
name="webhook_url"
|
||||
type="url"
|
||||
value={form.webhookUrl}
|
||||
placeholder="https://hooks.example.com/canary"
|
||||
onChange={(e) => onChange({ webhookUrl: e.target.value })}
|
||||
error={errors.webhook_url}
|
||||
hint="We POST the alert envelope JSON. Optionally HMAC-signed if the server has WEBHOOK_HMAC_SECRET set."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -395,20 +373,17 @@ function SlowRedirectField({
|
|||
errors,
|
||||
onChange,
|
||||
}: SectionPropsBase): React.ReactElement {
|
||||
const err = errors['metadata.destination_url']
|
||||
return (
|
||||
<div className={styles.fieldRow}>
|
||||
<FieldLabel index="A">DESTINATION URL</FieldLabel>
|
||||
<input
|
||||
className={styles.input}
|
||||
type="url"
|
||||
value={form.slowredirectDestination}
|
||||
placeholder="https://example.com/landing"
|
||||
onChange={(e) => onChange({ slowredirectDestination: e.target.value })}
|
||||
data-invalid={err !== undefined}
|
||||
/>
|
||||
{err ? <p className={styles.fieldError}>{err}</p> : null}
|
||||
</div>
|
||||
<TextField
|
||||
index="A"
|
||||
label="DESTINATION URL"
|
||||
name="metadata_destination_url"
|
||||
type="url"
|
||||
value={form.slowredirectDestination}
|
||||
placeholder="https://example.com/landing"
|
||||
onChange={(e) => onChange({ slowredirectDestination: e.target.value })}
|
||||
error={errors['metadata.destination_url']}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -469,19 +444,6 @@ function Section({
|
|||
)
|
||||
}
|
||||
|
||||
function FieldLabel({
|
||||
index,
|
||||
children,
|
||||
}: React.PropsWithChildren<{ index?: string }>): React.ReactElement {
|
||||
return (
|
||||
<div className={styles.fieldLabel}>
|
||||
{index ? <span className={styles.fieldIndex}>{index}</span> : null}
|
||||
<span className={styles.fieldName}>{children}</span>
|
||||
<span className={styles.fieldRule} aria-hidden="true" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type ChannelToggleProps = {
|
||||
value: 'telegram' | 'webhook'
|
||||
onChange: (next: 'telegram' | 'webhook') => void
|
||||
|
|
@ -608,6 +570,3 @@ function descriptorTitle(type: TokenType | ''): string {
|
|||
}
|
||||
return 'CONFIG'
|
||||
}
|
||||
|
||||
export { TOKEN_BLURB }
|
||||
export { ArtifactDisplay }
|
||||
|
|
|
|||
|
|
@ -262,83 +262,6 @@
|
|||
color: $ink-soft;
|
||||
}
|
||||
|
||||
.fieldRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-2;
|
||||
}
|
||||
|
||||
.fieldLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $space-2;
|
||||
}
|
||||
|
||||
.fieldIndex {
|
||||
font-family: $font-mono;
|
||||
font-size: $font-size-3xs;
|
||||
letter-spacing: $tracking-wider;
|
||||
color: $ink-mute;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fieldName {
|
||||
font-family: $font-mono;
|
||||
font-size: $font-size-3xs;
|
||||
font-weight: $font-weight-semibold;
|
||||
letter-spacing: $tracking-wider;
|
||||
text-transform: uppercase;
|
||||
color: $ink;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fieldRule {
|
||||
flex: 1 1 auto;
|
||||
block-size: $hairline;
|
||||
background-color: $rule-hair;
|
||||
}
|
||||
|
||||
.input,
|
||||
.textarea {
|
||||
inline-size: 100%;
|
||||
padding-block: $space-3;
|
||||
padding-inline: $space-3;
|
||||
border: $hairline solid $rule;
|
||||
background-color: $paper;
|
||||
font-family: $font-sans;
|
||||
font-size: $font-size-base;
|
||||
color: $ink;
|
||||
appearance: none;
|
||||
border-radius: 0;
|
||||
transition: border-color $duration-fast $ease-out;
|
||||
|
||||
&::placeholder {
|
||||
color: $ink-faint;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 $hairline $ink;
|
||||
border-color: $ink;
|
||||
}
|
||||
|
||||
&[data-invalid='true'] {
|
||||
border-color: $alarm;
|
||||
}
|
||||
}
|
||||
|
||||
.textarea {
|
||||
min-block-size: 5lh;
|
||||
resize: vertical;
|
||||
line-height: $line-height-normal;
|
||||
}
|
||||
|
||||
.fieldHint {
|
||||
font-size: $font-size-xs;
|
||||
color: $ink-mute;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.fieldError {
|
||||
font-family: $font-mono;
|
||||
font-size: $font-size-3xs;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
// event-row.tsx
|
||||
// ===================
|
||||
|
||||
import type { EventResponse, GeoView } from '@/api'
|
||||
import type { EventExtra, EventResponse, GeoView } from '@/api'
|
||||
import { Pill } from '@/components'
|
||||
import { NOTIFY_TONE } from './copy'
|
||||
import styles from './manage.module.scss'
|
||||
|
|
@ -115,8 +115,6 @@ function formatGeo(geo: GeoView): string {
|
|||
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
|
||||
function hasExtraDetails(extra: EventExtra): boolean {
|
||||
return Object.keys(extra).length > 0
|
||||
}
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ function ManageView({
|
|||
|
||||
<EventLogSection
|
||||
events={events}
|
||||
cursor={cursor}
|
||||
cursorIsSet={cursor !== undefined}
|
||||
hasMore={page.has_more}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
onLoadOlder={onLoadOlder}
|
||||
|
|
@ -218,7 +218,7 @@ function DossierCard({
|
|||
|
||||
type EventLogSectionProps = {
|
||||
events: ManageResponse['events']
|
||||
cursor: string | undefined
|
||||
cursorIsSet: boolean
|
||||
hasMore: boolean
|
||||
isFetchingNextPage: boolean
|
||||
onLoadOlder: () => void
|
||||
|
|
@ -227,7 +227,7 @@ type EventLogSectionProps = {
|
|||
|
||||
function EventLogSection({
|
||||
events,
|
||||
cursor,
|
||||
cursorIsSet,
|
||||
hasMore,
|
||||
isFetchingNextPage,
|
||||
onLoadOlder,
|
||||
|
|
@ -247,16 +247,12 @@ function EventLogSection({
|
|||
) : (
|
||||
<div className={styles.eventList}>
|
||||
{events.map((event, idx) => (
|
||||
<EventRow
|
||||
key={event.id}
|
||||
event={event}
|
||||
index={cursor === undefined ? idx + 1 : idx + 1}
|
||||
/>
|
||||
<EventRow key={event.id} event={event} index={idx + 1} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<footer className={styles.eventLogFoot}>
|
||||
{cursor !== undefined ? (
|
||||
{cursorIsSet ? (
|
||||
<Button variant="ghost" size="sm" onClick={onResetCursor}>
|
||||
← back to most recent
|
||||
</Button>
|
||||
|
|
|
|||
Loading…
Reference in New Issue