feat: add image rendering logs and refactor Icon component
- Add detailed tracing logs for image processing (CBZ, CBR, PDF) - Add cache hit/miss logging with timing info - Centralize all SVG icons into reusable Icon component - Add Settings icon to header navigation - Add icons for Image Processing, Cache, and Performance Limits sections
This commit is contained in:
@@ -16,6 +16,7 @@ use serde::Deserialize;
|
||||
use utoipa::ToSchema;
|
||||
use sha2::{Digest, Sha256};
|
||||
use sqlx::Row;
|
||||
use tracing::{debug, error, info, instrument, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{error::ApiError, AppState};
|
||||
@@ -66,7 +67,7 @@ fn write_to_disk_cache(cache_path: &Path, data: &[u8]) -> Result<(), std::io::Er
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
#[derive(Deserialize, ToSchema, Debug)]
|
||||
pub struct PageQuery {
|
||||
#[schema(value_type = Option<String>, example = "webp")]
|
||||
pub format: Option<String>,
|
||||
@@ -76,7 +77,7 @@ pub struct PageQuery {
|
||||
pub width: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum OutputFormat {
|
||||
Jpeg,
|
||||
Png,
|
||||
@@ -130,12 +131,16 @@ impl OutputFormat {
|
||||
),
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
#[instrument(skip(state), fields(book_id = %book_id, page = n))]
|
||||
pub async fn get_page(
|
||||
State(state): State<AppState>,
|
||||
AxumPath((book_id, n)): AxumPath<(Uuid, u32)>,
|
||||
Query(query): Query<PageQuery>,
|
||||
) -> Result<Response, ApiError> {
|
||||
info!("Processing image request");
|
||||
|
||||
if n == 0 {
|
||||
warn!("Invalid page number: 0");
|
||||
return Err(ApiError::bad_request("page index starts at 1"));
|
||||
}
|
||||
|
||||
@@ -143,6 +148,7 @@ pub async fn get_page(
|
||||
let quality = query.quality.unwrap_or(80).clamp(1, 100);
|
||||
let width = query.width.unwrap_or(0);
|
||||
if width > 2160 {
|
||||
warn!("Invalid width: {}", width);
|
||||
return Err(ApiError::bad_request("width must be <= 2160"));
|
||||
}
|
||||
|
||||
@@ -150,9 +156,11 @@ pub async fn get_page(
|
||||
|
||||
if let Some(cached) = state.page_cache.lock().await.get(&memory_cache_key).cloned() {
|
||||
state.metrics.page_cache_hits.fetch_add(1, Ordering::Relaxed);
|
||||
debug!("Memory cache hit for key: {}", memory_cache_key);
|
||||
return Ok(image_response(cached, format.content_type(), None));
|
||||
}
|
||||
state.metrics.page_cache_misses.fetch_add(1, Ordering::Relaxed);
|
||||
debug!("Memory cache miss for key: {}", memory_cache_key);
|
||||
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
@@ -165,31 +173,52 @@ pub async fn get_page(
|
||||
)
|
||||
.bind(book_id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Database error fetching book file for book_id {}: {}", book_id, e);
|
||||
e
|
||||
})?;
|
||||
|
||||
let row = match row {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
error!("Book file not found for book_id: {}", book_id);
|
||||
return Err(ApiError::not_found("book file not found"));
|
||||
}
|
||||
};
|
||||
|
||||
let row = row.ok_or_else(|| ApiError::not_found("book file not found"))?;
|
||||
let abs_path: String = row.get("abs_path");
|
||||
let abs_path = remap_libraries_path(&abs_path);
|
||||
let input_format: String = row.get("format");
|
||||
|
||||
info!("Processing book file: {} (format: {})", abs_path, input_format);
|
||||
|
||||
let disk_cache_key = get_cache_key(&abs_path, n, format.extension(), quality, width);
|
||||
let cache_path = get_cache_path(&disk_cache_key, &format);
|
||||
|
||||
if let Some(cached_bytes) = read_from_disk_cache(&cache_path) {
|
||||
info!("Disk cache hit for: {}", cache_path.display());
|
||||
let bytes = Arc::new(cached_bytes);
|
||||
state.page_cache.lock().await.put(memory_cache_key, bytes.clone());
|
||||
return Ok(image_response(bytes, format.content_type(), Some(&disk_cache_key)));
|
||||
}
|
||||
debug!("Disk cache miss for: {}", cache_path.display());
|
||||
|
||||
let _permit = state
|
||||
.page_render_limit
|
||||
.clone()
|
||||
.acquire_owned()
|
||||
.await
|
||||
.map_err(|_| ApiError::internal("render limiter unavailable"))?;
|
||||
.map_err(|e| {
|
||||
error!("Failed to acquire render permit: {}", e);
|
||||
ApiError::internal("render limiter unavailable")
|
||||
})?;
|
||||
|
||||
info!("Rendering page {} from {}", n, abs_path);
|
||||
let abs_path_clone = abs_path.clone();
|
||||
let format_clone = format;
|
||||
let start_time = std::time::Instant::now();
|
||||
|
||||
let bytes = tokio::time::timeout(
|
||||
Duration::from_secs(12),
|
||||
tokio::task::spawn_blocking(move || {
|
||||
@@ -197,15 +226,37 @@ pub async fn get_page(
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| ApiError::internal("page rendering timeout"))?
|
||||
.map_err(|e| ApiError::internal(format!("render task failed: {e}")))??;
|
||||
.map_err(|_| {
|
||||
error!("Page rendering timeout for {} page {}", abs_path, n);
|
||||
ApiError::internal("page rendering timeout")
|
||||
})?
|
||||
.map_err(|e| {
|
||||
error!("Render task panicked for {} page {}: {}", abs_path, n, e);
|
||||
ApiError::internal(format!("render task failed: {e}"))
|
||||
})?;
|
||||
|
||||
let _ = write_to_disk_cache(&cache_path, &bytes);
|
||||
let duration = start_time.elapsed();
|
||||
|
||||
let bytes = Arc::new(bytes);
|
||||
state.page_cache.lock().await.put(memory_cache_key, bytes.clone());
|
||||
match bytes {
|
||||
Ok(data) => {
|
||||
info!("Successfully rendered page {} in {:?}", n, duration);
|
||||
|
||||
Ok(image_response(bytes, format.content_type(), Some(&disk_cache_key)))
|
||||
if let Err(e) = write_to_disk_cache(&cache_path, &data) {
|
||||
warn!("Failed to write to disk cache: {}", e);
|
||||
} else {
|
||||
info!("Cached rendered image to: {}", cache_path.display());
|
||||
}
|
||||
|
||||
let bytes = Arc::new(data);
|
||||
state.page_cache.lock().await.put(memory_cache_key, bytes.clone());
|
||||
|
||||
Ok(image_response(bytes, format.content_type(), Some(&disk_cache_key)))
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to render page {} from {}: {:?}", n, abs_path, e);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn image_response(bytes: Arc<Vec<u8>>, content_type: &str, etag_suffix: Option<&str>) -> Response {
|
||||
@@ -246,34 +297,63 @@ fn render_page(
|
||||
}
|
||||
|
||||
fn extract_cbz_page(abs_path: &str, page_number: u32) -> Result<Vec<u8>, ApiError> {
|
||||
let file = std::fs::File::open(abs_path).map_err(|e| ApiError::internal(format!("cannot open cbz: {e}")))?;
|
||||
let mut archive = zip::ZipArchive::new(file).map_err(|e| ApiError::internal(format!("invalid cbz: {e}")))?;
|
||||
debug!("Opening CBZ archive: {}", abs_path);
|
||||
let file = std::fs::File::open(abs_path).map_err(|e| {
|
||||
error!("Cannot open CBZ file {}: {}", abs_path, e);
|
||||
ApiError::internal(format!("cannot open cbz: {e}"))
|
||||
})?;
|
||||
|
||||
let mut archive = zip::ZipArchive::new(file).map_err(|e| {
|
||||
error!("Invalid CBZ archive {}: {}", abs_path, e);
|
||||
ApiError::internal(format!("invalid cbz: {e}"))
|
||||
})?;
|
||||
|
||||
let mut image_names: Vec<String> = Vec::new();
|
||||
for i in 0..archive.len() {
|
||||
let entry = archive.by_index(i).map_err(|e| ApiError::internal(format!("cbz entry read failed: {e}")))?;
|
||||
let entry = archive.by_index(i).map_err(|e| {
|
||||
error!("Failed to read CBZ entry {} in {}: {}", i, abs_path, e);
|
||||
ApiError::internal(format!("cbz entry read failed: {e}"))
|
||||
})?;
|
||||
let name = entry.name().to_ascii_lowercase();
|
||||
if is_image_name(&name) {
|
||||
image_names.push(entry.name().to_string());
|
||||
}
|
||||
}
|
||||
image_names.sort();
|
||||
debug!("Found {} images in CBZ {}", image_names.len(), abs_path);
|
||||
|
||||
let index = page_number as usize - 1;
|
||||
let selected = image_names.get(index).ok_or_else(|| ApiError::not_found("page out of range"))?;
|
||||
let mut entry = archive.by_name(selected).map_err(|e| ApiError::internal(format!("cbz page read failed: {e}")))?;
|
||||
let selected = image_names.get(index).ok_or_else(|| {
|
||||
error!("Page {} out of range in {} (total: {})", page_number, abs_path, image_names.len());
|
||||
ApiError::not_found("page out of range")
|
||||
})?;
|
||||
|
||||
debug!("Extracting page {} ({}) from {}", page_number, selected, abs_path);
|
||||
let mut entry = archive.by_name(selected).map_err(|e| {
|
||||
error!("Failed to read CBZ page {} from {}: {}", selected, abs_path, e);
|
||||
ApiError::internal(format!("cbz page read failed: {e}"))
|
||||
})?;
|
||||
let mut buf = Vec::new();
|
||||
entry.read_to_end(&mut buf).map_err(|e| ApiError::internal(format!("cbz page load failed: {e}")))?;
|
||||
entry.read_to_end(&mut buf).map_err(|e| {
|
||||
error!("Failed to load CBZ page {} from {}: {}", selected, abs_path, e);
|
||||
ApiError::internal(format!("cbz page load failed: {e}"))
|
||||
})?;
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
fn extract_cbr_page(abs_path: &str, page_number: u32) -> Result<Vec<u8>, ApiError> {
|
||||
debug!("Listing CBR archive: {}", abs_path);
|
||||
let list_output = std::process::Command::new("unrar")
|
||||
.arg("lb")
|
||||
.arg(abs_path)
|
||||
.output()
|
||||
.map_err(|e| ApiError::internal(format!("unrar list failed: {e}")))?;
|
||||
.map_err(|e| {
|
||||
error!("unrar list command failed for {}: {}", abs_path, e);
|
||||
ApiError::internal(format!("unrar list failed: {e}"))
|
||||
})?;
|
||||
if !list_output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&list_output.stderr);
|
||||
error!("unrar could not list archive {}: {}", abs_path, stderr);
|
||||
return Err(ApiError::internal("unrar could not list archive"));
|
||||
}
|
||||
|
||||
@@ -283,25 +363,41 @@ fn extract_cbr_page(abs_path: &str, page_number: u32) -> Result<Vec<u8>, ApiErro
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
entries.sort();
|
||||
let index = page_number as usize - 1;
|
||||
let selected = entries.get(index).ok_or_else(|| ApiError::not_found("page out of range"))?;
|
||||
debug!("Found {} images in CBR {}", entries.len(), abs_path);
|
||||
|
||||
let index = page_number as usize - 1;
|
||||
let selected = entries.get(index).ok_or_else(|| {
|
||||
error!("Page {} out of range in {} (total: {})", page_number, abs_path, entries.len());
|
||||
ApiError::not_found("page out of range")
|
||||
})?;
|
||||
|
||||
debug!("Extracting page {} ({}) from {}", page_number, selected, abs_path);
|
||||
let page_output = std::process::Command::new("unrar")
|
||||
.arg("p")
|
||||
.arg("-inul")
|
||||
.arg(abs_path)
|
||||
.arg(selected)
|
||||
.output()
|
||||
.map_err(|e| ApiError::internal(format!("unrar extract failed: {e}")))?;
|
||||
.map_err(|e| {
|
||||
error!("unrar extract command failed for {} page {}: {}", abs_path, selected, e);
|
||||
ApiError::internal(format!("unrar extract failed: {e}"))
|
||||
})?;
|
||||
if !page_output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&page_output.stderr);
|
||||
error!("unrar could not extract page {} from {}: {}", selected, abs_path, stderr);
|
||||
return Err(ApiError::internal("unrar could not extract page"));
|
||||
}
|
||||
debug!("Successfully extracted {} bytes from CBR page {}", page_output.stdout.len(), page_number);
|
||||
Ok(page_output.stdout)
|
||||
}
|
||||
|
||||
fn render_pdf_page(abs_path: &str, page_number: u32, width: u32) -> Result<Vec<u8>, ApiError> {
|
||||
let tmp_dir = std::env::temp_dir().join(format!("stripstream-pdf-{}", Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&tmp_dir).map_err(|e| ApiError::internal(format!("cannot create temp dir: {e}")))?;
|
||||
debug!("Creating temp dir for PDF rendering: {}", tmp_dir.display());
|
||||
std::fs::create_dir_all(&tmp_dir).map_err(|e| {
|
||||
error!("Cannot create temp dir {}: {}", tmp_dir.display(), e);
|
||||
ApiError::internal(format!("cannot create temp dir: {e}"))
|
||||
})?;
|
||||
let output_prefix = tmp_dir.join("page");
|
||||
|
||||
let mut cmd = std::process::Command::new("pdftoppm");
|
||||
@@ -314,35 +410,58 @@ fn render_pdf_page(abs_path: &str, page_number: u32, width: u32) -> Result<Vec<u
|
||||
}
|
||||
cmd.arg(abs_path).arg(&output_prefix);
|
||||
|
||||
debug!("Running pdftoppm for page {} of {} (width: {})", page_number, abs_path, width);
|
||||
let output = cmd
|
||||
.output()
|
||||
.map_err(|e| ApiError::internal(format!("pdf render failed: {e}")))?;
|
||||
.map_err(|e| {
|
||||
error!("pdftoppm command failed for {} page {}: {}", abs_path, page_number, e);
|
||||
ApiError::internal(format!("pdf render failed: {e}"))
|
||||
})?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let _ = std::fs::remove_dir_all(&tmp_dir);
|
||||
error!("pdftoppm failed for {} page {}: {}", abs_path, page_number, stderr);
|
||||
return Err(ApiError::internal("pdf render command failed"));
|
||||
}
|
||||
|
||||
let image_path = output_prefix.with_extension("png");
|
||||
let bytes = std::fs::read(&image_path).map_err(|e| ApiError::internal(format!("render output missing: {e}")))?;
|
||||
debug!("Reading rendered PDF page from: {}", image_path.display());
|
||||
let bytes = std::fs::read(&image_path).map_err(|e| {
|
||||
error!("Failed to read rendered PDF output {}: {}", image_path.display(), e);
|
||||
ApiError::internal(format!("render output missing: {e}"))
|
||||
})?;
|
||||
let _ = std::fs::remove_dir_all(&tmp_dir);
|
||||
debug!("Successfully rendered PDF page {} to {} bytes", page_number, bytes.len());
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn transcode_image(input: &[u8], out_format: &OutputFormat, quality: u8, width: u32) -> Result<Vec<u8>, ApiError> {
|
||||
debug!("Transcoding image: {} bytes, format: {:?}, quality: {}, width: {}", input.len(), out_format, quality, width);
|
||||
let source_format = image::guess_format(input).ok();
|
||||
debug!("Source format detected: {:?}", source_format);
|
||||
let needs_transcode = source_format.map(|f| !format_matches(&f, out_format)).unwrap_or(true);
|
||||
|
||||
if width == 0 && !needs_transcode {
|
||||
debug!("No transcoding needed, returning original");
|
||||
return Ok(input.to_vec());
|
||||
}
|
||||
|
||||
let mut image = image::load_from_memory(input).map_err(|e| ApiError::internal(format!("invalid source image: {e}")))?;
|
||||
debug!("Loading image from memory...");
|
||||
let mut image = image::load_from_memory(input).map_err(|e| {
|
||||
error!("Failed to load image from memory: {} (input size: {} bytes)", e, input.len());
|
||||
ApiError::internal(format!("invalid source image: {e}"))
|
||||
})?;
|
||||
|
||||
if width > 0 {
|
||||
debug!("Resizing image to width: {}", width);
|
||||
image = image.resize(width, u32::MAX, image::imageops::FilterType::Lanczos3);
|
||||
}
|
||||
|
||||
debug!("Converting to RGBA...");
|
||||
let rgba = image.to_rgba8();
|
||||
let (w, h) = rgba.dimensions();
|
||||
debug!("Image dimensions: {}x{}", w, h);
|
||||
|
||||
let mut out = Vec::new();
|
||||
match out_format {
|
||||
OutputFormat::Jpeg => {
|
||||
|
||||
@@ -1,50 +1,90 @@
|
||||
type IconName = "dashboard" | "books" | "libraries" | "jobs" | "tokens" | "series" | "settings";
|
||||
type IconName =
|
||||
| "dashboard"
|
||||
| "books"
|
||||
| "libraries"
|
||||
| "jobs"
|
||||
| "tokens"
|
||||
| "series"
|
||||
| "settings"
|
||||
| "image"
|
||||
| "cache"
|
||||
| "performance"
|
||||
| "folder"
|
||||
| "folderOpen"
|
||||
| "search"
|
||||
| "plus"
|
||||
| "edit"
|
||||
| "trash"
|
||||
| "check"
|
||||
| "x"
|
||||
| "chevronLeft"
|
||||
| "chevronRight"
|
||||
| "chevronUp"
|
||||
| "chevronDown"
|
||||
| "arrowLeft"
|
||||
| "arrowRight"
|
||||
| "refresh"
|
||||
| "sun"
|
||||
| "moon"
|
||||
| "externalLink"
|
||||
| "key"
|
||||
| "play"
|
||||
| "stop"
|
||||
| "spinner"
|
||||
| "warning";
|
||||
|
||||
interface PageIconProps {
|
||||
type IconSize = "sm" | "md" | "lg" | "xl";
|
||||
|
||||
interface IconProps {
|
||||
name: IconName;
|
||||
size?: IconSize;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const icons: Record<IconName, React.ReactNode> = {
|
||||
dashboard: (
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
),
|
||||
books: (
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
),
|
||||
libraries: (
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
),
|
||||
jobs: (
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
),
|
||||
tokens: (
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
),
|
||||
series: (
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
),
|
||||
settings: (
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
),
|
||||
const sizeClasses: Record<IconSize, string> = {
|
||||
sm: "w-4 h-4",
|
||||
md: "w-5 h-5",
|
||||
lg: "w-6 h-6",
|
||||
xl: "w-8 h-8",
|
||||
};
|
||||
|
||||
const colors: Record<IconName, string> = {
|
||||
const icons: Record<IconName, string> = {
|
||||
dashboard: "M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6",
|
||||
books: "M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253",
|
||||
libraries: "M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z",
|
||||
jobs: "M13 10V3L4 14h7v7l9-11h-7z",
|
||||
tokens: "M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z",
|
||||
series: "M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10",
|
||||
settings: "M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z",
|
||||
image: "M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z",
|
||||
cache: "M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10",
|
||||
performance: "M13 10V3L4 14h7v7l9-11h-7z",
|
||||
folder: "M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z",
|
||||
folderOpen: "M5 19a2 2 0 01-2-2V7a2 2 0 012-2h4l2 2h4a2 2 0 012 2v1M5 19h14a2 2 0 002-2v-5a2 2 0 00-2-2H9a2 2 0 00-2 2v5a2 2 0 01-2 2z",
|
||||
search: "M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z",
|
||||
plus: "M12 4v16m8-8H4",
|
||||
edit: "M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z",
|
||||
trash: "M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16",
|
||||
check: "M5 13l4 4L19 7",
|
||||
x: "M6 18L18 6M6 6l12 12",
|
||||
chevronLeft: "M15 19l-7-7 7-7",
|
||||
chevronRight: "M9 5l7 7-7 7",
|
||||
chevronUp: "M5 15l7-7 7 7",
|
||||
chevronDown: "M19 9l-7 7-7-7",
|
||||
arrowLeft: "M10 19l-7-7m0 0l7-7m-7 7h18",
|
||||
arrowRight: "M14 5l7 7m0 0l-7 7m7-7H3",
|
||||
refresh: "M4 4v5h.582m15.582 0A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15",
|
||||
sun: "M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z",
|
||||
moon: "M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z",
|
||||
externalLink: "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14",
|
||||
key: "M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z",
|
||||
play: "M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z M21 12a9 9 0 11-18 0 9 9 0 0118 0z",
|
||||
stop: "M21 12a9 9 0 11-18 0 9 9 0 0118 0z M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z",
|
||||
spinner: "M4 4v5h.582m15.582 0A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15",
|
||||
warning: "M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z",
|
||||
};
|
||||
|
||||
const colorClasses: Partial<Record<IconName, string>> = {
|
||||
dashboard: "text-primary",
|
||||
books: "text-success",
|
||||
libraries: "text-primary",
|
||||
@@ -52,56 +92,32 @@ const colors: Record<IconName, string> = {
|
||||
tokens: "text-error",
|
||||
series: "text-primary",
|
||||
settings: "text-muted-foreground",
|
||||
image: "text-primary",
|
||||
cache: "text-warning",
|
||||
performance: "text-success",
|
||||
};
|
||||
|
||||
export function PageIcon({ name, className = "" }: PageIconProps) {
|
||||
export function Icon({ name, size = "md", className = "" }: IconProps) {
|
||||
const sizeClass = sizeClasses[size];
|
||||
const colorClass = colorClasses[name];
|
||||
|
||||
return (
|
||||
<span className={`${colors[name]} ${className}`}>
|
||||
{icons[name]}
|
||||
</span>
|
||||
<svg
|
||||
className={`${sizeClass} ${colorClass || ""} ${className}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={icons[name]} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Nav icons (smaller)
|
||||
export function NavIcon({ name, className = "" }: { name: IconName; className?: string }) {
|
||||
const navIcons: Record<IconName, React.ReactNode> = {
|
||||
dashboard: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
),
|
||||
books: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
),
|
||||
libraries: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
),
|
||||
jobs: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
),
|
||||
tokens: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
),
|
||||
series: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
),
|
||||
settings: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
return <span className={className}>{navIcons[name]}</span>;
|
||||
// Backwards compatibility aliases
|
||||
export function PageIcon({ name, className = "" }: { name: IconName; className?: string }) {
|
||||
return <Icon name={name} size="xl" className={className} />;
|
||||
}
|
||||
|
||||
export function NavIcon({ name, className = "" }: { name: IconName; className?: string }) {
|
||||
return <Icon name={name} size="sm" className={className} />;
|
||||
}
|
||||
|
||||
@@ -17,5 +17,5 @@ export {
|
||||
FormField, FormLabel, FormInput, FormSelect, FormRow,
|
||||
FormSection, FormError, FormDescription
|
||||
} from "./Form";
|
||||
export { PageIcon, NavIcon } from "./Icon";
|
||||
export { PageIcon, NavIcon, Icon } from "./Icon";
|
||||
export { CursorPagination, OffsetPagination } from "./Pagination";
|
||||
|
||||
@@ -6,7 +6,7 @@ import "./globals.css";
|
||||
import { ThemeProvider } from "./theme-provider";
|
||||
import { ThemeToggle } from "./theme-toggle";
|
||||
import { JobsIndicator } from "./components/JobsIndicator";
|
||||
import { NavIcon } from "./components/ui";
|
||||
import { NavIcon, Icon } from "./components/ui";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "StripStream Backoffice",
|
||||
@@ -25,7 +25,6 @@ const navItems: NavItem[] = [
|
||||
{ href: "/libraries", label: "Libraries", icon: "libraries" },
|
||||
{ href: "/jobs", label: "Jobs", icon: "jobs" },
|
||||
{ href: "/tokens", label: "Tokens", icon: "tokens" },
|
||||
{ href: "/settings", label: "Settings", icon: "settings" },
|
||||
];
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
@@ -72,6 +71,13 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1 pl-4 ml-2 border-l border-border/60">
|
||||
<JobsIndicator />
|
||||
<Link
|
||||
href="/settings"
|
||||
className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
||||
title="Settings"
|
||||
>
|
||||
<Icon name="settings" size="md" />
|
||||
</Link>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, FormField, FormInput, FormSelect, FormRow } from "../components/ui";
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, FormField, FormInput, FormSelect, FormRow, Icon } from "../components/ui";
|
||||
import { Settings, CacheStats, ClearCacheResponse } from "../../lib/api";
|
||||
|
||||
interface SettingsPageProps {
|
||||
@@ -63,10 +63,7 @@ export default function SettingsPage({ initialSettings, initialCacheStats }: Set
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3">
|
||||
<svg className="w-8 h-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<Icon name="settings" size="xl" />
|
||||
Settings
|
||||
</h1>
|
||||
</div>
|
||||
@@ -82,7 +79,10 @@ export default function SettingsPage({ initialSettings, initialCacheStats }: Set
|
||||
{/* Image Processing Settings */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Image Processing</CardTitle>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Icon name="image" size="md" />
|
||||
Image Processing
|
||||
</CardTitle>
|
||||
<CardDescription>Configure how images are processed and compressed</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -158,7 +158,10 @@ export default function SettingsPage({ initialSettings, initialCacheStats }: Set
|
||||
{/* Cache Settings */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Cache</CardTitle>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Icon name="cache" size="md" />
|
||||
Cache
|
||||
</CardTitle>
|
||||
<CardDescription>Manage the image cache and storage</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -218,17 +221,12 @@ export default function SettingsPage({ initialSettings, initialCacheStats }: Set
|
||||
>
|
||||
{isClearing ? (
|
||||
<>
|
||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<Icon name="spinner" size="sm" className="animate-spin -ml-1 mr-2" />
|
||||
Clearing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
<Icon name="trash" size="sm" className="mr-2" />
|
||||
Clear Cache
|
||||
</>
|
||||
)}
|
||||
@@ -240,7 +238,10 @@ export default function SettingsPage({ initialSettings, initialCacheStats }: Set
|
||||
{/* Limits Settings */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Performance Limits</CardTitle>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Icon name="performance" size="md" />
|
||||
Performance Limits
|
||||
</CardTitle>
|
||||
<CardDescription>Configure API performance and rate limiting</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
||||
Reference in New Issue
Block a user