@lightworkai.official/debug-capture-vue 0.6.0 → 0.7.0

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.
Files changed (35) hide show
  1. package/README.md +12 -8
  2. package/dist/components/{ToolbarButton.vue.d.ts → SupportPortalButton.vue.d.ts} +12 -7
  3. package/dist/index.d.ts +2 -8
  4. package/dist/index.mjs +762 -2319
  5. package/dist/index.mjs.map +1 -1
  6. package/package.json +2 -2
  7. package/src/components/SupportPortalButton.vue +50 -0
  8. package/src/index.ts +11 -16
  9. package/dist/components/ImageAnnotatorDialog.vue.d.ts +0 -19
  10. package/dist/components/MyTicketsPanel.vue.d.ts +0 -17
  11. package/dist/components/ReopenPanel.vue.d.ts +0 -12
  12. package/dist/components/RichReplyEditor.vue.d.ts +0 -59
  13. package/dist/components/StatusHero.vue.d.ts +0 -10
  14. package/dist/components/StatusPill.vue.d.ts +0 -16
  15. package/dist/components/TicketBody.vue.d.ts +0 -6
  16. package/dist/components/TicketConversation.vue.d.ts +0 -22
  17. package/dist/components/TicketDetail.vue.d.ts +0 -28
  18. package/dist/components/TicketScreenshots.vue.d.ts +0 -8
  19. package/dist/components/TicketTable.vue.d.ts +0 -25
  20. package/dist/components/TicketTimeline.vue.d.ts +0 -11
  21. package/dist/components/icons.d.ts +0 -38
  22. package/src/components/ImageAnnotatorDialog.vue +0 -160
  23. package/src/components/MyTicketsPanel.vue +0 -312
  24. package/src/components/ReopenPanel.vue +0 -47
  25. package/src/components/RichReplyEditor.vue +0 -321
  26. package/src/components/StatusHero.vue +0 -85
  27. package/src/components/StatusPill.vue +0 -14
  28. package/src/components/TicketBody.vue +0 -50
  29. package/src/components/TicketConversation.vue +0 -227
  30. package/src/components/TicketDetail.vue +0 -130
  31. package/src/components/TicketScreenshots.vue +0 -47
  32. package/src/components/TicketTable.vue +0 -142
  33. package/src/components/TicketTimeline.vue +0 -86
  34. package/src/components/ToolbarButton.vue +0 -33
  35. package/src/components/icons.ts +0 -90
