Add NFC tag support: write tag from device page, scan page for lookup
Deploy to LXC / deploy (push) Successful in 24s
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:
@@ -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 };
|
||||||
|
}
|
||||||
@@ -37,6 +37,11 @@
|
|||||||
label: 'Locations',
|
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'
|
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',
|
href: '/todos',
|
||||||
label: 'Todos',
|
label: 'Todos',
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -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 ────────────────────────────────────────────
|
// ─── Checklist Templates ────────────────────────────────────────────
|
||||||
|
|
||||||
export const checklistTemplates = pgTable('checklist_templates', {
|
export const checklistTemplates = pgTable('checklist_templates', {
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
};
|
||||||
@@ -13,11 +13,13 @@ import {
|
|||||||
checklistItems,
|
checklistItems,
|
||||||
checklistTemplates,
|
checklistTemplates,
|
||||||
templateItems,
|
templateItems,
|
||||||
locations
|
locations,
|
||||||
|
deviceTags
|
||||||
} from '$lib/server/db/schema.js';
|
} from '$lib/server/db/schema.js';
|
||||||
import { eq, desc, sql } from 'drizzle-orm';
|
import { eq, desc, sql } from 'drizzle-orm';
|
||||||
import { error, fail, redirect } from '@sveltejs/kit';
|
import { error, fail, redirect } from '@sveltejs/kit';
|
||||||
import { saveImage, saveDocument, deleteFile } from '$lib/server/uploads.js';
|
import { saveImage, saveDocument, deleteFile } from '$lib/server/uploads.js';
|
||||||
|
import { env } from '$env/dynamic/private';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params }) => {
|
export const load: PageServerLoad = async ({ params }) => {
|
||||||
const [device] = await db
|
const [device] = await db
|
||||||
@@ -154,6 +156,17 @@ export const load: PageServerLoad = async ({ params }) => {
|
|||||||
.from(checklistTemplates)
|
.from(checklistTemplates)
|
||||||
.orderBy(checklistTemplates.title);
|
.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 {
|
return {
|
||||||
device,
|
device,
|
||||||
parentLocationName,
|
parentLocationName,
|
||||||
@@ -167,7 +180,9 @@ export const load: PageServerLoad = async ({ params }) => {
|
|||||||
...c,
|
...c,
|
||||||
items: itemsByChecklist[c.id] ?? []
|
items: itemsByChecklist[c.id] ?? []
|
||||||
})),
|
})),
|
||||||
templates
|
templates,
|
||||||
|
tagUrl,
|
||||||
|
lastTagWrittenAt: lastTagWrite?.writtenAt ?? null
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -283,6 +298,22 @@ export const actions: Actions = {
|
|||||||
redirect(303, '/devices');
|
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 }) => {
|
createChecklist: async ({ request, params }) => {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const title = (formData.get('title') as string)?.trim();
|
const title = (formData.get('title') as string)?.trim();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import ImageUpload from '$lib/components/ui/ImageUpload.svelte';
|
import ImageUpload from '$lib/components/ui/ImageUpload.svelte';
|
||||||
import DocumentUpload from '$lib/components/ui/DocumentUpload.svelte';
|
import DocumentUpload from '$lib/components/ui/DocumentUpload.svelte';
|
||||||
import ImageLightbox from '$lib/components/ui/ImageLightbox.svelte';
|
import ImageLightbox from '$lib/components/ui/ImageLightbox.svelte';
|
||||||
|
import NfcWriteButton from '$lib/components/ui/NfcWriteButton.svelte';
|
||||||
|
|
||||||
let lightboxSrc = $state<string | null>(null);
|
let lightboxSrc = $state<string | null>(null);
|
||||||
import { formatDate, timeAgo } from '$lib/utils/date.js';
|
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">
|
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
|
Label
|
||||||
</a>
|
</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')}
|
<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">
|
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
|
Print
|
||||||
@@ -649,6 +655,12 @@
|
|||||||
<dt class="text-gray-500 dark:text-gray-400">Added</dt>
|
<dt class="text-gray-500 dark:text-gray-400">Added</dt>
|
||||||
<dd class="text-gray-900 dark:text-white">{formatDate(data.device.createdAt)}</dd>
|
<dd class="text-gray-900 dark:text-white">{formatDate(data.device.createdAt)}</dd>
|
||||||
</div>
|
</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>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user