@8wave/ai-elements 0.117.0-beta.32 → 0.117.0-beta.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_chunks/{PkChatbotFilePreview-DlOlZm3-.js → PkChatbotFilePreview-DAhN8TyL.js} +2 -2
- package/dist/_chunks/PkChatbotFilePreview-DAhN8TyL.js.map +1 -0
- package/dist/_chunks/{PkToolEditSpreadsheet-UjqTauEQ.js → PkToolEditSpreadsheet-y3S5O_ld.js} +2 -2
- package/dist/_chunks/{PkToolEditSpreadsheet-UjqTauEQ.js.map → PkToolEditSpreadsheet-y3S5O_ld.js.map} +1 -1
- package/dist/ai-elements.es.js +2 -2
- package/dist-vue/_chunks/PkChatbotFilePreview-DIP8KLkS.js.map +1 -1
- package/dist-vue/style.css +1 -1
- package/package.json +1 -1
- package/dist/_chunks/PkChatbotFilePreview-DlOlZm3-.js.map +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"PkChatbotFilePreview-DIP8KLkS.js","names":["$t","$emit","__unplugin_components_0","_openBlock","_createElementBlock","_normalizeClass","_createElementVNode","_withDirectives","_vShow","_createVNode","_createCommentVNode","_toDisplayString","_createTextVNode","_Fragment","_withModifiers","_normalizeStyle","_createBlock","_sfc_main","loadCdnCss","PHOTOSWIPE_CSS","DEFAULT_IMAGE_SIZE","useLightbox","options","lightbox","shallowRef","onBeforeUnmount","gallery","injectPhotoSwipeOverrides","PhotoSwipeLightbox","openInNewTab","downloadable","ui","url","getCurrentSlideUrl","pswp","downloadImage","useImageSizes","sizes","ref","image","images","img","downloadUrl","toDownloadUrl","anchor","getFilenameFromUrl","name","overridesInjected","style","_hoisted_1","_hoisted_2","_hoisted_3","_hoisted_4","_hoisted_5","_hoisted_6","_hoisted_7","_hoisted_8","_hoisted_9","_hoisted_10","_hoisted_11","_hoisted_12","_hoisted_13","_hoisted_14","_hoisted_15","THUMB_CACHE_MAX","PkChatbotFilePreview_default","defineComponent","props","__props","isImage","computed","extension","dot","subtype","KINDS","fileKind","haystack","test","kind","typeTag","formattedSize","bytes","units","value","unitIndex","thumbCache","setThumbCache","entry","cached","thumbStatus","dimensions","onImageLoad","event","onImageError","pswpSize","DEFAULT_PSWP_SIZE","imageMeta","docMeta","galleryRef","onMounted","init","_component_VvIcon","VvIcon","openBlock","createElementBlock","normalizeClass","createElementVNode","_ctx","withDirectives","vShow","createVNode","createCommentVNode","toDisplayString","createTextVNode","Fragment","_cache","withModifiers","normalizeStyle","createBlock"],"sources":["../../../../packages/components/src/composables/useLightbox.ts","../../../../packages/components/src/chat/PkChatbotFilePreview.vue","../../../../packages/components/src/chat/PkChatbotFilePreview.vue"],"sourcesContent":["import {\n onBeforeUnmount,\n ref,\n shallowRef,\n type Ref,\n type ShallowRef,\n} from 'vue'\nimport type PhotoSwipeLightbox from 'photoswipe/lightbox'\nimport type PhotoSwipe from 'photoswipe'\nimport { loadCdnCss } from 'utils'\n\nconst PHOTOSWIPE_CSS =\n 'https://cdn.jsdelivr.net/npm/photoswipe@5/dist/photoswipe.css'\n\nconst DEFAULT_IMAGE_SIZE = { width: 1200, height: 800 }\n\nexport interface LightboxImage {\n url: string\n caption?: string\n alt?: string\n /** Natural width in px — used by PhotoSwipe for proper sizing. */\n width?: number\n /** Natural height in px — used by PhotoSwipe for proper sizing. */\n height?: number\n}\n\nexport interface LightboxOptions {\n /** Container element whose child anchors are the gallery items */\n gallery: Ref<HTMLElement | undefined>\n /** CSS selector for gallery items inside the container (default: `'a[data-pswp-src]'`) */\n children?: string\n loop?: boolean\n /** Show a download button in the lightbox toolbar */\n downloadable?: boolean\n /** Show an open-in-new-tab button in the lightbox toolbar */\n openInNewTab?: boolean\n}\n\nexport interface UseLightboxReturn {\n lightbox: ShallowRef<PhotoSwipeLightbox | null>\n /** Initialises PhotoSwipe. Call this inside `onMounted`. */\n init: () => Promise<void>\n}\n\n/**\n * Encapsulates PhotoSwipe 5 initialisation, toolbar customisation, and lifecycle cleanup.\n * CSS is injected into `document.head` so the overlay works in both regular DOM and Shadow DOM.\n *\n * Usage:\n * ```ts\n * const { init } = useLightbox({ gallery: galleryRef, downloadable: true })\n * onMounted(init)\n * ```\n */\nexport function useLightbox(options: LightboxOptions): UseLightboxReturn {\n const {\n gallery,\n children = 'a[data-pswp-src]',\n loop = true,\n downloadable = true,\n openInNewTab = true,\n } = options\n\n const lightbox = shallowRef<PhotoSwipeLightbox | null>(null)\n\n const init = async () => {\n if (!gallery.value) {\n return\n }\n\n // PhotoSwipe renders its overlay into document.body, so CSS must be in\n // document.head — not inside a shadow root.\n loadCdnCss(PHOTOSWIPE_CSS)\n injectPhotoSwipeOverrides()\n\n const { default: PhotoSwipeLightbox } =\n await import('photoswipe/lightbox')\n\n lightbox.value = new PhotoSwipeLightbox({\n gallery: gallery.value,\n children,\n pswpModule: () => import('photoswipe'),\n loop,\n arrowPrevSVG:\n '<svg aria-hidden=\"true\" class=\"pswp__icn\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><path fill=\"#ffffff\" d=\"m10.828 12l4.95 4.95l-1.414 1.415L8 12l6.364-6.364l1.414 1.414z\"/></svg>',\n arrowNextSVG:\n '<svg aria-hidden=\"true\" class=\"pswp__icn\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><path fill=\"#ffffff\" d=\"m13.172 12l-4.95-4.95l1.414-1.413L16 12l-6.364 6.364l-1.414-1.415z\"/></svg>',\n closeSVG:\n '<svg aria-hidden=\"true\" class=\"pswp__icn\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><path fill=\"#ffffff\" d=\"m12 10.587l4.95-4.95l1.414 1.414l-4.95 4.95l4.95 4.95l-1.415 1.414l-4.95-4.95l-4.949 4.95l-1.414-1.415l4.95-4.95l-4.95-4.95L7.05 5.638z\"/></svg>',\n zoomSVG:\n '<svg aria-hidden=\"true\" class=\"pswp__icn\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><g class=\"pswp__icn--zoom-in\"><path fill=\"#ffffff\" d=\"m18.031 16.617l4.283 4.282l-1.415 1.415l-4.282-4.283A8.96 8.96 0 0 1 11 20c-4.968 0-9-4.032-9-9s4.032-9 9-9s9 4.032 9 9a8.96 8.96 0 0 1-1.969 5.617m-2.006-.742A6.98 6.98 0 0 0 18 11c0-3.867-3.133-7-7-7s-7 3.133-7 7s3.133 7 7 7a6.98 6.98 0 0 0 4.875-1.975zM10 10V7h2v3h3v2h-3v3h-2v-3H7v-2z\"/></g><g class=\"pswp__icn--zoom-out\"><path fill=\"#ffffff\" d=\"m18.031 16.617l4.283 4.282l-1.415 1.415l-4.282-4.283A8.96 8.96 0 0 1 11 20c-4.968 0-9-4.032-9-9s4.032-9 9-9s9 4.032 9 9a8.96 8.96 0 0 1-1.969 5.617m-2.006-.742A6.98 6.98 0 0 0 18 11c0-3.867-3.133-7-7-7s-7 3.133-7 7s3.133 7 7 7a6.98 6.98 0 0 0 4.875-1.975zM7 10h8v2H7z\"/></g></svg>',\n })\n\n if (openInNewTab || downloadable) {\n lightbox.value.on('uiRegister', () => {\n const ui = lightbox.value!.pswp?.ui\n if (!ui) {\n return\n }\n\n if (openInNewTab) {\n ui.registerElement({\n name: 'open-in-new-tab-button',\n order: 8,\n isButton: true,\n html: '<svg aria-hidden=\"true\" class=\"pswp__icn\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><path fill=\"#ffffff\" d=\"M10 6v2H5v11h11v-5h2v6a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1zm11-3v8h-2V6.413l-7.793 7.794l-1.414-1.414L17.585 5H13V3z\"/></svg>',\n title: 'Open in new tab',\n onClick: (_, __, pswp) => {\n const url = getCurrentSlideUrl(pswp)\n if (url) {\n window.open(\n url,\n '_blank',\n 'noopener,noreferrer',\n )\n }\n },\n })\n }\n\n if (downloadable) {\n ui.registerElement({\n name: 'download-button',\n order: 9,\n isButton: true,\n html: '<svg aria-hidden=\"true\" class=\"pswp__icn\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><path fill=\"#ffffff\" d=\"M3 19h18v2H3zm10-5.828L19.071 7.1l1.414 1.414L12 17L3.515 8.515L4.929 7.1L11 13.173V2h2z\"/></svg>',\n title: 'Download',\n onClick: (_, __, pswp) => {\n const url = getCurrentSlideUrl(pswp)\n if (url) {\n downloadImage(url)\n }\n },\n })\n }\n })\n }\n\n lightbox.value.init()\n }\n\n onBeforeUnmount(() => {\n lightbox.value?.destroy()\n lightbox.value = null\n })\n\n return { lightbox, init }\n}\n\n// ---------------------------------------------------------------------------\n// Image size preloading\n// ---------------------------------------------------------------------------\n\nexport interface UseImageSizesReturn {\n preload: (\n images: Array<{ url: string; width?: number; height?: number }>,\n ) => void\n getSize: (url: string) => { width: number; height: number }\n}\n\n/**\n * Preloads natural image dimensions so PhotoSwipe can size its slides correctly.\n * When `width`/`height` are already provided they are used as-is;\n * otherwise the image is loaded in the background to read `naturalWidth`/`naturalHeight`.\n */\nexport function useImageSizes(): UseImageSizesReturn {\n const sizes = ref(new Map<string, { width: number; height: number }>())\n\n const preload = (\n images: Array<{ url: string; width?: number; height?: number }>,\n ) => {\n for (const image of images) {\n if (image.width && image.height) {\n sizes.value.set(image.url, {\n width: image.width,\n height: image.height,\n })\n continue\n }\n const img = new Image()\n img.onload = () => {\n sizes.value.set(image.url, {\n width: img.naturalWidth,\n height: img.naturalHeight,\n })\n }\n img.src = image.url\n }\n }\n\n const getSize = (url: string) => sizes.value.get(url) ?? DEFAULT_IMAGE_SIZE\n\n return { preload, getSize }\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\nfunction getCurrentSlideUrl(pswp: PhotoSwipe): string | undefined {\n return (\n pswp.currSlide?.data?.src ??\n pswp.currSlide?.data?.element\n ?.querySelector('img')\n ?.getAttribute('src') ??\n pswp.currSlide?.data?.element\n ?.querySelector('source')\n ?.getAttribute('srcset') ??\n pswp.currSlide?.data?.element?.getAttribute('src') ??\n undefined\n )\n}\n\n/**\n * Triggers a client-side download for an image URL.\n *\n * For backend media URLs (.../media/:file/view), navigates to the\n * corresponding /download endpoint which redirects to an Azure SAS URL\n * with `Content-Disposition: attachment` — no CORS issues.\n *\n * For blob: URLs (in-session uploads), uses the fetch → blob approach.\n *\n * For data: URLs (inline SVGs, etc.), use the download attribute.\n *\n * Falls back to opening in a new tab for all other cases.\n */\nfunction downloadImage(url: string): void {\n const downloadUrl = toDownloadUrl(url)\n\n const anchor = document.createElement('a')\n anchor.href = downloadUrl\n anchor.target = '_blank'\n anchor.rel = 'noopener noreferrer'\n // `download` attribute works for same-origin, data: and blob: URLs; for\n // cross-origin it is ignored but the browser still follows Content-Disposition.\n anchor.download = getFilenameFromUrl(url)\n document.body.appendChild(anchor)\n anchor.click()\n document.body.removeChild(anchor)\n}\n\n/**\n * Converts a `/view` media URL to its `/download` counterpart.\n * Leaves blob:, data: and all other URLs untouched.\n */\nfunction toDownloadUrl(url: string): string {\n return url.replace(/\\/view(\\?|$)/, '/download$1')\n}\n\nfunction getFilenameFromUrl(url: string): string {\n if (url.startsWith('data:image/svg+xml')) {\n return 'diagram.svg'\n }\n if (url.startsWith('data:')) {\n const mime = url.split(':')[1].split(';')[0]\n const ext = mime.split('/')[1] || 'bin'\n return `image.${ext}`\n }\n try {\n const pathname = new URL(url).pathname\n const name = pathname.split('/').pop()\n return name && name.includes('.') ? name : 'image'\n } catch {\n return 'image'\n }\n}\n\nlet overridesInjected = false\n\n/**\n * Injects a one-time `<style>` into `document.head` that raises\n * PhotoSwipe's z-index above any host UI (e.g. floating chat widgets\n * that use `z-index: 2147483647`).\n */\nfunction injectPhotoSwipeOverrides(): void {\n if (overridesInjected) {\n return\n }\n overridesInjected = true\n const style = document.createElement('style')\n style.textContent = [\n '.pswp__counter { font-family: sans-serif; }',\n '.pswp { z-index: 2147483647 !important; }',\n '.pswp__icn { width: 20px; height: 20px; }',\n '.pswp__button { width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; }',\n '.pswp__icn--zoom-out { display: none; }',\n '.pswp--zoomed-in .pswp__icn--zoom-in { display: none; }',\n '.pswp--zoomed-in .pswp__icn--zoom-out { display: block; }',\n '.pswp__button--arrow .pswp__icn {left: auto; right: auto; width: 40px; height: 40px; transform: none; margin-top: -20px; }',\n ].join(' ')\n document.head.appendChild(style)\n}\n","<script lang=\"ts\" setup>\n import { computed, ref, onMounted } from 'vue'\n import { useLightbox } from '../composables/useLightbox'\n\n const props = defineProps<{\n mediaType: string\n url: string\n filename?: string\n /** Size in bytes, when known (e.g. local uploads). */\n size?: number\n loading?: boolean\n error?: string\n removable?: boolean\n /**\n * Dense layout for the composer: a small square thumbnail / slim row,\n * without the metadata caption. The full card is reserved for the\n * message log where the details matter.\n */\n compact?: boolean\n /**\n * Keeps the download button visible instead of revealing it on hover:\n * for a file the agent wrote, which the user is there to download.\n */\n showDownload?: boolean\n }>()\n\n defineEmits<{\n remove: []\n }>()\n\n const isImage = computed(() => props.mediaType.startsWith('image/'))\n\n // --- File-type identity (icon + accent + human kind label) ---\n\n const extension = computed(() => {\n const name = props.filename ?? ''\n const dot = name.lastIndexOf('.')\n return dot > 0 ? name.slice(dot + 1).toLowerCase() : ''\n })\n\n // mediaType subtype, e.g. \"pdf\" from \"application/pdf\"\n const subtype = computed(\n () => props.mediaType.split('/')[1]?.split(';')[0] ?? '',\n )\n\n type FileKind = {\n icon: string\n // CSS color driving the icon glyph + tile tint\n accent: string\n label: string\n }\n\n const KINDS: Array<{ test: RegExp; kind: FileKind }> = [\n {\n test: /pdf/,\n kind: {\n icon: 'ri:file-pdf-2-line',\n accent: '#e2483d',\n label: 'PDF',\n },\n },\n {\n // Match Word specifically: the generic `document` substring also\n // appears in the XLSX/PPTX OOXML mime types (…officedocument…), so\n // matching it here would mis-label spreadsheets/decks as Word.\n test: /msword|wordprocessingml/,\n kind: {\n icon: 'ri:file-word-2-line',\n accent: '#2b7de9',\n label: 'Word',\n },\n },\n {\n test: /sheet|excel|csv/,\n kind: {\n icon: 'ri:file-excel-2-line',\n accent: '#1f9d57',\n label: 'Spreadsheet',\n },\n },\n {\n test: /presentation|powerpoint/,\n kind: {\n icon: 'ri:file-ppt-2-line',\n accent: '#d98324',\n label: 'Presentation',\n },\n },\n {\n test: /zip|compressed|x-7z|x-rar|x-tar|gzip/,\n kind: {\n icon: 'ri:file-zip-line',\n accent: '#c08a1e',\n label: 'Archive',\n },\n },\n {\n test: /^audio\\//,\n kind: {\n icon: 'ri:file-music-line',\n accent: '#8b5cf6',\n label: 'Audio',\n },\n },\n {\n test: /^video\\//,\n kind: {\n icon: 'ri:film-line',\n accent: '#6366f1',\n label: 'Video',\n },\n },\n {\n test: /json|javascript|typescript|xml|html|x-sh|x-python/,\n kind: {\n icon: 'ri:file-code-line',\n accent: '#0d9488',\n label: 'Code',\n },\n },\n {\n test: /^text\\//,\n kind: {\n icon: 'ri:file-text-line',\n accent: '#64748b',\n label: 'Text',\n },\n },\n ]\n\n const fileKind = computed<FileKind>(() => {\n const haystack = `${props.mediaType} ${extension.value}`\n for (const { test, kind } of KINDS) {\n if (test.test(haystack)) {\n return kind\n }\n }\n return {\n icon: 'ri:file-line',\n accent: '#64748b',\n label: 'File',\n }\n })\n\n // Short type tag shown in the meta line: prefer the real extension, fall\n // back to the mime subtype, then the generic kind label.\n const typeTag = computed(() =>\n (\n extension.value ||\n subtype.value ||\n fileKind.value.label\n ).toUpperCase(),\n )\n\n // --- Size & dimensions ---\n\n const formattedSize = computed(() => {\n const bytes = props.size\n if (bytes === undefined || bytes < 0) {\n return undefined\n }\n if (bytes < 1024) {\n return `${bytes} B`\n }\n const units = ['KB', 'MB', 'GB', 'TB']\n let value = bytes / 1024\n let unitIndex = 0\n while (value >= 1024 && unitIndex < units.length - 1) {\n value /= 1024\n unitIndex += 1\n }\n return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[unitIndex]}`\n })\n\n // Thumbnail load lifecycle. Driving the spinner and the error fallback\n // off the real <img> events (not a second background fetch) means the\n // image is requested exactly once and reuses the browser cache on any\n // later re-render, instead of re-downloading every time the message list\n // repaints.\n // Not `useImageFallback`: this tile also needs `loading` vs `loaded` and the\n // decoded dimensions, cached across remounts, so the error flag is one field\n // of a richer per-url entry rather than a standalone broken-url set.\n type ThumbStatus = 'loading' | 'loaded' | 'error'\n\n // Module-level, keyed by URL: survives component recreation. In polled\n // views (e.g. admin chat-logs, refreshed every few seconds) the whole\n // message array is replaced on each poll, so this component can be torn\n // down and rebuilt. Seeding from the cache means a URL that already\n // decoded starts in `loaded` (no spinner flash, no re-decode) instead of\n // flickering back through `loading`. This is a client-side, per-session\n // cache (no server/Redis involved); bounded to a max size so a long-lived\n // session browsing many images can't grow it without limit — the oldest\n // entry is evicted first (Map preserves insertion order).\n type ThumbEntry = {\n status: ThumbStatus\n dimensions?: { width: number; height: number }\n }\n const THUMB_CACHE_MAX = 300\n const thumbCache = new Map<string, ThumbEntry>()\n const setThumbCache = (url: string, entry: ThumbEntry) => {\n // Re-insert to mark as most-recently used, then trim from the front.\n thumbCache.delete(url)\n thumbCache.set(url, entry)\n if (thumbCache.size > THUMB_CACHE_MAX) {\n thumbCache.delete(thumbCache.keys().next().value as string)\n }\n }\n\n const cached = thumbCache.get(props.url)\n const thumbStatus = ref<ThumbStatus>(cached?.status ?? 'loading')\n\n // Natural pixel dimensions, captured once the thumbnail decodes. Doubles\n // as the size hint PhotoSwipe needs for its slide (falls back until known).\n const dimensions = ref<{ width: number; height: number } | undefined>(\n cached?.dimensions,\n )\n const onImageLoad = (event: Event) => {\n const img = event.target as HTMLImageElement\n if (img.naturalWidth && img.naturalHeight) {\n dimensions.value = {\n width: img.naturalWidth,\n height: img.naturalHeight,\n }\n }\n thumbStatus.value = 'loaded'\n setThumbCache(props.url, {\n status: 'loaded',\n dimensions: dimensions.value,\n })\n }\n const onImageError = () => {\n thumbStatus.value = 'error'\n setThumbCache(props.url, { status: 'error' })\n }\n\n const pswpSize = computed(() => dimensions.value ?? DEFAULT_PSWP_SIZE)\n\n // Meta fragments joined with \" · \" for the caption / doc subtitle.\n const imageMeta = computed(() =>\n [\n dimensions.value &&\n `${dimensions.value.width}×${dimensions.value.height}`,\n formattedSize.value,\n ].filter(Boolean),\n )\n const docMeta = computed(() =>\n [typeTag.value, formattedSize.value].filter(Boolean),\n )\n\n // --- Lightbox ---\n\n // Slide size PhotoSwipe assumes before the real dimensions are known.\n const DEFAULT_PSWP_SIZE = { width: 1200, height: 800 }\n\n const galleryRef = ref<HTMLElement>()\n const { init } = useLightbox({ gallery: galleryRef, loop: false })\n\n onMounted(async () => {\n if (!isImage.value) {\n return\n }\n await init()\n })\n</script>\n\n<template>\n <!-- Image attachment: a thumbnail tile with a metadata caption -->\n <figure\n v-if=\"isImage\"\n class=\"pk-chatbot-file-preview pk-chatbot-file-preview--image\"\n :class=\"{ 'pk-chatbot-file-preview--compact': compact }\">\n <div ref=\"galleryRef\" class=\"pk-chatbot-file-preview__thumb-wrap\">\n <a\n :href=\"url\"\n :data-pswp-src=\"url\"\n :data-pswp-width=\"pswpSize.width\"\n :data-pswp-height=\"pswpSize.height\"\n :data-pswp-caption=\"filename ?? ''\"\n class=\"pk-chatbot-file-preview__thumb-link\"\n :aria-label=\"filename ?? $t('label.file')\">\n <img\n v-show=\"thumbStatus === 'loaded'\"\n :src=\"url\"\n :alt=\"filename\"\n decoding=\"async\"\n class=\"pk-chatbot-file-preview__thumb\"\n @load=\"onImageLoad\"\n @error=\"onImageError\" />\n </a>\n <!-- Spinner until the thumbnail decodes (or while an upload is in\n flight); a fixed box keeps this from shifting the layout. -->\n <span\n v-if=\"loading || thumbStatus === 'loading'\"\n class=\"pk-chatbot-file-preview__status pk-chatbot-file-preview__status--overlay\">\n <VvIcon name=\"loading-loop\" />\n </span>\n <!-- Failed to load: a quiet icon instead of the browser's broken\n image glyph. -->\n <span\n v-else-if=\"thumbStatus === 'error'\"\n class=\"pk-chatbot-file-preview__status pk-chatbot-file-preview__status--overlay pk-chatbot-file-preview__status--error\">\n <VvIcon name=\"ri:image-2-line\" />\n </span>\n </div>\n <figcaption v-if=\"!compact\" class=\"pk-chatbot-file-preview__caption\">\n <span\n v-if=\"filename\"\n class=\"pk-chatbot-file-preview__name\"\n :title=\"filename\"\n >{{ filename }}</span\n >\n <span class=\"pk-chatbot-file-preview__meta\">\n <span\n v-if=\"error\"\n class=\"pk-chatbot-file-preview__error\"\n :title=\"error\">\n <VvIcon name=\"ri:error-warning-line\" />\n {{ error }}\n </span>\n <template v-else>{{ imageMeta.join(' · ') }}</template>\n </span>\n </figcaption>\n <button\n v-if=\"removable && !loading\"\n type=\"button\"\n class=\"pk-chatbot-file-preview__remove\"\n :title=\"$t('action.remove')\"\n :aria-label=\"$t('action.remove')\"\n @click.prevent=\"$emit('remove')\">\n <VvIcon name=\"ri:close-line\" />\n </button>\n </figure>\n\n <!-- Document attachment: an icon tile + name + type · size -->\n <div\n v-else\n class=\"pk-chatbot-file-preview pk-chatbot-file-preview--doc\"\n :class=\"{\n 'pk-chatbot-file-preview--error': error,\n 'pk-chatbot-file-preview--compact': compact,\n 'pk-chatbot-file-preview--show-download': showDownload,\n }\">\n <span\n class=\"pk-chatbot-file-preview__icon-tile\"\n :style=\"{ '--file-accent': fileKind.accent }\">\n <VvIcon\n v-if=\"loading\"\n name=\"loading-loop\"\n class=\"pk-chatbot-file-preview__icon\" />\n <VvIcon\n v-else\n :name=\"fileKind.icon\"\n class=\"pk-chatbot-file-preview__icon\" />\n </span>\n <span class=\"pk-chatbot-file-preview__body\">\n <span\n v-if=\"filename\"\n class=\"pk-chatbot-file-preview__name\"\n :title=\"filename\"\n >{{ filename }}</span\n >\n <span class=\"pk-chatbot-file-preview__meta\">\n <span\n v-if=\"error\"\n class=\"pk-chatbot-file-preview__error\"\n :title=\"error\">\n <VvIcon name=\"ri:error-warning-line\" />\n {{ error }}\n </span>\n <template v-else>{{ docMeta.join(' · ') }}</template>\n </span>\n </span>\n <a\n v-if=\"!loading && !error && !compact\"\n :href=\"url\"\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n class=\"pk-chatbot-file-preview__open\"\n :title=\"$t('action.download')\"\n :aria-label=\"`${$t('action.download')}${filename ? ` — ${filename}` : ''}`\">\n <VvIcon name=\"ri:download-2-line\" />\n </a>\n <button\n v-if=\"removable && !loading\"\n type=\"button\"\n class=\"pk-chatbot-file-preview__remove\"\n :class=\"{ 'pk-chatbot-file-preview__remove--inline': !compact }\"\n :title=\"$t('action.remove')\"\n :aria-label=\"$t('action.remove')\"\n @click=\"$emit('remove')\">\n <VvIcon name=\"ri:close-line\" />\n </button>\n </div>\n</template>\n\n<style lang=\"scss\">\n .pk-chatbot-file-preview {\n // Concentric radii: outer card and inner tiles share an 8px step.\n --file-radius: var(--rounded-lg, 12px);\n position: relative;\n max-width: 280px;\n background-color: var(--color-surface-1);\n border: 1px solid var(--color-surface-3);\n border-radius: var(--file-radius);\n color: var(--color-word-2);\n font-size: var(--text-12);\n\n &--error {\n border-color: var(--color-danger);\n }\n\n // --- Document card ---\n &--doc {\n display: flex;\n align-items: center;\n gap: var(--spacing-8);\n padding: var(--spacing-8);\n min-width: 0;\n transition:\n border-color 0.18s var(--ease-out),\n background-color 0.18s var(--ease-out),\n box-shadow 0.18s var(--ease-out);\n\n &:hover {\n border-color: var(--color-surface-4);\n background-color: var(--color-surface-2);\n box-shadow: var(--shadow-sm);\n }\n }\n\n &__icon-tile {\n display: flex;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n width: var(--spacing-36);\n height: var(--spacing-36);\n // One step down from the card radius keeps the nested tile concentric.\n border-radius: var(--rounded-md, 6px);\n // Tint derived from the file-type accent; glyph sits at full accent.\n background-color: color-mix(\n in srgb,\n var(--file-accent) 14%,\n transparent\n );\n color: var(--file-accent);\n }\n\n &__icon {\n width: var(--spacing-20);\n height: var(--spacing-20);\n }\n\n &__body {\n display: flex;\n flex-direction: column;\n gap: 1px;\n min-width: 0;\n flex: 1;\n }\n\n &__open {\n display: flex;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n width: var(--spacing-28);\n height: var(--spacing-28);\n margin-inline-start: var(--spacing-4);\n border-radius: var(--rounded);\n color: var(--color-word-3);\n text-decoration: none;\n opacity: 0;\n transition:\n opacity 0.18s var(--ease-out),\n color 0.18s var(--ease-out),\n background-color 0.18s var(--ease-out);\n\n &:hover {\n background-color: var(--color-surface-3);\n color: var(--color-word-1);\n }\n }\n\n &--doc:hover &__open,\n &--doc:focus-within &__open,\n &--show-download &__open {\n opacity: 1;\n }\n\n // Touch devices have no hover: keep the open affordance visible.\n @media (hover: none) {\n &--doc &__open {\n opacity: 1;\n }\n }\n\n // --- Image card ---\n &--image {\n display: inline-flex;\n flex-direction: column;\n margin: 0;\n }\n\n &__thumb-wrap {\n position: relative;\n display: flex;\n // Fixed box: the card claims its final footprint before the\n // image decodes, so the message log never shifts on load.\n width: 280px;\n max-width: 100%;\n height: 200px;\n background-color: var(--color-surface);\n border-radius: var(--file-radius);\n overflow: hidden;\n }\n\n &__thumb-link {\n display: flex;\n width: 100%;\n height: 100%;\n cursor: pointer;\n }\n\n &__thumb {\n display: block;\n width: 100%;\n height: 100%;\n object-fit: cover;\n }\n\n &__caption {\n display: flex;\n flex-direction: column;\n gap: 1px;\n padding: var(--spacing-8);\n min-width: 0;\n }\n\n &__name {\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n font-weight: var(--font-medium, 500);\n color: var(--color-word-1);\n }\n\n &__meta {\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n font-size: var(--text-10);\n line-height: var(--leading-normal);\n // Reserve the line even while empty: image dimensions arrive\n // only after decode and must not grow the caption.\n min-height: 1.4em;\n color: var(--color-word-4);\n // Stable column for the dimensions/size figures.\n font-variant-numeric: tabular-nums;\n }\n\n &__error {\n display: inline-flex;\n align-items: center;\n gap: var(--spacing-4);\n color: var(--u-danger-text, var(--color-danger));\n\n .vv-icon {\n flex-shrink: 0;\n }\n }\n\n &__status {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n\n .vv-icon {\n width: var(--spacing-32);\n height: var(--spacing-32);\n }\n\n &--overlay {\n position: absolute;\n inset: 0;\n background-color: color-mix(\n in srgb,\n var(--color-surface-1) 70%,\n transparent\n );\n color: var(--color-word-1);\n }\n\n &--error {\n color: var(--color-word-4);\n }\n }\n\n // --- Remove button (input context) ---\n &__remove {\n position: absolute;\n top: 0;\n right: 0;\n transform: translate(50%, -50%);\n display: flex;\n align-items: center;\n justify-content: center;\n // Visible glyph is 18px but the hit area stays a comfortable 28px.\n width: var(--spacing-28);\n height: var(--spacing-28);\n padding: 0;\n border: none;\n border-radius: var(--rounded-full, 9999px);\n background-color: color-mix(\n in srgb,\n var(--color-surface-1) 85%,\n transparent\n );\n color: var(--color-word-2);\n cursor: pointer;\n line-height: 1;\n box-shadow: var(--shadow-sm);\n transition:\n background-color 0.18s var(--ease-out),\n color 0.18s var(--ease-out),\n scale 0.12s var(--ease-out);\n\n &:hover {\n background-color: var(--color-surface-2);\n color: var(--color-word-1);\n }\n\n &:active {\n scale: 0.96;\n }\n\n // Inside the document row the button rides inline, not overlaid\n // (size is inherited from the base).\n &--inline {\n position: static;\n flex-shrink: 0;\n box-shadow: none;\n background-color: transparent;\n color: var(--color-word-4);\n\n &:hover {\n background-color: var(--color-surface-3);\n color: var(--color-word-1);\n }\n }\n }\n\n // --- Compact (composer) chips ---\n // ChatGPT/Gemini-style: every attachment is the SAME height and carries\n // an always-visible round remove control in the top-right corner — no\n // hover-revealed actions, no size mismatch between image and file chips.\n &--compact {\n // Shared chip height so image and file chips align in the row.\n --chip-size: var(--spacing-56);\n max-width: 240px;\n\n &.pk-chatbot-file-preview--image {\n width: var(--chip-size);\n height: var(--chip-size);\n border-radius: var(--rounded-md, 6px);\n }\n\n // Fill the square; the root's `overflow: hidden` rounds the corners.\n .pk-chatbot-file-preview__thumb-wrap,\n .pk-chatbot-file-preview__thumb-link {\n width: 100%;\n height: 100%;\n }\n\n &.pk-chatbot-file-preview--doc {\n height: var(--chip-size);\n gap: var(--spacing-8);\n padding: var(--spacing-8);\n // Reserve room so the corner remove button never overlaps the\n // name/meta text.\n padding-inline-end: var(--spacing-28);\n }\n\n // Square icon tile that fills the chip height, so file chips read at\n // the same scale as image thumbnails.\n .pk-chatbot-file-preview__icon-tile {\n align-self: stretch;\n width: auto;\n height: auto;\n aspect-ratio: 1;\n }\n\n .pk-chatbot-file-preview__icon {\n width: var(--spacing-20);\n height: var(--spacing-20);\n }\n }\n\n // Always-visible round remove control in the top-right corner. Sits\n // just inside the chip (an ancestor clips overflow), bigger than before,\n // with an extended hit area for easy tapping.\n &--compact &__remove {\n top: var(--spacing-4);\n right: var(--spacing-4);\n width: var(--spacing-24);\n height: var(--spacing-24);\n border: 1px solid var(--color-surface-4);\n background-color: var(--color-surface);\n color: var(--color-word-1);\n font-size: var(--text-16);\n box-shadow: var(--shadow-sm);\n\n // Comfortable touch/click target beyond the small visible glyph.\n &::after {\n content: '';\n position: absolute;\n inset: calc(-1 * var(--spacing-4));\n }\n\n &:hover {\n background-color: var(--color-surface-2);\n color: var(--color-word-1);\n }\n }\n\n @media (prefers-reduced-motion: reduce) {\n &--doc,\n &__open,\n &__remove {\n transition: none;\n }\n }\n }\n</style>\n","<script lang=\"ts\" setup>\n import { computed, ref, onMounted } from 'vue'\n import { useLightbox } from '../composables/useLightbox'\n\n const props = defineProps<{\n mediaType: string\n url: string\n filename?: string\n /** Size in bytes, when known (e.g. local uploads). */\n size?: number\n loading?: boolean\n error?: string\n removable?: boolean\n /**\n * Dense layout for the composer: a small square thumbnail / slim row,\n * without the metadata caption. The full card is reserved for the\n * message log where the details matter.\n */\n compact?: boolean\n /**\n * Keeps the download button visible instead of revealing it on hover:\n * for a file the agent wrote, which the user is there to download.\n */\n showDownload?: boolean\n }>()\n\n defineEmits<{\n remove: []\n }>()\n\n const isImage = computed(() => props.mediaType.startsWith('image/'))\n\n // --- File-type identity (icon + accent + human kind label) ---\n\n const extension = computed(() => {\n const name = props.filename ?? ''\n const dot = name.lastIndexOf('.')\n return dot > 0 ? name.slice(dot + 1).toLowerCase() : ''\n })\n\n // mediaType subtype, e.g. \"pdf\" from \"application/pdf\"\n const subtype = computed(\n () => props.mediaType.split('/')[1]?.split(';')[0] ?? '',\n )\n\n type FileKind = {\n icon: string\n // CSS color driving the icon glyph + tile tint\n accent: string\n label: string\n }\n\n const KINDS: Array<{ test: RegExp; kind: FileKind }> = [\n {\n test: /pdf/,\n kind: {\n icon: 'ri:file-pdf-2-line',\n accent: '#e2483d',\n label: 'PDF',\n },\n },\n {\n // Match Word specifically: the generic `document` substring also\n // appears in the XLSX/PPTX OOXML mime types (…officedocument…), so\n // matching it here would mis-label spreadsheets/decks as Word.\n test: /msword|wordprocessingml/,\n kind: {\n icon: 'ri:file-word-2-line',\n accent: '#2b7de9',\n label: 'Word',\n },\n },\n {\n test: /sheet|excel|csv/,\n kind: {\n icon: 'ri:file-excel-2-line',\n accent: '#1f9d57',\n label: 'Spreadsheet',\n },\n },\n {\n test: /presentation|powerpoint/,\n kind: {\n icon: 'ri:file-ppt-2-line',\n accent: '#d98324',\n label: 'Presentation',\n },\n },\n {\n test: /zip|compressed|x-7z|x-rar|x-tar|gzip/,\n kind: {\n icon: 'ri:file-zip-line',\n accent: '#c08a1e',\n label: 'Archive',\n },\n },\n {\n test: /^audio\\//,\n kind: {\n icon: 'ri:file-music-line',\n accent: '#8b5cf6',\n label: 'Audio',\n },\n },\n {\n test: /^video\\//,\n kind: {\n icon: 'ri:film-line',\n accent: '#6366f1',\n label: 'Video',\n },\n },\n {\n test: /json|javascript|typescript|xml|html|x-sh|x-python/,\n kind: {\n icon: 'ri:file-code-line',\n accent: '#0d9488',\n label: 'Code',\n },\n },\n {\n test: /^text\\//,\n kind: {\n icon: 'ri:file-text-line',\n accent: '#64748b',\n label: 'Text',\n },\n },\n ]\n\n const fileKind = computed<FileKind>(() => {\n const haystack = `${props.mediaType} ${extension.value}`\n for (const { test, kind } of KINDS) {\n if (test.test(haystack)) {\n return kind\n }\n }\n return {\n icon: 'ri:file-line',\n accent: '#64748b',\n label: 'File',\n }\n })\n\n // Short type tag shown in the meta line: prefer the real extension, fall\n // back to the mime subtype, then the generic kind label.\n const typeTag = computed(() =>\n (\n extension.value ||\n subtype.value ||\n fileKind.value.label\n ).toUpperCase(),\n )\n\n // --- Size & dimensions ---\n\n const formattedSize = computed(() => {\n const bytes = props.size\n if (bytes === undefined || bytes < 0) {\n return undefined\n }\n if (bytes < 1024) {\n return `${bytes} B`\n }\n const units = ['KB', 'MB', 'GB', 'TB']\n let value = bytes / 1024\n let unitIndex = 0\n while (value >= 1024 && unitIndex < units.length - 1) {\n value /= 1024\n unitIndex += 1\n }\n return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[unitIndex]}`\n })\n\n // Thumbnail load lifecycle. Driving the spinner and the error fallback\n // off the real <img> events (not a second background fetch) means the\n // image is requested exactly once and reuses the browser cache on any\n // later re-render, instead of re-downloading every time the message list\n // repaints.\n // Not `useImageFallback`: this tile also needs `loading` vs `loaded` and the\n // decoded dimensions, cached across remounts, so the error flag is one field\n // of a richer per-url entry rather than a standalone broken-url set.\n type ThumbStatus = 'loading' | 'loaded' | 'error'\n\n // Module-level, keyed by URL: survives component recreation. In polled\n // views (e.g. admin chat-logs, refreshed every few seconds) the whole\n // message array is replaced on each poll, so this component can be torn\n // down and rebuilt. Seeding from the cache means a URL that already\n // decoded starts in `loaded` (no spinner flash, no re-decode) instead of\n // flickering back through `loading`. This is a client-side, per-session\n // cache (no server/Redis involved); bounded to a max size so a long-lived\n // session browsing many images can't grow it without limit — the oldest\n // entry is evicted first (Map preserves insertion order).\n type ThumbEntry = {\n status: ThumbStatus\n dimensions?: { width: number; height: number }\n }\n const THUMB_CACHE_MAX = 300\n const thumbCache = new Map<string, ThumbEntry>()\n const setThumbCache = (url: string, entry: ThumbEntry) => {\n // Re-insert to mark as most-recently used, then trim from the front.\n thumbCache.delete(url)\n thumbCache.set(url, entry)\n if (thumbCache.size > THUMB_CACHE_MAX) {\n thumbCache.delete(thumbCache.keys().next().value as string)\n }\n }\n\n const cached = thumbCache.get(props.url)\n const thumbStatus = ref<ThumbStatus>(cached?.status ?? 'loading')\n\n // Natural pixel dimensions, captured once the thumbnail decodes. Doubles\n // as the size hint PhotoSwipe needs for its slide (falls back until known).\n const dimensions = ref<{ width: number; height: number } | undefined>(\n cached?.dimensions,\n )\n const onImageLoad = (event: Event) => {\n const img = event.target as HTMLImageElement\n if (img.naturalWidth && img.naturalHeight) {\n dimensions.value = {\n width: img.naturalWidth,\n height: img.naturalHeight,\n }\n }\n thumbStatus.value = 'loaded'\n setThumbCache(props.url, {\n status: 'loaded',\n dimensions: dimensions.value,\n })\n }\n const onImageError = () => {\n thumbStatus.value = 'error'\n setThumbCache(props.url, { status: 'error' })\n }\n\n const pswpSize = computed(() => dimensions.value ?? DEFAULT_PSWP_SIZE)\n\n // Meta fragments joined with \" · \" for the caption / doc subtitle.\n const imageMeta = computed(() =>\n [\n dimensions.value &&\n `${dimensions.value.width}×${dimensions.value.height}`,\n formattedSize.value,\n ].filter(Boolean),\n )\n const docMeta = computed(() =>\n [typeTag.value, formattedSize.value].filter(Boolean),\n )\n\n // --- Lightbox ---\n\n // Slide size PhotoSwipe assumes before the real dimensions are known.\n const DEFAULT_PSWP_SIZE = { width: 1200, height: 800 }\n\n const galleryRef = ref<HTMLElement>()\n const { init } = useLightbox({ gallery: galleryRef, loop: false })\n\n onMounted(async () => {\n if (!isImage.value) {\n return\n }\n await init()\n })\n</script>\n\n<template>\n <!-- Image attachment: a thumbnail tile with a metadata caption -->\n <figure\n v-if=\"isImage\"\n class=\"pk-chatbot-file-preview pk-chatbot-file-preview--image\"\n :class=\"{ 'pk-chatbot-file-preview--compact': compact }\">\n <div ref=\"galleryRef\" class=\"pk-chatbot-file-preview__thumb-wrap\">\n <a\n :href=\"url\"\n :data-pswp-src=\"url\"\n :data-pswp-width=\"pswpSize.width\"\n :data-pswp-height=\"pswpSize.height\"\n :data-pswp-caption=\"filename ?? ''\"\n class=\"pk-chatbot-file-preview__thumb-link\"\n :aria-label=\"filename ?? $t('label.file')\">\n <img\n v-show=\"thumbStatus === 'loaded'\"\n :src=\"url\"\n :alt=\"filename\"\n decoding=\"async\"\n class=\"pk-chatbot-file-preview__thumb\"\n @load=\"onImageLoad\"\n @error=\"onImageError\" />\n </a>\n <!-- Spinner until the thumbnail decodes (or while an upload is in\n flight); a fixed box keeps this from shifting the layout. -->\n <span\n v-if=\"loading || thumbStatus === 'loading'\"\n class=\"pk-chatbot-file-preview__status pk-chatbot-file-preview__status--overlay\">\n <VvIcon name=\"loading-loop\" />\n </span>\n <!-- Failed to load: a quiet icon instead of the browser's broken\n image glyph. -->\n <span\n v-else-if=\"thumbStatus === 'error'\"\n class=\"pk-chatbot-file-preview__status pk-chatbot-file-preview__status--overlay pk-chatbot-file-preview__status--error\">\n <VvIcon name=\"ri:image-2-line\" />\n </span>\n </div>\n <figcaption v-if=\"!compact\" class=\"pk-chatbot-file-preview__caption\">\n <span\n v-if=\"filename\"\n class=\"pk-chatbot-file-preview__name\"\n :title=\"filename\"\n >{{ filename }}</span\n >\n <span class=\"pk-chatbot-file-preview__meta\">\n <span\n v-if=\"error\"\n class=\"pk-chatbot-file-preview__error\"\n :title=\"error\">\n <VvIcon name=\"ri:error-warning-line\" />\n {{ error }}\n </span>\n <template v-else>{{ imageMeta.join(' · ') }}</template>\n </span>\n </figcaption>\n <button\n v-if=\"removable && !loading\"\n type=\"button\"\n class=\"pk-chatbot-file-preview__remove\"\n :title=\"$t('action.remove')\"\n :aria-label=\"$t('action.remove')\"\n @click.prevent=\"$emit('remove')\">\n <VvIcon name=\"ri:close-line\" />\n </button>\n </figure>\n\n <!-- Document attachment: an icon tile + name + type · size -->\n <div\n v-else\n class=\"pk-chatbot-file-preview pk-chatbot-file-preview--doc\"\n :class=\"{\n 'pk-chatbot-file-preview--error': error,\n 'pk-chatbot-file-preview--compact': compact,\n 'pk-chatbot-file-preview--show-download': showDownload,\n }\">\n <span\n class=\"pk-chatbot-file-preview__icon-tile\"\n :style=\"{ '--file-accent': fileKind.accent }\">\n <VvIcon\n v-if=\"loading\"\n name=\"loading-loop\"\n class=\"pk-chatbot-file-preview__icon\" />\n <VvIcon\n v-else\n :name=\"fileKind.icon\"\n class=\"pk-chatbot-file-preview__icon\" />\n </span>\n <span class=\"pk-chatbot-file-preview__body\">\n <span\n v-if=\"filename\"\n class=\"pk-chatbot-file-preview__name\"\n :title=\"filename\"\n >{{ filename }}</span\n >\n <span class=\"pk-chatbot-file-preview__meta\">\n <span\n v-if=\"error\"\n class=\"pk-chatbot-file-preview__error\"\n :title=\"error\">\n <VvIcon name=\"ri:error-warning-line\" />\n {{ error }}\n </span>\n <template v-else>{{ docMeta.join(' · ') }}</template>\n </span>\n </span>\n <a\n v-if=\"!loading && !error && !compact\"\n :href=\"url\"\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n class=\"pk-chatbot-file-preview__open\"\n :title=\"$t('action.download')\"\n :aria-label=\"`${$t('action.download')}${filename ? ` — ${filename}` : ''}`\">\n <VvIcon name=\"ri:download-2-line\" />\n </a>\n <button\n v-if=\"removable && !loading\"\n type=\"button\"\n class=\"pk-chatbot-file-preview__remove\"\n :class=\"{ 'pk-chatbot-file-preview__remove--inline': !compact }\"\n :title=\"$t('action.remove')\"\n :aria-label=\"$t('action.remove')\"\n @click=\"$emit('remove')\">\n <VvIcon name=\"ri:close-line\" />\n </button>\n </div>\n</template>\n\n<style lang=\"scss\">\n .pk-chatbot-file-preview {\n // Concentric radii: outer card and inner tiles share an 8px step.\n --file-radius: var(--rounded-lg, 12px);\n position: relative;\n max-width: 280px;\n background-color: var(--color-surface-1);\n border: 1px solid var(--color-surface-3);\n border-radius: var(--file-radius);\n color: var(--color-word-2);\n font-size: var(--text-12);\n\n &--error {\n border-color: var(--color-danger);\n }\n\n // --- Document card ---\n &--doc {\n display: flex;\n align-items: center;\n gap: var(--spacing-8);\n padding: var(--spacing-8);\n min-width: 0;\n transition:\n border-color 0.18s var(--ease-out),\n background-color 0.18s var(--ease-out),\n box-shadow 0.18s var(--ease-out);\n\n &:hover {\n border-color: var(--color-surface-4);\n background-color: var(--color-surface-2);\n box-shadow: var(--shadow-sm);\n }\n }\n\n &__icon-tile {\n display: flex;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n width: var(--spacing-36);\n height: var(--spacing-36);\n // One step down from the card radius keeps the nested tile concentric.\n border-radius: var(--rounded-md, 6px);\n // Tint derived from the file-type accent; glyph sits at full accent.\n background-color: color-mix(\n in srgb,\n var(--file-accent) 14%,\n transparent\n );\n color: var(--file-accent);\n }\n\n &__icon {\n width: var(--spacing-20);\n height: var(--spacing-20);\n }\n\n &__body {\n display: flex;\n flex-direction: column;\n gap: 1px;\n min-width: 0;\n flex: 1;\n }\n\n &__open {\n display: flex;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n width: var(--spacing-28);\n height: var(--spacing-28);\n margin-inline-start: var(--spacing-4);\n border-radius: var(--rounded);\n color: var(--color-word-3);\n text-decoration: none;\n opacity: 0;\n transition:\n opacity 0.18s var(--ease-out),\n color 0.18s var(--ease-out),\n background-color 0.18s var(--ease-out);\n\n &:hover {\n background-color: var(--color-surface-3);\n color: var(--color-word-1);\n }\n }\n\n &--doc:hover &__open,\n &--doc:focus-within &__open,\n &--show-download &__open {\n opacity: 1;\n }\n\n // Touch devices have no hover: keep the open affordance visible.\n @media (hover: none) {\n &--doc &__open {\n opacity: 1;\n }\n }\n\n // --- Image card ---\n &--image {\n display: inline-flex;\n flex-direction: column;\n margin: 0;\n }\n\n &__thumb-wrap {\n position: relative;\n display: flex;\n // Fixed box: the card claims its final footprint before the\n // image decodes, so the message log never shifts on load.\n width: 280px;\n max-width: 100%;\n height: 200px;\n background-color: var(--color-surface);\n border-radius: var(--file-radius);\n overflow: hidden;\n }\n\n &__thumb-link {\n display: flex;\n width: 100%;\n height: 100%;\n cursor: pointer;\n }\n\n &__thumb {\n display: block;\n width: 100%;\n height: 100%;\n object-fit: cover;\n }\n\n &__caption {\n display: flex;\n flex-direction: column;\n gap: 1px;\n padding: var(--spacing-8);\n min-width: 0;\n }\n\n &__name {\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n font-weight: var(--font-medium, 500);\n color: var(--color-word-1);\n }\n\n &__meta {\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n font-size: var(--text-10);\n line-height: var(--leading-normal);\n // Reserve the line even while empty: image dimensions arrive\n // only after decode and must not grow the caption.\n min-height: 1.4em;\n color: var(--color-word-4);\n // Stable column for the dimensions/size figures.\n font-variant-numeric: tabular-nums;\n }\n\n &__error {\n display: inline-flex;\n align-items: center;\n gap: var(--spacing-4);\n color: var(--u-danger-text, var(--color-danger));\n\n .vv-icon {\n flex-shrink: 0;\n }\n }\n\n &__status {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n\n .vv-icon {\n width: var(--spacing-32);\n height: var(--spacing-32);\n }\n\n &--overlay {\n position: absolute;\n inset: 0;\n background-color: color-mix(\n in srgb,\n var(--color-surface-1) 70%,\n transparent\n );\n color: var(--color-word-1);\n }\n\n &--error {\n color: var(--color-word-4);\n }\n }\n\n // --- Remove button (input context) ---\n &__remove {\n position: absolute;\n top: 0;\n right: 0;\n transform: translate(50%, -50%);\n display: flex;\n align-items: center;\n justify-content: center;\n // Visible glyph is 18px but the hit area stays a comfortable 28px.\n width: var(--spacing-28);\n height: var(--spacing-28);\n padding: 0;\n border: none;\n border-radius: var(--rounded-full, 9999px);\n background-color: color-mix(\n in srgb,\n var(--color-surface-1) 85%,\n transparent\n );\n color: var(--color-word-2);\n cursor: pointer;\n line-height: 1;\n box-shadow: var(--shadow-sm);\n transition:\n background-color 0.18s var(--ease-out),\n color 0.18s var(--ease-out),\n scale 0.12s var(--ease-out);\n\n &:hover {\n background-color: var(--color-surface-2);\n color: var(--color-word-1);\n }\n\n &:active {\n scale: 0.96;\n }\n\n // Inside the document row the button rides inline, not overlaid\n // (size is inherited from the base).\n &--inline {\n position: static;\n flex-shrink: 0;\n box-shadow: none;\n background-color: transparent;\n color: var(--color-word-4);\n\n &:hover {\n background-color: var(--color-surface-3);\n color: var(--color-word-1);\n }\n }\n }\n\n // --- Compact (composer) chips ---\n // ChatGPT/Gemini-style: every attachment is the SAME height and carries\n // an always-visible round remove control in the top-right corner — no\n // hover-revealed actions, no size mismatch between image and file chips.\n &--compact {\n // Shared chip height so image and file chips align in the row.\n --chip-size: var(--spacing-56);\n max-width: 240px;\n\n &.pk-chatbot-file-preview--image {\n width: var(--chip-size);\n height: var(--chip-size);\n border-radius: var(--rounded-md, 6px);\n }\n\n // Fill the square; the root's `overflow: hidden` rounds the corners.\n .pk-chatbot-file-preview__thumb-wrap,\n .pk-chatbot-file-preview__thumb-link {\n width: 100%;\n height: 100%;\n }\n\n &.pk-chatbot-file-preview--doc {\n height: var(--chip-size);\n gap: var(--spacing-8);\n padding: var(--spacing-8);\n // Reserve room so the corner remove button never overlaps the\n // name/meta text.\n padding-inline-end: var(--spacing-28);\n }\n\n // Square icon tile that fills the chip height, so file chips read at\n // the same scale as image thumbnails.\n .pk-chatbot-file-preview__icon-tile {\n align-self: stretch;\n width: auto;\n height: auto;\n aspect-ratio: 1;\n }\n\n .pk-chatbot-file-preview__icon {\n width: var(--spacing-20);\n height: var(--spacing-20);\n }\n }\n\n // Always-visible round remove control in the top-right corner. Sits\n // just inside the chip (an ancestor clips overflow), bigger than before,\n // with an extended hit area for easy tapping.\n &--compact &__remove {\n top: var(--spacing-4);\n right: var(--spacing-4);\n width: var(--spacing-24);\n height: var(--spacing-24);\n border: 1px solid var(--color-surface-4);\n background-color: var(--color-surface);\n color: var(--color-word-1);\n font-size: var(--text-16);\n box-shadow: var(--shadow-sm);\n\n // Comfortable touch/click target beyond the small visible glyph.\n &::after {\n content: '';\n position: absolute;\n inset: calc(-1 * var(--spacing-4));\n }\n\n &:hover {\n background-color: var(--color-surface-2);\n color: var(--color-word-1);\n }\n }\n\n @media (prefers-reduced-motion: reduce) {\n &--doc,\n &__open,\n &__remove {\n transition: none;\n }\n }\n }\n</style>\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"PkChatbotFilePreview-DIP8KLkS.js","names":["$t","$emit","__unplugin_components_0","_openBlock","_createElementBlock","_normalizeClass","_createElementVNode","_withDirectives","_vShow","_createVNode","_createCommentVNode","_toDisplayString","_createTextVNode","_Fragment","_withModifiers","_normalizeStyle","_createBlock","_sfc_main","loadCdnCss","PHOTOSWIPE_CSS","DEFAULT_IMAGE_SIZE","useLightbox","options","lightbox","shallowRef","onBeforeUnmount","gallery","injectPhotoSwipeOverrides","PhotoSwipeLightbox","openInNewTab","downloadable","ui","url","getCurrentSlideUrl","pswp","downloadImage","useImageSizes","sizes","ref","image","images","img","downloadUrl","toDownloadUrl","anchor","getFilenameFromUrl","name","overridesInjected","style","_hoisted_1","_hoisted_2","_hoisted_3","_hoisted_4","_hoisted_5","_hoisted_6","_hoisted_7","_hoisted_8","_hoisted_9","_hoisted_10","_hoisted_11","_hoisted_12","_hoisted_13","_hoisted_14","_hoisted_15","THUMB_CACHE_MAX","PkChatbotFilePreview_default","defineComponent","props","__props","isImage","computed","extension","dot","subtype","KINDS","fileKind","haystack","test","kind","typeTag","formattedSize","bytes","units","value","unitIndex","thumbCache","setThumbCache","entry","cached","thumbStatus","dimensions","onImageLoad","event","onImageError","pswpSize","DEFAULT_PSWP_SIZE","imageMeta","docMeta","galleryRef","onMounted","init","_component_VvIcon","VvIcon","openBlock","createElementBlock","normalizeClass","createElementVNode","_ctx","withDirectives","vShow","createVNode","createCommentVNode","toDisplayString","createTextVNode","Fragment","_cache","withModifiers","normalizeStyle","createBlock"],"sources":["../../../../packages/components/src/composables/useLightbox.ts","../../../../packages/components/src/chat/PkChatbotFilePreview.vue","../../../../packages/components/src/chat/PkChatbotFilePreview.vue"],"sourcesContent":["import {\n onBeforeUnmount,\n ref,\n shallowRef,\n type Ref,\n type ShallowRef,\n} from 'vue'\nimport type PhotoSwipeLightbox from 'photoswipe/lightbox'\nimport type PhotoSwipe from 'photoswipe'\nimport { loadCdnCss } from 'utils'\n\nconst PHOTOSWIPE_CSS =\n 'https://cdn.jsdelivr.net/npm/photoswipe@5/dist/photoswipe.css'\n\nconst DEFAULT_IMAGE_SIZE = { width: 1200, height: 800 }\n\nexport interface LightboxImage {\n url: string\n caption?: string\n alt?: string\n /** Natural width in px — used by PhotoSwipe for proper sizing. */\n width?: number\n /** Natural height in px — used by PhotoSwipe for proper sizing. */\n height?: number\n}\n\nexport interface LightboxOptions {\n /** Container element whose child anchors are the gallery items */\n gallery: Ref<HTMLElement | undefined>\n /** CSS selector for gallery items inside the container (default: `'a[data-pswp-src]'`) */\n children?: string\n loop?: boolean\n /** Show a download button in the lightbox toolbar */\n downloadable?: boolean\n /** Show an open-in-new-tab button in the lightbox toolbar */\n openInNewTab?: boolean\n}\n\nexport interface UseLightboxReturn {\n lightbox: ShallowRef<PhotoSwipeLightbox | null>\n /** Initialises PhotoSwipe. Call this inside `onMounted`. */\n init: () => Promise<void>\n}\n\n/**\n * Encapsulates PhotoSwipe 5 initialisation, toolbar customisation, and lifecycle cleanup.\n * CSS is injected into `document.head` so the overlay works in both regular DOM and Shadow DOM.\n *\n * Usage:\n * ```ts\n * const { init } = useLightbox({ gallery: galleryRef, downloadable: true })\n * onMounted(init)\n * ```\n */\nexport function useLightbox(options: LightboxOptions): UseLightboxReturn {\n const {\n gallery,\n children = 'a[data-pswp-src]',\n loop = true,\n downloadable = true,\n openInNewTab = true,\n } = options\n\n const lightbox = shallowRef<PhotoSwipeLightbox | null>(null)\n\n const init = async () => {\n if (!gallery.value) {\n return\n }\n\n // PhotoSwipe renders its overlay into document.body, so CSS must be in\n // document.head — not inside a shadow root.\n loadCdnCss(PHOTOSWIPE_CSS)\n injectPhotoSwipeOverrides()\n\n const { default: PhotoSwipeLightbox } =\n await import('photoswipe/lightbox')\n\n lightbox.value = new PhotoSwipeLightbox({\n gallery: gallery.value,\n children,\n pswpModule: () => import('photoswipe'),\n loop,\n arrowPrevSVG:\n '<svg aria-hidden=\"true\" class=\"pswp__icn\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><path fill=\"#ffffff\" d=\"m10.828 12l4.95 4.95l-1.414 1.415L8 12l6.364-6.364l1.414 1.414z\"/></svg>',\n arrowNextSVG:\n '<svg aria-hidden=\"true\" class=\"pswp__icn\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><path fill=\"#ffffff\" d=\"m13.172 12l-4.95-4.95l1.414-1.413L16 12l-6.364 6.364l-1.414-1.415z\"/></svg>',\n closeSVG:\n '<svg aria-hidden=\"true\" class=\"pswp__icn\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><path fill=\"#ffffff\" d=\"m12 10.587l4.95-4.95l1.414 1.414l-4.95 4.95l4.95 4.95l-1.415 1.414l-4.95-4.95l-4.949 4.95l-1.414-1.415l4.95-4.95l-4.95-4.95L7.05 5.638z\"/></svg>',\n zoomSVG:\n '<svg aria-hidden=\"true\" class=\"pswp__icn\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><g class=\"pswp__icn--zoom-in\"><path fill=\"#ffffff\" d=\"m18.031 16.617l4.283 4.282l-1.415 1.415l-4.282-4.283A8.96 8.96 0 0 1 11 20c-4.968 0-9-4.032-9-9s4.032-9 9-9s9 4.032 9 9a8.96 8.96 0 0 1-1.969 5.617m-2.006-.742A6.98 6.98 0 0 0 18 11c0-3.867-3.133-7-7-7s-7 3.133-7 7s3.133 7 7 7a6.98 6.98 0 0 0 4.875-1.975zM10 10V7h2v3h3v2h-3v3h-2v-3H7v-2z\"/></g><g class=\"pswp__icn--zoom-out\"><path fill=\"#ffffff\" d=\"m18.031 16.617l4.283 4.282l-1.415 1.415l-4.282-4.283A8.96 8.96 0 0 1 11 20c-4.968 0-9-4.032-9-9s4.032-9 9-9s9 4.032 9 9a8.96 8.96 0 0 1-1.969 5.617m-2.006-.742A6.98 6.98 0 0 0 18 11c0-3.867-3.133-7-7-7s-7 3.133-7 7s3.133 7 7 7a6.98 6.98 0 0 0 4.875-1.975zM7 10h8v2H7z\"/></g></svg>',\n })\n\n if (openInNewTab || downloadable) {\n lightbox.value.on('uiRegister', () => {\n const ui = lightbox.value!.pswp?.ui\n if (!ui) {\n return\n }\n\n if (openInNewTab) {\n ui.registerElement({\n name: 'open-in-new-tab-button',\n order: 8,\n isButton: true,\n html: '<svg aria-hidden=\"true\" class=\"pswp__icn\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><path fill=\"#ffffff\" d=\"M10 6v2H5v11h11v-5h2v6a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1zm11-3v8h-2V6.413l-7.793 7.794l-1.414-1.414L17.585 5H13V3z\"/></svg>',\n title: 'Open in new tab',\n onClick: (_, __, pswp) => {\n const url = getCurrentSlideUrl(pswp)\n if (url) {\n window.open(\n url,\n '_blank',\n 'noopener,noreferrer',\n )\n }\n },\n })\n }\n\n if (downloadable) {\n ui.registerElement({\n name: 'download-button',\n order: 9,\n isButton: true,\n html: '<svg aria-hidden=\"true\" class=\"pswp__icn\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><path fill=\"#ffffff\" d=\"M3 19h18v2H3zm10-5.828L19.071 7.1l1.414 1.414L12 17L3.515 8.515L4.929 7.1L11 13.173V2h2z\"/></svg>',\n title: 'Download',\n onClick: (_, __, pswp) => {\n const url = getCurrentSlideUrl(pswp)\n if (url) {\n downloadImage(url)\n }\n },\n })\n }\n })\n }\n\n lightbox.value.init()\n }\n\n onBeforeUnmount(() => {\n lightbox.value?.destroy()\n lightbox.value = null\n })\n\n return { lightbox, init }\n}\n\n// ---------------------------------------------------------------------------\n// Image size preloading\n// ---------------------------------------------------------------------------\n\nexport interface UseImageSizesReturn {\n preload: (\n images: Array<{ url: string; width?: number; height?: number }>,\n ) => void\n getSize: (url: string) => { width: number; height: number }\n}\n\n/**\n * Preloads natural image dimensions so PhotoSwipe can size its slides correctly.\n * When `width`/`height` are already provided they are used as-is;\n * otherwise the image is loaded in the background to read `naturalWidth`/`naturalHeight`.\n */\nexport function useImageSizes(): UseImageSizesReturn {\n const sizes = ref(new Map<string, { width: number; height: number }>())\n\n const preload = (\n images: Array<{ url: string; width?: number; height?: number }>,\n ) => {\n for (const image of images) {\n if (image.width && image.height) {\n sizes.value.set(image.url, {\n width: image.width,\n height: image.height,\n })\n continue\n }\n const img = new Image()\n img.onload = () => {\n sizes.value.set(image.url, {\n width: img.naturalWidth,\n height: img.naturalHeight,\n })\n }\n img.src = image.url\n }\n }\n\n const getSize = (url: string) => sizes.value.get(url) ?? DEFAULT_IMAGE_SIZE\n\n return { preload, getSize }\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\nfunction getCurrentSlideUrl(pswp: PhotoSwipe): string | undefined {\n return (\n pswp.currSlide?.data?.src ??\n pswp.currSlide?.data?.element\n ?.querySelector('img')\n ?.getAttribute('src') ??\n pswp.currSlide?.data?.element\n ?.querySelector('source')\n ?.getAttribute('srcset') ??\n pswp.currSlide?.data?.element?.getAttribute('src') ??\n undefined\n )\n}\n\n/**\n * Triggers a client-side download for an image URL.\n *\n * For backend media URLs (.../media/:file/view), navigates to the\n * corresponding /download endpoint which redirects to an Azure SAS URL\n * with `Content-Disposition: attachment` — no CORS issues.\n *\n * For blob: URLs (in-session uploads), uses the fetch → blob approach.\n *\n * For data: URLs (inline SVGs, etc.), use the download attribute.\n *\n * Falls back to opening in a new tab for all other cases.\n */\nfunction downloadImage(url: string): void {\n const downloadUrl = toDownloadUrl(url)\n\n const anchor = document.createElement('a')\n anchor.href = downloadUrl\n anchor.target = '_blank'\n anchor.rel = 'noopener noreferrer'\n // `download` attribute works for same-origin, data: and blob: URLs; for\n // cross-origin it is ignored but the browser still follows Content-Disposition.\n anchor.download = getFilenameFromUrl(url)\n document.body.appendChild(anchor)\n anchor.click()\n document.body.removeChild(anchor)\n}\n\n/**\n * Converts a `/view` media URL to its `/download` counterpart.\n * Leaves blob:, data: and all other URLs untouched.\n */\nfunction toDownloadUrl(url: string): string {\n return url.replace(/\\/view(\\?|$)/, '/download$1')\n}\n\nfunction getFilenameFromUrl(url: string): string {\n if (url.startsWith('data:image/svg+xml')) {\n return 'diagram.svg'\n }\n if (url.startsWith('data:')) {\n const mime = url.split(':')[1].split(';')[0]\n const ext = mime.split('/')[1] || 'bin'\n return `image.${ext}`\n }\n try {\n const pathname = new URL(url).pathname\n const name = pathname.split('/').pop()\n return name && name.includes('.') ? name : 'image'\n } catch {\n return 'image'\n }\n}\n\nlet overridesInjected = false\n\n/**\n * Injects a one-time `<style>` into `document.head` that raises\n * PhotoSwipe's z-index above any host UI (e.g. floating chat widgets\n * that use `z-index: 2147483647`).\n */\nfunction injectPhotoSwipeOverrides(): void {\n if (overridesInjected) {\n return\n }\n overridesInjected = true\n const style = document.createElement('style')\n style.textContent = [\n '.pswp__counter { font-family: sans-serif; }',\n '.pswp { z-index: 2147483647 !important; }',\n '.pswp__icn { width: 20px; height: 20px; }',\n '.pswp__button { width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; }',\n '.pswp__icn--zoom-out { display: none; }',\n '.pswp--zoomed-in .pswp__icn--zoom-in { display: none; }',\n '.pswp--zoomed-in .pswp__icn--zoom-out { display: block; }',\n '.pswp__button--arrow .pswp__icn {left: auto; right: auto; width: 40px; height: 40px; transform: none; margin-top: -20px; }',\n ].join(' ')\n document.head.appendChild(style)\n}\n","<script lang=\"ts\" setup>\n import { computed, ref, onMounted } from 'vue'\n import { useLightbox } from '../composables/useLightbox'\n\n const props = defineProps<{\n mediaType: string\n url: string\n filename?: string\n /** Size in bytes, when known (e.g. local uploads). */\n size?: number\n loading?: boolean\n error?: string\n removable?: boolean\n /**\n * Dense layout for the composer: a small square thumbnail / slim row,\n * without the metadata caption. The full card is reserved for the\n * message log where the details matter.\n */\n compact?: boolean\n /**\n * Keeps the download button visible instead of revealing it on hover:\n * for a file the agent wrote, which the user is there to download.\n */\n showDownload?: boolean\n }>()\n\n defineEmits<{\n remove: []\n }>()\n\n const isImage = computed(() => props.mediaType.startsWith('image/'))\n\n // --- File-type identity (icon + accent + human kind label) ---\n\n const extension = computed(() => {\n const name = props.filename ?? ''\n const dot = name.lastIndexOf('.')\n return dot > 0 ? name.slice(dot + 1).toLowerCase() : ''\n })\n\n // mediaType subtype, e.g. \"pdf\" from \"application/pdf\"\n const subtype = computed(\n () => props.mediaType.split('/')[1]?.split(';')[0] ?? '',\n )\n\n type FileKind = {\n icon: string\n // CSS color driving the icon glyph + tile tint\n accent: string\n label: string\n }\n\n const KINDS: Array<{ test: RegExp; kind: FileKind }> = [\n {\n test: /pdf/,\n kind: {\n icon: 'ri:file-pdf-2-line',\n accent: '#e2483d',\n label: 'PDF',\n },\n },\n {\n // Match Word specifically: the generic `document` substring also\n // appears in the XLSX/PPTX OOXML mime types (…officedocument…), so\n // matching it here would mis-label spreadsheets/decks as Word.\n test: /msword|wordprocessingml/,\n kind: {\n icon: 'ri:file-word-2-line',\n accent: '#2b7de9',\n label: 'Word',\n },\n },\n {\n test: /sheet|excel|csv/,\n kind: {\n icon: 'ri:file-excel-2-line',\n accent: '#1f9d57',\n label: 'Spreadsheet',\n },\n },\n {\n test: /presentation|powerpoint/,\n kind: {\n icon: 'ri:file-ppt-2-line',\n accent: '#d98324',\n label: 'Presentation',\n },\n },\n {\n test: /zip|compressed|x-7z|x-rar|x-tar|gzip/,\n kind: {\n icon: 'ri:file-zip-line',\n accent: '#c08a1e',\n label: 'Archive',\n },\n },\n {\n test: /^audio\\//,\n kind: {\n icon: 'ri:file-music-line',\n accent: '#8b5cf6',\n label: 'Audio',\n },\n },\n {\n test: /^video\\//,\n kind: {\n icon: 'ri:film-line',\n accent: '#6366f1',\n label: 'Video',\n },\n },\n {\n test: /json|javascript|typescript|xml|html|x-sh|x-python/,\n kind: {\n icon: 'ri:file-code-line',\n accent: '#0d9488',\n label: 'Code',\n },\n },\n {\n test: /^text\\//,\n kind: {\n icon: 'ri:file-text-line',\n accent: '#64748b',\n label: 'Text',\n },\n },\n ]\n\n const fileKind = computed<FileKind>(() => {\n const haystack = `${props.mediaType} ${extension.value}`\n for (const { test, kind } of KINDS) {\n if (test.test(haystack)) {\n return kind\n }\n }\n return {\n icon: 'ri:file-line',\n accent: '#64748b',\n label: 'File',\n }\n })\n\n // Short type tag shown in the meta line: prefer the real extension, fall\n // back to the mime subtype, then the generic kind label.\n const typeTag = computed(() =>\n (\n extension.value ||\n subtype.value ||\n fileKind.value.label\n ).toUpperCase(),\n )\n\n // --- Size & dimensions ---\n\n const formattedSize = computed(() => {\n const bytes = props.size\n if (bytes === undefined || bytes < 0) {\n return undefined\n }\n if (bytes < 1024) {\n return `${bytes} B`\n }\n const units = ['KB', 'MB', 'GB', 'TB']\n let value = bytes / 1024\n let unitIndex = 0\n while (value >= 1024 && unitIndex < units.length - 1) {\n value /= 1024\n unitIndex += 1\n }\n return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[unitIndex]}`\n })\n\n // Thumbnail load lifecycle. Driving the spinner and the error fallback\n // off the real <img> events (not a second background fetch) means the\n // image is requested exactly once and reuses the browser cache on any\n // later re-render, instead of re-downloading every time the message list\n // repaints.\n // Not `useImageFallback`: this tile also needs `loading` vs `loaded` and the\n // decoded dimensions, cached across remounts, so the error flag is one field\n // of a richer per-url entry rather than a standalone broken-url set.\n type ThumbStatus = 'loading' | 'loaded' | 'error'\n\n // Module-level, keyed by URL: survives component recreation. In polled\n // views (e.g. admin chat-logs, refreshed every few seconds) the whole\n // message array is replaced on each poll, so this component can be torn\n // down and rebuilt. Seeding from the cache means a URL that already\n // decoded starts in `loaded` (no spinner flash, no re-decode) instead of\n // flickering back through `loading`. This is a client-side, per-session\n // cache (no server/Redis involved); bounded to a max size so a long-lived\n // session browsing many images can't grow it without limit — the oldest\n // entry is evicted first (Map preserves insertion order).\n type ThumbEntry = {\n status: ThumbStatus\n dimensions?: { width: number; height: number }\n }\n const THUMB_CACHE_MAX = 300\n const thumbCache = new Map<string, ThumbEntry>()\n const setThumbCache = (url: string, entry: ThumbEntry) => {\n // Re-insert to mark as most-recently used, then trim from the front.\n thumbCache.delete(url)\n thumbCache.set(url, entry)\n if (thumbCache.size > THUMB_CACHE_MAX) {\n thumbCache.delete(thumbCache.keys().next().value as string)\n }\n }\n\n const cached = thumbCache.get(props.url)\n const thumbStatus = ref<ThumbStatus>(cached?.status ?? 'loading')\n\n // Natural pixel dimensions, captured once the thumbnail decodes. Doubles\n // as the size hint PhotoSwipe needs for its slide (falls back until known).\n const dimensions = ref<{ width: number; height: number } | undefined>(\n cached?.dimensions,\n )\n const onImageLoad = (event: Event) => {\n const img = event.target as HTMLImageElement\n if (img.naturalWidth && img.naturalHeight) {\n dimensions.value = {\n width: img.naturalWidth,\n height: img.naturalHeight,\n }\n }\n thumbStatus.value = 'loaded'\n setThumbCache(props.url, {\n status: 'loaded',\n dimensions: dimensions.value,\n })\n }\n const onImageError = () => {\n thumbStatus.value = 'error'\n setThumbCache(props.url, { status: 'error' })\n }\n\n const pswpSize = computed(() => dimensions.value ?? DEFAULT_PSWP_SIZE)\n\n // Meta fragments joined with \" · \" for the caption / doc subtitle.\n const imageMeta = computed(() =>\n [\n dimensions.value &&\n `${dimensions.value.width}×${dimensions.value.height}`,\n formattedSize.value,\n ].filter(Boolean),\n )\n const docMeta = computed(() =>\n [typeTag.value, formattedSize.value].filter(Boolean),\n )\n\n // --- Lightbox ---\n\n // Slide size PhotoSwipe assumes before the real dimensions are known.\n const DEFAULT_PSWP_SIZE = { width: 1200, height: 800 }\n\n const galleryRef = ref<HTMLElement>()\n const { init } = useLightbox({ gallery: galleryRef, loop: false })\n\n onMounted(async () => {\n if (!isImage.value) {\n return\n }\n await init()\n })\n</script>\n\n<template>\n <!-- Image attachment: a thumbnail tile with a metadata caption -->\n <figure\n v-if=\"isImage\"\n class=\"pk-chatbot-file-preview pk-chatbot-file-preview--image\"\n :class=\"{ 'pk-chatbot-file-preview--compact': compact }\">\n <div ref=\"galleryRef\" class=\"pk-chatbot-file-preview__thumb-wrap\">\n <a\n :href=\"url\"\n :data-pswp-src=\"url\"\n :data-pswp-width=\"pswpSize.width\"\n :data-pswp-height=\"pswpSize.height\"\n :data-pswp-caption=\"filename ?? ''\"\n class=\"pk-chatbot-file-preview__thumb-link\"\n :aria-label=\"filename ?? $t('label.file')\">\n <img\n v-show=\"thumbStatus === 'loaded'\"\n :src=\"url\"\n :alt=\"filename\"\n decoding=\"async\"\n class=\"pk-chatbot-file-preview__thumb\"\n @load=\"onImageLoad\"\n @error=\"onImageError\" />\n </a>\n <!-- Spinner until the thumbnail decodes (or while an upload is in\n flight); a fixed box keeps this from shifting the layout. -->\n <span\n v-if=\"loading || thumbStatus === 'loading'\"\n class=\"pk-chatbot-file-preview__status pk-chatbot-file-preview__status--overlay\">\n <VvIcon name=\"loading-loop\" />\n </span>\n <!-- Failed to load: a quiet icon instead of the browser's broken\n image glyph. -->\n <span\n v-else-if=\"thumbStatus === 'error'\"\n class=\"pk-chatbot-file-preview__status pk-chatbot-file-preview__status--overlay pk-chatbot-file-preview__status--error\">\n <VvIcon name=\"ri:image-2-line\" />\n </span>\n </div>\n <figcaption v-if=\"!compact\" class=\"pk-chatbot-file-preview__caption\">\n <span\n v-if=\"filename\"\n class=\"pk-chatbot-file-preview__name\"\n :title=\"filename\"\n >{{ filename }}</span\n >\n <span class=\"pk-chatbot-file-preview__meta\">\n <span\n v-if=\"error\"\n class=\"pk-chatbot-file-preview__error\"\n :title=\"error\">\n <VvIcon name=\"ri:error-warning-line\" />\n {{ error }}\n </span>\n <template v-else>{{ imageMeta.join(' · ') }}</template>\n </span>\n </figcaption>\n <button\n v-if=\"removable && !loading\"\n type=\"button\"\n class=\"pk-chatbot-file-preview__remove\"\n :title=\"$t('action.remove')\"\n :aria-label=\"$t('action.remove')\"\n @click.prevent=\"$emit('remove')\">\n <VvIcon name=\"ri:close-line\" />\n </button>\n </figure>\n\n <!-- Document attachment: an icon tile + name + type · size -->\n <div\n v-else\n class=\"pk-chatbot-file-preview pk-chatbot-file-preview--doc\"\n :class=\"{\n 'pk-chatbot-file-preview--error': error,\n 'pk-chatbot-file-preview--compact': compact,\n 'pk-chatbot-file-preview--show-download': showDownload,\n }\">\n <span\n class=\"pk-chatbot-file-preview__icon-tile\"\n :style=\"{ '--file-accent': fileKind.accent }\">\n <VvIcon\n v-if=\"loading\"\n name=\"loading-loop\"\n class=\"pk-chatbot-file-preview__icon\" />\n <VvIcon\n v-else\n :name=\"fileKind.icon\"\n class=\"pk-chatbot-file-preview__icon\" />\n </span>\n <span class=\"pk-chatbot-file-preview__body\">\n <span\n v-if=\"filename\"\n class=\"pk-chatbot-file-preview__name\"\n :title=\"filename\"\n >{{ filename }}</span\n >\n <span class=\"pk-chatbot-file-preview__meta\">\n <span\n v-if=\"error\"\n class=\"pk-chatbot-file-preview__error\"\n :title=\"error\">\n <VvIcon name=\"ri:error-warning-line\" />\n {{ error }}\n </span>\n <template v-else>{{ docMeta.join(' · ') }}</template>\n </span>\n </span>\n <a\n v-if=\"!loading && !error && !compact\"\n :href=\"url\"\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n class=\"pk-chatbot-file-preview__open\"\n :title=\"$t('action.download')\"\n :aria-label=\"`${$t('action.download')}${filename ? ` — ${filename}` : ''}`\">\n <VvIcon name=\"ri:download-2-line\" />\n </a>\n <button\n v-if=\"removable && !loading\"\n type=\"button\"\n class=\"pk-chatbot-file-preview__remove\"\n :class=\"{ 'pk-chatbot-file-preview__remove--inline': !compact }\"\n :title=\"$t('action.remove')\"\n :aria-label=\"$t('action.remove')\"\n @click=\"$emit('remove')\">\n <VvIcon name=\"ri:close-line\" />\n </button>\n </div>\n</template>\n\n<style lang=\"scss\">\n .pk-chatbot-file-preview {\n // Concentric radii: outer card and inner tiles share an 8px step.\n --file-radius: var(--rounded-lg, 12px);\n position: relative;\n max-width: 280px;\n background-color: var(--color-surface-1);\n border: 1px solid var(--color-surface-3);\n border-radius: var(--file-radius);\n color: var(--color-word-2);\n font-size: var(--text-12);\n\n &--error {\n border-color: var(--color-danger);\n }\n\n // --- Document card ---\n &--doc {\n display: flex;\n align-items: center;\n gap: var(--spacing-8);\n padding: var(--spacing-8);\n min-width: 0;\n transition:\n border-color 0.18s var(--ease-out),\n background-color 0.18s var(--ease-out),\n box-shadow 0.18s var(--ease-out);\n\n &:hover {\n border-color: var(--color-surface-4);\n background-color: var(--color-surface-2);\n box-shadow: var(--shadow-sm);\n }\n }\n\n &__icon-tile {\n display: flex;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n width: var(--spacing-36);\n height: var(--spacing-36);\n // One step down from the card radius keeps the nested tile concentric.\n border-radius: var(--rounded-md, 6px);\n // Tint derived from the file-type accent; glyph sits at full accent.\n background-color: color-mix(\n in srgb,\n var(--file-accent) 14%,\n transparent\n );\n color: var(--file-accent);\n }\n\n &__icon {\n width: var(--spacing-20);\n height: var(--spacing-20);\n }\n\n &__body {\n display: flex;\n flex-direction: column;\n gap: 1px;\n min-width: 0;\n flex: 1;\n }\n\n &__open {\n display: flex;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n width: var(--spacing-28);\n height: var(--spacing-28);\n margin-inline-start: var(--spacing-4);\n border-radius: var(--rounded);\n color: var(--color-word-3);\n text-decoration: none;\n opacity: 0;\n transition:\n opacity 0.18s var(--ease-out),\n color 0.18s var(--ease-out),\n background-color 0.18s var(--ease-out);\n\n &:hover {\n background-color: var(--color-surface-3);\n color: var(--color-word-1);\n }\n }\n\n &--doc:hover &__open,\n &--doc:focus-within &__open,\n &--show-download &__open {\n opacity: 1;\n }\n\n // Touch devices have no hover: keep the open affordance visible.\n @media (hover: none) {\n &--doc &__open {\n opacity: 1;\n }\n }\n\n // --- Image card ---\n &--image {\n display: inline-flex;\n flex-direction: column;\n margin: 0;\n }\n\n &__thumb-wrap {\n position: relative;\n display: flex;\n // Fixed box: the card claims its final footprint before the\n // image decodes, so the message log never shifts on load.\n width: 280px;\n max-width: 100%;\n height: 200px;\n background-color: var(--color-surface);\n border-radius: var(--file-radius);\n overflow: hidden;\n }\n\n &__thumb-link {\n display: flex;\n width: 100%;\n height: 100%;\n cursor: pointer;\n }\n\n &__thumb {\n display: block;\n width: 100%;\n height: 100%;\n object-fit: cover;\n }\n\n &__caption {\n display: flex;\n flex-direction: column;\n gap: 1px;\n padding: var(--spacing-8);\n min-width: 0;\n }\n\n &__name {\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n font-weight: var(--font-medium, 500);\n color: var(--color-word-1);\n }\n\n &__meta {\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n font-size: var(--text-10);\n line-height: var(--leading-normal);\n // Reserve the line even while empty: image dimensions arrive\n // only after decode and must not grow the caption.\n min-height: 1.4em;\n color: var(--color-word-4);\n // Stable column for the dimensions/size figures.\n font-variant-numeric: tabular-nums;\n }\n\n &__error {\n display: inline-flex;\n align-items: center;\n gap: var(--spacing-4);\n color: var(--u-danger-text, var(--color-danger));\n\n .vv-icon {\n flex-shrink: 0;\n }\n }\n\n &__status {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n\n .vv-icon {\n width: var(--spacing-32);\n height: var(--spacing-32);\n }\n\n &--overlay {\n position: absolute;\n inset: 0;\n background-color: color-mix(\n in srgb,\n var(--color-surface-1) 70%,\n transparent\n );\n color: var(--color-word-1);\n }\n\n &--error {\n color: var(--color-word-4);\n }\n }\n\n // --- Remove button (input context) ---\n &__remove {\n position: absolute;\n top: 0;\n right: 0;\n transform: translate(50%, -50%);\n display: flex;\n align-items: center;\n justify-content: center;\n // Visible glyph is 18px but the hit area stays a comfortable 28px.\n width: var(--spacing-28);\n height: var(--spacing-28);\n padding: 0;\n border: none;\n border-radius: var(--rounded-full, 9999px);\n background-color: color-mix(\n in srgb,\n var(--color-surface-1) 85%,\n transparent\n );\n color: var(--color-word-2);\n cursor: pointer;\n line-height: 1;\n box-shadow: var(--shadow-sm);\n transition:\n background-color 0.18s var(--ease-out),\n color 0.18s var(--ease-out),\n scale 0.12s var(--ease-out);\n\n &:hover {\n background-color: var(--color-surface-2);\n color: var(--color-word-1);\n }\n\n &:active {\n scale: 0.96;\n }\n\n // Inside the document row the button rides inline, not overlaid\n // (size is inherited from the base).\n &--inline {\n position: static;\n flex-shrink: 0;\n box-shadow: none;\n background-color: transparent;\n color: var(--color-word-4);\n\n &:hover {\n background-color: var(--color-surface-3);\n color: var(--color-word-1);\n }\n }\n }\n\n // --- Compact (composer) chips ---\n // ChatGPT/Gemini-style: every attachment is the SAME height and carries\n // an always-visible round remove control in the top-right corner — no\n // hover-revealed actions, no size mismatch between image and file chips.\n &--compact {\n // Shared chip height so image and file chips align in the row.\n --chip-size: var(--spacing-56);\n max-width: 240px;\n\n &.pk-chatbot-file-preview--image {\n width: var(--chip-size);\n height: var(--chip-size);\n border-radius: var(--rounded-md, 6px);\n }\n\n // Fill the square; the root's `overflow: hidden` rounds the corners.\n .pk-chatbot-file-preview__thumb-wrap,\n .pk-chatbot-file-preview__thumb-link {\n width: 100%;\n height: 100%;\n }\n\n &.pk-chatbot-file-preview--doc {\n height: var(--chip-size);\n gap: var(--spacing-8);\n padding: var(--spacing-8);\n // Reserve room so the corner remove button never overlaps the\n // name/meta text.\n padding-inline-end: var(--spacing-28);\n }\n\n // Square icon tile that fills the chip height, so file chips read at\n // the same scale as image thumbnails: the chip less its padding and\n // its two 1px borders. Not `align-self: stretch` with `aspect-ratio`,\n // which Safari sizes from the chip's outer height, so the tile ran\n // past the chip's bottom edge.\n .pk-chatbot-file-preview__icon-tile {\n --chip-tile-size: calc(\n var(--chip-size) - 2 * var(--spacing-8) - 2px\n );\n width: var(--chip-tile-size);\n height: var(--chip-tile-size);\n }\n\n .pk-chatbot-file-preview__icon {\n width: var(--spacing-20);\n height: var(--spacing-20);\n }\n }\n\n // Always-visible round remove control in the top-right corner. Sits\n // just inside the chip (an ancestor clips overflow), bigger than before,\n // with an extended hit area for easy tapping.\n &--compact &__remove {\n top: var(--spacing-4);\n right: var(--spacing-4);\n width: var(--spacing-24);\n height: var(--spacing-24);\n border: 1px solid var(--color-surface-4);\n background-color: var(--color-surface);\n color: var(--color-word-1);\n font-size: var(--text-16);\n box-shadow: var(--shadow-sm);\n\n // Comfortable touch/click target beyond the small visible glyph.\n &::after {\n content: '';\n position: absolute;\n inset: calc(-1 * var(--spacing-4));\n }\n\n &:hover {\n background-color: var(--color-surface-2);\n color: var(--color-word-1);\n }\n }\n\n @media (prefers-reduced-motion: reduce) {\n &--doc,\n &__open,\n &__remove {\n transition: none;\n }\n }\n }\n</style>\n","<script lang=\"ts\" setup>\n import { computed, ref, onMounted } from 'vue'\n import { useLightbox } from '../composables/useLightbox'\n\n const props = defineProps<{\n mediaType: string\n url: string\n filename?: string\n /** Size in bytes, when known (e.g. local uploads). */\n size?: number\n loading?: boolean\n error?: string\n removable?: boolean\n /**\n * Dense layout for the composer: a small square thumbnail / slim row,\n * without the metadata caption. The full card is reserved for the\n * message log where the details matter.\n */\n compact?: boolean\n /**\n * Keeps the download button visible instead of revealing it on hover:\n * for a file the agent wrote, which the user is there to download.\n */\n showDownload?: boolean\n }>()\n\n defineEmits<{\n remove: []\n }>()\n\n const isImage = computed(() => props.mediaType.startsWith('image/'))\n\n // --- File-type identity (icon + accent + human kind label) ---\n\n const extension = computed(() => {\n const name = props.filename ?? ''\n const dot = name.lastIndexOf('.')\n return dot > 0 ? name.slice(dot + 1).toLowerCase() : ''\n })\n\n // mediaType subtype, e.g. \"pdf\" from \"application/pdf\"\n const subtype = computed(\n () => props.mediaType.split('/')[1]?.split(';')[0] ?? '',\n )\n\n type FileKind = {\n icon: string\n // CSS color driving the icon glyph + tile tint\n accent: string\n label: string\n }\n\n const KINDS: Array<{ test: RegExp; kind: FileKind }> = [\n {\n test: /pdf/,\n kind: {\n icon: 'ri:file-pdf-2-line',\n accent: '#e2483d',\n label: 'PDF',\n },\n },\n {\n // Match Word specifically: the generic `document` substring also\n // appears in the XLSX/PPTX OOXML mime types (…officedocument…), so\n // matching it here would mis-label spreadsheets/decks as Word.\n test: /msword|wordprocessingml/,\n kind: {\n icon: 'ri:file-word-2-line',\n accent: '#2b7de9',\n label: 'Word',\n },\n },\n {\n test: /sheet|excel|csv/,\n kind: {\n icon: 'ri:file-excel-2-line',\n accent: '#1f9d57',\n label: 'Spreadsheet',\n },\n },\n {\n test: /presentation|powerpoint/,\n kind: {\n icon: 'ri:file-ppt-2-line',\n accent: '#d98324',\n label: 'Presentation',\n },\n },\n {\n test: /zip|compressed|x-7z|x-rar|x-tar|gzip/,\n kind: {\n icon: 'ri:file-zip-line',\n accent: '#c08a1e',\n label: 'Archive',\n },\n },\n {\n test: /^audio\\//,\n kind: {\n icon: 'ri:file-music-line',\n accent: '#8b5cf6',\n label: 'Audio',\n },\n },\n {\n test: /^video\\//,\n kind: {\n icon: 'ri:film-line',\n accent: '#6366f1',\n label: 'Video',\n },\n },\n {\n test: /json|javascript|typescript|xml|html|x-sh|x-python/,\n kind: {\n icon: 'ri:file-code-line',\n accent: '#0d9488',\n label: 'Code',\n },\n },\n {\n test: /^text\\//,\n kind: {\n icon: 'ri:file-text-line',\n accent: '#64748b',\n label: 'Text',\n },\n },\n ]\n\n const fileKind = computed<FileKind>(() => {\n const haystack = `${props.mediaType} ${extension.value}`\n for (const { test, kind } of KINDS) {\n if (test.test(haystack)) {\n return kind\n }\n }\n return {\n icon: 'ri:file-line',\n accent: '#64748b',\n label: 'File',\n }\n })\n\n // Short type tag shown in the meta line: prefer the real extension, fall\n // back to the mime subtype, then the generic kind label.\n const typeTag = computed(() =>\n (\n extension.value ||\n subtype.value ||\n fileKind.value.label\n ).toUpperCase(),\n )\n\n // --- Size & dimensions ---\n\n const formattedSize = computed(() => {\n const bytes = props.size\n if (bytes === undefined || bytes < 0) {\n return undefined\n }\n if (bytes < 1024) {\n return `${bytes} B`\n }\n const units = ['KB', 'MB', 'GB', 'TB']\n let value = bytes / 1024\n let unitIndex = 0\n while (value >= 1024 && unitIndex < units.length - 1) {\n value /= 1024\n unitIndex += 1\n }\n return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[unitIndex]}`\n })\n\n // Thumbnail load lifecycle. Driving the spinner and the error fallback\n // off the real <img> events (not a second background fetch) means the\n // image is requested exactly once and reuses the browser cache on any\n // later re-render, instead of re-downloading every time the message list\n // repaints.\n // Not `useImageFallback`: this tile also needs `loading` vs `loaded` and the\n // decoded dimensions, cached across remounts, so the error flag is one field\n // of a richer per-url entry rather than a standalone broken-url set.\n type ThumbStatus = 'loading' | 'loaded' | 'error'\n\n // Module-level, keyed by URL: survives component recreation. In polled\n // views (e.g. admin chat-logs, refreshed every few seconds) the whole\n // message array is replaced on each poll, so this component can be torn\n // down and rebuilt. Seeding from the cache means a URL that already\n // decoded starts in `loaded` (no spinner flash, no re-decode) instead of\n // flickering back through `loading`. This is a client-side, per-session\n // cache (no server/Redis involved); bounded to a max size so a long-lived\n // session browsing many images can't grow it without limit — the oldest\n // entry is evicted first (Map preserves insertion order).\n type ThumbEntry = {\n status: ThumbStatus\n dimensions?: { width: number; height: number }\n }\n const THUMB_CACHE_MAX = 300\n const thumbCache = new Map<string, ThumbEntry>()\n const setThumbCache = (url: string, entry: ThumbEntry) => {\n // Re-insert to mark as most-recently used, then trim from the front.\n thumbCache.delete(url)\n thumbCache.set(url, entry)\n if (thumbCache.size > THUMB_CACHE_MAX) {\n thumbCache.delete(thumbCache.keys().next().value as string)\n }\n }\n\n const cached = thumbCache.get(props.url)\n const thumbStatus = ref<ThumbStatus>(cached?.status ?? 'loading')\n\n // Natural pixel dimensions, captured once the thumbnail decodes. Doubles\n // as the size hint PhotoSwipe needs for its slide (falls back until known).\n const dimensions = ref<{ width: number; height: number } | undefined>(\n cached?.dimensions,\n )\n const onImageLoad = (event: Event) => {\n const img = event.target as HTMLImageElement\n if (img.naturalWidth && img.naturalHeight) {\n dimensions.value = {\n width: img.naturalWidth,\n height: img.naturalHeight,\n }\n }\n thumbStatus.value = 'loaded'\n setThumbCache(props.url, {\n status: 'loaded',\n dimensions: dimensions.value,\n })\n }\n const onImageError = () => {\n thumbStatus.value = 'error'\n setThumbCache(props.url, { status: 'error' })\n }\n\n const pswpSize = computed(() => dimensions.value ?? DEFAULT_PSWP_SIZE)\n\n // Meta fragments joined with \" · \" for the caption / doc subtitle.\n const imageMeta = computed(() =>\n [\n dimensions.value &&\n `${dimensions.value.width}×${dimensions.value.height}`,\n formattedSize.value,\n ].filter(Boolean),\n )\n const docMeta = computed(() =>\n [typeTag.value, formattedSize.value].filter(Boolean),\n )\n\n // --- Lightbox ---\n\n // Slide size PhotoSwipe assumes before the real dimensions are known.\n const DEFAULT_PSWP_SIZE = { width: 1200, height: 800 }\n\n const galleryRef = ref<HTMLElement>()\n const { init } = useLightbox({ gallery: galleryRef, loop: false })\n\n onMounted(async () => {\n if (!isImage.value) {\n return\n }\n await init()\n })\n</script>\n\n<template>\n <!-- Image attachment: a thumbnail tile with a metadata caption -->\n <figure\n v-if=\"isImage\"\n class=\"pk-chatbot-file-preview pk-chatbot-file-preview--image\"\n :class=\"{ 'pk-chatbot-file-preview--compact': compact }\">\n <div ref=\"galleryRef\" class=\"pk-chatbot-file-preview__thumb-wrap\">\n <a\n :href=\"url\"\n :data-pswp-src=\"url\"\n :data-pswp-width=\"pswpSize.width\"\n :data-pswp-height=\"pswpSize.height\"\n :data-pswp-caption=\"filename ?? ''\"\n class=\"pk-chatbot-file-preview__thumb-link\"\n :aria-label=\"filename ?? $t('label.file')\">\n <img\n v-show=\"thumbStatus === 'loaded'\"\n :src=\"url\"\n :alt=\"filename\"\n decoding=\"async\"\n class=\"pk-chatbot-file-preview__thumb\"\n @load=\"onImageLoad\"\n @error=\"onImageError\" />\n </a>\n <!-- Spinner until the thumbnail decodes (or while an upload is in\n flight); a fixed box keeps this from shifting the layout. -->\n <span\n v-if=\"loading || thumbStatus === 'loading'\"\n class=\"pk-chatbot-file-preview__status pk-chatbot-file-preview__status--overlay\">\n <VvIcon name=\"loading-loop\" />\n </span>\n <!-- Failed to load: a quiet icon instead of the browser's broken\n image glyph. -->\n <span\n v-else-if=\"thumbStatus === 'error'\"\n class=\"pk-chatbot-file-preview__status pk-chatbot-file-preview__status--overlay pk-chatbot-file-preview__status--error\">\n <VvIcon name=\"ri:image-2-line\" />\n </span>\n </div>\n <figcaption v-if=\"!compact\" class=\"pk-chatbot-file-preview__caption\">\n <span\n v-if=\"filename\"\n class=\"pk-chatbot-file-preview__name\"\n :title=\"filename\"\n >{{ filename }}</span\n >\n <span class=\"pk-chatbot-file-preview__meta\">\n <span\n v-if=\"error\"\n class=\"pk-chatbot-file-preview__error\"\n :title=\"error\">\n <VvIcon name=\"ri:error-warning-line\" />\n {{ error }}\n </span>\n <template v-else>{{ imageMeta.join(' · ') }}</template>\n </span>\n </figcaption>\n <button\n v-if=\"removable && !loading\"\n type=\"button\"\n class=\"pk-chatbot-file-preview__remove\"\n :title=\"$t('action.remove')\"\n :aria-label=\"$t('action.remove')\"\n @click.prevent=\"$emit('remove')\">\n <VvIcon name=\"ri:close-line\" />\n </button>\n </figure>\n\n <!-- Document attachment: an icon tile + name + type · size -->\n <div\n v-else\n class=\"pk-chatbot-file-preview pk-chatbot-file-preview--doc\"\n :class=\"{\n 'pk-chatbot-file-preview--error': error,\n 'pk-chatbot-file-preview--compact': compact,\n 'pk-chatbot-file-preview--show-download': showDownload,\n }\">\n <span\n class=\"pk-chatbot-file-preview__icon-tile\"\n :style=\"{ '--file-accent': fileKind.accent }\">\n <VvIcon\n v-if=\"loading\"\n name=\"loading-loop\"\n class=\"pk-chatbot-file-preview__icon\" />\n <VvIcon\n v-else\n :name=\"fileKind.icon\"\n class=\"pk-chatbot-file-preview__icon\" />\n </span>\n <span class=\"pk-chatbot-file-preview__body\">\n <span\n v-if=\"filename\"\n class=\"pk-chatbot-file-preview__name\"\n :title=\"filename\"\n >{{ filename }}</span\n >\n <span class=\"pk-chatbot-file-preview__meta\">\n <span\n v-if=\"error\"\n class=\"pk-chatbot-file-preview__error\"\n :title=\"error\">\n <VvIcon name=\"ri:error-warning-line\" />\n {{ error }}\n </span>\n <template v-else>{{ docMeta.join(' · ') }}</template>\n </span>\n </span>\n <a\n v-if=\"!loading && !error && !compact\"\n :href=\"url\"\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n class=\"pk-chatbot-file-preview__open\"\n :title=\"$t('action.download')\"\n :aria-label=\"`${$t('action.download')}${filename ? ` — ${filename}` : ''}`\">\n <VvIcon name=\"ri:download-2-line\" />\n </a>\n <button\n v-if=\"removable && !loading\"\n type=\"button\"\n class=\"pk-chatbot-file-preview__remove\"\n :class=\"{ 'pk-chatbot-file-preview__remove--inline': !compact }\"\n :title=\"$t('action.remove')\"\n :aria-label=\"$t('action.remove')\"\n @click=\"$emit('remove')\">\n <VvIcon name=\"ri:close-line\" />\n </button>\n </div>\n</template>\n\n<style lang=\"scss\">\n .pk-chatbot-file-preview {\n // Concentric radii: outer card and inner tiles share an 8px step.\n --file-radius: var(--rounded-lg, 12px);\n position: relative;\n max-width: 280px;\n background-color: var(--color-surface-1);\n border: 1px solid var(--color-surface-3);\n border-radius: var(--file-radius);\n color: var(--color-word-2);\n font-size: var(--text-12);\n\n &--error {\n border-color: var(--color-danger);\n }\n\n // --- Document card ---\n &--doc {\n display: flex;\n align-items: center;\n gap: var(--spacing-8);\n padding: var(--spacing-8);\n min-width: 0;\n transition:\n border-color 0.18s var(--ease-out),\n background-color 0.18s var(--ease-out),\n box-shadow 0.18s var(--ease-out);\n\n &:hover {\n border-color: var(--color-surface-4);\n background-color: var(--color-surface-2);\n box-shadow: var(--shadow-sm);\n }\n }\n\n &__icon-tile {\n display: flex;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n width: var(--spacing-36);\n height: var(--spacing-36);\n // One step down from the card radius keeps the nested tile concentric.\n border-radius: var(--rounded-md, 6px);\n // Tint derived from the file-type accent; glyph sits at full accent.\n background-color: color-mix(\n in srgb,\n var(--file-accent) 14%,\n transparent\n );\n color: var(--file-accent);\n }\n\n &__icon {\n width: var(--spacing-20);\n height: var(--spacing-20);\n }\n\n &__body {\n display: flex;\n flex-direction: column;\n gap: 1px;\n min-width: 0;\n flex: 1;\n }\n\n &__open {\n display: flex;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n width: var(--spacing-28);\n height: var(--spacing-28);\n margin-inline-start: var(--spacing-4);\n border-radius: var(--rounded);\n color: var(--color-word-3);\n text-decoration: none;\n opacity: 0;\n transition:\n opacity 0.18s var(--ease-out),\n color 0.18s var(--ease-out),\n background-color 0.18s var(--ease-out);\n\n &:hover {\n background-color: var(--color-surface-3);\n color: var(--color-word-1);\n }\n }\n\n &--doc:hover &__open,\n &--doc:focus-within &__open,\n &--show-download &__open {\n opacity: 1;\n }\n\n // Touch devices have no hover: keep the open affordance visible.\n @media (hover: none) {\n &--doc &__open {\n opacity: 1;\n }\n }\n\n // --- Image card ---\n &--image {\n display: inline-flex;\n flex-direction: column;\n margin: 0;\n }\n\n &__thumb-wrap {\n position: relative;\n display: flex;\n // Fixed box: the card claims its final footprint before the\n // image decodes, so the message log never shifts on load.\n width: 280px;\n max-width: 100%;\n height: 200px;\n background-color: var(--color-surface);\n border-radius: var(--file-radius);\n overflow: hidden;\n }\n\n &__thumb-link {\n display: flex;\n width: 100%;\n height: 100%;\n cursor: pointer;\n }\n\n &__thumb {\n display: block;\n width: 100%;\n height: 100%;\n object-fit: cover;\n }\n\n &__caption {\n display: flex;\n flex-direction: column;\n gap: 1px;\n padding: var(--spacing-8);\n min-width: 0;\n }\n\n &__name {\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n font-weight: var(--font-medium, 500);\n color: var(--color-word-1);\n }\n\n &__meta {\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n font-size: var(--text-10);\n line-height: var(--leading-normal);\n // Reserve the line even while empty: image dimensions arrive\n // only after decode and must not grow the caption.\n min-height: 1.4em;\n color: var(--color-word-4);\n // Stable column for the dimensions/size figures.\n font-variant-numeric: tabular-nums;\n }\n\n &__error {\n display: inline-flex;\n align-items: center;\n gap: var(--spacing-4);\n color: var(--u-danger-text, var(--color-danger));\n\n .vv-icon {\n flex-shrink: 0;\n }\n }\n\n &__status {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n\n .vv-icon {\n width: var(--spacing-32);\n height: var(--spacing-32);\n }\n\n &--overlay {\n position: absolute;\n inset: 0;\n background-color: color-mix(\n in srgb,\n var(--color-surface-1) 70%,\n transparent\n );\n color: var(--color-word-1);\n }\n\n &--error {\n color: var(--color-word-4);\n }\n }\n\n // --- Remove button (input context) ---\n &__remove {\n position: absolute;\n top: 0;\n right: 0;\n transform: translate(50%, -50%);\n display: flex;\n align-items: center;\n justify-content: center;\n // Visible glyph is 18px but the hit area stays a comfortable 28px.\n width: var(--spacing-28);\n height: var(--spacing-28);\n padding: 0;\n border: none;\n border-radius: var(--rounded-full, 9999px);\n background-color: color-mix(\n in srgb,\n var(--color-surface-1) 85%,\n transparent\n );\n color: var(--color-word-2);\n cursor: pointer;\n line-height: 1;\n box-shadow: var(--shadow-sm);\n transition:\n background-color 0.18s var(--ease-out),\n color 0.18s var(--ease-out),\n scale 0.12s var(--ease-out);\n\n &:hover {\n background-color: var(--color-surface-2);\n color: var(--color-word-1);\n }\n\n &:active {\n scale: 0.96;\n }\n\n // Inside the document row the button rides inline, not overlaid\n // (size is inherited from the base).\n &--inline {\n position: static;\n flex-shrink: 0;\n box-shadow: none;\n background-color: transparent;\n color: var(--color-word-4);\n\n &:hover {\n background-color: var(--color-surface-3);\n color: var(--color-word-1);\n }\n }\n }\n\n // --- Compact (composer) chips ---\n // ChatGPT/Gemini-style: every attachment is the SAME height and carries\n // an always-visible round remove control in the top-right corner — no\n // hover-revealed actions, no size mismatch between image and file chips.\n &--compact {\n // Shared chip height so image and file chips align in the row.\n --chip-size: var(--spacing-56);\n max-width: 240px;\n\n &.pk-chatbot-file-preview--image {\n width: var(--chip-size);\n height: var(--chip-size);\n border-radius: var(--rounded-md, 6px);\n }\n\n // Fill the square; the root's `overflow: hidden` rounds the corners.\n .pk-chatbot-file-preview__thumb-wrap,\n .pk-chatbot-file-preview__thumb-link {\n width: 100%;\n height: 100%;\n }\n\n &.pk-chatbot-file-preview--doc {\n height: var(--chip-size);\n gap: var(--spacing-8);\n padding: var(--spacing-8);\n // Reserve room so the corner remove button never overlaps the\n // name/meta text.\n padding-inline-end: var(--spacing-28);\n }\n\n // Square icon tile that fills the chip height, so file chips read at\n // the same scale as image thumbnails: the chip less its padding and\n // its two 1px borders. Not `align-self: stretch` with `aspect-ratio`,\n // which Safari sizes from the chip's outer height, so the tile ran\n // past the chip's bottom edge.\n .pk-chatbot-file-preview__icon-tile {\n --chip-tile-size: calc(\n var(--chip-size) - 2 * var(--spacing-8) - 2px\n );\n width: var(--chip-tile-size);\n height: var(--chip-tile-size);\n }\n\n .pk-chatbot-file-preview__icon {\n width: var(--spacing-20);\n height: var(--spacing-20);\n }\n }\n\n // Always-visible round remove control in the top-right corner. Sits\n // just inside the chip (an ancestor clips overflow), bigger than before,\n // with an extended hit area for easy tapping.\n &--compact &__remove {\n top: var(--spacing-4);\n right: var(--spacing-4);\n width: var(--spacing-24);\n height: var(--spacing-24);\n border: 1px solid var(--color-surface-4);\n background-color: var(--color-surface);\n color: var(--color-word-1);\n font-size: var(--text-16);\n box-shadow: var(--shadow-sm);\n\n // Comfortable touch/click target beyond the small visible glyph.\n &::after {\n content: '';\n position: absolute;\n inset: calc(-1 * var(--spacing-4));\n }\n\n &:hover {\n background-color: var(--color-surface-2);\n color: var(--color-word-1);\n }\n }\n\n @media (prefers-reduced-motion: reduce) {\n &--doc,\n &__open,\n &__remove {\n transition: none;\n }\n }\n }\n</style>\n"],"mappings":"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"}
|