Commitf5b9ef5unknown_key
feat(platform): wider layout, clean nav, Claude integration, secrets panel, no-cache, zero TS errors
feat(platform): wider layout, clean nav, Claude integration, secrets panel, no-cache, zero TS errors Layout (layout.tsx): - Widen all containers from 1240px → 1440px (nav, main, footer) - Replace crowded top-level nav links with a clean user dropdown (Dashboard, Pulls, Issues, Activity, Import, Profile, Settings, Tokens, Theme toggle, Sign out) + bell inbox icon with unread badge - User avatar initials pill with gradient background Claude Code integration (routes/claude-integration.ts + routes/connect.tsx): - POST /api/claude/connect — Bearer-token auth, auto-creates repos on first call, returns gitRemote + mcpUrl for zero-config setup - GET /api/claude/connect — connection info for existing repos - POST /api/claude/session — no-auth telemetry endpoint, logs to activity_feed - GET /connect/claude-guide — onboarding page with 4-step guide + "Why Gluecron" grid No-cache middleware (middleware/no-cache.ts): - Stamps no-store/no-cache/must-revalidate on all text/html responses - Assets and git pack transfers unaffected Secrets panel (workflow-secrets.tsx): - wsecTestName client script: real-time ^[A-Z_][A-Z0-9_]*$ validation on name field - Test button with .is-ok/.is-error feedback and inline hint text - Workflow usage count badge per secret Issue/PR sort controls: - ?sort=newest|oldest|updated on both issues and PR list pages - Sort control links rendered below search form Pre-existing TS errors fixed (codebase now 0 errors): - diff-view.tsx: "POST" → "post" on form method attribute - ssh-server.ts: typed ssh2 callback params; @ts-expect-error on untyped import - editor.test.ts: double-cast via unknown for Hono type mismatch https://claude.ai/code/session_01ACsT2Pc8GRoZwZRF8SK68Y
11 files changed+1474−125f5b9ef5f691035912df24bf438415c06a443cd38
11 changed files+1474−125
Modifiedsrc/__tests__/editor.test.ts+3−3View fileUnifiedSplit
@@ -22,13 +22,13 @@ describe("editor module", () => {
2222 // Hono routers expose .routes and .fetch
2323 expect(typeof router).toBe("object");
2424 expect(router).not.toBeNull();
25 expect(typeof (router as Hono).fetch).toBe("function");
26 expect(Array.isArray((router as Hono).routes)).toBe(true);
25 expect(typeof (router as unknown as Hono).fetch).toBe("function");
26 expect(Array.isArray((router as unknown as Hono).routes)).toBe(true);
2727 });
2828
2929 it("registers at least one route for /:owner/:repo/edit/:ref", async () => {
3030 const mod = await import("../routes/editor");
31 const router = mod.default as Hono;
31 const router = mod.default as unknown as Hono;
3232 const editRoutes = router.routes.filter(
3333 (r) => r.path.includes("/edit/") || r.path.includes("edit")
3434 );
Modifiedsrc/app.tsx+12−0View fileUnifiedSplit
@@ -133,6 +133,8 @@ import installRoutes from "./routes/install";
133133import dxtRoutes from "./routes/dxt";
134134import connectClaudeRoutes from "./routes/connect-claude";
135135import claudeDeployRoutes from "./routes/claude-deploy";
136import claudeIntegration from "./routes/claude-integration";
137import connectRoutes from "./routes/connect";
136138import releasesRoutes from "./routes/releases";
137139import requiredChecksRoutes from "./routes/required-checks";
138140import rulesetsRoutes from "./routes/rulesets";
@@ -157,6 +159,7 @@ import voiceRoutes from "./routes/voice-to-pr";
157159import playgroundRoutes from "./routes/playground";
158160import { authRateLimit, gitRateLimit, searchRateLimit } from "./middleware/rate-limit";
159161import { csrfToken, csrfProtect } from "./middleware/csrf";
162import { noCache } from "./middleware/no-cache";
160163
161164import type { AuthEnv } from "./middleware/auth";
162165import { softAuth } from "./middleware/auth";
@@ -332,6 +335,11 @@ app.use("/:owner/:repo.git/*", gitRateLimit);
332335app.use("/:owner/:repo/search", searchRateLimit);
333336app.use("/explore", searchRateLimit);
334337
338// No-cache for HTML — ensures browsers and proxies never serve stale pages.
339// The middleware only stamps text/html responses so static assets keep
340// their own cache policies unchanged.
341app.use("*", noCache);
342
335343// Git Smart HTTP protocol routes (must be before web routes)
336344app.route("/", gitRoutes);
337345
@@ -612,6 +620,10 @@ app.route("/", dxtRoutes);
612620// Connect Claude — user-facing one-click MCP setup (/connect/claude). Mounted
613621// next to the other one-click flows (install.sh + .dxt) for surface symmetry.
614622app.route("/", connectClaudeRoutes);
623// Claude Code Integration Receiver — /api/claude/connect + /api/claude/session.
624app.route("/", claudeIntegration);
625// Connect guide — public onboarding page (/connect/claude-guide).
626app.route("/", connectRoutes);
615627// Hosted Claude tool-use loops — paste loop, get endpoint, billing meter.
616628// See src/routes/claude-deploy.tsx + src/lib/hosted-claude-loop.ts.
617629app.route("/", claudeDeployRoutes);
Modifiedsrc/lib/ssh-server.ts+9−5View fileUnifiedSplit
@@ -29,7 +29,11 @@
2929 * fine for dev but triggers "host key changed" on restart in prod).
3030 */
3131
32// ssh2 ships no TypeScript declarations; suppress until @types/ssh2 is available.
33// eslint-disable-next-line @typescript-eslint/ban-ts-comment
34// @ts-expect-error no types
3235import { Server as SshServer, utils as sshUtils } from "ssh2";
36// @ts-expect-error no types
3337import type { AuthContext, Connection, ServerChannel } from "ssh2";
3438import { spawn } from "child_process";
3539import { generateKeyPairSync } from "crypto";
@@ -458,10 +462,10 @@ export function createSshServer(): ReturnType<typeof SshServer> {
458462 });
459463
460464 client.on("ready", () => {
461 client.on("session", (accept) => {
465 client.on("session", (accept: () => ServerChannel) => {
462466 const session = accept();
463467
464 session.on("exec", async (accept, _reject, info) => {
468 session.on("exec", async (accept: () => ServerChannel, _reject: () => void, info: { command: string }) => {
465469 const parsed = parseGitCommand(info.command);
466470 const channel = accept();
467471
@@ -487,7 +491,7 @@ export function createSshServer(): ReturnType<typeof SshServer> {
487491 });
488492
489493 // Interactive shell — reject gracefully
490 session.on("shell", (accept) => {
494 session.on("shell", (accept: () => ServerChannel) => {
491495 const channel = accept();
492496 channel.write(
493497 "Hi there! Gluecron is a git-only service. Shell access is not available.\r\n"
@@ -501,7 +505,7 @@ export function createSshServer(): ReturnType<typeof SshServer> {
501505 });
502506 });
503507
504 client.on("error", (err) => {
508 client.on("error", (err: Error) => {
505509 if (process.env.SSH_DEBUG) {
506510 console.debug("[ssh] client error:", err.message);
507511 }
@@ -509,7 +513,7 @@ export function createSshServer(): ReturnType<typeof SshServer> {
509513 }
510514 );
511515
512 server.on("error", (err) => {
516 server.on("error", (err: Error) => {
513517 console.error("[ssh] server error:", err);
514518 });
515519
Addedsrc/middleware/no-cache.ts+14−0View fileUnifiedSplit
@@ -0,0 +1,14 @@
1import type { MiddlewareHandler } from "hono";
2
3// Applied to HTML routes that must never be served stale.
4// CSS/JS/git pack files are NOT affected by this middleware.
5export const noCache: MiddlewareHandler = async (c, next) => {
6 await next();
7 const ct = c.res.headers.get("Content-Type") || "";
8 // Only add no-cache to HTML responses — don't break asset caching
9 if (ct.includes("text/html")) {
10 c.res.headers.set("Cache-Control", "no-store, no-cache, must-revalidate");
11 c.res.headers.set("Pragma", "no-cache");
12 c.res.headers.set("Vary", "Cookie");
13 }
14};
Addedsrc/routes/claude-integration.ts+324−0View fileUnifiedSplit
@@ -0,0 +1,324 @@
1/**
2 * Claude Code Integration Receiver
3 *
4 * Lets any Claude Code session or repository report into Gluecron with zero
5 * config. Bearer token authenticated against api_tokens (SHA-256 hash).
6 *
7 * Routes
8 * POST /api/claude/connect — validate token, auto-create repo, return git remote + MCP URL
9 * GET /api/claude/connect — same auth, return existing connection info
10 * POST /api/claude/session — fire-and-forget session telemetry (no auth required)
11 */
12
13import { Hono } from "hono";
14import { eq, and } from "drizzle-orm";
15import { createHash } from "crypto";
16import { db } from "../db";
17import { users, repositories, activityFeed, apiTokens } from "../db/schema";
18import { initBareRepo } from "../git/repository";
19import { config } from "../lib/config";
20import type { AuthEnv } from "../middleware/auth";
21
22const claudeIntegration = new Hono<AuthEnv>();
23
24// ─── Auth helper ────────────────────────────────────────────────────────────
25
26function sha256hex(value: string): string {
27 return createHash("sha256").update(value).digest("hex");
28}
29
30/**
31 * Extract + validate Bearer token. Returns { user, token } on success,
32 * or { error } if the token is missing / invalid.
33 */
34async function authenticateBearer(
35 authHeader: string | undefined
36): Promise<
37 | { ok: true; user: typeof users.$inferSelect; tokenRow: typeof apiTokens.$inferSelect }
38 | { ok: false; error: string; status: 401 }
39> {
40 if (!authHeader?.startsWith("Bearer ")) {
41 return { ok: false, error: "Missing or malformed Authorization header. Use: Bearer <token>", status: 401 };
42 }
43
44 const raw = authHeader.slice(7).trim();
45 if (!raw) {
46 return { ok: false, error: "Empty bearer token", status: 401 };
47 }
48
49 const tokenHash = sha256hex(raw);
50
51 try {
52 const [tokenRow] = await db
53 .select()
54 .from(apiTokens)
55 .where(eq(apiTokens.tokenHash, tokenHash))
56 .limit(1);
57
58 if (!tokenRow) {
59 return { ok: false, error: "Invalid API token", status: 401 };
60 }
61
62 if (tokenRow.expiresAt && new Date(tokenRow.expiresAt) < new Date()) {
63 return { ok: false, error: "API token has expired", status: 401 };
64 }
65
66 const [user] = await db
67 .select()
68 .from(users)
69 .where(eq(users.id, tokenRow.userId))
70 .limit(1);
71
72 if (!user) {
73 return { ok: false, error: "Token owner not found", status: 401 };
74 }
75
76 // Touch last-used timestamp (best effort — no await)
77 db.update(apiTokens)
78 .set({ lastUsedAt: new Date() })
79 .where(eq(apiTokens.id, tokenRow.id))
80 .catch(() => {});
81
82 return { ok: true, user, tokenRow };
83 } catch (err) {
84 return { ok: false, error: "Authentication failed", status: 401 };
85 }
86}
87
88// ─── POST /api/claude/connect ───────────────────────────────────────────────
89
90claudeIntegration.post("/api/claude/connect", async (c) => {
91 const auth = await authenticateBearer(c.req.header("Authorization"));
92 if (!auth.ok) {
93 return c.json({ ok: false, error: auth.error }, auth.status);
94 }
95
96 const { user } = auth;
97
98 let body: { username?: string; repoName?: string; description?: string };
99 try {
100 body = await c.req.json();
101 } catch {
102 return c.json({ ok: false, error: "Invalid JSON body" }, 400);
103 }
104
105 // repoName is optional — if omitted, return basic connection info without a repo
106 const repoName = body.repoName?.trim();
107 const description = body.description?.trim() || null;
108
109 try {
110 const baseUrl = config.appBaseUrl;
111
112 if (!repoName) {
113 // No repo requested — just confirm the token is valid
114 return c.json({
115 ok: true,
116 username: user.username,
117 mcpUrl: `${baseUrl}/mcp`,
118 message: "Token valid. Provide repoName to auto-create a repository.",
119 });
120 }
121
122 // Validate repo name
123 if (!/^[a-zA-Z0-9._-]+$/.test(repoName)) {
124 return c.json({ ok: false, error: "Invalid repository name. Use letters, digits, hyphens, dots, or underscores." }, 400);
125 }
126
127 // Check if repo already exists
128 const [existing] = await db
129 .select()
130 .from(repositories)
131 .where(and(eq(repositories.ownerId, user.id), eq(repositories.name, repoName)))
132 .limit(1);
133
134 if (existing) {
135 const gitRemote = `${baseUrl}/${user.username}/${repoName}.git`;
136 const mcpUrl = `${baseUrl}/mcp`;
137 return c.json({
138 ok: true,
139 created: false,
140 gitRemote,
141 mcpUrl,
142 repoId: existing.id,
143 message: "Repository already exists.",
144 });
145 }
146
147 // Auto-create bare repo on disk
148 const diskPath = await initBareRepo(user.username, repoName);
149
150 // Insert into repositories table
151 const [repo] = await db
152 .insert(repositories)
153 .values({
154 name: repoName,
155 ownerId: user.id,
156 description,
157 isPrivate: false,
158 diskPath,
159 })
160 .returning();
161
162 if (!repo) {
163 return c.json({ ok: false, error: "Failed to create repository record" }, 500);
164 }
165
166 // Log to activity feed
167 await db.insert(activityFeed).values({
168 repositoryId: repo.id,
169 userId: user.id,
170 action: "repo_created",
171 targetType: "repository",
172 targetId: repo.id,
173 metadata: JSON.stringify({ source: "claude_connect", via: "api" }),
174 }).catch(() => {});
175
176 const gitRemote = `${baseUrl}/${user.username}/${repoName}.git`;
177 const mcpUrl = `${baseUrl}/mcp`;
178
179 return c.json({
180 ok: true,
181 created: true,
182 gitRemote,
183 mcpUrl,
184 repoId: repo.id,
185 message: `Repository '${repoName}' created. Push with: git push gluecron main`,
186 });
187 } catch (err) {
188 const msg = err instanceof Error ? err.message : String(err);
189 return c.json({ ok: false, error: `Server error: ${msg}` }, 500);
190 }
191});
192
193// ─── GET /api/claude/connect ────────────────────────────────────────────────
194
195claudeIntegration.get("/api/claude/connect", async (c) => {
196 const auth = await authenticateBearer(c.req.header("Authorization"));
197 if (!auth.ok) {
198 return c.json({ ok: false, error: auth.error }, auth.status);
199 }
200
201 const { user } = auth;
202 const repoName = c.req.query("repo");
203
204 try {
205 const baseUrl = config.appBaseUrl;
206
207 if (!repoName) {
208 // List all repos for this user
209 const repos = await db
210 .select({ id: repositories.id, name: repositories.name, description: repositories.description, createdAt: repositories.createdAt })
211 .from(repositories)
212 .where(eq(repositories.ownerId, user.id));
213
214 return c.json({
215 ok: true,
216 username: user.username,
217 mcpUrl: `${baseUrl}/mcp`,
218 repos: repos.map((r) => ({
219 ...r,
220 gitRemote: `${baseUrl}/${user.username}/${r.name}.git`,
221 })),
222 });
223 }
224
225 const [repo] = await db
226 .select()
227 .from(repositories)
228 .where(and(eq(repositories.ownerId, user.id), eq(repositories.name, repoName)))
229 .limit(1);
230
231 if (!repo) {
232 return c.json({ ok: false, error: `Repository '${repoName}' not found` }, 404);
233 }
234
235 const gitRemote = `${baseUrl}/${user.username}/${repoName}.git`;
236 const mcpUrl = `${baseUrl}/mcp`;
237
238 return c.json({
239 ok: true,
240 username: user.username,
241 repoId: repo.id,
242 repoName: repo.name,
243 gitRemote,
244 mcpUrl,
245 });
246 } catch (err) {
247 const msg = err instanceof Error ? err.message : String(err);
248 return c.json({ ok: false, error: `Server error: ${msg}` }, 500);
249 }
250});
251
252// ─── POST /api/claude/session ────────────────────────────────────────────────
253// Fire-and-forget telemetry. No auth required — sessions post to this.
254
255claudeIntegration.post("/api/claude/session", async (c) => {
256 let body: {
257 sessionId?: string;
258 repoName?: string;
259 event?: "start" | "push" | "issue" | "pr";
260 payload?: unknown;
261 };
262
263 try {
264 body = await c.req.json();
265 } catch {
266 return c.json({ ok: false, error: "Invalid JSON body" }, 400);
267 }
268
269 const { sessionId, repoName, event, payload } = body;
270
271 if (!event || !["start", "push", "issue", "pr"].includes(event)) {
272 return c.json({ ok: false, error: "event must be one of: start, push, issue, pr" }, 400);
273 }
274
275 // Best-effort: look up the repo if repoName is provided (need owner resolution via query param or body)
276 try {
277 let repositoryId: string | null = null;
278 let userId: string | null = null;
279
280 if (repoName) {
281 // Try to find via owner in payload or query string
282 const ownerHint =
283 (payload && typeof payload === "object" && "owner" in payload
284 ? (payload as Record<string, unknown>).owner
285 : undefined) as string | undefined;
286
287 if (ownerHint) {
288 const [ownerRow] = await db
289 .select({ id: users.id })
290 .from(users)
291 .where(eq(users.username, ownerHint))
292 .limit(1);
293
294 if (ownerRow) {
295 userId = ownerRow.id;
296 const [repo] = await db
297 .select({ id: repositories.id })
298 .from(repositories)
299 .where(and(eq(repositories.ownerId, ownerRow.id), eq(repositories.name, repoName)))
300 .limit(1);
301 if (repo) repositoryId = repo.id;
302 }
303 }
304 }
305
306 if (repositoryId) {
307 await db.insert(activityFeed).values({
308 repositoryId,
309 userId: userId ?? undefined,
310 action: "claude_session",
311 targetType: "session",
312 targetId: sessionId ?? null,
313 metadata: JSON.stringify({ sessionId, event, repoName, payload }),
314 });
315 }
316 // If no repo found, silently swallow (fire-and-forget)
317 } catch {
318 // Intentionally swallowed — telemetry must not block callers
319 }
320
321 return c.json({ ok: true });
322});
323
324export default claudeIntegration;
Addedsrc/routes/connect.tsx+517−0View fileUnifiedSplit
@@ -0,0 +1,517 @@
1/**
2 * Connect Page — /connect/claude onboarding for Claude Code sessions.
3 *
4 * Routes
5 * GET /connect/claude — Beautiful onboarding page. If the user is logged
6 * in, their username is pre-filled in code snippets.
7 *
8 * NOTE: The existing /connect/claude route in connect-claude.tsx is
9 * auth-gated (requireAuth). This file intentionally lives at the same path
10 * but is mounted AFTER connect-claude.tsx in app.tsx — it will never win the
11 * route match while connect-claude.tsx is registered first. That's by design:
12 * logged-in users hit the rich interactive page; unauthenticated visitors
13 * should instead be redirected to login by connect-claude.tsx's requireAuth.
14 *
15 * This file provides a standalone public-friendly version of the onboarding
16 * guide at a slightly different path to avoid any conflict:
17 * GET /connect/claude-guide — public, no auth required
18 *
19 * Both views reference the same instructions; the guide version simply shows
20 * USERNAME as a placeholder when no session is present.
21 */
22
23import { Hono } from "hono";
24import { Layout } from "../views/layout";
25import { softAuth } from "../middleware/auth";
26import type { AuthEnv } from "../middleware/auth";
27import { config } from "../lib/config";
28
29const connectRoutes = new Hono<AuthEnv>();
30
31// Apply softAuth so we can read the user (if any) for pre-filling snippets.
32connectRoutes.use("/connect/claude-guide", softAuth);
33
34// ─── Scoped CSS ─────────────────────────────────────────────────────────────
35const styles = `
36 /* All classes prefixed with .cg- (claude-guide) to avoid bleed */
37 .cg-wrap {
38 max-width: 860px;
39 margin: 0 auto;
40 padding: var(--space-5) var(--space-4) var(--space-6);
41 }
42
43 /* ─── Hero ─── */
44 .cg-hero {
45 position: relative;
46 margin-bottom: var(--space-6);
47 padding: var(--space-5) var(--space-6);
48 background: var(--bg-elevated);
49 border: 1px solid var(--border);
50 border-radius: 18px;
51 overflow: hidden;
52 }
53 .cg-hero::before {
54 content: '';
55 position: absolute;
56 top: 0; left: 0; right: 0;
57 height: 2px;
58 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
59 pointer-events: none;
60 }
61 .cg-hero-orb {
62 position: absolute;
63 inset: -20% -5% auto auto;
64 width: 420px; height: 420px;
65 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
66 filter: blur(80px);
67 opacity: 0.6;
68 pointer-events: none;
69 animation: cgOrbPulse 18s ease-in-out infinite;
70 }
71 @keyframes cgOrbPulse {
72 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.5; }
73 50% { transform: scale(1.1) translate(-10px, 6px); opacity: 0.8; }
74 }
75 @media (prefers-reduced-motion: reduce) {
76 .cg-hero-orb { animation: none; }
77 }
78 .cg-hero-inner {
79 position: relative;
80 z-index: 1;
81 max-width: 640px;
82 }
83 .cg-hero-tag {
84 display: inline-block;
85 padding: 3px 10px;
86 border-radius: 999px;
87 border: 1px solid rgba(140,109,255,0.35);
88 background: rgba(140,109,255,0.10);
89 color: #c5b3ff;
90 font-size: 12px;
91 font-weight: 600;
92 letter-spacing: 0.04em;
93 text-transform: uppercase;
94 margin-bottom: var(--space-3);
95 }
96 .cg-hero-title {
97 font-size: clamp(28px, 4.5vw, 44px);
98 font-family: var(--font-display);
99 font-weight: 800;
100 letter-spacing: -0.03em;
101 line-height: 1.06;
102 margin: 0 0 var(--space-3);
103 color: var(--text-strong);
104 }
105 .cg-hero-title .gradient {
106 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
107 -webkit-background-clip: text;
108 background-clip: text;
109 -webkit-text-fill-color: transparent;
110 color: transparent;
111 }
112 .cg-hero-sub {
113 font-size: 15px;
114 color: var(--text-muted);
115 margin: 0 0 var(--space-4);
116 line-height: 1.55;
117 }
118 .cg-hero-cta {
119 display: inline-flex;
120 align-items: center;
121 gap: 8px;
122 padding: 11px 20px;
123 border-radius: 10px;
124 border: 1px solid rgba(140,109,255,0.45);
125 background: linear-gradient(135deg, rgba(140,109,255,0.18), rgba(54,197,214,0.12));
126 color: var(--text-strong);
127 font-size: 14px;
128 font-weight: 600;
129 text-decoration: none;
130 transition: border-color 150ms ease, background 150ms ease, transform 150ms ease;
131 }
132 .cg-hero-cta:hover {
133 border-color: rgba(140,109,255,0.65);
134 transform: translateY(-1px);
135 text-decoration: none;
136 }
137
138 /* ─── Steps ─── */
139 .cg-steps {
140 display: flex;
141 flex-direction: column;
142 gap: var(--space-4);
143 margin-bottom: var(--space-6);
144 }
145 .cg-step {
146 position: relative;
147 background: var(--bg-elevated);
148 border: 1px solid var(--border);
149 border-radius: 14px;
150 overflow: hidden;
151 }
152 .cg-step-head {
153 display: flex;
154 align-items: center;
155 gap: 14px;
156 padding: var(--space-4) var(--space-5);
157 border-bottom: 1px solid var(--border-subtle);
158 }
159 .cg-step-num {
160 display: inline-flex;
161 align-items: center;
162 justify-content: center;
163 flex-shrink: 0;
164 width: 30px; height: 30px;
165 border-radius: 50%;
166 background: linear-gradient(135deg, rgba(140,109,255,0.22), rgba(54,197,214,0.16));
167 border: 1px solid rgba(140,109,255,0.40);
168 color: #c5b3ff;
169 font-family: var(--font-display);
170 font-weight: 700;
171 font-size: 14px;
172 }
173 .cg-step-title {
174 font-family: var(--font-display);
175 font-size: 16px;
176 font-weight: 700;
177 letter-spacing: -0.01em;
178 color: var(--text-strong);
179 margin: 0;
180 }
181 .cg-step-body {
182 padding: var(--space-4) var(--space-5);
183 }
184 .cg-step-desc {
185 font-size: 14px;
186 color: var(--text-muted);
187 margin: 0 0 var(--space-3);
188 line-height: 1.55;
189 }
190
191 /* ─── Code blocks ─── */
192 .cg-code-wrap {
193 position: relative;
194 margin-bottom: var(--space-3);
195 }
196 .cg-code {
197 display: block;
198 background: var(--bg-tertiary, #0d1018);
199 border: 1px solid var(--border-subtle);
200 border-radius: 8px;
201 padding: 14px 16px;
202 font-family: var(--font-mono);
203 font-size: 12.5px;
204 color: var(--text-strong);
205 overflow-x: auto;
206 white-space: pre;
207 line-height: 1.6;
208 margin: 0;
209 }
210 .cg-copy-btn {
211 appearance: none;
212 position: absolute;
213 top: 8px; right: 8px;
214 border: 1px solid var(--border-subtle);
215 background: var(--bg-elevated);
216 color: var(--text-muted);
217 padding: 4px 10px;
218 border-radius: 6px;
219 font-size: 11.5px;
220 font-family: inherit;
221 cursor: pointer;
222 transition: color 120ms ease, border-color 120ms ease;
223 }
224 .cg-copy-btn:hover {
225 color: var(--text-strong);
226 border-color: var(--border-strong);
227 }
228 .cg-link {
229 color: var(--accent);
230 text-decoration: none;
231 font-weight: 500;
232 }
233 .cg-link:hover { text-decoration: underline; }
234
235 /* ─── Why Gluecron section ─── */
236 .cg-why {
237 background: var(--bg-elevated);
238 border: 1px solid var(--border);
239 border-radius: 14px;
240 padding: var(--space-5);
241 margin-bottom: var(--space-4);
242 }
243 .cg-why-title {
244 font-family: var(--font-display);
245 font-size: 18px;
246 font-weight: 800;
247 letter-spacing: -0.015em;
248 color: var(--text-strong);
249 margin: 0 0 var(--space-4);
250 }
251 .cg-why-grid {
252 display: grid;
253 grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
254 gap: var(--space-3);
255 }
256 .cg-why-card {
257 padding: var(--space-3) var(--space-4);
258 background: var(--bg-secondary);
259 border: 1px solid var(--border-subtle);
260 border-radius: 10px;
261 display: flex;
262 flex-direction: column;
263 gap: 6px;
264 }
265 .cg-why-card-icon {
266 font-size: 20px;
267 line-height: 1;
268 }
269 .cg-why-card-title {
270 font-weight: 700;
271 font-size: 13.5px;
272 color: var(--text-strong);
273 margin: 0;
274 }
275 .cg-why-card-desc {
276 font-size: 12.5px;
277 color: var(--text-muted);
278 margin: 0;
279 line-height: 1.5;
280 }
281
282 @media (max-width: 600px) {
283 .cg-hero { padding: var(--space-4); }
284 .cg-step-body, .cg-step-head { padding: var(--space-3) var(--space-4); }
285 .cg-why-grid { grid-template-columns: 1fr; }
286 }
287`;
288
289// ─── Client-side copy + snippet fill ──────────────────────────────────────
290function clientScript(username: string) {
291 return `
292(function() {
293 // Copy-to-clipboard
294 document.querySelectorAll('[data-cg-copy]').forEach(function(btn) {
295 btn.addEventListener('click', function() {
296 var targetId = btn.getAttribute('data-cg-copy');
297 var el = document.getElementById(targetId);
298 if (!el) return;
299 var text = el.textContent || '';
300 if (navigator.clipboard && navigator.clipboard.writeText) {
301 navigator.clipboard.writeText(text).then(function() {
302 var prev = btn.textContent;
303 btn.textContent = 'Copied!';
304 setTimeout(function() { btn.textContent = prev; }, 1500);
305 }).catch(function() {});
306 }
307 });
308 });
309})();
310`;
311}
312
313// ─── GET /connect/claude-guide ─────────────────────────────────────────────
314
315connectRoutes.get("/connect/claude-guide", async (c) => {
316 const user = c.get("user") ?? null;
317 const username = user?.username ?? "USERNAME";
318 const host = config.appBaseUrl || "https://gluecron.com";
319
320 const remoteSnippet = `git remote add gluecron ${host}/${username}/REPO.git`;
321 const mcpJsonSnippet = JSON.stringify(
322 {
323 mcpServers: {
324 gluecron: {
325 transport: "http",
326 url: `${host}/mcp`,
327 headers: {
328 Authorization: "Bearer YOUR_TOKEN_HERE",
329 },
330 },
331 },
332 },
333 null,
334 2
335 );
336
337 return c.html(
338 <Layout
339 title="Connect Claude Code to Gluecron"
340 user={user}
341 description="Push from any Claude Code session in 60 seconds. Zero config."
342 >
343 <style dangerouslySetInnerHTML={{ __html: styles }} />
344 <div class="cg-wrap">
345
346 {/* ─── Hero ─── */}
347 <section class="cg-hero">
348 <div class="cg-hero-orb" aria-hidden="true" />
349 <div class="cg-hero-inner">
350 <span class="cg-hero-tag">Claude Code Integration</span>
351 <h1 class="cg-hero-title">
352 Connect <span class="gradient">Claude Code</span> to Gluecron
353 </h1>
354 <p class="cg-hero-sub">
355 Push from any Claude session in 60 seconds. Your repos get
356 lightning-fast CI, AI review on every PR, and full MCP tool
357 access — no extra configuration needed.
358 </p>
359 <a href="/settings/tokens" class="cg-hero-cta">
360 Create an access token →
361 </a>
362 </div>
363 </section>
364
365 {/* ─── Steps ─── */}
366 <div class="cg-steps">
367
368 {/* Step 1 */}
369 <section class="cg-step">
370 <div class="cg-step-head">
371 <span class="cg-step-num">1</span>
372 <h2 class="cg-step-title">Create an access token</h2>
373 </div>
374 <div class="cg-step-body">
375 <p class="cg-step-desc">
376 Head to{" "}
377 <a href="/settings/tokens" class="cg-link">
378 /settings/tokens
379 </a>{" "}
380 and generate a new personal access token with{" "}
381 <code>repo</code> scope (or <code>admin,repo,user</code> for
382 full MCP write access). Copy the token — it's shown once.
383 </p>
384 </div>
385 </section>
386
387 {/* Step 2 */}
388 <section class="cg-step">
389 <div class="cg-step-head">
390 <span class="cg-step-num">2</span>
391 <h2 class="cg-step-title">Add the remote</h2>
392 </div>
393 <div class="cg-step-body">
394 <p class="cg-step-desc">
395 In your project directory, add Gluecron as a git remote.
396 Replace <code>REPO</code> with your repository name — it will
397 be created automatically on the first push.
398 </p>
399 <div class="cg-code-wrap">
400 <pre id="cg-remote" class="cg-code">{remoteSnippet}</pre>
401 <button type="button" class="cg-copy-btn" data-cg-copy="cg-remote">
402 Copy
403 </button>
404 </div>
405 </div>
406 </section>
407
408 {/* Step 3 */}
409 <section class="cg-step">
410 <div class="cg-step-head">
411 <span class="cg-step-num">3</span>
412 <h2 class="cg-step-title">Configure MCP in .claude/settings.json</h2>
413 </div>
414 <div class="cg-step-body">
415 <p class="cg-step-desc">
416 Add the Gluecron MCP server so Claude Code can open issues, file
417 PRs, and query your repos directly. Paste the snippet below into{" "}
418 <code>.claude/settings.json</code> (or{" "}
419 <code>~/.claude/settings.json</code> for global config), replacing{" "}
420 <code>YOUR_TOKEN_HERE</code> with the token from Step 1.
421 </p>
422 <div class="cg-code-wrap">
423 <pre id="cg-mcp-json" class="cg-code">{mcpJsonSnippet}</pre>
424 <button type="button" class="cg-copy-btn" data-cg-copy="cg-mcp-json">
425 Copy
426 </button>
427 </div>
428 </div>
429 </section>
430
431 {/* Step 4 */}
432 <section class="cg-step">
433 <div class="cg-step-head">
434 <span class="cg-step-num">4</span>
435 <h2 class="cg-step-title">Push</h2>
436 </div>
437 <div class="cg-step-body">
438 <p class="cg-step-desc">
439 Push your code. The repository is created automatically if it
440 doesn't exist yet, and CI gates fire immediately.
441 </p>
442 <div class="cg-code-wrap">
443 <pre id="cg-push" class="cg-code">git push gluecron main</pre>
444 <button type="button" class="cg-copy-btn" data-cg-copy="cg-push">
445 Copy
446 </button>
447 </div>
448 <p class="cg-step-desc" style="margin-top: var(--space-3); margin-bottom: 0;">
449 That's it. Your push triggers the full gate suite and Claude
450 AI review lands on any PR within seconds.
451 </p>
452 </div>
453 </section>
454 </div>
455
456 {/* ─── Why Gluecron ─── */}
457 <section class="cg-why">
458 <h2 class="cg-why-title">Why Gluecron?</h2>
459 <div class="cg-why-grid">
460 <div class="cg-why-card">
461 <span class="cg-why-card-icon" aria-hidden="true">⚡</span>
462 <h3 class="cg-why-card-title">Lightning-fast CI</h3>
463 <p class="cg-why-card-desc">
464 Gate runs fire in parallel the moment code lands. Type-check,
465 lint, secret-scan, and test in under 30 seconds on most repos.
466 </p>
467 </div>
468 <div class="cg-why-card">
469 <span class="cg-why-card-icon" aria-hidden="true">🤖</span>
470 <h3 class="cg-why-card-title">AI review on every PR</h3>
471 <p class="cg-why-card-desc">
472 Claude reads the diff, flags bugs, suggests improvements, and
473 auto-approves or blocks merge — before a human even looks.
474 </p>
475 </div>
476 <div class="cg-why-card">
477 <span class="cg-why-card-icon" aria-hidden="true">🔧</span>
478 <h3 class="cg-why-card-title">15 MCP tools built-in</h3>
479 <p class="cg-why-card-desc">
480 Claude Code gets native read/write tools: search code, open
481 issues, create PRs, merge branches — all from the chat window.
482 </p>
483 </div>
484 <div class="cg-why-card">
485 <span class="cg-why-card-icon" aria-hidden="true">🔒</span>
486 <h3 class="cg-why-card-title">Secret scanning</h3>
487 <p class="cg-why-card-desc">
488 Every push is scanned for leaked credentials. A flagged push is
489 blocked at the gate — not just flagged after the fact.
490 </p>
491 </div>
492 <div class="cg-why-card">
493 <span class="cg-why-card-icon" aria-hidden="true">🌐</span>
494 <h3 class="cg-why-card-title">Self-hostable</h3>
495 <p class="cg-why-card-desc">
496 Run Gluecron on your own infra with a single Docker image. All
497 AI features work with your own Anthropic key.
498 </p>
499 </div>
500 </div>
501 </section>
502
503 {/* ─── CTA footer ─── */}
504 {!user && (
505 <p style="text-align: center; color: var(--text-muted); font-size: 14px; margin-top: var(--space-4);">
506 <a href="/register" class="cg-link">Create a free account</a> to get
507 started, or{" "}
508 <a href="/login" class="cg-link">sign in</a> if you already have one.
509 </p>
510 )}
511 </div>
512 <script dangerouslySetInnerHTML={{ __html: clientScript(username) }} />
513 </Layout>
514 );
515});
516
517export default connectRoutes;
Modifiedsrc/routes/issues.tsx+52−1View fileUnifiedSplit
@@ -650,6 +650,40 @@ const issuesStyles = `
650650 .iss-linked-pr-state.is-merged { color: #a78bfa; background: rgba(167,139,250,0.10); }
651651 .iss-linked-pr-state.is-closed { color: #8b949e; background: var(--bg-tertiary); }
652652 .iss-linked-pr-state.is-draft { color: #8b949e; background: var(--bg-tertiary); }
653
654 /* ─── Sort controls (issue list) ─── */
655 .issues-sort-row {
656 display: flex;
657 align-items: center;
658 gap: 6px;
659 margin: 0 0 12px;
660 flex-wrap: wrap;
661 }
662 .issues-sort-label {
663 font-size: 12.5px;
664 color: var(--text-muted);
665 font-weight: 600;
666 margin-right: 2px;
667 }
668 .issues-sort-opt {
669 font-size: 12.5px;
670 color: var(--text-muted);
671 text-decoration: none;
672 padding: 3px 10px;
673 border-radius: 9999px;
674 border: 1px solid transparent;
675 transition: background 120ms ease, color 120ms ease, border-color 120ms ease;
676 }
677 .issues-sort-opt:hover {
678 background: var(--bg-hover);
679 color: var(--text);
680 }
681 .issues-sort-opt.is-active {
682 background: rgba(140,109,255,0.12);
683 color: var(--text-link);
684 border-color: rgba(140,109,255,0.35);
685 font-weight: 600;
686 }
653687`;
654688
655689// Pre-rendered <style> tag (constant, reused per request).
@@ -712,6 +746,7 @@ issueRoutes.get("/:owner/:repo/issues", softAuth, requireRepoAccess("read"), asy
712746 const state = c.req.query("state") || "open";
713747 const searchQ = (c.req.query("q") || "").trim();
714748 const labelFilter = (c.req.query("label") || "").trim();
749 const sort = (c.req.query("sort") || "newest").trim();
715750 // Bounded pagination — unbounded selects ran a full table scan + O(n)
716751 // sort on every page load; with 10k+ issues the request would hang.
717752 const perPage = Math.min(100, Math.max(1, Number(c.req.query("per_page")) || 50));
@@ -805,7 +840,11 @@ issueRoutes.get("/:owner/:repo/issues", softAuth, requireRepoAccess("read"), asy
805840 .from(issues)
806841 .innerJoin(users, eq(issues.authorId, users.id))
807842 .where(baseWhere)
808 .orderBy(desc(issues.createdAt))
843 .orderBy(
844 sort === "oldest" ? asc(issues.createdAt)
845 : sort === "updated" ? desc(issues.updatedAt)
846 : desc(issues.createdAt) // newest (default)
847 )
809848 .limit(perPage)
810849 .offset(offset);
811850
@@ -920,6 +959,18 @@ issueRoutes.get("/:owner/:repo/issues", softAuth, requireRepoAccess("read"), asy
920959 </div>
921960 )}
922961
962 <div class="issues-sort-row">
963 <span class="issues-sort-label">Sort:</span>
964 {(["newest", "oldest", "updated"] as const).map((s) => (
965 <a
966 href={`/${ownerName}/${repoName}/issues?state=${state}&sort=${s}${searchQ ? `&q=${encodeURIComponent(searchQ)}` : ""}${labelFilter ? `&label=${encodeURIComponent(labelFilter)}` : ""}`}
967 class={`issues-sort-opt${sort === s ? " is-active" : ""}`}
968 >
969 {s === "newest" ? "Newest" : s === "oldest" ? "Oldest" : "Recently updated"}
970 </a>
971 ))}
972 </div>
973
923974 {issueList.length === 0 ? (
924975 <div class="issues-empty">
925976 <IssuesEmptySvg />
Modifiedsrc/routes/pulls.tsx+53−1View fileUnifiedSplit
@@ -399,6 +399,40 @@ const PRS_LIST_STYLES = `
399399 .prs-row { padding: 12px 14px; gap: 10px; }
400400 .prs-row-icon { width: 24px; height: 24px; }
401401 }
402
403 /* ─── Sort controls (PR list) ─── */
404 .prs-sort-row {
405 display: flex;
406 align-items: center;
407 gap: 6px;
408 margin: 0 0 12px;
409 flex-wrap: wrap;
410 }
411 .prs-sort-label {
412 font-size: 12.5px;
413 color: var(--text-muted);
414 font-weight: 600;
415 margin-right: 2px;
416 }
417 .prs-sort-opt {
418 font-size: 12.5px;
419 color: var(--text-muted);
420 text-decoration: none;
421 padding: 3px 10px;
422 border-radius: 9999px;
423 border: 1px solid transparent;
424 transition: background 120ms ease, color 120ms ease, border-color 120ms ease;
425 }
426 .prs-sort-opt:hover {
427 background: var(--bg-hover);
428 color: var(--text);
429 }
430 .prs-sort-opt.is-active {
431 background: rgba(140,109,255,0.12);
432 color: var(--text-link);
433 border-color: rgba(140,109,255,0.35);
434 font-weight: 600;
435 }
402436`;
403437
404438/* ──────────────────────────────────────────────────────────────────────
@@ -1860,6 +1894,7 @@ pulls.get("/:owner/:repo/pulls", softAuth, requireRepoAccess("read"), async (c)
18601894 const state = c.req.query("state") || "open";
18611895 const searchQ = c.req.query("q")?.trim() || "";
18621896 const authorFilter = c.req.query("author")?.trim() || "";
1897 const sortPr = (c.req.query("sort") || "newest").trim();
18631898
18641899 // ── Loading skeleton (flag-gated) ──
18651900 // Renders an SSR'd PR-row skeleton when `?skeleton=1` is set. Lets
@@ -1925,7 +1960,11 @@ pulls.get("/:owner/:repo/pulls", softAuth, requireRepoAccess("read"), async (c)
19251960 authorFilter ? eq(users.username, authorFilter) : undefined,
19261961 )
19271962 )
1928 .orderBy(desc(pullRequests.createdAt));
1963 .orderBy(
1964 sortPr === "oldest" ? asc(pullRequests.createdAt)
1965 : sortPr === "updated" ? desc(pullRequests.updatedAt)
1966 : desc(pullRequests.createdAt) // newest (default)
1967 );
19291968
19301969 // Batch-load review states + comment counts for all PRs in the list
19311970 const reviewMap = new Map<string, { approved: boolean; changesRequested: boolean }>();
@@ -2081,6 +2120,19 @@ pulls.get("/:owner/:repo/pulls", softAuth, requireRepoAccess("read"), async (c)
20812120 </a>
20822121 )}
20832122 </form>
2123
2124 <div class="prs-sort-row">
2125 <span class="prs-sort-label">Sort:</span>
2126 {(["newest", "oldest", "updated"] as const).map((s) => (
2127 <a
2128 href={`/${ownerName}/${repoName}/pulls?state=${state}&sort=${s}${searchQ ? `&q=${encodeURIComponent(searchQ)}` : ""}${authorFilter ? `&author=${encodeURIComponent(authorFilter)}` : ""}`}
2129 class={`prs-sort-opt${sortPr === s ? " is-active" : ""}`}
2130 >
2131 {s === "newest" ? "Newest" : s === "oldest" ? "Oldest" : "Recently updated"}
2132 </a>
2133 ))}
2134 </div>
2135
20842136 {prList.length === 0 ? (
20852137 <div class="prs-empty">
20862138 <div class="prs-empty-inner">
Modifiedsrc/routes/workflow-secrets.tsx+291−17View fileUnifiedSplit
@@ -32,6 +32,7 @@ import {
3232 deleteRepoSecret,
3333} from "../lib/workflow-secrets";
3434import { audit } from "../lib/notify";
35import { getDefaultBranch, getTree, getBlob } from "../git/repository";
3536
3637const workflowSecretsRoutes = new Hono<AuthEnv>();
3738
@@ -41,6 +42,57 @@ const SECRET_NAME_RE = /^[A-Z_][A-Z0-9_]*$/;
4142const MAX_NAME_LEN = 100;
4243const MAX_VALUE_LEN = 32768;
4344
45/**
46 * Count how many `.gluecron/workflows/*.yml` files reference a given
47 * secret name via `${{ secrets.NAME }}`. Returns a map of secretName
48 * -> count (number of workflow files that contain at least one reference).
49 * Returns an empty map on any error so the UI degrades gracefully.
50 */
51async function countSecretUsagesInWorkflows(
52 ownerName: string,
53 repoName: string,
54 secretNames: string[]
55): Promise<Map<string, number>> {
56 const counts = new Map<string, number>(secretNames.map((n) => [n, 0]));
57 if (secretNames.length === 0) return counts;
58
59 try {
60 const branch = await getDefaultBranch(ownerName, repoName);
61 if (!branch) return counts;
62
63 // List .gluecron/workflows/ directory
64 const entries = await getTree(ownerName, repoName, branch, ".gluecron/workflows");
65 const ymlFiles = entries.filter(
66 (e) => e.type === "blob" && (e.name.endsWith(".yml") || e.name.endsWith(".yaml"))
67 );
68
69 for (const file of ymlFiles) {
70 const blob = await getBlob(
71 ownerName,
72 repoName,
73 branch,
74 `.gluecron/workflows/${file.name}`
75 );
76 if (!blob || blob.isBinary || !blob.content) continue;
77 const content = blob.content;
78 for (const name of secretNames) {
79 // Match ${{ secrets.NAME }} with optional whitespace
80 const pattern = new RegExp(
81 `\\$\\{\\{\\s*secrets\\.${name}\\s*\\}\\}`,
82 "g"
83 );
84 if (pattern.test(content)) {
85 counts.set(name, (counts.get(name) ?? 0) + 1);
86 }
87 }
88 }
89 } catch {
90 // Best-effort — return whatever we have
91 }
92
93 return counts;
94}
95
4496/** Sub-nav shown under the main RepoNav for repo settings pages. */
4597function SettingsSubNav({
4698 owner,
@@ -94,6 +146,62 @@ function SettingsSubNav({
94146 */
95147const MASKED_PLACEHOLDER = "••••••••••••";
96148
149
150const wsecScript = `
151(function () {
152 var NAME_RE = /^[A-Z_][A-Z0-9_]*$/;
153
154 /**
155 * wsecTestName — called oninput on the name field and onclick on the
156 * Test button. Toggles .is-ok / .is-error CSS on the button and updates
157 * the hint text below the field.
158 *
159 * @param {HTMLInputElement} el the name <input> element
160 */
161 window.wsecTestName = function wsecTestName(el) {
162 var val = (el.value || '').trim();
163 var btn = document.getElementById('wsec-test-btn');
164 var hint = document.getElementById('wsec-name-hint');
165 if (!btn || !hint) return;
166
167 // Reset
168 btn.classList.remove('is-ok', 'is-error');
169
170 if (!val) {
171 hint.innerHTML = 'Referenced in YAML as <code>\$\{{ secrets.YOUR_NAME }}</code>.';
172 hint.className = 'wsec-field-help';
173 return;
174 }
175
176 if (NAME_RE.test(val)) {
177 btn.classList.add('is-ok');
178 hint.innerHTML = '<span class="wsec-name-hint-ok">✓ Valid name.</span> Referenced as <code>\$\{{ secrets.' + val + ' }}</code>.';
179 hint.className = 'wsec-field-help';
180 } else {
181 btn.classList.add('is-error');
182 var msg = 'Invalid name.';
183 if (!/^[A-Z_]/.test(val)) {
184 msg = 'Must start with an uppercase letter or underscore (A-Z or _).';
185 } else if (/[a-z]/.test(val)) {
186 msg = 'Lowercase letters are not allowed — use uppercase only.';
187 } else if (/[^A-Z0-9_]/.test(val)) {
188 msg = 'Only uppercase letters, digits (0-9), and underscores are allowed.';
189 }
190 hint.innerHTML = '<span class="wsec-name-hint-err">✗ ' + msg + '</span>';
191 hint.className = 'wsec-field-help';
192 }
193 };
194
195 // Wire up on DOMContentLoaded so the element definitely exists.
196 document.addEventListener('DOMContentLoaded', function () {
197 var nameInput = document.getElementById('secret-name');
198 if (nameInput) {
199 nameInput.addEventListener('input', function () { window.wsecTestName(nameInput); });
200 }
201 });
202}());
203`;
204
97205// ─── GET: List + add form ───────────────────────────────────────────────────
98206
99207workflowSecretsRoutes.get(
@@ -129,6 +237,13 @@ workflowSecretsRoutes.get(
129237 : [];
130238 const creatorMap = new Map(creatorRows.map((r) => [r.id, r.username]));
131239
240 // Count workflow usages per secret (best-effort, never throws).
241 const usageCounts = await countSecretUsagesInWorkflows(
242 ownerName,
243 repoName,
244 secrets.map((s) => s.name)
245 );
246
132247 return c.html(
133248 <Layout title={`Secrets — ${ownerName}/${repoName}`} user={user}>
134249 <RepoHeader owner={ownerName} repo={repoName} currentUser={user.username} />
@@ -214,12 +329,34 @@ workflowSecretsRoutes.get(
214329 <div class="wsec-empty">
215330 <div class="wsec-empty-orb" aria-hidden="true" />
216331 <div class="wsec-empty-inner">
217 <p class="wsec-empty-title">No secrets yet.</p>
332 <div class="wsec-empty-icon" aria-hidden="true">
333 <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
334 <rect x="3" y="11" width="18" height="11" rx="2" />
335 <path d="M7 11V7a5 5 0 0 1 10 0v4" />
336 </svg>
337 </div>
338 <p class="wsec-empty-title">No secrets configured yet.</p>
218339 <p class="wsec-empty-sub">
219 Add an encrypted secret below — names like{" "}
220 <code>DEPLOY_TOKEN</code> or <code>STRIPE_KEY</code>{" "}
221 become available to every workflow step in this repo.
340 Secrets let you store sensitive values — API keys, deploy tokens,
341 and passwords — securely encrypted in the database. Reference them
342 in any workflow file with{" "}
343 <code>{"${{ secrets.YOUR_NAME }}"}</code>. They're never
344 printed to logs and only visible to workflows at runtime.
222345 </p>
346 <div class="wsec-empty-steps">
347 <div class="wsec-empty-step">
348 <span class="wsec-empty-step-num">1</span>
349 <span>Enter a name like <code>DEPLOY_TOKEN</code> in the form below (uppercase letters, digits, underscores).</span>
350 </div>
351 <div class="wsec-empty-step">
352 <span class="wsec-empty-step-num">2</span>
353 <span>Paste the secret value — it's encrypted with AES-256-GCM before touching the database.</span>
354 </div>
355 <div class="wsec-empty-step">
356 <span class="wsec-empty-step-num">3</span>
357 <span>Use <code>{"${{ secrets.DEPLOY_TOKEN }}"}</code> in your <code>.gluecron/workflows/*.yml</code> files.</span>
358 </div>
359 </div>
223360 </div>
224361 </div>
225362 ) : (
@@ -231,6 +368,7 @@ workflowSecretsRoutes.get(
231368 const created = s.createdAt
232369 ? formatRelative(s.createdAt)
233370 : "—";
371 const wfCount = usageCounts.get(s.name) ?? 0;
234372 return (
235373 <li class="wsec-row">
236374 <div class="wsec-row-main">
@@ -239,6 +377,19 @@ workflowSecretsRoutes.get(
239377 <span class="wsec-row-masked" aria-label="value hidden">
240378 {MASKED_PLACEHOLDER}
241379 </span>
380 <span
381 class={`wsec-usage-badge${wfCount === 0 ? " is-unused" : ""}`}
382 title={wfCount === 0
383 ? "Not referenced in any workflow file"
384 : `Referenced in ${wfCount} workflow file${wfCount === 1 ? "" : "s"}`}
385 >
386 <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
387 <polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
388 </svg>
389 {wfCount === 0
390 ? "unused"
391 : `${wfCount} workflow${wfCount === 1 ? "" : "s"}`}
392 </span>
242393 </div>
243394 <div class="wsec-row-meta">
244395 <span class="wsec-meta-item">
@@ -306,19 +457,31 @@ workflowSecretsRoutes.get(
306457 <label class="wsec-field-label" for="secret-name">
307458 Name
308459 </label>
309 <input
310 type="text"
311 id="secret-name"
312 name="name"
313 required
314 pattern="[A-Z_][A-Z0-9_]*"
315 maxlength={MAX_NAME_LEN}
316 placeholder="DEPLOY_TOKEN"
317 autocomplete="off"
318 class="wsec-input wsec-input-mono"
319 title="Uppercase letters, digits, and underscores; cannot start with a digit"
320 />
321 <p class="wsec-field-help">
460 <div class="wsec-input-row">
461 <input
462 type="text"
463 id="secret-name"
464 name="name"
465 required
466 pattern="[A-Z_][A-Z0-9_]*"
467 maxlength={MAX_NAME_LEN}
468 placeholder="DEPLOY_TOKEN"
469 autocomplete="off"
470 class="wsec-input wsec-input-mono"
471 title="Uppercase letters, digits, and underscores; cannot start with a digit"
472 oninput="wsecTestName(this)"
473 />
474 <button
475 type="button"
476 class="wsec-btn wsec-btn-test"
477 id="wsec-test-btn"
478 onclick="wsecTestName(document.getElementById('secret-name'))"
479 title="Validate the name format"
480 >
481 Test
482 </button>
483 </div>
484 <p class="wsec-field-help" id="wsec-name-hint">
322485 Referenced in YAML as{" "}
323486 <code>{"${{ secrets.YOUR_NAME }}"}</code>.
324487 </p>
@@ -355,6 +518,7 @@ workflowSecretsRoutes.get(
355518 </div>
356519 </section>
357520 </div>
521 <script dangerouslySetInnerHTML={{ __html: wsecScript }} />
358522 </Layout>
359523 );
360524 }
@@ -779,9 +943,94 @@ const wsecStyles = `
779943 color: var(--text-strong);
780944 }
781945
946 /* ─── Usage badge ─── */
947 .wsec-usage-badge {
948 display: inline-flex;
949 align-items: center;
950 gap: 4px;
951 padding: 2px 8px;
952 border-radius: 9999px;
953 font-family: var(--font-mono);
954 font-size: 11px;
955 font-weight: 500;
956 background: rgba(52,211,153,0.10);
957 color: #6ee7b7;
958 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.25);
959 letter-spacing: 0.01em;
960 cursor: default;
961 }
962 .wsec-usage-badge.is-unused {
963 background: rgba(100,116,139,0.12);
964 color: var(--text-faint);
965 box-shadow: inset 0 0 0 1px rgba(100,116,139,0.25);
966 }
967
968 /* ─── Empty state steps ─── */
969 .wsec-empty-icon {
970 margin: 0 auto var(--space-3);
971 width: 48px; height: 48px;
972 border-radius: 14px;
973 background: rgba(140,109,255,0.10);
974 border: 1px solid rgba(140,109,255,0.20);
975 display: flex;
976 align-items: center;
977 justify-content: center;
978 color: #b69dff;
979 }
980 .wsec-empty-steps {
981 margin-top: var(--space-4);
982 display: flex;
983 flex-direction: column;
984 gap: 10px;
985 text-align: left;
986 max-width: 480px;
987 margin-left: auto;
988 margin-right: auto;
989 }
990 .wsec-empty-step {
991 display: flex;
992 align-items: flex-start;
993 gap: 10px;
994 font-size: 12.5px;
995 color: var(--text-muted);
996 line-height: 1.5;
997 }
998 .wsec-empty-step code {
999 font-family: var(--font-mono);
1000 font-size: 11.5px;
1001 background: var(--bg-tertiary);
1002 padding: 1px 5px;
1003 border-radius: 4px;
1004 color: var(--text-strong);
1005 }
1006 .wsec-empty-step-num {
1007 flex-shrink: 0;
1008 width: 20px; height: 20px;
1009 border-radius: 9999px;
1010 background: rgba(140,109,255,0.15);
1011 color: #c5b3ff;
1012 font-family: var(--font-mono);
1013 font-size: 11px;
1014 font-weight: 700;
1015 display: inline-flex;
1016 align-items: center;
1017 justify-content: center;
1018 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.25);
1019 margin-top: 1px;
1020 }
1021
7821022 /* ─── Form ─── */
7831023 .wsec-form { padding: var(--space-5); display: flex; flex-direction: column; gap: var(--space-4); }
7841024 .wsec-field { display: flex; flex-direction: column; gap: 6px; }
1025 .wsec-input-row {
1026 display: flex;
1027 gap: 8px;
1028 align-items: stretch;
1029 }
1030 .wsec-input-row .wsec-input {
1031 flex: 1;
1032 min-width: 0;
1033 }
7851034 .wsec-field-label {
7861035 font-family: var(--font-mono);
7871036 font-size: 12.5px;
@@ -870,6 +1119,27 @@ const wsecStyles = `
8701119 background: rgba(248,113,113,0.10);
8711120 color: #fee2e2;
8721121 }
1122 .wsec-btn-test {
1123 flex-shrink: 0;
1124 border-color: rgba(140,109,255,0.30);
1125 color: #c5b3ff;
1126 }
1127 .wsec-btn-test:hover {
1128 border-color: rgba(140,109,255,0.55);
1129 background: rgba(140,109,255,0.10);
1130 }
1131 .wsec-btn-test.is-ok {
1132 border-color: rgba(52,211,153,0.45);
1133 background: rgba(52,211,153,0.08);
1134 color: #6ee7b7;
1135 }
1136 .wsec-btn-test.is-error {
1137 border-color: rgba(248,113,113,0.45);
1138 background: rgba(248,113,113,0.08);
1139 color: #fca5a5;
1140 }
1141 .wsec-name-hint-ok { color: #6ee7b7; font-weight: 500; }
1142 .wsec-name-hint-err { color: #fca5a5; font-weight: 500; }
8731143
8741144 (max-width: 640px) {
8751145 .wsec-row { flex-direction: column; align-items: flex-start; }
@@ -879,4 +1149,8 @@ const wsecStyles = `
8791149 }
8801150`;
8811151
1152/* ─────────────────────────────────────────────────────────────────────────
1153 * Client-side script — validates the secret name field in real-time and
1154 * drives the Test button feedback. No external dependencies.
1155 * ───────────────────────────────────────────────────────────────────── */
8821156export default workflowSecretsRoutes;
Modifiedsrc/views/diff-view.tsx+1−1View fileUnifiedSplit
@@ -557,7 +557,7 @@ export const DiffView: FC<DiffViewProps> = ({ raw, files, viewFileBase, inlineCo
557557 <div class="diff-suggestion-header">
558558 <span>Suggested change</span>
559559 {applySuggestionUrl && (
560 <form method="POST" action={`${applySuggestionUrl}/${c.id}`} style="margin:0;display:inline;">
560 <form method="post" action={`${applySuggestionUrl}/${c.id}`} style="margin:0;display:inline;">
561561 <button type="submit" class="diff-apply-btn">Apply suggestion</button>
562562 </form>
563563 )}
Modifiedsrc/views/layout.tsx+198−97View fileUnifiedSplit
@@ -178,11 +178,7 @@ export const Layout: FC<
178178 </form>
179179 </div>
180180 <div class="nav-right">
181 {/* Block N3 — site-admin platform-deploy status pill. Hidden
182 by default; revealed client-side once /admin/deploys/latest.json
183 responds 200 (non-admins get 401/403 and the pill stays
184 hidden). Subscribes to the `platform:deploys` SSE topic
185 for live updates. See `deployPillScript` below. */}
181 {/* Block N3 — site-admin platform-deploy status pill */}
186182 {user && (
187183 <a
188184 id="deploy-pill"
@@ -196,46 +192,10 @@ export const Layout: FC<
196192 <span class="deploy-pill-text">Deploys</span>
197193 </a>
198194 )}
199 <a
200 href="/theme/toggle"
201 class="nav-link nav-theme"
202 title="Toggle theme"
203 aria-label="Toggle theme"
204 >
205 <span class="theme-icon-dark">{"\u263E"}</span>
206 <span class="theme-icon-light">{"\u2600"}</span>
207 </a>
208 <a href="/explore" class="nav-link">
209 Explore
210 </a>
195 <a href="/explore" class="nav-link">Explore</a>
211196 {user ? (
212197 <>
213 <a href="/dashboard" class="nav-link" style="font-weight: 600">
214 Dashboard
215 </a>
216 <a href="/pulls" class="nav-link">
217 Pulls
218 </a>
219 <a href="/issues" class="nav-link">
220 Issues
221 </a>
222 <a href="/activity" class="nav-link">
223 Activity
224 </a>
225 <a href="/inbox" class="nav-link" style="position:relative">
226 Inbox
227 {notificationCount && notificationCount > 0 ? (
228 <span
229 aria-label={`${notificationCount} unread`}
230 style="display:inline-flex;align-items:center;justify-content:center;min-width:16px;height:16px;padding:0 5px;margin-left:6px;font-size:10.5px;font-weight:700;font-variant-numeric:tabular-nums;line-height:1;color:#fff;background:linear-gradient(135deg,#8c6dff 0%,#36c5d6 100%);border-radius:9999px;box-shadow:0 0 8px rgba(140,109,255,0.45)"
231 >
232 {notificationCount > 99 ? "99+" : notificationCount}
233 </span>
234 ) : null}
235 </a>
236 {/* AI dropdown — consolidates Standups, Voice, Refactors,
237 Specs, Ask AI to keep the top-level nav lean. Hover-open
238 with click+keyboard fallback via navAiDropdownScript. */}
198 {/* AI dropdown */}
239199 <div class="nav-ai-dropdown" data-nav-ai>
240200 <button
241201 type="button"
@@ -245,21 +205,11 @@ export const Layout: FC<
245205 data-nav-ai-trigger
246206 >
247207 <span style="display:inline-flex;align-items:center;gap:5px">
248 <svg
249 width="13"
250 height="13"
251 viewBox="0 0 24 24"
252 fill="none"
253 stroke="currentColor"
254 stroke-width="2.2"
255 stroke-linecap="round"
256 stroke-linejoin="round"
257 aria-hidden="true"
258 >
208 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
259209 <path d="M12 2l2.39 5.95L20 9l-4.5 3.9L17 19l-5-3.2L7 19l1.5-6.1L4 9l5.61-1.05L12 2z" />
260210 </svg>
261211 AI
262 <span aria-hidden="true" style="font-size:9px;opacity:0.7">▾</span>
212 <span aria-hidden="true" style="font-size:9px;opacity:0.7">{String.fromCharCode(9660)}</span>
263213 </span>
264214 </button>
265215 <div class="nav-ai-menu" role="menu" data-nav-ai-menu>
@@ -285,30 +235,71 @@ export const Layout: FC<
285235 </a>
286236 </div>
287237 </div>
288 <a href="/import" class="nav-link">
289 Import
290 </a>
291 <a href="/new" class="btn btn-sm btn-primary">
292 + New
293 </a>
294 <a href={`/${user.username}`} class="nav-user">
295 {user.displayName || user.username}
296 </a>
297 <a href="/settings" class="nav-link">
298 Settings
299 </a>
300 <a href="/logout" class="nav-link">
301 Sign out
238 {/* Inbox bell with unread badge */}
239 <a
240 href="/inbox"
241 class="nav-inbox-btn"
242 aria-label={notificationCount && notificationCount > 0 ? `Inbox — ${notificationCount} unread` : "Inbox"}
243 title="Inbox"
244 >
245 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
246 <path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
247 <path d="M13.73 21a2 2 0 0 1-3.46 0"/>
248 </svg>
249 {notificationCount && notificationCount > 0 ? (
250 <span class="nav-inbox-badge" aria-hidden="true">
251 {notificationCount > 99 ? "99+" : notificationCount}
252 </span>
253 ) : null}
302254 </a>
255 <a href="/new" class="btn btn-sm btn-primary">+ New</a>
256 {/* User dropdown — consolidates profile + secondary nav */}
257 <div class="nav-user-dropdown" data-nav-user>
258 <button
259 type="button"
260 class="nav-user-trigger"
261 aria-haspopup="true"
262 aria-expanded="false"
263 data-nav-user-trigger
264 >
265 <span class="nav-user-avatar" aria-hidden="true">
266 {(user.displayName || user.username).charAt(0).toUpperCase()}
267 </span>
268 <span class="nav-user-name">{user.displayName || user.username}</span>
269 <span class="nav-user-caret" aria-hidden="true">{"▾"}</span>
270 </button>
271 <div class="nav-user-menu" role="menu" data-nav-user-menu>
272 <div class="nav-user-menu-header">
273 <span class="nav-user-menu-name">{user.displayName || user.username}</span>
274 <span class="nav-user-menu-handle">@{user.username}</span>
275 </div>
276 <div class="nav-user-menu-sep" />
277 <a href="/dashboard" role="menuitem" class="nav-user-item">Dashboard</a>
278 <a href="/pulls" role="menuitem" class="nav-user-item">Pull requests</a>
279 <a href="/issues" role="menuitem" class="nav-user-item">Issues</a>
280 <a href="/activity" role="menuitem" class="nav-user-item">Activity</a>
281 <a href="/import" role="menuitem" class="nav-user-item">Import from GitHub</a>
282 <div class="nav-user-menu-sep" />
283 <a href={`/${user.username}`} role="menuitem" class="nav-user-item">Your profile</a>
284 <a href="/settings" role="menuitem" class="nav-user-item">Settings</a>
285 <a href="/settings/tokens" role="menuitem" class="nav-user-item">Access tokens</a>
286 <div class="nav-user-menu-sep" />
287 <a href="/theme/toggle" class="nav-user-item" role="menuitem">
288 <span class="theme-icon-dark">{"☾"} Light mode</span>
289 <span class="theme-icon-light">{"☀"} Dark mode</span>
290 </a>
291 <a href="/logout" role="menuitem" class="nav-user-item nav-user-item--danger">Sign out</a>
292 </div>
293 </div>
303294 </>
304295 ) : (
305296 <>
306 <a href="/login" class="nav-link">
307 Sign in
308 </a>
309 <a href="/register" class="btn btn-sm btn-primary">
310 Register
297 <a href="/theme/toggle" class="nav-link nav-theme" title="Toggle theme" aria-label="Toggle theme">
298 <span class="theme-icon-dark">{"☾"}</span>
299 <span class="theme-icon-light">{"☀"}</span>
311300 </a>
301 <a href="/login" class="nav-link">Sign in</a>
302 <a href="/register" class="btn btn-sm btn-primary">Register</a>
312303 </>
313304 )}
314305 </div>
@@ -937,26 +928,31 @@ const navScript = `
937928// to-close. Lives in its own IIFE so it never interferes with navScript.
938929const navAiDropdownScript = `
939930 (function(){
940 var open = false;
941 var root = document.querySelector('[data-nav-ai]');
942 if (!root) return;
943 var trigger = root.querySelector('[data-nav-ai-trigger]');
944 var menu = root.querySelector('[data-nav-ai-menu]');
945 if (!trigger || !menu) return;
946 function setOpen(next){
947 open = !!next;
948 root.classList.toggle('is-open', open);
949 trigger.setAttribute('aria-expanded', open ? 'true' : 'false');
931 function makeDropdown(rootSel, triggerSel, menuSel) {
932 var open = false;
933 var root = document.querySelector(rootSel);
934 if (!root) return;
935 var trigger = root.querySelector(triggerSel);
936 var menu = root.querySelector(menuSel);
937 if (!trigger || !menu) return;
938 function setOpen(next){
939 open = !!next;
940 root.classList.toggle('is-open', open);
941 menu.classList.toggle('is-open', open);
942 trigger.setAttribute('aria-expanded', open ? 'true' : 'false');
943 }
944 trigger.addEventListener('click', function(e){ e.preventDefault(); setOpen(!open); });
945 document.addEventListener('click', function(e){
946 if (!open) return;
947 if (root.contains(e.target)) return;
948 setOpen(false);
949 });
950 document.addEventListener('keydown', function(e){
951 if (open && e.key === 'Escape') { e.preventDefault(); setOpen(false); trigger.focus(); }
952 });
950953 }
951 trigger.addEventListener('click', function(e){ e.preventDefault(); setOpen(!open); });
952 document.addEventListener('click', function(e){
953 if (!open) return;
954 if (root.contains(e.target)) return;
955 setOpen(false);
956 });
957 document.addEventListener('keydown', function(e){
958 if (open && e.key === 'Escape') { e.preventDefault(); setOpen(false); trigger.focus(); }
959 });
954 makeDropdown('[data-nav-ai]', '[data-nav-ai-trigger]', '[data-nav-ai-menu]');
955 makeDropdown('[data-nav-user]', '[data-nav-user-trigger]', '[data-nav-user-menu]');
960956 })();
961957`;
962958
@@ -1414,7 +1410,7 @@ const css = `
14141410 display: flex;
14151411 align-items: center;
14161412 gap: 18px;
1417 max-width: 1240px;
1413 max-width: 1440px;
14181414 margin: 0 auto;
14191415 height: 100%;
14201416 }
@@ -1644,8 +1640,113 @@ const css = `
16441640 }
16451641 .nav-user:hover { background: var(--bg-hover); text-decoration: none; }
16461642
1643 /* ── Inbox bell button ── */
1644 .nav-inbox-btn {
1645 position: relative;
1646 display: inline-flex;
1647 align-items: center;
1648 justify-content: center;
1649 width: 34px;
1650 height: 34px;
1651 border-radius: var(--r-sm);
1652 color: var(--text-muted);
1653 transition: color var(--t-fast) var(--ease), background var(--t-fast) var(--ease);
1654 flex-shrink: 0;
1655 }
1656 .nav-inbox-btn:hover { color: var(--text); background: var(--bg-hover); text-decoration: none; }
1657 .nav-inbox-badge {
1658 position: absolute;
1659 top: 3px; right: 3px;
1660 min-width: 15px; height: 15px;
1661 padding: 0 4px;
1662 font-size: 9.5px;
1663 font-weight: 700;
1664 line-height: 15px;
1665 color: #fff;
1666 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
1667 border-radius: 9999px;
1668 text-align: center;
1669 box-shadow: 0 0 6px rgba(140,109,255,0.45);
1670 font-variant-numeric: tabular-nums;
1671 }
1672
1673 /* ── User dropdown ── */
1674 .nav-user-dropdown { position: relative; }
1675 .nav-user-trigger {
1676 display: inline-flex;
1677 align-items: center;
1678 gap: 7px;
1679 padding: 5px 9px 5px 5px;
1680 border-radius: var(--r-sm);
1681 border: 1px solid transparent;
1682 background: transparent;
1683 color: var(--text);
1684 font-family: var(--font-sans);
1685 font-size: var(--t-sm);
1686 font-weight: 500;
1687 cursor: pointer;
1688 transition: background var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
1689 }
1690 .nav-user-trigger:hover { background: var(--bg-hover); border-color: var(--border); }
1691 .nav-user-avatar {
1692 width: 24px; height: 24px;
1693 border-radius: 50%;
1694 background: var(--accent-gradient);
1695 color: #fff;
1696 font-size: 11px;
1697 font-weight: 700;
1698 display: inline-flex;
1699 align-items: center;
1700 justify-content: center;
1701 flex-shrink: 0;
1702 box-shadow: 0 0 0 2px var(--border);
1703 }
1704 .nav-user-name { font-weight: 500; font-size: var(--t-sm); max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1705 .nav-user-caret { font-size: 8px; opacity: 0.5; }
1706 .nav-user-menu {
1707 display: none;
1708 position: absolute;
1709 top: calc(100% + 6px);
1710 right: 0;
1711 min-width: 220px;
1712 background: var(--bg-elevated);
1713 border: 1px solid var(--border-strong);
1714 border-radius: var(--r-lg);
1715 box-shadow: 0 16px 48px -8px rgba(0,0,0,0.55), 0 0 0 1px var(--border);
1716 padding: 6px;
1717 z-index: 200;
1718 animation: navMenuIn 140ms var(--ease) both;
1719 }
1720 .nav-user-menu.is-open { display: block; }
1721 .nav-user-menu-header {
1722 padding: 8px 10px 10px;
1723 display: flex;
1724 flex-direction: column;
1725 gap: 2px;
1726 }
1727 .nav-user-menu-name { font-weight: 600; font-size: var(--t-sm); color: var(--text-strong); }
1728 .nav-user-menu-handle { font-size: var(--t-xs); color: var(--text-muted); }
1729 .nav-user-menu-sep { height: 1px; background: var(--border); margin: 4px -6px; }
1730 .nav-user-item {
1731 display: block;
1732 padding: 7px 10px;
1733 border-radius: 6px;
1734 font-size: var(--t-sm);
1735 color: var(--text);
1736 transition: background var(--t-fast) var(--ease), color var(--t-fast) var(--ease);
1737 white-space: nowrap;
1738 }
1739 .nav-user-item:hover { background: var(--bg-hover); color: var(--text-strong); text-decoration: none; }
1740 .nav-user-item--danger { color: var(--red); }
1741 .nav-user-item--danger:hover { background: rgba(248,113,113,0.08); color: var(--red); }
1742
1743 navMenuIn {
1744 from { opacity: 0; transform: translateY(-4px) scale(0.97); }
1745 to { opacity: 1; transform: translateY(0) scale(1); }
1746 }
1747
16471748 main {
1648 max-width: 1240px;
1749 max-width: 1440px;
16491750 margin: 0 auto;
16501751 padding: 36px 24px 80px;
16511752 flex: 1;
@@ -1692,7 +1793,7 @@ const css = `
16921793 var(--bg);
16931794 }
16941795 footer .footer-inner {
1695 max-width: 1240px;
1796 max-width: 1440px;
16961797 margin: 0 auto;
16971798 display: grid;
16981799 grid-template-columns: 1fr auto;
@@ -1728,7 +1829,7 @@ const css = `
17281829 }
17291830 footer .footer-col a:hover { color: var(--text-strong); text-decoration: none; }
17301831 footer .footer-bottom {
1731 max-width: 1240px;
1832 max-width: 1440px;
17321833 margin: 40px auto 0;
17331834 padding-top: 24px;
17341835 border-top: 1px solid var(--border-subtle);
17351836