make upload and metadata UI responsive on mobile.

Improve narrow-viewport layout for upload, About, and shell overflow; keep modal scroll without extra dismiss or a11y markup changes.
This commit is contained in:
2026-10-30 16:23:09 +01:00
parent 938229eeea
commit bb08bb389f
20 changed files with 96 additions and 67 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
<template>
<div class="flex min-h-screen flex-col bg-background">
<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 flex-1 flex-col">
<div class="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-clip">
<router-view />
</div>
<AppFooter />
+15 -6
View File
@@ -50,8 +50,16 @@
@apply border-border;
}
html {
@apply w-full min-w-0 overflow-x-hidden;
}
body {
@apply bg-background text-foreground antialiased;
@apply min-w-0 overflow-x-hidden bg-background text-foreground antialiased;
}
#app {
@apply w-full min-w-0 overflow-x-clip;
}
}
@@ -71,7 +79,8 @@ body:has(.lightbox:target) {
z-index: 100;
align-items: center;
justify-content: center;
padding: 1.5rem;
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 {
@@ -87,8 +96,8 @@ body:has(.lightbox:target) {
.lightbox__img {
position: relative;
z-index: 1;
max-width: min(95vw, 75rem);
max-height: 90vh;
max-width: min(100%, 75rem);
max-height: min(85dvh, 90vh);
width: auto;
height: auto;
object-fit: contain;
@@ -98,8 +107,8 @@ body:has(.lightbox:target) {
.lightbox__close {
position: absolute;
top: 1rem;
right: 1rem;
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;
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<div class="expandable-image">
<div class="expandable-image min-w-0">
<figure class="m-0">
<a
:href="`#${lightboxId}`"
+2 -2
View File
@@ -1,10 +1,10 @@
<template>
<div class="inline-flex w-fit rounded-md border border-border p-0.5">
<div class="flex w-full rounded-md border border-border p-0.5 sm:inline-flex sm:w-fit">
<button
v-for="option in options"
:key="option.value"
type="button"
class="rounded px-3 py-1.5 text-xs font-medium transition-colors"
class="min-w-0 flex-1 rounded px-3 py-1.5 text-center text-xs font-medium transition-colors sm:flex-none"
:class="
modelValue === option.value
? 'bg-muted text-foreground'
@@ -1,15 +1,17 @@
<template>
<div class="overflow-x-auto rounded-lg border border-border">
<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/40">
<th class="sticky left-0 z-10 min-w-48 bg-muted/40 px-3 py-2.5 font-medium text-foreground">
<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 px-3 py-2.5 font-medium"
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 }}
@@ -19,7 +21,7 @@
<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)]"
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>
+1 -1
View File
@@ -1,7 +1,7 @@
<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-4 py-5 text-center text-xs text-muted-foreground sm:px-6 sm:text-sm"
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
+3 -3
View File
@@ -1,14 +1,14 @@
<template>
<header class="border-b border-border bg-card">
<div class="mx-auto flex h-14 max-w-5xl items-center justify-between gap-4 px-4 sm:px-6">
<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="shrink-0 transition-opacity hover:opacity-80"
class="min-w-0 shrink transition-opacity hover:opacity-80"
>
<img
src="/logo.png"
alt="Private Image"
class="h-8 w-auto sm:h-9"
class="h-7 w-auto max-w-[min(100%,12rem)] sm:h-8 md:h-9"
width="1024"
height="228"
decoding="async"
@@ -1,6 +1,6 @@
<template>
<section class="flex min-h-48 flex-col rounded-lg border border-border bg-card">
<header class="border-b border-border px-4 py-3">
<header class="border-b border-border px-3 py-2.5 sm:px-4 sm:py-3">
<h2 class="text-sm font-medium text-card-foreground">Metadata Inspector</h2>
<p v-if="fileName" class="mt-0.5 truncate text-xs text-muted-foreground">{{ fileName }}</p>
</header>
@@ -1,5 +1,5 @@
<template>
<pre
class="max-h-96 min-h-32 flex-1 overflow-auto rounded-md bg-muted p-3 text-xs leading-relaxed whitespace-pre-wrap text-foreground"
class="max-h-96 min-h-32 flex-1 overflow-auto rounded-md bg-muted p-3 text-xs leading-relaxed break-all whitespace-pre-wrap text-foreground"
><slot /></pre>
</template>
@@ -1,7 +1,7 @@
<template>
<label class="flex flex-col gap-1.5 text-xs">
<span class="font-medium text-muted-foreground">{{ label }}</span>
<div class="flex gap-2">
<div class="flex flex-col gap-2 sm:flex-row">
<input
type="text"
readonly
@@ -10,7 +10,7 @@
/>
<button
type="button"
class="shrink-0 rounded-md border border-border px-3 py-2 text-xs font-medium transition-colors hover:bg-muted/60"
class="shrink-0 rounded-md border border-border px-3 py-2.5 text-xs font-medium transition-colors hover:bg-muted/60 sm:py-2"
@click="onCopyClick"
>
{{ copied ? 'Copied' : 'Copy' }}
@@ -1,6 +1,6 @@
<template>
<Teleport to="body">
<div v-if="open" class="fixed inset-0 z-50 flex items-end justify-center bg-black/50 p-4 sm:items-center">
<div v-if="open" class="fixed inset-0 z-50 flex items-end justify-center overflow-y-auto bg-black/50 p-4 sm:items-center">
<div class="flex w-full max-w-lg flex-col rounded-lg border border-border bg-card shadow-lg">
<header class="border-b border-border px-4 py-3">
<h2 class="text-base font-medium text-card-foreground">
@@ -37,7 +37,7 @@
<div
v-if="open && metadataErrorConfirmOpen"
class="fixed inset-0 z-60 flex items-end justify-center bg-black/50 p-4 sm:items-center"
class="fixed inset-0 z-60 flex items-end justify-center overflow-y-auto bg-black/50 p-4 sm:items-center"
>
<div class="flex w-full max-w-md flex-col rounded-lg border border-border bg-card shadow-lg">
<header class="border-b border-border px-4 py-3">
@@ -1,12 +1,12 @@
<template>
<div class="flex w-full flex-col gap-3">
<p class="mb-3 text-base text-muted-foreground">
<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 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="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
@@ -24,7 +24,7 @@
<button
v-if="!uploadConfirmed"
type="button"
class="inline-flex shrink-0 items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
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"
>
@@ -3,7 +3,7 @@
<li v-for="row in rows" :key="row.key">
<button
type="button"
class="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-muted/60"
class="flex w-full items-start gap-3 px-3 py-3 text-left transition-colors hover:bg-muted/60 sm:items-center sm:px-4"
:class="isRowSelected(row) ? 'bg-muted/80' : ''"
@click="emit('select', row.file)"
>
@@ -19,16 +19,22 @@
class="h-10 w-10 shrink-0 rounded border border-border bg-muted/60"
aria-hidden="true"
/>
<span class="min-w-0 flex-1 truncate text-sm font-medium">{{ row.file.name }}</span>
<span class="shrink-0 text-xs text-muted-foreground">
{{ prettyBytes(row.file.size, { maximumFractionDigits: 1 }) }}
</span>
<span class="shrink-0 max-w-32 truncate text-xs sm:max-w-40" :class="metadataStatusClass(row.metadata)">
{{ row.metadata.message }}
</span>
<span class="shrink-0 max-w-32 truncate text-xs sm:max-w-40" :class="uploadStatusClass(row.upload)">
{{ row.upload.message }}
</span>
<div class="min-w-0 flex-1 space-y-1">
<div class="flex min-w-0 items-baseline justify-between gap-2">
<span class="min-w-0 truncate text-sm font-medium">{{ row.file.name }}</span>
<span class="shrink-0 text-xs text-muted-foreground">
{{ prettyBytes(row.file.size, { maximumFractionDigits: 1 }) }}
</span>
</div>
<div class="flex min-w-0 flex-col gap-0.5 text-xs sm:flex-row sm:flex-wrap sm:gap-x-3">
<span class="min-w-0 truncate" :class="metadataStatusClass(row.metadata)">
{{ row.metadata.message }}
</span>
<span class="min-w-0 truncate" :class="uploadStatusClass(row.upload)">
{{ row.upload.message }}
</span>
</div>
</div>
</button>
</li>
</ul>
@@ -52,12 +52,12 @@
</div>
</fieldset>
<div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1.5 text-sm">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<label class="flex min-w-0 flex-col gap-1.5 text-sm">
<span class="font-medium">Expiry</span>
<select
v-model="settings.expiryPreset"
class="rounded-md border border-input bg-background px-3 py-2 text-sm"
class="w-full rounded-md border border-input bg-background px-3 py-2 text-base"
>
<option v-for="option in expiryOptions" :key="option.value" :value="option.value">
{{ option.label }}
@@ -65,11 +65,11 @@
</select>
</label>
<label class="flex flex-col gap-1.5 text-sm">
<label class="flex min-w-0 flex-col gap-1.5 text-sm">
<span class="font-medium">Max views</span>
<select
v-model="settings.maxViewsPreset"
class="rounded-md border border-input bg-background px-3 py-2 text-sm"
class="w-full rounded-md border border-input bg-background px-3 py-2 text-base"
>
<option v-for="option in maxViewsOptions" :key="option.value" :value="option.value">
{{ option.label }}
@@ -92,7 +92,7 @@
v-model="settings.password"
type="password"
autocomplete="new-password"
class="rounded-md border border-input bg-background px-3 py-2 text-sm"
class="rounded-md border border-input bg-background px-3 py-2 text-base"
/>
</label>
</template>
@@ -1,6 +1,6 @@
<template>
<section class="flex min-h-48 flex-col rounded-lg border border-border bg-card">
<header class="border-b border-border px-4 py-3">
<header class="border-b border-border px-3 py-2.5 sm:px-4 sm:py-3">
<h2 class="text-sm font-medium text-card-foreground">Share links</h2>
<p v-if="fileName" class="mt-0.5 truncate text-xs text-muted-foreground">{{ fileName }}</p>
</header>
+10 -10
View File
@@ -1,5 +1,5 @@
<template>
<main class="mx-auto max-w-5xl flex-1 px-4 py-8 sm:px-6">
<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>
@@ -29,7 +29,7 @@
</p>
</article>
<section id="exiftool" class="mt-14 scroll-mt-20">
<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
@@ -45,10 +45,10 @@
</p>
</section>
<section id="share-modes" class="mt-14 scroll-mt-20">
<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-20 text-base font-medium text-foreground">
<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">
@@ -73,7 +73,7 @@
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>
<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
@@ -109,7 +109,7 @@
/>
</section>
<section id="crypto-shred" class="mt-14 scroll-mt-20">
<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.
@@ -137,7 +137,7 @@
</p>
</section>
<section id="limits" class="mt-14 scroll-mt-20">
<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
@@ -148,7 +148,7 @@
</p>
</section>
<section id="self-hosting" class="mt-14 scroll-mt-20">
<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
@@ -162,7 +162,7 @@
</p>
</section>
<section id="clearnet-and-onion" class="mt-14 scroll-mt-20">
<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
@@ -180,7 +180,7 @@
</p>
</section>
<section id="compare" class="mt-16 scroll-mt-20">
<section id="compare" class="mt-16 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
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<main class="mx-auto flex max-w-lg flex-1 flex-col items-center justify-center gap-3 p-8 text-center">
<main class="mx-auto flex w-full max-w-lg flex-1 flex-col items-center justify-center gap-3 px-4 py-6 text-center sm:p-8">
<p v-if="state === 'invalid'" class="text-sm text-muted-foreground">
This delete link is incomplete. Use the delete URL from your upload results.
</p>
+6 -2
View File
@@ -1,6 +1,10 @@
<template>
<main class="flex flex-1 items-center justify-center p-4">
<img v-if="imageSrc" :src="imageSrc" class="max-h-[80vh] max-w-full object-contain" />
<main class="flex w-full min-w-0 flex-1 flex-col items-center justify-center px-4 py-6 sm:px-6 sm:py-8">
<img
v-if="imageSrc"
:src="imageSrc"
class="max-h-[min(80vh,100dvh)] w-full object-contain"
/>
</main>
</template>
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<main class="mx-auto flex w-full max-w-3xl flex-1 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 class="flex flex-col gap-2">
<UploadIntakeControls
ref="intakeControlsRef"
+14 -6
View File
@@ -1,15 +1,19 @@
<template>
<main class="flex flex-1 items-center justify-center p-4">
<p v-if="fetching" class="text-sm text-muted-foreground">Loading image...</p>
<main class="flex w-full min-w-0 flex-1 flex-col items-center justify-center px-4 py-6 sm:px-6 sm:py-8">
<p v-if="fetching" class="text-sm text-muted-foreground sm:text-base">Loading image...</p>
<form v-else-if="zkPayload" class="flex w-full max-w-sm flex-col gap-3" @submit.prevent="onUnlockSubmit">
<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"
class="rounded-md border border-input bg-background px-3 py-2.5 text-sm sm:py-2"
:disabled="unlocking"
/>
</label>
@@ -18,14 +22,18 @@
<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"
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="imageObjectUrl" :src="imageObjectUrl" class="max-h-[80vh] max-w-full object-contain" />
<img
v-else-if="imageObjectUrl"
:src="imageObjectUrl"
class="max-h-[min(80vh,100dvh)] w-full object-contain"
/>
</main>
</template>