feat: Upgrade to v0.1.1 - Dashboard, Themes, Hot Reloading, Web Setup

This commit is contained in:
Brooklyn
2026-01-08 21:41:26 -05:00
parent e6029b08f6
commit 2ace2f6ff4
21 changed files with 1258 additions and 216 deletions

2
Cargo.lock generated
View File

@@ -26,7 +26,7 @@ dependencies = [
[[package]]
name = "alchemist"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"anyhow",
"async-trait",

View File

@@ -1,6 +1,6 @@
[package]
name = "alchemist"
version = "0.1.0"
version = "0.1.1"
edition = "2021"
license = "GPL-3.0"

View File

@@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS ui_preferences (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);

View File

@@ -486,4 +486,27 @@ impl Db {
.await?;
Ok(result.rows_affected())
}
/// Set UI preference
pub async fn set_preference(&self, key: &str, value: &str) -> Result<()> {
sqlx::query(
"INSERT INTO ui_preferences (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP",
)
.bind(key)
.bind(value)
.execute(&self.pool)
.await?;
Ok(())
}
/// Get UI preference
pub async fn get_preference(&self, key: &str) -> Result<Option<String>> {
let row: Option<(String,)> =
sqlx::query_as("SELECT value FROM ui_preferences WHERE key = ?")
.bind(key)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(|r| r.0))
}
}

View File

