initial commit
This commit is contained in:
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user