Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments

Frontend enhancements, autocomplete tags, ability to add tags in extension, fun stuff

+1022 -237
+15 -3
backend/internal/api/annotations.go
··· 757 } 758 759 type CreateBookmarkRequest struct { 760 - URL string `json:"url"` 761 - Title string `json:"title,omitempty"` 762 - Description string `json:"description,omitempty"` 763 } 764 765 func (s *AnnotationService) CreateBookmark(w http.ResponseWriter, r *http.Request) { ··· 782 783 urlHash := db.HashURL(req.URL) 784 record := xrpc.NewBookmarkRecord(req.URL, urlHash, req.Title, req.Description) 785 786 if err := record.Validate(); err != nil { 787 http.Error(w, "Validation error: "+err.Error(), http.StatusBadRequest) ··· 814 descPtr = &req.Description 815 } 816 817 cid := result.CID 818 bookmark := &db.Bookmark{ 819 URI: result.URI, ··· 822 SourceHash: urlHash, 823 Title: titlePtr, 824 Description: descPtr, 825 CreatedAt: time.Now(), 826 IndexedAt: time.Now(), 827 CID: &cid,
··· 757 } 758 759 type CreateBookmarkRequest struct { 760 + URL string `json:"url"` 761 + Title string `json:"title,omitempty"` 762 + Description string `json:"description,omitempty"` 763 + Tags []string `json:"tags,omitempty"` 764 } 765 766 func (s *AnnotationService) CreateBookmark(w http.ResponseWriter, r *http.Request) { ··· 783 784 urlHash := db.HashURL(req.URL) 785 record := xrpc.NewBookmarkRecord(req.URL, urlHash, req.Title, req.Description) 786 + if len(req.Tags) > 0 { 787 + record.Tags = req.Tags 788 + } 789 790 if err := record.Validate(); err != nil { 791 http.Error(w, "Validation error: "+err.Error(), http.StatusBadRequest) ··· 818 descPtr = &req.Description 819 } 820 821 + var tagsJSONPtr *string 822 + if len(req.Tags) > 0 { 823 + tagsBytes, _ := json.Marshal(req.Tags) 824 + tagsStr := string(tagsBytes) 825 + tagsJSONPtr = &tagsStr 826 + } 827 + 828 cid := result.CID 829 bookmark := &db.Bookmark{ 830 URI: result.URI, ··· 833 SourceHash: urlHash, 834 Title: titlePtr, 835 Description: descPtr, 836 + TagsJSON: tagsJSONPtr, 837 CreatedAt: time.Now(), 838 IndexedAt: time.Now(), 839 CID: &cid,
+3
backend/internal/api/handler.go
··· 77 r.Get("/users/{did}/highlights", h.GetUserHighlights) 78 r.Get("/users/{did}/bookmarks", h.GetUserBookmarks) 79 r.Get("/users/{did}/targets", h.GetUserTargetItems) 80 81 r.Get("/replies", h.GetReplies) 82 r.Get("/likes", h.GetLikeCount)
··· 77 r.Get("/users/{did}/highlights", h.GetUserHighlights) 78 r.Get("/users/{did}/bookmarks", h.GetUserBookmarks) 79 r.Get("/users/{did}/targets", h.GetUserTargetItems) 80 + r.Get("/users/{did}/tags", h.HandleGetUserTags) 81 + 82 + r.Get("/trending-tags", h.HandleGetTrendingTags) 83 84 r.Get("/replies", h.GetReplies) 85 r.Get("/likes", h.GetLikeCount)
+26
backend/internal/api/tags.go
··· 4 "encoding/json" 5 "net/http" 6 "strconv" 7 ) 8 9 func (h *Handler) HandleGetTrendingTags(w http.ResponseWriter, r *http.Request) { ··· 23 w.Header().Set("Content-Type", "application/json") 24 json.NewEncoder(w).Encode(tags) 25 }
··· 4 "encoding/json" 5 "net/http" 6 "strconv" 7 + 8 + "github.com/go-chi/chi/v5" 9 ) 10 11 func (h *Handler) HandleGetTrendingTags(w http.ResponseWriter, r *http.Request) { ··· 25 w.Header().Set("Content-Type", "application/json") 26 json.NewEncoder(w).Encode(tags) 27 } 28 + 29 + func (h *Handler) HandleGetUserTags(w http.ResponseWriter, r *http.Request) { 30 + did := chi.URLParam(r, "did") 31 + if did == "" { 32 + http.Error(w, `{"error": "did is required"}`, http.StatusBadRequest) 33 + return 34 + } 35 + 36 + limit := 50 37 + if l := r.URL.Query().Get("limit"); l != "" { 38 + if val, err := strconv.Atoi(l); err == nil && val > 0 && val <= 100 { 39 + limit = val 40 + } 41 + } 42 + 43 + tags, err := h.db.GetUserTags(did, limit) 44 + if err != nil { 45 + http.Error(w, `{"error": "Failed to fetch user tags"}`, http.StatusInternalServerError) 46 + return 47 + } 48 + 49 + w.Header().Set("Content-Type", "application/json") 50 + json.NewEncoder(w).Encode(tags) 51 + }
+133 -37
backend/internal/db/tags.go
··· 1 package db 2 3 type TrendingTag struct { 4 Tag string `json:"tag"` 5 Count int `json:"count"` ··· 9 var query string 10 if db.driver == "postgres" { 11 query = ` 12 - SELECT 13 - value as tag, 14 - COUNT(*) as count 15 - FROM annotations, json_array_elements_text(tags_json::json) as value 16 - WHERE tags_json IS NOT NULL 17 - AND tags_json != '' 18 - AND tags_json != '[]' 19 - AND created_at > NOW() - INTERVAL '7 days' 20 GROUP BY tag 21 - HAVING COUNT(*) > 2 22 - ORDER BY COUNT(*) DESC 23 LIMIT $1 24 ` 25 - rows, err := db.Query(query, limit) 26 - if err != nil { 27 return nil, err 28 } 29 - defer rows.Close() 30 31 - var tags []TrendingTag 32 - for rows.Next() { 33 - var t TrendingTag 34 - if err := rows.Scan(&t.Tag, &t.Count); err != nil { 35 - return nil, err 36 - } 37 - tags = append(tags, t) 38 - } 39 - return tags, nil 40 } 41 42 - query = ` 43 - SELECT 44 - json_each.value as tag, 45 - COUNT(*) as count 46 - FROM annotations, json_each(annotations.tags_json) 47 - WHERE tags_json IS NOT NULL 48 - AND tags_json != '' 49 - AND tags_json != '[]' 50 - AND created_at > datetime('now', '-7 days') 51 - GROUP BY tag 52 - HAVING count > 2 53 - ORDER BY count DESC 54 - LIMIT ? 55 - ` 56 - rows, err := db.Query(db.Rebind(query), limit) 57 if err != nil { 58 return nil, err 59 }
··· 1 package db 2 3 + import "database/sql" 4 + 5 type TrendingTag struct { 6 Tag string `json:"tag"` 7 Count int `json:"count"` ··· 11 var query string 12 if db.driver == "postgres" { 13 query = ` 14 + SELECT tag, SUM(cnt) as count FROM ( 15 + SELECT value as tag, COUNT(*) as cnt 16 + FROM annotations, json_array_elements_text(tags_json::json) as value 17 + WHERE tags_json IS NOT NULL AND tags_json != '' AND tags_json != '[]' 18 + AND created_at > NOW() - INTERVAL '14 days' 19 + GROUP BY tag 20 + UNION ALL 21 + SELECT value as tag, COUNT(*) as cnt 22 + FROM highlights, json_array_elements_text(tags_json::json) as value 23 + WHERE tags_json IS NOT NULL AND tags_json != '' AND tags_json != '[]' 24 + AND created_at > NOW() - INTERVAL '14 days' 25 + GROUP BY tag 26 + UNION ALL 27 + SELECT value as tag, COUNT(*) as cnt 28 + FROM bookmarks, json_array_elements_text(tags_json::json) as value 29 + WHERE tags_json IS NOT NULL AND tags_json != '' AND tags_json != '[]' 30 + AND created_at > NOW() - INTERVAL '14 days' 31 + GROUP BY tag 32 + ) combined 33 GROUP BY tag 34 + HAVING SUM(cnt) >= 2 35 + ORDER BY count DESC 36 LIMIT $1 37 ` 38 + } else { 39 + query = ` 40 + SELECT tag, SUM(cnt) as count FROM ( 41 + SELECT json_each.value as tag, COUNT(*) as cnt 42 + FROM annotations, json_each(annotations.tags_json) 43 + WHERE tags_json IS NOT NULL AND tags_json != '' AND tags_json != '[]' 44 + AND created_at > datetime('now', '-14 days') 45 + GROUP BY tag 46 + UNION ALL 47 + SELECT json_each.value as tag, COUNT(*) as cnt 48 + FROM highlights, json_each(highlights.tags_json) 49 + WHERE tags_json IS NOT NULL AND tags_json != '' AND tags_json != '[]' 50 + AND created_at > datetime('now', '-14 days') 51 + GROUP BY tag 52 + UNION ALL 53 + SELECT json_each.value as tag, COUNT(*) as cnt 54 + FROM bookmarks, json_each(bookmarks.tags_json) 55 + WHERE tags_json IS NOT NULL AND tags_json != '' AND tags_json != '[]' 56 + AND created_at > datetime('now', '-14 days') 57 + GROUP BY tag 58 + ) combined 59 + GROUP BY tag 60 + HAVING SUM(cnt) >= 2 61 + ORDER BY count DESC 62 + LIMIT ? 63 + ` 64 + } 65 + 66 + var rows *sql.Rows 67 + var err error 68 + if db.driver == "postgres" { 69 + rows, err = db.Query(query, limit) 70 + } else { 71 + rows, err = db.Query(db.Rebind(query), limit) 72 + } 73 + if err != nil { 74 + return nil, err 75 + } 76 + defer rows.Close() 77 + 78 + var tags []TrendingTag 79 + for rows.Next() { 80 + var t TrendingTag 81 + if err := rows.Scan(&t.Tag, &t.Count); err != nil { 82 return nil, err 83 } 84 + tags = append(tags, t) 85 + } 86 87 + if err = rows.Err(); err != nil { 88 + return nil, err 89 + } 90 + 91 + if tags == nil { 92 + return []TrendingTag{}, nil 93 + } 94 + 95 + return tags, nil 96 + } 97 + 98 + func (db *DB) GetUserTags(did string, limit int) ([]TrendingTag, error) { 99 + var query string 100 + if db.driver == "postgres" { 101 + query = ` 102 + SELECT tag, SUM(cnt) as count FROM ( 103 + SELECT value as tag, COUNT(*) as cnt 104 + FROM annotations, json_array_elements_text(tags_json::json) as value 105 + WHERE author_did = $1 AND tags_json IS NOT NULL AND tags_json != '' AND tags_json != '[]' 106 + GROUP BY tag 107 + UNION ALL 108 + SELECT value as tag, COUNT(*) as cnt 109 + FROM highlights, json_array_elements_text(tags_json::json) as value 110 + WHERE author_did = $1 AND tags_json IS NOT NULL AND tags_json != '' AND tags_json != '[]' 111 + GROUP BY tag 112 + UNION ALL 113 + SELECT value as tag, COUNT(*) as cnt 114 + FROM bookmarks, json_array_elements_text(tags_json::json) as value 115 + WHERE author_did = $1 AND tags_json IS NOT NULL AND tags_json != '' AND tags_json != '[]' 116 + GROUP BY tag 117 + ) combined 118 + GROUP BY tag 119 + ORDER BY count DESC 120 + LIMIT $2 121 + ` 122 + } else { 123 + query = ` 124 + SELECT tag, SUM(cnt) as count FROM ( 125 + SELECT json_each.value as tag, COUNT(*) as cnt 126 + FROM annotations, json_each(annotations.tags_json) 127 + WHERE author_did = ? AND tags_json IS NOT NULL AND tags_json != '' AND tags_json != '[]' 128 + GROUP BY tag 129 + UNION ALL 130 + SELECT json_each.value as tag, COUNT(*) as cnt 131 + FROM highlights, json_each(highlights.tags_json) 132 + WHERE author_did = ? AND tags_json IS NOT NULL AND tags_json != '' AND tags_json != '[]' 133 + GROUP BY tag 134 + UNION ALL 135 + SELECT json_each.value as tag, COUNT(*) as cnt 136 + FROM bookmarks, json_each(bookmarks.tags_json) 137 + WHERE author_did = ? AND tags_json IS NOT NULL AND tags_json != '' AND tags_json != '[]' 138 + GROUP BY tag 139 + ) combined 140 + GROUP BY tag 141 + ORDER BY count DESC 142 + LIMIT ? 143 + ` 144 } 145 146 + var rows *sql.Rows 147 + var err error 148 + if db.driver == "postgres" { 149 + rows, err = db.Query(query, did, limit) 150 + } else { 151 + rows, err = db.Query(db.Rebind(query), did, did, did, limit) 152 + } 153 if err != nil { 154 return nil, err 155 }
+27 -27
extension/src/assets/styles.css
··· 5 @tailwind utilities; 6 7 :root { 8 - --bg-primary: #020617; 9 - --bg-secondary: #0f172a; 10 - --bg-tertiary: #1e293b; 11 - --bg-card: #0f172a; 12 - --bg-elevated: #1e293b; 13 - --bg-hover: #1e293b; 14 - --text-primary: #f8fafc; 15 - --text-secondary: #94a3b8; 16 - --text-tertiary: #64748b; 17 - --border: rgba(148, 163, 184, 0.1); 18 - --border-strong: rgba(148, 163, 184, 0.2); 19 - --accent: #8b5cf6; 20 - --accent-hover: #a78bfa; 21 - --accent-subtle: rgba(139, 92, 246, 0.12); 22 --success: #34d399; 23 --warning: #fbbf24; 24 - --shadow: 0 1px 3px rgba(0, 0, 0, 0.3); 25 - --shadow-lg: 0 4px 20px rgba(0, 0, 0, 0.4); 26 } 27 28 .light { 29 - --bg-primary: #f8fafc; 30 --bg-secondary: #ffffff; 31 - --bg-tertiary: #f1f5f9; 32 --bg-card: #ffffff; 33 --bg-elevated: #ffffff; 34 - --bg-hover: #f1f5f9; 35 - --text-primary: #0f172a; 36 - --text-secondary: #64748b; 37 - --text-tertiary: #94a3b8; 38 - --border: rgba(100, 116, 139, 0.12); 39 - --border-strong: rgba(100, 116, 139, 0.2); 40 - --accent: #7c3aed; 41 - --accent-hover: #6d28d9; 42 - --accent-subtle: rgba(124, 58, 237, 0.08); 43 --shadow: 0 1px 3px rgba(0, 0, 0, 0.06); 44 --shadow-lg: 0 4px 20px rgba(0, 0, 0, 0.08); 45 }
··· 5 @tailwind utilities; 6 7 :root { 8 + --bg-primary: #0c0c0c; 9 + --bg-secondary: #141414; 10 + --bg-tertiary: #1c1c1c; 11 + --bg-card: #161616; 12 + --bg-elevated: #1e1e1e; 13 + --bg-hover: #262626; 14 + --text-primary: #e8e8e3; 15 + --text-secondary: #a1a09a; 16 + --text-tertiary: #6b6a65; 17 + --border: rgba(255, 255, 255, 0.08); 18 + --border-strong: rgba(255, 255, 255, 0.14); 19 + --accent: #7aa2f7; 20 + --accent-hover: #9bbcff; 21 + --accent-subtle: rgba(122, 162, 247, 0.14); 22 --success: #34d399; 23 --warning: #fbbf24; 24 + --shadow: 0 1px 3px rgba(0, 0, 0, 0.4); 25 + --shadow-lg: 0 4px 20px rgba(0, 0, 0, 0.5); 26 } 27 28 .light { 29 + --bg-primary: #fafaf8; 30 --bg-secondary: #ffffff; 31 + --bg-tertiary: #f2f2ef; 32 --bg-card: #ffffff; 33 --bg-elevated: #ffffff; 34 + --bg-hover: #eaeae6; 35 + --text-primary: #1a1a18; 36 + --text-secondary: #6b6a65; 37 + --text-tertiary: #a1a09a; 38 + --border: rgba(0, 0, 0, 0.08); 39 + --border-strong: rgba(0, 0, 0, 0.14); 40 + --accent: #3b82f6; 41 + --accent-hover: #2563eb; 42 + --accent-subtle: rgba(59, 130, 246, 0.08); 43 --shadow: 0 1px 3px rgba(0, 0, 0, 0.06); 44 --shadow-lg: 0 4px 20px rgba(0, 0, 0, 0.08); 45 }
+143
extension/src/components/TagInput.tsx
···
··· 1 + import { useState, useRef, useEffect } from 'react'; 2 + import { X, Tag } from 'lucide-react'; 3 + 4 + interface TagInputProps { 5 + tags: string[]; 6 + onChange: (tags: string[]) => void; 7 + suggestions?: string[]; 8 + placeholder?: string; 9 + } 10 + 11 + export default function TagInput({ 12 + tags, 13 + onChange, 14 + suggestions = [], 15 + placeholder = 'Add tag...', 16 + }: TagInputProps) { 17 + const [input, setInput] = useState(''); 18 + const [showSuggestions, setShowSuggestions] = useState(false); 19 + const [selectedIndex, setSelectedIndex] = useState(0); 20 + const inputRef = useRef<HTMLInputElement>(null); 21 + const containerRef = useRef<HTMLDivElement>(null); 22 + 23 + const filtered = input.trim() 24 + ? suggestions.filter((s) => s.toLowerCase().includes(input.toLowerCase()) && !tags.includes(s)) 25 + : []; 26 + 27 + useEffect(() => { 28 + setSelectedIndex(0); 29 + }, [input]); 30 + 31 + useEffect(() => { 32 + function handleClickOutside(e: MouseEvent) { 33 + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { 34 + setShowSuggestions(false); 35 + } 36 + } 37 + document.addEventListener('mousedown', handleClickOutside); 38 + return () => document.removeEventListener('mousedown', handleClickOutside); 39 + }, []); 40 + 41 + function addTag(tag: string) { 42 + const normalized = tag 43 + .trim() 44 + .toLowerCase() 45 + .replace(/[^a-z0-9_-]/g, ''); 46 + if (normalized && !tags.includes(normalized) && tags.length < 10) { 47 + onChange([...tags, normalized]); 48 + } 49 + setInput(''); 50 + setShowSuggestions(false); 51 + inputRef.current?.focus(); 52 + } 53 + 54 + function removeTag(tag: string) { 55 + onChange(tags.filter((t) => t !== tag)); 56 + inputRef.current?.focus(); 57 + } 58 + 59 + function handleKeyDown(e: React.KeyboardEvent) { 60 + if (e.key === 'Enter' || e.key === ',') { 61 + e.preventDefault(); 62 + if (filtered.length > 0 && showSuggestions) { 63 + addTag(filtered[selectedIndex] || filtered[0]); 64 + } else if (input.trim()) { 65 + addTag(input); 66 + } 67 + } else if (e.key === 'Backspace' && !input && tags.length > 0) { 68 + removeTag(tags[tags.length - 1]); 69 + } else if (e.key === 'ArrowDown' && showSuggestions) { 70 + e.preventDefault(); 71 + setSelectedIndex((i) => Math.min(i + 1, filtered.length - 1)); 72 + } else if (e.key === 'ArrowUp' && showSuggestions) { 73 + e.preventDefault(); 74 + setSelectedIndex((i) => Math.max(i - 1, 0)); 75 + } else if (e.key === 'Escape') { 76 + setShowSuggestions(false); 77 + } 78 + } 79 + 80 + return ( 81 + <div ref={containerRef} className="relative"> 82 + <div 83 + className="flex flex-wrap items-center gap-1.5 p-2 bg-[var(--bg-card)] border border-[var(--border)] rounded-lg text-xs cursor-text min-h-[34px] focus-within:border-[var(--accent)] focus-within:ring-1 focus-within:ring-[var(--accent-subtle)] transition-all" 84 + onClick={() => inputRef.current?.focus()} 85 + > 86 + <Tag size={12} className="text-[var(--text-tertiary)] flex-shrink-0" /> 87 + {tags.map((tag) => ( 88 + <span 89 + key={tag} 90 + className="inline-flex items-center gap-1 px-2 py-0.5 bg-[var(--accent-subtle)] text-[var(--accent)] rounded-md font-medium text-[11px]" 91 + > 92 + {tag} 93 + <button 94 + type="button" 95 + onClick={(e) => { 96 + e.stopPropagation(); 97 + removeTag(tag); 98 + }} 99 + className="hover:text-[var(--text-primary)] transition-colors" 100 + > 101 + <X size={10} /> 102 + </button> 103 + </span> 104 + ))} 105 + <input 106 + ref={inputRef} 107 + type="text" 108 + value={input} 109 + onChange={(e) => { 110 + setInput(e.target.value); 111 + setShowSuggestions(true); 112 + }} 113 + onFocus={() => setShowSuggestions(true)} 114 + onKeyDown={handleKeyDown} 115 + placeholder={tags.length === 0 ? placeholder : ''} 116 + className="flex-1 min-w-[60px] bg-transparent border-none outline-none text-[11px] text-[var(--text-primary)] placeholder:text-[var(--text-tertiary)]" 117 + /> 118 + </div> 119 + 120 + {showSuggestions && filtered.length > 0 && ( 121 + <div className="absolute z-50 mt-1 w-full bg-[var(--bg-card)] border border-[var(--border)] rounded-lg shadow-lg overflow-hidden max-h-[140px] overflow-y-auto"> 122 + {filtered.slice(0, 8).map((suggestion, i) => ( 123 + <button 124 + key={suggestion} 125 + type="button" 126 + onMouseDown={(e) => { 127 + e.preventDefault(); 128 + addTag(suggestion); 129 + }} 130 + className={`w-full text-left px-3 py-1.5 text-[11px] transition-colors ${ 131 + i === selectedIndex 132 + ? 'bg-[var(--accent-subtle)] text-[var(--accent)]' 133 + : 'text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]' 134 + }`} 135 + > 136 + {suggestion} 137 + </button> 138 + ))} 139 + </div> 140 + )} 141 + </div> 142 + ); 143 + }
+30 -3
extension/src/components/popup/App.tsx
··· 3 import { themeItem, apiUrlItem, overlayEnabledItem } from '@/utils/storage'; 4 import type { MarginSession, Annotation, Bookmark, Highlight, Collection } from '@/utils/types'; 5 import CollectionIcon from '@/components/CollectionIcon'; 6 import { 7 Settings, 8 ExternalLink, ··· 53 const [showSettings, setShowSettings] = useState(false); 54 const [apiUrl, setApiUrl] = useState('https://margin.at'); 55 const [overlayEnabled, setOverlayEnabled] = useState(true); 56 57 useEffect(() => { 58 checkSession(); ··· 62 }, []); 63 64 useEffect(() => { 65 if (session?.authenticated && currentUrl) { 66 if (activeTab === 'page') loadAnnotations(); 67 else if (activeTab === 'bookmarks') loadBookmarks(); ··· 298 url: currentUrl, 299 text: text.trim(), 300 title: currentTitle, 301 }); 302 if (result.success) { 303 setText(''); 304 loadAnnotations(); 305 } else { 306 alert('Failed to post annotation'); ··· 625 </div> 626 627 <div className="p-4 border-b border-[var(--border)]"> 628 - <div className="relative"> 629 <textarea 630 value={text} 631 onChange={(e) => setText(e.target.value)} 632 placeholder="Share your thoughts on this page..." 633 - className="w-full p-3 pb-12 bg-[var(--bg-card)] border border-[var(--border)] rounded-xl text-sm resize-none focus:outline-none focus:border-[var(--accent)] focus:ring-2 focus:ring-[var(--accent-subtle)] min-h-[90px]" 634 /> 635 - <div className="absolute bottom-3 right-3"> 636 <button 637 onClick={handlePost} 638 disabled={posting || !text.trim()}
··· 3 import { themeItem, apiUrlItem, overlayEnabledItem } from '@/utils/storage'; 4 import type { MarginSession, Annotation, Bookmark, Highlight, Collection } from '@/utils/types'; 5 import CollectionIcon from '@/components/CollectionIcon'; 6 + import TagInput from '@/components/TagInput'; 7 import { 8 Settings, 9 ExternalLink, ··· 54 const [showSettings, setShowSettings] = useState(false); 55 const [apiUrl, setApiUrl] = useState('https://margin.at'); 56 const [overlayEnabled, setOverlayEnabled] = useState(true); 57 + const [tags, setTags] = useState<string[]>([]); 58 + const [tagSuggestions, setTagSuggestions] = useState<string[]>([]); 59 60 useEffect(() => { 61 checkSession(); ··· 65 }, []); 66 67 useEffect(() => { 68 + if (session?.authenticated && session.did) { 69 + Promise.all([ 70 + sendMessage('getUserTags', { did: session.did }).catch(() => [] as string[]), 71 + sendMessage('getTrendingTags', undefined).catch(() => [] as string[]), 72 + ]).then(([userTags, trendingTags]) => { 73 + const seen = new Set(userTags); 74 + const merged = [...userTags]; 75 + for (const t of trendingTags) { 76 + if (!seen.has(t)) { 77 + merged.push(t); 78 + seen.add(t); 79 + } 80 + } 81 + setTagSuggestions(merged); 82 + }); 83 + } 84 + }, [session]); 85 + 86 + useEffect(() => { 87 if (session?.authenticated && currentUrl) { 88 if (activeTab === 'page') loadAnnotations(); 89 else if (activeTab === 'bookmarks') loadBookmarks(); ··· 320 url: currentUrl, 321 text: text.trim(), 322 title: currentTitle, 323 + tags: tags.length > 0 ? tags : undefined, 324 }); 325 if (result.success) { 326 setText(''); 327 + setTags([]); 328 loadAnnotations(); 329 } else { 330 alert('Failed to post annotation'); ··· 649 </div> 650 651 <div className="p-4 border-b border-[var(--border)]"> 652 + <div> 653 <textarea 654 value={text} 655 onChange={(e) => setText(e.target.value)} 656 placeholder="Share your thoughts on this page..." 657 + className="w-full p-3 bg-[var(--bg-card)] border border-[var(--border)] rounded-xl text-sm resize-none focus:outline-none focus:border-[var(--accent)] focus:ring-2 focus:ring-[var(--accent-subtle)] min-h-[90px]" 658 /> 659 + <div className="mt-2"> 660 + <TagInput tags={tags} onChange={setTags} suggestions={tagSuggestions} /> 661 + </div> 662 + <div className="flex justify-end mt-2"> 663 <button 664 onClick={handlePost} 665 disabled={posting || !text.trim()}
+27
extension/src/components/sidepanel/App.tsx
··· 4 import type { MarginSession, Annotation, Bookmark, Highlight, Collection } from '@/utils/types'; 5 import { APP_URL } from '@/utils/types'; 6 import CollectionIcon from '@/components/CollectionIcon'; 7 8 type Tab = 'page' | 'bookmarks' | 'highlights' | 'collections'; 9 type PageFilter = 'all' | 'annotations' | 'highlights'; ··· 101 const [collectionModalItem, setCollectionModalItem] = useState<string | null>(null); 102 const [addingToCollection, setAddingToCollection] = useState<string | null>(null); 103 const [containingCollections, setContainingCollections] = useState<Set<string>>(new Set()); 104 105 useEffect(() => { 106 checkSession(); ··· 118 }, []); 119 120 useEffect(() => { 121 if (session?.authenticated && currentUrl) { 122 if (activeTab === 'page') loadAnnotations(); 123 else if (activeTab === 'bookmarks') loadBookmarks(); ··· 279 url: currentUrl, 280 text: text.trim(), 281 title: currentTitle, 282 }); 283 if (result.success) { 284 setText(''); 285 loadAnnotations(); 286 } else { 287 alert('Failed to post annotation'); ··· 564 placeholder="Share your thoughts on this page..." 565 className="w-full p-4 bg-[var(--bg-card)] border border-[var(--border)] rounded-xl text-sm resize-none focus:outline-none focus:border-[var(--accent)] focus:ring-2 focus:ring-[var(--accent)]/20 min-h-[100px]" 566 /> 567 <div className="flex gap-2 mt-3"> 568 <button 569 onClick={handlePost}
··· 4 import type { MarginSession, Annotation, Bookmark, Highlight, Collection } from '@/utils/types'; 5 import { APP_URL } from '@/utils/types'; 6 import CollectionIcon from '@/components/CollectionIcon'; 7 + import TagInput from '@/components/TagInput'; 8 9 type Tab = 'page' | 'bookmarks' | 'highlights' | 'collections'; 10 type PageFilter = 'all' | 'annotations' | 'highlights'; ··· 102 const [collectionModalItem, setCollectionModalItem] = useState<string | null>(null); 103 const [addingToCollection, setAddingToCollection] = useState<string | null>(null); 104 const [containingCollections, setContainingCollections] = useState<Set<string>>(new Set()); 105 + const [tags, setTags] = useState<string[]>([]); 106 + const [tagSuggestions, setTagSuggestions] = useState<string[]>([]); 107 108 useEffect(() => { 109 checkSession(); ··· 121 }, []); 122 123 useEffect(() => { 124 + if (session?.authenticated && session.did) { 125 + Promise.all([ 126 + sendMessage('getUserTags', { did: session.did }).catch(() => [] as string[]), 127 + sendMessage('getTrendingTags', undefined).catch(() => [] as string[]), 128 + ]).then(([userTags, trendingTags]) => { 129 + const seen = new Set(userTags); 130 + const merged = [...userTags]; 131 + for (const t of trendingTags) { 132 + if (!seen.has(t)) { 133 + merged.push(t); 134 + seen.add(t); 135 + } 136 + } 137 + setTagSuggestions(merged); 138 + }); 139 + } 140 + }, [session]); 141 + 142 + useEffect(() => { 143 if (session?.authenticated && currentUrl) { 144 if (activeTab === 'page') loadAnnotations(); 145 else if (activeTab === 'bookmarks') loadBookmarks(); ··· 301 url: currentUrl, 302 text: text.trim(), 303 title: currentTitle, 304 + tags: tags.length > 0 ? tags : undefined, 305 }); 306 if (result.success) { 307 setText(''); 308 + setTags([]); 309 loadAnnotations(); 310 } else { 311 alert('Failed to post annotation'); ··· 588 placeholder="Share your thoughts on this page..." 589 className="w-full p-4 bg-[var(--bg-card)] border border-[var(--border)] rounded-xl text-sm resize-none focus:outline-none focus:border-[var(--accent)] focus:ring-2 focus:ring-[var(--accent)]/20 min-h-[100px]" 590 /> 591 + <div className="mt-2"> 592 + <TagInput tags={tags} onChange={setTags} suggestions={tagSuggestions} /> 593 + </div> 594 <div className="flex gap-2 mt-3"> 595 <button 596 onClick={handlePost}
+12 -2
extension/src/entrypoints/background.ts
··· 14 getItemCollections, 15 getReplies, 16 createReply, 17 } from '@/utils/api'; 18 import { overlayEnabledItem, apiUrlItem } from '@/utils/storage'; 19 ··· 130 return await overlayEnabledItem.getValue(); 131 }); 132 133 onMessage('openAppUrl', async ({ data }) => { 134 const apiUrl = await apiUrlItem.getValue(); 135 await browser.tabs.create({ url: `${apiUrl}${data.path}` }); ··· 141 142 if (tabId) { 143 await browser.action.setBadgeText({ text, tabId }); 144 - await browser.action.setBadgeBackgroundColor({ color: '#6366f1', tabId }); 145 } else { 146 const [tab] = await browser.tabs.query({ active: true, currentWindow: true }); 147 if (tab?.id) { 148 await browser.action.setBadgeText({ text, tabId: tab.id }); 149 - await browser.action.setBadgeBackgroundColor({ color: '#6366f1', tabId: tab.id }); 150 } 151 } 152 });
··· 14 getItemCollections, 15 getReplies, 16 createReply, 17 + getUserTags, 18 + getTrendingTags, 19 } from '@/utils/api'; 20 import { overlayEnabledItem, apiUrlItem } from '@/utils/storage'; 21 ··· 132 return await overlayEnabledItem.getValue(); 133 }); 134 135 + onMessage('getUserTags', async ({ data }) => { 136 + return await getUserTags(data.did); 137 + }); 138 + 139 + onMessage('getTrendingTags', async () => { 140 + return await getTrendingTags(); 141 + }); 142 + 143 onMessage('openAppUrl', async ({ data }) => { 144 const apiUrl = await apiUrlItem.getValue(); 145 await browser.tabs.create({ url: `${apiUrl}${data.path}` }); ··· 151 152 if (tabId) { 153 await browser.action.setBadgeText({ text, tabId }); 154 + await browser.action.setBadgeBackgroundColor({ color: '#3b82f6', tabId }); 155 } else { 156 const [tab] = await browser.tabs.query({ active: true, currentWindow: true }); 157 if (tab?.id) { 158 await browser.action.setBadgeText({ text, tabId: tab.id }); 159 + await browser.action.setBadgeBackgroundColor({ color: '#3b82f6', tabId: tab.id }); 160 } 161 } 162 });
+30 -2
extension/src/utils/api.ts
··· 127 text: string; 128 title?: string; 129 selector?: TextSelector; 130 }) { 131 try { 132 const res = await apiRequest('/annotations', { ··· 136 text: data.text, 137 title: data.title, 138 selector: data.selector, 139 }), 140 }); 141 ··· 150 } 151 } 152 153 - export async function createBookmark(data: { url: string; title?: string }) { 154 try { 155 const res = await apiRequest('/bookmarks', { 156 method: 'POST', 157 - body: JSON.stringify({ url: data.url, title: data.title }), 158 }); 159 160 if (!res.ok) { ··· 173 title?: string; 174 selector: TextSelector; 175 color?: string; 176 }) { 177 try { 178 const res = await apiRequest('/highlights', { ··· 182 title: data.title, 183 selector: data.selector, 184 color: data.color, 185 }), 186 }); 187 ··· 281 return { success: true }; 282 } catch (error) { 283 return { success: false, error: String(error) }; 284 } 285 } 286
··· 127 text: string; 128 title?: string; 129 selector?: TextSelector; 130 + tags?: string[]; 131 }) { 132 try { 133 const res = await apiRequest('/annotations', { ··· 137 text: data.text, 138 title: data.title, 139 selector: data.selector, 140 + tags: data.tags, 141 }), 142 }); 143 ··· 152 } 153 } 154 155 + export async function createBookmark(data: { url: string; title?: string; tags?: string[] }) { 156 try { 157 const res = await apiRequest('/bookmarks', { 158 method: 'POST', 159 + body: JSON.stringify({ url: data.url, title: data.title, tags: data.tags }), 160 }); 161 162 if (!res.ok) { ··· 175 title?: string; 176 selector: TextSelector; 177 color?: string; 178 + tags?: string[]; 179 }) { 180 try { 181 const res = await apiRequest('/highlights', { ··· 185 title: data.title, 186 selector: data.selector, 187 color: data.color, 188 + tags: data.tags, 189 }), 190 }); 191 ··· 285 return { success: true }; 286 } catch (error) { 287 return { success: false, error: String(error) }; 288 + } 289 + } 290 + 291 + export async function getUserTags(did: string) { 292 + try { 293 + const res = await apiRequest(`/users/${did}/tags?limit=50`); 294 + if (!res.ok) return []; 295 + const data = await res.json(); 296 + return (data || []).map((t: { tag: string }) => t.tag); 297 + } catch (error) { 298 + console.error('Get user tags error:', error); 299 + return []; 300 + } 301 + } 302 + 303 + export async function getTrendingTags() { 304 + try { 305 + const res = await apiRequest('/trending-tags?limit=50'); 306 + if (!res.ok) return []; 307 + const data = await res.json(); 308 + return (data || []).map((t: { tag: string }) => t.tag); 309 + } catch (error) { 310 + console.error('Get trending tags error:', error); 311 + return []; 312 } 313 } 314
+18 -3
extension/src/utils/messaging.ts
··· 13 14 getAnnotations(data: { url: string }): Annotation[]; 15 activateOnPdf(data: { tabId: number; url: string }): { redirected: boolean }; 16 - createAnnotation(data: { url: string; text: string; title?: string; selector?: TextSelector }): { 17 success: boolean; 18 data?: Annotation; 19 error?: string; 20 }; 21 22 - createBookmark(data: { url: string; title?: string }): { 23 success: boolean; 24 data?: Bookmark; 25 error?: string; 26 }; 27 getUserBookmarks(data: { did: string }): Bookmark[]; 28 29 - createHighlight(data: { url: string; title?: string; selector: TextSelector; color?: string }): { 30 success: boolean; 31 data?: Highlight; 32 error?: string; ··· 59 }): { success: boolean; error?: string }; 60 61 getOverlayEnabled(): boolean; 62 63 openAppUrl(data: { path: string }): void; 64
··· 13 14 getAnnotations(data: { url: string }): Annotation[]; 15 activateOnPdf(data: { tabId: number; url: string }): { redirected: boolean }; 16 + createAnnotation(data: { 17 + url: string; 18 + text: string; 19 + title?: string; 20 + selector?: TextSelector; 21 + tags?: string[]; 22 + }): { 23 success: boolean; 24 data?: Annotation; 25 error?: string; 26 }; 27 28 + createBookmark(data: { url: string; title?: string; tags?: string[] }): { 29 success: boolean; 30 data?: Bookmark; 31 error?: string; 32 }; 33 getUserBookmarks(data: { did: string }): Bookmark[]; 34 35 + createHighlight(data: { 36 + url: string; 37 + title?: string; 38 + selector: TextSelector; 39 + color?: string; 40 + tags?: string[]; 41 + }): { 42 success: boolean; 43 data?: Highlight; 44 error?: string; ··· 71 }): { success: boolean; error?: string }; 72 73 getOverlayEnabled(): boolean; 74 + 75 + getUserTags(data: { did: string }): string[]; 76 + getTrendingTags(): string[]; 77 78 openAppUrl(data: { path: string }): void; 79
+132 -24
extension/src/utils/overlay-styles.ts
··· 1 export const overlayStyles = /* css */ ` 2 :host { 3 all: initial; 4 - --bg-primary: #020617; 5 - --bg-secondary: #0f172a; 6 - --bg-tertiary: #1e293b; 7 - --bg-card: #0f172a; 8 - --bg-elevated: #1e293b; 9 - --bg-hover: #334155; 10 11 - --text-primary: #f8fafc; 12 - --text-secondary: #94a3b8; 13 - --text-tertiary: #64748b; 14 - --border: rgba(148, 163, 184, 0.12); 15 16 - --accent: #8b5cf6; 17 - --accent-hover: #a78bfa; 18 - --accent-subtle: rgba(139, 92, 246, 0.15); 19 20 --highlight-yellow: #fbbf24; 21 --highlight-green: #34d399; 22 --highlight-blue: #60a5fa; 23 --highlight-pink: #f472b6; 24 - --highlight-purple: #a78bfa; 25 } 26 27 :host(.light) { 28 - --bg-primary: #f8fafc; 29 --bg-secondary: #ffffff; 30 - --bg-tertiary: #f1f5f9; 31 --bg-card: #ffffff; 32 --bg-elevated: #ffffff; 33 - --bg-hover: #e2e8f0; 34 35 - --text-primary: #0f172a; 36 - --text-secondary: #64748b; 37 - --text-tertiary: #94a3b8; 38 - --border: rgba(100, 116, 139, 0.15); 39 40 - --accent: #7c3aed; 41 - --accent-hover: #6d28d9; 42 - --accent-subtle: rgba(124, 58, 237, 0.12); 43 } 44 45 .margin-overlay { ··· 527 opacity: 0.5; 528 cursor: not-allowed; 529 transform: none; 530 } 531 532 .margin-hover-indicator {
··· 1 export const overlayStyles = /* css */ ` 2 :host { 3 all: initial; 4 + --bg-primary: #0c0c0c; 5 + --bg-secondary: #141414; 6 + --bg-tertiary: #1c1c1c; 7 + --bg-card: #161616; 8 + --bg-elevated: #1e1e1e; 9 + --bg-hover: #262626; 10 11 + --text-primary: #e8e8e3; 12 + --text-secondary: #a1a09a; 13 + --text-tertiary: #6b6a65; 14 + --border: rgba(255, 255, 255, 0.08); 15 16 + --accent: #7aa2f7; 17 + --accent-hover: #9bbcff; 18 + --accent-subtle: rgba(122, 162, 247, 0.14); 19 20 --highlight-yellow: #fbbf24; 21 --highlight-green: #34d399; 22 --highlight-blue: #60a5fa; 23 --highlight-pink: #f472b6; 24 + --highlight-purple: #9bbcff; 25 } 26 27 :host(.light) { 28 + --bg-primary: #fafaf8; 29 --bg-secondary: #ffffff; 30 + --bg-tertiary: #f2f2ef; 31 --bg-card: #ffffff; 32 --bg-elevated: #ffffff; 33 + --bg-hover: #eaeae6; 34 35 + --text-primary: #1a1a18; 36 + --text-secondary: #6b6a65; 37 + --text-tertiary: #a1a09a; 38 + --border: rgba(0, 0, 0, 0.08); 39 40 + --accent: #3b82f6; 41 + --accent-hover: #2563eb; 42 + --accent-subtle: rgba(59, 130, 246, 0.08); 43 } 44 45 .margin-overlay { ··· 527 opacity: 0.5; 528 cursor: not-allowed; 529 transform: none; 530 + } 531 + 532 + .compose-tags-section { 533 + margin-top: 12px; 534 + position: relative; 535 + } 536 + 537 + .compose-tags-container { 538 + display: flex; 539 + flex-wrap: wrap; 540 + align-items: center; 541 + gap: 6px; 542 + padding: 8px 10px; 543 + background: var(--bg-elevated); 544 + border: 1px solid var(--border); 545 + border-radius: 8px; 546 + min-height: 34px; 547 + cursor: text; 548 + transition: border-color 0.15s; 549 + } 550 + 551 + .compose-tags-container:focus-within { 552 + border-color: var(--accent); 553 + } 554 + 555 + .compose-tag-pill { 556 + display: inline-flex; 557 + align-items: center; 558 + gap: 4px; 559 + padding: 2px 8px; 560 + background: var(--accent-subtle); 561 + color: var(--accent); 562 + border-radius: 6px; 563 + font-size: 12px; 564 + font-weight: 500; 565 + white-space: nowrap; 566 + } 567 + 568 + .compose-tag-remove { 569 + background: none; 570 + border: none; 571 + color: inherit; 572 + cursor: pointer; 573 + padding: 0; 574 + display: flex; 575 + align-items: center; 576 + opacity: 0.7; 577 + transition: opacity 0.15s; 578 + } 579 + 580 + .compose-tag-remove:hover { 581 + opacity: 1; 582 + } 583 + 584 + .compose-tag-remove svg { 585 + width: 10px; 586 + height: 10px; 587 + } 588 + 589 + .compose-tag-input { 590 + flex: 1; 591 + min-width: 60px; 592 + background: transparent; 593 + border: none; 594 + outline: none; 595 + color: var(--text-primary); 596 + font-family: inherit; 597 + font-size: 12px; 598 + padding: 0; 599 + } 600 + 601 + .compose-tag-input::placeholder { 602 + color: var(--text-tertiary); 603 + } 604 + 605 + .compose-tag-suggestions { 606 + position: absolute; 607 + top: 100%; 608 + left: 0; 609 + right: 0; 610 + margin-top: 4px; 611 + background: var(--bg-card); 612 + border: 1px solid var(--border); 613 + border-radius: 8px; 614 + box-shadow: 0 8px 24px rgba(0,0,0,0.3); 615 + overflow: hidden; 616 + z-index: 10; 617 + max-height: 140px; 618 + overflow-y: auto; 619 + } 620 + 621 + .compose-tag-suggestion-item { 622 + display: block; 623 + width: 100%; 624 + text-align: left; 625 + padding: 8px 12px; 626 + background: none; 627 + border: none; 628 + color: var(--text-secondary); 629 + font-size: 12px; 630 + font-family: inherit; 631 + cursor: pointer; 632 + transition: background 0.1s; 633 + } 634 + 635 + .compose-tag-suggestion-item:hover { 636 + background: var(--accent-subtle); 637 + color: var(--accent); 638 } 639 640 .margin-hover-indicator {
+111 -5
extension/src/utils/overlay.ts
··· 47 const injectedStyles = new Set<string>(); 48 let overlayEnabled = true; 49 let currentUserDid: string | null = null; 50 51 function getPageUrl(): string { 52 const pdfUrl = document.documentElement.dataset.marginPdfUrl; ··· 79 .then((session) => { 80 if (session.authenticated && session.did) { 81 currentUserDid = session.did; 82 } 83 }) 84 .catch(() => {}); ··· 242 textarea.placeholder = 'Write your annotation...'; 243 body.appendChild(textarea); 244 245 composeModal.appendChild(body); 246 247 const footer = document.createElement('div'); ··· 282 title: document.title, 283 text, 284 selector: { type: 'TextQuoteSelector', exact: quoteText }, 285 }); 286 287 if (!res.success) { ··· 339 const tempHighlight = new Highlight(range); 340 const hlName = 'margin-scroll-flash'; 341 CSS.highlights.set(hlName, tempHighlight); 342 - injectHighlightStyle(hlName, '#6366f1'); 343 344 const flashStyle = document.createElement('style'); 345 flashStyle.textContent = `::highlight(${hlName}) { 346 background-color: rgba(99, 102, 241, 0.25); 347 text-decoration: underline; 348 - text-decoration-color: #6366f1; 349 text-decoration-thickness: 3px; 350 text-underline-offset: 2px; 351 }`; ··· 359 try { 360 const highlight = document.createElement('mark'); 361 highlight.style.cssText = 362 - 'background: rgba(99, 102, 241, 0.25); color: inherit; padding: 2px 0; border-radius: 2px; text-decoration: underline; text-decoration-color: #6366f1; text-decoration-thickness: 3px; transition: all 0.5s;'; 363 range.surroundContents(highlight); 364 365 setTimeout(() => { ··· 454 activeItems.push({ range, item }); 455 456 const isHighlight = (item as any).type === 'Highlight'; 457 - const defaultColor = isHighlight ? '#f59e0b' : '#6366f1'; 458 const color = item.color || defaultColor; 459 if (!rangesByColor[color]) rangesByColor[color] = []; 460 rangesByColor[color].push(range); ··· 578 if (avatar) { 579 return `<img src="${avatar}" style="width: 24px; height: 24px; border-radius: 50%; object-fit: cover; border: 2px solid #09090b; margin-left: ${marginLeft};">`; 580 } else { 581 - return `<div style="width: 24px; height: 24px; border-radius: 50%; background: #6366f1; color: white; display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 600; font-family: -apple-system, sans-serif; border: 2px solid #09090b; margin-left: ${marginLeft};">${handle[0]?.toUpperCase() || 'U'}</div>`; 582 } 583 }) 584 .join('');
··· 47 const injectedStyles = new Set<string>(); 48 let overlayEnabled = true; 49 let currentUserDid: string | null = null; 50 + let cachedUserTags: string[] = []; 51 52 function getPageUrl(): string { 53 const pdfUrl = document.documentElement.dataset.marginPdfUrl; ··· 80 .then((session) => { 81 if (session.authenticated && session.did) { 82 currentUserDid = session.did; 83 + Promise.all([ 84 + sendMessage('getUserTags', { did: session.did }).catch(() => [] as string[]), 85 + sendMessage('getTrendingTags', undefined).catch(() => [] as string[]), 86 + ]).then(([userTags, trendingTags]) => { 87 + const seen = new Set(userTags); 88 + cachedUserTags = [...userTags]; 89 + for (const t of trendingTags) { 90 + if (!seen.has(t)) { 91 + cachedUserTags.push(t); 92 + seen.add(t); 93 + } 94 + } 95 + }); 96 } 97 }) 98 .catch(() => {}); ··· 256 textarea.placeholder = 'Write your annotation...'; 257 body.appendChild(textarea); 258 259 + const tagSection = document.createElement('div'); 260 + tagSection.className = 'compose-tags-section'; 261 + 262 + const tagContainer = document.createElement('div'); 263 + tagContainer.className = 'compose-tags-container'; 264 + 265 + const tagInput = document.createElement('input'); 266 + tagInput.type = 'text'; 267 + tagInput.className = 'compose-tag-input'; 268 + tagInput.placeholder = 'Add tags...'; 269 + 270 + const tagSuggestionsDropdown = document.createElement('div'); 271 + tagSuggestionsDropdown.className = 'compose-tag-suggestions'; 272 + tagSuggestionsDropdown.style.display = 'none'; 273 + 274 + const composeTags: string[] = []; 275 + 276 + function renderTags() { 277 + tagContainer.querySelectorAll('.compose-tag-pill').forEach((el) => el.remove()); 278 + composeTags.forEach((tag) => { 279 + const pill = document.createElement('span'); 280 + pill.className = 'compose-tag-pill'; 281 + pill.innerHTML = `${escapeHtml(tag)} <button class="compose-tag-remove">${Icons.close}</button>`; 282 + pill.querySelector('.compose-tag-remove')?.addEventListener('click', (e) => { 283 + e.stopPropagation(); 284 + const idx = composeTags.indexOf(tag); 285 + if (idx > -1) composeTags.splice(idx, 1); 286 + renderTags(); 287 + }); 288 + tagContainer.insertBefore(pill, tagInput); 289 + }); 290 + tagInput.placeholder = composeTags.length === 0 ? 'Add tags...' : ''; 291 + } 292 + 293 + function addComposeTag(tag: string) { 294 + const normalized = tag 295 + .trim() 296 + .toLowerCase() 297 + .replace(/[^a-z0-9_-]/g, ''); 298 + if (normalized && !composeTags.includes(normalized) && composeTags.length < 10) { 299 + composeTags.push(normalized); 300 + renderTags(); 301 + } 302 + tagInput.value = ''; 303 + tagSuggestionsDropdown.style.display = 'none'; 304 + tagInput.focus(); 305 + } 306 + 307 + function showTagSuggestions() { 308 + const query = tagInput.value.trim().toLowerCase(); 309 + if (!query) { 310 + tagSuggestionsDropdown.style.display = 'none'; 311 + return; 312 + } 313 + const matches = cachedUserTags 314 + .filter((t) => t.toLowerCase().includes(query) && !composeTags.includes(t)) 315 + .slice(0, 6); 316 + if (matches.length === 0) { 317 + tagSuggestionsDropdown.style.display = 'none'; 318 + return; 319 + } 320 + tagSuggestionsDropdown.innerHTML = matches 321 + .map((t) => `<button class="compose-tag-suggestion-item">${escapeHtml(t)}</button>`) 322 + .join(''); 323 + tagSuggestionsDropdown.style.display = 'block'; 324 + tagSuggestionsDropdown.querySelectorAll('.compose-tag-suggestion-item').forEach((btn) => { 325 + btn.addEventListener('click', (e) => { 326 + e.stopPropagation(); 327 + addComposeTag(btn.textContent || ''); 328 + }); 329 + }); 330 + } 331 + 332 + tagInput.addEventListener('input', showTagSuggestions); 333 + tagInput.addEventListener('keydown', (e) => { 334 + if (e.key === 'Enter' || e.key === ',') { 335 + e.preventDefault(); 336 + if (tagInput.value.trim()) addComposeTag(tagInput.value); 337 + } else if (e.key === 'Backspace' && !tagInput.value && composeTags.length > 0) { 338 + composeTags.pop(); 339 + renderTags(); 340 + } else if (e.key === 'Escape') { 341 + tagSuggestionsDropdown.style.display = 'none'; 342 + } 343 + }); 344 + 345 + tagContainer.appendChild(tagInput); 346 + tagSection.appendChild(tagContainer); 347 + tagSection.appendChild(tagSuggestionsDropdown); 348 + body.appendChild(tagSection); 349 + 350 composeModal.appendChild(body); 351 352 const footer = document.createElement('div'); ··· 387 title: document.title, 388 text, 389 selector: { type: 'TextQuoteSelector', exact: quoteText }, 390 + tags: composeTags.length > 0 ? composeTags : undefined, 391 }); 392 393 if (!res.success) { ··· 445 const tempHighlight = new Highlight(range); 446 const hlName = 'margin-scroll-flash'; 447 CSS.highlights.set(hlName, tempHighlight); 448 + injectHighlightStyle(hlName, '#3b82f6'); 449 450 const flashStyle = document.createElement('style'); 451 flashStyle.textContent = `::highlight(${hlName}) { 452 background-color: rgba(99, 102, 241, 0.25); 453 text-decoration: underline; 454 + text-decoration-color: #3b82f6; 455 text-decoration-thickness: 3px; 456 text-underline-offset: 2px; 457 }`; ··· 465 try { 466 const highlight = document.createElement('mark'); 467 highlight.style.cssText = 468 + 'background: rgba(59, 130, 246, 0.25); color: inherit; padding: 2px 0; border-radius: 2px; text-decoration: underline; text-decoration-color: #3b82f6; text-decoration-thickness: 3px; transition: all 0.5s;'; 469 range.surroundContents(highlight); 470 471 setTimeout(() => { ··· 560 activeItems.push({ range, item }); 561 562 const isHighlight = (item as any).type === 'Highlight'; 563 + const defaultColor = isHighlight ? '#f59e0b' : '#3b82f6'; 564 const color = item.color || defaultColor; 565 if (!rangesByColor[color]) rangesByColor[color] = []; 566 rangesByColor[color].push(range); ··· 684 if (avatar) { 685 return `<img src="${avatar}" style="width: 24px; height: 24px; border-radius: 50%; object-fit: cover; border: 2px solid #09090b; margin-left: ${marginLeft};">`; 686 } else { 687 + return `<div style="width: 24px; height: 24px; border-radius: 50%; background: #3b82f6; color: white; display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 600; font-family: -apple-system, sans-serif; border: 2px solid #09090b; margin-left: ${marginLeft};">${handle[0]?.toUpperCase() || 'U'}</div>`; 688 } 689 }) 690 .join('');
+3
extension/src/utils/types.ts
··· 26 }; 27 selector?: TextSelector; 28 color?: string; 29 created?: string; 30 createdAt?: string; 31 creator?: Author; ··· 47 title?: string; 48 description?: string; 49 image?: string; 50 createdAt?: string; 51 } 52 ··· 58 selector?: TextSelector; 59 }; 60 color?: string; 61 title?: string; 62 createdAt?: string; 63 }
··· 26 }; 27 selector?: TextSelector; 28 color?: string; 29 + tags?: string[]; 30 created?: string; 31 createdAt?: string; 32 creator?: Author; ··· 48 title?: string; 49 description?: string; 50 image?: string; 51 + tags?: string[]; 52 createdAt?: string; 53 } 54 ··· 60 selector?: TextSelector; 61 }; 62 color?: string; 63 + tags?: string[]; 64 title?: string; 65 createdAt?: string; 66 }
+22 -22
extension/tailwind.config.js
··· 9 }, 10 colors: { 11 primary: { 12 - 50: '#f5f3ff', 13 - 100: '#ede9fe', 14 - 200: '#ddd6fe', 15 - 300: '#c4b5fd', 16 - 400: '#a78bfa', 17 - 500: '#8b5cf6', 18 - 600: '#7c3aed', 19 - 700: '#6d28d9', 20 - 800: '#5b21b6', 21 - 900: '#4c1d95', 22 - 950: '#2e1065', 23 }, 24 surface: { 25 - 50: '#f8fafc', 26 - 100: '#f1f5f9', 27 - 200: '#e2e8f0', 28 - 300: '#cbd5e1', 29 - 400: '#94a3b8', 30 - 500: '#64748b', 31 - 600: '#475569', 32 - 700: '#334155', 33 - 800: '#1e293b', 34 - 900: '#0f172a', 35 - 950: '#020617', 36 }, 37 }, 38 animation: {
··· 9 }, 10 colors: { 11 primary: { 12 + 50: '#eff6ff', 13 + 100: '#dbeafe', 14 + 200: '#bfdbfe', 15 + 300: '#93c5fd', 16 + 400: '#60a5fa', 17 + 500: '#3b82f6', 18 + 600: '#2563eb', 19 + 700: '#1d4ed8', 20 + 800: '#1e40af', 21 + 900: '#1e3a8a', 22 + 950: '#172554', 23 }, 24 surface: { 25 + 50: '#fafaf8', 26 + 100: '#f2f2ef', 27 + 200: '#eaeae6', 28 + 300: '#d2d2cc', 29 + 400: '#a1a09a', 30 + 500: '#6b6a65', 31 + 600: '#4a4a46', 32 + 700: '#333331', 33 + 800: '#1e1e1e', 34 + 900: '#141414', 35 + 950: '#0c0c0c', 36 }, 37 }, 38 animation: {
+1 -1
web/public/logo.svg
··· 1 - <svg width="265" height="231" viewBox="0 0 265 231" fill="#6366f1" xmlns="http://www.w3.org/2000/svg"> 2 <path d="M0 230 V0 H199 V65.7156 H149.5 V115.216 H182.5 L199 131.716 V230 Z"/> 3 <path d="M215 214.224 V230 H264.5 V0 H215.07 V16.2242 H248.5 V214.224 H215 Z"/> 4 </svg>
··· 1 + <svg width="265" height="231" viewBox="0 0 265 231" fill="#027bff" xmlns="http://www.w3.org/2000/svg"> 2 <path d="M0 230 V0 H199 V65.7156 H149.5 V115.216 H182.5 L199 131.716 V230 Z"/> 3 <path d="M215 214.224 V230 H264.5 V0 H215.07 V16.2242 H248.5 V214.224 H215 Z"/> 4 </svg>
+19 -3
web/src/api/client.ts
··· 369 url, 370 title, 371 description, 372 }: { 373 url: string; 374 title?: string; 375 description?: string; 376 }) { 377 try { 378 const res = await apiRequest("/api/bookmarks", { 379 method: "POST", 380 - body: JSON.stringify({ url, title, description }), 381 }); 382 if (!res.ok) throw new Error(await res.text()); 383 const raw = await res.json(); ··· 772 count: number; 773 } 774 775 - export async function getTrendingTags(limit = 10): Promise<Tag[]> { 776 try { 777 - const res = await apiRequest(`/api/tags/trending?limit=${limit}`, { 778 skipAuthRedirect: true, 779 }); 780 if (!res.ok) return []; ··· 782 return Array.isArray(data) ? data : data.tags || []; 783 } catch (e) { 784 console.error("Failed to fetch trending tags:", e); 785 return []; 786 } 787 }
··· 369 url, 370 title, 371 description, 372 + tags, 373 }: { 374 url: string; 375 title?: string; 376 description?: string; 377 + tags?: string[]; 378 }) { 379 try { 380 const res = await apiRequest("/api/bookmarks", { 381 method: "POST", 382 + body: JSON.stringify({ url, title, description, tags }), 383 }); 384 if (!res.ok) throw new Error(await res.text()); 385 const raw = await res.json(); ··· 774 count: number; 775 } 776 777 + export async function getTrendingTags(limit = 50): Promise<Tag[]> { 778 try { 779 + const res = await apiRequest(`/api/trending-tags?limit=${limit}`, { 780 skipAuthRedirect: true, 781 }); 782 if (!res.ok) return []; ··· 784 return Array.isArray(data) ? data : data.tags || []; 785 } catch (e) { 786 console.error("Failed to fetch trending tags:", e); 787 + return []; 788 + } 789 + } 790 + 791 + export async function getUserTags(did: string, limit = 50): Promise<string[]> { 792 + try { 793 + const res = await apiRequest(`/api/users/${did}/tags?limit=${limit}`, { 794 + skipAuthRedirect: true, 795 + }); 796 + if (!res.ok) return []; 797 + const data = await res.json(); 798 + return (data || []).map((t: Tag) => t.tag); 799 + } catch (e) { 800 + console.error("Failed to fetch user tags:", e); 801 return []; 802 } 803 }
+40 -13
web/src/components/feed/Composer.tsx
··· 1 - import React, { useState } from "react"; 2 - import { createAnnotation, createHighlight } from "../../api/client"; 3 import type { Selector, ContentLabelValue } from "../../types"; 4 import { X, ShieldAlert } from "lucide-react"; 5 6 const SELF_LABEL_OPTIONS: { value: ContentLabelValue; label: string }[] = [ 7 { value: "sexual", label: "Sexual" }, ··· 27 }: ComposerProps) { 28 const [text, setText] = useState(""); 29 const [quoteText, setQuoteText] = useState(""); 30 - const [tags, setTags] = useState(""); 31 const [selector, setSelector] = useState(initialSelector); 32 const [loading, setLoading] = useState(false); 33 const [error, setError] = useState<string | null>(null); ··· 35 const [selfLabels, setSelfLabels] = useState<ContentLabelValue[]>([]); 36 const [showLabelPicker, setShowLabelPicker] = useState(false); 37 38 const highlightedText = 39 selector?.type === "TextQuoteSelector" ? selector.exact : null; 40 ··· 54 }; 55 } 56 57 - const tagList = tags 58 - .split(",") 59 - .map((t) => t.trim()) 60 - .filter(Boolean); 61 62 if (!text.trim()) { 63 if (!finalSelector) throw new Error("No text selected"); ··· 84 85 setText(""); 86 setQuoteText(""); 87 setSelector(null); 88 if (onSuccess) onSuccess(); 89 } catch (err) { ··· 176 disabled={loading} 177 /> 178 179 - <input 180 - type="text" 181 - value={tags} 182 - onChange={(e) => setTags(e.target.value)} 183 - placeholder="Tags (comma separated)" 184 - className="w-full p-2.5 bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-lg text-surface-900 dark:text-white placeholder:text-surface-400 dark:placeholder:text-surface-500 focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500 dark:focus:border-primary-400 outline-none text-sm" 185 disabled={loading} 186 /> 187
··· 1 + import React, { useState, useEffect } from "react"; 2 + import { 3 + createAnnotation, 4 + createHighlight, 5 + sessionAtom, 6 + getUserTags, 7 + getTrendingTags, 8 + } from "../../api/client"; 9 import type { Selector, ContentLabelValue } from "../../types"; 10 import { X, ShieldAlert } from "lucide-react"; 11 + import TagInput from "../ui/TagInput"; 12 13 const SELF_LABEL_OPTIONS: { value: ContentLabelValue; label: string }[] = [ 14 { value: "sexual", label: "Sexual" }, ··· 34 }: ComposerProps) { 35 const [text, setText] = useState(""); 36 const [quoteText, setQuoteText] = useState(""); 37 + const [tags, setTags] = useState<string[]>([]); 38 + const [tagSuggestions, setTagSuggestions] = useState<string[]>([]); 39 const [selector, setSelector] = useState(initialSelector); 40 const [loading, setLoading] = useState(false); 41 const [error, setError] = useState<string | null>(null); ··· 43 const [selfLabels, setSelfLabels] = useState<ContentLabelValue[]>([]); 44 const [showLabelPicker, setShowLabelPicker] = useState(false); 45 46 + useEffect(() => { 47 + const session = sessionAtom.get(); 48 + if (session?.did) { 49 + Promise.all([ 50 + getUserTags(session.did).catch(() => [] as string[]), 51 + getTrendingTags(50) 52 + .then((tags) => tags.map((t) => t.tag)) 53 + .catch(() => [] as string[]), 54 + ]).then(([userTags, trendingTags]) => { 55 + const seen = new Set(userTags); 56 + const merged = [...userTags]; 57 + for (const t of trendingTags) { 58 + if (!seen.has(t)) { 59 + merged.push(t); 60 + seen.add(t); 61 + } 62 + } 63 + setTagSuggestions(merged); 64 + }); 65 + } 66 + }, []); 67 + 68 const highlightedText = 69 selector?.type === "TextQuoteSelector" ? selector.exact : null; 70 ··· 84 }; 85 } 86 87 + const tagList = tags.filter(Boolean); 88 89 if (!text.trim()) { 90 if (!finalSelector) throw new Error("No text selected"); ··· 111 112 setText(""); 113 setQuoteText(""); 114 + setTags([]); 115 setSelector(null); 116 if (onSuccess) onSuccess(); 117 } catch (err) { ··· 204 disabled={loading} 205 /> 206 207 + <TagInput 208 + tags={tags} 209 + onChange={setTags} 210 + suggestions={tagSuggestions} 211 + placeholder="Add tags..." 212 disabled={loading} 213 /> 214
+1 -1
web/src/components/feed/MasonryFeed.tsx
··· 149 )} 150 151 {showTabs && ( 152 - <div className="sticky top-0 z-10 bg-surface-50/95 dark:bg-surface-950/95 backdrop-blur-sm pb-4 mb-2 -mx-1 px-1 pt-1"> 153 <div className="flex items-center gap-3"> 154 <div className="flex-1"> 155 <Tabs
··· 149 )} 150 151 {showTabs && ( 152 + <div className="sticky top-0 z-10 bg-white/95 dark:bg-surface-800/95 backdrop-blur-sm pb-4 mb-2 -mx-1 px-1 pt-1"> 153 <div className="flex items-center gap-3"> 154 <div className="flex-1"> 155 <Tabs
+32 -50
web/src/components/modals/EditItemModal.tsx
··· 1 - import React, { useState } from "react"; 2 import { X, ShieldAlert } from "lucide-react"; 3 import { 4 updateAnnotation, 5 updateHighlight, 6 updateBookmark, 7 } from "../../api/client"; 8 import type { AnnotationItem, ContentLabelValue } from "../../types"; 9 10 const SELF_LABEL_OPTIONS: { value: ContentLabelValue; label: string }[] = [ 11 { value: "sexual", label: "Sexual" }, ··· 58 }: Omit<EditItemModalProps, "isOpen">) { 59 const [text, setText] = useState(item.body?.value || ""); 60 const [tags, setTags] = useState<string[]>(item.tags || []); 61 - const [tagInput, setTagInput] = useState(""); 62 const [color, setColor] = useState(item.color || "yellow"); 63 const [title, setTitle] = useState(item.title || item.target?.title || ""); 64 const [description, setDescription] = useState(item.description || ""); ··· 73 const [saving, setSaving] = useState(false); 74 const [error, setError] = useState<string | null>(null); 75 76 - const addTag = () => { 77 - const t = tagInput.trim().toLowerCase(); 78 - if (t && !tags.includes(t)) { 79 - setTags([...tags, t]); 80 } 81 - setTagInput(""); 82 - }; 83 - 84 - const removeTag = (tag: string) => { 85 - setTags(tags.filter((t) => t !== tag)); 86 - }; 87 88 const toggleLabel = (val: ContentLabelValue) => { 89 setSelfLabels((prev) => ··· 262 <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1.5"> 263 Tags 264 </label> 265 - <div className="flex flex-wrap gap-1.5 mb-2"> 266 - {tags.map((tag) => ( 267 - <span 268 - key={tag} 269 - className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 text-xs font-medium" 270 - > 271 - #{tag} 272 - <button 273 - onClick={() => removeTag(tag)} 274 - className="hover:text-red-500 transition-colors" 275 - > 276 - <X size={12} /> 277 - </button> 278 - </span> 279 - ))} 280 - </div> 281 - <div className="flex gap-2"> 282 - <input 283 - type="text" 284 - value={tagInput} 285 - onChange={(e) => setTagInput(e.target.value)} 286 - onKeyDown={(e) => { 287 - if (e.key === "Enter") { 288 - e.preventDefault(); 289 - addTag(); 290 - } 291 - }} 292 - className="flex-1 px-3 py-1.5 rounded-lg border border-surface-200 dark:border-surface-700 bg-surface-50 dark:bg-surface-800 text-surface-900 dark:text-surface-100 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500" 293 - placeholder="Add a tag..." 294 - /> 295 - <button 296 - onClick={addTag} 297 - disabled={!tagInput.trim()} 298 - className="px-3 py-1.5 rounded-lg bg-primary-500 text-white text-sm font-medium hover:bg-primary-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors" 299 - > 300 - Add 301 - </button> 302 - </div> 303 </div> 304 305 <div>
··· 1 + import React, { useState, useEffect } from "react"; 2 import { X, ShieldAlert } from "lucide-react"; 3 import { 4 updateAnnotation, 5 updateHighlight, 6 updateBookmark, 7 + sessionAtom, 8 + getUserTags, 9 + getTrendingTags, 10 } from "../../api/client"; 11 import type { AnnotationItem, ContentLabelValue } from "../../types"; 12 + import TagInput from "../ui/TagInput"; 13 14 const SELF_LABEL_OPTIONS: { value: ContentLabelValue; label: string }[] = [ 15 { value: "sexual", label: "Sexual" }, ··· 62 }: Omit<EditItemModalProps, "isOpen">) { 63 const [text, setText] = useState(item.body?.value || ""); 64 const [tags, setTags] = useState<string[]>(item.tags || []); 65 + const [tagSuggestions, setTagSuggestions] = useState<string[]>([]); 66 const [color, setColor] = useState(item.color || "yellow"); 67 const [title, setTitle] = useState(item.title || item.target?.title || ""); 68 const [description, setDescription] = useState(item.description || ""); ··· 77 const [saving, setSaving] = useState(false); 78 const [error, setError] = useState<string | null>(null); 79 80 + useEffect(() => { 81 + const session = sessionAtom.get(); 82 + if (session?.did) { 83 + Promise.all([ 84 + getUserTags(session.did).catch(() => [] as string[]), 85 + getTrendingTags(50) 86 + .then((tags) => tags.map((t) => t.tag)) 87 + .catch(() => [] as string[]), 88 + ]).then(([userTags, trendingTags]) => { 89 + const seen = new Set(userTags); 90 + const merged = [...userTags]; 91 + for (const t of trendingTags) { 92 + if (!seen.has(t)) { 93 + merged.push(t); 94 + seen.add(t); 95 + } 96 + } 97 + setTagSuggestions(merged); 98 + }); 99 } 100 + }, []); 101 102 const toggleLabel = (val: ContentLabelValue) => { 103 setSelfLabels((prev) => ··· 276 <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1.5"> 277 Tags 278 </label> 279 + <TagInput 280 + tags={tags} 281 + onChange={setTags} 282 + suggestions={tagSuggestions} 283 + placeholder="Add a tag..." 284 + /> 285 </div> 286 287 <div>
+3 -3
web/src/components/navigation/RightSidebar.tsx
··· 23 }; 24 25 useEffect(() => { 26 - getTrendingTags().then(setTags); 27 }, []); 28 29 const extensionLink = ··· 34 : "https://chromewebstore.google.com/detail/margin/cgpmbiiagnehkikhcbnhiagfomajncpa"; 35 36 return ( 37 - <aside className="hidden xl:block w-[280px] shrink-0 sticky top-0 h-screen overflow-y-auto px-5 py-6 border-l border-surface-200/60 dark:border-surface-800/60"> 38 <div className="space-y-5"> 39 <div className="relative"> 40 <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> ··· 85 <a 86 key={t.tag} 87 href={`/home?tag=${encodeURIComponent(t.tag)}`} 88 - className="px-2 py-2.5 hover:bg-surface-100 dark:hover:bg-surface-800/60 rounded-lg transition-colors group" 89 > 90 <div className="font-semibold text-sm text-surface-900 dark:text-white group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors"> 91 #{t.tag}
··· 23 }; 24 25 useEffect(() => { 26 + getTrendingTags(10).then(setTags); 27 }, []); 28 29 const extensionLink = ··· 34 : "https://chromewebstore.google.com/detail/margin/cgpmbiiagnehkikhcbnhiagfomajncpa"; 35 36 return ( 37 + <aside className="hidden xl:block w-[320px] shrink-0 sticky top-0 h-screen overflow-y-auto px-6 py-6"> 38 <div className="space-y-5"> 39 <div className="relative"> 40 <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> ··· 85 <a 86 key={t.tag} 87 href={`/home?tag=${encodeURIComponent(t.tag)}`} 88 + className="px-2 py-2.5 hover:bg-surface-100 dark:hover:bg-surface-800 rounded-lg transition-colors group" 89 > 90 <div className="font-semibold text-sm text-surface-900 dark:text-white group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors"> 91 #{t.tag}
+5 -5
web/src/components/navigation/Sidebar.tsx
··· 80 const navItems = user ? authNavItems : publicNavItems; 81 82 return ( 83 - <aside className="sticky top-0 h-screen hidden md:flex flex-col justify-between py-6 px-2 lg:px-3 z-50 border-r border-surface-200/60 dark:border-surface-800/60 w-[68px] lg:w-[220px] transition-all duration-200"> 84 <div className="flex flex-col gap-6"> 85 <Link 86 to="/home" ··· 105 className={`flex items-center justify-center lg:justify-start gap-3 px-0 lg:px-3 py-2.5 rounded-lg transition-all duration-150 text-[14px] group ${ 106 isActive 107 ? "font-semibold text-primary-700 dark:text-primary-300 bg-primary-50 dark:bg-primary-950/40" 108 - : "font-medium text-surface-600 dark:text-surface-400 hover:bg-surface-100 dark:hover:bg-surface-800/60 hover:text-surface-900 dark:hover:text-white" 109 }`} 110 > 111 <item.icon ··· 140 title={ 141 theme === "light" ? "Light" : theme === "dark" ? "Dark" : "System" 142 } 143 - className="flex items-center justify-center lg:justify-start gap-3 px-0 lg:px-3 py-2.5 rounded-lg hover:bg-surface-100 dark:hover:bg-surface-800/60 text-[13px] font-medium text-surface-500 dark:text-surface-400 w-full transition-colors" 144 > 145 {theme === "light" ? ( 146 <Sun size={18} /> ··· 159 <Link 160 to="/settings" 161 title="Settings" 162 - className="flex items-center justify-center lg:justify-start gap-3 px-0 lg:px-3 py-2.5 rounded-lg hover:bg-surface-100 dark:hover:bg-surface-800/60 text-[13px] font-medium text-surface-500 dark:text-surface-400 transition-colors" 163 > 164 <Settings size={18} /> 165 <span className="hidden lg:inline">Settings</span> ··· 170 <Link 171 to={`/profile/${user.did}`} 172 title={user.displayName || user.handle} 173 - className="flex items-center justify-center lg:justify-start gap-2.5 p-2 rounded-lg hover:bg-surface-100 dark:hover:bg-surface-800/60 transition-colors w-full" 174 > 175 <Avatar did={user.did} avatar={user.avatar} size="sm" /> 176 <div className="flex-1 min-w-0 hidden lg:block">
··· 80 const navItems = user ? authNavItems : publicNavItems; 81 82 return ( 83 + <aside className="sticky top-0 h-screen hidden md:flex flex-col justify-between py-6 px-2 lg:px-4 z-50 w-[68px] lg:w-[260px] transition-all duration-200"> 84 <div className="flex flex-col gap-6"> 85 <Link 86 to="/home" ··· 105 className={`flex items-center justify-center lg:justify-start gap-3 px-0 lg:px-3 py-2.5 rounded-lg transition-all duration-150 text-[14px] group ${ 106 isActive 107 ? "font-semibold text-primary-700 dark:text-primary-300 bg-primary-50 dark:bg-primary-950/40" 108 + : "font-medium text-surface-600 dark:text-surface-400 hover:bg-surface-100 dark:hover:bg-surface-800 hover:text-surface-900 dark:hover:text-white" 109 }`} 110 > 111 <item.icon ··· 140 title={ 141 theme === "light" ? "Light" : theme === "dark" ? "Dark" : "System" 142 } 143 + className="flex items-center justify-center lg:justify-start gap-3 px-0 lg:px-3 py-2.5 rounded-lg hover:bg-surface-100 dark:hover:bg-surface-800 text-[13px] font-medium text-surface-500 dark:text-surface-400 w-full transition-colors" 144 > 145 {theme === "light" ? ( 146 <Sun size={18} /> ··· 159 <Link 160 to="/settings" 161 title="Settings" 162 + className="flex items-center justify-center lg:justify-start gap-3 px-0 lg:px-3 py-2.5 rounded-lg hover:bg-surface-100 dark:hover:bg-surface-800 text-[13px] font-medium text-surface-500 dark:text-surface-400 transition-colors" 163 > 164 <Settings size={18} /> 165 <span className="hidden lg:inline">Settings</span> ··· 170 <Link 171 to={`/profile/${user.did}`} 172 title={user.displayName || user.handle} 173 + className="flex items-center justify-center lg:justify-start gap-2.5 p-2 rounded-lg hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors w-full" 174 > 175 <Avatar did={user.did} avatar={user.avatar} size="sm" /> 176 <div className="flex-1 min-w-0 hidden lg:block">
+1 -1
web/src/components/ui/LayoutToggle.tsx
··· 19 return ( 20 <div 21 className={clsx( 22 - "inline-flex items-center rounded-lg border border-surface-200 dark:border-surface-700 p-0.5 bg-surface-100 dark:bg-surface-800/60", 23 className, 24 )} 25 >
··· 19 return ( 20 <div 21 className={clsx( 22 + "inline-flex items-center rounded-lg border border-surface-200 dark:border-surface-700 p-0.5 bg-surface-50 dark:bg-surface-800/60", 23 className, 24 )} 25 >
+154
web/src/components/ui/TagInput.tsx
···
··· 1 + import React, { useState, useRef, useEffect } from "react"; 2 + import { X, Tag } from "lucide-react"; 3 + 4 + interface TagInputProps { 5 + tags: string[]; 6 + onChange: (tags: string[]) => void; 7 + suggestions?: string[]; 8 + placeholder?: string; 9 + disabled?: boolean; 10 + } 11 + 12 + export default function TagInput({ 13 + tags, 14 + onChange, 15 + suggestions = [], 16 + placeholder = "Add a tag...", 17 + disabled = false, 18 + }: TagInputProps) { 19 + const [input, setInput] = useState(""); 20 + const [showSuggestions, setShowSuggestions] = useState(false); 21 + const [selectedIndex, setSelectedIndex] = useState(0); 22 + const inputRef = useRef<HTMLInputElement>(null); 23 + const containerRef = useRef<HTMLDivElement>(null); 24 + 25 + const filtered = input.trim() 26 + ? suggestions 27 + .filter( 28 + (s) => 29 + s.toLowerCase().includes(input.toLowerCase()) && !tags.includes(s), 30 + ) 31 + .slice(0, 8) 32 + : []; 33 + 34 + useEffect(() => { 35 + function handleClickOutside(e: MouseEvent) { 36 + if ( 37 + containerRef.current && 38 + !containerRef.current.contains(e.target as Node) 39 + ) { 40 + setShowSuggestions(false); 41 + } 42 + } 43 + document.addEventListener("mousedown", handleClickOutside); 44 + return () => document.removeEventListener("mousedown", handleClickOutside); 45 + }, []); 46 + 47 + function addTag(tag: string) { 48 + const normalized = tag 49 + .trim() 50 + .toLowerCase() 51 + .replace(/[^a-z0-9_-]/g, ""); 52 + if (normalized && !tags.includes(normalized) && tags.length < 10) { 53 + onChange([...tags, normalized]); 54 + } 55 + setInput(""); 56 + setShowSuggestions(false); 57 + inputRef.current?.focus(); 58 + } 59 + 60 + function removeTag(tag: string) { 61 + onChange(tags.filter((t) => t !== tag)); 62 + inputRef.current?.focus(); 63 + } 64 + 65 + function handleKeyDown(e: React.KeyboardEvent) { 66 + if (e.key === "Enter" || e.key === ",") { 67 + e.preventDefault(); 68 + if (filtered.length > 0 && showSuggestions) { 69 + addTag(filtered[selectedIndex] || filtered[0]); 70 + } else if (input.trim()) { 71 + addTag(input); 72 + } 73 + } else if (e.key === "Backspace" && !input && tags.length > 0) { 74 + removeTag(tags[tags.length - 1]); 75 + } else if (e.key === "ArrowDown" && showSuggestions) { 76 + e.preventDefault(); 77 + setSelectedIndex((i) => Math.min(i + 1, filtered.length - 1)); 78 + } else if (e.key === "ArrowUp" && showSuggestions) { 79 + e.preventDefault(); 80 + setSelectedIndex((i) => Math.max(i - 1, 0)); 81 + } else if (e.key === "Escape") { 82 + setShowSuggestions(false); 83 + } 84 + } 85 + 86 + return ( 87 + <div ref={containerRef} className="relative"> 88 + <div 89 + className="flex flex-wrap items-center gap-1.5 px-3 py-2 bg-surface-50 dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-xl text-sm cursor-text min-h-[38px] focus-within:ring-2 focus-within:ring-primary-500/20 focus-within:border-primary-500 dark:focus-within:border-primary-400 transition-all" 90 + onClick={() => inputRef.current?.focus()} 91 + > 92 + <Tag 93 + size={14} 94 + className="text-surface-400 dark:text-surface-500 flex-shrink-0" 95 + /> 96 + {tags.map((tag) => ( 97 + <span 98 + key={tag} 99 + className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 text-xs font-medium" 100 + > 101 + {tag} 102 + <button 103 + type="button" 104 + onClick={(e) => { 105 + e.stopPropagation(); 106 + removeTag(tag); 107 + }} 108 + className="hover:text-red-500 transition-colors" 109 + > 110 + <X size={12} /> 111 + </button> 112 + </span> 113 + ))} 114 + <input 115 + ref={inputRef} 116 + type="text" 117 + value={input} 118 + onChange={(e) => { 119 + setInput(e.target.value); 120 + setSelectedIndex(0); 121 + setShowSuggestions(true); 122 + }} 123 + onFocus={() => setShowSuggestions(true)} 124 + onKeyDown={handleKeyDown} 125 + placeholder={tags.length === 0 ? placeholder : ""} 126 + disabled={disabled} 127 + className="flex-1 min-w-[60px] bg-transparent border-none outline-none text-sm text-surface-900 dark:text-surface-100 placeholder:text-surface-400 dark:placeholder:text-surface-500" 128 + /> 129 + </div> 130 + 131 + {showSuggestions && filtered.length > 0 && ( 132 + <div className="absolute z-50 mt-1 w-full bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-xl shadow-lg overflow-hidden max-h-[180px] overflow-y-auto"> 133 + {filtered.map((suggestion, i) => ( 134 + <button 135 + key={suggestion} 136 + type="button" 137 + onMouseDown={(e) => { 138 + e.preventDefault(); 139 + addTag(suggestion); 140 + }} 141 + className={`w-full text-left px-3 py-2 text-sm transition-colors ${ 142 + i === selectedIndex 143 + ? "bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300" 144 + : "text-surface-600 dark:text-surface-400 hover:bg-surface-50 dark:hover:bg-surface-700" 145 + }`} 146 + > 147 + {suggestion} 148 + </button> 149 + ))} 150 + </div> 151 + )} 152 + </div> 153 + ); 154 + }
+5 -3
web/src/layouts/AppLayout.tsx
··· 13 useStore($theme); 14 15 return ( 16 - <div className="min-h-screen bg-surface-50 dark:bg-surface-950 flex"> 17 <Sidebar /> 18 19 <div className="flex-1 min-w-0 transition-all duration-200"> 20 <div className="flex w-full max-w-[1800px] mx-auto"> 21 - <main className="flex-1 w-full min-w-0 py-6 px-3 md:px-5 lg:px-8 pb-20 md:pb-6"> 22 - {children} 23 </main> 24 25 <RightSidebar />
··· 13 useStore($theme); 14 15 return ( 16 + <div className="min-h-screen bg-surface-100 dark:bg-surface-900 flex"> 17 <Sidebar /> 18 19 <div className="flex-1 min-w-0 transition-all duration-200"> 20 <div className="flex w-full max-w-[1800px] mx-auto"> 21 + <main className="flex-1 w-full min-w-0 py-2 md:py-3"> 22 + <div className="bg-white dark:bg-surface-800 rounded-2xl min-h-[calc(100vh-16px)] md:min-h-[calc(100vh-24px)] py-6 px-4 md:px-6 lg:px-8 pb-20 md:pb-6"> 23 + {children} 24 + </div> 25 </main> 26 27 <RightSidebar />
+6 -6
web/src/styles/global.css
··· 10 } 11 12 html { 13 - background-color: #f8fafc; 14 - color: #0f172a; 15 } 16 17 html[data-theme="dark"] { 18 - background-color: #020617; 19 - color: #f8fafc; 20 } 21 22 h1, ··· 44 } 45 46 .focus-ring { 47 - @apply focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:ring-offset-2 focus:ring-offset-white dark:focus:ring-offset-surface-950; 48 } 49 50 .glass { ··· 52 } 53 54 .card { 55 - @apply bg-white dark:bg-surface-900 rounded-xl border border-surface-200/80 dark:border-surface-800 shadow-sm; 56 } 57 58 .transition-default {
··· 10 } 11 12 html { 13 + background-color: #f4f4f5; 14 + color: #18181b; 15 } 16 17 html[data-theme="dark"] { 18 + background-color: #18181b; 19 + color: #fafafa; 20 } 21 22 h1, ··· 44 } 45 46 .focus-ring { 47 + @apply focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:ring-offset-2 focus:ring-offset-white dark:focus:ring-offset-surface-900; 48 } 49 50 .glass { ··· 52 } 53 54 .card { 55 + @apply bg-white dark:bg-surface-900 rounded-xl border border-surface-200/60 dark:border-surface-800 shadow-sm dark:shadow-none; 56 } 57 58 .transition-default {
+1 -1
web/src/views/core/Feed.tsx
··· 255 )} 256 257 {showTabs && ( 258 - <div className="sticky top-0 z-10 bg-surface-50/95 dark:bg-surface-950/95 backdrop-blur-sm pb-3 mb-2 -mx-1 px-1 pt-1 space-y-2"> 259 {!tag && ( 260 <Tabs 261 tabs={tabs}
··· 255 )} 256 257 {showTabs && ( 258 + <div className="sticky top-0 z-10 bg-white/95 dark:bg-surface-800/95 backdrop-blur-sm pb-3 mb-2 -mx-1 px-1 pt-1 space-y-2"> 259 {!tag && ( 260 <Tabs 261 tabs={tabs}
+22 -22
web/tailwind.config.mjs
··· 12 }, 13 colors: { 14 primary: { 15 - 50: "#f5f3ff", 16 - 100: "#ede9fe", 17 - 200: "#ddd6fe", 18 - 300: "#c4b5fd", 19 - 400: "#a78bfa", 20 - 500: "#8b5cf6", 21 - 600: "#7c3aed", 22 - 700: "#6d28d9", 23 - 800: "#5b21b6", 24 - 900: "#4c1d95", 25 - 950: "#2e1065", 26 }, 27 surface: { 28 - 50: "#f8fafc", 29 - 100: "#f1f5f9", 30 - 200: "#e2e8f0", 31 - 300: "#cbd5e1", 32 - 400: "#94a3b8", 33 - 500: "#64748b", 34 - 600: "#475569", 35 - 700: "#334155", 36 - 800: "#1e293b", 37 - 900: "#0f172a", 38 - 950: "#020617", 39 }, 40 }, 41 animation: {
··· 12 }, 13 colors: { 14 primary: { 15 + 50: "#eff6ff", 16 + 100: "#dbeafe", 17 + 200: "#bfdbfe", 18 + 300: "#93c5fd", 19 + 400: "#60a5fa", 20 + 500: "#3b82f6", 21 + 600: "#2563eb", 22 + 700: "#1d4ed8", 23 + 800: "#1e40af", 24 + 900: "#1e3a8a", 25 + 950: "#172554", 26 }, 27 surface: { 28 + 50: "#fafafa", 29 + 100: "#f4f4f5", 30 + 200: "#e4e4e7", 31 + 300: "#d4d4d8", 32 + 400: "#a1a1aa", 33 + 500: "#71717a", 34 + 600: "#52525b", 35 + 700: "#3f3f46", 36 + 800: "#27272a", 37 + 900: "#18181b", 38 + 950: "#09090b", 39 }, 40 }, 41 animation: {