| 1 | "use client"; |
| 2 | |
| 3 | import Link from "next/link"; |
| 4 | import { use, useEffect, useState } from "react"; |
| 5 | import { |
| 6 | repos as reposApi, |
| 7 | orgs as orgsApi, |
| 8 | type Repo, |
| 9 | type Org, |
| 10 | type OrgMember, |
| 11 | } from "@/lib/api"; |
| 12 | import { timeAgo } from "@/lib/utils"; |
| 13 | import { ListSkeleton } from "@/app/components/skeleton"; |
| 14 | import { useAuth } from "@/lib/auth"; |
| 15 | |
| 16 | interface Props { |
| 17 | params: Promise<{ owner: string }>; |
| 18 | } |
| 19 | |
| 20 | export default function OwnerPage({ params }: Props) { |
| 21 | const { owner } = use(params); |
| 22 | const { user } = useAuth(); |
| 23 | const [repoList, setRepoList] = useState<Repo[]>([]); |
| 24 | const [orgData, setOrgData] = useState<{ |
| 25 | org: Org; |
| 26 | members: OrgMember[]; |
| 27 | } | null>(null); |
| 28 | const [isOrg, setIsOrg] = useState(false); |
| 29 | const [loaded, setLoaded] = useState(false); |
| 30 | const [lastCommitTimes, setLastCommitTimes] = useState<Record<number, number>>({}); |
| 31 | |
| 32 | useEffect(() => { |
| 33 | document.title = owner; |
| 34 | }, [owner]); |
| 35 | |
| 36 | useEffect(() => { |
| 37 | Promise.all([ |
| 38 | orgsApi.get(owner).catch(() => null), |
| 39 | reposApi |
| 40 | .list() |
| 41 | .then(({ repos }) => repos.filter((r) => r.owner_name === owner)), |
| 42 | ]) |
| 43 | .then(([orgResult, repos]) => { |
| 44 | if (orgResult) { |
| 45 | setOrgData(orgResult); |
| 46 | setIsOrg(true); |
| 47 | } |
| 48 | setRepoList(repos); |
| 49 | }) |
| 50 | .catch(() => {}) |
| 51 | .finally(() => setLoaded(true)); |
| 52 | }, [owner]); |
| 53 | |
| 54 | useEffect(() => { |
| 55 | if (repoList.length === 0) return; |
| 56 | Promise.all( |
| 57 | repoList.map(async (repo) => { |
| 58 | try { |
| 59 | const data = await reposApi.commits(repo.owner_name, repo.name, "main", { limit: 1 }); |
| 60 | const latest = data.commits[0]; |
| 61 | if (latest) return { id: repo.id, timestamp: latest.timestamp }; |
| 62 | } catch {} |
| 63 | return null; |
| 64 | }) |
| 65 | ).then((results) => { |
| 66 | const times: Record<number, number> = {}; |
| 67 | for (const r of results) { |
| 68 | if (r) times[r.id] = r.timestamp; |
| 69 | } |
| 70 | setLastCommitTimes(times); |
| 71 | }); |
| 72 | }, [repoList]); |
| 73 | |
| 74 | return ( |
| 75 | <div className="max-w-3xl mx-auto px-4 py-6"> |
| 76 | <h1 className="text-lg mb-1"> |
| 77 | {isOrg && orgData |
| 78 | ? orgData.org.display_name || orgData.org.name |
| 79 | : owner} |
| 80 | </h1> |
| 81 | {isOrg && orgData && ( |
| 82 | <p className="text-xs mb-6" style={{ color: "var(--text-faint)" }}> |
| 83 | {orgData.members.length} member |
| 84 | {orgData.members.length !== 1 ? "s" : ""} |
| 85 | {" \u00b7 "} |
| 86 | {orgData.members.map((m) => m.username).join(", ")} |
| 87 | </p> |
| 88 | )} |
| 89 | |
| 90 | {!isOrg && <div className="mb-6" />} |
| 91 | |
| 92 | {user && ( |
| 93 | <div className="mb-4"> |
| 94 | <Link |
| 95 | href={`/new?owner=${encodeURIComponent(owner)}`} |
| 96 | className="inline-block px-3 py-1.5 text-sm" |
| 97 | style={{ |
| 98 | backgroundColor: "var(--accent)", |
| 99 | color: "var(--accent-text)", |
| 100 | }} |
| 101 | > |
| 102 | New repository |
| 103 | </Link> |
| 104 | </div> |
| 105 | )} |
| 106 | |
| 107 | {!loaded ? ( |
| 108 | <ListSkeleton rows={3} /> |
| 109 | ) : repoList.length === 0 ? ( |
| 110 | <p className="text-sm" style={{ color: "var(--text-muted)" }}> |
| 111 | No repositories found for this {isOrg ? "organization" : "user"}. |
| 112 | </p> |
| 113 | ) : ( |
| 114 | <div |
| 115 | style={{ |
| 116 | border: "1px solid var(--border-subtle)", |
| 117 | }} |
| 118 | > |
| 119 | {repoList.map((repo, i) => ( |
| 120 | <Link |
| 121 | key={repo.id} |
| 122 | href={`/${repo.owner_name}/${repo.name}`} |
| 123 | className="flex items-baseline justify-between py-2.5 px-3 text-sm hover-row" |
| 124 | style={{ |
| 125 | borderTop: i > 0 ? "1px solid var(--divide)" : undefined, |
| 126 | }} |
| 127 | > |
| 128 | <div className="min-w-0 truncate"> |
| 129 | <span style={{ color: "var(--accent)" }}>{repo.name}</span> |
| 130 | {repo.description && ( |
| 131 | <span |
| 132 | className="ml-3 text-xs hidden sm:inline" |
| 133 | style={{ color: "var(--text-faint)" }} |
| 134 | > |
| 135 | {repo.description} |
| 136 | </span> |
| 137 | )} |
| 138 | </div> |
| 139 | {lastCommitTimes[repo.id] && ( |
| 140 | <span |
| 141 | className="text-xs shrink-0 ml-4" |
| 142 | style={{ color: "var(--text-faint)" }} |
| 143 | > |
| 144 | {timeAgo(lastCommitTimes[repo.id])} |
| 145 | </span> |
| 146 | )} |
| 147 | </Link> |
| 148 | ))} |
| 149 | </div> |
| 150 | )} |
| 151 | </div> |
| 152 | ); |
| 153 | } |
| 154 | |