web/app/page.tsxblame
View source
4a006da1"use client";
4a006da2
3e3af553import Link from "next/link";
73fdc9e4import { useEffect, useMemo, useState } from "react";
4a006da5import { repos as reposApi, type Repo } from "@/lib/api";
818dc906import { GroveLogo } from "@/app/components/grove-logo";
4a006da7import { useAuth } from "@/lib/auth";
9d879c08import { Skeleton } from "@/app/components/skeleton";
0b1c50f9import { timeAgo } from "@/lib/utils";
4bb999b10import { LandingPage } from "./landing";
3e3af5511
3e3af5512export default function HomePage() {
4bb999b13 const { user, loading } = useAuth();
4bb999b14
4bb999b15 if (!loading && !user) {
4bb999b16 return <LandingPage />;
4bb999b17 }
4a006da18 const [repoList, setRepoList] = useState<Repo[]>([]);
4a006da19 const [loaded, setLoaded] = useState(false);
73fdc9e20 const [query, setQuery] = useState("");
4a006da21
1da987422 useEffect(() => {
1da987423 document.title = "Explore";
1da987424 }, []);
1da987425
4a006da26 useEffect(() => {
4a006da27 reposApi
4a006da28 .list()
4a006da29 .then(({ repos }) => setRepoList(repos))
4a006da30 .catch(() => {})
4a006da31 .finally(() => setLoaded(true));
4a006da32 }, []);
4a006da33
73fdc9e34 const reposWithActivity = useMemo(() => {
73fdc9e35 return repoList
73fdc9e36 .map((repo) => {
bc2f20537 const commitTs = repo.last_commit_ts ?? null;
73fdc9e38 const updatedTs = repo.updated_at
73fdc9e39 ? Math.floor(new Date(repo.updated_at).getTime() / 1000)
73fdc9e40 : null;
73fdc9e41 const activityTs = commitTs ?? updatedTs ?? null;
73fdc9e42 const activityLabel = commitTs ? "Pushed" : updatedTs ? "Updated" : null;
13a9fd143 return { repo, activityTs, activityLabel };
73fdc9e44 })
73fdc9e45 .sort((a, b) => {
73fdc9e46 const aTs = a.activityTs ?? 0;
73fdc9e47 const bTs = b.activityTs ?? 0;
73fdc9e48 if (aTs !== bTs) return bTs - aTs;
73fdc9e49 return a.repo.name.localeCompare(b.repo.name);
73fdc9e50 });
bc2f20551 }, [repoList]);
3e3af5552
73fdc9e53 const normalizedQuery = query.trim().toLowerCase();
73fdc9e54 const filteredRepos = useMemo(() => {
73fdc9e55 if (!normalizedQuery) return reposWithActivity;
73fdc9e56 return reposWithActivity.filter(({ repo }) => {
73fdc9e57 const haystack = `${repo.owner_name} ${repo.name} ${repo.description ?? ""}`.toLowerCase();
73fdc9e58 return haystack.includes(normalizedQuery);
73fdc9e59 });
73fdc9e60 }, [reposWithActivity, normalizedQuery]);
73fdc9e61
9af7da262 const groupedByOwner = useMemo(() => {
9af7da263 const groups: { owner: string; repos: typeof filteredRepos }[] = [];
9af7da264 const map = new Map<string, typeof filteredRepos>();
9af7da265 for (const entry of filteredRepos) {
9af7da266 const owner = entry.repo.owner_name;
9af7da267 let list = map.get(owner);
9af7da268 if (!list) {
9af7da269 list = [];
9af7da270 map.set(owner, list);
9af7da271 groups.push({ owner, repos: list });
9af7da272 }
9af7da273 list.push(entry);
9af7da274 }
9af7da275 return groups;
9af7da276 }, [filteredRepos]);
9af7da277
73fdc9e78 return (
73fdc9e79 <div className="max-w-3xl mx-auto px-4 py-8">
4a006da80 {!loaded ? (
73fdc9e81 <>
73fdc9e82 <div
73fdc9e83 className="mb-4 px-3 py-2.5"
73fdc9e84 style={{
73fdc9e85 backgroundColor: "var(--bg-card)",
73fdc9e86 border: "1px solid var(--border-subtle)",
73fdc9e87 }}
73fdc9e88 >
73fdc9e89 <Skeleton width="100%" height="1rem" />
73fdc9e90 </div>
73fdc9e91 <div
73fdc9e92 style={{
73fdc9e93 backgroundColor: "var(--bg-card)",
73fdc9e94 border: "1px solid var(--border-subtle)",
73fdc9e95 }}
73fdc9e96 >
73fdc9e97 {Array.from({ length: 4 }).map((_, i) => (
73fdc9e98 <div
73fdc9e99 key={i}
73fdc9e100 className="py-2.5 px-3"
73fdc9e101 style={{
73fdc9e102 borderTop: i > 0 ? "1px solid var(--divide)" : undefined,
73fdc9e103 }}
73fdc9e104 >
73fdc9e105 <div className="flex items-center justify-between gap-4">
13a9fd1106 <Skeleton width={`${[240, 200, 260][i % 3]}px`} height="0.875rem" />
73fdc9e107 <Skeleton width="3.2rem" height="0.75rem" />
73fdc9e108 </div>
73fdc9e109 <div className="mt-1">
73fdc9e110 <Skeleton width={`${[180, 220, 160][i % 3]}px`} height="0.7rem" />
73fdc9e111 </div>
9d879c0112 </div>
73fdc9e113 ))}
818dc90114 </div>
73fdc9e115 </>
4a006da116 ) : (
73fdc9e117 <>
73fdc9e118 {repoList.length === 0 ? (
73fdc9e119 <div
73fdc9e120 className="p-8 text-center"
4a006da121 style={{
73fdc9e122 backgroundColor: "var(--bg-card)",
73fdc9e123 border: "1px solid var(--border-subtle)",
cf89d3c124 }}
4a006da125 >
73fdc9e126 <div className="mx-auto mb-4 w-fit opacity-50">
73fdc9e127 <GroveLogo size={48} />
73fdc9e128 </div>
73fdc9e129 <p className="text-sm mb-1" style={{ color: "var(--text-secondary)" }}>
73fdc9e130 No repositories yet
73fdc9e131 </p>
73fdc9e132 <p className="text-sm" style={{ color: "var(--text-faint)" }}>
73fdc9e133 {user
13a9fd1134 ? "Create your first repository."
73fdc9e135 : "Sign in to deploy an instance and start hosting code."}
73fdc9e136 </p>
13a9fd1137 {user && (
13a9fd1138 <Link
13a9fd1139 href="/new"
13a9fd1140 className="inline-block mt-4 px-3 py-1.5 text-sm"
13a9fd1141 style={{
13a9fd1142 backgroundColor: "var(--accent)",
13a9fd1143 color: "var(--accent-text)",
13a9fd1144 }}
13a9fd1145 >
13a9fd1146 New repository
13a9fd1147 </Link>
13a9fd1148 )}
73fdc9e149 </div>
73fdc9e150 ) : (
73fdc9e151 <>
13a9fd1152 <div className="mb-4 flex items-center gap-2">
13a9fd1153 <div
13a9fd1154 className="px-3 py-2.5 flex-1"
73fdc9e155 style={{
13a9fd1156 backgroundColor: "var(--bg-card)",
13a9fd1157 border: "1px solid var(--border-subtle)",
73fdc9e158 }}
13a9fd1159 >
13a9fd1160 <input
13a9fd1161 value={query}
13a9fd1162 onChange={(e) => setQuery(e.target.value)}
13a9fd1163 placeholder="Search repositories"
13a9fd1164 className="w-full text-sm"
13a9fd1165 style={{
13a9fd1166 backgroundColor: "transparent",
13a9fd1167 color: "var(--text-secondary)",
13a9fd1168 outline: "none",
13a9fd1169 }}
13a9fd1170 />
13a9fd1171 </div>
13a9fd1172 {user && (
13a9fd1173 <Link
13a9fd1174 href="/new"
13a9fd1175 className="px-3 py-2.5 text-sm shrink-0"
13a9fd1176 style={{
13a9fd1177 backgroundColor: "var(--accent)",
13a9fd1178 color: "var(--accent-text)",
13a9fd1179 }}
13a9fd1180 >
13a9fd1181 New repository
13a9fd1182 </Link>
13a9fd1183 )}
73fdc9e184 </div>
9af7da2185 {filteredRepos.length === 0 ? (
9af7da2186 <div
9af7da2187 className="px-3 py-8 text-center text-sm"
9af7da2188 style={{
9af7da2189 backgroundColor: "var(--bg-card)",
9af7da2190 border: "1px solid var(--border-subtle)",
9af7da2191 color: "var(--text-faint)",
9af7da2192 }}
9af7da2193 >
9af7da2194 No repositories match your search.
9af7da2195 </div>
9af7da2196 ) : (
9af7da2197 <div className="flex flex-col gap-4">
9af7da2198 {groupedByOwner.map(({ owner, repos }) => (
9af7da2199 <div
9af7da2200 key={owner}
73fdc9e201 style={{
9af7da2202 backgroundColor: "var(--bg-card)",
9af7da2203 border: "1px solid var(--border-subtle)",
73fdc9e204 }}
73fdc9e205 >
9af7da2206 <Link
9af7da2207 href={`/${owner}`}
9af7da2208 className="block px-3 py-2 text-sm font-medium hover-row"
9af7da2209 style={{
9af7da2210 color: "var(--text-muted)",
9af7da2211 borderBottom: "1px solid var(--border-subtle)",
9af7da2212 }}
9af7da2213 >
9af7da2214 {owner}
9af7da2215 </Link>
9af7da2216 {repos.map(({ repo, activityTs, activityLabel }, i) => (
9af7da2217 <Link
9af7da2218 key={repo.id}
9af7da2219 href={`/${repo.owner_name}/${repo.name}`}
9af7da2220 className="block py-2.5 px-3 text-sm hover-row"
9af7da2221 style={{
9af7da2222 borderTop: i > 0 ? "1px solid var(--border-subtle)" : undefined,
9af7da2223 }}
9af7da2224 >
9af7da2225 <div className="flex items-center justify-between gap-4">
9af7da2226 <div className="min-w-0">
9af7da2227 <div className="truncate" style={{ color: "var(--accent)" }}>
9af7da2228 {repo.name}
9af7da2229 </div>
9af7da2230 <div className="text-xs mt-0.5 truncate" style={{ color: "var(--text-faint)" }}>
9af7da2231 {repo.description || "No description"}
9af7da2232 </div>
9af7da2233 </div>
9af7da2234 <span
9af7da2235 className="text-xs shrink-0 ml-4"
9af7da2236 style={{ color: "var(--text-faint)" }}
9af7da2237 >
9af7da2238 {activityTs
9af7da2239 ? `${activityLabel ?? "Updated"} ${timeAgo(activityTs)}`
9af7da2240 : "No activity"}
73fdc9e241 </span>
73fdc9e242 </div>
9af7da2243 </Link>
9af7da2244 ))}
9af7da2245 </div>
9af7da2246 ))}
9af7da2247 </div>
9af7da2248 )}
73fdc9e249 </>
73fdc9e250 )}
73fdc9e251 </>
4a006da252 )}
3e3af55253 </div>
3e3af55254 );
3e3af55255}