Implement RFID tag dump PWA with REST API, auth, and CI

Add Next.js app with SQLite/Drizzle storage for sites and LF/HF tag
dumps, multi-user Auth.js (credentials + optional OIDC), personal API
tokens in Settings, versioned /api/v1 for UI and future CLI use,
MCT/Proxmark/JSON import-export, PWA offline shell, Vitest tests, and
GitHub Actions CI.
This commit is contained in:
Cursor Agent
2026-08-23 22:17:51 +00:00
parent 5029945cc2
commit 3331944773
65 changed files with 13689 additions and 1 deletions
+102
View File
@@ -0,0 +1,102 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { formatUid } from "@/lib/rfid/uid";
type Site = { id: string; name: string; code: string; notes: string | null };
type Tag = {
id: string;
label: string;
frequency: string;
protocol: string;
uid: string;
updatedAt: string;
};
export default function SitePage() {
const { id } = useParams<{ id: string }>();
const router = useRouter();
const [site, setSite] = useState<Site | null>(null);
const [tags, setTags] = useState<Tag[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function load() {
const [siteRes, tagsRes] = await Promise.all([
fetch(`/api/v1/sites/${id}`),
fetch(`/api/v1/tags?siteId=${id}`),
]);
if (siteRes.status === 401 || tagsRes.status === 401) {
router.push("/login");
return;
}
if (!siteRes.ok) {
setError("Site not found");
return;
}
setSite(await siteRes.json());
const tagData = await tagsRes.json();
setTags(tagData.tags);
}
load();
}, [id, router]);
async function deleteSite() {
if (!confirm("Delete this site and all its tags?")) return;
const res = await fetch(`/api/v1/sites/${id}`, { method: "DELETE" });
if (!res.ok) {
setError("Failed to delete site");
return;
}
router.push("/");
}
if (error) return <div className="error-banner">{error}</div>;
if (!site) return <p className="muted">Loading</p>;
return (
<div>
<div className="row" style={{ justifyContent: "space-between", marginBottom: "1rem" }}>
<div>
<div className="row" style={{ gap: "0.5rem", marginBottom: "0.35rem" }}>
<span className="badge badge-accent">{site.code}</span>
<h1 className="page-title" style={{ margin: 0 }}>
{site.name}
</h1>
</div>
{site.notes && <p className="page-sub">{site.notes}</p>}
</div>
<div className="row">
<Link className="btn" href={`/tags/new?siteId=${site.id}`}>
Add tag
</Link>
<button type="button" className="btn btn-danger" onClick={deleteSite}>
Delete
</button>
</div>
</div>
<div className="panel" style={{ padding: 0, overflow: "hidden" }}>
{tags.length === 0 ? (
<p className="muted" style={{ padding: "1.25rem" }}>
No tags at this site yet.
</p>
) : (
tags.map((tag) => (
<Link key={tag.id} href={`/tags/${tag.id}`} className="list-row">
<div>
<strong>{tag.label}</strong>
<div className="muted mono" style={{ fontSize: "0.85rem", marginTop: "0.2rem" }}>
{formatUid(tag.uid)} · {tag.protocol.replaceAll("_", " ")}
</div>
</div>
<span className="badge">{tag.frequency}</span>
</Link>
))
)}
</div>
</div>
);
}