@vicket/create-support 1.1.1 → 1.1.2

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 (71) hide show
  1. package/bin/create-vicket-support.js +429 -389
  2. package/package.json +1 -1
  3. package/templates/next/src/app/api/vicket/[...path]/route.ts +2 -55
  4. package/templates/next/src/app/components/vicket/ReplyForm.tsx +154 -0
  5. package/templates/next/src/app/components/vicket/SupportContent.tsx +298 -0
  6. package/templates/next/src/app/components/vicket/TicketDialog.tsx +3 -3
  7. package/templates/next/src/app/support/page.tsx +27 -353
  8. package/templates/next/src/app/ticket/page.tsx +110 -325
  9. package/templates/next/src/app/vicket.css +1325 -1325
  10. package/templates/nuxt/app/assets/css/vicket.css +1325 -1325
  11. package/templates/nuxt/app/components/VicketReplyForm.vue +154 -0
  12. package/templates/nuxt/app/components/VicketSupportContent.vue +255 -0
  13. package/templates/nuxt/app/components/VicketTicketDialog.vue +2 -2
  14. package/templates/nuxt/app/pages/support.vue +7 -293
  15. package/templates/nuxt/app/pages/ticket.vue +36 -178
  16. package/templates/nuxt/server/api/vicket/[...path].ts +2 -85
  17. package/templates/sveltekit/src/lib/vicket/ReplyForm.svelte +134 -0
  18. package/templates/sveltekit/src/lib/vicket/SupportContent.svelte +263 -0
  19. package/templates/sveltekit/src/lib/vicket/TicketDialog.svelte +457 -459
  20. package/templates/sveltekit/src/lib/vicket.css +1325 -1325
  21. package/templates/sveltekit/src/routes/api/vicket/[...path]/+server.ts +2 -76
  22. package/templates/sveltekit/src/routes/support/+page.server.ts +13 -0
  23. package/templates/sveltekit/src/routes/support/+page.svelte +3 -312
  24. package/templates/sveltekit/src/routes/ticket/+page.server.ts +19 -0
  25. package/templates/sveltekit/src/routes/ticket/+page.svelte +13 -188
  26. package/templates-tailwind/next/src/app/api/vicket/[...path]/route.ts +6 -0
  27. package/templates-tailwind/next/src/app/support/page.tsx +33 -3
  28. package/templates-tailwind/next/src/app/ticket/page.tsx +249 -6
  29. package/templates-tailwind/next/src/components/vicket/reply-form.tsx +113 -0
  30. package/templates-tailwind/next/src/components/vicket/support-content.tsx +265 -0
  31. package/templates-tailwind/next/src/components/vicket/ticket-dialog.tsx +2 -2
  32. package/templates-tailwind/nuxt/app/components/VicketReplyForm.vue +169 -0
  33. package/templates-tailwind/nuxt/app/components/{VicketSupportPage.vue → VicketSupportContent.vue} +275 -317
  34. package/templates-tailwind/nuxt/app/components/VicketTicketDialog.vue +3 -0
  35. package/templates-tailwind/nuxt/app/pages/support.vue +10 -1
  36. package/templates-tailwind/nuxt/app/pages/ticket.vue +298 -1
  37. package/templates-tailwind/nuxt/server/api/vicket/[...path].ts +2 -0
  38. package/templates-tailwind/sveltekit/src/lib/vicket/ReplyForm.svelte +127 -0
  39. package/templates-tailwind/sveltekit/src/lib/vicket/{SupportPage.svelte → SupportContent.svelte} +9 -71
  40. package/templates-tailwind/sveltekit/src/lib/vicket/TicketDialog.svelte +405 -406
  41. package/templates-tailwind/sveltekit/src/routes/api/vicket/[...path]/+server.ts +3 -0
  42. package/templates-tailwind/sveltekit/src/routes/support/+page.server.ts +13 -0
  43. package/templates-tailwind/sveltekit/src/routes/support/+page.svelte +4 -2
  44. package/templates-tailwind/sveltekit/src/routes/ticket/+page.server.ts +19 -0
  45. package/templates-tailwind/sveltekit/src/routes/ticket/+page.svelte +292 -2
  46. package/templates/next/src/app/utils/vicket/api.ts +0 -149
  47. package/templates/next/src/app/utils/vicket/types.ts +0 -85
  48. package/templates/next/src/app/utils/vicket/utils.ts +0 -49
  49. package/templates/nuxt/app/composables/useVicket.ts +0 -274
  50. package/templates/sveltekit/src/lib/vicket/api.ts +0 -162
  51. package/templates/sveltekit/src/lib/vicket/types.ts +0 -87
  52. package/templates/sveltekit/src/lib/vicket/utils.ts +0 -55
  53. package/templates-tailwind/next/src/app/api/vicket/init/route.ts +0 -24
  54. package/templates-tailwind/next/src/app/api/vicket/messages/route.ts +0 -36
  55. package/templates-tailwind/next/src/app/api/vicket/thread/route.ts +0 -27
  56. package/templates-tailwind/next/src/app/api/vicket/tickets/route.ts +0 -37
  57. package/templates-tailwind/next/src/components/vicket/support-page.tsx +0 -359
  58. package/templates-tailwind/next/src/components/vicket/ticket-page.tsx +0 -425
  59. package/templates-tailwind/next/src/lib/vicket.ts +0 -257
  60. package/templates-tailwind/nuxt/app/components/VicketTicketPage.vue +0 -449
  61. package/templates-tailwind/nuxt/app/composables/use-vicket.ts +0 -249
  62. package/templates-tailwind/nuxt/server/api/vicket/init.get.ts +0 -22
  63. package/templates-tailwind/nuxt/server/api/vicket/messages.post.ts +0 -56
  64. package/templates-tailwind/nuxt/server/api/vicket/thread.get.ts +0 -26
  65. package/templates-tailwind/nuxt/server/api/vicket/tickets.post.ts +0 -53
  66. package/templates-tailwind/sveltekit/src/lib/vicket/TicketPage.svelte +0 -465
  67. package/templates-tailwind/sveltekit/src/lib/vicket/index.ts +0 -257
  68. package/templates-tailwind/sveltekit/src/routes/api/vicket/init/+server.ts +0 -22
  69. package/templates-tailwind/sveltekit/src/routes/api/vicket/messages/+server.ts +0 -40
  70. package/templates-tailwind/sveltekit/src/routes/api/vicket/thread/+server.ts +0 -25
  71. package/templates-tailwind/sveltekit/src/routes/api/vicket/tickets/+server.ts +0 -37
