269 lines
11 KiB
TypeScript
269 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import { useMemo, useOptimistic, useState, useTransition } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import type { Acc } from "@/lib/types";
|
|
import { updateAccount } from "@/app/actions";
|
|
import EditableCell from "./editable-cell";
|
|
|
|
type Props = { initial: Acc[]; prefixPattern: string };
|
|
|
|
type SortDir = "asc" | "desc";
|
|
type OptimisticPatch = { username: string; field: keyof Acc; value: string };
|
|
|
|
function sortAccounts(rows: Acc[], dir: SortDir, prefix: string): Acc[] {
|
|
return [...rows].sort((a, b) => {
|
|
const ap = a.username.startsWith(prefix);
|
|
const bp = b.username.startsWith(prefix);
|
|
if (ap && !bp) return -1;
|
|
if (!ap && bp) return 1;
|
|
return dir === "asc"
|
|
? a.username.localeCompare(b.username)
|
|
: b.username.localeCompare(a.username);
|
|
});
|
|
}
|
|
|
|
function StatusBadge({ status }: { status: string }) {
|
|
const map: Record<string, { bg: string; fg: string; label: string }> = {
|
|
"": { bg: "bg-zinc-100", fg: "text-zinc-600", label: "available" },
|
|
wait: { bg: "bg-amber-100", fg: "text-amber-800", label: "wait" },
|
|
done: { bg: "bg-emerald-100", fg: "text-emerald-800", label: "done" },
|
|
};
|
|
const v = map[status] ?? { bg: "bg-zinc-100", fg: "text-zinc-600", label: status || "—" };
|
|
return (
|
|
<span
|
|
className={`inline-flex items-center rounded-sm border border-current/10 ${v.bg} ${v.fg} px-1.5 py-0.5 font-mono text-[11px] font-semibold uppercase tracking-widest`}
|
|
>
|
|
{v.label}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
export default function AccountsTable({ initial, prefixPattern }: Props) {
|
|
const router = useRouter();
|
|
const [sortDir, setSortDir] = useState<SortDir>("desc");
|
|
const [editingKey, setEditingKey] = useState<string | null>(null);
|
|
const [, startTransition] = useTransition();
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
|
|
const [optimistic, applyOptimistic] = useOptimistic<Acc[], OptimisticPatch>(
|
|
initial,
|
|
(state, patch) =>
|
|
state.map((row) =>
|
|
row.username === patch.username ? { ...row, [patch.field]: patch.value } : row,
|
|
),
|
|
);
|
|
|
|
const sorted = useMemo(
|
|
() => sortAccounts(optimistic, sortDir, prefixPattern),
|
|
[optimistic, sortDir, prefixPattern],
|
|
);
|
|
|
|
function saveCell(username: string, field: keyof Acc, value: string) {
|
|
return new Promise<{ ok: boolean; error?: string }>((resolve) => {
|
|
startTransition(async () => {
|
|
applyOptimistic({ username, field, value });
|
|
const row = initial.find((r) => r.username === username);
|
|
if (!row) {
|
|
resolve({ ok: false, error: "row not found" });
|
|
return;
|
|
}
|
|
const next: Acc = { ...row, [field]: value };
|
|
const result = await updateAccount(next);
|
|
if (result.ok) resolve({ ok: true });
|
|
else resolve({ ok: false, error: result.error });
|
|
});
|
|
});
|
|
}
|
|
|
|
function refresh() {
|
|
setRefreshing(true);
|
|
startTransition(() => {
|
|
router.refresh();
|
|
setTimeout(() => setRefreshing(false), 400);
|
|
});
|
|
}
|
|
|
|
if (initial.length === 0) {
|
|
return (
|
|
<div className="mx-auto max-w-3xl px-4 py-16">
|
|
<h1 className="font-mono text-2xl font-bold uppercase tracking-tight text-zinc-900">
|
|
Accounts
|
|
</h1>
|
|
<div className="mt-8 border-2 border-dashed border-zinc-300 bg-white p-10 text-center">
|
|
<p className="font-mono text-sm text-zinc-500">
|
|
No accounts yet. The monitor will create some on the next run.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="mx-auto max-w-6xl px-4 py-8 sm:py-12">
|
|
<div className="flex flex-wrap items-end justify-between gap-4">
|
|
<div>
|
|
<p className="font-mono text-[11px] font-bold uppercase tracking-[0.25em] text-zinc-500">
|
|
// table
|
|
</p>
|
|
<h1 className="mt-1 font-mono text-2xl font-bold uppercase tracking-tight text-zinc-900 sm:text-3xl">
|
|
Accounts
|
|
</h1>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<div className="inline-flex items-baseline gap-2 border-2 border-zinc-900 bg-white px-3 py-1.5">
|
|
<span className="font-mono text-[10px] font-bold uppercase tracking-[0.2em] text-zinc-500">
|
|
count
|
|
</span>
|
|
<span className="font-mono text-lg font-bold text-zinc-900">
|
|
{optimistic.length}
|
|
</span>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={refresh}
|
|
disabled={refreshing}
|
|
className="inline-flex items-center gap-1.5 border-2 border-zinc-900 bg-yellow-300 px-3 py-1.5 font-mono text-[11px] font-bold uppercase tracking-[0.2em] text-zinc-900 hover:bg-zinc-900 hover:text-yellow-300 disabled:opacity-60"
|
|
>
|
|
<span aria-hidden="true" className={refreshing ? "inline-block animate-spin" : ""}>
|
|
↻
|
|
</span>
|
|
refresh
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-6 hidden border-2 border-zinc-900 bg-white sm:block">
|
|
<table className="w-full table-fixed border-collapse">
|
|
<thead>
|
|
<tr className="border-b-2 border-zinc-900 bg-zinc-50">
|
|
<th className="w-1/5 px-3 py-2 text-left">
|
|
<button
|
|
type="button"
|
|
onClick={() => setSortDir((d) => (d === "asc" ? "desc" : "asc"))}
|
|
className="inline-flex items-center gap-1 font-mono text-[10px] font-bold uppercase tracking-[0.2em] text-zinc-700 hover:text-zinc-900"
|
|
>
|
|
username
|
|
<span aria-hidden="true">{sortDir === "asc" ? "↑" : "↓"}</span>
|
|
</button>
|
|
</th>
|
|
<th className="w-1/5 px-3 py-2 text-left font-mono text-[10px] font-bold uppercase tracking-[0.2em] text-zinc-700">
|
|
password
|
|
</th>
|
|
<th className="w-[15%] px-3 py-2 text-left font-mono text-[10px] font-bold uppercase tracking-[0.2em] text-zinc-700">
|
|
status
|
|
</th>
|
|
<th className="px-3 py-2 text-left font-mono text-[10px] font-bold uppercase tracking-[0.2em] text-zinc-700">
|
|
link
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{sorted.map((row) => {
|
|
const k = (f: string) => `${row.username}::${f}`;
|
|
return (
|
|
<tr
|
|
key={row.username}
|
|
className="border-b border-zinc-200 last:border-b-0 hover:bg-zinc-50"
|
|
>
|
|
<td className="px-3 py-2 align-middle font-mono text-sm font-semibold text-zinc-900">
|
|
{row.username}
|
|
</td>
|
|
<td className="px-3 py-2 align-middle">
|
|
<EditableCell
|
|
value={row.password}
|
|
label={`password for ${row.username}`}
|
|
isCurrentlyEditing={editingKey === k("password")}
|
|
onEditStart={() => setEditingKey(k("password"))}
|
|
onEditEnd={() => setEditingKey(null)}
|
|
onSave={(v) => saveCell(row.username, "password", v)}
|
|
/>
|
|
</td>
|
|
<td className="px-3 py-2 align-middle">
|
|
<div className="flex items-center gap-2">
|
|
<StatusBadge status={row.status} />
|
|
<EditableCell
|
|
value={row.status}
|
|
label={`status for ${row.username}`}
|
|
isCurrentlyEditing={editingKey === k("status")}
|
|
onEditStart={() => setEditingKey(k("status"))}
|
|
onEditEnd={() => setEditingKey(null)}
|
|
onSave={(v) => saveCell(row.username, "status", v)}
|
|
/>
|
|
</div>
|
|
</td>
|
|
<td className="px-3 py-2 align-middle">
|
|
<EditableCell
|
|
value={row.link}
|
|
label={`link for ${row.username}`}
|
|
isCurrentlyEditing={editingKey === k("link")}
|
|
onEditStart={() => setEditingKey(k("link"))}
|
|
onEditEnd={() => setEditingKey(null)}
|
|
onSave={(v) => saveCell(row.username, "link", v)}
|
|
/>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div className="mt-6 space-y-3 sm:hidden">
|
|
{sorted.map((row) => {
|
|
const k = (f: string) => `${row.username}::${f}`;
|
|
return (
|
|
<div key={row.username} className="border-2 border-zinc-900 bg-white p-3">
|
|
<div className="flex items-baseline justify-between">
|
|
<span className="font-mono text-base font-bold text-zinc-900">{row.username}</span>
|
|
<StatusBadge status={row.status} />
|
|
</div>
|
|
<dl className="mt-3 grid grid-cols-[max-content_1fr] items-baseline gap-x-3 gap-y-2 border-t border-zinc-200 pt-3">
|
|
<dt className="font-mono text-[10px] font-bold uppercase tracking-[0.2em] text-zinc-500">
|
|
password
|
|
</dt>
|
|
<dd>
|
|
<EditableCell
|
|
value={row.password}
|
|
label={`password for ${row.username}`}
|
|
isCurrentlyEditing={editingKey === k("password")}
|
|
onEditStart={() => setEditingKey(k("password"))}
|
|
onEditEnd={() => setEditingKey(null)}
|
|
onSave={(v) => saveCell(row.username, "password", v)}
|
|
/>
|
|
</dd>
|
|
<dt className="font-mono text-[10px] font-bold uppercase tracking-[0.2em] text-zinc-500">
|
|
status
|
|
</dt>
|
|
<dd>
|
|
<EditableCell
|
|
value={row.status}
|
|
label={`status for ${row.username}`}
|
|
isCurrentlyEditing={editingKey === k("status")}
|
|
onEditStart={() => setEditingKey(k("status"))}
|
|
onEditEnd={() => setEditingKey(null)}
|
|
onSave={(v) => saveCell(row.username, "status", v)}
|
|
/>
|
|
</dd>
|
|
<dt className="font-mono text-[10px] font-bold uppercase tracking-[0.2em] text-zinc-500">
|
|
link
|
|
</dt>
|
|
<dd>
|
|
<EditableCell
|
|
value={row.link}
|
|
label={`link for ${row.username}`}
|
|
isCurrentlyEditing={editingKey === k("link")}
|
|
onEditStart={() => setEditingKey(k("link"))}
|
|
onEditEnd={() => setEditingKey(null)}
|
|
onSave={(v) => saveCell(row.username, "link", v)}
|
|
/>
|
|
</dd>
|
|
</dl>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|