@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.
- package/bin/create-vicket-support.js +429 -389
- package/package.json +1 -1
- package/templates/next/src/app/api/vicket/[...path]/route.ts +2 -55
- package/templates/next/src/app/components/vicket/ReplyForm.tsx +154 -0
- package/templates/next/src/app/components/vicket/SupportContent.tsx +298 -0
- package/templates/next/src/app/components/vicket/TicketDialog.tsx +3 -3
- package/templates/next/src/app/support/page.tsx +27 -353
- package/templates/next/src/app/ticket/page.tsx +110 -325
- package/templates/next/src/app/vicket.css +1325 -1325
- package/templates/nuxt/app/assets/css/vicket.css +1325 -1325
- package/templates/nuxt/app/components/VicketReplyForm.vue +154 -0
- package/templates/nuxt/app/components/VicketSupportContent.vue +255 -0
- package/templates/nuxt/app/components/VicketTicketDialog.vue +2 -2
- package/templates/nuxt/app/pages/support.vue +7 -293
- package/templates/nuxt/app/pages/ticket.vue +36 -178
- package/templates/nuxt/server/api/vicket/[...path].ts +2 -85
- package/templates/sveltekit/src/lib/vicket/ReplyForm.svelte +134 -0
- package/templates/sveltekit/src/lib/vicket/SupportContent.svelte +263 -0
- package/templates/sveltekit/src/lib/vicket/TicketDialog.svelte +457 -459
- package/templates/sveltekit/src/lib/vicket.css +1325 -1325
- package/templates/sveltekit/src/routes/api/vicket/[...path]/+server.ts +2 -76
- package/templates/sveltekit/src/routes/support/+page.server.ts +13 -0
- package/templates/sveltekit/src/routes/support/+page.svelte +3 -312
- package/templates/sveltekit/src/routes/ticket/+page.server.ts +19 -0
- package/templates/sveltekit/src/routes/ticket/+page.svelte +13 -188
- package/templates-tailwind/next/src/app/api/vicket/[...path]/route.ts +6 -0
- package/templates-tailwind/next/src/app/support/page.tsx +33 -3
- package/templates-tailwind/next/src/app/ticket/page.tsx +249 -6
- package/templates-tailwind/next/src/components/vicket/reply-form.tsx +113 -0
- package/templates-tailwind/next/src/components/vicket/support-content.tsx +265 -0
- package/templates-tailwind/next/src/components/vicket/ticket-dialog.tsx +2 -2
- package/templates-tailwind/nuxt/app/components/VicketReplyForm.vue +169 -0
- package/templates-tailwind/nuxt/app/components/{VicketSupportPage.vue → VicketSupportContent.vue} +275 -317
- package/templates-tailwind/nuxt/app/components/VicketTicketDialog.vue +3 -0
- package/templates-tailwind/nuxt/app/pages/support.vue +10 -1
- package/templates-tailwind/nuxt/app/pages/ticket.vue +298 -1
- package/templates-tailwind/nuxt/server/api/vicket/[...path].ts +2 -0
- package/templates-tailwind/sveltekit/src/lib/vicket/ReplyForm.svelte +127 -0
- package/templates-tailwind/sveltekit/src/lib/vicket/{SupportPage.svelte → SupportContent.svelte} +9 -71
- package/templates-tailwind/sveltekit/src/lib/vicket/TicketDialog.svelte +405 -406
- package/templates-tailwind/sveltekit/src/routes/api/vicket/[...path]/+server.ts +3 -0
- package/templates-tailwind/sveltekit/src/routes/support/+page.server.ts +13 -0
- package/templates-tailwind/sveltekit/src/routes/support/+page.svelte +4 -2
- package/templates-tailwind/sveltekit/src/routes/ticket/+page.server.ts +19 -0
- package/templates-tailwind/sveltekit/src/routes/ticket/+page.svelte +292 -2
- package/templates/next/src/app/utils/vicket/api.ts +0 -149
- package/templates/next/src/app/utils/vicket/types.ts +0 -85
- package/templates/next/src/app/utils/vicket/utils.ts +0 -49
- package/templates/nuxt/app/composables/useVicket.ts +0 -274
- package/templates/sveltekit/src/lib/vicket/api.ts +0 -162
- package/templates/sveltekit/src/lib/vicket/types.ts +0 -87
- package/templates/sveltekit/src/lib/vicket/utils.ts +0 -55
- package/templates-tailwind/next/src/app/api/vicket/init/route.ts +0 -24
- package/templates-tailwind/next/src/app/api/vicket/messages/route.ts +0 -36
- package/templates-tailwind/next/src/app/api/vicket/thread/route.ts +0 -27
- package/templates-tailwind/next/src/app/api/vicket/tickets/route.ts +0 -37
- package/templates-tailwind/next/src/components/vicket/support-page.tsx +0 -359
- package/templates-tailwind/next/src/components/vicket/ticket-page.tsx +0 -425
- package/templates-tailwind/next/src/lib/vicket.ts +0 -257
- package/templates-tailwind/nuxt/app/components/VicketTicketPage.vue +0 -449
- package/templates-tailwind/nuxt/app/composables/use-vicket.ts +0 -249
- package/templates-tailwind/nuxt/server/api/vicket/init.get.ts +0 -22
- package/templates-tailwind/nuxt/server/api/vicket/messages.post.ts +0 -56
- package/templates-tailwind/nuxt/server/api/vicket/thread.get.ts +0 -26
- package/templates-tailwind/nuxt/server/api/vicket/tickets.post.ts +0 -53
- package/templates-tailwind/sveltekit/src/lib/vicket/TicketPage.svelte +0 -465
- package/templates-tailwind/sveltekit/src/lib/vicket/index.ts +0 -257
- package/templates-tailwind/sveltekit/src/routes/api/vicket/init/+server.ts +0 -22
- package/templates-tailwind/sveltekit/src/routes/api/vicket/messages/+server.ts +0 -40
- package/templates-tailwind/sveltekit/src/routes/api/vicket/thread/+server.ts +0 -25
- package/templates-tailwind/sveltekit/src/routes/api/vicket/tickets/+server.ts +0 -37
|
@@ -1,5 +1,35 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { createServerClient } from "vicket/server";
|
|
2
|
+
import type { Template, Article, Faq } from "vicket";
|
|
3
|
+
import SupportContent from "@/components/vicket/support-content";
|
|
2
4
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
+
const vicket = createServerClient();
|
|
6
|
+
|
|
7
|
+
export default async function SupportPage() {
|
|
8
|
+
let templates: Template[] = [];
|
|
9
|
+
let articles: Article[] = [];
|
|
10
|
+
let faqs: Faq[] = [];
|
|
11
|
+
let websiteName = "Support";
|
|
12
|
+
let error = "";
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
const data = await vicket.fetchInit();
|
|
16
|
+
templates = data.templates || [];
|
|
17
|
+
articles = data.articles || [];
|
|
18
|
+
faqs = data.faqs || [];
|
|
19
|
+
websiteName = data.website?.name || "Support";
|
|
20
|
+
} catch (e) {
|
|
21
|
+
error = e instanceof Error ? e.message : "Failed to load support data.";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div className="min-h-screen bg-slate-50 font-[system-ui,-apple-system,sans-serif] antialiased">
|
|
26
|
+
<SupportContent
|
|
27
|
+
templates={templates}
|
|
28
|
+
articles={articles}
|
|
29
|
+
faqs={faqs}
|
|
30
|
+
websiteName={websiteName}
|
|
31
|
+
initialError={error}
|
|
32
|
+
/>
|
|
33
|
+
</div>
|
|
34
|
+
);
|
|
5
35
|
}
|
|
@@ -1,10 +1,253 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
1
|
+
import { createServerClient } from "vicket/server";
|
|
2
|
+
import { sanitizeHtml, formatDate, cn, isFileAnswer, formatAnswerText, AUTHOR_LABELS } from "vicket";
|
|
3
|
+
import type { Message } from "vicket";
|
|
4
|
+
import ReplyForm from "@/components/vicket/reply-form";
|
|
5
|
+
|
|
6
|
+
const vicket = createServerClient();
|
|
7
|
+
|
|
8
|
+
type Props = {
|
|
9
|
+
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export default async function TicketPage({ searchParams }: Props) {
|
|
13
|
+
const { token } = await searchParams;
|
|
14
|
+
const tokenStr = typeof token === "string" ? token : "";
|
|
15
|
+
|
|
16
|
+
let thread = null;
|
|
17
|
+
let error = "";
|
|
18
|
+
|
|
19
|
+
if (!tokenStr.trim()) {
|
|
20
|
+
error = "Missing ticket token in URL.";
|
|
21
|
+
} else {
|
|
22
|
+
try {
|
|
23
|
+
thread = await vicket.fetchThread(tokenStr);
|
|
24
|
+
} catch (e) {
|
|
25
|
+
error = e instanceof Error ? e.message : "Failed to load ticket.";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/* ── Derived data ──────────────────────────────── */
|
|
30
|
+
const firstReporterMessage = (() => {
|
|
31
|
+
if (!thread?.messages || thread.messages.length === 0) return null;
|
|
32
|
+
const sorted = [...thread.messages].sort(
|
|
33
|
+
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime(),
|
|
34
|
+
);
|
|
35
|
+
return sorted[0].author_type === "reporter" ? sorted[0] : null;
|
|
36
|
+
})();
|
|
37
|
+
|
|
38
|
+
const sortedMessages = (() => {
|
|
39
|
+
if (!thread?.messages) return [];
|
|
40
|
+
return [...thread.messages]
|
|
41
|
+
.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
|
|
42
|
+
.filter((m) => !firstReporterMessage || m.id !== firstReporterMessage.id);
|
|
43
|
+
})();
|
|
44
|
+
|
|
45
|
+
const summaryAnswers = (() => {
|
|
46
|
+
if (!thread?.answers) return [];
|
|
47
|
+
return thread.answers.filter((answer) => {
|
|
48
|
+
if (answer.attachments && answer.attachments.length > 0) return true;
|
|
49
|
+
if (answer.answer && answer.answer.trim().length > 0) return true;
|
|
50
|
+
return false;
|
|
51
|
+
});
|
|
52
|
+
})();
|
|
53
|
+
|
|
54
|
+
/* ── Tailwind classes for dangerouslySetInnerHTML content ── */
|
|
55
|
+
const htmlContentClasses = "[&_p]:mb-2 [&_p:last-child]:mb-0 [&_a]:text-blue-600 [&_a]:underline [&_ul]:my-1 [&_ul]:pl-6 [&_ol]:my-1 [&_ol]:pl-6 [&_pre]:overflow-x-auto [&_pre]:rounded-md [&_pre]:bg-slate-50 [&_pre]:p-3 [&_pre]:text-xs [&_code]:font-mono [&_code]:text-[0.85em]";
|
|
3
56
|
|
|
4
|
-
export default function Page() {
|
|
5
57
|
return (
|
|
6
|
-
<
|
|
7
|
-
<
|
|
8
|
-
|
|
58
|
+
<div className="min-h-screen bg-slate-50 font-[system-ui,-apple-system,sans-serif] antialiased">
|
|
59
|
+
<div className="mx-auto w-full max-w-3xl px-4 py-8">
|
|
60
|
+
{/* Back link */}
|
|
61
|
+
<div className="mb-6">
|
|
62
|
+
<a href="/support" className="inline-flex items-center gap-1.5 text-sm font-medium text-slate-500 no-underline transition-colors hover:text-slate-900">
|
|
63
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"><path d="m12 19-7-7 7-7" /><path d="M19 12H5" /></svg>
|
|
64
|
+
Back to support
|
|
65
|
+
</a>
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
{/* Error */}
|
|
69
|
+
{error && (
|
|
70
|
+
<div className="mb-4">
|
|
71
|
+
<div className="flex items-start gap-3 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-900" role="alert">
|
|
72
|
+
<span className="mt-0.5 shrink-0">
|
|
73
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="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>
|
|
74
|
+
</span>
|
|
75
|
+
<span className="flex-1">{error}</span>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
)}
|
|
79
|
+
|
|
80
|
+
{/* Thread content */}
|
|
81
|
+
{thread && (
|
|
82
|
+
<div className="space-y-6">
|
|
83
|
+
{/* Header */}
|
|
84
|
+
<div>
|
|
85
|
+
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
86
|
+
<h1 className="m-0 text-xl font-semibold tracking-tight text-slate-900 sm:text-2xl">{thread.title}</h1>
|
|
87
|
+
{thread.id && (
|
|
88
|
+
<span className="inline-flex items-center rounded-full border border-slate-200 bg-slate-50 px-2.5 py-0.5 text-xs font-semibold font-mono text-slate-500">
|
|
89
|
+
#{thread.id.slice(0, 8)}
|
|
90
|
+
</span>
|
|
91
|
+
)}
|
|
92
|
+
</div>
|
|
93
|
+
{((thread.status?.label && thread.status.label.toLowerCase() !== "open") || (thread.priority?.label && thread.priority.label.toLowerCase() !== "low")) && (
|
|
94
|
+
<div className="mt-2.5 flex flex-wrap items-center gap-2">
|
|
95
|
+
{thread.status?.label && thread.status.label.toLowerCase() !== "open" && (
|
|
96
|
+
<span className="inline-flex items-center rounded-full border border-blue-600/20 bg-blue-50 px-2.5 py-0.5 text-xs font-semibold text-blue-600">
|
|
97
|
+
{thread.status.label}
|
|
98
|
+
</span>
|
|
99
|
+
)}
|
|
100
|
+
{thread.priority?.label && thread.priority.label.toLowerCase() !== "low" && (
|
|
101
|
+
<span className="inline-flex items-center rounded-full border border-amber-200 bg-amber-50 px-2.5 py-0.5 text-xs font-semibold text-amber-700">
|
|
102
|
+
{thread.priority.label}
|
|
103
|
+
</span>
|
|
104
|
+
)}
|
|
105
|
+
</div>
|
|
106
|
+
)}
|
|
107
|
+
</div>
|
|
108
|
+
|
|
109
|
+
{/* Summary */}
|
|
110
|
+
{summaryAnswers.length > 0 && (
|
|
111
|
+
<div className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
|
112
|
+
<div className="flex items-center gap-2 border-b border-slate-200 px-5 py-3.5">
|
|
113
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" className="text-slate-500"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" /></svg>
|
|
114
|
+
<h2 className="m-0 text-sm font-semibold text-slate-900">Summary</h2>
|
|
115
|
+
</div>
|
|
116
|
+
<div className="space-y-3 p-5">
|
|
117
|
+
{summaryAnswers.map((answer) => (
|
|
118
|
+
<div key={answer.id} className="rounded-xl bg-slate-50/70 p-4">
|
|
119
|
+
<p className="mb-2.5 text-xs font-medium text-slate-500">{answer.question_label || "Question"}</p>
|
|
120
|
+
{answer.attachments && answer.attachments.length > 0 ? (
|
|
121
|
+
<div className="flex flex-wrap gap-1.5">
|
|
122
|
+
{answer.attachments.map((att) => (
|
|
123
|
+
<a key={att.id} href={att.url} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs text-slate-500 no-underline transition-colors hover:border-slate-300 hover:text-slate-900">
|
|
124
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="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>
|
|
125
|
+
{att.original_filename}
|
|
126
|
+
</a>
|
|
127
|
+
))}
|
|
128
|
+
</div>
|
|
129
|
+
) : isFileAnswer(answer.answer) ? (
|
|
130
|
+
<p className="text-sm italic text-slate-500">File uploaded</p>
|
|
131
|
+
) : (
|
|
132
|
+
<p className="whitespace-pre-wrap break-words rounded-md border border-slate-200/60 bg-white px-3 py-2 text-sm leading-relaxed text-slate-900">
|
|
133
|
+
{formatAnswerText(answer.answer) || "-"}
|
|
134
|
+
</p>
|
|
135
|
+
)}
|
|
136
|
+
</div>
|
|
137
|
+
))}
|
|
138
|
+
</div>
|
|
139
|
+
</div>
|
|
140
|
+
)}
|
|
141
|
+
|
|
142
|
+
{/* Description */}
|
|
143
|
+
{firstReporterMessage && (
|
|
144
|
+
<div className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
|
145
|
+
<div className="flex items-center gap-2 border-b border-slate-200 px-5 py-3.5">
|
|
146
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" className="text-slate-500"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" /></svg>
|
|
147
|
+
<h2 className="m-0 text-sm font-semibold text-slate-900">Description</h2>
|
|
148
|
+
</div>
|
|
149
|
+
<div className="p-5">
|
|
150
|
+
<div
|
|
151
|
+
className={cn("text-sm leading-relaxed text-slate-500", htmlContentClasses)}
|
|
152
|
+
dangerouslySetInnerHTML={{ __html: sanitizeHtml(firstReporterMessage.content) }}
|
|
153
|
+
/>
|
|
154
|
+
{firstReporterMessage.attachments && firstReporterMessage.attachments.length > 0 && (
|
|
155
|
+
<div className="mt-3 flex flex-wrap gap-1.5">
|
|
156
|
+
{firstReporterMessage.attachments.map((att) => (
|
|
157
|
+
<a key={att.id} href={att.url} target="_blank" rel="noopener noreferrer" className="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 no-underline transition-colors hover:border-slate-300 hover:text-slate-900">
|
|
158
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="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>
|
|
159
|
+
{att.original_filename}
|
|
160
|
+
</a>
|
|
161
|
+
))}
|
|
162
|
+
</div>
|
|
163
|
+
)}
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
)}
|
|
167
|
+
|
|
168
|
+
{/* Comments */}
|
|
169
|
+
<div className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
|
170
|
+
<div className="flex items-center gap-2 border-b border-slate-200 px-5 py-3.5">
|
|
171
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" className="text-slate-500"><path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z" /></svg>
|
|
172
|
+
<h2 className="m-0 text-sm font-semibold text-slate-900">Comments</h2>
|
|
173
|
+
</div>
|
|
174
|
+
|
|
175
|
+
{/* Reply form (only client component) */}
|
|
176
|
+
<ReplyForm token={tokenStr} />
|
|
177
|
+
|
|
178
|
+
{/* Messages */}
|
|
179
|
+
{sortedMessages.length === 0 ? (
|
|
180
|
+
<div className="px-5 py-12 text-center">
|
|
181
|
+
<div className="mx-auto mb-3 flex h-10 w-10 items-center justify-center rounded-full bg-slate-50 text-slate-500">
|
|
182
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"><path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z" /></svg>
|
|
183
|
+
</div>
|
|
184
|
+
<p className="m-0 text-sm text-slate-500">No messages yet. Be the first to reply!</p>
|
|
185
|
+
</div>
|
|
186
|
+
) : (
|
|
187
|
+
<div className="divide-y divide-slate-200">
|
|
188
|
+
{sortedMessages.map((message: Message) => {
|
|
189
|
+
/* System message */
|
|
190
|
+
if (message.author_type === "system") {
|
|
191
|
+
return (
|
|
192
|
+
<div key={message.id} className="flex gap-3 px-5 py-3.5">
|
|
193
|
+
<div className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-slate-400/20 text-[10px] font-bold leading-none text-slate-400" aria-hidden="true">
|
|
194
|
+
S
|
|
195
|
+
</div>
|
|
196
|
+
<div className="min-w-0 flex-1">
|
|
197
|
+
<div className="flex flex-wrap items-baseline gap-2">
|
|
198
|
+
<span className="text-xs font-medium text-slate-500">System</span>
|
|
199
|
+
<span className="text-xs text-slate-500/50">{formatDate(message.created_at)}</span>
|
|
200
|
+
</div>
|
|
201
|
+
<p className="mt-1 text-xs italic text-slate-500">{message.content.replace(/<[^>]*>/g, "")}</p>
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/* User / Reporter message */
|
|
208
|
+
const avatarColors =
|
|
209
|
+
message.author_type === "reporter"
|
|
210
|
+
? "bg-blue-600/15 text-blue-600"
|
|
211
|
+
: "bg-emerald-500/15 text-emerald-500";
|
|
212
|
+
|
|
213
|
+
return (
|
|
214
|
+
<div key={message.id} className="flex gap-3 px-5 py-3.5">
|
|
215
|
+
<div className={cn("mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-[10px] font-bold leading-none", avatarColors)} aria-hidden="true">
|
|
216
|
+
{(AUTHOR_LABELS[message.author_type] || "?")[0]}
|
|
217
|
+
</div>
|
|
218
|
+
<div className="min-w-0 flex-1">
|
|
219
|
+
<div className="flex flex-wrap items-baseline gap-2">
|
|
220
|
+
<span className="text-sm font-medium text-slate-900">{AUTHOR_LABELS[message.author_type] || message.author_type}</span>
|
|
221
|
+
<span className="text-xs text-slate-500/50">{formatDate(message.created_at)}</span>
|
|
222
|
+
</div>
|
|
223
|
+
<div
|
|
224
|
+
className={cn(
|
|
225
|
+
"mt-1.5 break-words rounded-xl px-4 py-3 text-sm leading-relaxed",
|
|
226
|
+
htmlContentClasses,
|
|
227
|
+
message.author_type === "user" ? "border border-blue-600/10 bg-blue-50 text-slate-800" : "border border-slate-200 bg-slate-50 text-slate-800",
|
|
228
|
+
)}
|
|
229
|
+
dangerouslySetInnerHTML={{ __html: sanitizeHtml(message.content) }}
|
|
230
|
+
/>
|
|
231
|
+
{message.attachments && message.attachments.length > 0 && (
|
|
232
|
+
<div className="mt-2 flex flex-wrap gap-1.5">
|
|
233
|
+
{message.attachments.map((att) => (
|
|
234
|
+
<a key={att.id} className="inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs text-slate-500 no-underline transition-all duration-150 hover:border-slate-300 hover:text-slate-900" href={att.url} rel="noopener noreferrer" target="_blank">
|
|
235
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="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>
|
|
236
|
+
{att.original_filename}
|
|
237
|
+
</a>
|
|
238
|
+
))}
|
|
239
|
+
</div>
|
|
240
|
+
)}
|
|
241
|
+
</div>
|
|
242
|
+
</div>
|
|
243
|
+
);
|
|
244
|
+
})}
|
|
245
|
+
</div>
|
|
246
|
+
)}
|
|
247
|
+
</div>
|
|
248
|
+
</div>
|
|
249
|
+
)}
|
|
250
|
+
</div>
|
|
251
|
+
</div>
|
|
9
252
|
);
|
|
10
253
|
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { type FormEvent, useState } from "react";
|
|
4
|
+
import { useRouter } from "next/navigation";
|
|
5
|
+
import { sendReply, cn } from "vicket";
|
|
6
|
+
|
|
7
|
+
export default function ReplyForm({ token }: { token: string }) {
|
|
8
|
+
const router = useRouter();
|
|
9
|
+
const [content, setContent] = useState("");
|
|
10
|
+
const [files, setFiles] = useState<File[]>([]);
|
|
11
|
+
const [isSending, setIsSending] = useState(false);
|
|
12
|
+
const [error, setError] = useState("");
|
|
13
|
+
const [success, setSuccess] = useState("");
|
|
14
|
+
|
|
15
|
+
const removeFile = (index: number) => {
|
|
16
|
+
setFiles((prev) => prev.filter((_, i) => i !== index));
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
|
20
|
+
event.preventDefault();
|
|
21
|
+
setError("");
|
|
22
|
+
setSuccess("");
|
|
23
|
+
if (!content.trim() && files.length === 0) { setError("Reply content is required."); return; }
|
|
24
|
+
setIsSending(true);
|
|
25
|
+
try {
|
|
26
|
+
await sendReply(token, content.trim(), files);
|
|
27
|
+
setContent("");
|
|
28
|
+
setFiles([]);
|
|
29
|
+
setSuccess("Reply sent.");
|
|
30
|
+
router.refresh();
|
|
31
|
+
} catch (e) {
|
|
32
|
+
setError(e instanceof Error ? e.message : "Unexpected error.");
|
|
33
|
+
} finally {
|
|
34
|
+
setIsSending(false);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div className="border-b border-slate-200 p-5">
|
|
40
|
+
{/* Error alert */}
|
|
41
|
+
{error && (
|
|
42
|
+
<div className="mb-3 flex items-start gap-3 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-900" role="alert">
|
|
43
|
+
<span className="mt-0.5 shrink-0">
|
|
44
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="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>
|
|
45
|
+
</span>
|
|
46
|
+
<span className="flex-1">{error}</span>
|
|
47
|
+
<button type="button" onClick={() => setError("")} className="shrink-0 cursor-pointer border-none bg-transparent p-0 opacity-50 transition-opacity hover:opacity-100" aria-label="Dismiss">
|
|
48
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6 6 18" /><path d="m6 6 12 12" /></svg>
|
|
49
|
+
</button>
|
|
50
|
+
</div>
|
|
51
|
+
)}
|
|
52
|
+
{/* Success alert */}
|
|
53
|
+
{success && (
|
|
54
|
+
<div className="mb-3 flex items-start gap-3 rounded-xl border border-green-200 bg-green-50 p-4 text-sm text-green-900" role="alert">
|
|
55
|
+
<span className="mt-0.5 shrink-0">
|
|
56
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6 9 17l-5-5" /></svg>
|
|
57
|
+
</span>
|
|
58
|
+
<span className="flex-1">{success}</span>
|
|
59
|
+
<button type="button" onClick={() => setSuccess("")} className="shrink-0 cursor-pointer border-none bg-transparent p-0 opacity-50 transition-opacity hover:opacity-100" aria-label="Dismiss">
|
|
60
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6 6 18" /><path d="m6 6 12 12" /></svg>
|
|
61
|
+
</button>
|
|
62
|
+
</div>
|
|
63
|
+
)}
|
|
64
|
+
|
|
65
|
+
<form className="space-y-3" onSubmit={onSubmit}>
|
|
66
|
+
<textarea
|
|
67
|
+
className="min-h-[80px] w-full resize-y rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5 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"
|
|
68
|
+
value={content}
|
|
69
|
+
onChange={(e) => setContent(e.target.value)}
|
|
70
|
+
placeholder="Write your reply..."
|
|
71
|
+
/>
|
|
72
|
+
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
73
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
74
|
+
<label className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-slate-200 bg-slate-50 px-3 py-1.5 text-xs font-medium text-slate-500 transition-colors hover:border-slate-300 hover:text-slate-900">
|
|
75
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="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>
|
|
76
|
+
Browse files
|
|
77
|
+
<input type="file" className="hidden" multiple accept="image/jpeg,image/png,image/gif,image/webp"
|
|
78
|
+
onChange={(e) => {
|
|
79
|
+
const ALLOWED = ["image/png","image/jpeg","image/gif","image/webp"];
|
|
80
|
+
const newFiles = Array.from(e.target.files || []).filter((f) => ALLOWED.includes(f.type));
|
|
81
|
+
setFiles((prev) => [...prev, ...newFiles]);
|
|
82
|
+
e.target.value = "";
|
|
83
|
+
}}
|
|
84
|
+
/>
|
|
85
|
+
</label>
|
|
86
|
+
{files.map((file, i) => (
|
|
87
|
+
<span key={`${file.name}-${i}`} className="inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1 text-xs text-slate-500">
|
|
88
|
+
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="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>
|
|
89
|
+
<span className="max-w-[120px] truncate">{file.name}</span>
|
|
90
|
+
<button type="button" onClick={() => removeFile(i)} className="flex cursor-pointer items-center border-none bg-transparent p-0 text-slate-500 transition-colors hover:text-red-600" aria-label={`Remove ${file.name}`}>
|
|
91
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6 6 18" /><path d="m6 6 12 12" /></svg>
|
|
92
|
+
</button>
|
|
93
|
+
</span>
|
|
94
|
+
))}
|
|
95
|
+
</div>
|
|
96
|
+
<button className="inline-flex items-center gap-2 rounded-lg border-none bg-blue-600 px-5 py-2.5 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" disabled={isSending} type="submit">
|
|
97
|
+
{isSending ? (
|
|
98
|
+
<>
|
|
99
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" className="animate-spin"><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg>
|
|
100
|
+
Sending...
|
|
101
|
+
</>
|
|
102
|
+
) : (
|
|
103
|
+
<>
|
|
104
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="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>
|
|
105
|
+
Send
|
|
106
|
+
</>
|
|
107
|
+
)}
|
|
108
|
+
</button>
|
|
109
|
+
</div>
|
|
110
|
+
</form>
|
|
111
|
+
</div>
|
|
112
|
+
);
|
|
113
|
+
}
|