feat: add SQLite database browser in FileNav
- New SqliteView component with table tabs, paginated data view (100 rows/page), SQL query editor (Cmd+Enter), NULL/BLOB formatting, sticky column headers, and dark mode - Supports .db, .sqlite, .sqlite3, .db3 extensions - Uses sql.js WASM served locally from /sql.js/sql-wasm.wasm - Also fixes display_file handling when another file is already open
This commit is contained in:
Generated
+7
@@ -93,6 +93,7 @@
|
|||||||
"shiki": "^4.0.1",
|
"shiki": "^4.0.1",
|
||||||
"socket.io-client": "^4.2.0",
|
"socket.io-client": "^4.2.0",
|
||||||
"sortablejs": "^1.15.6",
|
"sortablejs": "^1.15.6",
|
||||||
|
"sql.js": "^1.14.1",
|
||||||
"svelte-sonner": "^0.3.19",
|
"svelte-sonner": "^0.3.19",
|
||||||
"tippy.js": "^6.3.7",
|
"tippy.js": "^6.3.7",
|
||||||
"turndown": "^7.2.0",
|
"turndown": "^7.2.0",
|
||||||
@@ -12363,6 +12364,12 @@
|
|||||||
"integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
|
"integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/sql.js": {
|
||||||
|
"version": "1.14.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.1.tgz",
|
||||||
|
"integrity": "sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ssf": {
|
"node_modules/ssf": {
|
||||||
"version": "0.11.2",
|
"version": "0.11.2",
|
||||||
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
||||||
|
|||||||
@@ -137,6 +137,7 @@
|
|||||||
"shiki": "^4.0.1",
|
"shiki": "^4.0.1",
|
||||||
"socket.io-client": "^4.2.0",
|
"socket.io-client": "^4.2.0",
|
||||||
"sortablejs": "^1.15.6",
|
"sortablejs": "^1.15.6",
|
||||||
|
"sql.js": "^1.14.1",
|
||||||
"svelte-sonner": "^0.3.19",
|
"svelte-sonner": "^0.3.19",
|
||||||
"tippy.js": "^6.3.7",
|
"tippy.js": "^6.3.7",
|
||||||
"turndown": "^7.2.0",
|
"turndown": "^7.2.0",
|
||||||
|
|||||||
@@ -94,6 +94,7 @@
|
|||||||
let fileVideoUrl: string | null = null;
|
let fileVideoUrl: string | null = null;
|
||||||
let fileAudioUrl: string | null = null;
|
let fileAudioUrl: string | null = null;
|
||||||
let filePdfData: ArrayBuffer | null = null;
|
let filePdfData: ArrayBuffer | null = null;
|
||||||
|
let fileSqliteData: ArrayBuffer | null = null;
|
||||||
let fileLoading = false;
|
let fileLoading = false;
|
||||||
let filePreviewRef: FilePreview;
|
let filePreviewRef: FilePreview;
|
||||||
|
|
||||||
@@ -188,9 +189,11 @@
|
|||||||
const IMAGE_EXTS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'ico', 'avif']);
|
const IMAGE_EXTS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'ico', 'avif']);
|
||||||
const VIDEO_EXTS = new Set(['mp4', 'webm', 'mov', 'ogv', 'avi', 'mkv']);
|
const VIDEO_EXTS = new Set(['mp4', 'webm', 'mov', 'ogv', 'avi', 'mkv']);
|
||||||
const AUDIO_EXTS = new Set(['mp3', 'wav', 'ogg', 'oga', 'flac', 'm4a', 'aac', 'wma', 'opus']);
|
const AUDIO_EXTS = new Set(['mp3', 'wav', 'ogg', 'oga', 'flac', 'm4a', 'aac', 'wma', 'opus']);
|
||||||
|
const SQLITE_EXTS = new Set(['db', 'sqlite', 'sqlite3', 'db3']);
|
||||||
const isImage = (path: string) => IMAGE_EXTS.has(path.split('.').pop()?.toLowerCase() ?? '');
|
const isImage = (path: string) => IMAGE_EXTS.has(path.split('.').pop()?.toLowerCase() ?? '');
|
||||||
const isVideo = (path: string) => VIDEO_EXTS.has(path.split('.').pop()?.toLowerCase() ?? '');
|
const isVideo = (path: string) => VIDEO_EXTS.has(path.split('.').pop()?.toLowerCase() ?? '');
|
||||||
const isAudio = (path: string) => AUDIO_EXTS.has(path.split('.').pop()?.toLowerCase() ?? '');
|
const isAudio = (path: string) => AUDIO_EXTS.has(path.split('.').pop()?.toLowerCase() ?? '');
|
||||||
|
const isSqlite = (path: string) => SQLITE_EXTS.has(path.split('.').pop()?.toLowerCase() ?? '');
|
||||||
const isPdf = (path: string) => path.split('.').pop()?.toLowerCase() === 'pdf';
|
const isPdf = (path: string) => path.split('.').pop()?.toLowerCase() === 'pdf';
|
||||||
const isOffice = (path: string) => OFFICE_EXTS.has(path.split('.').pop()?.toLowerCase() ?? '');
|
const isOffice = (path: string) => OFFICE_EXTS.has(path.split('.').pop()?.toLowerCase() ?? '');
|
||||||
|
|
||||||
@@ -224,6 +227,7 @@
|
|||||||
fileAudioUrl = null;
|
fileAudioUrl = null;
|
||||||
}
|
}
|
||||||
filePdfData = null;
|
filePdfData = null;
|
||||||
|
fileSqliteData = null;
|
||||||
fileOfficeHtml = null;
|
fileOfficeHtml = null;
|
||||||
fileOfficeSlides = null;
|
fileOfficeSlides = null;
|
||||||
currentSlide = 0;
|
currentSlide = 0;
|
||||||
@@ -288,6 +292,9 @@
|
|||||||
} else if (isPdf(filePath)) {
|
} else if (isPdf(filePath)) {
|
||||||
const result = await downloadFileBlob(terminal.url, terminal.key, filePath);
|
const result = await downloadFileBlob(terminal.url, terminal.key, filePath);
|
||||||
if (result) filePdfData = await result.blob.arrayBuffer();
|
if (result) filePdfData = await result.blob.arrayBuffer();
|
||||||
|
} else if (isSqlite(filePath)) {
|
||||||
|
const result = await downloadFileBlob(terminal.url, terminal.key, filePath);
|
||||||
|
if (result) fileSqliteData = await result.blob.arrayBuffer();
|
||||||
} else if (isOffice(filePath)) {
|
} else if (isOffice(filePath)) {
|
||||||
const result = await downloadFileBlob(terminal.url, terminal.key, filePath);
|
const result = await downloadFileBlob(terminal.url, terminal.key, filePath);
|
||||||
if (result) {
|
if (result) {
|
||||||
@@ -794,6 +801,7 @@
|
|||||||
{fileVideoUrl}
|
{fileVideoUrl}
|
||||||
{fileAudioUrl}
|
{fileAudioUrl}
|
||||||
{filePdfData}
|
{filePdfData}
|
||||||
|
{fileSqliteData}
|
||||||
{fileContent}
|
{fileContent}
|
||||||
{fileOfficeHtml}
|
{fileOfficeHtml}
|
||||||
{fileOfficeSlides}
|
{fileOfficeSlides}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import PDFViewer from '../../common/PDFViewer.svelte';
|
import PDFViewer from '../../common/PDFViewer.svelte';
|
||||||
import JsonTreeView from './JsonTreeView.svelte';
|
import JsonTreeView from './JsonTreeView.svelte';
|
||||||
import NotebookView from './NotebookView.svelte';
|
import NotebookView from './NotebookView.svelte';
|
||||||
|
import SqliteView from './SqliteView.svelte';
|
||||||
|
|
||||||
let pdfViewerRef: PDFViewer;
|
let pdfViewerRef: PDFViewer;
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@
|
|||||||
export let fileVideoUrl: string | null = null;
|
export let fileVideoUrl: string | null = null;
|
||||||
export let fileAudioUrl: string | null = null;
|
export let fileAudioUrl: string | null = null;
|
||||||
export let filePdfData: ArrayBuffer | null = null;
|
export let filePdfData: ArrayBuffer | null = null;
|
||||||
|
export let fileSqliteData: ArrayBuffer | null = null;
|
||||||
export let fileContent: string | null = null;
|
export let fileContent: string | null = null;
|
||||||
|
|
||||||
// Office preview props
|
// Office preview props
|
||||||
@@ -268,6 +270,8 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else if filePdfData !== null}
|
{:else if filePdfData !== null}
|
||||||
<PDFViewer bind:this={pdfViewerRef} data={filePdfData} className="w-full h-full" />
|
<PDFViewer bind:this={pdfViewerRef} data={filePdfData} className="w-full h-full" />
|
||||||
|
{:else if fileSqliteData !== null}
|
||||||
|
<SqliteView data={fileSqliteData} />
|
||||||
{:else if fileOfficeHtml !== null}
|
{:else if fileOfficeHtml !== null}
|
||||||
<div class="flex flex-col h-full">
|
<div class="flex flex-col h-full">
|
||||||
<div class="office-preview overflow-auto flex-1 min-h-0">
|
<div class="office-preview overflow-auto flex-1 min-h-0">
|
||||||
|
|||||||
@@ -0,0 +1,330 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { getContext, onDestroy } from 'svelte';
|
||||||
|
import Spinner from '../../common/Spinner.svelte';
|
||||||
|
|
||||||
|
const i18n = getContext('i18n');
|
||||||
|
|
||||||
|
export let data: ArrayBuffer;
|
||||||
|
|
||||||
|
let db: any = null;
|
||||||
|
let loading = true;
|
||||||
|
let error: string | null = null;
|
||||||
|
|
||||||
|
let tables: string[] = [];
|
||||||
|
let selectedTable = '';
|
||||||
|
let columns: string[] = [];
|
||||||
|
let rows: string[][] = [];
|
||||||
|
let totalRows = 0;
|
||||||
|
let page = 0;
|
||||||
|
const pageSize = 100;
|
||||||
|
|
||||||
|
// Custom query
|
||||||
|
let queryMode = false;
|
||||||
|
let queryText = '';
|
||||||
|
let queryColumns: string[] = [];
|
||||||
|
let queryRows: string[][] = [];
|
||||||
|
let queryError: string | null = null;
|
||||||
|
|
||||||
|
const init = async () => {
|
||||||
|
try {
|
||||||
|
const initSqlJs = (await import('sql.js')).default;
|
||||||
|
const SQL = await initSqlJs({
|
||||||
|
locateFile: () => '/sql.js/sql-wasm.wasm'
|
||||||
|
});
|
||||||
|
db = new SQL.Database(new Uint8Array(data));
|
||||||
|
|
||||||
|
// Get table list
|
||||||
|
const result = db.exec(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
|
||||||
|
);
|
||||||
|
if (result.length > 0) {
|
||||||
|
tables = result[0].values.map((r: string[]) => r[0]);
|
||||||
|
if (tables.length > 0) {
|
||||||
|
selectTable(tables[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loading = false;
|
||||||
|
} catch (e) {
|
||||||
|
loading = false;
|
||||||
|
error = e instanceof Error ? e.message : 'Failed to open database';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectTable = (table: string) => {
|
||||||
|
if (!db) return;
|
||||||
|
selectedTable = table;
|
||||||
|
page = 0;
|
||||||
|
queryMode = false;
|
||||||
|
loadPage();
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadPage = () => {
|
||||||
|
if (!db || !selectedTable) return;
|
||||||
|
try {
|
||||||
|
// Get columns
|
||||||
|
const info = db.exec(`PRAGMA table_info("${selectedTable}")`);
|
||||||
|
if (info.length > 0) {
|
||||||
|
columns = info[0].values.map((r: any[]) => r[1] as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get total count
|
||||||
|
const countResult = db.exec(`SELECT COUNT(*) FROM "${selectedTable}"`);
|
||||||
|
if (countResult.length > 0) {
|
||||||
|
totalRows = countResult[0].values[0][0] as number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get page data
|
||||||
|
const offset = page * pageSize;
|
||||||
|
const result = db.exec(
|
||||||
|
`SELECT * FROM "${selectedTable}" LIMIT ${pageSize} OFFSET ${offset}`
|
||||||
|
);
|
||||||
|
if (result.length > 0) {
|
||||||
|
rows = result[0].values.map((r: any[]) => r.map((v: any) => formatValue(v)));
|
||||||
|
} else {
|
||||||
|
rows = [];
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Query failed';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runQuery = () => {
|
||||||
|
if (!db || !queryText.trim()) return;
|
||||||
|
queryError = null;
|
||||||
|
try {
|
||||||
|
const result = db.exec(queryText);
|
||||||
|
if (result.length > 0) {
|
||||||
|
queryColumns = result[0].columns;
|
||||||
|
queryRows = result[0].values.map((r: any[]) => r.map((v: any) => formatValue(v)));
|
||||||
|
} else {
|
||||||
|
queryColumns = [];
|
||||||
|
queryRows = [];
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
queryError = e instanceof Error ? e.message : 'Query failed';
|
||||||
|
queryColumns = [];
|
||||||
|
queryRows = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatValue = (v: any): string => {
|
||||||
|
if (v === null) return 'NULL';
|
||||||
|
if (v instanceof Uint8Array) return `[BLOB ${v.length}B]`;
|
||||||
|
return String(v);
|
||||||
|
};
|
||||||
|
|
||||||
|
$: totalPages = Math.ceil(totalRows / pageSize);
|
||||||
|
|
||||||
|
$: data && init();
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (db) {
|
||||||
|
try { db.close(); } catch {}
|
||||||
|
db = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="sqlite-view flex flex-col h-full">
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex items-center justify-center h-full"><Spinner className="size-4" /></div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="p-3 text-xs text-red-500">{error}</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Table tabs + query toggle -->
|
||||||
|
<div class="flex items-center gap-1 px-2 py-1.5 border-b border-gray-100 dark:border-gray-800 overflow-x-auto scrollbar-none shrink-0">
|
||||||
|
{#each tables as table}
|
||||||
|
<button
|
||||||
|
class="shrink-0 px-2 py-1 text-xs rounded transition
|
||||||
|
{table === selectedTable && !queryMode
|
||||||
|
? 'bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300 font-medium'
|
||||||
|
: 'text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800'}"
|
||||||
|
on:click={() => selectTable(table)}
|
||||||
|
>
|
||||||
|
{table}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
<button
|
||||||
|
class="shrink-0 px-2 py-1 text-xs rounded transition
|
||||||
|
{queryMode
|
||||||
|
? 'bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300 font-medium'
|
||||||
|
: 'text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800'}"
|
||||||
|
on:click={() => { queryMode = !queryMode; }}
|
||||||
|
>
|
||||||
|
SQL
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if queryMode}
|
||||||
|
<!-- Query editor -->
|
||||||
|
<div class="p-2 border-b border-gray-100 dark:border-gray-800 shrink-0">
|
||||||
|
<div class="flex gap-1.5">
|
||||||
|
<textarea
|
||||||
|
bind:value={queryText}
|
||||||
|
placeholder="SELECT * FROM ..."
|
||||||
|
class="flex-1 text-xs font-mono bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded px-2 py-1.5 outline-none resize-none min-h-[2.5rem]"
|
||||||
|
rows="2"
|
||||||
|
on:keydown={(e) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
runQuery();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
></textarea>
|
||||||
|
<button
|
||||||
|
class="shrink-0 px-3 py-1 text-xs bg-blue-500 hover:bg-blue-600 text-white rounded transition"
|
||||||
|
on:click={runQuery}
|
||||||
|
>
|
||||||
|
{$i18n.t('Run')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if queryError}
|
||||||
|
<div class="mt-1 text-xs text-red-500">{queryError}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if queryColumns.length > 0}
|
||||||
|
<div class="flex-1 overflow-auto min-h-0">
|
||||||
|
<table class="sqlite-table w-full text-xs font-mono border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="sqlite-row-num">#</th>
|
||||||
|
{#each queryColumns as col}
|
||||||
|
<th>{col}</th>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each queryRows as row, i}
|
||||||
|
<tr>
|
||||||
|
<td class="sqlite-row-num">{i + 1}</td>
|
||||||
|
{#each row as cell}
|
||||||
|
<td class:sqlite-null={cell === 'NULL'}>{cell}</td>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<!-- Table data -->
|
||||||
|
<div class="flex-1 overflow-auto min-h-0">
|
||||||
|
{#if columns.length > 0}
|
||||||
|
<table class="sqlite-table w-full text-xs font-mono border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="sqlite-row-num">#</th>
|
||||||
|
{#each columns as col}
|
||||||
|
<th>{col}</th>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each rows as row, i}
|
||||||
|
<tr>
|
||||||
|
<td class="sqlite-row-num">{page * pageSize + i + 1}</td>
|
||||||
|
{#each row as cell}
|
||||||
|
<td class:sqlite-null={cell === 'NULL'}>{cell}</td>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{:else}
|
||||||
|
<div class="text-xs text-gray-400 text-center pt-6">{$i18n.t('No data')}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
{#if totalPages > 1}
|
||||||
|
<div class="flex items-center justify-center gap-3 py-1.5 px-3 border-t border-gray-100 dark:border-gray-800 text-xs text-gray-500 shrink-0">
|
||||||
|
<button
|
||||||
|
class="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 disabled:opacity-30"
|
||||||
|
disabled={page === 0}
|
||||||
|
on:click={() => { page--; loadPage(); }}
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-4">
|
||||||
|
<path fill-rule="evenodd" d="M11.78 5.22a.75.75 0 0 1 0 1.06L8.06 10l3.72 3.72a.75.75 0 1 1-1.06 1.06l-4.25-4.25a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0Z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<span>{page + 1} / {totalPages} ({totalRows.toLocaleString()} rows)</span>
|
||||||
|
<button
|
||||||
|
class="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 disabled:opacity-30"
|
||||||
|
disabled={page >= totalPages - 1}
|
||||||
|
on:click={() => { page++; loadPage(); }}
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-4">
|
||||||
|
<path fill-rule="evenodd" d="M8.22 5.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06-1.06L11.94 10 8.22 6.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.sqlite-table {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.sqlite-table th,
|
||||||
|
.sqlite-table td {
|
||||||
|
padding: 4px 8px;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 1px solid rgba(128, 128, 128, 0.15);
|
||||||
|
}
|
||||||
|
.sqlite-table thead th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: rgba(243, 244, 246, 0.95);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
border-bottom: 2px solid rgba(128, 128, 128, 0.25);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
:global(.dark) .sqlite-table thead th {
|
||||||
|
background: rgba(31, 41, 55, 0.95);
|
||||||
|
color: #d1d5db;
|
||||||
|
}
|
||||||
|
.sqlite-table tbody tr:nth-child(even) {
|
||||||
|
background: rgba(128, 128, 128, 0.04);
|
||||||
|
}
|
||||||
|
.sqlite-table tbody tr:hover {
|
||||||
|
background: rgba(59, 130, 246, 0.06);
|
||||||
|
}
|
||||||
|
:global(.dark) .sqlite-table tbody tr:hover {
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
}
|
||||||
|
.sqlite-table td {
|
||||||
|
color: #374151;
|
||||||
|
max-width: 300px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
:global(.dark) .sqlite-table td {
|
||||||
|
color: #d1d5db;
|
||||||
|
}
|
||||||
|
.sqlite-row-num {
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
text-align: right !important;
|
||||||
|
user-select: none;
|
||||||
|
width: 1px;
|
||||||
|
padding-right: 6px !important;
|
||||||
|
}
|
||||||
|
:global(.dark) .sqlite-row-num {
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
.sqlite-null {
|
||||||
|
color: #9ca3af !important;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
:global(.dark) .sqlite-null {
|
||||||
|
color: #6b7280 !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Executable
BIN
Binary file not shown.
Reference in New Issue
Block a user