mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-14 13:55:55 -07:00
feat(config): add configuration window (#70)
This commit is contained in:
+127
-191
@@ -6,7 +6,12 @@ import type {
|
||||
ConfigSettingsSnapshot,
|
||||
ConfigSettingsSnapshotValue,
|
||||
} from '../types/settings';
|
||||
import { parseOptionalNumberInputValue } from './input-values';
|
||||
import {
|
||||
configureSettingsControls,
|
||||
initializeSettingsControls,
|
||||
renderControl,
|
||||
renderNoteFieldModelPicker,
|
||||
} from './settings-controls';
|
||||
import {
|
||||
createSettingsDraft,
|
||||
filterSettingsFields,
|
||||
@@ -15,6 +20,8 @@ import {
|
||||
setDraftValue,
|
||||
type SettingsDraft,
|
||||
} from './settings-model';
|
||||
import { getFieldTitleBadges } from './settings-field-layout';
|
||||
import { getSubtitleCssManagedConfigPaths, getSubtitleCssScopeForPath } from './subtitle-style-css';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -23,9 +30,9 @@ declare global {
|
||||
}
|
||||
|
||||
const CATEGORY_LABELS: Record<ConfigSettingsCategory, string> = {
|
||||
viewing: 'Viewing',
|
||||
appearance: 'Appearance',
|
||||
behavior: 'Behavior',
|
||||
'mining-anki': 'Mining & Anki',
|
||||
'playback-sources': 'Playback & Sources',
|
||||
input: 'Input',
|
||||
integrations: 'Integrations',
|
||||
'tracking-app': 'Tracking & App',
|
||||
@@ -33,9 +40,9 @@ const CATEGORY_LABELS: Record<ConfigSettingsCategory, string> = {
|
||||
};
|
||||
|
||||
const CATEGORY_ORDER: ConfigSettingsCategory[] = [
|
||||
'viewing',
|
||||
'appearance',
|
||||
'behavior',
|
||||
'mining-anki',
|
||||
'playback-sources',
|
||||
'input',
|
||||
'integrations',
|
||||
'tracking-app',
|
||||
@@ -51,7 +58,7 @@ const state: {
|
||||
} = {
|
||||
snapshot: null,
|
||||
draft: null,
|
||||
category: 'viewing',
|
||||
category: 'appearance',
|
||||
query: '',
|
||||
inputErrors: new Map(),
|
||||
};
|
||||
@@ -69,19 +76,12 @@ const dom = {
|
||||
categoryTitle: getElement<HTMLHeadingElement>('categoryTitle'),
|
||||
categoryMeta: getElement<HTMLElement>('categoryMeta'),
|
||||
searchInput: getElement<HTMLInputElement>('searchInput'),
|
||||
openFileButton: getElement<HTMLButtonElement>('openFileButton'),
|
||||
saveButton: getElement<HTMLButtonElement>('saveButton'),
|
||||
statusBanner: getElement<HTMLElement>('statusBanner'),
|
||||
warningsPanel: getElement<HTMLElement>('warningsPanel'),
|
||||
settingsContent: getElement<HTMLElement>('settingsContent'),
|
||||
};
|
||||
|
||||
function isSecretSnapshotValue(
|
||||
value: ConfigSettingsSnapshotValue,
|
||||
): value is { configured: boolean } {
|
||||
return Boolean(value && typeof value === 'object' && 'configured' in value);
|
||||
}
|
||||
|
||||
function setStatus(message: string, tone: 'info' | 'error' | 'success' = 'info'): void {
|
||||
dom.statusBanner.textContent = message;
|
||||
dom.statusBanner.className = `status-banner ${tone}`;
|
||||
@@ -113,26 +113,20 @@ function createElement<K extends keyof HTMLElementTagNameMap>(
|
||||
return element;
|
||||
}
|
||||
|
||||
function createFieldMeta(field: ConfigSettingsField): HTMLElement {
|
||||
const meta = createElement('div', 'field-meta');
|
||||
const path = createElement('code');
|
||||
path.textContent = field.configPath;
|
||||
meta.append(path);
|
||||
|
||||
const restart = createElement('span', `restart-chip ${field.restartBehavior}`);
|
||||
restart.textContent = field.restartBehavior === 'hot-reload' ? 'Live' : 'Restart';
|
||||
meta.append(restart);
|
||||
|
||||
if (field.advanced) {
|
||||
const advanced = createElement('span', 'advanced-chip');
|
||||
advanced.textContent = 'Advanced';
|
||||
meta.append(advanced);
|
||||
function valueForField(field: ConfigSettingsField): ConfigSettingsSnapshotValue {
|
||||
if (!state.draft) {
|
||||
return field.defaultValue;
|
||||
}
|
||||
return meta;
|
||||
return Object.hasOwn(state.draft.values, field.configPath)
|
||||
? state.draft.values[field.configPath]
|
||||
: field.defaultValue;
|
||||
}
|
||||
|
||||
function valueForField(field: ConfigSettingsField): ConfigSettingsSnapshotValue {
|
||||
return state.draft?.values[field.configPath] ?? field.defaultValue;
|
||||
function valueForPath(path: string): ConfigSettingsSnapshotValue | undefined {
|
||||
if (!state.draft || !Object.hasOwn(state.draft.values, path)) {
|
||||
return undefined;
|
||||
}
|
||||
return state.draft.values[path];
|
||||
}
|
||||
|
||||
function setFieldError(path: string, message: string | null): void {
|
||||
@@ -150,126 +144,11 @@ function updateDraft(path: string, value: ConfigSettingsSnapshotValue): void {
|
||||
syncSaveButton();
|
||||
}
|
||||
|
||||
function renderJsonInput(
|
||||
field: ConfigSettingsField,
|
||||
value: ConfigSettingsSnapshotValue,
|
||||
): HTMLElement {
|
||||
const textarea = createElement('textarea', 'config-textarea') as HTMLTextAreaElement;
|
||||
textarea.spellcheck = false;
|
||||
textarea.value = JSON.stringify(value ?? {}, null, 2);
|
||||
textarea.addEventListener('input', () => {
|
||||
try {
|
||||
updateDraft(field.configPath, JSON.parse(textarea.value));
|
||||
textarea.classList.remove('invalid');
|
||||
setFieldError(field.configPath, null);
|
||||
} catch {
|
||||
textarea.classList.add('invalid');
|
||||
setFieldError(field.configPath, 'Invalid JSON');
|
||||
}
|
||||
});
|
||||
return textarea;
|
||||
}
|
||||
|
||||
function renderStringListInput(
|
||||
field: ConfigSettingsField,
|
||||
value: ConfigSettingsSnapshotValue,
|
||||
): HTMLElement {
|
||||
const textarea = createElement('textarea', 'config-textarea compact') as HTMLTextAreaElement;
|
||||
textarea.spellcheck = false;
|
||||
textarea.value = Array.isArray(value) ? value.join('\n') : '';
|
||||
textarea.addEventListener('input', () => {
|
||||
updateDraft(
|
||||
field.configPath,
|
||||
textarea.value
|
||||
.split('\n')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
});
|
||||
return textarea;
|
||||
}
|
||||
|
||||
function renderControl(field: ConfigSettingsField): HTMLElement {
|
||||
const value = valueForField(field);
|
||||
|
||||
if (field.control === 'boolean') {
|
||||
const label = createElement('label', 'switch-control');
|
||||
const input = createElement('input') as HTMLInputElement;
|
||||
input.type = 'checkbox';
|
||||
input.checked = Boolean(value);
|
||||
input.addEventListener('change', () => updateDraft(field.configPath, input.checked));
|
||||
const track = createElement('span', 'switch-track');
|
||||
label.append(input, track);
|
||||
return label;
|
||||
}
|
||||
|
||||
if (field.control === 'number') {
|
||||
const input = createElement('input', 'config-input') as HTMLInputElement;
|
||||
input.type = 'number';
|
||||
input.value = typeof value === 'number' ? String(value) : '';
|
||||
input.addEventListener('input', () => {
|
||||
const next = parseOptionalNumberInputValue(input.value);
|
||||
if (next.ok) {
|
||||
input.classList.remove('invalid');
|
||||
setFieldError(field.configPath, null);
|
||||
updateDraft(field.configPath, next.value);
|
||||
} else {
|
||||
input.classList.add('invalid');
|
||||
setFieldError(field.configPath, 'Invalid number');
|
||||
}
|
||||
});
|
||||
return input;
|
||||
}
|
||||
|
||||
if (field.control === 'select') {
|
||||
const select = createElement('select', 'config-input') as HTMLSelectElement;
|
||||
for (const enumValue of field.enumValues ?? []) {
|
||||
const option = createElement('option') as HTMLOptionElement;
|
||||
option.value = enumValue;
|
||||
option.textContent = enumValue;
|
||||
option.selected = enumValue === value;
|
||||
select.append(option);
|
||||
}
|
||||
select.addEventListener('change', () => updateDraft(field.configPath, select.value));
|
||||
return select;
|
||||
}
|
||||
|
||||
if (field.control === 'string-list') {
|
||||
return renderStringListInput(field, value);
|
||||
}
|
||||
|
||||
if (field.control === 'json') {
|
||||
return renderJsonInput(field, value);
|
||||
}
|
||||
|
||||
if (field.control === 'textarea') {
|
||||
const textarea = createElement('textarea', 'config-textarea compact') as HTMLTextAreaElement;
|
||||
textarea.spellcheck = false;
|
||||
textarea.value = typeof value === 'string' ? value : '';
|
||||
textarea.addEventListener('input', () => updateDraft(field.configPath, textarea.value));
|
||||
return textarea;
|
||||
}
|
||||
|
||||
const input = createElement('input', 'config-input') as HTMLInputElement;
|
||||
input.type = field.control === 'secret' ? 'password' : field.control;
|
||||
if (field.control === 'secret') {
|
||||
input.placeholder =
|
||||
isSecretSnapshotValue(value) && value.configured ? 'Configured' : 'Not configured';
|
||||
input.addEventListener('input', () => {
|
||||
if (input.value.trim().length === 0) {
|
||||
if (state.draft) {
|
||||
setDraftValue(state.draft, field.configPath, state.draft.initialValues[field.configPath]);
|
||||
}
|
||||
syncSaveButton();
|
||||
return;
|
||||
}
|
||||
updateDraft(field.configPath, input.value);
|
||||
});
|
||||
} else {
|
||||
input.value = typeof value === 'string' ? value : '';
|
||||
input.addEventListener('input', () => updateDraft(field.configPath, input.value));
|
||||
}
|
||||
return input;
|
||||
function resetDraftPathContext(path: string, defaultValue?: ConfigSettingsSnapshotValue): void {
|
||||
if (!state.draft) return;
|
||||
resetDraftPath(state.draft, path, defaultValue);
|
||||
state.inputErrors.delete(path);
|
||||
syncSaveButton();
|
||||
}
|
||||
|
||||
function renderWarnings(snapshot: ConfigSettingsSnapshot): void {
|
||||
@@ -301,7 +180,7 @@ function renderCategoryNav(snapshot: ConfigSettingsSnapshot): void {
|
||||
dom.categoryNav.replaceChildren();
|
||||
for (const category of CATEGORY_ORDER) {
|
||||
const count = snapshot.fields.filter(
|
||||
(field) => field.category === category && !field.legacyHidden,
|
||||
(field) => field.category === category && !field.legacyHidden && !field.settingsHidden,
|
||||
).length;
|
||||
if (count === 0) continue;
|
||||
const button = createElement('button', 'category-button') as HTMLButtonElement;
|
||||
@@ -315,6 +194,7 @@ function renderCategoryNav(snapshot: ConfigSettingsSnapshot): void {
|
||||
button.addEventListener('click', () => {
|
||||
state.category = category;
|
||||
render();
|
||||
dom.settingsContent.scrollTop = 0;
|
||||
});
|
||||
dom.categoryNav.append(button);
|
||||
}
|
||||
@@ -324,19 +204,40 @@ function renderField(field: ConfigSettingsField): HTMLElement {
|
||||
const row = createElement('article', 'field-row');
|
||||
const header = createElement('div', 'field-copy');
|
||||
const label = createElement('h3');
|
||||
label.textContent = field.label;
|
||||
const labelText = createElement('span', 'field-title-text');
|
||||
labelText.textContent = field.label;
|
||||
label.append(labelText);
|
||||
for (const badge of getFieldTitleBadges(field)) {
|
||||
const badgeEl = createElement('span', badge.className);
|
||||
badgeEl.textContent = badge.text;
|
||||
label.append(badgeEl);
|
||||
}
|
||||
const description = createElement('p');
|
||||
description.textContent = field.description;
|
||||
header.append(label, description, createFieldMeta(field));
|
||||
header.append(label, description);
|
||||
|
||||
const controlWrap = createElement('div', 'field-control');
|
||||
controlWrap.append(renderControl(field));
|
||||
controlWrap.append(
|
||||
renderControl(field, {
|
||||
setFieldError,
|
||||
resetDraftPath: resetDraftPathContext,
|
||||
updateDraft,
|
||||
valueForField,
|
||||
valueForPath,
|
||||
}),
|
||||
);
|
||||
const resetButton = createElement('button', 'reset-button') as HTMLButtonElement;
|
||||
resetButton.type = 'button';
|
||||
resetButton.textContent = 'Reset';
|
||||
resetButton.addEventListener('click', () => {
|
||||
if (!state.draft) return;
|
||||
resetDraftPath(state.draft, field.configPath, field.defaultValue);
|
||||
const cssScope = getSubtitleCssScopeForPath(field.configPath);
|
||||
if (cssScope) {
|
||||
for (const path of getSubtitleCssManagedConfigPaths(cssScope)) {
|
||||
resetDraftPath(state.draft, path, undefined);
|
||||
}
|
||||
}
|
||||
state.inputErrors.delete(field.configPath);
|
||||
render();
|
||||
});
|
||||
@@ -347,13 +248,24 @@ function renderField(field: ConfigSettingsField): HTMLElement {
|
||||
|
||||
function renderSettingsContent(snapshot: ConfigSettingsSnapshot): void {
|
||||
dom.settingsContent.replaceChildren();
|
||||
const query = state.query.trim();
|
||||
const fields = filterSettingsFields(snapshot.fields, {
|
||||
category: state.category,
|
||||
query: state.query,
|
||||
category: query ? undefined : state.category,
|
||||
query,
|
||||
});
|
||||
|
||||
dom.categoryTitle.textContent = CATEGORY_LABELS[state.category];
|
||||
dom.categoryMeta.textContent = `${fields.length} setting${fields.length === 1 ? '' : 's'}`;
|
||||
if (query) {
|
||||
const categoryCount = new Set(fields.map((field) => field.category)).size;
|
||||
dom.categoryTitle.textContent = 'Search results';
|
||||
dom.categoryMeta.textContent = `${fields.length} setting${fields.length === 1 ? '' : 's'}${
|
||||
categoryCount > 0
|
||||
? ` across ${categoryCount} categor${categoryCount === 1 ? 'y' : 'ies'}`
|
||||
: ''
|
||||
}`;
|
||||
} else {
|
||||
dom.categoryTitle.textContent = CATEGORY_LABELS[state.category];
|
||||
dom.categoryMeta.textContent = `${fields.length} setting${fields.length === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
if (fields.length === 0) {
|
||||
const empty = createElement('div', 'empty-state');
|
||||
@@ -362,19 +274,41 @@ function renderSettingsContent(snapshot: ConfigSettingsSnapshot): void {
|
||||
return;
|
||||
}
|
||||
|
||||
const sections = new Map<string, ConfigSettingsField[]>();
|
||||
const sections = new Map<
|
||||
string,
|
||||
{ title: string; rawSection: string; fields: ConfigSettingsField[] }
|
||||
>();
|
||||
for (const field of fields) {
|
||||
const sectionFields = sections.get(field.section) ?? [];
|
||||
sectionFields.push(field);
|
||||
sections.set(field.section, sectionFields);
|
||||
const title = query ? `${CATEGORY_LABELS[field.category]} / ${field.section}` : field.section;
|
||||
const section = sections.get(title) ?? { title, rawSection: field.section, fields: [] };
|
||||
section.fields.push(field);
|
||||
sections.set(title, section);
|
||||
}
|
||||
|
||||
for (const [section, sectionFields] of sections) {
|
||||
for (const section of sections.values()) {
|
||||
const sectionEl = createElement('section', 'settings-section');
|
||||
const title = createElement('h2');
|
||||
title.textContent = section;
|
||||
title.textContent = section.title;
|
||||
sectionEl.append(title);
|
||||
for (const field of sectionFields) {
|
||||
if (section.rawSection === 'Note Fields') {
|
||||
sectionEl.append(
|
||||
renderNoteFieldModelPicker({
|
||||
setFieldError,
|
||||
resetDraftPath: resetDraftPathContext,
|
||||
updateDraft,
|
||||
valueForField,
|
||||
valueForPath,
|
||||
}),
|
||||
);
|
||||
}
|
||||
let currentSubsection = '';
|
||||
for (const field of section.fields) {
|
||||
if (field.subsection && field.subsection !== currentSubsection) {
|
||||
currentSubsection = field.subsection;
|
||||
const subsectionTitle = createElement('h3', 'settings-subsection-title');
|
||||
subsectionTitle.textContent = field.subsection;
|
||||
sectionEl.append(subsectionTitle);
|
||||
}
|
||||
sectionEl.append(renderField(field));
|
||||
}
|
||||
dom.settingsContent.append(sectionEl);
|
||||
@@ -390,11 +324,14 @@ function render(): void {
|
||||
syncSaveButton();
|
||||
}
|
||||
|
||||
configureSettingsControls({ requestRender: render });
|
||||
|
||||
async function loadSnapshot(): Promise<void> {
|
||||
clearStatus();
|
||||
const snapshot = await window.configSettingsAPI.getSnapshot();
|
||||
state.snapshot = snapshot;
|
||||
state.draft = createSettingsDraft(snapshot.values);
|
||||
initializeSettingsControls(snapshot.values);
|
||||
state.inputErrors.clear();
|
||||
render();
|
||||
}
|
||||
@@ -406,34 +343,36 @@ async function save(): Promise<void> {
|
||||
|
||||
dom.saveButton.disabled = true;
|
||||
setStatus('Saving...', 'info');
|
||||
let result;
|
||||
try {
|
||||
const result = await window.configSettingsAPI.savePatch({ operations });
|
||||
if (!result.ok || !result.snapshot) {
|
||||
const message =
|
||||
result.error ??
|
||||
result.warnings?.map((warning) => `${warning.path}: ${warning.message}`).join('\n') ??
|
||||
'Save failed';
|
||||
setStatus(message, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
state.snapshot = result.snapshot;
|
||||
state.draft = createSettingsDraft(result.snapshot.values);
|
||||
state.inputErrors.clear();
|
||||
const restartSections = result.restartRequiredSections ?? [];
|
||||
if (restartSections.length > 0) {
|
||||
setStatus(`Saved. Restart required: ${restartSections.join(', ')}`, 'info');
|
||||
} else if (result.hotReloadFields.length > 0) {
|
||||
setStatus('Saved. Live settings applied.', 'success');
|
||||
} else {
|
||||
setStatus('Saved.', 'success');
|
||||
}
|
||||
render();
|
||||
result = await window.configSettingsAPI.savePatch({ operations });
|
||||
} catch (error) {
|
||||
setStatus(error instanceof Error ? error.message : 'Save failed', 'error');
|
||||
} finally {
|
||||
syncSaveButton();
|
||||
return;
|
||||
}
|
||||
if (!result.ok || !result.snapshot) {
|
||||
const message =
|
||||
result.error ??
|
||||
result.warnings?.map((warning) => `${warning.path}: ${warning.message}`).join('\n') ??
|
||||
'Save failed';
|
||||
setStatus(message, 'error');
|
||||
syncSaveButton();
|
||||
return;
|
||||
}
|
||||
|
||||
state.snapshot = result.snapshot;
|
||||
state.draft = createSettingsDraft(result.snapshot.values);
|
||||
state.inputErrors.clear();
|
||||
const restartSections = result.restartRequiredSections ?? [];
|
||||
if (restartSections.length > 0) {
|
||||
setStatus(`Saved. Restart required: ${restartSections.join(', ')}`, 'info');
|
||||
} else if (result.hotReloadFields.length > 0) {
|
||||
setStatus('Saved. Live settings applied.', 'success');
|
||||
} else {
|
||||
setStatus('Saved.', 'success');
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
dom.searchInput.addEventListener('input', () => {
|
||||
@@ -443,9 +382,6 @@ dom.searchInput.addEventListener('input', () => {
|
||||
dom.saveButton.addEventListener('click', () => {
|
||||
void save();
|
||||
});
|
||||
dom.openFileButton.addEventListener('click', () => {
|
||||
void window.configSettingsAPI.openSettingsFile();
|
||||
});
|
||||
|
||||
void loadSnapshot().catch((error) => {
|
||||
setStatus(error instanceof Error ? error.message : 'Failed to load settings', 'error');
|
||||
|
||||
Reference in New Issue
Block a user