@@ -1,85 +0,0 @@
1
- <script setup lang="ts">
2
- /**
3
- * Where the report stands, in one banner.
4
- *
5
- * The status LINE is config-first: an awaiting lane's "please reply" wins, then
6
- * the lane's own description (so a realm's custom lane carries its own words),
7
- * then our default copy, then the bare label.
8
- */
9
- import { computed } from "vue";
10
- import type { MyTicketDetail, StatusMaps, TicketStrings } from "@lightworkai.official/debug-capture";
11
- import TicketBody from "./TicketBody.vue";
12
- import { BanIcon, CheckCircleIcon, ClockIcon, InboxIcon, MessageIcon, WrenchIcon } from "./icons";
13
-
14
- const props = defineProps<{
15
- ticket: MyTicketDetail;
16
- maps: StatusMaps;
17
- t: TicketStrings;
18
- locale: "th" | "en";
19
- host: string;
20
- }>();
21
-
22
- /** Only the SYSTEM statuses get a tone; an admin-added lane falls back to OPEN. */
23
- const TONES: Record<string, { icon: unknown; border: string; bg: string; fg: string; accent: string }> = {
24
- OPEN: { icon: InboxIcon, border: "#bfdbfe", bg: "#eff6ff", fg: "#1e3a8a", accent: "#3b82f6" },
25
- IN_PROGRESS: { icon: ClockIcon, border: "#fde68a", bg: "#fffbeb", fg: "#78350f", accent: "#f59e0b" },
26
- RESOLVED: { icon: CheckCircleIcon, border: "#a7f3d0", bg: "#ecfdf5", fg: "#064e3b", accent: "#10b981" },
27
- CLOSED: { icon: CheckCircleIcon, border: "#e5e7eb", bg: "#f9fafb", fg: "#1f2937", accent: "#9ca3af" },
28
- WONT_FIX: { icon: BanIcon, border: "#e5e7eb", bg: "#f9fafb", fg: "#374151", accent: "#9ca3af" },
29
- };
30
- const AWAITING = { icon: MessageIcon, border: "#fcd34d", bg: "#fffbeb", fg: "#78350f", accent: "#f59e0b" };
31
-
32
- const MESSAGES: Record<string, { th: string; en: string }> = {
33
- OPEN: { th: "รับเรื่องแล้ว — รอทีมงานตรวจสอบ", en: "Received — waiting for the team to look" },
34
- IN_PROGRESS: { th: "ทีมงานกำลังดำเนินการแก้ไข", en: "The team is working on it" },
35
- RESOLVED: { th: "แก้ไขเรียบร้อยแล้ว", en: "Fixed" },
36
- CLOSED: { th: "ปิดเรื่องแล้ว", en: "Closed" },
37
- WONT_FIX: { th: "พิจารณาแล้ว — ยังไม่ได้ดำเนินการในขณะนี้", en: "Reviewed — not being worked on for now" },
38
- };
39
-
40
- const awaiting = computed(() => props.maps.isAwaitingReply(props.ticket.status));
41
- const tone = computed(() => (awaiting.value ? AWAITING : (TONES[props.ticket.status] ?? TONES.OPEN!)));
42
-
43
- const headline = computed(() => {
44
- if (awaiting.value) {
45
- return props.locale === "en"
46
- ? "The team needs more information from you"
47
- : "ทีมงานขอข้อมูลเพิ่มเติม — รอการตอบกลับจากคุณ";
48
- }
49
- const described = props.maps.columnOf(props.ticket.status)?.description?.trim();
50
- return described || MESSAGES[props.ticket.status]?.[props.locale] || props.maps.labelOf(props.ticket.status);
51
- });
52
-
53
- /*
54
- * The solution note, only when the thread does not already carry it. A resolved
55
- * ticket usually has the team's answer as their last message, and printing the
56
- * note here as well says the same words twice on one screen. Kept as the
57
- * fallback for a ticket whose note never reached the conversation.
58
- */
59
- const showNote = computed(
60
- () =>
61
- !awaiting.value &&
62
- !props.ticket.messages.some((m) => m.authorRole === "AGENT") &&
63
- props.maps.isClosed(props.ticket.status) &&
64
- Boolean(props.ticket.resolutionNote?.trim()),
65
- );
66
- </script>
67
-
68
- <template>
69
- <section class="lw-hero" :style="{ borderColor: tone.border, background: tone.bg }">
70
- <component :is="tone.icon" class="lw-hero__icon" :style="{ color: tone.accent }" />
71
- <div class="lw-hero__body">
72
- <p class="lw-hero__kicker">
73
- {{ awaiting ? (locale === "en" ? "AWAITING YOUR REPLY" : "รอการตอบกลับจากคุณ") : locale === "en" ? "LATEST STATUS" : "สถานะล่าสุด" }}
74
- </p>
75
- <p class="lw-hero__headline" :style="{ color: tone.fg }">{{ headline }}</p>
76
- <p v-if="awaiting" class="lw-hero__nudge">
77
- {{ locale === "en" ? "Please reply to the team in the conversation below" : "กรุณาตอบกลับให้ทีมงานในช่องแชทด้านล่าง" }}
78
- </p>
79
- <div v-if="showNote" class="lw-hero__note">
80
- <WrenchIcon class="lw-hero__wrench" />
81
- <TicketBody :html="ticket.resolutionNote ?? ''" :host="host" />
82
- </div>
83
- </div>
84
- </section>
85
- </template>
@@ -1,14 +0,0 @@
1
- <script setup lang="ts">
2
- /**
3
- * A status as the realm coloured it.
4
- *
5
- * The tone arrives as DATA — a realm adds lanes whenever it likes — so it is an
6
- * inline style rather than a class. There is no stylesheet that could know the
7
- * names in advance.
8
- */
9
- defineProps<{ label: string; tone: { fg: string; bg: string } }>();
10
- </script>
11
-
12
- <template>
13
- <span class="lw-status" :style="{ background: tone.bg, color: tone.fg }">{{ label }}</span>
14
- </template>
@@ -1,50 +0,0 @@
1
- <script setup lang="ts">
2
- /**
3
- * A message or description body, rendered safely.
4
- *
5
- * Deliberately NOT `v-html`. Bodies are HTML written by other people and shown
6
- * inside the HOST application's page — markup that ran here would run with the
7
- * host's origin, against the host's session. `renderBody` parses into an inert
8
- * document, walks it against an allowlist and fills this node; `v-html` would
9
- * hand the string straight to innerHTML.
10
- */
11
- import { onMounted, ref, watch } from "vue";
12
- import { renderBody } from "@lightworkai.official/debug-capture";
13
-
14
- const props = defineProps<{ html: string; host: string }>();
15
- const target = ref<HTMLElement | null>(null);
16
- /**
17
- * The image being viewed full size, if any.
18
- *
19
- * Bodies are capped to a thumbnail so one screenshot cannot push a whole
20
- * conversation off the screen — which is only reasonable if the full size is a
21
- * click away.
22
- *
23
- * Delegated from the container rather than bound per image: this is sanitised
24
- * HTML we inject, not elements we built, so there is nothing to attach to.
25
- */
26
- const zoomed = ref<string | null>(null);
27
-
28
- function onClick(event: MouseEvent): void {
29
- const node = event.target as HTMLElement | null;
30
- if (!node || node.tagName !== "IMG") return;
31
- const src = node.getAttribute("src");
32
- if (src) zoomed.value = src;
33
- }
34
-
35
- function paint(): void {
36
- if (target.value) renderBody(target.value, props.html, props.host);
37
- }
38
-
39
- onMounted(paint);
40
- watch(() => [props.html, props.host], paint);
41
- </script>
42
-
43
- <template>
44
- <div ref="target" class="lw-body" @click="onClick" />
45
-
46
- <div v-if="zoomed" class="lw-zoom" role="dialog" aria-modal="true" @click="zoomed = null">
47
- <button type="button" class="lw-zoom__close" aria-label="close">✕</button>
48
- <img :src="zoomed" alt="" />
49
- </div>
50
- </template>
@@ -1,227 +0,0 @@
1
- <script setup lang="ts">
2
- /**
3
- * The thread, and the box to add to it.
4
- *
5
- * The composer is the same TipTap editor the team uses — the original's own
6
- * reasoning, in its own words: "ผู้แจ้งอธิบายปัญหาด้วยภาพหน้าจอเป็นเรื่องปกติที่สุด".
7
- */
8
- import { computed, nextTick, ref, shallowRef, watch } from "vue";
9
- import { editMyMessage, formatDateTime, type MyTicketDetail, type TicketStrings } from "@lightworkai.official/debug-capture";
10
- import RichReplyEditor from "./RichReplyEditor.vue";
11
- import TicketBody from "./TicketBody.vue";
12
- import { CheckIcon2, PencilIcon, SendIcon, XIcon } from "./icons";
13
-
14
- const props = defineProps<{
15
- ticket: MyTicketDetail;
16
- t: TicketStrings;
17
- host: string;
18
- timezone: string;
19
- locale: "th" | "en" | undefined;
20
- sending?: boolean;
21
- uploadImage: (file: File) => Promise<string>;
22
- }>();
23
-
24
- const emit = defineEmits<{
25
- (e: "send", body: string): void;
26
- (e: "edited"): void;
27
- (e: "error", message: string): void;
28
- }>();
29
-
30
- const editor = ref<InstanceType<typeof RichReplyEditor> | null>(null);
31
- const thread = ref<HTMLElement | null>(null);
32
-
33
- /**
34
- * The message being rewritten, if any.
35
- *
36
- * Only ONE at a time: an open editor per bubble would let someone start three
37
- * rewrites and lose track of which are unsaved. The original makes the same
38
- * choice for the same reason.
39
- *
40
- * Only your own, and the server agrees — it refuses any message the caller did
41
- * not write, so this button is a convenience, not the rule.
42
- */
43
- const editingId = ref<string | null>(null);
44
- const savingEdit = ref(false);
45
-
46
- /**
47
- * The open edit box.
48
- *
49
- * A FUNCTION ref, not a string one. A `ref="x"` inside `v-for` collects an
50
- * ARRAY of instances — even when only one of them renders — so calling
51
- * `.value()` on it threw "value is not a function" the moment anyone pressed
52
- * บันทึกการแก้ไข. A function ref is handed the instance itself, and null when it
53
- * unmounts.
54
- */
55
- const editingDraft = shallowRef<InstanceType<typeof RichReplyEditor> | null>(null);
56
-
57
- function bindDraft(el: unknown): void {
58
- editingDraft.value = (el as InstanceType<typeof RichReplyEditor> | null) ?? null;
59
- }
60
-
61
- /** Just the toolbar's words: TicketStrings also carries formatters. */
62
- const labels = computed<Record<string, string>>(() => ({
63
- bold: props.t.bold,
64
- italic: props.t.italic,
65
- underline: props.t.underline,
66
- bulletList: props.t.bulletList,
67
- orderedList: props.t.orderedList,
68
- link: props.t.link,
69
- linkPrompt: props.t.linkPrompt,
70
- attachImage: props.t.attachImage,
71
- uploading: props.t.uploading,
72
- }));
73
-
74
- function canEdit(message: MyTicketDetail["messages"][number]): boolean {
75
- return message.authorRole === "REPORTER";
76
- }
77
-
78
- async function saveEdit(message: MyTicketDetail["messages"][number]): Promise<void> {
79
- const body = editingDraft.value?.value() ?? "";
80
- if (!body || savingEdit.value) return;
81
- savingEdit.value = true;
82
- try {
83
- await editMyMessage(props.ticket.id, message.id, body);
84
- editingId.value = null;
85
- emit("edited");
86
- } catch (e) {
87
- emit("error", e instanceof Error ? e.message : props.t.sendFailed);
88
- } finally {
89
- savingEdit.value = false;
90
- }
91
- }
92
-
93
- function send(): void {
94
- const body = editor.value?.value() ?? "";
95
- if (!body || props.sending) return;
96
- emit("send", body);
97
- // Cleared here, not by the parent. It used to be cleared by accident: the
98
- // whole detail unmounted after a reply, so a fresh editor came back empty.
99
- // Now that the view survives the send, the box has to be emptied on purpose.
100
- editor.value?.clear();
101
- }
102
-
103
- /**
104
- * Open at the newest message.
105
- *
106
- * The thread is a fixed-height window now, so without this it opens showing the
107
- * OLDEST messages and the reader has to scroll to find out what has happened
108
- * since. Every chat opens at the bottom; this one should too.
109
- *
110
- * Jumped, not animated: this is where the conversation starts, not a transition
111
- * from somewhere else, and watching it scroll on open reads as a glitch.
112
- */
113
- async function toBottom(behavior: ScrollBehavior): Promise<void> {
114
- await nextTick();
115
- const box = thread.value;
116
- if (box) box.scrollTop = box.scrollHeight;
117
- if (behavior === "smooth") {
118
- thread.value?.lastElementChild?.scrollIntoView({ block: "nearest", behavior });
119
- }
120
- }
121
-
122
- // A different ticket is a different conversation: start it at the bottom too.
123
- watch(() => props.ticket.id, () => void toBottom("auto"), { immediate: true });
124
-
125
- /**
126
- * Keep the newest message in view.
127
- *
128
- * The thread sits above the composer, so a reply pushes the composer down and
129
- * out of sight — the reader is left looking at the middle of the conversation
130
- * with no sign their message landed.
131
- */
132
- watch(
133
- () => props.ticket.messages.length,
134
- (now, before) => {
135
- if (now <= (before ?? 0)) return;
136
- void toBottom("smooth");
137
- },
138
- );
139
-
140
- defineExpose({ clear: () => editor.value?.clear() });
141
- </script>
142
-
143
- <template>
144
- <section class="lw-card">
145
- <h3 class="lw-card__title">{{ t.conversation }}</h3>
146
-
147
- <div ref="thread" class="lw-thread">
148
- <p v-if="!ticket.messages.length" class="lw-thread__empty">{{ t.noMessages }}</p>
149
- <!-- The original's shape exactly: the meta line lives INSIDE a bordered
150
- bubble, and a TEAM message shows only "ทีมงาน" — never the officer's
151
- name. That is deliberate there and worth keeping: the team answers as
152
- one unit, and a reporter should not see which person replied. -->
153
- <article
154
- v-for="message in ticket.messages"
155
- :key="message.id"
156
- class="lw-msg"
157
- :class="{ 'lw-msg--mine': message.authorRole === 'REPORTER' }"
158
- >
159
- <div class="lw-msg__bubble">
160
- <p class="lw-msg__meta">
161
- <template v-if="message.authorRole === 'AGENT'">
162
- <span class="lw-msg__who">{{ t.team }}</span>
163
- </template>
164
- <template v-else>
165
- <span class="lw-msg__who">{{ message.authorName || t.you }}</span>
166
- <span class="lw-msg__role">{{ t.reporterRole }}</span>
167
- </template>
168
- <span>· {{ formatDateTime(message.createdAt, timezone, locale) }}</span>
169
- <span v-if="message.editedAt" class="lw-msg__edited">· {{ t.edited }}</span>
170
- <button
171
- v-if="canEdit(message) && editingId !== message.id"
172
- type="button"
173
- class="lw-msg__edit"
174
- @click="editingId = message.id"
175
- >
176
- <PencilIcon /> {{ t.edit }}
177
- </button>
178
- </p>
179
-
180
- <!-- Rewriting uses the same editor as the composer: a correction is
181
- usually fixing a link or replacing the screenshot, and neither
182
- survives a plain text box. -->
183
- <div v-if="editingId === message.id" class="lw-msg__editing">
184
- <RichReplyEditor
185
- :ref="bindDraft"
186
- :placeholder="t.replyPlaceholder"
187
- :host="host"
188
- :locale="locale"
189
- :disabled="savingEdit"
190
- :upload-image="uploadImage"
191
- :labels="labels"
192
- :initial="message.body"
193
- @error="emit('error', $event)"
194
- />
195
- <div class="lw-msg__editActions">
196
- <button type="button" class="lw-btn" :disabled="savingEdit" @click="editingId = null">
197
- <XIcon /> {{ t.cancel }}
198
- </button>
199
- <button type="button" class="lw-send" :disabled="savingEdit" @click="saveEdit(message)">
200
- <CheckIcon2 /> {{ t.saveEdit }}
201
- </button>
202
- </div>
203
- </div>
204
- <TicketBody v-else :html="message.body" :host="host" />
205
- </div>
206
- </article>
207
- </div>
208
-
209
- <div class="lw-composer">
210
- <RichReplyEditor
211
- ref="editor"
212
- :placeholder="t.replyPlaceholder"
213
- :host="host"
214
- :locale="locale"
215
- :disabled="sending"
216
- :upload-image="uploadImage"
217
- :labels="labels"
218
- @error="emit('error', $event)"
219
- />
220
- <div class="lw-composer__actions">
221
- <button type="button" class="lw-send" :disabled="sending" @click="send">
222
- <SendIcon /> {{ sending ? t.sending : t.send }}
223
- </button>
224
- </div>
225
- </div>
226
- </section>
227
- </template>
@@ -1,130 +0,0 @@
1
- <script setup lang="ts">
2
- /**
3
- * One report: what was filed, where it stands, what happened, and the thread.
4
- * The order is the original's.
5
- */
6
- import { computed } from "vue";
7
- import {
8
- formatDateTime,
9
- ticketKey,
10
- type MyTicketDetail,
11
- type RealmConfig,
12
- type StatusEvent,
13
- type StatusMaps,
14
- type TicketStrings,
15
- } from "@lightworkai.official/debug-capture";
16
- import TicketBody from "./TicketBody.vue";
17
- import StatusHero from "./StatusHero.vue";
18
- import ReopenPanel from "./ReopenPanel.vue";
19
- import TicketTimeline from "./TicketTimeline.vue";
20
- import TicketConversation from "./TicketConversation.vue";
21
- import TicketScreenshots from "./TicketScreenshots.vue";
22
- import { ArrowLeftIcon } from "./icons";
23
-
24
- const props = defineProps<{
25
- ticket: MyTicketDetail;
26
- history: StatusEvent[];
27
- config: RealmConfig;
28
- maps: StatusMaps;
29
- t: TicketStrings;
30
- host: string;
31
- locale: "th" | "en" | undefined;
32
- sending?: boolean;
33
- reopening?: boolean;
34
- uploadImage: (file: File) => Promise<string>;
35
- attachmentUrl: (id: string) => Promise<string>;
36
- }>();
37
-
38
- const emit = defineEmits<{
39
- (e: "back"): void;
40
- (e: "edited"): void;
41
- (e: "send", body: string): void;
42
- (e: "reopen", note: string): void;
43
- (e: "error", message: string): void;
44
- }>();
45
-
46
- const lang = computed<"th" | "en">(() => (props.locale === "en" ? "en" : "th"));
47
- const canReopen = computed(
48
- () => props.maps.isClosed(props.ticket.status) && props.config.featureFlags.selfServiceReopen === true,
49
- );
50
- const updated = computed(() => props.ticket.updatedAt && props.ticket.updatedAt !== props.ticket.createdAt);
51
-
52
- /*
53
- * Images only. The original's reporter view lists no attachments at all, and
54
- * what a widget report actually carries makes the reason plain: the other file
55
- * is `debug-bundle.json`, the capture payload the TEAM reads. The screenshot
56
- * they drew on is theirs; the bundle is not.
57
- */
58
- const screenshots = computed(() =>
59
- props.ticket.attachments.filter((a) => (a.contentType ?? "").startsWith("image/")),
60
- );
61
-
62
- function when(iso: string | null): string {
63
- return formatDateTime(iso, props.config.timezone, props.locale);
64
- }
65
- </script>
66
-
67
- <template>
68
- <div class="lw-detail">
69
- <button type="button" class="lw-back" @click="emit('back')">
70
- <ArrowLeftIcon /> {{ t.back }}
71
- </button>
72
-
73
- <section class="lw-card">
74
- <h3 class="lw-card__heading">{{ ticket.title }}</h3>
75
- <div class="lw-facts">
76
- <span class="lw-key">{{ ticketKey(config.realm.slug, ticket.number) }}</span>
77
- <span v-if="ticket.category" class="lw-chip">{{ ticket.category }}</span>
78
- <span>{{ t.colCreated }} {{ when(ticket.createdAt) }}</span>
79
- <span v-if="updated">{{ t.colUpdated }} {{ when(ticket.updatedAt) }}</span>
80
- </div>
81
- <TicketBody v-if="ticket.description" class="lw-intro" :html="ticket.description" :host="host" />
82
- <p v-if="ticket.routeUrl" class="lw-route">
83
- {{ t.page }}:
84
- <a :href="ticket.routeUrl" target="_blank" rel="noopener noreferrer">{{ ticket.routeUrl }}</a>
85
- </p>
86
- </section>
87
-
88
- <StatusHero :ticket="ticket" :maps="maps" :t="t" :locale="lang" :host="host" />
89
-
90
- <ReopenPanel
91
- v-if="canReopen"
92
- :t="t"
93
- :locale="lang"
94
- :busy="reopening"
95
- @reopen="emit('reopen', $event)"
96
- />
97
-
98
- <section class="lw-card">
99
- <h3 class="lw-card__title">{{ lang === "en" ? "Progress" : "ความคืบหน้า" }}</h3>
100
- <TicketTimeline
101
- :history="history"
102
- :status="ticket.status"
103
- :created-at="ticket.createdAt"
104
- :maps="maps"
105
- :timezone="config.timezone"
106
- :locale="lang"
107
- />
108
- </section>
109
-
110
- <TicketConversation
111
- :ticket="ticket"
112
- :t="t"
113
- :host="host"
114
- :timezone="config.timezone"
115
- :locale="locale"
116
- :sending="sending"
117
- :upload-image="uploadImage"
118
- @send="emit('send', $event)"
119
- @edited="emit('edited')"
120
- @error="emit('error', $event)"
121
- />
122
-
123
- <TicketScreenshots
124
- v-if="screenshots.length"
125
- :images="screenshots"
126
- :title="lang === 'en' ? 'Screenshots you sent' : 'ภาพหน้าจอที่คุณแจ้ง'"
127
- :attachment-url="attachmentUrl"
128
- />
129
- </div>
130
- </template>
@@ -1,47 +0,0 @@
1
- <script setup lang="ts">
2
- /**
3
- * The pictures the reporter attached — shown, not offered as downloads.
4
- *
5
- * The URL is FETCHED, not constructed. These are ordinary attachments: the
6
- * inline-image route answers only to the per-object tokens embedded images
7
- * carry, and the admin download route wants a session this widget has not got.
8
- */
9
- import { onMounted, ref } from "vue";
10
- import type { TicketAttachment } from "@lightworkai.official/debug-capture";
11
-
12
- const props = defineProps<{
13
- images: TicketAttachment[];
14
- title: string;
15
- attachmentUrl: (id: string) => Promise<string>;
16
- }>();
17
-
18
- /** id → signed URL. A missing entry means it is still loading or failed. */
19
- const urls = ref<Record<string, string>>({});
20
- const failed = ref<Record<string, true>>({});
21
-
22
- onMounted(() => {
23
- for (const image of props.images) {
24
- props.attachmentUrl(image.id).then(
25
- (url) => (urls.value = { ...urls.value, [image.id]: url }),
26
- // Drop the frame rather than leave a broken-image icon where a screenshot
27
- // should be.
28
- () => (failed.value = { ...failed.value, [image.id]: true }),
29
- );
30
- }
31
- });
32
- </script>
33
-
34
- <template>
35
- <section class="lw-card">
36
- <h3 class="lw-card__title">{{ title }}</h3>
37
- <template v-for="image in images" :key="image.id">
38
- <img
39
- v-if="urls[image.id] && !failed[image.id]"
40
- class="lw-shot"
41
- :src="urls[image.id]"
42
- :alt="image.filename ?? ''"
43
- @error="failed = { ...failed, [image.id]: true }"
44
- />
45
- </template>
46
- </section>
47
- </template>