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

merged
opened by anirudh.fi targeting master from push-zxovstvplnok
Changed files
+222 -19
appview
db
pages
templates
layouts
timeline
user
fragments
state
+4
appview/db/db.go
···
id integer primary key autoincrement,
name text unique
);
`)
if err != nil {
return nil, err
···
id integer primary key autoincrement,
name text unique
);
+
+
-- indexes for better star query performance
+
create index if not exists idx_stars_created on stars(created);
+
create index if not exists idx_stars_repo_at_created on stars(repo_at, created);
`)
if err != nil {
return nil, err
+72 -3
appview/db/star.go
···
// Get a star record
func GetStar(e Execer, starredByDid string, repoAt syntax.ATURI) (*Star, error) {
query := `
-
select starred_by_did, repo_at, created, rkey
from stars
where starred_by_did = ? and repo_at = ?`
row := e.QueryRow(query, starredByDid, repoAt)
···
}
repoQuery := fmt.Sprintf(
-
`select starred_by_did, repo_at, created, rkey
from stars
%s
order by created desc
···
var stars []Star
rows, err := e.Query(`
-
select
s.starred_by_did,
s.repo_at,
s.rkey,
···
return stars, nil
}
···
// Get a star record
func GetStar(e Execer, starredByDid string, repoAt syntax.ATURI) (*Star, error) {
query := `
+
select starred_by_did, repo_at, created, rkey
from stars
where starred_by_did = ? and repo_at = ?`
row := e.QueryRow(query, starredByDid, repoAt)
···
}
repoQuery := fmt.Sprintf(
+
`select starred_by_did, repo_at, created, rkey
from stars
%s
order by created desc
···
var stars []Star
rows, err := e.Query(`
+
select
s.starred_by_did,
s.repo_at,
s.rkey,
···
return stars, nil
}
+
+
// GetTopStarredReposLastWeek returns the top 8 most starred repositories from the last week
+
func GetTopStarredReposLastWeek(e Execer) ([]Repo, error) {
+
// first, get the top repo URIs by star count from the last week
+
query := `
+
with recent_starred_repos as (
+
select distinct repo_at
+
from stars
+
where created >= datetime('now', '-7 days')
+
),
+
repo_star_counts as (
+
select
+
s.repo_at,
+
count(*) as star_count
+
from stars s
+
join recent_starred_repos rsr on s.repo_at = rsr.repo_at
+
group by s.repo_at
+
)
+
select rsc.repo_at
+
from repo_star_counts rsc
+
order by rsc.star_count desc
+
limit 8
+
`
+
+
rows, err := e.Query(query)
+
if err != nil {
+
return nil, err
+
}
+
defer rows.Close()
+
+
var repoUris []string
+
for rows.Next() {
+
var repoUri string
+
err := rows.Scan(&repoUri)
+
if err != nil {
+
return nil, err
+
}
+
repoUris = append(repoUris, repoUri)
+
}
+
+
if err := rows.Err(); err != nil {
+
return nil, err
+
}
+
+
if len(repoUris) == 0 {
+
return []Repo{}, nil
+
}
+
+
// get full repo data
+
repos, err := GetRepos(e, 0, FilterIn("at_uri", repoUris))
+
if err != nil {
+
return nil, err
+
}
+
+
// sort repos by the original trending order
+
repoMap := make(map[string]Repo)
+
for _, repo := range repos {
+
repoMap[repo.RepoAt().String()] = repo
+
}
+
+
orderedRepos := make([]Repo, 0, len(repoUris))
+
for _, uri := range repoUris {
+
if repo, exists := repoMap[uri]; exists {
+
orderedRepos = append(orderedRepos, repo)
+
}
+
}
+
+
return orderedRepos, nil
+
}
+10 -1
appview/pages/pages.go
···
}
func (p *Pages) Timeline(w io.Writer, params TimelineParams) error {
-
return p.execute("timeline", w, params)
}
type SettingsParams struct {
···
}
func (p *Pages) Timeline(w io.Writer, params TimelineParams) error {
+
return p.execute("timeline/timeline", w, params)
+
}
+
+
type TopStarredReposLastWeekParams struct {
+
LoggedInUser *oauth.User
+
Repos []db.Repo
+
}
+
+
func (p *Pages) TopStarredReposLastWeek(w io.Writer, params TopStarredReposLastWeekParams) error {
+
return p.executePlain("timeline/fragments/topStarredRepos", w, params)
}
type SettingsParams struct {
+32 -9
appview/pages/templates/layouts/base.html
···
</head>
<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">
{{ block "topbarLayout" . }}
-
<header class="px-1 col-span-1 md:col-start-3 md:col-span-8" style="z-index: 20;">
{{ template "layouts/topbar" . }}
</header>
{{ end }}
{{ block "mainLayout" . }}
<div class="px-1 col-span-1 md:col-start-3 md:col-span-8 flex flex-col gap-4">
{{ block "contentLayout" . }}
-
<main class="col-span-1 md:col-span-8">
-
{{ block "content" . }}{{ end }}
-
</main>
{{ end }}
-
{{ block "contentAfterLayout" . }}
-
<main class="col-span-1 md:col-span-8">
-
{{ block "contentAfter" . }}{{ end }}
-
</main>
{{ end }}
</div>
{{ end }}
{{ block "footerLayout" . }}
-
<footer class="px-1 col-span-1 md:col-start-3 md:col-span-8 mt-12">
{{ template "layouts/footer" . }}
</footer>
{{ end }}
···
</head>
<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">
{{ block "topbarLayout" . }}
+
<header class="px-1 col-span-full" style="z-index: 20;">
{{ template "layouts/topbar" . }}
</header>
{{ end }}
{{ block "mainLayout" . }}
+
<!-- Mobile trending carousel at top - full width -->
+
<div class="px-1 col-span-full lg:hidden">
+
{{ block "contentRight" . }} {{ end }}
+
</div>
+
<div class="px-1 col-span-1 md:col-start-3 md:col-span-8 flex flex-col gap-4">
+
{{ block "contentLayout" . }}
+
<div class="grid grid-cols-1 lg:grid-cols-12 gap-4">
+
<div class="lg:col-span-2">
+
{{ block "contentLeft" . }} {{ end }}
+
</div>
+
<main class="lg:col-span-8">
+
{{ block "content" . }}{{ end }}
+
</main>
+
<!-- Desktop trending sidebar -->
+
<div class="hidden lg:block lg:col-span-2">
+
{{ block "contentRight" . }} {{ end }}
+
</div>
+
</div>
{{ end }}
+
{{ block "contentAfterLayout" . }}
+
<div class="grid grid-cols-1 lg:grid-cols-12 gap-4">
+
<div class="lg:col-span-2">
+
{{ block "contentAfterLeft" . }} {{ end }}
+
</div>
+
<main class="lg:col-span-8">
+
{{ block "contentAfter" . }}{{ end }}
+
</main>
+
<div class="lg:col-span-2">
+
{{ block "contentAfterRight" . }} {{ end }}
+
</div>
+
</div>
{{ end }}
</div>
{{ end }}
{{ block "footerLayout" . }}
+
<footer class="px-1 col-span-full mt-12">
{{ template "layouts/footer" . }}
</footer>
{{ end }}
+65
appview/pages/templates/timeline/fragments/topStarredRepos.html
···
···
+
{{ define "timeline/fragments/topStarredRepos" }}
+
<div>
+
<!-- Mobile: Horizontal carousel -->
+
<div class="lg:hidden w-full">
+
<div class="p-4">
+
<h3 class="font-bold text-lg text-gray-900 dark:text-white flex items-center gap-2 mb-3">
+
{{ i "trending-up" "size-4 flex-shrink-0" }}
+
Trending
+
</h3>
+
</div>
+
<div class="flex gap-3 overflow-x-auto pb-4 px-4 scrollbar-hide">
+
{{ range $index, $repo := .Repos }}
+
<div class="flex-none w-72 relative">
+
<!-- Small background number for mobile -->
+
<div class="absolute left-2 top-1 text-6xl font-black text-gray-200 dark:text-gray-700 leading-none select-none z-0">
+
{{ add $index 1 }}
+
</div>
+
<!-- Card above the number -->
+
<div class="relative z-10 ml-8 min-w-40 border border-gray-200 dark:border-gray-700 rounded-sm">
+
{{ template "user/fragments/repoCard" (list $ $repo true) }}
+
</div>
+
</div>
+
{{ else }}
+
<div class="py-8 px-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-sm">
+
<div class="text-sm text-gray-500 dark:text-gray-400 text-center">
+
No trending repositories this week
+
</div>
+
</div>
+
{{ end }}
+
</div>
+
</div>
+
+
<!-- Desktop: Vertical stack -->
+
<div class="hidden lg:block">
+
<div class="p-6">
+
<h3 class="font-bold text-xl text-gray-900 dark:text-white flex items-center gap-2">
+
{{ i "trending-up" "size-5 flex-shrink-0" }}
+
Trending
+
</h3>
+
</div>
+
+
<div class="flex flex-col gap-4">
+
{{ range $index, $repo := .Repos }}
+
<div class="relative">
+
<!-- Large background number -->
+
<div class="absolute left-4 top-1 text-[100px] font-black text-gray-200 dark:text-gray-700 leading-none select-none z-0">
+
{{ add $index 1 }}
+
</div>
+
+
<!-- Card above the number -->
+
<div class="relative z-10 ml-16 min-w-80 lg:max-w-full border border-gray-200 dark:border-gray-700 rounded-sm">
+
{{ template "user/fragments/repoCard" (list $ $repo true) }}
+
</div>
+
</div>
+
{{ else }}
+
<div class="py-8 px-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-sm">
+
<div class="text-sm text-gray-500 dark:text-gray-400 text-center">
+
No trending repositories this week
+
</div>
+
</div>
+
{{ end }}
+
</div>
+
</div>
+
</div>
+
{{ end }}
+23 -4
appview/pages/templates/timeline.html appview/pages/templates/timeline/timeline.html
···
<meta property="og:title" content="timeline · tangled" />
<meta property="og:type" content="object" />
<meta property="og:url" content="https://tangled.sh" />
-
<meta property="og:description" content="see what's tangling" />
{{ end }}
-
{{ define "topbar" }}
-
{{ template "layouts/topbar" $ }}
-
{{ end }}
{{ define "content" }}
{{ with .LoggedInUser }}
···
{{ end }}
{{ end }}
{{ define "hero" }}
<div class="flex flex-col text-black dark:text-white p-6 gap-6 max-w-xl">
<div class="font-bold text-4xl">tightly-knit<br>social coding.</div>
···
<meta property="og:title" content="timeline · tangled" />
<meta property="og:type" content="object" />
<meta property="og:url" content="https://tangled.sh" />
+
<meta property="og:description" content="tightly-knit social coding" />
{{ end }}
{{ define "content" }}
{{ with .LoggedInUser }}
···
{{ end }}
{{ end }}
+
{{ define "contentRight" }}
+
<div
+
hx-get="/timeline/trending"
+
hx-trigger="load"
+
hx-indicator="#starred-loading"
+
>
+
<!-- Loading spinner -->
+
<div id="starred-loading" class="htmx-indicator">
+
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-sm p-4">
+
<h3 class="font-bold text-lg mb-4 text-gray-900 dark:text-white flex items-center gap-2">
+
{{ i "trending-up" "size-5 flex-shrink-0" }}
+
Trending
+
</h3>
+
<div class="flex items-center justify-center py-8">
+
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-gray-900 dark:border-white"></div>
+
<span class="ml-2 text-sm text-gray-500 dark:text-gray-400">Loading...</span>
+
</div>
+
</div>
+
</div>
+
</div>
+
{{ end }}
+
{{ define "hero" }}
<div class="flex flex-col text-black dark:text-white p-6 gap-6 max-w-xl">
<div class="font-bold text-4xl">tightly-knit<br>social coding.</div>
+2 -2
appview/pages/templates/user/fragments/repoCard.html
···
{{ $repoOwner := resolve .Did }}
{{- if $fullName -}}
-
<a href="/{{ $repoOwner }}/{{ .Name }}">{{ $repoOwner }}/{{ .Name }}</a>
{{- else -}}
-
<a href="/{{ $repoOwner }}/{{ .Name }}">{{ .Name }}</a>
{{- end -}}
</div>
{{ with .Description }}
···
{{ $repoOwner := resolve .Did }}
{{- if $fullName -}}
+
<a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate">{{ $repoOwner }}/{{ .Name }}</a>
{{- else -}}
+
<a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate">{{ .Name }}</a>
{{- end -}}
</div>
{{ with .Description }}
+1
appview/state/router.go
···
r.Handle("/static/*", s.pages.Static())
r.Get("/", s.Timeline)
r.Route("/repo", func(r chi.Router) {
r.Route("/new", func(r chi.Router) {
···
r.Handle("/static/*", s.pages.Static())
r.Get("/", s.Timeline)
+
r.Get("/timeline/trending", s.TopStarredReposLastWeek)
r.Route("/repo", func(r chi.Router) {
r.Route("/new", func(r chi.Router) {
+13
appview/state/state.go
···
})
}
func (s *State) Keys(w http.ResponseWriter, r *http.Request) {
user := chi.URLParam(r, "user")
user = strings.TrimPrefix(user, "@")
···
})
}
+
func (s *State) TopStarredReposLastWeek(w http.ResponseWriter, r *http.Request) {
+
repos, err := db.GetTopStarredReposLastWeek(s.db)
+
if err != nil {
+
log.Println(err)
+
s.pages.Notice(w, "topstarredrepos", "Unable to load.")
+
return
+
}
+
+
s.pages.TopStarredReposLastWeek(w, pages.TopStarredReposLastWeekParams{
+
Repos: repos,
+
})
+
}
+
func (s *State) Keys(w http.ResponseWriter, r *http.Request) {
user := chi.URLParam(r, "user")
user = strings.TrimPrefix(user, "@")