@lightworkai.official/debug-capture-vue 0.6.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.
- package/README.md +52 -0
- package/dist/components/BugIcon.d.ts +3 -0
- package/dist/components/DebugCaptureInit.vue.d.ts +13 -0
- package/dist/components/ImageAnnotatorDialog.vue.d.ts +19 -0
- package/dist/components/MyTicketsPanel.vue.d.ts +17 -0
- package/dist/components/ReopenPanel.vue.d.ts +12 -0
- package/dist/components/ReportProblemButton.vue.d.ts +17 -0
- package/dist/components/RichReplyEditor.vue.d.ts +59 -0
- package/dist/components/StatusHero.vue.d.ts +10 -0
- package/dist/components/StatusPill.vue.d.ts +16 -0
- package/dist/components/TicketBody.vue.d.ts +6 -0
- package/dist/components/TicketConversation.vue.d.ts +22 -0
- package/dist/components/TicketDetail.vue.d.ts +28 -0
- package/dist/components/TicketScreenshots.vue.d.ts +8 -0
- package/dist/components/TicketTable.vue.d.ts +25 -0
- package/dist/components/TicketTimeline.vue.d.ts +11 -0
- package/dist/components/ToolbarButton.vue.d.ts +21 -0
- package/dist/components/icons.d.ts +38 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.mjs +2996 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +64 -0
- package/src/components/BugIcon.ts +23 -0
- package/src/components/DebugCaptureInit.vue +41 -0
- package/src/components/ImageAnnotatorDialog.vue +160 -0
- package/src/components/MyTicketsPanel.vue +312 -0
- package/src/components/README.md +12 -0
- package/src/components/ReopenPanel.vue +47 -0
- package/src/components/ReportProblemButton.vue +28 -0
- package/src/components/RichReplyEditor.vue +321 -0
- package/src/components/StatusHero.vue +85 -0
- package/src/components/StatusPill.vue +14 -0
- package/src/components/TicketBody.vue +50 -0
- package/src/components/TicketConversation.vue +227 -0
- package/src/components/TicketDetail.vue +130 -0
- package/src/components/TicketScreenshots.vue +47 -0
- package/src/components/TicketTable.vue +142 -0
- package/src/components/TicketTimeline.vue +86 -0
- package/src/components/ToolbarButton.vue +33 -0
- package/src/components/icons.ts +90 -0
- package/src/index.ts +34 -0
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lightworkai.official/debug-capture-vue",
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "Vue 3 bindings for @lightworkai.official/debug-capture",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"module": "./dist/index.mjs",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.mjs",
|
|
12
|
+
"default": "./dist/index.mjs"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"src",
|
|
18
|
+
"!src/**/*.test.ts",
|
|
19
|
+
"!src/test"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "rm -rf dist && vite build && vue-tsc -p tsconfig.build.json --emitDeclarationOnly --outDir dist",
|
|
23
|
+
"typecheck": "vue-tsc -p tsconfig.json --noEmit",
|
|
24
|
+
"test": "vitest run"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"vue": ">=3.3",
|
|
28
|
+
"@lightworkai.official/debug-capture": "^0.6.0",
|
|
29
|
+
"@tiptap/vue-3": "^3",
|
|
30
|
+
"@tiptap/starter-kit": "^3",
|
|
31
|
+
"@tiptap/extension-image": "^3",
|
|
32
|
+
"@tiptap/extension-placeholder": "^3"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@happy-dom/global-registrator": "^20.14.0",
|
|
36
|
+
"@tiptap/extension-image": "^3.31.0",
|
|
37
|
+
"@tiptap/extension-placeholder": "^3.31.0",
|
|
38
|
+
"@tiptap/starter-kit": "^3.31.0",
|
|
39
|
+
"@tiptap/vue-3": "^3.31.0",
|
|
40
|
+
"@vitejs/plugin-vue": "^5.1.0",
|
|
41
|
+
"@vue/test-utils": "^2.4.6",
|
|
42
|
+
"happy-dom": "^20.14.0",
|
|
43
|
+
"typescript": "^5.6.0",
|
|
44
|
+
"vite": "^6.0.0",
|
|
45
|
+
"vitest": "^2.1.0",
|
|
46
|
+
"vue": "^3.5.0",
|
|
47
|
+
"vue-tsc": "^2.1.0"
|
|
48
|
+
},
|
|
49
|
+
"main": "./dist/index.mjs",
|
|
50
|
+
"keywords": [
|
|
51
|
+
"vue",
|
|
52
|
+
"vue3",
|
|
53
|
+
"bug-report",
|
|
54
|
+
"debug",
|
|
55
|
+
"screenshot",
|
|
56
|
+
"support",
|
|
57
|
+
"helpdesk"
|
|
58
|
+
],
|
|
59
|
+
"license": "MIT",
|
|
60
|
+
"author": "Lightwork AI",
|
|
61
|
+
"publishConfig": {
|
|
62
|
+
"access": "public"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { h, type FunctionalComponent } from "vue";
|
|
2
|
+
|
|
3
|
+
/** The reporter's own glyph, drawn rather than depending on an icon set. */
|
|
4
|
+
export const BugIcon: FunctionalComponent = () =>
|
|
5
|
+
h(
|
|
6
|
+
"svg",
|
|
7
|
+
{
|
|
8
|
+
width: 20,
|
|
9
|
+
height: 20,
|
|
10
|
+
viewBox: "0 0 24 24",
|
|
11
|
+
fill: "none",
|
|
12
|
+
stroke: "currentColor",
|
|
13
|
+
"stroke-width": 1.8,
|
|
14
|
+
"stroke-linecap": "round",
|
|
15
|
+
"stroke-linejoin": "round",
|
|
16
|
+
"aria-hidden": "true",
|
|
17
|
+
},
|
|
18
|
+
[
|
|
19
|
+
h("path", { d: "M8 2l1.9 1.9M16 2l-1.9 1.9" }),
|
|
20
|
+
h("rect", { x: 8, y: 6, width: 8, height: 14, rx: 4 }),
|
|
21
|
+
h("path", { d: "M3 13h5M16 13h5M4 8l3 2M20 8l-3 2M4 18l3-2M20 18l-3-2" }),
|
|
22
|
+
],
|
|
23
|
+
);
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Arms the capture buffers and mounts the reporter. Renders nothing.
|
|
4
|
+
*
|
|
5
|
+
* Mount it once, in App.vue above the router-view — the buffers must be
|
|
6
|
+
* recording BEFORE a user hits a problem, or the report arrives with no trail
|
|
7
|
+
* of how they got there.
|
|
8
|
+
*/
|
|
9
|
+
import { onMounted } from "vue";
|
|
10
|
+
import {
|
|
11
|
+
configureDebugCapture,
|
|
12
|
+
installDebugCapture,
|
|
13
|
+
isConfigured,
|
|
14
|
+
setErroredQueriesSource,
|
|
15
|
+
type DebugCaptureConfig,
|
|
16
|
+
type ErroredQuery,
|
|
17
|
+
} from "@lightworkai.official/debug-capture";
|
|
18
|
+
|
|
19
|
+
const props = withDefaults(
|
|
20
|
+
defineProps<{
|
|
21
|
+
/** Mount the built-in reporter UI. Off if the host renders its own. */
|
|
22
|
+
ui?: boolean;
|
|
23
|
+
/** Failed queries from the app's data layer, if it has one. */
|
|
24
|
+
errorQueries?: () => ErroredQuery[];
|
|
25
|
+
/** The config, if you did not call configureDebugCapture at module scope. */
|
|
26
|
+
config?: DebugCaptureConfig;
|
|
27
|
+
}>(),
|
|
28
|
+
{ ui: true },
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
// In setup, not onMounted: a mounted hook runs after the first paint, and
|
|
32
|
+
// anything the app fetched or logged before then would be missed.
|
|
33
|
+
if (props.config && !isConfigured()) configureDebugCapture(props.config);
|
|
34
|
+
|
|
35
|
+
onMounted(() => {
|
|
36
|
+
if (props.errorQueries) setErroredQueriesSource(props.errorQueries);
|
|
37
|
+
installDebugCapture({ ui: props.ui });
|
|
38
|
+
});
|
|
39
|
+
</script>
|
|
40
|
+
|
|
41
|
+
<template><span style="display: none" /></template>
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Draw on an image before it is sent.
|
|
4
|
+
*
|
|
5
|
+
* The original opens this the moment you pick a picture, and the reason is in
|
|
6
|
+
* its own comment: annotating BEFORE upload keeps the canvas same-origin, so
|
|
7
|
+
* `toDataURL` is not tainted by a cross-origin presigned URL. Pointing at the
|
|
8
|
+
* problem is also most of what a screenshot is for.
|
|
9
|
+
*
|
|
10
|
+
* The drawing itself is `createAnnotator` from the core — one canvas engine,
|
|
11
|
+
* shared by all three framework packages, because there is no declarative way
|
|
12
|
+
* to draw on a canvas. What is here is only the chrome: a dialog, a toolbar and
|
|
13
|
+
* two buttons.
|
|
14
|
+
*/
|
|
15
|
+
import { onBeforeUnmount, ref, shallowRef, watch } from "vue";
|
|
16
|
+
import {
|
|
17
|
+
createAnnotator,
|
|
18
|
+
getConfig,
|
|
19
|
+
reporterStrings,
|
|
20
|
+
type Annotator,
|
|
21
|
+
type AnnotatorTool,
|
|
22
|
+
type ReporterStrings,
|
|
23
|
+
} from "@lightworkai.official/debug-capture";
|
|
24
|
+
import { ArrowUpRightIcon, HighlighterIcon, SquareIcon, TrashIcon, TypeIcon } from "./icons";
|
|
25
|
+
|
|
26
|
+
const props = defineProps<{
|
|
27
|
+
/**
|
|
28
|
+
* The picked image, as a same-origin data URI. Null closes the dialog, so a
|
|
29
|
+
* caller can keep it mounted and drive it from one nullable ref.
|
|
30
|
+
*/
|
|
31
|
+
src: string | null;
|
|
32
|
+
/** Shown in the description, as the app's dialog does. */
|
|
33
|
+
filename?: string;
|
|
34
|
+
/** Falls back to the configured locale — a caller inside the app need not pass it. */
|
|
35
|
+
locale?: "th" | "en";
|
|
36
|
+
}>();
|
|
37
|
+
|
|
38
|
+
const emit = defineEmits<{
|
|
39
|
+
/** The annotated image, at full resolution. */
|
|
40
|
+
(e: "confirm", dataUri: string): void;
|
|
41
|
+
(e: "cancel"): void;
|
|
42
|
+
}>();
|
|
43
|
+
|
|
44
|
+
const t = reporterStrings(props.locale ?? safeLocale());
|
|
45
|
+
|
|
46
|
+
/** `getConfig()` throws when nothing is configured; the dialog still has words. */
|
|
47
|
+
function safeLocale(): "th" | "en" | undefined {
|
|
48
|
+
try {
|
|
49
|
+
return getConfig().locale;
|
|
50
|
+
} catch {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const TOOLS: { tool: AnnotatorTool; key: keyof ReporterStrings; icon: unknown }[] = [
|
|
55
|
+
{ tool: "rect", key: "toolRect", icon: SquareIcon },
|
|
56
|
+
{ tool: "arrow", key: "toolArrow", icon: ArrowUpRightIcon },
|
|
57
|
+
{ tool: "highlight", key: "toolHighlight", icon: HighlighterIcon },
|
|
58
|
+
{ tool: "text", key: "toolText", icon: TypeIcon },
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
const canvas = ref<HTMLCanvasElement | null>(null);
|
|
62
|
+
// shallowRef: the annotator holds a canvas and its own mutable state, and Vue
|
|
63
|
+
// has no business making any of that reactive.
|
|
64
|
+
const annotator = shallowRef<Annotator | null>(null);
|
|
65
|
+
const active = ref<AnnotatorTool | null>(null);
|
|
66
|
+
const loading = ref(true);
|
|
67
|
+
|
|
68
|
+
watch(
|
|
69
|
+
() => [props.src, canvas.value] as const,
|
|
70
|
+
([src, node]) => {
|
|
71
|
+
if (!src || !node) return;
|
|
72
|
+
annotator.value?.destroy();
|
|
73
|
+
const image = new Image();
|
|
74
|
+
image.addEventListener("load", () => {
|
|
75
|
+
// A backing store at the image's own size, BEFORE the annotator draws
|
|
76
|
+
// into it. Without this the canvas keeps its default 300x150, the image
|
|
77
|
+
// is squashed into that, and CSS then stretches the result — blurry, and
|
|
78
|
+
// the wrong shape.
|
|
79
|
+
node.width = image.naturalWidth;
|
|
80
|
+
node.height = image.naturalHeight;
|
|
81
|
+
annotator.value = createAnnotator(node, image, () => {
|
|
82
|
+
// The toolbar mirrors the engine, so it has to hear about a tool being
|
|
83
|
+
// CONSUMED as well as a shape being added.
|
|
84
|
+
active.value = null;
|
|
85
|
+
});
|
|
86
|
+
loading.value = false;
|
|
87
|
+
});
|
|
88
|
+
image.src = src;
|
|
89
|
+
},
|
|
90
|
+
{ immediate: true },
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
onBeforeUnmount(() => annotator.value?.destroy());
|
|
94
|
+
|
|
95
|
+
function pick(tool: AnnotatorTool): void {
|
|
96
|
+
active.value = active.value === tool ? null : tool;
|
|
97
|
+
annotator.value?.setTool(active.value);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function clearAll(): void {
|
|
101
|
+
annotator.value?.clear();
|
|
102
|
+
active.value = null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function confirm(): void {
|
|
106
|
+
// Full resolution, never the on-screen scale.
|
|
107
|
+
emit("confirm", annotator.value?.toDataUrl() ?? props.src ?? "");
|
|
108
|
+
}
|
|
109
|
+
</script>
|
|
110
|
+
|
|
111
|
+
<template>
|
|
112
|
+
<div v-if="src" class="lw-annotator" role="dialog" aria-modal="true" @click.self="emit('cancel')">
|
|
113
|
+
<div class="lw-annotator__panel">
|
|
114
|
+
<!-- Header, toolbar, canvas, help, footer — the same shape and the same
|
|
115
|
+
words as the app's own dialog, so the two are one thing in two
|
|
116
|
+
places rather than two things that resemble each other. -->
|
|
117
|
+
<div class="lw-annotator__head">
|
|
118
|
+
<div class="lw-grow">
|
|
119
|
+
<h2 class="lw-annotator__title">{{ t.annotateTitle }}</h2>
|
|
120
|
+
<p class="lw-annotator__desc">
|
|
121
|
+
{{ t.annotateHint }}<template v-if="filename"> · {{ filename }}</template>
|
|
122
|
+
</p>
|
|
123
|
+
</div>
|
|
124
|
+
<button type="button" class="lw-annotator__close" :aria-label="t.cancel" @click="emit('cancel')">✕</button>
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
<div class="lw-annotator__tools">
|
|
128
|
+
<button
|
|
129
|
+
v-for="tool in TOOLS"
|
|
130
|
+
:key="tool.tool"
|
|
131
|
+
type="button"
|
|
132
|
+
class="lw-atool"
|
|
133
|
+
:aria-pressed="active === tool.tool"
|
|
134
|
+
:disabled="loading"
|
|
135
|
+
@click="pick(tool.tool)"
|
|
136
|
+
>
|
|
137
|
+
<component :is="tool.icon" /> {{ t[tool.key] }}
|
|
138
|
+
</button>
|
|
139
|
+
<span class="lw-grow" />
|
|
140
|
+
<!-- Right-aligned and on its own, like the app's. Deleting ONE shape is
|
|
141
|
+
the ✕ on the shape, which is what the help line below explains. -->
|
|
142
|
+
<button type="button" class="lw-atool lw-atool--plain" :disabled="loading" @click="clearAll">
|
|
143
|
+
<TrashIcon /> {{ t.clearAll }}
|
|
144
|
+
</button>
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
<div class="lw-annotator__canvas">
|
|
148
|
+
<div class="lw-annotator__canvasbox"><canvas ref="canvas" /></div>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
<p class="lw-annotator__hint">{{ t.annotateHelp }}</p>
|
|
152
|
+
|
|
153
|
+
<div class="lw-annotator__actions">
|
|
154
|
+
<span class="lw-grow" />
|
|
155
|
+
<button type="button" class="lw-btn" @click="emit('cancel')">{{ t.cancel }}</button>
|
|
156
|
+
<button type="button" class="lw-send" :disabled="loading" @click="confirm">{{ t.annotateApply }}</button>
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
</template>
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* "ปัญหาที่แจ้ง" — the reports this person filed, and what became of them.
|
|
4
|
+
*
|
|
5
|
+
* Drop it on a route of your own — the reporter-facing counterpart to the
|
|
6
|
+
* agent's ticket list. It needs `identity` in the config, because the widget's
|
|
7
|
+
* realm key is public and can authorise filing a report but never reading one
|
|
8
|
+
* back — see the core README.
|
|
9
|
+
*
|
|
10
|
+
* This component owns fetching and view state and nothing else: the columns,
|
|
11
|
+
* the sorting, the sanitising and the dates all live in the core, shared with
|
|
12
|
+
* the React and Angular packages.
|
|
13
|
+
*/
|
|
14
|
+
import { computed, onMounted, ref, watch } from "vue";
|
|
15
|
+
import {
|
|
16
|
+
NoIdentityError,
|
|
17
|
+
fetchRealmConfig,
|
|
18
|
+
getAttachmentUrl,
|
|
19
|
+
getConfig,
|
|
20
|
+
getMyTicket,
|
|
21
|
+
getMyTicketHistory,
|
|
22
|
+
listMyTickets,
|
|
23
|
+
reopenMyTicket,
|
|
24
|
+
replyToTicket,
|
|
25
|
+
statusMaps,
|
|
26
|
+
ticketStrings,
|
|
27
|
+
uploadInlineImage,
|
|
28
|
+
type MyTicketDetail,
|
|
29
|
+
type MyTicketListItem,
|
|
30
|
+
type RealmConfig,
|
|
31
|
+
type SortKey,
|
|
32
|
+
type StatusEvent,
|
|
33
|
+
} from "@lightworkai.official/debug-capture";
|
|
34
|
+
import TicketTable from "./TicketTable.vue";
|
|
35
|
+
import TicketDetail from "./TicketDetail.vue";
|
|
36
|
+
import { AlertIcon, SearchIcon } from "./icons";
|
|
37
|
+
|
|
38
|
+
const props = defineProps<{
|
|
39
|
+
/** Change it to re-read the list — after filing a report, say. */
|
|
40
|
+
refreshKey?: string | number;
|
|
41
|
+
/** Hide the panel's own heading when the host page already has one. */
|
|
42
|
+
hideTitle?: boolean;
|
|
43
|
+
}>();
|
|
44
|
+
|
|
45
|
+
const emit = defineEmits<{
|
|
46
|
+
(e: "replied", ticket: MyTicketDetail): void;
|
|
47
|
+
(e: "reopened", ticket: MyTicketDetail): void;
|
|
48
|
+
(e: "error", message: string): void;
|
|
49
|
+
}>();
|
|
50
|
+
|
|
51
|
+
const locale = computed(() => {
|
|
52
|
+
try {
|
|
53
|
+
return getConfig().locale;
|
|
54
|
+
} catch {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
const host = computed(() => {
|
|
59
|
+
try {
|
|
60
|
+
return getConfig().host;
|
|
61
|
+
} catch {
|
|
62
|
+
return "";
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
const t = computed(() => ticketStrings(locale.value));
|
|
66
|
+
|
|
67
|
+
const items = ref<MyTicketListItem[]>([]);
|
|
68
|
+
const config = ref<RealmConfig | null>(null);
|
|
69
|
+
const loading = ref(true);
|
|
70
|
+
const error = ref<string | null>(null);
|
|
71
|
+
const noIdentity = ref(false);
|
|
72
|
+
|
|
73
|
+
const keyword = ref("");
|
|
74
|
+
const applied = ref("");
|
|
75
|
+
const statusFilter = ref("");
|
|
76
|
+
const sort = ref<{ key: SortKey; direction: "asc" | "desc" }>({ key: "createdAt", direction: "desc" });
|
|
77
|
+
const page = ref(1);
|
|
78
|
+
|
|
79
|
+
const detail = ref<MyTicketDetail | null>(null);
|
|
80
|
+
const history = ref<StatusEvent[]>([]);
|
|
81
|
+
const detailError = ref<string | null>(null);
|
|
82
|
+
const detailLoading = ref(false);
|
|
83
|
+
const sending = ref(false);
|
|
84
|
+
const reopening = ref(false);
|
|
85
|
+
|
|
86
|
+
const maps = computed(() => statusMaps(config.value?.statusColumns ?? [], t.value.allStatuses));
|
|
87
|
+
const table = ref<InstanceType<typeof TicketTable> | null>(null);
|
|
88
|
+
const shownCount = computed(() => table.value?.count ?? 0);
|
|
89
|
+
|
|
90
|
+
// Debounced like the original: re-filtering per keystroke resets the page under
|
|
91
|
+
// the reader's hands.
|
|
92
|
+
let debounce: ReturnType<typeof setTimeout> | null = null;
|
|
93
|
+
watch(keyword, (next) => {
|
|
94
|
+
if (debounce) clearTimeout(debounce);
|
|
95
|
+
debounce = setTimeout(() => {
|
|
96
|
+
applied.value = next;
|
|
97
|
+
page.value = 1;
|
|
98
|
+
}, 300);
|
|
99
|
+
});
|
|
100
|
+
watch(statusFilter, () => (page.value = 1));
|
|
101
|
+
watch(() => props.refreshKey, () => void load());
|
|
102
|
+
|
|
103
|
+
onMounted(load);
|
|
104
|
+
|
|
105
|
+
async function load(): Promise<void> {
|
|
106
|
+
loading.value = true;
|
|
107
|
+
error.value = null;
|
|
108
|
+
noIdentity.value = false;
|
|
109
|
+
try {
|
|
110
|
+
// Config and list together: the labels are useless without the rows and the
|
|
111
|
+
// rows unreadable without the labels.
|
|
112
|
+
const [realm, list] = await Promise.all([fetchRealmConfig(), listMyTickets()]);
|
|
113
|
+
config.value = realm;
|
|
114
|
+
items.value = list;
|
|
115
|
+
} catch (e) {
|
|
116
|
+
if (e instanceof NoIdentityError) noIdentity.value = true;
|
|
117
|
+
else error.value = e instanceof Error ? e.message : t.value.listFailed;
|
|
118
|
+
} finally {
|
|
119
|
+
loading.value = false;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Open a DIFFERENT ticket: tear the view down, because nothing on screen is its. */
|
|
124
|
+
async function open(id: string): Promise<void> {
|
|
125
|
+
detail.value = null;
|
|
126
|
+
history.value = [];
|
|
127
|
+
detailError.value = null;
|
|
128
|
+
detailLoading.value = true;
|
|
129
|
+
try {
|
|
130
|
+
await reload(id);
|
|
131
|
+
} finally {
|
|
132
|
+
detailLoading.value = false;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Re-read the ticket we are already looking at, in place.
|
|
138
|
+
*
|
|
139
|
+
* Deliberately NOT `open()`. That nulls the detail and raises the loading flag,
|
|
140
|
+
* which unmounts the whole view and remounts it — so sending a reply threw the
|
|
141
|
+
* reader back to the top of the page, away from the conversation they were in
|
|
142
|
+
* the middle of. It reads as the page reloading, because visually that is what
|
|
143
|
+
* happens.
|
|
144
|
+
*
|
|
145
|
+
* Updating the refs instead lets Vue patch what changed: the new message
|
|
146
|
+
* appears, the timeline updates, and everything else stays exactly where it was.
|
|
147
|
+
*/
|
|
148
|
+
async function reload(id: string): Promise<void> {
|
|
149
|
+
try {
|
|
150
|
+
const [found, events] = await Promise.all([
|
|
151
|
+
getMyTicket(id),
|
|
152
|
+
// The timeline is a nicety, not the page: a ticket whose history fails to
|
|
153
|
+
// load should still show its conversation.
|
|
154
|
+
getMyTicketHistory(id).catch(() => [] as StatusEvent[]),
|
|
155
|
+
]);
|
|
156
|
+
detail.value = found;
|
|
157
|
+
history.value = events;
|
|
158
|
+
} catch (e) {
|
|
159
|
+
detailError.value = e instanceof Error ? e.message : t.value.detailFailed;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function back(): void {
|
|
164
|
+
detail.value = null;
|
|
165
|
+
// Counts and badges move when a reply lands, so the list is re-read rather
|
|
166
|
+
// than restored from what it said before the detail opened.
|
|
167
|
+
void load();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function toggleSort(key: SortKey): void {
|
|
171
|
+
sort.value =
|
|
172
|
+
sort.value.key === key
|
|
173
|
+
? { key, direction: sort.value.direction === "asc" ? "desc" : "asc" }
|
|
174
|
+
: // Dates open newest-first, text A→Z: the useful first click differs by
|
|
175
|
+
// what the column holds.
|
|
176
|
+
{ key, direction: key === "createdAt" || key === "updatedAt" ? "desc" : "asc" };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function send(body: string): Promise<void> {
|
|
180
|
+
const ticket = detail.value;
|
|
181
|
+
if (!ticket || sending.value) return;
|
|
182
|
+
sending.value = true;
|
|
183
|
+
try {
|
|
184
|
+
await replyToTicket(ticket.id, body);
|
|
185
|
+
await reload(ticket.id);
|
|
186
|
+
if (detail.value) emit("replied", detail.value);
|
|
187
|
+
} catch (e) {
|
|
188
|
+
emit("error", e instanceof Error ? e.message : t.value.sendFailed);
|
|
189
|
+
} finally {
|
|
190
|
+
sending.value = false;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function reopen(note: string): Promise<void> {
|
|
195
|
+
const ticket = detail.value;
|
|
196
|
+
if (!ticket || reopening.value) return;
|
|
197
|
+
reopening.value = true;
|
|
198
|
+
try {
|
|
199
|
+
await reopenMyTicket(ticket.id, note);
|
|
200
|
+
await reload(ticket.id);
|
|
201
|
+
if (detail.value) emit("reopened", detail.value);
|
|
202
|
+
} catch (e) {
|
|
203
|
+
emit("error", e instanceof Error ? e.message : t.value.reopenFailed);
|
|
204
|
+
} finally {
|
|
205
|
+
reopening.value = false;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function uploadImage(file: File): Promise<string> {
|
|
210
|
+
const ticket = detail.value;
|
|
211
|
+
if (!ticket) return Promise.reject(new Error("no ticket"));
|
|
212
|
+
return readDataUri(file).then((dataUri) =>
|
|
213
|
+
uploadInlineImage(ticket.id, dataUri, file.name).then((stored) => stored.url),
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function attachmentUrl(id: string): Promise<string> {
|
|
218
|
+
return getAttachmentUrl(id).then((found) => found.url);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function readDataUri(file: File): Promise<string> {
|
|
222
|
+
return new Promise((resolve, reject) => {
|
|
223
|
+
const reader = new FileReader();
|
|
224
|
+
reader.addEventListener("load", () =>
|
|
225
|
+
typeof reader.result === "string" ? resolve(reader.result) : reject(new Error("unreadable file")),
|
|
226
|
+
);
|
|
227
|
+
reader.addEventListener("error", () => reject(new Error("unreadable file")));
|
|
228
|
+
reader.readAsDataURL(file);
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
</script>
|
|
232
|
+
|
|
233
|
+
<template>
|
|
234
|
+
<div class="lw-panel">
|
|
235
|
+
<template v-if="!detail && !detailLoading">
|
|
236
|
+
<h2 v-if="!hideTitle" class="lw-panel__title">{{ t.title }}</h2>
|
|
237
|
+
<p class="lw-panel__intro">{{ t.intro }}</p>
|
|
238
|
+
|
|
239
|
+
<div class="lw-controls">
|
|
240
|
+
<div class="lw-search">
|
|
241
|
+
<SearchIcon class="lw-search__icon" />
|
|
242
|
+
<input
|
|
243
|
+
v-model="keyword"
|
|
244
|
+
type="search"
|
|
245
|
+
class="lw-input lw-search__field"
|
|
246
|
+
:placeholder="t.searchPlaceholder"
|
|
247
|
+
:aria-label="t.searchLabel"
|
|
248
|
+
/>
|
|
249
|
+
</div>
|
|
250
|
+
<select v-model="statusFilter" class="lw-input">
|
|
251
|
+
<option v-for="option in maps.options" :key="option.value" :value="option.value">
|
|
252
|
+
{{ option.label }}
|
|
253
|
+
</option>
|
|
254
|
+
</select>
|
|
255
|
+
<span class="lw-count">{{ t.count(shownCount) }}</span>
|
|
256
|
+
</div>
|
|
257
|
+
|
|
258
|
+
<p v-if="noIdentity" class="lw-state">{{ t.signedOut }}</p>
|
|
259
|
+
<div v-else-if="loading" class="lw-skeletons">
|
|
260
|
+
<div v-for="n in 4" :key="n" class="lw-skeleton" />
|
|
261
|
+
</div>
|
|
262
|
+
<p v-else-if="error" class="lw-state lw-state--error">
|
|
263
|
+
<AlertIcon /> {{ error }}
|
|
264
|
+
<button type="button" class="lw-retry" @click="load">{{ t.retry }}</button>
|
|
265
|
+
</p>
|
|
266
|
+
<TicketTable
|
|
267
|
+
v-else
|
|
268
|
+
ref="table"
|
|
269
|
+
:items="items"
|
|
270
|
+
:maps="maps"
|
|
271
|
+
:t="t"
|
|
272
|
+
:slug="config?.realm.slug"
|
|
273
|
+
:timezone="config?.timezone ?? 'Asia/Bangkok'"
|
|
274
|
+
:locale="locale"
|
|
275
|
+
:keyword="applied"
|
|
276
|
+
:status-filter="statusFilter"
|
|
277
|
+
:sort="sort"
|
|
278
|
+
:page="page"
|
|
279
|
+
@open="open"
|
|
280
|
+
@sort="toggleSort"
|
|
281
|
+
@page="page = $event"
|
|
282
|
+
/>
|
|
283
|
+
</template>
|
|
284
|
+
|
|
285
|
+
<div v-else-if="detailLoading" class="lw-skeletons">
|
|
286
|
+
<div class="lw-skeleton" />
|
|
287
|
+
<div class="lw-skeleton" />
|
|
288
|
+
</div>
|
|
289
|
+
|
|
290
|
+
<p v-else-if="detailError" class="lw-state lw-state--error">{{ detailError }}</p>
|
|
291
|
+
|
|
292
|
+
<TicketDetail
|
|
293
|
+
v-else-if="detail && config"
|
|
294
|
+
:ticket="detail"
|
|
295
|
+
:history="history"
|
|
296
|
+
:config="config"
|
|
297
|
+
:maps="maps"
|
|
298
|
+
:t="t"
|
|
299
|
+
:host="host"
|
|
300
|
+
:locale="locale"
|
|
301
|
+
:sending="sending"
|
|
302
|
+
:reopening="reopening"
|
|
303
|
+
:upload-image="uploadImage"
|
|
304
|
+
:attachment-url="attachmentUrl"
|
|
305
|
+
@back="back"
|
|
306
|
+
@edited="detail && reload(detail.id)"
|
|
307
|
+
@send="send"
|
|
308
|
+
@reopen="reopen"
|
|
309
|
+
@error="emit('error', $event)"
|
|
310
|
+
/>
|
|
311
|
+
</div>
|
|
312
|
+
</template>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Vue components
|
|
2
|
+
|
|
3
|
+
Real single-file components. The logic they arrange — the ingest client, the
|
|
4
|
+
sanitiser, the status maps, the comparators, the dates — lives in
|
|
5
|
+
`@lightworkai.official/debug-capture` and is shared with the React and Angular packages.
|
|
6
|
+
|
|
7
|
+
Styles live there too, in one stylesheet, rather than as `<style scoped>` blocks
|
|
8
|
+
here. Scoped styles read better in an SFC, but the same rules would then exist
|
|
9
|
+
three times across three framework packages, and "change a colour in three files
|
|
10
|
+
and hope you found them all" is the maintenance bill this structure exists to
|
|
11
|
+
avoid. Class names are `lw-` prefixed and every colour is a custom property, so a
|
|
12
|
+
host restyles by setting variables rather than by fighting specificity.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* "Still a problem?" — a SEPARATE action from the chat, with its own endpoint
|
|
4
|
+
* and its own confirmation, only when the ticket is done and the realm allows it.
|
|
5
|
+
*/
|
|
6
|
+
import { ref } from "vue";
|
|
7
|
+
import type { TicketStrings } from "@lightworkai.official/debug-capture";
|
|
8
|
+
import { RotateIcon } from "./icons";
|
|
9
|
+
|
|
10
|
+
defineProps<{ t: TicketStrings; locale: "th" | "en"; busy?: boolean }>();
|
|
11
|
+
const emit = defineEmits<{ (e: "reopen", note: string): void }>();
|
|
12
|
+
|
|
13
|
+
const open = ref(false);
|
|
14
|
+
const note = ref("");
|
|
15
|
+
|
|
16
|
+
function confirm(): void {
|
|
17
|
+
emit("reopen", note.value.trim());
|
|
18
|
+
}
|
|
19
|
+
</script>
|
|
20
|
+
|
|
21
|
+
<template>
|
|
22
|
+
<section class="lw-reopen">
|
|
23
|
+
<div v-if="!open" class="lw-reopen__row">
|
|
24
|
+
<span class="lw-reopen__hint">{{ locale === "en" ? "Problem not fixed?" : "ปัญหายังไม่หาย?" }}</span>
|
|
25
|
+
<button type="button" class="lw-reopen__open" @click="open = true">
|
|
26
|
+
<RotateIcon /> {{ t.reopen }}
|
|
27
|
+
</button>
|
|
28
|
+
</div>
|
|
29
|
+
<div v-else>
|
|
30
|
+
<p class="lw-reopen__title">{{ t.reopen }}</p>
|
|
31
|
+
<textarea
|
|
32
|
+
v-model="note"
|
|
33
|
+
rows="2"
|
|
34
|
+
class="lw-reopen__note"
|
|
35
|
+
:placeholder="locale === 'en' ? 'Tell us what is still happening (optional)…' : 'บอกเราหน่อยว่ายังพบปัญหาอะไร (ไม่ระบุก็ได้)…'"
|
|
36
|
+
/>
|
|
37
|
+
<div class="lw-reopen__actions">
|
|
38
|
+
<button type="button" class="lw-reopen__cancel" @click="open = false; note = ''">
|
|
39
|
+
{{ locale === "en" ? "Cancel" : "ยกเลิก" }}
|
|
40
|
+
</button>
|
|
41
|
+
<button type="button" class="lw-reopen__confirm" :disabled="busy" @click="confirm">
|
|
42
|
+
<RotateIcon /> {{ busy ? t.reopening : t.reopen }}
|
|
43
|
+
</button>
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
</section>
|
|
47
|
+
</template>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* The button that opens the reporter.
|
|
4
|
+
*
|
|
5
|
+
* `launch` runs straight out of the click with nothing awaited in front of it:
|
|
6
|
+
* screen capture is gated on a user gesture, and one `await` between the click
|
|
7
|
+
* and `getDisplayMedia` loses it — the browser then refuses with an error that
|
|
8
|
+
* explains nothing.
|
|
9
|
+
*
|
|
10
|
+
* Unstyled by default. It lands in the host's own header, and a widget with
|
|
11
|
+
* opinions about its border radius is a widget somebody has to fight.
|
|
12
|
+
*/
|
|
13
|
+
import { launch } from "@lightworkai.official/debug-capture";
|
|
14
|
+
import { BugIcon } from "./BugIcon";
|
|
15
|
+
|
|
16
|
+
withDefaults(defineProps<{ label?: string }>(), { label: "รายงานปัญหา" });
|
|
17
|
+
|
|
18
|
+
function report(event: MouseEvent): void {
|
|
19
|
+
if (event.defaultPrevented) return;
|
|
20
|
+
launch({ type: "manual" });
|
|
21
|
+
}
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<template>
|
|
25
|
+
<button type="button" :aria-label="label" :title="label" @click="report">
|
|
26
|
+
<slot><BugIcon /></slot>
|
|
27
|
+
</button>
|
|
28
|
+
</template>
|