(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
98
fork

Configure Feed

Select the types of activity you want to include in your feed.

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

scanash00 5f700c9b 68d26ff3

+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: {