Slave/ui polishing #13

Merged
nobswebdev merged 14 commits from slave/ui-polishing into master 2026-10-30 19:49:55 +00:00
42 changed files with 1354 additions and 333 deletions
+1
View File
@@ -2,6 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Private Image</title> <title>Private Image</title>
</head> </head>
Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

+6
View File
@@ -1,3 +1,9 @@
<template> <template>
<div class="flex min-h-screen w-full min-w-0 flex-col overflow-x-clip bg-background">
<AppHeader />
<div class="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-clip">
<router-view /> <router-view />
</div>
<AppFooter />
</div>
</template> </template>
+88 -37
View File
@@ -1,7 +1,5 @@
@import 'tailwindcss'; @import 'tailwindcss';
@custom-variant dark (&:is(.dark *));
@theme inline { @theme inline {
--radius-sm: calc(var(--radius) - 4px); --radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px); --radius-md: calc(var(--radius) - 2px);
@@ -28,43 +26,23 @@
:root { :root {
--radius: 0.5rem; --radius: 0.5rem;
--background: oklch(0.99 0.002 260); --background: oklch(1 0 0);
--foreground: oklch(0.2 0.02 260); --foreground: oklch(0.244 0.012 263);
--card: oklch(1 0 0); --card: oklch(1 0 0);
--card-foreground: oklch(0.2 0.02 260); --card-foreground: oklch(0.244 0.012 263);
--primary: oklch(0.42 0.08 260); --primary: oklch(0.497 0.227 263);
--primary-foreground: oklch(0.99 0.002 260); --primary-foreground: oklch(1 0 0);
--secondary: oklch(0.96 0.01 260); --secondary: oklch(0.94 0.025 263);
--secondary-foreground: oklch(0.28 0.03 260); --secondary-foreground: oklch(0.28 0.04 263);
--muted: oklch(0.96 0.008 260); --muted: oklch(0.955 0.018 263);
--muted-foreground: oklch(0.48 0.02 260); --muted-foreground: oklch(0.46 0.04 263);
--accent: oklch(0.94 0.02 260); --accent: oklch(0.93 0.04 263);
--accent-foreground: oklch(0.28 0.03 260); --accent-foreground: oklch(0.28 0.04 263);
--destructive: oklch(0.55 0.2 25); --destructive: oklch(0.55 0.2 25);
--success: oklch(0.45 0.14 155); --success: oklch(0.45 0.14 155);
--border: oklch(0.9 0.01 260); --border: oklch(0.88 0.025 263);
--input: oklch(0.9 0.01 260); --input: oklch(0.88 0.025 263);
--ring: oklch(0.42 0.08 260); --ring: oklch(0.497 0.227 263);
}
.dark {
--background: oklch(0.16 0.02 260);
--foreground: oklch(0.96 0.01 260);
--card: oklch(0.2 0.02 260);
--card-foreground: oklch(0.96 0.01 260);
--primary: oklch(0.72 0.1 260);
--primary-foreground: oklch(0.18 0.02 260);
--secondary: oklch(0.24 0.02 260);
--secondary-foreground: oklch(0.96 0.01 260);
--muted: oklch(0.24 0.02 260);
--muted-foreground: oklch(0.68 0.02 260);
--accent: oklch(0.28 0.03 260);
--accent-foreground: oklch(0.96 0.01 260);
--destructive: oklch(0.62 0.2 25);
--success: oklch(0.68 0.14 155);
--border: oklch(0.32 0.02 260);
--input: oklch(0.32 0.02 260);
--ring: oklch(0.72 0.1 260);
} }
@layer base { @layer base {
@@ -72,7 +50,80 @@
@apply border-border; @apply border-border;
} }
html {
@apply w-full min-w-0 overflow-x-hidden;
}
body { body {
@apply bg-background text-foreground antialiased; @apply min-w-0 overflow-x-hidden bg-background text-foreground antialiased;
}
#app {
@apply w-full min-w-0 overflow-x-clip;
} }
} }
body:has(.lightbox:target) {
overflow: hidden;
}
.expandable-image__link {
color: inherit;
cursor: zoom-in;
}
.lightbox {
display: none;
position: fixed;
inset: 0;
z-index: 100;
align-items: center;
justify-content: center;
padding: max(0.75rem, env(safe-area-inset-top)) max(0.75rem, env(safe-area-inset-right))
max(0.75rem, env(safe-area-inset-bottom)) max(0.75rem, env(safe-area-inset-left));
}
.lightbox:target {
display: flex;
}
.lightbox__backdrop {
position: absolute;
inset: 0;
background: rgb(0 0 0 / 0.92);
}
.lightbox__img {
position: relative;
z-index: 1;
max-width: min(100%, 75rem);
max-height: min(85dvh, 90vh);
width: auto;
height: auto;
object-fit: contain;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
}
.lightbox__close {
position: absolute;
top: max(0.5rem, env(safe-area-inset-top));
right: max(0.5rem, env(safe-area-inset-right));
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
font-size: 1.75rem;
line-height: 1;
color: var(--foreground);
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-md);
text-decoration: none;
}
.lightbox__close:hover {
border-color: var(--primary);
}
+7 -1
View File
@@ -11,6 +11,11 @@ export {}
/* prettier-ignore */ /* prettier-ignore */
declare module 'vue' { declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
AboutComparisonTable: typeof import('./components/about/AboutComparisonTable.vue')['default']
AboutSectionNav: typeof import('./components/about/AboutSectionNav.vue')['default']
AppFooter: typeof import('./components/layout/AppFooter.vue')['default']
AppHeader: typeof import('./components/layout/AppHeader.vue')['default']
ExpandableImage: typeof import('./components/ExpandableImage.vue')['default']
InlineTabList: typeof import('./components/InlineTabList.vue')['default'] InlineTabList: typeof import('./components/InlineTabList.vue')['default']
MetadataInspector: typeof import('./components/upload/MetadataInspector.vue')['default'] MetadataInspector: typeof import('./components/upload/MetadataInspector.vue')['default']
MetadataOutputPanel: typeof import('./components/upload/MetadataOutputPanel.vue')['default'] MetadataOutputPanel: typeof import('./components/upload/MetadataOutputPanel.vue')['default']
@@ -18,7 +23,8 @@ declare module 'vue' {
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
ShareLinkField: typeof import('./components/upload/ShareLinkField.vue')['default'] ShareLinkField: typeof import('./components/upload/ShareLinkField.vue')['default']
UploadConfigureModal: typeof import('./components/upload/UploadConfigureModal.vue')['default'] UploadConfigureModal: typeof import('./components/upload/UploadConfigureModal.vue')['default']
UploadDropzone: typeof import('./components/upload/UploadDropzone.vue')['default'] UploadIntakeControls: typeof import('./components/upload/UploadIntakeControls.vue')['default']
UploadPanelHeader: typeof import('./components/upload/UploadPanelHeader.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'] UploadSettingsPanel: typeof import('./components/upload/UploadSettingsPanel.vue')['default']
UploadShareResults: typeof import('./components/upload/UploadShareResults.vue')['default'] UploadShareResults: typeof import('./components/upload/UploadShareResults.vue')['default']
@@ -0,0 +1,72 @@
<template>
<div class="expandable-image min-w-0">
<figure class="m-0">
<a
:href="`#${lightboxId}`"
class="expandable-image__link block"
:aria-label="expandLabel"
>
<img
:src="src"
:alt="alt"
class="w-full rounded-lg border border-border transition-colors hover:border-primary/50"
:width="width"
:height="height"
loading="lazy"
decoding="async"
/>
</a>
</figure>
<div
:id="lightboxId"
class="lightbox"
role="dialog"
aria-modal="true"
:aria-label="expandLabel"
>
<a class="lightbox__backdrop" :href="closeHash" aria-label="Close" />
<img
class="lightbox__img"
:src="src"
:alt="alt"
:width="width"
:height="height"
/>
<a class="lightbox__close" :href="closeHash" aria-label="Close">&times;</a>
</div>
</div>
</template>
<script setup lang="ts">
defineProps({
src: {
type: String,
required: true
},
alt: {
type: String,
required: true
},
width: {
type: Number,
required: true
},
height: {
type: Number,
required: true
},
lightboxId: {
type: String,
required: true
},
closeHash: {
type: String,
required: true
},
expandLabel: {
type: String,
required: true
}
});
</script>
+2 -2
View File
@@ -1,10 +1,10 @@
<template> <template>
<div class="inline-flex w-fit rounded-md border border-border p-0.5"> <div class="flex w-full rounded-md border border-border p-0.5 sm:inline-flex sm:w-fit">
<button <button
v-for="option in options" v-for="option in options"
:key="option.value" :key="option.value"
type="button" type="button"
class="rounded px-3 py-1.5 text-xs font-medium transition-colors" class="min-w-0 flex-1 rounded px-3 py-1.5 text-center text-xs font-medium transition-colors sm:flex-none"
:class=" :class="
modelValue === option.value modelValue === option.value
? 'bg-muted text-foreground' ? 'bg-muted text-foreground'
@@ -0,0 +1,139 @@
<template>
<div
class="max-w-full overflow-x-auto rounded-lg border border-border contain-[layout_paint_style]"
>
<table class="w-full min-w-160 border-collapse text-left text-sm">
<thead>
<tr class="border-b border-border bg-muted">
<th class="sticky left-0 z-10 min-w-48 bg-muted px-3 py-2.5 font-medium text-foreground">
Feature
</th>
<th
v-for="host in comparisonHosts"
:key="host.id"
class="min-w-38 bg-muted px-3 py-2.5 font-medium"
:class="host.id === 'privateImage' ? 'bg-primary/5 text-foreground' : 'text-muted-foreground'"
>
{{ host.name }}
</th>
</tr>
</thead>
<tbody class="text-muted-foreground">
<tr v-for="row in comparisonRows" :key="row.feature" class="border-b border-border last:border-0">
<td
class="sticky left-0 z-10 bg-background px-3 py-2.5 font-medium text-foreground shadow-[1px_0_0_0_var(--border)]"
>
{{ row.feature }}
</td>
<td
v-for="host in comparisonHosts"
:key="host.id"
class="px-3 py-2.5 text-center align-middle"
:class="host.id === 'privateImage' ? 'bg-primary/3' : ''"
>
<span
class="inline-flex items-center justify-center"
:title="row.values[host.id] ? 'Yes' : 'No'"
>
<span class="sr-only">{{ row.values[host.id] ? 'Yes' : 'No' }}</span>
<span
v-if="row.values[host.id]"
class="inline-flex size-6 shrink-0 items-center justify-center rounded-full bg-success text-white shadow-sm"
aria-hidden="true"
>
<svg class="size-3.5" viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z"
clip-rule="evenodd"
/>
</svg>
</span>
<span
v-else
class="inline-flex size-6 shrink-0 items-center justify-center rounded-full bg-destructive text-white shadow-sm"
aria-hidden="true"
>
<svg class="size-3.5" viewBox="0 0 20 20" fill="currentColor">
<path
d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z"
/>
</svg>
</span>
</span>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<script setup lang="ts">
import type { ComparisonHost, ComparisonRow } from '@/types/about/ComparisonTable';
const comparisonHosts: ComparisonHost[] = [
{ id: 'privateImage', name: 'Private Image' },
{ id: 'imgbb', name: 'ImgBB' },
{ id: 'imgur', name: 'Imgur' },
{ id: 'catbox', name: 'Catbox.moe' }
];
const comparisonRows: ComparisonRow[] = [
{
feature: 'Metadata removed in your browser before upload',
values: { privateImage: true, imgbb: false, imgur: false, catbox: false }
},
{
feature: 'Metadata inspector before upload',
values: { privateImage: true, imgbb: false, imgur: false, catbox: false }
},
{
feature: 'Image encrypted in your browser before upload',
values: { privateImage: true, imgbb: false, imgur: false, catbox: false }
},
{
feature: 'Zero knowledge encryption',
values: { privateImage: true, imgbb: false, imgur: false, catbox: false }
},
{
feature: 'Hotlink mode with encryption at rest',
values: { privateImage: true, imgbb: false, imgur: false, catbox: false }
},
{
feature: 'Embeddable image URLs for forums and websites',
values: { privateImage: true, imgbb: true, imgur: true, catbox: true }
},
{
feature: 'Upload expiry or auto-delete timer',
values: { privateImage: true, imgbb: true, imgur: false, catbox: true }
},
{
feature: 'Max views and burn-after-read',
values: { privateImage: true, imgbb: false, imgur: false, catbox: false }
},
{
feature: 'Delete link with each upload (no account)',
values: { privateImage: true, imgbb: true, imgur: false, catbox: false }
},
{
feature: 'Self-hostable and open source',
values: { privateImage: true, imgbb: false, imgur: false, catbox: false }
},
{
feature: 'Tor onion service and dual clearnet + onion links',
values: { privateImage: true, imgbb: false, imgur: false, catbox: false }
},
{
feature: 'No account required to upload',
values: { privateImage: true, imgbb: true, imgur: true, catbox: true }
},
{
feature: 'No third-party ads',
values: { privateImage: true, imgbb: false, imgur: false, catbox: true }
},
{
feature: 'No content scanning',
values: { privateImage: true, imgbb: true, imgur: false, catbox: true }
}
];
</script>
@@ -0,0 +1,29 @@
<template>
<nav
class="mt-5 flex flex-wrap gap-x-4 gap-y-2 border-b border-border pb-4 text-sm text-muted-foreground"
aria-label="On this page"
>
<RouterLink
v-for="item in aboutPageNavSections"
:key="item.id"
:to="{ name: ROUTE_NAMES.About, hash: `#${item.id}` }"
class="text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
>
{{ item.label }}
</RouterLink>
</nav>
</template>
<script setup lang="ts">
import { ROUTE_NAMES } from '@/consts/routeNames';
const aboutPageNavSections = [
{ id: 'compare', label: 'Compare hosts' },
{ id: 'exiftool', label: 'ExifTool' },
{ id: 'share-modes', label: 'Share modes' },
{ id: 'crypto-shred', label: 'Crypto-shredding' },
{ id: 'limits', label: 'Limits' },
{ id: 'self-hosting', label: 'Self-hosting' },
{ id: 'clearnet-and-onion', label: 'Clearnet & onion' }
];
</script>
@@ -0,0 +1,37 @@
<template>
<footer class="mt-auto border-t border-border bg-card">
<div
class="mx-auto flex max-w-5xl flex-col items-center gap-2 px-3 py-4 text-center text-xs text-muted-foreground sm:px-6 sm:py-5 sm:text-sm"
>
<p class="flex flex-wrap items-center justify-center gap-x-2 gap-y-1">
<a
:href="sourceRepoClearnetUrl"
class="underline-offset-2 hover:text-foreground hover:underline"
rel="noopener noreferrer"
target="_blank"
>
Source (clearnet)
</a>
<span class="text-border" aria-hidden="true">·</span>
<a
:href="sourceRepoOnionUrl"
class="underline-offset-2 hover:text-foreground hover:underline"
rel="noopener noreferrer"
target="_blank"
>
Source (onion)
</a>
</p>
<p>
Private Image by <span class="font-medium text-foreground">nobswebdev</span>
</p>
</div>
</footer>
</template>
<script setup lang="ts">
const sourceRepoClearnetUrl = 'https://git.nobswebdev.com/nobswebdev/private_image';
const sourceRepoOnionUrl =
'http://4f5t7o2vgo4sdaliwax3isyxzfuxvbvkuwk4smfowutrvx2uneu4egyd.onion/nobswebdev/private_image';
</script>
@@ -0,0 +1,47 @@
<template>
<header class="border-b border-border bg-card">
<div class="mx-auto flex h-14 max-w-5xl items-center justify-between gap-2 px-3 sm:gap-4 sm:px-6">
<RouterLink
:to="{ name: ROUTE_NAMES.Upload }"
class="min-w-0 shrink transition-opacity hover:opacity-80"
>
<img
src="/logo.png"
alt="Private Image"
class="h-7 w-auto max-w-[min(100%,12rem)] sm:h-8 md:h-9"
width="1024"
height="228"
decoding="async"
/>
</RouterLink>
<nav class="flex items-center gap-1 sm:gap-2">
<RouterLink
v-for="item in navItems"
:key="item.name"
:to="{ name: item.name }"
class="rounded-md px-3 py-2 text-sm transition-colors"
:class="
route.name === item.name
? 'bg-muted font-medium text-foreground'
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
"
>
{{ item.label }}
</RouterLink>
</nav>
</div>
</header>
</template>
<script setup lang="ts">
import { useRoute } from 'vue-router';
import { ROUTE_NAMES } from '@/consts/routeNames';
const route = useRoute();
const navItems = [
{ name: ROUTE_NAMES.Upload, label: 'Upload' },
{ name: ROUTE_NAMES.About, label: 'About' }
];
</script>
@@ -1,25 +1,24 @@
<template> <template>
<section class="flex min-h-48 flex-col rounded-lg border border-border bg-card"> <section class="flex min-h-48 flex-col rounded-lg border border-border bg-card">
<header class="border-b border-border px-4 py-3"> <UploadPanelHeader
<h2 class="text-sm font-medium text-card-foreground">Metadata Inspector</h2> title="Metadata Inspector"
<p v-if="fileName" class="mt-0.5 truncate text-xs text-muted-foreground">{{ fileName }}</p> :file-name="fileName"
</header> :thumbnail-url="thumbnailUrl"
/>
<div class="flex flex-1 flex-col gap-3 p-4"> <div class="flex flex-1 flex-col gap-3 p-4">
<p v-if="!fileName" class="text-sm text-muted-foreground">Select an image to view its metadata.</p> <p v-if="!fileName" class="text-sm text-muted-foreground">Select an image to view its metadata.</p>
<template v-else-if="entry.status === 'error'"> <template v-else-if="entry.status === 'error' && !entry.output">
<p class="text-sm text-destructive">{{ entry.message }}</p> <p class="text-sm text-destructive">{{ entry.message }}</p>
<p v-if="entry.output" class="text-xs font-medium text-foreground">Original</p>
<MetadataOutputPanel v-if="entry.output">{{ entry.output }}</MetadataOutputPanel>
</template> </template>
<template v-else-if="entry.status === 'ready'"> <template v-else-if="showMetadataTabs">
<InlineTabList v-model="activeTab" :options="metadataTabOptions" /> <InlineTabList v-model="activeTab" :options="metadataTabOptions" />
<MetadataOutputPanel> <MetadataOutputPanel v-if="tabBody?.kind === 'output'">{{ tabBody.text }}</MetadataOutputPanel>
{{ activeTab === 'original' ? entry.output : entry.afterStripOutput }}
</MetadataOutputPanel> <p v-else-if="tabBody?.kind === 'error'" class="text-sm text-destructive">{{ tabBody.text }}</p>
</template> </template>
<p v-else class="text-sm text-muted-foreground">{{ entry.message }}</p> <p v-else class="text-sm text-muted-foreground">{{ entry.message }}</p>
@@ -28,8 +27,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, type PropType } from 'vue'; import { computed, ref, watch, type PropType } from 'vue';
import type { MetadataPreviewEntry } from '@/types/metadata/MetadataPreview'; import type { MetadataPreviewEntry } from '@/types/metadata/MetadataPreview';
import type { MetadataTabBody } from '@/types/metadata/MetadataTabBody';
import type { MetadataTab } from '@/types/metadata/MetadataTab'; import type { MetadataTab } from '@/types/metadata/MetadataTab';
import type { InlineTabOption } from '@/types/ui/InlineTabOption'; import type { InlineTabOption } from '@/types/ui/InlineTabOption';
@@ -46,11 +46,49 @@ const props = defineProps({
entry: { entry: {
type: Object as PropType<MetadataPreviewEntry>, type: Object as PropType<MetadataPreviewEntry>,
required: true required: true
},
thumbnailUrl: {
type: String as PropType<string | null>,
default: null
} }
}); });
const activeTab = ref<MetadataTab>('original'); const activeTab = ref<MetadataTab>('original');
const showMetadataTabs = computed(
() => props.entry.status === 'ready' || (props.entry.status === 'error' && Boolean(props.entry.output))
);
const tabBody = computed((): MetadataTabBody | null => {
const entry = props.entry;
if (!showMetadataTabs.value) {
return null;
}
if (activeTab.value === 'original') {
if (entry.status === 'ready') {
return { kind: 'output', text: entry.output };
}
if (entry.status === 'error' && entry.output) {
return { kind: 'output', text: entry.output };
}
return null;
}
if (entry.status === 'ready') {
return { kind: 'output', text: entry.afterStripOutput };
}
if (entry.status === 'error') {
return { kind: 'error', text: entry.message };
}
return null;
});
watch( watch(
() => props.fileName, () => props.fileName,
() => { () => {
@@ -1,5 +1,5 @@
<template> <template>
<pre <pre
class="max-h-96 min-h-32 flex-1 overflow-auto rounded-md bg-muted p-3 text-xs leading-relaxed whitespace-pre-wrap text-foreground" class="max-h-96 min-h-32 flex-1 overflow-auto rounded-md bg-muted p-3 text-xs leading-relaxed break-all whitespace-pre-wrap text-foreground"
><slot /></pre> ><slot /></pre>
</template> </template>
@@ -1,7 +1,7 @@
<template> <template>
<label class="flex flex-col gap-1.5 text-xs"> <label class="flex flex-col gap-1.5 text-xs">
<span class="font-medium text-muted-foreground">{{ label }}</span> <span class="font-medium text-muted-foreground">{{ label }}</span>
<div class="flex gap-2"> <div class="flex flex-col gap-2 sm:flex-row">
<input <input
type="text" type="text"
readonly readonly
@@ -10,7 +10,7 @@
/> />
<button <button
type="button" type="button"
class="shrink-0 rounded-md border border-border px-3 py-2 text-xs font-medium transition-colors hover:bg-muted/60" class="shrink-0 rounded-md border border-border px-3 py-2.5 text-xs font-medium transition-colors hover:bg-muted/60 sm:py-2"
@click="onCopyClick" @click="onCopyClick"
> >
{{ copied ? 'Copied' : 'Copy' }} {{ copied ? 'Copied' : 'Copy' }}
@@ -1,6 +1,6 @@
<template> <template>
<Teleport to="body"> <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 v-if="open" class="fixed inset-0 z-50 flex items-end justify-center overflow-y-auto bg-black/50 p-4 sm:items-center">
<div class="flex w-full max-w-lg flex-col rounded-lg border border-border bg-card shadow-lg"> <div 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"> <header class="border-b border-border px-4 py-3">
<h2 class="text-base font-medium text-card-foreground"> <h2 class="text-base font-medium text-card-foreground">
@@ -37,7 +37,7 @@
<div <div
v-if="open && metadataErrorConfirmOpen" v-if="open && metadataErrorConfirmOpen"
class="fixed inset-0 z-60 flex items-end justify-center bg-black/50 p-4 sm:items-center" class="fixed inset-0 z-60 flex items-end justify-center overflow-y-auto bg-black/50 p-4 sm:items-center"
> >
<div class="flex w-full max-w-md flex-col rounded-lg border border-border bg-card shadow-lg"> <div class="flex w-full max-w-md flex-col rounded-lg border border-border bg-card shadow-lg">
<header class="border-b border-border px-4 py-3"> <header class="border-b border-border px-4 py-3">
@@ -1,57 +0,0 @@
<template>
<div>
<label
class="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:opacity-90"
:class="disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'"
>
Choose images
<input
ref="fileInputRef"
type="file"
accept="image/*"
multiple
class="hidden"
:disabled="disabled"
@change="onFileInputChange"
/>
</label>
<p v-if="selectionError" class="mt-2 text-sm text-destructive">{{ selectionError }}</p>
</div>
</template>
<script setup lang="ts">
import { ref, type PropType } from 'vue';
defineProps({
disabled: {
type: Boolean,
required: true
},
selectionError: {
type: String as PropType<string | null>,
default: null
}
});
const emit = defineEmits<{
pick: [files: File[]];
}>();
const fileInputRef = ref<HTMLInputElement | null>(null);
const onFileInputChange = (event: Event): void => {
const input = event.target as HTMLInputElement;
const picked = input.files ? [...input.files] : [];
emit('pick', picked);
};
const clearInput = (): void => {
if (fileInputRef.value) {
fileInputRef.value.value = '';
}
};
defineExpose({ clearInput });
</script>
@@ -0,0 +1,228 @@
<template>
<div class="flex w-full flex-col gap-3">
<p class="text-sm text-muted-foreground sm:text-base">
Drop anywhere, paste or choose files. Max {{ maxQueueSize }} images, up to {{ maxImageSizeLabel }} each.
</p>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<label
class="inline-flex w-full items-center justify-center rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground transition-colors hover:opacity-90 sm:w-auto"
:class="disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'"
>
Choose images
<input
ref="fileInputRef"
type="file"
accept="image/*"
multiple
class="hidden"
:disabled="disabled"
@change="onFileInputChange"
/>
</label>
<button
v-if="!uploadConfirmed"
type="button"
class="inline-flex w-full shrink-0 items-center justify-center rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground transition-colors hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60 sm:w-auto"
:disabled="!canOpenConfigure"
@click="configureModalOpen = true"
>
Configure & upload
</button>
</div>
<UploadConfigureModal
v-model:open="configureModalOpen"
v-model:settings="settings"
:metadata-error-count="metadataErrorCount"
@upload="onUploadFromModal"
/>
<Teleport to="body">
<div v-if="showFileDragOverlay" class="pointer-events-none fixed inset-0 z-70 bg-background/70">
<div
class="absolute inset-[5%] flex items-center justify-center rounded-lg border-2 border-dashed border-primary"
>
<p class="text-2xl font-medium text-foreground sm:text-3xl">Drop files</p>
</div>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { config } from '@/config';
import type { UploadBatchSettingsForm } from '@/types/upload/UploadBatchSettingsForm';
import prettyBytes from 'pretty-bytes';
const { maxQueueSize, maxImageBytes } = config.validation.upload;
const props = defineProps({
disabled: {
type: Boolean,
required: true
},
canOpenConfigure: {
type: Boolean,
required: true
},
uploadConfirmed: {
type: Boolean,
required: true
},
metadataErrorCount: {
type: Number,
required: true
}
});
const settings = defineModel<UploadBatchSettingsForm>('settings', { required: true });
const emit = defineEmits<{
pick: [files: File[]];
upload: [uploadDespiteMetadataFailure?: boolean];
}>();
const fileInputRef = ref<HTMLInputElement | null>(null);
const configureModalOpen = ref(false);
const fileDragDepth = ref(0);
onMounted(() => {
window.addEventListener('dragenter', onWindowDragEnter);
window.addEventListener('dragleave', onWindowDragLeave);
window.addEventListener('dragover', onWindowDragOver);
window.addEventListener('drop', onWindowDrop);
window.addEventListener('dragend', onWindowDragEnd);
window.addEventListener('paste', onWindowPaste);
});
onBeforeUnmount(() => {
window.removeEventListener('dragenter', onWindowDragEnter);
window.removeEventListener('dragleave', onWindowDragLeave);
window.removeEventListener('dragover', onWindowDragOver);
window.removeEventListener('drop', onWindowDrop);
window.removeEventListener('dragend', onWindowDragEnd);
window.removeEventListener('paste', onWindowPaste);
});
const showFileDragOverlay = computed(() => fileDragDepth.value > 0 && !props.disabled);
const maxImageSizeLabel = computed(() => prettyBytes(maxImageBytes, { binary: true, maximumFractionDigits: 1 }));
const emitPicked = (picked: File[]): void => {
if (picked.length === 0) {
return;
}
emit('pick', picked);
};
const onFileInputChange = (event: Event): void => {
const input = event.target as HTMLInputElement;
const picked = input.files ? [...input.files] : [];
emitPicked(picked);
};
const onUploadFromModal = (uploadDespiteMetadataFailure?: boolean): void => {
configureModalOpen.value = false;
emit('upload', uploadDespiteMetadataFailure);
};
const isFileDrag = (event: DragEvent): boolean => {
return Boolean(event.dataTransfer?.types.includes('Files'));
};
const onWindowDragEnter = (event: DragEvent): void => {
if (props.disabled || !isFileDrag(event)) {
return;
}
fileDragDepth.value += 1;
};
const onWindowDragLeave = (event: DragEvent): void => {
if (!isFileDrag(event)) {
return;
}
fileDragDepth.value = Math.max(0, fileDragDepth.value - 1);
};
const onWindowDragOver = (event: DragEvent): void => {
if (props.disabled || !isFileDrag(event)) {
return;
}
event.preventDefault();
if (fileDragDepth.value === 0) {
fileDragDepth.value = 1;
}
};
const onWindowDrop = (event: DragEvent): void => {
resetFileDragDepth();
if (props.disabled) {
return;
}
event.preventDefault();
const picked = event.dataTransfer?.files ? [...event.dataTransfer.files] : [];
emitPicked(picked);
};
const onWindowDragEnd = (): void => {
resetFileDragDepth();
};
const isPasswordInputTarget = (target: EventTarget | null): boolean => {
if (!(target instanceof HTMLElement)) {
return false;
}
return target.closest('input[type="password"]') !== null;
};
const onWindowPaste = (event: ClipboardEvent): void => {
if (props.disabled) {
return;
}
const picked = event.clipboardData?.files ? [...event.clipboardData.files] : [];
if (picked.length === 0) {
return;
}
if (isPasswordInputTarget(event.target)) {
return;
}
event.preventDefault();
emitPicked(picked);
};
const clearInput = (): void => {
if (fileInputRef.value) {
fileInputRef.value.value = '';
}
};
const closeConfigureModal = (): void => {
configureModalOpen.value = false;
};
const resetFileDragDepth = (): void => {
fileDragDepth.value = 0;
};
defineExpose({ clearInput, closeConfigureModal });
</script>
@@ -0,0 +1,41 @@
<template>
<header class="border-b border-border px-3 py-2.5 sm:px-4 sm:py-3">
<div class="flex items-start gap-3 sm:items-center">
<img
v-if="thumbnailUrl"
:src="thumbnailUrl"
class="h-10 w-10 shrink-0 rounded border border-border object-cover"
loading="lazy"
decoding="async"
/>
<span
v-else-if="fileName"
class="h-10 w-10 shrink-0 rounded border border-border bg-muted/60"
aria-hidden="true"
/>
<div class="min-w-0 flex-1">
<h2 class="text-sm font-medium text-card-foreground">{{ title }}</h2>
<p v-if="fileName" class="mt-0.5 truncate text-xs text-muted-foreground">{{ fileName }}</p>
</div>
</div>
</header>
</template>
<script setup lang="ts">
import { type PropType } from 'vue';
defineProps({
title: {
type: String,
required: true
},
fileName: {
type: String as PropType<string | null>,
default: null
},
thumbnailUrl: {
type: String as PropType<string | null>,
default: null
}
});
</script>
@@ -3,20 +3,38 @@
<li v-for="row in rows" :key="row.key"> <li v-for="row in rows" :key="row.key">
<button <button
type="button" type="button"
class="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-muted/60" class="flex w-full items-start gap-3 px-3 py-3 text-left transition-colors hover:bg-muted/60 sm:items-center sm:px-4"
:class="isRowSelected(row) ? 'bg-muted/80' : ''" :class="isRowSelected(row) ? 'bg-muted/80' : ''"
@click="emit('select', row.file)" @click="emit('select', row.file)"
> >
<span class="min-w-0 flex-1 truncate text-sm font-medium">{{ row.file.name }}</span> <img
v-if="row.thumbnailUrl"
:src="row.thumbnailUrl"
class="h-10 w-10 shrink-0 rounded border border-border object-cover"
loading="lazy"
decoding="async"
/>
<span
v-else
class="h-10 w-10 shrink-0 rounded border border-border bg-muted/60"
aria-hidden="true"
/>
<div class="min-w-0 flex-1 space-y-1">
<div class="flex min-w-0 items-baseline justify-between gap-2">
<span class="min-w-0 truncate text-sm font-medium">{{ row.file.name }}</span>
<span class="shrink-0 text-xs text-muted-foreground"> <span class="shrink-0 text-xs text-muted-foreground">
{{ prettyBytes(row.file.size, { maximumFractionDigits: 1 }) }} {{ prettyBytes(row.file.size, { maximumFractionDigits: 1 }) }}
</span> </span>
<span class="shrink-0 max-w-32 truncate text-xs sm:max-w-40" :class="metadataStatusClass(row.metadata)"> </div>
<div class="flex min-w-0 flex-col gap-0.5 text-xs sm:flex-row sm:flex-wrap sm:gap-x-3">
<span class="min-w-0 truncate" :class="metadataStatusClass(row.metadata)">
{{ row.metadata.message }} {{ row.metadata.message }}
</span> </span>
<span class="shrink-0 max-w-32 truncate text-xs sm:max-w-40" :class="uploadStatusClass(row.upload)"> <span class="min-w-0 truncate" :class="uploadStatusClass(row.upload)">
{{ row.upload.message }} {{ row.upload.message }}
</span> </span>
</div>
</div>
</button> </button>
</li> </li>
</ul> </ul>
@@ -9,12 +9,20 @@
> >
<input v-model="settings.mode" class="mt-0.5" type="radio" name="upload-mode" value="zk" /> <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="min-w-0 flex-1">
<span class="inline-flex flex-wrap items-center gap-x-2 gap-y-1">
<span class="text-sm font-medium">Zero knowledge</span> <span class="text-sm font-medium">Zero knowledge</span>
<RouterLink
:to="{ name: ROUTE_NAMES.About, hash: '#zero-knowledge' }"
class="text-xs font-medium text-primary underline-offset-2 hover:underline"
target="_blank"
rel="noopener noreferrer"
@click.stop
>
Learn more
</RouterLink>
</span>
<p class="mt-1 text-xs text-muted-foreground"> <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 E2E encrypted, for direct sharing, can't be embedded.
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> </p>
</span> </span>
</label> </label>
@@ -24,34 +32,32 @@
> >
<input v-model="settings.mode" class="mt-0.5" type="radio" name="upload-mode" value="hotlink" /> <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="min-w-0 flex-1">
<span class="inline-flex flex-wrap items-center gap-x-2 gap-y-1">
<span class="text-sm font-medium">Hotlink</span> <span class="text-sm font-medium">Hotlink</span>
<RouterLink
:to="{ name: ROUTE_NAMES.About, hash: '#hotlink' }"
class="text-xs font-medium text-primary underline-offset-2 hover:underline"
target="_blank"
rel="noopener noreferrer"
@click.stop
>
Learn more
</RouterLink>
</span>
<p class="mt-1 text-xs text-muted-foreground"> <p class="mt-1 text-xs text-muted-foreground">
Still encrypted at rest, but the server decrypts on the fly. Works with BBCode, HTML, Still encrypted at rest, but support embedding, for forums, websites, etc.
etc.
Encryption here serves mainly as
<span class="whitespace-nowrap">
<span class="font-bold">crypto-shred</span
><sup class="font-bold text-[0.7em] text-muted-foreground">1</sup> </span
>, because hotlink images are intended to be public.
</p> </p>
</span> </span>
</label> </label>
</div> </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> </fieldset>
<div class="grid gap-4 sm:grid-cols-2"> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1.5 text-sm"> <label class="flex min-w-0 flex-col gap-1.5 text-sm">
<span class="font-medium">Expiry</span> <span class="font-medium">Expiry</span>
<select <select
v-model="settings.expiryPreset" v-model="settings.expiryPreset"
class="rounded-md border border-input bg-background px-3 py-2 text-sm" class="w-full rounded-md border border-input bg-background px-3 py-2 text-base"
> >
<option v-for="option in expiryOptions" :key="option.value" :value="option.value"> <option v-for="option in expiryOptions" :key="option.value" :value="option.value">
{{ option.label }} {{ option.label }}
@@ -59,11 +65,11 @@
</select> </select>
</label> </label>
<label class="flex flex-col gap-1.5 text-sm"> <label class="flex min-w-0 flex-col gap-1.5 text-sm">
<span class="font-medium">Max views</span> <span class="font-medium">Max views</span>
<select <select
v-model="settings.maxViewsPreset" v-model="settings.maxViewsPreset"
class="rounded-md border border-input bg-background px-3 py-2 text-sm" class="w-full rounded-md border border-input bg-background px-3 py-2 text-base"
> >
<option v-for="option in maxViewsOptions" :key="option.value" :value="option.value"> <option v-for="option in maxViewsOptions" :key="option.value" :value="option.value">
{{ option.label }} {{ option.label }}
@@ -77,9 +83,7 @@
<input v-model="settings.passwordEnabled" type="checkbox" class="rounded border-input" /> <input v-model="settings.passwordEnabled" type="checkbox" class="rounded border-input" />
<span class="font-medium">Set password</span> <span class="font-medium">Set password</span>
</label> </label>
<p class="text-xs text-muted-foreground"> <p class="text-xs text-muted-foreground">Requires the link and this password to decrypt.</p>
Requires the link and this password to decrypt. Use when the link alone should not be enough.
</p>
<template v-if="settings.passwordEnabled"> <template v-if="settings.passwordEnabled">
<label class="flex flex-col gap-1.5 text-sm"> <label class="flex flex-col gap-1.5 text-sm">
@@ -88,7 +92,7 @@
v-model="settings.password" v-model="settings.password"
type="password" type="password"
autocomplete="new-password" autocomplete="new-password"
class="rounded-md border border-input bg-background px-3 py-2 text-sm" class="rounded-md border border-input bg-background px-3 py-2 text-base"
/> />
</label> </label>
</template> </template>
@@ -98,6 +102,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import { ROUTE_NAMES } from '@/consts/routeNames';
import { config } from '@/config'; import { config } from '@/config';
import type { UploadBatchSettingsForm } from '@/types/upload/UploadBatchSettingsForm'; import type { UploadBatchSettingsForm } from '@/types/upload/UploadBatchSettingsForm';
import type { UploadExpiryPreset } from '@/types/upload/UploadExpiryPreset'; import type { UploadExpiryPreset } from '@/types/upload/UploadExpiryPreset';
@@ -1,9 +1,6 @@
<template> <template>
<section class="flex min-h-48 flex-col rounded-lg border border-border bg-card"> <section class="flex min-h-48 flex-col rounded-lg border border-border bg-card">
<header class="border-b border-border px-4 py-3"> <UploadPanelHeader title="Share links" :file-name="fileName" :thumbnail-url="thumbnailUrl" />
<h2 class="text-sm font-medium text-card-foreground">Share links</h2>
<p v-if="fileName" class="mt-0.5 truncate text-xs text-muted-foreground">{{ fileName }}</p>
</header>
<div class="flex flex-1 flex-col gap-3 p-4"> <div class="flex flex-1 flex-col gap-3 p-4">
<p v-if="!fileName" class="text-sm text-muted-foreground">Select an image to view share links.</p> <p v-if="!fileName" class="text-sm text-muted-foreground">Select an image to view share links.</p>
@@ -60,6 +57,10 @@ const props = defineProps({
upload: { upload: {
type: Object as PropType<UploadQueueEntry>, type: Object as PropType<UploadQueueEntry>,
required: true required: true
},
thumbnailUrl: {
type: String as PropType<string | null>,
default: null
} }
}); });
+2 -1
View File
@@ -1,6 +1,7 @@
export const ROUTE_NAMES = { export const ROUTE_NAMES = {
Upload: 'Upload', Upload: 'Upload',
Viewer: 'Viewer', About: 'About',
ZkViewer: 'ZkViewer',
HotlinkViewer: 'HotlinkViewer', HotlinkViewer: 'HotlinkViewer',
Delete: 'Delete' Delete: 'Delete'
} as const; } as const;
+27 -4
View File
@@ -3,6 +3,17 @@ import { ROUTE_NAMES } from '@/consts/routeNames';
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
scrollBehavior(to, _from, savedPosition) {
if (savedPosition) {
return savedPosition;
}
if (to.hash) {
return { el: to.hash, behavior: 'smooth' };
}
return { top: 0 };
},
routes: [ routes: [
{ {
path: '/', path: '/',
@@ -10,17 +21,23 @@ const router = createRouter({
component: () => import('@/views/UploadView.vue'), component: () => import('@/views/UploadView.vue'),
meta: { title: 'Private Image - Upload' } meta: { title: 'Private Image - Upload' }
}, },
{
path: '/about',
name: ROUTE_NAMES.About,
component: () => import('@/views/AboutView.vue'),
meta: { title: 'Private Image - About' }
},
{ {
path: '/v/:id', path: '/v/:id',
name: ROUTE_NAMES.Viewer, name: ROUTE_NAMES.ZkViewer,
component: () => import('@/views/ViewerView.vue'), component: () => import('@/views/ZkViewerView.vue'),
meta: { title: 'Private Image - Viewer' } meta: { title: 'Private Image - ZK Viewer' }
}, },
{ {
path: '/h/:id/:secret', path: '/h/:id/:secret',
name: ROUTE_NAMES.HotlinkViewer, name: ROUTE_NAMES.HotlinkViewer,
component: () => import('@/views/HotlinkViewerView.vue'), component: () => import('@/views/HotlinkViewerView.vue'),
meta: { title: 'Private Image - Viewer' } meta: { title: 'Private Image - Hotlink Viewer' }
}, },
{ {
path: '/d/:id', path: '/d/:id',
@@ -31,4 +48,10 @@ const router = createRouter({
] ]
}); });
router.afterEach(to => {
const title = to.meta.title;
document.title = typeof title === 'string' ? title : 'Private Image';
});
export default router; export default router;
@@ -7,7 +7,7 @@ import { toArrayBuffer } from '@/utils/toArrayBuffer';
import { decryptPasswordProtectedZkUpload } from '@/utils/upload/decryptPasswordProtectedZkUpload'; import { decryptPasswordProtectedZkUpload } from '@/utils/upload/decryptPasswordProtectedZkUpload';
import { decryptZkUpload } from '@/utils/upload/decryptZkUpload'; import { decryptZkUpload } from '@/utils/upload/decryptZkUpload';
export const useViewStore = defineStore('view', () => { export const useZkViewStore = defineStore('zkView', () => {
const imageObjectUrl = ref<string | null>(null); const imageObjectUrl = ref<string | null>(null);
const clearImage = (): void => { const clearImage = (): void => {
@@ -0,0 +1,11 @@
export type ComparisonHostId = 'privateImage' | 'imgbb' | 'imgur' | 'catbox';
export type ComparisonHost = {
id: ComparisonHostId;
name: string;
};
export type ComparisonRow = {
feature: string;
values: Record<ComparisonHostId, boolean>;
};
@@ -0,0 +1 @@
export type MetadataTabBody = { kind: 'output'; text: string } | { kind: 'error'; text: string };
@@ -4,6 +4,7 @@ import type { UploadQueueEntry } from '@/types/upload/UploadQueueEntry';
export type UploadQueueRow = { export type UploadQueueRow = {
file: File; file: File;
key: string; key: string;
thumbnailUrl: string | null;
metadata: MetadataPreviewEntry; metadata: MetadataPreviewEntry;
upload: UploadQueueEntry; upload: UploadQueueEntry;
}; };
@@ -0,0 +1 @@
export type HotlinkViewerViewState = 'invalidLink' | 'loading' | 'image' | 'notFound';
@@ -0,0 +1 @@
export type ZkViewerViewState = 'loading' | 'password' | 'image' | 'invalidLink' | 'notFound';
@@ -0,0 +1,29 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { createQueueThumbnailUrls } from '@/utils/upload/createQueueThumbnailUrls';
const makeFile = (name: string, size: number, lastModified: number): File =>
new File([new Uint8Array(size)], name, { type: 'image/png', lastModified });
describe('createQueueThumbnailUrls', () => {
beforeEach(() => {
vi.stubGlobal(
'URL',
Object.assign(globalThis.URL, {
createObjectURL: vi.fn(() => 'blob:mock-1')
})
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('creates one object URL per file', () => {
const files = [makeFile('a.png', 10, 1), makeFile('b.png', 20, 2)];
const urlsByKey = createQueueThumbnailUrls(files);
expect(URL.createObjectURL).toHaveBeenCalledTimes(2);
expect(Object.keys(urlsByKey)).toHaveLength(2);
});
});
@@ -0,0 +1,11 @@
import { makeKeyFromFile } from '@/utils/file/makeKeyFromFile';
export const createQueueThumbnailUrls = (files: File[]): Record<string, string> => {
const urlsByKey: Record<string, string> = {};
for (const file of files) {
urlsByKey[makeKeyFromFile(file)] = URL.createObjectURL(file);
}
return urlsByKey;
};
@@ -0,0 +1,20 @@
import { describe, expect, it, vi } from 'vitest';
import { revokeQueueThumbnailUrls } from '@/utils/upload/revokeQueueThumbnailUrls';
describe('revokeQueueThumbnailUrls', () => {
it('revokes every URL in the map', () => {
vi.stubGlobal(
'URL',
Object.assign(globalThis.URL, {
revokeObjectURL: vi.fn()
})
);
revokeQueueThumbnailUrls({ a: 'blob:1', b: 'blob:2' });
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:1');
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:2');
vi.unstubAllGlobals();
});
});
@@ -0,0 +1,5 @@
export const revokeQueueThumbnailUrls = (urlsByKey: Record<string, string>): void => {
for (const url of Object.values(urlsByKey)) {
URL.revokeObjectURL(url);
}
};
+197
View File
@@ -0,0 +1,197 @@
<template>
<main class="mx-auto flex w-full min-w-0 max-w-5xl flex-1 flex-col overflow-x-hidden px-4 py-6 sm:px-6 sm:py-8">
<article>
<h1 class="text-2xl font-semibold tracking-tight sm:text-3xl">About Private Image</h1>
<AboutSectionNav />
<p class="mt-6 text-sm leading-relaxed text-muted-foreground sm:text-base sm:leading-relaxed">
Private Image is an image-hosting service built with privacy in mind. Many image hosts store uploaded
files as they are, including EXIF and other metadata that can reveal where a photo was taken, which
device was used, and when it was captured. The service operator may also be able to open and view the
original image.
</p>
<p class="mt-4 text-sm leading-relaxed text-muted-foreground sm:text-base sm:leading-relaxed">
Private Image takes a different approach. It removes metadata using
<a
href="#exiftool"
class="font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground"
>
ExifTool
</a>
compiled to run directly in your browser, then encrypts the cleaned image before uploading it. The
server receives encrypted data along with your chosen expiry and view limits, but not the original image
in readable form.
</p>
<p class="mt-4 text-sm leading-relaxed text-muted-foreground sm:text-base sm:leading-relaxed">
Each upload comes with links to view and delete the image. There is no account registration, and your
upload history isn't retained after you close the page.
</p>
</article>
<section id="compare" class="mt-14 min-w-0 scroll-mt-16 sm:scroll-mt-20">
<h2 class="text-lg font-medium text-foreground">How other image hosts compare</h2>
<p class="mt-3 text-sm leading-relaxed text-muted-foreground sm:text-base sm:leading-relaxed">
The table below compares Private Image with ImgBB, Imgur, and Catbox, three free image hosts commonly
used for forum posts and quick sharing.
</p>
<p class="mt-4 text-sm leading-relaxed text-muted-foreground sm:text-base sm:leading-relaxed">
Features and privacy policies can change, so check each provider's current documentation before relying
on any particular capability.
</p>
<AboutComparisonTable class="mt-6" />
</section>
<section id="exiftool" class="mt-14 scroll-mt-16 sm:scroll-mt-20">
<h2 class="text-lg font-medium text-foreground">ExifTool runs in your browser</h2>
<p class="mt-3 text-sm leading-relaxed text-muted-foreground sm:text-base sm:leading-relaxed">
Private Image uses ExifTool, a widely used metadata tool normally run on a desktop, compiled to work in
your browser. It processes images locally rather than sending them to the server for inspection.
</p>
<p class="mt-4 text-sm leading-relaxed text-muted-foreground sm:text-base sm:leading-relaxed">
When you add an image, ExifTool reads its embedded metadata, including EXIF data, and removes it before
upload. You can inspect the metadata before proceeding. The image that's encrypted and uploaded is the
cleaned version, without embedded location data, device details, and similar information.
</p>
<p class="mt-4 text-sm leading-relaxed text-muted-foreground sm:text-base sm:leading-relaxed">
All of this happens in your browser, before the image reaches the server.
</p>
</section>
<section id="share-modes" class="mt-14 scroll-mt-16 sm:scroll-mt-20">
<h2 class="text-lg font-medium text-foreground">Share modes</h2>
<h3 id="zero-knowledge" class="mt-6 scroll-mt-16 sm:scroll-mt-20 text-base font-medium text-foreground">
Zero knowledge
</h3>
<p class="mt-2 text-sm leading-relaxed text-muted-foreground sm:text-base sm:leading-relaxed">
Images are encrypted end to end (E2E), so the server cannot decrypt or view them. Anyone with the
viewing link can access the image, unless you've also protected the upload with a password.
</p>
<p class="mt-4 text-sm leading-relaxed text-muted-foreground sm:text-base sm:leading-relaxed">
This mode is best for sensitive images, private messages, and sharing with a small group when you don't
want the server operator to be able to read the file.
</p>
<p class="mt-4 text-sm leading-relaxed text-muted-foreground sm:text-base sm:leading-relaxed">
Zero-knowledge links aren't compatible with BBCode, HTML, or similar image embeds.
</p>
<ExpandableImage
class="mt-6"
src="/about/zk-flow.jpg"
alt="Zero knowledge upload flow: select image, remove metadata with ExifTool, encrypt in browser, upload ciphertext, store encrypted on server, recipient decrypts locally"
:width="1024"
:height="512"
lightbox-id="lightbox-zk-flow"
close-hash="#zero-knowledge"
expand-label="View larger zero knowledge flow diagram"
/>
<h3 id="hotlink" class="mt-8 scroll-mt-16 sm:scroll-mt-20 text-base font-medium text-foreground">Hotlink</h3>
<p class="mt-2 text-sm leading-relaxed text-muted-foreground sm:text-base sm:leading-relaxed">
Hotlink mode keeps images encrypted at rest but decrypts them on the server when they're requested. This
makes it possible to embed images directly in forum posts, chats, wikis, and other places that support
BBCode, HTML, or similar formats.
</p>
<p class="mt-4 text-sm leading-relaxed text-muted-foreground sm:text-base sm:leading-relaxed">
Metadata is removed in the browser using the same
<a
href="#exiftool"
class="font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground"
>
ExifTool
</a>
process as in zero-knowledge mode. Since hotlinked images are intended to be publicly accessible,
encryption at rest mainly provides an additional layer of protection through
<a
href="#crypto-shred"
class="font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground"
>
crypto-shredding
</a>
when an image is deleted or expires.
</p>
<ExpandableImage
class="mt-6"
src="/about/hotlink-flow.jpg"
alt="Hotlink upload flow: select image, remove metadata with ExifTool, encrypt in browser, upload, store encrypted at rest, server decrypts on request for display or embedding"
:width="1024"
:height="512"
lightbox-id="lightbox-hotlink-flow"
close-hash="#hotlink"
expand-label="View larger hotlink flow diagram"
/>
</section>
<section id="crypto-shred" class="mt-14 scroll-mt-16 sm:scroll-mt-20">
<h2 class="text-lg font-medium text-foreground">Crypto-shredding</h2>
<p class="mt-3 text-sm leading-relaxed text-muted-foreground sm:text-base sm:leading-relaxed">
Deleting a file doesn't always mean every copy of its data has disappeared from a storage device.
Backups, snapshots, and the way modern drives manage storage can leave behind fragments that may remain
recoverable for some time.
</p>
<p class="mt-4 text-sm leading-relaxed text-muted-foreground sm:text-base sm:leading-relaxed">
Crypto-shredding addresses this by storing data as encrypted blobs. Even if someone recovers leftover
data from a drive, they can't read it without the decryption key.
</p>
<p class="mt-4 text-sm leading-relaxed text-muted-foreground sm:text-base sm:leading-relaxed">
Private Image stores uploads as encrypted data and keeps the information needed to view an image in the
sharing link rather than saving the key separately on the server. When you delete an upload or it
expires, the encrypted blob is removed and the link stops working.
</p>
<p class="mt-4 text-sm leading-relaxed text-muted-foreground sm:text-base sm:leading-relaxed">
This means that any recovered disk fragments remain unreadable without the link and its decryption
information. It's an important safeguard for hotlinked images and adds another layer of protection to
zero-knowledge uploads, which the server cannot decrypt in the first place.
</p>
<p class="mt-4 text-sm leading-relaxed text-muted-foreground sm:text-base sm:leading-relaxed">
Deleting an image or letting it expire ends practical access to it. It doesn't guarantee that every byte
has been physically erased from every drive, but crypto-shredding ensures that any leftover encrypted
data is useless without the key.
</p>
</section>
<section id="limits" class="mt-14 scroll-mt-16 sm:scroll-mt-20">
<h2 class="text-lg font-medium text-foreground">Set your own limits</h2>
<p class="mt-3 text-sm leading-relaxed text-muted-foreground sm:text-base sm:leading-relaxed">
Choose how long each upload stays available, from a short, fixed period to no expiry. You can also limit
the number of times a link can be used, including setting it to allow just one view before it burns.
</p>
<p class="mt-4 text-sm leading-relaxed text-muted-foreground sm:text-base sm:leading-relaxed">
Each upload also has a delete link, so you can remove the image immediately whenever you want.
</p>
</section>
<section id="self-hosting" class="mt-14 scroll-mt-16 sm:scroll-mt-20">
<h2 class="text-lg font-medium text-foreground">Self-hosting</h2>
<p class="mt-3 text-sm leading-relaxed text-muted-foreground sm:text-base sm:leading-relaxed">
Private Image is open source, so you can run your own instance on hardware you control instead of
relying on a public service. The upload experience works the same way: ExifTool, encryption, and the
different sharing modes all run in the browser.
</p>
<p class="mt-4 text-sm leading-relaxed text-muted-foreground sm:text-base sm:leading-relaxed">
The production setup uses Docker Compose with the application, database, Nginx, and Tor. You'll need to
configure your domain, TLS, and environment variables using the deployment guide in the project's
repository.
</p>
</section>
<section id="clearnet-and-onion" class="mt-14 scroll-mt-16 sm:scroll-mt-20">
<h2 class="text-lg font-medium text-foreground">Clearnet and onion access</h2>
<p class="mt-3 text-sm leading-relaxed text-muted-foreground sm:text-base sm:leading-relaxed">
Private Image is available over the regular web (clearnet, via HTTPS) and through Tor as an onion
service. You can upload images and open sharing links over either network, with the same encryption and
sharing options available on both.
</p>
<p class="mt-4 text-sm leading-relaxed text-muted-foreground sm:text-base sm:leading-relaxed">
After uploading, you'll get separate clearnet and onion links for viewing and deleting the image, as
well as direct image URLs in hotlink mode. Choose the set that matches how you and your recipients
access the service.
</p>
<p class="mt-4 text-sm leading-relaxed text-muted-foreground sm:text-base sm:leading-relaxed">
Forum embed codes, including BBCode and HTML, use clearnet URLs because most external sites expect
standard web addresses.
</p>
</section>
</main>
</template>
+1 -1
View File
@@ -1,5 +1,5 @@
<template> <template>
<main class="mx-auto flex min-h-screen max-w-lg flex-col items-center justify-center gap-3 p-8 text-center"> <main class="mx-auto flex w-full max-w-lg flex-1 flex-col items-center justify-center gap-3 px-4 py-6 text-center sm:p-8">
<p v-if="state === 'invalid'" class="text-sm text-muted-foreground"> <p v-if="state === 'invalid'" class="text-sm text-muted-foreground">
This delete link is incomplete. Use the delete URL from your upload results. This delete link is incomplete. Use the delete URL from your upload results.
</p> </p>
+39 -6
View File
@@ -1,29 +1,62 @@
<template> <template>
<main class="flex min-h-screen items-center justify-center p-4"> <main
<img v-if="imageSrc" :src="imageSrc" class="max-h-[80vh] max-w-full object-contain" /> class="mx-auto flex w-full max-w-lg flex-1 flex-col items-center justify-center gap-3 px-4 py-6 text-center sm:px-6 sm:py-8"
>
<p v-if="state === 'invalidLink'" class="text-sm text-muted-foreground">
This viewing link is incomplete. Use the hotlink URL from your upload results.
</p>
<p v-else-if="state === 'notFound'" class="text-sm text-muted-foreground">This image was not found.</p>
<template v-else>
<p v-if="state === 'loading'" class="text-sm text-muted-foreground sm:text-base">Loading image...</p>
<img
v-if="imageSrc"
:src="imageSrc"
class="max-h-[min(80vh,100dvh)] w-full object-contain"
:class="{ hidden: state === 'loading' }"
@load="onImageLoad"
@error="onImageError"
/>
</template>
</main> </main>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { onBeforeMount, ref } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { config } from '@/config'; import { config } from '@/config';
import type { HotlinkViewerViewState } from '@/types/view/HotlinkViewerViewState';
import { buildHotlinkImagePath } from '@/utils/share/buildHotlinkImagePath'; import { buildHotlinkImagePath } from '@/utils/share/buildHotlinkImagePath';
const route = useRoute(); const route = useRoute();
const imageSrc = computed((): string | null => { const state = ref<HotlinkViewerViewState>('loading');
const imageSrc = ref<string | null>(null);
onBeforeMount(() => {
const uploadId = route.params.id; const uploadId = route.params.id;
const secret = route.params.secret; const secret = route.params.secret;
if (typeof uploadId !== 'string' || typeof secret !== 'string' || !uploadId || !secret) { if (typeof uploadId !== 'string' || typeof secret !== 'string' || !uploadId || !secret) {
return null; state.value = 'invalidLink';
return;
} }
const { baseUrlRoot } = config.api; const { baseUrlRoot } = config.api;
const path = buildHotlinkImagePath(uploadId, secret); const path = buildHotlinkImagePath(uploadId, secret);
return `${baseUrlRoot}${path}`; imageSrc.value = `${baseUrlRoot}${path}`;
}); });
const onImageLoad = (): void => {
state.value = 'image';
};
const onImageError = (): void => {
state.value = 'notFound';
};
</script> </script>
+39 -40
View File
@@ -1,33 +1,17 @@
<template> <template>
<main class="mx-auto flex min-h-screen max-w-3xl flex-col gap-6 p-8"> <main class="mx-auto flex w-full max-w-3xl flex-1 flex-col gap-4 p-4 sm:gap-6 sm:p-6 lg:p-8">
<div>
<h1 class="text-2xl font-semibold tracking-tight">Upload</h1>
<p class="mt-2 text-sm text-muted-foreground">
Choose up to {{ maxQueueSize }} images, up to
{{ maxImageSizeFormatted }}
each.
</p>
</div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <UploadIntakeControls
<UploadDropzone ref="intakeControlsRef"
ref="dropzoneRef" v-model:settings="batchSettings"
:disabled="loadingMetadata || batchUploading" :disabled="loadingMetadata || batchUploading"
:can-open-configure="canOpenConfigure"
:upload-confirmed="uploadConfirmed"
:metadata-error-count="metadataErrorCount"
@pick="onFilesPicked" @pick="onFilesPicked"
@upload="onBatchUpload"
/> />
<button
v-if="!uploadConfirmed"
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> <p v-if="selectionError" class="text-sm text-destructive">{{ selectionError }}</p>
</div> </div>
@@ -41,22 +25,20 @@
v-if="showShareLinks" v-if="showShareLinks"
:file-name="selectedFile?.name ?? null" :file-name="selectedFile?.name ?? null"
:upload="selectedUpload" :upload="selectedUpload"
:thumbnail-url="selectedThumbnailUrl"
/> />
<MetadataInspector :file-name="selectedFile?.name ?? null" :entry="selectedPreview" /> <MetadataInspector
:file-name="selectedFile?.name ?? null"
<UploadConfigureModal :entry="selectedPreview"
v-model:open="configureModalOpen" :thumbnail-url="selectedThumbnailUrl"
v-model:settings="batchSettings"
:metadata-error-count="metadataErrorCount"
@upload="onBatchUpload"
/> />
</main> </main>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeMount, ref } from 'vue'; import { computed, onBeforeMount, onBeforeUnmount, ref } from 'vue';
import type UploadDropzone from '@/components/upload/UploadDropzone.vue'; import type UploadIntakeControls from '@/components/upload/UploadIntakeControls.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';
@@ -66,6 +48,8 @@ import { makeKeyFromFile } from '@/utils/file/makeKeyFromFile';
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 { formatUploadBatchSettingsSummary } from '@/utils/upload/formatUploadBatchSettingsSummary';
import { createQueueThumbnailUrls } from '@/utils/upload/createQueueThumbnailUrls';
import { revokeQueueThumbnailUrls } from '@/utils/upload/revokeQueueThumbnailUrls';
import prettyBytes from 'pretty-bytes'; import prettyBytes from 'pretty-bytes';
const { maxQueueSize, maxImageBytes } = config.validation.upload; const { maxQueueSize, maxImageBytes } = config.validation.upload;
@@ -83,7 +67,7 @@ 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 batchUploading = ref(false); const batchUploading = ref(false);
const dropzoneRef = ref<InstanceType<typeof UploadDropzone> | null>(null); const intakeControlsRef = ref<InstanceType<typeof UploadIntakeControls> | null>(null);
const batchSettings = ref<UploadBatchSettingsForm>({ const batchSettings = ref<UploadBatchSettingsForm>({
mode: 'zk', mode: 'zk',
expiryPreset: 'never', expiryPreset: 'never',
@@ -91,10 +75,10 @@ const batchSettings = ref<UploadBatchSettingsForm>({
passwordEnabled: false, passwordEnabled: false,
password: '' password: ''
}); });
const configureModalOpen = ref(false);
const uploadConfirmed = ref(false); const uploadConfirmed = ref(false);
const showShareLinks = ref(false); const showShareLinks = ref(false);
const confirmedBatchSummary = ref<string | null>(null); const confirmedBatchSummary = ref<string | null>(null);
const thumbnailUrlsByKey = ref<Record<string, string>>({});
onBeforeMount(async () => { onBeforeMount(async () => {
try { try {
@@ -104,6 +88,10 @@ onBeforeMount(async () => {
} }
}); });
onBeforeUnmount(() => {
resetUploadSession();
});
const maxImageSizeFormatted = computed(() => prettyBytes(maxImageBytes, { binary: true, maximumFractionDigits: 1 })); const maxImageSizeFormatted = computed(() => prettyBytes(maxImageBytes, { binary: true, maximumFractionDigits: 1 }));
const queueRows = computed(() => const queueRows = computed(() =>
@@ -113,6 +101,7 @@ const queueRows = computed(() =>
return { return {
file, file,
key, key,
thumbnailUrl: thumbnailUrlsByKey.value[key] ?? null,
metadata: metadataStore.getPreview(file), metadata: metadataStore.getPreview(file),
upload: uploadStore.getUpload(file) upload: uploadStore.getUpload(file)
}; };
@@ -121,6 +110,16 @@ const queueRows = computed(() =>
const selectedPreview = computed(() => metadataStore.getPreview(selectedFile.value)); const selectedPreview = computed(() => metadataStore.getPreview(selectedFile.value));
const selectedThumbnailUrl = computed((): string | null => {
if (!selectedFile.value) {
return null;
}
const key = makeKeyFromFile(selectedFile.value);
return thumbnailUrlsByKey.value[key] ?? null;
});
const selectedUpload = computed(() => uploadStore.getUpload(selectedFile.value)); const selectedUpload = computed(() => uploadStore.getUpload(selectedFile.value));
const metadataErrorCount = computed( const metadataErrorCount = computed(
@@ -164,6 +163,7 @@ const onFilesPicked = async (picked: File[]): Promise<void> => {
); );
queuedFiles.value = accepted; queuedFiles.value = accepted;
thumbnailUrlsByKey.value = createQueueThumbnailUrls(accepted);
selectedFile.value = accepted[0] ?? null; selectedFile.value = accepted[0] ?? null;
loadingMetadata.value = true; loadingMetadata.value = true;
@@ -176,15 +176,16 @@ const onFilesPicked = async (picked: File[]): Promise<void> => {
loadingMetadata.value = false; loadingMetadata.value = false;
} }
}; };
const resetUploadSession = (): void => { const resetUploadSession = (): void => {
selectionError.value = null; selectionError.value = null;
revokeQueueThumbnailUrls(thumbnailUrlsByKey.value);
thumbnailUrlsByKey.value = {};
clearAllPreviews(); clearAllPreviews();
queuedFiles.value = []; queuedFiles.value = [];
selectedFile.value = null; selectedFile.value = null;
clearAllUploads(); clearAllUploads();
dropzoneRef.value?.clearInput(); intakeControlsRef.value?.clearInput();
configureModalOpen.value = false; intakeControlsRef.value?.closeConfigureModal();
uploadConfirmed.value = false; uploadConfirmed.value = false;
showShareLinks.value = false; showShareLinks.value = false;
confirmedBatchSummary.value = null; confirmedBatchSummary.value = null;
@@ -195,8 +196,6 @@ const onBatchUpload = async (uploadDespiteMetadataFailure?: boolean): Promise<vo
confirmedBatchSummary.value = formatUploadBatchSettingsSummary(settings); confirmedBatchSummary.value = formatUploadBatchSettingsSummary(settings);
uploadConfirmed.value = true; uploadConfirmed.value = true;
configureModalOpen.value = false;
batchUploading.value = true; batchUploading.value = true;
try { try {
-118
View File
@@ -1,118 +0,0 @@
<template>
<main class="flex min-h-screen items-center justify-center p-4">
<p v-if="fetching" class="text-sm text-muted-foreground">Loading image...</p>
<form v-else-if="zkPayload" class="flex w-full max-w-sm flex-col gap-3" @submit.prevent="onUnlockSubmit">
<label class="flex flex-col gap-1.5 text-sm">
<span>Password</span>
<input
v-model="password"
type="password"
autocomplete="current-password"
class="rounded-md border border-input bg-background px-3 py-2 text-sm"
:disabled="unlocking"
/>
</label>
<p v-if="unlockError" class="text-sm text-destructive">{{ unlockError }}</p>
<button
type="submit"
class="inline-flex 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"
:disabled="unlocking || !password"
>
{{ unlocking ? 'Unlocking...' : 'View image' }}
</button>
</form>
<img v-else-if="imageObjectUrl" :src="imageObjectUrl" class="max-h-[80vh] max-w-full object-contain" />
</main>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia';
import { onBeforeMount, onBeforeUnmount, ref } from 'vue';
import { useRoute } from 'vue-router';
import { useViewStore } from '@/stores/view';
import type { GetZkUploadCiphertextResult } from '@/types/upload/GetZkUploadCiphertextResult';
const route = useRoute();
const viewStore = useViewStore();
const { imageObjectUrl } = storeToRefs(viewStore);
const fetching = ref(false);
const unlocking = ref(false);
const zkPayload = ref<GetZkUploadCiphertextResult | null>(null);
const fragmentKey = ref('');
const password = ref('');
const unlockError = ref('');
onBeforeMount(async () => {
const uploadId = route.params.id;
if (typeof uploadId !== 'string') {
return;
}
const key = route.hash.slice(1);
if (!key) {
return;
}
fragmentKey.value = key;
fetching.value = true;
try {
const payload = await viewStore.fetchZkCiphertext(uploadId);
if (payload.passwordProtected) {
zkPayload.value = payload;
return;
}
await viewStore.loadZk(payload, key);
} catch (error) {
console.error('[viewer] load failed', { uploadId, error });
} finally {
fetching.value = false;
}
});
onBeforeUnmount(() => {
cleanRefs();
viewStore.clearImage();
});
const onUnlockSubmit = async (): Promise<void> => {
const payload = zkPayload.value;
const key = fragmentKey.value;
if (!payload || !key) {
return;
}
unlocking.value = true;
unlockError.value = '';
try {
await viewStore.loadZk(payload, key, password.value);
zkPayload.value = null;
password.value = '';
} catch (error) {
console.error('[viewer] unlock failed', { error });
unlockError.value = 'Wrong password';
} finally {
unlocking.value = false;
}
};
const cleanRefs = (): void => {
zkPayload.value = null;
fragmentKey.value = '';
password.value = '';
unlockError.value = '';
};
</script>
+144
View File
@@ -0,0 +1,144 @@
<template>
<main
class="mx-auto flex w-full max-w-lg flex-1 flex-col items-center justify-center gap-3 px-4 py-6 text-center sm:px-6 sm:py-8"
>
<p v-if="state === 'loading'" class="text-sm text-muted-foreground sm:text-base">Loading image...</p>
<p v-else-if="state === 'invalidLink'" class="text-sm text-muted-foreground">
This viewing link is incomplete. Use the viewer URL from your upload results.
</p>
<p v-else-if="state === 'notFound'" class="text-sm text-muted-foreground">This image was not found.</p>
<form
v-else-if="state === 'password'"
class="flex w-full max-w-sm flex-col gap-3 text-left"
@submit.prevent="onUnlockSubmit"
>
<label class="flex flex-col gap-1.5 text-sm">
<span>Password</span>
<input
v-model="password"
type="password"
autocomplete="current-password"
class="rounded-md border border-input bg-background px-3 py-2.5 text-sm sm:py-2"
:disabled="unlocking"
/>
</label>
<p v-if="unlockError" class="text-sm text-destructive">{{ unlockError }}</p>
<button
type="submit"
class="inline-flex w-full items-center justify-center rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground transition-colors hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60 sm:py-2"
:disabled="unlocking || !password"
>
{{ unlocking ? 'Unlocking...' : 'View image' }}
</button>
</form>
<img
v-else-if="state === 'image' && imageObjectUrl"
:src="imageObjectUrl"
class="max-h-[min(80vh,100dvh)] w-full object-contain"
/>
</main>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia';
import { onBeforeMount, onBeforeUnmount, ref } from 'vue';
import { useRoute } from 'vue-router';
import { useZkViewStore } from '@/stores/zkView';
import type { GetZkUploadCiphertextResult } from '@/types/upload/GetZkUploadCiphertextResult';
import type { ZkViewerViewState } from '@/types/view/ZkViewerViewState';
const route = useRoute();
const zkViewStore = useZkViewStore();
const { imageObjectUrl } = storeToRefs(zkViewStore);
const state = ref<ZkViewerViewState>('loading');
const unlocking = ref(false);
const zkPayload = ref<GetZkUploadCiphertextResult | null>(null);
const fragmentKey = ref('');
const password = ref('');
const unlockError = ref('');
onBeforeMount(async () => {
const uploadId = route.params.id;
if (typeof uploadId !== 'string' || !uploadId) {
state.value = 'invalidLink';
return;
}
const key = route.hash.slice(1);
if (!key) {
state.value = 'invalidLink';
return;
}
fragmentKey.value = key;
state.value = 'loading';
try {
const payload = await zkViewStore.fetchZkCiphertext(uploadId);
if (payload.passwordProtected) {
zkPayload.value = payload;
state.value = 'password';
return;
}
await zkViewStore.loadZk(payload, key);
state.value = 'image';
} catch (error) {
console.error('[zk-viewer] load failed', { uploadId, error });
state.value = 'notFound';
}
});
onBeforeUnmount(() => {
cleanRefs();
zkViewStore.clearImage();
});
const onUnlockSubmit = async (): Promise<void> => {
const payload = zkPayload.value;
const key = fragmentKey.value;
if (!payload || !key) {
return;
}
unlocking.value = true;
unlockError.value = '';
try {
await zkViewStore.loadZk(payload, key, password.value);
zkPayload.value = null;
password.value = '';
state.value = 'image';
} catch (error) {
console.error('[zk-viewer] unlock failed', { error });
unlockError.value = 'Could not unlock this image. Check the viewer link and password, then try again.';
} finally {
unlocking.value = false;
}
};
const cleanRefs = (): void => {
zkPayload.value = null;
fragmentKey.value = '';
password.value = '';
unlockError.value = '';
};
</script>