Implement annotation editing and deletionUnverified
6017bda parent: 9681c7f modified
go.mod +1 -1 | @@ -10,6 +10,7 @@ require ( | ||
| 10 | 10 | github.com/go-chi/cors v1.2.2 |
| 11 | 11 | github.com/gorilla/websocket v1.5.3 |
| 12 | 12 | github.com/mattn/go-sqlite3 v1.14.22 |
| 13 | + gotest.tools/v3 v3.5.2 | |
| 13 | 14 | ) |
| 14 | 15 | |
| 15 | 16 | require ( |
| @@ -204,7 +205,6 @@ require ( | ||
| 204 | 205 | gopkg.in/ini.v1 v1.67.0 // indirect |
| 205 | 206 | gopkg.in/yaml.v2 v2.4.0 // indirect |
| 206 | 207 | gopkg.in/yaml.v3 v3.0.1 // indirect |
| 207 | - gotest.tools/v3 v3.5.2 // indirect | |
| 208 | 208 | honnef.co/go/tools v0.6.1 // indirect |
| 209 | 209 | mvdan.cc/gofumpt v0.7.0 // indirect |
| 210 | 210 | mvdan.cc/unparam v0.0.0-20240528143540-8a5130ca722f // indirect |
| @@ -10,6 +10,7 @@ require ( | |||
| 10 | github.com/go-chi/cors v1.2.2 | 10 | github.com/go-chi/cors v1.2.2 |
| 11 | github.com/gorilla/websocket v1.5.3 | 11 | github.com/gorilla/websocket v1.5.3 |
| 12 | github.com/mattn/go-sqlite3 v1.14.22 | 12 | github.com/mattn/go-sqlite3 v1.14.22 |
| 13 | + gotest.tools/v3 v3.5.2 | ||
| 13 | ) | 14 | ) |
| 14 | 15 | ||
| 15 | require ( | 16 | require ( |
| @@ -204,7 +205,6 @@ require ( | |||
| 204 | gopkg.in/ini.v1 v1.67.0 // indirect | 205 | gopkg.in/ini.v1 v1.67.0 // indirect |
| 205 | gopkg.in/yaml.v2 v2.4.0 // indirect | 206 | gopkg.in/yaml.v2 v2.4.0 // indirect |
| 206 | gopkg.in/yaml.v3 v3.0.1 // indirect | 207 | gopkg.in/yaml.v3 v3.0.1 // indirect |
| 207 | - gotest.tools/v3 v3.5.2 // indirect | ||
| 208 | honnef.co/go/tools v0.6.1 // indirect | 208 | honnef.co/go/tools v0.6.1 // indirect |
| 209 | mvdan.cc/gofumpt v0.7.0 // indirect | 209 | mvdan.cc/gofumpt v0.7.0 // indirect |
| 210 | mvdan.cc/unparam v0.0.0-20240528143540-8a5130ca722f // indirect | 210 | mvdan.cc/unparam v0.0.0-20240528143540-8a5130ca722f // indirect |
modified
internal/atproto/firehose.go +3 -2 | @@ -41,6 +41,7 @@ func NewFirehoseConsumer(relayURL string, handler FirehoseHandler, logger *slog. | ||
| 41 | 41 | "at.glean.subscription": true, |
| 42 | 42 | "at.glean.annotation": true, |
| 43 | 43 | "at.glean.like": true, |
| 44 | + // TODO: support at.margin.annotation as well (ref: https://tangled.org/did:plc:rgvlxa3ecwx3bfyzlrzrwtrs/issues/1) | |
| 44 | 45 | }, |
| 45 | 46 | } |
| 46 | 47 | } |
| @@ -127,8 +128,8 @@ func (fc *FirehoseConsumer) handleMessage(ctx context.Context, msg []byte) { | ||
| 127 | 128 | |
| 128 | 129 | func (fc *FirehoseConsumer) parseCommit(ctx context.Context, raw json.RawMessage) { |
| 129 | 130 | var commit struct { |
| 130 | - Did string `json:"did"` | |
| 131 | - Ops []struct { | |
| 131 | + Did string `json:"did"` | |
| 132 | + Ops []struct { | |
| 132 | 133 | Action string `json:"action"` |
| 133 | 134 | Path string `json:"path"` |
| 134 | 135 | CID json.RawMessage `json:"cid"` |
| @@ -41,6 +41,7 @@ func NewFirehoseConsumer(relayURL string, handler FirehoseHandler, logger *slog. | |||
| 41 | "at.glean.subscription": true, | 41 | "at.glean.subscription": true, |
| 42 | "at.glean.annotation": true, | 42 | "at.glean.annotation": true, |
| 43 | "at.glean.like": true, | 43 | "at.glean.like": true, |
| 44 | + // TODO: support at.margin.annotation as well (ref: https://tangled.org/did:plc:rgvlxa3ecwx3bfyzlrzrwtrs/issues/1) | ||
| 44 | }, | 45 | }, |
| 45 | } | 46 | } |
| 46 | } | 47 | } |
| @@ -127,8 +128,8 @@ func (fc *FirehoseConsumer) handleMessage(ctx context.Context, msg []byte) { | |||
| 127 | 128 | ||
| 128 | func (fc *FirehoseConsumer) parseCommit(ctx context.Context, raw json.RawMessage) { | 129 | func (fc *FirehoseConsumer) parseCommit(ctx context.Context, raw json.RawMessage) { |
| 129 | var commit struct { | 130 | var commit struct { |
| 130 | - Did string `json:"did"` | 131 | + Did string `json:"did"` |
| 131 | - Ops []struct { | 132 | + Ops []struct { |
| 132 | Action string `json:"action"` | 133 | Action string `json:"action"` |
| 133 | Path string `json:"path"` | 134 | Path string `json:"path"` |
| 134 | CID json.RawMessage `json:"cid"` | 135 | CID json.RawMessage `json:"cid"` |
modified
internal/atproto/firehose_handler.go +1 -1 | @@ -2,8 +2,8 @@ package atproto | ||
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | 4 | "context" |
| 5 | - "encoding/json" | |
| 6 | 5 | "database/sql" |
| 6 | + "encoding/json" | |
| 7 | 7 | "log/slog" |
| 8 | 8 | "time" |
| 9 | 9 | |
| @@ -2,8 +2,8 @@ package atproto | |||
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | "context" | 4 | "context" |
| 5 | - "encoding/json" | ||
| 6 | "database/sql" | 5 | "database/sql" |
| 6 | + "encoding/json" | ||
| 7 | "log/slog" | 7 | "log/slog" |
| 8 | "time" | 8 | "time" |
| 9 | 9 | ||
modified
internal/atproto/lexicon.go +4 -4 | @@ -51,13 +51,13 @@ type ListSubscriptionsResponse struct { | ||
| 51 | 51 | } |
| 52 | 52 | |
| 53 | 53 | type FeedListEntry struct { |
| 54 | - DID string `json:"did"` | |
| 55 | - SubscriptionCount int `json:"subscriptionCount"` | |
| 54 | + DID string `json:"did"` | |
| 55 | + SubscriptionCount int `json:"subscriptionCount"` | |
| 56 | 56 | Subscriptions []SubscriptionRecord `json:"subscriptions"` |
| 57 | 57 | } |
| 58 | 58 | |
| 59 | 59 | type ListFeedListsResponse struct { |
| 60 | - Cursor string `json:"cursor,omitempty"` | |
| 60 | + Cursor string `json:"cursor,omitempty"` | |
| 61 | 61 | Feeds []FeedListEntry `json:"feeds"` |
| 62 | 62 | } |
| 63 | 63 | |
| @@ -124,7 +124,7 @@ type RecommendedPerson struct { | ||
| 124 | 124 | } |
| 125 | 125 | |
| 126 | 126 | type GetRecommendationsResponse struct { |
| 127 | - Feeds []RecommendedFeed `json:"feeds"` | |
| 127 | + Feeds []RecommendedFeed `json:"feeds"` | |
| 128 | 128 | People []RecommendedPerson `json:"people"` |
| 129 | 129 | } |
| 130 | 130 | |
| @@ -51,13 +51,13 @@ type ListSubscriptionsResponse struct { | |||
| 51 | } | 51 | } |
| 52 | 52 | ||
| 53 | type FeedListEntry struct { | 53 | type FeedListEntry struct { |
| 54 | - DID string `json:"did"` | 54 | + DID string `json:"did"` |
| 55 | - SubscriptionCount int `json:"subscriptionCount"` | 55 | + SubscriptionCount int `json:"subscriptionCount"` |
| 56 | Subscriptions []SubscriptionRecord `json:"subscriptions"` | 56 | Subscriptions []SubscriptionRecord `json:"subscriptions"` |
| 57 | } | 57 | } |
| 58 | 58 | ||
| 59 | type ListFeedListsResponse struct { | 59 | type ListFeedListsResponse struct { |
| 60 | - Cursor string `json:"cursor,omitempty"` | 60 | + Cursor string `json:"cursor,omitempty"` |
| 61 | Feeds []FeedListEntry `json:"feeds"` | 61 | Feeds []FeedListEntry `json:"feeds"` |
| 62 | } | 62 | } |
| 63 | 63 | ||
| @@ -124,7 +124,7 @@ type RecommendedPerson struct { | |||
| 124 | } | 124 | } |
| 125 | 125 | ||
| 126 | type GetRecommendationsResponse struct { | 126 | type GetRecommendationsResponse struct { |
| 127 | - Feeds []RecommendedFeed `json:"feeds"` | 127 | + Feeds []RecommendedFeed `json:"feeds"` |
| 128 | People []RecommendedPerson `json:"people"` | 128 | People []RecommendedPerson `json:"people"` |
| 129 | } | 129 | } |
| 130 | 130 | ||
modified
internal/db/article.go +20 -20 | @@ -7,29 +7,29 @@ import ( | ||
| 7 | 7 | ) |
| 8 | 8 | |
| 9 | 9 | type Article struct { |
| 10 | - ID int64 | |
| 11 | - FeedURL string | |
| 12 | - FeedTitle string | |
| 13 | - GUID string | |
| 14 | - Title string | |
| 15 | - URL sql.NullString | |
| 16 | - Author sql.NullString | |
| 17 | - Summary sql.NullString | |
| 18 | - Content sql.NullString | |
| 19 | - Published sql.NullTime | |
| 20 | - Updated sql.NullTime | |
| 21 | - FetchedAt sql.NullTime | |
| 22 | - IsRead sql.NullBool | |
| 23 | - IsStarred sql.NullBool | |
| 10 | + ID int64 | |
| 11 | + FeedURL string | |
| 12 | + FeedTitle string | |
| 13 | + GUID string | |
| 14 | + Title string | |
| 15 | + URL sql.NullString | |
| 16 | + Author sql.NullString | |
| 17 | + Summary sql.NullString | |
| 18 | + Content sql.NullString | |
| 19 | + Published sql.NullTime | |
| 20 | + Updated sql.NullTime | |
| 21 | + FetchedAt sql.NullTime | |
| 22 | + IsRead sql.NullBool | |
| 23 | + IsStarred sql.NullBool | |
| 24 | 24 | } |
| 25 | 25 | |
| 26 | 26 | type ReadState struct { |
| 27 | - UserDID string | |
| 28 | - ArticleID int64 | |
| 29 | - IsRead bool | |
| 30 | - ReadAt sql.NullTime | |
| 31 | - IsStarred bool | |
| 32 | - StarredAt sql.NullTime | |
| 27 | + UserDID string | |
| 28 | + ArticleID int64 | |
| 29 | + IsRead bool | |
| 30 | + ReadAt sql.NullTime | |
| 31 | + IsStarred bool | |
| 32 | + StarredAt sql.NullTime | |
| 33 | 33 | } |
| 34 | 34 | |
| 35 | 35 | func (db *DB) UpsertArticle(ctx context.Context, article *Article) (int64, error) { |
| @@ -7,29 +7,29 @@ import ( | |||
| 7 | ) | 7 | ) |
| 8 | 8 | ||
| 9 | type Article struct { | 9 | type Article struct { |
| 10 | - ID int64 | 10 | + ID int64 |
| 11 | - FeedURL string | 11 | + FeedURL string |
| 12 | - FeedTitle string | 12 | + FeedTitle string |
| 13 | - GUID string | 13 | + GUID string |
| 14 | - Title string | 14 | + Title string |
| 15 | - URL sql.NullString | 15 | + URL sql.NullString |
| 16 | - Author sql.NullString | 16 | + Author sql.NullString |
| 17 | - Summary sql.NullString | 17 | + Summary sql.NullString |
| 18 | - Content sql.NullString | 18 | + Content sql.NullString |
| 19 | - Published sql.NullTime | 19 | + Published sql.NullTime |
| 20 | - Updated sql.NullTime | 20 | + Updated sql.NullTime |
| 21 | - FetchedAt sql.NullTime | 21 | + FetchedAt sql.NullTime |
| 22 | - IsRead sql.NullBool | 22 | + IsRead sql.NullBool |
| 23 | - IsStarred sql.NullBool | 23 | + IsStarred sql.NullBool |
| 24 | } | 24 | } |
| 25 | 25 | ||
| 26 | type ReadState struct { | 26 | type ReadState struct { |
| 27 | - UserDID string | 27 | + UserDID string |
| 28 | - ArticleID int64 | 28 | + ArticleID int64 |
| 29 | - IsRead bool | 29 | + IsRead bool |
| 30 | - ReadAt sql.NullTime | 30 | + ReadAt sql.NullTime |
| 31 | - IsStarred bool | 31 | + IsStarred bool |
| 32 | - StarredAt sql.NullTime | 32 | + StarredAt sql.NullTime |
| 33 | } | 33 | } |
| 34 | 34 | ||
| 35 | func (db *DB) UpsertArticle(ctx context.Context, article *Article) (int64, error) { | 35 | func (db *DB) UpsertArticle(ctx context.Context, article *Article) (int64, error) { |
modified
internal/db/cluster.go +14 -14 | @@ -227,11 +227,11 @@ func (db *DB) GetFeedRecommendations(ctx context.Context, userDID string, limit | ||
| 227 | 227 | return nil, err |
| 228 | 228 | } |
| 229 | 229 | results = append(results, map[string]any{ |
| 230 | - "feed_url": feedURL, | |
| 231 | - "score": score, | |
| 232 | - "title": title, | |
| 233 | - "site_url": siteURL, | |
| 234 | - "description": description, | |
| 230 | + "feed_url": feedURL, | |
| 231 | + "score": score, | |
| 232 | + "title": title, | |
| 233 | + "site_url": siteURL, | |
| 234 | + "description": description, | |
| 235 | 235 | "subscriber_count": subCount, |
| 236 | 236 | }) |
| 237 | 237 | } |
| @@ -306,15 +306,15 @@ func (db *DB) GetSimilarFeeds(ctx context.Context, feedURL string, limit int) ([ | ||
| 306 | 306 | } |
| 307 | 307 | |
| 308 | 308 | type ArticleRecommendation struct { |
| 309 | - ArticleID int64 | |
| 310 | - Title string | |
| 311 | - URL string | |
| 312 | - FeedURL string | |
| 313 | - FeedTitle string | |
| 314 | - Author string | |
| 315 | - Summary string | |
| 316 | - Published sql.NullTime | |
| 317 | - Score float64 | |
| 309 | + ArticleID int64 | |
| 310 | + Title string | |
| 311 | + URL string | |
| 312 | + FeedURL string | |
| 313 | + FeedTitle string | |
| 314 | + Author string | |
| 315 | + Summary string | |
| 316 | + Published sql.NullTime | |
| 317 | + Score float64 | |
| 318 | 318 | } |
| 319 | 319 | |
| 320 | 320 | func (db *DB) GetArticleRecommendations(ctx context.Context, userDID string, limit int) ([]*ArticleRecommendation, error) { |
| @@ -227,11 +227,11 @@ func (db *DB) GetFeedRecommendations(ctx context.Context, userDID string, limit | |||
| 227 | return nil, err | 227 | return nil, err |
| 228 | } | 228 | } |
| 229 | results = append(results, map[string]any{ | 229 | results = append(results, map[string]any{ |
| 230 | - "feed_url": feedURL, | 230 | + "feed_url": feedURL, |
| 231 | - "score": score, | 231 | + "score": score, |
| 232 | - "title": title, | 232 | + "title": title, |
| 233 | - "site_url": siteURL, | 233 | + "site_url": siteURL, |
| 234 | - "description": description, | 234 | + "description": description, |
| 235 | "subscriber_count": subCount, | 235 | "subscriber_count": subCount, |
| 236 | }) | 236 | }) |
| 237 | } | 237 | } |
| @@ -306,15 +306,15 @@ func (db *DB) GetSimilarFeeds(ctx context.Context, feedURL string, limit int) ([ | |||
| 306 | } | 306 | } |
| 307 | 307 | ||
| 308 | type ArticleRecommendation struct { | 308 | type ArticleRecommendation struct { |
| 309 | - ArticleID int64 | 309 | + ArticleID int64 |
| 310 | - Title string | 310 | + Title string |
| 311 | - URL string | 311 | + URL string |
| 312 | - FeedURL string | 312 | + FeedURL string |
| 313 | - FeedTitle string | 313 | + FeedTitle string |
| 314 | - Author string | 314 | + Author string |
| 315 | - Summary string | 315 | + Summary string |
| 316 | - Published sql.NullTime | 316 | + Published sql.NullTime |
| 317 | - Score float64 | 317 | + Score float64 |
| 318 | } | 318 | } |
| 319 | 319 | ||
| 320 | func (db *DB) GetArticleRecommendations(ctx context.Context, userDID string, limit int) ([]*ArticleRecommendation, error) { | 320 | func (db *DB) GetArticleRecommendations(ctx context.Context, userDID string, limit int) ([]*ArticleRecommendation, error) { |
modified
internal/db/db.go +1 -1 | @@ -2,9 +2,9 @@ package db | ||
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | 4 | "database/sql" |
| 5 | + _ "github.com/mattn/go-sqlite3" | |
| 5 | 6 | "strings" |
| 6 | 7 | "time" |
| 7 | - _ "github.com/mattn/go-sqlite3" | |
| 8 | 8 | ) |
| 9 | 9 | |
| 10 | 10 | func NullStr(s string) sql.NullString { |
| @@ -2,9 +2,9 @@ package db | |||
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | "database/sql" | 4 | "database/sql" |
| 5 | + _ "github.com/mattn/go-sqlite3" | ||
| 5 | "strings" | 6 | "strings" |
| 6 | "time" | 7 | "time" |
| 7 | - _ "github.com/mattn/go-sqlite3" | ||
| 8 | ) | 8 | ) |
| 9 | 9 | ||
| 10 | func NullStr(s string) sql.NullString { | 10 | func NullStr(s string) sql.NullString { |
modified
internal/db/social.go +26 -11 | @@ -8,18 +8,18 @@ import ( | ||
| 8 | 8 | ) |
| 9 | 9 | |
| 10 | 10 | type Annotation struct { |
| 11 | - ID int64 | |
| 12 | - URI string | |
| 13 | - AuthorDID string | |
| 11 | + ID int64 | |
| 12 | + URI string | |
| 13 | + AuthorDID string | |
| 14 | 14 | AuthorHandle string |
| 15 | - FeedURL string | |
| 16 | - ArticleURL string | |
| 17 | - Quote sql.NullString | |
| 18 | - Note sql.NullString | |
| 19 | - Tags sql.NullString | |
| 20 | - Rating sql.NullInt64 | |
| 21 | - CreatedAt sql.NullTime | |
| 22 | - CID sql.NullString | |
| 15 | + FeedURL string | |
| 16 | + ArticleURL string | |
| 17 | + Quote sql.NullString | |
| 18 | + Note sql.NullString | |
| 19 | + Tags sql.NullString | |
| 20 | + Rating sql.NullInt64 | |
| 21 | + CreatedAt sql.NullTime | |
| 22 | + CID sql.NullString | |
| 23 | 23 | } |
| 24 | 24 | |
| 25 | 25 | type Like struct { |
| @@ -40,6 +40,21 @@ func (db *DB) CreateAnnotation(ctx context.Context, a *Annotation) error { | ||
| 40 | 40 | return err |
| 41 | 41 | } |
| 42 | 42 | |
| 43 | +func (db *DB) GetAnnotation(ctx context.Context, id int64) (*Annotation, error) { | |
| 44 | + a := &Annotation{} | |
| 45 | + err := db.QueryRowContext(ctx, ` | |
| 46 | + SELECT a.id, a.uri, a.author_did, COALESCE(u.handle, ''), a.feed_url, a.article_url, a.quote, a.note, a.tags, a.rating, a.created_at, a.cid | |
| 47 | + FROM annotations a | |
| 48 | + LEFT JOIN users u ON a.author_did = u.did | |
| 49 | + WHERE a.id = ? | |
| 50 | + `, id).Scan(&a.ID, &a.URI, &a.AuthorDID, &a.AuthorHandle, &a.FeedURL, &a.ArticleURL, | |
| 51 | + &a.Quote, &a.Note, &a.Tags, &a.Rating, &a.CreatedAt, &a.CID) | |
| 52 | + if err != nil { | |
| 53 | + return nil, err | |
| 54 | + } | |
| 55 | + return a, nil | |
| 56 | +} | |
| 57 | + | |
| 43 | 58 | func (db *DB) DeleteAnnotation(ctx context.Context, uri string) error { |
| 44 | 59 | _, err := db.ExecContext(ctx, `DELETE FROM annotations WHERE uri = ?`, uri) |
| 45 | 60 | return err |
| @@ -8,18 +8,18 @@ import ( | |||
| 8 | ) | 8 | ) |
| 9 | 9 | ||
| 10 | type Annotation struct { | 10 | type Annotation struct { |
| 11 | - ID int64 | 11 | + ID int64 |
| 12 | - URI string | 12 | + URI string |
| 13 | - AuthorDID string | 13 | + AuthorDID string |
| 14 | AuthorHandle string | 14 | AuthorHandle string |
| 15 | - FeedURL string | 15 | + FeedURL string |
| 16 | - ArticleURL string | 16 | + ArticleURL string |
| 17 | - Quote sql.NullString | 17 | + Quote sql.NullString |
| 18 | - Note sql.NullString | 18 | + Note sql.NullString |
| 19 | - Tags sql.NullString | 19 | + Tags sql.NullString |
| 20 | - Rating sql.NullInt64 | 20 | + Rating sql.NullInt64 |
| 21 | - CreatedAt sql.NullTime | 21 | + CreatedAt sql.NullTime |
| 22 | - CID sql.NullString | 22 | + CID sql.NullString |
| 23 | } | 23 | } |
| 24 | 24 | ||
| 25 | type Like struct { | 25 | type Like struct { |
| @@ -40,6 +40,21 @@ func (db *DB) CreateAnnotation(ctx context.Context, a *Annotation) error { | |||
| 40 | return err | 40 | return err |
| 41 | } | 41 | } |
| 42 | 42 | ||
| 43 | +func (db *DB) GetAnnotation(ctx context.Context, id int64) (*Annotation, error) { | ||
| 44 | + a := &Annotation{} | ||
| 45 | + err := db.QueryRowContext(ctx, ` | ||
| 46 | + SELECT a.id, a.uri, a.author_did, COALESCE(u.handle, ''), a.feed_url, a.article_url, a.quote, a.note, a.tags, a.rating, a.created_at, a.cid | ||
| 47 | + FROM annotations a | ||
| 48 | + LEFT JOIN users u ON a.author_did = u.did | ||
| 49 | + WHERE a.id = ? | ||
| 50 | + `, id).Scan(&a.ID, &a.URI, &a.AuthorDID, &a.AuthorHandle, &a.FeedURL, &a.ArticleURL, | ||
| 51 | + &a.Quote, &a.Note, &a.Tags, &a.Rating, &a.CreatedAt, &a.CID) | ||
| 52 | + if err != nil { | ||
| 53 | + return nil, err | ||
| 54 | + } | ||
| 55 | + return a, nil | ||
| 56 | +} | ||
| 57 | + | ||
| 43 | func (db *DB) DeleteAnnotation(ctx context.Context, uri string) error { | 58 | func (db *DB) DeleteAnnotation(ctx context.Context, uri string) error { |
| 44 | _, err := db.ExecContext(ctx, `DELETE FROM annotations WHERE uri = ?`, uri) | 59 | _, err := db.ExecContext(ctx, `DELETE FROM annotations WHERE uri = ?`, uri) |
| 45 | return err | 60 | return err |
modified
internal/feed/parser.go +7 -7 | @@ -84,13 +84,13 @@ type jsonFeed struct { | ||
| 84 | 84 | HomePageURL string `json:"home_page_url"` |
| 85 | 85 | Description string `json:"description"` |
| 86 | 86 | Items []struct { |
| 87 | - ID string `json:"id"` | |
| 88 | - URL string `json:"url"` | |
| 89 | - Title string `json:"title"` | |
| 90 | - ContentHTML string `json:"content_html"` | |
| 91 | - ContentText string `json:"content_text"` | |
| 92 | - Summary string `json:"summary"` | |
| 93 | - Author struct { | |
| 87 | + ID string `json:"id"` | |
| 88 | + URL string `json:"url"` | |
| 89 | + Title string `json:"title"` | |
| 90 | + ContentHTML string `json:"content_html"` | |
| 91 | + ContentText string `json:"content_text"` | |
| 92 | + Summary string `json:"summary"` | |
| 93 | + Author struct { | |
| 94 | 94 | Name string `json:"name"` |
| 95 | 95 | } `json:"author"` |
| 96 | 96 | DatePublished string `json:"date_published"` |
| @@ -84,13 +84,13 @@ type jsonFeed struct { | |||
| 84 | HomePageURL string `json:"home_page_url"` | 84 | HomePageURL string `json:"home_page_url"` |
| 85 | Description string `json:"description"` | 85 | Description string `json:"description"` |
| 86 | Items []struct { | 86 | Items []struct { |
| 87 | - ID string `json:"id"` | 87 | + ID string `json:"id"` |
| 88 | - URL string `json:"url"` | 88 | + URL string `json:"url"` |
| 89 | - Title string `json:"title"` | 89 | + Title string `json:"title"` |
| 90 | - ContentHTML string `json:"content_html"` | 90 | + ContentHTML string `json:"content_html"` |
| 91 | - ContentText string `json:"content_text"` | 91 | + ContentText string `json:"content_text"` |
| 92 | - Summary string `json:"summary"` | 92 | + Summary string `json:"summary"` |
| 93 | - Author struct { | 93 | + Author struct { |
| 94 | Name string `json:"name"` | 94 | Name string `json:"name"` |
| 95 | } `json:"author"` | 95 | } `json:"author"` |
| 96 | DatePublished string `json:"date_published"` | 96 | DatePublished string `json:"date_published"` |
modified
internal/server/annotations_handler.go +46 -5 | @@ -7,6 +7,8 @@ import ( | ||
| 7 | 7 | "strconv" |
| 8 | 8 | "time" |
| 9 | 9 | |
| 10 | + "github.com/go-chi/chi/v5" | |
| 11 | + | |
| 10 | 12 | "pkg.rbrt.fr/glean/internal/atproto" |
| 11 | 13 | "pkg.rbrt.fr/glean/internal/db" |
| 12 | 14 | ) |
| @@ -51,11 +53,12 @@ func (s *Server) handleLibrary(w http.ResponseWriter, r *http.Request) { | ||
| 51 | 53 | } |
| 52 | 54 | |
| 53 | 55 | s.render(w, r, "library.html", map[string]any{ |
| 54 | - "User": user, | |
| 55 | - "Articles": articles, | |
| 56 | - "Annotations": annotations, | |
| 57 | - "LikedPage": likedPage, | |
| 58 | - "AnnotPage": annotPage, | |
| 56 | + "User": user, | |
| 57 | + "CurrentUserDID": user.DID, | |
| 58 | + "Articles": articles, | |
| 59 | + "Annotations": annotations, | |
| 60 | + "LikedPage": likedPage, | |
| 61 | + "AnnotPage": annotPage, | |
| 59 | 62 | }) |
| 60 | 63 | } |
| 61 | 64 | |
| @@ -105,3 +108,41 @@ func (s *Server) handleCreateAnnotation(w http.ResponseWriter, r *http.Request) | ||
| 105 | 108 | |
| 106 | 109 | w.WriteHeader(http.StatusNoContent) |
| 107 | 110 | } |
| 111 | + | |
| 112 | +func (s *Server) handleDeleteAnnotation(w http.ResponseWriter, r *http.Request) { | |
| 113 | + user := currentUser(r) | |
| 114 | + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) | |
| 115 | + if err != nil { | |
| 116 | + http.Error(w, "invalid id", http.StatusBadRequest) | |
| 117 | + return | |
| 118 | + } | |
| 119 | + | |
| 120 | + annotation, err := s.db.GetAnnotation(r.Context(), id) | |
| 121 | + if err != nil { | |
| 122 | + http.Error(w, "annotation not found", http.StatusNotFound) | |
| 123 | + return | |
| 124 | + } | |
| 125 | + | |
| 126 | + if annotation.AuthorDID != user.DID { | |
| 127 | + http.Error(w, "forbidden", http.StatusForbidden) | |
| 128 | + return | |
| 129 | + } | |
| 130 | + | |
| 131 | + if annotation.URI != "" { | |
| 132 | + if client := s.pdsClientForUser(r); client != nil { | |
| 133 | + parsed, ok := atproto.ParseRecordURI(annotation.URI) | |
| 134 | + if ok { | |
| 135 | + if delErr := client.DeleteRecord(r.Context(), user.DID, parsed.Collection, parsed.RKey); delErr != nil { | |
| 136 | + s.logger.Error("failed to delete annotation from PDS", "error", delErr) | |
| 137 | + } | |
| 138 | + } | |
| 139 | + } | |
| 140 | + } | |
| 141 | + | |
| 142 | + if err := s.db.DeleteAnnotation(r.Context(), annotation.URI); err != nil { | |
| 143 | + http.Error(w, err.Error(), http.StatusInternalServerError) | |
| 144 | + return | |
| 145 | + } | |
| 146 | + | |
| 147 | + w.WriteHeader(http.StatusOK) | |
| 148 | +} | |
| @@ -7,6 +7,8 @@ import ( | |||
| 7 | "strconv" | 7 | "strconv" |
| 8 | "time" | 8 | "time" |
| 9 | 9 | ||
| 10 | + "github.com/go-chi/chi/v5" | ||
| 11 | + | ||
| 10 | "pkg.rbrt.fr/glean/internal/atproto" | 12 | "pkg.rbrt.fr/glean/internal/atproto" |
| 11 | "pkg.rbrt.fr/glean/internal/db" | 13 | "pkg.rbrt.fr/glean/internal/db" |
| 12 | ) | 14 | ) |
| @@ -51,11 +53,12 @@ func (s *Server) handleLibrary(w http.ResponseWriter, r *http.Request) { | |||
| 51 | } | 53 | } |
| 52 | 54 | ||
| 53 | s.render(w, r, "library.html", map[string]any{ | 55 | s.render(w, r, "library.html", map[string]any{ |
| 54 | - "User": user, | 56 | + "User": user, |
| 55 | - "Articles": articles, | 57 | + "CurrentUserDID": user.DID, |
| 56 | - "Annotations": annotations, | 58 | + "Articles": articles, |
| 57 | - "LikedPage": likedPage, | 59 | + "Annotations": annotations, |
| 58 | - "AnnotPage": annotPage, | 60 | + "LikedPage": likedPage, |
| 61 | + "AnnotPage": annotPage, | ||
| 59 | }) | 62 | }) |
| 60 | } | 63 | } |
| 61 | 64 | ||
| @@ -105,3 +108,41 @@ func (s *Server) handleCreateAnnotation(w http.ResponseWriter, r *http.Request) | |||
| 105 | 108 | ||
| 106 | w.WriteHeader(http.StatusNoContent) | 109 | w.WriteHeader(http.StatusNoContent) |
| 107 | } | 110 | } |
| 111 | + | ||
| 112 | +func (s *Server) handleDeleteAnnotation(w http.ResponseWriter, r *http.Request) { | ||
| 113 | + user := currentUser(r) | ||
| 114 | + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) | ||
| 115 | + if err != nil { | ||
| 116 | + http.Error(w, "invalid id", http.StatusBadRequest) | ||
| 117 | + return | ||
| 118 | + } | ||
| 119 | + | ||
| 120 | + annotation, err := s.db.GetAnnotation(r.Context(), id) | ||
| 121 | + if err != nil { | ||
| 122 | + http.Error(w, "annotation not found", http.StatusNotFound) | ||
| 123 | + return | ||
| 124 | + } | ||
| 125 | + | ||
| 126 | + if annotation.AuthorDID != user.DID { | ||
| 127 | + http.Error(w, "forbidden", http.StatusForbidden) | ||
| 128 | + return | ||
| 129 | + } | ||
| 130 | + | ||
| 131 | + if annotation.URI != "" { | ||
| 132 | + if client := s.pdsClientForUser(r); client != nil { | ||
| 133 | + parsed, ok := atproto.ParseRecordURI(annotation.URI) | ||
| 134 | + if ok { | ||
| 135 | + if delErr := client.DeleteRecord(r.Context(), user.DID, parsed.Collection, parsed.RKey); delErr != nil { | ||
| 136 | + s.logger.Error("failed to delete annotation from PDS", "error", delErr) | ||
| 137 | + } | ||
| 138 | + } | ||
| 139 | + } | ||
| 140 | + } | ||
| 141 | + | ||
| 142 | + if err := s.db.DeleteAnnotation(r.Context(), annotation.URI); err != nil { | ||
| 143 | + http.Error(w, err.Error(), http.StatusInternalServerError) | ||
| 144 | + return | ||
| 145 | + } | ||
| 146 | + | ||
| 147 | + w.WriteHeader(http.StatusOK) | ||
| 148 | +} | ||
modified
internal/server/articles_handler.go +8 -7 | @@ -91,13 +91,14 @@ func (s *Server) handleArticleDetail(w http.ResponseWriter, r *http.Request) { | ||
| 91 | 91 | feed, _ := s.db.GetFeed(r.Context(), article.FeedURL) |
| 92 | 92 | |
| 93 | 93 | s.render(w, r, "article_detail.html", map[string]any{ |
| 94 | - "User": user, | |
| 95 | - "Article": article, | |
| 96 | - "Feed": feed, | |
| 97 | - "ReadState": readState, | |
| 98 | - "LikeCount": likeCount, | |
| 99 | - "HasLiked": liked, | |
| 100 | - "Annotations": annotations, | |
| 94 | + "User": user, | |
| 95 | + "CurrentUserDID": user.DID, | |
| 96 | + "Article": article, | |
| 97 | + "Feed": feed, | |
| 98 | + "ReadState": readState, | |
| 99 | + "LikeCount": likeCount, | |
| 100 | + "HasLiked": liked, | |
| 101 | + "Annotations": annotations, | |
| 101 | 102 | }) |
| 102 | 103 | } |
| 103 | 104 | |
| @@ -91,13 +91,14 @@ func (s *Server) handleArticleDetail(w http.ResponseWriter, r *http.Request) { | |||
| 91 | feed, _ := s.db.GetFeed(r.Context(), article.FeedURL) | 91 | feed, _ := s.db.GetFeed(r.Context(), article.FeedURL) |
| 92 | 92 | ||
| 93 | s.render(w, r, "article_detail.html", map[string]any{ | 93 | s.render(w, r, "article_detail.html", map[string]any{ |
| 94 | - "User": user, | 94 | + "User": user, |
| 95 | - "Article": article, | 95 | + "CurrentUserDID": user.DID, |
| 96 | - "Feed": feed, | 96 | + "Article": article, |
| 97 | - "ReadState": readState, | 97 | + "Feed": feed, |
| 98 | - "LikeCount": likeCount, | 98 | + "ReadState": readState, |
| 99 | - "HasLiked": liked, | 99 | + "LikeCount": likeCount, |
| 100 | - "Annotations": annotations, | 100 | + "HasLiked": liked, |
| 101 | + "Annotations": annotations, | ||
| 101 | }) | 102 | }) |
| 102 | } | 103 | } |
| 103 | 104 | ||
modified
internal/server/profile_handler.go +7 -4 | @@ -18,12 +18,15 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) { | ||
| 18 | 18 | annotations, _ := s.db.ListAnnotations(r.Context(), "", "", did, 50, 0) |
| 19 | 19 | subCount, _ := s.db.GetSubscriptionCount(r.Context(), did) |
| 20 | 20 | |
| 21 | + user := currentUser(r) | |
| 22 | + | |
| 21 | 23 | s.render(w, r, "profile.html", map[string]any{ |
| 22 | - "User": currentUser(r), | |
| 24 | + "User": user, | |
| 25 | + "CurrentUserDID": user.DID, | |
| 23 | 26 | "ProfileUser": profileUser, |
| 24 | - "Subscriptions": subs, | |
| 25 | - "Annotations": annotations, | |
| 27 | + "Subscriptions": subs, | |
| 28 | + "Annotations": annotations, | |
| 26 | 29 | "SubscriptionCount": subCount, |
| 27 | - "AnnotationCount": len(annotations), | |
| 30 | + "AnnotationCount": len(annotations), | |
| 28 | 31 | }) |
| 29 | 32 | } |
| @@ -18,12 +18,15 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) { | |||
| 18 | annotations, _ := s.db.ListAnnotations(r.Context(), "", "", did, 50, 0) | 18 | annotations, _ := s.db.ListAnnotations(r.Context(), "", "", did, 50, 0) |
| 19 | subCount, _ := s.db.GetSubscriptionCount(r.Context(), did) | 19 | subCount, _ := s.db.GetSubscriptionCount(r.Context(), did) |
| 20 | 20 | ||
| 21 | + user := currentUser(r) | ||
| 22 | + | ||
| 21 | s.render(w, r, "profile.html", map[string]any{ | 23 | s.render(w, r, "profile.html", map[string]any{ |
| 22 | - "User": currentUser(r), | 24 | + "User": user, |
| 25 | + "CurrentUserDID": user.DID, | ||
| 23 | "ProfileUser": profileUser, | 26 | "ProfileUser": profileUser, |
| 24 | - "Subscriptions": subs, | 27 | + "Subscriptions": subs, |
| 25 | - "Annotations": annotations, | 28 | + "Annotations": annotations, |
| 26 | "SubscriptionCount": subCount, | 29 | "SubscriptionCount": subCount, |
| 27 | - "AnnotationCount": len(annotations), | 30 | + "AnnotationCount": len(annotations), |
| 28 | }) | 31 | }) |
| 29 | } | 32 | } |
modified
internal/server/server.go +15 -0 | @@ -135,6 +135,7 @@ func (s *Server) setupRoutes() { | ||
| 135 | 135 | r.Use(s.requireAuth) |
| 136 | 136 | r.Get("/", s.handleLibrary) |
| 137 | 137 | r.Post("/create", s.handleCreateAnnotation) |
| 138 | + r.Post("/{id}/delete", s.handleDeleteAnnotation) | |
| 138 | 139 | }) |
| 139 | 140 | |
| 140 | 141 | s.router.Get("/auth/login", s.handleAuthLogin) |
| @@ -156,6 +157,20 @@ func (s *Server) setupRoutes() { | ||
| 156 | 157 | |
| 157 | 158 | func (s *Server) loadTemplates() { |
| 158 | 159 | fm := template.FuncMap{ |
| 160 | + "dict": func(values ...any) (map[string]any, error) { | |
| 161 | + if len(values)%2 != 0 { | |
| 162 | + return nil, fmt.Errorf("dict requires even number of arguments") | |
| 163 | + } | |
| 164 | + m := make(map[string]any, len(values)/2) | |
| 165 | + for i := 0; i < len(values); i += 2 { | |
| 166 | + key, ok := values[i].(string) | |
| 167 | + if !ok { | |
| 168 | + return nil, fmt.Errorf("dict key must be string") | |
| 169 | + } | |
| 170 | + m[key] = values[i+1] | |
| 171 | + } | |
| 172 | + return m, nil | |
| 173 | + }, | |
| 159 | 174 | "formatDate": func(t time.Time) string { |
| 160 | 175 | return t.Format("Jan 02, 2006") |
| 161 | 176 | }, |
| @@ -135,6 +135,7 @@ func (s *Server) setupRoutes() { | |||
| 135 | r.Use(s.requireAuth) | 135 | r.Use(s.requireAuth) |
| 136 | r.Get("/", s.handleLibrary) | 136 | r.Get("/", s.handleLibrary) |
| 137 | r.Post("/create", s.handleCreateAnnotation) | 137 | r.Post("/create", s.handleCreateAnnotation) |
| 138 | + r.Post("/{id}/delete", s.handleDeleteAnnotation) | ||
| 138 | }) | 139 | }) |
| 139 | 140 | ||
| 140 | s.router.Get("/auth/login", s.handleAuthLogin) | 141 | s.router.Get("/auth/login", s.handleAuthLogin) |
| @@ -156,6 +157,20 @@ func (s *Server) setupRoutes() { | |||
| 156 | 157 | ||
| 157 | func (s *Server) loadTemplates() { | 158 | func (s *Server) loadTemplates() { |
| 158 | fm := template.FuncMap{ | 159 | fm := template.FuncMap{ |
| 160 | + "dict": func(values ...any) (map[string]any, error) { | ||
| 161 | + if len(values)%2 != 0 { | ||
| 162 | + return nil, fmt.Errorf("dict requires even number of arguments") | ||
| 163 | + } | ||
| 164 | + m := make(map[string]any, len(values)/2) | ||
| 165 | + for i := 0; i < len(values); i += 2 { | ||
| 166 | + key, ok := values[i].(string) | ||
| 167 | + if !ok { | ||
| 168 | + return nil, fmt.Errorf("dict key must be string") | ||
| 169 | + } | ||
| 170 | + m[key] = values[i+1] | ||
| 171 | + } | ||
| 172 | + return m, nil | ||
| 173 | + }, | ||
| 159 | "formatDate": func(t time.Time) string { | 174 | "formatDate": func(t time.Time) string { |
| 160 | return t.Format("Jan 02, 2006") | 175 | return t.Format("Jan 02, 2006") |
| 161 | }, | 176 | }, |
modified
internal/tmpl/article_detail.html +1 -1 | @@ -99,7 +99,7 @@ | ||
| 99 | 99 | |
| 100 | 100 | <div id="annotations-list" class="space-y-3"> |
| 101 | 101 | {{range .Annotations}} |
| 102 | - {{template "annotation-card.html" .}} | |
| 102 | + {{template "annotation-card.html" dict "annotation" . "userDID" $.CurrentUserDID}} | |
| 103 | 103 | {{else}} |
| 104 | 104 | <p class="text-sm text-spot-secondary">No annotations yet. Be the first to add one.</p> |
| 105 | 105 | {{end}} |
| @@ -99,7 +99,7 @@ | |||
| 99 | 99 | ||
| 100 | <div id="annotations-list" class="space-y-3"> | 100 | <div id="annotations-list" class="space-y-3"> |
| 101 | {{range .Annotations}} | 101 | {{range .Annotations}} |
| 102 | - {{template "annotation-card.html" .}} | 102 | + {{template "annotation-card.html" dict "annotation" . "userDID" $.CurrentUserDID}} |
| 103 | {{else}} | 103 | {{else}} |
| 104 | <p class="text-sm text-spot-secondary">No annotations yet. Be the first to add one.</p> | 104 | <p class="text-sm text-spot-secondary">No annotations yet. Be the first to add one.</p> |
| 105 | {{end}} | 105 | {{end}} |
modified
internal/tmpl/base.html +30 -0 | @@ -262,6 +262,36 @@ | ||
| 262 | 262 | (quote ? form.querySelector('input[name="note"]') : quoteInput).focus(); |
| 263 | 263 | } |
| 264 | 264 | |
| 265 | + function editAnnotation(btn, id, feedURL, articleURL, quote, note, tags) { | |
| 266 | + var card = btn.closest('[id^="annotation-"]'); | |
| 267 | + card.innerHTML = '<form hx-post="/library/create" hx-swap="none" hx-on::after-request="editAnnotationDone(' + id + ')" class="space-y-3">' + | |
| 268 | + '<input type="hidden" name="feed_url" value="' + feedURL + '">' + | |
| 269 | + '<input type="hidden" name="article_url" value="' + articleURL + '">' + | |
| 270 | + '<input type="text" name="quote" value="' + (quote || '').replace(/"/g, '"') + '" placeholder="Quote a passage..." class="w-full bg-spot-hover text-spot-text rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-spot-green placeholder:text-spot-placeholder">' + | |
| 271 | + '<textarea name="note" rows="3" placeholder="Add a note..." class="w-full bg-spot-hover text-spot-text rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-spot-green placeholder:text-spot-placeholder resize-none">' + (note || '') + '</textarea>' + | |
| 272 | + '<input type="text" name="tags" value="' + (tags || '').replace(/"/g, '"') + '" placeholder="Tags (comma separated)" class="w-full bg-spot-hover text-spot-text rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-spot-green placeholder:text-spot-placeholder">' + | |
| 273 | + '<div class="flex gap-2 justify-end">' + | |
| 274 | + '<button type="button" onclick="cancelEdit(' + id + ')" class="border border-spot-outline text-spot-text rounded-pill px-4 py-1.5 text-xs font-bold uppercase tracking-button hover:border-spot-text transition">Cancel</button>' + | |
| 275 | + '<button type="submit" class="bg-spot-green text-white rounded-pill px-5 py-1.5 text-xs font-bold uppercase tracking-button hover:brightness-110 transition">Save</button>' + | |
| 276 | + '</div></form>'; | |
| 277 | + htmx.process(card); | |
| 278 | + var deleteReq = new XMLHttpRequest(); | |
| 279 | + deleteReq.open('POST', '/library/' + id + '/delete'); | |
| 280 | + deleteReq.setRequestHeader('HX-Request', 'true'); | |
| 281 | + deleteReq.send(); | |
| 282 | + } | |
| 283 | + | |
| 284 | + function editAnnotationDone(id) { | |
| 285 | + var params = new URLSearchParams(window.location.search); | |
| 286 | + var url = window.location.pathname; | |
| 287 | + if (params.toString()) url += '?' + params.toString(); | |
| 288 | + htmx.ajax('GET', url, '#main-content'); | |
| 289 | + } | |
| 290 | + | |
| 291 | + function cancelEdit(id) { | |
| 292 | + htmx.ajax('GET', window.location.pathname + window.location.search, '#main-content'); | |
| 293 | + } | |
| 294 | + | |
| 265 | 295 | document.addEventListener('mouseup', function(e) { |
| 266 | 296 | var sel = window.getSelection(); |
| 267 | 297 | var text = sel.toString().trim(); |
| @@ -262,6 +262,36 @@ | |||
| 262 | (quote ? form.querySelector('input[name="note"]') : quoteInput).focus(); | 262 | (quote ? form.querySelector('input[name="note"]') : quoteInput).focus(); |
| 263 | } | 263 | } |
| 264 | 264 | ||
| 265 | + function editAnnotation(btn, id, feedURL, articleURL, quote, note, tags) { | ||
| 266 | + var card = btn.closest('[id^="annotation-"]'); | ||
| 267 | + card.innerHTML = '<form hx-post="/library/create" hx-swap="none" hx-on::after-request="editAnnotationDone(' + id + ')" class="space-y-3">' + | ||
| 268 | + '<input type="hidden" name="feed_url" value="' + feedURL + '">' + | ||
| 269 | + '<input type="hidden" name="article_url" value="' + articleURL + '">' + | ||
| 270 | + '<input type="text" name="quote" value="' + (quote || '').replace(/"/g, '"') + '" placeholder="Quote a passage..." class="w-full bg-spot-hover text-spot-text rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-spot-green placeholder:text-spot-placeholder">' + | ||
| 271 | + '<textarea name="note" rows="3" placeholder="Add a note..." class="w-full bg-spot-hover text-spot-text rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-spot-green placeholder:text-spot-placeholder resize-none">' + (note || '') + '</textarea>' + | ||
| 272 | + '<input type="text" name="tags" value="' + (tags || '').replace(/"/g, '"') + '" placeholder="Tags (comma separated)" class="w-full bg-spot-hover text-spot-text rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-spot-green placeholder:text-spot-placeholder">' + | ||
| 273 | + '<div class="flex gap-2 justify-end">' + | ||
| 274 | + '<button type="button" onclick="cancelEdit(' + id + ')" class="border border-spot-outline text-spot-text rounded-pill px-4 py-1.5 text-xs font-bold uppercase tracking-button hover:border-spot-text transition">Cancel</button>' + | ||
| 275 | + '<button type="submit" class="bg-spot-green text-white rounded-pill px-5 py-1.5 text-xs font-bold uppercase tracking-button hover:brightness-110 transition">Save</button>' + | ||
| 276 | + '</div></form>'; | ||
| 277 | + htmx.process(card); | ||
| 278 | + var deleteReq = new XMLHttpRequest(); | ||
| 279 | + deleteReq.open('POST', '/library/' + id + '/delete'); | ||
| 280 | + deleteReq.setRequestHeader('HX-Request', 'true'); | ||
| 281 | + deleteReq.send(); | ||
| 282 | + } | ||
| 283 | + | ||
| 284 | + function editAnnotationDone(id) { | ||
| 285 | + var params = new URLSearchParams(window.location.search); | ||
| 286 | + var url = window.location.pathname; | ||
| 287 | + if (params.toString()) url += '?' + params.toString(); | ||
| 288 | + htmx.ajax('GET', url, '#main-content'); | ||
| 289 | + } | ||
| 290 | + | ||
| 291 | + function cancelEdit(id) { | ||
| 292 | + htmx.ajax('GET', window.location.pathname + window.location.search, '#main-content'); | ||
| 293 | + } | ||
| 294 | + | ||
| 265 | document.addEventListener('mouseup', function(e) { | 295 | document.addEventListener('mouseup', function(e) { |
| 266 | var sel = window.getSelection(); | 296 | var sel = window.getSelection(); |
| 267 | var text = sel.toString().trim(); | 297 | var text = sel.toString().trim(); |
modified
internal/tmpl/library.html +1 -8 | @@ -45,14 +45,7 @@ | ||
| 45 | 45 | <h2 class="text-lg font-semibold text-spot-text mb-4">Annotations</h2> |
| 46 | 46 | <div id="annotations-list" class="space-y-3"> |
| 47 | 47 | {{range .Annotations}} |
| 48 | - <div class="bg-spot-surface rounded-xl p-4"> | |
| 49 | - {{if .ArticleURL}} | |
| 50 | - <div class="text-xs text-spot-muted mb-2"> | |
| 51 | - <a href="{{.ArticleURL}}" class="text-spot-secondary hover:text-spot-green transition truncate block">{{.ArticleURL}}</a> | |
| 52 | - </div> | |
| 53 | - {{end}} | |
| 54 | - {{template "annotation-card.html" .}} | |
| 55 | - </div> | |
| 48 | + {{template "annotation-card.html" dict "annotation" . "userDID" $.CurrentUserDID}} | |
| 56 | 49 | {{else}} |
| 57 | 50 | <div class="flex flex-col items-center justify-center py-12 text-center"> |
| 58 | 51 | <div class="w-14 h-14 rounded-full bg-spot-hover flex items-center justify-center mb-4"> |
| @@ -45,14 +45,7 @@ | |||
| 45 | <h2 class="text-lg font-semibold text-spot-text mb-4">Annotations</h2> | 45 | <h2 class="text-lg font-semibold text-spot-text mb-4">Annotations</h2> |
| 46 | <div id="annotations-list" class="space-y-3"> | 46 | <div id="annotations-list" class="space-y-3"> |
| 47 | {{range .Annotations}} | 47 | {{range .Annotations}} |
| 48 | - <div class="bg-spot-surface rounded-xl p-4"> | 48 | + {{template "annotation-card.html" dict "annotation" . "userDID" $.CurrentUserDID}} |
| 49 | - {{if .ArticleURL}} | ||
| 50 | - <div class="text-xs text-spot-muted mb-2"> | ||
| 51 | - <a href="{{.ArticleURL}}" class="text-spot-secondary hover:text-spot-green transition truncate block">{{.ArticleURL}}</a> | ||
| 52 | - </div> | ||
| 53 | - {{end}} | ||
| 54 | - {{template "annotation-card.html" .}} | ||
| 55 | - </div> | ||
| 56 | {{else}} | 49 | {{else}} |
| 57 | <div class="flex flex-col items-center justify-center py-12 text-center"> | 50 | <div class="flex flex-col items-center justify-center py-12 text-center"> |
| 58 | <div class="w-14 h-14 rounded-full bg-spot-hover flex items-center justify-center mb-4"> | 51 | <div class="w-14 h-14 rounded-full bg-spot-hover flex items-center justify-center mb-4"> |
modified
internal/tmpl/partials/annotation-card.html +27 -13 | @@ -1,22 +1,36 @@ | ||
| 1 | 1 | {{define "annotation-card.html"}} |
| 2 | -<div class="bg-spot-surface rounded-xl p-4"> | |
| 3 | - {{if .Quote.Valid}} | |
| 4 | - <blockquote class="border-l-2 border-spot-green pl-3 text-sm text-spot-body italic">{{.Quote.String}}</blockquote> | |
| 2 | +<div id="annotation-{{.annotation.ID}}" class="bg-spot-surface rounded-xl p-4 shadow-spot"> | |
| 3 | + {{if .annotation.ArticleURL}} | |
| 4 | + <div class="text-xs text-spot-muted mb-2"> | |
| 5 | + <a href="{{.annotation.ArticleURL}}" class="text-spot-secondary hover:text-spot-green transition truncate block">{{.annotation.ArticleURL}}</a> | |
| 6 | + </div> | |
| 7 | + {{end}} | |
| 8 | + {{if .annotation.Quote.Valid}} | |
| 9 | + <blockquote class="border-l-2 border-spot-green pl-3 text-sm text-spot-body italic leading-relaxed">{{.annotation.Quote.String}}</blockquote> | |
| 5 | 10 | {{end}} |
| 6 | - {{if .Note.Valid}} | |
| 7 | - <p class="text-sm text-spot-body mt-2">{{.Note.String}}</p> | |
| 11 | + {{if .annotation.Note.Valid}} | |
| 12 | + <p class="text-sm text-spot-body mt-2 leading-relaxed">{{.annotation.Note.String}}</p> | |
| 8 | 13 | {{end}} |
| 9 | - {{if .Tags.Valid}} | |
| 10 | - <div class="flex gap-1 mt-2 flex-wrap"> | |
| 11 | - {{range $tag := split .Tags.String ","}}<span class="text-xs bg-spot-hover text-spot-secondary px-2.5 py-0.5 rounded-full">{{$tag}}</span>{{end}} | |
| 14 | + {{if .annotation.Tags.Valid}} | |
| 15 | + <div class="flex gap-1.5 mt-3 flex-wrap"> | |
| 16 | + {{range $tag := split .annotation.Tags.String ","}}<span class="text-xs bg-spot-hover text-spot-secondary px-2.5 py-0.5 rounded-full">{{$tag}}</span>{{end}} | |
| 12 | 17 | </div> |
| 13 | 18 | {{end}} |
| 14 | - {{if .Rating.Valid}} | |
| 15 | - <div class="text-sm text-spot-orange mt-1">{{repeat "★" (int .Rating.Int64)}}</div> | |
| 19 | + {{if .annotation.Rating.Valid}} | |
| 20 | + <div class="text-sm text-spot-orange mt-2 tracking-wide">{{repeat "★" (int .annotation.Rating.Int64)}}</div> | |
| 16 | 21 | {{end}} |
| 17 | - <div class="text-xs text-spot-muted mt-2"> | |
| 18 | - <a href="/profile/{{.AuthorDID}}" class="hover:text-spot-green transition">{{.AuthorHandle}}</a> | |
| 19 | - {{if .CreatedAt.Valid}}<span class="ml-2">{{.CreatedAt.Time.Format "Jan 2, 2006 15:04"}}</span>{{end}} | |
| 22 | + <div class="flex items-center justify-between mt-3 pt-3 border-t border-spot-divider"> | |
| 23 | + <div class="flex items-center gap-2 text-xs text-spot-muted"> | |
| 24 | + <a href="/profile/{{.annotation.AuthorDID}}" class="hover:text-spot-green transition">{{.annotation.AuthorHandle}}</a> | |
| 25 | + {{if .annotation.CreatedAt.Valid}}<span>·</span><span>{{.annotation.CreatedAt.Time.Format "Jan 2, 2006 15:04"}}</span>{{end}} | |
| 26 | + </div> | |
| 27 | + {{if eq .annotation.AuthorDID .userDID}} | |
| 28 | + <div class="flex items-center gap-2"> | |
| 29 | + <button class="text-xs text-spot-secondary hover:text-spot-text transition" onclick="editAnnotation(this, {{.annotation.ID}}, '{{js .annotation.FeedURL}}', '{{js .annotation.ArticleURL}}', '{{js .annotation.Quote.String}}', '{{js .annotation.Note.String}}', '{{js .annotation.Tags.String}}')">Edit</button> | |
| 30 | + <button hx-post="/library/{{.annotation.ID}}/delete" hx-target="#annotation-{{.annotation.ID}}" hx-swap="outerHTML swap:0.3s" hx-confirm="Delete this annotation?" | |
| 31 | + class="text-xs text-spot-secondary hover:text-spot-red transition">Delete</button> | |
| 32 | + </div> | |
| 33 | + {{end}} | |
| 20 | 34 | </div> |
| 21 | 35 | </div> |
| 22 | 36 | {{end}} |
| @@ -1,22 +1,36 @@ | |||
| 1 | {{define "annotation-card.html"}} | 1 | {{define "annotation-card.html"}} |
| 2 | -<div class="bg-spot-surface rounded-xl p-4"> | 2 | +<div id="annotation-{{.annotation.ID}}" class="bg-spot-surface rounded-xl p-4 shadow-spot"> |
| 3 | - {{if .Quote.Valid}} | 3 | + {{if .annotation.ArticleURL}} |
| 4 | - <blockquote class="border-l-2 border-spot-green pl-3 text-sm text-spot-body italic">{{.Quote.String}}</blockquote> | 4 | + <div class="text-xs text-spot-muted mb-2"> |
| 5 | + <a href="{{.annotation.ArticleURL}}" class="text-spot-secondary hover:text-spot-green transition truncate block">{{.annotation.ArticleURL}}</a> | ||
| 6 | + </div> | ||
| 7 | + {{end}} | ||
| 8 | + {{if .annotation.Quote.Valid}} | ||
| 9 | + <blockquote class="border-l-2 border-spot-green pl-3 text-sm text-spot-body italic leading-relaxed">{{.annotation.Quote.String}}</blockquote> | ||
| 5 | {{end}} | 10 | {{end}} |
| 6 | - {{if .Note.Valid}} | 11 | + {{if .annotation.Note.Valid}} |
| 7 | - <p class="text-sm text-spot-body mt-2">{{.Note.String}}</p> | 12 | + <p class="text-sm text-spot-body mt-2 leading-relaxed">{{.annotation.Note.String}}</p> |
| 8 | {{end}} | 13 | {{end}} |
| 9 | - {{if .Tags.Valid}} | 14 | + {{if .annotation.Tags.Valid}} |
| 10 | - <div class="flex gap-1 mt-2 flex-wrap"> | 15 | + <div class="flex gap-1.5 mt-3 flex-wrap"> |
| 11 | - {{range $tag := split .Tags.String ","}}<span class="text-xs bg-spot-hover text-spot-secondary px-2.5 py-0.5 rounded-full">{{$tag}}</span>{{end}} | 16 | + {{range $tag := split .annotation.Tags.String ","}}<span class="text-xs bg-spot-hover text-spot-secondary px-2.5 py-0.5 rounded-full">{{$tag}}</span>{{end}} |
| 12 | </div> | 17 | </div> |
| 13 | {{end}} | 18 | {{end}} |
| 14 | - {{if .Rating.Valid}} | 19 | + {{if .annotation.Rating.Valid}} |
| 15 | - <div class="text-sm text-spot-orange mt-1">{{repeat "★" (int .Rating.Int64)}}</div> | 20 | + <div class="text-sm text-spot-orange mt-2 tracking-wide">{{repeat "★" (int .annotation.Rating.Int64)}}</div> |
| 16 | {{end}} | 21 | {{end}} |
| 17 | - <div class="text-xs text-spot-muted mt-2"> | 22 | + <div class="flex items-center justify-between mt-3 pt-3 border-t border-spot-divider"> |
| 18 | - <a href="/profile/{{.AuthorDID}}" class="hover:text-spot-green transition">{{.AuthorHandle}}</a> | 23 | + <div class="flex items-center gap-2 text-xs text-spot-muted"> |
| 19 | - {{if .CreatedAt.Valid}}<span class="ml-2">{{.CreatedAt.Time.Format "Jan 2, 2006 15:04"}}</span>{{end}} | 24 | + <a href="/profile/{{.annotation.AuthorDID}}" class="hover:text-spot-green transition">{{.annotation.AuthorHandle}}</a> |
| 25 | + {{if .annotation.CreatedAt.Valid}}<span>·</span><span>{{.annotation.CreatedAt.Time.Format "Jan 2, 2006 15:04"}}</span>{{end}} | ||
| 26 | + </div> | ||
| 27 | + {{if eq .annotation.AuthorDID .userDID}} | ||
| 28 | + <div class="flex items-center gap-2"> | ||
| 29 | + <button class="text-xs text-spot-secondary hover:text-spot-text transition" onclick="editAnnotation(this, {{.annotation.ID}}, '{{js .annotation.FeedURL}}', '{{js .annotation.ArticleURL}}', '{{js .annotation.Quote.String}}', '{{js .annotation.Note.String}}', '{{js .annotation.Tags.String}}')">Edit</button> | ||
| 30 | + <button hx-post="/library/{{.annotation.ID}}/delete" hx-target="#annotation-{{.annotation.ID}}" hx-swap="outerHTML swap:0.3s" hx-confirm="Delete this annotation?" | ||
| 31 | + class="text-xs text-spot-secondary hover:text-spot-red transition">Delete</button> | ||
| 32 | + </div> | ||
| 33 | + {{end}} | ||
| 20 | </div> | 34 | </div> |
| 21 | </div> | 35 | </div> |
| 22 | {{end}} | 36 | {{end}} |
modified
internal/tmpl/profile.html +1 -1 | @@ -38,7 +38,7 @@ | ||
| 38 | 38 | <h2 class="text-lg font-semibold text-spot-text mb-3">Recent annotations</h2> |
| 39 | 39 | <div class="space-y-3"> |
| 40 | 40 | {{range .Annotations}} |
| 41 | - {{template "annotation-card.html" .}} | |
| 41 | + {{template "annotation-card.html" dict "annotation" . "userDID" $.CurrentUserDID}} | |
| 42 | 42 | {{else}} |
| 43 | 43 | <p class="text-sm text-spot-secondary">No public annotations.</p> |
| 44 | 44 | {{end}} |
| @@ -38,7 +38,7 @@ | |||
| 38 | <h2 class="text-lg font-semibold text-spot-text mb-3">Recent annotations</h2> | 38 | <h2 class="text-lg font-semibold text-spot-text mb-3">Recent annotations</h2> |
| 39 | <div class="space-y-3"> | 39 | <div class="space-y-3"> |
| 40 | {{range .Annotations}} | 40 | {{range .Annotations}} |
| 41 | - {{template "annotation-card.html" .}} | 41 | + {{template "annotation-card.html" dict "annotation" . "userDID" $.CurrentUserDID}} |
| 42 | {{else}} | 42 | {{else}} |
| 43 | <p class="text-sm text-spot-secondary">No public annotations.</p> | 43 | <p class="text-sm text-spot-secondary">No public annotations.</p> |
| 44 | {{end}} | 44 | {{end}} |
modified
main.go +1 -0 | @@ -99,6 +99,7 @@ func main() { | ||
| 99 | 99 | cancel() |
| 100 | 100 | |
| 101 | 101 | fmt.Println("glean stopped") |
| 102 | + os.Exit(0) | |
| 102 | 103 | } |
| 103 | 104 | |
| 104 | 105 | func envOr(key, fallback string) string { |
| @@ -99,6 +99,7 @@ func main() { | |||
| 99 | cancel() | 99 | cancel() |
| 100 | 100 | ||
| 101 | fmt.Println("glean stopped") | 101 | fmt.Println("glean stopped") |
| 102 | + os.Exit(0) | ||
| 102 | } | 103 | } |
| 103 | 104 | ||
| 104 | func envOr(key, fallback string) string { | 105 | func envOr(key, fallback string) string { |