diff --git a/frontend/public/schemas/20251121/linkml/manifest.json b/frontend/public/schemas/20251121/linkml/manifest.json index 527073fdc7..758cbc2a7d 100644 --- a/frontend/public/schemas/20251121/linkml/manifest.json +++ b/frontend/public/schemas/20251121/linkml/manifest.json @@ -1,5 +1,5 @@ { - "generated": "2025-12-06T23:25:26.568Z", + "generated": "2025-12-07T12:41:26.252Z", "version": "1.0.0", "categories": [ { diff --git a/frontend/src/components/database/DuckLakePanel.tsx b/frontend/src/components/database/DuckLakePanel.tsx index 9d578da41e..e3fef9c2da 100644 --- a/frontend/src/components/database/DuckLakePanel.tsx +++ b/frontend/src/components/database/DuckLakePanel.tsx @@ -3,7 +3,7 @@ * Lakehouse with time travel, ACID transactions, and schema evolution */ -import { useState, useRef } from 'react'; +import React, { useState, useRef, useCallback, useEffect } from 'react'; import { useDuckLake } from '@/hooks/useDuckLake'; import type { SnapshotInfo, SchemaChange } from '@/hooks/useDuckLake'; import { useLanguage } from '@/contexts/LanguageContext'; @@ -12,6 +12,270 @@ interface DuckLakePanelProps { compact?: boolean; } +// ============================================ +// ResizableNestedTable Component +// Self-contained component with its own resize state +// ============================================ +interface ResizableNestedTableProps { + columns: string[]; + rows: Record[]; + renderCell: (value: unknown, depth: number) => React.ReactNode; + depth?: number; + tableType?: 'array' | 'kv'; // Array of objects or key-value pairs +} + +// Helper function to estimate the width needed for nested content +function estimateNestedWidth(value: unknown, depth: number = 0): number { + if (value === null || value === undefined) return 50; + + // Limit recursion depth to prevent infinite loops + if (depth > 3) return 300; + + if (typeof value !== 'object') { + const str = String(value); + const isUrl = str.startsWith('http'); + const charWidth = isUrl ? 6 : (str.length > 30 ? 6.5 : 7.5); + return Math.min(str.length, isUrl ? 60 : 50) * charWidth + 20; + } + + if (Array.isArray(value)) { + if (value.length === 0) return 50; + + // Array of objects - calculate table width + if (typeof value[0] === 'object' && value[0] !== null) { + const keys = Object.keys(value[0]); + let totalWidth = 0; + + keys.forEach(key => { + const headerWidth = key.length * 8 + 30; + let maxColWidth = headerWidth; + + // Sample values in this column + value.forEach((item: Record) => { + const cellWidth = estimateNestedWidth(item[key], depth + 1); + maxColWidth = Math.max(maxColWidth, cellWidth); + }); + + totalWidth += Math.min(400, maxColWidth); + }); + + return Math.max(200, totalWidth + 20); // Add some padding + } + + // Array of primitives + let maxWidth = 100; + value.forEach(item => { + maxWidth = Math.max(maxWidth, estimateNestedWidth(item, depth + 1)); + }); + return maxWidth; + } + + // Object (key-value table) + const entries = Object.entries(value as Record); + if (entries.length === 0) return 50; + + let maxKeyWidth = 100; + let maxValueWidth = 150; + + entries.forEach(([key, val]) => { + const keyWidth = key.length * 8 + 30; + maxKeyWidth = Math.max(maxKeyWidth, Math.min(200, keyWidth)); + + const valueWidth = estimateNestedWidth(val, depth + 1); + maxValueWidth = Math.max(maxValueWidth, valueWidth); + }); + + return Math.min(200, maxKeyWidth) + Math.min(600, maxValueWidth) + 20; +} + +function ResizableNestedTable({ columns, rows, renderCell, depth = 0, tableType = 'array' }: ResizableNestedTableProps) { + const [colWidths, setColWidths] = useState>(() => { + // Initialize with smart defaults based on column names and actual content + const widths: Record = {}; + + if (tableType === 'kv') { + // For key-value tables, analyze actual key and value lengths + let maxKeyLen = 0; + let maxValueLen = 0; + + rows.forEach(row => { + const key = Object.keys(row)[0]; + const val = row[key]; + + // Key length (with some padding for the cell) + const keyLen = key.length * 8 + 24; + if (keyLen > maxKeyLen) maxKeyLen = keyLen; + + // Value length - calculate recursively for nested content + if (val !== null && val !== undefined) { + const estimatedWidth = estimateNestedWidth(val, 0); + maxValueLen = Math.max(maxValueLen, estimatedWidth); + } + }); + + // Set reasonable bounds - allow wider values for nested content + widths['key'] = Math.max(100, Math.min(220, maxKeyLen)); + widths['value'] = Math.max(150, Math.min(800, maxValueLen)); + } else { + // For array tables, calculate per-column with recursive estimation + columns.forEach(col => { + // Estimate width based on column name length + const headerLen = col.length * 8 + 30; + + // Sample ALL rows for better accuracy + let maxContentLen = headerLen; + + rows.forEach(row => { + const val = row[col]; + if (val !== null && val !== undefined) { + const estimatedWidth = estimateNestedWidth(val, 0); + maxContentLen = Math.max(maxContentLen, estimatedWidth); + } + }); + + widths[col] = Math.max(80, Math.min(500, maxContentLen)); + }); + } + + return widths; + }); + + const resizeRef = useRef<{ + column: string; + startX: number; + startWidth: number; + } | null>(null); + + const handleResizeStart = useCallback((e: React.MouseEvent, column: string) => { + e.preventDefault(); + e.stopPropagation(); + const startWidth = colWidths[column] || 100; + resizeRef.current = { + column, + startX: e.clientX, + startWidth + }; + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + }, [colWidths]); + + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (!resizeRef.current) return; + const delta = e.clientX - resizeRef.current.startX; + const newWidth = Math.max(50, resizeRef.current.startWidth + delta); + setColWidths(prev => ({ + ...prev, + [resizeRef.current!.column]: newWidth + })); + }; + + const handleMouseUp = () => { + if (resizeRef.current) { + resizeRef.current = null; + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + } + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + }, []); + + if (tableType === 'kv') { + // Key-value table (for objects) + const keyWidth = colWidths['key'] || 120; + const valueWidth = colWidths['value'] || 200; + const totalWidth = keyWidth + valueWidth; + return ( +
+ + + + + + + {rows.map((row, idx) => { + const key = Object.keys(row)[0]; + const val = row[key]; + return ( + + + + + ); + })} + +
+ {key} +
handleResizeStart(e, 'key')} + /> +
+
{renderCell(val, depth + 1)}
+
handleResizeStart(e, 'value')} + /> +
+
+ ); + } + + // Array of objects table + // Calculate total width for the table + const totalWidth = columns.reduce((sum, col) => sum + (colWidths[col] || 100), 0); + + return ( +
+ + + + {columns.map(col => ( + + ))} + + + + {rows.map((row, idx) => ( + + {columns.map(col => ( + + ))} + + ))} + +
+ {col} +
handleResizeStart(e, col)} + /> +
+
+ {renderCell(row[col], depth + 1)} +
+
+
+ ); +} +// ============================================ + const TEXT = { title: { nl: 'DuckLake Lakehouse', en: 'DuckLake Lakehouse' }, description: { @@ -58,6 +322,19 @@ const TEXT = { oldType: { nl: 'Oud type', en: 'Old type' }, newType: { nl: 'Nieuw type', en: 'New type' }, changedAt: { nl: 'Gewijzigd op', en: 'Changed at' }, + // Data Explorer + explore: { nl: 'Verkennen', en: 'Explore' }, + dataExplorer: { nl: 'Data Verkenner', en: 'Data Explorer' }, + selectTable: { nl: 'Selecteer een tabel om te verkennen', en: 'Select a table to explore' }, + viewData: { nl: 'Bekijk data', en: 'View data' }, + previewRows: { nl: 'Voorbeeld rijen', en: 'Preview rows' }, + showingRows: { nl: 'Toon rijen', en: 'Showing rows' }, + of: { nl: 'van', en: 'of' }, + previous: { nl: 'Vorige', en: 'Previous' }, + next: { nl: 'Volgende', en: 'Next' }, + backToTables: { nl: '← Terug naar tabellen', en: '← Back to tables' }, + schema: { nl: 'Schema', en: 'Schema' }, + data: { nl: 'Data', en: 'Data' }, // Disconnected state messages backendRequired: { nl: 'Server-backend Vereist', @@ -117,11 +394,11 @@ export function DuckLakePanel({ compact = false }: DuckLakePanelProps) { refresh, executeQuery, uploadData, - dropTable, + // dropTable - will add back when delete UI is implemented exportTable, } = useDuckLake(); - const [activeTab, setActiveTab] = useState<'overview' | 'query' | 'timetravel' | 'schema' | 'upload'>('overview'); + const [activeTab, setActiveTab] = useState<'explore' | 'query' | 'timetravel' | 'schema' | 'upload'>('explore'); const [query, setQuery] = useState('SELECT * FROM information_schema.tables LIMIT 100'); const [queryResult, setQueryResult] = useState(null); const [isQueryRunning, setIsQueryRunning] = useState(false); @@ -129,6 +406,127 @@ export function DuckLakePanel({ compact = false }: DuckLakePanelProps) { const [uploadTableName, setUploadTableName] = useState(''); const [uploadMode, setUploadMode] = useState<'append' | 'replace' | 'create'>('append'); const fileInputRef = useRef(null); + + // Data Explorer state + const [selectedTable, setSelectedTable] = useState(null); + const [tableData, setTableData] = useState<{ columns: string[]; rows: unknown[][] } | null>(null); + const [isLoadingData, setIsLoadingData] = useState(false); + const [dataPage, setDataPage] = useState(0); + const [explorerView, setExplorerView] = useState<'schema' | 'data'>('data'); + const [expandedRow, setExpandedRow] = useState<{ rowIndex: number; data: Record } | null>(null); + const [columnWidths, setColumnWidths] = useState>({}); + const [rowHeights, setRowHeights] = useState>({}); + const [isFullWidth, setIsFullWidth] = useState(false); // Full-width mode for row detail view + const [showWebArchive, setShowWebArchive] = useState(false); // Web archive viewer mode + const [webArchiveData, setWebArchiveData] = useState<{ + ghcid: string; + url: string; + pages: { title: string; path: string; archived_file: string }[]; + claims: { claim_id: string; claim_type: string; text_content: string; xpath: string; hypernym: string }[]; + } | null>(null); + const [selectedWebPage, setSelectedWebPage] = useState(null); + const PAGE_SIZE = 50; + + // Refs for resize tracking + const resizeRef = useRef<{ + type: 'column' | 'row'; + key: string | number; + startPos: number; + startSize: number; + } | null>(null); + const tableRef = useRef(null); + + // Calculate optimal column width based on content + const calculateOptimalWidth = useCallback((columnName: string, values: unknown[]): number => { + const headerLen = columnName.length * 9; // Approximate char width + let maxContentLen = 0; + + for (const val of values.slice(0, 20)) { // Sample first 20 rows + if (val === null || val === undefined) continue; + const str = typeof val === 'object' ? JSON.stringify(val) : String(val); + const len = Math.min(str.length, 100) * 8; // Cap at 100 chars + if (len > maxContentLen) maxContentLen = len; + } + + // Return width between 80 and 400px + return Math.max(80, Math.min(400, Math.max(headerLen, maxContentLen) + 24)); + }, []); + + // Initialize column widths when table data loads + useEffect(() => { + if (tableData && tableData.columns.length > 0) { + const widths: Record = {}; + tableData.columns.forEach((col, idx) => { + const colValues = tableData.rows.map(row => row[idx]); + widths[col] = calculateOptimalWidth(col, colValues); + }); + setColumnWidths(widths); + setRowHeights({}); // Reset row heights + } + }, [tableData, calculateOptimalWidth]); + + // Handle mouse down on column resize handle + const handleColumnResizeStart = useCallback((e: React.MouseEvent, columnName: string) => { + e.preventDefault(); + e.stopPropagation(); + resizeRef.current = { + type: 'column', + key: columnName, + startPos: e.clientX, + startSize: columnWidths[columnName] || 150 + }; + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + }, [columnWidths]); + + // Handle mouse down on row resize handle + const handleRowResizeStart = useCallback((e: React.MouseEvent, rowIndex: number, currentHeight: number) => { + e.preventDefault(); + e.stopPropagation(); + resizeRef.current = { + type: 'row', + key: rowIndex, + startPos: e.clientY, + startSize: currentHeight + }; + document.body.style.cursor = 'row-resize'; + document.body.style.userSelect = 'none'; + }, []); + + // Handle mouse move during resize + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (!resizeRef.current) return; + + const { type, key, startPos, startSize } = resizeRef.current; + + if (type === 'column') { + const delta = e.clientX - startPos; + const newWidth = Math.max(60, startSize + delta); + setColumnWidths(prev => ({ ...prev, [key]: newWidth })); + } else { + const delta = e.clientY - startPos; + const newHeight = Math.max(32, startSize + delta); + setRowHeights(prev => ({ ...prev, [key as number]: newHeight })); + } + }; + + const handleMouseUp = () => { + if (resizeRef.current) { + resizeRef.current = null; + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + } + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + }, []); const handleRunQuery = async () => { setIsQueryRunning(true); @@ -180,15 +578,7 @@ export function DuckLakePanel({ compact = false }: DuckLakePanelProps) { } }; - const handleDropTable = async (tableName: string) => { - if (window.confirm(`Delete table "${tableName}"? This action cannot be undone.`)) { - try { - await dropTable(tableName); - } catch (err) { - alert(`Drop failed: ${err instanceof Error ? err.message : 'Unknown error'}`); - } - } - }; + // handleDropTable removed - will add back when delete UI is implemented const formatUptime = (seconds: number): string => { if (seconds < 60) return `${Math.round(seconds)}s`; @@ -197,6 +587,273 @@ export function DuckLakePanel({ compact = false }: DuckLakePanelProps) { return `${Math.round(seconds / 86400)}d`; }; + // Load table data for explorer + const loadTableData = async (tableName: string) => { + setIsLoadingData(true); + setSelectedTable(tableName); + setDataPage(0); + try { + const offset = 0; + // Tables are in the 'heritage' schema within DuckLake + const fullTableName = `heritage.${tableName}`; + const result = await executeQuery( + `SELECT * FROM ${fullTableName} LIMIT ${PAGE_SIZE} OFFSET ${offset}`, + selectedSnapshot + ); + setTableData({ columns: result.columns, rows: result.rows }); + } catch (err) { + console.error('Failed to load table data:', err); + setTableData(null); + } finally { + setIsLoadingData(false); + } + }; + + // Load more data for pagination + const loadPage = async (page: number) => { + if (!selectedTable) return; + setIsLoadingData(true); + setDataPage(page); + try { + const offset = page * PAGE_SIZE; + // Tables are in the 'heritage' schema within DuckLake + const fullTableName = `heritage.${selectedTable}`; + const result = await executeQuery( + `SELECT * FROM ${fullTableName} LIMIT ${PAGE_SIZE} OFFSET ${offset}`, + selectedSnapshot + ); + setTableData({ columns: result.columns, rows: result.rows }); + } catch (err) { + console.error('Failed to load page:', err); + } finally { + setIsLoadingData(false); + } + }; + + // Get table icon based on name + const getTableIcon = (tableName: string): string => { + const name = tableName.toLowerCase(); + if (name.includes('custodian') || name.includes('institution')) return '🏛️'; + if (name.includes('collection')) return '📚'; + if (name.includes('location') || name.includes('geo')) return '📍'; + if (name.includes('person') || name.includes('staff')) return '👤'; + if (name.includes('event')) return '📅'; + if (name.includes('identifier') || name.includes('id')) return '🔖'; + if (name.includes('raw')) return '📦'; + return '📊'; + }; + + // Format cell value for display + const formatCellValue = (value: unknown): string => { + if (value === null || value === undefined) return '—'; + if (typeof value === 'object') { + const json = JSON.stringify(value); + if (json.length > 50) return json.slice(0, 47) + '...'; + return json; + } + if (typeof value === 'string') { + // Check if it's a JSON string + if ((value.startsWith('{') || value.startsWith('[')) && value.length > 50) { + return value.slice(0, 47) + '...'; + } + if (value.length > 80) return value.slice(0, 77) + '...'; + } + return String(value); + }; + + // Check if a cell value is expandable (JSON object/array or long string) + const isExpandable = (value: unknown): boolean => { + if (value === null || value === undefined) return false; + if (typeof value === 'object') return true; + if (typeof value === 'string') { + // JSON strings or long text + if (value.startsWith('{') || value.startsWith('[')) return true; + if (value.length > 80) return true; + } + return false; + }; + + // Parse a value that might be JSON string into an object + const parseJsonValue = (value: unknown): unknown => { + if (value === null || value === undefined) return null; + if (typeof value === 'object') return value; + if (typeof value === 'string' && (value.startsWith('{') || value.startsWith('['))) { + try { + return JSON.parse(value); + } catch { + return value; + } + } + return value; + }; + + // Convert a row array to an object with column names as keys + const rowToObject = (row: unknown[], columns: string[]): Record => { + const obj: Record = {}; + columns.forEach((col, idx) => { + obj[col] = row[idx]; + }); + return obj; + }; + + // Get a display name for a row (try common name fields) + const getRowDisplayName = (rowObj: Record): string => { + const nameFields = ['org_name', 'custodian_name', 'name', 'title', 'file_name']; + for (const field of nameFields) { + if (rowObj[field] && typeof rowObj[field] === 'string') { + return rowObj[field] as string; + } + } + return 'Record'; + }; + + // Get GHCID from row data (try common GHCID fields) + const getRowGhcid = (rowObj: Record): string | null => { + const ghcidFields = ['ghcid', 'ghcid_current', 'file_name']; + for (const field of ghcidFields) { + if (rowObj[field] && typeof rowObj[field] === 'string') { + const val = rowObj[field] as string; + // GHCID pattern: XX-XX-XXX-X-XXX + if (val.match(/^[A-Z]{2}-[A-Z0-9]{2,3}-[A-Z]{3}-[A-Z]-/)) { + return val.replace('.yaml', ''); + } + } + } + return null; + }; + + // Load web archive data for a GHCID + const loadWebArchive = async (ghcid: string) => { + if (!ghcid) return; + try { + // Query the web_archives table for this GHCID + const archiveResult = await executeQuery( + `SELECT ghcid, url, domain, total_pages FROM heritage.web_archives WHERE ghcid = '${ghcid}'`, + selectedSnapshot + ); + + if (archiveResult.rows.length === 0) { + setWebArchiveData(null); + return; + } + + const archive = archiveResult.rows[0]; + const url = archive[1] as string; + + // Get pages + const pagesResult = await executeQuery( + `SELECT page_title, source_path, archived_file FROM heritage.web_pages WHERE ghcid = '${ghcid}' LIMIT 50`, + selectedSnapshot + ); + + // Get claims + const claimsResult = await executeQuery( + `SELECT claim_id, claim_type, text_content, xpath, hypernym FROM heritage.web_claims WHERE ghcid = '${ghcid}' LIMIT 100`, + selectedSnapshot + ); + + setWebArchiveData({ + ghcid, + url, + pages: pagesResult.rows.map(r => ({ + title: r[0] as string, + path: r[1] as string, + archived_file: r[2] as string + })), + claims: claimsResult.rows.map(r => ({ + claim_id: r[0] as string, + claim_type: r[1] as string, + text_content: r[2] as string, + xpath: r[3] as string, + hypernym: r[4] as string + })) + }); + setShowWebArchive(true); + } catch (err) { + console.error('Failed to load web archive:', err); + setWebArchiveData(null); + } + }; + + // Check if row has web archive + const hasWebArchive = async (ghcid: string): Promise => { + if (!ghcid) return false; + try { + const result = await executeQuery( + `SELECT COUNT(*) FROM heritage.web_archives WHERE ghcid = '${ghcid}'`, + selectedSnapshot + ); + return result.rows.length > 0 && (result.rows[0][0] as number) > 0; + } catch { + return false; + } + }; + + // Render a value - either simple or as nested table with resizable columns + const renderValue = (value: unknown, depth = 0): React.ReactNode => { + if (value === null || value === undefined) return ; + + const parsed = parseJsonValue(value); + + // Simple values + if (typeof parsed !== 'object') { + const strVal = String(parsed); + if (strVal.startsWith('http://') || strVal.startsWith('https://')) { + return {strVal}; + } + return {strVal}; + } + + // Arrays + if (Array.isArray(parsed)) { + if (parsed.length === 0) return [ ]; + + // Array of objects - render as resizable sub-table + if (typeof parsed[0] === 'object' && parsed[0] !== null) { + const keys = Object.keys(parsed[0]); + return ( +
+ []} + renderCell={renderValue} + depth={depth} + tableType="array" + /> +
+ ); + } + + // Array of primitives + return ( +
    + {parsed.map((item, idx) => ( +
  • {renderValue(item, depth + 1)}
  • + ))} +
