Slave/upload ui #8
@@ -0,0 +1,28 @@
|
|||||||
|
# Upload flow PR plan
|
||||||
|
|
||||||
|
## Phase 1 — Configure UI (done)
|
||||||
|
|
||||||
|
- [x] Split upload page: dropzone, queue, metadata inspector, configure modal
|
||||||
|
- [x] Batch settings form (ZK / hotlink, expiry, max views, ZK password)
|
||||||
|
- [x] Preset → API helpers (`resolveUploadBatchExpiresAt`, `resolveUploadBatchMaxViews`, ZK prepare options builder)
|
||||||
|
- [x] Modal validation on Upload; batch settings summary after confirm
|
||||||
|
|
||||||
|
## Phase 2 — Upload execution + dual queue status (in progress)
|
||||||
|
|
||||||
|
- [ ] **Separate upload state** from metadata (`UploadQueueEntry` per file key); metadata column unchanged in `UploadQueueList`
|
||||||
|
- [ ] Show upload column once a batch upload has started (pending / uploading / uploaded / failed / skipped)
|
||||||
|
- [ ] `onBatchUpload`: stripped blob → encrypt → `useUploadStore` POST (ZK + hotlink)
|
||||||
|
- [ ] Share URLs via `buildZkViewerPath` / `buildHotlinkImagePath` (+ optional absolute URL from `VITE_CLEARNET_DOMAIN`)
|
||||||
|
- [ ] Disable dropzone / configure while batch upload runs; clear upload state on new pick
|
||||||
|
|
||||||
|
## Phase 3 — Hardening (follow-up or tail of PR)
|
||||||
|
|
||||||
|
- [ ] ALTCHA: fetch challenge + solve, pass `x-altcha` on upload (frontend not wired yet; backend may allow when disabled)
|
||||||
|
- [ ] Copy-to-clipboard for share links; optional delete token display
|
||||||
|
- [ ] Modal Esc / backdrop close
|
||||||
|
- [ ] Single validation gate (modal vs builder)
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Viewer / delete pages
|
||||||
|
- E2E tests against docker-compose
|
||||||
Generated
+7
@@ -12,6 +12,7 @@
|
|||||||
"@tailwindcss/vite": "^4.3.3",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"@uswriting/exiftool": "1.0.9",
|
"@uswriting/exiftool": "1.0.9",
|
||||||
"axios": "^1.20.0",
|
"axios": "^1.20.0",
|
||||||
|
"dayjs": "^1.11.23",
|
||||||
"hash-wasm": "^4.12.0",
|
"hash-wasm": "^4.12.0",
|
||||||
"pinia": "^4.0.3",
|
"pinia": "^4.0.3",
|
||||||
"pretty-bytes": "^7.2.0",
|
"pretty-bytes": "^7.2.0",
|
||||||
@@ -2199,6 +2200,12 @@
|
|||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/dayjs": {
|
||||||
|
"version": "1.11.23",
|
||||||
|
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz",
|
||||||
|
"integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"@tailwindcss/vite": "^4.3.3",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"@uswriting/exiftool": "1.0.9",
|
"@uswriting/exiftool": "1.0.9",
|
||||||
"axios": "^1.20.0",
|
"axios": "^1.20.0",
|
||||||
|
"dayjs": "^1.11.23",
|
||||||
"hash-wasm": "^4.12.0",
|
"hash-wasm": "^4.12.0",
|
||||||
"pinia": "^4.0.3",
|
"pinia": "^4.0.3",
|
||||||
"pretty-bytes": "^7.2.0",
|
"pretty-bytes": "^7.2.0",
|
||||||
|
|||||||
Vendored
+2
@@ -14,7 +14,9 @@ declare module 'vue' {
|
|||||||
MetadataInspector: typeof import('./components/upload/MetadataInspector.vue')['default']
|
MetadataInspector: typeof import('./components/upload/MetadataInspector.vue')['default']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
|
UploadConfigureModal: typeof import('./components/upload/UploadConfigureModal.vue')['default']
|
||||||
UploadDropzone: typeof import('./components/upload/UploadDropzone.vue')['default']
|
UploadDropzone: typeof import('./components/upload/UploadDropzone.vue')['default']
|
||||||
UploadQueueList: typeof import('./components/upload/UploadQueueList.vue')['default']
|
UploadQueueList: typeof import('./components/upload/UploadQueueList.vue')['default']
|
||||||
|
UploadSettingsPanel: typeof import('./components/upload/UploadSettingsPanel.vue')['default']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="flex min-h-48 flex-col rounded-lg border border-border bg-card" aria-live="polite">
|
<section class="flex min-h-48 flex-col rounded-lg border border-border bg-card" aria-live="polite">
|
||||||
<header class="border-b border-border px-4 py-3">
|
<header class="border-b border-border px-4 py-3">
|
||||||
<h2 class="text-sm font-medium text-card-foreground">Metadata</h2>
|
<h2 class="text-sm font-medium text-card-foreground">Metadata Inspector</h2>
|
||||||
<p v-if="fileName" class="mt-0.5 truncate text-xs text-muted-foreground">{{ fileName }}</p>
|
<p v-if="fileName" class="mt-0.5 truncate text-xs text-muted-foreground">{{ fileName }}</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="open" class="fixed inset-0 z-50 flex items-end justify-center bg-black/50 p-4 sm:items-center">
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="upload-configure-title"
|
||||||
|
class="flex w-full max-w-lg flex-col rounded-lg border border-border bg-card shadow-lg"
|
||||||
|
>
|
||||||
|
<header class="border-b border-border px-4 py-3">
|
||||||
|
<h2 id="upload-configure-title" class="text-base font-medium text-card-foreground">
|
||||||
|
Configure upload
|
||||||
|
</h2>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">Settings apply to all chosen images.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="px-4 py-4">
|
||||||
|
<UploadSettingsPanel v-model="settings" />
|
||||||
|
<p v-if="uploadValidationError" class="mt-3 text-sm text-destructive">
|
||||||
|
{{ uploadValidationError }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="flex flex-col-reverse gap-2 border-t border-border px-4 py-3 sm:flex-row sm:justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md border border-border px-4 py-2 text-sm font-medium transition-colors hover:bg-muted/60"
|
||||||
|
@click="close"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:opacity-90"
|
||||||
|
@click="onUploadClick"
|
||||||
|
>
|
||||||
|
Upload
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
import type { UploadBatchSettingsForm } from '@/types/upload/UploadBatchSettingsForm';
|
||||||
|
import { validateUploadBatchSettings } from '@/utils/upload/validateUploadBatchSettings';
|
||||||
|
|
||||||
|
const open = defineModel<boolean>('open', { required: true });
|
||||||
|
|
||||||
|
const settings = defineModel<UploadBatchSettingsForm>('settings', { required: true });
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
upload: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const uploadValidationError = ref<string | null>(null);
|
||||||
|
|
||||||
|
watch(open, isOpen => {
|
||||||
|
if (!isOpen) {
|
||||||
|
uploadValidationError.value = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const close = (): void => {
|
||||||
|
open.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUploadClick = (): void => {
|
||||||
|
const error = validateUploadBatchSettings(settings.value);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
uploadValidationError.value = error;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadValidationError.value = null;
|
||||||
|
|
||||||
|
emit('upload');
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-5">
|
||||||
|
<fieldset class="space-y-3">
|
||||||
|
<legend class="text-sm font-medium text-foreground">Share mode</legend>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<label
|
||||||
|
class="flex cursor-pointer gap-3 rounded-md border border-border p-3 has-checked:border-primary has-checked:bg-muted/40"
|
||||||
|
>
|
||||||
|
<input v-model="settings.mode" class="mt-0.5" type="radio" name="upload-mode" value="zk" />
|
||||||
|
<span class="min-w-0 flex-1">
|
||||||
|
<span class="text-sm font-medium">Zero knowledge</span>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
|
Images are E2E encrypted. The server has no technical ability to decrypt them. Having the link
|
||||||
|
gives permission to view the image. You can additionally password-protect the upload so the
|
||||||
|
link alone is not enough. Great for private sharing, but doesn't work with BBCode, HTML,
|
||||||
|
etc.
|
||||||
|
</p>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label
|
||||||
|
class="flex cursor-pointer gap-3 rounded-md border border-border p-3 has-checked:border-primary has-checked:bg-muted/40"
|
||||||
|
>
|
||||||
|
<input v-model="settings.mode" class="mt-0.5" type="radio" name="upload-mode" value="hotlink" />
|
||||||
|
<span class="min-w-0 flex-1">
|
||||||
|
<span class="text-sm font-medium">Hotlink</span>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
|
Still encrypted at rest, but the server decrypts on the fly. Works with BBCode, HTML,
|
||||||
|
etc.
|
||||||
|
Encryption here serves mainly as
|
||||||
|
<span class="whitespace-nowrap" aria-describedby="crypto-shred-footnote">
|
||||||
|
<span class="font-bold">crypto-shred</span
|
||||||
|
><sup class="font-bold text-[0.7em] text-muted-foreground" aria-hidden="true"
|
||||||
|
>1</sup
|
||||||
|
> </span
|
||||||
|
>, because hotlink images are intended to be public.
|
||||||
|
</p>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p id="crypto-shred-footnote" class="text-xs italic leading-snug text-muted-foreground">
|
||||||
|
<sup class="mr-1 font-bold not-italic text-[0.85em]">1</sup>
|
||||||
|
Hard drives do not guarantee that deleted files cannot be recovered. Crypto-shred consists of storing
|
||||||
|
data as encrypted blobs, so recovered leftovers are useless without the decryption key, which is never
|
||||||
|
stored on the server.
|
||||||
|
</p>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
|
<label class="flex flex-col gap-1.5 text-sm">
|
||||||
|
<span class="font-medium">Expiry</span>
|
||||||
|
<select
|
||||||
|
v-model="settings.expiryPreset"
|
||||||
|
class="rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option v-for="option in expiryOptions" :key="option.value" :value="option.value">
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-1.5 text-sm">
|
||||||
|
<span class="font-medium">Max views</span>
|
||||||
|
<select
|
||||||
|
v-model="settings.maxViewsPreset"
|
||||||
|
class="rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option v-for="option in maxViewsOptions" :key="option.value" :value="option.value">
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="settings.mode === 'zk'" class="space-y-3 rounded-md border border-border p-3">
|
||||||
|
<label class="flex cursor-pointer items-center gap-2 text-sm">
|
||||||
|
<input v-model="settings.passwordEnabled" type="checkbox" class="rounded border-input" />
|
||||||
|
<span class="font-medium">Set password</span>
|
||||||
|
</label>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Requires the link and this password to decrypt. Use when the link alone should not be enough.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<template v-if="settings.passwordEnabled">
|
||||||
|
<label class="flex flex-col gap-1.5 text-sm">
|
||||||
|
<span>Password</span>
|
||||||
|
<input
|
||||||
|
v-model="settings.password"
|
||||||
|
type="password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
class="rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { config } from '@/config';
|
||||||
|
import type { UploadBatchSettingsForm } from '@/types/upload/UploadBatchSettingsForm';
|
||||||
|
import type { UploadExpiryPreset } from '@/types/upload/UploadExpiryPreset';
|
||||||
|
import type { UploadMaxViewsPreset } from '@/types/upload/UploadMaxViewsPreset';
|
||||||
|
|
||||||
|
const settings = defineModel<UploadBatchSettingsForm>({ required: true });
|
||||||
|
|
||||||
|
const { maxViewsMax } = config.validation.upload;
|
||||||
|
|
||||||
|
const expiryOptions: { value: UploadExpiryPreset; label: string }[] = [
|
||||||
|
{ value: 'never', label: 'Never' },
|
||||||
|
{ value: '1h', label: '1 hour' },
|
||||||
|
{ value: '24h', label: '24 hours' },
|
||||||
|
{ value: '7d', label: '7 days' },
|
||||||
|
{ value: '30d', label: '30 days' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const maxViewsOptions = computed(() => {
|
||||||
|
const options: { value: UploadMaxViewsPreset; label: string; numeric: number | null }[] = [
|
||||||
|
{ value: 'unlimited', label: 'Unlimited', numeric: null },
|
||||||
|
{ value: '1', label: '1- Burn after read', numeric: 1 },
|
||||||
|
{ value: '10', label: '10', numeric: 10 },
|
||||||
|
{ value: '100', label: '100', numeric: 100 },
|
||||||
|
{ value: '1000', label: '1000', numeric: 1000 },
|
||||||
|
{ value: '10000', label: '10000', numeric: 10000 }
|
||||||
|
];
|
||||||
|
|
||||||
|
return options.filter(option => option.numeric === null || option.numeric <= maxViewsMax);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { UploadExpiryPreset } from '@/types/upload/UploadExpiryPreset';
|
||||||
|
import type { UploadMaxViewsPreset } from '@/types/upload/UploadMaxViewsPreset';
|
||||||
|
import type { UploadMode } from '@/types/upload/UploadMode';
|
||||||
|
|
||||||
|
export type UploadBatchSettingsForm = {
|
||||||
|
mode: UploadMode;
|
||||||
|
expiryPreset: UploadExpiryPreset;
|
||||||
|
maxViewsPreset: UploadMaxViewsPreset;
|
||||||
|
passwordEnabled: boolean;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import type { ManipulateType } from 'dayjs';
|
||||||
|
|
||||||
|
export type UploadExpiryOffset = {
|
||||||
|
amount: number;
|
||||||
|
unit: ManipulateType;
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export type UploadExpiryPreset = 'never' | '1h' | '24h' | '7d' | '30d';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export type UploadMaxViewsPreset = 'unlimited' | '1' | '10' | '100' | '1000' | '10000';
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import type { ZkUploadPrepareOptions } from '@/types/upload/ZkUploadPrepareOptions';
|
||||||
|
|
||||||
|
export type ZkUploadPrepareOptionsResult =
|
||||||
|
| { ok: true; options: ZkUploadPrepareOptions }
|
||||||
|
| { ok: false; error: string };
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { UploadBatchSettingsForm } from '@/types/upload/UploadBatchSettingsForm';
|
||||||
|
import { buildZkUploadPrepareOptionsFromBatchSettings } from '@/utils/upload/buildZkUploadPrepareOptionsFromBatchSettings';
|
||||||
|
|
||||||
|
const baseSettings: UploadBatchSettingsForm = {
|
||||||
|
mode: 'zk',
|
||||||
|
expiryPreset: 'never',
|
||||||
|
maxViewsPreset: 'unlimited',
|
||||||
|
passwordEnabled: false,
|
||||||
|
password: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('buildZkUploadPrepareOptionsFromBatchSettings', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2026-06-01T00:00:00.000Z'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds non-password options', () => {
|
||||||
|
const settings = {
|
||||||
|
...baseSettings,
|
||||||
|
expiryPreset: '24h' as const,
|
||||||
|
maxViewsPreset: '10' as const
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = buildZkUploadPrepareOptionsFromBatchSettings(settings);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
ok: true,
|
||||||
|
options: {
|
||||||
|
expiresAt: '2026-06-02T00:00:00.000Z',
|
||||||
|
maxViews: 10,
|
||||||
|
passwordProtected: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds password-protected options', () => {
|
||||||
|
const settings = {
|
||||||
|
...baseSettings,
|
||||||
|
passwordEnabled: true,
|
||||||
|
password: 'batch-secret'
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = buildZkUploadPrepareOptionsFromBatchSettings(settings);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
ok: true,
|
||||||
|
options: {
|
||||||
|
expiresAt: null,
|
||||||
|
maxViews: null,
|
||||||
|
passwordProtected: true,
|
||||||
|
password: 'batch-secret'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns validation errors', () => {
|
||||||
|
const settings = {
|
||||||
|
...baseSettings,
|
||||||
|
passwordEnabled: true,
|
||||||
|
password: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(buildZkUploadPrepareOptionsFromBatchSettings(settings)).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: 'Enter a password or turn off password protection.'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { UploadBatchSettingsForm } from '@/types/upload/UploadBatchSettingsForm';
|
||||||
|
import type { ZkUploadPrepareOptionsResult } from '@/types/upload/ZkUploadPrepareOptionsResult';
|
||||||
|
import { resolveUploadBatchExpiresAt } from '@/utils/upload/resolveUploadBatchExpiresAt';
|
||||||
|
import { resolveUploadBatchMaxViews } from '@/utils/upload/resolveUploadBatchMaxViews';
|
||||||
|
import { validateUploadBatchSettings } from '@/utils/upload/validateUploadBatchSettings';
|
||||||
|
|
||||||
|
export const buildZkUploadPrepareOptionsFromBatchSettings = (
|
||||||
|
settings: UploadBatchSettingsForm
|
||||||
|
): ZkUploadPrepareOptionsResult => {
|
||||||
|
const validationError = validateUploadBatchSettings(settings);
|
||||||
|
|
||||||
|
if (validationError) {
|
||||||
|
return { ok: false, error: validationError };
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadOptions = {
|
||||||
|
expiresAt: resolveUploadBatchExpiresAt(settings.expiryPreset),
|
||||||
|
maxViews: resolveUploadBatchMaxViews(settings.maxViewsPreset)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (settings.passwordEnabled) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
options: {
|
||||||
|
...uploadOptions,
|
||||||
|
passwordProtected: true,
|
||||||
|
password: settings.password
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
options: {
|
||||||
|
...uploadOptions,
|
||||||
|
passwordProtected: false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { UploadBatchSettingsForm } from '@/types/upload/UploadBatchSettingsForm';
|
||||||
|
import { formatUploadBatchSettingsSummary } from '@/utils/upload/formatUploadBatchSettingsSummary';
|
||||||
|
|
||||||
|
const baseSettings: UploadBatchSettingsForm = {
|
||||||
|
mode: 'zk',
|
||||||
|
expiryPreset: 'never',
|
||||||
|
maxViewsPreset: 'unlimited',
|
||||||
|
passwordEnabled: false,
|
||||||
|
password: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('formatUploadBatchSettingsSummary', () => {
|
||||||
|
it('formats default zk settings', () => {
|
||||||
|
expect(formatUploadBatchSettingsSummary(baseSettings)).toBe(
|
||||||
|
'Zero knowledge · Never expires · Unlimited views'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes password when enabled', () => {
|
||||||
|
const settings = {
|
||||||
|
...baseSettings,
|
||||||
|
passwordEnabled: true,
|
||||||
|
password: 'secret'
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(formatUploadBatchSettingsSummary(settings)).toBe(
|
||||||
|
'Zero knowledge · Never expires · Unlimited views · Password protected'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats hotlink mode', () => {
|
||||||
|
const settings = {
|
||||||
|
...baseSettings,
|
||||||
|
mode: 'hotlink' as const,
|
||||||
|
expiryPreset: '24h' as const,
|
||||||
|
maxViewsPreset: '10' as const
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(formatUploadBatchSettingsSummary(settings)).toBe('Hotlink · 24 hours · 10 views');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { UploadBatchSettingsForm } from '@/types/upload/UploadBatchSettingsForm';
|
||||||
|
|
||||||
|
const EXPIRY_LABELS: Record<UploadBatchSettingsForm['expiryPreset'], string> = {
|
||||||
|
never: 'Never expires',
|
||||||
|
'1h': '1 hour',
|
||||||
|
'24h': '24 hours',
|
||||||
|
'7d': '7 days',
|
||||||
|
'30d': '30 days'
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_VIEWS_LABELS: Record<UploadBatchSettingsForm['maxViewsPreset'], string> = {
|
||||||
|
unlimited: 'Unlimited views',
|
||||||
|
'1': 'Burn after read',
|
||||||
|
'10': '10 views',
|
||||||
|
'100': '100 views',
|
||||||
|
'1000': '1,000 views',
|
||||||
|
'10000': '10,000 views'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatUploadBatchSettingsSummary = (settings: UploadBatchSettingsForm): string => {
|
||||||
|
const modeLabel = settings.mode === 'zk' ? 'Zero knowledge' : 'Hotlink';
|
||||||
|
const parts = [modeLabel, EXPIRY_LABELS[settings.expiryPreset], MAX_VIEWS_LABELS[settings.maxViewsPreset]];
|
||||||
|
|
||||||
|
if (settings.mode === 'zk' && settings.passwordEnabled) {
|
||||||
|
parts.push('Password protected');
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join(' · ');
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { resolveUploadBatchExpiresAt } from '@/utils/upload/resolveUploadBatchExpiresAt';
|
||||||
|
|
||||||
|
describe('resolveUploadBatchExpiresAt', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2026-01-01T12:00:00.000Z'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for never', () => {
|
||||||
|
expect(resolveUploadBatchExpiresAt('never')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds preset offsets from the reference time', () => {
|
||||||
|
expect(resolveUploadBatchExpiresAt('1h')).toBe('2026-01-01T13:00:00.000Z');
|
||||||
|
expect(resolveUploadBatchExpiresAt('24h')).toBe('2026-01-02T12:00:00.000Z');
|
||||||
|
expect(resolveUploadBatchExpiresAt('7d')).toBe('2026-01-08T12:00:00.000Z');
|
||||||
|
expect(resolveUploadBatchExpiresAt('30d')).toBe('2026-01-31T12:00:00.000Z');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import dayjs from 'dayjs';
|
||||||
|
import type { UploadExpiryPreset } from '@/types/upload/UploadExpiryPreset';
|
||||||
|
import type { UploadExpiryOffset } from '@/types/upload/UploadExpiryOffset';
|
||||||
|
|
||||||
|
const EXPIRY_OFFSET: Record<Exclude<UploadExpiryPreset, 'never'>, UploadExpiryOffset> = {
|
||||||
|
'1h': { amount: 1, unit: 'hour' },
|
||||||
|
'24h': { amount: 24, unit: 'hour' },
|
||||||
|
'7d': { amount: 7, unit: 'day' },
|
||||||
|
'30d': { amount: 30, unit: 'day' }
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveUploadBatchExpiresAt = (preset: UploadExpiryPreset): string | null => {
|
||||||
|
if (preset === 'never') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { amount, unit } = EXPIRY_OFFSET[preset];
|
||||||
|
|
||||||
|
return dayjs().add(amount, unit).toISOString();
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { resolveUploadBatchMaxViews } from '@/utils/upload/resolveUploadBatchMaxViews';
|
||||||
|
|
||||||
|
describe('resolveUploadBatchMaxViews', () => {
|
||||||
|
it('maps presets to API values', () => {
|
||||||
|
expect(resolveUploadBatchMaxViews('unlimited')).toBeNull();
|
||||||
|
expect(resolveUploadBatchMaxViews('1')).toBe(1);
|
||||||
|
expect(resolveUploadBatchMaxViews('10')).toBe(10);
|
||||||
|
expect(resolveUploadBatchMaxViews('100')).toBe(100);
|
||||||
|
expect(resolveUploadBatchMaxViews('1000')).toBe(1000);
|
||||||
|
expect(resolveUploadBatchMaxViews('10000')).toBe(10000);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { UploadMaxViewsPreset } from '@/types/upload/UploadMaxViewsPreset';
|
||||||
|
|
||||||
|
export const resolveUploadBatchMaxViews = (preset: UploadMaxViewsPreset): number | null => {
|
||||||
|
if (preset === 'unlimited') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseInt(preset, 10);
|
||||||
|
};
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { UploadBatchSettingsForm } from '@/types/upload/UploadBatchSettingsForm';
|
||||||
|
import { validateUploadBatchSettings } from '@/utils/upload/validateUploadBatchSettings';
|
||||||
|
|
||||||
|
const baseSettings: UploadBatchSettingsForm = {
|
||||||
|
mode: 'zk',
|
||||||
|
expiryPreset: 'never',
|
||||||
|
maxViewsPreset: 'unlimited',
|
||||||
|
passwordEnabled: false,
|
||||||
|
password: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('validateUploadBatchSettings', () => {
|
||||||
|
it('allows hotlink mode without password', () => {
|
||||||
|
const settings = {
|
||||||
|
...baseSettings,
|
||||||
|
mode: 'hotlink' as const,
|
||||||
|
passwordEnabled: true,
|
||||||
|
password: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(validateUploadBatchSettings(settings)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires password when zk protection is enabled', () => {
|
||||||
|
const settings = {
|
||||||
|
...baseSettings,
|
||||||
|
passwordEnabled: true,
|
||||||
|
password: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(validateUploadBatchSettings(settings)).toBe('Enter a password or turn off password protection.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows zk upload when password is set', () => {
|
||||||
|
const settings = {
|
||||||
|
...baseSettings,
|
||||||
|
passwordEnabled: true,
|
||||||
|
password: 'secret'
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(validateUploadBatchSettings(settings)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { UploadBatchSettingsForm } from '@/types/upload/UploadBatchSettingsForm';
|
||||||
|
|
||||||
|
export const validateUploadBatchSettings = (settings: UploadBatchSettingsForm): string | null => {
|
||||||
|
if (settings.mode !== 'zk' || !settings.passwordEnabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.password.length === 0) {
|
||||||
|
return 'Enter a password or turn off password protection.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
@@ -9,16 +9,36 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UploadDropzone
|
<div class="flex flex-col gap-2">
|
||||||
ref="dropzoneRef"
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
:disabled="loadingMetadata"
|
<UploadDropzone ref="dropzoneRef" :disabled="loadingMetadata" @pick="onFilesPicked" />
|
||||||
:selection-error="selectionError"
|
|
||||||
@pick="onFilesPicked"
|
<button
|
||||||
/>
|
type="button"
|
||||||
|
class="inline-flex shrink-0 items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60 sm:self-center"
|
||||||
|
:disabled="!canOpenConfigure"
|
||||||
|
@click="configureModalOpen = true"
|
||||||
|
>
|
||||||
|
Configure & upload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="selectionError" class="text-sm text-destructive">{{ selectionError }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="confirmedBatchSummary" class="text-center text-xs text-muted-foreground" aria-live="polite">
|
||||||
|
{{ confirmedBatchSummary }}
|
||||||
|
</p>
|
||||||
|
|
||||||
<UploadQueueList :rows="queueRows" :selected-file="selectedFile" @select="selectedFile = $event" />
|
<UploadQueueList :rows="queueRows" :selected-file="selectedFile" @select="selectedFile = $event" />
|
||||||
|
|
||||||
<MetadataInspector :file-name="selectedFile?.name ?? null" :entry="selectedPreview" />
|
<MetadataInspector :file-name="selectedFile?.name ?? null" :entry="selectedPreview" />
|
||||||
|
|
||||||
|
<UploadConfigureModal
|
||||||
|
v-model:open="configureModalOpen"
|
||||||
|
v-model:settings="batchSettings"
|
||||||
|
@upload="onBatchUpload"
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -28,8 +48,10 @@ import type UploadDropzone from '@/components/upload/UploadDropzone.vue';
|
|||||||
import { config } from '@/config';
|
import { config } from '@/config';
|
||||||
import { useExiftoolStore } from '@/stores/exiftool';
|
import { useExiftoolStore } from '@/stores/exiftool';
|
||||||
import { useMetadataStore } from '@/stores/metadata';
|
import { useMetadataStore } from '@/stores/metadata';
|
||||||
|
import type { UploadBatchSettingsForm } from '@/types/upload/UploadBatchSettingsForm';
|
||||||
import { analyzePickedUploadFiles } from '@/utils/upload/analyzePickedUploadFiles';
|
import { analyzePickedUploadFiles } from '@/utils/upload/analyzePickedUploadFiles';
|
||||||
import { buildPickedUploadWarning } from '@/utils/upload/buildPickedUploadWarning';
|
import { buildPickedUploadWarning } from '@/utils/upload/buildPickedUploadWarning';
|
||||||
|
import { formatUploadBatchSettingsSummary } from '@/utils/upload/formatUploadBatchSettingsSummary';
|
||||||
import prettyBytes from 'pretty-bytes';
|
import prettyBytes from 'pretty-bytes';
|
||||||
|
|
||||||
const { maxQueueSize, maxImageBytes } = config.validation.upload;
|
const { maxQueueSize, maxImageBytes } = config.validation.upload;
|
||||||
@@ -45,6 +67,15 @@ const selectedFile = ref<File | null>(null);
|
|||||||
const selectionError = ref<string | null>(null);
|
const selectionError = ref<string | null>(null);
|
||||||
const loadingMetadata = ref(false);
|
const loadingMetadata = ref(false);
|
||||||
const dropzoneRef = ref<InstanceType<typeof UploadDropzone> | null>(null);
|
const dropzoneRef = ref<InstanceType<typeof UploadDropzone> | null>(null);
|
||||||
|
const batchSettings = ref<UploadBatchSettingsForm>({
|
||||||
|
mode: 'zk',
|
||||||
|
expiryPreset: 'never',
|
||||||
|
maxViewsPreset: 'unlimited',
|
||||||
|
passwordEnabled: false,
|
||||||
|
password: ''
|
||||||
|
});
|
||||||
|
const configureModalOpen = ref(false);
|
||||||
|
const confirmedBatchSummary = ref<string | null>(null);
|
||||||
|
|
||||||
onBeforeMount(async () => {
|
onBeforeMount(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -66,6 +97,14 @@ const queueRows = computed(() =>
|
|||||||
|
|
||||||
const selectedPreview = computed(() => getPreview(selectedFile.value));
|
const selectedPreview = computed(() => getPreview(selectedFile.value));
|
||||||
|
|
||||||
|
const canOpenConfigure = computed(() => {
|
||||||
|
if (queuedFiles.value.length === 0 || loadingMetadata.value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !queuedFiles.value.some(file => getPreview(file).status === 'error');
|
||||||
|
});
|
||||||
|
|
||||||
const onFilesPicked = async (picked: File[]): Promise<void> => {
|
const onFilesPicked = async (picked: File[]): Promise<void> => {
|
||||||
if (loadingMetadata.value) {
|
if (loadingMetadata.value) {
|
||||||
return;
|
return;
|
||||||
@@ -120,5 +159,12 @@ const resetUploadSelection = (): void => {
|
|||||||
queuedFiles.value = [];
|
queuedFiles.value = [];
|
||||||
selectedFile.value = null;
|
selectedFile.value = null;
|
||||||
dropzoneRef.value?.clearInput();
|
dropzoneRef.value?.clearInput();
|
||||||
|
configureModalOpen.value = false;
|
||||||
|
confirmedBatchSummary.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onBatchUpload = (): void => {
|
||||||
|
confirmedBatchSummary.value = formatUploadBatchSettingsSummary(batchSettings.value);
|
||||||
|
configureModalOpen.value = false;
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user