Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit06d5ffeunknown_key

feat: Week 2 — auth, user profiles, syntax highlighting, branch switching, stars, SSH keys

feat: Week 2 — auth, user profiles, syntax highlighting, branch switching, stars, SSH keys

- Auth system: registration, login, logout with bcrypt + sessions
- Auth middleware: soft auth (all pages) + hard auth (protected routes)
- User profiles: avatar, bio, public repo listing
- Repo creation via web UI with public/private visibility
- Syntax highlighting via highlight.js (40+ languages)
- Branch switching dropdown in file browser and commit log
- Star/unstar repositories with counter
- SSH key management (web UI + API)
- Settings page (profile editing)
- Auth-aware navbar (sign in/register vs user menu)
- 45 passing tests (was 19)

https://claude.ai/code/session_013wpQ5iX7qU6zy6PrtML3fP
Claude committed on April 12, 2026Parent: fc1817a
15 files changed+20057206d5ffe146a1c05144d3e62da97c2965eea7e3cd
15 changed files+2005−72
ModifiedREADME.md+29−2View fileUnifiedSplit
1# Gluecron.com
2AI-native code intelligence platform — git hosting, automated CI, and green ecosystem enforcement
1# gluecron
2
3AI-native code intelligence platform — git hosting, automated CI, and green ecosystem enforcement.
4
5## Quick Start
6
7```bash
8bun install
9bun dev
10```
11
12Then visit `http://localhost:3000` to register and create your first repository.
13
14## Features
15
16- **Git hosting** — clone, push, fetch via Smart HTTP protocol
17- **Web code browser** — file tree, syntax-highlighted source, commit log, unified diffs
18- **Authentication** — registration, login, sessions with bcrypt password hashing
19- **User profiles** — avatar, bio, public repository listing
20- **Repository management** — create repos via web UI, public/private visibility
21- **Branch switching** — dropdown to navigate between branches
22- **Stars** — star/unstar repositories
23- **SSH keys** — add/remove SSH keys for your account
24- **GateTest integration** — automated code scanning on every push
25- **Crontech deploy** — trigger deploys on push to main
26
27## Stack
28
29Bun + Hono + Drizzle ORM + Neon (PostgreSQL)
Addedsrc/__tests__/auth.test.ts+40−0View fileUnifiedSplit
1import { describe, it, expect } from "bun:test";
2import {
3 hashPassword,
4 verifyPassword,
5 generateSessionToken,
6 sessionExpiry,
7} from "../lib/auth";
8
9describe("auth utilities", () => {
10 it("should hash and verify passwords", async () => {
11 const password = "testpassword123";
12 const hash = await hashPassword(password);
13
14 expect(hash).toBeTruthy();
15 expect(hash).not.toBe(password);
16 expect(await verifyPassword(password, hash)).toBe(true);
17 expect(await verifyPassword("wrongpassword", hash)).toBe(false);
18 });
19
20 it("should generate unique session tokens", () => {
21 const token1 = generateSessionToken();
22 const token2 = generateSessionToken();
23
24 expect(token1).toBeTruthy();
25 expect(token1.length).toBe(64); // 32 bytes hex
26 expect(token1).not.toBe(token2);
27 });
28
29 it("should create future session expiry", () => {
30 const expiry = sessionExpiry();
31 const now = new Date();
32
33 expect(expiry.getTime()).toBeGreaterThan(now.getTime());
34 // Should be ~30 days from now
35 const diffDays =
36 (expiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24);
37 expect(diffDays).toBeGreaterThan(29);
38 expect(diffDays).toBeLessThan(31);
39 });
40});
Addedsrc/__tests__/highlight.test.ts+85−0View fileUnifiedSplit
1import { describe, it, expect } from "bun:test";
2import { highlightCode } from "../lib/highlight";
3
4describe("syntax highlighting", () => {
5 it("should highlight TypeScript code", () => {
6 const code = 'const x: number = 42;\nconsole.log(x);';
7 const result = highlightCode(code, "index.ts");
8
9 expect(result.language).toBe("typescript");
10 expect(result.html).toContain("hljs-");
11 expect(result.html).toContain("42");
12 });
13
14 it("should highlight Python code", () => {
15 const code = 'def hello():\n print("hello world")';
16 const result = highlightCode(code, "main.py");
17
18 expect(result.language).toBe("python");
19 expect(result.html).toContain("hljs-");
20 });
21
22 it("should highlight JSON", () => {
23 const code = '{"key": "value", "count": 42}';
24 const result = highlightCode(code, "config.json");
25
26 expect(result.language).toBe("json");
27 expect(result.html).toContain("hljs-");
28 });
29
30 it("should highlight Go code", () => {
31 const code = 'package main\n\nimport "fmt"\n\nfunc main() {\n\tfmt.Println("hello")\n}';
32 const result = highlightCode(code, "main.go");
33
34 expect(result.language).toBe("go");
35 expect(result.html).toContain("hljs-");
36 });
37
38 it("should highlight Rust code", () => {
39 const code = 'fn main() {\n println!("hello");\n}';
40 const result = highlightCode(code, "main.rs");
41
42 expect(result.language).toBe("rust");
43 expect(result.html).toContain("hljs-");
44 });
45
46 it("should handle unknown extensions gracefully", () => {
47 const code = "just some plain text";
48 const result = highlightCode(code, "readme.xyz");
49
50 // Should return escaped HTML
51 expect(result.html).toContain("just some plain text");
52 });
53
54 it("should escape HTML in plain text", () => {
55 const code = '<script>alert("xss")</script>';
56 const result = highlightCode(code, "file.xyz");
57
58 expect(result.html).not.toContain("<script>");
59 expect(result.html).toContain("&lt;script&gt;");
60 });
61
62 it("should highlight CSS", () => {
63 const code = "body { color: red; font-size: 14px; }";
64 const result = highlightCode(code, "style.css");
65
66 expect(result.language).toBe("css");
67 expect(result.html).toContain("hljs-");
68 });
69
70 it("should highlight SQL", () => {
71 const code = "SELECT * FROM users WHERE id = 1;";
72 const result = highlightCode(code, "query.sql");
73
74 expect(result.language).toBe("sql");
75 expect(result.html).toContain("hljs-");
76 });
77
78 it("should highlight Dockerfile", () => {
79 const code = "FROM node:20\nRUN npm install\nCMD [\"node\", \"index.js\"]";
80 const result = highlightCode(code, "Dockerfile");
81
82 // Dockerfile extension is lowercase 'dockerfile'
83 expect(result.html).toContain("node");
84 });
85});
Addedsrc/__tests__/web-routes.test.ts+153−0View fileUnifiedSplit
1import { describe, it, expect, beforeAll, afterAll } from "bun:test";
2import { join } from "path";
3import { rm, mkdir } from "fs/promises";
4import app from "../app";
5import { initBareRepo, getRepoPath } from "../git/repository";
6
7const TEST_REPOS = join(import.meta.dir, "../../.test-repos-web-" + Date.now());
8
9beforeAll(async () => {
10 await rm(TEST_REPOS, { recursive: true, force: true });
11 await mkdir(TEST_REPOS, { recursive: true });
12 process.env.GIT_REPOS_PATH = TEST_REPOS;
13
14 // Create a test repo with content
15 await initBareRepo("testuser", "myrepo");
16 const cloneDir = join(TEST_REPOS, "_clone");
17 await mkdir(cloneDir, { recursive: true });
18 const repoPath = getRepoPath("testuser", "myrepo");
19 const workDir = join(cloneDir, "work");
20
21 const run = async (cmd: string[], cwd: string) => {
22 const proc = Bun.spawn(cmd, { cwd, stdout: "pipe", stderr: "pipe" });
23 await proc.exited;
24 };
25
26 await run(["git", "clone", repoPath, workDir], TEST_REPOS);
27 await run(["git", "config", "user.email", "test@test.com"], workDir);
28 await run(["git", "config", "user.name", "Test"], workDir);
29
30 await mkdir(join(workDir, "src"), { recursive: true });
31 await Bun.write(join(workDir, "README.md"), "# My Repo\nHello");
32 await Bun.write(
33 join(workDir, "src/index.ts"),
34 'const x: number = 42;\nconsole.log(x);'
35 );
36
37 await run(["git", "add", "-A"], workDir);
38 await run(["git", "commit", "-m", "Initial commit"], workDir);
39 await run(["git", "branch", "-M", "main"], workDir);
40 await run(["git", "push", "-u", "origin", "main"], workDir);
41
42 // Create a second branch
43 await run(["git", "checkout", "-b", "feature"], workDir);
44 await Bun.write(join(workDir, "src/feature.ts"), "export const y = 1;");
45 await run(["git", "add", "-A"], workDir);
46 await run(["git", "commit", "-m", "Add feature"], workDir);
47 await run(["git", "push", "-u", "origin", "feature"], workDir);
48
49 await rm(cloneDir, { recursive: true, force: true });
50});
51
52afterAll(async () => {
53 await rm(TEST_REPOS, { recursive: true, force: true });
54});
55
56describe("web routes", () => {
57 it("GET / returns landing page", async () => {
58 const res = await app.request("/");
59 expect(res.status).toBe(200);
60 const html = await res.text();
61 expect(html).toContain("gluecron");
62 });
63
64 it("GET /login returns login form", async () => {
65 const res = await app.request("/login");
66 expect(res.status).toBe(200);
67 const html = await res.text();
68 expect(html).toContain("Sign in");
69 expect(html).toContain('name="username"');
70 expect(html).toContain('name="password"');
71 });
72
73 it("GET /register returns registration form", async () => {
74 const res = await app.request("/register");
75 expect(res.status).toBe(200);
76 const html = await res.text();
77 expect(html).toContain("Create account");
78 expect(html).toContain('name="username"');
79 expect(html).toContain('name="email"');
80 });
81
82 it("GET /new redirects to login without auth", async () => {
83 const res = await app.request("/new", { redirect: "manual" });
84 expect(res.status).toBe(302);
85 expect(res.headers.get("location")).toContain("/login");
86 });
87
88 it("GET /settings redirects to login without auth", async () => {
89 const res = await app.request("/settings", { redirect: "manual" });
90 expect(res.status).toBe(302);
91 expect(res.headers.get("location")).toContain("/login");
92 });
93
94 it("GET /:owner/:repo shows repo page", async () => {
95 const res = await app.request("/testuser/myrepo");
96 expect(res.status).toBe(200);
97 const html = await res.text();
98 expect(html).toContain("testuser");
99 expect(html).toContain("myrepo");
100 expect(html).toContain("README.md");
101 expect(html).toContain("src");
102 });
103
104 it("GET /:owner/:repo returns 404 for missing repo", async () => {
105 const res = await app.request("/nobody/nope");
106 expect(res.status).toBe(404);
107 });
108
109 it("GET /:owner/:repo/tree/:ref shows tree", async () => {
110 const res = await app.request("/testuser/myrepo/tree/main/src");
111 expect(res.status).toBe(200);
112 const html = await res.text();
113 expect(html).toContain("index.ts");
114 });
115
116 it("GET /:owner/:repo/blob/:ref/:path shows file with syntax highlighting", async () => {
117 const res = await app.request("/testuser/myrepo/blob/main/src/index.ts");
118 expect(res.status).toBe(200);
119 const html = await res.text();
120 // Should contain highlighted code
121 expect(html).toContain("hljs-");
122 expect(html).toContain("42");
123 });
124
125 it("GET /:owner/:repo/commits shows commits", async () => {
126 const res = await app.request("/testuser/myrepo/commits");
127 expect(res.status).toBe(200);
128 const html = await res.text();
129 expect(html).toContain("Initial commit");
130 });
131
132 it("supports branch switching — feature branch", async () => {
133 const res = await app.request("/testuser/myrepo/tree/feature");
134 expect(res.status).toBe(200);
135 const html = await res.text();
136 expect(html).toContain("feature");
137 });
138
139 it("feature branch has feature.ts", async () => {
140 const res = await app.request("/testuser/myrepo/tree/feature/src");
141 expect(res.status).toBe(200);
142 const html = await res.text();
143 expect(html).toContain("feature.ts");
144 });
145
146 it("shows branch dropdown when multiple branches", async () => {
147 const res = await app.request("/testuser/myrepo");
148 const html = await res.text();
149 expect(html).toContain("branch-dropdown");
150 expect(html).toContain("main");
151 expect(html).toContain("feature");
152 });
153});
Modifiedsrc/app.ts+8−0View fileUnifiedSplit
33import { cors } from "hono/cors";
44import gitRoutes from "./routes/git";
55import apiRoutes from "./routes/api";
6import authRoutes from "./routes/auth";
7import settingsRoutes from "./routes/settings";
68import webRoutes from "./routes/web";
79
810const app = new Hono();
1719// REST API
1820app.route("/", apiRoutes);
1921
22// Auth routes (register, login, logout)
23app.route("/", authRoutes);
24
25// Settings routes (profile, SSH keys) — requires auth
26app.route("/", settingsRoutes);
27
2028// Web UI (catch-all, must be last)
2129app.route("/", webRoutes);
2230
Modifiedsrc/db/schema.ts+29−1View fileUnifiedSplit
2020 updatedAt: timestamp("updated_at").defaultNow().notNull(),
2121});
2222
23export const sessions = pgTable("sessions", {
24 id: uuid("id").primaryKey().defaultRandom(),
25 userId: uuid("user_id")
26 .notNull()
27 .references(() => users.id, { onDelete: "cascade" }),
28 token: text("token").notNull().unique(),
29 expiresAt: timestamp("expires_at").notNull(),
30 createdAt: timestamp("created_at").defaultNow().notNull(),
31});
32
2333export const repositories = pgTable(
2434 "repositories",
2535 {
4151 (table) => [uniqueIndex("repos_owner_name").on(table.ownerId, table.name)]
4252);
4353
54export const stars = pgTable(
55 "stars",
56 {
57 id: uuid("id").primaryKey().defaultRandom(),
58 userId: uuid("user_id")
59 .notNull()
60 .references(() => users.id, { onDelete: "cascade" }),
61 repositoryId: uuid("repository_id")
62 .notNull()
63 .references(() => repositories.id, { onDelete: "cascade" }),
64 createdAt: timestamp("created_at").defaultNow().notNull(),
65 },
66 (table) => [uniqueIndex("stars_user_repo").on(table.userId, table.repositoryId)]
67);
68
4469export const sshKeys = pgTable("ssh_keys", {
4570 id: uuid("id").primaryKey().defaultRandom(),
4671 userId: uuid("user_id")
4772 .notNull()
48 .references(() => users.id),
73 .references(() => users.id, { onDelete: "cascade" }),
4974 title: text("title").notNull(),
5075 fingerprint: text("fingerprint").notNull(),
5176 publicKey: text("public_key").notNull(),
5782export type NewUser = typeof users.$inferInsert;
5883export type Repository = typeof repositories.$inferSelect;
5984export type NewRepository = typeof repositories.$inferInsert;
85export type Session = typeof sessions.$inferSelect;
86export type Star = typeof stars.$inferSelect;
87export type SshKey = typeof sshKeys.$inferSelect;
Addedsrc/lib/auth.ts+44−0View fileUnifiedSplit
1/**
2 * Authentication utilities — password hashing, session tokens.
3 * Uses Bun's native crypto for Argon2-like password hashing.
4 */
5
6const SESSION_DURATION_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
7
8export async function hashPassword(password: string): Promise<string> {
9 return await Bun.password.hash(password, { algorithm: "bcrypt", cost: 10 });
10}
11
12export async function verifyPassword(
13 password: string,
14 hash: string
15): Promise<boolean> {
16 return await Bun.password.verify(password, hash);
17}
18
19export function generateSessionToken(): string {
20 const bytes = crypto.getRandomValues(new Uint8Array(32));
21 return Array.from(bytes)
22 .map((b) => b.toString(16).padStart(2, "0"))
23 .join("");
24}
25
26export function sessionCookieOptions(): {
27 httpOnly: boolean;
28 secure: boolean;
29 sameSite: "Lax";
30 path: string;
31 maxAge: number;
32} {
33 return {
34 httpOnly: true,
35 secure: process.env.NODE_ENV === "production",
36 sameSite: "Lax",
37 path: "/",
38 maxAge: SESSION_DURATION_MS / 1000,
39 };
40}
41
42export function sessionExpiry(): Date {
43 return new Date(Date.now() + SESSION_DURATION_MS);
44}
Addedsrc/lib/highlight.ts+132−0View fileUnifiedSplit
1/**
2 * Syntax highlighting via highlight.js — server-side.
3 * Returns pre-highlighted HTML strings for code display.
4 */
5
6import hljs from "highlight.js";
7
8const EXT_TO_LANG: Record<string, string> = {
9 ts: "typescript",
10 tsx: "typescript",
11 js: "javascript",
12 jsx: "javascript",
13 py: "python",
14 rb: "ruby",
15 rs: "rust",
16 go: "go",
17 java: "java",
18 c: "c",
19 cpp: "cpp",
20 h: "c",
21 hpp: "cpp",
22 cs: "csharp",
23 php: "php",
24 swift: "swift",
25 kt: "kotlin",
26 scala: "scala",
27 sh: "bash",
28 bash: "bash",
29 zsh: "bash",
30 fish: "bash",
31 ps1: "powershell",
32 sql: "sql",
33 html: "html",
34 htm: "html",
35 css: "css",
36 scss: "scss",
37 less: "less",
38 json: "json",
39 yaml: "yaml",
40 yml: "yaml",
41 toml: "ini",
42 xml: "xml",
43 md: "markdown",
44 markdown: "markdown",
45 dockerfile: "dockerfile",
46 makefile: "makefile",
47 cmake: "cmake",
48 lua: "lua",
49 r: "r",
50 dart: "dart",
51 ex: "elixir",
52 exs: "elixir",
53 erl: "erlang",
54 hs: "haskell",
55 ml: "ocaml",
56 clj: "clojure",
57 vim: "vim",
58 tf: "hcl",
59 proto: "protobuf",
60 graphql: "graphql",
61 gql: "graphql",
62};
63
64export function highlightCode(
65 code: string,
66 filename: string
67): { html: string; language: string | null } {
68 const ext = filename.split(".").pop()?.toLowerCase() || "";
69 const lang = EXT_TO_LANG[ext];
70
71 if (lang) {
72 try {
73 const result = hljs.highlight(code, { language: lang });
74 return { html: result.value, language: lang };
75 } catch {
76 // fallback to auto-detect
77 }
78 }
79
80 // Try auto-detection for unknown extensions
81 try {
82 const result = hljs.highlightAuto(code);
83 if (result.language && result.relevance > 5) {
84 return { html: result.value, language: result.language };
85 }
86 } catch {
87 // fallback to plain
88 }
89
90 return { html: escapeHtml(code), language: null };
91}
92
93function escapeHtml(str: string): string {
94 return str
95 .replace(/&/g, "&amp;")
96 .replace(/</g, "&lt;")
97 .replace(/>/g, "&gt;")
98 .replace(/"/g, "&quot;");
99}
100
101export const hljsThemeCss = `
102 .hljs-keyword { color: #ff7b72; }
103 .hljs-built_in { color: #ffa657; }
104 .hljs-type { color: #ffa657; }
105 .hljs-literal { color: #79c0ff; }
106 .hljs-number { color: #79c0ff; }
107 .hljs-string { color: #a5d6ff; }
108 .hljs-regexp { color: #a5d6ff; }
109 .hljs-symbol { color: #79c0ff; }
110 .hljs-title { color: #d2a8ff; }
111 .hljs-title.function_ { color: #d2a8ff; }
112 .hljs-title.class_ { color: #ffa657; }
113 .hljs-params { color: #e6edf3; }
114 .hljs-comment { color: #8b949e; font-style: italic; }
115 .hljs-doctag { color: #8b949e; }
116 .hljs-meta { color: #79c0ff; }
117 .hljs-attr { color: #79c0ff; }
118 .hljs-attribute { color: #79c0ff; }
119 .hljs-selector-tag { color: #ff7b72; }
120 .hljs-selector-class { color: #d2a8ff; }
121 .hljs-selector-id { color: #79c0ff; }
122 .hljs-variable { color: #ffa657; }
123 .hljs-template-variable { color: #ffa657; }
124 .hljs-tag { color: #7ee787; }
125 .hljs-name { color: #7ee787; }
126 .hljs-section { color: #d2a8ff; font-weight: bold; }
127 .hljs-addition { color: #aff5b4; background: rgba(63, 185, 80, 0.15); }
128 .hljs-deletion { color: #ffdcd7; background: rgba(248, 81, 73, 0.1); }
129 .hljs-property { color: #79c0ff; }
130 .hljs-subst { color: #e6edf3; }
131 .hljs-punctuation { color: #8b949e; }
132`;
Addedsrc/middleware/auth.ts+91−0View fileUnifiedSplit
1/**
2 * Auth middleware — reads session cookie, injects user into context.
3 */
4
5import { createMiddleware } from "hono/factory";
6import { getCookie } from "hono/cookie";
7import { eq, gt } from "drizzle-orm";
8import { db } from "../db";
9import { sessions, users } from "../db/schema";
10import type { User } from "../db/schema";
11
12export type AuthEnv = {
13 Variables: {
14 user: User | null;
15 };
16};
17
18/**
19 * Soft auth — sets c.get("user") to the current user or null.
20 * Does NOT block unauthenticated requests.
21 */
22export const softAuth = createMiddleware<AuthEnv>(async (c, next) => {
23 const token = getCookie(c, "session");
24 if (!token) {
25 c.set("user", null);
26 return next();
27 }
28
29 try {
30 const [session] = await db
31 .select()
32 .from(sessions)
33 .where(eq(sessions.token, token))
34 .limit(1);
35
36 if (!session || new Date(session.expiresAt) < new Date()) {
37 c.set("user", null);
38 return next();
39 }
40
41 const [user] = await db
42 .select()
43 .from(users)
44 .where(eq(users.id, session.userId))
45 .limit(1);
46
47 c.set("user", user || null);
48 } catch {
49 c.set("user", null);
50 }
51
52 return next();
53});
54
55/**
56 * Hard auth — redirects to /login if not authenticated.
57 */
58export const requireAuth = createMiddleware<AuthEnv>(async (c, next) => {
59 const token = getCookie(c, "session");
60 if (!token) {
61 return c.redirect(`/login?redirect=${encodeURIComponent(c.req.path)}`);
62 }
63
64 try {
65 const [session] = await db
66 .select()
67 .from(sessions)
68 .where(eq(sessions.token, token))
69 .limit(1);
70
71 if (!session || new Date(session.expiresAt) < new Date()) {
72 return c.redirect(`/login?redirect=${encodeURIComponent(c.req.path)}`);
73 }
74
75 const [user] = await db
76 .select()
77 .from(users)
78 .where(eq(users.id, session.userId))
79 .limit(1);
80
81 if (!user) {
82 return c.redirect(`/login?redirect=${encodeURIComponent(c.req.path)}`);
83 }
84
85 c.set("user", user);
86 } catch {
87 return c.redirect(`/login?redirect=${encodeURIComponent(c.req.path)}`);
88 }
89
90 return next();
91});
Modifiedsrc/routes/api.ts+2−1View fileUnifiedSplit
77import { db } from "../db";
88import { users, repositories } from "../db/schema";
99import { initBareRepo, repoExists } from "../git/repository";
10import { hashPassword } from "../lib/auth";
1011
1112const api = new Hono().basePath("/api");
1213
119120 .values({
120121 username: body.username,
121122 email: body.email,
122 passwordHash: "placeholder", // TODO: real auth
123 passwordHash: await hashPassword("changeme"),
123124 })
124125 .returning();
125126 }
Addedsrc/routes/auth.tsx+326−0View fileUnifiedSplit
1/**
2 * Auth routes — register, login, logout (web + API).
3 */
4
5import { Hono } from "hono";
6import { setCookie, deleteCookie } from "hono/cookie";
7import { eq } from "drizzle-orm";
8import { db } from "../db";
9import { users, sessions } from "../db/schema";
10import {
11 hashPassword,
12 verifyPassword,
13 generateSessionToken,
14 sessionCookieOptions,
15 sessionExpiry,
16} from "../lib/auth";
17import { Layout } from "../views/layout";
18import type { AuthEnv } from "../middleware/auth";
19
20const auth = new Hono<AuthEnv>();
21
22// --- Web UI ---
23
24auth.get("/register", (c) => {
25 const error = c.req.query("error");
26 return c.html(
27 <Layout title="Register">
28 <div class="auth-container">
29 <h2>Create account</h2>
30 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
31 <form method="POST" action="/register">
32 <div class="form-group">
33 <label for="username">Username</label>
34 <input
35 type="text"
36 id="username"
37 name="username"
38 required
39 pattern="^[a-zA-Z0-9_-]+$"
40 minLength={2}
41 maxLength={39}
42 placeholder="your-username"
43 autocomplete="username"
44 />
45 </div>
46 <div class="form-group">
47 <label for="email">Email</label>
48 <input
49 type="email"
50 id="email"
51 name="email"
52 required
53 placeholder="you@example.com"
54 autocomplete="email"
55 />
56 </div>
57 <div class="form-group">
58 <label for="password">Password</label>
59 <input
60 type="password"
61 id="password"
62 name="password"
63 required
64 minLength={8}
65 placeholder="Min 8 characters"
66 autocomplete="new-password"
67 />
68 </div>
69 <button type="submit" class="btn btn-primary">
70 Create account
71 </button>
72 </form>
73 <p class="auth-switch">
74 Already have an account? <a href="/login">Sign in</a>
75 </p>
76 </div>
77 </Layout>
78 );
79});
80
81auth.post("/register", async (c) => {
82 const body = await c.req.parseBody();
83 const username = String(body.username || "").trim();
84 const email = String(body.email || "").trim();
85 const password = String(body.password || "");
86
87 if (!username || !email || !password) {
88 return c.redirect("/register?error=All+fields+are+required");
89 }
90
91 if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
92 return c.redirect(
93 "/register?error=Username+may+only+contain+letters%2C+numbers%2C+hyphens+and+underscores"
94 );
95 }
96
97 if (password.length < 8) {
98 return c.redirect("/register?error=Password+must+be+at+least+8+characters");
99 }
100
101 // Check existing
102 const [existingUser] = await db
103 .select()
104 .from(users)
105 .where(eq(users.username, username))
106 .limit(1);
107 if (existingUser) {
108 return c.redirect("/register?error=Username+already+taken");
109 }
110
111 const [existingEmail] = await db
112 .select()
113 .from(users)
114 .where(eq(users.email, email))
115 .limit(1);
116 if (existingEmail) {
117 return c.redirect("/register?error=Email+already+registered");
118 }
119
120 const passwordHash = await hashPassword(password);
121
122 const [user] = await db
123 .insert(users)
124 .values({ username, email, passwordHash })
125 .returning();
126
127 // Create session
128 const token = generateSessionToken();
129 await db.insert(sessions).values({
130 userId: user.id,
131 token,
132 expiresAt: sessionExpiry(),
133 });
134
135 setCookie(c, "session", token, sessionCookieOptions());
136
137 const redirect = c.req.query("redirect") || "/";
138 return c.redirect(redirect);
139});
140
141auth.get("/login", (c) => {
142 const error = c.req.query("error");
143 const redirect = c.req.query("redirect") || "";
144 return c.html(
145 <Layout title="Sign in">
146 <div class="auth-container">
147 <h2>Sign in</h2>
148 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
149 <form
150 method="POST"
151 action={`/login${redirect ? `?redirect=${encodeURIComponent(redirect)}` : ""}`}
152 >
153 <div class="form-group">
154 <label for="username">Username or email</label>
155 <input
156 type="text"
157 id="username"
158 name="username"
159 required
160 placeholder="username or email"
161 autocomplete="username"
162 />
163 </div>
164 <div class="form-group">
165 <label for="password">Password</label>
166 <input
167 type="password"
168 id="password"
169 name="password"
170 required
171 placeholder="Password"
172 autocomplete="current-password"
173 />
174 </div>
175 <button type="submit" class="btn btn-primary">
176 Sign in
177 </button>
178 </form>
179 <p class="auth-switch">
180 New to gluecron? <a href="/register">Create an account</a>
181 </p>
182 </div>
183 </Layout>
184 );
185});
186
187auth.post("/login", async (c) => {
188 const body = await c.req.parseBody();
189 const identifier = String(body.username || "").trim();
190 const password = String(body.password || "");
191 const redirect = c.req.query("redirect") || "/";
192
193 if (!identifier || !password) {
194 return c.redirect("/login?error=All+fields+are+required");
195 }
196
197 // Find user by username or email
198 const isEmail = identifier.includes("@");
199 const [user] = await db
200 .select()
201 .from(users)
202 .where(
203 isEmail
204 ? eq(users.email, identifier)
205 : eq(users.username, identifier)
206 )
207 .limit(1);
208
209 if (!user) {
210 return c.redirect("/login?error=Invalid+credentials");
211 }
212
213 const valid = await verifyPassword(password, user.passwordHash);
214 if (!valid) {
215 return c.redirect("/login?error=Invalid+credentials");
216 }
217
218 const token = generateSessionToken();
219 await db.insert(sessions).values({
220 userId: user.id,
221 token,
222 expiresAt: sessionExpiry(),
223 });
224
225 setCookie(c, "session", token, sessionCookieOptions());
226 return c.redirect(redirect);
227});
228
229auth.get("/logout", async (c) => {
230 deleteCookie(c, "session", { path: "/" });
231 return c.redirect("/");
232});
233
234// --- API ---
235
236auth.post("/api/auth/register", async (c) => {
237 const body = await c.req.json<{
238 username: string;
239 email: string;
240 password: string;
241 }>();
242
243 if (!body.username || !body.email || !body.password) {
244 return c.json({ error: "username, email, and password are required" }, 400);
245 }
246
247 if (!/^[a-zA-Z0-9_-]+$/.test(body.username)) {
248 return c.json({ error: "Invalid username" }, 400);
249 }
250
251 if (body.password.length < 8) {
252 return c.json({ error: "Password must be at least 8 characters" }, 400);
253 }
254
255 const [existing] = await db
256 .select()
257 .from(users)
258 .where(eq(users.username, body.username))
259 .limit(1);
260 if (existing) {
261 return c.json({ error: "Username already taken" }, 409);
262 }
263
264 const passwordHash = await hashPassword(body.password);
265 const [user] = await db
266 .insert(users)
267 .values({
268 username: body.username,
269 email: body.email,
270 passwordHash,
271 })
272 .returning();
273
274 const token = generateSessionToken();
275 await db.insert(sessions).values({
276 userId: user.id,
277 token,
278 expiresAt: sessionExpiry(),
279 });
280
281 return c.json(
282 {
283 user: { id: user.id, username: user.username, email: user.email },
284 token,
285 },
286 201
287 );
288});
289
290auth.post("/api/auth/login", async (c) => {
291 const body = await c.req.json<{ username: string; password: string }>();
292
293 if (!body.username || !body.password) {
294 return c.json({ error: "username and password are required" }, 400);
295 }
296
297 const isEmail = body.username.includes("@");
298 const [user] = await db
299 .select()
300 .from(users)
301 .where(
302 isEmail
303 ? eq(users.email, body.username)
304 : eq(users.username, body.username)
305 )
306 .limit(1);
307
308 if (!user) return c.json({ error: "Invalid credentials" }, 401);
309
310 const valid = await verifyPassword(body.password, user.passwordHash);
311 if (!valid) return c.json({ error: "Invalid credentials" }, 401);
312
313 const token = generateSessionToken();
314 await db.insert(sessions).values({
315 userId: user.id,
316 token,
317 expiresAt: sessionExpiry(),
318 });
319
320 return c.json({
321 user: { id: user.id, username: user.username, email: user.email },
322 token,
323 });
324});
325
326export default auth;
Addedsrc/routes/settings.tsx+309−0View fileUnifiedSplit
1/**
2 * User settings routes — profile, SSH keys.
3 */
4
5import { Hono } from "hono";
6import { eq } from "drizzle-orm";
7import { db } from "../db";
8import { users, sshKeys } from "../db/schema";
9import type { AuthEnv } from "../middleware/auth";
10import { requireAuth } from "../middleware/auth";
11import { Layout } from "../views/layout";
12
13const settings = new Hono<AuthEnv>();
14
15// Auth guard scoped to /settings paths only
16settings.use("/settings/*", requireAuth);
17settings.use("/settings", requireAuth);
18settings.use("/api/user/*", requireAuth);
19
20// Profile settings
21settings.get("/settings", (c) => {
22 const user = c.get("user")!;
23 const success = c.req.query("success");
24 return c.html(
25 <Layout title="Settings">
26 <div class="settings-container">
27 <h2>Profile settings</h2>
28 {success && (
29 <div class="auth-success">
30 {decodeURIComponent(success)}
31 </div>
32 )}
33 <form method="POST" action="/settings/profile">
34 <div class="form-group">
35 <label for="username">Username</label>
36 <input
37 type="text"
38 id="username"
39 value={user.username}
40 disabled
41 class="input-disabled"
42 />
43 </div>
44 <div class="form-group">
45 <label for="display_name">Display name</label>
46 <input
47 type="text"
48 id="display_name"
49 name="display_name"
50 value={user.displayName || ""}
51 placeholder="Your display name"
52 />
53 </div>
54 <div class="form-group">
55 <label for="bio">Bio</label>
56 <textarea
57 id="bio"
58 name="bio"
59 rows={3}
60 placeholder="Tell us about yourself"
61 >
62 {user.bio || ""}
63 </textarea>
64 </div>
65 <div class="form-group">
66 <label for="email">Email</label>
67 <input
68 type="email"
69 id="email"
70 name="email"
71 value={user.email}
72 required
73 />
74 </div>
75 <button type="submit" class="btn btn-primary">
76 Update profile
77 </button>
78 </form>
79 </div>
80 </Layout>
81 );
82});
83
84settings.post("/settings/profile", async (c) => {
85 const user = c.get("user")!;
86 const body = await c.req.parseBody();
87
88 await db
89 .update(users)
90 .set({
91 displayName: String(body.display_name || "").trim() || null,
92 bio: String(body.bio || "").trim() || null,
93 email: String(body.email || "").trim() || user.email,
94 updatedAt: new Date(),
95 })
96 .where(eq(users.id, user.id));
97
98 return c.redirect("/settings?success=Profile+updated");
99});
100
101// SSH Keys page
102settings.get("/settings/keys", async (c) => {
103 const user = c.get("user")!;
104 const success = c.req.query("success");
105 const error = c.req.query("error");
106
107 const keys = await db
108 .select()
109 .from(sshKeys)
110 .where(eq(sshKeys.userId, user.id));
111
112 return c.html(
113 <Layout title="SSH Keys">
114 <div class="settings-container">
115 <h2>SSH Keys</h2>
116 {success && (
117 <div class="auth-success">{decodeURIComponent(success)}</div>
118 )}
119 {error && (
120 <div class="auth-error">{decodeURIComponent(error)}</div>
121 )}
122 <div class="ssh-keys-list">
123 {keys.length === 0 ? (
124 <p style="color: var(--text-muted)">
125 No SSH keys yet. Add one below.
126 </p>
127 ) : (
128 keys.map((key) => (
129 <div class="ssh-key-item">
130 <div>
131 <strong>{key.title}</strong>
132 <div class="ssh-key-meta">
133 <code>{key.fingerprint}</code>
134 <span>
135 Added{" "}
136 {new Date(key.createdAt).toLocaleDateString("en-US", {
137 month: "short",
138 day: "numeric",
139 year: "numeric",
140 })}
141 </span>
142 {key.lastUsedAt && (
143 <span>
144 — Last used{" "}
145 {new Date(key.lastUsedAt).toLocaleDateString()}
146 </span>
147 )}
148 </div>
149 </div>
150 <form method="POST" action={`/settings/keys/${key.id}/delete`}>
151 <button type="submit" class="btn btn-danger btn-sm">
152 Delete
153 </button>
154 </form>
155 </div>
156 ))
157 )}
158 </div>
159
160 <h3 style="margin-top: 24px">Add new SSH key</h3>
161 <form method="POST" action="/settings/keys">
162 <div class="form-group">
163 <label for="title">Title</label>
164 <input
165 type="text"
166 id="title"
167 name="title"
168 required
169 placeholder="e.g. My laptop"
170 />
171 </div>
172 <div class="form-group">
173 <label for="public_key">Public key</label>
174 <textarea
175 id="public_key"
176 name="public_key"
177 rows={4}
178 required
179 placeholder="ssh-ed25519 AAAA... or ssh-rsa AAAA..."
180 style="font-family: var(--font-mono); font-size: 12px"
181 />
182 </div>
183 <button type="submit" class="btn btn-primary">
184 Add SSH key
185 </button>
186 </form>
187 </div>
188 </Layout>
189 );
190});
191
192settings.post("/settings/keys", async (c) => {
193 const user = c.get("user")!;
194 const body = await c.req.parseBody();
195 const title = String(body.title || "").trim();
196 const publicKey = String(body.public_key || "").trim();
197
198 if (!title || !publicKey) {
199 return c.redirect("/settings/keys?error=Title+and+key+are+required");
200 }
201
202 // Basic validation
203 if (
204 !publicKey.startsWith("ssh-rsa ") &&
205 !publicKey.startsWith("ssh-ed25519 ") &&
206 !publicKey.startsWith("ecdsa-sha2-")
207 ) {
208 return c.redirect("/settings/keys?error=Invalid+SSH+public+key+format");
209 }
210
211 // Generate a simple fingerprint (hash of key data)
212 const keyData = publicKey.split(" ")[1] || "";
213 const hashBuffer = await crypto.subtle.digest(
214 "SHA-256",
215 new TextEncoder().encode(keyData)
216 );
217 const fingerprint =
218 "SHA256:" +
219 btoa(String.fromCharCode(...new Uint8Array(hashBuffer)))
220 .replace(/=+$/, "");
221
222 await db.insert(sshKeys).values({
223 userId: user.id,
224 title,
225 fingerprint,
226 publicKey,
227 });
228
229 return c.redirect("/settings/keys?success=SSH+key+added");
230});
231
232settings.post("/settings/keys/:id/delete", async (c) => {
233 const user = c.get("user")!;
234 const keyId = c.req.param("id");
235
236 // Verify ownership
237 const [key] = await db
238 .select()
239 .from(sshKeys)
240 .where(eq(sshKeys.id, keyId))
241 .limit(1);
242
243 if (!key || key.userId !== user.id) {
244 return c.redirect("/settings/keys?error=Key+not+found");
245 }
246
247 await db.delete(sshKeys).where(eq(sshKeys.id, keyId));
248 return c.redirect("/settings/keys?success=SSH+key+deleted");
249});
250
251// SSH Keys API
252settings.get("/api/user/keys", async (c) => {
253 const user = c.get("user")!;
254 const keys = await db
255 .select()
256 .from(sshKeys)
257 .where(eq(sshKeys.userId, user.id));
258 return c.json(keys);
259});
260
261settings.post("/api/user/keys", async (c) => {
262 const user = c.get("user")!;
263 const body = await c.req.json<{ title: string; public_key: string }>();
264
265 if (!body.title || !body.public_key) {
266 return c.json({ error: "title and public_key are required" }, 400);
267 }
268
269 const keyData = body.public_key.split(" ")[1] || "";
270 const hashBuffer = await crypto.subtle.digest(
271 "SHA-256",
272 new TextEncoder().encode(keyData)
273 );
274 const fingerprint =
275 "SHA256:" +
276 btoa(String.fromCharCode(...new Uint8Array(hashBuffer))).replace(/=+$/, "");
277
278 const [key] = await db
279 .insert(sshKeys)
280 .values({
281 userId: user.id,
282 title: body.title,
283 fingerprint,
284 publicKey: body.public_key,
285 })
286 .returning();
287
288 return c.json(key, 201);
289});
290
291settings.delete("/api/user/keys/:id", async (c) => {
292 const user = c.get("user")!;
293 const keyId = c.req.param("id");
294
295 const [key] = await db
296 .select()
297 .from(sshKeys)
298 .where(eq(sshKeys.id, keyId))
299 .limit(1);
300
301 if (!key || key.userId !== user.id) {
302 return c.json({ error: "Key not found" }, 404);
303 }
304
305 await db.delete(sshKeys).where(eq(sshKeys.id, keyId));
306 return c.json({ deleted: true });
307});
308
309export default settings;
Modifiedsrc/routes/web.tsx+397−51View fileUnifiedSplit
11/**
22 * Web UI routes — browse repositories, code, commits, diffs.
3 * Now auth-aware with user profiles, repo creation, stars, and syntax highlighting.
34 */
45
56import { Hono } from "hono";
7import { eq, and, desc } from "drizzle-orm";
8import { db } from "../db";
9import { users, repositories, stars } from "../db/schema";
610import { Layout } from "../views/layout";
711import {
812 RepoHeader,
1115 FileTable,
1216 CommitList,
1317 DiffView,
18 RepoCard,
19 BranchSwitcher,
20 HighlightedCode,
21 PlainCode,
1422} from "../views/components";
1523import {
1624 getTree,
2331 getDefaultBranch,
2432 listBranches,
2533 repoExists,
34 initBareRepo,
2635} from "../git/repository";
36import { highlightCode } from "../lib/highlight";
37import { softAuth, requireAuth } from "../middleware/auth";
38import type { AuthEnv } from "../middleware/auth";
2739
28const web = new Hono();
40const web = new Hono<AuthEnv>();
41
42// Soft auth on all web routes — c.get("user") available but may be null
43web.use("*", softAuth);
2944
3045// Home page
31web.get("/", (c) => {
46web.get("/", async (c) => {
47 const user = c.get("user");
48
49 if (user) {
50 // Show user's repos
51 const repos = await db
52 .select()
53 .from(repositories)
54 .where(eq(repositories.ownerId, user.id))
55 .orderBy(desc(repositories.updatedAt));
56
57 return c.html(
58 <Layout title="Dashboard" user={user}>
59 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px">
60 <h2>Your repositories</h2>
61 <a href="/new" class="btn btn-primary">
62 + New repository
63 </a>
64 </div>
65 {repos.length === 0 ? (
66 <div class="empty-state">
67 <h2>No repositories yet</h2>
68 <p>Create your first repository to get started.</p>
69 </div>
70 ) : (
71 <div class="card-grid">
72 {repos.map((repo) => (
73 <RepoCard repo={repo} ownerName={user.username} />
74 ))}
75 </div>
76 )}
77 </Layout>
78 );
79 }
80
3281 return c.html(
33 <Layout>
82 <Layout user={null}>
3483 <div class="empty-state">
3584 <h2>gluecron</h2>
3685 <p>AI-native code intelligence platform</p>
37 <pre>{`# Quick start
86 <div style="margin-top: 24px; display: flex; gap: 12px; justify-content: center">
87 <a href="/register" class="btn btn-primary">
88 Get started
89 </a>
90 <a href="/login" class="btn">
91 Sign in
92 </a>
93 </div>
94 <pre style="margin-top: 32px">{`# Quick start
3895curl -X POST http://localhost:3000/api/setup \\
3996 -H 'Content-Type: application/json' \\
4097 -d '{"username":"you","email":"you@dev.com","repoName":"hello"}'
46103 );
47104});
48105
49// User profile (list repos) — placeholder
106// New repository form
107web.get("/new", requireAuth, (c) => {
108 const user = c.get("user")!;
109 const error = c.req.query("error");
110
111 return c.html(
112 <Layout title="New repository" user={user}>
113 <div class="new-repo-form">
114 <h2>Create a new repository</h2>
115 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
116 <form method="POST" action="/new">
117 <div class="form-group">
118 <label>Owner</label>
119 <input type="text" value={user.username} disabled class="input-disabled" />
120 </div>
121 <div class="form-group">
122 <label for="name">Repository name</label>
123 <input
124 type="text"
125 id="name"
126 name="name"
127 required
128 pattern="^[a-zA-Z0-9._-]+$"
129 placeholder="my-project"
130 autocomplete="off"
131 />
132 </div>
133 <div class="form-group">
134 <label for="description">Description (optional)</label>
135 <input
136 type="text"
137 id="description"
138 name="description"
139 placeholder="A short description of your repository"
140 />
141 </div>
142 <div class="visibility-options">
143 <label class="visibility-option">
144 <input type="radio" name="visibility" value="public" checked />
145 <div class="vis-label">Public</div>
146 <div class="vis-desc">Anyone can see this repository</div>
147 </label>
148 <label class="visibility-option">
149 <input type="radio" name="visibility" value="private" />
150 <div class="vis-label">Private</div>
151 <div class="vis-desc">Only you can see this repository</div>
152 </label>
153 </div>
154 <button type="submit" class="btn btn-primary">
155 Create repository
156 </button>
157 </form>
158 </div>
159 </Layout>
160 );
161});
162
163web.post("/new", requireAuth, async (c) => {
164 const user = c.get("user")!;
165 const body = await c.req.parseBody();
166 const name = String(body.name || "").trim();
167 const description = String(body.description || "").trim();
168 const isPrivate = body.visibility === "private";
169
170 if (!name) {
171 return c.redirect("/new?error=Repository+name+is+required");
172 }
173
174 if (!/^[a-zA-Z0-9._-]+$/.test(name)) {
175 return c.redirect("/new?error=Invalid+repository+name");
176 }
177
178 if (await repoExists(user.username, name)) {
179 return c.redirect("/new?error=Repository+already+exists");
180 }
181
182 const diskPath = await initBareRepo(user.username, name);
183
184 await db.insert(repositories).values({
185 name,
186 ownerId: user.id,
187 description: description || null,
188 isPrivate,
189 diskPath,
190 });
191
192 return c.redirect(`/${user.username}/${name}`);
193});
194
195// User profile
50196web.get("/:owner", async (c) => {
51 const { owner } = c.req.param();
197 const { owner: ownerName } = c.req.param();
198 const user = c.get("user");
199
200 // Avoid clashing with fixed routes
201 if (
202 ["login", "register", "logout", "new", "settings", "api"].includes(
203 ownerName
204 )
205 ) {
206 return c.notFound();
207 }
208
209 let ownerUser;
210 try {
211 const [found] = await db
212 .select()
213 .from(users)
214 .where(eq(users.username, ownerName))
215 .limit(1);
216 ownerUser = found;
217 } catch {
218 // DB not available — check if repos exist on disk
219 ownerUser = null;
220 }
221
222 // Even without DB, show repos if they exist on disk
223 let repos: any[] = [];
224 if (ownerUser) {
225 const allRepos = await db
226 .select()
227 .from(repositories)
228 .where(eq(repositories.ownerId, ownerUser.id))
229 .orderBy(desc(repositories.updatedAt));
230
231 // Show public repos to everyone, private only to owner
232 repos =
233 user?.id === ownerUser.id
234 ? allRepos
235 : allRepos.filter((r) => !r.isPrivate);
236 }
237
52238 return c.html(
53 <Layout title={owner}>
54 <h2 style="margin-bottom: 16px">{owner}</h2>
55 <p style="color: var(--text-muted)">
56 Repository listing coming soon. Use the API to browse repos.
57 </p>
239 <Layout title={ownerName} user={user}>
240 <div class="user-profile">
241 <div class="user-avatar">
242 {(ownerUser?.displayName || ownerName)[0].toUpperCase()}
243 </div>
244 <div class="user-info">
245 <h2>{ownerUser?.displayName || ownerName}</h2>
246 <div class="username">@{ownerName}</div>
247 {ownerUser?.bio && <div class="bio">{ownerUser.bio}</div>}
248 </div>
249 </div>
250 <h3 style="margin-bottom: 16px">Repositories</h3>
251 {repos.length === 0 ? (
252 <p style="color: var(--text-muted)">No repositories yet.</p>
253 ) : (
254 <div class="card-grid">
255 {repos.map((repo) => (
256 <RepoCard repo={repo} ownerName={ownerName} />
257 ))}
258 </div>
259 )}
58260 </Layout>
59261 );
60262});
61263
264// Star/unstar a repo
265web.post("/:owner/:repo/star", requireAuth, async (c) => {
266 const { owner: ownerName, repo: repoName } = c.req.param();
267 const user = c.get("user")!;
268
269 try {
270 const [ownerUser] = await db
271 .select()
272 .from(users)
273 .where(eq(users.username, ownerName))
274 .limit(1);
275 if (!ownerUser) return c.redirect(`/${ownerName}/${repoName}`);
276
277 const [repo] = await db
278 .select()
279 .from(repositories)
280 .where(
281 and(
282 eq(repositories.ownerId, ownerUser.id),
283 eq(repositories.name, repoName)
284 )
285 )
286 .limit(1);
287 if (!repo) return c.redirect(`/${ownerName}/${repoName}`);
288
289 // Toggle star
290 const [existing] = await db
291 .select()
292 .from(stars)
293 .where(
294 and(eq(stars.userId, user.id), eq(stars.repositoryId, repo.id))
295 )
296 .limit(1);
297
298 if (existing) {
299 await db.delete(stars).where(eq(stars.id, existing.id));
300 await db
301 .update(repositories)
302 .set({ starCount: Math.max(0, repo.starCount - 1) })
303 .where(eq(repositories.id, repo.id));
304 } else {
305 await db.insert(stars).values({
306 userId: user.id,
307 repositoryId: repo.id,
308 });
309 await db
310 .update(repositories)
311 .set({ starCount: repo.starCount + 1 })
312 .where(eq(repositories.id, repo.id));
313 }
314 } catch {
315 // DB error — ignore
316 }
317
318 return c.redirect(`/${ownerName}/${repoName}`);
319});
320
62321// Repository overview — file tree at HEAD
63322web.get("/:owner/:repo", async (c) => {
64323 const { owner, repo } = c.req.param();
324 const user = c.get("user");
65325
66326 if (!(await repoExists(owner, repo))) {
67327 return c.html(
68 <Layout title="Not Found">
328 <Layout title="Not Found" user={user}>
69329 <div class="empty-state">
70330 <h2>Repository not found</h2>
71331 <p>
78338 }
79339
80340 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
341 const branches = await listBranches(owner, repo);
81342 const tree = await getTree(owner, repo, defaultBranch);
82343
344 // Get star info if user logged in
345 let starCount = 0;
346 let starred = false;
347 try {
348 const [ownerUser] = await db
349 .select()
350 .from(users)
351 .where(eq(users.username, owner))
352 .limit(1);
353 if (ownerUser) {
354 const [repoRow] = await db
355 .select()
356 .from(repositories)
357 .where(
358 and(
359 eq(repositories.ownerId, ownerUser.id),
360 eq(repositories.name, repo)
361 )
362 )
363 .limit(1);
364 if (repoRow) {
365 starCount = repoRow.starCount;
366 if (user) {
367 const [star] = await db
368 .select()
369 .from(stars)
370 .where(
371 and(
372 eq(stars.userId, user.id),
373 eq(stars.repositoryId, repoRow.id)
374 )
375 )
376 .limit(1);
377 starred = !!star;
378 }
379 }
380 }
381 } catch {
382 // DB not available
383 }
384
83385 if (tree.length === 0) {
84386 return c.html(
85 <Layout title={`${owner}/${repo}`}>
86 <RepoHeader owner={owner} repo={repo} />
387 <Layout title={`${owner}/${repo}`} user={user}>
388 <RepoHeader
389 owner={owner}
390 repo={repo}
391 starCount={starCount}
392 starred={starred}
393 currentUser={user?.username}
394 />
87395 <RepoNav owner={owner} repo={repo} active="code" />
88396 <div class="empty-state">
89397 <h2>Empty repository</h2>
98406 const readme = await getReadme(owner, repo, defaultBranch);
99407
100408 return c.html(
101 <Layout title={`${owner}/${repo}`}>
102 <RepoHeader owner={owner} repo={repo} />
409 <Layout title={`${owner}/${repo}`} user={user}>
410 <RepoHeader
411 owner={owner}
412 repo={repo}
413 starCount={starCount}
414 starred={starred}
415 currentUser={user?.username}
416 />
103417 <RepoNav owner={owner} repo={repo} active="code" />
104 <div class="branch-selector">{defaultBranch}</div>
418 <BranchSwitcher
419 owner={owner}
420 repo={repo}
421 currentRef={defaultBranch}
422 branches={branches}
423 pathType="tree"
424 />
105425 <FileTable
106426 entries={tree}
107427 owner={owner}
110430 path=""
111431 />
112432 {readme && (
113 <div
114 class="blob-view"
115 style="margin-top: 20px"
116 >
433 <div class="blob-view" style="margin-top: 20px">
117434 <div class="blob-header">README.md</div>
118435 <div style="padding: 16px; white-space: pre-wrap; font-size: 14px;">
119436 {readme}
127444// Browse tree at ref/path
128445web.get("/:owner/:repo/tree/:ref{.+$}", async (c) => {
129446 const { owner, repo } = c.req.param();
447 const user = c.get("user");
130448 const refAndPath = c.req.param("ref");
131449
132 // Parse ref from path — try known branches first, fallback to first segment
133450 const branches = await listBranches(owner, repo);
134451 let ref = "";
135452 let treePath = "";
143460 }
144461
145462 if (!ref) {
146 // Assume first path segment is the ref
147463 const slashIdx = refAndPath.indexOf("/");
148464 if (slashIdx === -1) {
149465 ref = refAndPath;
156472 const tree = await getTree(owner, repo, ref, treePath);
157473
158474 return c.html(
159 <Layout title={`${treePath || "/"} — ${owner}/${repo}`}>
475 <Layout title={`${treePath || "/"} — ${owner}/${repo}`} user={user}>
160476 <RepoHeader owner={owner} repo={repo} />
161477 <RepoNav owner={owner} repo={repo} active="code" />
162 <div class="branch-selector">{ref}</div>
478 <BranchSwitcher
479 owner={owner}
480 repo={repo}
481 currentRef={ref}
482 branches={branches}
483 pathType="tree"
484 subPath={treePath}
485 />
163486 <Breadcrumb owner={owner} repo={repo} ref={ref} path={treePath} />
164487 <FileTable
165488 entries={tree}
172495 );
173496});
174497
175// View file blob
498// View file blob with syntax highlighting
176499web.get("/:owner/:repo/blob/:ref{.+$}", async (c) => {
177500 const { owner, repo } = c.req.param();
501 const user = c.get("user");
178502 const refAndPath = c.req.param("ref");
179503
180504 const branches = await listBranches(owner, repo);
199523 const blob = await getBlob(owner, repo, ref, filePath);
200524 if (!blob) {
201525 return c.html(
202 <Layout title="Not Found">
526 <Layout title="Not Found" user={user}>
203527 <div class="empty-state">
204528 <h2>File not found</h2>
205529 </div>
208532 );
209533 }
210534
211 const lines = blob.content.split("\n");
212 // Remove trailing empty line from split
213 if (lines[lines.length - 1] === "") lines.pop();
535 const fileName = filePath.split("/").pop() || filePath;
214536
215537 return c.html(
216 <Layout title={`${filePath} — ${owner}/${repo}`}>
538 <Layout title={`${filePath} — ${owner}/${repo}`} user={user}>
217539 <RepoHeader owner={owner} repo={repo} />
218540 <RepoNav owner={owner} repo={repo} active="code" />
219 <div class="branch-selector">{ref}</div>
541 <BranchSwitcher
542 owner={owner}
543 repo={repo}
544 currentRef={ref}
545 branches={branches}
546 pathType="blob"
547 subPath={filePath}
548 />
220549 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
221550 <div class="blob-view">
222551 <div class="blob-header">
223 {filePath.split("/").pop()} — {blob.size} bytes
552 <span>{fileName} — {blob.size} bytes</span>
224553 </div>
225554 {blob.isBinary ? (
226555 <div style="padding: 16px; color: var(--text-muted)">
227556 Binary file not shown.
228557 </div>
229 ) : (
230 <div class="blob-code">
231 <table>
232 <tbody>
233 {lines.map((line, i) => (
234 <tr>
235 <td class="line-num">{i + 1}</td>
236 <td class="line-content">{line}</td>
237 </tr>
238 ))}
239 </tbody>
240 </table>
241 </div>
242 )}
558 ) : (() => {
559 const { html: highlighted, language } = highlightCode(
560 blob.content,
561 fileName
562 );
563 const lineCount = blob.content.split("\n").length;
564 // Trim trailing newline from count
565 const adjustedCount =
566 blob.content.endsWith("\n") ? lineCount - 1 : lineCount;
567
568 if (language) {
569 return (
570 <HighlightedCode
571 highlightedHtml={highlighted}
572 lineCount={adjustedCount}
573 />
574 );
575 }
576 const lines = blob.content.split("\n");
577 if (lines[lines.length - 1] === "") lines.pop();
578 return <PlainCode lines={lines} />;
579 })()}
243580 </div>
244581 </Layout>
245582 );
248585// Commit log
249586web.get("/:owner/:repo/commits/:ref?", async (c) => {
250587 const { owner, repo } = c.req.param();
588 const user = c.get("user");
251589 const ref =
252590 c.req.param("ref") || (await getDefaultBranch(owner, repo)) || "main";
591 const branches = await listBranches(owner, repo);
253592
254593 const commits = await listCommits(owner, repo, ref, 50);
255594
256595 return c.html(
257 <Layout title={`Commits — ${owner}/${repo}`}>
596 <Layout title={`Commits — ${owner}/${repo}`} user={user}>
258597 <RepoHeader owner={owner} repo={repo} />
259598 <RepoNav owner={owner} repo={repo} active="commits" />
260 <div class="branch-selector">{ref}</div>
599 <BranchSwitcher
600 owner={owner}
601 repo={repo}
602 currentRef={ref}
603 branches={branches}
604 pathType="commits"
605 />
261606 {commits.length === 0 ? (
262607 <div class="empty-state">
263608 <p>No commits yet.</p>
272617// Single commit with diff
273618web.get("/:owner/:repo/commit/:sha", async (c) => {
274619 const { owner, repo, sha } = c.req.param();
620 const user = c.get("user");
275621
276622 const commit = await getCommit(owner, repo, sha);
277623 if (!commit) {
278624 return c.html(
279 <Layout title="Not Found">
625 <Layout title="Not Found" user={user}>
280626 <div class="empty-state">
281627 <h2>Commit not found</h2>
282628 </div>
289635 const { files, raw } = await getDiff(owner, repo, sha);
290636
291637 return c.html(
292 <Layout title={`${commit.message} — ${owner}/${repo}`}>
638 <Layout title={`${commit.message} — ${owner}/${repo}`} user={user}>
293639 <RepoHeader owner={owner} repo={repo} />
294640 <div
295641 style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; margin-bottom: 20px"
Modifiedsrc/views/components.tsx+134−12View fileUnifiedSplit
11import type { FC } from "hono/jsx";
2import { html } from "hono/html";
23import type { GitCommit, GitTreeEntry, GitDiffFile } from "../git/repository";
4import type { Repository } from "../db/schema";
35
4export const RepoHeader: FC<{ owner: string; repo: string }> = ({
5 owner,
6 repo,
7}) => (
6export const RepoHeader: FC<{
7 owner: string;
8 repo: string;
9 starCount?: number;
10 starred?: boolean;
11 currentUser?: string | null;
12}> = ({ owner, repo, starCount, starred, currentUser }) => (
813 <div class="repo-header">
914 <a href={`/${owner}`} class="owner">
1015 {owner}
1318 <a href={`/${owner}/${repo}`} class="name">
1419 {repo}
1520 </a>
21 <div class="repo-header-actions">
22 {starCount !== undefined && (
23 currentUser ? (
24 <form method="POST" action={`/${owner}/${repo}/star`} style="display:inline">
25 <button
26 type="submit"
27 class={`star-btn${starred ? " starred" : ""}`}
28 >
29 {starred ? "\u2605" : "\u2606"} {starCount}
30 </button>
31 </form>
32 ) : (
33 <span class="star-btn">
34 {"\u2606"} {starCount}
35 </span>
36 )
37 )}
38 </div>
1639 </div>
1740);
1841
2245 active: "code" | "commits";
2346}> = ({ owner, repo, active }) => (
2447 <div class="repo-nav">
25 <a
26 href={`/${owner}/${repo}`}
27 class={active === "code" ? "active" : ""}
28 >
48 <a href={`/${owner}/${repo}`} class={active === "code" ? "active" : ""}>
2949 Code
3050 </a>
3151 <a
3757 </div>
3858);
3959
60export const BranchSwitcher: FC<{
61 owner: string;
62 repo: string;
63 currentRef: string;
64 branches: string[];
65 pathType: "tree" | "blob" | "commits";
66 subPath?: string;
67}> = ({ owner, repo, currentRef, branches, pathType, subPath }) => {
68 if (branches.length <= 1) {
69 return <div class="branch-selector">{currentRef}</div>;
70 }
71
72 return (
73 <div class="branch-dropdown">
74 <button class="branch-selector" type="button">
75 {currentRef} &#9662;
76 </button>
77 <div class="branch-dropdown-content">
78 {branches.map((branch) => {
79 let href: string;
80 if (pathType === "commits") {
81 href = `/${owner}/${repo}/commits/${branch}`;
82 } else if (subPath) {
83 href = `/${owner}/${repo}/${pathType}/${branch}/${subPath}`;
84 } else {
85 href = `/${owner}/${repo}/tree/${branch}`;
86 }
87 return (
88 <a
89 href={href}
90 class={branch === currentRef ? "active-branch" : ""}
91 >
92 {branch}
93 </a>
94 );
95 })}
96 </div>
97 </div>
98 );
99};
100
40101export const Breadcrumb: FC<{
41102 owner: string;
42103 repo: string;
104165 </table>
105166);
106167
168export const HighlightedCode: FC<{
169 highlightedHtml: string;
170 lineCount: number;
171}> = ({ highlightedHtml, lineCount }) => {
172 const lineNums = Array.from({ length: lineCount }, (_, i) => i + 1);
173 return (
174 <div class="blob-code">
175 <table>
176 <tbody>
177 <tr>
178 <td class="line-num" style="vertical-align: top; padding-top: 0; padding-bottom: 0">
179 <pre style="margin: 0; line-height: 1.6; font-size: 13px">
180 {lineNums.map((n) => (
181 <>
182 <span>{n}</span>
183 {"\n"}
184 </>
185 ))}
186 </pre>
187 </td>
188 <td class="line-content" style="vertical-align: top; padding-top: 0; padding-bottom: 0">
189 <pre style="margin: 0; line-height: 1.6; font-size: 13px">{html([highlightedHtml] as unknown as TemplateStringsArray)}</pre>
190 </td>
191 </tr>
192 </tbody>
193 </table>
194 </div>
195 );
196};
197
198export const PlainCode: FC<{ lines: string[] }> = ({ lines }) => (
199 <div class="blob-code">
200 <table>
201 <tbody>
202 {lines.map((line, i) => (
203 <tr>
204 <td class="line-num">{i + 1}</td>
205 <td class="line-content">{line}</td>
206 </tr>
207 ))}
208 </tbody>
209 </table>
210 </div>
211);
212
107213export const CommitList: FC<{
108214 commits: GitCommit[];
109215 owner: string;
119225 </a>
120226 </div>
121227 <div class="commit-meta">
122 {commit.author} committed{" "}
123 {formatRelativeDate(commit.date)}
228 {commit.author} committed {formatRelativeDate(commit.date)}
124229 </div>
125230 </div>
126231 <a
138243 raw,
139244 files,
140245}) => {
141 // Parse unified diff into per-file sections
142246 const sections = parseDiff(raw);
143247
144248 return (
173277 );
174278};
175279
280export const RepoCard: FC<{ repo: Repository; ownerName: string }> = ({
281 repo,
282 ownerName,
283}) => (
284 <div class="card">
285 <h3>
286 <a href={`/${ownerName}/${repo.name}`}>{repo.name}</a>
287 </h3>
288 {repo.description && <p>{repo.description}</p>}
289 <div class="card-meta">
290 {repo.isPrivate && <span class="badge">Private</span>}
291 <span>{"\u2606"} {repo.starCount}</span>
292 {repo.pushedAt && (
293 <span>Updated {formatRelativeDate(repo.pushedAt.toString())}</span>
294 )}
295 </div>
296 </div>
297);
298
176299function parseDiff(raw: string): Array<{ path: string; lines: string[] }> {
177300 const sections: Array<{ path: string; lines: string[] }> = [];
178301 const diffRegex = /^diff --git a\/(.+?) b\/.+$/;
186309 continue;
187310 }
188311 if (current && !line.startsWith("diff --git")) {
189 // Skip index/--- /+++ header lines from display
190312 if (
191313 line.startsWith("index ") ||
192314 line.startsWith("--- ") ||
Modifiedsrc/views/layout.tsx+226−5View fileUnifiedSplit
11import type { FC, PropsWithChildren } from "hono/jsx";
2import type { User } from "../db/schema";
3import { hljsThemeCss } from "../lib/highlight";
24
3export const Layout: FC<PropsWithChildren<{ title?: string }>> = ({
4 children,
5 title,
6}) => {
5export const Layout: FC<
6 PropsWithChildren<{ title?: string; user?: User | null }>
7> = ({ children, title, user }) => {
78 return (
89 <html lang="en">
910 <head>
1112 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
1213 <title>{title ? `${title} — gluecron` : "gluecron"}</title>
1314 <style>{css}</style>
15 <style>{hljsThemeCss}</style>
1416 </head>
1517 <body>
1618 <header>
1820 <a href="/" class="logo">
1921 gluecron
2022 </a>
23 <div class="nav-right">
24 {user ? (
25 <>
26 <a href="/new" class="btn btn-sm btn-primary">
27 + New
28 </a>
29 <a href={`/${user.username}`} class="nav-user">
30 {user.displayName || user.username}
31 </a>
32 <a href="/settings" class="nav-link">
33 Settings
34 </a>
35 <a href="/logout" class="nav-link">
36 Sign out
37 </a>
38 </>
39 ) : (
40 <>
41 <a href="/login" class="nav-link">
42 Sign in
43 </a>
44 <a href="/register" class="btn btn-sm btn-primary">
45 Register
46 </a>
47 </>
48 )}
49 </div>
2150 </nav>
2251 </header>
2352 <main>{children}</main>
6998 background: var(--bg-secondary);
7099 }
71100
72 header nav { display: flex; align-items: center; gap: 20px; max-width: 1200px; margin: 0 auto; }
101 header nav {
102 display: flex;
103 align-items: center;
104 justify-content: space-between;
105 max-width: 1200px;
106 margin: 0 auto;
107 }
73108 .logo { font-size: 20px; font-weight: 700; color: var(--text); }
74109 .logo:hover { text-decoration: none; color: var(--text-link); }
75110
111 .nav-right { display: flex; align-items: center; gap: 16px; }
112 .nav-link { color: var(--text-muted); font-size: 14px; }
113 .nav-link:hover { color: var(--text); text-decoration: none; }
114 .nav-user { color: var(--text); font-weight: 600; font-size: 14px; }
115 .nav-user:hover { color: var(--text-link); text-decoration: none; }
116
76117 main { max-width: 1200px; margin: 0 auto; padding: 24px; flex: 1; width: 100%; }
77118
78119 footer {
83124 font-size: 13px;
84125 }
85126
127 /* Buttons */
128 .btn {
129 display: inline-flex;
130 align-items: center;
131 gap: 6px;
132 padding: 8px 16px;
133 border-radius: var(--radius);
134 font-size: 14px;
135 font-weight: 500;
136 border: 1px solid var(--border);
137 background: var(--bg-tertiary);
138 color: var(--text);
139 cursor: pointer;
140 text-decoration: none;
141 line-height: 1.4;
142 }
143 .btn:hover { background: var(--border); text-decoration: none; }
144 .btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
145 .btn-primary:hover { background: var(--accent-hover); }
146 .btn-danger { background: transparent; border-color: var(--red); color: var(--red); }
147 .btn-danger:hover { background: rgba(248, 81, 73, 0.15); }
148 .btn-sm { padding: 4px 12px; font-size: 13px; }
149
150 /* Forms */
151 .form-group { margin-bottom: 16px; }
152 .form-group label { display: block; font-size: 14px; font-weight: 500; margin-bottom: 6px; color: var(--text); }
153 .form-group input, .form-group textarea, .form-group select {
154 width: 100%;
155 padding: 8px 12px;
156 background: var(--bg);
157 border: 1px solid var(--border);
158 border-radius: var(--radius);
159 color: var(--text);
160 font-size: 14px;
161 font-family: var(--font-sans);
162 }
163 .form-group input:focus, .form-group textarea:focus, .form-group select:focus {
164 outline: none;
165 border-color: var(--accent);
166 box-shadow: 0 0 0 2px rgba(31, 111, 235, 0.3);
167 }
168 .input-disabled { opacity: 0.5; cursor: not-allowed; }
169
170 /* Auth */
171 .auth-container { max-width: 400px; margin: 40px auto; }
172 .auth-container h2 { margin-bottom: 20px; font-size: 24px; }
173 .auth-error {
174 background: rgba(248, 81, 73, 0.1);
175 border: 1px solid var(--red);
176 color: var(--red);
177 padding: 8px 12px;
178 border-radius: var(--radius);
179 margin-bottom: 16px;
180 font-size: 14px;
181 }
182 .auth-success {
183 background: rgba(63, 185, 80, 0.1);
184 border: 1px solid var(--green);
185 color: var(--green);
186 padding: 8px 12px;
187 border-radius: var(--radius);
188 margin-bottom: 16px;
189 font-size: 14px;
190 }
191 .auth-switch { margin-top: 16px; font-size: 14px; color: var(--text-muted); }
192
193 /* Settings */
194 .settings-container { max-width: 600px; }
195 .settings-container h2 { margin-bottom: 20px; font-size: 24px; }
196 .settings-container h3 { font-size: 18px; margin-bottom: 12px; }
197 .ssh-keys-list { margin-bottom: 24px; }
198 .ssh-key-item {
199 display: flex;
200 justify-content: space-between;
201 align-items: center;
202 padding: 12px 16px;
203 border: 1px solid var(--border);
204 border-radius: var(--radius);
205 margin-bottom: 8px;
206 background: var(--bg-secondary);
207 }
208 .ssh-key-meta { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
209 .ssh-key-meta code { font-size: 11px; background: var(--bg-tertiary); padding: 1px 6px; border-radius: 3px; margin-right: 8px; }
210
211 /* Repo header */
86212 .repo-header {
87213 display: flex;
88214 align-items: center;
93219 .repo-header .owner { color: var(--text-link); }
94220 .repo-header .separator { color: var(--text-muted); }
95221 .repo-header .name { color: var(--text-link); font-weight: 600; }
222 .repo-header-actions { margin-left: auto; display: flex; gap: 8px; align-items: center; }
96223
97224 .repo-nav {
98225 display: flex;
132259 border-bottom: 1px solid var(--border);
133260 font-size: 13px;
134261 color: var(--text-muted);
262 display: flex;
263 justify-content: space-between;
264 align-items: center;
135265 }
136266 .blob-code {
137267 overflow-x: auto;
243373 font-size: 13px;
244374 color: var(--text);
245375 margin-bottom: 12px;
376 position: relative;
246377 }
247378
379 .branch-dropdown {
380 position: relative;
381 display: inline-block;
382 margin-bottom: 12px;
383 }
384 .branch-dropdown-content {
385 display: none;
386 position: absolute;
387 top: 100%;
388 left: 0;
389 z-index: 10;
390 min-width: 200px;
391 background: var(--bg-secondary);
392 border: 1px solid var(--border);
393 border-radius: var(--radius);
394 margin-top: 4px;
395 box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
396 }
397 .branch-dropdown:hover .branch-dropdown-content,
398 .branch-dropdown:focus-within .branch-dropdown-content { display: block; }
399 .branch-dropdown-content a {
400 display: block;
401 padding: 8px 12px;
402 font-size: 13px;
403 color: var(--text);
404 border-bottom: 1px solid var(--border);
405 }
406 .branch-dropdown-content a:last-child { border-bottom: none; }
407 .branch-dropdown-content a:hover { background: var(--bg-tertiary); text-decoration: none; }
408 .branch-dropdown-content a.active-branch { color: var(--text-link); font-weight: 600; }
409
248410 .card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 16px; }
249411 .card {
250412 border: 1px solid var(--border);
251413 border-radius: var(--radius);
252414 padding: 16px;
253415 background: var(--bg-secondary);
416 transition: border-color 0.15s;
254417 }
418 .card:hover { border-color: var(--text-muted); }
255419 .card h3 { font-size: 16px; margin-bottom: 4px; }
420 .card h3 a { color: var(--text-link); }
256421 .card p { font-size: 13px; color: var(--text-muted); }
422 .card-meta { display: flex; gap: 16px; margin-top: 12px; font-size: 12px; color: var(--text-muted); }
423 .card-meta span { display: flex; align-items: center; gap: 4px; }
424
425 .star-btn {
426 display: inline-flex;
427 align-items: center;
428 gap: 6px;
429 padding: 4px 12px;
430 background: var(--bg-tertiary);
431 border: 1px solid var(--border);
432 border-radius: var(--radius);
433 color: var(--text);
434 font-size: 13px;
435 cursor: pointer;
436 }
437 .star-btn:hover { background: var(--border); text-decoration: none; }
438 .star-btn.starred { color: var(--yellow); border-color: var(--yellow); }
439
440 .user-profile {
441 display: flex;
442 gap: 32px;
443 margin-bottom: 32px;
444 }
445 .user-avatar {
446 width: 96px;
447 height: 96px;
448 border-radius: 50%;
449 background: var(--bg-tertiary);
450 border: 1px solid var(--border);
451 display: flex;
452 align-items: center;
453 justify-content: center;
454 font-size: 40px;
455 color: var(--text-muted);
456 flex-shrink: 0;
457 }
458 .user-info h2 { font-size: 24px; margin-bottom: 2px; }
459 .user-info .username { font-size: 16px; color: var(--text-muted); }
460 .user-info .bio { font-size: 14px; color: var(--text-muted); margin-top: 8px; }
461
462 .new-repo-form { max-width: 600px; }
463 .new-repo-form h2 { margin-bottom: 20px; }
464 .visibility-options { display: flex; gap: 12px; margin-bottom: 16px; }
465 .visibility-option {
466 flex: 1;
467 padding: 12px;
468 border: 1px solid var(--border);
469 border-radius: var(--radius);
470 background: var(--bg-secondary);
471 cursor: pointer;
472 text-align: center;
473 }
474 .visibility-option:has(input:checked) { border-color: var(--accent); background: rgba(31, 111, 235, 0.1); }
475 .visibility-option input { display: none; }
476 .visibility-option .vis-label { font-size: 14px; font-weight: 500; }
477 .visibility-option .vis-desc { font-size: 12px; color: var(--text-muted); }
257478`;
258479