Initial release: personal-records primitives
RecordCard / RecordGroupSection / TierBadge / RecordKindIcon, FieldTable + spec-driven FieldInputs + KeyValueEditor, the marquee RecordsEmpty, renderRecordMd (mirror of the personal cloud's server renderer), and parseMrzTd3 — a fully-local TD3 passport MRZ parser with check-digit verification (validated against the ICAO Doc 9303 specimen). Token-themed; lucide-react is the only dep (Crema shared-dep aliasing). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
45
README.md
Normal file
45
README.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# @crema/records-ui
|
||||||
|
|
||||||
|
Personal-records primitives for the personal cloud's Records surface
|
||||||
|
(arcadia-personal-cloud `docs/RECORDS.md`): kind-typed record cards, group
|
||||||
|
sections with privacy framing, field tables and spec-driven field inputs,
|
||||||
|
tier badges, the record→Markdown renderer, and a TD3 passport MRZ parser.
|
||||||
|
|
||||||
|
This lib ships **shapes and pure logic**, not data fetching. The app owns
|
||||||
|
the API client, the passkey doc-vault crypto, and flow orchestration; this
|
||||||
|
lib renders records and parses/renders their field structures.
|
||||||
|
|
||||||
|
## Public API
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import {
|
||||||
|
RecordCard, RecordGroupSection, TierBadge, RecordKindIcon,
|
||||||
|
FieldTable, FieldInputs, KeyValueEditor, RecordsEmpty,
|
||||||
|
renderRecordMd, parseMrzTd3,
|
||||||
|
type RecordKindInfo, type FieldSpec, type RecordTier,
|
||||||
|
} from "@crema/records-ui"
|
||||||
|
|
||||||
|
// Group section with the locked-group privacy framing
|
||||||
|
<RecordGroupSection label="Identity" locked>
|
||||||
|
<RecordCard record={summary} icon="passport" onClick={open} />
|
||||||
|
</RecordGroupSection>
|
||||||
|
|
||||||
|
// Read view of fields (registry order, blanks skipped)
|
||||||
|
<FieldTable fields={kind.fields} data={record.data} />
|
||||||
|
|
||||||
|
// Spec-driven add/edit inputs; KeyValueEditor for free-form `other`
|
||||||
|
<FieldInputs fields={kind.fields} values={draft} onChange={set} />
|
||||||
|
|
||||||
|
// The Markdown the assistant consumes (mirror of the server renderer)
|
||||||
|
const md = renderRecordMd("Passport", "My passport", kind.fields, data)
|
||||||
|
|
||||||
|
// Fully-local passport MRZ parse (two 44-char lines, check digits verified)
|
||||||
|
const mrz = parseMrzTd3(pastedText)
|
||||||
|
```
|
||||||
|
|
||||||
|
Field specs come from the backend registry (`GET /me/records/kinds`) —
|
||||||
|
this lib defines the types and rendering, never the vocabulary.
|
||||||
|
|
||||||
|
Token-themed throughout (`bg-card`, `text-muted-foreground`, `--primary`);
|
||||||
|
no hex values. `lucide-react` is the only dependency (a Crema shared dep —
|
||||||
|
consumers alias it to their installed copy).
|
||||||
647
src/index.tsx
Normal file
647
src/index.tsx
Normal file
@@ -0,0 +1,647 @@
|
|||||||
|
// PURPOSE: Personal-records primitives — kind-typed record cards, group
|
||||||
|
// sections with privacy framing, field tables and spec-driven
|
||||||
|
// field inputs, tier badges, the record→Markdown renderer, and a
|
||||||
|
// TD3 passport MRZ parser. Domain UI for the personal cloud's
|
||||||
|
// Records surface (arcadia-personal-cloud docs/RECORDS.md); shapes
|
||||||
|
// here, data fetching in the app.
|
||||||
|
// ===========================================================================
|
||||||
|
// EXPORTS
|
||||||
|
// Components: RecordKindIcon, TierBadge, RecordCard, RecordGroupSection,
|
||||||
|
// FieldTable, FieldInputs, KeyValueEditor, RecordsEmpty
|
||||||
|
// Functions: renderRecordMd, parseMrzTd3, isBlankValue
|
||||||
|
// Types: RecordTier, FieldSpec, RecordKindInfo, RecordGroupInfo,
|
||||||
|
// RecordSummaryLike, MrzResult
|
||||||
|
// ===========================================================================
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { FC, ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
Award,
|
||||||
|
Book,
|
||||||
|
BookUser,
|
||||||
|
Car,
|
||||||
|
Dumbbell,
|
||||||
|
File as FileIcon,
|
||||||
|
GraduationCap,
|
||||||
|
HeartPulse,
|
||||||
|
IdCard,
|
||||||
|
Lock,
|
||||||
|
Paperclip,
|
||||||
|
Pill,
|
||||||
|
Plane,
|
||||||
|
Plus,
|
||||||
|
Receipt,
|
||||||
|
Repeat,
|
||||||
|
Scroll,
|
||||||
|
Shield,
|
||||||
|
ShieldCheck,
|
||||||
|
Sparkles,
|
||||||
|
Stethoscope,
|
||||||
|
Syringe,
|
||||||
|
Trash2,
|
||||||
|
Wrench,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Utility */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function cn(...classes: (string | false | null | undefined)[]): string {
|
||||||
|
return classes.filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Types — mirror the backend registry (GET /me/records/kinds) */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export type RecordTier = "private" | "open";
|
||||||
|
|
||||||
|
export interface FieldSpec {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
type: "text" | "longtext" | "date" | "number";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecordKindInfo {
|
||||||
|
kind: string;
|
||||||
|
group: string;
|
||||||
|
label: string;
|
||||||
|
icon: string;
|
||||||
|
floor: RecordTier;
|
||||||
|
default_tier: RecordTier;
|
||||||
|
locked: boolean;
|
||||||
|
schema_version: number;
|
||||||
|
fields: FieldSpec[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecordGroupInfo {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
floor: RecordTier;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The slice of a record listing entry these components need. */
|
||||||
|
export interface RecordSummaryLike {
|
||||||
|
id: string;
|
||||||
|
kind: string;
|
||||||
|
label?: string | null;
|
||||||
|
tier: RecordTier;
|
||||||
|
locked?: boolean;
|
||||||
|
title?: string | null;
|
||||||
|
attachment_count?: number;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* RecordKindIcon — registry icon string → SVG */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const iconMap: Record<string, FC<{ className?: string }>> = {
|
||||||
|
passport: BookUser,
|
||||||
|
"id-card": IdCard,
|
||||||
|
plane: Plane,
|
||||||
|
scroll: Scroll,
|
||||||
|
"heart-pulse": HeartPulse,
|
||||||
|
stethoscope: Stethoscope,
|
||||||
|
syringe: Syringe,
|
||||||
|
pill: Pill,
|
||||||
|
shield: Shield,
|
||||||
|
badge: Award,
|
||||||
|
dumbbell: Dumbbell,
|
||||||
|
repeat: Repeat,
|
||||||
|
car: Car,
|
||||||
|
wrench: Wrench,
|
||||||
|
receipt: Receipt,
|
||||||
|
book: Book,
|
||||||
|
"graduation-cap": GraduationCap,
|
||||||
|
file: FileIcon,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RecordKindIcon({
|
||||||
|
icon,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
icon: string | undefined;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const Icon = (icon && iconMap[icon]) || FileIcon;
|
||||||
|
return <Icon className={className} aria-hidden />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* TierBadge */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/** Privacy posture at a glance. Private = lock ("only you can open
|
||||||
|
* this"); open = the assistant can use it ambiently. */
|
||||||
|
export function TierBadge({
|
||||||
|
tier,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
tier: RecordTier;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
if (tier === "private") {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="tier-badge"
|
||||||
|
data-tier="private"
|
||||||
|
title="Only you can open this — it's locked with your passkey"
|
||||||
|
className={cn(
|
||||||
|
"inline-flex shrink-0 items-center gap-1 rounded-full bg-foreground/8 px-2 py-0.5 text-[0.7rem] font-medium text-foreground/70",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Lock className="size-3" /> Private
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="tier-badge"
|
||||||
|
data-tier="open"
|
||||||
|
title="Your assistant can use this record"
|
||||||
|
className={cn(
|
||||||
|
"inline-flex shrink-0 items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-[0.7rem] font-medium text-primary",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Sparkles className="size-3" /> Assistant
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* RecordCard */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export function RecordCard({
|
||||||
|
record,
|
||||||
|
icon,
|
||||||
|
onClick,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
record: RecordSummaryLike;
|
||||||
|
/** Registry icon string for the record's kind. */
|
||||||
|
icon?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-slot="record-card"
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
"group flex w-full items-center gap-3 rounded-xl border bg-card p-3 text-left transition-all",
|
||||||
|
"hover:border-ring/50 hover:shadow-sm",
|
||||||
|
"focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"grid size-11 shrink-0 place-items-center rounded-lg",
|
||||||
|
record.tier === "private"
|
||||||
|
? "bg-foreground/[0.05] text-foreground/70"
|
||||||
|
: "bg-primary/10 text-primary",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<RecordKindIcon icon={icon} className="size-5" />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate text-sm font-medium text-foreground">
|
||||||
|
{record.title || record.label || record.kind}
|
||||||
|
</span>
|
||||||
|
<span className="mt-0.5 flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span className="truncate">{record.label ?? record.kind}</span>
|
||||||
|
{record.attachment_count ? (
|
||||||
|
<span className="inline-flex shrink-0 items-center gap-0.5">
|
||||||
|
<Paperclip className="size-3" />
|
||||||
|
{record.attachment_count}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<TierBadge tier={record.tier} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* RecordGroupSection */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/** A kind-group section. Locked groups (identity/medical) carry the
|
||||||
|
* privacy framing in the header — the product promise, stated where the
|
||||||
|
* records live. */
|
||||||
|
export function RecordGroupSection({
|
||||||
|
label,
|
||||||
|
locked,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
locked?: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section data-slot="record-group" className={cn("space-y-2", className)}>
|
||||||
|
<div className="flex items-baseline gap-2 px-1">
|
||||||
|
<h3 className="text-xs font-semibold tracking-wide text-muted-foreground uppercase">
|
||||||
|
{label}
|
||||||
|
</h3>
|
||||||
|
{locked ? (
|
||||||
|
<span className="inline-flex items-center gap-1 text-[0.7rem] text-muted-foreground/80">
|
||||||
|
<Lock className="size-3" /> only you can open these
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2">{children}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* FieldTable — read view of a record's fields */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export function isBlankValue(v: unknown): boolean {
|
||||||
|
return v == null || (typeof v === "string" && v.trim() === "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Definition-list rendering of record fields in registry order; blank
|
||||||
|
* fields are skipped. For free-form data (no spec), keys are humanised
|
||||||
|
* and sorted. */
|
||||||
|
export function FieldTable({
|
||||||
|
fields,
|
||||||
|
data,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
fields: FieldSpec[];
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const rows: { label: string; value: string }[] =
|
||||||
|
fields.length > 0
|
||||||
|
? fields.flatMap((f) => {
|
||||||
|
const v = data[f.name];
|
||||||
|
return isBlankValue(v)
|
||||||
|
? []
|
||||||
|
: [{ label: f.label, value: formatValue(v) }];
|
||||||
|
})
|
||||||
|
: Object.keys(data)
|
||||||
|
.sort()
|
||||||
|
.flatMap((k) => {
|
||||||
|
const v = data[k];
|
||||||
|
return isBlankValue(v)
|
||||||
|
? []
|
||||||
|
: [{ label: humanize(k), value: formatValue(v) }];
|
||||||
|
});
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className={cn("text-sm text-muted-foreground", className)}>
|
||||||
|
No details recorded yet.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<dl
|
||||||
|
data-slot="field-table"
|
||||||
|
className={cn("divide-y divide-border", className)}
|
||||||
|
>
|
||||||
|
{rows.map((r) => (
|
||||||
|
<div key={r.label} className="flex items-baseline gap-4 py-2">
|
||||||
|
<dt className="w-2/5 shrink-0 text-xs text-muted-foreground">
|
||||||
|
{r.label}
|
||||||
|
</dt>
|
||||||
|
<dd className="num min-w-0 flex-1 text-sm break-words text-foreground">
|
||||||
|
{r.value}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatValue(v: unknown): string {
|
||||||
|
if (typeof v === "boolean") return v ? "yes" : "no";
|
||||||
|
return String(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
function humanize(s: string): string {
|
||||||
|
const t = s.replace(/[_-]+/g, " ").trim();
|
||||||
|
return t.charAt(0).toUpperCase() + t.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* FieldInputs — spec-driven add/edit form fields */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const inputBase =
|
||||||
|
"w-full rounded-lg border border-input bg-transparent px-3 py-2 text-sm text-foreground " +
|
||||||
|
"placeholder:text-muted-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none";
|
||||||
|
|
||||||
|
/** Renders inputs for a kind's field spec. Values are kept as strings
|
||||||
|
* (numbers coerced by the caller on save if desired); all fields are
|
||||||
|
* optional — partial records are normal. */
|
||||||
|
export function FieldInputs({
|
||||||
|
fields,
|
||||||
|
values,
|
||||||
|
onChange,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
fields: FieldSpec[];
|
||||||
|
values: Record<string, string>;
|
||||||
|
onChange: (name: string, value: string) => void;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-inputs"
|
||||||
|
className={cn("grid gap-3 sm:grid-cols-2", className)}
|
||||||
|
>
|
||||||
|
{fields.map((f) => (
|
||||||
|
<label
|
||||||
|
key={f.name}
|
||||||
|
className={cn(
|
||||||
|
"block space-y-1",
|
||||||
|
f.type === "longtext" && "sm:col-span-2",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
|
{f.label}
|
||||||
|
</span>
|
||||||
|
{f.type === "longtext" ? (
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
className={inputBase}
|
||||||
|
value={values[f.name] ?? ""}
|
||||||
|
onChange={(e) => onChange(f.name, e.target.value)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type={
|
||||||
|
f.type === "date"
|
||||||
|
? "date"
|
||||||
|
: f.type === "number"
|
||||||
|
? "number"
|
||||||
|
: "text"
|
||||||
|
}
|
||||||
|
className={inputBase}
|
||||||
|
value={values[f.name] ?? ""}
|
||||||
|
onChange={(e) => onChange(f.name, e.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Free-form key/value editor for kinds with no fixed spec (`other`). */
|
||||||
|
export function KeyValueEditor({
|
||||||
|
entries,
|
||||||
|
onChange,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
entries: { key: string; value: string }[];
|
||||||
|
onChange: (entries: { key: string; value: string }[]) => void;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const set = (i: number, patch: Partial<{ key: string; value: string }>) =>
|
||||||
|
onChange(entries.map((e, j) => (j === i ? { ...e, ...patch } : e)));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-slot="key-value-editor" className={cn("space-y-2", className)}>
|
||||||
|
{entries.map((e, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
placeholder="Field"
|
||||||
|
className={cn(inputBase, "w-2/5")}
|
||||||
|
value={e.key}
|
||||||
|
onChange={(ev) => set(i, { key: ev.target.value })}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
placeholder="Value"
|
||||||
|
className={cn(inputBase, "flex-1")}
|
||||||
|
value={e.value}
|
||||||
|
onChange={(ev) => set(i, { value: ev.target.value })}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Remove field"
|
||||||
|
onClick={() => onChange(entries.filter((_, j) => j !== i))}
|
||||||
|
className="grid size-8 shrink-0 place-items-center rounded-lg text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange([...entries, { key: "", value: "" }])}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1 text-xs font-medium text-primary hover:bg-primary/10"
|
||||||
|
>
|
||||||
|
<Plus className="size-3.5" /> Add field
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* RecordsEmpty — the marquee empty state */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export function RecordsEmpty({
|
||||||
|
onAdd,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
onAdd?: () => void;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="records-empty"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-center gap-3 px-6 py-14 text-center",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="grid size-14 place-items-center rounded-2xl bg-primary/10 text-primary">
|
||||||
|
<ShieldCheck className="size-7" />
|
||||||
|
</span>
|
||||||
|
<div className="max-w-md space-y-1.5">
|
||||||
|
<h3 className="text-base font-semibold text-foreground">
|
||||||
|
Keep your important records in one place
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||||
|
Passport, licence, Medicare, memberships — saved as real details, not
|
||||||
|
just photos. Identity and medical records are locked with your
|
||||||
|
passkey: not your apps, not your assistant, not even us can open them
|
||||||
|
without you.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{onAdd ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onAdd}
|
||||||
|
className="mt-1 inline-flex items-center gap-1.5 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
<Plus className="size-4" /> Add your first record
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* renderRecordMd — client mirror of the server's Markdown renderer */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/** Renders record fields as the Markdown form the assistant consumes —
|
||||||
|
* the same generic layout the server produces for open records, used
|
||||||
|
* client-side when presenting a decrypted private record. */
|
||||||
|
export function renderRecordMd(
|
||||||
|
kindLabel: string,
|
||||||
|
title: string,
|
||||||
|
fields: FieldSpec[],
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
): string {
|
||||||
|
const heading = `# ${title.trim() || kindLabel}\n\n*${kindLabel}*\n`;
|
||||||
|
|
||||||
|
const rows: [string, string][] =
|
||||||
|
fields.length > 0
|
||||||
|
? fields.flatMap((f) => {
|
||||||
|
const v = data[f.name];
|
||||||
|
return isBlankValue(v)
|
||||||
|
? []
|
||||||
|
: [[f.label, mdValue(v)] as [string, string]];
|
||||||
|
})
|
||||||
|
: Object.keys(data)
|
||||||
|
.sort()
|
||||||
|
.flatMap((k) => {
|
||||||
|
const v = data[k];
|
||||||
|
return isBlankValue(v)
|
||||||
|
? []
|
||||||
|
: [[humanize(k), mdValue(v)] as [string, string]];
|
||||||
|
});
|
||||||
|
|
||||||
|
if (rows.length === 0) return heading;
|
||||||
|
const table = [
|
||||||
|
"| Field | Value |",
|
||||||
|
"| --- | --- |",
|
||||||
|
...rows.map(([l, v]) => `| ${l} | ${v} |`),
|
||||||
|
];
|
||||||
|
return `${heading}\n${table.join("\n")}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mdValue(v: unknown): string {
|
||||||
|
if (typeof v === "boolean") return v ? "yes" : "no";
|
||||||
|
return String(v).replace(/\|/g, "\\|").replace(/\n/g, " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* parseMrzTd3 — passport machine-readable zone (2 × 44 chars) */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export interface MrzResult {
|
||||||
|
valid: boolean;
|
||||||
|
/** Per-field check-digit failures (empty when valid). */
|
||||||
|
errors: string[];
|
||||||
|
/** Extracted fields keyed by the passport kind's field names. */
|
||||||
|
fields: {
|
||||||
|
passport_number?: string;
|
||||||
|
full_name?: string;
|
||||||
|
nationality?: string;
|
||||||
|
date_of_birth?: string;
|
||||||
|
sex?: string;
|
||||||
|
expiry_date?: string;
|
||||||
|
issuing_authority?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const MRZ_VALUES: Record<string, number> = (() => {
|
||||||
|
const m: Record<string, number> = { "<": 0 };
|
||||||
|
for (let i = 0; i <= 9; i++) m[String(i)] = i;
|
||||||
|
for (let i = 0; i < 26; i++) m[String.fromCharCode(65 + i)] = 10 + i;
|
||||||
|
return m;
|
||||||
|
})();
|
||||||
|
|
||||||
|
function mrzCheckDigit(s: string): number {
|
||||||
|
const weights = [7, 3, 1];
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 0; i < s.length; i++) {
|
||||||
|
sum += (MRZ_VALUES[s[i]] ?? 0) * weights[i % 3];
|
||||||
|
}
|
||||||
|
return sum % 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mrzDate(yymmdd: string, kind: "dob" | "expiry"): string | undefined {
|
||||||
|
if (!/^\d{6}$/.test(yymmdd)) return undefined;
|
||||||
|
const yy = Number(yymmdd.slice(0, 2));
|
||||||
|
// Expiry dates are always in the future-ish window (passports run ≤10
|
||||||
|
// years); birth dates older than "this year" belong to the 1900s.
|
||||||
|
const nowYY = new Date().getFullYear() % 100;
|
||||||
|
const century = kind === "expiry" ? 2000 : yy > nowYY ? 1900 : 2000;
|
||||||
|
return `${century + yy}-${yymmdd.slice(2, 4)}-${yymmdd.slice(4, 6)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parses a TD3 (passport) MRZ — the two 44-character lines at the bottom
|
||||||
|
* of the photo page. Pure text parsing, fully local: nothing leaves the
|
||||||
|
* device. Verifies the per-field check digits; `valid` is true only when
|
||||||
|
* they all pass (fields are still returned for review when they don't). */
|
||||||
|
export function parseMrzTd3(input: string): MrzResult | null {
|
||||||
|
const lines = input
|
||||||
|
.toUpperCase()
|
||||||
|
.replace(/ /g, "")
|
||||||
|
.split(/[\r\n]+/)
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (lines.length < 2) return null;
|
||||||
|
// Tolerate slightly short lines (missed trailing fillers) by padding.
|
||||||
|
const l1 = lines[0].padEnd(44, "<").slice(0, 44);
|
||||||
|
const l2 = lines[1].padEnd(44, "<").slice(0, 44);
|
||||||
|
if (!l1.startsWith("P")) return null;
|
||||||
|
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
const issuer = l1.slice(2, 5).replace(/</g, "");
|
||||||
|
const nameField = l1.slice(5);
|
||||||
|
const [surnamePart, givenPart] = nameField.split("<<");
|
||||||
|
const surname = (surnamePart ?? "").replace(/</g, " ").trim();
|
||||||
|
const given = (givenPart ?? "").replace(/</g, " ").trim();
|
||||||
|
const fullName = [given, surname].filter(Boolean).join(" ");
|
||||||
|
|
||||||
|
const number = l2.slice(0, 9);
|
||||||
|
const numberCheck = l2[9];
|
||||||
|
const nationality = l2.slice(10, 13).replace(/</g, "");
|
||||||
|
const dob = l2.slice(13, 19);
|
||||||
|
const dobCheck = l2[19];
|
||||||
|
const sex = l2[20];
|
||||||
|
const expiry = l2.slice(21, 27);
|
||||||
|
const expiryCheck = l2[27];
|
||||||
|
|
||||||
|
if (String(mrzCheckDigit(number)) !== numberCheck)
|
||||||
|
errors.push("passport number");
|
||||||
|
if (String(mrzCheckDigit(dob)) !== dobCheck) errors.push("date of birth");
|
||||||
|
if (String(mrzCheckDigit(expiry)) !== expiryCheck) errors.push("expiry date");
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: errors.length === 0,
|
||||||
|
errors,
|
||||||
|
fields: {
|
||||||
|
passport_number: number.replace(/</g, "") || undefined,
|
||||||
|
full_name: fullName || undefined,
|
||||||
|
nationality: nationality || undefined,
|
||||||
|
date_of_birth: mrzDate(dob, "dob"),
|
||||||
|
sex: sex === "M" || sex === "F" ? sex : undefined,
|
||||||
|
expiry_date: mrzDate(expiry, "expiry"),
|
||||||
|
issuing_authority: issuer || undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user