Merge pull request 'Slave/ui polishing' (#13) from slave/ui-polishing into master
Reviewed-on: #13
This commit was merged in pull request #13.
This commit is contained in:
@@ -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 |
@@ -1,3 +1,9 @@
|
|||||||
<template>
|
<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>
|
</template>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+7
-1
@@ -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">×</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>
|
<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
|
||||||
<span class="shrink-0 text-xs text-muted-foreground">
|
v-if="row.thumbnailUrl"
|
||||||
{{ prettyBytes(row.file.size, { maximumFractionDigits: 1 }) }}
|
:src="row.thumbnailUrl"
|
||||||
</span>
|
class="h-10 w-10 shrink-0 rounded border border-border object-cover"
|
||||||
<span class="shrink-0 max-w-32 truncate text-xs sm:max-w-40" :class="metadataStatusClass(row.metadata)">
|
loading="lazy"
|
||||||
{{ row.metadata.message }}
|
decoding="async"
|
||||||
</span>
|
/>
|
||||||
<span class="shrink-0 max-w-32 truncate text-xs sm:max-w-40" :class="uploadStatusClass(row.upload)">
|
<span
|
||||||
{{ row.upload.message }}
|
v-else
|
||||||
</span>
|
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>
|
</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="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">
|
<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="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">
|
<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
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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>
|
<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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,32 +1,16 @@
|
|||||||
<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"
|
||||||
@pick="onFilesPicked"
|
:can-open-configure="canOpenConfigure"
|
||||||
/>
|
:upload-confirmed="uploadConfirmed"
|
||||||
|
:metadata-error-count="metadataErrorCount"
|
||||||
<button
|
@pick="onFilesPicked"
|
||||||
v-if="!uploadConfirmed"
|
@upload="onBatchUpload"
|
||||||
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 {
|
||||||
|
|||||||
@@ -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