@@ -1,465 +0,0 @@
1
- <script lang="ts">
2
- import { page } from "$app/state";
3
- import type { TicketThread, Message } from "$lib/vicket";
4
- import { fetchThread, sendReply, sanitizeHtml, stripHtml, formatDate, cn, isFileAnswer, formatAnswerText, AUTHOR_LABELS } from "$lib/vicket";
5
-
6
- /* ---------------------------------------------- */
7
- /* State */
8
- /* ---------------------------------------------- */
9
- let thread = $state<TicketThread | null>(null);
10
- let content = $state("");
11
- let files = $state<File[]>([]);
12
- let isLoading = $state(true);
13
- let isSending = $state(false);
14
- let error = $state("");
15
- let success = $state("");
16
-
17
- /* ---------------------------------------------- */
18
- /* Derived */
19
- /* ---------------------------------------------- */
20
- let token = $derived(page.url.searchParams.get("token") || "");
21
- let hasToken = $derived(token.trim().length > 0);
22
-
23
- let firstReporterMessage = $derived.by(() => {
24
- if (!thread?.messages || thread.messages.length === 0) return null;
25
- const sorted = [...thread.messages].sort(
26
- (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime(),
27
- );
28
- // Only treat as description if the very first message is from the reporter
29
- return sorted[0].author_type === "reporter" ? sorted[0] : null;
30
- });
31
-
32
- let sortedMessages = $derived.by(() => {
33
- if (!thread?.messages) return [];
34
- return [...thread.messages]
35
- .sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
36
- .filter((m) => !firstReporterMessage || m.id !== firstReporterMessage.id);
37
- });
38
-
39
- let summaryAnswers = $derived.by(() => {
40
- if (!thread?.answers) return [];
41
- return thread.answers.filter((answer) => {
42
- if (answer.attachments && answer.attachments.length > 0) return true;
43
- if (answer.answer && answer.answer.trim().length > 0) return true;
44
- return false;
45
- });
46
- });
47
-
48
- /* ---------------------------------------------- */
49
- /* Functions */
50
- /* ---------------------------------------------- */
51
- function removeFile(index: number) {
52
- files = files.filter((_, i) => i !== index);
53
- }
54
-
55
- function onFileChange(event: Event) {
56
- const input = event.target as HTMLInputElement;
57
- const newFiles = Array.from(input.files || []);
58
- files = [...files, ...newFiles];
59
- input.value = "";
60
- }
61
-
62
- async function loadThread() {
63
- if (!hasToken) {
64
- isLoading = false;
65
- error = "Missing ticket token in URL.";
66
- return;
67
- }
68
-
69
- isLoading = true;
70
- error = "";
71
- try {
72
- const data = await fetchThread(token);
73
- thread = data;
74
- } catch (loadError) {
75
- error = loadError instanceof Error ? loadError.message : "Unexpected error.";
76
- } finally {
77
- isLoading = false;
78
- }
79
- }
80
-
81
- async function onSubmitReply(event: SubmitEvent) {
82
- event.preventDefault();
83
- error = "";
84
- success = "";
85
-
86
- if (!content.trim() && files.length === 0) {
87
- error = "Reply content is required.";
88
- return;
89
- }
90
-
91
- if (!hasToken) {
92
- error = "Missing ticket token.";
93
- return;
94
- }
95
-
96
- isSending = true;
97
- try {
98
- await sendReply(token, content.trim(), files);
99
- content = "";
100
- files = [];
101
- success = "Reply sent.";
102
- await loadThread();
103
- } catch (replyError) {
104
- error = replyError instanceof Error ? replyError.message : "Unexpected error.";
105
- } finally {
106
- isSending = false;
107
- }
108
- }
109
-
110
- /* ---------------------------------------------- */
111
- /* Load on mount */
112
- /* ---------------------------------------------- */
113
- $effect(() => {
114
- void loadThread();
115
- });
116
- </script>
117
-
118
- <div class="min-h-screen bg-slate-50 font-[system-ui,-apple-system,sans-serif] antialiased">
119
- <div class="mx-auto max-w-4xl px-6 py-16">
120
- <!-- Back link -->
121
- <div class="mb-6">
122
- <a href="/support" class="inline-flex items-center gap-1.5 text-sm font-medium text-slate-500 no-underline transition-colors hover:text-slate-900">
123
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m12 19-7-7 7-7" /><path d="M19 12H5" /></svg>
124
- Back to support
125
- </a>
126
- </div>
127
-
128
- <!-- Alerts -->
129
- {#if error}
130
- <div class="mb-6 flex items-start gap-3 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-900" role="alert">
131
- <span class="mt-0.5 shrink-0">
132
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" /><path d="M12 9v4" /><path d="M12 17h.01" /></svg>
133
- </span>
134
- <span class="flex-1">{error}</span>
135
- <button type="button" onclick={() => (error = "")} class="shrink-0 cursor-pointer border-none bg-transparent p-0 opacity-50 transition-opacity hover:opacity-100" aria-label="Dismiss">
136
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18" /><path d="m6 6 12 12" /></svg>
137
- </button>
138
- </div>
139
- {/if}
140
- {#if success}
141
- <div class="mb-6 flex items-start gap-3 rounded-xl border border-green-200 bg-green-50 p-4 text-sm text-green-900" role="alert">
142
- <span class="mt-0.5 shrink-0">
143
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5" /></svg>
144
- </span>
145
- <span class="flex-1">{success}</span>
146
- <button type="button" onclick={() => (success = "")} class="shrink-0 cursor-pointer border-none bg-transparent p-0 opacity-50 transition-opacity hover:opacity-100" aria-label="Dismiss">
147
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18" /><path d="m6 6 12 12" /></svg>
148
- </button>
149
- </div>
150
- {/if}
151
-
152
- <!-- Loading skeleton -->
153
- {#if isLoading}
154
- <div class="space-y-6">
155
- <!-- Header skeleton -->
156
- <div>
157
- <div class="h-8 w-80 animate-pulse rounded-lg bg-slate-200" aria-hidden="true"></div>
158
- <div class="mt-3 flex gap-2">
159
- <div class="h-6 w-16 animate-pulse rounded-full bg-slate-200" aria-hidden="true"></div>
160
- <div class="h-6 w-20 animate-pulse rounded-full bg-slate-200" aria-hidden="true"></div>
161
- </div>
162
- </div>
163
- <!-- Messages skeleton -->
164
- <div class="rounded-2xl border border-slate-200 bg-white shadow-sm">
165
- <div class="border-b border-slate-100 px-5 py-3">
166
- <div class="h-5 w-24 animate-pulse rounded bg-slate-200" aria-hidden="true"></div>
167
- </div>
168
- {#each [1, 2, 3] as i (i)}
169
- <div class="flex gap-3 border-b border-slate-50 px-5 py-4 last:border-b-0">
170
- <div class="h-8 w-8 shrink-0 animate-pulse rounded-full bg-slate-200" aria-hidden="true"></div>
171
- <div class="flex-1 space-y-2">
172
- <div class="h-4 w-32 animate-pulse rounded bg-slate-200" aria-hidden="true"></div>
173
- <div class="h-4 w-full animate-pulse rounded bg-slate-200" aria-hidden="true"></div>
174
- </div>
175
- </div>
176
- {/each}
177
- </div>
178
- </div>
179
- {/if}
180
-
181
- <!-- Thread -->
182
- {#if !isLoading && thread}
183
- <div class="space-y-6">
184
- <!-- Header -->
185
- <div>
186
- <div class="flex items-start gap-3">
187
- <h1 class="m-0 flex-1 text-xl font-bold text-slate-900 md:text-2xl">{thread.title}</h1>
188
- {#if thread.id}
189
- <span class="shrink-0 rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-500">
190
- #{thread.id.slice(0, 8)}
191
- </span>
192
- {/if}
193
- </div>
194
- {#if (thread.status?.label && thread.status.label.toLowerCase() !== "open") || (thread.priority?.label && thread.priority.label.toLowerCase() !== "low")}
195
- <div class="mt-3 flex flex-wrap gap-2">
196
- {#if thread.status?.label && thread.status.label.toLowerCase() !== "open"}
197
- <span class="rounded-full bg-blue-50 px-3 py-1 text-xs font-semibold text-blue-700">
198
- {thread.status.label}
199
- </span>
200
- {/if}
201
- {#if thread.priority?.label && thread.priority.label.toLowerCase() !== "low"}
202
- <span class="rounded-full bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700">
203
- {thread.priority.label}
204
- </span>
205
- {/if}
206
- </div>
207
- {/if}
208
- </div>
209
-
210
- <!-- Summary (ticket form answers) -->
211
- {#if summaryAnswers.length > 0}
212
- <div class="rounded-2xl border border-slate-200 bg-white shadow-sm">
213
- <div class="flex items-center gap-2 border-b border-slate-100 px-5 py-3">
214
- <span class="text-slate-400">
215
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z" /><path d="M14 2v6h6" /><path d="M16 13H8" /><path d="M16 17H8" /><path d="M10 9H8" /></svg>
216
- </span>
217
- <h2 class="m-0 text-sm font-semibold text-slate-900">Summary</h2>
218
- </div>
219
- <div class="divide-y divide-slate-50 px-5">
220
- {#each summaryAnswers as answer (answer.id)}
221
- <div class="py-3">
222
- <p class="m-0 text-xs font-semibold uppercase tracking-wider text-slate-400">{answer.question_label || "Question"}</p>
223
- {#if answer.attachments && answer.attachments.length > 0}
224
- <div class="mt-1.5 flex flex-wrap gap-1.5">
225
- {#each answer.attachments as attachment (attachment.id)}
226
- <a
227
- class="inline-flex items-center gap-1.5 rounded-lg border border-slate-200 bg-slate-50 px-3 py-1.5 text-xs text-slate-600 no-underline transition-colors hover:bg-slate-100"
228
- href={attachment.url}
229
- rel="noopener noreferrer"
230
- target="_blank"
231
- >
232
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48" /></svg>
233
- {attachment.original_filename}
234
- </a>
235
- {/each}
236
- </div>
237
- {:else if isFileAnswer(answer.answer)}
238
- <p class="m-0 mt-1 text-sm italic text-slate-400">File uploaded</p>
239
- {:else}
240
- <p class="m-0 mt-1 text-sm text-slate-700">{formatAnswerText(answer.answer) || "-"}</p>
241
- {/if}
242
- </div>
243
- {/each}
244
- </div>
245
- </div>
246
- {/if}
247
-
248
- <!-- Description (first reporter message) -->
249
- {#if firstReporterMessage}
250
- <div class="rounded-2xl border border-slate-200 bg-white shadow-sm">
251
- <div class="flex items-center gap-2 border-b border-slate-100 px-5 py-3">
252
- <span class="text-slate-400">
253
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z" /><path d="M14 2v6h6" /><path d="M16 13H8" /><path d="M16 17H8" /><path d="M10 9H8" /></svg>
254
- </span>
255
- <h2 class="m-0 text-sm font-semibold text-slate-900">Description</h2>
256
- </div>
257
- <div class="px-5 py-4">
258
- <div class="vk-message-content text-sm leading-relaxed text-slate-700">
259
- {@html sanitizeHtml(firstReporterMessage.content)}
260
- </div>
261
- {#if firstReporterMessage.attachments && firstReporterMessage.attachments.length > 0}
262
- <div class="mt-3 flex flex-wrap gap-1.5">
263
- {#each firstReporterMessage.attachments as att (att.id)}
264
- <a
265
- href={att.url}
266
- target="_blank"
267
- rel="noopener noreferrer"
268
- class="inline-flex items-center gap-1.5 rounded-lg border border-slate-200 bg-slate-50 px-3 py-1.5 text-xs text-slate-600 no-underline transition-colors hover:bg-slate-100"
269
- >
270
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48" /></svg>
271
- {att.original_filename}
272
- </a>
273
- {/each}
274
- </div>
275
- {/if}
276
- </div>
277
- </div>
278
- {/if}
279
-
280
- <!-- Comments card -->
281
- <div class="rounded-2xl border border-slate-200 bg-white shadow-sm">
282
- <div class="flex items-center gap-2 border-b border-slate-100 px-5 py-3">
283
- <span class="text-slate-400">
284
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z" /></svg>
285
- </span>
286
- <h2 class="m-0 text-sm font-semibold text-slate-900">Comments</h2>
287
- </div>
288
-
289
- <!-- Compose area -->
290
- <div class="border-b border-slate-100 px-5 py-4">
291
- <form class="space-y-3" onsubmit={onSubmitReply}>
292
- <textarea
293
- class="min-h-[80px] w-full resize-y rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-900 transition-all duration-150 placeholder:text-slate-500/60 focus:border-blue-600 focus:outline-none focus:ring-3 focus:ring-blue-600/10"
294
- bind:value={content}
295
- placeholder="Write your reply..."
296
- ></textarea>
297
-
298
- <div class="flex items-center justify-between gap-3">
299
- <!-- File input -->
300
- <div class="flex flex-wrap items-center gap-2">
301
- <label class="inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-600 transition-colors hover:bg-slate-50">
302
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48" /></svg>
303
- Browse files
304
- <input type="file" multiple class="hidden" onchange={onFileChange} />
305
- </label>
306
- {#each files as file, i}
307
- <span class="inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-slate-50 px-3 py-1.5 text-xs text-slate-500">
308
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48" /></svg>
309
- <span class="max-w-[120px] truncate">{file.name}</span>
310
- <button
311
- type="button"
312
- onclick={() => removeFile(i)}
313
- class="flex cursor-pointer items-center border-none bg-transparent p-0 text-slate-400 transition-colors hover:text-red-600"
314
- aria-label="Remove {file.name}"
315
- >
316
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18" /><path d="m6 6 12 12" /></svg>
317
- </button>
318
- </span>
319
- {/each}
320
- </div>
321
-
322
- <!-- Send -->
323
- <button
324
- class="inline-flex shrink-0 items-center gap-2 rounded-lg border-none bg-blue-600 px-5 py-2 text-sm font-semibold text-white cursor-pointer transition-all hover:bg-blue-700 hover:-translate-y-px hover:shadow-lg active:translate-y-0 disabled:opacity-60 disabled:cursor-not-allowed"
325
- disabled={isSending}
326
- type="submit"
327
- >
328
- {#if isSending}
329
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="animate-spin"><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg>
330
- Sending...
331
- {:else}
332
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z" /><path d="m21.854 2.147-10.94 10.939" /></svg>
333
- Send
334
- {/if}
335
- </button>
336
- </div>
337
- </form>
338
- </div>
339
-
340
- <!-- Message list -->
341
- {#if sortedMessages.length === 0}
342
- <div class="px-5 py-10 text-center">
343
- <div class="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-slate-100 text-slate-400">
344
- <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z" /></svg>
345
- </div>
346
- <p class="m-0 text-sm text-slate-500">No messages yet. Be the first to reply!</p>
347
- </div>
348
- {:else}
349
- <div class="divide-y divide-slate-50">
350
- {#each sortedMessages as message (message.id)}
351
- {#if message.author_type === "system"}
352
- <!-- System message -->
353
- <div class="flex gap-3 px-5 py-4">
354
- <div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-slate-100 text-xs font-bold text-slate-500" aria-hidden="true">
355
- S
356
- </div>
357
- <div class="min-w-0 flex-1">
358
- <div class="flex items-center gap-2">
359
- <span class="text-xs font-semibold italic text-slate-400">System</span>
360
- <span class="text-xs text-slate-400">{formatDate(message.created_at)}</span>
361
- </div>
362
- <p class="m-0 mt-1 text-sm italic text-slate-500">
363
- {stripHtml(message.content)}
364
- </p>
365
- </div>
366
- </div>
367
- {:else}
368
- <!-- Regular message -->
369
- <div class="flex gap-3 px-5 py-4">
370
- <div class={cn(
371
- "flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-bold",
372
- message.author_type === "reporter" ? "bg-blue-100 text-blue-700" : "bg-green-100 text-green-700"
373
- )} aria-hidden="true">
374
- {(AUTHOR_LABELS[message.author_type] || "?")[0]}
375
- </div>
376
- <div class="min-w-0 flex-1">
377
- <div class="flex items-center gap-2">
378
- <span class="text-xs font-semibold text-slate-900">{AUTHOR_LABELS[message.author_type] || message.author_type}</span>
379
- <span class="text-xs text-slate-400">{formatDate(message.created_at)}</span>
380
- </div>
381
- <div class={cn(
382
- "mt-1.5 rounded-xl px-4 py-3 text-sm leading-relaxed",
383
- message.author_type === "user" ? "bg-green-50 text-slate-800" : "bg-slate-50 text-slate-800"
384
- )}>
385
- <div class="vk-message-content">
386
- {@html sanitizeHtml(message.content)}
387
- </div>
388
- </div>
389
- {#if message.attachments && message.attachments.length > 0}
390
- <div class="mt-2 flex flex-wrap gap-1.5">
391
- {#each message.attachments as attachment (attachment.id)}
392
- <a
393
- class="inline-flex items-center gap-1.5 rounded-lg border border-slate-200 bg-slate-50 px-3 py-1.5 text-xs text-slate-600 no-underline transition-colors hover:bg-slate-100"
394
- href={attachment.url}
395
- rel="noopener noreferrer"
396
- target="_blank"
397
- >
398
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48" /></svg>
399
- {attachment.original_filename}
400
- </a>
401
- {/each}
402
- </div>
403
- {/if}
404
- </div>
405
- </div>
406
- {/if}
407
- {/each}
408
- </div>
409
- {/if}
410
- </div>
411
- </div>
412
- {/if}
413
- </div>
414
- </div>
415
-
416
- <style>
417
- :global(.vk-message-content h1,
418
- .vk-message-content h2,
419
- .vk-message-content h3,
420
- .vk-message-content h4,
421
- .vk-message-content h5,
422
- .vk-message-content h6) {
423
- margin: 1em 0 0.5em;
424
- line-height: 1.4;
425
- }
426
- :global(.vk-message-content p) {
427
- margin: 0.5em 0;
428
- }
429
- :global(.vk-message-content ul,
430
- .vk-message-content ol) {
431
- margin: 0.5em 0;
432
- padding-left: 1.5em;
433
- }
434
- :global(.vk-message-content a) {
435
- color: #2563eb;
436
- text-decoration: underline;
437
- }
438
- :global(.vk-message-content img) {
439
- max-width: 100%;
440
- border-radius: 0.5rem;
441
- }
442
- :global(.vk-message-content pre) {
443
- background: #f1f5f9;
444
- padding: 0.75em 1em;
445
- border-radius: 0.5rem;
446
- overflow-x: auto;
447
- font-size: 0.85em;
448
- }
449
- :global(.vk-message-content code) {
450
- background: #f1f5f9;
451
- padding: 0.15em 0.35em;
452
- border-radius: 0.25rem;
453
- font-size: 0.9em;
454
- }
455
- :global(.vk-message-content pre code) {
456
- background: none;
457
- padding: 0;
458
- }
459
- :global(.vk-message-content blockquote) {
460
- border-left: 3px solid #cbd5e1;
461
- margin: 0.5em 0;
462
- padding-left: 1em;
463
- color: #64748b;
464
- }
465
- </style>