1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-22 03:05:32 +02:00

feat: Extensions settings tab + capture dialog redesign with Advanced options

Settings/Extensions tab (admin-only):
- New 'Extensions' tab between Cookies and Storage
- ExtensionsTab component: shows uBlock Origin Lite card with pill toggle
- Reads ublock_enabled from instance settings; patch via existing PATCH endpoint
- Shows ublock_ext_available status from server (whether ARCHIVR_UBLOCK_EXT is set)

Instance settings:
- Add ublock_enabled BOOLEAN (default true) to instance_settings auth DB table
- Idempotent ALTER TABLE migration in initialize_auth_schema()
- get/update_instance_settings include ublock_enabled
- GET /api/admin/instance-settings now also returns ublock_ext_available (computed
  from ARCHIVR_UBLOCK_EXT env var at request time)
- PATCH /api/admin/instance-settings accepts ublock_enabled

Per-capture override:
- CaptureBody gains ublock_enabled: Option<bool>
- CaptureConfig gains ublock_enabled: Option<bool>
- singlefile::save() gains ublock_enabled_override: Option<bool> param
- Capture handler resolves: body override > global instance setting > env var
- submitCapture(aid, loc, qual, extensions) in api.js passes ublock_enabled

Capture dialog redesign:
- Archive button: full-width, 13px padding, min-width 220px, primary CTA
- Cancel: full-width but text-style, below Archive
- ‹Advanced options› chevron toggle (rotates on open)
- Expanded panel shows uBlock toggle for this capture session
- Loads global ublock_enabled default from instance settings on mount

Styles:
- .ext-toggle pill switch (44×24 and 36×20 small variant)
- .ext-card for Settings Extensions tab
- .capture-advanced + .capture-advanced-panel + .capture-chevron
- .capture-ext-row / .capture-ext-label / .capture-ext-name / .capture-ext-desc
- .form-hint utility class
This commit is contained in:
TheGeneralist 2026-07-07 17:33:35 +02:00
parent 8a90259a76
commit 1b3543ead8
Signed by: thegeneralist01
SSH key fingerprint: SHA256:pp9qddbCNmVNoSjevdvQvM5z0DHN7LTa8qBMbcMq/R4
13 changed files with 400 additions and 87 deletions

View file

