Hide and automate secret identifiers
This commit is contained in:
@@ -1 +0,0 @@
|
||||
587fc8645d2a5271c4d40105e7209cda16636afd9be2613f83eb92c9208ad8b5 backupper-2026.06.14.9.5-x86_64-1.txz
|
||||
BIN
Binary file not shown.
@@ -0,0 +1 @@
|
||||
6438688ae2884c69f23c7e2ec11eb7bab95d138b6f7d25c3c0335a6e319470a0 backupper-2026.06.14.9.6-x86_64-1.txz
|
||||
Vendored
+6
-2
@@ -2,13 +2,17 @@
|
||||
<!DOCTYPE PLUGIN [
|
||||
<!ENTITY name "backupper">
|
||||
<!ENTITY author "Michael Roll">
|
||||
<!ENTITY version "2026.06.14.9.5">
|
||||
<!ENTITY version "2026.06.14.9.6">
|
||||
<!ENTITY pluginURL "https://git.casaderoll.de/michael/BackUpper/raw/branch/main/dist/backupper.plg">
|
||||
<!ENTITY packageURL "https://git.casaderoll.de/michael/BackUpper/raw/branch/main/dist/backupper-&version;-x86_64-1.txz">
|
||||
<!ENTITY packageSHA256 "587fc8645d2a5271c4d40105e7209cda16636afd9be2613f83eb92c9208ad8b5">
|
||||
<!ENTITY packageSHA256 "6438688ae2884c69f23c7e2ec11eb7bab95d138b6f7d25c3c0335a6e319470a0">
|
||||
]>
|
||||
<PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/BackUpper/issues" icon="backupper.png">
|
||||
<CHANGES>
|
||||
### 2026.06.14.9.6
|
||||
- Hide technical secret IDs and manage them automatically for repositories, SMB, SFTP, and Gotify.
|
||||
- Require a Restic password when creating a new repository while preserving existing secrets during edits.
|
||||
|
||||
### 2026.06.14.9.5
|
||||
- Add live backup progress with percentage, file and byte counters, transfer rate, ETA, and current file.
|
||||
- Add a sanitized in-memory live log for backup, restore, check, and prune activities.
|
||||
|
||||
@@ -2,13 +2,17 @@
|
||||
<!DOCTYPE PLUGIN [
|
||||
<!ENTITY name "backupper">
|
||||
<!ENTITY author "Michael Roll">
|
||||
<!ENTITY version "2026.06.14.9.5">
|
||||
<!ENTITY version "2026.06.14.9.6">
|
||||
<!ENTITY pluginURL "https://git.casaderoll.de/michael/BackUpper/raw/branch/main/dist/backupper.plg">
|
||||
<!ENTITY packageURL "https://git.casaderoll.de/michael/BackUpper/raw/branch/main/dist/backupper-&version;-x86_64-1.txz">
|
||||
<!ENTITY packageSHA256 "REPLACE_DURING_RELEASE">
|
||||
]>
|
||||
<PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/BackUpper/issues" icon="backupper.png">
|
||||
<CHANGES>
|
||||
### 2026.06.14.9.6
|
||||
- Hide technical secret IDs and manage them automatically for repositories, SMB, SFTP, and Gotify.
|
||||
- Require a Restic password when creating a new repository while preserving existing secrets during edits.
|
||||
|
||||
### 2026.06.14.9.5
|
||||
- Add live backup progress with percentage, file and byte counters, transfer rate, ETA, and current file.
|
||||
- Add a sanitized in-memory live log for backup, restore, check, and prune activities.
|
||||
|
||||
@@ -9,12 +9,12 @@ Tag="URBM Unraid Restic Backup Manager backup snapshots restore"
|
||||
<?php
|
||||
$pluginRoot = '/plugins/backupper';
|
||||
?>
|
||||
<link rel="stylesheet" href="<?= $pluginRoot ?>/assets/backupper.css?v=2026061495">
|
||||
<link rel="stylesheet" href="<?= $pluginRoot ?>/assets/backupper.css?v=2026061496">
|
||||
<div id="backupper-app" data-api="<?= $pluginRoot ?>/api.php">
|
||||
<header class="bu-header">
|
||||
<div class="bu-brand">
|
||||
<img class="bu-logo" src="<?= $pluginRoot ?>/images/backupper.png?v=2026061495" alt="URBM logo">
|
||||
<div><h1>URBM <span class="bu-version">2026.06.14.9.5</span></h1><p>Restic Backup Manager for Unraid</p></div>
|
||||
<img class="bu-logo" src="<?= $pluginRoot ?>/images/backupper.png?v=2026061496" alt="URBM logo">
|
||||
<div><h1>URBM <span class="bu-version">2026.06.14.9.6</span></h1><p>Restic Backup Manager for Unraid</p></div>
|
||||
</div>
|
||||
<div id="bu-health" class="bu-health" tabindex="0" data-tooltip="Zeigt, ob die URBM-Hintergrundkomponente erreichbar ist. Beispiel: 'Daemon online' bedeutet, dass Jobs gestartet werden können.">Connecting...</div>
|
||||
</header>
|
||||
@@ -32,4 +32,4 @@ $pluginRoot = '/plugins/backupper';
|
||||
<div id="bu-tooltip" role="tooltip" aria-hidden="true"></div>
|
||||
</div>
|
||||
<script>window.BACKUPPER_CSRF = <?= json_encode($var['csrf_token'] ?? '') ?>;</script>
|
||||
<script src="<?= $pluginRoot ?>/assets/backupper-2026.06.14.9.5.js"></script>
|
||||
<script src="<?= $pluginRoot ?>/assets/backupper-2026.06.14.9.6.js"></script>
|
||||
|
||||
+10
-11
@@ -28,12 +28,10 @@
|
||||
keepMonthly: 'Zusätzliche monatliche Stände außerhalb der Altersfrist. 0 deaktiviert diese Zusatzregel.',
|
||||
enabled: 'Aktiviert automatische geplante Läufe. Deaktivierte Jobs können weiterhin gespeichert, aber nicht automatisch ausgeführt werden.',
|
||||
location: 'Restic-Ziel. Lokal: /mnt/user/backups/job1. SFTP: user@server:/backup/job1. Bei SMB/NFS mit Mount wird hier der Mount-Pfad verwendet.',
|
||||
passwordRef: 'Interne ID des verschlüsselt gespeicherten Restic-Passworts. Beispiel: repo-password-appdata. Keine Leerzeichen verwenden.',
|
||||
password: 'Passwort zur Restic-Verschlüsselung. Beispiel: eine lange zufällige Passphrase. Leer lassen, wenn ein vorhandenes Passwort unverändert bleiben soll.',
|
||||
managed: 'Wenn aktiv, mountet URBM SMB/NFS vor dem Job selbst. Wenn aus, muss der angegebene Pfad bereits durch Unraid oder Unassigned Devices gemountet sein.',
|
||||
remote: 'Adresse der Netzwerkfreigabe. SMB-Beispiel: //192.168.1.20/backup. NFS-Beispiel: 192.168.1.20:/volume1/backup.',
|
||||
mountPoint: 'Lokaler Einhängepunkt auf Unraid. Beispiel: /mnt/remotes/backup-nas. Erlaubt sind Pfade unter /mnt/remotes oder /mnt/disks.',
|
||||
credentialRef: 'Interne ID für SMB-Zugangsdaten oder einen SFTP-Schlüssel. Beispiel: nas-login oder sftp-backup-key.',
|
||||
credential: 'SMB-Beispiel: username=backup und password=geheim auf getrennten Zeilen. Bei SFTP hier den kompletten privaten SSH-Schlüssel einfügen.',
|
||||
knownHostsPath: 'Datei mit dem geprüften SSH-Hostschlüssel. Beispiel: /root/.ssh/known_hosts. StrictHostKeyChecking ist immer aktiv.',
|
||||
snapshotId: 'Eindeutige ID des Sicherungsstands. Beispiel: 7f3a91cd. Sie kann im Bereich Snapshots über Browse ausgewählt werden.',
|
||||
@@ -48,7 +46,6 @@
|
||||
checkCron: 'Zeitplan für vollständige Repository-Prüfungen. Beispiel: 0 3 1 * * prüft am ersten Tag jedes Monats um 03:00 Uhr.',
|
||||
pruneCron: 'Zeitplan zum Freigeben nicht mehr benötigter Restic-Daten. Beispiel: 0 4 * * 0 startet sonntags um 04:00 Uhr.',
|
||||
url: 'Basisadresse deines Gotify-Servers. Beispiel: https://gotify.example.de oder http://192.168.1.20:8080.',
|
||||
tokenRef: 'Interne ID für das verschlüsselt gespeicherte Gotify-App-Token. Beispiel: gotify-token-main.',
|
||||
token: 'App-Token aus Gotify unter Apps. Das Token wird verschlüsselt gespeichert und nicht erneut angezeigt.',
|
||||
events: 'Legt fest, bei welchen Ergebnissen dieser Kanal informiert. Mindestens ein Ereignis muss aktiv sein.',
|
||||
};
|
||||
@@ -385,10 +382,12 @@
|
||||
|
||||
function repositoryForm(repo = {}) {
|
||||
state.repositoryBrowserPath = '/'; state.repositoryDirectories = repositoryRoots; state.repositoryBrowserError = '';
|
||||
content.innerHTML = `<section class="bu-card"><h2>${repo.id?'Edit':'New'} repository</h2><form id="bu-repo-form" class="bu-form"><input type="hidden" name="id" value="${esc(repo.id||'')}">
|
||||
const passwordRef=repo.passwordRef||id('repo-password');
|
||||
const credentialRef=repo.credentialRef||id('repo-credential');
|
||||
content.innerHTML = `<section class="bu-card"><h2>${repo.id?'Edit':'New'} repository</h2><form id="bu-repo-form" class="bu-form"><input type="hidden" name="id" value="${esc(repo.id||'')}"><input type="hidden" name="passwordRef" value="${esc(passwordRef)}"><input type="hidden" name="credentialRef" value="${esc(credentialRef)}"><input type="hidden" name="hasCredential" value="${repo.credentialRef?'true':'false'}">
|
||||
<label>Name<input name="name" required value="${esc(repo.name||'')}"></label><label>Type<select name="type">${['local','smb','nfs','sftp'].map(v=>`<option ${v===repo.type?'selected':''}>${v}</option>`).join('')}</select></label>
|
||||
<div id="bu-repository-specific" class="wide"></div>
|
||||
<fieldset class="wide bu-fieldset"><legend>Restic encryption</legend><div class="bu-form-grid"><label>Password secret ID<input name="passwordRef" required value="${esc(repo.passwordRef||id('repo-password'))}"></label><label>Repository password<input name="password" type="password" placeholder="Leave blank to keep existing"></label></div></fieldset>
|
||||
<fieldset class="wide bu-fieldset"><legend>Restic encryption</legend><label>Repository password<input name="password" type="password" autocomplete="new-password" ${repo.id?'':'required'} placeholder="${repo.id?'Leave blank to keep existing':'Enter a strong repository password'}"></label><p class="bu-muted">Encrypted by Restic and stored securely by URBM. ${repo.id?'Leave blank to keep the existing password.':'Keep this password safe; backups cannot be restored without it.'}</p></fieldset>
|
||||
<div class="wide bu-actions">${button('Save','submit-repo','primary')}${button('Cancel','cancel-form')}</div></form></section>`;
|
||||
enhanceTooltips(content);
|
||||
renderRepositorySpecific(repo.type || 'local', repo);
|
||||
@@ -406,9 +405,9 @@
|
||||
renderRepositoryBrowser();
|
||||
} else if (type === 'smb' || type === 'nfs') {
|
||||
const managed = repo.mount?.managed === true;
|
||||
host.innerHTML = `<fieldset class="bu-fieldset"><legend>${type.toUpperCase()} destination</legend><label class="bu-inline-check"><input name="managed" type="checkbox" ${managed?'checked':''}><span>URBM should mount this share</span></label><div class="bu-external-mount" ${managed?'hidden':''}>${repositoryLocationField('Existing mounted folder',repo.location,'/mnt/remotes/backup-nas')}</div><div class="bu-managed-mount" ${managed?'':'hidden'}><div class="bu-form-grid"><label>Remote share<input name="remote" placeholder="${type==='smb'?'//192.168.1.20/backup':'192.168.1.20:/volume1/backup'}" value="${esc(repo.mount?.remote||'')}"></label><label>Mount point<input name="mountPoint" placeholder="/mnt/remotes/urbm-backup" value="${esc(repo.mount?.mountPoint||repo.location||'')}"></label>${type==='smb'?`<label>Credential secret ID<input name="credentialRef" value="${esc(repo.credentialRef||'smb-credentials')}"></label><label class="wide">SMB credentials<textarea name="credential" placeholder="username=backup password=secret"></textarea></label>`:''}</div></div></fieldset>`;
|
||||
host.innerHTML = `<fieldset class="bu-fieldset"><legend>${type.toUpperCase()} destination</legend><label class="bu-inline-check"><input name="managed" type="checkbox" ${managed?'checked':''}><span>URBM should mount this share</span></label><div class="bu-external-mount" ${managed?'hidden':''}>${repositoryLocationField('Existing mounted folder',repo.location,'/mnt/remotes/backup-nas')}</div><div class="bu-managed-mount" ${managed?'':'hidden'}><div class="bu-form-grid"><label>Remote share<input name="remote" placeholder="${type==='smb'?'//192.168.1.20/backup':'192.168.1.20:/volume1/backup'}" value="${esc(repo.mount?.remote||'')}"></label><label>Mount point<input name="mountPoint" placeholder="/mnt/remotes/urbm-backup" value="${esc(repo.mount?.mountPoint||repo.location||'')}"></label>${type==='smb'?`<label class="wide">SMB credentials<textarea name="credential" placeholder="${repo.credentialRef?'Leave blank to keep existing credentials':'username=backup password=secret'}"></textarea></label>`:''}</div></div></fieldset>`;
|
||||
} else {
|
||||
host.innerHTML = `<fieldset class="bu-fieldset"><legend>SFTP destination</legend><div class="bu-form-grid">${repositoryLocationField('SFTP repository location',repo.location,'backup@192.168.1.20:/srv/backups/urbm')}<label>SSH key secret ID<input name="credentialRef" value="${esc(repo.credentialRef||'')}"></label><label>SFTP known_hosts path<input name="knownHostsPath" value="${esc(repo.options?.knownHostsPath||'/root/.ssh/known_hosts')}"></label><label class="wide">Private SSH key (optional)<textarea name="credential" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"></textarea></label></div></fieldset>`;
|
||||
host.innerHTML = `<fieldset class="bu-fieldset"><legend>SFTP destination</legend><div class="bu-form-grid">${repositoryLocationField('SFTP repository location',repo.location,'backup@192.168.1.20:/srv/backups/urbm')}<label>SFTP known_hosts path<input name="knownHostsPath" value="${esc(repo.options?.knownHostsPath||'/root/.ssh/known_hosts')}"></label><label class="wide">Private SSH key (optional)<textarea name="credential" placeholder="${repo.credentialRef?'Leave blank to keep existing private key':'-----BEGIN OPENSSH PRIVATE KEY-----'}"></textarea></label></div></fieldset>`;
|
||||
}
|
||||
enhanceTooltips(host);
|
||||
}
|
||||
@@ -486,7 +485,7 @@
|
||||
const eventBoxes = target => [['success','Success'],['warning','Warning'],['failed','Failed']].map(([value,label])=>`<label><input name="events" type="checkbox" value="${value}" ${(target.events||[]).includes(value)?'checked':''}><span>${label}</span></label>`).join('');
|
||||
return `<section class="bu-card"><h2>Settings</h2><form id="bu-settings-form" class="bu-form"><label class="wide">Restic binary<input name="resticPath" value="${esc(s.resticPath)}"></label><label class="wide">Restore staging root<input name="restoreRoot" value="${esc(s.restoreRoot)}"></label><label class="wide">Persistent log directory<input name="persistentLogDir" value="${esc(s.persistentLogDir||'')}"></label><label>Catch-up window (hours)<input name="catchUp" type="number" value="${s.catchUpWindowHours||24}"></label><label>Monthly check cron<input name="checkCron" value="${esc(s.checkCron)}"></label><label>Prune cron<input name="pruneCron" value="${esc(s.pruneCron)}"></label><div class="wide bu-actions">${button('Save settings','submit-settings','primary')}</div></form></section>
|
||||
<section class="bu-card" style="margin-top:16px"><h2>Unraid notifications</h2><p class="bu-muted">Uses Unraid's notification system and all agents configured under Settings > Notification Settings.</p><form id="bu-unraid-notify-form" class="bu-form"><input type="hidden" name="id" value="${esc(unraid.id)}"><label><span>Enabled</span><input name="enabled" type="checkbox" ${unraid.enabled?'checked':''}></label><fieldset class="wide bu-fieldset"><legend>Notify on</legend><div class="bu-event-options">${eventBoxes(unraid)}</div></fieldset><div class="wide bu-actions">${button('Save Unraid notifications','submit-unraid-notify','primary')}</div></form></section>
|
||||
<section class="bu-card" style="margin-top:16px"><h2>Gotify notification</h2><p class="bu-muted">Create an application in Gotify and enter its application token here.</p><form id="bu-gotify-notify-form" class="bu-form"><input type="hidden" name="id" value="${esc(gotify.id||'gotify')}"><label class="wide">Server URL<input name="url" type="url" placeholder="https://gotify.example.de" value="${esc(gotify.url||'')}"></label><label>Token secret ID<input name="tokenRef" value="${esc(gotify.tokenRef||'gotify-token')}"></label><label>Application token<input name="token" type="password" autocomplete="new-password"></label><label><span>Enabled</span><input name="enabled" type="checkbox" ${gotify.enabled?'checked':''}></label><fieldset class="wide bu-fieldset"><legend>Notify on</legend><div class="bu-event-options">${eventBoxes(gotify)}</div></fieldset><div class="wide bu-actions">${button('Save Gotify notification','submit-gotify-notify','primary')}</div></form></section>`;
|
||||
<section class="bu-card" style="margin-top:16px"><h2>Gotify notification</h2><p class="bu-muted">Create an application in Gotify and enter its application token here.</p><form id="bu-gotify-notify-form" class="bu-form"><input type="hidden" name="id" value="${esc(gotify.id||'gotify')}"><input type="hidden" name="tokenRef" value="${esc(gotify.tokenRef||id('gotify-token'))}"><label class="wide">Server URL<input name="url" type="url" placeholder="https://gotify.example.de" value="${esc(gotify.url||'')}"></label><label>Application token<input name="token" type="password" autocomplete="new-password" placeholder="${gotify.tokenRef?'Leave blank to keep existing token':'Enter the Gotify application token'}"></label><label><span>Enabled</span><input name="enabled" type="checkbox" ${gotify.enabled?'checked':''}></label><fieldset class="wide bu-fieldset"><legend>Notify on</legend><div class="bu-event-options">${eventBoxes(gotify)}</div></fieldset><div class="wide bu-actions">${button('Save Gotify notification','submit-gotify-notify','primary')}</div></form></section>`;
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
@@ -547,9 +546,9 @@
|
||||
let location=managed?value('mountPoint'):value('location');
|
||||
if(!location) throw new Error(managed?'Mount point is required':'Repository location is required');
|
||||
if(managed&&!value('remote')) throw new Error('Remote share is required for a managed mount');
|
||||
let credentialRef=(type==='sftp'||(type==='smb'&&managed))?value('credentialRef'):'';
|
||||
if(value('credential')&&!credentialRef) credentialRef=id(type==='sftp'?'sftp-key':'smb-credentials');
|
||||
const usesCredential=type==='sftp'||(type==='smb'&&managed); const credentialRef=usesCredential&&(value('hasCredential')==='true'||Boolean(f.get('credential')))?value('credentialRef'):'';
|
||||
const repo={schemaVersion:1,id:value('id')||id('repo'),name:value('name'),type,location,passwordRef:value('passwordRef'),credentialRef,mount:managed?{managed:true,remote:value('remote'),mountPoint:value('mountPoint')}:null,options:type==='sftp'?{knownHostsPath:value('knownHostsPath')}: {}};
|
||||
if(!value('id')&&!f.get('password')) throw new Error('A repository password is required for a new repository');
|
||||
if (volatileLocation(repo) && !confirm('WARNING: This destination is under /tmp or /run and will be lost after an Unraid reboot. Save it anyway?')) return;
|
||||
if(f.get('password')) await api(`/v1/secrets/${encodeURIComponent(repo.passwordRef)}`,{method:'PUT',body:JSON.stringify({type:'restic-password',value:f.get('password')})});
|
||||
if(f.get('credential')) await api(`/v1/secrets/${encodeURIComponent(repo.credentialRef)}`,{method:'PUT',body:JSON.stringify({type:type==='sftp'?'sftp-private-key':'smb-credential',value:f.get('credential')})});
|
||||
@@ -563,7 +562,7 @@
|
||||
if (kind === 'submit-restore') { const f=new FormData(document.getElementById('bu-restore-form')); await api('/v1/restores',{method:'POST',body:JSON.stringify({schemaVersion:1,id:'',repositoryId:f.get('repositoryId'),snapshotId:f.get('snapshotId'),includes:lines(f.get('includes')),target:f.get('target'),inPlace:f.get('inPlace')==='on',confirmed:f.get('confirmed')==='on'})}); notify('Restore queued'); state.view='activity'; render(); }
|
||||
if (kind === 'submit-settings') { const f=new FormData(document.getElementById('bu-settings-form')); state.config.settings={resticPath:f.get('resticPath'),restoreRoot:f.get('restoreRoot'),persistentLogDir:f.get('persistentLogDir'),catchUpWindowHours:Number(f.get('catchUp')),checkCron:f.get('checkCron'),pruneCron:f.get('pruneCron')}; await saveConfig(); }
|
||||
if (kind === 'submit-unraid-notify') { const f=new FormData(document.getElementById('bu-unraid-notify-form')); const target={schemaVersion:1,id:f.get('id')||'unraid',name:'Unraid',type:'unraid',enabled:f.get('enabled')==='on',events:f.getAll('events')}; if(target.enabled&&!target.events.length) throw new Error('Select at least one event for Unraid notifications'); state.config.notifications=state.config.notifications.filter(n=>n.type!=='unraid'&&n.type!=='ntfy').concat(target); await saveConfig(); render(); }
|
||||
if (kind === 'submit-gotify-notify') { const f=new FormData(document.getElementById('bu-gotify-notify-form')); const target={schemaVersion:1,id:f.get('id')||'gotify',name:'Gotify',type:'gotify',enabled:f.get('enabled')==='on',url:String(f.get('url')||'').trim(),tokenRef:String(f.get('tokenRef')||'').trim(),events:f.getAll('events')}; if(target.enabled&&(!target.url||!target.tokenRef)) throw new Error('Gotify server URL and token secret ID are required'); if(target.enabled&&!target.events.length) throw new Error('Select at least one event for Gotify notifications'); if(f.get('token')) await api(`/v1/secrets/${encodeURIComponent(target.tokenRef)}`,{method:'PUT',body:JSON.stringify({type:'gotify-token',value:f.get('token')})}); state.config.notifications=state.config.notifications.filter(n=>n.type!=='gotify'&&n.type!=='ntfy'); if(target.enabled||target.url) state.config.notifications.push(target); await saveConfig(); render(); }
|
||||
if (kind === 'submit-gotify-notify') { const f=new FormData(document.getElementById('bu-gotify-notify-form')); const existing=state.config.notifications.find(n=>n.type==='gotify'); const target={schemaVersion:1,id:f.get('id')||'gotify',name:'Gotify',type:'gotify',enabled:f.get('enabled')==='on',url:String(f.get('url')||'').trim(),tokenRef:String(f.get('tokenRef')||'').trim(),events:f.getAll('events')}; if(target.enabled&&!target.url) throw new Error('Gotify server URL is required'); if(target.enabled&&!target.events.length) throw new Error('Select at least one event for Gotify notifications'); if(target.enabled&&!existing&&!f.get('token')) throw new Error('A Gotify application token is required'); if(f.get('token')) await api(`/v1/secrets/${encodeURIComponent(target.tokenRef)}`,{method:'PUT',body:JSON.stringify({type:'gotify-token',value:f.get('token')})}); state.config.notifications=state.config.notifications.filter(n=>n.type!=='gotify'&&n.type!=='ntfy'); if(target.enabled||target.url) state.config.notifications.push(target); await saveConfig(); render(); }
|
||||
}
|
||||
|
||||
document.querySelector('.bu-tabs').addEventListener('click', e => { const button=e.target.closest('[data-view]'); if(!button)return; state.view=button.dataset.view; document.querySelectorAll('.bu-tabs button').forEach(x=>x.classList.toggle('active',x===button)); render(); });
|
||||
|
||||
Reference in New Issue
Block a user