mirror of
https://github.com/bybrooklyn/alchemist.git
synced 2026-04-18 01:43:34 -04:00
feat: Upgrade to v0.1.1 - Dashboard, Themes, Hot Reloading, Web Setup
This commit is contained in:
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -26,7 +26,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "alchemist"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "alchemist"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
edition = "2021"
|
||||
license = "GPL-3.0"
|
||||
|
||||
|
||||
5
migrations/20260108220000_ui_preferences.sql
Normal file
5
migrations/20260108220000_ui_preferences.sql
Normal 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
|
||||
);
|
||||
23
src/db.rs
23
src/db.rs
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
108
src/main.rs
108
src/main.rs
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
304
web/src/components/AppearanceSettings.tsx
Normal file
304
web/src/components/AppearanceSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
189
web/src/components/Dashboard.tsx
Normal file
189
web/src/components/Dashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
194
web/src/components/LogViewer.tsx
Normal file
194
web/src/components/LogViewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
web/src/components/Sidebar.astro
Normal file
67
web/src/components/Sidebar.astro
Normal 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>
|
||||
@@ -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") {
|
||||
|
||||
@@ -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
32
web/src/pages/jobs.astro
Normal 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>
|
||||
32
web/src/pages/library.astro
Normal file
32
web/src/pages/library.astro
Normal 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
27
web/src/pages/logs.astro
Normal 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>
|
||||
37
web/src/pages/settings.astro
Normal file
37
web/src/pages/settings.astro
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
11
web/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "react",
|
||||
"baseUrl": ".",
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user