add not-found handling to ZK and hotlink viewer pages.

Rename ViewerView and view store to ZkViewerView and useZkViewStore; unify hotlink load and ZK unlock error copy.
This commit is contained in:
2026-10-30 17:43:29 +01:00
parent bb08bb389f
commit 19a8feff19
7 changed files with 85 additions and 36 deletions
+144
View File
@@ -0,0 +1,144 @@
<template>
<main
class="mx-auto flex w-full max-w-lg flex-1 flex-col items-center justify-center gap-3 px-4 py-6 text-center sm:px-6 sm:py-8"
>
<p v-if="state === 'loading'" class="text-sm text-muted-foreground sm:text-base">Loading image...</p>
<p v-else-if="state === 'invalidLink'" class="text-sm text-muted-foreground">
This viewing link is incomplete. Use the viewer URL from your upload results.
</p>
<p v-else-if="state === 'notFound'" class="text-sm text-muted-foreground">This image was not found.</p>
<form
v-else-if="state === 'password'"
class="flex w-full max-w-sm flex-col gap-3 text-left"
@submit.prevent="onUnlockSubmit"
>
<label class="flex flex-col gap-1.5 text-sm">
<span>Password</span>
<input
v-model="password"
type="password"
autocomplete="current-password"
class="rounded-md border border-input bg-background px-3 py-2.5 text-sm sm:py-2"
:disabled="unlocking"
/>
</label>
<p v-if="unlockError" class="text-sm text-destructive">{{ unlockError }}</p>
<button
type="submit"
class="inline-flex w-full items-center justify-center rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground transition-colors hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60 sm:py-2"
:disabled="unlocking || !password"
>
{{ unlocking ? 'Unlocking...' : 'View image' }}
</button>
</form>
<img
v-else-if="state === 'image' && imageObjectUrl"
:src="imageObjectUrl"
class="max-h-[min(80vh,100dvh)] w-full object-contain"
/>
</main>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia';
import { onBeforeMount, onBeforeUnmount, ref } from 'vue';
import { useRoute } from 'vue-router';
import { useZkViewStore } from '@/stores/zkView';
import type { GetZkUploadCiphertextResult } from '@/types/upload/GetZkUploadCiphertextResult';
import type { ZkViewerViewState } from '@/types/view/ZkViewerViewState';
const route = useRoute();
const zkViewStore = useZkViewStore();
const { imageObjectUrl } = storeToRefs(zkViewStore);
const state = ref<ZkViewerViewState>('loading');
const unlocking = ref(false);
const zkPayload = ref<GetZkUploadCiphertextResult | null>(null);
const fragmentKey = ref('');
const password = ref('');
const unlockError = ref('');
onBeforeMount(async () => {
const uploadId = route.params.id;
if (typeof uploadId !== 'string' || !uploadId) {
state.value = 'invalidLink';
return;
}
const key = route.hash.slice(1);
if (!key) {
state.value = 'invalidLink';
return;
}
fragmentKey.value = key;
state.value = 'loading';
try {
const payload = await zkViewStore.fetchZkCiphertext(uploadId);
if (payload.passwordProtected) {
zkPayload.value = payload;
state.value = 'password';
return;
}
await zkViewStore.loadZk(payload, key);
state.value = 'image';
} catch (error) {
console.error('[zk-viewer] load failed', { uploadId, error });
state.value = 'notFound';
}
});
onBeforeUnmount(() => {
cleanRefs();
zkViewStore.clearImage();
});
const onUnlockSubmit = async (): Promise<void> => {
const payload = zkPayload.value;
const key = fragmentKey.value;
if (!payload || !key) {
return;
}
unlocking.value = true;
unlockError.value = '';
try {
await zkViewStore.loadZk(payload, key, password.value);
zkPayload.value = null;
password.value = '';
state.value = 'image';
} catch (error) {
console.error('[zk-viewer] unlock failed', { error });
unlockError.value = 'Could not unlock this image. Check the viewer link and password, then try again.';
} finally {
unlocking.value = false;
}
};
const cleanRefs = (): void => {
zkPayload.value = null;
fragmentKey.value = '';
password.value = '';
unlockError.value = '';
};
</script>