+ ); + } + + // Objects - render as resizable key-value table + const entries = Object.entries(parsed as Record); + if (entries.length === 0) return { }; + + // Convert entries to rows format for ResizableNestedTable + const kvRows = entries.map(([key, val]) => ({ [key]: val })); + + return ( +
+ +
+ ); + }; + // Compact view for comparison grid if (compact) { // Show informative disconnected state @@ -204,7 +861,7 @@ export function DuckLakePanel({ compact = false }: DuckLakePanelProps) { return (
- 🏔️ + 🦆

DuckLake

{t('backendRequired')} @@ -227,7 +884,7 @@ export function DuckLakePanel({ compact = false }: DuckLakePanelProps) { return (
- 🏔️ + 🦆

DuckLake

{isLoading ? t('loading') : status.isConnected ? t('connected') : t('disconnected')} @@ -271,7 +928,7 @@ export function DuckLakePanel({ compact = false }: DuckLakePanelProps) { {/* Header */}
- 🏔️ + 🦆

{t('title')}

{t('description')}

@@ -345,7 +1002,7 @@ export function DuckLakePanel({ compact = false }: DuckLakePanelProps) { {/* Header */}
- 🏔️ + 🦆

{t('title')}

{t('description')}

@@ -409,16 +1066,16 @@ export function DuckLakePanel({ compact = false }: DuckLakePanelProps) { {/* Tabs */}
+
+ {getTableIcon(selectedTable)} +

{selectedTable}

+ {stats?.tables.find(t => t.name === selectedTable) && ( + + {stats.tables.find(t => t.name === selectedTable)?.rowCount.toLocaleString()} {t('rows')} + + )} +
+
+ + + + +
+
+ + {explorerView === 'schema' ? ( + /* Schema View */ +
+
+ {stats?.tables.find(t => t.name === selectedTable)?.columns.map((col, idx) => ( +
+ {idx + 1} + {col.name} + {col.type} +
+ ))} +
+
+ ) : ( + /* Data View */ +
+ {isLoadingData ? ( +
+ + {t('loading')} +
+ ) : expandedRow ? ( + /* Expanded Row Detail View */ +
+
+ +

+ 📋 + {getRowDisplayName(expandedRow.data)} +

+ Row {expandedRow.rowIndex} + + {/* Web Archive Button */} + {getRowGhcid(expandedRow.data) && ( + + )} +
+ + {/* Web Archive Viewer */} + {showWebArchive && webArchiveData && ( +
+
+
+ 🗄️ + Web Archive: {webArchiveData.ghcid} +
+ + {webArchiveData.url} ↗ + +
+ +
+ {/* Archived Pages - Wayback style */} +
+
📄 Archived Pages ({webArchiveData.pages.length})
+
+ {webArchiveData.pages.map((page, idx) => ( +
setSelectedWebPage( + selectedWebPage === page.archived_file ? null : page.archived_file + )} + > + {page.title || 'Untitled'} + {page.path} +
+ ))} + {webArchiveData.pages.length === 0 && ( +
No archived pages found
+ )} +
+
+ + {/* Extracted Claims */} +
+
🏷️ Extracted Claims ({webArchiveData.claims.length})
+
+ {webArchiveData.claims.map((claim, idx) => ( +
+
+ + {claim.claim_type} + + {claim.hypernym && ( + {claim.hypernym} + )} +
+
{claim.text_content}
+ {claim.xpath && ( +
+ 📍 {claim.xpath.substring(0, 60)}... +
+ )} +
+ ))} + {webArchiveData.claims.length === 0 && ( +
No claims extracted
+ )} +
+
+
+
+ )} + +
+ {Object.entries(expandedRow.data).map(([key, value]) => { + const hasNestedData = isExpandable(value); + return ( +
+
+ {key} + {hasNestedData && JSON} +
+
+ {renderValue(value)} +
+
+ ); + })} +
+
+ ) : tableData ? ( + <> +
+ + + + + {tableData.columns.map((col) => ( + + ))} + + + + {tableData.rows.map((row, rowIdx) => { + const rowObj = rowToObject(row, tableData.columns); + const rowHeight = rowHeights[rowIdx]; + return ( + setExpandedRow({ + rowIndex: dataPage * PAGE_SIZE + rowIdx + 1, + data: rowObj + })} + > + + {row.map((cell, cellIdx) => ( + + ))} + + ); + })} + +
+ {col} +
handleColumnResizeStart(e, col)} + /> +
+ +
handleRowResizeStart(e, rowIdx, rowHeight || 40)} + onClick={(e) => e.stopPropagation()} + /> +
+
+ {formatCellValue(cell)} +
+
+
+
+ + + {t('showingRows')} {dataPage * PAGE_SIZE + 1} - {dataPage * PAGE_SIZE + tableData.rows.length} + + +
+ + ) : ( +

No data available

+ )} +
+ )} +
)}
)} diff --git a/frontend/src/hooks/useWerkgebiedMapLibre.ts b/frontend/src/hooks/useWerkgebiedMapLibre.ts index a747366379..29a31ac28c 100644 --- a/frontend/src/hooks/useWerkgebiedMapLibre.ts +++ b/frontend/src/hooks/useWerkgebiedMapLibre.ts @@ -209,34 +209,31 @@ export function useWerkgebiedMapLibre(map: maplibregl.Map | null): WerkgebiedHoo }); } - // Add fill layer (below markers) + // Add fill layer (below markers) - use static values, we'll update them when showing if (!map.getLayer(WERKGEBIED_FILL_LAYER_ID)) { - // Check if institutions layer exists to insert before it - const beforeLayer = map.getLayer('institutions-circles') ? 'institutions-circles' : undefined; map.addLayer({ id: WERKGEBIED_FILL_LAYER_ID, type: 'fill', source: WERKGEBIED_SOURCE_ID, paint: { - 'fill-color': ['get', 'fillColor'], - 'fill-opacity': ['get', 'fillOpacity'], + 'fill-color': WERKGEBIED_FILL_COLOR, + 'fill-opacity': WERKGEBIED_FILL_OPACITY, }, - }, beforeLayer); + }); } // Add line layer for borders if (!map.getLayer(WERKGEBIED_LINE_LAYER_ID)) { - const beforeLayer = map.getLayer('institutions-circles') ? 'institutions-circles' : undefined; map.addLayer({ id: WERKGEBIED_LINE_LAYER_ID, type: 'line', source: WERKGEBIED_SOURCE_ID, paint: { - 'line-color': ['get', 'lineColor'], - 'line-width': ['get', 'lineWidth'], + 'line-color': WERKGEBIED_LINE_COLOR, + 'line-width': WERKGEBIED_LINE_WIDTH, 'line-dasharray': [5, 5], }, - }, beforeLayer); + }); } layersAddedRef.current = true; @@ -298,6 +295,54 @@ export function useWerkgebiedMapLibre(map: maplibregl.Map | null): WerkgebiedHoo setHistoricalDescription(null); }, [map]); + // Helper to ensure source and layers exist + const ensureSourceAndLayers = useCallback(() => { + if (!map) return false; + + // Add source if missing + if (!map.getSource(WERKGEBIED_SOURCE_ID)) { + console.log('[useWerkgebiedMapLibre] Adding missing source'); + map.addSource(WERKGEBIED_SOURCE_ID, { + type: 'geojson', + data: { + type: 'FeatureCollection', + features: [], + }, + }); + } + + // Add fill layer if missing + if (!map.getLayer(WERKGEBIED_FILL_LAYER_ID)) { + console.log('[useWerkgebiedMapLibre] Adding missing fill layer'); + map.addLayer({ + id: WERKGEBIED_FILL_LAYER_ID, + type: 'fill', + source: WERKGEBIED_SOURCE_ID, + paint: { + 'fill-color': WERKGEBIED_FILL_COLOR, + 'fill-opacity': WERKGEBIED_FILL_OPACITY, + }, + }); + } + + // Add line layer if missing + if (!map.getLayer(WERKGEBIED_LINE_LAYER_ID)) { + console.log('[useWerkgebiedMapLibre] Adding missing line layer'); + map.addLayer({ + id: WERKGEBIED_LINE_LAYER_ID, + type: 'line', + source: WERKGEBIED_SOURCE_ID, + paint: { + 'line-color': WERKGEBIED_LINE_COLOR, + 'line-width': WERKGEBIED_LINE_WIDTH, + 'line-dasharray': [5, 5], + }, + }); + } + + return true; + }, [map]); + // Helper to update map with GeoJSON features const updateMapWithFeatures = useCallback(( features: GeoJSONFeature[] | HALCFeature[], @@ -306,26 +351,50 @@ export function useWerkgebiedMapLibre(map: maplibregl.Map | null): WerkgebiedHoo ) => { if (!map) return; + // Ensure source and layers exist before updating + if (!ensureSourceAndLayers()) { + console.warn('[useWerkgebiedMapLibre] Failed to ensure source and layers'); + return; + } + const { fitBounds = true, fitBoundsPadding = [50, 50], maxZoom = 12 } = options; - // Add style properties to features - const styledFeatures = features.map(f => ({ - ...f, - properties: { - ...f.properties, - fillColor: isHistorical ? HISTORICAL_FILL_COLOR : WERKGEBIED_FILL_COLOR, - fillOpacity: isHistorical ? HISTORICAL_FILL_OPACITY : WERKGEBIED_FILL_OPACITY, - lineColor: isHistorical ? HISTORICAL_LINE_COLOR : WERKGEBIED_LINE_COLOR, - lineWidth: isHistorical ? HISTORICAL_LINE_WIDTH : WERKGEBIED_LINE_WIDTH, - }, - })); + // Update paint properties based on historical vs modern + const fillColor = isHistorical ? HISTORICAL_FILL_COLOR : WERKGEBIED_FILL_COLOR; + const fillOpacity = isHistorical ? HISTORICAL_FILL_OPACITY : WERKGEBIED_FILL_OPACITY; + const lineColor = isHistorical ? HISTORICAL_LINE_COLOR : WERKGEBIED_LINE_COLOR; + const lineWidth = isHistorical ? HISTORICAL_LINE_WIDTH : WERKGEBIED_LINE_WIDTH; + // Update layer paint properties + if (map.getLayer(WERKGEBIED_FILL_LAYER_ID)) { + map.setPaintProperty(WERKGEBIED_FILL_LAYER_ID, 'fill-color', fillColor); + map.setPaintProperty(WERKGEBIED_FILL_LAYER_ID, 'fill-opacity', fillOpacity); + } + if (map.getLayer(WERKGEBIED_LINE_LAYER_ID)) { + map.setPaintProperty(WERKGEBIED_LINE_LAYER_ID, 'line-color', lineColor); + map.setPaintProperty(WERKGEBIED_LINE_LAYER_ID, 'line-width', lineWidth); + } + + // Ensure werkgebied layers are below institutions layer + if (map.getLayer('institutions-circles')) { + if (map.getLayer(WERKGEBIED_FILL_LAYER_ID)) { + map.moveLayer(WERKGEBIED_FILL_LAYER_ID, 'institutions-circles'); + } + if (map.getLayer(WERKGEBIED_LINE_LAYER_ID)) { + map.moveLayer(WERKGEBIED_LINE_LAYER_ID, 'institutions-circles'); + } + } + + // Update source data const source = map.getSource(WERKGEBIED_SOURCE_ID) as maplibregl.GeoJSONSource | undefined; if (source) { source.setData({ type: 'FeatureCollection', - features: styledFeatures, + features: features, } as GeoJSON.FeatureCollection); + console.log('[useWerkgebiedMapLibre] Updated source with', features.length, 'features'); + } else { + console.warn('[useWerkgebiedMapLibre] Source still not found after ensure:', WERKGEBIED_SOURCE_ID); } // Fit map bounds to werkgebied - only if camera is zoomed out far @@ -356,7 +425,7 @@ export function useWerkgebiedMapLibre(map: maplibregl.Map | null): WerkgebiedHoo maxZoom: maxZoom, }); } - }, [map]); + }, [map, ensureSourceAndLayers]); // Show werkgebied for an archive const showWerkgebied = useCallback((archiveId: string, options: WerkgebiedOptions = {}) => { diff --git a/frontend/src/pages/Database.css b/frontend/src/pages/Database.css index 12f574264d..492c8da4d0 100644 --- a/frontend/src/pages/Database.css +++ b/frontend/src/pages/Database.css @@ -1,9 +1,9 @@ /* Database Page Styles - Oxigraph Triplestore Management */ .database-page { - max-width: 1200px; - margin: 0 auto; - padding: 2rem; + max-width: none; + margin: 0; + padding: 1rem 1.5rem; min-height: calc(100vh - 60px); animation: fadeIn 0.5s ease-in; } @@ -21,8 +21,8 @@ .database-header { text-align: center; - margin-bottom: 2rem; - padding-bottom: 1.5rem; + margin-bottom: 1.25rem; + padding-bottom: 1rem; border-bottom: 2px solid #e0e0e0; } @@ -42,10 +42,10 @@ .status-card { background: white; border-radius: 12px; - padding: 1.5rem; + padding: 1rem 1.25rem; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); border: 1px solid #e0e0e0; - margin-bottom: 2rem; + margin-bottom: 1.25rem; } .status-header { @@ -163,7 +163,7 @@ display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; - margin-bottom: 2rem; + margin-bottom: 1.25rem; } .stat-card { @@ -199,7 +199,7 @@ .database-tabs { display: flex; gap: 0.5rem; - margin-bottom: 1.5rem; + margin-bottom: 1rem; border-bottom: 2px solid #e0e0e0; padding-bottom: 0; flex-wrap: wrap; @@ -231,10 +231,10 @@ .tab-content { background: white; border-radius: 12px; - padding: 1.5rem; + padding: 1rem; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); border: 1px solid #e0e0e0; - margin-bottom: 2rem; + margin-bottom: 1.5rem; } /* Overview Tab */ @@ -674,7 +674,7 @@ button:disabled { /* Responsive Design */ @media (max-width: 768px) { .database-page { - padding: 1rem; + padding: 0.75rem; } .database-header h1 { @@ -841,7 +841,7 @@ button:disabled { .database-nav { display: flex; gap: 0.5rem; - margin-bottom: 2rem; + margin-bottom: 1.25rem; padding: 0.5rem; background: #f8f9fa; border-radius: 12px; @@ -1361,6 +1361,11 @@ button:disabled { border-radius: 4px; } +/* Override for resizable nested tables - width is set inline */ +.nested-table.resizable-nested { + width: auto !important; +} + .nested-table th, .nested-table td { padding: 0.5rem; @@ -1743,3 +1748,1436 @@ button:disabled { color: #ffc107; } } + +/* Clickable Database Cards */ +.db-status-card.clickable { + cursor: pointer; + user-select: none; +} + +.db-status-card.clickable:hover { + transform: translateY(-4px); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2); + border-color: var(--db-color, #667eea); +} + +.db-status-card.clickable:active { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +.db-status-card.clickable:focus { + outline: 2px solid var(--db-color, #667eea); + outline-offset: 2px; +} + +/* Back Button */ +.back-button { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1.25rem; + margin-bottom: 1.5rem; + background: #f5f5f5; + border: 1px solid #e0e0e0; + border-radius: 8px; + color: #2c3e50; + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.back-button:hover { + background: #e8e8e8; + border-color: #ccc; +} + +.back-button:active { + background: #ddd; +} + +@media (prefers-color-scheme: dark) { + .db-status-card.clickable:hover { + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); + } + + .back-button { + background: #2d2d6e; + border-color: #3d3d8e; + color: #e0e0e0; + } + + .back-button:hover { + background: #3d3d8e; + border-color: #4d4dae; + } +} + +/* ============================================ + Data Explorer Styles + ============================================ */ + +.data-explorer { + padding: 1rem 0; +} + +.explorer-hint { + color: #666; + margin-bottom: 1.5rem; + font-size: 1.1rem; +} + +/* Table Cards Grid */ +.table-cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 1rem; +} + +.table-card { + display: flex; + align-items: center; + gap: 1rem; + padding: 1.25rem; + background: white; + border: 1px solid #e0e0e0; + border-radius: 12px; + cursor: pointer; + transition: all 0.2s ease; +} + +.table-card:hover { + border-color: #FFC107; + box-shadow: 0 4px 12px rgba(255, 193, 7, 0.2); + transform: translateY(-2px); +} + +.table-card:focus { + outline: 2px solid #FFC107; + outline-offset: 2px; +} + +.table-card-icon { + font-size: 2.5rem; + flex-shrink: 0; +} + +.table-card-info { + flex: 1; + min-width: 0; +} + +.table-card-info h4 { + margin: 0 0 0.5rem 0; + font-size: 1.1rem; + color: #2c3e50; + word-break: break-word; +} + +.table-card-stats { + display: flex; + gap: 1rem; + font-size: 0.9rem; + color: #666; +} + +.table-stat strong { + color: #2c3e50; +} + +.table-card-arrow { + font-size: 1.5rem; + color: #ccc; + transition: transform 0.2s, color 0.2s; +} + +.table-card:hover .table-card-arrow { + color: #FFC107; + transform: translateX(4px); +} + +/* Table Viewer */ +.table-viewer { + background: white; + border-radius: 12px; + border: 1px solid #e0e0e0; + overflow: hidden; +} + +.viewer-header { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 1rem; + padding: 1rem 1.5rem; + background: #f8f9fa; + border-bottom: 1px solid #e0e0e0; +} + +.back-link { + background: none; + border: none; + color: #667eea; + font-size: 0.95rem; + cursor: pointer; + padding: 0.5rem 0; + transition: color 0.2s; +} + +.back-link:hover { + color: #5a6fd6; + text-decoration: underline; +} + +.viewer-title { + display: flex; + align-items: center; + gap: 0.75rem; + flex: 1; +} + +.viewer-icon { + font-size: 1.75rem; +} + +.viewer-title h3 { + margin: 0; + font-size: 1.25rem; + color: #2c3e50; +} + +.viewer-meta { + font-size: 0.9rem; + color: #666; + background: #e9ecef; + padding: 0.25rem 0.75rem; + border-radius: 20px; +} + +.viewer-actions { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.view-toggle { + padding: 0.5rem 1rem; + border: 1px solid #e0e0e0; + background: white; + border-radius: 6px; + cursor: pointer; + font-size: 0.9rem; + transition: all 0.2s; +} + +.view-toggle:hover { + background: #f0f0f0; +} + +.view-toggle.active { + background: #FFC107; + border-color: #FFC107; + color: #000; + font-weight: 500; +} + +/* Schema View */ +.schema-view { + padding: 1.5rem; +} + +.schema-columns { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.schema-column { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.75rem 1rem; + background: #f8f9fa; + border-radius: 8px; + border-left: 3px solid #FFC107; +} + +.column-index { + width: 2rem; + text-align: center; + font-size: 0.85rem; + color: #999; + font-weight: 500; +} + +.column-name { + flex: 1; + font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace; + font-weight: 500; + color: #2c3e50; +} + +.column-type { + font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace; + font-size: 0.85rem; + color: #667eea; + background: #eef0ff; + padding: 0.25rem 0.5rem; + border-radius: 4px; +} + +/* Data View */ +.data-view { + padding: 0; +} + +.loading-data { + display: flex; + align-items: center; + justify-content: center; + gap: 1rem; + padding: 3rem; + color: #666; +} + +.spinner { + width: 24px; + height: 24px; + border: 3px solid #e0e0e0; + border-top-color: #FFC107; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.data-table-wrapper { + overflow-x: auto; + max-height: 500px; + overflow-y: auto; +} + +.explorer-table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; +} + +.explorer-table th { + position: sticky; + top: 0; + background: #f8f9fa; + padding: 0.75rem 1rem; + text-align: left; + font-weight: 600; + color: #2c3e50; + border-bottom: 2px solid #e0e0e0; + white-space: nowrap; +} + +.explorer-table td { + padding: 0.6rem 1rem; + border-bottom: 1px solid #f0f0f0; + max-width: 300px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.explorer-table tbody tr:hover { + background: #fffdf5; +} + +.explorer-table tbody tr:nth-child(even) { + background: #fafafa; +} + +.explorer-table tbody tr:nth-child(even):hover { + background: #fffdf5; +} + +/* Pagination */ +.pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 1.5rem; + padding: 1rem; + border-top: 1px solid #e0e0e0; + background: #f8f9fa; +} + +.page-btn { + padding: 0.5rem 1rem; + border: 1px solid #e0e0e0; + background: white; + border-radius: 6px; + cursor: pointer; + font-size: 0.9rem; + transition: all 0.2s; +} + +.page-btn:hover:not(:disabled) { + background: #FFC107; + border-color: #FFC107; +} + +.page-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.page-info { + font-size: 0.9rem; + color: #666; +} + +/* Dark Mode Support */ +@media (prefers-color-scheme: dark) { + .table-card { + background: #1e1e2e; + border-color: #3d3d5c; + } + + .table-card:hover { + border-color: #FFC107; + box-shadow: 0 4px 12px rgba(255, 193, 7, 0.15); + } + + .table-card-info h4 { + color: #e0e0e0; + } + + .table-card-stats { + color: #a0a0a0; + } + + .table-stat strong { + color: #e0e0e0; + } + + .table-viewer { + background: #1e1e2e; + border-color: #3d3d5c; + } + + .viewer-header { + background: #16162a; + border-color: #3d3d5c; + } + + .back-link { + color: #8b9dff; + } + + .viewer-title h3 { + color: #e0e0e0; + } + + .viewer-meta { + background: #2d2d4a; + color: #a0a0a0; + } + + .view-toggle { + background: #2d2d4a; + border-color: #3d3d5c; + color: #e0e0e0; + } + + .view-toggle:hover { + background: #3d3d5c; + } + + .view-toggle.active { + background: #FFC107; + color: #000; + } + + .schema-column { + background: #16162a; + } + + .column-name { + color: #e0e0e0; + } + + .column-type { + background: #2d2d4a; + color: #8b9dff; + } + + .explorer-table th { + background: #16162a; + color: #e0e0e0; + border-color: #3d3d5c; + } + + .explorer-table td { + border-color: #2d2d4a; + color: #c0c0c0; + } + + .explorer-table tbody tr:hover { + background: #252540; + } + + .explorer-table tbody tr:nth-child(even) { + background: #1a1a2e; + } + + .explorer-table tbody tr:nth-child(even):hover { + background: #252540; + } + + .pagination { + background: #16162a; + border-color: #3d3d5c; + } + + .page-btn { + background: #2d2d4a; + border-color: #3d3d5c; + color: #e0e0e0; + } + + .page-btn:hover:not(:disabled) { + background: #FFC107; + color: #000; + } + + .page-info { + color: #a0a0a0; + } + + .explorer-hint { + color: #a0a0a0; + } + + .loading-data { + color: #a0a0a0; + } + + .spinner { + border-color: #3d3d5c; + border-top-color: #FFC107; + } +} + + +/* ========================================================================== + Expandable Cell & JSON Modal Styles + ========================================================================== */ + +/* Expandable cells in data table */ +.expandable-cell { + cursor: pointer; + position: relative; +} + +.expandable-cell:hover { + background: rgba(255, 193, 7, 0.15) !important; +} + +.expand-icon { + font-size: 0.7rem; + margin-right: 0.3rem; + opacity: 0.6; +} + +.expandable-cell:hover .expand-icon { + opacity: 1; +} + +/* Modal overlay */ +.cell-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 2rem; + backdrop-filter: blur(4px); +} + +/* Modal container */ +.cell-modal { + background: #fff; + border-radius: 12px; + max-width: 900px; + width: 100%; + max-height: 80vh; + display: flex; + flex-direction: column; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + overflow: hidden; +} + +/* Modal header */ +.cell-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 1.5rem; + border-bottom: 1px solid #e0e0e0; + background: #f8f9fa; +} + +.cell-modal-title { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.modal-icon { + font-size: 1.5rem; +} + +.modal-title-text h3 { + margin: 0; + font-size: 1.1rem; + font-weight: 600; + color: #1a1a2e; +} + +.modal-subtitle { + font-size: 0.8rem; + color: #666; +} + +.modal-close { + background: none; + border: none; + font-size: 1.5rem; + color: #666; + cursor: pointer; + padding: 0.25rem 0.5rem; + border-radius: 4px; + transition: all 0.2s ease; +} + +.modal-close:hover { + background: #e0e0e0; + color: #333; +} + +/* Modal content */ +.cell-modal-content { + padding: 1.5rem; + overflow: auto; + flex: 1; +} + +.cell-json-view { + background: #1a1a2e; + color: #e0e0e0; + padding: 1rem; + border-radius: 8px; + font-family: "SF Mono", "Monaco", "Consolas", monospace; + font-size: 0.85rem; + line-height: 1.5; + overflow-x: auto; + white-space: pre-wrap; + word-wrap: break-word; + margin: 0; +} + +/* Modal actions */ +.cell-modal-actions { + display: flex; + gap: 0.5rem; + padding: 1rem 1.5rem; + border-top: 1px solid #e0e0e0; + background: #f8f9fa; +} + +.modal-action-btn { + padding: 0.5rem 1rem; + border: 1px solid #ddd; + border-radius: 6px; + background: #fff; + cursor: pointer; + font-size: 0.9rem; + transition: all 0.2s ease; +} + +.modal-action-btn:hover { + background: #FFC107; + border-color: #FFC107; + color: #000; +} + +/* Dark mode for modal */ +@media (prefers-color-scheme: dark) { + .cell-modal { + background: #1a1a2e; + } + + .cell-modal-header { + background: #16162a; + border-color: #3d3d5c; + } + + .modal-title-text h3 { + color: #e0e0e0; + } + + .modal-subtitle { + color: #a0a0a0; + } + + .modal-close { + color: #a0a0a0; + } + + .modal-close:hover { + background: #3d3d5c; + color: #e0e0e0; + } + + .cell-json-view { + background: #0d0d1a; + } + + .cell-modal-actions { + background: #16162a; + border-color: #3d3d5c; + } + + .modal-action-btn { + background: #2d2d4a; + border-color: #3d3d5c; + color: #e0e0e0; + } + + .modal-action-btn:hover { + background: #FFC107; + border-color: #FFC107; + color: #000; + } +} + + +/* ========================================================================== + Row Detail View & Nested Data Styles + ========================================================================== */ + +/* Clickable rows */ +.clickable-row { + cursor: pointer; + transition: background 0.15s ease; +} + +.clickable-row:hover { + background: rgba(255, 193, 7, 0.15) !important; +} + +.row-expand-col { + width: 30px; + text-align: center; +} + +.row-expand-cell { + text-align: center; + color: #666; +} + +.expand-arrow { + font-size: 0.7rem; + transition: transform 0.2s ease; +} + +.clickable-row:hover .expand-arrow { + color: #FFC107; + transform: translateX(2px); +} + +.more-cols, .more-data { + color: #888; + font-style: italic; +} + +/* Row Detail View */ +.row-detail-view { + padding: 0; +} + +.row-detail-header { + display: flex; + align-items: center; + gap: 1rem; + padding: 1rem; + background: #f8f9fa; + border-bottom: 1px solid #e0e0e0; + border-radius: 8px 8px 0 0; + flex-wrap: wrap; +} + +.row-detail-title { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0; + font-size: 1.1rem; + flex: 1; +} + +.row-icon { + font-size: 1.2rem; +} + +.row-detail-meta { + background: #e9ecef; + padding: 0.25rem 0.75rem; + border-radius: 12px; + font-size: 0.8rem; + color: #666; +} + +/* Expand toggle button for full-width mode */ +.expand-toggle { + background: #e9ecef; + border: 1px solid #ccc; + border-radius: 6px; + padding: 0.35rem 0.6rem; + font-size: 1rem; + cursor: pointer; + transition: all 0.2s ease; + margin-left: auto; +} + +.expand-toggle:hover { + background: #ddd; + border-color: #999; +} + +.expand-toggle.active { + background: #FFC107; + border-color: #e0a800; + color: #000; +} + +/* Full-width mode for row detail view */ +.row-detail-view.full-width { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 1000; + background: #fff; + border-radius: 0; + margin: 0; + display: flex; + flex-direction: column; +} + +.row-detail-view.full-width .row-detail-header { + border-radius: 0; + padding: 0.75rem 1.5rem; + flex-shrink: 0; +} + +.row-detail-view.full-width .row-detail-content { + flex: 1; + max-height: none; + padding: 1rem 1.5rem; + overflow-y: auto; +} + +.row-detail-view.full-width .field-section { + margin-bottom: 0.75rem; +} + +.row-detail-view.full-width .field-value { + padding: 0.5rem 0.75rem; +} + +.row-detail-content { + padding: 0.75rem; + max-height: 60vh; + overflow-y: auto; +} + +/* Field sections */ +.field-section { + margin-bottom: 0.75rem; + border: 1px solid #e0e0e0; + border-radius: 8px; + overflow: hidden; +} + +.field-section.has-nested { + border-color: #FFC107; +} + +.field-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 0.75rem; + background: #f8f9fa; + border-bottom: 1px solid #e0e0e0; +} + +.field-name { + font-weight: 600; + font-size: 0.85rem; + color: #333; +} + +.field-type-badge { + background: #FFC107; + color: #000; + padding: 0.15rem 0.5rem; + border-radius: 4px; + font-size: 0.7rem; + font-weight: 600; +} + +.field-value { + padding: 0.5rem 0.75rem; + font-size: 0.9rem; + word-break: break-word; +} + +/* Value types */ +.null-value { + color: #999; + font-style: italic; +} + +.empty-value { + color: #999; +} + +.link-value { + color: #0066cc; + text-decoration: none; +} + +.link-value:hover { + text-decoration: underline; +} + +.simple-value { + color: #333; +} + +/* Nested tables */ +.nested-table { + width: 100%; + border-collapse: collapse; + font-size: 0.85rem; + margin: 0.25rem 0; +} + +/* Override for resizable nested tables - width controlled by inline style */ +.nested-table.resizable-nested { + width: auto !important; +} + +.nested-table th, +.nested-table td { + padding: 0.4rem 0.6rem; + border: 1px solid #e0e0e0; + text-align: left; + vertical-align: top; +} + +.nested-table th { + background: #f0f0f0; + font-weight: 600; + font-size: 0.8rem; + color: #555; +} + +.nested-table tbody tr:hover { + background: #f8f9fa; +} + +/* Key-value table */ +.kv-table .kv-key { + width: 30%; + background: #f8f9fa; + font-weight: 500; + color: #555; +} + +.kv-table .kv-value { + width: 70%; +} + +/* Override percentage widths for resizable KV tables - use inline widths instead */ +.kv-table.resizable-nested .kv-key, +.kv-table.resizable-nested .kv-value { + width: auto; /* Width controlled by inline styles and colgroup */ +} + +/* Array list */ +.array-list { + margin: 0; + padding-left: 1.5rem; + list-style: disc; +} + +.array-list li { + margin-bottom: 0.25rem; +} + +/* Nested containers */ +.nested-object, +.nested-array { + margin: 0.25rem 0; +} + +/* Dark mode for row detail */ +@media (prefers-color-scheme: dark) { + .row-detail-header { + background: #16162a; + border-color: #3d3d5c; + } + + .row-detail-title { + color: #e0e0e0; + } + + .row-detail-meta { + background: #2d2d4a; + color: #a0a0a0; + } + + .field-section { + border-color: #3d3d5c; + } + + .field-section.has-nested { + border-color: #FFC107; + } + + .field-header { + background: #16162a; + border-color: #3d3d5c; + } + + .field-name { + color: #e0e0e0; + } + + .field-value { + background: #1a1a2e; + } + + .simple-value { + color: #c0c0c0; + } + + .link-value { + color: #8b9dff; + } + + .nested-table th, + .nested-table td { + border-color: #3d3d5c; + } + + .nested-table th { + background: #16162a; + color: #a0a0a0; + } + + .nested-table tbody tr:hover { + background: #252540; + } + + .kv-table .kv-key { + background: #16162a; + color: #a0a0a0; + } + + .row-expand-cell { + color: #888; + } + + /* Dark mode for expand toggle and full-width mode */ + .expand-toggle { + background: #2d2d4a; + border-color: #3d3d5c; + color: #e0e0e0; + } + + .expand-toggle:hover { + background: #3d3d5c; + border-color: #5d5d7c; + } + + .expand-toggle.active { + background: #FFC107; + border-color: #e0a800; + color: #000; + } + + .row-detail-view.full-width { + background: #0d0d1a; + } +} + +/* ========================================================================== + Resizable Table Columns & Rows + ========================================================================== */ + +.resizable-table { + table-layout: fixed; + border-collapse: collapse; +} + +.resizable-header { + position: relative; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.header-text { + display: block; + overflow: hidden; + text-overflow: ellipsis; + padding-right: 8px; +} + +/* Column resize handle */ +.col-resize-handle { + position: absolute; + right: 0; + top: 0; + bottom: 0; + width: 6px; + cursor: col-resize; + background: transparent; + transition: background 0.15s ease; + z-index: 10; +} + +.col-resize-handle:hover, +.col-resize-handle:active { + background: #FFC107; +} + +.resizable-header:hover .col-resize-handle { + background: rgba(255, 193, 7, 0.4); +} + +/* Row resize handle */ +.row-resize-handle { + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 4px; + cursor: row-resize; + background: transparent; + transition: background 0.15s ease; +} + +.row-resize-handle:hover, +.row-resize-handle:active { + background: #FFC107; +} + +.row-expand-cell { + position: relative; +} + +.row-expand-cell:hover .row-resize-handle { + background: rgba(255, 193, 7, 0.3); +} + +/* Cell content wrapper for proper overflow */ +.cell-content { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-height: 100%; +} + +/* Data table wrapper - horizontal scroll */ +.data-table-wrapper { + overflow-x: auto; + overflow-y: auto; + max-height: 65vh; + border: 1px solid #e0e0e0; + border-radius: 8px; +} + +/* Resizable table cells */ +.resizable-table td { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 0; /* Forces text-overflow to work with table-layout: fixed */ +} + +/* Sticky header */ +.resizable-table thead { + position: sticky; + top: 0; + z-index: 5; +} + +.resizable-table th { + background: #f8f9fa; + border-bottom: 2px solid #e0e0e0; +} + +/* Visual feedback during resize */ +body.resizing-column, +body.resizing-column * { + cursor: col-resize !important; +} + +body.resizing-row, +body.resizing-row * { + cursor: row-resize !important; +} + +/* Improved row hover */ +.resizable-table tbody tr { + transition: background 0.1s ease; +} + +/* Column width indicator on hover */ +.resizable-header::after { + content: ''; + position: absolute; + right: 0; + top: 0; + bottom: 0; + width: 1px; + background: #e0e0e0; +} + +/* Dark mode adjustments */ +@media (prefers-color-scheme: dark) { + .data-table-wrapper { + border-color: #3d3d5c; + } + + .resizable-table th { + background: #16162a; + border-bottom-color: #3d3d5c; + } + + .resizable-header::after { + background: #3d3d5c; + } + + .col-resize-handle:hover, + .col-resize-handle:active { + background: #FFC107; + } + + .resizable-header:hover .col-resize-handle { + background: rgba(255, 193, 7, 0.4); + } + + .row-expand-cell:hover .row-resize-handle { + background: rgba(255, 193, 7, 0.3); + } +} + +/* ============================================ + RESIZABLE NESTED TABLES + Styles for ResizableNestedTable component + ============================================ */ + +/* Wrapper for nested tables with horizontal scroll */ +.nested-table-wrapper { + overflow-x: auto; + overflow-y: visible; + max-width: 100%; + margin: 4px 0; +} + +/* Resizable nested table base styles */ +.nested-table.resizable-nested { + table-layout: fixed; + border-collapse: collapse; + /* Width is set dynamically via inline style based on column widths */ + font-size: 0.85rem; +} + +/* Resizable nested table header */ +.resizable-nested-header { + position: relative; + background: #f0f4f8; + border: 1px solid #ddd; + padding: 6px 16px 6px 8px; + text-align: left; + font-weight: 600; + font-size: 0.8rem; + color: #444; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.nested-header-text { + display: block; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Resize handle for nested tables */ +.col-resize-handle.nested-resize { + position: absolute; + right: 0; + top: 0; + bottom: 0; + width: 6px; + cursor: col-resize; + background: transparent; + transition: background 0.15s ease; + z-index: 10; +} + +.col-resize-handle.nested-resize:hover { + background: rgba(255, 193, 7, 0.5); +} + +.col-resize-handle.nested-resize:active { + background: #FFC107; +} + +/* Nested table cells */ +.nested-table.resizable-nested td { + border: 1px solid #e0e0e0; + padding: 4px 8px; + vertical-align: top; + background: #fff; + overflow: hidden; +} + +.nested-table.resizable-nested th { + overflow: hidden; +} + +.nested-cell-content { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; + display: block; +} + +/* Key-value nested tables */ +.nested-table.kv-table.resizable-nested .kv-key { + position: relative; + background: #f5f5f5; + font-weight: 600; + color: #555; + padding-right: 16px; + overflow: hidden; + /* min-width is controlled via inline style, not here */ +} + +.nested-table.kv-table.resizable-nested .kv-value { + position: relative; + background: #fff; + overflow: hidden; + padding-right: 16px; /* Space for resize handle */ +} + +.nested-table.kv-table.resizable-nested .kv-key-text { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.nested-table.kv-table.resizable-nested .kv-value { + background: #fff; + overflow: hidden; +} + +/* Row hover effect for nested tables */ +.nested-table.resizable-nested tbody tr:hover td { + background: #f8f9fa; +} + +.nested-table.resizable-nested tbody tr:hover .kv-key { + background: #eef2f5; +} + +/* Visual indicator for resizable columns */ +.resizable-nested-header::after { + content: ''; + position: absolute; + right: 6px; + top: 50%; + transform: translateY(-50%); + width: 2px; + height: 60%; + background: linear-gradient(180deg, transparent 0%, #ccc 50%, transparent 100%); + opacity: 0; + transition: opacity 0.2s ease; +} + +.resizable-nested-header:hover::after { + opacity: 1; +} + +/* Dark mode for resizable nested tables */ +@media (prefers-color-scheme: dark) { + .nested-table-wrapper { + border-color: #3d3d5c; + } + + .resizable-nested-header { + background: #1e1e3f; + border-color: #3d3d5c; + color: #e0e0e0; + } + + .nested-table.resizable-nested td { + border-color: #3d3d5c; + background: #16162a; + color: #e0e0e0; + } + + .nested-table.kv-table.resizable-nested .kv-key { + background: #1e1e3f; + color: #b0b0c0; + } + + .nested-table.kv-table.resizable-nested .kv-value { + background: #16162a; + } + + .nested-table.resizable-nested tbody tr:hover td { + background: #1e1e3f; + } + + .nested-table.resizable-nested tbody tr:hover .kv-key { + background: #252550; + } + + .col-resize-handle.nested-resize:hover { + background: rgba(255, 193, 7, 0.4); + } + + .col-resize-handle.nested-resize:active { + background: #FFC107; + } + + .resizable-nested-header::after { + background: linear-gradient(180deg, transparent 0%, #555 50%, transparent 100%); + } +} diff --git a/frontend/src/pages/Database.tsx b/frontend/src/pages/Database.tsx index 259672c53c..55c41727a8 100644 --- a/frontend/src/pages/Database.tsx +++ b/frontend/src/pages/Database.tsx @@ -8,7 +8,6 @@ import { useState } from 'react'; import { useLanguage } from '../contexts/LanguageContext'; -import { DuckDBPanel } from '../components/database/DuckDBPanel'; import { DuckLakePanel } from '../components/database/DuckLakePanel'; import { PostgreSQLPanel } from '../components/database/PostgreSQLPanel'; import { TypeDBPanel } from '../components/database/TypeDBPanel'; @@ -52,7 +51,7 @@ const DATABASES: DatabaseInfo[] = [ nl: 'Lakehouse met tijdreizen, ACID-transacties en schema-evolutie', en: 'Lakehouse with time travel, ACID transactions, and schema evolution', }, - icon: '🏔️', + icon: '🦆', color: '#FFC107', }, { @@ -105,7 +104,7 @@ const TEXT = { loading: { nl: 'Laden...', en: 'Loading...' }, // Quick comparison - quickComparison: { nl: 'Snelle vergelijking', en: 'Quick Comparison' }, + quickComparison: { nl: 'Gegevensbanken', en: 'Databases' }, database: { nl: 'Database', en: 'Database' }, status: { nl: 'Status', en: 'Status' }, dataCount: { nl: 'Data', en: 'Data' }, @@ -152,71 +151,79 @@ export function Database() {

