@lightworkai.official/debug-capture-angular 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 +67 -0
- package/dist/components/icon.component.d.ts +15 -0
- package/dist/components/icons.d.ts +29 -0
- package/dist/components/image-annotator-dialog.component.d.ts +70 -0
- package/dist/components/my-tickets-panel.component.d.ts +100 -0
- package/dist/components/rich-reply-editor.component.d.ts +89 -0
- package/dist/components/ticket-body.component.d.ts +25 -0
- package/dist/components/ticket-detail.component.d.ts +125 -0
- package/dist/components/ticket-table.component.d.ts +75 -0
- package/dist/index.d.ts +59 -0
- package/dist/index.mjs +1592 -0
- package/package.json +56 -0
- package/src/components/icon.component.ts +24 -0
- package/src/components/icons.ts +64 -0
- package/src/components/image-annotator-dialog.component.ts +159 -0
- package/src/components/my-tickets-panel.component.ts +326 -0
- package/src/components/rich-reply-editor.component.ts +287 -0
- package/src/components/ticket-body.component.ts +53 -0
- package/src/components/ticket-detail.component.ts +441 -0
- package/src/components/ticket-table.component.ts +183 -0
- package/src/index.ts +119 -0
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lightworkai.official/debug-capture-angular",
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "Angular 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 && bun build src/index.ts --outfile dist/index.mjs --format esm --external @angular/core --external @angular/common --external @angular/forms --external @angular/platform-browser --external @tiptap/core --external @tiptap/starter-kit --external @tiptap/extension-image --external @tiptap/extension-placeholder --external @lightworkai.official/debug-capture && tsc -p tsconfig.build.json --emitDeclarationOnly --outDir dist",
|
|
23
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"@angular/core": ">=16",
|
|
27
|
+
"@angular/common": ">=16",
|
|
28
|
+
"@angular/forms": ">=16",
|
|
29
|
+
"@angular/platform-browser": ">=16",
|
|
30
|
+
"@lightworkai.official/debug-capture": "^0.6.0",
|
|
31
|
+
"@tiptap/core": "^3",
|
|
32
|
+
"@tiptap/starter-kit": "^3",
|
|
33
|
+
"@tiptap/extension-image": "^3",
|
|
34
|
+
"@tiptap/extension-placeholder": "^3"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"typescript": "^5.6.0",
|
|
38
|
+
"@angular/core": "^19.0.0",
|
|
39
|
+
"rxjs": "^7.8.0",
|
|
40
|
+
"zone.js": "^0.15.0"
|
|
41
|
+
},
|
|
42
|
+
"main": "./dist/index.mjs",
|
|
43
|
+
"keywords": [
|
|
44
|
+
"angular",
|
|
45
|
+
"bug-report",
|
|
46
|
+
"debug",
|
|
47
|
+
"screenshot",
|
|
48
|
+
"support",
|
|
49
|
+
"helpdesk"
|
|
50
|
+
],
|
|
51
|
+
"license": "MIT",
|
|
52
|
+
"author": "Lightwork AI",
|
|
53
|
+
"publishConfig": {
|
|
54
|
+
"access": "public"
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Component, Input } from "@angular/core";
|
|
2
|
+
import { DomSanitizer, type SafeHtml } from "@angular/platform-browser";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Renders one of our own SVG constants.
|
|
6
|
+
*
|
|
7
|
+
* `bypassSecurityTrustHtml` is safe HERE and only here: the markup comes from
|
|
8
|
+
* `ICONS`, a frozen table in this package's own source. Nothing a user or a
|
|
9
|
+
* server ever wrote reaches it — the thread's HTML goes through the core's
|
|
10
|
+
* allowlist walker instead.
|
|
11
|
+
*/
|
|
12
|
+
@Component({
|
|
13
|
+
selector: "lw-icon",
|
|
14
|
+
standalone: true,
|
|
15
|
+
template: `<span [innerHTML]="safe"></span>`,
|
|
16
|
+
styles: [":host { display: inline-flex; align-items: center; }"],
|
|
17
|
+
})
|
|
18
|
+
export class IconComponent {
|
|
19
|
+
@Input({ required: true }) set svg(value: string) {
|
|
20
|
+
this.safe = this.sanitizer.bypassSecurityTrustHtml(value);
|
|
21
|
+
}
|
|
22
|
+
protected safe: SafeHtml = "";
|
|
23
|
+
constructor(private readonly sanitizer: DomSanitizer) {}
|
|
24
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The panel's icons as inline SVG markup strings.
|
|
3
|
+
*
|
|
4
|
+
* Drawn rather than imported: the original uses lucide, and a package that
|
|
5
|
+
* pulled an icon set in so a reply box could show a B would cost every consumer
|
|
6
|
+
* that dependency. Rendered with `[innerHTML]` through Angular's sanitiser,
|
|
7
|
+
* which permits SVG and strips anything else — these are constants in our own
|
|
8
|
+
* source, so there is nothing untrusted here to begin with.
|
|
9
|
+
*/
|
|
10
|
+
function glyph(paths: string[], size = 15): string {
|
|
11
|
+
const body = paths.map((d) => `<path d="${d}"/>`).join("");
|
|
12
|
+
return (
|
|
13
|
+
`<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" ` +
|
|
14
|
+
`stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const ICONS = {
|
|
19
|
+
bold: glyph(["M6 4h8a4 4 0 0 1 0 8H6z", "M6 12h9a4 4 0 0 1 0 8H6z"]),
|
|
20
|
+
italic: glyph(["M19 4h-9", "M14 20H5", "M15 4L9 20"]),
|
|
21
|
+
underline: glyph(["M6 4v6a6 6 0 0 0 12 0V4", "M4 20h16"]),
|
|
22
|
+
bulletList: glyph(["M8 6h13", "M8 12h13", "M8 18h13", "M3 6h.01", "M3 12h.01", "M3 18h.01"]),
|
|
23
|
+
orderedList: glyph(["M10 6h11", "M10 12h11", "M10 18h11", "M4 6h1v4", "M4 10h2", "M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"]),
|
|
24
|
+
link: glyph([
|
|
25
|
+
"M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71",
|
|
26
|
+
"M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71",
|
|
27
|
+
]),
|
|
28
|
+
image: glyph([
|
|
29
|
+
"M19 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2z",
|
|
30
|
+
"M8.5 10a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z",
|
|
31
|
+
"M21 15l-5-5L5 21",
|
|
32
|
+
]),
|
|
33
|
+
send: glyph(["M22 2L11 13", "M22 2l-7 20-4-9-9-4 20-7z"]),
|
|
34
|
+
search: glyph(["M21 21l-4.34-4.34", "M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16z"], 16),
|
|
35
|
+
arrowLeft: glyph(["M19 12H5", "M12 19l-7-7 7-7"], 14),
|
|
36
|
+
check: glyph(["M20 6L9 17l-5-5"], 12),
|
|
37
|
+
message: glyph(["M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"], 12),
|
|
38
|
+
rotate: glyph(["M3 12a9 9 0 1 0 3-6.7L3 8", "M3 3v5h5"], 14),
|
|
39
|
+
alert: glyph(["M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20z", "M12 8v4", "M12 16h.01"], 16),
|
|
40
|
+
inbox: glyph([
|
|
41
|
+
"M22 12h-6l-2 3h-4l-2-3H2",
|
|
42
|
+
"M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z",
|
|
43
|
+
], 22),
|
|
44
|
+
clock: glyph(["M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20z", "M12 6v6l4 2"], 22),
|
|
45
|
+
checkCircle: glyph(["M22 11.08V12a10 10 0 1 1-5.93-9.14", "M22 4L12 14.01l-3-3"], 22),
|
|
46
|
+
ban: glyph(["M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20z", "M4.93 4.93l14.14 14.14"], 22),
|
|
47
|
+
wrench: glyph([
|
|
48
|
+
"M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z",
|
|
49
|
+
], 16),
|
|
50
|
+
square: glyph(["M19 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2z"], 14),
|
|
51
|
+
arrowUpRight: glyph(["M7 17L17 7", "M7 7h10v10"], 14),
|
|
52
|
+
highlighter: glyph([
|
|
53
|
+
"M9 11l-6 6v3h9l3-3",
|
|
54
|
+
"M22 12l-4.6 4.6a2 2 0 0 1-2.8 0l-5.2-5.2a2 2 0 0 1 0-2.8L14 4",
|
|
55
|
+
], 14),
|
|
56
|
+
type: glyph(["M4 7V4h16v3", "M9 20h6", "M12 4v16"], 14),
|
|
57
|
+
trash: glyph([
|
|
58
|
+
"M3 6h18",
|
|
59
|
+
"M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2",
|
|
60
|
+
], 14),
|
|
61
|
+
pencil: glyph(["M12 20h9", "M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z"], 11),
|
|
62
|
+
checkSmall: glyph(["M20 6L9 17l-5-5"], 12),
|
|
63
|
+
xSmall: glyph(["M18 6L6 18", "M6 6l12 12"], 12),
|
|
64
|
+
} as const;
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Draw on an image before it is sent.
|
|
3
|
+
*
|
|
4
|
+
* The original opens this the moment you pick a picture, and the reason is in
|
|
5
|
+
* its own comment: annotating BEFORE upload keeps the canvas same-origin, so
|
|
6
|
+
* `toDataURL` is not tainted by a cross-origin presigned URL. Pointing at the
|
|
7
|
+
* problem is also most of what a screenshot is for.
|
|
8
|
+
*
|
|
9
|
+
* The drawing itself is `createAnnotator` from the core — one canvas engine,
|
|
10
|
+
* shared by all three framework packages, because there is no declarative way
|
|
11
|
+
* to draw on a canvas. What is here is only the chrome.
|
|
12
|
+
*/
|
|
13
|
+
import {
|
|
14
|
+
Component, ElementRef, EventEmitter, Input, Output, ViewChild,
|
|
15
|
+
type AfterViewInit, type OnDestroy,
|
|
16
|
+
} from "@angular/core";
|
|
17
|
+
import { NgFor, NgIf } from "@angular/common";
|
|
18
|
+
import {
|
|
19
|
+
createAnnotator, reporterStrings,
|
|
20
|
+
type Annotator, type AnnotatorTool, type ReporterStrings,
|
|
21
|
+
} from "@lightworkai.official/debug-capture";
|
|
22
|
+
import { IconComponent } from "./icon.component";
|
|
23
|
+
import { ICONS } from "./icons";
|
|
24
|
+
|
|
25
|
+
@Component({
|
|
26
|
+
selector: "lw-image-annotator-dialog",
|
|
27
|
+
standalone: true,
|
|
28
|
+
imports: [NgFor, NgIf, IconComponent],
|
|
29
|
+
template: `
|
|
30
|
+
<!-- Header, toolbar, canvas, help, footer — the same shape and the same
|
|
31
|
+
words as the app's own dialog, so the two are one thing in two places
|
|
32
|
+
rather than two things that resemble each other. -->
|
|
33
|
+
<div class="lw-annotator" role="dialog" aria-modal="true" (click)="backdrop($event)">
|
|
34
|
+
<div class="lw-annotator__panel">
|
|
35
|
+
<div class="lw-annotator__head">
|
|
36
|
+
<div class="lw-grow">
|
|
37
|
+
<h2 class="lw-annotator__title">{{ t.annotateTitle }}</h2>
|
|
38
|
+
<p class="lw-annotator__desc">
|
|
39
|
+
{{ t.annotateHint }}<span *ngIf="filename"> · {{ filename }}</span>
|
|
40
|
+
</p>
|
|
41
|
+
</div>
|
|
42
|
+
<button type="button" class="lw-annotator__close" [attr.aria-label]="t.cancel" (click)="cancelled.emit()">
|
|
43
|
+
✕
|
|
44
|
+
</button>
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
<div class="lw-annotator__tools">
|
|
48
|
+
<button
|
|
49
|
+
*ngFor="let tool of tools"
|
|
50
|
+
type="button"
|
|
51
|
+
class="lw-atool"
|
|
52
|
+
[attr.aria-pressed]="active === tool.tool"
|
|
53
|
+
[disabled]="loading"
|
|
54
|
+
(click)="pick(tool.tool)"
|
|
55
|
+
>
|
|
56
|
+
<lw-icon [svg]="tool.icon" /> {{ label(tool.key) }}
|
|
57
|
+
</button>
|
|
58
|
+
<span class="lw-grow"></span>
|
|
59
|
+
<!-- Right-aligned and on its own, like the app's. Deleting ONE shape
|
|
60
|
+
is the ✕ on the shape, which the help line below explains. -->
|
|
61
|
+
<button type="button" class="lw-atool lw-atool--plain" [disabled]="loading" (click)="clear()">
|
|
62
|
+
<lw-icon [svg]="icons.trash" /> {{ t.clearAll }}
|
|
63
|
+
</button>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<div class="lw-annotator__canvas">
|
|
67
|
+
<div class="lw-annotator__canvasbox"><canvas #canvas></canvas></div>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<p class="lw-annotator__hint">{{ t.annotateHelp }}</p>
|
|
71
|
+
|
|
72
|
+
<div class="lw-annotator__actions">
|
|
73
|
+
<span class="lw-grow"></span>
|
|
74
|
+
<button type="button" class="lw-btn" (click)="cancelled.emit()">{{ t.cancel }}</button>
|
|
75
|
+
<button type="button" class="lw-send" [disabled]="loading" (click)="confirm()">
|
|
76
|
+
{{ t.annotateApply }}
|
|
77
|
+
</button>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
`,
|
|
82
|
+
})
|
|
83
|
+
export class ImageAnnotatorDialogComponent implements AfterViewInit, OnDestroy {
|
|
84
|
+
/** The picked image, as a same-origin data URI. */
|
|
85
|
+
@Input({ required: true }) src = "";
|
|
86
|
+
/** Shown in the description, as the app's dialog does. */
|
|
87
|
+
@Input() filename?: string;
|
|
88
|
+
@Input() locale?: "th" | "en";
|
|
89
|
+
|
|
90
|
+
/** The annotated image, at full resolution. */
|
|
91
|
+
@Output() confirmed = new EventEmitter<string>();
|
|
92
|
+
@Output() cancelled = new EventEmitter<void>();
|
|
93
|
+
|
|
94
|
+
@ViewChild("canvas", { static: true }) private canvas!: ElementRef<HTMLCanvasElement>;
|
|
95
|
+
|
|
96
|
+
protected annotator: Annotator | null = null;
|
|
97
|
+
protected active: AnnotatorTool | null = null;
|
|
98
|
+
protected loading = true;
|
|
99
|
+
|
|
100
|
+
protected readonly icons = ICONS;
|
|
101
|
+
protected readonly tools: { tool: AnnotatorTool; key: keyof ReporterStrings; icon: string }[] = [
|
|
102
|
+
{ tool: "rect", key: "toolRect", icon: ICONS.square },
|
|
103
|
+
{ tool: "arrow", key: "toolArrow", icon: ICONS.arrowUpRight },
|
|
104
|
+
{ tool: "highlight", key: "toolHighlight", icon: ICONS.highlighter },
|
|
105
|
+
{ tool: "text", key: "toolText", icon: ICONS.type },
|
|
106
|
+
];
|
|
107
|
+
|
|
108
|
+
protected get t(): ReporterStrings {
|
|
109
|
+
return reporterStrings(this.locale);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
protected label(key: keyof ReporterStrings): string {
|
|
113
|
+
return this.t[key] as string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
ngAfterViewInit(): void {
|
|
117
|
+
const image = new Image();
|
|
118
|
+
image.addEventListener("load", () => {
|
|
119
|
+
// A backing store at the image's own size, BEFORE the annotator draws
|
|
120
|
+
// into it. Without this the canvas keeps its default 300x150, the image
|
|
121
|
+
// is squashed into that, and CSS then stretches the result — blurry, and
|
|
122
|
+
// the wrong shape.
|
|
123
|
+
const node = this.canvas.nativeElement;
|
|
124
|
+
node.width = image.naturalWidth;
|
|
125
|
+
node.height = image.naturalHeight;
|
|
126
|
+
this.annotator = createAnnotator(node, image, () => {
|
|
127
|
+
// The toolbar mirrors the engine, so it has to hear about a tool being
|
|
128
|
+
// CONSUMED as well as a shape being added.
|
|
129
|
+
this.active = null;
|
|
130
|
+
});
|
|
131
|
+
this.loading = false;
|
|
132
|
+
});
|
|
133
|
+
image.src = this.src;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
ngOnDestroy(): void {
|
|
137
|
+
this.annotator?.destroy();
|
|
138
|
+
this.annotator = null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
protected pick(tool: AnnotatorTool): void {
|
|
142
|
+
this.active = this.active === tool ? null : tool;
|
|
143
|
+
this.annotator?.setTool(this.active);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
protected clear(): void {
|
|
147
|
+
this.annotator?.clear();
|
|
148
|
+
this.active = null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
protected backdrop(event: MouseEvent): void {
|
|
152
|
+
if (event.target === event.currentTarget) this.cancelled.emit();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
protected confirm(): void {
|
|
156
|
+
// Full resolution, never the on-screen scale.
|
|
157
|
+
this.confirmed.emit(this.annotator?.toDataUrl() ?? this.src);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "ปัญหาที่แจ้ง" — the reports this person filed, and what became of them.
|
|
3
|
+
*
|
|
4
|
+
* Put it on a route of your own — the reporter-facing counterpart to the
|
|
5
|
+
* agent's ticket list. It needs `identity` in the config, because the widget's
|
|
6
|
+
* realm key is public and can authorise filing a report but never reading one
|
|
7
|
+
* back — see the core README.
|
|
8
|
+
*
|
|
9
|
+
* This component owns fetching and view state and nothing else: the columns,
|
|
10
|
+
* the sorting, the sanitising and the dates all live in the core, shared with
|
|
11
|
+
* the Vue and React packages.
|
|
12
|
+
*/
|
|
13
|
+
import { Component, EventEmitter, Input, Output, type OnChanges, type OnInit } from "@angular/core";
|
|
14
|
+
import { FormsModule } from "@angular/forms";
|
|
15
|
+
import { NgFor, NgIf } from "@angular/common";
|
|
16
|
+
import {
|
|
17
|
+
NoIdentityError, fetchRealmConfig, getAttachmentUrl, getConfig, getMyTicket, getMyTicketHistory,
|
|
18
|
+
listMyTickets, reopenMyTicket, replyToTicket, statusMaps, ticketStrings, uploadInlineImage,
|
|
19
|
+
type MyTicketDetail, type MyTicketListItem, type RealmConfig, type Sort, type SortKey,
|
|
20
|
+
type StatusEvent, type StatusMaps, type TicketStrings,
|
|
21
|
+
} from "@lightworkai.official/debug-capture";
|
|
22
|
+
import { IconComponent } from "./icon.component";
|
|
23
|
+
import { TicketTableComponent } from "./ticket-table.component";
|
|
24
|
+
import { TicketDetailComponent } from "./ticket-detail.component";
|
|
25
|
+
import { ICONS } from "./icons";
|
|
26
|
+
|
|
27
|
+
@Component({
|
|
28
|
+
selector: "lw-my-tickets-panel",
|
|
29
|
+
standalone: true,
|
|
30
|
+
imports: [NgIf, NgFor, FormsModule, IconComponent, TicketTableComponent, TicketDetailComponent],
|
|
31
|
+
template: `
|
|
32
|
+
<div class="lw-panel">
|
|
33
|
+
<ng-container *ngIf="!detail && !detailLoading">
|
|
34
|
+
<h2 *ngIf="!hideTitle" class="lw-panel__title">{{ t.title }}</h2>
|
|
35
|
+
<p class="lw-panel__intro">{{ t.intro }}</p>
|
|
36
|
+
|
|
37
|
+
<div class="lw-controls">
|
|
38
|
+
<div class="lw-search">
|
|
39
|
+
<lw-icon class="lw-search__icon" [svg]="icons.search" />
|
|
40
|
+
<input
|
|
41
|
+
type="search"
|
|
42
|
+
class="lw-input lw-search__field"
|
|
43
|
+
[placeholder]="t.searchPlaceholder"
|
|
44
|
+
[attr.aria-label]="t.searchLabel"
|
|
45
|
+
[ngModel]="keyword"
|
|
46
|
+
(ngModelChange)="onKeyword($event)"
|
|
47
|
+
/>
|
|
48
|
+
</div>
|
|
49
|
+
<select class="lw-input" [ngModel]="statusFilter" (ngModelChange)="onStatus($event)">
|
|
50
|
+
<option *ngFor="let option of maps.options" [value]="option.value">{{ option.label }}</option>
|
|
51
|
+
</select>
|
|
52
|
+
<span class="lw-count">{{ t.count(shown) }}</span>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<p *ngIf="noIdentity" class="lw-state">{{ t.signedOut }}</p>
|
|
56
|
+
<div *ngIf="!noIdentity && loading">
|
|
57
|
+
<div class="lw-skeleton"></div>
|
|
58
|
+
<div class="lw-skeleton"></div>
|
|
59
|
+
<div class="lw-skeleton"></div>
|
|
60
|
+
<div class="lw-skeleton"></div>
|
|
61
|
+
</div>
|
|
62
|
+
<p *ngIf="!noIdentity && !loading && error" class="lw-state lw-state--error">
|
|
63
|
+
<lw-icon [svg]="icons.alert" /> {{ error }}
|
|
64
|
+
<button type="button" class="lw-retry" (click)="load()">{{ t.retry }}</button>
|
|
65
|
+
</p>
|
|
66
|
+
<lw-ticket-table
|
|
67
|
+
*ngIf="!noIdentity && !loading && !error"
|
|
68
|
+
[items]="items"
|
|
69
|
+
[maps]="maps"
|
|
70
|
+
[t]="t"
|
|
71
|
+
[slug]="config?.realm?.slug"
|
|
72
|
+
[timezone]="config?.timezone || 'Asia/Bangkok'"
|
|
73
|
+
[locale]="locale"
|
|
74
|
+
[keyword]="applied"
|
|
75
|
+
[statusFilter]="statusFilter"
|
|
76
|
+
[sort]="sort"
|
|
77
|
+
[page]="page"
|
|
78
|
+
(opened)="open($event)"
|
|
79
|
+
(sorted)="toggleSort($event)"
|
|
80
|
+
(paged)="page = $event"
|
|
81
|
+
(counted)="shown = $event"
|
|
82
|
+
/>
|
|
83
|
+
</ng-container>
|
|
84
|
+
|
|
85
|
+
<div *ngIf="detailLoading">
|
|
86
|
+
<div class="lw-skeleton"></div>
|
|
87
|
+
<div class="lw-skeleton"></div>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<p *ngIf="!detailLoading && detailError" class="lw-state lw-state--error">{{ detailError }}</p>
|
|
91
|
+
|
|
92
|
+
<lw-ticket-detail
|
|
93
|
+
*ngIf="!detailLoading && detail && config"
|
|
94
|
+
[ticket]="detail"
|
|
95
|
+
[history]="history"
|
|
96
|
+
[config]="config"
|
|
97
|
+
[maps]="maps"
|
|
98
|
+
[t]="t"
|
|
99
|
+
[host]="host"
|
|
100
|
+
[locale]="locale"
|
|
101
|
+
[sending]="sending"
|
|
102
|
+
[reopening]="reopening"
|
|
103
|
+
[uploadImage]="uploadImage"
|
|
104
|
+
[attachmentUrl]="attachmentUrl"
|
|
105
|
+
(back)="back()"
|
|
106
|
+
(edited)="detail && reload(detail.id)"
|
|
107
|
+
(sent)="send($event)"
|
|
108
|
+
(reopened)="reopen($event)"
|
|
109
|
+
(errored)="errored.emit($event)"
|
|
110
|
+
/>
|
|
111
|
+
</div>
|
|
112
|
+
`,
|
|
113
|
+
})
|
|
114
|
+
export class MyTicketsPanelComponent implements OnInit, OnChanges {
|
|
115
|
+
/** Change it to re-read the list — after filing a report, say. */
|
|
116
|
+
@Input() refreshKey?: string | number;
|
|
117
|
+
/** Hide the panel's own heading when the host page already has one. */
|
|
118
|
+
@Input() hideTitle = false;
|
|
119
|
+
|
|
120
|
+
@Output() replied = new EventEmitter<MyTicketDetail>();
|
|
121
|
+
@Output() reopenedTicket = new EventEmitter<MyTicketDetail>();
|
|
122
|
+
@Output() errored = new EventEmitter<string>();
|
|
123
|
+
|
|
124
|
+
protected readonly icons = ICONS;
|
|
125
|
+
protected items: MyTicketListItem[] = [];
|
|
126
|
+
protected config: RealmConfig | null = null;
|
|
127
|
+
protected loading = true;
|
|
128
|
+
protected error: string | null = null;
|
|
129
|
+
protected noIdentity = false;
|
|
130
|
+
|
|
131
|
+
protected keyword = "";
|
|
132
|
+
protected applied = "";
|
|
133
|
+
protected statusFilter = "";
|
|
134
|
+
protected sort: Sort = { key: "createdAt", direction: "desc" };
|
|
135
|
+
protected page = 1;
|
|
136
|
+
protected shown = 0;
|
|
137
|
+
|
|
138
|
+
protected detail: MyTicketDetail | null = null;
|
|
139
|
+
protected history: StatusEvent[] = [];
|
|
140
|
+
protected detailError: string | null = null;
|
|
141
|
+
protected detailLoading = false;
|
|
142
|
+
protected sending = false;
|
|
143
|
+
protected reopening = false;
|
|
144
|
+
|
|
145
|
+
private debounce: ReturnType<typeof setTimeout> | null = null;
|
|
146
|
+
private first = true;
|
|
147
|
+
|
|
148
|
+
protected get locale(): "th" | "en" | undefined {
|
|
149
|
+
return this.safe(() => getConfig().locale, undefined);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
protected get host(): string {
|
|
153
|
+
return this.safe(() => getConfig().host, "");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
protected get t(): TicketStrings {
|
|
157
|
+
return ticketStrings(this.locale);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
protected get maps(): StatusMaps {
|
|
161
|
+
return statusMaps(this.config?.statusColumns ?? [], this.t.allStatuses);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
ngOnInit(): void {
|
|
165
|
+
void this.load();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
ngOnChanges(): void {
|
|
169
|
+
// Not on the first change: ngOnInit already loaded, and fetching the same
|
|
170
|
+
// list twice on mount is a wasted round trip.
|
|
171
|
+
if (this.first) {
|
|
172
|
+
this.first = false;
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
void this.load();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
protected async load(): Promise<void> {
|
|
179
|
+
this.loading = true;
|
|
180
|
+
this.error = null;
|
|
181
|
+
this.noIdentity = false;
|
|
182
|
+
try {
|
|
183
|
+
// Config and list together: the labels are useless without the rows and
|
|
184
|
+
// the rows unreadable without the labels.
|
|
185
|
+
const [realm, list] = await Promise.all([fetchRealmConfig(), listMyTickets()]);
|
|
186
|
+
this.config = realm;
|
|
187
|
+
this.items = list;
|
|
188
|
+
} catch (e) {
|
|
189
|
+
if (e instanceof NoIdentityError) this.noIdentity = true;
|
|
190
|
+
else this.error = e instanceof Error ? e.message : this.t.listFailed;
|
|
191
|
+
} finally {
|
|
192
|
+
this.loading = false;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Debounced: re-filtering per keystroke resets the page under the reader's hands. */
|
|
197
|
+
protected onKeyword(next: string): void {
|
|
198
|
+
this.keyword = next;
|
|
199
|
+
if (this.debounce) clearTimeout(this.debounce);
|
|
200
|
+
this.debounce = setTimeout(() => {
|
|
201
|
+
this.applied = next;
|
|
202
|
+
this.page = 1;
|
|
203
|
+
}, 300);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
protected onStatus(next: string): void {
|
|
207
|
+
this.statusFilter = next;
|
|
208
|
+
this.page = 1;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Open a DIFFERENT ticket: tear the view down, because nothing on screen is its. */
|
|
212
|
+
protected async open(id: string): Promise<MyTicketDetail | null> {
|
|
213
|
+
this.detail = null;
|
|
214
|
+
this.history = [];
|
|
215
|
+
this.detailError = null;
|
|
216
|
+
this.detailLoading = true;
|
|
217
|
+
try {
|
|
218
|
+
return await this.reload(id);
|
|
219
|
+
} finally {
|
|
220
|
+
this.detailLoading = false;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Re-read the ticket we are already looking at, in place.
|
|
226
|
+
*
|
|
227
|
+
* Deliberately separate from `open()`. That one clears the detail and raises
|
|
228
|
+
* the loading flag, which destroys the whole view and rebuilds it — so
|
|
229
|
+
* sending a reply threw the reader back to the top of the page, away from the
|
|
230
|
+
* conversation they were in the middle of. It reads as the page reloading,
|
|
231
|
+
* because visually that is what happens.
|
|
232
|
+
*/
|
|
233
|
+
protected async reload(id: string): Promise<MyTicketDetail | null> {
|
|
234
|
+
try {
|
|
235
|
+
const [found, events] = await Promise.all([
|
|
236
|
+
getMyTicket(id),
|
|
237
|
+
// The timeline is a nicety, not the page: a ticket whose history fails
|
|
238
|
+
// to load should still show its conversation.
|
|
239
|
+
getMyTicketHistory(id).catch(() => [] as StatusEvent[]),
|
|
240
|
+
]);
|
|
241
|
+
this.detail = found;
|
|
242
|
+
this.history = events;
|
|
243
|
+
return found;
|
|
244
|
+
} catch (e) {
|
|
245
|
+
this.detailError = e instanceof Error ? e.message : this.t.detailFailed;
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
protected back(): void {
|
|
251
|
+
this.detail = null;
|
|
252
|
+
// Counts and badges move when a reply lands, so the list is re-read rather
|
|
253
|
+
// than restored from what it said before the detail opened.
|
|
254
|
+
void this.load();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
protected toggleSort(key: SortKey): void {
|
|
258
|
+
this.sort =
|
|
259
|
+
this.sort.key === key
|
|
260
|
+
? { key, direction: this.sort.direction === "asc" ? "desc" : "asc" }
|
|
261
|
+
: // Dates open newest-first, text A→Z.
|
|
262
|
+
{ key, direction: key === "createdAt" || key === "updatedAt" ? "desc" : "asc" };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
protected async send(body: string): Promise<void> {
|
|
266
|
+
const ticket = this.detail;
|
|
267
|
+
if (!ticket || this.sending) return;
|
|
268
|
+
this.sending = true;
|
|
269
|
+
try {
|
|
270
|
+
await replyToTicket(ticket.id, body);
|
|
271
|
+
const fresh = await this.reload(ticket.id);
|
|
272
|
+
if (fresh) this.replied.emit(fresh);
|
|
273
|
+
} catch (e) {
|
|
274
|
+
this.errored.emit(e instanceof Error ? e.message : this.t.sendFailed);
|
|
275
|
+
} finally {
|
|
276
|
+
this.sending = false;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
protected async reopen(note: string): Promise<void> {
|
|
281
|
+
const ticket = this.detail;
|
|
282
|
+
if (!ticket || this.reopening) return;
|
|
283
|
+
this.reopening = true;
|
|
284
|
+
try {
|
|
285
|
+
await reopenMyTicket(ticket.id, note);
|
|
286
|
+
const fresh = await this.reload(ticket.id);
|
|
287
|
+
if (fresh) this.reopenedTicket.emit(fresh);
|
|
288
|
+
} catch (e) {
|
|
289
|
+
this.errored.emit(e instanceof Error ? e.message : this.t.reopenFailed);
|
|
290
|
+
} finally {
|
|
291
|
+
this.reopening = false;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Arrow properties, not methods: they are passed as inputs and must keep
|
|
296
|
+
// their `this`.
|
|
297
|
+
protected readonly uploadImage = async (file: File): Promise<string> => {
|
|
298
|
+
const ticket = this.detail;
|
|
299
|
+
if (!ticket) throw new Error("no ticket");
|
|
300
|
+
const dataUri = await readDataUri(file);
|
|
301
|
+
const stored = await uploadInlineImage(ticket.id, dataUri, file.name);
|
|
302
|
+
return stored.url;
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
protected readonly attachmentUrl = (id: string): Promise<string> =>
|
|
306
|
+
getAttachmentUrl(id).then((found) => found.url);
|
|
307
|
+
|
|
308
|
+
private safe<T>(read: () => T, fallback: T): T {
|
|
309
|
+
try {
|
|
310
|
+
return read();
|
|
311
|
+
} catch {
|
|
312
|
+
return fallback;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function readDataUri(file: File): Promise<string> {
|
|
318
|
+
return new Promise((resolve, reject) => {
|
|
319
|
+
const reader = new FileReader();
|
|
320
|
+
reader.addEventListener("load", () =>
|
|
321
|
+
typeof reader.result === "string" ? resolve(reader.result) : reject(new Error("unreadable file")),
|
|
322
|
+
);
|
|
323
|
+
reader.addEventListener("error", () => reject(new Error("unreadable file")));
|
|
324
|
+
reader.readAsDataURL(file);
|
|
325
|
+
});
|
|
326
|
+
}
|