appview/pages: show trending repos in the timeline #515

merged
opened by anirudh.fi targeting master from push-zxovstvplnok
Changed files
+221 -17
appview
db
pages
templates
layouts
timeline
state
+4
appview/db/db.go
··· 470 470 id integer primary key autoincrement, 471 471 name text unique 472 472 ); 473 + 474 + -- indexes for better star query performance 475 + create index if not exists idx_stars_created on stars(created); 476 + create index if not exists idx_stars_repo_at_created on stars(repo_at, created); 473 477 `) 474 478 if err != nil { 475 479 return nil, err
+73 -3
appview/db/star.go
··· 47 47 // Get a star record 48 48 func GetStar(e Execer, starredByDid string, repoAt syntax.ATURI) (*Star, error) { 49 49 query := ` 50 - select starred_by_did, repo_at, created, rkey 50 + select starred_by_did, repo_at, created, rkey 51 51 from stars 52 52 where starred_by_did = ? and repo_at = ?` 53 53 row := e.QueryRow(query, starredByDid, repoAt) ··· 119 119 } 120 120 121 121 repoQuery := fmt.Sprintf( 122 - `select starred_by_did, repo_at, created, rkey 122 + `select starred_by_did, repo_at, created, rkey 123 123 from stars 124 124 %s 125 125 order by created desc ··· 187 187 var stars []Star 188 188 189 189 rows, err := e.Query(` 190 - select 190 + select 191 191 s.starred_by_did, 192 192 s.repo_at, 193 193 s.rkey, ··· 244 244 245 245 return stars, nil 246 246 } 247 + 248 + // GetTopStarredReposLastWeek returns the top 8 most starred repositories from the last week 249 + func GetTopStarredReposLastWeek(e Execer) ([]Repo, error) { 250 + // first, get the top repo URIs by star count from the last week 251 + query := ` 252 + with recent_starred_repos as ( 253 + select distinct repo_at 254 + from stars 255 + where created >= datetime('now', '-7 days') 256 + ), 257 + repo_star_counts as ( 258 + select 259 + s.repo_at, 260 + count(*) as star_count 261 + from stars s 262 + join recent_starred_repos rsr on s.repo_at = rsr.repo_at 263 + where s.created >= datetime('now', '-7 days') 264 + group by s.repo_at 265 + ) 266 + select rsc.repo_at 267 + from repo_star_counts rsc 268 + order by rsc.star_count desc 269 + limit 8 270 + ` 271 + 272 + rows, err := e.Query(query) 273 + if err != nil { 274 + return nil, err 275 + } 276 + defer rows.Close() 277 + 278 + var repoUris []string 279 + for rows.Next() { 280 + var repoUri string 281 + err := rows.Scan(&repoUri) 282 + if err != nil { 283 + return nil, err 284 + } 285 + repoUris = append(repoUris, repoUri) 286 + } 287 + 288 + if err := rows.Err(); err != nil { 289 + return nil, err 290 + } 291 + 292 + if len(repoUris) == 0 { 293 + return []Repo{}, nil 294 + } 295 + 296 + // get full repo data 297 + repos, err := GetRepos(e, 0, FilterIn("at_uri", repoUris)) 298 + if err != nil { 299 + return nil, err 300 + } 301 + 302 + // sort repos by the original trending order 303 + repoMap := make(map[string]Repo) 304 + for _, repo := range repos { 305 + repoMap[repo.RepoAt().String()] = repo 306 + } 307 + 308 + orderedRepos := make([]Repo, 0, len(repoUris)) 309 + for _, uri := range repoUris { 310 + if repo, exists := repoMap[uri]; exists { 311 + orderedRepos = append(orderedRepos, repo) 312 + } 313 + } 314 + 315 + return orderedRepos, nil 316 + }
+10 -1
appview/pages/pages.go
··· 302 302 } 303 303 304 304 func (p *Pages) Timeline(w io.Writer, params TimelineParams) error { 305 - return p.execute("timeline", w, params) 305 + return p.execute("timeline/timeline", w, params) 306 + } 307 + 308 + type TopStarredReposLastWeekParams struct { 309 + LoggedInUser *oauth.User 310 + Repos []db.Repo 311 + } 312 + 313 + func (p *Pages) TopStarredReposLastWeek(w io.Writer, params TopStarredReposLastWeekParams) error { 314 + return p.executePlain("timeline/fragments/topStarredRepos", w, params) 306 315 } 307 316 308 317 type SettingsParams struct {
+32 -9
appview/pages/templates/layouts/base.html
··· 16 16 </head> 17 17 <body class="min-h-screen grid grid-cols-1 grid-rows-[min-content_auto_min-content] md:grid-cols-12 gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200"> 18 18 {{ block "topbarLayout" . }} 19 - <header class="px-1 col-span-1 md:col-start-3 md:col-span-8" style="z-index: 20;"> 19 + <header class="px-1 col-span-full" style="z-index: 20;"> 20 20 {{ template "layouts/topbar" . }} 21 21 </header> 22 22 {{ end }} 23 23 24 24 {{ block "mainLayout" . }} 25 + <!-- Mobile trending carousel at top - full width --> 26 + <div class="px-1 col-span-full lg:hidden"> 27 + {{ block "contentRight" . }} {{ end }} 28 + </div> 29 + 25 30 <div class="px-1 col-span-1 md:col-start-3 md:col-span-8 flex flex-col gap-4"> 31 + 26 32 {{ block "contentLayout" . }} 27 - <main class="col-span-1 md:col-span-8"> 28 - {{ block "content" . }}{{ end }} 29 - </main> 33 + <div class="grid grid-cols-1 lg:grid-cols-12 gap-4"> 34 + <div class="lg:col-span-2"> 35 + {{ block "contentLeft" . }} {{ end }} 36 + </div> 37 + <main class="lg:col-span-8"> 38 + {{ block "content" . }}{{ end }} 39 + </main> 40 + <!-- Desktop trending sidebar --> 41 + <div class="hidden lg:block lg:col-span-2"> 42 + {{ block "contentRight" . }} {{ end }} 43 + </div> 44 + </div> 30 45 {{ end }} 31 - 46 + 32 47 {{ block "contentAfterLayout" . }} 33 - <main class="col-span-1 md:col-span-8"> 34 - {{ block "contentAfter" . }}{{ end }} 35 - </main> 48 + <div class="grid grid-cols-1 lg:grid-cols-12 gap-4"> 49 + <div class="lg:col-span-2"> 50 + {{ block "contentAfterLeft" . }} {{ end }} 51 + </div> 52 + <main class="lg:col-span-8"> 53 + {{ block "contentAfter" . }}{{ end }} 54 + </main> 55 + <div class="lg:col-span-2"> 56 + {{ block "contentAfterRight" . }} {{ end }} 57 + </div> 58 + </div> 36 59 {{ end }} 37 60 </div> 38 61 {{ end }} 39 62 40 63 {{ block "footerLayout" . }} 41 - <footer class="px-1 col-span-1 md:col-start-3 md:col-span-8 mt-12"> 64 + <footer class="px-1 col-span-full mt-12"> 42 65 {{ template "layouts/footer" . }} 43 66 </footer> 44 67 {{ end }}
+65
appview/pages/templates/timeline/fragments/topStarredRepos.html
··· 1 + {{ define "timeline/fragments/topStarredRepos" }} 2 + <div> 3 + <!-- Mobile: Horizontal carousel --> 4 + <div class="lg:hidden w-full"> 5 + <div class="p-4"> 6 + <h3 class="font-bold text-lg text-gray-900 dark:text-white flex items-center gap-2 mb-3"> 7 + {{ i "trending-up" "size-4" }} 8 + Trending 9 + </h3> 10 + </div> 11 + <div class="flex gap-3 overflow-x-auto pb-4 px-4 scrollbar-hide"> 12 + {{ range $index, $repo := .Repos }} 13 + <div class="flex-none w-72 relative"> 14 + <!-- Small background number for mobile --> 15 + <div class="absolute left-2 top-1 text-6xl font-black text-gray-200 dark:text-gray-700 leading-none select-none z-0"> 16 + {{ add $index 1 }} 17 + </div> 18 + <!-- Card above the number --> 19 + <div class="relative z-10 ml-8 min-w-40 border border-gray-200 dark:border-gray-700 rounded-sm"> 20 + {{ template "user/fragments/repoCard" (list $ $repo true) }} 21 + </div> 22 + </div> 23 + {{ else }} 24 + <div class="py-8 px-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-sm"> 25 + <div class="text-sm text-gray-500 dark:text-gray-400 text-center"> 26 + No trending repositories this week 27 + </div> 28 + </div> 29 + {{ end }} 30 + </div> 31 + </div> 32 + 33 + <!-- Desktop: Vertical stack --> 34 + <div class="hidden lg:block"> 35 + <div class="p-6"> 36 + <h3 class="font-bold text-xl text-gray-900 dark:text-white flex items-center gap-2"> 37 + {{ i "trending-up" "size-5" }} 38 + Trending 39 + </h3> 40 + </div> 41 + 42 + <div class="flex flex-col gap-4"> 43 + {{ range $index, $repo := .Repos }} 44 + <div class="relative"> 45 + <!-- Large background number --> 46 + <div class="absolute left-4 top-2 text-[120px] font-black text-gray-200 dark:text-gray-700 leading-none select-none z-0"> 47 + {{ add $index 1 }} 48 + </div> 49 + 50 + <!-- Card above the number --> 51 + <div class="relative z-10 ml-16 min-w-80 lg:max-w-full border border-gray-200 dark:border-gray-700 rounded-sm"> 52 + {{ template "user/fragments/repoCard" (list $ $repo true) }} 53 + </div> 54 + </div> 55 + {{ else }} 56 + <div class="py-8 px-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-sm"> 57 + <div class="text-sm text-gray-500 dark:text-gray-400 text-center"> 58 + No trending repositories this week 59 + </div> 60 + </div> 61 + {{ end }} 62 + </div> 63 + </div> 64 + </div> 65 + {{ end }}
+23 -4
appview/pages/templates/timeline.html appview/pages/templates/timeline/timeline.html
··· 4 4 <meta property="og:title" content="timeline · tangled" /> 5 5 <meta property="og:type" content="object" /> 6 6 <meta property="og:url" content="https://tangled.sh" /> 7 - <meta property="og:description" content="see what's tangling" /> 7 + <meta property="og:description" content="tightly-knit social coding" /> 8 8 {{ end }} 9 9 10 - {{ define "topbar" }} 11 - {{ template "layouts/topbar" $ }} 12 - {{ end }} 13 10 14 11 {{ define "content" }} 15 12 {{ with .LoggedInUser }} ··· 20 17 {{ end }} 21 18 {{ end }} 22 19 20 + {{ define "contentRight" }} 21 + <div 22 + hx-get="/timeline/trending" 23 + hx-trigger="load" 24 + hx-indicator="#starred-loading" 25 + > 26 + <!-- Loading spinner --> 27 + <div id="starred-loading" class="htmx-indicator"> 28 + <div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-sm p-4"> 29 + <h3 class="font-bold text-lg mb-4 text-gray-900 dark:text-white flex items-center gap-2"> 30 + {{ i "trending-up" "size-5" }} 31 + Trending This Week 32 + </h3> 33 + <div class="flex items-center justify-center py-8"> 34 + <div class="animate-spin rounded-full h-6 w-6 border-b-2 border-gray-900 dark:border-white"></div> 35 + <span class="ml-2 text-sm text-gray-500 dark:text-gray-400">Loading...</span> 36 + </div> 37 + </div> 38 + </div> 39 + </div> 40 + {{ end }} 41 + 23 42 {{ define "hero" }} 24 43 <div class="flex flex-col text-black dark:text-white p-6 gap-6 max-w-xl"> 25 44 <div class="font-bold text-4xl">tightly-knit<br>social coding.</div>
+1
appview/state/router.go
··· 109 109 r.Handle("/static/*", s.pages.Static()) 110 110 111 111 r.Get("/", s.Timeline) 112 + r.Get("/timeline/trending", s.TopStarredReposLastWeek) 112 113 113 114 r.Route("/repo", func(r chi.Router) { 114 115 r.Route("/new", func(r chi.Router) {
+13
appview/state/state.go
··· 199 199 }) 200 200 } 201 201 202 + func (s *State) TopStarredReposLastWeek(w http.ResponseWriter, r *http.Request) { 203 + repos, err := db.GetTopStarredReposLastWeek(s.db) 204 + if err != nil { 205 + log.Println(err) 206 + s.pages.Notice(w, "topstarredrepos", "Unable to load.") 207 + return 208 + } 209 + 210 + s.pages.TopStarredReposLastWeek(w, pages.TopStarredReposLastWeekParams{ 211 + Repos: repos, 212 + }) 213 + } 214 + 202 215 func (s *State) Keys(w http.ResponseWriter, r *http.Request) { 203 216 user := chi.URLParam(r, "user") 204 217 user = strings.TrimPrefix(user, "@")