Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

saved-replies.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

saved-replies.tsxBlame777 lines · 2 contributors
24cf2caClaude1/**
2 * Saved replies — per-user canned comment templates.
3 *
4 * Routes:
5 * GET /settings/replies list + create form
6 * POST /settings/replies create
7 * POST /settings/replies/:id/delete delete
8 * POST /settings/replies/:id update
9 * GET /api/user/replies JSON list for the insertion picker
7581253Claude10 *
11 * 2026 polish: scoped `.sr-*` styles, gradient-hairline hero + orb, card list
12 * with shortcut chip + preview + copy/delete actions, "new reply" form in its
13 * own card with focus rings + gradient submit. Empty state with orb + helpful
14 * CTA. Every form action, POST handler, and validation rule is preserved
15 * exactly — this is a pure visual refresh.
24cf2caClaude16 */
17
18import { Hono } from "hono";
19import { and, eq, asc } from "drizzle-orm";
20import { db } from "../db";
21import { savedReplies } from "../db/schema";
22import type { AuthEnv } from "../middleware/auth";
23import { requireAuth } from "../middleware/auth";
24import { Layout } from "../views/layout";
9b776daccantynz-alt25import { SettingsNav, settingsNavStyles } from "../views/components";
24cf2caClaude26
27const replies = new Hono<AuthEnv>();
28
29replies.use("/settings/replies", requireAuth);
30replies.use("/settings/replies/*", requireAuth);
31replies.use("/api/user/replies", requireAuth);
32
33function trimBounded(s: string, max: number): string {
34 const t = s.trim();
35 return t.length > max ? t.slice(0, max) : t;
36}
37
38async function listForUser(userId: string) {
39 try {
40 return await db
41 .select()
42 .from(savedReplies)
43 .where(eq(savedReplies.userId, userId))
44 .orderBy(asc(savedReplies.shortcut));
45 } catch (err) {
46 console.error("[saved-replies] list:", err);
47 return [];
48 }
49}
50
7581253Claude51// ─── Scoped CSS (.sr-*) ─────────────────────────────────────────────────────
52// Every selector prefixed `.sr-*` so this surface cannot bleed into any
53// other page. Mirrors the gradient-hairline hero + card patterns from
54// admin-integrations.tsx and settings-2fa.tsx.
55const srStyles = `
eed4684Claude56 .sr-wrap { max-width: 1320px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
7581253Claude57
58 /* ─── Hero ─── */
59 .sr-hero {
60 position: relative;
61 margin-bottom: var(--space-5);
62 padding: var(--space-5) var(--space-6);
63 background: var(--bg-elevated);
64 border: 1px solid var(--border);
65 border-radius: 16px;
66 overflow: hidden;
67 }
68 .sr-hero::before {
69 content: '';
70 position: absolute;
71 top: 0; left: 0; right: 0;
72 height: 2px;
6fd5915Claude73 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
7581253Claude74 opacity: 0.7;
75 pointer-events: none;
76 }
77 .sr-hero-orb {
78 position: absolute;
79 inset: -20% -10% auto auto;
80 width: 380px; height: 380px;
6fd5915Claude81 background: radial-gradient(circle, rgba(91,110,232,0.20), rgba(95,143,160,0.10) 45%, transparent 70%);
7581253Claude82 filter: blur(80px);
83 opacity: 0.7;
84 pointer-events: none;
85 z-index: 0;
86 }
87 .sr-hero-inner { position: relative; z-index: 1; max-width: 720px; }
88 .sr-eyebrow {
89 font-size: 12px;
90 color: var(--text-muted);
91 margin-bottom: var(--space-2);
92 letter-spacing: 0.02em;
93 display: inline-flex;
94 align-items: center;
95 gap: 8px;
96 text-transform: uppercase;
97 font-family: var(--font-mono);
98 font-weight: 600;
99 }
100 .sr-eyebrow-pill {
101 display: inline-flex;
102 align-items: center;
103 justify-content: center;
104 width: 18px; height: 18px;
105 border-radius: 6px;
6fd5915Claude106 background: rgba(91,110,232,0.14);
107 color: #5b6ee8;
108 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.35);
7581253Claude109 }
110 .sr-crumb { color: var(--text-muted); text-decoration: none; }
111 .sr-crumb:hover { color: var(--text); }
112 .sr-title {
113 font-size: clamp(28px, 4vw, 40px);
114 font-family: var(--font-display);
115 font-weight: 800;
116 letter-spacing: -0.028em;
117 line-height: 1.05;
118 margin: 0 0 var(--space-2);
119 color: var(--text-strong);
120 }
121 .sr-title-grad {
6fd5915Claude122 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
7581253Claude123 -webkit-background-clip: text;
124 background-clip: text;
125 -webkit-text-fill-color: transparent;
126 color: transparent;
127 }
128 .sr-sub {
129 font-size: 15px;
130 color: var(--text-muted);
131 margin: 0;
132 line-height: 1.55;
133 max-width: 620px;
134 }
135
136 /* ─── Banner ─── */
137 .sr-banner {
138 margin-bottom: var(--space-4);
139 padding: 10px 14px;
140 border-radius: 10px;
141 font-size: 13.5px;
142 border: 1px solid var(--border);
143 background: rgba(255,255,255,0.025);
144 color: var(--text);
145 display: flex;
146 align-items: center;
147 gap: 10px;
148 }
149 .sr-banner.is-ok {
150 border-color: rgba(52,211,153,0.40);
151 background: rgba(52,211,153,0.08);
152 color: #bbf7d0;
153 }
154 .sr-banner.is-error {
155 border-color: rgba(248,113,113,0.40);
156 background: rgba(248,113,113,0.08);
157 color: #fecaca;
158 }
159 .sr-banner-dot {
160 width: 8px; height: 8px;
161 border-radius: 9999px;
162 background: currentColor;
163 flex-shrink: 0;
164 }
165
166 /* ─── Section card ─── */
167 .sr-section {
168 margin-bottom: var(--space-5);
169 background: var(--bg-elevated);
170 border: 1px solid var(--border);
171 border-radius: 14px;
172 overflow: hidden;
173 }
174 .sr-section-head {
175 padding: var(--space-4) var(--space-5);
176 border-bottom: 1px solid var(--border);
177 }
178 .sr-section-title {
179 margin: 0;
180 font-family: var(--font-display);
181 font-size: 17px;
182 font-weight: 700;
183 letter-spacing: -0.018em;
184 color: var(--text-strong);
185 display: flex;
186 align-items: center;
187 gap: 10px;
188 }
189 .sr-section-title-icon {
190 display: inline-flex;
191 align-items: center;
192 justify-content: center;
193 width: 26px; height: 26px;
194 border-radius: 8px;
6fd5915Claude195 background: rgba(91,110,232,0.12);
196 color: #5b6ee8;
197 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.28);
7581253Claude198 flex-shrink: 0;
199 }
200 .sr-section-sub {
201 margin: 6px 0 0 36px;
202 font-size: 12.5px;
203 color: var(--text-muted);
204 line-height: 1.5;
205 }
206 .sr-section-body { padding: var(--space-4) var(--space-5); }
207
208 /* ─── Form fields ─── */
209 .sr-field { margin-bottom: var(--space-4); }
210 .sr-field:last-child { margin-bottom: 0; }
211 .sr-field label {
212 display: block;
213 margin-bottom: 6px;
214 font-family: var(--font-mono);
215 font-size: 11.5px;
216 font-weight: 700;
217 text-transform: uppercase;
218 letter-spacing: 0.12em;
219 color: var(--text-muted);
220 }
221 .sr-input,
222 .sr-textarea {
223 width: 100%;
224 padding: 10px 12px;
225 font-size: 14px;
226 color: var(--text);
227 background: var(--bg);
228 border: 1px solid var(--border-strong);
229 border-radius: 8px;
230 outline: none;
231 transition: border-color 120ms ease, box-shadow 120ms ease;
232 box-sizing: border-box;
233 font-family: inherit;
234 }
235 .sr-input {
236 font-family: var(--font-mono);
237 font-size: 13.5px;
238 }
239 .sr-textarea {
240 font-family: var(--font-mono);
241 font-size: 13px;
242 resize: vertical;
243 min-height: 96px;
244 }
245 .sr-input:focus,
246 .sr-textarea:focus {
6fd5915Claude247 border-color: var(--border-focus, rgba(91,110,232,0.55));
248 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
7581253Claude249 }
250 .sr-hint {
251 font-size: 11.5px;
252 color: var(--text-muted);
253 margin-top: 6px;
254 line-height: 1.45;
255 }
256
257 /* ─── Reply card list ─── */
258 .sr-list {
259 list-style: none;
260 margin: 0;
261 padding: 0;
262 display: flex;
263 flex-direction: column;
264 gap: var(--space-3);
265 }
266 .sr-card {
267 background: var(--bg-elevated);
268 border: 1px solid var(--border);
269 border-radius: 14px;
270 overflow: hidden;
271 transition: border-color 120ms ease, box-shadow 120ms ease;
272 }
273 .sr-card[open] {
6fd5915Claude274 border-color: rgba(91,110,232,0.32);
7581253Claude275 box-shadow: 0 8px 24px -10px rgba(0,0,0,0.32);
276 }
277 .sr-card-summary {
278 display: flex;
279 align-items: center;
280 gap: 12px;
281 padding: 14px 18px;
282 cursor: pointer;
283 list-style: none;
284 user-select: none;
285 }
286 .sr-card-summary::-webkit-details-marker { display: none; }
287 .sr-shortcut {
288 flex-shrink: 0;
289 display: inline-flex;
290 align-items: center;
291 gap: 6px;
292 padding: 4px 10px;
293 border-radius: 9999px;
6fd5915Claude294 background: linear-gradient(135deg, rgba(91,110,232,0.18), rgba(95,143,160,0.14));
295 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.32);
7581253Claude296 color: #e9d5ff;
297 font-family: var(--font-mono);
298 font-size: 12.5px;
299 font-weight: 700;
300 letter-spacing: -0.005em;
301 white-space: nowrap;
302 }
303 .sr-shortcut::before {
304 content: '/';
305 color: rgba(255,255,255,0.45);
306 margin-right: -2px;
307 }
308 .sr-preview {
309 flex: 1;
310 min-width: 0;
311 font-size: 13px;
312 color: var(--text-muted);
313 overflow: hidden;
314 text-overflow: ellipsis;
315 white-space: nowrap;
316 }
317 .sr-chev {
318 flex-shrink: 0;
319 color: var(--text-muted);
320 transition: transform 160ms ease;
321 }
322 .sr-card[open] .sr-chev { transform: rotate(90deg); }
323 .sr-card-body {
324 padding: var(--space-4) var(--space-5);
325 border-top: 1px solid var(--border);
326 background: rgba(255,255,255,0.012);
327 }
328
329 /* ─── Buttons ─── */
330 .sr-btn {
331 display: inline-flex;
332 align-items: center;
333 justify-content: center;
334 gap: 6px;
335 padding: 9px 16px;
336 border-radius: 10px;
337 font-size: 13px;
338 font-weight: 600;
339 text-decoration: none;
340 border: 1px solid transparent;
341 cursor: pointer;
342 font-family: inherit;
343 line-height: 1;
344 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease, color 120ms ease;
345 }
346 .sr-btn-primary {
6fd5915Claude347 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
7581253Claude348 color: #fff;
6fd5915Claude349 box-shadow: 0 6px 18px -4px rgba(91,110,232,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
7581253Claude350 }
351 .sr-btn-primary:hover {
352 transform: translateY(-1px);
6fd5915Claude353 box-shadow: 0 10px 24px -6px rgba(91,110,232,0.55), inset 0 1px 0 rgba(255,255,255,0.20);
7581253Claude354 color: #fff;
355 text-decoration: none;
356 }
357 .sr-btn-ghost {
358 background: rgba(255,255,255,0.025);
359 color: var(--text);
360 border-color: var(--border-strong);
361 }
362 .sr-btn-ghost:hover {
6fd5915Claude363 background: rgba(91,110,232,0.06);
364 border-color: rgba(91,110,232,0.45);
7581253Claude365 color: var(--text-strong);
366 text-decoration: none;
367 }
368 .sr-btn-danger {
369 background: transparent;
370 color: #fecaca;
371 border-color: rgba(248,113,113,0.40);
372 }
373 .sr-btn-danger:hover {
374 background: rgba(248,113,113,0.10);
375 border-color: rgba(248,113,113,0.65);
376 color: #fee2e2;
377 }
378 .sr-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: var(--space-3); }
379
380 /* ─── Empty state ─── */
381 .sr-empty {
382 position: relative;
383 padding: 56px 32px;
384 background: var(--bg-elevated);
385 border: 1px solid var(--border);
386 border-radius: 16px;
387 text-align: center;
388 overflow: hidden;
389 }
390 .sr-empty::before {
391 content: '';
392 position: absolute;
393 top: 0; left: 0; right: 0;
394 height: 2px;
6fd5915Claude395 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
7581253Claude396 opacity: 0.55;
397 pointer-events: none;
398 }
399 .sr-empty-orb {
400 width: 96px;
401 height: 96px;
402 margin: 0 auto 18px;
403 border-radius: 9999px;
404 background:
6fd5915Claude405 radial-gradient(circle at 35% 35%, rgba(91,110,232,0.55), rgba(95,143,160,0.25) 55%, transparent 75%);
7581253Claude406 box-shadow:
6fd5915Claude407 0 0 32px rgba(91,110,232,0.35),
408 inset 0 0 0 1px rgba(91,110,232,0.35);
7581253Claude409 display: flex;
410 align-items: center;
411 justify-content: center;
412 color: #fff;
413 }
414 .sr-empty-title {
415 font-family: var(--font-display);
416 font-size: 22px;
417 font-weight: 700;
418 letter-spacing: -0.018em;
419 color: var(--text-strong);
420 margin: 0 0 8px;
421 }
422 .sr-empty-sub {
423 font-size: 14.5px;
424 color: var(--text-muted);
425 line-height: 1.55;
426 margin: 0 auto 18px;
427 max-width: 460px;
428 }
429 .sr-count-pill {
430 display: inline-flex;
431 align-items: center;
432 gap: 6px;
433 padding: 3px 10px;
434 border-radius: 9999px;
435 background: rgba(255,255,255,0.04);
436 border: 1px solid var(--border);
437 font-size: 11.5px;
438 font-family: var(--font-mono);
439 color: var(--text-muted);
440 margin-left: 8px;
441 vertical-align: middle;
442 }
443`;
444
445const ReplyIcon = () => (
446 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
447 <polyline points="9 17 4 12 9 7" />
448 <path d="M20 18v-2a4 4 0 0 0-4-4H4" />
449 </svg>
450);
451
452const ChevIcon = () => (
453 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" class="sr-chev" aria-hidden="true">
454 <polyline points="9 18 15 12 9 6" />
455 </svg>
456);
457
458const EmptyIcon = () => (
459 <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
460 <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
461 </svg>
462);
463
24cf2caClaude464replies.get("/settings/replies", async (c) => {
465 const user = c.get("user")!;
466 const rows = await listForUser(user.id);
467 const error = c.req.query("error");
468 const success = c.req.query("success");
469
470 return c.html(
471 <Layout title="Saved replies" user={user}>
9b776daccantynz-alt472 <style dangerouslySetInnerHTML={{ __html: settingsNavStyles }} />
473 <div class="settings-shell" style="max-width:1500px;margin:0 auto;padding:var(--space-4)">
474 <SettingsNav active="replies" />
475 <div class="sr-wrap settings-content" style="max-width:none;margin:0;padding:0">
7581253Claude476 <section class="sr-hero">
477 <div class="sr-hero-orb" aria-hidden="true" />
478 <div class="sr-hero-inner">
479 <div class="sr-eyebrow">
480 <span class="sr-eyebrow-pill" aria-hidden="true">
481 <ReplyIcon />
482 </span>
483 <a href="/settings" class="sr-crumb">Settings</a>
484 <span>/</span>
485 <span>Saved replies</span>
486 </div>
487 <h2 class="sr-title">
488 <span class="sr-title-grad">Saved replies.</span>
489 </h2>
490 <p class="sr-sub">
491 Canned responses you can drop into any issue or PR comment with a
492 shortcut. The shortcut is a nickname only you ever see — pick
493 something fast to type.
494 </p>
495 </div>
496 </section>
24cf2caClaude497
7581253Claude498 {error && (
499 <div class="sr-banner is-error" role="alert">
500 <span class="sr-banner-dot" aria-hidden="true" />
501 {decodeURIComponent(error)}
24cf2caClaude502 </div>
7581253Claude503 )}
504 {success && (
505 <div class="sr-banner is-ok" role="status">
506 <span class="sr-banner-dot" aria-hidden="true" />
507 {decodeURIComponent(success)}
24cf2caClaude508 </div>
7581253Claude509 )}
510
511 {/* ─── Create form card ─── */}
512 <section class="sr-section">
513 <header class="sr-section-head">
514 <h3 class="sr-section-title">
515 <span class="sr-section-title-icon" aria-hidden="true">
516 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
517 <line x1="12" y1="5" x2="12" y2="19" />
518 <line x1="5" y1="12" x2="19" y2="12" />
519 </svg>
520 </span>
521 New saved reply
24cf2caClaude522 </h3>
7581253Claude523 <p class="sr-section-sub">
524 Pick a shortcut (a-z, 0-9, dashes work great) and write the body
525 once — reuse it everywhere.
526 </p>
527 </header>
528 <div class="sr-section-body">
529 <form method="post" action="/settings/replies">
530 <div class="sr-field">
531 <label for="shortcut">Shortcut</label>
532 <input
533 type="text"
534 id="shortcut"
535 name="shortcut"
536 required
537 maxLength={64}
538 placeholder="lgtm"
539 class="sr-input"
540 />
541 <div class="sr-hint">
542 Lowercase, dashes encouraged. Must be unique per account.
543 </div>
544 </div>
545 <div class="sr-field">
546 <label for="body">Reply body</label>
547 <textarea
548 id="body"
549 name="body"
550 rows={5}
551 required
552 maxLength={4096}
553 placeholder="LGTM! Thanks for the PR."
554 class="sr-textarea"
555 />
556 <div class="sr-hint">Markdown supported. Up to 4 096 characters.</div>
557 </div>
558 <button type="submit" class="sr-btn sr-btn-primary">
559 Add saved reply
560 </button>
561 </form>
562 </div>
563 </section>
564
565 {/* ─── List / empty state ─── */}
566 {rows.length === 0 ? (
567 <div class="sr-empty">
568 <div class="sr-empty-orb" aria-hidden="true">
569 <EmptyIcon />
24cf2caClaude570 </div>
7581253Claude571 <h2 class="sr-empty-title">No saved replies yet</h2>
572 <p class="sr-empty-sub">
573 Add a canned response above and it will show up here, ready to
574 insert into any issue or PR comment via the reply picker.
575 </p>
24cf2caClaude576 </div>
7581253Claude577 ) : (
578 <section class="sr-section">
579 <header class="sr-section-head">
580 <h3 class="sr-section-title">
581 <span class="sr-section-title-icon" aria-hidden="true">
582 <ReplyIcon />
583 </span>
584 Your replies
585 <span class="sr-count-pill">{rows.length}</span>
586 </h3>
587 <p class="sr-section-sub">
588 Click any reply to edit, copy the body, or delete it.
589 </p>
590 </header>
591 <div class="sr-section-body">
592 <ul class="sr-list">
593 {rows.map((r) => {
594 const preview = r.body.slice(0, 100).replace(/\n/g, " ");
595 const truncated = r.body.length > 100;
596 return (
597 <li>
598 <details class="sr-card">
599 <summary class="sr-card-summary">
600 <span class="sr-shortcut">{r.shortcut}</span>
601 <span class="sr-preview">
602 {preview}
603 {truncated ? "…" : ""}
604 </span>
605 <ChevIcon />
606 </summary>
607 <div class="sr-card-body">
608 <form method="post" action={`/settings/replies/${r.id}`}>
609 <div class="sr-field">
610 <label>Shortcut</label>
611 <input
612 type="text"
613 name="shortcut"
614 required
615 value={r.shortcut}
616 maxLength={64}
617 aria-label="Shortcut"
618 class="sr-input"
619 />
620 </div>
621 <div class="sr-field">
622 <label>Body</label>
623 <textarea
624 name="body"
625 rows={5}
626 required
627 maxLength={4096}
628 class="sr-textarea"
629 >
630 {r.body}
631 </textarea>
632 </div>
633 <div class="sr-actions">
634 <button type="submit" class="sr-btn sr-btn-primary">
635 Save changes
636 </button>
637 <button
638 type="button"
639 class="sr-btn sr-btn-ghost"
640 data-sr-copy={r.body}
641 title="Copy body to clipboard"
642 >
643 Copy body
644 </button>
645 <button
646 type="submit"
647 formaction={`/settings/replies/${r.id}/delete`}
648 class="sr-btn sr-btn-danger"
649 onclick="return confirm('Delete this saved reply?')"
650 >
651 Delete
652 </button>
653 </div>
654 </form>
655 </div>
656 </details>
657 </li>
658 );
659 })}
660 </ul>
661 </div>
662 </section>
24cf2caClaude663 )}
664 </div>
9b776daccantynz-alt665 </div>
7581253Claude666 <style dangerouslySetInnerHTML={{ __html: srStyles }} />
667 <script
668 dangerouslySetInnerHTML={{
669 __html: `
670 document.addEventListener('click', function (ev) {
671 var t = ev.target;
672 if (!(t instanceof HTMLElement)) return;
673 var btn = t.closest('[data-sr-copy]');
674 if (!btn) return;
675 ev.preventDefault();
676 var body = btn.getAttribute('data-sr-copy') || '';
677 if (navigator.clipboard && navigator.clipboard.writeText) {
678 navigator.clipboard.writeText(body).then(function () {
679 var prev = btn.textContent;
680 btn.textContent = 'Copied!';
681 setTimeout(function () { btn.textContent = prev; }, 1400);
682 });
683 }
684 });
685 `,
686 }}
687 />
24cf2caClaude688 </Layout>
689 );
690});
691
692replies.post("/settings/replies", async (c) => {
693 const user = c.get("user")!;
694 const body = await c.req.parseBody();
695 const shortcut = trimBounded(String(body.shortcut || ""), 64);
696 const text = trimBounded(String(body.body || ""), 4096);
697 if (!shortcut || !text) {
698 return c.redirect(
699 "/settings/replies?error=" + encodeURIComponent("Shortcut and body are required")
700 );
701 }
702 try {
703 await db.insert(savedReplies).values({
704 userId: user.id,
705 shortcut,
706 body: text,
707 });
708 } catch (err: any) {
709 if (String(err?.message || err).includes("saved_replies_user_shortcut")) {
710 return c.redirect(
711 "/settings/replies?error=" +
712 encodeURIComponent("You already have a reply with that shortcut")
713 );
714 }
715 console.error("[saved-replies] create:", err);
716 return c.redirect(
717 "/settings/replies?error=" + encodeURIComponent("Failed to save")
718 );
719 }
720 return c.redirect(
721 "/settings/replies?success=" + encodeURIComponent("Reply saved")
722 );
723});
724
725replies.post("/settings/replies/:id", async (c) => {
726 const user = c.get("user")!;
727 const id = c.req.param("id");
728 const body = await c.req.parseBody();
729 const shortcut = trimBounded(String(body.shortcut || ""), 64);
730 const text = trimBounded(String(body.body || ""), 4096);
731 if (!shortcut || !text) {
732 return c.redirect(
733 "/settings/replies?error=" + encodeURIComponent("Shortcut and body are required")
734 );
735 }
736 try {
737 await db
738 .update(savedReplies)
739 .set({ shortcut, body: text, updatedAt: new Date() })
740 .where(and(eq(savedReplies.id, id), eq(savedReplies.userId, user.id)));
741 } catch (err) {
742 console.error("[saved-replies] update:", err);
743 }
744 return c.redirect(
745 "/settings/replies?success=" + encodeURIComponent("Reply updated")
746 );
747});
748
749replies.post("/settings/replies/:id/delete", async (c) => {
750 const user = c.get("user")!;
751 const id = c.req.param("id");
752 try {
753 await db
754 .delete(savedReplies)
755 .where(and(eq(savedReplies.id, id), eq(savedReplies.userId, user.id)));
756 } catch (err) {
757 console.error("[saved-replies] delete:", err);
758 }
759 return c.redirect(
760 "/settings/replies?success=" + encodeURIComponent("Reply deleted")
761 );
762});
763
764replies.get("/api/user/replies", async (c) => {
765 const user = c.get("user")!;
766 const rows = await listForUser(user.id);
767 return c.json({
768 ok: true,
769 replies: rows.map((r) => ({
770 id: r.id,
771 shortcut: r.shortcut,
772 body: r.body,
773 })),
774 });
775});
776
777export default replies;