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