Add ui/DataTable, which builds ReUI's DataGrid from `columns` and `data`, and move all 23 tables onto it. What the grid does not take (a class on a row or cell, `aria-sort`, `style`, `colSpan`, `role`, `hidden`) is put on the elements it drew after every render. The column definitions keep their identity between renders, so cells are not mounted again, and the grid's own paging is off. The stacked rows on a phone, the column labels and the sort order of the original work as before. In Blocking > Lists a group heading is a row whose first cell spans the four columns. docs/reui-migration-plan.md records it. Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
884 lines
36 KiB
TypeScript
884 lines
36 KiB
TypeScript
import { useCallback, useEffect, useId, useRef, useState, type FormEvent, type ReactNode } from 'react'
|
||
import { readSettings, setSettings, forceUpdateBlockLists } from '../../api/settings'
|
||
import { getDashboardStats } from '../../api/dashboard'
|
||
import { loadQuickList, type QuickEntry } from '../../lib/quick-lists'
|
||
import { nextUpdateText } from '../settings/panes/Blocking'
|
||
import { SectionHeader } from '../../ui/SectionHeader'
|
||
import { Panel, Body } from '../../ui/Panel'
|
||
import { DataTable } from '../../ui/DataTable'
|
||
import { RowAction } from '../../ui/Table'
|
||
import { Field, Input, Select } from '../../ui/Field'
|
||
import { Button } from '../../ui/Button'
|
||
import { PermissionButton } from '../../ui/PermissionButton'
|
||
import { Confirm } from '../../ui/Confirm'
|
||
import { Notifier } from '../../ui/Notifier'
|
||
import { Failure, Loading } from '../../ui/Empty'
|
||
import { RouteLink } from '../../ui/RouteLink'
|
||
import { Icon } from '../../ui/Icon'
|
||
import { noticeFromFailure, type Notice } from '../../lib/notice'
|
||
import {
|
||
addHeading, addListTo, applyQuickTo, canMove, canToggle, changesOf, changesText, fromUrls, headingIndexes, headingName,
|
||
headingProblem, listName, moveLine, removeHeading, renameHeading, saveBody, toggleLine,
|
||
type ListLine,
|
||
} from './list-lines'
|
||
import { Locked } from './Locked'
|
||
import { hostsOf, moreLists } from './extra-lists'
|
||
import { missing, type Permissions } from './permissions'
|
||
import shared from './Blocking.module.css'
|
||
import styles from './BlockLists.module.css'
|
||
import { UrlText } from '../../ui/UrlText'
|
||
|
||
/*
|
||
The Lists tab: the "Allow / Block List URLs" field of Settings > Blocking drawn as a
|
||
table. OURS as a table; every line is the server's own grammar (`list-lines.ts`).
|
||
|
||
Upstream's words here: `Quick Add` and its `None`, `Update Now`, the next-update
|
||
value (`nextUpdateText`, shared with Settings), the update confirmation sentence and
|
||
every alert of the update and the save. Everything else is OURS:
|
||
- the figures `Block List Domains` and `Allow List Domains`, and `Next update ·
|
||
every N h` (upstream's label is "Block List Next Update On");
|
||
- the panel titles `Lists`, `Block Lists`, `Add a list`, and `Update Block Lists`
|
||
with its `Update` button (upstream asks with a bare `confirm()`);
|
||
- the field label `List URL` (upstream's is "Allow / Block List URLs"), its
|
||
placeholder, the `file://` help line, the invalid-URL hint and `This list is
|
||
already in the table.`; `Add block list` / `Add allow list`;
|
||
- the table: the columns `Enabled`, `List` and `Type`, the types `Block` and `Allow`,
|
||
`Remove`, `Move up` / `Move down`, `No lists`, and the row controls' aria-labels;
|
||
- the groups (2026-10-15): a comment line drawn as a group heading (`list-lines.ts`,
|
||
GROUPS) with its `N list(s)`, `No group` for the lists before the first heading,
|
||
`Unnamed group` for a bare `#`, `Rename`, `Ungroup`, `Add group`, `Group name`,
|
||
`Cancel`, the field `Add to group`, and `headingProblem`'s sentences;
|
||
- the table's foot: `N list(s) · N disabled · N group(s)`;
|
||
- the subtitle under the title (`LISTS_SUBTITLE`);
|
||
- the unsaved-changes bar (2026-10-15): `Unsaved changes to the block list URLs:`
|
||
and what changed, in `changesText`'s words (`N added`, `N removed`, `N switched on`,
|
||
`N switched off`, `N changed`, `N moved`, `N group(s) added`, `renamed`,
|
||
`removed`; `list-lines.ts`, WHAT CHANGED), `Discard`, and `Save`, shortened from
|
||
upstream's "Save Settings" (index.html:2460); and the Lists tab's mark while there
|
||
are unsaved changes, read as `unsaved changes` (`onDirty`, `Blocking.tsx`);
|
||
- the two `Could not read …` sentences and the link to Settings › Blocking;
|
||
- Quick Add's search (`Search lists`, `No lists match`) and its second catalogue, the
|
||
console's own (`extra-lists.ts`), under `More lists` after Technitium's.
|
||
|
||
Quick Add here lists Technitium's catalogue first, as Settings does, then ours. An
|
||
entry of ours is chosen exactly as one of theirs (`applyQuick`), and its single-URL
|
||
entries name their rows too. Its search matches the name and the URL's host.
|
||
|
||
No node selector: `blockListUrls` is a CLUSTER-WIDE parameter (`nodeScope`,
|
||
settings/model.ts:514), so it is read and saved on the cluster when there is one and
|
||
on this server when there is not, which is what Settings does with the aggregate.
|
||
|
||
Groups and moves are kept with everything else: a heading is a line like any other,
|
||
so adding, renaming or removing one, and moving a list, only changes `lines`.
|
||
|
||
Changes are KEPT until Save, as upstream's Save Settings keeps the textarea:
|
||
`Quick Add > None` empties every list, and saving on each click would make that one
|
||
click unrecoverable. One save also means one request, so quick changes cannot land
|
||
out of order.
|
||
*/
|
||
|
||
/* The value of an entry of ours in Quick Add: by position, so a name shared with an
|
||
entry of Technitium's still picks the right one. */
|
||
const MORE = 'more:'
|
||
|
||
const KIND_LABEL = { block: 'Block', allow: 'Allow' } as const
|
||
const KIND_CLASS = { block: styles.block, allow: styles.allow } as const
|
||
/* An icon and a word, not only a colour, as the Rule column of Rules: the same two
|
||
icons, so a block list and a blocked rule read as the same kind. */
|
||
const KIND_ICON = { block: 'blocked', allow: 'allowed' } as const
|
||
|
||
/*
|
||
The add field takes the bare URL; the buttons choose block or allow. Both sentences
|
||
are OURS. `addList` only refuses an identical line, so the table checks the rest
|
||
here: a `#` or `!` typed by hand would make the field say one thing and the button
|
||
another, and the same URL switched off or on the other list would be a second row
|
||
the server loads twice.
|
||
*/
|
||
const MSG_PREFIX = 'Enter the URL without # or !; use the buttons to choose block or allow.'
|
||
const MSG_DUPLICATE = 'This list is already in the table.'
|
||
/* OURS: `dashboard/stats/get` failed, which is not the same as zero domains. */
|
||
const COUNTS_FAILED = 'Could not read the counts.'
|
||
|
||
/*
|
||
After a Save or an Update Now the server reloads the lists in the background, and
|
||
the counts change when it has finished, not at once. Until then both figures say "Updating…" (OURS).
|
||
|
||
How the end is told, from the server (v15.5.1): `blockListNextUpdatedOn` is the last
|
||
SUCCESSFUL update plus the interval (WebServiceSettingsApi.cs:384), and the last update
|
||
only moves when a download ends well (BlockListZoneManager.cs:674-692). While it
|
||
reloads, then, the server keeps answering the OLD date (a future one, so "Updating
|
||
Now" is no signal). The value is captured when the action is taken (Save: from its
|
||
own answer; Update Now: read just before the call) and the settings are read every
|
||
POLL_MS until it differs; then the counts are read once more. POLL_LIMIT_MS bounds the
|
||
wait (a download that fails never moves the date), and at the limit the counts are read
|
||
anyway. A second Save or Update Now while waiting captures again and starts the limit
|
||
again.
|
||
*/
|
||
const UPDATING = 'Updating…'
|
||
const POLL_MS = 3000
|
||
const POLL_LIMIT_MS = 120_000
|
||
|
||
/** A reload being waited for: the date captured when it was asked for. Each one is a
|
||
new object, so a second action restarts the wait even with the same date. */
|
||
type Reload = { from: string | null }
|
||
|
||
/*
|
||
Whether a Save makes the server reload. Only when the set of lines changed, by its
|
||
`HasSameItems` (TechnitiumLibrary CollectionExtensions.cs: same count, every item of
|
||
the one in the other, order aside; BlockListZoneManager.cs:512-523), and there is
|
||
something to reload with a timer to do it: with no lines the server flushes the zones
|
||
there and then (`Flush()`, :527), and with the interval at 0 it reloads nothing.
|
||
*/
|
||
function sameItems(a: readonly string[], b: readonly string[]): boolean {
|
||
return a.length === b.length && a.every((x) => b.includes(x))
|
||
}
|
||
|
||
/** What the tab is for, under its title. OURS (CONVENTIONS.md, deviation 2). */
|
||
export const LISTS_SUBTITLE = 'The block and allow lists the server downloads. Changes to them wait until you save.'
|
||
|
||
/** A heading being written: an existing group by its ordinal, or a new one. */
|
||
type Editing = { group: number | 'new'; value: string; problem: string | null }
|
||
|
||
const UNNAMED = 'Unnamed group'
|
||
const nameOf = (line: ListLine) => headingName(line) || UNNAMED
|
||
const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`
|
||
|
||
/*
|
||
The heading editor: one field, its verb and `Cancel`. Enter takes it and Escape
|
||
leaves it, as a field in a row should; the sentence that refuses a name goes under
|
||
the field, once (the design rule).
|
||
*/
|
||
function HeadingEditor({
|
||
editing,
|
||
verb,
|
||
onChange,
|
||
onSubmit,
|
||
onCancel,
|
||
}: {
|
||
editing: Editing
|
||
verb: string
|
||
onChange: (value: string) => void
|
||
onSubmit: () => void
|
||
onCancel: () => void
|
||
}) {
|
||
const problemId = useId()
|
||
return (
|
||
<form
|
||
className={styles.editor}
|
||
onSubmit={(e: FormEvent) => {
|
||
e.preventDefault()
|
||
onSubmit()
|
||
}}
|
||
>
|
||
<Input
|
||
autoFocus
|
||
aria-label="Group name"
|
||
className={`${styles.bad} ${styles.editorField}`}
|
||
value={editing.value}
|
||
aria-invalid={editing.problem != null || undefined}
|
||
aria-describedby={editing.problem != null ? problemId : undefined}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Escape') {
|
||
e.preventDefault()
|
||
onCancel()
|
||
}
|
||
}}
|
||
/>
|
||
<Button size="sm" variant="primary" type="submit">{verb}</Button>
|
||
<Button size="sm" type="button" onClick={onCancel}>Cancel</Button>
|
||
{editing.problem != null && (
|
||
<p id={problemId} className={styles.invalid}>{editing.problem}</p>
|
||
)}
|
||
</form>
|
||
)
|
||
}
|
||
|
||
export function BlockLists({
|
||
tabs,
|
||
token,
|
||
permissions,
|
||
clusterInitialised = false,
|
||
onDirty,
|
||
}: {
|
||
tabs?: ReactNode
|
||
token: string | null
|
||
permissions: Permissions
|
||
clusterInitialised?: boolean
|
||
/** Told whether there are unsaved changes, for the tab's mark; false on leaving. */
|
||
onDirty?: (dirty: boolean) => void
|
||
}) {
|
||
const node = clusterInitialised ? 'cluster' : ''
|
||
const viewNeed = missing(permissions, 'Settings.canView')
|
||
const modifyNeed = missing(permissions, 'Settings.canModify')
|
||
const statsNeed = missing(permissions, 'Dashboard.canView')
|
||
const invalidId = useId()
|
||
|
||
const [saved, setSaved] = useState<ListLine[] | null>(null)
|
||
const [readFailed, setReadFailed] = useState(false)
|
||
const [lines, setLines] = useState<ListLine[]>([])
|
||
const [next, setNext] = useState<string | null>(null)
|
||
const [interval, setIntervalHours] = useState<number | null>(null)
|
||
const [hasSaved, setHasSaved] = useState(false)
|
||
const [counts, setCounts] = useState<{ block: number; allow: number } | null>(null)
|
||
const [countsFailed, setCountsFailed] = useState(false)
|
||
const [catalog, setCatalog] = useState<QuickEntry[]>([])
|
||
/** The console's own entries, once Technitium's are known to leave out repeats. */
|
||
const [more, setMore] = useState<QuickEntry[]>([])
|
||
const [field, setField] = useState('')
|
||
const [invalid, setInvalid] = useState<string | null>(null)
|
||
const [notice, setNotice] = useState<Notice | null>(null)
|
||
const [busy, setBusy] = useState(false)
|
||
const [askUpdate, setAskUpdate] = useState(false)
|
||
/** The group "Add a list" and Quick Add put lists into: a heading's ordinal, -1 for none. */
|
||
const [group, setGroup] = useState(-1)
|
||
const [editing, setEditing] = useState<Editing | null>(null)
|
||
/* Closing the editor removes the field that held the focus: it goes back to the
|
||
button that opened it, found by its name once the table is drawn again. */
|
||
const table = useRef<HTMLDivElement>(null)
|
||
const focusAfter = useRef<string | null>(null)
|
||
useEffect(() => {
|
||
const name = focusAfter.current
|
||
if (name == null || editing != null) return
|
||
focusAfter.current = null
|
||
const target = [...(table.current?.querySelectorAll('button') ?? [])].find(
|
||
(b) => (b.getAttribute('aria-label') ?? b.textContent) === name,
|
||
)
|
||
target?.focus()
|
||
}, [editing, lines])
|
||
/* What would be saved, against what the server answered. */
|
||
const changes = saved == null ? '' : changesText(changesOf(saved, lines))
|
||
const dirty = changes !== ''
|
||
useEffect(() => {
|
||
onDirty?.(dirty)
|
||
}, [dirty, onDirty])
|
||
useEffect(() => () => onDirty?.(false), [onDirty])
|
||
|
||
/** The server is reloading the lists: the counts on screen are not current yet. */
|
||
const [reloading, setReloading] = useState<Reload | null>(null)
|
||
/** The lines as the server last answered them, to tell whether a Save reloads. */
|
||
const serverUrls = useRef<string[]>([])
|
||
/** `blockListNextUpdatedOn` as the server last answered it. */
|
||
const serverNext = useRef<string | null>(null)
|
||
const mounted = useRef(true)
|
||
useEffect(() => {
|
||
mounted.current = true
|
||
return () => {
|
||
mounted.current = false
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
if (viewNeed != null) return
|
||
let live = true
|
||
void readSettings(token, node).then((r) => {
|
||
if (!live) return
|
||
/* As every other tab: with nothing on screen, the notice carries the server's
|
||
message, and the table slot says the read failed. */
|
||
if (r.kind !== 'ok') {
|
||
setReadFailed(true)
|
||
setNotice(noticeFromFailure(r))
|
||
return
|
||
}
|
||
const s = r.data
|
||
const read = fromUrls(s.blockListUrls)
|
||
setSaved(read)
|
||
setLines(read)
|
||
// `settings/get` OMITS this key when it is null (CONVENTIONS.md): absent is "Not Scheduled".
|
||
setNext(s.blockListNextUpdatedOn ?? null)
|
||
serverNext.current = s.blockListNextUpdatedOn ?? null
|
||
serverUrls.current = s.blockListUrls ?? []
|
||
setIntervalHours(s.blockListUpdateIntervalHours)
|
||
setHasSaved(s.blockListUrls != null)
|
||
})
|
||
return () => {
|
||
live = false
|
||
}
|
||
}, [token, node, viewNeed])
|
||
|
||
useEffect(() => {
|
||
let live = true
|
||
void loadQuickList('quick-block-lists').then((e) => {
|
||
if (!live) return
|
||
setCatalog(e)
|
||
setMore(moreLists(e))
|
||
})
|
||
return () => {
|
||
live = false
|
||
}
|
||
}, [])
|
||
|
||
const readCounts = useCallback(async () => {
|
||
if (statsNeed != null) return
|
||
const r = await getDashboardStats(token, 'LastHour', undefined, node)
|
||
if (!mounted.current) return
|
||
if (r.kind !== 'ok') {
|
||
setCountsFailed(true)
|
||
return
|
||
}
|
||
setCountsFailed(false)
|
||
setCounts({ block: r.data.stats.blockListZones, allow: r.data.stats.allowListZones })
|
||
}, [token, node, statsNeed])
|
||
|
||
useEffect(() => {
|
||
void readCounts()
|
||
}, [readCounts])
|
||
|
||
/* The wait described at POLL_MS: armed by a Save or an Update Now. Until it ends the
|
||
next-update line keeps what the action left there ("Updating Now" after Update
|
||
Now, main.js:2352-2356). */
|
||
useEffect(() => {
|
||
if (reloading == null) return
|
||
let live = true
|
||
let timer: ReturnType<typeof setTimeout> | undefined
|
||
const started = Date.now()
|
||
const tick = async () => {
|
||
const r = await readSettings(token, node)
|
||
if (!live) return
|
||
const now = r.kind !== 'ok' ? undefined : (r.data.blockListNextUpdatedOn ?? null)
|
||
const finished = now !== undefined && now !== reloading.from
|
||
if (!finished && Date.now() - started < POLL_LIMIT_MS) {
|
||
timer = setTimeout(() => void tick(), POLL_MS)
|
||
return
|
||
}
|
||
if (now !== undefined) {
|
||
setNext(now)
|
||
serverNext.current = now
|
||
}
|
||
await readCounts()
|
||
if (live) setReloading(null)
|
||
}
|
||
timer = setTimeout(() => void tick(), POLL_MS)
|
||
return () => {
|
||
live = false
|
||
clearTimeout(timer)
|
||
}
|
||
}, [reloading, token, node, readCounts])
|
||
|
||
if (viewNeed != null) {
|
||
return (
|
||
<>
|
||
<SectionHeader section="Blocking" title="Lists" subtitle={LISTS_SUBTITLE} tabs={tabs} />
|
||
<Locked title="Block Lists" need={viewNeed} />
|
||
</>
|
||
)
|
||
}
|
||
|
||
const off = modifyNeed != null
|
||
const loading = saved == null
|
||
const heads = headingIndexes(lines)
|
||
/* What the chosen group still means: a Discard or a Quick Add that emptied the
|
||
headings leaves an ordinal that no longer exists, and that is no group. */
|
||
const target = group < heads.length ? group : -1
|
||
const edit = editing != null && (editing.group === 'new' || editing.group < heads.length) ? editing : null
|
||
|
||
function submitHeading() {
|
||
if (edit == null) return
|
||
const problem = headingProblem(edit.value)
|
||
if (problem != null) {
|
||
setEditing({ ...edit, problem })
|
||
return
|
||
}
|
||
if (edit.group === 'new') {
|
||
setLines(addHeading(lines, edit.value))
|
||
// The new group is the one the next list goes into.
|
||
setGroup(heads.length)
|
||
focusAfter.current = 'Add group'
|
||
} else {
|
||
setLines(renameHeading(lines, heads[edit.group], edit.value))
|
||
focusAfter.current = `Rename group ${edit.value.trim()}`
|
||
}
|
||
setEditing(null)
|
||
}
|
||
|
||
function cancelEditing() {
|
||
if (edit == null) return
|
||
focusAfter.current = edit.group === 'new' ? 'Add group' : `Rename group ${nameOf(lines[heads[edit.group]])}`
|
||
setEditing(null)
|
||
}
|
||
|
||
function ungroup(g: number) {
|
||
setLines(removeHeading(lines, heads[g]))
|
||
// Its lists joined the group above, and so does the choice of group.
|
||
if (target >= g) setGroup(target - 1)
|
||
if (edit != null && edit.group !== 'new' && edit.group >= g) setEditing(null)
|
||
}
|
||
|
||
function add(kind: 'block' | 'allow') {
|
||
const url = field.trim()
|
||
if (url === '') return
|
||
if (url.startsWith('#') || url.startsWith('!')) {
|
||
setInvalid(MSG_PREFIX)
|
||
return
|
||
}
|
||
if (lines.some((l) => l.url === url)) {
|
||
setInvalid(MSG_DUPLICATE)
|
||
return
|
||
}
|
||
setLines(addListTo(lines, url, kind, target))
|
||
setField('')
|
||
setInvalid(null)
|
||
}
|
||
|
||
async function save() {
|
||
setBusy(true)
|
||
const r = await setSettings(token, saveBody(lines, node))
|
||
setBusy(false)
|
||
if (r.kind !== 'ok') {
|
||
setNotice(noticeFromFailure(r))
|
||
return
|
||
}
|
||
const read = fromUrls(r.data.response.blockListUrls)
|
||
setSaved(read)
|
||
setLines(read)
|
||
setHasSaved(r.data.response.blockListUrls != null)
|
||
// The response carries the server's schedule, as Settings redraws from it: emptying
|
||
// the lists stops the timer, and the key then comes back omitted ("Not Scheduled").
|
||
const answered = r.data.response.blockListNextUpdatedOn ?? null
|
||
setNext(answered)
|
||
setIntervalHours(r.data.response.blockListUpdateIntervalHours)
|
||
// Settings.tsx:253-257, title and sentence.
|
||
setNotice({ type: 'success', title: 'Settings Saved!', text: 'DNS Server settings were saved successfully.' })
|
||
// See `sameItems`: whether, and how, the counts change.
|
||
const urls = r.data.response.blockListUrls ?? []
|
||
const changed = !sameItems(urls, serverUrls.current)
|
||
serverUrls.current = urls
|
||
serverNext.current = answered
|
||
const interval = r.data.response.blockListUpdateIntervalHours ?? 0
|
||
if (!changed) return
|
||
if (urls.length > 0 && interval > 0) setReloading({ from: answered })
|
||
else {
|
||
setReloading(null)
|
||
void readCounts()
|
||
}
|
||
}
|
||
|
||
async function updateNow() {
|
||
setAskUpdate(false)
|
||
setBusy(true)
|
||
// The date to wait on, read just before the call (see POLL_MS); the last answer
|
||
// known if the read fails.
|
||
const before = await readSettings(token, node)
|
||
const from = before.kind !== 'ok' ? serverNext.current : (before.data.blockListNextUpdatedOn ?? null)
|
||
const ok = await forceUpdateBlockLists(token)
|
||
setBusy(false)
|
||
if (!ok) return
|
||
// main.js:2356. The label becomes "Updating Now" without reloading the settings.
|
||
setNext(new Date(0).toISOString())
|
||
setNotice({ type: 'success', title: 'Updating Block List!', text: 'Block list update was triggered successfully.' })
|
||
setReloading({ from })
|
||
}
|
||
|
||
/* What names a row: Technitium's entries first, so theirs wins a shared URL. */
|
||
const named = [...catalog, ...more]
|
||
|
||
const listsCount = lines.filter((l) => l.kind !== 'comment').length
|
||
const disabledCount = lines.filter((l) => l.kind !== 'comment' && !l.enabled).length
|
||
const groupCount = heads.length
|
||
|
||
/* The lists of each group, to say how many it holds. */
|
||
const groupSize = (g: number) => (heads[g + 1] ?? lines.length) - (g < 0 ? 0 : heads[g] + 1)
|
||
|
||
/* What the table draws, one item per row: a group heading (its content spans the
|
||
whole row) or a line. The grid draws cells, so a heading is a row whose first cell
|
||
takes the width of all four and whose other cells are not drawn. */
|
||
type Entry = ListLine & { kind: 'block' | 'allow' }
|
||
type Item =
|
||
| { key: string; kind: 'heading'; node: ReactNode }
|
||
| { key: string; kind: 'line'; l: Entry; i: number }
|
||
|
||
function heading(key: string, name: string, count: number, controls: ReactNode): Item {
|
||
return {
|
||
key,
|
||
kind: 'heading',
|
||
node: (
|
||
<div className={styles.groupHead}>
|
||
<span className={styles.groupName}>{name}</span>
|
||
<span className={styles.groupCount}>{plural(count, 'list', 'lists')}</span>
|
||
{controls != null && <span className={styles.spacer} />}
|
||
{controls}
|
||
</div>
|
||
),
|
||
}
|
||
}
|
||
|
||
/* One row per line. A key that follows the line and not its place, so a list that
|
||
moves or switches keeps its row, and the focus stays on the button pressed. */
|
||
const seen = new Map<string, number>()
|
||
const items: Item[] = []
|
||
if (heads.length > 0 && heads[0] > 0) items.push(heading('none', 'No group', groupSize(-1), null))
|
||
lines.forEach((l, i) => {
|
||
if (l.kind === 'comment') {
|
||
const g = heads.indexOf(i)
|
||
const name = nameOf(l)
|
||
items.push(
|
||
edit != null && edit.group === g
|
||
? {
|
||
key: `H:${g}`,
|
||
kind: 'heading',
|
||
node: (
|
||
<HeadingEditor
|
||
editing={edit}
|
||
verb="Rename"
|
||
onChange={(value) => setEditing({ ...edit, value, problem: null })}
|
||
onSubmit={submitHeading}
|
||
onCancel={cancelEditing}
|
||
/>
|
||
),
|
||
}
|
||
: heading(`H:${g}`, name, groupSize(g), (
|
||
<>
|
||
<PermissionButton size="sm" permission={modifyNeed} aria-label={`Rename group ${name}`}
|
||
onClick={() => setEditing({ group: g, value: headingName(l), problem: null })}>
|
||
Rename
|
||
</PermissionButton>
|
||
<PermissionButton size="sm" permission={modifyNeed} aria-label={`Ungroup ${name}`}
|
||
onClick={() => ungroup(g)}>
|
||
Ungroup
|
||
</PermissionButton>
|
||
</>
|
||
)),
|
||
)
|
||
return
|
||
}
|
||
const id = `${l.kind}:${l.url}`
|
||
const n = seen.get(id) ?? 0
|
||
seen.set(id, n + 1)
|
||
items.push({ key: `L:${id}:${n}`, kind: 'line', l: l as Entry, i })
|
||
})
|
||
if (edit != null && edit.group === 'new') {
|
||
items.push({
|
||
key: 'H:new',
|
||
kind: 'heading',
|
||
node: (
|
||
<HeadingEditor
|
||
editing={edit}
|
||
verb="Add group"
|
||
onChange={(value) => setEditing({ ...edit, value, problem: null })}
|
||
onSubmit={submitHeading}
|
||
onCancel={cancelEditing}
|
||
/>
|
||
),
|
||
})
|
||
}
|
||
|
||
/* What a line's cells share. */
|
||
const lineCells = (l: Entry) => ({
|
||
name: l.url == null ? null : listName(l.url, named),
|
||
faded: !l.enabled ? styles.off : undefined,
|
||
what: `${l.kind} list ${l.url ?? ''}`,
|
||
})
|
||
|
||
return (
|
||
<>
|
||
<SectionHeader section="Blocking" title="Lists" subtitle={LISTS_SUBTITLE} tabs={tabs} />
|
||
<Notifier notice={notice} onClose={() => setNotice(null)} />
|
||
|
||
<div className={shared.stack}>
|
||
<div className={shared.kpis3}>
|
||
{statsNeed != null ? (
|
||
<>
|
||
<Locked title="Block List Domains" need={statsNeed} />
|
||
<Locked title="Allow List Domains" need={statsNeed} />
|
||
</>
|
||
) : (
|
||
<>
|
||
<Panel className={shared.centred}><Body>
|
||
<div className={shared.kpiText}>
|
||
{countsFailed ? (
|
||
<Failure>{COUNTS_FAILED}</Failure>
|
||
) : (
|
||
<span className={`${shared.kpiValue}${reloading != null ? ` ${styles.stale}` : ''}`}>
|
||
{counts ? counts.block.toLocaleString() : '—'}
|
||
</span>
|
||
)}
|
||
{reloading != null ? (
|
||
<span className={styles.pending}>{UPDATING}</span>
|
||
) : (
|
||
<span className={shared.kpiSub}>{'\u00a0'}</span>
|
||
)}
|
||
<span className={shared.kpiLabel}>Block List Domains</span>
|
||
</div>
|
||
</Body></Panel>
|
||
<Panel className={shared.centred}><Body>
|
||
<div className={shared.kpiText}>
|
||
{countsFailed ? (
|
||
<Failure>{COUNTS_FAILED}</Failure>
|
||
) : (
|
||
<span className={`${shared.kpiValue}${reloading != null ? ` ${styles.stale}` : ''}`}>
|
||
{counts ? counts.allow.toLocaleString() : '—'}
|
||
</span>
|
||
)}
|
||
{reloading != null ? (
|
||
<span className={styles.pending}>{UPDATING}</span>
|
||
) : (
|
||
<span className={shared.kpiSub}>{'\u00a0'}</span>
|
||
)}
|
||
<span className={shared.kpiLabel}>Allow List Domains</span>
|
||
</div>
|
||
</Body></Panel>
|
||
</>
|
||
)}
|
||
<Panel className={shared.centred}><Body>
|
||
<div className={shared.kpi}>
|
||
<div className={shared.kpiText}>
|
||
<span className={shared.kpiValue}>{loading ? '—' : nextUpdateText(next)}</span>
|
||
<span className={shared.kpiSub}>{'\u00a0'}</span>
|
||
<span className={shared.kpiLabel}>
|
||
Next update{interval != null && interval > 0 ? ` · every ${interval} h` : ''}
|
||
</span>
|
||
</div>
|
||
<PermissionButton
|
||
permission={modifyNeed}
|
||
disabled={busy || !hasSaved}
|
||
onClick={() => setAskUpdate(true)}
|
||
>
|
||
Update Now
|
||
</PermissionButton>
|
||
</div>
|
||
</Body></Panel>
|
||
</div>
|
||
|
||
<Panel title="Add a list">
|
||
<Body>
|
||
{/* Above both ways in, the URL and Quick Add, because it serves both. */}
|
||
<div className={styles.target}>
|
||
<Field label="Add to group">
|
||
{(id) => (
|
||
<Select
|
||
id={id}
|
||
disabled={off || loading || heads.length === 0}
|
||
value={String(target)}
|
||
onChange={(ev) => setGroup(Number(ev.target.value))}
|
||
>
|
||
<option value="-1">No group</option>
|
||
{heads.map((h, g) => (
|
||
<option key={g} value={String(g)}>{nameOf(lines[h])}</option>
|
||
))}
|
||
</Select>
|
||
)}
|
||
</Field>
|
||
</div>
|
||
<div className={styles.add}>
|
||
<Field label="List URL">
|
||
{(id) => (
|
||
<Input
|
||
id={id}
|
||
mono
|
||
className={styles.bad}
|
||
placeholder="https://…/hosts"
|
||
value={field}
|
||
disabled={off || loading}
|
||
aria-invalid={invalid != null || undefined}
|
||
aria-describedby={invalid != null ? invalidId : undefined}
|
||
onChange={(e) => {
|
||
setField(e.target.value)
|
||
setInvalid(null)
|
||
}}
|
||
/>
|
||
)}
|
||
</Field>
|
||
<PermissionButton variant="primary" permission={modifyNeed} disabled={loading} onClick={() => add('block')}>
|
||
Add block list
|
||
</PermissionButton>
|
||
<PermissionButton permission={modifyNeed} disabled={loading} onClick={() => add('allow')}>
|
||
Add allow list
|
||
</PermissionButton>
|
||
{/* The URL's own sentences stay under it, in the URL's column. */}
|
||
{invalid != null && (
|
||
<p id={invalidId} className={styles.invalid}>
|
||
{invalid}
|
||
</p>
|
||
)}
|
||
<p className={styles.help}>
|
||
Use <code>file://</code> for a list stored on this server. Hosts, plain domain, wildcard or
|
||
Adblock Plus files.
|
||
</p>
|
||
{/* Its own line under the URL, as wide as the URL, as upstream draws it
|
||
(`index.html:2156-2157`, `width: 100%`): the catalogue's names run to
|
||
86 characters and a trigger beside the buttons cut them to one letter. */}
|
||
<div className={styles.quick}>
|
||
<Field label="Quick Add">
|
||
{(id) => (
|
||
<Select
|
||
id={id}
|
||
disabled={off || loading}
|
||
value=""
|
||
searchable
|
||
searchLabel="Search lists"
|
||
noMatchText="No lists match"
|
||
onChange={(ev) => {
|
||
const chosen = ev.target.value
|
||
if (chosen === '') return
|
||
const entry = chosen === 'none'
|
||
? 'none'
|
||
: chosen.startsWith(MORE)
|
||
? more[Number(chosen.slice(MORE.length))]
|
||
: catalog.find((q) => q.name === chosen)
|
||
if (entry != null) setLines((l) => applyQuickTo(l, entry, target))
|
||
}}
|
||
>
|
||
<option value="" />
|
||
<option value="none">None</option>
|
||
{catalog.map((q) => (
|
||
<option key={q.name} value={q.name} data-search={hostsOf(q)}>{q.name}</option>
|
||
))}
|
||
{more.length > 0 && (
|
||
<optgroup label="More lists">
|
||
{more.map((q, i) => (
|
||
<option key={`${MORE}${i}`} value={`${MORE}${i}`} data-search={hostsOf(q)}>
|
||
{q.name}
|
||
</option>
|
||
))}
|
||
</optgroup>
|
||
)}
|
||
</Select>
|
||
)}
|
||
</Field>
|
||
</div>
|
||
</div>
|
||
</Body>
|
||
</Panel>
|
||
|
||
<Panel className={shared.flush}>
|
||
<div ref={table}>
|
||
{readFailed ? (
|
||
<Body><Failure>Could not read the block list settings.</Failure></Body>
|
||
) : saved == null ? (
|
||
<Body><Loading /></Body>
|
||
) : (
|
||
<DataTable<Item>
|
||
data={items}
|
||
getRowId={(item) => item.key}
|
||
rowProps={(item) => (item.kind === 'heading' ? { className: styles.groupRow } : undefined)}
|
||
emptyText={lines.length === 0 && edit?.group !== 'new' ? 'No lists' : undefined}
|
||
className={shared.inPanel}
|
||
columns={[
|
||
{
|
||
id: 'enabled',
|
||
header: 'Enabled',
|
||
td: (item) =>
|
||
item.kind === 'heading'
|
||
? { colSpan: 4, scope: 'colgroup', role: 'columnheader' }
|
||
: undefined,
|
||
cell: (item) => {
|
||
if (item.kind === 'heading') return item.node
|
||
const { l, i } = item
|
||
const { what } = lineCells(l)
|
||
return (
|
||
canToggle(l) && (
|
||
<label className={styles.switch}>
|
||
<input
|
||
type="checkbox"
|
||
aria-label={`Enabled ${what}`}
|
||
checked={l.enabled}
|
||
disabled={off}
|
||
onChange={() => setLines((all) => all.map((x, j) => (j === i ? toggleLine(x) : x)))}
|
||
/>
|
||
</label>
|
||
)
|
||
)
|
||
},
|
||
},
|
||
{
|
||
id: 'list',
|
||
header: 'List',
|
||
td: (item) => (item.kind === 'heading' ? { hidden: true } : { className: lineCells(item.l).faded }),
|
||
cell: (item) => {
|
||
if (item.kind === 'heading') return null
|
||
const { l } = item
|
||
const { name } = lineCells(l)
|
||
return (
|
||
<>
|
||
{name != null && <div className={styles.name}>{name}</div>}
|
||
<div className={styles.url}><UrlText url={l.url ?? l.raw} /></div>
|
||
{/* The kind under the URL where the Type column is folded away. */}
|
||
<span className={`${styles.kind} ${styles.kindInline} ${KIND_CLASS[l.kind]}`}>
|
||
<Icon name={KIND_ICON[l.kind]} size={14} />
|
||
{KIND_LABEL[l.kind]}
|
||
</span>
|
||
</>
|
||
)
|
||
},
|
||
},
|
||
{
|
||
id: 'kind',
|
||
th: { className: styles.kindCell },
|
||
header: 'Type',
|
||
td: (item) =>
|
||
item.kind === 'heading'
|
||
? { hidden: true }
|
||
: { className: `${lineCells(item.l).faded ?? ''} ${styles.kindCell}` },
|
||
cell: (item) =>
|
||
item.kind === 'heading' ? null : (
|
||
<span className={`${styles.kind} ${KIND_CLASS[item.l.kind]}`}>
|
||
<Icon name={KIND_ICON[item.l.kind]} size={14} />
|
||
{KIND_LABEL[item.l.kind]}
|
||
</span>
|
||
),
|
||
},
|
||
{
|
||
id: 'actions',
|
||
td: (item) => (item.kind === 'heading' ? { hidden: true } : { className: styles.actions }),
|
||
cell: (item) => {
|
||
if (item.kind === 'heading') return null
|
||
const { l, i } = item
|
||
const { what } = lineCells(l)
|
||
return (
|
||
<div className={styles.rowActions}>
|
||
<RowAction icon="moveUp" name="Move up" aria-label={`Move up ${what}`}
|
||
disabled={off || !canMove(lines, i, -1)} onClick={() => setLines((all) => moveLine(all, i, -1))} />
|
||
<RowAction icon="moveDown" name="Move down" aria-label={`Move down ${what}`}
|
||
disabled={off || !canMove(lines, i, 1)} onClick={() => setLines((all) => moveLine(all, i, 1))} />
|
||
<PermissionButton size="sm" variant="danger" permission={modifyNeed}
|
||
aria-label={`Remove ${l.url ?? l.raw}`}
|
||
onClick={() => setLines((all) => all.filter((_, j) => j !== i))}>
|
||
Remove
|
||
</PermissionButton>
|
||
</div>
|
||
)
|
||
},
|
||
},
|
||
]}
|
||
/>
|
||
)}
|
||
<div className={styles.foot}>
|
||
<PermissionButton size="sm" permission={modifyNeed} disabled={loading || edit?.group === 'new'}
|
||
onClick={() => setEditing({ group: 'new', value: '', problem: null })}>
|
||
Add group
|
||
</PermissionButton>
|
||
<span>
|
||
{plural(listsCount, 'list', 'lists')} · {disabledCount} disabled · {plural(groupCount, 'group', 'groups')}
|
||
</span>
|
||
<span className={styles.spacer} />
|
||
<RouteLink to={{ section: 'settings', sub: 'Blocking' }}>More blocking settings in Settings › Blocking</RouteLink>
|
||
</div>
|
||
</div>
|
||
</Panel>
|
||
|
||
{dirty && (
|
||
<div className={styles.bar}>
|
||
<span>Unsaved changes to the block list URLs: {changes}</span>
|
||
<span className={styles.spacer} />
|
||
<Button disabled={busy} onClick={() => saved && setLines(saved)}>Discard</Button>
|
||
<PermissionButton variant="primary" disabled={busy} permission={modifyNeed} onClick={() => void save()}>
|
||
Save
|
||
</PermissionButton>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<Confirm
|
||
open={askUpdate}
|
||
onClose={() => setAskUpdate(false)}
|
||
title="Update Block Lists"
|
||
label="Update"
|
||
variant="primary"
|
||
text="Are you sure to force download and update the block lists?"
|
||
busy={busy}
|
||
onConfirm={() => void updateNow()}
|
||
/>
|
||
</>
|
||
)
|
||
}
|