mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-21 18:55:36 +02:00
feat: add db and multi-archive web UI foundation (#8)
* Add SQLite metadata database support * Implement archive metadata database * chore: let's guess cargoHash because there's something wrong with nixpkgs! * Gate test-only database helpers behind cfg(test) * Fix archive database row identity * Use serde for archive metadata JSON * Finalize archive runs at command level * Handle archive command errors without panics * Cover tweet entry metadata recording * Document static regex invariants * docs: add web UI design spec * docs: add web UI implementation plan * chore: move cli into workspace crate * chore: track workspace crates directory * refactor: extract archive core crate * refactor: add core archive opening APIs * refactor: rename taxonomy model to tags * feat: add archive query APIs * feat: add web server registry * feat: expose archive server APIs * feat: add archive table web UI * fix: complete web UI smoke path * docs: add architecture mental model * docs: remove private superpowers plans * nix: split cli and server packages * chore: remove PLAN.md
This commit is contained in:
parent
cc380ec5ba
commit
b56c969624
25 changed files with 3928 additions and 171 deletions
17
crates/archivr-server/Cargo.toml
Normal file
17
crates/archivr-server/Cargo.toml
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
[package]
|
||||
name = "archivr-server"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
archivr-core = { path = "../archivr-core" }
|
||||
axum.workspace = true
|
||||
serde.workspace = true
|
||||
tokio.workspace = true
|
||||
toml.workspace = true
|
||||
tower-http.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
tower.workspace = true
|
||||
20
crates/archivr-server/src/main.rs
Normal file
20
crates/archivr-server/src/main.rs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
mod registry;
|
||||
mod routes;
|
||||
|
||||
use anyhow::Result;
|
||||
use std::{net::SocketAddr, path::PathBuf};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let config_path = std::env::args()
|
||||
.nth(1)
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("archivr-server.toml"));
|
||||
let registry = registry::load_registry(&config_path)?;
|
||||
let app = routes::app(registry);
|
||||
let addr = SocketAddr::from(([127, 0, 0, 1], 8080));
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
println!("archivr-server listening on http://{addr}");
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
111
crates/archivr-server/src/registry.rs
Normal file
111
crates/archivr-server/src/registry.rs
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
use anyhow::{Context, Result, bail};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct MountedArchive {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub archive_path: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
|
||||
pub struct ServerRegistry {
|
||||
pub archives: Vec<MountedArchive>,
|
||||
}
|
||||
|
||||
pub fn load_registry(path: &Path) -> Result<ServerRegistry> {
|
||||
let contents = fs::read_to_string(path)
|
||||
.with_context(|| format!("failed to read server registry {}", path.display()))?;
|
||||
let registry = toml::from_str::<ServerRegistry>(&contents)
|
||||
.with_context(|| format!("failed to parse server registry {}", path.display()))?;
|
||||
validate_registry(®istry)?;
|
||||
Ok(registry)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn save_registry(path: &Path, registry: &ServerRegistry) -> Result<()> {
|
||||
validate_registry(registry)?;
|
||||
let contents = toml::to_string_pretty(registry)?;
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::write(path, contents)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_registry(registry: &ServerRegistry) -> Result<()> {
|
||||
let mut ids = std::collections::HashSet::new();
|
||||
for archive in ®istry.archives {
|
||||
if archive.id.trim().is_empty() {
|
||||
bail!("archive id must not be empty");
|
||||
}
|
||||
if !ids.insert(archive.id.as_str()) {
|
||||
bail!("duplicate archive id: {}", archive.id);
|
||||
}
|
||||
if !archive.archive_path.ends_with(".archivr") {
|
||||
bail!(
|
||||
"mounted archive path must point at a .archivr directory: {}",
|
||||
archive.archive_path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn registry_round_trips_archives_from_toml() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let archive_path = temp.path().join("personal").join(".archivr");
|
||||
fs::create_dir_all(&archive_path).unwrap();
|
||||
fs::write(archive_path.join("name"), "Personal").unwrap();
|
||||
fs::write(
|
||||
archive_path.join("store_path"),
|
||||
temp.path().join("store").display().to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let registry = ServerRegistry {
|
||||
archives: vec![MountedArchive {
|
||||
id: "personal".to_string(),
|
||||
label: "Personal".to_string(),
|
||||
archive_path: archive_path.clone(),
|
||||
}],
|
||||
};
|
||||
let path = temp.path().join("server.toml");
|
||||
save_registry(&path, ®istry).unwrap();
|
||||
|
||||
let loaded = load_registry(&path).unwrap();
|
||||
|
||||
assert_eq!(loaded, registry);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_rejects_duplicate_archive_ids() {
|
||||
let registry = ServerRegistry {
|
||||
archives: vec![
|
||||
MountedArchive {
|
||||
id: "personal".to_string(),
|
||||
label: "Personal".to_string(),
|
||||
archive_path: PathBuf::from("/tmp/a/.archivr"),
|
||||
},
|
||||
MountedArchive {
|
||||
id: "personal".to_string(),
|
||||
label: "Duplicate".to_string(),
|
||||
archive_path: PathBuf::from("/tmp/b/.archivr"),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let err = validate_registry(®istry).unwrap_err().to_string();
|
||||
|
||||
assert!(err.contains("duplicate archive id"));
|
||||
}
|
||||
}
|
||||
168
crates/archivr-server/src/routes.rs
Normal file
168
crates/archivr-server/src/routes.rs
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
use std::{path::PathBuf, sync::Arc};
|
||||
|
||||
use archivr_core::{archive, database};
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
routing::get,
|
||||
};
|
||||
use tower_http::services::{ServeDir, ServeFile};
|
||||
|
||||
use crate::registry::{MountedArchive, ServerRegistry};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
registry: Arc<ServerRegistry>,
|
||||
}
|
||||
|
||||
pub fn app(registry: ServerRegistry) -> Router {
|
||||
let state = AppState {
|
||||
registry: Arc::new(registry),
|
||||
};
|
||||
let static_dir = static_dir();
|
||||
|
||||
Router::new()
|
||||
.route("/health", get(|| async { "ok" }))
|
||||
.route("/api/archives", get(list_archives))
|
||||
.route("/api/archives/:archive_id/entries", get(list_entries))
|
||||
.route(
|
||||
"/api/archives/:archive_id/entries/:entry_uid",
|
||||
get(entry_detail),
|
||||
)
|
||||
.route("/api/archives/:archive_id/runs", get(list_runs))
|
||||
.nest_service("/assets", ServeDir::new(&static_dir))
|
||||
.fallback_service(ServeFile::new(static_dir.join("index.html")))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
fn static_dir() -> PathBuf {
|
||||
std::env::var_os("ARCHIVR_STATIC_DIR")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("static"))
|
||||
}
|
||||
|
||||
async fn list_archives(State(state): State<AppState>) -> Json<Vec<MountedArchive>> {
|
||||
Json(state.registry.archives.clone())
|
||||
}
|
||||
|
||||
async fn list_entries(
|
||||
State(state): State<AppState>,
|
||||
Path(archive_id): Path<String>,
|
||||
) -> Result<Json<Vec<archive::EntrySummary>>, ApiError> {
|
||||
let mounted = mounted_archive(&state, &archive_id)?;
|
||||
let conn = database::open_or_initialize(&mounted.archive_path)?;
|
||||
Ok(Json(archive::list_root_entries(&conn)?))
|
||||
}
|
||||
|
||||
async fn entry_detail(
|
||||
State(state): State<AppState>,
|
||||
Path((archive_id, entry_uid)): Path<(String, String)>,
|
||||
) -> Result<Json<archive::EntryDetail>, ApiError> {
|
||||
let mounted = mounted_archive(&state, &archive_id)?;
|
||||
let conn = database::open_or_initialize(&mounted.archive_path)?;
|
||||
let detail = archive::get_entry_detail(&conn, &entry_uid)?
|
||||
.ok_or(ApiError::not_found("entry not found"))?;
|
||||
Ok(Json(detail))
|
||||
}
|
||||
|
||||
async fn list_runs(
|
||||
State(state): State<AppState>,
|
||||
Path(archive_id): Path<String>,
|
||||
) -> Result<Json<Vec<archive::RunSummary>>, ApiError> {
|
||||
let mounted = mounted_archive(&state, &archive_id)?;
|
||||
let conn = database::open_or_initialize(&mounted.archive_path)?;
|
||||
Ok(Json(archive::list_runs(&conn)?))
|
||||
}
|
||||
|
||||
fn mounted_archive<'a>(
|
||||
state: &'a AppState,
|
||||
archive_id: &str,
|
||||
) -> Result<&'a MountedArchive, ApiError> {
|
||||
state
|
||||
.registry
|
||||
.archives
|
||||
.iter()
|
||||
.find(|archive| archive.id == archive_id)
|
||||
.ok_or(ApiError::not_found("archive not found"))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ApiError {
|
||||
status: StatusCode,
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl ApiError {
|
||||
fn not_found(message: &str) -> Self {
|
||||
Self {
|
||||
status: StatusCode::NOT_FOUND,
|
||||
message: message.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<E> From<E> for ApiError
|
||||
where
|
||||
E: Into<anyhow::Error>,
|
||||
{
|
||||
fn from(error: E) -> Self {
|
||||
let error = error.into();
|
||||
Self {
|
||||
status: StatusCode::INTERNAL_SERVER_ERROR,
|
||||
message: format!("{error:#}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
(self.status, self.message).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use tower::ServiceExt;
|
||||
|
||||
#[tokio::test]
|
||||
async fn archives_endpoint_lists_mounted_archives() {
|
||||
let registry = ServerRegistry {
|
||||
archives: vec![MountedArchive {
|
||||
id: "personal".to_string(),
|
||||
label: "Personal".to_string(),
|
||||
archive_path: std::path::PathBuf::from("/tmp/personal/.archivr"),
|
||||
}],
|
||||
};
|
||||
let response = app(registry)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/archives")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_archive_returns_404() {
|
||||
let response = app(ServerRegistry::default())
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/archives/missing/entries")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
}
|
||||
218
crates/archivr-server/static/app.js
Normal file
218
crates/archivr-server/static/app.js
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
const state = {
|
||||
archives: [],
|
||||
archiveId: null,
|
||||
entries: [],
|
||||
filteredEntries: [],
|
||||
selectedEntryUid: null,
|
||||
};
|
||||
|
||||
const archiveSwitcher = document.querySelector("#archive-switcher");
|
||||
const entriesBody = document.querySelector("#entries-body");
|
||||
const runsBody = document.querySelector("#runs-body");
|
||||
const contextBody = document.querySelector("#context-body");
|
||||
const navButtons = document.querySelectorAll(".nav-link");
|
||||
const searchInput = document.querySelector("#search");
|
||||
const resultCount = document.querySelector("#result-count");
|
||||
const adminArchives = document.querySelector("#admin-archives");
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (!bytes) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
let size = bytes;
|
||||
let unit = 0;
|
||||
while (size >= 1024 && unit < units.length - 1) {
|
||||
size /= 1024;
|
||||
unit += 1;
|
||||
}
|
||||
return `${size.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
|
||||
}
|
||||
|
||||
function valueText(value) {
|
||||
return value ?? "";
|
||||
}
|
||||
|
||||
async function getJson(url) {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`${response.status} ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function appendCell(row, text, className) {
|
||||
const cell = document.createElement("td");
|
||||
if (className) cell.className = className;
|
||||
cell.textContent = text;
|
||||
row.append(cell);
|
||||
return cell;
|
||||
}
|
||||
|
||||
function renderArchives() {
|
||||
archiveSwitcher.innerHTML = "";
|
||||
for (const archive of state.archives) {
|
||||
const option = document.createElement("option");
|
||||
option.value = archive.id;
|
||||
option.textContent = archive.label;
|
||||
archiveSwitcher.append(option);
|
||||
}
|
||||
archiveSwitcher.value = state.archiveId ?? "";
|
||||
|
||||
adminArchives.innerHTML = "";
|
||||
for (const archive of state.archives) {
|
||||
const item = document.createElement("div");
|
||||
item.className = "admin-archive";
|
||||
const label = document.createElement("strong");
|
||||
label.textContent = archive.label;
|
||||
const path = document.createElement("div");
|
||||
path.className = "muted";
|
||||
path.textContent = archive.archive_path;
|
||||
item.append(label, path);
|
||||
adminArchives.append(item);
|
||||
}
|
||||
}
|
||||
|
||||
function applyEntryFilter() {
|
||||
const query = searchInput.value.trim().toLowerCase();
|
||||
if (!query) {
|
||||
state.filteredEntries = state.entries;
|
||||
return;
|
||||
}
|
||||
state.filteredEntries = state.entries.filter((entry) => {
|
||||
const haystack = [
|
||||
entry.title,
|
||||
entry.original_url,
|
||||
entry.entry_uid,
|
||||
entry.source_kind,
|
||||
entry.entity_kind,
|
||||
entry.visibility,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return haystack.includes(query);
|
||||
});
|
||||
}
|
||||
|
||||
function renderEntries() {
|
||||
entriesBody.innerHTML = "";
|
||||
resultCount.textContent = `${state.filteredEntries.length} entries`;
|
||||
|
||||
for (const entry of state.filteredEntries) {
|
||||
const row = document.createElement("tr");
|
||||
row.tabIndex = 0;
|
||||
row.dataset.entryUid = entry.entry_uid;
|
||||
if (entry.entry_uid === state.selectedEntryUid) {
|
||||
row.classList.add("is-selected");
|
||||
}
|
||||
|
||||
appendCell(row, valueText(entry.archived_at));
|
||||
|
||||
const titleCell = appendCell(row, "");
|
||||
const title = document.createElement("span");
|
||||
title.className = "entry-title";
|
||||
title.textContent = valueText(entry.title) || valueText(entry.entry_uid);
|
||||
titleCell.append(title);
|
||||
|
||||
const typeCell = appendCell(row, "");
|
||||
const type = document.createElement("span");
|
||||
type.className = "type-pill";
|
||||
type.textContent = valueText(entry.entity_kind);
|
||||
typeCell.append(type);
|
||||
|
||||
appendCell(row, formatBytes(entry.total_artifact_bytes));
|
||||
appendCell(row, valueText(entry.original_url), "url-cell");
|
||||
|
||||
row.addEventListener("click", () => selectEntry(entry));
|
||||
row.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter") selectEntry(entry);
|
||||
});
|
||||
entriesBody.append(row);
|
||||
}
|
||||
}
|
||||
|
||||
function renderContextDetail(detail) {
|
||||
contextBody.innerHTML = "";
|
||||
const title = document.createElement("strong");
|
||||
title.textContent = valueText(detail.summary.title) || valueText(detail.summary.entry_uid);
|
||||
contextBody.append(title);
|
||||
|
||||
const items = [
|
||||
["Type", detail.summary.entity_kind],
|
||||
["Visibility", detail.summary.visibility],
|
||||
["Artifacts", detail.artifacts.length],
|
||||
["Structured root", detail.structured_root_relpath],
|
||||
];
|
||||
|
||||
for (const [label, value] of items) {
|
||||
const item = document.createElement("div");
|
||||
item.className = "rail-item";
|
||||
item.textContent = `${label}: ${valueText(value)}`;
|
||||
contextBody.append(item);
|
||||
}
|
||||
}
|
||||
|
||||
async function selectEntry(entry) {
|
||||
state.selectedEntryUid = entry.entry_uid;
|
||||
renderEntries();
|
||||
const detail = await getJson(`/api/archives/${state.archiveId}/entries/${entry.entry_uid}`);
|
||||
renderContextDetail(detail);
|
||||
}
|
||||
|
||||
async function loadRuns() {
|
||||
const runs = await getJson(`/api/archives/${state.archiveId}/runs`);
|
||||
runsBody.innerHTML = "";
|
||||
for (const run of runs) {
|
||||
const row = document.createElement("tr");
|
||||
appendCell(row, valueText(run.started_at));
|
||||
appendCell(row, valueText(run.status));
|
||||
appendCell(row, String(run.requested_count));
|
||||
appendCell(row, String(run.completed_count));
|
||||
appendCell(row, String(run.failed_count));
|
||||
runsBody.append(row);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEntries() {
|
||||
state.entries = await getJson(`/api/archives/${state.archiveId}/entries`);
|
||||
state.selectedEntryUid = null;
|
||||
applyEntryFilter();
|
||||
renderEntries();
|
||||
contextBody.textContent = "Select an entry.";
|
||||
}
|
||||
|
||||
async function loadArchives() {
|
||||
state.archives = await getJson("/api/archives");
|
||||
state.archiveId = state.archives[0]?.id ?? null;
|
||||
renderArchives();
|
||||
if (state.archiveId) {
|
||||
await loadEntries();
|
||||
await loadRuns();
|
||||
} else {
|
||||
contextBody.textContent = "No archives mounted.";
|
||||
resultCount.textContent = "0 entries";
|
||||
}
|
||||
}
|
||||
|
||||
archiveSwitcher.addEventListener("change", async () => {
|
||||
state.archiveId = archiveSwitcher.value;
|
||||
await loadEntries();
|
||||
await loadRuns();
|
||||
});
|
||||
|
||||
searchInput.addEventListener("input", () => {
|
||||
applyEntryFilter();
|
||||
renderEntries();
|
||||
});
|
||||
|
||||
navButtons.forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
navButtons.forEach((candidate) => candidate.classList.remove("is-active"));
|
||||
document.querySelectorAll(".view").forEach((view) => view.classList.remove("is-active"));
|
||||
button.classList.add("is-active");
|
||||
document.querySelector(`#${button.dataset.view}-view`).classList.add("is-active");
|
||||
});
|
||||
});
|
||||
|
||||
loadArchives().catch((error) => {
|
||||
contextBody.textContent = `Failed to load archives: ${error.message}`;
|
||||
});
|
||||
79
crates/archivr-server/static/index.html
Normal file
79
crates/archivr-server/static/index.html
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Archivr</title>
|
||||
<link rel="stylesheet" href="/assets/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand">Archivr</div>
|
||||
<select id="archive-switcher" class="archive-switcher" aria-label="Select archive"></select>
|
||||
<nav class="nav" aria-label="Primary">
|
||||
<button class="nav-link is-active" data-view="archive">Archive</button>
|
||||
<button class="nav-link" data-view="runs">Runs</button>
|
||||
<button class="nav-link" data-view="admin">Admin</button>
|
||||
</nav>
|
||||
<button class="capture-button">+ Capture</button>
|
||||
</header>
|
||||
|
||||
<main class="app-shell">
|
||||
<section class="workspace">
|
||||
<div class="search-row">
|
||||
<input id="search" class="search-input" type="search" aria-label="Search archive" autocomplete="off">
|
||||
<div id="result-count" class="result-count"></div>
|
||||
</div>
|
||||
|
||||
<section id="archive-view" class="view is-active">
|
||||
<table class="entry-table">
|
||||
<colgroup>
|
||||
<col class="col-added">
|
||||
<col class="col-title">
|
||||
<col class="col-type">
|
||||
<col class="col-size">
|
||||
<col class="col-url">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Added</th>
|
||||
<th>Title</th>
|
||||
<th>Type</th>
|
||||
<th>Size</th>
|
||||
<th>Original URL</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="entries-body"></tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section id="runs-view" class="view">
|
||||
<table class="entry-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Started</th>
|
||||
<th>Status</th>
|
||||
<th>Requested</th>
|
||||
<th>Completed</th>
|
||||
<th>Failed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="runs-body"></tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section id="admin-view" class="view admin-view">
|
||||
<h1>Mounted Archives</h1>
|
||||
<div id="admin-archives" class="admin-list"></div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<aside class="context-rail">
|
||||
<div class="rail-title">Context</div>
|
||||
<div id="context-body" class="rail-body">Select an entry.</div>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<script type="module" src="/assets/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
314
crates/archivr-server/static/styles.css
Normal file
314
crates/archivr-server/static/styles.css
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
:root {
|
||||
color-scheme: light;
|
||||
--ink: #20251f;
|
||||
--muted: #666a61;
|
||||
--paper: #f5f0e7;
|
||||
--paper-2: #e9e1d2;
|
||||
--paper-3: #fffaf0;
|
||||
--line: #d2c6b5;
|
||||
--line-soft: #e5dccd;
|
||||
--accent: #8d3f30;
|
||||
--accent-2: #b78342;
|
||||
--link: #245f72;
|
||||
--top: #141d18;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
height: 56px;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(180px, 250px) 1fr auto;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
padding: 0 18px;
|
||||
background: var(--top);
|
||||
color: #f7eedf;
|
||||
border-bottom: 3px solid var(--accent);
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-family: Georgia, "Times New Roman", serif;
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.archive-switcher {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 1px solid rgba(247, 238, 223, 0.35);
|
||||
background: #202b25;
|
||||
color: #f7eedf;
|
||||
padding: 7px 9px;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
justify-content: flex-end;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #d7cdbf;
|
||||
cursor: pointer;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.nav-link.is-active {
|
||||
color: #fffaf0;
|
||||
border-bottom: 1px solid #fffaf0;
|
||||
}
|
||||
|
||||
.capture-button {
|
||||
border: 0;
|
||||
background: #f7eedf;
|
||||
color: var(--top);
|
||||
padding: 9px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
height: calc(100vh - 56px);
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 320px;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.search-row {
|
||||
min-height: 76px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) max-content;
|
||||
gap: 18px;
|
||||
align-items: center;
|
||||
padding: 14px 16px;
|
||||
background: var(--paper-2);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
height: 46px;
|
||||
border: 2px solid var(--ink);
|
||||
background: var(--paper-3);
|
||||
color: var(--ink);
|
||||
padding: 0 14px;
|
||||
font-size: 16px;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.result-count {
|
||||
min-width: 76px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.view {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.view.is-active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.entry-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.entry-table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
text-align: left;
|
||||
padding: 10px;
|
||||
background: #ded5c7;
|
||||
color: #5d625e;
|
||||
border-bottom: 1px solid #cec4b5;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.entry-table td {
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.entry-table tr:nth-child(even) td {
|
||||
background: #f2ede5;
|
||||
}
|
||||
|
||||
.entry-table tr:nth-child(odd) td {
|
||||
background: var(--paper-3);
|
||||
}
|
||||
|
||||
.entry-table tr {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.entry-table tr.is-selected td {
|
||||
background: #eee2d2;
|
||||
border-top: 2px solid var(--accent);
|
||||
border-bottom: 2px solid var(--accent);
|
||||
}
|
||||
|
||||
.col-added {
|
||||
width: 178px;
|
||||
}
|
||||
|
||||
.col-title {
|
||||
width: 38%;
|
||||
}
|
||||
|
||||
.col-type {
|
||||
width: 130px;
|
||||
}
|
||||
|
||||
.col-size {
|
||||
width: 110px;
|
||||
}
|
||||
|
||||
.col-url {
|
||||
width: 34%;
|
||||
}
|
||||
|
||||
.entry-title {
|
||||
color: var(--link);
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.url-cell {
|
||||
color: #555b55;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.type-pill {
|
||||
display: inline-block;
|
||||
padding: 2px 6px;
|
||||
background: #d8e3df;
|
||||
color: #275a5f;
|
||||
border: 1px solid #bfd0ca;
|
||||
}
|
||||
|
||||
.context-rail {
|
||||
border-left: 1px solid var(--line);
|
||||
background: #efe7dc;
|
||||
padding: 18px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.rail-title {
|
||||
color: var(--accent);
|
||||
font-weight: 800;
|
||||
margin-bottom: 10px;
|
||||
text-transform: uppercase;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.rail-body {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.rail-body strong {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.rail-item {
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.admin-view {
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.admin-view h1 {
|
||||
margin: 0 0 16px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.admin-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
max-width: 780px;
|
||||
}
|
||||
|
||||
.admin-archive {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--paper-3);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.topbar {
|
||||
grid-template-columns: 1fr auto;
|
||||
height: auto;
|
||||
min-height: 56px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.archive-switcher,
|
||||
.nav {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.nav {
|
||||
justify-content: flex-start;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
height: auto;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.search-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.result-count {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.context-rail {
|
||||
border-left: 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.entry-table {
|
||||
min-width: 860px;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue