proper asset creation/deletion, also raw file view in admin as well
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import LayoutDashboard from "@lucide/svelte/icons/layout-dashboard";
|
||||
import FileArchive from "@lucide/svelte/icons/file-archive";
|
||||
import Smartphone from "@lucide/svelte/icons/smartphone";
|
||||
import { BellRingIcon, UsersIcon } from "@lucide/svelte";
|
||||
import UserCircle from "~icons/lucide/user-circle";
|
||||
@@ -31,6 +32,11 @@ export const mainNavTree = [
|
||||
url: "/devices",
|
||||
icon: Smartphone,
|
||||
},
|
||||
{
|
||||
title: "Files",
|
||||
url: "/files",
|
||||
icon: FileArchive,
|
||||
},
|
||||
] as AppSidebarItem[];
|
||||
|
||||
export const secondaryNavTree = [
|
||||
|
||||
48
apps/main/src/lib/domains/files/files.remote.ts
Normal file
48
apps/main/src/lib/domains/files/files.remote.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { getFileController } from "@pkg/logic/domains/files/controller";
|
||||
import {
|
||||
getFlowExecCtxForRemoteFuncs,
|
||||
unauthorized,
|
||||
} from "$lib/core/server.utils";
|
||||
import { getRequestEvent, query } from "$app/server";
|
||||
import * as v from "valibot";
|
||||
|
||||
const fc = getFileController();
|
||||
|
||||
const filesPaginationSchema = v.object({
|
||||
page: v.pipe(v.number(), v.integer()),
|
||||
pageSize: v.pipe(v.number(), v.integer()),
|
||||
sortBy: v.optional(v.string()),
|
||||
sortOrder: v.optional(v.picklist(["asc", "desc"])),
|
||||
});
|
||||
|
||||
const getFilesInputSchema = v.object({
|
||||
search: v.optional(v.string()),
|
||||
mimeType: v.optional(v.string()),
|
||||
status: v.optional(v.string()),
|
||||
visibility: v.optional(v.string()),
|
||||
pagination: filesPaginationSchema,
|
||||
});
|
||||
|
||||
export const getFilesSQ = query(getFilesInputSchema, async (input) => {
|
||||
const event = getRequestEvent();
|
||||
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
|
||||
if (!fctx.userId) {
|
||||
return unauthorized(fctx);
|
||||
}
|
||||
|
||||
const res = await fc.getFiles(
|
||||
fctx,
|
||||
{
|
||||
userId: fctx.userId,
|
||||
search: input.search,
|
||||
mimeType: input.mimeType,
|
||||
status: input.status,
|
||||
visibility: input.visibility,
|
||||
},
|
||||
input.pagination,
|
||||
);
|
||||
|
||||
return res.isOk()
|
||||
? { data: res.value, error: null }
|
||||
: { data: null, error: res.error };
|
||||
});
|
||||
60
apps/main/src/lib/domains/files/files.vm.svelte.ts
Normal file
60
apps/main/src/lib/domains/files/files.vm.svelte.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { File } from "@pkg/logic/domains/files/data";
|
||||
import { getFilesSQ } from "./files.remote";
|
||||
import { toast } from "svelte-sonner";
|
||||
|
||||
class FilesViewModel {
|
||||
files = $state([] as File[]);
|
||||
loading = $state(false);
|
||||
|
||||
search = $state("");
|
||||
page = $state(1);
|
||||
pageSize = $state(25);
|
||||
total = $state(0);
|
||||
totalPages = $state(0);
|
||||
sortBy = $state("createdAt");
|
||||
sortOrder = $state("desc" as "asc" | "desc");
|
||||
|
||||
async fetchFiles() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const result = await getFilesSQ({
|
||||
search: this.search || undefined,
|
||||
pagination: {
|
||||
page: this.page,
|
||||
pageSize: this.pageSize,
|
||||
sortBy: this.sortBy,
|
||||
sortOrder: this.sortOrder,
|
||||
},
|
||||
});
|
||||
|
||||
if (result?.error || !result?.data) {
|
||||
toast.error(result?.error?.message || "Failed to load files", {
|
||||
description: result?.error?.description || "Please try again later",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.files = result.data.data as File[];
|
||||
this.total = result.data.total;
|
||||
this.totalPages = result.data.totalPages;
|
||||
} catch (error) {
|
||||
toast.error("Failed to load files", {
|
||||
description:
|
||||
error instanceof Error ? error.message : "Please try again later",
|
||||
});
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
formatSize(size: number) {
|
||||
if (size < 1024) return `${size} B`;
|
||||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
||||
if (size < 1024 * 1024 * 1024) {
|
||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
return `${(size / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||
}
|
||||
}
|
||||
|
||||
export const filesVM = new FilesViewModel();
|
||||
115
apps/main/src/routes/(main)/files/+page.svelte
Normal file
115
apps/main/src/routes/(main)/files/+page.svelte
Normal file
@@ -0,0 +1,115 @@
|
||||
<script lang="ts">
|
||||
import Icon from "$lib/components/atoms/icon.svelte";
|
||||
import MaxWidthWrapper from "$lib/components/molecules/max-width-wrapper.svelte";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import * as Card from "$lib/components/ui/card";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import * as Table from "$lib/components/ui/table";
|
||||
import { mainNavTree } from "$lib/core/constants";
|
||||
import { filesVM } from "$lib/domains/files/files.vm.svelte";
|
||||
import { breadcrumbs } from "$lib/global.stores";
|
||||
import FileArchive from "@lucide/svelte/icons/file-archive";
|
||||
import RefreshCw from "@lucide/svelte/icons/refresh-cw";
|
||||
import Search from "@lucide/svelte/icons/search";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
const filesNavItem = mainNavTree.find((item) => item.url === "/files");
|
||||
breadcrumbs.set(filesNavItem ? [filesNavItem] : [{ title: "Files", url: "/files" }]);
|
||||
|
||||
onMount(async () => {
|
||||
await filesVM.fetchFiles();
|
||||
});
|
||||
</script>
|
||||
|
||||
<MaxWidthWrapper cls="space-y-4">
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon icon={FileArchive} cls="h-5 w-5 text-primary" />
|
||||
<Card.Title>Files</Card.Title>
|
||||
<span class="text-muted-foreground text-xs">
|
||||
{filesVM.total} total
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => void filesVM.fetchFiles()}
|
||||
disabled={filesVM.loading}
|
||||
>
|
||||
<Icon
|
||||
icon={RefreshCw}
|
||||
cls={`h-4 w-4 mr-2 ${filesVM.loading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="relative mt-3 max-w-sm">
|
||||
<Icon
|
||||
icon={Search}
|
||||
cls="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
class="pl-10"
|
||||
placeholder="Search by filename..."
|
||||
bind:value={filesVM.search}
|
||||
oninput={() => {
|
||||
filesVM.page = 1;
|
||||
void filesVM.fetchFiles();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Card.Header>
|
||||
|
||||
<Card.Content>
|
||||
{#if !filesVM.loading && filesVM.files.length === 0}
|
||||
<div class="py-10 text-center text-sm text-muted-foreground">
|
||||
No files found.
|
||||
</div>
|
||||
{:else}
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>File</Table.Head>
|
||||
<Table.Head>MIME</Table.Head>
|
||||
<Table.Head>Size</Table.Head>
|
||||
<Table.Head>Status</Table.Head>
|
||||
<Table.Head>Uploaded</Table.Head>
|
||||
<Table.Head>R2 URL</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each filesVM.files as item (item.id)}
|
||||
<Table.Row>
|
||||
<Table.Cell>
|
||||
<div class="font-medium">{item.originalName}</div>
|
||||
<div class="font-mono text-xs text-muted-foreground">
|
||||
{item.id}
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell>{item.mimeType}</Table.Cell>
|
||||
<Table.Cell>{filesVM.formatSize(item.size)}</Table.Cell>
|
||||
<Table.Cell>{item.status}</Table.Cell>
|
||||
<Table.Cell>
|
||||
{new Date(item.uploadedAt).toLocaleString()}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<a
|
||||
href={item.r2Url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="text-xs text-primary underline"
|
||||
>
|
||||
Open
|
||||
</a>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</MaxWidthWrapper>
|
||||
Reference in New Issue
Block a user