feature/file-manager-user-only-read

This commit is contained in:
2026-04-06 07:43:43 -06:00
parent 9439a6a775
commit 1355bf34c4
10 changed files with 614 additions and 12 deletions

View File

@@ -456,6 +456,7 @@ async function fetchBlob(endpoint: string, options: RequestInit = {}): Promise<B
// Métodos HTTP
export const api = {
get: <T = any>(endpoint: string) => fetchApi<T>(endpoint, { method: 'GET' }),
getBlob: (endpoint: string) => fetchBlob(endpoint, { method: 'GET' }),
post: <T = any>(endpoint: string, body: any, options: RequestInit = {}) =>
fetchApi<T>(endpoint, {

View File

@@ -0,0 +1,53 @@
import { api } from '$lib/api';
const BASE_PATH = '/v1/a76/audit-log/files';
export interface AuditFileBreadcrumb {
path: string;
display_name: string;
}
export interface AuditFolderItem {
path: string;
display_name: string;
}
export interface AuditFileItem {
path: string;
display_name: string;
size: number;
last_modified?: string | null;
}
export interface AuditFileListResponse {
current_path: string;
display_path: string;
breadcrumbs: AuditFileBreadcrumb[];
folders: AuditFolderItem[];
files: AuditFileItem[];
next_token?: string | null;
}
export const AuditFilesAPI = {
list: async (params?: {
path?: string;
continuation_token?: string;
max_keys?: number;
}): Promise<AuditFileListResponse> => {
const query = new URLSearchParams();
if (params?.path) query.set('path', params.path);
if (params?.continuation_token) query.set('continuation_token', params.continuation_token);
if (params?.max_keys) query.set('max_keys', String(params.max_keys));
const qs = query.toString();
const endpoint = qs ? `${BASE_PATH}?${qs}` : BASE_PATH;
const response = await api.get<AuditFileListResponse>(endpoint);
if (response.error || !response.data) {
throw new Error(response.error || 'Failed to list tenant files');
}
return response.data;
},
downloadBlob: (path: string) =>
api.getBlob(`${BASE_PATH}/download?path=${encodeURIComponent(path)}`)
};

View File

@@ -2,10 +2,11 @@
import { afterNavigate, goto } from '$app/navigation';
import { page } from '$app/state';
import * as Tabs from '$lib/components/ui/tabs';
import { ScrollText, ListTodo } from 'lucide-svelte';
import { ScrollText, ListTodo, FolderTree } from 'lucide-svelte';
import * as m from '$lib/paraglide/messages.js';
import BitacoraTab from './bitacora-tab.svelte';
import TasksTab from './tasks-tab.svelte';
import FilesTab from './files-tab.svelte';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
@@ -17,17 +18,19 @@
});
afterNavigate(() => {
const t = page.url.searchParams.get('tab') === 'tasks' ? 'tasks' : 'bitacora';
const raw = page.url.searchParams.get('tab');
const t = raw === 'tasks' || raw === 'files' ? raw : 'bitacora';
tabValue = t;
});
function onTabValueChange(v: string) {
const fromUrl = page.url.searchParams.get('tab') === 'tasks' ? 'tasks' : 'bitacora';
const raw = page.url.searchParams.get('tab');
const fromUrl = raw === 'tasks' || raw === 'files' ? raw : 'bitacora';
if (v === fromUrl) return;
const u = new URL(page.url.href);
if (v === 'tasks') {
u.searchParams.set('tab', 'tasks');
if (v === 'tasks' || v === 'files') {
u.searchParams.set('tab', v);
} else {
u.searchParams.delete('tab');
}
@@ -53,7 +56,7 @@
onValueChange={onTabValueChange}
class="flex min-h-0 w-full flex-1 flex-col"
>
<Tabs.List class="grid w-full grid-cols-2">
<Tabs.List class="grid w-full grid-cols-3">
<Tabs.Trigger value="bitacora">
<ScrollText class="mr-2 h-4 w-4" />
{m['sidebar.audit_logs_tab_bitacora']()}
@@ -62,6 +65,10 @@
<ListTodo class="mr-2 h-4 w-4" />
{m['sidebar.audit_logs_tab_tasks']()}
</Tabs.Trigger>
<Tabs.Trigger value="files">
<FolderTree class="mr-2 h-4 w-4" />
{m['sidebar.audit_logs_tab_files']()}
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="bitacora" class="mt-6 flex min-h-0 flex-1 flex-col overflow-hidden">
@@ -74,5 +81,10 @@
<TasksTab />
{/if}
</Tabs.Content>
<Tabs.Content value="files" class="mt-6 flex min-h-0 flex-1 flex-col overflow-hidden">
{#if tabValue === 'files'}
<FilesTab />
{/if}
</Tabs.Content>
</Tabs.Root>
</div>

View File

@@ -2,6 +2,6 @@ import type { PageLoad } from './$types';
export const load: PageLoad = ({ url }) => {
const tab = url.searchParams.get('tab');
const initialTab = tab === 'tasks' ? 'tasks' : 'bitacora';
const initialTab = tab === 'tasks' || tab === 'files' ? tab : 'bitacora';
return { initialTab };
};

View File

@@ -0,0 +1,205 @@
<script lang="ts">
import { onMount } from 'svelte';
import * as Card from '$lib/components/ui/card';
import * as Table from '$lib/components/ui/table';
import { Button } from '$lib/components/ui/button';
import { Folder, FileDown, RefreshCw } from 'lucide-svelte';
import * as m from '$lib/paraglide/messages.js';
import {
AuditFilesAPI,
type AuditFileBreadcrumb,
type AuditFileItem,
type AuditFolderItem
} from '$lib/api/dashboard/a76/audit_files';
let loading = $state(false);
let error = $state<string | null>(null);
let currentPath = $state('');
let displayPath = $state('');
let breadcrumbs = $state<AuditFileBreadcrumb[]>([]);
let folders = $state<AuditFolderItem[]>([]);
let files = $state<AuditFileItem[]>([]);
async function loadPath(path = '') {
loading = true;
error = null;
try {
const res = await AuditFilesAPI.list({ path, max_keys: 200 });
currentPath = res.current_path ?? '';
displayPath = res.display_path ?? '';
breadcrumbs = res.breadcrumbs ?? [];
folders = res.folders ?? [];
files = res.files ?? [];
} catch (e: unknown) {
error = e instanceof Error ? e.message : String(e);
} finally {
loading = false;
}
}
function formatSize(bytes: number) {
if (!bytes || bytes < 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let value = bytes;
let idx = 0;
while (value >= 1024 && idx < units.length - 1) {
value /= 1024;
idx++;
}
return `${value.toFixed(value >= 10 || idx === 0 ? 0 : 1)} ${units[idx]}`;
}
function formatDate(value?: string | null) {
if (!value) return '—';
try {
return new Date(value).toLocaleString();
} catch {
return value;
}
}
async function downloadFile(path: string, filename: string) {
try {
const blob = await AuditFilesAPI.downloadBlob(path);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch (e: unknown) {
error = e instanceof Error ? e.message : String(e);
}
}
onMount(() => {
void loadPath('');
});
</script>
<div class="flex min-h-0 flex-1 flex-col gap-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="text-lg font-semibold">{m['sidebar.audit_logs_files_title']()}</h2>
<p class="text-sm text-muted-foreground">{displayPath || m['sidebar.audit_logs_files_root']()}</p>
</div>
<Button variant="outline" onclick={() => void loadPath(currentPath)} disabled={loading}>
<RefreshCw class="mr-2 h-4 w-4 {loading ? 'animate-spin' : ''}" />
{m['sidebar.audit_logs_files_refresh']()}
</Button>
</div>
<Card.Root>
<Card.Content class="py-2">
<div class="flex flex-wrap items-center gap-1 text-xs text-muted-foreground">
{#each breadcrumbs as crumb, idx}
{#if idx > 0}
<span class="mx-1 text-[10px]">/</span>
{/if}
{#if idx === breadcrumbs.length - 1}
<span
class="max-w-[240px] truncate font-medium text-foreground sm:max-w-[320px]"
>
{crumb.display_name}
</span>
{:else}
<button
type="button"
class="max-w-[180px] truncate hover:text-foreground hover:underline underline-offset-2"
onclick={() => void loadPath(crumb.path)}
disabled={loading}
>
{crumb.display_name}
</button>
{/if}
{/each}
</div>
</Card.Content>
</Card.Root>
<Card.Root class="flex min-h-0 flex-1 flex-col overflow-hidden">
<Card.Header class="pb-2">
<Card.Title class="text-base">{m['sidebar.audit_logs_files_list_title']()}</Card.Title>
</Card.Header>
<Card.Content class="min-h-0 flex-1 overflow-auto p-0">
{#if error}
<div class="m-4 rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-900 dark:bg-red-950/40 dark:text-red-300">
{m['sidebar.audit_logs_files_error_prefix']()} {error}
</div>
{/if}
<div class="w-full overflow-x-auto">
<Table.Root class="min-w-full text-sm">
<Table.Header>
<Table.Row>
<Table.Head class="min-w-[200px]">
{m['sidebar.audit_logs_files_col_name']()}
</Table.Head>
<Table.Head class="w-[110px]">
{m['sidebar.audit_logs_files_col_size']()}
</Table.Head>
<Table.Head class="w-[160px]">
{m['sidebar.audit_logs_files_col_modified']()}
</Table.Head>
<Table.Head class="w-[120px] text-left">
{m['sidebar.audit_logs_files_col_actions']()}
</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if loading}
<Table.Row>
<Table.Cell colspan={4} class="py-8 text-left text-muted-foreground">
{m['sidebar.audit_logs_files_loading']()}
</Table.Cell>
</Table.Row>
{:else if folders.length === 0 && files.length === 0}
<Table.Row>
<Table.Cell colspan={4} class="py-8 text-left text-muted-foreground">
{m['sidebar.audit_logs_files_empty']()}
</Table.Cell>
</Table.Row>
{:else}
{#each folders as folder}
<Table.Row
class="cursor-pointer hover:bg-muted/30"
onclick={() => void loadPath(folder.path)}
>
<Table.Cell class="max-w-[260px] font-medium">
<span class="inline-flex items-center gap-2 truncate text-left">
<Folder class="h-4 w-4 flex-shrink-0 text-amber-500" />
<span class="truncate">{folder.display_name}</span>
</span>
</Table.Cell>
<Table.Cell class="text-muted-foreground"></Table.Cell>
<Table.Cell class="text-muted-foreground"></Table.Cell>
<Table.Cell class="text-left text-muted-foreground"></Table.Cell>
</Table.Row>
{/each}
{#each files as file}
<Table.Row>
<Table.Cell class="max-w-[260px] font-medium">
<span class="truncate">{file.display_name}</span>
</Table.Cell>
<Table.Cell>{formatSize(file.size)}</Table.Cell>
<Table.Cell>{formatDate(file.last_modified)}</Table.Cell>
<Table.Cell class="text-left">
<Button
size="icon"
variant="outline"
class="h-8 w-8"
onclick={() => void downloadFile(file.path, file.display_name)}
>
<FileDown class="h-4 w-4" />
</Button>
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</div>
</Card.Content>
</Card.Root>
</div>