add about page with features and competitor comparison.

Includes section nav, flow diagrams with lightbox, comparison table, brand theme tokens, and router hash scroll.
This commit is contained in:
2026-10-29 18:14:18 +01:00
parent eed17c9aab
commit fa1c2abb51
10 changed files with 551 additions and 36 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

+78 -36
View File
@@ -1,7 +1,5 @@
@import 'tailwindcss';
@custom-variant dark (&:is(.dark *));
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
@@ -28,43 +26,23 @@
:root {
--radius: 0.5rem;
--background: oklch(0.99 0.002 260);
--foreground: oklch(0.2 0.02 260);
--background: oklch(1 0 0);
--foreground: oklch(0.244 0.012 263);
--card: oklch(1 0 0);
--card-foreground: oklch(0.2 0.02 260);
--primary: oklch(0.42 0.08 260);
--primary-foreground: oklch(0.99 0.002 260);
--secondary: oklch(0.96 0.01 260);
--secondary-foreground: oklch(0.28 0.03 260);
--muted: oklch(0.96 0.008 260);
--muted-foreground: oklch(0.48 0.02 260);
--accent: oklch(0.94 0.02 260);
--accent-foreground: oklch(0.28 0.03 260);
--card-foreground: oklch(0.244 0.012 263);
--primary: oklch(0.497 0.227 263);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.94 0.025 263);
--secondary-foreground: oklch(0.28 0.04 263);
--muted: oklch(0.955 0.018 263);
--muted-foreground: oklch(0.46 0.04 263);
--accent: oklch(0.93 0.04 263);
--accent-foreground: oklch(0.28 0.04 263);
--destructive: oklch(0.55 0.2 25);
--success: oklch(0.45 0.14 155);
--border: oklch(0.9 0.01 260);
--input: oklch(0.9 0.01 260);
--ring: oklch(0.42 0.08 260);
}
.dark {
--background: oklch(0.16 0.02 260);
--foreground: oklch(0.96 0.01 260);
--card: oklch(0.2 0.02 260);
--card-foreground: oklch(0.96 0.01 260);
--primary: oklch(0.72 0.1 260);
--primary-foreground: oklch(0.18 0.02 260);
--secondary: oklch(0.24 0.02 260);
--secondary-foreground: oklch(0.96 0.01 260);
--muted: oklch(0.24 0.02 260);
--muted-foreground: oklch(0.68 0.02 260);
--accent: oklch(0.28 0.03 260);
--accent-foreground: oklch(0.96 0.01 260);
--destructive: oklch(0.62 0.2 25);
--success: oklch(0.68 0.14 155);
--border: oklch(0.32 0.02 260);
--input: oklch(0.32 0.02 260);
--ring: oklch(0.72 0.1 260);
--border: oklch(0.88 0.025 263);
--input: oklch(0.88 0.025 263);
--ring: oklch(0.497 0.227 263);
}
@layer base {
@@ -76,3 +54,67 @@
@apply bg-background text-foreground antialiased;
}
}
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: 1.5rem;
}
.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(95vw, 75rem);
max-height: 90vh;
width: auto;
height: auto;
object-fit: contain;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
}
.lightbox__close {
position: absolute;
top: 1rem;
right: 1rem;
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);
}
+4
View File
@@ -11,6 +11,10 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
AboutComparisonTable: typeof import('./components/about/AboutComparisonTable.vue')['default']
AboutSectionNav: typeof import('./components/about/AboutSectionNav.vue')['default']
AppHeader: typeof import('./components/layout/AppHeader.vue')['default']
ExpandableImage: typeof import('./components/ExpandableImage.vue')['default']
InlineTabList: typeof import('./components/InlineTabList.vue')['default']
MetadataInspector: typeof import('./components/upload/MetadataInspector.vue')['default']
MetadataOutputPanel: typeof import('./components/upload/MetadataOutputPanel.vue')['default']
@@ -0,0 +1,72 @@
<template>
<div class="expandable-image">
<figure class="m-0">
<a
:href="`#${lightboxId}`"
class="expandable-image__link block"
:aria-label="expandLabel"
>
<img
:src="src"
:alt="alt"
class="w-full rounded-lg border border-border transition-colors hover:border-primary/50"
:width="width"
:height="height"
loading="lazy"
decoding="async"
/>
</a>
</figure>
<div
:id="lightboxId"
class="lightbox"
role="dialog"
aria-modal="true"
:aria-label="expandLabel"
>
<a class="lightbox__backdrop" :href="closeHash" aria-label="Close" />
<img
class="lightbox__img"
:src="src"
:alt="alt"
:width="width"
:height="height"
/>
<a class="lightbox__close" :href="closeHash" aria-label="Close">&times;</a>
</div>
</div>
</template>
<script setup lang="ts">
defineProps({
src: {
type: String,
required: true
},
alt: {
type: String,
required: true
},
width: {
type: Number,
required: true
},
height: {
type: Number,
required: true
},
lightboxId: {
type: String,
required: true
},
closeHash: {
type: String,
required: true
},
expandLabel: {
type: String,
required: true
}
});
</script>
@@ -0,0 +1,137 @@
<template>
<div class="overflow-x-auto rounded-lg border border-border">
<table class="w-full min-w-160 border-collapse text-left text-sm">
<thead>
<tr class="border-b border-border bg-muted/40">
<th class="sticky left-0 z-10 min-w-48 bg-muted/40 px-3 py-2.5 font-medium text-foreground">
Feature
</th>
<th
v-for="host in comparisonHosts"
:key="host.id"
class="min-w-38 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-card 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: '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' },
{ id: 'compare', label: 'Compare hosts' }
];
</script>
+23
View File
@@ -3,6 +3,17 @@ import { ROUTE_NAMES } from '@/consts/routeNames';
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
scrollBehavior(to, _from, savedPosition) {
if (savedPosition) {
return savedPosition;
}
if (to.hash) {
return { el: to.hash, behavior: 'smooth' };
}
return { top: 0 };
},
routes: [
{
path: '/',
@@ -10,6 +21,12 @@ const router = createRouter({
component: () => import('@/views/UploadView.vue'),
meta: { title: 'Private Image - Upload' }
},
{
path: '/about',
name: ROUTE_NAMES.About,
component: () => import('@/views/AboutView.vue'),
meta: { title: 'Private Image - About' }
},
{
path: '/v/:id',
name: ROUTE_NAMES.Viewer,
@@ -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;
@@ -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>;
};
+197
View File
@@ -0,0 +1,197 @@
<template>
<main class="mx-auto max-w-5xl flex-1 px-4 py-8 sm:px-6">
<article class="max-w-3xl">
<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="exiftool" class="mt-14 max-w-3xl 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 max-w-3xl scroll-mt-20">
<h2 class="text-lg font-medium text-foreground">Share modes</h2>
<h3 id="zero-knowledge" class="mt-6 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="471"
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-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="527"
lightbox-id="lightbox-hotlink-flow"
close-hash="#hotlink"
expand-label="View larger hotlink flow diagram"
/>
</section>
<section id="crypto-shred" class="mt-14 max-w-3xl 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 max-w-3xl 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 max-w-3xl 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 max-w-3xl 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>
<section id="compare" class="mt-16 max-w-5xl scroll-mt-20">
<h2 class="text-lg font-medium text-foreground">How other image hosts compare</h2>
<p class="mt-3 max-w-3xl 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 max-w-3xl 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>
</main>
</template>