forked from tangled.org/core
this repo has no description
1package pages 2 3import ( 4 "bytes" 5 "crypto/sha256" 6 "embed" 7 "encoding/hex" 8 "fmt" 9 "html/template" 10 "io" 11 "io/fs" 12 "log" 13 "net/http" 14 "os" 15 "path" 16 "path/filepath" 17 "slices" 18 "strings" 19 20 "tangled.sh/tangled.sh/core/appview/auth" 21 "tangled.sh/tangled.sh/core/appview/db" 22 "tangled.sh/tangled.sh/core/appview/pages/markup" 23 "tangled.sh/tangled.sh/core/appview/pagination" 24 "tangled.sh/tangled.sh/core/appview/state/userutil" 25 "tangled.sh/tangled.sh/core/patchutil" 26 "tangled.sh/tangled.sh/core/types" 27 28 "github.com/alecthomas/chroma/v2" 29 chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 30 "github.com/alecthomas/chroma/v2/lexers" 31 "github.com/alecthomas/chroma/v2/styles" 32 "github.com/bluesky-social/indigo/atproto/syntax" 33 "github.com/microcosm-cc/bluemonday" 34) 35 36//go:embed templates/* static 37var Files embed.FS 38 39type Pages struct { 40 t map[string]*template.Template 41 dev bool 42 embedFS embed.FS 43 templateDir string // Path to templates on disk for dev mode 44} 45 46func NewPages(dev bool) *Pages { 47 p := &Pages{ 48 t: make(map[string]*template.Template), 49 dev: dev, 50 embedFS: Files, 51 templateDir: "appview/pages", 52 } 53 54 // Initial load of all templates 55 p.loadAllTemplates() 56 57 return p 58} 59 60func (p *Pages) loadAllTemplates() { 61 templates := make(map[string]*template.Template) 62 var fragmentPaths []string 63 64 // Use embedded FS for initial loading 65 // First, collect all fragment paths 66 err := fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error { 67 if err != nil { 68 return err 69 } 70 if d.IsDir() { 71 return nil 72 } 73 if !strings.HasSuffix(path, ".html") { 74 return nil 75 } 76 if !strings.Contains(path, "fragments/") { 77 return nil 78 } 79 name := strings.TrimPrefix(path, "templates/") 80 name = strings.TrimSuffix(name, ".html") 81 tmpl, err := template.New(name). 82 Funcs(funcMap()). 83 ParseFS(p.embedFS, path) 84 if err != nil { 85 log.Fatalf("setting up fragment: %v", err) 86 } 87 templates[name] = tmpl 88 fragmentPaths = append(fragmentPaths, path) 89 log.Printf("loaded fragment: %s", name) 90 return nil 91 }) 92 if err != nil { 93 log.Fatalf("walking template dir for fragments: %v", err) 94 } 95 96 // Then walk through and setup the rest of the templates 97 err = fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error { 98 if err != nil { 99 return err 100 } 101 if d.IsDir() { 102 return nil 103 } 104 if !strings.HasSuffix(path, "html") { 105 return nil 106 } 107 // Skip fragments as they've already been loaded 108 if strings.Contains(path, "fragments/") { 109 return nil 110 } 111 // Skip layouts 112 if strings.Contains(path, "layouts/") { 113 return nil 114 } 115 name := strings.TrimPrefix(path, "templates/") 116 name = strings.TrimSuffix(name, ".html") 117 // Add the page template on top of the base 118 allPaths := []string{} 119 allPaths = append(allPaths, "templates/layouts/*.html") 120 allPaths = append(allPaths, fragmentPaths...) 121 allPaths = append(allPaths, path) 122 tmpl, err := template.New(name). 123 Funcs(funcMap()). 124 ParseFS(p.embedFS, allPaths...) 125 if err != nil { 126 return fmt.Errorf("setting up template: %w", err) 127 } 128 templates[name] = tmpl 129 log.Printf("loaded template: %s", name) 130 return nil 131 }) 132 if err != nil { 133 log.Fatalf("walking template dir: %v", err) 134 } 135 136 log.Printf("total templates loaded: %d", len(templates)) 137 p.t = templates 138} 139 140// loadTemplateFromDisk loads a template from the filesystem in dev mode 141func (p *Pages) loadTemplateFromDisk(name string) error { 142 if !p.dev { 143 return nil 144 } 145 146 log.Printf("reloading template from disk: %s", name) 147 148 // Find all fragments first 149 var fragmentPaths []string 150 err := filepath.WalkDir(filepath.Join(p.templateDir, "templates"), func(path string, d fs.DirEntry, err error) error { 151 if err != nil { 152 return err 153 } 154 if d.IsDir() { 155 return nil 156 } 157 if !strings.HasSuffix(path, ".html") { 158 return nil 159 } 160 if !strings.Contains(path, "fragments/") { 161 return nil 162 } 163 fragmentPaths = append(fragmentPaths, path) 164 return nil 165 }) 166 if err != nil { 167 return fmt.Errorf("walking disk template dir for fragments: %w", err) 168 } 169 170 // Find the template path on disk 171 templatePath := filepath.Join(p.templateDir, "templates", name+".html") 172 if _, err := os.Stat(templatePath); os.IsNotExist(err) { 173 return fmt.Errorf("template not found on disk: %s", name) 174 } 175 176 // Create a new template 177 tmpl := template.New(name).Funcs(funcMap()) 178 179 // Parse layouts 180 layoutGlob := filepath.Join(p.templateDir, "templates", "layouts", "*.html") 181 layouts, err := filepath.Glob(layoutGlob) 182 if err != nil { 183 return fmt.Errorf("finding layout templates: %w", err) 184 } 185 186 // Create paths for parsing 187 allFiles := append(layouts, fragmentPaths...) 188 allFiles = append(allFiles, templatePath) 189 190 // Parse all templates 191 tmpl, err = tmpl.ParseFiles(allFiles...) 192 if err != nil { 193 return fmt.Errorf("parsing template files: %w", err) 194 } 195 196 // Update the template in the map 197 p.t[name] = tmpl 198 log.Printf("template reloaded from disk: %s", name) 199 return nil 200} 201 202func (p *Pages) executeOrReload(templateName string, w io.Writer, base string, params any) error { 203 // In dev mode, reload the template from disk before executing 204 if p.dev { 205 if err := p.loadTemplateFromDisk(templateName); err != nil { 206 log.Printf("warning: failed to reload template %s from disk: %v", templateName, err) 207 // Continue with the existing template 208 } 209 } 210 211 tmpl, exists := p.t[templateName] 212 if !exists { 213 return fmt.Errorf("template not found: %s", templateName) 214 } 215 216 if base == "" { 217 return tmpl.Execute(w, params) 218 } else { 219 return tmpl.ExecuteTemplate(w, base, params) 220 } 221} 222 223func (p *Pages) execute(name string, w io.Writer, params any) error { 224 return p.executeOrReload(name, w, "layouts/base", params) 225} 226 227func (p *Pages) executePlain(name string, w io.Writer, params any) error { 228 return p.executeOrReload(name, w, "", params) 229} 230 231func (p *Pages) executeRepo(name string, w io.Writer, params any) error { 232 return p.executeOrReload(name, w, "layouts/repobase", params) 233} 234 235type LoginParams struct { 236} 237 238func (p *Pages) Login(w io.Writer, params LoginParams) error { 239 return p.executePlain("user/login", w, params) 240} 241 242type TimelineParams struct { 243 LoggedInUser *auth.User 244 Timeline []db.TimelineEvent 245 DidHandleMap map[string]string 246} 247 248func (p *Pages) Timeline(w io.Writer, params TimelineParams) error { 249 return p.execute("timeline", w, params) 250} 251 252type SettingsParams struct { 253 LoggedInUser *auth.User 254 PubKeys []db.PublicKey 255 Emails []db.Email 256} 257 258func (p *Pages) Settings(w io.Writer, params SettingsParams) error { 259 return p.execute("settings", w, params) 260} 261 262type KnotsParams struct { 263 LoggedInUser *auth.User 264 Registrations []db.Registration 265} 266 267func (p *Pages) Knots(w io.Writer, params KnotsParams) error { 268 return p.execute("knots", w, params) 269} 270 271type KnotParams struct { 272 LoggedInUser *auth.User 273 DidHandleMap map[string]string 274 Registration *db.Registration 275 Members []string 276 IsOwner bool 277} 278 279func (p *Pages) Knot(w io.Writer, params KnotParams) error { 280 return p.execute("knot", w, params) 281} 282 283type NewRepoParams struct { 284 LoggedInUser *auth.User 285 Knots []string 286} 287 288func (p *Pages) NewRepo(w io.Writer, params NewRepoParams) error { 289 return p.execute("repo/new", w, params) 290} 291 292type ForkRepoParams struct { 293 LoggedInUser *auth.User 294 Knots []string 295 RepoInfo RepoInfo 296} 297 298func (p *Pages) ForkRepo(w io.Writer, params ForkRepoParams) error { 299 return p.execute("repo/fork", w, params) 300} 301 302type ProfilePageParams struct { 303 LoggedInUser *auth.User 304 UserDid string 305 UserHandle string 306 Repos []db.Repo 307 CollaboratingRepos []db.Repo 308 ProfileStats ProfileStats 309 FollowStatus db.FollowStatus 310 AvatarUri string 311 ProfileTimeline *db.ProfileTimeline 312 313 DidHandleMap map[string]string 314} 315 316type ProfileStats struct { 317 Followers int 318 Following int 319} 320 321func (p *Pages) ProfilePage(w io.Writer, params ProfilePageParams) error { 322 return p.execute("user/profile", w, params) 323} 324 325type FollowFragmentParams struct { 326 UserDid string 327 FollowStatus db.FollowStatus 328} 329 330func (p *Pages) FollowFragment(w io.Writer, params FollowFragmentParams) error { 331 return p.executePlain("user/fragments/follow", w, params) 332} 333 334type RepoActionsFragmentParams struct { 335 IsStarred bool 336 RepoAt syntax.ATURI 337 Stats db.RepoStats 338} 339 340func (p *Pages) RepoActionsFragment(w io.Writer, params RepoActionsFragmentParams) error { 341 return p.executePlain("repo/fragments/repoActions", w, params) 342} 343 344type RepoDescriptionParams struct { 345 RepoInfo RepoInfo 346} 347 348func (p *Pages) EditRepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error { 349 return p.executePlain("repo/fragments/editRepoDescription", w, params) 350} 351 352func (p *Pages) RepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error { 353 return p.executePlain("repo/fragments/repoDescription", w, params) 354} 355 356type RepoInfo struct { 357 Name string 358 OwnerDid string 359 OwnerHandle string 360 Description string 361 Knot string 362 RepoAt syntax.ATURI 363 IsStarred bool 364 Stats db.RepoStats 365 Roles RolesInRepo 366 Source *db.Repo 367 SourceHandle string 368 DisableFork bool 369} 370 371type RolesInRepo struct { 372 Roles []string 373} 374 375func (r RolesInRepo) SettingsAllowed() bool { 376 return slices.Contains(r.Roles, "repo:settings") 377} 378 379func (r RolesInRepo) CollaboratorInviteAllowed() bool { 380 return slices.Contains(r.Roles, "repo:invite") 381} 382 383func (r RolesInRepo) RepoDeleteAllowed() bool { 384 return slices.Contains(r.Roles, "repo:delete") 385} 386 387func (r RolesInRepo) IsOwner() bool { 388 return slices.Contains(r.Roles, "repo:owner") 389} 390 391func (r RolesInRepo) IsCollaborator() bool { 392 return slices.Contains(r.Roles, "repo:collaborator") 393} 394 395func (r RolesInRepo) IsPushAllowed() bool { 396 return slices.Contains(r.Roles, "repo:push") 397} 398 399func (r RepoInfo) OwnerWithAt() string { 400 if r.OwnerHandle != "" { 401 return fmt.Sprintf("@%s", r.OwnerHandle) 402 } else { 403 return r.OwnerDid 404 } 405} 406 407func (r RepoInfo) FullName() string { 408 return path.Join(r.OwnerWithAt(), r.Name) 409} 410 411func (r RepoInfo) OwnerWithoutAt() string { 412 if strings.HasPrefix(r.OwnerWithAt(), "@") { 413 return strings.TrimPrefix(r.OwnerWithAt(), "@") 414 } else { 415 return userutil.FlattenDid(r.OwnerDid) 416 } 417} 418 419func (r RepoInfo) FullNameWithoutAt() string { 420 return path.Join(r.OwnerWithoutAt(), r.Name) 421} 422 423func (r RepoInfo) GetTabs() [][]string { 424 tabs := [][]string{ 425 {"overview", "/", "square-chart-gantt"}, 426 {"issues", "/issues", "circle-dot"}, 427 {"pulls", "/pulls", "git-pull-request"}, 428 } 429 430 if r.Roles.SettingsAllowed() { 431 tabs = append(tabs, []string{"settings", "/settings", "cog"}) 432 } 433 434 return tabs 435} 436 437// each tab on a repo could have some metadata: 438// 439// issues -> number of open issues etc. 440// settings -> a warning icon to setup branch protection? idk 441// 442// we gather these bits of info here, because go templates 443// are difficult to program in 444func (r RepoInfo) TabMetadata() map[string]any { 445 meta := make(map[string]any) 446 447 if r.Stats.PullCount.Open > 0 { 448 meta["pulls"] = r.Stats.PullCount.Open 449 } 450 451 if r.Stats.IssueCount.Open > 0 { 452 meta["issues"] = r.Stats.IssueCount.Open 453 } 454 455 // more stuff? 456 457 return meta 458} 459 460type RepoIndexParams struct { 461 LoggedInUser *auth.User 462 RepoInfo RepoInfo 463 Active string 464 TagMap map[string][]string 465 types.RepoIndexResponse 466 HTMLReadme template.HTML 467 Raw bool 468 EmailToDidOrHandle map[string]string 469} 470 471func (p *Pages) RepoIndexPage(w io.Writer, params RepoIndexParams) error { 472 params.Active = "overview" 473 if params.IsEmpty { 474 return p.executeRepo("repo/empty", w, params) 475 } 476 477 if params.ReadmeFileName != "" { 478 var htmlString string 479 ext := filepath.Ext(params.ReadmeFileName) 480 switch ext { 481 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": 482 htmlString = markup.RenderMarkdown(params.Readme) 483 params.Raw = false 484 params.HTMLReadme = template.HTML(bluemonday.UGCPolicy().Sanitize(htmlString)) 485 default: 486 htmlString = string(params.Readme) 487 params.Raw = true 488 params.HTMLReadme = template.HTML(bluemonday.NewPolicy().Sanitize(htmlString)) 489 } 490 } 491 492 return p.executeRepo("repo/index", w, params) 493} 494 495type RepoLogParams struct { 496 LoggedInUser *auth.User 497 RepoInfo RepoInfo 498 types.RepoLogResponse 499 Active string 500 EmailToDidOrHandle map[string]string 501} 502 503func (p *Pages) RepoLog(w io.Writer, params RepoLogParams) error { 504 params.Active = "overview" 505 return p.execute("repo/log", w, params) 506} 507 508type RepoCommitParams struct { 509 LoggedInUser *auth.User 510 RepoInfo RepoInfo 511 Active string 512 EmailToDidOrHandle map[string]string 513 514 types.RepoCommitResponse 515} 516 517func (p *Pages) RepoCommit(w io.Writer, params RepoCommitParams) error { 518 params.Active = "overview" 519 return p.executeRepo("repo/commit", w, params) 520} 521 522type RepoTreeParams struct { 523 LoggedInUser *auth.User 524 RepoInfo RepoInfo 525 Active string 526 BreadCrumbs [][]string 527 BaseTreeLink string 528 BaseBlobLink string 529 types.RepoTreeResponse 530} 531 532type RepoTreeStats struct { 533 NumFolders uint64 534 NumFiles uint64 535} 536 537func (r RepoTreeParams) TreeStats() RepoTreeStats { 538 numFolders, numFiles := 0, 0 539 for _, f := range r.Files { 540 if !f.IsFile { 541 numFolders += 1 542 } else if f.IsFile { 543 numFiles += 1 544 } 545 } 546 547 return RepoTreeStats{ 548 NumFolders: uint64(numFolders), 549 NumFiles: uint64(numFiles), 550 } 551} 552 553func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error { 554 params.Active = "overview" 555 return p.execute("repo/tree", w, params) 556} 557 558type RepoBranchesParams struct { 559 LoggedInUser *auth.User 560 RepoInfo RepoInfo 561 types.RepoBranchesResponse 562} 563 564func (p *Pages) RepoBranches(w io.Writer, params RepoBranchesParams) error { 565 return p.executeRepo("repo/branches", w, params) 566} 567 568type RepoTagsParams struct { 569 LoggedInUser *auth.User 570 RepoInfo RepoInfo 571 types.RepoTagsResponse 572} 573 574func (p *Pages) RepoTags(w io.Writer, params RepoTagsParams) error { 575 return p.executeRepo("repo/tags", w, params) 576} 577 578type RepoBlobParams struct { 579 LoggedInUser *auth.User 580 RepoInfo RepoInfo 581 Active string 582 BreadCrumbs [][]string 583 ShowRendered bool 584 RenderToggle bool 585 RenderedContents template.HTML 586 types.RepoBlobResponse 587} 588 589func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error { 590 var style *chroma.Style = styles.Get("catpuccin-latte") 591 592 if params.ShowRendered { 593 switch markup.GetFormat(params.Path) { 594 case markup.FormatMarkdown: 595 params.RenderedContents = template.HTML(markup.RenderMarkdown(params.Contents)) 596 } 597 } 598 599 if params.Lines < 5000 { 600 c := params.Contents 601 formatter := chromahtml.New( 602 chromahtml.InlineCode(false), 603 chromahtml.WithLineNumbers(true), 604 chromahtml.WithLinkableLineNumbers(true, "L"), 605 chromahtml.Standalone(false), 606 chromahtml.WithClasses(true), 607 ) 608 609 lexer := lexers.Get(filepath.Base(params.Path)) 610 if lexer == nil { 611 lexer = lexers.Fallback 612 } 613 614 iterator, err := lexer.Tokenise(nil, c) 615 if err != nil { 616 return fmt.Errorf("chroma tokenize: %w", err) 617 } 618 619 var code bytes.Buffer 620 err = formatter.Format(&code, style, iterator) 621 if err != nil { 622 return fmt.Errorf("chroma format: %w", err) 623 } 624 625 params.Contents = code.String() 626 } 627 628 params.Active = "overview" 629 return p.executeRepo("repo/blob", w, params) 630} 631 632type Collaborator struct { 633 Did string 634 Handle string 635 Role string 636} 637 638type RepoSettingsParams struct { 639 LoggedInUser *auth.User 640 RepoInfo RepoInfo 641 Collaborators []Collaborator 642 Active string 643 Branches []string 644 DefaultBranch string 645 // TODO: use repoinfo.roles 646 IsCollaboratorInviteAllowed bool 647} 648 649func (p *Pages) RepoSettings(w io.Writer, params RepoSettingsParams) error { 650 params.Active = "settings" 651 return p.executeRepo("repo/settings", w, params) 652} 653 654type RepoIssuesParams struct { 655 LoggedInUser *auth.User 656 RepoInfo RepoInfo 657 Active string 658 Issues []db.Issue 659 DidHandleMap map[string]string 660 Page pagination.Page 661 FilteringByOpen bool 662} 663 664func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error { 665 params.Active = "issues" 666 return p.executeRepo("repo/issues/issues", w, params) 667} 668 669type RepoSingleIssueParams struct { 670 LoggedInUser *auth.User 671 RepoInfo RepoInfo 672 Active string 673 Issue db.Issue 674 Comments []db.Comment 675 IssueOwnerHandle string 676 DidHandleMap map[string]string 677 678 State string 679} 680 681func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error { 682 params.Active = "issues" 683 if params.Issue.Open { 684 params.State = "open" 685 } else { 686 params.State = "closed" 687 } 688 return p.execute("repo/issues/issue", w, params) 689} 690 691type RepoNewIssueParams struct { 692 LoggedInUser *auth.User 693 RepoInfo RepoInfo 694 Active string 695} 696 697func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error { 698 params.Active = "issues" 699 return p.executeRepo("repo/issues/new", w, params) 700} 701 702type EditIssueCommentParams struct { 703 LoggedInUser *auth.User 704 RepoInfo RepoInfo 705 Issue *db.Issue 706 Comment *db.Comment 707} 708 709func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error { 710 return p.executePlain("repo/issues/fragments/editIssueComment", w, params) 711} 712 713type SingleIssueCommentParams struct { 714 LoggedInUser *auth.User 715 DidHandleMap map[string]string 716 RepoInfo RepoInfo 717 Issue *db.Issue 718 Comment *db.Comment 719} 720 721func (p *Pages) SingleIssueCommentFragment(w io.Writer, params SingleIssueCommentParams) error { 722 return p.executePlain("repo/issues/fragments/issueComment", w, params) 723} 724 725type RepoNewPullParams struct { 726 LoggedInUser *auth.User 727 RepoInfo RepoInfo 728 Branches []types.Branch 729 Active string 730} 731 732func (p *Pages) RepoNewPull(w io.Writer, params RepoNewPullParams) error { 733 params.Active = "pulls" 734 return p.executeRepo("repo/pulls/new", w, params) 735} 736 737type RepoPullsParams struct { 738 LoggedInUser *auth.User 739 RepoInfo RepoInfo 740 Pulls []*db.Pull 741 Active string 742 DidHandleMap map[string]string 743 FilteringBy db.PullState 744} 745 746func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error { 747 params.Active = "pulls" 748 return p.executeRepo("repo/pulls/pulls", w, params) 749} 750 751type ResubmitResult uint64 752 753const ( 754 ShouldResubmit ResubmitResult = iota 755 ShouldNotResubmit 756 Unknown 757) 758 759func (r ResubmitResult) Yes() bool { 760 return r == ShouldResubmit 761} 762func (r ResubmitResult) No() bool { 763 return r == ShouldNotResubmit 764} 765func (r ResubmitResult) Unknown() bool { 766 return r == Unknown 767} 768 769type RepoSinglePullParams struct { 770 LoggedInUser *auth.User 771 RepoInfo RepoInfo 772 Active string 773 DidHandleMap map[string]string 774 Pull *db.Pull 775 MergeCheck types.MergeCheckResponse 776 ResubmitCheck ResubmitResult 777} 778 779func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error { 780 params.Active = "pulls" 781 return p.executeRepo("repo/pulls/pull", w, params) 782} 783 784type RepoPullPatchParams struct { 785 LoggedInUser *auth.User 786 DidHandleMap map[string]string 787 RepoInfo RepoInfo 788 Pull *db.Pull 789 Diff *types.NiceDiff 790 Round int 791 Submission *db.PullSubmission 792} 793 794// this name is a mouthful 795func (p *Pages) RepoPullPatchPage(w io.Writer, params RepoPullPatchParams) error { 796 return p.execute("repo/pulls/patch", w, params) 797} 798 799type RepoPullInterdiffParams struct { 800 LoggedInUser *auth.User 801 DidHandleMap map[string]string 802 RepoInfo RepoInfo 803 Pull *db.Pull 804 Round int 805 Interdiff *patchutil.InterdiffResult 806} 807 808// this name is a mouthful 809func (p *Pages) RepoPullInterdiffPage(w io.Writer, params RepoPullInterdiffParams) error { 810 return p.execute("repo/pulls/interdiff", w, params) 811} 812 813type PullPatchUploadParams struct { 814 RepoInfo RepoInfo 815} 816 817func (p *Pages) PullPatchUploadFragment(w io.Writer, params PullPatchUploadParams) error { 818 return p.executePlain("repo/pulls/fragments/pullPatchUpload", w, params) 819} 820 821type PullCompareBranchesParams struct { 822 RepoInfo RepoInfo 823 Branches []types.Branch 824} 825 826func (p *Pages) PullCompareBranchesFragment(w io.Writer, params PullCompareBranchesParams) error { 827 return p.executePlain("repo/pulls/fragments/pullCompareBranches", w, params) 828} 829 830type PullCompareForkParams struct { 831 RepoInfo RepoInfo 832 Forks []db.Repo 833} 834 835func (p *Pages) PullCompareForkFragment(w io.Writer, params PullCompareForkParams) error { 836 return p.executePlain("repo/pulls/fragments/pullCompareForks", w, params) 837} 838 839type PullCompareForkBranchesParams struct { 840 RepoInfo RepoInfo 841 SourceBranches []types.Branch 842 TargetBranches []types.Branch 843} 844 845func (p *Pages) PullCompareForkBranchesFragment(w io.Writer, params PullCompareForkBranchesParams) error { 846 return p.executePlain("repo/pulls/fragments/pullCompareForksBranches", w, params) 847} 848 849type PullResubmitParams struct { 850 LoggedInUser *auth.User 851 RepoInfo RepoInfo 852 Pull *db.Pull 853 SubmissionId int 854} 855 856func (p *Pages) PullResubmitFragment(w io.Writer, params PullResubmitParams) error { 857 return p.executePlain("repo/pulls/fragments/pullResubmit", w, params) 858} 859 860type PullActionsParams struct { 861 LoggedInUser *auth.User 862 RepoInfo RepoInfo 863 Pull *db.Pull 864 RoundNumber int 865 MergeCheck types.MergeCheckResponse 866 ResubmitCheck ResubmitResult 867} 868 869func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error { 870 return p.executePlain("repo/pulls/fragments/pullActions", w, params) 871} 872 873type PullNewCommentParams struct { 874 LoggedInUser *auth.User 875 RepoInfo RepoInfo 876 Pull *db.Pull 877 RoundNumber int 878} 879 880func (p *Pages) PullNewCommentFragment(w io.Writer, params PullNewCommentParams) error { 881 return p.executePlain("repo/pulls/fragments/pullNewComment", w, params) 882} 883 884func (p *Pages) Static() http.Handler { 885 if p.dev { 886 return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static"))) 887 } 888 889 sub, err := fs.Sub(Files, "static") 890 if err != nil { 891 log.Fatalf("no static dir found? that's crazy: %v", err) 892 } 893 // Custom handler to apply Cache-Control headers for font files 894 return Cache(http.StripPrefix("/static/", http.FileServer(http.FS(sub)))) 895} 896 897func Cache(h http.Handler) http.Handler { 898 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 899 path := strings.Split(r.URL.Path, "?")[0] 900 901 if strings.HasSuffix(path, ".css") { 902 // on day for css files 903 w.Header().Set("Cache-Control", "public, max-age=86400") 904 } else { 905 w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") 906 } 907 h.ServeHTTP(w, r) 908 }) 909} 910 911func CssContentHash() string { 912 cssFile, err := Files.Open("static/tw.css") 913 if err != nil { 914 log.Printf("Error opening CSS file: %v", err) 915 return "" 916 } 917 defer cssFile.Close() 918 919 hasher := sha256.New() 920 if _, err := io.Copy(hasher, cssFile); err != nil { 921 log.Printf("Error hashing CSS file: %v", err) 922 return "" 923 } 924 925 return hex.EncodeToString(hasher.Sum(nil))[:8] // Use first 8 chars of hash 926} 927 928func (p *Pages) Error500(w io.Writer) error { 929 return p.execute("errors/500", w, nil) 930} 931 932func (p *Pages) Error404(w io.Writer) error { 933 return p.execute("errors/404", w, nil) 934} 935 936func (p *Pages) Error503(w io.Writer) error { 937 return p.execute("errors/503", w, nil) 938}