| 1 | "use client"; |
| 2 | |
| 3 | import Link from "next/link"; |
| 4 | import { useEffect, useMemo, useState } from "react"; |
| 5 | import { repos as reposApi, type Repo } from "@/lib/api"; |
| 6 | import { GroveLogo } from "@/app/components/grove-logo"; |
| 7 | import { useAuth } from "@/lib/auth"; |
| 8 | import { Skeleton } from "@/app/components/skeleton"; |
| 9 | import { timeAgo } from "@/lib/utils"; |
| 10 | import { LandingPage } from "./landing"; |
| 11 | |
| 12 | export 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 | |