feat: Add web UI for browsing packages in pkg6depotd

Add a human-facing web interface at /ui/ for browsing IPS package
repositories. Uses Askama templates, HTMX for interactivity, and
Pico.css for styling.

Routes:
- /ui/ - Publisher list with package counts
- /ui/packages/:publisher - Paginated package list
- /ui/search - Search with HTMX search-as-you-type
- /ui/package/:publisher/*fmri - Package detail with lazy manifest
- /ui/p5i - P5I file generation for installing package sets
This commit is contained in:
Till Wegmueller 2026-03-15 21:55:10 +01:00
parent 6f5040978b
commit d49bb3c306
No known key found for this signature in database
16 changed files with 1047 additions and 7 deletions

78
Cargo.lock generated
View file

@ -131,6 +131,58 @@ version = "1.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457"
[[package]]
name = "askama"
version = "0.15.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08e1676b346cadfec169374f949d7490fd80a24193d37d2afce0c047cf695e57"
dependencies = [
"askama_macros",
"itoa",
"percent-encoding",
"serde",
"serde_json",
]
[[package]]
name = "askama_derive"
version = "0.15.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7661ff56517787343f376f75db037426facd7c8d3049cef8911f1e75016f3a37"
dependencies = [
"askama_parser",
"basic-toml",
"memchr",
"proc-macro2",
"quote",
"rustc-hash",
"serde",
"serde_derive",
"syn 2.0.111",
]
[[package]]
name = "askama_macros"
version = "0.15.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "713ee4dbfd1eb719c2dab859465b01fa1d21cb566684614a713a6b7a99a4e47b"
dependencies = [
"askama_derive",
]
[[package]]
name = "askama_parser"
version = "0.15.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d62d674238a526418b30c0def480d5beadb9d8964e7f38d635b03bf639c704c"
dependencies = [
"rustc-hash",
"serde",
"serde_derive",
"unicode-ident",
"winnow",
]
[[package]]
name = "assert_cmd"
version = "2.1.1"
@ -326,6 +378,15 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "basic-toml"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a"
dependencies = [
"serde",
]
[[package]]
name = "bitflags"
version = "2.10.0"
@ -2124,6 +2185,7 @@ dependencies = [
name = "pkg6depotd"
version = "0.5.3"
dependencies = [
"askama",
"assert_cmd",
"axum",
"axum-server",
@ -2160,6 +2222,7 @@ dependencies = [
"tracing",
"tracing-opentelemetry",
"tracing-subscriber",
"urlencoding",
]
[[package]]
@ -3532,6 +3595,12 @@ dependencies = [
"serde",
]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "userland"
version = "0.5.3"
@ -3965,6 +4034,15 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winnow"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
dependencies = [
"memchr",
]
[[package]]
name = "winsafe"
version = "0.0.19"

View file

@ -49,6 +49,10 @@ tracing-opentelemetry = "0.32"
thiserror = "2"
miette = { version = "7.6.0", features = ["fancy"] }
# Templating & Web UI
askama = "0.15"
urlencoding = "2"
# Project Dependencies
libips = { path = "../libips" }

View file

@ -146,7 +146,7 @@ pub async fn get_info(
// - Prefer publisher-scoped file path; fallback to global location.
// - If content appears to be gzip-compressed (magic 1f 8b), decompress.
// - Decode as UTF-8 (lossy) and enforce a maximum output size to avoid huge responses.
fn resolve_license_text(repo: &DepotRepo, publisher: &str, digest: &str) -> Option<String> {
pub(crate) fn resolve_license_text(repo: &DepotRepo, publisher: &str, digest: &str) -> Option<String> {
let path = repo.get_file_path(publisher, digest)?;
let bytes = fs::read(&path).ok()?;
@ -181,7 +181,7 @@ fn resolve_license_text(repo: &DepotRepo, publisher: &str, digest: &str) -> Opti
Some(text)
}
fn find_attr(manifest: &Manifest, key: &str) -> Option<String> {
pub(crate) fn find_attr(manifest: &Manifest, key: &str) -> Option<String> {
for attr in &manifest.attributes {
if attr.key == key {
return attr.values.first().cloned();
@ -208,7 +208,7 @@ fn month_name(month: u32) -> &'static str {
}
}
fn format_packaging_date(ts: &str) -> Option<String> {
pub(crate) fn format_packaging_date(ts: &str) -> Option<String> {
// Expect formats like YYYYMMDDThhmmssZ or with fractional seconds before Z
let clean_ts = if let Some((base, _frac)) = ts.split_once('.') {
format!("{}Z", base)
@ -239,7 +239,7 @@ fn format_packaging_date(ts: &str) -> Option<String> {
}
// Sum pkg.size (uncompressed) and pkg.csize (compressed) over all file actions
fn compute_sizes(manifest: &Manifest) -> (u128, u128) {
pub(crate) fn compute_sizes(manifest: &Manifest) -> (u128, u128) {
let mut size: u128 = 0;
let mut csize: u128 = 0;
@ -260,7 +260,7 @@ fn compute_sizes(manifest: &Manifest) -> (u128, u128) {
(size, csize)
}
fn human_bytes(bytes: u128) -> String {
pub(crate) fn human_bytes(bytes: u128) -> String {
// Use binary (IEC-like) units for familiarity; format with two decimals for KB and above
const KIB: u128 = 1024;
const MIB: u128 = 1024 * 1024;

View file

@ -5,4 +5,5 @@ pub mod manifest;
pub mod publisher;
pub mod search;
pub mod shard;
pub mod ui;
pub mod versions;

View file

@ -0,0 +1,502 @@
use crate::errors::DepotError;
use crate::http::handlers::info::{compute_sizes, find_attr, format_packaging_date, human_bytes};
use crate::repo::DepotRepo;
use askama::Template;
use axum::{
extract::{Path, Query, State},
http::{header, HeaderMap, StatusCode},
response::{IntoResponse, Response},
};
use libips::actions::Manifest;
use libips::fmri::Fmri;
use std::collections::BTreeSet;
use std::str::FromStr;
use std::sync::Arc;
// ---------------------------------------------------------------------------
// Helper: render an Askama template into an HTML response
// ---------------------------------------------------------------------------
fn render_template(tmpl: &impl Template) -> Result<Response, DepotError> {
let html = tmpl
.render()
.map_err(|e| DepotError::Server(e.to_string()))?;
Ok(([(header::CONTENT_TYPE, "text/html; charset=utf-8")], html).into_response())
}
// ---------------------------------------------------------------------------
// Helper: URL-encode an FMRI for use in URL path segments
// ---------------------------------------------------------------------------
fn encode_fmri(fmri_str: &str) -> String {
// Encode characters that are problematic in URLs but preserve / for readability
let mut out = String::with_capacity(fmri_str.len());
for ch in fmri_str.chars() {
match ch {
'@' => out.push_str("%40"),
',' => out.push_str("%2C"),
':' => out.push_str("%3A"),
_ => out.push(ch),
}
}
out
}
// ---------------------------------------------------------------------------
// Helper: parse a manifest from text (JSON or legacy)
// ---------------------------------------------------------------------------
fn parse_manifest(content: &str) -> Result<Manifest, DepotError> {
match serde_json::from_str::<Manifest>(content) {
Ok(m) => Ok(m),
Err(_) => Manifest::parse_string(content.to_string())
.map_err(|e| DepotError::Repo(libips::repository::RepositoryError::Other(e.to_string()))),
}
}
// ---------------------------------------------------------------------------
// Helper: parse version components from an FMRI version string
// ---------------------------------------------------------------------------
struct VersionParts {
version: String,
build_release: Option<String>,
branch: Option<String>,
timestamp: Option<String>,
}
fn parse_version_parts(version_full: &str) -> VersionParts {
let mut version = version_full.to_string();
let mut build_release = None;
let mut branch = None;
let mut timestamp = None;
if let Some((core, rest)) = version_full.split_once(',') {
version = core.to_string();
if let Some((rel_branch, ts)) = rest.split_once(':') {
timestamp = Some(ts.to_string());
if let Some((rel, br)) = rel_branch.split_once('-') {
if !rel.is_empty() {
build_release = Some(rel.to_string());
}
if !br.is_empty() {
branch = Some(br.to_string());
}
} else if !rel_branch.is_empty() {
build_release = Some(rel_branch.to_string());
}
} else if let Some((rel, br)) = rest.split_once('-') {
if !rel.is_empty() {
build_release = Some(rel.to_string());
}
if !br.is_empty() {
branch = Some(br.to_string());
}
} else if !rest.is_empty() {
build_release = Some(rest.to_string());
}
}
VersionParts {
version,
build_release,
branch,
timestamp,
}
}
// ===========================================================================
// Templates
// ===========================================================================
#[derive(Template)]
#[template(path = "index.html")]
struct IndexTemplate {
publishers: Vec<PublisherDisplay>,
}
struct PublisherDisplay {
name: String,
package_count: usize,
updated: String,
}
#[derive(Template)]
#[template(path = "packages.html")]
struct PackagesTemplate {
publisher: String,
packages: Vec<PackageRow>,
page: usize,
per_page: usize,
total_pages: usize,
total_packages: usize,
}
struct PackageRow {
name: String,
version: String,
fmri_str: String,
fmri_encoded: String,
}
#[derive(Template)]
#[template(path = "search.html")]
struct SearchTemplate {
publishers: Vec<String>,
}
#[derive(Template)]
#[template(path = "partials/search_results.html")]
struct SearchResultsTemplate {
results: Vec<SearchResultRow>,
}
struct SearchResultRow {
name: String,
publisher: String,
fmri: String,
fmri_encoded: String,
}
#[derive(Template)]
#[template(path = "package_detail.html")]
struct PackageDetailTemplate {
publisher: String,
name: String,
summary: Option<String>,
version: String,
build_release: Option<String>,
branch: Option<String>,
packaging_date: Option<String>,
size: String,
csize: String,
fmri_str: String,
fmri_encoded: String,
dependencies: Vec<DepDisplay>,
}
struct DepDisplay {
dep_type: String,
fmri: String,
link: Option<String>,
}
#[derive(Template)]
#[template(path = "partials/manifest.html")]
struct ManifestTemplate {
manifest_text: String,
}
// ===========================================================================
// Query parameter structs
// ===========================================================================
#[derive(serde::Deserialize)]
pub struct PaginationParams {
page: Option<usize>,
per_page: Option<usize>,
}
#[derive(serde::Deserialize)]
pub struct SearchParams {
q: Option<String>,
publisher: Option<String>,
}
#[derive(serde::Deserialize)]
pub struct P5iParams {
publisher: String,
#[serde(default)]
pkg: Vec<String>,
}
// ===========================================================================
// Handlers
// ===========================================================================
/// GET /ui/ - Home page: publisher list with package counts
pub async fn ui_index(
State(repo): State<Arc<DepotRepo>>,
) -> Result<Response, DepotError> {
let info = repo.get_info()?;
let publishers = info
.publishers
.into_iter()
.map(|p| PublisherDisplay {
name: p.name,
package_count: p.package_count,
updated: p.updated,
})
.collect();
render_template(&IndexTemplate { publishers })
}
/// GET /ui/packages/:publisher - Paginated package list
pub async fn ui_packages(
State(repo): State<Arc<DepotRepo>>,
Path(publisher): Path<String>,
Query(params): Query<PaginationParams>,
) -> Result<Response, DepotError> {
let page = params.page.unwrap_or(1).max(1);
let per_page = params.per_page.unwrap_or(50).clamp(10, 200);
let mut pkg_list = repo.list_packages(Some(&publisher), None)?;
pkg_list.sort_by(|a, b| a.fmri.name.cmp(&b.fmri.name));
let total_packages = pkg_list.len();
let total_pages = (total_packages + per_page - 1) / per_page.max(1);
let start = (page - 1) * per_page;
let packages: Vec<PackageRow> = pkg_list
.into_iter()
.skip(start)
.take(per_page)
.map(|pi| {
let name = pi.fmri.name.clone();
let version = pi.fmri.version();
let fmri_str = if version.is_empty() {
format!("pkg://{}/{}", publisher, name)
} else {
format!("pkg://{}/{}@{}", publisher, name, version)
};
let fmri_encoded = encode_fmri(&fmri_str);
PackageRow {
name,
version,
fmri_str,
fmri_encoded,
}
})
.collect();
render_template(&PackagesTemplate {
publisher,
packages,
page,
per_page,
total_pages,
total_packages,
})
}
/// GET /ui/search - Search page (full page)
pub async fn ui_search(
State(repo): State<Arc<DepotRepo>>,
) -> Result<Response, DepotError> {
let info = repo.get_info()?;
let publishers = info.publishers.into_iter().map(|p| p.name).collect();
render_template(&SearchTemplate { publishers })
}
/// GET /ui/search/results - HTMX partial: search result rows
pub async fn ui_search_results(
State(repo): State<Arc<DepotRepo>>,
Query(params): Query<SearchParams>,
) -> Result<Response, DepotError> {
let query = match params.q {
Some(ref q) if !q.trim().is_empty() => q.trim().to_string(),
_ => {
return render_template(&SearchResultsTemplate {
results: Vec::new(),
});
}
};
let publisher = params
.publisher
.as_deref()
.filter(|p| !p.is_empty());
let entries = repo.search(publisher, &query, false)?;
// Deduplicate by FMRI
let mut seen = BTreeSet::new();
let mut results = Vec::new();
for entry in entries {
if seen.insert(entry.fmri.clone()) {
// Extract publisher and name from the FMRI string
let (pub_name, pkg_name) = if let Ok(fmri) = Fmri::from_str(&entry.fmri) {
(
fmri.publisher.unwrap_or_default(),
fmri.name.clone(),
)
} else {
// Fallback: try to parse manually
(String::new(), entry.fmri.clone())
};
let fmri_encoded = encode_fmri(&entry.fmri);
results.push(SearchResultRow {
name: pkg_name,
publisher: pub_name,
fmri: entry.fmri,
fmri_encoded,
});
}
}
render_template(&SearchResultsTemplate { results })
}
/// GET /ui/package/:publisher/*fmri - Package detail or manifest partial
pub async fn ui_package_detail(
State(repo): State<Arc<DepotRepo>>,
Path((publisher, fmri_str)): Path<(String, String)>,
) -> Result<Response, DepotError> {
// If the path ends with /manifest, serve the manifest partial instead
if fmri_str.ends_with("/manifest") {
return ui_package_manifest_inner(&repo, &publisher, &fmri_str).await;
}
let decoded_fmri = urlencoding::decode(&fmri_str)
.map(|c| c.into_owned())
.unwrap_or(fmri_str);
let fmri = Fmri::from_str(&decoded_fmri)
.map_err(|e| DepotError::Repo(libips::repository::RepositoryError::Other(e.to_string())))?;
let content = repo.get_manifest_text(&publisher, &fmri)?;
let manifest = parse_manifest(&content)?;
let name = fmri.stem().to_string();
let version_full = fmri.version();
let vp = parse_version_parts(&version_full);
let packaging_date = vp
.timestamp
.as_deref()
.and_then(format_packaging_date);
let (total_size, total_csize) = compute_sizes(&manifest);
let fmri_display = if version_full.is_empty() {
format!("pkg://{}/{}", publisher, name)
} else {
format!("pkg://{}/{}@{}", publisher, name, version_full)
};
let fmri_encoded = encode_fmri(&fmri_display);
let summary = find_attr(&manifest, "pkg.summary");
let dependencies: Vec<DepDisplay> = manifest
.dependencies
.iter()
.filter_map(|dep| {
let dep_fmri = dep.fmri.as_ref()?;
let dep_fmri_str = format!("{}", dep_fmri);
// Try to link to detail page if same publisher
let link = if dep_fmri.publisher.is_none()
|| dep_fmri.publisher.as_deref() == Some(&publisher)
{
let full = if dep_fmri.version().is_empty() {
format!("pkg://{}/{}", publisher, dep_fmri.stem())
} else {
format!(
"pkg://{}/{}@{}",
publisher,
dep_fmri.stem(),
dep_fmri.version()
)
};
Some(format!("/ui/package/{}/{}", publisher, encode_fmri(&full)))
} else {
None
};
Some(DepDisplay {
dep_type: dep.dependency_type.clone(),
fmri: dep_fmri_str,
link,
})
})
.collect();
render_template(&PackageDetailTemplate {
publisher,
name,
summary,
version: vp.version,
build_release: vp.build_release,
branch: vp.branch,
packaging_date,
size: human_bytes(total_size),
csize: human_bytes(total_csize),
fmri_str: fmri_display,
fmri_encoded,
dependencies,
})
}
/// Serve manifest partial for HTMX lazy loading
async fn ui_package_manifest_inner(
repo: &DepotRepo,
publisher: &str,
fmri_str: &str,
) -> Result<Response, DepotError> {
// Strip trailing "/manifest" from the catch-all path
let fmri_path = fmri_str
.strip_suffix("/manifest")
.unwrap_or(fmri_str)
.to_string();
let decoded = urlencoding::decode(&fmri_path)
.map(|c| c.into_owned())
.unwrap_or(fmri_path);
let fmri = Fmri::from_str(&decoded)
.map_err(|e| DepotError::Repo(libips::repository::RepositoryError::Other(e.to_string())))?;
let manifest_text = repo.get_manifest_text(publisher, &fmri)?;
render_template(&ManifestTemplate { manifest_text })
}
/// GET /ui/p5i - Generate P5I file from query params
pub async fn ui_p5i_generate(
headers: HeaderMap,
Query(params): Query<P5iParams>,
) -> Result<Response, DepotError> {
if params.pkg.is_empty() {
return Err(DepotError::Server(
"No packages selected".to_string(),
));
}
let host = headers
.get(header::HOST)
.and_then(|v| v.to_str().ok())
.unwrap_or("localhost");
let origin = format!("http://{}", host);
#[derive(serde::Serialize)]
struct P5iPublisherInfo {
alias: Option<String>,
name: String,
packages: Vec<String>,
repositories: Vec<String>,
}
#[derive(serde::Serialize)]
struct P5iFile {
packages: Vec<String>,
publishers: Vec<P5iPublisherInfo>,
version: u32,
}
let p5i = P5iFile {
packages: Vec::new(),
publishers: vec![P5iPublisherInfo {
alias: None,
name: params.publisher,
packages: params.pkg,
repositories: vec![origin],
}],
version: 1,
};
let json = serde_json::to_string_pretty(&p5i)
.map_err(|e| DepotError::Server(e.to_string()))?;
Ok((
StatusCode::OK,
[
(header::CONTENT_TYPE, "application/vnd.pkg5.info"),
(
header::CONTENT_DISPOSITION,
"attachment; filename=\"packages.p5i\"",
),
],
json,
)
.into_response())
}

View file

@ -1,14 +1,20 @@
use crate::http::admin;
use crate::http::handlers::{catalog, file, info, manifest, publisher, search, shard, versions};
use crate::http::handlers::{catalog, file, info, manifest, publisher, search, shard, ui, versions};
use crate::repo::DepotRepo;
use axum::{
Router,
routing::{get, post},
};
use std::sync::Arc;
use tower_http::services::ServeDir;
use tower_http::trace::TraceLayer;
pub fn app_router(state: Arc<DepotRepo>) -> Router {
// Static file serving for the web UI
let static_dir = ServeDir::new(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("static"),
);
Router::new()
.route("/versions/0", get(versions::get_versions))
.route("/versions/0/", get(versions::get_versions))
@ -64,6 +70,14 @@ pub fn app_router(state: Arc<DepotRepo>) -> Router {
// Admin API over HTTP
.route("/admin/health", get(admin::health))
.route("/admin/auth/check", post(admin::auth_check))
// Web UI routes
.route("/ui/", get(ui::ui_index))
.route("/ui/packages/{publisher}", get(ui::ui_packages))
.route("/ui/search", get(ui::ui_search))
.route("/ui/search/results", get(ui::ui_search_results))
.route("/ui/package/{publisher}/{*fmri}", get(ui::ui_package_detail))
.route("/ui/p5i", get(ui::ui_p5i_generate))
.nest_service("/ui/static", static_dir)
.layer(TraceLayer::new_for_http())
.with_state(state)
}

View file

@ -1,7 +1,7 @@
use crate::config::Config;
use crate::errors::{DepotError, Result};
use libips::fmri::Fmri;
use libips::repository::{FileBackend, IndexEntry, ReadableRepository};
use libips::repository::{FileBackend, IndexEntry, PackageInfo, ReadableRepository};
use std::path::PathBuf;
use std::sync::Mutex;
@ -118,6 +118,18 @@ impl DepotRepo {
.map_err(DepotError::Repo)
}
pub fn list_packages(
&self,
publisher: Option<&str>,
pattern: Option<&str>,
) -> Result<Vec<PackageInfo>> {
let backend = self
.backend
.lock()
.map_err(|e| DepotError::Server(format!("Lock poisoned: {}", e)))?;
backend.list_packages(publisher, pattern).map_err(DepotError::Repo)
}
pub fn get_info(&self) -> Result<libips::repository::RepositoryInfo> {
let backend = self
.backend

View file

@ -0,0 +1,180 @@
/* pkg6depotd Web UI */
:root {
--depot-accent: #0969da;
}
/* Navigation */
nav.depot-nav {
background: var(--pico-card-background-color, #fff);
border-bottom: 1px solid var(--pico-muted-border-color, #dee2e6);
padding: 0.75rem 1.5rem;
margin-bottom: 1.5rem;
}
nav.depot-nav .nav-inner {
max-width: 1200px;
margin: 0 auto;
display: flex;
align-items: center;
gap: 2rem;
}
nav.depot-nav .brand {
font-weight: 700;
font-size: 1.25rem;
text-decoration: none;
color: var(--pico-color, #333);
}
nav.depot-nav a {
text-decoration: none;
color: var(--pico-muted-color, #666);
}
nav.depot-nav a:hover {
color: var(--pico-color, #333);
}
/* Publisher cards */
.publisher-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.publisher-card {
border: 1px solid var(--pico-muted-border-color, #dee2e6);
border-radius: 8px;
padding: 1.25rem;
text-decoration: none;
color: inherit;
transition: box-shadow 0.15s;
}
.publisher-card:hover {
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.publisher-card h3 {
margin: 0 0 0.5rem 0;
}
.publisher-card .meta {
color: var(--pico-muted-color, #666);
font-size: 0.9rem;
}
/* Package table */
table.packages td.pkg-name {
font-family: var(--pico-font-family-monospace, monospace);
font-size: 0.9rem;
}
/* Manifest code block */
.manifest-block {
max-height: 600px;
overflow: auto;
background: var(--pico-card-background-color, #f8f9fa);
border: 1px solid var(--pico-muted-border-color, #dee2e6);
border-radius: 4px;
padding: 1rem;
}
.manifest-block pre {
margin: 0;
white-space: pre-wrap;
word-break: break-all;
font-size: 0.85rem;
}
/* P5I cart */
.p5i-cart {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
background: var(--depot-accent);
color: #fff;
padding: 0.75rem 1.25rem;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
display: none;
z-index: 1000;
font-size: 0.95rem;
}
.p5i-cart a {
color: #fff;
font-weight: 600;
}
/* Search */
.search-input {
max-width: 600px;
}
/* Pagination */
.pagination {
display: flex;
gap: 0.5rem;
align-items: center;
justify-content: center;
margin-top: 1.5rem;
}
.pagination a, .pagination span {
padding: 0.4rem 0.8rem;
border-radius: 4px;
text-decoration: none;
}
.pagination .current {
background: var(--depot-accent);
color: #fff;
}
/* Detail info table */
.info-table {
width: 100%;
}
.info-table th {
width: 180px;
text-align: right;
padding-right: 1rem;
white-space: nowrap;
vertical-align: top;
}
/* HTMX loading indicator */
.htmx-indicator {
opacity: 0;
transition: opacity 200ms ease-in;
}
.htmx-request .htmx-indicator,
.htmx-request.htmx-indicator {
opacity: 1;
}
/* Dependencies list */
.dep-list {
list-style: none;
padding: 0;
}
.dep-list li {
padding: 0.25rem 0;
font-family: var(--pico-font-family-monospace, monospace);
font-size: 0.9rem;
}
.dep-type {
display: inline-block;
padding: 0.1rem 0.4rem;
border-radius: 3px;
font-size: 0.75rem;
font-weight: 600;
margin-right: 0.5rem;
background: var(--pico-muted-border-color, #dee2e6);
}

1
pkg6depotd/static/js/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,59 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Package Repository{% endblock %}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
<link rel="stylesheet" href="/ui/static/css/style.css">
<script src="/ui/static/js/htmx.min.js"></script>
</head>
<body>
<nav class="depot-nav">
<div class="nav-inner">
<a href="/ui/" class="brand">pkg depot</a>
<a href="/ui/">Publishers</a>
<a href="/ui/search">Search</a>
</div>
</nav>
<main class="container">
{% block content %}{% endblock %}
</main>
<div id="p5i-cart" class="p5i-cart">
<span id="p5i-count">0</span> selected &mdash;
<a href="#" id="p5i-download" onclick="downloadP5i(); return false;">Download P5I</a>
</div>
<script>
const p5iSelection = new Set();
function toggleP5i(checkbox, publisher, fmri) {
const key = publisher + '|' + fmri;
if (checkbox.checked) {
p5iSelection.add(key);
} else {
p5iSelection.delete(key);
}
const cart = document.getElementById('p5i-cart');
const count = document.getElementById('p5i-count');
count.textContent = p5iSelection.size;
cart.style.display = p5iSelection.size > 0 ? 'block' : 'none';
}
function downloadP5i() {
const byPub = {};
for (const key of p5iSelection) {
const [pub, fmri] = key.split('|', 2);
if (!byPub[pub]) byPub[pub] = [];
byPub[pub].push(fmri);
}
const publishers = Object.keys(byPub);
if (publishers.length === 0) return;
const pub = publishers[0];
const params = new URLSearchParams();
params.set('publisher', pub);
for (const fmri of byPub[pub]) {
params.append('pkg', fmri);
}
window.location.href = '/ui/p5i?' + params.toString();
}
</script>
</body>
</html>

View file

@ -0,0 +1,20 @@
{% extends "base.html" %}
{% block title %}Package Repository{% endblock %}
{% block content %}
<h1>Package Repository</h1>
<div class="publisher-grid">
{% for pub_info in publishers %}
<a href="/ui/packages/{{ pub_info.name }}" class="publisher-card">
<h3>{{ pub_info.name }}</h3>
<div class="meta">
{{ pub_info.package_count }} packages
{% if !pub_info.updated.is_empty() %}
&middot; Updated {{ pub_info.updated }}
{% endif %}
</div>
</a>
{% endfor %}
</div>
{% endblock %}

View file

@ -0,0 +1,56 @@
{% extends "base.html" %}
{% block title %}{{ name }} - Package Detail{% endblock %}
{% block content %}
<h1>{{ name }}</h1>
{% if let Some(summary) = summary %}
<p>{{ summary }}</p>
{% endif %}
<table class="info-table">
<tr><th>Publisher</th><td>{{ publisher }}</td></tr>
<tr><th>Version</th><td>{{ version }}</td></tr>
{% if let Some(br) = build_release %}
<tr><th>Build Release</th><td>{{ br }}</td></tr>
{% endif %}
{% if let Some(b) = branch %}
<tr><th>Branch</th><td>{{ b }}</td></tr>
{% endif %}
{% if let Some(date) = packaging_date %}
<tr><th>Packaging Date</th><td>{{ date }}</td></tr>
{% endif %}
<tr><th>Size</th><td>{{ size }}</td></tr>
<tr><th>Compressed Size</th><td>{{ csize }}</td></tr>
<tr><th>FMRI</th><td><code>{{ fmri_str }}</code></td></tr>
</table>
<h2>Install</h2>
<pre><code>pkg install {{ fmri_str }}</code></pre>
{% if !dependencies.is_empty() %}
<h2>Dependencies</h2>
<ul class="dep-list">
{% for dep in &dependencies %}
<li>
<span class="dep-type">{{ dep.dep_type }}</span>
{% if let Some(link) = dep.link %}
<a href="{{ link }}">{{ dep.fmri }}</a>
{% else %}
{{ dep.fmri }}
{% endif %}
</li>
{% endfor %}
</ul>
{% endif %}
<h2>Manifest</h2>
<button hx-get="/ui/package/{{ publisher }}/{{ fmri_encoded }}/manifest"
hx-target="#manifest-content"
hx-swap="innerHTML"
hx-indicator="#manifest-spinner">
Show Manifest
</button>
<span id="manifest-spinner" class="htmx-indicator" aria-busy="true">Loading...</span>
<div id="manifest-content"></div>
{% endblock %}

View file

@ -0,0 +1,45 @@
{% extends "base.html" %}
{% block title %}{{ publisher }} - Packages{% endblock %}
{% block content %}
<h1>{{ publisher }}</h1>
<p>{{ total_packages }} packages</p>
<table class="packages" role="grid">
<thead>
<tr>
<th style="width: 40px;"></th>
<th>Package</th>
<th>Version</th>
</tr>
</thead>
<tbody>
{% for pkg in packages %}
<tr>
<td>
<input type="checkbox"
onchange="toggleP5i(this, '{{ publisher }}', '{{ pkg.fmri_str }}')"
aria-label="Select {{ pkg.name }}">
</td>
<td class="pkg-name">
<a href="/ui/package/{{ publisher }}/{{ pkg.fmri_encoded }}">{{ pkg.name }}</a>
</td>
<td>{{ pkg.version }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if total_pages > 1 %}
<div class="pagination">
{% if page > 1 %}
<a href="/ui/packages/{{ publisher }}?page={{ page - 1 }}&per_page={{ per_page }}">&#8592; Prev</a>
{% endif %}
<span>Page {{ page }} of {{ total_pages }}</span>
{% if page < total_pages %}
<a href="/ui/packages/{{ publisher }}?page={{ page + 1 }}&per_page={{ per_page }}">Next &#8594;</a>
{% endif %}
</div>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,3 @@
<div class="manifest-block">
<pre>{{ manifest_text }}</pre>
</div>

View file

@ -0,0 +1,28 @@
{% if results.is_empty() %}
<p>No packages found.</p>
{% else %}
<table class="packages" role="grid">
<thead>
<tr>
<th style="width: 40px;"></th>
<th>Package</th>
<th>Publisher</th>
</tr>
</thead>
<tbody>
{% for r in &results %}
<tr>
<td>
<input type="checkbox"
onchange="toggleP5i(this, '{{ r.publisher }}', '{{ r.fmri }}')"
aria-label="Select {{ r.name }}">
</td>
<td class="pkg-name">
<a href="/ui/package/{{ r.publisher }}/{{ r.fmri_encoded }}">{{ r.name }}</a>
</td>
<td>{{ r.publisher }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}

View file

@ -0,0 +1,37 @@
{% extends "base.html" %}
{% block title %}Search Packages{% endblock %}
{% block content %}
<h1>Search Packages</h1>
<div class="search-input">
<input type="search"
name="q"
placeholder="Search packages..."
hx-get="/ui/search/results"
hx-trigger="input changed delay:300ms, search"
hx-target="#search-results"
hx-indicator="#search-spinner"
hx-include="[name='publisher']"
autofocus>
</div>
{% if !publishers.is_empty() %}
<details>
<summary>Filter by publisher</summary>
<select name="publisher">
<option value="">All publishers</option>
{% for p in &publishers %}
<option value="{{ p }}">{{ p }}</option>
{% endfor %}
</select>
</details>
{% endif %}
<span id="search-spinner" class="htmx-indicator" aria-busy="true">Searching...</span>
<div id="search-results">
<p><em>Type to search packages by name, summary, or description.</em></p>
</div>
{% endblock %}