+187
appview/db/repos.go
+187
appview/db/repos.go
···
3
3
import (
4
4
"database/sql"
5
5
"fmt"
6
+
"strings"
6
7
"time"
7
8
8
9
"github.com/bluesky-social/indigo/atproto/syntax"
···
65
66
66
67
if err := rows.Err(); err != nil {
67
68
return nil, err
69
+
}
70
+
71
+
return repos, nil
72
+
}
73
+
74
+
func GetRepos(e Execer, filters ...filter) ([]Repo, error) {
75
+
repoMap := make(map[syntax.ATURI]Repo)
76
+
77
+
var conditions []string
78
+
var args []any
79
+
for _, filter := range filters {
80
+
conditions = append(conditions, filter.Condition())
81
+
args = append(args, filter.Arg()...)
82
+
}
83
+
84
+
whereClause := ""
85
+
if conditions != nil {
86
+
whereClause = " where " + strings.Join(conditions, " and ")
87
+
}
88
+
89
+
repoQuery := fmt.Sprintf(
90
+
`select
91
+
did,
92
+
name,
93
+
knot,
94
+
rkey,
95
+
created,
96
+
description,
97
+
source,
98
+
spindle
99
+
from
100
+
repos r
101
+
%s`,
102
+
whereClause,
103
+
)
104
+
rows, err := e.Query(repoQuery, args...)
105
+
106
+
if err != nil {
107
+
return nil, fmt.Errorf("failed to execute repo query: %w ", err)
108
+
}
109
+
110
+
for rows.Next() {
111
+
var repo Repo
112
+
var createdAt string
113
+
var description, source, spindle sql.NullString
114
+
115
+
err := rows.Scan(
116
+
&repo.Did,
117
+
&repo.Name,
118
+
&repo.Knot,
119
+
&repo.Rkey,
120
+
&createdAt,
121
+
&description,
122
+
&source,
123
+
&spindle,
124
+
)
125
+
if err != nil {
126
+
return nil, fmt.Errorf("failed to execute repo query: %w ", err)
127
+
}
128
+
129
+
if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
130
+
repo.Created = t
131
+
}
132
+
if description.Valid {
133
+
repo.Description = description.String
134
+
}
135
+
if source.Valid {
136
+
repo.Source = source.String
137
+
}
138
+
if spindle.Valid {
139
+
repo.Spindle = spindle.String
140
+
}
141
+
142
+
repoMap[repo.RepoAt()] = repo
143
+
}
144
+
145
+
if err = rows.Err(); err != nil {
146
+
return nil, fmt.Errorf("failed to execute repo query: %w ", err)
147
+
}
148
+
149
+
inClause := strings.TrimSuffix(strings.Repeat("?, ", len(repoMap)), ", ")
150
+
args = make([]any, len(repoMap))
151
+
for _, r := range repoMap {
152
+
args = append(args, r.RepoAt())
153
+
}
154
+
155
+
starCountQuery := fmt.Sprintf(
156
+
`select
157
+
repo_at, count(1)
158
+
from stars
159
+
where repo_at in (%s)
160
+
group by repo_at`,
161
+
inClause,
162
+
)
163
+
rows, err = e.Query(starCountQuery, args...)
164
+
if err != nil {
165
+
return nil, fmt.Errorf("failed to execute star-count query: %w ", err)
166
+
}
167
+
for rows.Next() {
168
+
var repoat string
169
+
var count int
170
+
if err := rows.Scan(&repoat, &count); err != nil {
171
+
continue
172
+
}
173
+
if r, ok := repoMap[syntax.ATURI(repoat)]; ok {
174
+
r.RepoStats.StarCount = count
175
+
}
176
+
}
177
+
if err = rows.Err(); err != nil {
178
+
return nil, fmt.Errorf("failed to execute star-count query: %w ", err)
179
+
}
180
+
181
+
issueCountQuery := fmt.Sprintf(
182
+
`select
183
+
repo_at,
184
+
count(case when open = 1 then 1 end) as open_count,
185
+
count(case when open = 0 then 1 end) as closed_count
186
+
from issues
187
+
where repo_at in (%s)
188
+
group by repo_at`,
189
+
inClause,
190
+
)
191
+
rows, err = e.Query(issueCountQuery, args...)
192
+
if err != nil {
193
+
return nil, fmt.Errorf("failed to execute issue-count query: %w ", err)
194
+
}
195
+
for rows.Next() {
196
+
var repoat string
197
+
var open, closed int
198
+
if err := rows.Scan(&repoat, &open, &closed); err != nil {
199
+
continue
200
+
}
201
+
if r, ok := repoMap[syntax.ATURI(repoat)]; ok {
202
+
r.RepoStats.IssueCount.Open = open
203
+
r.RepoStats.IssueCount.Closed = closed
204
+
}
205
+
}
206
+
if err = rows.Err(); err != nil {
207
+
return nil, fmt.Errorf("failed to execute issue-count query: %w ", err)
208
+
}
209
+
210
+
pullCountQuery := fmt.Sprintf(
211
+
`select
212
+
repo_at,
213
+
count(case when state = ? then 1 end) as open_count,
214
+
count(case when state = ? then 1 end) as merged_count,
215
+
count(case when state = ? then 1 end) as closed_count,
216
+
count(case when state = ? then 1 end) as deleted_count
217
+
from pulls
218
+
where repo_at in (%s)
219
+
group by repo_at`,
220
+
inClause,
221
+
)
222
+
args = append([]any{
223
+
PullOpen,
224
+
PullMerged,
225
+
PullClosed,
226
+
PullDeleted,
227
+
}, args...)
228
+
rows, err = e.Query(
229
+
pullCountQuery,
230
+
args...,
231
+
)
232
+
if err != nil {
233
+
return nil, fmt.Errorf("failed to execute pulls-count query: %w ", err)
234
+
}
235
+
for rows.Next() {
236
+
var repoat string
237
+
var open, merged, closed, deleted int
238
+
if err := rows.Scan(&repoat, &open, &merged, &closed, &deleted); err != nil {
239
+
continue
240
+
}
241
+
if r, ok := repoMap[syntax.ATURI(repoat)]; ok {
242
+
r.RepoStats.PullCount.Open = open
243
+
r.RepoStats.PullCount.Merged = merged
244
+
r.RepoStats.PullCount.Closed = closed
245
+
r.RepoStats.PullCount.Deleted = deleted
246
+
}
247
+
}
248
+
if err = rows.Err(); err != nil {
249
+
return nil, fmt.Errorf("failed to execute pulls-count query: %w ", err)
250
+
}
251
+
252
+
var repos []Repo
253
+
for _, r := range repoMap {
254
+
repos = append(repos, r)
68
255
}
69
256
70
257
return repos, nil
+13
-2
appview/pages/pages.go
+13
-2
appview/pages/pages.go
···
301
301
}
302
302
303
303
type SpindleListingParams struct {
304
+
db.Spindle
305
+
}
306
+
307
+
func (p *Pages) SpindleListing(w io.Writer, params SpindleListingParams) error {
308
+
return p.executePlain("spindles/fragments/spindleListing", w, params)
309
+
}
310
+
311
+
type SpindleDashboardParams struct {
304
312
LoggedInUser *oauth.User
305
313
Spindle db.Spindle
314
+
Members []string
315
+
Repos map[string][]db.Repo
316
+
DidHandleMap map[string]string
306
317
}
307
318
308
-
func (p *Pages) SpindleListing(w io.Writer, params SpindleListingParams) error {
309
-
return p.execute("spindles/fragments/spindleListing", w, params)
319
+
func (p *Pages) SpindleDashboard(w io.Writer, params SpindleDashboardParams) error {
320
+
return p.execute("spindles/dashboard", w, params)
310
321
}
311
322
312
323
type NewRepoParams struct {
+119
appview/pages/templates/spindles/dashboard.html
+119
appview/pages/templates/spindles/dashboard.html
···
1
+
{{ define "title" }}{{.Spindle.Instance}} · spindles{{ end }}
2
+
3
+
{{ define "content" }}
4
+
<div class="px-6 py-4">
5
+
<div class="flex justify-between items-center">
6
+
<h1 class="text-xl font-bold dark:text-white">{{ .Spindle.Instance }}</h1>
7
+
<div id="right-side" class="flex gap-2">
8
+
{{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2" }}
9
+
{{ $isOwner := and .LoggedInUser (eq .LoggedInUser.Did .Spindle.Owner) }}
10
+
{{ if .Spindle.Verified }}
11
+
<span class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 {{$style}}">{{ i "shield-check" "w-4 h-4" }} verified</span>
12
+
{{ if $isOwner }}
13
+
{{ template "spindles/fragments/addMemberModal" .Spindle }}
14
+
{{ end }}
15
+
{{ else }}
16
+
<span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">{{ i "shield-off" "w-4 h-4" }} unverified</span>
17
+
{{ if $isOwner }}
18
+
{{ block "retryButton" .Spindle }} {{ end }}
19
+
{{ end }}
20
+
{{ end }}
21
+
22
+
{{ if $isOwner }}
23
+
{{ block "deleteButton" .Spindle }} {{ end }}
24
+
{{ end }}
25
+
</div>
26
+
</div>
27
+
<div id="operation-error" class="dark:text-red-400"></div>
28
+
</div>
29
+
30
+
{{ if .Members }}
31
+
<section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white">
32
+
<div class="flex flex-col gap-2">
33
+
{{ block "member" . }} {{ end }}
34
+
</div>
35
+
</section>
36
+
{{ end }}
37
+
{{ end }}
38
+
39
+
40
+
{{ define "member" }}
41
+
{{ range .Members }}
42
+
<div>
43
+
<div class="flex justify-between items-center">
44
+
<div class="flex items-center gap-2">
45
+
{{ i "user" "size-4" }}
46
+
{{ $user := index $.DidHandleMap . }}
47
+
<a href="/{{ $user }}">{{ $user }}</a>
48
+
</div>
49
+
{{ if ne $.LoggedInUser.Did . }}
50
+
{{ block "removeMemberButton" (list $ . ) }} {{ end }}
51
+
{{ end }}
52
+
</div>
53
+
<div class="ml-2 pl-2 pt-2 border-l border-gray-200 dark:border-gray-700">
54
+
{{ $repos := index $.Repos . }}
55
+
{{ range $repos }}
56
+
<div class="flex gap-2 items-center">
57
+
{{ i "book-marked" "size-4" }}
58
+
<a href="/{{ .Did }}/{{ .Name }}">
59
+
{{ .Name }}
60
+
</a>
61
+
</div>
62
+
{{ else }}
63
+
<div class="text-gray-500 dark:text-gray-400">
64
+
No repositories configured yet.
65
+
</div>
66
+
{{ end }}
67
+
</div>
68
+
</div>
69
+
{{ end }}
70
+
{{ end }}
71
+
72
+
{{ define "deleteButton" }}
73
+
<button
74
+
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
75
+
title="Delete spindle"
76
+
hx-delete="/spindles/{{ .Instance }}"
77
+
hx-swap="outerHTML"
78
+
hx-confirm="Are you sure you want to delete the spindle '{{ .Instance }}'?"
79
+
hx-headers='{"shouldRedirect": "true"}'
80
+
>
81
+
{{ i "trash-2" "w-5 h-5" }}
82
+
<span class="hidden md:inline">delete</span>
83
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
84
+
</button>
85
+
{{ end }}
86
+
87
+
88
+
{{ define "retryButton" }}
89
+
<button
90
+
class="btn gap-2 group"
91
+
title="Retry spindle verification"
92
+
hx-post="/spindles/{{ .Instance }}/retry"
93
+
hx-swap="none"
94
+
hx-headers='{"shouldRefresh": "true"}'
95
+
>
96
+
{{ i "rotate-ccw" "w-5 h-5" }}
97
+
<span class="hidden md:inline">retry</span>
98
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
99
+
</button>
100
+
{{ end }}
101
+
102
+
103
+
{{ define "removeMemberButton" }}
104
+
{{ $root := index . 0 }}
105
+
{{ $member := index . 1 }}
106
+
<button
107
+
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group"
108
+
title="Remove member"
109
+
hx-post="/spindles/{{ $root.Spindle.Instance }}/remove"
110
+
hx-swap="none"
111
+
hx-vals='{"member": "{{$member}}" }'
112
+
hx-confirm="Are you sure you want to remove {{ index $root.DidHandleMap $member }} from this instance?"
113
+
>
114
+
{{ i "user-minus" "w-4 h-4" }}
115
+
remove
116
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
117
+
</button>
118
+
{{ end }}
119
+
+57
appview/pages/templates/spindles/fragments/addMemberModal.html
+57
appview/pages/templates/spindles/fragments/addMemberModal.html
···
1
+
{{ define "spindles/fragments/addMemberModal" }}
2
+
<button
3
+
class="btn gap-2 group"
4
+
title="Add member to this spindle"
5
+
popovertarget="add-member-{{ .Instance }}"
6
+
popovertargetaction="toggle"
7
+
>
8
+
{{ i "user-plus" "w-5 h-5" }}
9
+
<span class="hidden md:inline">add member</span>
10
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
11
+
</button>
12
+
13
+
<div
14
+
id="add-member-{{ .Instance }}"
15
+
popover
16
+
class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded drop-shadow dark:text-white">
17
+
{{ block "addMemberPopover" . }} {{ end }}
18
+
</div>
19
+
{{ end }}
20
+
21
+
{{ define "addMemberPopover" }}
22
+
<form
23
+
hx-post="/spindles/{{ .Instance }}/add"
24
+
hx-indicator="#spinner"
25
+
hx-swap="none"
26
+
class="flex flex-col gap-2"
27
+
>
28
+
<label for="member-did-{{ .Id }}" class="uppercase p-0">
29
+
ADD MEMBER
30
+
</label>
31
+
<p class="text-sm text-gray-500 dark:text-gray-400">Members can register repositories and run workflows on this spindle.</p>
32
+
<input
33
+
type="text"
34
+
id="member-did-{{ .Id }}"
35
+
name="member"
36
+
required
37
+
placeholder="@foo.bsky.social"
38
+
/>
39
+
<div class="flex gap-2 pt-2">
40
+
<button
41
+
type="button"
42
+
popovertarget="add-member-{{ .Instance }}"
43
+
popovertargetaction="hide"
44
+
class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
45
+
>
46
+
{{ i "x" "size-4" }} cancel
47
+
</button>
48
+
<button type="submit" class="btn w-1/2 flex items-center">
49
+
<span class="inline-flex gap-2 items-center">{{ i "user-plus" "size-4" }} add</span>
50
+
<span id="spinner" class="group">
51
+
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
52
+
</span>
53
+
</button>
54
+
</div>
55
+
<div id="add-member-error-{{ .Id }}" class="text-red-500 dark:text-red-400"></div>
56
+
</form>
57
+
{{ end }}
+22
-3
appview/pages/templates/spindles/fragments/spindleListing.html
+22
-3
appview/pages/templates/spindles/fragments/spindleListing.html
···
1
1
{{ define "spindles/fragments/spindleListing" }}
2
-
<div id="spindle-{{.Id}}" class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700">
3
-
<div id="left-side" class="flex items-center gap-2 min-w-0 max-w-[60%]">
2
+
<div id="spindle-{{.Id}}" class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700">
3
+
{{ block "leftSide" . }} {{ end }}
4
+
{{ block "rightSide" . }} {{ end }}
5
+
</div>
6
+
{{ end }}
7
+
8
+
{{ define "leftSide" }}
9
+
{{ if .Verified }}
10
+
<a href="/spindles/{{ .Instance }}" class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]">
11
+
{{ i "hard-drive" "w-4 h-4" }}
12
+
{{ .Instance }}
13
+
<span class="text-gray-500">
14
+
{{ .Created | shortTimeFmt }} ago
15
+
</span>
16
+
</a>
17
+
{{ else }}
18
+
<div class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]">
4
19
{{ i "hard-drive" "w-4 h-4" }}
5
20
{{ .Instance }}
6
21
<span class="text-gray-500">
7
22
{{ .Created | shortTimeFmt }} ago
8
23
</span>
9
24
</div>
25
+
{{ end }}
26
+
{{ end }}
27
+
28
+
{{ define "rightSide" }}
10
29
<div id="right-side" class="flex gap-2">
11
30
{{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2 text-sm" }}
12
31
{{ if .Verified }}
13
32
<span class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 {{$style}}">{{ i "shield-check" "w-4 h-4" }} verified</span>
33
+
{{ template "spindles/fragments/addMemberModal" . }}
14
34
{{ else }}
15
35
<span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">{{ i "shield-off" "w-4 h-4" }} unverified</span>
16
36
{{ block "retryButton" . }} {{ end }}
17
37
{{ end }}
18
38
{{ block "deleteButton" . }} {{ end }}
19
39
</div>
20
-
</div>
21
40
{{ end }}
22
41
23
42
{{ define "deleteButton" }}
+4
-3
appview/pages/templates/spindles/index.html
+4
-3
appview/pages/templates/spindles/index.html
···
25
25
</div>
26
26
{{ end }}
27
27
</div>
28
-
<div id="operation-error" class="dark:text-red-400"></div>
28
+
<div id="operation-error" class="text-red-500 dark:text-red-400"></div>
29
29
</section>
30
30
{{ end }}
31
31
···
36
36
<form
37
37
hx-post="/spindles/register"
38
38
class="max-w-2xl mb-2 space-y-4"
39
-
hx-indicator="#register-spinner"
39
+
hx-indicator="#register-button"
40
40
hx-swap="none"
41
41
>
42
42
<div class="flex gap-2">
···
50
50
>
51
51
<button
52
52
type="submit"
53
+
id="register-button"
53
54
class="btn rounded flex items-center py-2 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 group"
54
55
>
55
56
<span class="inline-flex items-center gap-2">
56
57
{{ i "plus" "w-4 h-4" }}
57
58
register
58
59
</span>
59
-
<span id="register-spinner" class="pl-2 hidden group-[.htmx-request]:inline">
60
+
<span class="pl-2 hidden group-[.htmx-request]:inline">
60
61
{{ i "loader-circle" "w-4 h-4 animate-spin" }}
61
62
</span>
62
63
</button>
+7
-6
appview/state/router.go
+7
-6
appview/state/router.go
···
178
178
logger := log.New("spindles")
179
179
180
180
spindles := &spindles.Spindles{
181
-
Db: s.db,
182
-
OAuth: s.oauth,
183
-
Pages: s.pages,
184
-
Config: s.config,
185
-
Enforcer: s.enforcer,
186
-
Logger: logger,
181
+
Db: s.db,
182
+
OAuth: s.oauth,
183
+
Pages: s.pages,
184
+
Config: s.config,
185
+
Enforcer: s.enforcer,
186
+
IdResolver: s.idResolver,
187
+
Logger: logger,
187
188
}
188
189
189
190
return spindles.Router()