8.9 KB256 lines
Blame
1"use client";
2
3import Link from "next/link";
4import { useEffect, useMemo, useState } from "react";
5import { repos as reposApi, type Repo } from "@/lib/api";
6import { GroveLogo } from "@/app/components/grove-logo";
7import { useAuth } from "@/lib/auth";
8import { Skeleton } from "@/app/components/skeleton";
9import { timeAgo } from "@/lib/utils";
10import { LandingPage } from "./landing";
11
12export default function HomePage() {
13 const { user, loading } = useAuth();
14
15 if (!loading && !user) {
16 return <LandingPage />;
17 }
18 const [repoList, setRepoList] = useState<Repo[]>([]);
19 const [loaded, setLoaded] = useState(false);
20 const [query, setQuery] = useState("");
21
22 useEffect(() => {
23 document.title = "Explore";
24 }, []);
25
26 useEffect(() => {
27 reposApi
28 .list()
29 .then(({ repos }) => setRepoList(repos))
30 .catch(() => {})
31 .finally(() => setLoaded(true));
32 }, []);
33
34 const reposWithActivity = useMemo(() => {
35 return repoList
36 .map((repo) => {
37 const commitTs = repo.last_commit_ts ?? null;
38 const updatedTs = repo.updated_at
39 ? Math.floor(new Date(repo.updated_at).getTime() / 1000)
40 : null;
41 const activityTs = commitTs ?? updatedTs ?? null;
42 const activityLabel = commitTs ? "Pushed" : updatedTs ? "Updated" : null;
43 return { repo, activityTs, activityLabel };
44 })
45 .sort((a, b) => {
46 const aTs = a.activityTs ?? 0;
47 const bTs = b.activityTs ?? 0;
48 if (aTs !== bTs) return bTs - aTs;
49 return a.repo.name.localeCompare(b.repo.name);
50 });
51 }, [repoList]);
52
53 const normalizedQuery = query.trim().toLowerCase();
54 const filteredRepos = useMemo(() => {
55 if (!normalizedQuery) return reposWithActivity;
56 return reposWithActivity.filter(({ repo }) => {
57 const haystack = `${repo.owner_name} ${repo.name} ${repo.description ?? ""}`.toLowerCase();
58 return haystack.includes(normalizedQuery);
59 });
60 }, [reposWithActivity, normalizedQuery]);
61
62 const groupedByOwner = useMemo(() => {
63 const groups: { owner: string; repos: typeof filteredRepos }[] = [];
64 const map = new Map<string, typeof filteredRepos>();
65 for (const entry of filteredRepos) {
66 const owner = entry.repo.owner_name;
67 let list = map.get(owner);
68 if (!list) {
69 list = [];
70 map.set(owner, list);
71 groups.push({ owner, repos: list });
72 }
73 list.push(entry);
74 }
75 return groups;
76 }, [filteredRepos]);
77
78 return (
79 <div className="max-w-3xl mx-auto px-4 py-8">
80 {!loaded ? (
81 <>
82 <div
83 className="mb-4 px-3 py-2.5"
84 style={{
85 backgroundColor: "var(--bg-card)",
86 border: "1px solid var(--border-subtle)",
87 }}
88 >
89 <Skeleton width="100%" height="1rem" />
90 </div>
91 <div
92 style={{
93 backgroundColor: "var(--bg-card)",
94 border: "1px solid var(--border-subtle)",
95 }}
96 >
97 {Array.from({ length: 4 }).map((_, i) => (
98 <div
99 key={i}
100 className="py-2.5 px-3"
101 style={{
102 borderTop: i > 0 ? "1px solid var(--divide)" : undefined,
103 }}
104 >
105 <div className="flex items-center justify-between gap-4">
106 <Skeleton width={`${[240, 200, 260][i % 3]}px`} height="0.875rem" />
107 <Skeleton width="3.2rem" height="0.75rem" />
108 </div>
109 <div className="mt-1">
110 <Skeleton width={`${[180, 220, 160][i % 3]}px`} height="0.7rem" />
111 </div>
112 </div>
113 ))}
114 </div>
115 </>
116 ) : (
117 <>
118 {repoList.length === 0 ? (
119 <div
120 className="p-8 text-center"
121 style={{
122 backgroundColor: "var(--bg-card)",
123 border: "1px solid var(--border-subtle)",
124 }}
125 >
126 <div className="mx-auto mb-4 w-fit opacity-50">
127 <GroveLogo size={48} />
128 </div>
129 <p className="text-sm mb-1" style={{ color: "var(--text-secondary)" }}>
130 No repositories yet
131 </p>
132 <p className="text-sm" style={{ color: "var(--text-faint)" }}>
133 {user
134 ? "Create your first repository."
135 : "Sign in to deploy an instance and start hosting code."}
136 </p>
137 {user && (
138 <Link
139 href="/new"
140 className="inline-block mt-4 px-3 py-1.5 text-sm"
141 style={{
142 backgroundColor: "var(--accent)",
143 color: "var(--accent-text)",
144 }}
145 >
146 New repository
147 </Link>
148 )}
149 </div>
150 ) : (
151 <>
152 <div className="mb-4 flex items-center gap-2">
153 <div
154 className="px-3 py-2.5 flex-1"
155 style={{
156 backgroundColor: "var(--bg-card)",
157 border: "1px solid var(--border-subtle)",
158 }}
159 >
160 <input
161 value={query}
162 onChange={(e) => setQuery(e.target.value)}
163 placeholder="Search repositories"
164 className="w-full text-sm"
165 style={{
166 backgroundColor: "transparent",
167 color: "var(--text-secondary)",
168 outline: "none",
169 }}
170 />
171 </div>
172 {user && (
173 <Link
174 href="/new"
175 className="px-3 py-2.5 text-sm shrink-0"
176 style={{
177 backgroundColor: "var(--accent)",
178 color: "var(--accent-text)",
179 }}
180 >
181 New repository
182 </Link>
183 )}
184 </div>
185 {filteredRepos.length === 0 ? (
186 <div
187 className="px-3 py-8 text-center text-sm"
188 style={{
189 backgroundColor: "var(--bg-card)",
190 border: "1px solid var(--border-subtle)",
191 color: "var(--text-faint)",
192 }}
193 >
194 No repositories match your search.
195 </div>
196 ) : (
197 <div className="flex flex-col gap-4">
198 {groupedByOwner.map(({ owner, repos }) => (
199 <div
200 key={owner}
201 style={{
202 backgroundColor: "var(--bg-card)",
203 border: "1px solid var(--border-subtle)",
204 }}
205 >
206 <Link
207 href={`/${owner}`}
208 className="block px-3 py-2 text-sm font-medium hover-row"
209 style={{
210 color: "var(--text-muted)",
211 borderBottom: "1px solid var(--border-subtle)",
212 }}
213 >
214 {owner}
215 </Link>
216 {repos.map(({ repo, activityTs, activityLabel }, i) => (
217 <Link
218 key={repo.id}
219 href={`/${repo.owner_name}/${repo.name}`}
220 className="block py-2.5 px-3 text-sm hover-row"
221 style={{
222 borderTop: i > 0 ? "1px solid var(--border-subtle)" : undefined,
223 }}
224 >
225 <div className="flex items-center justify-between gap-4">
226 <div className="min-w-0">
227 <div className="truncate" style={{ color: "var(--accent)" }}>
228 {repo.name}
229 </div>
230 <div className="text-xs mt-0.5 truncate" style={{ color: "var(--text-faint)" }}>
231 {repo.description || "No description"}
232 </div>
233 </div>
234 <span
235 className="text-xs shrink-0 ml-4"
236 style={{ color: "var(--text-faint)" }}
237 >
238 {activityTs
239 ? `${activityLabel ?? "Updated"} ${timeAgo(activityTs)}`
240 : "No activity"}
241 </span>
242 </div>
243 </Link>
244 ))}
245 </div>
246 ))}
247 </div>
248 )}
249 </>
250 )}
251 </>
252 )}
253 </div>
254 );
255}
256