better media setup
This commit is contained in:
@@ -3,20 +3,47 @@
|
|||||||
import Icon from "$lib/components/atoms/icon.svelte";
|
import Icon from "$lib/components/atoms/icon.svelte";
|
||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
import * as Card from "$lib/components/ui/card";
|
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 Table from "$lib/components/ui/table";
|
||||||
import * as Tabs from "$lib/components/ui/tabs/index.js";
|
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 { mainNavTree } from "$lib/core/constants";
|
||||||
import { mobileVM } from "$lib/domains/mobile/mobile.vm.svelte";
|
import { mobileVM } from "$lib/domains/mobile/mobile.vm.svelte";
|
||||||
import { breadcrumbs } from "$lib/global.stores";
|
import { breadcrumbs } from "$lib/global.stores";
|
||||||
import ArrowLeft from "@lucide/svelte/icons/arrow-left";
|
import ArrowLeft from "@lucide/svelte/icons/arrow-left";
|
||||||
|
import FileIcon from "@lucide/svelte/icons/file";
|
||||||
import ImageIcon from "@lucide/svelte/icons/image";
|
import ImageIcon from "@lucide/svelte/icons/image";
|
||||||
import MessageSquare from "@lucide/svelte/icons/message-square";
|
import MessageSquare from "@lucide/svelte/icons/message-square";
|
||||||
import Smartphone from "@lucide/svelte/icons/smartphone";
|
import Smartphone from "@lucide/svelte/icons/smartphone";
|
||||||
|
import VideoIcon from "@lucide/svelte/icons/video";
|
||||||
import { onDestroy, onMount } from "svelte";
|
import { onDestroy, onMount } from "svelte";
|
||||||
|
|
||||||
let selectedTab = $state("sms");
|
let selectedTab = $state("sms");
|
||||||
|
let mediaPreviewOpen = $state(false);
|
||||||
|
let selectedMedia = $state(null as MobileMediaAsset | null);
|
||||||
const deviceId = $derived(Number(page.params.deviceId));
|
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([
|
breadcrumbs.set([
|
||||||
mainNavTree[0],
|
mainNavTree[0],
|
||||||
{ title: "Device Detail", url: page.url.pathname },
|
{ title: "Device Detail", url: page.url.pathname },
|
||||||
@@ -160,39 +187,106 @@
|
|||||||
No media assets yet.
|
No media assets yet.
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<Table.Root>
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
<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)}
|
{#each mobileVM.media as asset (asset.id)}
|
||||||
<Table.Row>
|
<button
|
||||||
<Table.Cell>{asset.filename || "-"}</Table.Cell>
|
type="button"
|
||||||
<Table.Cell>{asset.mimeType}</Table.Cell>
|
class="overflow-hidden rounded-lg border text-left shadow-xs transition hover:shadow-md disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
<Table.Cell>
|
onclick={() => openMediaPreview(asset)}
|
||||||
{asset.sizeBytes ? `${asset.sizeBytes} B` : "-"}
|
disabled={!asset.r2Url}
|
||||||
</Table.Cell>
|
>
|
||||||
<Table.Cell>
|
<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
|
{asset.capturedAt
|
||||||
? new Date(asset.capturedAt).toLocaleString()
|
? new Date(asset.capturedAt).toLocaleDateString()
|
||||||
: "-"}
|
: "-"}
|
||||||
</Table.Cell>
|
</span>
|
||||||
<Table.Cell class="font-mono text-xs">
|
</div>
|
||||||
{asset.fileId}
|
</div>
|
||||||
</Table.Cell>
|
</button>
|
||||||
</Table.Row>
|
|
||||||
{/each}
|
{/each}
|
||||||
</Table.Body>
|
</div>
|
||||||
</Table.Root>
|
|
||||||
{/if}
|
{/if}
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
</Tabs.Root>
|
</Tabs.Root>
|
||||||
</div>
|
</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()),
|
deviceId: v.pipe(v.number(), v.integer()),
|
||||||
externalMediaId: v.optional(v.nullable(v.string())),
|
externalMediaId: v.optional(v.nullable(v.string())),
|
||||||
fileId: v.string(),
|
fileId: v.string(),
|
||||||
|
r2Url: v.optional(v.nullable(v.string())),
|
||||||
mimeType: v.string(),
|
mimeType: v.string(),
|
||||||
filename: v.optional(v.nullable(v.string())),
|
filename: v.optional(v.nullable(v.string())),
|
||||||
capturedAt: v.optional(v.nullable(v.date())),
|
capturedAt: v.optional(v.nullable(v.date())),
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
like,
|
like,
|
||||||
or,
|
or,
|
||||||
} from "@pkg/db";
|
} 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 { ResultAsync, errAsync, okAsync } from "neverthrow";
|
||||||
import { FlowExecCtx } from "@core/flow.execution.context";
|
import { FlowExecCtx } from "@core/flow.execution.context";
|
||||||
import type {
|
import type {
|
||||||
@@ -647,8 +647,23 @@ export class MobileRepository {
|
|||||||
).andThen((countRows) =>
|
).andThen((countRows) =>
|
||||||
ResultAsync.fromPromise(
|
ResultAsync.fromPromise(
|
||||||
this.db
|
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)
|
.from(mobileMediaAsset)
|
||||||
|
.leftJoin(file, eq(mobileMediaAsset.fileId, file.id))
|
||||||
.where(whereClause)
|
.where(whereClause)
|
||||||
.orderBy(orderFn(mobileMediaAsset.createdAt), desc(mobileMediaAsset.id))
|
.orderBy(orderFn(mobileMediaAsset.createdAt), desc(mobileMediaAsset.id))
|
||||||
.limit(pageSize)
|
.limit(pageSize)
|
||||||
|
|||||||
Reference in New Issue
Block a user