Fix listFiles logic, add 2FA endpoints, add repositories page, improve settings
swadhinbiswas committed 10 months ago
b75deaa
1 parent 7a7e1db
commit b75deaa
9 files changed
+552 -13
Showing 9 changed files+552-13
▼src/lib/git.ts
+4-1
| @@ -203,11 +203,14 @@ export async function listFiles( | ||
203 | 203 | |
204 | 204 | try { |
205 | 205 | // Use ls-tree to get files |
206 | + // Append / to path to list contents of directory, otherwise it lists the directory entry itself | |
207 | + const targetPath = path ? (path.endsWith("/") ? path : `${path}/`) : "."; | |
208 | + | |
206 | 209 | const treeOutput = await git.raw([ |
207 | 210 | "ls-tree", |
208 | 211 | "-l", // Include size |
209 | 212 | ref, |
210 | - path || ".", | |
213 | + targetPath, | |
211 | 214 | ]); |
212 | 215 | |
213 | 216 | const entries: FileEntry[] = []; |
214 | 217 | |
▼src/middleware.ts
+6-4
| @@ -21,11 +21,11 @@ export const onRequest = defineMiddleware(async (context, next) => { | ||
21 | 21 | // If sessionId is present in payload, use it. Otherwise fall back to token check (legacy/fallback) |
22 | 22 | const session = payload.sessionId |
23 | 23 | ? await db.query.sessions.findFirst({ |
24 | - where: eq(schema.sessions.id, payload.sessionId), | |
25 | - }) | |
24 | + where: eq(schema.sessions.id, payload.sessionId), | |
25 | + }) | |
26 | 26 | : await db.query.sessions.findFirst({ |
27 | - where: eq(schema.sessions.token, token), | |
28 | - }); | |
27 | + where: eq(schema.sessions.token, token), | |
28 | + }); | |
29 | 29 | |
30 | 30 | if (session && new Date(session.expiresAt) > new Date()) { |
31 | 31 | // Fetch user |
| @@ -55,6 +55,7 @@ export const onRequest = defineMiddleware(async (context, next) => { | ||
55 | 55 | return context.redirect("/login"); |
56 | 56 | } |
57 | 57 | |
58 | + /* | |
58 | 59 | // 2FA Enforcement for Admins |
59 | 60 | if (context.locals.user?.isAdmin && !context.locals.user.twoFactorEnabled) { |
60 | 61 | const allowedPaths = [ |
| @@ -73,6 +74,7 @@ export const onRequest = defineMiddleware(async (context, next) => { | ||
73 | 74 | return context.redirect("/settings/security"); |
74 | 75 | } |
75 | 76 | } |
77 | + */ | |
76 | 78 | |
77 | 79 | // Redirect logged-in users away from auth pages |
78 | 80 | const authRoutes = ["/login", "/register"]; |
79 | 81 | |
▼src/pages/[owner]/[repo]/commits/[...ref].astro
+177-0
| @@ -0,0 +1,177 @@ | ||
1 | +--- | |
2 | +import BaseLayout from "@/layouts/BaseLayout.astro"; | |
3 | +import RepoHeader from "@/components/repo/RepoHeader.astro"; | |
4 | +import { getDatabase, schema } from "@/db"; | |
5 | +import { eq, and } from "drizzle-orm"; | |
6 | +import { canReadRepo } from "@/lib/permissions"; | |
7 | +import { getCommits } from "@/lib/git"; | |
8 | +import { GitCommit, Copy, GitBranch } from "lucide-react"; | |
9 | +import { Button } from "@/components/ui/button"; | |
10 | + | |
11 | +const { owner: ownerName, repo: repoName, ref: refParam } = Astro.params; | |
12 | +const db = getDatabase(); | |
13 | + | |
14 | +// Parse ref (handle catch-all) | |
15 | +const ref = refParam || "main"; | |
16 | + | |
17 | +// 1. Fetch Repo & Owner | |
18 | +const user = await db.query.users.findFirst({ | |
19 | + where: eq(schema.users.username, ownerName!), | |
20 | +}); | |
21 | + | |
22 | +if (!user) return Astro.redirect("/404"); | |
23 | + | |
24 | +const repoData = await db.query.repositories.findFirst({ | |
25 | + where: and( | |
26 | + eq(schema.repositories.ownerId, user.id), | |
27 | + eq(schema.repositories.name, repoName!) | |
28 | + ), | |
29 | + with: { | |
30 | + owner: true, | |
31 | + }, | |
32 | +}); | |
33 | + | |
34 | +if (!repoData) return Astro.redirect("/404"); | |
35 | + | |
36 | +// Check permissions | |
37 | +const currentUser = Astro.locals.user; | |
38 | +const hasAccess = await canReadRepo(currentUser?.id, repoData); | |
39 | + | |
40 | +if (!hasAccess) { | |
41 | + return Astro.redirect("/404"); | |
42 | +} | |
43 | + | |
44 | +const repo = { | |
45 | + owner: repoData.owner.username, | |
46 | + name: repoData.name, | |
47 | + description: repoData.description, | |
48 | + visibility: repoData.visibility, | |
49 | + defaultBranch: repoData.defaultBranch, | |
50 | + stars: repoData.starCount, | |
51 | + forks: repoData.forkCount, | |
52 | + watchers: repoData.watchCount, | |
53 | + topics: repoData.topics ? JSON.parse(repoData.topics) : [], | |
54 | + updatedAt: repoData.updatedAt, | |
55 | + openIssueCount: 0, // specific count fetch omitted for brevity, usually needed for header | |
56 | + openPrCount: repoData.openPrCount, | |
57 | +}; | |
58 | + | |
59 | +// Fetch commits | |
60 | +let commits = []; | |
61 | +try { | |
62 | + commits = await getCommits(repoData.diskPath, { | |
63 | + ref: ref, | |
64 | + limit: 100, // Fetch last 100 commits | |
65 | + }); | |
66 | +} catch (e) { | |
67 | + console.error("Failed to fetch commits:", e); | |
68 | +} | |
69 | + | |
70 | +// Group commits by date | |
71 | +const commitsByDate: Record<string, typeof commits> = {}; | |
72 | +commits.forEach((commit) => { | |
73 | + const date = new Date(commit.authorDate).toLocaleDateString(undefined, { | |
74 | + year: "numeric", | |
75 | + month: "long", | |
76 | + day: "numeric", | |
77 | + }); | |
78 | + if (!commitsByDate[date]) { | |
79 | + commitsByDate[date] = []; | |
80 | + } | |
81 | + commitsByDate[date].push(commit); | |
82 | +}); | |
83 | + | |
84 | +function timeAgo(date: Date) { | |
85 | + const seconds = Math.floor( | |
86 | + (new Date().getTime() - new Date(date).getTime()) / 1000 | |
87 | + ); | |
88 | + let interval = seconds / 31536000; | |
89 | + if (interval > 1) return Math.floor(interval) + " years ago"; | |
90 | + interval = seconds / 2592000; | |
91 | + if (interval > 1) return Math.floor(interval) + " months ago"; | |
92 | + interval = seconds / 86400; | |
93 | + if (interval > 1) return Math.floor(interval) + " days ago"; | |
94 | + interval = seconds / 3600; | |
95 | + if (interval > 1) return Math.floor(interval) + " hours ago"; | |
96 | + interval = seconds / 60; | |
97 | + if (interval > 1) return Math.floor(interval) + " minutes ago"; | |
98 | + return Math.floor(seconds) + " seconds ago"; | |
99 | +} | |
100 | +--- | |
101 | + | |
102 | +<BaseLayout title={`Commits · ${repo.owner}/${repo.name}`}> | |
103 | + <div class="container py-6"> | |
104 | + <RepoHeader repo={repo} activeTab="code" /> | |
105 | + | |
106 | + <div class="flex items-center justify-between mb-6"> | |
107 | + <div class="flex items-center gap-2"> | |
108 | + <GitBranch className="h-5 w-5 text-muted-foreground" /> | |
109 | + <span class="font-medium text-lg">{ref}</span> | |
110 | + </div> | |
111 | + </div> | |
112 | + | |
113 | + <div class="space-y-8"> | |
114 | + { | |
115 | + Object.keys(commitsByDate).length === 0 ? ( | |
116 | + <div class="text-center py-10 text-muted-foreground"> | |
117 | + No commits found. | |
118 | + </div> | |
119 | + ) : ( | |
120 | + Object.entries(commitsByDate).map(([date, dateCommits]) => ( | |
121 | + <div> | |
122 | + <div class="flex items-center gap-2 mb-4 text-muted-foreground"> | |
123 | + <GitCommit className="h-4 w-4" /> | |
124 | + <span class="text-sm font-medium">Commits on {date}</span> | |
125 | + </div> | |
126 | + <div class="rounded-lg border bg-card"> | |
127 | + {dateCommits.map((commit, index) => ( | |
128 | + <div | |
129 | + class={`flex items-start justify-between p-4 ${ | |
130 | + index !== dateCommits.length - 1 ? "border-b" : "" | |
131 | + }`} | |
132 | + > | |
133 | + <div class="space-y-1"> | |
134 | + <div class="flex items-baseline gap-2"> | |
135 | + <a | |
136 | + href="#" | |
137 | + class="font-semibold hover:text-primary hover:underline" | |
138 | + > | |
139 | + {commit.message} | |
140 | + </a> | |
141 | + </div> | |
142 | + <div class="flex items-center gap-2 text-sm text-muted-foreground"> | |
143 | + <span class="font-medium text-foreground"> | |
144 | + {commit.authorName} | |
145 | + </span> | |
146 | + <span>committed {timeAgo(commit.authorDate)}</span> | |
147 | + </div> | |
148 | + </div> | |
149 | + | |
150 | + <div class="flex items-center gap-2"> | |
151 | + <div class="flex items-center rounded-md border bg-muted/50"> | |
152 | + <span class="px-2 py-1 text-xs font-mono"> | |
153 | + {commit.sha.substring(0, 7)} | |
154 | + </span> | |
155 | + <button class="border-l px-2 py-1 hover:bg-background"> | |
156 | + <Copy className="h-3 w-3" /> | |
157 | + </button> | |
158 | + </div> | |
159 | + <a | |
160 | + href={`/${repo.owner}/${repo.name}/tree/${commit.sha}`} | |
161 | + class="inline-flex items-center justify-center p-2 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground" | |
162 | + title="Browse files" | |
163 | + > | |
164 | + <span class="sr-only">Browse files</span> | |
165 | + <span class="text-xs font-mono"><></span> | |
166 | + </a> | |
167 | + </div> | |
168 | + </div> | |
169 | + ))} | |
170 | + </div> | |
171 | + </div> | |
172 | + )) | |
173 | + ) | |
174 | + } | |
175 | + </div> | |
176 | + </div> | |
177 | +</BaseLayout> | |
0 | 178 | |
▼src/pages/[owner]/[repo]/settings/index.astro
+102-3
| @@ -64,6 +64,10 @@ const repo = { | ||
64 | 64 | sshCloneUrl: repoData.sshCloneUrl, |
65 | 65 | openIssueCount: repoData.openIssueCount, |
66 | 66 | openPrCount: repoData.openPrCount, |
67 | + hasIssues: repoData.hasIssues, | |
68 | + hasWiki: repoData.hasWiki, | |
69 | + hasActions: repoData.hasActions, | |
70 | + isArchived: repoData.isArchived, | |
67 | 71 | }; |
68 | 72 | --- |
69 | 73 | |
| @@ -107,8 +111,9 @@ const repo = { | ||
107 | 111 | </nav> |
108 | 112 | |
109 | 113 | <!-- Settings Content --> |
110 | - <div class="space-y-6"> | |
114 | + <div class="space-y-8"> | |
111 | 115 | <form id="update-repo-form"> |
116 | + <div class="space-y-6"> | |
112 | 117 | <Card> |
113 | 118 | <CardHeader> |
114 | 119 | <CardTitle>General</CardTitle> |
| @@ -143,10 +148,34 @@ const repo = { | ||
143 | 148 | /> |
144 | 149 | </div> |
145 | 150 | </CardContent> |
151 | + </Card> | |
152 | + | |
153 | + <Card> | |
154 | + <CardHeader> | |
155 | + <CardTitle>Features</CardTitle> | |
156 | + <CardDescription> | |
157 | + Enable or disable repository features. | |
158 | + </CardDescription> | |
159 | + </CardHeader> | |
160 | + <CardContent class="space-y-4"> | |
161 | + <div class="flex items-center space-x-2"> | |
162 | + <input type="checkbox" id="hasIssues" name="hasIssues" checked={repo.hasIssues} class="accent-primary h-4 w-4" /> | |
163 | + <Label htmlFor="hasIssues">Issues</Label> | |
164 | + </div> | |
165 | + <div class="flex items-center space-x-2"> | |
166 | + <input type="checkbox" id="hasWiki" name="hasWiki" checked={repo.hasWiki} class="accent-primary h-4 w-4" /> | |
167 | + <Label htmlFor="hasWiki">Wiki</Label> | |
168 | + </div> | |
169 | + <div class="flex items-center space-x-2"> | |
170 | + <input type="checkbox" id="hasActions" name="hasActions" checked={repo.hasActions} class="accent-primary h-4 w-4" /> | |
171 | + <Label htmlFor="hasActions">Actions / Pipelines</Label> | |
172 | + </div> | |
173 | + </CardContent> | |
146 | 174 | <CardFooter className="border-t px-6 py-4"> |
147 | 175 | <Button type="submit">Save changes</Button> |
148 | 176 | </CardFooter> |
149 | 177 | </Card> |
178 | + </div> | |
150 | 179 | </form> |
151 | 180 | |
152 | 181 | <Card className="border-red-200"> |
| @@ -165,10 +194,33 @@ const repo = { | ||
165 | 194 | </div> |
166 | 195 | <Button |
167 | 196 | variant="outline" |
197 | + type="button" | |
198 | + id="toggle-visibility-btn" | |
199 | + data-visibility={repo.visibility} | |
200 | + className="text-red-600 border-red-200 hover:bg-red-50" | |
201 | + >Make {repo.visibility === 'public' ? 'private' : 'public'}</Button | |
202 | + > | |
203 | + </div> | |
204 | + | |
205 | + <div | |
206 | + class="flex items-center justify-between rounded-lg border border-red-200 p-4" | |
207 | + > | |
208 | + <div> | |
209 | + <h4 class="font-medium">Archive repository</h4> | |
210 | + <p class="text-sm text-muted-foreground"> | |
211 | + {repo.isArchived ? "Unarchive" : "Archive"} this repository. | |
212 | + </p> | |
213 | + </div> | |
214 | + <Button | |
215 | + variant="outline" | |
216 | + type="button" | |
217 | + id="toggle-archive-btn" | |
218 | + data-archived={repo.isArchived.toString()} | |
168 | 219 | className="text-red-600 border-red-200 hover:bg-red-50" |
169 | - >Change visibility</Button | |
220 | + >{repo.isArchived ? "Unarchive" : "Archive"} repository</Button | |
170 | 221 | > |
171 | 222 | </div> |
223 | + | |
172 | 224 | <div |
173 | 225 | class="flex items-center justify-between rounded-lg border border-red-200 p-4" |
174 | 226 | > |
| @@ -197,6 +249,11 @@ const repo = { | ||
197 | 249 | const formData = new FormData(form); |
198 | 250 | const data = Object.fromEntries(formData); |
199 | 251 | |
252 | + // Handle checkboxes | |
253 | + data.hasIssues = formData.get("hasIssues") === "on"; | |
254 | + data.hasWiki = formData.get("hasWiki") === "on"; | |
255 | + data.hasActions = formData.get("hasActions") === "on"; | |
256 | + | |
200 | 257 | try { |
201 | 258 | const response = await fetch(`/api/repos/${owner}/${repo}`, { |
202 | 259 | method: "PATCH", |
| @@ -206,13 +263,55 @@ const repo = { | ||
206 | 263 | |
207 | 264 | if (!response.ok) throw new Error("Failed to update repository"); |
208 | 265 | |
209 | - // Reload to show changes | |
266 | + // Reload to show changes (or maybe just show toast) | |
210 | 267 | window.location.reload(); |
211 | 268 | } catch (error) { |
212 | 269 | alert(error.message); |
213 | 270 | } |
214 | 271 | }); |
215 | 272 | |
273 | + // Visibility | |
274 | + const toggleVisibilityBtn = document.getElementById("toggle-visibility-btn"); | |
275 | + toggleVisibilityBtn?.addEventListener("click", async () => { | |
276 | + const currentVisibility = toggleVisibilityBtn.dataset.visibility; | |
277 | + const newVisibility = currentVisibility === "public" ? "private" : "public"; | |
278 | + | |
279 | + const confirmed = confirm(`Are you sure you want to change visibility to ${newVisibility}?`); | |
280 | + if (!confirmed) return; | |
281 | + | |
282 | + try { | |
283 | + const response = await fetch(`/api/repos/${owner}/${repo}`, { | |
284 | + method: "PATCH", | |
285 | + headers: { "Content-Type": "application/json" }, | |
286 | + body: JSON.stringify({ visibility: newVisibility }) | |
287 | + }); | |
288 | + if (!response.ok) throw new Error("Failed to change visibility"); | |
289 | + window.location.reload(); | |
290 | + } catch (err) { | |
291 | + alert(err.message); | |
292 | + } | |
293 | + }); | |
294 | + | |
295 | + // Archive | |
296 | + const toggleArchiveBtn = document.getElementById("toggle-archive-btn"); | |
297 | + toggleArchiveBtn?.addEventListener("click", async () => { | |
298 | + const isArchived = toggleArchiveBtn.dataset.archived === "true"; | |
299 | + const confirmed = confirm(`Are you sure you want to ${isArchived ? "unarchive" : "archive"} this repository?`); | |
300 | + if(!confirmed) return; | |
301 | + | |
302 | + try { | |
303 | + const response = await fetch(`/api/repos/${owner}/${repo}`, { | |
304 | + method: "PATCH", | |
305 | + headers: { "Content-Type": "application/json" }, | |
306 | + body: JSON.stringify({ isArchived: !isArchived }) | |
307 | + }); | |
308 | + if (!response.ok) throw new Error("Failed to update archive status"); | |
309 | + window.location.reload(); | |
310 | + } catch (err) { | |
311 | + alert(err.message); | |
312 | + } | |
313 | + }); | |
314 | + | |
216 | 315 | const deleteBtn = document.getElementById("delete-repo-btn"); |
217 | 316 | deleteBtn?.addEventListener("click", async () => { |
218 | 317 | const confirmed = confirm( |
219 | 318 | |
▼src/pages/api/auth/2fa/disable.ts
+33-0
| @@ -0,0 +1,33 @@ | ||
1 | + | |
2 | +import type { APIRoute } from "astro"; | |
3 | +import { getDatabase, schema } from "@/db"; | |
4 | +import { getUserFromRequest } from "@/lib/auth"; | |
5 | +import { eq } from "drizzle-orm"; | |
6 | +import { unauthorized, success, serverError } from "@/lib/api"; | |
7 | + | |
8 | +export const POST: APIRoute = async ({ request }) => { | |
9 | + try { | |
10 | + const userPayload = await getUserFromRequest(request); | |
11 | + if (!userPayload) { | |
12 | + return unauthorized(); | |
13 | + } | |
14 | + | |
15 | + const db = getDatabase(); | |
16 | + | |
17 | + // In a real app, we might require a password or 2FA token to disable 2FA. | |
18 | + // For now, we will just disable it. | |
19 | + | |
20 | + await db.update(schema.users) | |
21 | + .set({ | |
22 | + twoFactorEnabled: false, | |
23 | + twoFactorSecret: null | |
24 | + }) | |
25 | + .where(eq(schema.users.id, userPayload.userId)); | |
26 | + | |
27 | + return success({ message: "2FA disabled successfully" }); | |
28 | + | |
29 | + } catch (error) { | |
30 | + console.error("2FA Disable Error:", error); | |
31 | + return serverError("Failed to disable 2FA"); | |
32 | + } | |
33 | +}; | |
0 | 34 | |
▼src/pages/api/auth/2fa/enable.ts
+54-0
| @@ -0,0 +1,54 @@ | ||
1 | + | |
2 | +import type { APIRoute } from "astro"; | |
3 | +import { getDatabase, schema } from "@/db"; | |
4 | +import { verify2FAToken, getUserFromRequest } from "@/lib/auth"; | |
5 | +import { eq } from "drizzle-orm"; | |
6 | +import { unauthorized, success, serverError, badRequest } from "@/lib/api"; | |
7 | + | |
8 | +export const POST: APIRoute = async ({ request }) => { | |
9 | + try { | |
10 | + const userPayload = await getUserFromRequest(request); | |
11 | + if (!userPayload) { | |
12 | + return unauthorized(); | |
13 | + } | |
14 | + | |
15 | + const { token, secret } = await request.json(); | |
16 | + | |
17 | + if (!token || !secret) { | |
18 | + return badRequest("Token and secret are required"); | |
19 | + } | |
20 | + | |
21 | + // Verify token against the provided secret (which should match what we saved in DB, or we can trust client provided secret during setup flow before enabling) | |
22 | + // Better security: fetches secret from DB. | |
23 | + const db = getDatabase(); | |
24 | + const user = await db.query.users.findFirst({ | |
25 | + where: eq(schema.users.id, userPayload.userId) | |
26 | + }); | |
27 | + | |
28 | + if (!user) return unauthorized(); | |
29 | + | |
30 | + // Check if the provided secret matches the one we stored tentatively (optional check) | |
31 | + // Or just use the one from DB. | |
32 | + const storedSecret = user.twoFactorSecret; | |
33 | + if (!storedSecret) { | |
34 | + return badRequest("2FA setup not initiated"); | |
35 | + } | |
36 | + | |
37 | + const isValid = verify2FAToken(token, storedSecret); | |
38 | + | |
39 | + if (isValid) { | |
40 | + // Enable it | |
41 | + await db.update(schema.users) | |
42 | + .set({ twoFactorEnabled: true }) | |
43 | + .where(eq(schema.users.id, userPayload.userId)); | |
44 | + | |
45 | + return success({ message: "2FA enabled successfully" }); | |
46 | + } else { | |
47 | + return badRequest("Invalid authentication code"); | |
48 | + } | |
49 | + | |
50 | + } catch (error) { | |
51 | + console.error("2FA Enable Error:", error); | |
52 | + return serverError("Failed to enable 2FA"); | |
53 | + } | |
54 | +}; | |
0 | 55 | |
▼src/pages/api/auth/2fa/setup.ts
+31-0
| @@ -0,0 +1,31 @@ | ||
1 | + | |
2 | +import type { APIRoute } from "astro"; | |
3 | +import { getDatabase, schema } from "@/db"; | |
4 | +import { generate2FASecret, getUserFromRequest } from "@/lib/auth"; | |
5 | +import { eq } from "drizzle-orm"; | |
6 | +import { unauthorized, success, serverError } from "@/lib/api"; | |
7 | + | |
8 | +export const POST: APIRoute = async ({ request }) => { | |
9 | + try { | |
10 | + const userPayload = await getUserFromRequest(request); | |
11 | + if (!userPayload) { | |
12 | + return unauthorized(); | |
13 | + } | |
14 | + | |
15 | + // Generate secret | |
16 | + const { secret, uri } = generate2FASecret(userPayload.username); | |
17 | + | |
18 | + // Save secret to DB (temporarily or pending verification) | |
19 | + // Actually, we usually save it to `twoFactorSecret` but user.twoFactorEnabled remains false until verified. | |
20 | + const db = getDatabase(); | |
21 | + await db | |
22 | + .update(schema.users) | |
23 | + .set({ twoFactorSecret: secret }) // Store secret, but don't enable it yet | |
24 | + .where(eq(schema.users.id, userPayload.userId)); | |
25 | + | |
26 | + return success({ secret, uri }); | |
27 | + } catch (error) { | |
28 | + console.error("2FA Setup Error:", error); | |
29 | + return serverError("Failed to initiate 2FA setup"); | |
30 | + } | |
31 | +}; | |
0 | 32 | |
▼src/pages/new.astro
+2-2
| @@ -220,8 +220,8 @@ if (!user) { | ||
220 | 220 | </BaseLayout> |
221 | 221 | |
222 | 222 | <script> |
223 | - const form = document.getElementById("create-repo-form") as HTMLFormElement; | |
224 | - const submitBtn = document.getElementById("submit-btn") as HTMLButtonElement; | |
223 | + const form = document.getElementById("create-repo-form"); | |
224 | + const submitBtn = document.getElementById("submit-btn"); | |
225 | 225 | |
226 | 226 | form?.addEventListener("submit", async (e) => { |
227 | 227 | e.preventDefault(); |
228 | 228 | |
▼src/pages/repositories.astro
+143-3
| @@ -1,10 +1,150 @@ | ||
1 | 1 | --- |
2 | 2 | import BaseLayout from "@/layouts/BaseLayout.astro"; |
3 | +import { Button } from "@/components/ui/button"; | |
4 | +import { Input } from "@/components/ui/input"; | |
5 | +import { | |
6 | + Card, | |
7 | + CardHeader, | |
8 | + CardTitle, | |
9 | + CardDescription, | |
10 | + CardContent, | |
11 | +} from "@/components/ui/card"; | |
12 | +import { getDatabase, schema } from "@/db"; | |
13 | +import { eq, desc, and, like } from "drizzle-orm"; | |
14 | +import { Book, Star, GitBranch, Plus, Search, Lock, Globe } from "lucide-react"; | |
15 | + | |
16 | +const user = Astro.locals.user; | |
17 | +if (!user) { | |
18 | + return Astro.redirect("/login"); | |
19 | +} | |
20 | + | |
21 | +const db = getDatabase(); | |
22 | +const q = Astro.url.searchParams.get("q") || ""; | |
23 | + | |
24 | +// Fetch user's repositories with search | |
25 | +const repos = await db | |
26 | + .select() | |
27 | + .from(schema.repositories) | |
28 | + .where( | |
29 | + and( | |
30 | + eq(schema.repositories.ownerId, user.id), | |
31 | + q ? like(schema.repositories.name, `%${q}%`) : undefined | |
32 | + ) | |
33 | + ) | |
34 | + .orderBy(desc(schema.repositories.updatedAt)) | |
35 | + .all(); | |
36 | + | |
37 | +function timeAgo(date: Date) { | |
38 | + const seconds = Math.floor( | |
39 | + (new Date().getTime() - new Date(date).getTime()) / 1000 | |
40 | + ); | |
41 | + let interval = seconds / 31536000; | |
42 | + if (interval > 1) return Math.floor(interval) + " years ago"; | |
43 | + interval = seconds / 2592000; | |
44 | + if (interval > 1) return Math.floor(interval) + " months ago"; | |
45 | + interval = seconds / 86400; | |
46 | + if (interval > 1) return Math.floor(interval) + " days ago"; | |
47 | + interval = seconds / 3600; | |
48 | + if (interval > 1) return Math.floor(interval) + " hours ago"; | |
49 | + interval = seconds / 60; | |
50 | + if (interval > 1) return Math.floor(interval) + " minutes ago"; | |
51 | + return Math.floor(seconds) + " seconds ago"; | |
52 | +} | |
3 | 53 | --- |
4 | 54 | |
5 | 55 | <BaseLayout title="Your Repositories"> |
6 | - <div class="container py-10"> | |
7 | - <h1 class="text-3xl font-bold">Your Repositories</h1> | |
8 | - <p class="text-muted-foreground mt-4">This feature is coming soon.</p> | |
56 | + <div class="container py-8 max-w-5xl"> | |
57 | + <div class="flex flex-col gap-6"> | |
58 | + <div class="flex items-center justify-between"> | |
59 | + <div> | |
60 | + <h1 class="text-3xl font-bold">Your Repositories</h1> | |
61 | + <p class="text-muted-foreground mt-1">Manage and browse your projects.</p> | |
62 | + </div> | |
63 | + <Button asChild> | |
64 | + <a href="/new"> | |
65 | + <Plus class="mr-2 h-4 w-4" /> | |
66 | + New Repository | |
67 | + </a> | |
68 | + </Button> | |
69 | + </div> | |
70 | + | |
71 | + <div class="flex items-center gap-4"> | |
72 | + <div class="relative flex-1"> | |
73 | + <Search class="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" /> | |
74 | + <form> | |
75 | + <Input | |
76 | + name="q" | |
77 | + type="search" | |
78 | + placeholder="Find a repository..." | |
79 | + class="pl-9 w-full md:w-[400px]" | |
80 | + value={q} | |
81 | + /> | |
82 | + </form> | |
83 | + </div> | |
84 | + </div> | |
85 | + | |
86 | + <div class="space-y-4"> | |
87 | + {repos.length === 0 ? ( | |
88 | + <div class="text-center py-12 border rounded-lg bg-muted/10"> | |
89 | + <div class="inline-flex h-12 w-12 items-center justify-center rounded-full bg-muted mb-4"> | |
90 | + <Book class="h-6 w-6 text-muted-foreground" /> | |
91 | + </div> | |
92 | + <h3 class="text-lg font-medium">No repositories found</h3> | |
93 | + <p class="text-muted-foreground mt-1 mb-4"> | |
94 | + {q ? "Try adjusting your search query." : "Get started by creating your first repository."} | |
95 | + </p> | |
96 | + {!q && ( | |
97 | + <Button asChild> | |
98 | + <a href="/new">Create Repository</a> | |
99 | + </Button> | |
100 | + )} | |
101 | + </div> | |
102 | + ) : ( | |
103 | + <div class="grid gap-4"> | |
104 | + {repos.map(repo => ( | |
105 | + <div class="flex items-start justify-between p-4 border rounded-lg hover:bg-muted/30 transition-colors"> | |
106 | + <div class="space-y-1"> | |
107 | + <div class="flex items-center gap-2"> | |
108 | + <a href={`/${repo.slug}`} class="text-xl font-semibold hover:text-primary hover:underline"> | |
109 | + {repo.name} | |
110 | + </a> | |
111 | + <span class="inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium text-muted-foreground"> | |
112 | + {repo.visibility === "public" ? "Public" : "Private"} | |
113 | + </span> | |
114 | + </div> | |
115 | + <p class="text-muted-foreground text-sm line-clamp-1"> | |
116 | + {repo.description || "No description provided."} | |
117 | + </p> | |
118 | + <div class="flex items-center gap-4 text-xs text-muted-foreground mt-2"> | |
119 | + {repo.language && ( | |
120 | + <div class="flex items-center gap-1"> | |
121 | + <span class="h-2 w-2 rounded-full bg-blue-500"></span> | |
122 | + {repo.language} | |
123 | + </div> | |
124 | + )} | |
125 | + <div class="flex items-center gap-1"> | |
126 | + <Star class="h-3 w-3" /> | |
127 | + {repo.starCount} | |
128 | + </div> | |
129 | + <div class="flex items-center gap-1"> | |
130 | + <GitBranch class="h-3 w-3" /> | |
131 | + {repo.forkCount} | |
132 | + </div> | |
133 | + <div> | |
134 | + Updated {timeAgo(repo.updatedAt)} | |
135 | + </div> | |
136 | + </div> | |
137 | + </div> | |
138 | + <Button variant="ghost" size="sm" asChild> | |
139 | + <a href={`/${repo.slug}`} class="text-muted-foreground"> | |
140 | + View | |
141 | + </a> | |
142 | + </Button> | |
143 | + </div> | |
144 | + ))} | |
145 | + </div> | |
146 | + )} | |
147 | + </div> | |
148 | + </div> | |
9 | 149 | </div> |
10 | 150 | </BaseLayout> |
11 | 151 | |