milestone 23 s2: react aria primitives and their cluster
This commit is contained in:
@@ -1,16 +1,16 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { clientDeleteMutation, clientPrefixesQuery, clientsQuery, groupsQuery } from "@/lib/queries";
|
||||
import { formatTime } from "@/lib/format";
|
||||
import type { Client } from "@/lib/types";
|
||||
import ClientEditDialog from "./ClientEditDialog";
|
||||
import PrefixesEditor from "./PrefixesEditor";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { smallButtonClass, tableWrapClass } from "@/ui/classes";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
||||
|
||||
const cellClass = "px-3 py-2";
|
||||
|
||||
/**
|
||||
* Deleting an observed row discards runtime state the file never declared, so
|
||||
* it stays live under file authority; deleting a hand-edited row contradicts
|
||||
@@ -18,6 +18,97 @@ const cellClass = "px-3 py-2";
|
||||
*/
|
||||
const DECLARED_CLIENT_NOTE = "This client is declared in the configuration file; remove it there and restart.";
|
||||
|
||||
const styles = stylex.create({
|
||||
heading: {
|
||||
fontSize: "1.5rem",
|
||||
lineHeight: "2rem",
|
||||
fontWeight: 600,
|
||||
},
|
||||
empty: {
|
||||
marginTop: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
table: {
|
||||
width: "100%",
|
||||
minWidth: "48rem",
|
||||
textAlign: "left",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
headRow: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomStyle: "solid",
|
||||
borderBottomColor: colors.border,
|
||||
color: colors.textMuted,
|
||||
},
|
||||
bodyRow: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomStyle: "solid",
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
cell: {
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.5rem",
|
||||
},
|
||||
mono: {
|
||||
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
||||
},
|
||||
right: {
|
||||
textAlign: "right",
|
||||
},
|
||||
dash: {
|
||||
color: colors.textMuted,
|
||||
},
|
||||
badge: {
|
||||
marginLeft: "0.5rem",
|
||||
borderRadius: "0.25rem",
|
||||
backgroundColor: colors.primary,
|
||||
paddingInline: "0.375rem",
|
||||
paddingBlock: "0.125rem",
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
fontWeight: 500,
|
||||
color: colors.primaryText,
|
||||
},
|
||||
confirmGroup: {
|
||||
display: "inline-flex",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
gap: "0.5rem",
|
||||
},
|
||||
actionGroup: {
|
||||
display: "inline-flex",
|
||||
gap: "0.5rem",
|
||||
},
|
||||
note: {
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
dangerText: {
|
||||
color: colors.danger,
|
||||
},
|
||||
dimWhenDisabled: {
|
||||
opacity: { default: 1, ":disabled": 0.5 },
|
||||
},
|
||||
/**
|
||||
* The accessible name of the actions column, kept out of the visual table
|
||||
* without leaving the accessibility tree.
|
||||
*/
|
||||
srOnly: {
|
||||
position: "absolute",
|
||||
width: 1,
|
||||
height: 1,
|
||||
padding: 0,
|
||||
margin: -1,
|
||||
overflow: "hidden",
|
||||
clipPath: "inset(50%)",
|
||||
whiteSpace: "nowrap",
|
||||
borderWidth: 0,
|
||||
},
|
||||
});
|
||||
|
||||
export default function ClientsPage() {
|
||||
const { data: clients } = useSuspenseQuery(clientsQuery());
|
||||
const { data: prefixes } = useSuspenseQuery(clientPrefixesQuery());
|
||||
@@ -30,46 +121,46 @@ export default function ClientsPage() {
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h1 className="text-2xl font-semibold">Clients</h1>
|
||||
<h1 {...stylex.props(styles.heading)}>Clients</h1>
|
||||
{clients.length === 0 ? (
|
||||
<p className="mt-4 text-zinc-500">
|
||||
<p {...stylex.props(styles.empty)}>
|
||||
No clients yet. Rows appear automatically as devices on the network make DNS queries — there is
|
||||
nothing to create by hand.
|
||||
</p>
|
||||
) : (
|
||||
<div className={tableWrapClass}>
|
||||
<table className="w-full min-w-[48rem] text-left text-sm">
|
||||
<div {...stylex.props(shared.tableWrap)}>
|
||||
<table {...stylex.props(styles.table)}>
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-200 text-zinc-500 dark:border-zinc-700">
|
||||
<th className={cellClass}>IP</th>
|
||||
<th className={cellClass}>Name</th>
|
||||
<th className={cellClass}>Group</th>
|
||||
<th className={cellClass}>First seen</th>
|
||||
<th className={cellClass}>Last seen</th>
|
||||
<th className={cellClass}>
|
||||
<span className="sr-only">Actions</span>
|
||||
<tr {...stylex.props(styles.headRow)}>
|
||||
<th {...stylex.props(styles.cell)}>IP</th>
|
||||
<th {...stylex.props(styles.cell)}>Name</th>
|
||||
<th {...stylex.props(styles.cell)}>Group</th>
|
||||
<th {...stylex.props(styles.cell)}>First seen</th>
|
||||
<th {...stylex.props(styles.cell)}>Last seen</th>
|
||||
<th {...stylex.props(styles.cell)}>
|
||||
<span {...stylex.props(styles.srOnly)}>Actions</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{clients.map((client) => (
|
||||
<tr key={client.id} className="border-b border-zinc-100 dark:border-zinc-800">
|
||||
<td className={`${cellClass} font-mono`}>{client.ip}</td>
|
||||
<td className={cellClass}>
|
||||
{client.name === "" ? <span className="text-zinc-400">—</span> : client.name}
|
||||
{client.hand_edited && (
|
||||
<span className="ml-2 rounded bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
edited
|
||||
</span>
|
||||
<tr key={client.id} {...stylex.props(styles.bodyRow)}>
|
||||
<td {...stylex.props(styles.cell, styles.mono)}>{client.ip}</td>
|
||||
<td {...stylex.props(styles.cell)}>
|
||||
{client.name === "" ? (
|
||||
<span {...stylex.props(styles.dash)}>—</span>
|
||||
) : (
|
||||
client.name
|
||||
)}
|
||||
{client.hand_edited && <span {...stylex.props(styles.badge)}>edited</span>}
|
||||
</td>
|
||||
<td className={cellClass}>{client.group}</td>
|
||||
<td className={cellClass}>{formatTime(client.first_seen)}</td>
|
||||
<td className={cellClass}>{formatTime(client.last_seen)}</td>
|
||||
<td className={`${cellClass} text-right`}>
|
||||
<td {...stylex.props(styles.cell)}>{client.group}</td>
|
||||
<td {...stylex.props(styles.cell)}>{formatTime(client.first_seen)}</td>
|
||||
<td {...stylex.props(styles.cell)}>{formatTime(client.last_seen)}</td>
|
||||
<td {...stylex.props(styles.cell, styles.right)}>
|
||||
{confirmingId === client.id ? (
|
||||
<span className="inline-flex flex-wrap items-center justify-end gap-2">
|
||||
<span className="text-xs text-zinc-500">
|
||||
<span {...stylex.props(styles.confirmGroup)}>
|
||||
<span {...stylex.props(styles.note)}>
|
||||
Deleted clients re-materialize on their next DNS query.
|
||||
</span>
|
||||
<button
|
||||
@@ -78,26 +169,34 @@ export default function ClientsPage() {
|
||||
setConfirmingId(null);
|
||||
deleteMutation.mutate(client.id);
|
||||
}}
|
||||
className={`${smallButtonClass} text-red-700 dark:text-red-400`}
|
||||
{...stylex.props(
|
||||
shared.smallButton,
|
||||
styles.dangerText,
|
||||
shared.focusRing,
|
||||
)}
|
||||
>
|
||||
Confirm delete
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmingId(null)}
|
||||
className={smallButtonClass}
|
||||
{...stylex.props(shared.smallButton, shared.focusRing)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex gap-2">
|
||||
<span {...stylex.props(styles.actionGroup)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(client)}
|
||||
disabled={readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
className={`${smallButtonClass} disabled:opacity-50`}
|
||||
{...stylex.props(
|
||||
shared.smallButton,
|
||||
styles.dimWhenDisabled,
|
||||
shared.focusRing,
|
||||
)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
@@ -110,7 +209,12 @@ export default function ClientsPage() {
|
||||
? DECLARED_CLIENT_NOTE
|
||||
: undefined
|
||||
}
|
||||
className={`${smallButtonClass} text-red-700 disabled:opacity-50 dark:text-red-400`}
|
||||
{...stylex.props(
|
||||
shared.smallButton,
|
||||
styles.dangerText,
|
||||
styles.dimWhenDisabled,
|
||||
shared.focusRing,
|
||||
)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user