{t('pageSubtitle')}

- {/* Database Type Tabs */} - - {/* Content based on active tab */}
- {activeDatabase === 'all' && } - {activeDatabase === 'duckdb' && } - {activeDatabase === 'postgres' && } - {activeDatabase === 'typedb' && } - {activeDatabase === 'oxigraph' && } + {activeDatabase === 'all' && } + {activeDatabase === 'duckdb' && ( + <> + + + + )} + {activeDatabase === 'postgres' && ( + <> + + + + )} + {activeDatabase === 'typedb' && ( + <> + + + + )} + {activeDatabase === 'oxigraph' && ( + <> + + + + )}
- {/* Info Section */} -
-

{t('databaseTypes')}

-
-
- 🏔️ -
- DuckLake -

{t('ducklakeDescription')}

+ {/* Info Section - only show on overview */} + {activeDatabase === 'all' && ( +
+

{t('databaseTypes')}

+
+
+ 🦆 +
+ DuckLake +

{t('ducklakeDescription')}

+
+
+
+ 🐘 +
+ PostgreSQL +

{t('relationalDescription')}

+
+
+
+ 🧠 +
+ TypeDB +

{t('graphDescription')}

+
+
+
+ 🔗 +
+ Oxigraph +

{t('rdfDescription')}

+
-
- 🐘 -
- PostgreSQL -

{t('relationalDescription')}

-
-
-
- 🧠 -
- TypeDB -

{t('graphDescription')}

-
-
-
- 🔗 -
- Oxigraph -

{t('rdfDescription')}

-
-
-
-
+ + )}
); } @@ -224,7 +231,7 @@ export function Database() { /** * All Databases Overview View */ -function AllDatabasesView({ language }: { language: 'nl' | 'en' }) { +function AllDatabasesView({ language, onSelectDatabase }: { language: 'nl' | 'en'; onSelectDatabase: (db: DatabaseType) => void }) { const t = (key: keyof typeof TEXT) => TEXT[key][language]; // Initialize all database hooks @@ -295,7 +302,7 @@ function AllDatabasesView({ language }: { language: 'nl' | 'en' }) { return (
- {/* Quick Comparison Grid */} + {/* Database Cards Grid */}

{t('quickComparison')}

@@ -305,26 +312,11 @@ function AllDatabasesView({ language }: { language: 'nl' | 'en' }) { db={db} language={language} stats={dbStats[db.id]} + onClick={() => onSelectDatabase(db.id)} /> ))}
- - {/* Compact panels for each database */} -
-
- -
-
- -
-
- -
-
- -
-
); } @@ -336,10 +328,12 @@ function DatabaseStatusCard({ db, language, stats, + onClick, }: { db: DatabaseInfo; language: 'nl' | 'en'; stats: DatabaseStats; + onClick: () => void; }) { // Format large numbers const formatNumber = (n: number) => { @@ -350,8 +344,12 @@ function DatabaseStatusCard({ return (
e.key === 'Enter' && onClick()} >
{db.icon}