cm_bot_v2/web/components/users-table.tsx

315 lines
12 KiB
TypeScript

"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>
);
}