fixing sum' file logic flaws & device UI stuff stuff
This commit is contained in:
@@ -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,88 +94,173 @@
|
||||
No devices registered yet.
|
||||
</div>
|
||||
{:else}
|
||||
<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.Header>
|
||||
<Table.Body>
|
||||
{#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">
|
||||
<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}
|
||||
</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>
|
||||
</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>
|
||||
<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>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#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}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -214,46 +214,59 @@ export class FileController {
|
||||
attributes: { "app.user.id": userId },
|
||||
fn: () =>
|
||||
this.fileRepo
|
||||
.listReferencedObjectKeysForUser(fctx, userId)
|
||||
.andThen((referencedKeys) => {
|
||||
const referencedSet = new Set(referencedKeys);
|
||||
return ResultAsync.combine([
|
||||
this.storageRepo.listObjectKeys(
|
||||
fctx,
|
||||
`uploads/${userId}/`,
|
||||
),
|
||||
this.storageRepo.listObjectKeys(
|
||||
fctx,
|
||||
`thumbnails/${userId}/`,
|
||||
),
|
||||
]).andThen(([uploadKeys, thumbnailKeys]) => {
|
||||
const existingStorageKeys = [
|
||||
...new Set([...uploadKeys, ...thumbnailKeys]),
|
||||
];
|
||||
.listMobileMediaReferencedObjectKeysForUser(fctx, userId)
|
||||
.andThen((referencedKeys) =>
|
||||
this.fileRepo
|
||||
.listMobileMediaDanglingFileIdsForUser(fctx, userId)
|
||||
.andThen((danglingFileIds) => {
|
||||
const referencedSet = new Set(referencedKeys);
|
||||
return ResultAsync.combine([
|
||||
this.storageRepo.listObjectKeys(
|
||||
fctx,
|
||||
`uploads/${userId}/`,
|
||||
),
|
||||
this.storageRepo.listObjectKeys(
|
||||
fctx,
|
||||
`thumbnails/${userId}/`,
|
||||
),
|
||||
]).andThen(([uploadKeys, thumbnailKeys]) => {
|
||||
const existingStorageKeys = [
|
||||
...new Set([...uploadKeys, ...thumbnailKeys]),
|
||||
];
|
||||
|
||||
const danglingKeys = existingStorageKeys.filter(
|
||||
(key) => !referencedSet.has(key),
|
||||
);
|
||||
const danglingKeys = existingStorageKeys.filter(
|
||||
(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 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,
|
||||
}));
|
||||
});
|
||||
}),
|
||||
}),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user