better media setup
This commit is contained in:
@@ -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>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{#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>
|
||||
<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).toLocaleString()
|
||||
? new Date(asset.capturedAt).toLocaleDateString()
|
||||
: "-"}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="font-mono text-xs">
|
||||
{asset.fileId}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</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>
|
||||
|
||||
@@ -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())),
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user