@ -95,9 +95,13 @@ export async function fetchTags(archiveId) {
return getJson(`/api/archives/${archiveId}/tags`);
}
export async function submitCapture(archiveId, locator, quality = null) {
export async function submitCapture(archiveId, locator, quality = null, extensions = null) {
const payload = { locator }
if (quality && quality !== 'best') payload.quality = quality
// extensions: { ublock_enabled?: bool } — per-capture overrides
if (extensions && typeof extensions.ublock_enabled === 'boolean') {
payload.ublock_enabled = extensions.ublock_enabled
}
const res = await fetch(`/api/archives/${archiveId}/captures`, {
method: "POST",
headers: { "Content-Type": "application/json" },

View file

@ -1,5 +1,5 @@
import { useRef, useEffect, useState } from 'react'
import { submitCapture, pollCaptureJob, probeCapture } from '../api'
import { useRef, useEffect, useState, useCallback } from 'react'
import { submitCapture, pollCaptureJob, probeCapture, getInstanceSettings } from '../api'
let nextItemId = 1
@ -112,6 +112,23 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
sessionStorage.setItem('captureItems', JSON.stringify(items))
}, [items])
// Advanced options panel state
const [advancedOpen, setAdvancedOpen] = useState(false)
// null = use server default; true/false = per-session override
const [ublockOverride, setUblockOverride] = useState(null)
// Server-side global default (loaded on mount, null until loaded)
const [globalUblock, setGlobalUblock] = useState(null)
// Load global uBlock setting from server once on mount
useEffect(() => {
getInstanceSettings()
.then(s => setGlobalUblock(s.ublock_enabled ?? true))
.catch(() => setGlobalUblock(true)) // safe default
}, [])
// Effective uBlock for this session
const ublockEnabled = ublockOverride !== null ? ublockOverride : (globalUblock ?? true)
// On mount: clean up old single-locator sessionStorage keys; reconnect running jobs
useEffect(() => {
;['captureDialogLocator','captureDialogError','captureDialogBusy',
@ -223,7 +240,8 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
const qual = item.quality || 'best'
setItems(prev => prev.map(it => it.id === item.id ? { ...it, status: 'submitting', error: null } : it))
try {
const job = await submitCapture(aid, loc, qual)
const extensions = { ublock_enabled: ublockEnabled }
const job = await submitCapture(aid, loc, qual, extensions)
setItems(prev => prev.map(it =>
it.id === item.id ? { ...it, status: 'running', jobUid: job.job_uid, archiveId: aid } : it
))
@ -342,10 +360,47 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
Add another
</button>
<div className="capture-actions">
<button type="button" className="capture-cancel" onClick={() => dialogRef.current?.close()}>
{anyActive ? 'Close' : 'Cancel'}
{/* ── Advanced options ────────────────────────────── */}
<div className="capture-advanced">
<button
type="button"
className="capture-advanced-toggle"
onClick={() => setAdvancedOpen(v => !v)}
aria-expanded={advancedOpen}
>
<svg
className={`capture-chevron${advancedOpen ? ' capture-chevron--open' : ''}`}
viewBox="0 0 16 16" fill="none" stroke="currentColor"
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
>
<polyline points="4 6 8 10 12 6"/>
</svg>
Advanced options
</button>
{advancedOpen && (
<div className="capture-advanced-panel">
<label className="capture-ext-row">
<span className="capture-ext-label">
<span className="capture-ext-name">uBlock Origin Lite</span>
<span className="capture-ext-desc">Block ads during this capture</span>
</span>
<button
type="button"
role="switch"
aria-checked={ublockEnabled}
className={`ext-toggle ext-toggle--sm${ublockEnabled ? ' ext-toggle--on' : ''}`}
onClick={() => setUblockOverride(v => v === null ? !ublockEnabled : !v)}
aria-label="Toggle uBlock for this capture"
>
<span className="ext-toggle-knob" />
</button>
</label>
</div>
)}
</div>
{/* ── Primary action ──────────────────────────────── */}
<div className="capture-actions">
<button
type="button"
className="capture-submit"
@ -354,6 +409,9 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
>
{pendingCount > 1 ? `Archive ${pendingCount}` : 'Archive'}
</button>
<button type="button" className="capture-cancel" onClick={() => dialogRef.current?.close()}>
{anyActive ? 'Close' : 'Cancel'}
</button>
</div>
</div>
</dialog>

View file

@ -14,8 +14,8 @@ export default function SettingsView({ tab, onTabChange, archiveId }) {
const { currentUser, setCurrentUser } = useContext(AuthContext) ?? {}
const isAdmin = currentUser && ((currentUser.role_bits & ROLE_ADMIN) !== 0)
const tabs = ['profile', 'tokens', ...(isAdmin ? ['instance', 'cookies', 'storage'] : [])]
const tabLabels = { profile: 'Profile', tokens: 'API Tokens', instance: 'Instance', cookies: 'Cookies', storage: 'Storage' }
const tabs = ['profile', 'tokens', ...(isAdmin ? ['instance', 'cookies', 'extensions', 'storage'] : [])]
const tabLabels = { profile: 'Profile', tokens: 'API Tokens', instance: 'Instance', cookies: 'Cookies', extensions: 'Extensions', storage: 'Storage' }
return (
<section className="admin-view">
@ -34,6 +34,7 @@ export default function SettingsView({ tab, onTabChange, archiveId }) {
{tab === 'tokens' && <TokensTab />}
{tab === 'instance' && isAdmin && <InstanceTab />}
{tab === 'cookies' && isAdmin && <CookiesTab />}
{tab === 'extensions' && isAdmin && <ExtensionsTab />}
{tab === 'storage' && isAdmin && <StorageTab archiveId={archiveId} />}
</section>
)
@ -638,4 +639,81 @@ function CookiesTab() {
</div>
</div>
)
}
function ExtensionsTab() {
const [settings, setSettings] = useState(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [msg, setMsg] = useState(null)
useEffect(() => {
(async () => {
try { setSettings(await getInstanceSettings()) }
catch (e) { setMsg({ ok: false, text: e.message }) }
finally { setLoading(false) }
})()
}, [])
async function toggleUblock(val) {
setSaving(true)
setMsg(null)
try {
await updateInstanceSettings({ ublock_enabled: val })
setSettings(s => ({ ...s, ublock_enabled: val }))
setMsg({ ok: true, text: 'Saved.' })
} catch (e) {
setMsg({ ok: false, text: e.message })
} finally {
setSaving(false)
}
}
if (loading) return <div className="muted">Loading\u2026</div>
const extAvailable = settings?.ublock_ext_available ?? false
const extEnabled = settings?.ublock_enabled ?? true
return (
<div style={{ maxWidth: 560 }}>
<div className="form-section">
<h2>Extensions</h2>
<p className="form-hint" style={{ marginBottom: 20 }}>
Extensions run inside the browser during WebPage captures and can block ads,
accept cookie banners, and more. Changes take effect on the next capture.
</p>
<div className="ext-card">
<div className="ext-card-header">
<div className="ext-card-info">
<span className="ext-card-name">uBlock Origin Lite</span>
<span className="ext-card-desc">
Blocks ads, trackers, and other page clutter during archiving
via Chrome&rsquo;s declarativeNetRequest API (Manifest V3).
</span>
{!extAvailable && (
<span className="ext-card-hint">
Not configured &mdash; set <code>ARCHIVR_UBLOCK_EXT</code> to the
unpacked extension directory to enable.
</span>
)}
</div>
<button
type="button"
role="switch"
aria-checked={extEnabled}
className={`ext-toggle${extEnabled ? ' ext-toggle--on' : ''}`}
onClick={() => toggleUblock(!extEnabled)}
disabled={saving}
aria-label="Toggle uBlock Origin Lite"
>
<span className="ext-toggle-knob" />
</button>
</div>
</div>
{msg && <div className={`form-msg form-msg--${msg.ok ? 'ok' : 'err'}`}>{msg.text}</div>}
</div>
</div>
)
}

View file

@ -608,30 +608,86 @@ select {
}
.capture-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 16px;
flex-direction: column;
gap: 8px;
margin-top: 18px;
}
.capture-cancel {
border: 1px solid var(--line);
background: none;
color: var(--ink);
padding: 8px 18px;
border-radius: var(--r);
cursor: pointer;
}
.capture-cancel:hover { background: var(--paper-2); }
.capture-submit {
border: 0;
background: var(--ink);
color: var(--paper);
padding: 8px 20px;
border-radius: var(--r);
padding: 13px 20px;
border-radius: var(--r2);
cursor: pointer;
font-weight: 600;
font-size: 15px;
width: 100%;
min-width: 220px;
letter-spacing: 0.01em;
}
.capture-submit:hover { opacity: 0.85; }
.capture-submit:disabled { opacity: 0.45; cursor: default; }
.capture-cancel {
border: 0;
background: none;
color: var(--muted);
padding: 7px 18px;
border-radius: var(--r);
cursor: pointer;
font-size: 13px;
text-align: center;
width: 100%;
}
.capture-cancel:hover { color: var(--ink); background: var(--paper-2); }
/* ── Capture advanced options ────────────────────────────────────────────── */
.capture-advanced { margin-top: 12px; }
.capture-advanced-toggle {
display: flex;
align-items: center;
gap: 5px;
background: none;
border: 0;
cursor: pointer;
color: var(--muted);
font-size: 12.5px;
padding: 4px 0;
border-radius: var(--r);
}
.capture-advanced-toggle:hover { color: var(--ink); }
.capture-chevron {
width: 14px; height: 14px;
flex-shrink: 0;
transition: transform 0.18s ease;
}
.capture-chevron--open { transform: rotate(180deg); }
.capture-advanced-panel {
margin-top: 8px;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: var(--r2);
background: var(--paper-2);
}
.capture-ext-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
cursor: default;
}
.capture-ext-label { flex: 1; min-width: 0; }
.capture-ext-name {
display: block;
font-size: 13px;
font-weight: 600;
color: var(--ink);
}
.capture-ext-desc {
display: block;
font-size: 11.5px;
color: var(--muted);
margin-top: 1px;
}
/* ── Capture dialog: header + multi-row ─────────────────────────────────── */
.capture-dialog-header {
@ -906,6 +962,72 @@ select {
}
.toast-ignore-btn { color: var(--muted); }
/* ── Extension toggle (pill switch) ─────────────────────────────────────── */
.ext-toggle {
flex-shrink: 0;
position: relative;
width: 44px;
height: 24px;
border-radius: 12px;
background: var(--line);
border: 0;
cursor: pointer;
transition: background 0.18s ease;
padding: 0;
}
.ext-toggle--sm { width: 36px; height: 20px; border-radius: 10px; }
.ext-toggle--on { background: var(--ink); }
.ext-toggle:disabled { opacity: 0.4; cursor: default; }
.ext-toggle-knob {
position: absolute;
top: 3px; left: 3px;
width: 18px; height: 18px;
border-radius: 50%;
background: var(--paper);
transition: transform 0.18s ease;
display: block;
box-shadow: 0 1px 3px rgba(0,0,0,.2);
}
.ext-toggle--sm .ext-toggle-knob { width: 14px; height: 14px; top: 3px; left: 3px; }
.ext-toggle--on .ext-toggle-knob { transform: translateX(20px); }
.ext-toggle--sm.ext-toggle--on .ext-toggle-knob { transform: translateX(16px); }
/* ── Extension card (Settings / Extensions tab) ──────────────────────────── */
.ext-card {
border: 1px solid var(--line);
border-radius: var(--r2);
padding: 16px 18px;
background: var(--paper);
}
.ext-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.ext-card-info { flex: 1; min-width: 0; }
.ext-card-name {
display: block;
font-weight: 600;
font-size: 14px;
margin-bottom: 4px;
}
.ext-card-desc {
display: block;
font-size: 13px;
color: var(--muted);
line-height: 1.5;
}
.ext-card-hint {
display: block;
font-size: 12px;
color: #e8a000;
margin-top: 6px;
}
.ext-card-hint code { font-size: 11.5px; }
.form-hint { font-size: 13px; color: var(--muted); line-height: 1.55; margin: 0; }
/* ── Utility ─────────────────────────────────────────────────────────────── */
.muted { color: var(--muted); }