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.tsxBlame243 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
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
01ba63dClaude64 <form method="post" action="/settings/replies" style="margin-bottom: 24px">
24cf2caClaude65 <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)">
01ba63dClaude109 <form method="post" action={`/settings/replies/${r.id}`}>
24cf2caClaude110 <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}
e1c0fbecopilot-swe-agent[bot]118 aria-label="Shortcut"
24cf2caClaude119 />
120 </div>
121 <div class="form-group">
122 <label>Body</label>
123 <textarea
124 name="body"
125 rows={4}
126 required
127 maxLength={4096}
128 style="font-family: var(--font-mono); font-size: 13px"
129 >
130 {r.body}
131 </textarea>
132 </div>
133 <div style="display: flex; gap: 8px">
134 <button type="submit" class="btn btn-primary">
135 Save
136 </button>
137 <button
138 type="submit"
139 formaction={`/settings/replies/${r.id}/delete`}
140 class="btn btn-danger"
141 onclick="return confirm('Delete this saved reply?')"
142 >
143 Delete
144 </button>
145 </div>
146 </form>
147 </div>
148 </details>
149 ))}
150 </div>
151 </div>
152 )}
153 </div>
154 </Layout>
155 );
156});
157
158replies.post("/settings/replies", async (c) => {
159 const user = c.get("user")!;
160 const body = await c.req.parseBody();
161 const shortcut = trimBounded(String(body.shortcut || ""), 64);
162 const text = trimBounded(String(body.body || ""), 4096);
163 if (!shortcut || !text) {
164 return c.redirect(
165 "/settings/replies?error=" + encodeURIComponent("Shortcut and body are required")
166 );
167 }
168 try {
169 await db.insert(savedReplies).values({
170 userId: user.id,
171 shortcut,
172 body: text,
173 });
174 } catch (err: any) {
175 if (String(err?.message || err).includes("saved_replies_user_shortcut")) {
176 return c.redirect(
177 "/settings/replies?error=" +
178 encodeURIComponent("You already have a reply with that shortcut")
179 );
180 }
181 console.error("[saved-replies] create:", err);
182 return c.redirect(
183 "/settings/replies?error=" + encodeURIComponent("Failed to save")
184 );
185 }
186 return c.redirect(
187 "/settings/replies?success=" + encodeURIComponent("Reply saved")
188 );
189});
190
191replies.post("/settings/replies/:id", async (c) => {
192 const user = c.get("user")!;
193 const id = c.req.param("id");
194 const body = await c.req.parseBody();
195 const shortcut = trimBounded(String(body.shortcut || ""), 64);
196 const text = trimBounded(String(body.body || ""), 4096);
197 if (!shortcut || !text) {
198 return c.redirect(
199 "/settings/replies?error=" + encodeURIComponent("Shortcut and body are required")
200 );
201 }
202 try {
203 await db
204 .update(savedReplies)
205 .set({ shortcut, body: text, updatedAt: new Date() })
206 .where(and(eq(savedReplies.id, id), eq(savedReplies.userId, user.id)));
207 } catch (err) {
208 console.error("[saved-replies] update:", err);
209 }
210 return c.redirect(
211 "/settings/replies?success=" + encodeURIComponent("Reply updated")
212 );
213});
214
215replies.post("/settings/replies/:id/delete", async (c) => {
216 const user = c.get("user")!;
217 const id = c.req.param("id");
218 try {
219 await db
220 .delete(savedReplies)
221 .where(and(eq(savedReplies.id, id), eq(savedReplies.userId, user.id)));
222 } catch (err) {
223 console.error("[saved-replies] delete:", err);
224 }
225 return c.redirect(
226 "/settings/replies?success=" + encodeURIComponent("Reply deleted")
227 );
228});
229
230replies.get("/api/user/replies", async (c) => {
231 const user = c.get("user")!;
232 const rows = await listForUser(user.id);
233 return c.json({
234 ok: true,
235 replies: rows.map((r) => ({
236 id: r.id,
237 shortcut: r.shortcut,
238 body: r.body,
239 })),
240 });
241});
242
243export default replies;