forked from tangled.org/core
this repo has no description

Compare changes

Choose any two refs to compare.

+1194 -1003
+4 -4
appview/db/follow.go
··· 56 56 } 57 57 58 58 type FollowStats struct { 59 - Followers int 60 - Following int 59 + Followers int64 60 + Following int64 61 61 } 62 62 63 63 func GetFollowerFollowingCount(e Execer, did string) (FollowStats, error) { 64 - followers, following := 0, 0 64 + var followers, following int64 65 65 err := e.QueryRow( 66 66 `SELECT 67 67 COUNT(CASE WHEN subject_did = ? THEN 1 END) AS followers, ··· 122 122 123 123 for rows.Next() { 124 124 var did string 125 - var followers, following int 125 + var followers, following int64 126 126 if err := rows.Scan(&did, &followers, &following); err != nil { 127 127 return nil, err 128 128 }
+14
appview/db/profile.go
··· 22 22 ByMonth []ByMonth 23 23 } 24 24 25 + func (p *ProfileTimeline) IsEmpty() bool { 26 + if p == nil { 27 + return true 28 + } 29 + 30 + for _, m := range p.ByMonth { 31 + if !m.IsEmpty() { 32 + return false 33 + } 34 + } 35 + 36 + return true 37 + } 38 + 25 39 type ByMonth struct { 26 40 RepoEvents []RepoEvent 27 41 IssueEvents IssueEvents
+4 -4
appview/db/punchcard.go
··· 29 29 Punches []Punch 30 30 } 31 31 32 - func MakePunchcard(e Execer, filters ...filter) (Punchcard, error) { 33 - punchcard := Punchcard{} 32 + func MakePunchcard(e Execer, filters ...filter) (*Punchcard, error) { 33 + punchcard := &Punchcard{} 34 34 now := time.Now() 35 35 startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC) 36 36 endOfYear := time.Date(now.Year(), 12, 31, 0, 0, 0, 0, time.UTC) ··· 63 63 64 64 rows, err := e.Query(query, args...) 65 65 if err != nil { 66 - return punchcard, err 66 + return nil, err 67 67 } 68 68 defer rows.Close() 69 69 ··· 72 72 var date string 73 73 var count sql.NullInt64 74 74 if err := rows.Scan(&date, &count); err != nil { 75 - return punchcard, err 75 + return nil, err 76 76 } 77 77 78 78 punch.Date, err = time.Parse(time.DateOnly, date)
+27 -66
appview/db/repos.go
··· 2 2 3 3 import ( 4 4 "database/sql" 5 + "errors" 5 6 "fmt" 6 7 "log" 7 8 "slices" ··· 36 37 func (r Repo) DidSlashRepo() string { 37 38 p, _ := securejoin.SecureJoin(r.Did, r.Name) 38 39 return p 39 - } 40 - 41 - func GetAllRepos(e Execer, limit int) ([]Repo, error) { 42 - var repos []Repo 43 - 44 - rows, err := e.Query( 45 - `select did, name, knot, rkey, description, created, source 46 - from repos 47 - order by created desc 48 - limit ? 49 - `, 50 - limit, 51 - ) 52 - if err != nil { 53 - return nil, err 54 - } 55 - defer rows.Close() 56 - 57 - for rows.Next() { 58 - var repo Repo 59 - err := scanRepo( 60 - rows, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &repo.Description, &repo.Created, &repo.Source, 61 - ) 62 - if err != nil { 63 - return nil, err 64 - } 65 - repos = append(repos, repo) 66 - } 67 - 68 - if err := rows.Err(); err != nil { 69 - return nil, err 70 - } 71 - 72 - return repos, nil 73 40 } 74 41 75 42 func GetRepos(e Execer, limit int, filters ...filter) ([]Repo, error) { ··· 310 277 311 278 slices.SortFunc(repos, func(a, b Repo) int { 312 279 if a.Created.After(b.Created) { 313 - return 1 280 + return -1 314 281 } 315 - return -1 282 + return 1 316 283 }) 317 284 318 285 return repos, nil 319 286 } 320 287 288 + func CountRepos(e Execer, filters ...filter) (int64, error) { 289 + var conditions []string 290 + var args []any 291 + for _, filter := range filters { 292 + conditions = append(conditions, filter.Condition()) 293 + args = append(args, filter.Arg()...) 294 + } 295 + 296 + whereClause := "" 297 + if conditions != nil { 298 + whereClause = " where " + strings.Join(conditions, " and ") 299 + } 300 + 301 + repoQuery := fmt.Sprintf(`select count(1) from repos %s`, whereClause) 302 + var count int64 303 + err := e.QueryRow(repoQuery, args...).Scan(&count) 304 + 305 + if !errors.Is(err, sql.ErrNoRows) && err != nil { 306 + return 0, err 307 + } 308 + 309 + return count, nil 310 + } 311 + 321 312 func GetAllReposByDid(e Execer, did string) ([]Repo, error) { 322 313 var repos []Repo 323 314 ··· 570 561 IssueCount IssueCount 571 562 PullCount PullCount 572 563 } 573 - 574 - func scanRepo(rows *sql.Rows, did, name, knot, rkey, description *string, created *time.Time, source *string) error { 575 - var createdAt string 576 - var nullableDescription sql.NullString 577 - var nullableSource sql.NullString 578 - if err := rows.Scan(did, name, knot, rkey, &nullableDescription, &createdAt, &nullableSource); err != nil { 579 - return err 580 - } 581 - 582 - if nullableDescription.Valid { 583 - *description = nullableDescription.String 584 - } else { 585 - *description = "" 586 - } 587 - 588 - createdAtTime, err := time.Parse(time.RFC3339, createdAt) 589 - if err != nil { 590 - *created = time.Now() 591 - } else { 592 - *created = createdAtTime 593 - } 594 - 595 - if nullableSource.Valid { 596 - *source = nullableSource.String 597 - } else { 598 - *source = "" 599 - } 600 - 601 - return nil 602 - }
+26
appview/db/star.go
··· 1 1 package db 2 2 3 3 import ( 4 + "database/sql" 5 + "errors" 4 6 "fmt" 5 7 "log" 6 8 "strings" ··· 181 183 } 182 184 183 185 return stars, nil 186 + } 187 + 188 + func CountStars(e Execer, filters ...filter) (int64, error) { 189 + var conditions []string 190 + var args []any 191 + for _, filter := range filters { 192 + conditions = append(conditions, filter.Condition()) 193 + args = append(args, filter.Arg()...) 194 + } 195 + 196 + whereClause := "" 197 + if conditions != nil { 198 + whereClause = " where " + strings.Join(conditions, " and ") 199 + } 200 + 201 + repoQuery := fmt.Sprintf(`select count(1) from stars %s`, whereClause) 202 + var count int64 203 + err := e.QueryRow(repoQuery, args...).Scan(&count) 204 + 205 + if !errors.Is(err, sql.ErrNoRows) && err != nil { 206 + return 0, err 207 + } 208 + 209 + return count, nil 184 210 } 185 211 186 212 func GetAllStars(e Execer, limit int) ([]Star, error) {
+24
appview/db/strings.go
··· 206 206 return all, nil 207 207 } 208 208 209 + func CountStrings(e Execer, filters ...filter) (int64, error) { 210 + var conditions []string 211 + var args []any 212 + for _, filter := range filters { 213 + conditions = append(conditions, filter.Condition()) 214 + args = append(args, filter.Arg()...) 215 + } 216 + 217 + whereClause := "" 218 + if conditions != nil { 219 + whereClause = " where " + strings.Join(conditions, " and ") 220 + } 221 + 222 + repoQuery := fmt.Sprintf(`select count(1) from strings %s`, whereClause) 223 + var count int64 224 + err := e.QueryRow(repoQuery, args...).Scan(&count) 225 + 226 + if !errors.Is(err, sql.ErrNoRows) && err != nil { 227 + return 0, err 228 + } 229 + 230 + return count, nil 231 + } 232 + 209 233 func DeleteString(e Execer, filters ...filter) error { 210 234 var conditions []string 211 235 var args []any
+35
appview/pages/cache.go
··· 1 + package pages 2 + 3 + import ( 4 + "sync" 5 + ) 6 + 7 + type TmplCache[K comparable, V any] struct { 8 + data map[K]V 9 + mutex sync.RWMutex 10 + } 11 + 12 + func NewTmplCache[K comparable, V any]() *TmplCache[K, V] { 13 + return &TmplCache[K, V]{ 14 + data: make(map[K]V), 15 + } 16 + } 17 + 18 + func (c *TmplCache[K, V]) Get(key K) (V, bool) { 19 + c.mutex.RLock() 20 + defer c.mutex.RUnlock() 21 + val, exists := c.data[key] 22 + return val, exists 23 + } 24 + 25 + func (c *TmplCache[K, V]) Set(key K, value V) { 26 + c.mutex.Lock() 27 + defer c.mutex.Unlock() 28 + c.data[key] = value 29 + } 30 + 31 + func (c *TmplCache[K, V]) Size() int { 32 + c.mutex.RLock() 33 + defer c.mutex.RUnlock() 34 + return len(c.data) 35 + }
+202 -164
appview/pages/pages.go
··· 9 9 "html/template" 10 10 "io" 11 11 "io/fs" 12 - "log" 12 + "log/slog" 13 13 "net/http" 14 14 "os" 15 15 "path/filepath" ··· 42 42 var Files embed.FS 43 43 44 44 type Pages struct { 45 - mu sync.RWMutex 46 - t map[string]*template.Template 45 + mu sync.RWMutex 46 + cache *TmplCache[string, *template.Template] 47 47 48 48 avatar config.AvatarConfig 49 49 resolver *idresolver.Resolver 50 50 dev bool 51 - embedFS embed.FS 51 + embedFS fs.FS 52 52 templateDir string // Path to templates on disk for dev mode 53 53 rctx *markup.RenderContext 54 + logger *slog.Logger 54 55 } 55 56 56 57 func NewPages(config *config.Config, res *idresolver.Resolver) *Pages { ··· 64 65 65 66 p := &Pages{ 66 67 mu: sync.RWMutex{}, 67 - t: make(map[string]*template.Template), 68 + cache: NewTmplCache[string, *template.Template](), 68 69 dev: config.Core.Dev, 69 70 avatar: config.Avatar, 70 - embedFS: Files, 71 71 rctx: rctx, 72 72 resolver: res, 73 73 templateDir: "appview/pages", 74 + logger: slog.Default().With("component", "pages"), 74 75 } 75 76 76 - // Initial load of all templates 77 - p.loadAllTemplates() 77 + if p.dev { 78 + p.embedFS = os.DirFS(p.templateDir) 79 + } else { 80 + p.embedFS = Files 81 + } 78 82 79 83 return p 80 84 } 81 85 82 - func (p *Pages) loadAllTemplates() { 83 - templates := make(map[string]*template.Template) 84 - var fragmentPaths []string 86 + func (p *Pages) pathToName(s string) string { 87 + return strings.TrimSuffix(strings.TrimPrefix(s, "templates/"), ".html") 88 + } 85 89 86 - // Use embedded FS for initial loading 87 - // First, collect all fragment paths 90 + // reverse of pathToName 91 + func (p *Pages) nameToPath(s string) string { 92 + return "templates/" + s + ".html" 93 + } 94 + 95 + func (p *Pages) fragmentPaths() ([]string, error) { 96 + var fragmentPaths []string 88 97 err := fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error { 89 98 if err != nil { 90 99 return err ··· 98 107 if !strings.Contains(path, "fragments/") { 99 108 return nil 100 109 } 101 - name := strings.TrimPrefix(path, "templates/") 102 - name = strings.TrimSuffix(name, ".html") 103 - tmpl, err := template.New(name). 104 - Funcs(p.funcMap()). 105 - ParseFS(p.embedFS, path) 106 - if err != nil { 107 - log.Fatalf("setting up fragment: %v", err) 108 - } 109 - templates[name] = tmpl 110 110 fragmentPaths = append(fragmentPaths, path) 111 - log.Printf("loaded fragment: %s", name) 112 111 return nil 113 112 }) 114 113 if err != nil { 115 - log.Fatalf("walking template dir for fragments: %v", err) 114 + return nil, err 116 115 } 117 116 118 - // Then walk through and setup the rest of the templates 119 - err = fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error { 117 + return fragmentPaths, nil 118 + } 119 + 120 + func (p *Pages) fragments() (*template.Template, error) { 121 + fragmentPaths, err := p.fragmentPaths() 122 + if err != nil { 123 + return nil, err 124 + } 125 + 126 + funcs := p.funcMap() 127 + 128 + // parse all fragments together 129 + allFragments := template.New("").Funcs(funcs) 130 + for _, f := range fragmentPaths { 131 + name := p.pathToName(f) 132 + 133 + pf, err := template.New(name). 134 + Funcs(funcs). 135 + ParseFS(p.embedFS, f) 120 136 if err != nil { 121 - return err 122 - } 123 - if d.IsDir() { 124 - return nil 125 - } 126 - if !strings.HasSuffix(path, "html") { 127 - return nil 137 + return nil, err 128 138 } 129 - // Skip fragments as they've already been loaded 130 - if strings.Contains(path, "fragments/") { 131 - return nil 132 - } 133 - // Skip layouts 134 - if strings.Contains(path, "layouts/") { 135 - return nil 136 - } 137 - name := strings.TrimPrefix(path, "templates/") 138 - name = strings.TrimSuffix(name, ".html") 139 - // Add the page template on top of the base 140 - allPaths := []string{} 141 - allPaths = append(allPaths, "templates/layouts/*.html") 142 - allPaths = append(allPaths, fragmentPaths...) 143 - allPaths = append(allPaths, path) 144 - tmpl, err := template.New(name). 145 - Funcs(p.funcMap()). 146 - ParseFS(p.embedFS, allPaths...) 139 + 140 + allFragments, err = allFragments.AddParseTree(name, pf.Tree) 147 141 if err != nil { 148 - return fmt.Errorf("setting up template: %w", err) 142 + return nil, err 149 143 } 150 - templates[name] = tmpl 151 - log.Printf("loaded template: %s", name) 152 - return nil 153 - }) 154 - if err != nil { 155 - log.Fatalf("walking template dir: %v", err) 156 144 } 157 145 158 - log.Printf("total templates loaded: %d", len(templates)) 159 - p.mu.Lock() 160 - defer p.mu.Unlock() 161 - p.t = templates 146 + return allFragments, nil 162 147 } 163 148 164 - // loadTemplateFromDisk loads a template from the filesystem in dev mode 165 - func (p *Pages) loadTemplateFromDisk(name string) error { 166 - if !p.dev { 167 - return nil 149 + // parse without memoization 150 + func (p *Pages) rawParse(stack ...string) (*template.Template, error) { 151 + paths, err := p.fragmentPaths() 152 + if err != nil { 153 + return nil, err 154 + } 155 + for _, s := range stack { 156 + paths = append(paths, p.nameToPath(s)) 168 157 } 169 158 170 - log.Printf("reloading template from disk: %s", name) 171 - 172 - // Find all fragments first 173 - var fragmentPaths []string 174 - err := filepath.WalkDir(filepath.Join(p.templateDir, "templates"), func(path string, d fs.DirEntry, err error) error { 175 - if err != nil { 176 - return err 177 - } 178 - if d.IsDir() { 179 - return nil 180 - } 181 - if !strings.HasSuffix(path, ".html") { 182 - return nil 183 - } 184 - if !strings.Contains(path, "fragments/") { 185 - return nil 186 - } 187 - fragmentPaths = append(fragmentPaths, path) 188 - return nil 189 - }) 159 + funcs := p.funcMap() 160 + top := stack[len(stack)-1] 161 + parsed, err := template.New(top). 162 + Funcs(funcs). 163 + ParseFS(p.embedFS, paths...) 190 164 if err != nil { 191 - return fmt.Errorf("walking disk template dir for fragments: %w", err) 165 + return nil, err 192 166 } 193 167 194 - // Find the template path on disk 195 - templatePath := filepath.Join(p.templateDir, "templates", name+".html") 196 - if _, err := os.Stat(templatePath); os.IsNotExist(err) { 197 - return fmt.Errorf("template not found on disk: %s", name) 198 - } 168 + return parsed, nil 169 + } 199 170 200 - // Create a new template 201 - tmpl := template.New(name).Funcs(p.funcMap()) 171 + func (p *Pages) parse(stack ...string) (*template.Template, error) { 172 + key := strings.Join(stack, "|") 202 173 203 - // Parse layouts 204 - layoutGlob := filepath.Join(p.templateDir, "templates", "layouts", "*.html") 205 - layouts, err := filepath.Glob(layoutGlob) 206 - if err != nil { 207 - return fmt.Errorf("finding layout templates: %w", err) 174 + // never cache in dev mode 175 + if cached, exists := p.cache.Get(key); !p.dev && exists { 176 + return cached, nil 208 177 } 209 178 210 - // Create paths for parsing 211 - allFiles := append(layouts, fragmentPaths...) 212 - allFiles = append(allFiles, templatePath) 213 - 214 - // Parse all templates 215 - tmpl, err = tmpl.ParseFiles(allFiles...) 179 + result, err := p.rawParse(stack...) 216 180 if err != nil { 217 - return fmt.Errorf("parsing template files: %w", err) 181 + return nil, err 218 182 } 219 183 220 - // Update the template in the map 221 - p.mu.Lock() 222 - defer p.mu.Unlock() 223 - p.t[name] = tmpl 224 - log.Printf("template reloaded from disk: %s", name) 225 - return nil 184 + p.cache.Set(key, result) 185 + return result, nil 226 186 } 227 187 228 - func (p *Pages) executeOrReload(templateName string, w io.Writer, base string, params any) error { 229 - // In dev mode, reload the template from disk before executing 230 - if p.dev { 231 - if err := p.loadTemplateFromDisk(templateName); err != nil { 232 - log.Printf("warning: failed to reload template %s from disk: %v", templateName, err) 233 - // Continue with the existing template 234 - } 188 + func (p *Pages) parseBase(top string) (*template.Template, error) { 189 + stack := []string{ 190 + "layouts/base", 191 + top, 235 192 } 193 + return p.parse(stack...) 194 + } 236 195 237 - p.mu.RLock() 238 - defer p.mu.RUnlock() 239 - tmpl, exists := p.t[templateName] 240 - if !exists { 241 - return fmt.Errorf("template not found: %s", templateName) 196 + func (p *Pages) parseRepoBase(top string) (*template.Template, error) { 197 + stack := []string{ 198 + "layouts/base", 199 + "layouts/repobase", 200 + top, 242 201 } 202 + return p.parse(stack...) 203 + } 243 204 244 - if base == "" { 245 - return tmpl.Execute(w, params) 246 - } else { 247 - return tmpl.ExecuteTemplate(w, base, params) 205 + func (p *Pages) parseProfileBase(top string) (*template.Template, error) { 206 + stack := []string{ 207 + "layouts/base", 208 + "layouts/profilebase", 209 + top, 248 210 } 211 + return p.parse(stack...) 212 + } 213 + 214 + func (p *Pages) executePlain(name string, w io.Writer, params any) error { 215 + tpl, err := p.parse(name) 216 + if err != nil { 217 + return err 218 + } 219 + 220 + return tpl.Execute(w, params) 249 221 } 250 222 251 223 func (p *Pages) execute(name string, w io.Writer, params any) error { 252 - return p.executeOrReload(name, w, "layouts/base", params) 224 + tpl, err := p.parseBase(name) 225 + if err != nil { 226 + return err 227 + } 228 + 229 + return tpl.ExecuteTemplate(w, "layouts/base", params) 253 230 } 254 231 255 - func (p *Pages) executePlain(name string, w io.Writer, params any) error { 256 - return p.executeOrReload(name, w, "", params) 232 + func (p *Pages) executeRepo(name string, w io.Writer, params any) error { 233 + tpl, err := p.parseRepoBase(name) 234 + if err != nil { 235 + return err 236 + } 237 + 238 + return tpl.ExecuteTemplate(w, "layouts/base", params) 257 239 } 258 240 259 - func (p *Pages) executeRepo(name string, w io.Writer, params any) error { 260 - return p.executeOrReload(name, w, "layouts/repobase", params) 241 + func (p *Pages) executeProfile(name string, w io.Writer, params any) error { 242 + tpl, err := p.parseProfileBase(name) 243 + if err != nil { 244 + return err 245 + } 246 + 247 + return tpl.ExecuteTemplate(w, "layouts/base", params) 261 248 } 262 249 263 250 func (p *Pages) Favicon(w io.Writer) error { ··· 422 409 return p.execute("repo/fork", w, params) 423 410 } 424 411 425 - type ProfileHomePageParams struct { 412 + type ProfileCard struct { 413 + UserDid string 414 + UserHandle string 415 + FollowStatus db.FollowStatus 416 + Punchcard *db.Punchcard 417 + Profile *db.Profile 418 + Stats ProfileStats 419 + Active string 420 + } 421 + 422 + type ProfileStats struct { 423 + RepoCount int64 424 + StarredCount int64 425 + StringCount int64 426 + FollowersCount int64 427 + FollowingCount int64 428 + } 429 + 430 + func (p *ProfileCard) GetTabs() [][]any { 431 + tabs := [][]any{ 432 + {"overview", "overview", "square-chart-gantt", nil}, 433 + {"repos", "repos", "book-marked", p.Stats.RepoCount}, 434 + {"starred", "starred", "star", p.Stats.StarredCount}, 435 + {"strings", "strings", "line-squiggle", p.Stats.StringCount}, 436 + } 437 + 438 + return tabs 439 + } 440 + 441 + type ProfileOverviewParams struct { 426 442 LoggedInUser *oauth.User 427 443 Repos []db.Repo 428 444 CollaboratingRepos []db.Repo 429 445 ProfileTimeline *db.ProfileTimeline 430 - Card ProfileCard 431 - Punchcard db.Punchcard 446 + Card *ProfileCard 447 + Active string 432 448 } 433 449 434 - type ProfileCard struct { 435 - UserDid string 436 - UserHandle string 437 - FollowStatus db.FollowStatus 438 - FollowersCount int 439 - FollowingCount int 450 + func (p *Pages) ProfileOverview(w io.Writer, params ProfileOverviewParams) error { 451 + params.Active = "overview" 452 + return p.executeProfile("user/overview", w, params) 453 + } 440 454 441 - Profile *db.Profile 455 + type ProfileReposParams struct { 456 + LoggedInUser *oauth.User 457 + Repos []db.Repo 458 + Card *ProfileCard 459 + Active string 442 460 } 443 461 444 - func (p *Pages) ProfileHomePage(w io.Writer, params ProfileHomePageParams) error { 445 - return p.execute("user/profile", w, params) 462 + func (p *Pages) ProfileRepos(w io.Writer, params ProfileReposParams) error { 463 + params.Active = "repos" 464 + return p.executeProfile("user/repos", w, params) 446 465 } 447 466 448 - type ReposPageParams struct { 467 + type ProfileStarredParams struct { 449 468 LoggedInUser *oauth.User 450 469 Repos []db.Repo 451 - Card ProfileCard 470 + Card *ProfileCard 471 + Active string 472 + } 473 + 474 + func (p *Pages) ProfileStarred(w io.Writer, params ProfileStarredParams) error { 475 + params.Active = "starred" 476 + return p.executeProfile("user/starred", w, params) 477 + } 478 + 479 + type ProfileStringsParams struct { 480 + LoggedInUser *oauth.User 481 + Strings []db.String 482 + Card *ProfileCard 483 + Active string 452 484 } 453 485 454 - func (p *Pages) ReposPage(w io.Writer, params ReposPageParams) error { 455 - return p.execute("user/repos", w, params) 486 + func (p *Pages) ProfileStrings(w io.Writer, params ProfileStringsParams) error { 487 + params.Active = "strings" 488 + return p.executeProfile("user/strings", w, params) 456 489 } 457 490 458 491 type FollowCard struct { 459 492 UserDid string 460 493 FollowStatus db.FollowStatus 461 - FollowersCount int 462 - FollowingCount int 494 + FollowersCount int64 495 + FollowingCount int64 463 496 Profile *db.Profile 464 497 } 465 498 466 - type FollowersPageParams struct { 499 + type ProfileFollowersParams struct { 467 500 LoggedInUser *oauth.User 468 501 Followers []FollowCard 469 - Card ProfileCard 502 + Card *ProfileCard 503 + Active string 470 504 } 471 505 472 - func (p *Pages) FollowersPage(w io.Writer, params FollowersPageParams) error { 473 - return p.execute("user/followers", w, params) 506 + func (p *Pages) ProfileFollowers(w io.Writer, params ProfileFollowersParams) error { 507 + params.Active = "overview" 508 + return p.executeProfile("user/followers", w, params) 474 509 } 475 510 476 - type FollowingPageParams struct { 511 + type ProfileFollowingParams struct { 477 512 LoggedInUser *oauth.User 478 513 Following []FollowCard 479 - Card ProfileCard 514 + Card *ProfileCard 515 + Active string 480 516 } 481 517 482 - func (p *Pages) FollowingPage(w io.Writer, params FollowingPageParams) error { 483 - return p.execute("user/following", w, params) 518 + func (p *Pages) ProfileFollowing(w io.Writer, params ProfileFollowingParams) error { 519 + params.Active = "overview" 520 + return p.executeProfile("user/following", w, params) 484 521 } 485 522 486 523 type FollowFragmentParams struct { ··· 649 686 650 687 func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error { 651 688 params.Active = "overview" 652 - return p.execute("repo/tree", w, params) 689 + return p.executeRepo("repo/tree", w, params) 653 690 } 654 691 655 692 type RepoBranchesParams struct { ··· 863 900 } else { 864 901 params.State = "closed" 865 902 } 866 - return p.execute("repo/issues/issue", w, params) 903 + return p.executeRepo("repo/issues/issue", w, params) 867 904 } 868 905 869 906 type RepoNewIssueParams struct { ··· 1269 1306 1270 1307 sub, err := fs.Sub(Files, "static") 1271 1308 if err != nil { 1272 - log.Fatalf("no static dir found? that's crazy: %v", err) 1309 + p.logger.Error("no static dir found? that's crazy", "err", err) 1310 + panic(err) 1273 1311 } 1274 1312 // Custom handler to apply Cache-Control headers for font files 1275 1313 return Cache(http.StripPrefix("/static/", http.FileServer(http.FS(sub)))) ··· 1292 1330 func CssContentHash() string { 1293 1331 cssFile, err := Files.Open("static/tw.css") 1294 1332 if err != nil { 1295 - log.Printf("Error opening CSS file: %v", err) 1333 + slog.Debug("Error opening CSS file", "err", err) 1296 1334 return "" 1297 1335 } 1298 1336 defer cssFile.Close() 1299 1337 1300 1338 hasher := sha256.New() 1301 1339 if _, err := io.Copy(hasher, cssFile); err != nil { 1302 - log.Printf("Error hashing CSS file: %v", err) 1340 + slog.Debug("Error hashing CSS file", "err", err) 1303 1341 return "" 1304 1342 } 1305 1343
+2 -7
appview/pages/repoinfo/repoinfo.go
··· 78 78 func (r RepoInfo) TabMetadata() map[string]any { 79 79 meta := make(map[string]any) 80 80 81 - if r.Stats.PullCount.Open > 0 { 82 - meta["pulls"] = r.Stats.PullCount.Open 83 - } 84 - 85 - if r.Stats.IssueCount.Open > 0 { 86 - meta["issues"] = r.Stats.IssueCount.Open 87 - } 81 + meta["pulls"] = r.Stats.PullCount.Open 82 + meta["issues"] = r.Stats.IssueCount.Open 88 83 89 84 // more stuff? 90 85
+2 -2
appview/pages/templates/layouts/base.html
··· 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 19 <header class="px-1 col-span-1 md:col-start-3 md:col-span-8" style="z-index: 20;"> 20 - {{ template "layouts/topbar" . }} 20 + {{ template "layouts/fragments/topbar" . }} 21 21 </header> 22 22 {{ end }} 23 23 ··· 39 39 40 40 {{ block "footerLayout" . }} 41 41 <footer class="px-1 col-span-1 md:col-start-3 md:col-span-8 mt-12"> 42 - {{ template "layouts/footer" . }} 42 + {{ template "layouts/fragments/footer" . }} 43 43 </footer> 44 44 {{ end }} 45 45 </body>
-48
appview/pages/templates/layouts/footer.html
··· 1 - {{ define "layouts/footer" }} 2 - <div class="w-full p-4 md:p-8 bg-white dark:bg-gray-800 rounded-t drop-shadow-sm"> 3 - <div class="container mx-auto max-w-7xl px-4"> 4 - <div class="flex flex-col lg:flex-row justify-between items-start text-gray-600 dark:text-gray-400 text-sm gap-8"> 5 - <div class="mb-4 md:mb-0"> 6 - <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic"> 7 - tangled<sub>alpha</sub> 8 - </a> 9 - </div> 10 - 11 - {{ $headerStyle := "text-gray-900 dark:text-gray-200 font-bold text-xs uppercase tracking-wide mb-1" }} 12 - {{ $linkStyle := "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" }} 13 - {{ $iconStyle := "w-4 h-4 flex-shrink-0" }} 14 - <div class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-4 sm:gap-6 md:gap-2 gap-6 flex-1"> 15 - <div class="flex flex-col gap-1"> 16 - <div class="{{ $headerStyle }}">legal</div> 17 - <a href="/terms" class="{{ $linkStyle }}">{{ i "file-text" $iconStyle }} terms of service</a> 18 - <a href="/privacy" class="{{ $linkStyle }}">{{ i "shield" $iconStyle }} privacy policy</a> 19 - </div> 20 - 21 - <div class="flex flex-col gap-1"> 22 - <div class="{{ $headerStyle }}">resources</div> 23 - <a href="https://blog.tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 24 - <a href="https://tangled.sh/@tangled.sh/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 25 - <a href="https://tangled.sh/@tangled.sh/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 26 - </div> 27 - 28 - <div class="flex flex-col gap-1"> 29 - <div class="{{ $headerStyle }}">social</div> 30 - <a href="https://chat.tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "message-circle" $iconStyle }} discord</a> 31 - <a href="https://web.libera.chat/#tangled" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "hash" $iconStyle }} irc</a> 32 - <a href="https://bsky.app/profile/tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ template "user/fragments/bluesky" $iconStyle }} bluesky</a> 33 - </div> 34 - 35 - <div class="flex flex-col gap-1"> 36 - <div class="{{ $headerStyle }}">contact</div> 37 - <a href="mailto:team@tangled.sh" class="{{ $linkStyle }}">{{ i "mail" "w-4 h-4 flex-shrink-0" }} team@tangled.sh</a> 38 - <a href="mailto:security@tangled.sh" class="{{ $linkStyle }}">{{ i "shield-check" "w-4 h-4 flex-shrink-0" }} security@tangled.sh</a> 39 - </div> 40 - </div> 41 - 42 - <div class="text-center lg:text-right flex-shrink-0"> 43 - <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 44 - </div> 45 - </div> 46 - </div> 47 - </div> 48 - {{ end }}
+48
appview/pages/templates/layouts/fragments/footer.html
··· 1 + {{ define "layouts/fragments/footer" }} 2 + <div class="w-full p-4 md:p-8 bg-white dark:bg-gray-800 rounded-t drop-shadow-sm"> 3 + <div class="container mx-auto max-w-7xl px-4"> 4 + <div class="flex flex-col lg:flex-row justify-between items-start text-gray-600 dark:text-gray-400 text-sm gap-8"> 5 + <div class="mb-4 md:mb-0"> 6 + <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic"> 7 + tangled<sub>alpha</sub> 8 + </a> 9 + </div> 10 + 11 + {{ $headerStyle := "text-gray-900 dark:text-gray-200 font-bold text-xs uppercase tracking-wide mb-1" }} 12 + {{ $linkStyle := "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" }} 13 + {{ $iconStyle := "w-4 h-4 flex-shrink-0" }} 14 + <div class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-4 sm:gap-6 md:gap-2 gap-6 flex-1"> 15 + <div class="flex flex-col gap-1"> 16 + <div class="{{ $headerStyle }}">legal</div> 17 + <a href="/terms" class="{{ $linkStyle }}">{{ i "file-text" $iconStyle }} terms of service</a> 18 + <a href="/privacy" class="{{ $linkStyle }}">{{ i "shield" $iconStyle }} privacy policy</a> 19 + </div> 20 + 21 + <div class="flex flex-col gap-1"> 22 + <div class="{{ $headerStyle }}">resources</div> 23 + <a href="https://blog.tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 24 + <a href="https://tangled.sh/@tangled.sh/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 25 + <a href="https://tangled.sh/@tangled.sh/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 26 + </div> 27 + 28 + <div class="flex flex-col gap-1"> 29 + <div class="{{ $headerStyle }}">social</div> 30 + <a href="https://chat.tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "message-circle" $iconStyle }} discord</a> 31 + <a href="https://web.libera.chat/#tangled" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "hash" $iconStyle }} irc</a> 32 + <a href="https://bsky.app/profile/tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ template "user/fragments/bluesky" $iconStyle }} bluesky</a> 33 + </div> 34 + 35 + <div class="flex flex-col gap-1"> 36 + <div class="{{ $headerStyle }}">contact</div> 37 + <a href="mailto:team@tangled.sh" class="{{ $linkStyle }}">{{ i "mail" "w-4 h-4 flex-shrink-0" }} team@tangled.sh</a> 38 + <a href="mailto:security@tangled.sh" class="{{ $linkStyle }}">{{ i "shield-check" "w-4 h-4 flex-shrink-0" }} security@tangled.sh</a> 39 + </div> 40 + </div> 41 + 42 + <div class="text-center lg:text-right flex-shrink-0"> 43 + <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 44 + </div> 45 + </div> 46 + </div> 47 + </div> 48 + {{ end }}
+87
appview/pages/templates/layouts/fragments/topbar.html
··· 1 + {{ define "layouts/fragments/topbar" }} 2 + <nav class="space-x-4 px-6 py-2 rounded bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm"> 3 + <div class="flex justify-between p-0 items-center"> 4 + <div id="left-items"> 5 + <a href="/" hx-boost="true" class="flex gap-2 font-bold italic"> 6 + tangled<sub>alpha</sub> 7 + </a> 8 + </div> 9 + 10 + <div id="right-items" class="flex items-center gap-2"> 11 + {{ with .LoggedInUser }} 12 + {{ block "newButton" . }} {{ end }} 13 + {{ block "dropDown" . }} {{ end }} 14 + {{ else }} 15 + <a href="/login">login</a> 16 + <span class="text-gray-500 dark:text-gray-400">or</span> 17 + <a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2"> 18 + join now {{ i "arrow-right" "size-4" }} 19 + </a> 20 + {{ end }} 21 + </div> 22 + </div> 23 + </nav> 24 + {{ if .LoggedInUser }} 25 + <div id="upgrade-banner" 26 + hx-get="/knots/upgradeBanner" 27 + hx-trigger="load" 28 + hx-swap="innerHTML"> 29 + </div> 30 + {{ end }} 31 + {{ end }} 32 + 33 + {{ define "newButton" }} 34 + <details class="relative inline-block text-left nav-dropdown"> 35 + <summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2"> 36 + {{ i "plus" "w-4 h-4" }} new 37 + </summary> 38 + <div class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"> 39 + <a href="/repo/new" class="flex items-center gap-2"> 40 + {{ i "book-plus" "w-4 h-4" }} 41 + new repository 42 + </a> 43 + <a href="/strings/new" class="flex items-center gap-2"> 44 + {{ i "line-squiggle" "w-4 h-4" }} 45 + new string 46 + </a> 47 + </div> 48 + </details> 49 + {{ end }} 50 + 51 + {{ define "dropDown" }} 52 + <details class="relative inline-block text-left nav-dropdown"> 53 + <summary 54 + class="cursor-pointer list-none flex items-center" 55 + > 56 + {{ $user := didOrHandle .Did .Handle }} 57 + {{ template "user/fragments/picHandle" $user }} 58 + </summary> 59 + <div 60 + class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700" 61 + > 62 + <a href="/{{ $user }}">profile</a> 63 + <a href="/{{ $user }}?tab=repos">repositories</a> 64 + <a href="/{{ $user }}?tab=strings">strings</a> 65 + <a href="/knots">knots</a> 66 + <a href="/spindles">spindles</a> 67 + <a href="/settings">settings</a> 68 + <a href="#" 69 + hx-post="/logout" 70 + hx-swap="none" 71 + class="text-red-400 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"> 72 + logout 73 + </a> 74 + </div> 75 + </details> 76 + 77 + <script> 78 + document.addEventListener('click', function(event) { 79 + const dropdowns = document.querySelectorAll('.nav-dropdown'); 80 + dropdowns.forEach(function(dropdown) { 81 + if (!dropdown.contains(event.target)) { 82 + dropdown.removeAttribute('open'); 83 + } 84 + }); 85 + }); 86 + </script> 87 + {{ end }}
+104
appview/pages/templates/layouts/profilebase.html
··· 1 + {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }}{{ end }} 2 + 3 + {{ define "extrameta" }} 4 + <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" /> 5 + <meta property="og:type" content="profile" /> 6 + <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}?tab={{ .Active }}" /> 7 + <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 8 + {{ end }} 9 + 10 + {{ define "content" }} 11 + {{ template "profileTabs" . }} 12 + <section class="bg-white dark:bg-gray-800 p-6 rounded w-full dark:text-white drop-shadow-sm"> 13 + <div class="grid grid-cols-1 md:grid-cols-11 gap-4"> 14 + <div class="md:col-span-3 order-1 md:order-1"> 15 + <div class="flex flex-col gap-4"> 16 + {{ template "user/fragments/profileCard" .Card }} 17 + {{ block "punchcard" .Card.Punchcard }} {{ end }} 18 + </div> 19 + </div> 20 + {{ block "profileContent" . }} {{ end }} 21 + </div> 22 + </section> 23 + {{ end }} 24 + 25 + {{ define "profileTabs" }} 26 + <nav class="w-full pl-4 overflow-x-auto overflow-y-hidden"> 27 + <div class="flex z-60"> 28 + {{ $activeTabStyles := "-mb-px bg-white dark:bg-gray-800" }} 29 + {{ $tabs := .Card.GetTabs }} 30 + {{ $tabmeta := dict "x" "y" }} 31 + {{ range $item := $tabs }} 32 + {{ $key := index $item 0 }} 33 + {{ $value := index $item 1 }} 34 + {{ $icon := index $item 2 }} 35 + {{ $meta := index $item 3 }} 36 + <a 37 + href="?tab={{ $value }}" 38 + class="relative -mr-px group no-underline hover:no-underline" 39 + hx-boost="true"> 40 + <div 41 + class="px-4 py-1 mr-1 text-black dark:text-white min-w-[80px] text-center relative rounded-t whitespace-nowrap 42 + {{ if eq $.Active $key }} 43 + {{ $activeTabStyles }} 44 + {{ else }} 45 + group-hover:bg-gray-100/25 group-hover:dark:bg-gray-700/25 46 + {{ end }} 47 + "> 48 + <span class="flex items-center justify-center"> 49 + {{ i $icon "w-4 h-4 mr-2" }} 50 + {{ $key }} 51 + {{ if $meta }} 52 + <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-sm ml-1">{{ $meta }}</span> 53 + {{ end }} 54 + </span> 55 + </div> 56 + </a> 57 + {{ end }} 58 + </div> 59 + </nav> 60 + {{ end }} 61 + 62 + {{ define "punchcard" }} 63 + {{ $now := now }} 64 + <div> 65 + <p class="px-2 pb-4 flex gap-2 text-sm font-bold dark:text-white"> 66 + PUNCHCARD 67 + <span class="font-mono font-normal text-sm text-gray-500 dark:text-gray-400 "> 68 + {{ .Total | int64 | commaFmt }} commits 69 + </span> 70 + </p> 71 + <div class="grid grid-cols-28 md:grid-cols-14 gap-y-3 w-full h-full"> 72 + {{ range .Punches }} 73 + {{ $count := .Count }} 74 + {{ $theme := "bg-gray-200 dark:bg-gray-700 size-[4px]" }} 75 + {{ if lt $count 1 }} 76 + {{ $theme = "bg-gray-200 dark:bg-gray-700 size-[4px]" }} 77 + {{ else if lt $count 2 }} 78 + {{ $theme = "bg-green-200 dark:bg-green-900 size-[5px]" }} 79 + {{ else if lt $count 4 }} 80 + {{ $theme = "bg-green-300 dark:bg-green-800 size-[5px]" }} 81 + {{ else if lt $count 8 }} 82 + {{ $theme = "bg-green-400 dark:bg-green-700 size-[6px]" }} 83 + {{ else }} 84 + {{ $theme = "bg-green-500 dark:bg-green-600 size-[7px]" }} 85 + {{ end }} 86 + 87 + {{ if .Date.After $now }} 88 + {{ $theme = "border border-gray-200 dark:border-gray-700 size-[4px]" }} 89 + {{ end }} 90 + <div class="w-full h-full flex justify-center items-center"> 91 + <div 92 + class="aspect-square rounded-full transition-all duration-300 {{ $theme }} max-w-full max-h-full" 93 + title="{{ .Date.Format "2006-01-02" }}: {{ .Count }} commits"> 94 + </div> 95 + </div> 96 + {{ end }} 97 + </div> 98 + </div> 99 + {{ end }} 100 + 101 + {{ define "layouts/profilebase" }} 102 + {{ template "layouts/base" . }} 103 + {{ end }} 104 +
+2 -6
appview/pages/templates/layouts/repobase.html
··· 71 71 <span class="flex items-center justify-center"> 72 72 {{ i $icon "w-4 h-4 mr-2" }} 73 73 {{ $key }} 74 - {{ if not (isNil $meta) }} 75 - <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-sm ml-1">{{ $meta }}</span> 74 + {{ if $meta }} 75 + <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-sm ml-1">{{ $meta }}</span> 76 76 {{ end }} 77 77 </span> 78 78 </div> ··· 88 88 {{ block "repoAfter" . }}{{ end }} 89 89 </section> 90 90 {{ end }} 91 - 92 - {{ define "layouts/repobase" }} 93 - {{ template "layouts/base" . }} 94 - {{ end }}
-87
appview/pages/templates/layouts/topbar.html
··· 1 - {{ define "layouts/topbar" }} 2 - <nav class="space-x-4 px-6 py-2 rounded bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm"> 3 - <div class="flex justify-between p-0 items-center"> 4 - <div id="left-items"> 5 - <a href="/" hx-boost="true" class="flex gap-2 font-bold italic"> 6 - tangled<sub>alpha</sub> 7 - </a> 8 - </div> 9 - 10 - <div id="right-items" class="flex items-center gap-2"> 11 - {{ with .LoggedInUser }} 12 - {{ block "newButton" . }} {{ end }} 13 - {{ block "dropDown" . }} {{ end }} 14 - {{ else }} 15 - <a href="/login">login</a> 16 - <span class="text-gray-500 dark:text-gray-400">or</span> 17 - <a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2"> 18 - join now {{ i "arrow-right" "size-4" }} 19 - </a> 20 - {{ end }} 21 - </div> 22 - </div> 23 - </nav> 24 - {{ if .LoggedInUser }} 25 - <div id="upgrade-banner" 26 - hx-get="/knots/upgradeBanner" 27 - hx-trigger="load" 28 - hx-swap="innerHTML"> 29 - </div> 30 - {{ end }} 31 - {{ end }} 32 - 33 - {{ define "newButton" }} 34 - <details class="relative inline-block text-left nav-dropdown"> 35 - <summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2"> 36 - {{ i "plus" "w-4 h-4" }} new 37 - </summary> 38 - <div class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"> 39 - <a href="/repo/new" class="flex items-center gap-2"> 40 - {{ i "book-plus" "w-4 h-4" }} 41 - new repository 42 - </a> 43 - <a href="/strings/new" class="flex items-center gap-2"> 44 - {{ i "line-squiggle" "w-4 h-4" }} 45 - new string 46 - </a> 47 - </div> 48 - </details> 49 - {{ end }} 50 - 51 - {{ define "dropDown" }} 52 - <details class="relative inline-block text-left nav-dropdown"> 53 - <summary 54 - class="cursor-pointer list-none flex items-center" 55 - > 56 - {{ $user := didOrHandle .Did .Handle }} 57 - {{ template "user/fragments/picHandle" $user }} 58 - </summary> 59 - <div 60 - class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700" 61 - > 62 - <a href="/{{ $user }}">profile</a> 63 - <a href="/{{ $user }}?tab=repos">repositories</a> 64 - <a href="/strings/{{ $user }}">strings</a> 65 - <a href="/knots">knots</a> 66 - <a href="/spindles">spindles</a> 67 - <a href="/settings">settings</a> 68 - <a href="#" 69 - hx-post="/logout" 70 - hx-swap="none" 71 - class="text-red-400 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"> 72 - logout 73 - </a> 74 - </div> 75 - </details> 76 - 77 - <script> 78 - document.addEventListener('click', function(event) { 79 - const dropdowns = document.querySelectorAll('.nav-dropdown'); 80 - dropdowns.forEach(function(dropdown) { 81 - if (!dropdown.contains(event.target)) { 82 - dropdown.removeAttribute('open'); 83 - } 84 - }); 85 - }); 86 - </script> 87 - {{ end }}
+2 -2
appview/pages/templates/repo/commit.html
··· 81 81 82 82 {{ define "topbarLayout" }} 83 83 <header class="px-1 col-span-full" style="z-index: 20;"> 84 - {{ template "layouts/topbar" . }} 84 + {{ template "layouts/fragments/topbar" . }} 85 85 </header> 86 86 {{ end }} 87 87 ··· 106 106 107 107 {{ define "footerLayout" }} 108 108 <footer class="px-1 col-span-full mt-12"> 109 - {{ template "layouts/footer" . }} 109 + {{ template "layouts/fragments/footer" . }} 110 110 </footer> 111 111 {{ end }} 112 112
+2 -2
appview/pages/templates/repo/compare/compare.html
··· 12 12 13 13 {{ define "topbarLayout" }} 14 14 <header class="px-1 col-span-full" style="z-index: 20;"> 15 - {{ template "layouts/topbar" . }} 15 + {{ template "layouts/fragments/topbar" . }} 16 16 </header> 17 17 {{ end }} 18 18 ··· 37 37 38 38 {{ define "footerLayout" }} 39 39 <footer class="px-1 col-span-full mt-12"> 40 - {{ template "layouts/footer" . }} 40 + {{ template "layouts/fragments/footer" . }} 41 41 </footer> 42 42 {{ end }} 43 43
+4
appview/pages/templates/repo/fragments/duration.html
··· 1 + {{ define "repo/fragments/duration" }} 2 + <time datetime="{{ . | iso8601DurationFmt }}" title="{{ . | longDurationFmt }}">{{ . | durationFmt }}</time> 3 + {{ end }} 4 +
+4
appview/pages/templates/repo/fragments/shortTime.html
··· 1 + {{ define "repo/fragments/shortTime" }} 2 + {{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (. | shortRelTimeFmt)) }} 3 + {{ end }} 4 +
+4
appview/pages/templates/repo/fragments/shortTimeAgo.html
··· 1 + {{ define "repo/fragments/shortTimeAgo" }} 2 + {{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (print (. | shortRelTimeFmt) " ago")) }} 3 + {{ end }} 4 +
-16
appview/pages/templates/repo/fragments/time.html
··· 1 - {{ define "repo/fragments/timeWrapper" }} 2 - <time datetime="{{ .Time | iso8601DateTimeFmt }}" title="{{ .Time | longTimeFmt }}">{{ .Content }}</time> 3 - {{ end }} 4 - 5 1 {{ define "repo/fragments/time" }} 6 2 {{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (. | relTimeFmt)) }} 7 3 {{ end }} 8 - 9 - {{ define "repo/fragments/shortTime" }} 10 - {{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (. | shortRelTimeFmt)) }} 11 - {{ end }} 12 - 13 - {{ define "repo/fragments/shortTimeAgo" }} 14 - {{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (print (. | shortRelTimeFmt) " ago")) }} 15 - {{ end }} 16 - 17 - {{ define "repo/fragments/duration" }} 18 - <time datetime="{{ . | iso8601DurationFmt }}" title="{{ . | longDurationFmt }}">{{ . | durationFmt }}</time> 19 - {{ end }}
+5
appview/pages/templates/repo/fragments/timeWrapper.html
··· 1 + {{ define "repo/fragments/timeWrapper" }} 2 + <time datetime="{{ .Time | iso8601DateTimeFmt }}" title="{{ .Time | longTimeFmt }}">{{ .Content }}</time> 3 + {{ end }} 4 + 5 +
+1 -1
appview/pages/templates/repo/pulls/fragments/pullStack.html
··· 52 52 </div> 53 53 {{ end }} 54 54 <div class="{{ if not $isCurrent }} pl-6 {{ end }} flex-grow min-w-0 w-full py-2"> 55 - {{ template "repo/pulls/fragments/summarizedHeader" (list $pull $pipeline) }} 55 + {{ template "repo/pulls/fragments/summarizedPullHeader" (list $pull $pipeline) }} 56 56 </div> 57 57 </div> 58 58 </a>
+1 -1
appview/pages/templates/repo/pulls/fragments/summarizedPullHeader.html
··· 1 - {{ define "repo/pulls/fragments/summarizedHeader" }} 1 + {{ define "repo/pulls/fragments/summarizedPullHeader" }} 2 2 {{ $pull := index . 0 }} 3 3 {{ $pipeline := index . 1 }} 4 4 {{ with $pull }}
+2 -2
appview/pages/templates/repo/pulls/interdiff.html
··· 30 30 31 31 {{ define "topbarLayout" }} 32 32 <header class="px-1 col-span-full" style="z-index: 20;"> 33 - {{ template "layouts/topbar" . }} 33 + {{ template "layouts/fragments/topbar" . }} 34 34 </header> 35 35 {{ end }} 36 36 ··· 55 55 56 56 {{ define "footerLayout" }} 57 57 <footer class="px-1 col-span-full mt-12"> 58 - {{ template "layouts/footer" . }} 58 + {{ template "layouts/fragments/footer" . }} 59 59 </footer> 60 60 {{ end }} 61 61
+2 -2
appview/pages/templates/repo/pulls/patch.html
··· 36 36 37 37 {{ define "topbarLayout" }} 38 38 <header class="px-1 col-span-full" style="z-index: 20;"> 39 - {{ template "layouts/topbar" . }} 39 + {{ template "layouts/fragments/topbar" . }} 40 40 </header> 41 41 {{ end }} 42 42 ··· 61 61 62 62 {{ define "footerLayout" }} 63 63 <footer class="px-1 col-span-full mt-12"> 64 - {{ template "layouts/footer" . }} 64 + {{ template "layouts/fragments/footer" . }} 65 65 </footer> 66 66 {{ end }} 67 67
+1 -1
appview/pages/templates/repo/pulls/pulls.html
··· 144 144 <a href="/{{ $root.RepoInfo.FullName }}/pulls/{{ $pull.PullId }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25"> 145 145 <div class="flex gap-2 items-center px-6"> 146 146 <div class="flex-grow min-w-0 w-full py-2"> 147 - {{ template "repo/pulls/fragments/summarizedHeader" (list $pull $pipeline) }} 147 + {{ template "repo/pulls/fragments/summarizedPullHeader" (list $pull $pipeline) }} 148 148 </div> 149 149 </div> 150 150 </a>
-4
appview/pages/templates/strings/put.html
··· 1 1 {{ define "title" }}publish a new string{{ end }} 2 2 3 - {{ define "topbar" }} 4 - {{ template "layouts/topbar" $ }} 5 - {{ end }} 6 - 7 3 {{ define "content" }} 8 4 <div class="px-6 py-2 mb-4"> 9 5 {{ if eq .Action "new" }}
-4
appview/pages/templates/strings/string.html
··· 8 8 <meta property="og:description" content="{{ .String.Description }}" /> 9 9 {{ end }} 10 10 11 - {{ define "topbar" }} 12 - {{ template "layouts/topbar" $ }} 13 - {{ end }} 14 - 15 11 {{ define "content" }} 16 12 {{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }} 17 13 <section id="string-header" class="mb-4 py-2 px-6 dark:text-white">
-4
appview/pages/templates/strings/timeline.html
··· 1 1 {{ define "title" }} all strings {{ end }} 2 2 3 - {{ define "topbar" }} 4 - {{ template "layouts/topbar" $ }} 5 - {{ end }} 6 - 7 3 {{ define "content" }} 8 4 {{ block "timeline" $ }}{{ end }} 9 5 {{ end }}
+4 -16
appview/pages/templates/user/followers.html
··· 1 1 {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท followers {{ end }} 2 2 3 - {{ define "extrameta" }} 4 - <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}'s followers" /> 5 - <meta property="og:type" content="object" /> 6 - <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}?tab=followers" /> 7 - <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 8 - {{ end }} 9 - 10 - {{ define "content" }} 11 - <div class="grid grid-cols-1 md:grid-cols-11 gap-4"> 12 - <div class="md:col-span-3 order-1 md:order-1"> 13 - {{ template "user/fragments/profileCard" .Card }} 14 - </div> 15 - <div id="all-followers" class="md:col-span-8 order-2 md:order-2"> 16 - {{ block "followers" . }}{{ end }} 17 - </div> 18 - </div> 3 + {{ define "profileContent" }} 4 + <div id="all-followers" class="md:col-span-8 order-2 md:order-2"> 5 + {{ block "followers" . }}{{ end }} 6 + </div> 19 7 {{ end }} 20 8 21 9 {{ define "followers" }}
+4 -16
appview/pages/templates/user/following.html
··· 1 1 {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท following {{ end }} 2 2 3 - {{ define "extrameta" }} 4 - <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}'s following" /> 5 - <meta property="og:type" content="object" /> 6 - <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}?tab=following" /> 7 - <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 8 - {{ end }} 9 - 10 - {{ define "content" }} 11 - <div class="grid grid-cols-1 md:grid-cols-11 gap-4"> 12 - <div class="md:col-span-3 order-1 md:order-1"> 13 - {{ template "user/fragments/profileCard" .Card }} 14 - </div> 15 - <div id="all-following" class="md:col-span-8 order-2 md:order-2"> 16 - {{ block "following" . }}{{ end }} 17 - </div> 18 - </div> 3 + {{ define "profileContent" }} 4 + <div id="all-following" class="md:col-span-8 order-2 md:order-2"> 5 + {{ block "following" . }}{{ end }} 6 + </div> 19 7 {{ end }} 20 8 21 9 {{ define "following" }}
+1 -1
appview/pages/templates/user/fragments/editBio.html
··· 13 13 <label class="m-0 p-0" for="description">bio</label> 14 14 <textarea 15 15 type="text" 16 - class="py-1 px-1 w-full" 16 + class="p-2 w-full" 17 17 name="description" 18 18 rows="3" 19 19 placeholder="write a bio">{{ $description }}</textarea>
+2 -4
appview/pages/templates/user/fragments/profileCard.html
··· 1 1 {{ define "user/fragments/profileCard" }} 2 2 {{ $userIdent := didOrHandle .UserDid .UserHandle }} 3 - <div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm max-h-fit"> 4 3 <div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center"> 5 4 <div id="avatar" class="col-span-1 flex justify-center items-center"> 6 5 <div class="w-3/4 aspect-square relative"> ··· 85 84 <div id="update-profile" class="text-red-400 dark:text-red-500"></div> 86 85 </div> 87 86 </div> 88 - </div> 89 87 {{ end }} 90 88 91 89 {{ define "followerFollowing" }} ··· 94 92 {{ with $root }} 95 93 <div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 96 94 <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 97 - <span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .FollowersCount }} followers</a></span> 95 + <span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .Stats.FollowersCount }} followers</a></span> 98 96 <span class="select-none after:content-['ยท']"></span> 99 - <span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .FollowingCount }} following</a></span> 97 + <span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .Stats.FollowingCount }} following</a></span> 100 98 </div> 101 99 {{ end }} 102 100 {{ end }}
+258
appview/pages/templates/user/overview.html
··· 1 + {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }}{{ end }} 2 + 3 + {{ define "profileContent" }} 4 + <div id="all-repos" class="md:col-span-4 order-2 md:order-2"> 5 + <div class="grid grid-cols-1 gap-4"> 6 + {{ block "ownRepos" . }}{{ end }} 7 + {{ block "collaboratingRepos" . }}{{ end }} 8 + </div> 9 + </div> 10 + <div class="md:col-span-4 order-3 md:order-3"> 11 + {{ block "profileTimeline" . }}{{ end }} 12 + </div> 13 + {{ end }} 14 + 15 + {{ define "profileTimeline" }} 16 + <p class="text-sm font-bold px-2 pb-4 dark:text-white">ACTIVITY</p> 17 + <div class="flex flex-col gap-4 relative"> 18 + {{ if .ProfileTimeline.IsEmpty }} 19 + <p class="dark:text-white">This user does not have any activity yet.</p> 20 + {{ end }} 21 + 22 + {{ with .ProfileTimeline }} 23 + {{ range $idx, $byMonth := .ByMonth }} 24 + {{ with $byMonth }} 25 + {{ if not .IsEmpty }} 26 + <div class="border border-gray-200 dark:border-gray-700 rounded-sm py-4 px-6"> 27 + <p class="text-sm font-mono mb-2 text-gray-500 dark:text-gray-400"> 28 + {{ if eq $idx 0 }} 29 + this month 30 + {{ else }} 31 + {{$idx}} month{{if ne $idx 1}}s{{end}} ago 32 + {{ end }} 33 + </p> 34 + 35 + <div class="flex flex-col gap-1"> 36 + {{ block "repoEvents" .RepoEvents }} {{ end }} 37 + {{ block "issueEvents" .IssueEvents }} {{ end }} 38 + {{ block "pullEvents" .PullEvents }} {{ end }} 39 + </div> 40 + </div> 41 + {{ end }} 42 + {{ end }} 43 + {{ end }} 44 + {{ end }} 45 + </div> 46 + {{ end }} 47 + 48 + {{ define "repoEvents" }} 49 + {{ if gt (len .) 0 }} 50 + <details> 51 + <summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 52 + <div class="flex flex-wrap items-center gap-2"> 53 + {{ i "book-plus" "w-4 h-4" }} 54 + created {{ len . }} {{if eq (len .) 1 }}repository{{else}}repositories{{end}} 55 + </div> 56 + </summary> 57 + <div class="py-2 text-sm flex flex-col gap-3 mb-2"> 58 + {{ range . }} 59 + <div class="flex flex-wrap items-center gap-2"> 60 + <span class="text-gray-500 dark:text-gray-400"> 61 + {{ if .Source }} 62 + {{ i "git-fork" "w-4 h-4" }} 63 + {{ else }} 64 + {{ i "book-plus" "w-4 h-4" }} 65 + {{ end }} 66 + </span> 67 + <a href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}" class="no-underline hover:underline"> 68 + {{- .Repo.Name -}} 69 + </a> 70 + </div> 71 + {{ end }} 72 + </div> 73 + </details> 74 + {{ end }} 75 + {{ end }} 76 + 77 + {{ define "issueEvents" }} 78 + {{ $items := .Items }} 79 + {{ $stats := .Stats }} 80 + 81 + {{ if gt (len $items) 0 }} 82 + <details> 83 + <summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 84 + <div class="flex flex-wrap items-center gap-2"> 85 + {{ i "circle-dot" "w-4 h-4" }} 86 + 87 + <div> 88 + created {{ len $items }} {{if eq (len $items) 1 }}issue{{else}}issues{{end}} 89 + </div> 90 + 91 + {{ if gt $stats.Open 0 }} 92 + <span class="px-2 py-1/2 text-sm rounded text-white bg-green-600 dark:bg-green-700"> 93 + {{$stats.Open}} open 94 + </span> 95 + {{ end }} 96 + 97 + {{ if gt $stats.Closed 0 }} 98 + <span class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700"> 99 + {{$stats.Closed}} closed 100 + </span> 101 + {{ end }} 102 + 103 + </div> 104 + </summary> 105 + <div class="py-2 text-sm flex flex-col gap-3 mb-2"> 106 + {{ range $items }} 107 + {{ $repoOwner := resolve .Metadata.Repo.Did }} 108 + {{ $repoName := .Metadata.Repo.Name }} 109 + {{ $repoUrl := printf "%s/%s" $repoOwner $repoName }} 110 + 111 + <div class="flex gap-2 text-gray-600 dark:text-gray-300"> 112 + {{ if .Open }} 113 + <span class="text-green-600 dark:text-green-500"> 114 + {{ i "circle-dot" "w-4 h-4" }} 115 + </span> 116 + {{ else }} 117 + <span class="text-gray-500 dark:text-gray-400"> 118 + {{ i "ban" "w-4 h-4" }} 119 + </span> 120 + {{ end }} 121 + <div class="flex-none min-w-8 text-right"> 122 + <span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span> 123 + </div> 124 + <div class="break-words max-w-full"> 125 + <a href="/{{$repoUrl}}/issues/{{ .IssueId }}" class="no-underline hover:underline"> 126 + {{ .Title -}} 127 + </a> 128 + on 129 + <a href="/{{$repoUrl}}" class="no-underline hover:underline whitespace-nowrap"> 130 + {{$repoUrl}} 131 + </a> 132 + </div> 133 + </div> 134 + {{ end }} 135 + </div> 136 + </details> 137 + {{ end }} 138 + {{ end }} 139 + 140 + {{ define "pullEvents" }} 141 + {{ $items := .Items }} 142 + {{ $stats := .Stats }} 143 + {{ if gt (len $items) 0 }} 144 + <details> 145 + <summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 146 + <div class="flex flex-wrap items-center gap-2"> 147 + {{ i "git-pull-request" "w-4 h-4" }} 148 + 149 + <div> 150 + created {{ len $items }} {{if eq (len $items) 1 }}pull request{{else}}pull requests{{end}} 151 + </div> 152 + 153 + {{ if gt $stats.Open 0 }} 154 + <span class="px-2 py-1/2 text-sm rounded text-white bg-green-600 dark:bg-green-700"> 155 + {{$stats.Open}} open 156 + </span> 157 + {{ end }} 158 + 159 + {{ if gt $stats.Merged 0 }} 160 + <span class="px-2 py-1/2 text-sm rounded text-white bg-purple-600 dark:bg-purple-700"> 161 + {{$stats.Merged}} merged 162 + </span> 163 + {{ end }} 164 + 165 + 166 + {{ if gt $stats.Closed 0 }} 167 + <span class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700"> 168 + {{$stats.Closed}} closed 169 + </span> 170 + {{ end }} 171 + 172 + </div> 173 + </summary> 174 + <div class="py-2 text-sm flex flex-col gap-3 mb-2"> 175 + {{ range $items }} 176 + {{ $repoOwner := resolve .Repo.Did }} 177 + {{ $repoName := .Repo.Name }} 178 + {{ $repoUrl := printf "%s/%s" $repoOwner $repoName }} 179 + 180 + <div class="flex gap-2 text-gray-600 dark:text-gray-300"> 181 + {{ if .State.IsOpen }} 182 + <span class="text-green-600 dark:text-green-500"> 183 + {{ i "git-pull-request" "w-4 h-4" }} 184 + </span> 185 + {{ else if .State.IsMerged }} 186 + <span class="text-purple-600 dark:text-purple-500"> 187 + {{ i "git-merge" "w-4 h-4" }} 188 + </span> 189 + {{ else }} 190 + <span class="text-gray-600 dark:text-gray-300"> 191 + {{ i "git-pull-request-closed" "w-4 h-4" }} 192 + </span> 193 + {{ end }} 194 + <div class="flex-none min-w-8 text-right"> 195 + <span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span> 196 + </div> 197 + <div class="break-words max-w-full"> 198 + <a href="/{{$repoUrl}}/pulls/{{ .PullId }}" class="no-underline hover:underline"> 199 + {{ .Title -}} 200 + </a> 201 + on 202 + <a href="/{{$repoUrl}}" class="no-underline hover:underline whitespace-nowrap"> 203 + {{$repoUrl}} 204 + </a> 205 + </div> 206 + </div> 207 + {{ end }} 208 + </div> 209 + </details> 210 + {{ end }} 211 + {{ end }} 212 + 213 + {{ define "ownRepos" }} 214 + <div> 215 + <div class="text-sm font-bold px-2 pb-4 dark:text-white flex items-center gap-2"> 216 + <a href="/@{{ or $.Card.UserHandle $.Card.UserDid }}?tab=repos" 217 + class="flex text-black dark:text-white items-center gap-2 no-underline hover:no-underline group"> 218 + <span>PINNED REPOS</span> 219 + </a> 220 + {{ if and .LoggedInUser (eq .LoggedInUser.Did .Card.UserDid) }} 221 + <button 222 + hx-get="profile/edit-pins" 223 + hx-target="#all-repos" 224 + class="py-0 font-normal text-sm flex gap-2 items-center group"> 225 + {{ i "pencil" "w-3 h-3" }} 226 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 227 + </button> 228 + {{ end }} 229 + </div> 230 + <div id="repos" class="grid grid-cols-1 gap-4 items-stretch"> 231 + {{ range .Repos }} 232 + <div class="border border-gray-200 dark:border-gray-700 rounded-sm"> 233 + {{ template "user/fragments/repoCard" (list $ . false) }} 234 + </div> 235 + {{ else }} 236 + <p class="dark:text-white">This user does not have any pinned repos.</p> 237 + {{ end }} 238 + </div> 239 + </div> 240 + {{ end }} 241 + 242 + {{ define "collaboratingRepos" }} 243 + {{ if gt (len .CollaboratingRepos) 0 }} 244 + <div> 245 + <p class="text-sm font-bold px-2 pb-4 dark:text-white">COLLABORATING ON</p> 246 + <div id="collaborating" class="grid grid-cols-1 gap-4"> 247 + {{ range .CollaboratingRepos }} 248 + <div class="border border-gray-200 dark:border-gray-700 rounded-sm"> 249 + {{ template "user/fragments/repoCard" (list $ . true) }} 250 + </div> 251 + {{ else }} 252 + <p class="px-6 dark:text-white">This user is not collaborating.</p> 253 + {{ end }} 254 + </div> 255 + </div> 256 + {{ end }} 257 + {{ end }} 258 +
-318
appview/pages/templates/user/profile.html
··· 1 - {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }}{{ end }} 2 - 3 - {{ define "extrameta" }} 4 - <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" /> 5 - <meta property="og:type" content="profile" /> 6 - <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}" /> 7 - <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 8 - {{ end }} 9 - 10 - {{ define "content" }} 11 - <div class="grid grid-cols-1 md:grid-cols-11 gap-4"> 12 - <div class="md:col-span-3 order-1 md:order-1"> 13 - <div class="grid grid-cols-1 gap-4"> 14 - {{ template "user/fragments/profileCard" .Card }} 15 - {{ block "punchcard" .Punchcard }} {{ end }} 16 - </div> 17 - </div> 18 - <div id="all-repos" class="md:col-span-4 order-2 md:order-2"> 19 - <div class="grid grid-cols-1 gap-4"> 20 - {{ block "ownRepos" . }}{{ end }} 21 - {{ block "collaboratingRepos" . }}{{ end }} 22 - </div> 23 - </div> 24 - <div class="md:col-span-4 order-3 md:order-3"> 25 - {{ block "profileTimeline" . }}{{ end }} 26 - </div> 27 - </div> 28 - {{ end }} 29 - 30 - {{ define "profileTimeline" }} 31 - <p class="text-sm font-bold p-2 dark:text-white">ACTIVITY</p> 32 - <div class="flex flex-col gap-4 relative"> 33 - {{ with .ProfileTimeline }} 34 - {{ range $idx, $byMonth := .ByMonth }} 35 - {{ with $byMonth }} 36 - <div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm"> 37 - {{ if eq $idx 0 }} 38 - 39 - {{ else }} 40 - {{ $s := "s" }} 41 - {{ if eq $idx 1 }} 42 - {{ $s = "" }} 43 - {{ end }} 44 - <p class="text-sm font-bold dark:text-white mb-2">{{$idx}} month{{$s}} ago</p> 45 - {{ end }} 46 - 47 - {{ if .IsEmpty }} 48 - <div class="text-gray-500 dark:text-gray-400"> 49 - No activity for this month 50 - </div> 51 - {{ else }} 52 - <div class="flex flex-col gap-1"> 53 - {{ block "repoEvents" .RepoEvents }} {{ end }} 54 - {{ block "issueEvents" .IssueEvents }} {{ end }} 55 - {{ block "pullEvents" .PullEvents }} {{ end }} 56 - </div> 57 - {{ end }} 58 - </div> 59 - 60 - {{ end }} 61 - {{ else }} 62 - <p class="dark:text-white">This user does not have any activity yet.</p> 63 - {{ end }} 64 - {{ end }} 65 - </div> 66 - {{ end }} 67 - 68 - {{ define "repoEvents" }} 69 - {{ if gt (len .) 0 }} 70 - <details> 71 - <summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 72 - <div class="flex flex-wrap items-center gap-2"> 73 - {{ i "book-plus" "w-4 h-4" }} 74 - created {{ len . }} {{if eq (len .) 1 }}repository{{else}}repositories{{end}} 75 - </div> 76 - </summary> 77 - <div class="py-2 text-sm flex flex-col gap-3 mb-2"> 78 - {{ range . }} 79 - <div class="flex flex-wrap items-center gap-2"> 80 - <span class="text-gray-500 dark:text-gray-400"> 81 - {{ if .Source }} 82 - {{ i "git-fork" "w-4 h-4" }} 83 - {{ else }} 84 - {{ i "book-plus" "w-4 h-4" }} 85 - {{ end }} 86 - </span> 87 - <a href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}" class="no-underline hover:underline"> 88 - {{- .Repo.Name -}} 89 - </a> 90 - </div> 91 - {{ end }} 92 - </div> 93 - </details> 94 - {{ end }} 95 - {{ end }} 96 - 97 - {{ define "issueEvents" }} 98 - {{ $items := .Items }} 99 - {{ $stats := .Stats }} 100 - 101 - {{ if gt (len $items) 0 }} 102 - <details> 103 - <summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 104 - <div class="flex flex-wrap items-center gap-2"> 105 - {{ i "circle-dot" "w-4 h-4" }} 106 - 107 - <div> 108 - created {{ len $items }} {{if eq (len $items) 1 }}issue{{else}}issues{{end}} 109 - </div> 110 - 111 - {{ if gt $stats.Open 0 }} 112 - <span class="px-2 py-1/2 text-sm rounded text-white bg-green-600 dark:bg-green-700"> 113 - {{$stats.Open}} open 114 - </span> 115 - {{ end }} 116 - 117 - {{ if gt $stats.Closed 0 }} 118 - <span class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700"> 119 - {{$stats.Closed}} closed 120 - </span> 121 - {{ end }} 122 - 123 - </div> 124 - </summary> 125 - <div class="py-2 text-sm flex flex-col gap-3 mb-2"> 126 - {{ range $items }} 127 - {{ $repoOwner := resolve .Metadata.Repo.Did }} 128 - {{ $repoName := .Metadata.Repo.Name }} 129 - {{ $repoUrl := printf "%s/%s" $repoOwner $repoName }} 130 - 131 - <div class="flex gap-2 text-gray-600 dark:text-gray-300"> 132 - {{ if .Open }} 133 - <span class="text-green-600 dark:text-green-500"> 134 - {{ i "circle-dot" "w-4 h-4" }} 135 - </span> 136 - {{ else }} 137 - <span class="text-gray-500 dark:text-gray-400"> 138 - {{ i "ban" "w-4 h-4" }} 139 - </span> 140 - {{ end }} 141 - <div class="flex-none min-w-8 text-right"> 142 - <span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span> 143 - </div> 144 - <div class="break-words max-w-full"> 145 - <a href="/{{$repoUrl}}/issues/{{ .IssueId }}" class="no-underline hover:underline"> 146 - {{ .Title -}} 147 - </a> 148 - on 149 - <a href="/{{$repoUrl}}" class="no-underline hover:underline whitespace-nowrap"> 150 - {{$repoUrl}} 151 - </a> 152 - </div> 153 - </div> 154 - {{ end }} 155 - </div> 156 - </details> 157 - {{ end }} 158 - {{ end }} 159 - 160 - {{ define "pullEvents" }} 161 - {{ $items := .Items }} 162 - {{ $stats := .Stats }} 163 - {{ if gt (len $items) 0 }} 164 - <details> 165 - <summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 166 - <div class="flex flex-wrap items-center gap-2"> 167 - {{ i "git-pull-request" "w-4 h-4" }} 168 - 169 - <div> 170 - created {{ len $items }} {{if eq (len $items) 1 }}pull request{{else}}pull requests{{end}} 171 - </div> 172 - 173 - {{ if gt $stats.Open 0 }} 174 - <span class="px-2 py-1/2 text-sm rounded text-white bg-green-600 dark:bg-green-700"> 175 - {{$stats.Open}} open 176 - </span> 177 - {{ end }} 178 - 179 - {{ if gt $stats.Merged 0 }} 180 - <span class="px-2 py-1/2 text-sm rounded text-white bg-purple-600 dark:bg-purple-700"> 181 - {{$stats.Merged}} merged 182 - </span> 183 - {{ end }} 184 - 185 - 186 - {{ if gt $stats.Closed 0 }} 187 - <span class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700"> 188 - {{$stats.Closed}} closed 189 - </span> 190 - {{ end }} 191 - 192 - </div> 193 - </summary> 194 - <div class="py-2 text-sm flex flex-col gap-3 mb-2"> 195 - {{ range $items }} 196 - {{ $repoOwner := resolve .Repo.Did }} 197 - {{ $repoName := .Repo.Name }} 198 - {{ $repoUrl := printf "%s/%s" $repoOwner $repoName }} 199 - 200 - <div class="flex gap-2 text-gray-600 dark:text-gray-300"> 201 - {{ if .State.IsOpen }} 202 - <span class="text-green-600 dark:text-green-500"> 203 - {{ i "git-pull-request" "w-4 h-4" }} 204 - </span> 205 - {{ else if .State.IsMerged }} 206 - <span class="text-purple-600 dark:text-purple-500"> 207 - {{ i "git-merge" "w-4 h-4" }} 208 - </span> 209 - {{ else }} 210 - <span class="text-gray-600 dark:text-gray-300"> 211 - {{ i "git-pull-request-closed" "w-4 h-4" }} 212 - </span> 213 - {{ end }} 214 - <div class="flex-none min-w-8 text-right"> 215 - <span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span> 216 - </div> 217 - <div class="break-words max-w-full"> 218 - <a href="/{{$repoUrl}}/pulls/{{ .PullId }}" class="no-underline hover:underline"> 219 - {{ .Title -}} 220 - </a> 221 - on 222 - <a href="/{{$repoUrl}}" class="no-underline hover:underline whitespace-nowrap"> 223 - {{$repoUrl}} 224 - </a> 225 - </div> 226 - </div> 227 - {{ end }} 228 - </div> 229 - </details> 230 - {{ end }} 231 - {{ end }} 232 - 233 - {{ define "ownRepos" }} 234 - <div> 235 - <div class="text-sm font-bold p-2 pr-0 dark:text-white flex items-center justify-between gap-2"> 236 - <a href="/@{{ or $.Card.UserHandle $.Card.UserDid }}?tab=repos" 237 - class="flex text-black dark:text-white items-center gap-2 no-underline hover:no-underline group"> 238 - <span>PINNED REPOS</span> 239 - <span class="flex gap-1 items-center font-normal text-sm text-gray-500 dark:text-gray-400 "> 240 - view all {{ i "chevron-right" "w-4 h-4" }} 241 - </span> 242 - </a> 243 - {{ if and .LoggedInUser (eq .LoggedInUser.Did .Card.UserDid) }} 244 - <button 245 - hx-get="profile/edit-pins" 246 - hx-target="#all-repos" 247 - class="btn py-0 font-normal text-sm flex gap-2 items-center group"> 248 - {{ i "pencil" "w-3 h-3" }} 249 - edit 250 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 251 - </button> 252 - {{ end }} 253 - </div> 254 - <div id="repos" class="grid grid-cols-1 gap-4 items-stretch"> 255 - {{ range .Repos }} 256 - {{ template "user/fragments/repoCard" (list $ . false) }} 257 - {{ else }} 258 - <p class="px-6 dark:text-white">This user does not have any repos yet.</p> 259 - {{ end }} 260 - </div> 261 - </div> 262 - {{ end }} 263 - 264 - {{ define "collaboratingRepos" }} 265 - {{ if gt (len .CollaboratingRepos) 0 }} 266 - <div> 267 - <p class="text-sm font-bold p-2 dark:text-white">COLLABORATING ON</p> 268 - <div id="collaborating" class="grid grid-cols-1 gap-4"> 269 - {{ range .CollaboratingRepos }} 270 - {{ template "user/fragments/repoCard" (list $ . true) }} 271 - {{ else }} 272 - <p class="px-6 dark:text-white">This user is not collaborating.</p> 273 - {{ end }} 274 - </div> 275 - </div> 276 - {{ end }} 277 - {{ end }} 278 - 279 - {{ define "punchcard" }} 280 - {{ $now := now }} 281 - <div> 282 - <p class="p-2 flex gap-2 text-sm font-bold dark:text-white"> 283 - PUNCHCARD 284 - <span class="font-normal text-sm text-gray-500 dark:text-gray-400 "> 285 - {{ .Total | int64 | commaFmt }} commits 286 - </span> 287 - </p> 288 - <div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm"> 289 - <div class="grid grid-cols-28 md:grid-cols-14 gap-y-2 w-full h-full"> 290 - {{ range .Punches }} 291 - {{ $count := .Count }} 292 - {{ $theme := "bg-gray-200 dark:bg-gray-700 size-[4px]" }} 293 - {{ if lt $count 1 }} 294 - {{ $theme = "bg-gray-200 dark:bg-gray-700 size-[4px]" }} 295 - {{ else if lt $count 2 }} 296 - {{ $theme = "bg-green-200 dark:bg-green-900 size-[5px]" }} 297 - {{ else if lt $count 4 }} 298 - {{ $theme = "bg-green-300 dark:bg-green-800 size-[5px]" }} 299 - {{ else if lt $count 8 }} 300 - {{ $theme = "bg-green-400 dark:bg-green-700 size-[6px]" }} 301 - {{ else }} 302 - {{ $theme = "bg-green-500 dark:bg-green-600 size-[7px]" }} 303 - {{ end }} 304 - 305 - {{ if .Date.After $now }} 306 - {{ $theme = "border border-gray-200 dark:border-gray-700 size-[4px]" }} 307 - {{ end }} 308 - <div class="w-full h-full flex justify-center items-center"> 309 - <div 310 - class="aspect-square rounded-full transition-all duration-300 {{ $theme }} max-w-full max-h-full" 311 - title="{{ .Date.Format "2006-01-02" }}: {{ .Count }} commits"> 312 - </div> 313 - </div> 314 - {{ end }} 315 - </div> 316 - </div> 317 - </div> 318 - {{ end }}
+7 -18
appview/pages/templates/user/repos.html
··· 1 1 {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท repos {{ end }} 2 2 3 - {{ define "extrameta" }} 4 - <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}'s repos" /> 5 - <meta property="og:type" content="object" /> 6 - <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}?tab=repos" /> 7 - <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 8 - {{ end }} 9 - 10 - {{ define "content" }} 11 - <div class="grid grid-cols-1 md:grid-cols-11 gap-4"> 12 - <div class="md:col-span-3 order-1 md:order-1"> 13 - {{ template "user/fragments/profileCard" .Card }} 14 - </div> 15 - <div id="all-repos" class="md:col-span-8 order-2 md:order-2"> 16 - {{ block "ownRepos" . }}{{ end }} 17 - </div> 18 - </div> 3 + {{ define "profileContent" }} 4 + <div id="all-repos" class="md:col-span-8 order-2 md:order-2"> 5 + {{ block "ownRepos" . }}{{ end }} 6 + </div> 19 7 {{ end }} 20 8 21 9 {{ define "ownRepos" }} 22 - <p class="text-sm font-bold p-2 dark:text-white">ALL REPOSITORIES</p> 23 10 <div id="repos" class="grid grid-cols-1 gap-4 mb-6"> 24 11 {{ range .Repos }} 25 - {{ template "user/fragments/repoCard" (list $ . false) }} 12 + <div class="border border-gray-200 dark:border-gray-700 rounded-sm"> 13 + {{ template "user/fragments/repoCard" (list $ . false) }} 14 + </div> 26 15 {{ else }} 27 16 <p class="px-6 dark:text-white">This user does not have any repos yet.</p> 28 17 {{ end }}
+19
appview/pages/templates/user/starred.html
··· 1 + {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท repos {{ end }} 2 + 3 + {{ define "profileContent" }} 4 + <div id="all-repos" class="md:col-span-8 order-2 md:order-2"> 5 + {{ block "starredRepos" . }}{{ end }} 6 + </div> 7 + {{ end }} 8 + 9 + {{ define "starredRepos" }} 10 + <div id="repos" class="grid grid-cols-1 gap-4 mb-6"> 11 + {{ range .Repos }} 12 + <div class="border border-gray-200 dark:border-gray-700 rounded-sm"> 13 + {{ template "user/fragments/repoCard" (list $ . true) }} 14 + </div> 15 + {{ else }} 16 + <p class="px-6 dark:text-white">This user does not have any starred repos yet.</p> 17 + {{ end }} 18 + </div> 19 + {{ end }}
+45
appview/pages/templates/user/strings.html
··· 1 + {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท strings {{ end }} 2 + 3 + {{ define "profileContent" }} 4 + <div id="all-strings" class="md:col-span-8 order-2 md:order-2"> 5 + {{ block "allStrings" . }}{{ end }} 6 + </div> 7 + {{ end }} 8 + 9 + {{ define "allStrings" }} 10 + <div id="strings" class="grid grid-cols-1 gap-4 mb-6"> 11 + {{ range .Strings }} 12 + <div class="border border-gray-200 dark:border-gray-700 rounded-sm"> 13 + {{ template "singleString" (list $ .) }} 14 + </div> 15 + {{ else }} 16 + <p class="px-6 dark:text-white">This user does not have any strings yet.</p> 17 + {{ end }} 18 + </div> 19 + {{ end }} 20 + 21 + {{ define "singleString" }} 22 + {{ $root := index . 0 }} 23 + {{ $s := index . 1 }} 24 + <div class="py-4 px-6 rounded bg-white dark:bg-gray-800"> 25 + <div class="font-medium dark:text-white flex gap-2 items-center"> 26 + <a href="/strings/{{ or $root.Card.UserHandle $root.Card.UserDid }}/{{ $s.Rkey }}">{{ $s.Filename }}</a> 27 + </div> 28 + {{ with $s.Description }} 29 + <div class="text-gray-600 dark:text-gray-300 text-sm"> 30 + {{ . }} 31 + </div> 32 + {{ end }} 33 + 34 + {{ $stat := $s.Stats }} 35 + <div class="text-gray-400 pt-4 text-sm font-mono inline-flex gap-2 mt-auto"> 36 + <span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span> 37 + <span class="select-none [&:before]:content-['ยท']"></span> 38 + {{ with $s.Edited }} 39 + <span>edited {{ template "repo/fragments/shortTimeAgo" . }}</span> 40 + {{ else }} 41 + {{ template "repo/fragments/shortTimeAgo" $s.Created }} 42 + {{ end }} 43 + </div> 44 + </div> 45 + {{ end }}
+191 -132
appview/state/profile.go
··· 17 17 "github.com/gorilla/feeds" 18 18 "tangled.sh/tangled.sh/core/api/tangled" 19 19 "tangled.sh/tangled.sh/core/appview/db" 20 - "tangled.sh/tangled.sh/core/appview/oauth" 20 + // "tangled.sh/tangled.sh/core/appview/oauth" 21 21 "tangled.sh/tangled.sh/core/appview/pages" 22 22 ) 23 23 24 24 func (s *State) Profile(w http.ResponseWriter, r *http.Request) { 25 25 tabVal := r.URL.Query().Get("tab") 26 26 switch tabVal { 27 - case "": 28 - s.profileHomePage(w, r) 29 27 case "repos": 30 28 s.reposPage(w, r) 31 29 case "followers": 32 30 s.followersPage(w, r) 33 31 case "following": 34 32 s.followingPage(w, r) 33 + case "starred": 34 + s.starredPage(w, r) 35 + case "strings": 36 + s.stringsPage(w, r) 37 + default: 38 + s.profileOverview(w, r) 35 39 } 36 40 } 37 41 38 - type ProfilePageParams struct { 39 - Id identity.Identity 40 - LoggedInUser *oauth.User 41 - Card pages.ProfileCard 42 - } 43 - 44 - func (s *State) profilePage(w http.ResponseWriter, r *http.Request) *ProfilePageParams { 42 + func (s *State) profile(r *http.Request) (*pages.ProfileCard, error) { 45 43 didOrHandle := chi.URLParam(r, "user") 46 44 if didOrHandle == "" { 47 - http.Error(w, "bad request", http.StatusBadRequest) 48 - return nil 45 + return nil, fmt.Errorf("empty DID or handle") 49 46 } 50 47 51 48 ident, ok := r.Context().Value("resolvedId").(identity.Identity) 52 49 if !ok { 53 - log.Printf("malformed middleware") 54 - w.WriteHeader(http.StatusInternalServerError) 55 - return nil 50 + return nil, fmt.Errorf("failed to resolve ID") 56 51 } 57 52 did := ident.DID.String() 58 53 59 54 profile, err := db.GetProfile(s.db, did) 60 55 if err != nil { 61 - log.Printf("getting profile data for %s: %s", did, err) 62 - s.pages.Error500(w) 63 - return nil 56 + return nil, fmt.Errorf("failed to get profile: %w", err) 57 + } 58 + 59 + repoCount, err := db.CountRepos(s.db, db.FilterEq("did", did)) 60 + if err != nil { 61 + return nil, fmt.Errorf("failed to get repo count: %w", err) 62 + } 63 + 64 + stringCount, err := db.CountStrings(s.db, db.FilterEq("did", did)) 65 + if err != nil { 66 + return nil, fmt.Errorf("failed to get string count: %w", err) 67 + } 68 + 69 + starredCount, err := db.CountStars(s.db, db.FilterEq("starred_by_did", did)) 70 + if err != nil { 71 + return nil, fmt.Errorf("failed to get starred repo count: %w", err) 64 72 } 65 73 66 74 followStats, err := db.GetFollowerFollowingCount(s.db, did) 67 75 if err != nil { 68 - log.Printf("getting follow stats for %s: %s", did, err) 76 + return nil, fmt.Errorf("failed to get follower stats: %w", err) 69 77 } 70 78 71 79 loggedInUser := s.oauth.GetUser(r) ··· 74 82 followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, did) 75 83 } 76 84 77 - return &ProfilePageParams{ 78 - Id: ident, 79 - LoggedInUser: loggedInUser, 80 - Card: pages.ProfileCard{ 81 - UserDid: did, 82 - UserHandle: ident.Handle.String(), 83 - Profile: profile, 84 - FollowStatus: followStatus, 85 + now := time.Now() 86 + startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC) 87 + punchcard, err := db.MakePunchcard( 88 + s.db, 89 + db.FilterEq("did", did), 90 + db.FilterGte("date", startOfYear.Format(time.DateOnly)), 91 + db.FilterLte("date", now.Format(time.DateOnly)), 92 + ) 93 + if err != nil { 94 + return nil, fmt.Errorf("failed to get punchcard for %s: %w", did, err) 95 + } 96 + 97 + return &pages.ProfileCard{ 98 + UserDid: did, 99 + UserHandle: ident.Handle.String(), 100 + Profile: profile, 101 + FollowStatus: followStatus, 102 + Stats: pages.ProfileStats{ 103 + RepoCount: repoCount, 104 + StringCount: stringCount, 105 + StarredCount: starredCount, 85 106 FollowersCount: followStats.Followers, 86 107 FollowingCount: followStats.Following, 87 108 }, 88 - } 109 + Punchcard: punchcard, 110 + }, nil 89 111 } 90 112 91 - func (s *State) profileHomePage(w http.ResponseWriter, r *http.Request) { 92 - pageWithProfile := s.profilePage(w, r) 93 - if pageWithProfile == nil { 113 + func (s *State) profileOverview(w http.ResponseWriter, r *http.Request) { 114 + l := s.logger.With("handler", "profileHomePage") 115 + 116 + profile, err := s.profile(r) 117 + if err != nil { 118 + l.Error("failed to build profile card", "err", err) 119 + s.pages.Error500(w) 94 120 return 95 121 } 122 + l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 96 123 97 - id := pageWithProfile.Id 98 124 repos, err := db.GetRepos( 99 125 s.db, 100 126 0, 101 - db.FilterEq("did", id.DID), 127 + db.FilterEq("did", profile.UserDid), 102 128 ) 103 129 if err != nil { 104 - log.Printf("getting repos for %s: %s", id.DID, err) 130 + l.Error("failed to fetch repos", "err", err) 105 131 } 106 132 107 - profile := pageWithProfile.Card.Profile 108 133 // filter out ones that are pinned 109 134 pinnedRepos := []db.Repo{} 110 135 for i, r := range repos { 111 136 // if this is a pinned repo, add it 112 - if slices.Contains(profile.PinnedRepos[:], r.RepoAt()) { 137 + if slices.Contains(profile.Profile.PinnedRepos[:], r.RepoAt()) { 113 138 pinnedRepos = append(pinnedRepos, r) 114 139 } 115 140 116 141 // if there are no saved pins, add the first 4 repos 117 - if profile.IsPinnedReposEmpty() && i < 4 { 142 + if profile.Profile.IsPinnedReposEmpty() && i < 4 { 118 143 pinnedRepos = append(pinnedRepos, r) 119 144 } 120 145 } 121 146 122 - collaboratingRepos, err := db.CollaboratingIn(s.db, id.DID.String()) 147 + collaboratingRepos, err := db.CollaboratingIn(s.db, profile.UserDid) 123 148 if err != nil { 124 - log.Printf("getting collaborating repos for %s: %s", id.DID, err) 149 + l.Error("failed to fetch collaborating repos", "err", err) 125 150 } 126 151 127 152 pinnedCollaboratingRepos := []db.Repo{} 128 153 for _, r := range collaboratingRepos { 129 154 // if this is a pinned repo, add it 130 - if slices.Contains(profile.PinnedRepos[:], r.RepoAt()) { 155 + if slices.Contains(profile.Profile.PinnedRepos[:], r.RepoAt()) { 131 156 pinnedCollaboratingRepos = append(pinnedCollaboratingRepos, r) 132 157 } 133 158 } 134 159 135 - timeline, err := db.MakeProfileTimeline(s.db, id.DID.String()) 160 + timeline, err := db.MakeProfileTimeline(s.db, profile.UserDid) 136 161 if err != nil { 137 - log.Printf("failed to create profile timeline for %s: %s", id.DID, err) 162 + l.Error("failed to create timeline", "err", err) 138 163 } 139 164 140 - var didsToResolve []string 141 - for _, r := range collaboratingRepos { 142 - didsToResolve = append(didsToResolve, r.Did) 143 - } 144 - for _, byMonth := range timeline.ByMonth { 145 - for _, pe := range byMonth.PullEvents.Items { 146 - didsToResolve = append(didsToResolve, pe.Repo.Did) 147 - } 148 - for _, ie := range byMonth.IssueEvents.Items { 149 - didsToResolve = append(didsToResolve, ie.Metadata.Repo.Did) 150 - } 151 - for _, re := range byMonth.RepoEvents { 152 - didsToResolve = append(didsToResolve, re.Repo.Did) 153 - if re.Source != nil { 154 - didsToResolve = append(didsToResolve, re.Source.Did) 155 - } 156 - } 165 + s.pages.ProfileOverview(w, pages.ProfileOverviewParams{ 166 + LoggedInUser: s.oauth.GetUser(r), 167 + Card: profile, 168 + Repos: pinnedRepos, 169 + CollaboratingRepos: pinnedCollaboratingRepos, 170 + ProfileTimeline: timeline, 171 + }) 172 + } 173 + 174 + func (s *State) reposPage(w http.ResponseWriter, r *http.Request) { 175 + l := s.logger.With("handler", "reposPage") 176 + 177 + profile, err := s.profile(r) 178 + if err != nil { 179 + l.Error("failed to build profile card", "err", err) 180 + s.pages.Error500(w) 181 + return 157 182 } 183 + l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 158 184 159 - now := time.Now() 160 - startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC) 161 - punchcard, err := db.MakePunchcard( 185 + repos, err := db.GetRepos( 162 186 s.db, 163 - db.FilterEq("did", id.DID), 164 - db.FilterGte("date", startOfYear.Format(time.DateOnly)), 165 - db.FilterLte("date", now.Format(time.DateOnly)), 187 + 0, 188 + db.FilterEq("did", profile.UserDid), 166 189 ) 167 190 if err != nil { 168 - log.Println("failed to get punchcard for did", "did", id.DID, "err", err) 191 + l.Error("failed to get repos", "err", err) 192 + s.pages.Error500(w) 193 + return 169 194 } 170 195 171 - s.pages.ProfileHomePage(w, pages.ProfileHomePageParams{ 172 - LoggedInUser: pageWithProfile.LoggedInUser, 173 - Repos: pinnedRepos, 174 - CollaboratingRepos: pinnedCollaboratingRepos, 175 - Card: pageWithProfile.Card, 176 - Punchcard: punchcard, 177 - ProfileTimeline: timeline, 196 + err = s.pages.ProfileRepos(w, pages.ProfileReposParams{ 197 + LoggedInUser: s.oauth.GetUser(r), 198 + Repos: repos, 199 + Card: profile, 178 200 }) 179 201 } 180 202 181 - func (s *State) reposPage(w http.ResponseWriter, r *http.Request) { 182 - pageWithProfile := s.profilePage(w, r) 183 - if pageWithProfile == nil { 203 + func (s *State) starredPage(w http.ResponseWriter, r *http.Request) { 204 + l := s.logger.With("handler", "starredPage") 205 + 206 + profile, err := s.profile(r) 207 + if err != nil { 208 + l.Error("failed to build profile card", "err", err) 209 + s.pages.Error500(w) 210 + return 211 + } 212 + l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 213 + 214 + stars, err := db.GetStars(s.db, 0, db.FilterEq("starred_by_did", profile.UserDid)) 215 + if err != nil { 216 + l.Error("failed to get stars", "err", err) 217 + s.pages.Error500(w) 184 218 return 185 219 } 220 + var repoAts []string 221 + for _, s := range stars { 222 + repoAts = append(repoAts, string(s.RepoAt)) 223 + } 186 224 187 - id := pageWithProfile.Id 188 225 repos, err := db.GetRepos( 189 226 s.db, 190 227 0, 191 - db.FilterEq("did", id.DID), 228 + db.FilterIn("at_uri", repoAts), 192 229 ) 193 230 if err != nil { 194 - log.Printf("getting repos for %s: %s", id.DID, err) 231 + l.Error("failed to get repos", "err", err) 232 + s.pages.Error500(w) 233 + return 195 234 } 196 235 197 - s.pages.ReposPage(w, pages.ReposPageParams{ 198 - LoggedInUser: pageWithProfile.LoggedInUser, 236 + err = s.pages.ProfileStarred(w, pages.ProfileStarredParams{ 237 + LoggedInUser: s.oauth.GetUser(r), 199 238 Repos: repos, 200 - Card: pageWithProfile.Card, 239 + Card: profile, 240 + }) 241 + } 242 + 243 + func (s *State) stringsPage(w http.ResponseWriter, r *http.Request) { 244 + l := s.logger.With("handler", "stringsPage") 245 + 246 + profile, err := s.profile(r) 247 + if err != nil { 248 + l.Error("failed to build profile card", "err", err) 249 + s.pages.Error500(w) 250 + return 251 + } 252 + l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 253 + 254 + strings, err := db.GetStrings(s.db, 0, db.FilterEq("did", profile.UserDid)) 255 + if err != nil { 256 + l.Error("failed to get strings", "err", err) 257 + s.pages.Error500(w) 258 + return 259 + } 260 + 261 + err = s.pages.ProfileStrings(w, pages.ProfileStringsParams{ 262 + LoggedInUser: s.oauth.GetUser(r), 263 + Strings: strings, 264 + Card: profile, 201 265 }) 202 266 } 203 267 204 268 type FollowsPageParams struct { 205 - LoggedInUser *oauth.User 206 - Follows []pages.FollowCard 207 - Card pages.ProfileCard 269 + Follows []pages.FollowCard 270 + Card *pages.ProfileCard 208 271 } 209 272 210 - func (s *State) followPage(w http.ResponseWriter, r *http.Request, fetchFollows func(db.Execer, string) ([]db.Follow, error), extractDid func(db.Follow) string) (FollowsPageParams, error) { 211 - pageWithProfile := s.profilePage(w, r) 212 - if pageWithProfile == nil { 213 - return FollowsPageParams{}, nil 273 + func (s *State) followPage( 274 + r *http.Request, 275 + fetchFollows func(db.Execer, string) ([]db.Follow, error), 276 + extractDid func(db.Follow) string, 277 + ) (*FollowsPageParams, error) { 278 + l := s.logger.With("handler", "reposPage") 279 + 280 + profile, err := s.profile(r) 281 + if err != nil { 282 + return nil, err 214 283 } 284 + l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 215 285 216 - id := pageWithProfile.Id 217 - loggedInUser := pageWithProfile.LoggedInUser 286 + loggedInUser := s.oauth.GetUser(r) 218 287 219 - follows, err := fetchFollows(s.db, id.DID.String()) 288 + follows, err := fetchFollows(s.db, profile.UserDid) 220 289 if err != nil { 221 - log.Printf("getting followers for %s: %s", id.DID, err) 222 - return FollowsPageParams{}, err 290 + l.Error("failed to fetch follows", "err", err) 291 + return nil, err 223 292 } 224 293 225 294 if len(follows) == 0 { 226 - return FollowsPageParams{ 227 - LoggedInUser: loggedInUser, 228 - Follows: []pages.FollowCard{}, 229 - Card: pageWithProfile.Card, 230 - }, nil 295 + return nil, nil 231 296 } 232 297 233 298 followDids := make([]string, 0, len(follows)) ··· 237 302 238 303 profiles, err := db.GetProfiles(s.db, db.FilterIn("did", followDids)) 239 304 if err != nil { 240 - log.Printf("getting profile for %s: %s", followDids, err) 241 - return FollowsPageParams{}, err 305 + l.Error("failed to get profiles", "followDids", followDids, "err", err) 306 + return nil, err 242 307 } 243 308 244 309 followStatsMap, err := db.GetFollowerFollowingCounts(s.db, followDids) ··· 246 311 log.Printf("getting follow counts for %s: %s", followDids, err) 247 312 } 248 313 249 - var loggedInUserFollowing map[string]struct{} 314 + loggedInUserFollowing := make(map[string]struct{}) 250 315 if loggedInUser != nil { 251 316 following, err := db.GetFollowing(s.db, loggedInUser.Did) 252 317 if err != nil { 253 - return FollowsPageParams{}, err 318 + l.Error("failed to get follow list", "err", err, "loggedInUser", loggedInUser.Did) 319 + return nil, err 254 320 } 255 - if len(following) > 0 { 256 - loggedInUserFollowing = make(map[string]struct{}, len(following)) 257 - for _, follow := range following { 258 - loggedInUserFollowing[follow.SubjectDid] = struct{}{} 259 - } 321 + loggedInUserFollowing = make(map[string]struct{}, len(following)) 322 + for _, follow := range following { 323 + loggedInUserFollowing[follow.SubjectDid] = struct{}{} 260 324 } 261 325 } 262 326 263 - followCards := make([]pages.FollowCard, 0, len(follows)) 264 - for _, did := range followDids { 265 - followStats, exists := followStatsMap[did] 266 - if !exists { 267 - followStats = db.FollowStats{} 268 - } 327 + followCards := make([]pages.FollowCard, len(follows)) 328 + for i, did := range followDids { 329 + followStats := followStatsMap[did] 269 330 followStatus := db.IsNotFollowing 270 - if loggedInUserFollowing != nil { 271 - if _, exists := loggedInUserFollowing[did]; exists { 272 - followStatus = db.IsFollowing 273 - } else if loggedInUser.Did == did { 274 - followStatus = db.IsSelf 275 - } 331 + if _, exists := loggedInUserFollowing[did]; exists { 332 + followStatus = db.IsFollowing 333 + } else if loggedInUser != nil && loggedInUser.Did == did { 334 + followStatus = db.IsSelf 276 335 } 336 + 277 337 var profile *db.Profile 278 338 if p, exists := profiles[did]; exists { 279 339 profile = p ··· 281 341 profile = &db.Profile{} 282 342 profile.Did = did 283 343 } 284 - followCards = append(followCards, pages.FollowCard{ 344 + followCards[i] = pages.FollowCard{ 285 345 UserDid: did, 286 346 FollowStatus: followStatus, 287 347 FollowersCount: followStats.Followers, 288 348 FollowingCount: followStats.Following, 289 349 Profile: profile, 290 - }) 350 + } 291 351 } 292 352 293 - return FollowsPageParams{ 294 - LoggedInUser: loggedInUser, 295 - Follows: followCards, 296 - Card: pageWithProfile.Card, 353 + return &FollowsPageParams{ 354 + Follows: followCards, 355 + Card: profile, 297 356 }, nil 298 357 } 299 358 300 359 func (s *State) followersPage(w http.ResponseWriter, r *http.Request) { 301 - followPage, err := s.followPage(w, r, db.GetFollowers, func(f db.Follow) string { return f.UserDid }) 360 + followPage, err := s.followPage(r, db.GetFollowers, func(f db.Follow) string { return f.UserDid }) 302 361 if err != nil { 303 362 s.pages.Notice(w, "all-followers", "Failed to load followers") 304 363 return 305 364 } 306 365 307 - s.pages.FollowersPage(w, pages.FollowersPageParams{ 308 - LoggedInUser: followPage.LoggedInUser, 366 + s.pages.ProfileFollowers(w, pages.ProfileFollowersParams{ 367 + LoggedInUser: s.oauth.GetUser(r), 309 368 Followers: followPage.Follows, 310 369 Card: followPage.Card, 311 370 }) 312 371 } 313 372 314 373 func (s *State) followingPage(w http.ResponseWriter, r *http.Request) { 315 - followPage, err := s.followPage(w, r, db.GetFollowing, func(f db.Follow) string { return f.SubjectDid }) 374 + followPage, err := s.followPage(r, db.GetFollowing, func(f db.Follow) string { return f.SubjectDid }) 316 375 if err != nil { 317 376 s.pages.Notice(w, "all-following", "Failed to load following") 318 377 return 319 378 } 320 379 321 - s.pages.FollowingPage(w, pages.FollowingPageParams{ 322 - LoggedInUser: followPage.LoggedInUser, 380 + s.pages.ProfileFollowing(w, pages.ProfileFollowingParams{ 381 + LoggedInUser: s.oauth.GetUser(r), 323 382 Following: followPage.Follows, 324 383 Card: followPage.Card, 325 384 })
+1 -59
appview/strings/strings.go
··· 5 5 "log/slog" 6 6 "net/http" 7 7 "path" 8 - "slices" 9 8 "strconv" 10 9 "time" 11 10 ··· 161 160 } 162 161 163 162 func (s *Strings) dashboard(w http.ResponseWriter, r *http.Request) { 164 - l := s.Logger.With("handler", "dashboard") 165 - 166 - id, ok := r.Context().Value("resolvedId").(identity.Identity) 167 - if !ok { 168 - l.Error("malformed middleware") 169 - w.WriteHeader(http.StatusInternalServerError) 170 - return 171 - } 172 - l = l.With("did", id.DID, "handle", id.Handle) 173 - 174 - all, err := db.GetStrings( 175 - s.Db, 176 - 0, 177 - db.FilterEq("did", id.DID), 178 - ) 179 - if err != nil { 180 - l.Error("failed to fetch strings", "err", err) 181 - w.WriteHeader(http.StatusInternalServerError) 182 - return 183 - } 184 - 185 - slices.SortFunc(all, func(a, b db.String) int { 186 - if a.Created.After(b.Created) { 187 - return -1 188 - } else { 189 - return 1 190 - } 191 - }) 192 - 193 - profile, err := db.GetProfile(s.Db, id.DID.String()) 194 - if err != nil { 195 - l.Error("failed to fetch user profile", "err", err) 196 - w.WriteHeader(http.StatusInternalServerError) 197 - return 198 - } 199 - loggedInUser := s.OAuth.GetUser(r) 200 - followStatus := db.IsNotFollowing 201 - if loggedInUser != nil { 202 - followStatus = db.GetFollowStatus(s.Db, loggedInUser.Did, id.DID.String()) 203 - } 204 - 205 - followStats, err := db.GetFollowerFollowingCount(s.Db, id.DID.String()) 206 - if err != nil { 207 - l.Error("failed to get follow stats", "err", err) 208 - } 209 - 210 - s.Pages.StringsDashboard(w, pages.StringsDashboardParams{ 211 - LoggedInUser: s.OAuth.GetUser(r), 212 - Card: pages.ProfileCard{ 213 - UserDid: id.DID.String(), 214 - UserHandle: id.Handle.String(), 215 - Profile: profile, 216 - FollowStatus: followStatus, 217 - FollowersCount: followStats.Followers, 218 - FollowingCount: followStats.Following, 219 - }, 220 - Strings: all, 221 - }) 163 + http.Redirect(w, r, fmt.Sprintf("/%s?tab=strings", chi.URLParam(r, "user")), http.StatusFound) 222 164 } 223 165 224 166 func (s *Strings) edit(w http.ResponseWriter, r *http.Request) {
+53 -12
docs/hacking.md
··· 48 48 redis-server 49 49 ``` 50 50 51 - ## running a knot 51 + ## running knots and spindles 52 52 53 53 An end-to-end knot setup requires setting up a machine with 54 54 `sshd`, `AuthorizedKeysCommand`, and git user, which is 55 55 quite cumbersome. So the nix flake provides a 56 56 `nixosConfiguration` to do so. 57 57 58 - To begin, grab your DID from http://localhost:3000/settings. 59 - Then, set `TANGLED_VM_KNOT_OWNER` and 60 - `TANGLED_VM_SPINDLE_OWNER` to your DID. 58 + <details> 59 + <summary><strong>MacOS users will have to setup a Nix Builder first</strong></summary> 60 + 61 + In order to build Tangled's dev VM on macOS, you will 62 + first need to set up a Linux Nix builder. The recommended 63 + way to do so is to run a [`darwin.linux-builder` 64 + VM](https://nixos.org/manual/nixpkgs/unstable/#sec-darwin-builder) 65 + and to register it in `nix.conf` as a builder for Linux 66 + with the same architecture as your Mac (`linux-aarch64` if 67 + you are using Apple Silicon). 68 + 69 + > IMPORTANT: You must build `darwin.linux-builder` somewhere other than inside 70 + > the tangled repo so that it doesn't conflict with the other VM. For example, 71 + > you can do 72 + > 73 + > ```shell 74 + > cd $(mktemp -d buildervm.XXXXX) && nix run nixpkgs#darwin.linux-builder 75 + > ``` 76 + > 77 + > to store the builder VM in a temporary dir. 78 + > 79 + > You should read and follow [all the other intructions][darwin builder vm] to 80 + > avoid subtle problems. 81 + 82 + Alternatively, you can use any other method to set up a 83 + Linux machine with `nix` installed that you can `sudo ssh` 84 + into (in other words, root user on your Mac has to be able 85 + to ssh into the Linux machine without entering a password) 86 + and that has the same architecture as your Mac. See 87 + [remote builder 88 + instructions](https://nix.dev/manual/nix/2.28/advanced-topics/distributed-builds.html#requirements) 89 + for how to register such a builder in `nix.conf`. 61 90 62 - If you don't want to [set up a spindle](#running-a-spindle), 63 - you can use any placeholder value. 91 + > WARNING: If you'd like to use 92 + > [`nixos-lima`](https://github.com/nixos-lima/nixos-lima) or 93 + > [Orbstack](https://orbstack.dev/), note that setting them up so that `sudo 94 + > ssh` works can be tricky. It seems to be [possible with 95 + > Orbstack](https://github.com/orgs/orbstack/discussions/1669). 64 96 65 - You can now start a lightweight NixOS VM like so: 97 + </details> 98 + 99 + To begin, grab your DID from http://localhost:3000/settings. 100 + Then, set `TANGLED_VM_KNOT_OWNER` and 101 + `TANGLED_VM_SPINDLE_OWNER` to your DID. You can now start a 102 + lightweight NixOS VM like so: 66 103 67 104 ```bash 68 105 nix run --impure .#vm ··· 74 111 with `ssh` exposed on port 2222. 75 112 76 113 Once the services are running, head to 77 - http://localhost:3000/knots and hit verify (and similarly, 78 - http://localhost:3000/spindles to verify your spindle). It 79 - should verify the ownership of the services instantly if 80 - everything went smoothly. 114 + http://localhost:3000/knots and hit verify. It should 115 + verify the ownership of the services instantly if everything 116 + went smoothly. 81 117 82 118 You can push repositories to this VM with this ssh config 83 119 block on your main machine: ··· 97 133 git push local-dev main 98 134 ``` 99 135 100 - ## running a spindle 136 + ### running a spindle 101 137 102 138 The above VM should already be running a spindle on 103 139 `localhost:6555`. Head to http://localhost:3000/spindles and ··· 119 155 # litecli has a nicer REPL interface: 120 156 litecli /var/lib/spindle/spindle.db 121 157 ``` 158 + 159 + If for any reason you wish to disable either one of the 160 + services in the VM, modify [nix/vm.nix](/nix/vm.nix) and set 161 + `services.tangled-spindle.enable` (or 162 + `services.tangled-knot.enable`) to `false`.