Add expanded view setting with full content and auto-readUnverified
20597e8 parent: 29d5292 modified
docs/specs.md +10 -2 | @@ -412,6 +412,7 @@ The `/articles` page is the main reading view: | ||
| 412 | 412 | - **Open original**: Title links to the source article |
| 413 | 413 | - **Share**: Share to Bluesky |
| 414 | 414 | - **Keyboard navigation**: `j`/`k` to navigate, `l` to like, `m` to mark read (progressive enhancement via a small `<script>` block) |
| 415 | +- **Expanded view**: User setting that shows full article content inline on the articles page. Articles are automatically marked as read via `IntersectionObserver` after being visible for 3 seconds. YouTube videos are embedded inline. A duplicate like button appears at the bottom of each article. Configurable in profile settings. | |
| 415 | 416 | |
| 416 | 417 | ### 4.7 Feed Discovery from Content |
| 417 | 418 | |
| @@ -488,8 +489,14 @@ CREATE TABLE users ( | ||
| 488 | 489 | did TEXT PRIMARY KEY, |
| 489 | 490 | indexed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
| 490 | 491 | updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
| 491 | - follows_dirty BOOLEAN NOT NULL DEFAULT 1, | |
| 492 | - languages TEXT | |
| 492 | + follows_dirty BOOLEAN NOT NULL DEFAULT 1 | |
| 493 | +); | |
| 494 | + | |
| 495 | +CREATE TABLE user_settings ( | |
| 496 | + did TEXT PRIMARY KEY, | |
| 497 | + languages TEXT, | |
| 498 | + expanded_view BOOLEAN NOT NULL DEFAULT 0, | |
| 499 | + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | |
| 493 | 500 | ); |
| 494 | 501 | ``` |
| 495 | 502 | |
| @@ -956,6 +963,7 @@ The server renders HTML fragments that htmx swaps into the page. No JSON API nee | ||
| 956 | 963 | | `/stats` | GET | Application metrics and performance data (Prometheus, public) | |
| 957 | 964 | | `/profile/{did}` | GET | Public profile: their feeds, likes, annotations | |
| 958 | 965 | | `/settings/languages` | POST | Save preferred recommendation languages (htmx, requires auth) | |
| 966 | +| `/settings/expanded-view` | POST | Toggle expanded article view setting (htmx, requires auth) | | |
| 959 | 967 | | `/auth/login` | GET | Login page | |
| 960 | 968 | | `/auth/register` | GET | Register with Eurosky (OAuth flow with hardcoded PDS) | |
| 961 | 969 | | `/auth/resolve` | GET | Resolve handle to DID | |
| @@ -412,6 +412,7 @@ The `/articles` page is the main reading view: | |||
| 412 | - **Open original**: Title links to the source article | 412 | - **Open original**: Title links to the source article |
| 413 | - **Share**: Share to Bluesky | 413 | - **Share**: Share to Bluesky |
| 414 | - **Keyboard navigation**: `j`/`k` to navigate, `l` to like, `m` to mark read (progressive enhancement via a small `<script>` block) | 414 | - **Keyboard navigation**: `j`/`k` to navigate, `l` to like, `m` to mark read (progressive enhancement via a small `<script>` block) |
| 415 | +- **Expanded view**: User setting that shows full article content inline on the articles page. Articles are automatically marked as read via `IntersectionObserver` after being visible for 3 seconds. YouTube videos are embedded inline. A duplicate like button appears at the bottom of each article. Configurable in profile settings. | ||
| 415 | 416 | ||
| 416 | ### 4.7 Feed Discovery from Content | 417 | ### 4.7 Feed Discovery from Content |
| 417 | 418 | ||
| @@ -488,8 +489,14 @@ CREATE TABLE users ( | |||
| 488 | did TEXT PRIMARY KEY, | 489 | did TEXT PRIMARY KEY, |
| 489 | indexed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | 490 | indexed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
| 490 | updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | 491 | updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
| 491 | - follows_dirty BOOLEAN NOT NULL DEFAULT 1, | 492 | + follows_dirty BOOLEAN NOT NULL DEFAULT 1 |
| 492 | - languages TEXT | 493 | +); |
| 494 | + | ||
| 495 | +CREATE TABLE user_settings ( | ||
| 496 | + did TEXT PRIMARY KEY, | ||
| 497 | + languages TEXT, | ||
| 498 | + expanded_view BOOLEAN NOT NULL DEFAULT 0, | ||
| 499 | + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | ||
| 493 | ); | 500 | ); |
| 494 | ``` | 501 | ``` |
| 495 | 502 | ||
| @@ -956,6 +963,7 @@ The server renders HTML fragments that htmx swaps into the page. No JSON API nee | |||
| 956 | | `/stats` | GET | Application metrics and performance data (Prometheus, public) | | 963 | | `/stats` | GET | Application metrics and performance data (Prometheus, public) | |
| 957 | | `/profile/{did}` | GET | Public profile: their feeds, likes, annotations | | 964 | | `/profile/{did}` | GET | Public profile: their feeds, likes, annotations | |
| 958 | | `/settings/languages` | POST | Save preferred recommendation languages (htmx, requires auth) | | 965 | | `/settings/languages` | POST | Save preferred recommendation languages (htmx, requires auth) | |
| 966 | +| `/settings/expanded-view` | POST | Toggle expanded article view setting (htmx, requires auth) | | ||
| 959 | | `/auth/login` | GET | Login page | | 967 | | `/auth/login` | GET | Login page | |
| 960 | | `/auth/register` | GET | Register with Eurosky (OAuth flow with hardcoded PDS) | | 968 | | `/auth/register` | GET | Register with Eurosky (OAuth flow with hardcoded PDS) | |
| 961 | | `/auth/resolve` | GET | Resolve handle to DID | | 969 | | `/auth/resolve` | GET | Resolve handle to DID | |
modified
internal/atproto/stream_handler_test.go +1 -1 | @@ -116,7 +116,7 @@ func TestHandleMarginNote_SkipsWhenSameContentDifferentURI(t *testing.T) { | ||
| 116 | 116 | err := dbs.Articles.CreateAnnotation(ctx, &db.Annotation{ |
| 117 | 117 | URI: "at://did:test:u1/at.glean.annotation/rkey3", AuthorDID: "did:test:u1", |
| 118 | 118 | FeedURL: "https://a.com/feed", ArticleURL: "https://a.com/1", |
| 119 | - Note: db.NullStr("identical note"), | |
| 119 | + Note: db.NullStr("identical note"), | |
| 120 | 120 | CreatedAt: db.NullTime(time.Now()), |
| 121 | 121 | }) |
| 122 | 122 | assert.NilError(t, err) |
| @@ -116,7 +116,7 @@ func TestHandleMarginNote_SkipsWhenSameContentDifferentURI(t *testing.T) { | |||
| 116 | err := dbs.Articles.CreateAnnotation(ctx, &db.Annotation{ | 116 | err := dbs.Articles.CreateAnnotation(ctx, &db.Annotation{ |
| 117 | URI: "at://did:test:u1/at.glean.annotation/rkey3", AuthorDID: "did:test:u1", | 117 | URI: "at://did:test:u1/at.glean.annotation/rkey3", AuthorDID: "did:test:u1", |
| 118 | FeedURL: "https://a.com/feed", ArticleURL: "https://a.com/1", | 118 | FeedURL: "https://a.com/feed", ArticleURL: "https://a.com/1", |
| 119 | - Note: db.NullStr("identical note"), | 119 | + Note: db.NullStr("identical note"), |
| 120 | CreatedAt: db.NullTime(time.Now()), | 120 | CreatedAt: db.NullTime(time.Now()), |
| 121 | }) | 121 | }) |
| 122 | assert.NilError(t, err) | 122 | assert.NilError(t, err) |
modified
internal/db/article.go +1 -1 | @@ -472,7 +472,7 @@ func (s *ArticleStore) SearchArticles(ctx context.Context, userDID, query string | ||
| 472 | 472 | COALESCE(lc.cnt, 0), |
| 473 | 473 | COALESCE(ul.liked, 0) |
| 474 | 474 | FROM ( |
| 475 | - SELECT rowid, rank FROM articles.articles_fts WHERE articles_fts MATCH ? | |
| 475 | + SELECT rowid, rank FROM articles.articles_fts WHERE articles.articles_fts MATCH ? | |
| 476 | 476 | ) ft |
| 477 | 477 | JOIN articles.articles a ON a.id = ft.rowid |
| 478 | 478 | JOIN articles.subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ? |
| @@ -472,7 +472,7 @@ func (s *ArticleStore) SearchArticles(ctx context.Context, userDID, query string | |||
| 472 | COALESCE(lc.cnt, 0), | 472 | COALESCE(lc.cnt, 0), |
| 473 | COALESCE(ul.liked, 0) | 473 | COALESCE(ul.liked, 0) |
| 474 | FROM ( | 474 | FROM ( |
| 475 | - SELECT rowid, rank FROM articles.articles_fts WHERE articles_fts MATCH ? | 475 | + SELECT rowid, rank FROM articles.articles_fts WHERE articles.articles_fts MATCH ? |
| 476 | ) ft | 476 | ) ft |
| 477 | JOIN articles.articles a ON a.id = ft.rowid | 477 | JOIN articles.articles a ON a.id = ft.rowid |
| 478 | JOIN articles.subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ? | 478 | JOIN articles.subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ? |
modified
internal/db/migrations.go +20 -1 | @@ -13,7 +13,7 @@ func init() { | ||
| 13 | 13 | } |
| 14 | 14 | |
| 15 | 15 | // SchemaVersion must be incremented each time a migration is added to the migrations slice (used so that fresh dbs skip running migrations). |
| 16 | -const SchemaVersion = 4 | |
| 16 | +const SchemaVersion = 5 | |
| 17 | 17 | |
| 18 | 18 | type migration struct { |
| 19 | 19 | id int |
| @@ -42,6 +42,11 @@ var migrations = []migration{ | ||
| 42 | 42 | name: "jetstream_cursor", |
| 43 | 43 | run: migrateJetstreamCursor, |
| 44 | 44 | }, |
| 45 | + { | |
| 46 | + id: 5, | |
| 47 | + name: "user_settings_expanded_view", | |
| 48 | + run: migrateUserSettingsExpandedView, | |
| 49 | + }, | |
| 45 | 50 | } |
| 46 | 51 | |
| 47 | 52 | func runMigrations(db *DB) error { |
| @@ -229,3 +234,17 @@ func migrateJetstreamCursor(db *DB) error { | ||
| 229 | 234 | } |
| 230 | 235 | return nil |
| 231 | 236 | } |
| 237 | + | |
| 238 | +func migrateUserSettingsExpandedView(db *DB) error { | |
| 239 | + var colCount int | |
| 240 | + err := db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('user_settings') WHERE name='expanded_view'").Scan(&colCount) | |
| 241 | + if err != nil { | |
| 242 | + return fmt.Errorf("check user_settings.expanded_view column: %w", err) | |
| 243 | + } | |
| 244 | + if colCount == 0 { | |
| 245 | + if _, err := db.Exec("ALTER TABLE user_settings ADD COLUMN expanded_view BOOLEAN NOT NULL DEFAULT 0"); err != nil { | |
| 246 | + return fmt.Errorf("add user_settings.expanded_view: %w", err) | |
| 247 | + } | |
| 248 | + } | |
| 249 | + return nil | |
| 250 | +} | |
| @@ -13,7 +13,7 @@ func init() { | |||
| 13 | } | 13 | } |
| 14 | 14 | ||
| 15 | // SchemaVersion must be incremented each time a migration is added to the migrations slice (used so that fresh dbs skip running migrations). | 15 | // SchemaVersion must be incremented each time a migration is added to the migrations slice (used so that fresh dbs skip running migrations). |
| 16 | -const SchemaVersion = 4 | 16 | +const SchemaVersion = 5 |
| 17 | 17 | ||
| 18 | type migration struct { | 18 | type migration struct { |
| 19 | id int | 19 | id int |
| @@ -42,6 +42,11 @@ var migrations = []migration{ | |||
| 42 | name: "jetstream_cursor", | 42 | name: "jetstream_cursor", |
| 43 | run: migrateJetstreamCursor, | 43 | run: migrateJetstreamCursor, |
| 44 | }, | 44 | }, |
| 45 | + { | ||
| 46 | + id: 5, | ||
| 47 | + name: "user_settings_expanded_view", | ||
| 48 | + run: migrateUserSettingsExpandedView, | ||
| 49 | + }, | ||
| 45 | } | 50 | } |
| 46 | 51 | ||
| 47 | func runMigrations(db *DB) error { | 52 | func runMigrations(db *DB) error { |
| @@ -229,3 +234,17 @@ func migrateJetstreamCursor(db *DB) error { | |||
| 229 | } | 234 | } |
| 230 | return nil | 235 | return nil |
| 231 | } | 236 | } |
| 237 | + | ||
| 238 | +func migrateUserSettingsExpandedView(db *DB) error { | ||
| 239 | + var colCount int | ||
| 240 | + err := db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('user_settings') WHERE name='expanded_view'").Scan(&colCount) | ||
| 241 | + if err != nil { | ||
| 242 | + return fmt.Errorf("check user_settings.expanded_view column: %w", err) | ||
| 243 | + } | ||
| 244 | + if colCount == 0 { | ||
| 245 | + if _, err := db.Exec("ALTER TABLE user_settings ADD COLUMN expanded_view BOOLEAN NOT NULL DEFAULT 0"); err != nil { | ||
| 246 | + return fmt.Errorf("add user_settings.expanded_view: %w", err) | ||
| 247 | + } | ||
| 248 | + } | ||
| 249 | + return nil | ||
| 250 | +} | ||
modified
internal/db/user_settings.go +14 -4 | @@ -7,20 +7,22 @@ import ( | ||
| 7 | 7 | ) |
| 8 | 8 | |
| 9 | 9 | type UserSettings struct { |
| 10 | - DID string | |
| 11 | - Languages []string | |
| 10 | + DID string | |
| 11 | + Languages []string | |
| 12 | + ExpandedView bool | |
| 12 | 13 | } |
| 13 | 14 | |
| 14 | 15 | func (s *UserStore) GetSettings(ctx context.Context, did string) (*UserSettings, error) { |
| 15 | 16 | var langs sql.NullString |
| 16 | - err := s.db.QueryRowContext(ctx, `SELECT languages FROM user_settings WHERE did = ?`, did).Scan(&langs) | |
| 17 | + var expandedView bool | |
| 18 | + err := s.db.QueryRowContext(ctx, `SELECT languages, COALESCE(expanded_view, 0) FROM user_settings WHERE did = ?`, did).Scan(&langs, &expandedView) | |
| 17 | 19 | if err != nil { |
| 18 | 20 | if err == sql.ErrNoRows { |
| 19 | 21 | return &UserSettings{DID: did}, nil |
| 20 | 22 | } |
| 21 | 23 | return nil, err |
| 22 | 24 | } |
| 23 | - us := &UserSettings{DID: did} | |
| 25 | + us := &UserSettings{DID: did, ExpandedView: expandedView} | |
| 24 | 26 | if langs.Valid && langs.String != "" { |
| 25 | 27 | us.Languages = strings.Split(langs.String, ",") |
| 26 | 28 | } |
| @@ -46,3 +48,11 @@ func (s *UserStore) GetLanguages(ctx context.Context, did string) ([]string, err | ||
| 46 | 48 | } |
| 47 | 49 | return us.Languages, nil |
| 48 | 50 | } |
| 51 | + | |
| 52 | +func (s *UserStore) SetExpandedView(ctx context.Context, did string, enabled bool) error { | |
| 53 | + _, err := s.db.ExecContext(ctx, ` | |
| 54 | + INSERT INTO user_settings (did, expanded_view, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) | |
| 55 | + ON CONFLICT(did) DO UPDATE SET expanded_view = excluded.expanded_view, updated_at = CURRENT_TIMESTAMP | |
| 56 | + `, did, enabled) | |
| 57 | + return err | |
| 58 | +} | |
| @@ -7,20 +7,22 @@ import ( | |||
| 7 | ) | 7 | ) |
| 8 | 8 | ||
| 9 | type UserSettings struct { | 9 | type UserSettings struct { |
| 10 | - DID string | 10 | + DID string |
| 11 | - Languages []string | 11 | + Languages []string |
| 12 | + ExpandedView bool | ||
| 12 | } | 13 | } |
| 13 | 14 | ||
| 14 | func (s *UserStore) GetSettings(ctx context.Context, did string) (*UserSettings, error) { | 15 | func (s *UserStore) GetSettings(ctx context.Context, did string) (*UserSettings, error) { |
| 15 | var langs sql.NullString | 16 | var langs sql.NullString |
| 16 | - err := s.db.QueryRowContext(ctx, `SELECT languages FROM user_settings WHERE did = ?`, did).Scan(&langs) | 17 | + var expandedView bool |
| 18 | + err := s.db.QueryRowContext(ctx, `SELECT languages, COALESCE(expanded_view, 0) FROM user_settings WHERE did = ?`, did).Scan(&langs, &expandedView) | ||
| 17 | if err != nil { | 19 | if err != nil { |
| 18 | if err == sql.ErrNoRows { | 20 | if err == sql.ErrNoRows { |
| 19 | return &UserSettings{DID: did}, nil | 21 | return &UserSettings{DID: did}, nil |
| 20 | } | 22 | } |
| 21 | return nil, err | 23 | return nil, err |
| 22 | } | 24 | } |
| 23 | - us := &UserSettings{DID: did} | 25 | + us := &UserSettings{DID: did, ExpandedView: expandedView} |
| 24 | if langs.Valid && langs.String != "" { | 26 | if langs.Valid && langs.String != "" { |
| 25 | us.Languages = strings.Split(langs.String, ",") | 27 | us.Languages = strings.Split(langs.String, ",") |
| 26 | } | 28 | } |
| @@ -46,3 +48,11 @@ func (s *UserStore) GetLanguages(ctx context.Context, did string) ([]string, err | |||
| 46 | } | 48 | } |
| 47 | return us.Languages, nil | 49 | return us.Languages, nil |
| 48 | } | 50 | } |
| 51 | + | ||
| 52 | +func (s *UserStore) SetExpandedView(ctx context.Context, did string, enabled bool) error { | ||
| 53 | + _, err := s.db.ExecContext(ctx, ` | ||
| 54 | + INSERT INTO user_settings (did, expanded_view, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) | ||
| 55 | + ON CONFLICT(did) DO UPDATE SET expanded_view = excluded.expanded_view, updated_at = CURRENT_TIMESTAMP | ||
| 56 | + `, did, enabled) | ||
| 57 | + return err | ||
| 58 | +} | ||
modified
internal/server/articles_handler.go +18 -11 | @@ -95,16 +95,23 @@ func (s *Server) handleArticles(w http.ResponseWriter, r *http.Request) { | ||
| 95 | 95 | a.NavSuffix = navSuffix |
| 96 | 96 | } |
| 97 | 97 | |
| 98 | + settings, _ := s.dbs.Users.GetSettings(ctx, user.DID) | |
| 99 | + expandedView := false | |
| 100 | + if settings != nil { | |
| 101 | + expandedView = settings.ExpandedView | |
| 102 | + } | |
| 103 | + | |
| 98 | 104 | data := map[string]any{ |
| 99 | - "User": user, | |
| 100 | - "Articles": articles, | |
| 101 | - "FeedURL": feedURL, | |
| 102 | - "Status": status, | |
| 103 | - "SearchQuery": searchQuery, | |
| 104 | - "Page": page, | |
| 105 | - "BaseURL": "/articles", | |
| 106 | - "QueryParams": buildQueryParams(map[string]string{"feed": feedURL, "status": status, "q": searchQuery}), | |
| 107 | - "Now": time.Now(), | |
| 105 | + "User": user, | |
| 106 | + "Articles": articles, | |
| 107 | + "FeedURL": feedURL, | |
| 108 | + "Status": status, | |
| 109 | + "SearchQuery": searchQuery, | |
| 110 | + "Page": page, | |
| 111 | + "BaseURL": "/articles", | |
| 112 | + "QueryParams": buildQueryParams(map[string]string{"feed": feedURL, "status": status, "q": searchQuery}), | |
| 113 | + "Now": time.Now(), | |
| 114 | + "ExpandedView": expandedView, | |
| 108 | 115 | } |
| 109 | 116 | |
| 110 | 117 | if feedURL != "" { |
| @@ -286,7 +293,7 @@ func (s *Server) handleMarkRead(w http.ResponseWriter, r *http.Request) { | ||
| 286 | 293 | return |
| 287 | 294 | } |
| 288 | 295 | w.Header().Set("Content-Type", "text/html") |
| 289 | - _, _ = fmt.Fprintf(w, `<span id="read-btn-%d" class="text-xs text-spot-green uppercase tracking-button">Read</span>`, id) | |
| 296 | + fmt.Fprintf(w, `<button id="read-btn-%[1]d" hx-post="/articles/%[1]d/unread" hx-target="#read-btn-%[1]d" hx-swap="outerHTML" title="Mark as Unread" class="group inline-flex items-center gap-1 text-[10px] text-spot-text uppercase tracking-button px-2 py-0.5 rounded-pill bg-spot-hover hover:text-spot-green hover:bg-spot-green/15 transition"><svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg><span>Unread</span></button>`, id) | |
| 290 | 297 | } |
| 291 | 298 | |
| 292 | 299 | func (s *Server) handleMarkUnread(w http.ResponseWriter, r *http.Request) { |
| @@ -301,7 +308,7 @@ func (s *Server) handleMarkUnread(w http.ResponseWriter, r *http.Request) { | ||
| 301 | 308 | return |
| 302 | 309 | } |
| 303 | 310 | w.Header().Set("Content-Type", "text/html") |
| 304 | - _, _ = fmt.Fprintf(w, `<span id="read-btn-%d" class="text-xs text-spot-muted uppercase tracking-button"></span>`, id) | |
| 311 | + fmt.Fprintf(w, `<button id="read-btn-%[1]d" hx-post="/articles/%[1]d/read" hx-target="#read-btn-%[1]d" hx-swap="outerHTML" title="Mark as Read" class="group inline-flex items-center gap-1 text-[10px] text-spot-text uppercase tracking-button px-2 py-0.5 rounded-pill bg-spot-hover hover:text-spot-green hover:bg-spot-green/15 transition"><svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg><span>Read</span></button>`, id) | |
| 305 | 312 | } |
| 306 | 313 | |
| 307 | 314 | func (s *Server) handleLikeArticle(w http.ResponseWriter, r *http.Request) { |
| @@ -95,16 +95,23 @@ func (s *Server) handleArticles(w http.ResponseWriter, r *http.Request) { | |||
| 95 | a.NavSuffix = navSuffix | 95 | a.NavSuffix = navSuffix |
| 96 | } | 96 | } |
| 97 | 97 | ||
| 98 | + settings, _ := s.dbs.Users.GetSettings(ctx, user.DID) | ||
| 99 | + expandedView := false | ||
| 100 | + if settings != nil { | ||
| 101 | + expandedView = settings.ExpandedView | ||
| 102 | + } | ||
| 103 | + | ||
| 98 | data := map[string]any{ | 104 | data := map[string]any{ |
| 99 | - "User": user, | 105 | + "User": user, |
| 100 | - "Articles": articles, | 106 | + "Articles": articles, |
| 101 | - "FeedURL": feedURL, | 107 | + "FeedURL": feedURL, |
| 102 | - "Status": status, | 108 | + "Status": status, |
| 103 | - "SearchQuery": searchQuery, | 109 | + "SearchQuery": searchQuery, |
| 104 | - "Page": page, | 110 | + "Page": page, |
| 105 | - "BaseURL": "/articles", | 111 | + "BaseURL": "/articles", |
| 106 | - "QueryParams": buildQueryParams(map[string]string{"feed": feedURL, "status": status, "q": searchQuery}), | 112 | + "QueryParams": buildQueryParams(map[string]string{"feed": feedURL, "status": status, "q": searchQuery}), |
| 107 | - "Now": time.Now(), | 113 | + "Now": time.Now(), |
| 114 | + "ExpandedView": expandedView, | ||
| 108 | } | 115 | } |
| 109 | 116 | ||
| 110 | if feedURL != "" { | 117 | if feedURL != "" { |
| @@ -286,7 +293,7 @@ func (s *Server) handleMarkRead(w http.ResponseWriter, r *http.Request) { | |||
| 286 | return | 293 | return |
| 287 | } | 294 | } |
| 288 | w.Header().Set("Content-Type", "text/html") | 295 | w.Header().Set("Content-Type", "text/html") |
| 289 | - _, _ = fmt.Fprintf(w, `<span id="read-btn-%d" class="text-xs text-spot-green uppercase tracking-button">Read</span>`, id) | 296 | + fmt.Fprintf(w, `<button id="read-btn-%[1]d" hx-post="/articles/%[1]d/unread" hx-target="#read-btn-%[1]d" hx-swap="outerHTML" title="Mark as Unread" class="group inline-flex items-center gap-1 text-[10px] text-spot-text uppercase tracking-button px-2 py-0.5 rounded-pill bg-spot-hover hover:text-spot-green hover:bg-spot-green/15 transition"><svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg><span>Unread</span></button>`, id) |
| 290 | } | 297 | } |
| 291 | 298 | ||
| 292 | func (s *Server) handleMarkUnread(w http.ResponseWriter, r *http.Request) { | 299 | func (s *Server) handleMarkUnread(w http.ResponseWriter, r *http.Request) { |
| @@ -301,7 +308,7 @@ func (s *Server) handleMarkUnread(w http.ResponseWriter, r *http.Request) { | |||
| 301 | return | 308 | return |
| 302 | } | 309 | } |
| 303 | w.Header().Set("Content-Type", "text/html") | 310 | w.Header().Set("Content-Type", "text/html") |
| 304 | - _, _ = fmt.Fprintf(w, `<span id="read-btn-%d" class="text-xs text-spot-muted uppercase tracking-button"></span>`, id) | 311 | + fmt.Fprintf(w, `<button id="read-btn-%[1]d" hx-post="/articles/%[1]d/read" hx-target="#read-btn-%[1]d" hx-swap="outerHTML" title="Mark as Read" class="group inline-flex items-center gap-1 text-[10px] text-spot-text uppercase tracking-button px-2 py-0.5 rounded-pill bg-spot-hover hover:text-spot-green hover:bg-spot-green/15 transition"><svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg><span>Read</span></button>`, id) |
| 305 | } | 312 | } |
| 306 | 313 | ||
| 307 | func (s *Server) handleLikeArticle(w http.ResponseWriter, r *http.Request) { | 314 | func (s *Server) handleLikeArticle(w http.ResponseWriter, r *http.Request) { |
modified
internal/server/profile_handler.go +11 -0 | @@ -86,6 +86,16 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) { | ||
| 86 | 86 | return nil |
| 87 | 87 | }) |
| 88 | 88 | |
| 89 | + userSettings := &db.UserSettings{} | |
| 90 | + g.Go(func() error { | |
| 91 | + var err error | |
| 92 | + userSettings, err = s.dbs.Users.GetSettings(gCtx, user.DID) | |
| 93 | + if err != nil { | |
| 94 | + s.logger.Warn("failed to get user settings", "error", err, "did", user.DID) | |
| 95 | + } | |
| 96 | + return nil | |
| 97 | + }) | |
| 98 | + | |
| 89 | 99 | if err := g.Wait(); err != nil { |
| 90 | 100 | s.logger.Warn("profile error", "error", err, "did", did) |
| 91 | 101 | } |
| @@ -100,5 +110,6 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) { | ||
| 100 | 110 | "AnnotationCount": len(annotations), |
| 101 | 111 | "UserLanguages": userLangs, |
| 102 | 112 | "AvailableLanguages": langdetect.KnownLanguages(), |
| 113 | + "ExpandedView": userSettings.ExpandedView, | |
| 103 | 114 | }) |
| 104 | 115 | } |
| @@ -86,6 +86,16 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) { | |||
| 86 | return nil | 86 | return nil |
| 87 | }) | 87 | }) |
| 88 | 88 | ||
| 89 | + userSettings := &db.UserSettings{} | ||
| 90 | + g.Go(func() error { | ||
| 91 | + var err error | ||
| 92 | + userSettings, err = s.dbs.Users.GetSettings(gCtx, user.DID) | ||
| 93 | + if err != nil { | ||
| 94 | + s.logger.Warn("failed to get user settings", "error", err, "did", user.DID) | ||
| 95 | + } | ||
| 96 | + return nil | ||
| 97 | + }) | ||
| 98 | + | ||
| 89 | if err := g.Wait(); err != nil { | 99 | if err := g.Wait(); err != nil { |
| 90 | s.logger.Warn("profile error", "error", err, "did", did) | 100 | s.logger.Warn("profile error", "error", err, "did", did) |
| 91 | } | 101 | } |
| @@ -100,5 +110,6 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) { | |||
| 100 | "AnnotationCount": len(annotations), | 110 | "AnnotationCount": len(annotations), |
| 101 | "UserLanguages": userLangs, | 111 | "UserLanguages": userLangs, |
| 102 | "AvailableLanguages": langdetect.KnownLanguages(), | 112 | "AvailableLanguages": langdetect.KnownLanguages(), |
| 113 | + "ExpandedView": userSettings.ExpandedView, | ||
| 103 | }) | 114 | }) |
| 104 | } | 115 | } |
modified
internal/server/server.go +1 -0 | @@ -211,6 +211,7 @@ func (s *Server) setupRoutes() { | ||
| 211 | 211 | s.router.Route("/settings", func(r chi.Router) { |
| 212 | 212 | r.Use(s.requireAuth) |
| 213 | 213 | r.Post("/languages", s.handleUpdateLanguages) |
| 214 | + r.Post("/expanded-view", s.handleToggleExpandedView) | |
| 214 | 215 | }) |
| 215 | 216 | |
| 216 | 217 | s.router.Get("/auth/login", s.handleAuthLogin) |
| @@ -211,6 +211,7 @@ func (s *Server) setupRoutes() { | |||
| 211 | s.router.Route("/settings", func(r chi.Router) { | 211 | s.router.Route("/settings", func(r chi.Router) { |
| 212 | r.Use(s.requireAuth) | 212 | r.Use(s.requireAuth) |
| 213 | r.Post("/languages", s.handleUpdateLanguages) | 213 | r.Post("/languages", s.handleUpdateLanguages) |
| 214 | + r.Post("/expanded-view", s.handleToggleExpandedView) | ||
| 214 | }) | 215 | }) |
| 215 | 216 | ||
| 216 | s.router.Get("/auth/login", s.handleAuthLogin) | 217 | s.router.Get("/auth/login", s.handleAuthLogin) |
modified
internal/server/settings_handler.go +20 -0 | @@ -37,3 +37,23 @@ func (s *Server) handleUpdateLanguages(w http.ResponseWriter, r *http.Request) { | ||
| 37 | 37 | w.Header().Set("HX-Redirect", "/profile/"+user.DID) |
| 38 | 38 | w.WriteHeader(http.StatusOK) |
| 39 | 39 | } |
| 40 | + | |
| 41 | +func (s *Server) handleToggleExpandedView(w http.ResponseWriter, r *http.Request) { | |
| 42 | + user := currentUser(r) | |
| 43 | + | |
| 44 | + if err := r.ParseForm(); err != nil { | |
| 45 | + http.Error(w, err.Error(), http.StatusBadRequest) | |
| 46 | + return | |
| 47 | + } | |
| 48 | + | |
| 49 | + enabled := r.FormValue("expanded_view") == "1" | |
| 50 | + | |
| 51 | + if err := s.dbs.Users.SetExpandedView(r.Context(), user.DID, enabled); err != nil { | |
| 52 | + s.logger.Error("failed to update expanded view", "error", err) | |
| 53 | + http.Error(w, err.Error(), http.StatusInternalServerError) | |
| 54 | + return | |
| 55 | + } | |
| 56 | + | |
| 57 | + w.Header().Set("HX-Redirect", "/profile/"+user.DID) | |
| 58 | + w.WriteHeader(http.StatusOK) | |
| 59 | +} | |
| @@ -37,3 +37,23 @@ func (s *Server) handleUpdateLanguages(w http.ResponseWriter, r *http.Request) { | |||
| 37 | w.Header().Set("HX-Redirect", "/profile/"+user.DID) | 37 | w.Header().Set("HX-Redirect", "/profile/"+user.DID) |
| 38 | w.WriteHeader(http.StatusOK) | 38 | w.WriteHeader(http.StatusOK) |
| 39 | } | 39 | } |
| 40 | + | ||
| 41 | +func (s *Server) handleToggleExpandedView(w http.ResponseWriter, r *http.Request) { | ||
| 42 | + user := currentUser(r) | ||
| 43 | + | ||
| 44 | + if err := r.ParseForm(); err != nil { | ||
| 45 | + http.Error(w, err.Error(), http.StatusBadRequest) | ||
| 46 | + return | ||
| 47 | + } | ||
| 48 | + | ||
| 49 | + enabled := r.FormValue("expanded_view") == "1" | ||
| 50 | + | ||
| 51 | + if err := s.dbs.Users.SetExpandedView(r.Context(), user.DID, enabled); err != nil { | ||
| 52 | + s.logger.Error("failed to update expanded view", "error", err) | ||
| 53 | + http.Error(w, err.Error(), http.StatusInternalServerError) | ||
| 54 | + return | ||
| 55 | + } | ||
| 56 | + | ||
| 57 | + w.Header().Set("HX-Redirect", "/profile/"+user.DID) | ||
| 58 | + w.WriteHeader(http.StatusOK) | ||
| 59 | +} | ||
modified
internal/tmpl/article_detail.html +3 -3 | @@ -41,8 +41,8 @@ | ||
| 41 | 41 | </button> |
| 42 | 42 | |
| 43 | 43 | <button hx-post="/articles/{{.Article.ID}}/{{if .ReadState.IsRead}}unread{{else}}read{{end}}" |
| 44 | - hx-target="#read-btn" hx-swap="outerHTML" | |
| 45 | - id="read-btn" | |
| 44 | + hx-target="#read-btn-{{.Article.ID}}" hx-swap="outerHTML" | |
| 45 | + id="read-btn-{{.Article.ID}}" | |
| 46 | 46 | title="{{if .ReadState.IsRead}}Mark as Unread{{else}}Mark as Read{{end}}" |
| 47 | 47 | class="group inline-flex items-center gap-1.5 text-[10px] uppercase tracking-button px-2.5 py-1 rounded-pill transition {{if .ReadState.IsRead}}text-spot-text bg-spot-hover hover:text-spot-green hover:bg-spot-green/15{{else}}text-spot-text bg-spot-hover hover:text-spot-green hover:bg-spot-green/15{{end}}"> |
| 48 | 48 | <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg> |
| @@ -274,7 +274,7 @@ | ||
| 274 | 274 | var btn = document.querySelector('[hx-post*="/like"]'); |
| 275 | 275 | if (btn) btn.click(); |
| 276 | 276 | } else if (e.key === 'm') { |
| 277 | - var rbtn = document.getElementById('read-btn'); | |
| 277 | + var rbtn = document.getElementById('read-btn-{{.Article.ID}}'); | |
| 278 | 278 | if (rbtn) rbtn.click(); |
| 279 | 279 | } else if (e.key === 'Escape') { |
| 280 | 280 | history.back(); |
| @@ -41,8 +41,8 @@ | |||
| 41 | </button> | 41 | </button> |
| 42 | 42 | ||
| 43 | <button hx-post="/articles/{{.Article.ID}}/{{if .ReadState.IsRead}}unread{{else}}read{{end}}" | 43 | <button hx-post="/articles/{{.Article.ID}}/{{if .ReadState.IsRead}}unread{{else}}read{{end}}" |
| 44 | - hx-target="#read-btn" hx-swap="outerHTML" | 44 | + hx-target="#read-btn-{{.Article.ID}}" hx-swap="outerHTML" |
| 45 | - id="read-btn" | 45 | + id="read-btn-{{.Article.ID}}" |
| 46 | title="{{if .ReadState.IsRead}}Mark as Unread{{else}}Mark as Read{{end}}" | 46 | title="{{if .ReadState.IsRead}}Mark as Unread{{else}}Mark as Read{{end}}" |
| 47 | class="group inline-flex items-center gap-1.5 text-[10px] uppercase tracking-button px-2.5 py-1 rounded-pill transition {{if .ReadState.IsRead}}text-spot-text bg-spot-hover hover:text-spot-green hover:bg-spot-green/15{{else}}text-spot-text bg-spot-hover hover:text-spot-green hover:bg-spot-green/15{{end}}"> | 47 | class="group inline-flex items-center gap-1.5 text-[10px] uppercase tracking-button px-2.5 py-1 rounded-pill transition {{if .ReadState.IsRead}}text-spot-text bg-spot-hover hover:text-spot-green hover:bg-spot-green/15{{else}}text-spot-text bg-spot-hover hover:text-spot-green hover:bg-spot-green/15{{end}}"> |
| 48 | <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg> | 48 | <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg> |
| @@ -274,7 +274,7 @@ | |||
| 274 | var btn = document.querySelector('[hx-post*="/like"]'); | 274 | var btn = document.querySelector('[hx-post*="/like"]'); |
| 275 | if (btn) btn.click(); | 275 | if (btn) btn.click(); |
| 276 | } else if (e.key === 'm') { | 276 | } else if (e.key === 'm') { |
| 277 | - var rbtn = document.getElementById('read-btn'); | 277 | + var rbtn = document.getElementById('read-btn-{{.Article.ID}}'); |
| 278 | if (rbtn) rbtn.click(); | 278 | if (rbtn) rbtn.click(); |
| 279 | } else if (e.key === 'Escape') { | 279 | } else if (e.key === 'Escape') { |
| 280 | history.back(); | 280 | history.back(); |
modified
internal/tmpl/articles.html +36 -0 | @@ -118,6 +118,42 @@ | ||
| 118 | 118 | if (rbtn) rbtn.click(); |
| 119 | 119 | } |
| 120 | 120 | }); |
| 121 | + | |
| 122 | + {{if .ExpandedView}} | |
| 123 | + // Wait 3s before marking read so fast scrolling doesn't mark everything. | |
| 124 | + // Cancel the timer if the article leaves the viewport before it fires. | |
| 125 | + function markRead(el) { | |
| 126 | + var id = el.getAttribute('data-article-id'); | |
| 127 | + if (!id || el.getAttribute('data-marked-read')) return; | |
| 128 | + el.setAttribute('data-marked-read', '1'); | |
| 129 | + fetch('/articles/' + id + '/read', { | |
| 130 | + method: 'POST', | |
| 131 | + headers: {'X-CSRF-Token': document.querySelector('input[name="csrf_token"]') ? document.querySelector('input[name="csrf_token"]').value : ''} | |
| 132 | + }).then(function() { | |
| 133 | + var dot = el.querySelector('.bg-spot-green.rounded-full'); | |
| 134 | + if (dot) { dot.classList.remove('bg-spot-green'); dot.classList.add('bg-spot-muted'); } | |
| 135 | + }).catch(function() {}); | |
| 136 | + } | |
| 137 | + var readTimers = {}; | |
| 138 | + var observer = new IntersectionObserver(function(entries) { | |
| 139 | + entries.forEach(function(entry) { | |
| 140 | + var id = entry.target.getAttribute('data-article-id'); | |
| 141 | + if (!id) return; | |
| 142 | + if (entry.isIntersecting) { | |
| 143 | + readTimers[id] = setTimeout(function() { markRead(entry.target); }, 3000); | |
| 144 | + } else { | |
| 145 | + clearTimeout(readTimers[id]); | |
| 146 | + delete readTimers[id]; | |
| 147 | + } | |
| 148 | + }); | |
| 149 | + }, {rootMargin: '0px 0px -50% 0px', threshold: 0}); | |
| 150 | + | |
| 151 | + articles.forEach(function(a) { | |
| 152 | + if (a.getAttribute('data-expanded') === 'true') { | |
| 153 | + observer.observe(a); | |
| 154 | + } | |
| 155 | + }); | |
| 156 | + {{end}} | |
| 121 | 157 | })(); |
| 122 | 158 | </script> |
| 123 | 159 | {{end}} |
| @@ -118,6 +118,42 @@ | |||
| 118 | if (rbtn) rbtn.click(); | 118 | if (rbtn) rbtn.click(); |
| 119 | } | 119 | } |
| 120 | }); | 120 | }); |
| 121 | + | ||
| 122 | + {{if .ExpandedView}} | ||
| 123 | + // Wait 3s before marking read so fast scrolling doesn't mark everything. | ||
| 124 | + // Cancel the timer if the article leaves the viewport before it fires. | ||
| 125 | + function markRead(el) { | ||
| 126 | + var id = el.getAttribute('data-article-id'); | ||
| 127 | + if (!id || el.getAttribute('data-marked-read')) return; | ||
| 128 | + el.setAttribute('data-marked-read', '1'); | ||
| 129 | + fetch('/articles/' + id + '/read', { | ||
| 130 | + method: 'POST', | ||
| 131 | + headers: {'X-CSRF-Token': document.querySelector('input[name="csrf_token"]') ? document.querySelector('input[name="csrf_token"]').value : ''} | ||
| 132 | + }).then(function() { | ||
| 133 | + var dot = el.querySelector('.bg-spot-green.rounded-full'); | ||
| 134 | + if (dot) { dot.classList.remove('bg-spot-green'); dot.classList.add('bg-spot-muted'); } | ||
| 135 | + }).catch(function() {}); | ||
| 136 | + } | ||
| 137 | + var readTimers = {}; | ||
| 138 | + var observer = new IntersectionObserver(function(entries) { | ||
| 139 | + entries.forEach(function(entry) { | ||
| 140 | + var id = entry.target.getAttribute('data-article-id'); | ||
| 141 | + if (!id) return; | ||
| 142 | + if (entry.isIntersecting) { | ||
| 143 | + readTimers[id] = setTimeout(function() { markRead(entry.target); }, 3000); | ||
| 144 | + } else { | ||
| 145 | + clearTimeout(readTimers[id]); | ||
| 146 | + delete readTimers[id]; | ||
| 147 | + } | ||
| 148 | + }); | ||
| 149 | + }, {rootMargin: '0px 0px -50% 0px', threshold: 0}); | ||
| 150 | + | ||
| 151 | + articles.forEach(function(a) { | ||
| 152 | + if (a.getAttribute('data-expanded') === 'true') { | ||
| 153 | + observer.observe(a); | ||
| 154 | + } | ||
| 155 | + }); | ||
| 156 | + {{end}} | ||
| 121 | })(); | 157 | })(); |
| 122 | </script> | 158 | </script> |
| 123 | {{end}} | 159 | {{end}} |
added
internal/tmpl/partials/article-card-expanded.html +47 -0 | new file mode 100644 | ||
| @@ -0,0 +1,47 @@ | ||
| 1 | +{{define "article-card-expanded.html"}} | |
| 2 | +<article data-article-id="{{.ID}}" data-feed-url="{{.FeedURL}}" data-article-url="{{if .URL.Valid}}{{.URL.String}}{{end}}" data-expanded="true" class="bg-spot-surface rounded-xl px-5 py-4 shadow-spot relative"> | |
| 3 | + <div class="flex items-start justify-between gap-4"> | |
| 4 | + <div class="min-w-0 flex-1"> | |
| 5 | + <div class="flex items-start gap-2.5"> | |
| 6 | + <span class="w-2 h-2 rounded-full shrink-0 mt-2 {{if .IsRead.Bool}}bg-spot-muted{{else}}bg-spot-green{{end}}"></span> | |
| 7 | + <a href="/articles/{{.ID}}{{.NavSuffix}}" class="font-semibold text-spot-text hover:text-spot-green transition leading-snug {{if .IsRead.Bool}}text-[15px]{{else}}text-[17px]{{end}}">{{.Title}}</a> | |
| 8 | + </div> | |
| 9 | + <div class="text-xs text-spot-secondary mt-2 flex items-center gap-1.5 flex-wrap pl-[18px]"> | |
| 10 | + {{template "favicon" dict "src" .FeedFaviconURL.String "size" "w-3.5 h-3.5"}} | |
| 11 | + <a href="/articles?feed={{.FeedURL}}" class="hover:text-spot-text transition font-medium">{{if .FeedTitle}}{{.FeedTitle}}{{else}}{{.FeedURL}}{{end}}</a> | |
| 12 | + {{if .Author.Valid}}{{if .Author.String}}<span class="text-spot-muted">·</span><span>{{.Author.String}}</span>{{end}}{{end}} | |
| 13 | + {{if .Published.Valid}}<span class="text-spot-muted">·</span><span>{{.Published.Time.Format "Jan 2, 2006 15:04"}}</span>{{end}} | |
| 14 | + </div> | |
| 15 | + </div> | |
| 16 | + <div class="flex items-center gap-1.5 shrink-0 pt-0.5"> | |
| 17 | + <div>{{template "like-button.html" .}}</div> | |
| 18 | + </div> | |
| 19 | + </div> | |
| 20 | + | |
| 21 | + {{if .URL.Valid}} | |
| 22 | + {{with youtubeID .URL.String}} | |
| 23 | + <div class="mt-4 aspect-video w-full rounded-xl overflow-hidden shadow-spot-heavy"> | |
| 24 | + <iframe class="w-full h-full" src="https://www.youtube.com/embed/{{.}}" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe> | |
| 25 | + </div> | |
| 26 | + {{end}} | |
| 27 | + {{end}} | |
| 28 | + | |
| 29 | + {{if or .Content.Valid .FullContent.Valid .Summary.Valid}} | |
| 30 | + <div class="mt-4 pt-4 border-t border-spot-divider"> | |
| 31 | + <div class="article-body text-sm"> | |
| 32 | + {{if .Content.Valid}}{{sanitizeHTML .Content.String}} | |
| 33 | + {{else if .FullContent.Valid}}{{sanitizeHTML .FullContent.String}} | |
| 34 | + {{else if .Summary.Valid}}{{sanitizeHTML .Summary.String}}{{end}} | |
| 35 | + </div> | |
| 36 | + </div> | |
| 37 | + {{else}} | |
| 38 | + <div class="mt-4 pt-4 border-t border-spot-divider"> | |
| 39 | + <a href="/articles/{{.ID}}{{.NavSuffix}}" class="text-spot-green hover:underline text-sm">Read full article →</a> | |
| 40 | + </div> | |
| 41 | + {{end}} | |
| 42 | + | |
| 43 | + <div class="mt-4 pt-4 border-t border-spot-divider flex items-center justify-end gap-1.5"> | |
| 44 | + <div>{{template "like-button.html" .}}</div> | |
| 45 | + </div> | |
| 46 | +</article> | |
| 47 | +{{end}} | |
| \ No newline at end of file | ||
| new file mode 100644 | |||
| @@ -0,0 +1,47 @@ | |||
| 1 | +{{define "article-card-expanded.html"}} | ||
| 2 | +<article data-article-id="{{.ID}}" data-feed-url="{{.FeedURL}}" data-article-url="{{if .URL.Valid}}{{.URL.String}}{{end}}" data-expanded="true" class="bg-spot-surface rounded-xl px-5 py-4 shadow-spot relative"> | ||
| 3 | + <div class="flex items-start justify-between gap-4"> | ||
| 4 | + <div class="min-w-0 flex-1"> | ||
| 5 | + <div class="flex items-start gap-2.5"> | ||
| 6 | + <span class="w-2 h-2 rounded-full shrink-0 mt-2 {{if .IsRead.Bool}}bg-spot-muted{{else}}bg-spot-green{{end}}"></span> | ||
| 7 | + <a href="/articles/{{.ID}}{{.NavSuffix}}" class="font-semibold text-spot-text hover:text-spot-green transition leading-snug {{if .IsRead.Bool}}text-[15px]{{else}}text-[17px]{{end}}">{{.Title}}</a> | ||
| 8 | + </div> | ||
| 9 | + <div class="text-xs text-spot-secondary mt-2 flex items-center gap-1.5 flex-wrap pl-[18px]"> | ||
| 10 | + {{template "favicon" dict "src" .FeedFaviconURL.String "size" "w-3.5 h-3.5"}} | ||
| 11 | + <a href="/articles?feed={{.FeedURL}}" class="hover:text-spot-text transition font-medium">{{if .FeedTitle}}{{.FeedTitle}}{{else}}{{.FeedURL}}{{end}}</a> | ||
| 12 | + {{if .Author.Valid}}{{if .Author.String}}<span class="text-spot-muted">·</span><span>{{.Author.String}}</span>{{end}}{{end}} | ||
| 13 | + {{if .Published.Valid}}<span class="text-spot-muted">·</span><span>{{.Published.Time.Format "Jan 2, 2006 15:04"}}</span>{{end}} | ||
| 14 | + </div> | ||
| 15 | + </div> | ||
| 16 | + <div class="flex items-center gap-1.5 shrink-0 pt-0.5"> | ||
| 17 | + <div>{{template "like-button.html" .}}</div> | ||
| 18 | + </div> | ||
| 19 | + </div> | ||
| 20 | + | ||
| 21 | + {{if .URL.Valid}} | ||
| 22 | + {{with youtubeID .URL.String}} | ||
| 23 | + <div class="mt-4 aspect-video w-full rounded-xl overflow-hidden shadow-spot-heavy"> | ||
| 24 | + <iframe class="w-full h-full" src="https://www.youtube.com/embed/{{.}}" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe> | ||
| 25 | + </div> | ||
| 26 | + {{end}} | ||
| 27 | + {{end}} | ||
| 28 | + | ||
| 29 | + {{if or .Content.Valid .FullContent.Valid .Summary.Valid}} | ||
| 30 | + <div class="mt-4 pt-4 border-t border-spot-divider"> | ||
| 31 | + <div class="article-body text-sm"> | ||
| 32 | + {{if .Content.Valid}}{{sanitizeHTML .Content.String}} | ||
| 33 | + {{else if .FullContent.Valid}}{{sanitizeHTML .FullContent.String}} | ||
| 34 | + {{else if .Summary.Valid}}{{sanitizeHTML .Summary.String}}{{end}} | ||
| 35 | + </div> | ||
| 36 | + </div> | ||
| 37 | + {{else}} | ||
| 38 | + <div class="mt-4 pt-4 border-t border-spot-divider"> | ||
| 39 | + <a href="/articles/{{.ID}}{{.NavSuffix}}" class="text-spot-green hover:underline text-sm">Read full article →</a> | ||
| 40 | + </div> | ||
| 41 | + {{end}} | ||
| 42 | + | ||
| 43 | + <div class="mt-4 pt-4 border-t border-spot-divider flex items-center justify-end gap-1.5"> | ||
| 44 | + <div>{{template "like-button.html" .}}</div> | ||
| 45 | + </div> | ||
| 46 | +</article> | ||
| 47 | +{{end}} | ||
| \ No newline at end of file | \ No newline at end of file | ||
modified
internal/tmpl/partials/article-card.html +3 -10 | @@ -3,7 +3,7 @@ | ||
| 3 | 3 | <div class="flex items-start justify-between gap-4"> |
| 4 | 4 | <div class="min-w-0 flex-1"> |
| 5 | 5 | <div class="flex items-start gap-2.5"> |
| 6 | - {{if not .IsRead.Bool}}<span class="w-2 h-2 rounded-full bg-spot-green shrink-0 mt-2 animate-pulse-dot"></span>{{end}} | |
| 6 | + {{if not .IsRead.Bool}}<span class="w-2 h-2 rounded-full bg-spot-green shrink-0 mt-2"></span>{{end}} | |
| 7 | 7 | <a href="/articles/{{.ID}}{{.NavSuffix}}" class="font-semibold text-spot-text hover:text-spot-green transition leading-snug {{if .IsRead.Bool}}text-[15px]{{else}}text-[17px]{{end}}">{{.Title}}</a> |
| 8 | 8 | </div> |
| 9 | 9 | <div class="text-xs text-spot-secondary mt-2 flex items-center gap-1.5 flex-wrap {{if not .IsRead.Bool}}pl-[18px]{{end}}"> |
| @@ -18,19 +18,12 @@ | ||
| 18 | 18 | </div> |
| 19 | 19 | <div class="grid gap-2 shrink-0 pt-0.5"> |
| 20 | 20 | <div class="flex justify-center">{{template "like-button.html" .}}</div> |
| 21 | - {{if .IsRead.Bool}} | |
| 22 | 21 | <div class="flex justify-center"> |
| 23 | - <span id="read-btn-{{.ID}}" title="Read" class="inline-flex items-center gap-1 text-[10px] text-spot-muted uppercase tracking-button px-2 py-0.5 rounded-pill bg-spot-hover"> | |
| 24 | - <svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg> | |
| 25 | - </span> | |
| 26 | - </div> | |
| 27 | - {{else}} | |
| 28 | - <div class="flex justify-center"> | |
| 29 | - <button hx-post="/articles/{{.ID}}/read" hx-target="#read-btn-{{.ID}}" hx-swap="outerHTML" id="read-btn-{{.ID}}" title="Mark as Read" class="group inline-flex items-center gap-1 text-[10px] text-spot-text uppercase tracking-button px-2 py-0.5 rounded-pill bg-spot-hover hover:text-spot-green hover:bg-spot-green/15 transition"> | |
| 22 | + <button id="read-btn-{{.ID}}" hx-post="/articles/{{.ID}}/{{if .IsRead.Bool}}unread{{else}}read{{end}}" hx-target="#read-btn-{{.ID}}" hx-swap="outerHTML" title="{{if .IsRead.Bool}}Mark as Unread{{else}}Mark as Read{{end}}" class="group inline-flex items-center gap-1 text-[10px] {{if .IsRead.Bool}}text-spot-muted{{else}}text-spot-text hover:text-spot-green hover:bg-spot-green/15{{end}} uppercase tracking-button px-2 py-0.5 rounded-pill bg-spot-hover transition"> | |
| 30 | 23 | <svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg> |
| 24 | + <span>{{if .IsRead.Bool}}Unread{{else}}Read{{end}}</span> | |
| 31 | 25 | </button> |
| 32 | 26 | </div> |
| 33 | - {{end}} | |
| 34 | 27 | </div> |
| 35 | 28 | </div> |
| 36 | 29 | <div class="annotate-form hidden mt-4 pt-4 border-t border-spot-divider"> |
| @@ -3,7 +3,7 @@ | |||
| 3 | <div class="flex items-start justify-between gap-4"> | 3 | <div class="flex items-start justify-between gap-4"> |
| 4 | <div class="min-w-0 flex-1"> | 4 | <div class="min-w-0 flex-1"> |
| 5 | <div class="flex items-start gap-2.5"> | 5 | <div class="flex items-start gap-2.5"> |
| 6 | - {{if not .IsRead.Bool}}<span class="w-2 h-2 rounded-full bg-spot-green shrink-0 mt-2 animate-pulse-dot"></span>{{end}} | 6 | + {{if not .IsRead.Bool}}<span class="w-2 h-2 rounded-full bg-spot-green shrink-0 mt-2"></span>{{end}} |
| 7 | <a href="/articles/{{.ID}}{{.NavSuffix}}" class="font-semibold text-spot-text hover:text-spot-green transition leading-snug {{if .IsRead.Bool}}text-[15px]{{else}}text-[17px]{{end}}">{{.Title}}</a> | 7 | <a href="/articles/{{.ID}}{{.NavSuffix}}" class="font-semibold text-spot-text hover:text-spot-green transition leading-snug {{if .IsRead.Bool}}text-[15px]{{else}}text-[17px]{{end}}">{{.Title}}</a> |
| 8 | </div> | 8 | </div> |
| 9 | <div class="text-xs text-spot-secondary mt-2 flex items-center gap-1.5 flex-wrap {{if not .IsRead.Bool}}pl-[18px]{{end}}"> | 9 | <div class="text-xs text-spot-secondary mt-2 flex items-center gap-1.5 flex-wrap {{if not .IsRead.Bool}}pl-[18px]{{end}}"> |
| @@ -18,19 +18,12 @@ | |||
| 18 | </div> | 18 | </div> |
| 19 | <div class="grid gap-2 shrink-0 pt-0.5"> | 19 | <div class="grid gap-2 shrink-0 pt-0.5"> |
| 20 | <div class="flex justify-center">{{template "like-button.html" .}}</div> | 20 | <div class="flex justify-center">{{template "like-button.html" .}}</div> |
| 21 | - {{if .IsRead.Bool}} | ||
| 22 | <div class="flex justify-center"> | 21 | <div class="flex justify-center"> |
| 23 | - <span id="read-btn-{{.ID}}" title="Read" class="inline-flex items-center gap-1 text-[10px] text-spot-muted uppercase tracking-button px-2 py-0.5 rounded-pill bg-spot-hover"> | 22 | + <button id="read-btn-{{.ID}}" hx-post="/articles/{{.ID}}/{{if .IsRead.Bool}}unread{{else}}read{{end}}" hx-target="#read-btn-{{.ID}}" hx-swap="outerHTML" title="{{if .IsRead.Bool}}Mark as Unread{{else}}Mark as Read{{end}}" class="group inline-flex items-center gap-1 text-[10px] {{if .IsRead.Bool}}text-spot-muted{{else}}text-spot-text hover:text-spot-green hover:bg-spot-green/15{{end}} uppercase tracking-button px-2 py-0.5 rounded-pill bg-spot-hover transition"> |
| 24 | - <svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg> | ||
| 25 | - </span> | ||
| 26 | - </div> | ||
| 27 | - {{else}} | ||
| 28 | - <div class="flex justify-center"> | ||
| 29 | - <button hx-post="/articles/{{.ID}}/read" hx-target="#read-btn-{{.ID}}" hx-swap="outerHTML" id="read-btn-{{.ID}}" title="Mark as Read" class="group inline-flex items-center gap-1 text-[10px] text-spot-text uppercase tracking-button px-2 py-0.5 rounded-pill bg-spot-hover hover:text-spot-green hover:bg-spot-green/15 transition"> | ||
| 30 | <svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg> | 23 | <svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg> |
| 24 | + <span>{{if .IsRead.Bool}}Unread{{else}}Read{{end}}</span> | ||
| 31 | </button> | 25 | </button> |
| 32 | </div> | 26 | </div> |
| 33 | - {{end}} | ||
| 34 | </div> | 27 | </div> |
| 35 | </div> | 28 | </div> |
| 36 | <div class="annotate-form hidden mt-4 pt-4 border-t border-spot-divider"> | 29 | <div class="annotate-form hidden mt-4 pt-4 border-t border-spot-divider"> |
modified
internal/tmpl/partials/articles-content.html +2 -2 | @@ -1,6 +1,6 @@ | ||
| 1 | 1 | {{define "articles-content.html"}} |
| 2 | 2 | {{range .Articles}} |
| 3 | -{{template "article-card.html" .}} | |
| 3 | +{{if $.ExpandedView}}{{template "article-card-expanded.html" .}}{{else}}{{template "article-card.html" .}}{{end}} | |
| 4 | 4 | {{else}} |
| 5 | 5 | <div class="bg-spot-surface rounded-2xl py-12 px-6 text-center"> |
| 6 | 6 | <div class="w-14 h-14 rounded-2xl bg-spot-green/15 flex items-center justify-center mb-5 mx-auto"> |
| @@ -12,4 +12,4 @@ | ||
| 12 | 12 | {{else}}<p class="text-xs text-spot-muted">Subscribe to feeds to see articles here.</p>{{end}} |
| 13 | 13 | </div> |
| 14 | 14 | {{end}} |
| 15 | -{{end}} | |
| 15 | +{{end}} | |
| \ No newline at end of file | ||
| @@ -1,6 +1,6 @@ | |||
| 1 | {{define "articles-content.html"}} | 1 | {{define "articles-content.html"}} |
| 2 | {{range .Articles}} | 2 | {{range .Articles}} |
| 3 | -{{template "article-card.html" .}} | 3 | +{{if $.ExpandedView}}{{template "article-card-expanded.html" .}}{{else}}{{template "article-card.html" .}}{{end}} |
| 4 | {{else}} | 4 | {{else}} |
| 5 | <div class="bg-spot-surface rounded-2xl py-12 px-6 text-center"> | 5 | <div class="bg-spot-surface rounded-2xl py-12 px-6 text-center"> |
| 6 | <div class="w-14 h-14 rounded-2xl bg-spot-green/15 flex items-center justify-center mb-5 mx-auto"> | 6 | <div class="w-14 h-14 rounded-2xl bg-spot-green/15 flex items-center justify-center mb-5 mx-auto"> |
| @@ -12,4 +12,4 @@ | |||
| 12 | {{else}}<p class="text-xs text-spot-muted">Subscribe to feeds to see articles here.</p>{{end}} | 12 | {{else}}<p class="text-xs text-spot-muted">Subscribe to feeds to see articles here.</p>{{end}} |
| 13 | </div> | 13 | </div> |
| 14 | {{end}} | 14 | {{end}} |
| 15 | -{{end}} | 15 | +{{end}} |
| \ No newline at end of file | \ No newline at end of file | ||
modified
internal/tmpl/partials/recommendation-article-card.html +1 -1 | @@ -3,7 +3,7 @@ | ||
| 3 | 3 | <div class="flex items-start justify-between gap-4"> |
| 4 | 4 | <div class="min-w-0 flex-1"> |
| 5 | 5 | <div class="flex items-start gap-2.5"> |
| 6 | - {{if not .IsRead}}<span class="w-2 h-2 rounded-full bg-spot-green shrink-0 mt-2 animate-pulse-dot"></span>{{end}} | |
| 6 | + {{if not .IsRead}}<span class="w-2 h-2 rounded-full bg-spot-green shrink-0 mt-2"></span>{{end}} | |
| 7 | 7 | <a href="/articles/{{.ArticleID}}" class="font-semibold text-spot-text hover:text-spot-green transition text-[17px] leading-snug">{{.Title}}</a> |
| 8 | 8 | </div> |
| 9 | 9 | <div class="text-xs text-spot-secondary mt-2 flex items-center gap-1.5 flex-wrap {{if not .IsRead}}pl-[18px]{{end}}"> |
| @@ -3,7 +3,7 @@ | |||
| 3 | <div class="flex items-start justify-between gap-4"> | 3 | <div class="flex items-start justify-between gap-4"> |
| 4 | <div class="min-w-0 flex-1"> | 4 | <div class="min-w-0 flex-1"> |
| 5 | <div class="flex items-start gap-2.5"> | 5 | <div class="flex items-start gap-2.5"> |
| 6 | - {{if not .IsRead}}<span class="w-2 h-2 rounded-full bg-spot-green shrink-0 mt-2 animate-pulse-dot"></span>{{end}} | 6 | + {{if not .IsRead}}<span class="w-2 h-2 rounded-full bg-spot-green shrink-0 mt-2"></span>{{end}} |
| 7 | <a href="/articles/{{.ArticleID}}" class="font-semibold text-spot-text hover:text-spot-green transition text-[17px] leading-snug">{{.Title}}</a> | 7 | <a href="/articles/{{.ArticleID}}" class="font-semibold text-spot-text hover:text-spot-green transition text-[17px] leading-snug">{{.Title}}</a> |
| 8 | </div> | 8 | </div> |
| 9 | <div class="text-xs text-spot-secondary mt-2 flex items-center gap-1.5 flex-wrap {{if not .IsRead}}pl-[18px]{{end}}"> | 9 | <div class="text-xs text-spot-secondary mt-2 flex items-center gap-1.5 flex-wrap {{if not .IsRead}}pl-[18px]{{end}}"> |
modified
internal/tmpl/profile.html +14 -1 | @@ -24,7 +24,20 @@ | ||
| 24 | 24 | |
| 25 | 25 | {{if eq .ProfileUser.DID .CurrentUserDID}} |
| 26 | 26 | <h2 class="text-sm font-bold text-spot-text uppercase tracking-wide mb-3">Settings</h2> |
| 27 | - <div class="bg-spot-surface rounded-xl p-5 mb-6"> | |
| 27 | + <div class="bg-spot-surface rounded-xl p-5 mb-6 space-y-5"> | |
| 28 | + <form hx-post="/settings/expanded-view" hx-swap="none" hx-on::after-request="location.reload()"> | |
| 29 | + <input type="hidden" name="expanded_view" value="{{if .ExpandedView}}0{{else}}1{{end}}"> | |
| 30 | + <div class="flex items-center justify-between"> | |
| 31 | + <div> | |
| 32 | + <p class="text-sm font-semibold text-spot-text mb-0.5">Expanded article view</p> | |
| 33 | + <p class="text-xs text-spot-secondary">Show full article content inline. Articles are marked as read as you scroll.</p> | |
| 34 | + </div> | |
| 35 | + <button type="submit" class="relative inline-flex h-6 w-11 shrink-0 items-center rounded-xl border-0 p-0 cursor-pointer transition-colors appearance-none {{if .ExpandedView}}bg-spot-green{{else}}bg-spot-hover{{end}}" aria-label="Toggle expanded view"> | |
| 36 | + <span class="pointer-events-none inline-block h-4 w-4 rounded-xl bg-white shadow-sm transition-transform {{if .ExpandedView}}translate-x-6{{else}}translate-x-1{{end}}"></span> | |
| 37 | + </button> | |
| 38 | + </div> | |
| 39 | + </form> | |
| 40 | + <hr class="border-spot-divider"> | |
| 28 | 41 | <form id="lang-form" hx-post="/settings/languages" hx-swap="none" hx-on::after-request="location.reload()"> |
| 29 | 42 | <p class="text-sm font-semibold text-spot-text mb-1">Recommendation languages</p> |
| 30 | 43 | <p class="text-xs text-spot-secondary mb-4">Filter article recommendations to specific languages. Leave empty to show all.</p> |
| @@ -24,7 +24,20 @@ | |||
| 24 | 24 | ||
| 25 | {{if eq .ProfileUser.DID .CurrentUserDID}} | 25 | {{if eq .ProfileUser.DID .CurrentUserDID}} |
| 26 | <h2 class="text-sm font-bold text-spot-text uppercase tracking-wide mb-3">Settings</h2> | 26 | <h2 class="text-sm font-bold text-spot-text uppercase tracking-wide mb-3">Settings</h2> |
| 27 | - <div class="bg-spot-surface rounded-xl p-5 mb-6"> | 27 | + <div class="bg-spot-surface rounded-xl p-5 mb-6 space-y-5"> |
| 28 | + <form hx-post="/settings/expanded-view" hx-swap="none" hx-on::after-request="location.reload()"> | ||
| 29 | + <input type="hidden" name="expanded_view" value="{{if .ExpandedView}}0{{else}}1{{end}}"> | ||
| 30 | + <div class="flex items-center justify-between"> | ||
| 31 | + <div> | ||
| 32 | + <p class="text-sm font-semibold text-spot-text mb-0.5">Expanded article view</p> | ||
| 33 | + <p class="text-xs text-spot-secondary">Show full article content inline. Articles are marked as read as you scroll.</p> | ||
| 34 | + </div> | ||
| 35 | + <button type="submit" class="relative inline-flex h-6 w-11 shrink-0 items-center rounded-xl border-0 p-0 cursor-pointer transition-colors appearance-none {{if .ExpandedView}}bg-spot-green{{else}}bg-spot-hover{{end}}" aria-label="Toggle expanded view"> | ||
| 36 | + <span class="pointer-events-none inline-block h-4 w-4 rounded-xl bg-white shadow-sm transition-transform {{if .ExpandedView}}translate-x-6{{else}}translate-x-1{{end}}"></span> | ||
| 37 | + </button> | ||
| 38 | + </div> | ||
| 39 | + </form> | ||
| 40 | + <hr class="border-spot-divider"> | ||
| 28 | <form id="lang-form" hx-post="/settings/languages" hx-swap="none" hx-on::after-request="location.reload()"> | 41 | <form id="lang-form" hx-post="/settings/languages" hx-swap="none" hx-on::after-request="location.reload()"> |
| 29 | <p class="text-sm font-semibold text-spot-text mb-1">Recommendation languages</p> | 42 | <p class="text-sm font-semibold text-spot-text mb-1">Recommendation languages</p> |
| 30 | <p class="text-xs text-spot-secondary mb-4">Filter article recommendations to specific languages. Leave empty to show all.</p> | 43 | <p class="text-xs text-spot-secondary mb-4">Filter article recommendations to specific languages. Leave empty to show all.</p> |