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