Slave/upload ui #8
Vendored
+4
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user