On an AutoDL server civitai.com timed out while civitai.red, Civitai's other address with the same API, answered in about a second, so scans there fell back to guessing from the files. civitai_client.api_hosts gives both addresses, the one that answered last first; the scan's lookups, import's and Model Check's (download_sources._civitai_json) and the network test ask the other one when the first does not answer at all. An HTTP error is Civitai's own answer and is not asked again. The test answers which host answered, and Settings says so when it was civitai.red. The pictures stay on image.civitai.com, which such networks often cannot reach either: after COVERS_DOWN_AFTER (2) covers in a row that would not download, a scan fetches no more covers instead of waiting on each. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
525 lines
24 KiB
JavaScript
525 lines
24 KiB
JavaScript
/**
|
|
* The settings page (the rail's gear), in tabs (`owner.settingsTab`, kept while the page is
|
|
* away): Look (how the browser looks, model cards and what they cost in memory with the card
|
|
* image cache, how the browser opens), Models (model folders — a view of its own,
|
|
* ui_folder_manager.js, `owner.settingsView === 'folders'` —, the network: the proxy the plugin's
|
|
* own requests use and a Civitai connection test, and where downloads and imported models go), Workflows,
|
|
* Node packs (those not used lately, ui_pack_usage.js), Prompts (translation with a DeepL key), Backup (ui_backup.js) and
|
|
* Help. A tool page like the scan page, with a way back to the page you came from. Display
|
|
* preferences go through `owner.displayPrefs` (ui_settings_hub.js); language, theme and opening
|
|
* mode are ComfyUI settings.
|
|
*/
|
|
|
|
import { app } from "../../../scripts/app.js";
|
|
import { translate } from './locales.js';
|
|
import { LANGUAGE_SETTING_ID, applyLanguagePreference, setThemePreference, themePreference } from './interface_settings.js';
|
|
import { ENTRY_MODE_SETTING_ID } from './browser_entry.js';
|
|
import { showUpdateGuide } from './ui_update_guide.js';
|
|
import { startSpotlightTour } from './ui_spotlight_tour.js';
|
|
import { copyDiagnostics, openRepoPage } from './feedback.js';
|
|
import { renderFolderPage } from './ui_folder_manager.js';
|
|
import { openFeedbackDialog } from './ui_feedback_dialog.js';
|
|
import { checkOnOpen, setCheckOnOpen } from './ui_doctor_banner.js';
|
|
import { fetchDownloadSettings, hfMirrorOn, saveDownloadSettings } from './model_download.js';
|
|
import { saveApiKey } from './ui_scan_settings.js';
|
|
import { exportBackup, importBackup } from './ui_backup.js';
|
|
import { followsSelection, setFollowsSelection } from './shell_open_rules.js';
|
|
import { FOLDER_PRESETS, expandFolder } from './download_places.js';
|
|
import { createGalleryFormatSettings } from './ui_gallery_filter.js';
|
|
import { failureWords } from './net_failure.js';
|
|
import { renderPackUsage } from './ui_pack_usage.js';
|
|
|
|
const t = (key, params) => translate(key, params);
|
|
// The full written guide: the README, at its Chinese half for Chinese.
|
|
const README_URL = 'https://github.com/DemonGatanjieu/Anomalous_Model_Browser#readme';
|
|
const README_ZH_URL = 'https://github.com/DemonGatanjieu/Anomalous_Model_Browser#-视频演示与教程';
|
|
|
|
function el(tag, className, text) {
|
|
const node = document.createElement(tag);
|
|
if (className) node.className = className;
|
|
if (text !== undefined) node.textContent = text;
|
|
return node;
|
|
}
|
|
|
|
function button(className, label, onClick) {
|
|
const node = el('button', className, label);
|
|
node.type = 'button';
|
|
node.onclick = onClick;
|
|
return node;
|
|
}
|
|
|
|
function comfySetting(id) {
|
|
try { return app.extensionManager?.setting?.get(id); } catch (_) { return undefined; }
|
|
}
|
|
|
|
async function setComfySetting(id, value) {
|
|
const settings = app.extensionManager?.setting;
|
|
if (typeof settings?.set !== 'function') return false;
|
|
try {
|
|
await settings.set(id, value);
|
|
return true;
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/** A row: name and what it does on the left, its control on the right. */
|
|
function row(titleKey, helpKey, control, { help = null } = {}) {
|
|
const line = el('div', 'anomalous-scan-setting');
|
|
const copy = el('span', 'anomalous-scan-setting-copy');
|
|
copy.append(el('span', 'anomalous-scan-setting-title', t(titleKey)));
|
|
const helpText = help ?? (helpKey ? t(helpKey) : '');
|
|
if (helpText) copy.append(el('small', 'anomalous-scan-setting-help', helpText));
|
|
line.append(copy, control);
|
|
return line;
|
|
}
|
|
|
|
/** Choices side by side; `onPick(value)` when one is clicked. */
|
|
function segment(choices, current, onPick) {
|
|
const box = el('div', 'anomalous-scan-segment');
|
|
box.setAttribute('role', 'radiogroup');
|
|
for (const [value, labelKey] of choices) {
|
|
const choice = button('anomalous-scan-segment-btn', t(labelKey), () => onPick(value));
|
|
choice.setAttribute('role', 'radio');
|
|
choice.setAttribute('aria-checked', String(value === current));
|
|
box.append(choice);
|
|
}
|
|
return box;
|
|
}
|
|
|
|
function toggleSwitch(checked, onChange) {
|
|
const input = el('input', 'anomalous-scan-switch');
|
|
input.type = 'checkbox';
|
|
input.checked = checked;
|
|
input.onchange = () => onChange(input.checked);
|
|
return input;
|
|
}
|
|
|
|
/** A slider with its value shown as a percentage. */
|
|
function slider(value, { min, max, step }, onCommit) {
|
|
const box = el('label', 'anomalous-settings-slider');
|
|
const input = el('input');
|
|
input.type = 'range';
|
|
Object.assign(input, { min: String(min), max: String(max), step: String(step), value: String(value) });
|
|
const shown = el('span', 'anomalous-settings-slider-value', `${Math.round(value * 100)}%`);
|
|
input.oninput = () => { shown.textContent = `${Math.round(Number(input.value) * 100)}%`; onCommit(Number(input.value)); };
|
|
box.append(input, shown);
|
|
return box;
|
|
}
|
|
|
|
function group(titleKey, ...rows) {
|
|
const box = el('section', 'anomalous-scan-card anomalous-scan-group');
|
|
box.append(el('h3', 'anomalous-scan-group-title', t(titleKey)), ...rows);
|
|
return box;
|
|
}
|
|
|
|
function formatBytes(bytes) {
|
|
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
|
|
}
|
|
|
|
/** The card image cache: how much it holds, and a way to empty it. */
|
|
function cacheRow(owner) {
|
|
const clear = button('anomalous-scan-secondary anomalous-scan-small-btn', t('settingsCacheClear'), async () => {
|
|
clear.disabled = true;
|
|
try {
|
|
await fetch('/anomalous/card_cache/clear', { method: 'POST' });
|
|
} finally {
|
|
clear.disabled = false;
|
|
refresh();
|
|
}
|
|
});
|
|
const line = row('settingsCacheTitle', null, clear, { help: t('settingsCacheLoading') });
|
|
const help = line.querySelector('.anomalous-scan-setting-help');
|
|
const refresh = async () => {
|
|
try {
|
|
const data = await (await fetch('/anomalous/card_cache')).json();
|
|
help.textContent = t('settingsCacheHelp', { count: data.count, size: formatBytes(data.bytes), limit: formatBytes(data.limit) });
|
|
clear.disabled = !data.count;
|
|
} catch (_) {
|
|
help.textContent = t('settingsCacheUnknown');
|
|
}
|
|
};
|
|
refresh();
|
|
return line;
|
|
}
|
|
|
|
function appearanceGroup(owner, redraw) {
|
|
const prefs = owner.displayPrefs.get();
|
|
const language = localStorage.getItem('anomalous_lang') || 'auto';
|
|
const rows = [
|
|
row('sidebarViewMode', 'settingsViewModeHelp', segment(
|
|
[['compact', 'sidebarViewModeCompact'], ['standard', 'sidebarViewModeStandard'], ['aesthetic', 'sidebarViewModeAesthetic']],
|
|
prefs.viewMode, mode => { owner.displayPrefs.setViewMode(mode); redraw(); })),
|
|
];
|
|
if (prefs.viewMode === 'aesthetic') {
|
|
rows.push(row('sidebarBgAtmosphere', 'settingsAtmosphereHelp',
|
|
slider(prefs.bgOpacity, { min: 0, max: 1, step: 0.05 }, value => owner.displayPrefs.setBgOpacity(value))));
|
|
}
|
|
rows.push(
|
|
row('sidebarUiScale', 'settingsScaleHelp', slider(prefs.scale, { min: 0.5, max: 1.5, step: 0.1 }, value => owner.displayPrefs.setScale(value))),
|
|
row('settingsTheme', 'settingsThemeHelp', segment(
|
|
[['auto', 'settingsThemeAuto'], ['dark', 'settingsThemeDark'], ['light', 'settingsThemeLight'], ['abyssal', 'settingsThemeAbyssal']],
|
|
themePreference(), value => { setThemePreference(value, true); redraw(); })),
|
|
row('mainLanguageSetting', null, segment([['auto', 'mainLanguageAuto'], ['zh', 'mainLanguageChinese'], ['en', 'mainLanguageEnglish']],
|
|
language, async value => {
|
|
// The ComfyUI setting's own change handler switches the language; without it, switch here.
|
|
if (!await setComfySetting(LANGUAGE_SETTING_ID, value)) applyLanguagePreference(value);
|
|
})),
|
|
);
|
|
return group('settingsAppearance', ...rows);
|
|
}
|
|
|
|
function cardsGroup(owner, redraw) {
|
|
const reload = () => {
|
|
owner.galleryLoaded = false; // the gallery's video cards follow Video covers too
|
|
owner.loadModels();
|
|
redraw();
|
|
};
|
|
return group('settingsCards',
|
|
row('sidebarCardQuality', 'settingsCardQualityHelp', segment(
|
|
[['balanced', 'sidebarOptimizedThumbnail'], ['original', 'sidebarOriginalCover']], owner.cardThumbnailMode, value => {
|
|
owner.cardThumbnailMode = value === 'original' ? 'original' : 'balanced';
|
|
localStorage.setItem('anomalous_card_thumbnail_mode', owner.cardThumbnailMode);
|
|
reload();
|
|
})),
|
|
row('sidebarVideoPlayback', 'settingsVideoHelp', segment(
|
|
[['hover', 'sidebarHoverPlay'], ['always', 'sidebarAlwaysPlay']], owner.energySaving ? 'hover' : 'always', value => {
|
|
owner.energySaving = value === 'hover';
|
|
localStorage.setItem('anomalous_energy_saving', String(owner.energySaving));
|
|
reload();
|
|
})),
|
|
cacheRow(owner),
|
|
);
|
|
}
|
|
|
|
function galleryGroup(owner) {
|
|
return group('settingsGallery',
|
|
// The gallery lists again when it is next shown.
|
|
row('galleryFormatsTitle', 'galleryFormatsHelp', createGalleryFormatSettings(() => { owner.galleryLoaded = false; })),
|
|
);
|
|
}
|
|
|
|
function foldersGroup(owner) {
|
|
return group('settingsFolders',
|
|
row('sidebarManageFolders', 'settingsFoldersHelp',
|
|
button('anomalous-scan-secondary anomalous-scan-small-btn', t('settingsOpen'), () => {
|
|
owner.settingsView = 'folders';
|
|
render(owner);
|
|
})));
|
|
}
|
|
|
|
function workflowGroup(owner) {
|
|
return group('settingsWorkflows',
|
|
row('settingsCheckOnOpen', 'settingsCheckOnOpenHelp', toggleSwitch(checkOnOpen(), on => setCheckOnOpen(owner, on))),
|
|
row('sidebarProvenance', 'sidebarProvenanceDesc', toggleSwitch(localStorage.getItem('anomalous_inject_hash') !== 'false', on => {
|
|
// Read by hash_resolver.js when a workflow is saved.
|
|
localStorage.setItem('anomalous_inject_hash', on ? 'true' : 'false');
|
|
})));
|
|
}
|
|
|
|
/** The download folder: a ready-made choice, or one typed by hand; an example path below. */
|
|
function folderChoice(current) {
|
|
const box = el('div', 'anomalous-download-folder-choice');
|
|
const select = el('select', 'anomalous-download-root');
|
|
FOLDER_PRESETS.forEach((value, index) => {
|
|
const option = el('option', '', t(`settingsDownloadFolderPreset${index}`));
|
|
option.value = value;
|
|
select.append(option);
|
|
});
|
|
const custom = el('option', '', t('settingsDownloadFolderCustom'));
|
|
custom.value = '*'; // never a folder name
|
|
select.append(custom);
|
|
const input = el('input', 'anomalous-download-folder');
|
|
input.type = 'text';
|
|
input.spellcheck = false;
|
|
input.placeholder = t('settingsDownloadFolderRoot');
|
|
const example = el('small', 'anomalous-download-folder-example');
|
|
const show = (folder) => {
|
|
const preset = FOLDER_PRESETS.includes(folder);
|
|
select.value = preset && !input.dataset.typing ? folder : custom.value;
|
|
input.hidden = select.value !== custom.value;
|
|
input.value = folder;
|
|
const sub = expandFolder(folder, 'SDXL 1.0', []);
|
|
example.textContent = t('settingsDownloadFolderExample', { path: `loras/${sub ? `${sub}/` : ''}model.safetensors` });
|
|
};
|
|
const save = (folder) => saveDownloadSettings({ folder })
|
|
.then(saved => { input.classList.remove('is-bad'); show(saved.folder); })
|
|
.catch(() => input.classList.add('is-bad'));
|
|
select.onchange = () => {
|
|
if (select.value === custom.value) {
|
|
input.dataset.typing = '1';
|
|
input.hidden = false;
|
|
input.focus();
|
|
} else {
|
|
delete input.dataset.typing;
|
|
save(select.value);
|
|
}
|
|
};
|
|
input.onchange = () => save(input.value);
|
|
show(current);
|
|
box.append(select, input, example);
|
|
return box;
|
|
}
|
|
|
|
/** The proxy the plugin's own requests use (net_proxy.py; empty = the system's), and a test that Civitai answers. */
|
|
function networkGroup() {
|
|
const input = el('input', 'anomalous-download-folder');
|
|
input.type = 'text';
|
|
input.spellcheck = false;
|
|
input.placeholder = t('settingsProxyPlaceholder');
|
|
const proxyRow = row('settingsProxy', 'settingsProxyHelp', input);
|
|
const proxyHelp = proxyRow.querySelector('.anomalous-scan-setting-help');
|
|
fetch('/anomalous/config').then(res => res.json()).then(config => { input.value = config.proxy || ''; })
|
|
.catch(error => console.warn('[AMB] Settings: proxy unavailable.', error));
|
|
input.onchange = async () => {
|
|
let data = null;
|
|
try {
|
|
const res = await fetch('/anomalous/save_config', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ proxy: input.value }),
|
|
});
|
|
data = await res.json();
|
|
} catch (_) { /* shown below as not saved */ }
|
|
const saved = data?.status === 'ok';
|
|
input.classList.toggle('is-bad', !saved);
|
|
proxyHelp.textContent = saved ? t('settingsProxyHelp') : t(data?.code === 'socks' ? 'settingsProxySocks' : 'settingsProxyBad');
|
|
if (saved) input.value = (await fetch('/anomalous/config').then(res => res.json()).catch(() => ({}))).proxy ?? input.value;
|
|
};
|
|
|
|
const test = button('anomalous-scan-secondary anomalous-scan-small-btn', t('settingsNetworkTest'), async () => {
|
|
test.disabled = true;
|
|
testHelp.textContent = t('settingsNetworkTesting');
|
|
try {
|
|
const data = await (await fetch('/anomalous/civitai_check')).json();
|
|
testHelp.textContent = !data.reachable ? t('settingsNetworkFail', { why: failureWords(data.failure) })
|
|
: data.key === 'refused' ? t('settingsNetworkKeyRefused')
|
|
: t(data.host === 'civitai.red' ? 'settingsNetworkOkRed' : 'settingsNetworkOk', { ms: data.ms });
|
|
} catch (error) {
|
|
testHelp.textContent = t('settingsNetworkFail', { why: String(error.message || error) });
|
|
} finally {
|
|
test.disabled = false;
|
|
}
|
|
});
|
|
const testRow = row('settingsNetworkTest', 'settingsNetworkTestHelp', test);
|
|
const testHelp = testRow.querySelector('.anomalous-scan-setting-help');
|
|
return group('settingsNetwork', proxyRow, testRow);
|
|
}
|
|
|
|
/** Where downloads and imported models go (model_download.js); filled once the settings are read. */
|
|
function downloadGroup(owner, redraw) {
|
|
const box = group('settingsDownloads');
|
|
fetchDownloadSettings().then(settings => {
|
|
box.append(
|
|
row('settingsDownloadPlace', 'settingsDownloadPlaceHelp', segment(
|
|
[['workflow', 'settingsDownloadPlaceWorkflow'], ['folder', 'settingsDownloadPlaceFolder']], settings.place,
|
|
value => saveDownloadSettings({ place: value }).then(redraw).catch(() => {}))),
|
|
row('settingsDownloadFolder', 'settingsDownloadFolderHelp', folderChoice(settings.folder)),
|
|
row('settingsHfMirror', 'settingsHfMirrorHelp', toggleSwitch(hfMirrorOn(settings),
|
|
on => saveDownloadSettings({ hf_mirror: on }).catch(() => redraw()))),
|
|
row('settingsDownloadKey', 'settingsDownloadKeyHelp', button('anomalous-scan-secondary anomalous-scan-small-btn',
|
|
t('settingsDownloadKeySet'), () => saveApiKey())),
|
|
);
|
|
}).catch(error => console.warn('[AMB] Settings: download settings unavailable.', error));
|
|
return box;
|
|
}
|
|
|
|
/** Why DeepL did not translate (translation_routes.py `deepl_error`), in the user's words. */
|
|
function deeplReason(error) {
|
|
const known = { bad_key: 'settingsDeeplBadKey', quota: 'settingsDeeplQuota', busy: 'settingsDeeplBusy' };
|
|
return known[error] ? t(known[error]) : String(error || '?');
|
|
}
|
|
|
|
/**
|
|
* Asks for the DeepL key and saves it, then translates one sentence to show whether it works.
|
|
* Resolves to the saved key ("" when removed), or null when nothing was saved.
|
|
*/
|
|
async function saveDeeplKey() {
|
|
const value = prompt(t('settingsDeeplKeyPrompt'), '');
|
|
if (value === null) return null;
|
|
const key = value.trim();
|
|
try {
|
|
const res = await fetch('/anomalous/save_config', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ deepl_key: key }),
|
|
});
|
|
const data = await res.json();
|
|
if (data.status !== 'ok') throw new Error(data.message || `HTTP ${res.status}`);
|
|
} catch (error) {
|
|
alert(t('settingsDeeplKeySaveFailed', { error: String(error.message || error) }));
|
|
return null;
|
|
}
|
|
if (!key) {
|
|
alert(t('settingsDeeplKeyCleared'));
|
|
return key;
|
|
}
|
|
try {
|
|
const res = await fetch('/anomalous/translate', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ text: 'A girl reading under a cherry tree.', target_lang: 'zh-CN' }),
|
|
});
|
|
const data = await res.json();
|
|
alert(data.engine === 'deepl'
|
|
? t('settingsDeeplKeyWorks', { text: data.translated })
|
|
: t('settingsDeeplKeyFailed', { why: deeplReason(data.deepl_error || data.error) }));
|
|
} catch (error) {
|
|
alert(t('settingsDeeplKeyFailed', { why: String(error.message || error) }));
|
|
}
|
|
return key;
|
|
}
|
|
|
|
/** Prompt translation: DeepL with the user's own key, else the free services (translation_routes.py). */
|
|
function translationGroup() {
|
|
const box = group('settingsTranslation');
|
|
fetch('/anomalous/config').then(res => res.json()).then(config => {
|
|
const tag = el('span');
|
|
const show = (hasKey) => {
|
|
tag.className = `anomalous-scan-tag ${hasKey ? 'is-on' : 'is-off'}`;
|
|
tag.textContent = t(hasKey ? 'settingsDeeplKeyOn' : 'settingsDeeplKeyOff');
|
|
};
|
|
show(Boolean(config.has_deepl_key));
|
|
// Only the tag changes: redrawing the page would lose its scroll place.
|
|
const line = row('settingsDeeplKey', 'settingsDeeplKeyHelp', button('anomalous-scan-secondary anomalous-scan-small-btn',
|
|
t('settingsDownloadKeySet'), async () => {
|
|
const key = await saveDeeplKey();
|
|
if (key !== null) show(Boolean(key));
|
|
}));
|
|
line.querySelector('.anomalous-scan-setting-title').append(tag);
|
|
box.append(line);
|
|
}).catch(error => console.warn('[AMB] Settings: translation settings unavailable.', error));
|
|
return box;
|
|
}
|
|
|
|
/** One .zip of the user's data to keep anywhere, and putting one back (ui_backup.js). */
|
|
function backupGroup(owner) {
|
|
const small = (label, onClick) => button('anomalous-scan-secondary anomalous-scan-small-btn', label, onClick);
|
|
return group('settingsBackup',
|
|
row('settingsBackupExport', 'settingsBackupExportHelp', small(t('settingsBackupExportButton'), () => exportBackup())),
|
|
row('settingsBackupImport', 'settingsBackupImportHelp', small(t('settingsBackupImportButton'), () => importBackup(owner))));
|
|
}
|
|
|
|
function openingGroup(owner, redraw) {
|
|
const mode = comfySetting(ENTRY_MODE_SETTING_ID) || 'floating';
|
|
return group('settingsOpening',
|
|
row('mainEntryModeSetting', 'settingsEntryHelp', segment(
|
|
[['floating', 'mainEntryModeFloating'], ['topbar', 'mainEntryModeTopbar'], ['menu', 'mainEntryModeMenu']], mode, async value => {
|
|
await setComfySetting(ENTRY_MODE_SETTING_ID, value);
|
|
redraw();
|
|
})),
|
|
row('settingsFollowNode', 'settingsFollowNodeHelp', toggleSwitch(followsSelection(), setFollowsSelection)),
|
|
row('settingsLayout', 'settingsLayoutHelp', button('anomalous-scan-secondary anomalous-scan-small-btn', t('settingsReset'), () => {
|
|
if (!confirm(t('sidebarResetConfirm'))) return;
|
|
owner.displayPrefs.resetLayout();
|
|
redraw();
|
|
})),
|
|
);
|
|
}
|
|
|
|
function helpGroup(owner) {
|
|
const small = (label, onClick) => button('anomalous-scan-secondary anomalous-scan-small-btn', label, onClick);
|
|
return group('settingsHelp',
|
|
row('settingsTour', 'settingsTourHelp', small(t('settingsStart'), () => startSpotlightTour(owner))),
|
|
row('updateGuideReplay', 'settingsNewsHelp', small(t('settingsOpen'), () => showUpdateGuide(owner, { force: true }))),
|
|
row('settingsGuide', 'settingsGuideHelp', small(t('settingsOpenGuide'), () => {
|
|
window.open(window.anomalous_browser_lang === 'zh' ? README_ZH_URL : README_URL, '_blank', 'noopener');
|
|
})),
|
|
row('feedbackReport', 'settingsReportHelp', small(t('feedbackWrite'), () => openFeedbackDialog(owner, 'bug'))),
|
|
row('feedbackSuggest', 'settingsSuggestHelp', small(t('feedbackWrite'), () => openFeedbackDialog(owner, 'idea'))),
|
|
row('feedbackCopy', 'feedbackCopyHint', (() => {
|
|
const copy = small(t('feedbackCopy'), async () => {
|
|
copy.textContent = t(await copyDiagnostics(owner) ? 'feedbackCopied' : 'feedbackCopyFailed');
|
|
setTimeout(() => { copy.textContent = t('feedbackCopy'); }, 1600);
|
|
});
|
|
return copy;
|
|
})()),
|
|
row('settingsStar', 'settingsStarHelp', small(t('settingsStarOpen'), openRepoPage)),
|
|
);
|
|
}
|
|
|
|
// Tab -> its title and its cards, in order.
|
|
const TABS = [
|
|
['look', 'settingsTabLook', (owner, redraw) => [appearanceGroup(owner, redraw), cardsGroup(owner, redraw), galleryGroup(owner), openingGroup(owner, redraw)]],
|
|
['models', 'settingsTabModels', (owner, redraw) => [foldersGroup(owner), networkGroup(), downloadGroup(owner, redraw)]],
|
|
['workflows', 'settingsTabWorkflows', (owner) => [workflowGroup(owner)]],
|
|
['packs', 'settingsTabPacks', (owner, redraw) => [renderPackUsage(owner, redraw)]],
|
|
['prompts', 'settingsTabPrompts', () => [translationGroup()]],
|
|
['backup', 'settingsTabBackup', (owner) => [backupGroup(owner)]],
|
|
['help', 'settingsTabHelp', (owner) => [helpGroup(owner)]],
|
|
];
|
|
|
|
function tabBar(owner, current) {
|
|
const bar = el('div', 'anomalous-settings-tabs');
|
|
bar.setAttribute('role', 'tablist');
|
|
for (const [id, labelKey] of TABS) {
|
|
const tab = button('anomalous-settings-tab', t(labelKey), () => {
|
|
if (owner.settingsTab === id) return;
|
|
owner.settingsTab = id;
|
|
render(owner);
|
|
});
|
|
tab.setAttribute('role', 'tab');
|
|
tab.setAttribute('aria-selected', String(id === current));
|
|
bar.append(tab);
|
|
}
|
|
return bar;
|
|
}
|
|
|
|
function render(owner) {
|
|
const panel = owner.settingsPanel;
|
|
const tab = TABS.find(([id]) => id === owner.settingsTab) || TABS[0];
|
|
owner.settingsTab = tab[0];
|
|
const view = `${owner.settingsView || ''}|${tab[0]}`;
|
|
const top = panel._settingsView === view ? panel.scrollTop : 0;
|
|
panel._settingsView = view;
|
|
const redraw = () => render(owner);
|
|
const page = el('div', 'anomalous-scan-page');
|
|
if (owner.settingsView === 'folders') {
|
|
panel.replaceChildren(page);
|
|
panel.scrollTop = 0;
|
|
renderFolderPage(owner, page, () => {
|
|
owner.settingsView = '';
|
|
render(owner);
|
|
});
|
|
return;
|
|
}
|
|
page.append(
|
|
button('anomalous-scan-back', t('settingsBack'), () => leaveSettingsPage(owner)),
|
|
el('h1', 'anomalous-scan-title', t('sidebarSettings')),
|
|
tabBar(owner, tab[0]),
|
|
...tab[2](owner, redraw),
|
|
);
|
|
panel.replaceChildren(page);
|
|
panel.scrollTop = top;
|
|
}
|
|
|
|
export function isSettingsPageOpen(owner) {
|
|
return Boolean(owner.settingsPanel && owner.settingsPanel.style.display !== 'none');
|
|
}
|
|
|
|
/** Opens the page (at `tab`, else the tab seen last); `keepReturn`: drawn again in place
|
|
* (language change), Back still goes where it went. */
|
|
export function openSettingsPage(owner, { keepReturn = false, tab = '' } = {}) {
|
|
if (!keepReturn && !isSettingsPageOpen(owner)) {
|
|
owner.settingsReturn = owner.currentShellPage?.() || 'home';
|
|
owner.settingsView = '';
|
|
}
|
|
if (tab) {
|
|
owner.settingsTab = tab;
|
|
owner.settingsView = '';
|
|
}
|
|
owner.enterToolPage?.('settings');
|
|
owner.hideAllPanels();
|
|
owner.settingsPanel.style.display = 'flex';
|
|
document.getElementById('anomalous-global-settings-btn')?.classList.add('is-active');
|
|
render(owner);
|
|
}
|
|
|
|
/** Back to the page the gear was pressed on. */
|
|
export function leaveSettingsPage(owner) {
|
|
const back = owner.settingsReturn || 'home';
|
|
owner.settingsReturn = null;
|
|
if (back === 'scan') owner.openScanPage();
|
|
else if (back === 'doctor') owner.openDoctorPage();
|
|
else owner.goTo(['assistant', 'settings'].includes(back) ? 'home' : back);
|
|
}
|