initial commit

This commit is contained in:
Pierre
2026-01-11 17:26:07 +01:00
commit ff4fbf136f
49 changed files with 13118 additions and 0 deletions
+229
View File
@@ -0,0 +1,229 @@
import { useState, useEffect, useCallback } from 'react';
import { RulesEditor } from './components/RulesEditor';
interface Stats {
fileCount: number;
directoryCount: number;
totalSize: number;
lastRefresh: string | null;
redisConnected: boolean;
}
interface HealthStatus {
status: string;
timestamp: string;
service: string;
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
function formatTime(isoString: string | null): string {
if (!isoString) return 'Never';
const date = new Date(isoString);
return date.toLocaleTimeString();
}
function App() {
const [stats, setStats] = useState<Stats | null>(null);
const [health, setHealth] = useState<HealthStatus | null>(null);
const [directories, setDirectories] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [refreshing, setRefreshing] = useState(false);
const [showRules, setShowRules] = useState(false);
const fetchData = useCallback(async () => {
try {
const [statsRes, healthRes, dirsRes] = await Promise.all([
fetch('/api/fuse/stats'),
fetch('/api/fuse/health'),
fetch('/api/fuse/directories'),
]);
if (!statsRes.ok || !healthRes.ok) {
throw new Error('Failed to fetch data');
}
const statsData = await statsRes.json();
const healthData = await healthRes.json();
const dirsData = await dirsRes.json();
setStats(statsData);
setHealth(healthData);
setDirectories(dirsData.directories || []);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
}, []);
const handleRefresh = async () => {
setRefreshing(true);
try {
await fetch('/api/fuse/refresh', { method: 'POST' });
await fetchData();
} catch (err) {
setError(err instanceof Error ? err.message : 'Refresh failed');
} finally {
setRefreshing(false);
}
};
useEffect(() => {
fetchData();
const dataInterval = setInterval(fetchData, 5000);
return () => {
clearInterval(dataInterval);
};
}, [fetchData]);
if (loading) {
return (
<div className="container">
<div className="loading">Loading...</div>
</div>
);
}
const isConnected = health?.status === 'ok' && stats?.redisConnected;
return (
<div className="container">
<header className="header">
<div>
<h1>meta-fuse</h1>
<p className="header-subtitle">Virtual filesystem service</p>
</div>
<div className={`status-badge ${isConnected ? 'connected' : 'disconnected'}`}>
<span className={`status-dot ${isConnected ? 'connected' : 'disconnected'}`} />
{isConnected ? 'Connected' : 'Disconnected'}
</div>
</header>
{error && <div className="error">{error}</div>}
<div className="section">
<h2 className="section-title">VFS Control</h2>
<div className="grid">
<div className="card">
<div className="card-header">
<span className="card-title">Files</span>
</div>
<div className="card-value">{stats?.fileCount ?? 0}</div>
<div className="card-subtitle">Total files in VFS</div>
</div>
<div className="card">
<div className="card-header">
<span className="card-title">Directories</span>
</div>
<div className="card-value">{stats?.directoryCount ?? 0}</div>
<div className="card-subtitle">Total directories</div>
</div>
<div className="card">
<div className="card-header">
<span className="card-title">Total Size</span>
</div>
<div className="card-value">{formatBytes(stats?.totalSize ?? 0)}</div>
<div className="card-subtitle">Combined file size</div>
</div>
<div className="card">
<div className="card-header">
<span className="card-title">Last Refresh</span>
<button
className="refresh-btn"
onClick={handleRefresh}
disabled={refreshing}
>
{refreshing ? 'Refreshing...' : 'Refresh'}
</button>
</div>
<div className="card-value" style={{ fontSize: '1.5rem' }}>
{formatTime(stats?.lastRefresh ?? null)}
</div>
<div className="card-subtitle">From Redis metadata</div>
</div>
</div>
<div className="card" style={{ marginTop: '1.5rem' }}>
<div className="card-header">
<span className="card-title">Connection Status</span>
</div>
<div className="info-grid">
<div className="info-item">
<div className="info-label">API Status</div>
<div className="info-value">{health?.status ?? 'unknown'}</div>
</div>
<div className="info-item">
<div className="info-label">Redis</div>
<div className="info-value">
{stats?.redisConnected ? 'Connected' : 'Disconnected'}
</div>
</div>
<div className="info-item">
<div className="info-label">Service</div>
<div className="info-value">{health?.service ?? 'unknown'}</div>
</div>
<div className="info-item">
<div className="info-label">Timestamp</div>
<div className="info-value">{formatTime(health?.timestamp ?? null)}</div>
</div>
</div>
</div>
<div className="subsection">
<div
className="section-header-collapsible"
onClick={() => setShowRules(!showRules)}
>
<h3 className="subsection-title">Renaming Rules</h3>
<span className="collapse-icon">{showRules ? '-' : '+'}</span>
</div>
{showRules && (
<RulesEditor onSave={fetchData} />
)}
</div>
</div>
<div className="section">
<h2 className="section-title">Virtual Directories</h2>
<div className="file-list">
{directories.length === 0 ? (
<div className="file-item">
<span className="file-name" style={{ color: '#64748b' }}>
No directories found. Waiting for metadata from meta-sort...
</span>
</div>
) : (
directories.slice(0, 20).map((dir) => (
<div key={dir} className="file-item">
<span className="file-icon">📁</span>
<span className="file-name">{dir}</span>
</div>
))
)}
{directories.length > 20 && (
<div className="file-item">
<span className="file-name" style={{ color: '#64748b' }}>
... and {directories.length - 20} more directories
</span>
</div>
)}
</div>
</div>
</div>
);
}
export default App;
@@ -0,0 +1,626 @@
import { useState, useEffect, useCallback } from 'react';
import type {
RenamingConfig,
RenamingRule,
ConditionGroup,
Condition,
PreviewItem,
TemplateVariable,
} from '../types/rules';
import { isConditionGroup, generateId } from '../types/rules';
interface RulesEditorProps {
onSave?: () => void;
}
// Available metadata fields for conditions
const AVAILABLE_FIELDS = [
{ value: 'fileType', label: 'File Type', type: 'string' },
{ value: 'titles.eng', label: 'English Title', type: 'string' },
{ value: 'originalTitle', label: 'Original Title', type: 'string' },
{ value: 'season', label: 'Season', type: 'number' },
{ value: 'episode', label: 'Episode', type: 'number' },
{ value: 'extra', label: 'Is Extra', type: 'boolean' },
{ value: 'movieYear', label: 'Movie Year', type: 'number' },
{ value: 'year', label: 'Year', type: 'number' },
{ value: 'extension', label: 'Extension', type: 'string' },
{ value: 'version', label: 'Version', type: 'string' },
{ value: 'subtitleLanguage', label: 'Subtitle Language', type: 'string' },
];
const CONDITION_TYPES = [
{ value: 'EXISTS', label: 'exists', needsValue: false },
{ value: 'NOT_EXISTS', label: 'does not exist', needsValue: false },
{ value: 'EQUALS', label: 'equals', needsValue: true },
{ value: 'NOT_EQUALS', label: 'does not equal', needsValue: true },
{ value: 'CONTAINS', label: 'contains', needsValue: true },
{ value: 'MATCHES', label: 'matches regex', needsValue: true },
];
function createEmptyRule(): RenamingRule {
return {
id: generateId(),
name: 'New Rule',
description: '',
enabled: true,
priority: 50,
conditions: { operator: 'AND', conditions: [] },
template: '',
fallbackToUnsorted: true,
};
}
function createEmptyCondition(): Condition {
return {
type: 'EXISTS',
field: 'titles.eng',
};
}
// ConditionRow component
interface ConditionRowProps {
condition: Condition;
onChange: (condition: Condition) => void;
onDelete: () => void;
}
function ConditionRow({ condition, onChange, onDelete }: ConditionRowProps) {
const conditionType = CONDITION_TYPES.find(t => t.value === condition.type);
const needsValue = conditionType?.needsValue ?? false;
return (
<div className="condition-row">
<select
value={condition.field}
onChange={e => onChange({ ...condition, field: e.target.value })}
className="condition-field"
>
{AVAILABLE_FIELDS.map(f => (
<option key={f.value} value={f.value}>{f.label}</option>
))}
</select>
<select
value={condition.type}
onChange={e => onChange({ ...condition, type: e.target.value as Condition['type'] })}
className="condition-type"
>
{CONDITION_TYPES.map(t => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
{needsValue && (
<input
type="text"
value={String(condition.value ?? '')}
onChange={e => onChange({ ...condition, value: e.target.value })}
placeholder="Value"
className="condition-value"
/>
)}
<button onClick={onDelete} className="btn-icon btn-delete" title="Remove condition">
x
</button>
</div>
);
}
// ConditionBuilder component
interface ConditionBuilderProps {
conditions: ConditionGroup;
onChange: (conditions: ConditionGroup) => void;
}
function ConditionBuilder({ conditions, onChange }: ConditionBuilderProps) {
const addCondition = () => {
onChange({
...conditions,
conditions: [...conditions.conditions, createEmptyCondition()],
});
};
const updateCondition = (index: number, updated: Condition | ConditionGroup) => {
const newConditions = [...conditions.conditions];
newConditions[index] = updated;
onChange({ ...conditions, conditions: newConditions });
};
const deleteCondition = (index: number) => {
const newConditions = conditions.conditions.filter((_, i) => i !== index);
onChange({ ...conditions, conditions: newConditions });
};
return (
<div className="condition-builder">
<div className="condition-operator">
<label>Match</label>
<select
value={conditions.operator}
onChange={e => onChange({ ...conditions, operator: e.target.value as 'AND' | 'OR' })}
>
<option value="AND">ALL conditions (AND)</option>
<option value="OR">ANY condition (OR)</option>
</select>
</div>
<div className="condition-list">
{conditions.conditions.length === 0 && (
<div className="condition-empty">No conditions - rule will match all files</div>
)}
{conditions.conditions.map((cond, index) => (
!isConditionGroup(cond) && (
<ConditionRow
key={index}
condition={cond}
onChange={updated => updateCondition(index, updated)}
onDelete={() => deleteCondition(index)}
/>
)
))}
</div>
<button onClick={addCondition} className="btn-add-condition">
+ Add Condition
</button>
</div>
);
}
// TemplateEditor component
interface TemplateEditorProps {
template: string;
onChange: (template: string) => void;
variables: TemplateVariable[];
}
function TemplateEditor({ template, onChange, variables }: TemplateEditorProps) {
const [showVars, setShowVars] = useState(false);
const insertVariable = (path: string) => {
onChange(template + `{${path}}`);
};
return (
<div className="template-editor">
<div className="template-header">
<label>Path Template</label>
<button
onClick={() => setShowVars(!showVars)}
className="btn-small"
>
{showVars ? 'Hide Variables' : 'Show Variables'}
</button>
</div>
<textarea
value={template}
onChange={e => onChange(e.target.value)}
placeholder="e.g., Movies/{titles.eng|originalTitle}{movieYear? ({movieYear})}/{fileName}.{extension}"
rows={3}
className="template-input"
/>
{showVars && (
<div className="variable-palette">
{variables.map(v => (
<button
key={v.path}
onClick={() => insertVariable(v.path)}
className="variable-chip"
title={`${v.description} (${v.type})`}
>
{v.name}
</button>
))}
</div>
)}
<div className="template-help">
<span><code>{'{var}'}</code> basic</span>
<span><code>{'{var:pad2}'}</code> zero-pad</span>
<span><code>{'{var?}'}</code> optional</span>
<span><code>{'{var?(text)}'}</code> conditional</span>
<span><code>{'{var|default}'}</code> default</span>
</div>
</div>
);
}
// RuleCard component
interface RuleCardProps {
rule: RenamingRule;
variables: TemplateVariable[];
expanded: boolean;
onToggle: () => void;
onUpdate: (rule: RenamingRule) => void;
onDelete: () => void;
onMoveUp: () => void;
onMoveDown: () => void;
isFirst: boolean;
isLast: boolean;
}
function RuleCard({
rule,
variables,
expanded,
onToggle,
onUpdate,
onDelete,
onMoveUp,
onMoveDown,
isFirst,
isLast,
}: RuleCardProps) {
const conditionCount = rule.conditions.conditions.length;
return (
<div className={`rule-card ${expanded ? 'expanded' : ''} ${!rule.enabled ? 'disabled' : ''}`}>
<div className="rule-header" onClick={onToggle}>
<div className="rule-header-left">
<input
type="checkbox"
checked={rule.enabled}
onChange={e => {
e.stopPropagation();
onUpdate({ ...rule, enabled: e.target.checked });
}}
title="Enable/disable rule"
/>
<span className="rule-name">{rule.name}</span>
<span className="rule-meta">
Priority: {rule.priority} | {conditionCount} condition{conditionCount !== 1 ? 's' : ''}
</span>
</div>
<div className="rule-header-right">
<button
onClick={e => { e.stopPropagation(); onMoveUp(); }}
disabled={isFirst}
className="btn-icon"
title="Move up"
>
^
</button>
<button
onClick={e => { e.stopPropagation(); onMoveDown(); }}
disabled={isLast}
className="btn-icon"
title="Move down"
>
v
</button>
<button
onClick={e => { e.stopPropagation(); onDelete(); }}
className="btn-icon btn-delete"
title="Delete rule"
>
x
</button>
<span className="expand-icon">{expanded ? '-' : '+'}</span>
</div>
</div>
{expanded && (
<div className="rule-body">
<div className="rule-field">
<label>Name</label>
<input
type="text"
value={rule.name}
onChange={e => onUpdate({ ...rule, name: e.target.value })}
/>
</div>
<div className="rule-field">
<label>Priority (higher = evaluated first)</label>
<input
type="number"
value={rule.priority}
onChange={e => onUpdate({ ...rule, priority: parseInt(e.target.value, 10) || 0 })}
min={0}
max={1000}
/>
</div>
<div className="rule-field">
<label>Conditions</label>
<ConditionBuilder
conditions={rule.conditions}
onChange={conditions => onUpdate({ ...rule, conditions })}
/>
</div>
<div className="rule-field">
<TemplateEditor
template={rule.template}
onChange={template => onUpdate({ ...rule, template })}
variables={variables}
/>
</div>
<div className="rule-field rule-field-inline">
<label>
<input
type="checkbox"
checked={rule.fallbackToUnsorted}
onChange={e => onUpdate({ ...rule, fallbackToUnsorted: e.target.checked })}
/>
Fall back to Unsorted folder if template fails
</label>
</div>
</div>
)}
</div>
);
}
// PreviewPanel component
interface PreviewPanelProps {
previews: PreviewItem[];
loading: boolean;
total: number;
limited: boolean;
}
function PreviewPanel({ previews, loading, total, limited }: PreviewPanelProps) {
if (loading) {
return <div className="preview-loading">Loading preview...</div>;
}
return (
<div className="preview-panel">
<div className="preview-header">
<h3>Preview</h3>
<span className="preview-count">
Showing {previews.length} of {total} files
{limited && ' (limited)'}
</span>
</div>
{previews.length === 0 ? (
<div className="preview-empty">No files to preview</div>
) : (
<div className="preview-table-wrapper">
<table className="preview-table">
<thead>
<tr>
<th>Source</th>
<th>Current Path</th>
<th>New Path</th>
<th>Rule</th>
</tr>
</thead>
<tbody>
{previews.map((p, i) => {
const changed = p.newVirtualPath !== p.currentVirtualPath;
return (
<tr key={i} className={p.error ? 'error' : changed ? 'changed' : ''}>
<td title={p.sourcePath}>{p.sourcePath.split('/').pop()}</td>
<td title={p.currentVirtualPath}>{truncatePath(p.currentVirtualPath)}</td>
<td className={changed ? 'highlight' : ''} title={p.newVirtualPath || ''}>
{p.error ? (
<span className="error-text">{p.error}</span>
) : (
truncatePath(p.newVirtualPath || '')
)}
</td>
<td>{p.matchedRule || '-'}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}
function truncatePath(path: string, maxLen = 40): string {
if (path.length <= maxLen) return path;
return '...' + path.slice(-maxLen + 3);
}
// Main RulesEditor component
export function RulesEditor({ onSave }: RulesEditorProps) {
const [config, setConfig] = useState<RenamingConfig | null>(null);
const [variables, setVariables] = useState<TemplateVariable[]>([]);
const [expandedRule, setExpandedRule] = useState<string | null>(null);
const [previews, setPreviews] = useState<PreviewItem[]>([]);
const [previewTotal, setPreviewTotal] = useState(0);
const [previewLimited, setPreviewLimited] = useState(false);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [previewLoading, setPreviewLoading] = useState(false);
const [dirty, setDirty] = useState(false);
const [error, setError] = useState<string | null>(null);
// Fetch rules and variables on mount
useEffect(() => {
Promise.all([
fetch('/api/fuse/rules').then(r => r.json()),
fetch('/api/fuse/rules/variables').then(r => r.json()),
])
.then(([rulesData, varsData]) => {
setConfig(rulesData.config);
setVariables(varsData.variables);
setLoading(false);
})
.catch(err => {
setError(err.message);
setLoading(false);
});
}, []);
// Fetch preview when config changes
const fetchPreview = useCallback(async () => {
if (!config) return;
setPreviewLoading(true);
try {
const res = await fetch('/api/fuse/rules/preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rules: config.rules, limit: 20 }),
});
const data = await res.json();
setPreviews(data.previews || []);
setPreviewTotal(data.total || 0);
setPreviewLimited(data.limited || false);
} catch (err) {
console.error('Failed to fetch preview:', err);
} finally {
setPreviewLoading(false);
}
}, [config]);
// Debounced preview fetch
useEffect(() => {
if (!dirty || !config) return;
const timeout = setTimeout(fetchPreview, 500);
return () => clearTimeout(timeout);
}, [config, dirty, fetchPreview]);
// Initial preview
useEffect(() => {
if (config && !dirty) {
fetchPreview();
}
}, [config, dirty, fetchPreview]);
const handleSave = async () => {
if (!config) return;
setSaving(true);
setError(null);
try {
const res = await fetch('/api/fuse/rules', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.errors?.join(', ') || data.error || 'Failed to save');
}
setDirty(false);
onSave?.();
// Refetch preview after save
await fetchPreview();
} catch (err: any) {
setError(err.message);
} finally {
setSaving(false);
}
};
const updateRules = (rules: RenamingRule[]) => {
if (!config) return;
setConfig({ ...config, rules });
setDirty(true);
};
const addRule = () => {
if (!config) return;
const newRule = createEmptyRule();
updateRules([...config.rules, newRule]);
setExpandedRule(newRule.id);
};
const updateRule = (id: string, updated: RenamingRule) => {
if (!config) return;
updateRules(config.rules.map(r => r.id === id ? updated : r));
};
const deleteRule = (id: string) => {
if (!config) return;
if (confirm('Are you sure you want to delete this rule?')) {
updateRules(config.rules.filter(r => r.id !== id));
if (expandedRule === id) setExpandedRule(null);
}
};
const moveRule = (index: number, direction: -1 | 1) => {
if (!config) return;
const newIndex = index + direction;
if (newIndex < 0 || newIndex >= config.rules.length) return;
const rules = [...config.rules];
[rules[index], rules[newIndex]] = [rules[newIndex], rules[index]];
updateRules(rules);
};
if (loading) {
return <div className="rules-loading">Loading rules configuration...</div>;
}
if (!config) {
return <div className="rules-error">Failed to load rules configuration</div>;
}
return (
<div className="rules-editor">
<div className="rules-header">
<h2>Renaming Rules</h2>
<div className="rules-actions">
{dirty && <span className="unsaved-badge">Unsaved changes</span>}
<button
onClick={handleSave}
disabled={!dirty || saving}
className={`btn-save ${dirty ? 'btn-primary' : ''}`}
>
{saving ? 'Saving...' : 'Save & Apply'}
</button>
</div>
</div>
{error && (
<div className="rules-error-message">{error}</div>
)}
<div className="rules-content">
<div className="rules-list">
{config.rules.length === 0 && (
<div className="rules-empty">
No rules configured. Click "Add Rule" to create one.
</div>
)}
{config.rules.map((rule, index) => (
<RuleCard
key={rule.id}
rule={rule}
variables={variables}
expanded={expandedRule === rule.id}
onToggle={() => setExpandedRule(expandedRule === rule.id ? null : rule.id)}
onUpdate={updated => updateRule(rule.id, updated)}
onDelete={() => deleteRule(rule.id)}
onMoveUp={() => moveRule(index, -1)}
onMoveDown={() => moveRule(index, 1)}
isFirst={index === 0}
isLast={index === config.rules.length - 1}
/>
))}
<button onClick={addRule} className="btn-add-rule">
+ Add Rule
</button>
</div>
<PreviewPanel
previews={previews}
loading={previewLoading}
total={previewTotal}
limited={previewLimited}
/>
</div>
</div>
);
}
export default RulesEditor;
+828
View File
@@ -0,0 +1,828 @@
:root {
--bg-primary: #0a0a0f;
--bg-secondary: #12121a;
--bg-tertiary: #1a1a2e;
--text-primary: #e0e0e0;
--text-secondary: #a0a0a0;
--accent-primary: #4ecdc4;
--accent-secondary: #ff6b6b;
--accent-warning: #ffd93d;
--border-color: #2a2a3e;
--success: #4ecdc4;
--error: #ff6b6b;
--warning: #ffd93d;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
}
a {
color: var(--accent-primary);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-color);
}
.header h1 {
font-size: 1.8rem;
font-weight: 600;
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.header-subtitle {
color: var(--text-secondary);
font-size: 0.9rem;
margin-top: 0.25rem;
}
.status-badge {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 500;
}
.status-badge.connected {
background: rgba(78, 205, 196, 0.15);
color: var(--success);
}
.status-badge.disconnected {
background: rgba(255, 107, 107, 0.15);
color: var(--error);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.status-dot.connected {
background: var(--success);
box-shadow: 0 0 8px var(--success);
}
.status-dot.disconnected {
background: var(--error);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.card {
background: var(--bg-secondary);
border-radius: 12px;
padding: 1.5rem;
border: 1px solid var(--border-color);
transition: border-color 0.2s;
}
.card:hover {
border-color: rgba(78, 205, 196, 0.3);
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.card-title {
font-size: 0.875rem;
font-weight: 500;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.card-value {
font-size: 2.25rem;
font-weight: 700;
color: var(--accent-primary);
}
.card-subtitle {
font-size: 0.875rem;
color: var(--text-secondary);
margin-top: 0.25rem;
}
.info-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
.info-item {
background: var(--bg-tertiary);
padding: 1rem;
border-radius: 8px;
}
.info-label {
font-size: 0.75rem;
color: var(--text-secondary);
margin-bottom: 0.25rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.info-value {
font-size: 1rem;
color: var(--text-primary);
font-family: monospace;
}
.section {
margin-top: 2rem;
}
.section-title {
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 1rem;
}
.subsection {
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border-color);
}
.subsection-title {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.file-list {
background: var(--bg-secondary);
border-radius: 12px;
border: 1px solid var(--border-color);
overflow: hidden;
}
.file-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color);
font-family: monospace;
font-size: 0.875rem;
}
.file-item:last-child {
border-bottom: none;
}
.file-item:hover {
background: var(--bg-tertiary);
}
.file-icon {
color: var(--text-secondary);
}
.file-name {
flex: 1;
color: var(--text-primary);
}
.file-size {
color: var(--text-secondary);
}
.loading {
display: flex;
align-items: center;
justify-content: center;
height: 200px;
color: var(--text-secondary);
}
.error {
background: rgba(255, 107, 107, 0.15);
color: var(--error);
padding: 1rem;
border-radius: 8px;
margin-bottom: 1rem;
border: 1px solid rgba(255, 107, 107, 0.3);
}
.refresh-btn {
background: var(--accent-primary);
color: var(--bg-primary);
border: none;
padding: 0.5rem 1rem;
border-radius: 8px;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
transition: all 0.2s;
}
.refresh-btn:hover {
background: #3db8b0;
transform: translateY(-1px);
}
.refresh-btn:disabled {
background: var(--bg-tertiary);
color: var(--text-secondary);
cursor: not-allowed;
transform: none;
}
/* Collapsible section header */
.section-header-collapsible {
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
padding: 0.5rem 0;
user-select: none;
}
.section-header-collapsible:hover .section-title {
color: var(--accent-primary);
}
.collapse-icon {
font-size: 1.5rem;
color: var(--text-secondary);
font-weight: bold;
}
/* Rules Editor */
.rules-editor {
background: var(--bg-secondary);
border-radius: 12px;
border: 1px solid var(--border-color);
padding: 1.5rem;
margin-top: 1rem;
}
.rules-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-color);
}
.rules-header h2 {
font-size: 1.25rem;
font-weight: 600;
margin: 0;
}
.rules-actions {
display: flex;
align-items: center;
gap: 1rem;
}
.unsaved-badge {
background: rgba(255, 217, 61, 0.15);
color: var(--warning);
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}
.btn-save {
background: var(--bg-tertiary);
color: var(--text-secondary);
border: 1px solid var(--border-color);
padding: 0.5rem 1rem;
border-radius: 8px;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
transition: all 0.2s;
}
.btn-save.btn-primary {
background: var(--accent-primary);
color: var(--bg-primary);
border-color: var(--accent-primary);
}
.btn-save:hover:not(:disabled) {
transform: translateY(-1px);
}
.btn-save:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.rules-content {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.rules-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.rules-empty {
text-align: center;
color: var(--text-secondary);
padding: 2rem;
background: var(--bg-tertiary);
border-radius: 8px;
}
.btn-add-rule {
background: transparent;
border: 2px dashed var(--border-color);
color: var(--text-secondary);
padding: 1rem;
border-radius: 8px;
cursor: pointer;
font-size: 0.875rem;
transition: all 0.2s;
}
.btn-add-rule:hover {
border-color: var(--accent-primary);
color: var(--accent-primary);
}
/* Rule Card */
.rule-card {
background: var(--bg-tertiary);
border-radius: 8px;
border: 1px solid var(--border-color);
overflow: hidden;
}
.rule-card.disabled {
opacity: 0.6;
}
.rule-card.expanded {
border-color: var(--accent-primary);
}
.rule-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
cursor: pointer;
transition: background 0.2s;
}
.rule-header:hover {
background: rgba(255, 255, 255, 0.02);
}
.rule-header-left {
display: flex;
align-items: center;
gap: 0.75rem;
}
.rule-header-left input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
}
.rule-name {
font-weight: 500;
color: var(--text-primary);
}
.rule-meta {
font-size: 0.75rem;
color: var(--text-secondary);
}
.rule-header-right {
display: flex;
align-items: center;
gap: 0.5rem;
}
.btn-icon {
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-secondary);
width: 28px;
height: 28px;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.875rem;
transition: all 0.2s;
}
.btn-icon:hover:not(:disabled) {
border-color: var(--accent-primary);
color: var(--accent-primary);
}
.btn-icon:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.btn-icon.btn-delete:hover {
border-color: var(--error);
color: var(--error);
}
.expand-icon {
font-size: 1.25rem;
color: var(--text-secondary);
margin-left: 0.5rem;
}
.rule-body {
padding: 1rem;
border-top: 1px solid var(--border-color);
display: flex;
flex-direction: column;
gap: 1rem;
}
.rule-field {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.rule-field > label {
font-size: 0.75rem;
font-weight: 500;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.rule-field input[type="text"],
.rule-field input[type="number"] {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 0.5rem 0.75rem;
border-radius: 6px;
font-size: 0.875rem;
}
.rule-field input:focus {
outline: none;
border-color: var(--accent-primary);
}
.rule-field-inline {
flex-direction: row;
align-items: center;
}
.rule-field-inline label {
display: flex;
align-items: center;
gap: 0.5rem;
text-transform: none;
font-size: 0.875rem;
cursor: pointer;
}
/* Condition Builder */
.condition-builder {
background: var(--bg-secondary);
border-radius: 8px;
padding: 1rem;
}
.condition-operator {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
}
.condition-operator label {
font-size: 0.875rem;
color: var(--text-secondary);
}
.condition-operator select {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 0.375rem 0.75rem;
border-radius: 6px;
font-size: 0.875rem;
}
.condition-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
}
.condition-empty {
text-align: center;
color: var(--text-secondary);
font-size: 0.875rem;
padding: 1rem;
background: var(--bg-tertiary);
border-radius: 6px;
}
.condition-row {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.condition-field,
.condition-type {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 0.375rem 0.75rem;
border-radius: 6px;
font-size: 0.875rem;
}
.condition-value {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 0.375rem 0.75rem;
border-radius: 6px;
font-size: 0.875rem;
flex: 1;
min-width: 100px;
}
.condition-value:focus {
outline: none;
border-color: var(--accent-primary);
}
.btn-add-condition {
background: transparent;
border: 1px dashed var(--border-color);
color: var(--text-secondary);
padding: 0.5rem 1rem;
border-radius: 6px;
cursor: pointer;
font-size: 0.875rem;
transition: all 0.2s;
}
.btn-add-condition:hover {
border-color: var(--accent-primary);
color: var(--accent-primary);
}
/* Template Editor */
.template-editor {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.template-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.template-header label {
font-size: 0.75rem;
font-weight: 500;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.btn-small {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-secondary);
padding: 0.25rem 0.5rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.75rem;
transition: all 0.2s;
}
.btn-small:hover {
border-color: var(--accent-primary);
color: var(--accent-primary);
}
.template-input {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 0.75rem;
border-radius: 6px;
font-size: 0.875rem;
font-family: monospace;
resize: vertical;
min-height: 60px;
}
.template-input:focus {
outline: none;
border-color: var(--accent-primary);
}
.variable-palette {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
padding: 0.75rem;
background: var(--bg-tertiary);
border-radius: 6px;
}
.variable-chip {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 0.25rem 0.5rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.75rem;
transition: all 0.2s;
}
.variable-chip:hover {
border-color: var(--accent-primary);
color: var(--accent-primary);
}
.template-help {
display: flex;
flex-wrap: wrap;
gap: 1rem;
font-size: 0.75rem;
color: var(--text-secondary);
}
.template-help code {
background: var(--bg-tertiary);
padding: 0.125rem 0.375rem;
border-radius: 4px;
font-family: monospace;
color: var(--accent-primary);
}
/* Preview Panel */
.preview-panel {
background: var(--bg-tertiary);
border-radius: 8px;
overflow: hidden;
}
.preview-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color);
}
.preview-header h3 {
font-size: 0.875rem;
font-weight: 500;
margin: 0;
}
.preview-count {
font-size: 0.75rem;
color: var(--text-secondary);
}
.preview-loading,
.preview-empty {
text-align: center;
color: var(--text-secondary);
padding: 2rem;
}
.preview-table-wrapper {
overflow-x: auto;
}
.preview-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8rem;
}
.preview-table th,
.preview-table td {
padding: 0.5rem 0.75rem;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
.preview-table th {
background: var(--bg-secondary);
font-weight: 500;
color: var(--text-secondary);
text-transform: uppercase;
font-size: 0.7rem;
letter-spacing: 0.05em;
}
.preview-table td {
font-family: monospace;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
}
.preview-table tr.changed td.highlight {
color: var(--accent-primary);
}
.preview-table tr.error td {
color: var(--error);
}
.error-text {
color: var(--error);
font-style: italic;
}
.rules-loading,
.rules-error {
text-align: center;
color: var(--text-secondary);
padding: 2rem;
}
.rules-error-message {
background: rgba(255, 107, 107, 0.15);
color: var(--error);
padding: 0.75rem 1rem;
border-radius: 8px;
margin-bottom: 1rem;
font-size: 0.875rem;
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+74
View File
@@ -0,0 +1,74 @@
/**
* Types for renaming rules UI
* These mirror the backend types for API communication
*/
export type ConditionOperator =
| 'EXISTS'
| 'NOT_EXISTS'
| 'EQUALS'
| 'NOT_EQUALS'
| 'CONTAINS'
| 'MATCHES';
export interface Condition {
type: ConditionOperator;
field: string;
value?: string | number | boolean;
}
export interface ConditionGroup {
operator: 'AND' | 'OR';
conditions: (Condition | ConditionGroup)[];
}
export interface RenamingRule {
id: string;
name: string;
description?: string;
enabled: boolean;
priority: number;
conditions: ConditionGroup;
template: string;
fallbackToUnsorted: boolean;
}
export interface RenamingConfig {
version: number;
rules: RenamingRule[];
defaultRule?: RenamingRule;
lastModified?: string;
isDefault?: boolean; // True if this is the default config (not user-customized)
}
export interface PreviewItem {
sourcePath: string;
currentVirtualPath: string;
newVirtualPath: string | null;
matchedRule: string | null;
error?: string;
}
export interface PreviewResponse {
previews: PreviewItem[];
total: number;
limited: boolean;
}
export interface TemplateVariable {
name: string;
path: string;
type: 'string' | 'number' | 'boolean';
description: string;
example?: string;
}
// Helper function to check if a condition is a group
export function isConditionGroup(condition: Condition | ConditionGroup): condition is ConditionGroup {
return 'operator' in condition && 'conditions' in condition;
}
// Generate a unique ID
export function generateId(): string {
return `rule-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />