feat(web): add data tables and editable-cell primitive (frontend-design)
This commit is contained in:
parent
0ebd35f964
commit
7b97e593e5
268
web/components/accounts-table.tsx
Normal file
268
web/components/accounts-table.tsx
Normal file
@ -0,0 +1,268 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
142
web/components/editable-cell.tsx
Normal file
142
web/components/editable-cell.tsx
Normal file
@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, useTransition } from "react";
|
||||
|
||||
type EditableCellProps = {
|
||||
value: string;
|
||||
onSave: (next: string) => Promise<{ ok: boolean; error?: string }>;
|
||||
label?: string;
|
||||
isCurrentlyEditing?: boolean;
|
||||
onEditStart?: () => void;
|
||||
onEditEnd?: () => void;
|
||||
};
|
||||
|
||||
export default function EditableCell({
|
||||
value,
|
||||
onSave,
|
||||
label,
|
||||
isCurrentlyEditing,
|
||||
onEditStart,
|
||||
onEditEnd,
|
||||
}: EditableCellProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(value);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Keep draft in sync if the underlying value changes from outside
|
||||
// (auto-refresh, server revalidation) while we are NOT actively editing.
|
||||
useEffect(() => {
|
||||
if (!editing) setDraft(value);
|
||||
}, [value, editing]);
|
||||
|
||||
// Auto-clear an error after 3 seconds.
|
||||
useEffect(() => {
|
||||
if (!error) return;
|
||||
const id = setTimeout(() => setError(null), 3000);
|
||||
return () => clearTimeout(id);
|
||||
}, [error]);
|
||||
|
||||
function begin() {
|
||||
setDraft(value);
|
||||
setEditing(true);
|
||||
onEditStart?.();
|
||||
requestAnimationFrame(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
});
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
setEditing(false);
|
||||
setDraft(value);
|
||||
setError(null);
|
||||
onEditEnd?.();
|
||||
}
|
||||
|
||||
function commit() {
|
||||
const next = draft;
|
||||
if (next === value) {
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
startTransition(async () => {
|
||||
const result = await onSave(next);
|
||||
if (result.ok) {
|
||||
setEditing(false);
|
||||
setError(null);
|
||||
onEditEnd?.();
|
||||
} else {
|
||||
setError(result.error ?? "Save failed");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!editing) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={begin}
|
||||
aria-label={label ? `Edit ${label}` : undefined}
|
||||
className="group relative -mx-1 -my-0.5 inline-flex w-full items-center rounded-sm px-1 py-0.5 text-left font-mono text-sm text-zinc-900 hover:bg-yellow-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-yellow-400"
|
||||
>
|
||||
<span className="truncate">
|
||||
{value || <em className="not-italic text-zinc-400">—</em>}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="ml-2 hidden text-[10px] font-semibold uppercase tracking-widest text-zinc-400 group-hover:inline"
|
||||
>
|
||||
edit
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative -mx-1 -my-0.5 flex flex-col">
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
commit();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
cancel();
|
||||
}
|
||||
}}
|
||||
disabled={isPending}
|
||||
className="w-full min-w-0 rounded-sm border-2 border-yellow-400 bg-white px-1 py-0.5 font-mono text-sm text-zinc-900 outline-none disabled:opacity-60"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={commit}
|
||||
disabled={isPending}
|
||||
aria-label="Save"
|
||||
className="shrink-0 border border-zinc-900 bg-zinc-900 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-widest text-white hover:bg-zinc-700 disabled:opacity-60"
|
||||
>
|
||||
{isPending ? "…" : "save"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancel}
|
||||
disabled={isPending}
|
||||
aria-label="Cancel"
|
||||
className="shrink-0 border border-zinc-300 bg-white px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-widest text-zinc-600 hover:border-zinc-500 hover:text-zinc-900 disabled:opacity-60"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="mt-1 font-mono text-[11px] text-red-700" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
314
web/components/users-table.tsx
Normal file
314
web/components/users-table.tsx
Normal file
@ -0,0 +1,314 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useOptimistic, useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { User } from "@/lib/types";
|
||||
import { updateUser } from "@/app/actions";
|
||||
import EditableCell from "./editable-cell";
|
||||
|
||||
type Props = { initial: User[]; prefixPattern: string };
|
||||
|
||||
type SortDir = "asc" | "desc";
|
||||
type SortKey = "f_username" | "last_update_time";
|
||||
|
||||
type OptimisticPatch = {
|
||||
f_username: string;
|
||||
field: keyof Pick<User, "f_password" | "t_username" | "t_password">;
|
||||
value: string;
|
||||
};
|
||||
|
||||
function timeOf(t: string | null) {
|
||||
if (!t) return 0;
|
||||
const ms = Date.parse(t);
|
||||
return Number.isNaN(ms) ? 0 : ms;
|
||||
}
|
||||
|
||||
function sortUsers(rows: User[], key: SortKey, dir: SortDir, prefix: string): User[] {
|
||||
return [...rows].sort((a, b) => {
|
||||
const ap = a.f_username.startsWith(prefix);
|
||||
const bp = b.f_username.startsWith(prefix);
|
||||
if (ap && !bp) return -1;
|
||||
if (!ap && bp) return 1;
|
||||
if (key === "f_username") {
|
||||
return dir === "asc"
|
||||
? a.f_username.localeCompare(b.f_username)
|
||||
: b.f_username.localeCompare(a.f_username);
|
||||
}
|
||||
return dir === "asc"
|
||||
? timeOf(a.last_update_time) - timeOf(b.last_update_time)
|
||||
: timeOf(b.last_update_time) - timeOf(a.last_update_time);
|
||||
});
|
||||
}
|
||||
|
||||
function formatTime(t: string | null) {
|
||||
if (!t) return <em className="not-italic text-zinc-400">—</em>;
|
||||
const d = new Date(t);
|
||||
if (Number.isNaN(d.getTime())) return t;
|
||||
return d.toLocaleString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export default function UsersTable({ initial, prefixPattern }: Props) {
|
||||
const router = useRouter();
|
||||
const [sortKey, setSortKey] = useState<SortKey>("last_update_time");
|
||||
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<User[], OptimisticPatch>(
|
||||
initial,
|
||||
(state, patch) =>
|
||||
state.map((row) =>
|
||||
row.f_username === patch.f_username ? { ...row, [patch.field]: patch.value } : row,
|
||||
),
|
||||
);
|
||||
|
||||
const sorted = useMemo(
|
||||
() => sortUsers(optimistic, sortKey, sortDir, prefixPattern),
|
||||
[optimistic, sortKey, sortDir, prefixPattern],
|
||||
);
|
||||
|
||||
function saveCell(
|
||||
f_username: string,
|
||||
field: OptimisticPatch["field"],
|
||||
value: string,
|
||||
) {
|
||||
return new Promise<{ ok: boolean; error?: string }>((resolve) => {
|
||||
startTransition(async () => {
|
||||
applyOptimistic({ f_username, field, value });
|
||||
const row = initial.find((r) => r.f_username === f_username);
|
||||
if (!row) {
|
||||
resolve({ ok: false, error: "row not found" });
|
||||
return;
|
||||
}
|
||||
const next = {
|
||||
f_username: row.f_username,
|
||||
f_password: row.f_password,
|
||||
t_username: row.t_username,
|
||||
t_password: row.t_password,
|
||||
[field]: value,
|
||||
};
|
||||
const result = await updateUser(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);
|
||||
});
|
||||
}
|
||||
|
||||
function toggleSort(k: SortKey) {
|
||||
if (sortKey === k) {
|
||||
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
||||
} else {
|
||||
setSortKey(k);
|
||||
setSortDir("desc");
|
||||
}
|
||||
}
|
||||
|
||||
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">
|
||||
Users
|
||||
</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 users yet.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderButton({ k, label }: { k: SortKey; label: string }) {
|
||||
const active = sortKey === k;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSort(k)}
|
||||
className={`inline-flex items-center gap-1 font-mono text-[10px] font-bold uppercase tracking-[0.2em] ${
|
||||
active ? "text-zinc-900" : "text-zinc-700"
|
||||
} hover:text-zinc-900`}
|
||||
>
|
||||
{label}
|
||||
<span aria-hidden="true">{active ? (sortDir === "asc" ? "↑" : "↓") : "↕"}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
Users
|
||||
</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-[18%] px-3 py-2 text-left">
|
||||
<HeaderButton k="f_username" label="from username" />
|
||||
</th>
|
||||
<th className="w-[18%] px-3 py-2 text-left font-mono text-[10px] font-bold uppercase tracking-[0.2em] text-zinc-700">
|
||||
from password
|
||||
</th>
|
||||
<th className="w-[18%] px-3 py-2 text-left font-mono text-[10px] font-bold uppercase tracking-[0.2em] text-zinc-700">
|
||||
to username
|
||||
</th>
|
||||
<th className="w-[18%] px-3 py-2 text-left font-mono text-[10px] font-bold uppercase tracking-[0.2em] text-zinc-700">
|
||||
to password
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left">
|
||||
<HeaderButton k="last_update_time" label="last update" />
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((row) => {
|
||||
const k = (f: string) => `${row.f_username}::${f}`;
|
||||
return (
|
||||
<tr
|
||||
key={row.f_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.f_username}
|
||||
</td>
|
||||
<td className="px-3 py-2 align-middle">
|
||||
<EditableCell
|
||||
value={row.f_password}
|
||||
label={`from password for ${row.f_username}`}
|
||||
isCurrentlyEditing={editingKey === k("f_password")}
|
||||
onEditStart={() => setEditingKey(k("f_password"))}
|
||||
onEditEnd={() => setEditingKey(null)}
|
||||
onSave={(v) => saveCell(row.f_username, "f_password", v)}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 align-middle">
|
||||
<EditableCell
|
||||
value={row.t_username}
|
||||
label={`to username for ${row.f_username}`}
|
||||
isCurrentlyEditing={editingKey === k("t_username")}
|
||||
onEditStart={() => setEditingKey(k("t_username"))}
|
||||
onEditEnd={() => setEditingKey(null)}
|
||||
onSave={(v) => saveCell(row.f_username, "t_username", v)}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 align-middle">
|
||||
<EditableCell
|
||||
value={row.t_password}
|
||||
label={`to password for ${row.f_username}`}
|
||||
isCurrentlyEditing={editingKey === k("t_password")}
|
||||
onEditStart={() => setEditingKey(k("t_password"))}
|
||||
onEditEnd={() => setEditingKey(null)}
|
||||
onSave={(v) => saveCell(row.f_username, "t_password", v)}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 align-middle font-mono text-xs text-zinc-600">
|
||||
{formatTime(row.last_update_time)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-3 sm:hidden">
|
||||
{sorted.map((row) => {
|
||||
const k = (f: string) => `${row.f_username}::${f}`;
|
||||
return (
|
||||
<div key={row.f_username} className="border-2 border-zinc-900 bg-white p-3">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="font-mono text-base font-bold text-zinc-900">
|
||||
{row.f_username}
|
||||
</span>
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.2em] text-zinc-500">
|
||||
{formatTime(row.last_update_time)}
|
||||
</span>
|
||||
</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">
|
||||
from pw
|
||||
</dt>
|
||||
<dd>
|
||||
<EditableCell
|
||||
value={row.f_password}
|
||||
label={`from password for ${row.f_username}`}
|
||||
isCurrentlyEditing={editingKey === k("f_password")}
|
||||
onEditStart={() => setEditingKey(k("f_password"))}
|
||||
onEditEnd={() => setEditingKey(null)}
|
||||
onSave={(v) => saveCell(row.f_username, "f_password", v)}
|
||||
/>
|
||||
</dd>
|
||||
<dt className="font-mono text-[10px] font-bold uppercase tracking-[0.2em] text-zinc-500">
|
||||
to user
|
||||
</dt>
|
||||
<dd>
|
||||
<EditableCell
|
||||
value={row.t_username}
|
||||
label={`to username for ${row.f_username}`}
|
||||
isCurrentlyEditing={editingKey === k("t_username")}
|
||||
onEditStart={() => setEditingKey(k("t_username"))}
|
||||
onEditEnd={() => setEditingKey(null)}
|
||||
onSave={(v) => saveCell(row.f_username, "t_username", v)}
|
||||
/>
|
||||
</dd>
|
||||
<dt className="font-mono text-[10px] font-bold uppercase tracking-[0.2em] text-zinc-500">
|
||||
to pw
|
||||
</dt>
|
||||
<dd>
|
||||
<EditableCell
|
||||
value={row.t_password}
|
||||
label={`to password for ${row.f_username}`}
|
||||
isCurrentlyEditing={editingKey === k("t_password")}
|
||||
onEditStart={() => setEditingKey(k("t_password"))}
|
||||
onEditEnd={() => setEditingKey(null)}
|
||||
onSave={(v) => saveCell(row.f_username, "t_password", v)}
|
||||
/>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user