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

Compare changes

Choose any two refs to compare.

+1 -1
.github/workflows/docker-publish.yml
··· 2 2 3 3 on: 4 4 push: 5 - branches: [ "main" ] 5 + branches: ["main"] 6 6 workflow_dispatch: 7 7 8 8 env:
+13 -3
.github/workflows/release-extension.yml
··· 3 3 on: 4 4 push: 5 5 tags: 6 - - 'v*' 6 + - "v*" 7 7 8 8 jobs: 9 9 release: ··· 19 19 run: | 20 20 VERSION=${GITHUB_REF_NAME#v} 21 21 echo "Updating manifests to version $VERSION" 22 - 22 + 23 23 cd extension 24 24 for manifest in manifest.json manifest.chrome.json manifest.firefox.json; do 25 25 if [ -f "$manifest" ]; then ··· 36 36 cp manifest.chrome.json manifest.json 37 37 zip -r ../margin-extension-chrome.zip . -x "*.DS_Store" -x "*.git*" -x "manifest.*.json" 38 38 cd .. 39 - 39 + 40 40 - name: Build Extension (Firefox) 41 41 run: | 42 42 cd extension ··· 76 76 npx web-ext sign --channel=listed --api-key=$AMO_JWT_ISSUER --api-secret=$AMO_JWT_SECRET --source-dir=. --artifacts-dir=../web-ext-artifacts --approval-timeout=300000 --amo-metadata=amo-metadata.json || echo "Web-ext sign timed out (expected), continuing..." 77 77 rm amo-metadata.json 78 78 cd .. 79 + 80 + - name: Prepare signed Firefox XPI 81 + run: | 82 + if ls web-ext-artifacts/*.xpi 1> /dev/null 2>&1; then 83 + SIGNED_XPI=$(ls web-ext-artifacts/*.xpi | head -1) 84 + echo "Found signed XPI: $SIGNED_XPI" 85 + cp "$SIGNED_XPI" margin-extension-firefox.xpi 86 + else 87 + echo "No signed XPI found, using unsigned build" 88 + fi 79 89 80 90 - name: Create Release 81 91 uses: softprops/action-gh-release@v1
+3 -5
README.md
··· 1 1 # Margin 2 2 3 - *Write in the margins of the web* 3 + _Write in the margins of the web_ 4 4 5 5 A web comments layer built on [AT Protocol](https://atproto.com) that lets you annotate any URL on the internet. 6 6 7 7 ## Project Structure 8 8 9 9 ``` 10 - project-agua/ 10 + margin/ 11 11 โ”œโ”€โ”€ lexicons/ # AT Protocol lexicon schemas 12 12 โ”‚ โ””โ”€โ”€ at/margin/ 13 13 โ”‚ โ”œโ”€โ”€ annotation.json ··· 40 40 41 41 Server runs on http://localhost:8080 42 42 43 - Server runs on http://localhost:8080 44 - 45 43 ### Docker (Recommended) 46 44 47 45 Run the full stack (Backend + Postgres) with Docker: 48 46 49 47 ```bash 50 - docker-compose up -d --build 48 + docker compose up -d --build 51 49 ``` 52 50 53 51 ### Web App
+8
backend/cmd/server/main.go
··· 97 97 r.Get("/og-image", ogHandler.HandleOGImage) 98 98 r.Get("/annotation/{did}/{rkey}", ogHandler.HandleAnnotationPage) 99 99 r.Get("/at/{did}/{rkey}", ogHandler.HandleAnnotationPage) 100 + r.Get("/{handle}/annotation/{rkey}", ogHandler.HandleAnnotationPage) 101 + r.Get("/{handle}/highlight/{rkey}", ogHandler.HandleAnnotationPage) 102 + r.Get("/{handle}/bookmark/{rkey}", ogHandler.HandleAnnotationPage) 103 + 104 + r.Get("/api/tags/trending", handler.HandleGetTrendingTags) 105 + 106 + r.Get("/collection/{uri}", ogHandler.HandleCollectionPage) 107 + r.Get("/{handle}/collection/{rkey}", ogHandler.HandleCollectionPage) 100 108 101 109 staticDir := getEnv("STATIC_DIR", "../web/dist") 102 110 serveStatic(r, staticDir)
+45 -24
backend/internal/api/annotations.go
··· 47 47 return 48 48 } 49 49 50 - if req.URL == "" || req.Text == "" { 51 - http.Error(w, "URL and text are required", http.StatusBadRequest) 50 + if req.URL == "" { 51 + http.Error(w, "URL is required", http.StatusBadRequest) 52 + return 53 + } 54 + 55 + if req.Text == "" && req.Selector == nil && len(req.Tags) == 0 { 56 + http.Error(w, "Must provide text, selector, or tags", http.StatusBadRequest) 52 57 return 53 58 } 54 59 ··· 67 72 } 68 73 69 74 record := xrpc.NewAnnotationRecordWithMotivation(req.URL, urlHash, req.Text, req.Selector, req.Title, motivation) 75 + if len(req.Tags) > 0 { 76 + record.Tags = req.Tags 77 + } 70 78 71 79 var result *xrpc.CreateRecordOutput 72 80 err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { ··· 93 101 selectorJSONPtr = &selectorStr 94 102 } 95 103 104 + var tagsJSONPtr *string 105 + if len(req.Tags) > 0 { 106 + tagsBytes, _ := json.Marshal(req.Tags) 107 + tagsStr := string(tagsBytes) 108 + tagsJSONPtr = &tagsStr 109 + } 110 + 96 111 cid := result.CID 97 112 did := session.DID 98 113 annotation := &db.Annotation{ ··· 105 120 TargetHash: urlHash, 106 121 TargetTitle: targetTitlePtr, 107 122 SelectorJSON: selectorJSONPtr, 123 + TagsJSON: tagsJSONPtr, 108 124 CreatedAt: time.Now(), 109 125 IndexedAt: time.Now(), 110 126 } ··· 203 219 } 204 220 rkey := parts[2] 205 221 206 - var selector interface{} = nil 207 - if annotation.SelectorJSON != nil && *annotation.SelectorJSON != "" { 208 - json.Unmarshal([]byte(*annotation.SelectorJSON), &selector) 209 - } 210 - 211 222 tagsJSON := "" 212 223 if len(req.Tags) > 0 { 213 224 tagsBytes, _ := json.Marshal(req.Tags) 214 225 tagsJSON = string(tagsBytes) 215 226 } 216 227 217 - record := map[string]interface{}{ 218 - "$type": xrpc.CollectionAnnotation, 219 - "text": req.Text, 220 - "url": annotation.TargetSource, 221 - "createdAt": annotation.CreatedAt.Format(time.RFC3339), 222 - } 223 - if selector != nil { 224 - record["selector"] = selector 225 - } 226 - if len(req.Tags) > 0 { 227 - record["tags"] = req.Tags 228 - } 229 - if annotation.TargetTitle != nil { 230 - record["title"] = *annotation.TargetTitle 231 - } 232 - 233 228 if annotation.BodyValue != nil { 234 229 previousContent := *annotation.BodyValue 235 230 s.db.SaveEditHistory(uri, "annotation", previousContent, annotation.CID) ··· 237 232 238 233 var result *xrpc.PutRecordOutput 239 234 err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 235 + existing, getErr := client.GetRecord(r.Context(), did, xrpc.CollectionAnnotation, rkey) 236 + if getErr != nil { 237 + return fmt.Errorf("failed to fetch existing record: %w", getErr) 238 + } 239 + 240 + var record map[string]interface{} 241 + if err := json.Unmarshal(existing.Value, &record); err != nil { 242 + return fmt.Errorf("failed to parse existing record: %w", err) 243 + } 244 + 245 + record["text"] = req.Text 246 + if req.Tags != nil { 247 + record["tags"] = req.Tags 248 + } else { 249 + delete(record, "tags") 250 + } 251 + 240 252 var updateErr error 241 253 result, updateErr = client.PutRecord(r.Context(), did, xrpc.CollectionAnnotation, rkey, record) 242 254 if updateErr != nil { ··· 498 510 Title string `json:"title,omitempty"` 499 511 Selector interface{} `json:"selector"` 500 512 Color string `json:"color,omitempty"` 513 + Tags []string `json:"tags,omitempty"` 501 514 } 502 515 503 516 func (s *AnnotationService) CreateHighlight(w http.ResponseWriter, r *http.Request) { ··· 519 532 } 520 533 521 534 urlHash := db.HashURL(req.URL) 522 - record := xrpc.NewHighlightRecord(req.URL, urlHash, req.Selector, req.Color) 535 + record := xrpc.NewHighlightRecord(req.URL, urlHash, req.Selector, req.Color, req.Tags) 523 536 524 537 var result *xrpc.CreateRecordOutput 525 538 err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { ··· 549 562 colorPtr = &req.Color 550 563 } 551 564 565 + var tagsJSONPtr *string 566 + if len(req.Tags) > 0 { 567 + tagsBytes, _ := json.Marshal(req.Tags) 568 + tagsStr := string(tagsBytes) 569 + tagsJSONPtr = &tagsStr 570 + } 571 + 552 572 cid := result.CID 553 573 highlight := &db.Highlight{ 554 574 URI: result.URI, ··· 558 578 TargetTitle: titlePtr, 559 579 SelectorJSON: selectorJSONPtr, 560 580 Color: colorPtr, 581 + TagsJSON: tagsJSONPtr, 561 582 CreatedAt: time.Now(), 562 583 IndexedAt: time.Now(), 563 584 CID: &cid,
+35 -5
backend/internal/api/collections.go
··· 213 213 return 214 214 } 215 215 216 + profiles := fetchProfilesForDIDs([]string{authorDID}) 217 + creator := profiles[authorDID] 218 + 219 + apiCollections := make([]APICollection, len(collections)) 220 + for i, c := range collections { 221 + icon := "" 222 + if c.Icon != nil { 223 + icon = *c.Icon 224 + } 225 + desc := "" 226 + if c.Description != nil { 227 + desc = *c.Description 228 + } 229 + apiCollections[i] = APICollection{ 230 + URI: c.URI, 231 + Name: c.Name, 232 + Description: desc, 233 + Icon: icon, 234 + Creator: creator, 235 + CreatedAt: c.CreatedAt, 236 + IndexedAt: c.IndexedAt, 237 + } 238 + } 239 + 216 240 w.Header().Set("Content-Type", "application/json") 217 241 json.NewEncoder(w).Encode(map[string]interface{}{ 218 242 "@context": "http://www.w3.org/ns/anno.jsonld", 219 243 "type": "Collection", 220 - "items": collections, 221 - "totalItems": len(collections), 244 + "items": apiCollections, 245 + "totalItems": len(apiCollections), 222 246 }) 223 247 } 224 248 ··· 254 278 255 279 enrichedItems := make([]EnrichedCollectionItem, 0, len(items)) 256 280 281 + session, err := s.refresher.GetSessionWithAutoRefresh(r) 282 + viewerDID := "" 283 + if err == nil { 284 + viewerDID = session.DID 285 + } 286 + 257 287 for _, item := range items { 258 288 enriched := EnrichedCollectionItem{ 259 289 URI: item.URI, ··· 266 296 if strings.Contains(item.AnnotationURI, "at.margin.annotation") { 267 297 enriched.Type = "annotation" 268 298 if a, err := s.db.GetAnnotationByURI(item.AnnotationURI); err == nil { 269 - hydrated, _ := hydrateAnnotations([]db.Annotation{*a}) 299 + hydrated, _ := hydrateAnnotations(s.db, []db.Annotation{*a}, viewerDID) 270 300 if len(hydrated) > 0 { 271 301 enriched.Annotation = &hydrated[0] 272 302 } ··· 274 304 } else if strings.Contains(item.AnnotationURI, "at.margin.highlight") { 275 305 enriched.Type = "highlight" 276 306 if h, err := s.db.GetHighlightByURI(item.AnnotationURI); err == nil { 277 - hydrated, _ := hydrateHighlights([]db.Highlight{*h}) 307 + hydrated, _ := hydrateHighlights(s.db, []db.Highlight{*h}, viewerDID) 278 308 if len(hydrated) > 0 { 279 309 enriched.Highlight = &hydrated[0] 280 310 } ··· 282 312 } else if strings.Contains(item.AnnotationURI, "at.margin.bookmark") { 283 313 enriched.Type = "bookmark" 284 314 if b, err := s.db.GetBookmarkByURI(item.AnnotationURI); err == nil { 285 - hydrated, _ := hydrateBookmarks([]db.Bookmark{*b}) 315 + hydrated, _ := hydrateBookmarks(s.db, []db.Bookmark{*b}, viewerDID) 286 316 if len(hydrated) > 0 { 287 317 enriched.Bookmark = &hydrated[0] 288 318 }
+119 -40
backend/internal/api/handler.go
··· 81 81 limit := parseIntParam(r, "limit", 50) 82 82 offset := parseIntParam(r, "offset", 0) 83 83 motivation := r.URL.Query().Get("motivation") 84 + tag := r.URL.Query().Get("tag") 84 85 85 86 var annotations []db.Annotation 86 87 var err error ··· 90 91 annotations, err = h.db.GetAnnotationsByTargetHash(urlHash, limit, offset) 91 92 } else if motivation != "" { 92 93 annotations, err = h.db.GetAnnotationsByMotivation(motivation, limit, offset) 94 + } else if tag != "" { 95 + annotations, err = h.db.GetAnnotationsByTag(tag, limit, offset) 93 96 } else { 94 97 annotations, err = h.db.GetRecentAnnotations(limit, offset) 95 98 } ··· 99 102 return 100 103 } 101 104 102 - enriched, _ := hydrateAnnotations(annotations) 105 + enriched, _ := hydrateAnnotations(h.db, annotations, h.getViewerDID(r)) 103 106 104 107 w.Header().Set("Content-Type", "application/json") 105 108 json.NewEncoder(w).Encode(map[string]interface{}{ ··· 112 115 113 116 func (h *Handler) GetFeed(w http.ResponseWriter, r *http.Request) { 114 117 limit := parseIntParam(r, "limit", 50) 115 - 116 - annotations, _ := h.db.GetRecentAnnotations(limit, 0) 117 - highlights, _ := h.db.GetRecentHighlights(limit, 0) 118 - bookmarks, _ := h.db.GetRecentBookmarks(limit, 0) 118 + tag := r.URL.Query().Get("tag") 119 + creator := r.URL.Query().Get("creator") 119 120 120 - authAnnos, _ := hydrateAnnotations(annotations) 121 - authHighs, _ := hydrateHighlights(highlights) 122 - authBooks, _ := hydrateBookmarks(bookmarks) 121 + var annotations []db.Annotation 122 + var highlights []db.Highlight 123 + var bookmarks []db.Bookmark 124 + var collectionItems []db.CollectionItem 125 + var err error 123 126 124 - collectionItems, err := h.db.GetRecentCollectionItems(limit, 0) 125 - if err != nil { 126 - log.Printf("Error fetching collection items: %v\n", err) 127 + if tag != "" { 128 + if creator != "" { 129 + annotations, _ = h.db.GetAnnotationsByTagAndAuthor(tag, creator, limit, 0) 130 + highlights, _ = h.db.GetHighlightsByTagAndAuthor(tag, creator, limit, 0) 131 + bookmarks, _ = h.db.GetBookmarksByTagAndAuthor(tag, creator, limit, 0) 132 + collectionItems = []db.CollectionItem{} 133 + } else { 134 + annotations, _ = h.db.GetAnnotationsByTag(tag, limit, 0) 135 + highlights, _ = h.db.GetHighlightsByTag(tag, limit, 0) 136 + bookmarks, _ = h.db.GetBookmarksByTag(tag, limit, 0) 137 + collectionItems = []db.CollectionItem{} 138 + } 139 + } else if creator != "" { 140 + annotations, _ = h.db.GetAnnotationsByAuthor(creator, limit, 0) 141 + highlights, _ = h.db.GetHighlightsByAuthor(creator, limit, 0) 142 + bookmarks, _ = h.db.GetBookmarksByAuthor(creator, limit, 0) 143 + collectionItems = []db.CollectionItem{} 144 + } else { 145 + annotations, _ = h.db.GetRecentAnnotations(limit, 0) 146 + highlights, _ = h.db.GetRecentHighlights(limit, 0) 147 + bookmarks, _ = h.db.GetRecentBookmarks(limit, 0) 148 + collectionItems, err = h.db.GetRecentCollectionItems(limit, 0) 149 + if err != nil { 150 + log.Printf("Error fetching collection items: %v\n", err) 151 + } 127 152 } 128 - // log.Printf("Fetched %d collection items\n", len(collectionItems)) 129 - authCollectionItems, _ := hydrateCollectionItems(h.db, collectionItems) 130 - // log.Printf("Hydrated %d collection items\n", len(authCollectionItems)) 153 + 154 + viewerDID := h.getViewerDID(r) 155 + authAnnos, _ := hydrateAnnotations(h.db, annotations, viewerDID) 156 + authHighs, _ := hydrateHighlights(h.db, highlights, viewerDID) 157 + authBooks, _ := hydrateBookmarks(h.db, bookmarks, viewerDID) 158 + 159 + authCollectionItems, _ := hydrateCollectionItems(h.db, collectionItems, viewerDID) 131 160 132 161 var feed []interface{} 133 162 for _, a := range authAnnos { ··· 188 217 return 189 218 } 190 219 191 - annotation, err := h.db.GetAnnotationByURI(uri) 192 - if err != nil { 193 - http.Error(w, "Annotation not found", http.StatusNotFound) 194 - return 220 + serveResponse := func(data interface{}, context string) { 221 + w.Header().Set("Content-Type", "application/json") 222 + response := map[string]interface{}{ 223 + "@context": context, 224 + } 225 + jsonData, _ := json.Marshal(data) 226 + json.Unmarshal(jsonData, &response) 227 + json.NewEncoder(w).Encode(response) 228 + } 229 + 230 + if annotation, err := h.db.GetAnnotationByURI(uri); err == nil { 231 + if enriched, _ := hydrateAnnotations(h.db, []db.Annotation{*annotation}, h.getViewerDID(r)); len(enriched) > 0 { 232 + serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld") 233 + return 234 + } 235 + } 236 + 237 + if highlight, err := h.db.GetHighlightByURI(uri); err == nil { 238 + if enriched, _ := hydrateHighlights(h.db, []db.Highlight{*highlight}, h.getViewerDID(r)); len(enriched) > 0 { 239 + serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld") 240 + return 241 + } 242 + } 243 + 244 + if strings.Contains(uri, "at.margin.annotation") { 245 + highlightURI := strings.Replace(uri, "at.margin.annotation", "at.margin.highlight", 1) 246 + if highlight, err := h.db.GetHighlightByURI(highlightURI); err == nil { 247 + if enriched, _ := hydrateHighlights(h.db, []db.Highlight{*highlight}, h.getViewerDID(r)); len(enriched) > 0 { 248 + serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld") 249 + return 250 + } 251 + } 195 252 } 196 253 197 - enriched, _ := hydrateAnnotations([]db.Annotation{*annotation}) 198 - if len(enriched) == 0 { 199 - http.Error(w, "Annotation not found", http.StatusNotFound) 200 - return 254 + if bookmark, err := h.db.GetBookmarkByURI(uri); err == nil { 255 + if enriched, _ := hydrateBookmarks(h.db, []db.Bookmark{*bookmark}, h.getViewerDID(r)); len(enriched) > 0 { 256 + serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld") 257 + return 258 + } 201 259 } 202 260 203 - w.Header().Set("Content-Type", "application/json") 204 - response := map[string]interface{}{ 205 - "@context": "http://www.w3.org/ns/anno.jsonld", 261 + if strings.Contains(uri, "at.margin.annotation") { 262 + bookmarkURI := strings.Replace(uri, "at.margin.annotation", "at.margin.bookmark", 1) 263 + if bookmark, err := h.db.GetBookmarkByURI(bookmarkURI); err == nil { 264 + if enriched, _ := hydrateBookmarks(h.db, []db.Bookmark{*bookmark}, h.getViewerDID(r)); len(enriched) > 0 { 265 + serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld") 266 + return 267 + } 268 + } 206 269 } 207 - annJSON, _ := json.Marshal(enriched[0]) 208 - json.Unmarshal(annJSON, &response) 270 + 271 + http.Error(w, "Annotation, Highlight, or Bookmark not found", http.StatusNotFound) 209 272 210 - json.NewEncoder(w).Encode(response) 211 273 } 212 274 213 275 func (h *Handler) GetByTarget(w http.ResponseWriter, r *http.Request) { ··· 228 290 annotations, _ := h.db.GetAnnotationsByTargetHash(urlHash, limit, offset) 229 291 highlights, _ := h.db.GetHighlightsByTargetHash(urlHash, limit, offset) 230 292 231 - enrichedAnnotations, _ := hydrateAnnotations(annotations) 232 - enrichedHighlights, _ := hydrateHighlights(highlights) 293 + enrichedAnnotations, _ := hydrateAnnotations(h.db, annotations, h.getViewerDID(r)) 294 + enrichedHighlights, _ := hydrateHighlights(h.db, highlights, h.getViewerDID(r)) 233 295 234 296 w.Header().Set("Content-Type", "application/json") 235 297 json.NewEncoder(w).Encode(map[string]interface{}{ ··· 243 305 244 306 func (h *Handler) GetHighlights(w http.ResponseWriter, r *http.Request) { 245 307 did := r.URL.Query().Get("creator") 308 + tag := r.URL.Query().Get("tag") 246 309 limit := parseIntParam(r, "limit", 50) 247 310 offset := parseIntParam(r, "offset", 0) 248 311 249 - if did == "" { 250 - http.Error(w, "creator parameter required", http.StatusBadRequest) 251 - return 312 + var highlights []db.Highlight 313 + var err error 314 + 315 + if did != "" { 316 + highlights, err = h.db.GetHighlightsByAuthor(did, limit, offset) 317 + } else if tag != "" { 318 + highlights, err = h.db.GetHighlightsByTag(tag, limit, offset) 319 + } else { 320 + highlights, err = h.db.GetRecentHighlights(limit, offset) 252 321 } 253 322 254 - highlights, err := h.db.GetHighlightsByAuthor(did, limit, offset) 255 323 if err != nil { 256 324 http.Error(w, err.Error(), http.StatusInternalServerError) 257 325 return 258 326 } 259 327 260 - enriched, _ := hydrateHighlights(highlights) 328 + enriched, _ := hydrateHighlights(h.db, highlights, h.getViewerDID(r)) 261 329 262 330 w.Header().Set("Content-Type", "application/json") 263 331 json.NewEncoder(w).Encode(map[string]interface{}{ ··· 284 352 return 285 353 } 286 354 287 - enriched, _ := hydrateBookmarks(bookmarks) 355 + enriched, _ := hydrateBookmarks(h.db, bookmarks, h.getViewerDID(r)) 288 356 289 357 w.Header().Set("Content-Type", "application/json") 290 358 json.NewEncoder(w).Encode(map[string]interface{}{ ··· 309 377 return 310 378 } 311 379 312 - enriched, _ := hydrateAnnotations(annotations) 380 + enriched, _ := hydrateAnnotations(h.db, annotations, h.getViewerDID(r)) 313 381 314 382 w.Header().Set("Content-Type", "application/json") 315 383 json.NewEncoder(w).Encode(map[string]interface{}{ ··· 335 403 return 336 404 } 337 405 338 - enriched, _ := hydrateHighlights(highlights) 406 + enriched, _ := hydrateHighlights(h.db, highlights, h.getViewerDID(r)) 339 407 340 408 w.Header().Set("Content-Type", "application/json") 341 409 json.NewEncoder(w).Encode(map[string]interface{}{ ··· 361 429 return 362 430 } 363 431 364 - enriched, _ := hydrateBookmarks(bookmarks) 432 + enriched, _ := hydrateBookmarks(h.db, bookmarks, h.getViewerDID(r)) 365 433 366 434 w.Header().Set("Content-Type", "application/json") 367 435 json.NewEncoder(w).Encode(map[string]interface{}{ ··· 515 583 return 516 584 } 517 585 518 - enriched, err := hydrateNotifications(notifications) 586 + enriched, err := hydrateNotifications(h.db, notifications) 519 587 if err != nil { 520 588 log.Printf("Failed to hydrate notifications: %v\n", err) 521 589 } ··· 560 628 w.Header().Set("Content-Type", "application/json") 561 629 json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) 562 630 } 631 + func (h *Handler) getViewerDID(r *http.Request) string { 632 + cookie, err := r.Cookie("margin_session") 633 + if err != nil { 634 + return "" 635 + } 636 + did, _, _, _, _, err := h.db.GetSession(cookie.Value) 637 + if err != nil { 638 + return "" 639 + } 640 + return did 641 + }
+131 -50
backend/internal/api/hydration.go
··· 50 50 } 51 51 52 52 type APIAnnotation struct { 53 - ID string `json:"id"` 54 - CID string `json:"cid"` 55 - Type string `json:"type"` 56 - Motivation string `json:"motivation,omitempty"` 57 - Author Author `json:"creator"` 58 - Body *APIBody `json:"body,omitempty"` 59 - Target APITarget `json:"target"` 60 - Tags []string `json:"tags,omitempty"` 61 - Generator *APIGenerator `json:"generator,omitempty"` 62 - CreatedAt time.Time `json:"created"` 63 - IndexedAt time.Time `json:"indexed"` 53 + ID string `json:"id"` 54 + CID string `json:"cid"` 55 + Type string `json:"type"` 56 + Motivation string `json:"motivation,omitempty"` 57 + Author Author `json:"creator"` 58 + Body *APIBody `json:"body,omitempty"` 59 + Target APITarget `json:"target"` 60 + Tags []string `json:"tags,omitempty"` 61 + Generator *APIGenerator `json:"generator,omitempty"` 62 + CreatedAt time.Time `json:"created"` 63 + IndexedAt time.Time `json:"indexed"` 64 + LikeCount int `json:"likeCount"` 65 + ReplyCount int `json:"replyCount"` 66 + ViewerHasLiked bool `json:"viewerHasLiked"` 64 67 } 65 68 66 69 type APIHighlight struct { 67 - ID string `json:"id"` 68 - Type string `json:"type"` 69 - Author Author `json:"creator"` 70 - Target APITarget `json:"target"` 71 - Color string `json:"color,omitempty"` 72 - Tags []string `json:"tags,omitempty"` 73 - CreatedAt time.Time `json:"created"` 74 - CID string `json:"cid,omitempty"` 70 + ID string `json:"id"` 71 + Type string `json:"type"` 72 + Author Author `json:"creator"` 73 + Target APITarget `json:"target"` 74 + Color string `json:"color,omitempty"` 75 + Tags []string `json:"tags,omitempty"` 76 + CreatedAt time.Time `json:"created"` 77 + CID string `json:"cid,omitempty"` 78 + LikeCount int `json:"likeCount"` 79 + ReplyCount int `json:"replyCount"` 80 + ViewerHasLiked bool `json:"viewerHasLiked"` 75 81 } 76 82 77 83 type APIBookmark struct { 78 - ID string `json:"id"` 79 - Type string `json:"type"` 80 - Author Author `json:"creator"` 81 - Source string `json:"source"` 82 - Title string `json:"title,omitempty"` 83 - Description string `json:"description,omitempty"` 84 - Tags []string `json:"tags,omitempty"` 85 - CreatedAt time.Time `json:"created"` 86 - CID string `json:"cid,omitempty"` 84 + ID string `json:"id"` 85 + Type string `json:"type"` 86 + Author Author `json:"creator"` 87 + Source string `json:"source"` 88 + Title string `json:"title,omitempty"` 89 + Description string `json:"description,omitempty"` 90 + Tags []string `json:"tags,omitempty"` 91 + CreatedAt time.Time `json:"created"` 92 + CID string `json:"cid,omitempty"` 93 + LikeCount int `json:"likeCount"` 94 + ReplyCount int `json:"replyCount"` 95 + ViewerHasLiked bool `json:"viewerHasLiked"` 87 96 } 88 97 89 98 type APIReply struct { ··· 99 108 } 100 109 101 110 type APICollection struct { 102 - URI string `json:"uri"` 103 - Name string `json:"name"` 104 - Icon string `json:"icon,omitempty"` 111 + URI string `json:"uri"` 112 + Name string `json:"name"` 113 + Description string `json:"description,omitempty"` 114 + Icon string `json:"icon,omitempty"` 115 + Creator Author `json:"creator"` 116 + CreatedAt time.Time `json:"createdAt"` 117 + IndexedAt time.Time `json:"indexedAt"` 105 118 } 106 119 107 120 type APICollectionItem struct { ··· 118 131 } 119 132 120 133 type APINotification struct { 121 - ID int `json:"id"` 122 - Recipient Author `json:"recipient"` 123 - Actor Author `json:"actor"` 124 - Type string `json:"type"` 125 - SubjectURI string `json:"subjectUri"` 126 - CreatedAt time.Time `json:"createdAt"` 127 - ReadAt *time.Time `json:"readAt,omitempty"` 134 + ID int `json:"id"` 135 + Recipient Author `json:"recipient"` 136 + Actor Author `json:"actor"` 137 + Type string `json:"type"` 138 + SubjectURI string `json:"subjectUri"` 139 + Subject interface{} `json:"subject,omitempty"` 140 + CreatedAt time.Time `json:"createdAt"` 141 + ReadAt *time.Time `json:"readAt,omitempty"` 128 142 } 129 143 130 - func hydrateAnnotations(annotations []db.Annotation) ([]APIAnnotation, error) { 144 + func hydrateAnnotations(database *db.DB, annotations []db.Annotation, viewerDID string) ([]APIAnnotation, error) { 131 145 if len(annotations) == 0 { 132 146 return []APIAnnotation{}, nil 133 147 } ··· 192 206 CreatedAt: a.CreatedAt, 193 207 IndexedAt: a.IndexedAt, 194 208 } 209 + 210 + if database != nil { 211 + result[i].LikeCount, _ = database.GetLikeCount(a.URI) 212 + result[i].ReplyCount, _ = database.GetReplyCount(a.URI) 213 + if viewerDID != "" { 214 + if _, err := database.GetLikeByUserAndSubject(viewerDID, a.URI); err == nil { 215 + result[i].ViewerHasLiked = true 216 + } 217 + } 218 + } 195 219 } 196 220 197 221 return result, nil 198 222 } 199 223 200 - func hydrateHighlights(highlights []db.Highlight) ([]APIHighlight, error) { 224 + func hydrateHighlights(database *db.DB, highlights []db.Highlight, viewerDID string) ([]APIHighlight, error) { 201 225 if len(highlights) == 0 { 202 226 return []APIHighlight{}, nil 203 227 } ··· 245 269 Tags: tags, 246 270 CreatedAt: h.CreatedAt, 247 271 CID: cid, 272 + } 273 + 274 + if database != nil { 275 + result[i].LikeCount, _ = database.GetLikeCount(h.URI) 276 + result[i].ReplyCount, _ = database.GetReplyCount(h.URI) 277 + if viewerDID != "" { 278 + if _, err := database.GetLikeByUserAndSubject(viewerDID, h.URI); err == nil { 279 + result[i].ViewerHasLiked = true 280 + } 281 + } 248 282 } 249 283 } 250 284 251 285 return result, nil 252 286 } 253 287 254 - func hydrateBookmarks(bookmarks []db.Bookmark) ([]APIBookmark, error) { 288 + func hydrateBookmarks(database *db.DB, bookmarks []db.Bookmark, viewerDID string) ([]APIBookmark, error) { 255 289 if len(bookmarks) == 0 { 256 290 return []APIBookmark{}, nil 257 291 } ··· 290 324 Tags: tags, 291 325 CreatedAt: b.CreatedAt, 292 326 CID: cid, 327 + } 328 + if database != nil { 329 + result[i].LikeCount, _ = database.GetLikeCount(b.URI) 330 + result[i].ReplyCount, _ = database.GetReplyCount(b.URI) 331 + if viewerDID != "" { 332 + if _, err := database.GetLikeByUserAndSubject(viewerDID, b.URI); err == nil { 333 + result[i].ViewerHasLiked = true 334 + } 335 + } 293 336 } 294 337 } 295 338 ··· 434 477 return result, nil 435 478 } 436 479 437 - func hydrateCollectionItems(database *db.DB, items []db.CollectionItem) ([]APICollectionItem, error) { 480 + func hydrateCollectionItems(database *db.DB, items []db.CollectionItem, viewerDID string) ([]APICollectionItem, error) { 438 481 if len(items) == 0 { 439 482 return []APICollectionItem{}, nil 440 483 } ··· 457 500 if coll.Icon != nil { 458 501 icon = *coll.Icon 459 502 } 503 + desc := "" 504 + if coll.Description != nil { 505 + desc = *coll.Description 506 + } 460 507 apiItem.Collection = &APICollection{ 461 - URI: coll.URI, 462 - Name: coll.Name, 463 - Icon: icon, 508 + URI: coll.URI, 509 + Name: coll.Name, 510 + Description: desc, 511 + Icon: icon, 512 + Creator: profiles[coll.AuthorDID], 513 + CreatedAt: coll.CreatedAt, 514 + IndexedAt: coll.IndexedAt, 464 515 } 465 516 } 466 517 467 518 if strings.Contains(item.AnnotationURI, "at.margin.annotation") { 468 519 if a, err := database.GetAnnotationByURI(item.AnnotationURI); err == nil { 469 - hydrated, _ := hydrateAnnotations([]db.Annotation{*a}) 520 + hydrated, _ := hydrateAnnotations(database, []db.Annotation{*a}, viewerDID) 470 521 if len(hydrated) > 0 { 471 522 apiItem.Annotation = &hydrated[0] 472 523 } 473 524 } 474 525 } else if strings.Contains(item.AnnotationURI, "at.margin.highlight") { 475 526 if h, err := database.GetHighlightByURI(item.AnnotationURI); err == nil { 476 - hydrated, _ := hydrateHighlights([]db.Highlight{*h}) 527 + hydrated, _ := hydrateHighlights(database, []db.Highlight{*h}, viewerDID) 477 528 if len(hydrated) > 0 { 478 529 apiItem.Highlight = &hydrated[0] 479 530 } 480 531 } 481 532 } else if strings.Contains(item.AnnotationURI, "at.margin.bookmark") { 482 533 if b, err := database.GetBookmarkByURI(item.AnnotationURI); err == nil { 483 - hydrated, _ := hydrateBookmarks([]db.Bookmark{*b}) 534 + hydrated, _ := hydrateBookmarks(database, []db.Bookmark{*b}, viewerDID) 484 535 if len(hydrated) > 0 { 485 536 apiItem.Bookmark = &hydrated[0] 486 537 } else { 487 538 log.Printf("Failed to hydrate bookmark %s: empty hydration result\n", item.AnnotationURI) 488 539 } 489 540 } else { 490 - log.Printf("GetBookmarkByURI failed for %s: %v\n", item.AnnotationURI, err) 491 541 } 492 542 } else { 493 543 log.Printf("Unknown item type for URI: %s\n", item.AnnotationURI) ··· 498 548 return result, nil 499 549 } 500 550 501 - func hydrateNotifications(notifications []db.Notification) ([]APINotification, error) { 551 + func hydrateNotifications(database *db.DB, notifications []db.Notification) ([]APINotification, error) { 502 552 if len(notifications) == 0 { 503 553 return []APINotification{}, nil 504 554 } ··· 518 568 519 569 profiles := fetchProfilesForDIDs(dids) 520 570 571 + replyURIs := make([]string, 0) 572 + for _, n := range notifications { 573 + if n.Type == "reply" { 574 + replyURIs = append(replyURIs, n.SubjectURI) 575 + } 576 + } 577 + 578 + replyMap := make(map[string]APIReply) 579 + if len(replyURIs) > 0 { 580 + var replies []db.Reply 581 + for _, uri := range replyURIs { 582 + r, err := database.GetReplyByURI(uri) 583 + if err == nil { 584 + replies = append(replies, *r) 585 + } 586 + } 587 + 588 + hydratedReplies, _ := hydrateReplies(replies) 589 + for _, r := range hydratedReplies { 590 + replyMap[r.ID] = r 591 + } 592 + } 593 + 521 594 result := make([]APINotification, len(notifications)) 522 595 for i, n := range notifications { 596 + var subject interface{} 597 + if n.Type == "reply" { 598 + if val, ok := replyMap[n.SubjectURI]; ok { 599 + subject = val 600 + } 601 + } 602 + 523 603 result[i] = APINotification{ 524 604 ID: n.ID, 525 605 Recipient: profiles[n.RecipientDID], 526 606 Actor: profiles[n.ActorDID], 527 607 Type: n.Type, 528 608 SubjectURI: n.SubjectURI, 609 + Subject: subject, 529 610 CreatedAt: n.CreatedAt, 530 611 ReadAt: n.ReadAt, 531 612 }
+691 -60
backend/internal/api/og.go
··· 15 15 "net/http" 16 16 "net/url" 17 17 "os" 18 - "regexp" 19 18 "strings" 20 19 21 20 "golang.org/x/image/font" ··· 101 100 "Bluesky", 102 101 } 103 102 103 + var lucideToEmoji = map[string]string{ 104 + "folder": "๐Ÿ“", 105 + "star": "โญ", 106 + "heart": "โค๏ธ", 107 + "bookmark": "๐Ÿ”–", 108 + "lightbulb": "๐Ÿ’ก", 109 + "zap": "โšก", 110 + "coffee": "โ˜•", 111 + "music": "๐ŸŽต", 112 + "camera": "๐Ÿ“ท", 113 + "code": "๐Ÿ’ป", 114 + "globe": "๐ŸŒ", 115 + "flag": "๐Ÿšฉ", 116 + "tag": "๐Ÿท๏ธ", 117 + "box": "๐Ÿ“ฆ", 118 + "archive": "๐Ÿ—„๏ธ", 119 + "file": "๐Ÿ“„", 120 + "image": "๐Ÿ–ผ๏ธ", 121 + "video": "๐ŸŽฌ", 122 + "mail": "โœ‰๏ธ", 123 + "pin": "๐Ÿ“", 124 + "calendar": "๐Ÿ“…", 125 + "clock": "๐Ÿ•", 126 + "search": "๐Ÿ”", 127 + "settings": "โš™๏ธ", 128 + "user": "๐Ÿ‘ค", 129 + "users": "๐Ÿ‘ฅ", 130 + "home": "๐Ÿ ", 131 + "briefcase": "๐Ÿ’ผ", 132 + "gift": "๐ŸŽ", 133 + "award": "๐Ÿ†", 134 + "target": "๐ŸŽฏ", 135 + "trending": "๐Ÿ“ˆ", 136 + "activity": "๐Ÿ“Š", 137 + "cpu": "๐Ÿ”ฒ", 138 + "database": "๐Ÿ—ƒ๏ธ", 139 + "cloud": "โ˜๏ธ", 140 + "sun": "โ˜€๏ธ", 141 + "moon": "๐ŸŒ™", 142 + "flame": "๐Ÿ”ฅ", 143 + "leaf": "๐Ÿƒ", 144 + } 145 + 146 + func iconToEmoji(icon string) string { 147 + if strings.HasPrefix(icon, "icon:") { 148 + name := strings.TrimPrefix(icon, "icon:") 149 + if emoji, ok := lucideToEmoji[name]; ok { 150 + return emoji 151 + } 152 + return "๐Ÿ“" 153 + } 154 + return icon 155 + } 156 + 104 157 func isCrawler(userAgent string) bool { 105 158 ua := strings.ToLower(userAgent) 106 159 for _, bot := range crawlerUserAgents { ··· 111 164 return false 112 165 } 113 166 167 + func (h *OGHandler) resolveHandle(handle string) (string, error) { 168 + resp, err := http.Get(fmt.Sprintf("https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=%s", url.QueryEscape(handle))) 169 + if err == nil && resp.StatusCode == http.StatusOK { 170 + var result struct { 171 + Did string `json:"did"` 172 + } 173 + if err := json.NewDecoder(resp.Body).Decode(&result); err == nil && result.Did != "" { 174 + return result.Did, nil 175 + } 176 + } 177 + defer resp.Body.Close() 178 + 179 + return "", fmt.Errorf("failed to resolve handle") 180 + } 181 + 114 182 func (h *OGHandler) HandleAnnotationPage(w http.ResponseWriter, r *http.Request) { 115 183 path := r.URL.Path 184 + var did, rkey, collectionType string 116 185 117 - var annotationMatch = regexp.MustCompile(`^/at/([^/]+)/([^/]+)$`) 118 - matches := annotationMatch.FindStringSubmatch(path) 186 + parts := strings.Split(strings.Trim(path, "/"), "/") 187 + if len(parts) >= 2 { 188 + firstPart, _ := url.QueryUnescape(parts[0]) 189 + 190 + if firstPart == "at" || firstPart == "annotation" { 191 + if len(parts) >= 3 { 192 + did, _ = url.QueryUnescape(parts[1]) 193 + rkey = parts[2] 194 + } 195 + } else { 196 + if len(parts) >= 3 { 197 + var err error 198 + did, err = h.resolveHandle(firstPart) 199 + if err != nil { 200 + h.serveIndexHTML(w, r) 201 + return 202 + } 119 203 120 - if len(matches) != 3 { 204 + switch parts[1] { 205 + case "highlight": 206 + collectionType = "at.margin.highlight" 207 + case "bookmark": 208 + collectionType = "at.margin.bookmark" 209 + case "annotation": 210 + collectionType = "at.margin.annotation" 211 + } 212 + rkey = parts[2] 213 + } 214 + } 215 + } 216 + 217 + if did == "" || rkey == "" { 121 218 h.serveIndexHTML(w, r) 122 219 return 123 220 } 124 221 125 - did, _ := url.QueryUnescape(matches[1]) 126 - rkey := matches[2] 127 - 128 222 if !isCrawler(r.UserAgent()) { 129 223 h.serveIndexHTML(w, r) 130 224 return 131 225 } 132 226 133 - uri := fmt.Sprintf("at://%s/at.margin.annotation/%s", did, rkey) 134 - annotation, err := h.db.GetAnnotationByURI(uri) 135 - if err == nil && annotation != nil { 136 - h.serveAnnotationOG(w, annotation) 137 - return 227 + if collectionType != "" { 228 + uri := fmt.Sprintf("at://%s/%s/%s", did, collectionType, rkey) 229 + if h.tryServeType(w, uri, collectionType) { 230 + return 231 + } 232 + } else { 233 + types := []string{ 234 + "at.margin.annotation", 235 + "at.margin.bookmark", 236 + "at.margin.highlight", 237 + } 238 + for _, t := range types { 239 + uri := fmt.Sprintf("at://%s/%s/%s", did, t, rkey) 240 + if h.tryServeType(w, uri, t) { 241 + return 242 + } 243 + } 244 + 245 + colURI := fmt.Sprintf("at://%s/at.margin.collection/%s", did, rkey) 246 + if h.tryServeType(w, colURI, "at.margin.collection") { 247 + return 248 + } 138 249 } 139 250 140 - bookmarkURI := fmt.Sprintf("at://%s/at.margin.bookmark/%s", did, rkey) 141 - bookmark, err := h.db.GetBookmarkByURI(bookmarkURI) 142 - if err == nil && bookmark != nil { 143 - h.serveBookmarkOG(w, bookmark) 251 + h.serveIndexHTML(w, r) 252 + } 253 + 254 + func (h *OGHandler) tryServeType(w http.ResponseWriter, uri, colType string) bool { 255 + switch colType { 256 + case "at.margin.annotation": 257 + if item, err := h.db.GetAnnotationByURI(uri); err == nil && item != nil { 258 + h.serveAnnotationOG(w, item) 259 + return true 260 + } 261 + case "at.margin.highlight": 262 + if item, err := h.db.GetHighlightByURI(uri); err == nil && item != nil { 263 + h.serveHighlightOG(w, item) 264 + return true 265 + } 266 + case "at.margin.bookmark": 267 + if item, err := h.db.GetBookmarkByURI(uri); err == nil && item != nil { 268 + h.serveBookmarkOG(w, item) 269 + return true 270 + } 271 + case "at.margin.collection": 272 + if item, err := h.db.GetCollectionByURI(uri); err == nil && item != nil { 273 + h.serveCollectionOG(w, item) 274 + return true 275 + } 276 + } 277 + return false 278 + } 279 + 280 + func (h *OGHandler) HandleCollectionPage(w http.ResponseWriter, r *http.Request) { 281 + path := r.URL.Path 282 + var did, rkey string 283 + 284 + if strings.Contains(path, "/collection/") { 285 + parts := strings.Split(strings.Trim(path, "/"), "/") 286 + if len(parts) == 3 && parts[1] == "collection" { 287 + handle, _ := url.QueryUnescape(parts[0]) 288 + rkey = parts[2] 289 + var err error 290 + did, err = h.resolveHandle(handle) 291 + if err != nil { 292 + h.serveIndexHTML(w, r) 293 + return 294 + } 295 + } else if strings.HasPrefix(path, "/collection/") { 296 + uriParam := strings.TrimPrefix(path, "/collection/") 297 + if uriParam != "" { 298 + uri, err := url.QueryUnescape(uriParam) 299 + if err == nil { 300 + parts := strings.Split(uri, "/") 301 + if len(parts) >= 3 && strings.HasPrefix(uri, "at://") { 302 + did = parts[2] 303 + rkey = parts[len(parts)-1] 304 + } 305 + } 306 + } 307 + } 308 + } 309 + 310 + if did == "" && rkey == "" { 311 + h.serveIndexHTML(w, r) 144 312 return 313 + } else if did != "" && rkey != "" { 314 + uri := fmt.Sprintf("at://%s/at.margin.collection/%s", did, rkey) 315 + 316 + if !isCrawler(r.UserAgent()) { 317 + h.serveIndexHTML(w, r) 318 + return 319 + } 320 + 321 + collection, err := h.db.GetCollectionByURI(uri) 322 + if err == nil && collection != nil { 323 + h.serveCollectionOG(w, collection) 324 + return 325 + } 145 326 } 146 327 147 328 h.serveIndexHTML(w, r) ··· 232 413 w.Write([]byte(htmlContent)) 233 414 } 234 415 416 + func (h *OGHandler) serveHighlightOG(w http.ResponseWriter, highlight *db.Highlight) { 417 + title := "Highlight on Margin" 418 + description := "" 419 + 420 + if highlight.SelectorJSON != nil && *highlight.SelectorJSON != "" { 421 + var selector struct { 422 + Exact string `json:"exact"` 423 + } 424 + if err := json.Unmarshal([]byte(*highlight.SelectorJSON), &selector); err == nil && selector.Exact != "" { 425 + description = fmt.Sprintf("\"%s\"", selector.Exact) 426 + if len(description) > 200 { 427 + description = description[:197] + "...\"" 428 + } 429 + } 430 + } 431 + 432 + if highlight.TargetTitle != nil && *highlight.TargetTitle != "" { 433 + title = fmt.Sprintf("Highlight on: %s", *highlight.TargetTitle) 434 + if len(title) > 60 { 435 + title = title[:57] + "..." 436 + } 437 + } 438 + 439 + sourceDomain := "" 440 + if highlight.TargetSource != "" { 441 + if parsed, err := url.Parse(highlight.TargetSource); err == nil { 442 + sourceDomain = parsed.Host 443 + } 444 + } 445 + 446 + authorHandle := highlight.AuthorDID 447 + profiles := fetchProfilesForDIDs([]string{highlight.AuthorDID}) 448 + if profile, ok := profiles[highlight.AuthorDID]; ok && profile.Handle != "" { 449 + authorHandle = "@" + profile.Handle 450 + } 451 + 452 + if description == "" { 453 + description = fmt.Sprintf("A highlight by %s", authorHandle) 454 + if sourceDomain != "" { 455 + description += fmt.Sprintf(" on %s", sourceDomain) 456 + } 457 + } 458 + 459 + pageURL := fmt.Sprintf("%s/at/%s", h.baseURL, url.PathEscape(highlight.URI[5:])) 460 + ogImageURL := fmt.Sprintf("%s/og-image?uri=%s", h.baseURL, url.QueryEscape(highlight.URI)) 461 + 462 + htmlContent := fmt.Sprintf(`<!DOCTYPE html> 463 + <html lang="en"> 464 + <head> 465 + <meta charset="UTF-8"> 466 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 467 + <title>%s - Margin</title> 468 + <meta name="description" content="%s"> 469 + 470 + <!-- Open Graph --> 471 + <meta property="og:type" content="article"> 472 + <meta property="og:title" content="%s"> 473 + <meta property="og:description" content="%s"> 474 + <meta property="og:url" content="%s"> 475 + <meta property="og:image" content="%s"> 476 + <meta property="og:image:width" content="1200"> 477 + <meta property="og:image:height" content="630"> 478 + <meta property="og:site_name" content="Margin"> 479 + 480 + <!-- Twitter Card --> 481 + <meta name="twitter:card" content="summary_large_image"> 482 + <meta name="twitter:title" content="%s"> 483 + <meta name="twitter:description" content="%s"> 484 + <meta name="twitter:image" content="%s"> 485 + 486 + <!-- Author --> 487 + <meta property="article:author" content="%s"> 488 + 489 + <meta http-equiv="refresh" content="0; url=%s"> 490 + </head> 491 + <body> 492 + <p>Redirecting to <a href="%s">%s</a>...</p> 493 + </body> 494 + </html>`, 495 + html.EscapeString(title), 496 + html.EscapeString(description), 497 + html.EscapeString(title), 498 + html.EscapeString(description), 499 + html.EscapeString(pageURL), 500 + html.EscapeString(ogImageURL), 501 + html.EscapeString(title), 502 + html.EscapeString(description), 503 + html.EscapeString(ogImageURL), 504 + html.EscapeString(authorHandle), 505 + html.EscapeString(pageURL), 506 + html.EscapeString(pageURL), 507 + html.EscapeString(title), 508 + ) 509 + 510 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 511 + w.Write([]byte(htmlContent)) 512 + } 513 + 514 + func (h *OGHandler) serveCollectionOG(w http.ResponseWriter, collection *db.Collection) { 515 + icon := "๐Ÿ“" 516 + if collection.Icon != nil && *collection.Icon != "" { 517 + icon = iconToEmoji(*collection.Icon) 518 + } 519 + 520 + title := fmt.Sprintf("%s %s", icon, collection.Name) 521 + description := "" 522 + if collection.Description != nil && *collection.Description != "" { 523 + description = *collection.Description 524 + if len(description) > 200 { 525 + description = description[:197] + "..." 526 + } 527 + } 528 + 529 + authorHandle := collection.AuthorDID 530 + var avatarURL string 531 + profiles := fetchProfilesForDIDs([]string{collection.AuthorDID}) 532 + if profile, ok := profiles[collection.AuthorDID]; ok { 533 + if profile.Handle != "" { 534 + authorHandle = "@" + profile.Handle 535 + } 536 + if profile.Avatar != "" { 537 + avatarURL = profile.Avatar 538 + } 539 + } 540 + 541 + if description == "" { 542 + description = fmt.Sprintf("A collection by %s", authorHandle) 543 + } else { 544 + description = fmt.Sprintf("By %s โ€ข %s", authorHandle, description) 545 + } 546 + 547 + pageURL := fmt.Sprintf("%s/collection/%s", h.baseURL, url.PathEscape(collection.URI)) 548 + ogImageURL := fmt.Sprintf("%s/og-image?uri=%s", h.baseURL, url.QueryEscape(collection.URI)) 549 + 550 + _ = avatarURL 551 + 552 + htmlContent := fmt.Sprintf(`<!DOCTYPE html> 553 + <html lang="en"> 554 + <head> 555 + <meta charset="UTF-8"> 556 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 557 + <title>%s - Margin</title> 558 + <meta name="description" content="%s"> 559 + 560 + <!-- Open Graph --> 561 + <meta property="og:type" content="article"> 562 + <meta property="og:title" content="%s"> 563 + <meta property="og:description" content="%s"> 564 + <meta property="og:url" content="%s"> 565 + <meta property="og:image" content="%s"> 566 + <meta property="og:image:width" content="1200"> 567 + <meta property="og:image:height" content="630"> 568 + <meta property="og:site_name" content="Margin"> 569 + 570 + <!-- Twitter Card --> 571 + <meta name="twitter:card" content="summary_large_image"> 572 + <meta name="twitter:title" content="%s"> 573 + <meta name="twitter:description" content="%s"> 574 + <meta name="twitter:image" content="%s"> 575 + 576 + <!-- Author --> 577 + <meta property="article:author" content="%s"> 578 + 579 + <meta http-equiv="refresh" content="0; url=%s"> 580 + </head> 581 + <body> 582 + <p>Redirecting to <a href="%s">%s</a>...</p> 583 + </body> 584 + </html>`, 585 + html.EscapeString(title), 586 + html.EscapeString(description), 587 + html.EscapeString(title), 588 + html.EscapeString(description), 589 + html.EscapeString(pageURL), 590 + html.EscapeString(ogImageURL), 591 + html.EscapeString(title), 592 + html.EscapeString(description), 593 + html.EscapeString(ogImageURL), 594 + html.EscapeString(authorHandle), 595 + html.EscapeString(pageURL), 596 + html.EscapeString(pageURL), 597 + html.EscapeString(title), 598 + ) 599 + 600 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 601 + w.Write([]byte(htmlContent)) 602 + } 603 + 235 604 func (h *OGHandler) serveAnnotationOG(w http.ResponseWriter, annotation *db.Annotation) { 236 605 title := "Annotation on Margin" 237 606 description := "" ··· 417 786 } 418 787 } 419 788 } else { 420 - http.Error(w, "Record not found", http.StatusNotFound) 421 - return 789 + highlight, err := h.db.GetHighlightByURI(uri) 790 + if err == nil && highlight != nil { 791 + authorHandle = highlight.AuthorDID 792 + profiles := fetchProfilesForDIDs([]string{highlight.AuthorDID}) 793 + if profile, ok := profiles[highlight.AuthorDID]; ok { 794 + if profile.Handle != "" { 795 + authorHandle = "@" + profile.Handle 796 + } 797 + if profile.Avatar != "" { 798 + avatarURL = profile.Avatar 799 + } 800 + } 801 + 802 + targetTitle := "" 803 + if highlight.TargetTitle != nil { 804 + targetTitle = *highlight.TargetTitle 805 + } 806 + 807 + if highlight.SelectorJSON != nil && *highlight.SelectorJSON != "" { 808 + var selector struct { 809 + Exact string `json:"exact"` 810 + } 811 + if err := json.Unmarshal([]byte(*highlight.SelectorJSON), &selector); err == nil && selector.Exact != "" { 812 + quote = selector.Exact 813 + } 814 + } 815 + 816 + if highlight.TargetSource != "" { 817 + if parsed, err := url.Parse(highlight.TargetSource); err == nil { 818 + sourceDomain = parsed.Host 819 + } 820 + } 821 + 822 + img := generateHighlightOGImagePNG(authorHandle, targetTitle, quote, sourceDomain, avatarURL) 823 + 824 + w.Header().Set("Content-Type", "image/png") 825 + w.Header().Set("Cache-Control", "public, max-age=86400") 826 + png.Encode(w, img) 827 + return 828 + } else { 829 + collection, err := h.db.GetCollectionByURI(uri) 830 + if err == nil && collection != nil { 831 + authorHandle = collection.AuthorDID 832 + profiles := fetchProfilesForDIDs([]string{collection.AuthorDID}) 833 + if profile, ok := profiles[collection.AuthorDID]; ok { 834 + if profile.Handle != "" { 835 + authorHandle = "@" + profile.Handle 836 + } 837 + if profile.Avatar != "" { 838 + avatarURL = profile.Avatar 839 + } 840 + } 841 + 842 + icon := "๐Ÿ“" 843 + if collection.Icon != nil && *collection.Icon != "" { 844 + icon = iconToEmoji(*collection.Icon) 845 + } 846 + 847 + description := "" 848 + if collection.Description != nil && *collection.Description != "" { 849 + description = *collection.Description 850 + } 851 + 852 + img := generateCollectionOGImagePNG(authorHandle, collection.Name, description, icon, avatarURL) 853 + 854 + w.Header().Set("Content-Type", "image/png") 855 + w.Header().Set("Cache-Control", "public, max-age=86400") 856 + png.Encode(w, img) 857 + return 858 + } else { 859 + http.Error(w, "Record not found", http.StatusNotFound) 860 + return 861 + } 862 + } 422 863 } 423 864 } 424 865 ··· 432 873 func generateOGImagePNG(author, text, quote, source, avatarURL string) image.Image { 433 874 width := 1200 434 875 height := 630 435 - padding := 120 876 + padding := 100 436 877 437 878 bgPrimary := color.RGBA{12, 10, 20, 255} 438 879 accent := color.RGBA{168, 85, 247, 255} 439 880 textPrimary := color.RGBA{244, 240, 255, 255} 440 881 textSecondary := color.RGBA{168, 158, 200, 255} 441 - textTertiary := color.RGBA{107, 95, 138, 255} 442 882 border := color.RGBA{45, 38, 64, 255} 443 883 444 884 img := image.NewRGBA(image.Rect(0, 0, width, height)) 445 885 446 886 draw.Draw(img, img.Bounds(), &image.Uniform{bgPrimary}, image.Point{}, draw.Src) 447 - draw.Draw(img, image.Rect(0, 0, width, 6), &image.Uniform{accent}, image.Point{}, draw.Src) 448 - 449 - if logoImage != nil { 450 - logoHeight := 50 451 - logoWidth := int(float64(logoImage.Bounds().Dx()) * (float64(logoHeight) / float64(logoImage.Bounds().Dy()))) 452 - drawScaledImage(img, logoImage, padding, 80, logoWidth, logoHeight) 453 - } else { 454 - drawText(img, "Margin", padding, 120, accent, 36, true) 455 - } 887 + draw.Draw(img, image.Rect(0, 0, width, 12), &image.Uniform{accent}, image.Point{}, draw.Src) 456 888 457 - avatarSize := 80 889 + avatarSize := 64 458 890 avatarX := padding 459 - avatarY := 180 891 + avatarY := padding 892 + 460 893 avatarImg := fetchAvatarImage(avatarURL) 461 894 if avatarImg != nil { 462 895 drawCircularAvatar(img, avatarImg, avatarX, avatarY, avatarSize) 463 896 } else { 464 897 drawDefaultAvatar(img, author, avatarX, avatarY, avatarSize, accent) 465 898 } 466 - 467 - handleX := avatarX + avatarSize + 24 468 - drawText(img, author, handleX, avatarY+50, textSecondary, 24, false) 469 - 470 - yPos := 280 471 - draw.Draw(img, image.Rect(padding, yPos, width-padding, yPos+1), &image.Uniform{border}, image.Point{}, draw.Src) 472 - yPos += 40 899 + drawText(img, author, avatarX+avatarSize+24, avatarY+42, textSecondary, 28, false) 473 900 474 901 contentWidth := width - (padding * 2) 902 + yPos := 220 475 903 476 - if quote != "" { 477 - if len(quote) > 100 { 478 - quote = quote[:97] + "..." 479 - } 904 + if text != "" { 905 + textLen := len(text) 906 + textSize := 32.0 907 + textLineHeight := 42 908 + maxTextLines := 5 480 909 481 - lines := wrapTextToWidth(quote, contentWidth-30, 24) 482 - numLines := min(len(lines), 2) 483 - barHeight := numLines*32 + 10 910 + if textLen > 200 { 911 + textSize = 28.0 912 + textLineHeight = 36 913 + maxTextLines = 6 914 + } 484 915 485 - draw.Draw(img, image.Rect(padding, yPos, padding+6, yPos+barHeight), &image.Uniform{accent}, image.Point{}, draw.Src) 916 + lines := wrapTextToWidth(text, contentWidth, int(textSize)) 917 + numLines := min(len(lines), maxTextLines) 486 918 487 - for i, line := range lines { 488 - if i >= 2 { 489 - break 919 + for i := 0; i < numLines; i++ { 920 + line := lines[i] 921 + if i == numLines-1 && len(lines) > numLines { 922 + line += "..." 490 923 } 491 - drawText(img, "\""+line+"\"", padding+24, yPos+28+(i*32), textTertiary, 24, true) 924 + drawText(img, line, padding, yPos+(i*textLineHeight), textPrimary, textSize, false) 492 925 } 493 - yPos += 30 + (numLines * 32) + 30 926 + yPos += (numLines * textLineHeight) + 40 494 927 } 495 928 496 - if text != "" { 497 - if len(text) > 300 { 498 - text = text[:297] + "..." 929 + if quote != "" { 930 + quoteLen := len(quote) 931 + quoteSize := 24.0 932 + quoteLineHeight := 32 933 + maxQuoteLines := 3 934 + 935 + if quoteLen > 150 { 936 + quoteSize = 20.0 937 + quoteLineHeight = 28 938 + maxQuoteLines = 4 499 939 } 500 - lines := wrapTextToWidth(text, contentWidth, 32) 501 - for i, line := range lines { 502 - if i >= 6 { 503 - break 940 + 941 + lines := wrapTextToWidth(quote, contentWidth-30, int(quoteSize)) 942 + numLines := min(len(lines), maxQuoteLines) 943 + barHeight := numLines * quoteLineHeight 944 + 945 + draw.Draw(img, image.Rect(padding, yPos, padding+6, yPos+barHeight), &image.Uniform{accent}, image.Point{}, draw.Src) 946 + 947 + for i := 0; i < numLines; i++ { 948 + line := lines[i] 949 + if i == numLines-1 && len(lines) > numLines { 950 + line += "..." 504 951 } 505 - drawText(img, line, padding, yPos+(i*42), textPrimary, 32, false) 952 + drawText(img, line, padding+24, yPos+24+(i*quoteLineHeight), textSecondary, quoteSize, true) 506 953 } 954 + yPos += barHeight + 40 507 955 } 508 956 509 - drawText(img, source, padding, 580, textTertiary, 20, false) 957 + draw.Draw(img, image.Rect(padding, yPos, width-padding, yPos+1), &image.Uniform{border}, image.Point{}, draw.Src) 958 + yPos += 40 959 + drawText(img, source, padding, yPos+32, textSecondary, 24, false) 510 960 511 961 return img 512 962 } ··· 662 1112 } 663 1113 return lines 664 1114 } 1115 + 1116 + func generateCollectionOGImagePNG(author, collectionName, description, icon, avatarURL string) image.Image { 1117 + width := 1200 1118 + height := 630 1119 + padding := 120 1120 + 1121 + bgPrimary := color.RGBA{12, 10, 20, 255} 1122 + accent := color.RGBA{168, 85, 247, 255} 1123 + textPrimary := color.RGBA{244, 240, 255, 255} 1124 + textSecondary := color.RGBA{168, 158, 200, 255} 1125 + textTertiary := color.RGBA{107, 95, 138, 255} 1126 + border := color.RGBA{45, 38, 64, 255} 1127 + 1128 + img := image.NewRGBA(image.Rect(0, 0, width, height)) 1129 + 1130 + draw.Draw(img, img.Bounds(), &image.Uniform{bgPrimary}, image.Point{}, draw.Src) 1131 + draw.Draw(img, image.Rect(0, 0, width, 12), &image.Uniform{accent}, image.Point{}, draw.Src) 1132 + 1133 + iconY := 120 1134 + var iconWidth int 1135 + if icon != "" { 1136 + emojiImg := fetchTwemojiImage(icon) 1137 + if emojiImg != nil { 1138 + iconSize := 96 1139 + drawScaledImage(img, emojiImg, padding, iconY, iconSize, iconSize) 1140 + iconWidth = iconSize + 32 1141 + } else { 1142 + drawText(img, icon, padding, iconY+70, textPrimary, 80, true) 1143 + iconWidth = 100 1144 + } 1145 + } 1146 + 1147 + drawText(img, collectionName, padding+iconWidth, iconY+65, textPrimary, 64, true) 1148 + 1149 + yPos := 280 1150 + contentWidth := width - (padding * 2) 1151 + 1152 + if description != "" { 1153 + if len(description) > 200 { 1154 + description = description[:197] + "..." 1155 + } 1156 + lines := wrapTextToWidth(description, contentWidth, 32) 1157 + for i, line := range lines { 1158 + if i >= 4 { 1159 + break 1160 + } 1161 + drawText(img, line, padding, yPos+(i*42), textSecondary, 32, false) 1162 + } 1163 + } else { 1164 + drawText(img, "A collection on Margin", padding, yPos, textTertiary, 32, false) 1165 + } 1166 + 1167 + yPos = 480 1168 + draw.Draw(img, image.Rect(padding, yPos, width-padding, yPos+1), &image.Uniform{border}, image.Point{}, draw.Src) 1169 + 1170 + avatarSize := 64 1171 + avatarX := padding 1172 + avatarY := yPos + 40 1173 + 1174 + avatarImg := fetchAvatarImage(avatarURL) 1175 + if avatarImg != nil { 1176 + drawCircularAvatar(img, avatarImg, avatarX, avatarY, avatarSize) 1177 + } else { 1178 + drawDefaultAvatar(img, author, avatarX, avatarY, avatarSize, accent) 1179 + } 1180 + 1181 + handleX := avatarX + avatarSize + 24 1182 + drawText(img, author, handleX, avatarY+42, textTertiary, 28, false) 1183 + 1184 + return img 1185 + } 1186 + 1187 + func fetchTwemojiImage(emoji string) image.Image { 1188 + var codes []string 1189 + for _, r := range emoji { 1190 + codes = append(codes, fmt.Sprintf("%x", r)) 1191 + } 1192 + hexCode := strings.Join(codes, "-") 1193 + 1194 + url := fmt.Sprintf("https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/72x72/%s.png", hexCode) 1195 + 1196 + resp, err := http.Get(url) 1197 + if err != nil || resp.StatusCode != 200 { 1198 + if strings.Contains(hexCode, "-fe0f") { 1199 + simpleHex := strings.ReplaceAll(hexCode, "-fe0f", "") 1200 + url = fmt.Sprintf("https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/72x72/%s.png", simpleHex) 1201 + resp, err = http.Get(url) 1202 + if err != nil || resp.StatusCode != 200 { 1203 + return nil 1204 + } 1205 + } else { 1206 + return nil 1207 + } 1208 + } 1209 + defer resp.Body.Close() 1210 + 1211 + img, _, err := image.Decode(resp.Body) 1212 + if err != nil { 1213 + return nil 1214 + } 1215 + return img 1216 + } 1217 + 1218 + func generateHighlightOGImagePNG(author, pageTitle, quote, source, avatarURL string) image.Image { 1219 + width := 1200 1220 + height := 630 1221 + padding := 100 1222 + 1223 + bgPrimary := color.RGBA{12, 10, 20, 255} 1224 + accent := color.RGBA{250, 204, 21, 255} 1225 + textPrimary := color.RGBA{244, 240, 255, 255} 1226 + textSecondary := color.RGBA{168, 158, 200, 255} 1227 + border := color.RGBA{45, 38, 64, 255} 1228 + 1229 + img := image.NewRGBA(image.Rect(0, 0, width, height)) 1230 + 1231 + draw.Draw(img, img.Bounds(), &image.Uniform{bgPrimary}, image.Point{}, draw.Src) 1232 + draw.Draw(img, image.Rect(0, 0, width, 12), &image.Uniform{accent}, image.Point{}, draw.Src) 1233 + 1234 + avatarSize := 64 1235 + avatarX := padding 1236 + avatarY := padding 1237 + 1238 + avatarImg := fetchAvatarImage(avatarURL) 1239 + if avatarImg != nil { 1240 + drawCircularAvatar(img, avatarImg, avatarX, avatarY, avatarSize) 1241 + } else { 1242 + drawDefaultAvatar(img, author, avatarX, avatarY, avatarSize, accent) 1243 + } 1244 + drawText(img, author, avatarX+avatarSize+24, avatarY+42, textSecondary, 28, false) 1245 + 1246 + contentWidth := width - (padding * 2) 1247 + yPos := 220 1248 + if quote != "" { 1249 + quoteLen := len(quote) 1250 + fontSize := 42.0 1251 + lineHeight := 56 1252 + maxLines := 4 1253 + 1254 + if quoteLen > 200 { 1255 + fontSize = 32.0 1256 + lineHeight = 44 1257 + maxLines = 6 1258 + } else if quoteLen > 100 { 1259 + fontSize = 36.0 1260 + lineHeight = 48 1261 + maxLines = 5 1262 + } 1263 + 1264 + lines := wrapTextToWidth(quote, contentWidth-40, int(fontSize)) 1265 + numLines := min(len(lines), maxLines) 1266 + barHeight := numLines * lineHeight 1267 + 1268 + draw.Draw(img, image.Rect(padding, yPos, padding+8, yPos+barHeight), &image.Uniform{accent}, image.Point{}, draw.Src) 1269 + 1270 + for i := 0; i < numLines; i++ { 1271 + line := lines[i] 1272 + if i == numLines-1 && len(lines) > numLines { 1273 + line += "..." 1274 + } 1275 + drawText(img, line, padding+40, yPos+42+(i*lineHeight), textPrimary, fontSize, false) 1276 + } 1277 + yPos += barHeight + 40 1278 + } 1279 + 1280 + draw.Draw(img, image.Rect(padding, yPos, width-padding, yPos+1), &image.Uniform{border}, image.Point{}, draw.Src) 1281 + yPos += 40 1282 + 1283 + if pageTitle != "" { 1284 + if len(pageTitle) > 60 { 1285 + pageTitle = pageTitle[:57] + "..." 1286 + } 1287 + drawText(img, pageTitle, padding, yPos+32, textSecondary, 32, true) 1288 + } 1289 + 1290 + if source != "" { 1291 + drawText(img, source, padding, yPos+80, textSecondary, 24, false) 1292 + } 1293 + 1294 + return img 1295 + }
+25
backend/internal/api/tags.go
··· 1 + package api 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + "strconv" 7 + ) 8 + 9 + func (h *Handler) HandleGetTrendingTags(w http.ResponseWriter, r *http.Request) { 10 + limit := 10 11 + if l := r.URL.Query().Get("limit"); l != "" { 12 + if val, err := strconv.Atoi(l); err == nil && val > 0 && val <= 50 { 13 + limit = val 14 + } 15 + } 16 + 17 + tags, err := h.db.GetTrendingTags(limit) 18 + if err != nil { 19 + http.Error(w, `{"error": "Failed to fetch trending tags: `+err.Error()+`"}`, http.StatusInternalServerError) 20 + return 21 + } 22 + 23 + w.Header().Set("Content-Type", "application/json") 24 + json.NewEncoder(w).Encode(tags) 25 + }
+143
backend/internal/db/queries.go
··· 104 104 return scanAnnotations(rows) 105 105 } 106 106 107 + func (db *DB) GetAnnotationsByTag(tag string, limit, offset int) ([]Annotation, error) { 108 + pattern := "%\"" + tag + "\"%" 109 + rows, err := db.Query(db.Rebind(` 110 + SELECT uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid 111 + FROM annotations 112 + WHERE tags_json LIKE ? 113 + ORDER BY created_at DESC 114 + LIMIT ? OFFSET ? 115 + `), pattern, limit, offset) 116 + if err != nil { 117 + return nil, err 118 + } 119 + defer rows.Close() 120 + 121 + return scanAnnotations(rows) 122 + } 123 + 107 124 func (db *DB) DeleteAnnotation(uri string) error { 108 125 _, err := db.Exec(db.Rebind(`DELETE FROM annotations WHERE uri = ?`), uri) 109 126 return err ··· 242 259 return highlights, nil 243 260 } 244 261 262 + func (db *DB) GetHighlightsByTag(tag string, limit, offset int) ([]Highlight, error) { 263 + pattern := "%\"" + tag + "\"%" 264 + rows, err := db.Query(db.Rebind(` 265 + SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid 266 + FROM highlights 267 + WHERE tags_json LIKE ? 268 + ORDER BY created_at DESC 269 + LIMIT ? OFFSET ? 270 + `), pattern, limit, offset) 271 + if err != nil { 272 + return nil, err 273 + } 274 + defer rows.Close() 275 + 276 + var highlights []Highlight 277 + for rows.Next() { 278 + var h Highlight 279 + if err := rows.Scan(&h.URI, &h.AuthorDID, &h.TargetSource, &h.TargetHash, &h.TargetTitle, &h.SelectorJSON, &h.Color, &h.TagsJSON, &h.CreatedAt, &h.IndexedAt, &h.CID); err != nil { 280 + return nil, err 281 + } 282 + highlights = append(highlights, h) 283 + } 284 + return highlights, nil 285 + } 286 + 245 287 func (db *DB) GetRecentBookmarks(limit, offset int) ([]Bookmark, error) { 246 288 rows, err := db.Query(db.Rebind(` 247 289 SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid ··· 265 307 return bookmarks, nil 266 308 } 267 309 310 + func (db *DB) GetBookmarksByTag(tag string, limit, offset int) ([]Bookmark, error) { 311 + pattern := "%\"" + tag + "\"%" 312 + rows, err := db.Query(db.Rebind(` 313 + SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid 314 + FROM bookmarks 315 + WHERE tags_json LIKE ? 316 + ORDER BY created_at DESC 317 + LIMIT ? OFFSET ? 318 + `), pattern, limit, offset) 319 + if err != nil { 320 + return nil, err 321 + } 322 + defer rows.Close() 323 + 324 + var bookmarks []Bookmark 325 + for rows.Next() { 326 + var b Bookmark 327 + if err := rows.Scan(&b.URI, &b.AuthorDID, &b.Source, &b.SourceHash, &b.Title, &b.Description, &b.TagsJSON, &b.CreatedAt, &b.IndexedAt, &b.CID); err != nil { 328 + return nil, err 329 + } 330 + bookmarks = append(bookmarks, b) 331 + } 332 + return bookmarks, nil 333 + } 334 + 335 + func (db *DB) GetAnnotationsByTagAndAuthor(tag, authorDID string, limit, offset int) ([]Annotation, error) { 336 + pattern := "%\"" + tag + "\"%" 337 + rows, err := db.Query(db.Rebind(` 338 + SELECT uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid 339 + FROM annotations 340 + WHERE author_did = ? AND tags_json LIKE ? 341 + ORDER BY created_at DESC 342 + LIMIT ? OFFSET ? 343 + `), authorDID, pattern, limit, offset) 344 + if err != nil { 345 + return nil, err 346 + } 347 + defer rows.Close() 348 + 349 + return scanAnnotations(rows) 350 + } 351 + 352 + func (db *DB) GetHighlightsByTagAndAuthor(tag, authorDID string, limit, offset int) ([]Highlight, error) { 353 + pattern := "%\"" + tag + "\"%" 354 + rows, err := db.Query(db.Rebind(` 355 + SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid 356 + FROM highlights 357 + WHERE author_did = ? AND tags_json LIKE ? 358 + ORDER BY created_at DESC 359 + LIMIT ? OFFSET ? 360 + `), authorDID, pattern, limit, offset) 361 + if err != nil { 362 + return nil, err 363 + } 364 + defer rows.Close() 365 + 366 + var highlights []Highlight 367 + for rows.Next() { 368 + var h Highlight 369 + if err := rows.Scan(&h.URI, &h.AuthorDID, &h.TargetSource, &h.TargetHash, &h.TargetTitle, &h.SelectorJSON, &h.Color, &h.TagsJSON, &h.CreatedAt, &h.IndexedAt, &h.CID); err != nil { 370 + return nil, err 371 + } 372 + highlights = append(highlights, h) 373 + } 374 + return highlights, nil 375 + } 376 + 377 + func (db *DB) GetBookmarksByTagAndAuthor(tag, authorDID string, limit, offset int) ([]Bookmark, error) { 378 + pattern := "%\"" + tag + "\"%" 379 + rows, err := db.Query(db.Rebind(` 380 + SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid 381 + FROM bookmarks 382 + WHERE author_did = ? AND tags_json LIKE ? 383 + ORDER BY created_at DESC 384 + LIMIT ? OFFSET ? 385 + `), authorDID, pattern, limit, offset) 386 + if err != nil { 387 + return nil, err 388 + } 389 + defer rows.Close() 390 + 391 + var bookmarks []Bookmark 392 + for rows.Next() { 393 + var b Bookmark 394 + if err := rows.Scan(&b.URI, &b.AuthorDID, &b.Source, &b.SourceHash, &b.Title, &b.Description, &b.TagsJSON, &b.CreatedAt, &b.IndexedAt, &b.CID); err != nil { 395 + return nil, err 396 + } 397 + bookmarks = append(bookmarks, b) 398 + } 399 + return bookmarks, nil 400 + } 401 + 268 402 func (db *DB) GetHighlightsByTargetHash(targetHash string, limit, offset int) ([]Highlight, error) { 269 403 rows, err := db.Query(db.Rebind(` 270 404 SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid ··· 500 634 return count, err 501 635 } 502 636 637 + func (db *DB) GetReplyCount(rootURI string) (int, error) { 638 + var count int 639 + err := db.QueryRow(db.Rebind(`SELECT COUNT(*) FROM replies WHERE root_uri = ?`), rootURI).Scan(&count) 640 + return count, err 641 + } 642 + 503 643 func (db *DB) GetLikeByUserAndSubject(userDID, subjectURI string) (*Like, error) { 504 644 var like Like 505 645 err := db.QueryRow(db.Rebind(` ··· 685 825 } 686 826 687 827 normalized := strings.ToLower(parsed.Host) + parsed.Path 828 + if parsed.RawQuery != "" { 829 + normalized += "?" + parsed.RawQuery 830 + } 688 831 normalized = strings.TrimSuffix(normalized, "/") 689 832 690 833 return hashString(normalized)
+46
backend/internal/db/tags.go
··· 1 + package db 2 + 3 + type TrendingTag struct { 4 + Tag string `json:"tag"` 5 + Count int `json:"count"` 6 + } 7 + 8 + func (db *DB) GetTrendingTags(limit int) ([]TrendingTag, error) { 9 + query := ` 10 + SELECT 11 + json_each.value as tag, 12 + COUNT(*) as count 13 + FROM annotations, json_each(annotations.tags_json) 14 + WHERE tags_json IS NOT NULL 15 + AND tags_json != '' 16 + AND tags_json != '[]' 17 + GROUP BY tag 18 + ORDER BY count DESC 19 + LIMIT ? 20 + ` 21 + 22 + rows, err := db.Query(db.Rebind(query), limit) 23 + if err != nil { 24 + return nil, err 25 + } 26 + defer rows.Close() 27 + 28 + var tags []TrendingTag 29 + for rows.Next() { 30 + var t TrendingTag 31 + if err := rows.Scan(&t.Tag, &t.Count); err != nil { 32 + return nil, err 33 + } 34 + tags = append(tags, t) 35 + } 36 + 37 + if err = rows.Err(); err != nil { 38 + return nil, err 39 + } 40 + 41 + if tags == nil { 42 + return []TrendingTag{}, nil 43 + } 44 + 45 + return tags, nil 46 + }
+2 -2
backend/internal/oauth/client.go
··· 205 205 "jti": base64.RawURLEncoding.EncodeToString(jti), 206 206 "htm": method, 207 207 "htu": uri, 208 - "iat": now.Unix(), 208 + "iat": now.Add(-30 * time.Second).Unix(), 209 209 "exp": now.Add(5 * time.Minute).Unix(), 210 210 } 211 211 if nonce != "" { ··· 243 243 Issuer: c.ClientID, 244 244 Subject: c.ClientID, 245 245 Audience: jwt.Audience{issuer}, 246 - IssuedAt: jwt.NewNumericDate(now), 246 + IssuedAt: jwt.NewNumericDate(now.Add(-30 * time.Second)), 247 247 Expiry: jwt.NewNumericDate(now.Add(5 * time.Minute)), 248 248 ID: base64.RawURLEncoding.EncodeToString(jti), 249 249 }
+1
backend/internal/oauth/handler.go
··· 244 244 245 245 parResp, state, dpopNonce, err := client.SendPAR(meta, req.Handle, scope, dpopKey, pkceChallenge) 246 246 if err != nil { 247 + log.Printf("PAR request failed: %v", err) 247 248 w.Header().Set("Content-Type", "application/json") 248 249 w.WriteHeader(http.StatusInternalServerError) 249 250 json.NewEncoder(w).Encode(map[string]string{"error": "Failed to initiate authentication"})
+2 -1
backend/internal/xrpc/records.go
··· 78 78 CreatedAt string `json:"createdAt"` 79 79 } 80 80 81 - func NewHighlightRecord(url, urlHash string, selector interface{}, color string) *HighlightRecord { 81 + func NewHighlightRecord(url, urlHash string, selector interface{}, color string, tags []string) *HighlightRecord { 82 82 return &HighlightRecord{ 83 83 Type: CollectionHighlight, 84 84 Target: AnnotationTarget{ ··· 87 87 Selector: selector, 88 88 }, 89 89 Color: color, 90 + Tags: tags, 90 91 CreatedAt: time.Now().UTC().Format(time.RFC3339), 91 92 } 92 93 }
+123 -51
extension/background/service-worker.js
··· 6 6 const hasSidebarAction = 7 7 typeof browser !== "undefined" && 8 8 typeof browser.sidebarAction !== "undefined"; 9 - const hasSessionStorage = 10 - typeof chrome !== "undefined" && 11 - chrome.storage && 12 - typeof chrome.storage.session !== "undefined"; 13 9 const hasNotifications = 14 10 typeof chrome !== "undefined" && typeof chrome.notifications !== "undefined"; 15 11 ··· 43 39 } 44 40 } 45 41 46 - async function openAnnotationUI(tabId) { 42 + async function openAnnotationUI(tabId, windowId) { 47 43 if (hasSidePanel) { 48 44 try { 49 - const tab = await chrome.tabs.get(tabId); 50 - await chrome.sidePanel.setOptions({ 51 - tabId: tabId, 52 - path: "sidepanel/sidepanel.html", 53 - enabled: true, 54 - }); 55 - await chrome.sidePanel.open({ windowId: tab.windowId }); 45 + let targetWindowId = windowId; 46 + 47 + if (!targetWindowId) { 48 + const tab = await chrome.tabs.get(tabId); 49 + targetWindowId = tab.windowId; 50 + } 51 + 52 + await chrome.sidePanel.open({ windowId: targetWindowId }); 56 53 return true; 57 54 } catch (err) { 58 55 console.error("Could not open Chrome side panel:", err); ··· 71 68 return false; 72 69 } 73 70 74 - async function storePendingAnnotation(data) { 75 - if (hasSessionStorage) { 76 - await chrome.storage.session.set({ pendingAnnotation: data }); 77 - } else { 78 - await chrome.storage.local.set({ 79 - pendingAnnotation: data, 80 - pendingAnnotationExpiry: Date.now() + 60000, 81 - }); 82 - } 83 - } 84 - 85 71 chrome.runtime.onInstalled.addListener(async () => { 86 72 const stored = await chrome.storage.local.get(["apiUrl"]); 87 73 if (!stored.apiUrl) { ··· 118 104 if (hasSidebarAction) { 119 105 try { 120 106 await browser.sidebarAction.close(); 121 - } catch (e) {} 107 + } catch { 108 + /* ignore */ 109 + } 122 110 } 123 111 }); 124 112 125 - chrome.action.onClicked.addListener(async (tab) => { 113 + chrome.action.onClicked.addListener(async () => { 126 114 const stored = await chrome.storage.local.get(["apiUrl"]); 127 115 const webUrl = stored.apiUrl || WEB_BASE; 128 116 chrome.tabs.create({ url: webUrl }); ··· 130 118 131 119 chrome.contextMenus.onClicked.addListener(async (info, tab) => { 132 120 if (info.menuItemId === "margin-open-sidebar") { 133 - if (hasSidePanel && chrome.sidePanel && chrome.sidePanel.open) { 134 - try { 135 - await chrome.sidePanel.open({ windowId: tab.windowId }); 136 - } catch (err) { 137 - console.error("Failed to open side panel:", err); 138 - } 139 - } else if (hasSidebarAction) { 140 - try { 141 - await browser.sidebarAction.open(); 142 - } catch (err) { 143 - console.error("Failed to open Firefox sidebar:", err); 144 - } 145 - } 121 + await openAnnotationUI(tab.id, tab.windowId); 146 122 return; 147 123 } 148 124 ··· 189 165 selectionText: info.selectionText, 190 166 }); 191 167 selector = response?.selector; 192 - } catch (err) {} 193 - 194 - if (selector && (hasSidePanel || hasSidebarAction)) { 195 - await storePendingAnnotation({ 196 - url: tab.url, 197 - title: tab.title, 198 - selector: selector, 199 - }); 200 - const opened = await openAnnotationUI(tab.id); 201 - if (opened) return; 168 + } catch { 169 + /* ignore */ 202 170 } 203 171 204 172 if (!selector && info.selectionText) { ··· 208 176 }; 209 177 } 210 178 179 + if (selector) { 180 + try { 181 + await chrome.tabs.sendMessage(tab.id, { 182 + type: "SHOW_INLINE_ANNOTATE", 183 + data: { 184 + url: tab.url, 185 + title: tab.title, 186 + selector: selector, 187 + }, 188 + }); 189 + return; 190 + } catch { 191 + /* ignore */ 192 + } 193 + } 194 + 211 195 if (WEB_BASE) { 212 196 let composeUrl = `${WEB_BASE}/new?url=${encodeURIComponent(tab.url)}`; 213 197 if (selector) { ··· 227 211 selectionText: info.selectionText, 228 212 }); 229 213 if (response && response.success) return; 230 - } catch (err) {} 214 + } catch { 215 + /* ignore */ 216 + } 231 217 232 218 if (info.selectionText) { 233 219 selector = { ··· 334 320 } 335 321 336 322 case "GET_ANNOTATIONS": { 323 + const stored = await chrome.storage.local.get(["apiUrl"]); 324 + const currentApiUrl = stored.apiUrl 325 + ? stored.apiUrl.replace(/\/$/, "") 326 + : API_BASE; 327 + 337 328 const pageUrl = request.data.url; 338 329 const res = await fetch( 339 - `${API_BASE}/api/targets?source=${encodeURIComponent(pageUrl)}`, 330 + `${currentApiUrl}/api/targets?source=${encodeURIComponent(pageUrl)}`, 340 331 ); 341 332 const data = await res.json(); 342 333 ··· 422 413 return; 423 414 } 424 415 const { url, selector } = request.data; 425 - 426 416 let composeUrl = `${WEB_BASE}/new?url=${encodeURIComponent(url)}`; 427 417 if (selector) { 428 418 composeUrl += `&selector=${encodeURIComponent(JSON.stringify(selector))}`; ··· 430 420 chrome.tabs.create({ url: composeUrl }); 431 421 break; 432 422 } 423 + 424 + case "OPEN_APP_URL": { 425 + if (!WEB_BASE) { 426 + chrome.runtime.openOptionsPage(); 427 + return; 428 + } 429 + const path = request.data.path; 430 + const safePath = path.startsWith("/") ? path : `/${path}`; 431 + chrome.tabs.create({ url: `${WEB_BASE}${safePath}` }); 432 + break; 433 + } 434 + 435 + case "OPEN_SIDE_PANEL": 436 + if (sender.tab && sender.tab.windowId) { 437 + chrome.sidePanel 438 + .open({ windowId: sender.tab.windowId }) 439 + .catch((err) => console.error("Failed to open side panel", err)); 440 + } 441 + break; 433 442 434 443 case "CREATE_BOOKMARK": { 435 444 if (!API_BASE) { ··· 634 643 throw new Error( 635 644 `Failed to add to collection: ${res.status} ${errText}`, 636 645 ); 646 + } 647 + 648 + const data = await res.json(); 649 + sendResponse({ success: true, data }); 650 + break; 651 + } 652 + 653 + case "GET_REPLIES": { 654 + if (!API_BASE) { 655 + sendResponse({ success: false, error: "API URL not configured" }); 656 + return; 657 + } 658 + 659 + const uri = request.data.uri; 660 + const res = await fetch( 661 + `${API_BASE}/api/replies?uri=${encodeURIComponent(uri)}`, 662 + ); 663 + 664 + if (!res.ok) { 665 + throw new Error(`Failed to fetch replies: ${res.status}`); 666 + } 667 + 668 + const data = await res.json(); 669 + sendResponse({ success: true, data: data.items || [] }); 670 + break; 671 + } 672 + 673 + case "CREATE_REPLY": { 674 + if (!API_BASE) { 675 + sendResponse({ success: false, error: "API URL not configured" }); 676 + return; 677 + } 678 + 679 + const cookie = await chrome.cookies.get({ 680 + url: API_BASE, 681 + name: "margin_session", 682 + }); 683 + 684 + if (!cookie) { 685 + sendResponse({ success: false, error: "Not authenticated" }); 686 + return; 687 + } 688 + 689 + const { parentUri, parentCid, rootUri, rootCid, text } = request.data; 690 + const res = await fetch(`${API_BASE}/api/annotations/reply`, { 691 + method: "POST", 692 + credentials: "include", 693 + headers: { 694 + "Content-Type": "application/json", 695 + "X-Session-Token": cookie.value, 696 + }, 697 + body: JSON.stringify({ 698 + parentUri, 699 + parentCid, 700 + rootUri, 701 + rootCid, 702 + text, 703 + }), 704 + }); 705 + 706 + if (!res.ok) { 707 + const errText = await res.text(); 708 + throw new Error(`Failed to create reply: ${res.status} ${errText}`); 637 709 } 638 710 639 711 const data = await res.json();
+952 -241
extension/content/content.js
··· 1 1 (() => { 2 - function buildTextQuoteSelector(selection) { 3 - const exact = selection.toString().trim(); 4 - if (!exact) return null; 2 + let sidebarHost = null; 3 + let sidebarShadow = null; 4 + let popoverEl = null; 5 5 6 - const range = selection.getRangeAt(0); 7 - const contextLength = 32; 6 + 7 + let activeItems = []; 8 + let currentSelection = null; 8 9 9 - let prefix = ""; 10 - try { 11 - const preRange = document.createRange(); 12 - preRange.selectNodeContents(document.body); 13 - preRange.setEnd(range.startContainer, range.startOffset); 14 - const preText = preRange.toString(); 15 - prefix = preText.slice(-contextLength).trim(); 16 - } catch (e) { 17 - console.warn("Could not get prefix:", e); 10 + const OVERLAY_STYLES = ` 11 + :host { all: initial; } 12 + .margin-overlay { 13 + position: absolute; 14 + top: 0; 15 + left: 0; 16 + width: 100%; 17 + height: 100%; 18 + pointer-events: none; 19 + } 20 + 21 + .margin-popover { 22 + position: absolute; 23 + width: 320px; 24 + background: #09090b; 25 + border: 1px solid #27272a; 26 + border-radius: 12px; 27 + padding: 0; 28 + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.5), 0 10px 10px -5px rgba(0, 0, 0, 0.2); 29 + display: flex; 30 + flex-direction: column; 31 + pointer-events: auto; 32 + z-index: 2147483647; 33 + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 34 + color: #e4e4e7; 35 + opacity: 0; 36 + transform: scale(0.95); 37 + animation: popover-in 0.15s forwards; 38 + max-height: 480px; 39 + overflow: hidden; 40 + } 41 + @keyframes popover-in { to { opacity: 1; transform: scale(1); } } 42 + .popover-header { 43 + padding: 12px 16px; 44 + border-bottom: 1px solid #27272a; 45 + display: flex; 46 + justify-content: space-between; 47 + align-items: center; 48 + background: #0f0f12; 49 + border-radius: 12px 12px 0 0; 50 + font-weight: 600; 51 + font-size: 13px; 52 + } 53 + .popover-scroll-area { 54 + overflow-y: auto; 55 + max-height: 400px; 56 + } 57 + .popover-item-block { 58 + border-bottom: 1px solid #27272a; 59 + margin-bottom: 0; 60 + animation: fade-in 0.2s; 61 + } 62 + .popover-item-block:last-child { 63 + border-bottom: none; 64 + } 65 + .popover-item-header { 66 + padding: 12px 16px 4px; 67 + display: flex; 68 + align-items: center; 69 + gap: 8px; 70 + } 71 + .popover-avatar { 72 + width: 24px; height: 24px; border-radius: 50%; background: #27272a; 73 + display: flex; align-items: center; justify-content: center; 74 + font-size: 10px; color: #a1a1aa; 75 + } 76 + .popover-handle { font-size: 12px; font-weight: 600; color: #e4e4e7; } 77 + .popover-close { background: none; border: none; color: #71717a; cursor: pointer; padding: 4px; } 78 + .popover-close:hover { color: #e4e4e7; } 79 + .popover-content { padding: 4px 16px 12px; font-size: 13px; line-height: 1.5; color: #e4e4e7; } 80 + .popover-quote { 81 + margin-top: 8px; padding: 6px 10px; background: #18181b; 82 + border-left: 2px solid #6366f1; border-radius: 4px; 83 + font-size: 11px; color: #a1a1aa; font-style: italic; 84 + } 85 + .popover-actions { 86 + padding: 8px 16px; 87 + display: flex; justify-content: flex-end; gap: 8px; 88 + } 89 + .btn-action { 90 + background: none; border: 1px solid #27272a; border-radius: 4px; 91 + padding: 4px 8px; color: #a1a1aa; font-size: 11px; cursor: pointer; 92 + } 93 + .btn-action:hover { background: #27272a; color: #e4e4e7; } 94 + 95 + .margin-selection-popup { 96 + position: fixed; 97 + display: flex; 98 + gap: 4px; 99 + padding: 6px; 100 + background: #09090b; 101 + border: 1px solid #27272a; 102 + border-radius: 8px; 103 + box-shadow: 0 8px 16px rgba(0,0,0,0.4); 104 + z-index: 2147483647; 105 + pointer-events: auto; 106 + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 107 + animation: popover-in 0.15s forwards; 108 + } 109 + .selection-btn { 110 + display: flex; 111 + align-items: center; 112 + gap: 6px; 113 + padding: 6px 12px; 114 + background: transparent; 115 + border: none; 116 + border-radius: 6px; 117 + color: #e4e4e7; 118 + font-size: 12px; 119 + font-weight: 500; 120 + cursor: pointer; 121 + transition: background 0.15s; 122 + } 123 + .selection-btn:hover { 124 + background: #27272a; 125 + } 126 + .selection-btn svg { 127 + width: 14px; 128 + height: 14px; 129 + } 130 + .inline-compose-modal { 131 + position: fixed; 132 + width: 340px; 133 + max-width: calc(100vw - 40px); 134 + background: #09090b; 135 + border: 1px solid #27272a; 136 + border-radius: 12px; 137 + padding: 16px; 138 + box-sizing: border-box; 139 + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.5); 140 + z-index: 2147483647; 141 + pointer-events: auto; 142 + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 143 + color: #e4e4e7; 144 + animation: popover-in 0.15s forwards; 145 + overflow: hidden; 146 + } 147 + .inline-compose-modal * { 148 + box-sizing: border-box; 149 + } 150 + .inline-compose-quote { 151 + padding: 8px 12px; 152 + background: #18181b; 153 + border-left: 3px solid #6366f1; 154 + border-radius: 4px; 155 + font-size: 12px; 156 + color: #a1a1aa; 157 + font-style: italic; 158 + margin-bottom: 12px; 159 + max-height: 60px; 160 + overflow: hidden; 161 + word-break: break-word; 162 + } 163 + .inline-compose-textarea { 164 + width: 100%; 165 + min-height: 80px; 166 + padding: 10px 12px; 167 + background: #18181b; 168 + border: 1px solid #27272a; 169 + border-radius: 8px; 170 + color: #e4e4e7; 171 + font-family: inherit; 172 + font-size: 13px; 173 + resize: vertical; 174 + margin-bottom: 12px; 175 + box-sizing: border-box; 176 + } 177 + .inline-compose-textarea:focus { 178 + outline: none; 179 + border-color: #6366f1; 180 + } 181 + .inline-compose-actions { 182 + display: flex; 183 + justify-content: flex-end; 184 + gap: 8px; 185 + } 186 + .btn-cancel { 187 + padding: 8px 16px; 188 + background: transparent; 189 + border: 1px solid #27272a; 190 + border-radius: 6px; 191 + color: #a1a1aa; 192 + font-size: 13px; 193 + cursor: pointer; 194 + } 195 + .btn-cancel:hover { 196 + background: #27272a; 197 + color: #e4e4e7; 198 + } 199 + .btn-submit { 200 + padding: 8px 16px; 201 + background: #6366f1; 202 + border: none; 203 + border-radius: 6px; 204 + color: white; 205 + font-size: 13px; 206 + font-weight: 500; 207 + cursor: pointer; 208 + } 209 + .btn-submit:hover { 210 + background: #4f46e5; 211 + } 212 + .btn-submit:disabled { 213 + opacity: 0.5; 214 + cursor: not-allowed; 215 + } 216 + .reply-section { 217 + border-top: 1px solid #27272a; 218 + padding: 12px 16px; 219 + background: #0f0f12; 220 + border-radius: 0 0 12px 12px; 221 + } 222 + .reply-textarea { 223 + width: 100%; 224 + min-height: 60px; 225 + padding: 8px 10px; 226 + background: #18181b; 227 + border: 1px solid #27272a; 228 + border-radius: 6px; 229 + color: #e4e4e7; 230 + font-family: inherit; 231 + font-size: 12px; 232 + resize: none; 233 + margin-bottom: 8px; 234 + } 235 + .reply-textarea:focus { 236 + outline: none; 237 + border-color: #6366f1; 238 + } 239 + .reply-submit { 240 + padding: 6px 12px; 241 + background: #6366f1; 242 + border: none; 243 + border-radius: 4px; 244 + color: white; 245 + font-size: 11px; 246 + font-weight: 500; 247 + cursor: pointer; 248 + float: right; 249 + } 250 + .reply-submit:disabled { 251 + opacity: 0.5; 18 252 } 253 + .reply-item { 254 + padding: 8px 0; 255 + border-top: 1px solid #27272a; 256 + } 257 + .reply-item:first-child { 258 + border-top: none; 259 + } 260 + .reply-author { 261 + font-size: 11px; 262 + font-weight: 600; 263 + color: #a1a1aa; 264 + margin-bottom: 4px; 265 + } 266 + .reply-text { 267 + font-size: 12px; 268 + color: #e4e4e7; 269 + line-height: 1.4; 270 + } 271 + `; 19 272 20 - let suffix = ""; 21 - try { 22 - const postRange = document.createRange(); 23 - postRange.selectNodeContents(document.body); 24 - postRange.setStart(range.endContainer, range.endOffset); 25 - const postText = postRange.toString(); 26 - suffix = postText.slice(0, contextLength).trim(); 27 - } catch (e) { 28 - console.warn("Could not get suffix:", e); 273 + class DOMTextMatcher { 274 + constructor() { 275 + this.textNodes = []; 276 + this.corpus = ""; 277 + this.indices = []; 278 + this.buildMap(); 29 279 } 30 280 31 - return { 32 - type: "TextQuoteSelector", 33 - exact: exact, 34 - prefix: prefix || undefined, 35 - suffix: suffix || undefined, 36 - }; 37 - } 281 + buildMap() { 282 + const walker = document.createTreeWalker( 283 + document.body, 284 + NodeFilter.SHOW_TEXT, 285 + { 286 + acceptNode: (node) => { 287 + if (!node.parentNode) return NodeFilter.FILTER_REJECT; 288 + const tag = node.parentNode.tagName; 289 + if ( 290 + ["SCRIPT", "STYLE", "NOSCRIPT", "TEXTAREA", "INPUT"].includes(tag) 291 + ) 292 + return NodeFilter.FILTER_REJECT; 293 + if (node.textContent.trim().length === 0) 294 + return NodeFilter.FILTER_SKIP; 38 295 39 - function findAndScrollToText(selector) { 40 - if (!selector || !selector.exact) return false; 296 + if (node.parentNode.offsetParent === null) 297 + return NodeFilter.FILTER_REJECT; 41 298 42 - const searchText = selector.exact.trim(); 43 - const normalizedSearch = searchText.replace(/\s+/g, " "); 299 + return NodeFilter.FILTER_ACCEPT; 300 + }, 301 + }, 302 + ); 303 + 304 + let currentNode; 305 + let index = 0; 306 + while ((currentNode = walker.nextNode())) { 307 + const text = currentNode.textContent; 308 + this.textNodes.push(currentNode); 309 + this.corpus += text; 310 + this.indices.push({ 311 + start: index, 312 + node: currentNode, 313 + length: text.length, 314 + }); 315 + index += text.length; 316 + } 317 + } 44 318 45 - const treeWalker = document.createTreeWalker( 46 - document.body, 47 - NodeFilter.SHOW_TEXT, 48 - null, 49 - false, 50 - ); 319 + findRange(searchText) { 320 + if (!searchText) return null; 51 321 52 - let currentNode; 53 - while ((currentNode = treeWalker.nextNode())) { 54 - const nodeText = currentNode.textContent; 55 - const normalizedNode = nodeText.replace(/\s+/g, " "); 322 + let matchIndex = this.corpus.indexOf(searchText); 56 323 57 - let index = nodeText.indexOf(searchText); 324 + if (matchIndex === -1) { 325 + const normalizedSearch = searchText.replace(/\s+/g, " ").trim(); 326 + matchIndex = this.corpus.indexOf(normalizedSearch); 58 327 59 - if (index === -1) { 60 - const normIndex = normalizedNode.indexOf(normalizedSearch); 61 - if (normIndex !== -1) { 62 - index = nodeText.indexOf(searchText.substring(0, 20)); 63 - if (index === -1) index = 0; 328 + if (matchIndex === -1) { 329 + const fuzzyMatch = this.fuzzyFindInCorpus(searchText); 330 + if (fuzzyMatch) { 331 + const start = this.mapIndexToPoint(fuzzyMatch.start); 332 + const end = this.mapIndexToPoint(fuzzyMatch.end); 333 + if (start && end) { 334 + const range = document.createRange(); 335 + range.setStart(start.node, start.offset); 336 + range.setEnd(end.node, end.offset); 337 + return range; 338 + } 339 + } 340 + return null; 64 341 } 65 342 } 66 343 67 - if (index !== -1 && nodeText.trim().length > 0) { 68 - try { 69 - const range = document.createRange(); 70 - const endIndex = Math.min(index + searchText.length, nodeText.length); 71 - range.setStart(currentNode, index); 72 - range.setEnd(currentNode, endIndex); 344 + const start = this.mapIndexToPoint(matchIndex); 345 + const end = this.mapIndexToPoint(matchIndex + searchText.length); 73 346 74 - if (typeof CSS !== "undefined" && CSS.highlights) { 75 - const highlight = new Highlight(range); 76 - CSS.highlights.set("margin-scroll-highlight", highlight); 347 + if (start && end) { 348 + const range = document.createRange(); 349 + range.setStart(start.node, start.offset); 350 + range.setEnd(end.node, end.offset); 351 + return range; 352 + } 353 + return null; 354 + } 77 355 78 - setTimeout(() => { 79 - CSS.highlights.delete("margin-scroll-highlight"); 80 - }, 3000); 81 - } 356 + fuzzyFindInCorpus(searchText) { 357 + const searchWords = searchText 358 + .trim() 359 + .split(/\s+/) 360 + .filter((w) => w.length > 0); 361 + if (searchWords.length === 0) return null; 82 362 83 - const rect = range.getBoundingClientRect(); 84 - window.scrollTo({ 85 - top: window.scrollY + rect.top - window.innerHeight / 3, 86 - behavior: "smooth", 87 - }); 363 + const corpusLower = this.corpus.toLowerCase(); 88 364 89 - window.scrollTo({ 90 - top: window.scrollY + rect.top - window.innerHeight / 3, 91 - behavior: "smooth", 92 - }); 365 + const firstWord = searchWords[0].toLowerCase(); 366 + let searchStart = 0; 367 + 368 + while (searchStart < corpusLower.length) { 369 + const wordStart = corpusLower.indexOf(firstWord, searchStart); 370 + if (wordStart === -1) break; 371 + 372 + let corpusPos = wordStart; 373 + let matched = true; 374 + let lastMatchEnd = wordStart; 375 + 376 + for (const word of searchWords) { 377 + const wordLower = word.toLowerCase(); 378 + while ( 379 + corpusPos < corpusLower.length && 380 + /\s/.test(this.corpus[corpusPos]) 381 + ) { 382 + corpusPos++; 383 + } 384 + const corpusSlice = corpusLower.slice( 385 + corpusPos, 386 + corpusPos + wordLower.length, 387 + ); 388 + if (corpusSlice !== wordLower) { 389 + matched = false; 390 + break; 391 + } 93 392 94 - return true; 95 - } catch (e) { 96 - console.warn("Could not create range:", e); 393 + corpusPos += wordLower.length; 394 + lastMatchEnd = corpusPos; 97 395 } 396 + 397 + if (matched) { 398 + return { start: wordStart, end: lastMatchEnd }; 399 + } 400 + 401 + searchStart = wordStart + 1; 98 402 } 403 + 404 + return null; 99 405 } 100 406 101 - if (window.find) { 102 - window.getSelection()?.removeAllRanges(); 103 - const found = window.find(searchText, false, false, true, false); 104 - if (found) { 105 - const selection = window.getSelection(); 106 - if (selection && selection.rangeCount > 0) { 107 - const range = selection.getRangeAt(0); 108 - const rect = range.getBoundingClientRect(); 109 - window.scrollTo({ 110 - top: window.scrollY + rect.top - window.innerHeight / 3, 111 - behavior: "smooth", 112 - }); 407 + mapIndexToPoint(corpusIndex) { 408 + for (const info of this.indices) { 409 + if ( 410 + corpusIndex >= info.start && 411 + corpusIndex < info.start + info.length 412 + ) { 413 + return { node: info.node, offset: corpusIndex - info.start }; 414 + } 415 + } 416 + if (this.indices.length > 0) { 417 + const last = this.indices[this.indices.length - 1]; 418 + if (corpusIndex === last.start + last.length) { 419 + return { node: last.node, offset: last.length }; 113 420 } 114 - return true; 115 421 } 422 + return null; 116 423 } 424 + } 117 425 118 - return false; 426 + function initOverlay() { 427 + sidebarHost = document.createElement("div"); 428 + sidebarHost.id = "margin-overlay-host"; 429 + const getScrollHeight = () => { 430 + const bodyH = document.body?.scrollHeight || 0; 431 + const docH = document.documentElement?.scrollHeight || 0; 432 + return Math.max(bodyH, docH); 433 + }; 434 + 435 + sidebarHost.style.cssText = ` 436 + position: absolute; top: 0; left: 0; width: 100%; 437 + height: ${getScrollHeight()}px; 438 + pointer-events: none; z-index: 2147483647; 439 + `; 440 + document.body?.appendChild(sidebarHost) || 441 + document.documentElement.appendChild(sidebarHost); 442 + 443 + sidebarShadow = sidebarHost.attachShadow({ mode: "open" }); 444 + const styleEl = document.createElement("style"); 445 + styleEl.textContent = OVERLAY_STYLES; 446 + sidebarShadow.appendChild(styleEl); 447 + 448 + const container = document.createElement("div"); 449 + container.className = "margin-overlay"; 450 + container.id = "margin-overlay-container"; 451 + sidebarShadow.appendChild(container); 452 + 453 + const observer = new ResizeObserver(() => { 454 + sidebarHost.style.height = `${getScrollHeight()}px`; 455 + }); 456 + if (document.body) observer.observe(document.body); 457 + if (document.documentElement) observer.observe(document.documentElement); 458 + 459 + if (typeof chrome !== "undefined" && chrome.storage) { 460 + chrome.storage.local.get(["showOverlay"], (result) => { 461 + if (result.showOverlay === false) { 462 + sidebarHost.style.display = "none"; 463 + } else { 464 + fetchAnnotations(); 465 + } 466 + }); 467 + } else { 468 + fetchAnnotations(); 469 + } 470 + 471 + document.addEventListener("mousemove", handleMouseMove); 472 + document.addEventListener("click", handleDocumentClick, true); 119 473 } 120 474 121 - function renderPageHighlights(highlights) { 122 - if (!highlights || !Array.isArray(highlights) || !CSS.highlights) return; 475 + function showInlineComposeModal() { 476 + if (!sidebarShadow || !currentSelection) return; 477 + 478 + const container = sidebarShadow.getElementById("margin-overlay-container"); 479 + if (!container) return; 480 + 481 + const existingModal = container.querySelector(".inline-compose-modal"); 482 + if (existingModal) existingModal.remove(); 483 + 484 + const modal = document.createElement("div"); 485 + modal.className = "inline-compose-modal"; 123 486 124 - const ranges = []; 487 + modal.style.left = `${Math.max(20, (window.innerWidth - 340) / 2)}px`; 488 + modal.style.top = `${Math.min(200, window.innerHeight / 4)}px`; 125 489 126 - highlights.forEach((item) => { 127 - const selector = item.target?.selector; 128 - if (!selector?.exact) return; 490 + const truncatedQuote = 491 + currentSelection.text.length > 100 492 + ? currentSelection.text.substring(0, 100) + "..." 493 + : currentSelection.text; 129 494 130 - const searchText = selector.exact; 131 - const treeWalker = document.createTreeWalker( 132 - document.body, 133 - NodeFilter.SHOW_TEXT, 134 - null, 135 - false, 495 + modal.innerHTML = ` 496 + <div class="inline-compose-quote">"${truncatedQuote}"</div> 497 + <textarea class="inline-compose-textarea" placeholder="Add your annotation..." autofocus></textarea> 498 + <div class="inline-compose-actions"> 499 + <button class="btn-cancel">Cancel</button> 500 + <button class="btn-submit">Post Annotation</button> 501 + </div> 502 + `; 503 + 504 + const textarea = modal.querySelector("textarea"); 505 + const submitBtn = modal.querySelector(".btn-submit"); 506 + const cancelBtn = modal.querySelector(".btn-cancel"); 507 + 508 + cancelBtn.addEventListener("click", () => { 509 + modal.remove(); 510 + }); 511 + 512 + submitBtn.addEventListener("click", async () => { 513 + const text = textarea.value.trim(); 514 + if (!text) return; 515 + 516 + submitBtn.disabled = true; 517 + submitBtn.textContent = "Posting..."; 518 + 519 + chrome.runtime.sendMessage( 520 + { 521 + type: "CREATE_ANNOTATION", 522 + data: { 523 + url: currentSelection.url || window.location.href, 524 + title: currentSelection.title || document.title, 525 + text: text, 526 + selector: currentSelection.selector, 527 + }, 528 + }, 529 + (res) => { 530 + if (res && res.success) { 531 + modal.remove(); 532 + fetchAnnotations(); 533 + } else { 534 + submitBtn.disabled = false; 535 + submitBtn.textContent = "Post Annotation"; 536 + alert( 537 + "Failed to create annotation: " + (res?.error || "Unknown error"), 538 + ); 539 + } 540 + }, 136 541 ); 542 + }); 137 543 138 - let currentNode; 139 - while ((currentNode = treeWalker.nextNode())) { 140 - const nodeText = currentNode.textContent; 141 - const index = nodeText.indexOf(searchText); 544 + container.appendChild(modal); 545 + textarea.focus(); 142 546 143 - if (index !== -1) { 144 - try { 145 - const range = document.createRange(); 146 - range.setStart(currentNode, index); 147 - range.setEnd(currentNode, index + searchText.length); 148 - ranges.push(range); 149 - } catch (e) { 150 - console.warn("Could not create range for highlight:", e); 547 + const handleEscape = (e) => { 548 + if (e.key === "Escape") { 549 + modal.remove(); 550 + document.removeEventListener("keydown", handleEscape); 551 + } 552 + }; 553 + document.addEventListener("keydown", handleEscape); 554 + } 555 + 556 + let hoverIndicator = null; 557 + 558 + function handleMouseMove(e) { 559 + const x = e.clientX; 560 + const y = e.clientY; 561 + let foundItems = []; 562 + let firstRange = null; 563 + for (const { range, item } of activeItems) { 564 + const rects = range.getClientRects(); 565 + for (const rect of rects) { 566 + if ( 567 + x >= rect.left && 568 + x <= rect.right && 569 + y >= rect.top && 570 + y <= rect.bottom 571 + ) { 572 + if (!firstRange) firstRange = range; 573 + if (!foundItems.some((f) => f.item === item)) { 574 + foundItems.push({ range, item, rect }); 151 575 } 152 576 break; 153 577 } 154 578 } 155 - }); 579 + } 156 580 157 - if (ranges.length > 0) { 158 - const highlight = new Highlight(...ranges); 159 - CSS.highlights.set("margin-page-highlights", highlight); 160 - } 161 - } 581 + if (foundItems.length > 0) { 582 + document.body.style.cursor = "pointer"; 162 583 163 - chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { 164 - if (request.type === "GET_SELECTOR_FOR_ANNOTATE_INLINE") { 165 - const selection = window.getSelection(); 166 - if (!selection || selection.toString().trim().length === 0) { 167 - sendResponse({ selector: null }); 168 - return true; 584 + if (!hoverIndicator && sidebarShadow) { 585 + const container = sidebarShadow.getElementById( 586 + "margin-overlay-container", 587 + ); 588 + if (container) { 589 + hoverIndicator = document.createElement("div"); 590 + hoverIndicator.className = "margin-hover-indicator"; 591 + hoverIndicator.style.cssText = ` 592 + position: fixed; 593 + display: flex; 594 + align-items: center; 595 + pointer-events: none; 596 + z-index: 2147483647; 597 + opacity: 0; 598 + transition: opacity 0.15s, transform 0.15s; 599 + transform: scale(0.8); 600 + `; 601 + container.appendChild(hoverIndicator); 602 + } 169 603 } 170 604 171 - const selector = buildTextQuoteSelector(selection); 172 - sendResponse({ selector: selector }); 173 - return true; 605 + if (hoverIndicator) { 606 + const authorsMap = new Map(); 607 + foundItems.forEach(({ item }) => { 608 + const author = item.author || item.creator || {}; 609 + const id = author.did || author.handle || "unknown"; 610 + if (!authorsMap.has(id)) { 611 + authorsMap.set(id, author); 612 + } 613 + }); 614 + const uniqueAuthors = Array.from(authorsMap.values()); 615 + 616 + const maxShow = 3; 617 + const displayAuthors = uniqueAuthors.slice(0, maxShow); 618 + const overflow = uniqueAuthors.length - maxShow; 619 + 620 + let html = displayAuthors 621 + .map((author, i) => { 622 + const avatar = author.avatar; 623 + const handle = author.handle || "U"; 624 + const marginLeft = i === 0 ? "0" : "-8px"; 625 + 626 + if (avatar) { 627 + return `<img src="${avatar}" style="width: 24px; height: 24px; border-radius: 50%; object-fit: cover; border: 2px solid #09090b; margin-left: ${marginLeft};">`; 628 + } else { 629 + 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>`; 630 + } 631 + }) 632 + .join(""); 633 + 634 + if (overflow > 0) { 635 + html += `<div style="width: 24px; height: 24px; border-radius: 50%; background: #27272a; color: #a1a1aa; display: flex; align-items: center; justify-content: center; font-size: 10px; font-weight: 600; font-family: -apple-system, sans-serif; border: 2px solid #09090b; margin-left: -8px;">+${overflow}</div>`; 636 + } 637 + 638 + hoverIndicator.innerHTML = html; 639 + 640 + const firstRect = firstRange.getClientRects()[0]; 641 + const totalWidth = 642 + Math.min(uniqueAuthors.length, maxShow + (overflow > 0 ? 1 : 0)) * 643 + 18 + 644 + 8; 645 + const leftPos = firstRect.left - totalWidth; 646 + const topPos = firstRect.top + firstRect.height / 2 - 12; 647 + 648 + hoverIndicator.style.left = `${leftPos}px`; 649 + hoverIndicator.style.top = `${topPos}px`; 650 + hoverIndicator.style.opacity = "1"; 651 + hoverIndicator.style.transform = "scale(1)"; 652 + } 653 + } else { 654 + document.body.style.cursor = ""; 655 + if (hoverIndicator) { 656 + hoverIndicator.style.opacity = "0"; 657 + hoverIndicator.style.transform = "scale(0.8)"; 658 + } 174 659 } 660 + } 175 661 176 - if (request.type === "GET_SELECTOR_FOR_ANNOTATE") { 177 - const selection = window.getSelection(); 178 - if (!selection || selection.toString().trim().length === 0) { 662 + function handleDocumentClick(e) { 663 + const x = e.clientX; 664 + const y = e.clientY; 665 + if (popoverEl && sidebarShadow) { 666 + const rect = popoverEl.getBoundingClientRect(); 667 + if ( 668 + x >= rect.left && 669 + x <= rect.right && 670 + y >= rect.top && 671 + y <= rect.bottom 672 + ) { 179 673 return; 180 674 } 675 + } 181 676 182 - const selector = buildTextQuoteSelector(selection); 183 - if (selector) { 184 - chrome.runtime.sendMessage({ 185 - type: "OPEN_COMPOSE", 186 - data: { 187 - url: window.location.href, 188 - selector: selector, 189 - }, 190 - }); 677 + let clickedItems = []; 678 + for (const { range, item } of activeItems) { 679 + const rects = range.getClientRects(); 680 + for (const rect of rects) { 681 + if ( 682 + x >= rect.left && 683 + x <= rect.right && 684 + y >= rect.top && 685 + y <= rect.bottom 686 + ) { 687 + if (!clickedItems.includes(item)) { 688 + clickedItems.push(item); 689 + } 690 + break; 691 + } 191 692 } 192 693 } 193 694 194 - if (request.type === "GET_SELECTOR_FOR_HIGHLIGHT") { 195 - const selection = window.getSelection(); 196 - if (!selection || selection.toString().trim().length === 0) { 197 - sendResponse({ success: false, error: "No text selected" }); 198 - return true; 695 + if (clickedItems.length > 0) { 696 + e.preventDefault(); 697 + e.stopPropagation(); 698 + 699 + if (popoverEl) { 700 + const currentIds = popoverEl.dataset.itemIds; 701 + const newIds = clickedItems 702 + .map((i) => i.uri || i.id) 703 + .sort() 704 + .join(","); 705 + 706 + if (currentIds === newIds) { 707 + popoverEl.remove(); 708 + popoverEl = null; 709 + return; 710 + } 711 + } 712 + 713 + const firstItem = clickedItems[0]; 714 + const match = activeItems.find((x) => x.item === firstItem); 715 + if (match) { 716 + const rects = match.range.getClientRects(); 717 + if (rects.length > 0) { 718 + const rect = rects[0]; 719 + const top = rect.top + window.scrollY; 720 + const left = rect.left + window.scrollX; 721 + showPopover(clickedItems, top, left); 722 + } 199 723 } 724 + } else { 725 + if (popoverEl) { 726 + popoverEl.remove(); 727 + popoverEl = null; 728 + } 729 + } 730 + } 200 731 201 - const selector = buildTextQuoteSelector(selection); 202 - if (selector) { 203 - chrome.runtime 204 - .sendMessage({ 205 - type: "CREATE_HIGHLIGHT", 206 - data: { 207 - url: window.location.href, 208 - title: document.title, 209 - selector: selector, 210 - }, 211 - }) 212 - .then((response) => { 213 - if (response?.success) { 214 - showNotification("Text highlighted!", "success"); 732 + function renderBadges(annotations) { 733 + if (!sidebarShadow) return; 215 734 216 - if (CSS.highlights) { 217 - try { 218 - const range = selection.getRangeAt(0); 219 - const highlight = new Highlight(range); 220 - CSS.highlights.set("margin-highlight-preview", highlight); 221 - } catch (e) { 222 - console.warn("Could not visually highlight:", e); 223 - } 224 - } 735 + const itemsToRender = annotations || []; 736 + activeItems = []; 737 + const rangesByColor = {}; 225 738 226 - window.getSelection().removeAllRanges(); 227 - } else { 228 - showNotification( 229 - "Failed to highlight: " + (response?.error || "Unknown error"), 230 - "error", 231 - ); 232 - } 233 - sendResponse(response); 234 - }) 235 - .catch((err) => { 236 - console.error("Highlight error:", err); 237 - showNotification("Error creating highlight", "error"); 238 - sendResponse({ success: false, error: err.message }); 239 - }); 240 - return true; 739 + const matcher = new DOMTextMatcher(); 740 + 741 + itemsToRender.forEach((item) => { 742 + const selector = item.target?.selector || item.selector; 743 + if (!selector?.exact) return; 744 + 745 + const range = matcher.findRange(selector.exact); 746 + if (range) { 747 + activeItems.push({ range, item }); 748 + 749 + const color = item.color || "#6366f1"; 750 + if (!rangesByColor[color]) rangesByColor[color] = []; 751 + rangesByColor[color].push(range); 241 752 } 242 - sendResponse({ success: false, error: "Could not build selector" }); 243 - return true; 244 - } 753 + }); 245 754 246 - if (request.type === "SCROLL_TO_TEXT") { 247 - const found = findAndScrollToText(request.selector); 248 - if (!found) { 249 - showNotification("Could not find text on page", "error"); 755 + if (typeof CSS !== "undefined" && CSS.highlights) { 756 + CSS.highlights.clear(); 757 + for (const [color, ranges] of Object.entries(rangesByColor)) { 758 + const highlight = new Highlight(...ranges); 759 + const safeColor = color.replace(/[^a-zA-Z0-9]/g, ""); 760 + const name = `margin-hl-${safeColor}`; 761 + CSS.highlights.set(name, highlight); 762 + injectHighlightStyle(name, color); 250 763 } 251 764 } 765 + } 252 766 253 - if (request.type === "RENDER_HIGHLIGHTS") { 254 - renderPageHighlights(request.highlights); 767 + const injectedStyles = new Set(); 768 + function injectHighlightStyle(name, color) { 769 + if (injectedStyles.has(name)) return; 770 + const style = document.createElement("style"); 771 + style.textContent = ` 772 + ::highlight(${name}) { 773 + text-decoration: underline; 774 + text-decoration-color: ${color}; 775 + text-decoration-thickness: 2px; 776 + text-underline-offset: 2px; 777 + cursor: pointer; 778 + } 779 + `; 780 + document.head.appendChild(style); 781 + injectedStyles.add(name); 782 + } 783 + 784 + function showPopover(items, top, left) { 785 + if (popoverEl) popoverEl.remove(); 786 + const container = sidebarShadow.getElementById("margin-overlay-container"); 787 + popoverEl = document.createElement("div"); 788 + popoverEl.className = "margin-popover"; 789 + 790 + const ids = items 791 + .map((i) => i.uri || i.id) 792 + .sort() 793 + .join(","); 794 + popoverEl.dataset.itemIds = ids; 795 + 796 + const popWidth = 320; 797 + const screenWidth = window.innerWidth; 798 + let finalLeft = left; 799 + if (left + popWidth > screenWidth) finalLeft = screenWidth - popWidth - 20; 800 + 801 + popoverEl.style.top = `${top + 20}px`; 802 + popoverEl.style.left = `${finalLeft}px`; 803 + 804 + const hasHighlights = items.some((item) => item.type === "Highlight"); 805 + const hasAnnotations = items.some((item) => item.type !== "Highlight"); 806 + let title; 807 + if (items.length > 1) { 808 + if (hasHighlights && hasAnnotations) { 809 + title = `${items.length} Items`; 810 + } else if (hasHighlights) { 811 + title = `${items.length} Highlights`; 812 + } else { 813 + title = `${items.length} Annotations`; 814 + } 815 + } else { 816 + title = items[0]?.type === "Highlight" ? "Highlight" : "Annotation"; 255 817 } 256 818 257 - return true; 258 - }); 819 + let contentHtml = items 820 + .map((item) => { 821 + const author = item.author || item.creator || {}; 822 + const handle = author.handle || "User"; 823 + const avatar = author.avatar; 824 + const text = item.body?.value || item.text || ""; 825 + const quote = 826 + item.target?.selector?.exact || item.selector?.exact || ""; 827 + const id = item.id || item.uri; 259 828 260 - function showNotification(message, type = "info") { 261 - const existing = document.querySelector(".margin-notification"); 262 - if (existing) existing.remove(); 829 + let avatarHtml = `<div class="popover-avatar">${handle[0]?.toUpperCase() || "U"}</div>`; 830 + if (avatar) { 831 + avatarHtml = `<img src="${avatar}" class="popover-avatar" style="object-fit: cover;">`; 832 + } 263 833 264 - const notification = document.createElement("div"); 265 - notification.className = "margin-notification"; 266 - notification.textContent = message; 834 + const isHighlight = item.type === "Highlight"; 835 + 836 + let bodyHtml = ""; 837 + if (isHighlight) { 838 + bodyHtml = `<div class="popover-text" style="font-style: italic; color: #a1a1aa;">"${quote}"</div>`; 839 + } else { 840 + bodyHtml = `<div class="popover-text">${text}</div>`; 841 + if (quote) { 842 + bodyHtml += `<div class="popover-quote">"${quote}"</div>`; 843 + } 844 + } 267 845 268 - const bgColor = 269 - type === "success" ? "#10b981" : type === "error" ? "#ef4444" : "#6366f1"; 270 - notification.style.cssText = ` 271 - position: fixed; 272 - bottom: 24px; 273 - right: 24px; 274 - padding: 12px 20px; 275 - background: ${bgColor}; 276 - color: white; 277 - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; 278 - font-size: 14px; 279 - font-weight: 500; 280 - border-radius: 8px; 281 - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); 282 - z-index: 999999; 283 - animation: margin-slide-in 0.2s ease; 846 + return ` 847 + <div class="popover-item-block"> 848 + <div class="popover-item-header"> 849 + <div class="popover-author"> 850 + ${avatarHtml} 851 + <span class="popover-handle">@${handle}</span> 852 + </div> 853 + </div> 854 + <div class="popover-content"> 855 + ${bodyHtml} 856 + </div> 857 + <div class="popover-actions"> 858 + ${!isHighlight ? `<button class="btn-action btn-reply" data-id="${id}">Reply</button>` : ""} 859 + <button class="btn-action btn-share" data-id="${id}" data-text="${text}" data-quote="${quote}">Share</button> 860 + </div> 861 + </div> 284 862 `; 863 + }) 864 + .join(""); 285 865 286 - document.body.appendChild(notification); 866 + popoverEl.innerHTML = ` 867 + <div class="popover-header"> 868 + <span>${title}</span> 869 + <button class="popover-close">โœ•</button> 870 + </div> 871 + <div class="popover-scroll-area"> 872 + ${contentHtml} 873 + </div> 874 + `; 287 875 288 - setTimeout(() => { 289 - notification.style.animation = "margin-slide-out 0.2s ease forwards"; 290 - setTimeout(() => notification.remove(), 200); 291 - }, 3000); 292 - } 876 + popoverEl.querySelector(".popover-close").addEventListener("click", (e) => { 877 + e.stopPropagation(); 878 + popoverEl.remove(); 879 + popoverEl = null; 880 + }); 293 881 294 - const style = document.createElement("style"); 295 - style.textContent = ` 296 - @keyframes margin-slide-in { 297 - from { opacity: 0; transform: translateY(10px); } 298 - to { opacity: 1; transform: translateY(0); } 882 + const replyBtns = popoverEl.querySelectorAll(".btn-reply"); 883 + replyBtns.forEach((btn) => { 884 + btn.addEventListener("click", (e) => { 885 + e.stopPropagation(); 886 + const id = btn.getAttribute("data-id"); 887 + if (id) { 888 + chrome.runtime.sendMessage({ 889 + type: "OPEN_APP_URL", 890 + data: { path: `/annotation/${encodeURIComponent(id)}` }, 891 + }); 299 892 } 300 - @keyframes margin-slide-out { 301 - from { opacity: 1; transform: translateY(0); } 302 - to { opacity: 0; transform: translateY(10px); } 893 + }); 894 + }); 895 + 896 + const shareBtns = popoverEl.querySelectorAll(".btn-share"); 897 + shareBtns.forEach((btn) => { 898 + btn.addEventListener("click", async () => { 899 + const id = btn.getAttribute("data-id"); 900 + const text = btn.getAttribute("data-text"); 901 + const quote = btn.getAttribute("data-quote"); 902 + const u = `https://margin.at/annotation/${encodeURIComponent(id)}`; 903 + const shareText = `${text ? text + "\n" : ""}${quote ? `"${quote}"\n` : ""}${u}`; 904 + 905 + try { 906 + await navigator.clipboard.writeText(shareText); 907 + const originalText = btn.innerText; 908 + btn.innerText = "Copied!"; 909 + setTimeout(() => (btn.innerText = originalText), 2000); 910 + } catch (e) { 911 + console.error("Failed to copy", e); 303 912 } 304 - ::highlight(margin-highlight-preview) { 305 - background-color: rgba(168, 85, 247, 0.3); 306 - color: inherit; 913 + }); 914 + }); 915 + 916 + container.appendChild(popoverEl); 917 + 918 + setTimeout(() => { 919 + document.addEventListener("click", closePopoverOutside); 920 + }, 0); 921 + } 922 + 923 + function closePopoverOutside() { 924 + if (popoverEl) { 925 + popoverEl.remove(); 926 + popoverEl = null; 927 + document.removeEventListener("click", closePopoverOutside); 928 + } 929 + } 930 + 931 + function fetchAnnotations(retryCount = 0) { 932 + if (typeof chrome !== "undefined" && chrome.runtime) { 933 + chrome.runtime.sendMessage( 934 + { 935 + type: "GET_ANNOTATIONS", 936 + data: { url: window.location.href }, 937 + }, 938 + (res) => { 939 + if (res && res.success && res.data && res.data.length > 0) { 940 + renderBadges(res.data); 941 + } else if (retryCount < 3) { 942 + setTimeout( 943 + () => fetchAnnotations(retryCount + 1), 944 + 1000 * (retryCount + 1), 945 + ); 946 + } 947 + }, 948 + ); 949 + } 950 + } 951 + 952 + chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { 953 + if (request.type === "GET_SELECTOR_FOR_ANNOTATE_INLINE") { 954 + const sel = window.getSelection(); 955 + if (!sel || !sel.toString()) { 956 + sendResponse({ selector: null }); 957 + return true; 958 + } 959 + const exact = sel.toString().trim(); 960 + sendResponse({ selector: { type: "TextQuoteSelector", exact } }); 961 + return true; 962 + } 963 + 964 + if (request.type === "SHOW_INLINE_ANNOTATE") { 965 + currentSelection = { 966 + text: request.data.selector?.exact || "", 967 + selector: request.data.selector, 968 + url: request.data.url, 969 + title: request.data.title, 970 + }; 971 + showInlineComposeModal(); 972 + sendResponse({ success: true }); 973 + return true; 974 + } 975 + 976 + if (request.type === "UPDATE_OVERLAY_VISIBILITY") { 977 + if (sidebarHost) { 978 + sidebarHost.style.display = request.show ? "block" : "none"; 979 + } 980 + if (request.show) { 981 + fetchAnnotations(); 982 + } else { 983 + if (typeof CSS !== "undefined" && CSS.highlights) { 984 + CSS.highlights.clear(); 307 985 } 308 - ::highlight(margin-scroll-highlight) { 309 - background-color: rgba(99, 102, 241, 0.4); 310 - color: inherit; 986 + } 987 + sendResponse({ success: true }); 988 + return true; 989 + } 990 + 991 + if (request.type === "SCROLL_TO_TEXT") { 992 + const selector = request.selector; 993 + if (selector?.exact) { 994 + const matcher = new DOMTextMatcher(); 995 + const range = matcher.findRange(selector.exact); 996 + if (range) { 997 + const rect = range.getBoundingClientRect(); 998 + window.scrollTo({ 999 + top: window.scrollY + rect.top - window.innerHeight / 3, 1000 + behavior: "smooth", 1001 + }); 1002 + const highlight = new Highlight(range); 1003 + CSS.highlights.set("margin-scroll-flash", highlight); 1004 + injectHighlightStyle("margin-scroll-flash", "#8b5cf6"); 1005 + setTimeout(() => CSS.highlights.delete("margin-scroll-flash"), 2000); 311 1006 } 312 - ::highlight(margin-page-highlights) { 313 - background-color: rgba(252, 211, 77, 0.3); 314 - color: inherit; 1007 + } 1008 + } 1009 + return true; 1010 + }); 1011 + 1012 + if (document.readyState === "loading") { 1013 + document.addEventListener("DOMContentLoaded", initOverlay); 1014 + } else { 1015 + initOverlay(); 1016 + } 1017 + 1018 + window.addEventListener("load", () => { 1019 + if (typeof chrome !== "undefined" && chrome.storage) { 1020 + chrome.storage.local.get(["showOverlay"], (result) => { 1021 + if (result.showOverlay !== false) { 1022 + setTimeout(() => fetchAnnotations(), 500); 315 1023 } 316 - `; 317 - document.head.appendChild(style); 1024 + }); 1025 + } else { 1026 + setTimeout(() => fetchAnnotations(), 500); 1027 + } 1028 + }); 318 1029 })();
+25
extension/eslint.config.js
··· 1 + import js from "@eslint/js"; 2 + import globals from "globals"; 3 + 4 + export default [ 5 + { ignores: ["dist"] }, 6 + { 7 + files: ["**/*.js"], 8 + languageOptions: { 9 + ecmaVersion: 2020, 10 + globals: { 11 + ...globals.browser, 12 + ...globals.webextensions, 13 + }, 14 + parserOptions: { 15 + ecmaVersion: "latest", 16 + sourceType: "module", 17 + }, 18 + }, 19 + rules: { 20 + ...js.configs.recommended.rules, 21 + "no-unused-vars": ["warn", { argsIgnorePattern: "^_" }], 22 + "no-undef": "warn", 23 + }, 24 + }, 25 + ];
+19 -1
extension/icons/site.webmanifest
··· 1 - {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} 1 + { 2 + "name": "Margin", 3 + "short_name": "Margin", 4 + "icons": [ 5 + { 6 + "src": "/android-chrome-192x192.png", 7 + "sizes": "192x192", 8 + "type": "image/png" 9 + }, 10 + { 11 + "src": "/android-chrome-512x512.png", 12 + "sizes": "512x512", 13 + "type": "image/png" 14 + } 15 + ], 16 + "theme_color": "#ffffff", 17 + "background_color": "#ffffff", 18 + "display": "standalone" 19 + }
+4 -4
extension/manifest.firefox.json
··· 50 50 "browser_specific_settings": { 51 51 "gecko": { 52 52 "id": "hello@margin.at", 53 - "strict_min_version": "109.0" 54 - }, 55 - "gecko_android": { 56 - "strict_min_version": "113.0" 53 + "strict_min_version": "140.0", 54 + "data_collection_permissions": { 55 + "required": ["none"] 56 + } 57 57 } 58 58 } 59 59 }
+1091
extension/package-lock.json
··· 1 + { 2 + "name": "margin-extension", 3 + "version": "0.1.0", 4 + "lockfileVersion": 3, 5 + "requires": true, 6 + "packages": { 7 + "": { 8 + "name": "margin-extension", 9 + "version": "0.1.0", 10 + "devDependencies": { 11 + "@eslint/js": "^9.39.2", 12 + "eslint": "^9.39.2", 13 + "globals": "^17.0.0" 14 + } 15 + }, 16 + "node_modules/@eslint-community/eslint-utils": { 17 + "version": "4.9.1", 18 + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", 19 + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", 20 + "dev": true, 21 + "license": "MIT", 22 + "dependencies": { 23 + "eslint-visitor-keys": "^3.4.3" 24 + }, 25 + "engines": { 26 + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" 27 + }, 28 + "funding": { 29 + "url": "https://opencollective.com/eslint" 30 + }, 31 + "peerDependencies": { 32 + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" 33 + } 34 + }, 35 + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { 36 + "version": "3.4.3", 37 + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", 38 + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", 39 + "dev": true, 40 + "license": "Apache-2.0", 41 + "engines": { 42 + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" 43 + }, 44 + "funding": { 45 + "url": "https://opencollective.com/eslint" 46 + } 47 + }, 48 + "node_modules/@eslint-community/regexpp": { 49 + "version": "4.12.2", 50 + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", 51 + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", 52 + "dev": true, 53 + "license": "MIT", 54 + "engines": { 55 + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" 56 + } 57 + }, 58 + "node_modules/@eslint/config-array": { 59 + "version": "0.21.1", 60 + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", 61 + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", 62 + "dev": true, 63 + "license": "Apache-2.0", 64 + "dependencies": { 65 + "@eslint/object-schema": "^2.1.7", 66 + "debug": "^4.3.1", 67 + "minimatch": "^3.1.2" 68 + }, 69 + "engines": { 70 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 71 + } 72 + }, 73 + "node_modules/@eslint/config-helpers": { 74 + "version": "0.4.2", 75 + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", 76 + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", 77 + "dev": true, 78 + "license": "Apache-2.0", 79 + "dependencies": { 80 + "@eslint/core": "^0.17.0" 81 + }, 82 + "engines": { 83 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 84 + } 85 + }, 86 + "node_modules/@eslint/core": { 87 + "version": "0.17.0", 88 + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", 89 + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", 90 + "dev": true, 91 + "license": "Apache-2.0", 92 + "dependencies": { 93 + "@types/json-schema": "^7.0.15" 94 + }, 95 + "engines": { 96 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 97 + } 98 + }, 99 + "node_modules/@eslint/eslintrc": { 100 + "version": "3.3.3", 101 + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", 102 + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", 103 + "dev": true, 104 + "license": "MIT", 105 + "dependencies": { 106 + "ajv": "^6.12.4", 107 + "debug": "^4.3.2", 108 + "espree": "^10.0.1", 109 + "globals": "^14.0.0", 110 + "ignore": "^5.2.0", 111 + "import-fresh": "^3.2.1", 112 + "js-yaml": "^4.1.1", 113 + "minimatch": "^3.1.2", 114 + "strip-json-comments": "^3.1.1" 115 + }, 116 + "engines": { 117 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 118 + }, 119 + "funding": { 120 + "url": "https://opencollective.com/eslint" 121 + } 122 + }, 123 + "node_modules/@eslint/eslintrc/node_modules/globals": { 124 + "version": "14.0.0", 125 + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", 126 + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", 127 + "dev": true, 128 + "license": "MIT", 129 + "engines": { 130 + "node": ">=18" 131 + }, 132 + "funding": { 133 + "url": "https://github.com/sponsors/sindresorhus" 134 + } 135 + }, 136 + "node_modules/@eslint/js": { 137 + "version": "9.39.2", 138 + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", 139 + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", 140 + "dev": true, 141 + "license": "MIT", 142 + "engines": { 143 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 144 + }, 145 + "funding": { 146 + "url": "https://eslint.org/donate" 147 + } 148 + }, 149 + "node_modules/@eslint/object-schema": { 150 + "version": "2.1.7", 151 + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", 152 + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", 153 + "dev": true, 154 + "license": "Apache-2.0", 155 + "engines": { 156 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 157 + } 158 + }, 159 + "node_modules/@eslint/plugin-kit": { 160 + "version": "0.4.1", 161 + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", 162 + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", 163 + "dev": true, 164 + "license": "Apache-2.0", 165 + "dependencies": { 166 + "@eslint/core": "^0.17.0", 167 + "levn": "^0.4.1" 168 + }, 169 + "engines": { 170 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 171 + } 172 + }, 173 + "node_modules/@humanfs/core": { 174 + "version": "0.19.1", 175 + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", 176 + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", 177 + "dev": true, 178 + "license": "Apache-2.0", 179 + "engines": { 180 + "node": ">=18.18.0" 181 + } 182 + }, 183 + "node_modules/@humanfs/node": { 184 + "version": "0.16.7", 185 + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", 186 + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", 187 + "dev": true, 188 + "license": "Apache-2.0", 189 + "dependencies": { 190 + "@humanfs/core": "^0.19.1", 191 + "@humanwhocodes/retry": "^0.4.0" 192 + }, 193 + "engines": { 194 + "node": ">=18.18.0" 195 + } 196 + }, 197 + "node_modules/@humanwhocodes/module-importer": { 198 + "version": "1.0.1", 199 + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", 200 + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", 201 + "dev": true, 202 + "license": "Apache-2.0", 203 + "engines": { 204 + "node": ">=12.22" 205 + }, 206 + "funding": { 207 + "type": "github", 208 + "url": "https://github.com/sponsors/nzakas" 209 + } 210 + }, 211 + "node_modules/@humanwhocodes/retry": { 212 + "version": "0.4.3", 213 + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", 214 + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", 215 + "dev": true, 216 + "license": "Apache-2.0", 217 + "engines": { 218 + "node": ">=18.18" 219 + }, 220 + "funding": { 221 + "type": "github", 222 + "url": "https://github.com/sponsors/nzakas" 223 + } 224 + }, 225 + "node_modules/@types/estree": { 226 + "version": "1.0.8", 227 + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", 228 + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", 229 + "dev": true, 230 + "license": "MIT" 231 + }, 232 + "node_modules/@types/json-schema": { 233 + "version": "7.0.15", 234 + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", 235 + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", 236 + "dev": true, 237 + "license": "MIT" 238 + }, 239 + "node_modules/acorn": { 240 + "version": "8.15.0", 241 + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", 242 + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", 243 + "dev": true, 244 + "license": "MIT", 245 + "peer": true, 246 + "bin": { 247 + "acorn": "bin/acorn" 248 + }, 249 + "engines": { 250 + "node": ">=0.4.0" 251 + } 252 + }, 253 + "node_modules/acorn-jsx": { 254 + "version": "5.3.2", 255 + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", 256 + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", 257 + "dev": true, 258 + "license": "MIT", 259 + "peerDependencies": { 260 + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" 261 + } 262 + }, 263 + "node_modules/ajv": { 264 + "version": "6.12.6", 265 + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", 266 + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", 267 + "dev": true, 268 + "license": "MIT", 269 + "dependencies": { 270 + "fast-deep-equal": "^3.1.1", 271 + "fast-json-stable-stringify": "^2.0.0", 272 + "json-schema-traverse": "^0.4.1", 273 + "uri-js": "^4.2.2" 274 + }, 275 + "funding": { 276 + "type": "github", 277 + "url": "https://github.com/sponsors/epoberezkin" 278 + } 279 + }, 280 + "node_modules/ansi-styles": { 281 + "version": "4.3.0", 282 + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", 283 + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", 284 + "dev": true, 285 + "license": "MIT", 286 + "dependencies": { 287 + "color-convert": "^2.0.1" 288 + }, 289 + "engines": { 290 + "node": ">=8" 291 + }, 292 + "funding": { 293 + "url": "https://github.com/chalk/ansi-styles?sponsor=1" 294 + } 295 + }, 296 + "node_modules/argparse": { 297 + "version": "2.0.1", 298 + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", 299 + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", 300 + "dev": true, 301 + "license": "Python-2.0" 302 + }, 303 + "node_modules/balanced-match": { 304 + "version": "1.0.2", 305 + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", 306 + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", 307 + "dev": true, 308 + "license": "MIT" 309 + }, 310 + "node_modules/brace-expansion": { 311 + "version": "1.1.12", 312 + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", 313 + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", 314 + "dev": true, 315 + "license": "MIT", 316 + "dependencies": { 317 + "balanced-match": "^1.0.0", 318 + "concat-map": "0.0.1" 319 + } 320 + }, 321 + "node_modules/callsites": { 322 + "version": "3.1.0", 323 + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", 324 + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", 325 + "dev": true, 326 + "license": "MIT", 327 + "engines": { 328 + "node": ">=6" 329 + } 330 + }, 331 + "node_modules/chalk": { 332 + "version": "4.1.2", 333 + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", 334 + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", 335 + "dev": true, 336 + "license": "MIT", 337 + "dependencies": { 338 + "ansi-styles": "^4.1.0", 339 + "supports-color": "^7.1.0" 340 + }, 341 + "engines": { 342 + "node": ">=10" 343 + }, 344 + "funding": { 345 + "url": "https://github.com/chalk/chalk?sponsor=1" 346 + } 347 + }, 348 + "node_modules/color-convert": { 349 + "version": "2.0.1", 350 + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", 351 + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", 352 + "dev": true, 353 + "license": "MIT", 354 + "dependencies": { 355 + "color-name": "~1.1.4" 356 + }, 357 + "engines": { 358 + "node": ">=7.0.0" 359 + } 360 + }, 361 + "node_modules/color-name": { 362 + "version": "1.1.4", 363 + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", 364 + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", 365 + "dev": true, 366 + "license": "MIT" 367 + }, 368 + "node_modules/concat-map": { 369 + "version": "0.0.1", 370 + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", 371 + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", 372 + "dev": true, 373 + "license": "MIT" 374 + }, 375 + "node_modules/cross-spawn": { 376 + "version": "7.0.6", 377 + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", 378 + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", 379 + "dev": true, 380 + "license": "MIT", 381 + "dependencies": { 382 + "path-key": "^3.1.0", 383 + "shebang-command": "^2.0.0", 384 + "which": "^2.0.1" 385 + }, 386 + "engines": { 387 + "node": ">= 8" 388 + } 389 + }, 390 + "node_modules/debug": { 391 + "version": "4.4.3", 392 + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", 393 + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", 394 + "dev": true, 395 + "license": "MIT", 396 + "dependencies": { 397 + "ms": "^2.1.3" 398 + }, 399 + "engines": { 400 + "node": ">=6.0" 401 + }, 402 + "peerDependenciesMeta": { 403 + "supports-color": { 404 + "optional": true 405 + } 406 + } 407 + }, 408 + "node_modules/deep-is": { 409 + "version": "0.1.4", 410 + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", 411 + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", 412 + "dev": true, 413 + "license": "MIT" 414 + }, 415 + "node_modules/escape-string-regexp": { 416 + "version": "4.0.0", 417 + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", 418 + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", 419 + "dev": true, 420 + "license": "MIT", 421 + "engines": { 422 + "node": ">=10" 423 + }, 424 + "funding": { 425 + "url": "https://github.com/sponsors/sindresorhus" 426 + } 427 + }, 428 + "node_modules/eslint": { 429 + "version": "9.39.2", 430 + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", 431 + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", 432 + "dev": true, 433 + "license": "MIT", 434 + "peer": true, 435 + "dependencies": { 436 + "@eslint-community/eslint-utils": "^4.8.0", 437 + "@eslint-community/regexpp": "^4.12.1", 438 + "@eslint/config-array": "^0.21.1", 439 + "@eslint/config-helpers": "^0.4.2", 440 + "@eslint/core": "^0.17.0", 441 + "@eslint/eslintrc": "^3.3.1", 442 + "@eslint/js": "9.39.2", 443 + "@eslint/plugin-kit": "^0.4.1", 444 + "@humanfs/node": "^0.16.6", 445 + "@humanwhocodes/module-importer": "^1.0.1", 446 + "@humanwhocodes/retry": "^0.4.2", 447 + "@types/estree": "^1.0.6", 448 + "ajv": "^6.12.4", 449 + "chalk": "^4.0.0", 450 + "cross-spawn": "^7.0.6", 451 + "debug": "^4.3.2", 452 + "escape-string-regexp": "^4.0.0", 453 + "eslint-scope": "^8.4.0", 454 + "eslint-visitor-keys": "^4.2.1", 455 + "espree": "^10.4.0", 456 + "esquery": "^1.5.0", 457 + "esutils": "^2.0.2", 458 + "fast-deep-equal": "^3.1.3", 459 + "file-entry-cache": "^8.0.0", 460 + "find-up": "^5.0.0", 461 + "glob-parent": "^6.0.2", 462 + "ignore": "^5.2.0", 463 + "imurmurhash": "^0.1.4", 464 + "is-glob": "^4.0.0", 465 + "json-stable-stringify-without-jsonify": "^1.0.1", 466 + "lodash.merge": "^4.6.2", 467 + "minimatch": "^3.1.2", 468 + "natural-compare": "^1.4.0", 469 + "optionator": "^0.9.3" 470 + }, 471 + "bin": { 472 + "eslint": "bin/eslint.js" 473 + }, 474 + "engines": { 475 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 476 + }, 477 + "funding": { 478 + "url": "https://eslint.org/donate" 479 + }, 480 + "peerDependencies": { 481 + "jiti": "*" 482 + }, 483 + "peerDependenciesMeta": { 484 + "jiti": { 485 + "optional": true 486 + } 487 + } 488 + }, 489 + "node_modules/eslint-scope": { 490 + "version": "8.4.0", 491 + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", 492 + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", 493 + "dev": true, 494 + "license": "BSD-2-Clause", 495 + "dependencies": { 496 + "esrecurse": "^4.3.0", 497 + "estraverse": "^5.2.0" 498 + }, 499 + "engines": { 500 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 501 + }, 502 + "funding": { 503 + "url": "https://opencollective.com/eslint" 504 + } 505 + }, 506 + "node_modules/eslint-visitor-keys": { 507 + "version": "4.2.1", 508 + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", 509 + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", 510 + "dev": true, 511 + "license": "Apache-2.0", 512 + "engines": { 513 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 514 + }, 515 + "funding": { 516 + "url": "https://opencollective.com/eslint" 517 + } 518 + }, 519 + "node_modules/espree": { 520 + "version": "10.4.0", 521 + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", 522 + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", 523 + "dev": true, 524 + "license": "BSD-2-Clause", 525 + "dependencies": { 526 + "acorn": "^8.15.0", 527 + "acorn-jsx": "^5.3.2", 528 + "eslint-visitor-keys": "^4.2.1" 529 + }, 530 + "engines": { 531 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 532 + }, 533 + "funding": { 534 + "url": "https://opencollective.com/eslint" 535 + } 536 + }, 537 + "node_modules/esquery": { 538 + "version": "1.7.0", 539 + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", 540 + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", 541 + "dev": true, 542 + "license": "BSD-3-Clause", 543 + "dependencies": { 544 + "estraverse": "^5.1.0" 545 + }, 546 + "engines": { 547 + "node": ">=0.10" 548 + } 549 + }, 550 + "node_modules/esrecurse": { 551 + "version": "4.3.0", 552 + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", 553 + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", 554 + "dev": true, 555 + "license": "BSD-2-Clause", 556 + "dependencies": { 557 + "estraverse": "^5.2.0" 558 + }, 559 + "engines": { 560 + "node": ">=4.0" 561 + } 562 + }, 563 + "node_modules/estraverse": { 564 + "version": "5.3.0", 565 + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", 566 + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", 567 + "dev": true, 568 + "license": "BSD-2-Clause", 569 + "engines": { 570 + "node": ">=4.0" 571 + } 572 + }, 573 + "node_modules/esutils": { 574 + "version": "2.0.3", 575 + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", 576 + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", 577 + "dev": true, 578 + "license": "BSD-2-Clause", 579 + "engines": { 580 + "node": ">=0.10.0" 581 + } 582 + }, 583 + "node_modules/fast-deep-equal": { 584 + "version": "3.1.3", 585 + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", 586 + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", 587 + "dev": true, 588 + "license": "MIT" 589 + }, 590 + "node_modules/fast-json-stable-stringify": { 591 + "version": "2.1.0", 592 + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", 593 + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", 594 + "dev": true, 595 + "license": "MIT" 596 + }, 597 + "node_modules/fast-levenshtein": { 598 + "version": "2.0.6", 599 + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", 600 + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", 601 + "dev": true, 602 + "license": "MIT" 603 + }, 604 + "node_modules/file-entry-cache": { 605 + "version": "8.0.0", 606 + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", 607 + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", 608 + "dev": true, 609 + "license": "MIT", 610 + "dependencies": { 611 + "flat-cache": "^4.0.0" 612 + }, 613 + "engines": { 614 + "node": ">=16.0.0" 615 + } 616 + }, 617 + "node_modules/find-up": { 618 + "version": "5.0.0", 619 + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", 620 + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", 621 + "dev": true, 622 + "license": "MIT", 623 + "dependencies": { 624 + "locate-path": "^6.0.0", 625 + "path-exists": "^4.0.0" 626 + }, 627 + "engines": { 628 + "node": ">=10" 629 + }, 630 + "funding": { 631 + "url": "https://github.com/sponsors/sindresorhus" 632 + } 633 + }, 634 + "node_modules/flat-cache": { 635 + "version": "4.0.1", 636 + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", 637 + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", 638 + "dev": true, 639 + "license": "MIT", 640 + "dependencies": { 641 + "flatted": "^3.2.9", 642 + "keyv": "^4.5.4" 643 + }, 644 + "engines": { 645 + "node": ">=16" 646 + } 647 + }, 648 + "node_modules/flatted": { 649 + "version": "3.3.3", 650 + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", 651 + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", 652 + "dev": true, 653 + "license": "ISC" 654 + }, 655 + "node_modules/glob-parent": { 656 + "version": "6.0.2", 657 + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", 658 + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", 659 + "dev": true, 660 + "license": "ISC", 661 + "dependencies": { 662 + "is-glob": "^4.0.3" 663 + }, 664 + "engines": { 665 + "node": ">=10.13.0" 666 + } 667 + }, 668 + "node_modules/globals": { 669 + "version": "17.0.0", 670 + "resolved": "https://registry.npmjs.org/globals/-/globals-17.0.0.tgz", 671 + "integrity": "sha512-gv5BeD2EssA793rlFWVPMMCqefTlpusw6/2TbAVMy0FzcG8wKJn4O+NqJ4+XWmmwrayJgw5TzrmWjFgmz1XPqw==", 672 + "dev": true, 673 + "license": "MIT", 674 + "engines": { 675 + "node": ">=18" 676 + }, 677 + "funding": { 678 + "url": "https://github.com/sponsors/sindresorhus" 679 + } 680 + }, 681 + "node_modules/has-flag": { 682 + "version": "4.0.0", 683 + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", 684 + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", 685 + "dev": true, 686 + "license": "MIT", 687 + "engines": { 688 + "node": ">=8" 689 + } 690 + }, 691 + "node_modules/ignore": { 692 + "version": "5.3.2", 693 + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", 694 + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", 695 + "dev": true, 696 + "license": "MIT", 697 + "engines": { 698 + "node": ">= 4" 699 + } 700 + }, 701 + "node_modules/import-fresh": { 702 + "version": "3.3.1", 703 + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", 704 + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", 705 + "dev": true, 706 + "license": "MIT", 707 + "dependencies": { 708 + "parent-module": "^1.0.0", 709 + "resolve-from": "^4.0.0" 710 + }, 711 + "engines": { 712 + "node": ">=6" 713 + }, 714 + "funding": { 715 + "url": "https://github.com/sponsors/sindresorhus" 716 + } 717 + }, 718 + "node_modules/imurmurhash": { 719 + "version": "0.1.4", 720 + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", 721 + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", 722 + "dev": true, 723 + "license": "MIT", 724 + "engines": { 725 + "node": ">=0.8.19" 726 + } 727 + }, 728 + "node_modules/is-extglob": { 729 + "version": "2.1.1", 730 + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", 731 + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", 732 + "dev": true, 733 + "license": "MIT", 734 + "engines": { 735 + "node": ">=0.10.0" 736 + } 737 + }, 738 + "node_modules/is-glob": { 739 + "version": "4.0.3", 740 + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", 741 + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", 742 + "dev": true, 743 + "license": "MIT", 744 + "dependencies": { 745 + "is-extglob": "^2.1.1" 746 + }, 747 + "engines": { 748 + "node": ">=0.10.0" 749 + } 750 + }, 751 + "node_modules/isexe": { 752 + "version": "2.0.0", 753 + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", 754 + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", 755 + "dev": true, 756 + "license": "ISC" 757 + }, 758 + "node_modules/js-yaml": { 759 + "version": "4.1.1", 760 + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", 761 + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", 762 + "dev": true, 763 + "license": "MIT", 764 + "dependencies": { 765 + "argparse": "^2.0.1" 766 + }, 767 + "bin": { 768 + "js-yaml": "bin/js-yaml.js" 769 + } 770 + }, 771 + "node_modules/json-buffer": { 772 + "version": "3.0.1", 773 + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", 774 + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", 775 + "dev": true, 776 + "license": "MIT" 777 + }, 778 + "node_modules/json-schema-traverse": { 779 + "version": "0.4.1", 780 + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", 781 + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", 782 + "dev": true, 783 + "license": "MIT" 784 + }, 785 + "node_modules/json-stable-stringify-without-jsonify": { 786 + "version": "1.0.1", 787 + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", 788 + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", 789 + "dev": true, 790 + "license": "MIT" 791 + }, 792 + "node_modules/keyv": { 793 + "version": "4.5.4", 794 + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", 795 + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", 796 + "dev": true, 797 + "license": "MIT", 798 + "dependencies": { 799 + "json-buffer": "3.0.1" 800 + } 801 + }, 802 + "node_modules/levn": { 803 + "version": "0.4.1", 804 + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", 805 + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", 806 + "dev": true, 807 + "license": "MIT", 808 + "dependencies": { 809 + "prelude-ls": "^1.2.1", 810 + "type-check": "~0.4.0" 811 + }, 812 + "engines": { 813 + "node": ">= 0.8.0" 814 + } 815 + }, 816 + "node_modules/locate-path": { 817 + "version": "6.0.0", 818 + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", 819 + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", 820 + "dev": true, 821 + "license": "MIT", 822 + "dependencies": { 823 + "p-locate": "^5.0.0" 824 + }, 825 + "engines": { 826 + "node": ">=10" 827 + }, 828 + "funding": { 829 + "url": "https://github.com/sponsors/sindresorhus" 830 + } 831 + }, 832 + "node_modules/lodash.merge": { 833 + "version": "4.6.2", 834 + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", 835 + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", 836 + "dev": true, 837 + "license": "MIT" 838 + }, 839 + "node_modules/minimatch": { 840 + "version": "3.1.2", 841 + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", 842 + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", 843 + "dev": true, 844 + "license": "ISC", 845 + "dependencies": { 846 + "brace-expansion": "^1.1.7" 847 + }, 848 + "engines": { 849 + "node": "*" 850 + } 851 + }, 852 + "node_modules/ms": { 853 + "version": "2.1.3", 854 + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", 855 + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", 856 + "dev": true, 857 + "license": "MIT" 858 + }, 859 + "node_modules/natural-compare": { 860 + "version": "1.4.0", 861 + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", 862 + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", 863 + "dev": true, 864 + "license": "MIT" 865 + }, 866 + "node_modules/optionator": { 867 + "version": "0.9.4", 868 + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", 869 + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", 870 + "dev": true, 871 + "license": "MIT", 872 + "dependencies": { 873 + "deep-is": "^0.1.3", 874 + "fast-levenshtein": "^2.0.6", 875 + "levn": "^0.4.1", 876 + "prelude-ls": "^1.2.1", 877 + "type-check": "^0.4.0", 878 + "word-wrap": "^1.2.5" 879 + }, 880 + "engines": { 881 + "node": ">= 0.8.0" 882 + } 883 + }, 884 + "node_modules/p-limit": { 885 + "version": "3.1.0", 886 + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", 887 + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", 888 + "dev": true, 889 + "license": "MIT", 890 + "dependencies": { 891 + "yocto-queue": "^0.1.0" 892 + }, 893 + "engines": { 894 + "node": ">=10" 895 + }, 896 + "funding": { 897 + "url": "https://github.com/sponsors/sindresorhus" 898 + } 899 + }, 900 + "node_modules/p-locate": { 901 + "version": "5.0.0", 902 + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", 903 + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", 904 + "dev": true, 905 + "license": "MIT", 906 + "dependencies": { 907 + "p-limit": "^3.0.2" 908 + }, 909 + "engines": { 910 + "node": ">=10" 911 + }, 912 + "funding": { 913 + "url": "https://github.com/sponsors/sindresorhus" 914 + } 915 + }, 916 + "node_modules/parent-module": { 917 + "version": "1.0.1", 918 + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", 919 + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", 920 + "dev": true, 921 + "license": "MIT", 922 + "dependencies": { 923 + "callsites": "^3.0.0" 924 + }, 925 + "engines": { 926 + "node": ">=6" 927 + } 928 + }, 929 + "node_modules/path-exists": { 930 + "version": "4.0.0", 931 + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", 932 + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", 933 + "dev": true, 934 + "license": "MIT", 935 + "engines": { 936 + "node": ">=8" 937 + } 938 + }, 939 + "node_modules/path-key": { 940 + "version": "3.1.1", 941 + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", 942 + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", 943 + "dev": true, 944 + "license": "MIT", 945 + "engines": { 946 + "node": ">=8" 947 + } 948 + }, 949 + "node_modules/prelude-ls": { 950 + "version": "1.2.1", 951 + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", 952 + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", 953 + "dev": true, 954 + "license": "MIT", 955 + "engines": { 956 + "node": ">= 0.8.0" 957 + } 958 + }, 959 + "node_modules/punycode": { 960 + "version": "2.3.1", 961 + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", 962 + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", 963 + "dev": true, 964 + "license": "MIT", 965 + "engines": { 966 + "node": ">=6" 967 + } 968 + }, 969 + "node_modules/resolve-from": { 970 + "version": "4.0.0", 971 + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", 972 + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", 973 + "dev": true, 974 + "license": "MIT", 975 + "engines": { 976 + "node": ">=4" 977 + } 978 + }, 979 + "node_modules/shebang-command": { 980 + "version": "2.0.0", 981 + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", 982 + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", 983 + "dev": true, 984 + "license": "MIT", 985 + "dependencies": { 986 + "shebang-regex": "^3.0.0" 987 + }, 988 + "engines": { 989 + "node": ">=8" 990 + } 991 + }, 992 + "node_modules/shebang-regex": { 993 + "version": "3.0.0", 994 + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", 995 + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", 996 + "dev": true, 997 + "license": "MIT", 998 + "engines": { 999 + "node": ">=8" 1000 + } 1001 + }, 1002 + "node_modules/strip-json-comments": { 1003 + "version": "3.1.1", 1004 + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", 1005 + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", 1006 + "dev": true, 1007 + "license": "MIT", 1008 + "engines": { 1009 + "node": ">=8" 1010 + }, 1011 + "funding": { 1012 + "url": "https://github.com/sponsors/sindresorhus" 1013 + } 1014 + }, 1015 + "node_modules/supports-color": { 1016 + "version": "7.2.0", 1017 + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", 1018 + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", 1019 + "dev": true, 1020 + "license": "MIT", 1021 + "dependencies": { 1022 + "has-flag": "^4.0.0" 1023 + }, 1024 + "engines": { 1025 + "node": ">=8" 1026 + } 1027 + }, 1028 + "node_modules/type-check": { 1029 + "version": "0.4.0", 1030 + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", 1031 + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", 1032 + "dev": true, 1033 + "license": "MIT", 1034 + "dependencies": { 1035 + "prelude-ls": "^1.2.1" 1036 + }, 1037 + "engines": { 1038 + "node": ">= 0.8.0" 1039 + } 1040 + }, 1041 + "node_modules/uri-js": { 1042 + "version": "4.4.1", 1043 + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", 1044 + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", 1045 + "dev": true, 1046 + "license": "BSD-2-Clause", 1047 + "dependencies": { 1048 + "punycode": "^2.1.0" 1049 + } 1050 + }, 1051 + "node_modules/which": { 1052 + "version": "2.0.2", 1053 + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", 1054 + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", 1055 + "dev": true, 1056 + "license": "ISC", 1057 + "dependencies": { 1058 + "isexe": "^2.0.0" 1059 + }, 1060 + "bin": { 1061 + "node-which": "bin/node-which" 1062 + }, 1063 + "engines": { 1064 + "node": ">= 8" 1065 + } 1066 + }, 1067 + "node_modules/word-wrap": { 1068 + "version": "1.2.5", 1069 + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", 1070 + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", 1071 + "dev": true, 1072 + "license": "MIT", 1073 + "engines": { 1074 + "node": ">=0.10.0" 1075 + } 1076 + }, 1077 + "node_modules/yocto-queue": { 1078 + "version": "0.1.0", 1079 + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", 1080 + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", 1081 + "dev": true, 1082 + "license": "MIT", 1083 + "engines": { 1084 + "node": ">=10" 1085 + }, 1086 + "funding": { 1087 + "url": "https://github.com/sponsors/sindresorhus" 1088 + } 1089 + } 1090 + } 1091 + }
+14
extension/package.json
··· 1 + { 2 + "name": "margin-extension", 3 + "version": "0.1.0", 4 + "private": true, 5 + "type": "module", 6 + "scripts": { 7 + "lint": "eslint ." 8 + }, 9 + "devDependencies": { 10 + "@eslint/js": "^9.39.2", 11 + "eslint": "^9.39.2", 12 + "globals": "^17.0.0" 13 + } 14 + }
+86 -20
extension/popup/popup.css
··· 1 1 :root { 2 - --bg-primary: #0c0a14; 3 - --bg-secondary: #14111f; 4 - --bg-tertiary: #1a1528; 5 - --bg-card: #14111f; 6 - --bg-hover: #1e1932; 7 - 8 - --text-primary: #f4f0ff; 9 - --text-secondary: #a89ec8; 10 - --text-tertiary: #6b5f8a; 11 - 12 - --accent: #a855f7; 13 - --accent-hover: #c084fc; 14 - --accent-subtle: rgba(168, 85, 247, 0.15); 2 + --bg-primary: #09090b; 3 + --bg-secondary: #0f0f12; 4 + --bg-tertiary: #18181b; 5 + --bg-card: #09090b; 6 + --bg-elevated: #18181b; 7 + --bg-hover: #27272a; 15 8 16 - --border: #2d2640; 17 - --border-hover: #3d3560; 9 + --text-primary: #e4e4e7; 10 + --text-secondary: #a1a1aa; 11 + --text-tertiary: #71717a; 12 + --border: #27272a; 13 + --border-hover: #3f3f46; 18 14 19 - --success: #22c55e; 20 - --danger: #ef4444; 15 + --accent: #6366f1; 16 + --accent-hover: #4f46e5; 17 + --accent-subtle: rgba(99, 102, 241, 0.1); 18 + --accent-text: #818cf8; 19 + --success: #10b981; 20 + --error: #ef4444; 21 21 --warning: #f59e0b; 22 22 23 - --radius-sm: 6px; 24 - --radius-md: 10px; 25 - --radius-lg: 16px; 23 + --radius-sm: 4px; 24 + --radius-md: 6px; 25 + --radius-lg: 8px; 26 + --radius-full: 9999px; 27 + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); 28 + --shadow-md: 29 + 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); 26 30 } 27 31 28 32 * { ··· 644 648 gap: 8px; 645 649 margin-left: auto; 646 650 } 651 + 652 + .toggle-switch { 653 + position: relative; 654 + display: inline-block; 655 + width: 44px; 656 + height: 24px; 657 + flex-shrink: 0; 658 + } 659 + 660 + .toggle-switch input { 661 + opacity: 0; 662 + width: 0; 663 + height: 0; 664 + } 665 + 666 + .toggle-slider { 667 + position: absolute; 668 + cursor: pointer; 669 + top: 0; 670 + left: 0; 671 + right: 0; 672 + bottom: 0; 673 + background-color: var(--border); 674 + transition: 0.2s; 675 + border-radius: 24px; 676 + } 677 + 678 + .toggle-slider:before { 679 + position: absolute; 680 + content: ""; 681 + height: 18px; 682 + width: 18px; 683 + left: 3px; 684 + bottom: 3px; 685 + background-color: var(--text-secondary); 686 + transition: 0.2s; 687 + border-radius: 50%; 688 + } 689 + 690 + .toggle-switch input:checked + .toggle-slider { 691 + background-color: var(--accent); 692 + } 693 + 694 + .toggle-switch input:checked + .toggle-slider:before { 695 + transform: translateX(20px); 696 + background-color: white; 697 + } 698 + 699 + .settings-input { 700 + width: 100%; 701 + padding: 10px 12px; 702 + background: var(--bg-tertiary); 703 + border: 1px solid var(--border); 704 + border-radius: var(--radius-md); 705 + color: var(--text-primary); 706 + font-size: 13px; 707 + } 708 + 709 + .settings-input:focus { 710 + outline: none; 711 + border-color: var(--accent); 712 + }
+20
extension/popup/popup.html
··· 218 218 <button id="close-settings" class="btn-icon">ร—</button> 219 219 </div> 220 220 <div class="setting-item"> 221 + <div 222 + style=" 223 + display: flex; 224 + justify-content: space-between; 225 + align-items: center; 226 + " 227 + > 228 + <div> 229 + <label>Show page overlays</label> 230 + <p class="setting-help" style="margin-top: 2px"> 231 + Highlights, badges, and tooltips on pages 232 + </p> 233 + </div> 234 + <label class="toggle-switch"> 235 + <input type="checkbox" id="overlay-toggle" checked /> 236 + <span class="toggle-slider"></span> 237 + </label> 238 + </div> 239 + </div> 240 + <div class="setting-item"> 221 241 <label for="api-url">API URL (for self-hosting)</label> 222 242 <input 223 243 type="url"
+37 -18
extension/popup/popup.js
··· 39 39 collectionList: document.getElementById("collection-list"), 40 40 collectionLoading: document.getElementById("collection-loading"), 41 41 collectionsEmpty: document.getElementById("collections-empty"), 42 + overlayToggle: document.getElementById("overlay-toggle"), 42 43 }; 43 44 44 45 let currentTab = null; 45 46 let apiUrl = "https://margin.at"; 46 47 let currentUserDid = null; 47 48 let pendingSelector = null; 48 - let activeAnnotationUriForCollection = null; 49 + // let _activeAnnotationUriForCollection = null; 49 50 50 - const storage = await browserAPI.storage.local.get(["apiUrl"]); 51 + const storage = await browserAPI.storage.local.get(["apiUrl", "showOverlay"]); 51 52 if (storage.apiUrl) { 52 53 apiUrl = storage.apiUrl; 53 54 } 54 55 els.apiUrlInput.value = apiUrl; 56 + 57 + if (els.overlayToggle) { 58 + els.overlayToggle.checked = storage.showOverlay !== false; 59 + } 55 60 56 61 try { 57 62 const [tab] = await browserAPI.tabs.query({ ··· 74 79 pendingData = sessionData.pendingAnnotation; 75 80 await browserAPI.storage.session.remove(["pendingAnnotation"]); 76 81 } 77 - } catch (e) {} 82 + } catch { 83 + /* ignore */ 84 + } 78 85 } 79 86 80 87 if (!pendingData) { ··· 209 216 210 217 els.saveSettings?.addEventListener("click", async () => { 211 218 const newUrl = els.apiUrlInput.value.replace(/\/$/, ""); 219 + const showOverlay = els.overlayToggle?.checked ?? true; 220 + 221 + await browserAPI.storage.local.set({ apiUrl: newUrl, showOverlay }); 212 222 if (newUrl) { 213 - await browserAPI.storage.local.set({ apiUrl: newUrl }); 214 223 apiUrl = newUrl; 215 - await sendMessage({ type: "UPDATE_SETTINGS" }); 216 - views.settings.style.display = "none"; 217 - checkSession(); 224 + } 225 + await sendMessage({ type: "UPDATE_SETTINGS" }); 226 + 227 + const tabs = await browserAPI.tabs.query({}); 228 + for (const tab of tabs) { 229 + if (tab.id) { 230 + try { 231 + await browserAPI.tabs.sendMessage(tab.id, { 232 + type: "UPDATE_OVERLAY_VISIBILITY", 233 + show: showOverlay, 234 + }); 235 + } catch { 236 + /* ignore */ 237 + } 238 + } 218 239 } 240 + 241 + views.settings.style.display = "none"; 242 + checkSession(); 219 243 }); 220 244 221 245 els.closeCollectionSelector?.addEventListener("click", () => { 222 246 views.collectionSelector.style.display = "none"; 223 - activeAnnotationUriForCollection = null; 224 247 }); 225 248 226 249 async function openCollectionSelector(annotationUri) { ··· 228 251 console.error("No currentUserDid, returning early"); 229 252 return; 230 253 } 231 - activeAnnotationUriForCollection = annotationUri; 232 254 views.collectionSelector.style.display = "flex"; 233 255 els.collectionList.innerHTML = ""; 234 256 els.collectionLoading.style.display = "block"; ··· 358 380 const res = await sendMessage({ type: "CHECK_SESSION" }); 359 381 360 382 if (res.success && res.data?.authenticated) { 361 - if (els.userHandle) els.userHandle.textContent = "@" + res.data.handle; 383 + if (els.userHandle) { 384 + const handle = res.data.handle || res.data.email || "User"; 385 + els.userHandle.textContent = "@" + handle; 386 + } 362 387 els.userInfo.style.display = "flex"; 363 388 currentUserDid = res.data.did; 364 389 showView("main"); ··· 504 529 const actions = document.createElement("div"); 505 530 actions.className = "annotation-item-actions"; 506 531 507 - if ( 508 - item.author?.did === currentUserDid || 509 - item.creator?.did === currentUserDid 510 - ) { 532 + if (currentUserDid) { 511 533 const folderBtn = document.createElement("button"); 512 534 folderBtn.className = "btn-icon"; 513 535 folderBtn.innerHTML = ··· 577 599 578 600 row.appendChild(content); 579 601 580 - if ( 581 - item.author?.did === currentUserDid || 582 - item.creator?.did === currentUserDid 583 - ) { 602 + if (currentUserDid) { 584 603 const folderBtn = document.createElement("button"); 585 604 folderBtn.className = "btn-icon"; 586 605 folderBtn.innerHTML =
+217 -20
extension/sidepanel/sidepanel.css
··· 1 1 :root { 2 - --bg-primary: #0c0a14; 3 - --bg-secondary: #110e1c; 4 - --bg-tertiary: #1a1528; 5 - --bg-card: #14111f; 6 - --bg-hover: #1e1932; 7 - --bg-elevated: #1a1528; 2 + --bg-primary: #09090b; 3 + --bg-secondary: #0f0f12; 4 + --bg-tertiary: #18181b; 5 + --bg-card: #09090b; 6 + --bg-hover: #18181b; 7 + --bg-elevated: #18181b; 8 8 9 - --text-primary: #f4f0ff; 10 - --text-secondary: #a89ec8; 11 - --text-tertiary: #6b5f8a; 9 + --text-primary: #e4e4e7; 10 + --text-secondary: #a1a1aa; 11 + --text-tertiary: #71717a; 12 12 13 - --accent: #a855f7; 14 - --accent-hover: #c084fc; 15 - --accent-subtle: rgba(168, 85, 247, 0.15); 13 + --accent: #6366f1; 14 + --accent-hover: #4f46e5; 15 + --accent-subtle: rgba(99, 102, 241, 0.1); 16 + --accent-text: #818cf8; 16 17 17 - --border: #2d2640; 18 - --border-hover: #3d3560; 18 + --border: #27272a; 19 + --border-hover: #3f3f46; 19 20 20 - --success: #22c55e; 21 + --success: #10b981; 21 22 --error: #ef4444; 22 23 --warning: #f59e0b; 23 24 24 - --radius-sm: 6px; 25 - --radius-md: 10px; 26 - --radius-lg: 16px; 25 + --radius-sm: 4px; 26 + --radius-md: 6px; 27 + --radius-lg: 8px; 27 28 --radius-full: 9999px; 28 29 29 - --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3); 30 - --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4); 30 + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); 31 + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1); 32 + } 33 + 34 + * { 35 + margin: 0; 36 + padding: 0; 37 + box-sizing: border-box; 38 + } 39 + 40 + body { 41 + font-family: 42 + "Inter", 43 + -apple-system, 44 + BlinkMacSystemFont, 45 + "Segoe UI", 46 + sans-serif; 47 + background: var(--bg-primary); 48 + color: var(--text-primary); 49 + min-height: 100vh; 50 + -webkit-font-smoothing: antialiased; 51 + } 52 + 53 + .sidebar { 54 + display: flex; 55 + flex-direction: column; 56 + height: 100vh; 57 + background: var(--bg-primary); 58 + } 59 + 60 + .sidebar-header { 61 + display: flex; 62 + align-items: center; 63 + justify-content: space-between; 64 + padding: 14px 16px; 65 + border-bottom: 1px solid var(--border); 66 + background: var(--bg-primary); 67 + } 68 + 69 + .user-handle { 70 + font-size: 12px; 71 + color: var(--text-secondary); 72 + background: var(--bg-tertiary); 73 + padding: 4px 8px; 74 + border-radius: var(--radius-sm); 75 + } 76 + 77 + .current-page-info { 78 + display: flex; 79 + align-items: center; 80 + gap: 8px; 81 + padding: 10px 16px; 82 + background: var(--bg-primary); 83 + border-bottom: 1px solid var(--border); 84 + } 85 + 86 + .tabs { 87 + display: flex; 88 + border-bottom: 1px solid var(--border); 89 + background: var(--bg-primary); 90 + padding: 4px; 91 + gap: 4px; 92 + margin: 0; 93 + } 94 + 95 + .tab-btn { 96 + flex: 1; 97 + padding: 10px 8px; 98 + background: transparent; 99 + border: none; 100 + font-size: 12px; 101 + font-weight: 500; 102 + color: var(--text-secondary); 103 + cursor: pointer; 104 + border-radius: var(--radius-sm); 105 + transition: all 0.15s; 106 + } 107 + 108 + .tab-btn:hover { 109 + color: var(--text-primary); 110 + background: var(--bg-hover); 111 + } 112 + 113 + .tab-btn.active { 114 + color: var(--text-primary); 115 + background: var(--bg-tertiary); 116 + box-shadow: none; 117 + } 118 + 119 + .quick-actions { 120 + display: flex; 121 + gap: 8px; 122 + padding: 12px 16px; 123 + border-bottom: 1px solid var(--border); 124 + background: var(--bg-primary); 125 + } 126 + 127 + .create-form { 128 + padding: 16px; 129 + border-bottom: 1px solid var(--border); 130 + background: var(--bg-primary); 131 + } 132 + 133 + .section-header { 134 + display: flex; 135 + justify-content: space-between; 136 + align-items: center; 137 + padding: 14px 16px; 138 + background: var(--bg-primary); 139 + border-bottom: 1px solid var(--border); 140 + } 141 + 142 + .annotation-item { 143 + border: 1px solid var(--border); 144 + border-radius: var(--radius-md); 145 + padding: 12px; 146 + background: var(--bg-primary); 147 + transition: border-color 0.15s; 148 + } 149 + 150 + .annotation-item:hover { 151 + border-color: var(--border-hover); 152 + background: var(--bg-hover); 153 + } 154 + 155 + .sidebar-footer { 156 + display: flex; 157 + align-items: center; 158 + justify-content: space-between; 159 + padding: 12px 16px; 160 + border-top: 1px solid var(--border); 161 + background: var(--bg-primary); 162 + } 163 + 164 + ::-webkit-scrollbar { 165 + width: 10px; 166 + height: 10px; 167 + } 168 + 169 + ::-webkit-scrollbar-track { 170 + background: transparent; 171 + } 172 + 173 + ::-webkit-scrollbar-thumb { 174 + background: var(--border); 175 + border-radius: 5px; 176 + border: 2px solid var(--bg-primary); 177 + } 178 + 179 + ::-webkit-scrollbar-thumb:hover { 180 + background: var(--border-hover); 31 181 } 32 182 33 183 * { ··· 732 882 gap: 8px; 733 883 margin-left: auto; 734 884 } 885 + 886 + .toggle-switch { 887 + position: relative; 888 + display: inline-block; 889 + width: 44px; 890 + height: 24px; 891 + flex-shrink: 0; 892 + } 893 + 894 + .toggle-switch input { 895 + opacity: 0; 896 + width: 0; 897 + height: 0; 898 + } 899 + 900 + .toggle-slider { 901 + position: absolute; 902 + cursor: pointer; 903 + top: 0; 904 + left: 0; 905 + right: 0; 906 + bottom: 0; 907 + background-color: var(--border); 908 + transition: 0.2s; 909 + border-radius: 24px; 910 + } 911 + 912 + .toggle-slider:before { 913 + position: absolute; 914 + content: ""; 915 + height: 18px; 916 + width: 18px; 917 + left: 3px; 918 + bottom: 3px; 919 + background-color: var(--text-secondary); 920 + transition: 0.2s; 921 + border-radius: 50%; 922 + } 923 + 924 + .toggle-switch input:checked + .toggle-slider { 925 + background-color: var(--accent); 926 + } 927 + 928 + .toggle-switch input:checked + .toggle-slider:before { 929 + transform: translateX(20px); 930 + background-color: white; 931 + }
+20
extension/sidepanel/sidepanel.html
··· 250 250 <button id="close-settings" class="btn-icon">ร—</button> 251 251 </div> 252 252 <div class="setting-item"> 253 + <div 254 + style=" 255 + display: flex; 256 + justify-content: space-between; 257 + align-items: center; 258 + " 259 + > 260 + <div> 261 + <label>Show page overlays</label> 262 + <p class="setting-help" style="margin-top: 2px"> 263 + Display highlights, badges, and tooltips on pages 264 + </p> 265 + </div> 266 + <label class="toggle-switch"> 267 + <input type="checkbox" id="overlay-toggle" checked /> 268 + <span class="toggle-slider"></span> 269 + </label> 270 + </div> 271 + </div> 272 + <div class="setting-item"> 253 273 <label for="api-url">API URL</label> 254 274 <input 255 275 type="url"
+36 -21
extension/sidepanel/sidepanel.js
··· 37 37 collectionList: document.getElementById("collection-list"), 38 38 collectionLoading: document.getElementById("collection-loading"), 39 39 collectionsEmpty: document.getElementById("collections-empty"), 40 + overlayToggle: document.getElementById("overlay-toggle"), 40 41 }; 41 42 42 43 let currentTab = null; 43 44 let apiUrl = ""; 44 45 let currentUserDid = null; 45 46 let pendingSelector = null; 46 - let activeAnnotationUriForCollection = null; 47 47 48 48 const storage = await chrome.storage.local.get(["apiUrl"]); 49 49 if (storage.apiUrl) { ··· 51 51 } 52 52 53 53 els.apiUrlInput.value = apiUrl; 54 + 55 + const overlayStorage = await chrome.storage.local.get(["showOverlay"]); 56 + if (els.overlayToggle) { 57 + els.overlayToggle.checked = overlayStorage.showOverlay !== false; 58 + } 54 59 55 60 chrome.storage.onChanged.addListener((changes, area) => { 56 61 if (area === "local" && changes.apiUrl) { ··· 253 258 254 259 els.closeCollectionSelector?.addEventListener("click", () => { 255 260 views.collectionSelector.style.display = "none"; 256 - activeAnnotationUriForCollection = null; 257 261 }); 258 262 259 263 els.saveSettings?.addEventListener("click", async () => { 260 264 const newUrl = els.apiUrlInput.value.replace(/\/$/, ""); 265 + const showOverlay = els.overlayToggle?.checked ?? true; 266 + 267 + await chrome.storage.local.set({ apiUrl: newUrl, showOverlay }); 261 268 if (newUrl) { 262 - await chrome.storage.local.set({ apiUrl: newUrl }); 263 269 apiUrl = newUrl; 264 - await sendMessage({ type: "UPDATE_SETTINGS" }); 265 - views.settings.style.display = "none"; 266 - checkSession(); 270 + } 271 + await sendMessage({ type: "UPDATE_SETTINGS" }); 272 + 273 + const tabs = await chrome.tabs.query({}); 274 + for (const tab of tabs) { 275 + if (tab.id) { 276 + try { 277 + await chrome.tabs.sendMessage(tab.id, { 278 + type: "UPDATE_OVERLAY_VISIBILITY", 279 + show: showOverlay, 280 + }); 281 + } catch { 282 + /* ignore */ 283 + } 284 + } 267 285 } 286 + 287 + views.settings.style.display = "none"; 288 + checkSession(); 268 289 }); 269 290 270 291 els.signOutBtn?.addEventListener("click", async () => { ··· 367 388 console.error("No currentUserDid, returning early"); 368 389 return; 369 390 } 370 - activeAnnotationUriForCollection = annotationUri; 371 391 views.collectionSelector.style.display = "flex"; 372 392 els.collectionList.innerHTML = ""; 373 393 els.collectionLoading.style.display = "block"; ··· 561 581 header.appendChild(badge); 562 582 } 563 583 564 - if ( 565 - item.author?.did === currentUserDid || 566 - item.creator?.did === currentUserDid 567 - ) { 584 + if (currentUserDid) { 568 585 const actions = document.createElement("div"); 569 586 actions.className = "annotation-item-actions"; 570 587 ··· 635 652 let hostname = item.source; 636 653 try { 637 654 hostname = new URL(item.source).hostname; 638 - } catch {} 655 + } catch { 656 + /* ignore */ 657 + } 639 658 640 659 const row = document.createElement("div"); 641 660 row.style.display = "flex"; ··· 658 677 659 678 row.appendChild(content); 660 679 661 - if ( 662 - item.author?.did === currentUserDid || 663 - item.creator?.did === currentUserDid 664 - ) { 680 + if (currentUserDid) { 665 681 const folderBtn = document.createElement("button"); 666 682 folderBtn.className = "btn-icon"; 667 683 folderBtn.innerHTML = ··· 701 717 let hostname = url; 702 718 try { 703 719 hostname = new URL(url).hostname; 704 - } catch {} 720 + } catch { 721 + /* ignore */ 722 + } 705 723 706 724 const header = document.createElement("div"); 707 725 header.className = "annotation-item-header"; ··· 721 739 722 740 header.appendChild(meta); 723 741 724 - if ( 725 - item.author?.did === currentUserDid || 726 - item.creator?.did === currentUserDid 727 - ) { 742 + if (currentUserDid) { 728 743 const actions = document.createElement("div"); 729 744 actions.className = "annotation-item-actions"; 730 745
+9 -28
lexicons/at/margin/annotation.json
··· 10 10 "key": "tid", 11 11 "record": { 12 12 "type": "object", 13 - "required": [ 14 - "target", 15 - "createdAt" 16 - ], 13 + "required": ["target", "createdAt"], 17 14 "properties": { 18 15 "motivation": { 19 16 "type": "string", ··· 87 84 "target": { 88 85 "type": "object", 89 86 "description": "W3C SpecificResource - the target with optional selector", 90 - "required": [ 91 - "source" 92 - ], 87 + "required": ["source"], 93 88 "properties": { 94 89 "source": { 95 90 "type": "string", ··· 127 122 "textQuoteSelector": { 128 123 "type": "object", 129 124 "description": "W3C TextQuoteSelector - select text by quoting it with context", 130 - "required": [ 131 - "exact" 132 - ], 125 + "required": ["exact"], 133 126 "properties": { 134 127 "type": { 135 128 "type": "string", ··· 158 151 "textPositionSelector": { 159 152 "type": "object", 160 153 "description": "W3C TextPositionSelector - select by character offsets", 161 - "required": [ 162 - "start", 163 - "end" 164 - ], 154 + "required": ["start", "end"], 165 155 "properties": { 166 156 "type": { 167 157 "type": "string", ··· 182 172 "cssSelector": { 183 173 "type": "object", 184 174 "description": "W3C CssSelector - select DOM elements by CSS selector", 185 - "required": [ 186 - "value" 187 - ], 175 + "required": ["value"], 188 176 "properties": { 189 177 "type": { 190 178 "type": "string", ··· 200 188 "xpathSelector": { 201 189 "type": "object", 202 190 "description": "W3C XPathSelector - select by XPath expression", 203 - "required": [ 204 - "value" 205 - ], 191 + "required": ["value"], 206 192 "properties": { 207 193 "type": { 208 194 "type": "string", ··· 218 204 "fragmentSelector": { 219 205 "type": "object", 220 206 "description": "W3C FragmentSelector - select by URI fragment", 221 - "required": [ 222 - "value" 223 - ], 207 + "required": ["value"], 224 208 "properties": { 225 209 "type": { 226 210 "type": "string", ··· 241 225 "rangeSelector": { 242 226 "type": "object", 243 227 "description": "W3C RangeSelector - select range between two selectors", 244 - "required": [ 245 - "startSelector", 246 - "endSelector" 247 - ], 228 + "required": ["startSelector", "endSelector"], 248 229 "properties": { 249 230 "type": { 250 231 "type": "string", ··· 289 270 } 290 271 } 291 272 } 292 - } 273 + }
+49 -52
lexicons/at/margin/bookmark.json
··· 1 1 { 2 - "lexicon": 1, 3 - "id": "at.margin.bookmark", 4 - "description": "A bookmark record - save URL for later", 5 - "defs": { 6 - "main": { 7 - "type": "record", 8 - "description": "A bookmarked URL (motivation: bookmarking)", 9 - "key": "tid", 10 - "record": { 11 - "type": "object", 12 - "required": [ 13 - "source", 14 - "createdAt" 15 - ], 16 - "properties": { 17 - "source": { 18 - "type": "string", 19 - "format": "uri", 20 - "description": "The bookmarked URL" 21 - }, 22 - "sourceHash": { 23 - "type": "string", 24 - "description": "SHA256 hash of normalized URL for indexing" 25 - }, 26 - "title": { 27 - "type": "string", 28 - "maxLength": 500, 29 - "description": "Page title" 30 - }, 31 - "description": { 32 - "type": "string", 33 - "maxLength": 1000, 34 - "maxGraphemes": 300, 35 - "description": "Optional description/note" 36 - }, 37 - "tags": { 38 - "type": "array", 39 - "description": "Tags for categorization", 40 - "items": { 41 - "type": "string", 42 - "maxLength": 64, 43 - "maxGraphemes": 32 44 - }, 45 - "maxLength": 10 46 - }, 47 - "createdAt": { 48 - "type": "string", 49 - "format": "datetime" 50 - } 51 - } 52 - } 2 + "lexicon": 1, 3 + "id": "at.margin.bookmark", 4 + "description": "A bookmark record - save URL for later", 5 + "defs": { 6 + "main": { 7 + "type": "record", 8 + "description": "A bookmarked URL (motivation: bookmarking)", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": ["source", "createdAt"], 13 + "properties": { 14 + "source": { 15 + "type": "string", 16 + "format": "uri", 17 + "description": "The bookmarked URL" 18 + }, 19 + "sourceHash": { 20 + "type": "string", 21 + "description": "SHA256 hash of normalized URL for indexing" 22 + }, 23 + "title": { 24 + "type": "string", 25 + "maxLength": 500, 26 + "description": "Page title" 27 + }, 28 + "description": { 29 + "type": "string", 30 + "maxLength": 1000, 31 + "maxGraphemes": 300, 32 + "description": "Optional description/note" 33 + }, 34 + "tags": { 35 + "type": "array", 36 + "description": "Tags for categorization", 37 + "items": { 38 + "type": "string", 39 + "maxLength": 64, 40 + "maxGraphemes": 32 41 + }, 42 + "maxLength": 10 43 + }, 44 + "createdAt": { 45 + "type": "string", 46 + "format": "datetime" 47 + } 53 48 } 49 + } 54 50 } 55 - } 51 + } 52 + }
+37 -40
lexicons/at/margin/collection.json
··· 1 1 { 2 - "lexicon": 1, 3 - "id": "at.margin.collection", 4 - "description": "A collection of annotations (like a folder or notebook)", 5 - "defs": { 6 - "main": { 7 - "type": "record", 8 - "description": "A named collection for organizing annotations", 9 - "key": "tid", 10 - "record": { 11 - "type": "object", 12 - "required": [ 13 - "name", 14 - "createdAt" 15 - ], 16 - "properties": { 17 - "name": { 18 - "type": "string", 19 - "maxLength": 100, 20 - "maxGraphemes": 50, 21 - "description": "Collection name" 22 - }, 23 - "description": { 24 - "type": "string", 25 - "maxLength": 500, 26 - "maxGraphemes": 150, 27 - "description": "Collection description" 28 - }, 29 - "icon": { 30 - "type": "string", 31 - "maxLength": 10, 32 - "maxGraphemes": 2, 33 - "description": "Emoji icon for the collection" 34 - }, 35 - "createdAt": { 36 - "type": "string", 37 - "format": "datetime" 38 - } 39 - } 40 - } 2 + "lexicon": 1, 3 + "id": "at.margin.collection", 4 + "description": "A collection of annotations (like a folder or notebook)", 5 + "defs": { 6 + "main": { 7 + "type": "record", 8 + "description": "A named collection for organizing annotations", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": ["name", "createdAt"], 13 + "properties": { 14 + "name": { 15 + "type": "string", 16 + "maxLength": 100, 17 + "maxGraphemes": 50, 18 + "description": "Collection name" 19 + }, 20 + "description": { 21 + "type": "string", 22 + "maxLength": 500, 23 + "maxGraphemes": 150, 24 + "description": "Collection description" 25 + }, 26 + "icon": { 27 + "type": "string", 28 + "maxLength": 10, 29 + "maxGraphemes": 2, 30 + "description": "Emoji icon for the collection" 31 + }, 32 + "createdAt": { 33 + "type": "string", 34 + "format": "datetime" 35 + } 41 36 } 37 + } 42 38 } 43 - } 39 + } 40 + }
+34 -38
lexicons/at/margin/collectionItem.json
··· 1 1 { 2 - "lexicon": 1, 3 - "id": "at.margin.collectionItem", 4 - "description": "An item in a collection (links annotation to collection)", 5 - "defs": { 6 - "main": { 7 - "type": "record", 8 - "description": "Associates an annotation with a collection", 9 - "key": "tid", 10 - "record": { 11 - "type": "object", 12 - "required": [ 13 - "collection", 14 - "annotation", 15 - "createdAt" 16 - ], 17 - "properties": { 18 - "collection": { 19 - "type": "string", 20 - "format": "at-uri", 21 - "description": "AT URI of the collection" 22 - }, 23 - "annotation": { 24 - "type": "string", 25 - "format": "at-uri", 26 - "description": "AT URI of the annotation, highlight, or bookmark" 27 - }, 28 - "position": { 29 - "type": "integer", 30 - "minimum": 0, 31 - "description": "Sort order within the collection" 32 - }, 33 - "createdAt": { 34 - "type": "string", 35 - "format": "datetime" 36 - } 37 - } 38 - } 2 + "lexicon": 1, 3 + "id": "at.margin.collectionItem", 4 + "description": "An item in a collection (links annotation to collection)", 5 + "defs": { 6 + "main": { 7 + "type": "record", 8 + "description": "Associates an annotation with a collection", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": ["collection", "annotation", "createdAt"], 13 + "properties": { 14 + "collection": { 15 + "type": "string", 16 + "format": "at-uri", 17 + "description": "AT URI of the collection" 18 + }, 19 + "annotation": { 20 + "type": "string", 21 + "format": "at-uri", 22 + "description": "AT URI of the annotation, highlight, or bookmark" 23 + }, 24 + "position": { 25 + "type": "integer", 26 + "minimum": 0, 27 + "description": "Sort order within the collection" 28 + }, 29 + "createdAt": { 30 + "type": "string", 31 + "format": "datetime" 32 + } 39 33 } 34 + } 40 35 } 41 - } 36 + } 37 + }
+39 -42
lexicons/at/margin/highlight.json
··· 1 1 { 2 - "lexicon": 1, 3 - "id": "at.margin.highlight", 4 - "description": "A lightweight highlight record - annotation without body text", 5 - "defs": { 6 - "main": { 7 - "type": "record", 8 - "description": "A highlight on a web page (motivation: highlighting)", 9 - "key": "tid", 10 - "record": { 11 - "type": "object", 12 - "required": [ 13 - "target", 14 - "createdAt" 15 - ], 16 - "properties": { 17 - "target": { 18 - "type": "ref", 19 - "ref": "at.margin.annotation#target", 20 - "description": "The resource and segment being highlighted" 21 - }, 22 - "color": { 23 - "type": "string", 24 - "description": "Highlight color (hex or named)", 25 - "maxLength": 20 26 - }, 27 - "tags": { 28 - "type": "array", 29 - "description": "Tags for categorization", 30 - "items": { 31 - "type": "string", 32 - "maxLength": 64, 33 - "maxGraphemes": 32 34 - }, 35 - "maxLength": 10 36 - }, 37 - "createdAt": { 38 - "type": "string", 39 - "format": "datetime" 40 - } 41 - } 42 - } 2 + "lexicon": 1, 3 + "id": "at.margin.highlight", 4 + "description": "A lightweight highlight record - annotation without body text", 5 + "defs": { 6 + "main": { 7 + "type": "record", 8 + "description": "A highlight on a web page (motivation: highlighting)", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": ["target", "createdAt"], 13 + "properties": { 14 + "target": { 15 + "type": "ref", 16 + "ref": "at.margin.annotation#target", 17 + "description": "The resource and segment being highlighted" 18 + }, 19 + "color": { 20 + "type": "string", 21 + "description": "Highlight color (hex or named)", 22 + "maxLength": 20 23 + }, 24 + "tags": { 25 + "type": "array", 26 + "description": "Tags for categorization", 27 + "items": { 28 + "type": "string", 29 + "maxLength": 64, 30 + "maxGraphemes": 32 31 + }, 32 + "maxLength": 10 33 + }, 34 + "createdAt": { 35 + "type": "string", 36 + "format": "datetime" 37 + } 43 38 } 39 + } 44 40 } 45 - } 41 + } 42 + }
+36 -42
lexicons/at/margin/like.json
··· 1 1 { 2 - "lexicon": 1, 3 - "id": "at.margin.like", 4 - "defs": { 5 - "main": { 6 - "type": "record", 7 - "description": "A like on an annotation or reply", 8 - "key": "tid", 9 - "record": { 10 - "type": "object", 11 - "required": [ 12 - "subject", 13 - "createdAt" 14 - ], 15 - "properties": { 16 - "subject": { 17 - "type": "ref", 18 - "ref": "#subjectRef", 19 - "description": "Reference to the annotation or reply being liked" 20 - }, 21 - "createdAt": { 22 - "type": "string", 23 - "format": "datetime" 24 - } 25 - } 26 - } 2 + "lexicon": 1, 3 + "id": "at.margin.like", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "A like on an annotation or reply", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": ["subject", "createdAt"], 12 + "properties": { 13 + "subject": { 14 + "type": "ref", 15 + "ref": "#subjectRef", 16 + "description": "Reference to the annotation or reply being liked" 17 + }, 18 + "createdAt": { 19 + "type": "string", 20 + "format": "datetime" 21 + } 22 + } 23 + } 24 + }, 25 + "subjectRef": { 26 + "type": "object", 27 + "required": ["uri", "cid"], 28 + "properties": { 29 + "uri": { 30 + "type": "string", 31 + "format": "at-uri" 27 32 }, 28 - "subjectRef": { 29 - "type": "object", 30 - "required": [ 31 - "uri", 32 - "cid" 33 - ], 34 - "properties": { 35 - "uri": { 36 - "type": "string", 37 - "format": "at-uri" 38 - }, 39 - "cid": { 40 - "type": "string", 41 - "format": "cid" 42 - } 43 - } 33 + "cid": { 34 + "type": "string", 35 + "format": "cid" 44 36 } 37 + } 45 38 } 46 - } 39 + } 40 + }
+55 -63
lexicons/at/margin/reply.json
··· 1 1 { 2 - "lexicon": 1, 3 - "id": "at.margin.reply", 4 - "revision": 2, 5 - "description": "A reply to an annotation or another reply", 6 - "defs": { 7 - "main": { 8 - "type": "record", 9 - "description": "A reply to an annotation (motivation: replying)", 10 - "key": "tid", 11 - "record": { 12 - "type": "object", 13 - "required": [ 14 - "parent", 15 - "root", 16 - "text", 17 - "createdAt" 18 - ], 19 - "properties": { 20 - "parent": { 21 - "type": "ref", 22 - "ref": "#replyRef", 23 - "description": "Reference to the parent annotation or reply" 24 - }, 25 - "root": { 26 - "type": "ref", 27 - "ref": "#replyRef", 28 - "description": "Reference to the root annotation of the thread" 29 - }, 30 - "text": { 31 - "type": "string", 32 - "maxLength": 10000, 33 - "maxGraphemes": 3000, 34 - "description": "Reply text content" 35 - }, 36 - "format": { 37 - "type": "string", 38 - "description": "MIME type of the text content", 39 - "default": "text/plain" 40 - }, 41 - "createdAt": { 42 - "type": "string", 43 - "format": "datetime" 44 - } 45 - } 46 - } 2 + "lexicon": 1, 3 + "id": "at.margin.reply", 4 + "revision": 2, 5 + "description": "A reply to an annotation or another reply", 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "description": "A reply to an annotation (motivation: replying)", 10 + "key": "tid", 11 + "record": { 12 + "type": "object", 13 + "required": ["parent", "root", "text", "createdAt"], 14 + "properties": { 15 + "parent": { 16 + "type": "ref", 17 + "ref": "#replyRef", 18 + "description": "Reference to the parent annotation or reply" 19 + }, 20 + "root": { 21 + "type": "ref", 22 + "ref": "#replyRef", 23 + "description": "Reference to the root annotation of the thread" 24 + }, 25 + "text": { 26 + "type": "string", 27 + "maxLength": 10000, 28 + "maxGraphemes": 3000, 29 + "description": "Reply text content" 30 + }, 31 + "format": { 32 + "type": "string", 33 + "description": "MIME type of the text content", 34 + "default": "text/plain" 35 + }, 36 + "createdAt": { 37 + "type": "string", 38 + "format": "datetime" 39 + } 40 + } 41 + } 42 + }, 43 + "replyRef": { 44 + "type": "object", 45 + "description": "Strong reference to an annotation or reply", 46 + "required": ["uri", "cid"], 47 + "properties": { 48 + "uri": { 49 + "type": "string", 50 + "format": "at-uri" 47 51 }, 48 - "replyRef": { 49 - "type": "object", 50 - "description": "Strong reference to an annotation or reply", 51 - "required": [ 52 - "uri", 53 - "cid" 54 - ], 55 - "properties": { 56 - "uri": { 57 - "type": "string", 58 - "format": "at-uri" 59 - }, 60 - "cid": { 61 - "type": "string", 62 - "format": "cid" 63 - } 64 - } 52 + "cid": { 53 + "type": "string", 54 + "format": "cid" 65 55 } 56 + } 66 57 } 67 - } 58 + } 59 + }
+40
web/eslint.config.js
··· 1 + import js from "@eslint/js"; 2 + import globals from "globals"; 3 + import react from "eslint-plugin-react"; 4 + import reactHooks from "eslint-plugin-react-hooks"; 5 + import reactRefresh from "eslint-plugin-react-refresh"; 6 + 7 + export default [ 8 + { ignores: ["dist"] }, 9 + { 10 + files: ["**/*.{js,jsx}"], 11 + languageOptions: { 12 + ecmaVersion: 2020, 13 + globals: globals.browser, 14 + parserOptions: { 15 + ecmaVersion: "latest", 16 + ecmaFeatures: { jsx: true }, 17 + sourceType: "module", 18 + }, 19 + }, 20 + settings: { react: { version: "18.3" } }, 21 + plugins: { 22 + react, 23 + "react-hooks": reactHooks, 24 + "react-refresh": reactRefresh, 25 + }, 26 + rules: { 27 + ...js.configs.recommended.rules, 28 + ...react.configs.recommended.rules, 29 + ...react.configs["jsx-runtime"].rules, 30 + ...reactHooks.configs.recommended.rules, 31 + "react/jsx-no-target-blank": "off", 32 + "react-refresh/only-export-components": [ 33 + "warn", 34 + { allowConstantExport: true }, 35 + ], 36 + "no-unused-vars": ["warn", { argsIgnorePattern: "^_" }], 37 + "react/prop-types": "off", 38 + }, 39 + }, 40 + ];
+3051 -12
web/package-lock.json
··· 15 15 "react-router-dom": "^6.28.0" 16 16 }, 17 17 "devDependencies": { 18 + "@eslint/js": "^9.39.2", 18 19 "@types/react": "^18.3.12", 19 20 "@types/react-dom": "^18.3.1", 20 21 "@vitejs/plugin-react": "^4.3.3", 22 + "eslint": "^9.39.2", 23 + "eslint-plugin-react": "^7.37.5", 24 + "eslint-plugin-react-hooks": "^7.0.1", 25 + "eslint-plugin-react-refresh": "^0.4.26", 26 + "globals": "^17.0.0", 21 27 "vite": "^6.0.3" 22 28 } 23 29 }, ··· 746 752 "node": ">=18" 747 753 } 748 754 }, 755 + "node_modules/@eslint-community/eslint-utils": { 756 + "version": "4.9.1", 757 + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", 758 + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", 759 + "dev": true, 760 + "license": "MIT", 761 + "dependencies": { 762 + "eslint-visitor-keys": "^3.4.3" 763 + }, 764 + "engines": { 765 + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" 766 + }, 767 + "funding": { 768 + "url": "https://opencollective.com/eslint" 769 + }, 770 + "peerDependencies": { 771 + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" 772 + } 773 + }, 774 + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { 775 + "version": "3.4.3", 776 + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", 777 + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", 778 + "dev": true, 779 + "license": "Apache-2.0", 780 + "engines": { 781 + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" 782 + }, 783 + "funding": { 784 + "url": "https://opencollective.com/eslint" 785 + } 786 + }, 787 + "node_modules/@eslint-community/regexpp": { 788 + "version": "4.12.2", 789 + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", 790 + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", 791 + "dev": true, 792 + "license": "MIT", 793 + "engines": { 794 + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" 795 + } 796 + }, 797 + "node_modules/@eslint/config-array": { 798 + "version": "0.21.1", 799 + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", 800 + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", 801 + "dev": true, 802 + "license": "Apache-2.0", 803 + "dependencies": { 804 + "@eslint/object-schema": "^2.1.7", 805 + "debug": "^4.3.1", 806 + "minimatch": "^3.1.2" 807 + }, 808 + "engines": { 809 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 810 + } 811 + }, 812 + "node_modules/@eslint/config-helpers": { 813 + "version": "0.4.2", 814 + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", 815 + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", 816 + "dev": true, 817 + "license": "Apache-2.0", 818 + "dependencies": { 819 + "@eslint/core": "^0.17.0" 820 + }, 821 + "engines": { 822 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 823 + } 824 + }, 825 + "node_modules/@eslint/core": { 826 + "version": "0.17.0", 827 + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", 828 + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", 829 + "dev": true, 830 + "license": "Apache-2.0", 831 + "dependencies": { 832 + "@types/json-schema": "^7.0.15" 833 + }, 834 + "engines": { 835 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 836 + } 837 + }, 838 + "node_modules/@eslint/eslintrc": { 839 + "version": "3.3.3", 840 + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", 841 + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", 842 + "dev": true, 843 + "license": "MIT", 844 + "dependencies": { 845 + "ajv": "^6.12.4", 846 + "debug": "^4.3.2", 847 + "espree": "^10.0.1", 848 + "globals": "^14.0.0", 849 + "ignore": "^5.2.0", 850 + "import-fresh": "^3.2.1", 851 + "js-yaml": "^4.1.1", 852 + "minimatch": "^3.1.2", 853 + "strip-json-comments": "^3.1.1" 854 + }, 855 + "engines": { 856 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 857 + }, 858 + "funding": { 859 + "url": "https://opencollective.com/eslint" 860 + } 861 + }, 862 + "node_modules/@eslint/eslintrc/node_modules/globals": { 863 + "version": "14.0.0", 864 + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", 865 + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", 866 + "dev": true, 867 + "license": "MIT", 868 + "engines": { 869 + "node": ">=18" 870 + }, 871 + "funding": { 872 + "url": "https://github.com/sponsors/sindresorhus" 873 + } 874 + }, 875 + "node_modules/@eslint/js": { 876 + "version": "9.39.2", 877 + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", 878 + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", 879 + "dev": true, 880 + "license": "MIT", 881 + "engines": { 882 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 883 + }, 884 + "funding": { 885 + "url": "https://eslint.org/donate" 886 + } 887 + }, 888 + "node_modules/@eslint/object-schema": { 889 + "version": "2.1.7", 890 + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", 891 + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", 892 + "dev": true, 893 + "license": "Apache-2.0", 894 + "engines": { 895 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 896 + } 897 + }, 898 + "node_modules/@eslint/plugin-kit": { 899 + "version": "0.4.1", 900 + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", 901 + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", 902 + "dev": true, 903 + "license": "Apache-2.0", 904 + "dependencies": { 905 + "@eslint/core": "^0.17.0", 906 + "levn": "^0.4.1" 907 + }, 908 + "engines": { 909 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 910 + } 911 + }, 912 + "node_modules/@humanfs/core": { 913 + "version": "0.19.1", 914 + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", 915 + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", 916 + "dev": true, 917 + "license": "Apache-2.0", 918 + "engines": { 919 + "node": ">=18.18.0" 920 + } 921 + }, 922 + "node_modules/@humanfs/node": { 923 + "version": "0.16.7", 924 + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", 925 + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", 926 + "dev": true, 927 + "license": "Apache-2.0", 928 + "dependencies": { 929 + "@humanfs/core": "^0.19.1", 930 + "@humanwhocodes/retry": "^0.4.0" 931 + }, 932 + "engines": { 933 + "node": ">=18.18.0" 934 + } 935 + }, 936 + "node_modules/@humanwhocodes/module-importer": { 937 + "version": "1.0.1", 938 + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", 939 + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", 940 + "dev": true, 941 + "license": "Apache-2.0", 942 + "engines": { 943 + "node": ">=12.22" 944 + }, 945 + "funding": { 946 + "type": "github", 947 + "url": "https://github.com/sponsors/nzakas" 948 + } 949 + }, 950 + "node_modules/@humanwhocodes/retry": { 951 + "version": "0.4.3", 952 + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", 953 + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", 954 + "dev": true, 955 + "license": "Apache-2.0", 956 + "engines": { 957 + "node": ">=18.18" 958 + }, 959 + "funding": { 960 + "type": "github", 961 + "url": "https://github.com/sponsors/nzakas" 962 + } 963 + }, 749 964 "node_modules/@jridgewell/gen-mapping": { 750 965 "version": "0.3.13", 751 966 "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", ··· 797 1012 } 798 1013 }, 799 1014 "node_modules/@remix-run/router": { 800 - "version": "1.23.1", 801 - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.1.tgz", 802 - "integrity": "sha512-vDbaOzF7yT2Qs4vO6XV1MHcJv+3dgR1sT+l3B8xxOVhUC336prMvqrvsLL/9Dnw2xr6Qhz4J0dmS0llNAbnUmQ==", 1015 + "version": "1.23.2", 1016 + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", 1017 + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", 803 1018 "license": "MIT", 804 1019 "engines": { 805 1020 "node": ">=14.0.0" ··· 1172 1387 "dev": true, 1173 1388 "license": "MIT" 1174 1389 }, 1390 + "node_modules/@types/json-schema": { 1391 + "version": "7.0.15", 1392 + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", 1393 + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", 1394 + "dev": true, 1395 + "license": "MIT" 1396 + }, 1175 1397 "node_modules/@types/prop-types": { 1176 1398 "version": "15.7.15", 1177 1399 "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", ··· 1222 1444 "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" 1223 1445 } 1224 1446 }, 1447 + "node_modules/acorn": { 1448 + "version": "8.15.0", 1449 + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", 1450 + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", 1451 + "dev": true, 1452 + "license": "MIT", 1453 + "peer": true, 1454 + "bin": { 1455 + "acorn": "bin/acorn" 1456 + }, 1457 + "engines": { 1458 + "node": ">=0.4.0" 1459 + } 1460 + }, 1461 + "node_modules/acorn-jsx": { 1462 + "version": "5.3.2", 1463 + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", 1464 + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", 1465 + "dev": true, 1466 + "license": "MIT", 1467 + "peerDependencies": { 1468 + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" 1469 + } 1470 + }, 1471 + "node_modules/ajv": { 1472 + "version": "6.12.6", 1473 + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", 1474 + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", 1475 + "dev": true, 1476 + "license": "MIT", 1477 + "dependencies": { 1478 + "fast-deep-equal": "^3.1.1", 1479 + "fast-json-stable-stringify": "^2.0.0", 1480 + "json-schema-traverse": "^0.4.1", 1481 + "uri-js": "^4.2.2" 1482 + }, 1483 + "funding": { 1484 + "type": "github", 1485 + "url": "https://github.com/sponsors/epoberezkin" 1486 + } 1487 + }, 1488 + "node_modules/ansi-styles": { 1489 + "version": "4.3.0", 1490 + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", 1491 + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", 1492 + "dev": true, 1493 + "license": "MIT", 1494 + "dependencies": { 1495 + "color-convert": "^2.0.1" 1496 + }, 1497 + "engines": { 1498 + "node": ">=8" 1499 + }, 1500 + "funding": { 1501 + "url": "https://github.com/chalk/ansi-styles?sponsor=1" 1502 + } 1503 + }, 1504 + "node_modules/argparse": { 1505 + "version": "2.0.1", 1506 + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", 1507 + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", 1508 + "dev": true, 1509 + "license": "Python-2.0" 1510 + }, 1511 + "node_modules/array-buffer-byte-length": { 1512 + "version": "1.0.2", 1513 + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", 1514 + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", 1515 + "dev": true, 1516 + "license": "MIT", 1517 + "dependencies": { 1518 + "call-bound": "^1.0.3", 1519 + "is-array-buffer": "^3.0.5" 1520 + }, 1521 + "engines": { 1522 + "node": ">= 0.4" 1523 + }, 1524 + "funding": { 1525 + "url": "https://github.com/sponsors/ljharb" 1526 + } 1527 + }, 1528 + "node_modules/array-includes": { 1529 + "version": "3.1.9", 1530 + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", 1531 + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", 1532 + "dev": true, 1533 + "license": "MIT", 1534 + "dependencies": { 1535 + "call-bind": "^1.0.8", 1536 + "call-bound": "^1.0.4", 1537 + "define-properties": "^1.2.1", 1538 + "es-abstract": "^1.24.0", 1539 + "es-object-atoms": "^1.1.1", 1540 + "get-intrinsic": "^1.3.0", 1541 + "is-string": "^1.1.1", 1542 + "math-intrinsics": "^1.1.0" 1543 + }, 1544 + "engines": { 1545 + "node": ">= 0.4" 1546 + }, 1547 + "funding": { 1548 + "url": "https://github.com/sponsors/ljharb" 1549 + } 1550 + }, 1551 + "node_modules/array.prototype.findlast": { 1552 + "version": "1.2.5", 1553 + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", 1554 + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", 1555 + "dev": true, 1556 + "license": "MIT", 1557 + "dependencies": { 1558 + "call-bind": "^1.0.7", 1559 + "define-properties": "^1.2.1", 1560 + "es-abstract": "^1.23.2", 1561 + "es-errors": "^1.3.0", 1562 + "es-object-atoms": "^1.0.0", 1563 + "es-shim-unscopables": "^1.0.2" 1564 + }, 1565 + "engines": { 1566 + "node": ">= 0.4" 1567 + }, 1568 + "funding": { 1569 + "url": "https://github.com/sponsors/ljharb" 1570 + } 1571 + }, 1572 + "node_modules/array.prototype.flat": { 1573 + "version": "1.3.3", 1574 + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", 1575 + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", 1576 + "dev": true, 1577 + "license": "MIT", 1578 + "dependencies": { 1579 + "call-bind": "^1.0.8", 1580 + "define-properties": "^1.2.1", 1581 + "es-abstract": "^1.23.5", 1582 + "es-shim-unscopables": "^1.0.2" 1583 + }, 1584 + "engines": { 1585 + "node": ">= 0.4" 1586 + }, 1587 + "funding": { 1588 + "url": "https://github.com/sponsors/ljharb" 1589 + } 1590 + }, 1591 + "node_modules/array.prototype.flatmap": { 1592 + "version": "1.3.3", 1593 + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", 1594 + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", 1595 + "dev": true, 1596 + "license": "MIT", 1597 + "dependencies": { 1598 + "call-bind": "^1.0.8", 1599 + "define-properties": "^1.2.1", 1600 + "es-abstract": "^1.23.5", 1601 + "es-shim-unscopables": "^1.0.2" 1602 + }, 1603 + "engines": { 1604 + "node": ">= 0.4" 1605 + }, 1606 + "funding": { 1607 + "url": "https://github.com/sponsors/ljharb" 1608 + } 1609 + }, 1610 + "node_modules/array.prototype.tosorted": { 1611 + "version": "1.1.4", 1612 + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", 1613 + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", 1614 + "dev": true, 1615 + "license": "MIT", 1616 + "dependencies": { 1617 + "call-bind": "^1.0.7", 1618 + "define-properties": "^1.2.1", 1619 + "es-abstract": "^1.23.3", 1620 + "es-errors": "^1.3.0", 1621 + "es-shim-unscopables": "^1.0.2" 1622 + }, 1623 + "engines": { 1624 + "node": ">= 0.4" 1625 + } 1626 + }, 1627 + "node_modules/arraybuffer.prototype.slice": { 1628 + "version": "1.0.4", 1629 + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", 1630 + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", 1631 + "dev": true, 1632 + "license": "MIT", 1633 + "dependencies": { 1634 + "array-buffer-byte-length": "^1.0.1", 1635 + "call-bind": "^1.0.8", 1636 + "define-properties": "^1.2.1", 1637 + "es-abstract": "^1.23.5", 1638 + "es-errors": "^1.3.0", 1639 + "get-intrinsic": "^1.2.6", 1640 + "is-array-buffer": "^3.0.4" 1641 + }, 1642 + "engines": { 1643 + "node": ">= 0.4" 1644 + }, 1645 + "funding": { 1646 + "url": "https://github.com/sponsors/ljharb" 1647 + } 1648 + }, 1649 + "node_modules/async-function": { 1650 + "version": "1.0.0", 1651 + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", 1652 + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", 1653 + "dev": true, 1654 + "license": "MIT", 1655 + "engines": { 1656 + "node": ">= 0.4" 1657 + } 1658 + }, 1659 + "node_modules/available-typed-arrays": { 1660 + "version": "1.0.7", 1661 + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", 1662 + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", 1663 + "dev": true, 1664 + "license": "MIT", 1665 + "dependencies": { 1666 + "possible-typed-array-names": "^1.0.0" 1667 + }, 1668 + "engines": { 1669 + "node": ">= 0.4" 1670 + }, 1671 + "funding": { 1672 + "url": "https://github.com/sponsors/ljharb" 1673 + } 1674 + }, 1675 + "node_modules/balanced-match": { 1676 + "version": "1.0.2", 1677 + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", 1678 + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", 1679 + "dev": true, 1680 + "license": "MIT" 1681 + }, 1225 1682 "node_modules/baseline-browser-mapping": { 1226 1683 "version": "2.9.11", 1227 1684 "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz", ··· 1230 1687 "license": "Apache-2.0", 1231 1688 "bin": { 1232 1689 "baseline-browser-mapping": "dist/cli.js" 1690 + } 1691 + }, 1692 + "node_modules/brace-expansion": { 1693 + "version": "1.1.12", 1694 + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", 1695 + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", 1696 + "dev": true, 1697 + "license": "MIT", 1698 + "dependencies": { 1699 + "balanced-match": "^1.0.0", 1700 + "concat-map": "0.0.1" 1233 1701 } 1234 1702 }, 1235 1703 "node_modules/browserslist": { ··· 1267 1735 "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" 1268 1736 } 1269 1737 }, 1738 + "node_modules/call-bind": { 1739 + "version": "1.0.8", 1740 + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", 1741 + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", 1742 + "dev": true, 1743 + "license": "MIT", 1744 + "dependencies": { 1745 + "call-bind-apply-helpers": "^1.0.0", 1746 + "es-define-property": "^1.0.0", 1747 + "get-intrinsic": "^1.2.4", 1748 + "set-function-length": "^1.2.2" 1749 + }, 1750 + "engines": { 1751 + "node": ">= 0.4" 1752 + }, 1753 + "funding": { 1754 + "url": "https://github.com/sponsors/ljharb" 1755 + } 1756 + }, 1757 + "node_modules/call-bind-apply-helpers": { 1758 + "version": "1.0.2", 1759 + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", 1760 + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", 1761 + "dev": true, 1762 + "license": "MIT", 1763 + "dependencies": { 1764 + "es-errors": "^1.3.0", 1765 + "function-bind": "^1.1.2" 1766 + }, 1767 + "engines": { 1768 + "node": ">= 0.4" 1769 + } 1770 + }, 1771 + "node_modules/call-bound": { 1772 + "version": "1.0.4", 1773 + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", 1774 + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", 1775 + "dev": true, 1776 + "license": "MIT", 1777 + "dependencies": { 1778 + "call-bind-apply-helpers": "^1.0.2", 1779 + "get-intrinsic": "^1.3.0" 1780 + }, 1781 + "engines": { 1782 + "node": ">= 0.4" 1783 + }, 1784 + "funding": { 1785 + "url": "https://github.com/sponsors/ljharb" 1786 + } 1787 + }, 1788 + "node_modules/callsites": { 1789 + "version": "3.1.0", 1790 + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", 1791 + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", 1792 + "dev": true, 1793 + "license": "MIT", 1794 + "engines": { 1795 + "node": ">=6" 1796 + } 1797 + }, 1270 1798 "node_modules/caniuse-lite": { 1271 1799 "version": "1.0.30001762", 1272 1800 "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001762.tgz", ··· 1288 1816 ], 1289 1817 "license": "CC-BY-4.0" 1290 1818 }, 1819 + "node_modules/chalk": { 1820 + "version": "4.1.2", 1821 + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", 1822 + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", 1823 + "dev": true, 1824 + "license": "MIT", 1825 + "dependencies": { 1826 + "ansi-styles": "^4.1.0", 1827 + "supports-color": "^7.1.0" 1828 + }, 1829 + "engines": { 1830 + "node": ">=10" 1831 + }, 1832 + "funding": { 1833 + "url": "https://github.com/chalk/chalk?sponsor=1" 1834 + } 1835 + }, 1836 + "node_modules/color-convert": { 1837 + "version": "2.0.1", 1838 + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", 1839 + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", 1840 + "dev": true, 1841 + "license": "MIT", 1842 + "dependencies": { 1843 + "color-name": "~1.1.4" 1844 + }, 1845 + "engines": { 1846 + "node": ">=7.0.0" 1847 + } 1848 + }, 1849 + "node_modules/color-name": { 1850 + "version": "1.1.4", 1851 + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", 1852 + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", 1853 + "dev": true, 1854 + "license": "MIT" 1855 + }, 1856 + "node_modules/concat-map": { 1857 + "version": "0.0.1", 1858 + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", 1859 + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", 1860 + "dev": true, 1861 + "license": "MIT" 1862 + }, 1291 1863 "node_modules/convert-source-map": { 1292 1864 "version": "2.0.0", 1293 1865 "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", ··· 1295 1867 "dev": true, 1296 1868 "license": "MIT" 1297 1869 }, 1870 + "node_modules/cross-spawn": { 1871 + "version": "7.0.6", 1872 + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", 1873 + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", 1874 + "dev": true, 1875 + "license": "MIT", 1876 + "dependencies": { 1877 + "path-key": "^3.1.0", 1878 + "shebang-command": "^2.0.0", 1879 + "which": "^2.0.1" 1880 + }, 1881 + "engines": { 1882 + "node": ">= 8" 1883 + } 1884 + }, 1298 1885 "node_modules/csstype": { 1299 1886 "version": "3.2.3", 1300 1887 "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", ··· 1302 1889 "dev": true, 1303 1890 "license": "MIT" 1304 1891 }, 1892 + "node_modules/data-view-buffer": { 1893 + "version": "1.0.2", 1894 + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", 1895 + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", 1896 + "dev": true, 1897 + "license": "MIT", 1898 + "dependencies": { 1899 + "call-bound": "^1.0.3", 1900 + "es-errors": "^1.3.0", 1901 + "is-data-view": "^1.0.2" 1902 + }, 1903 + "engines": { 1904 + "node": ">= 0.4" 1905 + }, 1906 + "funding": { 1907 + "url": "https://github.com/sponsors/ljharb" 1908 + } 1909 + }, 1910 + "node_modules/data-view-byte-length": { 1911 + "version": "1.0.2", 1912 + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", 1913 + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", 1914 + "dev": true, 1915 + "license": "MIT", 1916 + "dependencies": { 1917 + "call-bound": "^1.0.3", 1918 + "es-errors": "^1.3.0", 1919 + "is-data-view": "^1.0.2" 1920 + }, 1921 + "engines": { 1922 + "node": ">= 0.4" 1923 + }, 1924 + "funding": { 1925 + "url": "https://github.com/sponsors/inspect-js" 1926 + } 1927 + }, 1928 + "node_modules/data-view-byte-offset": { 1929 + "version": "1.0.1", 1930 + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", 1931 + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", 1932 + "dev": true, 1933 + "license": "MIT", 1934 + "dependencies": { 1935 + "call-bound": "^1.0.2", 1936 + "es-errors": "^1.3.0", 1937 + "is-data-view": "^1.0.1" 1938 + }, 1939 + "engines": { 1940 + "node": ">= 0.4" 1941 + }, 1942 + "funding": { 1943 + "url": "https://github.com/sponsors/ljharb" 1944 + } 1945 + }, 1305 1946 "node_modules/debug": { 1306 1947 "version": "4.4.3", 1307 1948 "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", ··· 1320 1961 } 1321 1962 } 1322 1963 }, 1964 + "node_modules/deep-is": { 1965 + "version": "0.1.4", 1966 + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", 1967 + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", 1968 + "dev": true, 1969 + "license": "MIT" 1970 + }, 1971 + "node_modules/define-data-property": { 1972 + "version": "1.1.4", 1973 + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", 1974 + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", 1975 + "dev": true, 1976 + "license": "MIT", 1977 + "dependencies": { 1978 + "es-define-property": "^1.0.0", 1979 + "es-errors": "^1.3.0", 1980 + "gopd": "^1.0.1" 1981 + }, 1982 + "engines": { 1983 + "node": ">= 0.4" 1984 + }, 1985 + "funding": { 1986 + "url": "https://github.com/sponsors/ljharb" 1987 + } 1988 + }, 1989 + "node_modules/define-properties": { 1990 + "version": "1.2.1", 1991 + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", 1992 + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", 1993 + "dev": true, 1994 + "license": "MIT", 1995 + "dependencies": { 1996 + "define-data-property": "^1.0.1", 1997 + "has-property-descriptors": "^1.0.0", 1998 + "object-keys": "^1.1.1" 1999 + }, 2000 + "engines": { 2001 + "node": ">= 0.4" 2002 + }, 2003 + "funding": { 2004 + "url": "https://github.com/sponsors/ljharb" 2005 + } 2006 + }, 2007 + "node_modules/doctrine": { 2008 + "version": "2.1.0", 2009 + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", 2010 + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", 2011 + "dev": true, 2012 + "license": "Apache-2.0", 2013 + "dependencies": { 2014 + "esutils": "^2.0.2" 2015 + }, 2016 + "engines": { 2017 + "node": ">=0.10.0" 2018 + } 2019 + }, 2020 + "node_modules/dunder-proto": { 2021 + "version": "1.0.1", 2022 + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", 2023 + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", 2024 + "dev": true, 2025 + "license": "MIT", 2026 + "dependencies": { 2027 + "call-bind-apply-helpers": "^1.0.1", 2028 + "es-errors": "^1.3.0", 2029 + "gopd": "^1.2.0" 2030 + }, 2031 + "engines": { 2032 + "node": ">= 0.4" 2033 + } 2034 + }, 1323 2035 "node_modules/electron-to-chromium": { 1324 2036 "version": "1.5.267", 1325 2037 "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", ··· 1327 2039 "dev": true, 1328 2040 "license": "ISC" 1329 2041 }, 2042 + "node_modules/es-abstract": { 2043 + "version": "1.24.1", 2044 + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", 2045 + "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", 2046 + "dev": true, 2047 + "license": "MIT", 2048 + "dependencies": { 2049 + "array-buffer-byte-length": "^1.0.2", 2050 + "arraybuffer.prototype.slice": "^1.0.4", 2051 + "available-typed-arrays": "^1.0.7", 2052 + "call-bind": "^1.0.8", 2053 + "call-bound": "^1.0.4", 2054 + "data-view-buffer": "^1.0.2", 2055 + "data-view-byte-length": "^1.0.2", 2056 + "data-view-byte-offset": "^1.0.1", 2057 + "es-define-property": "^1.0.1", 2058 + "es-errors": "^1.3.0", 2059 + "es-object-atoms": "^1.1.1", 2060 + "es-set-tostringtag": "^2.1.0", 2061 + "es-to-primitive": "^1.3.0", 2062 + "function.prototype.name": "^1.1.8", 2063 + "get-intrinsic": "^1.3.0", 2064 + "get-proto": "^1.0.1", 2065 + "get-symbol-description": "^1.1.0", 2066 + "globalthis": "^1.0.4", 2067 + "gopd": "^1.2.0", 2068 + "has-property-descriptors": "^1.0.2", 2069 + "has-proto": "^1.2.0", 2070 + "has-symbols": "^1.1.0", 2071 + "hasown": "^2.0.2", 2072 + "internal-slot": "^1.1.0", 2073 + "is-array-buffer": "^3.0.5", 2074 + "is-callable": "^1.2.7", 2075 + "is-data-view": "^1.0.2", 2076 + "is-negative-zero": "^2.0.3", 2077 + "is-regex": "^1.2.1", 2078 + "is-set": "^2.0.3", 2079 + "is-shared-array-buffer": "^1.0.4", 2080 + "is-string": "^1.1.1", 2081 + "is-typed-array": "^1.1.15", 2082 + "is-weakref": "^1.1.1", 2083 + "math-intrinsics": "^1.1.0", 2084 + "object-inspect": "^1.13.4", 2085 + "object-keys": "^1.1.1", 2086 + "object.assign": "^4.1.7", 2087 + "own-keys": "^1.0.1", 2088 + "regexp.prototype.flags": "^1.5.4", 2089 + "safe-array-concat": "^1.1.3", 2090 + "safe-push-apply": "^1.0.0", 2091 + "safe-regex-test": "^1.1.0", 2092 + "set-proto": "^1.0.0", 2093 + "stop-iteration-iterator": "^1.1.0", 2094 + "string.prototype.trim": "^1.2.10", 2095 + "string.prototype.trimend": "^1.0.9", 2096 + "string.prototype.trimstart": "^1.0.8", 2097 + "typed-array-buffer": "^1.0.3", 2098 + "typed-array-byte-length": "^1.0.3", 2099 + "typed-array-byte-offset": "^1.0.4", 2100 + "typed-array-length": "^1.0.7", 2101 + "unbox-primitive": "^1.1.0", 2102 + "which-typed-array": "^1.1.19" 2103 + }, 2104 + "engines": { 2105 + "node": ">= 0.4" 2106 + }, 2107 + "funding": { 2108 + "url": "https://github.com/sponsors/ljharb" 2109 + } 2110 + }, 2111 + "node_modules/es-define-property": { 2112 + "version": "1.0.1", 2113 + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", 2114 + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", 2115 + "dev": true, 2116 + "license": "MIT", 2117 + "engines": { 2118 + "node": ">= 0.4" 2119 + } 2120 + }, 2121 + "node_modules/es-errors": { 2122 + "version": "1.3.0", 2123 + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", 2124 + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", 2125 + "dev": true, 2126 + "license": "MIT", 2127 + "engines": { 2128 + "node": ">= 0.4" 2129 + } 2130 + }, 2131 + "node_modules/es-iterator-helpers": { 2132 + "version": "1.2.2", 2133 + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.2.tgz", 2134 + "integrity": "sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==", 2135 + "dev": true, 2136 + "license": "MIT", 2137 + "dependencies": { 2138 + "call-bind": "^1.0.8", 2139 + "call-bound": "^1.0.4", 2140 + "define-properties": "^1.2.1", 2141 + "es-abstract": "^1.24.1", 2142 + "es-errors": "^1.3.0", 2143 + "es-set-tostringtag": "^2.1.0", 2144 + "function-bind": "^1.1.2", 2145 + "get-intrinsic": "^1.3.0", 2146 + "globalthis": "^1.0.4", 2147 + "gopd": "^1.2.0", 2148 + "has-property-descriptors": "^1.0.2", 2149 + "has-proto": "^1.2.0", 2150 + "has-symbols": "^1.1.0", 2151 + "internal-slot": "^1.1.0", 2152 + "iterator.prototype": "^1.1.5", 2153 + "safe-array-concat": "^1.1.3" 2154 + }, 2155 + "engines": { 2156 + "node": ">= 0.4" 2157 + } 2158 + }, 2159 + "node_modules/es-object-atoms": { 2160 + "version": "1.1.1", 2161 + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", 2162 + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", 2163 + "dev": true, 2164 + "license": "MIT", 2165 + "dependencies": { 2166 + "es-errors": "^1.3.0" 2167 + }, 2168 + "engines": { 2169 + "node": ">= 0.4" 2170 + } 2171 + }, 2172 + "node_modules/es-set-tostringtag": { 2173 + "version": "2.1.0", 2174 + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", 2175 + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", 2176 + "dev": true, 2177 + "license": "MIT", 2178 + "dependencies": { 2179 + "es-errors": "^1.3.0", 2180 + "get-intrinsic": "^1.2.6", 2181 + "has-tostringtag": "^1.0.2", 2182 + "hasown": "^2.0.2" 2183 + }, 2184 + "engines": { 2185 + "node": ">= 0.4" 2186 + } 2187 + }, 2188 + "node_modules/es-shim-unscopables": { 2189 + "version": "1.1.0", 2190 + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", 2191 + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", 2192 + "dev": true, 2193 + "license": "MIT", 2194 + "dependencies": { 2195 + "hasown": "^2.0.2" 2196 + }, 2197 + "engines": { 2198 + "node": ">= 0.4" 2199 + } 2200 + }, 2201 + "node_modules/es-to-primitive": { 2202 + "version": "1.3.0", 2203 + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", 2204 + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", 2205 + "dev": true, 2206 + "license": "MIT", 2207 + "dependencies": { 2208 + "is-callable": "^1.2.7", 2209 + "is-date-object": "^1.0.5", 2210 + "is-symbol": "^1.0.4" 2211 + }, 2212 + "engines": { 2213 + "node": ">= 0.4" 2214 + }, 2215 + "funding": { 2216 + "url": "https://github.com/sponsors/ljharb" 2217 + } 2218 + }, 1330 2219 "node_modules/esbuild": { 1331 2220 "version": "0.25.12", 1332 2221 "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", ··· 1379 2268 "node": ">=6" 1380 2269 } 1381 2270 }, 2271 + "node_modules/escape-string-regexp": { 2272 + "version": "4.0.0", 2273 + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", 2274 + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", 2275 + "dev": true, 2276 + "license": "MIT", 2277 + "engines": { 2278 + "node": ">=10" 2279 + }, 2280 + "funding": { 2281 + "url": "https://github.com/sponsors/sindresorhus" 2282 + } 2283 + }, 2284 + "node_modules/eslint": { 2285 + "version": "9.39.2", 2286 + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", 2287 + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", 2288 + "dev": true, 2289 + "license": "MIT", 2290 + "peer": true, 2291 + "dependencies": { 2292 + "@eslint-community/eslint-utils": "^4.8.0", 2293 + "@eslint-community/regexpp": "^4.12.1", 2294 + "@eslint/config-array": "^0.21.1", 2295 + "@eslint/config-helpers": "^0.4.2", 2296 + "@eslint/core": "^0.17.0", 2297 + "@eslint/eslintrc": "^3.3.1", 2298 + "@eslint/js": "9.39.2", 2299 + "@eslint/plugin-kit": "^0.4.1", 2300 + "@humanfs/node": "^0.16.6", 2301 + "@humanwhocodes/module-importer": "^1.0.1", 2302 + "@humanwhocodes/retry": "^0.4.2", 2303 + "@types/estree": "^1.0.6", 2304 + "ajv": "^6.12.4", 2305 + "chalk": "^4.0.0", 2306 + "cross-spawn": "^7.0.6", 2307 + "debug": "^4.3.2", 2308 + "escape-string-regexp": "^4.0.0", 2309 + "eslint-scope": "^8.4.0", 2310 + "eslint-visitor-keys": "^4.2.1", 2311 + "espree": "^10.4.0", 2312 + "esquery": "^1.5.0", 2313 + "esutils": "^2.0.2", 2314 + "fast-deep-equal": "^3.1.3", 2315 + "file-entry-cache": "^8.0.0", 2316 + "find-up": "^5.0.0", 2317 + "glob-parent": "^6.0.2", 2318 + "ignore": "^5.2.0", 2319 + "imurmurhash": "^0.1.4", 2320 + "is-glob": "^4.0.0", 2321 + "json-stable-stringify-without-jsonify": "^1.0.1", 2322 + "lodash.merge": "^4.6.2", 2323 + "minimatch": "^3.1.2", 2324 + "natural-compare": "^1.4.0", 2325 + "optionator": "^0.9.3" 2326 + }, 2327 + "bin": { 2328 + "eslint": "bin/eslint.js" 2329 + }, 2330 + "engines": { 2331 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 2332 + }, 2333 + "funding": { 2334 + "url": "https://eslint.org/donate" 2335 + }, 2336 + "peerDependencies": { 2337 + "jiti": "*" 2338 + }, 2339 + "peerDependenciesMeta": { 2340 + "jiti": { 2341 + "optional": true 2342 + } 2343 + } 2344 + }, 2345 + "node_modules/eslint-plugin-react": { 2346 + "version": "7.37.5", 2347 + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", 2348 + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", 2349 + "dev": true, 2350 + "license": "MIT", 2351 + "dependencies": { 2352 + "array-includes": "^3.1.8", 2353 + "array.prototype.findlast": "^1.2.5", 2354 + "array.prototype.flatmap": "^1.3.3", 2355 + "array.prototype.tosorted": "^1.1.4", 2356 + "doctrine": "^2.1.0", 2357 + "es-iterator-helpers": "^1.2.1", 2358 + "estraverse": "^5.3.0", 2359 + "hasown": "^2.0.2", 2360 + "jsx-ast-utils": "^2.4.1 || ^3.0.0", 2361 + "minimatch": "^3.1.2", 2362 + "object.entries": "^1.1.9", 2363 + "object.fromentries": "^2.0.8", 2364 + "object.values": "^1.2.1", 2365 + "prop-types": "^15.8.1", 2366 + "resolve": "^2.0.0-next.5", 2367 + "semver": "^6.3.1", 2368 + "string.prototype.matchall": "^4.0.12", 2369 + "string.prototype.repeat": "^1.0.0" 2370 + }, 2371 + "engines": { 2372 + "node": ">=4" 2373 + }, 2374 + "peerDependencies": { 2375 + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" 2376 + } 2377 + }, 2378 + "node_modules/eslint-plugin-react-hooks": { 2379 + "version": "7.0.1", 2380 + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", 2381 + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", 2382 + "dev": true, 2383 + "license": "MIT", 2384 + "dependencies": { 2385 + "@babel/core": "^7.24.4", 2386 + "@babel/parser": "^7.24.4", 2387 + "hermes-parser": "^0.25.1", 2388 + "zod": "^3.25.0 || ^4.0.0", 2389 + "zod-validation-error": "^3.5.0 || ^4.0.0" 2390 + }, 2391 + "engines": { 2392 + "node": ">=18" 2393 + }, 2394 + "peerDependencies": { 2395 + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" 2396 + } 2397 + }, 2398 + "node_modules/eslint-plugin-react-refresh": { 2399 + "version": "0.4.26", 2400 + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", 2401 + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", 2402 + "dev": true, 2403 + "license": "MIT", 2404 + "peerDependencies": { 2405 + "eslint": ">=8.40" 2406 + } 2407 + }, 2408 + "node_modules/eslint-scope": { 2409 + "version": "8.4.0", 2410 + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", 2411 + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", 2412 + "dev": true, 2413 + "license": "BSD-2-Clause", 2414 + "dependencies": { 2415 + "esrecurse": "^4.3.0", 2416 + "estraverse": "^5.2.0" 2417 + }, 2418 + "engines": { 2419 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 2420 + }, 2421 + "funding": { 2422 + "url": "https://opencollective.com/eslint" 2423 + } 2424 + }, 2425 + "node_modules/eslint-visitor-keys": { 2426 + "version": "4.2.1", 2427 + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", 2428 + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", 2429 + "dev": true, 2430 + "license": "Apache-2.0", 2431 + "engines": { 2432 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 2433 + }, 2434 + "funding": { 2435 + "url": "https://opencollective.com/eslint" 2436 + } 2437 + }, 2438 + "node_modules/espree": { 2439 + "version": "10.4.0", 2440 + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", 2441 + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", 2442 + "dev": true, 2443 + "license": "BSD-2-Clause", 2444 + "dependencies": { 2445 + "acorn": "^8.15.0", 2446 + "acorn-jsx": "^5.3.2", 2447 + "eslint-visitor-keys": "^4.2.1" 2448 + }, 2449 + "engines": { 2450 + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 2451 + }, 2452 + "funding": { 2453 + "url": "https://opencollective.com/eslint" 2454 + } 2455 + }, 2456 + "node_modules/esquery": { 2457 + "version": "1.7.0", 2458 + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", 2459 + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", 2460 + "dev": true, 2461 + "license": "BSD-3-Clause", 2462 + "dependencies": { 2463 + "estraverse": "^5.1.0" 2464 + }, 2465 + "engines": { 2466 + "node": ">=0.10" 2467 + } 2468 + }, 2469 + "node_modules/esrecurse": { 2470 + "version": "4.3.0", 2471 + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", 2472 + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", 2473 + "dev": true, 2474 + "license": "BSD-2-Clause", 2475 + "dependencies": { 2476 + "estraverse": "^5.2.0" 2477 + }, 2478 + "engines": { 2479 + "node": ">=4.0" 2480 + } 2481 + }, 2482 + "node_modules/estraverse": { 2483 + "version": "5.3.0", 2484 + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", 2485 + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", 2486 + "dev": true, 2487 + "license": "BSD-2-Clause", 2488 + "engines": { 2489 + "node": ">=4.0" 2490 + } 2491 + }, 2492 + "node_modules/esutils": { 2493 + "version": "2.0.3", 2494 + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", 2495 + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", 2496 + "dev": true, 2497 + "license": "BSD-2-Clause", 2498 + "engines": { 2499 + "node": ">=0.10.0" 2500 + } 2501 + }, 2502 + "node_modules/fast-deep-equal": { 2503 + "version": "3.1.3", 2504 + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", 2505 + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", 2506 + "dev": true, 2507 + "license": "MIT" 2508 + }, 2509 + "node_modules/fast-json-stable-stringify": { 2510 + "version": "2.1.0", 2511 + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", 2512 + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", 2513 + "dev": true, 2514 + "license": "MIT" 2515 + }, 2516 + "node_modules/fast-levenshtein": { 2517 + "version": "2.0.6", 2518 + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", 2519 + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", 2520 + "dev": true, 2521 + "license": "MIT" 2522 + }, 1382 2523 "node_modules/fdir": { 1383 2524 "version": "6.5.0", 1384 2525 "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", ··· 1397 2538 } 1398 2539 } 1399 2540 }, 2541 + "node_modules/file-entry-cache": { 2542 + "version": "8.0.0", 2543 + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", 2544 + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", 2545 + "dev": true, 2546 + "license": "MIT", 2547 + "dependencies": { 2548 + "flat-cache": "^4.0.0" 2549 + }, 2550 + "engines": { 2551 + "node": ">=16.0.0" 2552 + } 2553 + }, 2554 + "node_modules/find-up": { 2555 + "version": "5.0.0", 2556 + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", 2557 + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", 2558 + "dev": true, 2559 + "license": "MIT", 2560 + "dependencies": { 2561 + "locate-path": "^6.0.0", 2562 + "path-exists": "^4.0.0" 2563 + }, 2564 + "engines": { 2565 + "node": ">=10" 2566 + }, 2567 + "funding": { 2568 + "url": "https://github.com/sponsors/sindresorhus" 2569 + } 2570 + }, 2571 + "node_modules/flat-cache": { 2572 + "version": "4.0.1", 2573 + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", 2574 + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", 2575 + "dev": true, 2576 + "license": "MIT", 2577 + "dependencies": { 2578 + "flatted": "^3.2.9", 2579 + "keyv": "^4.5.4" 2580 + }, 2581 + "engines": { 2582 + "node": ">=16" 2583 + } 2584 + }, 2585 + "node_modules/flatted": { 2586 + "version": "3.3.3", 2587 + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", 2588 + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", 2589 + "dev": true, 2590 + "license": "ISC" 2591 + }, 2592 + "node_modules/for-each": { 2593 + "version": "0.3.5", 2594 + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", 2595 + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", 2596 + "dev": true, 2597 + "license": "MIT", 2598 + "dependencies": { 2599 + "is-callable": "^1.2.7" 2600 + }, 2601 + "engines": { 2602 + "node": ">= 0.4" 2603 + }, 2604 + "funding": { 2605 + "url": "https://github.com/sponsors/ljharb" 2606 + } 2607 + }, 1400 2608 "node_modules/fsevents": { 1401 2609 "version": "2.3.3", 1402 2610 "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", ··· 1412 2620 "node": "^8.16.0 || ^10.6.0 || >=11.0.0" 1413 2621 } 1414 2622 }, 2623 + "node_modules/function-bind": { 2624 + "version": "1.1.2", 2625 + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", 2626 + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", 2627 + "dev": true, 2628 + "license": "MIT", 2629 + "funding": { 2630 + "url": "https://github.com/sponsors/ljharb" 2631 + } 2632 + }, 2633 + "node_modules/function.prototype.name": { 2634 + "version": "1.1.8", 2635 + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", 2636 + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", 2637 + "dev": true, 2638 + "license": "MIT", 2639 + "dependencies": { 2640 + "call-bind": "^1.0.8", 2641 + "call-bound": "^1.0.3", 2642 + "define-properties": "^1.2.1", 2643 + "functions-have-names": "^1.2.3", 2644 + "hasown": "^2.0.2", 2645 + "is-callable": "^1.2.7" 2646 + }, 2647 + "engines": { 2648 + "node": ">= 0.4" 2649 + }, 2650 + "funding": { 2651 + "url": "https://github.com/sponsors/ljharb" 2652 + } 2653 + }, 2654 + "node_modules/functions-have-names": { 2655 + "version": "1.2.3", 2656 + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", 2657 + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", 2658 + "dev": true, 2659 + "license": "MIT", 2660 + "funding": { 2661 + "url": "https://github.com/sponsors/ljharb" 2662 + } 2663 + }, 2664 + "node_modules/generator-function": { 2665 + "version": "2.0.1", 2666 + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", 2667 + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", 2668 + "dev": true, 2669 + "license": "MIT", 2670 + "engines": { 2671 + "node": ">= 0.4" 2672 + } 2673 + }, 1415 2674 "node_modules/gensync": { 1416 2675 "version": "1.0.0-beta.2", 1417 2676 "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", ··· 1422 2681 "node": ">=6.9.0" 1423 2682 } 1424 2683 }, 2684 + "node_modules/get-intrinsic": { 2685 + "version": "1.3.0", 2686 + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", 2687 + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", 2688 + "dev": true, 2689 + "license": "MIT", 2690 + "dependencies": { 2691 + "call-bind-apply-helpers": "^1.0.2", 2692 + "es-define-property": "^1.0.1", 2693 + "es-errors": "^1.3.0", 2694 + "es-object-atoms": "^1.1.1", 2695 + "function-bind": "^1.1.2", 2696 + "get-proto": "^1.0.1", 2697 + "gopd": "^1.2.0", 2698 + "has-symbols": "^1.1.0", 2699 + "hasown": "^2.0.2", 2700 + "math-intrinsics": "^1.1.0" 2701 + }, 2702 + "engines": { 2703 + "node": ">= 0.4" 2704 + }, 2705 + "funding": { 2706 + "url": "https://github.com/sponsors/ljharb" 2707 + } 2708 + }, 2709 + "node_modules/get-proto": { 2710 + "version": "1.0.1", 2711 + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", 2712 + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", 2713 + "dev": true, 2714 + "license": "MIT", 2715 + "dependencies": { 2716 + "dunder-proto": "^1.0.1", 2717 + "es-object-atoms": "^1.0.0" 2718 + }, 2719 + "engines": { 2720 + "node": ">= 0.4" 2721 + } 2722 + }, 2723 + "node_modules/get-symbol-description": { 2724 + "version": "1.1.0", 2725 + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", 2726 + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", 2727 + "dev": true, 2728 + "license": "MIT", 2729 + "dependencies": { 2730 + "call-bound": "^1.0.3", 2731 + "es-errors": "^1.3.0", 2732 + "get-intrinsic": "^1.2.6" 2733 + }, 2734 + "engines": { 2735 + "node": ">= 0.4" 2736 + }, 2737 + "funding": { 2738 + "url": "https://github.com/sponsors/ljharb" 2739 + } 2740 + }, 2741 + "node_modules/glob-parent": { 2742 + "version": "6.0.2", 2743 + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", 2744 + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", 2745 + "dev": true, 2746 + "license": "ISC", 2747 + "dependencies": { 2748 + "is-glob": "^4.0.3" 2749 + }, 2750 + "engines": { 2751 + "node": ">=10.13.0" 2752 + } 2753 + }, 2754 + "node_modules/globals": { 2755 + "version": "17.0.0", 2756 + "resolved": "https://registry.npmjs.org/globals/-/globals-17.0.0.tgz", 2757 + "integrity": "sha512-gv5BeD2EssA793rlFWVPMMCqefTlpusw6/2TbAVMy0FzcG8wKJn4O+NqJ4+XWmmwrayJgw5TzrmWjFgmz1XPqw==", 2758 + "dev": true, 2759 + "license": "MIT", 2760 + "engines": { 2761 + "node": ">=18" 2762 + }, 2763 + "funding": { 2764 + "url": "https://github.com/sponsors/sindresorhus" 2765 + } 2766 + }, 2767 + "node_modules/globalthis": { 2768 + "version": "1.0.4", 2769 + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", 2770 + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", 2771 + "dev": true, 2772 + "license": "MIT", 2773 + "dependencies": { 2774 + "define-properties": "^1.2.1", 2775 + "gopd": "^1.0.1" 2776 + }, 2777 + "engines": { 2778 + "node": ">= 0.4" 2779 + }, 2780 + "funding": { 2781 + "url": "https://github.com/sponsors/ljharb" 2782 + } 2783 + }, 2784 + "node_modules/gopd": { 2785 + "version": "1.2.0", 2786 + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", 2787 + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", 2788 + "dev": true, 2789 + "license": "MIT", 2790 + "engines": { 2791 + "node": ">= 0.4" 2792 + }, 2793 + "funding": { 2794 + "url": "https://github.com/sponsors/ljharb" 2795 + } 2796 + }, 2797 + "node_modules/has-bigints": { 2798 + "version": "1.1.0", 2799 + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", 2800 + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", 2801 + "dev": true, 2802 + "license": "MIT", 2803 + "engines": { 2804 + "node": ">= 0.4" 2805 + }, 2806 + "funding": { 2807 + "url": "https://github.com/sponsors/ljharb" 2808 + } 2809 + }, 2810 + "node_modules/has-flag": { 2811 + "version": "4.0.0", 2812 + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", 2813 + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", 2814 + "dev": true, 2815 + "license": "MIT", 2816 + "engines": { 2817 + "node": ">=8" 2818 + } 2819 + }, 2820 + "node_modules/has-property-descriptors": { 2821 + "version": "1.0.2", 2822 + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", 2823 + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", 2824 + "dev": true, 2825 + "license": "MIT", 2826 + "dependencies": { 2827 + "es-define-property": "^1.0.0" 2828 + }, 2829 + "funding": { 2830 + "url": "https://github.com/sponsors/ljharb" 2831 + } 2832 + }, 2833 + "node_modules/has-proto": { 2834 + "version": "1.2.0", 2835 + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", 2836 + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", 2837 + "dev": true, 2838 + "license": "MIT", 2839 + "dependencies": { 2840 + "dunder-proto": "^1.0.0" 2841 + }, 2842 + "engines": { 2843 + "node": ">= 0.4" 2844 + }, 2845 + "funding": { 2846 + "url": "https://github.com/sponsors/ljharb" 2847 + } 2848 + }, 2849 + "node_modules/has-symbols": { 2850 + "version": "1.1.0", 2851 + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", 2852 + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", 2853 + "dev": true, 2854 + "license": "MIT", 2855 + "engines": { 2856 + "node": ">= 0.4" 2857 + }, 2858 + "funding": { 2859 + "url": "https://github.com/sponsors/ljharb" 2860 + } 2861 + }, 2862 + "node_modules/has-tostringtag": { 2863 + "version": "1.0.2", 2864 + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", 2865 + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", 2866 + "dev": true, 2867 + "license": "MIT", 2868 + "dependencies": { 2869 + "has-symbols": "^1.0.3" 2870 + }, 2871 + "engines": { 2872 + "node": ">= 0.4" 2873 + }, 2874 + "funding": { 2875 + "url": "https://github.com/sponsors/ljharb" 2876 + } 2877 + }, 2878 + "node_modules/hasown": { 2879 + "version": "2.0.2", 2880 + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", 2881 + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", 2882 + "dev": true, 2883 + "license": "MIT", 2884 + "dependencies": { 2885 + "function-bind": "^1.1.2" 2886 + }, 2887 + "engines": { 2888 + "node": ">= 0.4" 2889 + } 2890 + }, 2891 + "node_modules/hermes-estree": { 2892 + "version": "0.25.1", 2893 + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", 2894 + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", 2895 + "dev": true, 2896 + "license": "MIT" 2897 + }, 2898 + "node_modules/hermes-parser": { 2899 + "version": "0.25.1", 2900 + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", 2901 + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", 2902 + "dev": true, 2903 + "license": "MIT", 2904 + "dependencies": { 2905 + "hermes-estree": "0.25.1" 2906 + } 2907 + }, 2908 + "node_modules/ignore": { 2909 + "version": "5.3.2", 2910 + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", 2911 + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", 2912 + "dev": true, 2913 + "license": "MIT", 2914 + "engines": { 2915 + "node": ">= 4" 2916 + } 2917 + }, 2918 + "node_modules/import-fresh": { 2919 + "version": "3.3.1", 2920 + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", 2921 + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", 2922 + "dev": true, 2923 + "license": "MIT", 2924 + "dependencies": { 2925 + "parent-module": "^1.0.0", 2926 + "resolve-from": "^4.0.0" 2927 + }, 2928 + "engines": { 2929 + "node": ">=6" 2930 + }, 2931 + "funding": { 2932 + "url": "https://github.com/sponsors/sindresorhus" 2933 + } 2934 + }, 2935 + "node_modules/imurmurhash": { 2936 + "version": "0.1.4", 2937 + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", 2938 + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", 2939 + "dev": true, 2940 + "license": "MIT", 2941 + "engines": { 2942 + "node": ">=0.8.19" 2943 + } 2944 + }, 2945 + "node_modules/internal-slot": { 2946 + "version": "1.1.0", 2947 + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", 2948 + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", 2949 + "dev": true, 2950 + "license": "MIT", 2951 + "dependencies": { 2952 + "es-errors": "^1.3.0", 2953 + "hasown": "^2.0.2", 2954 + "side-channel": "^1.1.0" 2955 + }, 2956 + "engines": { 2957 + "node": ">= 0.4" 2958 + } 2959 + }, 2960 + "node_modules/is-array-buffer": { 2961 + "version": "3.0.5", 2962 + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", 2963 + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", 2964 + "dev": true, 2965 + "license": "MIT", 2966 + "dependencies": { 2967 + "call-bind": "^1.0.8", 2968 + "call-bound": "^1.0.3", 2969 + "get-intrinsic": "^1.2.6" 2970 + }, 2971 + "engines": { 2972 + "node": ">= 0.4" 2973 + }, 2974 + "funding": { 2975 + "url": "https://github.com/sponsors/ljharb" 2976 + } 2977 + }, 2978 + "node_modules/is-async-function": { 2979 + "version": "2.1.1", 2980 + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", 2981 + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", 2982 + "dev": true, 2983 + "license": "MIT", 2984 + "dependencies": { 2985 + "async-function": "^1.0.0", 2986 + "call-bound": "^1.0.3", 2987 + "get-proto": "^1.0.1", 2988 + "has-tostringtag": "^1.0.2", 2989 + "safe-regex-test": "^1.1.0" 2990 + }, 2991 + "engines": { 2992 + "node": ">= 0.4" 2993 + }, 2994 + "funding": { 2995 + "url": "https://github.com/sponsors/ljharb" 2996 + } 2997 + }, 2998 + "node_modules/is-bigint": { 2999 + "version": "1.1.0", 3000 + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", 3001 + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", 3002 + "dev": true, 3003 + "license": "MIT", 3004 + "dependencies": { 3005 + "has-bigints": "^1.0.2" 3006 + }, 3007 + "engines": { 3008 + "node": ">= 0.4" 3009 + }, 3010 + "funding": { 3011 + "url": "https://github.com/sponsors/ljharb" 3012 + } 3013 + }, 3014 + "node_modules/is-boolean-object": { 3015 + "version": "1.2.2", 3016 + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", 3017 + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", 3018 + "dev": true, 3019 + "license": "MIT", 3020 + "dependencies": { 3021 + "call-bound": "^1.0.3", 3022 + "has-tostringtag": "^1.0.2" 3023 + }, 3024 + "engines": { 3025 + "node": ">= 0.4" 3026 + }, 3027 + "funding": { 3028 + "url": "https://github.com/sponsors/ljharb" 3029 + } 3030 + }, 3031 + "node_modules/is-callable": { 3032 + "version": "1.2.7", 3033 + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", 3034 + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", 3035 + "dev": true, 3036 + "license": "MIT", 3037 + "engines": { 3038 + "node": ">= 0.4" 3039 + }, 3040 + "funding": { 3041 + "url": "https://github.com/sponsors/ljharb" 3042 + } 3043 + }, 3044 + "node_modules/is-core-module": { 3045 + "version": "2.16.1", 3046 + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", 3047 + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", 3048 + "dev": true, 3049 + "license": "MIT", 3050 + "dependencies": { 3051 + "hasown": "^2.0.2" 3052 + }, 3053 + "engines": { 3054 + "node": ">= 0.4" 3055 + }, 3056 + "funding": { 3057 + "url": "https://github.com/sponsors/ljharb" 3058 + } 3059 + }, 3060 + "node_modules/is-data-view": { 3061 + "version": "1.0.2", 3062 + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", 3063 + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", 3064 + "dev": true, 3065 + "license": "MIT", 3066 + "dependencies": { 3067 + "call-bound": "^1.0.2", 3068 + "get-intrinsic": "^1.2.6", 3069 + "is-typed-array": "^1.1.13" 3070 + }, 3071 + "engines": { 3072 + "node": ">= 0.4" 3073 + }, 3074 + "funding": { 3075 + "url": "https://github.com/sponsors/ljharb" 3076 + } 3077 + }, 3078 + "node_modules/is-date-object": { 3079 + "version": "1.1.0", 3080 + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", 3081 + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", 3082 + "dev": true, 3083 + "license": "MIT", 3084 + "dependencies": { 3085 + "call-bound": "^1.0.2", 3086 + "has-tostringtag": "^1.0.2" 3087 + }, 3088 + "engines": { 3089 + "node": ">= 0.4" 3090 + }, 3091 + "funding": { 3092 + "url": "https://github.com/sponsors/ljharb" 3093 + } 3094 + }, 3095 + "node_modules/is-extglob": { 3096 + "version": "2.1.1", 3097 + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", 3098 + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", 3099 + "dev": true, 3100 + "license": "MIT", 3101 + "engines": { 3102 + "node": ">=0.10.0" 3103 + } 3104 + }, 3105 + "node_modules/is-finalizationregistry": { 3106 + "version": "1.1.1", 3107 + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", 3108 + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", 3109 + "dev": true, 3110 + "license": "MIT", 3111 + "dependencies": { 3112 + "call-bound": "^1.0.3" 3113 + }, 3114 + "engines": { 3115 + "node": ">= 0.4" 3116 + }, 3117 + "funding": { 3118 + "url": "https://github.com/sponsors/ljharb" 3119 + } 3120 + }, 3121 + "node_modules/is-generator-function": { 3122 + "version": "1.1.2", 3123 + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", 3124 + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", 3125 + "dev": true, 3126 + "license": "MIT", 3127 + "dependencies": { 3128 + "call-bound": "^1.0.4", 3129 + "generator-function": "^2.0.0", 3130 + "get-proto": "^1.0.1", 3131 + "has-tostringtag": "^1.0.2", 3132 + "safe-regex-test": "^1.1.0" 3133 + }, 3134 + "engines": { 3135 + "node": ">= 0.4" 3136 + }, 3137 + "funding": { 3138 + "url": "https://github.com/sponsors/ljharb" 3139 + } 3140 + }, 3141 + "node_modules/is-glob": { 3142 + "version": "4.0.3", 3143 + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", 3144 + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", 3145 + "dev": true, 3146 + "license": "MIT", 3147 + "dependencies": { 3148 + "is-extglob": "^2.1.1" 3149 + }, 3150 + "engines": { 3151 + "node": ">=0.10.0" 3152 + } 3153 + }, 3154 + "node_modules/is-map": { 3155 + "version": "2.0.3", 3156 + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", 3157 + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", 3158 + "dev": true, 3159 + "license": "MIT", 3160 + "engines": { 3161 + "node": ">= 0.4" 3162 + }, 3163 + "funding": { 3164 + "url": "https://github.com/sponsors/ljharb" 3165 + } 3166 + }, 3167 + "node_modules/is-negative-zero": { 3168 + "version": "2.0.3", 3169 + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", 3170 + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", 3171 + "dev": true, 3172 + "license": "MIT", 3173 + "engines": { 3174 + "node": ">= 0.4" 3175 + }, 3176 + "funding": { 3177 + "url": "https://github.com/sponsors/ljharb" 3178 + } 3179 + }, 3180 + "node_modules/is-number-object": { 3181 + "version": "1.1.1", 3182 + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", 3183 + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", 3184 + "dev": true, 3185 + "license": "MIT", 3186 + "dependencies": { 3187 + "call-bound": "^1.0.3", 3188 + "has-tostringtag": "^1.0.2" 3189 + }, 3190 + "engines": { 3191 + "node": ">= 0.4" 3192 + }, 3193 + "funding": { 3194 + "url": "https://github.com/sponsors/ljharb" 3195 + } 3196 + }, 3197 + "node_modules/is-regex": { 3198 + "version": "1.2.1", 3199 + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", 3200 + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", 3201 + "dev": true, 3202 + "license": "MIT", 3203 + "dependencies": { 3204 + "call-bound": "^1.0.2", 3205 + "gopd": "^1.2.0", 3206 + "has-tostringtag": "^1.0.2", 3207 + "hasown": "^2.0.2" 3208 + }, 3209 + "engines": { 3210 + "node": ">= 0.4" 3211 + }, 3212 + "funding": { 3213 + "url": "https://github.com/sponsors/ljharb" 3214 + } 3215 + }, 3216 + "node_modules/is-set": { 3217 + "version": "2.0.3", 3218 + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", 3219 + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", 3220 + "dev": true, 3221 + "license": "MIT", 3222 + "engines": { 3223 + "node": ">= 0.4" 3224 + }, 3225 + "funding": { 3226 + "url": "https://github.com/sponsors/ljharb" 3227 + } 3228 + }, 3229 + "node_modules/is-shared-array-buffer": { 3230 + "version": "1.0.4", 3231 + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", 3232 + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", 3233 + "dev": true, 3234 + "license": "MIT", 3235 + "dependencies": { 3236 + "call-bound": "^1.0.3" 3237 + }, 3238 + "engines": { 3239 + "node": ">= 0.4" 3240 + }, 3241 + "funding": { 3242 + "url": "https://github.com/sponsors/ljharb" 3243 + } 3244 + }, 3245 + "node_modules/is-string": { 3246 + "version": "1.1.1", 3247 + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", 3248 + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", 3249 + "dev": true, 3250 + "license": "MIT", 3251 + "dependencies": { 3252 + "call-bound": "^1.0.3", 3253 + "has-tostringtag": "^1.0.2" 3254 + }, 3255 + "engines": { 3256 + "node": ">= 0.4" 3257 + }, 3258 + "funding": { 3259 + "url": "https://github.com/sponsors/ljharb" 3260 + } 3261 + }, 3262 + "node_modules/is-symbol": { 3263 + "version": "1.1.1", 3264 + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", 3265 + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", 3266 + "dev": true, 3267 + "license": "MIT", 3268 + "dependencies": { 3269 + "call-bound": "^1.0.2", 3270 + "has-symbols": "^1.1.0", 3271 + "safe-regex-test": "^1.1.0" 3272 + }, 3273 + "engines": { 3274 + "node": ">= 0.4" 3275 + }, 3276 + "funding": { 3277 + "url": "https://github.com/sponsors/ljharb" 3278 + } 3279 + }, 3280 + "node_modules/is-typed-array": { 3281 + "version": "1.1.15", 3282 + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", 3283 + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", 3284 + "dev": true, 3285 + "license": "MIT", 3286 + "dependencies": { 3287 + "which-typed-array": "^1.1.16" 3288 + }, 3289 + "engines": { 3290 + "node": ">= 0.4" 3291 + }, 3292 + "funding": { 3293 + "url": "https://github.com/sponsors/ljharb" 3294 + } 3295 + }, 3296 + "node_modules/is-weakmap": { 3297 + "version": "2.0.2", 3298 + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", 3299 + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", 3300 + "dev": true, 3301 + "license": "MIT", 3302 + "engines": { 3303 + "node": ">= 0.4" 3304 + }, 3305 + "funding": { 3306 + "url": "https://github.com/sponsors/ljharb" 3307 + } 3308 + }, 3309 + "node_modules/is-weakref": { 3310 + "version": "1.1.1", 3311 + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", 3312 + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", 3313 + "dev": true, 3314 + "license": "MIT", 3315 + "dependencies": { 3316 + "call-bound": "^1.0.3" 3317 + }, 3318 + "engines": { 3319 + "node": ">= 0.4" 3320 + }, 3321 + "funding": { 3322 + "url": "https://github.com/sponsors/ljharb" 3323 + } 3324 + }, 3325 + "node_modules/is-weakset": { 3326 + "version": "2.0.4", 3327 + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", 3328 + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", 3329 + "dev": true, 3330 + "license": "MIT", 3331 + "dependencies": { 3332 + "call-bound": "^1.0.3", 3333 + "get-intrinsic": "^1.2.6" 3334 + }, 3335 + "engines": { 3336 + "node": ">= 0.4" 3337 + }, 3338 + "funding": { 3339 + "url": "https://github.com/sponsors/ljharb" 3340 + } 3341 + }, 3342 + "node_modules/isarray": { 3343 + "version": "2.0.5", 3344 + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", 3345 + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", 3346 + "dev": true, 3347 + "license": "MIT" 3348 + }, 3349 + "node_modules/isexe": { 3350 + "version": "2.0.0", 3351 + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", 3352 + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", 3353 + "dev": true, 3354 + "license": "ISC" 3355 + }, 3356 + "node_modules/iterator.prototype": { 3357 + "version": "1.1.5", 3358 + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", 3359 + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", 3360 + "dev": true, 3361 + "license": "MIT", 3362 + "dependencies": { 3363 + "define-data-property": "^1.1.4", 3364 + "es-object-atoms": "^1.0.0", 3365 + "get-intrinsic": "^1.2.6", 3366 + "get-proto": "^1.0.0", 3367 + "has-symbols": "^1.1.0", 3368 + "set-function-name": "^2.0.2" 3369 + }, 3370 + "engines": { 3371 + "node": ">= 0.4" 3372 + } 3373 + }, 1425 3374 "node_modules/js-tokens": { 1426 3375 "version": "4.0.0", 1427 3376 "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", 1428 3377 "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", 1429 3378 "license": "MIT" 1430 3379 }, 3380 + "node_modules/js-yaml": { 3381 + "version": "4.1.1", 3382 + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", 3383 + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", 3384 + "dev": true, 3385 + "license": "MIT", 3386 + "dependencies": { 3387 + "argparse": "^2.0.1" 3388 + }, 3389 + "bin": { 3390 + "js-yaml": "bin/js-yaml.js" 3391 + } 3392 + }, 1431 3393 "node_modules/jsesc": { 1432 3394 "version": "3.1.0", 1433 3395 "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", ··· 1441 3403 "node": ">=6" 1442 3404 } 1443 3405 }, 3406 + "node_modules/json-buffer": { 3407 + "version": "3.0.1", 3408 + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", 3409 + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", 3410 + "dev": true, 3411 + "license": "MIT" 3412 + }, 3413 + "node_modules/json-schema-traverse": { 3414 + "version": "0.4.1", 3415 + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", 3416 + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", 3417 + "dev": true, 3418 + "license": "MIT" 3419 + }, 3420 + "node_modules/json-stable-stringify-without-jsonify": { 3421 + "version": "1.0.1", 3422 + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", 3423 + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", 3424 + "dev": true, 3425 + "license": "MIT" 3426 + }, 1444 3427 "node_modules/json5": { 1445 3428 "version": "2.2.3", 1446 3429 "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", ··· 1454 3437 "node": ">=6" 1455 3438 } 1456 3439 }, 3440 + "node_modules/jsx-ast-utils": { 3441 + "version": "3.3.5", 3442 + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", 3443 + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", 3444 + "dev": true, 3445 + "license": "MIT", 3446 + "dependencies": { 3447 + "array-includes": "^3.1.6", 3448 + "array.prototype.flat": "^1.3.1", 3449 + "object.assign": "^4.1.4", 3450 + "object.values": "^1.1.6" 3451 + }, 3452 + "engines": { 3453 + "node": ">=4.0" 3454 + } 3455 + }, 3456 + "node_modules/keyv": { 3457 + "version": "4.5.4", 3458 + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", 3459 + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", 3460 + "dev": true, 3461 + "license": "MIT", 3462 + "dependencies": { 3463 + "json-buffer": "3.0.1" 3464 + } 3465 + }, 3466 + "node_modules/levn": { 3467 + "version": "0.4.1", 3468 + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", 3469 + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", 3470 + "dev": true, 3471 + "license": "MIT", 3472 + "dependencies": { 3473 + "prelude-ls": "^1.2.1", 3474 + "type-check": "~0.4.0" 3475 + }, 3476 + "engines": { 3477 + "node": ">= 0.8.0" 3478 + } 3479 + }, 3480 + "node_modules/locate-path": { 3481 + "version": "6.0.0", 3482 + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", 3483 + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", 3484 + "dev": true, 3485 + "license": "MIT", 3486 + "dependencies": { 3487 + "p-locate": "^5.0.0" 3488 + }, 3489 + "engines": { 3490 + "node": ">=10" 3491 + }, 3492 + "funding": { 3493 + "url": "https://github.com/sponsors/sindresorhus" 3494 + } 3495 + }, 3496 + "node_modules/lodash.merge": { 3497 + "version": "4.6.2", 3498 + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", 3499 + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", 3500 + "dev": true, 3501 + "license": "MIT" 3502 + }, 1457 3503 "node_modules/loose-envify": { 1458 3504 "version": "1.4.0", 1459 3505 "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", ··· 1485 3531 "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" 1486 3532 } 1487 3533 }, 3534 + "node_modules/math-intrinsics": { 3535 + "version": "1.1.0", 3536 + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", 3537 + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", 3538 + "dev": true, 3539 + "license": "MIT", 3540 + "engines": { 3541 + "node": ">= 0.4" 3542 + } 3543 + }, 3544 + "node_modules/minimatch": { 3545 + "version": "3.1.2", 3546 + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", 3547 + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", 3548 + "dev": true, 3549 + "license": "ISC", 3550 + "dependencies": { 3551 + "brace-expansion": "^1.1.7" 3552 + }, 3553 + "engines": { 3554 + "node": "*" 3555 + } 3556 + }, 1488 3557 "node_modules/ms": { 1489 3558 "version": "2.1.3", 1490 3559 "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", ··· 1511 3580 "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" 1512 3581 } 1513 3582 }, 3583 + "node_modules/natural-compare": { 3584 + "version": "1.4.0", 3585 + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", 3586 + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", 3587 + "dev": true, 3588 + "license": "MIT" 3589 + }, 1514 3590 "node_modules/node-releases": { 1515 3591 "version": "2.0.27", 1516 3592 "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", 1517 3593 "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", 3594 + "dev": true, 3595 + "license": "MIT" 3596 + }, 3597 + "node_modules/object-assign": { 3598 + "version": "4.1.1", 3599 + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", 3600 + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", 3601 + "dev": true, 3602 + "license": "MIT", 3603 + "engines": { 3604 + "node": ">=0.10.0" 3605 + } 3606 + }, 3607 + "node_modules/object-inspect": { 3608 + "version": "1.13.4", 3609 + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", 3610 + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", 3611 + "dev": true, 3612 + "license": "MIT", 3613 + "engines": { 3614 + "node": ">= 0.4" 3615 + }, 3616 + "funding": { 3617 + "url": "https://github.com/sponsors/ljharb" 3618 + } 3619 + }, 3620 + "node_modules/object-keys": { 3621 + "version": "1.1.1", 3622 + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", 3623 + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", 3624 + "dev": true, 3625 + "license": "MIT", 3626 + "engines": { 3627 + "node": ">= 0.4" 3628 + } 3629 + }, 3630 + "node_modules/object.assign": { 3631 + "version": "4.1.7", 3632 + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", 3633 + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", 3634 + "dev": true, 3635 + "license": "MIT", 3636 + "dependencies": { 3637 + "call-bind": "^1.0.8", 3638 + "call-bound": "^1.0.3", 3639 + "define-properties": "^1.2.1", 3640 + "es-object-atoms": "^1.0.0", 3641 + "has-symbols": "^1.1.0", 3642 + "object-keys": "^1.1.1" 3643 + }, 3644 + "engines": { 3645 + "node": ">= 0.4" 3646 + }, 3647 + "funding": { 3648 + "url": "https://github.com/sponsors/ljharb" 3649 + } 3650 + }, 3651 + "node_modules/object.entries": { 3652 + "version": "1.1.9", 3653 + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", 3654 + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", 3655 + "dev": true, 3656 + "license": "MIT", 3657 + "dependencies": { 3658 + "call-bind": "^1.0.8", 3659 + "call-bound": "^1.0.4", 3660 + "define-properties": "^1.2.1", 3661 + "es-object-atoms": "^1.1.1" 3662 + }, 3663 + "engines": { 3664 + "node": ">= 0.4" 3665 + } 3666 + }, 3667 + "node_modules/object.fromentries": { 3668 + "version": "2.0.8", 3669 + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", 3670 + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", 3671 + "dev": true, 3672 + "license": "MIT", 3673 + "dependencies": { 3674 + "call-bind": "^1.0.7", 3675 + "define-properties": "^1.2.1", 3676 + "es-abstract": "^1.23.2", 3677 + "es-object-atoms": "^1.0.0" 3678 + }, 3679 + "engines": { 3680 + "node": ">= 0.4" 3681 + }, 3682 + "funding": { 3683 + "url": "https://github.com/sponsors/ljharb" 3684 + } 3685 + }, 3686 + "node_modules/object.values": { 3687 + "version": "1.2.1", 3688 + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", 3689 + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", 3690 + "dev": true, 3691 + "license": "MIT", 3692 + "dependencies": { 3693 + "call-bind": "^1.0.8", 3694 + "call-bound": "^1.0.3", 3695 + "define-properties": "^1.2.1", 3696 + "es-object-atoms": "^1.0.0" 3697 + }, 3698 + "engines": { 3699 + "node": ">= 0.4" 3700 + }, 3701 + "funding": { 3702 + "url": "https://github.com/sponsors/ljharb" 3703 + } 3704 + }, 3705 + "node_modules/optionator": { 3706 + "version": "0.9.4", 3707 + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", 3708 + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", 3709 + "dev": true, 3710 + "license": "MIT", 3711 + "dependencies": { 3712 + "deep-is": "^0.1.3", 3713 + "fast-levenshtein": "^2.0.6", 3714 + "levn": "^0.4.1", 3715 + "prelude-ls": "^1.2.1", 3716 + "type-check": "^0.4.0", 3717 + "word-wrap": "^1.2.5" 3718 + }, 3719 + "engines": { 3720 + "node": ">= 0.8.0" 3721 + } 3722 + }, 3723 + "node_modules/own-keys": { 3724 + "version": "1.0.1", 3725 + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", 3726 + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", 3727 + "dev": true, 3728 + "license": "MIT", 3729 + "dependencies": { 3730 + "get-intrinsic": "^1.2.6", 3731 + "object-keys": "^1.1.1", 3732 + "safe-push-apply": "^1.0.0" 3733 + }, 3734 + "engines": { 3735 + "node": ">= 0.4" 3736 + }, 3737 + "funding": { 3738 + "url": "https://github.com/sponsors/ljharb" 3739 + } 3740 + }, 3741 + "node_modules/p-limit": { 3742 + "version": "3.1.0", 3743 + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", 3744 + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", 3745 + "dev": true, 3746 + "license": "MIT", 3747 + "dependencies": { 3748 + "yocto-queue": "^0.1.0" 3749 + }, 3750 + "engines": { 3751 + "node": ">=10" 3752 + }, 3753 + "funding": { 3754 + "url": "https://github.com/sponsors/sindresorhus" 3755 + } 3756 + }, 3757 + "node_modules/p-locate": { 3758 + "version": "5.0.0", 3759 + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", 3760 + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", 3761 + "dev": true, 3762 + "license": "MIT", 3763 + "dependencies": { 3764 + "p-limit": "^3.0.2" 3765 + }, 3766 + "engines": { 3767 + "node": ">=10" 3768 + }, 3769 + "funding": { 3770 + "url": "https://github.com/sponsors/sindresorhus" 3771 + } 3772 + }, 3773 + "node_modules/parent-module": { 3774 + "version": "1.0.1", 3775 + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", 3776 + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", 3777 + "dev": true, 3778 + "license": "MIT", 3779 + "dependencies": { 3780 + "callsites": "^3.0.0" 3781 + }, 3782 + "engines": { 3783 + "node": ">=6" 3784 + } 3785 + }, 3786 + "node_modules/path-exists": { 3787 + "version": "4.0.0", 3788 + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", 3789 + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", 3790 + "dev": true, 3791 + "license": "MIT", 3792 + "engines": { 3793 + "node": ">=8" 3794 + } 3795 + }, 3796 + "node_modules/path-key": { 3797 + "version": "3.1.1", 3798 + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", 3799 + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", 3800 + "dev": true, 3801 + "license": "MIT", 3802 + "engines": { 3803 + "node": ">=8" 3804 + } 3805 + }, 3806 + "node_modules/path-parse": { 3807 + "version": "1.0.7", 3808 + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", 3809 + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", 1518 3810 "dev": true, 1519 3811 "license": "MIT" 1520 3812 }, ··· 1539 3831 "url": "https://github.com/sponsors/jonschlinkert" 1540 3832 } 1541 3833 }, 3834 + "node_modules/possible-typed-array-names": { 3835 + "version": "1.1.0", 3836 + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", 3837 + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", 3838 + "dev": true, 3839 + "license": "MIT", 3840 + "engines": { 3841 + "node": ">= 0.4" 3842 + } 3843 + }, 1542 3844 "node_modules/postcss": { 1543 3845 "version": "8.5.6", 1544 3846 "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", ··· 1568 3870 "node": "^10 || ^12 || >=14" 1569 3871 } 1570 3872 }, 3873 + "node_modules/prelude-ls": { 3874 + "version": "1.2.1", 3875 + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", 3876 + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", 3877 + "dev": true, 3878 + "license": "MIT", 3879 + "engines": { 3880 + "node": ">= 0.8.0" 3881 + } 3882 + }, 3883 + "node_modules/prop-types": { 3884 + "version": "15.8.1", 3885 + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", 3886 + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", 3887 + "dev": true, 3888 + "license": "MIT", 3889 + "dependencies": { 3890 + "loose-envify": "^1.4.0", 3891 + "object-assign": "^4.1.1", 3892 + "react-is": "^16.13.1" 3893 + } 3894 + }, 3895 + "node_modules/punycode": { 3896 + "version": "2.3.1", 3897 + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", 3898 + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", 3899 + "dev": true, 3900 + "license": "MIT", 3901 + "engines": { 3902 + "node": ">=6" 3903 + } 3904 + }, 1571 3905 "node_modules/react": { 1572 3906 "version": "18.3.1", 1573 3907 "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", ··· 1604 3938 "react": "*" 1605 3939 } 1606 3940 }, 3941 + "node_modules/react-is": { 3942 + "version": "16.13.1", 3943 + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", 3944 + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", 3945 + "dev": true, 3946 + "license": "MIT" 3947 + }, 1607 3948 "node_modules/react-refresh": { 1608 3949 "version": "0.17.0", 1609 3950 "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", ··· 1615 3956 } 1616 3957 }, 1617 3958 "node_modules/react-router": { 1618 - "version": "6.30.2", 1619 - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.2.tgz", 1620 - "integrity": "sha512-H2Bm38Zu1bm8KUE5NVWRMzuIyAV8p/JrOaBJAwVmp37AXG72+CZJlEBw6pdn9i5TBgLMhNDgijS4ZlblpHyWTA==", 3959 + "version": "6.30.3", 3960 + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", 3961 + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", 1621 3962 "license": "MIT", 1622 3963 "dependencies": { 1623 - "@remix-run/router": "1.23.1" 3964 + "@remix-run/router": "1.23.2" 1624 3965 }, 1625 3966 "engines": { 1626 3967 "node": ">=14.0.0" ··· 1630 3971 } 1631 3972 }, 1632 3973 "node_modules/react-router-dom": { 1633 - "version": "6.30.2", 1634 - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.2.tgz", 1635 - "integrity": "sha512-l2OwHn3UUnEVUqc6/1VMmR1cvZryZ3j3NzapC2eUXO1dB0sYp5mvwdjiXhpUbRb21eFow3qSxpP8Yv6oAU824Q==", 3974 + "version": "6.30.3", 3975 + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", 3976 + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", 1636 3977 "license": "MIT", 1637 3978 "dependencies": { 1638 - "@remix-run/router": "1.23.1", 1639 - "react-router": "6.30.2" 3979 + "@remix-run/router": "1.23.2", 3980 + "react-router": "6.30.3" 1640 3981 }, 1641 3982 "engines": { 1642 3983 "node": ">=14.0.0" ··· 1646 3987 "react-dom": ">=16.8" 1647 3988 } 1648 3989 }, 3990 + "node_modules/reflect.getprototypeof": { 3991 + "version": "1.0.10", 3992 + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", 3993 + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", 3994 + "dev": true, 3995 + "license": "MIT", 3996 + "dependencies": { 3997 + "call-bind": "^1.0.8", 3998 + "define-properties": "^1.2.1", 3999 + "es-abstract": "^1.23.9", 4000 + "es-errors": "^1.3.0", 4001 + "es-object-atoms": "^1.0.0", 4002 + "get-intrinsic": "^1.2.7", 4003 + "get-proto": "^1.0.1", 4004 + "which-builtin-type": "^1.2.1" 4005 + }, 4006 + "engines": { 4007 + "node": ">= 0.4" 4008 + }, 4009 + "funding": { 4010 + "url": "https://github.com/sponsors/ljharb" 4011 + } 4012 + }, 4013 + "node_modules/regexp.prototype.flags": { 4014 + "version": "1.5.4", 4015 + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", 4016 + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", 4017 + "dev": true, 4018 + "license": "MIT", 4019 + "dependencies": { 4020 + "call-bind": "^1.0.8", 4021 + "define-properties": "^1.2.1", 4022 + "es-errors": "^1.3.0", 4023 + "get-proto": "^1.0.1", 4024 + "gopd": "^1.2.0", 4025 + "set-function-name": "^2.0.2" 4026 + }, 4027 + "engines": { 4028 + "node": ">= 0.4" 4029 + }, 4030 + "funding": { 4031 + "url": "https://github.com/sponsors/ljharb" 4032 + } 4033 + }, 4034 + "node_modules/resolve": { 4035 + "version": "2.0.0-next.5", 4036 + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", 4037 + "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", 4038 + "dev": true, 4039 + "license": "MIT", 4040 + "dependencies": { 4041 + "is-core-module": "^2.13.0", 4042 + "path-parse": "^1.0.7", 4043 + "supports-preserve-symlinks-flag": "^1.0.0" 4044 + }, 4045 + "bin": { 4046 + "resolve": "bin/resolve" 4047 + }, 4048 + "funding": { 4049 + "url": "https://github.com/sponsors/ljharb" 4050 + } 4051 + }, 4052 + "node_modules/resolve-from": { 4053 + "version": "4.0.0", 4054 + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", 4055 + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", 4056 + "dev": true, 4057 + "license": "MIT", 4058 + "engines": { 4059 + "node": ">=4" 4060 + } 4061 + }, 1649 4062 "node_modules/rollup": { 1650 4063 "version": "4.54.0", 1651 4064 "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz", ··· 1688 4101 "fsevents": "~2.3.2" 1689 4102 } 1690 4103 }, 4104 + "node_modules/safe-array-concat": { 4105 + "version": "1.1.3", 4106 + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", 4107 + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", 4108 + "dev": true, 4109 + "license": "MIT", 4110 + "dependencies": { 4111 + "call-bind": "^1.0.8", 4112 + "call-bound": "^1.0.2", 4113 + "get-intrinsic": "^1.2.6", 4114 + "has-symbols": "^1.1.0", 4115 + "isarray": "^2.0.5" 4116 + }, 4117 + "engines": { 4118 + "node": ">=0.4" 4119 + }, 4120 + "funding": { 4121 + "url": "https://github.com/sponsors/ljharb" 4122 + } 4123 + }, 4124 + "node_modules/safe-push-apply": { 4125 + "version": "1.0.0", 4126 + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", 4127 + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", 4128 + "dev": true, 4129 + "license": "MIT", 4130 + "dependencies": { 4131 + "es-errors": "^1.3.0", 4132 + "isarray": "^2.0.5" 4133 + }, 4134 + "engines": { 4135 + "node": ">= 0.4" 4136 + }, 4137 + "funding": { 4138 + "url": "https://github.com/sponsors/ljharb" 4139 + } 4140 + }, 4141 + "node_modules/safe-regex-test": { 4142 + "version": "1.1.0", 4143 + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", 4144 + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", 4145 + "dev": true, 4146 + "license": "MIT", 4147 + "dependencies": { 4148 + "call-bound": "^1.0.2", 4149 + "es-errors": "^1.3.0", 4150 + "is-regex": "^1.2.1" 4151 + }, 4152 + "engines": { 4153 + "node": ">= 0.4" 4154 + }, 4155 + "funding": { 4156 + "url": "https://github.com/sponsors/ljharb" 4157 + } 4158 + }, 1691 4159 "node_modules/scheduler": { 1692 4160 "version": "0.23.2", 1693 4161 "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", ··· 1707 4175 "semver": "bin/semver.js" 1708 4176 } 1709 4177 }, 4178 + "node_modules/set-function-length": { 4179 + "version": "1.2.2", 4180 + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", 4181 + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", 4182 + "dev": true, 4183 + "license": "MIT", 4184 + "dependencies": { 4185 + "define-data-property": "^1.1.4", 4186 + "es-errors": "^1.3.0", 4187 + "function-bind": "^1.1.2", 4188 + "get-intrinsic": "^1.2.4", 4189 + "gopd": "^1.0.1", 4190 + "has-property-descriptors": "^1.0.2" 4191 + }, 4192 + "engines": { 4193 + "node": ">= 0.4" 4194 + } 4195 + }, 4196 + "node_modules/set-function-name": { 4197 + "version": "2.0.2", 4198 + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", 4199 + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", 4200 + "dev": true, 4201 + "license": "MIT", 4202 + "dependencies": { 4203 + "define-data-property": "^1.1.4", 4204 + "es-errors": "^1.3.0", 4205 + "functions-have-names": "^1.2.3", 4206 + "has-property-descriptors": "^1.0.2" 4207 + }, 4208 + "engines": { 4209 + "node": ">= 0.4" 4210 + } 4211 + }, 4212 + "node_modules/set-proto": { 4213 + "version": "1.0.0", 4214 + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", 4215 + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", 4216 + "dev": true, 4217 + "license": "MIT", 4218 + "dependencies": { 4219 + "dunder-proto": "^1.0.1", 4220 + "es-errors": "^1.3.0", 4221 + "es-object-atoms": "^1.0.0" 4222 + }, 4223 + "engines": { 4224 + "node": ">= 0.4" 4225 + } 4226 + }, 4227 + "node_modules/shebang-command": { 4228 + "version": "2.0.0", 4229 + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", 4230 + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", 4231 + "dev": true, 4232 + "license": "MIT", 4233 + "dependencies": { 4234 + "shebang-regex": "^3.0.0" 4235 + }, 4236 + "engines": { 4237 + "node": ">=8" 4238 + } 4239 + }, 4240 + "node_modules/shebang-regex": { 4241 + "version": "3.0.0", 4242 + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", 4243 + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", 4244 + "dev": true, 4245 + "license": "MIT", 4246 + "engines": { 4247 + "node": ">=8" 4248 + } 4249 + }, 4250 + "node_modules/side-channel": { 4251 + "version": "1.1.0", 4252 + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", 4253 + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", 4254 + "dev": true, 4255 + "license": "MIT", 4256 + "dependencies": { 4257 + "es-errors": "^1.3.0", 4258 + "object-inspect": "^1.13.3", 4259 + "side-channel-list": "^1.0.0", 4260 + "side-channel-map": "^1.0.1", 4261 + "side-channel-weakmap": "^1.0.2" 4262 + }, 4263 + "engines": { 4264 + "node": ">= 0.4" 4265 + }, 4266 + "funding": { 4267 + "url": "https://github.com/sponsors/ljharb" 4268 + } 4269 + }, 4270 + "node_modules/side-channel-list": { 4271 + "version": "1.0.0", 4272 + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", 4273 + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", 4274 + "dev": true, 4275 + "license": "MIT", 4276 + "dependencies": { 4277 + "es-errors": "^1.3.0", 4278 + "object-inspect": "^1.13.3" 4279 + }, 4280 + "engines": { 4281 + "node": ">= 0.4" 4282 + }, 4283 + "funding": { 4284 + "url": "https://github.com/sponsors/ljharb" 4285 + } 4286 + }, 4287 + "node_modules/side-channel-map": { 4288 + "version": "1.0.1", 4289 + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", 4290 + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", 4291 + "dev": true, 4292 + "license": "MIT", 4293 + "dependencies": { 4294 + "call-bound": "^1.0.2", 4295 + "es-errors": "^1.3.0", 4296 + "get-intrinsic": "^1.2.5", 4297 + "object-inspect": "^1.13.3" 4298 + }, 4299 + "engines": { 4300 + "node": ">= 0.4" 4301 + }, 4302 + "funding": { 4303 + "url": "https://github.com/sponsors/ljharb" 4304 + } 4305 + }, 4306 + "node_modules/side-channel-weakmap": { 4307 + "version": "1.0.2", 4308 + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", 4309 + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", 4310 + "dev": true, 4311 + "license": "MIT", 4312 + "dependencies": { 4313 + "call-bound": "^1.0.2", 4314 + "es-errors": "^1.3.0", 4315 + "get-intrinsic": "^1.2.5", 4316 + "object-inspect": "^1.13.3", 4317 + "side-channel-map": "^1.0.1" 4318 + }, 4319 + "engines": { 4320 + "node": ">= 0.4" 4321 + }, 4322 + "funding": { 4323 + "url": "https://github.com/sponsors/ljharb" 4324 + } 4325 + }, 1710 4326 "node_modules/source-map-js": { 1711 4327 "version": "1.2.1", 1712 4328 "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", ··· 1717 4333 "node": ">=0.10.0" 1718 4334 } 1719 4335 }, 4336 + "node_modules/stop-iteration-iterator": { 4337 + "version": "1.1.0", 4338 + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", 4339 + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", 4340 + "dev": true, 4341 + "license": "MIT", 4342 + "dependencies": { 4343 + "es-errors": "^1.3.0", 4344 + "internal-slot": "^1.1.0" 4345 + }, 4346 + "engines": { 4347 + "node": ">= 0.4" 4348 + } 4349 + }, 4350 + "node_modules/string.prototype.matchall": { 4351 + "version": "4.0.12", 4352 + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", 4353 + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", 4354 + "dev": true, 4355 + "license": "MIT", 4356 + "dependencies": { 4357 + "call-bind": "^1.0.8", 4358 + "call-bound": "^1.0.3", 4359 + "define-properties": "^1.2.1", 4360 + "es-abstract": "^1.23.6", 4361 + "es-errors": "^1.3.0", 4362 + "es-object-atoms": "^1.0.0", 4363 + "get-intrinsic": "^1.2.6", 4364 + "gopd": "^1.2.0", 4365 + "has-symbols": "^1.1.0", 4366 + "internal-slot": "^1.1.0", 4367 + "regexp.prototype.flags": "^1.5.3", 4368 + "set-function-name": "^2.0.2", 4369 + "side-channel": "^1.1.0" 4370 + }, 4371 + "engines": { 4372 + "node": ">= 0.4" 4373 + }, 4374 + "funding": { 4375 + "url": "https://github.com/sponsors/ljharb" 4376 + } 4377 + }, 4378 + "node_modules/string.prototype.repeat": { 4379 + "version": "1.0.0", 4380 + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", 4381 + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", 4382 + "dev": true, 4383 + "license": "MIT", 4384 + "dependencies": { 4385 + "define-properties": "^1.1.3", 4386 + "es-abstract": "^1.17.5" 4387 + } 4388 + }, 4389 + "node_modules/string.prototype.trim": { 4390 + "version": "1.2.10", 4391 + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", 4392 + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", 4393 + "dev": true, 4394 + "license": "MIT", 4395 + "dependencies": { 4396 + "call-bind": "^1.0.8", 4397 + "call-bound": "^1.0.2", 4398 + "define-data-property": "^1.1.4", 4399 + "define-properties": "^1.2.1", 4400 + "es-abstract": "^1.23.5", 4401 + "es-object-atoms": "^1.0.0", 4402 + "has-property-descriptors": "^1.0.2" 4403 + }, 4404 + "engines": { 4405 + "node": ">= 0.4" 4406 + }, 4407 + "funding": { 4408 + "url": "https://github.com/sponsors/ljharb" 4409 + } 4410 + }, 4411 + "node_modules/string.prototype.trimend": { 4412 + "version": "1.0.9", 4413 + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", 4414 + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", 4415 + "dev": true, 4416 + "license": "MIT", 4417 + "dependencies": { 4418 + "call-bind": "^1.0.8", 4419 + "call-bound": "^1.0.2", 4420 + "define-properties": "^1.2.1", 4421 + "es-object-atoms": "^1.0.0" 4422 + }, 4423 + "engines": { 4424 + "node": ">= 0.4" 4425 + }, 4426 + "funding": { 4427 + "url": "https://github.com/sponsors/ljharb" 4428 + } 4429 + }, 4430 + "node_modules/string.prototype.trimstart": { 4431 + "version": "1.0.8", 4432 + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", 4433 + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", 4434 + "dev": true, 4435 + "license": "MIT", 4436 + "dependencies": { 4437 + "call-bind": "^1.0.7", 4438 + "define-properties": "^1.2.1", 4439 + "es-object-atoms": "^1.0.0" 4440 + }, 4441 + "engines": { 4442 + "node": ">= 0.4" 4443 + }, 4444 + "funding": { 4445 + "url": "https://github.com/sponsors/ljharb" 4446 + } 4447 + }, 4448 + "node_modules/strip-json-comments": { 4449 + "version": "3.1.1", 4450 + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", 4451 + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", 4452 + "dev": true, 4453 + "license": "MIT", 4454 + "engines": { 4455 + "node": ">=8" 4456 + }, 4457 + "funding": { 4458 + "url": "https://github.com/sponsors/sindresorhus" 4459 + } 4460 + }, 4461 + "node_modules/supports-color": { 4462 + "version": "7.2.0", 4463 + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", 4464 + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", 4465 + "dev": true, 4466 + "license": "MIT", 4467 + "dependencies": { 4468 + "has-flag": "^4.0.0" 4469 + }, 4470 + "engines": { 4471 + "node": ">=8" 4472 + } 4473 + }, 4474 + "node_modules/supports-preserve-symlinks-flag": { 4475 + "version": "1.0.0", 4476 + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", 4477 + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", 4478 + "dev": true, 4479 + "license": "MIT", 4480 + "engines": { 4481 + "node": ">= 0.4" 4482 + }, 4483 + "funding": { 4484 + "url": "https://github.com/sponsors/ljharb" 4485 + } 4486 + }, 1720 4487 "node_modules/tinyglobby": { 1721 4488 "version": "0.2.15", 1722 4489 "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", ··· 1734 4501 "url": "https://github.com/sponsors/SuperchupuDev" 1735 4502 } 1736 4503 }, 4504 + "node_modules/type-check": { 4505 + "version": "0.4.0", 4506 + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", 4507 + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", 4508 + "dev": true, 4509 + "license": "MIT", 4510 + "dependencies": { 4511 + "prelude-ls": "^1.2.1" 4512 + }, 4513 + "engines": { 4514 + "node": ">= 0.8.0" 4515 + } 4516 + }, 4517 + "node_modules/typed-array-buffer": { 4518 + "version": "1.0.3", 4519 + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", 4520 + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", 4521 + "dev": true, 4522 + "license": "MIT", 4523 + "dependencies": { 4524 + "call-bound": "^1.0.3", 4525 + "es-errors": "^1.3.0", 4526 + "is-typed-array": "^1.1.14" 4527 + }, 4528 + "engines": { 4529 + "node": ">= 0.4" 4530 + } 4531 + }, 4532 + "node_modules/typed-array-byte-length": { 4533 + "version": "1.0.3", 4534 + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", 4535 + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", 4536 + "dev": true, 4537 + "license": "MIT", 4538 + "dependencies": { 4539 + "call-bind": "^1.0.8", 4540 + "for-each": "^0.3.3", 4541 + "gopd": "^1.2.0", 4542 + "has-proto": "^1.2.0", 4543 + "is-typed-array": "^1.1.14" 4544 + }, 4545 + "engines": { 4546 + "node": ">= 0.4" 4547 + }, 4548 + "funding": { 4549 + "url": "https://github.com/sponsors/ljharb" 4550 + } 4551 + }, 4552 + "node_modules/typed-array-byte-offset": { 4553 + "version": "1.0.4", 4554 + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", 4555 + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", 4556 + "dev": true, 4557 + "license": "MIT", 4558 + "dependencies": { 4559 + "available-typed-arrays": "^1.0.7", 4560 + "call-bind": "^1.0.8", 4561 + "for-each": "^0.3.3", 4562 + "gopd": "^1.2.0", 4563 + "has-proto": "^1.2.0", 4564 + "is-typed-array": "^1.1.15", 4565 + "reflect.getprototypeof": "^1.0.9" 4566 + }, 4567 + "engines": { 4568 + "node": ">= 0.4" 4569 + }, 4570 + "funding": { 4571 + "url": "https://github.com/sponsors/ljharb" 4572 + } 4573 + }, 4574 + "node_modules/typed-array-length": { 4575 + "version": "1.0.7", 4576 + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", 4577 + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", 4578 + "dev": true, 4579 + "license": "MIT", 4580 + "dependencies": { 4581 + "call-bind": "^1.0.7", 4582 + "for-each": "^0.3.3", 4583 + "gopd": "^1.0.1", 4584 + "is-typed-array": "^1.1.13", 4585 + "possible-typed-array-names": "^1.0.0", 4586 + "reflect.getprototypeof": "^1.0.6" 4587 + }, 4588 + "engines": { 4589 + "node": ">= 0.4" 4590 + }, 4591 + "funding": { 4592 + "url": "https://github.com/sponsors/ljharb" 4593 + } 4594 + }, 4595 + "node_modules/unbox-primitive": { 4596 + "version": "1.1.0", 4597 + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", 4598 + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", 4599 + "dev": true, 4600 + "license": "MIT", 4601 + "dependencies": { 4602 + "call-bound": "^1.0.3", 4603 + "has-bigints": "^1.0.2", 4604 + "has-symbols": "^1.1.0", 4605 + "which-boxed-primitive": "^1.1.1" 4606 + }, 4607 + "engines": { 4608 + "node": ">= 0.4" 4609 + }, 4610 + "funding": { 4611 + "url": "https://github.com/sponsors/ljharb" 4612 + } 4613 + }, 1737 4614 "node_modules/update-browserslist-db": { 1738 4615 "version": "1.2.3", 1739 4616 "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", ··· 1763 4640 }, 1764 4641 "peerDependencies": { 1765 4642 "browserslist": ">= 4.21.0" 4643 + } 4644 + }, 4645 + "node_modules/uri-js": { 4646 + "version": "4.4.1", 4647 + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", 4648 + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", 4649 + "dev": true, 4650 + "license": "BSD-2-Clause", 4651 + "dependencies": { 4652 + "punycode": "^2.1.0" 1766 4653 } 1767 4654 }, 1768 4655 "node_modules/vite": { ··· 1841 4728 } 1842 4729 } 1843 4730 }, 4731 + "node_modules/which": { 4732 + "version": "2.0.2", 4733 + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", 4734 + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", 4735 + "dev": true, 4736 + "license": "ISC", 4737 + "dependencies": { 4738 + "isexe": "^2.0.0" 4739 + }, 4740 + "bin": { 4741 + "node-which": "bin/node-which" 4742 + }, 4743 + "engines": { 4744 + "node": ">= 8" 4745 + } 4746 + }, 4747 + "node_modules/which-boxed-primitive": { 4748 + "version": "1.1.1", 4749 + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", 4750 + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", 4751 + "dev": true, 4752 + "license": "MIT", 4753 + "dependencies": { 4754 + "is-bigint": "^1.1.0", 4755 + "is-boolean-object": "^1.2.1", 4756 + "is-number-object": "^1.1.1", 4757 + "is-string": "^1.1.1", 4758 + "is-symbol": "^1.1.1" 4759 + }, 4760 + "engines": { 4761 + "node": ">= 0.4" 4762 + }, 4763 + "funding": { 4764 + "url": "https://github.com/sponsors/ljharb" 4765 + } 4766 + }, 4767 + "node_modules/which-builtin-type": { 4768 + "version": "1.2.1", 4769 + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", 4770 + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", 4771 + "dev": true, 4772 + "license": "MIT", 4773 + "dependencies": { 4774 + "call-bound": "^1.0.2", 4775 + "function.prototype.name": "^1.1.6", 4776 + "has-tostringtag": "^1.0.2", 4777 + "is-async-function": "^2.0.0", 4778 + "is-date-object": "^1.1.0", 4779 + "is-finalizationregistry": "^1.1.0", 4780 + "is-generator-function": "^1.0.10", 4781 + "is-regex": "^1.2.1", 4782 + "is-weakref": "^1.0.2", 4783 + "isarray": "^2.0.5", 4784 + "which-boxed-primitive": "^1.1.0", 4785 + "which-collection": "^1.0.2", 4786 + "which-typed-array": "^1.1.16" 4787 + }, 4788 + "engines": { 4789 + "node": ">= 0.4" 4790 + }, 4791 + "funding": { 4792 + "url": "https://github.com/sponsors/ljharb" 4793 + } 4794 + }, 4795 + "node_modules/which-collection": { 4796 + "version": "1.0.2", 4797 + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", 4798 + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", 4799 + "dev": true, 4800 + "license": "MIT", 4801 + "dependencies": { 4802 + "is-map": "^2.0.3", 4803 + "is-set": "^2.0.3", 4804 + "is-weakmap": "^2.0.2", 4805 + "is-weakset": "^2.0.3" 4806 + }, 4807 + "engines": { 4808 + "node": ">= 0.4" 4809 + }, 4810 + "funding": { 4811 + "url": "https://github.com/sponsors/ljharb" 4812 + } 4813 + }, 4814 + "node_modules/which-typed-array": { 4815 + "version": "1.1.20", 4816 + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", 4817 + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", 4818 + "dev": true, 4819 + "license": "MIT", 4820 + "dependencies": { 4821 + "available-typed-arrays": "^1.0.7", 4822 + "call-bind": "^1.0.8", 4823 + "call-bound": "^1.0.4", 4824 + "for-each": "^0.3.5", 4825 + "get-proto": "^1.0.1", 4826 + "gopd": "^1.2.0", 4827 + "has-tostringtag": "^1.0.2" 4828 + }, 4829 + "engines": { 4830 + "node": ">= 0.4" 4831 + }, 4832 + "funding": { 4833 + "url": "https://github.com/sponsors/ljharb" 4834 + } 4835 + }, 4836 + "node_modules/word-wrap": { 4837 + "version": "1.2.5", 4838 + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", 4839 + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", 4840 + "dev": true, 4841 + "license": "MIT", 4842 + "engines": { 4843 + "node": ">=0.10.0" 4844 + } 4845 + }, 1844 4846 "node_modules/yallist": { 1845 4847 "version": "3.1.1", 1846 4848 "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", 1847 4849 "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", 1848 4850 "dev": true, 1849 4851 "license": "ISC" 4852 + }, 4853 + "node_modules/yocto-queue": { 4854 + "version": "0.1.0", 4855 + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", 4856 + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", 4857 + "dev": true, 4858 + "license": "MIT", 4859 + "engines": { 4860 + "node": ">=10" 4861 + }, 4862 + "funding": { 4863 + "url": "https://github.com/sponsors/sindresorhus" 4864 + } 4865 + }, 4866 + "node_modules/zod": { 4867 + "version": "4.3.5", 4868 + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", 4869 + "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", 4870 + "dev": true, 4871 + "license": "MIT", 4872 + "peer": true, 4873 + "funding": { 4874 + "url": "https://github.com/sponsors/colinhacks" 4875 + } 4876 + }, 4877 + "node_modules/zod-validation-error": { 4878 + "version": "4.0.2", 4879 + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", 4880 + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", 4881 + "dev": true, 4882 + "license": "MIT", 4883 + "engines": { 4884 + "node": ">=18.0.0" 4885 + }, 4886 + "peerDependencies": { 4887 + "zod": "^3.25.0 || ^4.0.0" 4888 + } 1850 4889 } 1851 4890 } 1852 4891 }
+7
web/package.json
··· 6 6 "scripts": { 7 7 "dev": "vite", 8 8 "build": "vite build", 9 + "lint": "eslint .", 9 10 "preview": "vite preview" 10 11 }, 11 12 "dependencies": { ··· 16 17 "react-router-dom": "^6.28.0" 17 18 }, 18 19 "devDependencies": { 20 + "@eslint/js": "^9.39.2", 19 21 "@types/react": "^18.3.12", 20 22 "@types/react-dom": "^18.3.1", 21 23 "@vitejs/plugin-react": "^4.3.3", 24 + "eslint": "^9.39.2", 25 + "eslint-plugin-react": "^7.37.5", 26 + "eslint-plugin-react-hooks": "^7.0.1", 27 + "eslint-plugin-react-refresh": "^0.4.26", 28 + "globals": "^17.0.0", 22 29 "vite": "^6.0.3" 23 30 } 24 31 }
+49 -23
web/src/App.jsx
··· 1 1 import { Routes, Route } from "react-router-dom"; 2 2 import { AuthProvider } from "./context/AuthContext"; 3 - import Navbar from "./components/Navbar"; 3 + import Sidebar from "./components/Sidebar"; 4 + import RightSidebar from "./components/RightSidebar"; 5 + import MobileNav from "./components/MobileNav"; 4 6 import Feed from "./pages/Feed"; 5 7 import Url from "./pages/Url"; 6 8 import Profile from "./pages/Profile"; ··· 14 16 import CollectionDetail from "./pages/CollectionDetail"; 15 17 import Privacy from "./pages/Privacy"; 16 18 19 + import Terms from "./pages/Terms"; 20 + 21 + import ScrollToTop from "./components/ScrollToTop"; 22 + 17 23 function AppContent() { 18 24 return ( 19 - <div className="app"> 20 - <Navbar /> 21 - <main className="main-content"> 22 - <Routes> 23 - <Route path="/" element={<Feed />} /> 24 - <Route path="/url" element={<Url />} /> 25 - <Route path="/new" element={<New />} /> 26 - <Route path="/bookmarks" element={<Bookmarks />} /> 27 - <Route path="/highlights" element={<Highlights />} /> 28 - <Route path="/notifications" element={<Notifications />} /> 29 - <Route path="/profile/:handle" element={<Profile />} /> 30 - <Route path="/login" element={<Login />} /> 31 - {} 32 - <Route path="/at/:did/:rkey" element={<AnnotationDetail />} /> 33 - {} 34 - <Route path="/annotation/:uri" element={<AnnotationDetail />} /> 35 - <Route path="/collections" element={<Collections />} /> 36 - <Route path="/collections/:rkey" element={<CollectionDetail />} /> 37 - <Route path="/collection/*" element={<CollectionDetail />} /> 38 - <Route path="/privacy" element={<Privacy />} /> 39 - </Routes> 40 - </main> 25 + <div className="layout"> 26 + <ScrollToTop /> 27 + <Sidebar /> 28 + <div className="main-layout"> 29 + <main className="main-content-wrapper"> 30 + <Routes> 31 + <Route path="/" element={<Feed />} /> 32 + <Route path="/url" element={<Url />} /> 33 + <Route path="/new" element={<New />} /> 34 + <Route path="/bookmarks" element={<Bookmarks />} /> 35 + <Route path="/highlights" element={<Highlights />} /> 36 + <Route path="/notifications" element={<Notifications />} /> 37 + <Route path="/profile/:handle" element={<Profile />} /> 38 + <Route path="/login" element={<Login />} /> 39 + <Route path="/at/:did/:rkey" element={<AnnotationDetail />} /> 40 + <Route path="/annotation/:uri" element={<AnnotationDetail />} /> 41 + <Route path="/collections" element={<Collections />} /> 42 + <Route path="/collections/:rkey" element={<CollectionDetail />} /> 43 + <Route 44 + path="/:handle/collection/:rkey" 45 + element={<CollectionDetail />} 46 + /> 47 + <Route 48 + path="/:handle/annotation/:rkey" 49 + element={<AnnotationDetail />} 50 + /> 51 + <Route 52 + path="/:handle/highlight/:rkey" 53 + element={<AnnotationDetail />} 54 + /> 55 + <Route 56 + path="/:handle/bookmark/:rkey" 57 + element={<AnnotationDetail />} 58 + /> 59 + <Route path="/collection/*" element={<CollectionDetail />} /> 60 + <Route path="/privacy" element={<Privacy />} /> 61 + <Route path="/terms" element={<Terms />} /> 62 + </Routes> 63 + </main> 64 + </div> 65 + <RightSidebar /> 66 + <MobileNav /> 41 67 </div> 42 68 ); 43 69 }
+86 -33
web/src/api/client.js
··· 23 23 return request(`${API_BASE}/url-metadata?url=${encodeURIComponent(url)}`); 24 24 } 25 25 26 - export async function getAnnotationFeed(limit = 50, offset = 0) { 27 - return request( 28 - `${API_BASE}/annotations/feed?limit=${limit}&offset=${offset}`, 29 - ); 26 + export async function getAnnotationFeed( 27 + limit = 50, 28 + offset = 0, 29 + tag = "", 30 + creator = "", 31 + ) { 32 + let url = `${API_BASE}/annotations/feed?limit=${limit}&offset=${offset}`; 33 + if (tag) url += `&tag=${encodeURIComponent(tag)}`; 34 + if (creator) url += `&creator=${encodeURIComponent(creator)}`; 35 + return request(url); 30 36 } 31 37 32 38 export async function getAnnotations({ ··· 210 216 }); 211 217 } 212 218 213 - export async function createAnnotation({ url, text, quote, title, selector }) { 219 + export async function createHighlight({ url, title, selector, color, tags }) { 220 + return request(`${API_BASE}/highlights`, { 221 + method: "POST", 222 + body: JSON.stringify({ url, title, selector, color, tags }), 223 + }); 224 + } 225 + 226 + export async function createAnnotation({ 227 + url, 228 + text, 229 + quote, 230 + title, 231 + selector, 232 + tags, 233 + }) { 214 234 return request(`${API_BASE}/annotations`, { 215 235 method: "POST", 216 - body: JSON.stringify({ url, text, quote, title, selector }), 236 + body: JSON.stringify({ url, text, quote, title, selector, tags }), 217 237 }); 218 238 } 219 239 ··· 283 303 284 304 if (item.type === "Annotation") { 285 305 return { 286 - uri: item.id, 287 - author: item.creator, 288 - url: item.target?.source, 289 - title: item.target?.title, 290 - text: item.body?.value, 291 - selector: item.target?.selector, 306 + type: item.type, 307 + uri: item.uri || item.id, 308 + author: item.author || item.creator, 309 + url: item.url || item.target?.source, 310 + title: item.title || item.target?.title, 311 + text: item.text || item.body?.value, 312 + selector: item.selector || item.target?.selector, 292 313 motivation: item.motivation, 293 314 tags: item.tags || [], 294 - createdAt: item.created, 315 + createdAt: item.createdAt || item.created, 295 316 cid: item.cid || item.CID, 317 + likeCount: item.likeCount || 0, 318 + replyCount: item.replyCount || 0, 319 + viewerHasLiked: item.viewerHasLiked || false, 296 320 }; 297 321 } 298 322 299 323 if (item.type === "Bookmark") { 300 324 return { 301 - uri: item.id, 302 - author: item.creator, 303 - url: item.source, 325 + type: item.type, 326 + uri: item.uri || item.id, 327 + author: item.author || item.creator, 328 + url: item.url || item.source, 304 329 title: item.title, 305 330 description: item.description, 306 331 tags: item.tags || [], 307 - createdAt: item.created, 332 + createdAt: item.createdAt || item.created, 308 333 cid: item.cid || item.CID, 334 + likeCount: item.likeCount || 0, 335 + replyCount: item.replyCount || 0, 336 + viewerHasLiked: item.viewerHasLiked || false, 309 337 }; 310 338 } 311 339 312 340 if (item.type === "Highlight") { 313 341 return { 314 - uri: item.id, 315 - author: item.creator, 316 - url: item.target?.source, 317 - title: item.target?.title, 318 - selector: item.target?.selector, 342 + type: item.type, 343 + uri: item.uri || item.id, 344 + author: item.author || item.creator, 345 + url: item.url || item.target?.source, 346 + title: item.title || item.target?.title, 347 + selector: item.selector || item.target?.selector, 319 348 color: item.color, 320 349 tags: item.tags || [], 321 - createdAt: item.created, 350 + createdAt: item.createdAt || item.created, 322 351 cid: item.cid || item.CID, 352 + likeCount: item.likeCount || 0, 353 + replyCount: item.replyCount || 0, 354 + viewerHasLiked: item.viewerHasLiked || false, 323 355 }; 324 356 } 325 357 ··· 335 367 tags: item.tags || [], 336 368 createdAt: item.createdAt || item.created, 337 369 cid: item.cid || item.CID, 370 + likeCount: item.likeCount || 0, 371 + replyCount: item.replyCount || 0, 372 + viewerHasLiked: item.viewerHasLiked || false, 338 373 }; 339 374 } 340 375 341 376 export function normalizeHighlight(highlight) { 342 377 return { 343 - uri: highlight.id, 344 - author: highlight.creator, 345 - url: highlight.target?.source, 346 - title: highlight.target?.title, 347 - selector: highlight.target?.selector, 378 + uri: highlight.uri || highlight.id, 379 + author: highlight.author || highlight.creator, 380 + url: highlight.url || highlight.target?.source, 381 + title: highlight.title || highlight.target?.title, 382 + selector: highlight.selector || highlight.target?.selector, 348 383 color: highlight.color, 349 384 tags: highlight.tags || [], 350 - createdAt: highlight.created, 385 + createdAt: highlight.createdAt || highlight.created, 386 + likeCount: highlight.likeCount || 0, 387 + replyCount: highlight.replyCount || 0, 388 + viewerHasLiked: highlight.viewerHasLiked || false, 351 389 }; 352 390 } 353 391 354 392 export function normalizeBookmark(bookmark) { 355 393 return { 356 - uri: bookmark.id, 357 - author: bookmark.creator, 358 - url: bookmark.source, 394 + uri: bookmark.uri || bookmark.id, 395 + author: bookmark.author || bookmark.creator, 396 + url: bookmark.url || bookmark.source, 359 397 title: bookmark.title, 360 398 description: bookmark.description, 361 399 tags: bookmark.tags || [], 362 - createdAt: bookmark.created, 400 + createdAt: bookmark.createdAt || bookmark.created, 401 + likeCount: bookmark.likeCount || 0, 402 + replyCount: bookmark.replyCount || 0, 403 + viewerHasLiked: bookmark.viewerHasLiked || false, 363 404 }; 364 405 } 365 406 ··· 371 412 return res.json(); 372 413 } 373 414 415 + export async function resolveHandle(handle) { 416 + const res = await fetch( 417 + `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`, 418 + ); 419 + if (!res.ok) throw new Error("Failed to resolve handle"); 420 + const data = await res.json(); 421 + return data.did; 422 + } 423 + 374 424 export async function startLogin(handle, inviteCode) { 375 425 return request(`${AUTH_BASE}/start`, { 376 426 method: "POST", 377 427 body: JSON.stringify({ handle, invite_code: inviteCode }), 378 428 }); 379 429 } 430 + export async function getTrendingTags(limit = 10) { 431 + return request(`${API_BASE}/tags/trending?limit=${limit}`); 432 + }
+154
web/src/assets/tangled.svg
··· 1 + <?xml version="1.0" encoding="UTF-8" standalone="no"?> 2 + <!-- Created with Inkscape (http://www.inkscape.org/) --> 3 + 4 + <svg 5 + version="1.1" 6 + id="svg1" 7 + width="24.122343" 8 + height="23.274094" 9 + viewBox="0 0 24.122343 23.274094" 10 + sodipodi:docname="tangled_dolly_face_only.svg" 11 + inkscape:export-filename="tangled_logotype_black_on_trans.svg" 12 + inkscape:export-xdpi="96" 13 + inkscape:export-ydpi="96" 14 + inkscape:version="1.4 (e7c3feb100, 2024-10-09)" 15 + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" 16 + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" 17 + xmlns="http://www.w3.org/2000/svg" 18 + xmlns:svg="http://www.w3.org/2000/svg"> 19 + <defs 20 + id="defs1"> 21 + <filter 22 + style="color-interpolation-filters:sRGB" 23 + inkscape:menu-tooltip="Fades hue progressively to white" 24 + inkscape:menu="Color" 25 + inkscape:label="Hue to White" 26 + id="filter24" 27 + x="0" 28 + y="0" 29 + width="1" 30 + height="1"> 31 + <feColorMatrix 32 + values="1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 0 0 0 0 1 " 33 + type="matrix" 34 + result="r" 35 + in="SourceGraphic" 36 + id="feColorMatrix17" /> 37 + <feColorMatrix 38 + values="0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 0 0 0 1 " 39 + type="matrix" 40 + result="g" 41 + in="SourceGraphic" 42 + id="feColorMatrix18" /> 43 + <feColorMatrix 44 + values="0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 0 0 1 " 45 + type="matrix" 46 + result="b" 47 + in="SourceGraphic" 48 + id="feColorMatrix19" /> 49 + <feBlend 50 + result="minrg" 51 + in="r" 52 + mode="darken" 53 + in2="g" 54 + id="feBlend19" /> 55 + <feBlend 56 + result="p" 57 + in="minrg" 58 + mode="darken" 59 + in2="b" 60 + id="feBlend20" /> 61 + <feBlend 62 + result="maxrg" 63 + in="r" 64 + mode="lighten" 65 + in2="g" 66 + id="feBlend21" /> 67 + <feBlend 68 + result="q" 69 + in="maxrg" 70 + mode="lighten" 71 + in2="b" 72 + id="feBlend22" /> 73 + <feComponentTransfer 74 + result="q2" 75 + in="q" 76 + id="feComponentTransfer22"> 77 + <feFuncR 78 + slope="0" 79 + type="linear" 80 + id="feFuncR22" /> 81 + </feComponentTransfer> 82 + <feBlend 83 + result="pq" 84 + in="p" 85 + mode="lighten" 86 + in2="q2" 87 + id="feBlend23" /> 88 + <feColorMatrix 89 + values="-1 1 0 0 0 -1 1 0 0 0 -1 1 0 0 0 0 0 0 0 1 " 90 + type="matrix" 91 + result="qminp" 92 + in="pq" 93 + id="feColorMatrix23" /> 94 + <feComposite 95 + k3="1" 96 + operator="arithmetic" 97 + result="qminpc" 98 + in="qminp" 99 + in2="qminp" 100 + id="feComposite23" 101 + k1="0" 102 + k2="0" 103 + k4="0" /> 104 + <feBlend 105 + result="result2" 106 + in2="SourceGraphic" 107 + mode="screen" 108 + id="feBlend24" /> 109 + <feComposite 110 + operator="in" 111 + in="result2" 112 + in2="SourceGraphic" 113 + result="result1" 114 + id="feComposite24" /> 115 + </filter> 116 + </defs> 117 + <sodipodi:namedview 118 + id="namedview1" 119 + pagecolor="#ffffff" 120 + bordercolor="#000000" 121 + borderopacity="0.25" 122 + inkscape:showpageshadow="2" 123 + inkscape:pageopacity="0.0" 124 + inkscape:pagecheckerboard="true" 125 + inkscape:deskcolor="#d5d5d5" 126 + inkscape:zoom="7.0916564" 127 + inkscape:cx="38.84847" 128 + inkscape:cy="31.515909" 129 + inkscape:window-width="1920" 130 + inkscape:window-height="1080" 131 + inkscape:window-x="0" 132 + inkscape:window-y="0" 133 + inkscape:window-maximized="0" 134 + inkscape:current-layer="g1"> 135 + <inkscape:page 136 + x="0" 137 + y="0" 138 + width="24.122343" 139 + height="23.274094" 140 + id="page2" 141 + margin="0" 142 + bleed="0" /> 143 + </sodipodi:namedview> 144 + <g 145 + inkscape:groupmode="layer" 146 + inkscape:label="Image" 147 + id="g1" 148 + transform="translate(-0.4388285,-0.8629527)"> 149 + <path 150 + style="fill:#ffffff;fill-opacity:1;stroke-width:0.111183;filter:url(#filter24)" 151 + d="m 16.348974,24.09935 -0.06485,-0.03766 -0.202005,-0.0106 -0.202008,-0.01048 -0.275736,-0.02601 -0.275734,-0.02602 v -0.02649 -0.02648 l -0.204577,-0.04019 -0.204578,-0.04019 -0.167616,-0.08035 -0.167617,-0.08035 -0.0014,-0.04137 -0.0014,-0.04137 -0.266473,-0.143735 -0.266475,-0.143735 -0.276098,-0.20335 -0.2761,-0.203347 -0.262064,-0.251949 -0.262064,-0.25195 -0.22095,-0.284628 -0.220948,-0.284629 -0.170253,-0.284631 -0.170252,-0.284628 -0.01341,-0.0144 -0.0134,-0.0144 -0.141982,0.161297 -0.14198,0.1613 -0.22313,0.21426 -0.223132,0.214264 -0.186025,0.146053 -0.186023,0.14605 -0.252501,0.163342 -0.252502,0.163342 -0.249014,0.115348 -0.249013,0.115336 0.0053,0.03241 0.0053,0.03241 -0.1716725,0.04599 -0.171669,0.046 -0.3379966,0.101058 -0.3379972,0.101058 -0.1778925,0.04506 -0.1778935,0.04508 -0.3913655,0.02601 -0.3913643,0.02603 -0.3557868,-0.03514 -0.3557863,-0.03514 -0.037426,-0.03029 -0.037427,-0.03029 -0.076924,0.02011 -0.076924,0.02011 -0.050508,-0.05051 -0.050405,-0.05056 L 6.6604532,23.110188 6.451745,23.063961 6.1546135,22.960559 5.8574835,22.857156 5.5319879,22.694039 5.2064938,22.530922 4.8793922,22.302961 4.5522905,22.075005 4.247598,21.786585 3.9429055,21.49817 3.7185335,21.208777 3.4941628,20.919385 3.3669822,20.705914 3.239803,20.492443 3.1335213,20.278969 3.0272397,20.065499 2.9015252,19.7275 2.7758105,19.389504 2.6925225,18.998139 2.6092345,18.606774 2.6096814,17.91299 2.6101284,17.219208 2.6744634,16.90029 2.7387984,16.581374 2.8474286,16.242088 2.9560588,15.9028 3.1137374,15.583492 3.2714148,15.264182 3.3415068,15.150766 3.4115988,15.03735 3.3127798,14.96945 3.2139618,14.90157 3.0360685,14.800239 2.8581753,14.698908 2.5913347,14.503228 2.3244955,14.307547 2.0621238,14.055599 1.7997507,13.803651 1.6111953,13.56878 1.4226411,13.333906 1.2632237,13.087474 1.1038089,12.841042 0.97442,12.575195 0.8450307,12.30935 0.724603,11.971351 0.6041766,11.633356 0.52150365,11.241991 0.4388285,10.850626 0.44091592,10.156842 0.44300333,9.4630594 0.54235911,9.0369608 0.6417149,8.6108622 0.7741173,8.2694368 0.9065196,7.9280115 1.0736303,7.6214262 1.2407515,7.3148397 1.45931,7.0191718 1.6778685,6.7235039 1.9300326,6.4611321 2.1821966,6.1987592 2.4134579,6.0137228 2.6447193,5.8286865 2.8759792,5.6776409 3.1072406,5.526594 3.4282004,5.3713977 3.7491603,5.2162016 3.9263009,5.1508695 4.1034416,5.0855373 4.2813348,4.7481598 4.4592292,4.4107823 4.6718,4.108422 4.8843733,3.8060618 5.198353,3.4805372 5.5123313,3.155014 5.7685095,2.9596425 6.0246877,2.7642722 6.329187,2.5851365 6.6336863,2.406002 6.9497657,2.2751596 7.2658453,2.1443184 7.4756394,2.0772947 7.6854348,2.01027 8.0825241,1.931086 8.4796139,1.851902 l 0.5870477,0.00291 0.5870469,0.00291 0.4447315,0.092455 0.444734,0.092455 0.302419,0.1105495 0.302417,0.1105495 0.329929,0.1646046 0.32993,0.1646033 0.239329,-0.2316919 0.239329,-0.2316919 0.160103,-0.1256767 0.160105,-0.1256767 0.160102,-0.1021909 0.160105,-0.1021899 0.142315,-0.082328 0.142314,-0.082328 0.231262,-0.1090091 0.231259,-0.1090091 0.26684,-0.098743 0.266839,-0.098743 0.320208,-0.073514 0.320209,-0.073527 0.355787,-0.041833 0.355785,-0.041834 0.426942,0.023827 0.426945,0.023828 0.355785,0.071179 0.355788,0.0711791 0.284627,0.09267 0.284629,0.09267 0.28514,0.1310267 0.28514,0.1310255 0.238179,0.1446969 0.238174,0.1446979 0.259413,0.1955332 0.259413,0.1955319 0.290757,0.296774 0.290758,0.2967753 0.151736,0.1941581 0.151734,0.1941594 0.135326,0.2149951 0.135327,0.2149952 0.154755,0.3202073 0.154758,0.3202085 0.09409,0.2677358 0.09409,0.267737 0.06948,0.3319087 0.06948,0.3319099 0.01111,0.00808 0.01111,0.00808 0.444734,0.2173653 0.444734,0.2173665 0.309499,0.2161102 0.309497,0.2161101 0.309694,0.2930023 0.309694,0.2930037 0.18752,0.2348726 0.187524,0.2348727 0.166516,0.2574092 0.166519,0.2574108 0.15273,0.3260252 0.152734,0.3260262 0.08972,0.2668403 0.08971,0.2668391 0.08295,0.3913655 0.08295,0.3913652 -6.21e-4,0.6582049 -6.21e-4,0.658204 -0.06362,0.315725 -0.06362,0.315725 -0.09046,0.289112 -0.09046,0.289112 -0.122759,0.281358 -0.12276,0.281356 -0.146626,0.252323 -0.146629,0.252322 -0.190443,0.258668 -0.190448,0.258671 -0.254911,0.268356 -0.254911,0.268355 -0.286872,0.223127 -0.286874,0.223127 -0.320203,0.187693 -0.320209,0.187693 -0.04347,0.03519 -0.04347,0.03521 0.0564,0.12989 0.0564,0.129892 0.08728,0.213472 0.08728,0.213471 0.189755,0.729363 0.189753,0.729362 0.0652,0.302417 0.0652,0.302419 -0.0018,0.675994 -0.0018,0.675995 -0.0801,0.373573 -0.08009,0.373577 -0.09,0.266839 -0.09,0.26684 -0.190389,0.391364 -0.19039,0.391366 -0.223169,0.320207 -0.223167,0.320209 -0.303585,0.315294 -0.303584,0.315291 -0.284631,0.220665 -0.284629,0.220663 -0.220128,0.132359 -0.220127,0.132358 -0.242395,0.106698 -0.242394,0.106699 -0.08895,0.04734 -0.08895,0.04733 -0.249052,0.07247 -0.24905,0.07247 -0.322042,0.0574 -0.322044,0.0574 -0.282794,-0.003 -0.282795,-0.003 -0.07115,-0.0031 -0.07115,-0.0031 -0.177894,-0.0033 -0.177893,-0.0033 -0.124528,0.02555 -0.124528,0.02555 z m -4.470079,-5.349839 0.214838,-0.01739 0.206601,-0.06782 0.206602,-0.06782 0.244389,-0.117874 0.244393,-0.11786 0.274473,-0.206822 0.27447,-0.20682 0.229308,-0.257201 0.229306,-0.2572 0.219161,-0.28463 0.219159,-0.284629 0.188541,-0.284628 0.188543,-0.28463 0.214594,-0.373574 0.214593,-0.373577 0.133861,-0.312006 0.133865,-0.312007 0.02861,-0.01769 0.02861,-0.01769 0.197275,0.26212 0.197278,0.262119 0.163613,0.150814 0.163614,0.150814 0.201914,0.09276 0.201914,0.09276 0.302417,0.01421 0.302418,0.01421 0.213472,-0.08025 0.213471,-0.08025 0.200606,-0.204641 0.200606,-0.204642 0.09242,-0.278887 0.09241,-0.278888 0.05765,-0.302418 0.05764,-0.302416 L 18.41327,13.768114 18.39502,13.34117 18.31849,12.915185 18.24196,12.4892 18.15595,12.168033 18.06994,11.846867 17.928869,11.444534 17.787801,11.042201 17.621278,10.73296 17.454757,10.423723 17.337388,10.263619 17.220021,10.103516 17.095645,9.9837986 16.971268,9.8640816 16.990048,9.6813736 17.008828,9.4986654 16.947568,9.249616 16.886308,9.0005655 16.752419,8.7159355 16.618521,8.4313217 16.435707,8.2294676 16.252892,8.0276114 16.079629,7.9004245 15.906366,7.773238 l -0.20429,0.1230127 -0.204289,0.1230121 -0.26702,0.059413 -0.267022,0.059413 -0.205761,-0.021508 -0.205766,-0.021508 -0.23495,-0.08844 -0.234953,-0.08844 -0.118429,-0.090334 -0.118428,-0.090333 h -0.03944 -0.03944 L 13.711268,7.8540732 13.655958,7.9706205 13.497227,8.1520709 13.338499,8.3335203 13.168394,8.4419112 12.998289,8.550301 12.777045,8.624223 12.5558,8.698155 H 12.275611 11.995429 L 11.799973,8.6309015 11.604513,8.5636472 11.491311,8.5051061 11.37811,8.446565 11.138172,8.2254579 10.898231,8.0043497 l -0.09565,-0.084618 -0.09565,-0.084613 -0.218822,0.198024 -0.218822,0.1980231 -0.165392,0.078387 -0.1653925,0.078387 -0.177894,0.047948 -0.177892,0.047948 L 9.3635263,8.4842631 9.144328,8.4846889 8.9195029,8.4147138 8.6946778,8.3447386 8.5931214,8.4414036 8.491565,8.5380686 8.3707618,8.7019598 8.2499597,8.8658478 8.0802403,8.9290726 7.9105231,8.9922974 7.7952769,9.0780061 7.6800299,9.1637148 7.5706169,9.2778257 7.4612038,9.3919481 7.1059768,9.9205267 6.7507497,10.449105 l -0.2159851,0.449834 -0.2159839,0.449834 -0.2216572,0.462522 -0.2216559,0.462523 -0.1459343,0.337996 -0.1459342,0.337998 -0.055483,0.220042 -0.055483,0.220041 -0.015885,0.206903 -0.015872,0.206901 0.034307,0.242939 0.034307,0.24294 0.096281,0.196632 0.096281,0.196634 0.143607,0.125222 0.1436071,0.125222 0.1873143,0.08737 0.1873141,0.08737 0.2752084,0.002 0.2752084,0.002 0.2312297,-0.09773 0.231231,-0.09772 0.1067615,-0.07603 0.1067614,-0.07603 0.3679062,-0.29377 0.3679065,-0.293771 0.026804,0.01656 0.026804,0.01656 0.023626,0.466819 0.023626,0.466815 0.088326,0.513195 0.088326,0.513193 0.08897,0.364413 0.08897,0.364411 0.1315362,0.302418 0.1315352,0.302418 0.1051964,0.160105 0.1051954,0.160103 0.1104741,0.11877 0.1104731,0.118769 0.2846284,0.205644 0.2846305,0.205642 0.144448,0.07312 0.144448,0.07312 0.214787,0.05566 0.214787,0.05566 0.245601,0.03075 0.245602,0.03075 0.204577,-0.0125 0.204578,-0.0125 z m 0.686342,-3.497495 -0.11281,-0.06077 -0.106155,-0.134033 -0.106155,-0.134031 -0.04406,-0.18371 -0.04406,-0.183707 0.02417,-0.553937 0.02417,-0.553936 0.03513,-0.426945 0.03513,-0.426942 0.07225,-0.373576 0.07225,-0.373575 0.05417,-0.211338 0.05417,-0.211339 0.0674,-0.132112 0.0674,-0.132112 0.132437,-0.10916 0.132437,-0.109161 0.187436,-0.04195 0.187438,-0.04195 0.170366,0.06469 0.170364,0.06469 0.114312,0.124073 0.114313,0.124086 0.04139,0.18495 0.04139,0.184951 -0.111218,0.459845 -0.111219,0.459844 -0.03383,0.26584 -0.03382,0.265841 -0.03986,0.818307 -0.03986,0.818309 -0.0378,0.15162 -0.03779,0.151621 -0.11089,0.110562 -0.110891,0.110561 -0.114489,0.04913 -0.114489,0.04913 -0.187932,-0.0016 -0.187929,-0.0016 z m -2.8087655,-0.358124 -0.146445,-0.06848 -0.088025,-0.119502 -0.088024,-0.119502 -0.038581,-0.106736 -0.038581,-0.106736 -0.02237,-0.134956 -0.02239,-0.134957 -0.031955,-0.46988 -0.031955,-0.469881 0.036203,-0.444733 0.036203,-0.444731 0.048862,-0.215257 0.048862,-0.215255 0.076082,-0.203349 0.076081,-0.203348 0.0936,-0.111244 0.0936,-0.111245 0.143787,-0.06531 0.1437865,-0.06532 h 0.142315 0.142314 l 0.142314,0.06588 0.142316,0.06588 0.093,0.102325 0.093,0.102325 0.04042,0.120942 0.04042,0.120942 v 0.152479 0.152477 l -0.03347,0.08804 -0.03347,0.08805 -0.05693,0.275653 -0.05693,0.275651 2.11e-4,0.430246 2.12e-4,0.430243 0.04294,0.392646 0.04295,0.392647 -0.09189,0.200702 -0.09189,0.200702 -0.148688,0.0984 -0.148687,0.0984 -0.20136,0.01212 -0.2013595,0.01212 z" 152 + id="path4" /> 153 + </g> 154 + </svg>
+9 -5
web/src/components/AddToCollectionModal.jsx
··· 1 - import { useState, useEffect } from "react"; 1 + import { useState, useEffect, useCallback } from "react"; 2 2 import { X, Plus, Check, Folder } from "lucide-react"; 3 3 import { 4 4 getCollections, ··· 23 23 24 24 useEffect(() => { 25 25 if (isOpen && user) { 26 + if (!annotationUri) { 27 + setLoading(false); 28 + return; 29 + } 26 30 loadCollections(); 27 31 setError(null); 28 32 } 29 - }, [isOpen, user]); 33 + }, [isOpen, user, annotationUri, loadCollections]); 30 34 31 - const loadCollections = async () => { 35 + const loadCollections = useCallback(async () => { 32 36 try { 33 37 setLoading(true); 34 38 const [data, existingURIs] = await Promise.all([ ··· 45 49 } finally { 46 50 setLoading(false); 47 51 } 48 - }; 52 + }, [user?.did, annotationUri]); 49 53 50 54 const handleAdd = async (collectionUri) => { 51 55 if (addedTo.has(collectionUri)) return; ··· 71 75 className="modal-container" 72 76 style={{ 73 77 maxWidth: "380px", 74 - maxHeight: "80vh", 78 + maxHeight: "80dvh", 75 79 display: "flex", 76 80 flexDirection: "column", 77 81 }}
+425 -343
web/src/components/AnnotationCard.jsx
··· 5 5 import { 6 6 normalizeAnnotation, 7 7 normalizeHighlight, 8 - deleteAnnotation, 9 8 likeAnnotation, 10 9 unlikeAnnotation, 11 10 getReplies, 12 11 createReply, 13 12 deleteReply, 14 - getLikeCount, 15 13 updateAnnotation, 16 14 updateHighlight, 17 - updateBookmark, 18 15 getEditHistory, 16 + deleteAnnotation, 19 17 } from "../api/client"; 20 18 import { 21 - HeartIcon, 22 - MessageIcon, 23 - TrashIcon, 24 - ExternalLinkIcon, 25 - HighlightIcon, 26 - BookmarkIcon, 27 - } from "./Icons"; 28 - import { Folder, Edit2, Save, X, Clock } from "lucide-react"; 29 - import AddToCollectionModal from "./AddToCollectionModal"; 19 + MessageSquare, 20 + Heart, 21 + Trash2, 22 + Folder, 23 + Edit2, 24 + Save, 25 + X, 26 + Clock, 27 + } from "lucide-react"; 28 + import { HighlightIcon, TrashIcon } from "./Icons"; 30 29 import ShareMenu from "./ShareMenu"; 31 30 32 31 function buildTextFragmentUrl(baseUrl, selector) { ··· 59 58 } 60 59 }; 61 60 62 - export default function AnnotationCard({ annotation, onDelete }) { 61 + export default function AnnotationCard({ 62 + annotation, 63 + onDelete, 64 + onAddToCollection, 65 + }) { 63 66 const { user, login } = useAuth(); 64 67 const data = normalizeAnnotation(annotation); 65 68 66 - const [likeCount, setLikeCount] = useState(0); 67 - const [isLiked, setIsLiked] = useState(false); 69 + const [likeCount, setLikeCount] = useState(data.likeCount || 0); 70 + const [isLiked, setIsLiked] = useState(data.viewerHasLiked || false); 68 71 const [deleting, setDeleting] = useState(false); 69 - const [showAddToCollection, setShowAddToCollection] = useState(false); 70 72 const [isEditing, setIsEditing] = useState(false); 71 73 const [editText, setEditText] = useState(data.text || ""); 74 + const [editTags, setEditTags] = useState(data.tags?.join(", ") || ""); 72 75 const [saving, setSaving] = useState(false); 73 76 74 77 const [showHistory, setShowHistory] = useState(false); ··· 76 79 const [loadingHistory, setLoadingHistory] = useState(false); 77 80 78 81 const [replies, setReplies] = useState([]); 79 - const [replyCount, setReplyCount] = useState(0); 82 + const [replyCount, setReplyCount] = useState(data.replyCount || 0); 80 83 const [showReplies, setShowReplies] = useState(false); 81 84 const [replyingTo, setReplyingTo] = useState(null); 82 85 const [replyText, setReplyText] = useState(""); ··· 87 90 const [hasEditHistory, setHasEditHistory] = useState(false); 88 91 89 92 useEffect(() => { 90 - let mounted = true; 91 - async function fetchData() { 92 - try { 93 - const repliesRes = await getReplies(data.uri); 94 - if (mounted && repliesRes.items) { 95 - setReplies(repliesRes.items); 96 - setReplyCount(repliesRes.items.length); 97 - } 98 - 99 - const likeRes = await getLikeCount(data.uri); 100 - if (mounted) { 101 - if (likeRes.count !== undefined) { 102 - setLikeCount(likeRes.count); 103 - } 104 - if (likeRes.liked !== undefined) { 105 - setIsLiked(likeRes.liked); 93 + if (data.uri && !data.color && !data.description) { 94 + getEditHistory(data.uri) 95 + .then((history) => { 96 + if (history && history.length > 0) { 97 + setHasEditHistory(true); 106 98 } 107 - } 108 - 109 - if (!data.color && !data.description) { 110 - try { 111 - const history = await getEditHistory(data.uri); 112 - if (mounted && history && history.length > 0) { 113 - setHasEditHistory(true); 114 - } 115 - } catch {} 116 - } 117 - } catch (err) { 118 - console.error("Failed to fetch data:", err); 119 - } 120 - } 121 - if (data.uri) { 122 - fetchData(); 99 + }) 100 + .catch(() => {}); 123 101 } 124 - return () => { 125 - mounted = false; 126 - }; 127 - }, [data.uri]); 102 + }, [data.uri, data.color, data.description]); 128 103 129 104 const fetchHistory = async () => { 130 105 if (showHistory) { ··· 181 156 const handleSaveEdit = async () => { 182 157 try { 183 158 setSaving(true); 184 - await updateAnnotation(data.uri, editText, data.tags); 159 + const tagList = editTags 160 + .split(",") 161 + .map((t) => t.trim()) 162 + .filter(Boolean); 163 + await updateAnnotation(data.uri, editText, tagList); 185 164 setIsEditing(false); 186 165 if (annotation.body) annotation.body.value = editText; 187 166 else if (annotation.text) annotation.text = editText; 167 + if (annotation.tags) annotation.tags = tagList; 168 + data.tags = tagList; 188 169 } catch (err) { 189 170 alert("Failed to update: " + err.message); 190 171 } finally { ··· 244 225 } 245 226 }; 246 227 247 - const handleShare = async () => { 248 - const uriParts = data.uri.split("/"); 249 - const did = uriParts[2]; 250 - const rkey = uriParts[uriParts.length - 1]; 251 - const shareUrl = `${window.location.origin}/at/${did}/${rkey}`; 252 - 253 - if (navigator.share) { 254 - try { 255 - await navigator.share({ 256 - title: "Margin Annotation", 257 - text: data.text?.substring(0, 100), 258 - url: shareUrl, 259 - }); 260 - } catch (err) {} 261 - } else { 262 - try { 263 - await navigator.clipboard.writeText(shareUrl); 264 - alert("Link copied!"); 265 - } catch { 266 - prompt("Copy this link:", shareUrl); 267 - } 268 - } 269 - }; 270 - 271 228 const handleDelete = async () => { 272 229 if (!confirm("Delete this annotation? This cannot be undone.")) return; 273 230 try { ··· 287 244 return ( 288 245 <article className="card annotation-card"> 289 246 <header className="annotation-header"> 290 - <Link to={marginProfileUrl || "#"} className="annotation-avatar-link"> 291 - <div className="annotation-avatar"> 292 - {authorAvatar ? ( 293 - <img src={authorAvatar} alt={authorDisplayName} /> 294 - ) : ( 295 - <span> 296 - {(authorDisplayName || authorHandle || "??") 297 - ?.substring(0, 2) 298 - .toUpperCase()} 299 - </span> 300 - )} 301 - </div> 302 - </Link> 303 - <div className="annotation-meta"> 304 - <div className="annotation-author-row"> 305 - <Link 306 - to={marginProfileUrl || "#"} 307 - className="annotation-author-link" 308 - > 309 - <span className="annotation-author">{authorDisplayName}</span> 310 - </Link> 311 - {authorHandle && ( 312 - <a 313 - href={`https://bsky.app/profile/${authorHandle}`} 314 - target="_blank" 315 - rel="noopener noreferrer" 316 - className="annotation-handle" 247 + <div className="annotation-header-left"> 248 + <Link to={marginProfileUrl || "#"} className="annotation-avatar-link"> 249 + <div className="annotation-avatar"> 250 + {authorAvatar ? ( 251 + <img src={authorAvatar} alt={authorDisplayName} /> 252 + ) : ( 253 + <span> 254 + {(authorDisplayName || authorHandle || "??") 255 + ?.substring(0, 2) 256 + .toUpperCase()} 257 + </span> 258 + )} 259 + </div> 260 + </Link> 261 + <div className="annotation-meta"> 262 + <div className="annotation-author-row"> 263 + <Link 264 + to={marginProfileUrl || "#"} 265 + className="annotation-author-link" 317 266 > 318 - @{authorHandle} <ExternalLinkIcon size={12} /> 319 - </a> 320 - )} 321 - </div> 322 - <div className="annotation-time">{formatDate(data.createdAt)}</div> 323 - </div> 324 - <div className="action-buttons"> 325 - {} 326 - {hasEditHistory && !data.color && !data.description && ( 327 - <button 328 - className="annotation-edit-btn" 329 - onClick={fetchHistory} 330 - title="View Edit History" 331 - > 332 - <Clock size={16} /> 333 - </button> 334 - )} 335 - {} 336 - {isOwner && ( 337 - <> 338 - {!data.color && !data.description && ( 339 - <button 340 - className="annotation-edit-btn" 341 - onClick={() => setIsEditing(!isEditing)} 342 - title="Edit" 267 + <span className="annotation-author">{authorDisplayName}</span> 268 + </Link> 269 + {authorHandle && ( 270 + <a 271 + href={`https://bsky.app/profile/${authorHandle}`} 272 + target="_blank" 273 + rel="noopener noreferrer" 274 + className="annotation-handle" 343 275 > 344 - <Edit2 size={16} /> 345 - </button> 276 + @{authorHandle} 277 + </a> 346 278 )} 279 + </div> 280 + <div className="annotation-time">{formatDate(data.createdAt)}</div> 281 + </div> 282 + </div> 283 + <div className="annotation-header-right"> 284 + <div style={{ display: "flex", gap: "4px" }}> 285 + {hasEditHistory && !data.color && !data.description && ( 347 286 <button 348 - className="annotation-delete" 349 - onClick={handleDelete} 350 - disabled={deleting} 351 - title="Delete" 287 + className="annotation-action action-icon-only" 288 + onClick={fetchHistory} 289 + title="View Edit History" 352 290 > 353 - <TrashIcon size={16} /> 291 + <Clock size={16} /> 354 292 </button> 355 - </> 356 - )} 293 + )} 294 + 295 + {isOwner && ( 296 + <> 297 + {!data.color && !data.description && ( 298 + <button 299 + className="annotation-action action-icon-only" 300 + onClick={() => setIsEditing(!isEditing)} 301 + title="Edit" 302 + > 303 + <Edit2 size={16} /> 304 + </button> 305 + )} 306 + <button 307 + className="annotation-action action-icon-only" 308 + onClick={handleDelete} 309 + disabled={deleting} 310 + title="Delete" 311 + > 312 + <Trash2 size={16} /> 313 + </button> 314 + </> 315 + )} 316 + </div> 357 317 </div> 358 318 </header> 359 319 360 - {} 361 - {} 362 320 {showHistory && ( 363 321 <div className="history-panel"> 364 322 <div className="history-header"> ··· 390 348 </div> 391 349 )} 392 350 393 - <a 394 - href={data.url} 395 - target="_blank" 396 - rel="noopener noreferrer" 397 - className="annotation-source" 398 - > 399 - {truncateUrl(data.url)} 400 - {data.title && ( 401 - <span className="annotation-source-title"> โ€ข {data.title}</span> 402 - )} 403 - </a> 404 - 405 - {highlightedText && ( 351 + <div className="annotation-content"> 406 352 <a 407 - href={fragmentUrl} 353 + href={data.url} 408 354 target="_blank" 409 355 rel="noopener noreferrer" 410 - className="annotation-highlight" 356 + className="annotation-source" 411 357 > 412 - <mark>"{highlightedText}"</mark> 358 + {truncateUrl(data.url)} 359 + {data.title && ( 360 + <span className="annotation-source-title"> โ€ข {data.title}</span> 361 + )} 413 362 </a> 414 - )} 415 363 416 - {isEditing ? ( 417 - <div className="mt-3"> 418 - <textarea 419 - value={editText} 420 - onChange={(e) => setEditText(e.target.value)} 421 - className="reply-input" 422 - rows={3} 423 - style={{ marginBottom: "8px" }} 424 - /> 425 - <div className="action-buttons-end"> 426 - <button 427 - onClick={() => setIsEditing(false)} 428 - className="btn btn-ghost" 429 - > 430 - Cancel 431 - </button> 432 - <button 433 - onClick={handleSaveEdit} 434 - disabled={saving} 435 - className="btn btn-primary btn-sm" 436 - > 437 - {saving ? ( 438 - "Saving..." 439 - ) : ( 440 - <> 441 - <Save size={14} /> Save 442 - </> 443 - )} 444 - </button> 364 + {highlightedText && ( 365 + <a 366 + href={fragmentUrl} 367 + target="_blank" 368 + rel="noopener noreferrer" 369 + className="annotation-highlight" 370 + style={{ 371 + borderLeftColor: data.color || "var(--accent)", 372 + }} 373 + > 374 + <mark>&quot;{highlightedText}&quot;</mark> 375 + </a> 376 + )} 377 + 378 + {isEditing ? ( 379 + <div className="mt-3"> 380 + <textarea 381 + value={editText} 382 + onChange={(e) => setEditText(e.target.value)} 383 + className="reply-input" 384 + rows={3} 385 + style={{ marginBottom: "8px" }} 386 + /> 387 + <input 388 + type="text" 389 + className="reply-input" 390 + placeholder="Tags (comma separated)..." 391 + value={editTags} 392 + onChange={(e) => setEditTags(e.target.value)} 393 + style={{ marginBottom: "8px" }} 394 + /> 395 + <div className="action-buttons-end"> 396 + <button 397 + onClick={() => setIsEditing(false)} 398 + className="btn btn-ghost" 399 + > 400 + Cancel 401 + </button> 402 + <button 403 + onClick={handleSaveEdit} 404 + disabled={saving} 405 + className="btn btn-primary btn-sm" 406 + > 407 + {saving ? ( 408 + "Saving..." 409 + ) : ( 410 + <> 411 + <Save size={14} /> Save 412 + </> 413 + )} 414 + </button> 415 + </div> 445 416 </div> 446 - </div> 447 - ) : ( 448 - data.text && <p className="annotation-text">{data.text}</p> 449 - )} 417 + ) : ( 418 + data.text && <p className="annotation-text">{data.text}</p> 419 + )} 450 420 451 - {data.tags?.length > 0 && ( 452 - <div className="annotation-tags"> 453 - {data.tags.map((tag, i) => ( 454 - <span key={i} className="annotation-tag"> 455 - #{tag} 456 - </span> 457 - ))} 458 - </div> 459 - )} 421 + {data.tags?.length > 0 && ( 422 + <div className="annotation-tags"> 423 + {data.tags.map((tag, i) => ( 424 + <Link 425 + key={i} 426 + to={`/?tag=${encodeURIComponent(tag)}`} 427 + className="annotation-tag" 428 + > 429 + #{tag} 430 + </Link> 431 + ))} 432 + </div> 433 + )} 434 + </div> 460 435 461 436 <footer className="annotation-actions"> 462 - <button 463 - className={`annotation-action ${isLiked ? "liked" : ""}`} 464 - onClick={handleLike} 465 - > 466 - <HeartIcon filled={isLiked} size={16} /> 467 - {likeCount > 0 && <span>{likeCount}</span>} 468 - </button> 469 - <button 470 - className={`annotation-action ${showReplies ? "active" : ""}`} 471 - onClick={() => setShowReplies(!showReplies)} 472 - > 473 - <MessageIcon size={16} /> 474 - <span>{replyCount > 0 ? `${replyCount}` : "Reply"}</span> 475 - </button> 476 - <ShareMenu uri={data.uri} text={data.text} /> 477 - <button 478 - className="annotation-action" 479 - onClick={() => { 480 - if (!user) { 481 - login(); 482 - return; 483 - } 484 - setShowAddToCollection(true); 485 - }} 486 - > 487 - <Folder size={16} /> 488 - <span>Collect</span> 489 - </button> 437 + <div className="annotation-actions-left"> 438 + <button 439 + className={`annotation-action ${isLiked ? "liked" : ""}`} 440 + onClick={handleLike} 441 + > 442 + <Heart filled={isLiked} size={16} /> 443 + {likeCount > 0 && <span>{likeCount}</span>} 444 + </button> 445 + <button 446 + className={`annotation-action ${showReplies ? "active" : ""}`} 447 + onClick={async () => { 448 + if (!showReplies && replies.length === 0) { 449 + try { 450 + const res = await getReplies(data.uri); 451 + if (res.items) setReplies(res.items); 452 + } catch (err) { 453 + console.error("Failed to load replies:", err); 454 + } 455 + } 456 + setShowReplies(!showReplies); 457 + }} 458 + > 459 + <MessageSquare size={16} /> 460 + <span>{replyCount > 0 ? `${replyCount}` : "Reply"}</span> 461 + </button> 462 + <ShareMenu 463 + uri={data.uri} 464 + text={data.title || data.url} 465 + handle={data.author?.handle} 466 + type="Annotation" 467 + /> 468 + <button 469 + className="annotation-action" 470 + onClick={() => { 471 + if (!user) { 472 + login(); 473 + return; 474 + } 475 + if (onAddToCollection) onAddToCollection(); 476 + }} 477 + > 478 + <Folder size={16} /> 479 + <span>Collect</span> 480 + </button> 481 + </div> 490 482 </footer> 491 483 492 484 {showReplies && ( ··· 554 546 onChange={(e) => setReplyText(e.target.value)} 555 547 onFocus={(e) => { 556 548 if (!user) { 557 - e.target.blur(); 558 - login(); 549 + e.preventDefault(); 550 + alert("Please sign in to like annotations"); 559 551 } 560 552 }} 561 553 rows={2} ··· 578 570 </div> 579 571 </div> 580 572 )} 581 - 582 - <AddToCollectionModal 583 - isOpen={showAddToCollection} 584 - onClose={() => setShowAddToCollection(false)} 585 - annotationUri={data.uri} 586 - /> 587 573 </article> 588 574 ); 589 575 } 590 576 591 - export function HighlightCard({ highlight, onDelete }) { 577 + export function HighlightCard({ 578 + highlight, 579 + onDelete, 580 + onAddToCollection, 581 + onUpdate, 582 + }) { 592 583 const { user, login } = useAuth(); 593 584 const data = normalizeHighlight(highlight); 594 585 const highlightedText = 595 586 data.selector?.type === "TextQuoteSelector" ? data.selector.exact : null; 596 587 const fragmentUrl = buildTextFragmentUrl(data.url, data.selector); 597 588 const isOwner = user?.did && data.author?.did === user.did; 598 - const [showAddToCollection, setShowAddToCollection] = useState(false); 599 589 const [isEditing, setIsEditing] = useState(false); 600 590 const [editColor, setEditColor] = useState(data.color || "#f59e0b"); 591 + const [editTags, setEditTags] = useState(data.tags?.join(", ") || ""); 601 592 602 593 const handleSaveEdit = async () => { 603 594 try { 604 - await updateHighlight(data.uri, editColor, []); 605 - setIsEditing(false); 595 + const tagList = editTags 596 + .split(",") 597 + .map((t) => t.trim()) 598 + .filter(Boolean); 606 599 607 - if (highlight.color) highlight.color = editColor; 600 + await updateHighlight(data.uri, editColor, tagList); 601 + setIsEditing(false); 602 + if (typeof onUpdate === "function") 603 + onUpdate({ ...highlight, color: editColor, tags: tagList }); 608 604 } catch (err) { 609 605 alert("Failed to update: " + err.message); 610 606 } ··· 633 629 return ( 634 630 <article className="card annotation-card"> 635 631 <header className="annotation-header"> 636 - <Link 637 - to={data.author?.did ? `/profile/${data.author.did}` : "#"} 638 - className="annotation-avatar-link" 639 - > 640 - <div className="annotation-avatar"> 641 - {data.author?.avatar ? ( 642 - <img src={data.author.avatar} alt="avatar" /> 643 - ) : ( 644 - <span>??</span> 632 + <div className="annotation-header-left"> 633 + <Link 634 + to={data.author?.did ? `/profile/${data.author.did}` : "#"} 635 + className="annotation-avatar-link" 636 + > 637 + <div className="annotation-avatar"> 638 + {data.author?.avatar ? ( 639 + <img src={data.author.avatar} alt="avatar" /> 640 + ) : ( 641 + <span>??</span> 642 + )} 643 + </div> 644 + </Link> 645 + <div className="annotation-meta"> 646 + <Link to="#" className="annotation-author-link"> 647 + <span className="annotation-author"> 648 + {data.author?.displayName || "Unknown"} 649 + </span> 650 + </Link> 651 + <div className="annotation-time">{formatDate(data.createdAt)}</div> 652 + {data.author?.handle && ( 653 + <a 654 + href={`https://bsky.app/profile/${data.author.handle}`} 655 + target="_blank" 656 + rel="noopener noreferrer" 657 + className="annotation-handle" 658 + > 659 + @{data.author.handle} 660 + </a> 645 661 )} 646 662 </div> 647 - </Link> 648 - <div className="annotation-meta"> 649 - <Link to="#" className="annotation-author-link"> 650 - <span className="annotation-author"> 651 - {data.author?.displayName || "Unknown"} 652 - </span> 653 - </Link> 654 - <div className="annotation-time">{formatDate(data.createdAt)}</div> 655 663 </div> 656 - <div className="action-buttons"> 657 - {isOwner && ( 658 - <> 659 - <button 660 - className="annotation-edit-btn" 661 - onClick={() => setIsEditing(!isEditing)} 662 - title="Edit Color" 663 - > 664 - <Edit2 size={16} /> 665 - </button> 666 - <button 667 - className="annotation-delete" 668 - onClick={(e) => { 669 - e.preventDefault(); 670 - onDelete && onDelete(highlight.id || highlight.uri); 671 - }} 672 - > 673 - <TrashIcon size={16} /> 674 - </button> 675 - </> 676 - )} 664 + 665 + <div className="annotation-header-right"> 666 + <div style={{ display: "flex", gap: "4px" }}> 667 + {isOwner && ( 668 + <> 669 + <button 670 + className="annotation-action action-icon-only" 671 + onClick={() => setIsEditing(!isEditing)} 672 + title="Edit Color" 673 + > 674 + <Edit2 size={16} /> 675 + </button> 676 + <button 677 + className="annotation-action action-icon-only" 678 + onClick={(e) => { 679 + e.preventDefault(); 680 + onDelete && onDelete(highlight.id || highlight.uri); 681 + }} 682 + > 683 + <TrashIcon size={16} /> 684 + </button> 685 + </> 686 + )} 687 + </div> 677 688 </div> 678 689 </header> 679 690 680 - <a 681 - href={data.url} 682 - target="_blank" 683 - rel="noopener noreferrer" 684 - className="annotation-source" 685 - > 686 - {truncateUrl(data.url)} 687 - </a> 688 - 689 - {highlightedText && ( 691 + <div className="annotation-content"> 690 692 <a 691 - href={fragmentUrl} 693 + href={data.url} 692 694 target="_blank" 693 695 rel="noopener noreferrer" 694 - className="annotation-highlight" 695 - style={{ 696 - borderLeftColor: isEditing ? editColor : data.color || "#f59e0b", 697 - }} 696 + className="annotation-source" 698 697 > 699 - <mark>"{highlightedText}"</mark> 698 + {truncateUrl(data.url)} 700 699 </a> 701 - )} 702 700 703 - {isEditing && ( 704 - <div 705 - className="mt-3" 706 - style={{ display: "flex", alignItems: "center", gap: "8px" }} 707 - > 708 - <span style={{ fontSize: "0.9rem" }}>Color:</span> 709 - <input 710 - type="color" 711 - value={editColor} 712 - onChange={(e) => setEditColor(e.target.value)} 701 + {highlightedText && ( 702 + <a 703 + href={fragmentUrl} 704 + target="_blank" 705 + rel="noopener noreferrer" 706 + className="annotation-highlight" 713 707 style={{ 714 - height: "32px", 715 - width: "64px", 716 - padding: 0, 717 - border: "none", 718 - borderRadius: "var(--radius-sm)", 719 - overflow: "hidden", 708 + borderLeftColor: isEditing ? editColor : data.color || "#f59e0b", 720 709 }} 710 + > 711 + <mark>&quot;{highlightedText}&quot;</mark> 712 + </a> 713 + )} 714 + 715 + {isEditing && ( 716 + <div 717 + className="mt-3" 718 + style={{ 719 + display: "flex", 720 + gap: "8px", 721 + alignItems: "center", 722 + padding: "8px", 723 + background: "var(--bg-secondary)", 724 + borderRadius: "var(--radius-md)", 725 + border: "1px solid var(--border)", 726 + }} 727 + > 728 + <div 729 + className="color-picker-compact" 730 + style={{ 731 + position: "relative", 732 + width: "28px", 733 + height: "28px", 734 + flexShrink: 0, 735 + }} 736 + > 737 + <div 738 + style={{ 739 + backgroundColor: editColor, 740 + width: "100%", 741 + height: "100%", 742 + borderRadius: "50%", 743 + border: "2px solid var(--bg-card)", 744 + boxShadow: "0 0 0 1px var(--border)", 745 + }} 746 + /> 747 + <input 748 + type="color" 749 + value={editColor} 750 + onChange={(e) => setEditColor(e.target.value)} 751 + style={{ 752 + position: "absolute", 753 + top: 0, 754 + left: 0, 755 + width: "100%", 756 + height: "100%", 757 + opacity: 0, 758 + cursor: "pointer", 759 + }} 760 + title="Change Color" 761 + /> 762 + </div> 763 + 764 + <input 765 + type="text" 766 + className="reply-input" 767 + placeholder="e.g. tag1, tag2" 768 + value={editTags} 769 + onChange={(e) => setEditTags(e.target.value)} 770 + style={{ 771 + margin: 0, 772 + flex: 1, 773 + fontSize: "0.9rem", 774 + padding: "6px 10px", 775 + height: "32px", 776 + border: "none", 777 + background: "transparent", 778 + }} 779 + /> 780 + 781 + <button 782 + onClick={handleSaveEdit} 783 + className="btn btn-primary btn-sm" 784 + style={{ padding: "0 10px", height: "32px", minWidth: "auto" }} 785 + title="Save" 786 + > 787 + <Save size={16} /> 788 + </button> 789 + </div> 790 + )} 791 + 792 + {data.tags?.length > 0 && ( 793 + <div className="annotation-tags"> 794 + {data.tags.map((tag, i) => ( 795 + <Link 796 + key={i} 797 + to={`/?tag=${encodeURIComponent(tag)}`} 798 + className="annotation-tag" 799 + > 800 + #{tag} 801 + </Link> 802 + ))} 803 + </div> 804 + )} 805 + </div> 806 + 807 + <footer className="annotation-actions"> 808 + <div className="annotation-actions-left"> 809 + <span 810 + className="annotation-action" 811 + style={{ 812 + color: data.color || "#f59e0b", 813 + background: "none", 814 + paddingLeft: 0, 815 + }} 816 + > 817 + <HighlightIcon size={14} /> Highlight 818 + </span> 819 + <ShareMenu 820 + uri={data.uri} 821 + text={data.title || data.description} 822 + handle={data.author?.handle} 823 + type="Highlight" 721 824 /> 722 825 <button 723 - onClick={handleSaveEdit} 724 - className="btn btn-primary btn-sm" 725 - style={{ marginLeft: "auto" }} 826 + className="annotation-action" 827 + onClick={() => { 828 + if (!user) { 829 + login(); 830 + return; 831 + } 832 + if (onAddToCollection) onAddToCollection(); 833 + }} 726 834 > 727 - Save 835 + <Folder size={16} /> 836 + <span>Collect</span> 728 837 </button> 729 838 </div> 730 - )} 731 - 732 - <footer className="annotation-actions"> 733 - <span 734 - className="annotation-action annotation-type-badge" 735 - style={{ color: data.color || "#f59e0b" }} 736 - > 737 - <HighlightIcon size={14} /> Highlight 738 - </span> 739 - <button 740 - className="annotation-action" 741 - onClick={() => { 742 - if (!user) { 743 - login(); 744 - return; 745 - } 746 - setShowAddToCollection(true); 747 - }} 748 - > 749 - <Folder size={16} /> 750 - <span>Collect</span> 751 - </button> 752 839 </footer> 753 - <AddToCollectionModal 754 - isOpen={showAddToCollection} 755 - onClose={() => setShowAddToCollection(false)} 756 - annotationUri={data.uri} 757 - /> 758 840 </article> 759 841 ); 760 842 }
+26
web/src/components/AnnotationSkeleton.jsx
··· 1 + export default function AnnotationSkeleton() { 2 + return ( 3 + <div className="skeleton-card"> 4 + <div className="skeleton-header"> 5 + <div className="skeleton skeleton-avatar" /> 6 + <div className="skeleton-meta"> 7 + <div className="skeleton skeleton-name" /> 8 + <div className="skeleton skeleton-handle" /> 9 + </div> 10 + </div> 11 + 12 + <div className="skeleton-content"> 13 + <div className="skeleton skeleton-source" /> 14 + <div className="skeleton skeleton-highlight" /> 15 + <div className="skeleton skeleton-text-1" /> 16 + <div className="skeleton skeleton-text-2" /> 17 + </div> 18 + 19 + <div className="skeleton-actions"> 20 + <div className="skeleton skeleton-action" /> 21 + <div className="skeleton skeleton-action" /> 22 + <div className="skeleton skeleton-action" /> 23 + </div> 24 + </div> 25 + ); 26 + }
+124 -133
web/src/components/BookmarkCard.jsx
··· 3 3 import { Link } from "react-router-dom"; 4 4 import { 5 5 normalizeAnnotation, 6 + normalizeBookmark, 6 7 likeAnnotation, 7 8 unlikeAnnotation, 8 9 getLikeCount, 9 10 deleteBookmark, 10 11 } from "../api/client"; 11 - import { HeartIcon, TrashIcon, ExternalLinkIcon, BookmarkIcon } from "./Icons"; 12 + import { HeartIcon, TrashIcon, BookmarkIcon } from "./Icons"; 12 13 import { Folder } from "lucide-react"; 13 - import AddToCollectionModal from "./AddToCollectionModal"; 14 14 import ShareMenu from "./ShareMenu"; 15 15 16 - export default function BookmarkCard({ bookmark, annotation, onDelete }) { 16 + export default function BookmarkCard({ 17 + bookmark, 18 + onAddToCollection, 19 + onDelete, 20 + }) { 17 21 const { user, login } = useAuth(); 18 - const data = normalizeAnnotation(bookmark || annotation); 22 + const raw = bookmark; 23 + const data = 24 + raw.type === "Bookmark" ? normalizeBookmark(raw) : normalizeAnnotation(raw); 19 25 20 26 const [likeCount, setLikeCount] = useState(0); 21 27 const [isLiked, setIsLiked] = useState(false); 22 28 const [deleting, setDeleting] = useState(false); 23 - const [showAddToCollection, setShowAddToCollection] = useState(false); 24 29 25 30 const isOwner = user?.did && data.author?.did === user.did; 26 31 ··· 33 38 if (likeRes.count !== undefined) setLikeCount(likeRes.count); 34 39 if (likeRes.liked !== undefined) setIsLiked(likeRes.liked); 35 40 } 36 - } catch (err) { 37 - console.error("Failed to fetch data:", err); 41 + } catch { 42 + /* ignore */ 38 43 } 39 44 } 40 45 if (data.uri) fetchData(); ··· 59 64 const cid = data.cid || ""; 60 65 if (data.uri && cid) await likeAnnotation(data.uri, cid); 61 66 } 62 - } catch (err) { 67 + } catch { 63 68 setIsLiked(!isLiked); 64 69 setLikeCount((prev) => (isLiked ? prev + 1 : prev - 1)); 65 70 } 66 71 }; 67 72 68 73 const handleDelete = async () => { 74 + if (onDelete) { 75 + onDelete(data.uri); 76 + return; 77 + } 78 + 69 79 if (!confirm("Delete this bookmark?")) return; 70 80 try { 71 81 setDeleting(true); 72 82 const parts = data.uri.split("/"); 73 83 const rkey = parts[parts.length - 1]; 74 84 await deleteBookmark(rkey); 75 - if (onDelete) onDelete(data.uri); 76 - else window.location.reload(); 85 + window.location.reload(); 77 86 } catch (err) { 78 87 alert("Failed to delete: " + err.message); 79 88 } finally { ··· 81 90 } 82 91 }; 83 92 84 - const handleShare = async () => { 85 - const uriParts = data.uri.split("/"); 86 - const did = uriParts[2]; 87 - const rkey = uriParts[uriParts.length - 1]; 88 - const shareUrl = `${window.location.origin}/at/${did}/${rkey}`; 89 - if (navigator.share) { 90 - try { 91 - await navigator.share({ title: "Bookmark", url: shareUrl }); 92 - } catch {} 93 - } else { 94 - try { 95 - await navigator.clipboard.writeText(shareUrl); 96 - alert("Link copied!"); 97 - } catch { 98 - prompt("Copy:", shareUrl); 99 - } 100 - } 101 - }; 102 - 103 93 const formatDate = (dateString) => { 104 94 if (!dateString) return ""; 105 95 const date = new Date(dateString); ··· 118 108 let domain = ""; 119 109 try { 120 110 if (data.url) domain = new URL(data.url).hostname.replace("www.", ""); 121 - } catch {} 111 + } catch { 112 + /* ignore */ 113 + } 122 114 123 115 const authorDisplayName = data.author?.displayName || data.author?.handle; 124 116 const authorHandle = data.author?.handle; ··· 127 119 const marginProfileUrl = authorDid ? `/profile/${authorDid}` : null; 128 120 129 121 return ( 130 - <article className="card bookmark-card"> 131 - {} 122 + <article className="card annotation-card bookmark-card"> 132 123 <header className="annotation-header"> 133 - <Link to={marginProfileUrl || "#"} className="annotation-avatar-link"> 134 - <div className="annotation-avatar"> 135 - {authorAvatar ? ( 136 - <img src={authorAvatar} alt={authorDisplayName} /> 137 - ) : ( 138 - <span> 139 - {(authorDisplayName || authorHandle || "??") 140 - ?.substring(0, 2) 141 - .toUpperCase()} 142 - </span> 143 - )} 124 + <div className="annotation-header-left"> 125 + <Link to={marginProfileUrl || "#"} className="annotation-avatar-link"> 126 + <div className="annotation-avatar"> 127 + {authorAvatar ? ( 128 + <img src={authorAvatar} alt={authorDisplayName} /> 129 + ) : ( 130 + <span> 131 + {(authorDisplayName || authorHandle || "??") 132 + ?.substring(0, 2) 133 + .toUpperCase()} 134 + </span> 135 + )} 136 + </div> 137 + </Link> 138 + <div className="annotation-meta"> 139 + <div className="annotation-author-row"> 140 + <Link 141 + to={marginProfileUrl || "#"} 142 + className="annotation-author-link" 143 + > 144 + <span className="annotation-author">{authorDisplayName}</span> 145 + </Link> 146 + {authorHandle && ( 147 + <a 148 + href={`https://bsky.app/profile/${authorHandle}`} 149 + target="_blank" 150 + rel="noopener noreferrer" 151 + className="annotation-handle" 152 + > 153 + @{authorHandle} 154 + </a> 155 + )} 156 + </div> 157 + <div className="annotation-time">{formatDate(data.createdAt)}</div> 144 158 </div> 145 - </Link> 146 - <div className="annotation-meta"> 147 - <div className="annotation-author-row"> 148 - <Link 149 - to={marginProfileUrl || "#"} 150 - className="annotation-author-link" 151 - > 152 - <span className="annotation-author">{authorDisplayName}</span> 153 - </Link> 154 - {authorHandle && ( 155 - <a 156 - href={`https://bsky.app/profile/${authorHandle}`} 157 - target="_blank" 158 - rel="noopener noreferrer" 159 - className="annotation-handle" 159 + </div> 160 + 161 + <div className="annotation-header-right"> 162 + <div style={{ display: "flex", gap: "4px" }}> 163 + {(isOwner || onDelete) && ( 164 + <button 165 + className="annotation-action action-icon-only" 166 + onClick={handleDelete} 167 + disabled={deleting} 168 + title="Delete" 160 169 > 161 - @{authorHandle} <ExternalLinkIcon size={12} /> 162 - </a> 170 + <TrashIcon size={16} /> 171 + </button> 163 172 )} 164 173 </div> 165 - <div className="annotation-time">{formatDate(data.createdAt)}</div> 166 - </div> 167 - <div className="action-buttons"> 168 - {isOwner && ( 169 - <button 170 - className="annotation-delete" 171 - onClick={handleDelete} 172 - disabled={deleting} 173 - title="Delete" 174 - > 175 - <TrashIcon size={16} /> 176 - </button> 177 - )} 178 174 </div> 179 175 </header> 180 176 181 - {} 182 - <a 183 - href={data.url} 184 - target="_blank" 185 - rel="noopener noreferrer" 186 - className="bookmark-preview" 187 - > 188 - <div className="bookmark-preview-content"> 189 - <div className="bookmark-preview-site"> 190 - <BookmarkIcon size={14} /> 191 - <span>{domain}</span> 177 + <div className="annotation-content"> 178 + <a 179 + href={data.url} 180 + target="_blank" 181 + rel="noopener noreferrer" 182 + className="bookmark-preview" 183 + > 184 + <div className="bookmark-preview-content"> 185 + <div className="bookmark-preview-site"> 186 + <BookmarkIcon size={14} /> 187 + <span>{domain}</span> 188 + </div> 189 + <h3 className="bookmark-preview-title">{data.title || data.url}</h3> 190 + {data.description && ( 191 + <p className="bookmark-preview-desc">{data.description}</p> 192 + )} 192 193 </div> 193 - <h3 className="bookmark-preview-title">{data.title || data.url}</h3> 194 - {data.description && ( 195 - <p className="bookmark-preview-desc">{data.description}</p> 196 - )} 197 - </div> 198 - <div className="bookmark-preview-arrow"> 199 - <ExternalLinkIcon size={18} /> 200 - </div> 201 - </a> 194 + </a> 202 195 203 - {} 204 - {data.tags?.length > 0 && ( 205 - <div className="annotation-tags"> 206 - {data.tags.map((tag, i) => ( 207 - <span key={i} className="annotation-tag"> 208 - #{tag} 209 - </span> 210 - ))} 211 - </div> 212 - )} 196 + {data.tags?.length > 0 && ( 197 + <div className="annotation-tags"> 198 + {data.tags.map((tag, i) => ( 199 + <span key={i} className="annotation-tag"> 200 + #{tag} 201 + </span> 202 + ))} 203 + </div> 204 + )} 205 + </div> 213 206 214 - {} 215 207 <footer className="annotation-actions"> 216 - <button 217 - className={`annotation-action ${isLiked ? "liked" : ""}`} 218 - onClick={handleLike} 219 - > 220 - <HeartIcon filled={isLiked} size={16} /> 221 - {likeCount > 0 && <span>{likeCount}</span>} 222 - </button> 223 - <ShareMenu uri={data.uri} text={data.title || data.description} /> 224 - <button 225 - className="annotation-action" 226 - onClick={() => { 227 - if (!user) { 228 - login(); 229 - return; 230 - } 231 - setShowAddToCollection(true); 232 - }} 233 - > 234 - <Folder size={16} /> 235 - <span>Collect</span> 236 - </button> 208 + <div className="annotation-actions-left"> 209 + <button 210 + className={`annotation-action ${isLiked ? "liked" : ""}`} 211 + onClick={handleLike} 212 + > 213 + <HeartIcon filled={isLiked} size={16} /> 214 + {likeCount > 0 && <span>{likeCount}</span>} 215 + </button> 216 + <ShareMenu 217 + uri={data.uri} 218 + text={data.title || data.description} 219 + handle={data.author?.handle} 220 + type="Bookmark" 221 + /> 222 + <button 223 + className="annotation-action" 224 + onClick={() => { 225 + if (!user) { 226 + login(); 227 + return; 228 + } 229 + if (onAddToCollection) onAddToCollection(); 230 + }} 231 + > 232 + <Folder size={16} /> 233 + <span>Collect</span> 234 + </button> 235 + </div> 237 236 </footer> 238 - 239 - {showAddToCollection && ( 240 - <AddToCollectionModal 241 - isOpen={showAddToCollection} 242 - annotationUri={data.uri} 243 - onClose={() => setShowAddToCollection(false)} 244 - /> 245 - )} 246 237 </article> 247 238 ); 248 239 }
+4 -3
web/src/components/CollectionItemCard.jsx
··· 1 - import React from "react"; 2 1 import { Link } from "react-router-dom"; 3 2 import AnnotationCard, { HighlightCard } from "./AnnotationCard"; 4 3 import BookmarkCard from "./BookmarkCard"; ··· 54 53 </span>{" "} 55 54 added to{" "} 56 55 <Link 57 - to={`/collection/${encodeURIComponent(collection.uri)}?author=${encodeURIComponent(author.did)}`} 56 + to={`/${author.handle}/collection/${collection.uri.split("/").pop()}`} 58 57 style={{ 59 58 display: "inline-flex", 60 59 alignItems: "center", ··· 70 69 </span> 71 70 <div style={{ marginLeft: "auto" }}> 72 71 <ShareMenu 73 - customUrl={`${window.location.origin}/collection/${encodeURIComponent(collection.uri)}?author=${encodeURIComponent(author.did)}`} 72 + uri={collection.uri} 73 + handle={author.handle} 74 + type="Collection" 74 75 text={`Check out this collection by ${author.displayName}: ${collection.name}`} 75 76 /> 76 77 </div>
-7
web/src/components/CollectionModal.jsx
··· 12 12 Camera, 13 13 Code, 14 14 Globe, 15 - Lock, 16 15 Flag, 17 16 Tag, 18 17 Box, ··· 21 20 Image, 22 21 Video, 23 22 Mail, 24 - Phone, 25 23 MapPin, 26 24 Calendar, 27 25 Clock, ··· 31 29 Users, 32 30 Home, 33 31 Briefcase, 34 - ShoppingBag, 35 32 Gift, 36 33 Award, 37 34 Target, 38 35 TrendingUp, 39 - BarChart, 40 - PieChart, 41 36 Activity, 42 37 Cpu, 43 38 Database, ··· 46 41 Moon, 47 42 Flame, 48 43 Leaf, 49 - Droplet, 50 - Snowflake, 51 44 } from "lucide-react"; 52 45 import { createCollection, updateCollection } from "../api/client"; 53 46
+5 -3
web/src/components/CollectionRow.jsx
··· 6 6 return ( 7 7 <div className="collection-row"> 8 8 <Link 9 - to={`/collection/${encodeURIComponent(collection.uri)}?author=${encodeURIComponent( 10 - collection.authorDid || collection.author?.did, 11 - )}`} 9 + to={ 10 + collection.creator?.handle 11 + ? `/${collection.creator.handle}/collection/${collection.uri.split("/").pop()}` 12 + : `/collection/${encodeURIComponent(collection.uri)}` 13 + } 12 14 className="collection-row-content" 13 15 > 14 16 <div className="collection-row-icon">
+38 -10
web/src/components/Composer.jsx
··· 1 1 import { useState } from "react"; 2 - import { createAnnotation } from "../api/client"; 2 + import { createAnnotation, createHighlight } from "../api/client"; 3 3 4 4 export default function Composer({ 5 5 url, ··· 9 9 }) { 10 10 const [text, setText] = useState(""); 11 11 const [quoteText, setQuoteText] = useState(""); 12 + const [tags, setTags] = useState(""); 12 13 const [selector, setSelector] = useState(initialSelector); 13 14 const [loading, setLoading] = useState(false); 14 15 const [error, setError] = useState(null); ··· 19 20 20 21 const handleSubmit = async (e) => { 21 22 e.preventDefault(); 22 - if (!text.trim()) return; 23 + if (!text.trim() && !highlightedText && !quoteText.trim()) return; 23 24 24 25 try { 25 26 setLoading(true); ··· 33 34 }; 34 35 } 35 36 36 - await createAnnotation({ 37 - url, 38 - text, 39 - selector: finalSelector || undefined, 40 - }); 37 + const tagList = tags 38 + .split(",") 39 + .map((t) => t.trim()) 40 + .filter(Boolean); 41 + 42 + if (!text.trim()) { 43 + await createHighlight({ 44 + url, 45 + selector: finalSelector, 46 + color: "yellow", 47 + tags: tagList, 48 + }); 49 + } else { 50 + await createAnnotation({ 51 + url, 52 + text, 53 + selector: finalSelector || undefined, 54 + tags: tagList, 55 + }); 56 + } 41 57 42 58 setText(""); 43 59 setQuoteText(""); ··· 75 91 ร— 76 92 </button> 77 93 <blockquote> 78 - <mark className="quote-exact">"{highlightedText}"</mark> 94 + <mark className="quote-exact">&quot;{highlightedText}&quot;</mark> 79 95 </blockquote> 80 96 </div> 81 97 )} ··· 123 139 className="composer-input" 124 140 rows={4} 125 141 maxLength={3000} 126 - required 127 142 disabled={loading} 128 143 /> 129 144 145 + <div className="composer-tags"> 146 + <input 147 + type="text" 148 + value={tags} 149 + onChange={(e) => setTags(e.target.value)} 150 + placeholder="Add tags (comma separated)..." 151 + className="composer-tags-input" 152 + disabled={loading} 153 + /> 154 + </div> 155 + 130 156 <div className="composer-footer"> 131 157 <span className="composer-count">{text.length}/3000</span> 132 158 <div className="composer-actions"> ··· 143 169 <button 144 170 type="submit" 145 171 className="btn btn-primary" 146 - disabled={loading || !text.trim()} 172 + disabled={ 173 + loading || (!text.trim() && !highlightedText && !quoteText) 174 + } 147 175 > 148 176 {loading ? "Posting..." : "Post"} 149 177 </button>
+61
web/src/components/MobileNav.jsx
··· 1 + import { Link, useLocation } from "react-router-dom"; 2 + import { useAuth } from "../context/AuthContext"; 3 + import { Home, Search, Folder, User, PenSquare } from "lucide-react"; 4 + 5 + export default function MobileNav() { 6 + const { user, isAuthenticated } = useAuth(); 7 + const location = useLocation(); 8 + 9 + const isActive = (path) => { 10 + if (path === "/") return location.pathname === "/"; 11 + return location.pathname.startsWith(path); 12 + }; 13 + 14 + return ( 15 + <nav className="mobile-nav"> 16 + <div className="mobile-nav-inner"> 17 + <Link 18 + to="/" 19 + className={`mobile-nav-item ${isActive("/") ? "active" : ""}`} 20 + > 21 + <Home /> 22 + <span>Home</span> 23 + </Link> 24 + 25 + <Link 26 + to="/url" 27 + className={`mobile-nav-item ${isActive("/url") ? "active" : ""}`} 28 + > 29 + <Search /> 30 + <span>Browse</span> 31 + </Link> 32 + 33 + {isAuthenticated ? ( 34 + <Link to="/new" className="mobile-nav-item mobile-nav-new"> 35 + <PenSquare /> 36 + </Link> 37 + ) : ( 38 + <Link to="/login" className="mobile-nav-item mobile-nav-new"> 39 + <User /> 40 + </Link> 41 + )} 42 + 43 + <Link 44 + to="/collections" 45 + className={`mobile-nav-item ${isActive("/collections") ? "active" : ""}`} 46 + > 47 + <Folder /> 48 + <span>Library</span> 49 + </Link> 50 + 51 + <Link 52 + to={isAuthenticated && user?.did ? `/profile/${user.did}` : "/login"} 53 + className={`mobile-nav-item ${isActive("/profile") ? "active" : ""}`} 54 + > 55 + <User /> 56 + <span>Profile</span> 57 + </Link> 58 + </div> 59 + </nav> 60 + ); 61 + }
-245
web/src/components/Navbar.jsx
··· 1 - import { useState, useRef, useEffect } from "react"; 2 - import { Link, useLocation } from "react-router-dom"; 3 - import { Folder } from "lucide-react"; 4 - import { useAuth } from "../context/AuthContext"; 5 - import { 6 - PenIcon, 7 - BookmarkIcon, 8 - HighlightIcon, 9 - SearchIcon, 10 - LogoutIcon, 11 - BellIcon, 12 - } from "./Icons"; 13 - import { getUnreadNotificationCount } from "../api/client"; 14 - import { SiFirefox, SiGooglechrome } from "react-icons/si"; 15 - import { FaEdge } from "react-icons/fa"; 16 - 17 - import logo from "../assets/logo.svg"; 18 - 19 - const isFirefox = 20 - typeof navigator !== "undefined" && /Firefox/i.test(navigator.userAgent); 21 - const isEdge = 22 - typeof navigator !== "undefined" && /Edg/i.test(navigator.userAgent); 23 - const isChrome = 24 - typeof navigator !== "undefined" && 25 - /Chrome/i.test(navigator.userAgent) && 26 - !isEdge; 27 - 28 - export default function Navbar() { 29 - const { user, isAuthenticated, logout, loading } = useAuth(); 30 - const location = useLocation(); 31 - const [menuOpen, setMenuOpen] = useState(false); 32 - const [unreadCount, setUnreadCount] = useState(0); 33 - const menuRef = useRef(null); 34 - 35 - const isActive = (path) => location.pathname === path; 36 - 37 - useEffect(() => { 38 - if (isAuthenticated) { 39 - getUnreadNotificationCount() 40 - .then((data) => setUnreadCount(data.count || 0)) 41 - .catch(() => {}); 42 - const interval = setInterval(() => { 43 - getUnreadNotificationCount() 44 - .then((data) => setUnreadCount(data.count || 0)) 45 - .catch(() => {}); 46 - }, 60000); 47 - return () => clearInterval(interval); 48 - } 49 - }, [isAuthenticated]); 50 - 51 - useEffect(() => { 52 - const handleClickOutside = (e) => { 53 - if (menuRef.current && !menuRef.current.contains(e.target)) { 54 - setMenuOpen(false); 55 - } 56 - }; 57 - document.addEventListener("mousedown", handleClickOutside); 58 - return () => document.removeEventListener("mousedown", handleClickOutside); 59 - }, []); 60 - 61 - const getInitials = () => { 62 - if (user?.displayName) { 63 - return user.displayName.substring(0, 2).toUpperCase(); 64 - } 65 - if (user?.handle) { 66 - return user.handle.substring(0, 2).toUpperCase(); 67 - } 68 - return "U"; 69 - }; 70 - 71 - return ( 72 - <nav className="navbar"> 73 - <div className="navbar-inner"> 74 - {} 75 - <Link to="/" className="navbar-brand"> 76 - <img src={logo} alt="Margin Logo" className="navbar-logo-img" /> 77 - <span className="navbar-title">Margin</span> 78 - </Link> 79 - 80 - {} 81 - <div className="navbar-center"> 82 - <Link 83 - to="/" 84 - className={`navbar-link ${isActive("/") ? "active" : ""}`} 85 - > 86 - Feed 87 - </Link> 88 - <Link 89 - to="/url" 90 - className={`navbar-link ${isActive("/url") ? "active" : ""}`} 91 - > 92 - <SearchIcon size={16} /> 93 - Browse 94 - </Link> 95 - {isFirefox ? ( 96 - <a 97 - href="https://addons.mozilla.org/en-US/firefox/addon/margin/" 98 - target="_blank" 99 - rel="noopener noreferrer" 100 - className="navbar-link navbar-extension-link" 101 - > 102 - <SiFirefox size={16} /> 103 - Get Extension 104 - </a> 105 - ) : isEdge ? ( 106 - <a 107 - href="https://microsoftedge.microsoft.com/addons/detail/margin/nfjnmllpdgcdnhmmggjihjbidmeadddn" 108 - target="_blank" 109 - rel="noopener noreferrer" 110 - className="navbar-link navbar-extension-link" 111 - > 112 - <FaEdge size={16} /> 113 - Get Extension 114 - </a> 115 - ) : isChrome ? ( 116 - <a 117 - href="https://chromewebstore.google.com/detail/margin/cgpmbiiagnehkikhcbnhiagfomajncpa/" 118 - target="_blank" 119 - rel="noopener noreferrer" 120 - className="navbar-link navbar-extension-link" 121 - > 122 - <SiGooglechrome size={16} /> 123 - Get Extension 124 - </a> 125 - ) : ( 126 - <a 127 - href="https://addons.mozilla.org/en-US/firefox/addon/margin/" 128 - target="_blank" 129 - rel="noopener noreferrer" 130 - className="navbar-link navbar-extension-link" 131 - > 132 - <SiFirefox size={16} /> 133 - Get Extension 134 - </a> 135 - )} 136 - </div> 137 - 138 - {} 139 - <div className="navbar-right"> 140 - {!loading && 141 - (isAuthenticated ? ( 142 - <> 143 - <Link 144 - to="/highlights" 145 - className={`navbar-icon-link ${isActive("/highlights") ? "active" : ""}`} 146 - title="Highlights" 147 - > 148 - <HighlightIcon size={20} /> 149 - </Link> 150 - <Link 151 - to="/bookmarks" 152 - className={`navbar-icon-link ${isActive("/bookmarks") ? "active" : ""}`} 153 - title="Bookmarks" 154 - > 155 - <BookmarkIcon size={20} /> 156 - </Link> 157 - <Link 158 - to="/collections" 159 - className={`navbar-icon-link ${isActive("/collections") ? "active" : ""}`} 160 - title="Collections" 161 - > 162 - <Folder size={20} /> 163 - </Link> 164 - <Link 165 - to="/notifications" 166 - className={`navbar-icon-link notification-link ${isActive("/notifications") ? "active" : ""}`} 167 - title="Notifications" 168 - onClick={() => setUnreadCount(0)} 169 - > 170 - <BellIcon size={20} /> 171 - {unreadCount > 0 && ( 172 - <span className="notification-badge">{unreadCount}</span> 173 - )} 174 - </Link> 175 - <Link 176 - to="/new" 177 - className="navbar-new-btn" 178 - title="New Annotation" 179 - > 180 - <PenIcon size={16} /> 181 - <span>New</span> 182 - </Link> 183 - 184 - {} 185 - <div className="navbar-user-menu" ref={menuRef}> 186 - <button 187 - className="navbar-avatar-btn" 188 - onClick={() => setMenuOpen(!menuOpen)} 189 - title={user?.handle} 190 - > 191 - {user?.avatar ? ( 192 - <img 193 - src={user.avatar} 194 - alt={user.displayName} 195 - className="navbar-avatar-img" 196 - /> 197 - ) : ( 198 - <span className="navbar-avatar-text"> 199 - {getInitials()} 200 - </span> 201 - )} 202 - </button> 203 - 204 - {menuOpen && ( 205 - <div className="navbar-dropdown"> 206 - <div className="navbar-dropdown-header"> 207 - <span className="navbar-dropdown-name"> 208 - {user?.displayName} 209 - </span> 210 - <span className="navbar-dropdown-handle"> 211 - @{user?.handle} 212 - </span> 213 - </div> 214 - <div className="navbar-dropdown-divider" /> 215 - <Link 216 - to={`/profile/${user?.did}`} 217 - className="navbar-dropdown-item" 218 - onClick={() => setMenuOpen(false)} 219 - > 220 - View Profile 221 - </Link> 222 - <button 223 - onClick={() => { 224 - logout(); 225 - setMenuOpen(false); 226 - }} 227 - className="navbar-dropdown-item navbar-dropdown-logout" 228 - > 229 - <LogoutIcon size={16} /> 230 - Sign Out 231 - </button> 232 - </div> 233 - )} 234 - </div> 235 - </> 236 - ) : ( 237 - <Link to="/login" className="navbar-signin"> 238 - Sign In 239 - </Link> 240 - ))} 241 - </div> 242 - </div> 243 - </nav> 244 - ); 245 - }
-1
web/src/components/ReplyList.jsx
··· 1 - import React from "react"; 2 1 import { Link } from "react-router-dom"; 3 2 import { MessageSquare, Trash2, Reply } from "lucide-react"; 4 3
+156
web/src/components/RightSidebar.jsx
··· 1 + import { useState, useEffect } from "react"; 2 + import { Link } from "react-router-dom"; 3 + import { ExternalLink } from "lucide-react"; 4 + import { SiFirefox, SiGooglechrome, SiGithub, SiBluesky } from "react-icons/si"; 5 + import { FaEdge } from "react-icons/fa"; 6 + import { useAuth } from "../context/AuthContext"; 7 + import { getTrendingTags } from "../api/client"; 8 + 9 + const isFirefox = 10 + typeof navigator !== "undefined" && /Firefox/i.test(navigator.userAgent); 11 + const isEdge = 12 + typeof navigator !== "undefined" && /Edg/i.test(navigator.userAgent); 13 + 14 + function getExtensionInfo() { 15 + if (isFirefox) { 16 + return { 17 + url: "https://addons.mozilla.org/en-US/firefox/addon/margin/", 18 + icon: SiFirefox, 19 + name: "Firefox", 20 + }; 21 + } 22 + if (isEdge) { 23 + return { 24 + url: "https://microsoftedge.microsoft.com/addons/detail/margin/nfjnmllpdgcdnhmmggjihjbidmeadddn", 25 + icon: FaEdge, 26 + name: "Edge", 27 + }; 28 + } 29 + return { 30 + url: "https://chromewebstore.google.com/detail/margin/cgpmbiiagnehkikhcbnhiagfomajncpa/", 31 + icon: SiGooglechrome, 32 + name: "Chrome", 33 + }; 34 + } 35 + 36 + export default function RightSidebar() { 37 + const { isAuthenticated } = useAuth(); 38 + const ext = getExtensionInfo(); 39 + const ExtIcon = ext.icon; 40 + const [trendingTags, setTrendingTags] = useState([]); 41 + const [loading, setLoading] = useState(true); 42 + 43 + useEffect(() => { 44 + getTrendingTags() 45 + .then((tags) => setTrendingTags(tags)) 46 + .catch((err) => console.error("Failed to fetch trending tags:", err)) 47 + .finally(() => setLoading(false)); 48 + }, []); 49 + 50 + return ( 51 + <aside className="right-sidebar"> 52 + <div className="right-section"> 53 + <h3 className="right-section-title">Get the Extension</h3> 54 + <p className="right-section-desc"> 55 + Annotate, highlight, and bookmark any webpage 56 + </p> 57 + <a 58 + href={ext.url} 59 + target="_blank" 60 + rel="noopener noreferrer" 61 + className="right-extension-btn" 62 + > 63 + <ExtIcon size={18} /> 64 + Install for {ext.name} 65 + <ExternalLink size={14} /> 66 + </a> 67 + </div> 68 + 69 + {isAuthenticated ? ( 70 + <div className="right-section"> 71 + <h3 className="right-section-title">Trending Tags</h3> 72 + <div className="right-links"> 73 + {loading ? ( 74 + <span className="right-section-desc">Loading...</span> 75 + ) : trendingTags.length > 0 ? ( 76 + trendingTags.map(({ tag, count }) => ( 77 + <Link 78 + key={tag} 79 + to={`/?tag=${encodeURIComponent(tag)}`} 80 + className="right-link" 81 + > 82 + <span>#{tag}</span> 83 + <span style={{ fontSize: "0.75rem", opacity: 0.6 }}> 84 + {count} 85 + </span> 86 + </Link> 87 + )) 88 + ) : ( 89 + <span className="right-section-desc">No trending tags yet</span> 90 + )} 91 + </div> 92 + </div> 93 + ) : ( 94 + <div className="right-section"> 95 + <h3 className="right-section-title">Explore</h3> 96 + <nav className="right-links"> 97 + <Link to="/url" className="right-link"> 98 + Browse by URL 99 + </Link> 100 + <Link to="/highlights" className="right-link"> 101 + Public Highlights 102 + </Link> 103 + </nav> 104 + </div> 105 + )} 106 + 107 + <div className="right-section"> 108 + <h3 className="right-section-title">Resources</h3> 109 + <nav className="right-links"> 110 + <a 111 + href="https://github.com/margin-at/margin" 112 + target="_blank" 113 + rel="noopener noreferrer" 114 + className="right-link" 115 + > 116 + <div style={{ display: "flex", alignItems: "center", gap: "8px" }}> 117 + <SiGithub size={16} /> 118 + GitHub 119 + </div> 120 + <ExternalLink size={12} /> 121 + </a> 122 + <a 123 + href="https://tangled.org/margin.at/margin" 124 + target="_blank" 125 + rel="noopener noreferrer" 126 + className="right-link" 127 + > 128 + <div style={{ display: "flex", alignItems: "center", gap: "8px" }}> 129 + <div className="tangled-icon" /> 130 + Tangled 131 + </div> 132 + <ExternalLink size={12} /> 133 + </a> 134 + <a 135 + href="https://bsky.app/profile/margin.at" 136 + target="_blank" 137 + rel="noopener noreferrer" 138 + className="right-link" 139 + > 140 + <div style={{ display: "flex", alignItems: "center", gap: "8px" }}> 141 + <SiBluesky size={16} /> 142 + Bluesky 143 + </div> 144 + <ExternalLink size={12} /> 145 + </a> 146 + </nav> 147 + </div> 148 + 149 + <div className="right-footer"> 150 + <Link to="/privacy">Privacy</Link> 151 + <span>ยท</span> 152 + <Link to="/terms">Terms</Link> 153 + </div> 154 + </aside> 155 + ); 156 + }
+12
web/src/components/ScrollToTop.jsx
··· 1 + import { useEffect } from "react"; 2 + import { useLocation } from "react-router-dom"; 3 + 4 + export default function ScrollToTop() { 5 + const { pathname } = useLocation(); 6 + 7 + useEffect(() => { 8 + window.scrollTo(0, 0); 9 + }, [pathname]); 10 + 11 + return null; 12 + }
+21 -3
web/src/components/ShareMenu.jsx
··· 97 97 { name: "Deer", domain: "deer.social", Icon: DeerIcon }, 98 98 ]; 99 99 100 - export default function ShareMenu({ uri, text, customUrl }) { 100 + export default function ShareMenu({ uri, text, customUrl, handle, type }) { 101 101 const [isOpen, setIsOpen] = useState(false); 102 102 const [copied, setCopied] = useState(false); 103 103 const menuRef = useRef(null); ··· 105 105 const getShareUrl = () => { 106 106 if (customUrl) return customUrl; 107 107 if (!uri) return ""; 108 + 108 109 const uriParts = uri.split("/"); 109 - const did = uriParts[2]; 110 110 const rkey = uriParts[uriParts.length - 1]; 111 + 112 + if (handle && type) { 113 + return `${window.location.origin}/${handle}/${type.toLowerCase()}/${rkey}`; 114 + } 115 + 116 + const did = uriParts[2]; 111 117 return `${window.location.origin}/at/${did}/${rkey}`; 112 118 }; 113 119 ··· 119 125 setIsOpen(false); 120 126 } 121 127 }; 128 + 129 + const card = menuRef.current?.closest(".card"); 130 + if (card) { 131 + if (isOpen) { 132 + card.style.zIndex = "50"; 133 + } else { 134 + card.style.zIndex = ""; 135 + } 136 + } 137 + 122 138 if (isOpen) { 123 139 document.addEventListener("mousedown", handleClickOutside); 124 140 } ··· 155 171 text: text?.substring(0, 100), 156 172 url: shareUrl, 157 173 }); 158 - } catch {} 174 + } catch { 175 + /* ignore */ 176 + } 159 177 } 160 178 setIsOpen(false); 161 179 };
+189
web/src/components/Sidebar.jsx
··· 1 + import { useState, useRef, useEffect } from "react"; 2 + import { Link, useLocation } from "react-router-dom"; 3 + import { useAuth } from "../context/AuthContext"; 4 + import { 5 + Home, 6 + Search, 7 + Folder, 8 + Bell, 9 + PenSquare, 10 + User, 11 + LogOut, 12 + MoreHorizontal, 13 + Highlighter, 14 + Bookmark, 15 + } from "lucide-react"; 16 + import { getUnreadNotificationCount } from "../api/client"; 17 + import logo from "../assets/logo.svg"; 18 + 19 + export default function Sidebar() { 20 + const { user, isAuthenticated, logout, loading } = useAuth(); 21 + const location = useLocation(); 22 + const [menuOpen, setMenuOpen] = useState(false); 23 + const [unreadCount, setUnreadCount] = useState(0); 24 + const menuRef = useRef(null); 25 + 26 + const isActive = (path) => { 27 + if (path === "/") return location.pathname === "/"; 28 + return location.pathname.startsWith(path); 29 + }; 30 + 31 + useEffect(() => { 32 + if (isAuthenticated) { 33 + getUnreadNotificationCount() 34 + .then((data) => setUnreadCount(data.count || 0)) 35 + .catch(() => {}); 36 + const interval = setInterval(() => { 37 + getUnreadNotificationCount() 38 + .then((data) => setUnreadCount(data.count || 0)) 39 + .catch(() => {}); 40 + }, 60000); 41 + return () => clearInterval(interval); 42 + } 43 + }, [isAuthenticated]); 44 + 45 + useEffect(() => { 46 + const handleClickOutside = (e) => { 47 + if (menuRef.current && !menuRef.current.contains(e.target)) { 48 + setMenuOpen(false); 49 + } 50 + }; 51 + document.addEventListener("mousedown", handleClickOutside); 52 + return () => document.removeEventListener("mousedown", handleClickOutside); 53 + }, []); 54 + 55 + const getInitials = () => { 56 + if (user?.displayName) { 57 + return user.displayName.substring(0, 2).toUpperCase(); 58 + } 59 + if (user?.handle) { 60 + return user.handle.substring(0, 2).toUpperCase(); 61 + } 62 + return "U"; 63 + }; 64 + 65 + return ( 66 + <aside className="sidebar"> 67 + <Link to="/" className="sidebar-header"> 68 + <img src={logo} alt="Margin" className="sidebar-logo" /> 69 + <span className="sidebar-brand">Margin</span> 70 + </Link> 71 + 72 + <nav className="sidebar-nav"> 73 + <Link 74 + to="/" 75 + className={`sidebar-link ${isActive("/") ? "active" : ""}`} 76 + > 77 + <Home size={20} /> 78 + <span>Home</span> 79 + </Link> 80 + <Link 81 + to="/url" 82 + className={`sidebar-link ${isActive("/url") ? "active" : ""}`} 83 + > 84 + <Search size={20} /> 85 + <span>Browse</span> 86 + </Link> 87 + 88 + {isAuthenticated && ( 89 + <> 90 + <div className="sidebar-section-title">Library</div> 91 + <Link 92 + to="/highlights" 93 + className={`sidebar-link ${isActive("/highlights") ? "active" : ""}`} 94 + > 95 + <Highlighter size={20} /> 96 + <span>Highlights</span> 97 + </Link> 98 + <Link 99 + to="/bookmarks" 100 + className={`sidebar-link ${isActive("/bookmarks") ? "active" : ""}`} 101 + > 102 + <Bookmark size={20} /> 103 + <span>Bookmarks</span> 104 + </Link> 105 + <Link 106 + to="/collections" 107 + className={`sidebar-link ${isActive("/collections") ? "active" : ""}`} 108 + > 109 + <Folder size={20} /> 110 + <span>Collections</span> 111 + </Link> 112 + <Link 113 + to="/notifications" 114 + className={`sidebar-link ${isActive("/notifications") ? "active" : ""}`} 115 + onClick={() => setUnreadCount(0)} 116 + > 117 + <Bell size={20} /> 118 + <span>Notifications</span> 119 + {unreadCount > 0 && ( 120 + <span className="notification-badge">{unreadCount}</span> 121 + )} 122 + </Link> 123 + </> 124 + )} 125 + </nav> 126 + 127 + {isAuthenticated && ( 128 + <Link to="/new" className="sidebar-new-btn"> 129 + <PenSquare size={18} /> 130 + <span>New</span> 131 + </Link> 132 + )} 133 + 134 + <div className="sidebar-footer" ref={menuRef}> 135 + {!loading && 136 + (isAuthenticated ? ( 137 + <> 138 + <div 139 + className="sidebar-user" 140 + onClick={() => setMenuOpen(!menuOpen)} 141 + > 142 + <div className="sidebar-avatar"> 143 + {user?.avatar ? ( 144 + <img src={user.avatar} alt={user.displayName} /> 145 + ) : ( 146 + <span>{getInitials()}</span> 147 + )} 148 + </div> 149 + <div className="sidebar-user-info"> 150 + <div className="sidebar-user-name"> 151 + {user?.displayName || user?.handle} 152 + </div> 153 + <div className="sidebar-user-handle">@{user?.handle}</div> 154 + </div> 155 + <MoreHorizontal size={18} className="sidebar-user-menu" /> 156 + </div> 157 + 158 + {menuOpen && ( 159 + <div className="sidebar-dropdown"> 160 + <Link 161 + to={`/profile/${user?.did}`} 162 + className="sidebar-dropdown-item" 163 + onClick={() => setMenuOpen(false)} 164 + > 165 + <User size={16} /> 166 + View Profile 167 + </Link> 168 + <button 169 + onClick={() => { 170 + logout(); 171 + setMenuOpen(false); 172 + }} 173 + className="sidebar-dropdown-item danger" 174 + > 175 + <LogOut size={16} /> 176 + Sign Out 177 + </button> 178 + </div> 179 + )} 180 + </> 181 + ) : ( 182 + <Link to="/login" className="sidebar-new-btn" style={{ margin: 0 }}> 183 + Sign In 184 + </Link> 185 + ))} 186 + </div> 187 + </aside> 188 + ); 189 + }
+4 -1
web/src/context/AuthContext.jsx
··· 48 48 const handleLogout = async () => { 49 49 try { 50 50 await logout(); 51 - } catch {} 51 + } catch (e) { 52 + console.warn("Logout failed", e); 53 + } 52 54 setUser(null); 53 55 }; 54 56 ··· 64 66 return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>; 65 67 } 66 68 69 + // eslint-disable-next-line react-refresh/only-export-components 67 70 export function useAuth() { 68 71 const context = useContext(AuthContext); 69 72 if (!context) {
+473
web/src/css/annotations.css
··· 1 + .annotation-detail-page { 2 + max-width: 680px; 3 + margin: 0 auto; 4 + padding: 24px 16px; 5 + min-height: 100vh; 6 + } 7 + 8 + .annotation-detail-header { 9 + margin-bottom: 24px; 10 + } 11 + 12 + .back-link { 13 + display: inline-flex; 14 + align-items: center; 15 + color: var(--text-tertiary); 16 + text-decoration: none; 17 + font-size: 0.9rem; 18 + font-weight: 500; 19 + transition: color 0.15s; 20 + } 21 + 22 + .back-link:hover { 23 + color: var(--text-primary); 24 + } 25 + 26 + .replies-section { 27 + margin-top: 32px; 28 + border-top: 1px solid var(--border); 29 + padding-top: 24px; 30 + } 31 + 32 + .replies-title { 33 + display: flex; 34 + align-items: center; 35 + gap: 8px; 36 + font-size: 1.1rem; 37 + font-weight: 600; 38 + color: var(--text-primary); 39 + margin-bottom: 20px; 40 + } 41 + 42 + .annotation-card { 43 + display: flex; 44 + flex-direction: column; 45 + gap: 12px; 46 + padding: 20px 0; 47 + border-bottom: 1px solid var(--border); 48 + transition: background 0.15s ease; 49 + } 50 + 51 + .annotation-card:last-child { 52 + border-bottom: none; 53 + } 54 + 55 + .annotation-header { 56 + display: flex; 57 + justify-content: space-between; 58 + align-items: flex-start; 59 + gap: 12px; 60 + } 61 + 62 + .annotation-header-left { 63 + display: flex; 64 + align-items: center; 65 + gap: 10px; 66 + flex: 1; 67 + min-width: 0; 68 + } 69 + 70 + .annotation-avatar { 71 + width: 36px; 72 + height: 36px; 73 + min-width: 36px; 74 + border-radius: 50%; 75 + background: var(--bg-tertiary); 76 + display: flex; 77 + align-items: center; 78 + justify-content: center; 79 + font-weight: 600; 80 + font-size: 0.85rem; 81 + color: var(--text-secondary); 82 + overflow: hidden; 83 + } 84 + 85 + .annotation-avatar img { 86 + width: 100%; 87 + height: 100%; 88 + object-fit: cover; 89 + } 90 + 91 + .annotation-meta { 92 + display: flex; 93 + flex-direction: column; 94 + justify-content: center; 95 + line-height: 1.3; 96 + } 97 + 98 + .annotation-avatar-link { 99 + text-decoration: none; 100 + border-radius: 50%; 101 + } 102 + 103 + .annotation-author-row { 104 + display: flex; 105 + align-items: baseline; 106 + gap: 6px; 107 + flex-wrap: wrap; 108 + } 109 + 110 + .annotation-author { 111 + font-weight: 600; 112 + color: var(--text-primary); 113 + font-size: 0.9rem; 114 + } 115 + 116 + .annotation-handle { 117 + font-size: 0.85rem; 118 + color: var(--text-tertiary); 119 + text-decoration: none; 120 + } 121 + 122 + .annotation-handle:hover { 123 + color: var(--text-secondary); 124 + } 125 + 126 + .annotation-time { 127 + font-size: 0.75rem; 128 + color: var(--text-tertiary); 129 + } 130 + 131 + .annotation-content { 132 + display: flex; 133 + flex-direction: column; 134 + gap: 10px; 135 + padding-left: 46px; 136 + } 137 + 138 + .annotation-source { 139 + display: inline-flex; 140 + align-items: center; 141 + gap: 6px; 142 + font-size: 0.75rem; 143 + color: var(--text-tertiary); 144 + text-decoration: none; 145 + transition: color 0.15s ease; 146 + max-width: 100%; 147 + overflow: hidden; 148 + text-overflow: ellipsis; 149 + white-space: nowrap; 150 + } 151 + 152 + .annotation-source:hover { 153 + color: var(--text-secondary); 154 + text-decoration: underline; 155 + } 156 + 157 + .annotation-source-title { 158 + color: var(--text-tertiary); 159 + opacity: 0.7; 160 + } 161 + 162 + .annotation-highlight { 163 + display: block; 164 + position: relative; 165 + padding-left: 12px; 166 + margin: 4px 0; 167 + text-decoration: none; 168 + border-left: 2px solid var(--border); 169 + transition: all 0.15s ease; 170 + } 171 + 172 + .annotation-highlight:hover { 173 + border-left-color: var(--text-secondary); 174 + } 175 + 176 + .annotation-highlight mark { 177 + background: transparent; 178 + color: var(--text-primary); 179 + font-style: italic; 180 + font-size: 1rem; 181 + line-height: 1.6; 182 + font-weight: 400; 183 + font-family: var(--font-serif, var(--font-sans)); 184 + display: inline; 185 + } 186 + 187 + .annotation-text { 188 + font-size: 0.95rem; 189 + line-height: 1.6; 190 + color: var(--text-primary); 191 + white-space: pre-wrap; 192 + } 193 + 194 + .annotation-tags { 195 + display: flex; 196 + flex-wrap: wrap; 197 + gap: 6px; 198 + margin-top: 4px; 199 + } 200 + 201 + .annotation-tag { 202 + font-size: 0.8rem; 203 + color: var(--accent); 204 + text-decoration: none; 205 + font-weight: 500; 206 + opacity: 0.9; 207 + transition: opacity 0.15s; 208 + } 209 + 210 + .annotation-tag:hover { 211 + opacity: 1; 212 + text-decoration: underline; 213 + } 214 + 215 + .annotation-actions { 216 + display: flex; 217 + align-items: center; 218 + justify-content: space-between; 219 + margin-top: 4px; 220 + padding-left: 46px; 221 + } 222 + 223 + .annotation-actions-left { 224 + display: flex; 225 + align-items: center; 226 + gap: 16px; 227 + } 228 + 229 + .annotation-action { 230 + display: flex; 231 + align-items: center; 232 + gap: 6px; 233 + color: var(--text-tertiary); 234 + font-size: 0.8rem; 235 + font-weight: 500; 236 + padding: 6px; 237 + margin-left: -6px; 238 + border-radius: var(--radius-sm); 239 + transition: all 0.15s ease; 240 + background: transparent; 241 + cursor: pointer; 242 + border: none; 243 + } 244 + 245 + .annotation-action:hover { 246 + color: var(--text-secondary); 247 + background: var(--bg-tertiary); 248 + } 249 + 250 + .annotation-action.liked { 251 + color: #ef4444; 252 + } 253 + 254 + .annotation-action.liked svg { 255 + fill: #ef4444; 256 + } 257 + 258 + .annotation-action.active { 259 + color: var(--accent); 260 + } 261 + 262 + .action-icon-only { 263 + padding: 6px; 264 + } 265 + 266 + .annotation-header-right { 267 + opacity: 0; 268 + transition: opacity 0.15s; 269 + } 270 + 271 + .annotation-card:hover .annotation-header-right { 272 + opacity: 1; 273 + } 274 + 275 + .inline-replies { 276 + margin-top: 12px; 277 + padding-left: 46px; 278 + } 279 + 280 + @media (max-width: 600px) { 281 + .annotation-content, 282 + .annotation-actions, 283 + .inline-replies { 284 + padding-left: 0; 285 + } 286 + 287 + .annotation-header-right { 288 + opacity: 1; 289 + } 290 + } 291 + 292 + .replies-list-threaded { 293 + margin-top: 16px; 294 + display: flex; 295 + flex-direction: column; 296 + } 297 + 298 + .reply-card-threaded { 299 + position: relative; 300 + padding-left: 0; 301 + transition: background 0.15s; 302 + } 303 + 304 + .reply-header { 305 + display: flex; 306 + align-items: center; 307 + gap: 10px; 308 + margin-bottom: 6px; 309 + } 310 + 311 + .reply-avatar { 312 + width: 28px; 313 + height: 28px; 314 + border-radius: 50%; 315 + background: var(--bg-tertiary); 316 + overflow: hidden; 317 + flex-shrink: 0; 318 + display: flex; 319 + align-items: center; 320 + justify-content: center; 321 + } 322 + 323 + .reply-avatar img { 324 + width: 100%; 325 + height: 100%; 326 + object-fit: cover; 327 + } 328 + 329 + .reply-avatar span { 330 + font-size: 0.7rem; 331 + font-weight: 600; 332 + color: var(--text-secondary); 333 + } 334 + 335 + .reply-meta { 336 + display: flex; 337 + align-items: baseline; 338 + gap: 6px; 339 + flex: 1; 340 + min-width: 0; 341 + } 342 + 343 + .reply-author { 344 + font-weight: 600; 345 + font-size: 0.85rem; 346 + color: var(--text-primary); 347 + white-space: nowrap; 348 + overflow: hidden; 349 + text-overflow: ellipsis; 350 + } 351 + 352 + .reply-handle { 353 + font-size: 0.8rem; 354 + color: var(--text-tertiary); 355 + text-decoration: none; 356 + white-space: nowrap; 357 + overflow: hidden; 358 + text-overflow: ellipsis; 359 + } 360 + 361 + .reply-time { 362 + font-size: 0.75rem; 363 + color: var(--text-tertiary); 364 + white-space: nowrap; 365 + } 366 + 367 + .reply-dot { 368 + color: var(--text-tertiary); 369 + font-size: 0.7rem; 370 + } 371 + 372 + .reply-text { 373 + font-size: 0.9rem; 374 + line-height: 1.5; 375 + color: var(--text-primary); 376 + margin: 0; 377 + padding-left: 38px; 378 + } 379 + 380 + .reply-actions { 381 + display: flex; 382 + align-items: center; 383 + gap: 4px; 384 + opacity: 0; 385 + transition: opacity 0.15s; 386 + } 387 + 388 + .reply-card-threaded:hover .reply-actions { 389 + opacity: 1; 390 + } 391 + 392 + .reply-action-btn { 393 + background: none; 394 + border: none; 395 + padding: 4px; 396 + color: var(--text-tertiary); 397 + cursor: pointer; 398 + border-radius: 4px; 399 + display: flex; 400 + align-items: center; 401 + justify-content: center; 402 + } 403 + 404 + .reply-action-btn:hover { 405 + background: var(--bg-tertiary); 406 + color: var(--text-secondary); 407 + } 408 + 409 + .reply-action-delete:hover { 410 + color: #ef4444; 411 + background: rgba(239, 68, 68, 0.1); 412 + } 413 + 414 + .reply-form { 415 + border: 1px solid var(--border); 416 + border-radius: var(--radius-md); 417 + padding: 16px; 418 + background: var(--bg-secondary); 419 + margin-bottom: 24px; 420 + } 421 + 422 + .replying-to-banner { 423 + display: flex; 424 + justify-content: space-between; 425 + align-items: center; 426 + background: var(--bg-tertiary); 427 + padding: 8px 12px; 428 + border-radius: var(--radius-sm); 429 + margin-bottom: 12px; 430 + font-size: 0.85rem; 431 + color: var(--text-secondary); 432 + } 433 + 434 + .cancel-reply { 435 + background: none; 436 + border: none; 437 + color: var(--text-tertiary); 438 + cursor: pointer; 439 + font-size: 1.2rem; 440 + padding: 0 4px; 441 + line-height: 1; 442 + } 443 + 444 + .cancel-reply:hover { 445 + color: var(--text-primary); 446 + } 447 + 448 + .reply-input { 449 + width: 100%; 450 + background: var(--bg-primary); 451 + border: 1px solid var(--border); 452 + border-radius: var(--radius-sm); 453 + padding: 12px; 454 + color: var(--text-primary); 455 + font-family: inherit; 456 + font-size: 0.95rem; 457 + resize: vertical; 458 + min-height: 80px; 459 + transition: border-color 0.15s; 460 + display: block; 461 + box-sizing: border-box; 462 + } 463 + 464 + .reply-input:focus { 465 + outline: none; 466 + border-color: var(--accent); 467 + } 468 + 469 + .reply-form-actions { 470 + display: flex; 471 + justify-content: flex-end; 472 + margin-top: 12px; 473 + }
+142
web/src/css/base.css
··· 1 + :root { 2 + --bg-primary: #09090b; 3 + --bg-secondary: #0f0f12; 4 + --bg-tertiary: #18181b; 5 + --bg-card: #09090b; 6 + --bg-elevated: #18181b; 7 + --text-primary: #e4e4e7; 8 + --text-secondary: #a1a1aa; 9 + --text-tertiary: #71717a; 10 + --border: #27272a; 11 + --border-hover: #3f3f46; 12 + --accent: #6366f1; 13 + --accent-hover: #4f46e5; 14 + --accent-subtle: rgba(99, 102, 241, 0.1); 15 + --accent-text: #818cf8; 16 + --success: #10b981; 17 + --error: #ef4444; 18 + --warning: #f59e0b; 19 + --info: #3b82f6; 20 + --radius-sm: 4px; 21 + --radius-md: 6px; 22 + --radius-lg: 8px; 23 + --radius-full: 9999px; 24 + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); 25 + --shadow-md: 26 + 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); 27 + --shadow-lg: 28 + 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); 29 + --font-sans: 30 + "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; 31 + --font-mono: 32 + "JetBrains Mono", source-code-pro, Menlo, Monaco, Consolas, monospace; 33 + } 34 + 35 + * { 36 + margin: 0; 37 + padding: 0; 38 + box-sizing: border-box; 39 + } 40 + 41 + html { 42 + font-size: 16px; 43 + -webkit-text-size-adjust: 100%; 44 + overflow-x: hidden; 45 + } 46 + 47 + body { 48 + font-family: var(--font-sans); 49 + background: var(--bg-primary); 50 + color: var(--text-primary); 51 + line-height: 1.5; 52 + min-height: 100vh; 53 + -webkit-font-smoothing: antialiased; 54 + -moz-osx-font-smoothing: grayscale; 55 + overflow-x: hidden; 56 + max-width: 100vw; 57 + } 58 + 59 + a { 60 + color: inherit; 61 + text-decoration: none; 62 + transition: color 0.15s ease; 63 + } 64 + 65 + h1, 66 + h2, 67 + h3, 68 + h4, 69 + h5, 70 + h6 { 71 + font-weight: 600; 72 + line-height: 1.25; 73 + letter-spacing: -0.025em; 74 + color: var(--text-primary); 75 + } 76 + 77 + p { 78 + color: var(--text-secondary); 79 + } 80 + 81 + button { 82 + font-family: inherit; 83 + cursor: pointer; 84 + border: none; 85 + background: none; 86 + } 87 + 88 + input, 89 + textarea, 90 + select { 91 + font-family: inherit; 92 + font-size: inherit; 93 + color: var(--text-primary); 94 + } 95 + 96 + ::selection { 97 + background: var(--accent-subtle); 98 + color: var(--accent-text); 99 + } 100 + 101 + .text-sm { 102 + font-size: 0.875rem; 103 + } 104 + 105 + .text-xs { 106 + font-size: 0.75rem; 107 + } 108 + 109 + .font-medium { 110 + font-weight: 500; 111 + } 112 + 113 + .font-semibold { 114 + font-weight: 600; 115 + } 116 + 117 + .text-muted { 118 + color: var(--text-secondary); 119 + } 120 + 121 + .text-faint { 122 + color: var(--text-tertiary); 123 + } 124 + 125 + ::-webkit-scrollbar { 126 + width: 10px; 127 + height: 10px; 128 + } 129 + 130 + ::-webkit-scrollbar-track { 131 + background: transparent; 132 + } 133 + 134 + ::-webkit-scrollbar-thumb { 135 + background: var(--border); 136 + border-radius: 5px; 137 + border: 2px solid var(--bg-primary); 138 + } 139 + 140 + ::-webkit-scrollbar-thumb:hover { 141 + background: var(--border-hover); 142 + }
+127
web/src/css/buttons.css
··· 1 + .btn { 2 + display: inline-flex; 3 + align-items: center; 4 + justify-content: center; 5 + gap: 8px; 6 + padding: 10px 20px; 7 + font-size: 0.9rem; 8 + font-weight: 500; 9 + border-radius: var(--radius-md); 10 + transition: all 0.15s ease; 11 + } 12 + 13 + .btn-primary { 14 + background: var(--accent); 15 + color: white; 16 + } 17 + 18 + .btn-primary:hover { 19 + background: var(--accent-hover); 20 + transform: translateY(-1px); 21 + box-shadow: var(--shadow-md); 22 + } 23 + 24 + .btn-secondary { 25 + background: var(--bg-tertiary); 26 + color: var(--text-primary); 27 + border: 1px solid var(--border); 28 + } 29 + 30 + .btn-secondary:hover { 31 + background: var(--bg-hover); 32 + border-color: var(--border-hover); 33 + } 34 + 35 + .btn-ghost { 36 + color: var(--text-secondary); 37 + padding: 8px 12px; 38 + } 39 + 40 + .btn-ghost:hover { 41 + color: var(--text-primary); 42 + background: var(--bg-tertiary); 43 + } 44 + 45 + .btn-bluesky { 46 + background: #0085ff; 47 + color: white; 48 + display: flex; 49 + align-items: center; 50 + justify-content: center; 51 + gap: 10px; 52 + transition: 53 + background 0.2s, 54 + transform 0.2s; 55 + } 56 + 57 + .btn-bluesky:hover { 58 + background: #0070dd; 59 + transform: translateY(-1px); 60 + } 61 + 62 + .btn-sm { 63 + padding: 6px 12px; 64 + font-size: 0.85rem; 65 + } 66 + 67 + .btn-text { 68 + background: none; 69 + border: none; 70 + color: var(--text-secondary); 71 + font-size: 0.9rem; 72 + padding: 8px 12px; 73 + cursor: pointer; 74 + transition: color 0.15s; 75 + } 76 + 77 + .btn-text:hover { 78 + color: var(--text-primary); 79 + } 80 + 81 + .btn-block { 82 + width: 100%; 83 + text-align: left; 84 + padding: 8px 12px; 85 + color: var(--text-secondary); 86 + background: var(--bg-tertiary); 87 + border-radius: var(--radius-md); 88 + margin-top: 8px; 89 + font-size: 0.9rem; 90 + cursor: pointer; 91 + transition: all 0.2s; 92 + } 93 + 94 + .btn-block:hover { 95 + background: var(--border); 96 + color: var(--text-primary); 97 + } 98 + 99 + .btn-icon-danger { 100 + padding: 8px; 101 + background: var(--error); 102 + color: white; 103 + border: none; 104 + border-radius: var(--radius-md); 105 + cursor: pointer; 106 + box-shadow: var(--shadow-md); 107 + transition: all 0.15s ease; 108 + display: flex; 109 + align-items: center; 110 + justify-content: center; 111 + } 112 + 113 + .btn-icon-danger:hover { 114 + background: #dc2626; 115 + transform: scale(1.05); 116 + } 117 + 118 + .action-buttons { 119 + display: flex; 120 + gap: 8px; 121 + } 122 + 123 + .action-buttons-end { 124 + display: flex; 125 + justify-content: flex-end; 126 + gap: 8px; 127 + }
+310
web/src/css/collections.css
··· 1 + .collections-list { 2 + display: flex; 3 + flex-direction: column; 4 + gap: 2px; 5 + background: var(--bg-card); 6 + border: 1px solid var(--border); 7 + border-radius: var(--radius-lg); 8 + overflow: hidden; 9 + } 10 + 11 + .collection-row { 12 + display: flex; 13 + align-items: center; 14 + background: var(--bg-card); 15 + transition: background 0.15s ease; 16 + } 17 + 18 + .collection-row:not(:last-child) { 19 + border-bottom: 1px solid var(--border); 20 + } 21 + 22 + .collection-row:hover { 23 + background: var(--bg-secondary); 24 + } 25 + 26 + .collection-row-content { 27 + flex: 1; 28 + display: flex; 29 + align-items: center; 30 + gap: 16px; 31 + padding: 16px 20px; 32 + text-decoration: none; 33 + min-width: 0; 34 + } 35 + 36 + .collection-row-icon { 37 + width: 44px; 38 + height: 44px; 39 + min-width: 44px; 40 + display: flex; 41 + align-items: center; 42 + justify-content: center; 43 + background: linear-gradient( 44 + 135deg, 45 + rgba(79, 70, 229, 0.1), 46 + rgba(168, 85, 247, 0.15) 47 + ); 48 + color: var(--accent); 49 + border-radius: var(--radius-md); 50 + transition: all 0.2s ease; 51 + } 52 + 53 + .collection-row:hover .collection-row-icon { 54 + background: linear-gradient( 55 + 135deg, 56 + rgba(79, 70, 229, 0.15), 57 + rgba(168, 85, 247, 0.2) 58 + ); 59 + transform: scale(1.05); 60 + } 61 + 62 + .collection-row-info { 63 + flex: 1; 64 + min-width: 0; 65 + } 66 + 67 + .collection-row-name { 68 + font-size: 1rem; 69 + font-weight: 600; 70 + color: var(--text-primary); 71 + margin: 0 0 2px 0; 72 + white-space: nowrap; 73 + overflow: hidden; 74 + text-overflow: ellipsis; 75 + } 76 + 77 + .collection-row:hover .collection-row-name { 78 + color: var(--accent); 79 + } 80 + 81 + .collection-row-desc { 82 + font-size: 0.85rem; 83 + color: var(--text-secondary); 84 + margin: 0; 85 + white-space: nowrap; 86 + overflow: hidden; 87 + text-overflow: ellipsis; 88 + } 89 + 90 + .collection-row-arrow { 91 + color: var(--text-tertiary); 92 + opacity: 0; 93 + transition: all 0.2s ease; 94 + } 95 + 96 + .collection-row:hover .collection-row-arrow { 97 + opacity: 1; 98 + color: var(--accent); 99 + transform: translateX(2px); 100 + } 101 + 102 + .collection-row-edit { 103 + padding: 10px; 104 + margin-right: 12px; 105 + color: var(--text-tertiary); 106 + background: none; 107 + border: none; 108 + border-radius: var(--radius-sm); 109 + cursor: pointer; 110 + opacity: 0; 111 + transition: all 0.15s ease; 112 + } 113 + 114 + .collection-row:hover .collection-row-edit { 115 + opacity: 1; 116 + } 117 + 118 + .collection-row-edit:hover { 119 + color: var(--text-primary); 120 + background: var(--bg-tertiary); 121 + } 122 + 123 + .collection-detail-header { 124 + display: flex; 125 + gap: 20px; 126 + padding: 24px; 127 + background: var(--bg-card); 128 + border: 1px solid var(--border); 129 + border-radius: var(--radius-lg); 130 + margin-bottom: 32px; 131 + position: relative; 132 + } 133 + 134 + .collection-detail-icon { 135 + width: 56px; 136 + height: 56px; 137 + min-width: 56px; 138 + display: flex; 139 + align-items: center; 140 + justify-content: center; 141 + background: linear-gradient( 142 + 135deg, 143 + rgba(79, 70, 229, 0.1), 144 + rgba(168, 85, 247, 0.1) 145 + ); 146 + color: var(--accent); 147 + border-radius: var(--radius-md); 148 + } 149 + 150 + .collection-detail-info { 151 + flex: 1; 152 + min-width: 0; 153 + } 154 + 155 + .collection-detail-visibility { 156 + display: flex; 157 + align-items: center; 158 + gap: 6px; 159 + font-size: 0.8rem; 160 + font-weight: 600; 161 + color: var(--accent); 162 + text-transform: capitalize; 163 + margin-bottom: 8px; 164 + } 165 + 166 + .collection-detail-title { 167 + font-size: 1.5rem; 168 + font-weight: 700; 169 + color: var(--text-primary); 170 + margin-bottom: 8px; 171 + line-height: 1.3; 172 + } 173 + 174 + .collection-detail-desc { 175 + color: var(--text-secondary); 176 + font-size: 1rem; 177 + line-height: 1.5; 178 + margin-bottom: 12px; 179 + max-width: 600px; 180 + } 181 + 182 + .collection-detail-stats { 183 + display: flex; 184 + align-items: center; 185 + gap: 8px; 186 + font-size: 0.85rem; 187 + color: var(--text-tertiary); 188 + } 189 + 190 + .collection-detail-actions { 191 + position: absolute; 192 + top: 20px; 193 + right: 20px; 194 + display: flex; 195 + align-items: center; 196 + gap: 8px; 197 + } 198 + 199 + .collection-detail-actions .share-menu-container { 200 + display: flex; 201 + align-items: center; 202 + } 203 + 204 + .collection-detail-actions .annotation-action { 205 + padding: 10px; 206 + color: var(--text-tertiary); 207 + background: none; 208 + border: none; 209 + border-radius: var(--radius-sm); 210 + cursor: pointer; 211 + transition: all 0.15s ease; 212 + } 213 + 214 + .collection-detail-actions .annotation-action:hover { 215 + color: var(--accent); 216 + background: var(--bg-tertiary); 217 + } 218 + 219 + .collection-detail-edit, 220 + .collection-detail-delete { 221 + padding: 10px; 222 + color: var(--text-tertiary); 223 + background: none; 224 + border: none; 225 + border-radius: var(--radius-sm); 226 + cursor: pointer; 227 + transition: all 0.15s ease; 228 + } 229 + 230 + .collection-detail-edit:hover { 231 + color: var(--accent); 232 + background: var(--bg-tertiary); 233 + } 234 + 235 + .collection-detail-delete:hover { 236 + color: var(--error); 237 + background: rgba(239, 68, 68, 0.1); 238 + } 239 + 240 + .collection-item-wrapper { 241 + position: relative; 242 + } 243 + 244 + .collection-item-remove { 245 + position: absolute; 246 + top: 12px; 247 + left: -40px; 248 + z-index: 10; 249 + padding: 8px; 250 + background: var(--bg-card); 251 + border: 1px solid var(--border); 252 + border-radius: var(--radius-sm); 253 + color: var(--text-tertiary); 254 + cursor: pointer; 255 + opacity: 0; 256 + transition: all 0.15s ease; 257 + } 258 + 259 + .collection-item-wrapper:hover .collection-item-remove { 260 + opacity: 1; 261 + } 262 + 263 + .collection-item-remove:hover { 264 + color: var(--error); 265 + border-color: var(--error); 266 + background: rgba(239, 68, 68, 0.05); 267 + } 268 + 269 + .collection-list-item { 270 + width: 100%; 271 + text-align: left; 272 + padding: 12px 16px; 273 + border-radius: var(--radius-md); 274 + background: var(--bg-primary); 275 + border: 1px solid transparent; 276 + color: var(--text-primary); 277 + transition: all 0.15s ease; 278 + display: flex; 279 + align-items: center; 280 + justify-content: space-between; 281 + cursor: pointer; 282 + } 283 + 284 + .collection-list-item:hover { 285 + background: var(--bg-hover); 286 + border-color: var(--border); 287 + } 288 + 289 + .collection-list-item:hover .collection-list-item-icon { 290 + opacity: 1; 291 + } 292 + 293 + .collection-list-item:disabled { 294 + opacity: 0.6; 295 + cursor: not-allowed; 296 + } 297 + 298 + .item-delete-overlay { 299 + position: absolute; 300 + top: 16px; 301 + right: 16px; 302 + z-index: 10; 303 + opacity: 0; 304 + transition: opacity 0.15s ease; 305 + } 306 + 307 + .card:hover .item-delete-overlay, 308 + div:hover > .item-delete-overlay { 309 + opacity: 1; 310 + }
+139
web/src/css/feed.css
··· 1 + .feed { 2 + display: flex; 3 + flex-direction: column; 4 + gap: 16px; 5 + } 6 + 7 + .feed-header { 8 + display: flex; 9 + align-items: center; 10 + justify-content: space-between; 11 + margin-bottom: 8px; 12 + } 13 + 14 + .feed-title { 15 + font-size: 1.5rem; 16 + font-weight: 700; 17 + } 18 + 19 + .feed-filters { 20 + display: flex; 21 + gap: 8px; 22 + margin-bottom: 24px; 23 + padding: 4px; 24 + background: var(--bg-tertiary); 25 + border-radius: var(--radius-lg); 26 + width: fit-content; 27 + } 28 + 29 + .filter-tab { 30 + padding: 8px 16px; 31 + font-size: 0.9rem; 32 + font-weight: 500; 33 + color: var(--text-secondary); 34 + background: transparent; 35 + border: none; 36 + border-radius: var(--radius-md); 37 + cursor: pointer; 38 + transition: all 0.15s ease; 39 + } 40 + 41 + .filter-tab:hover { 42 + color: var(--text-primary); 43 + background: var(--bg-hover); 44 + } 45 + 46 + .filter-tab.active { 47 + color: var(--text-primary); 48 + background: var(--bg-card); 49 + box-shadow: var(--shadow-sm); 50 + } 51 + 52 + .page-header { 53 + margin-bottom: 32px; 54 + } 55 + 56 + .page-title { 57 + font-size: 2rem; 58 + font-weight: 700; 59 + margin-bottom: 8px; 60 + } 61 + 62 + .page-description { 63 + color: var(--text-secondary); 64 + font-size: 1.1rem; 65 + } 66 + 67 + .url-input-wrapper { 68 + margin-bottom: 24px; 69 + } 70 + 71 + .url-input-container { 72 + display: flex; 73 + gap: 12px; 74 + } 75 + 76 + .url-input { 77 + width: 100%; 78 + padding: 16px; 79 + background: var(--bg-secondary); 80 + border: 1px solid var(--border); 81 + border-radius: var(--radius-md); 82 + color: var(--text-primary); 83 + font-size: 1.1rem; 84 + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); 85 + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); 86 + } 87 + 88 + .url-input:focus { 89 + outline: none; 90 + border-color: var(--accent); 91 + box-shadow: 0 0 0 4px var(--accent-subtle); 92 + background: var(--bg-primary); 93 + } 94 + 95 + .url-input::placeholder { 96 + color: var(--text-tertiary); 97 + } 98 + 99 + .url-results-header { 100 + display: flex; 101 + align-items: center; 102 + justify-content: space-between; 103 + margin-bottom: 16px; 104 + flex-wrap: wrap; 105 + gap: 12px; 106 + } 107 + 108 + .back-link { 109 + display: inline-flex; 110 + align-items: center; 111 + gap: 8px; 112 + color: var(--text-secondary); 113 + font-size: 0.9rem; 114 + text-decoration: none; 115 + margin-bottom: 24px; 116 + transition: color 0.15s; 117 + } 118 + 119 + .back-link:hover { 120 + color: var(--accent); 121 + } 122 + 123 + .new-page { 124 + max-width: 600px; 125 + margin: 0 auto; 126 + display: flex; 127 + flex-direction: column; 128 + gap: 32px; 129 + } 130 + 131 + @media (max-width: 640px) { 132 + .main-content { 133 + padding: 16px 12px; 134 + } 135 + 136 + .page-title { 137 + font-size: 1.5rem; 138 + } 139 + }
+465
web/src/css/layout.css
··· 1 + .layout { 2 + display: flex; 3 + min-height: 100vh; 4 + background: var(--bg-primary); 5 + } 6 + 7 + .sidebar { 8 + position: fixed; 9 + left: 0; 10 + top: 0; 11 + bottom: 0; 12 + width: 240px; 13 + background: var(--bg-primary); 14 + border-right: 1px solid var(--border); 15 + display: flex; 16 + flex-direction: column; 17 + z-index: 50; 18 + padding-bottom: 20px; 19 + } 20 + 21 + .sidebar-header { 22 + height: 64px; 23 + display: flex; 24 + align-items: center; 25 + padding: 0 20px; 26 + margin-bottom: 12px; 27 + text-decoration: none; 28 + color: var(--text-primary); 29 + } 30 + 31 + .sidebar-logo { 32 + width: 24px; 33 + height: 24px; 34 + object-fit: contain; 35 + margin-right: 12px; 36 + } 37 + 38 + .sidebar-brand { 39 + font-size: 1rem; 40 + font-weight: 600; 41 + color: var(--text-primary); 42 + letter-spacing: -0.01em; 43 + } 44 + 45 + .sidebar-nav { 46 + flex: 1; 47 + display: flex; 48 + flex-direction: column; 49 + gap: 4px; 50 + padding: 0 12px; 51 + overflow-y: auto; 52 + } 53 + 54 + .sidebar-link { 55 + display: flex; 56 + align-items: center; 57 + gap: 12px; 58 + padding: 8px 12px; 59 + border-radius: var(--radius-md); 60 + color: var(--text-secondary); 61 + text-decoration: none; 62 + font-size: 0.9rem; 63 + font-weight: 500; 64 + transition: all 0.15s ease; 65 + } 66 + 67 + .sidebar-link:hover { 68 + background: var(--bg-tertiary); 69 + color: var(--text-primary); 70 + } 71 + 72 + .sidebar-link.active { 73 + background: var(--bg-tertiary); 74 + color: var(--text-primary); 75 + } 76 + 77 + .sidebar-link svg { 78 + width: 18px; 79 + height: 18px; 80 + color: var(--text-tertiary); 81 + transition: color 0.15s ease; 82 + } 83 + 84 + .sidebar-link:hover svg, 85 + .sidebar-link.active svg { 86 + color: var(--text-primary); 87 + } 88 + 89 + .sidebar-section-title { 90 + padding: 24px 12px 8px; 91 + font-size: 0.75rem; 92 + font-weight: 600; 93 + color: var(--text-tertiary); 94 + text-transform: uppercase; 95 + letter-spacing: 0.05em; 96 + } 97 + 98 + .notification-badge { 99 + background: var(--accent); 100 + color: white; 101 + font-size: 0.7rem; 102 + font-weight: 600; 103 + padding: 0 6px; 104 + height: 18px; 105 + border-radius: 99px; 106 + display: flex; 107 + align-items: center; 108 + justify-content: center; 109 + margin-left: auto; 110 + } 111 + 112 + .sidebar-new-btn { 113 + display: flex; 114 + align-items: center; 115 + gap: 10px; 116 + margin: 0 12px 16px; 117 + padding: 10px 16px; 118 + background: var(--text-primary); 119 + color: var(--bg-primary); 120 + border-radius: var(--radius-md); 121 + font-size: 0.9rem; 122 + font-weight: 600; 123 + text-decoration: none; 124 + transition: opacity 0.15s; 125 + justify-content: center; 126 + } 127 + 128 + .sidebar-new-btn:hover { 129 + opacity: 0.9; 130 + } 131 + 132 + .sidebar-footer { 133 + padding: 0 12px; 134 + margin-top: auto; 135 + } 136 + 137 + .sidebar-user { 138 + display: flex; 139 + align-items: center; 140 + gap: 10px; 141 + padding: 8px 12px; 142 + border-radius: var(--radius-md); 143 + cursor: pointer; 144 + transition: background 0.15s ease; 145 + } 146 + 147 + .sidebar-user:hover, 148 + .sidebar-user.active { 149 + background: var(--bg-tertiary); 150 + } 151 + 152 + .sidebar-avatar { 153 + width: 32px; 154 + height: 32px; 155 + border-radius: 50%; 156 + background: var(--bg-tertiary); 157 + display: flex; 158 + align-items: center; 159 + justify-content: center; 160 + color: var(--text-secondary); 161 + font-size: 0.8rem; 162 + font-weight: 500; 163 + overflow: hidden; 164 + flex-shrink: 0; 165 + border: 1px solid var(--border); 166 + } 167 + 168 + .sidebar-avatar img { 169 + width: 100%; 170 + height: 100%; 171 + object-fit: cover; 172 + } 173 + 174 + .sidebar-user-info { 175 + flex: 1; 176 + min-width: 0; 177 + display: flex; 178 + flex-direction: column; 179 + } 180 + 181 + .sidebar-user-name { 182 + font-size: 0.85rem; 183 + font-weight: 500; 184 + color: var(--text-primary); 185 + } 186 + 187 + .sidebar-user-handle { 188 + font-size: 0.75rem; 189 + color: var(--text-tertiary); 190 + } 191 + 192 + .sidebar-dropdown { 193 + position: absolute; 194 + bottom: 74px; 195 + left: 12px; 196 + width: 216px; 197 + background: var(--bg-card); 198 + border: 1px solid var(--border); 199 + border-radius: var(--radius-md); 200 + box-shadow: var(--shadow-lg); 201 + padding: 4px; 202 + z-index: 1000; 203 + overflow: hidden; 204 + animation: scaleIn 0.1s ease-out; 205 + transform-origin: bottom center; 206 + } 207 + 208 + @keyframes scaleIn { 209 + from { 210 + opacity: 0; 211 + transform: scale(0.95); 212 + } 213 + 214 + to { 215 + opacity: 1; 216 + transform: scale(1); 217 + } 218 + } 219 + 220 + .sidebar-dropdown-item { 221 + display: flex; 222 + align-items: center; 223 + gap: 10px; 224 + width: 100%; 225 + padding: 8px 12px; 226 + font-size: 0.85rem; 227 + color: var(--text-secondary); 228 + text-decoration: none; 229 + background: transparent; 230 + cursor: pointer; 231 + border-radius: var(--radius-sm); 232 + transition: all 0.15s; 233 + border: none; 234 + } 235 + 236 + .sidebar-dropdown-item:hover { 237 + background: var(--bg-tertiary); 238 + color: var(--text-primary); 239 + } 240 + 241 + .sidebar-dropdown-item.danger:hover { 242 + background: rgba(239, 68, 68, 0.1); 243 + color: var(--error); 244 + } 245 + 246 + .main-layout { 247 + flex: 1; 248 + margin-left: 240px; 249 + margin-right: 280px; 250 + min-height: 100vh; 251 + } 252 + 253 + .main-content-wrapper { 254 + max-width: 640px; 255 + margin: 0 auto; 256 + padding: 40px 24px; 257 + } 258 + 259 + .right-sidebar { 260 + position: fixed; 261 + right: 0; 262 + top: 0; 263 + bottom: 0; 264 + width: 280px; 265 + background: var(--bg-primary); 266 + border-left: 1px solid var(--border); 267 + padding: 32px 24px; 268 + overflow-y: auto; 269 + display: flex; 270 + flex-direction: column; 271 + gap: 32px; 272 + } 273 + 274 + .right-section { 275 + display: flex; 276 + flex-direction: column; 277 + gap: 12px; 278 + } 279 + 280 + .right-section-title { 281 + font-size: 0.75rem; 282 + font-weight: 600; 283 + color: var(--text-primary); 284 + margin-bottom: 4px; 285 + } 286 + 287 + .right-section-desc { 288 + font-size: 0.85rem; 289 + line-height: 1.5; 290 + color: var(--text-secondary); 291 + } 292 + 293 + .right-extension-btn { 294 + display: inline-flex; 295 + align-items: center; 296 + gap: 8px; 297 + padding: 8px 12px; 298 + background: var(--bg-primary); 299 + border: 1px solid var(--border); 300 + border-radius: var(--radius-md); 301 + color: var(--text-primary); 302 + font-size: 0.85rem; 303 + font-weight: 500; 304 + text-decoration: none; 305 + transition: all 0.15s ease; 306 + width: fit-content; 307 + } 308 + 309 + .right-extension-btn:hover { 310 + border-color: var(--text-tertiary); 311 + background: var(--bg-tertiary); 312 + } 313 + 314 + .right-links { 315 + display: flex; 316 + flex-direction: column; 317 + gap: 4px; 318 + } 319 + 320 + .right-link { 321 + display: flex; 322 + align-items: center; 323 + justify-content: space-between; 324 + padding: 6px 0; 325 + color: var(--text-secondary); 326 + font-size: 0.9rem; 327 + transition: color 0.15s; 328 + text-decoration: none; 329 + } 330 + 331 + .right-link:hover { 332 + color: var(--text-primary); 333 + } 334 + 335 + .right-link svg { 336 + width: 16px; 337 + height: 16px; 338 + color: var(--text-tertiary); 339 + transition: all 0.15s; 340 + } 341 + 342 + .right-link:hover svg { 343 + color: var(--text-secondary); 344 + } 345 + 346 + .tangled-icon { 347 + width: 16px; 348 + height: 16px; 349 + background-color: var(--text-tertiary); 350 + -webkit-mask: url("../assets/tangled.svg") no-repeat center / contain; 351 + mask: url("../assets/tangled.svg") no-repeat center / contain; 352 + transition: background-color 0.15s; 353 + } 354 + 355 + .right-link:hover .tangled-icon { 356 + background-color: var(--text-secondary); 357 + } 358 + 359 + .right-footer { 360 + margin-top: auto; 361 + display: flex; 362 + flex-wrap: wrap; 363 + gap: 12px; 364 + font-size: 0.75rem; 365 + color: var(--text-tertiary); 366 + } 367 + 368 + .right-footer a { 369 + color: var(--text-tertiary); 370 + } 371 + 372 + .right-footer a:hover { 373 + color: var(--text-secondary); 374 + } 375 + 376 + .mobile-nav { 377 + display: none; 378 + position: fixed; 379 + bottom: 0; 380 + left: 0; 381 + right: 0; 382 + background: rgba(9, 9, 11, 0.9); 383 + backdrop-filter: blur(12px); 384 + -webkit-backdrop-filter: blur(12px); 385 + border-top: 1px solid var(--border); 386 + padding: 8px 16px; 387 + padding-bottom: calc(8px + env(safe-area-inset-bottom, 0)); 388 + z-index: 100; 389 + } 390 + 391 + .mobile-nav-inner { 392 + display: flex; 393 + justify-content: space-between; 394 + align-items: center; 395 + } 396 + 397 + .mobile-nav-item { 398 + display: flex; 399 + flex-direction: column; 400 + align-items: center; 401 + justify-content: center; 402 + gap: 4px; 403 + color: var(--text-tertiary); 404 + text-decoration: none; 405 + font-size: 0.65rem; 406 + font-weight: 500; 407 + width: 60px; 408 + transition: color 0.15s; 409 + } 410 + 411 + .mobile-nav-item.active { 412 + color: var(--text-primary); 413 + } 414 + 415 + .mobile-nav-item svg { 416 + width: 24px; 417 + height: 24px; 418 + } 419 + 420 + .mobile-nav-new { 421 + width: 48px; 422 + height: 36px; 423 + border-radius: var(--radius-md); 424 + background: var(--text-primary); 425 + color: var(--bg-primary); 426 + display: flex; 427 + align-items: center; 428 + justify-content: center; 429 + } 430 + 431 + .mobile-nav-new svg { 432 + width: 20px; 433 + height: 20px; 434 + } 435 + 436 + @media (max-width: 1200px) { 437 + .right-sidebar { 438 + display: none; 439 + } 440 + 441 + .main-layout { 442 + margin-right: 0; 443 + } 444 + } 445 + 446 + @media (max-width: 768px) { 447 + .sidebar { 448 + display: none; 449 + } 450 + 451 + .main-layout { 452 + margin-left: 0; 453 + padding-bottom: 80px; 454 + } 455 + 456 + .main-content-wrapper { 457 + padding: 20px 16px; 458 + max-width: 100%; 459 + overflow-x: hidden; 460 + } 461 + 462 + .mobile-nav { 463 + display: block; 464 + } 465 + }
+297
web/src/css/login.css
··· 1 + .login-page { 2 + display: flex; 3 + flex-direction: column; 4 + align-items: center; 5 + justify-content: center; 6 + min-height: 70vh; 7 + padding: 60px 20px; 8 + width: 100%; 9 + max-width: 500px; 10 + margin: 0 auto; 11 + } 12 + 13 + .login-at-logo { 14 + font-size: 5rem; 15 + font-weight: 800; 16 + color: var(--accent); 17 + margin-bottom: 24px; 18 + line-height: 1; 19 + } 20 + 21 + .login-logo-img { 22 + width: 80px; 23 + height: 80px; 24 + margin-bottom: 24px; 25 + object-fit: contain; 26 + } 27 + 28 + .login-heading { 29 + font-size: 1.5rem; 30 + font-weight: 600; 31 + margin-bottom: 32px; 32 + display: flex; 33 + align-items: center; 34 + gap: 10px; 35 + text-align: center; 36 + line-height: 1.4; 37 + } 38 + 39 + .login-help-btn { 40 + background: none; 41 + border: none; 42 + color: var(--text-tertiary); 43 + cursor: pointer; 44 + padding: 4px; 45 + display: flex; 46 + align-items: center; 47 + transition: color 0.15s; 48 + flex-shrink: 0; 49 + } 50 + 51 + .login-help-btn:hover { 52 + color: var(--accent); 53 + } 54 + 55 + .login-help-text { 56 + background: var(--bg-elevated); 57 + border: 1px solid var(--border); 58 + border-radius: var(--radius-md); 59 + padding: 16px 20px; 60 + margin-bottom: 24px; 61 + font-size: 0.95rem; 62 + color: var(--text-secondary); 63 + line-height: 1.6; 64 + text-align: center; 65 + } 66 + 67 + .login-help-text code { 68 + background: var(--bg-tertiary); 69 + padding: 2px 8px; 70 + border-radius: var(--radius-sm); 71 + font-size: 0.9rem; 72 + } 73 + 74 + .login-form { 75 + display: flex; 76 + flex-direction: column; 77 + gap: 16px; 78 + width: 100%; 79 + } 80 + 81 + .login-input-wrapper { 82 + position: relative; 83 + } 84 + 85 + .login-input { 86 + width: 100%; 87 + padding: 14px 16px; 88 + background: var(--bg-elevated); 89 + border: 1px solid var(--border); 90 + border-radius: var(--radius-md); 91 + color: var(--text-primary); 92 + font-size: 1rem; 93 + transition: 94 + border-color 0.15s, 95 + box-shadow 0.15s; 96 + } 97 + 98 + .login-input:focus { 99 + outline: none; 100 + border-color: var(--accent); 101 + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15); 102 + } 103 + 104 + .login-input::placeholder { 105 + color: var(--text-tertiary); 106 + } 107 + 108 + .login-suggestions { 109 + position: absolute; 110 + top: calc(100% + 4px); 111 + left: 0; 112 + right: 0; 113 + background: var(--bg-card); 114 + border: 1px solid var(--border); 115 + border-radius: var(--radius-md); 116 + box-shadow: var(--shadow-lg); 117 + overflow: hidden; 118 + z-index: 100; 119 + } 120 + 121 + .login-suggestion { 122 + display: flex; 123 + align-items: center; 124 + gap: 12px; 125 + width: 100%; 126 + padding: 12px 16px; 127 + background: transparent; 128 + border: none; 129 + cursor: pointer; 130 + text-align: left; 131 + transition: background 0.1s; 132 + } 133 + 134 + .login-suggestion:hover, 135 + .login-suggestion.selected { 136 + background: var(--bg-elevated); 137 + } 138 + 139 + .login-suggestion-avatar { 140 + width: 40px; 141 + height: 40px; 142 + border-radius: var(--radius-full); 143 + background: linear-gradient(135deg, var(--accent), #a855f7); 144 + display: flex; 145 + align-items: center; 146 + justify-content: center; 147 + flex-shrink: 0; 148 + overflow: hidden; 149 + font-size: 0.875rem; 150 + font-weight: 600; 151 + color: white; 152 + } 153 + 154 + .login-suggestion-avatar img { 155 + width: 100%; 156 + height: 100%; 157 + object-fit: cover; 158 + } 159 + 160 + .login-suggestion-info { 161 + display: flex; 162 + flex-direction: column; 163 + min-width: 0; 164 + } 165 + 166 + .login-suggestion-name { 167 + font-weight: 600; 168 + color: var(--text-primary); 169 + white-space: nowrap; 170 + overflow: hidden; 171 + text-overflow: ellipsis; 172 + } 173 + 174 + .login-suggestion-handle { 175 + font-size: 0.875rem; 176 + color: var(--text-secondary); 177 + white-space: nowrap; 178 + overflow: hidden; 179 + text-overflow: ellipsis; 180 + } 181 + 182 + .login-error { 183 + padding: 12px 16px; 184 + background: rgba(239, 68, 68, 0.1); 185 + border: 1px solid rgba(239, 68, 68, 0.3); 186 + border-radius: var(--radius-md); 187 + color: #ef4444; 188 + font-size: 0.875rem; 189 + } 190 + 191 + .login-legal { 192 + font-size: 0.75rem; 193 + color: var(--text-tertiary); 194 + line-height: 1.5; 195 + margin-top: 16px; 196 + } 197 + 198 + .login-brand { 199 + display: flex; 200 + align-items: center; 201 + justify-content: center; 202 + gap: 12px; 203 + margin-bottom: 24px; 204 + } 205 + 206 + .login-brand-icon { 207 + width: 48px; 208 + height: 48px; 209 + background: linear-gradient(135deg, var(--accent), #a855f7); 210 + border-radius: var(--radius-lg); 211 + display: flex; 212 + align-items: center; 213 + justify-content: center; 214 + font-size: 1.75rem; 215 + font-weight: 800; 216 + color: white; 217 + } 218 + 219 + .login-brand-name { 220 + font-size: 1.75rem; 221 + font-weight: 700; 222 + } 223 + 224 + .login-avatar { 225 + width: 72px; 226 + height: 72px; 227 + border-radius: var(--radius-full); 228 + background: linear-gradient(135deg, var(--accent), #a855f7); 229 + display: flex; 230 + align-items: center; 231 + justify-content: center; 232 + margin: 0 auto 16px; 233 + font-weight: 700; 234 + font-size: 1.5rem; 235 + color: white; 236 + overflow: hidden; 237 + } 238 + 239 + .login-avatar img { 240 + width: 100%; 241 + height: 100%; 242 + object-fit: cover; 243 + } 244 + 245 + .login-avatar-large { 246 + width: 100px; 247 + height: 100px; 248 + border-radius: var(--radius-full); 249 + background: linear-gradient(135deg, var(--accent), #a855f7); 250 + display: flex; 251 + align-items: center; 252 + justify-content: center; 253 + margin-bottom: 20px; 254 + font-weight: 700; 255 + font-size: 2rem; 256 + color: white; 257 + overflow: hidden; 258 + } 259 + 260 + .login-avatar-large img { 261 + width: 100%; 262 + height: 100%; 263 + object-fit: cover; 264 + } 265 + 266 + .login-welcome { 267 + font-size: 1.5rem; 268 + font-weight: 600; 269 + margin-bottom: 32px; 270 + text-align: center; 271 + } 272 + 273 + .login-welcome-name { 274 + font-size: 1.25rem; 275 + font-weight: 600; 276 + margin-bottom: 24px; 277 + } 278 + 279 + .login-actions { 280 + display: flex; 281 + flex-direction: column; 282 + gap: 12px; 283 + width: 100%; 284 + } 285 + 286 + .login-btn { 287 + width: 100%; 288 + padding: 14px 24px; 289 + font-size: 1rem; 290 + font-weight: 600; 291 + } 292 + 293 + .login-submit { 294 + padding: 18px 32px; 295 + font-size: 1.1rem; 296 + font-weight: 600; 297 + }
+262
web/src/css/modals.css
··· 1 + .modal-overlay { 2 + position: fixed; 3 + inset: 0; 4 + background: rgba(0, 0, 0, 0.5); 5 + display: flex; 6 + align-items: center; 7 + justify-content: center; 8 + padding: 16px; 9 + z-index: 50; 10 + animation: fadeIn 0.2s ease-out; 11 + } 12 + 13 + .modal-container { 14 + background: var(--bg-secondary); 15 + border-radius: var(--radius-lg); 16 + width: 100%; 17 + max-width: 28rem; 18 + border: 1px solid var(--border); 19 + box-shadow: var(--shadow-lg); 20 + animation: zoomIn 0.2s ease-out; 21 + } 22 + 23 + .modal-header { 24 + display: flex; 25 + align-items: center; 26 + justify-content: space-between; 27 + padding: 16px; 28 + border-bottom: 1px solid var(--border); 29 + } 30 + 31 + .modal-title { 32 + font-size: 1.25rem; 33 + font-weight: 700; 34 + color: var(--text-primary); 35 + } 36 + 37 + .modal-close-btn { 38 + padding: 8px; 39 + color: var(--text-tertiary); 40 + border-radius: var(--radius-md); 41 + transition: color 0.15s; 42 + } 43 + 44 + .modal-close-btn:hover { 45 + color: var(--text-primary); 46 + background: var(--bg-hover); 47 + } 48 + 49 + .modal-form { 50 + padding: 16px; 51 + display: flex; 52 + flex-direction: column; 53 + gap: 16px; 54 + } 55 + 56 + .icon-picker-tabs { 57 + display: flex; 58 + gap: 4px; 59 + margin-bottom: 12px; 60 + } 61 + 62 + .icon-picker-tab { 63 + flex: 1; 64 + padding: 8px 12px; 65 + background: var(--bg-primary); 66 + border: 1px solid var(--border); 67 + border-radius: var(--radius-md); 68 + color: var(--text-secondary); 69 + font-size: 0.85rem; 70 + font-weight: 500; 71 + cursor: pointer; 72 + transition: all 0.15s ease; 73 + } 74 + 75 + .icon-picker-tab:hover { 76 + background: var(--bg-tertiary); 77 + } 78 + 79 + .icon-picker-tab.active { 80 + background: var(--accent); 81 + border-color: var(--accent); 82 + color: white; 83 + } 84 + 85 + .emoji-picker-wrapper { 86 + display: flex; 87 + flex-direction: column; 88 + gap: 10px; 89 + } 90 + 91 + .emoji-custom-input input { 92 + width: 100%; 93 + } 94 + 95 + .emoji-picker, 96 + .icon-picker { 97 + display: flex; 98 + flex-wrap: wrap; 99 + gap: 4px; 100 + max-height: 120px; 101 + overflow-y: auto; 102 + padding: 8px; 103 + background: var(--bg-primary); 104 + border: 1px solid var(--border); 105 + border-radius: var(--radius-md); 106 + } 107 + 108 + .emoji-option, 109 + .icon-option { 110 + width: 36px; 111 + height: 36px; 112 + display: flex; 113 + align-items: center; 114 + justify-content: center; 115 + font-size: 1.2rem; 116 + background: transparent; 117 + border: 2px solid transparent; 118 + border-radius: var(--radius-sm); 119 + cursor: pointer; 120 + transition: all 0.15s ease; 121 + color: var(--text-secondary); 122 + } 123 + 124 + .emoji-option:hover, 125 + .icon-option:hover { 126 + background: var(--bg-tertiary); 127 + transform: scale(1.1); 128 + color: var(--text-primary); 129 + } 130 + 131 + .emoji-option.selected, 132 + .icon-option.selected { 133 + border-color: var(--accent); 134 + background: var(--accent-subtle); 135 + color: var(--accent); 136 + } 137 + 138 + .modal-actions { 139 + display: flex; 140 + justify-content: flex-end; 141 + gap: 12px; 142 + padding-top: 8px; 143 + } 144 + 145 + @keyframes fadeIn { 146 + from { 147 + opacity: 0; 148 + } 149 + 150 + to { 151 + opacity: 1; 152 + } 153 + } 154 + 155 + @keyframes zoomIn { 156 + from { 157 + opacity: 0; 158 + transform: scale(0.95); 159 + } 160 + 161 + to { 162 + opacity: 1; 163 + transform: scale(1); 164 + } 165 + } 166 + 167 + .form-group { 168 + margin-bottom: 0; 169 + } 170 + 171 + .form-label { 172 + display: block; 173 + font-size: 0.85rem; 174 + font-weight: 600; 175 + color: var(--text-secondary); 176 + margin-bottom: 6px; 177 + } 178 + 179 + .form-input, 180 + .form-textarea, 181 + .form-select { 182 + width: 100%; 183 + padding: 8px 12px; 184 + background: var(--bg-primary); 185 + border: 1px solid var(--border); 186 + border-radius: var(--radius-md); 187 + color: var(--text-primary); 188 + transition: all 0.15s; 189 + } 190 + 191 + .form-input:focus, 192 + .form-textarea:focus, 193 + .form-select:focus { 194 + outline: none; 195 + border-color: var(--accent); 196 + box-shadow: 0 0 0 2px var(--accent-subtle); 197 + } 198 + 199 + .form-textarea { 200 + resize: none; 201 + } 202 + 203 + .input { 204 + width: 100%; 205 + padding: 12px 14px; 206 + font-size: 0.95rem; 207 + color: var(--text-primary); 208 + background: var(--bg-secondary); 209 + border: 1px solid var(--border); 210 + border-radius: var(--radius-md); 211 + outline: none; 212 + transition: all 0.15s ease; 213 + } 214 + 215 + .input:focus { 216 + border-color: var(--accent); 217 + box-shadow: 0 0 0 3px var(--accent-subtle); 218 + } 219 + 220 + .input::placeholder { 221 + color: var(--text-tertiary); 222 + } 223 + 224 + .color-input-container { 225 + display: flex; 226 + align-items: center; 227 + gap: 12px; 228 + background: var(--bg-tertiary); 229 + padding: 8px 12px; 230 + border-radius: var(--radius-md); 231 + border: 1px solid var(--border); 232 + width: fit-content; 233 + } 234 + 235 + .color-input-wrapper { 236 + position: relative; 237 + width: 32px; 238 + height: 32px; 239 + border-radius: var(--radius-full); 240 + overflow: hidden; 241 + border: 2px solid var(--border); 242 + cursor: pointer; 243 + transition: transform 0.1s; 244 + } 245 + 246 + .color-input-wrapper:hover { 247 + transform: scale(1.1); 248 + border-color: var(--accent); 249 + } 250 + 251 + .color-input-wrapper input[type="color"] { 252 + position: absolute; 253 + top: -50%; 254 + left: -50%; 255 + width: 200%; 256 + height: 200%; 257 + padding: 0; 258 + margin: 0; 259 + border: none; 260 + cursor: pointer; 261 + opacity: 0; 262 + }
+65
web/src/css/notifications.css
··· 1 + .notifications-page { 2 + max-width: 680px; 3 + margin: 0 auto; 4 + } 5 + 6 + .notifications-list { 7 + display: flex; 8 + flex-direction: column; 9 + gap: 12px; 10 + } 11 + 12 + .notification-item { 13 + display: flex; 14 + gap: 16px; 15 + align-items: flex-start; 16 + text-decoration: none; 17 + color: inherit; 18 + } 19 + 20 + .notification-item:hover { 21 + background: var(--bg-hover); 22 + } 23 + 24 + .notification-icon { 25 + width: 36px; 26 + height: 36px; 27 + border-radius: var(--radius-full); 28 + display: flex; 29 + align-items: center; 30 + justify-content: center; 31 + background: var(--bg-tertiary); 32 + color: var(--text-secondary); 33 + flex-shrink: 0; 34 + } 35 + 36 + .notification-icon[data-type="like"] { 37 + color: #ef4444; 38 + background: rgba(239, 68, 68, 0.1); 39 + } 40 + 41 + .notification-icon[data-type="reply"] { 42 + color: #3b82f6; 43 + background: rgba(59, 130, 246, 0.1); 44 + } 45 + 46 + .notification-content { 47 + flex: 1; 48 + min-width: 0; 49 + } 50 + 51 + .notification-text { 52 + font-size: 0.95rem; 53 + margin-bottom: 4px; 54 + line-height: 1.4; 55 + color: var(--text-primary); 56 + } 57 + 58 + .notification-text strong { 59 + font-weight: 600; 60 + } 61 + 62 + .notification-time { 63 + font-size: 0.85rem; 64 + color: var(--text-tertiary); 65 + }
+250
web/src/css/profile.css
··· 1 + .profile-header { 2 + display: flex; 3 + align-items: center; 4 + gap: 24px; 5 + margin-bottom: 32px; 6 + padding-bottom: 24px; 7 + border-bottom: 1px solid var(--border); 8 + } 9 + 10 + .profile-avatar { 11 + width: 80px; 12 + height: 80px; 13 + min-width: 80px; 14 + border-radius: 50%; 15 + background: var(--bg-tertiary); 16 + display: flex; 17 + align-items: center; 18 + justify-content: center; 19 + font-weight: 600; 20 + font-size: 2rem; 21 + color: var(--text-secondary); 22 + overflow: hidden; 23 + border: 1px solid var(--border); 24 + } 25 + 26 + .profile-avatar img { 27 + width: 100%; 28 + height: 100%; 29 + object-fit: cover; 30 + } 31 + 32 + .profile-avatar-link { 33 + text-decoration: none; 34 + } 35 + 36 + .profile-info { 37 + flex: 1; 38 + display: flex; 39 + flex-direction: column; 40 + gap: 4px; 41 + } 42 + 43 + .profile-name { 44 + font-size: 1.5rem; 45 + font-weight: 700; 46 + color: var(--text-primary); 47 + line-height: 1.2; 48 + } 49 + 50 + .profile-handle-row { 51 + display: flex; 52 + align-items: center; 53 + gap: 12px; 54 + margin-top: 4px; 55 + flex-wrap: wrap; 56 + } 57 + 58 + .profile-handle-link { 59 + color: var(--text-tertiary); 60 + text-decoration: none; 61 + font-size: 1rem; 62 + transition: color 0.15s; 63 + } 64 + 65 + .profile-handle-link:hover { 66 + color: var(--text-secondary); 67 + } 68 + 69 + .profile-bluesky-link { 70 + display: inline-flex; 71 + align-items: center; 72 + gap: 6px; 73 + color: #3b82f6; 74 + text-decoration: none; 75 + font-size: 0.85rem; 76 + font-weight: 500; 77 + padding: 2px 8px; 78 + border-radius: var(--radius-sm); 79 + background: rgba(59, 130, 246, 0.1); 80 + transition: all 0.15s ease; 81 + } 82 + 83 + .profile-bluesky-link:hover { 84 + background: rgba(59, 130, 246, 0.15); 85 + } 86 + 87 + .profile-stats { 88 + display: flex; 89 + gap: 24px; 90 + margin-top: 12px; 91 + } 92 + 93 + .profile-stat { 94 + color: var(--text-tertiary); 95 + font-size: 0.9rem; 96 + } 97 + 98 + .profile-stat strong { 99 + color: var(--text-primary); 100 + font-weight: 600; 101 + } 102 + 103 + .profile-tabs { 104 + display: flex; 105 + gap: 24px; 106 + margin-bottom: 24px; 107 + border-bottom: 1px solid var(--border); 108 + } 109 + 110 + .profile-tab { 111 + padding: 12px 0; 112 + font-size: 0.95rem; 113 + font-weight: 500; 114 + color: var(--text-tertiary); 115 + background: transparent; 116 + border: none; 117 + cursor: pointer; 118 + transition: all 0.15s ease; 119 + position: relative; 120 + } 121 + 122 + .profile-tab:hover { 123 + color: var(--text-primary); 124 + } 125 + 126 + .profile-tab.active { 127 + color: var(--text-primary); 128 + } 129 + 130 + .profile-tab.active::after { 131 + content: ""; 132 + position: absolute; 133 + bottom: -1px; 134 + left: 0; 135 + right: 0; 136 + height: 2px; 137 + background: var(--text-primary); 138 + } 139 + 140 + .profile-badge-wrapper { 141 + display: inline-flex; 142 + align-items: center; 143 + } 144 + 145 + .profile-badge-clickable { 146 + position: relative; 147 + display: inline-flex; 148 + align-items: center; 149 + cursor: pointer; 150 + margin-left: 8px; 151 + } 152 + 153 + .badge-info-popover { 154 + position: absolute; 155 + top: calc(100% + 8px); 156 + left: 50%; 157 + transform: translateX(-50%); 158 + padding: 16px; 159 + background: var(--bg-elevated); 160 + border: 1px solid var(--border); 161 + border-radius: var(--radius-md); 162 + box-shadow: var(--shadow-lg); 163 + font-size: 0.85rem; 164 + white-space: nowrap; 165 + z-index: 100; 166 + min-width: 200px; 167 + } 168 + 169 + .badge-info-title { 170 + font-weight: 600; 171 + color: var(--text-primary); 172 + margin-bottom: 8px; 173 + } 174 + 175 + .verifier-link { 176 + display: flex; 177 + align-items: center; 178 + gap: 8px; 179 + padding: 8px; 180 + background: var(--bg-tertiary); 181 + border-radius: var(--radius-sm); 182 + text-decoration: none; 183 + transition: background 0.15s ease; 184 + } 185 + 186 + .verifier-link:hover { 187 + background: var(--bg-hover); 188 + } 189 + 190 + .verifier-avatar { 191 + width: 24px; 192 + height: 24px; 193 + border-radius: 50%; 194 + object-fit: cover; 195 + } 196 + 197 + .verifier-name { 198 + color: var(--text-primary); 199 + font-size: 0.85rem; 200 + font-weight: 500; 201 + } 202 + 203 + .profile-suspended { 204 + display: flex; 205 + flex-direction: column; 206 + align-items: center; 207 + justify-content: center; 208 + padding: 60px 20px; 209 + text-align: center; 210 + background: var(--bg-secondary); 211 + border-radius: var(--radius-lg); 212 + margin-top: 20px; 213 + border: 1px solid var(--border); 214 + } 215 + 216 + .suspended-icon { 217 + font-size: 40px; 218 + margin-bottom: 16px; 219 + color: var(--text-tertiary); 220 + } 221 + 222 + .profile-suspended h2 { 223 + color: var(--text-primary); 224 + margin-bottom: 8px; 225 + font-size: 1.25rem; 226 + } 227 + 228 + @media (max-width: 640px) { 229 + .profile-header { 230 + flex-direction: column; 231 + text-align: center; 232 + } 233 + 234 + .profile-info { 235 + align-items: center; 236 + } 237 + 238 + .profile-handle-row { 239 + justify-content: center; 240 + } 241 + 242 + .profile-stats { 243 + justify-content: center; 244 + } 245 + 246 + .profile-tabs { 247 + justify-content: center; 248 + gap: 16px; 249 + } 250 + }
+106
web/src/css/skeleton.css
··· 1 + @keyframes shimmer { 2 + 0% { 3 + background-position: -200% 0; 4 + } 5 + 6 + 100% { 7 + background-position: 200% 0; 8 + } 9 + } 10 + 11 + .skeleton { 12 + background: linear-gradient( 13 + 90deg, 14 + var(--bg-tertiary) 25%, 15 + var(--bg-secondary) 50%, 16 + var(--bg-tertiary) 75% 17 + ); 18 + background-size: 200% 100%; 19 + animation: shimmer 1.5s infinite; 20 + border-radius: var(--radius-sm); 21 + } 22 + 23 + .skeleton-card { 24 + padding: 24px 0; 25 + border-bottom: 1px solid var(--border); 26 + display: flex; 27 + flex-direction: column; 28 + gap: 16px; 29 + } 30 + 31 + .skeleton-header { 32 + display: flex; 33 + align-items: center; 34 + gap: 12px; 35 + } 36 + 37 + .skeleton-avatar { 38 + width: 36px; 39 + height: 36px; 40 + border-radius: 50%; 41 + } 42 + 43 + .skeleton-meta { 44 + display: flex; 45 + flex-direction: column; 46 + gap: 6px; 47 + } 48 + 49 + .skeleton-name { 50 + width: 120px; 51 + height: 14px; 52 + } 53 + 54 + .skeleton-handle { 55 + width: 80px; 56 + height: 12px; 57 + } 58 + 59 + .skeleton-content { 60 + display: flex; 61 + flex-direction: column; 62 + gap: 12px; 63 + padding-left: 48px; 64 + } 65 + 66 + .skeleton-source { 67 + width: 180px; 68 + height: 24px; 69 + border-radius: var(--radius-full); 70 + } 71 + 72 + .skeleton-highlight { 73 + width: 100%; 74 + height: 60px; 75 + border-left: 2px solid var(--border); 76 + } 77 + 78 + .skeleton-text-1 { 79 + width: 90%; 80 + height: 14px; 81 + } 82 + 83 + .skeleton-text-2 { 84 + width: 60%; 85 + height: 14px; 86 + } 87 + 88 + .skeleton-actions { 89 + display: flex; 90 + gap: 24px; 91 + padding-left: 48px; 92 + margin-top: 4px; 93 + } 94 + 95 + .skeleton-action { 96 + width: 24px; 97 + height: 24px; 98 + border-radius: var(--radius-sm); 99 + } 100 + 101 + @media (max-width: 600px) { 102 + .skeleton-content, 103 + .skeleton-actions { 104 + padding-left: 0; 105 + } 106 + }
+730
web/src/css/utilities.css
··· 1 + .legal-content { 2 + max-width: 800px; 3 + margin: 0 auto; 4 + padding: 20px; 5 + } 6 + 7 + .legal-content h1 { 8 + font-size: 2rem; 9 + margin-bottom: 8px; 10 + color: var(--text-primary); 11 + } 12 + 13 + .legal-content h2 { 14 + font-size: 1.4rem; 15 + margin-top: 32px; 16 + margin-bottom: 12px; 17 + color: var(--text-primary); 18 + } 19 + 20 + .legal-content h3 { 21 + font-size: 1.1rem; 22 + margin-top: 20px; 23 + margin-bottom: 8px; 24 + color: var(--text-primary); 25 + } 26 + 27 + .legal-content p { 28 + color: var(--text-secondary); 29 + line-height: 1.7; 30 + margin-bottom: 12px; 31 + } 32 + 33 + .legal-content ul { 34 + color: var(--text-secondary); 35 + line-height: 1.7; 36 + margin-left: 24px; 37 + margin-bottom: 12px; 38 + } 39 + 40 + .legal-content li { 41 + margin-bottom: 6px; 42 + } 43 + 44 + .legal-content a { 45 + color: var(--accent); 46 + text-decoration: none; 47 + } 48 + 49 + .legal-content a:hover { 50 + text-decoration: underline; 51 + } 52 + 53 + .legal-content section { 54 + margin-bottom: 24px; 55 + } 56 + 57 + .text-secondary { 58 + color: var(--text-secondary); 59 + } 60 + 61 + .text-error { 62 + color: var(--error); 63 + } 64 + 65 + .text-center { 66 + text-align: center; 67 + } 68 + 69 + .flex { 70 + display: flex; 71 + } 72 + 73 + .items-center { 74 + align-items: center; 75 + } 76 + 77 + .justify-center { 78 + justify-content: center; 79 + } 80 + 81 + .justify-end { 82 + justify-content: flex-end; 83 + } 84 + 85 + .gap-2 { 86 + gap: 8px; 87 + } 88 + 89 + .gap-3 { 90 + gap: 12px; 91 + } 92 + 93 + .mt-3 { 94 + margin-top: 12px; 95 + } 96 + 97 + .mb-6 { 98 + margin-bottom: 24px; 99 + } 100 + 101 + .composer { 102 + margin-bottom: 24px; 103 + } 104 + 105 + .composer-header { 106 + display: flex; 107 + justify-content: space-between; 108 + align-items: center; 109 + margin-bottom: 12px; 110 + } 111 + 112 + .composer-title { 113 + font-size: 1.1rem; 114 + font-weight: 600; 115 + color: var(--text-primary); 116 + margin: 0; 117 + } 118 + 119 + .composer-input { 120 + width: 100%; 121 + min-height: 120px; 122 + padding: 16px; 123 + background: var(--bg-secondary); 124 + border: 1px solid var(--border); 125 + border-radius: var(--radius-md); 126 + color: var(--text-primary); 127 + font-size: 1rem; 128 + resize: vertical; 129 + transition: all 0.15s ease; 130 + } 131 + 132 + .composer-input:focus { 133 + outline: none; 134 + border-color: var(--accent); 135 + box-shadow: 0 0 0 3px var(--accent-subtle); 136 + } 137 + 138 + .composer-footer { 139 + display: flex; 140 + justify-content: space-between; 141 + align-items: center; 142 + margin-top: 12px; 143 + } 144 + 145 + .composer-actions { 146 + display: flex; 147 + justify-content: flex-end; 148 + gap: 8px; 149 + } 150 + 151 + .composer-count { 152 + font-size: 0.85rem; 153 + color: var(--text-tertiary); 154 + } 155 + 156 + .composer-count.warning { 157 + color: var(--warning); 158 + } 159 + 160 + .composer-count.error { 161 + color: var(--error); 162 + } 163 + 164 + .composer-char-count.warning { 165 + color: var(--warning); 166 + } 167 + 168 + .composer-char-count.error { 169 + color: var(--error); 170 + } 171 + 172 + .composer-add-quote { 173 + width: 100%; 174 + padding: 12px 16px; 175 + margin-bottom: 12px; 176 + background: var(--bg-tertiary); 177 + border: 1px dashed var(--border); 178 + border-radius: var(--radius-md); 179 + color: var(--text-secondary); 180 + font-size: 0.9rem; 181 + cursor: pointer; 182 + transition: all 0.15s ease; 183 + } 184 + 185 + .composer-add-quote:hover { 186 + border-color: var(--accent); 187 + color: var(--accent); 188 + background: var(--accent-subtle); 189 + } 190 + 191 + .composer-quote-input-wrapper { 192 + margin-bottom: 12px; 193 + } 194 + 195 + .composer-quote-input { 196 + width: 100%; 197 + padding: 12px 16px; 198 + background: linear-gradient( 199 + 135deg, 200 + rgba(79, 70, 229, 0.05), 201 + rgba(168, 85, 247, 0.05) 202 + ); 203 + border: 1px solid var(--border); 204 + border-left: 3px solid var(--accent); 205 + border-radius: 0 var(--radius-md) var(--radius-md) 0; 206 + color: var(--text-primary); 207 + font-size: 0.95rem; 208 + font-style: italic; 209 + resize: vertical; 210 + font-family: inherit; 211 + transition: all 0.15s ease; 212 + } 213 + 214 + .composer-quote-input:focus { 215 + outline: none; 216 + border-color: var(--accent); 217 + } 218 + 219 + .composer-quote-input::placeholder { 220 + color: var(--text-tertiary); 221 + font-style: italic; 222 + } 223 + 224 + .composer-quote-remove-btn { 225 + margin-top: 8px; 226 + padding: 6px 12px; 227 + background: none; 228 + border: none; 229 + color: var(--text-tertiary); 230 + font-size: 0.85rem; 231 + cursor: pointer; 232 + } 233 + 234 + .composer-quote-remove-btn:hover { 235 + color: var(--error); 236 + } 237 + 238 + .composer-error { 239 + margin-top: 12px; 240 + padding: 12px; 241 + background: rgba(239, 68, 68, 0.1); 242 + border: 1px solid rgba(239, 68, 68, 0.3); 243 + border-radius: var(--radius-md); 244 + color: var(--error); 245 + font-size: 0.9rem; 246 + } 247 + 248 + .composer-url { 249 + font-size: 0.85rem; 250 + color: var(--text-secondary); 251 + word-break: break-all; 252 + } 253 + 254 + .composer-quote { 255 + position: relative; 256 + padding: 12px 16px; 257 + padding-right: 36px; 258 + background: var(--bg-secondary); 259 + border-left: 3px solid var(--accent); 260 + border-radius: 0 var(--radius-sm) var(--radius-sm) 0; 261 + margin-bottom: 16px; 262 + font-style: italic; 263 + color: var(--text-secondary); 264 + } 265 + 266 + .composer-quote-remove { 267 + position: absolute; 268 + top: 8px; 269 + right: 8px; 270 + width: 24px; 271 + height: 24px; 272 + border-radius: var(--radius-full); 273 + background: var(--bg-tertiary); 274 + color: var(--text-secondary); 275 + font-size: 1rem; 276 + display: flex; 277 + align-items: center; 278 + justify-content: center; 279 + } 280 + 281 + .composer-quote-remove:hover { 282 + background: var(--bg-hover); 283 + color: var(--text-primary); 284 + } 285 + 286 + .composer-tags { 287 + flex: 1; 288 + } 289 + 290 + .composer-meta-row { 291 + display: flex; 292 + gap: 12px; 293 + margin-top: 12px; 294 + align-items: flex-start; 295 + } 296 + 297 + .composer-labels-wrapper { 298 + position: relative; 299 + } 300 + 301 + .composer-labels-btn { 302 + display: flex; 303 + align-items: center; 304 + justify-content: center; 305 + width: 42px; 306 + height: 42px; 307 + background: var(--bg-secondary); 308 + border: 1px solid var(--border); 309 + border-radius: var(--radius-md); 310 + cursor: pointer; 311 + color: var(--text-tertiary); 312 + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); 313 + position: relative; 314 + } 315 + 316 + .composer-labels-btn:hover { 317 + color: var(--text-primary); 318 + background: var(--bg-hover); 319 + border-color: var(--text-tertiary); 320 + } 321 + 322 + .composer-labels-btn.active { 323 + color: var(--accent); 324 + background: var(--accent-subtle); 325 + border-color: var(--accent); 326 + } 327 + 328 + .composer-labels-badge { 329 + position: absolute; 330 + top: -4px; 331 + right: -4px; 332 + background: var(--error); 333 + color: white; 334 + font-size: 0.7rem; 335 + width: 18px; 336 + height: 18px; 337 + border-radius: 50%; 338 + display: flex; 339 + align-items: center; 340 + justify-content: center; 341 + font-weight: bold; 342 + border: 2px solid var(--bg-primary); 343 + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); 344 + } 345 + 346 + .composer-labels-picker { 347 + position: absolute; 348 + bottom: 100%; 349 + right: 0; 350 + margin-bottom: 12px; 351 + background: var(--bg-elevated); 352 + border: 1px solid var(--border); 353 + border-radius: var(--radius-md); 354 + padding: 8px 0; 355 + min-width: 200px; 356 + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25); 357 + z-index: 50; 358 + animation: scaleIn 0.2s ease-out forwards; 359 + transform-origin: bottom right; 360 + } 361 + 362 + @keyframes scaleIn { 363 + from { 364 + opacity: 0; 365 + transform: scale(0.95) translateY(5px); 366 + } 367 + 368 + to { 369 + opacity: 1; 370 + transform: scale(1) translateY(0); 371 + } 372 + } 373 + 374 + .picker-header { 375 + font-size: 0.75rem; 376 + font-weight: 600; 377 + color: var(--text-tertiary); 378 + text-transform: uppercase; 379 + letter-spacing: 0.05em; 380 + margin-bottom: 4px; 381 + padding: 4px 12px 8px; 382 + border-bottom: 1px solid var(--border); 383 + } 384 + 385 + .picker-item { 386 + display: flex; 387 + align-items: center; 388 + gap: 10px; 389 + padding: 10px 14px; 390 + cursor: pointer; 391 + color: var(--text-secondary); 392 + font-size: 0.9rem; 393 + transition: all 0.15s ease; 394 + user-select: none; 395 + } 396 + 397 + .picker-item:hover { 398 + background: var(--bg-hover); 399 + color: var(--text-primary); 400 + } 401 + 402 + .picker-checkbox-wrapper { 403 + position: relative; 404 + width: 18px; 405 + height: 18px; 406 + display: flex; 407 + align-items: center; 408 + justify-content: center; 409 + } 410 + 411 + .picker-checkbox-wrapper input { 412 + position: absolute; 413 + opacity: 0; 414 + width: 100%; 415 + height: 100%; 416 + cursor: pointer; 417 + z-index: 10; 418 + } 419 + 420 + .picker-checkbox-custom { 421 + width: 18px; 422 + height: 18px; 423 + border: 2px solid var(--text-tertiary); 424 + border-radius: 4px; 425 + display: flex; 426 + align-items: center; 427 + justify-content: center; 428 + background: transparent; 429 + transition: all 0.2s ease; 430 + color: white; 431 + } 432 + 433 + .picker-item:hover .picker-checkbox-custom { 434 + border-color: var(--text-secondary); 435 + } 436 + 437 + .picker-checkbox-wrapper input:checked + .picker-checkbox-custom { 438 + background: var(--accent); 439 + border-color: var(--accent); 440 + color: white; 441 + } 442 + 443 + .composer-tags-input { 444 + width: 100%; 445 + padding: 12px 16px; 446 + background: var(--bg-secondary); 447 + border: 1px solid var(--border); 448 + border-radius: var(--radius-md); 449 + color: var(--text-primary); 450 + font-size: 0.95rem; 451 + transition: all 0.15s ease; 452 + } 453 + 454 + .composer-tags-input:focus { 455 + outline: none; 456 + border-color: var(--accent); 457 + box-shadow: 0 0 0 3px var(--accent-subtle); 458 + } 459 + 460 + .composer-tags-input::placeholder { 461 + color: var(--text-tertiary); 462 + } 463 + 464 + .history-panel { 465 + background: var(--bg-tertiary); 466 + border: 1px solid var(--border); 467 + border-radius: var(--radius-md); 468 + padding: 1rem; 469 + margin-bottom: 1rem; 470 + font-size: 0.9rem; 471 + animation: fadeIn 0.2s ease-out; 472 + } 473 + 474 + .history-header { 475 + display: flex; 476 + justify-content: space-between; 477 + align-items: center; 478 + margin-bottom: 1rem; 479 + padding-bottom: 0.5rem; 480 + border-bottom: 1px solid var(--border); 481 + } 482 + 483 + .history-title { 484 + font-weight: 600; 485 + text-transform: uppercase; 486 + letter-spacing: 0.05em; 487 + font-size: 0.75rem; 488 + color: var(--text-secondary); 489 + } 490 + 491 + .history-list { 492 + list-style: none; 493 + display: flex; 494 + flex-direction: column; 495 + gap: 1rem; 496 + } 497 + 498 + .history-item { 499 + position: relative; 500 + padding-left: 1rem; 501 + border-left: 2px solid var(--border); 502 + } 503 + 504 + .history-date { 505 + font-size: 0.75rem; 506 + color: var(--text-tertiary); 507 + margin-bottom: 0.25rem; 508 + } 509 + 510 + .history-content { 511 + color: var(--text-secondary); 512 + white-space: pre-wrap; 513 + } 514 + 515 + .history-close-btn { 516 + color: var(--text-tertiary); 517 + padding: 4px; 518 + border-radius: var(--radius-sm); 519 + transition: all 0.2s; 520 + display: flex; 521 + align-items: center; 522 + justify-content: center; 523 + } 524 + 525 + .history-close-btn:hover { 526 + background: var(--bg-hover); 527 + color: var(--text-primary); 528 + } 529 + 530 + .history-status { 531 + text-align: center; 532 + color: var(--text-tertiary); 533 + font-style: italic; 534 + padding: 1rem; 535 + } 536 + 537 + .share-menu-container { 538 + position: relative; 539 + } 540 + 541 + .share-menu { 542 + position: absolute; 543 + top: 100%; 544 + right: 0; 545 + margin-top: 8px; 546 + background: var(--bg-primary); 547 + border: 1px solid var(--border); 548 + border-radius: var(--radius-lg); 549 + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); 550 + min-width: 180px; 551 + padding: 8px 0; 552 + z-index: 100; 553 + animation: fadeInUp 0.15s ease; 554 + } 555 + 556 + @keyframes fadeInUp { 557 + from { 558 + opacity: 0; 559 + transform: translateY(-8px); 560 + } 561 + 562 + to { 563 + opacity: 1; 564 + transform: translateY(0); 565 + } 566 + } 567 + 568 + .share-menu-section { 569 + display: flex; 570 + flex-direction: column; 571 + } 572 + 573 + .share-menu-label { 574 + padding: 4px 12px 8px; 575 + font-size: 0.7rem; 576 + font-weight: 600; 577 + text-transform: uppercase; 578 + letter-spacing: 0.05em; 579 + color: var(--text-tertiary); 580 + } 581 + 582 + .share-menu-item { 583 + display: flex; 584 + align-items: center; 585 + gap: 10px; 586 + padding: 10px 14px; 587 + background: none; 588 + border: none; 589 + width: 100%; 590 + text-align: left; 591 + font-size: 0.9rem; 592 + color: var(--text-primary); 593 + cursor: pointer; 594 + transition: all 0.1s ease; 595 + } 596 + 597 + .share-menu-item:hover { 598 + background: var(--bg-tertiary); 599 + } 600 + 601 + .share-menu-icon { 602 + font-size: 1.1rem; 603 + width: 24px; 604 + text-align: center; 605 + } 606 + 607 + .share-menu-divider { 608 + height: 1px; 609 + background: var(--border); 610 + margin: 6px 0; 611 + } 612 + 613 + .bookmark-card { 614 + display: flex; 615 + flex-direction: column; 616 + gap: 16px; 617 + } 618 + 619 + .bookmark-preview { 620 + display: flex; 621 + flex-direction: column; 622 + background: var(--bg-secondary); 623 + border: 1px solid var(--border); 624 + border-radius: var(--radius-md); 625 + overflow: hidden; 626 + text-decoration: none; 627 + transition: all 0.2s ease; 628 + position: relative; 629 + } 630 + 631 + .bookmark-preview:hover { 632 + border-color: var(--accent); 633 + box-shadow: var(--shadow-sm); 634 + transform: translateY(-1px); 635 + } 636 + 637 + .bookmark-preview::before { 638 + content: ""; 639 + position: absolute; 640 + left: 0; 641 + top: 0; 642 + bottom: 0; 643 + width: 4px; 644 + background: var(--accent); 645 + opacity: 0.7; 646 + } 647 + 648 + .bookmark-preview-content { 649 + padding: 16px 20px; 650 + display: flex; 651 + flex-direction: column; 652 + gap: 8px; 653 + } 654 + 655 + .bookmark-preview-header { 656 + display: flex; 657 + align-items: center; 658 + gap: 8px; 659 + margin-bottom: 4px; 660 + } 661 + 662 + .bookmark-preview-site { 663 + display: flex; 664 + align-items: center; 665 + gap: 6px; 666 + font-size: 0.75rem; 667 + font-weight: 600; 668 + color: var(--accent); 669 + text-transform: uppercase; 670 + letter-spacing: 0.03em; 671 + } 672 + 673 + .bookmark-preview-title { 674 + font-size: 1rem; 675 + font-weight: 600; 676 + line-height: 1.4; 677 + color: var(--text-primary); 678 + margin: 0; 679 + display: -webkit-box; 680 + -webkit-line-clamp: 2; 681 + line-clamp: 2; 682 + -webkit-box-orient: vertical; 683 + overflow: hidden; 684 + } 685 + 686 + .bookmark-preview-desc { 687 + font-size: 0.875rem; 688 + color: var(--text-secondary); 689 + line-height: 1.5; 690 + margin: 0; 691 + display: -webkit-box; 692 + -webkit-line-clamp: 2; 693 + line-clamp: 2; 694 + -webkit-box-orient: vertical; 695 + overflow: hidden; 696 + } 697 + 698 + .bookmark-preview-arrow { 699 + display: flex; 700 + align-items: center; 701 + justify-content: center; 702 + color: var(--text-tertiary); 703 + padding: 0 4px; 704 + transition: all 0.2s ease; 705 + } 706 + 707 + .bookmark-preview:hover .bookmark-preview-arrow { 708 + color: var(--accent); 709 + transform: translateX(2px); 710 + } 711 + 712 + .bookmark-description { 713 + font-size: 0.9rem; 714 + color: var(--text-secondary); 715 + margin: 0; 716 + line-height: 1.5; 717 + } 718 + 719 + .bookmark-meta { 720 + display: flex; 721 + align-items: center; 722 + gap: 12px; 723 + margin-top: 12px; 724 + font-size: 0.85rem; 725 + color: var(--text-tertiary); 726 + } 727 + 728 + .bookmark-time { 729 + color: var(--text-tertiary); 730 + }
+13 -3190
web/src/index.css
··· 1 - :root { 2 - --bg-primary: #0c0a14; 3 - --bg-secondary: #110e1c; 4 - --bg-tertiary: #1a1528; 5 - --bg-card: #14111f; 6 - --bg-hover: #1e1932; 7 - --bg-elevated: #1a1528; 8 - 9 - --text-primary: #f4f0ff; 10 - --text-secondary: #a89ec8; 11 - --text-tertiary: #6b5f8a; 12 - 13 - --accent: #a855f7; 14 - --accent-hover: #c084fc; 15 - --accent-subtle: rgba(168, 85, 247, 0.15); 16 - 17 - --border: #2d2640; 18 - --border-hover: #3d3560; 19 - 20 - --success: #22c55e; 21 - --error: #ef4444; 22 - --warning: #f59e0b; 23 - 24 - --radius-sm: 6px; 25 - --radius-md: 10px; 26 - --radius-lg: 16px; 27 - --radius-full: 9999px; 28 - 29 - --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3); 30 - --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4); 31 - --shadow-lg: 0 10px 25px rgba(0, 0, 0, 0.5), 0 0 40px rgba(168, 85, 247, 0.1); 32 - --shadow-glow: 0 0 20px rgba(168, 85, 247, 0.3); 33 - 34 - --font-sans: 35 - "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; 36 - } 37 - 38 - * { 39 - margin: 0; 40 - padding: 0; 41 - box-sizing: border-box; 42 - } 43 - 44 - html { 45 - font-size: 16px; 46 - } 47 - 48 - body { 49 - font-family: var(--font-sans); 50 - background: var(--bg-primary); 51 - color: var(--text-primary); 52 - line-height: 1.6; 53 - min-height: 100vh; 54 - -webkit-font-smoothing: antialiased; 55 - -moz-osx-font-smoothing: grayscale; 56 - } 57 - 58 - a { 59 - color: var(--accent); 60 - text-decoration: none; 61 - transition: color 0.15s ease; 62 - } 63 - 64 - a:hover { 65 - color: var(--accent-hover); 66 - } 67 - 68 - button { 69 - font-family: inherit; 70 - cursor: pointer; 71 - border: none; 72 - background: none; 73 - } 74 - 75 - input, 76 - textarea { 77 - font-family: inherit; 78 - font-size: inherit; 79 - } 80 - 81 - .app { 82 - min-height: 100vh; 83 - display: flex; 84 - flex-direction: column; 85 - } 86 - 87 - .main-content { 88 - flex: 1; 89 - max-width: 680px; 90 - width: 100%; 91 - margin: 0 auto; 92 - padding: 24px 16px; 93 - } 94 - 95 - .btn { 96 - display: inline-flex; 97 - align-items: center; 98 - justify-content: center; 99 - gap: 8px; 100 - padding: 10px 20px; 101 - font-size: 0.9rem; 102 - font-weight: 500; 103 - border-radius: var(--radius-md); 104 - transition: all 0.15s ease; 105 - } 106 - 107 - .btn-primary { 108 - background: var(--accent); 109 - color: white; 110 - } 111 - 112 - .btn-primary:hover { 113 - background: var(--accent-hover); 114 - transform: translateY(-1px); 115 - box-shadow: var(--shadow-md); 116 - } 117 - 118 - .btn-secondary { 119 - background: var(--bg-tertiary); 120 - color: var(--text-primary); 121 - border: 1px solid var(--border); 122 - } 123 - 124 - .btn-secondary:hover { 125 - background: var(--bg-hover); 126 - border-color: var(--border-hover); 127 - } 128 - 129 - .btn-ghost { 130 - color: var(--text-secondary); 131 - padding: 8px 12px; 132 - } 133 - 134 - .btn-ghost:hover { 135 - color: var(--text-primary); 136 - background: var(--bg-tertiary); 137 - } 138 - 139 - .card { 140 - background: var(--bg-card); 141 - border: 1px solid var(--border); 142 - border-radius: var(--radius-lg); 143 - padding: 20px; 144 - transition: all 0.2s ease; 145 - } 146 - 147 - .card:hover { 148 - border-color: var(--border-hover); 149 - box-shadow: var(--shadow-sm); 150 - } 151 - 152 - .annotation-card { 153 - display: flex; 154 - flex-direction: column; 155 - gap: 12px; 156 - } 157 - 158 - .annotation-header { 159 - display: flex; 160 - align-items: center; 161 - gap: 12px; 162 - } 163 - 164 - .annotation-avatar { 165 - width: 42px; 166 - height: 42px; 167 - min-width: 42px; 168 - border-radius: var(--radius-full); 169 - background: linear-gradient(135deg, var(--accent), #a855f7); 170 - display: flex; 171 - align-items: center; 172 - justify-content: center; 173 - font-weight: 600; 174 - font-size: 1rem; 175 - color: white; 176 - overflow: hidden; 177 - } 178 - 179 - .annotation-avatar img { 180 - width: 100%; 181 - height: 100%; 182 - object-fit: cover; 183 - } 184 - 185 - .annotation-meta { 186 - flex: 1; 187 - min-width: 0; 188 - } 189 - 190 - .annotation-avatar-link { 191 - text-decoration: none; 192 - } 193 - 194 - .annotation-author-row { 195 - display: flex; 196 - align-items: center; 197 - gap: 6px; 198 - flex-wrap: wrap; 199 - } 200 - 201 - .annotation-author { 202 - font-weight: 600; 203 - color: var(--text-primary); 204 - } 205 - 206 - .annotation-handle { 207 - font-size: 0.9rem; 208 - color: var(--text-tertiary); 209 - text-decoration: none; 210 - } 211 - 212 - .annotation-handle:hover { 213 - color: var(--accent); 214 - text-decoration: underline; 215 - } 216 - 217 - .annotation-time { 218 - font-size: 0.85rem; 219 - color: var(--text-tertiary); 220 - } 221 - 222 - .annotation-source { 223 - display: block; 224 - font-size: 0.85rem; 225 - color: var(--text-tertiary); 226 - text-decoration: none; 227 - margin-bottom: 8px; 228 - } 229 - 230 - .annotation-source:hover { 231 - color: var(--accent); 232 - } 233 - 234 - .annotation-source-title { 235 - color: var(--text-secondary); 236 - } 237 - 238 - .annotation-highlight { 239 - display: block; 240 - padding: 12px 16px; 241 - background: linear-gradient( 242 - 135deg, 243 - rgba(79, 70, 229, 0.05), 244 - rgba(168, 85, 247, 0.05) 245 - ); 246 - border-left: 3px solid var(--accent); 247 - border-radius: 0 var(--radius-sm) var(--radius-sm) 0; 248 - text-decoration: none; 249 - transition: all 0.15s ease; 250 - margin-bottom: 12px; 251 - } 252 - 253 - .annotation-highlight:hover { 254 - background: linear-gradient( 255 - 135deg, 256 - rgba(79, 70, 229, 0.1), 257 - rgba(168, 85, 247, 0.1) 258 - ); 259 - } 260 - 261 - .annotation-highlight mark { 262 - background: transparent; 263 - color: var(--text-primary); 264 - font-style: italic; 265 - font-size: 0.95rem; 266 - } 267 - 268 - .annotation-text { 269 - font-size: 1rem; 270 - line-height: 1.65; 271 - color: var(--text-primary); 272 - } 273 - 274 - .annotation-actions { 275 - display: flex; 276 - align-items: center; 277 - gap: 16px; 278 - padding-top: 8px; 279 - } 280 - 281 - .annotation-action { 282 - display: flex; 283 - align-items: center; 284 - gap: 6px; 285 - color: var(--text-tertiary); 286 - font-size: 0.85rem; 287 - padding: 6px 10px; 288 - border-radius: var(--radius-sm); 289 - transition: all 0.15s ease; 290 - } 291 - 292 - .annotation-action:hover { 293 - color: var(--text-secondary); 294 - background: var(--bg-tertiary); 295 - } 296 - 297 - .annotation-action.liked { 298 - color: #ef4444; 299 - } 300 - 301 - .annotation-delete { 302 - background: none; 303 - border: none; 304 - cursor: pointer; 305 - padding: 6px 8px; 306 - font-size: 1rem; 307 - color: var(--text-tertiary); 308 - transition: all 0.15s ease; 309 - border-radius: var(--radius-sm); 310 - } 311 - 312 - .annotation-delete:hover { 313 - color: var(--error); 314 - background: rgba(239, 68, 68, 0.1); 315 - } 316 - 317 - .annotation-delete:disabled { 318 - cursor: not-allowed; 319 - opacity: 0.3; 320 - } 321 - 322 - .share-menu-container { 323 - position: relative; 324 - } 325 - 326 - .share-menu { 327 - position: absolute; 328 - top: 100%; 329 - right: 0; 330 - margin-top: 8px; 331 - background: var(--bg-primary); 332 - border: 1px solid var(--border); 333 - border-radius: var(--radius-lg); 334 - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); 335 - min-width: 180px; 336 - padding: 8px 0; 337 - z-index: 100; 338 - animation: fadeInUp 0.15s ease; 339 - } 340 - 341 - @keyframes fadeInUp { 342 - from { 343 - opacity: 0; 344 - transform: translateY(-8px); 345 - } 346 - 347 - to { 348 - opacity: 1; 349 - transform: translateY(0); 350 - } 351 - } 352 - 353 - .share-menu-section { 354 - display: flex; 355 - flex-direction: column; 356 - } 357 - 358 - .share-menu-label { 359 - padding: 4px 12px 8px; 360 - font-size: 0.7rem; 361 - font-weight: 600; 362 - text-transform: uppercase; 363 - letter-spacing: 0.05em; 364 - color: var(--text-tertiary); 365 - } 366 - 367 - .share-menu-item { 368 - display: flex; 369 - align-items: center; 370 - gap: 10px; 371 - padding: 10px 14px; 372 - background: none; 373 - border: none; 374 - width: 100%; 375 - text-align: left; 376 - font-size: 0.9rem; 377 - color: var(--text-primary); 378 - cursor: pointer; 379 - transition: all 0.1s ease; 380 - } 381 - 382 - .share-menu-item:hover { 383 - background: var(--bg-tertiary); 384 - } 385 - 386 - .share-menu-icon { 387 - font-size: 1.1rem; 388 - width: 24px; 389 - text-align: center; 390 - } 391 - 392 - .share-menu-divider { 393 - height: 1px; 394 - background: var(--border); 395 - margin: 6px 0; 396 - } 397 - 398 - .feed { 399 - display: flex; 400 - flex-direction: column; 401 - gap: 16px; 402 - } 403 - 404 - .feed-header { 405 - display: flex; 406 - align-items: center; 407 - justify-content: space-between; 408 - margin-bottom: 8px; 409 - } 410 - 411 - .feed-title { 412 - font-size: 1.5rem; 413 - font-weight: 700; 414 - } 415 - 416 - .page-header { 417 - margin-bottom: 32px; 418 - } 419 - 420 - .page-title { 421 - font-size: 2rem; 422 - font-weight: 700; 423 - margin-bottom: 8px; 424 - } 425 - 426 - .page-description { 427 - color: var(--text-secondary); 428 - font-size: 1.1rem; 429 - } 430 - 431 - .url-input-wrapper { 432 - margin-bottom: 32px; 433 - } 434 - 435 - .url-input-container { 436 - display: flex; 437 - gap: 12px; 438 - } 439 - 440 - .url-input { 441 - flex: 1; 442 - padding: 14px 18px; 443 - background: var(--bg-secondary); 444 - border: 1px solid var(--border); 445 - border-radius: var(--radius-md); 446 - color: var(--text-primary); 447 - font-size: 1rem; 448 - transition: all 0.15s ease; 449 - } 450 - 451 - .url-input:focus { 452 - outline: none; 453 - border-color: var(--accent); 454 - box-shadow: 0 0 0 3px var(--accent-subtle); 455 - } 456 - 457 - .url-input::placeholder { 458 - color: var(--text-tertiary); 459 - } 460 - 461 - .empty-state { 462 - text-align: center; 463 - padding: 60px 20px; 464 - color: var(--text-secondary); 465 - } 466 - 467 - .empty-state-icon { 468 - font-size: 3rem; 469 - margin-bottom: 16px; 470 - opacity: 0.5; 471 - } 472 - 473 - .empty-state-title { 474 - font-size: 1.25rem; 475 - font-weight: 600; 476 - color: var(--text-primary); 477 - margin-bottom: 8px; 478 - } 479 - 480 - .empty-state-text { 481 - font-size: 1rem; 482 - max-width: 400px; 483 - margin: 0 auto; 484 - } 485 - 486 - .feed-filters { 487 - display: flex; 488 - gap: 8px; 489 - margin-bottom: 24px; 490 - padding: 4px; 491 - background: var(--bg-tertiary); 492 - border-radius: var(--radius-lg); 493 - width: fit-content; 494 - } 495 - 496 - .login-page { 497 - display: flex; 498 - flex-direction: column; 499 - align-items: center; 500 - justify-content: center; 501 - min-height: 70vh; 502 - padding: 60px 20px; 503 - width: 100%; 504 - max-width: 500px; 505 - margin: 0 auto; 506 - } 507 - 508 - .login-at-logo { 509 - font-size: 5rem; 510 - font-weight: 800; 511 - color: var(--accent); 512 - margin-bottom: 24px; 513 - line-height: 1; 514 - } 515 - 516 - .login-heading { 517 - font-size: 1.5rem; 518 - font-weight: 600; 519 - margin-bottom: 32px; 520 - display: flex; 521 - align-items: center; 522 - gap: 10px; 523 - text-align: center; 524 - line-height: 1.4; 525 - } 526 - 527 - .login-help-btn { 528 - background: none; 529 - border: none; 530 - color: var(--text-tertiary); 531 - cursor: pointer; 532 - padding: 4px; 533 - display: flex; 534 - align-items: center; 535 - transition: color 0.15s; 536 - flex-shrink: 0; 537 - } 538 - 539 - .login-help-btn:hover { 540 - color: var(--accent); 541 - } 542 - 543 - .login-help-text { 544 - background: var(--bg-elevated); 545 - border: 1px solid var(--border); 546 - border-radius: var(--radius-md); 547 - padding: 16px 20px; 548 - margin-bottom: 24px; 549 - font-size: 0.95rem; 550 - color: var(--text-secondary); 551 - line-height: 1.6; 552 - text-align: center; 553 - } 554 - 555 - .login-help-text code { 556 - background: var(--bg-tertiary); 557 - padding: 2px 8px; 558 - border-radius: var(--radius-sm); 559 - font-size: 0.9rem; 560 - } 561 - 562 - .login-form { 563 - display: flex; 564 - flex-direction: column; 565 - gap: 20px; 566 - width: 100%; 567 - } 568 - 569 - .login-input-wrapper { 570 - position: relative; 571 - } 572 - 573 - .login-input { 574 - width: 100%; 575 - padding: 18px 20px; 576 - background: var(--bg-elevated); 577 - border: 2px solid var(--border); 578 - border-radius: var(--radius-lg); 579 - color: var(--text-primary); 580 - font-size: 1.1rem; 581 - transition: 582 - border-color 0.15s, 583 - box-shadow 0.15s; 584 - } 585 - 586 - .login-input:focus { 587 - outline: none; 588 - border-color: var(--accent); 589 - box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.15); 590 - } 591 - 592 - .login-input::placeholder { 593 - color: var(--text-tertiary); 594 - } 595 - 596 - .login-suggestions { 597 - position: absolute; 598 - top: calc(100% + 8px); 599 - left: 0; 600 - right: 0; 601 - background: var(--bg-card); 602 - border: 1px solid var(--border); 603 - border-radius: var(--radius-lg); 604 - box-shadow: var(--shadow-lg); 605 - overflow: hidden; 606 - z-index: 100; 607 - } 608 - 609 - .login-suggestion { 610 - display: flex; 611 - align-items: center; 612 - gap: 14px; 613 - width: 100%; 614 - padding: 14px 18px; 615 - background: transparent; 616 - border: none; 617 - cursor: pointer; 618 - text-align: left; 619 - color: var(--text-primary); 620 - transition: background 0.1s; 621 - } 622 - 623 - .login-suggestion:hover, 624 - .login-suggestion.selected { 625 - background: var(--bg-elevated); 626 - } 627 - 628 - .login-suggestion-avatar { 629 - width: 44px; 630 - height: 44px; 631 - border-radius: var(--radius-full); 632 - background: linear-gradient(135deg, var(--accent), #a855f7); 633 - display: flex; 634 - align-items: center; 635 - justify-content: center; 636 - flex-shrink: 0; 637 - overflow: hidden; 638 - font-size: 0.9rem; 639 - font-weight: 600; 640 - color: white; 641 - } 642 - 643 - .login-suggestion-avatar img { 644 - width: 100%; 645 - height: 100%; 646 - object-fit: cover; 647 - } 648 - 649 - .login-suggestion-info { 650 - display: flex; 651 - flex-direction: column; 652 - gap: 2px; 653 - min-width: 0; 654 - } 655 - 656 - .login-suggestion-name { 657 - font-weight: 600; 658 - font-size: 1rem; 659 - color: var(--text-primary); 660 - white-space: nowrap; 661 - overflow: hidden; 662 - text-overflow: ellipsis; 663 - } 664 - 665 - .login-suggestion-handle { 666 - font-size: 0.9rem; 667 - color: var(--text-secondary); 668 - white-space: nowrap; 669 - overflow: hidden; 670 - text-overflow: ellipsis; 671 - } 672 - 673 - .login-error { 674 - padding: 12px 16px; 675 - background: rgba(239, 68, 68, 0.1); 676 - border: 1px solid rgba(239, 68, 68, 0.3); 677 - border-radius: var(--radius-md); 678 - color: #ef4444; 679 - font-size: 0.9rem; 680 - text-align: center; 681 - } 682 - 683 - .login-submit { 684 - padding: 18px 32px; 685 - font-size: 1.1rem; 686 - font-weight: 600; 687 - } 688 - 689 - .login-avatar-large { 690 - width: 100px; 691 - height: 100px; 692 - border-radius: var(--radius-full); 693 - background: linear-gradient(135deg, var(--accent), #a855f7); 694 - display: flex; 695 - align-items: center; 696 - justify-content: center; 697 - margin-bottom: 20px; 698 - font-weight: 700; 699 - font-size: 2rem; 700 - color: white; 701 - overflow: hidden; 702 - } 703 - 704 - .login-avatar-large img { 705 - width: 100%; 706 - height: 100%; 707 - object-fit: cover; 708 - } 709 - 710 - .login-welcome { 711 - font-size: 1.5rem; 712 - font-weight: 600; 713 - margin-bottom: 32px; 714 - text-align: center; 715 - } 716 - 717 - .login-actions { 718 - display: flex; 719 - flex-direction: column; 720 - gap: 12px; 721 - width: 100%; 722 - } 723 - 724 - .login-avatar { 725 - width: 72px; 726 - height: 72px; 727 - border-radius: var(--radius-full); 728 - background: linear-gradient(135deg, var(--accent), #a855f7); 729 - display: flex; 730 - align-items: center; 731 - justify-content: center; 732 - margin: 0 auto 16px; 733 - font-weight: 700; 734 - font-size: 1.5rem; 735 - color: white; 736 - overflow: hidden; 737 - } 738 - 739 - .login-avatar img { 740 - width: 100%; 741 - height: 100%; 742 - object-fit: cover; 743 - } 744 - 745 - .login-welcome-name { 746 - font-size: 1.25rem; 747 - font-weight: 600; 748 - margin-bottom: 24px; 749 - } 750 - 751 - .login-actions { 752 - display: flex; 753 - flex-direction: column; 754 - gap: 12px; 755 - } 756 - 757 - .btn-bluesky { 758 - background: #0085ff; 759 - color: white; 760 - display: flex; 761 - align-items: center; 762 - justify-content: center; 763 - gap: 10px; 764 - transition: 765 - background 0.2s, 766 - transform 0.2s; 767 - } 768 - 769 - .btn-bluesky:hover { 770 - background: #0070dd; 771 - transform: translateY(-1px); 772 - } 773 - 774 - .login-btn { 775 - width: 100%; 776 - padding: 14px 24px; 777 - font-size: 1rem; 778 - font-weight: 600; 779 - } 780 - 781 - .login-brand { 782 - display: flex; 783 - align-items: center; 784 - justify-content: center; 785 - gap: 12px; 786 - margin-bottom: 24px; 787 - } 788 - 789 - .login-brand-icon { 790 - width: 48px; 791 - height: 48px; 792 - background: linear-gradient(135deg, var(--accent), #a855f7); 793 - border-radius: var(--radius-lg); 794 - display: flex; 795 - align-items: center; 796 - justify-content: center; 797 - font-size: 1.75rem; 798 - font-weight: 800; 799 - color: white; 800 - } 801 - 802 - .login-brand-name { 803 - font-size: 1.75rem; 804 - font-weight: 700; 805 - } 806 - 807 - .login-form { 808 - display: flex; 809 - flex-direction: column; 810 - gap: 16px; 811 - } 812 - 813 - .login-input-wrapper { 814 - position: relative; 815 - } 816 - 817 - .login-input { 818 - width: 100%; 819 - padding: 14px 16px; 820 - background: var(--bg-elevated); 821 - border: 1px solid var(--border); 822 - border-radius: var(--radius-md); 823 - color: var(--text-primary); 824 - font-size: 1rem; 825 - transition: 826 - border-color 0.15s, 827 - box-shadow 0.15s; 828 - } 829 - 830 - .login-input:focus { 831 - outline: none; 832 - border-color: var(--accent); 833 - box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15); 834 - } 835 - 836 - .login-input::placeholder { 837 - color: var(--text-tertiary); 838 - } 839 - 840 - .login-suggestions { 841 - position: absolute; 842 - top: calc(100% + 4px); 843 - left: 0; 844 - right: 0; 845 - background: var(--bg-card); 846 - border: 1px solid var(--border); 847 - border-radius: var(--radius-md); 848 - box-shadow: var(--shadow-lg); 849 - overflow: hidden; 850 - z-index: 100; 851 - } 852 - 853 - .login-suggestion { 854 - display: flex; 855 - align-items: center; 856 - gap: 12px; 857 - width: 100%; 858 - padding: 12px 16px; 859 - background: transparent; 860 - border: none; 861 - cursor: pointer; 862 - text-align: left; 863 - transition: background 0.1s; 864 - } 865 - 866 - .login-suggestion:hover, 867 - .login-suggestion.selected { 868 - background: var(--bg-elevated); 869 - } 870 - 871 - .login-suggestion-avatar { 872 - width: 40px; 873 - height: 40px; 874 - border-radius: var(--radius-full); 875 - background: linear-gradient(135deg, var(--accent), #a855f7); 876 - display: flex; 877 - align-items: center; 878 - justify-content: center; 879 - flex-shrink: 0; 880 - overflow: hidden; 881 - font-size: 0.875rem; 882 - font-weight: 600; 883 - color: white; 884 - } 885 - 886 - .login-suggestion-avatar img { 887 - width: 100%; 888 - height: 100%; 889 - object-fit: cover; 890 - } 891 - 892 - .login-suggestion-info { 893 - display: flex; 894 - flex-direction: column; 895 - min-width: 0; 896 - } 897 - 898 - .login-suggestion-name { 899 - font-weight: 600; 900 - color: var(--text-primary); 901 - white-space: nowrap; 902 - overflow: hidden; 903 - text-overflow: ellipsis; 904 - } 905 - 906 - .login-suggestion-handle { 907 - font-size: 0.875rem; 908 - color: var(--text-secondary); 909 - white-space: nowrap; 910 - overflow: hidden; 911 - text-overflow: ellipsis; 912 - } 913 - 914 - .login-error { 915 - padding: 12px 16px; 916 - background: rgba(239, 68, 68, 0.1); 917 - border: 1px solid rgba(239, 68, 68, 0.3); 918 - border-radius: var(--radius-md); 919 - color: #ef4444; 920 - font-size: 0.875rem; 921 - } 922 - 923 - .login-legal { 924 - font-size: 0.75rem; 925 - color: var(--text-tertiary); 926 - line-height: 1.5; 927 - margin-top: 16px; 928 - } 929 - 930 - .profile-header { 931 - display: flex; 932 - align-items: center; 933 - gap: 20px; 934 - margin-bottom: 32px; 935 - padding-bottom: 24px; 936 - border-bottom: 1px solid var(--border); 937 - } 938 - 939 - .profile-avatar { 940 - width: 80px; 941 - height: 80px; 942 - min-width: 80px; 943 - border-radius: var(--radius-full); 944 - background: linear-gradient(135deg, var(--accent), #a855f7); 945 - display: flex; 946 - align-items: center; 947 - justify-content: center; 948 - font-weight: 700; 949 - font-size: 2rem; 950 - color: white; 951 - overflow: hidden; 952 - } 953 - 954 - .profile-avatar img { 955 - width: 100%; 956 - height: 100%; 957 - object-fit: cover; 958 - } 959 - 960 - .profile-avatar-link { 961 - text-decoration: none; 962 - } 963 - 964 - .profile-info { 965 - flex: 1; 966 - } 967 - 968 - .profile-name { 969 - font-size: 1.5rem; 970 - font-weight: 700; 971 - } 972 - 973 - .profile-handle-link { 974 - color: var(--text-secondary); 975 - text-decoration: none; 976 - } 977 - 978 - .profile-handle-link:hover { 979 - color: var(--accent); 980 - text-decoration: underline; 981 - } 982 - 983 - .profile-bluesky-link { 984 - display: inline-flex; 985 - align-items: center; 986 - gap: 6px; 987 - color: #0085ff; 988 - text-decoration: none; 989 - font-size: 0.95rem; 990 - padding: 4px 10px; 991 - border-radius: var(--radius-md); 992 - background: rgba(0, 133, 255, 0.1); 993 - transition: all 0.15s ease; 994 - } 995 - 996 - .profile-bluesky-link:hover { 997 - background: rgba(0, 133, 255, 0.2); 998 - color: #0070dd; 999 - } 1000 - 1001 - .profile-stats { 1002 - display: flex; 1003 - gap: 24px; 1004 - margin-top: 8px; 1005 - } 1006 - 1007 - .profile-stat { 1008 - color: var(--text-secondary); 1009 - font-size: 0.9rem; 1010 - } 1011 - 1012 - .profile-stat strong { 1013 - color: var(--text-primary); 1014 - } 1015 - 1016 - .profile-tabs { 1017 - display: flex; 1018 - gap: 0; 1019 - margin-bottom: 24px; 1020 - border-bottom: 1px solid var(--border); 1021 - } 1022 - 1023 - .profile-tab { 1024 - padding: 12px 20px; 1025 - font-size: 0.9rem; 1026 - font-weight: 500; 1027 - color: var(--text-secondary); 1028 - background: transparent; 1029 - border: none; 1030 - border-bottom: 2px solid transparent; 1031 - cursor: pointer; 1032 - transition: all 0.15s ease; 1033 - margin-bottom: -1px; 1034 - } 1035 - 1036 - .profile-tab:hover { 1037 - color: var(--text-primary); 1038 - background: var(--bg-tertiary); 1039 - } 1040 - 1041 - .profile-tab.active { 1042 - color: var(--accent); 1043 - border-bottom-color: var(--accent); 1044 - } 1045 - 1046 - .bookmark-card { 1047 - padding: 16px 20px; 1048 - } 1049 - 1050 - .bookmark-header { 1051 - display: flex; 1052 - align-items: flex-start; 1053 - justify-content: space-between; 1054 - gap: 12px; 1055 - } 1056 - 1057 - .bookmark-link { 1058 - text-decoration: none; 1059 - flex: 1; 1060 - } 1061 - 1062 - .bookmark-title { 1063 - font-size: 1rem; 1064 - font-weight: 600; 1065 - color: var(--text-primary); 1066 - margin: 0 0 4px 0; 1067 - line-height: 1.4; 1068 - } 1069 - 1070 - .bookmark-title:hover { 1071 - color: var(--accent); 1072 - } 1073 - 1074 - .bookmark-description { 1075 - font-size: 0.9rem; 1076 - color: var(--text-secondary); 1077 - margin: 0; 1078 - line-height: 1.5; 1079 - } 1080 - 1081 - .bookmark-meta { 1082 - display: flex; 1083 - align-items: center; 1084 - gap: 12px; 1085 - margin-top: 12px; 1086 - font-size: 0.85rem; 1087 - color: var(--text-tertiary); 1088 - } 1089 - 1090 - .bookmark-time { 1091 - color: var(--text-tertiary); 1092 - } 1093 - 1094 - .composer { 1095 - margin-bottom: 24px; 1096 - } 1097 - 1098 - .composer-textarea { 1099 - width: 100%; 1100 - min-height: 120px; 1101 - padding: 16px; 1102 - background: var(--bg-secondary); 1103 - border: 1px solid var(--border); 1104 - border-radius: var(--radius-md); 1105 - color: var(--text-primary); 1106 - font-size: 1rem; 1107 - resize: vertical; 1108 - transition: all 0.15s ease; 1109 - } 1110 - 1111 - .composer-textarea:focus { 1112 - outline: none; 1113 - border-color: var(--accent); 1114 - box-shadow: 0 0 0 3px var(--accent-subtle); 1115 - } 1116 - 1117 - .composer-footer { 1118 - display: flex; 1119 - justify-content: space-between; 1120 - align-items: center; 1121 - margin-top: 12px; 1122 - } 1123 - 1124 - .composer-char-count { 1125 - font-size: 0.85rem; 1126 - color: var(--text-tertiary); 1127 - } 1128 - 1129 - .composer-char-count.warning { 1130 - color: var(--warning); 1131 - } 1132 - 1133 - .composer-char-count.error { 1134 - color: var(--error); 1135 - } 1136 - 1137 - .composer-add-quote { 1138 - width: 100%; 1139 - padding: 12px 16px; 1140 - margin-bottom: 12px; 1141 - background: var(--bg-tertiary); 1142 - border: 1px dashed var(--border); 1143 - border-radius: var(--radius-md); 1144 - color: var(--text-secondary); 1145 - font-size: 0.9rem; 1146 - cursor: pointer; 1147 - transition: all 0.15s ease; 1148 - } 1149 - 1150 - .composer-add-quote:hover { 1151 - border-color: var(--accent); 1152 - color: var(--accent); 1153 - background: var(--accent-subtle); 1154 - } 1155 - 1156 - .composer-quote-input-wrapper { 1157 - margin-bottom: 12px; 1158 - } 1159 - 1160 - .composer-quote-input { 1161 - width: 100%; 1162 - padding: 12px 16px; 1163 - background: linear-gradient( 1164 - 135deg, 1165 - rgba(79, 70, 229, 0.05), 1166 - rgba(168, 85, 247, 0.05) 1167 - ); 1168 - border: 1px solid var(--border); 1169 - border-left: 3px solid var(--accent); 1170 - border-radius: 0 var(--radius-md) var(--radius-md) 0; 1171 - color: var(--text-primary); 1172 - font-size: 0.95rem; 1173 - font-style: italic; 1174 - resize: vertical; 1175 - font-family: inherit; 1176 - transition: all 0.15s ease; 1177 - } 1178 - 1179 - .composer-quote-input:focus { 1180 - outline: none; 1181 - border-color: var(--accent); 1182 - } 1183 - 1184 - .composer-quote-input::placeholder { 1185 - color: var(--text-tertiary); 1186 - font-style: italic; 1187 - } 1188 - 1189 - .composer-quote-remove-btn { 1190 - margin-top: 8px; 1191 - padding: 6px 12px; 1192 - background: none; 1193 - border: none; 1194 - color: var(--text-tertiary); 1195 - font-size: 0.85rem; 1196 - cursor: pointer; 1197 - } 1198 - 1199 - .composer-quote-remove-btn:hover { 1200 - color: var(--error); 1201 - } 1202 - 1203 - @keyframes shimmer { 1204 - 0% { 1205 - background-position: -200% 0; 1206 - } 1207 - 1208 - 100% { 1209 - background-position: 200% 0; 1210 - } 1211 - } 1212 - 1213 - .skeleton { 1214 - background: linear-gradient( 1215 - 90deg, 1216 - var(--bg-tertiary) 25%, 1217 - var(--bg-hover) 50%, 1218 - var(--bg-tertiary) 75% 1219 - ); 1220 - background-size: 200% 100%; 1221 - animation: shimmer 1.5s infinite; 1222 - border-radius: var(--radius-sm); 1223 - } 1224 - 1225 - .skeleton-text { 1226 - height: 1em; 1227 - margin-bottom: 8px; 1228 - } 1229 - 1230 - .skeleton-text:last-child { 1231 - width: 60%; 1232 - } 1233 - 1234 - @media (max-width: 640px) { 1235 - .main-content { 1236 - padding: 16px 12px; 1237 - } 1238 - 1239 - .navbar-inner { 1240 - padding: 0 16px; 1241 - } 1242 - 1243 - .page-title { 1244 - font-size: 1.5rem; 1245 - } 1246 - 1247 - .url-input-container { 1248 - flex-direction: column; 1249 - } 1250 - 1251 - .profile-header { 1252 - flex-direction: column; 1253 - text-align: center; 1254 - } 1255 - 1256 - .profile-stats { 1257 - justify-content: center; 1258 - } 1259 - } 1260 - 1261 - .main { 1262 - flex: 1; 1263 - width: 100%; 1264 - } 1265 - 1266 - .page-container { 1267 - max-width: 680px; 1268 - margin: 0 auto; 1269 - padding: 24px 16px; 1270 - } 1271 - 1272 - .navbar-logo { 1273 - width: 32px; 1274 - height: 32px; 1275 - background: linear-gradient(135deg, var(--accent), #8b5cf6); 1276 - border-radius: var(--radius-sm); 1277 - display: flex; 1278 - align-items: center; 1279 - justify-content: center; 1280 - font-weight: 700; 1281 - font-size: 1rem; 1282 - color: white; 1283 - } 1284 - 1285 - .navbar-user { 1286 - display: flex; 1287 - align-items: center; 1288 - gap: 8px; 1289 - } 1290 - 1291 - .navbar-avatar { 1292 - width: 36px; 1293 - height: 36px; 1294 - border-radius: var(--radius-full); 1295 - background: linear-gradient(135deg, var(--accent), #a855f7); 1296 - display: flex; 1297 - align-items: center; 1298 - justify-content: center; 1299 - font-weight: 600; 1300 - font-size: 0.85rem; 1301 - color: white; 1302 - text-decoration: none; 1303 - } 1304 - 1305 - .btn-sm { 1306 - padding: 6px 12px; 1307 - font-size: 0.85rem; 1308 - } 1309 - 1310 - .composer-url { 1311 - font-size: 0.85rem; 1312 - color: var(--text-secondary); 1313 - word-break: break-all; 1314 - } 1315 - 1316 - .composer-quote { 1317 - position: relative; 1318 - padding: 12px 16px; 1319 - padding-right: 36px; 1320 - background: var(--bg-secondary); 1321 - border-left: 3px solid var(--accent); 1322 - border-radius: 0 var(--radius-sm) var(--radius-sm) 0; 1323 - margin-bottom: 16px; 1324 - font-style: italic; 1325 - color: var(--text-secondary); 1326 - } 1327 - 1328 - .composer-quote-remove { 1329 - position: absolute; 1330 - top: 8px; 1331 - right: 8px; 1332 - width: 24px; 1333 - height: 24px; 1334 - border-radius: var(--radius-full); 1335 - background: var(--bg-tertiary); 1336 - color: var(--text-secondary); 1337 - font-size: 1rem; 1338 - display: flex; 1339 - align-items: center; 1340 - justify-content: center; 1341 - } 1342 - 1343 - .composer-quote-remove:hover { 1344 - background: var(--bg-hover); 1345 - color: var(--text-primary); 1346 - } 1347 - 1348 - .composer-input { 1349 - width: 100%; 1350 - min-height: 120px; 1351 - padding: 16px; 1352 - background: var(--bg-secondary); 1353 - border: 1px solid var(--border); 1354 - border-radius: var(--radius-md); 1355 - color: var(--text-primary); 1356 - font-size: 1rem; 1357 - resize: vertical; 1358 - transition: all 0.15s ease; 1359 - } 1360 - 1361 - .composer-input:focus { 1362 - outline: none; 1363 - border-color: var(--accent); 1364 - box-shadow: 0 0 0 3px var(--accent-subtle); 1365 - } 1366 - 1367 - .composer-input::placeholder { 1368 - color: var(--text-tertiary); 1369 - } 1370 - 1371 - .composer-footer { 1372 - display: flex; 1373 - justify-content: space-between; 1374 - align-items: center; 1375 - margin-top: 12px; 1376 - } 1377 - 1378 - .composer-count { 1379 - font-size: 0.85rem; 1380 - color: var(--text-tertiary); 1381 - } 1382 - 1383 - .composer-actions { 1384 - display: flex; 1385 - gap: 8px; 1386 - } 1387 - 1388 - .composer-error { 1389 - margin-top: 12px; 1390 - padding: 12px; 1391 - background: rgba(239, 68, 68, 0.1); 1392 - border: 1px solid rgba(239, 68, 68, 0.3); 1393 - border-radius: var(--radius-md); 1394 - color: var(--error); 1395 - font-size: 0.9rem; 1396 - } 1397 - 1398 - .annotation-detail-page { 1399 - max-width: 680px; 1400 - margin: 0 auto; 1401 - padding: 24px 16px; 1402 - } 1403 - 1404 - .annotation-detail-header { 1405 - margin-bottom: 24px; 1406 - } 1407 - 1408 - .back-link { 1409 - color: var(--text-secondary); 1410 - text-decoration: none; 1411 - font-size: 0.9rem; 1412 - } 1413 - 1414 - .back-link:hover { 1415 - color: var(--accent); 1416 - } 1417 - 1418 - .replies-section { 1419 - margin-top: 32px; 1420 - } 1421 - 1422 - .replies-title { 1423 - font-size: 1.1rem; 1424 - font-weight: 600; 1425 - margin-bottom: 16px; 1426 - color: var(--text-primary); 1427 - } 1428 - 1429 - .reply-form { 1430 - margin-bottom: 24px; 1431 - } 1432 - 1433 - .reply-input { 1434 - width: 100%; 1435 - padding: 12px; 1436 - border: 1px solid var(--border); 1437 - border-radius: var(--radius-md); 1438 - font-size: 0.95rem; 1439 - resize: vertical; 1440 - margin-bottom: 12px; 1441 - font-family: inherit; 1442 - } 1443 - 1444 - .reply-input:focus { 1445 - outline: none; 1446 - border-color: var(--accent); 1447 - box-shadow: 0 0 0 3px var(--accent-subtle); 1448 - } 1449 - 1450 - .replies-list { 1451 - display: flex; 1452 - flex-direction: column; 1453 - gap: 12px; 1454 - } 1455 - 1456 - .reply-card { 1457 - padding: 16px; 1458 - background: var(--bg-secondary); 1459 - border-radius: var(--radius-md); 1460 - border: 1px solid var(--border); 1461 - } 1462 - 1463 - .reply-header { 1464 - display: flex; 1465 - align-items: center; 1466 - gap: 12px; 1467 - margin-bottom: 12px; 1468 - } 1469 - 1470 - .reply-avatar-link { 1471 - text-decoration: none; 1472 - } 1473 - 1474 - .reply-avatar { 1475 - width: 36px; 1476 - height: 36px; 1477 - min-width: 36px; 1478 - border-radius: var(--radius-full); 1479 - background: linear-gradient(135deg, var(--accent), #a855f7); 1480 - display: flex; 1481 - align-items: center; 1482 - justify-content: center; 1483 - font-weight: 600; 1484 - font-size: 0.85rem; 1485 - color: white; 1486 - overflow: hidden; 1487 - } 1488 - 1489 - .reply-avatar img { 1490 - width: 100%; 1491 - height: 100%; 1492 - object-fit: cover; 1493 - } 1494 - 1495 - .reply-meta { 1496 - flex: 1; 1497 - min-width: 0; 1498 - } 1499 - 1500 - .reply-author { 1501 - font-weight: 600; 1502 - color: var(--text-primary); 1503 - } 1504 - 1505 - .reply-handle { 1506 - font-size: 0.85rem; 1507 - color: var(--text-tertiary); 1508 - text-decoration: none; 1509 - margin-left: 6px; 1510 - } 1511 - 1512 - .reply-handle:hover { 1513 - color: var(--accent); 1514 - text-decoration: underline; 1515 - } 1516 - 1517 - .reply-time { 1518 - font-size: 0.85rem; 1519 - color: var(--text-tertiary); 1520 - white-space: nowrap; 1521 - } 1522 - 1523 - .reply-text { 1524 - color: var(--text-primary); 1525 - line-height: 1.5; 1526 - margin: 0; 1527 - } 1528 - 1529 - .replies-title { 1530 - display: flex; 1531 - align-items: center; 1532 - gap: 8px; 1533 - } 1534 - 1535 - .replies-title svg { 1536 - color: var(--accent); 1537 - } 1538 - 1539 - .replies-list-threaded { 1540 - display: flex; 1541 - flex-direction: column; 1542 - gap: 8px; 1543 - } 1544 - 1545 - .reply-card-threaded { 1546 - padding: 16px; 1547 - transition: background 0.15s ease; 1548 - } 1549 - 1550 - .reply-card-threaded .reply-header { 1551 - margin-bottom: 8px; 1552 - } 1553 - 1554 - .reply-card-threaded .reply-meta { 1555 - display: flex; 1556 - align-items: center; 1557 - gap: 6px; 1558 - flex-wrap: wrap; 1559 - } 1560 - 1561 - .reply-dot { 1562 - color: var(--text-tertiary); 1563 - font-size: 0.75rem; 1564 - } 1565 - 1566 - .reply-actions { 1567 - display: flex; 1568 - gap: 4px; 1569 - margin-left: auto; 1570 - } 1571 - 1572 - .reply-action-btn { 1573 - background: none; 1574 - border: none; 1575 - padding: 4px 8px; 1576 - color: var(--text-tertiary); 1577 - cursor: pointer; 1578 - border-radius: var(--radius-sm); 1579 - transition: all 0.15s ease; 1580 - display: flex; 1581 - align-items: center; 1582 - justify-content: center; 1583 - } 1584 - 1585 - .reply-action-btn:hover { 1586 - color: var(--accent); 1587 - background: var(--accent-subtle); 1588 - } 1589 - 1590 - .reply-action-delete:hover { 1591 - color: var(--error); 1592 - background: rgba(239, 68, 68, 0.1); 1593 - } 1594 - 1595 - .replying-to-banner { 1596 - display: flex; 1597 - align-items: center; 1598 - justify-content: space-between; 1599 - padding: 8px 12px; 1600 - margin-bottom: 12px; 1601 - background: var(--accent-subtle); 1602 - border-radius: var(--radius-sm); 1603 - font-size: 0.85rem; 1604 - color: var(--text-secondary); 1605 - } 1606 - 1607 - .cancel-reply { 1608 - background: none; 1609 - border: none; 1610 - font-size: 1.2rem; 1611 - color: var(--text-tertiary); 1612 - cursor: pointer; 1613 - padding: 0 4px; 1614 - line-height: 1; 1615 - } 1616 - 1617 - .cancel-reply:hover { 1618 - color: var(--text-primary); 1619 - } 1620 - 1621 - .reply-form.card { 1622 - padding: 16px; 1623 - margin-bottom: 16px; 1624 - } 1625 - 1626 - .reply-form-actions { 1627 - display: flex; 1628 - justify-content: flex-end; 1629 - } 1630 - 1631 - .inline-replies { 1632 - margin-top: 16px; 1633 - padding-top: 16px; 1634 - border-top: 1px solid var(--border); 1635 - display: flex; 1636 - flex-direction: column; 1637 - gap: 16px; 1638 - } 1639 - 1640 - .main-reply-composer { 1641 - margin-top: 16px; 1642 - background: var(--bg-secondary); 1643 - padding: 12px; 1644 - border-radius: var(--radius-md); 1645 - } 1646 - 1647 - .reply-input { 1648 - width: 100%; 1649 - min-height: 80px; 1650 - padding: 12px; 1651 - border: 1px solid var(--border); 1652 - border-radius: var(--radius-md); 1653 - background: var(--bg-card); 1654 - color: var(--text-primary); 1655 - font-family: inherit; 1656 - font-size: 0.95rem; 1657 - resize: vertical; 1658 - display: block; 1659 - } 1660 - 1661 - .reply-input:focus { 1662 - border-color: var(--accent); 1663 - outline: none; 1664 - } 1665 - 1666 - .reply-input.small { 1667 - min-height: 60px; 1668 - font-size: 0.9rem; 1669 - margin-bottom: 8px; 1670 - } 1671 - 1672 - .composer-actions { 1673 - display: flex; 1674 - justify-content: flex-end; 1675 - } 1676 - 1677 - .btn-block { 1678 - width: 100%; 1679 - text-align: left; 1680 - padding: 8px 12px; 1681 - color: var(--text-secondary); 1682 - background: var(--bg-tertiary); 1683 - border-radius: var(--radius-md); 1684 - margin-top: 8px; 1685 - font-size: 0.9rem; 1686 - cursor: pointer; 1687 - transition: all 0.2s; 1688 - } 1689 - 1690 - .btn-block:hover { 1691 - background: var(--border); 1692 - color: var(--text-primary); 1693 - } 1694 - 1695 - .annotation-action.active { 1696 - color: var(--accent); 1697 - } 1698 - 1699 - .new-page { 1700 - max-width: 600px; 1701 - margin: 0 auto; 1702 - display: flex; 1703 - flex-direction: column; 1704 - gap: 32px; 1705 - } 1706 - 1707 - .loading-spinner { 1708 - width: 32px; 1709 - height: 32px; 1710 - border: 3px solid var(--border); 1711 - border-top-color: var(--accent); 1712 - border-radius: 50%; 1713 - animation: spin 0.8s linear infinite; 1714 - margin: 60px auto; 1715 - } 1716 - 1717 - @keyframes spin { 1718 - to { 1719 - transform: rotate(360deg); 1720 - } 1721 - } 1722 - 1723 - .navbar { 1724 - position: sticky; 1725 - top: 0; 1726 - z-index: 1000; 1727 - background: rgba(12, 10, 20, 0.95); 1728 - backdrop-filter: blur(12px); 1729 - -webkit-backdrop-filter: blur(12px); 1730 - border-bottom: 1px solid var(--border); 1731 - } 1732 - 1733 - .navbar-inner { 1734 - max-width: 1200px; 1735 - margin: 0 auto; 1736 - padding: 12px 24px; 1737 - display: flex; 1738 - align-items: center; 1739 - justify-content: space-between; 1740 - gap: 24px; 1741 - } 1742 - 1743 - .navbar-brand { 1744 - display: flex; 1745 - align-items: center; 1746 - gap: 10px; 1747 - text-decoration: none; 1748 - flex-shrink: 0; 1749 - } 1750 - 1751 - .navbar-logo { 1752 - width: 32px; 1753 - height: 32px; 1754 - background: linear-gradient(135deg, var(--accent), #8b5cf6); 1755 - border-radius: 8px; 1756 - display: flex; 1757 - align-items: center; 1758 - justify-content: center; 1759 - font-weight: 700; 1760 - font-size: 1rem; 1761 - color: white; 1762 - } 1763 - 1764 - .navbar-title { 1765 - font-weight: 700; 1766 - font-size: 1.25rem; 1767 - color: var(--text-primary); 1768 - } 1769 - 1770 - .navbar-center { 1771 - display: flex; 1772 - align-items: center; 1773 - gap: 8px; 1774 - background: var(--bg-tertiary); 1775 - padding: 4px; 1776 - border-radius: var(--radius-lg); 1777 - } 1778 - 1779 - .navbar-link { 1780 - display: flex; 1781 - align-items: center; 1782 - gap: 6px; 1783 - padding: 8px 16px; 1784 - font-size: 0.9rem; 1785 - font-weight: 500; 1786 - color: var(--text-secondary); 1787 - text-decoration: none; 1788 - border-radius: var(--radius-md); 1789 - transition: all 0.15s ease; 1790 - } 1791 - 1792 - .navbar-link:hover { 1793 - color: var(--text-primary); 1794 - background: var(--bg-hover); 1795 - } 1796 - 1797 - .navbar-link.active { 1798 - color: var(--text-primary); 1799 - background: var(--bg-card); 1800 - box-shadow: var(--shadow-sm); 1801 - } 1802 - 1803 - .navbar-right { 1804 - display: flex; 1805 - align-items: center; 1806 - gap: 12px; 1807 - flex-shrink: 0; 1808 - } 1809 - 1810 - .navbar-icon-link { 1811 - display: flex; 1812 - align-items: center; 1813 - justify-content: center; 1814 - width: 36px; 1815 - height: 36px; 1816 - color: var(--text-tertiary); 1817 - border-radius: var(--radius-md); 1818 - transition: all 0.15s ease; 1819 - } 1820 - 1821 - .navbar-icon-link:hover { 1822 - color: var(--text-primary); 1823 - background: var(--bg-tertiary); 1824 - } 1825 - 1826 - .navbar-icon-link.active { 1827 - color: var(--accent); 1828 - background: var(--accent-subtle); 1829 - } 1830 - 1831 - .navbar-new-btn { 1832 - display: flex; 1833 - align-items: center; 1834 - gap: 6px; 1835 - padding: 8px 14px; 1836 - background: linear-gradient(135deg, var(--accent), #8b5cf6); 1837 - color: white; 1838 - font-size: 0.85rem; 1839 - font-weight: 600; 1840 - text-decoration: none; 1841 - border-radius: var(--radius-full); 1842 - transition: all 0.2s ease; 1843 - } 1844 - 1845 - .navbar-new-btn:hover { 1846 - transform: translateY(-1px); 1847 - box-shadow: 0 4px 12px rgba(79, 70, 229, 0.3); 1848 - color: white; 1849 - } 1850 - 1851 - .navbar-user-section { 1852 - display: flex; 1853 - align-items: center; 1854 - gap: 4px; 1855 - } 1856 - 1857 - .navbar-avatar { 1858 - width: 32px; 1859 - height: 32px; 1860 - border-radius: var(--radius-full); 1861 - background: linear-gradient(135deg, var(--accent), #a855f7); 1862 - display: flex; 1863 - align-items: center; 1864 - justify-content: center; 1865 - font-weight: 600; 1866 - font-size: 0.75rem; 1867 - color: white; 1868 - text-decoration: none; 1869 - transition: transform 0.15s ease; 1870 - } 1871 - 1872 - .navbar-avatar:hover { 1873 - transform: scale(1.05); 1874 - } 1875 - 1876 - .navbar-logout { 1877 - width: 24px; 1878 - height: 24px; 1879 - border: none; 1880 - background: transparent; 1881 - color: var(--text-tertiary); 1882 - font-size: 1.25rem; 1883 - cursor: pointer; 1884 - border-radius: var(--radius-sm); 1885 - transition: all 0.15s ease; 1886 - display: flex; 1887 - align-items: center; 1888 - justify-content: center; 1889 - } 1890 - 1891 - .navbar-logout:hover { 1892 - color: var(--error); 1893 - background: rgba(239, 68, 68, 0.1); 1894 - } 1895 - 1896 - .navbar-signin { 1897 - padding: 8px 16px; 1898 - background: var(--accent); 1899 - color: white; 1900 - font-size: 0.9rem; 1901 - font-weight: 500; 1902 - text-decoration: none; 1903 - border-radius: var(--radius-full); 1904 - transition: all 0.15s ease; 1905 - } 1906 - 1907 - .navbar-signin:hover { 1908 - background: var(--accent-hover); 1909 - color: white; 1910 - } 1911 - 1912 - .navbar-user-menu { 1913 - position: relative; 1914 - } 1915 - 1916 - .navbar-avatar-btn { 1917 - width: 36px; 1918 - height: 36px; 1919 - border-radius: var(--radius-full); 1920 - background: linear-gradient(135deg, var(--accent), #a855f7); 1921 - border: none; 1922 - cursor: pointer; 1923 - overflow: hidden; 1924 - display: flex; 1925 - align-items: center; 1926 - justify-content: center; 1927 - transition: 1928 - transform 0.15s ease, 1929 - box-shadow 0.15s ease; 1930 - } 1931 - 1932 - .navbar-avatar-btn:hover { 1933 - transform: scale(1.05); 1934 - box-shadow: 0 4px 12px rgba(79, 70, 229, 0.3); 1935 - } 1936 - 1937 - .navbar-avatar-img { 1938 - width: 100%; 1939 - height: 100%; 1940 - object-fit: cover; 1941 - } 1942 - 1943 - .navbar-avatar-text { 1944 - font-weight: 600; 1945 - font-size: 0.75rem; 1946 - color: white; 1947 - } 1948 - 1949 - .navbar-dropdown { 1950 - position: absolute; 1951 - top: calc(100% + 8px); 1952 - right: 0; 1953 - min-width: 200px; 1954 - background: var(--bg-card); 1955 - border: 1px solid var(--border); 1956 - border-radius: var(--radius-lg); 1957 - box-shadow: var(--shadow-lg); 1958 - overflow: hidden; 1959 - z-index: 1001; 1960 - animation: dropdownFade 0.15s ease; 1961 - } 1962 - 1963 - @keyframes dropdownFade { 1964 - from { 1965 - opacity: 0; 1966 - transform: translateY(-8px); 1967 - } 1968 - 1969 - to { 1970 - opacity: 1; 1971 - transform: translateY(0); 1972 - } 1973 - } 1974 - 1975 - .navbar-dropdown-header { 1976 - padding: 12px 16px; 1977 - background: var(--bg-secondary); 1978 - } 1979 - 1980 - .navbar-dropdown-name { 1981 - display: block; 1982 - font-weight: 600; 1983 - color: var(--text-primary); 1984 - font-size: 0.9rem; 1985 - } 1986 - 1987 - .navbar-dropdown-handle { 1988 - display: block; 1989 - color: var(--text-tertiary); 1990 - font-size: 0.8rem; 1991 - margin-top: 2px; 1992 - } 1993 - 1994 - .navbar-dropdown-divider { 1995 - height: 1px; 1996 - background: var(--border); 1997 - } 1998 - 1999 - .navbar-dropdown-item { 2000 - display: flex; 2001 - align-items: center; 2002 - gap: 10px; 2003 - width: 100%; 2004 - padding: 12px 16px; 2005 - font-size: 0.9rem; 2006 - color: var(--text-primary); 2007 - text-decoration: none; 2008 - background: none; 2009 - border: none; 2010 - cursor: pointer; 2011 - transition: background 0.15s ease; 2012 - text-align: left; 2013 - } 2014 - 2015 - .navbar-dropdown-item:hover { 2016 - background: var(--bg-tertiary); 2017 - } 2018 - 2019 - .navbar-dropdown-logout { 2020 - color: var(--error); 2021 - border-top: 1px solid var(--border); 2022 - } 2023 - 2024 - .navbar-dropdown-logout:hover { 2025 - background: rgba(239, 68, 68, 0.1); 2026 - } 2027 - 2028 - @media (max-width: 768px) { 2029 - .navbar-inner { 2030 - padding: 10px 16px; 2031 - } 2032 - 2033 - .navbar-title { 2034 - display: none; 2035 - } 2036 - 2037 - .navbar-center { 2038 - display: none; 2039 - } 2040 - 2041 - .navbar-new-btn span { 2042 - display: none; 2043 - } 2044 - 2045 - .navbar-new-btn { 2046 - width: 36px; 2047 - height: 36px; 2048 - padding: 0; 2049 - justify-content: center; 2050 - } 2051 - } 2052 - 2053 - .collections-list { 2054 - display: flex; 2055 - flex-direction: column; 2056 - gap: 2px; 2057 - background: var(--bg-card); 2058 - border: 1px solid var(--border); 2059 - border-radius: var(--radius-lg); 2060 - overflow: hidden; 2061 - } 2062 - 2063 - .collection-row { 2064 - display: flex; 2065 - align-items: center; 2066 - background: var(--bg-card); 2067 - transition: background 0.15s ease; 2068 - } 2069 - 2070 - .collection-row:not(:last-child) { 2071 - border-bottom: 1px solid var(--border); 2072 - } 2073 - 2074 - .collection-row:hover { 2075 - background: var(--bg-secondary); 2076 - } 2077 - 2078 - .collection-row-content { 2079 - flex: 1; 2080 - display: flex; 2081 - align-items: center; 2082 - gap: 16px; 2083 - padding: 16px 20px; 2084 - text-decoration: none; 2085 - min-width: 0; 2086 - } 2087 - 2088 - .collection-row-icon { 2089 - width: 44px; 2090 - height: 44px; 2091 - min-width: 44px; 2092 - display: flex; 2093 - align-items: center; 2094 - justify-content: center; 2095 - background: linear-gradient( 2096 - 135deg, 2097 - rgba(79, 70, 229, 0.1), 2098 - rgba(168, 85, 247, 0.15) 2099 - ); 2100 - color: var(--accent); 2101 - border-radius: var(--radius-md); 2102 - transition: all 0.2s ease; 2103 - } 2104 - 2105 - .collection-row:hover .collection-row-icon { 2106 - background: linear-gradient( 2107 - 135deg, 2108 - rgba(79, 70, 229, 0.15), 2109 - rgba(168, 85, 247, 0.2) 2110 - ); 2111 - transform: scale(1.05); 2112 - } 2113 - 2114 - .collection-row-info { 2115 - flex: 1; 2116 - min-width: 0; 2117 - } 2118 - 2119 - .collection-row-name { 2120 - font-size: 1rem; 2121 - font-weight: 600; 2122 - color: var(--text-primary); 2123 - margin: 0 0 2px 0; 2124 - white-space: nowrap; 2125 - overflow: hidden; 2126 - text-overflow: ellipsis; 2127 - } 2128 - 2129 - .collection-row:hover .collection-row-name { 2130 - color: var(--accent); 2131 - } 2132 - 2133 - .collection-row-desc { 2134 - font-size: 0.85rem; 2135 - color: var(--text-secondary); 2136 - margin: 0; 2137 - white-space: nowrap; 2138 - overflow: hidden; 2139 - text-overflow: ellipsis; 2140 - } 2141 - 2142 - .collection-row-arrow { 2143 - color: var(--text-tertiary); 2144 - opacity: 0; 2145 - transition: all 0.2s ease; 2146 - } 2147 - 2148 - .collection-row:hover .collection-row-arrow { 2149 - opacity: 1; 2150 - color: var(--accent); 2151 - transform: translateX(2px); 2152 - } 2153 - 2154 - .collection-row-edit { 2155 - padding: 10px; 2156 - margin-right: 12px; 2157 - color: var(--text-tertiary); 2158 - background: none; 2159 - border: none; 2160 - border-radius: var(--radius-sm); 2161 - cursor: pointer; 2162 - opacity: 0; 2163 - transition: all 0.15s ease; 2164 - } 2165 - 2166 - .collection-row:hover .collection-row-edit { 2167 - opacity: 1; 2168 - } 2169 - 2170 - .collection-row-edit:hover { 2171 - color: var(--text-primary); 2172 - background: var(--bg-tertiary); 2173 - } 2174 - 2175 - .back-link { 2176 - display: inline-flex; 2177 - align-items: center; 2178 - gap: 6px; 2179 - color: var(--text-tertiary); 2180 - font-size: 0.9rem; 2181 - font-weight: 500; 2182 - text-decoration: none; 2183 - margin-bottom: 24px; 2184 - transition: color 0.15s ease; 2185 - } 2186 - 2187 - .back-link:hover { 2188 - color: var(--accent); 2189 - } 2190 - 2191 - .collection-detail-header { 2192 - display: flex; 2193 - gap: 20px; 2194 - padding: 24px; 2195 - background: var(--bg-card); 2196 - border: 1px solid var(--border); 2197 - border-radius: var(--radius-lg); 2198 - margin-bottom: 32px; 2199 - position: relative; 2200 - } 2201 - 2202 - .collection-detail-icon { 2203 - width: 56px; 2204 - height: 56px; 2205 - min-width: 56px; 2206 - display: flex; 2207 - align-items: center; 2208 - justify-content: center; 2209 - background: linear-gradient( 2210 - 135deg, 2211 - rgba(79, 70, 229, 0.1), 2212 - rgba(168, 85, 247, 0.1) 2213 - ); 2214 - color: var(--accent); 2215 - border-radius: var(--radius-md); 2216 - } 2217 - 2218 - .collection-detail-info { 2219 - flex: 1; 2220 - min-width: 0; 2221 - } 2222 - 2223 - .collection-detail-visibility { 2224 - display: flex; 2225 - align-items: center; 2226 - gap: 6px; 2227 - font-size: 0.8rem; 2228 - font-weight: 600; 2229 - color: var(--accent); 2230 - text-transform: capitalize; 2231 - margin-bottom: 8px; 2232 - } 2233 - 2234 - .collection-detail-title { 2235 - font-size: 1.5rem; 2236 - font-weight: 700; 2237 - color: var(--text-primary); 2238 - margin-bottom: 8px; 2239 - line-height: 1.3; 2240 - } 2241 - 2242 - .collection-detail-desc { 2243 - color: var(--text-secondary); 2244 - font-size: 1rem; 2245 - line-height: 1.5; 2246 - margin-bottom: 12px; 2247 - max-width: 600px; 2248 - } 2249 - 2250 - .collection-detail-stats { 2251 - display: flex; 2252 - align-items: center; 2253 - gap: 8px; 2254 - font-size: 0.85rem; 2255 - color: var(--text-tertiary); 2256 - } 2257 - 2258 - .collection-detail-actions { 2259 - position: absolute; 2260 - top: 20px; 2261 - right: 20px; 2262 - display: flex; 2263 - align-items: center; 2264 - gap: 8px; 2265 - } 2266 - 2267 - .collection-detail-actions .share-menu-container { 2268 - display: flex; 2269 - align-items: center; 2270 - } 2271 - 2272 - .collection-detail-actions .annotation-action { 2273 - padding: 10px; 2274 - color: var(--text-tertiary); 2275 - background: none; 2276 - border: none; 2277 - border-radius: var(--radius-sm); 2278 - cursor: pointer; 2279 - transition: all 0.15s ease; 2280 - } 2281 - 2282 - .collection-detail-actions .annotation-action:hover { 2283 - color: var(--accent); 2284 - background: var(--bg-tertiary); 2285 - } 2286 - 2287 - .collection-detail-edit, 2288 - .collection-detail-delete { 2289 - padding: 10px; 2290 - color: var(--text-tertiary); 2291 - background: none; 2292 - border: none; 2293 - border-radius: var(--radius-sm); 2294 - cursor: pointer; 2295 - transition: all 0.15s ease; 2296 - } 2297 - 2298 - .collection-detail-edit:hover { 2299 - color: var(--accent); 2300 - background: var(--bg-tertiary); 2301 - } 2302 - 2303 - .collection-detail-delete:hover { 2304 - color: var(--error); 2305 - background: rgba(239, 68, 68, 0.1); 2306 - } 2307 - 2308 - .collection-item-wrapper { 2309 - position: relative; 2310 - } 2311 - 2312 - .collection-item-remove { 2313 - position: absolute; 2314 - top: 12px; 2315 - left: -40px; 2316 - z-index: 10; 2317 - padding: 8px; 2318 - background: var(--bg-card); 2319 - border: 1px solid var(--border); 2320 - border-radius: var(--radius-sm); 2321 - color: var(--text-tertiary); 2322 - cursor: pointer; 2323 - opacity: 0; 2324 - transition: all 0.15s ease; 2325 - } 2326 - 2327 - .collection-item-wrapper:hover .collection-item-remove { 2328 - opacity: 1; 2329 - } 2330 - 2331 - .collection-item-remove:hover { 2332 - color: var(--error); 2333 - border-color: var(--error); 2334 - background: rgba(239, 68, 68, 0.05); 2335 - } 2336 - 2337 - .modal-overlay { 2338 - position: fixed; 2339 - inset: 0; 2340 - background: rgba(0, 0, 0, 0.5); 2341 - display: flex; 2342 - align-items: center; 2343 - justify-content: center; 2344 - padding: 16px; 2345 - z-index: 50; 2346 - animation: fadeIn 0.2s ease-out; 2347 - } 2348 - 2349 - .modal-container { 2350 - background: var(--bg-secondary); 2351 - border-radius: var(--radius-lg); 2352 - width: 100%; 2353 - max-width: 28rem; 2354 - border: 1px solid var(--border); 2355 - box-shadow: var(--shadow-lg); 2356 - animation: zoomIn 0.2s ease-out; 2357 - } 2358 - 2359 - .modal-header { 2360 - display: flex; 2361 - align-items: center; 2362 - justify-content: space-between; 2363 - padding: 16px; 2364 - border-bottom: 1px solid var(--border); 2365 - } 2366 - 2367 - .modal-title { 2368 - font-size: 1.25rem; 2369 - font-weight: 700; 2370 - color: var(--text-primary); 2371 - } 2372 - 2373 - .modal-close-btn { 2374 - padding: 8px; 2375 - color: var(--text-tertiary); 2376 - border-radius: var(--radius-md); 2377 - transition: color 0.15s; 2378 - } 2379 - 2380 - .modal-close-btn:hover { 2381 - color: var(--text-primary); 2382 - background: var(--bg-hover); 2383 - } 2384 - 2385 - .modal-form { 2386 - padding: 16px; 2387 - display: flex; 2388 - flex-direction: column; 2389 - gap: 16px; 2390 - } 2391 - 2392 - .icon-picker-tabs { 2393 - display: flex; 2394 - gap: 4px; 2395 - margin-bottom: 12px; 2396 - } 2397 - 2398 - .icon-picker-tab { 2399 - flex: 1; 2400 - padding: 8px 12px; 2401 - background: var(--bg-primary); 2402 - border: 1px solid var(--border); 2403 - border-radius: var(--radius-md); 2404 - color: var(--text-secondary); 2405 - font-size: 0.85rem; 2406 - font-weight: 500; 2407 - cursor: pointer; 2408 - transition: all 0.15s ease; 2409 - } 2410 - 2411 - .icon-picker-tab:hover { 2412 - background: var(--bg-tertiary); 2413 - } 2414 - 2415 - .icon-picker-tab.active { 2416 - background: var(--accent); 2417 - border-color: var(--accent); 2418 - color: white; 2419 - } 2420 - 2421 - .emoji-picker-wrapper { 2422 - display: flex; 2423 - flex-direction: column; 2424 - gap: 10px; 2425 - } 2426 - 2427 - .emoji-custom-input input { 2428 - width: 100%; 2429 - } 2430 - 2431 - .emoji-picker, 2432 - .icon-picker { 2433 - display: flex; 2434 - flex-wrap: wrap; 2435 - gap: 4px; 2436 - max-height: 120px; 2437 - overflow-y: auto; 2438 - padding: 8px; 2439 - background: var(--bg-primary); 2440 - border: 1px solid var(--border); 2441 - border-radius: var(--radius-md); 2442 - } 2443 - 2444 - .emoji-option, 2445 - .icon-option { 2446 - width: 36px; 2447 - height: 36px; 2448 - display: flex; 2449 - align-items: center; 2450 - justify-content: center; 2451 - font-size: 1.2rem; 2452 - background: transparent; 2453 - border: 2px solid transparent; 2454 - border-radius: var(--radius-sm); 2455 - cursor: pointer; 2456 - transition: all 0.15s ease; 2457 - color: var(--text-secondary); 2458 - } 2459 - 2460 - .emoji-option:hover, 2461 - .icon-option:hover { 2462 - background: var(--bg-tertiary); 2463 - transform: scale(1.1); 2464 - color: var(--text-primary); 2465 - } 2466 - 2467 - .emoji-option.selected, 2468 - .icon-option.selected { 2469 - border-color: var(--accent); 2470 - background: var(--accent-subtle); 2471 - color: var(--accent); 2472 - } 2473 - 2474 - .form-group { 2475 - margin-bottom: 0; 2476 - } 2477 - 2478 - .form-label { 2479 - display: block; 2480 - font-size: 0.875rem; 2481 - font-weight: 500; 2482 - color: var(--text-secondary); 2483 - margin-bottom: 4px; 2484 - } 2485 - 2486 - .form-input, 2487 - .form-textarea, 2488 - .form-select { 2489 - width: 100%; 2490 - padding: 8px 12px; 2491 - background: var(--bg-primary); 2492 - border: 1px solid var(--border); 2493 - border-radius: var(--radius-md); 2494 - color: var(--text-primary); 2495 - transition: all 0.15s; 2496 - } 2497 - 2498 - .form-input:focus, 2499 - .form-textarea:focus, 2500 - .form-select:focus { 2501 - outline: none; 2502 - border-color: var(--accent); 2503 - box-shadow: 0 0 0 2px var(--accent-subtle); 2504 - } 2505 - 2506 - .form-textarea { 2507 - resize: none; 2508 - } 2509 - 2510 - .modal-actions { 2511 - display: flex; 2512 - justify-content: flex-end; 2513 - gap: 12px; 2514 - padding-top: 8px; 2515 - } 2516 - 2517 - @keyframes fadeIn { 2518 - from { 2519 - opacity: 0; 2520 - } 2521 - 2522 - to { 2523 - opacity: 1; 2524 - } 2525 - } 2526 - 2527 - @keyframes zoomIn { 2528 - from { 2529 - opacity: 0; 2530 - transform: scale(0.95); 2531 - } 2532 - 2533 - to { 2534 - opacity: 1; 2535 - transform: scale(1); 2536 - } 2537 - } 2538 - 2539 - .annotation-detail-page { 2540 - max-width: 680px; 2541 - margin: 0 auto; 2542 - padding: 24px 16px; 2543 - } 2544 - 2545 - .annotation-detail-header { 2546 - margin-bottom: 24px; 2547 - } 2548 - 2549 - .back-link { 2550 - display: inline-flex; 2551 - align-items: center; 2552 - gap: 8px; 2553 - color: var(--text-secondary); 2554 - font-size: 0.9rem; 2555 - transition: color 0.15s; 2556 - } 2557 - 2558 - .back-link:hover { 2559 - color: var(--text-primary); 2560 - } 2561 - 2562 - .text-secondary { 2563 - color: var(--text-secondary); 2564 - } 2565 - 2566 - .text-error { 2567 - color: var(--error); 2568 - } 2569 - 2570 - .text-center { 2571 - text-align: center; 2572 - } 2573 - 2574 - .flex { 2575 - display: flex; 2576 - } 2577 - 2578 - .items-center { 2579 - align-items: center; 2580 - } 2581 - 2582 - .justify-center { 2583 - justify-content: center; 2584 - } 2585 - 2586 - .justify-end { 2587 - justify-content: flex-end; 2588 - } 2589 - 2590 - .gap-2 { 2591 - gap: 8px; 2592 - } 2593 - 2594 - .gap-3 { 2595 - gap: 12px; 2596 - } 2597 - 2598 - .mt-3 { 2599 - margin-top: 12px; 2600 - } 2601 - 2602 - .mb-6 { 2603 - margin-bottom: 24px; 2604 - } 2605 - 2606 - .btn-text { 2607 - background: none; 2608 - border: none; 2609 - color: var(--text-secondary); 2610 - font-size: 0.9rem; 2611 - padding: 8px 12px; 2612 - cursor: pointer; 2613 - transition: color 0.15s; 2614 - } 2615 - 2616 - .btn-text:hover { 2617 - color: var(--text-primary); 2618 - } 2619 - 2620 - .btn-sm { 2621 - padding: 6px 12px; 2622 - font-size: 0.85rem; 2623 - } 2624 - 2625 - .annotation-edit-btn { 2626 - background: none; 2627 - border: none; 2628 - cursor: pointer; 2629 - padding: 6px 8px; 2630 - color: var(--text-tertiary); 2631 - border-radius: var(--radius-sm); 2632 - transition: all 0.15s ease; 2633 - } 2634 - 2635 - .annotation-edit-btn:hover { 2636 - color: var(--accent); 2637 - background: var(--accent-subtle); 2638 - } 2639 - 2640 - .spinner { 2641 - width: 32px; 2642 - height: 32px; 2643 - border: 3px solid var(--border); 2644 - border-top-color: var(--accent); 2645 - border-radius: 50%; 2646 - animation: spin 0.8s linear infinite; 2647 - } 2648 - 2649 - .spinner-sm { 2650 - width: 16px; 2651 - height: 16px; 2652 - border-width: 2px; 2653 - } 2654 - 2655 - @keyframes spin { 2656 - to { 2657 - transform: rotate(360deg); 2658 - } 2659 - } 2660 - 2661 - .collection-list-item { 2662 - width: 100%; 2663 - text-align: left; 2664 - padding: 12px 16px; 2665 - border-radius: var(--radius-md); 2666 - background: var(--bg-primary); 2667 - border: 1px solid transparent; 2668 - color: var(--text-primary); 2669 - transition: all 0.15s ease; 2670 - display: flex; 2671 - align-items: center; 2672 - justify-content: space-between; 2673 - cursor: pointer; 2674 - } 2675 - 2676 - .collection-list-item:hover { 2677 - background: var(--bg-hover); 2678 - border-color: var(--border); 2679 - } 2680 - 2681 - .collection-list-item:hover .collection-list-item-icon { 2682 - opacity: 1; 2683 - } 2684 - 2685 - .collection-list-item:disabled { 2686 - opacity: 0.6; 2687 - cursor: not-allowed; 2688 - } 2689 - 2690 - .item-delete-overlay { 2691 - position: absolute; 2692 - top: 16px; 2693 - right: 16px; 2694 - z-index: 10; 2695 - opacity: 0; 2696 - transition: opacity 0.15s ease; 2697 - } 2698 - 2699 - .card:hover .item-delete-overlay, 2700 - div:hover > .item-delete-overlay { 2701 - opacity: 1; 2702 - } 2703 - 2704 - .btn-icon-danger { 2705 - padding: 8px; 2706 - background: var(--error); 2707 - color: white; 2708 - border: none; 2709 - border-radius: var(--radius-md); 2710 - cursor: pointer; 2711 - box-shadow: var(--shadow-md); 2712 - transition: all 0.15s ease; 2713 - display: flex; 2714 - align-items: center; 2715 - justify-content: center; 2716 - } 2717 - 2718 - .btn-icon-danger:hover { 2719 - background: #dc2626; 2720 - transform: scale(1.05); 2721 - } 2722 - 2723 - .action-buttons { 2724 - display: flex; 2725 - gap: 8px; 2726 - } 2727 - 2728 - .action-buttons-end { 2729 - display: flex; 2730 - justify-content: flex-end; 2731 - gap: 8px; 2732 - } 2733 - 2734 - .filter-tab { 2735 - padding: 8px 16px; 2736 - font-size: 0.9rem; 2737 - font-weight: 500; 2738 - color: var(--text-secondary); 2739 - background: transparent; 2740 - border: none; 2741 - border-radius: var(--radius-md); 2742 - cursor: pointer; 2743 - transition: all 0.15s ease; 2744 - } 2745 - 2746 - .filter-tab:hover { 2747 - color: var(--text-primary); 2748 - background: var(--bg-hover); 2749 - } 2750 - 2751 - .filter-tab.active { 2752 - color: var(--text-primary); 2753 - background: var(--bg-card); 2754 - box-shadow: var(--shadow-sm); 2755 - } 2756 - 2757 - .inline-reply { 2758 - padding: 12px 16px; 2759 - border-bottom: 1px solid var(--border); 2760 - } 2761 - 2762 - .inline-reply:last-child { 2763 - border-bottom: none; 2764 - } 2765 - 2766 - .inline-reply-avatar { 2767 - width: 28px; 2768 - height: 28px; 2769 - min-width: 28px; 2770 - border-radius: var(--radius-full); 2771 - background: linear-gradient(135deg, var(--accent), #a855f7); 2772 - display: flex; 2773 - align-items: center; 2774 - justify-content: center; 2775 - font-weight: 600; 2776 - font-size: 0.7rem; 2777 - color: white; 2778 - overflow: hidden; 2779 - } 2780 - 2781 - .inline-reply-avatar img, 2782 - .inline-reply-avatar-placeholder { 2783 - width: 100%; 2784 - height: 100%; 2785 - object-fit: cover; 2786 - } 2787 - 2788 - .inline-reply-avatar-placeholder { 2789 - display: flex; 2790 - align-items: center; 2791 - justify-content: center; 2792 - font-weight: 600; 2793 - font-size: 0.7rem; 2794 - color: white; 2795 - } 2796 - 2797 - .inline-reply-content { 2798 - flex: 1; 2799 - min-width: 0; 2800 - } 2801 - 2802 - .inline-reply-header { 2803 - display: flex; 2804 - align-items: center; 2805 - gap: 8px; 2806 - margin-bottom: 4px; 2807 - } 2808 - 2809 - .inline-reply-author { 2810 - font-weight: 600; 2811 - font-size: 0.85rem; 2812 - color: var(--text-primary); 2813 - } 2814 - 2815 - .inline-reply-handle { 2816 - color: var(--text-tertiary); 2817 - font-size: 0.8rem; 2818 - text-decoration: none; 2819 - } 2820 - 2821 - .inline-reply-time { 2822 - color: var(--text-tertiary); 2823 - font-size: 0.75rem; 2824 - margin-left: auto; 2825 - } 2826 - 2827 - .inline-reply-text { 2828 - font-size: 0.9rem; 2829 - color: var(--text-primary); 2830 - line-height: 1.5; 2831 - } 2832 - 2833 - .inline-reply-action { 2834 - display: flex; 2835 - align-items: center; 2836 - gap: 4px; 2837 - padding: 4px 8px; 2838 - font-size: 0.8rem; 2839 - color: var(--text-tertiary); 2840 - background: none; 2841 - border: none; 2842 - border-radius: var(--radius-sm); 2843 - cursor: pointer; 2844 - transition: all 0.15s ease; 2845 - } 2846 - 2847 - .inline-reply-action:hover { 2848 - color: var(--text-secondary); 2849 - background: var(--bg-hover); 2850 - } 2851 - 2852 - .inline-reply-composer { 2853 - display: flex; 2854 - align-items: flex-start; 2855 - gap: 12px; 2856 - padding: 12px 16px; 2857 - } 2858 - 2859 - .history-panel { 2860 - background: var(--bg-tertiary); 2861 - border: 1px solid var(--border); 2862 - border-radius: var(--radius-md); 2863 - padding: 1rem; 2864 - margin-bottom: 1rem; 2865 - font-size: 0.9rem; 2866 - animation: fadeIn 0.2s ease-out; 2867 - } 2868 - 2869 - .history-header { 2870 - display: flex; 2871 - justify-content: space-between; 2872 - align-items: center; 2873 - margin-bottom: 1rem; 2874 - padding-bottom: 0.5rem; 2875 - border-bottom: 1px solid var(--border); 2876 - } 2877 - 2878 - .history-title { 2879 - font-weight: 600; 2880 - text-transform: uppercase; 2881 - letter-spacing: 0.05em; 2882 - font-size: 0.75rem; 2883 - color: var(--text-secondary); 2884 - } 2885 - 2886 - .history-list { 2887 - list-style: none; 2888 - display: flex; 2889 - flex-direction: column; 2890 - gap: 1rem; 2891 - } 2892 - 2893 - .history-item { 2894 - position: relative; 2895 - padding-left: 1rem; 2896 - border-left: 2px solid var(--border); 2897 - } 2898 - 2899 - .history-date { 2900 - font-size: 0.75rem; 2901 - color: var(--text-tertiary); 2902 - margin-bottom: 0.25rem; 2903 - } 2904 - 2905 - .history-content { 2906 - color: var(--text-secondary); 2907 - white-space: pre-wrap; 2908 - } 2909 - 2910 - .history-close-btn { 2911 - color: var(--text-tertiary); 2912 - padding: 4px; 2913 - border-radius: var(--radius-sm); 2914 - transition: all 0.2s; 2915 - display: flex; 2916 - align-items: center; 2917 - justify-content: center; 2918 - } 2919 - 2920 - .history-close-btn:hover { 2921 - background: var(--bg-hover); 2922 - color: var(--text-primary); 2923 - } 2924 - 2925 - .history-status { 2926 - text-align: center; 2927 - color: var(--text-tertiary); 2928 - font-style: italic; 2929 - padding: 1rem; 2930 - } 2931 - 2932 - .bookmark-card { 2933 - display: flex; 2934 - flex-direction: column; 2935 - gap: 12px; 2936 - } 2937 - 2938 - .bookmark-preview { 2939 - display: flex; 2940 - align-items: stretch; 2941 - gap: 16px; 2942 - padding: 14px 16px; 2943 - background: var(--bg-secondary); 2944 - border: 1px solid var(--border); 2945 - border-radius: var(--radius-md); 2946 - text-decoration: none; 2947 - transition: all 0.2s ease; 2948 - } 2949 - 2950 - .bookmark-preview:hover { 2951 - background: var(--bg-tertiary); 2952 - border-color: var(--accent-subtle); 2953 - transform: translateY(-1px); 2954 - } 2955 - 2956 - .bookmark-preview-content { 2957 - flex: 1; 2958 - min-width: 0; 2959 - display: flex; 2960 - flex-direction: column; 2961 - gap: 6px; 2962 - } 2963 - 2964 - .bookmark-preview-site { 2965 - display: flex; 2966 - align-items: center; 2967 - gap: 6px; 2968 - font-size: 0.75rem; 2969 - font-weight: 600; 2970 - color: var(--accent); 2971 - text-transform: uppercase; 2972 - letter-spacing: 0.03em; 2973 - } 2974 - 2975 - .bookmark-preview-title { 2976 - font-size: 1rem; 2977 - font-weight: 600; 2978 - line-height: 1.4; 2979 - color: var(--text-primary); 2980 - margin: 0; 2981 - display: -webkit-box; 2982 - -webkit-line-clamp: 2; 2983 - line-clamp: 2; 2984 - -webkit-box-orient: vertical; 2985 - overflow: hidden; 2986 - } 2987 - 2988 - .bookmark-preview-desc { 2989 - font-size: 0.875rem; 2990 - color: var(--text-secondary); 2991 - line-height: 1.5; 2992 - margin: 0; 2993 - display: -webkit-box; 2994 - -webkit-line-clamp: 2; 2995 - line-clamp: 2; 2996 - -webkit-box-orient: vertical; 2997 - overflow: hidden; 2998 - } 2999 - 3000 - .bookmark-preview-arrow { 3001 - display: flex; 3002 - align-items: center; 3003 - justify-content: center; 3004 - color: var(--text-tertiary); 3005 - padding: 0 4px; 3006 - transition: all 0.2s ease; 3007 - } 3008 - 3009 - .bookmark-preview:hover .bookmark-preview-arrow { 3010 - color: var(--accent); 3011 - transform: translateX(2px); 3012 - } 3013 - 3014 - .navbar-logo-img { 3015 - width: 24px; 3016 - height: 24px; 3017 - object-fit: contain; 3018 - } 3019 - 3020 - .login-logo-img { 3021 - width: 80px; 3022 - height: 80px; 3023 - margin-bottom: 24px; 3024 - object-fit: contain; 3025 - } 3026 - 3027 - .legal-content { 3028 - max-width: 800px; 3029 - margin: 0 auto; 3030 - padding: 20px; 3031 - } 3032 - 3033 - .legal-content h1 { 3034 - font-size: 2rem; 3035 - margin-bottom: 8px; 3036 - color: var(--text-primary); 3037 - } 3038 - 3039 - .legal-content h2 { 3040 - font-size: 1.4rem; 3041 - margin-top: 32px; 3042 - margin-bottom: 12px; 3043 - color: var(--text-primary); 3044 - } 3045 - 3046 - .legal-content h3 { 3047 - font-size: 1.1rem; 3048 - margin-top: 20px; 3049 - margin-bottom: 8px; 3050 - color: var(--text-primary); 3051 - } 3052 - 3053 - .legal-content p { 3054 - color: var(--text-secondary); 3055 - line-height: 1.7; 3056 - margin-bottom: 12px; 3057 - } 3058 - 3059 - .legal-content ul { 3060 - color: var(--text-secondary); 3061 - line-height: 1.7; 3062 - margin-left: 24px; 3063 - margin-bottom: 12px; 3064 - } 3065 - 3066 - .legal-content li { 3067 - margin-bottom: 6px; 3068 - } 3069 - 3070 - .legal-content a { 3071 - color: var(--accent); 3072 - text-decoration: none; 3073 - } 3074 - 3075 - .legal-content a:hover { 3076 - text-decoration: underline; 3077 - } 3078 - 3079 - .legal-content section { 3080 - margin-bottom: 24px; 3081 - } 3082 - 3083 - .input { 3084 - width: 100%; 3085 - padding: 12px 14px; 3086 - font-size: 0.95rem; 3087 - color: var(--text-primary); 3088 - background: var(--bg-secondary); 3089 - border: 1px solid var(--border); 3090 - border-radius: var(--radius-md); 3091 - outline: none; 3092 - transition: all 0.15s ease; 3093 - } 3094 - 3095 - .input:focus { 3096 - border-color: var(--accent); 3097 - box-shadow: 0 0 0 3px var(--accent-subtle); 3098 - } 3099 - 3100 - .input::placeholder { 3101 - color: var(--text-tertiary); 3102 - } 3103 - 3104 - .notifications-page { 3105 - max-width: 680px; 3106 - margin: 0 auto; 3107 - } 3108 - 3109 - .notifications-list { 3110 - display: flex; 3111 - flex-direction: column; 3112 - gap: 12px; 3113 - } 3114 - 3115 - .notification-item { 3116 - display: flex; 3117 - gap: 16px; 3118 - align-items: flex-start; 3119 - text-decoration: none; 3120 - color: inherit; 3121 - } 3122 - 3123 - .notification-item:hover { 3124 - background: var(--bg-hover); 3125 - } 3126 - 3127 - .notification-icon { 3128 - width: 36px; 3129 - height: 36px; 3130 - border-radius: var(--radius-full); 3131 - display: flex; 3132 - align-items: center; 3133 - justify-content: center; 3134 - background: var(--bg-tertiary); 3135 - color: var(--text-secondary); 3136 - flex-shrink: 0; 3137 - } 3138 - 3139 - .notification-icon[data-type="like"] { 3140 - color: #ef4444; 3141 - background: rgba(239, 68, 68, 0.1); 3142 - } 3143 - 3144 - .notification-icon[data-type="reply"] { 3145 - color: #3b82f6; 3146 - background: rgba(59, 130, 246, 0.1); 3147 - } 3148 - 3149 - .notification-content { 3150 - flex: 1; 3151 - min-width: 0; 3152 - } 3153 - 3154 - .notification-text { 3155 - font-size: 0.95rem; 3156 - margin-bottom: 4px; 3157 - line-height: 1.4; 3158 - color: var(--text-primary); 3159 - } 3160 - 3161 - .notification-text strong { 3162 - font-weight: 600; 3163 - } 3164 - 3165 - .notification-time { 3166 - font-size: 0.85rem; 3167 - color: var(--text-tertiary); 3168 - } 3169 - 3170 - .notification-link { 3171 - position: relative; 3172 - } 3173 - 3174 - .notification-badge { 3175 - position: absolute; 3176 - top: -2px; 3177 - right: -2px; 3178 - background: var(--error); 3179 - color: white; 3180 - font-size: 0.7rem; 3181 - font-weight: 700; 3182 - min-width: 16px; 3183 - height: 16px; 3184 - border-radius: var(--radius-full); 3185 - display: flex; 3186 - align-items: center; 3187 - justify-content: center; 3188 - padding: 0 4px; 3189 - border: 2px solid var(--bg-primary); 3190 - } 1 + @import "./css/layout.css"; 2 + @import "./css/base.css"; 3 + @import "./css/buttons.css"; 4 + @import "./css/buttons.css"; 5 + @import "./css/feed.css"; 6 + @import "./css/profile.css"; 7 + @import "./css/login.css"; 8 + @import "./css/annotations.css"; 9 + @import "./css/collections.css"; 10 + @import "./css/modals.css"; 11 + @import "./css/notifications.css"; 12 + @import "./css/skeleton.css"; 13 + @import "./css/utilities.css";
+121 -62
web/src/pages/AnnotationDetail.jsx
··· 1 1 import { useState, useEffect } from "react"; 2 - import { useParams, Link } from "react-router-dom"; 3 - import AnnotationCard from "../components/AnnotationCard"; 2 + import { useParams, Link, useLocation } from "react-router-dom"; 3 + import AnnotationCard, { HighlightCard } from "../components/AnnotationCard"; 4 + import BookmarkCard from "../components/BookmarkCard"; 4 5 import ReplyList from "../components/ReplyList"; 5 6 import { 6 7 getAnnotation, 7 8 getReplies, 8 9 createReply, 9 10 deleteReply, 11 + resolveHandle, 12 + normalizeAnnotation, 10 13 } from "../api/client"; 11 14 import { useAuth } from "../context/AuthContext"; 12 15 import { MessageSquare } from "lucide-react"; 13 16 14 17 export default function AnnotationDetail() { 15 - const { uri, did, rkey } = useParams(); 18 + const { uri, did, rkey, handle, type } = useParams(); 19 + const location = useLocation(); 16 20 const { isAuthenticated, user } = useAuth(); 17 21 const [annotation, setAnnotation] = useState(null); 18 22 const [replies, setReplies] = useState([]); ··· 23 27 const [posting, setPosting] = useState(false); 24 28 const [replyingTo, setReplyingTo] = useState(null); 25 29 26 - const annotationUri = uri || `at://${did}/at.margin.annotation/${rkey}`; 30 + const [targetUri, setTargetUri] = useState(uri); 31 + 32 + useEffect(() => { 33 + async function resolve() { 34 + if (uri) { 35 + setTargetUri(uri); 36 + return; 37 + } 38 + 39 + if (handle && rkey) { 40 + let collection = "at.margin.annotation"; 41 + if (type === "highlight") collection = "at.margin.highlight"; 42 + if (type === "bookmark") collection = "at.margin.bookmark"; 43 + 44 + try { 45 + const resolvedDid = await resolveHandle(handle); 46 + if (resolvedDid) { 47 + setTargetUri(`at://${resolvedDid}/${collection}/${rkey}`); 48 + } 49 + } catch (e) { 50 + console.error("Failed to resolve handle:", e); 51 + } 52 + } else if (did && rkey) { 53 + setTargetUri(`at://${did}/at.margin.annotation/${rkey}`); 54 + } else { 55 + const pathParts = location.pathname.split("/"); 56 + const atIndex = pathParts.indexOf("at"); 57 + if ( 58 + atIndex !== -1 && 59 + pathParts[atIndex + 1] && 60 + pathParts[atIndex + 2] 61 + ) { 62 + setTargetUri( 63 + `at://${pathParts[atIndex + 1]}/at.margin.annotation/${pathParts[atIndex + 2]}`, 64 + ); 65 + } 66 + } 67 + } 68 + resolve(); 69 + }, [uri, did, rkey, handle, type, location.pathname]); 27 70 28 71 const refreshReplies = async () => { 29 - const repliesData = await getReplies(annotationUri); 72 + if (!targetUri) return; 73 + const repliesData = await getReplies(targetUri); 30 74 setReplies(repliesData.items || []); 31 75 }; 32 76 33 77 useEffect(() => { 34 78 async function fetchData() { 79 + if (!targetUri) return; 80 + 35 81 try { 36 82 setLoading(true); 37 83 const [annData, repliesData] = await Promise.all([ 38 - getAnnotation(annotationUri), 39 - getReplies(annotationUri).catch(() => ({ items: [] })), 84 + getAnnotation(targetUri), 85 + getReplies(targetUri).catch(() => ({ items: [] })), 40 86 ]); 41 - setAnnotation(annData); 87 + setAnnotation(normalizeAnnotation(annData)); 42 88 setReplies(repliesData.items || []); 43 89 } catch (err) { 44 90 setError(err.message); ··· 47 93 } 48 94 } 49 95 fetchData(); 50 - }, [annotationUri]); 96 + }, [targetUri]); 51 97 52 98 const handleReply = async (e) => { 53 99 if (e) e.preventDefault(); ··· 57 103 setPosting(true); 58 104 const parentUri = replyingTo 59 105 ? replyingTo.id || replyingTo.uri 60 - : annotationUri; 106 + : targetUri; 61 107 const parentCid = replyingTo 62 108 ? replyingTo.cid || "" 63 109 : annotation?.cid || ""; ··· 65 111 await createReply({ 66 112 parentUri, 67 113 parentCid, 68 - rootUri: annotationUri, 114 + rootUri: targetUri, 69 115 rootCid: annotation?.cid || "", 70 116 text: replyText, 71 117 }); ··· 130 176 </Link> 131 177 </div> 132 178 133 - <AnnotationCard annotation={annotation} /> 179 + {annotation.type === "Highlight" ? ( 180 + <HighlightCard 181 + highlight={annotation} 182 + onDelete={() => (window.location.href = "/")} 183 + /> 184 + ) : annotation.type === "Bookmark" ? ( 185 + <BookmarkCard 186 + bookmark={annotation} 187 + onDelete={() => (window.location.href = "/")} 188 + /> 189 + ) : ( 190 + <AnnotationCard annotation={annotation} /> 191 + )} 134 192 135 - {} 136 - <div className="replies-section"> 137 - <h3 className="replies-title"> 138 - <MessageSquare size={18} /> 139 - Replies ({replies.length}) 140 - </h3> 193 + {annotation.type !== "Bookmark" && annotation.type !== "Highlight" && ( 194 + <div className="replies-section"> 195 + <h3 className="replies-title"> 196 + <MessageSquare size={18} /> 197 + Replies ({replies.length}) 198 + </h3> 141 199 142 - {isAuthenticated && ( 143 - <div className="reply-form card"> 144 - {replyingTo && ( 145 - <div className="replying-to-banner"> 146 - <span> 147 - Replying to @ 148 - {(replyingTo.creator || replyingTo.author)?.handle || 149 - "unknown"} 150 - </span> 200 + {isAuthenticated && ( 201 + <div className="reply-form card"> 202 + {replyingTo && ( 203 + <div className="replying-to-banner"> 204 + <span> 205 + Replying to @ 206 + {(replyingTo.creator || replyingTo.author)?.handle || 207 + "unknown"} 208 + </span> 209 + <button 210 + onClick={() => setReplyingTo(null)} 211 + className="cancel-reply" 212 + > 213 + ร— 214 + </button> 215 + </div> 216 + )} 217 + <textarea 218 + value={replyText} 219 + onChange={(e) => setReplyText(e.target.value)} 220 + placeholder={ 221 + replyingTo 222 + ? `Reply to @${(replyingTo.creator || replyingTo.author)?.handle}...` 223 + : "Write a reply..." 224 + } 225 + className="reply-input" 226 + rows={3} 227 + disabled={posting} 228 + /> 229 + <div className="reply-form-actions"> 151 230 <button 152 - onClick={() => setReplyingTo(null)} 153 - className="cancel-reply" 231 + className="btn btn-primary" 232 + disabled={posting || !replyText.trim()} 233 + onClick={() => handleReply()} 154 234 > 155 - ร— 235 + {posting ? "Posting..." : "Reply"} 156 236 </button> 157 237 </div> 158 - )} 159 - <textarea 160 - value={replyText} 161 - onChange={(e) => setReplyText(e.target.value)} 162 - placeholder={ 163 - replyingTo 164 - ? `Reply to @${(replyingTo.creator || replyingTo.author)?.handle}...` 165 - : "Write a reply..." 166 - } 167 - className="reply-input" 168 - rows={3} 169 - disabled={posting} 170 - /> 171 - <div className="reply-form-actions"> 172 - <button 173 - className="btn btn-primary" 174 - disabled={posting || !replyText.trim()} 175 - onClick={() => handleReply()} 176 - > 177 - {posting ? "Posting..." : "Reply"} 178 - </button> 179 238 </div> 180 - </div> 181 - )} 239 + )} 182 240 183 - <ReplyList 184 - replies={replies} 185 - rootUri={annotationUri} 186 - user={user} 187 - onReply={(reply) => setReplyingTo(reply)} 188 - onDelete={handleDeleteReply} 189 - isInline={false} 190 - /> 191 - </div> 241 + <ReplyList 242 + replies={replies} 243 + rootUri={targetUri} 244 + user={user} 245 + onReply={(reply) => setReplyingTo(reply)} 246 + onDelete={handleDeleteReply} 247 + isInline={false} 248 + /> 249 + </div> 250 + )} 192 251 </div> 193 252 ); 194 253 }
+7 -7
web/src/pages/Bookmarks.jsx
··· 1 - import { useState, useEffect } from "react"; 1 + import { useState, useEffect, useCallback } from "react"; 2 2 import { Link } from "react-router-dom"; 3 3 import { Plus } from "lucide-react"; 4 4 import { useAuth } from "../context/AuthContext"; ··· 22 22 const [submitting, setSubmitting] = useState(false); 23 23 const [fetchingTitle, setFetchingTitle] = useState(false); 24 24 25 - const loadBookmarks = async () => { 25 + const loadBookmarks = useCallback(async () => { 26 26 if (!user?.did) return; 27 27 28 28 try { ··· 35 35 } finally { 36 36 setLoadingBookmarks(false); 37 37 } 38 - }; 38 + }, [user]); 39 39 40 40 useEffect(() => { 41 41 if (isAuthenticated && user) { 42 42 loadBookmarks(); 43 43 } 44 - }, [isAuthenticated, user]); 44 + }, [isAuthenticated, user, loadBookmarks]); 45 45 46 46 const handleDelete = async (uri) => { 47 47 if (!confirm("Delete this bookmark?")) return; ··· 133 133 > 134 134 <div> 135 135 <h1 className="page-title">My Bookmarks</h1> 136 - <p className="page-description">Pages you've saved for later</p> 136 + <p className="page-description">Pages you&apos;ve saved for later</p> 137 137 </div> 138 138 <button 139 139 onClick={() => setShowAddForm(!showAddForm)} ··· 274 274 </div> 275 275 <h3 className="empty-state-title">No bookmarks yet</h3> 276 276 <p className="empty-state-text"> 277 - Click "Add Bookmark" above to save a page, or use the browser 278 - extension. 277 + Click &quot;Add Bookmark&quot; above to save a page, or use the 278 + browser extension. 279 279 </p> 280 280 </div> 281 281 ) : (
+55 -42
web/src/pages/CollectionDetail.jsx
··· 1 - import { useState, useEffect } from "react"; 1 + import { useState, useEffect, useCallback } from "react"; 2 2 import { useParams, useNavigate, Link, useLocation } from "react-router-dom"; 3 3 import { ArrowLeft, Edit2, Trash2, Plus } from "lucide-react"; 4 4 import { ··· 6 6 getCollectionItems, 7 7 removeItemFromCollection, 8 8 deleteCollection, 9 + resolveHandle, 9 10 } from "../api/client"; 10 11 import { useAuth } from "../context/AuthContext"; 11 12 import CollectionModal from "../components/CollectionModal"; ··· 15 16 import ShareMenu from "../components/ShareMenu"; 16 17 17 18 export default function CollectionDetail() { 18 - const { rkey, "*": wildcardPath } = useParams(); 19 + const { rkey, handle, "*": wildcardPath } = useParams(); 19 20 const location = useLocation(); 20 21 const navigate = useNavigate(); 21 22 const { user } = useAuth(); ··· 27 28 const [isEditModalOpen, setIsEditModalOpen] = useState(false); 28 29 29 30 const searchParams = new URLSearchParams(location.search); 30 - const authorDid = searchParams.get("author") || user?.did; 31 + const paramAuthorDid = searchParams.get("author"); 32 + 33 + const isOwner = 34 + user?.did && 35 + (collection?.creator?.did === user.did || paramAuthorDid === user.did); 36 + 37 + const fetchContext = useCallback(async () => { 38 + try { 39 + setLoading(true); 40 + 41 + let targetUri = null; 42 + let targetDid = paramAuthorDid || user?.did; 43 + 44 + if (handle && rkey) { 45 + try { 46 + targetDid = await resolveHandle(handle); 47 + targetUri = `at://${targetDid}/at.margin.collection/${rkey}`; 48 + } catch (e) { 49 + console.error("Failed to resolve handle", e); 50 + } 51 + } else if (wildcardPath) { 52 + targetUri = decodeURIComponent(wildcardPath); 53 + } else if (rkey && targetDid) { 54 + targetUri = `at://${targetDid}/at.margin.collection/${rkey}`; 55 + } 31 56 32 - const getCollectionUri = () => { 33 - if (wildcardPath) { 34 - return decodeURIComponent(wildcardPath); 35 - } 36 - if (rkey && authorDid) { 37 - return `at://${authorDid}/at.margin.collection/${rkey}`; 38 - } 39 - return null; 40 - }; 57 + if (!targetUri) { 58 + if (!user && !handle && !paramAuthorDid) { 59 + setError("Please log in to view your collections"); 60 + return; 61 + } 62 + setError("Invalid collection URL"); 63 + return; 64 + } 41 65 42 - const collectionUri = getCollectionUri(); 43 - const isOwner = user?.did && authorDid === user.did; 66 + if (!targetDid && targetUri.startsWith("at://")) { 67 + const parts = targetUri.split("/"); 68 + if (parts.length > 2) targetDid = parts[2]; 69 + } 44 70 45 - const fetchContext = async () => { 46 - if (!collectionUri || !authorDid) { 47 - setError("Invalid collection URL"); 48 - setLoading(false); 49 - return; 50 - } 71 + if (!targetDid) { 72 + setError("Could not determine collection owner"); 73 + return; 74 + } 51 75 52 - try { 53 - setLoading(true); 54 76 const [cols, itemsData] = await Promise.all([ 55 - getCollections(authorDid), 56 - getCollectionItems(collectionUri), 77 + getCollections(targetDid), 78 + getCollectionItems(targetUri), 57 79 ]); 58 80 59 81 const found = 60 - cols.items?.find((c) => c.uri === collectionUri) || 82 + cols.items?.find((c) => c.uri === targetUri) || 61 83 cols.items?.find( 62 - (c) => 63 - collectionUri && c.uri.endsWith(collectionUri.split("/").pop()), 84 + (c) => targetUri && c.uri.endsWith(targetUri.split("/").pop()), 64 85 ); 86 + 65 87 if (!found) { 66 - console.error( 67 - "Collection not found. Looking for:", 68 - collectionUri, 69 - "Available:", 70 - cols.items?.map((c) => c.uri), 71 - ); 72 88 setError("Collection not found"); 73 89 return; 74 90 } ··· 80 96 } finally { 81 97 setLoading(false); 82 98 } 83 - }; 99 + }, [paramAuthorDid, user, handle, rkey, wildcardPath]); 84 100 85 101 useEffect(() => { 86 - if (collectionUri && authorDid) { 87 - fetchContext(); 88 - } else if (!user && !searchParams.get("author")) { 89 - setLoading(false); 90 - setError("Please log in to view your collections"); 91 - } 92 - }, [rkey, wildcardPath, authorDid, user]); 102 + fetchContext(); 103 + }, [fetchContext]); 93 104 94 105 const handleEditSuccess = () => { 95 106 fetchContext(); ··· 171 182 </div> 172 183 <div className="collection-detail-actions"> 173 184 <ShareMenu 174 - customUrl={`${window.location.origin}/collection/${encodeURIComponent(collection.uri)}?author=${encodeURIComponent(authorDid)}`} 185 + uri={collection.uri} 186 + handle={collection.creator?.handle} 187 + type="Collection" 175 188 text={`Check out this collection: ${collection.name}`} 176 189 /> 177 190 {isOwner && (
+5 -6
web/src/pages/Collections.jsx
··· 1 - import { useState, useEffect } from "react"; 2 - import { Link } from "react-router-dom"; 3 - import { Folder, Plus, Edit2, ChevronRight } from "lucide-react"; 1 + import { useState, useEffect, useCallback } from "react"; 2 + import { Folder, Plus } from "lucide-react"; 4 3 import { getCollections } from "../api/client"; 5 4 import { useAuth } from "../context/AuthContext"; 6 5 import CollectionModal from "../components/CollectionModal"; ··· 14 13 const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); 15 14 const [editingCollection, setEditingCollection] = useState(null); 16 15 17 - const fetchCollections = async () => { 16 + const fetchCollections = useCallback(async () => { 18 17 try { 19 18 setLoading(true); 20 19 const data = await getCollections(user.did); ··· 25 24 } finally { 26 25 setLoading(false); 27 26 } 28 - }; 27 + }, [user]); 29 28 30 29 useEffect(() => { 31 30 if (user) { 32 31 fetchCollections(); 33 32 } 34 - }, [user]); 33 + }, [user, fetchCollections]); 35 34 36 35 const handleCreateSuccess = () => { 37 36 fetchCollections();
+177 -59
web/src/pages/Feed.jsx
··· 1 1 import { useState, useEffect } from "react"; 2 + import { useSearchParams } from "react-router-dom"; 2 3 import AnnotationCard, { HighlightCard } from "../components/AnnotationCard"; 3 4 import BookmarkCard from "../components/BookmarkCard"; 4 5 import CollectionItemCard from "../components/CollectionItemCard"; 5 - import { getAnnotationFeed } from "../api/client"; 6 + import AnnotationSkeleton from "../components/AnnotationSkeleton"; 7 + import { getAnnotationFeed, deleteHighlight } from "../api/client"; 6 8 import { AlertIcon, InboxIcon } from "../components/Icons"; 9 + import { useAuth } from "../context/AuthContext"; 10 + 11 + import AddToCollectionModal from "../components/AddToCollectionModal"; 7 12 8 13 export default function Feed() { 14 + const [searchParams, setSearchParams] = useSearchParams(); 15 + const tagFilter = searchParams.get("tag"); 16 + 17 + const [filter, setFilter] = useState(() => { 18 + return localStorage.getItem("feedFilter") || "all"; 19 + }); 20 + 9 21 const [annotations, setAnnotations] = useState([]); 10 22 const [loading, setLoading] = useState(true); 11 23 const [error, setError] = useState(null); 12 - const [filter, setFilter] = useState("all"); 24 + 25 + useEffect(() => { 26 + localStorage.setItem("feedFilter", filter); 27 + }, [filter]); 28 + 29 + const [collectionModalState, setCollectionModalState] = useState({ 30 + isOpen: false, 31 + uri: null, 32 + }); 33 + 34 + const { user } = useAuth(); 13 35 14 36 useEffect(() => { 15 37 async function fetchFeed() { 16 38 try { 17 39 setLoading(true); 18 - const data = await getAnnotationFeed(); 40 + let creatorDid = ""; 41 + 42 + if (filter === "my-tags") { 43 + if (user?.did) { 44 + creatorDid = user.did; 45 + } else { 46 + setAnnotations([]); 47 + setLoading(false); 48 + return; 49 + } 50 + } 51 + 52 + const data = await getAnnotationFeed( 53 + 50, 54 + 0, 55 + tagFilter || "", 56 + creatorDid, 57 + ); 19 58 setAnnotations(data.items || []); 20 59 } catch (err) { 21 60 setError(err.message); ··· 24 63 } 25 64 } 26 65 fetchFeed(); 27 - }, []); 66 + }, [tagFilter, filter, user]); 28 67 29 68 const filteredAnnotations = 30 - filter === "all" 69 + filter === "all" || filter === "my-tags" 31 70 ? annotations 32 71 : annotations.filter((a) => { 33 72 if (filter === "commenting") ··· 46 85 <p className="page-description"> 47 86 See what people are annotating, highlighting, and bookmarking 48 87 </p> 88 + {tagFilter && ( 89 + <div 90 + style={{ 91 + marginTop: "16px", 92 + display: "flex", 93 + alignItems: "center", 94 + gap: "8px", 95 + }} 96 + > 97 + <span 98 + style={{ fontSize: "0.9rem", color: "var(--text-secondary)" }} 99 + > 100 + Filtering by tag: <strong>#{tagFilter}</strong> 101 + </span> 102 + <button 103 + onClick={() => 104 + setSearchParams((prev) => { 105 + const next = new URLSearchParams(prev); 106 + next.delete("tag"); 107 + return next; 108 + }) 109 + } 110 + className="btn btn-sm" 111 + style={{ padding: "2px 8px", fontSize: "0.8rem" }} 112 + > 113 + Clear 114 + </button> 115 + </div> 116 + )} 49 117 </div> 50 118 51 119 {} ··· 56 124 > 57 125 All 58 126 </button> 127 + {user && ( 128 + <button 129 + className={`filter-tab ${filter === "my-tags" ? "active" : ""}`} 130 + onClick={() => setFilter("my-tags")} 131 + > 132 + My Feed 133 + </button> 134 + )} 59 135 <button 60 136 className={`filter-tab ${filter === "commenting" ? "active" : ""}`} 61 137 onClick={() => setFilter("commenting")} ··· 76 152 </button> 77 153 </div> 78 154 79 - {loading && ( 155 + {loading ? ( 80 156 <div className="feed"> 81 - {[1, 2, 3].map((i) => ( 82 - <div key={i} className="card"> 83 - <div 84 - className="skeleton skeleton-text" 85 - style={{ width: "40%" }} 86 - /> 87 - <div className="skeleton skeleton-text" /> 88 - <div className="skeleton skeleton-text" /> 89 - <div 90 - className="skeleton skeleton-text" 91 - style={{ width: "60%" }} 92 - /> 93 - </div> 157 + {[1, 2, 3, 4, 5].map((i) => ( 158 + <AnnotationSkeleton key={i} /> 94 159 ))} 95 160 </div> 96 - )} 161 + ) : ( 162 + <> 163 + {error && ( 164 + <div className="empty-state"> 165 + <div className="empty-state-icon"> 166 + <AlertIcon size={32} /> 167 + </div> 168 + <h3 className="empty-state-title">Something went wrong</h3> 169 + <p className="empty-state-text">{error}</p> 170 + </div> 171 + )} 97 172 98 - {error && ( 99 - <div className="empty-state"> 100 - <div className="empty-state-icon"> 101 - <AlertIcon size={32} /> 102 - </div> 103 - <h3 className="empty-state-title">Something went wrong</h3> 104 - <p className="empty-state-text">{error}</p> 105 - </div> 106 - )} 173 + {!error && filteredAnnotations.length === 0 && ( 174 + <div className="empty-state"> 175 + <div className="empty-state-icon"> 176 + <InboxIcon size={32} /> 177 + </div> 178 + <h3 className="empty-state-title">No items yet</h3> 179 + <p className="empty-state-text"> 180 + {filter === "all" 181 + ? "Be the first to annotate something!" 182 + : `No ${filter} items found.`} 183 + </p> 184 + </div> 185 + )} 107 186 108 - {!loading && !error && filteredAnnotations.length === 0 && ( 109 - <div className="empty-state"> 110 - <div className="empty-state-icon"> 111 - <InboxIcon size={32} /> 112 - </div> 113 - <h3 className="empty-state-title">No items yet</h3> 114 - <p className="empty-state-text"> 115 - {filter === "all" 116 - ? "Be the first to annotate something!" 117 - : `No ${filter} items found.`} 118 - </p> 119 - </div> 187 + {!error && filteredAnnotations.length > 0 && ( 188 + <div className="feed"> 189 + {filteredAnnotations.map((item) => { 190 + if (item.type === "CollectionItem") { 191 + return <CollectionItemCard key={item.id} item={item} />; 192 + } 193 + if ( 194 + item.type === "Highlight" || 195 + item.motivation === "highlighting" 196 + ) { 197 + return ( 198 + <HighlightCard 199 + key={item.id} 200 + highlight={item} 201 + onDelete={async (uri) => { 202 + const rkey = uri.split("/").pop(); 203 + await deleteHighlight(rkey); 204 + setAnnotations((prev) => 205 + prev.filter((a) => a.id !== item.id), 206 + ); 207 + }} 208 + onAddToCollection={() => 209 + setCollectionModalState({ 210 + isOpen: true, 211 + uri: item.uri || item.id, 212 + }) 213 + } 214 + /> 215 + ); 216 + } 217 + if ( 218 + item.type === "Bookmark" || 219 + item.motivation === "bookmarking" 220 + ) { 221 + return ( 222 + <BookmarkCard 223 + key={item.id} 224 + bookmark={item} 225 + onAddToCollection={() => 226 + setCollectionModalState({ 227 + isOpen: true, 228 + uri: item.uri || item.id, 229 + }) 230 + } 231 + /> 232 + ); 233 + } 234 + return ( 235 + <AnnotationCard 236 + key={item.id} 237 + annotation={item} 238 + onAddToCollection={() => 239 + setCollectionModalState({ 240 + isOpen: true, 241 + uri: item.uri || item.id, 242 + }) 243 + } 244 + /> 245 + ); 246 + })} 247 + </div> 248 + )} 249 + </> 120 250 )} 121 251 122 - {!loading && !error && filteredAnnotations.length > 0 && ( 123 - <div className="feed"> 124 - {filteredAnnotations.map((item) => { 125 - if (item.type === "CollectionItem") { 126 - return <CollectionItemCard key={item.id} item={item} />; 127 - } 128 - if ( 129 - item.type === "Highlight" || 130 - item.motivation === "highlighting" 131 - ) { 132 - return <HighlightCard key={item.id} highlight={item} />; 133 - } 134 - if (item.type === "Bookmark" || item.motivation === "bookmarking") { 135 - return <BookmarkCard key={item.id} bookmark={item} />; 136 - } 137 - return <AnnotationCard key={item.id} annotation={item} />; 138 - })} 139 - </div> 252 + {collectionModalState.isOpen && ( 253 + <AddToCollectionModal 254 + isOpen={collectionModalState.isOpen} 255 + onClose={() => setCollectionModalState({ isOpen: false, uri: null })} 256 + annotationUri={collectionModalState.uri} 257 + /> 140 258 )} 141 259 </div> 142 260 );
+1 -1
web/src/pages/Highlights.jsx
··· 77 77 <div className="page-header"> 78 78 <h1 className="page-title">My Highlights</h1> 79 79 <p className="page-description"> 80 - Text you've highlighted across the web 80 + Text you&apos;ve highlighted across the web 81 81 </p> 82 82 </div> 83 83
+24 -22
web/src/pages/Login.jsx
··· 23 23 const isSelectionRef = useRef(false); 24 24 25 25 useEffect(() => { 26 - if (handle.length < 3) { 27 - setSuggestions([]); 28 - setShowSuggestions(false); 29 - return; 30 - } 26 + if (handle.length >= 3) { 27 + if (isSelectionRef.current) { 28 + isSelectionRef.current = false; 29 + return; 30 + } 31 31 32 - if (isSelectionRef.current) { 33 - isSelectionRef.current = false; 34 - return; 32 + const timer = setTimeout(async () => { 33 + try { 34 + const data = await searchActors(handle); 35 + setSuggestions(data.actors || []); 36 + setShowSuggestions(true); 37 + setSelectedIndex(-1); 38 + } catch (e) { 39 + console.error("Search failed:", e); 40 + } 41 + }, 300); 42 + return () => clearTimeout(timer); 35 43 } 36 - 37 - const timer = setTimeout(async () => { 38 - try { 39 - const data = await searchActors(handle); 40 - setSuggestions(data.actors || []); 41 - setShowSuggestions(true); 42 - setSelectedIndex(-1); 43 - } catch (e) { 44 - console.error("Search failed:", e); 45 - } 46 - }, 300); 47 - 48 - return () => clearTimeout(timer); 49 44 }, [handle]); 50 45 51 46 useEffect(() => { ··· 178 173 className="login-input" 179 174 placeholder="yourname.bsky.social" 180 175 value={handle} 181 - onChange={(e) => setHandle(e.target.value)} 176 + onChange={(e) => { 177 + const val = e.target.value; 178 + setHandle(val); 179 + if (val.length < 3) { 180 + setSuggestions([]); 181 + setShowSuggestions(false); 182 + } 183 + }} 182 184 onKeyDown={handleKeyDown} 183 185 onFocus={() => 184 186 handle.length >= 3 &&
+5 -1
web/src/pages/New.jsx
··· 84 84 85 85 <div className="card"> 86 86 <Composer 87 - url={url || initialUrl} 87 + url={ 88 + (url || initialUrl) && !/^(?:f|ht)tps?:\/\//.test(url || initialUrl) 89 + ? `https://${url || initialUrl}` 90 + : url || initialUrl 91 + } 88 92 selector={initialSelector} 89 93 onSuccess={handleSuccess} 90 94 onCancel={() => navigate(-1)}
+12 -8
web/src/pages/Notifications.jsx
··· 4 4 import { getNotifications, markNotificationsRead } from "../api/client"; 5 5 import { BellIcon, HeartIcon, ReplyIcon } from "../components/Icons"; 6 6 7 - function getContentRoute(subjectUri) { 8 - if (!subjectUri) return "/"; 9 - if (subjectUri.includes("at.margin.bookmark")) { 7 + function getNotificationRoute(n) { 8 + if (n.type === "reply" && n.subject?.inReplyTo) { 9 + return `/annotation/${encodeURIComponent(n.subject.inReplyTo)}`; 10 + } 11 + if (!n.subjectUri) return "/"; 12 + if (n.subjectUri.includes("at.margin.bookmark")) { 10 13 return `/bookmarks`; 11 14 } 12 - if (subjectUri.includes("at.margin.highlight")) { 15 + if (n.subjectUri.includes("at.margin.highlight")) { 13 16 return `/highlights`; 14 17 } 15 - return `/annotation/${encodeURIComponent(subjectUri)}`; 18 + return `/annotation/${encodeURIComponent(n.subjectUri)}`; 16 19 } 17 20 18 21 export default function Notifications() { ··· 153 156 <BellIcon size={48} /> 154 157 <h3>No notifications yet</h3> 155 158 <p> 156 - When someone likes or replies to your content, you'll see it here 159 + When someone likes or replies to your content, you&apos;ll see it 160 + here 157 161 </p> 158 162 </div> 159 163 )} ··· 163 167 {notifications.map((n, i) => ( 164 168 <Link 165 169 key={n.id || i} 166 - to={getContentRoute(n.subjectUri)} 170 + to={getNotificationRoute(n)} 167 171 className="notification-item card" 168 172 style={{ alignItems: "center" }} 169 173 > 170 174 <div 171 175 className="notification-avatar-container" 172 - style={{ marginRight: 12 }} 176 + style={{ marginRight: 12, position: "relative" }} 173 177 > 174 178 {n.actor?.avatar ? ( 175 179 <img
+7 -7
web/src/pages/Privacy.jsx
··· 16 16 <section> 17 17 <h2>Overview</h2> 18 18 <p> 19 - Margin ("we", "our", or "us") is a web annotation tool that lets you 20 - highlight, annotate, and bookmark any webpage. Your data is stored 21 - on the decentralized AT Protocol network, giving you ownership and 22 - control over your content. 19 + Margin (&quot;we&quot;, &quot;our&quot;, or &quot;us&quot;) is a web 20 + annotation tool that lets you highlight, annotate, and bookmark any 21 + webpage. Your data is stored on the decentralized AT Protocol 22 + network, giving you ownership and control over your content. 23 23 </p> 24 24 </section> 25 25 ··· 111 111 <strong>Cookies:</strong> To maintain your logged-in session 112 112 </li> 113 113 <li> 114 - <strong>Tabs:</strong> To know which page you're viewing 114 + <strong>Tabs:</strong> To know which page you&apos;re viewing 115 115 </li> 116 116 </ul> 117 117 </section> ··· 121 121 <p>You can:</p> 122 122 <ul> 123 123 <li> 124 - Delete any annotation, highlight, or bookmark you've created 124 + Delete any annotation, highlight, or bookmark you&apos;ve created 125 125 </li> 126 126 <li>Delete your collections</li> 127 127 <li>Export your data from your PDS</li> 128 - <li>Revoke the extension's access at any time</li> 128 + <li>Revoke the extension&apos;s access at any time</li> 129 129 </ul> 130 130 </section> 131 131
+5 -21
web/src/pages/Profile.jsx
··· 89 89 </div> 90 90 <h3 className="empty-state-title">No annotations</h3> 91 91 <p className="empty-state-text"> 92 - This user hasn't posted any annotations. 92 + This user hasn&apos;t posted any annotations. 93 93 </p> 94 94 </div> 95 95 ); ··· 108 108 </div> 109 109 <h3 className="empty-state-title">No highlights</h3> 110 110 <p className="empty-state-text"> 111 - This user hasn't saved any highlights. 111 + This user hasn&apos;t saved any highlights. 112 112 </p> 113 113 </div> 114 114 ); ··· 125 125 </div> 126 126 <h3 className="empty-state-title">No bookmarks</h3> 127 127 <p className="empty-state-text"> 128 - This user hasn't bookmarked any pages. 129 - </p> 130 - </div> 131 - ); 132 - } 133 - return bookmarks.map((b) => <BookmarkCard key={b.id} annotation={b} />); 134 - } 135 - if (activeTab === "bookmarks") { 136 - if (bookmarks.length === 0) { 137 - return ( 138 - <div className="empty-state"> 139 - <div className="empty-state-icon"> 140 - <BookmarkIcon size={32} /> 141 - </div> 142 - <h3 className="empty-state-title">No bookmarks</h3> 143 - <p className="empty-state-text"> 144 - This user hasn't bookmarked any pages. 128 + This user hasn&apos;t bookmarked any pages. 145 129 </p> 146 130 </div> 147 131 ); 148 132 } 149 - return bookmarks.map((b) => <BookmarkCard key={b.id} annotation={b} />); 133 + return bookmarks.map((b) => <BookmarkCard key={b.uri} bookmark={b} />); 150 134 } 151 135 152 136 if (activeTab === "collections") { ··· 158 142 </div> 159 143 <h3 className="empty-state-title">No collections</h3> 160 144 <p className="empty-state-text"> 161 - This user hasn't created any collections. 145 + This user hasn&apos;t created any collections. 162 146 </p> 163 147 </div> 164 148 );
+81
web/src/pages/Terms.jsx
··· 1 + import { ArrowLeft } from "lucide-react"; 2 + import { Link } from "react-router-dom"; 3 + 4 + export default function Terms() { 5 + return ( 6 + <div className="feed-page"> 7 + <Link to="/" className="back-link"> 8 + <ArrowLeft size={18} /> 9 + <span>Home</span> 10 + </Link> 11 + 12 + <div className="legal-content"> 13 + <h1>Terms of Service</h1> 14 + <p className="text-secondary">Last updated: January 17, 2026</p> 15 + 16 + <section> 17 + <h2>Overview</h2> 18 + <p> 19 + Margin is an open-source project. By using our service, you agree to 20 + these terms (&quot;Terms&quot;). If you do not agree to these Terms, 21 + please do not use the Service. 22 + </p> 23 + </section> 24 + 25 + <section> 26 + <h2>Open Source</h2> 27 + <p> 28 + Margin is open source software. The code is available publicly and 29 + is provided &quot;as is&quot;, without warranty of any kind, express 30 + or implied. 31 + </p> 32 + </section> 33 + 34 + <section> 35 + <h2>User Conduct</h2> 36 + <p> 37 + You are responsible for your use of the Service and for any content 38 + you provide, including compliance with applicable laws, rules, and 39 + regulations. 40 + </p> 41 + <p> 42 + We reserve the right to remove any content that violates these 43 + terms, including but not limited to: 44 + </p> 45 + <ul> 46 + <li>Illegal content</li> 47 + <li>Harassment or hate speech</li> 48 + <li>Spam or malicious content</li> 49 + </ul> 50 + </section> 51 + 52 + <section> 53 + <h2>Decentralized Nature</h2> 54 + <p> 55 + Margin interacts with the AT Protocol network. We do not control the 56 + network itself or the data stored on your Personal Data Server 57 + (PDS). Please refer to the terms of your PDS provider for data 58 + storage policies. 59 + </p> 60 + </section> 61 + 62 + <section> 63 + <h2>Disclaimer</h2> 64 + <p> 65 + THE SERVICE IS PROVIDED &quot;AS IS&quot; AND &quot;AS 66 + AVAILABLE&quot;. WE DISCLAIM ALL CONDITIONS, REPRESENTATIONS AND 67 + WARRANTIES NOT EXPRESSLY SET OUT IN THESE TERMS. 68 + </p> 69 + </section> 70 + 71 + <section> 72 + <h2>Contact</h2> 73 + <p> 74 + For questions about these Terms, please contact us at{" "} 75 + <a href="mailto:hello@margin.at">hello@margin.at</a> 76 + </p> 77 + </section> 78 + </div> 79 + </div> 80 + ); 81 + }