Monorepo for Tangled tangled.org

appview/pages: header and footer occupy full page width #621

As discussed on Discord, the header and footer now take up full width. I went with the version where the content is still capped at 1024px, like the main content.

The changes are purely CSS, except for an extra div around the main content. This is needed because the grid no longer adds a minimum height to the main content, which means the footer will not be pushed to the bottom on pages with little main content. So now instead the header, content and footer are in a flex column, and the content flex-grow’s to make sure it’s at least taking up the remaining viewport space.

A few redundant classes have been removed, e.g. grid properties on elements that were not grid-items. I also removed (unused/invisible) border radius and drop-shadow from the header and footer.

I tried best possible to check the layout across the different views. There does not currently seem to be any specific UI test suite or similar - let me know if I missed it.

Normally I would add screenshots to a PR like this, but this does not seem supported currently. I can share over Discord if you’re interested.

Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:laqygfbyvnkyuhsuaxmp6ez3/sh.tangled.repo.pull/3m23e25a2sb22
+890 -473
Diff #1
+4 -2
appview/config/config.go
··· 72 } 73 74 type Cloudflare struct { 75 - ApiToken string `env:"API_TOKEN"` 76 - ZoneId string `env:"ZONE_ID"` 77 } 78 79 func (cfg RedisConfig) ToURL() string {
··· 72 } 73 74 type Cloudflare struct { 75 + ApiToken string `env:"API_TOKEN"` 76 + ZoneId string `env:"ZONE_ID"` 77 + TurnstileSiteKey string `env:"TURNSTILE_SITE_KEY"` 78 + TurnstileSecretKey string `env:"TURNSTILE_SECRET_KEY"` 79 } 80 81 func (cfg RedisConfig) ToURL() string {
+1 -1
appview/pages/templates/user/login.html
··· 36 placeholder="akshay.tngl.sh" 37 /> 38 <span class="text-sm text-gray-500 mt-1"> 39 - Use your <a href="https://atproto.com">ATProto</a> 40 handle to log in. If you're unsure, this is likely 41 your Tangled (<code>.tngl.sh</code>) or <a href="https://bsky.app">Bluesky</a> (<code>.bsky.social</code>) account. 42 </span>
··· 36 placeholder="akshay.tngl.sh" 37 /> 38 <span class="text-sm text-gray-500 mt-1"> 39 + Use your <a href="https://atproto.com">AT Protocol</a> 40 handle to log in. If you're unsure, this is likely 41 your Tangled (<code>.tngl.sh</code>) or <a href="https://bsky.app">Bluesky</a> (<code>.bsky.social</code>) account. 42 </span>
+6 -1
appview/pages/templates/user/signup.html
··· 10 <script src="/static/htmx.min.js"></script> 11 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 12 <title>sign up &middot; tangled</title> 13 </head> 14 <body class="flex items-center justify-center min-h-screen"> 15 <main class="max-w-md px-6 -mt-4"> ··· 39 invite code, desired username, and password in the next 40 page to complete your registration. 41 </span> 42 <button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" > 43 <span>join now</span> 44 </button> 45 </form> 46 <p class="text-sm text-gray-500"> 47 - Already have an ATProto account? <a href="/login" class="underline">Login to Tangled</a>. 48 </p> 49 50 <p id="signup-msg" class="error w-full"></p>
··· 10 <script src="/static/htmx.min.js"></script> 11 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 12 <title>sign up &middot; tangled</title> 13 + 14 + <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script> 15 </head> 16 <body class="flex items-center justify-center min-h-screen"> 17 <main class="max-w-md px-6 -mt-4"> ··· 41 invite code, desired username, and password in the next 42 page to complete your registration. 43 </span> 44 + <div class="w-full mt-4 text-center"> 45 + <div class="cf-turnstile" data-sitekey="{{ .CloudflareSiteKey }}"></div> 46 + </div> 47 <button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" > 48 <span>join now</span> 49 </button> 50 </form> 51 <p class="text-sm text-gray-500"> 52 + Already have an AT Protocol account? <a href="/login" class="underline">Login to Tangled</a>. 53 </p> 54 55 <p id="signup-msg" class="error w-full"></p>
+65 -1
appview/signup/signup.go
··· 2 3 import ( 4 "bufio" 5 "fmt" 6 "log/slog" 7 "net/http" 8 "os" 9 "strings" 10 ··· 116 func (s *Signup) signup(w http.ResponseWriter, r *http.Request) { 117 switch r.Method { 118 case http.MethodGet: 119 - s.pages.Signup(w) 120 case http.MethodPost: 121 if s.cf == nil { 122 http.Error(w, "signup is disabled", http.StatusFailedDependency) 123 } 124 emailId := r.FormValue("email") 125 126 noticeId := "signup-msg" 127 if !email.IsValidEmail(emailId) { 128 s.pages.Notice(w, noticeId, "Invalid email address.") 129 return ··· 255 return 256 } 257 }
··· 2 3 import ( 4 "bufio" 5 + "encoding/json" 6 + "errors" 7 "fmt" 8 "log/slog" 9 "net/http" 10 + "net/url" 11 "os" 12 "strings" 13 ··· 119 func (s *Signup) signup(w http.ResponseWriter, r *http.Request) { 120 switch r.Method { 121 case http.MethodGet: 122 + s.pages.Signup(w, pages.SignupParams{ 123 + CloudflareSiteKey: s.config.Cloudflare.TurnstileSiteKey, 124 + }) 125 case http.MethodPost: 126 if s.cf == nil { 127 http.Error(w, "signup is disabled", http.StatusFailedDependency) 128 + return 129 } 130 emailId := r.FormValue("email") 131 + cfToken := r.FormValue("cf-turnstile-response") 132 133 noticeId := "signup-msg" 134 + 135 + if err := s.validateCaptcha(cfToken, r); err != nil { 136 + s.l.Warn("turnstile validation failed", "error", err) 137 + s.pages.Notice(w, noticeId, "Captcha validation failed.") 138 + return 139 + } 140 + 141 if !email.IsValidEmail(emailId) { 142 s.pages.Notice(w, noticeId, "Invalid email address.") 143 return ··· 269 return 270 } 271 } 272 + 273 + type turnstileResponse struct { 274 + Success bool `json:"success"` 275 + ErrorCodes []string `json:"error-codes,omitempty"` 276 + ChallengeTs string `json:"challenge_ts,omitempty"` 277 + Hostname string `json:"hostname,omitempty"` 278 + } 279 + 280 + func (s *Signup) validateCaptcha(cfToken string, r *http.Request) error { 281 + if cfToken == "" { 282 + return errors.New("captcha token is empty") 283 + } 284 + 285 + if s.config.Cloudflare.TurnstileSecretKey == "" { 286 + return errors.New("turnstile secret key not configured") 287 + } 288 + 289 + data := url.Values{} 290 + data.Set("secret", s.config.Cloudflare.TurnstileSecretKey) 291 + data.Set("response", cfToken) 292 + 293 + // include the client IP if we have it 294 + if remoteIP := r.Header.Get("CF-Connecting-IP"); remoteIP != "" { 295 + data.Set("remoteip", remoteIP) 296 + } else if remoteIP := r.Header.Get("X-Forwarded-For"); remoteIP != "" { 297 + if ips := strings.Split(remoteIP, ","); len(ips) > 0 { 298 + data.Set("remoteip", strings.TrimSpace(ips[0])) 299 + } 300 + } else { 301 + data.Set("remoteip", r.RemoteAddr) 302 + } 303 + 304 + resp, err := http.PostForm("https://challenges.cloudflare.com/turnstile/v0/siteverify", data) 305 + if err != nil { 306 + return fmt.Errorf("failed to verify turnstile token: %w", err) 307 + } 308 + defer resp.Body.Close() 309 + 310 + var turnstileResp turnstileResponse 311 + if err := json.NewDecoder(resp.Body).Decode(&turnstileResp); err != nil { 312 + return fmt.Errorf("failed to decode turnstile response: %w", err) 313 + } 314 + 315 + if !turnstileResp.Success { 316 + s.l.Warn("turnstile validation failed", "error_codes", turnstileResp.ErrorCodes) 317 + return errors.New("turnstile validation failed") 318 + } 319 + 320 + return nil 321 + }
+13 -9
appview/db/email.go
··· 71 return did, nil 72 } 73 74 - func GetEmailToDid(e Execer, ems []string, isVerifiedFilter bool) (map[string]string, error) { 75 - if len(ems) == 0 { 76 return make(map[string]string), nil 77 } 78 ··· 81 verifiedFilter = 1 82 } 83 84 // Create placeholders for the IN clause 85 - placeholders := make([]string, len(ems)) 86 - args := make([]any, len(ems)+1) 87 88 args[0] = verifiedFilter 89 - for i, em := range ems { 90 - placeholders[i] = "?" 91 - args[i+1] = em 92 } 93 94 query := ` ··· 105 } 106 defer rows.Close() 107 108 - assoc := make(map[string]string) 109 - 110 for rows.Next() { 111 var email, did string 112 if err := rows.Scan(&email, &did); err != nil {
··· 71 return did, nil 72 } 73 74 + func GetEmailToDid(e Execer, emails []string, isVerifiedFilter bool) (map[string]string, error) { 75 + if len(emails) == 0 { 76 return make(map[string]string), nil 77 } 78 ··· 81 verifiedFilter = 1 82 } 83 84 + assoc := make(map[string]string) 85 + 86 // Create placeholders for the IN clause 87 + placeholders := make([]string, 0, len(emails)) 88 + args := make([]any, 1, len(emails)+1) 89 90 args[0] = verifiedFilter 91 + for _, email := range emails { 92 + if strings.HasPrefix(email, "did:") { 93 + assoc[email] = email 94 + continue 95 + } 96 + placeholders = append(placeholders, "?") 97 + args = append(args, email) 98 } 99 100 query := ` ··· 111 } 112 defer rows.Close() 113 114 for rows.Next() { 115 var email, did string 116 if err := rows.Scan(&email, &did); err != nil {
+7
appview/pages/templates/repo/fork.html
··· 6 </div> 7 <div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded"> 8 <form hx-post="/{{ .RepoInfo.FullName }}/fork" class="space-y-12" hx-swap="none" hx-indicator="#spinner"> 9 <fieldset class="space-y-3"> 10 <legend class="dark:text-white">Select a knot to fork into</legend> 11 <div class="space-y-2">
··· 6 </div> 7 <div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded"> 8 <form hx-post="/{{ .RepoInfo.FullName }}/fork" class="space-y-12" hx-swap="none" hx-indicator="#spinner"> 9 + 10 + <fieldset class="space-y-3"> 11 + <legend for="repo_name" class="dark:text-white">Repository name</legend> 12 + <input type="text" id="repo_name" name="repo_name" value="{{ .RepoInfo.Name }}" 13 + class="w-full p-2 border rounded bg-gray-100 dark:bg-gray-700 dark:text-white dark:border-gray-600" /> 14 + </fieldset> 15 + 16 <fieldset class="space-y-3"> 17 <legend class="dark:text-white">Select a knot to fork into</legend> 18 <div class="space-y-2">
+11 -7
appview/repo/repo.go
··· 2129 } 2130 2131 // choose a name for a fork 2132 - forkName := f.Name 2133 // this check is *only* to see if the forked repo name already exists 2134 // in the user's account. 2135 existingRepo, err := db.GetRepo( 2136 rp.db, 2137 db.FilterEq("did", user.Did), 2138 - db.FilterEq("name", f.Name), 2139 ) 2140 if err != nil { 2141 - if errors.Is(err, sql.ErrNoRows) { 2142 - // no existing repo with this name found, we can use the name as is 2143 - } else { 2144 log.Println("error fetching existing repo from db", "err", err) 2145 rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.") 2146 return 2147 } 2148 } else if existingRepo != nil { 2149 - // repo with this name already exists, append random string 2150 - forkName = fmt.Sprintf("%s-%s", forkName, randomString(3)) 2151 } 2152 l = l.With("forkName", forkName) 2153
··· 2129 } 2130 2131 // choose a name for a fork 2132 + forkName := r.FormValue("repo_name") 2133 + if forkName == "" { 2134 + rp.pages.Notice(w, "repo", "Repository name cannot be empty.") 2135 + return 2136 + } 2137 + 2138 // this check is *only* to see if the forked repo name already exists 2139 // in the user's account. 2140 existingRepo, err := db.GetRepo( 2141 rp.db, 2142 db.FilterEq("did", user.Did), 2143 + db.FilterEq("name", forkName), 2144 ) 2145 if err != nil { 2146 + if !errors.Is(err, sql.ErrNoRows) { 2147 log.Println("error fetching existing repo from db", "err", err) 2148 rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.") 2149 return 2150 } 2151 } else if existingRepo != nil { 2152 + // repo with this name already exists 2153 + rp.pages.Notice(w, "repo", "A repository with this name already exists.") 2154 + return 2155 } 2156 l = l.With("forkName", forkName) 2157
+140
appview/db/db.go
··· 954 return err 955 }) 956 957 return &DB{db}, nil 958 } 959
··· 954 return err 955 }) 956 957 + // add generated at_uri column to pulls table 958 + // 959 + // this requires a full table recreation because stored columns 960 + // cannot be added via alter 961 + // 962 + // disable foreign-keys for the next migration 963 + conn.ExecContext(ctx, "pragma foreign_keys = off;") 964 + runMigration(conn, "add-at-uri-to-pulls", func(tx *sql.Tx) error { 965 + _, err := tx.Exec(` 966 + create table if not exists pulls_new ( 967 + -- identifiers 968 + id integer primary key autoincrement, 969 + pull_id integer not null, 970 + at_uri text generated always as ('at://' || owner_did || '/' || 'sh.tangled.repo.pull' || '/' || rkey) stored, 971 + 972 + -- at identifiers 973 + repo_at text not null, 974 + owner_did text not null, 975 + rkey text not null, 976 + 977 + -- content 978 + title text not null, 979 + body text not null, 980 + target_branch text not null, 981 + state integer not null default 0 check (state in (0, 1, 2, 3)), -- closed, open, merged, deleted 982 + 983 + -- source info 984 + source_branch text, 985 + source_repo_at text, 986 + 987 + -- stacking 988 + stack_id text, 989 + change_id text, 990 + parent_change_id text, 991 + 992 + -- meta 993 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 994 + 995 + -- constraints 996 + unique(repo_at, pull_id), 997 + unique(at_uri), 998 + foreign key (repo_at) references repos(at_uri) on delete cascade 999 + ); 1000 + `) 1001 + if err != nil { 1002 + return err 1003 + } 1004 + 1005 + // transfer data 1006 + _, err = tx.Exec(` 1007 + insert into pulls_new ( 1008 + id, pull_id, repo_at, owner_did, rkey, 1009 + title, body, target_branch, state, 1010 + source_branch, source_repo_at, 1011 + stack_id, change_id, parent_change_id, 1012 + created 1013 + ) 1014 + select 1015 + id, pull_id, repo_at, owner_did, rkey, 1016 + title, body, target_branch, state, 1017 + source_branch, source_repo_at, 1018 + stack_id, change_id, parent_change_id, 1019 + created 1020 + from pulls; 1021 + `) 1022 + if err != nil { 1023 + return err 1024 + } 1025 + 1026 + // drop old table 1027 + _, err = tx.Exec(`drop table pulls`) 1028 + if err != nil { 1029 + return err 1030 + } 1031 + 1032 + // rename new table 1033 + _, err = tx.Exec(`alter table pulls_new rename to pulls`) 1034 + return err 1035 + }) 1036 + conn.ExecContext(ctx, "pragma foreign_keys = on;") 1037 + 1038 + // remove repo_at and pull_id from pull_submissions and replace with pull_at 1039 + // 1040 + // this requires a full table recreation because stored columns 1041 + // cannot be added via alter 1042 + // 1043 + // disable foreign-keys for the next migration 1044 + conn.ExecContext(ctx, "pragma foreign_keys = off;") 1045 + runMigration(conn, "remove-repo-at-pull-id-from-pull-submissions", func(tx *sql.Tx) error { 1046 + _, err := tx.Exec(` 1047 + create table if not exists pull_submissions_new ( 1048 + -- identifiers 1049 + id integer primary key autoincrement, 1050 + pull_at text not null, 1051 + 1052 + -- content, these are immutable, and require a resubmission to update 1053 + round_number integer not null default 0, 1054 + patch text, 1055 + source_rev text, 1056 + 1057 + -- meta 1058 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 1059 + 1060 + -- constraints 1061 + unique(pull_at, round_number), 1062 + foreign key (pull_at) references pulls(at_uri) on delete cascade 1063 + ); 1064 + `) 1065 + if err != nil { 1066 + return err 1067 + } 1068 + 1069 + // transfer data, constructing pull_at from pulls table 1070 + _, err = tx.Exec(` 1071 + insert into pull_submissions_new (id, pull_at, round_number, patch, created) 1072 + select 1073 + ps.id, 1074 + 'at://' || p.owner_did || '/sh.tangled.repo.pull/' || p.rkey, 1075 + ps.round_number, 1076 + ps.patch, 1077 + ps.created 1078 + from pull_submissions ps 1079 + join pulls p on ps.repo_at = p.repo_at and ps.pull_id = p.pull_id; 1080 + `) 1081 + if err != nil { 1082 + return err 1083 + } 1084 + 1085 + // drop old table 1086 + _, err = tx.Exec(`drop table pull_submissions`) 1087 + if err != nil { 1088 + return err 1089 + } 1090 + 1091 + // rename new table 1092 + _, err = tx.Exec(`alter table pull_submissions_new rename to pull_submissions`) 1093 + return err 1094 + }) 1095 + conn.ExecContext(ctx, "pragma foreign_keys = on;") 1096 + 1097 return &DB{db}, nil 1098 } 1099
+125 -234
appview/db/pulls.go
··· 1 2 - 3 import ( 4 "database/sql" 5 "fmt" 6 - "log" 7 "sort" 8 "strings" 9 "time" ··· 87 pull.ID = int(id) 88 89 _, err = tx.Exec(` 90 - insert into pull_submissions (pull_id, repo_at, round_number, patch, source_rev) 91 - values (?, ?, ?, ?, ?) 92 - `, pull.PullId, pull.RepoAt, 0, pull.Submissions[0].Patch, pull.Submissions[0].SourceRev) 93 return err 94 } 95 ··· 108 } 109 110 func GetPullsWithLimit(e Execer, limit int, filters ...filter) ([]*models.Pull, error) { 111 - pulls := make(map[int]*models.Pull) 112 113 var conditions []string 114 var args []any ··· 211 pull.ParentChangeId = parentChangeId.String 212 } 213 214 - pulls[pull.PullId] = &pull 215 } 216 217 - // get latest round no. for each pull 218 - inClause := strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ") 219 - submissionsQuery := fmt.Sprintf(` 220 - select 221 - id, pull_id, round_number, patch, created, source_rev 222 - from 223 - pull_submissions 224 - where 225 - repo_at in (%s) and pull_id in (%s) 226 - `, inClause, inClause) 227 - 228 - args = make([]any, len(pulls)*2) 229 - idx := 0 230 for _, p := range pulls { 231 - args[idx] = p.RepoAt 232 - idx += 1 233 } 234 - for _, p := range pulls { 235 - args[idx] = p.PullId 236 - idx += 1 237 - } 238 - submissionsRows, err := e.Query(submissionsQuery, args...) 239 if err != nil { 240 - return nil, err 241 } 242 - defer submissionsRows.Close() 243 244 - for submissionsRows.Next() { 245 - var s models.PullSubmission 246 - var sourceRev sql.NullString 247 - var createdAt string 248 - err := submissionsRows.Scan( 249 - &s.ID, 250 - &s.PullId, 251 - &s.RoundNumber, 252 - &s.Patch, 253 - &createdAt, 254 - &sourceRev, 255 - ) 256 - if err != nil { 257 - return nil, err 258 } 259 - 260 - createdTime, err := time.Parse(time.RFC3339, createdAt) 261 - if err != nil { 262 - return nil, err 263 - } 264 - s.Created = createdTime 265 - 266 - if sourceRev.Valid { 267 - s.SourceRev = sourceRev.String 268 - } 269 - 270 - if p, ok := pulls[s.PullId]; ok { 271 - p.Submissions = make([]*models.PullSubmission, s.RoundNumber+1) 272 - p.Submissions[s.RoundNumber] = &s 273 - } 274 } 275 - if err := rows.Err(); err != nil { 276 - return nil, err 277 - } 278 - 279 - // get comment count on latest submission on each pull 280 - inClause = strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ") 281 - commentsQuery := fmt.Sprintf(` 282 - select 283 - count(id), pull_id 284 - from 285 - pull_comments 286 - where 287 - submission_id in (%s) 288 - group by 289 - submission_id 290 - `, inClause) 291 - 292 - args = []any{} 293 - for _, p := range pulls { 294 - args = append(args, p.Submissions[p.LastRoundNumber()].ID) 295 - } 296 - commentsRows, err := e.Query(commentsQuery, args...) 297 if err != nil { 298 - return nil, err 299 } 300 - defer commentsRows.Close() 301 - 302 - for commentsRows.Next() { 303 - var commentCount, pullId int 304 - err := commentsRows.Scan( 305 - &commentCount, 306 - &pullId, 307 - ) 308 - if err != nil { 309 - return nil, err 310 } 311 - if p, ok := pulls[pullId]; ok { 312 - p.Submissions[p.LastRoundNumber()].Comments = make([]models.PullComment, commentCount) 313 - } 314 } 315 - if err := rows.Err(); err != nil { 316 - return nil, err 317 - } 318 319 orderedByPullId := []*models.Pull{} 320 for _, p := range pulls { ··· 332 } 333 334 func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*models.Pull, error) { 335 - query := ` 336 - select 337 - id, 338 - owner_did, 339 - pull_id, 340 - created, 341 - title, 342 - state, 343 - target_branch, 344 - repo_at, 345 - body, 346 - rkey, 347 - source_branch, 348 - source_repo_at, 349 - stack_id, 350 - change_id, 351 - parent_change_id 352 - from 353 - pulls 354 - where 355 - repo_at = ? and pull_id = ? 356 - ` 357 - row := e.QueryRow(query, repoAt, pullId) 358 - 359 - var pull models.Pull 360 - var createdAt string 361 - var sourceBranch, sourceRepoAt, stackId, changeId, parentChangeId sql.NullString 362 - err := row.Scan( 363 - &pull.ID, 364 - &pull.OwnerDid, 365 - &pull.PullId, 366 - &createdAt, 367 - &pull.Title, 368 - &pull.State, 369 - &pull.TargetBranch, 370 - &pull.RepoAt, 371 - &pull.Body, 372 - &pull.Rkey, 373 - &sourceBranch, 374 - &sourceRepoAt, 375 - &stackId, 376 - &changeId, 377 - &parentChangeId, 378 - ) 379 if err != nil { 380 return nil, err 381 } 382 - 383 - createdTime, err := time.Parse(time.RFC3339, createdAt) 384 - if err != nil { 385 - return nil, err 386 } 387 - pull.Created = createdTime 388 389 - // populate source 390 - if sourceBranch.Valid { 391 - pull.PullSource = &models.PullSource{ 392 - Branch: sourceBranch.String, 393 - } 394 - if sourceRepoAt.Valid { 395 - sourceRepoAtParsed, err := syntax.ParseATURI(sourceRepoAt.String) 396 - if err != nil { 397 - return nil, err 398 - } 399 - pull.PullSource.RepoAt = &sourceRepoAtParsed 400 - } 401 } 402 403 - if stackId.Valid { 404 - pull.StackId = stackId.String 405 } 406 - if changeId.Valid { 407 - pull.ChangeId = changeId.String 408 - } 409 - if parentChangeId.Valid { 410 - pull.ParentChangeId = parentChangeId.String 411 - } 412 413 - submissionsQuery := ` 414 select 415 - id, pull_id, repo_at, round_number, patch, created, source_rev 416 from 417 pull_submissions 418 - where 419 - repo_at = ? and pull_id = ? 420 - ` 421 - submissionsRows, err := e.Query(submissionsQuery, repoAt, pullId) 422 if err != nil { 423 return nil, err 424 } 425 - defer submissionsRows.Close() 426 427 - submissionsMap := make(map[int]*models.PullSubmission) 428 429 - for submissionsRows.Next() { 430 var submission models.PullSubmission 431 - var submissionCreatedStr string 432 - var submissionSourceRev sql.NullString 433 - err := submissionsRows.Scan( 434 &submission.ID, 435 - &submission.PullId, 436 - &submission.RepoAt, 437 &submission.RoundNumber, 438 &submission.Patch, 439 - &submissionCreatedStr, 440 - &submissionSourceRev, 441 ) 442 if err != nil { 443 return nil, err 444 } 445 446 - submissionCreatedTime, err := time.Parse(time.RFC3339, submissionCreatedStr) 447 if err != nil { 448 return nil, err 449 } 450 - submission.Created = submissionCreatedTime 451 452 - if submissionSourceRev.Valid { 453 - submission.SourceRev = submissionSourceRev.String 454 } 455 456 - submissionsMap[submission.ID] = &submission 457 } 458 - if err = submissionsRows.Close(); err != nil { 459 return nil, err 460 } 461 - if len(submissionsMap) == 0 { 462 - return &pull, nil 463 } 464 465 var args []any 466 - for k := range submissionsMap { 467 - args = append(args, k) 468 } 469 - inClause := strings.TrimSuffix(strings.Repeat("?, ", len(submissionsMap)), ", ") 470 - commentsQuery := fmt.Sprintf(` 471 select 472 id, 473 pull_id, ··· 479 created 480 from 481 pull_comments 482 - where 483 - submission_id IN (%s) 484 order by 485 created asc 486 - `, inClause) 487 - commentsRows, err := e.Query(commentsQuery, args...) 488 if err != nil { 489 return nil, err 490 } 491 - defer commentsRows.Close() 492 493 - for commentsRows.Next() { 494 var comment models.PullComment 495 - var commentCreatedStr string 496 - err := commentsRows.Scan( 497 &comment.ID, 498 &comment.PullId, 499 &comment.SubmissionId, ··· 501 &comment.OwnerDid, 502 &comment.CommentAt, 503 &comment.Body, 504 - &commentCreatedStr, 505 ) 506 if err != nil { 507 return nil, err 508 } 509 510 - commentCreatedTime, err := time.Parse(time.RFC3339, commentCreatedStr) 511 - if err != nil { 512 - return nil, err 513 } 514 - comment.Created = commentCreatedTime 515 516 - // Add the comment to its submission 517 - if submission, ok := submissionsMap[comment.SubmissionId]; ok { 518 - submission.Comments = append(submission.Comments, comment) 519 - } 520 - 521 } 522 - if err = commentsRows.Err(); err != nil { 523 return nil, err 524 } 525 526 - var pullSourceRepo *models.Repo 527 - if pull.PullSource != nil { 528 - if pull.PullSource.RepoAt != nil { 529 - pullSourceRepo, err = GetRepoByAtUri(e, pull.PullSource.RepoAt.String()) 530 - if err != nil { 531 - log.Printf("failed to get repo by at uri: %v", err) 532 - } else { 533 - pull.PullSource.Repo = pullSourceRepo 534 - } 535 - } 536 - } 537 - 538 - pull.Submissions = make([]*models.PullSubmission, len(submissionsMap)) 539 - for _, submission := range submissionsMap { 540 - pull.Submissions[submission.RoundNumber] = submission 541 - } 542 - 543 - return &pull, nil 544 } 545 546 // timeframe here is directly passed into the sql query filter, and any ··· 677 func ResubmitPull(e Execer, pull *models.Pull, newPatch, sourceRev string) error { 678 newRoundNumber := len(pull.Submissions) 679 _, err := e.Exec(` 680 - insert into pull_submissions (pull_id, repo_at, round_number, patch, source_rev) 681 - values (?, ?, ?, ?, ?) 682 - `, pull.PullId, pull.RepoAt, newRoundNumber, newPatch, sourceRev) 683 684 return err 685 }
··· 1 + package db 2 3 import ( 4 + "cmp" 5 "database/sql" 6 "fmt" 7 + "maps" 8 + "slices" 9 "sort" 10 "strings" 11 "time" ··· 89 pull.ID = int(id) 90 91 _, err = tx.Exec(` 92 + insert into pull_submissions (pull_at, round_number, patch, source_rev) 93 + values (?, ?, ?, ?) 94 + `, pull.PullAt(), 0, pull.Submissions[0].Patch, pull.Submissions[0].SourceRev) 95 return err 96 } 97 ··· 110 } 111 112 func GetPullsWithLimit(e Execer, limit int, filters ...filter) ([]*models.Pull, error) { 113 + pulls := make(map[syntax.ATURI]*models.Pull) 114 115 var conditions []string 116 var args []any ··· 213 pull.ParentChangeId = parentChangeId.String 214 } 215 216 + pulls[pull.PullAt()] = &pull 217 } 218 219 + var pullAts []syntax.ATURI 220 for _, p := range pulls { 221 + pullAts = append(pullAts, p.PullAt()) 222 } 223 + submissionsMap, err := GetPullSubmissions(e, FilterIn("pull_at", pullAts)) 224 if err != nil { 225 + return nil, fmt.Errorf("failed to get submissions: %w", err) 226 } 227 228 + for pullAt, submissions := range submissionsMap { 229 + if p, ok := pulls[pullAt]; ok { 230 + p.Submissions = submissions 231 } 232 } 233 + // collect allLabels for each issue 234 + allLabels, err := GetLabels(e, FilterIn("subject", pullAts)) 235 if err != nil { 236 + return nil, fmt.Errorf("failed to query labels: %w", err) 237 } 238 + for pullAt, labels := range allLabels { 239 + if p, ok := pulls[pullAt]; ok { 240 + p.Labels = labels 241 } 242 } 243 244 orderedByPullId := []*models.Pull{} 245 for _, p := range pulls { ··· 257 } 258 259 func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*models.Pull, error) { 260 + pulls, err := GetPullsWithLimit(e, 1, FilterEq("repo_at", repoAt), FilterEq("pull_id", pullId)) 261 if err != nil { 262 return nil, err 263 } 264 + if pulls == nil { 265 + return nil, sql.ErrNoRows 266 } 267 268 + return pulls[0], nil 269 + } 270 + 271 + // mapping from pull -> pull submissions 272 + func GetPullSubmissions(e Execer, filters ...filter) (map[syntax.ATURI][]*models.PullSubmission, error) { 273 + var conditions []string 274 + var args []any 275 + for _, filter := range filters { 276 + conditions = append(conditions, filter.Condition()) 277 + args = append(args, filter.Arg()...) 278 } 279 280 + whereClause := "" 281 + if conditions != nil { 282 + whereClause = " where " + strings.Join(conditions, " and ") 283 } 284 285 + query := fmt.Sprintf(` 286 select 287 + id, 288 + pull_at, 289 + round_number, 290 + patch, 291 + created, 292 + source_rev 293 from 294 pull_submissions 295 + %s 296 + order by 297 + round_number asc 298 + `, whereClause) 299 + 300 + rows, err := e.Query(query, args...) 301 if err != nil { 302 return nil, err 303 } 304 + defer rows.Close() 305 306 + submissionMap := make(map[int]*models.PullSubmission) 307 308 + for rows.Next() { 309 var submission models.PullSubmission 310 + var createdAt string 311 + var sourceRev sql.NullString 312 + err := rows.Scan( 313 &submission.ID, 314 + &submission.PullAt, 315 &submission.RoundNumber, 316 &submission.Patch, 317 + &createdAt, 318 + &sourceRev, 319 ) 320 if err != nil { 321 return nil, err 322 } 323 324 + createdTime, err := time.Parse(time.RFC3339, createdAt) 325 if err != nil { 326 return nil, err 327 } 328 + submission.Created = createdTime 329 330 + if sourceRev.Valid { 331 + submission.SourceRev = sourceRev.String 332 } 333 334 + submissionMap[submission.ID] = &submission 335 } 336 + 337 + if err := rows.Err(); err != nil { 338 return nil, err 339 } 340 + 341 + // Get comments for all submissions using GetPullComments 342 + submissionIds := slices.Collect(maps.Keys(submissionMap)) 343 + comments, err := GetPullComments(e, FilterIn("submission_id", submissionIds)) 344 + if err != nil { 345 + return nil, err 346 } 347 + for _, comment := range comments { 348 + if submission, ok := submissionMap[comment.SubmissionId]; ok { 349 + submission.Comments = append(submission.Comments, comment) 350 + } 351 + } 352 353 + // group the submissions by pull_at 354 + m := make(map[syntax.ATURI][]*models.PullSubmission) 355 + for _, s := range submissionMap { 356 + m[s.PullAt] = append(m[s.PullAt], s) 357 + } 358 + 359 + // sort each one by round number 360 + for _, s := range m { 361 + slices.SortFunc(s, func(a, b *models.PullSubmission) int { 362 + return cmp.Compare(a.RoundNumber, b.RoundNumber) 363 + }) 364 + } 365 + 366 + return m, nil 367 + } 368 + 369 + func GetPullComments(e Execer, filters ...filter) ([]models.PullComment, error) { 370 + var conditions []string 371 var args []any 372 + for _, filter := range filters { 373 + conditions = append(conditions, filter.Condition()) 374 + args = append(args, filter.Arg()...) 375 } 376 + 377 + whereClause := "" 378 + if conditions != nil { 379 + whereClause = " where " + strings.Join(conditions, " and ") 380 + } 381 + 382 + query := fmt.Sprintf(` 383 select 384 id, 385 pull_id, ··· 391 created 392 from 393 pull_comments 394 + %s 395 order by 396 created asc 397 + `, whereClause) 398 + 399 + rows, err := e.Query(query, args...) 400 if err != nil { 401 return nil, err 402 } 403 + defer rows.Close() 404 405 + var comments []models.PullComment 406 + for rows.Next() { 407 var comment models.PullComment 408 + var createdAt string 409 + err := rows.Scan( 410 &comment.ID, 411 &comment.PullId, 412 &comment.SubmissionId, ··· 414 &comment.OwnerDid, 415 &comment.CommentAt, 416 &comment.Body, 417 + &createdAt, 418 ) 419 if err != nil { 420 return nil, err 421 } 422 423 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 424 + comment.Created = t 425 } 426 427 + comments = append(comments, comment) 428 } 429 + 430 + if err := rows.Err(); err != nil { 431 return nil, err 432 } 433 434 + return comments, nil 435 } 436 437 // timeframe here is directly passed into the sql query filter, and any ··· 568 func ResubmitPull(e Execer, pull *models.Pull, newPatch, sourceRev string) error { 569 newRoundNumber := len(pull.Submissions) 570 _, err := e.Exec(` 571 + insert into pull_submissions (pull_at, round_number, patch, source_rev) 572 + values (?, ?, ?, ?) 573 + `, pull.PullAt(), newRoundNumber, newPatch, sourceRev) 574 575 return err 576 }
+141 -8
appview/models/pull.go
··· 74 75 76 77 78 79 80 81 ··· 118 119 120 121 - 122 - 123 - 124 - 125 - 126 type PullSubmission struct { 127 // ids 128 - ID int 129 - PullId int 130 131 // at ids 132 - RepoAt syntax.ATURI 133 134 // content 135 RoundNumber int
··· 74 75 76 77 + PullSource *PullSource 78 79 + // optionally, populate this when querying for reverse mappings 80 + Labels LabelState 81 + Repo *Repo 82 + } 83 84 + func (p Pull) AsRecord() tangled.RepoPull { 85 86 87 ··· 124 125 126 127 type PullSubmission struct { 128 // ids 129 + ID int 130 131 // at ids 132 + PullAt syntax.ATURI 133 134 // content 135 RoundNumber int 136 + 137 + 138 + 139 + 140 + 141 + 142 + 143 + 144 + 145 + 146 + 147 + 148 + 149 + 150 + 151 + 152 + 153 + 154 + 155 + 156 + 157 + 158 + 159 + 160 + 161 + 162 + 163 + 164 + 165 + 166 + 167 + 168 + 169 + 170 + 171 + 172 + 173 + 174 + 175 + 176 + 177 + 178 + 179 + 180 + 181 + 182 + 183 + 184 + 185 + 186 + 187 + 188 + 189 + 190 + 191 + 192 + 193 + 194 + 195 + 196 + 197 + 198 + 199 + 200 + 201 + 202 + 203 + 204 + 205 + 206 + 207 + return p.StackId != "" 208 + } 209 + 210 + func (p *Pull) Participants() []string { 211 + participantSet := make(map[string]struct{}) 212 + participants := []string{} 213 + 214 + addParticipant := func(did string) { 215 + if _, exists := participantSet[did]; !exists { 216 + participantSet[did] = struct{}{} 217 + participants = append(participants, did) 218 + } 219 + } 220 + 221 + addParticipant(p.OwnerDid) 222 + 223 + for _, s := range p.Submissions { 224 + for _, sp := range s.Participants() { 225 + addParticipant(sp) 226 + } 227 + } 228 + 229 + return participants 230 + } 231 + 232 + func (s PullSubmission) IsFormatPatch() bool { 233 + return patchutil.IsFormatPatch(s.Patch) 234 + } 235 + 236 + 237 + 238 + 239 + 240 + 241 + 242 + 243 + return patches 244 + } 245 + 246 + func (s *PullSubmission) Participants() []string { 247 + participantSet := make(map[string]struct{}) 248 + participants := []string{} 249 + 250 + addParticipant := func(did string) { 251 + if _, exists := participantSet[did]; !exists { 252 + participantSet[did] = struct{}{} 253 + participants = append(participants, did) 254 + } 255 + } 256 + 257 + addParticipant(s.PullAt.Authority().String()) 258 + 259 + for _, c := range s.Comments { 260 + addParticipant(c.OwnerDid) 261 + } 262 + 263 + return participants 264 + } 265 + 266 + type Stack []*Pull 267 + 268 + // position of this pull in the stack
+5 -1
appview/issues/issues.go
··· 798 return 799 } 800 801 - labelDefs, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels)) 802 if err != nil { 803 log.Println("failed to fetch labels", err) 804 rp.pages.Error503(w)
··· 798 return 799 } 800 801 + labelDefs, err := db.GetLabelDefinitions( 802 + rp.db, 803 + db.FilterIn("at_uri", f.Repo.Labels), 804 + db.FilterContains("scope", tangled.RepoIssueNSID), 805 + ) 806 if err != nil { 807 log.Println("failed to fetch labels", err) 808 rp.pages.Error503(w)
+1 -1
appview/pages/templates/repo/fragments/labelPanel.html
··· 1 {{ define "repo/fragments/labelPanel" }} 2 - <div id="label-panel" class="flex flex-col gap-6"> 3 {{ template "basicLabels" . }} 4 {{ template "kvLabels" . }} 5 </div>
··· 1 {{ define "repo/fragments/labelPanel" }} 2 + <div id="label-panel" class="flex flex-col gap-6 px-6 md:px-0"> 3 {{ template "basicLabels" . }} 4 {{ template "kvLabels" . }} 5 </div>
+26
appview/pages/templates/repo/fragments/participants.html
···
··· 1 + {{ define "repo/fragments/participants" }} 2 + {{ $all := . }} 3 + {{ $ps := take $all 5 }} 4 + <div class="px-6 md:px-0"> 5 + <div class="py-1 flex items-center text-sm"> 6 + <span class="font-bold text-gray-500 dark:text-gray-400 capitalize">Participants</span> 7 + <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 ml-1">{{ len $all }}</span> 8 + </div> 9 + <div class="flex items-center -space-x-3 mt-2"> 10 + {{ $c := "z-50 z-40 z-30 z-20 z-10" }} 11 + {{ range $i, $p := $ps }} 12 + <img 13 + src="{{ tinyAvatar . }}" 14 + alt="" 15 + class="rounded-full h-8 w-8 mr-1 border-2 border-gray-100 dark:border-gray-900 z-{{sub 5 $i}}0" 16 + /> 17 + {{ end }} 18 + 19 + {{ if gt (len $all) 5 }} 20 + <span class="pl-4 text-gray-500 dark:text-gray-400 text-sm"> 21 + +{{ sub (len $all) 5 }} 22 + </span> 23 + {{ end }} 24 + </div> 25 + </div> 26 + {{ end }}
+1 -27
appview/pages/templates/repo/issues/issue.html
··· 22 "Defs" $.LabelDefs 23 "Subject" $.Issue.AtUri 24 "State" $.Issue.Labels) }} 25 - {{ template "issueParticipants" . }} 26 </div> 27 </div> 28 {{ end }} ··· 122 </div> 123 {{ end }} 124 125 - {{ define "issueParticipants" }} 126 - {{ $all := .Issue.Participants }} 127 - {{ $ps := take $all 5 }} 128 - <div> 129 - <div class="py-1 flex items-center text-sm"> 130 - <span class="font-bold text-gray-500 dark:text-gray-400 capitalize">Participants</span> 131 - <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 ml-1">{{ len $all }}</span> 132 - </div> 133 - <div class="flex items-center -space-x-3 mt-2"> 134 - {{ $c := "z-50 z-40 z-30 z-20 z-10" }} 135 - {{ range $i, $p := $ps }} 136 - <img 137 - src="{{ tinyAvatar . }}" 138 - alt="" 139 - class="rounded-full h-8 w-8 mr-1 border-2 border-gray-100 dark:border-gray-900 z-{{sub 5 $i}}0" 140 - /> 141 - {{ end }} 142 - 143 - {{ if gt (len $all) 5 }} 144 - <span class="pl-4 text-gray-500 dark:text-gray-400 text-sm"> 145 - +{{ sub (len $all) 5 }} 146 - </span> 147 - {{ end }} 148 - </div> 149 - </div> 150 - {{ end }} 151 152 {{ define "repoAfter" }} 153 <div class="flex flex-col gap-4 mt-4">
··· 22 "Defs" $.LabelDefs 23 "Subject" $.Issue.AtUri 24 "State" $.Issue.Labels) }} 25 + {{ template "repo/fragments/participants" $.Issue.Participants }} 26 </div> 27 </div> 28 {{ end }} ··· 122 </div> 123 {{ end }} 124 125 126 {{ define "repoAfter" }} 127 <div class="flex flex-col gap-4 mt-4">
+30 -12
appview/pages/templates/repo/pulls/pull.html
··· 9 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 10 {{ end }} 11 12 13 {{ define "repoContent" }} 14 {{ template "repo/pulls/fragments/pullHeader" . }} ··· 39 {{ with $item }} 40 <details {{ if eq $idx $lastIdx }}open{{ end }}> 41 <summary id="round-#{{ .RoundNumber }}" class="list-none cursor-pointer"> 42 - <div class="flex flex-wrap gap-2 items-center"> 43 <!-- round number --> 44 <div class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-3 py-2 dark:text-white"> 45 <span class="flex items-center">{{ i "hash" "w-4 h-4" }}{{ .RoundNumber }}</span> 46 </div> 47 <!-- round summary --> 48 - <div class="rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400"> 49 <span class="gap-1 flex items-center"> 50 {{ $owner := resolve $.Pull.OwnerDid }} 51 {{ $re := "re" }} ··· 72 <span class="hidden md:inline">diff</span> 73 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 74 </a> 75 - {{ if not (eq .RoundNumber 0) }} 76 - <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group" 77 - hx-boost="true" 78 - href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}/interdiff"> 79 - {{ i "chevrons-left-right-ellipsis" "w-4 h-4 rotate-90" }} 80 - <span class="hidden md:inline">interdiff</span> 81 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 82 - </a> 83 - <span id="interdiff-error-{{.RoundNumber}}"></span> 84 {{ end }} 85 </div> 86 </summary> 87 ··· 146 147 <div class="md:pl-[3.5rem] flex flex-col gap-2 mt-2 relative"> 148 {{ range $cidx, $c := .Comments }} 149 - <div id="comment-{{$c.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full md:max-w-3/5 md:w-fit"> 150 {{ if gt $cidx 0 }} 151 <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 152 {{ end }}
··· 9 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 10 {{ end }} 11 12 + {{ define "repoContentLayout" }} 13 + <div class="grid grid-cols-1 md:grid-cols-10 gap-4 w-full"> 14 + <div class="col-span-1 md:col-span-8"> 15 + <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white"> 16 + {{ block "repoContent" . }}{{ end }} 17 + </section> 18 + {{ block "repoAfter" . }}{{ end }} 19 + </div> 20 + <div class="col-span-1 md:col-span-2 flex flex-col gap-6"> 21 + {{ template "repo/fragments/labelPanel" 22 + (dict "RepoInfo" $.RepoInfo 23 + "Defs" $.LabelDefs 24 + "Subject" $.Pull.PullAt 25 + "State" $.Pull.Labels) }} 26 + {{ template "repo/fragments/participants" $.Pull.Participants }} 27 + </div> 28 + </div> 29 + {{ end }} 30 31 {{ define "repoContent" }} 32 {{ template "repo/pulls/fragments/pullHeader" . }} ··· 57 {{ with $item }} 58 <details {{ if eq $idx $lastIdx }}open{{ end }}> 59 <summary id="round-#{{ .RoundNumber }}" class="list-none cursor-pointer"> 60 + <div class="flex flex-wrap gap-2 items-stretch"> 61 <!-- round number --> 62 <div class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-3 py-2 dark:text-white"> 63 <span class="flex items-center">{{ i "hash" "w-4 h-4" }}{{ .RoundNumber }}</span> 64 </div> 65 <!-- round summary --> 66 + <div class="flex-1 rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400"> 67 <span class="gap-1 flex items-center"> 68 {{ $owner := resolve $.Pull.OwnerDid }} 69 {{ $re := "re" }} ··· 90 <span class="hidden md:inline">diff</span> 91 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 92 </a> 93 + {{ if ne $idx 0 }} 94 + <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group" 95 + hx-boost="true" 96 + href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}/interdiff"> 97 + {{ i "chevrons-left-right-ellipsis" "w-4 h-4 rotate-90" }} 98 + <span class="hidden md:inline">interdiff</span> 99 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 100 + </a> 101 {{ end }} 102 + <span id="interdiff-error-{{.RoundNumber}}"></span> 103 </div> 104 </summary> 105 ··· 164 165 <div class="md:pl-[3.5rem] flex flex-col gap-2 mt-2 relative"> 166 {{ range $cidx, $c := .Comments }} 167 + <div id="comment-{{$c.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full"> 168 {{ if gt $cidx 0 }} 169 <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 170 {{ end }}
+7
appview/pages/templates/repo/pulls/pulls.html
··· 108 <span class="before:content-['·']"></span> 109 {{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }} 110 {{ end }} 111 </div> 112 </div> 113 {{ if .StackId }}
··· 108 <span class="before:content-['·']"></span> 109 {{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }} 110 {{ end }} 111 + 112 + {{ $state := .Labels }} 113 + {{ range $k, $d := $.LabelDefs }} 114 + {{ range $v, $s := $state.GetValSet $d.AtUri.String }} 115 + {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }} 116 + {{ end }} 117 + {{ end }} 118 </div> 119 </div> 120 {{ if .StackId }}
+35
appview/pulls/pulls.go
··· 200 userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.PullAt()) 201 } 202 203 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 204 LoggedInUser: user, 205 RepoInfo: repoInfo, ··· 213 OrderedReactionKinds: models.OrderedReactionKinds, 214 Reactions: reactionCountMap, 215 UserReacted: userReactions, 216 }) 217 } 218 ··· 557 m[p.Sha] = p 558 } 559 560 s.pages.RepoPulls(w, pages.RepoPullsParams{ 561 LoggedInUser: s.oauth.GetUser(r), 562 RepoInfo: f.RepoInfo(user), 563 Pulls: pulls, 564 FilteringBy: state, 565 Stacks: stacks, 566 Pipelines: m,
··· 200 userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.PullAt()) 201 } 202 203 + labelDefs, err := db.GetLabelDefinitions( 204 + s.db, 205 + db.FilterIn("at_uri", f.Repo.Labels), 206 + db.FilterContains("scope", tangled.RepoPullNSID), 207 + ) 208 + if err != nil { 209 + log.Println("failed to fetch labels", err) 210 + s.pages.Error503(w) 211 + return 212 + } 213 + 214 + defs := make(map[string]*models.LabelDefinition) 215 + for _, l := range labelDefs { 216 + defs[l.AtUri().String()] = &l 217 + } 218 + 219 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 220 LoggedInUser: user, 221 RepoInfo: repoInfo, ··· 229 OrderedReactionKinds: models.OrderedReactionKinds, 230 Reactions: reactionCountMap, 231 UserReacted: userReactions, 232 + 233 + LabelDefs: defs, 234 }) 235 } 236 ··· 575 m[p.Sha] = p 576 } 577 578 + labelDefs, err := db.GetLabelDefinitions( 579 + s.db, 580 + db.FilterIn("at_uri", f.Repo.Labels), 581 + db.FilterContains("scope", tangled.RepoPullNSID), 582 + ) 583 + if err != nil { 584 + log.Println("failed to fetch labels", err) 585 + s.pages.Error503(w) 586 + return 587 + } 588 + 589 + defs := make(map[string]*models.LabelDefinition) 590 + for _, l := range labelDefs { 591 + defs[l.AtUri().String()] = &l 592 + } 593 + 594 s.pages.RepoPulls(w, pages.RepoPullsParams{ 595 LoggedInUser: s.oauth.GetUser(r), 596 RepoInfo: f.RepoInfo(user), 597 Pulls: pulls, 598 + LabelDefs: defs, 599 FilteringBy: state, 600 Stacks: stacks, 601 Pipelines: m,
+28 -19
appview/db/notifications.go
··· 1 2 3 4 5 6 ··· 239 240 241 242 - 243 - 244 - 245 - 246 - 247 - 248 return GetNotificationsPaginated(e, pagination.FirstPage(), filters...) 249 } 250 251 - // GetNotifications retrieves notifications for a user with pagination (legacy method for backward compatibility) 252 - func (d *DB) GetNotifications(ctx context.Context, userDID string, limit, offset int) ([]*models.Notification, error) { 253 - page := pagination.Page{Limit: limit, Offset: offset} 254 - return GetNotificationsPaginated(d.DB, page, FilterEq("recipient_did", userDID)) 255 - } 256 257 - // GetNotificationsWithEntities retrieves notifications with entities for a user with pagination 258 - func (d *DB) GetNotificationsWithEntities(ctx context.Context, userDID string, limit, offset int) ([]*models.NotificationWithEntity, error) { 259 - page := pagination.Page{Limit: limit, Offset: offset} 260 - return GetNotificationsWithEntities(d.DB, page, FilterEq("recipient_did", userDID)) 261 - } 262 263 - func (d *DB) GetUnreadNotificationCount(ctx context.Context, userDID string) (int, error) { 264 - recipientFilter := FilterEq("recipient_did", userDID) 265 - readFilter := FilterEq("read", 0)
··· 1 2 3 + import ( 4 + "context" 5 + "database/sql" 6 + "errors" 7 + "fmt" 8 + "strings" 9 + "time" 10 11 + "tangled.org/core/appview/models" 12 13 14 ··· 247 248 249 250 return GetNotificationsPaginated(e, pagination.FirstPage(), filters...) 251 } 252 253 + func CountNotifications(e Execer, filters ...filter) (int64, error) { 254 + var conditions []string 255 + var args []any 256 + for _, filter := range filters { 257 + conditions = append(conditions, filter.Condition()) 258 + args = append(args, filter.Arg()...) 259 + } 260 261 + whereClause := "" 262 + if conditions != nil { 263 + whereClause = " where " + strings.Join(conditions, " and ") 264 + } 265 266 + query := fmt.Sprintf(`select count(1) from notifications %s`, whereClause) 267 + var count int64 268 + err := e.QueryRow(query, args...).Scan(&count) 269 + 270 + if !errors.Is(err, sql.ErrNoRows) && err != nil { 271 + return 0, err 272 + } 273 + 274 + return count, nil
+59 -19
appview/notifications/notifications.go
··· 1 2 3 4 5 - 6 - 7 - 8 - 9 - 10 "tangled.org/core/appview/middleware" 11 "tangled.org/core/appview/oauth" 12 "tangled.org/core/appview/pages" 13 ) 14 15 type Notifications struct { ··· 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 ··· 48 49 50 51 - 52 - 53 - 54 - 55 - 56 - 57 - 58 - 59 - 60 - 61 - } 62 } 63 64 - notifications, err := n.db.GetNotificationsWithEntities(r.Context(), userDid, limit+1, offset) 65 if err != nil { 66 - log.Println("failed to get notifications:", err) 67 - n.pages.Error500(w)
··· 1 + package notifications 2 3 + import ( 4 + "fmt" 5 + "log" 6 + "net/http" 7 + "strconv" 8 9 10 11 "tangled.org/core/appview/middleware" 12 "tangled.org/core/appview/oauth" 13 "tangled.org/core/appview/pages" 14 + "tangled.org/core/appview/pagination" 15 ) 16 17 type Notifications struct { ··· 31 32 33 34 + r.Use(middleware.AuthMiddleware(n.oauth)) 35 36 + r.With(middleware.Paginate).Get("/", n.notificationsPage) 37 38 + r.Get("/count", n.getUnreadCount) 39 + r.Post("/{id}/read", n.markRead) 40 41 42 43 44 45 46 + func (n *Notifications) notificationsPage(w http.ResponseWriter, r *http.Request) { 47 + userDid := n.oauth.GetDid(r) 48 49 + page, ok := r.Context().Value("page").(pagination.Page) 50 + if !ok { 51 + log.Println("failed to get page") 52 + page = pagination.FirstPage() 53 + } 54 55 + total, err := db.CountNotifications( 56 + n.db, 57 + db.FilterEq("recipient_did", userDid), 58 + ) 59 + if err != nil { 60 + log.Println("failed to get total notifications:", err) 61 + n.pages.Error500(w) 62 + return 63 + } 64 65 + notifications, err := db.GetNotificationsWithEntities( 66 + n.db, 67 + page, 68 + db.FilterEq("recipient_did", userDid), 69 + ) 70 + if err != nil { 71 + log.Println("failed to get notifications:", err) 72 + n.pages.Error500(w) 73 + return 74 + } 75 76 + err = n.db.MarkAllNotificationsRead(r.Context(), userDid) 77 + if err != nil { 78 + log.Println("failed to mark notifications as read:", err) 79 80 81 ··· 83 84 85 86 + return 87 } 88 89 + fmt.Println(n.pages.Notifications(w, pages.NotificationsParams{ 90 + LoggedInUser: user, 91 + Notifications: notifications, 92 + UnreadCount: unreadCount, 93 + Page: page, 94 + Total: total, 95 + })) 96 + } 97 + 98 + func (n *Notifications) getUnreadCount(w http.ResponseWriter, r *http.Request) { 99 + user := n.oauth.GetUser(r) 100 + count, err := db.CountNotifications( 101 + n.db, 102 + db.FilterEq("recipient_did", user.Did), 103 + db.FilterEq("read", 0), 104 + ) 105 if err != nil { 106 + http.Error(w, "Failed to get unread count", http.StatusInternalServerError) 107 + return
+8 -48
appview/notify/db/db.go
··· 30 31 func (n *databaseNotifier) NewStar(ctx context.Context, star *models.Star) { 32 var err error 33 - repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(star.RepoAt))) 34 if err != nil { 35 log.Printf("NewStar: failed to get repos: %v", err) 36 return 37 } 38 - if len(repos) == 0 { 39 - log.Printf("NewStar: no repo found for %s", star.RepoAt) 40 - return 41 - } 42 - repo := repos[0] 43 44 // don't notify yourself 45 if repo.Did == star.StarredByDid { ··· 76 } 77 78 func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) { 79 - repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(issue.RepoAt))) 80 if err != nil { 81 log.Printf("NewIssue: failed to get repos: %v", err) 82 return 83 } 84 - if len(repos) == 0 { 85 - log.Printf("NewIssue: no repo found for %s", issue.RepoAt) 86 - return 87 - } 88 - repo := repos[0] 89 90 if repo.Did == issue.Did { 91 return ··· 129 } 130 issue := issues[0] 131 132 - repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(issue.RepoAt))) 133 if err != nil { 134 log.Printf("NewIssueComment: failed to get repos: %v", err) 135 return 136 } 137 - if len(repos) == 0 { 138 - log.Printf("NewIssueComment: no repo found for %s", issue.RepoAt) 139 - return 140 - } 141 - repo := repos[0] 142 143 recipients := make(map[string]bool) 144 ··· 211 } 212 213 func (n *databaseNotifier) NewPull(ctx context.Context, pull *models.Pull) { 214 - repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(pull.RepoAt))) 215 if err != nil { 216 log.Printf("NewPull: failed to get repos: %v", err) 217 return 218 } 219 - if len(repos) == 0 { 220 - log.Printf("NewPull: no repo found for %s", pull.RepoAt) 221 - return 222 - } 223 - repo := repos[0] 224 225 if repo.Did == pull.OwnerDid { 226 return ··· 266 } 267 pull := pulls[0] 268 269 - repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", comment.RepoAt)) 270 if err != nil { 271 log.Printf("NewPullComment: failed to get repos: %v", err) 272 return 273 } 274 - if len(repos) == 0 { 275 - log.Printf("NewPullComment: no repo found for %s", comment.RepoAt) 276 - return 277 - } 278 - repo := repos[0] 279 280 recipients := make(map[string]bool) 281 ··· 335 336 func (n *databaseNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) { 337 // Get repo details 338 - repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(issue.RepoAt))) 339 if err != nil { 340 log.Printf("NewIssueClosed: failed to get repos: %v", err) 341 return 342 } 343 - if len(repos) == 0 { 344 - log.Printf("NewIssueClosed: no repo found for %s", issue.RepoAt) 345 - return 346 - } 347 - repo := repos[0] 348 349 // Don't notify yourself 350 if repo.Did == issue.Did { ··· 380 381 func (n *databaseNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) { 382 // Get repo details 383 - repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(pull.RepoAt))) 384 if err != nil { 385 log.Printf("NewPullMerged: failed to get repos: %v", err) 386 return 387 } 388 - if len(repos) == 0 { 389 - log.Printf("NewPullMerged: no repo found for %s", pull.RepoAt) 390 - return 391 - } 392 - repo := repos[0] 393 394 // Don't notify yourself 395 if repo.Did == pull.OwnerDid { ··· 425 426 func (n *databaseNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) { 427 // Get repo details 428 - repos, err := db.GetRepos(n.db, 1, db.FilterEq("at_uri", string(pull.RepoAt))) 429 if err != nil { 430 log.Printf("NewPullClosed: failed to get repos: %v", err) 431 return 432 } 433 - if len(repos) == 0 { 434 - log.Printf("NewPullClosed: no repo found for %s", pull.RepoAt) 435 - return 436 - } 437 - repo := repos[0] 438 439 // Don't notify yourself 440 if repo.Did == pull.OwnerDid {
··· 30 31 func (n *databaseNotifier) NewStar(ctx context.Context, star *models.Star) { 32 var err error 33 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(star.RepoAt))) 34 if err != nil { 35 log.Printf("NewStar: failed to get repos: %v", err) 36 return 37 } 38 39 // don't notify yourself 40 if repo.Did == star.StarredByDid { ··· 71 } 72 73 func (n *databaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) { 74 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt))) 75 if err != nil { 76 log.Printf("NewIssue: failed to get repos: %v", err) 77 return 78 } 79 80 if repo.Did == issue.Did { 81 return ··· 119 } 120 issue := issues[0] 121 122 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt))) 123 if err != nil { 124 log.Printf("NewIssueComment: failed to get repos: %v", err) 125 return 126 } 127 128 recipients := make(map[string]bool) 129 ··· 196 } 197 198 func (n *databaseNotifier) NewPull(ctx context.Context, pull *models.Pull) { 199 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt))) 200 if err != nil { 201 log.Printf("NewPull: failed to get repos: %v", err) 202 return 203 } 204 205 if repo.Did == pull.OwnerDid { 206 return ··· 246 } 247 pull := pulls[0] 248 249 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", comment.RepoAt)) 250 if err != nil { 251 log.Printf("NewPullComment: failed to get repos: %v", err) 252 return 253 } 254 255 recipients := make(map[string]bool) 256 ··· 310 311 func (n *databaseNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) { 312 // Get repo details 313 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(issue.RepoAt))) 314 if err != nil { 315 log.Printf("NewIssueClosed: failed to get repos: %v", err) 316 return 317 } 318 319 // Don't notify yourself 320 if repo.Did == issue.Did { ··· 350 351 func (n *databaseNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) { 352 // Get repo details 353 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt))) 354 if err != nil { 355 log.Printf("NewPullMerged: failed to get repos: %v", err) 356 return 357 } 358 359 // Don't notify yourself 360 if repo.Did == pull.OwnerDid { ··· 390 391 func (n *databaseNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) { 392 // Get repo details 393 + repo, err := db.GetRepo(n.db, db.FilterEq("at_uri", string(pull.RepoAt))) 394 if err != nil { 395 log.Printf("NewPullClosed: failed to get repos: %v", err) 396 return 397 } 398 399 // Don't notify yourself 400 if repo.Did == pull.OwnerDid {
+29 -1
appview/models/notifications.go
··· 1 package models 2 3 - import "time" 4 5 type NotificationType string 6 ··· 32 PullId *int64 33 } 34 35 type NotificationWithEntity struct { 36 *Notification 37 Repo *Repo
··· 1 package models 2 3 + import ( 4 + "time" 5 + ) 6 7 type NotificationType string 8 ··· 34 PullId *int64 35 } 36 37 + // lucide icon that represents this notification 38 + func (n *Notification) Icon() string { 39 + switch n.Type { 40 + case NotificationTypeRepoStarred: 41 + return "star" 42 + case NotificationTypeIssueCreated: 43 + return "circle-dot" 44 + case NotificationTypeIssueCommented: 45 + return "message-square" 46 + case NotificationTypeIssueClosed: 47 + return "ban" 48 + case NotificationTypePullCreated: 49 + return "git-pull-request-create" 50 + case NotificationTypePullCommented: 51 + return "message-square" 52 + case NotificationTypePullMerged: 53 + return "git-merge" 54 + case NotificationTypePullClosed: 55 + return "git-pull-request-closed" 56 + case NotificationTypeFollowed: 57 + return "user-plus" 58 + default: 59 + return "" 60 + } 61 + } 62 + 63 type NotificationWithEntity struct { 64 *Notification 65 Repo *Repo
+73 -35
appview/pages/templates/notifications/fragments/item.html
··· 1 {{define "notifications/fragments/item"}} 2 - <div class="border border-gray-200 dark:border-gray-700 rounded-sm p-3 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors {{if not .Read}}bg-blue-50 dark:bg-blue-900/20{{end}}"> 3 - {{if .Issue}} 4 - {{template "issueNotification" .}} 5 - {{else if .Pull}} 6 - {{template "pullNotification" .}} 7 - {{else if .Repo}} 8 - {{template "repoNotification" .}} 9 - {{else if eq .Type "followed"}} 10 - {{template "followNotification" .}} 11 - {{else}} 12 - {{template "genericNotification" .}} 13 - {{end}} 14 - </div> 15 {{end}} 16 17 - {{define "issueNotification"}} 18 - {{$url := printf "/%s/%s/issues/%d" (resolve .Repo.Did) .Repo.Name .Issue.IssueId}} 19 - <a 20 21 22 23 24 25 - 26 - 27 - 28 - 29 - 30 - 31 - 32 - 33 - 34 - 35 - 36 - 37 - {{ i "ban" "w-4 h-4" }} 38 - </span> 39 - {{end}} 40 - {{template "user/fragments/picHandle" (resolve .ActorDid)}} 41 - {{if eq .Type "issue_created"}} 42 - <span class="text-gray-500 dark:text-gray-400">opened issue</span> 43 - {{else if eq .Type "issue_commented"}}
··· 1 {{define "notifications/fragments/item"}} 2 + <a href="{{ template "notificationUrl" . }}" class="block no-underline hover:no-underline"> 3 + <div 4 + class=" 5 + w-full mx-auto rounded drop-shadow-sm dark:text-white bg-white dark:bg-gray-800 px-2 md:px-6 py-4 transition-colors 6 + {{if not .Read}}bg-blue-50 dark:bg-blue-800/20 border border-blue-500 dark:border-sky-800{{end}} 7 + flex gap-2 items-center 8 + "> 9 + {{ template "notificationIcon" . }} 10 + <div class="flex-1 w-full flex flex-col gap-1"> 11 + <span>{{ template "notificationHeader" . }}</span> 12 + <span class="text-sm text-gray-500 dark:text-gray-400">{{ template "notificationSummary" . }}</span> 13 + </div> 14 + 15 + </div> 16 + </a> 17 {{end}} 18 19 + {{ define "notificationIcon" }} 20 + <div class="flex-shrink-0 max-h-full w-16 h-16 relative"> 21 + <img class="object-cover rounded-full p-2" src="{{ fullAvatar .ActorDid }}" /> 22 + <div class="absolute border-2 border-white dark:border-gray-800 bg-gray-200 dark:bg-gray-700 bottom-1 right-1 rounded-full p-2 flex items-center justify-center z-10"> 23 + {{ i .Icon "size-3 text-black dark:text-white" }} 24 + </div> 25 + </div> 26 + {{ end }} 27 28 + {{ define "notificationHeader" }} 29 + {{ $actor := resolve .ActorDid }} 30 31 + <span class="text-black dark:text-white w-fit">{{ $actor }}</span> 32 + {{ if eq .Type "repo_starred" }} 33 + starred <span class="text-black dark:text-white">{{ resolve .Repo.Did }}/{{ .Repo.Name }}</span> 34 + {{ else if eq .Type "issue_created" }} 35 + opened an issue 36 + {{ else if eq .Type "issue_commented" }} 37 + commented on an issue 38 + {{ else if eq .Type "issue_closed" }} 39 + closed an issue 40 + {{ else if eq .Type "pull_created" }} 41 + created a pull request 42 + {{ else if eq .Type "pull_commented" }} 43 + commented on a pull request 44 + {{ else if eq .Type "pull_merged" }} 45 + merged a pull request 46 + {{ else if eq .Type "pull_closed" }} 47 + closed a pull request 48 + {{ else if eq .Type "followed" }} 49 + followed you 50 + {{ else }} 51 + {{ end }} 52 + {{ end }} 53 54 + {{ define "notificationSummary" }} 55 + {{ if eq .Type "repo_starred" }} 56 + <!-- no summary --> 57 + {{ else if .Issue }} 58 + #{{.Issue.IssueId}} {{.Issue.Title}} on {{resolve .Repo.Did}}/{{.Repo.Name}} 59 + {{ else if .Pull }} 60 + #{{.Pull.PullId}} {{.Pull.Title}} on {{resolve .Repo.Did}}/{{.Repo.Name}} 61 + {{ else if eq .Type "followed" }} 62 + <!-- no summary --> 63 + {{ else }} 64 + {{ end }} 65 + {{ end }} 66 67 + {{ define "notificationUrl" }} 68 + {{ $url := "" }} 69 + {{ if eq .Type "repo_starred" }} 70 + {{$url = printf "/%s/%s" (resolve .Repo.Did) .Repo.Name}} 71 + {{ else if .Issue }} 72 + {{$url = printf "/%s/%s/issues/%d" (resolve .Repo.Did) .Repo.Name .Issue.IssueId}} 73 + {{ else if .Pull }} 74 + {{$url = printf "/%s/%s/pulls/%d" (resolve .Repo.Did) .Repo.Name .Pull.PullId}} 75 + {{ else if eq .Type "followed" }} 76 + {{$url = printf "/%s" (resolve .ActorDid)}} 77 + {{ else }} 78 + {{ end }} 79 80 + {{ $url }} 81 + {{ end }}
+44 -25
appview/pages/templates/notifications/list.html
··· 1 {{ define "title" }}notifications{{ end }} 2 3 {{ define "content" }} 4 - <div class="p-6"> 5 - <div class="flex items-center justify-between mb-4"> 6 <p class="text-xl font-bold dark:text-white">Notifications</p> 7 <a href="/settings/notifications" class="flex items-center gap-2"> 8 {{ i "settings" "w-4 h-4" }} ··· 11 </div> 12 </div> 13 14 - <div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 15 - {{if .Notifications}} 16 - <div class="flex flex-col gap-4" id="notifications-list"> 17 - {{range .Notifications}} 18 - {{template "notifications/fragments/item" .}} 19 - {{end}} 20 - </div> 21 - 22 - {{if .HasMore}} 23 - <div class="mt-6 text-center"> 24 - <button 25 - class="btn gap-2 group" 26 - hx-get="/notifications?offset={{.NextOffset}}&limit={{.Limit}}" 27 - hx-target="#notifications-list" 28 - hx-swap="beforeend" 29 - > 30 - {{ i "chevron-down" "w-4 h-4 group-[.htmx-request]:hidden" }} 31 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 32 - Load more 33 - </button> 34 - </div> 35 {{end}} 36 - {{else}} 37 <div class="text-center py-12"> 38 <div class="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600"> 39 {{ i "bell-off" "w-16 h-16" }} ··· 41 <h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">No notifications</h3> 42 <p class="text-gray-600 dark:text-gray-400">When you receive notifications, they'll appear here.</p> 43 </div> 44 - {{end}} 45 </div> 46 {{ end }}
··· 1 {{ define "title" }}notifications{{ end }} 2 3 {{ define "content" }} 4 + <div class="px-6 py-4"> 5 + <div class="flex items-center justify-between"> 6 <p class="text-xl font-bold dark:text-white">Notifications</p> 7 <a href="/settings/notifications" class="flex items-center gap-2"> 8 {{ i "settings" "w-4 h-4" }} ··· 11 </div> 12 </div> 13 14 + {{if .Notifications}} 15 + <div class="flex flex-col gap-2" id="notifications-list"> 16 + {{range .Notifications}} 17 + {{template "notifications/fragments/item" .}} 18 {{end}} 19 + </div> 20 + 21 + {{else}} 22 + <div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 23 <div class="text-center py-12"> 24 <div class="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600"> 25 {{ i "bell-off" "w-16 h-16" }} ··· 27 <h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">No notifications</h3> 28 <p class="text-gray-600 dark:text-gray-400">When you receive notifications, they'll appear here.</p> 29 </div> 30 + </div> 31 + {{end}} 32 + 33 + {{ template "pagination" . }} 34 + {{ end }} 35 + 36 + {{ define "pagination" }} 37 + <div class="flex justify-end mt-4 gap-2"> 38 + {{ if gt .Page.Offset 0 }} 39 + {{ $prev := .Page.Previous }} 40 + <a 41 + class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 42 + hx-boost="true" 43 + href = "/notifications?offset={{ $prev.Offset }}&limit={{ $prev.Limit }}" 44 + > 45 + {{ i "chevron-left" "w-4 h-4" }} 46 + previous 47 + </a> 48 + {{ else }} 49 + <div></div> 50 + {{ end }} 51 + 52 + {{ $next := .Page.Next }} 53 + {{ if lt $next.Offset .Total }} 54 + {{ $next := .Page.Next }} 55 + <a 56 + class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 57 + hx-boost="true" 58 + href = "/notifications?offset={{ $next.Offset }}&limit={{ $next.Limit }}" 59 + > 60 + next 61 + {{ i "chevron-right" "w-4 h-4" }} 62 + </a> 63 + {{ end }} 64 </div> 65 {{ end }}
+1 -1
appview/pagination/page.go
··· 8 func FirstPage() Page { 9 return Page{ 10 Offset: 0, 11 - Limit: 10, 12 } 13 } 14
··· 8 func FirstPage() Page { 9 return Page{ 10 Offset: 0, 11 + Limit: 30, 12 } 13 } 14
+27 -18
appview/pages/templates/layouts/base.html
··· 18 19 20 21 22 23 24 ··· 26 27 28 29 30 - 31 - 32 - 33 - 34 - 35 - 36 - 37 - 38 - 39 - 40 {{ block "mainLayout" . }} 41 - <div class="px-1 col-span-full md:col-span-1 md:col-start-2 flex flex-col gap-4"> 42 - {{ block "contentLayout" . }} 43 - <main class="col-span-1 md:col-span-8"> 44 {{ block "content" . }}{{ end }} 45 </main> 46 - {{ end }} 47 - 48 - {{ block "contentAfterLayout" . }} 49 - <main class="col-span-1 md:col-span-8"> 50 {{ block "contentAfter" . }}{{ end }} 51 </main> 52 - {{ end }}
··· 18 19 20 21 + <title>{{ block "title" . }}{{ end }} · tangled</title> 22 + {{ block "extrameta" . }}{{ end }} 23 + </head> 24 + <body class="min-h-screen flex flex-col gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200"> 25 + {{ block "topbarLayout" . }} 26 + <header class="w-full bg-white dark:bg-gray-800 col-span-full md:col-span-1 md:col-start-2" style="z-index: 20;"> 27 28 + {{ if .LoggedInUser }} 29 + <div id="upgrade-banner" 30 31 32 ··· 34 35 36 37 + {{ end }} 38 39 {{ block "mainLayout" . }} 40 + <div class="flex-grow"> 41 + <div class="max-w-screen-lg px-4 mx-auto my-6 flex flex-col gap-4"> 42 + {{ block "contentLayout" . }} 43 + <main> 44 {{ block "content" . }}{{ end }} 45 </main> 46 + {{ end }} 47 + 48 + {{ block "contentAfterLayout" . }} 49 + <main> 50 {{ block "contentAfter" . }}{{ end }} 51 </main> 52 + {{ end }} 53 + </div> 54 + </div> 55 + {{ end }} 56 + 57 + {{ block "footerLayout" . }} 58 + <footer class="bg-white dark:bg-gray-800 mt-12"> 59 + {{ template "layouts/fragments/footer" . }} 60 + </footer> 61 + {{ end }}
+2 -2
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 no-underline hover:no-underline">
··· 1 {{ define "layouts/fragments/footer" }} 2 + <div class="w-full p-8"> 3 + <div class="max-w-screen-lg mx-auto 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 no-underline hover:no-underline">
+1 -1
appview/pages/templates/layouts/fragments/topbar.html
··· 1 {{ define "layouts/fragments/topbar" }} 2 - <nav class="space-x-4 px-6 py-2 rounded-b 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="text-2xl no-underline hover:no-underline flex items-center gap-2">
··· 1 {{ define "layouts/fragments/topbar" }} 2 + <nav class="max-w-screen-lg mx-auto space-x-4 px-6 py-2 rounded-b 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="text-2xl no-underline hover:no-underline flex items-center gap-2">

History

2 rounds 0 comments
sign up or login to add to the discussion
12 commits
expand
appview/signup: set up cf turnstile
appview/pages: center captcha in signup page
appview: associate users to commits by did
appview/repo: add an option to choose the name of the forked repo
appview/db: refactor GetPulls
appview/pages: add labels to pulls
appview/notifications: code cleanup for notifier
appview/pages: improve notification styles
appview/notifications: fix pagination
appview/notifications: add link element to each notification
Remove redundant grid item properties in layouts/base
Header and footer background is full page width
expand 0 comments
closed without merging
2 commits
expand
Remove redundant grid item properties in layouts/base
Header and footer background is full page width
expand 0 comments