better media setup

This commit is contained in:
user
2026-03-01 18:46:49 +02:00
parent cf736095a4
commit 5d66dbceb7
3 changed files with 142 additions and 32 deletions

View File

@@ -3,20 +3,47 @@
import Icon from "$lib/components/atoms/icon.svelte";
import { Button } from "$lib/components/ui/button";
import * as Card from "$lib/components/ui/card";
import * as Dialog from "$lib/components/ui/dialog/index.js";
import * as Table from "$lib/components/ui/table";
import * as Tabs from "$lib/components/ui/tabs/index.js";
import type { MobileMediaAsset } from "@pkg/logic/domains/mobile/data";
import { mainNavTree } from "$lib/core/constants";
import { mobileVM } from "$lib/domains/mobile/mobile.vm.svelte";
import { breadcrumbs } from "$lib/global.stores";
import ArrowLeft from "@lucide/svelte/icons/arrow-left";
import FileIcon from "@lucide/svelte/icons/file";
import ImageIcon from "@lucide/svelte/icons/image";
import MessageSquare from "@lucide/svelte/icons/message-square";
import Smartphone from "@lucide/svelte/icons/smartphone";
import VideoIcon from "@lucide/svelte/icons/video";
import { onDestroy, onMount } from "svelte";
let selectedTab = $state("sms");
let mediaPreviewOpen = $state(false);
let selectedMedia = $state(null as MobileMediaAsset | null);
const deviceId = $derived(Number(page.params.deviceId));
function isImageAsset(asset: MobileMediaAsset) {
return asset.mimeType.startsWith("image/");
}
function isVideoAsset(asset: MobileMediaAsset) {
return asset.mimeType.startsWith("video/");
}
function formatSize(size: number | null | undefined) {
if (!size || size <= 0) return "-";
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`;
}
function openMediaPreview(asset: MobileMediaAsset) {
selectedMedia = asset;
mediaPreviewOpen = true;
}
breadcrumbs.set([
mainNavTree[0],
{ title: "Device Detail", url: page.url.pathname },
@@ -160,39 +187,106 @@
No media assets yet.
</div>
{:else}
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Filename</Table.Head>
<Table.Head>MIME</Table.Head>
<Table.Head>Size</Table.Head>
<Table.Head>Captured</Table.Head>
<Table.Head>File ID</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each mobileVM.media as asset (asset.id)}
<Table.Row>
<Table.Cell>{asset.filename || "-"}</Table.Cell>
<Table.Cell>{asset.mimeType}</Table.Cell>
<Table.Cell>
{asset.sizeBytes ? `${asset.sizeBytes} B` : "-"}
</Table.Cell>
<Table.Cell>
{asset.capturedAt
? new Date(asset.capturedAt).toLocaleString()
: "-"}
</Table.Cell>
<Table.Cell class="font-mono text-xs">
{asset.fileId}
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
{#each mobileVM.media as asset (asset.id)}
<button
type="button"
class="overflow-hidden rounded-lg border text-left shadow-xs transition hover:shadow-md disabled:cursor-not-allowed disabled:opacity-60"
onclick={() => openMediaPreview(asset)}
disabled={!asset.r2Url}
>
<div class="bg-muted relative aspect-square">
{#if isImageAsset(asset) && asset.r2Url}
<img
src={asset.r2Url}
alt={asset.filename || asset.fileId}
class="h-full w-full object-cover"
loading="lazy"
/>
{:else if isVideoAsset(asset)}
<div class="flex h-full w-full items-center justify-center text-muted-foreground">
<div class="text-center">
<Icon icon={VideoIcon} cls="mx-auto h-10 w-10" />
<p class="mt-2 text-xs">Video file</p>
</div>
</div>
{:else}
<div class="flex h-full w-full items-center justify-center text-muted-foreground">
<div class="text-center">
<Icon icon={FileIcon} cls="mx-auto h-10 w-10" />
<p class="mt-2 text-xs">File</p>
</div>
</div>
{/if}
</div>
<div class="space-y-1 p-3">
<p class="truncate text-sm font-medium">
{asset.filename || "Untitled"}
</p>
<p class="text-muted-foreground truncate text-xs">
{asset.mimeType}
</p>
<div class="flex items-center justify-between text-xs text-muted-foreground">
<span>{formatSize(asset.sizeBytes)}</span>
<span>
{asset.capturedAt
? new Date(asset.capturedAt).toLocaleDateString()
: "-"}
</span>
</div>
</div>
</button>
{/each}
</div>
{/if}
</Card.Content>
</Card.Root>
</Tabs.Content>
</Tabs.Root>
</div>
<Dialog.Root bind:open={mediaPreviewOpen}>
<Dialog.Content class="max-w-6xl p-8">
{#if selectedMedia}
<Dialog.Header>
<Dialog.Title>{selectedMedia.filename || "Media Preview"}</Dialog.Title>
<Dialog.Description>{selectedMedia.mimeType}</Dialog.Description>
</Dialog.Header>
<div class="mt-2">
{#if isImageAsset(selectedMedia) && selectedMedia.r2Url}
<img
src={selectedMedia.r2Url}
alt={selectedMedia.filename || selectedMedia.fileId}
class="max-h-[80vh] w-full rounded-md object-contain"
/>
{:else if isVideoAsset(selectedMedia)}
<div class="bg-muted flex h-[60vh] items-center justify-center rounded-md text-center text-sm text-muted-foreground">
<div>
<Icon icon={VideoIcon} cls="mx-auto h-10 w-10" />
<p class="mt-2">Video preview opens in a new tab.</p>
</div>
</div>
{:else}
<div class="bg-muted flex h-[60vh] items-center justify-center rounded-md text-center text-sm text-muted-foreground">
<div>
<Icon icon={FileIcon} cls="mx-auto h-10 w-10" />
<p class="mt-2">Preview unavailable for this file type.</p>
</div>
</div>
{/if}
</div>
<Dialog.Footer class="mt-3">
{#if selectedMedia.r2Url}
<Button
variant="outline"
onclick={() => window.open(selectedMedia?.r2Url, "_blank", "noopener,noreferrer")}
>
Open Raw File
</Button>
{/if}
</Dialog.Footer>
{/if}
</Dialog.Content>
</Dialog.Root>

View File

@@ -71,6 +71,7 @@ export const mobileMediaAssetSchema = v.object({
deviceId: v.pipe(v.number(), v.integer()),
externalMediaId: v.optional(v.nullable(v.string())),
fileId: v.string(),
r2Url: v.optional(v.nullable(v.string())),
mimeType: v.string(),
filename: v.optional(v.nullable(v.string())),
capturedAt: v.optional(v.nullable(v.date())),

View File

@@ -8,7 +8,7 @@ import {
like,
or,
} from "@pkg/db";
import { mobileDevice, mobileMediaAsset, mobileSMS, user } from "@pkg/db/schema";
import { file, mobileDevice, mobileMediaAsset, mobileSMS, user } from "@pkg/db/schema";
import { ResultAsync, errAsync, okAsync } from "neverthrow";
import { FlowExecCtx } from "@core/flow.execution.context";
import type {
@@ -647,8 +647,23 @@ export class MobileRepository {
).andThen((countRows) =>
ResultAsync.fromPromise(
this.db
.select()
.select({
id: mobileMediaAsset.id,
deviceId: mobileMediaAsset.deviceId,
externalMediaId: mobileMediaAsset.externalMediaId,
fileId: mobileMediaAsset.fileId,
r2Url: file.r2Url,
mimeType: mobileMediaAsset.mimeType,
filename: mobileMediaAsset.filename,
capturedAt: mobileMediaAsset.capturedAt,
sizeBytes: mobileMediaAsset.sizeBytes,
hash: mobileMediaAsset.hash,
metadata: mobileMediaAsset.metadata,
createdAt: mobileMediaAsset.createdAt,
updatedAt: mobileMediaAsset.updatedAt,
})
.from(mobileMediaAsset)
.leftJoin(file, eq(mobileMediaAsset.fileId, file.id))
.where(whereClause)
.orderBy(orderFn(mobileMediaAsset.createdAt), desc(mobileMediaAsset.id))
.limit(pageSize)