meta-sort cleanup, add editor, remove duplicate
This commit is contained in:
@@ -1,166 +0,0 @@
|
||||
/**
|
||||
* HTTP REST API Server for Duplicate Files
|
||||
*
|
||||
* Provides endpoints for accessing duplicate file detection results.
|
||||
* Separate from FUSE API to maintain proper separation of concerns.
|
||||
*/
|
||||
|
||||
import * as http from 'http';
|
||||
|
||||
export interface DuplicatesAPIConfig {
|
||||
/** Port to listen on (default: 3003) */
|
||||
port?: number;
|
||||
/** Host to bind to (default: 'localhost') */
|
||||
host?: string;
|
||||
/** Enable CORS (default: true) */
|
||||
enableCors?: boolean;
|
||||
/** Mount path for duplicates endpoint (default: '/duplicates') */
|
||||
mountPath?: string;
|
||||
}
|
||||
|
||||
export class DuplicatesAPI {
|
||||
private server: http.Server | null = null;
|
||||
private config: Required<DuplicatesAPIConfig>;
|
||||
|
||||
constructor(config: DuplicatesAPIConfig = {}) {
|
||||
this.config = {
|
||||
port: config.port ?? 3003,
|
||||
host: config.host ?? 'localhost',
|
||||
enableCors: config.enableCors ?? true,
|
||||
mountPath: config.mountPath ?? '/duplicates'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the HTTP server
|
||||
*/
|
||||
public async start(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.server = http.createServer(this.handleRequest.bind(this));
|
||||
|
||||
this.server.on('error', reject);
|
||||
|
||||
this.server.listen(this.config.port, this.config.host, () => {
|
||||
console.log(`Duplicates API listening on http://${this.config.host}:${this.config.port}${this.config.mountPath}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the HTTP server
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
if (!this.server) {
|
||||
return;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.server!.close((err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
console.log('Duplicates API stopped');
|
||||
this.server = null;
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming HTTP requests
|
||||
*/
|
||||
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
||||
// CORS headers
|
||||
if (this.config.enableCors) {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(req.url!, `http://${req.headers.host}`);
|
||||
const pathname = url.pathname;
|
||||
const method = req.method!;
|
||||
|
||||
// Route the request
|
||||
if (method === 'GET' && pathname === this.config.mountPath) {
|
||||
await this.handleDuplicates(res);
|
||||
} else if (method === 'GET' && pathname === '/health') {
|
||||
this.handleHealth(res);
|
||||
} else {
|
||||
this.sendError(res, 404, 'Not Found');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error handling request:', error);
|
||||
this.sendError(res, 500, 'Internal Server Error', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send JSON response
|
||||
*/
|
||||
private sendJson(res: http.ServerResponse, data: any, statusCode: number = 200): void {
|
||||
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Send error response
|
||||
*/
|
||||
private sendError(res: http.ServerResponse, statusCode: number, message: string, error?: any): void {
|
||||
this.sendJson(res, {
|
||||
error: message,
|
||||
details: error?.message ?? error
|
||||
}, statusCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /duplicates
|
||||
* Get duplicate files
|
||||
* NOTE: This API is deprecated. Use UnifiedAPIServer's /api/duplicates endpoint instead.
|
||||
* Disk-based duplicate logging has been removed for performance.
|
||||
*/
|
||||
private async handleDuplicates(res: http.ServerResponse): Promise<void> {
|
||||
try {
|
||||
// Return empty results - duplicate detection now happens in-memory via UnifiedAPIServer
|
||||
const hashDuplicates: Array<{ key: string; files: string[]; virtualPath?: string }> = [];
|
||||
const titleDuplicates: Array<{ key: string; files: string[]; virtualPath?: string }> = [];
|
||||
|
||||
this.sendJson(res, {
|
||||
hashDuplicates,
|
||||
titleDuplicates,
|
||||
stats: {
|
||||
hashGroupCount: 0,
|
||||
hashFileCount: 0,
|
||||
titleGroupCount: 0,
|
||||
titleFileCount: 0
|
||||
},
|
||||
deprecated: true,
|
||||
message: 'This API is deprecated. Use UnifiedAPIServer /api/duplicates endpoint instead.'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error in duplicates endpoint:', error);
|
||||
this.sendError(res, 500, 'Failed to get duplicate data', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /health
|
||||
* Health check endpoint
|
||||
*/
|
||||
private handleHealth(res: http.ServerResponse): void {
|
||||
this.sendJson(res, {
|
||||
status: 'ok',
|
||||
service: 'duplicates-api',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import fastifyCors from '@fastify/cors';
|
||||
import { VirtualFileSystem } from './VirtualFileSystem.js';
|
||||
import { performanceMetrics } from '../metrics/PerformanceMetrics.js';
|
||||
import { UnifiedProcessingStateManager } from '../logic/UnifiedProcessingStateManager.js';
|
||||
import { DuplicateResult } from '../logic/DuplicateFinder.js';
|
||||
import type { IKVClient } from '../kv/IKVClient.js';
|
||||
import type { KVManager } from '../kv/KVManager.js';
|
||||
import type { ExtendedProcessingSnapshot, IKVClientWithPubSub } from '../types/ExtendedInterfaces.js';
|
||||
@@ -35,7 +34,6 @@ export class UnifiedAPIServer {
|
||||
private app: FastifyInstance;
|
||||
private vfs: VirtualFileSystem;
|
||||
private unifiedStateManager: UnifiedProcessingStateManager | null = null;
|
||||
private getDuplicateResult: (() => DuplicateResult | null) | null = null;
|
||||
private kvClient: IKVClient | null = null;
|
||||
private kvManager: KVManager | null = null;
|
||||
private getQueueStatus: (() => any) | null = null;
|
||||
@@ -56,7 +54,6 @@ export class UnifiedAPIServer {
|
||||
vfs: VirtualFileSystem,
|
||||
config: UnifiedAPIServerConfig = {},
|
||||
unifiedStateManager?: UnifiedProcessingStateManager,
|
||||
getDuplicateResult?: () => DuplicateResult | null,
|
||||
kvClient?: IKVClient,
|
||||
backgroundQueueConcurrency?: number,
|
||||
fastQueueConcurrency?: number,
|
||||
@@ -67,7 +64,6 @@ export class UnifiedAPIServer {
|
||||
) {
|
||||
this.vfs = vfs;
|
||||
this.unifiedStateManager = unifiedStateManager || null;
|
||||
this.getDuplicateResult = getDuplicateResult || null;
|
||||
this.kvClient = kvClient || null;
|
||||
this.kvManager = kvManager || null;
|
||||
this.getQueueStatus = getQueueStatus || null;
|
||||
@@ -161,7 +157,6 @@ export class UnifiedAPIServer {
|
||||
services: {
|
||||
fuse: 'ok',
|
||||
metrics: 'ok',
|
||||
duplicates: 'ok',
|
||||
unifiedProcessing: this.unifiedStateManager ? 'ok' : 'unavailable'
|
||||
}
|
||||
};
|
||||
@@ -187,8 +182,7 @@ export class UnifiedAPIServer {
|
||||
// Metrics API routes (under /api/metrics)
|
||||
this.setupMetricsRoutes();
|
||||
|
||||
// Duplicates API routes (under /api/duplicates)
|
||||
this.setupDuplicatesRoutes();
|
||||
// NOTE: Duplicates API has been moved to meta-dup service (port 8183)
|
||||
|
||||
// NOTE: Metadata API routes (/api/metadata/*) and KV Browser routes (/api/kv/*)
|
||||
// have been moved to meta-core Go API. The editor UI now uses meta-core's endpoints.
|
||||
@@ -492,59 +486,8 @@ export class UnifiedAPIServer {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup Duplicates API routes
|
||||
*/
|
||||
private setupDuplicatesRoutes(): void {
|
||||
// Get duplicate files (from stored duplicate data)
|
||||
this.app.get('/api/duplicates', async (request, reply) => {
|
||||
try {
|
||||
// Get stored duplicate result from WatchedFileProcessor
|
||||
const duplicateResult = this.getDuplicateResult ? this.getDuplicateResult() : null;
|
||||
|
||||
if (!duplicateResult) {
|
||||
// No duplicates found yet (processing hasn't completed)
|
||||
return {
|
||||
hashDuplicates: [],
|
||||
titleDuplicates: [],
|
||||
stats: {
|
||||
hashGroupCount: 0,
|
||||
hashFileCount: 0,
|
||||
titleGroupCount: 0,
|
||||
titleFileCount: 0
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
hashDuplicates: duplicateResult.hashDuplicates,
|
||||
titleDuplicates: duplicateResult.titleDuplicates,
|
||||
stats: {
|
||||
hashGroupCount: duplicateResult.hashDuplicates.length,
|
||||
hashFileCount: duplicateResult.hashDuplicates.reduce((sum, g) => sum + g.files.length, 0),
|
||||
titleGroupCount: duplicateResult.titleDuplicates.length,
|
||||
titleFileCount: duplicateResult.titleDuplicates.reduce((sum, g) => sum + g.files.length, 0)
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error retrieving duplicates:', error);
|
||||
return reply.status(500).send({
|
||||
error: 'Failed to retrieve duplicates',
|
||||
details: (error as Error).message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Refresh duplicates (trigger re-computation)
|
||||
this.app.post('/api/duplicates/refresh', async (request, reply) => {
|
||||
// Currently just acknowledges the request
|
||||
// The duplicate detection runs automatically during processing
|
||||
return {
|
||||
status: 'ok',
|
||||
message: 'Duplicate refresh triggered'
|
||||
};
|
||||
});
|
||||
}
|
||||
// NOTE: Duplicates API has been moved to meta-dup service (port 8183)
|
||||
// Use http://localhost:8183/api/duplicates for duplicate detection
|
||||
|
||||
// NOTE: setupMetadataRoutes() and setupKVBrowserRoutes() have been removed.
|
||||
// The metadata editor API (/api/metadata/*) and KV browser API (/api/kv/*)
|
||||
@@ -1905,7 +1848,6 @@ export class UnifiedAPIServer {
|
||||
console.log(`Unified API Server listening on http://${this.config.host}:${this.config.port}`);
|
||||
console.log(` - FUSE API: http://${this.config.host}:${this.config.port}/api/fuse/*`);
|
||||
console.log(` - Metrics API: http://${this.config.host}:${this.config.port}/api/metrics`);
|
||||
console.log(` - Duplicates API: http://${this.config.host}:${this.config.port}/api/duplicates`);
|
||||
if (this.kvClient) {
|
||||
console.log(` - Metadata API: http://${this.config.host}:${this.config.port}/api/metadata/* (GET/PUT/DELETE/POST)`);
|
||||
console.log(` - KV Browser API: http://${this.config.host}:${this.config.port}/api/kv/* (GET)`);
|
||||
|
||||
@@ -9,7 +9,6 @@ import { expect } from 'chai';
|
||||
import { UnifiedAPIServer } from './UnifiedAPIServer.js';
|
||||
import { VirtualFileSystem } from './VirtualFileSystem.js';
|
||||
import { UnifiedProcessingStateManager } from '../logic/UnifiedProcessingStateManager.js';
|
||||
import { DuplicateResult } from '../logic/DuplicateFinder.js';
|
||||
import type { IKVClient } from '../kv/IKVClient.js';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
|
||||
@@ -205,25 +204,7 @@ class MockProcessingStateManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock Duplicate Result Provider
|
||||
*/
|
||||
function createMockDuplicateResult(): DuplicateResult {
|
||||
return {
|
||||
hashDuplicates: [
|
||||
{
|
||||
key: 'abc123',
|
||||
files: ['/files/movie1.mp4', '/files/movie1-copy.mp4']
|
||||
}
|
||||
],
|
||||
titleDuplicates: [
|
||||
{
|
||||
key: 'Movie Title',
|
||||
files: ['/files/movie1.mp4', '/files/movie2.mp4']
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
// NOTE: Duplicates API has been moved to meta-dup service (port 8183)
|
||||
|
||||
// =============================================================================
|
||||
// Test Suite
|
||||
@@ -234,7 +215,6 @@ describe('UnifiedAPIServer', function() {
|
||||
let app: FastifyInstance;
|
||||
let mockKV: MockKVClient;
|
||||
let mockStateManager: MockProcessingStateManager;
|
||||
let duplicateResult = createMockDuplicateResult();
|
||||
|
||||
before(async function() {
|
||||
// Create mocks
|
||||
@@ -268,7 +248,6 @@ describe('UnifiedAPIServer', function() {
|
||||
vfs,
|
||||
{ port: 3000, host: 'localhost', enableCors: true },
|
||||
mockStateManager as any,
|
||||
() => duplicateResult,
|
||||
mockKV as any,
|
||||
4, // backgroundQueueConcurrency
|
||||
16, // fastQueueConcurrency
|
||||
@@ -396,36 +375,8 @@ describe('UnifiedAPIServer', function() {
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Duplicates API (Duplicates Page)
|
||||
// =========================================================================
|
||||
|
||||
describe('Duplicates API (/api/duplicates)', function() {
|
||||
it('GET /api/duplicates returns duplicate analysis', async function() {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/duplicates'
|
||||
});
|
||||
|
||||
expect(response.statusCode).to.equal(200);
|
||||
const body = JSON.parse(response.payload);
|
||||
expect(body).to.have.property('hashDuplicates');
|
||||
expect(body).to.have.property('titleDuplicates');
|
||||
expect(Array.isArray(body.hashDuplicates)).to.be.true;
|
||||
expect(Array.isArray(body.titleDuplicates)).to.be.true;
|
||||
});
|
||||
|
||||
it('POST /api/duplicates/refresh acknowledges request', async function() {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/duplicates/refresh'
|
||||
});
|
||||
|
||||
expect(response.statusCode).to.equal(200);
|
||||
const body = JSON.parse(response.payload);
|
||||
expect(body).to.have.property('status');
|
||||
});
|
||||
});
|
||||
// NOTE: Duplicates API has been moved to meta-dup service (port 8183)
|
||||
// See dev/test/suites/meta-dup.bats for duplicate detection tests
|
||||
|
||||
// =========================================================================
|
||||
// Stats API (Dashboard)
|
||||
|
||||
@@ -74,7 +74,6 @@ fileProcessor.setPipeline(pipeline);
|
||||
// VFS and state manager are available immediately
|
||||
const vfs = fileProcessor.getVirtualFileSystem();
|
||||
const unifiedStateManager = fileProcessor.getUnifiedStateManager();
|
||||
const getDuplicateResult = () => fileProcessor.getDuplicateResult();
|
||||
|
||||
// API Server will be initialized after KV is ready (deferred to async init)
|
||||
let apiServer: UnifiedAPIServer | null = null;
|
||||
@@ -164,7 +163,7 @@ process.on('SIGINT', async () => {
|
||||
port: config.FUSE_API_PORT,
|
||||
host: config.FUSE_API_HOST,
|
||||
enableCors: true,
|
||||
}, unifiedStateManager, getDuplicateResult, kvClient || undefined, backgroundQueueConcurrency, fastQueueConcurrency, () => fileProcessor.getQueueStatus(), kvManager || undefined, getPluginManagerInstance, () => fileProcessor.getTaskScheduler());
|
||||
}, unifiedStateManager, kvClient || undefined, backgroundQueueConcurrency, fastQueueConcurrency, () => fileProcessor.getQueueStatus(), kvManager || undefined, getPluginManagerInstance, () => fileProcessor.getTaskScheduler());
|
||||
|
||||
await apiServer.start();
|
||||
console.log('Unified API Server started successfully');
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
import {CID_ALGORITHM_NAMES} from "@metazla/meta-hash";
|
||||
import {VideoMeta, HashMeta, VersionMeta, FileNameMeta, FileStatMeta} from "@metazla/meta-interface";
|
||||
import {renamingRule} from "../config/RenamingRule.js";
|
||||
|
||||
type Local = VideoMeta & HashMeta & VersionMeta & FileNameMeta & FileStatMeta;
|
||||
|
||||
export interface DuplicateGroup {
|
||||
key: string;
|
||||
files: string[];
|
||||
virtualPath?: string;
|
||||
}
|
||||
|
||||
export interface DuplicateResult {
|
||||
hashDuplicates: DuplicateGroup[];
|
||||
titleDuplicates: DuplicateGroup[];
|
||||
}
|
||||
|
||||
export class DuplicateFinder {
|
||||
async findDuplicates(database: Map<string, Local>): Promise<DuplicateResult> {
|
||||
let [hashDuplicates, titleDuplicates] = await Promise.all([
|
||||
this.findDuplicatesGeneric(database, this.hashKeyExtractor, 'remove'),
|
||||
this.findDuplicatesGeneric(database, this.pathExtractor, 'version')
|
||||
]);
|
||||
|
||||
return {
|
||||
hashDuplicates,
|
||||
titleDuplicates
|
||||
};
|
||||
}
|
||||
|
||||
private hashKeyExtractor(metadata: Local): string | null {
|
||||
return metadata[CID_ALGORITHM_NAMES.sha256] ?? null;
|
||||
}
|
||||
|
||||
private pathExtractor(metadata: Local): string | null {
|
||||
//case insensitive
|
||||
try {
|
||||
return renamingRule(metadata, '')?.toLowerCase() ?? null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async findDuplicatesGeneric(
|
||||
database: Map<string, Local>,
|
||||
keyExtractor: (metadata: Local) => string | null,
|
||||
action?: 'remove' | 'version'
|
||||
): Promise<DuplicateGroup[]> {
|
||||
const duplicates = new Map<string, string[]>();
|
||||
const duplicateGroups: DuplicateGroup[] = [];
|
||||
|
||||
for (const [filePath, metadata] of database) {
|
||||
const key = keyExtractor(metadata);
|
||||
if (key) {
|
||||
const files = duplicates.get(key) ?? [];
|
||||
files.push(filePath);
|
||||
duplicates.set(key, files);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, files] of duplicates) {
|
||||
if (files.length > 1) {
|
||||
// Store duplicate group before modifying database
|
||||
const sortedFiles = [...files].sort();
|
||||
|
||||
// Generate virtualPath for both hash and title duplicates
|
||||
let virtualPath: string | undefined;
|
||||
if (action === 'version') {
|
||||
// For title duplicates, use the key (normalized title path)
|
||||
virtualPath = key;
|
||||
} else if (action === 'remove') {
|
||||
// For hash duplicates, use renamingRule on the first file's metadata
|
||||
const firstFile = sortedFiles[0];
|
||||
const metadata = database.get(firstFile);
|
||||
if (metadata) {
|
||||
try {
|
||||
virtualPath = renamingRule(metadata, '');
|
||||
} catch (e) {
|
||||
// If renamingRule fails, virtualPath remains undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
duplicateGroups.push({
|
||||
key,
|
||||
files: sortedFiles,
|
||||
virtualPath
|
||||
});
|
||||
|
||||
//alphabetical order
|
||||
const remainingFiles = files.slice(1).sort();
|
||||
//rename all problematic entries V2, V3, V4, V5
|
||||
let i = 2;
|
||||
for (const file of remainingFiles) {
|
||||
if (action === 'remove') {
|
||||
database.delete(file);
|
||||
} else if (action === 'version') {
|
||||
let definition = database.get(file);
|
||||
definition.version = "V" + i;
|
||||
if (definition.fileType !== "subtitle") {//allow multiple version of subtitle in the same folder
|
||||
definition.extra = "true";//mark as extra so it don't pollute the main list
|
||||
}
|
||||
i++;
|
||||
} else {
|
||||
throw new Error();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return duplicateGroups;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import {dirname, join, parse} from 'path';
|
||||
import {config} from "../config/EnvConfig.js";
|
||||
import {FileProcessorInterface} from "@metazla/meta-hash";
|
||||
import {MetaDataToFolderStruct} from "./MetaDataToFolderStruct.js";
|
||||
import {DuplicateFinder, DuplicateResult} from "./DuplicateFinder.js";
|
||||
import {FileType} from "@metazla/filename-tools";
|
||||
import {FileAnalyzerInterface} from "./fileProcessor/FileAnalyzerInterface.js";
|
||||
import {FileProcessorPiscina} from "./fileProcessor/FileProcessorPiscina.js";
|
||||
@@ -33,8 +32,6 @@ export class WatchedFileProcessor implements FileProcessorInterface {
|
||||
virtualFileSystem: VirtualFileSystem;
|
||||
unifiedStateManager = new UnifiedProcessingStateManager();
|
||||
fileProcessor:FileAnalyzerInterface = new FileProcessorPiscina();
|
||||
duplicateFinder = new DuplicateFinder();
|
||||
duplicateResult: DuplicateResult | null = null;
|
||||
fileType = new FileType();
|
||||
supported = ['video','subtitle','torrent'];
|
||||
|
||||
@@ -77,10 +74,6 @@ export class WatchedFileProcessor implements FileProcessorInterface {
|
||||
return this.unifiedStateManager;
|
||||
}
|
||||
|
||||
getDuplicateResult(): DuplicateResult | null {
|
||||
return this.duplicateResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the KV client
|
||||
*/
|
||||
@@ -415,20 +408,10 @@ export class WatchedFileProcessor implements FileProcessorInterface {
|
||||
console.log(`No files to process`);
|
||||
return;
|
||||
}
|
||||
|
||||
// NOTE: Duplicate detection has been moved to meta-dup service
|
||||
|
||||
let startTime = performance.now();
|
||||
const newDuplicateResult = await this.duplicateFinder.findDuplicates(this.fileProcessor.getDatabase());//find duplicates and removes them to avoid conflict
|
||||
const duplicateTime = Math.ceil(performance.now() - startTime);
|
||||
const totalDuplicates = newDuplicateResult.hashDuplicates.length + newDuplicateResult.titleDuplicates.length;
|
||||
console.log(`Duplicate find took ${duplicateTime}ms - found ${newDuplicateResult.hashDuplicates.length} hash groups, ${newDuplicateResult.titleDuplicates.length} title groups`);
|
||||
|
||||
// Only update duplicateResult if we found new duplicates, or if it's the first run
|
||||
if (totalDuplicates > 0 || this.duplicateResult === null) {
|
||||
this.duplicateResult = newDuplicateResult;
|
||||
}
|
||||
|
||||
performanceMetrics.recordDuplicateDetection(duplicateTime, totalDuplicates);
|
||||
|
||||
startTime = performance.now();
|
||||
console.log(`Generating virtual structure for ${this.fileProcessor.getDatabase().size} files`);
|
||||
const virtualStructure = this.metaDataToFolderStruct.generateVirtualStructure(this.fileProcessor.getDatabase());
|
||||
const virtualStructureTime = Math.ceil(performance.now() - startTime);
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
# @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.
|
||||
@@ -0,0 +1,28 @@
|
||||
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 },
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
<!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
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
.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-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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
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 { 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>
|
||||
)}
|
||||
|
||||
{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;
|
||||
@@ -0,0 +1,151 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
.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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
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 => {
|
||||
if (!metadata) return undefined;
|
||||
|
||||
// First, check if the key exists as a flat key (e.g., "fileinfo/duration")
|
||||
if (fieldKey in metadata) {
|
||||
return (metadata as any)[fieldKey];
|
||||
}
|
||||
|
||||
// Fall back to nested traversal
|
||||
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/*" - aggregate all matching keys
|
||||
const patternPrefix = schema.pattern.replace(/\{n\}.*$/, '').replace(/\*$/, '').replace(/\/$/, '');
|
||||
const result: Record<string, any> = {};
|
||||
|
||||
// First, check flat keys (e.g., "stream/0", "stream/1")
|
||||
for (const [key, val] of Object.entries(metadata)) {
|
||||
if (key.startsWith(patternPrefix + '/') || key === patternPrefix) {
|
||||
// Store with the key suffix after the prefix
|
||||
const relativePath = key.substring(patternPrefix.length + 1) || key;
|
||||
result[relativePath] = val;
|
||||
}
|
||||
}
|
||||
|
||||
// Also check nested objects for backwards compatibility
|
||||
const findMatching = (obj: any, currentPath: string = '') => {
|
||||
if (!obj || typeof obj !== 'object') return;
|
||||
|
||||
for (const [key, val] of Object.entries(obj)) {
|
||||
// Skip flat keys we already processed
|
||||
if (key.includes('/')) continue;
|
||||
|
||||
const newPath = currentPath ? `${currentPath}/${key}` : key;
|
||||
if (newPath.startsWith(patternPrefix)) {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
.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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
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 value from object using path-style key
|
||||
* First checks for flat key (e.g., "fileinfo/duration"), then tries nested traversal
|
||||
*/
|
||||
function getNestedValue(obj: any, path: string): any {
|
||||
if (!obj) return undefined;
|
||||
|
||||
// First, check if the key exists as a flat key (e.g., "fileinfo/duration")
|
||||
if (path in obj) {
|
||||
return obj[path];
|
||||
}
|
||||
|
||||
// Fall back to nested traversal
|
||||
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
|
||||
* Handles both flat keys (e.g., "stream/0") and nested objects
|
||||
*/
|
||||
function hasKeyWithPrefix(obj: any, prefix: string, currentPath: string = ''): boolean {
|
||||
if (!obj || typeof obj !== 'object') return false;
|
||||
|
||||
for (const key of Object.keys(obj)) {
|
||||
// Check flat keys that contain '/' (e.g., "stream/0", "fileinfo/duration")
|
||||
if (key.startsWith(prefix + '/') || key === prefix) {
|
||||
return true;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
.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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
.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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
.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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
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 response = await MetadataAPI.getMetadata(hashId);
|
||||
// API returns { hashId, metadata: {...} } - extract the metadata object
|
||||
const data = (response as any).metadata || response;
|
||||
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 {
|
||||
if (!obj) return undefined;
|
||||
|
||||
// First, check if the key exists as a flat key (e.g., "fileinfo/duration")
|
||||
if (path in obj) {
|
||||
return obj[path];
|
||||
}
|
||||
|
||||
// Fall back to nested traversal
|
||||
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)) {
|
||||
// Check flat keys that contain '/' (e.g., "stream/0", "fileinfo/duration")
|
||||
if (key.startsWith(prefix + '/') || key === prefix) {
|
||||
return true;
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
.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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
: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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
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>,
|
||||
);
|
||||
@@ -0,0 +1,155 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* 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
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"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/LanguageCombobox.tsx","./src/components/MetadataEditor.tsx","./src/components/SearchBar.tsx","./src/types/index.ts","./src/utils/formatting.ts"],"version":"5.9.3"}
|
||||
@@ -0,0 +1,22 @@
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -2,10 +2,10 @@ import { Routes, Route } from 'react-router-dom';
|
||||
import Welcome from './pages/Welcome';
|
||||
import Monitor from './pages/Monitor';
|
||||
import Plugins from './pages/Plugins';
|
||||
import Duplicates from './pages/Duplicates';
|
||||
import ServiceNav from './components/ServiceNav';
|
||||
|
||||
// NOTE: Mounts page has been moved to meta-core dashboard
|
||||
// NOTE: Duplicates page has been moved to meta-dup service (port 8183)
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@@ -15,7 +15,6 @@ function App() {
|
||||
<Routes>
|
||||
<Route path="/" element={<Welcome />} />
|
||||
<Route path="/monitor" element={<Monitor />} />
|
||||
<Route path="/duplicates" element={<Duplicates />} />
|
||||
<Route path="/plugins" element={<Plugins />} />
|
||||
</Routes>
|
||||
</main>
|
||||
|
||||
@@ -1,510 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { DuplicateData, DuplicateGroup } from '../types';
|
||||
import { formatNumber, formatMs, getFilename } from '../utils/format';
|
||||
|
||||
function Duplicates() {
|
||||
const [duplicateData, setDuplicateData] = useState<DuplicateData | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'hash' | 'title'>('hash');
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||
|
||||
const fetchDuplicates = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/duplicates');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setDuplicateData(data);
|
||||
setError(null);
|
||||
} else {
|
||||
setError('Failed to fetch duplicate data');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch duplicates:', err);
|
||||
setError('Failed to fetch duplicate data');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const computeDuplicates = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/duplicates/compute', { method: 'POST' });
|
||||
if (res.ok) {
|
||||
await fetchDuplicates();
|
||||
} else {
|
||||
setError('Failed to compute duplicates');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to compute duplicates:', err);
|
||||
setError('Failed to compute duplicates');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchDuplicates();
|
||||
const interval = setInterval(fetchDuplicates, 30000); // Refresh every 30s
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchDuplicates]);
|
||||
|
||||
const toggleGroup = (key: string) => {
|
||||
setExpandedGroups(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.add(key);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const expandAll = () => {
|
||||
const groups = activeTab === 'hash'
|
||||
? duplicateData?.hashDuplicates
|
||||
: duplicateData?.titleDuplicates;
|
||||
if (groups) {
|
||||
setExpandedGroups(new Set(groups.map(g => g.key)));
|
||||
}
|
||||
};
|
||||
|
||||
const collapseAll = () => {
|
||||
setExpandedGroups(new Set());
|
||||
};
|
||||
|
||||
const renderDuplicateGroup = (group: DuplicateGroup, type: 'hash' | 'title') => {
|
||||
const isExpanded = expandedGroups.has(group.key);
|
||||
const displayKey = type === 'hash'
|
||||
? `${group.key.substring(0, 16)}...`
|
||||
: group.key;
|
||||
|
||||
return (
|
||||
<div key={group.key} className={`duplicate-group ${isExpanded ? 'expanded' : ''}`}>
|
||||
<div
|
||||
className="group-header"
|
||||
onClick={() => toggleGroup(group.key)}
|
||||
>
|
||||
<span className="expand-icon">{isExpanded ? '▼' : '▶'}</span>
|
||||
<span className="group-key" title={group.key}>{displayKey}</span>
|
||||
<span className="group-count">{group.files.length} files</span>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="group-files">
|
||||
{group.files.map((file, idx) => (
|
||||
<div key={idx} className="file-item">
|
||||
<span className="file-index">{idx + 1}.</span>
|
||||
<span className="file-name" title={file}>{getFilename(file)}</span>
|
||||
<span className="file-path" title={file}>{file}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const hashDuplicates = duplicateData?.hashDuplicates || [];
|
||||
const titleDuplicates = duplicateData?.titleDuplicates || [];
|
||||
const stats = duplicateData?.stats;
|
||||
|
||||
return (
|
||||
<div className="duplicates-page">
|
||||
<div className="duplicates-header">
|
||||
<h1>Duplicate Analysis</h1>
|
||||
<div className="header-actions">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={computeDuplicates}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Computing...' : 'Recompute Duplicates'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="error-banner">{error}</div>
|
||||
)}
|
||||
|
||||
{/* Stats Cards */}
|
||||
{stats && (
|
||||
<div className="stats-cards">
|
||||
<div className="stat-card hash">
|
||||
<div className="stat-icon">#</div>
|
||||
<div className="stat-content">
|
||||
<div className="stat-value">{formatNumber(stats.hashGroupCount)}</div>
|
||||
<div className="stat-label">Hash Duplicate Groups</div>
|
||||
<div className="stat-detail">{formatNumber(stats.hashFileCount)} total files</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat-card title">
|
||||
<div className="stat-icon">T</div>
|
||||
<div className="stat-content">
|
||||
<div className="stat-value">{formatNumber(stats.titleGroupCount)}</div>
|
||||
<div className="stat-label">Title Duplicate Groups</div>
|
||||
<div className="stat-detail">{formatNumber(stats.titleFileCount)} total files</div>
|
||||
</div>
|
||||
</div>
|
||||
{duplicateData?.computedAt && (
|
||||
<div className="stat-card info">
|
||||
<div className="stat-icon">i</div>
|
||||
<div className="stat-content">
|
||||
<div className="stat-value">{formatMs(duplicateData.computationTimeMs)}</div>
|
||||
<div className="stat-label">Computation Time</div>
|
||||
<div className="stat-detail">
|
||||
{new Date(duplicateData.computedAt).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="card duplicates-card">
|
||||
<div className="tabs-header">
|
||||
<div className="tabs">
|
||||
<button
|
||||
className={`tab ${activeTab === 'hash' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('hash')}
|
||||
>
|
||||
Hash Duplicates ({hashDuplicates.length})
|
||||
</button>
|
||||
<button
|
||||
className={`tab ${activeTab === 'title' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('title')}
|
||||
>
|
||||
Title Duplicates ({titleDuplicates.length})
|
||||
</button>
|
||||
</div>
|
||||
<div className="tab-actions">
|
||||
<button className="btn btn-small btn-secondary" onClick={expandAll}>
|
||||
Expand All
|
||||
</button>
|
||||
<button className="btn btn-small btn-secondary" onClick={collapseAll}>
|
||||
Collapse All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tab-content">
|
||||
{activeTab === 'hash' && (
|
||||
<div className="duplicates-list">
|
||||
{hashDuplicates.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>No hash duplicates found</p>
|
||||
<p className="hint">Files with identical content (same SHA-256 hash)</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="list-description">
|
||||
Files with identical content (exact byte-for-byte matches)
|
||||
</p>
|
||||
{hashDuplicates.map(group => renderDuplicateGroup(group, 'hash'))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'title' && (
|
||||
<div className="duplicates-list">
|
||||
{titleDuplicates.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>No title duplicates found</p>
|
||||
<p className="hint">Files with similar parsed titles</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="list-description">
|
||||
Files with matching parsed titles (may be different quality/releases)
|
||||
</p>
|
||||
{titleDuplicates.map(group => renderDuplicateGroup(group, 'title'))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
.duplicates-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.duplicates-header h1 {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
background: rgba(255, 107, 107, 0.1);
|
||||
border: 1px solid var(--error);
|
||||
color: var(--error);
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* Stats Cards */
|
||||
.stats-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.stat-card.hash {
|
||||
border-left: 4px solid #a78bfa;
|
||||
}
|
||||
|
||||
.stat-card.title {
|
||||
border-left: 4px solid #fbbf24;
|
||||
}
|
||||
|
||||
.stat-card.info {
|
||||
border-left: 4px solid #60a5fa;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.stat-card.hash .stat-icon {
|
||||
color: #a78bfa;
|
||||
}
|
||||
|
||||
.stat-card.title .stat-icon {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.stat-card.info .stat-icon {
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.stat-detail {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8rem;
|
||||
margin-top: 4px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.duplicates-card {
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.tabs-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 10px 20px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
border-radius: 8px 8px 0 0;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--accent-primary);
|
||||
background: var(--bg-tertiary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tab-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 6px 12px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Duplicates List */
|
||||
.list-description {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.duplicates-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.duplicate-group {
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.group-header:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8rem;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.group-key {
|
||||
flex: 1;
|
||||
font-family: monospace;
|
||||
font-size: 0.9rem;
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.group-count {
|
||||
background: var(--bg-secondary);
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.group-files {
|
||||
background: var(--bg-secondary);
|
||||
padding: 8px 16px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.file-item {
|
||||
display: grid;
|
||||
grid-template-columns: 30px 200px 1fr;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.file-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.file-index {
|
||||
color: var(--text-secondary);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-path {
|
||||
color: var(--text-secondary);
|
||||
font-family: monospace;
|
||||
font-size: 0.8rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.empty-state .hint {
|
||||
font-size: 0.9rem;
|
||||
margin-top: 8px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.tabs-header {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
grid-template-columns: 30px 1fr;
|
||||
}
|
||||
|
||||
.file-path {
|
||||
grid-column: 1 / -1;
|
||||
padding-left: 42px;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Duplicates;
|
||||
@@ -22,17 +22,6 @@ function Welcome() {
|
||||
<p>Real-time processing dashboard</p>
|
||||
</Link>
|
||||
|
||||
<Link to="/duplicates" className="link-card">
|
||||
<div className="link-icon">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="8" y="2" width="13" height="18" rx="2" ry="2"/>
|
||||
<rect x="3" y="4" width="13" height="18" rx="2" ry="2"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Duplicates</h3>
|
||||
<p>Find duplicate files</p>
|
||||
</Link>
|
||||
|
||||
<Link to="/plugins" className="link-card">
|
||||
<div className="link-icon">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
@@ -44,6 +33,17 @@ function Welcome() {
|
||||
<h3>Plugins</h3>
|
||||
<p>Manage metadata plugins</p>
|
||||
</Link>
|
||||
|
||||
<a href="/editor/" 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="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Editor</h3>
|
||||
<p>Edit file metadata</p>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -153,24 +153,7 @@ export interface Metrics {
|
||||
pluginProcessingTimes?: Record<string, PluginTimingStats>;
|
||||
}
|
||||
|
||||
export interface DuplicateGroup {
|
||||
key: string;
|
||||
files: string[];
|
||||
}
|
||||
|
||||
export interface DuplicateData {
|
||||
hashDuplicates: DuplicateGroup[];
|
||||
titleDuplicates: DuplicateGroup[];
|
||||
stats: {
|
||||
hashGroupCount: number;
|
||||
hashFileCount: number;
|
||||
titleGroupCount: number;
|
||||
titleFileCount: number;
|
||||
};
|
||||
computedAt: number;
|
||||
computationTimeMs: number;
|
||||
status?: string;
|
||||
}
|
||||
// NOTE: DuplicateGroup and DuplicateData types have been moved to meta-dup service
|
||||
|
||||
export interface RedisStats {
|
||||
fileCount: number;
|
||||
|
||||
Generated
+28
@@ -132,6 +132,34 @@ importers:
|
||||
specifier: ^5.3.3
|
||||
version: 5.9.3
|
||||
|
||||
packages/meta-sort-editor:
|
||||
dependencies:
|
||||
iso-639-3:
|
||||
specifier: ^3.0.1
|
||||
version: 3.0.1
|
||||
react:
|
||||
specifier: ^18.2.0
|
||||
version: 18.3.1
|
||||
react-dom:
|
||||
specifier: ^18.2.0
|
||||
version: 18.3.1([email protected])
|
||||
devDependencies:
|
||||
'@types/react':
|
||||
specifier: ^18.2.48
|
||||
version: 18.3.27
|
||||
'@types/react-dom':
|
||||
specifier: ^18.2.18
|
||||
version: 18.3.7(@types/[email protected])
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^4.2.1
|
||||
version: 4.7.0([email protected](@types/[email protected]))
|
||||
typescript:
|
||||
specifier: ^5.3.3
|
||||
version: 5.9.3
|
||||
vite:
|
||||
specifier: ^5.0.12
|
||||
version: 5.4.21(@types/[email protected])
|
||||
|
||||
packages/meta-sort-ui:
|
||||
dependencies:
|
||||
react:
|
||||
|
||||
@@ -3,3 +3,4 @@ packages:
|
||||
- 'packages/async-utils'
|
||||
- 'packages/meta-sort-core'
|
||||
- 'packages/meta-sort-ui'
|
||||
- 'packages/meta-sort-editor'
|
||||
|
||||
Reference in New Issue
Block a user