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:
Vendored
+4
@@ -11,12 +11,16 @@ export {}
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
InlineTabList: typeof import('./components/InlineTabList.vue')['default']
|
||||
MetadataInspector: typeof import('./components/upload/MetadataInspector.vue')['default']
|
||||
MetadataOutputPanel: typeof import('./components/upload/MetadataOutputPanel.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
ShareLinkField: typeof import('./components/upload/ShareLinkField.vue')['default']
|
||||
UploadConfigureModal: typeof import('./components/upload/UploadConfigureModal.vue')['default']
|
||||
UploadDropzone: typeof import('./components/upload/UploadDropzone.vue')['default']
|
||||
UploadQueueList: typeof import('./components/upload/UploadQueueList.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>
|
||||
@@ -31,12 +31,18 @@
|
||||
<p v-if="selectionError" class="text-sm text-destructive">{{ selectionError }}</p>
|
||||
</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 }}
|
||||
</p>
|
||||
|
||||
<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" />
|
||||
|
||||
<UploadConfigureModal
|
||||
@@ -87,6 +93,7 @@ const batchSettings = ref<UploadBatchSettingsForm>({
|
||||
});
|
||||
const configureModalOpen = ref(false);
|
||||
const uploadConfirmed = ref(false);
|
||||
const showShareLinks = ref(false);
|
||||
const confirmedBatchSummary = ref<string | null>(null);
|
||||
|
||||
onBeforeMount(async () => {
|
||||
@@ -114,6 +121,8 @@ const queueRows = computed(() =>
|
||||
|
||||
const selectedPreview = computed(() => metadataStore.getPreview(selectedFile.value));
|
||||
|
||||
const selectedUpload = computed(() => uploadStore.getUpload(selectedFile.value));
|
||||
|
||||
const metadataErrorCount = computed(
|
||||
() => queuedFiles.value.filter(file => metadataStore.getPreview(file).status === 'error').length
|
||||
);
|
||||
@@ -125,7 +134,7 @@ const onFilesPicked = async (picked: File[]): Promise<void> => {
|
||||
return;
|
||||
}
|
||||
|
||||
resetUploadSelection();
|
||||
resetUploadSession();
|
||||
|
||||
if (picked.length === 0) {
|
||||
return;
|
||||
@@ -168,7 +177,7 @@ const onFilesPicked = async (picked: File[]): Promise<void> => {
|
||||
}
|
||||
};
|
||||
|
||||
const resetUploadSelection = (): void => {
|
||||
const resetUploadSession = (): void => {
|
||||
selectionError.value = null;
|
||||
clearAllPreviews();
|
||||
queuedFiles.value = [];
|
||||
@@ -177,6 +186,7 @@ const resetUploadSelection = (): void => {
|
||||
dropzoneRef.value?.clearInput();
|
||||
configureModalOpen.value = false;
|
||||
uploadConfirmed.value = false;
|
||||
showShareLinks.value = false;
|
||||
confirmedBatchSummary.value = null;
|
||||
};
|
||||
|
||||
@@ -195,6 +205,7 @@ const onBatchUpload = async (uploadDespiteMetadataFailure?: boolean): Promise<vo
|
||||
console.error(error);
|
||||
} finally {
|
||||
batchUploading.value = false;
|
||||
showShareLinks.value = true;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user