Earlier change made the badge the editable trigger and demoted it into the body's Status row. That separated status from the row identifier on mobile, which read as 'where is this status from?'. Move the EditableCell-with-StatusBadge back into the card header, right after the username, and drop the body Status row entirely. Mobile now matches desktop's information density: identifier + status badge inline, edit via badge click.
403 lines
14 KiB
TypeScript
403 lines
14 KiB
TypeScript
"use client";
|
||
|
||
import { useMemo, useOptimistic, useState, useTransition } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
import type { Acc } from "@/lib/types";
|
||
import { deleteAccount, updateAccount } from "@/app/actions";
|
||
import EditableCell from "./editable-cell";
|
||
import ConfirmDialog from "./confirm-dialog";
|
||
import CreateAccountDialog from "./create-account-dialog";
|
||
import Toast, { type ToastMessage } from "./toast";
|
||
|
||
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-700", label: "wait" },
|
||
done: { bg: "bg-emerald-100", fg: "text-emerald-700", label: "done" },
|
||
};
|
||
const v = map[status] ?? { bg: "bg-zinc-100", fg: "text-zinc-600", label: status || "—" };
|
||
return (
|
||
<span
|
||
className={`inline-flex items-center rounded-full ${v.bg} ${v.fg} px-2 py-0.5 text-[11px] font-medium`}
|
||
>
|
||
{v.label}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function DeleteButton({
|
||
label,
|
||
onClick,
|
||
}: {
|
||
label: string;
|
||
onClick: () => void;
|
||
}) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
aria-label={`Delete ${label}`}
|
||
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-zinc-400 transition-colors hover:bg-red-50 hover:text-red-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400"
|
||
>
|
||
<span aria-hidden="true" className="text-base leading-none">
|
||
×
|
||
</span>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
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 [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
||
const [deleting, setDeleting] = useState(false);
|
||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||
const [createOpen, setCreateOpen] = useState(false);
|
||
const [toast, setToast] = useState<ToastMessage | null>(null);
|
||
|
||
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) return resolve({ ok: false, error: "row not found" });
|
||
const next: Acc = { ...row, [field]: value };
|
||
const result = await updateAccount(next);
|
||
resolve(result.ok ? { ok: true } : { ok: false, error: result.error });
|
||
});
|
||
});
|
||
}
|
||
|
||
function refresh() {
|
||
setRefreshing(true);
|
||
startTransition(() => {
|
||
router.refresh();
|
||
setTimeout(() => setRefreshing(false), 400);
|
||
});
|
||
}
|
||
|
||
async function confirmDelete() {
|
||
if (!deleteTarget) return;
|
||
setDeleting(true);
|
||
setDeleteError(null);
|
||
const result = await deleteAccount(deleteTarget);
|
||
setDeleting(false);
|
||
if (result.ok) {
|
||
const deleted = deleteTarget;
|
||
setDeleteTarget(null);
|
||
setToast({ type: "success", message: `Account ${deleted} deleted` });
|
||
} else {
|
||
setDeleteError(result.error);
|
||
}
|
||
}
|
||
|
||
if (initial.length === 0) {
|
||
return (
|
||
<div>
|
||
<PageHead
|
||
count={0}
|
||
onRefresh={refresh}
|
||
refreshing={refreshing}
|
||
onAdd={() => setCreateOpen(true)}
|
||
/>
|
||
<div className="mt-6 rounded-2xl bg-white px-8 py-16 text-center ring-1 ring-zinc-200/60">
|
||
<p className="text-sm text-zinc-500">
|
||
No accounts yet. Click <span className="font-medium text-zinc-700">Add</span> above to create one manually, or wait for the monitor.
|
||
</p>
|
||
</div>
|
||
<CreateAccountDialog
|
||
open={createOpen}
|
||
onClose={() => setCreateOpen(false)}
|
||
prefixPattern={prefixPattern}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
<PageHead
|
||
count={optimistic.length}
|
||
onRefresh={refresh}
|
||
refreshing={refreshing}
|
||
onAdd={() => setCreateOpen(true)}
|
||
/>
|
||
|
||
{/* Desktop / tablet table */}
|
||
<div className="mt-6 hidden overflow-hidden rounded-2xl bg-white ring-1 ring-zinc-200/60 sm:block">
|
||
<table className="w-full table-fixed border-collapse">
|
||
<thead>
|
||
<tr className="bg-zinc-50/60">
|
||
<th className="w-[18%] px-5 py-3 text-left">
|
||
<button
|
||
type="button"
|
||
onClick={() => setSortDir((d) => (d === "asc" ? "desc" : "asc"))}
|
||
className="inline-flex items-center gap-1 text-[11px] font-medium uppercase tracking-wider text-zinc-500 transition-colors hover:text-zinc-900"
|
||
>
|
||
Username
|
||
<span aria-hidden="true">{sortDir === "asc" ? "↑" : "↓"}</span>
|
||
</button>
|
||
</th>
|
||
<Th>Password</Th>
|
||
<Th className="w-[16%]">Status</Th>
|
||
<Th>Link</Th>
|
||
<th className="w-12 px-3 py-3" aria-hidden="true" />
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-zinc-100">
|
||
{sorted.map((row) => {
|
||
const k = (f: string) => `${row.username}::${f}`;
|
||
return (
|
||
<tr key={row.username} className="transition-colors hover:bg-zinc-50/60">
|
||
<td className="px-5 py-3 align-middle font-mono text-[13px] font-semibold text-zinc-900">
|
||
{row.username}
|
||
</td>
|
||
<td className="px-5 py-3 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-5 py-3 align-middle">
|
||
<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)}
|
||
renderView={(v) => <StatusBadge status={v} />}
|
||
/>
|
||
</td>
|
||
<td className="px-5 py-3 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>
|
||
<td className="px-3 py-3 text-right align-middle">
|
||
<DeleteButton
|
||
label={row.username}
|
||
onClick={() => {
|
||
setDeleteError(null);
|
||
setDeleteTarget(row.username);
|
||
}}
|
||
/>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{/* Mobile cards */}
|
||
<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="rounded-2xl bg-white p-5 ring-1 ring-zinc-200/60">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div className="flex min-w-0 items-center gap-3">
|
||
<span className="shrink-0 font-mono text-base font-semibold text-zinc-900">
|
||
{row.username}
|
||
</span>
|
||
<div className="min-w-0 flex-1">
|
||
<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)}
|
||
renderView={(v) => <StatusBadge status={v} />}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<DeleteButton
|
||
label={row.username}
|
||
onClick={() => {
|
||
setDeleteError(null);
|
||
setDeleteTarget(row.username);
|
||
}}
|
||
/>
|
||
</div>
|
||
<dl className="mt-4 space-y-3 border-t border-zinc-100 pt-4">
|
||
<CardRow label="Password">
|
||
<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)}
|
||
/>
|
||
</CardRow>
|
||
<CardRow label="Link">
|
||
<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)}
|
||
/>
|
||
</CardRow>
|
||
</dl>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
<CreateAccountDialog
|
||
open={createOpen}
|
||
onClose={() => setCreateOpen(false)}
|
||
onSuccess={(name) =>
|
||
setToast({ type: "success", message: `Account ${name} created` })
|
||
}
|
||
prefixPattern={prefixPattern}
|
||
/>
|
||
|
||
<Toast toast={toast} onDismiss={() => setToast(null)} />
|
||
|
||
<ConfirmDialog
|
||
open={!!deleteTarget}
|
||
onCancel={() => {
|
||
if (!deleting) setDeleteTarget(null);
|
||
}}
|
||
onConfirm={confirmDelete}
|
||
title={`Delete ${deleteTarget ?? ""}?`}
|
||
message={
|
||
<>
|
||
<p>
|
||
Permanently remove{" "}
|
||
<code className="rounded bg-zinc-100 px-1.5 py-0.5 font-mono text-xs text-zinc-700">
|
||
{deleteTarget}
|
||
</code>{" "}
|
||
from the accounts table. This action cannot be undone.
|
||
</p>
|
||
{deleteError && (
|
||
<p className="mt-3 font-mono text-[11px] text-red-600" role="alert">
|
||
{deleteError}
|
||
</p>
|
||
)}
|
||
</>
|
||
}
|
||
confirmLabel="Delete"
|
||
destructive
|
||
pending={deleting}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Th({ children, className = "" }: { children: React.ReactNode; className?: string }) {
|
||
return (
|
||
<th
|
||
className={`px-5 py-3 text-left text-[11px] font-medium uppercase tracking-wider text-zinc-500 ${className}`}
|
||
>
|
||
{children}
|
||
</th>
|
||
);
|
||
}
|
||
|
||
function CardRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||
return (
|
||
<div className="grid grid-cols-[max-content_1fr] items-baseline gap-x-4">
|
||
<dt className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">
|
||
{label}
|
||
</dt>
|
||
<dd className="min-w-0">{children}</dd>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PageHead({
|
||
count,
|
||
onRefresh,
|
||
refreshing,
|
||
onAdd,
|
||
}: {
|
||
count: number;
|
||
onRefresh: () => void;
|
||
refreshing: boolean;
|
||
onAdd: () => void;
|
||
}) {
|
||
return (
|
||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||
<div>
|
||
<p className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">
|
||
Table
|
||
</p>
|
||
<h1 className="mt-1 text-2xl font-semibold tracking-tight text-zinc-900 sm:text-3xl">
|
||
Accounts
|
||
<span className="ml-2 align-middle text-base font-medium text-zinc-400">
|
||
{count}
|
||
</span>
|
||
</h1>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={onRefresh}
|
||
disabled={refreshing}
|
||
className="inline-flex items-center gap-2 rounded-full bg-white px-4 py-2 text-xs font-medium text-zinc-700 shadow-sm ring-1 ring-zinc-200 transition-colors hover:bg-zinc-50 hover:text-zinc-900 disabled:opacity-60"
|
||
>
|
||
<span aria-hidden="true" className={refreshing ? "inline-block animate-spin" : ""}>
|
||
⟳
|
||
</span>
|
||
Refresh
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={onAdd}
|
||
className="inline-flex items-center gap-1.5 rounded-full bg-zinc-900 px-4 py-2 text-xs font-medium text-white shadow-sm transition-colors hover:bg-zinc-700"
|
||
>
|
||
<span aria-hidden="true">+</span>
|
||
Add
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|