forked from tangled.org/core
Monorepo for Tangled — https://tangled.org

Compare changes

Choose any two refs to compare.

Changed files
+4574 -1352
.tangled
workflows
api
tangled
appview
docs
spindle
knotserver
legal
lexicons
repo
nix
types
+6
.tangled/workflows/test.yml
··· 14 14 command: | 15 15 mkdir -p appview/pages/static; touch appview/pages/static/x 16 16 17 + - name: run linter 18 + environment: 19 + CGO_ENABLED: 1 20 + command: | 21 + go vet -v ./... 22 + 17 23 - name: run all tests 18 24 environment: 19 25 CGO_ENABLED: 1
+10
api/tangled/repotree.go
··· 31 31 Files []*RepoTree_TreeEntry `json:"files" cborgen:"files"` 32 32 // parent: The parent path in the tree 33 33 Parent *string `json:"parent,omitempty" cborgen:"parent,omitempty"` 34 + // readme: Readme for this file tree 35 + Readme *RepoTree_Readme `json:"readme,omitempty" cborgen:"readme,omitempty"` 34 36 // ref: The git reference used 35 37 Ref string `json:"ref" cborgen:"ref"` 38 + } 39 + 40 + // RepoTree_Readme is a "readme" in the sh.tangled.repo.tree schema. 41 + type RepoTree_Readme struct { 42 + // contents: Contents of the readme file 43 + Contents string `json:"contents" cborgen:"contents"` 44 + // filename: Name of the readme file 45 + Filename string `json:"filename" cborgen:"filename"` 36 46 } 37 47 38 48 // RepoTree_TreeEntry is a "treeEntry" in the sh.tangled.repo.tree schema.
+4 -2
appview/config/config.go
··· 72 72 } 73 73 74 74 type Cloudflare struct { 75 - ApiToken string `env:"API_TOKEN"` 76 - ZoneId string `env:"ZONE_ID"` 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"` 77 79 } 78 80 79 81 func (cfg RedisConfig) ToURL() string {
+172 -10
appview/db/db.go
··· 527 527 -- label to subscribe to 528 528 label_at text not null, 529 529 530 - unique (repo_at, label_at), 531 - foreign key (label_at) references label_definitions (at_uri) 530 + unique (repo_at, label_at) 531 + ); 532 + 533 + create table if not exists notifications ( 534 + id integer primary key autoincrement, 535 + recipient_did text not null, 536 + actor_did text not null, 537 + type text not null, 538 + entity_type text not null, 539 + entity_id text not null, 540 + read integer not null default 0, 541 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 542 + repo_id integer references repos(id), 543 + issue_id integer references issues(id), 544 + pull_id integer references pulls(id) 545 + ); 546 + 547 + create table if not exists notification_preferences ( 548 + id integer primary key autoincrement, 549 + user_did text not null unique, 550 + repo_starred integer not null default 1, 551 + issue_created integer not null default 1, 552 + issue_commented integer not null default 1, 553 + pull_created integer not null default 1, 554 + pull_commented integer not null default 1, 555 + followed integer not null default 1, 556 + pull_merged integer not null default 1, 557 + issue_closed integer not null default 1, 558 + email_notifications integer not null default 0 532 559 ); 533 560 534 561 create table if not exists migrations ( ··· 536 563 name text unique 537 564 ); 538 565 539 - -- indexes for better star query performance 566 + -- indexes for better performance 567 + create index if not exists idx_notifications_recipient_created on notifications(recipient_did, created desc); 568 + create index if not exists idx_notifications_recipient_read on notifications(recipient_did, read); 540 569 create index if not exists idx_stars_created on stars(created); 541 570 create index if not exists idx_stars_repo_at_created on stars(repo_at, created); 542 571 `) ··· 788 817 _, err := tx.Exec(` 789 818 alter table spindles add column needs_upgrade integer not null default 0; 790 819 `) 791 - if err != nil { 792 - return err 793 - } 794 - 795 - _, err = tx.Exec(` 796 - update spindles set needs_upgrade = 1; 797 - `) 798 820 return err 799 821 }) 800 822 ··· 931 953 _, err = tx.Exec(`drop table comments`) 932 954 return err 933 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;") 934 1096 935 1097 return &DB{db}, nil 936 1098 }
+13 -9
appview/db/email.go
··· 71 71 return did, nil 72 72 } 73 73 74 - func GetEmailToDid(e Execer, ems []string, isVerifiedFilter bool) (map[string]string, error) { 75 - if len(ems) == 0 { 74 + func GetEmailToDid(e Execer, emails []string, isVerifiedFilter bool) (map[string]string, error) { 75 + if len(emails) == 0 { 76 76 return make(map[string]string), nil 77 77 } 78 78 ··· 80 80 if isVerifiedFilter { 81 81 verifiedFilter = 1 82 82 } 83 + 84 + assoc := make(map[string]string) 83 85 84 86 // Create placeholders for the IN clause 85 - placeholders := make([]string, len(ems)) 86 - args := make([]any, len(ems)+1) 87 + placeholders := make([]string, 0, len(emails)) 88 + args := make([]any, 1, len(emails)+1) 87 89 88 90 args[0] = verifiedFilter 89 - for i, em := range ems { 90 - placeholders[i] = "?" 91 - args[i+1] = em 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) 92 98 } 93 99 94 100 query := ` ··· 104 110 return nil, err 105 111 } 106 112 defer rows.Close() 107 - 108 - assoc := make(map[string]string) 109 113 110 114 for rows.Next() { 111 115 var email, did string
+1 -1
appview/db/label.go
··· 349 349 defs[l.AtUri().String()] = &l 350 350 } 351 351 352 - return &models.LabelApplicationCtx{defs}, nil 352 + return &models.LabelApplicationCtx{Defs: defs}, nil 353 353 }
+34
appview/db/language.go
··· 1 1 package db 2 2 3 3 import ( 4 + "database/sql" 4 5 "fmt" 5 6 "strings" 6 7 8 + "github.com/bluesky-social/indigo/atproto/syntax" 7 9 "tangled.org/core/appview/models" 8 10 ) 9 11 ··· 82 84 83 85 return nil 84 86 } 87 + 88 + func DeleteRepoLanguages(e Execer, filters ...filter) error { 89 + var conditions []string 90 + var args []any 91 + for _, filter := range filters { 92 + conditions = append(conditions, filter.Condition()) 93 + args = append(args, filter.Arg()...) 94 + } 95 + 96 + whereClause := "" 97 + if conditions != nil { 98 + whereClause = " where " + strings.Join(conditions, " and ") 99 + } 100 + 101 + query := fmt.Sprintf(`delete from repo_languages %s`, whereClause) 102 + 103 + _, err := e.Exec(query, args...) 104 + return err 105 + } 106 + 107 + func UpdateRepoLanguages(tx *sql.Tx, repoAt syntax.ATURI, ref string, langs []models.RepoLanguage) error { 108 + err := DeleteRepoLanguages( 109 + tx, 110 + FilterEq("repo_at", repoAt), 111 + FilterEq("ref", ref), 112 + ) 113 + if err != nil { 114 + return fmt.Errorf("failed to delete existing languages: %w", err) 115 + } 116 + 117 + return InsertRepoLanguages(tx, langs) 118 + }
+450
appview/db/notifications.go
··· 1 + package db 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "errors" 7 + "fmt" 8 + "strings" 9 + "time" 10 + 11 + "tangled.org/core/appview/models" 12 + "tangled.org/core/appview/pagination" 13 + ) 14 + 15 + func (d *DB) CreateNotification(ctx context.Context, notification *models.Notification) error { 16 + query := ` 17 + INSERT INTO notifications (recipient_did, actor_did, type, entity_type, entity_id, read, repo_id, issue_id, pull_id) 18 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) 19 + ` 20 + 21 + result, err := d.DB.ExecContext(ctx, query, 22 + notification.RecipientDid, 23 + notification.ActorDid, 24 + string(notification.Type), 25 + notification.EntityType, 26 + notification.EntityId, 27 + notification.Read, 28 + notification.RepoId, 29 + notification.IssueId, 30 + notification.PullId, 31 + ) 32 + if err != nil { 33 + return fmt.Errorf("failed to create notification: %w", err) 34 + } 35 + 36 + id, err := result.LastInsertId() 37 + if err != nil { 38 + return fmt.Errorf("failed to get notification ID: %w", err) 39 + } 40 + 41 + notification.ID = id 42 + return nil 43 + } 44 + 45 + // GetNotificationsPaginated retrieves notifications with filters and pagination 46 + func GetNotificationsPaginated(e Execer, page pagination.Page, filters ...filter) ([]*models.Notification, error) { 47 + var conditions []string 48 + var args []any 49 + 50 + for _, filter := range filters { 51 + conditions = append(conditions, filter.Condition()) 52 + args = append(args, filter.Arg()...) 53 + } 54 + 55 + whereClause := "" 56 + if len(conditions) > 0 { 57 + whereClause = "WHERE " + conditions[0] 58 + for _, condition := range conditions[1:] { 59 + whereClause += " AND " + condition 60 + } 61 + } 62 + 63 + query := fmt.Sprintf(` 64 + select id, recipient_did, actor_did, type, entity_type, entity_id, read, created, repo_id, issue_id, pull_id 65 + from notifications 66 + %s 67 + order by created desc 68 + limit ? offset ? 69 + `, whereClause) 70 + 71 + args = append(args, page.Limit, page.Offset) 72 + 73 + rows, err := e.QueryContext(context.Background(), query, args...) 74 + if err != nil { 75 + return nil, fmt.Errorf("failed to query notifications: %w", err) 76 + } 77 + defer rows.Close() 78 + 79 + var notifications []*models.Notification 80 + for rows.Next() { 81 + var n models.Notification 82 + var typeStr string 83 + var createdStr string 84 + err := rows.Scan( 85 + &n.ID, 86 + &n.RecipientDid, 87 + &n.ActorDid, 88 + &typeStr, 89 + &n.EntityType, 90 + &n.EntityId, 91 + &n.Read, 92 + &createdStr, 93 + &n.RepoId, 94 + &n.IssueId, 95 + &n.PullId, 96 + ) 97 + if err != nil { 98 + return nil, fmt.Errorf("failed to scan notification: %w", err) 99 + } 100 + n.Type = models.NotificationType(typeStr) 101 + n.Created, err = time.Parse(time.RFC3339, createdStr) 102 + if err != nil { 103 + return nil, fmt.Errorf("failed to parse created timestamp: %w", err) 104 + } 105 + notifications = append(notifications, &n) 106 + } 107 + 108 + return notifications, nil 109 + } 110 + 111 + // GetNotificationsWithEntities retrieves notifications with their related entities 112 + func GetNotificationsWithEntities(e Execer, page pagination.Page, filters ...filter) ([]*models.NotificationWithEntity, error) { 113 + var conditions []string 114 + var args []any 115 + 116 + for _, filter := range filters { 117 + conditions = append(conditions, filter.Condition()) 118 + args = append(args, filter.Arg()...) 119 + } 120 + 121 + whereClause := "" 122 + if len(conditions) > 0 { 123 + whereClause = "WHERE " + conditions[0] 124 + for _, condition := range conditions[1:] { 125 + whereClause += " AND " + condition 126 + } 127 + } 128 + 129 + query := fmt.Sprintf(` 130 + select 131 + n.id, n.recipient_did, n.actor_did, n.type, n.entity_type, n.entity_id, 132 + n.read, n.created, n.repo_id, n.issue_id, n.pull_id, 133 + r.id as r_id, r.did as r_did, r.name as r_name, r.description as r_description, 134 + i.id as i_id, i.did as i_did, i.issue_id as i_issue_id, i.title as i_title, i.open as i_open, 135 + p.id as p_id, p.owner_did as p_owner_did, p.pull_id as p_pull_id, p.title as p_title, p.state as p_state 136 + from notifications n 137 + left join repos r on n.repo_id = r.id 138 + left join issues i on n.issue_id = i.id 139 + left join pulls p on n.pull_id = p.id 140 + %s 141 + order by n.created desc 142 + limit ? offset ? 143 + `, whereClause) 144 + 145 + args = append(args, page.Limit, page.Offset) 146 + 147 + rows, err := e.QueryContext(context.Background(), query, args...) 148 + if err != nil { 149 + return nil, fmt.Errorf("failed to query notifications with entities: %w", err) 150 + } 151 + defer rows.Close() 152 + 153 + var notifications []*models.NotificationWithEntity 154 + for rows.Next() { 155 + var n models.Notification 156 + var typeStr string 157 + var createdStr string 158 + var repo models.Repo 159 + var issue models.Issue 160 + var pull models.Pull 161 + var rId, iId, pId sql.NullInt64 162 + var rDid, rName, rDescription sql.NullString 163 + var iDid sql.NullString 164 + var iIssueId sql.NullInt64 165 + var iTitle sql.NullString 166 + var iOpen sql.NullBool 167 + var pOwnerDid sql.NullString 168 + var pPullId sql.NullInt64 169 + var pTitle sql.NullString 170 + var pState sql.NullInt64 171 + 172 + err := rows.Scan( 173 + &n.ID, &n.RecipientDid, &n.ActorDid, &typeStr, &n.EntityType, &n.EntityId, 174 + &n.Read, &createdStr, &n.RepoId, &n.IssueId, &n.PullId, 175 + &rId, &rDid, &rName, &rDescription, 176 + &iId, &iDid, &iIssueId, &iTitle, &iOpen, 177 + &pId, &pOwnerDid, &pPullId, &pTitle, &pState, 178 + ) 179 + if err != nil { 180 + return nil, fmt.Errorf("failed to scan notification with entities: %w", err) 181 + } 182 + 183 + n.Type = models.NotificationType(typeStr) 184 + n.Created, err = time.Parse(time.RFC3339, createdStr) 185 + if err != nil { 186 + return nil, fmt.Errorf("failed to parse created timestamp: %w", err) 187 + } 188 + 189 + nwe := &models.NotificationWithEntity{Notification: &n} 190 + 191 + // populate repo if present 192 + if rId.Valid { 193 + repo.Id = rId.Int64 194 + if rDid.Valid { 195 + repo.Did = rDid.String 196 + } 197 + if rName.Valid { 198 + repo.Name = rName.String 199 + } 200 + if rDescription.Valid { 201 + repo.Description = rDescription.String 202 + } 203 + nwe.Repo = &repo 204 + } 205 + 206 + // populate issue if present 207 + if iId.Valid { 208 + issue.Id = iId.Int64 209 + if iDid.Valid { 210 + issue.Did = iDid.String 211 + } 212 + if iIssueId.Valid { 213 + issue.IssueId = int(iIssueId.Int64) 214 + } 215 + if iTitle.Valid { 216 + issue.Title = iTitle.String 217 + } 218 + if iOpen.Valid { 219 + issue.Open = iOpen.Bool 220 + } 221 + nwe.Issue = &issue 222 + } 223 + 224 + // populate pull if present 225 + if pId.Valid { 226 + pull.ID = int(pId.Int64) 227 + if pOwnerDid.Valid { 228 + pull.OwnerDid = pOwnerDid.String 229 + } 230 + if pPullId.Valid { 231 + pull.PullId = int(pPullId.Int64) 232 + } 233 + if pTitle.Valid { 234 + pull.Title = pTitle.String 235 + } 236 + if pState.Valid { 237 + pull.State = models.PullState(pState.Int64) 238 + } 239 + nwe.Pull = &pull 240 + } 241 + 242 + notifications = append(notifications, nwe) 243 + } 244 + 245 + return notifications, nil 246 + } 247 + 248 + // GetNotifications retrieves notifications with filters 249 + func GetNotifications(e Execer, filters ...filter) ([]*models.Notification, error) { 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 275 + } 276 + 277 + func (d *DB) MarkNotificationRead(ctx context.Context, notificationID int64, userDID string) error { 278 + idFilter := FilterEq("id", notificationID) 279 + recipientFilter := FilterEq("recipient_did", userDID) 280 + 281 + query := fmt.Sprintf(` 282 + UPDATE notifications 283 + SET read = 1 284 + WHERE %s AND %s 285 + `, idFilter.Condition(), recipientFilter.Condition()) 286 + 287 + args := append(idFilter.Arg(), recipientFilter.Arg()...) 288 + 289 + result, err := d.DB.ExecContext(ctx, query, args...) 290 + if err != nil { 291 + return fmt.Errorf("failed to mark notification as read: %w", err) 292 + } 293 + 294 + rowsAffected, err := result.RowsAffected() 295 + if err != nil { 296 + return fmt.Errorf("failed to get rows affected: %w", err) 297 + } 298 + 299 + if rowsAffected == 0 { 300 + return fmt.Errorf("notification not found or access denied") 301 + } 302 + 303 + return nil 304 + } 305 + 306 + func (d *DB) MarkAllNotificationsRead(ctx context.Context, userDID string) error { 307 + recipientFilter := FilterEq("recipient_did", userDID) 308 + readFilter := FilterEq("read", 0) 309 + 310 + query := fmt.Sprintf(` 311 + UPDATE notifications 312 + SET read = 1 313 + WHERE %s AND %s 314 + `, recipientFilter.Condition(), readFilter.Condition()) 315 + 316 + args := append(recipientFilter.Arg(), readFilter.Arg()...) 317 + 318 + _, err := d.DB.ExecContext(ctx, query, args...) 319 + if err != nil { 320 + return fmt.Errorf("failed to mark all notifications as read: %w", err) 321 + } 322 + 323 + return nil 324 + } 325 + 326 + func (d *DB) DeleteNotification(ctx context.Context, notificationID int64, userDID string) error { 327 + idFilter := FilterEq("id", notificationID) 328 + recipientFilter := FilterEq("recipient_did", userDID) 329 + 330 + query := fmt.Sprintf(` 331 + DELETE FROM notifications 332 + WHERE %s AND %s 333 + `, idFilter.Condition(), recipientFilter.Condition()) 334 + 335 + args := append(idFilter.Arg(), recipientFilter.Arg()...) 336 + 337 + result, err := d.DB.ExecContext(ctx, query, args...) 338 + if err != nil { 339 + return fmt.Errorf("failed to delete notification: %w", err) 340 + } 341 + 342 + rowsAffected, err := result.RowsAffected() 343 + if err != nil { 344 + return fmt.Errorf("failed to get rows affected: %w", err) 345 + } 346 + 347 + if rowsAffected == 0 { 348 + return fmt.Errorf("notification not found or access denied") 349 + } 350 + 351 + return nil 352 + } 353 + 354 + func (d *DB) GetNotificationPreferences(ctx context.Context, userDID string) (*models.NotificationPreferences, error) { 355 + userFilter := FilterEq("user_did", userDID) 356 + 357 + query := fmt.Sprintf(` 358 + SELECT id, user_did, repo_starred, issue_created, issue_commented, pull_created, 359 + pull_commented, followed, pull_merged, issue_closed, email_notifications 360 + FROM notification_preferences 361 + WHERE %s 362 + `, userFilter.Condition()) 363 + 364 + var prefs models.NotificationPreferences 365 + err := d.DB.QueryRowContext(ctx, query, userFilter.Arg()...).Scan( 366 + &prefs.ID, 367 + &prefs.UserDid, 368 + &prefs.RepoStarred, 369 + &prefs.IssueCreated, 370 + &prefs.IssueCommented, 371 + &prefs.PullCreated, 372 + &prefs.PullCommented, 373 + &prefs.Followed, 374 + &prefs.PullMerged, 375 + &prefs.IssueClosed, 376 + &prefs.EmailNotifications, 377 + ) 378 + 379 + if err != nil { 380 + if err == sql.ErrNoRows { 381 + return &models.NotificationPreferences{ 382 + UserDid: userDID, 383 + RepoStarred: true, 384 + IssueCreated: true, 385 + IssueCommented: true, 386 + PullCreated: true, 387 + PullCommented: true, 388 + Followed: true, 389 + PullMerged: true, 390 + IssueClosed: true, 391 + EmailNotifications: false, 392 + }, nil 393 + } 394 + return nil, fmt.Errorf("failed to get notification preferences: %w", err) 395 + } 396 + 397 + return &prefs, nil 398 + } 399 + 400 + func (d *DB) UpdateNotificationPreferences(ctx context.Context, prefs *models.NotificationPreferences) error { 401 + query := ` 402 + INSERT OR REPLACE INTO notification_preferences 403 + (user_did, repo_starred, issue_created, issue_commented, pull_created, 404 + pull_commented, followed, pull_merged, issue_closed, email_notifications) 405 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 406 + ` 407 + 408 + result, err := d.DB.ExecContext(ctx, query, 409 + prefs.UserDid, 410 + prefs.RepoStarred, 411 + prefs.IssueCreated, 412 + prefs.IssueCommented, 413 + prefs.PullCreated, 414 + prefs.PullCommented, 415 + prefs.Followed, 416 + prefs.PullMerged, 417 + prefs.IssueClosed, 418 + prefs.EmailNotifications, 419 + ) 420 + if err != nil { 421 + return fmt.Errorf("failed to update notification preferences: %w", err) 422 + } 423 + 424 + if prefs.ID == 0 { 425 + id, err := result.LastInsertId() 426 + if err != nil { 427 + return fmt.Errorf("failed to get preferences ID: %w", err) 428 + } 429 + prefs.ID = id 430 + } 431 + 432 + return nil 433 + } 434 + 435 + func (d *DB) ClearOldNotifications(ctx context.Context, olderThan time.Duration) error { 436 + cutoff := time.Now().Add(-olderThan) 437 + createdFilter := FilterLte("created", cutoff) 438 + 439 + query := fmt.Sprintf(` 440 + DELETE FROM notifications 441 + WHERE %s 442 + `, createdFilter.Condition()) 443 + 444 + _, err := d.DB.ExecContext(ctx, query, createdFilter.Arg()...) 445 + if err != nil { 446 + return fmt.Errorf("failed to cleanup old notifications: %w", err) 447 + } 448 + 449 + return nil 450 + }
+155 -228
appview/db/pulls.go
··· 1 1 package db 2 2 3 3 import ( 4 + "cmp" 4 5 "database/sql" 6 + "errors" 5 7 "fmt" 6 - "log" 8 + "maps" 9 + "slices" 7 10 "sort" 8 11 "strings" 9 12 "time" ··· 55 58 parentChangeId = &pull.ParentChangeId 56 59 } 57 60 58 - _, err = tx.Exec( 61 + result, err := tx.Exec( 59 62 ` 60 63 insert into pulls ( 61 64 repo_at, owner_did, pull_id, title, target_branch, body, rkey, state, source_branch, source_repo_at, stack_id, change_id, parent_change_id ··· 79 82 return err 80 83 } 81 84 85 + // Set the database primary key ID 86 + id, err := result.LastInsertId() 87 + if err != nil { 88 + return err 89 + } 90 + pull.ID = int(id) 91 + 82 92 _, err = tx.Exec(` 83 - insert into pull_submissions (pull_id, repo_at, round_number, patch, source_rev) 84 - values (?, ?, ?, ?, ?) 85 - `, pull.PullId, pull.RepoAt, 0, pull.Submissions[0].Patch, pull.Submissions[0].SourceRev) 93 + insert into pull_submissions (pull_at, round_number, patch, source_rev) 94 + values (?, ?, ?, ?) 95 + `, pull.PullAt(), 0, pull.Submissions[0].Patch, pull.Submissions[0].SourceRev) 86 96 return err 87 97 } 88 98 ··· 101 111 } 102 112 103 113 func GetPullsWithLimit(e Execer, limit int, filters ...filter) ([]*models.Pull, error) { 104 - pulls := make(map[int]*models.Pull) 114 + pulls := make(map[syntax.ATURI]*models.Pull) 105 115 106 116 var conditions []string 107 117 var args []any ··· 121 131 122 132 query := fmt.Sprintf(` 123 133 select 134 + id, 124 135 owner_did, 125 136 repo_at, 126 137 pull_id, ··· 154 165 var createdAt string 155 166 var sourceBranch, sourceRepoAt, stackId, changeId, parentChangeId sql.NullString 156 167 err := rows.Scan( 168 + &pull.ID, 157 169 &pull.OwnerDid, 158 170 &pull.RepoAt, 159 171 &pull.PullId, ··· 202 214 pull.ParentChangeId = parentChangeId.String 203 215 } 204 216 205 - pulls[pull.PullId] = &pull 217 + pulls[pull.PullAt()] = &pull 206 218 } 207 219 208 - // get latest round no. for each pull 209 - inClause := strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ") 210 - submissionsQuery := fmt.Sprintf(` 211 - select 212 - id, pull_id, round_number, patch, created, source_rev 213 - from 214 - pull_submissions 215 - where 216 - repo_at in (%s) and pull_id in (%s) 217 - `, inClause, inClause) 218 - 219 - args = make([]any, len(pulls)*2) 220 - idx := 0 221 - for _, p := range pulls { 222 - args[idx] = p.RepoAt 223 - idx += 1 224 - } 220 + var pullAts []syntax.ATURI 225 221 for _, p := range pulls { 226 - args[idx] = p.PullId 227 - idx += 1 222 + pullAts = append(pullAts, p.PullAt()) 228 223 } 229 - submissionsRows, err := e.Query(submissionsQuery, args...) 224 + submissionsMap, err := GetPullSubmissions(e, FilterIn("pull_at", pullAts)) 230 225 if err != nil { 231 - return nil, err 226 + return nil, fmt.Errorf("failed to get submissions: %w", err) 232 227 } 233 - defer submissionsRows.Close() 234 228 235 - for submissionsRows.Next() { 236 - var s models.PullSubmission 237 - var sourceRev sql.NullString 238 - var createdAt string 239 - err := submissionsRows.Scan( 240 - &s.ID, 241 - &s.PullId, 242 - &s.RoundNumber, 243 - &s.Patch, 244 - &createdAt, 245 - &sourceRev, 246 - ) 247 - if err != nil { 248 - return nil, err 249 - } 250 - 251 - createdTime, err := time.Parse(time.RFC3339, createdAt) 252 - if err != nil { 253 - return nil, err 254 - } 255 - s.Created = createdTime 256 - 257 - if sourceRev.Valid { 258 - s.SourceRev = sourceRev.String 229 + for pullAt, submissions := range submissionsMap { 230 + if p, ok := pulls[pullAt]; ok { 231 + p.Submissions = submissions 259 232 } 233 + } 260 234 261 - if p, ok := pulls[s.PullId]; ok { 262 - p.Submissions = make([]*models.PullSubmission, s.RoundNumber+1) 263 - p.Submissions[s.RoundNumber] = &s 264 - } 235 + // collect allLabels for each issue 236 + allLabels, err := GetLabels(e, FilterIn("subject", pullAts)) 237 + if err != nil { 238 + return nil, fmt.Errorf("failed to query labels: %w", err) 265 239 } 266 - if err := rows.Err(); err != nil { 267 - return nil, err 240 + for pullAt, labels := range allLabels { 241 + if p, ok := pulls[pullAt]; ok { 242 + p.Labels = labels 243 + } 268 244 } 269 245 270 - // get comment count on latest submission on each pull 271 - inClause = strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ") 272 - commentsQuery := fmt.Sprintf(` 273 - select 274 - count(id), pull_id 275 - from 276 - pull_comments 277 - where 278 - submission_id in (%s) 279 - group by 280 - submission_id 281 - `, inClause) 282 - 283 - args = []any{} 246 + // collect pull source for all pulls that need it 247 + var sourceAts []syntax.ATURI 284 248 for _, p := range pulls { 285 - args = append(args, p.Submissions[p.LastRoundNumber()].ID) 249 + if p.PullSource != nil && p.PullSource.RepoAt != nil { 250 + sourceAts = append(sourceAts, *p.PullSource.RepoAt) 251 + } 286 252 } 287 - commentsRows, err := e.Query(commentsQuery, args...) 288 - if err != nil { 289 - return nil, err 253 + sourceRepos, err := GetRepos(e, 0, FilterIn("at_uri", sourceAts)) 254 + if err != nil && !errors.Is(err, sql.ErrNoRows) { 255 + return nil, fmt.Errorf("failed to get source repos: %w", err) 290 256 } 291 - defer commentsRows.Close() 292 - 293 - for commentsRows.Next() { 294 - var commentCount, pullId int 295 - err := commentsRows.Scan( 296 - &commentCount, 297 - &pullId, 298 - ) 299 - if err != nil { 300 - return nil, err 301 - } 302 - if p, ok := pulls[pullId]; ok { 303 - p.Submissions[p.LastRoundNumber()].Comments = make([]models.PullComment, commentCount) 304 - } 257 + sourceRepoMap := make(map[syntax.ATURI]*models.Repo) 258 + for _, r := range sourceRepos { 259 + sourceRepoMap[r.RepoAt()] = &r 305 260 } 306 - if err := rows.Err(); err != nil { 307 - return nil, err 261 + for _, p := range pulls { 262 + if p.PullSource != nil && p.PullSource.RepoAt != nil { 263 + if sourceRepo, ok := sourceRepoMap[*p.PullSource.RepoAt]; ok { 264 + p.PullSource.Repo = sourceRepo 265 + } 266 + } 308 267 } 309 268 310 269 orderedByPullId := []*models.Pull{} ··· 323 282 } 324 283 325 284 func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*models.Pull, error) { 326 - query := ` 327 - select 328 - owner_did, 329 - pull_id, 330 - created, 331 - title, 332 - state, 333 - target_branch, 334 - repo_at, 335 - body, 336 - rkey, 337 - source_branch, 338 - source_repo_at, 339 - stack_id, 340 - change_id, 341 - parent_change_id 342 - from 343 - pulls 344 - where 345 - repo_at = ? and pull_id = ? 346 - ` 347 - row := e.QueryRow(query, repoAt, pullId) 348 - 349 - var pull models.Pull 350 - var createdAt string 351 - var sourceBranch, sourceRepoAt, stackId, changeId, parentChangeId sql.NullString 352 - err := row.Scan( 353 - &pull.OwnerDid, 354 - &pull.PullId, 355 - &createdAt, 356 - &pull.Title, 357 - &pull.State, 358 - &pull.TargetBranch, 359 - &pull.RepoAt, 360 - &pull.Body, 361 - &pull.Rkey, 362 - &sourceBranch, 363 - &sourceRepoAt, 364 - &stackId, 365 - &changeId, 366 - &parentChangeId, 367 - ) 285 + pulls, err := GetPullsWithLimit(e, 1, FilterEq("repo_at", repoAt), FilterEq("pull_id", pullId)) 368 286 if err != nil { 369 287 return nil, err 370 288 } 289 + if pulls == nil { 290 + return nil, sql.ErrNoRows 291 + } 371 292 372 - createdTime, err := time.Parse(time.RFC3339, createdAt) 373 - if err != nil { 374 - return nil, err 375 - } 376 - pull.Created = createdTime 293 + return pulls[0], nil 294 + } 377 295 378 - // populate source 379 - if sourceBranch.Valid { 380 - pull.PullSource = &models.PullSource{ 381 - Branch: sourceBranch.String, 382 - } 383 - if sourceRepoAt.Valid { 384 - sourceRepoAtParsed, err := syntax.ParseATURI(sourceRepoAt.String) 385 - if err != nil { 386 - return nil, err 387 - } 388 - pull.PullSource.RepoAt = &sourceRepoAtParsed 389 - } 296 + // mapping from pull -> pull submissions 297 + func GetPullSubmissions(e Execer, filters ...filter) (map[syntax.ATURI][]*models.PullSubmission, error) { 298 + var conditions []string 299 + var args []any 300 + for _, filter := range filters { 301 + conditions = append(conditions, filter.Condition()) 302 + args = append(args, filter.Arg()...) 390 303 } 391 304 392 - if stackId.Valid { 393 - pull.StackId = stackId.String 394 - } 395 - if changeId.Valid { 396 - pull.ChangeId = changeId.String 397 - } 398 - if parentChangeId.Valid { 399 - pull.ParentChangeId = parentChangeId.String 305 + whereClause := "" 306 + if conditions != nil { 307 + whereClause = " where " + strings.Join(conditions, " and ") 400 308 } 401 309 402 - submissionsQuery := ` 310 + query := fmt.Sprintf(` 403 311 select 404 - id, pull_id, repo_at, round_number, patch, created, source_rev 312 + id, 313 + pull_at, 314 + round_number, 315 + patch, 316 + created, 317 + source_rev 405 318 from 406 319 pull_submissions 407 - where 408 - repo_at = ? and pull_id = ? 409 - ` 410 - submissionsRows, err := e.Query(submissionsQuery, repoAt, pullId) 320 + %s 321 + order by 322 + round_number asc 323 + `, whereClause) 324 + 325 + rows, err := e.Query(query, args...) 411 326 if err != nil { 412 327 return nil, err 413 328 } 414 - defer submissionsRows.Close() 329 + defer rows.Close() 415 330 416 - submissionsMap := make(map[int]*models.PullSubmission) 331 + submissionMap := make(map[int]*models.PullSubmission) 417 332 418 - for submissionsRows.Next() { 333 + for rows.Next() { 419 334 var submission models.PullSubmission 420 - var submissionCreatedStr string 421 - var submissionSourceRev sql.NullString 422 - err := submissionsRows.Scan( 335 + var createdAt string 336 + var sourceRev sql.NullString 337 + err := rows.Scan( 423 338 &submission.ID, 424 - &submission.PullId, 425 - &submission.RepoAt, 339 + &submission.PullAt, 426 340 &submission.RoundNumber, 427 341 &submission.Patch, 428 - &submissionCreatedStr, 429 - &submissionSourceRev, 342 + &createdAt, 343 + &sourceRev, 430 344 ) 431 345 if err != nil { 432 346 return nil, err 433 347 } 434 348 435 - submissionCreatedTime, err := time.Parse(time.RFC3339, submissionCreatedStr) 349 + createdTime, err := time.Parse(time.RFC3339, createdAt) 436 350 if err != nil { 437 351 return nil, err 438 352 } 439 - submission.Created = submissionCreatedTime 353 + submission.Created = createdTime 440 354 441 - if submissionSourceRev.Valid { 442 - submission.SourceRev = submissionSourceRev.String 355 + if sourceRev.Valid { 356 + submission.SourceRev = sourceRev.String 443 357 } 444 358 445 - submissionsMap[submission.ID] = &submission 359 + submissionMap[submission.ID] = &submission 446 360 } 447 - if err = submissionsRows.Close(); err != nil { 361 + 362 + if err := rows.Err(); err != nil { 448 363 return nil, err 449 364 } 450 - if len(submissionsMap) == 0 { 451 - return &pull, nil 365 + 366 + // Get comments for all submissions using GetPullComments 367 + submissionIds := slices.Collect(maps.Keys(submissionMap)) 368 + comments, err := GetPullComments(e, FilterIn("submission_id", submissionIds)) 369 + if err != nil { 370 + return nil, err 371 + } 372 + for _, comment := range comments { 373 + if submission, ok := submissionMap[comment.SubmissionId]; ok { 374 + submission.Comments = append(submission.Comments, comment) 375 + } 376 + } 377 + 378 + // group the submissions by pull_at 379 + m := make(map[syntax.ATURI][]*models.PullSubmission) 380 + for _, s := range submissionMap { 381 + m[s.PullAt] = append(m[s.PullAt], s) 382 + } 383 + 384 + // sort each one by round number 385 + for _, s := range m { 386 + slices.SortFunc(s, func(a, b *models.PullSubmission) int { 387 + return cmp.Compare(a.RoundNumber, b.RoundNumber) 388 + }) 452 389 } 453 390 391 + return m, nil 392 + } 393 + 394 + func GetPullComments(e Execer, filters ...filter) ([]models.PullComment, error) { 395 + var conditions []string 454 396 var args []any 455 - for k := range submissionsMap { 456 - args = append(args, k) 397 + for _, filter := range filters { 398 + conditions = append(conditions, filter.Condition()) 399 + args = append(args, filter.Arg()...) 400 + } 401 + 402 + whereClause := "" 403 + if conditions != nil { 404 + whereClause = " where " + strings.Join(conditions, " and ") 457 405 } 458 - inClause := strings.TrimSuffix(strings.Repeat("?, ", len(submissionsMap)), ", ") 459 - commentsQuery := fmt.Sprintf(` 406 + 407 + query := fmt.Sprintf(` 460 408 select 461 409 id, 462 410 pull_id, ··· 468 416 created 469 417 from 470 418 pull_comments 471 - where 472 - submission_id IN (%s) 419 + %s 473 420 order by 474 421 created asc 475 - `, inClause) 476 - commentsRows, err := e.Query(commentsQuery, args...) 422 + `, whereClause) 423 + 424 + rows, err := e.Query(query, args...) 477 425 if err != nil { 478 426 return nil, err 479 427 } 480 - defer commentsRows.Close() 428 + defer rows.Close() 481 429 482 - for commentsRows.Next() { 430 + var comments []models.PullComment 431 + for rows.Next() { 483 432 var comment models.PullComment 484 - var commentCreatedStr string 485 - err := commentsRows.Scan( 433 + var createdAt string 434 + err := rows.Scan( 486 435 &comment.ID, 487 436 &comment.PullId, 488 437 &comment.SubmissionId, ··· 490 439 &comment.OwnerDid, 491 440 &comment.CommentAt, 492 441 &comment.Body, 493 - &commentCreatedStr, 442 + &createdAt, 494 443 ) 495 444 if err != nil { 496 445 return nil, err 497 446 } 498 447 499 - commentCreatedTime, err := time.Parse(time.RFC3339, commentCreatedStr) 500 - if err != nil { 501 - return nil, err 448 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 449 + comment.Created = t 502 450 } 503 - comment.Created = commentCreatedTime 504 451 505 - // Add the comment to its submission 506 - if submission, ok := submissionsMap[comment.SubmissionId]; ok { 507 - submission.Comments = append(submission.Comments, comment) 508 - } 509 - 510 - } 511 - if err = commentsRows.Err(); err != nil { 512 - return nil, err 513 - } 514 - 515 - var pullSourceRepo *models.Repo 516 - if pull.PullSource != nil { 517 - if pull.PullSource.RepoAt != nil { 518 - pullSourceRepo, err = GetRepoByAtUri(e, pull.PullSource.RepoAt.String()) 519 - if err != nil { 520 - log.Printf("failed to get repo by at uri: %v", err) 521 - } else { 522 - pull.PullSource.Repo = pullSourceRepo 523 - } 524 - } 452 + comments = append(comments, comment) 525 453 } 526 454 527 - pull.Submissions = make([]*models.PullSubmission, len(submissionsMap)) 528 - for _, submission := range submissionsMap { 529 - pull.Submissions[submission.RoundNumber] = submission 455 + if err := rows.Err(); err != nil { 456 + return nil, err 530 457 } 531 458 532 - return &pull, nil 459 + return comments, nil 533 460 } 534 461 535 462 // timeframe here is directly passed into the sql query filter, and any ··· 666 593 func ResubmitPull(e Execer, pull *models.Pull, newPatch, sourceRev string) error { 667 594 newRoundNumber := len(pull.Submissions) 668 595 _, err := e.Exec(` 669 - insert into pull_submissions (pull_id, repo_at, round_number, patch, source_rev) 670 - values (?, ?, ?, ?, ?) 671 - `, pull.PullId, pull.RepoAt, newRoundNumber, newPatch, sourceRev) 596 + insert into pull_submissions (pull_at, round_number, patch, source_rev) 597 + values (?, ?, ?, ?) 598 + `, pull.PullAt(), newRoundNumber, newPatch, sourceRev) 672 599 673 600 return err 674 601 }
+65 -23
appview/db/repos.go
··· 10 10 "time" 11 11 12 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 + securejoin "github.com/cyphar/filepath-securejoin" 14 + "tangled.org/core/api/tangled" 13 15 "tangled.org/core/appview/models" 14 16 ) 15 17 18 + type Repo struct { 19 + Id int64 20 + Did string 21 + Name string 22 + Knot string 23 + Rkey string 24 + Created time.Time 25 + Description string 26 + Spindle string 27 + 28 + // optionally, populate this when querying for reverse mappings 29 + RepoStats *models.RepoStats 30 + 31 + // optional 32 + Source string 33 + } 34 + 35 + func (r Repo) RepoAt() syntax.ATURI { 36 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", r.Did, tangled.RepoNSID, r.Rkey)) 37 + } 38 + 39 + func (r Repo) DidSlashRepo() string { 40 + p, _ := securejoin.SecureJoin(r.Did, r.Name) 41 + return p 42 + } 43 + 16 44 func GetRepos(e Execer, limit int, filters ...filter) ([]models.Repo, error) { 17 45 repoMap := make(map[syntax.ATURI]*models.Repo) 18 46 ··· 35 63 36 64 repoQuery := fmt.Sprintf( 37 65 `select 66 + id, 38 67 did, 39 68 name, 40 69 knot, ··· 63 92 var description, source, spindle sql.NullString 64 93 65 94 err := rows.Scan( 95 + &repo.Id, 66 96 &repo.Did, 67 97 &repo.Name, 68 98 &repo.Knot, ··· 131 161 132 162 languageQuery := fmt.Sprintf( 133 163 ` 134 - select 135 - repo_at, language 136 - from 137 - repo_languages r1 138 - where 139 - repo_at IN (%s) 164 + select repo_at, language 165 + from ( 166 + select 167 + repo_at, 168 + language, 169 + row_number() over ( 170 + partition by repo_at 171 + order by bytes desc 172 + ) as rn 173 + from repo_languages 174 + where repo_at in (%s) 140 175 and is_default_ref = 1 141 - and id = ( 142 - select id 143 - from repo_languages r2 144 - where r2.repo_at = r1.repo_at 145 - and r2.is_default_ref = 1 146 - order by bytes desc 147 - limit 1 148 - ); 176 + ) 177 + where rn = 1 149 178 `, 150 179 inClause, 151 180 ) ··· 328 357 var repo models.Repo 329 358 var nullableDescription sql.NullString 330 359 331 - row := e.QueryRow(`select did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri) 360 + row := e.QueryRow(`select id, did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri) 332 361 333 362 var createdAt string 334 - if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil { 363 + if err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil { 335 364 return nil, err 336 365 } 337 366 createdAtTime, _ := time.Parse(time.RFC3339, createdAt) ··· 346 375 return &repo, nil 347 376 } 348 377 349 - func AddRepo(e Execer, repo *models.Repo) error { 350 - _, err := e.Exec( 378 + func AddRepo(tx *sql.Tx, repo *models.Repo) error { 379 + _, err := tx.Exec( 351 380 `insert into repos 352 381 (did, name, knot, rkey, at_uri, description, source) 353 382 values (?, ?, ?, ?, ?, ?, ?)`, 354 383 repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Source, 355 384 ) 356 - return err 385 + if err != nil { 386 + return fmt.Errorf("failed to insert repo: %w", err) 387 + } 388 + 389 + for _, dl := range repo.Labels { 390 + if err := SubscribeLabel(tx, &models.RepoLabel{ 391 + RepoAt: repo.RepoAt(), 392 + LabelAt: syntax.ATURI(dl), 393 + }); err != nil { 394 + return fmt.Errorf("failed to subscribe to label: %w", err) 395 + } 396 + } 397 + 398 + return nil 357 399 } 358 400 359 401 func RemoveRepo(e Execer, did, name string) error { ··· 374 416 var repos []models.Repo 375 417 376 418 rows, err := e.Query( 377 - `select distinct r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source 419 + `select distinct r.id, r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source 378 420 from repos r 379 421 left join collaborators c on r.at_uri = c.repo_at 380 422 where (r.did = ? or c.subject_did = ?) ··· 394 436 var nullableDescription sql.NullString 395 437 var nullableSource sql.NullString 396 438 397 - err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 439 + err := rows.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 398 440 if err != nil { 399 441 return nil, err 400 442 } ··· 431 473 var nullableSource sql.NullString 432 474 433 475 row := e.QueryRow( 434 - `select did, name, knot, rkey, description, created, source 476 + `select id, did, name, knot, rkey, description, created, source 435 477 from repos 436 478 where did = ? and name = ? and source is not null and source != ''`, 437 479 did, name, 438 480 ) 439 481 440 - err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 482 + err := row.Scan(&repo.Id, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 441 483 if err != nil { 442 484 return nil, err 443 485 }
+11
appview/db/star.go
··· 5 5 "errors" 6 6 "fmt" 7 7 "log" 8 + "slices" 8 9 "strings" 9 10 "time" 10 11 ··· 208 209 for _, s := range starMap { 209 210 stars = append(stars, s...) 210 211 } 212 + 213 + slices.SortFunc(stars, func(a, b models.Star) int { 214 + if a.Created.After(b.Created) { 215 + return -1 216 + } 217 + if b.Created.After(a.Created) { 218 + return 1 219 + } 220 + return 0 221 + }) 211 222 212 223 return stars, nil 213 224 }
+80
appview/ingester.go
··· 5 5 "encoding/json" 6 6 "fmt" 7 7 "log/slog" 8 + "maps" 9 + "slices" 8 10 9 11 "time" 10 12 ··· 80 82 err = i.ingestIssueComment(e) 81 83 case tangled.LabelDefinitionNSID: 82 84 err = i.ingestLabelDefinition(e) 85 + case tangled.LabelOpNSID: 86 + err = i.ingestLabelOp(e) 83 87 } 84 88 l = i.Logger.With("nsid", e.Commit.Collection) 85 89 } ··· 953 957 954 958 return nil 955 959 } 960 + 961 + func (i *Ingester) ingestLabelOp(e *jmodels.Event) error { 962 + did := e.Did 963 + rkey := e.Commit.RKey 964 + 965 + var err error 966 + 967 + l := i.Logger.With("handler", "ingestLabelOp", "nsid", e.Commit.Collection, "did", did, "rkey", rkey) 968 + l.Info("ingesting record") 969 + 970 + ddb, ok := i.Db.Execer.(*db.DB) 971 + if !ok { 972 + return fmt.Errorf("failed to index label op, invalid db cast") 973 + } 974 + 975 + switch e.Commit.Operation { 976 + case jmodels.CommitOperationCreate: 977 + raw := json.RawMessage(e.Commit.Record) 978 + record := tangled.LabelOp{} 979 + err = json.Unmarshal(raw, &record) 980 + if err != nil { 981 + return fmt.Errorf("invalid record: %w", err) 982 + } 983 + 984 + subject := syntax.ATURI(record.Subject) 985 + collection := subject.Collection() 986 + 987 + var repo *models.Repo 988 + switch collection { 989 + case tangled.RepoIssueNSID: 990 + i, err := db.GetIssues(ddb, db.FilterEq("at_uri", subject)) 991 + if err != nil || len(i) != 1 { 992 + return fmt.Errorf("failed to find subject: %w || subject count %d", err, len(i)) 993 + } 994 + repo = i[0].Repo 995 + default: 996 + return fmt.Errorf("unsupport label subject: %s", collection) 997 + } 998 + 999 + actx, err := db.NewLabelApplicationCtx(ddb, db.FilterIn("at_uri", repo.Labels)) 1000 + if err != nil { 1001 + return fmt.Errorf("failed to build label application ctx: %w", err) 1002 + } 1003 + 1004 + ops := models.LabelOpsFromRecord(did, rkey, record) 1005 + 1006 + for _, o := range ops { 1007 + def, ok := actx.Defs[o.OperandKey] 1008 + if !ok { 1009 + return fmt.Errorf("failed to find label def for key: %s, expected: %q", o.OperandKey, slices.Collect(maps.Keys(actx.Defs))) 1010 + } 1011 + if err := i.Validator.ValidateLabelOp(def, repo, &o); err != nil { 1012 + return fmt.Errorf("failed to validate labelop: %w", err) 1013 + } 1014 + } 1015 + 1016 + tx, err := ddb.Begin() 1017 + if err != nil { 1018 + return err 1019 + } 1020 + defer tx.Rollback() 1021 + 1022 + for _, o := range ops { 1023 + _, err = db.AddLabelOp(tx, &o) 1024 + if err != nil { 1025 + return fmt.Errorf("failed to add labelop: %w", err) 1026 + } 1027 + } 1028 + 1029 + if err = tx.Commit(); err != nil { 1030 + return err 1031 + } 1032 + } 1033 + 1034 + return nil 1035 + }
+13 -1
appview/issues/issues.go
··· 301 301 return 302 302 } 303 303 304 + // notify about the issue closure 305 + rp.notifier.NewIssueClosed(r.Context(), issue) 306 + 304 307 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 305 308 return 306 309 } else { ··· 434 437 435 438 // reset atUri to make rollback a no-op 436 439 atUri = "" 440 + 441 + // notify about the new comment 442 + comment.Id = commentId 443 + rp.notifier.NewIssueComment(r.Context(), &comment) 444 + 437 445 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId)) 438 446 } 439 447 ··· 790 798 return 791 799 } 792 800 793 - labelDefs, err := db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", f.Repo.Labels)) 801 + labelDefs, err := db.GetLabelDefinitions( 802 + rp.db, 803 + db.FilterIn("at_uri", f.Repo.Labels), 804 + db.FilterContains("scope", tangled.RepoIssueNSID), 805 + ) 794 806 if err != nil { 795 807 log.Println("failed to fetch labels", err) 796 808 rp.pages.Error503(w)
+14 -7
appview/labels/labels.go
··· 23 23 "tangled.org/core/appview/validator" 24 24 "tangled.org/core/appview/xrpcclient" 25 25 "tangled.org/core/log" 26 + "tangled.org/core/rbac" 26 27 "tangled.org/core/tid" 27 28 ) 28 29 ··· 32 33 db *db.DB 33 34 logger *slog.Logger 34 35 validator *validator.Validator 36 + enforcer *rbac.Enforcer 35 37 } 36 38 37 39 func New( ··· 39 41 pages *pages.Pages, 40 42 db *db.DB, 41 43 validator *validator.Validator, 44 + enforcer *rbac.Enforcer, 42 45 ) *Labels { 43 46 logger := log.New("labels") 44 47 ··· 48 51 db: db, 49 52 logger: logger, 50 53 validator: validator, 54 + enforcer: enforcer, 51 55 } 52 56 } 53 57 ··· 86 90 repoAt := r.Form.Get("repo") 87 91 subjectUri := r.Form.Get("subject") 88 92 93 + repo, err := db.GetRepo(l.db, db.FilterEq("at_uri", repoAt)) 94 + if err != nil { 95 + fail("Failed to get repository.", err) 96 + return 97 + } 98 + 89 99 // find all the labels that this repo subscribes to 90 100 repoLabels, err := db.GetRepoLabels(l.db, db.FilterEq("repo_at", repoAt)) 91 101 if err != nil { ··· 104 114 return 105 115 } 106 116 107 - l.logger.Info("actx", "labels", labelAts) 108 - l.logger.Info("actx", "defs", actx.Defs) 109 - 110 117 // calculate the start state by applying already known labels 111 118 existingOps, err := db.GetLabelOps(l.db, db.FilterEq("subject", subjectUri)) 112 119 if err != nil { ··· 155 162 } 156 163 } 157 164 158 - // reduce the opset 159 - labelOps = models.ReduceLabelOps(labelOps) 160 - 161 165 for i := range labelOps { 162 166 def := actx.Defs[labelOps[i].OperandKey] 163 - if err := l.validator.ValidateLabelOp(def, &labelOps[i]); err != nil { 167 + if err := l.validator.ValidateLabelOp(def, repo, &labelOps[i]); err != nil { 164 168 fail(fmt.Sprintf("Invalid form data: %s", err), err) 165 169 return 166 170 } 167 171 } 172 + 173 + // reduce the opset 174 + labelOps = models.ReduceLabelOps(labelOps) 168 175 169 176 // next, apply all ops introduced in this request and filter out ones that are no-ops 170 177 validLabelOps := labelOps[:0]
+9
appview/middleware/middleware.go
··· 43 43 44 44 type middlewareFunc func(http.Handler) http.Handler 45 45 46 + func (mw *Middleware) TryRefreshSession() middlewareFunc { 47 + return func(next http.Handler) http.Handler { 48 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 49 + _, _, _ = mw.oauth.GetSession(r) 50 + next.ServeHTTP(w, r) 51 + }) 52 + } 53 + } 54 + 46 55 func AuthMiddleware(a *oauth.OAuth) middlewareFunc { 47 56 return func(next http.Handler) http.Handler { 48 57 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+83 -14
appview/models/label.go
··· 1 1 package models 2 2 3 3 import ( 4 + "context" 4 5 "crypto/sha1" 5 6 "encoding/hex" 7 + "encoding/json" 6 8 "errors" 7 9 "fmt" 8 10 "slices" 9 11 "time" 10 12 13 + "github.com/bluesky-social/indigo/api/atproto" 11 14 "github.com/bluesky-social/indigo/atproto/syntax" 15 + "github.com/bluesky-social/indigo/xrpc" 12 16 "tangled.org/core/api/tangled" 13 17 "tangled.org/core/consts" 18 + "tangled.org/core/idresolver" 14 19 ) 15 20 16 21 type ConcreteType string ··· 227 232 } 228 233 229 234 var ops []LabelOp 230 - for _, o := range record.Add { 235 + // deletes first, then additions 236 + for _, o := range record.Delete { 231 237 if o != nil { 232 238 op := mkOp(o) 233 - op.Operation = LabelOperationAdd 239 + op.Operation = LabelOperationDel 234 240 ops = append(ops, op) 235 241 } 236 242 } 237 - for _, o := range record.Delete { 243 + for _, o := range record.Add { 238 244 if o != nil { 239 245 op := mkOp(o) 240 - op.Operation = LabelOperationDel 246 + op.Operation = LabelOperationAdd 241 247 ops = append(ops, op) 242 248 } 243 249 } ··· 455 461 return result 456 462 } 457 463 464 + var ( 465 + LabelWontfix = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "wontfix") 466 + LabelDuplicate = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "duplicate") 467 + LabelAssignee = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "assignee") 468 + LabelGoodFirstIssue = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "good-first-issue") 469 + LabelDocumentation = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "documentation") 470 + ) 471 + 458 472 func DefaultLabelDefs() []string { 459 - rkeys := []string{ 460 - "wontfix", 461 - "duplicate", 462 - "assignee", 463 - "good-first-issue", 464 - "documentation", 473 + return []string{ 474 + LabelWontfix, 475 + LabelDuplicate, 476 + LabelAssignee, 477 + LabelGoodFirstIssue, 478 + LabelDocumentation, 465 479 } 480 + } 466 481 467 - defs := make([]string, len(rkeys)) 468 - for i, r := range rkeys { 469 - defs[i] = fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, r) 482 + func FetchDefaultDefs(r *idresolver.Resolver) ([]LabelDefinition, error) { 483 + resolved, err := r.ResolveIdent(context.Background(), consts.TangledDid) 484 + if err != nil { 485 + return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", consts.TangledDid, err) 486 + } 487 + pdsEndpoint := resolved.PDSEndpoint() 488 + if pdsEndpoint == "" { 489 + return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", consts.TangledDid) 490 + } 491 + client := &xrpc.Client{ 492 + Host: pdsEndpoint, 493 + } 494 + 495 + var labelDefs []LabelDefinition 496 + 497 + for _, dl := range DefaultLabelDefs() { 498 + atUri := syntax.ATURI(dl) 499 + parsedUri, err := syntax.ParseATURI(string(atUri)) 500 + if err != nil { 501 + return nil, fmt.Errorf("failed to parse AT-URI %s: %v", atUri, err) 502 + } 503 + record, err := atproto.RepoGetRecord( 504 + context.Background(), 505 + client, 506 + "", 507 + parsedUri.Collection().String(), 508 + parsedUri.Authority().String(), 509 + parsedUri.RecordKey().String(), 510 + ) 511 + if err != nil { 512 + return nil, fmt.Errorf("failed to get record for %s: %v", atUri, err) 513 + } 514 + 515 + if record != nil { 516 + bytes, err := record.Value.MarshalJSON() 517 + if err != nil { 518 + return nil, fmt.Errorf("failed to marshal record value for %s: %v", atUri, err) 519 + } 520 + 521 + raw := json.RawMessage(bytes) 522 + labelRecord := tangled.LabelDefinition{} 523 + err = json.Unmarshal(raw, &labelRecord) 524 + if err != nil { 525 + return nil, fmt.Errorf("invalid record for %s: %w", atUri, err) 526 + } 527 + 528 + labelDef, err := LabelDefinitionFromRecord( 529 + parsedUri.Authority().String(), 530 + parsedUri.RecordKey().String(), 531 + labelRecord, 532 + ) 533 + if err != nil { 534 + return nil, fmt.Errorf("failed to create label definition from record %s: %v", atUri, err) 535 + } 536 + 537 + labelDefs = append(labelDefs, *labelDef) 538 + } 470 539 } 471 540 472 - return defs 541 + return labelDefs, nil 473 542 }
+82
appview/models/notifications.go
··· 1 + package models 2 + 3 + import ( 4 + "time" 5 + ) 6 + 7 + type NotificationType string 8 + 9 + const ( 10 + NotificationTypeRepoStarred NotificationType = "repo_starred" 11 + NotificationTypeIssueCreated NotificationType = "issue_created" 12 + NotificationTypeIssueCommented NotificationType = "issue_commented" 13 + NotificationTypePullCreated NotificationType = "pull_created" 14 + NotificationTypePullCommented NotificationType = "pull_commented" 15 + NotificationTypeFollowed NotificationType = "followed" 16 + NotificationTypePullMerged NotificationType = "pull_merged" 17 + NotificationTypeIssueClosed NotificationType = "issue_closed" 18 + NotificationTypePullClosed NotificationType = "pull_closed" 19 + ) 20 + 21 + type Notification struct { 22 + ID int64 23 + RecipientDid string 24 + ActorDid string 25 + Type NotificationType 26 + EntityType string 27 + EntityId string 28 + Read bool 29 + Created time.Time 30 + 31 + // foreign key references 32 + RepoId *int64 33 + IssueId *int64 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 66 + Issue *Issue 67 + Pull *Pull 68 + } 69 + 70 + type NotificationPreferences struct { 71 + ID int64 72 + UserDid string 73 + RepoStarred bool 74 + IssueCreated bool 75 + IssueCommented bool 76 + PullCreated bool 77 + PullCommented bool 78 + Followed bool 79 + PullMerged bool 80 + IssueClosed bool 81 + EmailNotifications bool 82 + }
+46 -4
appview/models/pull.go
··· 77 77 PullSource *PullSource 78 78 79 79 // optionally, populate this when querying for reverse mappings 80 - Repo *Repo 80 + Labels LabelState 81 + Repo *Repo 81 82 } 82 83 83 84 func (p Pull) AsRecord() tangled.RepoPull { ··· 125 126 126 127 type PullSubmission struct { 127 128 // ids 128 - ID int 129 - PullId int 129 + ID int 130 130 131 131 // at ids 132 - RepoAt syntax.ATURI 132 + PullAt syntax.ATURI 133 133 134 134 // content 135 135 RoundNumber int ··· 207 207 return p.StackId != "" 208 208 } 209 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 + 210 232 func (s PullSubmission) IsFormatPatch() bool { 211 233 return patchutil.IsFormatPatch(s.Patch) 212 234 } ··· 219 241 } 220 242 221 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 222 264 } 223 265 224 266 type Stack []*Pull
+6
appview/models/repo.go
··· 10 10 ) 11 11 12 12 type Repo struct { 13 + Id int64 13 14 Did string 14 15 Name string 15 16 Knot string ··· 85 86 RepoAt syntax.ATURI 86 87 LabelAt syntax.ATURI 87 88 } 89 + 90 + type RepoGroup struct { 91 + Repo *Repo 92 + Issues []Issue 93 + }
+168
appview/notifications/notifications.go
··· 1 + package notifications 2 + 3 + import ( 4 + "fmt" 5 + "log" 6 + "net/http" 7 + "strconv" 8 + 9 + "github.com/go-chi/chi/v5" 10 + "tangled.org/core/appview/db" 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 { 18 + db *db.DB 19 + oauth *oauth.OAuth 20 + pages *pages.Pages 21 + } 22 + 23 + func New(database *db.DB, oauthHandler *oauth.OAuth, pagesHandler *pages.Pages) *Notifications { 24 + return &Notifications{ 25 + db: database, 26 + oauth: oauthHandler, 27 + pages: pagesHandler, 28 + } 29 + } 30 + 31 + func (n *Notifications) Router(mw *middleware.Middleware) http.Handler { 32 + r := chi.NewRouter() 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 + r.Post("/read-all", n.markAllRead) 41 + r.Delete("/{id}", n.deleteNotification) 42 + 43 + return r 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 + unreadCount := 0 82 + 83 + user := n.oauth.GetUser(r) 84 + if user == nil { 85 + http.Error(w, "Failed to get user", http.StatusInternalServerError) 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 108 + } 109 + 110 + params := pages.NotificationCountParams{ 111 + Count: count, 112 + } 113 + err = n.pages.NotificationCount(w, params) 114 + if err != nil { 115 + http.Error(w, "Failed to render count", http.StatusInternalServerError) 116 + return 117 + } 118 + } 119 + 120 + func (n *Notifications) markRead(w http.ResponseWriter, r *http.Request) { 121 + userDid := n.oauth.GetDid(r) 122 + 123 + idStr := chi.URLParam(r, "id") 124 + notificationID, err := strconv.ParseInt(idStr, 10, 64) 125 + if err != nil { 126 + http.Error(w, "Invalid notification ID", http.StatusBadRequest) 127 + return 128 + } 129 + 130 + err = n.db.MarkNotificationRead(r.Context(), notificationID, userDid) 131 + if err != nil { 132 + http.Error(w, "Failed to mark notification as read", http.StatusInternalServerError) 133 + return 134 + } 135 + 136 + w.WriteHeader(http.StatusNoContent) 137 + } 138 + 139 + func (n *Notifications) markAllRead(w http.ResponseWriter, r *http.Request) { 140 + userDid := n.oauth.GetDid(r) 141 + 142 + err := n.db.MarkAllNotificationsRead(r.Context(), userDid) 143 + if err != nil { 144 + http.Error(w, "Failed to mark all notifications as read", http.StatusInternalServerError) 145 + return 146 + } 147 + 148 + http.Redirect(w, r, "/notifications", http.StatusSeeOther) 149 + } 150 + 151 + func (n *Notifications) deleteNotification(w http.ResponseWriter, r *http.Request) { 152 + userDid := n.oauth.GetDid(r) 153 + 154 + idStr := chi.URLParam(r, "id") 155 + notificationID, err := strconv.ParseInt(idStr, 10, 64) 156 + if err != nil { 157 + http.Error(w, "Invalid notification ID", http.StatusBadRequest) 158 + return 159 + } 160 + 161 + err = n.db.DeleteNotification(r.Context(), notificationID, userDid) 162 + if err != nil { 163 + http.Error(w, "Failed to delete notification", http.StatusInternalServerError) 164 + return 165 + } 166 + 167 + w.WriteHeader(http.StatusOK) 168 + }
+429
appview/notify/db/db.go
··· 1 + package db 2 + 3 + import ( 4 + "context" 5 + "log" 6 + 7 + "tangled.org/core/appview/db" 8 + "tangled.org/core/appview/models" 9 + "tangled.org/core/appview/notify" 10 + "tangled.org/core/idresolver" 11 + ) 12 + 13 + type databaseNotifier struct { 14 + db *db.DB 15 + res *idresolver.Resolver 16 + } 17 + 18 + func NewDatabaseNotifier(database *db.DB, resolver *idresolver.Resolver) notify.Notifier { 19 + return &databaseNotifier{ 20 + db: database, 21 + res: resolver, 22 + } 23 + } 24 + 25 + var _ notify.Notifier = &databaseNotifier{} 26 + 27 + func (n *databaseNotifier) NewRepo(ctx context.Context, repo *models.Repo) { 28 + // no-op for now 29 + } 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 { 41 + return 42 + } 43 + 44 + // check if user wants these notifications 45 + prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did) 46 + if err != nil { 47 + log.Printf("NewStar: failed to get notification preferences for %s: %v", repo.Did, err) 48 + return 49 + } 50 + if !prefs.RepoStarred { 51 + return 52 + } 53 + 54 + notification := &models.Notification{ 55 + RecipientDid: repo.Did, 56 + ActorDid: star.StarredByDid, 57 + Type: models.NotificationTypeRepoStarred, 58 + EntityType: "repo", 59 + EntityId: string(star.RepoAt), 60 + RepoId: &repo.Id, 61 + } 62 + err = n.db.CreateNotification(ctx, notification) 63 + if err != nil { 64 + log.Printf("NewStar: failed to create notification: %v", err) 65 + return 66 + } 67 + } 68 + 69 + func (n *databaseNotifier) DeleteStar(ctx context.Context, star *models.Star) { 70 + // no-op 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 82 + } 83 + 84 + prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did) 85 + if err != nil { 86 + log.Printf("NewIssue: failed to get notification preferences for %s: %v", repo.Did, err) 87 + return 88 + } 89 + if !prefs.IssueCreated { 90 + return 91 + } 92 + 93 + notification := &models.Notification{ 94 + RecipientDid: repo.Did, 95 + ActorDid: issue.Did, 96 + Type: models.NotificationTypeIssueCreated, 97 + EntityType: "issue", 98 + EntityId: string(issue.AtUri()), 99 + RepoId: &repo.Id, 100 + IssueId: &issue.Id, 101 + } 102 + 103 + err = n.db.CreateNotification(ctx, notification) 104 + if err != nil { 105 + log.Printf("NewIssue: failed to create notification: %v", err) 106 + return 107 + } 108 + } 109 + 110 + func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) { 111 + issues, err := db.GetIssues(n.db, db.FilterEq("at_uri", comment.IssueAt)) 112 + if err != nil { 113 + log.Printf("NewIssueComment: failed to get issues: %v", err) 114 + return 115 + } 116 + if len(issues) == 0 { 117 + log.Printf("NewIssueComment: no issue found for %s", comment.IssueAt) 118 + 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 + 130 + // notify issue author (if not the commenter) 131 + if issue.Did != comment.Did { 132 + prefs, err := n.db.GetNotificationPreferences(ctx, issue.Did) 133 + if err == nil && prefs.IssueCommented { 134 + recipients[issue.Did] = true 135 + } else if err != nil { 136 + log.Printf("NewIssueComment: failed to get preferences for issue author %s: %v", issue.Did, err) 137 + } 138 + } 139 + 140 + // notify repo owner (if not the commenter and not already added) 141 + if repo.Did != comment.Did && repo.Did != issue.Did { 142 + prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did) 143 + if err == nil && prefs.IssueCommented { 144 + recipients[repo.Did] = true 145 + } else if err != nil { 146 + log.Printf("NewIssueComment: failed to get preferences for repo owner %s: %v", repo.Did, err) 147 + } 148 + } 149 + 150 + // create notifications for all recipients 151 + for recipientDid := range recipients { 152 + notification := &models.Notification{ 153 + RecipientDid: recipientDid, 154 + ActorDid: comment.Did, 155 + Type: models.NotificationTypeIssueCommented, 156 + EntityType: "issue", 157 + EntityId: string(issue.AtUri()), 158 + RepoId: &repo.Id, 159 + IssueId: &issue.Id, 160 + } 161 + 162 + err = n.db.CreateNotification(ctx, notification) 163 + if err != nil { 164 + log.Printf("NewIssueComment: failed to create notification for %s: %v", recipientDid, err) 165 + } 166 + } 167 + } 168 + 169 + func (n *databaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) { 170 + prefs, err := n.db.GetNotificationPreferences(ctx, follow.SubjectDid) 171 + if err != nil { 172 + log.Printf("NewFollow: failed to get notification preferences for %s: %v", follow.SubjectDid, err) 173 + return 174 + } 175 + if !prefs.Followed { 176 + return 177 + } 178 + 179 + notification := &models.Notification{ 180 + RecipientDid: follow.SubjectDid, 181 + ActorDid: follow.UserDid, 182 + Type: models.NotificationTypeFollowed, 183 + EntityType: "follow", 184 + EntityId: follow.UserDid, 185 + } 186 + 187 + err = n.db.CreateNotification(ctx, notification) 188 + if err != nil { 189 + log.Printf("NewFollow: failed to create notification: %v", err) 190 + return 191 + } 192 + } 193 + 194 + func (n *databaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) { 195 + // no-op 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 207 + } 208 + 209 + prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did) 210 + if err != nil { 211 + log.Printf("NewPull: failed to get notification preferences for %s: %v", repo.Did, err) 212 + return 213 + } 214 + if !prefs.PullCreated { 215 + return 216 + } 217 + 218 + notification := &models.Notification{ 219 + RecipientDid: repo.Did, 220 + ActorDid: pull.OwnerDid, 221 + Type: models.NotificationTypePullCreated, 222 + EntityType: "pull", 223 + EntityId: string(pull.RepoAt), 224 + RepoId: &repo.Id, 225 + PullId: func() *int64 { id := int64(pull.ID); return &id }(), 226 + } 227 + 228 + err = n.db.CreateNotification(ctx, notification) 229 + if err != nil { 230 + log.Printf("NewPull: failed to create notification: %v", err) 231 + return 232 + } 233 + } 234 + 235 + func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) { 236 + pulls, err := db.GetPulls(n.db, 237 + db.FilterEq("repo_at", comment.RepoAt), 238 + db.FilterEq("pull_id", comment.PullId)) 239 + if err != nil { 240 + log.Printf("NewPullComment: failed to get pulls: %v", err) 241 + return 242 + } 243 + if len(pulls) == 0 { 244 + log.Printf("NewPullComment: no pull found for %s PR %d", comment.RepoAt, comment.PullId) 245 + 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 + 257 + // notify pull request author (if not the commenter) 258 + if pull.OwnerDid != comment.OwnerDid { 259 + prefs, err := n.db.GetNotificationPreferences(ctx, pull.OwnerDid) 260 + if err == nil && prefs.PullCommented { 261 + recipients[pull.OwnerDid] = true 262 + } else if err != nil { 263 + log.Printf("NewPullComment: failed to get preferences for pull author %s: %v", pull.OwnerDid, err) 264 + } 265 + } 266 + 267 + // notify repo owner (if not the commenter and not already added) 268 + if repo.Did != comment.OwnerDid && repo.Did != pull.OwnerDid { 269 + prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did) 270 + if err == nil && prefs.PullCommented { 271 + recipients[repo.Did] = true 272 + } else if err != nil { 273 + log.Printf("NewPullComment: failed to get preferences for repo owner %s: %v", repo.Did, err) 274 + } 275 + } 276 + 277 + for recipientDid := range recipients { 278 + notification := &models.Notification{ 279 + RecipientDid: recipientDid, 280 + ActorDid: comment.OwnerDid, 281 + Type: models.NotificationTypePullCommented, 282 + EntityType: "pull", 283 + EntityId: comment.RepoAt, 284 + RepoId: &repo.Id, 285 + PullId: func() *int64 { id := int64(pull.ID); return &id }(), 286 + } 287 + 288 + err = n.db.CreateNotification(ctx, notification) 289 + if err != nil { 290 + log.Printf("NewPullComment: failed to create notification for %s: %v", recipientDid, err) 291 + } 292 + } 293 + } 294 + 295 + func (n *databaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) { 296 + // no-op 297 + } 298 + 299 + func (n *databaseNotifier) DeleteString(ctx context.Context, did, rkey string) { 300 + // no-op 301 + } 302 + 303 + func (n *databaseNotifier) EditString(ctx context.Context, string *models.String) { 304 + // no-op 305 + } 306 + 307 + func (n *databaseNotifier) NewString(ctx context.Context, string *models.String) { 308 + // no-op 309 + } 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 { 321 + return 322 + } 323 + 324 + // Check if user wants these notifications 325 + prefs, err := n.db.GetNotificationPreferences(ctx, repo.Did) 326 + if err != nil { 327 + log.Printf("NewIssueClosed: failed to get notification preferences for %s: %v", repo.Did, err) 328 + return 329 + } 330 + if !prefs.IssueClosed { 331 + return 332 + } 333 + 334 + notification := &models.Notification{ 335 + RecipientDid: repo.Did, 336 + ActorDid: issue.Did, 337 + Type: models.NotificationTypeIssueClosed, 338 + EntityType: "issue", 339 + EntityId: string(issue.AtUri()), 340 + RepoId: &repo.Id, 341 + IssueId: &issue.Id, 342 + } 343 + 344 + err = n.db.CreateNotification(ctx, notification) 345 + if err != nil { 346 + log.Printf("NewIssueClosed: failed to create notification: %v", err) 347 + return 348 + } 349 + } 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 { 361 + return 362 + } 363 + 364 + // Check if user wants these notifications 365 + prefs, err := n.db.GetNotificationPreferences(ctx, pull.OwnerDid) 366 + if err != nil { 367 + log.Printf("NewPullMerged: failed to get notification preferences for %s: %v", pull.OwnerDid, err) 368 + return 369 + } 370 + if !prefs.PullMerged { 371 + return 372 + } 373 + 374 + notification := &models.Notification{ 375 + RecipientDid: pull.OwnerDid, 376 + ActorDid: repo.Did, 377 + Type: models.NotificationTypePullMerged, 378 + EntityType: "pull", 379 + EntityId: string(pull.RepoAt), 380 + RepoId: &repo.Id, 381 + PullId: func() *int64 { id := int64(pull.ID); return &id }(), 382 + } 383 + 384 + err = n.db.CreateNotification(ctx, notification) 385 + if err != nil { 386 + log.Printf("NewPullMerged: failed to create notification: %v", err) 387 + return 388 + } 389 + } 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 { 401 + return 402 + } 403 + 404 + // Check if user wants these notifications - reuse pull_merged preference for now 405 + prefs, err := n.db.GetNotificationPreferences(ctx, pull.OwnerDid) 406 + if err != nil { 407 + log.Printf("NewPullClosed: failed to get notification preferences for %s: %v", pull.OwnerDid, err) 408 + return 409 + } 410 + if !prefs.PullMerged { 411 + return 412 + } 413 + 414 + notification := &models.Notification{ 415 + RecipientDid: pull.OwnerDid, 416 + ActorDid: repo.Did, 417 + Type: models.NotificationTypePullClosed, 418 + EntityType: "pull", 419 + EntityId: string(pull.RepoAt), 420 + RepoId: &repo.Id, 421 + PullId: func() *int64 { id := int64(pull.ID); return &id }(), 422 + } 423 + 424 + err = n.db.CreateNotification(ctx, notification) 425 + if err != nil { 426 + log.Printf("NewPullClosed: failed to create notification: %v", err) 427 + return 428 + } 429 + }
+23
appview/notify/merged_notifier.go
··· 38 38 notifier.NewIssue(ctx, issue) 39 39 } 40 40 } 41 + func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) { 42 + for _, notifier := range m.notifiers { 43 + notifier.NewIssueComment(ctx, comment) 44 + } 45 + } 46 + 47 + func (m *mergedNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) { 48 + for _, notifier := range m.notifiers { 49 + notifier.NewIssueClosed(ctx, issue) 50 + } 51 + } 41 52 42 53 func (m *mergedNotifier) NewFollow(ctx context.Context, follow *models.Follow) { 43 54 for _, notifier := range m.notifiers { ··· 58 69 func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) { 59 70 for _, notifier := range m.notifiers { 60 71 notifier.NewPullComment(ctx, comment) 72 + } 73 + } 74 + 75 + func (m *mergedNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) { 76 + for _, notifier := range m.notifiers { 77 + notifier.NewPullMerged(ctx, pull) 78 + } 79 + } 80 + 81 + func (m *mergedNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) { 82 + for _, notifier := range m.notifiers { 83 + notifier.NewPullClosed(ctx, pull) 61 84 } 62 85 } 63 86
+9 -1
appview/notify/notifier.go
··· 13 13 DeleteStar(ctx context.Context, star *models.Star) 14 14 15 15 NewIssue(ctx context.Context, issue *models.Issue) 16 + NewIssueComment(ctx context.Context, comment *models.IssueComment) 17 + NewIssueClosed(ctx context.Context, issue *models.Issue) 16 18 17 19 NewFollow(ctx context.Context, follow *models.Follow) 18 20 DeleteFollow(ctx context.Context, follow *models.Follow) 19 21 20 22 NewPull(ctx context.Context, pull *models.Pull) 21 23 NewPullComment(ctx context.Context, comment *models.PullComment) 24 + NewPullMerged(ctx context.Context, pull *models.Pull) 25 + NewPullClosed(ctx context.Context, pull *models.Pull) 22 26 23 27 UpdateProfile(ctx context.Context, profile *models.Profile) 24 28 ··· 37 41 func (m *BaseNotifier) NewStar(ctx context.Context, star *models.Star) {} 38 42 func (m *BaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {} 39 43 40 - func (m *BaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) {} 44 + func (m *BaseNotifier) NewIssue(ctx context.Context, issue *models.Issue) {} 45 + func (m *BaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) {} 46 + func (m *BaseNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) {} 41 47 42 48 func (m *BaseNotifier) NewFollow(ctx context.Context, follow *models.Follow) {} 43 49 func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {} 44 50 45 51 func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {} 46 52 func (m *BaseNotifier) NewPullComment(ctx context.Context, models *models.PullComment) {} 53 + func (m *BaseNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) {} 54 + func (m *BaseNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) {} 47 55 48 56 func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) {} 49 57
+219
appview/notify/posthog/notifier.go
··· 1 + package posthog 2 + 3 + import ( 4 + "context" 5 + "log" 6 + 7 + "github.com/posthog/posthog-go" 8 + "tangled.org/core/appview/models" 9 + "tangled.org/core/appview/notify" 10 + ) 11 + 12 + type posthogNotifier struct { 13 + client posthog.Client 14 + notify.BaseNotifier 15 + } 16 + 17 + func NewPosthogNotifier(client posthog.Client) notify.Notifier { 18 + return &posthogNotifier{ 19 + client, 20 + notify.BaseNotifier{}, 21 + } 22 + } 23 + 24 + var _ notify.Notifier = &posthogNotifier{} 25 + 26 + func (n *posthogNotifier) NewRepo(ctx context.Context, repo *models.Repo) { 27 + err := n.client.Enqueue(posthog.Capture{ 28 + DistinctId: repo.Did, 29 + Event: "new_repo", 30 + Properties: posthog.Properties{"repo": repo.Name, "repo_at": repo.RepoAt()}, 31 + }) 32 + if err != nil { 33 + log.Println("failed to enqueue posthog event:", err) 34 + } 35 + } 36 + 37 + func (n *posthogNotifier) NewStar(ctx context.Context, star *models.Star) { 38 + err := n.client.Enqueue(posthog.Capture{ 39 + DistinctId: star.StarredByDid, 40 + Event: "star", 41 + Properties: posthog.Properties{"repo_at": star.RepoAt.String()}, 42 + }) 43 + if err != nil { 44 + log.Println("failed to enqueue posthog event:", err) 45 + } 46 + } 47 + 48 + func (n *posthogNotifier) DeleteStar(ctx context.Context, star *models.Star) { 49 + err := n.client.Enqueue(posthog.Capture{ 50 + DistinctId: star.StarredByDid, 51 + Event: "unstar", 52 + Properties: posthog.Properties{"repo_at": star.RepoAt.String()}, 53 + }) 54 + if err != nil { 55 + log.Println("failed to enqueue posthog event:", err) 56 + } 57 + } 58 + 59 + func (n *posthogNotifier) NewIssue(ctx context.Context, issue *models.Issue) { 60 + err := n.client.Enqueue(posthog.Capture{ 61 + DistinctId: issue.Did, 62 + Event: "new_issue", 63 + Properties: posthog.Properties{ 64 + "repo_at": issue.RepoAt.String(), 65 + "issue_id": issue.IssueId, 66 + }, 67 + }) 68 + if err != nil { 69 + log.Println("failed to enqueue posthog event:", err) 70 + } 71 + } 72 + 73 + func (n *posthogNotifier) NewPull(ctx context.Context, pull *models.Pull) { 74 + err := n.client.Enqueue(posthog.Capture{ 75 + DistinctId: pull.OwnerDid, 76 + Event: "new_pull", 77 + Properties: posthog.Properties{ 78 + "repo_at": pull.RepoAt, 79 + "pull_id": pull.PullId, 80 + }, 81 + }) 82 + if err != nil { 83 + log.Println("failed to enqueue posthog event:", err) 84 + } 85 + } 86 + 87 + func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) { 88 + err := n.client.Enqueue(posthog.Capture{ 89 + DistinctId: comment.OwnerDid, 90 + Event: "new_pull_comment", 91 + Properties: posthog.Properties{ 92 + "repo_at": comment.RepoAt, 93 + "pull_id": comment.PullId, 94 + }, 95 + }) 96 + if err != nil { 97 + log.Println("failed to enqueue posthog event:", err) 98 + } 99 + } 100 + 101 + func (n *posthogNotifier) NewPullClosed(ctx context.Context, pull *models.Pull) { 102 + err := n.client.Enqueue(posthog.Capture{ 103 + DistinctId: pull.OwnerDid, 104 + Event: "pull_closed", 105 + Properties: posthog.Properties{ 106 + "repo_at": pull.RepoAt, 107 + "pull_id": pull.PullId, 108 + }, 109 + }) 110 + if err != nil { 111 + log.Println("failed to enqueue posthog event:", err) 112 + } 113 + } 114 + 115 + func (n *posthogNotifier) NewFollow(ctx context.Context, follow *models.Follow) { 116 + err := n.client.Enqueue(posthog.Capture{ 117 + DistinctId: follow.UserDid, 118 + Event: "follow", 119 + Properties: posthog.Properties{"subject": follow.SubjectDid}, 120 + }) 121 + if err != nil { 122 + log.Println("failed to enqueue posthog event:", err) 123 + } 124 + } 125 + 126 + func (n *posthogNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) { 127 + err := n.client.Enqueue(posthog.Capture{ 128 + DistinctId: follow.UserDid, 129 + Event: "unfollow", 130 + Properties: posthog.Properties{"subject": follow.SubjectDid}, 131 + }) 132 + if err != nil { 133 + log.Println("failed to enqueue posthog event:", err) 134 + } 135 + } 136 + 137 + func (n *posthogNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) { 138 + err := n.client.Enqueue(posthog.Capture{ 139 + DistinctId: profile.Did, 140 + Event: "edit_profile", 141 + }) 142 + if err != nil { 143 + log.Println("failed to enqueue posthog event:", err) 144 + } 145 + } 146 + 147 + func (n *posthogNotifier) DeleteString(ctx context.Context, did, rkey string) { 148 + err := n.client.Enqueue(posthog.Capture{ 149 + DistinctId: did, 150 + Event: "delete_string", 151 + Properties: posthog.Properties{"rkey": rkey}, 152 + }) 153 + if err != nil { 154 + log.Println("failed to enqueue posthog event:", err) 155 + } 156 + } 157 + 158 + func (n *posthogNotifier) EditString(ctx context.Context, string *models.String) { 159 + err := n.client.Enqueue(posthog.Capture{ 160 + DistinctId: string.Did.String(), 161 + Event: "edit_string", 162 + Properties: posthog.Properties{"rkey": string.Rkey}, 163 + }) 164 + if err != nil { 165 + log.Println("failed to enqueue posthog event:", err) 166 + } 167 + } 168 + 169 + func (n *posthogNotifier) NewString(ctx context.Context, string *models.String) { 170 + err := n.client.Enqueue(posthog.Capture{ 171 + DistinctId: string.Did.String(), 172 + Event: "new_string", 173 + Properties: posthog.Properties{"rkey": string.Rkey}, 174 + }) 175 + if err != nil { 176 + log.Println("failed to enqueue posthog event:", err) 177 + } 178 + } 179 + 180 + func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment) { 181 + err := n.client.Enqueue(posthog.Capture{ 182 + DistinctId: comment.Did, 183 + Event: "new_issue_comment", 184 + Properties: posthog.Properties{ 185 + "issue_at": comment.IssueAt, 186 + }, 187 + }) 188 + if err != nil { 189 + log.Println("failed to enqueue posthog event:", err) 190 + } 191 + } 192 + 193 + func (n *posthogNotifier) NewIssueClosed(ctx context.Context, issue *models.Issue) { 194 + err := n.client.Enqueue(posthog.Capture{ 195 + DistinctId: issue.Did, 196 + Event: "issue_closed", 197 + Properties: posthog.Properties{ 198 + "repo_at": issue.RepoAt.String(), 199 + "issue_id": issue.IssueId, 200 + }, 201 + }) 202 + if err != nil { 203 + log.Println("failed to enqueue posthog event:", err) 204 + } 205 + } 206 + 207 + func (n *posthogNotifier) NewPullMerged(ctx context.Context, pull *models.Pull) { 208 + err := n.client.Enqueue(posthog.Capture{ 209 + DistinctId: pull.OwnerDid, 210 + Event: "pull_merged", 211 + Properties: posthog.Properties{ 212 + "repo_at": pull.RepoAt, 213 + "pull_id": pull.PullId, 214 + }, 215 + }) 216 + if err != nil { 217 + log.Println("failed to enqueue posthog event:", err) 218 + } 219 + }
+15 -15
appview/pages/funcmap.go
··· 141 141 "relTimeFmt": humanize.Time, 142 142 "shortRelTimeFmt": func(t time.Time) string { 143 143 return humanize.CustomRelTime(t, time.Now(), "", "", []humanize.RelTimeMagnitude{ 144 - {time.Second, "now", time.Second}, 145 - {2 * time.Second, "1s %s", 1}, 146 - {time.Minute, "%ds %s", time.Second}, 147 - {2 * time.Minute, "1min %s", 1}, 148 - {time.Hour, "%dmin %s", time.Minute}, 149 - {2 * time.Hour, "1hr %s", 1}, 150 - {humanize.Day, "%dhrs %s", time.Hour}, 151 - {2 * humanize.Day, "1d %s", 1}, 152 - {20 * humanize.Day, "%dd %s", humanize.Day}, 153 - {8 * humanize.Week, "%dw %s", humanize.Week}, 154 - {humanize.Year, "%dmo %s", humanize.Month}, 155 - {18 * humanize.Month, "1y %s", 1}, 156 - {2 * humanize.Year, "2y %s", 1}, 157 - {humanize.LongTime, "%dy %s", humanize.Year}, 158 - {math.MaxInt64, "a long while %s", 1}, 144 + {D: time.Second, Format: "now", DivBy: time.Second}, 145 + {D: 2 * time.Second, Format: "1s %s", DivBy: 1}, 146 + {D: time.Minute, Format: "%ds %s", DivBy: time.Second}, 147 + {D: 2 * time.Minute, Format: "1min %s", DivBy: 1}, 148 + {D: time.Hour, Format: "%dmin %s", DivBy: time.Minute}, 149 + {D: 2 * time.Hour, Format: "1hr %s", DivBy: 1}, 150 + {D: humanize.Day, Format: "%dhrs %s", DivBy: time.Hour}, 151 + {D: 2 * humanize.Day, Format: "1d %s", DivBy: 1}, 152 + {D: 20 * humanize.Day, Format: "%dd %s", DivBy: humanize.Day}, 153 + {D: 8 * humanize.Week, Format: "%dw %s", DivBy: humanize.Week}, 154 + {D: humanize.Year, Format: "%dmo %s", DivBy: humanize.Month}, 155 + {D: 18 * humanize.Month, Format: "1y %s", DivBy: 1}, 156 + {D: 2 * humanize.Year, Format: "2y %s", DivBy: 1}, 157 + {D: humanize.LongTime, Format: "%dy %s", DivBy: humanize.Year}, 158 + {D: math.MaxInt64, Format: "a long while %s", DivBy: 1}, 159 159 }) 160 160 }, 161 161 "longTimeFmt": func(t time.Time) string {
+30
appview/pages/funcmap_test.go
··· 1 + package pages 2 + 3 + import ( 4 + "html/template" 5 + "tangled.org/core/appview/config" 6 + "tangled.org/core/idresolver" 7 + "testing" 8 + ) 9 + 10 + func TestPages_funcMap(t *testing.T) { 11 + tests := []struct { 12 + name string // description of this test case 13 + // Named input parameters for receiver constructor. 14 + config *config.Config 15 + res *idresolver.Resolver 16 + want template.FuncMap 17 + }{ 18 + // TODO: Add test cases. 19 + } 20 + for _, tt := range tests { 21 + t.Run(tt.name, func(t *testing.T) { 22 + p := NewPages(tt.config, tt.res) 23 + got := p.funcMap() 24 + // TODO: update the condition below to compare got with tt.want. 25 + if true { 26 + t.Errorf("funcMap() = %v, want %v", got, tt.want) 27 + } 28 + }) 29 + } 30 + }
+156
appview/pages/legal/privacy.md
··· 1 + **Last updated:** September 26, 2025 2 + 3 + This Privacy Policy describes how Tangled ("we," "us," or "our") 4 + collects, uses, and shares your personal information when you use our 5 + platform and services (the "Service"). 6 + 7 + ## 1. Information We Collect 8 + 9 + ### Account Information 10 + 11 + When you create an account, we collect: 12 + 13 + - Your chosen username 14 + - Email address 15 + - Profile information you choose to provide 16 + - Authentication data 17 + 18 + ### Content and Activity 19 + 20 + We store: 21 + 22 + - Code repositories and associated metadata 23 + - Issues, pull requests, and comments 24 + - Activity logs and usage patterns 25 + - Public keys for authentication 26 + 27 + ## 2. Data Location and Hosting 28 + 29 + ### EU Data Hosting 30 + 31 + **All Tangled service data is hosted within the European Union.** 32 + Specifically: 33 + 34 + - **Personal Data Servers (PDS):** Accounts hosted on Tangled PDS 35 + (*.tngl.sh) are located in Finland 36 + - **Application Data:** All other service data is stored on EU-based 37 + servers 38 + - **Data Processing:** All data processing occurs within EU 39 + jurisdiction 40 + 41 + ### External PDS Notice 42 + 43 + **Important:** If your account is hosted on Bluesky's PDS or other 44 + self-hosted Personal Data Servers (not *.tngl.sh), we do not control 45 + that data. The data protection, storage location, and privacy 46 + practices for such accounts are governed by the respective PDS 47 + provider's policies, not this Privacy Policy. We only control data 48 + processing within our own services and infrastructure. 49 + 50 + ## 3. Third-Party Data Processors 51 + 52 + We only share your data with the following third-party processors: 53 + 54 + ### Resend (Email Services) 55 + 56 + - **Purpose:** Sending transactional emails (account verification, 57 + notifications) 58 + - **Data Shared:** Email address and necessary message content 59 + 60 + ### Cloudflare (Image Caching) 61 + 62 + - **Purpose:** Caching and optimizing image delivery 63 + - **Data Shared:** Public images and associated metadata for caching 64 + purposes 65 + 66 + ### Posthog (Usage Metrics Tracking) 67 + 68 + - **Purpose:** Tracking usage and platform metrics 69 + - **Data Shared:** Anonymous usage data, IP addresses, DIDs, and browser 70 + information 71 + 72 + ## 4. How We Use Your Information 73 + 74 + We use your information to: 75 + 76 + - Provide and maintain the Service 77 + - Process your transactions and requests 78 + - Send you technical notices and support messages 79 + - Improve and develop new features 80 + - Ensure security and prevent fraud 81 + - Comply with legal obligations 82 + 83 + ## 5. Data Sharing and Disclosure 84 + 85 + We do not sell, trade, or rent your personal information. We may share 86 + your information only in the following circumstances: 87 + 88 + - With the third-party processors listed above 89 + - When required by law or legal process 90 + - To protect our rights, property, or safety, or that of our users 91 + - In connection with a merger, acquisition, or sale of assets (with 92 + appropriate protections) 93 + 94 + ## 6. Data Security 95 + 96 + We implement appropriate technical and organizational measures to 97 + protect your personal information against unauthorized access, 98 + alteration, disclosure, or destruction. However, no method of 99 + transmission over the Internet is 100% secure. 100 + 101 + ## 7. Data Retention 102 + 103 + We retain your personal information for as long as necessary to provide 104 + the Service and fulfill the purposes outlined in this Privacy Policy, 105 + unless a longer retention period is required by law. 106 + 107 + ## 8. Your Rights 108 + 109 + Under applicable data protection laws, you have the right to: 110 + 111 + - Access your personal information 112 + - Correct inaccurate information 113 + - Request deletion of your information 114 + - Object to processing of your information 115 + - Data portability 116 + - Withdraw consent (where applicable) 117 + 118 + ## 9. Cookies and Tracking 119 + 120 + We use cookies and similar technologies to: 121 + 122 + - Maintain your login session 123 + - Remember your preferences 124 + - Analyze usage patterns to improve the Service 125 + 126 + You can control cookie settings through your browser preferences. 127 + 128 + ## 10. Children's Privacy 129 + 130 + The Service is not intended for children under 16 years of age. We do 131 + not knowingly collect personal information from children under 16. If 132 + we become aware that we have collected such information, we will take 133 + steps to delete it. 134 + 135 + ## 11. International Data Transfers 136 + 137 + While all our primary data processing occurs within the EU, some of our 138 + third-party processors may process data outside the EU. When this 139 + occurs, we ensure appropriate safeguards are in place, such as Standard 140 + Contractual Clauses or adequacy decisions. 141 + 142 + ## 12. Changes to This Privacy Policy 143 + 144 + We may update this Privacy Policy from time to time. We will notify you 145 + of any changes by posting the new Privacy Policy on this page and 146 + updating the "Last updated" date. 147 + 148 + ## 13. Contact Information 149 + 150 + If you have any questions about this Privacy Policy or wish to exercise 151 + your rights, please contact us through our platform or via email. 152 + 153 + --- 154 + 155 + This Privacy Policy complies with the EU General Data Protection 156 + Regulation (GDPR) and other applicable data protection laws.
+107
appview/pages/legal/terms.md
··· 1 + **Last updated:** September 26, 2025 2 + 3 + Welcome to Tangled. These Terms of Service ("Terms") govern your access 4 + to and use of the Tangled platform and services (the "Service") 5 + operated by us ("Tangled," "we," "us," or "our"). 6 + 7 + ## 1. Acceptance of Terms 8 + 9 + By accessing or using our Service, you agree to be bound by these Terms. 10 + If you disagree with any part of these terms, then you may not access 11 + the Service. 12 + 13 + ## 2. Account Registration 14 + 15 + To use certain features of the Service, you must register for an 16 + account. You agree to provide accurate, current, and complete 17 + information during the registration process and to update such 18 + information to keep it accurate, current, and complete. 19 + 20 + ## 3. Account Termination 21 + 22 + > **Important Notice** 23 + > 24 + > **We reserve the right to terminate, suspend, or restrict access to 25 + > your account at any time, for any reason, or for no reason at all, at 26 + > our sole discretion.** This includes, but is not limited to, 27 + > termination for violation of these Terms, inappropriate conduct, spam, 28 + > abuse, or any other behavior we deem harmful to the Service or other 29 + > users. 30 + > 31 + > Account termination may result in the loss of access to your 32 + > repositories, data, and other content associated with your account. We 33 + > are not obligated to provide advance notice of termination, though we 34 + > may do so in our discretion. 35 + 36 + ## 4. Acceptable Use 37 + 38 + You agree not to use the Service to: 39 + 40 + - Violate any applicable laws or regulations 41 + - Infringe upon the rights of others 42 + - Upload, store, or share content that is illegal, harmful, threatening, 43 + abusive, harassing, defamatory, vulgar, obscene, or otherwise 44 + objectionable 45 + - Engage in spam, phishing, or other deceptive practices 46 + - Attempt to gain unauthorized access to the Service or other users' 47 + accounts 48 + - Interfere with or disrupt the Service or servers connected to the 49 + Service 50 + 51 + ## 5. Content and Intellectual Property 52 + 53 + You retain ownership of the content you upload to the Service. By 54 + uploading content, you grant us a non-exclusive, worldwide, royalty-free 55 + license to use, reproduce, modify, and distribute your content as 56 + necessary to provide the Service. 57 + 58 + ## 6. Privacy 59 + 60 + Your privacy is important to us. Please review our [Privacy 61 + Policy](/privacy), which also governs your use of the Service. 62 + 63 + ## 7. Disclaimers 64 + 65 + The Service is provided on an "AS IS" and "AS AVAILABLE" basis. We make 66 + no warranties, expressed or implied, and hereby disclaim and negate all 67 + other warranties including without limitation, implied warranties or 68 + conditions of merchantability, fitness for a particular purpose, or 69 + non-infringement of intellectual property or other violation of rights. 70 + 71 + ## 8. Limitation of Liability 72 + 73 + In no event shall Tangled, nor its directors, employees, partners, 74 + agents, suppliers, or affiliates, be liable for any indirect, 75 + incidental, special, consequential, or punitive damages, including 76 + without limitation, loss of profits, data, use, goodwill, or other 77 + intangible losses, resulting from your use of the Service. 78 + 79 + ## 9. Indemnification 80 + 81 + You agree to defend, indemnify, and hold harmless Tangled and its 82 + affiliates, officers, directors, employees, and agents from and against 83 + any and all claims, damages, obligations, losses, liabilities, costs, 84 + or debt, and expenses (including attorney's fees). 85 + 86 + ## 10. Governing Law 87 + 88 + These Terms shall be interpreted and governed by the laws of Finland, 89 + without regard to its conflict of law provisions. 90 + 91 + ## 11. Changes to Terms 92 + 93 + We reserve the right to modify or replace these Terms at any time. If a 94 + revision is material, we will try to provide at least 30 days notice 95 + prior to any new terms taking effect. 96 + 97 + ## 12. Contact Information 98 + 99 + If you have any questions about these Terms of Service, please contact 100 + us through our platform or via email. 101 + 102 + --- 103 + 104 + These terms are effective as of the last updated date shown above and 105 + will remain in effect except with respect to any changes in their 106 + provisions in the future, which will be in effect immediately after 107 + being posted on this page.
+15 -17
appview/pages/markup/format.go
··· 1 1 package markup 2 2 3 - import "strings" 3 + import ( 4 + "regexp" 5 + ) 4 6 5 7 type Format string 6 8 ··· 10 12 ) 11 13 12 14 var FileTypes map[Format][]string = map[Format][]string{ 13 - FormatMarkdown: []string{".md", ".markdown", ".mdown", ".mkdn", ".mkd"}, 15 + FormatMarkdown: {".md", ".markdown", ".mdown", ".mkdn", ".mkd"}, 14 16 } 15 17 16 - // ReadmeFilenames contains the list of common README filenames to search for, 17 - // in order of preference. Only includes well-supported formats. 18 - var ReadmeFilenames = []string{ 19 - "README.md", "readme.md", 20 - "README", 21 - "readme", 22 - "README.markdown", 23 - "readme.markdown", 24 - "README.txt", 25 - "readme.txt", 18 + var FileTypePatterns = map[Format]*regexp.Regexp{ 19 + FormatMarkdown: regexp.MustCompile(`(?i)\.(md|markdown|mdown|mkdn|mkd)$`), 20 + } 21 + 22 + var ReadmePattern = regexp.MustCompile(`(?i)^readme(\.(md|markdown|txt))?$`) 23 + 24 + func IsReadmeFile(filename string) bool { 25 + return ReadmePattern.MatchString(filename) 26 26 } 27 27 28 28 func GetFormat(filename string) Format { 29 - for format, extensions := range FileTypes { 30 - for _, extension := range extensions { 31 - if strings.HasSuffix(filename, extension) { 32 - return format 33 - } 29 + for format, pattern := range FileTypePatterns { 30 + if pattern.MatchString(filename) { 31 + return format 34 32 } 35 33 } 36 34 // default format
+109 -29
appview/pages/pages.go
··· 38 38 "github.com/go-git/go-git/v5/plumbing/object" 39 39 ) 40 40 41 - //go:embed templates/* static 41 + //go:embed templates/* static legal 42 42 var Files embed.FS 43 43 44 44 type Pages struct { ··· 81 81 } 82 82 83 83 return p 84 - } 85 - 86 - func (p *Pages) pathToName(s string) string { 87 - return strings.TrimSuffix(strings.TrimPrefix(s, "templates/"), ".html") 88 84 } 89 85 90 86 // reverse of pathToName ··· 230 226 return p.executePlain("user/login", w, params) 231 227 } 232 228 233 - func (p *Pages) Signup(w io.Writer) error { 234 - return p.executePlain("user/signup", w, nil) 229 + type SignupParams struct { 230 + CloudflareSiteKey string 231 + } 232 + 233 + func (p *Pages) Signup(w io.Writer, params SignupParams) error { 234 + return p.executePlain("user/signup", w, params) 235 235 } 236 236 237 237 func (p *Pages) CompleteSignup(w io.Writer) error { ··· 246 246 func (p *Pages) TermsOfService(w io.Writer, params TermsOfServiceParams) error { 247 247 filename := "terms.md" 248 248 filePath := filepath.Join("legal", filename) 249 - markdownBytes, err := os.ReadFile(filePath) 249 + 250 + file, err := p.embedFS.Open(filePath) 251 + if err != nil { 252 + return fmt.Errorf("failed to read %s: %w", filename, err) 253 + } 254 + defer file.Close() 255 + 256 + markdownBytes, err := io.ReadAll(file) 250 257 if err != nil { 251 258 return fmt.Errorf("failed to read %s: %w", filename, err) 252 259 } ··· 267 274 func (p *Pages) PrivacyPolicy(w io.Writer, params PrivacyPolicyParams) error { 268 275 filename := "privacy.md" 269 276 filePath := filepath.Join("legal", filename) 270 - markdownBytes, err := os.ReadFile(filePath) 277 + 278 + file, err := p.embedFS.Open(filePath) 279 + if err != nil { 280 + return fmt.Errorf("failed to read %s: %w", filename, err) 281 + } 282 + defer file.Close() 283 + 284 + markdownBytes, err := io.ReadAll(file) 271 285 if err != nil { 272 286 return fmt.Errorf("failed to read %s: %w", filename, err) 273 287 } ··· 280 294 return p.execute("legal/privacy", w, params) 281 295 } 282 296 297 + type BrandParams struct { 298 + LoggedInUser *oauth.User 299 + } 300 + 301 + func (p *Pages) Brand(w io.Writer, params BrandParams) error { 302 + return p.execute("brand/brand", w, params) 303 + } 304 + 283 305 type TimelineParams struct { 284 306 LoggedInUser *oauth.User 285 307 Timeline []models.TimelineEvent 286 308 Repos []models.Repo 309 + GfiLabel *models.LabelDefinition 287 310 } 288 311 289 312 func (p *Pages) Timeline(w io.Writer, params TimelineParams) error { 290 313 return p.execute("timeline/timeline", w, params) 291 314 } 292 315 316 + type GoodFirstIssuesParams struct { 317 + LoggedInUser *oauth.User 318 + Issues []models.Issue 319 + RepoGroups []*models.RepoGroup 320 + LabelDefs map[string]*models.LabelDefinition 321 + GfiLabel *models.LabelDefinition 322 + Page pagination.Page 323 + } 324 + 325 + func (p *Pages) GoodFirstIssues(w io.Writer, params GoodFirstIssuesParams) error { 326 + return p.execute("goodfirstissues/index", w, params) 327 + } 328 + 293 329 type UserProfileSettingsParams struct { 294 330 LoggedInUser *oauth.User 295 331 Tabs []map[string]any ··· 300 336 return p.execute("user/settings/profile", w, params) 301 337 } 302 338 339 + type NotificationsParams struct { 340 + LoggedInUser *oauth.User 341 + Notifications []*models.NotificationWithEntity 342 + UnreadCount int 343 + Page pagination.Page 344 + Total int64 345 + } 346 + 347 + func (p *Pages) Notifications(w io.Writer, params NotificationsParams) error { 348 + return p.execute("notifications/list", w, params) 349 + } 350 + 351 + type NotificationItemParams struct { 352 + Notification *models.Notification 353 + } 354 + 355 + func (p *Pages) NotificationItem(w io.Writer, params NotificationItemParams) error { 356 + return p.executePlain("notifications/fragments/item", w, params) 357 + } 358 + 359 + type NotificationCountParams struct { 360 + Count int64 361 + } 362 + 363 + func (p *Pages) NotificationCount(w io.Writer, params NotificationCountParams) error { 364 + return p.executePlain("notifications/fragments/count", w, params) 365 + } 366 + 303 367 type UserKeysSettingsParams struct { 304 368 LoggedInUser *oauth.User 305 369 PubKeys []models.PublicKey ··· 322 386 return p.execute("user/settings/emails", w, params) 323 387 } 324 388 389 + type UserNotificationSettingsParams struct { 390 + LoggedInUser *oauth.User 391 + Preferences *models.NotificationPreferences 392 + Tabs []map[string]any 393 + Tab string 394 + } 395 + 396 + func (p *Pages) UserNotificationSettings(w io.Writer, params UserNotificationSettingsParams) error { 397 + return p.execute("user/settings/notifications", w, params) 398 + } 399 + 325 400 type UpgradeBannerParams struct { 326 401 Registrations []models.Registration 327 402 Spindles []models.Spindle ··· 488 563 489 564 type FollowCard struct { 490 565 UserDid string 566 + LoggedInUser *oauth.User 491 567 FollowStatus models.FollowStatus 492 568 FollowersCount int64 493 569 FollowingCount int64 ··· 658 734 } 659 735 660 736 type RepoTreeParams struct { 661 - LoggedInUser *oauth.User 662 - RepoInfo repoinfo.RepoInfo 663 - Active string 664 - BreadCrumbs [][]string 665 - TreePath string 666 - Readme string 667 - ReadmeFileName string 668 - HTMLReadme template.HTML 669 - Raw bool 737 + LoggedInUser *oauth.User 738 + RepoInfo repoinfo.RepoInfo 739 + Active string 740 + BreadCrumbs [][]string 741 + TreePath string 742 + Raw bool 743 + HTMLReadme template.HTML 670 744 types.RepoTreeResponse 671 745 } 672 746 ··· 694 768 func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error { 695 769 params.Active = "overview" 696 770 697 - if params.ReadmeFileName != "" { 698 - params.ReadmeFileName = filepath.Base(params.ReadmeFileName) 771 + p.rctx.RepoInfo = params.RepoInfo 772 + p.rctx.RepoInfo.Ref = params.Ref 773 + p.rctx.RendererType = markup.RendererTypeRepoMarkdown 699 774 775 + if params.ReadmeFileName != "" { 700 776 ext := filepath.Ext(params.ReadmeFileName) 701 777 switch ext { 702 778 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": ··· 838 914 } 839 915 840 916 type RepoGeneralSettingsParams struct { 841 - LoggedInUser *oauth.User 842 - RepoInfo repoinfo.RepoInfo 843 - Labels []models.LabelDefinition 844 - DefaultLabels []models.LabelDefinition 845 - SubscribedLabels map[string]struct{} 846 - Active string 847 - Tabs []map[string]any 848 - Tab string 849 - Branches []types.Branch 917 + LoggedInUser *oauth.User 918 + RepoInfo repoinfo.RepoInfo 919 + Labels []models.LabelDefinition 920 + DefaultLabels []models.LabelDefinition 921 + SubscribedLabels map[string]struct{} 922 + ShouldSubscribeAll bool 923 + Active string 924 + Tabs []map[string]any 925 + Tab string 926 + Branches []types.Branch 850 927 } 851 928 852 929 func (p *Pages) RepoGeneralSettings(w io.Writer, params RepoGeneralSettingsParams) error { ··· 1023 1100 FilteringBy models.PullState 1024 1101 Stacks map[string]models.Stack 1025 1102 Pipelines map[string]models.Pipeline 1103 + LabelDefs map[string]*models.LabelDefinition 1026 1104 } 1027 1105 1028 1106 func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error { ··· 1062 1140 OrderedReactionKinds []models.ReactionKind 1063 1141 Reactions map[models.ReactionKind]int 1064 1142 UserReacted map[models.ReactionKind]bool 1143 + 1144 + LabelDefs map[string]*models.LabelDefinition 1065 1145 } 1066 1146 1067 1147 func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error {
+224
appview/pages/templates/brand/brand.html
··· 1 + {{ define "title" }}brand{{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="grid grid-cols-10"> 5 + <header class="col-span-full md:col-span-10 px-6 py-2 mb-4"> 6 + <h1 class="text-2xl font-bold dark:text-white mb-1">Brand</h1> 7 + <p class="text-gray-600 dark:text-gray-400 mb-1"> 8 + Assets and guidelines for using Tangled's logo and brand elements. 9 + </p> 10 + </header> 11 + 12 + <main class="col-span-full md:col-span-10 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10"> 13 + <div class="space-y-16"> 14 + 15 + <!-- Introduction Section --> 16 + <section> 17 + <p class="text-gray-600 dark:text-gray-400 mb-2"> 18 + Tangled's logo and mascot is <strong>Dolly</strong>, the first ever <em>cloned</em> mammal. Please 19 + follow the below guidelines when using Dolly and the logotype. 20 + </p> 21 + <p class="text-gray-600 dark:text-gray-400 mb-2"> 22 + All assets are served as SVGs, and can be downloaded by right-clicking and clicking "Save image as". 23 + </p> 24 + </section> 25 + 26 + <!-- Black Logotype Section --> 27 + <section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center"> 28 + <div class="order-2 lg:order-1"> 29 + <div class="border border-gray-200 dark:border-gray-700 p-8 sm:p-16 bg-gray-50 dark:bg-gray-100 rounded"> 30 + <img src="https://assets.tangled.network/tangled_logotype_black_on_trans.svg" 31 + alt="Tangled logo - black version" 32 + class="w-full max-w-sm mx-auto" /> 33 + </div> 34 + </div> 35 + <div class="order-1 lg:order-2"> 36 + <h2 class="text-xl font-semibold dark:text-white mb-3">Black logotype</h2> 37 + <p class="text-gray-600 dark:text-gray-400 mb-4">For use on light-colored backgrounds.</p> 38 + <p class="text-gray-700 dark:text-gray-300"> 39 + This is the preferred version of the logotype, featuring dark text and elements, ideal for light 40 + backgrounds and designs. 41 + </p> 42 + </div> 43 + </section> 44 + 45 + <!-- White Logotype Section --> 46 + <section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center"> 47 + <div class="order-2 lg:order-1"> 48 + <div class="bg-black p-8 sm:p-16 rounded"> 49 + <img src="https://assets.tangled.network/tangled_logotype_white_on_trans.svg" 50 + alt="Tangled logo - white version" 51 + class="w-full max-w-sm mx-auto" /> 52 + </div> 53 + </div> 54 + <div class="order-1 lg:order-2"> 55 + <h2 class="text-xl font-semibold dark:text-white mb-3">White logotype</h2> 56 + <p class="text-gray-600 dark:text-gray-400 mb-4">For use on dark-colored backgrounds.</p> 57 + <p class="text-gray-700 dark:text-gray-300"> 58 + This version features white text and elements, ideal for dark backgrounds 59 + and inverted designs. 60 + </p> 61 + </div> 62 + </section> 63 + 64 + <!-- Mark Only Section --> 65 + <section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center"> 66 + <div class="order-2 lg:order-1"> 67 + <div class="grid grid-cols-2 gap-2"> 68 + <!-- Black mark on light background --> 69 + <div class="border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-100 p-8 sm:p-12 rounded"> 70 + <img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg" 71 + alt="Dolly face - black version" 72 + class="w-full max-w-16 mx-auto" /> 73 + </div> 74 + <!-- White mark on dark background --> 75 + <div class="bg-black p-8 sm:p-12 rounded"> 76 + <img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg" 77 + alt="Dolly face - white version" 78 + class="w-full max-w-16 mx-auto" /> 79 + </div> 80 + </div> 81 + </div> 82 + <div class="order-1 lg:order-2"> 83 + <h2 class="text-xl font-semibold dark:text-white mb-3">Mark only</h2> 84 + <p class="text-gray-600 dark:text-gray-400 mb-4"> 85 + When a smaller 1:1 logo or icon is needed, Dolly's face may be used on its own. 86 + </p> 87 + <p class="text-gray-700 dark:text-gray-300 mb-4"> 88 + <strong class="font-semibold">Note</strong>: for situations where the background 89 + is unknown, use the black version for ideal contrast in most environments. 90 + </p> 91 + </div> 92 + </section> 93 + 94 + <!-- Colored Backgrounds Section --> 95 + <section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center"> 96 + <div class="order-2 lg:order-1"> 97 + <div class="grid grid-cols-2 gap-2"> 98 + <!-- Pastel Green background --> 99 + <div class="bg-green-500 p-8 sm:p-12 rounded"> 100 + <img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg" 101 + alt="Tangled logo on pastel green background" 102 + class="w-full max-w-16 mx-auto" /> 103 + </div> 104 + <!-- Pastel Blue background --> 105 + <div class="bg-blue-500 p-8 sm:p-12 rounded"> 106 + <img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg" 107 + alt="Tangled logo on pastel blue background" 108 + class="w-full max-w-16 mx-auto" /> 109 + </div> 110 + <!-- Pastel Yellow background --> 111 + <div class="bg-yellow-500 p-8 sm:p-12 rounded"> 112 + <img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg" 113 + alt="Tangled logo on pastel yellow background" 114 + class="w-full max-w-16 mx-auto" /> 115 + </div> 116 + <!-- Pastel Red background --> 117 + <div class="bg-red-500 p-8 sm:p-12 rounded"> 118 + <img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg" 119 + alt="Tangled logo on pastel red background" 120 + class="w-full max-w-16 mx-auto" /> 121 + </div> 122 + </div> 123 + </div> 124 + <div class="order-1 lg:order-2"> 125 + <h2 class="text-xl font-semibold dark:text-white mb-3">Colored backgrounds</h2> 126 + <p class="text-gray-600 dark:text-gray-400 mb-4"> 127 + White logo mark on colored backgrounds. 128 + </p> 129 + <p class="text-gray-700 dark:text-gray-300 mb-4"> 130 + The white logo mark provides contrast on colored backgrounds. 131 + Perfect for more fun design contexts. 132 + </p> 133 + </div> 134 + </section> 135 + 136 + <!-- Black Logo on Pastel Backgrounds Section --> 137 + <section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center"> 138 + <div class="order-2 lg:order-1"> 139 + <div class="grid grid-cols-2 gap-2"> 140 + <!-- Pastel Green background --> 141 + <div class="bg-green-200 p-8 sm:p-12 rounded"> 142 + <img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg" 143 + alt="Tangled logo on pastel green background" 144 + class="w-full max-w-16 mx-auto" /> 145 + </div> 146 + <!-- Pastel Blue background --> 147 + <div class="bg-blue-200 p-8 sm:p-12 rounded"> 148 + <img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg" 149 + alt="Tangled logo on pastel blue background" 150 + class="w-full max-w-16 mx-auto" /> 151 + </div> 152 + <!-- Pastel Yellow background --> 153 + <div class="bg-yellow-200 p-8 sm:p-12 rounded"> 154 + <img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg" 155 + alt="Tangled logo on pastel yellow background" 156 + class="w-full max-w-16 mx-auto" /> 157 + </div> 158 + <!-- Pastel Pink background --> 159 + <div class="bg-pink-200 p-8 sm:p-12 rounded"> 160 + <img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg" 161 + alt="Tangled logo on pastel pink background" 162 + class="w-full max-w-16 mx-auto" /> 163 + </div> 164 + </div> 165 + </div> 166 + <div class="order-1 lg:order-2"> 167 + <h2 class="text-xl font-semibold dark:text-white mb-3">Lighter backgrounds</h2> 168 + <p class="text-gray-600 dark:text-gray-400 mb-4"> 169 + Dark logo mark on lighter, pastel backgrounds. 170 + </p> 171 + <p class="text-gray-700 dark:text-gray-300 mb-4"> 172 + The dark logo mark works beautifully on pastel backgrounds, 173 + providing crisp contrast. 174 + </p> 175 + </div> 176 + </section> 177 + 178 + <!-- Recoloring Section --> 179 + <section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center"> 180 + <div class="order-2 lg:order-1"> 181 + <div class="bg-yellow-100 border border-yellow-200 p-8 sm:p-16 rounded"> 182 + <img src="https://assets.tangled.network/tangled_logotype_black_on_trans.svg" 183 + alt="Recolored Tangled logotype in gray/sand color" 184 + class="w-full max-w-sm mx-auto opacity-60 sepia contrast-75 saturate-50" /> 185 + </div> 186 + </div> 187 + <div class="order-1 lg:order-2"> 188 + <h2 class="text-xl font-semibold dark:text-white mb-3">Recoloring</h2> 189 + <p class="text-gray-600 dark:text-gray-400 mb-4"> 190 + Custom coloring of the logotype is permitted. 191 + </p> 192 + <p class="text-gray-700 dark:text-gray-300 mb-4"> 193 + Recoloring the logotype is allowed as long as readability is maintained. 194 + </p> 195 + <p class="text-gray-700 dark:text-gray-300 text-sm"> 196 + <strong>Example:</strong> Gray/sand colored logotype on a light yellow/tan background. 197 + </p> 198 + </div> 199 + </section> 200 + 201 + <!-- Silhouette Section --> 202 + <section class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center"> 203 + <div class="order-2 lg:order-1"> 204 + <div class="border border-gray-200 dark:border-gray-700 p-8 sm:p-16 bg-gray-50 dark:bg-gray-100 rounded"> 205 + <img src="https://assets.tangled.network/tangled_dolly_silhouette.svg" 206 + alt="Dolly silhouette" 207 + class="w-full max-w-32 mx-auto" /> 208 + </div> 209 + </div> 210 + <div class="order-1 lg:order-2"> 211 + <h2 class="text-xl font-semibold dark:text-white mb-3">Dolly silhouette</h2> 212 + <p class="text-gray-600 dark:text-gray-400 mb-4">A minimalist version of Dolly.</p> 213 + <p class="text-gray-700 dark:text-gray-300"> 214 + The silhouette can be used where a subtle brand presence is needed, 215 + or as a background element. Works on any background color with proper contrast. 216 + For example, we use this as the site's favicon. 217 + </p> 218 + </div> 219 + </section> 220 + 221 + </div> 222 + </main> 223 + </div> 224 + {{ end }}
+4 -11
appview/pages/templates/errors/500.html
··· 5 5 <div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto"> 6 6 <div class="mb-6"> 7 7 <div class="w-16 h-16 mx-auto mb-4 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center"> 8 - {{ i "alert-triangle" "w-8 h-8 text-red-500 dark:text-red-400" }} 8 + {{ i "triangle-alert" "w-8 h-8 text-red-500 dark:text-red-400" }} 9 9 </div> 10 10 </div> 11 11 ··· 14 14 500 &mdash; internal server error 15 15 </h1> 16 16 <p class="text-gray-600 dark:text-gray-300"> 17 - Something went wrong on our end. We've been notified and are working to fix the issue. 18 - </p> 19 - <div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded p-3 text-sm text-yellow-800 dark:text-yellow-200"> 20 - <div class="flex items-center gap-2"> 21 - {{ i "info" "w-4 h-4" }} 22 - <span class="font-medium">we're on it!</span> 23 - </div> 24 - <p class="mt-1">Our team has been automatically notified about this error.</p> 25 - </div> 17 + We encountered an error while processing your request. Please try again later. 18 + </p> 26 19 <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 27 20 <button onclick="location.reload()" class="btn-create gap-2"> 28 21 {{ i "refresh-cw" "w-4 h-4" }} 29 22 try again 30 23 </button> 31 24 <a href="/" class="btn no-underline hover:no-underline gap-2"> 32 - {{ i "home" "w-4 h-4" }} 25 + {{ i "arrow-left" "w-4 h-4" }} 33 26 back to home 34 27 </a> 35 28 </div>
+167
appview/pages/templates/goodfirstissues/index.html
··· 1 + {{ define "title" }}good first issues{{ end }} 2 + 3 + {{ define "extrameta" }} 4 + <meta property="og:title" content="good first issues · tangled" /> 5 + <meta property="og:type" content="object" /> 6 + <meta property="og:url" content="https://tangled.org/goodfirstissues" /> 7 + <meta property="og:description" content="Find good first issues to contribute to open source projects" /> 8 + {{ end }} 9 + 10 + {{ define "content" }} 11 + <div class="grid grid-cols-10"> 12 + <header class="col-span-full md:col-span-10 px-6 py-2 text-center flex flex-col items-center justify-center py-8"> 13 + <h1 class="scale-150 dark:text-white mb-4"> 14 + {{ template "labels/fragments/label" (dict "def" .GfiLabel "val" "" "withPrefix" true) }} 15 + </h1> 16 + <p class="text-gray-600 dark:text-gray-400 mb-2"> 17 + Find beginner-friendly issues across all repositories to get started with open source contributions. 18 + </p> 19 + </header> 20 + 21 + <div class="col-span-full md:col-span-10 space-y-6"> 22 + {{ if eq (len .RepoGroups) 0 }} 23 + <div class="bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10"> 24 + <div class="text-center py-16"> 25 + <div class="text-gray-500 dark:text-gray-400 mb-4"> 26 + {{ i "circle-dot" "w-16 h-16 mx-auto" }} 27 + </div> 28 + <h3 class="text-xl font-medium text-gray-900 dark:text-white mb-2">No good first issues available</h3> 29 + <p class="text-gray-600 dark:text-gray-400 mb-3 max-w-md mx-auto"> 30 + There are currently no open issues labeled as "good-first-issue" across all repositories. 31 + </p> 32 + <p class="text-gray-500 dark:text-gray-500 text-sm max-w-md mx-auto"> 33 + Repository maintainers can add the "good-first-issue" label to beginner-friendly issues to help newcomers get started. 34 + </p> 35 + </div> 36 + </div> 37 + {{ else }} 38 + {{ range .RepoGroups }} 39 + <div class="mb-4 gap-1 flex flex-col drop-shadow-sm rounded bg-white dark:bg-gray-800"> 40 + <div class="flex px-6 pt-4 flex-row gap-1 items-center justify-between flex-wrap"> 41 + <div class="font-medium dark:text-white flex items-center justify-between"> 42 + <div class="flex items-center min-w-0 flex-1 mr-2"> 43 + {{ if .Repo.Source }} 44 + {{ i "git-fork" "w-4 h-4 mr-1.5 shrink-0" }} 45 + {{ else }} 46 + {{ i "book-marked" "w-4 h-4 mr-1.5 shrink-0" }} 47 + {{ end }} 48 + {{ $repoOwner := resolve .Repo.Did }} 49 + <a href="/{{ $repoOwner }}/{{ .Repo.Name }}" class="truncate min-w-0">{{ $repoOwner }}/{{ .Repo.Name }}</a> 50 + </div> 51 + </div> 52 + 53 + 54 + {{ if .Repo.RepoStats }} 55 + <div class="text-gray-400 text-sm font-mono inline-flex gap-4"> 56 + {{ with .Repo.RepoStats.Language }} 57 + <div class="flex gap-2 items-center text-sm"> 58 + {{ template "repo/fragments/colorBall" (dict "color" (langColor .)) }} 59 + <span>{{ . }}</span> 60 + </div> 61 + {{ end }} 62 + {{ with .Repo.RepoStats.StarCount }} 63 + <div class="flex gap-1 items-center text-sm"> 64 + {{ i "star" "w-3 h-3 fill-current" }} 65 + <span>{{ . }}</span> 66 + </div> 67 + {{ end }} 68 + {{ with .Repo.RepoStats.IssueCount.Open }} 69 + <div class="flex gap-1 items-center text-sm"> 70 + {{ i "circle-dot" "w-3 h-3" }} 71 + <span>{{ . }}</span> 72 + </div> 73 + {{ end }} 74 + {{ with .Repo.RepoStats.PullCount.Open }} 75 + <div class="flex gap-1 items-center text-sm"> 76 + {{ i "git-pull-request" "w-3 h-3" }} 77 + <span>{{ . }}</span> 78 + </div> 79 + {{ end }} 80 + </div> 81 + {{ end }} 82 + </div> 83 + 84 + {{ with .Repo.Description }} 85 + <div class="pl-6 pb-2 text-gray-600 dark:text-gray-300 text-sm line-clamp-2"> 86 + {{ . | description }} 87 + </div> 88 + {{ end }} 89 + 90 + {{ if gt (len .Issues) 0 }} 91 + <div class="grid grid-cols-1 rounded-b border-b border-t border-gray-200 dark:border-gray-900 divide-y divide-gray-200 dark:divide-gray-900"> 92 + {{ range .Issues }} 93 + <a href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}/issues/{{ .IssueId }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25"> 94 + <div class="py-2 px-6"> 95 + <div class="flex-grow min-w-0 w-full"> 96 + <div class="flex text-sm items-center justify-between w-full"> 97 + <div class="flex items-center gap-2 min-w-0 flex-1 pr-2"> 98 + <span class="truncate text-sm text-gray-800 dark:text-gray-200"> 99 + <span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span> 100 + {{ .Title | description }} 101 + </span> 102 + </div> 103 + <div class="flex-shrink-0 flex items-center gap-2 text-gray-500 dark:text-gray-400"> 104 + <span> 105 + <div class="inline-flex items-center gap-1"> 106 + {{ i "message-square" "w-3 h-3" }} 107 + {{ len .Comments }} 108 + </div> 109 + </span> 110 + <span class="before:content-['·'] before:select-none"></span> 111 + <span class="text-sm"> 112 + {{ template "repo/fragments/shortTimeAgo" .Created }} 113 + </span> 114 + <div class="hidden md:inline-flex md:gap-1"> 115 + {{ $labelState := .Labels }} 116 + {{ range $k, $d := $.LabelDefs }} 117 + {{ range $v, $s := $labelState.GetValSet $d.AtUri.String }} 118 + {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }} 119 + {{ end }} 120 + {{ end }} 121 + </div> 122 + </div> 123 + </div> 124 + </div> 125 + </div> 126 + </a> 127 + {{ end }} 128 + </div> 129 + {{ end }} 130 + </div> 131 + {{ end }} 132 + 133 + {{ if or (gt .Page.Offset 0) (eq (len .RepoGroups) .Page.Limit) }} 134 + <div class="flex justify-center mt-8"> 135 + <div class="flex gap-2"> 136 + {{ if gt .Page.Offset 0 }} 137 + {{ $prev := .Page.Previous }} 138 + <a 139 + class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 140 + hx-boost="true" 141 + href="/goodfirstissues?offset={{ $prev.Offset }}&limit={{ $prev.Limit }}" 142 + > 143 + {{ i "chevron-left" "w-4 h-4" }} 144 + previous 145 + </a> 146 + {{ else }} 147 + <div></div> 148 + {{ end }} 149 + 150 + {{ if eq (len .RepoGroups) .Page.Limit }} 151 + {{ $next := .Page.Next }} 152 + <a 153 + class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 154 + hx-boost="true" 155 + href="/goodfirstissues?offset={{ $next.Offset }}&limit={{ $next.Limit }}" 156 + > 157 + next 158 + {{ i "chevron-right" "w-4 h-4" }} 159 + </a> 160 + {{ end }} 161 + </div> 162 + </div> 163 + {{ end }} 164 + {{ end }} 165 + </div> 166 + </div> 167 + {{ end }}
+1 -1
appview/pages/templates/labels/fragments/label.html
··· 2 2 {{ $d := .def }} 3 3 {{ $v := .val }} 4 4 {{ $withPrefix := .withPrefix }} 5 - <span class="flex items-center gap-2 font-normal normal-case rounded py-1 px-2 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm"> 5 + <span class="w-fit flex items-center gap-2 font-normal normal-case rounded py-1 px-2 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm"> 6 6 {{ template "repo/fragments/colorBall" (dict "color" $d.GetColor) }} 7 7 8 8 {{ $lhs := printf "%s" $d.Name }}
+16 -12
appview/pages/templates/layouts/base.html
··· 14 14 <link rel="preconnect" href="https://avatar.tangled.sh" /> 15 15 <link rel="preconnect" href="https://camo.tangled.sh" /> 16 16 17 + <!-- pwa manifest --> 18 + <link rel="manifest" href="/pwa-manifest.json" /> 19 + 17 20 <!-- preload main font --> 18 21 <link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin /> 19 22 ··· 21 24 <title>{{ block "title" . }}{{ end }} · tangled</title> 22 25 {{ block "extrameta" . }}{{ end }} 23 26 </head> 24 - <body class="min-h-screen grid grid-cols-1 grid-rows-[min-content_auto_min-content] gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200" 25 - style="grid-template-columns: minmax(1rem, 1fr) minmax(auto, 1024px) minmax(1rem, 1fr);"> 27 + <body class="min-h-screen flex flex-col gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200"> 26 28 {{ block "topbarLayout" . }} 27 - <header class="px-1 col-span-full md:col-span-1 md:col-start-2" style="z-index: 20;"> 29 + <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;"> 28 30 29 31 {{ if .LoggedInUser }} 30 32 <div id="upgrade-banner" ··· 38 40 {{ end }} 39 41 40 42 {{ 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"> 43 + <div class="flex-grow"> 44 + <div class="max-w-screen-lg mx-auto flex flex-col gap-4"> 45 + {{ block "contentLayout" . }} 46 + <main> 44 47 {{ block "content" . }}{{ end }} 45 48 </main> 46 - {{ end }} 47 - 48 - {{ block "contentAfterLayout" . }} 49 - <main class="col-span-1 md:col-span-8"> 49 + {{ end }} 50 + 51 + {{ block "contentAfterLayout" . }} 52 + <main> 50 53 {{ block "contentAfter" . }}{{ end }} 51 54 </main> 52 - {{ end }} 55 + {{ end }} 56 + </div> 53 57 </div> 54 58 {{ end }} 55 59 56 60 {{ block "footerLayout" . }} 57 - <footer class="px-1 col-span-full md:col-span-1 md:col-start-2 mt-12"> 61 + <footer class="bg-white dark:bg-gray-800 mt-12"> 58 62 {{ template "layouts/fragments/footer" . }} 59 63 </footer> 60 64 {{ end }}
+87 -33
appview/pages/templates/layouts/fragments/footer.html
··· 1 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"> 7 - {{ template "fragments/logotypeSmall" }} 8 - </a> 9 - </div> 2 + <div class="w-full p-8"> 3 + <div class="mx-auto px-4"> 4 + <div class="flex flex-col text-gray-600 dark:text-gray-400 gap-8"> 5 + <!-- Desktop layout: grid with 3 columns --> 6 + <div class="hidden lg:grid lg:grid-cols-[1fr_minmax(0,1024px)_1fr] lg:gap-8 lg:items-start"> 7 + <!-- Left section --> 8 + <div> 9 + <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic no-underline hover:no-underline"> 10 + {{ template "fragments/logotypeSmall" }} 11 + </a> 12 + </div> 10 13 11 - {{ $headerStyle := "text-gray-900 dark:text-gray-200 font-bold text-xs uppercase tracking-wide mb-1" }} 12 - {{ $linkStyle := "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" }} 13 - {{ $iconStyle := "w-4 h-4 flex-shrink-0" }} 14 - <div class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-4 sm:gap-6 md:gap-2 gap-6 flex-1"> 15 - <div class="flex flex-col gap-1"> 16 - <div class="{{ $headerStyle }}">legal</div> 17 - <a href="/terms" class="{{ $linkStyle }}">{{ i "file-text" $iconStyle }} terms of service</a> 18 - <a href="/privacy" class="{{ $linkStyle }}">{{ i "shield" $iconStyle }} privacy policy</a> 14 + {{ $headerStyle := "text-gray-900 dark:text-gray-200 font-bold text-sm uppercase tracking-wide mb-1" }} 15 + {{ $linkStyle := "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" }} 16 + {{ $iconStyle := "w-4 h-4 flex-shrink-0" }} 17 + 18 + <!-- Center section with max-width --> 19 + <div class="grid grid-cols-4 gap-2"> 20 + <div class="flex flex-col gap-1"> 21 + <div class="{{ $headerStyle }}">legal</div> 22 + <a href="/terms" class="{{ $linkStyle }}">{{ i "file-text" $iconStyle }} terms of service</a> 23 + <a href="/privacy" class="{{ $linkStyle }}">{{ i "shield" $iconStyle }} privacy policy</a> 24 + </div> 25 + 26 + <div class="flex flex-col gap-1"> 27 + <div class="{{ $headerStyle }}">resources</div> 28 + <a href="https://blog.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 29 + <a href="https://tangled.org/@tangled.org/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 30 + <a href="https://tangled.org/@tangled.org/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 31 + <a href="https://tangled.org/brand" class="{{ $linkStyle }}">{{ i "paintbrush" $iconStyle }} brand</a> 32 + </div> 33 + 34 + <div class="flex flex-col gap-1"> 35 + <div class="{{ $headerStyle }}">social</div> 36 + <a href="https://chat.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "message-circle" $iconStyle }} discord</a> 37 + <a href="https://web.libera.chat/#tangled" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "hash" $iconStyle }} irc</a> 38 + <a href="https://bsky.app/profile/tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ template "user/fragments/bluesky" $iconStyle }} bluesky</a> 39 + </div> 40 + 41 + <div class="flex flex-col gap-1"> 42 + <div class="{{ $headerStyle }}">contact</div> 43 + <a href="mailto:team@tangled.org" class="{{ $linkStyle }}">{{ i "mail" "w-4 h-4 flex-shrink-0" }} team@tangled.org</a> 44 + <a href="mailto:security@tangled.org" class="{{ $linkStyle }}">{{ i "shield-check" "w-4 h-4 flex-shrink-0" }} security@tangled.org</a> 45 + </div> 19 46 </div> 20 47 21 - <div class="flex flex-col gap-1"> 22 - <div class="{{ $headerStyle }}">resources</div> 23 - <a href="https://blog.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 24 - <a href="https://tangled.org/@tangled.org/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 25 - <a href="https://tangled.org/@tangled.org/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 48 + <!-- Right section --> 49 + <div class="text-right"> 50 + <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 26 51 </div> 52 + </div> 27 53 28 - <div class="flex flex-col gap-1"> 29 - <div class="{{ $headerStyle }}">social</div> 30 - <a href="https://chat.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "message-circle" $iconStyle }} discord</a> 31 - <a href="https://web.libera.chat/#tangled" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "hash" $iconStyle }} irc</a> 32 - <a href="https://bsky.app/profile/tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ template "user/fragments/bluesky" $iconStyle }} bluesky</a> 54 + <!-- Mobile layout: stacked --> 55 + <div class="lg:hidden flex flex-col gap-8"> 56 + {{ $headerStyle := "text-gray-900 dark:text-gray-200 font-bold text-xs uppercase tracking-wide mb-1" }} 57 + {{ $linkStyle := "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" }} 58 + {{ $iconStyle := "w-4 h-4 flex-shrink-0" }} 59 + 60 + <div class="mb-4 md:mb-0"> 61 + <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic no-underline hover:no-underline"> 62 + {{ template "fragments/logotypeSmall" }} 63 + </a> 33 64 </div> 34 65 35 - <div class="flex flex-col gap-1"> 36 - <div class="{{ $headerStyle }}">contact</div> 37 - <a href="mailto:team@tangled.org" class="{{ $linkStyle }}">{{ i "mail" "w-4 h-4 flex-shrink-0" }} team@tangled.org</a> 38 - <a href="mailto:security@tangled.org" class="{{ $linkStyle }}">{{ i "shield-check" "w-4 h-4 flex-shrink-0" }} security@tangled.org</a> 66 + <div class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-4 sm:gap-6 md:gap-2 gap-6"> 67 + <div class="flex flex-col gap-1"> 68 + <div class="{{ $headerStyle }}">legal</div> 69 + <a href="/terms" class="{{ $linkStyle }}">{{ i "file-text" $iconStyle }} terms of service</a> 70 + <a href="/privacy" class="{{ $linkStyle }}">{{ i "shield" $iconStyle }} privacy policy</a> 71 + </div> 72 + 73 + <div class="flex flex-col gap-1"> 74 + <div class="{{ $headerStyle }}">resources</div> 75 + <a href="https://blog.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 76 + <a href="https://tangled.org/@tangled.org/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 77 + <a href="https://tangled.org/@tangled.org/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 78 + <a href="https://tangled.org/brand" class="{{ $linkStyle }}">{{ i "paintbrush" $iconStyle }} brand</a> 79 + </div> 80 + 81 + <div class="flex flex-col gap-1"> 82 + <div class="{{ $headerStyle }}">social</div> 83 + <a href="https://chat.tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "message-circle" $iconStyle }} discord</a> 84 + <a href="https://web.libera.chat/#tangled" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "hash" $iconStyle }} irc</a> 85 + <a href="https://bsky.app/profile/tangled.org" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ template "user/fragments/bluesky" $iconStyle }} bluesky</a> 86 + </div> 87 + 88 + <div class="flex flex-col gap-1"> 89 + <div class="{{ $headerStyle }}">contact</div> 90 + <a href="mailto:team@tangled.org" class="{{ $linkStyle }}">{{ i "mail" "w-4 h-4 flex-shrink-0" }} team@tangled.org</a> 91 + <a href="mailto:security@tangled.org" class="{{ $linkStyle }}">{{ i "shield-check" "w-4 h-4 flex-shrink-0" }} security@tangled.org</a> 92 + </div> 39 93 </div> 40 - </div> 41 94 42 - <div class="text-center lg:text-right flex-shrink-0"> 43 - <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 95 + <div class="text-center"> 96 + <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 97 + </div> 44 98 </div> 45 99 </div> 46 100 </div>
+17 -7
appview/pages/templates/layouts/fragments/topbar.html
··· 1 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"> 2 + <nav class="mx-auto space-x-4 px-6 py-2 rounded-b dark:text-white drop-shadow-sm"> 3 3 <div class="flex justify-between p-0 items-center"> 4 4 <div id="left-items"> 5 - <a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline"> 6 - {{ template "fragments/logotypeSmall" }} 5 + <a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline flex items-center gap-2"> 6 + {{ template "fragments/dolly/logo" "size-8 text-black dark:text-white" }} 7 + <span class="font-bold text-xl not-italic hidden md:inline">tangled</span> 8 + <span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1 hidden md:inline"> 9 + alpha 10 + </span> 7 11 </a> 8 12 </div> 9 13 10 - <div id="right-items" class="flex items-center gap-2"> 14 + <div id="right-items" class="flex items-center gap-4"> 11 15 {{ with .LoggedInUser }} 12 16 {{ block "newButton" . }} {{ end }} 17 + {{ template "notifications/fragments/bell" }} 13 18 {{ block "dropDown" . }} {{ end }} 14 19 {{ else }} 15 20 <a href="/login">login</a> ··· 26 31 {{ define "newButton" }} 27 32 <details class="relative inline-block text-left nav-dropdown"> 28 33 <summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2"> 29 - {{ i "plus" "w-4 h-4" }} new 34 + {{ i "plus" "w-4 h-4" }} <span class="hidden md:inline">new</span> 30 35 </summary> 31 36 <div class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"> 32 37 <a href="/repo/new" class="flex items-center gap-2"> ··· 44 49 {{ define "dropDown" }} 45 50 <details class="relative inline-block text-left nav-dropdown"> 46 51 <summary 47 - class="cursor-pointer list-none flex items-center" 52 + class="cursor-pointer list-none flex items-center gap-1" 48 53 > 49 54 {{ $user := didOrHandle .Did .Handle }} 50 - {{ template "user/fragments/picHandle" $user }} 55 + <img 56 + src="{{ tinyAvatar $user }}" 57 + alt="" 58 + class="rounded-full h-6 w-6 border border-gray-300 dark:border-gray-700" 59 + /> 60 + <span class="hidden md:inline">{{ $user | resolve | truncateAt30 }}</span> 51 61 </summary> 52 62 <div 53 63 class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"
+13 -6
appview/pages/templates/legal/privacy.html
··· 1 1 {{ define "title" }}privacy policy{{ end }} 2 2 3 3 {{ define "content" }} 4 - <div class="max-w-4xl mx-auto px-4 py-8"> 5 - <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8"> 6 - <div class="prose prose-gray dark:prose-invert max-w-none"> 7 - {{ .Content }} 8 - </div> 4 + <div class="grid grid-cols-10"> 5 + <header class="col-span-full md:col-span-10 px-6 py-2 mb-4"> 6 + <h1 class="text-2xl font-bold dark:text-white mb-1">Privacy Policy</h1> 7 + <p class="text-gray-600 dark:text-gray-400 mb-1"> 8 + Learn how we collect, use, and protect your personal information. 9 + </p> 10 + </header> 11 + 12 + <main class="col-span-full md:col-span-10 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10"> 13 + <div class="prose prose-gray dark:prose-invert max-w-none"> 14 + {{ .Content }} 9 15 </div> 16 + </main> 10 17 </div> 11 - {{ end }} 18 + {{ end }}
+13 -6
appview/pages/templates/legal/terms.html
··· 1 1 {{ define "title" }}terms of service{{ end }} 2 2 3 3 {{ define "content" }} 4 - <div class="max-w-4xl mx-auto px-4 py-8"> 5 - <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8"> 6 - <div class="prose prose-gray dark:prose-invert max-w-none"> 7 - {{ .Content }} 8 - </div> 4 + <div class="grid grid-cols-10"> 5 + <header class="col-span-full md:col-span-10 px-6 py-2 mb-4"> 6 + <h1 class="text-2xl font-bold dark:text-white mb-1">Terms of Service</h1> 7 + <p class="text-gray-600 dark:text-gray-400 mb-1"> 8 + A few things you should know. 9 + </p> 10 + </header> 11 + 12 + <main class="col-span-full md:col-span-10 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10"> 13 + <div class="prose prose-gray dark:prose-invert max-w-none"> 14 + {{ .Content }} 9 15 </div> 16 + </main> 10 17 </div> 11 - {{ end }} 18 + {{ end }}
+11
appview/pages/templates/notifications/fragments/bell.html
··· 1 + {{define "notifications/fragments/bell"}} 2 + <div class="relative" 3 + hx-get="/notifications/count" 4 + hx-target="#notification-count" 5 + hx-trigger="load, every 30s"> 6 + <a href="/notifications" class="text-gray-500 dark:text-gray-400 flex gap-1 items-end group"> 7 + {{ i "bell" "w-5 h-5" }} 8 + <span id="notification-count"></span> 9 + </a> 10 + </div> 11 + {{end}}
+7
appview/pages/templates/notifications/fragments/count.html
··· 1 + {{define "notifications/fragments/count"}} 2 + {{if and .Count (gt .Count 0)}} 3 + <span class="absolute -top-1.5 -right-0.5 min-w-[16px] h-[16px] px-1 bg-red-500 text-white text-xs font-medium rounded-full flex items-center justify-center"> 4 + {{if gt .Count 99}}99+{{else}}{{.Count}}{{end}} 5 + </span> 6 + {{end}} 7 + {{end}}
+81
appview/pages/templates/notifications/fragments/item.html
··· 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 }}
+65
appview/pages/templates/notifications/list.html
··· 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" }} 9 + preferences 10 + </a> 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" }} 26 + </div> 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 }}
+7
appview/pages/templates/repo/fork.html
··· 6 6 </div> 7 7 <div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded"> 8 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 + 9 16 <fieldset class="space-y-3"> 10 17 <legend class="dark:text-white">Select a knot to fork into</legend> 11 18 <div class="space-y-2">
+1 -1
appview/pages/templates/repo/fragments/cloneDropdown.html
··· 1 1 {{ define "repo/fragments/cloneDropdown" }} 2 2 {{ $knot := .RepoInfo.Knot }} 3 3 {{ if eq $knot "knot1.tangled.sh" }} 4 - {{ $knot = "tangled.sh" }} 4 + {{ $knot = "tangled.org" }} 5 5 {{ end }} 6 6 7 7 <details id="clone-dropdown" class="relative inline-block text-left group">
+1 -1
appview/pages/templates/repo/fragments/labelPanel.html
··· 1 1 {{ define "repo/fragments/labelPanel" }} 2 - <div id="label-panel" class="flex flex-col gap-6"> 2 + <div id="label-panel" class="flex flex-col gap-6 px-6 md:px-0"> 3 3 {{ template "basicLabels" . }} 4 4 {{ template "kvLabels" . }} 5 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 }}
+2 -2
appview/pages/templates/repo/fragments/readme.html
··· 1 1 {{ define "repo/fragments/readme" }} 2 2 <div class="mt-4 rounded bg-white dark:bg-gray-800 drop-shadow-sm w-full mx-auto overflow-hidden"> 3 3 {{- if .ReadmeFileName -}} 4 - <div class="px-4 py-2 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600 flex items-center gap-2"> 4 + <div class="px-4 py-2 border-b border-gray-200 dark:border-gray-600 flex items-center gap-2"> 5 5 {{ i "file-text" "w-4 h-4" "text-gray-600 dark:text-gray-400" }} 6 6 <span class="font-mono text-sm text-gray-800 dark:text-gray-200">{{ .ReadmeFileName }}</span> 7 7 </div> 8 8 {{- end -}} 9 9 <section 10 - class="p-6 overflow-auto {{ if not .Raw }} 10 + class="px-6 pb-6 overflow-auto {{ if not .Raw }} 11 11 prose dark:prose-invert dark:[&_pre]:bg-gray-900 12 12 dark:[&_code]:text-gray-300 dark:[&_pre_code]:bg-gray-900 13 13 dark:[&_pre]:border dark:[&_pre]:border-gray-700
+63
appview/pages/templates/repo/issues/fragments/globalIssueListing.html
··· 1 + {{ define "repo/issues/fragments/globalIssueListing" }} 2 + <div class="flex flex-col gap-2"> 3 + {{ range .Issues }} 4 + <div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700"> 5 + <div class="pb-2 mb-3"> 6 + <div class="flex items-center gap-3 mb-2"> 7 + <a 8 + href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}" 9 + class="text-blue-600 dark:text-blue-400 font-medium hover:underline text-sm" 10 + > 11 + {{ resolve .Repo.Did }}/{{ .Repo.Name }} 12 + </a> 13 + </div> 14 + <a 15 + href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}/issues/{{ .IssueId }}" 16 + class="no-underline hover:underline" 17 + > 18 + {{ .Title | description }} 19 + <span class="text-gray-500">#{{ .IssueId }}</span> 20 + </a> 21 + </div> 22 + <div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 23 + {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 24 + {{ $icon := "ban" }} 25 + {{ $state := "closed" }} 26 + {{ if .Open }} 27 + {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 28 + {{ $icon = "circle-dot" }} 29 + {{ $state = "open" }} 30 + {{ end }} 31 + 32 + <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm"> 33 + {{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }} 34 + <span class="text-white dark:text-white">{{ $state }}</span> 35 + </span> 36 + 37 + <span class="ml-1"> 38 + {{ template "user/fragments/picHandleLink" .Did }} 39 + </span> 40 + 41 + <span class="before:content-['·']"> 42 + {{ template "repo/fragments/time" .Created }} 43 + </span> 44 + 45 + <span class="before:content-['·']"> 46 + {{ $s := "s" }} 47 + {{ if eq (len .Comments) 1 }} 48 + {{ $s = "" }} 49 + {{ end }} 50 + <a href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a> 51 + </span> 52 + 53 + {{ $state := .Labels }} 54 + {{ range $k, $d := $.LabelDefs }} 55 + {{ range $v, $s := $state.GetValSet $d.AtUri.String }} 56 + {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }} 57 + {{ end }} 58 + {{ end }} 59 + </div> 60 + </div> 61 + {{ end }} 62 + </div> 63 + {{ end }}
+55
appview/pages/templates/repo/issues/fragments/issueListing.html
··· 1 + {{ define "repo/issues/fragments/issueListing" }} 2 + <div class="flex flex-col gap-2"> 3 + {{ range .Issues }} 4 + <div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700"> 5 + <div class="pb-2"> 6 + <a 7 + href="/{{ $.RepoPrefix }}/issues/{{ .IssueId }}" 8 + class="no-underline hover:underline" 9 + > 10 + {{ .Title | description }} 11 + <span class="text-gray-500">#{{ .IssueId }}</span> 12 + </a> 13 + </div> 14 + <div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 15 + {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 16 + {{ $icon := "ban" }} 17 + {{ $state := "closed" }} 18 + {{ if .Open }} 19 + {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 20 + {{ $icon = "circle-dot" }} 21 + {{ $state = "open" }} 22 + {{ end }} 23 + 24 + <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm"> 25 + {{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }} 26 + <span class="text-white dark:text-white">{{ $state }}</span> 27 + </span> 28 + 29 + <span class="ml-1"> 30 + {{ template "user/fragments/picHandleLink" .Did }} 31 + </span> 32 + 33 + <span class="before:content-['·']"> 34 + {{ template "repo/fragments/time" .Created }} 35 + </span> 36 + 37 + <span class="before:content-['·']"> 38 + {{ $s := "s" }} 39 + {{ if eq (len .Comments) 1 }} 40 + {{ $s = "" }} 41 + {{ end }} 42 + <a href="/{{ $.RepoPrefix }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a> 43 + </span> 44 + 45 + {{ $state := .Labels }} 46 + {{ range $k, $d := $.LabelDefs }} 47 + {{ range $v, $s := $state.GetValSet $d.AtUri.String }} 48 + {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }} 49 + {{ end }} 50 + {{ end }} 51 + </div> 52 + </div> 53 + {{ end }} 54 + </div> 55 + {{ end }}
+1 -27
appview/pages/templates/repo/issues/issue.html
··· 22 22 "Defs" $.LabelDefs 23 23 "Subject" $.Issue.AtUri 24 24 "State" $.Issue.Labels) }} 25 - {{ template "issueParticipants" . }} 25 + {{ template "repo/fragments/participants" $.Issue.Participants }} 26 26 </div> 27 27 </div> 28 28 {{ end }} ··· 122 122 </div> 123 123 {{ end }} 124 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 125 152 126 {{ define "repoAfter" }} 153 127 <div class="flex flex-col gap-4 mt-4">
+2 -52
appview/pages/templates/repo/issues/issues.html
··· 37 37 {{ end }} 38 38 39 39 {{ define "repoAfter" }} 40 - <div class="flex flex-col gap-2 mt-2"> 41 - {{ range .Issues }} 42 - <div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700"> 43 - <div class="pb-2"> 44 - <a 45 - href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" 46 - class="no-underline hover:underline" 47 - > 48 - {{ .Title | description }} 49 - <span class="text-gray-500">#{{ .IssueId }}</span> 50 - </a> 51 - </div> 52 - <div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 53 - {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 54 - {{ $icon := "ban" }} 55 - {{ $state := "closed" }} 56 - {{ if .Open }} 57 - {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 58 - {{ $icon = "circle-dot" }} 59 - {{ $state = "open" }} 60 - {{ end }} 61 - 62 - <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm"> 63 - {{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }} 64 - <span class="text-white dark:text-white">{{ $state }}</span> 65 - </span> 66 - 67 - <span class="ml-1"> 68 - {{ template "user/fragments/picHandleLink" .Did }} 69 - </span> 70 - 71 - <span class="before:content-['·']"> 72 - {{ template "repo/fragments/time" .Created }} 73 - </span> 74 - 75 - <span class="before:content-['·']"> 76 - {{ $s := "s" }} 77 - {{ if eq (len .Comments) 1 }} 78 - {{ $s = "" }} 79 - {{ end }} 80 - <a href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a> 81 - </span> 82 - 83 - {{ $state := .Labels }} 84 - {{ range $k, $d := $.LabelDefs }} 85 - {{ range $v, $s := $state.GetValSet $d.AtUri.String }} 86 - {{ template "labels/fragments/label" (dict "def" $d "val" $v "withPrefix" true) }} 87 - {{ end }} 88 - {{ end }} 89 - </div> 90 - </div> 91 - {{ end }} 40 + <div class="mt-2"> 41 + {{ template "repo/issues/fragments/issueListing" (dict "Issues" .Issues "RepoPrefix" .RepoInfo.FullName "LabelDefs" .LabelDefs) }} 92 42 </div> 93 43 {{ block "pagination" . }} {{ end }} 94 44 {{ end }}
+163 -61
appview/pages/templates/repo/new.html
··· 1 1 {{ define "title" }}new repo{{ end }} 2 2 3 3 {{ define "content" }} 4 - <div class="p-6"> 5 - <p class="text-xl font-bold dark:text-white">Create a new repository</p> 4 + <div class="grid grid-cols-12"> 5 + <div class="col-span-full md:col-start-3 md:col-span-8 px-6 py-2 mb-4"> 6 + <h1 class="text-xl font-bold dark:text-white mb-1">Create a new repository</h1> 7 + <p class="text-gray-600 dark:text-gray-400 mb-1"> 8 + Repositories contain a project's files and version history. All 9 + repositories are publicly accessible. 10 + </p> 11 + </div> 12 + {{ template "newRepoPanel" . }} 6 13 </div> 7 - <div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded"> 8 - <form hx-post="/repo/new" class="space-y-12" hx-swap="none" hx-indicator="#spinner"> 9 - <div class="space-y-2"> 10 - <label for="name" class="-mb-1 dark:text-white">Repository name</label> 11 - <input 12 - type="text" 13 - id="name" 14 - name="name" 15 - required 16 - class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600" 17 - /> 18 - <p class="text-sm text-gray-500 dark:text-gray-400">All repositories are publicly visible.</p> 14 + {{ end }} 19 15 20 - <label for="branch" class="dark:text-white">Default branch</label> 21 - <input 22 - type="text" 23 - id="branch" 24 - name="branch" 25 - value="main" 26 - required 27 - class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600" 28 - /> 16 + {{ define "newRepoPanel" }} 17 + <div class="col-span-full md:col-start-3 md:col-span-8 bg-white dark:bg-gray-800 drop-shadow-sm rounded p-6 md:px-10"> 18 + {{ template "newRepoForm" . }} 19 + </div> 20 + {{ end }} 29 21 30 - <label for="description" class="dark:text-white">Description</label> 31 - <input 32 - type="text" 33 - id="description" 34 - name="description" 35 - class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600" 36 - /> 22 + {{ define "newRepoForm" }} 23 + <form hx-post="/repo/new" hx-swap="none" hx-indicator="#spinner"> 24 + {{ template "step-1" . }} 25 + {{ template "step-2" . }} 26 + 27 + <div class="mt-8 flex justify-end"> 28 + <button type="submit" class="btn-create flex items-center gap-2"> 29 + {{ i "book-plus" "w-4 h-4" }} 30 + create repo 31 + <span id="spinner" class="group"> 32 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 33 + </span> 34 + </button> 37 35 </div> 36 + <div id="repo" class="error mt-2"></div> 38 37 39 - <fieldset class="space-y-3"> 40 - <legend class="dark:text-white">Select a knot</legend> 38 + </form> 39 + {{ end }} 40 + 41 + {{ define "step-1" }} 42 + <div class="flex gap-4 relative border-l border-gray-200 dark:border-gray-700 pl-6"> 43 + <div class="absolute -left-3 -top-0"> 44 + {{ template "numberCircle" 1 }} 45 + </div> 46 + 47 + <!-- Content column --> 48 + <div class="flex-1 pb-12"> 49 + <h2 class="text-lg font-semibold dark:text-white">General</h2> 50 + <div class="text-sm text-gray-500 dark:text-gray-400 mb-4">Basic repository information.</div> 51 + 41 52 <div class="space-y-2"> 42 - <div class="flex flex-col"> 43 - {{ range .Knots }} 44 - <div class="flex items-center"> 45 - <input 46 - type="radio" 47 - name="domain" 48 - value="{{ . }}" 49 - class="mr-2" 50 - id="domain-{{ . }}" 51 - /> 52 - <label for="domain-{{ . }}" class="dark:text-white">{{ . }}</label> 53 - </div> 54 - {{ else }} 55 - <p class="dark:text-white">No knots available.</p> 56 - {{ end }} 57 - </div> 53 + {{ template "name" . }} 54 + {{ template "description" . }} 58 55 </div> 59 - <p class="text-sm text-gray-500 dark:text-gray-400">A knot hosts repository data. <a href="/knots" class="underline">Learn how to register your own knot.</a></p> 60 - </fieldset> 56 + </div> 57 + </div> 58 + {{ end }} 61 59 62 - <div class="space-y-2"> 63 - <button type="submit" class="btn-create flex items-center gap-2"> 64 - {{ i "book-plus" "w-4 h-4" }} 65 - create repo 66 - <span id="spinner" class="group"> 67 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 68 - </span> 69 - </button> 70 - <div id="repo" class="error"></div> 60 + {{ define "step-2" }} 61 + <div class="flex gap-4 relative border-l border-gray-200 dark:border-gray-700 pl-6"> 62 + <div class="absolute -left-3 -top-0"> 63 + {{ template "numberCircle" 2 }} 71 64 </div> 72 - </form> 73 - </div> 65 + 66 + <div class="flex-1"> 67 + <h2 class="text-lg font-semibold dark:text-white">Configuration</h2> 68 + <div class="text-sm text-gray-500 dark:text-gray-400 mb-4">Repository settings and hosting.</div> 69 + 70 + <div class="space-y-2"> 71 + {{ template "defaultBranch" . }} 72 + {{ template "knot" . }} 73 + </div> 74 + </div> 75 + </div> 76 + {{ end }} 77 + 78 + {{ define "name" }} 79 + <!-- Repository Name with Owner --> 80 + <div> 81 + <label class="block text-sm font-bold uppercase dark:text-white mb-1"> 82 + Repository name 83 + </label> 84 + <div class="flex flex-col md:flex-row md:items-center gap-2 md:gap-0 w-full"> 85 + <div class="shrink-0 hidden md:flex items-center px-2 py-2 gap-1 text-sm text-gray-700 dark:text-gray-300 md:border md:border-r-0 md:border-gray-300 md:dark:border-gray-600 md:rounded-l md:bg-gray-50 md:dark:bg-gray-700"> 86 + {{ template "user/fragments/picHandle" .LoggedInUser.Did }} 87 + </div> 88 + <input 89 + type="text" 90 + id="name" 91 + name="name" 92 + required 93 + class="flex-1 dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded md:rounded-r md:rounded-l-none px-3 py-2" 94 + placeholder="repository-name" 95 + /> 96 + </div> 97 + <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 98 + Choose a unique, descriptive name for your repository. Use letters, numbers, and hyphens. 99 + </p> 100 + </div> 101 + {{ end }} 102 + 103 + {{ define "description" }} 104 + <!-- Description --> 105 + <div> 106 + <label for="description" class="block text-sm font-bold uppercase dark:text-white mb-1"> 107 + Description 108 + </label> 109 + <input 110 + type="text" 111 + id="description" 112 + name="description" 113 + class="w-full w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded px-3 py-2" 114 + placeholder="A brief description of your project..." 115 + /> 116 + <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 117 + Optional. A short description to help others understand what your project does. 118 + </p> 119 + </div> 120 + {{ end }} 121 + 122 + {{ define "defaultBranch" }} 123 + <!-- Default Branch --> 124 + <div> 125 + <label for="branch" class="block text-sm font-bold uppercase dark:text-white mb-1"> 126 + Default branch 127 + </label> 128 + <input 129 + type="text" 130 + id="branch" 131 + name="branch" 132 + value="main" 133 + required 134 + class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded px-3 py-2" 135 + /> 136 + <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 137 + The primary branch where development happens. Common choices are "main" or "master". 138 + </p> 139 + </div> 140 + {{ end }} 141 + 142 + {{ define "knot" }} 143 + <!-- Knot Selection --> 144 + <div> 145 + <label class="block text-sm font-bold uppercase dark:text-white mb-1"> 146 + Select a knot 147 + </label> 148 + <div class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded p-3 space-y-2"> 149 + {{ range .Knots }} 150 + <div class="flex items-center"> 151 + <input 152 + type="radio" 153 + name="domain" 154 + value="{{ . }}" 155 + class="mr-2" 156 + id="domain-{{ . }}" 157 + required 158 + /> 159 + <label for="domain-{{ . }}" class="dark:text-white lowercase">{{ . }}</label> 160 + </div> 161 + {{ else }} 162 + <p class="dark:text-white">no knots available.</p> 163 + {{ end }} 164 + </div> 165 + <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> 166 + A knot hosts repository data and handles Git operations. 167 + You can also <a href="/knots" class="underline">register your own knot</a>. 168 + </p> 169 + </div> 170 + {{ end }} 171 + 172 + {{ define "numberCircle" }} 173 + <div class="w-6 h-6 bg-gray-200 dark:bg-gray-600 rounded-full flex items-center justify-center text-sm font-medium mt-1"> 174 + {{.}} 175 + </div> 74 176 {{ end }}
+30 -12
appview/pages/templates/repo/pulls/pull.html
··· 9 9 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 10 10 {{ end }} 11 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 }} 12 30 13 31 {{ define "repoContent" }} 14 32 {{ template "repo/pulls/fragments/pullHeader" . }} ··· 39 57 {{ with $item }} 40 58 <details {{ if eq $idx $lastIdx }}open{{ end }}> 41 59 <summary id="round-#{{ .RoundNumber }}" class="list-none cursor-pointer"> 42 - <div class="flex flex-wrap gap-2 items-center"> 60 + <div class="flex flex-wrap gap-2 items-stretch"> 43 61 <!-- round number --> 44 62 <div class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-3 py-2 dark:text-white"> 45 63 <span class="flex items-center">{{ i "hash" "w-4 h-4" }}{{ .RoundNumber }}</span> 46 64 </div> 47 65 <!-- round summary --> 48 - <div class="rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400"> 66 + <div class="flex-1 rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400"> 49 67 <span class="gap-1 flex items-center"> 50 68 {{ $owner := resolve $.Pull.OwnerDid }} 51 69 {{ $re := "re" }} ··· 72 90 <span class="hidden md:inline">diff</span> 73 91 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 74 92 </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> 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> 84 101 {{ end }} 102 + <span id="interdiff-error-{{.RoundNumber}}"></span> 85 103 </div> 86 104 </summary> 87 105 ··· 146 164 147 165 <div class="md:pl-[3.5rem] flex flex-col gap-2 mt-2 relative"> 148 166 {{ 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"> 167 + <div id="comment-{{$c.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full"> 150 168 {{ if gt $cidx 0 }} 151 169 <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 152 170 {{ end }}
+7
appview/pages/templates/repo/pulls/pulls.html
··· 108 108 <span class="before:content-['·']"></span> 109 109 {{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }} 110 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 }} 111 118 </div> 112 119 </div> 113 120 {{ if .StackId }}
+36 -6
appview/pages/templates/repo/settings/general.html
··· 46 46 47 47 {{ define "defaultLabelSettings" }} 48 48 <div class="flex flex-col gap-2"> 49 - <h2 class="text-sm pb-2 uppercase font-bold">Default Labels</h2> 50 - <p class="text-gray-500 dark:text-gray-400"> 51 - Manage your issues and pulls by creating labels to categorize them. Only 52 - repository owners may configure labels. You may choose to subscribe to 53 - default labels, or create entirely custom labels. 54 - </p> 49 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 50 + <div class="col-span-1 md:col-span-2"> 51 + <h2 class="text-sm pb-2 uppercase font-bold">Default Labels</h2> 52 + <p class="text-gray-500 dark:text-gray-400"> 53 + Manage your issues and pulls by creating labels to categorize them. Only 54 + repository owners may configure labels. You may choose to subscribe to 55 + default labels, or create entirely custom labels. 56 + <p> 57 + </div> 58 + <form class="col-span-1 md:col-span-1 md:justify-self-end"> 59 + {{ $title := "Unubscribe from all labels" }} 60 + {{ $icon := "x" }} 61 + {{ $text := "unsubscribe all" }} 62 + {{ $action := "unsubscribe" }} 63 + {{ if $.ShouldSubscribeAll }} 64 + {{ $title = "Subscribe to all labels" }} 65 + {{ $icon = "check-check" }} 66 + {{ $text = "subscribe all" }} 67 + {{ $action = "subscribe" }} 68 + {{ end }} 69 + {{ range .DefaultLabels }} 70 + <input type="hidden" name="label" value="{{ .AtUri.String }}"> 71 + {{ end }} 72 + <button 73 + type="submit" 74 + title="{{$title}}" 75 + class="btn flex items-center gap-2 group" 76 + hx-swap="none" 77 + hx-post="/{{ $.RepoInfo.FullName }}/settings/label/{{$action}}" 78 + {{ if not .RepoInfo.Roles.IsOwner }}disabled{{ end }}> 79 + {{ i $icon "size-4" }} 80 + {{ $text }} 81 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 82 + </button> 83 + </form> 84 + </div> 55 85 <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full"> 56 86 {{ range .DefaultLabels }} 57 87 <div id="label-{{.Id}}" class="flex items-center justify-between p-2 pl-4">
+1 -1
appview/pages/templates/repo/tree.html
··· 91 91 92 92 {{ define "repoAfter" }} 93 93 {{- if or .HTMLReadme .Readme -}} 94 - {{ template "repo/fragments/readme" . }} 94 + {{ template "repo/fragments/readme" . }} 95 95 {{- end -}} 96 96 {{ end }}
+2 -2
appview/pages/templates/strings/put.html
··· 3 3 {{ define "content" }} 4 4 <div class="px-6 py-2 mb-4"> 5 5 {{ if eq .Action "new" }} 6 - <p class="text-xl font-bold dark:text-white">Create a new string</p> 7 - <p class="">Store and share code snippets with ease.</p> 6 + <p class="text-xl font-bold dark:text-white mb-1">Create a new string</p> 7 + <p class="text-gray-600 dark:text-gray-400 mb-1">Store and share code snippets with ease.</p> 8 8 {{ else }} 9 9 <p class="text-xl font-bold dark:text-white">Edit string</p> 10 10 {{ end }}
+5 -7
appview/pages/templates/strings/timeline.html
··· 26 26 {{ end }} 27 27 28 28 {{ define "stringCard" }} 29 + {{ $resolved := resolve .Did.String }} 29 30 <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800"> 30 - <div class="font-medium dark:text-white flex gap-2 items-center"> 31 - <a href="/strings/{{ resolve .Did.String }}/{{ .Rkey }}">{{ .Filename }}</a> 31 + <div class="font-medium dark:text-white flex flex-wrap gap-1 items-center"> 32 + <a href="/strings/{{ $resolved }}" class="flex gap-1 items-center">{{ template "user/fragments/picHandle" $resolved }}</a> 33 + <span class="select-none">/</span> 34 + <a href="/strings/{{ $resolved }}/{{ .Rkey }}">{{ .Filename }}</a> 32 35 </div> 33 36 {{ with .Description }} 34 37 <div class="text-gray-600 dark:text-gray-300 text-sm"> ··· 42 45 43 46 {{ define "stringCardInfo" }} 44 47 {{ $stat := .Stats }} 45 - {{ $resolved := resolve .Did.String }} 46 48 <div class="text-gray-400 pt-4 text-sm font-mono inline-flex items-center gap-2 mt-auto"> 47 - <a href="/strings/{{ $resolved }}" class="flex items-center"> 48 - {{ template "user/fragments/picHandle" $resolved }} 49 - </a> 50 - <span class="select-none [&:before]:content-['·']"></span> 51 49 <span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span> 52 50 <span class="select-none [&:before]:content-['·']"></span> 53 51 {{ with .Edited }}
+30
appview/pages/templates/timeline/fragments/goodfirstissues.html
··· 1 + {{ define "timeline/fragments/goodfirstissues" }} 2 + {{ if .GfiLabel }} 3 + <a href="/goodfirstissues" class="no-underline hover:no-underline"> 4 + <div class="flex items-center justify-between gap-2 bg-purple-200 dark:bg-purple-900 border border-purple-400 dark:border-purple-500 rounded mb-4 py-4 px-6 "> 5 + <div class="flex-1 flex flex-col gap-2"> 6 + <div class="text-purple-500 dark:text-purple-400">Oct 2025</div> 7 + <p> 8 + Make your first contribution to an open-source project this October. 9 + <em>good-first-issue</em> helps new contributors find easy ways to 10 + start contributing to open-source projects. 11 + </p> 12 + <span class="flex items-center gap-2 text-purple-500 dark:text-purple-400"> 13 + Browse issues {{ i "arrow-right" "size-4" }} 14 + </span> 15 + </div> 16 + <div class="hidden md:block relative px-16 scale-150"> 17 + <div class="relative opacity-60"> 18 + {{ template "labels/fragments/label" (dict "def" .GfiLabel "val" "" "withPrefix" true) }} 19 + </div> 20 + <div class="relative -mt-4 ml-2 opacity-80"> 21 + {{ template "labels/fragments/label" (dict "def" .GfiLabel "val" "" "withPrefix" true) }} 22 + </div> 23 + <div class="relative -mt-4 ml-4"> 24 + {{ template "labels/fragments/label" (dict "def" .GfiLabel "val" "" "withPrefix" true) }} 25 + </div> 26 + </div> 27 + </div> 28 + </a> 29 + {{ end }} 30 + {{ end }}
+10 -33
appview/pages/templates/timeline/fragments/timeline.html
··· 82 82 {{ $event := index . 1 }} 83 83 {{ $follow := $event.Follow }} 84 84 {{ $profile := $event.Profile }} 85 - {{ $stat := $event.FollowStats }} 85 + {{ $followStats := $event.FollowStats }} 86 + {{ $followStatus := $event.FollowStatus }} 86 87 87 88 {{ $userHandle := resolve $follow.UserDid }} 88 89 {{ $subjectHandle := resolve $follow.SubjectDid }} ··· 92 93 {{ template "user/fragments/picHandleLink" $subjectHandle }} 93 94 <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span> 94 95 </div> 95 - <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex flex-col md:flex-row md:items-center gap-4"> 96 - <div class="flex items-center gap-4 flex-1"> 97 - <div class="flex-shrink-0 max-h-full w-24 h-24"> 98 - <img alt="" class="object-cover rounded-full p-2" src="{{ fullAvatar $subjectHandle }}" /> 99 - </div> 100 - 101 - <div class="flex-1 min-h-0 justify-around flex flex-col"> 102 - <a href="/{{ $subjectHandle }}"> 103 - <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $subjectHandle | truncateAt30 }}</span> 104 - </a> 105 - {{ with $profile }} 106 - {{ with .Description }} 107 - <p class="text-sm pb-2 md:pb-2">{{.}}</p> 108 - {{ end }} 109 - {{ end }} 110 - {{ with $stat }} 111 - <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 112 - <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 113 - <span id="followers"><a href="/{{ $subjectHandle }}?tab=followers">{{ .Followers }} followers</a></span> 114 - <span class="select-none after:content-['·']"></span> 115 - <span id="following"><a href="/{{ $subjectHandle }}?tab=following">{{ .Following }} following</a></span> 116 - </div> 117 - {{ end }} 118 - </div> 119 - </div> 120 - 121 - {{ if and $root.LoggedInUser (ne $event.FollowStatus.String "IsSelf") }} 122 - <div class="flex-shrink-0 w-fit ml-auto"> 123 - {{ template "user/fragments/follow" (dict "UserDid" $follow.SubjectDid "FollowStatus" $event.FollowStatus) }} 124 - </div> 125 - {{ end }} 126 - </div> 96 + {{ template "user/fragments/followCard" 97 + (dict 98 + "LoggedInUser" $root.LoggedInUser 99 + "UserDid" $follow.SubjectDid 100 + "Profile" $profile 101 + "FollowStatus" $followStatus 102 + "FollowersCount" $followStats.Followers 103 + "FollowingCount" $followStats.Following) }} 127 104 {{ end }}
+1
appview/pages/templates/timeline/home.html
··· 12 12 <div class="flex flex-col gap-4"> 13 13 {{ template "timeline/fragments/hero" . }} 14 14 {{ template "features" . }} 15 + {{ template "timeline/fragments/goodfirstissues" . }} 15 16 {{ template "timeline/fragments/trending" . }} 16 17 {{ template "timeline/fragments/timeline" . }} 17 18 <div class="flex justify-end">
+1
appview/pages/templates/timeline/timeline.html
··· 13 13 {{ template "timeline/fragments/hero" . }} 14 14 {{ end }} 15 15 16 + {{ template "timeline/fragments/goodfirstissues" . }} 16 17 {{ template "timeline/fragments/trending" . }} 17 18 {{ template "timeline/fragments/timeline" . }} 18 19 {{ end }}
+1
appview/pages/templates/user/completeSignup.html
··· 20 20 content="complete your signup for tangled" 21 21 /> 22 22 <script src="/static/htmx.min.js"></script> 23 + <link rel="manifest" href="/pwa-manifest.json" /> 23 24 <link 24 25 rel="stylesheet" 25 26 href="/static/tw.css?{{ cssContentHash }}"
+8 -1
appview/pages/templates/user/followers.html
··· 10 10 <p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWERS</p> 11 11 <div id="followers" class="grid grid-cols-1 gap-4 mb-6"> 12 12 {{ range .Followers }} 13 - {{ template "user/fragments/followCard" . }} 13 + {{ template "user/fragments/followCard" 14 + (dict 15 + "LoggedInUser" $.LoggedInUser 16 + "UserDid" .UserDid 17 + "Profile" .Profile 18 + "FollowStatus" .FollowStatus 19 + "FollowersCount" .FollowersCount 20 + "FollowingCount" .FollowingCount) }} 14 21 {{ else }} 15 22 <p class="px-6 dark:text-white">This user does not have any followers yet.</p> 16 23 {{ end }}
+8 -1
appview/pages/templates/user/following.html
··· 10 10 <p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWING</p> 11 11 <div id="following" class="grid grid-cols-1 gap-4 mb-6"> 12 12 {{ range .Following }} 13 - {{ template "user/fragments/followCard" . }} 13 + {{ template "user/fragments/followCard" 14 + (dict 15 + "LoggedInUser" $.LoggedInUser 16 + "UserDid" .UserDid 17 + "Profile" .Profile 18 + "FollowStatus" .FollowStatus 19 + "FollowersCount" .FollowersCount 20 + "FollowingCount" .FollowingCount) }} 14 21 {{ else }} 15 22 <p class="px-6 dark:text-white">This user does not follow anyone yet.</p> 16 23 {{ end }}
+6 -2
appview/pages/templates/user/fragments/follow.html
··· 1 1 {{ define "user/fragments/follow" }} 2 2 <button id="{{ normalizeForHtmlId .UserDid }}" 3 - class="btn mt-2 flex gap-2 items-center group" 3 + class="btn w-full flex gap-2 items-center group" 4 4 5 5 {{ if eq .FollowStatus.String "IsNotFollowing" }} 6 6 hx-post="/follow?subject={{.UserDid}}" ··· 12 12 hx-target="#{{ normalizeForHtmlId .UserDid }}" 13 13 hx-swap="outerHTML" 14 14 > 15 - {{ if eq .FollowStatus.String "IsNotFollowing" }}{{ i "user-round-plus" "w-4 h-4" }} follow{{ else }}{{ i "user-round-minus" "w-4 h-4" }} unfollow{{ end }} 15 + {{ if eq .FollowStatus.String "IsNotFollowing" }} 16 + {{ i "user-round-plus" "w-4 h-4" }} follow 17 + {{ else }} 18 + {{ i "user-round-minus" "w-4 h-4" }} unfollow 19 + {{ end }} 16 20 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 17 21 </button> 18 22 {{ end }}
+20 -17
appview/pages/templates/user/fragments/followCard.html
··· 1 1 {{ define "user/fragments/followCard" }} 2 2 {{ $userIdent := resolve .UserDid }} 3 - <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm"> 3 + <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 rounded-sm"> 4 4 <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4"> 5 5 <div class="flex-shrink-0 max-h-full w-24 h-24"> 6 6 <img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" /> 7 7 </div> 8 8 9 - <div class="flex-1 min-h-0 justify-around flex flex-col"> 10 - <a href="/{{ $userIdent }}"> 11 - <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $userIdent | truncateAt30 }}</span> 12 - </a> 13 - <p class="text-sm pb-2 md:pb-2">{{.Profile.Description}}</p> 14 - <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 15 - <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 16 - <span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .FollowersCount }} followers</a></span> 17 - <span class="select-none after:content-['·']"></span> 18 - <span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .FollowingCount }} following</a></span> 9 + <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2 w-full"> 10 + <div class="flex-1 min-h-0 justify-around flex flex-col"> 11 + <a href="/{{ $userIdent }}"> 12 + <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $userIdent | truncateAt30 }}</span> 13 + </a> 14 + {{ with .Profile }} 15 + <p class="text-sm pb-2 md:pb-2">{{.Description}}</p> 16 + {{ end }} 17 + <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 18 + <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 19 + <span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .FollowersCount }} followers</a></span> 20 + <span class="select-none after:content-['·']"></span> 21 + <span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .FollowingCount }} following</a></span> 22 + </div> 19 23 </div> 20 - </div> 21 - 22 - {{ if ne .FollowStatus.String "IsSelf" }} 23 - <div class="max-w-24"> 24 + {{ if and .LoggedInUser (ne .FollowStatus.String "IsSelf") }} 25 + <div class="w-full md:w-auto md:max-w-24 order-last md:order-none"> 24 26 {{ template "user/fragments/follow" . }} 25 27 </div> 26 - {{ end }} 28 + {{ end }} 29 + </div> 27 30 </div> 28 31 </div> 29 - {{ end }} 32 + {{ end }}
+2 -2
appview/pages/templates/user/fragments/picHandle.html
··· 2 2 <img 3 3 src="{{ tinyAvatar . }}" 4 4 alt="" 5 - class="rounded-full h-6 w-6 mr-1 border border-gray-300 dark:border-gray-700" 5 + class="rounded-full h-6 w-6 border border-gray-300 dark:border-gray-700" 6 6 /> 7 - {{ . | truncateAt30 }} 7 + {{ . | resolve | truncateAt30 }} 8 8 {{ end }}
+2 -3
appview/pages/templates/user/fragments/picHandleLink.html
··· 1 1 {{ define "user/fragments/picHandleLink" }} 2 - {{ $resolved := resolve . }} 3 - <a href="/{{ $resolved }}" class="flex items-center"> 4 - {{ template "user/fragments/picHandle" $resolved }} 2 + <a href="/{{ resolve . }}" class="flex items-center gap-1"> 3 + {{ template "user/fragments/picHandle" . }} 5 4 </a> 6 5 {{ end }}
+10 -10
appview/pages/templates/user/fragments/repoCard.html
··· 14 14 {{ with $repo }} 15 15 <div class="py-4 px-6 gap-1 flex flex-col drop-shadow-sm rounded bg-white dark:bg-gray-800 min-h-32"> 16 16 <div class="font-medium dark:text-white flex items-center justify-between"> 17 - <div class="flex items-center"> 18 - {{ if .Source }} 19 - {{ i "git-fork" "w-4 h-4 mr-1.5 shrink-0" }} 20 - {{ else }} 21 - {{ i "book-marked" "w-4 h-4 mr-1.5 shrink-0" }} 22 - {{ end }} 23 - 17 + <div class="flex items-center min-w-0 flex-1 mr-2"> 18 + {{ if .Source }} 19 + {{ i "git-fork" "w-4 h-4 mr-1.5 shrink-0" }} 20 + {{ else }} 21 + {{ i "book-marked" "w-4 h-4 mr-1.5 shrink-0" }} 22 + {{ end }} 24 23 {{ $repoOwner := resolve .Did }} 25 24 {{- if $fullName -}} 26 - <a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate">{{ $repoOwner }}/{{ .Name }}</a> 25 + <a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate min-w-0">{{ $repoOwner }}/{{ .Name }}</a> 27 26 {{- else -}} 28 - <a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate">{{ .Name }}</a> 27 + <a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate min-w-0">{{ .Name }}</a> 29 28 {{- end -}} 30 29 </div> 31 - 32 30 {{ if and $starButton $root.LoggedInUser }} 31 + <div class="shrink-0"> 33 32 {{ template "repo/fragments/repoStar" $starData }} 33 + </div> 34 34 {{ end }} 35 35 </div> 36 36 {{ with .Description }}
+2 -1
appview/pages/templates/user/login.html
··· 8 8 <meta property="og:url" content="https://tangled.org/login" /> 9 9 <meta property="og:description" content="login to for tangled" /> 10 10 <script src="/static/htmx.min.js"></script> 11 + <link rel="manifest" href="/pwa-manifest.json" /> 11 12 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 12 13 <title>login &middot; tangled</title> 13 14 </head> ··· 36 37 placeholder="akshay.tngl.sh" 37 38 /> 38 39 <span class="text-sm text-gray-500 mt-1"> 39 - Use your <a href="https://atproto.com">ATProto</a> 40 + Use your <a href="https://atproto.com">AT Protocol</a> 40 41 handle to log in. If you're unsure, this is likely 41 42 your Tangled (<code>.tngl.sh</code>) or <a href="https://bsky.app">Bluesky</a> (<code>.bsky.social</code>) account. 42 43 </span>
+173
appview/pages/templates/user/settings/notifications.html
··· 1 + {{ define "title" }}{{ .Tab }} settings{{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="p-6"> 5 + <p class="text-xl font-bold dark:text-white">Settings</p> 6 + </div> 7 + <div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 8 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6"> 9 + <div class="col-span-1"> 10 + {{ template "user/settings/fragments/sidebar" . }} 11 + </div> 12 + <div class="col-span-1 md:col-span-3 flex flex-col gap-6"> 13 + {{ template "notificationSettings" . }} 14 + </div> 15 + </section> 16 + </div> 17 + {{ end }} 18 + 19 + {{ define "notificationSettings" }} 20 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 21 + <div class="col-span-1 md:col-span-2"> 22 + <h2 class="text-sm pb-2 uppercase font-bold">Notification Preferences</h2> 23 + <p class="text-gray-500 dark:text-gray-400"> 24 + Choose which notifications you want to receive when activity happens on your repositories and profile. 25 + </p> 26 + </div> 27 + </div> 28 + 29 + <form hx-put="/settings/notifications" hx-swap="none" class="flex flex-col gap-6"> 30 + 31 + <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full"> 32 + <div class="flex items-center justify-between p-2"> 33 + <div class="flex items-center gap-2"> 34 + <div class="flex flex-col gap-1"> 35 + <span class="font-bold">Repository starred</span> 36 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 37 + <span>When someone stars your repository.</span> 38 + </div> 39 + </div> 40 + </div> 41 + <label class="flex items-center gap-2"> 42 + <input type="checkbox" name="repo_starred" {{if .Preferences.RepoStarred}}checked{{end}}> 43 + </label> 44 + </div> 45 + 46 + <div class="flex items-center justify-between p-2"> 47 + <div class="flex items-center gap-2"> 48 + <div class="flex flex-col gap-1"> 49 + <span class="font-bold">New issues</span> 50 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 51 + <span>When someone creates an issue on your repository.</span> 52 + </div> 53 + </div> 54 + </div> 55 + <label class="flex items-center gap-2"> 56 + <input type="checkbox" name="issue_created" {{if .Preferences.IssueCreated}}checked{{end}}> 57 + </label> 58 + </div> 59 + 60 + <div class="flex items-center justify-between p-2"> 61 + <div class="flex items-center gap-2"> 62 + <div class="flex flex-col gap-1"> 63 + <span class="font-bold">Issue comments</span> 64 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 65 + <span>When someone comments on an issue you're involved with.</span> 66 + </div> 67 + </div> 68 + </div> 69 + <label class="flex items-center gap-2"> 70 + <input type="checkbox" name="issue_commented" {{if .Preferences.IssueCommented}}checked{{end}}> 71 + </label> 72 + </div> 73 + 74 + <div class="flex items-center justify-between p-2"> 75 + <div class="flex items-center gap-2"> 76 + <div class="flex flex-col gap-1"> 77 + <span class="font-bold">Issue closed</span> 78 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 79 + <span>When an issue on your repository is closed.</span> 80 + </div> 81 + </div> 82 + </div> 83 + <label class="flex items-center gap-2"> 84 + <input type="checkbox" name="issue_closed" {{if .Preferences.IssueClosed}}checked{{end}}> 85 + </label> 86 + </div> 87 + 88 + <div class="flex items-center justify-between p-2"> 89 + <div class="flex items-center gap-2"> 90 + <div class="flex flex-col gap-1"> 91 + <span class="font-bold">New pull requests</span> 92 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 93 + <span>When someone creates a pull request on your repository.</span> 94 + </div> 95 + </div> 96 + </div> 97 + <label class="flex items-center gap-2"> 98 + <input type="checkbox" name="pull_created" {{if .Preferences.PullCreated}}checked{{end}}> 99 + </label> 100 + </div> 101 + 102 + <div class="flex items-center justify-between p-2"> 103 + <div class="flex items-center gap-2"> 104 + <div class="flex flex-col gap-1"> 105 + <span class="font-bold">Pull request comments</span> 106 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 107 + <span>When someone comments on a pull request you're involved with.</span> 108 + </div> 109 + </div> 110 + </div> 111 + <label class="flex items-center gap-2"> 112 + <input type="checkbox" name="pull_commented" {{if .Preferences.PullCommented}}checked{{end}}> 113 + </label> 114 + </div> 115 + 116 + <div class="flex items-center justify-between p-2"> 117 + <div class="flex items-center gap-2"> 118 + <div class="flex flex-col gap-1"> 119 + <span class="font-bold">Pull request merged</span> 120 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 121 + <span>When your pull request is merged.</span> 122 + </div> 123 + </div> 124 + </div> 125 + <label class="flex items-center gap-2"> 126 + <input type="checkbox" name="pull_merged" {{if .Preferences.PullMerged}}checked{{end}}> 127 + </label> 128 + </div> 129 + 130 + <div class="flex items-center justify-between p-2"> 131 + <div class="flex items-center gap-2"> 132 + <div class="flex flex-col gap-1"> 133 + <span class="font-bold">New followers</span> 134 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 135 + <span>When someone follows you.</span> 136 + </div> 137 + </div> 138 + </div> 139 + <label class="flex items-center gap-2"> 140 + <input type="checkbox" name="followed" {{if .Preferences.Followed}}checked{{end}}> 141 + </label> 142 + </div> 143 + 144 + <div class="flex items-center justify-between p-2"> 145 + <div class="flex items-center gap-2"> 146 + <div class="flex flex-col gap-1"> 147 + <span class="font-bold">Email notifications</span> 148 + <div class="flex text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 149 + <span>Receive notifications via email in addition to in-app notifications.</span> 150 + </div> 151 + </div> 152 + </div> 153 + <label class="flex items-center gap-2"> 154 + <input type="checkbox" name="email_notifications" {{if .Preferences.EmailNotifications}}checked{{end}}> 155 + </label> 156 + </div> 157 + </div> 158 + 159 + <div class="flex justify-end pt-2"> 160 + <button 161 + type="submit" 162 + class="btn-create flex items-center gap-2 group" 163 + > 164 + {{ i "save" "w-4 h-4" }} 165 + save 166 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 167 + </button> 168 + </div> 169 + <div id="settings-notifications-success"></div> 170 + 171 + <div id="settings-notifications-error" class="error"></div> 172 + </form> 173 + {{ end }}
+7 -1
appview/pages/templates/user/signup.html
··· 8 8 <meta property="og:url" content="https://tangled.org/signup" /> 9 9 <meta property="og:description" content="sign up for tangled" /> 10 10 <script src="/static/htmx.min.js"></script> 11 + <link rel="manifest" href="/pwa-manifest.json" /> 11 12 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 12 13 <title>sign up &middot; tangled</title> 14 + 15 + <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script> 13 16 </head> 14 17 <body class="flex items-center justify-center min-h-screen"> 15 18 <main class="max-w-md px-6 -mt-4"> ··· 39 42 invite code, desired username, and password in the next 40 43 page to complete your registration. 41 44 </span> 45 + <div class="w-full mt-4 text-center"> 46 + <div class="cf-turnstile" data-sitekey="{{ .CloudflareSiteKey }}"></div> 47 + </div> 42 48 <button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" > 43 49 <span>join now</span> 44 50 </button> 45 51 </form> 46 52 <p class="text-sm text-gray-500"> 47 - Already have an ATProto account? <a href="/login" class="underline">Login to Tangled</a>. 53 + Already have an AT Protocol account? <a href="/login" class="underline">Login to Tangled</a>. 48 54 </p> 49 55 50 56 <p id="signup-msg" class="error w-full"></p>
+1 -1
appview/pagination/page.go
··· 8 8 func FirstPage() Page { 9 9 return Page{ 10 10 Offset: 0, 11 - Limit: 10, 11 + Limit: 30, 12 12 } 13 13 } 14 14
-164
appview/posthog/notifier.go
··· 1 - package posthog_service 2 - 3 - import ( 4 - "context" 5 - "log" 6 - 7 - "github.com/posthog/posthog-go" 8 - "tangled.org/core/appview/models" 9 - "tangled.org/core/appview/notify" 10 - ) 11 - 12 - type posthogNotifier struct { 13 - client posthog.Client 14 - notify.BaseNotifier 15 - } 16 - 17 - func NewPosthogNotifier(client posthog.Client) notify.Notifier { 18 - return &posthogNotifier{ 19 - client, 20 - notify.BaseNotifier{}, 21 - } 22 - } 23 - 24 - var _ notify.Notifier = &posthogNotifier{} 25 - 26 - func (n *posthogNotifier) NewRepo(ctx context.Context, repo *models.Repo) { 27 - err := n.client.Enqueue(posthog.Capture{ 28 - DistinctId: repo.Did, 29 - Event: "new_repo", 30 - Properties: posthog.Properties{"repo": repo.Name, "repo_at": repo.RepoAt()}, 31 - }) 32 - if err != nil { 33 - log.Println("failed to enqueue posthog event:", err) 34 - } 35 - } 36 - 37 - func (n *posthogNotifier) NewStar(ctx context.Context, star *models.Star) { 38 - err := n.client.Enqueue(posthog.Capture{ 39 - DistinctId: star.StarredByDid, 40 - Event: "star", 41 - Properties: posthog.Properties{"repo_at": star.RepoAt.String()}, 42 - }) 43 - if err != nil { 44 - log.Println("failed to enqueue posthog event:", err) 45 - } 46 - } 47 - 48 - func (n *posthogNotifier) DeleteStar(ctx context.Context, star *models.Star) { 49 - err := n.client.Enqueue(posthog.Capture{ 50 - DistinctId: star.StarredByDid, 51 - Event: "unstar", 52 - Properties: posthog.Properties{"repo_at": star.RepoAt.String()}, 53 - }) 54 - if err != nil { 55 - log.Println("failed to enqueue posthog event:", err) 56 - } 57 - } 58 - 59 - func (n *posthogNotifier) NewIssue(ctx context.Context, issue *models.Issue) { 60 - err := n.client.Enqueue(posthog.Capture{ 61 - DistinctId: issue.Did, 62 - Event: "new_issue", 63 - Properties: posthog.Properties{ 64 - "repo_at": issue.RepoAt.String(), 65 - "issue_id": issue.IssueId, 66 - }, 67 - }) 68 - if err != nil { 69 - log.Println("failed to enqueue posthog event:", err) 70 - } 71 - } 72 - 73 - func (n *posthogNotifier) NewPull(ctx context.Context, pull *models.Pull) { 74 - err := n.client.Enqueue(posthog.Capture{ 75 - DistinctId: pull.OwnerDid, 76 - Event: "new_pull", 77 - Properties: posthog.Properties{ 78 - "repo_at": pull.RepoAt, 79 - "pull_id": pull.PullId, 80 - }, 81 - }) 82 - if err != nil { 83 - log.Println("failed to enqueue posthog event:", err) 84 - } 85 - } 86 - 87 - func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *models.PullComment) { 88 - err := n.client.Enqueue(posthog.Capture{ 89 - DistinctId: comment.OwnerDid, 90 - Event: "new_pull_comment", 91 - Properties: posthog.Properties{ 92 - "repo_at": comment.RepoAt, 93 - "pull_id": comment.PullId, 94 - }, 95 - }) 96 - if err != nil { 97 - log.Println("failed to enqueue posthog event:", err) 98 - } 99 - } 100 - 101 - func (n *posthogNotifier) NewFollow(ctx context.Context, follow *models.Follow) { 102 - err := n.client.Enqueue(posthog.Capture{ 103 - DistinctId: follow.UserDid, 104 - Event: "follow", 105 - Properties: posthog.Properties{"subject": follow.SubjectDid}, 106 - }) 107 - if err != nil { 108 - log.Println("failed to enqueue posthog event:", err) 109 - } 110 - } 111 - 112 - func (n *posthogNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) { 113 - err := n.client.Enqueue(posthog.Capture{ 114 - DistinctId: follow.UserDid, 115 - Event: "unfollow", 116 - Properties: posthog.Properties{"subject": follow.SubjectDid}, 117 - }) 118 - if err != nil { 119 - log.Println("failed to enqueue posthog event:", err) 120 - } 121 - } 122 - 123 - func (n *posthogNotifier) UpdateProfile(ctx context.Context, profile *models.Profile) { 124 - err := n.client.Enqueue(posthog.Capture{ 125 - DistinctId: profile.Did, 126 - Event: "edit_profile", 127 - }) 128 - if err != nil { 129 - log.Println("failed to enqueue posthog event:", err) 130 - } 131 - } 132 - 133 - func (n *posthogNotifier) DeleteString(ctx context.Context, did, rkey string) { 134 - err := n.client.Enqueue(posthog.Capture{ 135 - DistinctId: did, 136 - Event: "delete_string", 137 - Properties: posthog.Properties{"rkey": rkey}, 138 - }) 139 - if err != nil { 140 - log.Println("failed to enqueue posthog event:", err) 141 - } 142 - } 143 - 144 - func (n *posthogNotifier) EditString(ctx context.Context, string *models.String) { 145 - err := n.client.Enqueue(posthog.Capture{ 146 - DistinctId: string.Did.String(), 147 - Event: "edit_string", 148 - Properties: posthog.Properties{"rkey": string.Rkey}, 149 - }) 150 - if err != nil { 151 - log.Println("failed to enqueue posthog event:", err) 152 - } 153 - } 154 - 155 - func (n *posthogNotifier) CreateString(ctx context.Context, string models.String) { 156 - err := n.client.Enqueue(posthog.Capture{ 157 - DistinctId: string.Did.String(), 158 - Event: "create_string", 159 - Properties: posthog.Properties{"rkey": string.Rkey}, 160 - }) 161 - if err != nil { 162 - log.Println("failed to enqueue posthog event:", err) 163 - } 164 - }
+51 -3
appview/pulls/pulls.go
··· 200 200 userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.PullAt()) 201 201 } 202 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 + 203 219 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 204 220 LoggedInUser: user, 205 221 RepoInfo: repoInfo, ··· 213 229 OrderedReactionKinds: models.OrderedReactionKinds, 214 230 Reactions: reactionCountMap, 215 231 UserReacted: userReactions, 232 + 233 + LabelDefs: defs, 216 234 }) 217 235 } 218 236 ··· 557 575 m[p.Sha] = p 558 576 } 559 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 + 560 594 s.pages.RepoPulls(w, pages.RepoPullsParams{ 561 595 LoggedInUser: s.oauth.GetUser(r), 562 596 RepoInfo: f.RepoInfo(user), 563 597 Pulls: pulls, 598 + LabelDefs: defs, 564 599 FilteringBy: state, 565 600 Stacks: stacks, 566 601 Pipelines: m, ··· 1058 1093 1059 1094 // We've already checked earlier if it's diff-based and title is empty, 1060 1095 // so if it's still empty now, it's intentionally skipped owing to format-patch. 1061 - if title == "" { 1096 + if title == "" || body == "" { 1062 1097 formatPatches, err := patchutil.ExtractPatches(patch) 1063 1098 if err != nil { 1064 1099 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err)) ··· 1069 1104 return 1070 1105 } 1071 1106 1072 - title = formatPatches[0].Title 1073 - body = formatPatches[0].Body 1107 + if title == "" { 1108 + title = formatPatches[0].Title 1109 + } 1110 + if body == "" { 1111 + body = formatPatches[0].Body 1112 + } 1074 1113 } 1075 1114 1076 1115 rkey := tid.TID() ··· 2147 2186 return 2148 2187 } 2149 2188 2189 + // notify about the pull merge 2190 + for _, p := range pullsToMerge { 2191 + s.notifier.NewPullMerged(r.Context(), p) 2192 + } 2193 + 2150 2194 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId)) 2151 2195 } 2152 2196 ··· 2212 2256 log.Println("failed to commit transaction", err) 2213 2257 s.pages.Notice(w, "pull-close", "Failed to close pull.") 2214 2258 return 2259 + } 2260 + 2261 + for _, p := range pullsToClose { 2262 + s.notifier.NewPullClosed(r.Context(), p) 2215 2263 } 2216 2264 2217 2265 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
+40 -14
appview/repo/artifact.go
··· 4 4 "context" 5 5 "encoding/json" 6 6 "fmt" 7 + "io" 7 8 "log" 8 9 "net/http" 9 10 "net/url" ··· 134 135 }) 135 136 } 136 137 137 - // TODO: proper statuses here on early exit 138 138 func (rp *Repo) DownloadArtifact(w http.ResponseWriter, r *http.Request) { 139 - tagParam := chi.URLParam(r, "tag") 140 - filename := chi.URLParam(r, "file") 141 139 f, err := rp.repoResolver.Resolve(r) 142 140 if err != nil { 143 141 log.Println("failed to get repo and knot", err) 142 + http.Error(w, "failed to resolve repo", http.StatusInternalServerError) 144 143 return 145 144 } 145 + 146 + tagParam := chi.URLParam(r, "tag") 147 + filename := chi.URLParam(r, "file") 146 148 147 149 tag, err := rp.resolveTag(r.Context(), f, tagParam) 148 150 if err != nil { ··· 151 153 return 152 154 } 153 155 154 - client, err := rp.oauth.AuthorizedClient(r) 155 - if err != nil { 156 - log.Println("failed to get authorized client", err) 157 - return 158 - } 159 - 160 156 artifacts, err := db.GetArtifact( 161 157 rp.db, 162 158 db.FilterEq("repo_at", f.RepoAt()), ··· 165 161 ) 166 162 if err != nil { 167 163 log.Println("failed to get artifacts", err) 164 + http.Error(w, "failed to get artifact", http.StatusInternalServerError) 168 165 return 169 166 } 167 + 170 168 if len(artifacts) != 1 { 171 - log.Printf("too many or too little artifacts found") 169 + log.Printf("too many or too few artifacts found") 170 + http.Error(w, "artifact not found", http.StatusNotFound) 172 171 return 173 172 } 174 173 175 174 artifact := artifacts[0] 176 175 177 - getBlobResp, err := client.SyncGetBlob(r.Context(), artifact.BlobCid.String(), artifact.Did) 176 + ownerPds := f.OwnerId.PDSEndpoint() 177 + url, _ := url.Parse(fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob", ownerPds)) 178 + q := url.Query() 179 + q.Set("cid", artifact.BlobCid.String()) 180 + q.Set("did", artifact.Did) 181 + url.RawQuery = q.Encode() 182 + 183 + req, err := http.NewRequest(http.MethodGet, url.String(), nil) 184 + if err != nil { 185 + log.Println("failed to create request", err) 186 + http.Error(w, "failed to create request", http.StatusInternalServerError) 187 + return 188 + } 189 + req.Header.Set("Content-Type", "application/json") 190 + 191 + resp, err := http.DefaultClient.Do(req) 178 192 if err != nil { 179 - log.Println("failed to get blob from pds", err) 193 + log.Println("failed to make request", err) 194 + http.Error(w, "failed to make request to PDS", http.StatusInternalServerError) 180 195 return 181 196 } 197 + defer resp.Body.Close() 182 198 183 - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) 184 - w.Write(getBlobResp) 199 + // copy status code and relevant headers from upstream response 200 + w.WriteHeader(resp.StatusCode) 201 + for key, values := range resp.Header { 202 + for _, v := range values { 203 + w.Header().Add(key, v) 204 + } 205 + } 206 + 207 + // stream the body directly to the client 208 + if _, err := io.Copy(w, resp.Body); err != nil { 209 + log.Println("error streaming response to client:", err) 210 + } 185 211 } 186 212 187 213 // TODO: proper statuses here on early exit
+17 -22
appview/repo/index.go
··· 22 22 "tangled.org/core/appview/db" 23 23 "tangled.org/core/appview/models" 24 24 "tangled.org/core/appview/pages" 25 - "tangled.org/core/appview/pages/markup" 26 25 "tangled.org/core/appview/reporesolver" 27 26 "tangled.org/core/appview/xrpcclient" 28 27 "tangled.org/core/types" ··· 201 200 }) 202 201 } 203 202 203 + tx, err := rp.db.Begin() 204 + if err != nil { 205 + return nil, err 206 + } 207 + defer tx.Rollback() 208 + 204 209 // update appview's cache 205 - err = db.InsertRepoLanguages(rp.db, langs) 210 + err = db.UpdateRepoLanguages(tx, f.RepoAt(), currentRef, langs) 206 211 if err != nil { 207 212 // non-fatal 208 213 log.Println("failed to cache lang results", err) 214 + } 215 + 216 + err = tx.Commit() 217 + if err != nil { 218 + return nil, err 209 219 } 210 220 } 211 221 ··· 328 338 } 329 339 }() 330 340 331 - // readme content 332 - wg.Add(1) 333 - go func() { 334 - defer wg.Done() 335 - for _, filename := range markup.ReadmeFilenames { 336 - blobResp, err := tangled.RepoBlob(ctx, xrpcc, filename, false, ref, repo) 337 - if err != nil { 338 - continue 339 - } 340 - 341 - if blobResp == nil { 342 - continue 343 - } 344 - 345 - readmeContent = blobResp.Content 346 - readmeFileName = filename 347 - break 348 - } 349 - }() 350 - 351 341 wg.Wait() 352 342 353 343 if errs != nil { ··· 374 364 } 375 365 files = append(files, niceFile) 376 366 } 367 + } 368 + 369 + if treeResp != nil && treeResp.Readme != nil { 370 + readmeFileName = treeResp.Readme.Filename 371 + readmeContent = treeResp.Readme.Contents 377 372 } 378 373 379 374 result := &types.RepoIndexResponse{
+77 -51
appview/repo/repo.go
··· 449 449 return 450 450 } 451 451 452 - // readme content 453 - var ( 454 - readmeContent string 455 - readmeFileName string 456 - ) 457 - 458 - for _, filename := range markup.ReadmeFilenames { 459 - path := fmt.Sprintf("%s/%s", treePath, filename) 460 - blobResp, err := tangled.RepoBlob(r.Context(), xrpcc, path, false, ref, repo) 461 - if err != nil { 462 - continue 463 - } 464 - 465 - if blobResp == nil { 466 - continue 467 - } 468 - 469 - readmeContent = blobResp.Content 470 - readmeFileName = path 471 - break 472 - } 473 - 474 452 // Convert XRPC response to internal types.RepoTreeResponse 475 453 files := make([]types.NiceTree, len(xrpcResp.Files)) 476 454 for i, xrpcFile := range xrpcResp.Files { ··· 506 484 if xrpcResp.Dotdot != nil { 507 485 result.DotDot = *xrpcResp.Dotdot 508 486 } 487 + if xrpcResp.Readme != nil { 488 + result.ReadmeFileName = xrpcResp.Readme.Filename 489 + result.Readme = xrpcResp.Readme.Contents 490 + } 509 491 510 492 // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, 511 493 // so we can safely redirect to the "parent" (which is the same file). ··· 532 514 BreadCrumbs: breadcrumbs, 533 515 TreePath: treePath, 534 516 RepoInfo: f.RepoInfo(user), 535 - Readme: readmeContent, 536 - ReadmeFileName: readmeFileName, 537 517 RepoTreeResponse: result, 538 518 }) 539 519 } ··· 1248 1228 return 1249 1229 } 1250 1230 1231 + if err := r.ParseForm(); err != nil { 1232 + l.Error("invalid form", "err", err) 1233 + return 1234 + } 1235 + 1251 1236 errorId := "default-label-operation" 1252 1237 fail := func(msg string, err error) { 1253 1238 l.Error(msg, "err", err) 1254 1239 rp.pages.Notice(w, errorId, msg) 1255 1240 } 1256 1241 1257 - labelAt := r.FormValue("label") 1258 - _, err = db.GetLabelDefinition(rp.db, db.FilterEq("at_uri", labelAt)) 1242 + labelAts := r.Form["label"] 1243 + _, err = db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", labelAts)) 1259 1244 if err != nil { 1260 1245 fail("Failed to subscribe to label.", err) 1261 1246 return 1262 1247 } 1263 1248 1264 1249 newRepo := f.Repo 1265 - newRepo.Labels = append(newRepo.Labels, labelAt) 1250 + newRepo.Labels = append(newRepo.Labels, labelAts...) 1251 + 1252 + // dedup 1253 + slices.Sort(newRepo.Labels) 1254 + newRepo.Labels = slices.Compact(newRepo.Labels) 1255 + 1266 1256 repoRecord := newRepo.AsRecord() 1267 1257 1268 1258 client, err := rp.oauth.AuthorizedClient(r) ··· 1286 1276 }, 1287 1277 }) 1288 1278 1289 - err = db.SubscribeLabel(rp.db, &models.RepoLabel{ 1290 - RepoAt: f.RepoAt(), 1291 - LabelAt: syntax.ATURI(labelAt), 1292 - }) 1279 + tx, err := rp.db.Begin() 1293 1280 if err != nil { 1294 1281 fail("Failed to subscribe to label.", err) 1295 1282 return 1296 1283 } 1284 + defer tx.Rollback() 1285 + 1286 + for _, l := range labelAts { 1287 + err = db.SubscribeLabel(tx, &models.RepoLabel{ 1288 + RepoAt: f.RepoAt(), 1289 + LabelAt: syntax.ATURI(l), 1290 + }) 1291 + if err != nil { 1292 + fail("Failed to subscribe to label.", err) 1293 + return 1294 + } 1295 + } 1296 + 1297 + if err := tx.Commit(); err != nil { 1298 + fail("Failed to subscribe to label.", err) 1299 + return 1300 + } 1297 1301 1298 1302 // everything succeeded 1299 1303 rp.pages.HxRefresh(w) ··· 1311 1315 return 1312 1316 } 1313 1317 1318 + if err := r.ParseForm(); err != nil { 1319 + l.Error("invalid form", "err", err) 1320 + return 1321 + } 1322 + 1314 1323 errorId := "default-label-operation" 1315 1324 fail := func(msg string, err error) { 1316 1325 l.Error(msg, "err", err) 1317 1326 rp.pages.Notice(w, errorId, msg) 1318 1327 } 1319 1328 1320 - labelAt := r.FormValue("label") 1321 - _, err = db.GetLabelDefinition(rp.db, db.FilterEq("at_uri", labelAt)) 1329 + labelAts := r.Form["label"] 1330 + _, err = db.GetLabelDefinitions(rp.db, db.FilterIn("at_uri", labelAts)) 1322 1331 if err != nil { 1323 1332 fail("Failed to unsubscribe to label.", err) 1324 1333 return ··· 1328 1337 newRepo := f.Repo 1329 1338 var updated []string 1330 1339 for _, l := range newRepo.Labels { 1331 - if l != labelAt { 1340 + if !slices.Contains(labelAts, l) { 1332 1341 updated = append(updated, l) 1333 1342 } 1334 1343 } ··· 1359 1368 err = db.UnsubscribeLabel( 1360 1369 rp.db, 1361 1370 db.FilterEq("repo_at", f.RepoAt()), 1362 - db.FilterEq("label_at", labelAt), 1371 + db.FilterIn("label_at", labelAts), 1363 1372 ) 1364 1373 if err != nil { 1365 1374 fail("Failed to unsubscribe label.", err) ··· 1927 1936 subscribedLabels[l] = struct{}{} 1928 1937 } 1929 1938 1939 + // if there is atleast 1 unsubbed default label, show the "subscribe all" button, 1940 + // if all default labels are subbed, show the "unsubscribe all" button 1941 + shouldSubscribeAll := false 1942 + for _, dl := range defaultLabels { 1943 + if _, ok := subscribedLabels[dl.AtUri().String()]; !ok { 1944 + // one of the default labels is not subscribed to 1945 + shouldSubscribeAll = true 1946 + break 1947 + } 1948 + } 1949 + 1930 1950 rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{ 1931 - LoggedInUser: user, 1932 - RepoInfo: f.RepoInfo(user), 1933 - Branches: result.Branches, 1934 - Labels: labels, 1935 - DefaultLabels: defaultLabels, 1936 - SubscribedLabels: subscribedLabels, 1937 - Tabs: settingsTabs, 1938 - Tab: "general", 1951 + LoggedInUser: user, 1952 + RepoInfo: f.RepoInfo(user), 1953 + Branches: result.Branches, 1954 + Labels: labels, 1955 + DefaultLabels: defaultLabels, 1956 + SubscribedLabels: subscribedLabels, 1957 + ShouldSubscribeAll: shouldSubscribeAll, 1958 + Tabs: settingsTabs, 1959 + Tab: "general", 1939 1960 }) 1940 1961 } 1941 1962 ··· 2108 2129 } 2109 2130 2110 2131 // choose a name for a fork 2111 - forkName := f.Name 2132 + forkName := r.FormValue("repo_name") 2133 + if forkName == "" { 2134 + rp.pages.Notice(w, "repo", "Repository name cannot be empty.") 2135 + return 2136 + } 2137 + 2112 2138 // this check is *only* to see if the forked repo name already exists 2113 2139 // in the user's account. 2114 2140 existingRepo, err := db.GetRepo( 2115 2141 rp.db, 2116 2142 db.FilterEq("did", user.Did), 2117 - db.FilterEq("name", f.Name), 2143 + db.FilterEq("name", forkName), 2118 2144 ) 2119 2145 if err != nil { 2120 - if errors.Is(err, sql.ErrNoRows) { 2121 - // no existing repo with this name found, we can use the name as is 2122 - } else { 2146 + if !errors.Is(err, sql.ErrNoRows) { 2123 2147 log.Println("error fetching existing repo from db", "err", err) 2124 2148 rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.") 2125 2149 return 2126 2150 } 2127 2151 } else if existingRepo != nil { 2128 - // repo with this name already exists, append random string 2129 - forkName = fmt.Sprintf("%s-%s", forkName, randomString(3)) 2152 + // repo with this name already exists 2153 + rp.pages.Notice(w, "repo", "A repository with this name already exists.") 2154 + return 2130 2155 } 2131 2156 l = l.With("forkName", forkName) 2132 2157 ··· 2148 2173 Knot: targetKnot, 2149 2174 Rkey: rkey, 2150 2175 Source: sourceAt, 2151 - Description: existingRepo.Description, 2176 + Description: f.Repo.Description, 2152 2177 Created: time.Now(), 2178 + Labels: models.DefaultLabelDefs(), 2153 2179 } 2154 2180 record := repo.AsRecord() 2155 2181
+2 -3
appview/repo/router.go
··· 21 21 r.Route("/tags", func(r chi.Router) { 22 22 r.Get("/", rp.RepoTags) 23 23 r.Route("/{tag}", func(r chi.Router) { 24 - r.Use(middleware.AuthMiddleware(rp.oauth)) 25 - // require auth to download for now 26 24 r.Get("/download/{file}", rp.DownloadArtifact) 27 25 28 26 // require repo:push to upload or delete artifacts ··· 30 28 // additionally: only the uploader can truly delete an artifact 31 29 // (record+blob will live on their pds) 32 30 r.Group(func(r chi.Router) { 33 - r.With(mw.RepoPermissionMiddleware("repo:push")) 31 + r.Use(middleware.AuthMiddleware(rp.oauth)) 32 + r.Use(mw.RepoPermissionMiddleware("repo:push")) 34 33 r.Post("/upload", rp.AttachArtifact) 35 34 r.Delete("/{file}", rp.DeleteArtifact) 36 35 })
+1 -1
appview/reporesolver/resolver.go
··· 212 212 func (f *ResolvedRepo) RolesInRepo(u *oauth.User) repoinfo.RolesInRepo { 213 213 if u != nil { 214 214 r := f.rr.enforcer.GetPermissionsInRepo(u.Did, f.Knot, f.DidSlashRepo()) 215 - return repoinfo.RolesInRepo{r} 215 + return repoinfo.RolesInRepo{Roles: r} 216 216 } else { 217 217 return repoinfo.RolesInRepo{} 218 218 }
+51
appview/settings/settings.go
··· 41 41 {"Name": "profile", "Icon": "user"}, 42 42 {"Name": "keys", "Icon": "key"}, 43 43 {"Name": "emails", "Icon": "mail"}, 44 + {"Name": "notifications", "Icon": "bell"}, 44 45 } 45 46 ) 46 47 ··· 68 69 r.Post("/primary", s.emailsPrimary) 69 70 }) 70 71 72 + r.Route("/notifications", func(r chi.Router) { 73 + r.Get("/", s.notificationsSettings) 74 + r.Put("/", s.updateNotificationPreferences) 75 + }) 76 + 71 77 return r 72 78 } 73 79 ··· 79 85 Tabs: settingsTabs, 80 86 Tab: "profile", 81 87 }) 88 + } 89 + 90 + func (s *Settings) notificationsSettings(w http.ResponseWriter, r *http.Request) { 91 + user := s.OAuth.GetUser(r) 92 + did := s.OAuth.GetDid(r) 93 + 94 + prefs, err := s.Db.GetNotificationPreferences(r.Context(), did) 95 + if err != nil { 96 + log.Printf("failed to get notification preferences: %s", err) 97 + s.Pages.Notice(w, "settings-notifications-error", "Unable to load notification preferences.") 98 + return 99 + } 100 + 101 + s.Pages.UserNotificationSettings(w, pages.UserNotificationSettingsParams{ 102 + LoggedInUser: user, 103 + Preferences: prefs, 104 + Tabs: settingsTabs, 105 + Tab: "notifications", 106 + }) 107 + } 108 + 109 + func (s *Settings) updateNotificationPreferences(w http.ResponseWriter, r *http.Request) { 110 + did := s.OAuth.GetDid(r) 111 + 112 + prefs := &models.NotificationPreferences{ 113 + UserDid: did, 114 + RepoStarred: r.FormValue("repo_starred") == "on", 115 + IssueCreated: r.FormValue("issue_created") == "on", 116 + IssueCommented: r.FormValue("issue_commented") == "on", 117 + IssueClosed: r.FormValue("issue_closed") == "on", 118 + PullCreated: r.FormValue("pull_created") == "on", 119 + PullCommented: r.FormValue("pull_commented") == "on", 120 + PullMerged: r.FormValue("pull_merged") == "on", 121 + Followed: r.FormValue("followed") == "on", 122 + EmailNotifications: r.FormValue("email_notifications") == "on", 123 + } 124 + 125 + err := s.Db.UpdateNotificationPreferences(r.Context(), prefs) 126 + if err != nil { 127 + log.Printf("failed to update notification preferences: %s", err) 128 + s.Pages.Notice(w, "settings-notifications-error", "Unable to save notification preferences.") 129 + return 130 + } 131 + 132 + s.Pages.Notice(w, "settings-notifications-success", "Notification preferences saved successfully.") 82 133 } 83 134 84 135 func (s *Settings) keysSettings(w http.ResponseWriter, r *http.Request) {
+65 -1
appview/signup/signup.go
··· 2 2 3 3 import ( 4 4 "bufio" 5 + "encoding/json" 6 + "errors" 5 7 "fmt" 6 8 "log/slog" 7 9 "net/http" 10 + "net/url" 8 11 "os" 9 12 "strings" 10 13 ··· 116 119 func (s *Signup) signup(w http.ResponseWriter, r *http.Request) { 117 120 switch r.Method { 118 121 case http.MethodGet: 119 - s.pages.Signup(w) 122 + s.pages.Signup(w, pages.SignupParams{ 123 + CloudflareSiteKey: s.config.Cloudflare.TurnstileSiteKey, 124 + }) 120 125 case http.MethodPost: 121 126 if s.cf == nil { 122 127 http.Error(w, "signup is disabled", http.StatusFailedDependency) 128 + return 123 129 } 124 130 emailId := r.FormValue("email") 131 + cfToken := r.FormValue("cf-turnstile-response") 125 132 126 133 noticeId := "signup-msg" 134 + 135 + if err := s.validateCaptcha(cfToken, r); err != nil { 136 + s.l.Warn("turnstile validation failed", "error", err, "email", emailId) 137 + s.pages.Notice(w, noticeId, "Captcha validation failed.") 138 + return 139 + } 140 + 127 141 if !email.IsValidEmail(emailId) { 128 142 s.pages.Notice(w, noticeId, "Invalid email address.") 129 143 return ··· 255 269 return 256 270 } 257 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 + }
+151
appview/state/gfi.go
··· 1 + package state 2 + 3 + import ( 4 + "fmt" 5 + "log" 6 + "net/http" 7 + "sort" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "tangled.org/core/api/tangled" 11 + "tangled.org/core/appview/db" 12 + "tangled.org/core/appview/models" 13 + "tangled.org/core/appview/pages" 14 + "tangled.org/core/appview/pagination" 15 + "tangled.org/core/consts" 16 + ) 17 + 18 + func (s *State) GoodFirstIssues(w http.ResponseWriter, r *http.Request) { 19 + user := s.oauth.GetUser(r) 20 + 21 + page, ok := r.Context().Value("page").(pagination.Page) 22 + if !ok { 23 + page = pagination.FirstPage() 24 + } 25 + 26 + goodFirstIssueLabel := fmt.Sprintf("at://%s/%s/%s", consts.TangledDid, tangled.LabelDefinitionNSID, "good-first-issue") 27 + 28 + repoLabels, err := db.GetRepoLabels(s.db, db.FilterEq("label_at", goodFirstIssueLabel)) 29 + if err != nil { 30 + log.Println("failed to get repo labels", err) 31 + s.pages.Error503(w) 32 + return 33 + } 34 + 35 + if len(repoLabels) == 0 { 36 + s.pages.GoodFirstIssues(w, pages.GoodFirstIssuesParams{ 37 + LoggedInUser: user, 38 + RepoGroups: []*models.RepoGroup{}, 39 + LabelDefs: make(map[string]*models.LabelDefinition), 40 + Page: page, 41 + }) 42 + return 43 + } 44 + 45 + repoUris := make([]string, 0, len(repoLabels)) 46 + for _, rl := range repoLabels { 47 + repoUris = append(repoUris, rl.RepoAt.String()) 48 + } 49 + 50 + allIssues, err := db.GetIssuesPaginated( 51 + s.db, 52 + pagination.Page{ 53 + Limit: 500, 54 + }, 55 + db.FilterIn("repo_at", repoUris), 56 + db.FilterEq("open", 1), 57 + ) 58 + if err != nil { 59 + log.Println("failed to get issues", err) 60 + s.pages.Error503(w) 61 + return 62 + } 63 + 64 + var goodFirstIssues []models.Issue 65 + for _, issue := range allIssues { 66 + if issue.Labels.ContainsLabel(goodFirstIssueLabel) { 67 + goodFirstIssues = append(goodFirstIssues, issue) 68 + } 69 + } 70 + 71 + repoGroups := make(map[syntax.ATURI]*models.RepoGroup) 72 + for _, issue := range goodFirstIssues { 73 + if group, exists := repoGroups[issue.Repo.RepoAt()]; exists { 74 + group.Issues = append(group.Issues, issue) 75 + } else { 76 + repoGroups[issue.Repo.RepoAt()] = &models.RepoGroup{ 77 + Repo: issue.Repo, 78 + Issues: []models.Issue{issue}, 79 + } 80 + } 81 + } 82 + 83 + var sortedGroups []*models.RepoGroup 84 + for _, group := range repoGroups { 85 + sortedGroups = append(sortedGroups, group) 86 + } 87 + 88 + sort.Slice(sortedGroups, func(i, j int) bool { 89 + iIsTangled := sortedGroups[i].Repo.Did == consts.TangledDid 90 + jIsTangled := sortedGroups[j].Repo.Did == consts.TangledDid 91 + 92 + // If one is tangled and the other isn't, non-tangled comes first 93 + if iIsTangled != jIsTangled { 94 + return jIsTangled // true if j is tangled (i should come first) 95 + } 96 + 97 + // Both tangled or both not tangled: sort by name 98 + return sortedGroups[i].Repo.Name < sortedGroups[j].Repo.Name 99 + }) 100 + 101 + groupStart := page.Offset 102 + groupEnd := page.Offset + page.Limit 103 + if groupStart > len(sortedGroups) { 104 + groupStart = len(sortedGroups) 105 + } 106 + if groupEnd > len(sortedGroups) { 107 + groupEnd = len(sortedGroups) 108 + } 109 + 110 + paginatedGroups := sortedGroups[groupStart:groupEnd] 111 + 112 + var allIssuesFromGroups []models.Issue 113 + for _, group := range paginatedGroups { 114 + allIssuesFromGroups = append(allIssuesFromGroups, group.Issues...) 115 + } 116 + 117 + var allLabelDefs []models.LabelDefinition 118 + if len(allIssuesFromGroups) > 0 { 119 + labelDefUris := make(map[string]bool) 120 + for _, issue := range allIssuesFromGroups { 121 + for labelDefUri := range issue.Labels.Inner() { 122 + labelDefUris[labelDefUri] = true 123 + } 124 + } 125 + 126 + uriList := make([]string, 0, len(labelDefUris)) 127 + for uri := range labelDefUris { 128 + uriList = append(uriList, uri) 129 + } 130 + 131 + if len(uriList) > 0 { 132 + allLabelDefs, err = db.GetLabelDefinitions(s.db, db.FilterIn("at_uri", uriList)) 133 + if err != nil { 134 + log.Println("failed to fetch labels", err) 135 + } 136 + } 137 + } 138 + 139 + labelDefsMap := make(map[string]*models.LabelDefinition) 140 + for i := range allLabelDefs { 141 + labelDefsMap[allLabelDefs[i].AtUri().String()] = &allLabelDefs[i] 142 + } 143 + 144 + s.pages.GoodFirstIssues(w, pages.GoodFirstIssuesParams{ 145 + LoggedInUser: user, 146 + RepoGroups: paginatedGroups, 147 + LabelDefs: labelDefsMap, 148 + Page: page, 149 + GfiLabel: labelDefsMap[goodFirstIssueLabel], 150 + }) 151 + }
+14 -1
appview/state/knotstream.go
··· 172 172 }) 173 173 } 174 174 175 - return db.InsertRepoLanguages(d, langs) 175 + tx, err := d.Begin() 176 + if err != nil { 177 + return err 178 + } 179 + defer tx.Rollback() 180 + 181 + // update appview's cache 182 + err = db.UpdateRepoLanguages(tx, repo.RepoAt(), ref.Short(), langs) 183 + if err != nil { 184 + fmt.Printf("failed; %s\n", err) 185 + // non-fatal 186 + } 187 + 188 + return tx.Commit() 176 189 } 177 190 178 191 func ingestPipeline(d *db.DB, source ec.Source, msg ec.Message) error {
+5 -13
appview/state/profile.go
··· 217 217 s.pages.Error500(w) 218 218 return 219 219 } 220 - var repoAts []string 220 + var repos []models.Repo 221 221 for _, s := range stars { 222 - repoAts = append(repoAts, string(s.RepoAt)) 223 - } 224 - 225 - repos, err := db.GetRepos( 226 - s.db, 227 - 0, 228 - db.FilterIn("at_uri", repoAts), 229 - ) 230 - if err != nil { 231 - l.Error("failed to get repos", "err", err) 232 - s.pages.Error500(w) 233 - return 222 + if s.Repo != nil { 223 + repos = append(repos, *s.Repo) 224 + } 234 225 } 235 226 236 227 err = s.pages.ProfileStarred(w, pages.ProfileStarredParams{ ··· 345 336 profile.Did = did 346 337 } 347 338 followCards[i] = pages.FollowCard{ 339 + LoggedInUser: loggedInUser, 348 340 UserDid: did, 349 341 FollowStatus: followStatus, 350 342 FollowersCount: followStats.Followers,
+16 -3
appview/state/router.go
··· 10 10 "tangled.org/core/appview/knots" 11 11 "tangled.org/core/appview/labels" 12 12 "tangled.org/core/appview/middleware" 13 + "tangled.org/core/appview/notifications" 13 14 oauthhandler "tangled.org/core/appview/oauth/handler" 14 15 "tangled.org/core/appview/pipelines" 15 16 "tangled.org/core/appview/pulls" ··· 33 34 s.pages, 34 35 ) 35 36 37 + router.Use(middleware.TryRefreshSession()) 36 38 router.Get("/favicon.svg", s.Favicon) 37 39 router.Get("/favicon.ico", s.Favicon) 40 + router.Get("/pwa-manifest.json", s.PWAManifest) 38 41 39 42 userRouter := s.UserRouter(&middleware) 40 43 standardRouter := s.StandardRouter(&middleware) ··· 115 118 116 119 r.Get("/", s.HomeOrTimeline) 117 120 r.Get("/timeline", s.Timeline) 118 - r.With(middleware.AuthMiddleware(s.oauth)).Get("/upgradeBanner", s.UpgradeBanner) 121 + r.Get("/upgradeBanner", s.UpgradeBanner) 119 122 120 123 // special-case handler for serving tangled.org/core 121 124 r.Get("/core", s.Core()) ··· 128 131 }) 129 132 // r.Post("/import", s.ImportRepo) 130 133 }) 134 + 135 + r.Get("/goodfirstissues", s.GoodFirstIssues) 131 136 132 137 r.With(middleware.AuthMiddleware(s.oauth)).Route("/follow", func(r chi.Router) { 133 138 r.Post("/", s.Follow) ··· 156 161 r.Mount("/strings", s.StringsRouter(mw)) 157 162 r.Mount("/knots", s.KnotsRouter()) 158 163 r.Mount("/spindles", s.SpindlesRouter()) 164 + r.Mount("/notifications", s.NotificationsRouter(mw)) 165 + 159 166 r.Mount("/signup", s.SignupRouter()) 160 167 r.Mount("/", s.OAuthRouter()) 161 168 162 169 r.Get("/keys/{user}", s.Keys) 163 170 r.Get("/terms", s.TermsOfService) 164 171 r.Get("/privacy", s.PrivacyPolicy) 172 + r.Get("/brand", s.Brand) 165 173 166 174 r.NotFound(func(w http.ResponseWriter, r *http.Request) { 167 175 s.pages.Error404(w) ··· 175 183 return func(w http.ResponseWriter, r *http.Request) { 176 184 if r.URL.Query().Get("go-get") == "1" { 177 185 w.Header().Set("Content-Type", "text/html") 178 - w.Write([]byte(`<meta name="go-import" content="tangled.org/core git https://tangled.org/tangled.org/core">`)) 186 + w.Write([]byte(`<meta name="go-import" content="tangled.org/core git https://tangled.org/@tangled.org/core">`)) 179 187 return 180 188 } 181 189 ··· 270 278 } 271 279 272 280 func (s *State) LabelsRouter(mw *middleware.Middleware) http.Handler { 273 - ls := labels.New(s.oauth, s.pages, s.db, s.validator) 281 + ls := labels.New(s.oauth, s.pages, s.db, s.validator, s.enforcer) 274 282 return ls.Router(mw) 283 + } 284 + 285 + func (s *State) NotificationsRouter(mw *middleware.Middleware) http.Handler { 286 + notifs := notifications.New(s.db, s.oauth, s.pages) 287 + return notifs.Router(mw) 275 288 } 276 289 277 290 func (s *State) SignupRouter() http.Handler {
+85 -6
appview/state/state.go
··· 25 25 "tangled.org/core/appview/db" 26 26 "tangled.org/core/appview/models" 27 27 "tangled.org/core/appview/notify" 28 + dbnotify "tangled.org/core/appview/notify/db" 29 + phnotify "tangled.org/core/appview/notify/posthog" 28 30 "tangled.org/core/appview/oauth" 29 31 "tangled.org/core/appview/pages" 30 - posthogService "tangled.org/core/appview/posthog" 31 32 "tangled.org/core/appview/reporesolver" 32 33 "tangled.org/core/appview/validator" 33 34 xrpcclient "tangled.org/core/appview/xrpcclient" ··· 78 79 cache := cache.New(config.Redis.Addr) 79 80 sess := session.New(cache) 80 81 oauth := oauth.NewOAuth(config, sess) 81 - validator := validator.New(d, res) 82 + validator := validator.New(d, res, enforcer) 82 83 83 84 posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint}) 84 85 if err != nil { ··· 87 88 88 89 repoResolver := reporesolver.New(config, enforcer, res, d) 89 90 90 - wrapper := db.DbWrapper{d} 91 + wrapper := db.DbWrapper{Execer: d} 91 92 jc, err := jetstream.NewJetstreamClient( 92 93 config.Jetstream.Endpoint, 93 94 "appview", ··· 103 104 tangled.RepoIssueNSID, 104 105 tangled.RepoIssueCommentNSID, 105 106 tangled.LabelDefinitionNSID, 107 + tangled.LabelOpNSID, 106 108 }, 107 109 nil, 108 110 slog.Default(), ··· 115 117 ) 116 118 if err != nil { 117 119 return nil, fmt.Errorf("failed to create jetstream client: %w", err) 120 + } 121 + 122 + if err := BackfillDefaultDefs(d, res); err != nil { 123 + return nil, fmt.Errorf("failed to backfill default label defs: %w", err) 118 124 } 119 125 120 126 ingester := appview.Ingester{ ··· 143 149 spindlestream.Start(ctx) 144 150 145 151 var notifiers []notify.Notifier 152 + 153 + // Always add the database notifier 154 + notifiers = append(notifiers, dbnotify.NewDatabaseNotifier(d, res)) 155 + 156 + // Add other notifiers in production only 146 157 if !config.Core.Dev { 147 - notifiers = append(notifiers, posthogService.NewPosthogNotifier(posthog)) 158 + notifiers = append(notifiers, phnotify.NewPosthogNotifier(posthog)) 148 159 } 149 160 notifier := notify.NewMergedNotifier(notifiers...) 150 161 ··· 187 198 s.pages.Favicon(w) 188 199 } 189 200 201 + // https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest 202 + const manifestJson = `{ 203 + "name": "tangled", 204 + "description": "tightly-knit social coding.", 205 + "icons": [ 206 + { 207 + "src": "/favicon.svg", 208 + "sizes": "144x144" 209 + } 210 + ], 211 + "start_url": "/", 212 + "id": "org.tangled", 213 + 214 + "display": "standalone", 215 + "background_color": "#111827", 216 + "theme_color": "#111827" 217 + }` 218 + 219 + func (p *State) PWAManifest(w http.ResponseWriter, r *http.Request) { 220 + w.Header().Set("Content-Type", "application/json") 221 + w.Write([]byte(manifestJson)) 222 + } 223 + 190 224 func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) { 191 225 user := s.oauth.GetUser(r) 192 226 s.pages.TermsOfService(w, pages.TermsOfServiceParams{ ··· 201 235 }) 202 236 } 203 237 238 + func (s *State) Brand(w http.ResponseWriter, r *http.Request) { 239 + user := s.oauth.GetUser(r) 240 + s.pages.Brand(w, pages.BrandParams{ 241 + LoggedInUser: user, 242 + }) 243 + } 244 + 204 245 func (s *State) HomeOrTimeline(w http.ResponseWriter, r *http.Request) { 205 246 if s.oauth.GetUser(r) != nil { 206 247 s.Timeline(w, r) ··· 229 270 return 230 271 } 231 272 232 - s.pages.Timeline(w, pages.TimelineParams{ 273 + gfiLabel, err := db.GetLabelDefinition(s.db, db.FilterEq("at_uri", models.LabelGoodFirstIssue)) 274 + if err != nil { 275 + // non-fatal 276 + } 277 + 278 + fmt.Println(s.pages.Timeline(w, pages.TimelineParams{ 233 279 LoggedInUser: user, 234 280 Timeline: timeline, 235 281 Repos: repos, 236 - }) 282 + GfiLabel: gfiLabel, 283 + })) 237 284 } 238 285 239 286 func (s *State) UpgradeBanner(w http.ResponseWriter, r *http.Request) { 240 287 user := s.oauth.GetUser(r) 288 + if user == nil { 289 + return 290 + } 291 + 241 292 l := s.logger.With("handler", "UpgradeBanner") 242 293 l = l.With("did", user.Did) 243 294 l = l.With("handle", user.Handle) ··· 440 491 Rkey: rkey, 441 492 Description: description, 442 493 Created: time.Now(), 494 + Labels: models.DefaultLabelDefs(), 443 495 } 444 496 record := repo.AsRecord() 445 497 ··· 580 632 }) 581 633 return err 582 634 } 635 + 636 + func BackfillDefaultDefs(e db.Execer, r *idresolver.Resolver) error { 637 + defaults := models.DefaultLabelDefs() 638 + defaultLabels, err := db.GetLabelDefinitions(e, db.FilterIn("at_uri", defaults)) 639 + if err != nil { 640 + return err 641 + } 642 + // already present 643 + if len(defaultLabels) == len(defaults) { 644 + return nil 645 + } 646 + 647 + labelDefs, err := models.FetchDefaultDefs(r) 648 + if err != nil { 649 + return err 650 + } 651 + 652 + // Insert each label definition to the database 653 + for _, labelDef := range labelDefs { 654 + _, err = db.AddLabelDefinition(e, &labelDef) 655 + if err != nil { 656 + return fmt.Errorf("failed to add label definition %s: %v", labelDef.Name, err) 657 + } 658 + } 659 + 660 + return nil 661 + }
+15 -1
appview/validator/label.go
··· 95 95 return nil 96 96 } 97 97 98 - func (v *Validator) ValidateLabelOp(labelDef *models.LabelDefinition, labelOp *models.LabelOp) error { 98 + func (v *Validator) ValidateLabelOp(labelDef *models.LabelDefinition, repo *models.Repo, labelOp *models.LabelOp) error { 99 99 if labelDef == nil { 100 100 return fmt.Errorf("label definition is required") 101 101 } 102 + if repo == nil { 103 + return fmt.Errorf("repo is required") 104 + } 102 105 if labelOp == nil { 103 106 return fmt.Errorf("label operation is required") 107 + } 108 + 109 + // validate permissions: only collaborators can apply labels currently 110 + // 111 + // TODO: introduce a repo:triage permission 112 + ok, err := v.enforcer.IsPushAllowed(labelOp.Did, repo.Knot, repo.DidSlashRepo()) 113 + if err != nil { 114 + return fmt.Errorf("failed to enforce permissions: %w", err) 115 + } 116 + if !ok { 117 + return fmt.Errorf("unauhtorized label operation") 104 118 } 105 119 106 120 expectedKey := labelDef.AtUri().String()
+4 -1
appview/validator/validator.go
··· 4 4 "tangled.org/core/appview/db" 5 5 "tangled.org/core/appview/pages/markup" 6 6 "tangled.org/core/idresolver" 7 + "tangled.org/core/rbac" 7 8 ) 8 9 9 10 type Validator struct { 10 11 db *db.DB 11 12 sanitizer markup.Sanitizer 12 13 resolver *idresolver.Resolver 14 + enforcer *rbac.Enforcer 13 15 } 14 16 15 - func New(db *db.DB, res *idresolver.Resolver) *Validator { 17 + func New(db *db.DB, res *idresolver.Resolver, enforcer *rbac.Enforcer) *Validator { 16 18 return &Validator{ 17 19 db: db, 18 20 sanitizer: markup.NewSanitizer(), 19 21 resolver: res, 22 + enforcer: enforcer, 20 23 } 21 24 }
+1 -1
docs/spindle/pipeline.md
··· 21 21 - `manual`: The workflow can be triggered manually. 22 22 - `branch`: This is a **required** field that defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event. 23 23 24 - For example, if you'd like define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with: 24 + For example, if you'd like to define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with: 25 25 26 26 ```yaml 27 27 when:
+1 -1
go.mod
··· 43 43 github.com/yuin/goldmark v1.7.12 44 44 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc 45 45 golang.org/x/crypto v0.40.0 46 + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b 46 47 golang.org/x/net v0.42.0 47 48 golang.org/x/sync v0.16.0 48 49 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da ··· 168 169 go.uber.org/atomic v1.11.0 // indirect 169 170 go.uber.org/multierr v1.11.0 // indirect 170 171 go.uber.org/zap v1.27.0 // indirect 171 - golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect 172 172 golang.org/x/sys v0.34.0 // indirect 173 173 golang.org/x/text v0.27.0 // indirect 174 174 golang.org/x/time v0.12.0 // indirect
+1 -1
knotserver/config/config.go
··· 41 41 Repo Repo `env:",prefix=KNOT_REPO_"` 42 42 Server Server `env:",prefix=KNOT_SERVER_"` 43 43 Git Git `env:",prefix=KNOT_GIT_"` 44 - AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.sh"` 44 + AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.org"` 45 45 } 46 46 47 47 func Load(ctx context.Context) (*Config, error) {
-103
knotserver/git/git.go
··· 27 27 h plumbing.Hash 28 28 } 29 29 30 - type TagList struct { 31 - refs []*TagReference 32 - r *git.Repository 33 - } 34 - 35 - // TagReference is used to list both tag and non-annotated tags. 36 - // Non-annotated tags should only contains a reference. 37 - // Annotated tags should contain its reference and its tag information. 38 - type TagReference struct { 39 - ref *plumbing.Reference 40 - tag *object.Tag 41 - } 42 - 43 30 // infoWrapper wraps the property of a TreeEntry so it can export fs.FileInfo 44 31 // to tar WriteHeader 45 32 type infoWrapper struct { ··· 48 35 mode fs.FileMode 49 36 modTime time.Time 50 37 isDir bool 51 - } 52 - 53 - func (self *TagList) Len() int { 54 - return len(self.refs) 55 - } 56 - 57 - func (self *TagList) Swap(i, j int) { 58 - self.refs[i], self.refs[j] = self.refs[j], self.refs[i] 59 - } 60 - 61 - // sorting tags in reverse chronological order 62 - func (self *TagList) Less(i, j int) bool { 63 - var dateI time.Time 64 - var dateJ time.Time 65 - 66 - if self.refs[i].tag != nil { 67 - dateI = self.refs[i].tag.Tagger.When 68 - } else { 69 - c, err := self.r.CommitObject(self.refs[i].ref.Hash()) 70 - if err != nil { 71 - dateI = time.Now() 72 - } else { 73 - dateI = c.Committer.When 74 - } 75 - } 76 - 77 - if self.refs[j].tag != nil { 78 - dateJ = self.refs[j].tag.Tagger.When 79 - } else { 80 - c, err := self.r.CommitObject(self.refs[j].ref.Hash()) 81 - if err != nil { 82 - dateJ = time.Now() 83 - } else { 84 - dateJ = c.Committer.When 85 - } 86 - } 87 - 88 - return dateI.After(dateJ) 89 38 } 90 39 91 40 func Open(path string, ref string) (*GitRepo, error) { ··· 171 120 return g.r.CommitObject(h) 172 121 } 173 122 174 - func (g *GitRepo) LastCommit() (*object.Commit, error) { 175 - c, err := g.r.CommitObject(g.h) 176 - if err != nil { 177 - return nil, fmt.Errorf("last commit: %w", err) 178 - } 179 - return c, nil 180 - } 181 - 182 123 func (g *GitRepo) FileContentN(path string, cap int64) ([]byte, error) { 183 124 c, err := g.r.CommitObject(g.h) 184 125 if err != nil { ··· 211 152 } 212 153 213 154 return buf.Bytes(), nil 214 - } 215 - 216 - func (g *GitRepo) FileContent(path string) (string, error) { 217 - c, err := g.r.CommitObject(g.h) 218 - if err != nil { 219 - return "", fmt.Errorf("commit object: %w", err) 220 - } 221 - 222 - tree, err := c.Tree() 223 - if err != nil { 224 - return "", fmt.Errorf("file tree: %w", err) 225 - } 226 - 227 - file, err := tree.File(path) 228 - if err != nil { 229 - return "", err 230 - } 231 - 232 - isbin, _ := file.IsBinary() 233 - 234 - if !isbin { 235 - return file.Contents() 236 - } else { 237 - return "", ErrBinaryFile 238 - } 239 155 } 240 156 241 157 func (g *GitRepo) RawContent(path string) ([]byte, error) { ··· 410 326 func (i *infoWrapper) Sys() any { 411 327 return nil 412 328 } 413 - 414 - func (t *TagReference) Name() string { 415 - return t.ref.Name().Short() 416 - } 417 - 418 - func (t *TagReference) Message() string { 419 - if t.tag != nil { 420 - return t.tag.Message 421 - } 422 - return "" 423 - } 424 - 425 - func (t *TagReference) TagObject() *object.Tag { 426 - return t.tag 427 - } 428 - 429 - func (t *TagReference) Hash() plumbing.Hash { 430 - return t.ref.Hash() 431 - }
+1 -3
knotserver/git/tag.go
··· 2 2 3 3 import ( 4 4 "fmt" 5 - "slices" 6 5 "strconv" 7 6 "strings" 8 7 "time" ··· 35 34 outFormat.WriteString("") 36 35 outFormat.WriteString(recordSeparator) 37 36 38 - output, err := g.forEachRef(outFormat.String(), "refs/tags") 37 + output, err := g.forEachRef(outFormat.String(), "--sort=-creatordate", "refs/tags") 39 38 if err != nil { 40 39 return nil, fmt.Errorf("failed to get tags: %w", err) 41 40 } ··· 94 93 tags = append(tags, tag) 95 94 } 96 95 97 - slices.Reverse(tags) 98 96 return tags, nil 99 97 }
-4
knotserver/http_util.go
··· 16 16 w.WriteHeader(status) 17 17 json.NewEncoder(w).Encode(map[string]string{"error": msg}) 18 18 } 19 - 20 - func notFound(w http.ResponseWriter) { 21 - writeError(w, "not found", http.StatusNotFound) 22 - }
+1 -1
knotserver/xrpc/repo_blob.go
··· 44 44 45 45 contents, err := gr.RawContent(treePath) 46 46 if err != nil { 47 - x.Logger.Error("file content", "error", err.Error()) 47 + x.Logger.Error("file content", "error", err.Error(), "treePath", treePath) 48 48 writeError(w, xrpcerr.NewXrpcError( 49 49 xrpcerr.WithTag("FileNotFound"), 50 50 xrpcerr.WithMessage("file not found at the specified path"),
+24
knotserver/xrpc/repo_tree.go
··· 4 4 "net/http" 5 5 "path/filepath" 6 6 "time" 7 + "unicode/utf8" 7 8 8 9 "tangled.org/core/api/tangled" 10 + "tangled.org/core/appview/pages/markup" 9 11 "tangled.org/core/knotserver/git" 10 12 xrpcerr "tangled.org/core/xrpc/errors" 11 13 ) ··· 43 45 return 44 46 } 45 47 48 + // if any of these files are a readme candidate, pass along its blob contents too 49 + var readmeFileName string 50 + var readmeContents string 51 + for _, file := range files { 52 + if markup.IsReadmeFile(file.Name) { 53 + contents, err := gr.RawContent(filepath.Join(path, file.Name)) 54 + if err != nil { 55 + x.Logger.Error("failed to read contents of file", "path", path, "file", file.Name) 56 + } 57 + 58 + if utf8.Valid(contents) { 59 + readmeFileName = file.Name 60 + readmeContents = string(contents) 61 + break 62 + } 63 + } 64 + } 65 + 46 66 // convert NiceTree -> tangled.RepoTree_TreeEntry 47 67 treeEntries := make([]*tangled.RepoTree_TreeEntry, len(files)) 48 68 for i, file := range files { ··· 83 103 Parent: parentPtr, 84 104 Dotdot: dotdotPtr, 85 105 Files: treeEntries, 106 + Readme: &tangled.RepoTree_Readme{ 107 + Filename: readmeFileName, 108 + Contents: readmeContents, 109 + }, 86 110 } 87 111 88 112 writeJson(w, response)
-158
legal/privacy.md
··· 1 - # Privacy Policy 2 - 3 - **Last updated:** January 15, 2025 4 - 5 - This Privacy Policy describes how Tangled ("we," "us," or "our") 6 - collects, uses, and shares your personal information when you use our 7 - platform and services (the "Service"). 8 - 9 - ## 1. Information We Collect 10 - 11 - ### Account Information 12 - 13 - When you create an account, we collect: 14 - 15 - - Your chosen username 16 - - Email address 17 - - Profile information you choose to provide 18 - - Authentication data 19 - 20 - ### Content and Activity 21 - 22 - We store: 23 - 24 - - Code repositories and associated metadata 25 - - Issues, pull requests, and comments 26 - - Activity logs and usage patterns 27 - - Public keys for authentication 28 - 29 - ## 2. Data Location and Hosting 30 - 31 - ### EU Data Hosting 32 - 33 - **All Tangled service data is hosted within the European Union.** 34 - Specifically: 35 - 36 - - **Personal Data Servers (PDS):** Accounts hosted on Tangled PDS 37 - (*.tngl.sh) are located in Finland 38 - - **Application Data:** All other service data is stored on EU-based 39 - servers 40 - - **Data Processing:** All data processing occurs within EU 41 - jurisdiction 42 - 43 - ### External PDS Notice 44 - 45 - **Important:** If your account is hosted on Bluesky's PDS or other 46 - self-hosted Personal Data Servers (not *.tngl.sh), we do not control 47 - that data. The data protection, storage location, and privacy 48 - practices for such accounts are governed by the respective PDS 49 - provider's policies, not this Privacy Policy. We only control data 50 - processing within our own services and infrastructure. 51 - 52 - ## 3. Third-Party Data Processors 53 - 54 - We only share your data with the following third-party processors: 55 - 56 - ### Resend (Email Services) 57 - 58 - - **Purpose:** Sending transactional emails (account verification, 59 - notifications) 60 - - **Data Shared:** Email address and necessary message content 61 - 62 - ### Cloudflare (Image Caching) 63 - 64 - - **Purpose:** Caching and optimizing image delivery 65 - - **Data Shared:** Public images and associated metadata for caching 66 - purposes 67 - 68 - ### Posthog (Usage Metrics Tracking) 69 - 70 - - **Purpose:** Tracking usage and platform metrics 71 - - **Data Shared:** Anonymous usage data, IP addresses, DIDs, and browser 72 - information 73 - 74 - ## 4. How We Use Your Information 75 - 76 - We use your information to: 77 - 78 - - Provide and maintain the Service 79 - - Process your transactions and requests 80 - - Send you technical notices and support messages 81 - - Improve and develop new features 82 - - Ensure security and prevent fraud 83 - - Comply with legal obligations 84 - 85 - ## 5. Data Sharing and Disclosure 86 - 87 - We do not sell, trade, or rent your personal information. We may share 88 - your information only in the following circumstances: 89 - 90 - - With the third-party processors listed above 91 - - When required by law or legal process 92 - - To protect our rights, property, or safety, or that of our users 93 - - In connection with a merger, acquisition, or sale of assets (with 94 - appropriate protections) 95 - 96 - ## 6. Data Security 97 - 98 - We implement appropriate technical and organizational measures to 99 - protect your personal information against unauthorized access, 100 - alteration, disclosure, or destruction. However, no method of 101 - transmission over the Internet is 100% secure. 102 - 103 - ## 7. Data Retention 104 - 105 - We retain your personal information for as long as necessary to provide 106 - the Service and fulfill the purposes outlined in this Privacy Policy, 107 - unless a longer retention period is required by law. 108 - 109 - ## 8. Your Rights 110 - 111 - Under applicable data protection laws, you have the right to: 112 - 113 - - Access your personal information 114 - - Correct inaccurate information 115 - - Request deletion of your information 116 - - Object to processing of your information 117 - - Data portability 118 - - Withdraw consent (where applicable) 119 - 120 - ## 9. Cookies and Tracking 121 - 122 - We use cookies and similar technologies to: 123 - 124 - - Maintain your login session 125 - - Remember your preferences 126 - - Analyze usage patterns to improve the Service 127 - 128 - You can control cookie settings through your browser preferences. 129 - 130 - ## 10. Children's Privacy 131 - 132 - The Service is not intended for children under 16 years of age. We do 133 - not knowingly collect personal information from children under 16. If 134 - we become aware that we have collected such information, we will take 135 - steps to delete it. 136 - 137 - ## 11. International Data Transfers 138 - 139 - While all our primary data processing occurs within the EU, some of our 140 - third-party processors may process data outside the EU. When this 141 - occurs, we ensure appropriate safeguards are in place, such as Standard 142 - Contractual Clauses or adequacy decisions. 143 - 144 - ## 12. Changes to This Privacy Policy 145 - 146 - We may update this Privacy Policy from time to time. We will notify you 147 - of any changes by posting the new Privacy Policy on this page and 148 - updating the "Last updated" date. 149 - 150 - ## 13. Contact Information 151 - 152 - If you have any questions about this Privacy Policy or wish to exercise 153 - your rights, please contact us through our platform or via email. 154 - 155 - --- 156 - 157 - This Privacy Policy complies with the EU General Data Protection 158 - Regulation (GDPR) and other applicable data protection laws.
-109
legal/terms.md
··· 1 - # Terms of Service 2 - 3 - **Last updated:** January 15, 2025 4 - 5 - Welcome to Tangled. These Terms of Service ("Terms") govern your access 6 - to and use of the Tangled platform and services (the "Service") 7 - operated by us ("Tangled," "we," "us," or "our"). 8 - 9 - ## 1. Acceptance of Terms 10 - 11 - By accessing or using our Service, you agree to be bound by these Terms. 12 - If you disagree with any part of these terms, then you may not access 13 - the Service. 14 - 15 - ## 2. Account Registration 16 - 17 - To use certain features of the Service, you must register for an 18 - account. You agree to provide accurate, current, and complete 19 - information during the registration process and to update such 20 - information to keep it accurate, current, and complete. 21 - 22 - ## 3. Account Termination 23 - 24 - > **Important Notice** 25 - > 26 - > **We reserve the right to terminate, suspend, or restrict access to 27 - > your account at any time, for any reason, or for no reason at all, at 28 - > our sole discretion.** This includes, but is not limited to, 29 - > termination for violation of these Terms, inappropriate conduct, spam, 30 - > abuse, or any other behavior we deem harmful to the Service or other 31 - > users. 32 - > 33 - > Account termination may result in the loss of access to your 34 - > repositories, data, and other content associated with your account. We 35 - > are not obligated to provide advance notice of termination, though we 36 - > may do so in our discretion. 37 - 38 - ## 4. Acceptable Use 39 - 40 - You agree not to use the Service to: 41 - 42 - - Violate any applicable laws or regulations 43 - - Infringe upon the rights of others 44 - - Upload, store, or share content that is illegal, harmful, threatening, 45 - abusive, harassing, defamatory, vulgar, obscene, or otherwise 46 - objectionable 47 - - Engage in spam, phishing, or other deceptive practices 48 - - Attempt to gain unauthorized access to the Service or other users' 49 - accounts 50 - - Interfere with or disrupt the Service or servers connected to the 51 - Service 52 - 53 - ## 5. Content and Intellectual Property 54 - 55 - You retain ownership of the content you upload to the Service. By 56 - uploading content, you grant us a non-exclusive, worldwide, royalty-free 57 - license to use, reproduce, modify, and distribute your content as 58 - necessary to provide the Service. 59 - 60 - ## 6. Privacy 61 - 62 - Your privacy is important to us. Please review our [Privacy 63 - Policy](/privacy), which also governs your use of the Service. 64 - 65 - ## 7. Disclaimers 66 - 67 - The Service is provided on an "AS IS" and "AS AVAILABLE" basis. We make 68 - no warranties, expressed or implied, and hereby disclaim and negate all 69 - other warranties including without limitation, implied warranties or 70 - conditions of merchantability, fitness for a particular purpose, or 71 - non-infringement of intellectual property or other violation of rights. 72 - 73 - ## 8. Limitation of Liability 74 - 75 - In no event shall Tangled, nor its directors, employees, partners, 76 - agents, suppliers, or affiliates, be liable for any indirect, 77 - incidental, special, consequential, or punitive damages, including 78 - without limitation, loss of profits, data, use, goodwill, or other 79 - intangible losses, resulting from your use of the Service. 80 - 81 - ## 9. Indemnification 82 - 83 - You agree to defend, indemnify, and hold harmless Tangled and its 84 - affiliates, officers, directors, employees, and agents from and against 85 - any and all claims, damages, obligations, losses, liabilities, costs, 86 - or debt, and expenses (including attorney's fees). 87 - 88 - ## 10. Governing Law 89 - 90 - These Terms shall be interpreted and governed by the laws of Finland, 91 - without regard to its conflict of law provisions. 92 - 93 - ## 11. Changes to Terms 94 - 95 - We reserve the right to modify or replace these Terms at any time. If a 96 - revision is material, we will try to provide at least 30 days notice 97 - prior to any new terms taking effect. 98 - 99 - ## 12. Contact Information 100 - 101 - If you have any questions about these Terms of Service, please contact 102 - us through our platform or via email. 103 - 104 - --- 105 - 106 - These terms are effective as of the last updated date shown above and 107 - will remain in effect except with respect to any changes in their 108 - provisions in the future, which will be in effect immediately after 109 - being posted on this page.
+19
lexicons/repo/tree.json
··· 41 41 "type": "string", 42 42 "description": "Parent directory path" 43 43 }, 44 + "readme": { 45 + "type": "ref", 46 + "ref": "#readme", 47 + "description": "Readme for this file tree" 48 + }, 44 49 "files": { 45 50 "type": "array", 46 51 "items": { ··· 69 74 "description": "Invalid request parameters" 70 75 } 71 76 ] 77 + }, 78 + "readme": { 79 + "type": "object", 80 + "required": ["filename", "contents"], 81 + "properties": { 82 + "filename": { 83 + "type": "string", 84 + "description": "Name of the readme file" 85 + }, 86 + "contents": { 87 + "type": "string", 88 + "description": "Contents of the readme file" 89 + } 90 + } 72 91 }, 73 92 "treeEntry": { 74 93 "type": "object",
+1 -1
nix/pkgs/knot-unwrapped.nix
··· 4 4 sqlite-lib, 5 5 src, 6 6 }: let 7 - version = "1.9.0-alpha"; 7 + version = "1.9.1-alpha"; 8 8 in 9 9 buildGoApplication { 10 10 pname = "knot";
+7 -5
types/repo.go
··· 41 41 } 42 42 43 43 type RepoTreeResponse struct { 44 - Ref string `json:"ref,omitempty"` 45 - Parent string `json:"parent,omitempty"` 46 - Description string `json:"description,omitempty"` 47 - DotDot string `json:"dotdot,omitempty"` 48 - Files []NiceTree `json:"files,omitempty"` 44 + Ref string `json:"ref,omitempty"` 45 + Parent string `json:"parent,omitempty"` 46 + Description string `json:"description,omitempty"` 47 + DotDot string `json:"dotdot,omitempty"` 48 + Files []NiceTree `json:"files,omitempty"` 49 + ReadmeFileName string `json:"readme_filename,omitempty"` 50 + Readme string `json:"readme_contents,omitempty"` 49 51 } 50 52 51 53 type TagReference struct {