fixing sum' file logic flaws & device UI stuff stuff

This commit is contained in:
user
2026-03-01 19:29:27 +02:00
parent 48792692ff
commit 3940130dad
4 changed files with 278 additions and 134 deletions

View File

@@ -33,7 +33,7 @@
<MaxWidthWrapper cls="space-y-4">
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between gap-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-center gap-2">
<Icon icon={Smartphone} cls="h-5 w-5 text-primary" />
<Card.Title>Devices</Card.Title>
@@ -41,35 +41,37 @@
{mobileVM.devicesTotal} total
</span>
</div>
<div class="flex items-center gap-2">
<div class="grid grid-cols-2 gap-2 sm:flex sm:items-center">
<Button
variant="outline"
size="sm"
onclick={() => void filesVM.cleanupDanglingFiles()}
disabled={filesVM.cleanupLoading}
class="col-span-1"
>
<Icon
icon={Trash2}
cls={`h-4 w-4 mr-2 ${filesVM.cleanupLoading ? "animate-spin" : ""}`}
/>
Cleanup Storage
<span class="truncate">Cleanup Storage</span>
</Button>
<Button
variant="outline"
size="sm"
onclick={() => void mobileVM.refreshDevices()}
disabled={mobileVM.devicesLoading}
class="col-span-1"
>
<Icon
icon={RefreshCw}
cls={`h-4 w-4 mr-2 ${mobileVM.devicesLoading ? "animate-spin" : ""}`}
/>
Refresh
<span class="hidden sm:inline">Refresh</span>
</Button>
</div>
</div>
<div class="relative mt-3 max-w-sm">
<div class="relative mt-2 w-full sm:max-w-sm">
<Icon
icon={Search}
cls="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
@@ -92,6 +94,90 @@
No devices registered yet.
</div>
{:else}
<div class="space-y-3 md:hidden">
{#each mobileVM.devices as device (device.id)}
<div
class="rounded-lg border bg-background p-3"
role="button"
tabindex="0"
onclick={() => goto(`/devices/${device.id}`)}
onkeydown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
void goto(`/devices/${device.id}`);
}
}}
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="truncate text-sm font-medium">{device.name}</p>
<p class="text-muted-foreground truncate text-xs">
{device.externalDeviceId}
</p>
</div>
<AlertDialog.Root>
<AlertDialog.Trigger
class={buttonVariants({
variant: "destructive",
size: "sm",
})}
disabled={mobileVM.deletingDeviceId === device.id}
onclick={(e) => e.stopPropagation()}
>
<Icon icon={Trash2} cls="h-4 w-4" />
</AlertDialog.Trigger>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>
Delete device?
</AlertDialog.Title>
<AlertDialog.Description>
This deletes the device and all related SMS/media data.
Files in storage linked to this device are also removed.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
<AlertDialog.Action
onclick={async (e) => {
e.stopPropagation();
await mobileVM.deleteDevice(device.id);
}}
>
Delete
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
</div>
<div class="mt-3 grid grid-cols-2 gap-x-3 gap-y-2 text-xs">
<div>
<p class="text-muted-foreground">Manufacturer / Model</p>
<p class="truncate">{device.manufacturer} / {device.model}</p>
</div>
<div>
<p class="text-muted-foreground">Android</p>
<p>{device.androidVersion}</p>
</div>
<div>
<p class="text-muted-foreground">Created</p>
<p class="truncate">
{new Date(device.createdAt).toLocaleString()}
</p>
</div>
<div>
<p class="text-muted-foreground">Last Ping</p>
<p class="truncate">
{mobileVM.formatLastPing(device.lastPingAt)}
</p>
</div>
</div>
</div>
{/each}
</div>
<div class="hidden md:block">
<Table.Root>
<Table.Header>
<Table.Row>
@@ -174,6 +260,7 @@
{/each}
</Table.Body>
</Table.Root>
</div>
{/if}
</Card.Content>
</Card.Root>

View File

