tangled
alpha
login
or
join now
yoten.app
/
yoten
17
fork
atom
Yōten: A social tracker for your language learning journey built on the atproto.
17
fork
atom
overview
issues
pulls
pipelines
feat: add / delete comments
brookjeynes.dev
5 months ago
a8a2c290
6e6f4201
verified
This commit was signed with the committer's
known signature
.
brookjeynes.dev
SSH Key Fingerprint:
SHA256:N3n3PCBSiXfS6EHlmGdx+LMEruJMj6FS2hqaXyfsw0s=
+510
-25
11 changed files
expand all
collapse all
unified
split
internal
clients
posthog
posthog.go
consumer
ingester.go
db
comment.go
server
app.go
handlers
comment.go
router.go
study-session.go
views
partials
comment.templ
discussion.templ
partials.go
study-session.templ
+4
internal/clients/posthog/posthog.go
···
19
19
ReactionRecordCreatedEvent string = "reaction-record-created"
20
20
ReactionRecordDeletedEvent string = "reaction-record-deleted"
21
21
22
22
+
CommentRecordCreatedEvent string = "comment-record-created"
23
23
+
CommentRecordDeletedEvent string = "comment-record-deleted"
24
24
+
CommentRecordEditedEvent string = "comment-record-edited"
25
25
+
22
26
ActivityDefRecordFirstCreated string = "activity-def-record-first-created"
23
27
ActivityDefRecordCreatedEvent string = "activity-def-record-created"
24
28
ActivityDefRecordDeletedEvent string = "activity-def-record-deleted"
+69
internal/consumer/ingester.go
···
50
50
err = i.ingestFollow(e)
51
51
case yoten.FeedReactionNSID:
52
52
err = i.ingestReaction(e)
53
53
+
case yoten.FeedCommentNSID:
54
54
+
err = i.ingestComment(e)
53
55
}
54
56
}
55
57
if err != nil {
···
561
563
562
564
return nil
563
565
}
566
566
+
567
567
+
func (i *Ingester) ingestComment(e *models.Event) error {
568
568
+
var err error
569
569
+
did := e.Did
570
570
+
571
571
+
switch e.Commit.Operation {
572
572
+
case models.CommitOperationCreate, models.CommitOperationUpdate:
573
573
+
raw := json.RawMessage(e.Commit.Record)
574
574
+
record := yoten.FeedComment{}
575
575
+
err = json.Unmarshal(raw, &record)
576
576
+
if err != nil {
577
577
+
return fmt.Errorf("invalid record: %w", err)
578
578
+
}
579
579
+
580
580
+
subject := record.Subject
581
581
+
subjectUri, err := syntax.ParseATURI(subject)
582
582
+
if err != nil {
583
583
+
return fmt.Errorf("failed to parse study session at-uri: %w", err)
584
584
+
}
585
585
+
586
586
+
body := record.Body
587
587
+
if len(body) == 0 {
588
588
+
return fmt.Errorf("invalid body: length cannot be 0")
589
589
+
}
590
590
+
591
591
+
createdAt, err := time.Parse(time.RFC3339, record.CreatedAt)
592
592
+
if err != nil {
593
593
+
return fmt.Errorf("invalid createdAt format: %w", err)
594
594
+
}
595
595
+
596
596
+
ddb, ok := i.Db.Execer.(*db.DB)
597
597
+
if !ok {
598
598
+
return fmt.Errorf("failed to index resource record: %w", err)
599
599
+
}
600
600
+
601
601
+
tx, err := ddb.Begin()
602
602
+
if err != nil {
603
603
+
return fmt.Errorf("failed to start transaction: %w", err)
604
604
+
}
605
605
+
606
606
+
// TODO: Parse reply
607
607
+
608
608
+
comment := db.Comment{
609
609
+
Did: did,
610
610
+
Rkey: e.Commit.RKey,
611
611
+
StudySessionUri: subjectUri,
612
612
+
Body: body,
613
613
+
CreatedAt: createdAt,
614
614
+
}
615
615
+
616
616
+
log.Println("upserting comment from pds request")
617
617
+
err = db.UpsertComment(i.Db, comment)
618
618
+
if err != nil {
619
619
+
tx.Rollback()
620
620
+
return fmt.Errorf("failed to upsert comment record: %w", err)
621
621
+
}
622
622
+
return tx.Commit()
623
623
+
case models.CommitOperationDelete:
624
624
+
log.Println("deleting comment from pds request")
625
625
+
err = db.DeleteCommentByRkey(i.Db, did, e.Commit.RKey)
626
626
+
}
627
627
+
if err != nil {
628
628
+
return fmt.Errorf("failed to %s follow record: %w", e.Commit.Operation, err)
629
629
+
}
630
630
+
631
631
+
return nil
632
632
+
}
+65
-6
internal/db/comment.go
···
1
1
package db
2
2
3
3
import (
4
4
+
"database/sql"
4
5
"fmt"
5
6
"time"
6
7
···
9
10
"yoten.app/internal/types"
10
11
)
11
12
13
13
+
type CommentFeedItem struct {
14
14
+
CommentWithBskyProfile
15
15
+
Replies []CommentWithBskyProfile
16
16
+
}
17
17
+
12
18
type CommentWithBskyProfile struct {
13
19
Comment
14
14
-
BskyProfile types.BskyProfile
20
20
+
ProfileLevel int
21
21
+
ProfileDisplayName string
22
22
+
BskyProfile types.BskyProfile
15
23
}
16
24
17
25
type Comment struct {
···
29
37
return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", c.Did, yoten.FeedCommentNSID, c.Rkey))
30
38
}
31
39
40
40
+
func (c Comment) GetRkey() string {
41
41
+
return c.Rkey
42
42
+
}
43
43
+
32
44
func UpsertComment(e Execer, comment Comment) error {
45
45
+
var parentCommentUri *string
46
46
+
if comment.ParentCommentUri != nil {
47
47
+
parentCommentUri = ToPtr(comment.ParentCommentUri.String())
48
48
+
} else {
49
49
+
parentCommentUri = nil
50
50
+
}
51
51
+
33
52
_, err := e.Exec(`
34
34
-
insert into study_sessions (
35
35
-
id,
53
53
+
insert into comments (
36
54
did,
37
55
rkey,
38
56
study_session_uri,
···
41
59
is_deleted,
42
60
created_at
43
61
)
44
44
-
values ()
62
62
+
values (?, ?, ?, ?, ?, ?, ?)
45
63
on conflict(did, rkey) do update set
46
64
is_deleted = excluded.is_deleted,
47
65
body = excluded.body`,
48
48
-
comment.ID,
49
66
comment.Did,
50
67
comment.Rkey,
51
68
comment.StudySessionUri.String(),
52
52
-
comment.ParentCommentUri.String(),
69
69
+
parentCommentUri,
53
70
comment.Body,
54
71
comment.IsDeleted,
55
72
comment.CreatedAt.Format(time.RFC3339),
···
60
77
61
78
return nil
62
79
}
80
80
+
81
81
+
func DeleteCommentByRkey(e Execer, did string, rkey string) error {
82
82
+
_, err := e.Exec(`
83
83
+
update comments
84
84
+
set is_deleted = ?
85
85
+
where did = ? and rkey = ?`,
86
86
+
Deleted, did, rkey,
87
87
+
)
88
88
+
return err
89
89
+
}
90
90
+
91
91
+
func GetCommentByRkey(e Execer, did string, rkey string) (Comment, error) {
92
92
+
comment := Comment{}
93
93
+
var parentCommentUri sql.NullString
94
94
+
var studySessionUriStr string
95
95
+
var createdAtStr string
96
96
+
97
97
+
err := e.QueryRow(`
98
98
+
select id, did, rkey, study_session_uri, parent_comment_uri, body, is_deleted, created_at
99
99
+
from comments
100
100
+
where did is ? and rkey = ?`,
101
101
+
did, rkey,
102
102
+
).Scan(&comment.ID, &comment.Did, &comment.Rkey, &studySessionUriStr, &parentCommentUri, &comment.Body, &comment.IsDeleted, &createdAtStr)
103
103
+
if err != nil {
104
104
+
if err == sql.ErrNoRows {
105
105
+
return Comment{}, fmt.Errorf("comment does not exist")
106
106
+
}
107
107
+
return Comment{}, err
108
108
+
}
109
109
+
110
110
+
comment.CreatedAt, err = time.Parse(time.RFC3339, createdAtStr)
111
111
+
if err != nil {
112
112
+
return Comment{}, fmt.Errorf("failed to parse created at string '%s': %w", createdAtStr, err)
113
113
+
}
114
114
+
115
115
+
comment.StudySessionUri, err = syntax.ParseATURI(studySessionUriStr)
116
116
+
if err != nil {
117
117
+
return Comment{}, fmt.Errorf("failed to parse study session at-uri: %w", err)
118
118
+
}
119
119
+
120
120
+
return comment, nil
121
121
+
}
+3
-1
internal/server/app.go
···
62
62
jc, err := consumer.NewJetstreamClient(
63
63
config.Jetstream.Endpoint,
64
64
"yoten",
65
65
-
[]string{yoten.ActorProfileNSID,
65
65
+
[]string{
66
66
+
yoten.ActorProfileNSID,
66
67
yoten.FeedSessionNSID,
67
68
yoten.FeedResourceNSID,
69
69
+
yoten.FeedCommentNSID,
68
70
yoten.FeedReactionNSID,
69
71
yoten.ActivityDefNSID,
70
72
yoten.GraphFollowNSID,
+205
internal/server/handlers/comment.go
···
1
1
+
package handlers
2
2
+
3
3
+
import (
4
4
+
"log"
5
5
+
"net/http"
6
6
+
"time"
7
7
+
8
8
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
9
9
+
"github.com/bluesky-social/indigo/atproto/syntax"
10
10
+
lexutil "github.com/bluesky-social/indigo/lex/util"
11
11
+
"github.com/go-chi/chi/v5"
12
12
+
"github.com/posthog/posthog-go"
13
13
+
"yoten.app/api/yoten"
14
14
+
"yoten.app/internal/atproto"
15
15
+
"yoten.app/internal/clients/bsky"
16
16
+
ph "yoten.app/internal/clients/posthog"
17
17
+
"yoten.app/internal/db"
18
18
+
"yoten.app/internal/server/htmx"
19
19
+
"yoten.app/internal/server/views/partials"
20
20
+
)
21
21
+
22
22
+
const (
23
23
+
PendingCommentCreation string = "pending_comment_creation"
24
24
+
PendingCommentDeletion string = "pending_comment_deletion"
25
25
+
)
26
26
+
27
27
+
func (h *Handler) HandleNewComment(w http.ResponseWriter, r *http.Request) {
28
28
+
client, err := h.Oauth.AuthorizedClient(r, w)
29
29
+
if err != nil {
30
30
+
log.Println("failed to get authorized client:", err)
31
31
+
htmx.HxRedirect(w, "/login")
32
32
+
return
33
33
+
}
34
34
+
35
35
+
user, err := bsky.GetUserWithBskyProfile(h.Oauth, r)
36
36
+
if err != nil {
37
37
+
log.Println("failed to get logged-in user:", err)
38
38
+
htmx.HxRedirect(w, "/login")
39
39
+
return
40
40
+
}
41
41
+
42
42
+
profile, err := db.GetProfile(h.Db, user.Did)
43
43
+
if err != nil {
44
44
+
log.Println("failed to get logged-in user:", err)
45
45
+
htmx.HxRedirect(w, "/login")
46
46
+
return
47
47
+
}
48
48
+
49
49
+
err = r.ParseForm()
50
50
+
if err != nil {
51
51
+
log.Println("invalid comment form:", err)
52
52
+
htmx.HxError(w, http.StatusBadRequest, "Unable to process comment, please try again later.")
53
53
+
return
54
54
+
}
55
55
+
56
56
+
commentBody := r.FormValue("comment")
57
57
+
if len(commentBody) == 0 {
58
58
+
log.Println("invalid comment form: missing comment body")
59
59
+
htmx.HxError(w, http.StatusBadRequest, "Comment cannot be empty.")
60
60
+
return
61
61
+
}
62
62
+
63
63
+
studySessionUri := r.FormValue("study_session_uri")
64
64
+
if len(studySessionUri) == 0 {
65
65
+
log.Println("invalid comment form: missing study session Uri")
66
66
+
htmx.HxError(w, http.StatusBadRequest, "Unable to create comment, please try again later.")
67
67
+
return
68
68
+
}
69
69
+
70
70
+
newComment := db.Comment{
71
71
+
Rkey: atproto.TID(),
72
72
+
Did: user.Did,
73
73
+
StudySessionUri: syntax.ATURI(studySessionUri),
74
74
+
Body: commentBody,
75
75
+
CreatedAt: time.Now(),
76
76
+
}
77
77
+
78
78
+
newCommentRecord := yoten.FeedComment{
79
79
+
LexiconTypeID: yoten.FeedCommentNSID,
80
80
+
Body: newComment.Body,
81
81
+
Subject: newComment.StudySessionUri.String(),
82
82
+
CreatedAt: newComment.CreatedAt.Format(time.RFC3339),
83
83
+
}
84
84
+
85
85
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
86
86
+
Collection: yoten.FeedCommentNSID,
87
87
+
Repo: newComment.Did,
88
88
+
Rkey: newComment.Rkey,
89
89
+
Record: &lexutil.LexiconTypeDecoder{
90
90
+
Val: &newCommentRecord,
91
91
+
},
92
92
+
})
93
93
+
if err != nil {
94
94
+
log.Println("failed to create comment record:", err)
95
95
+
htmx.HxError(w, http.StatusInternalServerError, "Failed to create comment, try again later.")
96
96
+
return
97
97
+
}
98
98
+
99
99
+
err = SavePendingCreate(h, w, r, PendingCommentCreation, newComment)
100
100
+
if err != nil {
101
101
+
log.Printf("failed to save yoten-session to add pending comment creation: %v", err)
102
102
+
}
103
103
+
104
104
+
if !h.Config.Core.Dev {
105
105
+
event := posthog.Capture{
106
106
+
DistinctId: user.Did,
107
107
+
Event: ph.CommentRecordDeletedEvent,
108
108
+
Properties: posthog.NewProperties().
109
109
+
Set("is_reply", newComment.ParentCommentUri != nil).
110
110
+
Set("character_count", len(newComment.Body)).
111
111
+
Set("study_session_uri", newComment.StudySessionUri.String()),
112
112
+
}
113
113
+
114
114
+
if newComment.ParentCommentUri != nil {
115
115
+
event.Properties.Set("parent_comment_uri", *newComment.ParentCommentUri)
116
116
+
}
117
117
+
118
118
+
err = h.Posthog.Enqueue(event)
119
119
+
if err != nil {
120
120
+
log.Println("failed to enqueue posthog event:", err)
121
121
+
}
122
122
+
}
123
123
+
124
124
+
partials.Comment(partials.CommentProps{
125
125
+
Comment: db.CommentFeedItem{
126
126
+
CommentWithBskyProfile: db.CommentWithBskyProfile{
127
127
+
Comment: newComment,
128
128
+
ProfileLevel: profile.Level,
129
129
+
ProfileDisplayName: profile.DisplayName,
130
130
+
BskyProfile: user.BskyProfile,
131
131
+
},
132
132
+
Replies: []db.CommentWithBskyProfile{},
133
133
+
},
134
134
+
}).Render(r.Context(), w)
135
135
+
}
136
136
+
137
137
+
func (h *Handler) HandleDeleteComment(w http.ResponseWriter, r *http.Request) {
138
138
+
user := h.Oauth.GetUser(r)
139
139
+
if user == nil {
140
140
+
log.Println("failed to get logged-in user")
141
141
+
htmx.HxRedirect(w, "/login")
142
142
+
return
143
143
+
}
144
144
+
client, err := h.Oauth.AuthorizedClient(r, w)
145
145
+
if err != nil {
146
146
+
log.Println("failed to get authorized client:", err)
147
147
+
htmx.HxRedirect(w, "/login")
148
148
+
return
149
149
+
}
150
150
+
151
151
+
switch r.Method {
152
152
+
case http.MethodDelete:
153
153
+
rkey := chi.URLParam(r, "rkey")
154
154
+
comment, err := db.GetCommentByRkey(h.Db, user.Did, rkey)
155
155
+
if err != nil {
156
156
+
log.Println("failed to get comment from db:", err)
157
157
+
htmx.HxError(w, http.StatusInternalServerError, "Failed to delete comment, try again later.")
158
158
+
return
159
159
+
}
160
160
+
161
161
+
if user.Did != comment.Did {
162
162
+
log.Printf("user '%s' does not own record '%s'", user.Did, rkey)
163
163
+
htmx.HxError(w, http.StatusUnauthorized, "You do not have permissions to delete this comment.")
164
164
+
return
165
165
+
}
166
166
+
167
167
+
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
168
168
+
Collection: yoten.FeedCommentNSID,
169
169
+
Repo: user.Did,
170
170
+
Rkey: comment.Rkey,
171
171
+
})
172
172
+
if err != nil {
173
173
+
log.Println("failed to delete comment from PDS:", err)
174
174
+
htmx.HxError(w, http.StatusInternalServerError, "Failed to delete comment, try again later.")
175
175
+
return
176
176
+
}
177
177
+
178
178
+
err = SavePendingDelete(h, w, r, PendingCommentDeletion, comment)
179
179
+
if err != nil {
180
180
+
log.Printf("failed to save yoten-session to add pending comment deletion: %v", err)
181
181
+
}
182
182
+
183
183
+
if !h.Config.Core.Dev {
184
184
+
event := posthog.Capture{
185
185
+
DistinctId: user.Did,
186
186
+
Event: ph.CommentRecordDeletedEvent,
187
187
+
Properties: posthog.NewProperties().
188
188
+
Set("is_reply", comment.ParentCommentUri != nil).
189
189
+
Set("character_count", len(comment.Body)).
190
190
+
Set("study_session_uri", comment.StudySessionUri.String()),
191
191
+
}
192
192
+
193
193
+
if comment.ParentCommentUri != nil {
194
194
+
event.Properties.Set("parent_comment_uri", *comment.ParentCommentUri)
195
195
+
}
196
196
+
197
197
+
err = h.Posthog.Enqueue(event)
198
198
+
if err != nil {
199
199
+
log.Println("failed to enqueue posthog event:", err)
200
200
+
}
201
201
+
}
202
202
+
203
203
+
w.WriteHeader(http.StatusOK)
204
204
+
}
205
205
+
}
+10
-1
internal/server/handlers/router.go
···
86
86
r.Delete("/{rkey}", h.HandleDeleteResource)
87
87
})
88
88
89
89
+
r.Route("/comment", func(r chi.Router) {
90
90
+
r.Use(middleware.AuthMiddleware(h.Oauth))
91
91
+
r.Post("/new", h.HandleNewComment)
92
92
+
r.Delete("/{rkey}", h.HandleDeleteComment)
93
93
+
})
94
94
+
89
95
r.Route("/activity", func(r chi.Router) {
90
96
r.Use(middleware.AuthMiddleware(h.Oauth))
91
97
r.Get("/new", h.HandleNewActivityPage)
···
129
135
r.Route("/{user}", func(r chi.Router) {
130
136
r.Get("/", h.HandleProfilePage)
131
137
r.Get("/feed", h.HandleProfileFeed)
132
132
-
r.Get("/session/{rkey}", h.HandleStudySessionPage)
138
138
+
r.Route("/session/{rkey}", func(r chi.Router) {
139
139
+
r.Get("/", h.HandleStudySessionPage)
140
140
+
r.Get("/feed", h.HandleStudySessionPageCommentFeed)
141
141
+
})
133
142
})
134
143
})
135
144
+3
internal/server/handlers/study-session.go
···
685
685
},
686
686
}).Render(r.Context(), w)
687
687
}
688
688
+
689
689
+
func (h *Handler) HandleStudySessionPageCommentFeed(w http.ResponseWriter, r *http.Request) {
690
690
+
}
+106
internal/server/views/partials/comment.templ
···
1
1
+
package partials
2
2
+
3
3
+
import (
4
4
+
"fmt"
5
5
+
"yoten.app/internal/db"
6
6
+
)
7
7
+
8
8
+
templ Reply(reply db.CommentWithBskyProfile) {
9
9
+
{{ replyId := SanitiseHtmlId(fmt.Sprintf("reply-%s-%s", reply.Did, reply.Rkey)) }}
10
10
+
<div id={ replyId } class="flex flex-col gap-3 pl-4 py-2 border-l-2 border-gray-200">
11
11
+
<div class="flex items-center gap-3">
12
12
+
if reply.BskyProfile.Avatar == "" {
13
13
+
<div class="flex items-center justify-center w-10 h-10 rounded-full bg-primary">
14
14
+
<i class="w-7 h-7" data-lucide="user"></i>
15
15
+
</div>
16
16
+
} else {
17
17
+
<img src={ reply.BskyProfile.Avatar } class="w-10 h-10 rounded-full"/>
18
18
+
}
19
19
+
<div>
20
20
+
<div class="flex items-center gap-2">
21
21
+
<a href={ templ.URL(fmt.Sprintf("/@%s", reply.Did)) } class="font-semibold">
22
22
+
{ reply.ProfileDisplayName }
23
23
+
</a>
24
24
+
<p class="pill pill-secondary px-2 py-0.5 h-fit items-center justify-center gap-1 w-fit flex">
25
25
+
<i class="w-3.5 h-3.5" data-lucide="star"></i>
26
26
+
<span class="text-xs">{ reply.ProfileLevel }</span>
27
27
+
</p>
28
28
+
<span class="text-xs text-text-muted">{ reply.CreatedAt.Format("2006-01-02") }</span>
29
29
+
</div>
30
30
+
<p class="text-text-muted text-sm">@{ reply.BskyProfile.Handle }</p>
31
31
+
</div>
32
32
+
</div>
33
33
+
<p class="leading-relaxed">
34
34
+
{ reply.Body }
35
35
+
</p>
36
36
+
</div>
37
37
+
}
38
38
+
39
39
+
templ Comment(params CommentProps) {
40
40
+
{{ elementId := SanitiseHtmlId(fmt.Sprintf("comment-%s-%s", params.Comment.Did, params.Comment.Rkey)) }}
41
41
+
<div id={ elementId } class="flex flex-col gap-3" x-init="lucide.createIcons()">
42
42
+
<div class="flex items-center justify-between">
43
43
+
<div class="flex items-center gap-3">
44
44
+
if params.Comment.BskyProfile.Avatar == "" {
45
45
+
<div class="flex items-center justify-center w-10 h-10 rounded-full bg-primary">
46
46
+
<i class="w-7 h-7" data-lucide="user"></i>
47
47
+
</div>
48
48
+
} else {
49
49
+
<img src={ params.Comment.BskyProfile.Avatar } class="w-10 h-10 rounded-full"/>
50
50
+
}
51
51
+
<div>
52
52
+
<div class="flex items-center gap-2">
53
53
+
<a href={ templ.URL(fmt.Sprintf("/@%s", params.Comment.Did)) } class="font-semibold">
54
54
+
{ params.Comment.ProfileDisplayName }
55
55
+
</a>
56
56
+
<p class="pill pill-secondary px-2 py-0.5 h-fit items-center justify-center gap-1 w-fit flex">
57
57
+
<i class="w-3.5 h-3.5" data-lucide="star"></i>
58
58
+
<span class="text-xs">{ params.Comment.ProfileLevel }</span>
59
59
+
</p>
60
60
+
<span class="text-xs text-text-muted">{ params.Comment.CreatedAt.Format("2006-01-02") }</span>
61
61
+
</div>
62
62
+
<p class="text-text-muted text-sm">@{ params.Comment.BskyProfile.Handle }</p>
63
63
+
</div>
64
64
+
</div>
65
65
+
<details class="relative inline-block text-left">
66
66
+
<summary class="cursor-pointer list-none">
67
67
+
<div class="btn btn-muted p-2">
68
68
+
<i class="w-4 h-4 flex-shrink-0" data-lucide="ellipsis"></i>
69
69
+
</div>
70
70
+
</summary>
71
71
+
<div class="absolute flex flex-col right-0 mt-2 p-1 gap-1 rounded w-32 bg-bg-light border border-bg-dark">
72
72
+
<button id="edit-button" type="button" class="w-full">
73
73
+
<a
74
74
+
href={ templ.URL(fmt.Sprintf("/comment/edit/%s", params.Comment.Rkey)) }
75
75
+
class="text-base text-text flex items-center px-4 py-2 text-sm hover:bg-bg gap-2"
76
76
+
>
77
77
+
<i class="w-4 h-4" data-lucide="square-pen"></i>
78
78
+
Edit
79
79
+
</a>
80
80
+
</button>
81
81
+
<button
82
82
+
class="text-base text-red-600 cursor-pointer flex items-center px-4 py-2 text-sm hover:bg-bg gap-2 group"
83
83
+
type="button"
84
84
+
id="delete-button"
85
85
+
hx-disabled-elt="#delete-button,#edit-button"
86
86
+
hx-target={ "#" + elementId }
87
87
+
hx-swap="outerSelf"
88
88
+
hx-delete={ templ.URL(fmt.Sprintf("/comment/%s", params.Comment.Rkey)) }
89
89
+
>
90
90
+
<i class="w-4 h-4" data-lucide="trash-2"></i>
91
91
+
Delete
92
92
+
<i class="w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" data-lucide="loader-circle"></i>
93
93
+
</button>
94
94
+
</div>
95
95
+
</details>
96
96
+
</div>
97
97
+
<p class="leading-relaxed break-words">
98
98
+
{ params.Comment.Body }
99
99
+
</p>
100
100
+
<div class="flex flex-col mt-2">
101
101
+
for _, reply := range params.Comment.Replies {
102
102
+
@Reply(reply)
103
103
+
}
104
104
+
</div>
105
105
+
</div>
106
106
+
}
+36
-16
internal/server/views/partials/discussion.templ
···
6
6
<i class="w-5 h-5" data-lucide="message-square"></i>
7
7
<h1 class="text-2xl font-bold">Discussion</h1>
8
8
</div>
9
9
-
<div class="mt-2" x-data="{ text: '' }" x-init="text = $el.querySelector('textarea').value">
10
10
-
<textarea
11
11
-
x-model="text"
12
12
-
id="comment"
13
13
-
name="comment"
14
14
-
placeholder="Share your thoughts about this study session..."
15
15
-
class="input w-full"
16
16
-
maxLength="256"
17
17
-
rows="3"
18
18
-
></textarea>
19
19
-
<div class="flex justify-between mt-2">
20
20
-
<div class="text-sm text-text-muted">
21
21
-
<span x-text="text.length"></span> / 256
9
9
+
<form
10
10
+
hx-post="/comment/new"
11
11
+
hx-swap="afterbegin"
12
12
+
hx-target="#comments"
13
13
+
hx-disabled-elt="#post-comment-button"
14
14
+
@htmx:after-request="text = ''"
15
15
+
x-data="{ text: '' }"
16
16
+
x-init="text = $el.querySelector('textarea').value"
17
17
+
>
18
18
+
<input type="hidden" name="study_session_uri" value={ params.StudySessionUri }/>
19
19
+
<div class="mt-2">
20
20
+
<textarea
21
21
+
x-model="text"
22
22
+
id="comment"
23
23
+
name="comment"
24
24
+
placeholder="Share your thoughts about this study session..."
25
25
+
class="input w-full"
26
26
+
maxLength="256"
27
27
+
rows="3"
28
28
+
></textarea>
29
29
+
<div class="flex justify-between mt-2">
30
30
+
<div class="text-sm text-text-muted">
31
31
+
<span x-text="text.length"></span> / 256
32
32
+
</div>
33
33
+
<button
34
34
+
type="submit"
35
35
+
id="post-comment-button"
36
36
+
class="btn btn-primary w-fit"
37
37
+
>
38
38
+
Post Comment
39
39
+
</button>
22
40
</div>
23
23
-
<button class="btn btn-primary w-fit">
24
24
-
Post Comment
25
25
-
</button>
26
41
</div>
42
42
+
</form>
43
43
+
<div id="comments" class="flex flex-col gap-4 mt-2">
44
44
+
for _, comment := range params.Comments {
45
45
+
@Comment(CommentProps{Comment: comment})
46
46
+
}
27
47
</div>
28
48
</div>
29
49
}
+6
internal/server/views/partials/partials.go
···
220
220
}
221
221
222
222
type DiscussionProps struct {
223
223
+
Comments []db.CommentFeedItem
224
224
+
StudySessionUri string
225
225
+
}
226
226
+
227
227
+
type CommentProps struct {
228
228
+
Comment db.CommentFeedItem
223
229
}
+3
-1
internal/server/views/study-session.templ
···
14
14
DoesOwn: params.DoesOwn,
15
15
StudySession: params.StudySession,
16
16
})
17
17
-
@partials.Discussion(partials.DiscussionProps{})
17
17
+
@partials.Discussion(partials.DiscussionProps{
18
18
+
StudySessionUri: params.StudySession.StudySessionAt().String(),
19
19
+
})
18
20
</div>
19
21
}
20
22
}