feat: DashboardDataGrid migration + FileList 7-feature rewrite + validation API

DashboardDataGrid:
- Add server-side pagination (serverTotal, serverTotalPages)
- Add hideFilter, bulkActions (renamed from children)
- Add header/rowCell snippet slots
- Support {#key} for forced re-render

/dashboards migration:
- Replace inline CSS Grid (1460px) with DashboardDataGrid
- Header snippet for sort buttons + ColumnFilterPopover
- Render functions with raw:true for complex cells
- Selection bridge (array <-> Set) for checkboxes
- Server-side pagination via model bridge
- Maintenance badge mounting via $effect + tick
- Validation dots via {#key localValidationVersion}

Fixes:
- getFilterOptions: pass column parameter (was hardcoded 'title')
- pageSize bridge: pass Event-like object (was number)
- getValidationStatusBatch: stub -> real fetchApi endpoint
- Breadcrumbs: nav.dashboard -> nav.dashboards
- Uncaught (in promise): add .catch() to async calls

Backend:
- New GET /status/batch endpoint for validation batch query

FileList rewrite (7 features):
1. Headers: text-sm font-semibold (was text-xs uppercase)
2. Column sorting (Name, Category, Size, Date)
3. Breadcrumbs with navigate-up button
4. Loading skeleton (animated rows)
5. Client-side pagination (20 per page)
6. Multi-select + bulk delete/download
7. Search by name/category

QA: build passes, 131 dashboard tests pass, 0 console errors
Pre-existing: 3 test failures unrelated (provider_config, api stub)
This commit is contained in:
2026-06-18 12:53:03 +03:00
parent 8aa5c57b60
commit 8016c07ebb
10 changed files with 616 additions and 232 deletions

View File

@@ -121,7 +121,7 @@ async def create_task(
extra={"src": "validation_tasks_routes"}, extra={"src": "validation_tasks_routes"},
) )
try: try:
result = service.create_task(payload) result = await service.create_task(payload)
scheduler.reload_validation_policy(result.id) scheduler.reload_validation_policy(result.id)
return result return result
except ValueError as e: except ValueError as e:
@@ -203,7 +203,7 @@ async def update_task(
extra={"src": "validation_tasks_routes"}, extra={"src": "validation_tasks_routes"},
) )
try: try:
result = service.update_task(policy_id, payload) result = await service.update_task(policy_id, payload)
scheduler.reload_validation_policy(result.id) scheduler.reload_validation_policy(result.id)
return result return result
except ValueError as e: except ValueError as e:
@@ -259,7 +259,7 @@ async def toggle_task_status(
extra={"src": "validation_tasks_routes"}, extra={"src": "validation_tasks_routes"},
) )
try: try:
result = service.update_task(policy_id, ValidationTaskUpdate(is_active=is_active)) result = await service.update_task(policy_id, ValidationTaskUpdate(is_active=is_active))
if not is_active: if not is_active:
scheduler.remove_validation_job(policy_id) scheduler.remove_validation_job(policy_id)
else: else:
@@ -327,7 +327,7 @@ async def list_all_runs(
extra={"src": "validation_tasks_routes"}, extra={"src": "validation_tasks_routes"},
) )
try: try:
total, raw_items = service.list_all_runs( total, raw_items = await service.list_all_runs(
status=status_filter, environment_id=environment_id, dashboard_id=dashboard_id, status=status_filter, environment_id=environment_id, dashboard_id=dashboard_id,
page=page, page_size=page_size, page=page, page_size=page_size,
) )
@@ -382,7 +382,7 @@ async def get_run_detail(
extra={"src": "validation_tasks_routes"}, extra={"src": "validation_tasks_routes"},
) )
try: try:
result = service.get_run_detail(run_id) result = await service.get_run_detail(run_id)
return RunDetailResponse( return RunDetailResponse(
run=_dict_to_run_response(result["run"]), run=_dict_to_run_response(result["run"]),
records=result.get("records", []), records=result.get("records", []),
@@ -490,4 +490,70 @@ async def legacy_llm_report_redirect(
# #endregion legacy_llm_report_redirect # #endregion legacy_llm_report_redirect
# #region get_validation_status_batch [C:3] [TYPE Function] [SEMANTICS validation,api,status,batch]
# @BRIEF Batch-fetch latest validation status for multiple dashboard IDs.
# @LAYER API
# @RELATION DEPENDS_ON -> [ValidationRecord]
# @DATA_CONTRACT params -> env_id: str, dashboard_ids: str (comma-separated)
# @DATA_CONTRACT response -> { [dashboard_id]: { status: str, run_id: str | None, task_name: str | None, last_run_at: str | None, history: list } }
@router.get("/status/batch")
async def get_validation_status_batch(
env_id: str = Query(...),
dashboard_ids: str = Query(...),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return latest validation status for each requested dashboard ID."""
from ...models.llm import ValidationRecord, ValidationRun
ids = [id.strip() for id in dashboard_ids.split(",") if id.strip()]
if not ids:
return {}
# Get latest record per dashboard for this environment
from sqlalchemy import and_, desc
records = (
db.query(ValidationRecord)
.filter(
and_(
ValidationRecord.dashboard_id.in_(ids),
ValidationRecord.environment_id == env_id,
)
)
.order_by(desc(ValidationRecord.timestamp))
.all()
)
# Get run info for task names
run_ids = list({r.run_id for r in records if r.run_id})
runs = {}
if run_ids:
run_objs = db.query(ValidationRun).filter(ValidationRun.id.in_(run_ids)).all()
runs = {r.id: r for r in run_objs}
result: dict[str, dict] = {}
for rec in records:
if rec.dashboard_id in result:
# Append to history if already have latest
result[rec.dashboard_id]["history"].append({
"status": rec.status,
"last_run_at": rec.timestamp.isoformat() if rec.timestamp else None,
"task_name": runs.get(rec.run_id, {}).task_id if rec.run_id else None,
"run_id": rec.run_id,
})
else:
# First (latest) record sets the primary status
run = runs.get(rec.run_id)
result[rec.dashboard_id] = {
"status": rec.status,
"last_run_at": rec.timestamp.isoformat() if rec.timestamp else None,
"task_name": rec.task_id or (run.task_id if run else None),
"run_id": rec.run_id,
"history": [],
}
return result
# #endregion get_validation_status_batch
# #endregion ValidationTasksRoutes # #endregion ValidationTasksRoutes

View File

@@ -1034,7 +1034,8 @@ export const api = {
// @RELATION DEPENDS_ON -> [fetchApi] // @RELATION DEPENDS_ON -> [fetchApi]
// @DATA_CONTRACT params -> { env_id, dashboard_ids: comma-separated string } // @DATA_CONTRACT params -> { env_id, dashboard_ids: comma-separated string }
// @DATA_CONTRACT response -> { [dashboard_id]: { status: PASS|FAIL|WARN, last_run_at?, task_name?, run_id?, history?: { status, last_run_at, task_name }[] } } // @DATA_CONTRACT response -> { [dashboard_id]: { status: PASS|FAIL|WARN, last_run_at?, task_name?, run_id?, history?: { status, last_run_at, task_name }[] } }
getValidationStatusBatch: async <T = unknown>(): Promise<T> => ({ results: [] } as T), getValidationStatusBatch: <T = unknown>(envId: string, dashboardIds: string) =>
fetchApi<T>(`/validation-tasks/status/batch?env_id=${encodeURIComponent(envId)}&dashboard_ids=${encodeURIComponent(dashboardIds)}`),
// #endregion getValidationStatusBatch // #endregion getValidationStatusBatch
// ═══ API Keys ═════════════════════════════════════════════════ // ═══ API Keys ═════════════════════════════════════════════════

View File

@@ -1,11 +1,12 @@
<!-- #region Dashboard.DataGrid [C:3] [TYPE Component] [SEMANTICS dashboard,grid,selection,sort,filter,pagination] --> <!-- #region Dashboard.DataGrid [C:3] [TYPE Component] [SEMANTICS dashboard,grid,selection,sort,filter,pagination,server-side] -->
<!-- @ingroup Dashboard --> <!-- @ingroup Dashboard -->
<!-- @BRIEF Configurable dashboard data grid with optional selection, sorting, filtering, and pagination. --> <!-- @BRIEF Configurable dashboard data grid with optional selection, sorting, filtering, pagination, custom header/cell snippets, and server-side pagination support. -->
<!-- @LAYER UI --> <!-- @LAYER UI -->
<!-- @RATIONALE Replaces DashboardGrid — consolidation of 3 dashboard grids (migration, git, dashboards) into one configurable component. Reduces ~1080 lines of duplicated sort/filter/paginate/select logic to a single component with opt-in features. --> <!-- @RATIONALE Replaces DashboardGrid — consolidation of 3 dashboard grids (migration, git, dashboards) into one configurable component. Reduces ~1080 lines of duplicated sort/filter/paginate/select logic to a single component with opt-in features. Added server-side pagination and snippet slots for dashboards inline grid migration. -->
<!-- @RELATION DEPENDS_ON -> [$lib/ui/Input] --> <!-- @RELATION DEPENDS_ON -> [$lib/ui/Input] -->
<!-- @RELATION DEPENDS_ON -> [$lib/ui/Button] --> <!-- @RELATION DEPENDS_ON -> [$lib/ui/Button] -->
<!-- @RELATION USED_BY -> [frontend/src/routes/migration/+page.svelte] --> <!-- @RELATION USED_BY -> [frontend/src/routes/migration/+page.svelte] -->
<!-- @RELATION USED_BY -> [frontend/src/routes/dashboards/+page.svelte] -->
<!-- @UX_STATE Loading -> Animated skeleton rows --> <!-- @UX_STATE Loading -> Animated skeleton rows -->
<!-- @UX_STATE Empty -> Centered dashed-border message with muted text --> <!-- @UX_STATE Empty -> Centered dashed-border message with muted text -->
<!-- @UX_STATE Table -> Data rows with optional selection, sort headers, filter, pagination --> <!-- @UX_STATE Table -> Data rows with optional selection, sort headers, filter, pagination -->
@@ -21,15 +22,19 @@
<!-- @INVARIANT State via $bindable() — parent MAY bind props for external state ownership --> <!-- @INVARIANT State via $bindable() — parent MAY bind props for external state ownership -->
<!-- @INVARIANT Filter is case-insensitive across all visible columns (using render() if defined, else raw value) --> <!-- @INVARIANT Filter is case-insensitive across all visible columns (using render() if defined, else raw value) -->
<!-- @INVARIANT Sorting operates on item[key] raw value, independent of display rendering --> <!-- @INVARIANT Sorting operates on item[key] raw value, independent of display rendering -->
<!-- @INVARIANT Page resets to 0 on filter change --> <!-- @INVARIANT Page resets to 0 on filter change (client-side mode only) -->
<!-- @INVARIANT In server-side mode (serverTotal provided), data is treated as current page, no client-side slicing -->
<!-- @INVARIANT Custom header/cell snippets receive Column and item — parent owns per-column UI -->
<!-- @PRE data is array of items, columns is array of Column definitions --> <!-- @PRE data is array of items, columns is array of Column definitions -->
<!-- @POST Renders empty state when data is empty, loading skeleton when loading is true --> <!-- @POST Renders empty state when data is empty, loading skeleton when loading is true -->
<!-- @POST Renders checkboxes when selectable, filter input when filterable, pagination when paginated --> <!-- @POST Renders checkboxes when selectable, filter input when filterable, pagination when paginated -->
<!-- @POST Selected items tracked via bind:selectedIds regardless of which page they are on --> <!-- @POST Selected items tracked via bind:selectedIds regardless of which page they are on -->
<!-- @POST With serverTotal, pagination uses server counts; displayData = data directly -->
<!-- @SIDE_EFFECT Modifies selectedIds array via bind: when user checks/unchecks --> <!-- @SIDE_EFFECT Modifies selectedIds array via bind: when user checks/unchecks -->
<!-- @SIDE_EFFECT Modifies page/filterText/sortColumn/sortDirection via bind: when user interacts --> <!-- @SIDE_EFFECT Modifies page/filterText/sortColumn/sortDirection via bind: when user interacts -->
<script lang="ts"> <script lang="ts">
// [SECTION: IMPORTS] // [SECTION: IMPORTS]
import type { Snippet } from 'svelte';
import { t } from '$lib/i18n/index.svelte.js'; import { t } from '$lib/i18n/index.svelte.js';
import { Button, EmptyState, Input } from "$lib/ui"; import { Button, EmptyState, Input } from "$lib/ui";
@@ -68,11 +73,17 @@
filterable = false, filterable = false,
filterText = $bindable("" as string), filterText = $bindable("" as string),
filterPlaceholder = "", filterPlaceholder = "",
/** Hide built-in filter input (parent provides custom search UI) */
hideFilter = false,
// Pagination // Pagination
paginated = false, paginated = false,
page = $bindable(0), page = $bindable(0),
pageSize = 20, pageSize = 20,
/** Server-side total count — when provided, disables client-side slicing */
serverTotal = undefined as number | undefined,
/** Server-side total pages — when provided, used instead of Math.ceil(totalCount/pageSize) */
serverTotalPages = undefined as number | undefined,
// UX // UX
loading = false, loading = false,
@@ -80,7 +91,7 @@
emptyText = "", emptyText = "",
// Snippet for bulk actions bar (shown above table when items are selected) // Snippet for bulk actions bar (shown above table when items are selected)
children = undefined, bulkActions = undefined as Snippet | undefined,
// Per-row action buttons (rendered as a final column) // Per-row action buttons (rendered as a final column)
actions = [] as Array<{ actions = [] as Array<{
@@ -90,23 +101,17 @@
condition?: (item: any) => boolean; condition?: (item: any) => boolean;
disabled?: (item: any) => boolean; disabled?: (item: any) => boolean;
}>, }>,
// Custom header snippet — receives Column, parent renders sort button + filters
header = undefined as Snippet | undefined,
// Custom cell snippet — receives item, parent renders complex cells
rowCell = undefined as Snippet | undefined,
} = $props(); } = $props();
// [SECTION: HELPERS]
function getCellText(item: any, column: Column): string {
if (column.render) return column.render(item);
return String(item[column.key] ?? "");
}
function itemMatchesFilter(item: any): boolean {
if (!filterable || !filterText) return true;
const q = filterText.toLowerCase();
return columns.some(col => {
return getCellText(item, col).toLowerCase().includes(q);
});
}
// [SECTION: DERIVED] // [SECTION: DERIVED]
let isServerMode = $derived(serverTotal !== undefined);
let filteredData = $derived( let filteredData = $derived(
filterable && filterText ? data.filter(itemMatchesFilter) : data, filterable && filterText ? data.filter(itemMatchesFilter) : data,
); );
@@ -125,13 +130,21 @@
}); });
let displayData = $derived( let displayData = $derived(
paginated isServerMode
? sortedData // Server already paginated — use as-is
: paginated
? sortedData.slice(page * pageSize, (page + 1) * pageSize) ? sortedData.slice(page * pageSize, (page + 1) * pageSize)
: sortedData, : sortedData,
); );
let totalCount = $derived(sortedData.length); let totalCount = $derived(isServerMode ? serverTotal! : sortedData.length);
let totalPages = $derived(paginated ? Math.ceil(totalCount / pageSize) : 1); let totalPages = $derived(
isServerMode
? (serverTotalPages ?? Math.ceil(totalCount / pageSize))
: paginated
? Math.ceil(totalCount / pageSize)
: 1,
);
let allSelected = $derived( let allSelected = $derived(
displayData.length > 0 && displayData.every((d: any) => selectedIds.includes(d[keyField])), displayData.length > 0 && displayData.every((d: any) => selectedIds.includes(d[keyField])),
@@ -140,6 +153,20 @@
displayData.some((d: any) => selectedIds.includes(d[keyField])), displayData.some((d: any) => selectedIds.includes(d[keyField])),
); );
// [SECTION: HELPERS]
function getCellText(item: any, column: Column): string {
if (column.render) return column.render(item);
return String(item[column.key] ?? "");
}
function itemMatchesFilter(item: any): boolean {
if (!filterable || !filterText) return true;
const q = filterText.toLowerCase();
return columns.some(col => {
return getCellText(item, col).toLowerCase().includes(q);
});
}
// [SECTION: HANDLERS] // [SECTION: HANDLERS]
function handleSort(key: string) { function handleSort(key: string) {
if (sortColumn === key) { if (sortColumn === key) {
@@ -176,17 +203,17 @@
} }
// [SECTION: REACTIVE] // [SECTION: REACTIVE]
// Reset page to 0 when filter text changes // Reset page to 0 when filter text changes (client-side mode only)
$effect(() => { $effect(() => {
filterText; // track filterText; // track
if (paginated) page = 0; if (paginated && !isServerMode) page = 0;
}); });
</script> </script>
<!-- [SECTION: TEMPLATE] --> <!-- [SECTION: TEMPLATE] -->
<div class="dashboard-data-grid"> <div class="dashboard-data-grid">
<!-- ── Filter Input ── --> <!-- ── Filter Input ── -->
{#if filterable} {#if filterable && !hideFilter}
<div class="mb-4"> <div class="mb-4">
<Input <Input
bind:value={filterText} bind:value={filterText}
@@ -196,16 +223,16 @@
{/if} {/if}
<!-- ── Bulk Actions Bar ── --> <!-- ── Bulk Actions Bar ── -->
{#if selectable && selectedIds.length > 0 && children} {#if selectable && selectedIds.length > 0 && bulkActions}
<div class="mb-4"> <div class="mb-4">
{@render children()} {@render bulkActions()}
</div> </div>
{/if} {/if}
<!-- ── Loading Skeleton ── --> <!-- ── Loading Skeleton ── -->
{#if loading} {#if loading}
<div class="animate-pulse rounded-lg border border-border overflow-hidden"> <div class="animate-pulse rounded-lg border border-border overflow-hidden">
<div class="bg-surface-muted h-10 border-b border-border"></div> <div class="bg-surface-muted h-12 border-b border-border"></div>
{#each Array(loadingRows) as _} {#each Array(loadingRows) as _}
<div class="flex items-center gap-4 px-6 py-4 border-b border-border last:border-b-0"> <div class="flex items-center gap-4 px-6 py-4 border-b border-border last:border-b-0">
{#if selectable} {#if selectable}
@@ -235,7 +262,7 @@
<thead class="bg-surface-muted"> <thead class="bg-surface-muted">
<tr> <tr>
{#if selectable} {#if selectable}
<th class="px-6 py-3 text-left w-12" scope="col"> <th class="px-6 py-4 text-left w-12" scope="col">
<input <input
type="checkbox" type="checkbox"
checked={allSelected} checked={allSelected}
@@ -249,13 +276,16 @@
{#each columns as col (col.key)} {#each columns as col (col.key)}
<th <th
scope="col" scope="col"
class="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider class="px-6 py-4 text-left text-sm font-semibold text-text-muted
{col.sortable ? 'cursor-pointer hover:text-text transition-colors select-none' : ''} {col.sortable && !header ? 'cursor-pointer hover:text-text transition-colors select-none' : ''}
{col.class || ''}" {col.class || ''}"
style={col.width ? `width:${col.width}` : ""} style={col.width ? `width:${col.width}` : ""}
onclick={col.sortable ? () => handleSort(col.key) : undefined} onclick={col.sortable && !header ? () => handleSort(col.key) : undefined}
aria-sort={col.sortable && sortColumn === col.key ? (sortDirection === 'asc' ? 'ascending' : 'descending') : undefined} aria-sort={col.sortable && sortColumn === col.key ? (sortDirection === 'asc' ? 'ascending' : 'descending') : undefined}
> >
{#if header}
{@render header(col)}
{:else}
{col.label} {col.label}
{#if col.sortable} {#if col.sortable}
{#if sortColumn === col.key} {#if sortColumn === col.key}
@@ -264,10 +294,11 @@
<span class="ml-0.5 text-text-subtle opacity-40">⇅</span> <span class="ml-0.5 text-text-subtle opacity-40">⇅</span>
{/if} {/if}
{/if} {/if}
{/if}
</th> </th>
{/each} {/each}
{#if actions.length > 0} {#if actions.length > 0}
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider"> <th scope="col" class="px-6 py-4 text-left text-sm font-semibold text-text-muted">
Actions Actions
</th> </th>
{/if} {/if}
@@ -291,7 +322,9 @@
class="px-6 py-4 whitespace-nowrap text-sm {col.class || ''}" class="px-6 py-4 whitespace-nowrap text-sm {col.class || ''}"
style={col.width ? `width:${col.width}` : ""} style={col.width ? `width:${col.width}` : ""}
> >
{#if col.render} {#if rowCell}
{@render rowCell(item)}
{:else if col.render}
{#if col.raw} {#if col.raw}
{@html col.render(item)} {@html col.render(item)}
{:else} {:else}
@@ -326,7 +359,7 @@
</div> </div>
<!-- ── Pagination Controls ── --> <!-- ── Pagination Controls ── -->
{#if paginated && sortedData.length > 0} {#if paginated && totalCount > 0}
<div class="flex items-center justify-between mt-4"> <div class="flex items-center justify-between mt-4">
<div class="text-sm text-text-muted"> <div class="text-sm text-text-muted">
{($t.dashboard?.showing ?? "") {($t.dashboard?.showing ?? "")

View File

@@ -303,7 +303,7 @@
actions={rowActions} actions={rowActions}
loading={bulkActionRunning} loading={bulkActionRunning}
> >
{#snippet children()} {#snippet bulkActions()}
{#if selectedIds.length > 0} {#if selectedIds.length > 0}
<div class="flex flex-wrap items-center gap-2 rounded-lg border border-primary-ring bg-primary-light/60 px-3 py-2"> <div class="flex flex-wrap items-center gap-2 rounded-lg border border-primary-ring bg-primary-light/60 px-3 py-2">
{#if !repositoriesOnly} {#if !repositoriesOnly}

View File

@@ -252,5 +252,80 @@ describe('DashboardDataGrid', () => {
expect(screen.queryByText('Prev')).toBeNull(); expect(screen.queryByText('Prev')).toBeNull();
expect(screen.queryByText('Next')).toBeNull(); expect(screen.queryByText('Next')).toBeNull();
}); });
// ── Server-side pagination ────────────────────────────────────
it('uses serverTotal for pagination count when provided', () => {
render(DashboardDataGrid, {
data: sampleData.slice(0, 2), // Only 2 items visible
columns: sampleColumns,
paginated: true,
pageSize: 2,
serverTotal: 100, // Server says 100 total
});
// Should show "Showing 1-2 of 100" not "Showing 1-2 of 3"
expect(screen.getByText(/Showing 1-2 of 100/)).toBeDefined();
});
it('uses serverTotalPages for page count when provided', () => {
render(DashboardDataGrid, {
data: sampleData.slice(0, 2),
columns: sampleColumns,
paginated: true,
pageSize: 2,
serverTotal: 100,
serverTotalPages: 50,
});
// Next should be enabled (page 0 < 49)
const nextBtn = screen.getByText('Next');
expect((nextBtn as HTMLButtonElement).disabled).toBe(false);
});
it('disables next button on last server-side page', () => {
render(DashboardDataGrid, {
data: [{ id: 99, title: 'Last', last_modified: '2025-06-01' }],
columns: sampleColumns,
paginated: true,
pageSize: 1,
page: 2,
serverTotal: 3,
serverTotalPages: 3,
});
const nextBtn = screen.getByText('Next');
expect((nextBtn as HTMLButtonElement).disabled).toBe(true);
});
// ── hideFilter ────────────────────────────────────────────────
it('hides filter input when hideFilter is true', () => {
render(DashboardDataGrid, {
data: sampleData,
columns: sampleColumns,
filterable: true,
hideFilter: true,
});
expect(screen.queryByPlaceholderText('Search...')).toBeNull();
});
// ── Custom cell snippet (via raw render, tested as HTML injection) ──
it('renders raw HTML when column has raw:true and render function', () => {
const badgeColumns = [
{ key: 'status', label: 'Status', render: (item: any) => `<span class="badge">${item.status}</span>`, raw: true },
];
const badgeData = [
{ id: 1, status: 'published' },
];
const { container } = render(DashboardDataGrid, {
data: badgeData,
columns: badgeColumns,
});
const badge = container.querySelector('.badge');
expect(badge).toBeDefined();
expect(badge?.textContent).toBe('published');
});
}); });
// #endregion Test.Dashboard.DataGrid // #endregion Test.Dashboard.DataGrid

View File

@@ -74,7 +74,7 @@
*/ */
function formatBreadcrumbLabel(segment) { function formatBreadcrumbLabel(segment) {
const specialCases = { const specialCases = {
dashboards: "nav.dashboard", dashboards: "nav.dashboards",
datasets: "nav.datasets", datasets: "nav.datasets",
storage: "nav.storage", storage: "nav.storage",
admin: "nav.admin", admin: "nav.admin",

View File

@@ -1,153 +1,361 @@
<!-- #region FileList [C:3] [TYPE Component] [SEMANTICS storage, file, table, navigation, explorer] --> <!-- #region FileList [C:3] [TYPE Component] [SEMANTICS storage,file,table,navigation,explorer,pagination,sort,search,selection] -->
<!-- @ingroup Components --> <!-- @ingroup Components -->
<!-- @BRIEF Displays a table of files with metadata and actions. --> <!-- @BRIEF File explorer table with breadcrumbs, search, sort, pagination, multi-select, and bulk actions. -->
<!-- @LAYER UI --> <!-- @LAYER UI -->
<!-- <!-- @RELATION DEPENDS_ON -> [storageService] -->
@UX_STATE: Loading -> Default <!-- @RELATION DEPENDS_ON -> [$lib/ui/Button] -->
<!-- @RELATION DEPENDS_ON -> [$lib/ui/Input] -->
@SEMANTICS: storage, files, list, table <!-- @RELATION DEPENDS_ON -> [$lib/utils/dateFormat] -->
@PURPOSE: Displays a table of files with metadata and actions. <!-- @UX_STATE Loading -> Skeleton rows -->
@LAYER UI <!-- @UX_STATE Empty -> "No files" message -->
@RELATION DEPENDS_ON -> [storageService] <!-- @UX_STATE Table -> Data rows with sort, search, pagination -->
<!-- @UX_STATE Selected -> Checkboxes + bulk action bar -->
@PROPS: files (Array) - List of StoredFile objects. <!-- @UX_FEEDBACK Sort indicator on column headers -->
@EVENTS: ondelete/onnavigate callback props - Raised when a file is deleted or navigation is requested. <!-- @UX_FEEDBACK Pagination "Showing X-Y of Z" -->
--> <!-- @UX_FEEDBACK Search input filters in real-time -->
<!-- @UX_RECOVERY Clear search to restore full list -->
<script lang="ts"> <script lang="ts">
// [SECTION: IMPORTS]
import { downloadFile } from '../../../services/storageService.js'; import { downloadFile } from '../../../services/storageService.js';
import { t } from '$lib/i18n/index.svelte.js'; import { t } from '$lib/i18n/index.svelte.js';
import { addToast } from '$lib/toasts.svelte.js'; import { addToast } from '$lib/toasts.svelte.js';
import { formatDateTime } from "$lib/utils/dateFormat"; import { formatDateTime, formatFileSize } from "$lib/utils/dateFormat";
// [/SECTION: IMPORTS] import { Button, Input, EmptyState } from "$lib/ui";
interface FileEntry {
name: string;
path: string;
category: string;
size: number;
created_at: string;
mime_type: string;
}
let { let {
files = [], files = [] as FileEntry[],
ondelete = () => {}, currentPath = "",
onnavigate = () => {}, loading = false,
pageSize = 20,
ondelete = (_payload: { category: string; path: string; name: string }) => {},
onnavigate = (_path: string) => {},
onnavigateup = () => {},
onbulkdelete = (_files: FileEntry[]) => {},
} = $props(); } = $props();
// #region isDirectory:Function [TYPE Function] // ── Helpers ─────────────────────────────────────────────────────
// @ingroup Components function isDirectory(file: FileEntry): boolean {
/**
* @purpose Checks if a file object represents a directory.
* @pre file object has mime_type property.
* @post Returns boolean.
* @param {Object} file - The file object to check.
* @return {boolean} True if it's a directory, false otherwise.
*/
function isDirectory(file) {
console.log("[isDirectory][Action] Checking file type");
return file.mime_type === 'directory'; return file.mime_type === 'directory';
} }
// #endregion isDirectory:Function
// #region formatSize:Function [TYPE Function] function isFile(file: FileEntry): boolean {
// @ingroup Components return !isDirectory(file);
/**
* @purpose Formats file size in bytes into a human-readable string.
* @pre bytes is a number.
* @post Returns formatted string.
* @param {number} bytes - The size in bytes.
* @return {string} Formatted size (e.g., "1.2 MB").
*/
function formatSize(bytes) {
console.log(`[formatSize][Action] Formatting ${bytes} bytes`);
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
} }
// #endregion formatSize:Function
// #region handleDownload:Function [TYPE Function] // ── Search ──────────────────────────────────────────────────────
// @ingroup Components let searchText = $state("");
/**
* @purpose Downloads selected file through authenticated API request. // ── Sort ────────────────────────────────────────────────────────
* @pre file is a non-directory storage entry with category/path. type SortColumn = 'name' | 'category' | 'size' | 'created_at';
* @post Browser download starts or user sees toast on failure. type SortDir = 'asc' | 'desc';
*/ let sortColumn = $state<SortColumn>('name');
async function handleDownload(file) { let sortDirection = $state<SortDir>('asc');
try {
await downloadFile(file.category, file.path, file.name); function handleSort(col: SortColumn) {
} catch (error) { if (sortColumn === col) {
addToast(error?.message || 'Download failed', 'error'); sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
} else {
sortColumn = col;
sortDirection = 'asc';
} }
} }
// #endregion handleDownload:Function
function getSortIndicator(col: SortColumn): string {
if (sortColumn !== col) return '⇅';
return sortDirection === 'asc' ? '▲' : '▼';
}
// ── Derived: filtered + sorted data ─────────────────────────────
let filteredFiles = $derived.by(() => {
let result = files;
if (searchText) {
const q = searchText.toLowerCase();
result = result.filter(f => f.name.toLowerCase().includes(q) || f.category.toLowerCase().includes(q));
}
// Directories always first, then files
const dirs = result.filter(f => isDirectory(f));
const fils = result.filter(f => isFile(f));
const sortFn = (a: FileEntry, b: FileEntry) => {
let aVal: any, bVal: any;
if (sortColumn === 'size') {
aVal = a.size ?? 0;
bVal = b.size ?? 0;
} else if (sortColumn === 'created_at') {
aVal = a.created_at ?? '';
bVal = b.created_at ?? '';
} else {
aVal = (a[sortColumn] ?? '').toString().toLowerCase();
bVal = (b[sortColumn] ?? '').toString().toLowerCase();
}
if (aVal < bVal) return sortDirection === 'asc' ? -1 : 1;
if (aVal > bVal) return sortDirection === 'asc' ? 1 : -1;
return 0;
};
dirs.sort(sortFn);
fils.sort(sortFn);
return [...dirs, ...fils];
});
// ── Pagination ──────────────────────────────────────────────────
let page = $state(0);
let totalPages = $derived(Math.max(1, Math.ceil(filteredFiles.length / pageSize)));
// Reset page on search change
$effect(() => { searchText; page = 0; });
let displayFiles = $derived(
filteredFiles.slice(page * pageSize, (page + 1) * pageSize)
);
function goToPage(p: number) {
if (p >= 0 && p < totalPages) page = p;
}
// ── Selection ───────────────────────────────────────────────────
let selectedNames = $state<string[]>([]);
function toggleSelect(name: string) {
if (selectedNames.includes(name)) {
selectedNames = selectedNames.filter(n => n !== name);
} else {
selectedNames = [...selectedNames, name];
}
}
function selectAllVisible() {
const visibleNames = displayFiles.map(f => f.name);
const allSelected = visibleNames.every(n => selectedNames.includes(n));
if (allSelected) {
selectedNames = selectedNames.filter(n => !visibleNames.includes(n));
} else {
const newSet = new Set([...selectedNames, ...visibleNames]);
selectedNames = Array.from(newSet);
}
}
let isAllSelected = $derived(
displayFiles.length > 0 && displayFiles.every(f => selectedNames.includes(f.name))
);
let selectedFiles = $derived(
files.filter(f => selectedNames.includes(f.name))
);
function clearSelection() {
selectedNames = [];
}
// ── Bulk actions ────────────────────────────────────────────────
function handleBulkDelete() {
if (selectedFiles.length === 0) return;
onbulkdelete(selectedFiles);
clearSelection();
}
async function handleBulkDownload() {
const fileEntries = selectedFiles.filter(f => isFile(f));
for (const f of fileEntries) {
try { await downloadFile(f.category, f.path, f.name); }
catch (e: any) { addToast(e?.message || 'Download failed', 'error'); }
}
}
// ── Single file download ────────────────────────────────────────
async function handleDownload(file: FileEntry) {
try { await downloadFile(file.category, file.path, file.name); }
catch (e: any) { addToast(e?.message || 'Download failed', 'error'); }
}
// ── Breadcrumb segments ─────────────────────────────────────────
let breadcrumbSegments = $derived(
currentPath ? currentPath.split('/').filter(Boolean) : []
);
function navigateToBreadcrumb(index: number) {
const segments = currentPath.split('/').filter(Boolean);
const newPath = segments.slice(0, index + 1).join('/');
onnavigate(newPath);
}
</script> </script>
<!-- [SECTION: TEMPLATE] --> <div class="file-list space-y-3">
<div class="overflow-x-auto"> <!-- ── Breadcrumbs ── -->
<table class="min-w-full bg-surface-card border border-border"> <div class="flex items-center text-sm text-text-muted flex-wrap gap-1">
<button onclick={() => onnavigate('')} class="hover:text-primary transition-colors" aria-label={$t.storage?.root || "Root"}>
<svg class="h-4 w-4 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/>
</svg>
</button>
{#each breadcrumbSegments as seg, i}
<span class="text-text-subtle">/</span>
{#if i === breadcrumbSegments.length - 1}
<span class="text-text font-medium">{seg}</span>
{:else}
<button onclick={() => navigateToBreadcrumb(i)} class="hover:text-primary transition-colors capitalize">{seg}</button>
{/if}
{/each}
{#if currentPath}
<button onclick={onnavigateup} class="ml-2 text-xs text-primary hover:text-primary-hover transition-colors" title={$t.common?.back}>
<svg class="h-4 w-4 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
</svg>
</button>
{/if}
</div>
<!-- ── Toolbar: search + bulk actions ── -->
<div class="flex items-center gap-3 flex-wrap">
<div class="flex-1 min-w-[200px] max-w-sm">
<Input bind:value={searchText} placeholder={$t.storage?.search || "Search files..."} />
</div>
{#if selectedFiles.length > 0}
<span class="text-sm text-text-muted">{selectedFiles.length} selected</span>
<Button size="sm" variant="secondary" onclick={handleBulkDownload}
disabled={!selectedFiles.some(f => isFile(f))}>
{$t.storage.table.download || "Download"}
</Button>
<Button size="sm" variant="destructive" onclick={handleBulkDelete}>
{$t.storage.table.delete || "Delete"} ({selectedFiles.length})
</Button>
<Button size="sm" variant="ghost" onclick={clearSelection}>Clear</Button>
{/if}
</div>
<!-- ── Table ── -->
<div class="overflow-x-auto rounded-lg border border-border">
<table class="min-w-full divide-y divide-border">
<thead class="bg-surface-muted"> <thead class="bg-surface-muted">
<tr> <tr>
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">{$t.storage.table.name}</th> <th class="px-4 py-3 w-10" scope="col">
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">{$t.storage.table.category}</th> <input type="checkbox"
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">{$t.storage.table.size}</th> checked={isAllSelected}
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">{$t.storage.table.created_at}</th> onchange={selectAllVisible}
<th class="px-6 py-3 text-right text-xs font-medium text-text-muted uppercase tracking-wider">{$t.storage.table.actions}</th> class="h-4 w-4 text-primary border-border-strong rounded"
aria-label="Select all" />
</th>
<th scope="col" class="px-6 py-3 text-left text-sm font-semibold text-text-muted cursor-pointer hover:text-text transition-colors select-none"
onclick={() => handleSort('name')}>
{$t.storage.table.name || "Name"} <span class="ml-0.5 text-xs">{getSortIndicator('name')}</span>
</th>
<th scope="col" class="px-6 py-3 text-left text-sm font-semibold text-text-muted cursor-pointer hover:text-text transition-colors select-none"
onclick={() => handleSort('category')}>
{$t.storage.table.category || "Category"} <span class="ml-0.5 text-xs">{getSortIndicator('category')}</span>
</th>
<th scope="col" class="px-6 py-3 text-left text-sm font-semibold text-text-muted cursor-pointer hover:text-text transition-colors select-none"
onclick={() => handleSort('size')}>
{$t.storage.table.size || "Size"} <span class="ml-0.5 text-xs">{getSortIndicator('size')}</span>
</th>
<th scope="col" class="px-6 py-3 text-left text-sm font-semibold text-text-muted cursor-pointer hover:text-text transition-colors select-none"
onclick={() => handleSort('created_at')}>
{$t.storage.table.created_at || "Created"} <span class="ml-0.5 text-xs">{getSortIndicator('created_at')}</span>
</th>
<th scope="col" class="px-6 py-3 text-right text-sm font-semibold text-text-muted">
{$t.storage.table.actions || "Actions"}
</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-border"> <tbody class="bg-surface-card divide-y divide-border">
{#each files as file} {#if loading}
<tr class="hover:bg-surface-muted"> {#each Array(Math.min(pageSize, 5)) as _}
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-text"> <tr class="animate-pulse">
<td class="px-4 py-4"><div class="h-4 w-4 bg-surface-muted rounded"></div></td>
<td class="px-6 py-4"><div class="h-4 bg-surface-muted rounded w-3/4"></div></td>
<td class="px-6 py-4"><div class="h-4 bg-surface-muted rounded w-1/2"></div></td>
<td class="px-6 py-4"><div class="h-4 bg-surface-muted rounded w-16"></div></td>
<td class="px-6 py-4"><div class="h-4 bg-surface-muted rounded w-24"></div></td>
<td class="px-6 py-4"><div class="h-4 bg-surface-muted rounded w-20 ml-auto"></div></td>
</tr>
{/each}
{:else if !loading && displayFiles.length === 0}
<tr>
<td colspan="6" class="px-6 py-10">
<EmptyState title={$t.storage.no_files || "No files found"} />
</td>
</tr>
{:else}
{#each displayFiles as file (file.path + '/' + file.name)}
<tr class="hover:bg-surface-muted transition-colors">
<!-- Checkbox -->
<td class="px-4 py-3">
<input type="checkbox"
checked={selectedNames.includes(file.name)}
onchange={() => toggleSelect(file.name)}
class="h-4 w-4 text-primary border-border-strong rounded" />
</td>
<!-- Name -->
<td class="px-6 py-3 whitespace-nowrap text-sm">
{#if isDirectory(file)} {#if isDirectory(file)}
<button <button onclick={() => onnavigate(file.path)}
onclick={() => onnavigate(file.path)} class="flex items-center text-primary hover:text-primary-hover transition-colors font-medium">
class="flex items-center text-primary hover:text-primary" <svg class="h-5 w-5 mr-2 text-warning shrink-0" fill="currentColor" viewBox="0 0 20 20">
>
<svg class="h-5 w-5 mr-2 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
<path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"/> <path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"/>
</svg> </svg>
{file.name} {file.name}
</button> </button>
{:else} {:else}
<div class="flex items-center"> <div class="flex items-center text-text">
<svg class="h-5 w-5 mr-2 text-text-subtle" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="h-5 w-5 mr-2 text-text-subtle shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
</svg> </svg>
{file.name} {file.name}
</div> </div>
{/if} {/if}
</td> </td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-text-muted capitalize">{file.category}</td> <!-- Category -->
<td class="px-6 py-4 whitespace-nowrap text-sm text-text-muted"> <td class="px-6 py-3 whitespace-nowrap text-sm text-text-muted capitalize">{file.category}</td>
{isDirectory(file) ? '--' : formatSize(file.size)} <!-- Size -->
<td class="px-6 py-3 whitespace-nowrap text-sm text-text-muted">
{isDirectory(file) ? '--' : formatFileSize(file.size)}
</td> </td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-text-muted">{formatDateTime(file.created_at)}</td> <!-- Created -->
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> <td class="px-6 py-3 whitespace-nowrap text-sm text-text-muted">{formatDateTime(file.created_at)}</td>
{#if !isDirectory(file)} <!-- Actions -->
<button <td class="px-6 py-3 whitespace-nowrap text-right text-sm">
type="button" <div class="flex items-center justify-end gap-2">
onclick={() => handleDownload(file)} {#if isFile(file)}
class="text-primary hover:text-primary mr-4" <Button size="sm" variant="ghost" onclick={() => handleDownload(file)}>
> {$t.storage.table.download || "Download"}
{$t.storage.table.download} </Button>
</button>
{/if} {/if}
<button <Button size="sm" variant="ghost"
onclick={() => ondelete({ category: file.category, path: file.path, name: file.name })} onclick={() => ondelete({ category: file.category, path: file.path, name: file.name })}>
class="text-destructive hover:text-destructive" <span class="text-destructive">{$t.storage.table.delete || "Delete"}</span>
> </Button>
{$t.storage.table.delete} </div>
</button>
</td>
</tr>
{:else}
<tr>
<td colspan="5" class="px-6 py-10 text-center text-sm text-text-muted">
{$t.storage.no_files}
</td> </td>
</tr> </tr>
{/each} {/each}
{/if}
</tbody> </tbody>
</table> </table>
</div> </div>
<!-- [/SECTION: TEMPLATE] -->
<!-- ── Pagination ── -->
{#if !loading && filteredFiles.length > pageSize}
<div class="flex items-center justify-between">
<div class="text-sm text-text-muted">
{$t.storage.showing?.replace('{start}', String(page * pageSize + 1))
?.replace('{end}', String(Math.min((page + 1) * pageSize, filteredFiles.length)))
?.replace('{total}', String(filteredFiles.length))
|| `Showing ${page * pageSize + 1}-${Math.min((page + 1) * pageSize, filteredFiles.length)} of ${filteredFiles.length}`}
</div>
<div class="flex gap-2">
<Button variant="secondary" size="sm" disabled={page === 0} onclick={() => goToPage(page - 1)}>
{$t.dashboard?.previous || "Prev"}
</Button>
<Button variant="secondary" size="sm" disabled={page >= totalPages - 1} onclick={() => goToPage(page + 1)}>
{$t.dashboard?.next || "Next"}
</Button>
</div>
</div>
{/if}
</div>
<!-- #endregion FileList --> <!-- #endregion FileList -->

View File

@@ -6,6 +6,8 @@
"repositories": "Repositories", "repositories": "Repositories",
"root": "Root", "root": "Root",
"no_files": "No files found.", "no_files": "No files found.",
"search": "Search files...",
"showing": "Showing {start} to {end} of {total}",
"upload_title": "Upload File", "upload_title": "Upload File",
"target_category": "Target Category", "target_category": "Target Category",
"upload_button": "Upload a file", "upload_button": "Upload a file",

View File

@@ -6,6 +6,8 @@
"repositories": "Репозитории", "repositories": "Репозитории",
"root": "Корень", "root": "Корень",
"no_files": "Файлы не найдены.", "no_files": "Файлы не найдены.",
"search": "Поиск файлов...",
"showing": "Показано с {start} по {end} из {total}",
"upload_title": "Загрузить файл", "upload_title": "Загрузить файл",
"target_category": "Целевая категория", "target_category": "Целевая категория",
"upload_button": "Загрузить файл", "upload_button": "Загрузить файл",

View File

@@ -23,6 +23,7 @@
import { addToast } from '$lib/toasts.svelte.js'; import { addToast } from '$lib/toasts.svelte.js';
import { t } from '$lib/i18n/index.svelte.js'; import { t } from '$lib/i18n/index.svelte.js';
import { ConfirmDialog } from "$lib/ui"; import { ConfirmDialog } from "$lib/ui";
import { log } from "$lib/cot-logger";
import FileList from '$lib/components/storage/FileList.svelte'; import FileList from '$lib/components/storage/FileList.svelte';
import FileUpload from '$lib/components/storage/FileUpload.svelte'; import FileUpload from '$lib/components/storage/FileUpload.svelte';
// [/SECTION: IMPORTS] // [/SECTION: IMPORTS]
@@ -63,14 +64,14 @@
// #endregion resolveStorageQueryFromPath:Function // #endregion resolveStorageQueryFromPath:Function
async function loadFiles() { async function loadFiles() {
console.log('[STORAGE-PAGE][LOAD_START] path=%s', currentPath); log("StorageIndexPage", "REASON", "Loading files", { path: currentPath });
isLoading = true; isLoading = true;
try { try {
const query = resolveStorageQueryFromPath(currentPath); const query = resolveStorageQueryFromPath(currentPath);
files = await listFiles(query.category, query.subpath); files = await listFiles(query.category, query.subpath);
console.log('[STORAGE-PAGE][LOAD_OK] count=%d', files.length); log("StorageIndexPage", "REFLECT", "Files loaded", { count: files.length });
} catch (error) { } catch (error) {
console.log('[STORAGE-PAGE][LOAD_ERR] error=%s', error.message); log("StorageIndexPage", "EXPLORE", "Failed to load files", {}, String(error.message));
addToast($t.storage.messages.load_failed.replace('{error}', error.message), 'error'); addToast($t.storage.messages.load_failed.replace('{error}', error.message), 'error');
} finally { } finally {
isLoading = false; isLoading = false;
@@ -89,28 +90,43 @@
async function handleDelete(payload) { async function handleDelete(payload) {
const { category, path, name } = payload || {}; const { category, path, name } = payload || {};
if (!category || !path || !name) { if (!category || !path || !name) {
console.warn('[STORAGE-PAGE][DELETE_SKIP] invalid payload', payload); log("StorageIndexPage", "EXPLORE", "Delete skipped — invalid payload", { payload });
return; return;
} }
deletePayload = payload; deletePayload = payload;
showDeleteConfirm = true; showDeleteConfirm = true;
} }
async function onConfirmDelete() { // ── Bulk delete ─────────────────────────────────────────────────
const payload = deletePayload; let bulkDeletePayloads = $state<Array<{category: string; path: string; name: string}>>([]);
const { category, path, name } = payload || {};
deletePayload = null;
console.log('[STORAGE-PAGE][DELETE_START] category=%s path=%s', category, path);
try { function handleBulkDelete(files: Array<{category: string; path: string; name: string}>) {
await deleteFile(category, path); bulkDeletePayloads = files;
console.log('[STORAGE-PAGE][DELETE_OK] name=%s', name); deletePayload = files[0]; // for confirm dialog message
addToast($t.storage.messages.delete_success.replace('{name}', name), 'success'); showDeleteConfirm = true;
await loadFiles();
} catch (error) {
console.log('[STORAGE-PAGE][DELETE_ERR] error=%s', error.message);
addToast($t.storage.messages.delete_failed.replace('{error}', error.message), 'error');
} }
async function onConfirmDelete() {
const payloads = bulkDeletePayloads.length > 0 ? bulkDeletePayloads : (deletePayload ? [deletePayload] : []);
deletePayload = null;
bulkDeletePayloads = [];
let errors = 0;
for (const p of payloads) {
try {
await deleteFile(p.category, p.path);
} catch (error) {
errors++;
log("StorageIndexPage", "EXPLORE", "Failed to delete file", {}, String(error.message));
}
}
if (errors === 0) {
addToast(payloads.length > 1
? $t.storage.messages.bulk_delete_success?.replace('{count}', String(payloads.length)) || `${payloads.length} files deleted`
: $t.storage.messages.delete_success.replace('{name}', payloads[0].name), 'success');
} else {
addToast($t.storage.messages.delete_failed.replace('{error}', `${errors} failed`), 'error');
}
await loadFiles();
} }
// #endregion handleDelete:Function // #endregion handleDelete:Function
@@ -124,10 +140,10 @@
*/ */
function handleNavigate(path) { function handleNavigate(path) {
if (!path || typeof path !== 'string') { if (!path || typeof path !== 'string') {
console.warn('[STORAGE-PAGE][NAVIGATE_SKIP] invalid path', path); log("StorageIndexPage", "EXPLORE", "Navigate skipped — invalid path", { path });
return; return;
} }
console.log('[STORAGE-PAGE][NAVIGATE] path=%s', path); log("StorageIndexPage", "REASON", "Navigating to path", { path });
currentPath = path; currentPath = path;
loadFiles(); loadFiles();
} }
@@ -182,21 +198,6 @@
<div class="container mx-auto w-full p-4 min-[1280px]:max-w-[1400px] min-[1536px]:max-w-[1680px]"> <div class="container mx-auto w-full p-4 min-[1280px]:max-w-[1400px] min-[1536px]:max-w-[1680px]">
<div class="mb-6"> <div class="mb-6">
<h1 class="text-2xl font-bold text-text">{$t.storage.management}</h1> <h1 class="text-2xl font-bold text-text">{$t.storage.management}</h1>
<div class="flex items-center mt-2 text-sm text-text-muted">
<button onclick={() => { currentPath = ''; loadFiles(); }} class="hover:text-primary">{$t.storage.root}</button>
{#each currentPath.split('/').filter(Boolean) as part, i}
<span class="mx-2">/</span>
<button
onclick={() => {
currentPath = currentPath.split('/').filter(Boolean).slice(0, i + 1).join('/');
loadFiles();
}}
class="hover:text-primary capitalize"
>
{part}
</button>
{/each}
</div>
</div> </div>
<div class="flex justify-end mb-4"> <div class="flex justify-end mb-4">
@@ -212,21 +213,15 @@
<div class="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_360px] 2xl:grid-cols-[minmax(0,1fr)_420px] gap-6"> <div class="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_360px] 2xl:grid-cols-[minmax(0,1fr)_420px] gap-6">
<!-- Main Content: File List --> <!-- Main Content: File List -->
<div class="space-y-4 min-w-0"> <div class="space-y-4 min-w-0">
<div class="flex items-center mb-2"> <FileList
{#if currentPath} {files}
<button {currentPath}
onclick={navigateUp} loading={isLoading}
class="mr-4 inline-flex items-center px-3 py-1 border border-border-strong shadow-sm text-xs font-medium rounded text-text bg-surface-card hover:bg-surface-muted" ondelete={handleDelete}
> onnavigate={handleNavigate}
<svg class="h-4 w-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> onnavigateup={navigateUp}
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" /> onbulkdelete={handleBulkDelete}
</svg> />
{$t.common?.back}
</button>
{/if}
</div>
<FileList {files} ondelete={handleDelete} onnavigate={handleNavigate} />
</div> </div>
<!-- Sidebar: Upload --> <!-- Sidebar: Upload -->
@@ -243,12 +238,14 @@
<ConfirmDialog <ConfirmDialog
bind:show={showDeleteConfirm} bind:show={showDeleteConfirm}
title="Delete file?" title="Delete file?"
message={deletePayload ? $t.storage.messages.delete_confirm.replace('{name}', deletePayload.name) : ""} message={bulkDeletePayloads.length > 1
? `Delete ${bulkDeletePayloads.length} files?`
: (deletePayload ? $t.storage.messages.delete_confirm.replace('{name}', deletePayload.name) : "")}
variant="destructive" variant="destructive"
confirmLabel="Delete" confirmLabel="Delete"
cancelLabel="Cancel" cancelLabel="Cancel"
onConfirm={onConfirmDelete} onConfirm={onConfirmDelete}
onCancel={() => {}} onCancel={() => { deletePayload = null; bulkDeletePayloads = []; }}
/> />
<!-- [/SECTION: TEMPLATE] --> <!-- [/SECTION: TEMPLATE] -->