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
|
|
@ -77,6 +77,11 @@ impl PlatformMetadata {
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct CaptureConfig {
|
pub struct CaptureConfig {
|
||||||
pub cookie_rules: Vec<database::CookieRule>,
|
pub cookie_rules: Vec<database::CookieRule>,
|
||||||
|
/// Override for uBlock Origin Lite during WebPage captures.
|
||||||
|
/// `None` = derive from `ARCHIVR_UBLOCK` env var (existing behaviour).
|
||||||
|
/// `Some(false)` = disabled by admin/user choice for this capture.
|
||||||
|
/// `Some(true)` = explicitly enabled (still needs `ARCHIVR_UBLOCK_EXT` to be valid).
|
||||||
|
pub ublock_enabled: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolves which cookies apply to `url` by evaluating all rules in ordinal order.
|
/// Resolves which cookies apply to `url` by evaluating all rules in ordinal order.
|
||||||
|
|
@ -1010,7 +1015,7 @@ pub fn perform_capture(
|
||||||
|
|
||||||
// Source: web page — archive as a self-contained HTML snapshot via single-file-cli
|
// Source: web page — archive as a self-contained HTML snapshot via single-file-cli
|
||||||
if source == Source::WebPage {
|
if source == Source::WebPage {
|
||||||
match downloader::singlefile::save(locator, store_path, ×tamp, &cookies) {
|
match downloader::singlefile::save(locator, store_path, ×tamp, &cookies, config.ublock_enabled) {
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
let file_extension = ".html".to_string();
|
let file_extension = ".html".to_string();
|
||||||
let temp_html = store_path
|
let temp_html = store_path
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,9 @@ pub struct InstanceSettings {
|
||||||
pub public_entry_content_enabled: bool,
|
pub public_entry_content_enabled: bool,
|
||||||
pub open_registration_enabled: bool, // maps to public_archive_submission_enabled column
|
pub open_registration_enabled: bool, // maps to public_archive_submission_enabled column
|
||||||
pub default_entry_visibility: u32,
|
pub default_entry_visibility: u32,
|
||||||
|
/// Global default for ad-blocking via uBlock Origin Lite during WebPage captures.
|
||||||
|
/// Per-capture requests can override this.
|
||||||
|
pub ublock_enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
|
@ -460,13 +463,14 @@ pub fn initialize_auth_schema(conn: &Connection) -> Result<()> {
|
||||||
public_index_enabled INTEGER NOT NULL DEFAULT 0 CHECK (public_index_enabled IN (0, 1)),
|
public_index_enabled INTEGER NOT NULL DEFAULT 0 CHECK (public_index_enabled IN (0, 1)),
|
||||||
public_entry_content_enabled INTEGER NOT NULL DEFAULT 0 CHECK (public_entry_content_enabled IN (0, 1)),
|
public_entry_content_enabled INTEGER NOT NULL DEFAULT 0 CHECK (public_entry_content_enabled IN (0, 1)),
|
||||||
public_archive_submission_enabled INTEGER NOT NULL DEFAULT 0 CHECK (public_archive_submission_enabled IN (0, 1)),
|
public_archive_submission_enabled INTEGER NOT NULL DEFAULT 0 CHECK (public_archive_submission_enabled IN (0, 1)),
|
||||||
default_entry_visibility INTEGER NOT NULL DEFAULT 2
|
default_entry_visibility INTEGER NOT NULL DEFAULT 2,
|
||||||
|
ublock_enabled INTEGER NOT NULL DEFAULT 1 CHECK (ublock_enabled IN (0, 1))
|
||||||
);
|
);
|
||||||
|
|
||||||
INSERT OR IGNORE INTO instance_settings
|
INSERT OR IGNORE INTO instance_settings
|
||||||
(id, public_index_enabled, public_entry_content_enabled,
|
(id, public_index_enabled, public_entry_content_enabled,
|
||||||
public_archive_submission_enabled, default_entry_visibility)
|
public_archive_submission_enabled, default_entry_visibility, ublock_enabled)
|
||||||
VALUES (1, 0, 0, 0, 2);
|
VALUES (1, 0, 0, 0, 2, 1);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
|
|
@ -500,6 +504,12 @@ pub fn initialize_auth_schema(conn: &Connection) -> Result<()> {
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Add ublock_enabled column to instance_settings if not present (idempotent migration)
|
||||||
|
let _ = conn.execute(
|
||||||
|
"ALTER TABLE instance_settings ADD COLUMN ublock_enabled INTEGER NOT NULL DEFAULT 1",
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -724,7 +734,8 @@ pub fn list_user_tokens(conn: &Connection, user_id: i64) -> Result<Vec<ApiTokenR
|
||||||
pub fn get_instance_settings(conn: &Connection) -> Result<InstanceSettings> {
|
pub fn get_instance_settings(conn: &Connection) -> Result<InstanceSettings> {
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT public_index_enabled, public_entry_content_enabled,
|
"SELECT public_index_enabled, public_entry_content_enabled,
|
||||||
public_archive_submission_enabled, default_entry_visibility
|
public_archive_submission_enabled, default_entry_visibility,
|
||||||
|
COALESCE(ublock_enabled, 1)
|
||||||
FROM instance_settings WHERE id = 1",
|
FROM instance_settings WHERE id = 1",
|
||||||
[],
|
[],
|
||||||
|row| {
|
|row| {
|
||||||
|
|
@ -733,6 +744,7 @@ pub fn get_instance_settings(conn: &Connection) -> Result<InstanceSettings> {
|
||||||
public_entry_content_enabled: row.get::<_, i64>(1)? != 0,
|
public_entry_content_enabled: row.get::<_, i64>(1)? != 0,
|
||||||
open_registration_enabled: row.get::<_, i64>(2)? != 0,
|
open_registration_enabled: row.get::<_, i64>(2)? != 0,
|
||||||
default_entry_visibility: row.get::<_, i64>(3)? as u32,
|
default_entry_visibility: row.get::<_, i64>(3)? as u32,
|
||||||
|
ublock_enabled: row.get::<_, i64>(4)? != 0,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -745,13 +757,15 @@ pub fn update_instance_settings(conn: &Connection, settings: &InstanceSettings)
|
||||||
SET public_index_enabled = ?1,
|
SET public_index_enabled = ?1,
|
||||||
public_entry_content_enabled = ?2,
|
public_entry_content_enabled = ?2,
|
||||||
public_archive_submission_enabled = ?3,
|
public_archive_submission_enabled = ?3,
|
||||||
default_entry_visibility = ?4
|
default_entry_visibility = ?4,
|
||||||
|
ublock_enabled = ?5
|
||||||
WHERE id = 1",
|
WHERE id = 1",
|
||||||
params![
|
params![
|
||||||
settings.public_index_enabled as i64,
|
settings.public_index_enabled as i64,
|
||||||
settings.public_entry_content_enabled as i64,
|
settings.public_entry_content_enabled as i64,
|
||||||
settings.open_registration_enabled as i64,
|
settings.open_registration_enabled as i64,
|
||||||
settings.default_entry_visibility as i64,
|
settings.default_entry_visibility as i64,
|
||||||
|
settings.ublock_enabled as i64,
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
|
||||||
|
|
@ -43,11 +43,12 @@ pub fn save(
|
||||||
store_path: &Path,
|
store_path: &Path,
|
||||||
timestamp: &str,
|
timestamp: &str,
|
||||||
cookies: &HashMap<String, String>,
|
cookies: &HashMap<String, String>,
|
||||||
|
ublock_enabled_override: Option<bool>,
|
||||||
) -> Result<SaveResult> {
|
) -> Result<SaveResult> {
|
||||||
let single_file =
|
let single_file =
|
||||||
env::var("ARCHIVR_SINGLE_FILE").unwrap_or_else(|_| "single-file".to_string());
|
env::var("ARCHIVR_SINGLE_FILE").unwrap_or_else(|_| "single-file".to_string());
|
||||||
let chrome = env::var("ARCHIVR_CHROME").unwrap_or_else(|_| "chromium".to_string());
|
let chrome = env::var("ARCHIVR_CHROME").unwrap_or_else(|_| "chromium".to_string());
|
||||||
let (ublock_ext, ublock_skipped) = resolve_ublock_config();
|
let (ublock_ext, ublock_skipped) = resolve_ublock_config(ublock_enabled_override);
|
||||||
let mut result = save_with(
|
let mut result = save_with(
|
||||||
url,
|
url,
|
||||||
store_path,
|
store_path,
|
||||||
|
|
@ -61,21 +62,26 @@ pub fn save(
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reads `ARCHIVR_UBLOCK` and `ARCHIVR_UBLOCK_EXT` and returns:
|
/// Resolves uBlock configuration from env vars, optionally overridden by the caller.
|
||||||
///
|
///
|
||||||
|
/// Returns:
|
||||||
/// - `(Some(path), false)` — uBlock is enabled and the extension dir is valid.
|
/// - `(Some(path), false)` — uBlock is enabled and the extension dir is valid.
|
||||||
/// - `(None, true)` — uBlock is enabled but the extension dir is missing/invalid
|
/// - `(None, true)` — uBlock is enabled but the extension dir is missing/invalid
|
||||||
/// (warns to stderr; caller should surface this to the user).
|
/// (warns to stderr; the capture proceeds without ad-blocking).
|
||||||
/// - `(None, false)` — uBlock is explicitly disabled (`ARCHIVR_UBLOCK=false/0`).
|
/// - `(None, false)` — uBlock is disabled (`ARCHIVR_UBLOCK=false` or overridden).
|
||||||
fn resolve_ublock_config() -> (Option<PathBuf>, bool) {
|
fn resolve_ublock_config(enabled_override: Option<bool>) -> (Option<PathBuf>, bool) {
|
||||||
let enabled = env::var("ARCHIVR_UBLOCK").unwrap_or_else(|_| "true".to_string());
|
// The override (from instance settings or per-capture body) takes precedence over env.
|
||||||
if enabled.eq_ignore_ascii_case("false") || enabled == "0" {
|
let want_ublock = enabled_override.unwrap_or_else(|| {
|
||||||
|
let env_val = env::var("ARCHIVR_UBLOCK").unwrap_or_else(|_| "true".to_string());
|
||||||
|
!env_val.eq_ignore_ascii_case("false") && env_val != "0"
|
||||||
|
});
|
||||||
|
if !want_ublock {
|
||||||
return (None, false);
|
return (None, false);
|
||||||
}
|
}
|
||||||
match env::var("ARCHIVR_UBLOCK_EXT").ok().filter(|s| !s.is_empty()) {
|
match env::var("ARCHIVR_UBLOCK_EXT").ok().filter(|s| !s.is_empty()) {
|
||||||
None => {
|
None => {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"warn: uBlock: ARCHIVR_UBLOCK=true but ARCHIVR_UBLOCK_EXT is not set; \
|
"warn: uBlock: ARCHIVR_UBLOCK_EXT is not set; \
|
||||||
capturing without ad-blocking"
|
capturing without ad-blocking"
|
||||||
);
|
);
|
||||||
(None, true)
|
(None, true)
|
||||||
|
|
|
||||||
|
|
@ -653,6 +653,8 @@ struct CaptureBody {
|
||||||
/// Optional quality cap for yt-dlp sources: `"best"` or any `"NNNp"` string
|
/// Optional quality cap for yt-dlp sources: `"best"` or any `"NNNp"` string
|
||||||
/// (e.g. `"1080p"`, `"720p"`, `"2160p"`). Absent or `"best"` → highest available.
|
/// (e.g. `"1080p"`, `"720p"`, `"2160p"`). Absent or `"best"` → highest available.
|
||||||
quality: Option<String>,
|
quality: Option<String>,
|
||||||
|
/// Per-capture uBlock override. Absent → use global instance setting.
|
||||||
|
ublock_enabled: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, serde::Deserialize)]
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
|
@ -733,14 +735,26 @@ async fn capture_handler(
|
||||||
let job_uid = database::create_capture_job(&conn, &archive_id)?;
|
let job_uid = database::create_capture_job(&conn, &archive_id)?;
|
||||||
drop(conn);
|
drop(conn);
|
||||||
|
|
||||||
// Load cookie rules from the auth DB to pass into the capture background task.
|
// Load cookie rules and global uBlock setting from the auth DB.
|
||||||
let cookie_rules = {
|
let (cookie_rules, global_ublock) = {
|
||||||
match database::open_auth_db(&state.auth_db_path) {
|
match database::open_auth_db(&state.auth_db_path) {
|
||||||
Ok(conn) => database::list_cookie_rules(&conn).unwrap_or_default(),
|
Ok(conn) => {
|
||||||
Err(_) => vec![],
|
let rules = database::list_cookie_rules(&conn).unwrap_or_default();
|
||||||
|
let ublock = database::get_instance_settings(&conn)
|
||||||
|
.map(|s| s.ublock_enabled)
|
||||||
|
.unwrap_or(true);
|
||||||
|
(rules, ublock)
|
||||||
|
}
|
||||||
|
Err(_) => (vec![], true),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let capture_config = capture::CaptureConfig { cookie_rules };
|
// Per-capture body overrides global; if body doesn't specify, use the global setting.
|
||||||
|
// The resolved bool is then passed as Some(_) to singlefile, overriding the env var.
|
||||||
|
let effective_ublock = body.ublock_enabled.unwrap_or(global_ublock);
|
||||||
|
let capture_config = capture::CaptureConfig {
|
||||||
|
cookie_rules,
|
||||||
|
ublock_enabled: Some(effective_ublock),
|
||||||
|
};
|
||||||
|
|
||||||
// Spawn background capture.
|
// Spawn background capture.
|
||||||
let locator = body.locator.trim().to_string();
|
let locator = body.locator.trim().to_string();
|
||||||
|
|
@ -1020,10 +1034,20 @@ async fn patch_me(
|
||||||
async fn get_instance_settings_handler(
|
async fn get_instance_settings_handler(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
auth_user: AuthUser,
|
auth_user: AuthUser,
|
||||||
) -> Result<Json<database::InstanceSettings>, ApiError> {
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
auth_user.require_role(ROLE_ADMIN)?;
|
auth_user.require_role(ROLE_ADMIN)?;
|
||||||
let conn = database::open_auth_db(&state.auth_db_path)?;
|
let conn = database::open_auth_db(&state.auth_db_path)?;
|
||||||
Ok(Json(database::get_instance_settings(&conn)?))
|
let settings = database::get_instance_settings(&conn)?;
|
||||||
|
let ublock_ext_available = std::env::var("ARCHIVR_UBLOCK_EXT")
|
||||||
|
.ok()
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(|p| std::path::Path::new(&p).is_dir())
|
||||||
|
.unwrap_or(false);
|
||||||
|
let mut val = serde_json::to_value(&settings).unwrap_or_default();
|
||||||
|
if let Some(obj) = val.as_object_mut() {
|
||||||
|
obj.insert("ublock_ext_available".into(), serde_json::Value::Bool(ublock_ext_available));
|
||||||
|
}
|
||||||
|
Ok(Json(val))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn update_instance_settings_handler(
|
async fn update_instance_settings_handler(
|
||||||
|
|
@ -1038,6 +1062,7 @@ async fn update_instance_settings_handler(
|
||||||
if let Some(v) = body.public_entry_content_enabled { settings.public_entry_content_enabled = v; }
|
if let Some(v) = body.public_entry_content_enabled { settings.public_entry_content_enabled = v; }
|
||||||
if let Some(v) = body.open_registration_enabled { settings.open_registration_enabled = v; }
|
if let Some(v) = body.open_registration_enabled { settings.open_registration_enabled = v; }
|
||||||
if let Some(v) = body.default_entry_visibility { settings.default_entry_visibility = v; }
|
if let Some(v) = body.default_entry_visibility { settings.default_entry_visibility = v; }
|
||||||
|
if let Some(v) = body.ublock_enabled { settings.ublock_enabled = v; }
|
||||||
database::update_instance_settings(&conn, &settings)?;
|
database::update_instance_settings(&conn, &settings)?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
@ -1367,6 +1392,7 @@ struct UpdateInstanceSettingsBody {
|
||||||
public_entry_content_enabled: Option<bool>,
|
public_entry_content_enabled: Option<bool>,
|
||||||
open_registration_enabled: Option<bool>,
|
open_registration_enabled: Option<bool>,
|
||||||
default_entry_visibility: Option<u32>,
|
default_entry_visibility: Option<u32>,
|
||||||
|
ublock_enabled: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn admin_list_users(
|
async fn admin_list_users(
|
||||||
|
|
|
||||||
40
crates/archivr-server/static/assets/index-BB_JhtCc.js
Normal file
40
crates/archivr-server/static/assets/index-BB_JhtCc.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
crates/archivr-server/static/assets/index-C0BIEdow.css
Normal file
1
crates/archivr-server/static/assets/index-C0BIEdow.css
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -4,8 +4,8 @@
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>Archivr</title>
|
<title>Archivr</title>
|
||||||
<script type="module" crossorigin src="/assets/index-BpXoSc97.js"></script>
|
<script type="module" crossorigin src="/assets/index-BB_JhtCc.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-BGSI2WEx.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-C0BIEdow.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
|
|
@ -95,9 +95,13 @@ export async function fetchTags(archiveId) {
|
||||||
return getJson(`/api/archives/${archiveId}/tags`);
|
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 }
|
const payload = { locator }
|
||||||
if (quality && quality !== 'best') payload.quality = quality
|
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`, {
|
const res = await fetch(`/api/archives/${archiveId}/captures`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useRef, useEffect, useState } from 'react'
|
import { useRef, useEffect, useState, useCallback } from 'react'
|
||||||
import { submitCapture, pollCaptureJob, probeCapture } from '../api'
|
import { submitCapture, pollCaptureJob, probeCapture, getInstanceSettings } from '../api'
|
||||||
|
|
||||||
let nextItemId = 1
|
let nextItemId = 1
|
||||||
|
|
||||||
|
|
@ -112,6 +112,23 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
||||||
sessionStorage.setItem('captureItems', JSON.stringify(items))
|
sessionStorage.setItem('captureItems', JSON.stringify(items))
|
||||||
}, [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
|
// On mount: clean up old single-locator sessionStorage keys; reconnect running jobs
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
;['captureDialogLocator','captureDialogError','captureDialogBusy',
|
;['captureDialogLocator','captureDialogError','captureDialogBusy',
|
||||||
|
|
@ -223,7 +240,8 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
||||||
const qual = item.quality || 'best'
|
const qual = item.quality || 'best'
|
||||||
setItems(prev => prev.map(it => it.id === item.id ? { ...it, status: 'submitting', error: null } : it))
|
setItems(prev => prev.map(it => it.id === item.id ? { ...it, status: 'submitting', error: null } : it))
|
||||||
try {
|
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 =>
|
setItems(prev => prev.map(it =>
|
||||||
it.id === item.id ? { ...it, status: 'running', jobUid: job.job_uid, archiveId: aid } : 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
|
Add another
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="capture-actions">
|
{/* ── Advanced options ────────────────────────────── */}
|
||||||
<button type="button" className="capture-cancel" onClick={() => dialogRef.current?.close()}>
|
<div className="capture-advanced">
|
||||||
{anyActive ? 'Close' : 'Cancel'}
|
<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>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="capture-submit"
|
className="capture-submit"
|
||||||
|
|
@ -354,6 +409,9 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
||||||
>
|
>
|
||||||
{pendingCount > 1 ? `Archive ${pendingCount}` : 'Archive'}
|
{pendingCount > 1 ? `Archive ${pendingCount}` : 'Archive'}
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" className="capture-cancel" onClick={() => dialogRef.current?.close()}>
|
||||||
|
{anyActive ? 'Close' : 'Cancel'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,8 @@ export default function SettingsView({ tab, onTabChange, archiveId }) {
|
||||||
const { currentUser, setCurrentUser } = useContext(AuthContext) ?? {}
|
const { currentUser, setCurrentUser } = useContext(AuthContext) ?? {}
|
||||||
const isAdmin = currentUser && ((currentUser.role_bits & ROLE_ADMIN) !== 0)
|
const isAdmin = currentUser && ((currentUser.role_bits & ROLE_ADMIN) !== 0)
|
||||||
|
|
||||||
const tabs = ['profile', 'tokens', ...(isAdmin ? ['instance', 'cookies', 'storage'] : [])]
|
const tabs = ['profile', 'tokens', ...(isAdmin ? ['instance', 'cookies', 'extensions', 'storage'] : [])]
|
||||||
const tabLabels = { profile: 'Profile', tokens: 'API Tokens', instance: 'Instance', cookies: 'Cookies', storage: 'Storage' }
|
const tabLabels = { profile: 'Profile', tokens: 'API Tokens', instance: 'Instance', cookies: 'Cookies', extensions: 'Extensions', storage: 'Storage' }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="admin-view">
|
<section className="admin-view">
|
||||||
|
|
@ -34,6 +34,7 @@ export default function SettingsView({ tab, onTabChange, archiveId }) {
|
||||||
{tab === 'tokens' && <TokensTab />}
|
{tab === 'tokens' && <TokensTab />}
|
||||||
{tab === 'instance' && isAdmin && <InstanceTab />}
|
{tab === 'instance' && isAdmin && <InstanceTab />}
|
||||||
{tab === 'cookies' && isAdmin && <CookiesTab />}
|
{tab === 'cookies' && isAdmin && <CookiesTab />}
|
||||||
|
{tab === 'extensions' && isAdmin && <ExtensionsTab />}
|
||||||
{tab === 'storage' && isAdmin && <StorageTab archiveId={archiveId} />}
|
{tab === 'storage' && isAdmin && <StorageTab archiveId={archiveId} />}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|
@ -638,4 +639,81 @@ function CookiesTab() {
|
||||||
</div>
|
</div>
|
||||||
</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 {
|
.capture-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
flex-direction: column;
|
||||||
justify-content: flex-end;
|
gap: 8px;
|
||||||
margin-top: 16px;
|
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 {
|
.capture-submit {
|
||||||
border: 0;
|
border: 0;
|
||||||
background: var(--ink);
|
background: var(--ink);
|
||||||
color: var(--paper);
|
color: var(--paper);
|
||||||
padding: 8px 20px;
|
padding: 13px 20px;
|
||||||
border-radius: var(--r);
|
border-radius: var(--r2);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
font-size: 15px;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 220px;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
}
|
}
|
||||||
.capture-submit:hover { opacity: 0.85; }
|
.capture-submit:hover { opacity: 0.85; }
|
||||||
.capture-submit:disabled { opacity: 0.45; cursor: default; }
|
.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 + multi-row ─────────────────────────────────── */
|
||||||
.capture-dialog-header {
|
.capture-dialog-header {
|
||||||
|
|
@ -906,6 +962,72 @@ select {
|
||||||
}
|
}
|
||||||
.toast-ignore-btn { color: var(--muted); }
|
.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 ─────────────────────────────────────────────────────────────── */
|
/* ── Utility ─────────────────────────────────────────────────────────────── */
|
||||||
.muted { color: var(--muted); }
|
.muted { color: var(--muted); }
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue