Add NFC tag support: write tag from device page, scan page for lookup
Deploy to LXC / deploy (push) Successful in 24s

Tags carry a URL record (https://host/d/{uuid}) plus a text record with the
raw UUID, so iOS scan-to-open works natively and the UUID stays recoverable
if the domain ever changes. Short-link routes /d/{id} and /i/{id} keep tag
payloads compact and decouple them from canonical paths. Write flow detects
Web NFC support and degrades gracefully (Android Chrome only) with a clear
fallback message. Each successful write is logged in a new device_tags
table; the device page surfaces "NFC tag written X ago".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-27 10:15:22 +07:00
parent da27ae5541
commit ba32984a52
9 changed files with 502 additions and 2 deletions
+158
View File
@@ -0,0 +1,158 @@
// Web NFC helpers. Chromium-on-Android only.
// Spec: https://w3c.github.io/web-nfc/
export type NfcRecord =
| { recordType: 'url'; data: string }
| { recordType: 'text'; data: string; lang?: string };
export class NfcUnsupportedError extends Error {
constructor() {
super('Web NFC is not supported on this browser. Use Chrome on Android to write tags.');
this.name = 'NfcUnsupportedError';
}
}
export class NfcPermissionError extends Error {
constructor() {
super('NFC permission denied. Allow NFC access and try again.');
this.name = 'NfcPermissionError';
}
}
export class NfcWriteError extends Error {
constructor(message: string) {
super(message);
this.name = 'NfcWriteError';
}
}
export function isNfcSupported(): boolean {
return typeof window !== 'undefined' && 'NDEFReader' in window;
}
function mapDomError(err: unknown): Error {
if (!(err instanceof DOMException)) {
return err instanceof Error ? err : new Error(String(err));
}
switch (err.name) {
case 'NotAllowedError':
return new NfcPermissionError();
case 'NotSupportedError':
return new NfcUnsupportedError();
case 'NetworkError':
return new NfcWriteError('Lost contact with the tag. Hold it steady against the phone.');
case 'AbortError':
return new NfcWriteError('Cancelled.');
case 'InvalidStateError':
return new NfcWriteError('Tag is locked or read-only.');
default:
return new NfcWriteError(`${err.name}: ${err.message}`);
}
}
/**
* Write multiple NDEF records to the next tag presented.
* Must be called from inside a user-gesture handler (click).
*/
export async function writeRecords(
records: NfcRecord[],
signal?: AbortSignal
): Promise<void> {
if (!isNfcSupported()) throw new NfcUnsupportedError();
try {
// @ts-expect-error - NDEFReader is not in lib.dom yet
const reader = new NDEFReader();
await reader.write({ records }, signal ? { signal } : undefined);
} catch (err) {
throw mapDomError(err);
}
}
/**
* Scan for the next NDEF tag and resolve with its records + serial number.
* Must be called from inside a user-gesture handler (click).
*/
export async function scanOnce(
signal?: AbortSignal
): Promise<{ serialNumber: string; records: ReadonlyArray<{ recordType: string; data?: unknown; mediaType?: string; lang?: string }> }> {
if (!isNfcSupported()) throw new NfcUnsupportedError();
try {
// @ts-expect-error - NDEFReader is not in lib.dom yet
const reader = new NDEFReader();
await reader.scan(signal ? { signal } : undefined);
return await new Promise((resolve, reject) => {
const onReading = (event: any) => {
const records: Array<{ recordType: string; data?: unknown; mediaType?: string; lang?: string }> = [];
for (const r of event.message.records) {
records.push({
recordType: r.recordType,
mediaType: r.mediaType,
lang: r.lang,
data: decodeRecordData(r)
});
}
reader.removeEventListener('reading', onReading);
reader.removeEventListener('readingerror', onError);
resolve({ serialNumber: event.serialNumber ?? '', records });
};
const onError = () => {
reader.removeEventListener('reading', onReading);
reader.removeEventListener('readingerror', onError);
reject(new NfcWriteError('Could not read tag.'));
};
reader.addEventListener('reading', onReading);
reader.addEventListener('readingerror', onError);
signal?.addEventListener('abort', () => {
reader.removeEventListener('reading', onReading);
reader.removeEventListener('readingerror', onError);
reject(new NfcWriteError('Cancelled.'));
});
});
} catch (err) {
throw mapDomError(err);
}
}
function decodeRecordData(r: any): string | unknown {
try {
if (r.recordType === 'url' || r.recordType === 'text') {
return new TextDecoder().decode(r.data);
}
} catch {
/* fall through */
}
return r.data;
}
const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
/**
* Extract a UUID from any record data (URL with /d/{uuid} or /i/{uuid},
* a bare UUID text record, or the canonical /devices/{uuid} URL).
* Returns the UUID and a routing target if recognisable.
*/
export function resolveTag(records: ReadonlyArray<{ recordType: string; data?: unknown }>): {
uuid: string | null;
target: { kind: 'device' | 'instance' | 'unknown'; path: string } | null;
} {
let firstUuid: string | null = null;
let target: { kind: 'device' | 'instance' | 'unknown'; path: string } | null = null;
for (const r of records) {
const text = typeof r.data === 'string' ? r.data : '';
const m = text.match(UUID_RE);
if (!m) continue;
const uuid = m[0];
if (!firstUuid) firstUuid = uuid;
if (text.includes('/d/') || text.includes('/devices/')) {
target = { kind: 'device', path: `/devices/${uuid}` };
} else if (text.includes('/i/')) {
target = { kind: 'instance', path: `/i/${uuid}` };
} else if (!target) {
target = { kind: 'unknown', path: `/lookup?q=${uuid}` };
}
}
return { uuid: firstUuid, target };
}
+5
View File
@@ -37,6 +37,11 @@
label: 'Locations',
icon: 'M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z M15 11a3 3 0 11-6 0 3 3 0 016 0z'
},
{
href: '/scan',
label: 'Scan',
icon: 'M4 4h4v4H4V4zm12 0h4v4h-4V4zM4 16h4v4H4v-4zm12 4v-4h-4v4h4zm4-4h-4v4h4v-4zM10 4v6H4v2h6v6h2V12h6v-2h-6V4h-2z'
},
{
href: '/todos',
label: 'Todos',
+124
View File
@@ -0,0 +1,124 @@
<script lang="ts">
import {
isNfcSupported,
writeRecords,
NfcUnsupportedError,
NfcPermissionError
} from '$lib/client/nfc.js';
interface Props {
payloadUrl: string;
uuid: string;
recordEndpoint: string;
label?: string;
}
let { payloadUrl, uuid, recordEndpoint, label = 'Write NFC Tag' }: Props = $props();
let supported = $state(false);
let phase = $state<'idle' | 'unsupported' | 'writing' | 'success' | 'error'>('idle');
let errorMsg = $state('');
let showHelp = $state(false);
$effect(() => {
supported = isNfcSupported();
if (!supported) phase = 'unsupported';
});
async function handleClick() {
if (!supported) {
phase = 'unsupported';
return;
}
phase = 'writing';
errorMsg = '';
try {
await writeRecords([
{ recordType: 'url', data: payloadUrl },
{ recordType: 'text', data: uuid, lang: 'en' }
]);
phase = 'success';
void recordWrite();
} catch (err) {
phase = 'error';
if (err instanceof NfcUnsupportedError) {
errorMsg = err.message;
supported = false;
} else if (err instanceof NfcPermissionError) {
errorMsg = err.message;
} else {
errorMsg = err instanceof Error ? err.message : String(err);
}
}
}
async function recordWrite() {
try {
const fd = new FormData();
fd.set('payloadUrl', payloadUrl);
await fetch(recordEndpoint, { method: 'POST', body: fd });
} catch {
// Swallow — the tag is written either way; logging is best-effort.
}
}
function reset() {
phase = supported ? 'idle' : 'unsupported';
errorMsg = '';
}
</script>
{#if phase === 'unsupported'}
<button
type="button"
onclick={() => (showHelp = !showHelp)}
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-400 hover:bg-gray-100 dark:border-gray-600 dark:text-gray-500 dark:hover:bg-gray-700"
title="NFC not supported in this browser"
>
NFC ⚠
</button>
{#if showHelp}
<div
class="absolute z-20 mt-2 max-w-xs rounded-md border border-gray-200 bg-white p-3 text-xs text-gray-600 shadow-lg dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300"
>
NFC tag writing only works in Chrome on Android. Use the QR Label here for printing.
</div>
{/if}
{:else if phase === 'writing'}
<button
type="button"
disabled
class="rounded-md border border-blue-300 bg-blue-50 px-3 py-1.5 text-sm text-blue-700 dark:border-blue-700 dark:bg-blue-900/40 dark:text-blue-300"
>
Hold tag to phone…
</button>
{:else if phase === 'success'}
<button
type="button"
onclick={reset}
class="rounded-md border border-green-300 bg-green-50 px-3 py-1.5 text-sm text-green-700 hover:bg-green-100 dark:border-green-700 dark:bg-green-900/40 dark:text-green-300"
>
✓ Tag written
</button>
{:else if phase === 'error'}
<button
type="button"
onclick={reset}
class="rounded-md border border-red-300 bg-red-50 px-3 py-1.5 text-sm text-red-700 hover:bg-red-100 dark:border-red-700 dark:bg-red-900/40 dark:text-red-300"
title={errorMsg}
>
Failed — retry
</button>
{:else}
<button
type="button"
onclick={handleClick}
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-100 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700"
>
{label}
</button>
{/if}
{#if phase === 'error' && errorMsg}
<p class="mt-1 text-xs text-red-600 dark:text-red-400">{errorMsg}</p>
{/if}
+20
View File
@@ -236,6 +236,26 @@ export const installationLog = pgTable(
]
);
// ─── Device NFC Tags ────────────────────────────────────────────────
export const deviceTags = pgTable(
'device_tags',
{
id: uuid('id').defaultRandom().primaryKey(),
deviceId: uuid('device_id')
.notNull()
.references(() => devices.id, { onDelete: 'cascade' }),
tagUid: text('tag_uid'),
payloadUrl: text('payload_url').notNull(),
writtenBy: text('written_by'),
writtenAt: timestamp('written_at', { withTimezone: true }).defaultNow().notNull()
},
(table) => [
index('device_tags_device_idx').on(table.deviceId),
index('device_tags_written_at_idx').on(table.writtenAt)
]
);
// ─── Checklist Templates ────────────────────────────────────────────
export const checklistTemplates = pgTable('checklist_templates', {
+11
View File
@@ -0,0 +1,11 @@
import type { RequestHandler } from './$types';
import { redirect } from '@sveltejs/kit';
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export const GET: RequestHandler = ({ params }) => {
if (!UUID_RE.test(params.id)) {
redirect(303, `/lookup?q=${encodeURIComponent(params.id)}`);
}
redirect(303, `/devices/${params.id}`);
};
+33 -2
View File
@@ -13,11 +13,13 @@ import {
checklistItems,
checklistTemplates,
templateItems,
locations
locations,
deviceTags
} from '$lib/server/db/schema.js';
import { eq, desc, sql } from 'drizzle-orm';
import { error, fail, redirect } from '@sveltejs/kit';
import { saveImage, saveDocument, deleteFile } from '$lib/server/uploads.js';
import { env } from '$env/dynamic/private';
export const load: PageServerLoad = async ({ params }) => {
const [device] = await db
@@ -154,6 +156,17 @@ export const load: PageServerLoad = async ({ params }) => {
.from(checklistTemplates)
.orderBy(checklistTemplates.title);
// Last NFC tag write
const [lastTagWrite] = await db
.select({ writtenAt: deviceTags.writtenAt })
.from(deviceTags)
.where(eq(deviceTags.deviceId, params.id))
.orderBy(desc(deviceTags.writtenAt))
.limit(1);
const baseUrl = env.BASE_URL ?? 'http://localhost:5173';
const tagUrl = `${baseUrl}/d/${device.id}`;
return {
device,
parentLocationName,
@@ -167,7 +180,9 @@ export const load: PageServerLoad = async ({ params }) => {
...c,
items: itemsByChecklist[c.id] ?? []
})),
templates
templates,
tagUrl,
lastTagWrittenAt: lastTagWrite?.writtenAt ?? null
};
};
@@ -283,6 +298,22 @@ export const actions: Actions = {
redirect(303, '/devices');
},
recordTagWrite: async ({ request, params, locals }) => {
const formData = await request.formData();
const payloadUrl = (formData.get('payloadUrl') as string)?.trim();
const tagUid = (formData.get('tagUid') as string)?.trim();
if (!payloadUrl) return fail(400, { error: 'payloadUrl required' });
await db.insert(deviceTags).values({
deviceId: params.id,
payloadUrl,
tagUid: tagUid || null,
writtenBy: locals.user?.email ?? null
});
return { tagWritten: true };
},
createChecklist: async ({ request, params }) => {
const formData = await request.formData();
const title = (formData.get('title') as string)?.trim();
@@ -4,6 +4,7 @@
import ImageUpload from '$lib/components/ui/ImageUpload.svelte';
import DocumentUpload from '$lib/components/ui/DocumentUpload.svelte';
import ImageLightbox from '$lib/components/ui/ImageLightbox.svelte';
import NfcWriteButton from '$lib/components/ui/NfcWriteButton.svelte';
let lightboxSrc = $state<string | null>(null);
import { formatDate, timeAgo } from '$lib/utils/date.js';
@@ -89,6 +90,11 @@
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-100 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700">
Label
</a>
<NfcWriteButton
payloadUrl={data.tagUrl}
uuid={data.device.id}
recordEndpoint="?/recordTagWrite"
/>
<button onclick={() => window.open(`/print/device/${data.device.id}`, '_blank', 'width=600,height=400')}
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-100 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700">
Print
@@ -649,6 +655,12 @@
<dt class="text-gray-500 dark:text-gray-400">Added</dt>
<dd class="text-gray-900 dark:text-white">{formatDate(data.device.createdAt)}</dd>
</div>
{#if data.lastTagWrittenAt}
<div>
<dt class="text-gray-500 dark:text-gray-400">NFC tag</dt>
<dd class="text-gray-900 dark:text-white">written {timeAgo(data.lastTagWrittenAt)}</dd>
</div>
{/if}
</dl>
</div>
+6
View File
@@ -0,0 +1,6 @@
import type { RequestHandler } from './$types';
import { error } from '@sveltejs/kit';
export const GET: RequestHandler = () => {
error(404, 'Component instance pages are not yet available');
};
+133
View File
@@ -0,0 +1,133 @@
<script lang="ts">
import { goto } from '$app/navigation';
import {
isNfcSupported,
scanOnce,
resolveTag,
NfcUnsupportedError,
NfcPermissionError
} from '$lib/client/nfc.js';
let supported = $state(false);
let phase = $state<'idle' | 'scanning' | 'found' | 'error' | 'unsupported'>('idle');
let errorMsg = $state('');
let lastTagUid = $state('');
let lastForeignUrl = $state('');
let manualQuery = $state('');
$effect(() => {
supported = isNfcSupported();
if (!supported) phase = 'unsupported';
});
async function handleScan() {
if (!supported) return;
phase = 'scanning';
errorMsg = '';
lastTagUid = '';
lastForeignUrl = '';
try {
const { serialNumber, records } = await scanOnce();
lastTagUid = serialNumber;
const { uuid, target } = resolveTag(records);
if (target && (target.kind === 'device' || target.kind === 'instance')) {
phase = 'found';
goto(target.path);
return;
}
if (uuid) {
phase = 'found';
goto(`/lookup?q=${uuid}`);
return;
}
// Surface the first URL-ish record we couldn't route
const firstUrl = records.find((r) => r.recordType === 'url');
lastForeignUrl = typeof firstUrl?.data === 'string' ? firstUrl.data : '';
phase = 'error';
errorMsg = 'Tag does not contain a recognised device or component link.';
} catch (err) {
phase = 'error';
if (err instanceof NfcUnsupportedError) {
supported = false;
phase = 'unsupported';
return;
}
if (err instanceof NfcPermissionError) {
errorMsg = err.message;
return;
}
errorMsg = err instanceof Error ? err.message : String(err);
}
}
function handleManualSubmit(e: Event) {
e.preventDefault();
const q = manualQuery.trim();
if (!q) return;
goto(`/lookup?q=${encodeURIComponent(q)}`);
}
</script>
<svelte:head>
<title>Scan Tag - My Collection</title>
</svelte:head>
<div class="mx-auto max-w-xl">
<h1 class="mb-1 text-2xl font-bold text-gray-900 dark:text-white">Scan NFC Tag</h1>
<p class="mb-6 text-sm text-gray-500 dark:text-gray-400">
Hold a tag against your phone to look up the linked device.
</p>
{#if phase === 'unsupported'}
<div class="mb-6 rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-900/30 dark:text-amber-300">
<p class="font-medium">NFC scanning isn't available in this browser.</p>
<p class="mt-1">Open this page in <strong>Chrome on Android</strong>, or use the manual lookup below.</p>
</div>
{:else if phase === 'scanning'}
<div class="mb-6 rounded-lg border border-blue-200 bg-blue-50 p-6 text-center dark:border-blue-800 dark:bg-blue-900/30">
<p class="text-sm font-medium text-blue-700 dark:text-blue-300">Hold the tag against your phone…</p>
<p class="mt-2 text-xs text-blue-600 dark:text-blue-400">Tap cancel to stop.</p>
</div>
{:else if phase === 'error'}
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700 dark:border-red-800 dark:bg-red-900/30 dark:text-red-300">
<p>{errorMsg}</p>
{#if lastForeignUrl}
<p class="mt-2 break-all text-xs">Tag contained: <code>{lastForeignUrl}</code></p>
{/if}
{#if lastTagUid}
<p class="mt-1 font-mono text-xs text-red-500/70 dark:text-red-400/70">UID: {lastTagUid}</p>
{/if}
</div>
{/if}
{#if supported && phase !== 'scanning'}
<button
type="button"
onclick={handleScan}
class="w-full rounded-md bg-blue-600 px-4 py-3 text-sm font-medium text-white hover:bg-blue-700"
>
{phase === 'error' ? 'Try Again' : 'Tap to Scan'}
</button>
{/if}
<div class="mt-8 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<h2 class="mb-2 text-sm font-semibold text-gray-700 dark:text-gray-300">Manual lookup</h2>
<p class="mb-3 text-xs text-gray-500 dark:text-gray-400">
Paste a UUID or URL. Useful when NFC isn't available.
</p>
<form onsubmit={handleManualSubmit} class="flex gap-2">
<input
type="text"
bind:value={manualQuery}
placeholder="UUID or URL"
class="flex-1 rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
<button
type="submit"
class="rounded-md bg-gray-700 px-4 py-2 text-sm font-medium text-white hover:bg-gray-800 dark:bg-gray-600 dark:hover:bg-gray-500"
>
Go
</button>
</form>
</div>
</div>