mirror of
https://github.com/bybrooklyn/alchemist.git
synced 2026-04-18 09:53:33 -04:00
Fix engine analysis, drain flow, and settings UI regressions
This commit is contained in:
@@ -206,9 +206,9 @@ export default function Dashboard() {
|
||||
<div className="rounded-lg border border-helios-solar/20 bg-helios-solar/10 px-4 py-3 flex items-center gap-3">
|
||||
<span className="text-helios-solar shrink-0 text-xs font-semibold">ENGINE PAUSED</span>
|
||||
<span className="text-sm text-helios-ink">
|
||||
The queue can fill up but Alchemist won't start encoding until you click
|
||||
<span className="font-bold"> Start</span>
|
||||
{" "}in the header.
|
||||
Analysis runs automatically. Click{" "}
|
||||
<span className="font-bold">Start</span>
|
||||
{" "}in the header to begin encoding.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -99,22 +99,6 @@ export default function HeaderActions() {
|
||||
}
|
||||
};
|
||||
|
||||
const handlePause = async () => {
|
||||
setEngineLoading(true);
|
||||
try {
|
||||
await apiAction("/api/engine/pause", { method: "POST" });
|
||||
await refreshEngineStatus();
|
||||
} catch {
|
||||
showToast({
|
||||
kind: "error",
|
||||
title: "Engine",
|
||||
message: "Failed to update engine state.",
|
||||
});
|
||||
} finally {
|
||||
setEngineLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = async () => {
|
||||
setEngineLoading(true);
|
||||
try {
|
||||
@@ -131,22 +115,6 @@ export default function HeaderActions() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelStop = async () => {
|
||||
setEngineLoading(true);
|
||||
try {
|
||||
await apiAction("/api/engine/stop-drain", { method: "POST" });
|
||||
await refreshEngineStatus();
|
||||
} catch {
|
||||
showToast({
|
||||
kind: "error",
|
||||
title: "Engine",
|
||||
message: "Failed to update engine state.",
|
||||
});
|
||||
} finally {
|
||||
setEngineLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await apiAction("/api/auth/logout", { method: "POST" });
|
||||
@@ -174,8 +142,8 @@ export default function HeaderActions() {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Start — shown when paused or draining */}
|
||||
{(status === "paused" || status === "draining") && (
|
||||
{/* Single action button — changes based on state */}
|
||||
{status === "paused" && (
|
||||
<button
|
||||
onClick={() => void handleStart()}
|
||||
disabled={engineLoading}
|
||||
@@ -186,19 +154,6 @@ export default function HeaderActions() {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Pause — shown when running */}
|
||||
{status === "running" && (
|
||||
<button
|
||||
onClick={() => void handlePause()}
|
||||
disabled={engineLoading}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-helios-line/20 px-3 py-1.5 text-xs font-medium text-helios-slate hover:bg-helios-surface-soft hover:text-helios-ink transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Pause size={13} />
|
||||
Pause
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Stop — shown when running */}
|
||||
{status === "running" && (
|
||||
<button
|
||||
onClick={() => void handleStop()}
|
||||
@@ -210,15 +165,13 @@ export default function HeaderActions() {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Cancel Stop — shown when draining */}
|
||||
{status === "draining" && (
|
||||
<button
|
||||
onClick={() => void handleCancelStop()}
|
||||
disabled={engineLoading}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-blue-400/30 px-3 py-1.5 text-xs font-medium text-blue-400 hover:bg-blue-400/10 transition-colors disabled:opacity-50"
|
||||
disabled
|
||||
className="flex items-center gap-1.5 rounded-lg border border-helios-line/20 px-3 py-1.5 text-xs font-medium text-helios-slate/50 opacity-60 cursor-not-allowed"
|
||||
>
|
||||
<X size={13} />
|
||||
Cancel Stop
|
||||
<Square size={13} className="animate-pulse" />
|
||||
Stopping…
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
||||
@@ -219,17 +219,19 @@ interface EncodeStats {
|
||||
vmaf_score?: number;
|
||||
}
|
||||
|
||||
interface LogEntry {
|
||||
id: number;
|
||||
level: string;
|
||||
message: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface JobDetail {
|
||||
job: Job;
|
||||
metadata?: JobMetadata;
|
||||
encode_stats?: EncodeStats;
|
||||
job_logs?: Array<{
|
||||
id: number;
|
||||
level: string;
|
||||
message: string;
|
||||
created_at: string;
|
||||
}>;
|
||||
job_failure_summary?: string;
|
||||
metadata: JobMetadata | null;
|
||||
encode_stats: EncodeStats | null;
|
||||
job_logs: LogEntry[];
|
||||
job_failure_summary: string | null;
|
||||
}
|
||||
|
||||
interface CountMessageResponse {
|
||||
@@ -633,10 +635,6 @@ export default function JobManager() {
|
||||
? humanizeSkipReason(focusedJob.job.decision_reason)
|
||||
: null;
|
||||
const focusedJobLogs = focusedJob?.job_logs ?? [];
|
||||
const focusedFailureDetail = focusedJob?.job.decision_reason ?? focusedJob?.job_failure_summary ?? null;
|
||||
const focusedFailureExplanation = focusedFailureDetail
|
||||
? explainFailureSummary(focusedFailureDetail)
|
||||
: null;
|
||||
const shouldShowFfmpegOutput = focusedJob
|
||||
? ["failed", "completed", "skipped"].includes(focusedJob.job.status) && focusedJobLogs.length > 0
|
||||
: false;
|
||||
@@ -1242,33 +1240,6 @@ export default function JobManager() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{focusedJob.job.status === "failed" && focusedFailureDetail && (
|
||||
<div className="rounded-lg border border-status-error/20 bg-status-error/5 px-4 py-3 space-y-2">
|
||||
<div className="flex items-center gap-2 text-status-error">
|
||||
<AlertCircle size={14} />
|
||||
<span className="text-sm font-semibold">What went wrong</span>
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-status-error">
|
||||
{focusedFailureExplanation}
|
||||
</p>
|
||||
<p className="break-all font-mono text-xs leading-relaxed text-helios-slate">
|
||||
{focusedFailureDetail}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{focusedJob.job.status === "failed" && !focusedFailureDetail && (
|
||||
<div className="p-4 rounded-lg bg-status-error/5 border border-status-error/15">
|
||||
<div className="flex items-center gap-2 text-status-error mb-2">
|
||||
<AlertCircle size={14} />
|
||||
<span className="text-sm font-semibold">What went wrong</span>
|
||||
</div>
|
||||
<p className="text-sm text-helios-slate leading-relaxed">
|
||||
No error summary was recorded. Review the FFmpeg output below for the last encoder messages.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{focusedJob.job.status === "skipped" && focusedJob.job.decision_reason && (
|
||||
<div className="p-4 rounded-lg bg-helios-surface-soft border border-helios-line/10">
|
||||
<p className="text-sm text-helios-ink leading-relaxed">
|
||||
@@ -1304,6 +1275,31 @@ export default function JobManager() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{focusedJob.job.status === "failed" && (
|
||||
<div className="rounded-lg border border-status-error/20 bg-status-error/5 px-4 py-4 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle size={14} className="text-status-error shrink-0" />
|
||||
<span className="text-xs font-semibold text-status-error uppercase tracking-wide">
|
||||
Failure Reason
|
||||
</span>
|
||||
</div>
|
||||
{focusedJob.job_failure_summary ? (
|
||||
<>
|
||||
<p className="text-sm font-medium text-helios-ink">
|
||||
{explainFailureSummary(focusedJob.job_failure_summary)}
|
||||
</p>
|
||||
<p className="text-xs font-mono text-helios-slate/70 break-all leading-relaxed">
|
||||
{focusedJob.job_failure_summary}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-helios-slate">
|
||||
No error details captured. Check the logs below.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shouldShowFfmpegOutput && (
|
||||
<details className="rounded-lg border border-helios-line/15 bg-helios-surface-soft/40 p-4">
|
||||
<summary className="cursor-pointer text-xs text-helios-solar">
|
||||
|
||||
@@ -147,10 +147,7 @@ export default function SystemStatus() {
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="system-status-title"
|
||||
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
layoutId={layoutId}
|
||||
className="w-full max-w-lg bg-helios-surface border border-helios-line/30 rounded-xl shadow-2xl overflow-hidden relative outline-none"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
tabIndex={-1}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { FolderOpen, Trash2, Plus, Folder, Play, Pencil } from "lucide-react";
|
||||
import { FolderOpen, X, Play, Pencil } from "lucide-react";
|
||||
import { apiAction, apiJson, isApiError } from "../lib/api";
|
||||
import { showToast } from "../lib/toast";
|
||||
import ConfirmDialog from "./ui/ConfirmDialog";
|
||||
@@ -62,18 +62,14 @@ export default function WatchFolders() {
|
||||
const [dirs, setDirs] = useState<WatchDir[]>([]);
|
||||
const [profiles, setProfiles] = useState<LibraryProfile[]>([]);
|
||||
const [presets, setPresets] = useState<LibraryProfile[]>([]);
|
||||
const [libraryDirs, setLibraryDirs] = useState<string[]>([]);
|
||||
const [path, setPath] = useState("");
|
||||
const [libraryPath, setLibraryPath] = useState("");
|
||||
const [isRecursive, setIsRecursive] = useState(true);
|
||||
const [dirInput, setDirInput] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [syncingLibrary, setSyncingLibrary] = useState(false);
|
||||
const [assigningDirId, setAssigningDirId] = useState<number | null>(null);
|
||||
const [savingProfile, setSavingProfile] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pendingRemoveId, setPendingRemoveId] = useState<number | null>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState<null | "library" | "watch">(null);
|
||||
const [pendingRemovePath, setPendingRemovePath] = useState<string | null>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState<boolean>(false);
|
||||
const [customizeDir, setCustomizeDir] = useState<WatchDir | null>(null);
|
||||
const [profileDraft, setProfileDraft] = useState<ProfileDraft | null>(null);
|
||||
|
||||
@@ -86,14 +82,40 @@ export default function WatchFolders() {
|
||||
[profiles]
|
||||
);
|
||||
|
||||
const fetchBundle = async () => {
|
||||
const data = await apiJson<SettingsBundleResponse>("/api/settings/bundle");
|
||||
setLibraryDirs(data.settings.scanner.directories);
|
||||
};
|
||||
|
||||
const fetchDirs = async () => {
|
||||
const data = await apiJson<WatchDir[]>("/api/settings/watch-dirs");
|
||||
setDirs(data);
|
||||
// Fetch both canonical library dirs and extra watch dirs, merge them for the UI
|
||||
const [bundle, watchDirs] = await Promise.all([
|
||||
apiJson<SettingsBundleResponse>("/api/settings/bundle"),
|
||||
apiJson<WatchDir[]>("/api/settings/watch-dirs")
|
||||
]);
|
||||
|
||||
const merged: WatchDir[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
// Canonical roots get mapped to WatchDir structure (id is synthetic/negative, profile_id is null)
|
||||
bundle.settings.scanner.directories.forEach((dir, idx) => {
|
||||
if (!seen.has(dir)) {
|
||||
seen.add(dir);
|
||||
merged.push({ id: -(idx + 1), path: dir, is_recursive: true, profile_id: null });
|
||||
}
|
||||
});
|
||||
|
||||
// Extra watch dirs append (usually they would be stored in the DB)
|
||||
watchDirs.forEach(wd => {
|
||||
if (!seen.has(wd.path)) {
|
||||
seen.add(wd.path);
|
||||
merged.push(wd);
|
||||
} else {
|
||||
// If it exists in both, prefer the DB version so we have a real ID for profiles
|
||||
const existing = merged.find(m => m.path === wd.path);
|
||||
if (existing) {
|
||||
existing.id = wd.id;
|
||||
existing.profile_id = wd.profile_id;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setDirs(merged);
|
||||
};
|
||||
|
||||
const fetchProfiles = async () => {
|
||||
@@ -108,7 +130,7 @@ export default function WatchFolders() {
|
||||
|
||||
const refreshAll = async () => {
|
||||
try {
|
||||
await Promise.all([fetchDirs(), fetchBundle(), fetchProfiles(), fetchPresets()]);
|
||||
await Promise.all([fetchDirs(), fetchProfiles(), fetchPresets()]);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
const message = isApiError(e) ? e.message : "Failed to load watch folders";
|
||||
@@ -138,19 +160,45 @@ export default function WatchFolders() {
|
||||
}
|
||||
};
|
||||
|
||||
const addDir = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!path.trim()) return;
|
||||
const addDirectory = async (targetPath: string) => {
|
||||
const normalized = targetPath.trim();
|
||||
if (!normalized) return;
|
||||
if (dirs.some((d) => d.path === normalized)) {
|
||||
showToast({ kind: "error", title: "Watch Folders", message: "Folder already exists." });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await apiAction("/api/settings/watch-dirs", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: path.trim(), is_recursive: isRecursive }),
|
||||
});
|
||||
// Add to BOTH config (canonical) and DB (profiles)
|
||||
const bundle = await apiJson<SettingsBundleResponse>("/api/settings/bundle");
|
||||
if (!bundle.settings.scanner.directories.includes(normalized)) {
|
||||
await apiAction("/api/settings/bundle", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
...bundle.settings,
|
||||
scanner: {
|
||||
...bundle.settings.scanner,
|
||||
directories: [...bundle.settings.scanner.directories, normalized],
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
setPath("");
|
||||
setIsRecursive(true);
|
||||
try {
|
||||
await apiAction("/api/settings/watch-dirs", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: normalized, is_recursive: true }),
|
||||
});
|
||||
} catch (innerE) {
|
||||
// If it's just a duplicate DB error we can ignore it since we successfully added to canonical
|
||||
if (!(isApiError(innerE) && innerE.status === 409)) {
|
||||
throw innerE;
|
||||
}
|
||||
}
|
||||
|
||||
setDirInput("");
|
||||
setError(null);
|
||||
await fetchDirs();
|
||||
showToast({ kind: "success", title: "Watch Folders", message: "Folder added." });
|
||||
@@ -161,49 +209,36 @@ export default function WatchFolders() {
|
||||
}
|
||||
};
|
||||
|
||||
const saveLibraryDirs = async (nextDirectories: string[]) => {
|
||||
setSyncingLibrary(true);
|
||||
const removeDirectory = async (dirPath: string) => {
|
||||
const dir = dirs.find((d) => d.path === dirPath);
|
||||
if (!dir) return;
|
||||
|
||||
try {
|
||||
// Remove from canonical config if present
|
||||
const bundle = await apiJson<SettingsBundleResponse>("/api/settings/bundle");
|
||||
await apiAction("/api/settings/bundle", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
...bundle.settings,
|
||||
scanner: {
|
||||
...bundle.settings.scanner,
|
||||
directories: nextDirectories,
|
||||
},
|
||||
}),
|
||||
});
|
||||
setLibraryDirs(nextDirectories);
|
||||
setError(null);
|
||||
showToast({ kind: "success", title: "Library", message: "Library directories updated." });
|
||||
} catch (e) {
|
||||
const message = isApiError(e) ? e.message : "Failed to update library directories";
|
||||
setError(message);
|
||||
showToast({ kind: "error", title: "Library", message });
|
||||
} finally {
|
||||
setSyncingLibrary(false);
|
||||
}
|
||||
};
|
||||
const filteredDirs = bundle.settings.scanner.directories.filter(candidate => candidate !== dir.path);
|
||||
|
||||
if (filteredDirs.length !== bundle.settings.scanner.directories.length) {
|
||||
await apiAction("/api/settings/bundle", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
...bundle.settings,
|
||||
scanner: {
|
||||
...bundle.settings.scanner,
|
||||
directories: filteredDirs,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const addLibraryDir = async () => {
|
||||
const nextPath = libraryPath.trim();
|
||||
if (!nextPath || libraryDirs.includes(nextPath)) return;
|
||||
await saveLibraryDirs([...libraryDirs, nextPath]);
|
||||
setLibraryPath("");
|
||||
};
|
||||
// Remove from DB if it has a real ID
|
||||
if (dir.id > 0) {
|
||||
await apiAction(`/api/settings/watch-dirs/${dir.id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
const removeLibraryDir = async (dir: string) => {
|
||||
await saveLibraryDirs(libraryDirs.filter(candidate => candidate !== dir));
|
||||
};
|
||||
|
||||
const removeDir = async (id: number) => {
|
||||
try {
|
||||
await apiAction(`/api/settings/watch-dirs/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
setError(null);
|
||||
await fetchDirs();
|
||||
showToast({ kind: "success", title: "Watch Folders", message: "Folder removed." });
|
||||
@@ -215,6 +250,12 @@ export default function WatchFolders() {
|
||||
};
|
||||
|
||||
const assignProfile = async (dirId: number, profileId: number | null) => {
|
||||
// Can only assign profiles to DB-backed rows
|
||||
if (dirId < 0) {
|
||||
showToast({ kind: "error", title: "Profiles", message: "This directory must be re-added to support profiles." });
|
||||
return;
|
||||
}
|
||||
|
||||
setAssigningDirId(dirId);
|
||||
try {
|
||||
await apiAction(`/api/watch-dirs/${dirId}/profile`, {
|
||||
@@ -239,6 +280,11 @@ export default function WatchFolders() {
|
||||
};
|
||||
|
||||
const openCustomizeModal = (dir: WatchDir) => {
|
||||
if (dir.id < 0) {
|
||||
showToast({ kind: "error", title: "Profiles", message: "This directory must be re-added to support custom profiles." });
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedProfile = profiles.find((profile) => profile.id === dir.profile_id);
|
||||
const fallbackPreset =
|
||||
presets.find((preset) => preset.preset === "balanced")
|
||||
@@ -311,7 +357,16 @@ export default function WatchFolders() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6" aria-live="polite">
|
||||
<div className="flex justify-end mb-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="flex items-center gap-2 text-xl font-semibold text-helios-ink">
|
||||
<FolderOpen size={20} className="text-helios-solar" />
|
||||
Media Folders
|
||||
</h2>
|
||||
<p className="text-sm text-helios-slate">
|
||||
Folders Alchemist scans and watches for new media.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => void triggerScan()}
|
||||
disabled={scanning}
|
||||
@@ -328,189 +383,129 @@ export default function WatchFolders() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={addDir} className="space-y-3">
|
||||
<div className="space-y-3 rounded-lg border border-helios-line/20 bg-helios-surface-soft/50 p-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-helios-ink">Library Directories</h3>
|
||||
<p className="text-xs text-helios-slate mt-1">
|
||||
Canonical library roots from setup/TOML. These are stored in the main config file and synchronized into runtime watchers.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Folder className="absolute left-3 top-1/2 -translate-y-1/2 text-helios-slate/50" size={16} />
|
||||
<input
|
||||
type="text"
|
||||
value={libraryPath}
|
||||
onChange={(e) => setLibraryPath(e.target.value)}
|
||||
placeholder="Add library directory..."
|
||||
className="w-full bg-helios-surface border border-helios-line/20 rounded-lg pl-10 pr-4 py-2.5 text-sm text-helios-ink placeholder:text-helios-slate/40 focus:border-helios-solar focus:ring-1 focus:ring-helios-solar/50 outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickerOpen("library")}
|
||||
className="rounded-lg border border-helios-line/30 bg-helios-surface px-4 py-2.5 text-sm font-medium text-helios-ink"
|
||||
>
|
||||
Browse
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void addLibraryDir()}
|
||||
disabled={!libraryPath.trim() || syncingLibrary}
|
||||
className="bg-helios-solar hover:bg-helios-solar-dark text-helios-surface px-5 py-2.5 rounded-lg font-medium text-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 shadow-sm shadow-helios-solar/20"
|
||||
>
|
||||
<Plus size={16} /> Add Library
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{libraryDirs.map((dir) => (
|
||||
<div key={dir} className="flex items-center justify-between rounded-lg border border-helios-line/10 bg-helios-surface px-3 py-2">
|
||||
<span className="truncate font-mono text-sm text-helios-ink" title={dir}>{dir}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void removeLibraryDir(dir)}
|
||||
disabled={syncingLibrary}
|
||||
className="rounded-lg p-2 text-helios-slate hover:text-red-500 hover:bg-red-500/10 transition-colors"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{libraryDirs.length === 0 && (
|
||||
<p className="text-xs text-helios-slate">No canonical library directories configured yet.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Folder className="absolute left-3 top-1/2 -translate-y-1/2 text-helios-slate/50" size={16} />
|
||||
<input
|
||||
type="text"
|
||||
value={path}
|
||||
onChange={(e) => setPath(e.target.value)}
|
||||
placeholder="Enter full directory path..."
|
||||
className="w-full bg-helios-surface border border-helios-line/20 rounded-lg pl-10 pr-4 py-2.5 text-sm text-helios-ink placeholder:text-helios-slate/40 focus:border-helios-solar focus:ring-1 focus:ring-helios-solar/50 outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickerOpen("watch")}
|
||||
className="rounded-lg border border-helios-line/30 bg-helios-surface px-4 py-2.5 text-sm font-medium text-helios-ink"
|
||||
>
|
||||
Browse
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!path.trim()}
|
||||
className="bg-helios-solar hover:bg-helios-solar-dark text-helios-surface px-5 py-2.5 rounded-lg font-medium text-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 shadow-sm shadow-helios-solar/20"
|
||||
>
|
||||
<Plus size={16} /> Add
|
||||
</button>
|
||||
</div>
|
||||
<label className="inline-flex items-center gap-2 rounded-lg border border-helios-line/20 bg-helios-surface px-3 py-2 text-sm text-helios-ink">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isRecursive}
|
||||
onChange={(e) => setIsRecursive(e.target.checked)}
|
||||
className="rounded border-helios-line/30 bg-helios-surface-soft accent-helios-solar"
|
||||
/>
|
||||
Watch subdirectories recursively
|
||||
</label>
|
||||
</form>
|
||||
|
||||
<div className="space-y-2">
|
||||
{dirs.map((dir) => (
|
||||
<div key={dir.id} className="flex flex-col gap-3 p-3 bg-helios-surface border border-helios-line/10 rounded-lg group hover:border-helios-line/30 hover:shadow-sm transition-all">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3 overflow-hidden">
|
||||
<div className="p-1.5 bg-helios-slate/5 rounded-lg text-helios-slate">
|
||||
<Folder size={16} />
|
||||
</div>
|
||||
<span className="text-sm font-mono text-helios-ink truncate max-w-[400px]" title={dir.path}>
|
||||
{dir.path}
|
||||
</span>
|
||||
<span className="rounded-full border border-helios-line/20 px-2 py-0.5 text-xs font-bold text-helios-slate">
|
||||
{dir.is_recursive ? "Recursive" : "Top level"}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setPendingRemoveId(dir.id)}
|
||||
className="p-2 text-helios-slate hover:text-red-500 hover:bg-red-500/10 rounded-lg transition-all opacity-0 group-hover:opacity-100"
|
||||
title="Stop watching"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center">
|
||||
<select
|
||||
value={dir.profile_id === null ? "" : String(dir.profile_id)}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value;
|
||||
void assignProfile(
|
||||
dir.id,
|
||||
value === "" ? null : Number(value)
|
||||
);
|
||||
}}
|
||||
disabled={assigningDirId === dir.id}
|
||||
className="w-full rounded-lg border border-helios-line/20 bg-helios-surface-soft px-4 py-2.5 text-sm text-helios-ink outline-none focus:border-helios-solar disabled:opacity-60"
|
||||
>
|
||||
<option value="">No profile (use global settings)</option>
|
||||
{builtinProfiles.map((profile) => (
|
||||
<option key={profile.id} value={profile.id}>
|
||||
{profile.name}
|
||||
</option>
|
||||
))}
|
||||
{customProfiles.length > 0 ? (
|
||||
<option value="divider" disabled>
|
||||
──────────
|
||||
</option>
|
||||
) : null}
|
||||
{customProfiles.map((profile) => (
|
||||
<option key={profile.id} value={profile.id}>
|
||||
{profile.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openCustomizeModal(dir)}
|
||||
className="inline-flex items-center justify-center rounded-lg border border-helios-line/20 bg-helios-surface px-3 py-2 text-helios-slate hover:text-helios-ink hover:bg-helios-surface-soft"
|
||||
title="Customize profile"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!loading && dirs.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-10 text-center border-2 border-dashed border-helios-line/10 rounded-lg bg-helios-surface/30">
|
||||
<FolderOpen className="text-helios-slate/20 mb-2" size={32} />
|
||||
<p className="text-sm text-helios-slate">No watch folders configured</p>
|
||||
<p className="text-xs text-helios-slate/60 mt-1">Add a directory to start scanning</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="text-center py-8 text-helios-slate animate-pulse text-sm">
|
||||
Loading directories...
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<input
|
||||
type="text"
|
||||
value={dirInput}
|
||||
onChange={(e) => setDirInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void addDirectory(dirInput);
|
||||
}
|
||||
}}
|
||||
placeholder="/path/to/media"
|
||||
className="flex-1 rounded-lg border border-helios-line/40 bg-helios-surface px-4 py-2.5 font-mono text-sm text-helios-ink outline-none transition-colors focus:border-helios-solar"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickerOpen(true)}
|
||||
className="rounded-lg border border-helios-line/30 bg-helios-surface px-4 py-2.5 text-sm font-medium text-helios-slate transition-colors hover:border-helios-solar/40 hover:text-helios-ink"
|
||||
>
|
||||
Browse
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void addDirectory(dirInput)}
|
||||
disabled={!dirInput.trim()}
|
||||
className="rounded-lg bg-helios-solar px-4 py-2.5 text-sm font-semibold text-helios-main transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-8 text-helios-slate animate-pulse text-sm">
|
||||
Loading folders...
|
||||
</div>
|
||||
) : dirs.length > 0 ? (
|
||||
<div className="overflow-hidden rounded-lg border border-helios-line/30 bg-helios-surface">
|
||||
{dirs.map((dir, index) => (
|
||||
<div
|
||||
key={dir.path}
|
||||
className={`flex flex-col gap-3 px-4 py-3 ${
|
||||
index < dirs.length - 1 ? "border-b border-helios-line/10" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<p
|
||||
className="min-w-0 flex-1 break-all font-mono text-sm text-helios-slate"
|
||||
title={dir.path}
|
||||
>
|
||||
{dir.path}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPendingRemovePath(dir.path)}
|
||||
className="shrink-0 rounded-lg p-1.5 text-helios-slate transition-colors hover:text-status-error"
|
||||
aria-label={`Remove ${dir.path}`}
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center">
|
||||
<select
|
||||
value={dir.profile_id === null ? "" : String(dir.profile_id)}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value;
|
||||
void assignProfile(
|
||||
dir.id,
|
||||
value === "" ? null : Number(value)
|
||||
);
|
||||
}}
|
||||
disabled={assigningDirId === dir.id || dir.id < 0}
|
||||
className="w-full rounded-lg border border-helios-line/20 bg-helios-surface-soft px-4 py-2 text-sm text-helios-ink outline-none focus:border-helios-solar disabled:opacity-60"
|
||||
>
|
||||
<option value="">No profile (use global settings)</option>
|
||||
{builtinProfiles.map((profile) => (
|
||||
<option key={profile.id} value={profile.id}>
|
||||
{profile.name}
|
||||
</option>
|
||||
))}
|
||||
{customProfiles.length > 0 ? (
|
||||
<option value="divider" disabled>
|
||||
──────────
|
||||
</option>
|
||||
) : null}
|
||||
{customProfiles.map((profile) => (
|
||||
<option key={profile.id} value={profile.id}>
|
||||
{profile.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openCustomizeModal(dir)}
|
||||
disabled={dir.id < 0}
|
||||
className="inline-flex items-center justify-center rounded-lg border border-helios-line/20 bg-helios-surface px-3 py-2 text-helios-slate hover:text-helios-ink hover:bg-helios-surface-soft disabled:opacity-50"
|
||||
title="Customize profile"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-sm text-helios-slate/60">No folders added yet</p>
|
||||
<p className="mt-1 text-sm text-helios-slate/60">
|
||||
Add a folder above or browse the server filesystem
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={pendingRemoveId !== null}
|
||||
title="Stop watching folder"
|
||||
description="Stop watching this folder for new media?"
|
||||
confirmLabel="Stop Watching"
|
||||
open={pendingRemovePath !== null}
|
||||
title="Remove folder"
|
||||
description={`Stop watching ${pendingRemovePath} for new media?`}
|
||||
confirmLabel="Remove"
|
||||
tone="danger"
|
||||
onClose={() => setPendingRemoveId(null)}
|
||||
onClose={() => setPendingRemovePath(null)}
|
||||
onConfirm={async () => {
|
||||
if (pendingRemoveId === null) return;
|
||||
await removeDir(pendingRemoveId);
|
||||
if (pendingRemovePath === null) return;
|
||||
await removeDirectory(pendingRemovePath);
|
||||
setPendingRemovePath(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -661,21 +656,13 @@ export default function WatchFolders() {
|
||||
) : null}
|
||||
|
||||
<ServerDirectoryPicker
|
||||
open={pickerOpen !== null}
|
||||
title={pickerOpen === "library" ? "Select Library Root" : "Select Extra Watch Folder"}
|
||||
description={
|
||||
pickerOpen === "library"
|
||||
? "Choose a canonical server folder that represents a media library root."
|
||||
: "Choose an additional server folder to watch outside the canonical library roots."
|
||||
}
|
||||
onClose={() => setPickerOpen(null)}
|
||||
open={pickerOpen}
|
||||
title="Select Folder"
|
||||
description="Choose a directory for Alchemist to scan and watch for new media."
|
||||
onClose={() => setPickerOpen(false)}
|
||||
onSelect={(selectedPath) => {
|
||||
if (pickerOpen === "library") {
|
||||
setLibraryPath(selectedPath);
|
||||
} else {
|
||||
setPath(selectedPath);
|
||||
}
|
||||
setPickerOpen(null);
|
||||
setDirInput(selectedPath);
|
||||
setPickerOpen(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { FolderOpen, FolderSearch, Plus, X } from "lucide-react";
|
||||
import { ChevronLeft, ChevronRight, Folder, FolderOpen, X } from "lucide-react";
|
||||
import { apiJson, isApiError } from "../../lib/api";
|
||||
import ServerDirectoryPicker from "../ui/ServerDirectoryPicker";
|
||||
import type { FsPreviewResponse, FsRecommendation, StepValidator } from "./types";
|
||||
|
||||
interface LibraryStepProps {
|
||||
@@ -15,16 +14,38 @@ interface LibraryStepProps {
|
||||
registerValidator: (validator: StepValidator) => void;
|
||||
}
|
||||
|
||||
interface FsBreadcrumb {
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface FsDirEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
readable: boolean;
|
||||
}
|
||||
|
||||
interface FsBrowseResponse {
|
||||
path: string;
|
||||
readable: boolean;
|
||||
breadcrumbs: FsBreadcrumb[];
|
||||
warnings: string[];
|
||||
entries: FsDirEntry[];
|
||||
}
|
||||
|
||||
export default function LibraryStep({
|
||||
dirInput,
|
||||
directories,
|
||||
recommendations,
|
||||
recommendations: _recommendations,
|
||||
onDirInputChange,
|
||||
onDirectoriesChange,
|
||||
onPreviewChange,
|
||||
registerValidator,
|
||||
}: LibraryStepProps) {
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [browse, setBrowse] = useState<FsBrowseResponse | null>(null);
|
||||
const [browseError, setBrowseError] = useState("");
|
||||
const [browseLoading, setBrowseLoading] = useState(false);
|
||||
|
||||
const previewFailureMessage = (err: unknown) =>
|
||||
isApiError(err)
|
||||
@@ -55,17 +76,6 @@ export default function LibraryStep({
|
||||
if (directories.length === 0) {
|
||||
return "Select at least one server folder before continuing.";
|
||||
}
|
||||
let nextPreview: FsPreviewResponse | null;
|
||||
try {
|
||||
nextPreview = await fetchPreview();
|
||||
} catch (err) {
|
||||
return err instanceof Error
|
||||
? err.message
|
||||
: "Failed to preview the selected folders. Double-check the path and that the Alchemist server can read it.";
|
||||
}
|
||||
if (nextPreview && nextPreview.total_media_files === 0) {
|
||||
return "Preview did not find any supported media files yet. Double-check the chosen folders.";
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -88,205 +98,315 @@ export default function LibraryStep({
|
||||
onDirInputChange("");
|
||||
};
|
||||
|
||||
const loadBrowse = useCallback(async (path?: string) => {
|
||||
setBrowseLoading(true);
|
||||
setBrowseError("");
|
||||
try {
|
||||
const query = path ? `?path=${encodeURIComponent(path)}` : "";
|
||||
const data = await apiJson<FsBrowseResponse>(`/api/fs/browse${query}`);
|
||||
setBrowse(data);
|
||||
} catch (err) {
|
||||
setBrowse(null);
|
||||
setBrowseError(isApiError(err) ? err.message : "Failed to browse server folders.");
|
||||
} finally {
|
||||
setBrowseLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pickerOpen) {
|
||||
return;
|
||||
}
|
||||
void loadBrowse();
|
||||
}, [pickerOpen, loadBrowse]);
|
||||
|
||||
const removeDirectory = (path: string) => {
|
||||
onDirectoriesChange(directories.filter((directory) => directory !== path));
|
||||
};
|
||||
|
||||
const handleBrowseOpen = () => {
|
||||
setBrowse(null);
|
||||
setBrowseError("");
|
||||
setPickerOpen(true);
|
||||
};
|
||||
|
||||
const handleBrowseClose = () => {
|
||||
setPickerOpen(false);
|
||||
setBrowse(null);
|
||||
setBrowseError("");
|
||||
setBrowseLoading(false);
|
||||
};
|
||||
|
||||
const currentBrowsePath = browse?.path ?? "";
|
||||
const currentBrowseName =
|
||||
currentBrowsePath.split("/").filter(Boolean).pop() || currentBrowsePath || "root";
|
||||
const breadcrumbs = browse?.breadcrumbs ?? [];
|
||||
const parentBreadcrumb =
|
||||
breadcrumbs.length > 1 ? breadcrumbs[breadcrumbs.length - 2] : null;
|
||||
const visibleEntries = browse?.entries.filter((entry) => entry.readable) ?? [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<motion.div
|
||||
key="library"
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* Step heading */}
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-xl font-semibold text-helios-ink
|
||||
flex items-center gap-2">
|
||||
<FolderOpen size={20}
|
||||
className="text-helios-solar" />
|
||||
Library Selection
|
||||
</h2>
|
||||
<p className="text-sm text-helios-slate">
|
||||
Choose the server folders Alchemist should scan
|
||||
and keep watching.
|
||||
</p>
|
||||
</div>
|
||||
<motion.div
|
||||
key="library"
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<h2 className="flex items-center gap-2 text-xl font-semibold text-helios-ink">
|
||||
<FolderOpen size={20} className="text-helios-solar" />
|
||||
Library Selection
|
||||
</h2>
|
||||
<p className="text-sm text-helios-slate">
|
||||
Choose folders Alchemist should scan and watch for new media.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Recommendations — shown when server returns any */}
|
||||
{recommendations.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium
|
||||
text-helios-slate">
|
||||
Suggested folders
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{recommendations.map((rec) => {
|
||||
const alreadyAdded =
|
||||
directories.includes(rec.path);
|
||||
return (
|
||||
<button
|
||||
key={rec.path}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
!alreadyAdded &&
|
||||
addDirectory(rec.path)
|
||||
}
|
||||
disabled={alreadyAdded}
|
||||
className={`w-full flex items-center
|
||||
justify-between gap-4 rounded-lg
|
||||
border px-4 py-3 text-left
|
||||
transition-all ${
|
||||
alreadyAdded
|
||||
? "border-helios-solar/30 bg-helios-solar/5 cursor-default"
|
||||
: "border-helios-line/30 bg-helios-surface hover:border-helios-solar/40 hover:bg-helios-surface-soft"
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium
|
||||
text-helios-ink truncate">
|
||||
{rec.label}
|
||||
</p>
|
||||
<p className="text-xs font-mono
|
||||
text-helios-slate/70 truncate
|
||||
mt-0.5"
|
||||
title={rec.path}>
|
||||
{rec.path}
|
||||
</p>
|
||||
</div>
|
||||
{alreadyAdded ? (
|
||||
<span className="text-xs
|
||||
text-helios-solar shrink-0
|
||||
font-medium">
|
||||
Added
|
||||
</span>
|
||||
) : (
|
||||
<Plus size={15}
|
||||
className="text-helios-solar/60
|
||||
shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Empty state — no recommendations */
|
||||
<div className="rounded-lg border border-helios-line/20
|
||||
bg-helios-surface-soft/40 px-5 py-8 text-center
|
||||
space-y-3">
|
||||
<p className="text-sm text-helios-slate">
|
||||
No media folders were auto-detected on this
|
||||
server.
|
||||
</p>
|
||||
<p className="text-xs text-helios-slate/60">
|
||||
Use Browse below to navigate the server
|
||||
filesystem manually.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selected folders as chips */}
|
||||
{directories.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-helios-slate">
|
||||
Selected ({directories.length})
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{directories.map((dir) => (
|
||||
<div
|
||||
key={dir}
|
||||
className="flex items-center gap-2
|
||||
rounded-lg border border-helios-solar/30
|
||||
bg-helios-solar/5 pl-3 pr-2 py-1.5"
|
||||
>
|
||||
<span className="font-mono text-xs
|
||||
text-helios-ink truncate max-w-[300px]"
|
||||
title={dir}>
|
||||
{dir.split("/").pop() || dir}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onDirectoriesChange(
|
||||
directories.filter(
|
||||
(d) => d !== dir
|
||||
)
|
||||
)
|
||||
}
|
||||
className="text-helios-slate/50
|
||||
hover:text-status-error
|
||||
transition-colors shrink-0"
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Browse button */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<input
|
||||
type="text"
|
||||
value={dirInput}
|
||||
onChange={(e) => onDirInputChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
addDirectory(dirInput);
|
||||
}
|
||||
}}
|
||||
placeholder="/path/to/media"
|
||||
className="flex-1 rounded-lg border border-helios-line/40 bg-helios-surface px-4 py-2.5 font-mono text-sm text-helios-ink outline-none transition-colors focus:border-helios-solar"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickerOpen(true)}
|
||||
className="w-full flex items-center justify-center
|
||||
gap-2 rounded-lg border border-helios-line/30
|
||||
bg-helios-surface py-3 text-sm font-medium
|
||||
text-helios-slate hover:border-helios-solar/40
|
||||
hover:text-helios-ink transition-colors"
|
||||
onClick={handleBrowseOpen}
|
||||
className="rounded-lg border border-helios-line/30 bg-helios-surface px-4 py-2.5 text-sm font-medium text-helios-slate transition-colors hover:border-helios-solar/40 hover:text-helios-ink"
|
||||
>
|
||||
<FolderSearch size={15} />
|
||||
Browse server folders
|
||||
Browse
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addDirectory(dirInput)}
|
||||
className="rounded-lg bg-helios-solar px-4 py-2.5 text-sm font-semibold text-helios-main transition-opacity hover:opacity-90"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Manual path input */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium
|
||||
text-helios-slate">
|
||||
Or paste a path directly
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={dirInput}
|
||||
onChange={(e) => onDirInputChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
addDirectory(dirInput);
|
||||
}
|
||||
}}
|
||||
placeholder="/path/to/media"
|
||||
className="flex-1 rounded-lg border
|
||||
border-helios-line/40 bg-helios-surface
|
||||
px-4 py-2.5 font-mono text-sm
|
||||
text-helios-ink focus:border-helios-solar
|
||||
outline-none"
|
||||
/>
|
||||
{pickerOpen ? (
|
||||
<div className="flex h-[min(28rem,calc(100dvh-20rem))] min-h-0 flex-col gap-4 overflow-hidden rounded-lg border border-helios-line/30 bg-helios-surface p-4">
|
||||
<div className="shrink-0 flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 space-y-3">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium uppercase tracking-[0.12em] text-helios-slate/70">
|
||||
Server Filesystem
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Folder size={16} className="shrink-0 text-helios-solar" />
|
||||
<p className="truncate text-sm font-medium text-helios-ink">
|
||||
{currentBrowseName}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
parentBreadcrumb
|
||||
? void loadBrowse(parentBreadcrumb.path)
|
||||
: void loadBrowse()
|
||||
}
|
||||
disabled={browseLoading || !browse || !parentBreadcrumb}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-helios-line/30 px-3 py-1.5 text-sm text-helios-slate transition-colors hover:border-helios-solar/40 hover:text-helios-ink disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<ChevronLeft size={15} />
|
||||
Up
|
||||
</button>
|
||||
|
||||
<div className="min-w-0 flex-1 overflow-x-auto">
|
||||
<div className="flex min-w-max items-center gap-1.5 text-sm text-helios-slate">
|
||||
{breadcrumbs.length > 0 ? (
|
||||
breadcrumbs.map((crumb, index) => {
|
||||
const isCurrent = crumb.path === currentBrowsePath;
|
||||
return (
|
||||
<div
|
||||
key={crumb.path}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
{index > 0 && (
|
||||
<span className="text-helios-slate/50">/</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadBrowse(crumb.path)}
|
||||
className={
|
||||
isCurrent
|
||||
? "rounded-lg bg-helios-solar/10 px-2 py-1 font-medium text-helios-ink"
|
||||
: "rounded-lg px-2 py-1 transition-colors hover:bg-helios-surface-soft hover:text-helios-ink"
|
||||
}
|
||||
>
|
||||
{crumb.name}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<span className="rounded-lg bg-helios-solar/10 px-2 py-1 font-medium text-helios-ink">
|
||||
/
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addDirectory(dirInput)}
|
||||
className="rounded-lg bg-helios-solar px-4
|
||||
py-2.5 text-sm font-semibold
|
||||
text-helios-main hover:opacity-90
|
||||
transition-opacity"
|
||||
onClick={handleBrowseClose}
|
||||
className="shrink-0 rounded-lg border border-helios-line/30 px-3 py-1.5 text-sm text-helios-slate transition-colors hover:border-helios-solar/40 hover:text-helios-ink"
|
||||
aria-label="Close folder browser"
|
||||
>
|
||||
Add
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain rounded-lg border border-helios-line/20 bg-helios-surface-soft/30">
|
||||
{browse?.warnings.length ? (
|
||||
<div className="border-b border-helios-line/10 px-4 py-3">
|
||||
{browse.warnings.map((warning) => (
|
||||
<p
|
||||
key={warning}
|
||||
className="text-xs text-helios-slate"
|
||||
>
|
||||
{warning}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{browseLoading ? (
|
||||
<div className="animate-pulse space-y-3 p-4">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-3 rounded-lg border border-helios-line/10 bg-helios-surface px-4 py-3"
|
||||
>
|
||||
<div className="h-4 w-4 rounded bg-helios-line/20" />
|
||||
<div className="h-3 flex-1 rounded bg-helios-line/20" />
|
||||
<div className="h-3 w-3 rounded bg-helios-line/20" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : browseError ? (
|
||||
<div className="px-4 py-6 text-sm text-status-error">{browseError}</div>
|
||||
) : visibleEntries.length === 0 ? (
|
||||
<div className="px-4 py-6 text-sm text-helios-slate">
|
||||
No readable child folders were found here.
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-helios-line/10">
|
||||
{parentBreadcrumb ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadBrowse(parentBreadcrumb.path)}
|
||||
className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-helios-surface/70"
|
||||
>
|
||||
<ChevronLeft size={16} className="shrink-0 text-helios-slate" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-helios-ink">
|
||||
..
|
||||
</span>
|
||||
<span className="block truncate text-xs text-helios-slate">
|
||||
Go up to {parentBreadcrumb.name}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{visibleEntries.map((entry) => (
|
||||
<button
|
||||
key={entry.path}
|
||||
type="button"
|
||||
onClick={() => void loadBrowse(entry.path)}
|
||||
className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-helios-solar/5"
|
||||
>
|
||||
<Folder size={16} className="shrink-0 text-helios-slate" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm text-helios-ink">
|
||||
{entry.name}
|
||||
</span>
|
||||
<span className="block truncate font-mono text-xs text-helios-slate/80">
|
||||
{entry.path}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronRight
|
||||
size={16}
|
||||
className="shrink-0 text-helios-slate"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 flex flex-col gap-3 rounded-lg border border-helios-line/20 bg-helios-surface-soft/30 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium text-helios-slate/80">
|
||||
Current folder
|
||||
</p>
|
||||
<p className="min-w-0 break-all font-mono text-xs text-helios-slate">
|
||||
{currentBrowsePath || "/"}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!currentBrowsePath) {
|
||||
return;
|
||||
}
|
||||
addDirectory(currentBrowsePath);
|
||||
handleBrowseClose();
|
||||
}}
|
||||
disabled={!currentBrowsePath}
|
||||
className="shrink-0 rounded-lg bg-helios-solar px-4 py-2 text-sm font-semibold text-helios-main transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Add {currentBrowseName}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</motion.div>
|
||||
|
||||
<ServerDirectoryPicker
|
||||
open={pickerOpen}
|
||||
title="Browse Server Folders"
|
||||
description="Navigate the server filesystem and choose
|
||||
the folder Alchemist should treat as a media root."
|
||||
onClose={() => setPickerOpen(false)}
|
||||
onSelect={(path) => {
|
||||
addDirectory(path);
|
||||
setPickerOpen(false);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : directories.length > 0 ? (
|
||||
<div className="overflow-hidden rounded-lg border border-helios-line/30 bg-helios-surface">
|
||||
{directories.map((dir, index) => (
|
||||
<div
|
||||
key={dir}
|
||||
className={`flex items-start gap-4 px-4 py-3 ${
|
||||
index < directories.length - 1 ? "border-b border-helios-line/10" : ""
|
||||
}`}
|
||||
>
|
||||
<p
|
||||
className="min-w-0 flex-1 break-all font-mono text-sm text-helios-slate"
|
||||
title={dir}
|
||||
>
|
||||
{dir}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeDirectory(dir)}
|
||||
className="shrink-0 rounded-lg p-1.5 text-helios-slate transition-colors hover:text-status-error"
|
||||
aria-label={`Remove ${dir}`}
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-sm text-helios-slate/60">No folders added yet</p>
|
||||
<p className="mt-1 text-sm text-helios-slate/60">
|
||||
Add a folder above or browse the server filesystem
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ export default function SetupFrame({ step, configMutable, error, submitting, onB
|
||||
|
||||
{/* Step content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-4xl mx-auto px-6 py-8">
|
||||
<div className="max-w-6xl mx-auto px-6 py-8">
|
||||
<AnimatePresence mode="wait">
|
||||
{children}
|
||||
</AnimatePresence>
|
||||
@@ -60,7 +60,7 @@ export default function SetupFrame({ step, configMutable, error, submitting, onB
|
||||
{step < 6 && (
|
||||
<div className="shrink-0 border-t border-helios-line/20
|
||||
bg-helios-surface/50 px-6 py-4">
|
||||
<div className="max-w-4xl mx-auto flex items-center
|
||||
<div className="max-w-6xl mx-auto flex items-center
|
||||
justify-between gap-4">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -117,7 +117,7 @@ export default function ServerDirectoryPicker({
|
||||
/>
|
||||
|
||||
<div className="absolute inset-0 flex items-center justify-center px-4 py-6">
|
||||
<div className="w-full max-w-5xl rounded-xl border border-helios-line/30 bg-helios-surface shadow-2xl overflow-hidden">
|
||||
<div className="w-full max-w-5xl rounded-xl border border-helios-line/30 bg-helios-surface shadow-2xl overflow-hidden flex flex-col max-h-[min(90vh,800px)]">
|
||||
<div className="border-b border-helios-line/20 px-6 py-5 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -142,7 +142,7 @@ export default function ServerDirectoryPicker({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[320px_1fr] min-h-[620px]">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[320px_1fr] flex-1 min-h-0 overflow-hidden">
|
||||
<aside className="border-r border-helios-line/20 bg-helios-surface-soft/40 px-5 py-5 space-y-5">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-helios-slate">
|
||||
@@ -195,7 +195,7 @@ export default function ServerDirectoryPicker({
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="px-6 py-5 flex flex-col">
|
||||
<section className="px-6 py-5 flex flex-col overflow-y-auto min-h-0">
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-500">
|
||||
{error}
|
||||
@@ -259,7 +259,7 @@ export default function ServerDirectoryPicker({
|
||||
|
||||
<div className="flex-1 overflow-y-auto rounded-lg border border-helios-line/20 bg-helios-surface-soft/30">
|
||||
{browse.entries.length === 0 ? (
|
||||
<div className="flex h-full min-h-[260px] items-center justify-center px-6 text-sm text-helios-slate">
|
||||
<div className="flex h-full min-h-[120px] items-center justify-center px-6 text-sm text-helios-slate">
|
||||
No child directories were found here.
|
||||
</div>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user