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:
parent
8a90259a76
commit
1b3543ead8
13 changed files with 400 additions and 87 deletions
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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’s declarativeNetRequest API (Manifest V3).
|
||||
</span>
|
||||
{!extAvailable && (
|
||||
<span className="ext-card-hint">
|
||||
Not configured — 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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); }
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue