Show copyable share links after batch upload completes.

Add share results panel gated by showShareLinks, build URLs from queue entry secrets, and align file name handling with the metadata inspector.
This commit is contained in:
2026-10-25 22:00:16 +01:00
parent f305ce7a39
commit f5be0a0de3
4 changed files with 173 additions and 3 deletions
+4
View File
@@ -11,12 +11,16 @@ export {}
/* prettier-ignore */ /* prettier-ignore */
declare module 'vue' { declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
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']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
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'] UploadDropzone: typeof import('./components/upload/UploadDropzone.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']
} }
} }
@@ -0,0 +1,77 @@
<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">
<input
type="text"
readonly
class="min-w-0 flex-1 rounded-md border border-input bg-muted/40 px-3 py-2 font-mono text-xs text-foreground"
:value="value"
/>
<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"
@click="onCopyClick"
>
{{ copied ? 'Copied' : 'Copy' }}
</button>
</div>
</label>
</template>
<script setup lang="ts">
import { onUnmounted, ref, watch } from 'vue';
const COPIED_RESET_MS = 1000;
const props = defineProps({
label: {
type: String,
required: true
},
value: {
type: String,
required: true
}
});
const copied = ref(false);
let copiedResetTimeout: ReturnType<typeof setTimeout> | undefined;
// component stay mounted when user switching between images so we need to reset the copied state when the value changes
watch(
() => props.value,
() => resetCopied()
);
onUnmounted(() => clearCopiedResetTimeout());
const clearCopiedResetTimeout = (): void => {
if (copiedResetTimeout !== undefined) {
clearTimeout(copiedResetTimeout);
copiedResetTimeout = undefined;
}
};
const resetCopied = (): void => {
clearCopiedResetTimeout();
copied.value = false;
};
const onCopyClick = async (): Promise<void> => {
try {
await navigator.clipboard.writeText(props.value);
copied.value = true;
clearCopiedResetTimeout();
copiedResetTimeout = setTimeout(() => {
copied.value = false;
}, COPIED_RESET_MS);
} catch (error) {
console.error('[ShareLinkField] copy failed', error);
}
};
</script>
@@ -0,0 +1,78 @@
<template>
<section class="flex min-h-48 flex-col rounded-lg border border-border bg-card">
<header class="border-b border-border px-4 py-3">
<h2 class="text-sm font-medium text-card-foreground">Share links</h2>
<p v-if="fileName" class="mt-0.5 truncate text-xs text-muted-foreground">{{ fileName }}</p>
</header>
<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>
<template v-else-if="shareUrls">
<InlineTabList v-model="activeNetworkTab" :options="shareNetworkTabOptions" />
<div class="flex flex-col gap-3">
<ShareLinkField label="Viewer link" :value="shareUrls.viewer[activeNetworkTab]" />
<ShareLinkField label="Delete link" :value="shareUrls.delete[activeNetworkTab]" />
<ShareLinkField
v-if="shareUrls.mode === 'hotlink'"
label="Direct image"
:value="shareUrls.direct[activeNetworkTab]"
/>
<template v-if="activeNetworkTab === 'clearnet'">
<template v-if="shareUrls.mode === 'hotlink'">
<ShareLinkField label="BBCode" :value="shareUrls.bbcode" />
<ShareLinkField label="Markdown image" :value="shareUrls.markdownImage" />
<ShareLinkField label="HTML image" :value="shareUrls.htmlImage" />
</template>
<template v-else>
<ShareLinkField label="Markdown link" :value="shareUrls.markdownLink" />
<ShareLinkField label="HTML link" :value="shareUrls.htmlLink" />
</template>
</template>
</div>
</template>
<p v-else class="text-sm text-destructive">No share links are available.</p>
</div>
</section>
</template>
<script setup lang="ts">
import { computed, ref, watch, type PropType } from 'vue';
import type { ShareNetworkTab } from '@/types/share/ShareNetworkTab';
import type { InlineTabOption } from '@/types/ui/InlineTabOption';
import type { UploadQueueEntry } from '@/types/upload/UploadQueueEntry';
import { buildUploadedShareUrls } from '@/utils/share/buildUploadedShareUrls';
const shareNetworkTabOptions: InlineTabOption<ShareNetworkTab>[] = [
{ value: 'clearnet', label: 'Clearnet' },
{ value: 'onion', label: 'Onion' }
];
const props = defineProps({
fileName: {
type: String as PropType<string | null>,
default: null
},
upload: {
type: Object as PropType<UploadQueueEntry>,
required: true
}
});
const activeNetworkTab = ref<ShareNetworkTab>('clearnet');
const shareUrls = computed(() =>
props.fileName ? buildUploadedShareUrls(props.upload, props.fileName) : null
);
watch(
() => props.fileName,
() => {
activeNetworkTab.value = 'clearnet';
}
);
</script>
+14 -3
View File
@@ -31,12 +31,18 @@
<p v-if="selectionError" class="text-sm text-destructive">{{ selectionError }}</p> <p v-if="selectionError" class="text-sm text-destructive">{{ selectionError }}</p>
</div> </div>
<p v-if="confirmedBatchSummary" class="text-center text-xs text-muted-foreground" aria-live="polite"> <p v-if="confirmedBatchSummary" class="text-center text-xs text-muted-foreground">
{{ confirmedBatchSummary }} {{ confirmedBatchSummary }}
</p> </p>
<UploadQueueList :rows="queueRows" :selected-file="selectedFile" @select="selectedFile = $event" /> <UploadQueueList :rows="queueRows" :selected-file="selectedFile" @select="selectedFile = $event" />
<UploadShareResults
v-if="showShareLinks"
:file-name="selectedFile?.name ?? null"
:upload="selectedUpload"
/>
<MetadataInspector :file-name="selectedFile?.name ?? null" :entry="selectedPreview" /> <MetadataInspector :file-name="selectedFile?.name ?? null" :entry="selectedPreview" />
<UploadConfigureModal <UploadConfigureModal
@@ -87,6 +93,7 @@ const batchSettings = ref<UploadBatchSettingsForm>({
}); });
const configureModalOpen = ref(false); const configureModalOpen = ref(false);
const uploadConfirmed = ref(false); const uploadConfirmed = ref(false);
const showShareLinks = ref(false);
const confirmedBatchSummary = ref<string | null>(null); const confirmedBatchSummary = ref<string | null>(null);
onBeforeMount(async () => { onBeforeMount(async () => {
@@ -114,6 +121,8 @@ const queueRows = computed(() =>
const selectedPreview = computed(() => metadataStore.getPreview(selectedFile.value)); const selectedPreview = computed(() => metadataStore.getPreview(selectedFile.value));
const selectedUpload = computed(() => uploadStore.getUpload(selectedFile.value));
const metadataErrorCount = computed( const metadataErrorCount = computed(
() => queuedFiles.value.filter(file => metadataStore.getPreview(file).status === 'error').length () => queuedFiles.value.filter(file => metadataStore.getPreview(file).status === 'error').length
); );
@@ -125,7 +134,7 @@ const onFilesPicked = async (picked: File[]): Promise<void> => {
return; return;
} }
resetUploadSelection(); resetUploadSession();
if (picked.length === 0) { if (picked.length === 0) {
return; return;
@@ -168,7 +177,7 @@ const onFilesPicked = async (picked: File[]): Promise<void> => {
} }
}; };
const resetUploadSelection = (): void => { const resetUploadSession = (): void => {
selectionError.value = null; selectionError.value = null;
clearAllPreviews(); clearAllPreviews();
queuedFiles.value = []; queuedFiles.value = [];
@@ -177,6 +186,7 @@ const resetUploadSelection = (): void => {
dropzoneRef.value?.clearInput(); dropzoneRef.value?.clearInput();
configureModalOpen.value = false; configureModalOpen.value = false;
uploadConfirmed.value = false; uploadConfirmed.value = false;
showShareLinks.value = false;
confirmedBatchSummary.value = null; confirmedBatchSummary.value = null;
}; };
@@ -195,6 +205,7 @@ const onBatchUpload = async (uploadDespiteMetadataFailure?: boolean): Promise<vo
console.error(error); console.error(error);
} finally { } finally {
batchUploading.value = false; batchUploading.value = false;
showShareLinks.value = true;
} }
}; };
</script> </script>