move feature from meta-sort to meta-core
This commit is contained in:
@@ -25,6 +25,7 @@
|
|||||||
"chokidar": "^3.5.3",
|
"chokidar": "^3.5.3",
|
||||||
"dockerode": "^4.0.2",
|
"dockerode": "^4.0.2",
|
||||||
"dotenv": "^16.4.1",
|
"dotenv": "^16.4.1",
|
||||||
|
"eventsource": "^4.1.0",
|
||||||
"fastify": "^5.6.1",
|
"fastify": "^5.6.1",
|
||||||
"fluent-ffmpeg": "^2.1.2",
|
"fluent-ffmpeg": "^2.1.2",
|
||||||
"franc-min": "^6.2.0",
|
"franc-min": "^6.2.0",
|
||||||
@@ -42,6 +43,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/chai": "^4.3.11",
|
"@types/chai": "^4.3.11",
|
||||||
"@types/dockerode": "^3.3.31",
|
"@types/dockerode": "^3.3.31",
|
||||||
|
"@types/eventsource": "^3.0.0",
|
||||||
"@types/fluent-ffmpeg": "^2.1.24",
|
"@types/fluent-ffmpeg": "^2.1.24",
|
||||||
"@types/lodash": "^4.17.0",
|
"@types/lodash": "^4.17.0",
|
||||||
"@types/mocha": "^10.0.6",
|
"@types/mocha": "^10.0.6",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -839,10 +839,10 @@ describe('UnifiedAPIServer', function() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Mounts API (Mounts Page)
|
// Mounts API (Mounts Page) - SKIPPED in Architecture V3 (moved to meta-core)
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
describe('Mounts API (/api/mounts/*)', function() {
|
describe.skip('Mounts API (/api/mounts/*)', function() {
|
||||||
it('GET /api/mounts returns mount list', async function() {
|
it('GET /api/mounts returns mount list', async function() {
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
|
|||||||
@@ -1,24 +1,13 @@
|
|||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
export interface PollingPathConfig {
|
|
||||||
path: string;
|
|
||||||
intervalMs: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EnvConfig {
|
interface EnvConfig {
|
||||||
/** Comma-separated list of folders to watch for changes.(mandatory) */
|
|
||||||
WATCH_FOLDER_LIST: string;
|
|
||||||
|
|
||||||
/** Path to the index file within the NFO CID library. */
|
/** Path to the index file within the NFO CID library. */
|
||||||
INDEX_FOLDER_PATH: string;
|
INDEX_FOLDER_PATH: string;
|
||||||
|
|
||||||
/** Interval in milliseconds to update/check for changes. (default 30000) */
|
/** Interval in milliseconds to update/check for changes. (default 30000) */
|
||||||
UPDATE_INTERVAL_MS: number;
|
UPDATE_INTERVAL_MS: number;
|
||||||
|
|
||||||
/** Interval in milliseconds for polling watch folders. Set to 0 to disable polling. (default 0) */
|
|
||||||
WATCH_FOLDER_POOLING_INTERVAL_MS: number;
|
|
||||||
|
|
||||||
/** CHMOD for new folder format (default 0o777) */
|
/** CHMOD for new folder format (default 0o777) */
|
||||||
CHMOD_FOR_NEW_FOLDER: number;
|
CHMOD_FOR_NEW_FOLDER: number;
|
||||||
|
|
||||||
@@ -41,11 +30,8 @@ interface EnvConfig {
|
|||||||
/** Maximum number of worker threads for Piscina worker pool AND max concurrent file processing (default: CPU count) */
|
/** Maximum number of worker threads for Piscina worker pool AND max concurrent file processing (default: CPU count) */
|
||||||
MAX_WORKER_THREADS?: number;
|
MAX_WORKER_THREADS?: number;
|
||||||
|
|
||||||
/** JSON array of polling path configurations. Example: '[{"path": "/data/watch/smb/downloads", "intervalMs": 60000}]' (optional) */
|
|
||||||
POLLING_PATHS?: PollingPathConfig[];
|
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
// KV Storage Configuration (NEW)
|
// KV Storage Configuration
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|
||||||
/** Path to META_CORE_VOLUME - infrastructure volume for KV DB, locks, services (default '/meta-core') */
|
/** Path to META_CORE_VOLUME - infrastructure volume for KV DB, locks, services (default '/meta-core') */
|
||||||
@@ -123,54 +109,30 @@ interface EnvConfig {
|
|||||||
|
|
||||||
// NOTE: Plugin output files are written via WebDAV (WEBDAV_URL/plugin/<pluginId>/)
|
// NOTE: Plugin output files are written via WebDAV (WEBDAV_URL/plugin/<pluginId>/)
|
||||||
// No PLUGIN_OUTPUT_HOST_PATH needed - plugins use HTTP PUT to write files
|
// No PLUGIN_OUTPUT_HOST_PATH needed - plugins use HTTP PUT to write files
|
||||||
}
|
|
||||||
|
|
||||||
// Parse POLLING_PATHS from JSON string
|
// ========================================================================
|
||||||
function parsePollingPaths(): PollingPathConfig[] | undefined {
|
// Event Subscriber Configuration (Architecture V3)
|
||||||
if (!process.env.POLLING_PATHS) {
|
// ========================================================================
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
/**
|
||||||
const parsed = JSON.parse(process.env.POLLING_PATHS);
|
* URL of meta-core API for event subscription (SSE).
|
||||||
if (!Array.isArray(parsed)) {
|
* REQUIRED: meta-sort receives file events from meta-core via SSE.
|
||||||
console.error('POLLING_PATHS must be a JSON array');
|
* Example: "http://meta-core" or "http://localhost:8083"
|
||||||
return undefined;
|
*/
|
||||||
}
|
META_CORE_URL?: string;
|
||||||
|
|
||||||
// Validate structure
|
|
||||||
const valid = parsed.every(item =>
|
|
||||||
item && typeof item === 'object' &&
|
|
||||||
typeof item.path === 'string' &&
|
|
||||||
typeof item.intervalMs === 'number'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!valid) {
|
|
||||||
console.error('POLLING_PATHS entries must have {path: string, intervalMs: number}');
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return parsed as PollingPathConfig[];
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to parse POLLING_PATHS:', error);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const config: EnvConfig = {
|
export const config: EnvConfig = {
|
||||||
WATCH_FOLDER_LIST: process.env.WATCH_FOLDER_LIST,
|
INDEX_FOLDER_PATH: process.env.INDEX_FOLDER_PATH || '/data/cache/hash-index',
|
||||||
INDEX_FOLDER_PATH: process.env.INDEX_FOLDER_PATH || '/data/cache/hash-index',//default /data/cache/hash-index
|
UPDATE_INTERVAL_MS: parseInt(process.env.UPDATE_INTERVAL_MS || "30000", 10),
|
||||||
UPDATE_INTERVAL_MS: parseInt(process.env.UPDATE_INTERVAL_MS || "30000", 10),//default 30000
|
CHMOD_FOR_NEW_FOLDER: process.env.CHMOD_FOR_NEW_FOLDER && parseInt(process.env.CHMOD_FOR_NEW_FOLDER, 8),
|
||||||
WATCH_FOLDER_POOLING_INTERVAL_MS: parseInt(process.env.WATCH_FOLDER_POOLING_INTERVAL_MS || "0", 10),//default 0
|
CACHE_FOLDER_PATH: process.env.CACHE_FOLDER_PATH || '/data/cache',
|
||||||
CHMOD_FOR_NEW_FOLDER: process.env.CHMOD_FOR_NEW_FOLDER && parseInt(process.env.CHMOD_FOR_NEW_FOLDER, 8),//default 0o777 (this is the default for mkdir)
|
|
||||||
CACHE_FOLDER_PATH: process.env.CACHE_FOLDER_PATH || '/data/cache',//default /data/cache
|
|
||||||
JELLYFIN_ENDPOINT: process.env.JELLYFIN_ENDPOINT!,
|
JELLYFIN_ENDPOINT: process.env.JELLYFIN_ENDPOINT!,
|
||||||
JELLYFIN_API_KEY: process.env.JELLYFIN_API_KEY!,
|
JELLYFIN_API_KEY: process.env.JELLYFIN_API_KEY!,
|
||||||
FUSE_API_PORT: parseInt(process.env.FUSE_API_PORT || "3000", 10),//default 3000
|
FUSE_API_PORT: parseInt(process.env.FUSE_API_PORT || "3000", 10),
|
||||||
FUSE_API_HOST: process.env.FUSE_API_HOST || '0.0.0.0',//default 0.0.0.0
|
FUSE_API_HOST: process.env.FUSE_API_HOST || '0.0.0.0',
|
||||||
METADATA_FORMATS: (process.env.METADATA_FORMATS || 'meta').split(',').map(f => f.trim()).filter(f => f.length > 0),//default 'meta'
|
METADATA_FORMATS: (process.env.METADATA_FORMATS || 'meta').split(',').map(f => f.trim()).filter(f => f.length > 0),
|
||||||
MAX_WORKER_THREADS: process.env.MAX_WORKER_THREADS ? parseInt(process.env.MAX_WORKER_THREADS, 10) : undefined,
|
MAX_WORKER_THREADS: process.env.MAX_WORKER_THREADS ? parseInt(process.env.MAX_WORKER_THREADS, 10) : undefined,
|
||||||
POLLING_PATHS: parsePollingPaths(),
|
|
||||||
|
|
||||||
// KV Storage Configuration
|
// KV Storage Configuration
|
||||||
META_CORE_PATH: process.env.META_CORE_PATH || '/meta-core',
|
META_CORE_PATH: process.env.META_CORE_PATH || '/meta-core',
|
||||||
@@ -194,11 +156,7 @@ export const config: EnvConfig = {
|
|||||||
PLUGIN_WEBDAV_URL: process.env.PLUGIN_WEBDAV_URL,
|
PLUGIN_WEBDAV_URL: process.env.PLUGIN_WEBDAV_URL,
|
||||||
PLUGIN_STACK_NAME: process.env.PLUGIN_STACK_NAME,
|
PLUGIN_STACK_NAME: process.env.PLUGIN_STACK_NAME,
|
||||||
PLUGIN_CACHE_HOST_PATH: process.env.PLUGIN_CACHE_HOST_PATH,
|
PLUGIN_CACHE_HOST_PATH: process.env.PLUGIN_CACHE_HOST_PATH,
|
||||||
|
|
||||||
|
// Event Subscriber Configuration
|
||||||
|
META_CORE_URL: process.env.META_CORE_URL,
|
||||||
};
|
};
|
||||||
|
|
||||||
if(process.env.TEST!=='true') {
|
|
||||||
if (!config.WATCH_FOLDER_LIST) {
|
|
||||||
console.error('Invalid configuration: WATCH_FOLDER_LIST is required');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@@ -119,9 +119,31 @@ export class ContainerManager extends EventEmitter {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Discover service URLs from meta-core service discovery file
|
* Discover service URLs from meta-core service discovery file
|
||||||
* Falls back to environment variables if discovery fails
|
* Explicit env vars (CONTAINER_CALLBACK_URL, PLUGIN_WEBDAV_URL) take priority
|
||||||
|
* Service discovery is only used as fallback
|
||||||
*/
|
*/
|
||||||
private async discoverServiceUrls(): Promise<void> {
|
private async discoverServiceUrls(): Promise<void> {
|
||||||
|
// Check if explicit env vars are set - these take priority for container communication
|
||||||
|
const explicitCallbackUrl = config.CONTAINER_CALLBACK_URL;
|
||||||
|
const explicitWebdavUrl = config.PLUGIN_WEBDAV_URL;
|
||||||
|
|
||||||
|
if (explicitCallbackUrl) {
|
||||||
|
this.callbackUrl = explicitCallbackUrl;
|
||||||
|
console.log(`[ContainerManager] Using explicit callback URL: ${this.callbackUrl}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (explicitWebdavUrl) {
|
||||||
|
this.webdavUrl = explicitWebdavUrl;
|
||||||
|
console.log(`[ContainerManager] Using explicit WebDAV URL: ${this.webdavUrl}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If both are explicitly set, skip service discovery
|
||||||
|
if (explicitCallbackUrl && explicitWebdavUrl) {
|
||||||
|
console.log('[ContainerManager] Using explicit env vars, skipping service discovery');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to service discovery for any missing URLs
|
||||||
const serviceFile = join(config.META_CORE_PATH, 'services', 'meta-sort.json');
|
const serviceFile = join(config.META_CORE_PATH, 'services', 'meta-sort.json');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -132,7 +154,8 @@ export class ContainerManager extends EventEmitter {
|
|||||||
const api = serviceInfo.api;
|
const api = serviceInfo.api;
|
||||||
const endpoints = serviceInfo.endpoints || {};
|
const endpoints = serviceInfo.endpoints || {};
|
||||||
|
|
||||||
// Update URLs from service discovery
|
// Only use service discovery if env var not set
|
||||||
|
if (!explicitCallbackUrl) {
|
||||||
if (endpoints.callback) {
|
if (endpoints.callback) {
|
||||||
this.callbackUrl = endpoints.callback;
|
this.callbackUrl = endpoints.callback;
|
||||||
console.log(`[ContainerManager] Discovered callback URL: ${this.callbackUrl}`);
|
console.log(`[ContainerManager] Discovered callback URL: ${this.callbackUrl}`);
|
||||||
@@ -141,7 +164,9 @@ export class ContainerManager extends EventEmitter {
|
|||||||
this.callbackUrl = `${api}/api/plugins/callback`;
|
this.callbackUrl = `${api}/api/plugins/callback`;
|
||||||
console.log(`[ContainerManager] Constructed callback URL: ${this.callbackUrl}`);
|
console.log(`[ContainerManager] Constructed callback URL: ${this.callbackUrl}`);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!explicitWebdavUrl) {
|
||||||
if (endpoints.webdav) {
|
if (endpoints.webdav) {
|
||||||
this.webdavUrl = endpoints.webdav;
|
this.webdavUrl = endpoints.webdav;
|
||||||
console.log(`[ContainerManager] Discovered WebDAV URL: ${this.webdavUrl}`);
|
console.log(`[ContainerManager] Discovered WebDAV URL: ${this.webdavUrl}`);
|
||||||
@@ -150,6 +175,7 @@ export class ContainerManager extends EventEmitter {
|
|||||||
this.webdavUrl = `${api}/webdav`;
|
this.webdavUrl = `${api}/webdav`;
|
||||||
console.log(`[ContainerManager] Constructed WebDAV URL: ${this.webdavUrl}`);
|
console.log(`[ContainerManager] Constructed WebDAV URL: ${this.webdavUrl}`);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (endpoints.health) {
|
if (endpoints.health) {
|
||||||
// Extract meta-core URL from health endpoint
|
// Extract meta-core URL from health endpoint
|
||||||
|
|||||||
@@ -0,0 +1,292 @@
|
|||||||
|
/**
|
||||||
|
* EventSubscriber
|
||||||
|
*
|
||||||
|
* Subscribes to file events from meta-core via SSE (Server-Sent Events).
|
||||||
|
* When events are received, they are forwarded to the StreamingPipeline.
|
||||||
|
*
|
||||||
|
* This enables meta-sort to run without direct filesystem access - it receives
|
||||||
|
* file discovery events from meta-core and processes files via WebDAV.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EventSource } from 'eventsource';
|
||||||
|
|
||||||
|
// File event types matching meta-core's watcher
|
||||||
|
interface FileEvent {
|
||||||
|
type: 'add' | 'change' | 'delete' | 'rename';
|
||||||
|
path: string; // Relative to FILES_PATH
|
||||||
|
size?: number;
|
||||||
|
timestamp: number;
|
||||||
|
partialHash?: string; // Hash of first 64KB
|
||||||
|
oldPath?: string; // For rename events
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pipeline interface (minimal, matching StreamingPipeline)
|
||||||
|
interface Pipeline {
|
||||||
|
handleFileAdded(filePath: string): void;
|
||||||
|
handleFileChanged(filePath: string): void;
|
||||||
|
handleFileDeleted(filePath: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventSubscriberOptions {
|
||||||
|
/** URL of meta-core API (e.g., http://meta-core or http://localhost:8083) */
|
||||||
|
metaCoreUrl: string;
|
||||||
|
|
||||||
|
/** Pipeline to receive events */
|
||||||
|
pipeline: Pipeline;
|
||||||
|
|
||||||
|
/** Base path for files (e.g., /files). Paths from meta-core are relative and need this prefix. */
|
||||||
|
filesPath?: string;
|
||||||
|
|
||||||
|
/** Whether to request an initial scan when connecting */
|
||||||
|
requestInitialScan?: boolean;
|
||||||
|
|
||||||
|
/** Reconnect delay in milliseconds */
|
||||||
|
reconnectDelayMs?: number;
|
||||||
|
|
||||||
|
/** Maximum reconnect attempts (0 = unlimited) */
|
||||||
|
maxReconnectAttempts?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EventSubscriber {
|
||||||
|
private options: Required<EventSubscriberOptions>;
|
||||||
|
private eventSource: EventSource | null = null;
|
||||||
|
private isRunning = false;
|
||||||
|
private reconnectAttempts = 0;
|
||||||
|
private reconnectTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
constructor(options: EventSubscriberOptions) {
|
||||||
|
this.options = {
|
||||||
|
filesPath: '/files',
|
||||||
|
requestInitialScan: true,
|
||||||
|
reconnectDelayMs: 5000,
|
||||||
|
maxReconnectAttempts: 0, // Unlimited by default
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start subscribing to events from meta-core
|
||||||
|
*/
|
||||||
|
async start(): Promise<void> {
|
||||||
|
if (this.isRunning) {
|
||||||
|
console.log('[EventSubscriber] Already running');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isRunning = true;
|
||||||
|
console.log(`[EventSubscriber] Connecting to ${this.options.metaCoreUrl}/api/events/subscribe`);
|
||||||
|
|
||||||
|
await this.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop subscribing to events
|
||||||
|
*/
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
this.isRunning = false;
|
||||||
|
|
||||||
|
if (this.reconnectTimer) {
|
||||||
|
clearTimeout(this.reconnectTimer);
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.eventSource) {
|
||||||
|
this.eventSource.close();
|
||||||
|
this.eventSource = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[EventSubscriber] Stopped');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger a manual scan on meta-core
|
||||||
|
*/
|
||||||
|
async triggerScan(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.options.metaCoreUrl}/api/scan/trigger`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[EventSubscriber] Scan triggered on meta-core');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[EventSubscriber] Failed to trigger scan:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get watcher status from meta-core
|
||||||
|
*/
|
||||||
|
async getStatus(): Promise<{
|
||||||
|
status: string;
|
||||||
|
scanning: boolean;
|
||||||
|
lastScan: number;
|
||||||
|
fileCount: number;
|
||||||
|
} | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.options.metaCoreUrl}/api/scan/status`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[EventSubscriber] Failed to get status:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poll for events since a timestamp (fallback for SSE)
|
||||||
|
*/
|
||||||
|
async pollEvents(sinceMs: number, limit = 100): Promise<FileEvent[]> {
|
||||||
|
try {
|
||||||
|
const url = `${this.options.metaCoreUrl}/api/events/poll?since=${sinceMs}&limit=${limit}`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data.events || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[EventSubscriber] Failed to poll events:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async connect(): Promise<void> {
|
||||||
|
if (!this.isRunning) return;
|
||||||
|
|
||||||
|
const url = `${this.options.metaCoreUrl}/api/events/subscribe`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.eventSource = new EventSource(url);
|
||||||
|
|
||||||
|
this.eventSource.onopen = () => {
|
||||||
|
console.log('[EventSubscriber] Connected to meta-core SSE');
|
||||||
|
this.reconnectAttempts = 0;
|
||||||
|
|
||||||
|
// Request initial scan if configured
|
||||||
|
if (this.options.requestInitialScan) {
|
||||||
|
this.triggerScan().catch(err => {
|
||||||
|
console.error('[EventSubscriber] Failed to trigger initial scan:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.eventSource.onerror = (error) => {
|
||||||
|
console.error('[EventSubscriber] SSE connection error:', error);
|
||||||
|
this.scheduleReconnect();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle file events
|
||||||
|
this.eventSource.addEventListener('file', (event: MessageEvent) => {
|
||||||
|
try {
|
||||||
|
const fileEvent: FileEvent = JSON.parse(event.data);
|
||||||
|
this.handleEvent(fileEvent);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[EventSubscriber] Failed to parse event:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle connected event
|
||||||
|
this.eventSource.addEventListener('connected', () => {
|
||||||
|
console.log('[EventSubscriber] Received connected event');
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[EventSubscriber] Failed to connect:', error);
|
||||||
|
this.scheduleReconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleEvent(event: FileEvent): void {
|
||||||
|
const { pipeline, filesPath } = this.options;
|
||||||
|
|
||||||
|
// Convert relative path to absolute path
|
||||||
|
const toAbsolutePath = (relativePath: string): string => {
|
||||||
|
// If path is already absolute, return as-is
|
||||||
|
if (relativePath.startsWith('/')) {
|
||||||
|
return relativePath;
|
||||||
|
}
|
||||||
|
// Otherwise, prepend filesPath
|
||||||
|
return `${filesPath}/${relativePath}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const absolutePath = toAbsolutePath(event.path);
|
||||||
|
|
||||||
|
switch (event.type) {
|
||||||
|
case 'add':
|
||||||
|
pipeline.handleFileAdded(absolutePath);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'change':
|
||||||
|
pipeline.handleFileChanged(absolutePath);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'delete':
|
||||||
|
pipeline.handleFileDeleted(absolutePath);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'rename':
|
||||||
|
// Handle rename as delete + add
|
||||||
|
if (event.oldPath) {
|
||||||
|
pipeline.handleFileDeleted(toAbsolutePath(event.oldPath));
|
||||||
|
}
|
||||||
|
pipeline.handleFileAdded(absolutePath);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.warn(`[EventSubscriber] Unknown event type: ${event.type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleReconnect(): void {
|
||||||
|
if (!this.isRunning) return;
|
||||||
|
|
||||||
|
// Close existing connection
|
||||||
|
if (this.eventSource) {
|
||||||
|
this.eventSource.close();
|
||||||
|
this.eventSource = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check reconnect limits
|
||||||
|
if (this.options.maxReconnectAttempts > 0 &&
|
||||||
|
this.reconnectAttempts >= this.options.maxReconnectAttempts) {
|
||||||
|
console.error('[EventSubscriber] Max reconnect attempts reached, giving up');
|
||||||
|
this.isRunning = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.reconnectAttempts++;
|
||||||
|
const delay = this.options.reconnectDelayMs * Math.min(this.reconnectAttempts, 6); // Max 30s
|
||||||
|
|
||||||
|
console.log(`[EventSubscriber] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`);
|
||||||
|
|
||||||
|
this.reconnectTimer = setTimeout(() => {
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
this.connect();
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an EventSubscriber with default configuration
|
||||||
|
*/
|
||||||
|
export function createEventSubscriber(
|
||||||
|
metaCoreUrl: string,
|
||||||
|
pipeline: Pipeline,
|
||||||
|
options?: Partial<EventSubscriberOptions>
|
||||||
|
): EventSubscriber {
|
||||||
|
return new EventSubscriber({
|
||||||
|
metaCoreUrl,
|
||||||
|
pipeline,
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import {FolderWatcher, PollingWatcher} from "@metazla/meta-hash";
|
|
||||||
import {config} from "./config/EnvConfig.js";
|
import {config} from "./config/EnvConfig.js";
|
||||||
|
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
@@ -12,6 +11,7 @@ import {KVManager} from "./kv/KVManager.js";
|
|||||||
import {getPluginManagerInstance, initializePluginManager} from "./logic/fileProcessor/FileProcessorPiscina.js";
|
import {getPluginManagerInstance, initializePluginManager} from "./logic/fileProcessor/FileProcessorPiscina.js";
|
||||||
import {ContainerManager} from "./container-plugins/ContainerManager.js";
|
import {ContainerManager} from "./container-plugins/ContainerManager.js";
|
||||||
import {ContainerPluginScheduler} from "./container-plugins/ContainerPluginScheduler.js";
|
import {ContainerPluginScheduler} from "./container-plugins/ContainerPluginScheduler.js";
|
||||||
|
import {EventSubscriber} from "./events/EventSubscriber.js";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get current user information
|
// Get current user information
|
||||||
@@ -31,7 +31,6 @@ console.log(config);
|
|||||||
process.umask(0);//This unable the process to create files with 777 permissions
|
process.umask(0);//This unable the process to create files with 777 permissions
|
||||||
|
|
||||||
let fileProcessor = new WatchedFileProcessor();
|
let fileProcessor = new WatchedFileProcessor();
|
||||||
let folders = config.WATCH_FOLDER_LIST;
|
|
||||||
|
|
||||||
// KV Manager for Redis-based storage with leader election (optional)
|
// KV Manager for Redis-based storage with leader election (optional)
|
||||||
let kvManager: KVManager | null = null;
|
let kvManager: KVManager | null = null;
|
||||||
@@ -40,6 +39,9 @@ let kvManager: KVManager | null = null;
|
|||||||
let containerManager: ContainerManager | null = null;
|
let containerManager: ContainerManager | null = null;
|
||||||
let containerPluginScheduler: ContainerPluginScheduler | null = null;
|
let containerPluginScheduler: ContainerPluginScheduler | null = null;
|
||||||
|
|
||||||
|
// Event Subscriber for receiving events from meta-core (Architecture V3)
|
||||||
|
let eventSubscriber: EventSubscriber | null = null;
|
||||||
|
|
||||||
// Calculate concurrency for pipeline stages
|
// Calculate concurrency for pipeline stages
|
||||||
const cpuCount = os.cpus().length;
|
const cpuCount = os.cpus().length;
|
||||||
// Smart defaults optimized for network storage + HDD scenarios:
|
// Smart defaults optimized for network storage + HDD scenarios:
|
||||||
@@ -69,13 +71,6 @@ const pipeline = new StreamingPipeline({
|
|||||||
// Connect pipeline to fileProcessor for queue status monitoring
|
// Connect pipeline to fileProcessor for queue status monitoring
|
||||||
fileProcessor.setPipeline(pipeline);
|
fileProcessor.setPipeline(pipeline);
|
||||||
|
|
||||||
// Create generic file discovery
|
|
||||||
const folderWatcher = new FolderWatcher();
|
|
||||||
|
|
||||||
// Parse folder list
|
|
||||||
const folderList = folders.split(',').map(f => f.trim()).filter(f => f.length > 0);
|
|
||||||
console.log(`Watching folders: ${folderList.join(', ')}`);
|
|
||||||
|
|
||||||
// VFS and state manager are available immediately
|
// VFS and state manager are available immediately
|
||||||
const vfs = fileProcessor.getVirtualFileSystem();
|
const vfs = fileProcessor.getVirtualFileSystem();
|
||||||
const unifiedStateManager = fileProcessor.getUnifiedStateManager();
|
const unifiedStateManager = fileProcessor.getUnifiedStateManager();
|
||||||
@@ -84,52 +79,21 @@ const getDuplicateResult = () => fileProcessor.getDuplicateResult();
|
|||||||
// API Server will be initialized after KV is ready (deferred to async init)
|
// API Server will be initialized after KV is ready (deferred to async init)
|
||||||
let apiServer: UnifiedAPIServer | null = null;
|
let apiServer: UnifiedAPIServer | null = null;
|
||||||
|
|
||||||
// Shared progressive discovery and cleanup logic (used by both startup and manual scan)
|
// Manual scan trigger (called from API) - delegates to meta-core via EventSubscriber
|
||||||
const runProgressiveDiscovery = async () => {
|
|
||||||
console.log(`[Discovery] Progressive scan mode: ${folderList.length} folders`);
|
|
||||||
|
|
||||||
// Process folders one at a time for progressive cleanup
|
|
||||||
for (const folder of folderList) {
|
|
||||||
console.log(`[Discovery] Scanning folder: ${folder}`);
|
|
||||||
|
|
||||||
// Track discovered files for this folder
|
|
||||||
const discoveredFilesInFolder = new Set<string>();
|
|
||||||
|
|
||||||
// Wrap discovery stream to track files
|
|
||||||
async function* trackDiscoveredFiles(stream: AsyncGenerator<string>) {
|
|
||||||
for await (const filePath of stream) {
|
|
||||||
discoveredFilesInFolder.add(filePath);
|
|
||||||
yield filePath;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const discoveryStream = folderWatcher.discoverFiles([folder]);
|
|
||||||
const trackedStream = trackDiscoveredFiles(discoveryStream);
|
|
||||||
|
|
||||||
await pipeline.start(trackedStream);
|
|
||||||
|
|
||||||
// Progressive cleanup: clean up after each folder completes
|
|
||||||
console.log(`[Discovery] Folder scan complete: ${folder}. Found ${discoveredFilesInFolder.size} files. Running cleanup...`);
|
|
||||||
await fileProcessor.cleanupStaleEntries([folder], discoveredFilesInFolder);
|
|
||||||
console.log(`[Discovery] Cleanup complete for folder: ${folder}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[Discovery] All folders scanned and cleaned up`);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Manual scan trigger (called from API)
|
|
||||||
// Note: Stremio updates happen automatically via incremental add/remove notifications
|
|
||||||
// during file processing (like FUSE), so no explicit refresh needed here
|
|
||||||
const triggerScan = async () => {
|
const triggerScan = async () => {
|
||||||
console.log('[Scan] Triggering manual scan from API...');
|
console.log('[Scan] Triggering manual scan...');
|
||||||
|
|
||||||
// Reset pipeline counters and state before fresh scan
|
// Reset pipeline counters and state before fresh scan
|
||||||
// Also publishes reset event to meta-fuse
|
// Also publishes reset event to meta-fuse
|
||||||
await pipeline.reset();
|
await pipeline.reset();
|
||||||
|
|
||||||
console.log('[Scan] Starting fresh discovery...');
|
// Trigger scan on meta-core (which will send events back via SSE)
|
||||||
await runProgressiveDiscovery();
|
if (eventSubscriber) {
|
||||||
console.log('[Scan] Manual scan complete');
|
await eventSubscriber.triggerScan();
|
||||||
|
console.log('[Scan] Scan triggered on meta-core');
|
||||||
|
} else {
|
||||||
|
console.warn('[Scan] EventSubscriber not available, cannot trigger scan');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Global error handlers - prevent crashes from unforeseen errors
|
// Global error handlers - prevent crashes from unforeseen errors
|
||||||
@@ -153,6 +117,12 @@ process.on('SIGINT', async () => {
|
|||||||
await apiServer.stop();
|
await apiServer.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stop Event Subscriber if running
|
||||||
|
if (eventSubscriber) {
|
||||||
|
console.log('[Shutdown] Stopping Event Subscriber...');
|
||||||
|
await eventSubscriber.stop();
|
||||||
|
}
|
||||||
|
|
||||||
// Stop Container Manager if running
|
// Stop Container Manager if running
|
||||||
if (containerManager) {
|
if (containerManager) {
|
||||||
console.log('[Shutdown] Stopping Container Manager...');
|
console.log('[Shutdown] Stopping Container Manager...');
|
||||||
@@ -171,6 +141,13 @@ process.on('SIGINT', async () => {
|
|||||||
// Start initial discovery and processing (streaming pipeline)
|
// Start initial discovery and processing (streaming pipeline)
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
|
// Validate META_CORE_URL is configured
|
||||||
|
if (!config.META_CORE_URL) {
|
||||||
|
console.error('[Startup] ERROR: META_CORE_URL is required. Set META_CORE_URL environment variable.');
|
||||||
|
console.error('[Startup] Example: META_CORE_URL=http://meta-core');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize KV Manager if configured (Redis + leader election)
|
// Initialize KV Manager if configured (Redis + leader election)
|
||||||
if (config.META_CORE_PATH) {
|
if (config.META_CORE_PATH) {
|
||||||
console.log('[Startup] Initializing KV Manager (Redis + leader election)...');
|
console.log('[Startup] Initializing KV Manager (Redis + leader election)...');
|
||||||
@@ -278,10 +255,6 @@ process.on('SIGINT', async () => {
|
|||||||
await fileProcessor.rebuildVFSFromKV();
|
await fileProcessor.rebuildVFSFromKV();
|
||||||
console.log('[Startup] VFS rebuild completed');
|
console.log('[Startup] VFS rebuild completed');
|
||||||
|
|
||||||
console.log('[Startup] Starting initial file discovery with progressive cleanup...');
|
|
||||||
await runProgressiveDiscovery();
|
|
||||||
console.log('[Startup] Initial discovery completed, processing continues in background');
|
|
||||||
|
|
||||||
// Log cache performance summary
|
// Log cache performance summary
|
||||||
const metrics = performanceMetrics.getMetrics();
|
const metrics = performanceMetrics.getMetrics();
|
||||||
if (metrics.cacheHits.midhash256 || metrics.cacheMisses.midhash256) {
|
if (metrics.cacheHits.midhash256 || metrics.cacheMisses.midhash256) {
|
||||||
@@ -292,32 +265,18 @@ process.on('SIGINT', async () => {
|
|||||||
console.log(`[Cache] midhash256 cache: ${hits} hits, ${misses} misses (${hitRate}% hit rate)`);
|
console.log(`[Cache] midhash256 cache: ${hits} hits, ${misses} misses (${hitRate}% hit rate)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup file watching for changes
|
// Connect to meta-core for file events via SSE (Architecture V3)
|
||||||
console.log('[Startup] Setting up file watching...');
|
console.log(`[Startup] Connecting to meta-core at ${config.META_CORE_URL}...`);
|
||||||
folderWatcher.watch(folderList, {
|
eventSubscriber = new EventSubscriber({
|
||||||
onAdd: (filePath) => pipeline.handleFileAdded(filePath),
|
metaCoreUrl: config.META_CORE_URL,
|
||||||
onChange: (filePath) => pipeline.handleFileChanged(filePath),
|
pipeline: pipeline,
|
||||||
onUnlink: (filePath) => pipeline.handleFileDeleted(filePath)
|
requestInitialScan: true, // Request initial scan from meta-core
|
||||||
}, {
|
reconnectDelayMs: 5000,
|
||||||
interval: config.WATCH_FOLDER_POOLING_INTERVAL_MS || 30000,
|
|
||||||
stabilityThreshold: 30000,
|
|
||||||
pollInterval: 5000
|
|
||||||
});
|
});
|
||||||
console.log('[Startup] File watching active');
|
await eventSubscriber.start();
|
||||||
|
console.log('[Startup] EventSubscriber connected to meta-core');
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Startup] Error during initial discovery:', error);
|
console.error('[Startup] Error during startup:', error);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// Start PollingWatcher for path-specific polling (if configured)
|
|
||||||
if (config.POLLING_PATHS && config.POLLING_PATHS.length > 0) {
|
|
||||||
console.log(`PollingWatcher: ${config.POLLING_PATHS.length} paths configured`);
|
|
||||||
const pollingWatcher = new PollingWatcher(fileProcessor, config.POLLING_PATHS);
|
|
||||||
pollingWatcher.start().then(() => {
|
|
||||||
console.log('PollingWatcher started successfully');
|
|
||||||
}).catch((error) => {
|
|
||||||
console.error('Error starting PollingWatcher:', error);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
console.log('PollingWatcher: No polling paths configured (skipped)');
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -527,19 +527,38 @@ export class StreamingPipeline {
|
|||||||
(this.config as any).containerPluginScheduler = scheduler;
|
(this.config as any).containerPluginScheduler = scheduler;
|
||||||
|
|
||||||
// Listen for file:complete events to transition files to done state
|
// Listen for file:complete events to transition files to done state
|
||||||
scheduler.on('file:complete', ({ fileHash, filePath }) => {
|
scheduler.on('file:complete', async ({ fileHash, filePath }) => {
|
||||||
console.log(`[Pipeline] Container plugins complete for ${filePath} (${fileHash})`);
|
console.log(`[Pipeline] Container plugins complete for ${filePath} (${fileHash})`);
|
||||||
|
|
||||||
// Get virtual path from metadata if available
|
// Fetch metadata from Redis (container plugins write directly to Redis via /meta API)
|
||||||
let virtualPath: string | undefined;
|
let virtualPath: string | undefined;
|
||||||
const metadata = this.config.fileProcessor?.getDatabase().get(filePath);
|
let metadata: Record<string, any> | undefined;
|
||||||
if (metadata) {
|
|
||||||
try {
|
try {
|
||||||
virtualPath = this.config.metaDataToFolderStruct.renamingRule(metadata as any, filePath);
|
if (this.config.kvClient) {
|
||||||
} catch {
|
// Get fresh metadata from Redis
|
||||||
// Renaming rule may fail for incomplete metadata
|
const redisMetadata = await this.config.kvClient.getMetadataFlat(fileHash);
|
||||||
|
if (redisMetadata && Object.keys(redisMetadata).length > 0) {
|
||||||
|
metadata = redisMetadata;
|
||||||
|
// Update local database to stay in sync
|
||||||
|
if (this.config.fileProcessor?.getDatabase()) {
|
||||||
|
this.config.fileProcessor.getDatabase().set(filePath, metadata);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to local database if Redis fetch failed
|
||||||
|
if (!metadata) {
|
||||||
|
metadata = this.config.fileProcessor?.getDatabase().get(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metadata) {
|
||||||
|
virtualPath = this.config.metaDataToFolderStruct.renamingRule(metadata as any, filePath);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Renaming rule may fail for incomplete metadata
|
||||||
|
console.warn(`[Pipeline] Failed to compute virtualPath for ${filePath}:`, error);
|
||||||
|
}
|
||||||
|
|
||||||
// Transition file to done state
|
// Transition file to done state
|
||||||
this.config.stateManager.completeHashProcessing(filePath, fileHash, virtualPath);
|
this.config.stateManager.completeHashProcessing(filePath, fileHash, virtualPath);
|
||||||
|
|||||||
@@ -1,140 +0,0 @@
|
|||||||
# @metazla/meta-editor
|
|
||||||
|
|
||||||
User-friendly metadata editor for MetaMesh. Search and edit file metadata directly through a web interface.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- **Search**: Find files by title, filename, or hash ID
|
|
||||||
- **Edit**: Modify all metadata fields including title, season, episode, year, Jellyfin metadata, and more
|
|
||||||
- **Batch Operations**: Update multiple files simultaneously
|
|
||||||
- **Real-time**: Direct integration with etcd via REST API
|
|
||||||
- **User-friendly**: Clean, intuitive interface with form validation
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
- Node.js 21.6.2+
|
|
||||||
- pnpm 10.19.0+
|
|
||||||
- Meta-mesh API server running (provides the `/api/metadata/*` endpoints)
|
|
||||||
|
|
||||||
### Running Locally
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# From the root directory
|
|
||||||
pnpm run start:editor
|
|
||||||
|
|
||||||
# Or from the package directory
|
|
||||||
cd packages/meta-editor
|
|
||||||
pnpm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
The editor will be available at `http://localhost:5173` with API proxying to `http://localhost:3000`.
|
|
||||||
|
|
||||||
### Building
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# From the root directory
|
|
||||||
pnpm run build:editor
|
|
||||||
|
|
||||||
# Or from the package directory
|
|
||||||
cd packages/meta-editor
|
|
||||||
pnpm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
The built files will be in `dist/` directory.
|
|
||||||
|
|
||||||
## Docker Deployment
|
|
||||||
|
|
||||||
In the Docker development environment, the editor is automatically available at:
|
|
||||||
|
|
||||||
```
|
|
||||||
http://localhost/editor
|
|
||||||
```
|
|
||||||
|
|
||||||
The nginx configuration serves the built files from `/app/packages/meta-editor/dist/`.
|
|
||||||
|
|
||||||
## API Integration
|
|
||||||
|
|
||||||
The editor communicates with the UnifiedAPIServer (meta-mesh) via REST API:
|
|
||||||
|
|
||||||
- `POST /api/metadata/search` - Search for files
|
|
||||||
- `GET /api/metadata/:hashId` - Get file metadata
|
|
||||||
- `PUT /api/metadata/:hashId` - Update complete metadata
|
|
||||||
- `PUT /api/metadata/:hashId/property` - Update specific property
|
|
||||||
- `POST /api/metadata/batch` - Batch update multiple files
|
|
||||||
|
|
||||||
See `src/api/metadataApi.ts` for the API client implementation.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
### Searching for Files
|
|
||||||
|
|
||||||
1. Select search type: "Search by Title/Filename" or "Search by Hash ID"
|
|
||||||
2. Enter your search query
|
|
||||||
3. Click "Search"
|
|
||||||
4. Results will appear in the left sidebar
|
|
||||||
|
|
||||||
### Editing Metadata
|
|
||||||
|
|
||||||
1. Select a file from the search results
|
|
||||||
2. Modify any fields in the editor form
|
|
||||||
3. Click "Save Changes" to persist updates
|
|
||||||
4. Use "Reset" to discard changes
|
|
||||||
|
|
||||||
### Supported Metadata Fields
|
|
||||||
|
|
||||||
**Basic Information:**
|
|
||||||
- Title, Original Title, Show Title
|
|
||||||
- Video Type (Movie, TV, Anime, Other)
|
|
||||||
- Year, Season, Episode
|
|
||||||
|
|
||||||
**Jellyfin Metadata:**
|
|
||||||
- Plot, IMDb ID, TMDb ID, AniDB ID
|
|
||||||
- Director, Studio, Rating, Runtime
|
|
||||||
- Genres, Premiered Date, Release Date
|
|
||||||
|
|
||||||
**File Information:**
|
|
||||||
- Filename, File Size, MIME Type
|
|
||||||
- Hash values (SHA-256, MD5, etc.) - read-only
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
packages/meta-editor/
|
|
||||||
├── src/
|
|
||||||
│ ├── api/ # API client for metadata operations
|
|
||||||
│ ├── components/ # React components
|
|
||||||
│ │ ├── SearchBar.tsx # Search interface
|
|
||||||
│ │ ├── FileList.tsx # Search results list
|
|
||||||
│ │ └── MetadataEditor.tsx # Main editing form
|
|
||||||
│ ├── types/ # TypeScript type definitions
|
|
||||||
│ ├── utils/ # Utility functions
|
|
||||||
│ ├── App.tsx # Main application component
|
|
||||||
│ ├── main.tsx # Entry point
|
|
||||||
│ └── index.css # Global styles
|
|
||||||
├── index.html
|
|
||||||
├── vite.config.ts
|
|
||||||
├── tsconfig.json
|
|
||||||
└── package.json
|
|
||||||
```
|
|
||||||
|
|
||||||
## Technology Stack
|
|
||||||
|
|
||||||
- **React 18** - UI framework
|
|
||||||
- **TypeScript 5.7** - Type safety
|
|
||||||
- **Vite 6** - Build tool and dev server
|
|
||||||
- **Native CSS** - Styling (no CSS framework)
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
When adding new features:
|
|
||||||
|
|
||||||
1. Add type definitions to `src/types/index.ts`
|
|
||||||
2. Update API client in `src/api/metadataApi.ts`
|
|
||||||
3. Create/update components as needed
|
|
||||||
4. Update this README with new features
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
Part of the MetaMesh project.
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import js from '@eslint/js'
|
|
||||||
import globals from 'globals'
|
|
||||||
import reactHooks from 'eslint-plugin-react-hooks'
|
|
||||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
|
||||||
import tseslint from 'typescript-eslint'
|
|
||||||
|
|
||||||
export default tseslint.config(
|
|
||||||
{ ignores: ['dist'] },
|
|
||||||
{
|
|
||||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
languageOptions: {
|
|
||||||
ecmaVersion: 2020,
|
|
||||||
globals: globals.browser,
|
|
||||||
},
|
|
||||||
plugins: {
|
|
||||||
'react-hooks': reactHooks,
|
|
||||||
'react-refresh': reactRefresh,
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
...reactHooks.configs.recommended.rules,
|
|
||||||
'react-refresh/only-export-components': [
|
|
||||||
'warn',
|
|
||||||
{ allowConstantExport: true },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>MetaMesh Metadata Editor</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
-1570
File diff suppressed because it is too large
Load Diff
@@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@meta-sort/editor",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "tsc -b && vite build",
|
|
||||||
"preview": "vite preview"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"iso-639-3": "^3.0.1",
|
|
||||||
"react": "^18.2.0",
|
|
||||||
"react-dom": "^18.2.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/react": "^18.2.48",
|
|
||||||
"@types/react-dom": "^18.2.18",
|
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
|
||||||
"typescript": "^5.3.3",
|
|
||||||
"vite": "^5.0.12"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
.app {
|
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header {
|
|
||||||
padding: 2rem;
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
background-color: rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header h1 {
|
|
||||||
margin: 0 0 0.5rem 0;
|
|
||||||
color: #646cff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-description {
|
|
||||||
margin: 0;
|
|
||||||
color: rgba(255, 255, 255, 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-main {
|
|
||||||
flex: 1;
|
|
||||||
padding: 2rem;
|
|
||||||
max-width: 1400px;
|
|
||||||
width: 100%;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-error {
|
|
||||||
padding: 1rem;
|
|
||||||
background-color: rgba(255, 0, 0, 0.1);
|
|
||||||
border: 1px solid rgba(255, 0, 0, 0.3);
|
|
||||||
border-radius: 8px;
|
|
||||||
color: #ff6b6b;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-loading {
|
|
||||||
text-align: center;
|
|
||||||
padding: 3rem 2rem;
|
|
||||||
color: rgba(255, 255, 255, 0.7);
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-welcome {
|
|
||||||
text-align: center;
|
|
||||||
padding: 4rem 2rem;
|
|
||||||
color: rgba(255, 255, 255, 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-welcome h2 {
|
|
||||||
margin: 0 0 1rem 0;
|
|
||||||
color: rgba(255, 255, 255, 0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-welcome-features {
|
|
||||||
margin-top: 1rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: rgba(255, 255, 255, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-kv-browser {
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
text-align: left;
|
|
||||||
max-width: 900px;
|
|
||||||
margin-left: auto;
|
|
||||||
margin-right: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-content {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 350px 1fr;
|
|
||||||
gap: 1.5rem;
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar {
|
|
||||||
min-height: 400px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-editor {
|
|
||||||
min-height: 400px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-no-selection {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 100%;
|
|
||||||
min-height: 400px;
|
|
||||||
background-color: rgba(255, 255, 255, 0.05);
|
|
||||||
border-radius: 8px;
|
|
||||||
color: rgba(255, 255, 255, 0.5);
|
|
||||||
text-align: center;
|
|
||||||
padding: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-footer {
|
|
||||||
padding: 1.5rem 2rem;
|
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
text-align: center;
|
|
||||||
color: rgba(255, 255, 255, 0.5);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-footer a {
|
|
||||||
color: #646cff;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-footer a:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
.app-content {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar {
|
|
||||||
order: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-editor {
|
|
||||||
order: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.app-header,
|
|
||||||
.app-main,
|
|
||||||
.app-footer {
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-content {
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: light) {
|
|
||||||
.app-header {
|
|
||||||
border-bottom-color: rgba(0, 0, 0, 0.1);
|
|
||||||
background-color: rgba(0, 0, 0, 0.02);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-description {
|
|
||||||
color: rgba(0, 0, 0, 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-error {
|
|
||||||
background-color: rgba(255, 0, 0, 0.1);
|
|
||||||
border-color: rgba(255, 0, 0, 0.3);
|
|
||||||
color: #cc0000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-loading {
|
|
||||||
color: rgba(0, 0, 0, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-welcome {
|
|
||||||
color: rgba(0, 0, 0, 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-welcome h2 {
|
|
||||||
color: rgba(0, 0, 0, 0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-welcome-features {
|
|
||||||
color: rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-no-selection {
|
|
||||||
background-color: rgba(0, 0, 0, 0.02);
|
|
||||||
color: rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-footer {
|
|
||||||
border-top-color: rgba(0, 0, 0, 0.1);
|
|
||||||
color: rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { SearchBar } from './components/SearchBar';
|
|
||||||
import { FileList } from './components/FileList';
|
|
||||||
import { MetadataEditor } from './components/MetadataEditor';
|
|
||||||
import { BulkMetadataEditor } from './components/BulkMetadataEditor';
|
|
||||||
import { KVTreeView } from './components/KVTreeView';
|
|
||||||
import { MetadataAPI } from './api/metadataApi';
|
|
||||||
import { SearchResult } from './types';
|
|
||||||
import './App.css';
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
|
||||||
const [selectedHashId, setSelectedHashId] = useState<string | undefined>();
|
|
||||||
const [selectedHashIds, setSelectedHashIds] = useState<Set<string>>(new Set());
|
|
||||||
const [bulkEditMode, setBulkEditMode] = useState(false);
|
|
||||||
const [isSearching, setIsSearching] = useState(false);
|
|
||||||
const [isLoadingDirectHash, setIsLoadingDirectHash] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Extract hash from URL path (supports /editor/file/hash or /editor/?hash=...)
|
|
||||||
const getHashFromUrl = (): string | null => {
|
|
||||||
const path = window.location.pathname;
|
|
||||||
const fileMatch = path.match(/\/file\/([a-z0-9]+)$/i);
|
|
||||||
if (fileMatch) {
|
|
||||||
return fileMatch[1];
|
|
||||||
}
|
|
||||||
// Fallback to query param for backwards compatibility
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
return params.get('hash');
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update URL when file is selected
|
|
||||||
const updateUrlForFile = (hashId: string | undefined) => {
|
|
||||||
const basePath = window.location.pathname.replace(/\/file\/[a-z0-9]+$/i, '').replace(/\/$/, '');
|
|
||||||
if (hashId) {
|
|
||||||
const newPath = `${basePath}/file/${hashId}`;
|
|
||||||
window.history.pushState({ hashId }, '', newPath);
|
|
||||||
} else {
|
|
||||||
window.history.pushState({}, '', basePath || '/editor/');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check for hash parameter in URL on component mount
|
|
||||||
useEffect(() => {
|
|
||||||
const loadHashFromUrl = async () => {
|
|
||||||
const hashParam = getHashFromUrl();
|
|
||||||
|
|
||||||
if (hashParam) {
|
|
||||||
try {
|
|
||||||
setIsLoadingDirectHash(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
// Try to get metadata for this hash
|
|
||||||
const metadata = await MetadataAPI.getMetadata(hashParam);
|
|
||||||
|
|
||||||
// Create a search result from the metadata
|
|
||||||
const searchResult: SearchResult = {
|
|
||||||
hashId: hashParam,
|
|
||||||
metadata: metadata,
|
|
||||||
score: 1.0,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Set the search results and select this file
|
|
||||||
setSearchResults([searchResult]);
|
|
||||||
setSelectedHashId(hashParam);
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(`Failed to load file with hash ${hashParam}: ${err.message}`);
|
|
||||||
setSearchResults([]);
|
|
||||||
setSelectedHashId(undefined);
|
|
||||||
} finally {
|
|
||||||
setIsLoadingDirectHash(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadHashFromUrl();
|
|
||||||
|
|
||||||
// Handle browser back/forward
|
|
||||||
const handlePopState = (event: PopStateEvent) => {
|
|
||||||
const hashId = event.state?.hashId || getHashFromUrl();
|
|
||||||
if (hashId) {
|
|
||||||
setSelectedHashId(hashId);
|
|
||||||
} else {
|
|
||||||
setSelectedHashId(undefined);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener('popstate', handlePopState);
|
|
||||||
return () => window.removeEventListener('popstate', handlePopState);
|
|
||||||
}, []); // Empty dependency array means this runs once on mount
|
|
||||||
|
|
||||||
const handleSearch = async (query: string) => {
|
|
||||||
try {
|
|
||||||
setIsSearching(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
// Unified search - searches across all fields
|
|
||||||
const response = await MetadataAPI.search({ query });
|
|
||||||
|
|
||||||
setSearchResults(response.results);
|
|
||||||
|
|
||||||
// Auto-select first result if available
|
|
||||||
if (response.results.length > 0) {
|
|
||||||
setSelectedHashId(response.results[0].hashId);
|
|
||||||
} else {
|
|
||||||
setSelectedHashId(undefined);
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err.message);
|
|
||||||
setSearchResults([]);
|
|
||||||
setSelectedHashId(undefined);
|
|
||||||
} finally {
|
|
||||||
setIsSearching(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelectFile = (hashId: string) => {
|
|
||||||
if (!bulkEditMode) {
|
|
||||||
setSelectedHashId(hashId);
|
|
||||||
setSelectedHashIds(new Set());
|
|
||||||
updateUrlForFile(hashId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToggleSelection = (hashId: string) => {
|
|
||||||
const newSelection = new Set(selectedHashIds);
|
|
||||||
if (newSelection.has(hashId)) {
|
|
||||||
newSelection.delete(hashId);
|
|
||||||
} else {
|
|
||||||
newSelection.add(hashId);
|
|
||||||
}
|
|
||||||
setSelectedHashIds(newSelection);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelectAll = () => {
|
|
||||||
const allHashIds = new Set(searchResults.map(r => r.hashId));
|
|
||||||
setSelectedHashIds(allHashIds);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClearSelection = () => {
|
|
||||||
setSelectedHashIds(new Set());
|
|
||||||
setBulkEditMode(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBulkEditMode = (enabled: boolean) => {
|
|
||||||
setBulkEditMode(enabled);
|
|
||||||
if (enabled) {
|
|
||||||
setSelectedHashId(undefined);
|
|
||||||
} else {
|
|
||||||
setSelectedHashIds(new Set());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMetadataSaved = () => {
|
|
||||||
// Optionally refresh the search results to reflect changes
|
|
||||||
console.log('Metadata saved successfully');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBulkMetadataSaved = () => {
|
|
||||||
console.log('Bulk metadata saved successfully');
|
|
||||||
handleClearSelection();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="app">
|
|
||||||
<header className="app-header">
|
|
||||||
<h1>MetaMesh Metadata Editor</h1>
|
|
||||||
<p className="app-description">
|
|
||||||
Search and edit metadata for your media files
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main className="app-main">
|
|
||||||
<SearchBar onSearch={handleSearch} isLoading={isSearching} />
|
|
||||||
|
|
||||||
{error && <div className="app-error">{error}</div>}
|
|
||||||
|
|
||||||
{isLoadingDirectHash && (
|
|
||||||
<div className="app-loading">Loading file metadata...</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isSearching && !isLoadingDirectHash && searchResults.length === 0 && !error && (
|
|
||||||
<div className="app-welcome">
|
|
||||||
<h2>Welcome to MetaMesh Metadata Editor</h2>
|
|
||||||
<p>Search for files by title, filename, file path, or hash ID to get started.</p>
|
|
||||||
<p className="app-welcome-features">
|
|
||||||
Unified search across all fields | Single file editing | Bulk editing | Direct access via URL
|
|
||||||
</p>
|
|
||||||
<div className="app-kv-browser">
|
|
||||||
<KVTreeView />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{searchResults.length > 0 && (
|
|
||||||
<div className="app-content">
|
|
||||||
<div className="app-sidebar">
|
|
||||||
<FileList
|
|
||||||
results={searchResults}
|
|
||||||
selectedHashId={selectedHashId}
|
|
||||||
selectedHashIds={selectedHashIds}
|
|
||||||
bulkEditMode={bulkEditMode}
|
|
||||||
onSelectFile={handleSelectFile}
|
|
||||||
onToggleSelection={handleToggleSelection}
|
|
||||||
onSelectAll={handleSelectAll}
|
|
||||||
onClearSelection={handleClearSelection}
|
|
||||||
onBulkEditMode={handleBulkEditMode}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="app-editor">
|
|
||||||
{bulkEditMode && selectedHashIds.size > 0 ? (
|
|
||||||
<BulkMetadataEditor
|
|
||||||
hashIds={Array.from(selectedHashIds)}
|
|
||||||
onSaved={handleBulkMetadataSaved}
|
|
||||||
onCancel={handleClearSelection}
|
|
||||||
/>
|
|
||||||
) : selectedHashId ? (
|
|
||||||
<MetadataEditor
|
|
||||||
key={selectedHashId}
|
|
||||||
hashId={selectedHashId}
|
|
||||||
onSaved={handleMetadataSaved}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="app-no-selection">
|
|
||||||
{bulkEditMode
|
|
||||||
? 'Select files from the list to bulk edit metadata'
|
|
||||||
: 'Select a file from the list to edit its metadata'}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer className="app-footer">
|
|
||||||
<p>
|
|
||||||
MetaMesh Metadata Editor | Part of{' '}
|
|
||||||
<a href="/" target="_blank">
|
|
||||||
MetaMesh
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App;
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
import {
|
|
||||||
FileMetadata,
|
|
||||||
SearchResponse,
|
|
||||||
UpdateResult,
|
|
||||||
BatchUpdateResponse,
|
|
||||||
} from '../types';
|
|
||||||
|
|
||||||
const API_BASE = '/api/metadata';
|
|
||||||
|
|
||||||
export class MetadataAPI {
|
|
||||||
/**
|
|
||||||
* Search for files by query, hashId, or property
|
|
||||||
*/
|
|
||||||
static async search(params: {
|
|
||||||
query?: string;
|
|
||||||
hashId?: string;
|
|
||||||
property?: string;
|
|
||||||
propertyValue?: string;
|
|
||||||
limit?: number;
|
|
||||||
}): Promise<SearchResponse> {
|
|
||||||
const response = await fetch(`${API_BASE}/search`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(params),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json();
|
|
||||||
throw new Error(error.error || 'Search failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get metadata for a specific file by hash ID
|
|
||||||
*/
|
|
||||||
static async getMetadata(hashId: string): Promise<FileMetadata> {
|
|
||||||
const response = await fetch(`${API_BASE}/${hashId}`);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json();
|
|
||||||
throw new Error(error.error || 'Failed to get metadata');
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update complete metadata for a file
|
|
||||||
*/
|
|
||||||
static async updateMetadata(
|
|
||||||
hashId: string,
|
|
||||||
metadata: FileMetadata
|
|
||||||
): Promise<UpdateResult> {
|
|
||||||
const response = await fetch(`${API_BASE}/${hashId}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(metadata),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json();
|
|
||||||
throw new Error(error.error || 'Failed to update metadata');
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update a specific property for a file
|
|
||||||
*/
|
|
||||||
static async updateProperty(
|
|
||||||
hashId: string,
|
|
||||||
property: string,
|
|
||||||
value: string
|
|
||||||
): Promise<UpdateResult> {
|
|
||||||
const response = await fetch(`${API_BASE}/${hashId}/property`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ property, value }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json();
|
|
||||||
throw new Error(error.error || 'Failed to update property');
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Batch update multiple files
|
|
||||||
*/
|
|
||||||
static async batchUpdate(
|
|
||||||
updates: Array<{
|
|
||||||
hashId: string;
|
|
||||||
metadata?: FileMetadata;
|
|
||||||
properties?: Record<string, string>;
|
|
||||||
}>
|
|
||||||
): Promise<BatchUpdateResponse> {
|
|
||||||
const response = await fetch(`${API_BASE}/batch`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ updates }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json();
|
|
||||||
throw new Error(error.error || 'Batch update failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete metadata for a file
|
|
||||||
*/
|
|
||||||
static async deleteMetadata(hashId: string): Promise<void> {
|
|
||||||
const response = await fetch(`${API_BASE}/${hashId}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json();
|
|
||||||
throw new Error(error.error || 'Failed to delete metadata');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all hash IDs
|
|
||||||
*/
|
|
||||||
static async getAllHashIds(): Promise<{ hashIds: string[]; count: number }> {
|
|
||||||
const response = await fetch(`${API_BASE}/hash-ids`);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json();
|
|
||||||
throw new Error(error.error || 'Failed to get hash IDs');
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,382 +0,0 @@
|
|||||||
.bulk-metadata-editor {
|
|
||||||
background-color: rgba(255, 255, 255, 0.05);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bulk-metadata-editor-header {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
padding-bottom: 1rem;
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bulk-metadata-editor-header h2 {
|
|
||||||
margin: 0 0 0.5rem 0;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bulk-info {
|
|
||||||
margin: 0 0 1rem 0;
|
|
||||||
color: rgba(255, 255, 255, 0.6);
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bulk-metadata-editor-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bulk-metadata-editor-actions button.primary {
|
|
||||||
background-color: #646cff;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bulk-metadata-editor-actions button.primary:hover:not(:disabled) {
|
|
||||||
background-color: #535bf2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bulk-metadata-editor-message {
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bulk-metadata-editor-message.success {
|
|
||||||
background-color: rgba(0, 200, 0, 0.1);
|
|
||||||
border: 1px solid rgba(0, 200, 0, 0.3);
|
|
||||||
color: #00ff00;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bulk-metadata-editor-message.error {
|
|
||||||
background-color: rgba(255, 0, 0, 0.1);
|
|
||||||
border: 1px solid rgba(255, 0, 0, 0.3);
|
|
||||||
color: #ff6b6b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bulk-metadata-editor-form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bulk-metadata-section {
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
border-left: 4px solid #646cff;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 1.5rem;
|
|
||||||
background-color: rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bulk-metadata-section-header {
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
padding-bottom: 0.75rem;
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bulk-metadata-section-header h3 {
|
|
||||||
margin: 0 0 0.25rem 0;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
color: #646cff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-hint {
|
|
||||||
margin: 0.25rem 0 0 0;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: rgba(255, 255, 255, 0.5);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-hint-bulk {
|
|
||||||
margin: 0.5rem 0 0 0;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: rgba(255, 255, 255, 0.4);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Plugin-based bulk fields */
|
|
||||||
.bulk-fields-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bulk-field-wrapper {
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 0.75rem;
|
|
||||||
background-color: rgba(0, 0, 0, 0.1);
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bulk-field-wrapper.has-value {
|
|
||||||
border-color: rgba(100, 108, 255, 0.3);
|
|
||||||
background-color: rgba(100, 108, 255, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bulk-field-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-key {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: rgba(255, 255, 255, 0.6);
|
|
||||||
text-transform: lowercase;
|
|
||||||
font-family: 'Courier New', monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bulk-field-wrapper.has-value .field-key {
|
|
||||||
color: #646cff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bulk-field-input {
|
|
||||||
/* Plugin component will render inside this */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Legacy bulk fields (for compatibility) */
|
|
||||||
.bulk-metadata-field {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bulk-metadata-field:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-header label {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: rgba(255, 255, 255, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
.clear-field-btn {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
padding: 0;
|
|
||||||
border: 1px solid rgba(255, 0, 0, 0.3);
|
|
||||||
background-color: rgba(255, 0, 0, 0.1);
|
|
||||||
color: #ff6b6b;
|
|
||||||
border-radius: 50%;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 16px;
|
|
||||||
line-height: 1;
|
|
||||||
transition: all 0.2s;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.clear-field-btn:hover {
|
|
||||||
background-color: rgba(255, 0, 0, 0.2);
|
|
||||||
border-color: rgba(255, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bulk-metadata-field input,
|
|
||||||
.bulk-metadata-field textarea,
|
|
||||||
.bulk-metadata-field select {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bulk-metadata-field-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
||||||
gap: 1rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bulk-metadata-field-row:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Modified fields summary */
|
|
||||||
.modified-fields {
|
|
||||||
background-color: rgba(100, 108, 255, 0.05);
|
|
||||||
border-color: rgba(100, 108, 255, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modified-fields h3 {
|
|
||||||
color: #646cff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modified-fields-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modified-field-item {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.75rem;
|
|
||||||
background-color: rgba(100, 108, 255, 0.05);
|
|
||||||
border: 1px solid rgba(100, 108, 255, 0.2);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-name {
|
|
||||||
font-weight: 600;
|
|
||||||
color: #646cff;
|
|
||||||
min-width: 150px;
|
|
||||||
font-family: 'Courier New', monospace;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-value {
|
|
||||||
flex: 1;
|
|
||||||
word-break: break-word;
|
|
||||||
color: rgba(255, 255, 255, 0.8);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.remove-field-btn {
|
|
||||||
padding: 0.25rem 0.75rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
background-color: rgba(255, 0, 0, 0.1);
|
|
||||||
border: 1px solid rgba(255, 0, 0, 0.3);
|
|
||||||
color: #ff6b6b;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.remove-field-btn:hover {
|
|
||||||
background-color: rgba(255, 0, 0, 0.2);
|
|
||||||
border-color: rgba(255, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.bulk-metadata-editor-header {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bulk-metadata-editor-actions {
|
|
||||||
width: 100%;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bulk-metadata-editor-actions button {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bulk-metadata-field-row {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modified-field-item {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-name {
|
|
||||||
min-width: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.remove-field-btn {
|
|
||||||
align-self: flex-end;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: light) {
|
|
||||||
.bulk-metadata-editor {
|
|
||||||
background-color: rgba(0, 0, 0, 0.02);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bulk-metadata-editor-header {
|
|
||||||
border-bottom-color: rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bulk-info {
|
|
||||||
color: rgba(0, 0, 0, 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bulk-metadata-section {
|
|
||||||
border-color: rgba(0, 0, 0, 0.1);
|
|
||||||
background-color: rgba(0, 0, 0, 0.02);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bulk-metadata-section-header {
|
|
||||||
border-bottom-color: rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-hint {
|
|
||||||
color: rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-hint-bulk {
|
|
||||||
color: rgba(0, 0, 0, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bulk-field-wrapper {
|
|
||||||
border-color: rgba(0, 0, 0, 0.1);
|
|
||||||
background-color: rgba(0, 0, 0, 0.02);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bulk-field-wrapper.has-value {
|
|
||||||
border-color: rgba(100, 108, 255, 0.3);
|
|
||||||
background-color: rgba(100, 108, 255, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-key {
|
|
||||||
color: rgba(0, 0, 0, 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bulk-field-wrapper.has-value .field-key {
|
|
||||||
color: #646cff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-header label {
|
|
||||||
color: rgba(0, 0, 0, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-value {
|
|
||||||
color: rgba(0, 0, 0, 0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bulk-metadata-editor-message.success {
|
|
||||||
background-color: rgba(0, 200, 0, 0.1);
|
|
||||||
border-color: rgba(0, 200, 0, 0.3);
|
|
||||||
color: #008800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bulk-metadata-editor-message.error {
|
|
||||||
background-color: rgba(255, 0, 0, 0.1);
|
|
||||||
border-color: rgba(255, 0, 0, 0.3);
|
|
||||||
color: #cc0000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modified-fields {
|
|
||||||
background-color: rgba(100, 108, 255, 0.05);
|
|
||||||
border-color: rgba(100, 108, 255, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modified-field-item {
|
|
||||||
background-color: rgba(100, 108, 255, 0.05);
|
|
||||||
border-color: rgba(100, 108, 255, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.clear-field-btn {
|
|
||||||
border-color: rgba(255, 0, 0, 0.3);
|
|
||||||
background-color: rgba(255, 0, 0, 0.1);
|
|
||||||
color: #cc0000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.clear-field-btn:hover {
|
|
||||||
background-color: rgba(255, 0, 0, 0.2);
|
|
||||||
border-color: rgba(255, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,280 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { FileMetadata, PluginSchema } from '../types';
|
|
||||||
import { MetadataAPI } from '../api/metadataApi';
|
|
||||||
import { DynamicFieldRenderer } from './DynamicFieldRenderer';
|
|
||||||
import './BulkMetadataEditor.css';
|
|
||||||
|
|
||||||
interface PluginsResponse {
|
|
||||||
plugins: PluginSchema[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BulkMetadataEditorProps {
|
|
||||||
hashIds: string[];
|
|
||||||
onSaved?: () => void;
|
|
||||||
onCancel?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BulkMetadataEditor({ hashIds, onSaved, onCancel }: BulkMetadataEditorProps) {
|
|
||||||
const [plugins, setPlugins] = useState<PluginSchema[]>([]);
|
|
||||||
const [commonValues, setCommonValues] = useState<Record<string, any>>({});
|
|
||||||
const [editedFields, setEditedFields] = useState<Partial<FileMetadata>>({});
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Load plugins and metadata on mount
|
|
||||||
useEffect(() => {
|
|
||||||
const loadData = async () => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
// Fetch plugins
|
|
||||||
const pluginsResponse = await fetch('/api/plugins');
|
|
||||||
if (pluginsResponse.ok) {
|
|
||||||
const data: PluginsResponse = await pluginsResponse.json();
|
|
||||||
// Sort by priority
|
|
||||||
const sorted = [...data.plugins].sort((a, b) =>
|
|
||||||
(a.priority ?? 100) - (b.priority ?? 100)
|
|
||||||
);
|
|
||||||
setPlugins(sorted);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load metadata for all selected files
|
|
||||||
const metadataPromises = hashIds.map((hashId) => MetadataAPI.getMetadata(hashId));
|
|
||||||
const metadata = await Promise.all(metadataPromises);
|
|
||||||
|
|
||||||
// Calculate common values
|
|
||||||
const commons: Record<string, any> = {};
|
|
||||||
|
|
||||||
// Get all editable fields from all plugins
|
|
||||||
const allFields: string[] = [];
|
|
||||||
plugins.forEach((plugin) => {
|
|
||||||
if (plugin.metadataSchema) {
|
|
||||||
Object.entries(plugin.metadataSchema).forEach(([key, field]) => {
|
|
||||||
if (!field.readonly) {
|
|
||||||
allFields.push(key);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
allFields.forEach((field) => {
|
|
||||||
const values = metadata.map((m) => m[field as keyof FileMetadata]);
|
|
||||||
|
|
||||||
// Filter out undefined and null values
|
|
||||||
const definedValues = values.filter((v) => v !== undefined && v !== null);
|
|
||||||
|
|
||||||
if (definedValues.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Safely stringify values
|
|
||||||
const uniqueValues = Array.from(
|
|
||||||
new Set(
|
|
||||||
definedValues.map((v) => {
|
|
||||||
try {
|
|
||||||
return JSON.stringify(v);
|
|
||||||
} catch {
|
|
||||||
return String(v);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (uniqueValues.length === 1) {
|
|
||||||
try {
|
|
||||||
commons[field] = JSON.parse(uniqueValues[0]);
|
|
||||||
} catch {
|
|
||||||
commons[field] = uniqueValues[0];
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
commons[field] = '*';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
setCommonValues(commons);
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(`Failed to load data: ${err.message}`);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadData();
|
|
||||||
}, [hashIds]);
|
|
||||||
|
|
||||||
const handleFieldChange = (field: string, value: any) => {
|
|
||||||
setEditedFields({
|
|
||||||
...editedFields,
|
|
||||||
[field]: value,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClearField = (field: string) => {
|
|
||||||
const newFields = { ...editedFields };
|
|
||||||
delete newFields[field];
|
|
||||||
setEditedFields(newFields);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
if (Object.keys(editedFields).length === 0) {
|
|
||||||
setError('No fields have been edited');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setIsSaving(true);
|
|
||||||
setError(null);
|
|
||||||
setSuccessMessage(null);
|
|
||||||
|
|
||||||
// Build batch update payload
|
|
||||||
const updates = hashIds.map((hashId) => ({
|
|
||||||
hashId,
|
|
||||||
metadata: editedFields,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const result = await MetadataAPI.batchUpdate(updates);
|
|
||||||
|
|
||||||
if (result.errors > 0) {
|
|
||||||
setError(
|
|
||||||
`Updated ${result.success} files successfully, but ${result.errors} failed. Check console for details.`
|
|
||||||
);
|
|
||||||
console.error('Batch update errors:', result.results.filter((r) => r.error));
|
|
||||||
} else {
|
|
||||||
setSuccessMessage(`Successfully updated ${result.success} files!`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (onSaved) {
|
|
||||||
onSaved();
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(() => setSuccessMessage(null), 3000);
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err.message);
|
|
||||||
} finally {
|
|
||||||
setIsSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasChanges = Object.keys(editedFields).length > 0;
|
|
||||||
|
|
||||||
// Filter to only editable plugins (those with non-readonly fields)
|
|
||||||
const editablePlugins = plugins.filter((plugin) => {
|
|
||||||
if (!plugin.metadataSchema) return false;
|
|
||||||
return Object.values(plugin.metadataSchema).some((field) => !field.readonly);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="bulk-metadata-editor">
|
|
||||||
<div className="bulk-metadata-editor-loading">
|
|
||||||
Loading metadata for {hashIds.length} files...
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build display metadata (edited values or common values)
|
|
||||||
const displayMetadata: Record<string, any> = { ...commonValues, ...editedFields };
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bulk-metadata-editor">
|
|
||||||
<div className="bulk-metadata-editor-header">
|
|
||||||
<h2>Bulk Edit Metadata</h2>
|
|
||||||
<p className="bulk-info">
|
|
||||||
Editing {hashIds.length} file{hashIds.length !== 1 ? 's' : ''} - Only modified fields will
|
|
||||||
be updated. Fields showing "*" have different values across files.
|
|
||||||
</p>
|
|
||||||
<div className="bulk-metadata-editor-actions">
|
|
||||||
{onCancel && (
|
|
||||||
<button onClick={onCancel} disabled={isSaving}>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button onClick={handleSave} disabled={isSaving || !hasChanges} className="primary">
|
|
||||||
{isSaving ? 'Saving...' : `Update ${hashIds.length} File${hashIds.length !== 1 ? 's' : ''}`}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{successMessage && (
|
|
||||||
<div className="bulk-metadata-editor-message success">{successMessage}</div>
|
|
||||||
)}
|
|
||||||
{error && <div className="bulk-metadata-editor-message error">{error}</div>}
|
|
||||||
|
|
||||||
<div className="bulk-metadata-editor-form">
|
|
||||||
{editablePlugins.map((plugin) => {
|
|
||||||
if (!plugin.metadataSchema) return null;
|
|
||||||
|
|
||||||
// Get editable fields for this plugin
|
|
||||||
const editableFields = Object.entries(plugin.metadataSchema).filter(
|
|
||||||
([, field]) => !field.readonly
|
|
||||||
);
|
|
||||||
|
|
||||||
if (editableFields.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
key={plugin.id}
|
|
||||||
className="bulk-metadata-section"
|
|
||||||
style={{ borderLeftColor: plugin.color || '#646cff' }}
|
|
||||||
>
|
|
||||||
<div className="bulk-metadata-section-header">
|
|
||||||
<h3>{plugin.name}</h3>
|
|
||||||
{plugin.description && (
|
|
||||||
<p className="section-hint">{plugin.description}</p>
|
|
||||||
)}
|
|
||||||
<p className="section-hint-bulk">
|
|
||||||
Common values shown. "*" indicates different values. Edit to update all files.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="plugin-fields">
|
|
||||||
{editableFields.map(([key, field]) => (
|
|
||||||
<DynamicFieldRenderer
|
|
||||||
key={key}
|
|
||||||
fieldKey={key}
|
|
||||||
schema={field}
|
|
||||||
metadata={displayMetadata as FileMetadata}
|
|
||||||
onChange={handleFieldChange}
|
|
||||||
readonly={false}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Modified Fields Summary */}
|
|
||||||
{hasChanges && (
|
|
||||||
<section className="bulk-metadata-section modified-fields">
|
|
||||||
<h3>Fields to be Updated ({Object.keys(editedFields).length})</h3>
|
|
||||||
<div className="modified-fields-list">
|
|
||||||
{Object.entries(editedFields).map(([key, value]) => (
|
|
||||||
<div key={key} className="modified-field-item">
|
|
||||||
<span className="field-name">{key}:</span>
|
|
||||||
<span className="field-value">
|
|
||||||
{Array.isArray(value)
|
|
||||||
? value.join(', ')
|
|
||||||
: typeof value === 'object'
|
|
||||||
? JSON.stringify(value)
|
|
||||||
: String(value || '(empty)')}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
className="remove-field-btn"
|
|
||||||
onClick={() => handleClearField(key)}
|
|
||||||
title="Remove this field"
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,297 +0,0 @@
|
|||||||
.dynamic-field {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dynamic-field:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dynamic-field label {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: rgba(255, 255, 255, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dynamic-field input[type="text"],
|
|
||||||
.dynamic-field input[type="number"],
|
|
||||||
.dynamic-field select,
|
|
||||||
.dynamic-field textarea {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
background: rgba(0, 0, 0, 0.3);
|
|
||||||
color: #fff;
|
|
||||||
transition: border-color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dynamic-field input:focus,
|
|
||||||
.dynamic-field select:focus,
|
|
||||||
.dynamic-field textarea:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #646cff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dynamic-field input:disabled,
|
|
||||||
.dynamic-field select:disabled,
|
|
||||||
.dynamic-field textarea:disabled {
|
|
||||||
background: rgba(0, 0, 0, 0.2);
|
|
||||||
color: rgba(255, 255, 255, 0.5);
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dynamic-field textarea {
|
|
||||||
resize: vertical;
|
|
||||||
min-height: 80px;
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-hint {
|
|
||||||
display: block;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: rgba(255, 255, 255, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-label {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: normal;
|
|
||||||
color: rgba(255, 255, 255, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-label input[type="checkbox"] {
|
|
||||||
width: 1.1rem;
|
|
||||||
height: 1.1rem;
|
|
||||||
cursor: pointer;
|
|
||||||
accent-color: #646cff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-label input:disabled {
|
|
||||||
cursor: not-allowed;
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.readonly-value {
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
background: rgba(0, 0, 0, 0.2);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 4px;
|
|
||||||
color: rgba(255, 255, 255, 0.7);
|
|
||||||
font-family: 'Courier New', monospace;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-link {
|
|
||||||
color: #646cff;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-link:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* CID field styling */
|
|
||||||
.cid-field .cid-value-container {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cid-field .cid-value {
|
|
||||||
flex: 1;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
background: rgba(0, 0, 0, 0.3);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 4px;
|
|
||||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Courier New', monospace;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: #a0d8ef;
|
|
||||||
cursor: pointer;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cid-field .cid-value:hover {
|
|
||||||
background: rgba(0, 0, 0, 0.4);
|
|
||||||
border-color: #646cff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cid-field .cid-open-button {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
background: #646cff;
|
|
||||||
color: #fff;
|
|
||||||
border-radius: 4px;
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 500;
|
|
||||||
white-space: nowrap;
|
|
||||||
transition: background 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cid-field .cid-open-button:hover {
|
|
||||||
background: #535bf2;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* JSON field styling */
|
|
||||||
.json-field .json-viewer {
|
|
||||||
background: rgba(0, 0, 0, 0.4);
|
|
||||||
color: #d4d4d4;
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Courier New', monospace;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
overflow-x: auto;
|
|
||||||
max-height: 300px;
|
|
||||||
overflow-y: auto;
|
|
||||||
margin: 0;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-all;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Language string field styling */
|
|
||||||
.language-string-field .language-entries {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.language-string-field .language-entry {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.language-string-field .lang-input {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.language-string-field .lang-code {
|
|
||||||
display: inline-block;
|
|
||||||
width: 50px;
|
|
||||||
padding: 0.4rem 0.5rem;
|
|
||||||
background: rgba(100, 108, 255, 0.2);
|
|
||||||
border: 1px solid rgba(100, 108, 255, 0.3);
|
|
||||||
border-radius: 4px;
|
|
||||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Courier New', monospace;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: #a0d8ef;
|
|
||||||
text-align: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.language-string-field .lang-text {
|
|
||||||
flex: 1;
|
|
||||||
padding: 0.4rem 0.75rem;
|
|
||||||
background: rgba(0, 0, 0, 0.2);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 4px;
|
|
||||||
color: rgba(255, 255, 255, 0.8);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.language-string-field .lang-remove-btn {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: rgba(255, 100, 100, 0.2);
|
|
||||||
border: 1px solid rgba(255, 100, 100, 0.3);
|
|
||||||
border-radius: 4px;
|
|
||||||
color: #ff6b6b;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.2s, border-color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.language-string-field .lang-remove-btn:hover {
|
|
||||||
background: rgba(255, 100, 100, 0.3);
|
|
||||||
border-color: rgba(255, 100, 100, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.language-string-field .lang-add-btn {
|
|
||||||
align-self: flex-start;
|
|
||||||
padding: 0.4rem 0.75rem;
|
|
||||||
background: rgba(100, 108, 255, 0.2);
|
|
||||||
border: 1px solid rgba(100, 108, 255, 0.3);
|
|
||||||
border-radius: 4px;
|
|
||||||
color: #646cff;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.2s, border-color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.language-string-field .lang-add-btn:hover {
|
|
||||||
background: rgba(100, 108, 255, 0.3);
|
|
||||||
border-color: rgba(100, 108, 255, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Light theme */
|
|
||||||
@media (prefers-color-scheme: light) {
|
|
||||||
.dynamic-field label {
|
|
||||||
color: rgba(0, 0, 0, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dynamic-field input[type="text"],
|
|
||||||
.dynamic-field input[type="number"],
|
|
||||||
.dynamic-field select,
|
|
||||||
.dynamic-field textarea {
|
|
||||||
border-color: rgba(0, 0, 0, 0.1);
|
|
||||||
background: #fff;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dynamic-field input:disabled,
|
|
||||||
.dynamic-field select:disabled,
|
|
||||||
.dynamic-field textarea:disabled {
|
|
||||||
background: #f5f5f5;
|
|
||||||
color: rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-hint {
|
|
||||||
color: rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-label {
|
|
||||||
color: rgba(0, 0, 0, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
.readonly-value {
|
|
||||||
background: #f8f9fa;
|
|
||||||
border-color: #e9ecef;
|
|
||||||
color: #495057;
|
|
||||||
}
|
|
||||||
|
|
||||||
.json-field .json-viewer {
|
|
||||||
background: #1e1e1e;
|
|
||||||
border-color: rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.language-string-field .lang-code {
|
|
||||||
background: rgba(100, 108, 255, 0.1);
|
|
||||||
color: #4a50c8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.language-string-field .lang-text {
|
|
||||||
background: #f8f9fa;
|
|
||||||
border-color: #e9ecef;
|
|
||||||
color: #495057;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,378 +0,0 @@
|
|||||||
import { SchemaField, FileMetadata } from '../types';
|
|
||||||
import { LanguageCombobox, formatLanguageOption } from './LanguageCombobox';
|
|
||||||
import './DynamicFieldRenderer.css';
|
|
||||||
|
|
||||||
interface DynamicFieldRendererProps {
|
|
||||||
fieldKey: string;
|
|
||||||
schema: SchemaField;
|
|
||||||
metadata: FileMetadata;
|
|
||||||
onChange: (key: string, value: any) => void;
|
|
||||||
readonly?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders a single metadata field based on its schema definition
|
|
||||||
*/
|
|
||||||
export function DynamicFieldRenderer({
|
|
||||||
fieldKey,
|
|
||||||
schema,
|
|
||||||
metadata,
|
|
||||||
onChange,
|
|
||||||
readonly = false,
|
|
||||||
}: DynamicFieldRendererProps) {
|
|
||||||
const isReadonly = readonly || schema.readonly;
|
|
||||||
const fieldType = schema.type || 'string';
|
|
||||||
|
|
||||||
// Get value from metadata using path-style key
|
|
||||||
const getValue = (): any => {
|
|
||||||
// Handle path-style keys like "stream/duration" or "nfo/title"
|
|
||||||
const parts = fieldKey.split('/');
|
|
||||||
let value: any = metadata;
|
|
||||||
for (const part of parts) {
|
|
||||||
if (value === undefined || value === null) return undefined;
|
|
||||||
value = value[part];
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Set value in metadata using path-style key
|
|
||||||
const setValue = (newValue: any) => {
|
|
||||||
onChange(fieldKey, newValue);
|
|
||||||
};
|
|
||||||
|
|
||||||
const value = getValue();
|
|
||||||
|
|
||||||
// Render link if configured
|
|
||||||
const renderValueWithLink = (displayValue: string) => {
|
|
||||||
if (schema.link && displayValue) {
|
|
||||||
const url = schema.link.replace('{value}', encodeURIComponent(displayValue));
|
|
||||||
return (
|
|
||||||
<a href={url} target="_blank" rel="noopener noreferrer" className="field-link">
|
|
||||||
{displayValue}
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return displayValue;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Render based on field type
|
|
||||||
switch (fieldType) {
|
|
||||||
case 'boolean':
|
|
||||||
return (
|
|
||||||
<div className="dynamic-field">
|
|
||||||
<label className="checkbox-label">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={Boolean(value)}
|
|
||||||
onChange={(e) => setValue(e.target.checked)}
|
|
||||||
disabled={isReadonly}
|
|
||||||
/>
|
|
||||||
<span>{schema.label}</span>
|
|
||||||
</label>
|
|
||||||
{schema.hint && <small className="field-hint">{schema.hint}</small>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'number':
|
|
||||||
return (
|
|
||||||
<div className="dynamic-field">
|
|
||||||
<label>{schema.label}</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={value ?? ''}
|
|
||||||
onChange={(e) => setValue(e.target.value ? Number(e.target.value) : undefined)}
|
|
||||||
disabled={isReadonly}
|
|
||||||
min={schema.min}
|
|
||||||
max={schema.max}
|
|
||||||
placeholder={schema.placeholder}
|
|
||||||
/>
|
|
||||||
{schema.hint && <small className="field-hint">{schema.hint}</small>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'select':
|
|
||||||
return (
|
|
||||||
<div className="dynamic-field">
|
|
||||||
<label>{schema.label}</label>
|
|
||||||
<select
|
|
||||||
value={value ?? ''}
|
|
||||||
onChange={(e) => setValue(e.target.value || undefined)}
|
|
||||||
disabled={isReadonly}
|
|
||||||
>
|
|
||||||
<option value="">Select...</option>
|
|
||||||
{schema.options?.map((opt) => (
|
|
||||||
<option key={opt.value} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{schema.hint && <small className="field-hint">{schema.hint}</small>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'text':
|
|
||||||
return (
|
|
||||||
<div className="dynamic-field">
|
|
||||||
<label>{schema.label}</label>
|
|
||||||
<textarea
|
|
||||||
value={value ?? ''}
|
|
||||||
onChange={(e) => setValue(e.target.value || undefined)}
|
|
||||||
disabled={isReadonly}
|
|
||||||
placeholder={schema.placeholder}
|
|
||||||
rows={4}
|
|
||||||
/>
|
|
||||||
{schema.hint && <small className="field-hint">{schema.hint}</small>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'array':
|
|
||||||
// Convert RecordSet to comma-separated string for display
|
|
||||||
const arrayValue = (() => {
|
|
||||||
if (!value) return '';
|
|
||||||
if (typeof value === 'string') return value;
|
|
||||||
if (typeof value === 'object') {
|
|
||||||
return Object.values(value).join(', ');
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
})();
|
|
||||||
|
|
||||||
// Convert comma-separated string back to RecordSet
|
|
||||||
const handleArrayChange = (str: string) => {
|
|
||||||
const items = str.split(',').map(s => s.trim()).filter(Boolean);
|
|
||||||
if (items.length === 0) {
|
|
||||||
setValue(undefined);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const recordSet: Record<string, string> = {};
|
|
||||||
items.forEach((item, idx) => {
|
|
||||||
recordSet[idx.toString()] = item;
|
|
||||||
});
|
|
||||||
setValue(recordSet);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="dynamic-field">
|
|
||||||
<label>{schema.label}</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={arrayValue}
|
|
||||||
onChange={(e) => handleArrayChange(e.target.value)}
|
|
||||||
disabled={isReadonly}
|
|
||||||
placeholder={schema.placeholder || 'Comma-separated values'}
|
|
||||||
/>
|
|
||||||
{schema.hint && <small className="field-hint">{schema.hint}</small>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'json':
|
|
||||||
// Aggregate matching keys based on pattern
|
|
||||||
const jsonData = (() => {
|
|
||||||
if (!schema.pattern) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pattern like "stream/video/{n}/*" - aggregate all matching keys
|
|
||||||
const patternPrefix = schema.pattern.replace(/\{n\}.*$/, '');
|
|
||||||
const result: Record<string, any> = {};
|
|
||||||
|
|
||||||
// Recursively find all matching keys
|
|
||||||
const findMatching = (obj: any, currentPath: string = '') => {
|
|
||||||
if (!obj || typeof obj !== 'object') return;
|
|
||||||
|
|
||||||
for (const [key, val] of Object.entries(obj)) {
|
|
||||||
const newPath = currentPath ? `${currentPath}/${key}` : key;
|
|
||||||
if (newPath.startsWith(patternPrefix.replace(/\/$/, ''))) {
|
|
||||||
// Store in result with relative path
|
|
||||||
const relativePath = newPath.substring(patternPrefix.length - 1);
|
|
||||||
if (relativePath) {
|
|
||||||
setNestedValue(result, relativePath, val);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (typeof val === 'object' && val !== null) {
|
|
||||||
findMatching(val, newPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
findMatching(metadata);
|
|
||||||
return Object.keys(result).length > 0 ? result : undefined;
|
|
||||||
})();
|
|
||||||
|
|
||||||
if (!jsonData) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="dynamic-field json-field">
|
|
||||||
<label>{schema.label}</label>
|
|
||||||
<pre className="json-viewer">
|
|
||||||
{JSON.stringify(jsonData, null, 2)}
|
|
||||||
</pre>
|
|
||||||
{schema.hint && <small className="field-hint">{schema.hint}</small>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'cid':
|
|
||||||
// CID field type - displays CID with "Open in Editor" button
|
|
||||||
if (!value) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cidValue = String(value);
|
|
||||||
const editorUrl = `/editor/file/${cidValue}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="dynamic-field cid-field">
|
|
||||||
<label>{schema.label}</label>
|
|
||||||
<div className="cid-value-container">
|
|
||||||
<code
|
|
||||||
className="cid-value"
|
|
||||||
onClick={() => navigator.clipboard.writeText(cidValue)}
|
|
||||||
title="Click to copy"
|
|
||||||
>
|
|
||||||
{cidValue}
|
|
||||||
</code>
|
|
||||||
<a
|
|
||||||
href={editorUrl}
|
|
||||||
className="cid-open-button"
|
|
||||||
title="Open in Editor"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
↗ Open
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{schema.hint && <small className="field-hint">{schema.hint}</small>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'languageString':
|
|
||||||
// Language-keyed string object: { "eng": "English Title", "jpn": "日本語タイトル" }
|
|
||||||
const langEntries: Array<[string, string]> = value && typeof value === 'object'
|
|
||||||
? Object.entries(value).map(([k, v]) => [k, String(v)])
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const handleLangCodeChange = (oldLang: string, newLang: string) => {
|
|
||||||
if (oldLang === newLang) return;
|
|
||||||
const newValue = { ...value };
|
|
||||||
const text = newValue[oldLang];
|
|
||||||
delete newValue[oldLang];
|
|
||||||
newValue[newLang] = text;
|
|
||||||
setValue(Object.keys(newValue).length > 0 ? newValue : undefined);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLangTextChange = (lang: string, text: string) => {
|
|
||||||
const newValue = { ...value, [lang]: text };
|
|
||||||
setValue(newValue);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveLang = (lang: string) => {
|
|
||||||
const newValue = { ...value };
|
|
||||||
delete newValue[lang];
|
|
||||||
setValue(Object.keys(newValue).length > 0 ? newValue : undefined);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddLang = () => {
|
|
||||||
// Find a language code not already used, default to 'eng'
|
|
||||||
const usedLangs = new Set(langEntries.map(([k]) => k));
|
|
||||||
const commonDefaults = ['eng', 'jpn', 'zho', 'kor', 'fra', 'deu', 'spa'];
|
|
||||||
const defaultLang = commonDefaults.find(l => !usedLangs.has(l)) || 'eng';
|
|
||||||
setValue({ ...value, [defaultLang]: '' });
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isReadonly && langEntries.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="dynamic-field language-string-field">
|
|
||||||
<label>{schema.label}</label>
|
|
||||||
<div className="language-entries">
|
|
||||||
{langEntries.map(([lang, text]) => (
|
|
||||||
<div key={lang} className="language-entry">
|
|
||||||
{isReadonly ? (
|
|
||||||
<>
|
|
||||||
<span className="lang-code" title={formatLanguageOption(lang)}>{lang}</span>
|
|
||||||
<span className="lang-text">{text}</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<LanguageCombobox
|
|
||||||
value={lang}
|
|
||||||
onChange={(newLang) => handleLangCodeChange(lang, newLang)}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="lang-input"
|
|
||||||
value={text}
|
|
||||||
onChange={(e) => handleLangTextChange(lang, e.target.value)}
|
|
||||||
placeholder={schema.placeholder || 'Text...'}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="lang-remove-btn"
|
|
||||||
onClick={() => handleRemoveLang(lang)}
|
|
||||||
title="Remove"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{!isReadonly && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="lang-add-btn"
|
|
||||||
onClick={handleAddLang}
|
|
||||||
>
|
|
||||||
+ Add Language
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{schema.hint && <small className="field-hint">{schema.hint}</small>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'string':
|
|
||||||
default:
|
|
||||||
// Show nothing if no value and readonly
|
|
||||||
if (isReadonly && !value) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="dynamic-field">
|
|
||||||
<label>{schema.label}</label>
|
|
||||||
{isReadonly ? (
|
|
||||||
<div className="readonly-value">
|
|
||||||
{renderValueWithLink(String(value ?? ''))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={value ?? ''}
|
|
||||||
onChange={(e) => setValue(e.target.value || undefined)}
|
|
||||||
placeholder={schema.placeholder}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{schema.hint && <small className="field-hint">{schema.hint}</small>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to set nested value in object
|
|
||||||
function setNestedValue(obj: any, path: string, value: any) {
|
|
||||||
const parts = path.split('/').filter(Boolean);
|
|
||||||
let current = obj;
|
|
||||||
for (let i = 0; i < parts.length - 1; i++) {
|
|
||||||
if (!current[parts[i]]) {
|
|
||||||
current[parts[i]] = {};
|
|
||||||
}
|
|
||||||
current = current[parts[i]];
|
|
||||||
}
|
|
||||||
if (parts.length > 0) {
|
|
||||||
current[parts[parts.length - 1]] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
.plugin-section {
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
border-left: 4px solid #646cff;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 1.5rem;
|
|
||||||
background-color: rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-section-header {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
padding-bottom: 0.75rem;
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-section-header h3 {
|
|
||||||
margin: 0 0 0.25rem 0;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
color: #646cff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-section-header .plugin-description {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: rgba(255, 255, 255, 0.5);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-fields {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive grid for fields */
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.plugin-fields {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Make text and json fields span full width */
|
|
||||||
.plugin-fields .dynamic-field:has(textarea),
|
|
||||||
.plugin-fields .json-field {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Light theme */
|
|
||||||
@media (prefers-color-scheme: light) {
|
|
||||||
.plugin-section {
|
|
||||||
border-color: rgba(0, 0, 0, 0.1);
|
|
||||||
background-color: rgba(0, 0, 0, 0.02);
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-section-header {
|
|
||||||
border-bottom-color: rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-section-header .plugin-description {
|
|
||||||
color: rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
import { PluginSchema, FileMetadata, SchemaField } from '../types';
|
|
||||||
import { DynamicFieldRenderer } from './DynamicFieldRenderer';
|
|
||||||
import './DynamicPluginSection.css';
|
|
||||||
|
|
||||||
interface DynamicPluginSectionProps {
|
|
||||||
plugin: PluginSchema;
|
|
||||||
metadata: FileMetadata;
|
|
||||||
onChange: (key: string, value: any) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders a plugin section with all its fields based on schema
|
|
||||||
*/
|
|
||||||
export function DynamicPluginSection({
|
|
||||||
plugin,
|
|
||||||
metadata,
|
|
||||||
onChange,
|
|
||||||
}: DynamicPluginSectionProps) {
|
|
||||||
const schema = plugin.metadataSchema;
|
|
||||||
|
|
||||||
if (!schema || Object.keys(schema).length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if plugin has any data in metadata
|
|
||||||
const hasData = checkPluginHasData(schema, metadata);
|
|
||||||
if (!hasData) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort fields: non-readonly first, then by key
|
|
||||||
const sortedFields = Object.entries(schema).sort(([keyA, a], [keyB, b]) => {
|
|
||||||
// Non-readonly fields first
|
|
||||||
if (a.readonly !== b.readonly) {
|
|
||||||
return a.readonly ? 1 : -1;
|
|
||||||
}
|
|
||||||
// Then alphabetically
|
|
||||||
return keyA.localeCompare(keyB);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
className="plugin-section"
|
|
||||||
style={{ borderLeftColor: plugin.color || '#646cff' }}
|
|
||||||
>
|
|
||||||
<div className="plugin-section-header">
|
|
||||||
<h3>{plugin.name}</h3>
|
|
||||||
{plugin.description && (
|
|
||||||
<p className="plugin-description">{plugin.description}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="plugin-fields">
|
|
||||||
{sortedFields.map(([fieldKey, fieldSchema]) => (
|
|
||||||
<DynamicFieldRenderer
|
|
||||||
key={fieldKey}
|
|
||||||
fieldKey={fieldKey}
|
|
||||||
schema={fieldSchema}
|
|
||||||
metadata={metadata}
|
|
||||||
onChange={onChange}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a plugin has any data in the metadata
|
|
||||||
*/
|
|
||||||
function checkPluginHasData(
|
|
||||||
schema: Record<string, SchemaField>,
|
|
||||||
metadata: FileMetadata
|
|
||||||
): boolean {
|
|
||||||
for (const fieldKey of Object.keys(schema)) {
|
|
||||||
const fieldSchema = schema[fieldKey];
|
|
||||||
|
|
||||||
// For JSON type with pattern, check if any matching keys exist
|
|
||||||
if (fieldSchema.type === 'json' && fieldSchema.pattern) {
|
|
||||||
const patternPrefix = fieldSchema.pattern.replace(/\{n\}.*$/, '').replace(/\/$/, '');
|
|
||||||
if (hasKeyWithPrefix(metadata, patternPrefix)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For regular fields, check if value exists
|
|
||||||
if (getNestedValue(metadata, fieldKey) !== undefined) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get nested value from object using path-style key
|
|
||||||
*/
|
|
||||||
function getNestedValue(obj: any, path: string): any {
|
|
||||||
const parts = path.split('/');
|
|
||||||
let value = obj;
|
|
||||||
for (const part of parts) {
|
|
||||||
if (value === undefined || value === null) return undefined;
|
|
||||||
value = value[part];
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if object has any key starting with prefix
|
|
||||||
*/
|
|
||||||
function hasKeyWithPrefix(obj: any, prefix: string, currentPath: string = ''): boolean {
|
|
||||||
if (!obj || typeof obj !== 'object') return false;
|
|
||||||
|
|
||||||
for (const key of Object.keys(obj)) {
|
|
||||||
const newPath = currentPath ? `${currentPath}/${key}` : key;
|
|
||||||
if (newPath.startsWith(prefix)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (typeof obj[key] === 'object' && obj[key] !== null) {
|
|
||||||
if (hasKeyWithPrefix(obj[key], prefix, newPath)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
.file-list {
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-list-header {
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
background-color: rgba(255, 255, 255, 0.05);
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-count {
|
|
||||||
font-size: 0.9em;
|
|
||||||
color: rgba(255, 255, 255, 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bulk-edit-toggle {
|
|
||||||
padding: 0.4rem 0.8rem;
|
|
||||||
font-size: 0.85em;
|
|
||||||
background-color: rgba(100, 108, 255, 0.1);
|
|
||||||
border: 1px solid rgba(100, 108, 255, 0.3);
|
|
||||||
color: #646cff;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bulk-edit-toggle:hover {
|
|
||||||
background-color: rgba(100, 108, 255, 0.2);
|
|
||||||
border-color: rgba(100, 108, 255, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bulk-edit-toggle.active {
|
|
||||||
background-color: rgba(100, 108, 255, 0.3);
|
|
||||||
border-color: #646cff;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-list-bulk-actions {
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
background-color: rgba(100, 108, 255, 0.1);
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-all-btn {
|
|
||||||
padding: 0.4rem 0.8rem;
|
|
||||||
font-size: 0.85em;
|
|
||||||
background-color: rgba(255, 255, 255, 0.1);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
|
||||||
color: rgba(255, 255, 255, 0.9);
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-all-btn:hover {
|
|
||||||
background-color: rgba(255, 255, 255, 0.2);
|
|
||||||
border-color: rgba(255, 255, 255, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.selection-count {
|
|
||||||
font-size: 0.9em;
|
|
||||||
color: #646cff;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-list-items {
|
|
||||||
max-height: 500px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-list-item {
|
|
||||||
padding: 1rem;
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.2s;
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-list-item-checkbox {
|
|
||||||
padding-top: 0.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-list-item-checkbox input[type='checkbox'] {
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-list-item-content {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-list-item:hover {
|
|
||||||
background-color: rgba(255, 255, 255, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-list-item.selected {
|
|
||||||
background-color: rgba(100, 108, 255, 0.2);
|
|
||||||
border-left: 3px solid #646cff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-list-item-title {
|
|
||||||
font-weight: 500;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-list-item-details {
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
font-size: 0.9em;
|
|
||||||
color: rgba(255, 255, 255, 0.6);
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-list-item-hash {
|
|
||||||
font-size: 0.8em;
|
|
||||||
color: rgba(255, 255, 255, 0.4);
|
|
||||||
font-family: 'Courier New', monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-list-empty {
|
|
||||||
padding: 3rem 1rem;
|
|
||||||
text-align: center;
|
|
||||||
color: rgba(255, 255, 255, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: light) {
|
|
||||||
.file-list {
|
|
||||||
border-color: rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-list-header {
|
|
||||||
background-color: rgba(0, 0, 0, 0.02);
|
|
||||||
border-bottom-color: rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-count {
|
|
||||||
color: rgba(0, 0, 0, 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-list-item {
|
|
||||||
border-bottom-color: rgba(0, 0, 0, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-list-item:hover {
|
|
||||||
background-color: rgba(0, 0, 0, 0.02);
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-list-item.selected {
|
|
||||||
background-color: rgba(100, 108, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-list-item-details {
|
|
||||||
color: rgba(0, 0, 0, 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-list-item-hash {
|
|
||||||
color: rgba(0, 0, 0, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-list-empty {
|
|
||||||
color: rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
import { SearchResult } from '../types';
|
|
||||||
import { getDisplayName, truncateHash, formatBytes } from '../utils/formatting';
|
|
||||||
import './FileList.css';
|
|
||||||
|
|
||||||
interface FileListProps {
|
|
||||||
results: SearchResult[];
|
|
||||||
selectedHashId?: string;
|
|
||||||
selectedHashIds: Set<string>;
|
|
||||||
bulkEditMode: boolean;
|
|
||||||
onSelectFile: (hashId: string) => void;
|
|
||||||
onToggleSelection: (hashId: string) => void;
|
|
||||||
onSelectAll: () => void;
|
|
||||||
onClearSelection: () => void;
|
|
||||||
onBulkEditMode: (enabled: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FileList({
|
|
||||||
results,
|
|
||||||
selectedHashId,
|
|
||||||
selectedHashIds,
|
|
||||||
bulkEditMode,
|
|
||||||
onSelectFile,
|
|
||||||
onToggleSelection,
|
|
||||||
onSelectAll,
|
|
||||||
onClearSelection,
|
|
||||||
onBulkEditMode,
|
|
||||||
}: FileListProps) {
|
|
||||||
if (results.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="file-list-empty">
|
|
||||||
<p>No files found. Try searching for a title, filename, or hash ID.</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const allSelected = results.length > 0 && results.every(r => selectedHashIds.has(r.hashId));
|
|
||||||
|
|
||||||
// Sort results by file path
|
|
||||||
const sortedResults = [...results].sort((a, b) => {
|
|
||||||
const pathA = a.metadata.filePath || a.metadata.privateFilePath || '';
|
|
||||||
const pathB = b.metadata.filePath || b.metadata.privateFilePath || '';
|
|
||||||
return pathA.localeCompare(pathB);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="file-list">
|
|
||||||
<div className="file-list-header">
|
|
||||||
<span className="file-count">{results.length} files found</span>
|
|
||||||
<button
|
|
||||||
className={`bulk-edit-toggle ${bulkEditMode ? 'active' : ''}`}
|
|
||||||
onClick={() => onBulkEditMode(!bulkEditMode)}
|
|
||||||
title={bulkEditMode ? 'Exit bulk edit mode' : 'Enter bulk edit mode'}
|
|
||||||
>
|
|
||||||
{bulkEditMode ? '✓ Bulk Edit' : 'Bulk Edit'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{bulkEditMode && (
|
|
||||||
<div className="file-list-bulk-actions">
|
|
||||||
<button onClick={allSelected ? onClearSelection : onSelectAll} className="select-all-btn">
|
|
||||||
{allSelected ? 'Deselect All' : 'Select All'}
|
|
||||||
</button>
|
|
||||||
{selectedHashIds.size > 0 && (
|
|
||||||
<span className="selection-count">
|
|
||||||
{selectedHashIds.size} selected
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="file-list-items">
|
|
||||||
{sortedResults.map((result) => {
|
|
||||||
const isSelected = bulkEditMode
|
|
||||||
? selectedHashIds.has(result.hashId)
|
|
||||||
: selectedHashId === result.hashId;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={result.hashId}
|
|
||||||
className={`file-list-item ${isSelected ? 'selected' : ''}`}
|
|
||||||
onClick={() => {
|
|
||||||
if (bulkEditMode) {
|
|
||||||
onToggleSelection(result.hashId);
|
|
||||||
} else {
|
|
||||||
onSelectFile(result.hashId);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{bulkEditMode && (
|
|
||||||
<div className="file-list-item-checkbox">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={selectedHashIds.has(result.hashId)}
|
|
||||||
onChange={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onToggleSelection(result.hashId);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="file-list-item-content">
|
|
||||||
<div className="file-list-item-title">
|
|
||||||
{getDisplayName(result.metadata)}
|
|
||||||
</div>
|
|
||||||
<div className="file-list-item-details">
|
|
||||||
<span className="file-list-item-type">
|
|
||||||
{result.metadata.videoType || 'Unknown'}
|
|
||||||
</span>
|
|
||||||
<span className="file-list-item-size">
|
|
||||||
{formatBytes(result.metadata.byteSize)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="file-list-item-hash">
|
|
||||||
Hash: {truncateHash(result.hashId)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,247 +0,0 @@
|
|||||||
.kv-tree {
|
|
||||||
background-color: rgba(255, 255, 255, 0.05);
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv-tree-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1rem;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
background-color: rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv-tree-header h3 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: rgba(255, 255, 255, 0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv-tree-info {
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: rgba(255, 255, 255, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv-tree-refresh {
|
|
||||||
margin-left: auto;
|
|
||||||
padding: 0.25rem 0.75rem;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
background-color: rgba(100, 108, 255, 0.2);
|
|
||||||
color: #646cff;
|
|
||||||
border: 1px solid rgba(100, 108, 255, 0.3);
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv-tree-refresh:hover:not(:disabled) {
|
|
||||||
background-color: rgba(100, 108, 255, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv-tree-refresh:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv-tree-error {
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
background-color: rgba(255, 0, 0, 0.1);
|
|
||||||
color: #ff6b6b;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv-tree-content {
|
|
||||||
max-height: 400px;
|
|
||||||
overflow-y: auto;
|
|
||||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv-tree-node {
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv-tree-node:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv-tree-key {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv-tree-key:hover {
|
|
||||||
background-color: rgba(255, 255, 255, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv-tree-key.expanded {
|
|
||||||
background-color: rgba(100, 108, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv-tree-toggle {
|
|
||||||
width: 1rem;
|
|
||||||
color: rgba(255, 255, 255, 0.4);
|
|
||||||
font-size: 0.65rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv-tree-key-name {
|
|
||||||
flex: 1;
|
|
||||||
color: #88c0d0;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv-tree-type {
|
|
||||||
padding: 0.1rem 0.4rem;
|
|
||||||
font-size: 0.65rem;
|
|
||||||
background-color: rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 3px;
|
|
||||||
color: rgba(255, 255, 255, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv-tree-value {
|
|
||||||
padding: 0.5rem 1rem 0.5rem 2.5rem;
|
|
||||||
background-color: rgba(0, 0, 0, 0.2);
|
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv-tree-fields {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv-tree-fields tr:hover {
|
|
||||||
background-color: rgba(255, 255, 255, 0.03);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv-field-name {
|
|
||||||
padding: 0.25rem 0.5rem 0.25rem 0;
|
|
||||||
color: #a3be8c;
|
|
||||||
white-space: nowrap;
|
|
||||||
vertical-align: top;
|
|
||||||
width: 1%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv-field-value {
|
|
||||||
padding: 0.25rem 0;
|
|
||||||
color: rgba(255, 255, 255, 0.7);
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv-field-value pre {
|
|
||||||
margin: 0;
|
|
||||||
font-family: inherit;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv-tree-load-more {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.75rem;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
background-color: transparent;
|
|
||||||
color: #646cff;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv-tree-load-more:hover:not(:disabled) {
|
|
||||||
background-color: rgba(100, 108, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv-tree-load-more:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv-tree-empty {
|
|
||||||
padding: 2rem;
|
|
||||||
text-align: center;
|
|
||||||
color: rgba(255, 255, 255, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Light mode */
|
|
||||||
@media (prefers-color-scheme: light) {
|
|
||||||
.kv-tree {
|
|
||||||
background-color: rgba(0, 0, 0, 0.02);
|
|
||||||
border-color: rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv-tree-header {
|
|
||||||
border-bottom-color: rgba(0, 0, 0, 0.1);
|
|
||||||
background-color: rgba(0, 0, 0, 0.03);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv-tree-header h3 {
|
|
||||||
color: rgba(0, 0, 0, 0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv-tree-info {
|
|
||||||
color: rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv-tree-error {
|
|
||||||
background-color: rgba(255, 0, 0, 0.08);
|
|
||||||
color: #cc0000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv-tree-node {
|
|
||||||
border-bottom-color: rgba(0, 0, 0, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv-tree-key:hover {
|
|
||||||
background-color: rgba(0, 0, 0, 0.03);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv-tree-key.expanded {
|
|
||||||
background-color: rgba(100, 108, 255, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv-tree-toggle {
|
|
||||||
color: rgba(0, 0, 0, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv-tree-key-name {
|
|
||||||
color: #0077aa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv-tree-type {
|
|
||||||
background-color: rgba(0, 0, 0, 0.08);
|
|
||||||
color: rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv-tree-value {
|
|
||||||
background-color: rgba(0, 0, 0, 0.02);
|
|
||||||
border-top-color: rgba(0, 0, 0, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv-tree-fields tr:hover {
|
|
||||||
background-color: rgba(0, 0, 0, 0.02);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv-field-name {
|
|
||||||
color: #228822;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv-field-value {
|
|
||||||
color: rgba(0, 0, 0, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv-tree-load-more:hover:not(:disabled) {
|
|
||||||
background-color: rgba(100, 108, 255, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kv-tree-empty {
|
|
||||||
color: rgba(0, 0, 0, 0.4);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import './KVTreeView.css';
|
|
||||||
|
|
||||||
interface KeyValue {
|
|
||||||
key: string;
|
|
||||||
type?: string;
|
|
||||||
value?: Record<string, any> | string;
|
|
||||||
expanded?: boolean;
|
|
||||||
loading?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface KVInfo {
|
|
||||||
prefix: string;
|
|
||||||
fileCount: number;
|
|
||||||
keyCount: number;
|
|
||||||
totalSize: number;
|
|
||||||
memoryUsage?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const API_BASE = '/api/kv';
|
|
||||||
|
|
||||||
export function KVTreeView() {
|
|
||||||
const [keys, setKeys] = useState<KeyValue[]>([]);
|
|
||||||
const [cursor, setCursor] = useState<string>('0');
|
|
||||||
const [hasMore, setHasMore] = useState(true);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [info, setInfo] = useState<KVInfo | null>(null);
|
|
||||||
|
|
||||||
// Load KV info on mount
|
|
||||||
useEffect(() => {
|
|
||||||
fetch(`${API_BASE}/info`)
|
|
||||||
.then(res => res.json())
|
|
||||||
.then(data => setInfo(data))
|
|
||||||
.catch(err => console.error('Failed to load KV info:', err));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Load initial keys
|
|
||||||
const loadKeys = useCallback(async (reset = false) => {
|
|
||||||
if (loading) return;
|
|
||||||
|
|
||||||
const currentCursor = reset ? '0' : cursor;
|
|
||||||
if (!reset && currentCursor === '0' && keys.length > 0) return;
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_BASE}/keys?cursor=${currentCursor}&count=50`);
|
|
||||||
if (!res.ok) throw new Error('Failed to load keys');
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
const newKeys: KeyValue[] = data.keys.map((key: string) => ({
|
|
||||||
key,
|
|
||||||
expanded: false,
|
|
||||||
loading: false
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (reset) {
|
|
||||||
setKeys(newKeys);
|
|
||||||
} else {
|
|
||||||
setKeys(prev => [...prev, ...newKeys]);
|
|
||||||
}
|
|
||||||
|
|
||||||
setCursor(data.cursor);
|
|
||||||
setHasMore(data.hasMore);
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err.message);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [cursor, keys.length, loading]);
|
|
||||||
|
|
||||||
// Load on mount
|
|
||||||
useEffect(() => {
|
|
||||||
loadKeys(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Toggle key expansion and load value
|
|
||||||
const toggleKey = async (index: number) => {
|
|
||||||
const key = keys[index];
|
|
||||||
|
|
||||||
if (key.expanded) {
|
|
||||||
// Collapse
|
|
||||||
setKeys(prev => prev.map((k, i) =>
|
|
||||||
i === index ? { ...k, expanded: false } : k
|
|
||||||
));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expand and load value if not already loaded
|
|
||||||
if (key.value === undefined) {
|
|
||||||
setKeys(prev => prev.map((k, i) =>
|
|
||||||
i === index ? { ...k, loading: true } : k
|
|
||||||
));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_BASE}/key/${encodeURIComponent(key.key)}`);
|
|
||||||
if (!res.ok) throw new Error('Failed to load value');
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
setKeys(prev => prev.map((k, i) =>
|
|
||||||
i === index ? {
|
|
||||||
...k,
|
|
||||||
type: data.type,
|
|
||||||
value: data.value,
|
|
||||||
expanded: true,
|
|
||||||
loading: false
|
|
||||||
} : k
|
|
||||||
));
|
|
||||||
} catch (err) {
|
|
||||||
setKeys(prev => prev.map((k, i) =>
|
|
||||||
i === index ? { ...k, loading: false, value: 'Error loading value' } : k
|
|
||||||
));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setKeys(prev => prev.map((k, i) =>
|
|
||||||
i === index ? { ...k, expanded: true } : k
|
|
||||||
));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatValue = (value: any): string => {
|
|
||||||
if (value === null) return 'null';
|
|
||||||
if (value === undefined) return 'undefined';
|
|
||||||
if (typeof value === 'object') {
|
|
||||||
return JSON.stringify(value, null, 2);
|
|
||||||
}
|
|
||||||
return String(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatSize = (bytes: number): string => {
|
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
||||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
||||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="kv-tree">
|
|
||||||
<div className="kv-tree-header">
|
|
||||||
<h3>KV Database Browser</h3>
|
|
||||||
{info && (
|
|
||||||
<div className="kv-tree-info">
|
|
||||||
<span>Keys: {info.keyCount}</span>
|
|
||||||
<span>Files: {formatSize(info.totalSize)}</span>
|
|
||||||
{info.memoryUsage && <span>Memory: {info.memoryUsage}</span>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
className="kv-tree-refresh"
|
|
||||||
onClick={() => loadKeys(true)}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{loading ? 'Loading...' : 'Refresh'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <div className="kv-tree-error">{error}</div>}
|
|
||||||
|
|
||||||
<div className="kv-tree-content">
|
|
||||||
{keys.map((item, index) => (
|
|
||||||
<div key={item.key} className="kv-tree-node">
|
|
||||||
<div
|
|
||||||
className={`kv-tree-key ${item.expanded ? 'expanded' : ''}`}
|
|
||||||
onClick={() => toggleKey(index)}
|
|
||||||
>
|
|
||||||
<span className="kv-tree-toggle">
|
|
||||||
{item.loading ? '...' : (item.expanded ? '▼' : '▶')}
|
|
||||||
</span>
|
|
||||||
<span className="kv-tree-key-name">{item.key}</span>
|
|
||||||
{item.type && <span className="kv-tree-type">{item.type}</span>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{item.expanded && item.value && (
|
|
||||||
<div className="kv-tree-value">
|
|
||||||
{typeof item.value === 'object' ? (
|
|
||||||
<table className="kv-tree-fields">
|
|
||||||
<tbody>
|
|
||||||
{Object.entries(item.value).map(([field, val]) => (
|
|
||||||
<tr key={field}>
|
|
||||||
<td className="kv-field-name">{field}</td>
|
|
||||||
<td className="kv-field-value">
|
|
||||||
<pre>{formatValue(val)}</pre>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
) : (
|
|
||||||
<pre>{formatValue(item.value)}</pre>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{hasMore && (
|
|
||||||
<button
|
|
||||||
className="kv-tree-load-more"
|
|
||||||
onClick={() => loadKeys()}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{loading ? 'Loading...' : 'Load More'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && keys.length === 0 && (
|
|
||||||
<div className="kv-tree-empty">No keys found</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
.language-combobox {
|
|
||||||
position: relative;
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 180px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.language-combobox-input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.5rem 2rem 0.5rem 0.75rem;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
background: rgba(0, 0, 0, 0.3);
|
|
||||||
color: #fff;
|
|
||||||
cursor: pointer;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.language-combobox-input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #646cff;
|
|
||||||
cursor: text;
|
|
||||||
}
|
|
||||||
|
|
||||||
.language-combobox.disabled .language-combobox-input {
|
|
||||||
background: rgba(0, 0, 0, 0.2);
|
|
||||||
color: rgba(255, 255, 255, 0.5);
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.language-combobox-arrow {
|
|
||||||
position: absolute;
|
|
||||||
right: 0.5rem;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
font-size: 0.7rem;
|
|
||||||
color: rgba(255, 255, 255, 0.5);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.language-combobox.open .language-combobox-arrow {
|
|
||||||
transform: translateY(-50%) rotate(180deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.language-combobox-dropdown {
|
|
||||||
position: absolute;
|
|
||||||
top: 100%;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
margin-top: 4px;
|
|
||||||
max-height: 250px;
|
|
||||||
overflow-y: auto;
|
|
||||||
background: #1a1a2e;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
|
||||||
border-radius: 4px;
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
|
||||||
z-index: 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
.language-combobox-option {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.language-combobox-option:hover,
|
|
||||||
.language-combobox-option.highlighted {
|
|
||||||
background: rgba(100, 108, 255, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.language-combobox-option.selected {
|
|
||||||
background: rgba(100, 108, 255, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.language-combobox-option.custom {
|
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
color: #a0d8ef;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lang-option-name {
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lang-option-code {
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin-left: 0.5rem;
|
|
||||||
padding: 0.15rem 0.4rem;
|
|
||||||
background: rgba(100, 108, 255, 0.2);
|
|
||||||
border-radius: 3px;
|
|
||||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Courier New', monospace;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: #a0d8ef;
|
|
||||||
}
|
|
||||||
|
|
||||||
.language-combobox-empty {
|
|
||||||
padding: 0.75rem;
|
|
||||||
text-align: center;
|
|
||||||
color: rgba(255, 255, 255, 0.5);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Light theme */
|
|
||||||
@media (prefers-color-scheme: light) {
|
|
||||||
.language-combobox-input {
|
|
||||||
background: #fff;
|
|
||||||
color: #333;
|
|
||||||
border-color: rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.language-combobox.disabled .language-combobox-input {
|
|
||||||
background: #f5f5f5;
|
|
||||||
color: rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.language-combobox-arrow {
|
|
||||||
color: rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.language-combobox-dropdown {
|
|
||||||
background: #fff;
|
|
||||||
border-color: rgba(0, 0, 0, 0.15);
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.language-combobox-option:hover,
|
|
||||||
.language-combobox-option.highlighted {
|
|
||||||
background: rgba(100, 108, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.language-combobox-option.selected {
|
|
||||||
background: rgba(100, 108, 255, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.lang-option-code {
|
|
||||||
background: rgba(100, 108, 255, 0.1);
|
|
||||||
color: #4a50c8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.language-combobox-empty {
|
|
||||||
color: rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
import { useState, useRef, useEffect, useMemo } from 'react';
|
|
||||||
import { iso6393, Language } from 'iso-639-3';
|
|
||||||
import './LanguageCombobox.css';
|
|
||||||
|
|
||||||
// Build language lookup maps
|
|
||||||
const languagesByCode = new Map<string, Language>();
|
|
||||||
for (const lang of iso6393) {
|
|
||||||
languagesByCode.set(lang.iso6393, lang);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get display name for a language code
|
|
||||||
export function getLanguageName(code: string): string {
|
|
||||||
const lang = languagesByCode.get(code);
|
|
||||||
if (lang) {
|
|
||||||
return lang.name;
|
|
||||||
}
|
|
||||||
return code.toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Format for display: "English (eng)" or "eng" for unknown
|
|
||||||
export function formatLanguageOption(code: string): string {
|
|
||||||
const lang = languagesByCode.get(code);
|
|
||||||
if (lang) {
|
|
||||||
return `${lang.name} (${code})`;
|
|
||||||
}
|
|
||||||
return code;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LanguageComboboxProps {
|
|
||||||
value: string;
|
|
||||||
onChange: (code: string) => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
placeholder?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LanguageCombobox({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
disabled = false,
|
|
||||||
placeholder = 'Search language...',
|
|
||||||
}: LanguageComboboxProps) {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const listRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// Filter languages based on search
|
|
||||||
const filteredLanguages = useMemo(() => {
|
|
||||||
const searchLower = search.toLowerCase().trim();
|
|
||||||
if (!searchLower) {
|
|
||||||
// Show common languages first when no search
|
|
||||||
const common = ['eng', 'jpn', 'zho', 'kor', 'fra', 'deu', 'spa', 'ita', 'por', 'rus'];
|
|
||||||
const commonLangs = common
|
|
||||||
.map(code => languagesByCode.get(code))
|
|
||||||
.filter((l): l is typeof iso6393[0] => l !== undefined);
|
|
||||||
return commonLangs;
|
|
||||||
}
|
|
||||||
|
|
||||||
return iso6393.filter((lang: Language) => {
|
|
||||||
const nameMatch = lang.name.toLowerCase().includes(searchLower);
|
|
||||||
const codeMatch = lang.iso6393.toLowerCase().includes(searchLower);
|
|
||||||
const iso1Match = lang.iso6391 && lang.iso6391.toLowerCase().includes(searchLower);
|
|
||||||
return nameMatch || codeMatch || iso1Match;
|
|
||||||
}).slice(0, 50); // Limit results for performance
|
|
||||||
}, [search]);
|
|
||||||
|
|
||||||
// Check if search is a valid custom 3-letter code
|
|
||||||
const isValidCustomCode = useMemo(() => {
|
|
||||||
const trimmed = search.trim().toLowerCase();
|
|
||||||
return trimmed.length === 3 && /^[a-z]{3}$/.test(trimmed) && !languagesByCode.has(trimmed);
|
|
||||||
}, [search]);
|
|
||||||
|
|
||||||
// Reset highlight when filtered list changes
|
|
||||||
useEffect(() => {
|
|
||||||
setHighlightedIndex(0);
|
|
||||||
}, [filteredLanguages.length]);
|
|
||||||
|
|
||||||
// Scroll highlighted item into view
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen && listRef.current) {
|
|
||||||
const highlighted = listRef.current.querySelector('.highlighted');
|
|
||||||
if (highlighted) {
|
|
||||||
highlighted.scrollIntoView({ block: 'nearest' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [highlightedIndex, isOpen]);
|
|
||||||
|
|
||||||
const handleSelect = (code: string) => {
|
|
||||||
onChange(code);
|
|
||||||
setSearch('');
|
|
||||||
setIsOpen(false);
|
|
||||||
inputRef.current?.blur();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
||||||
if (!isOpen) {
|
|
||||||
if (e.key === 'ArrowDown' || e.key === 'Enter') {
|
|
||||||
setIsOpen(true);
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalItems = filteredLanguages.length + (isValidCustomCode ? 1 : 0);
|
|
||||||
|
|
||||||
switch (e.key) {
|
|
||||||
case 'ArrowDown':
|
|
||||||
e.preventDefault();
|
|
||||||
setHighlightedIndex(prev => (prev + 1) % totalItems);
|
|
||||||
break;
|
|
||||||
case 'ArrowUp':
|
|
||||||
e.preventDefault();
|
|
||||||
setHighlightedIndex(prev => (prev - 1 + totalItems) % totalItems);
|
|
||||||
break;
|
|
||||||
case 'Enter':
|
|
||||||
e.preventDefault();
|
|
||||||
if (isValidCustomCode && highlightedIndex === filteredLanguages.length) {
|
|
||||||
handleSelect(search.trim().toLowerCase());
|
|
||||||
} else if (filteredLanguages[highlightedIndex]) {
|
|
||||||
handleSelect(filteredLanguages[highlightedIndex].iso6393);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'Escape':
|
|
||||||
setIsOpen(false);
|
|
||||||
setSearch('');
|
|
||||||
inputRef.current?.blur();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const displayValue = isOpen ? search : formatLanguageOption(value);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`language-combobox ${isOpen ? 'open' : ''} ${disabled ? 'disabled' : ''}`}>
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="text"
|
|
||||||
className="language-combobox-input"
|
|
||||||
value={displayValue}
|
|
||||||
onChange={(e) => {
|
|
||||||
setSearch(e.target.value);
|
|
||||||
setIsOpen(true);
|
|
||||||
}}
|
|
||||||
onFocus={() => {
|
|
||||||
setSearch('');
|
|
||||||
setIsOpen(true);
|
|
||||||
}}
|
|
||||||
onBlur={() => {
|
|
||||||
// Delay to allow click on option
|
|
||||||
setTimeout(() => {
|
|
||||||
setIsOpen(false);
|
|
||||||
setSearch('');
|
|
||||||
}, 200);
|
|
||||||
}}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
disabled={disabled}
|
|
||||||
placeholder={placeholder}
|
|
||||||
/>
|
|
||||||
<span className="language-combobox-arrow">▼</span>
|
|
||||||
|
|
||||||
{isOpen && (
|
|
||||||
<div className="language-combobox-dropdown" ref={listRef}>
|
|
||||||
{filteredLanguages.map((lang: Language, index: number) => (
|
|
||||||
<div
|
|
||||||
key={lang.iso6393}
|
|
||||||
className={`language-combobox-option ${index === highlightedIndex ? 'highlighted' : ''} ${lang.iso6393 === value ? 'selected' : ''}`}
|
|
||||||
onMouseDown={() => handleSelect(lang.iso6393)}
|
|
||||||
onMouseEnter={() => setHighlightedIndex(index)}
|
|
||||||
>
|
|
||||||
<span className="lang-option-name">{lang.name}</span>
|
|
||||||
<span className="lang-option-code">{lang.iso6393}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{isValidCustomCode && (
|
|
||||||
<div
|
|
||||||
className={`language-combobox-option custom ${highlightedIndex === filteredLanguages.length ? 'highlighted' : ''}`}
|
|
||||||
onMouseDown={() => handleSelect(search.trim().toLowerCase())}
|
|
||||||
onMouseEnter={() => setHighlightedIndex(filteredLanguages.length)}
|
|
||||||
>
|
|
||||||
<span className="lang-option-name">Use custom code</span>
|
|
||||||
<span className="lang-option-code">{search.trim().toLowerCase()}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{filteredLanguages.length === 0 && !isValidCustomCode && (
|
|
||||||
<div className="language-combobox-empty">
|
|
||||||
No languages found. Type a 3-letter code to add custom.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,438 +0,0 @@
|
|||||||
.metadata-editor {
|
|
||||||
background-color: rgba(255, 255, 255, 0.05);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metadata-editor-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
padding-bottom: 1rem;
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.metadata-editor-header h2 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metadata-editor-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metadata-editor-actions button.primary {
|
|
||||||
background-color: #646cff;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metadata-editor-actions button.primary:hover:not(:disabled) {
|
|
||||||
background-color: #535bf2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metadata-editor-actions .download-button {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
background-color: #28a745;
|
|
||||||
color: white;
|
|
||||||
border-radius: 4px;
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 500;
|
|
||||||
transition: background-color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metadata-editor-actions .download-button:hover {
|
|
||||||
background-color: #218838;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metadata-editor-message {
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metadata-editor-message.success {
|
|
||||||
background-color: rgba(0, 200, 0, 0.1);
|
|
||||||
border: 1px solid rgba(0, 200, 0, 0.3);
|
|
||||||
color: #00ff00;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metadata-editor-message.error {
|
|
||||||
background-color: rgba(255, 0, 0, 0.1);
|
|
||||||
border: 1px solid rgba(255, 0, 0, 0.3);
|
|
||||||
color: #ff6b6b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metadata-editor-hashes {
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
padding: 1rem;
|
|
||||||
background: rgba(0, 0, 0, 0.3);
|
|
||||||
border-radius: 8px;
|
|
||||||
border-left: 4px solid #888;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hashes-header {
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
padding-bottom: 0.5rem;
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hashes-header h3 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: rgba(255, 255, 255, 0.7);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hashes-grid {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hash-value {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.15rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hash-value-primary {
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
padding-bottom: 0.5rem;
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hash-value-primary .hash-label {
|
|
||||||
color: #4ecdc4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hash-value-primary .hash-code {
|
|
||||||
color: #4ecdc4;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hash-label {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: rgba(255, 255, 255, 0.4);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.03em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hash-code {
|
|
||||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Courier New', monospace;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: rgba(255, 255, 255, 0.6);
|
|
||||||
word-break: break-all;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hash-code:hover {
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Plugin Chips */
|
|
||||||
.metadata-editor-chips {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-chip {
|
|
||||||
padding: 0.4rem 0.8rem;
|
|
||||||
border-radius: 16px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
border: 2px solid var(--chip-color);
|
|
||||||
color: var(--chip-color);
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-chip:hover {
|
|
||||||
background-color: var(--chip-color);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-chip.active {
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Plugin Accordion */
|
|
||||||
.plugin-accordion {
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
background-color: rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-accordion-header {
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 1rem 1.25rem;
|
|
||||||
border: none;
|
|
||||||
border-left: 4px solid #646cff;
|
|
||||||
background-color: rgba(255, 255, 255, 0.05);
|
|
||||||
color: inherit;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-accordion-header:hover {
|
|
||||||
background-color: rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-accordion.expanded .plugin-accordion-header {
|
|
||||||
background-color: rgba(255, 255, 255, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-accordion-title {
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-accordion-icon {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
opacity: 0.6;
|
|
||||||
transition: transform 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-accordion-content {
|
|
||||||
padding: 1rem 1.25rem;
|
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.metadata-editor-form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metadata-section {
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
border-left: 4px solid #646cff;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 1.5rem;
|
|
||||||
background-color: rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.metadata-section-header {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
padding-bottom: 0.75rem;
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.metadata-section-header h3 {
|
|
||||||
margin: 0 0 0.25rem 0;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
color: #646cff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-description {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: rgba(255, 255, 255, 0.5);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metadata-field {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metadata-field:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metadata-field label {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: rgba(255, 255, 255, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
.metadata-field input,
|
|
||||||
.metadata-field textarea,
|
|
||||||
.metadata-field select {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metadata-field-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
||||||
gap: 1rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metadata-field-row:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hash-field {
|
|
||||||
font-family: 'Courier New', monospace;
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-hash-info {
|
|
||||||
margin: 0;
|
|
||||||
padding: 1rem;
|
|
||||||
text-align: center;
|
|
||||||
color: rgba(255, 255, 255, 0.5);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metadata-editor-loading,
|
|
||||||
.metadata-editor-error,
|
|
||||||
.metadata-editor-empty {
|
|
||||||
padding: 3rem;
|
|
||||||
text-align: center;
|
|
||||||
color: rgba(255, 255, 255, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.metadata-editor-error button {
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.metadata-editor-header {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metadata-editor-actions {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metadata-editor-actions button {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metadata-field-row {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: light) {
|
|
||||||
.metadata-editor {
|
|
||||||
background-color: rgba(0, 0, 0, 0.02);
|
|
||||||
}
|
|
||||||
|
|
||||||
.metadata-editor-header {
|
|
||||||
border-bottom-color: rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.metadata-section {
|
|
||||||
border-color: rgba(0, 0, 0, 0.1);
|
|
||||||
background-color: rgba(0, 0, 0, 0.02);
|
|
||||||
}
|
|
||||||
|
|
||||||
.metadata-section-header {
|
|
||||||
border-bottom-color: rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-description {
|
|
||||||
color: rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.metadata-field label {
|
|
||||||
color: rgba(0, 0, 0, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
.metadata-editor-message.success {
|
|
||||||
background-color: rgba(0, 200, 0, 0.1);
|
|
||||||
border-color: rgba(0, 200, 0, 0.3);
|
|
||||||
color: #008800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metadata-editor-message.error {
|
|
||||||
background-color: rgba(255, 0, 0, 0.1);
|
|
||||||
border-color: rgba(255, 0, 0, 0.3);
|
|
||||||
color: #cc0000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metadata-editor-loading,
|
|
||||||
.metadata-editor-error,
|
|
||||||
.metadata-editor-empty {
|
|
||||||
color: rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-hash-info {
|
|
||||||
color: rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.metadata-editor-hashes {
|
|
||||||
background: rgba(0, 0, 0, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hashes-header {
|
|
||||||
border-bottom-color: rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hashes-header h3 {
|
|
||||||
color: rgba(0, 0, 0, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hash-value-primary {
|
|
||||||
border-bottom-color: rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hash-value-primary .hash-label,
|
|
||||||
.hash-value-primary .hash-code {
|
|
||||||
color: #0a9e94;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hash-label {
|
|
||||||
color: rgba(0, 0, 0, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hash-code {
|
|
||||||
color: rgba(0, 0, 0, 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hash-code:hover {
|
|
||||||
color: #000;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Plugin Chips - Light theme */
|
|
||||||
.plugin-chip {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-chip:hover,
|
|
||||||
.plugin-chip.active {
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Plugin Accordion - Light theme */
|
|
||||||
.plugin-accordion {
|
|
||||||
background-color: rgba(0, 0, 0, 0.02);
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-accordion-header {
|
|
||||||
background-color: rgba(0, 0, 0, 0.03);
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-accordion-header:hover {
|
|
||||||
background-color: rgba(0, 0, 0, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-accordion.expanded .plugin-accordion-header {
|
|
||||||
background-color: rgba(0, 0, 0, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-accordion-content {
|
|
||||||
border-top-color: rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,392 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { FileMetadata, PluginSchema, PluginsResponse } from '../types';
|
|
||||||
import { MetadataAPI } from '../api/metadataApi';
|
|
||||||
import { DynamicPluginSection } from './DynamicPluginSection';
|
|
||||||
import './MetadataEditor.css';
|
|
||||||
|
|
||||||
interface PluginAccordionProps {
|
|
||||||
plugin: PluginSchema;
|
|
||||||
metadata: FileMetadata;
|
|
||||||
onChange: (field: string, value: any) => void;
|
|
||||||
isExpanded: boolean;
|
|
||||||
onToggle: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function PluginAccordion({ plugin, metadata, onChange, isExpanded, onToggle }: PluginAccordionProps) {
|
|
||||||
return (
|
|
||||||
<div className={`plugin-accordion ${isExpanded ? 'expanded' : ''}`}>
|
|
||||||
<button
|
|
||||||
className="plugin-accordion-header"
|
|
||||||
onClick={onToggle}
|
|
||||||
style={{ borderLeftColor: plugin.color || '#646cff' }}
|
|
||||||
>
|
|
||||||
<span className="plugin-accordion-title">{plugin.name}</span>
|
|
||||||
<span className="plugin-accordion-icon">{isExpanded ? '▼' : '▶'}</span>
|
|
||||||
</button>
|
|
||||||
{isExpanded && (
|
|
||||||
<div className="plugin-accordion-content">
|
|
||||||
<DynamicPluginSection
|
|
||||||
plugin={plugin}
|
|
||||||
metadata={metadata}
|
|
||||||
onChange={onChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MetadataEditorProps {
|
|
||||||
hashId: string;
|
|
||||||
onSaved?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MetadataEditor({ hashId, onSaved }: MetadataEditorProps) {
|
|
||||||
const [metadata, setMetadata] = useState<FileMetadata | null>(null);
|
|
||||||
const [editedMetadata, setEditedMetadata] = useState<FileMetadata | null>(null);
|
|
||||||
const [plugins, setPlugins] = useState<PluginSchema[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
|
||||||
const [expandedPlugins, setExpandedPlugins] = useState<Set<string>>(new Set());
|
|
||||||
|
|
||||||
// Fetch plugins schema on mount
|
|
||||||
useEffect(() => {
|
|
||||||
fetchPlugins();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Fetch metadata when hashId changes
|
|
||||||
useEffect(() => {
|
|
||||||
loadMetadata();
|
|
||||||
}, [hashId]);
|
|
||||||
|
|
||||||
const fetchPlugins = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/plugins');
|
|
||||||
if (response.ok) {
|
|
||||||
const data: PluginsResponse = await response.json();
|
|
||||||
// Sort by priority (lower = higher priority)
|
|
||||||
const sorted = [...data.plugins].sort((a, b) =>
|
|
||||||
(a.priority ?? 100) - (b.priority ?? 100)
|
|
||||||
);
|
|
||||||
setPlugins(sorted);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to fetch plugin schemas:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadMetadata = async () => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
const data = await MetadataAPI.getMetadata(hashId);
|
|
||||||
setMetadata(data);
|
|
||||||
setEditedMetadata(data);
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err.message);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFieldChange = (field: string, value: any) => {
|
|
||||||
if (!editedMetadata) return;
|
|
||||||
|
|
||||||
// Handle path-style keys like "stream/duration" or "nfo/title"
|
|
||||||
const parts = field.split('/');
|
|
||||||
|
|
||||||
if (parts.length === 1) {
|
|
||||||
// Simple key
|
|
||||||
setEditedMetadata({
|
|
||||||
...editedMetadata,
|
|
||||||
[field]: value,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Nested key - need to update nested structure
|
|
||||||
const newMetadata = { ...editedMetadata };
|
|
||||||
let current: any = newMetadata;
|
|
||||||
|
|
||||||
for (let i = 0; i < parts.length - 1; i++) {
|
|
||||||
if (!current[parts[i]]) {
|
|
||||||
current[parts[i]] = {};
|
|
||||||
} else {
|
|
||||||
current[parts[i]] = { ...current[parts[i]] };
|
|
||||||
}
|
|
||||||
current = current[parts[i]];
|
|
||||||
}
|
|
||||||
|
|
||||||
current[parts[parts.length - 1]] = value;
|
|
||||||
setEditedMetadata(newMetadata);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
if (!editedMetadata) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setIsSaving(true);
|
|
||||||
setError(null);
|
|
||||||
setSuccessMessage(null);
|
|
||||||
|
|
||||||
await MetadataAPI.updateMetadata(hashId, editedMetadata);
|
|
||||||
setMetadata(editedMetadata);
|
|
||||||
setSuccessMessage('Metadata saved successfully!');
|
|
||||||
|
|
||||||
if (onSaved) {
|
|
||||||
onSaved();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear success message after 3 seconds
|
|
||||||
setTimeout(() => setSuccessMessage(null), 3000);
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err.message);
|
|
||||||
} finally {
|
|
||||||
setIsSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReset = () => {
|
|
||||||
setEditedMetadata(metadata);
|
|
||||||
setError(null);
|
|
||||||
setSuccessMessage(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const togglePlugin = (pluginId: string) => {
|
|
||||||
setExpandedPlugins(prev => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(pluginId)) {
|
|
||||||
next.delete(pluginId);
|
|
||||||
} else {
|
|
||||||
next.add(pluginId);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const scrollToPlugin = (pluginId: string) => {
|
|
||||||
// Expand the plugin if not already expanded
|
|
||||||
if (!expandedPlugins.has(pluginId)) {
|
|
||||||
setExpandedPlugins(prev => new Set(prev).add(pluginId));
|
|
||||||
}
|
|
||||||
// Scroll to the plugin after a short delay to allow expansion
|
|
||||||
setTimeout(() => {
|
|
||||||
const element = document.getElementById(`plugin-${pluginId}`);
|
|
||||||
if (element) {
|
|
||||||
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <div className="metadata-editor-loading">Loading metadata...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error && !metadata) {
|
|
||||||
return (
|
|
||||||
<div className="metadata-editor-error">
|
|
||||||
<p>Error loading metadata: {error}</p>
|
|
||||||
<button onClick={loadMetadata}>Retry</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!metadata || !editedMetadata) {
|
|
||||||
return <div className="metadata-editor-empty">No metadata available</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasChanges = JSON.stringify(metadata) !== JSON.stringify(editedMetadata);
|
|
||||||
|
|
||||||
// Filter plugins that have data
|
|
||||||
const pluginsWithData = plugins.filter(plugin => {
|
|
||||||
if (!plugin.metadataSchema) return false;
|
|
||||||
return hasPluginData(plugin, editedMetadata);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get file path for download
|
|
||||||
const filePath = editedMetadata?.filePath as string | undefined;
|
|
||||||
const downloadUrl = filePath ? `/api/file/download?path=${encodeURIComponent(filePath)}` : null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="metadata-editor">
|
|
||||||
<div className="metadata-editor-header">
|
|
||||||
<h2>Edit Metadata</h2>
|
|
||||||
<div className="metadata-editor-actions">
|
|
||||||
{downloadUrl && (
|
|
||||||
<a
|
|
||||||
href={downloadUrl}
|
|
||||||
className="download-button"
|
|
||||||
title={`Download: ${filePath}`}
|
|
||||||
download
|
|
||||||
>
|
|
||||||
⬇ Download
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{hasChanges && (
|
|
||||||
<button onClick={handleReset} disabled={isSaving}>
|
|
||||||
Reset
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={isSaving || !hasChanges}
|
|
||||||
className="primary"
|
|
||||||
>
|
|
||||||
{isSaving ? 'Saving...' : 'Save Changes'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{successMessage && (
|
|
||||||
<div className="metadata-editor-message success">{successMessage}</div>
|
|
||||||
)}
|
|
||||||
{error && <div className="metadata-editor-message error">{error}</div>}
|
|
||||||
|
|
||||||
<div className="metadata-editor-hashes">
|
|
||||||
<div className="hashes-header">
|
|
||||||
<h3>Content Identifiers</h3>
|
|
||||||
</div>
|
|
||||||
<div className="hashes-grid">
|
|
||||||
<HashValue label="midhash256" value={hashId} primary />
|
|
||||||
{getHashValues(editedMetadata).map(({ key, label, value }) => (
|
|
||||||
<HashValue key={key} label={label} value={value} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{pluginsWithData.length > 0 && (
|
|
||||||
<div className="metadata-editor-chips">
|
|
||||||
{pluginsWithData.map((plugin) => (
|
|
||||||
<button
|
|
||||||
key={plugin.id}
|
|
||||||
className={`plugin-chip ${expandedPlugins.has(plugin.id) ? 'active' : ''}`}
|
|
||||||
style={{
|
|
||||||
'--chip-color': plugin.color || '#646cff',
|
|
||||||
backgroundColor: expandedPlugins.has(plugin.id) ? plugin.color || '#646cff' : 'transparent'
|
|
||||||
} as React.CSSProperties}
|
|
||||||
onClick={() => scrollToPlugin(plugin.id)}
|
|
||||||
>
|
|
||||||
{plugin.name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="metadata-editor-form">
|
|
||||||
{pluginsWithData.length === 0 ? (
|
|
||||||
<div className="metadata-editor-empty">
|
|
||||||
No metadata found for this file
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
pluginsWithData.map((plugin) => (
|
|
||||||
<div key={plugin.id} id={`plugin-${plugin.id}`}>
|
|
||||||
<PluginAccordion
|
|
||||||
plugin={plugin}
|
|
||||||
metadata={editedMetadata}
|
|
||||||
onChange={handleFieldChange}
|
|
||||||
isExpanded={expandedPlugins.has(plugin.id)}
|
|
||||||
onToggle={() => togglePlugin(plugin.id)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a plugin has any data in the metadata
|
|
||||||
*/
|
|
||||||
function hasPluginData(plugin: PluginSchema, metadata: FileMetadata): boolean {
|
|
||||||
const schema = plugin.metadataSchema;
|
|
||||||
if (!schema) return false;
|
|
||||||
|
|
||||||
for (const fieldKey of Object.keys(schema)) {
|
|
||||||
const fieldSchema = schema[fieldKey];
|
|
||||||
|
|
||||||
// For JSON type with pattern, check if any matching keys exist
|
|
||||||
if (fieldSchema.type === 'json' && fieldSchema.pattern) {
|
|
||||||
const patternPrefix = fieldSchema.pattern.replace(/\{n\}.*$/, '').replace(/\/$/, '');
|
|
||||||
if (hasKeyWithPrefix(metadata, patternPrefix)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For regular fields, check if value exists
|
|
||||||
if (getNestedValue(metadata, fieldKey) !== undefined) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getNestedValue(obj: any, path: string): any {
|
|
||||||
const parts = path.split('/');
|
|
||||||
let value = obj;
|
|
||||||
for (const part of parts) {
|
|
||||||
if (value === undefined || value === null) return undefined;
|
|
||||||
value = value[part];
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasKeyWithPrefix(obj: any, prefix: string, currentPath: string = ''): boolean {
|
|
||||||
if (!obj || typeof obj !== 'object') return false;
|
|
||||||
|
|
||||||
for (const key of Object.keys(obj)) {
|
|
||||||
const newPath = currentPath ? `${currentPath}/${key}` : key;
|
|
||||||
if (newPath.startsWith(prefix)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (typeof obj[key] === 'object' && obj[key] !== null) {
|
|
||||||
if (hasKeyWithPrefix(obj[key], prefix, newPath)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract all hash values (cid_*) from metadata
|
|
||||||
*/
|
|
||||||
function getHashValues(metadata: FileMetadata): { key: string; label: string; value: string }[] {
|
|
||||||
const hashOrder = [
|
|
||||||
{ key: 'cid_sha2-256', label: 'SHA-256' },
|
|
||||||
{ key: 'cid_sha3-256', label: 'SHA3-256' },
|
|
||||||
{ key: 'cid_sha3-384', label: 'SHA3-384' },
|
|
||||||
{ key: 'cid_sha1', label: 'SHA-1' },
|
|
||||||
{ key: 'cid_md5', label: 'MD5' },
|
|
||||||
{ key: 'cid_crc32', label: 'CRC32' },
|
|
||||||
{ key: 'cid_btih_v2', label: 'BitTorrent v2' },
|
|
||||||
];
|
|
||||||
|
|
||||||
return hashOrder
|
|
||||||
.filter(({ key }) => (metadata as any)[key])
|
|
||||||
.map(({ key, label }) => ({
|
|
||||||
key,
|
|
||||||
label,
|
|
||||||
value: (metadata as any)[key],
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Display a single hash value with copy functionality
|
|
||||||
*/
|
|
||||||
function HashValue({ label, value, primary }: { label: string; value: string; primary?: boolean }) {
|
|
||||||
const handleCopy = () => {
|
|
||||||
navigator.clipboard.writeText(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`hash-value ${primary ? 'hash-value-primary' : ''}`}>
|
|
||||||
<span className="hash-label">{label}</span>
|
|
||||||
<code className="hash-code" onClick={handleCopy} title="Click to copy">
|
|
||||||
{value}
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
.search-bar {
|
|
||||||
width: 100%;
|
|
||||||
padding: 1.5rem;
|
|
||||||
background-color: rgba(255, 255, 255, 0.05);
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-controls {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-input {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-input.unified-search {
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-bar button {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
min-width: 100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-hint {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: rgba(255, 255, 255, 0.5);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.search-controls {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-input,
|
|
||||||
.search-bar button {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: light) {
|
|
||||||
.search-hint {
|
|
||||||
color: rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import './SearchBar.css';
|
|
||||||
|
|
||||||
interface SearchBarProps {
|
|
||||||
onSearch: (query: string) => void;
|
|
||||||
isLoading: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SearchBar({ onSearch, isLoading }: SearchBarProps) {
|
|
||||||
const [query, setQuery] = useState('');
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (query.trim()) {
|
|
||||||
onSearch(query.trim());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form className="search-bar" onSubmit={handleSubmit}>
|
|
||||||
<div className="search-controls">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
|
||||||
placeholder="Search by title, filename, filepath, or hash ID..."
|
|
||||||
className="search-input unified-search"
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<button type="submit" disabled={isLoading || !query.trim()}>
|
|
||||||
{isLoading ? 'Searching...' : 'Search'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className="search-hint">
|
|
||||||
Search across all fields: title, filename, file path, and hash ID
|
|
||||||
</p>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
:root {
|
|
||||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
|
||||||
line-height: 1.5;
|
|
||||||
font-weight: 400;
|
|
||||||
|
|
||||||
color-scheme: light dark;
|
|
||||||
color: rgba(255, 255, 255, 0.87);
|
|
||||||
background-color: #242424;
|
|
||||||
|
|
||||||
font-synthesis: none;
|
|
||||||
text-rendering: optimizeLegibility;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
min-width: 320px;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 2em;
|
|
||||||
line-height: 1.1;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
padding: 0.6em 1.2em;
|
|
||||||
font-size: 1em;
|
|
||||||
font-weight: 500;
|
|
||||||
font-family: inherit;
|
|
||||||
background-color: #1a1a1a;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color 0.25s;
|
|
||||||
}
|
|
||||||
|
|
||||||
button:hover {
|
|
||||||
border-color: #646cff;
|
|
||||||
}
|
|
||||||
|
|
||||||
button:focus,
|
|
||||||
button:focus-visible {
|
|
||||||
outline: 4px auto -webkit-focus-ring-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
input,
|
|
||||||
textarea,
|
|
||||||
select {
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid #444;
|
|
||||||
padding: 0.5em;
|
|
||||||
font-size: 1em;
|
|
||||||
font-family: inherit;
|
|
||||||
background-color: #1a1a1a;
|
|
||||||
color: rgba(255, 255, 255, 0.87);
|
|
||||||
}
|
|
||||||
|
|
||||||
input:focus,
|
|
||||||
textarea:focus,
|
|
||||||
select:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #646cff;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: light) {
|
|
||||||
:root {
|
|
||||||
color: #213547;
|
|
||||||
background-color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
background-color: #f9f9f9;
|
|
||||||
}
|
|
||||||
|
|
||||||
input,
|
|
||||||
textarea,
|
|
||||||
select {
|
|
||||||
background-color: #f9f9f9;
|
|
||||||
color: #213547;
|
|
||||||
border-color: #ddd;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { StrictMode } from 'react';
|
|
||||||
import { createRoot } from 'react-dom/client';
|
|
||||||
import './index.css';
|
|
||||||
import App from './App.tsx';
|
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
|
||||||
<StrictMode>
|
|
||||||
<App />
|
|
||||||
</StrictMode>,
|
|
||||||
);
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
// API Response Types
|
|
||||||
export interface FileMetadata {
|
|
||||||
// File info
|
|
||||||
fileName?: string;
|
|
||||||
extension?: string;
|
|
||||||
byteSize?: number;
|
|
||||||
mimeType?: string;
|
|
||||||
fileType?: string;
|
|
||||||
|
|
||||||
// Filename parsing
|
|
||||||
title?: string;
|
|
||||||
titles?: string[];
|
|
||||||
originaltitle?: string;
|
|
||||||
showtitle?: string;
|
|
||||||
season?: number;
|
|
||||||
episode?: number;
|
|
||||||
movieYear?: number;
|
|
||||||
videoType?: string;
|
|
||||||
|
|
||||||
// General media metadata
|
|
||||||
year?: number;
|
|
||||||
imdbid?: string;
|
|
||||||
tmdbid?: string;
|
|
||||||
anidbid?: string;
|
|
||||||
premiered?: string;
|
|
||||||
genre?: string[];
|
|
||||||
releasedate?: string;
|
|
||||||
plot?: string;
|
|
||||||
director?: string;
|
|
||||||
rating?: number;
|
|
||||||
runtime?: number;
|
|
||||||
studio?: string;
|
|
||||||
art?: any;
|
|
||||||
actor?: any[];
|
|
||||||
aired?: string;
|
|
||||||
customrating?: string;
|
|
||||||
dateadded?: string;
|
|
||||||
sorttitle?: string;
|
|
||||||
mpaa?: string;
|
|
||||||
aspectratio?: string;
|
|
||||||
collectionnumber?: number;
|
|
||||||
criticrating?: number;
|
|
||||||
|
|
||||||
// Hashes
|
|
||||||
cid_crc32?: string;
|
|
||||||
cid_md5?: string;
|
|
||||||
cid_sha1?: string;
|
|
||||||
'cid_sha2-256'?: string;
|
|
||||||
'cid_sha3-256'?: string;
|
|
||||||
cid_sha3_384?: string;
|
|
||||||
|
|
||||||
// Video info
|
|
||||||
video?: {
|
|
||||||
codec?: string;
|
|
||||||
width?: number;
|
|
||||||
height?: number;
|
|
||||||
bitrate?: number;
|
|
||||||
fps?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Audio info
|
|
||||||
audio?: Array<{
|
|
||||||
codec?: string;
|
|
||||||
channels?: number;
|
|
||||||
language?: string;
|
|
||||||
bitrate?: number;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
// Subtitles
|
|
||||||
subtitles?: Array<{
|
|
||||||
language?: string;
|
|
||||||
codec?: string;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
// Other
|
|
||||||
extra?: any;
|
|
||||||
anime?: any;
|
|
||||||
fileinfo?: any;
|
|
||||||
trackers?: string[];
|
|
||||||
|
|
||||||
// Allow any additional properties
|
|
||||||
[key: string]: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SearchResult {
|
|
||||||
hashId: string;
|
|
||||||
metadata: FileMetadata;
|
|
||||||
score?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SearchResponse {
|
|
||||||
results: SearchResult[];
|
|
||||||
count: number;
|
|
||||||
total?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateResult {
|
|
||||||
status: string;
|
|
||||||
hashId: string;
|
|
||||||
message?: string;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BatchUpdateResponse {
|
|
||||||
status: string;
|
|
||||||
total: number;
|
|
||||||
success: number;
|
|
||||||
errors: number;
|
|
||||||
results: Array<{
|
|
||||||
hashId: string;
|
|
||||||
status: string;
|
|
||||||
error?: string;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Plugin Schema Types
|
|
||||||
export type SchemaFieldType = 'string' | 'number' | 'boolean' | 'select' | 'text' | 'array' | 'json' | 'cid' | 'languageString';
|
|
||||||
|
|
||||||
export interface SelectOption {
|
|
||||||
value: string;
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SchemaField {
|
|
||||||
label: string;
|
|
||||||
type?: SchemaFieldType;
|
|
||||||
readonly?: boolean;
|
|
||||||
indexed?: boolean;
|
|
||||||
options?: SelectOption[];
|
|
||||||
hint?: string;
|
|
||||||
link?: string;
|
|
||||||
pattern?: string;
|
|
||||||
min?: number;
|
|
||||||
max?: number;
|
|
||||||
placeholder?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PluginSchema {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
version: string;
|
|
||||||
description?: string;
|
|
||||||
priority?: number;
|
|
||||||
color?: string;
|
|
||||||
dependencies: string[];
|
|
||||||
active: boolean;
|
|
||||||
metadataSchema?: Record<string, SchemaField>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PluginsResponse {
|
|
||||||
plugins: PluginSchema[];
|
|
||||||
executionOrder: string[];
|
|
||||||
activeCount: number;
|
|
||||||
totalCount: number;
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
/**
|
|
||||||
* Format bytes to human readable format
|
|
||||||
*/
|
|
||||||
export function formatBytes(bytes: number | undefined): string {
|
|
||||||
if (!bytes) return 'N/A';
|
|
||||||
|
|
||||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
||||||
if (bytes === 0) return '0 B';
|
|
||||||
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
||||||
return Math.round((bytes / Math.pow(1024, i)) * 100) / 100 + ' ' + sizes[i];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format duration in seconds to HH:MM:SS
|
|
||||||
*/
|
|
||||||
export function formatDuration(seconds: number | undefined): string {
|
|
||||||
if (!seconds) return 'N/A';
|
|
||||||
|
|
||||||
const hours = Math.floor(seconds / 3600);
|
|
||||||
const minutes = Math.floor((seconds % 3600) / 60);
|
|
||||||
const secs = Math.floor(seconds % 60);
|
|
||||||
|
|
||||||
if (hours > 0) {
|
|
||||||
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Truncate hash to first and last 6 characters
|
|
||||||
*/
|
|
||||||
export function truncateHash(hash: string | undefined): string {
|
|
||||||
if (!hash) return 'N/A';
|
|
||||||
if (hash.length <= 16) return hash;
|
|
||||||
return `${hash.slice(0, 6)}...${hash.slice(-6)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get display name for a file from metadata
|
|
||||||
*/
|
|
||||||
export function getDisplayName(metadata: any): string {
|
|
||||||
if (metadata.title) {
|
|
||||||
if (metadata.season && metadata.episode) {
|
|
||||||
return `${metadata.title} S${String(metadata.season).padStart(2, '0')}E${String(metadata.episode).padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
if (metadata.movieYear) {
|
|
||||||
return `${metadata.title} (${metadata.movieYear})`;
|
|
||||||
}
|
|
||||||
return metadata.title;
|
|
||||||
}
|
|
||||||
return metadata.fileName || 'Unknown';
|
|
||||||
}
|
|
||||||
-1
@@ -1 +0,0 @@
|
|||||||
/// <reference types="vite/client" />
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2020",
|
|
||||||
"useDefineForClassFields": true,
|
|
||||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
|
||||||
"module": "ESNext",
|
|
||||||
"skipLibCheck": true,
|
|
||||||
|
|
||||||
/* Bundler mode */
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"moduleDetection": "force",
|
|
||||||
"noEmit": true,
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
|
|
||||||
/* Linting */
|
|
||||||
"strict": true,
|
|
||||||
"noUnusedLocals": true,
|
|
||||||
"noUnusedParameters": true,
|
|
||||||
"noFallthroughCasesInSwitch": true,
|
|
||||||
"noUncheckedSideEffectImports": true
|
|
||||||
},
|
|
||||||
"include": ["src"]
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/metadataApi.ts","./src/components/BulkMetadataEditor.tsx","./src/components/DynamicFieldRenderer.tsx","./src/components/DynamicPluginSection.tsx","./src/components/FileList.tsx","./src/components/KVTreeView.tsx","./src/components/LanguageCombobox.tsx","./src/components/MetadataEditor.tsx","./src/components/SearchBar.tsx","./src/types/index.ts","./src/utils/formatting.ts"],"version":"5.9.3"}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import { defineConfig } from 'vite';
|
|
||||||
import react from '@vitejs/plugin-react';
|
|
||||||
|
|
||||||
// https://vite.dev/config/
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [react()],
|
|
||||||
base: '/editor/',
|
|
||||||
build: {
|
|
||||||
outDir: 'dist',
|
|
||||||
assetsDir: 'assets',
|
|
||||||
emptyOutDir: true,
|
|
||||||
},
|
|
||||||
server: {
|
|
||||||
port: 5173,
|
|
||||||
proxy: {
|
|
||||||
'/api': {
|
|
||||||
target: 'http://localhost:3000',
|
|
||||||
changeOrigin: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import { Routes, Route } from 'react-router-dom';
|
import { Routes, Route } from 'react-router-dom';
|
||||||
import Welcome from './pages/Welcome';
|
import Welcome from './pages/Welcome';
|
||||||
import Monitor from './pages/Monitor';
|
import Monitor from './pages/Monitor';
|
||||||
import Mounts from './pages/Mounts';
|
|
||||||
import Plugins from './pages/Plugins';
|
import Plugins from './pages/Plugins';
|
||||||
import Duplicates from './pages/Duplicates';
|
import Duplicates from './pages/Duplicates';
|
||||||
import ServiceNav from './components/ServiceNav';
|
import ServiceNav from './components/ServiceNav';
|
||||||
|
|
||||||
|
// NOTE: Mounts page has been moved to meta-core dashboard
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
@@ -15,7 +16,6 @@ function App() {
|
|||||||
<Route path="/" element={<Welcome />} />
|
<Route path="/" element={<Welcome />} />
|
||||||
<Route path="/monitor" element={<Monitor />} />
|
<Route path="/monitor" element={<Monitor />} />
|
||||||
<Route path="/duplicates" element={<Duplicates />} />
|
<Route path="/duplicates" element={<Duplicates />} />
|
||||||
<Route path="/mounts" element={<Mounts />} />
|
|
||||||
<Route path="/plugins" element={<Plugins />} />
|
<Route path="/plugins" element={<Plugins />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,637 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
|
|
||||||
interface MountConfig {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
type: 'nfs' | 'smb' | 'rclone';
|
|
||||||
enabled: boolean;
|
|
||||||
mountPath: string;
|
|
||||||
// Type-specific
|
|
||||||
nfsServer?: string;
|
|
||||||
nfsPath?: string;
|
|
||||||
smbServer?: string;
|
|
||||||
smbShare?: string;
|
|
||||||
smbUsername?: string;
|
|
||||||
smbPassword?: string;
|
|
||||||
smbDomain?: string;
|
|
||||||
rcloneRemote?: string;
|
|
||||||
rclonePath?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MountStatus extends MountConfig {
|
|
||||||
mounted: boolean;
|
|
||||||
error?: string;
|
|
||||||
lastChecked: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RcloneRemote {
|
|
||||||
name: string;
|
|
||||||
type: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Mounts() {
|
|
||||||
const [mounts, setMounts] = useState<MountStatus[]>([]);
|
|
||||||
const [remotes, setRemotes] = useState<RcloneRemote[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [showAddModal, setShowAddModal] = useState(false);
|
|
||||||
const [newMount, setNewMount] = useState<Partial<MountConfig>>({
|
|
||||||
type: 'rclone',
|
|
||||||
enabled: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchData();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fetchData = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const [mountsRes, remotesRes] = await Promise.all([
|
|
||||||
fetch('/api/mounts'),
|
|
||||||
fetch('/api/mounts/rclone/remotes')
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (mountsRes.ok) {
|
|
||||||
const data = await mountsRes.json();
|
|
||||||
setMounts(data.mounts || []);
|
|
||||||
}
|
|
||||||
if (remotesRes.ok) {
|
|
||||||
const data = await remotesRes.json();
|
|
||||||
setRemotes(data.remotes || []);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to fetch mounts:', err);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMount = async (id: string) => {
|
|
||||||
try {
|
|
||||||
await fetch(`/api/mounts/${id}/mount`, { method: 'POST' });
|
|
||||||
fetchData();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to mount:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUnmount = async (id: string) => {
|
|
||||||
try {
|
|
||||||
await fetch(`/api/mounts/${id}/unmount`, { method: 'POST' });
|
|
||||||
fetchData();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to unmount:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
|
||||||
if (!confirm('Are you sure you want to remove this mount?')) return;
|
|
||||||
try {
|
|
||||||
await fetch(`/api/mounts/${id}`, { method: 'DELETE' });
|
|
||||||
fetchData();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to delete mount:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddMount = async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/mounts', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(newMount),
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
setShowAddModal(false);
|
|
||||||
setNewMount({ type: 'rclone', enabled: true });
|
|
||||||
fetchData();
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to add mount:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getMountIcon = (type: string) => {
|
|
||||||
switch (type) {
|
|
||||||
case 'nfs':
|
|
||||||
return (
|
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"/>
|
|
||||||
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"/>
|
|
||||||
<line x1="6" y1="6" x2="6.01" y2="6"/>
|
|
||||||
<line x1="6" y1="18" x2="6.01" y2="18"/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
case 'smb':
|
|
||||||
return (
|
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
|
||||||
<line x1="12" y1="11" x2="12" y2="17"/>
|
|
||||||
<line x1="9" y1="14" x2="15" y2="14"/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
case 'rclone':
|
|
||||||
return (
|
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z"/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mounts-page">
|
|
||||||
<div className="mounts-header">
|
|
||||||
<h1>Remote Mounts</h1>
|
|
||||||
<div className="mounts-controls">
|
|
||||||
<a href="/rclone/" target="_blank" rel="noopener noreferrer" className="btn btn-secondary">
|
|
||||||
rclone Web UI
|
|
||||||
</a>
|
|
||||||
<button className="btn btn-primary" onClick={() => setShowAddModal(true)}>
|
|
||||||
Add Mount
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{remotes.length > 0 && (
|
|
||||||
<div className="card">
|
|
||||||
<h2>Available rclone Remotes</h2>
|
|
||||||
<div className="remotes-grid">
|
|
||||||
{remotes.map((remote) => (
|
|
||||||
<div key={remote.name} className="remote-item">
|
|
||||||
<span className="remote-name">{remote.name}</span>
|
|
||||||
<span className="remote-type">{remote.type}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className="loading">Loading mounts...</div>
|
|
||||||
) : mounts.length === 0 ? (
|
|
||||||
<div className="empty-state">
|
|
||||||
<p>No mounts configured</p>
|
|
||||||
<p className="hint">Add a mount to connect remote storage like NFS, SMB, or cloud storage via rclone</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="mounts-grid">
|
|
||||||
{mounts.map((mount) => (
|
|
||||||
<div key={mount.id} className={`mount-card ${mount.mounted ? 'mounted' : ''}`}>
|
|
||||||
<div className="mount-header">
|
|
||||||
<div className="mount-icon">{getMountIcon(mount.type)}</div>
|
|
||||||
<div className="mount-info">
|
|
||||||
<h3>{mount.name}</h3>
|
|
||||||
<span className="mount-type">{mount.type.toUpperCase()}</span>
|
|
||||||
</div>
|
|
||||||
<div className={`mount-status ${mount.mounted ? 'status-success' : 'status-error'}`}>
|
|
||||||
{mount.mounted ? 'Mounted' : 'Unmounted'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mount-details">
|
|
||||||
<div className="detail">
|
|
||||||
<span className="label">Mount Path</span>
|
|
||||||
<span className="value">{mount.mountPath}</span>
|
|
||||||
</div>
|
|
||||||
{mount.type === 'nfs' && (
|
|
||||||
<div className="detail">
|
|
||||||
<span className="label">Server</span>
|
|
||||||
<span className="value">{mount.nfsServer}:{mount.nfsPath}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{mount.type === 'smb' && (
|
|
||||||
<div className="detail">
|
|
||||||
<span className="label">Share</span>
|
|
||||||
<span className="value">//{mount.smbServer}/{mount.smbShare}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{mount.type === 'rclone' && (
|
|
||||||
<div className="detail">
|
|
||||||
<span className="label">Remote</span>
|
|
||||||
<span className="value">{mount.rcloneRemote}{mount.rclonePath || ''}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{mount.error && (
|
|
||||||
<div className="mount-error">{mount.error}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mount-actions">
|
|
||||||
{mount.mounted ? (
|
|
||||||
<button className="btn btn-secondary" onClick={() => handleUnmount(mount.id)}>
|
|
||||||
Unmount
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button className="btn btn-primary" onClick={() => handleMount(mount.id)}>
|
|
||||||
Mount
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button className="btn btn-danger" onClick={() => handleDelete(mount.id)}>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showAddModal && (
|
|
||||||
<div className="modal-overlay" onClick={() => setShowAddModal(false)}>
|
|
||||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<div className="modal-header">
|
|
||||||
<h2>Add Mount</h2>
|
|
||||||
<button className="close-btn" onClick={() => setShowAddModal(false)}>×</button>
|
|
||||||
</div>
|
|
||||||
<div className="modal-content">
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Name</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newMount.name || ''}
|
|
||||||
onChange={(e) => setNewMount({ ...newMount, name: e.target.value })}
|
|
||||||
placeholder="My Remote Storage"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Type</label>
|
|
||||||
<select
|
|
||||||
value={newMount.type}
|
|
||||||
onChange={(e) => setNewMount({ ...newMount, type: e.target.value as any })}
|
|
||||||
>
|
|
||||||
<option value="rclone">rclone (Cloud)</option>
|
|
||||||
<option value="nfs">NFS</option>
|
|
||||||
<option value="smb">SMB/CIFS</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="form-hint">Mount path will be: /files/{newMount.name ? newMount.name.toLowerCase().replace(/[^a-z0-9-_]/g, '-').slice(0, 64) : '{name}'}</p>
|
|
||||||
|
|
||||||
{newMount.type === 'rclone' && (
|
|
||||||
<>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>rclone Remote</label>
|
|
||||||
<select
|
|
||||||
value={newMount.rcloneRemote || ''}
|
|
||||||
onChange={(e) => setNewMount({ ...newMount, rcloneRemote: e.target.value })}
|
|
||||||
>
|
|
||||||
<option value="">Select remote...</option>
|
|
||||||
{remotes.map((r) => (
|
|
||||||
<option key={r.name} value={r.name}>{r.name} ({r.type})</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Path in Remote (optional)</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newMount.rclonePath || ''}
|
|
||||||
onChange={(e) => setNewMount({ ...newMount, rclonePath: e.target.value })}
|
|
||||||
placeholder="/path/to/folder"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{newMount.type === 'nfs' && (
|
|
||||||
<>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>NFS Server</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newMount.nfsServer || ''}
|
|
||||||
onChange={(e) => setNewMount({ ...newMount, nfsServer: e.target.value })}
|
|
||||||
placeholder="192.168.1.100"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>NFS Path</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newMount.nfsPath || ''}
|
|
||||||
onChange={(e) => setNewMount({ ...newMount, nfsPath: e.target.value })}
|
|
||||||
placeholder="/export/media"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{newMount.type === 'smb' && (
|
|
||||||
<>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>SMB Server</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newMount.smbServer || ''}
|
|
||||||
onChange={(e) => setNewMount({ ...newMount, smbServer: e.target.value })}
|
|
||||||
placeholder="192.168.1.100"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Share Name</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newMount.smbShare || ''}
|
|
||||||
onChange={(e) => setNewMount({ ...newMount, smbShare: e.target.value })}
|
|
||||||
placeholder="media"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Username (optional)</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newMount.smbUsername || ''}
|
|
||||||
onChange={(e) => setNewMount({ ...newMount, smbUsername: e.target.value })}
|
|
||||||
placeholder="user"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Password (optional)</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={newMount.smbPassword || ''}
|
|
||||||
onChange={(e) => setNewMount({ ...newMount, smbPassword: e.target.value })}
|
|
||||||
placeholder="********"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Domain (optional)</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newMount.smbDomain || ''}
|
|
||||||
onChange={(e) => setNewMount({ ...newMount, smbDomain: e.target.value })}
|
|
||||||
placeholder="WORKGROUP"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="form-actions">
|
|
||||||
<button className="btn btn-secondary" onClick={() => setShowAddModal(false)}>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button className="btn btn-primary" onClick={handleAddMount}>
|
|
||||||
Add Mount
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<style>{`
|
|
||||||
.mounts-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mounts-header h1 {
|
|
||||||
font-size: 1.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mounts-controls {
|
|
||||||
display: flex;
|
|
||||||
gap: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.remotes-grid {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.remote-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 8px 15px;
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.remote-name {
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.remote-type {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading, .empty-state {
|
|
||||||
text-align: center;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
padding: 60px;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state .hint {
|
|
||||||
margin-top: 10px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mounts-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mount-card {
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 20px;
|
|
||||||
transition: border-color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mount-card.mounted {
|
|
||||||
border-color: var(--accent-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mount-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 15px;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mount-icon {
|
|
||||||
color: var(--accent-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mount-info {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mount-info h3 {
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mount-type {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mount-status {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
padding: 4px 10px;
|
|
||||||
border-radius: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mount-details {
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mount-details .detail {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 8px 0;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mount-details .detail:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mount-details .label {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mount-details .value {
|
|
||||||
font-family: monospace;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mount-error {
|
|
||||||
background: rgba(255, 107, 107, 0.1);
|
|
||||||
color: var(--error);
|
|
||||||
padding: 10px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mount-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger {
|
|
||||||
background: transparent;
|
|
||||||
color: var(--error);
|
|
||||||
border: 1px solid var(--error);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger:hover {
|
|
||||||
background: rgba(255, 107, 107, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-overlay {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.7);
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 1000;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 40px 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal {
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 12px;
|
|
||||||
width: 90%;
|
|
||||||
max-width: 500px;
|
|
||||||
max-height: none;
|
|
||||||
margin: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 20px;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-header h2 {
|
|
||||||
color: var(--accent-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-btn {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 1.5rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-content {
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group label {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group input,
|
|
||||||
.form-group select {
|
|
||||||
width: 100%;
|
|
||||||
padding: 10px 15px;
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 6px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group input:focus,
|
|
||||||
.form-group select:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--accent-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 10px;
|
|
||||||
margin-top: 20px;
|
|
||||||
padding-top: 20px;
|
|
||||||
border-top: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-hint {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin-bottom: 20px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
border-radius: 6px;
|
|
||||||
font-family: monospace;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Mounts;
|
|
||||||
@@ -45,16 +45,6 @@ function Welcome() {
|
|||||||
<p>Manage metadata plugins</p>
|
<p>Manage metadata plugins</p>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link to="/mounts" className="link-card">
|
|
||||||
<div className="link-icon">
|
|
||||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h3>Mounts</h3>
|
|
||||||
<p>Manage remote storage</p>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<a href="/editor/" className="link-card">
|
<a href="/editor/" className="link-card">
|
||||||
<div className="link-icon">
|
<div className="link-icon">
|
||||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
|||||||
Generated
+28
@@ -58,6 +58,9 @@ importers:
|
|||||||
dotenv:
|
dotenv:
|
||||||
specifier: ^16.4.1
|
specifier: ^16.4.1
|
||||||
version: 16.6.1
|
version: 16.6.1
|
||||||
|
eventsource:
|
||||||
|
specifier: ^4.1.0
|
||||||
|
version: 4.1.0
|
||||||
fastify:
|
fastify:
|
||||||
specifier: ^5.6.1
|
specifier: ^5.6.1
|
||||||
version: 5.6.2
|
version: 5.6.2
|
||||||
@@ -104,6 +107,9 @@ importers:
|
|||||||
'@types/dockerode':
|
'@types/dockerode':
|
||||||
specifier: ^3.3.31
|
specifier: ^3.3.31
|
||||||
version: 3.3.47
|
version: 3.3.47
|
||||||
|
'@types/eventsource':
|
||||||
|
specifier: ^3.0.0
|
||||||
|
version: 3.0.0
|
||||||
'@types/fluent-ffmpeg':
|
'@types/fluent-ffmpeg':
|
||||||
specifier: ^2.1.24
|
specifier: ^2.1.24
|
||||||
version: 2.1.28
|
version: 2.1.28
|
||||||
@@ -1423,6 +1429,10 @@ packages:
|
|||||||
'@types/[email protected]':
|
'@types/[email protected]':
|
||||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||||
|
|
||||||
|
'@types/[email protected]':
|
||||||
|
resolution: {integrity: sha512-yEhFj31FTD29DtNeqePu+A+lD6loRef6YOM5XfN1kUwBHyy2DySGlA3jJU+FbQSkrfmlBVluf2Dub/OyReFGKA==}
|
||||||
|
deprecated: This is a stub types definition. eventsource provides its own type definitions, so you do not need this installed.
|
||||||
|
|
||||||
'@types/[email protected]':
|
'@types/[email protected]':
|
||||||
resolution: {integrity: sha512-5ovxsDwBcPfJ+eYs1I/ZpcYCnkce7pvH9AHSvrZllAp1ZPpTRDZAFjF3TRFbukxSgIYTTNYePbS0rKUmaxVbXw==}
|
resolution: {integrity: sha512-5ovxsDwBcPfJ+eYs1I/ZpcYCnkce7pvH9AHSvrZllAp1ZPpTRDZAFjF3TRFbukxSgIYTTNYePbS0rKUmaxVbXw==}
|
||||||
|
|
||||||
@@ -2031,6 +2041,14 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
|
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==}
|
||||||
|
engines: {node: '>=18.0.0'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-2GuF51iuHX6A9xdTccMTsNb7VO0lHZihApxhvQzJB5A03DvHDd2FQepodbMaztPBmBcE/ox7o2gqaxGhYB9LhQ==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==}
|
resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -4621,6 +4639,10 @@ snapshots:
|
|||||||
|
|
||||||
'@types/[email protected]': {}
|
'@types/[email protected]': {}
|
||||||
|
|
||||||
|
'@types/[email protected]':
|
||||||
|
dependencies:
|
||||||
|
eventsource: 4.1.0
|
||||||
|
|
||||||
'@types/[email protected]':
|
'@types/[email protected]':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 20.19.27
|
'@types/node': 20.19.27
|
||||||
@@ -5299,6 +5321,12 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
dependencies:
|
||||||
|
eventsource-parser: 3.0.6
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
cross-spawn: 7.0.6
|
cross-spawn: 7.0.6
|
||||||
|
|||||||
Reference in New Issue
Block a user