@@ -372,7 +372,9 @@
</div>
<Dialog.Root bind:open={mediaPreviewOpen}>
<Dialog.Content class="max-w-6xl p-8">
<Dialog.Content
class="h-[92vh] w-[96vw] overflow-hidden p-4 sm:max-w-[96vw] md:max-w-[94vw] lg:max-w-[90vw] xl:max-w-[86vw] 2xl:max-w-[82vw] md:p-6"
>
{#if selectedMedia}
<Dialog.Header>
<Dialog.Title
@@ -382,22 +384,30 @@
>
</Dialog.Header>
<div class="mt-2">
<div
class="bg-muted/30 mt-1 flex h-[calc(92vh-10.5rem)] items-center justify-center overflow-hidden rounded-md"
>
{#if isImageAsset(selectedMedia) && selectedMedia.r2Url}
<img
src={selectedMedia.r2Url}
alt={selectedMedia.filename || selectedMedia.fileId}
class="max-h-[80vh] w-full rounded-md object-contain"
class="h-full w-full object-contain"
/>
{:else if isVideoAsset(selectedMedia) && selectedMedia.r2Url}
<video
src={selectedMedia.r2Url}
controls
playsinline
preload="metadata"
class="h-full w-full bg-black 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"
class="flex h-full w-full items-center justify-center 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>
<p class="mt-2">Video URL unavailable for preview.</p>
</div>
</div>
{:else}
@@ -414,7 +424,7 @@
{/if}
</div>
<Dialog.Footer class="mt-3">
<Dialog.Footer class="mt-2 justify-end">
{#if selectedMedia.r2Url}
<Button
variant="outline"

View File

@@ -214,8 +214,11 @@ export class FileController {
attributes: { "app.user.id": userId },
fn: () =>
this.fileRepo
.listReferencedObjectKeysForUser(fctx, userId)
.andThen((referencedKeys) => {
.listMobileMediaReferencedObjectKeysForUser(fctx, userId)
.andThen((referencedKeys) =>
this.fileRepo
.listMobileMediaDanglingFileIdsForUser(fctx, userId)
.andThen((danglingFileIds) => {
const referencedSet = new Set(referencedKeys);
return ResultAsync.combine([
this.storageRepo.listObjectKeys(
@@ -235,25 +238,35 @@ export class FileController {
(key) => !referencedSet.has(key),
);
if (danglingKeys.length === 0) {
return okAsync({
scanned: existingStorageKeys.length,
referenced: referencedKeys.length,
dangling: 0,
deleted: 0,
});
}
const deleteStorage =
danglingKeys.length > 0
? this.storageRepo.deleteFiles(
fctx,
danglingKeys,
)
: okAsync(true);
return this.storageRepo
.deleteFiles(fctx, danglingKeys)
.map(() => ({
return deleteStorage.andThen(() => {
const deleteRows =
danglingFileIds.length > 0
? this.fileRepo.deleteFiles(
fctx,
danglingFileIds,
userId,
)
: okAsync(true);
return deleteRows.map(() => ({
scanned: existingStorageKeys.length,
referenced: referencedKeys.length,
dangling: danglingKeys.length,
deleted: danglingKeys.length,
deletedRows: danglingFileIds.length,
}));
});
});
}),
),
});
}

View File

@@ -20,7 +20,7 @@ import {
} from "@pkg/db";
import { ResultAsync, errAsync, okAsync } from "neverthrow";
import { FlowExecCtx } from "@core/flow.execution.context";
import { file, fileAccess } from "@pkg/db/schema";
import { file, fileAccess, mobileDevice, mobileMediaAsset } from "@pkg/db/schema";
import { type Err } from "@pkg/result";
import { fileErrors } from "./errors";
import { logDomainEvent } from "@pkg/logger";
@@ -381,7 +381,7 @@ export class FileRepository {
});
}
listReferencedObjectKeysForUser(
listMobileMediaReferencedObjectKeysForUser(
fctx: FlowExecCtx,
userId: string,
): ResultAsync<string[], Err> {
@@ -391,8 +391,13 @@ export class FileRepository {
objectKey: file.objectKey,
metadata: file.metadata,
})
.from(file)
.where(eq(file.userId, userId)),
.from(mobileMediaAsset)
.innerJoin(file, eq(mobileMediaAsset.fileId, file.id))
.innerJoin(
mobileDevice,
eq(mobileMediaAsset.deviceId, mobileDevice.id),
)
.where(eq(mobileDevice.ownerUserId, userId)),
(error) =>
fileErrors.getFilesFailed(
fctx,
@@ -422,6 +427,35 @@ export class FileRepository {
});
}
listMobileMediaDanglingFileIdsForUser(
fctx: FlowExecCtx,
userId: string,
): ResultAsync<string[], Err> {
return ResultAsync.fromPromise(
this.db
.select({ id: file.id })
.from(file)
.where(
and(
eq(file.userId, userId),
sql`NOT EXISTS (
SELECT 1
FROM ${mobileMediaAsset}
INNER JOIN ${mobileDevice}
ON ${mobileMediaAsset.deviceId} = ${mobileDevice.id}
WHERE ${mobileMediaAsset.fileId} = ${file.id}
AND ${mobileDevice.ownerUserId} = ${userId}
)`,
),
),
(error) =>
fileErrors.getFilesFailed(
fctx,
error instanceof Error ? error.message : String(error),
),
).map((rows) => rows.map((row) => row.id));
}
updateFileStatus(
fctx: FlowExecCtx,
fileId: string,