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 757 } 758 758 759 759 type CreateBookmarkRequest struct { 760 - URL string `json:"url"` 761 - Title string `json:"title,omitempty"` 762 - Description string `json:"description,omitempty"` 760 + URL string `json:"url"` 761 + Title string `json:"title,omitempty"` 762 + Description string `json:"description,omitempty"` 763 + Tags []string `json:"tags,omitempty"` 763 764 } 764 765 765 766 func (s *AnnotationService) CreateBookmark(w http.ResponseWriter, r *http.Request) { ··· 782 783 783 784 urlHash := db.HashURL(req.URL) 784 785 record := xrpc.NewBookmarkRecord(req.URL, urlHash, req.Title, req.Description) 786 + if len(req.Tags) > 0 { 787 + record.Tags = req.Tags 788 + } 785 789 786 790 if err := record.Validate(); err != nil { 787 791 http.Error(w, "Validation error: "+err.Error(), http.StatusBadRequest) ··· 814 818 descPtr = &req.Description 815 819 } 816 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 + 817 828 cid := result.CID 818 829 bookmark := &db.Bookmark{ 819 830 URI: result.URI, ··· 822 833 SourceHash: urlHash, 823 834 Title: titlePtr, 824 835 Description: descPtr, 836 + TagsJSON: tagsJSONPtr, 825 837 CreatedAt: time.Now(), 826 838 IndexedAt: time.Now(), 827 839 CID: &cid,
+3
backend/internal/api/handler.go
··· 77 77 r.Get("/users/{did}/highlights", h.GetUserHighlights) 78 78 r.Get("/users/{did}/bookmarks", h.GetUserBookmarks) 79 79 r.Get("/users/{did}/targets", h.GetUserTargetItems) 80 + r.Get("/users/{did}/tags", h.HandleGetUserTags) 81 + 82 + r.Get("/trending-tags", h.HandleGetTrendingTags) 80 83 81 84 r.Get("/replies", h.GetReplies) 82 85 r.Get("/likes", h.GetLikeCount)
+26
backend/internal/api/tags.go
··· 4 4 "encoding/json" 5 5 "net/http" 6 6 "strconv" 7 + 8 + "github.com/go-chi/chi/v5" 7 9 ) 8 10 9 11 func (h *Handler) HandleGetTrendingTags(w http.ResponseWriter, r *http.Request) { ··· 23 25 w.Header().Set("Content-Type", "application/json") 24 26 json.NewEncoder(w).Encode(tags) 25 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 1 package db 2 2 3 + import "database/sql" 4 + 3 5 type TrendingTag struct { 4 6 Tag string `json:"tag"` 5 7 Count int `json:"count"` ··· 9 11 var query string 10 12 if db.driver == "postgres" { 11 13 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' 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 20 33 GROUP BY tag 21 - HAVING COUNT(*) > 2 22 - ORDER BY COUNT(*) DESC 34 + HAVING SUM(cnt) >= 2 35 + ORDER BY count DESC 23 36 LIMIT $1 24 37 ` 25 - rows, err := db.Query(query, limit) 26 - if err != nil { 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 { 27 82 return nil, err 28 83 } 29 - defer rows.Close() 84 + tags = append(tags, t) 85 + } 30 86 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 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 + ` 40 144 } 41 145 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) 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 + } 57 153 if err != nil { 58 154 return nil, err 59 155 }
+27 -27
extension/src/assets/styles.css
··· 5 5 @tailwind utilities; 6 6 7 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); 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 22 --success: #34d399; 23 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); 24 + --shadow: 0 1px 3px rgba(0, 0, 0, 0.4); 25 + --shadow-lg: 0 4px 20px rgba(0, 0, 0, 0.5); 26 26 } 27 27 28 28 .light { 29 - --bg-primary: #f8fafc; 29 + --bg-primary: #fafaf8; 30 30 --bg-secondary: #ffffff; 31 - --bg-tertiary: #f1f5f9; 31 + --bg-tertiary: #f2f2ef; 32 32 --bg-card: #ffffff; 33 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); 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 43 --shadow: 0 1px 3px rgba(0, 0, 0, 0.06); 44 44 --shadow-lg: 0 4px 20px rgba(0, 0, 0, 0.08); 45 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 3 import { themeItem, apiUrlItem, overlayEnabledItem } from '@/utils/storage'; 4 4 import type { MarginSession, Annotation, Bookmark, Highlight, Collection } from '@/utils/types'; 5 5 import CollectionIcon from '@/components/CollectionIcon'; 6 + import TagInput from '@/components/TagInput'; 6 7 import { 7 8 Settings, 8 9 ExternalLink, ··· 53 54 const [showSettings, setShowSettings] = useState(false); 54 55 const [apiUrl, setApiUrl] = useState('https://margin.at'); 55 56 const [overlayEnabled, setOverlayEnabled] = useState(true); 57 + const [tags, setTags] = useState<string[]>([]); 58 + const [tagSuggestions, setTagSuggestions] = useState<string[]>([]); 56 59 57 60 useEffect(() => { 58 61 checkSession(); ··· 62 65 }, []); 63 66 64 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(() => { 65 87 if (session?.authenticated && currentUrl) { 66 88 if (activeTab === 'page') loadAnnotations(); 67 89 else if (activeTab === 'bookmarks') loadBookmarks(); ··· 298 320 url: currentUrl, 299 321 text: text.trim(), 300 322 title: currentTitle, 323 + tags: tags.length > 0 ? tags : undefined, 301 324 }); 302 325 if (result.success) { 303 326 setText(''); 327 + setTags([]); 304 328 loadAnnotations(); 305 329 } else { 306 330 alert('Failed to post annotation'); ··· 625 649 </div> 626 650 627 651 <div className="p-4 border-b border-[var(--border)]"> 628 - <div className="relative"> 652 + <div> 629 653 <textarea 630 654 value={text} 631 655 onChange={(e) => setText(e.target.value)} 632 656 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]" 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]" 634 658 /> 635 - <div className="absolute bottom-3 right-3"> 659 + <div className="mt-2"> 660 + <TagInput tags={tags} onChange={setTags} suggestions={tagSuggestions} /> 661 + </div> 662 + <div className="flex justify-end mt-2"> 636 663 <button 637 664 onClick={handlePost} 638 665 disabled={posting || !text.trim()}
+27
extension/src/components/sidepanel/App.tsx
··· 4 4 import type { MarginSession, Annotation, Bookmark, Highlight, Collection } from '@/utils/types'; 5 5 import { APP_URL } from '@/utils/types'; 6 6 import CollectionIcon from '@/components/CollectionIcon'; 7 + import TagInput from '@/components/TagInput'; 7 8 8 9 type Tab = 'page' | 'bookmarks' | 'highlights' | 'collections'; 9 10 type PageFilter = 'all' | 'annotations' | 'highlights'; ··· 101 102 const [collectionModalItem, setCollectionModalItem] = useState<string | null>(null); 102 103 const [addingToCollection, setAddingToCollection] = useState<string | null>(null); 103 104 const [containingCollections, setContainingCollections] = useState<Set<string>>(new Set()); 105 + const [tags, setTags] = useState<string[]>([]); 106 + const [tagSuggestions, setTagSuggestions] = useState<string[]>([]); 104 107 105 108 useEffect(() => { 106 109 checkSession(); ··· 118 121 }, []); 119 122 120 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(() => { 121 143 if (session?.authenticated && currentUrl) { 122 144 if (activeTab === 'page') loadAnnotations(); 123 145 else if (activeTab === 'bookmarks') loadBookmarks(); ··· 279 301 url: currentUrl, 280 302 text: text.trim(), 281 303 title: currentTitle, 304 + tags: tags.length > 0 ? tags : undefined, 282 305 }); 283 306 if (result.success) { 284 307 setText(''); 308 + setTags([]); 285 309 loadAnnotations(); 286 310 } else { 287 311 alert('Failed to post annotation'); ··· 564 588 placeholder="Share your thoughts on this page..." 565 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]" 566 590 /> 591 + <div className="mt-2"> 592 + <TagInput tags={tags} onChange={setTags} suggestions={tagSuggestions} /> 593 + </div> 567 594 <div className="flex gap-2 mt-3"> 568 595 <button 569 596 onClick={handlePost}
+12 -2
extension/src/entrypoints/background.ts
··· 14 14 getItemCollections, 15 15 getReplies, 16 16 createReply, 17 + getUserTags, 18 + getTrendingTags, 17 19 } from '@/utils/api'; 18 20 import { overlayEnabledItem, apiUrlItem } from '@/utils/storage'; 19 21 ··· 130 132 return await overlayEnabledItem.getValue(); 131 133 }); 132 134 135 + onMessage('getUserTags', async ({ data }) => { 136 + return await getUserTags(data.did); 137 + }); 138 + 139 + onMessage('getTrendingTags', async () => { 140 + return await getTrendingTags(); 141 + }); 142 + 133 143 onMessage('openAppUrl', async ({ data }) => { 134 144 const apiUrl = await apiUrlItem.getValue(); 135 145 await browser.tabs.create({ url: `${apiUrl}${data.path}` }); ··· 141 151 142 152 if (tabId) { 143 153 await browser.action.setBadgeText({ text, tabId }); 144 - await browser.action.setBadgeBackgroundColor({ color: '#6366f1', tabId }); 154 + await browser.action.setBadgeBackgroundColor({ color: '#3b82f6', tabId }); 145 155 } else { 146 156 const [tab] = await browser.tabs.query({ active: true, currentWindow: true }); 147 157 if (tab?.id) { 148 158 await browser.action.setBadgeText({ text, tabId: tab.id }); 149 - await browser.action.setBadgeBackgroundColor({ color: '#6366f1', tabId: tab.id }); 159 + await browser.action.setBadgeBackgroundColor({ color: '#3b82f6', tabId: tab.id }); 150 160 } 151 161 } 152 162 });
+30 -2
extension/src/utils/api.ts
··· 127 127 text: string; 128 128 title?: string; 129 129 selector?: TextSelector; 130 + tags?: string[]; 130 131 }) { 131 132 try { 132 133 const res = await apiRequest('/annotations', { ··· 136 137 text: data.text, 137 138 title: data.title, 138 139 selector: data.selector, 140 + tags: data.tags, 139 141 }), 140 142 }); 141 143 ··· 150 152 } 151 153 } 152 154 153 - export async function createBookmark(data: { url: string; title?: string }) { 155 + export async function createBookmark(data: { url: string; title?: string; tags?: string[] }) { 154 156 try { 155 157 const res = await apiRequest('/bookmarks', { 156 158 method: 'POST', 157 - body: JSON.stringify({ url: data.url, title: data.title }), 159 + body: JSON.stringify({ url: data.url, title: data.title, tags: data.tags }), 158 160 }); 159 161 160 162 if (!res.ok) { ··· 173 175 title?: string; 174 176 selector: TextSelector; 175 177 color?: string; 178 + tags?: string[]; 176 179 }) { 177 180 try { 178 181 const res = await apiRequest('/highlights', { ··· 182 185 title: data.title, 183 186 selector: data.selector, 184 187 color: data.color, 188 + tags: data.tags, 185 189 }), 186 190 }); 187 191 ··· 281 285 return { success: true }; 282 286 } catch (error) { 283 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 []; 284 312 } 285 313 } 286 314
+18 -3
extension/src/utils/messaging.ts
··· 13 13 14 14 getAnnotations(data: { url: string }): Annotation[]; 15 15 activateOnPdf(data: { tabId: number; url: string }): { redirected: boolean }; 16 - createAnnotation(data: { url: string; text: string; title?: string; selector?: TextSelector }): { 16 + createAnnotation(data: { 17 + url: string; 18 + text: string; 19 + title?: string; 20 + selector?: TextSelector; 21 + tags?: string[]; 22 + }): { 17 23 success: boolean; 18 24 data?: Annotation; 19 25 error?: string; 20 26 }; 21 27 22 - createBookmark(data: { url: string; title?: string }): { 28 + createBookmark(data: { url: string; title?: string; tags?: string[] }): { 23 29 success: boolean; 24 30 data?: Bookmark; 25 31 error?: string; 26 32 }; 27 33 getUserBookmarks(data: { did: string }): Bookmark[]; 28 34 29 - createHighlight(data: { url: string; title?: string; selector: TextSelector; color?: string }): { 35 + createHighlight(data: { 36 + url: string; 37 + title?: string; 38 + selector: TextSelector; 39 + color?: string; 40 + tags?: string[]; 41 + }): { 30 42 success: boolean; 31 43 data?: Highlight; 32 44 error?: string; ··· 59 71 }): { success: boolean; error?: string }; 60 72 61 73 getOverlayEnabled(): boolean; 74 + 75 + getUserTags(data: { did: string }): string[]; 76 + getTrendingTags(): string[]; 62 77 63 78 openAppUrl(data: { path: string }): void; 64 79
+132 -24
extension/src/utils/overlay-styles.ts
··· 1 1 export const overlayStyles = /* css */ ` 2 2 :host { 3 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; 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 10 11 - --text-primary: #f8fafc; 12 - --text-secondary: #94a3b8; 13 - --text-tertiary: #64748b; 14 - --border: rgba(148, 163, 184, 0.12); 11 + --text-primary: #e8e8e3; 12 + --text-secondary: #a1a09a; 13 + --text-tertiary: #6b6a65; 14 + --border: rgba(255, 255, 255, 0.08); 15 15 16 - --accent: #8b5cf6; 17 - --accent-hover: #a78bfa; 18 - --accent-subtle: rgba(139, 92, 246, 0.15); 16 + --accent: #7aa2f7; 17 + --accent-hover: #9bbcff; 18 + --accent-subtle: rgba(122, 162, 247, 0.14); 19 19 20 20 --highlight-yellow: #fbbf24; 21 21 --highlight-green: #34d399; 22 22 --highlight-blue: #60a5fa; 23 23 --highlight-pink: #f472b6; 24 - --highlight-purple: #a78bfa; 24 + --highlight-purple: #9bbcff; 25 25 } 26 26 27 27 :host(.light) { 28 - --bg-primary: #f8fafc; 28 + --bg-primary: #fafaf8; 29 29 --bg-secondary: #ffffff; 30 - --bg-tertiary: #f1f5f9; 30 + --bg-tertiary: #f2f2ef; 31 31 --bg-card: #ffffff; 32 32 --bg-elevated: #ffffff; 33 - --bg-hover: #e2e8f0; 33 + --bg-hover: #eaeae6; 34 34 35 - --text-primary: #0f172a; 36 - --text-secondary: #64748b; 37 - --text-tertiary: #94a3b8; 38 - --border: rgba(100, 116, 139, 0.15); 35 + --text-primary: #1a1a18; 36 + --text-secondary: #6b6a65; 37 + --text-tertiary: #a1a09a; 38 + --border: rgba(0, 0, 0, 0.08); 39 39 40 - --accent: #7c3aed; 41 - --accent-hover: #6d28d9; 42 - --accent-subtle: rgba(124, 58, 237, 0.12); 40 + --accent: #3b82f6; 41 + --accent-hover: #2563eb; 42 + --accent-subtle: rgba(59, 130, 246, 0.08); 43 43 } 44 44 45 45 .margin-overlay { ··· 527 527 opacity: 0.5; 528 528 cursor: not-allowed; 529 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); 530 638 } 531 639 532 640 .margin-hover-indicator {
+111 -5
extension/src/utils/overlay.ts
··· 47 47 const injectedStyles = new Set<string>(); 48 48 let overlayEnabled = true; 49 49 let currentUserDid: string | null = null; 50 + let cachedUserTags: string[] = []; 50 51 51 52 function getPageUrl(): string { 52 53 const pdfUrl = document.documentElement.dataset.marginPdfUrl; ··· 79 80 .then((session) => { 80 81 if (session.authenticated && session.did) { 81 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 + }); 82 96 } 83 97 }) 84 98 .catch(() => {}); ··· 242 256 textarea.placeholder = 'Write your annotation...'; 243 257 body.appendChild(textarea); 244 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 + 245 350 composeModal.appendChild(body); 246 351 247 352 const footer = document.createElement('div'); ··· 282 387 title: document.title, 283 388 text, 284 389 selector: { type: 'TextQuoteSelector', exact: quoteText }, 390 + tags: composeTags.length > 0 ? composeTags : undefined, 285 391 }); 286 392 287 393 if (!res.success) { ··· 339 445 const tempHighlight = new Highlight(range); 340 446 const hlName = 'margin-scroll-flash'; 341 447 CSS.highlights.set(hlName, tempHighlight); 342 - injectHighlightStyle(hlName, '#6366f1'); 448 + injectHighlightStyle(hlName, '#3b82f6'); 343 449 344 450 const flashStyle = document.createElement('style'); 345 451 flashStyle.textContent = `::highlight(${hlName}) { 346 452 background-color: rgba(99, 102, 241, 0.25); 347 453 text-decoration: underline; 348 - text-decoration-color: #6366f1; 454 + text-decoration-color: #3b82f6; 349 455 text-decoration-thickness: 3px; 350 456 text-underline-offset: 2px; 351 457 }`; ··· 359 465 try { 360 466 const highlight = document.createElement('mark'); 361 467 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;'; 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;'; 363 469 range.surroundContents(highlight); 364 470 365 471 setTimeout(() => { ··· 454 560 activeItems.push({ range, item }); 455 561 456 562 const isHighlight = (item as any).type === 'Highlight'; 457 - const defaultColor = isHighlight ? '#f59e0b' : '#6366f1'; 563 + const defaultColor = isHighlight ? '#f59e0b' : '#3b82f6'; 458 564 const color = item.color || defaultColor; 459 565 if (!rangesByColor[color]) rangesByColor[color] = []; 460 566 rangesByColor[color].push(range); ··· 578 684 if (avatar) { 579 685 return `<img src="${avatar}" style="width: 24px; height: 24px; border-radius: 50%; object-fit: cover; border: 2px solid #09090b; margin-left: ${marginLeft};">`; 580 686 } 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>`; 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>`; 582 688 } 583 689 }) 584 690 .join('');
+3
extension/src/utils/types.ts
··· 26 26 }; 27 27 selector?: TextSelector; 28 28 color?: string; 29 + tags?: string[]; 29 30 created?: string; 30 31 createdAt?: string; 31 32 creator?: Author; ··· 47 48 title?: string; 48 49 description?: string; 49 50 image?: string; 51 + tags?: string[]; 50 52 createdAt?: string; 51 53 } 52 54 ··· 58 60 selector?: TextSelector; 59 61 }; 60 62 color?: string; 63 + tags?: string[]; 61 64 title?: string; 62 65 createdAt?: string; 63 66 }
+22 -22
extension/tailwind.config.js
··· 9 9 }, 10 10 colors: { 11 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', 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 23 }, 24 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', 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 36 }, 37 37 }, 38 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"> 1 + <svg width="265" height="231" viewBox="0 0 265 231" fill="#027bff" xmlns="http://www.w3.org/2000/svg"> 2 2 <path d="M0 230 V0 H199 V65.7156 H149.5 V115.216 H182.5 L199 131.716 V230 Z"/> 3 3 <path d="M215 214.224 V230 H264.5 V0 H215.07 V16.2242 H248.5 V214.224 H215 Z"/> 4 4 </svg>
+19 -3
web/src/api/client.ts
··· 369 369 url, 370 370 title, 371 371 description, 372 + tags, 372 373 }: { 373 374 url: string; 374 375 title?: string; 375 376 description?: string; 377 + tags?: string[]; 376 378 }) { 377 379 try { 378 380 const res = await apiRequest("/api/bookmarks", { 379 381 method: "POST", 380 - body: JSON.stringify({ url, title, description }), 382 + body: JSON.stringify({ url, title, description, tags }), 381 383 }); 382 384 if (!res.ok) throw new Error(await res.text()); 383 385 const raw = await res.json(); ··· 772 774 count: number; 773 775 } 774 776 775 - export async function getTrendingTags(limit = 10): Promise<Tag[]> { 777 + export async function getTrendingTags(limit = 50): Promise<Tag[]> { 776 778 try { 777 - const res = await apiRequest(`/api/tags/trending?limit=${limit}`, { 779 + const res = await apiRequest(`/api/trending-tags?limit=${limit}`, { 778 780 skipAuthRedirect: true, 779 781 }); 780 782 if (!res.ok) return []; ··· 782 784 return Array.isArray(data) ? data : data.tags || []; 783 785 } catch (e) { 784 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); 785 801 return []; 786 802 } 787 803 }
+40 -13
web/src/components/feed/Composer.tsx
··· 1 - import React, { useState } from "react"; 2 - import { createAnnotation, createHighlight } from "../../api/client"; 1 + import React, { useState, useEffect } from "react"; 2 + import { 3 + createAnnotation, 4 + createHighlight, 5 + sessionAtom, 6 + getUserTags, 7 + getTrendingTags, 8 + } from "../../api/client"; 3 9 import type { Selector, ContentLabelValue } from "../../types"; 4 10 import { X, ShieldAlert } from "lucide-react"; 11 + import TagInput from "../ui/TagInput"; 5 12 6 13 const SELF_LABEL_OPTIONS: { value: ContentLabelValue; label: string }[] = [ 7 14 { value: "sexual", label: "Sexual" }, ··· 27 34 }: ComposerProps) { 28 35 const [text, setText] = useState(""); 29 36 const [quoteText, setQuoteText] = useState(""); 30 - const [tags, setTags] = useState(""); 37 + const [tags, setTags] = useState<string[]>([]); 38 + const [tagSuggestions, setTagSuggestions] = useState<string[]>([]); 31 39 const [selector, setSelector] = useState(initialSelector); 32 40 const [loading, setLoading] = useState(false); 33 41 const [error, setError] = useState<string | null>(null); ··· 35 43 const [selfLabels, setSelfLabels] = useState<ContentLabelValue[]>([]); 36 44 const [showLabelPicker, setShowLabelPicker] = useState(false); 37 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 + 38 68 const highlightedText = 39 69 selector?.type === "TextQuoteSelector" ? selector.exact : null; 40 70 ··· 54 84 }; 55 85 } 56 86 57 - const tagList = tags 58 - .split(",") 59 - .map((t) => t.trim()) 60 - .filter(Boolean); 87 + const tagList = tags.filter(Boolean); 61 88 62 89 if (!text.trim()) { 63 90 if (!finalSelector) throw new Error("No text selected"); ··· 84 111 85 112 setText(""); 86 113 setQuoteText(""); 114 + setTags([]); 87 115 setSelector(null); 88 116 if (onSuccess) onSuccess(); 89 117 } catch (err) { ··· 176 204 disabled={loading} 177 205 /> 178 206 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" 207 + <TagInput 208 + tags={tags} 209 + onChange={setTags} 210 + suggestions={tagSuggestions} 211 + placeholder="Add tags..." 185 212 disabled={loading} 186 213 /> 187 214
+1 -1
web/src/components/feed/MasonryFeed.tsx
··· 149 149 )} 150 150 151 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"> 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 153 <div className="flex items-center gap-3"> 154 154 <div className="flex-1"> 155 155 <Tabs
+32 -50
web/src/components/modals/EditItemModal.tsx
··· 1 - import React, { useState } from "react"; 1 + import React, { useState, useEffect } from "react"; 2 2 import { X, ShieldAlert } from "lucide-react"; 3 3 import { 4 4 updateAnnotation, 5 5 updateHighlight, 6 6 updateBookmark, 7 + sessionAtom, 8 + getUserTags, 9 + getTrendingTags, 7 10 } from "../../api/client"; 8 11 import type { AnnotationItem, ContentLabelValue } from "../../types"; 12 + import TagInput from "../ui/TagInput"; 9 13 10 14 const SELF_LABEL_OPTIONS: { value: ContentLabelValue; label: string }[] = [ 11 15 { value: "sexual", label: "Sexual" }, ··· 58 62 }: Omit<EditItemModalProps, "isOpen">) { 59 63 const [text, setText] = useState(item.body?.value || ""); 60 64 const [tags, setTags] = useState<string[]>(item.tags || []); 61 - const [tagInput, setTagInput] = useState(""); 65 + const [tagSuggestions, setTagSuggestions] = useState<string[]>([]); 62 66 const [color, setColor] = useState(item.color || "yellow"); 63 67 const [title, setTitle] = useState(item.title || item.target?.title || ""); 64 68 const [description, setDescription] = useState(item.description || ""); ··· 73 77 const [saving, setSaving] = useState(false); 74 78 const [error, setError] = useState<string | null>(null); 75 79 76 - const addTag = () => { 77 - const t = tagInput.trim().toLowerCase(); 78 - if (t && !tags.includes(t)) { 79 - setTags([...tags, t]); 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 + }); 80 99 } 81 - setTagInput(""); 82 - }; 83 - 84 - const removeTag = (tag: string) => { 85 - setTags(tags.filter((t) => t !== tag)); 86 - }; 100 + }, []); 87 101 88 102 const toggleLabel = (val: ContentLabelValue) => { 89 103 setSelfLabels((prev) => ··· 262 276 <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1.5"> 263 277 Tags 264 278 </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> 279 + <TagInput 280 + tags={tags} 281 + onChange={setTags} 282 + suggestions={tagSuggestions} 283 + placeholder="Add a tag..." 284 + /> 303 285 </div> 304 286 305 287 <div>
+3 -3
web/src/components/navigation/RightSidebar.tsx
··· 23 23 }; 24 24 25 25 useEffect(() => { 26 - getTrendingTags().then(setTags); 26 + getTrendingTags(10).then(setTags); 27 27 }, []); 28 28 29 29 const extensionLink = ··· 34 34 : "https://chromewebstore.google.com/detail/margin/cgpmbiiagnehkikhcbnhiagfomajncpa"; 35 35 36 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"> 37 + <aside className="hidden xl:block w-[320px] shrink-0 sticky top-0 h-screen overflow-y-auto px-6 py-6"> 38 38 <div className="space-y-5"> 39 39 <div className="relative"> 40 40 <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> ··· 85 85 <a 86 86 key={t.tag} 87 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" 88 + className="px-2 py-2.5 hover:bg-surface-100 dark:hover:bg-surface-800 rounded-lg transition-colors group" 89 89 > 90 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 91 #{t.tag}
+5 -5
web/src/components/navigation/Sidebar.tsx
··· 80 80 const navItems = user ? authNavItems : publicNavItems; 81 81 82 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"> 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 84 <div className="flex flex-col gap-6"> 85 85 <Link 86 86 to="/home" ··· 105 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 106 isActive 107 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" 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 109 }`} 110 110 > 111 111 <item.icon ··· 140 140 title={ 141 141 theme === "light" ? "Light" : theme === "dark" ? "Dark" : "System" 142 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" 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 144 > 145 145 {theme === "light" ? ( 146 146 <Sun size={18} /> ··· 159 159 <Link 160 160 to="/settings" 161 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" 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 163 > 164 164 <Settings size={18} /> 165 165 <span className="hidden lg:inline">Settings</span> ··· 170 170 <Link 171 171 to={`/profile/${user.did}`} 172 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" 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 174 > 175 175 <Avatar did={user.did} avatar={user.avatar} size="sm" /> 176 176 <div className="flex-1 min-w-0 hidden lg:block">
+1 -1
web/src/components/ui/LayoutToggle.tsx
··· 19 19 return ( 20 20 <div 21 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", 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 23 className, 24 24 )} 25 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 13 useStore($theme); 14 14 15 15 return ( 16 - <div className="min-h-screen bg-surface-50 dark:bg-surface-950 flex"> 16 + <div className="min-h-screen bg-surface-100 dark:bg-surface-900 flex"> 17 17 <Sidebar /> 18 18 19 19 <div className="flex-1 min-w-0 transition-all duration-200"> 20 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} 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> 23 25 </main> 24 26 25 27 <RightSidebar />
+6 -6
web/src/styles/global.css
··· 10 10 } 11 11 12 12 html { 13 - background-color: #f8fafc; 14 - color: #0f172a; 13 + background-color: #f4f4f5; 14 + color: #18181b; 15 15 } 16 16 17 17 html[data-theme="dark"] { 18 - background-color: #020617; 19 - color: #f8fafc; 18 + background-color: #18181b; 19 + color: #fafafa; 20 20 } 21 21 22 22 h1, ··· 44 44 } 45 45 46 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; 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 48 } 49 49 50 50 .glass { ··· 52 52 } 53 53 54 54 .card { 55 - @apply bg-white dark:bg-surface-900 rounded-xl border border-surface-200/80 dark:border-surface-800 shadow-sm; 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 56 } 57 57 58 58 .transition-default {
+1 -1
web/src/views/core/Feed.tsx
··· 255 255 )} 256 256 257 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"> 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 259 {!tag && ( 260 260 <Tabs 261 261 tabs={tabs}
+22 -22
web/tailwind.config.mjs
··· 12 12 }, 13 13 colors: { 14 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", 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 26 }, 27 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", 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 39 }, 40 40 }, 41 41 animation: {