@@ -7,7 +7,9 @@ use std::sync::Arc;
use tracing::{error, info, warn};
use tracing_subscriber::EnvFilter;
use notify::{RecursiveMode, Watcher};
use tokio::sync::broadcast;
use tokio::sync::RwLock;
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
@@ -135,15 +137,18 @@ async fn main() -> Result<()> {
let db = Arc::new(db::Db::new("alchemist.db").await?);
let (tx, _rx) = broadcast::channel(100);
let transcoder = Arc::new(Transcoder::new());
let config = Arc::new(config);
let agent = Arc::new(Agent::new(
db.clone(),
transcoder.clone(),
config.clone(),
Some(hw_info),
tx.clone(),
args.dry_run,
));
let config = Arc::new(RwLock::new(config));
let agent = Arc::new(
Agent::new(
db.clone(),
transcoder.clone(),
config.clone(),
Some(hw_info),
tx.clone(),
args.dry_run,
)
.await,
);
info!("Database and services initialized.");
@@ -165,13 +170,23 @@ async fn main() -> Result<()> {
info!("Starting web server...");
// Start File Watcher if directories are configured and not in setup mode
if !setup_mode && !config.scanner.directories.is_empty() {
let watcher_dirs: Vec<PathBuf> = config
.scanner
.directories
.iter()
.map(PathBuf::from)
.collect();
let watcher_dirs_opt = {
let config_read = config.read().await;
if !setup_mode && !config_read.scanner.directories.is_empty() {
Some(
config_read
.scanner
.directories
.iter()
.map(PathBuf::from)
.collect::<Vec<_>>(),
)
} else {
None
}
};
if let Some(watcher_dirs) = watcher_dirs_opt {
let watcher = alchemist::system::watcher::FileWatcher::new(watcher_dirs, db.clone());
let watcher_handle = watcher.clone();
tokio::spawn(async move {
@@ -181,6 +196,67 @@ async fn main() -> Result<()> {
});
}
// Config Watcher
let config_watcher_arc = config.clone();
tokio::task::spawn_blocking(move || {
let (tx, rx) = std::sync::mpsc::channel();
// We use recommended_watcher (usually Create/Write/Modify/Remove events)
let mut watcher = match notify::recommended_watcher(tx) {
Ok(w) => w,
Err(e) => {
error!("Failed to create config watcher: {}", e);
return;
}
};
if let Err(e) = watcher.watch(
std::path::Path::new("config.toml"),
RecursiveMode::NonRecursive,
) {
error!("Failed to watch config.toml: {}", e);
return;
}
// Simple debounce by waiting for events
for res in rx {
match res {
Ok(event) => {
// Reload on any event for simplicity, usually Write/Modify
// We can filter for event.kind.
if let notify::EventKind::Modify(_) = event.kind {
info!("Config file changed. Reloading...");
// Brief sleep to ensure write complete?
std::thread::sleep(std::time::Duration::from_millis(100));
match alchemist::config::Config::load(std::path::Path::new(
"config.toml",
)) {
Ok(new_config) => {
// We need to write to the async RwLock from this blocking thread.
// We can use blocking_write() if available or block_on.
// tokio::sync::RwLock can contain a blocking_write feature?
// No, tokio RwLock is async.
// We can spawn a handle back to async world?
// Or just use std::sync::RwLock for config?
// Using `blocking_write` requires `tokio` feature `sync`?
// Actually `config_watcher_arc` is `Arc<tokio::sync::RwLock<Config>>`.
// We can use `futures::executor::block_on` or create a new runtime?
// BETTER: Spawn the loop as a `tokio::spawn`, but use `notify::Event` stream (async config)?
// OR: Use `tokio::sync::RwLock::blocking_write()` method? It exists!
let mut w = config_watcher_arc.blocking_write();
*w = new_config;
info!("Configuration reloaded successfully.");
}
Err(e) => {
error!("Failed to reload config: {}", e);
}
}
}
}
Err(e) => error!("Config watch error: {:?}", e),
}
}
});
alchemist::server::run_server(db, config, agent, transcoder, tx, setup_mode).await?;
} else {
// CLI Mode

View File

@@ -14,13 +14,13 @@ use crate::Transcoder;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tokio::sync::{broadcast, Semaphore};
use tokio::sync::{broadcast, RwLock, Semaphore};
use tracing::{error, info, warn};
pub struct Agent {
db: Arc<Db>,
orchestrator: Arc<Transcoder>,
config: Arc<Config>,
config: Arc<RwLock<Config>>,
hw_info: Arc<Option<HardwareInfo>>,
tx: Arc<broadcast::Sender<AlchemistEvent>>,
semaphore: Arc<Semaphore>,
@@ -30,16 +30,20 @@ pub struct Agent {
}
impl Agent {
pub fn new(
pub async fn new(
db: Arc<Db>,
orchestrator: Arc<Transcoder>,
config: Arc<Config>,
config: Arc<RwLock<Config>>,
hw_info: Option<HardwareInfo>,
tx: broadcast::Sender<AlchemistEvent>,
dry_run: bool,
) -> Self {
let concurrent_jobs = config.transcode.concurrent_jobs;
let notifications = NotificationService::new(config.notifications.clone());
// Read config asynchronously to avoid blocking atomic in async runtime
let config_read = config.read().await;
let concurrent_jobs = config_read.transcode.concurrent_jobs;
let notifications = NotificationService::new(config_read.notifications.clone());
drop(config_read);
Self {
db,
orchestrator,
@@ -164,7 +168,11 @@ impl Agent {
);
info!("[Job {}] Codec: {}", job.id, metadata.codec_name);
let planner = BasicPlanner::new(self.config.clone(), self.hw_info.as_ref().clone());
let config_snapshot = self.config.read().await.clone();
let planner = BasicPlanner::new(
Arc::new(config_snapshot.clone()),
self.hw_info.as_ref().clone(),
);
let decision = planner.plan(&metadata).await?;
let should_encode = decision.action == "encode";
let reason = decision.reason.clone();
@@ -188,8 +196,8 @@ impl Agent {
let executor = FfmpegExecutor::new(
self.orchestrator.clone(),
self.config.clone(),
self.hw_info.as_ref().clone(), // Option<HardwareInfo>
Arc::new(config_snapshot.clone()), // Use snapshot
self.hw_info.as_ref().clone(), // Option<HardwareInfo>
self.tx.clone(),
self.dry_run,
);
@@ -274,8 +282,10 @@ impl Agent {
let reduction = 1.0 - (output_size as f64 / input_size as f64);
let encode_duration = start_time.elapsed().as_secs_f64();
let config = self.config.read().await;
// Check reduction threshold
if output_size == 0 || reduction < self.config.transcode.size_reduction_threshold {
if output_size == 0 || reduction < config.transcode.size_reduction_threshold {
warn!(
"Job {}: Size reduction gate failed ({:.2}%). Reverting.",
job_id,
@@ -294,19 +304,18 @@ impl Agent {
// 2. QUALITY GATE (VMAF)
let mut vmaf_score = None;
if self.config.quality.enable_vmaf {
if config.quality.enable_vmaf {
info!("[Job {}] Phase 2: Computing VMAF quality score...", job_id);
match crate::media::ffmpeg::QualityScore::compute(input_path, output_path) {
Ok(score) => {
vmaf_score = score.vmaf;
if let Some(s) = vmaf_score {
info!("[Job {}] VMAF Score: {:.2}", job_id, s);
if s < self.config.quality.min_vmaf_score
&& self.config.quality.revert_on_low_quality
if s < config.quality.min_vmaf_score && config.quality.revert_on_low_quality
{
warn!(
"Job {}: Quality gate failed ({:.2} < {}). Reverting.",
job_id, s, self.config.quality.min_vmaf_score
job_id, s, config.quality.min_vmaf_score
);
let _ = std::fs::remove_file(output_path);
let _ = self

View File

@@ -18,7 +18,7 @@ use futures::stream::Stream;
use rust_embed::RustEmbed;
use std::convert::Infallible;
use std::sync::Arc;
use tokio::sync::broadcast;
use tokio::sync::{broadcast, RwLock};
use tokio_stream::wrappers::BroadcastStream;
use tokio_stream::StreamExt;
use tracing::info;
@@ -29,7 +29,7 @@ struct Assets;
pub struct AppState {
pub db: Arc<Db>,
pub config: Arc<Config>,
pub config: Arc<RwLock<Config>>,
pub agent: Arc<Agent>,
pub transcoder: Arc<Transcoder>,
pub tx: broadcast::Sender<AlchemistEvent>,
@@ -38,7 +38,7 @@ pub struct AppState {
pub async fn run_server(
db: Arc<Db>,
config: Arc<Config>,
config: Arc<RwLock<Config>>,
agent: Arc<Agent>,
transcoder: Arc<Transcoder>,
tx: broadcast::Sender<AlchemistEvent>,
@@ -69,6 +69,10 @@ pub async fn run_server(
// Setup Routes
.route("/api/setup/status", get(setup_status_handler))
.route("/api/setup/complete", post(setup_complete_handler))
.route(
"/api/ui/preferences",
get(get_preferences_handler).post(update_preferences_handler),
)
// Static Asset Routes
.route("/", get(index_handler))
.route("/*file", get(static_handler))
@@ -143,6 +147,36 @@ async fn setup_complete_handler(
axum::Json(serde_json::json!({ "status": "saved" })).into_response()
}
#[derive(serde::Deserialize, serde::Serialize)]
struct UiPreferences {
active_theme_id: Option<String>,
}
async fn get_preferences_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let active_theme_id = state
.db
.get_preference("active_theme_id")
.await
.unwrap_or(None);
axum::Json(UiPreferences { active_theme_id })
}
async fn update_preferences_handler(
State(state): State<Arc<AppState>>,
axum::Json(payload): axum::Json<UiPreferences>,
) -> impl IntoResponse {
if let Some(theme_id) = payload.active_theme_id {
if let Err(e) = state.db.set_preference("active_theme_id", &theme_id).await {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to save preference: {}", e),
)
.into_response();
}
}
StatusCode::OK.into_response()
}
async fn index_handler() -> impl IntoResponse {
static_handler(Uri::from_static("/index.html")).await
}
@@ -153,28 +187,23 @@ async fn static_handler(uri: Uri) -> impl IntoResponse {
path = "index.html".to_string();
}
match Assets::get(&path) {
Some(content) => {
let mime = mime_guess::from_path(&path).first_or_octet_stream();
([(header::CONTENT_TYPE, mime.as_ref())], content.data).into_response()
}
None => {
if path.contains('.') {
StatusCode::NOT_FOUND.into_response()
} else {
// Fallback to index.html for client-side routing if we add it later
// For now, it might be better to 404 if we are strict MPA
// But let's try to serve index.html if it's a route
match Assets::get("index.html") {
Some(content) => {
let mime = mime_guess::from_path("index.html").first_or_octet_stream();
([(header::CONTENT_TYPE, mime.as_ref())], content.data).into_response()
}
None => StatusCode::NOT_FOUND.into_response(),
}
}
if let Some(content) = Assets::get(&path) {
let mime = mime_guess::from_path(&path).first_or_octet_stream();
return ([(header::CONTENT_TYPE, mime.as_ref())], content.data).into_response();
}
// Attempt to serve index.html for directory paths (e.g. /jobs -> jobs/index.html)
if !path.contains('.') {
let index_path = format!("{}/index.html", path);
if let Some(content) = Assets::get(&index_path) {
let mime = mime_guess::from_path("index.html").first_or_octet_stream();
return ([(header::CONTENT_TYPE, mime.as_ref())], content.data).into_response();
}
}
// Default fallback to 404 for missing files, except for the SPA root fallback if intended.
// Given we are using Astro as SSG for these pages, if it's not found, it's a 404.
StatusCode::NOT_FOUND.into_response()
}
struct StatsData {
@@ -229,13 +258,19 @@ async fn jobs_table_handler(State(state): State<Arc<AppState>>) -> impl IntoResp
}
async fn scan_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let dirs = state
.config
let config = state.config.read().await;
let dirs = config
.scanner
.directories
.iter()
.map(std::path::PathBuf::from)
.collect();
drop(config); // Release lock before awaiting scan (though scan might take long time? no scan_and_enqueue is async but returns quickly? Let's check Agent::scan_and_enqueue)
// Agent::scan_and_enqueue is async. We should probably release lock before calling it if it takes long time.
// It does 'Scanner::new().scan()' which IS synchronous and blocking?
// Looking at Agent::scan_and_enqueue: `let files = scanner.scan(directories);`
// If scanner.scan is slow, we are holding the config lock? No, I dropped it.
let _ = state.agent.scan_and_enqueue(dirs).await;
StatusCode::OK
}

View File

@@ -1,6 +1,6 @@
{
"name": "alchemist-web",
"version": "0.1.0",
"version": "0.1.1",
"private": true,
"type": "module",
"scripts": {
@@ -24,4 +24,4 @@
"postcss": "^8.4.0",
"autoprefixer": "^10.4.0"
}
}
}

View File

@@ -0,0 +1,304 @@
import { useCallback, useEffect, useState } from "react";
import {
Palette,
AlertCircle,
Sparkles,
CloudMoon,
Sun,
Zap,
CheckCircle2,
Loader2
} from "lucide-react";
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
interface Theme {
id: string;
name: string;
description: string;
}
interface ThemeCategory {
id: string;
label: string;
icon: React.ReactNode;
themes: Theme[];
}
const THEME_CATEGORIES: ThemeCategory[] = [
{
id: "vibrant",
label: "Vibrant & Energetic",
icon: <Sparkles size={16} className="text-helios-solar" />,
themes: [
{
id: "helios-orange",
name: "Helios Orange",
description: "Warm ember tones with bright solar accents.",
},
{
id: "sunset",
name: "Sunset",
description: "Warm, radiant gradients inspired by dusk.",
},
{
id: "neon",
name: "Neon",
description: "Electric high-contrast cyber aesthetics.",
},
{
id: "crimson",
name: "Crimson",
description: "Charcoal reds with confident crimson energy.",
},
],
},
{
id: "cool",
label: "Cool & Calm",
icon: <CloudMoon size={16} className="text-status-success" />,
themes: [
{
id: "deep-blue",
name: "Deep Blue",
description: "Navy panels with crisp, cool blue highlights.",
},
{
id: "ocean",
name: "Ocean",
description: "Calm, deep teal and turquoise currents.",
},
{
id: "emerald",
name: "Emerald",
description: "Deep green base with luminous emerald accents.",
},
],
},
{
id: "soft",
label: "Soft & Dreamy",
icon: <Sun size={16} className="text-status-warning" />,
themes: [
{
id: "lavender",
name: "Lavender",
description: "Soft, dreamy pastels with deep purple undertones.",
},
{
id: "purple",
name: "Purple",
description: "Velvet violets with bright lavender accents.",
},
],
},
{
id: "dark",
label: "Dark & Minimal",
icon: <Zap size={16} className="text-helios-slate" />,
themes: [
{
id: "midnight",
name: "Midnight",
description: "Pure OLED black with stark white accents.",
},
{
id: "monochrome",
name: "Monochrome",
description: "Neutral graphite with clean grayscale accents.",
},
{
id: "dracula",
name: "Dracula",
description: "Dark vampire aesthetic with pink and purple accents.",
},
],
},
];
const getRootTheme = () => {
if (typeof document === "undefined") {
return null;
}
return document.documentElement.getAttribute("data-color-profile");
};
const applyRootTheme = (themeId: string) => {
if (typeof document === "undefined") {
return;
}
document.documentElement.setAttribute("data-color-profile", themeId);
localStorage.setItem("theme", themeId);
};
export default function AppearanceSettings() {
// Initialize from local storage or default
const [activeThemeId, setActiveThemeId] = useState(
() => (typeof window !== 'undefined' ? localStorage.getItem("theme") : null) || getRootTheme() || "helios-orange"
);
const [savingThemeId, setSavingThemeId] = useState<string | null>(null);
const [error, setError] = useState("");
// Effect to ensure theme is applied on mount (if mismatched)
useEffect(() => {
applyRootTheme(activeThemeId);
}, [activeThemeId]);
const handleSelect = useCallback(
async (themeId: string) => {
if (!themeId || themeId === activeThemeId || savingThemeId) {
return;
}
const previousTheme = activeThemeId;
setActiveThemeId(themeId);
setSavingThemeId(themeId);
setError("");
applyRootTheme(themeId);
try {
// Determine API endpoint.
// Since we don't have the full Helios API, we'll implement a simple one or just use local storage for now if backend isn't ready.
// But the plan says "Implement PUT /api/ui/preferences".
// We'll try to fetch it.
const response = await fetch("/api/ui/preferences", {
method: "POST", // Using POST for simplicity if PUT is tricky in backend routing without full REST
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ active_theme_id: themeId }),
});
if (!response.ok) {
// If backend doesn't support it yet, we just rely on LocalStorage, which we already set in applyRootTheme.
// So we might warn but not revert UI, or just suppress error if 404.
if (response.status !== 404) {
throw new Error("Failed to save preference");
}
}
} catch (saveError) {
console.warn("Theme save failed, using local storage fallback", saveError);
// We don't revert here because we want the UI to update immediately and persist locally at least.
// setError("Unable to save theme preference to server.");
} finally {
setSavingThemeId(null);
}
},
[activeThemeId, savingThemeId]
);
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between pb-2 border-b border-helios-line/10">
<div>
<h3 className="text-base font-bold text-helios-ink tracking-tight uppercase tracking-[0.1em]">Color Profiles</h3>
<p className="text-xs text-helios-slate mt-0.5">Customize the interface aesthetic across all your devices.</p>
</div>
<div className="p-2 bg-helios-solar/10 rounded-xl text-helios-solar">
<Palette size={20} />
</div>
</div>
{error && (
<div className="py-2.5 px-4 rounded-xl flex items-center gap-2 border border-red-500/20 bg-red-500/10 text-red-500">
<AlertCircle size={16} />
<span className="text-xs font-semibold">{error}</span>
</div>
)}
<div className="flex flex-col gap-10">
{THEME_CATEGORIES.map((category) => (
<div key={category.id} className="flex flex-col gap-4">
<div className="flex items-center gap-2 px-1">
{category.icon}
<h4 className="text-[10px] font-bold uppercase tracking-widest text-helios-slate/60">
{category.label}
</h4>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{category.themes.map((theme) => {
const isActive = theme.id === activeThemeId;
const isSaving = savingThemeId === theme.id;
return (
<button
key={theme.id}
onClick={() => handleSelect(theme.id)}
disabled={isActive || Boolean(savingThemeId)}
className={cn(
"group relative flex flex-col items-start gap-4 rounded-3xl border p-5 text-left transition-all duration-300 outline-none",
isActive
? "border-helios-solar bg-helios-solar/5 shadow-[0_0_20px_rgba(var(--accent-primary),0.1)] ring-1 ring-helios-solar/30"
: "border-helios-line/40 bg-helios-surface hover:border-helios-solar/40 hover:bg-helios-surface/80 hover:shadow-xl hover:shadow-black/5"
)}
>
<div className="flex w-full items-center justify-between gap-3">
<div
className="h-12 w-12 rounded-2xl border border-white/5 shadow-inner flex-shrink-0 flex items-center justify-center relative overflow-hidden"
data-color-profile={theme.id}
style={{
background: `linear-gradient(135deg, rgb(var(--bg-main)), rgb(var(--bg-panel)))`,
}}
>
<div
className="absolute inset-0 opacity-20"
style={{
background: `linear-gradient(to bottom right, rgb(var(--accent-primary)), transparent)`
}}
/>
<div
className="relative z-10 w-3 h-3 rounded-full shadow-[0_0_10px_rgb(var(--accent-primary))]"
style={{ backgroundColor: `rgb(var(--accent-primary))` }}
/>
</div>
{isActive && (
<div className="flex-shrink-0 flex items-center gap-1.5 bg-helios-solar text-helios-mist px-2.5 py-1 rounded-full">
<CheckCircle2 size={12} />
<span className="text-[9px] font-bold uppercase tracking-widest">Active</span>
</div>
)}
{isSaving && (
<Loader2 size={16} className="animate-spin text-helios-solar" />
)}
</div>
<div className="flex flex-col gap-1 min-w-0">
<span className={cn(
"text-sm font-bold tracking-tight",
isActive ? 'text-helios-ink' : 'text-helios-ink/90'
)}>
{theme.name}
</span>
<span className="text-[11px] text-helios-slate font-medium leading-relaxed opacity-70">
{theme.description}
</span>
</div>
<div className="mt-2 flex w-full gap-1.5 opacity-40 transition-opacity group-hover:opacity-80" data-color-profile={theme.id}>
<div className="h-1.5 flex-1 rounded-full" style={{ backgroundColor: 'rgb(var(--accent-primary))' }} />
<div className="h-1.5 flex-1 rounded-full" style={{ backgroundColor: 'rgb(var(--accent-secondary))' }} />
<div className="h-1.5 flex-1 rounded-full opacity-40" style={{ backgroundColor: 'rgb(var(--accent-primary))' }} />
</div>
{!isActive && !savingThemeId && (
<div className="absolute top-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity">
<div className="p-1.5 bg-helios-line/10 rounded-full text-helios-slate">
<Palette size={14} />
</div>
</div>
)}
</button>
);
})}
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,189 @@
import { useEffect, useState } from "react";
import {
Activity,
CheckCircle2,
AlertCircle,
Clock,
HardDrive,
Database,
Zap
} from "lucide-react";
interface Stats {
total: number;
completed: number;
active: number;
failed: number;
}
interface Job {
id: number;
input_path: string;
status: string;
created_at: string;
}
export default function Dashboard() {
const [stats, setStats] = useState<Stats>({ total: 0, completed: 0, active: 0, failed: 0 });
const [jobs, setJobs] = useState<Job[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
try {
const [statsRes, jobsRes] = await Promise.all([
fetch("/api/stats"),
fetch("/api/jobs/table")
]);
if (statsRes.ok) {
setStats(await statsRes.json());
}
if (jobsRes.ok) {
const allJobs = await jobsRes.json();
// Get 5 most recent
setJobs(allJobs.slice(0, 5));
}
} catch (e) {
console.error("Dashboard fetch error", e);
} finally {
setLoading(false);
}
};
fetchData();
const interval = setInterval(fetchData, 5000);
return () => clearInterval(interval);
}, []);
const StatCard = ({ label, value, icon: Icon, colorClass }: any) => (
<div className="p-5 rounded-2xl bg-helios-surface border border-helios-line/40 shadow-sm relative overflow-hidden group hover:bg-helios-surface-soft transition-colors">
<div className={`absolute top-0 right-0 p-3 opacity-10 group-hover:opacity-20 transition-opacity ${colorClass}`}>
<Icon size={64} />
</div>
<div className="relative z-10 flex flex-col gap-1">
<span className="text-xs font-bold uppercase tracking-wider text-helios-slate">{label}</span>
<span className={`text-3xl font-bold font-mono tracking-tight ${colorClass.replace("text-", "text-")}`}>{value}</span>
</div>
</div>
);
return (
<div className="flex flex-col gap-6">
{/* Stats Grid */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
label="Active Jobs"
value={stats.active}
icon={Zap}
colorClass="text-amber-500"
/>
<StatCard
label="Completed"
value={stats.completed}
icon={CheckCircle2}
colorClass="text-emerald-500"
/>
<StatCard
label="Failed"
value={stats.failed}
icon={AlertCircle}
colorClass="text-red-500"
/>
<StatCard
label="Total Processed"
value={stats.total}
icon={Database}
colorClass="text-helios-solar"
/>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Recent Activity */}
<div className="lg:col-span-2 p-6 rounded-3xl bg-helios-surface border border-helios-line/40 shadow-sm">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-bold text-helios-ink flex items-center gap-2">
<Activity size={20} className="text-helios-solar" />
Recent Activity
</h3>
<a href="/jobs" className="text-xs font-bold text-helios-solar hover:underline uppercase tracking-wide">View All</a>
</div>
<div className="flex flex-col gap-3">
{loading && jobs.length === 0 ? (
<div className="text-center py-8 text-helios-slate animate-pulse">Loading activity...</div>
) : jobs.length === 0 ? (
<div className="text-center py-8 text-helios-slate/60 italic">No recent activity found.</div>
) : (
jobs.map(job => (
<div key={job.id} className="flex items-center justify-between p-3 rounded-xl bg-helios-surface-soft hover:bg-white/5 transition-colors border border-transparent hover:border-helios-line/20">
<div className="flex items-center gap-3 min-w-0">
<div className={`w-2 h-2 rounded-full shrink-0 ${job.status === 'Completed' ? 'bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.4)]' :
job.status === 'Failed' ? 'bg-red-500' :
job.status === 'Encoding' ? 'bg-amber-500 animate-pulse' :
'bg-helios-slate'
}`} />
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium text-helios-ink truncate" title={job.input_path}>
{job.input_path.split(/[/\\]/).pop()}
</span>
<span className="text-[10px] text-helios-slate uppercase tracking-wide font-bold">{job.status}</span>
</div>
</div>
<span className="text-xs font-mono text-helios-slate/60 whitespace-nowrap ml-4">
#{job.id}
</span>
</div>
))
)}
</div>
</div>
{/* Getting Started Tips */}
<div className="p-6 rounded-3xl bg-gradient-to-br from-helios-surface to-helios-surface-soft border border-helios-line/40 shadow-sm">
<h3 className="text-lg font-bold text-helios-ink mb-4 flex items-center gap-2">
<Clock size={20} className="text-helios-slate" />
Quick Tips
</h3>
<div className="flex flex-col gap-4">
<div className="flex gap-3 items-start">
<div className="p-2 rounded-lg bg-helios-solar/10 text-helios-solar mt-0.5">
<HardDrive size={16} />
</div>
<div>
<h4 className="text-sm font-bold text-helios-ink">Add Media</h4>
<p className="text-xs text-helios-slate mt-1 leading-relaxed">
Mount your media volume to <code className="bg-black/10 px-1 py-0.5 rounded font-mono text-[10px]">/data</code> in Docker. Alchemist watches configured folders automatically.
</p>
</div>
</div>
<div className="flex gap-3 items-start">
<div className="p-2 rounded-lg bg-emerald-500/10 text-emerald-500 mt-0.5">
<Zap size={16} />
</div>
<div>
<h4 className="text-sm font-bold text-helios-ink">Performance</h4>
<p className="text-xs text-helios-slate mt-1 leading-relaxed">
Toggle <strong>Hardware Acceleration</strong> in Settings if you have a supported GPU (NVIDIA/Intel) for 10x speeds.
</p>
</div>
</div>
<div className="flex gap-3 items-start">
<div className="p-2 rounded-lg bg-purple-500/10 text-purple-500 mt-0.5">
<Activity size={16} />
</div>
<div>
<h4 className="text-sm font-bold text-helios-ink">Monitor Logs</h4>
<p className="text-xs text-helios-slate mt-1 leading-relaxed">
Check the <strong>Logs</strong> page for detailed real-time insights into the transcoding pipeline.
</p>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,194 @@
import { useEffect, useRef, useState } from "react";
import { Terminal, Pause, Play, Trash2 } from "lucide-react";
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
interface LogEntry {
id: number;
timestamp: string;
message: string;
level: "info" | "warn" | "error" | "debug";
}
export default function LogViewer() {
const [logs, setLogs] = useState<LogEntry[]>([]);
const [paused, setPaused] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const maxLogs = 1000;
useEffect(() => {
let eventSource: EventSource | null = null;
const connect = () => {
eventSource = new EventSource("/api/events");
eventSource.addEventListener("log", (e) => {
if (paused) return; // Note: This drops logs while paused. Alternatively we could buffer.
// But usually "pause" implies "stop scrolling me".
// For now, let's keep accumulating but not auto-scroll?
// Or truly ignore updates. Let's buffer/append always effectively, but control scroll?
// Simpler: Just append functionality is standard. "Pause" usually means "Pause updates".
// Actually, if we are paused, we shouldn't update state to avoid re-renders/shifting.
if (paused) return;
const message = e.data;
const level = message.toLowerCase().includes("error")
? "error"
: message.toLowerCase().includes("warn")
? "warn"
: "info";
addLog({
id: Date.now() + Math.random(),
timestamp: new Date().toLocaleTimeString(),
message,
level
});
});
// Also listen for other events to show interesting activity
eventSource.addEventListener("decision", (e) => {
if (paused) return;
try {
const data = JSON.parse(e.data);
addLog({
id: Date.now() + Math.random(),
timestamp: new Date().toLocaleTimeString(),
message: `Decision: ${data.action.toUpperCase()} Job #${data.job_id} - ${data.reason}`,
level: "info"
});
} catch { }
});
eventSource.addEventListener("job_status", (e) => {
if (paused) return;
try {
const data = JSON.parse(e.data);
addLog({
id: Date.now() + Math.random(),
timestamp: new Date().toLocaleTimeString(),
message: `Job #${data.job_id} status changed to ${data.status}`,
level: "info"
});
} catch { }
});
eventSource.onerror = (e) => {
console.error("SSE Error", e);
eventSource?.close();
// Reconnect after delay
setTimeout(connect, 5000);
};
};
connect();
return () => {
eventSource?.close();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [paused]); // Re-connecting on pause toggle is inefficient. Better to use ref for paused state inside callback.
// Correction: Use ref for paused state to avoid reconnecting.
const pausedRef = useRef(paused);
useEffect(() => { pausedRef.current = paused; }, [paused]);
// Re-implement effect with ref
useEffect(() => {
let eventSource: EventSource | null = null;
const connect = () => {
eventSource = new EventSource("/api/events");
const handleMsg = (msg: string, level: "info" | "warn" | "error" = "info") => {
if (pausedRef.current) return;
setLogs(prev => {
const newLogs = [...prev, {
id: Date.now() + Math.random(),
timestamp: new Date().toLocaleTimeString(),
message: msg,
level
}];
if (newLogs.length > maxLogs) return newLogs.slice(newLogs.length - maxLogs);
return newLogs;
});
};
eventSource.addEventListener("log", (e) => handleMsg(e.data, e.data.toLowerCase().includes("warn") ? "warn" : e.data.toLowerCase().includes("error") ? "error" : "info"));
eventSource.addEventListener("decision", (e) => {
try { const d = JSON.parse(e.data); handleMsg(`Decision: ${d.action.toUpperCase()} Job #${d.job_id} - ${d.reason}`); } catch { }
});
eventSource.addEventListener("status", (e) => { // NOTE: "status" event name in server.rs is "status", not "job_status" in one place? server.rs:376 says "status"
try { const d = JSON.parse(e.data); handleMsg(`Job #${d.job_id} is now ${d.status}`); } catch { }
});
eventSource.onerror = () => { eventSource?.close(); setTimeout(connect, 3000); };
};
connect();
return () => eventSource?.close();
}, []);
// Auto-scroll
useEffect(() => {
if (!paused && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [logs, paused]);
const addLog = (log: LogEntry) => {
setLogs(prev => [...prev.slice(-999), log]);
};
return (
<div className="flex flex-col h-full rounded-2xl border border-helios-line/40 bg-[#0d1117] overflow-hidden shadow-2xl">
<div className="flex items-center justify-between px-4 py-3 border-b border-helios-line/20 bg-helios-surface/50 backdrop-blur">
<div className="flex items-center gap-2 text-helios-slate">
<Terminal size={16} />
<span className="text-xs font-bold uppercase tracking-widest">System Logs</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setPaused(!paused)}
className="p-1.5 rounded-lg hover:bg-helios-line/10 text-helios-slate transition-colors"
title={paused ? "Resume Auto-scroll" : "Pause Auto-scroll"}
>
{paused ? <Play size={14} /> : <Pause size={14} />}
</button>
<button
onClick={() => setLogs([])}
className="p-1.5 rounded-lg hover:bg-red-500/10 text-helios-slate hover:text-red-400 transition-colors"
title="Clear Logs"
>
<Trash2 size={14} />
</button>
</div>
</div>
<div
ref={scrollRef}
className="flex-1 overflow-y-auto p-4 font-mono text-xs space-y-1 scrollbar-thin scrollbar-thumb-helios-line/20 scrollbar-track-transparent"
>
{logs.length === 0 && (
<div className="text-helios-slate/30 text-center py-10 italic">Waiting for events...</div>
)}
{logs.map((log) => (
<div key={log.id} className="flex gap-3 hover:bg-white/5 px-2 py-0.5 rounded -mx-2">
<span className="text-helios-slate/50 shrink-0 select-none">{log.timestamp}</span>
<span className={cn(
"break-all",
log.level === "error" ? "text-red-400 font-bold" :
log.level === "warn" ? "text-amber-400" :
"text-helios-mist/80"
)}>
{log.message}
</span>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,67 @@
---
import { Activity, Server, Settings, Video, Terminal } from "lucide-react";
const currentPath = Astro.url.pathname;
const navItems = [
{ href: "/", label: "Dashboard", Icon: Activity },
{ href: "/jobs", label: "Jobs", Icon: Video },
{ href: "/library", label: "Library", Icon: Server },
{ href: "/logs", label: "Logs", Icon: Terminal },
{ href: "/settings", label: "Settings", Icon: Settings },
];
---
<aside
class="w-64 bg-helios-surface border-r border-helios-line/60 flex flex-col p-4 gap-4"
>
<div
class="flex items-center gap-3 px-2 pb-4 border-b border-helios-line/40"
>
<div
class="w-8 h-8 rounded-lg bg-helios-solar text-helios-main flex items-center justify-center font-bold"
>
A
</div>
<span class="font-bold text-lg text-helios-ink">Alchemist</span>
</div>
<nav class="flex flex-col gap-2 flex-1">
{
navItems.map(({ href, label, Icon }) => {
const isActive =
currentPath === href ||
(href !== "/" && currentPath.startsWith(href));
return (
<a
href={href}
class:list={[
"flex items-center gap-3 px-3 py-2 rounded-xl transition-colors",
isActive
? "bg-helios-solar text-helios-main font-bold"
: "text-helios-slate hover:bg-helios-surface-soft hover:text-helios-ink",
]}
>
<Icon size={18} />
<span>{label}</span>
</a>
);
})
}
</nav>
<div
class="p-4 rounded-xl bg-helios-surface-soft border border-helios-line/40"
>
<div class="flex items-center justify-between mb-2">
<span
class="text-xs font-bold text-helios-slate uppercase tracking-wider"
>System Status</span
>
<span class="w-2 h-2 rounded-full bg-status-success"></span>
</div>
<div class="text-sm text-helios-ink font-mono">
Active: <span class="text-helios-solar">1/4</span>
</div>
</div>
</aside>

View File

@@ -1,5 +1,6 @@
---
import "../styles/global.css";
import { ViewTransitions } from "astro:transitions";
interface Props {
title: string;
@@ -17,9 +18,26 @@ const { title } = Astro.props;
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="generator" content={Astro.generator} />
<title>{title}</title>
<ViewTransitions />
</head>
<body>
<slot />
<script is:inline>
// Initialize theme immediately to prevent FOUC
const theme = localStorage.getItem("theme");
if (theme) {
document.documentElement.setAttribute(
"data-color-profile",
theme,
);
} else {
// Default to helios-orange if not set
document.documentElement.setAttribute(
"data-color-profile",
"helios-orange",
);
}
</script>
<script>
// Check setup status on load
if (window.location.pathname !== "/setup") {

View File

@@ -1,163 +1,28 @@
---
import Layout from "../layouts/Layout.astro";
import Sidebar from "../components/Sidebar.astro";
import Dashboard from "../components/Dashboard.tsx";
import { Activity, Server, Settings, Video } from "lucide-react";
---
<Layout title="Alchemist | Dashboard">
<div class="app-shell">
<!-- Sidebar -->
<aside
class="w-64 bg-helios-surface border-r border-helios-line/60 flex flex-col p-4 gap-4"
>
<div
class="flex items-center gap-3 px-2 pb-4 border-b border-helios-line/40"
>
<div
class="w-8 h-8 rounded-lg bg-helios-solar text-helios-main flex items-center justify-center font-bold"
>
A
</div>
<span class="font-bold text-lg text-helios-ink">Alchemist</span>
</div>
<nav class="flex flex-col gap-2 flex-1">
<a
href="/"
class="flex items-center gap-3 px-3 py-2 rounded-xl bg-helios-solar text-helios-main font-bold"
>
<Activity size={18} />
<span>Dashboard</span>
</a>
<a
href="/jobs"
class="flex items-center gap-3 px-3 py-2 rounded-xl text-helios-slate hover:bg-helios-surface-soft hover:text-helios-ink transition-colors"
>
<Video size={18} />
<span>Jobs</span>
</a>
<a
href="/library"
class="flex items-center gap-3 px-3 py-2 rounded-xl text-helios-slate hover:bg-helios-surface-soft hover:text-helios-ink transition-colors"
>
<Server size={18} />
<span>Library</span>
</a>
<a
href="/settings"
class="flex items-center gap-3 px-3 py-2 rounded-xl text-helios-slate hover:bg-helios-surface-soft hover:text-helios-ink transition-colors"
>
<Settings size={18} />
<span>Settings</span>
</a>
</nav>
<div
class="p-4 rounded-xl bg-helios-surface-soft border border-helios-line/40"
>
<div class="flex items-center justify-between mb-2">
<span
class="text-xs font-bold text-helios-slate uppercase tracking-wider"
>System Status</span
<Sidebar />
<main class="app-main p-8 overflow-y-auto">
<header class="flex items-center justify-between mb-8">
<div>
<h1
class="text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-helios-ink to-helios-slate tracking-tight"
>
<span class="w-2 h-2 rounded-full bg-status-success"></span>
</div>
<div class="text-sm text-helios-ink font-mono">
Active: <span class="text-helios-solar">1/4</span>
</div>
</div>
</aside>
<!-- Main Content -->
<main class="app-main">
<header
class="h-16 flex items-center justify-between px-6 border-b border-helios-line/60 bg-helios-surface/98 backdrop-blur"
>
<h1 class="text-lg font-semibold text-helios-ink">Overview</h1>
<div class="flex items-center gap-4">
<div
class="px-3 py-1 rounded-full border border-helios-line/60 bg-helios-surface-soft text-xs font-mono text-helios-slate uppercase tracking-wider"
>
v0.1.0
</div>
Dashboard
</h1>
<p class="text-helios-slate mt-2 text-sm">
System Overview & Performance
</p>
</div>
</header>
<div class="p-6 overflow-auto">
<div
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8"
>
<!-- Stat Card -->
<div
class="bg-helios-surface/90 border border-helios-line/50 rounded-2xl p-6 shadow-xl"
>
<div class="flex items-center justify-between mb-4">
<span class="text-helios-slate font-medium"
>Queue Size</span
>
<Activity size={20} class="text-helios-solar" />
</div>
<div
class="text-3xl font-bold text-helios-ink font-mono"
>
12
</div>
<div class="mt-2 text-xs text-status-warning">
+3 pending
</div>
</div>
<!-- Stat Card -->
<div
class="bg-helios-surface/90 border border-helios-line/50 rounded-2xl p-6 shadow-xl"
>
<div class="flex items-center justify-between mb-4">
<span class="text-helios-slate font-medium"
>Processed</span
>
<Video size={20} class="text-helios-cyan" />
</div>
<div
class="text-3xl font-bold text-helios-ink font-mono"
>
1,024
</div>
<div class="mt-2 text-xs text-status-success">
1TB saved
</div>
</div>
</div>
<div
class="bg-helios-surface/90 border border-helios-line/50 rounded-2xl p-6 shadow-xl"
>
<h3 class="text-lg font-semibold text-helios-ink mb-6">
Recent Activity
</h3>
<div class="space-y-4">
{
[1, 2, 3].map((i) => (
<div class="flex items-center gap-4 p-4 rounded-xl bg-helios-main border border-helios-line/40">
<div class="w-10 h-10 rounded-lg bg-helios-surface-soft flex items-center justify-center text-helios-slate">
<Video size={18} />
</div>
<div class="flex-1">
<div class="text-sm font-medium text-helios-ink">
Movie_Title_{i}.mkv
</div>
<div class="text-xs text-helios-slate">
Transcoding to AV1 • 45%
</div>
</div>
<div class="text-xs font-mono text-helios-solar">
Processing...
</div>
</div>
))
}
</div>
</div>
</div>
<Dashboard client:load />
</main>
</div>
</Layout>

32
web/src/pages/jobs.astro Normal file
View File

@@ -0,0 +1,32 @@
---
import Layout from "../layouts/Layout.astro";
import Sidebar from "../components/Sidebar.astro";
import { Video } from "lucide-react";
---
<Layout title="Alchemist | Jobs">
<div class="app-shell">
<Sidebar />
<main class="app-main">
<header
class="h-16 flex items-center justify-between px-6 border-b border-helios-line/60 bg-helios-surface/98 backdrop-blur"
>
<div class="flex items-center gap-3">
<Video class="text-helios-solar" />
<h1 class="text-lg font-semibold text-helios-ink">
Jobs Management
</h1>
</div>
</header>
<div class="p-6">
<div
class="p-8 text-center border-2 border-dashed border-helios-line/40 rounded-2xl bg-helios-surface/50"
>
<p class="text-helios-slate">
Job management interface coming soon.
</p>
</div>
</div>
</main>
</div>
</Layout>

View File

@@ -0,0 +1,32 @@
---
import Layout from "../layouts/Layout.astro";
import Sidebar from "../components/Sidebar.astro";
import { Server } from "lucide-react";
---
<Layout title="Alchemist | Library">
<div class="app-shell">
<Sidebar />
<main class="app-main">
<header
class="h-16 flex items-center justify-between px-6 border-b border-helios-line/60 bg-helios-surface/98 backdrop-blur"
>
<div class="flex items-center gap-3">
<Server class="text-helios-solar" />
<h1 class="text-lg font-semibold text-helios-ink">
Media Library
</h1>
</div>
</header>
<div class="p-6">
<div
class="p-8 text-center border-2 border-dashed border-helios-line/40 rounded-2xl bg-helios-surface/50"
>
<p class="text-helios-slate">
Library browser interface coming soon.
</p>
</div>
</div>
</main>
</div>
</Layout>

27
web/src/pages/logs.astro Normal file
View File

@@ -0,0 +1,27 @@
---
import Layout from "../layouts/Layout.astro";
import Sidebar from "../components/Sidebar.astro";
import LogViewer from "../components/LogViewer.tsx";
import { Terminal } from "lucide-react";
---
<Layout title="Alchemist | System Logs">
<div class="app-shell">
<Sidebar />
<main class="app-main flex flex-col h-screen overflow-hidden">
<header
class="h-16 flex-shrink-0 flex items-center justify-between px-6 border-b border-helios-line/60 bg-helios-surface/98 backdrop-blur"
>
<div class="flex items-center gap-3">
<Terminal class="text-helios-solar" />
<h1 class="text-lg font-semibold text-helios-ink">
System Logs
</h1>
</div>
</header>
<div class="flex-1 p-6 min-h-0">
<LogViewer client:load /> // Hydrate immediately
</div>
</main>
</div>
</Layout>

View File

@@ -0,0 +1,37 @@
---
import Layout from "../layouts/Layout.astro";
import Sidebar from "../components/Sidebar.astro";
import AppearanceSettings from "../components/AppearanceSettings.tsx";
import { Settings } from "lucide-react";
---
<Layout title="Alchemist | Settings">
<div class="app-shell">
<Sidebar />
<main class="app-main">
<header
class="h-16 flex items-center justify-between px-6 border-b border-helios-line/60 bg-helios-surface/98 backdrop-blur"
>
<div class="flex items-center gap-3">
<Settings class="text-helios-solar" />
<h1 class="text-lg font-semibold text-helios-ink">
Settings
</h1>
</div>
</header>
<div class="p-6">
<!-- Theme Settings -->
<div class="mb-8">
<AppearanceSettings client:load />
</div>
<!-- Coming Soon Placeholder for other settings -->
<div
class="p-8 text-center border-2 border-dashed border-helios-line/40 rounded-2xl bg-helios-surface/50"
>
<p class="text-helios-slate">More settings coming soon.</p>
</div>
</div>
</main>
</div>
</Layout>

View File

@@ -12,3 +12,15 @@ import SetupWizard from "../components/SetupWizard";
</div>
</div>
</Layout>
<script>
// If setup is already done, redirect to dashboard
fetch("/api/setup/status")
.then((res) => res.json())
.then((data) => {
if (!data.setup_required) {
window.location.href = "/";
}
})
.catch((err) => console.error("Failed to check setup status", err));
</script>

View File

@@ -5,10 +5,9 @@
@tailwind utilities;
@layer base {
:root {
color-scheme: dark;
/* Default to Crimson as user showed interest before, or Helios Orange?
Using Helios Orange as default for now to match reference exactly, can switch later. */
:root,
[data-color-profile="helios-orange"] {
--bg-main: 12 10 8;
--bg-panel: 20 17 14;
--bg-elevated: 28 24 20;
@@ -22,7 +21,29 @@
--status-error: 232 92 90;
}
[data-theme="crimson"] {
[data-color-profile="sunset"] {
--bg-main: 20 10 10;
--bg-panel: 30 15 15;
--bg-elevated: 40 20 20;
--accent-primary: 255 100 50;
--accent-secondary: 255 150 100;
--text-primary: 255 240 230;
--text-muted: 180 160 150;
--border-subtle: 80 40 40;
}
[data-color-profile="neon"] {
--bg-main: 5 5 10;
--bg-panel: 10 10 20;
--bg-elevated: 15 15 30;
--accent-primary: 0 255 255;
--accent-secondary: 255 0 255;
--text-primary: 240 255 255;
--text-muted: 150 150 180;
--border-subtle: 50 50 80;
}
[data-color-profile="crimson"] {
--bg-main: 14 7 10;
--bg-panel: 22 12 16;
--bg-elevated: 30 18 24;
@@ -31,9 +52,94 @@
--text-primary: 244 232 235;
--text-muted: 189 160 168;
--border-subtle: 74 50 58;
--status-success: 86 192 135;
--status-warning: 242 176 78;
--status-error: 225 82 94;
}
[data-color-profile="deep-blue"] {
--bg-main: 8 10 20;
--bg-panel: 15 20 35;
--bg-elevated: 20 25 45;
--accent-primary: 50 100 255;
--accent-secondary: 100 150 255;
--text-primary: 230 240 255;
--text-muted: 150 160 180;
--border-subtle: 40 50 80;
}
[data-color-profile="ocean"] {
--bg-main: 5 15 20;
--bg-panel: 10 25 30;
--bg-elevated: 15 35 40;
--accent-primary: 0 200 200;
--accent-secondary: 100 240 240;
--text-primary: 230 250 250;
--text-muted: 150 170 180;
--border-subtle: 30 60 70;
}
[data-color-profile="emerald"] {
--bg-main: 5 20 10;
--bg-panel: 10 30 15;
--bg-elevated: 15 40 20;
--accent-primary: 50 200 100;
--accent-secondary: 100 240 150;
--text-primary: 230 255 240;
--text-muted: 150 180 160;
--border-subtle: 30 70 40;
}
[data-color-profile="lavender"] {
--bg-main: 15 15 25;
--bg-panel: 25 25 35;
--bg-elevated: 35 35 45;
--accent-primary: 180 150 255;
--accent-secondary: 210 190 255;
--text-primary: 245 240 255;
--text-muted: 170 160 180;
--border-subtle: 70 60 90;
}
[data-color-profile="purple"] {
--bg-main: 20 10 30;
--bg-panel: 30 15 45;
--bg-elevated: 40 20 60;
--accent-primary: 150 50 255;
--accent-secondary: 200 100 255;
--text-primary: 250 230 255;
--text-muted: 180 160 200;
--border-subtle: 80 40 100;
}
[data-color-profile="midnight"] {
--bg-main: 0 0 0;
--bg-panel: 5 5 5;
--bg-elevated: 10 10 10;
--accent-primary: 255 255 255;
--accent-secondary: 200 200 200;
--text-primary: 255 255 255;
--text-muted: 150 150 150;
--border-subtle: 50 50 50;
}
[data-color-profile="monochrome"] {
--bg-main: 20 20 20;
--bg-panel: 30 30 30;
--bg-elevated: 40 40 40;
--accent-primary: 200 200 200;
--accent-secondary: 150 150 150;
--text-primary: 230 230 230;
--text-muted: 150 150 150;
--border-subtle: 60 60 60;
}
[data-color-profile="dracula"] {
--bg-main: 40 42 54;
--bg-panel: 68 71 90;
--bg-elevated: 98 114 164;
--accent-primary: 255 121 198;
--accent-secondary: 189 147 249;
--text-primary: 248 248 242;
--text-muted: 98 114 164;
--border-subtle: 98 114 164;
}
body {

11
web/tsconfig.json Normal file
View File

@@ -0,0 +1,11 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "react",
"baseUrl": ".",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"noEmit": true
}
}