From aa45f4f32e898bc0a896d0c8054e9a990e85c727 Mon Sep 17 00:00:00 2001 From: Pierre Date: Thu, 14 May 2026 23:31:07 +0200 Subject: [PATCH] improve editor and add type detection --- editor/src/App.css | 224 ------ editor/src/App.tsx | 270 ------- editor/src/api/metadataApi.ts | 151 ---- editor/src/api/schemaApi.ts | 74 ++ editor/src/components/BulkMetadataEditor.css | 382 ---------- editor/src/components/BulkMetadataEditor.tsx | 280 -------- .../src/components/DynamicFieldRenderer.css | 297 -------- .../src/components/DynamicFieldRenderer.tsx | 378 ---------- .../src/components/DynamicPluginSection.css | 63 -- .../src/components/DynamicPluginSection.tsx | 126 ---- editor/src/components/FileList.css | 174 ----- editor/src/components/FileList.tsx | 123 ---- editor/src/components/KVTreeView.css | 247 ------- editor/src/components/KVTreeView.tsx | 216 ------ editor/src/components/LanguageCombobox.tsx | 9 - editor/src/components/MetadataEditor.css | 438 ------------ editor/src/components/MetadataEditor.tsx | 392 ---------- editor/src/components/SearchBar.css | 52 -- editor/src/components/SearchBar.tsx | 40 -- editor/src/kv/KVApp.css | 321 +++++++++ editor/src/kv/KVApp.tsx | 45 +- editor/src/kv/KVFileEditor.tsx | 675 ++++++++++++++++++ editor/src/kv/KVLeafEditor.tsx | 119 +-- editor/src/kv/KVTree.tsx | 108 ++- editor/src/kv/leafRenderers.tsx | 295 ++++++++ editor/src/kv/schemaLookup.ts | 90 +++ editor/src/kv/typeHeuristics.ts | 4 +- editor/src/types/index.ts | 155 ---- editor/src/utils/formatting.ts | 53 -- editor/tsconfig.tsbuildinfo | 2 +- internal/api/handlers.go | 57 ++ internal/api/schema_handlers.go | 45 ++ internal/api/server.go | 23 + internal/schema/classify.go | 103 +++ internal/schema/indexer.go | 354 +++++++++ internal/schema/types.go | 48 ++ internal/storage/client.go | 96 ++- 37 files changed, 2309 insertions(+), 4220 deletions(-) delete mode 100644 editor/src/App.css delete mode 100644 editor/src/App.tsx delete mode 100644 editor/src/api/metadataApi.ts create mode 100644 editor/src/api/schemaApi.ts delete mode 100644 editor/src/components/BulkMetadataEditor.css delete mode 100644 editor/src/components/BulkMetadataEditor.tsx delete mode 100644 editor/src/components/DynamicFieldRenderer.css delete mode 100644 editor/src/components/DynamicFieldRenderer.tsx delete mode 100644 editor/src/components/DynamicPluginSection.css delete mode 100644 editor/src/components/DynamicPluginSection.tsx delete mode 100644 editor/src/components/FileList.css delete mode 100644 editor/src/components/FileList.tsx delete mode 100644 editor/src/components/KVTreeView.css delete mode 100644 editor/src/components/KVTreeView.tsx delete mode 100644 editor/src/components/MetadataEditor.css delete mode 100644 editor/src/components/MetadataEditor.tsx delete mode 100644 editor/src/components/SearchBar.css delete mode 100644 editor/src/components/SearchBar.tsx create mode 100644 editor/src/kv/KVFileEditor.tsx create mode 100644 editor/src/kv/leafRenderers.tsx create mode 100644 editor/src/kv/schemaLookup.ts delete mode 100644 editor/src/types/index.ts delete mode 100644 editor/src/utils/formatting.ts create mode 100644 internal/api/schema_handlers.go create mode 100644 internal/schema/classify.go create mode 100644 internal/schema/indexer.go create mode 100644 internal/schema/types.go diff --git a/editor/src/App.css b/editor/src/App.css deleted file mode 100644 index cbf6576..0000000 --- a/editor/src/App.css +++ /dev/null @@ -1,224 +0,0 @@ -.app { - min-height: 100vh; - display: flex; - flex-direction: column; -} - -.app-header { - padding: 2rem; - border-bottom: 1px solid rgba(255, 255, 255, 0.1); - background-color: rgba(0, 0, 0, 0.2); -} - -.app-header h1 { - margin: 0 0 0.5rem 0; - color: #646cff; -} - -.app-description { - margin: 0; - color: rgba(255, 255, 255, 0.6); -} - -.app-main { - flex: 1; - padding: 2rem; - max-width: 1400px; - width: 100%; - margin: 0 auto; -} - -.app-error { - padding: 1rem; - background-color: rgba(255, 0, 0, 0.1); - border: 1px solid rgba(255, 0, 0, 0.3); - border-radius: 8px; - color: #ff6b6b; - margin-bottom: 1rem; -} - -.app-loading { - text-align: center; - padding: 3rem 2rem; - color: rgba(255, 255, 255, 0.7); - font-size: 1.1rem; -} - -.app-welcome { - text-align: center; - padding: 4rem 2rem; - color: rgba(255, 255, 255, 0.6); -} - -.app-welcome h2 { - margin: 0 0 1rem 0; - color: rgba(255, 255, 255, 0.8); -} - -.app-welcome-features { - margin-top: 1rem; - margin-bottom: 1.5rem; - font-size: 0.85rem; - color: rgba(255, 255, 255, 0.5); -} - -.app-kv-browser { - margin-top: 1.5rem; - text-align: left; - max-width: 900px; - margin-left: auto; - margin-right: auto; -} - -.app-content { - display: grid; - grid-template-columns: 350px 1fr; - gap: 1.5rem; - margin-top: 1rem; -} - -.app-sidebar { - min-height: 400px; -} - -.app-editor { - min-height: 400px; -} - -.app-no-selection { - display: flex; - align-items: center; - justify-content: center; - height: 100%; - min-height: 400px; - background-color: rgba(255, 255, 255, 0.05); - border-radius: 8px; - color: rgba(255, 255, 255, 0.5); - text-align: center; - padding: 2rem; -} - -.app-footer { - padding: 1.5rem 2rem; - border-top: 1px solid rgba(255, 255, 255, 0.1); - text-align: center; - color: rgba(255, 255, 255, 0.5); - font-size: 0.9rem; -} - -.app-footer a { - color: #646cff; - text-decoration: none; -} - -.app-footer a:hover { - text-decoration: underline; -} - -@media (max-width: 1024px) { - .app-content { - grid-template-columns: 1fr; - } - - .app-sidebar { - order: 2; - } - - .app-editor { - order: 1; - } -} - -@media (max-width: 768px) { - .app-header, - .app-main, - .app-footer { - padding: 1rem; - } - - .app-content { - gap: 1rem; - } -} - -@media (prefers-color-scheme: light) { - .app-header { - border-bottom-color: rgba(0, 0, 0, 0.1); - background-color: rgba(0, 0, 0, 0.02); - } - - .app-description { - color: rgba(0, 0, 0, 0.6); - } - - .app-error { - background-color: rgba(255, 0, 0, 0.1); - border-color: rgba(255, 0, 0, 0.3); - color: #cc0000; - } - - .app-loading { - color: rgba(0, 0, 0, 0.7); - } - - .app-welcome { - color: rgba(0, 0, 0, 0.6); - } - - .app-welcome h2 { - color: rgba(0, 0, 0, 0.8); - } - - .app-welcome-features { - color: rgba(0, 0, 0, 0.5); - } - - .app-no-selection { - background-color: rgba(0, 0, 0, 0.02); - color: rgba(0, 0, 0, 0.5); - } - - .app-footer { - border-top-color: rgba(0, 0, 0, 0.1); - color: rgba(0, 0, 0, 0.5); - } -} - -.app-nav { - display: flex; - gap: 0.5rem; - margin-top: 0.75rem; -} - -.app-nav button { - background: transparent; - border: 1px solid rgba(255, 255, 255, 0.2); - color: inherit; - padding: 0.4rem 1rem; - border-radius: 4px; - cursor: pointer; - font-size: 0.95rem; -} - -.app-nav button:hover { - background: rgba(255, 255, 255, 0.08); -} - -.app-nav button.active { - background: rgba(255, 255, 255, 0.15); - border-color: rgba(255, 255, 255, 0.4); - font-weight: 600; -} - -@media (prefers-color-scheme: light) { - .app-nav button { - border-color: rgba(0, 0, 0, 0.2); - } - .app-nav button:hover { - background: rgba(0, 0, 0, 0.05); - } - .app-nav button.active { - background: rgba(0, 0, 0, 0.08); - border-color: rgba(0, 0, 0, 0.4); - } -} diff --git a/editor/src/App.tsx b/editor/src/App.tsx deleted file mode 100644 index f631348..0000000 --- a/editor/src/App.tsx +++ /dev/null @@ -1,270 +0,0 @@ -import { useState, useEffect } from 'react'; -import { SearchBar } from './components/SearchBar'; -import { FileList } from './components/FileList'; -import { MetadataEditor } from './components/MetadataEditor'; -import { BulkMetadataEditor } from './components/BulkMetadataEditor'; -import { KVTreeView } from './components/KVTreeView'; -import { SnapshotPanel } from './components/SnapshotPanel'; -import { MetadataAPI } from './api/metadataApi'; -import { SearchResult } from './types'; -import './App.css'; - -type View = 'edit' | 'snapshot'; - -function App() { - const [view, setView] = useState('edit'); - const [searchResults, setSearchResults] = useState([]); - const [selectedHashId, setSelectedHashId] = useState(); - const [selectedHashIds, setSelectedHashIds] = useState>(new Set()); - const [bulkEditMode, setBulkEditMode] = useState(false); - const [isSearching, setIsSearching] = useState(false); - const [isLoadingDirectHash, setIsLoadingDirectHash] = useState(false); - const [error, setError] = useState(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 ( -
-
-

MetaMesh Metadata Editor

-

- Search and edit metadata for your media files -

- -
- - {view === 'snapshot' ? ( -
- -
- ) : ( -
- - - {error &&
{error}
} - - {isLoadingDirectHash && ( -
Loading file metadata...
- )} - - {!isSearching && !isLoadingDirectHash && searchResults.length === 0 && !error && ( -
-

Welcome to MetaMesh Metadata Editor

-

Search for files by title, filename, file path, or hash ID to get started.

-

- Unified search across all fields | Single file editing | Bulk editing | Direct access via URL -

-
- -
-
- )} - - {searchResults.length > 0 && ( -
-
- -
-
- {bulkEditMode && selectedHashIds.size > 0 ? ( - - ) : selectedHashId ? ( - - ) : ( -
- {bulkEditMode - ? 'Select files from the list to bulk edit metadata' - : 'Select a file from the list to edit its metadata'} -
- )} -
-
- )} -
- )} - - -
- ); -} - -export default App; diff --git a/editor/src/api/metadataApi.ts b/editor/src/api/metadataApi.ts deleted file mode 100644 index deb209f..0000000 --- a/editor/src/api/metadataApi.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { - FileMetadata, - SearchResponse, - UpdateResult, - BatchUpdateResponse, -} from '../types'; - -const API_BASE = '/api/metadata'; - -export class MetadataAPI { - /** - * Search for files by query, hashId, or property - */ - static async search(params: { - query?: string; - hashId?: string; - property?: string; - propertyValue?: string; - limit?: number; - }): Promise { - 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 { - 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 { - 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 { - 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; - }> - ): Promise { - 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 { - 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(); - } -} diff --git a/editor/src/api/schemaApi.ts b/editor/src/api/schemaApi.ts new file mode 100644 index 0000000..d98d047 --- /dev/null +++ b/editor/src/api/schemaApi.ts @@ -0,0 +1,74 @@ +import { useEffect, useState } from 'react'; + +export type SchemaPrimitive = + | 'string' + | 'int' + | 'float' + | 'bool' + | 'json-object' + | 'json-array' + | 'undefined' + | 'mixed'; + +export type SchemaHint = 'cid' | 'timestamp'; +export type SchemaKeyHint = 'language-code'; + +export interface SchemaFieldInfo { + type: SchemaPrimitive; + hint?: SchemaHint; + key_hint?: SchemaKeyHint; + breakdown: Record; +} + +export interface SchemaResponse { + fields: Record; + generated_at: string; + source: 'live' | 'rescan'; +} + +let cached: Promise | null = null; + +export async function fetchSchema(): Promise { + if (!cached) { + cached = fetch('/api/schema').then((r) => { + if (!r.ok) { + cached = null; + throw new Error(`schema fetch failed: ${r.status}`); + } + return r.json(); + }); + } + return cached; +} + +/** + * Returns the live schema entry for a field, or undefined if not yet observed. + * Loads /api/schema lazily on first mount and caches the result process-wide. + */ +export function useSchema(): { + schema: SchemaResponse | null; + getFieldHint: (key: string) => SchemaFieldInfo | undefined; +} { + const [schema, setSchema] = useState(null); + + useEffect(() => { + let alive = true; + fetchSchema() + .then((s) => { + if (alive) setSchema(s); + }) + .catch(() => { + // Schema endpoint missing or errored — editor falls back to plugin schemas. + }); + return () => { + alive = false; + }; + }, []); + + const getFieldHint = (key: string): SchemaFieldInfo | undefined => { + if (!schema) return undefined; + return schema.fields[key]; + }; + + return { schema, getFieldHint }; +} diff --git a/editor/src/components/BulkMetadataEditor.css b/editor/src/components/BulkMetadataEditor.css deleted file mode 100644 index 5a461ff..0000000 --- a/editor/src/components/BulkMetadataEditor.css +++ /dev/null @@ -1,382 +0,0 @@ -.bulk-metadata-editor { - background-color: rgba(255, 255, 255, 0.05); - border-radius: 8px; - padding: 1.5rem; -} - -.bulk-metadata-editor-header { - margin-bottom: 1rem; - padding-bottom: 1rem; - border-bottom: 1px solid rgba(255, 255, 255, 0.1); -} - -.bulk-metadata-editor-header h2 { - margin: 0 0 0.5rem 0; - font-size: 1.5rem; -} - -.bulk-info { - margin: 0 0 1rem 0; - color: rgba(255, 255, 255, 0.6); - font-size: 0.95rem; -} - -.bulk-metadata-editor-actions { - display: flex; - gap: 0.5rem; -} - -.bulk-metadata-editor-actions button.primary { - background-color: #646cff; - color: white; -} - -.bulk-metadata-editor-actions button.primary:hover:not(:disabled) { - background-color: #535bf2; -} - -.bulk-metadata-editor-message { - padding: 1rem; - border-radius: 4px; - margin-bottom: 1rem; -} - -.bulk-metadata-editor-message.success { - background-color: rgba(0, 200, 0, 0.1); - border: 1px solid rgba(0, 200, 0, 0.3); - color: #00ff00; -} - -.bulk-metadata-editor-message.error { - background-color: rgba(255, 0, 0, 0.1); - border: 1px solid rgba(255, 0, 0, 0.3); - color: #ff6b6b; -} - -.bulk-metadata-editor-form { - display: flex; - flex-direction: column; - gap: 1.5rem; -} - -.bulk-metadata-section { - border: 1px solid rgba(255, 255, 255, 0.1); - border-left: 4px solid #646cff; - border-radius: 8px; - padding: 1.5rem; - background-color: rgba(0, 0, 0, 0.2); -} - -.bulk-metadata-section-header { - margin-bottom: 1.5rem; - padding-bottom: 0.75rem; - border-bottom: 1px solid rgba(255, 255, 255, 0.1); -} - -.bulk-metadata-section-header h3 { - margin: 0 0 0.25rem 0; - font-size: 1.1rem; - color: #646cff; -} - -.section-hint { - margin: 0.25rem 0 0 0; - font-size: 0.85rem; - color: rgba(255, 255, 255, 0.5); - font-style: italic; -} - -.section-hint-bulk { - margin: 0.5rem 0 0 0; - font-size: 0.8rem; - color: rgba(255, 255, 255, 0.4); - font-weight: 500; -} - -/* Plugin-based bulk fields */ -.bulk-fields-container { - display: flex; - flex-direction: column; - gap: 1rem; -} - -.bulk-field-wrapper { - border: 1px solid rgba(255, 255, 255, 0.05); - border-radius: 4px; - padding: 0.75rem; - background-color: rgba(0, 0, 0, 0.1); - transition: all 0.2s ease; -} - -.bulk-field-wrapper.has-value { - border-color: rgba(100, 108, 255, 0.3); - background-color: rgba(100, 108, 255, 0.05); -} - -.bulk-field-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 0.5rem; -} - -.field-key { - font-size: 0.85rem; - font-weight: 600; - color: rgba(255, 255, 255, 0.6); - text-transform: lowercase; - font-family: 'Courier New', monospace; -} - -.bulk-field-wrapper.has-value .field-key { - color: #646cff; -} - -.bulk-field-input { - /* Plugin component will render inside this */ -} - -/* Legacy bulk fields (for compatibility) */ -.bulk-metadata-field { - display: flex; - flex-direction: column; - gap: 0.5rem; - margin-bottom: 1rem; -} - -.bulk-metadata-field:last-child { - margin-bottom: 0; -} - -.field-header { - display: flex; - justify-content: space-between; - align-items: center; -} - -.field-header label { - font-size: 0.9rem; - font-weight: 500; - color: rgba(255, 255, 255, 0.7); -} - -.clear-field-btn { - width: 20px; - height: 20px; - padding: 0; - border: 1px solid rgba(255, 0, 0, 0.3); - background-color: rgba(255, 0, 0, 0.1); - color: #ff6b6b; - border-radius: 50%; - cursor: pointer; - font-size: 16px; - line-height: 1; - transition: all 0.2s; - display: flex; - align-items: center; - justify-content: center; -} - -.clear-field-btn:hover { - background-color: rgba(255, 0, 0, 0.2); - border-color: rgba(255, 0, 0, 0.5); -} - -.bulk-metadata-field input, -.bulk-metadata-field textarea, -.bulk-metadata-field select { - width: 100%; -} - -.bulk-metadata-field-row { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 1rem; - margin-bottom: 1rem; -} - -.bulk-metadata-field-row:last-child { - margin-bottom: 0; -} - -/* Modified fields summary */ -.modified-fields { - background-color: rgba(100, 108, 255, 0.05); - border-color: rgba(100, 108, 255, 0.3); -} - -.modified-fields h3 { - color: #646cff; -} - -.modified-fields-list { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.modified-field-item { - display: flex; - gap: 0.75rem; - align-items: center; - padding: 0.75rem; - background-color: rgba(100, 108, 255, 0.05); - border: 1px solid rgba(100, 108, 255, 0.2); - border-radius: 4px; -} - -.field-name { - font-weight: 600; - color: #646cff; - min-width: 150px; - font-family: 'Courier New', monospace; - font-size: 0.9rem; -} - -.field-value { - flex: 1; - word-break: break-word; - color: rgba(255, 255, 255, 0.8); - font-size: 0.9rem; -} - -.remove-field-btn { - padding: 0.25rem 0.75rem; - font-size: 0.85rem; - background-color: rgba(255, 0, 0, 0.1); - border: 1px solid rgba(255, 0, 0, 0.3); - color: #ff6b6b; - border-radius: 4px; - cursor: pointer; - transition: all 0.2s ease; - white-space: nowrap; -} - -.remove-field-btn:hover { - background-color: rgba(255, 0, 0, 0.2); - border-color: rgba(255, 0, 0, 0.5); -} - -@media (max-width: 768px) { - .bulk-metadata-editor-header { - flex-direction: column; - align-items: flex-start; - gap: 1rem; - } - - .bulk-metadata-editor-actions { - width: 100%; - flex-direction: column; - } - - .bulk-metadata-editor-actions button { - width: 100%; - } - - .bulk-metadata-field-row { - grid-template-columns: 1fr; - } - - .modified-field-item { - flex-direction: column; - align-items: flex-start; - } - - .field-name { - min-width: auto; - } - - .remove-field-btn { - align-self: flex-end; - } -} - -@media (prefers-color-scheme: light) { - .bulk-metadata-editor { - background-color: rgba(0, 0, 0, 0.02); - } - - .bulk-metadata-editor-header { - border-bottom-color: rgba(0, 0, 0, 0.1); - } - - .bulk-info { - color: rgba(0, 0, 0, 0.6); - } - - .bulk-metadata-section { - border-color: rgba(0, 0, 0, 0.1); - background-color: rgba(0, 0, 0, 0.02); - } - - .bulk-metadata-section-header { - border-bottom-color: rgba(0, 0, 0, 0.1); - } - - .section-hint { - color: rgba(0, 0, 0, 0.5); - } - - .section-hint-bulk { - color: rgba(0, 0, 0, 0.4); - } - - .bulk-field-wrapper { - border-color: rgba(0, 0, 0, 0.1); - background-color: rgba(0, 0, 0, 0.02); - } - - .bulk-field-wrapper.has-value { - border-color: rgba(100, 108, 255, 0.3); - background-color: rgba(100, 108, 255, 0.05); - } - - .field-key { - color: rgba(0, 0, 0, 0.6); - } - - .bulk-field-wrapper.has-value .field-key { - color: #646cff; - } - - .field-header label { - color: rgba(0, 0, 0, 0.7); - } - - .field-value { - color: rgba(0, 0, 0, 0.8); - } - - .bulk-metadata-editor-message.success { - background-color: rgba(0, 200, 0, 0.1); - border-color: rgba(0, 200, 0, 0.3); - color: #008800; - } - - .bulk-metadata-editor-message.error { - background-color: rgba(255, 0, 0, 0.1); - border-color: rgba(255, 0, 0, 0.3); - color: #cc0000; - } - - .modified-fields { - background-color: rgba(100, 108, 255, 0.05); - border-color: rgba(100, 108, 255, 0.3); - } - - .modified-field-item { - background-color: rgba(100, 108, 255, 0.05); - border-color: rgba(100, 108, 255, 0.2); - } - - .clear-field-btn { - border-color: rgba(255, 0, 0, 0.3); - background-color: rgba(255, 0, 0, 0.1); - color: #cc0000; - } - - .clear-field-btn:hover { - background-color: rgba(255, 0, 0, 0.2); - border-color: rgba(255, 0, 0, 0.5); - } -} diff --git a/editor/src/components/BulkMetadataEditor.tsx b/editor/src/components/BulkMetadataEditor.tsx deleted file mode 100644 index f054584..0000000 --- a/editor/src/components/BulkMetadataEditor.tsx +++ /dev/null @@ -1,280 +0,0 @@ -import { useState, useEffect } from 'react'; -import { FileMetadata, PluginSchema } from '../types'; -import { MetadataAPI } from '../api/metadataApi'; -import { DynamicFieldRenderer } from './DynamicFieldRenderer'; -import './BulkMetadataEditor.css'; - -interface PluginsResponse { - plugins: PluginSchema[]; -} - -interface BulkMetadataEditorProps { - hashIds: string[]; - onSaved?: () => void; - onCancel?: () => void; -} - -export function BulkMetadataEditor({ hashIds, onSaved, onCancel }: BulkMetadataEditorProps) { - const [plugins, setPlugins] = useState([]); - const [commonValues, setCommonValues] = useState>({}); - const [editedFields, setEditedFields] = useState>({}); - const [isLoading, setIsLoading] = useState(true); - const [isSaving, setIsSaving] = useState(false); - const [error, setError] = useState(null); - const [successMessage, setSuccessMessage] = useState(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 = {}; - - // 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 ( -
-
- Loading metadata for {hashIds.length} files... -
-
- ); - } - - // Build display metadata (edited values or common values) - const displayMetadata: Record = { ...commonValues, ...editedFields }; - - return ( -
-
-

Bulk Edit Metadata

-

- Editing {hashIds.length} file{hashIds.length !== 1 ? 's' : ''} - Only modified fields will - be updated. Fields showing "*" have different values across files. -

-
- {onCancel && ( - - )} - -
-
- - {successMessage && ( -
{successMessage}
- )} - {error &&
{error}
} - -
- {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 ( -
-
-

{plugin.name}

- {plugin.description && ( -

{plugin.description}

- )} -

- Common values shown. "*" indicates different values. Edit to update all files. -

-
- -
- {editableFields.map(([key, field]) => ( - - ))} -
-
- ); - })} - - {/* Modified Fields Summary */} - {hasChanges && ( -
-

Fields to be Updated ({Object.keys(editedFields).length})

-
- {Object.entries(editedFields).map(([key, value]) => ( -
- {key}: - - {Array.isArray(value) - ? value.join(', ') - : typeof value === 'object' - ? JSON.stringify(value) - : String(value || '(empty)')} - - -
- ))} -
-
- )} -
-
- ); -} diff --git a/editor/src/components/DynamicFieldRenderer.css b/editor/src/components/DynamicFieldRenderer.css deleted file mode 100644 index fac5e4f..0000000 --- a/editor/src/components/DynamicFieldRenderer.css +++ /dev/null @@ -1,297 +0,0 @@ -.dynamic-field { - display: flex; - flex-direction: column; - gap: 0.5rem; - margin-bottom: 0.5rem; -} - -.dynamic-field:last-child { - margin-bottom: 0; -} - -.dynamic-field label { - font-size: 0.9rem; - font-weight: 500; - color: rgba(255, 255, 255, 0.7); -} - -.dynamic-field input[type="text"], -.dynamic-field input[type="number"], -.dynamic-field select, -.dynamic-field textarea { - width: 100%; - padding: 0.5rem 0.75rem; - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 4px; - font-size: 0.9rem; - background: rgba(0, 0, 0, 0.3); - color: #fff; - transition: border-color 0.2s; -} - -.dynamic-field input:focus, -.dynamic-field select:focus, -.dynamic-field textarea:focus { - outline: none; - border-color: #646cff; -} - -.dynamic-field input:disabled, -.dynamic-field select:disabled, -.dynamic-field textarea:disabled { - background: rgba(0, 0, 0, 0.2); - color: rgba(255, 255, 255, 0.5); - cursor: not-allowed; -} - -.dynamic-field textarea { - resize: vertical; - min-height: 80px; - font-family: inherit; -} - -.field-hint { - display: block; - font-size: 0.8rem; - color: rgba(255, 255, 255, 0.4); -} - -.checkbox-label { - display: flex; - align-items: center; - gap: 0.5rem; - cursor: pointer; - font-weight: normal; - color: rgba(255, 255, 255, 0.7); -} - -.checkbox-label input[type="checkbox"] { - width: 1.1rem; - height: 1.1rem; - cursor: pointer; - accent-color: #646cff; -} - -.checkbox-label input:disabled { - cursor: not-allowed; - opacity: 0.5; -} - -.readonly-value { - padding: 0.5rem 0.75rem; - background: rgba(0, 0, 0, 0.2); - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 4px; - color: rgba(255, 255, 255, 0.7); - font-family: 'Courier New', monospace; - font-size: 0.9rem; - word-break: break-all; -} - -.field-link { - color: #646cff; - text-decoration: none; -} - -.field-link:hover { - text-decoration: underline; -} - -/* CID field styling */ -.cid-field .cid-value-container { - display: flex; - align-items: center; - gap: 0.5rem; -} - -.cid-field .cid-value { - flex: 1; - padding: 0.5rem 0.75rem; - background: rgba(0, 0, 0, 0.3); - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 4px; - font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Courier New', monospace; - font-size: 0.85rem; - color: #a0d8ef; - cursor: pointer; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.cid-field .cid-value:hover { - background: rgba(0, 0, 0, 0.4); - border-color: #646cff; -} - -.cid-field .cid-open-button { - display: inline-flex; - align-items: center; - padding: 0.5rem 0.75rem; - background: #646cff; - color: #fff; - border-radius: 4px; - text-decoration: none; - font-size: 0.85rem; - font-weight: 500; - white-space: nowrap; - transition: background 0.2s; -} - -.cid-field .cid-open-button:hover { - background: #535bf2; -} - -/* JSON field styling */ -.json-field .json-viewer { - background: rgba(0, 0, 0, 0.4); - color: #d4d4d4; - padding: 1rem; - border-radius: 4px; - font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Courier New', monospace; - font-size: 0.85rem; - overflow-x: auto; - max-height: 300px; - overflow-y: auto; - margin: 0; - white-space: pre-wrap; - word-break: break-all; - border: 1px solid rgba(255, 255, 255, 0.1); -} - -/* Language string field styling */ -.language-string-field .language-entries { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.language-string-field .language-entry { - display: flex; - align-items: center; - gap: 0.5rem; -} - -.language-string-field .lang-input { - flex: 1; - min-width: 0; -} - -.language-string-field .lang-code { - display: inline-block; - width: 50px; - padding: 0.4rem 0.5rem; - background: rgba(100, 108, 255, 0.2); - border: 1px solid rgba(100, 108, 255, 0.3); - border-radius: 4px; - font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Courier New', monospace; - font-size: 0.8rem; - color: #a0d8ef; - text-align: center; - flex-shrink: 0; -} - -.language-string-field .lang-text { - flex: 1; - padding: 0.4rem 0.75rem; - background: rgba(0, 0, 0, 0.2); - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 4px; - color: rgba(255, 255, 255, 0.8); - font-size: 0.9rem; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.language-string-field .lang-remove-btn { - width: 32px; - height: 32px; - flex-shrink: 0; - display: flex; - align-items: center; - justify-content: center; - background: rgba(255, 100, 100, 0.2); - border: 1px solid rgba(255, 100, 100, 0.3); - border-radius: 4px; - color: #ff6b6b; - font-size: 1.2rem; - cursor: pointer; - transition: background 0.2s, border-color 0.2s; -} - -.language-string-field .lang-remove-btn:hover { - background: rgba(255, 100, 100, 0.3); - border-color: rgba(255, 100, 100, 0.5); -} - -.language-string-field .lang-add-btn { - align-self: flex-start; - padding: 0.4rem 0.75rem; - background: rgba(100, 108, 255, 0.2); - border: 1px solid rgba(100, 108, 255, 0.3); - border-radius: 4px; - color: #646cff; - font-size: 0.85rem; - cursor: pointer; - transition: background 0.2s, border-color 0.2s; -} - -.language-string-field .lang-add-btn:hover { - background: rgba(100, 108, 255, 0.3); - border-color: rgba(100, 108, 255, 0.5); -} - -/* Light theme */ -@media (prefers-color-scheme: light) { - .dynamic-field label { - color: rgba(0, 0, 0, 0.7); - } - - .dynamic-field input[type="text"], - .dynamic-field input[type="number"], - .dynamic-field select, - .dynamic-field textarea { - border-color: rgba(0, 0, 0, 0.1); - background: #fff; - color: #333; - } - - .dynamic-field input:disabled, - .dynamic-field select:disabled, - .dynamic-field textarea:disabled { - background: #f5f5f5; - color: rgba(0, 0, 0, 0.5); - } - - .field-hint { - color: rgba(0, 0, 0, 0.5); - } - - .checkbox-label { - color: rgba(0, 0, 0, 0.7); - } - - .readonly-value { - background: #f8f9fa; - border-color: #e9ecef; - color: #495057; - } - - .json-field .json-viewer { - background: #1e1e1e; - border-color: rgba(0, 0, 0, 0.2); - } - - .language-string-field .lang-code { - background: rgba(100, 108, 255, 0.1); - color: #4a50c8; - } - - .language-string-field .lang-text { - background: #f8f9fa; - border-color: #e9ecef; - color: #495057; - } -} diff --git a/editor/src/components/DynamicFieldRenderer.tsx b/editor/src/components/DynamicFieldRenderer.tsx deleted file mode 100644 index 9a0d885..0000000 --- a/editor/src/components/DynamicFieldRenderer.tsx +++ /dev/null @@ -1,378 +0,0 @@ -import { SchemaField, FileMetadata } from '../types'; -import { LanguageCombobox, formatLanguageOption } from './LanguageCombobox'; -import './DynamicFieldRenderer.css'; - -interface DynamicFieldRendererProps { - fieldKey: string; - schema: SchemaField; - metadata: FileMetadata; - onChange: (key: string, value: any) => void; - readonly?: boolean; -} - -/** - * Renders a single metadata field based on its schema definition - */ -export function DynamicFieldRenderer({ - fieldKey, - schema, - metadata, - onChange, - readonly = false, -}: DynamicFieldRendererProps) { - const isReadonly = readonly || schema.readonly; - const fieldType = schema.type || 'string'; - - // Get value from metadata using path-style key - const getValue = (): any => { - // Handle path-style keys like "stream/duration" or "nfo/title" - const parts = fieldKey.split('/'); - let value: any = metadata; - for (const part of parts) { - if (value === undefined || value === null) return undefined; - value = value[part]; - } - return value; - }; - - // Set value in metadata using path-style key - const setValue = (newValue: any) => { - onChange(fieldKey, newValue); - }; - - const value = getValue(); - - // Render link if configured - const renderValueWithLink = (displayValue: string) => { - if (schema.link && displayValue) { - const url = schema.link.replace('{value}', encodeURIComponent(displayValue)); - return ( - - {displayValue} - - ); - } - return displayValue; - }; - - // Render based on field type - switch (fieldType) { - case 'boolean': - return ( -
- - {schema.hint && {schema.hint}} -
- ); - - case 'number': - return ( -
- - setValue(e.target.value ? Number(e.target.value) : undefined)} - disabled={isReadonly} - min={schema.min} - max={schema.max} - placeholder={schema.placeholder} - /> - {schema.hint && {schema.hint}} -
- ); - - case 'select': - return ( -
- - - {schema.hint && {schema.hint}} -
- ); - - case 'text': - return ( -
- -