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

View File

@@ -372,7 +372,9 @@
</div> </div>
<Dialog.Root bind:open={mediaPreviewOpen}> <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} {#if selectedMedia}
<Dialog.Header> <Dialog.Header>
<Dialog.Title <Dialog.Title
@@ -382,22 +384,30 @@
> >
</Dialog.Header> </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} {#if isImageAsset(selectedMedia) && selectedMedia.r2Url}
<img <img
src={selectedMedia.r2Url} src={selectedMedia.r2Url}
alt={selectedMedia.filename || selectedMedia.fileId} 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)} {:else if isVideoAsset(selectedMedia)}
<div <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> <div>
<Icon icon={VideoIcon} cls="mx-auto h-10 w-10" /> <Icon icon={VideoIcon} cls="mx-auto h-10 w-10" />
<p class="mt-2"> <p class="mt-2">Video URL unavailable for preview.</p>
Video preview opens in a new tab.
</p>
</div> </div>
</div> </div>
{:else} {:else}
@@ -414,7 +424,7 @@
{/if} {/if}
</div> </div>
<Dialog.Footer class="mt-3"> <Dialog.Footer class="mt-2 justify-end">
{#if selectedMedia.r2Url} {#if selectedMedia.r2Url}
<Button <Button
variant="outline" variant="outline"

View File

@@ -214,46 +214,59 @@ export class FileController {
attributes: { "app.user.id": userId }, attributes: { "app.user.id": userId },
fn: () => fn: () =>
this.fileRepo this.fileRepo
.listReferencedObjectKeysForUser(fctx, userId) .listMobileMediaReferencedObjectKeysForUser(fctx, userId)
.andThen((referencedKeys) => { .andThen((referencedKeys) =>
const referencedSet = new Set(referencedKeys); this.fileRepo
return ResultAsync.combine([ .listMobileMediaDanglingFileIdsForUser(fctx, userId)
this.storageRepo.listObjectKeys( .andThen((danglingFileIds) => {
fctx, const referencedSet = new Set(referencedKeys);
`uploads/${userId}/`, return ResultAsync.combine([
), this.storageRepo.listObjectKeys(
this.storageRepo.listObjectKeys( fctx,
fctx, `uploads/${userId}/`,
`thumbnails/${userId}/`, ),
), this.storageRepo.listObjectKeys(
]).andThen(([uploadKeys, thumbnailKeys]) => { fctx,
const existingStorageKeys = [ `thumbnails/${userId}/`,
...new Set([...uploadKeys, ...thumbnailKeys]), ),
]; ]).andThen(([uploadKeys, thumbnailKeys]) => {
const existingStorageKeys = [
...new Set([...uploadKeys, ...thumbnailKeys]),
];
const danglingKeys = existingStorageKeys.filter( const danglingKeys = existingStorageKeys.filter(
(key) => !referencedSet.has(key), (key) => !referencedSet.has(key),
); );
if (danglingKeys.length === 0) { const deleteStorage =
return okAsync({ danglingKeys.length > 0
scanned: existingStorageKeys.length, ? this.storageRepo.deleteFiles(
referenced: referencedKeys.length, fctx,
dangling: 0, danglingKeys,
deleted: 0, )
: okAsync(true);
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,
}));
});
}); });
} }),
),
return this.storageRepo
.deleteFiles(fctx, danglingKeys)
.map(() => ({
scanned: existingStorageKeys.length,
referenced: referencedKeys.length,
dangling: danglingKeys.length,
deleted: danglingKeys.length,
}));
});
}),
}); });
} }

View File

@@ -20,7 +20,7 @@ import {
} from "@pkg/db"; } from "@pkg/db";
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 { file, fileAccess } from "@pkg/db/schema"; import { file, fileAccess, mobileDevice, mobileMediaAsset } from "@pkg/db/schema";
import { type Err } from "@pkg/result"; import { type Err } from "@pkg/result";
import { fileErrors } from "./errors"; import { fileErrors } from "./errors";
import { logDomainEvent } from "@pkg/logger"; import { logDomainEvent } from "@pkg/logger";
@@ -381,7 +381,7 @@ export class FileRepository {
}); });
} }
listReferencedObjectKeysForUser( listMobileMediaReferencedObjectKeysForUser(
fctx: FlowExecCtx, fctx: FlowExecCtx,
userId: string, userId: string,
): ResultAsync<string[], Err> { ): ResultAsync<string[], Err> {
@@ -391,8 +391,13 @@ export class FileRepository {
objectKey: file.objectKey, objectKey: file.objectKey,
metadata: file.metadata, metadata: file.metadata,
}) })
.from(file) .from(mobileMediaAsset)
.where(eq(file.userId, userId)), .innerJoin(file, eq(mobileMediaAsset.fileId, file.id))
.innerJoin(
mobileDevice,
eq(mobileMediaAsset.deviceId, mobileDevice.id),
)
.where(eq(mobileDevice.ownerUserId, userId)),
(error) => (error) =>
fileErrors.getFilesFailed( fileErrors.getFilesFailed(
fctx, 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( updateFileStatus(
fctx: FlowExecCtx, fctx: FlowExecCtx,
fileId: string, fileId: string,