diff --git a/src/lib/client/nfc.ts b/src/lib/client/nfc.ts new file mode 100644 index 0000000..4a7213d --- /dev/null +++ b/src/lib/client/nfc.ts @@ -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 { + 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 }; +} diff --git a/src/lib/components/layout/Sidebar.svelte b/src/lib/components/layout/Sidebar.svelte index 18f845f..3e37615 100644 --- a/src/lib/components/layout/Sidebar.svelte +++ b/src/lib/components/layout/Sidebar.svelte @@ -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', diff --git a/src/lib/components/ui/NfcWriteButton.svelte b/src/lib/components/ui/NfcWriteButton.svelte new file mode 100644 index 0000000..2cc9a11 --- /dev/null +++ b/src/lib/components/ui/NfcWriteButton.svelte @@ -0,0 +1,124 @@ + + +{#if phase === 'unsupported'} + + {#if showHelp} +
+ NFC tag writing only works in Chrome on Android. Use the QR Label here for printing. +
+ {/if} +{:else if phase === 'writing'} + +{:else if phase === 'success'} + +{:else if phase === 'error'} + +{:else} + +{/if} + +{#if phase === 'error' && errorMsg} +

{errorMsg}

+{/if} diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index ee7dff7..4606bc9 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -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', { diff --git a/src/routes/(app)/d/[id]/+server.ts b/src/routes/(app)/d/[id]/+server.ts new file mode 100644 index 0000000..44f05c2 --- /dev/null +++ b/src/routes/(app)/d/[id]/+server.ts @@ -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}`); +}; diff --git a/src/routes/(app)/devices/[id]/+page.server.ts b/src/routes/(app)/devices/[id]/+page.server.ts index 4160216..de2af0c 100644 --- a/src/routes/(app)/devices/[id]/+page.server.ts +++ b/src/routes/(app)/devices/[id]/+page.server.ts @@ -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(); diff --git a/src/routes/(app)/devices/[id]/+page.svelte b/src/routes/(app)/devices/[id]/+page.svelte index ce689a0..90a8dc7 100644 --- a/src/routes/(app)/devices/[id]/+page.svelte +++ b/src/routes/(app)/devices/[id]/+page.svelte @@ -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(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 + + {/if} + +
+

Manual lookup

+

+ Paste a UUID or URL. Useful when NFC isn't available. +

+
+ + +
+
+