@@ -0,0 +1,759 @@
// Hand-written mirror of arr-api's /api/policies and /api/roots schemas,
// plus the settings view over them — same reasoning as search.ts: the
// generated client (src/api/) is uncommitted, so CI's tsc cannot see it.
import { navigate } from "./router" ;
import type { Root } from "./search" ;
/** One policy row: the JSON columns as the API returns them. */
export interface PolicyDoc {
id : number ;
name : string ;
required_audio : { require : "original_language" } | { require : "any_of" ; langs : string [ ] } ;
dub_blacklist : string [ ] ;
hdr_rules : { dv_profile_reject : string [ ] } ;
size_bands : Record <
string ,
{ floor_gib : number ; target_gib : number ; penalty_points_per_gib_over : number }
> ;
resolution_pref : string [ ] ;
source_weights : Record < string , number > ;
score_weights : {
size_at_target : number ;
source_tier : number ;
seeder_doubling : number ;
resolution_step : number ;
} ;
}
export interface RootInput {
kind : string ;
audience : string ;
path : string ;
policy_id : number ;
}
const RESOLUTIONS = [ "2160p" , "1080p" , "720p" ] as const ;
const SOURCES = [ "Remux" , "BluRay" , "WEB-DL" , "WEBRip" , "HDTV" ] as const ;
async function errorDetail ( response : Response ) : Promise < string > {
try {
const body = ( await response . json ( ) ) as { error? : string } ;
return body . error ? ? ` http ${ response . status } ` ;
} catch {
return ` http ${ response . status } ` ;
}
}
async function fetchPolicies ( ) : Promise < PolicyDoc [ ] > {
const response = await fetch ( "/api/policies" ) ;
if ( ! response . ok ) {
throw new Error ( await errorDetail ( response ) ) ;
}
return ( await response . json ( ) ) as PolicyDoc [ ] ;
}
async function fetchRoots ( ) : Promise < Root [ ] > {
const response = await fetch ( "/api/roots" ) ;
if ( ! response . ok ) {
throw new Error ( await errorDetail ( response ) ) ;
}
return ( await response . json ( ) ) as Root [ ] ;
}
type SaveOutcome = { ok : true ; json : unknown } | { ok : false ; detail : string } ;
async function sendJson ( url : string , method : string , body : unknown ) : Promise < SaveOutcome > {
try {
const response = await fetch ( url , {
method ,
headers : { "content-type" : "application/json" } ,
body : JSON.stringify ( body ) ,
} ) ;
if ( ! response . ok ) {
return { ok : false , detail : await errorDetail ( response ) } ;
}
return { ok : true , json : await response . json ( ) } ;
} catch {
return { ok : false , detail : "daemon unreachable" } ;
}
}
function savePolicy ( id : number | null , policy : Omit < PolicyDoc , "id" > ) : Promise < SaveOutcome > {
return id === null
? sendJson ( "/api/policies" , "POST" , policy )
: sendJson ( ` /api/policies/ ${ id } ` , "PUT" , policy ) ;
}
function saveRoot ( id : number | null , root : RootInput ) : Promise < SaveOutcome > {
return id === null
? sendJson ( "/api/roots" , "POST" , root )
: sendJson ( ` /api/roots/ ${ id } ` , "PUT" , root ) ;
}
async function deleteRow ( kind : "policies" | "roots" , id : number ) : Promise < string | null > {
try {
const response = await fetch ( ` /api/ ${ kind } / ${ id } ` , { method : "DELETE" } ) ;
if ( ! response . ok ) {
return await errorDetail ( response ) ;
}
return null ;
} catch {
return "daemon unreachable" ;
}
}
/* ---- form primitives ---------------------------------------------------- */
function must < T extends Element > ( selector : string ) : T {
const element = document . querySelector < T > ( selector ) ;
if ( ! element ) {
throw new Error ( ` settings markup is missing ${ selector } ` ) ;
}
return element ;
}
function el < K extends keyof HTMLElementTagNameMap > (
tag : K ,
className : string ,
text? : string ,
) : HTMLElementTagNameMap [ K ] {
const node = document . createElement ( tag ) ;
node . className = className ;
if ( text !== undefined ) {
node . textContent = text ;
}
return node ;
}
function chip ( text : string ) : HTMLSpanElement {
return el ( "span" , "chip readout" , text ) ;
}
function rowTitle ( title : string ) : HTMLElement {
const wrap = el ( "span" , "row-id" ) ;
wrap . append ( el ( "span" , "row-title" , title ) ) ;
return wrap ;
}
function field ( labelText : string , control : HTMLElement ) : HTMLDivElement {
const wrap = el ( "div" , "field" ) ;
wrap . append ( el ( "span" , "field-label readout dim" , labelText ) , control ) ;
return wrap ;
}
function textControl ( value : string , placeholder = "" ) : HTMLInputElement {
const input = document . createElement ( "input" ) ;
input . type = "text" ;
input . className = "form-input readout" ;
input . value = value ;
input . placeholder = placeholder ;
input . spellcheck = false ;
input . autocomplete = "off" ;
return input ;
}
function numberControl ( value : number ) : HTMLInputElement {
const input = document . createElement ( "input" ) ;
input . type = "number" ;
input . className = "form-input readout form-num" ;
input . value = String ( value ) ;
return input ;
}
function selectControl ( options : readonly string [ ] , value : string ) : HTMLSelectElement {
const select = document . createElement ( "select" ) ;
select . className = "form-input readout" ;
for ( const option of options ) {
const item = document . createElement ( "option" ) ;
item . value = option ;
item . textContent = option ;
if ( option === value ) {
item . selected = true ;
}
select . append ( item ) ;
}
select . value = value ;
return select ;
}
/** `a, b, c` ⇄ parts. Empty entries dropped; validation happens at save. */
function listValue ( input : HTMLInputElement ) : string [ ] {
return input . value
. split ( "," )
. map ( ( part ) = > part . trim ( ) )
. filter ( ( part ) = > part !== "" ) ;
}
/**
* First click arms the destructive action, second confirms. The armed state
* clears on blur or after a few seconds, so an accidental double click
* never deletes.
*/
function armedDelete ( label : string , execute : ( ) = > void ) : HTMLButtonElement {
const button = el ( "button" , "control control-quiet readout" , label ) ;
button . type = "button" ;
let armed = false ;
let resetTimer : number | undefined ;
const disarm = ( ) = > {
armed = false ;
window . clearTimeout ( resetTimer ) ;
delete button . dataset . armed ;
button . textContent = label ;
} ;
button . addEventListener ( "click" , ( ) = > {
if ( armed ) {
disarm ( ) ;
button . disabled = true ;
execute ( ) ;
return ;
}
armed = true ;
button . dataset . armed = "true" ;
button . textContent = ` confirm ${ label } ` ;
resetTimer = window . setTimeout ( disarm , 4000 ) ;
} ) ;
button . addEventListener ( "blur" , ( ) = > {
if ( armed ) {
disarm ( ) ;
}
} ) ;
return button ;
}
interface SettingsView {
hide : ( ) = > void ;
open : ( ) = > void ;
}
export function settingsMain ( board : HTMLElement , views : { hide : ( ) = > void } [ ] ) : SettingsView {
const view = must < HTMLElement > ( "#settings" ) ;
const deck = must < HTMLElement > ( "#deck" ) ;
const nav = must < HTMLButtonElement > ( "#nav-settings" ) ;
const summary = must < HTMLElement > ( "#settings-summary" ) ;
const status = must < HTMLElement > ( "#settings-status" ) ;
const rootsSection = must < HTMLElement > ( "#group-roots" ) ;
const rootsRows = must < HTMLUListElement > ( "#rows-roots" ) ;
const policiesSection = must < HTMLElement > ( "#group-policies" ) ;
const policiesRows = must < HTMLUListElement > ( "#rows-policies" ) ;
let roots : Root [ ] = [ ] ;
let policies : PolicyDoc [ ] = [ ] ;
// guards a stale fetch from painting over a newer view
let sequence = 0 ;
function setStatus ( text : string | null , tone ? : "fault" ) {
status . hidden = text === null ;
status . textContent = text ? ? "" ;
if ( tone ) {
status . dataset . tone = tone ;
} else {
delete status . dataset . tone ;
}
}
function clearGroups() {
for ( const section of [ rootsSection , policiesSection ] ) {
section . hidden = true ;
}
rootsRows . replaceChildren ( ) ;
policiesRows . replaceChildren ( ) ;
summary . textContent = "" ;
}
async function load() {
sequence += 1 ;
const ticket = sequence ;
setStatus ( "reading settings…" ) ;
try {
const [ fetchedRoots , fetchedPolicies ] = await Promise . all ( [ fetchRoots ( ) , fetchPolicies ( ) ] ) ;
if ( ticket !== sequence ) {
return ;
}
roots = fetchedRoots ;
policies = fetchedPolicies ;
} catch ( error ) {
if ( ticket !== sequence ) {
return ;
}
clearGroups ( ) ;
setStatus ( ` settings unavailable — ${ ( error as Error ) . message } ` , "fault" ) ;
return ;
}
render ( ) ;
}
function render() {
clearGroups ( ) ;
setStatus ( null ) ;
rootsSection . hidden = false ;
policiesSection . hidden = false ;
summary . textContent = ` ${ roots . length } roots · ${ policies . length } policies ` ;
for ( const root of roots ) {
rootsRows . append ( rootRow ( root ) ) ;
}
for ( const policy of policies ) {
policiesRows . append ( policyRow ( policy ) ) ;
}
}
/* -- roots -------------------------------------------------------------- */
/** A delete/edit action pair sharing the row's feedback line. */
function rowActions (
item : HTMLLIElement ,
del : HTMLButtonElement ,
edit : HTMLButtonElement ,
) : HTMLParagraphElement {
const actions = el ( "div" , "settings-row-actions" ) ;
actions . append ( edit , del ) ;
item . append ( actions ) ;
const note = el ( "p" , "add-note readout" ) ;
note . setAttribute ( "role" , "status" ) ;
note . hidden = true ;
item . append ( note ) ;
return note ;
}
function rootRow ( root : Root ) : HTMLLIElement {
const item = el ( "li" , "queue-item" ) ;
const row = el ( "div" , "row" ) ;
const chips = el ( "span" , "row-chips" ) ;
chips . append ( chip ( root . kind ) , chip ( root . audience ) , chip ( root . policy_name ) ) ;
row . append ( rowTitle ( root . path ) , chips ) ;
item . append ( row ) ;
const del = armedDelete ( "delete" , ( ) = > {
void deleteRow ( "roots" , root . id ) . then ( ( detail ) = > {
if ( detail !== null ) {
note . hidden = false ;
note . dataset . tone = "fault" ;
note . textContent = detail ;
return ;
}
void load ( ) ;
} ) ;
} ) ;
const edit = el ( "button" , "control control-quiet readout" , "edit" ) ;
edit . type = "button" ;
edit . setAttribute ( "aria-expanded" , "false" ) ;
edit . addEventListener ( "click" , ( ) = > {
const existing = item . querySelector ( ".edit-panel" ) ;
if ( existing ) {
existing . remove ( ) ;
edit . setAttribute ( "aria-expanded" , "false" ) ;
return ;
}
item . append ( rootPanel ( root ) ) ;
edit . setAttribute ( "aria-expanded" , "true" ) ;
} ) ;
const note = rowActions ( item , del , edit ) ;
return item ;
}
function rootPanel ( root : Root | null ) : HTMLElement {
const panel = el ( "div" , "edit-panel add-panel" ) ;
const grid = el ( "div" , "form-grid" ) ;
const kind = selectControl ( [ "movie" , "tv" ] , root ? . kind ? ? "movie" ) ;
const audience = selectControl ( [ "main" , "kids" ] , root ? . audience ? ? "main" ) ;
const path = textControl ( root ? . path ? ? "" , "/mnt/media/…" ) ;
const policyId = selectControl (
policies . map ( ( policy ) = > String ( policy . id ) ) ,
String ( root ? . policy_id ? ? policies [ 0 ] ? . id ? ? "" ) ,
) ;
// readable option labels without coupling value → name lookups
for ( const [ index , policy ] of policies . entries ( ) ) {
const option = policyId . options [ index ] ;
if ( option ) {
option . textContent = policy . name ;
}
}
grid . append (
field ( "kind" , kind ) ,
field ( "audience" , audience ) ,
field ( "path" , path ) ,
field ( "policy" , policyId ) ,
) ;
const note = el ( "p" , "add-note readout" ) ;
note . setAttribute ( "role" , "status" ) ;
note . hidden = true ;
const save = el ( "button" , "control readout" , root === null ? "create root" : "save root" ) ;
save . type = "button" ;
save . addEventListener ( "click" , ( ) = > {
save . disabled = true ;
note . hidden = false ;
delete note . dataset . tone ;
note . textContent = "saving…" ;
const input : RootInput = {
kind : kind.value ,
audience : audience.value ,
path : path.value.trim ( ) ,
policy_id : Number ( policyId . value ) ,
} ;
void saveRoot ( root ? . id ? ? null , input ) . then ( ( outcome ) = > {
if ( outcome . ok ) {
void load ( ) ;
return ;
}
save . disabled = false ;
note . hidden = false ;
note . dataset . tone = "fault" ;
note . textContent = outcome . detail ;
} ) ;
} ) ;
const cancel = el ( "button" , "control control-quiet readout" , "cancel" ) ;
cancel . type = "button" ;
cancel . addEventListener ( "click" , ( ) = > {
panel . remove ( ) ;
} ) ;
const actions = el ( "div" , "add-actions" ) ;
actions . append ( save , cancel , note ) ;
panel . append ( grid , actions ) ;
queueMicrotask ( ( ) = > ( root === null ? kind : path ) . focus ( ) ) ;
return panel ;
}
/* -- policies ------------------------------------------------------------ */
function requiredAudioLabel ( policy : PolicyDoc ) : string {
return policy . required_audio . require === "original_language"
? "original language"
: ` any of ${ policy . required_audio . langs . join ( ", " ) } ` ;
}
function policyRow ( policy : PolicyDoc ) : HTMLLIElement {
const item = el ( "li" , "queue-item" ) ;
const row = el ( "div" , "row" ) ;
const chips = el ( "span" , "row-chips" ) ;
chips . append ( chip ( policy . resolution_pref . join ( " › " ) || "no resolutions" ) ) ;
chips . append ( chip ( requiredAudioLabel ( policy ) ) ) ;
const bandCount = Object . keys ( policy . size_bands ) . length ;
chips . append ( chip ( ` ${ bandCount } band ${ bandCount === 1 ? "" : "s" } ` ) ) ;
row . append ( rowTitle ( policy . name ) , chips ) ;
item . append ( row ) ;
const del = armedDelete ( "delete" , ( ) = > {
void deleteRow ( "policies" , policy . id ) . then ( ( detail ) = > {
if ( detail !== null ) {
note . hidden = false ;
note . dataset . tone = "fault" ;
note . textContent = detail ;
return ;
}
void load ( ) ;
} ) ;
} ) ;
const edit = el ( "button" , "control control-quiet readout" , "edit" ) ;
edit . type = "button" ;
edit . setAttribute ( "aria-expanded" , "false" ) ;
edit . addEventListener ( "click" , ( ) = > {
const existing = item . querySelector ( ".edit-panel" ) ;
if ( existing ) {
existing . remove ( ) ;
edit . setAttribute ( "aria-expanded" , "false" ) ;
return ;
}
item . append ( policyPanel ( policy ) ) ;
edit . setAttribute ( "aria-expanded" , "true" ) ;
} ) ;
const note = rowActions ( item , del , edit ) ;
return item ;
}
/**
* Comma-list editor that checks its vocabulary while typing, so an unknown
* resolution or profile is named before a round trip.
*/
function knownList (
labelText : string ,
values : string [ ] ,
known : readonly string [ ] ,
invalid : ( message : string | null ) = > void ,
) : HTMLDivElement {
const input = textControl ( values . join ( ", " ) , known . join ( ", " ) ) ;
input . addEventListener ( "input" , ( ) = > {
const unknown = listValue ( input ) . find ( ( part ) = > ! known . includes ( part ) ) ;
if ( unknown !== undefined ) {
input . dataset . invalid = "true" ;
invalid ( ` ${ labelText } : unknown value ' ${ unknown } ' ` ) ;
} else if ( input . dataset . invalid !== undefined ) {
delete input . dataset . invalid ;
invalid ( null ) ;
}
} ) ;
return field ( labelText , input ) ;
}
function newPolicyDoc ( ) : Omit < PolicyDoc , "id" > {
return {
name : "" ,
required_audio : { require : "original_language" } ,
dub_blacklist : [ "pt-BR" ] ,
hdr_rules : { dv_profile_reject : [ "5" , "7" ] } ,
size_bands : { } ,
resolution_pref : [ "2160p" , "1080p" ] ,
source_weights : { } ,
score_weights : {
size_at_target : 1000 ,
source_tier : 25 ,
seeder_doubling : 8 ,
resolution_step : 300 ,
} ,
} ;
}
function policyPanel ( policy : PolicyDoc | null ) : HTMLElement {
const current : Omit < PolicyDoc , "id" > = policy ? ? newPolicyDoc ( ) ;
const panel = el ( "div" , "edit-panel add-panel" ) ;
const grid = el ( "div" , "form-grid" ) ;
const invalidate = ( message : string | null ) = > {
note . hidden = message === null || note . textContent === "" ;
if ( message !== null ) {
note . dataset . tone = "fault" ;
note . textContent = message ;
} else if ( note . dataset . tone === "fault" ) {
note . hidden = true ;
}
} ;
const note = el ( "p" , "add-note readout" ) ;
note . setAttribute ( "role" , "status" ) ;
note . hidden = true ;
const name = textControl ( current . name , "policy name" ) ;
grid . append ( field ( "name" , name ) ) ;
const audioMode = selectControl (
[ "original_language" , "any_of" ] ,
current . required_audio . require ,
) ;
const audioLangs = textControl (
current . required_audio . require === "any_of" ? current . required_audio . langs . join ( ", " ) : "" ,
"pt-PT, …" ,
) ;
audioMode . addEventListener ( "change" , ( ) = > {
audioLangs . disabled = audioMode . value === "original_language" ;
} ) ;
audioLangs . disabled = audioMode . value === "original_language" ;
const blacklist = textControl ( current . dub_blacklist . join ( ", " ) , "pt-BR" ) ;
grid . append ( field ( "required audio" , audioMode ) , field ( "required langs" , audioLangs ) ) ;
grid . append ( field ( "dub blacklist" , blacklist ) ) ;
let profilesInput : HTMLInputElement ;
const profiles = knownList (
"rejected dv profiles" ,
current . hdr_rules . dv_profile_reject ,
[ "5" , "7" , "8" ] ,
invalidate ,
) ;
profilesInput = profiles . querySelector ( "input" ) ? ? document . createElement ( "input" ) ;
grid . append ( profiles ) ;
// one line per resolution: floor, target, penalty — numbers stay numbers
for ( const resolution of RESOLUTIONS ) {
const band = current . size_bands [ resolution ] ;
const line = el ( "div" , "band-line" ) ;
line . dataset . resolution = resolution ;
line . append ( el ( "span" , "field-label readout dim" , resolution ) ) ;
const floor = numberControl ( band ? . floor_gib ? ? 0 ) ;
const target = numberControl ( band ? . target_gib ? ? 0 ) ;
const penalty = numberControl ( band ? . penalty_points_per_gib_over ? ? 0 ) ;
floor . setAttribute ( "aria-label" , ` ${ resolution } floor GiB ` ) ;
target . setAttribute ( "aria-label" , ` ${ resolution } target GiB ` ) ;
penalty . setAttribute ( "aria-label" , ` ${ resolution } penalty points per GiB over ` ) ;
for ( const input of [ floor , target , penalty ] ) {
input . classList . add ( "band-num" ) ;
line . append ( input ) ;
}
grid . append ( line ) ;
}
let prefInput : HTMLInputElement ;
const pref = knownList (
"resolution preference (first wins)" ,
current . resolution_pref ,
RESOLUTIONS ,
invalidate ,
) ;
prefInput = pref . querySelector ( "input" ) ? ? document . createElement ( "input" ) ;
grid . append ( pref ) ;
const WEIGHT_KEYS = [
"size_at_target" ,
"source_tier" ,
"seeder_doubling" ,
"resolution_step" ,
] as const ;
type SourceKey = ( typeof SOURCES ) [ number ] ;
type WeightKey = ( typeof WEIGHT_KEYS ) [ number ] ;
const sourceInputs = { } as Record < SourceKey , HTMLInputElement > ;
for ( const source of SOURCES ) {
const weight = numberControl ( current . source_weights [ source ] ? ? 0 ) ;
weight . setAttribute ( "aria-label" , ` ${ source } source weight ` ) ;
grid . append ( field ( ` source · ${ source . toLowerCase ( ) } ` , weight ) ) ;
sourceInputs [ source ] = weight ;
}
const scoreInputs = { } as Record < WeightKey , HTMLInputElement > ;
for ( const key of WEIGHT_KEYS ) {
const input = numberControl ( current . score_weights [ key ] ) ;
input . setAttribute ( "aria-label" , key . replaceAll ( "_" , " " ) ) ;
grid . append ( field ( key . replaceAll ( "_" , " " ) , input ) ) ;
scoreInputs [ key ] = input ;
}
const save = el ( "button" , "control readout" , policy === null ? "create policy" : "save policy" ) ;
save . type = "button" ;
save . addEventListener ( "click" , ( ) = > {
const bad = panel . querySelector < HTMLElement > ( "[data-invalid]" ) ;
if ( bad !== null ) {
note . hidden = false ;
note . dataset . tone = "fault" ;
note . textContent = "fix the highlighted fields first" ;
return ;
}
const emptyWeight = [ . . . Object . values ( sourceInputs ) , . . . Object . values ( scoreInputs ) ] . find (
( input ) = > input . value === "" ,
) ;
if ( name . value . trim ( ) === "" || emptyWeight !== undefined ) {
note . hidden = false ;
note . dataset . tone = "fault" ;
note . textContent = "name and every scoring weight are required" ;
return ;
}
const sizeBands : PolicyDoc [ "size_bands" ] = { } ;
for ( const line of panel . querySelectorAll < HTMLDivElement > ( ".band-line" ) ) {
const [ floor , target , penalty ] = [ . . . line . querySelectorAll < HTMLInputElement > ( "input" ) ] ;
if ( ! floor || ! target || ! penalty ) {
continue ;
}
sizeBands [ line . dataset . resolution ? ? "" ] = {
floor_gib : Number ( floor . value ) ,
target_gib : Number ( target . value ) ,
penalty_points_per_gib_over : Number ( penalty . value ) ,
} ;
}
save . disabled = true ;
note . hidden = false ;
delete note . dataset . tone ;
note . textContent = "saving…" ;
const payload : Omit < PolicyDoc , "id" > = {
name : name.value.trim ( ) ,
required_audio :
audioMode.value === "any_of"
? { require : "any_of" , langs : listValue ( audioLangs ) }
: { require : "original_language" } ,
dub_blacklist : listValue ( blacklist ) ,
hdr_rules : { dv_profile_reject : listValue ( profilesInput ) } ,
size_bands : sizeBands ,
resolution_pref : listValue ( prefInput ) ,
source_weights : Object.fromEntries (
SOURCES . filter ( ( source ) = > sourceInputs [ source ] . value !== "" ) . map ( ( source ) = > [
source ,
Number ( sourceInputs [ source ] . value ) ,
] ) ,
) ,
score_weights : Object.fromEntries (
WEIGHT_KEYS . map ( ( key ) = > [ key , Number ( scoreInputs [ key ] . value ) ] ) ,
) as PolicyDoc [ "score_weights" ] ,
} ;
void savePolicy ( policy ? . id ? ? null , payload ) . then ( ( outcome ) = > {
if ( outcome . ok ) {
void load ( ) ;
return ;
}
save . disabled = false ;
note . hidden = false ;
note . dataset . tone = "fault" ;
note . textContent = outcome . detail ;
} ) ;
} ) ;
const cancel = el ( "button" , "control control-quiet readout" , "cancel" ) ;
cancel . type = "button" ;
cancel . addEventListener ( "click" , ( ) = > {
panel . remove ( ) ;
} ) ;
const actions = el ( "div" , "add-actions" ) ;
actions . append ( save , cancel , note ) ;
panel . append ( grid , actions ) ;
queueMicrotask ( ( ) = > name . focus ( ) ) ;
return panel ;
}
/* -- shell ---------------------------------------------------------------- */
function open() {
for ( const sibling of views ) {
sibling . hide ( ) ;
}
board . hidden = true ;
deck . hidden = true ;
view . hidden = false ;
nav . setAttribute ( "aria-pressed" , "true" ) ;
void load ( ) ;
}
function hide() {
view . hidden = true ;
nav . setAttribute ( "aria-pressed" , "false" ) ;
sequence += 1 ;
}
function close() {
hide ( ) ;
board . hidden = false ;
nav . focus ( ) ;
}
nav . addEventListener ( "click" , ( ) = > {
if ( view . hidden ) {
navigate ( { kind : "settings" } ) ;
open ( ) ;
} else {
navigate ( { kind : "board" } ) ;
close ( ) ;
}
} ) ;
must < HTMLButtonElement > ( "#root-add" ) . addEventListener ( "click" , ( ) = > {
rootsSection . append ( rootPanel ( null ) ) ;
} ) ;
must < HTMLButtonElement > ( "#policy-add" ) . addEventListener ( "click" , ( ) = > {
policiesSection . append ( policyPanel ( null ) ) ;
} ) ;
// capture, like the other decks: one Esc steps back one layer
window . addEventListener (
"keydown" ,
( event ) = > {
if ( event . key === "Escape" && ! view . hidden ) {
event . stopImmediatePropagation ( ) ;
navigate ( { kind : "board" } ) ;
close ( ) ;
}
} ,
true ,
) ;
return { hide , open } ;
}