Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
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.tsxBlame242 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
10 */
11
12import { Hono } from "hono";
13import { and, eq, asc } from "drizzle-orm";
14import { db } from "../db";
15import { savedReplies } from "../db/schema";
16import type { AuthEnv } from "../middleware/auth";
17import { requireAuth } from "../middleware/auth";
18import { Layout } from "../views/layout";
19
20const replies = new Hono<AuthEnv>();
21
22replies.use("/settings/replies", requireAuth);
23replies.use("/settings/replies/*", requireAuth);
24replies.use("/api/user/replies", requireAuth);
25
26function trimBounded(s: string, max: number): string {
27 const t = s.trim();
28 return t.length > max ? t.slice(0, max) : t;
29}
30
31async function listForUser(userId: string) {
32 try {
33 return await db
34 .select()
35 .from(savedReplies)
36 .where(eq(savedReplies.userId, userId))
37 .orderBy(asc(savedReplies.shortcut));
38 } catch (err) {
39 console.error("[saved-replies] list:", err);
40 return [];
41 }
42}
43
44replies.get("/settings/replies", async (c) => {
45 const user = c.get("user")!;
46 const rows = await listForUser(user.id);
47 const error = c.req.query("error");
48 const success = c.req.query("success");
49
50 return c.html(
51 <Layout title="Saved replies" user={user}>
52 <div class="settings-container" style="max-width: 720px">
53 <h2>Saved replies</h2>
54 <p style="color: var(--text-muted); font-size: 14px; margin-bottom: 16px">
55 Canned responses you can insert into any issue or PR comment. The
56 shortcut is a nickname only you see.
57 </p>
58
59 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
60 {success && (
61 <div class="auth-success">{decodeURIComponent(success)}</div>
62 )}
63
64 <form method="POST" action="/settings/replies" style="margin-bottom: 24px">
65 <div class="form-group">
66 <label for="shortcut">Shortcut</label>
67 <input
68 type="text"
69 id="shortcut"
70 name="shortcut"
71 required
72 maxLength={64}
73 placeholder="lgtm"
74 />
75 </div>
76 <div class="form-group">
77 <label for="body">Reply body</label>
78 <textarea
79 id="body"
80 name="body"
81 rows={4}
82 required
83 maxLength={4096}
84 placeholder="LGTM! Thanks for the PR."
85 style="font-family: var(--font-mono); font-size: 13px"
86 />
87 </div>
88 <button type="submit" class="btn btn-primary">
89 Add saved reply
90 </button>
91 </form>
92
93 {rows.length > 0 && (
94 <div>
95 <h3 style="font-size: 16px; margin-bottom: 12px">
96 Your replies ({rows.length})
97 </h3>
98 <div class="saved-replies-list">
99 {rows.map((r) => (
100 <details class="saved-reply-item">
101 <summary>
102 <code>{r.shortcut}</code>
103 <span style="color: var(--text-muted); font-size: 12px; margin-left: 8px">
104 {r.body.slice(0, 80).replace(/\n/g, " ")}
105 {r.body.length > 80 ? "\u2026" : ""}
106 </span>
107 </summary>
108 <div style="padding: 12px 16px; background: var(--bg-secondary); border-top: 1px solid var(--border)">
109 <form method="POST" action={`/settings/replies/${r.id}`}>
110 <div class="form-group">
111 <label>Shortcut</label>
112 <input
113 type="text"
114 name="shortcut"
115 required
116 value={r.shortcut}
117 maxLength={64}
118 />
119 </div>
120 <div class="form-group">
121 <label>Body</label>
122 <textarea
123 name="body"
124 rows={4}
125 required
126 maxLength={4096}
127 style="font-family: var(--font-mono); font-size: 13px"
128 >
129 {r.body}
130 </textarea>
131 </div>
132 <div style="display: flex; gap: 8px">
133 <button type="submit" class="btn btn-primary">
134 Save
135 </button>
136 <button
137 type="submit"
138 formaction={`/settings/replies/${r.id}/delete`}
139 class="btn btn-danger"
140 onclick="return confirm('Delete this saved reply?')"
141 >
142 Delete
143 </button>
144 </div>
145 </form>
146 </div>
147 </details>
148 ))}
149 </div>
150 </div>
151 )}
152 </div>
153 </Layout>
154 );
155});
156
157replies.post("/settings/replies", async (c) => {
158 const user = c.get("user")!;
159 const body = await c.req.parseBody();
160 const shortcut = trimBounded(String(body.shortcut || ""), 64);
161 const text = trimBounded(String(body.body || ""), 4096);
162 if (!shortcut || !text) {
163 return c.redirect(
164 "/settings/replies?error=" + encodeURIComponent("Shortcut and body are required")
165 );
166 }
167 try {
168 await db.insert(savedReplies).values({
169 userId: user.id,
170 shortcut,
171 body: text,
172 });
173 } catch (err: any) {
174 if (String(err?.message || err).includes("saved_replies_user_shortcut")) {
175 return c.redirect(
176 "/settings/replies?error=" +
177 encodeURIComponent("You already have a reply with that shortcut")
178 );
179 }
180 console.error("[saved-replies] create:", err);
181 return c.redirect(
182 "/settings/replies?error=" + encodeURIComponent("Failed to save")
183 );
184 }
185 return c.redirect(
186 "/settings/replies?success=" + encodeURIComponent("Reply saved")
187 );
188});
189
190replies.post("/settings/replies/:id", async (c) => {
191 const user = c.get("user")!;
192 const id = c.req.param("id");
193 const body = await c.req.parseBody();
194 const shortcut = trimBounded(String(body.shortcut || ""), 64);
195 const text = trimBounded(String(body.body || ""), 4096);
196 if (!shortcut || !text) {
197 return c.redirect(
198 "/settings/replies?error=" + encodeURIComponent("Shortcut and body are required")
199 );
200 }
201 try {
202 await db
203 .update(savedReplies)
204 .set({ shortcut, body: text, updatedAt: new Date() })
205 .where(and(eq(savedReplies.id, id), eq(savedReplies.userId, user.id)));
206 } catch (err) {
207 console.error("[saved-replies] update:", err);
208 }
209 return c.redirect(
210 "/settings/replies?success=" + encodeURIComponent("Reply updated")
211 );
212});
213
214replies.post("/settings/replies/:id/delete", async (c) => {
215 const user = c.get("user")!;
216 const id = c.req.param("id");
217 try {
218 await db
219 .delete(savedReplies)
220 .where(and(eq(savedReplies.id, id), eq(savedReplies.userId, user.id)));
221 } catch (err) {
222 console.error("[saved-replies] delete:", err);
223 }
224 return c.redirect(
225 "/settings/replies?success=" + encodeURIComponent("Reply deleted")
226 );
227});
228
229replies.get("/api/user/replies", async (c) => {
230 const user = c.get("user")!;
231 const rows = await listForUser(user.id);
232 return c.json({
233 ok: true,
234 replies: rows.map((r) => ({
235 id: r.id,
236 shortcut: r.shortcut,
237 body: r.body,
238 })),
239 });
240});
241
242export default replies;