Slave/ui polishing #13
@@ -2,6 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Private Image</title>
|
||||
</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 |
@@ -1,3 +1,9 @@
|
||||
<template>
|
||||
<router-view />
|
||||
<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 />
|
||||
</div>
|
||||
<AppFooter />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
@@ -28,43 +26,23 @@
|
||||
|
||||
:root {
|
||||
--radius: 0.5rem;
|
||||
--background: oklch(0.99 0.002 260);
|
||||
--foreground: oklch(0.2 0.02 260);
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.244 0.012 263);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.2 0.02 260);
|
||||
--primary: oklch(0.42 0.08 260);
|
||||
--primary-foreground: oklch(0.99 0.002 260);
|
||||
--secondary: oklch(0.96 0.01 260);
|
||||
--secondary-foreground: oklch(0.28 0.03 260);
|
||||
--muted: oklch(0.96 0.008 260);
|
||||
--muted-foreground: oklch(0.48 0.02 260);
|
||||
--accent: oklch(0.94 0.02 260);
|
||||
--accent-foreground: oklch(0.28 0.03 260);
|
||||
--card-foreground: oklch(0.244 0.012 263);
|
||||
--primary: oklch(0.497 0.227 263);
|
||||
--primary-foreground: oklch(1 0 0);
|
||||
--secondary: oklch(0.94 0.025 263);
|
||||
--secondary-foreground: oklch(0.28 0.04 263);
|
||||
--muted: oklch(0.955 0.018 263);
|
||||
--muted-foreground: oklch(0.46 0.04 263);
|
||||
--accent: oklch(0.93 0.04 263);
|
||||
--accent-foreground: oklch(0.28 0.04 263);
|
||||
--destructive: oklch(0.55 0.2 25);
|
||||
--success: oklch(0.45 0.14 155);
|
||||
--border: oklch(0.9 0.01 260);
|
||||
--input: oklch(0.9 0.01 260);
|
||||
--ring: oklch(0.42 0.08 260);
|
||||
}
|
||||
|
||||
.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);
|
||||
--border: oklch(0.88 0.025 263);
|
||||
--input: oklch(0.88 0.025 263);
|
||||
--ring: oklch(0.497 0.227 263);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -72,7 +50,80 @@
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
html {
|
||||
@apply w-full min-w-0 overflow-x-hidden;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
Vendored
+7
-1
@@ -11,6 +11,11 @@ export {}
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
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']
|
||||
MetadataInspector: typeof import('./components/upload/MetadataInspector.vue')['default']
|
||||
MetadataOutputPanel: typeof import('./components/upload/MetadataOutputPanel.vue')['default']
|
||||
@@ -18,7 +23,8 @@ declare module 'vue' {
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
ShareLinkField: typeof import('./components/upload/ShareLinkField.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']
|
||||
UploadSettingsPanel: typeof import('./components/upload/UploadSettingsPanel.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">×</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>
|
||||
@@ -1,10 +1,10 @@
|
||||
<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
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
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="
|
||||
modelValue === option.value
|
||||
? '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>
|
||||
<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">
|
||||
<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>
|
||||
</header>
|
||||
<UploadPanelHeader
|
||||
title="Metadata Inspector"
|
||||
:file-name="fileName"
|
||||
:thumbnail-url="thumbnailUrl"
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
<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 v-if="entry.output" class="text-xs font-medium text-foreground">Original</p>
|
||||
<MetadataOutputPanel v-if="entry.output">{{ entry.output }}</MetadataOutputPanel>
|
||||
</template>
|
||||
|
||||
<template v-else-if="entry.status === 'ready'">
|
||||
<template v-else-if="showMetadataTabs">
|
||||
<InlineTabList v-model="activeTab" :options="metadataTabOptions" />
|
||||
|
||||
<MetadataOutputPanel>
|
||||
{{ activeTab === 'original' ? entry.output : entry.afterStripOutput }}
|
||||
</MetadataOutputPanel>
|
||||
<MetadataOutputPanel v-if="tabBody?.kind === 'output'">{{ tabBody.text }}</MetadataOutputPanel>
|
||||
|
||||
<p v-else-if="tabBody?.kind === 'error'" class="text-sm text-destructive">{{ tabBody.text }}</p>
|
||||
</template>
|
||||
|
||||
<p v-else class="text-sm text-muted-foreground">{{ entry.message }}</p>
|
||||
@@ -28,8 +27,9 @@
|
||||
</template>
|
||||
|
||||
<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 { MetadataTabBody } from '@/types/metadata/MetadataTabBody';
|
||||
import type { MetadataTab } from '@/types/metadata/MetadataTab';
|
||||
import type { InlineTabOption } from '@/types/ui/InlineTabOption';
|
||||
|
||||
@@ -46,11 +46,49 @@ const props = defineProps({
|
||||
entry: {
|
||||
type: Object as PropType<MetadataPreviewEntry>,
|
||||
required: true
|
||||
},
|
||||
thumbnailUrl: {
|
||||
type: String as PropType<string | null>,
|
||||
default: null
|
||||
}
|
||||
});
|
||||
|
||||
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(
|
||||
() => props.fileName,
|
||||
() => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<label class="flex flex-col gap-1.5 text-xs">
|
||||
<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
|
||||
type="text"
|
||||
readonly
|
||||
@@ -10,7 +10,7 @@
|
||||
/>
|
||||
<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"
|
||||
>
|
||||
{{ copied ? 'Copied' : 'Copy' }}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<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 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">
|
||||
<header class="border-b border-border px-4 py-3">
|
||||
<h2 class="text-base font-medium text-card-foreground">
|
||||
@@ -37,7 +37,7 @@
|
||||
|
||||
<div
|
||||
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">
|
||||
<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">
|
||||
<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' : ''"
|
||||
@click="emit('select', row.file)"
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-medium">{{ row.file.name }}</span>
|
||||
<span class="shrink-0 text-xs text-muted-foreground">
|
||||
{{ prettyBytes(row.file.size, { maximumFractionDigits: 1 }) }}
|
||||
</span>
|
||||
<span class="shrink-0 max-w-32 truncate text-xs sm:max-w-40" :class="metadataStatusClass(row.metadata)">
|
||||
{{ row.metadata.message }}
|
||||
</span>
|
||||
<span class="shrink-0 max-w-32 truncate text-xs sm:max-w-40" :class="uploadStatusClass(row.upload)">
|
||||
{{ row.upload.message }}
|
||||
</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">
|
||||
{{ prettyBytes(row.file.size, { maximumFractionDigits: 1 }) }}
|
||||
</span>
|
||||
</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 }}
|
||||
</span>
|
||||
<span class="min-w-0 truncate" :class="uploadStatusClass(row.upload)">
|
||||
{{ row.upload.message }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -9,12 +9,20 @@
|
||||
>
|
||||
<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>
|
||||
<span class="inline-flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<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">
|
||||
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.
|
||||
E2E encrypted, for direct sharing, can't be embedded.
|
||||
</p>
|
||||
</span>
|
||||
</label>
|
||||
@@ -24,34 +32,32 @@
|
||||
>
|
||||
<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>
|
||||
<span class="inline-flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<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">
|
||||
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">
|
||||
<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.
|
||||
Still encrypted at rest, but support embedding, for forums, websites, etc.
|
||||
</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">
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<label class="flex min-w-0 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"
|
||||
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.label }}
|
||||
@@ -59,11 +65,11 @@
|
||||
</select>
|
||||
</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>
|
||||
<select
|
||||
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.label }}
|
||||
@@ -77,9 +83,7 @@
|
||||
<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>
|
||||
<p class="text-xs text-muted-foreground">Requires the link and this password to decrypt.</p>
|
||||
|
||||
<template v-if="settings.passwordEnabled">
|
||||
<label class="flex flex-col gap-1.5 text-sm">
|
||||
@@ -88,7 +92,7 @@
|
||||
v-model="settings.password"
|
||||
type="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>
|
||||
</template>
|
||||
@@ -98,6 +102,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { ROUTE_NAMES } from '@/consts/routeNames';
|
||||
import { config } from '@/config';
|
||||
import type { UploadBatchSettingsForm } from '@/types/upload/UploadBatchSettingsForm';
|
||||
import type { UploadExpiryPreset } from '@/types/upload/UploadExpiryPreset';
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
<template>
|
||||
<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">
|
||||
<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>
|
||||
<UploadPanelHeader title="Share links" :file-name="fileName" :thumbnail-url="thumbnailUrl" />
|
||||
|
||||
<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>
|
||||
@@ -60,6 +57,10 @@ const props = defineProps({
|
||||
upload: {
|
||||
type: Object as PropType<UploadQueueEntry>,
|
||||
required: true
|
||||
},
|
||||
thumbnailUrl: {
|
||||
type: String as PropType<string | null>,
|
||||
default: null
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export const ROUTE_NAMES = {
|
||||
Upload: 'Upload',
|
||||
Viewer: 'Viewer',
|
||||
About: 'About',
|
||||
ZkViewer: 'ZkViewer',
|
||||
HotlinkViewer: 'HotlinkViewer',
|
||||
Delete: 'Delete'
|
||||
} as const;
|
||||
|
||||
@@ -3,6 +3,17 @@ import { ROUTE_NAMES } from '@/consts/routeNames';
|
||||
|
||||
const router = createRouter({
|
||||
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: [
|
||||
{
|
||||
path: '/',
|
||||
@@ -10,17 +21,23 @@ const router = createRouter({
|
||||
component: () => import('@/views/UploadView.vue'),
|
||||
meta: { title: 'Private Image - Upload' }
|
||||
},
|
||||
{
|
||||
path: '/about',
|
||||
name: ROUTE_NAMES.About,
|
||||
component: () => import('@/views/AboutView.vue'),
|
||||
meta: { title: 'Private Image - About' }
|
||||
},
|
||||
{
|
||||
path: '/v/:id',
|
||||
name: ROUTE_NAMES.Viewer,
|
||||
component: () => import('@/views/ViewerView.vue'),
|
||||
meta: { title: 'Private Image - Viewer' }
|
||||
name: ROUTE_NAMES.ZkViewer,
|
||||
component: () => import('@/views/ZkViewerView.vue'),
|
||||
meta: { title: 'Private Image - ZK Viewer' }
|
||||
},
|
||||
{
|
||||
path: '/h/:id/:secret',
|
||||
name: ROUTE_NAMES.HotlinkViewer,
|
||||
component: () => import('@/views/HotlinkViewerView.vue'),
|
||||
meta: { title: 'Private Image - Viewer' }
|
||||
meta: { title: 'Private Image - Hotlink Viewer' }
|
||||
},
|
||||
{
|
||||
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;
|
||||
|
||||
@@ -7,7 +7,7 @@ import { toArrayBuffer } from '@/utils/toArrayBuffer';
|
||||
import { decryptPasswordProtectedZkUpload } from '@/utils/upload/decryptPasswordProtectedZkUpload';
|
||||
import { decryptZkUpload } from '@/utils/upload/decryptZkUpload';
|
||||
|
||||
export const useViewStore = defineStore('view', () => {
|
||||
export const useZkViewStore = defineStore('zkView', () => {
|
||||
const imageObjectUrl = ref<string | null>(null);
|
||||
|
||||
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 = {
|
||||
file: File;
|
||||
key: string;
|
||||
thumbnailUrl: string | null;
|
||||
metadata: MetadataPreviewEntry;
|
||||
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);
|
||||
}
|
||||
};
|
||||
@@ -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,5 +1,5 @@
|
||||
<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">
|
||||
This delete link is incomplete. Use the delete URL from your upload results.
|
||||
</p>
|
||||
|
||||
@@ -1,29 +1,62 @@
|
||||
<template>
|
||||
<main class="flex min-h-screen items-center justify-center p-4">
|
||||
<img v-if="imageSrc" :src="imageSrc" class="max-h-[80vh] max-w-full object-contain" />
|
||||
<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 === '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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { onBeforeMount, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { config } from '@/config';
|
||||
import type { HotlinkViewerViewState } from '@/types/view/HotlinkViewerViewState';
|
||||
import { buildHotlinkImagePath } from '@/utils/share/buildHotlinkImagePath';
|
||||
|
||||
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 secret = route.params.secret;
|
||||
|
||||
if (typeof uploadId !== 'string' || typeof secret !== 'string' || !uploadId || !secret) {
|
||||
return null;
|
||||
state.value = 'invalidLink';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const { baseUrlRoot } = config.api;
|
||||
|
||||
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>
|
||||
|
||||
@@ -1,32 +1,16 @@
|
||||
<template>
|
||||
<main class="mx-auto flex min-h-screen max-w-3xl flex-col gap-6 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>
|
||||
|
||||
<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 class="flex flex-col gap-2">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<UploadDropzone
|
||||
ref="dropzoneRef"
|
||||
:disabled="loadingMetadata || batchUploading"
|
||||
@pick="onFilesPicked"
|
||||
/>
|
||||
|
||||
<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>
|
||||
<UploadIntakeControls
|
||||
ref="intakeControlsRef"
|
||||
v-model:settings="batchSettings"
|
||||
:disabled="loadingMetadata || batchUploading"
|
||||
:can-open-configure="canOpenConfigure"
|
||||
:upload-confirmed="uploadConfirmed"
|
||||
:metadata-error-count="metadataErrorCount"
|
||||
@pick="onFilesPicked"
|
||||
@upload="onBatchUpload"
|
||||
/>
|
||||
|
||||
<p v-if="selectionError" class="text-sm text-destructive">{{ selectionError }}</p>
|
||||
</div>
|
||||
@@ -41,22 +25,20 @@
|
||||
v-if="showShareLinks"
|
||||
:file-name="selectedFile?.name ?? null"
|
||||
:upload="selectedUpload"
|
||||
:thumbnail-url="selectedThumbnailUrl"
|
||||
/>
|
||||
|
||||
<MetadataInspector :file-name="selectedFile?.name ?? null" :entry="selectedPreview" />
|
||||
|
||||
<UploadConfigureModal
|
||||
v-model:open="configureModalOpen"
|
||||
v-model:settings="batchSettings"
|
||||
:metadata-error-count="metadataErrorCount"
|
||||
@upload="onBatchUpload"
|
||||
<MetadataInspector
|
||||
:file-name="selectedFile?.name ?? null"
|
||||
:entry="selectedPreview"
|
||||
:thumbnail-url="selectedThumbnailUrl"
|
||||
/>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeMount, ref } from 'vue';
|
||||
import type UploadDropzone from '@/components/upload/UploadDropzone.vue';
|
||||
import { computed, onBeforeMount, onBeforeUnmount, ref } from 'vue';
|
||||
import type UploadIntakeControls from '@/components/upload/UploadIntakeControls.vue';
|
||||
import { config } from '@/config';
|
||||
import { useExiftoolStore } from '@/stores/exiftool';
|
||||
import { useMetadataStore } from '@/stores/metadata';
|
||||
@@ -66,6 +48,8 @@ import { makeKeyFromFile } from '@/utils/file/makeKeyFromFile';
|
||||
import { analyzePickedUploadFiles } from '@/utils/upload/analyzePickedUploadFiles';
|
||||
import { buildPickedUploadWarning } from '@/utils/upload/buildPickedUploadWarning';
|
||||
import { formatUploadBatchSettingsSummary } from '@/utils/upload/formatUploadBatchSettingsSummary';
|
||||
import { createQueueThumbnailUrls } from '@/utils/upload/createQueueThumbnailUrls';
|
||||
import { revokeQueueThumbnailUrls } from '@/utils/upload/revokeQueueThumbnailUrls';
|
||||
import prettyBytes from 'pretty-bytes';
|
||||
|
||||
const { maxQueueSize, maxImageBytes } = config.validation.upload;
|
||||
@@ -83,7 +67,7 @@ const selectedFile = ref<File | null>(null);
|
||||
const selectionError = ref<string | null>(null);
|
||||
const loadingMetadata = 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>({
|
||||
mode: 'zk',
|
||||
expiryPreset: 'never',
|
||||
@@ -91,10 +75,10 @@ const batchSettings = ref<UploadBatchSettingsForm>({
|
||||
passwordEnabled: false,
|
||||
password: ''
|
||||
});
|
||||
const configureModalOpen = ref(false);
|
||||
const uploadConfirmed = ref(false);
|
||||
const showShareLinks = ref(false);
|
||||
const confirmedBatchSummary = ref<string | null>(null);
|
||||
const thumbnailUrlsByKey = ref<Record<string, string>>({});
|
||||
|
||||
onBeforeMount(async () => {
|
||||
try {
|
||||
@@ -104,6 +88,10 @@ onBeforeMount(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resetUploadSession();
|
||||
});
|
||||
|
||||
const maxImageSizeFormatted = computed(() => prettyBytes(maxImageBytes, { binary: true, maximumFractionDigits: 1 }));
|
||||
|
||||
const queueRows = computed(() =>
|
||||
@@ -113,6 +101,7 @@ const queueRows = computed(() =>
|
||||
return {
|
||||
file,
|
||||
key,
|
||||
thumbnailUrl: thumbnailUrlsByKey.value[key] ?? null,
|
||||
metadata: metadataStore.getPreview(file),
|
||||
upload: uploadStore.getUpload(file)
|
||||
};
|
||||
@@ -121,6 +110,16 @@ const queueRows = computed(() =>
|
||||
|
||||
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 metadataErrorCount = computed(
|
||||
@@ -164,6 +163,7 @@ const onFilesPicked = async (picked: File[]): Promise<void> => {
|
||||
);
|
||||
|
||||
queuedFiles.value = accepted;
|
||||
thumbnailUrlsByKey.value = createQueueThumbnailUrls(accepted);
|
||||
selectedFile.value = accepted[0] ?? null;
|
||||
|
||||
loadingMetadata.value = true;
|
||||
@@ -176,15 +176,16 @@ const onFilesPicked = async (picked: File[]): Promise<void> => {
|
||||
loadingMetadata.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const resetUploadSession = (): void => {
|
||||
selectionError.value = null;
|
||||
revokeQueueThumbnailUrls(thumbnailUrlsByKey.value);
|
||||
thumbnailUrlsByKey.value = {};
|
||||
clearAllPreviews();
|
||||
queuedFiles.value = [];
|
||||
selectedFile.value = null;
|
||||
clearAllUploads();
|
||||
dropzoneRef.value?.clearInput();
|
||||
configureModalOpen.value = false;
|
||||
intakeControlsRef.value?.clearInput();
|
||||
intakeControlsRef.value?.closeConfigureModal();
|
||||
uploadConfirmed.value = false;
|
||||
showShareLinks.value = false;
|
||||
confirmedBatchSummary.value = null;
|
||||
@@ -195,8 +196,6 @@ const onBatchUpload = async (uploadDespiteMetadataFailure?: boolean): Promise<vo
|
||||
|
||||
confirmedBatchSummary.value = formatUploadBatchSettingsSummary(settings);
|
||||
uploadConfirmed.value = true;
|
||||
configureModalOpen.value = false;
|
||||
|
||||
batchUploading.value = true;
|
||||
|
||||
try {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user