nandi/gleanpublic Fork 0
20597e8
Commits
Clone
git clone https://git.rickub.com/nandi/glean.git
git clone ssh://git@rickub.com/nandi/glean.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Add expanded view setting with full content and auto-readUnverified

Julien Robert committed 2026-05-14T20:13:16+02:00 Browse files
20597e8 parent: 29d5292
modified docs/specs.md +10 -2
@@ -412,6 +412,7 @@ The `/articles` page is the main reading view:
412412 - **Open original**: Title links to the source article
413413 - **Share**: Share to Bluesky
414414 - **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.
415416
416417 ### 4.7 Feed Discovery from Content
417418
@@ -488,8 +489,14 @@ CREATE TABLE users (
488489 did TEXT PRIMARY KEY,
489490 indexed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
490491 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
493500 );
494501 ```
495502
@@ -956,6 +963,7 @@ The server renders HTML fragments that htmx swaps into the page. No JSON API nee
956963 | `/stats` | GET | Application metrics and performance data (Prometheus, public) |
957964 | `/profile/{did}` | GET | Public profile: their feeds, likes, annotations |
958965 | `/settings/languages` | POST | Save preferred recommendation languages (htmx, requires auth) |
966+| `/settings/expanded-view` | POST | Toggle expanded article view setting (htmx, requires auth) |
959967 | `/auth/login` | GET | Login page |
960968 | `/auth/register` | GET | Register with Eurosky (OAuth flow with hardcoded PDS) |
961969 | `/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 article412 - **Open original**: Title links to the source article
413 - **Share**: Share to Bluesky413 - **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 Content417 ### 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 TEXT493+);
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) {
116116 err := dbs.Articles.CreateAnnotation(ctx, &db.Annotation{
117117 URI: "at://did:test:u1/at.glean.annotation/rkey3", AuthorDID: "did:test:u1",
118118 FeedURL: "https://a.com/feed", ArticleURL: "https://a.com/1",
119- Note: db.NullStr("identical note"),
119+ Note: db.NullStr("identical note"),
120120 CreatedAt: db.NullTime(time.Now()),
121121 })
122122 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
472472 COALESCE(lc.cnt, 0),
473473 COALESCE(ul.liked, 0)
474474 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 ?
476476 ) ft
477477 JOIN articles.articles a ON a.id = ft.rowid
478478 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 ) ft476 ) ft
477 JOIN articles.articles a ON a.id = ft.rowid477 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() {
1313 }
1414
1515 // 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
1717
1818 type migration struct {
1919 id int
@@ -42,6 +42,11 @@ var migrations = []migration{
4242 name: "jetstream_cursor",
4343 run: migrateJetstreamCursor,
4444 },
45+ {
46+ id: 5,
47+ name: "user_settings_expanded_view",
48+ run: migrateUserSettingsExpandedView,
49+ },
4550 }
4651
4752 func runMigrations(db *DB) error {
@@ -229,3 +234,17 @@ func migrateJetstreamCursor(db *DB) error {
229234 }
230235 return nil
231236 }
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 = 416+const SchemaVersion = 5
17 17
18 type migration struct {18 type migration struct {
19 id int19 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 nil235 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 (
77 )
88
99 type UserSettings struct {
10- DID string
11- Languages []string
10+ DID string
11+ Languages []string
12+ ExpandedView bool
1213 }
1314
1415 func (s *UserStore) GetSettings(ctx context.Context, did string) (*UserSettings, error) {
1516 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)
1719 if err != nil {
1820 if err == sql.ErrNoRows {
1921 return &UserSettings{DID: did}, nil
2022 }
2123 return nil, err
2224 }
23- us := &UserSettings{DID: did}
25+ us := &UserSettings{DID: did, ExpandedView: expandedView}
2426 if langs.Valid && langs.String != "" {
2527 us.Languages = strings.Split(langs.String, ",")
2628 }
@@ -46,3 +48,11 @@ func (s *UserStore) GetLanguages(ctx context.Context, did string) ([]string, err
4648 }
4749 return us.Languages, nil
4850 }
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 string10+ DID string
11- Languages []string11+ 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.NullString16 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}, nil21 return &UserSettings{DID: did}, nil
20 }22 }
21 return nil, err23 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, nil49 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) {
9595 a.NavSuffix = navSuffix
9696 }
9797
98+ settings, _ := s.dbs.Users.GetSettings(ctx, user.DID)
99+ expandedView := false
100+ if settings != nil {
101+ expandedView = settings.ExpandedView
102+ }
103+
98104 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,
108115 }
109116
110117 if feedURL != "" {
@@ -286,7 +293,7 @@ func (s *Server) handleMarkRead(w http.ResponseWriter, r *http.Request) {
286293 return
287294 }
288295 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)
290297 }
291298
292299 func (s *Server) handleMarkUnread(w http.ResponseWriter, r *http.Request) {
@@ -301,7 +308,7 @@ func (s *Server) handleMarkUnread(w http.ResponseWriter, r *http.Request) {
301308 return
302309 }
303310 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)
305312 }
306313
307314 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 = navSuffix95 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 return293 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 return308 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) {
8686 return nil
8787 })
8888
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+
8999 if err := g.Wait(); err != nil {
90100 s.logger.Warn("profile error", "error", err, "did", did)
91101 }
@@ -100,5 +110,6 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) {
100110 "AnnotationCount": len(annotations),
101111 "UserLanguages": userLangs,
102112 "AvailableLanguages": langdetect.KnownLanguages(),
113+ "ExpandedView": userSettings.ExpandedView,
103114 })
104115 }
@@ -86,6 +86,16 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) {
86 return nil86 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() {
211211 s.router.Route("/settings", func(r chi.Router) {
212212 r.Use(s.requireAuth)
213213 r.Post("/languages", s.handleUpdateLanguages)
214+ r.Post("/expanded-view", s.handleToggleExpandedView)
214215 })
215216
216217 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) {
3737 w.Header().Set("HX-Redirect", "/profile/"+user.DID)
3838 w.WriteHeader(http.StatusOK)
3939 }
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 @@
4141 </button>
4242
4343 <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}}"
4646 title="{{if .ReadState.IsRead}}Mark as Unread{{else}}Mark as Read{{end}}"
4747 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}}">
4848 <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 @@
274274 var btn = document.querySelector('[hx-post*="/like"]');
275275 if (btn) btn.click();
276276 } else if (e.key === 'm') {
277- var rbtn = document.getElementById('read-btn');
277+ var rbtn = document.getElementById('read-btn-{{.Article.ID}}');
278278 if (rbtn) rbtn.click();
279279 } else if (e.key === 'Escape') {
280280 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 @@
118118 if (rbtn) rbtn.click();
119119 }
120120 });
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}}
121157 })();
122158 </script>
123159 {{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">&middot;</span><span>{{.Author.String}}</span>{{end}}{{end}}
13+ {{if .Published.Valid}}<span class="text-spot-muted">&middot;</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 &rarr;</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">&middot;</span><span>{{.Author.String}}</span>{{end}}{{end}}
13+ {{if .Published.Valid}}<span class="text-spot-muted">&middot;</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 &rarr;</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 @@
33 <div class="flex items-start justify-between gap-4">
44 <div class="min-w-0 flex-1">
55 <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}}
77 <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>
88 </div>
99 <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 @@
1818 </div>
1919 <div class="grid gap-2 shrink-0 pt-0.5">
2020 <div class="flex justify-center">{{template "like-button.html" .}}</div>
21- {{if .IsRead.Bool}}
2221 <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">
3023 <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>
3125 </button>
3226 </div>
33- {{end}}
3427 </div>
3528 </div>
3629 <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 @@
11 {{define "articles-content.html"}}
22 {{range .Articles}}
3-{{template "article-card.html" .}}
3+{{if $.ExpandedView}}{{template "article-card-expanded.html" .}}{{else}}{{template "article-card.html" .}}{{end}}
44 {{else}}
55 <div class="bg-spot-surface rounded-2xl py-12 px-6 text-center">
66 <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 @@
1212 {{else}}<p class="text-xs text-spot-muted">Subscribe to feeds to see articles here.</p>{{end}}
1313 </div>
1414 {{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 @@
33 <div class="flex items-start justify-between gap-4">
44 <div class="min-w-0 flex-1">
55 <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}}
77 <a href="/articles/{{.ArticleID}}" class="font-semibold text-spot-text hover:text-spot-green transition text-[17px] leading-snug">{{.Title}}</a>
88 </div>
99 <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 @@
2424
2525 {{if eq .ProfileUser.DID .CurrentUserDID}}
2626 <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">
2841 <form id="lang-form" hx-post="/settings/languages" hx-swap="none" hx-on::after-request="location.reload()">
2942 <p class="text-sm font-semibold text-spot-text mb-1">Recommendation languages</p>
3043 <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>