#56 named 44px and #195 took the page header there. Settings is the other half of #186 and much the larger one: swept on the reference device (TLP301, 424x439) with all eleven config-sections expanded, **120 controls** were under the floor -- not the 93 the issue's table implies, and config-field is eight of them. The bulk is behind the disclosures, which is why nobody had counted it: 36 .column-arrow-btn 16x14 <- smallest in the app 29 .column-toggle 16x16 26 shortcut-capture button 80x25 8 download format checkbox 16x16 7 config-field select 335x30 6 wa-input / wa-button 204x20, 185x21 **The density argument, measured rather than guessed, and it is smaller than it looks.** The rows were already near the floor -- .column-item is 335x36 and .shortcut-row 335x37; it is the controls *inside* them that were 14-25px. So a control grows into the row it already occupies and the row goes 36 to 44. Measured after: the two column lists went 373->447 and 690->850, +234px over the whole page. Half a screen of extra scroll on a page that already scrolls, against 36 targets of 16x14. **Settings is cheaper than the header was, and for a stated reason.** There is no overflow fit on this page, so the header's "only width is contested" rule does not bind at all and nothing here needs padding with a negative margin. Height is a min-size, and the two square controls can simply be square. Three shapes, because one rule does not fit three kinds of control: **A native checkbox is targeted through its label.** It cannot grow its hit area without growing its paint, and a 44px checkbox is not what anyone wants -- so .column-label is a real <label for> now and the column's *name* is the target, 70x44 rather than 16x16. That is the argument config-field already makes one file over ("a real label association also makes the label text a click target, which is behaviour, not annotation"), and here it is the whole fix. The download formats already had the label; they only needed the height. **The arrows take padding, which is invisible.** They carry background: none and a transparent border, so 16x14 -> 44x44 changes nothing anyone can see until hover -- #186's Direction exactly. **Web Awesome's controls come from the library's own API.** Their height is decided inside somebody else's shadow root, and --wa-form-control-height is the variable that decides it. A custom property inherits through a shadow boundary, so a :host declaration reaches them; styles/wa-touch-floor.css.ts is that, once, adopted rather than written at :root in index.css -- a :root rule would be invisible to the component tier, which renders a component and no page stylesheet. **Two controls no sweep can see are fixed by name**, and they are the trap this issue keeps setting. config-field's toggle has an <input> that is opacity: 0; width: 0; height: 0, so a walk of every input skips it as a zero-sized node -- what a finger hits is the <label>, which measured **34x19**, smaller than anything in either of #186's tables and absent from both. It is 44x44 with the pill still painted at 2.5em x 1.4em and negative inline margins keeping it flush with the inputs above. And shortcut-capture's reset button renders only for a shortcut somebody has rebound, so a sweep of a fresh install never meets it. Verified on the device, same method as the sweep that filed it: 120 controls under the floor before, 42 after. All 42 are accounted for -- 37 are checkboxes whose labels measure 70x44 and 57x44, four are wa-input's inner input at 204x**42**, which is the control measured *inside* its own 1px border (part=base is 238x44), and one is the skip link, which #186 already ruled out as keyboard-only. The e2e suite passes, top-bar-fit and header-action-overflow included -- but that is **chromium**, which is half an answer, and saying so is the whole of what #195's second commit was about. What can be argued rather than run: library-filter is the only thing here in a container that measures itself, and its width did not change. The fit measures inline size. Two page-header screenshots are refreshed because they are this issue's own debris -- #195's taller sort control, merged last session, with its references never re-recorded. app-sidebar's and now-playing's are deliberately left: they are unrelated drift, and blessing an unrelated screenshot is how the sidebar reference came to still list a destination #27 retired. That is #196.
853 lines
30 KiB
TypeScript
853 lines
30 KiB
TypeScript
import { LitElement, html, css, nothing } from 'lit';
|
||
import { customElement, state } from 'lit/decorators.js';
|
||
import '@awesome.me/webawesome/dist/components/button/button.js';
|
||
import '@awesome.me/webawesome/dist/components/input/input.js';
|
||
import '@awesome.me/webawesome/dist/components/select/select.js';
|
||
import '@awesome.me/webawesome/dist/components/option/option.js';
|
||
import '@awesome.me/webawesome/dist/components/switch/switch.js';
|
||
import '@awesome.me/webawesome/dist/components/spinner/spinner.js';
|
||
import '@awesome.me/webawesome/dist/components/callout/callout.js';
|
||
import { designTokens } from '../../styles/tokens.css';
|
||
import { waTouchFloor } from '../../styles/wa-touch-floor.css';
|
||
import type {
|
||
DownloadDescriptor,
|
||
DownloadProvider,
|
||
ProviderField,
|
||
} from '@store/download-store';
|
||
import { downloadStore } from '@store/download-store';
|
||
import { GetDownloadPreferences, SetDownloadPreferences } from '@go/config/config.js';
|
||
import { SetPreferences } from '@go/download/service.js';
|
||
import type * as download from '@go/download/models.js';
|
||
import { Format } from '@go/download/models.js';
|
||
import { compact } from '@utils/binding';
|
||
import { describeError, explainError } from '@utils/describe-error';
|
||
import { confirmAction } from '@components/confirm-dialog/confirm-dialog';
|
||
import './config-section';
|
||
import '@components/jobs/job-panel';
|
||
import { pickDirectory } from '../../utils/pick-directory';
|
||
|
||
/**
|
||
* Allowed audio formats for auto-download, mirrored from
|
||
* backend/download/types.go's `Format` constants. `FormatUnknown` is
|
||
* deliberately excluded — it names "no format detected", not a format a
|
||
* user could opt into.
|
||
*/
|
||
const AUTO_DOWNLOAD_FORMATS: { value: Format; label: string }[] = [
|
||
{ value: Format.FormatFLAC, label: 'FLAC' },
|
||
{ value: Format.FormatALAC, label: 'ALAC' },
|
||
{ value: Format.FormatWAV, label: 'WAV' },
|
||
{ value: Format.FormatMP3, label: 'MP3' },
|
||
{ value: Format.FormatAAC, label: 'AAC' },
|
||
{ value: Format.FormatOGG, label: 'OGG' },
|
||
{ value: Format.FormatOpus, label: 'Opus' },
|
||
{ value: Format.FormatWMA, label: 'WMA' },
|
||
];
|
||
|
||
/**
|
||
* Download client configuration.
|
||
*
|
||
* The forms are rendered from the descriptors the backend publishes, not
|
||
* from anything hard-coded here, so adding a provider on the backend
|
||
* gives it a settings UI with no frontend change. That is also why
|
||
* secret fields render as password inputs purely on the descriptor's
|
||
* say-so — the frontend never needs to know which services have keys.
|
||
*/
|
||
@customElement('download-clients')
|
||
export class DownloadClients extends LitElement {
|
||
@state()
|
||
private providers: DownloadProvider[] = [];
|
||
|
||
@state()
|
||
private descriptors: DownloadDescriptor[] = [];
|
||
|
||
/** Provider being edited, or 'new' while adding one. */
|
||
@state()
|
||
private editing: number | 'new' | null = null;
|
||
|
||
/** Kind selected in the add form. */
|
||
@state()
|
||
private newKind = '';
|
||
|
||
/** Working copy of the form's field values. */
|
||
@state()
|
||
private draft: Record<string, string> = {};
|
||
|
||
@state()
|
||
private draftName = '';
|
||
|
||
/** Per-provider connection test results, keyed by provider ID. */
|
||
@state()
|
||
private testResults: Record<number, { ok: boolean; message: string }> = {};
|
||
|
||
@state()
|
||
private testing: number | null = null;
|
||
|
||
@state()
|
||
private errorMessage = '';
|
||
|
||
/** Working copy of the auto-download guardrails. */
|
||
@state()
|
||
private prefs: download.AutoDownloadPrefs = {
|
||
minKbps: 0,
|
||
maxKbps: 0,
|
||
preferredKbps: 0,
|
||
maxSizeMb: 0,
|
||
allowedFormats: [],
|
||
} as download.AutoDownloadPrefs;
|
||
|
||
@state()
|
||
private prefsSaving = false;
|
||
|
||
@state()
|
||
private prefsError = '';
|
||
|
||
@state()
|
||
private prefsSaved = false;
|
||
|
||
private unsubscribe: (() => void) | null = null;
|
||
|
||
override connectedCallback(): void {
|
||
super.connectedCallback();
|
||
|
||
this.unsubscribe = downloadStore.subscribe(() => this.syncFromStore());
|
||
|
||
void downloadStore.init().then(() => this.syncFromStore());
|
||
void this.loadPreferences();
|
||
}
|
||
|
||
override disconnectedCallback(): void {
|
||
super.disconnectedCallback();
|
||
|
||
this.unsubscribe?.();
|
||
this.unsubscribe = null;
|
||
}
|
||
|
||
private syncFromStore(): void {
|
||
this.providers = downloadStore.providers;
|
||
this.descriptors = downloadStore.descriptors;
|
||
}
|
||
|
||
private async loadPreferences(): Promise<void> {
|
||
try {
|
||
this.prefs = await GetDownloadPreferences();
|
||
} catch (err) {
|
||
console.error('Failed to load auto-download preferences:', err);
|
||
}
|
||
}
|
||
|
||
static override styles = [
|
||
designTokens,
|
||
waTouchFloor,
|
||
css`
|
||
:host {
|
||
display: block;
|
||
}
|
||
|
||
.clients {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.6em;
|
||
}
|
||
|
||
.client {
|
||
display: grid;
|
||
grid-template-columns: 1fr auto;
|
||
gap: 0.75em;
|
||
align-items: center;
|
||
padding: 0.7em 0.85em;
|
||
border: 1px solid var(--wa-color-surface-border, #333);
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.client-name {
|
||
font-weight: 600;
|
||
}
|
||
|
||
.client-meta {
|
||
font-size: 0.82em;
|
||
opacity: 0.7;
|
||
margin-top: 0.15em;
|
||
}
|
||
|
||
.client-actions {
|
||
display: flex;
|
||
gap: 0.4em;
|
||
align-items: center;
|
||
}
|
||
|
||
.test-result {
|
||
font-size: 0.8em;
|
||
margin-top: 0.35em;
|
||
}
|
||
|
||
.test-result.ok {
|
||
color: var(--wa-color-success-fill-loud, #4c9f70);
|
||
}
|
||
|
||
.test-result.fail {
|
||
color: var(--wa-color-danger-fill-loud, #c65f5f);
|
||
}
|
||
|
||
.form {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.7em;
|
||
padding: 0.9em;
|
||
border: 1px solid var(--wa-color-surface-border, #333);
|
||
border-radius: 8px;
|
||
margin-top: 0.6em;
|
||
}
|
||
|
||
.form-actions {
|
||
display: flex;
|
||
gap: 0.5em;
|
||
justify-content: flex-end;
|
||
margin-top: 0.3em;
|
||
}
|
||
|
||
.requires {
|
||
font-size: 0.82em;
|
||
opacity: 0.75;
|
||
}
|
||
|
||
.empty {
|
||
opacity: 0.7;
|
||
font-size: 0.9em;
|
||
padding: 0.5em 0;
|
||
}
|
||
|
||
.add-row {
|
||
margin-top: 0.8em;
|
||
}
|
||
|
||
.field-row {
|
||
display: flex;
|
||
gap: 0.5em;
|
||
align-items: flex-end;
|
||
}
|
||
|
||
.field-row wa-input {
|
||
flex: 1;
|
||
}
|
||
|
||
.field-row .browse-button {
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.format-options {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.4em 1em;
|
||
margin-top: 0.4em;
|
||
}
|
||
|
||
/* The checkbox is 16x16 and cannot grow without becoming
|
||
a 44px checkbox, but it is already wrapped in the label
|
||
that names it -- so the label is the target and only
|
||
needs the height (#186). Eight of them. */
|
||
.format-option {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.4em;
|
||
font-size: 0.9em;
|
||
cursor: pointer;
|
||
min-block-size: 44px;
|
||
}
|
||
`,
|
||
];
|
||
|
||
override render() {
|
||
return html`
|
||
<config-section
|
||
heading="Download Clients"
|
||
description="Connect services you already run to search for and download music. Nothing is enabled until you add a client."
|
||
>
|
||
${this.errorMessage
|
||
? html`<wa-callout variant="danger">${this.errorMessage}</wa-callout>`
|
||
: nothing}
|
||
|
||
<div class="clients">
|
||
${this.providers.length === 0 && this.editing !== 'new'
|
||
? html`<div class="empty">No download clients connected.</div>`
|
||
: nothing}
|
||
${this.providers.map((provider) => this.renderProvider(provider))}
|
||
</div>
|
||
|
||
${this.editing === 'new'
|
||
? this.renderAddForm()
|
||
: html`
|
||
<div class="add-row">
|
||
<wa-button size="small" @click=${this.startAdd}>
|
||
Add download client
|
||
</wa-button>
|
||
</div>
|
||
`}
|
||
|
||
<!--
|
||
The Downloads view already shows every download's
|
||
lifecycle state; what it has never had is pause,
|
||
cancel and the log, which the Jobs tab carried (#27).
|
||
Renders nothing while nothing is downloading.
|
||
-->
|
||
<job-panel
|
||
kinds="download"
|
||
heading="Downloads in progress"
|
||
></job-panel>
|
||
</config-section>
|
||
|
||
<config-section
|
||
heading="Auto-download preferences"
|
||
description="Guardrails on what the pipeline may grab without asking — a manual pick is never restricted by these, only automatic ones."
|
||
>
|
||
${this.prefsError
|
||
? html`<wa-callout variant="danger">${this.prefsError}</wa-callout>`
|
||
: nothing}
|
||
|
||
<div class="form">
|
||
<!-- Bitrate, not megabytes. A size means nothing
|
||
on its own: 300 MB is a generous single and a
|
||
suspiciously small boxset, and whoever fills
|
||
this in has no idea which release it will be
|
||
applied to. A rate is the same statement
|
||
divided by how long the music is, so one number
|
||
holds across an EP and an opera. -->
|
||
<div class="field-row">
|
||
<wa-input
|
||
label="Minimum bitrate (kbps)"
|
||
type="number"
|
||
min="0"
|
||
placeholder="No minimum"
|
||
.value=${this.prefs.minKbps ? String(this.prefs.minKbps) : ''}
|
||
@input=${(e: Event) => {
|
||
this.prefs = {
|
||
...this.prefs,
|
||
minKbps: Number((e.target as HTMLInputElement).value) || 0,
|
||
};
|
||
}}
|
||
></wa-input>
|
||
<wa-input
|
||
label="Maximum bitrate (kbps)"
|
||
type="number"
|
||
min="0"
|
||
placeholder="No maximum"
|
||
.value=${this.prefs.maxKbps ? String(this.prefs.maxKbps) : ''}
|
||
@input=${(e: Event) => {
|
||
this.prefs = {
|
||
...this.prefs,
|
||
maxKbps: Number((e.target as HTMLInputElement).value) || 0,
|
||
};
|
||
}}
|
||
></wa-input>
|
||
<wa-input
|
||
label="Preferred bitrate (kbps)"
|
||
type="number"
|
||
min="0"
|
||
placeholder="No preference"
|
||
.value=${this.prefs.preferredKbps
|
||
? String(this.prefs.preferredKbps)
|
||
: ''}
|
||
@input=${(e: Event) => {
|
||
this.prefs = {
|
||
...this.prefs,
|
||
preferredKbps:
|
||
Number((e.target as HTMLInputElement).value) || 0,
|
||
};
|
||
}}
|
||
></wa-input>
|
||
</div>
|
||
|
||
<div class="requires">
|
||
320 is the top of MP3; a FLAC rip is usually
|
||
500–1000 depending on the music. Preferred
|
||
decides between copies that are otherwise equally
|
||
good — it never rules one out, which is what the
|
||
minimum and maximum are for.
|
||
</div>
|
||
|
||
<div class="field-row">
|
||
<wa-input
|
||
label="Never grab more than (MB)"
|
||
type="number"
|
||
min="0"
|
||
placeholder="No limit"
|
||
.value=${this.prefs.maxSizeMb ? String(this.prefs.maxSizeMb) : ''}
|
||
@input=${(e: Event) => {
|
||
this.prefs = {
|
||
...this.prefs,
|
||
maxSizeMb: Number((e.target as HTMLInputElement).value) || 0,
|
||
};
|
||
}}
|
||
></wa-input>
|
||
</div>
|
||
|
||
<div class="requires">
|
||
A ceiling on the download itself, in case a
|
||
mislabelled boxset gets through. Still a size
|
||
because it is a question about disk space, and
|
||
because it has to apply to a candidate whose
|
||
bitrate cannot be worked out at all.
|
||
</div>
|
||
|
||
<div>
|
||
<div class="requires">
|
||
Allowed formats — leave all unchecked to allow any format.
|
||
</div>
|
||
<div class="format-options">
|
||
${AUTO_DOWNLOAD_FORMATS.map(
|
||
(format) => html`
|
||
<label class="format-option">
|
||
<input
|
||
type="checkbox"
|
||
.checked=${(this.prefs.allowedFormats ?? []).includes(
|
||
format.value,
|
||
)}
|
||
@change=${(e: Event) =>
|
||
this.toggleFormat(
|
||
format.value,
|
||
(e.target as HTMLInputElement).checked,
|
||
)}
|
||
/>
|
||
${format.label}
|
||
</label>
|
||
`,
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-actions">
|
||
${this.prefsSaved
|
||
? html`<span class="test-result ok">Saved.</span>`
|
||
: nothing}
|
||
<wa-button
|
||
size="small"
|
||
variant="brand"
|
||
?disabled=${this.prefsSaving}
|
||
@click=${this.savePreferences}
|
||
>
|
||
${this.prefsSaving
|
||
? html`<wa-spinner></wa-spinner>`
|
||
: 'Save preferences'}
|
||
</wa-button>
|
||
</div>
|
||
</div>
|
||
</config-section>
|
||
`;
|
||
}
|
||
|
||
private renderProvider(provider: DownloadProvider) {
|
||
const descriptor = this.descriptorFor(provider.kind);
|
||
const test = this.testResults[provider.id];
|
||
|
||
if (this.editing === provider.id) {
|
||
return this.renderEditForm(provider);
|
||
}
|
||
|
||
return html`
|
||
<div class="client">
|
||
<div>
|
||
<div class="client-name">${provider.name}</div>
|
||
<div class="client-meta">
|
||
${descriptor?.name ?? provider.kind} ·
|
||
${provider.enabled ? 'Enabled' : 'Disabled'} ·
|
||
priority ${provider.priority}
|
||
</div>
|
||
${test
|
||
? html`<div class="test-result ${test.ok ? 'ok' : 'fail'}">
|
||
${test.message}
|
||
</div>`
|
||
: nothing}
|
||
</div>
|
||
<div class="client-actions">
|
||
<wa-button
|
||
size="small"
|
||
appearance="plain"
|
||
?disabled=${this.testing === provider.id}
|
||
@click=${() => this.testProvider(provider)}
|
||
>
|
||
${this.testing === provider.id
|
||
? html`<wa-spinner></wa-spinner>`
|
||
: 'Test'}
|
||
</wa-button>
|
||
<wa-button
|
||
size="small"
|
||
appearance="plain"
|
||
@click=${() => this.startEdit(provider)}
|
||
>
|
||
Edit
|
||
</wa-button>
|
||
<wa-button
|
||
size="small"
|
||
appearance="plain"
|
||
variant="danger"
|
||
@click=${() => void this.deleteProvider(provider)}
|
||
>
|
||
Remove
|
||
</wa-button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
private renderAddForm() {
|
||
const descriptor = this.descriptorFor(this.newKind);
|
||
|
||
return html`
|
||
<div class="form">
|
||
<wa-select
|
||
label="Client type"
|
||
.value=${this.newKind}
|
||
@change=${this.onKindChange}
|
||
>
|
||
${this.descriptors.map(
|
||
(d) => html`<wa-option value=${d.kind}>${d.name}</wa-option>`,
|
||
)}
|
||
</wa-select>
|
||
|
||
${descriptor
|
||
? html`
|
||
<div class="requires">
|
||
${descriptor.summary}
|
||
${descriptor.requiresExternal
|
||
? html`<br />Requires a running
|
||
${descriptor.requiresExternal} instance.`
|
||
: nothing}
|
||
</div>
|
||
|
||
<wa-input
|
||
label="Name"
|
||
.value=${this.draftName}
|
||
@input=${(e: Event) => {
|
||
this.draftName = (e.target as HTMLInputElement).value;
|
||
}}
|
||
></wa-input>
|
||
|
||
${this.renderFields(descriptor)}
|
||
`
|
||
: nothing}
|
||
|
||
<div class="form-actions">
|
||
<wa-button size="small" appearance="plain" @click=${this.cancelEdit}>
|
||
Cancel
|
||
</wa-button>
|
||
<wa-button
|
||
size="small"
|
||
variant="brand"
|
||
?disabled=${!descriptor}
|
||
@click=${this.saveNew}
|
||
>
|
||
Add
|
||
</wa-button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
private renderEditForm(provider: DownloadProvider) {
|
||
const descriptor = this.descriptorFor(provider.kind);
|
||
|
||
return html`
|
||
<div class="form">
|
||
<wa-input
|
||
label="Name"
|
||
.value=${this.draftName}
|
||
@input=${(e: Event) => {
|
||
this.draftName = (e.target as HTMLInputElement).value;
|
||
}}
|
||
></wa-input>
|
||
|
||
${descriptor ? this.renderFields(descriptor, provider) : nothing}
|
||
|
||
<wa-input
|
||
label="Priority"
|
||
type="number"
|
||
.value=${String(provider.priority)}
|
||
@input=${(e: Event) => {
|
||
this.draft['__priority'] = (e.target as HTMLInputElement).value;
|
||
}}
|
||
></wa-input>
|
||
|
||
<wa-switch
|
||
?checked=${provider.enabled}
|
||
@change=${(e: Event) => {
|
||
this.draft['__enabled'] = (e.target as HTMLInputElement)
|
||
.checked
|
||
? '1'
|
||
: '';
|
||
}}
|
||
>
|
||
Enabled
|
||
</wa-switch>
|
||
|
||
<div class="form-actions">
|
||
<wa-button size="small" appearance="plain" @click=${this.cancelEdit}>
|
||
Cancel
|
||
</wa-button>
|
||
<wa-button
|
||
size="small"
|
||
variant="brand"
|
||
@click=${() => this.saveEdit(provider)}
|
||
>
|
||
Save
|
||
</wa-button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
/** Renders one input per descriptor field, plus a folder browse
|
||
* button for path fields and an "already set" placeholder for
|
||
* secrets the provider already has a stored value for. */
|
||
private renderFields(descriptor: DownloadDescriptor, provider?: DownloadProvider) {
|
||
return (descriptor.fields ?? []).map((field) => {
|
||
const isSet = field.secret && provider?.setSecrets?.[field.key];
|
||
const placeholder = isSet
|
||
? '•••••••• (unchanged — enter a new value to replace it)'
|
||
: (field.placeholder ?? '');
|
||
|
||
return html`
|
||
<div class="field-row">
|
||
<wa-input
|
||
label=${field.label}
|
||
placeholder=${placeholder}
|
||
type=${field.secret ? 'password' : 'text'}
|
||
.value=${this.draft[field.key] ?? ''}
|
||
@input=${(e: Event) => {
|
||
this.draft = {
|
||
...this.draft,
|
||
[field.key]: (e.target as HTMLInputElement).value,
|
||
};
|
||
}}
|
||
>
|
||
${field.help ? html`<span slot="hint">${field.help}</span>` : nothing}
|
||
</wa-input>
|
||
${field.path
|
||
? html`
|
||
<wa-button
|
||
size="small"
|
||
appearance="outlined"
|
||
class="browse-button"
|
||
@click=${() => this.browseForFolder(field)}
|
||
>
|
||
Browse
|
||
</wa-button>
|
||
`
|
||
: nothing}
|
||
</div>
|
||
`;
|
||
});
|
||
}
|
||
|
||
private browseForFolder = async (field: ProviderField) => {
|
||
try {
|
||
const dir = await pickDirectory();
|
||
|
||
if (dir) {
|
||
this.draft = { ...this.draft, [field.key]: dir };
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to open directory picker:', err);
|
||
}
|
||
};
|
||
|
||
private descriptorFor(kind: string): DownloadDescriptor | undefined {
|
||
return this.descriptors.find((d) => d.kind === kind);
|
||
}
|
||
|
||
private startAdd = () => {
|
||
this.editing = 'new';
|
||
this.errorMessage = '';
|
||
this.newKind = this.descriptors[0]?.kind ?? '';
|
||
this.draftName = this.descriptorFor(this.newKind)?.name ?? '';
|
||
this.draft = this.defaultsFor(this.newKind);
|
||
};
|
||
|
||
private startEdit(provider: DownloadProvider) {
|
||
this.editing = provider.id;
|
||
this.errorMessage = '';
|
||
this.draftName = provider.name;
|
||
// Secrets are never sent back to the frontend, so their fields
|
||
// start blank; a blank secret on save means "leave it alone"
|
||
// rather than "clear it".
|
||
this.draft = compact(provider.settings);
|
||
}
|
||
|
||
private cancelEdit = () => {
|
||
this.editing = null;
|
||
this.draft = {};
|
||
this.errorMessage = '';
|
||
};
|
||
|
||
private onKindChange = (event: Event) => {
|
||
this.newKind = (event.target as HTMLInputElement).value;
|
||
this.draftName = this.descriptorFor(this.newKind)?.name ?? '';
|
||
this.draft = this.defaultsFor(this.newKind);
|
||
};
|
||
|
||
private defaultsFor(kind: string): Record<string, string> {
|
||
const descriptor = this.descriptorFor(kind);
|
||
const out: Record<string, string> = {};
|
||
|
||
for (const field of descriptor?.fields ?? []) {
|
||
if (field.default) out[field.key] = field.default;
|
||
}
|
||
|
||
return out;
|
||
}
|
||
|
||
private saveNew = async () => {
|
||
this.errorMessage = '';
|
||
|
||
try {
|
||
await downloadStore.addProvider(
|
||
this.newKind,
|
||
this.draftName || this.newKind,
|
||
this.cleanDraft(),
|
||
);
|
||
|
||
this.cancelEdit();
|
||
} catch (err) {
|
||
console.error('Failed to add download client:', err);
|
||
this.errorMessage = explainError(
|
||
err,
|
||
'That client could not be saved.',
|
||
);
|
||
}
|
||
};
|
||
|
||
private async saveEdit(provider: DownloadProvider) {
|
||
this.errorMessage = '';
|
||
|
||
const priority = this.draft['__priority']
|
||
? Number(this.draft['__priority'])
|
||
: provider.priority;
|
||
|
||
const enabled =
|
||
'__enabled' in this.draft
|
||
? this.draft['__enabled'] === '1'
|
||
: provider.enabled;
|
||
|
||
try {
|
||
await downloadStore.updateProvider(
|
||
provider.id,
|
||
this.draftName || provider.name,
|
||
enabled,
|
||
priority,
|
||
this.cleanDraft(),
|
||
);
|
||
|
||
this.cancelEdit();
|
||
} catch (err) {
|
||
console.error('Failed to save download client:', err);
|
||
this.errorMessage = explainError(
|
||
err,
|
||
'Those changes could not be saved.',
|
||
);
|
||
}
|
||
}
|
||
|
||
/** Strips the form's internal bookkeeping keys before saving. */
|
||
private cleanDraft(): Record<string, string> {
|
||
const out: Record<string, string> = {};
|
||
|
||
for (const [key, value] of Object.entries(this.draft)) {
|
||
if (!key.startsWith('__')) out[key] = value;
|
||
}
|
||
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Removing a client discards its stored credentials, which cannot
|
||
* be recovered — and it used to happen on one click (errors.m4).
|
||
*/
|
||
private async deleteProvider(provider: DownloadProvider) {
|
||
const ok = await confirmAction({
|
||
title: `Remove “${provider.name}”?`,
|
||
message:
|
||
'YellowJacket will stop using this client for downloads.',
|
||
impact:
|
||
'Its stored credentials are deleted and cannot be recovered.',
|
||
confirmLabel: 'Remove client',
|
||
danger: true,
|
||
});
|
||
|
||
if (!ok) return;
|
||
|
||
this.errorMessage = '';
|
||
|
||
try {
|
||
await downloadStore.deleteProvider(provider.id);
|
||
} catch (err) {
|
||
console.error('Failed to remove download client:', err);
|
||
this.errorMessage = describeError(
|
||
err,
|
||
'That client could not be removed.',
|
||
);
|
||
}
|
||
}
|
||
|
||
private async testProvider(provider: DownloadProvider) {
|
||
this.testing = provider.id;
|
||
|
||
try {
|
||
await downloadStore.testProvider(provider.id);
|
||
|
||
this.testResults = {
|
||
...this.testResults,
|
||
[provider.id]: { ok: true, message: 'Connected.' },
|
||
};
|
||
} catch (err) {
|
||
// Deliberately verbatim: a connection test's error is the
|
||
// user's debugging tool for a misconfigured client, and is
|
||
// the documented exception to describeError() (errors.M9).
|
||
this.testResults = {
|
||
...this.testResults,
|
||
[provider.id]: { ok: false, message: String(err) },
|
||
};
|
||
} finally {
|
||
this.testing = null;
|
||
}
|
||
}
|
||
|
||
private toggleFormat(format: Format, checked: boolean): void {
|
||
const current = this.prefs.allowedFormats ?? [];
|
||
const allowedFormats = checked
|
||
? [...current, format]
|
||
: current.filter((f) => f !== format);
|
||
|
||
this.prefs = { ...this.prefs, allowedFormats };
|
||
}
|
||
|
||
/**
|
||
* Saves the guardrails both to disk and to the running download
|
||
* manager in one action — persistence alone would leave the setting
|
||
* inert until restart, which is exactly the bug this mirrors away
|
||
* from (see `config.Library`'s prior persist-without-apply gap).
|
||
*/
|
||
private savePreferences = async () => {
|
||
this.prefsSaving = true;
|
||
this.prefsError = '';
|
||
this.prefsSaved = false;
|
||
|
||
try {
|
||
await SetDownloadPreferences(this.prefs);
|
||
await SetPreferences(this.prefs);
|
||
this.prefsSaved = true;
|
||
} catch (err) {
|
||
console.error('Failed to save download preferences:', err);
|
||
this.prefsError = describeError(
|
||
err,
|
||
'Those preferences could not be saved.',
|
||
);
|
||
} finally {
|
||
this.prefsSaving = false;
|
||
}
|
||
};
|
||
}
|
||
|
||
declare global {
|
||
interface HTMLElementTagNameMap {
|
||
'download-clients': DownloadClients;
|
||
}
|
||
}
|