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)
+16 -1
backend/go.mod
··· 3 3 go 1.24.0 4 4 5 5 require ( 6 + github.com/fxamacker/cbor/v2 v2.9.0 6 7 github.com/go-chi/chi/v5 v5.1.0 7 8 github.com/go-chi/cors v1.2.1 8 9 github.com/go-jose/go-jose/v4 v4.0.4 10 + github.com/gorilla/websocket v1.5.3 11 + github.com/ipfs/go-cid v0.6.0 9 12 github.com/joho/godotenv v1.5.1 10 13 github.com/lib/pq v1.10.9 11 14 github.com/mattn/go-sqlite3 v1.14.22 ··· 14 17 15 18 require ( 16 19 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 20 + github.com/klauspost/cpuid/v2 v2.0.9 // indirect 21 + github.com/minio/sha256-simd v1.0.0 // indirect 22 + github.com/mr-tron/base58 v1.2.0 // indirect 23 + github.com/multiformats/go-base32 v0.0.3 // indirect 24 + github.com/multiformats/go-base36 v0.1.0 // indirect 25 + github.com/multiformats/go-multibase v0.2.0 // indirect 26 + github.com/multiformats/go-multihash v0.2.3 // indirect 27 + github.com/multiformats/go-varint v0.1.0 // indirect 17 28 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 29 + github.com/spaolacci/murmur3 v1.1.0 // indirect 18 30 github.com/stretchr/testify v1.10.0 // indirect 19 - golang.org/x/crypto v0.31.0 // indirect 31 + github.com/x448/float16 v0.8.4 // indirect 32 + golang.org/x/crypto v0.35.0 // indirect 33 + golang.org/x/sys v0.30.0 // indirect 20 34 golang.org/x/text v0.32.0 // indirect 35 + lukechampine.com/blake3 v1.1.6 // indirect 21 36 )
+33 -2
backend/go.sum
··· 1 1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 2 2 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 + github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= 4 + github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= 3 5 github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= 4 6 github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= 5 7 github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= ··· 8 10 github.com/go-jose/go-jose/v4 v4.0.4/go.mod h1:NKb5HO1EZccyMpiZNbdUw/14tiXNyUJh188dfnMCAfc= 9 11 github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 10 12 github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 13 + github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= 14 + github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 15 + github.com/ipfs/go-cid v0.6.0 h1:DlOReBV1xhHBhhfy/gBNNTSyfOM6rLiIx9J7A4DGf30= 16 + github.com/ipfs/go-cid v0.6.0/go.mod h1:NC4kS1LZjzfhK40UGmpXv5/qD2kcMzACYJNntCUiDhQ= 11 17 github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 12 18 github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 19 + github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= 20 + github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= 21 + github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= 13 22 github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= 14 23 github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 15 24 github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= 16 25 github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 26 + github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g= 27 + github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM= 28 + github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= 29 + github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= 30 + github.com/multiformats/go-base32 v0.0.3 h1:tw5+NhuwaOjJCC5Pp82QuXbrmLzWg7uxlMFp8Nq/kkI= 31 + github.com/multiformats/go-base32 v0.0.3/go.mod h1:pLiuGC8y0QR3Ue4Zug5UzK9LjgbkL8NSQj0zQ5Nz/AA= 32 + github.com/multiformats/go-base36 v0.1.0 h1:JR6TyF7JjGd3m6FbLU2cOxhC0Li8z8dLNGQ89tUg4F4= 33 + github.com/multiformats/go-base36 v0.1.0/go.mod h1:kFGE83c6s80PklsHO9sRn2NCoffoRdUUOENyW/Vv6sM= 34 + github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g= 35 + github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk= 36 + github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U= 37 + github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= 38 + github.com/multiformats/go-varint v0.1.0 h1:i2wqFp4sdl3IcIxfAonHQV9qU5OsZ4Ts9IOoETFs5dI= 39 + github.com/multiformats/go-varint v0.1.0/go.mod h1:5KVAVXegtfmNQQm/lCY+ATvDzvJJhSkUlGQV9wgObdI= 17 40 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 18 41 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 42 + github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= 43 + github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= 19 44 github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 20 45 github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 21 - golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= 22 - golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= 46 + github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= 47 + github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= 48 + golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= 49 + golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= 23 50 golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8= 24 51 golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU= 52 + golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= 53 + golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 25 54 golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= 26 55 golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= 27 56 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 28 57 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 58 + lukechampine.com/blake3 v1.1.6 h1:H3cROdztr7RCfoaTpGZFQsrqvweFLrqS73j7L7cmR5c= 59 + lukechampine.com/blake3 v1.1.6/go.mod h1:tkKEOtDkNtklkXtLNEOGNq5tcV90tJiA1vAA12R78LA=
+49 -25
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 { ··· 468 480 469 481 func resolveDIDToPDS(did string) (string, error) { 470 482 if strings.HasPrefix(did, "did:plc:") { 471 - resp, err := http.Get("https://plc.directory/" + did) 483 + client := &http.Client{ 484 + Timeout: 10 * time.Second, 485 + } 486 + resp, err := client.Get("https://plc.directory/" + did) 472 487 if err != nil { 473 488 return "", err 474 489 } ··· 498 513 Title string `json:"title,omitempty"` 499 514 Selector interface{} `json:"selector"` 500 515 Color string `json:"color,omitempty"` 516 + Tags []string `json:"tags,omitempty"` 501 517 } 502 518 503 519 func (s *AnnotationService) CreateHighlight(w http.ResponseWriter, r *http.Request) { ··· 519 535 } 520 536 521 537 urlHash := db.HashURL(req.URL) 522 - record := xrpc.NewHighlightRecord(req.URL, urlHash, req.Selector, req.Color) 538 + record := xrpc.NewHighlightRecord(req.URL, urlHash, req.Selector, req.Color, req.Tags) 523 539 524 540 var result *xrpc.CreateRecordOutput 525 541 err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { ··· 549 565 colorPtr = &req.Color 550 566 } 551 567 568 + var tagsJSONPtr *string 569 + if len(req.Tags) > 0 { 570 + tagsBytes, _ := json.Marshal(req.Tags) 571 + tagsStr := string(tagsBytes) 572 + tagsJSONPtr = &tagsStr 573 + } 574 + 552 575 cid := result.CID 553 576 highlight := &db.Highlight{ 554 577 URI: result.URI, ··· 558 581 TargetTitle: titlePtr, 559 582 SelectorJSON: selectorJSONPtr, 560 583 Color: colorPtr, 584 + TagsJSON: tagsJSONPtr, 561 585 CreatedAt: time.Now(), 562 586 IndexedAt: time.Now(), 563 587 CID: &cid,
+435
backend/internal/api/apikey.go
··· 1 + package api 2 + 3 + import ( 4 + "crypto/rand" 5 + "crypto/sha256" 6 + "crypto/x509" 7 + "encoding/hex" 8 + "encoding/json" 9 + "encoding/pem" 10 + "fmt" 11 + "net/http" 12 + "strings" 13 + "time" 14 + 15 + "github.com/go-chi/chi/v5" 16 + 17 + "margin.at/internal/db" 18 + "margin.at/internal/xrpc" 19 + ) 20 + 21 + type APIKeyHandler struct { 22 + db *db.DB 23 + refresher *TokenRefresher 24 + } 25 + 26 + func NewAPIKeyHandler(database *db.DB, refresher *TokenRefresher) *APIKeyHandler { 27 + return &APIKeyHandler{db: database, refresher: refresher} 28 + } 29 + 30 + type CreateKeyRequest struct { 31 + Name string `json:"name"` 32 + } 33 + 34 + type CreateKeyResponse struct { 35 + ID string `json:"id"` 36 + Name string `json:"name"` 37 + Key string `json:"key"` 38 + CreatedAt time.Time `json:"createdAt"` 39 + } 40 + 41 + func (h *APIKeyHandler) CreateKey(w http.ResponseWriter, r *http.Request) { 42 + session, err := h.refresher.GetSessionWithAutoRefresh(r) 43 + if err != nil { 44 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 45 + return 46 + } 47 + 48 + var req CreateKeyRequest 49 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 50 + http.Error(w, "Invalid request body", http.StatusBadRequest) 51 + return 52 + } 53 + 54 + if req.Name == "" { 55 + req.Name = "API Key" 56 + } 57 + 58 + rawKey := generateAPIKey() 59 + keyHash := hashAPIKey(rawKey) 60 + keyID := generateKeyID() 61 + 62 + apiKey := &db.APIKey{ 63 + ID: keyID, 64 + OwnerDID: session.DID, 65 + Name: req.Name, 66 + KeyHash: keyHash, 67 + CreatedAt: time.Now(), 68 + } 69 + 70 + if err := h.db.CreateAPIKey(apiKey); err != nil { 71 + http.Error(w, "Failed to create key", http.StatusInternalServerError) 72 + return 73 + } 74 + 75 + w.Header().Set("Content-Type", "application/json") 76 + json.NewEncoder(w).Encode(CreateKeyResponse{ 77 + ID: keyID, 78 + Name: req.Name, 79 + Key: rawKey, 80 + CreatedAt: apiKey.CreatedAt, 81 + }) 82 + } 83 + 84 + func (h *APIKeyHandler) ListKeys(w http.ResponseWriter, r *http.Request) { 85 + session, err := h.refresher.GetSessionWithAutoRefresh(r) 86 + if err != nil { 87 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 88 + return 89 + } 90 + 91 + keys, err := h.db.GetAPIKeysByOwner(session.DID) 92 + if err != nil { 93 + http.Error(w, "Failed to get keys", http.StatusInternalServerError) 94 + return 95 + } 96 + 97 + if keys == nil { 98 + keys = []db.APIKey{} 99 + } 100 + 101 + w.Header().Set("Content-Type", "application/json") 102 + json.NewEncoder(w).Encode(map[string]interface{}{"keys": keys}) 103 + } 104 + 105 + func (h *APIKeyHandler) DeleteKey(w http.ResponseWriter, r *http.Request) { 106 + session, err := h.refresher.GetSessionWithAutoRefresh(r) 107 + if err != nil { 108 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 109 + return 110 + } 111 + 112 + keyID := chi.URLParam(r, "id") 113 + if keyID == "" { 114 + http.Error(w, "Key ID required", http.StatusBadRequest) 115 + return 116 + } 117 + 118 + if err := h.db.DeleteAPIKey(keyID, session.DID); err != nil { 119 + http.Error(w, "Failed to delete key", http.StatusInternalServerError) 120 + return 121 + } 122 + 123 + w.Header().Set("Content-Type", "application/json") 124 + json.NewEncoder(w).Encode(map[string]bool{"success": true}) 125 + } 126 + 127 + type QuickBookmarkRequest struct { 128 + URL string `json:"url"` 129 + Title string `json:"title,omitempty"` 130 + Description string `json:"description,omitempty"` 131 + } 132 + 133 + func (h *APIKeyHandler) QuickBookmark(w http.ResponseWriter, r *http.Request) { 134 + apiKey, err := h.authenticateAPIKey(r) 135 + if err != nil { 136 + http.Error(w, err.Error(), http.StatusUnauthorized) 137 + return 138 + } 139 + 140 + var req QuickBookmarkRequest 141 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 142 + http.Error(w, "Invalid request body", http.StatusBadRequest) 143 + return 144 + } 145 + 146 + if req.URL == "" { 147 + http.Error(w, "URL is required", http.StatusBadRequest) 148 + return 149 + } 150 + 151 + session, err := h.getSessionByDID(apiKey.OwnerDID) 152 + if err != nil { 153 + http.Error(w, "User session not found. Please log in to margin.at first.", http.StatusUnauthorized) 154 + return 155 + } 156 + 157 + urlHash := db.HashURL(req.URL) 158 + record := xrpc.NewBookmarkRecord(req.URL, urlHash, req.Title, req.Description) 159 + 160 + var result *xrpc.CreateRecordOutput 161 + err = h.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 162 + var createErr error 163 + result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionBookmark, record) 164 + return createErr 165 + }) 166 + if err != nil { 167 + http.Error(w, "Failed to create bookmark: "+err.Error(), http.StatusInternalServerError) 168 + return 169 + } 170 + 171 + h.db.UpdateAPIKeyLastUsed(apiKey.ID) 172 + 173 + var titlePtr, descPtr *string 174 + if req.Title != "" { 175 + titlePtr = &req.Title 176 + } 177 + if req.Description != "" { 178 + descPtr = &req.Description 179 + } 180 + 181 + cid := result.CID 182 + bookmark := &db.Bookmark{ 183 + URI: result.URI, 184 + AuthorDID: apiKey.OwnerDID, 185 + Source: req.URL, 186 + SourceHash: urlHash, 187 + Title: titlePtr, 188 + Description: descPtr, 189 + CreatedAt: time.Now(), 190 + IndexedAt: time.Now(), 191 + CID: &cid, 192 + } 193 + h.db.CreateBookmark(bookmark) 194 + 195 + w.Header().Set("Content-Type", "application/json") 196 + json.NewEncoder(w).Encode(map[string]string{ 197 + "uri": result.URI, 198 + "cid": result.CID, 199 + "message": "Bookmark created successfully", 200 + }) 201 + } 202 + 203 + type QuickAnnotationRequest struct { 204 + URL string `json:"url"` 205 + Text string `json:"text"` 206 + } 207 + 208 + func (h *APIKeyHandler) QuickAnnotation(w http.ResponseWriter, r *http.Request) { 209 + apiKey, err := h.authenticateAPIKey(r) 210 + if err != nil { 211 + http.Error(w, err.Error(), http.StatusUnauthorized) 212 + return 213 + } 214 + 215 + var req QuickAnnotationRequest 216 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 217 + http.Error(w, "Invalid request body", http.StatusBadRequest) 218 + return 219 + } 220 + 221 + if req.URL == "" || req.Text == "" { 222 + http.Error(w, "URL and text are required", http.StatusBadRequest) 223 + return 224 + } 225 + 226 + session, err := h.getSessionByDID(apiKey.OwnerDID) 227 + if err != nil { 228 + http.Error(w, "User session not found. Please log in to margin.at first.", http.StatusUnauthorized) 229 + return 230 + } 231 + 232 + urlHash := db.HashURL(req.URL) 233 + record := xrpc.NewAnnotationRecord(req.URL, urlHash, req.Text, nil, "") 234 + 235 + var result *xrpc.CreateRecordOutput 236 + err = h.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 237 + var createErr error 238 + result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionAnnotation, record) 239 + return createErr 240 + }) 241 + if err != nil { 242 + http.Error(w, "Failed to create annotation: "+err.Error(), http.StatusInternalServerError) 243 + return 244 + } 245 + 246 + h.db.UpdateAPIKeyLastUsed(apiKey.ID) 247 + 248 + bodyValue := req.Text 249 + annotation := &db.Annotation{ 250 + URI: result.URI, 251 + AuthorDID: apiKey.OwnerDID, 252 + Motivation: "commenting", 253 + BodyValue: &bodyValue, 254 + TargetSource: req.URL, 255 + TargetHash: urlHash, 256 + CreatedAt: time.Now(), 257 + IndexedAt: time.Now(), 258 + CID: &result.CID, 259 + } 260 + h.db.CreateAnnotation(annotation) 261 + 262 + w.Header().Set("Content-Type", "application/json") 263 + json.NewEncoder(w).Encode(map[string]string{ 264 + "uri": result.URI, 265 + "cid": result.CID, 266 + "message": "Annotation created successfully", 267 + }) 268 + } 269 + 270 + type QuickHighlightRequest struct { 271 + URL string `json:"url"` 272 + Selector interface{} `json:"selector"` 273 + Color string `json:"color,omitempty"` 274 + } 275 + 276 + func (h *APIKeyHandler) QuickHighlight(w http.ResponseWriter, r *http.Request) { 277 + apiKey, err := h.authenticateAPIKey(r) 278 + if err != nil { 279 + http.Error(w, err.Error(), http.StatusUnauthorized) 280 + return 281 + } 282 + 283 + var req QuickHighlightRequest 284 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 285 + http.Error(w, "Invalid request body", http.StatusBadRequest) 286 + return 287 + } 288 + 289 + if req.URL == "" || req.Selector == nil { 290 + http.Error(w, "URL and selector are required", http.StatusBadRequest) 291 + return 292 + } 293 + 294 + session, err := h.getSessionByDID(apiKey.OwnerDID) 295 + if err != nil { 296 + http.Error(w, "User session not found. Please log in to margin.at first.", http.StatusUnauthorized) 297 + return 298 + } 299 + 300 + urlHash := db.HashURL(req.URL) 301 + color := req.Color 302 + if color == "" { 303 + color = "yellow" 304 + } 305 + 306 + record := xrpc.NewHighlightRecord(req.URL, urlHash, req.Selector, color, nil) 307 + 308 + var result *xrpc.CreateRecordOutput 309 + err = h.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 310 + var createErr error 311 + result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionHighlight, record) 312 + return createErr 313 + }) 314 + if err != nil { 315 + http.Error(w, "Failed to create highlight: "+err.Error(), http.StatusInternalServerError) 316 + return 317 + } 318 + 319 + h.db.UpdateAPIKeyLastUsed(apiKey.ID) 320 + 321 + selectorJSON, _ := json.Marshal(req.Selector) 322 + selectorStr := string(selectorJSON) 323 + colorPtr := &color 324 + 325 + highlight := &db.Highlight{ 326 + URI: result.URI, 327 + AuthorDID: apiKey.OwnerDID, 328 + TargetSource: req.URL, 329 + TargetHash: urlHash, 330 + SelectorJSON: &selectorStr, 331 + Color: colorPtr, 332 + CreatedAt: time.Now(), 333 + IndexedAt: time.Now(), 334 + CID: &result.CID, 335 + } 336 + if err := h.db.CreateHighlight(highlight); err != nil { 337 + fmt.Printf("Warning: failed to index highlight in local DB: %v\n", err) 338 + } 339 + 340 + w.Header().Set("Content-Type", "application/json") 341 + json.NewEncoder(w).Encode(map[string]string{ 342 + "uri": result.URI, 343 + "cid": result.CID, 344 + "message": "Highlight created successfully", 345 + }) 346 + } 347 + 348 + func (h *APIKeyHandler) authenticateAPIKey(r *http.Request) (*db.APIKey, error) { 349 + auth := r.Header.Get("Authorization") 350 + if auth == "" { 351 + return nil, fmt.Errorf("missing Authorization header") 352 + } 353 + 354 + if !strings.HasPrefix(auth, "Bearer ") { 355 + return nil, fmt.Errorf("invalid Authorization format, expected 'Bearer <key>'") 356 + } 357 + 358 + rawKey := strings.TrimPrefix(auth, "Bearer ") 359 + keyHash := hashAPIKey(rawKey) 360 + 361 + apiKey, err := h.db.GetAPIKeyByHash(keyHash) 362 + if err != nil { 363 + return nil, fmt.Errorf("invalid API key") 364 + } 365 + 366 + return apiKey, nil 367 + } 368 + 369 + func (h *APIKeyHandler) getSessionByDID(did string) (*SessionData, error) { 370 + rows, err := h.db.Query(h.db.Rebind(` 371 + SELECT id, did, handle, access_token, refresh_token, COALESCE(dpop_key, '') 372 + FROM sessions 373 + WHERE did = ? AND expires_at > ? 374 + ORDER BY created_at DESC 375 + LIMIT 1 376 + `), did, time.Now()) 377 + if err != nil { 378 + return nil, err 379 + } 380 + defer rows.Close() 381 + 382 + if !rows.Next() { 383 + return nil, fmt.Errorf("no active session") 384 + } 385 + 386 + var sessionID, sessDID, handle, accessToken, refreshToken, dpopKeyStr string 387 + if err := rows.Scan(&sessionID, &sessDID, &handle, &accessToken, &refreshToken, &dpopKeyStr); err != nil { 388 + return nil, err 389 + } 390 + 391 + block, _ := pem.Decode([]byte(dpopKeyStr)) 392 + if block == nil { 393 + return nil, fmt.Errorf("invalid session DPoP key") 394 + } 395 + dpopKey, err := x509.ParseECPrivateKey(block.Bytes) 396 + if err != nil { 397 + return nil, fmt.Errorf("invalid session DPoP key: %w", err) 398 + } 399 + 400 + pds, err := resolveDIDToPDS(sessDID) 401 + if err != nil { 402 + return nil, fmt.Errorf("failed to resolve PDS: %w", err) 403 + } 404 + if pds == "" { 405 + return nil, fmt.Errorf("PDS not found for DID: %s", sessDID) 406 + } 407 + 408 + return &SessionData{ 409 + ID: sessionID, 410 + DID: sessDID, 411 + Handle: handle, 412 + AccessToken: accessToken, 413 + RefreshToken: refreshToken, 414 + DPoPKey: dpopKey, 415 + PDS: pds, 416 + }, nil 417 + } 418 + 419 + func generateAPIKey() string { 420 + b := make([]byte, 32) 421 + rand.Read(b) 422 + return "mk_" + hex.EncodeToString(b) 423 + } 424 + 425 + func generateKeyID() string { 426 + b := make([]byte, 16) 427 + rand.Read(b) 428 + return hex.EncodeToString(b) 429 + } 430 + 431 + func hashAPIKey(key string) string { 432 + h := sha256.New() 433 + h.Write([]byte(key)) 434 + return hex.EncodeToString(h.Sum(nil)) 435 + }
+117
backend/internal/api/avatar.go
··· 1 + package api 2 + 3 + import ( 4 + "encoding/json" 5 + "io" 6 + "net/http" 7 + "net/url" 8 + "os" 9 + "sync" 10 + "time" 11 + 12 + "github.com/go-chi/chi/v5" 13 + ) 14 + 15 + type avatarCache struct { 16 + url string 17 + fetchedAt time.Time 18 + } 19 + 20 + var ( 21 + avatarCacheMu sync.RWMutex 22 + avatarCacheMap = make(map[string]avatarCache) 23 + avatarCacheTTL = 5 * time.Minute 24 + ) 25 + 26 + func (h *Handler) HandleAvatarProxy(w http.ResponseWriter, r *http.Request) { 27 + did := chi.URLParam(r, "did") 28 + if did == "" { 29 + http.Error(w, "DID required", http.StatusBadRequest) 30 + return 31 + } 32 + 33 + if decoded, err := url.QueryUnescape(did); err == nil { 34 + did = decoded 35 + } 36 + 37 + avatarURL := getAvatarURL(did) 38 + if avatarURL == "" { 39 + http.Error(w, "Avatar not found", http.StatusNotFound) 40 + return 41 + } 42 + 43 + client := &http.Client{Timeout: 10 * time.Second} 44 + resp, err := client.Get(avatarURL) 45 + if err != nil { 46 + http.Error(w, "Failed to fetch avatar", http.StatusBadGateway) 47 + return 48 + } 49 + defer resp.Body.Close() 50 + 51 + if resp.StatusCode != http.StatusOK { 52 + http.Error(w, "Avatar not available", http.StatusNotFound) 53 + return 54 + } 55 + 56 + contentType := resp.Header.Get("Content-Type") 57 + if contentType == "" { 58 + contentType = "image/jpeg" 59 + } 60 + 61 + w.Header().Set("Content-Type", contentType) 62 + w.Header().Set("Cache-Control", "public, max-age=3600") 63 + w.Header().Set("Access-Control-Allow-Origin", "*") 64 + 65 + io.Copy(w, resp.Body) 66 + } 67 + 68 + func getAvatarURL(did string) string { 69 + avatarCacheMu.RLock() 70 + if cached, ok := avatarCacheMap[did]; ok && time.Since(cached.fetchedAt) < avatarCacheTTL { 71 + avatarCacheMu.RUnlock() 72 + return cached.url 73 + } 74 + avatarCacheMu.RUnlock() 75 + 76 + q := url.Values{} 77 + q.Add("actor", did) 78 + 79 + resp, err := http.Get("https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?" + q.Encode()) 80 + if err != nil { 81 + return "" 82 + } 83 + defer resp.Body.Close() 84 + 85 + if resp.StatusCode != 200 { 86 + return "" 87 + } 88 + 89 + var profile struct { 90 + Avatar string `json:"avatar"` 91 + } 92 + if err := json.NewDecoder(resp.Body).Decode(&profile); err != nil { 93 + return "" 94 + } 95 + 96 + avatarCacheMu.Lock() 97 + avatarCacheMap[did] = avatarCache{ 98 + url: profile.Avatar, 99 + fetchedAt: time.Now(), 100 + } 101 + avatarCacheMu.Unlock() 102 + 103 + return profile.Avatar 104 + } 105 + 106 + func getProxiedAvatarURL(did, originalURL string) string { 107 + if originalURL == "" { 108 + return "" 109 + } 110 + 111 + baseURL := os.Getenv("BASE_URL") 112 + if baseURL == "" { 113 + return originalURL 114 + } 115 + 116 + return baseURL + "/api/avatar/" + url.PathEscape(did) 117 + }
+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 }
+135 -41
backend/internal/api/handler.go
··· 19 19 db *db.DB 20 20 annotationService *AnnotationService 21 21 refresher *TokenRefresher 22 + apiKeys *APIKeyHandler 22 23 } 23 24 24 25 func NewHandler(database *db.DB, annotationService *AnnotationService, refresher *TokenRefresher) *Handler { 25 - return &Handler{db: database, annotationService: annotationService, refresher: refresher} 26 + return &Handler{ 27 + db: database, 28 + annotationService: annotationService, 29 + refresher: refresher, 30 + apiKeys: NewAPIKeyHandler(database, refresher), 31 + } 26 32 } 27 33 28 34 func (h *Handler) RegisterRoutes(r chi.Router) { ··· 64 70 r.Get("/notifications", h.GetNotifications) 65 71 r.Get("/notifications/count", h.GetUnreadNotificationCount) 66 72 r.Post("/notifications/read", h.MarkNotificationsRead) 73 + r.Get("/avatar/{did}", h.HandleAvatarProxy) 74 + 75 + r.Post("/keys", h.apiKeys.CreateKey) 76 + r.Get("/keys", h.apiKeys.ListKeys) 77 + r.Delete("/keys/{id}", h.apiKeys.DeleteKey) 78 + 79 + r.Post("/quick/bookmark", h.apiKeys.QuickBookmark) 80 + r.Post("/quick/annotation", h.apiKeys.QuickAnnotation) 81 + r.Post("/quick/highlight", h.apiKeys.QuickHighlight) 67 82 }) 68 83 } 69 84 ··· 81 96 limit := parseIntParam(r, "limit", 50) 82 97 offset := parseIntParam(r, "offset", 0) 83 98 motivation := r.URL.Query().Get("motivation") 99 + tag := r.URL.Query().Get("tag") 84 100 85 101 var annotations []db.Annotation 86 102 var err error ··· 90 106 annotations, err = h.db.GetAnnotationsByTargetHash(urlHash, limit, offset) 91 107 } else if motivation != "" { 92 108 annotations, err = h.db.GetAnnotationsByMotivation(motivation, limit, offset) 109 + } else if tag != "" { 110 + annotations, err = h.db.GetAnnotationsByTag(tag, limit, offset) 93 111 } else { 94 112 annotations, err = h.db.GetRecentAnnotations(limit, offset) 95 113 } ··· 99 117 return 100 118 } 101 119 102 - enriched, _ := hydrateAnnotations(annotations) 120 + enriched, _ := hydrateAnnotations(h.db, annotations, h.getViewerDID(r)) 103 121 104 122 w.Header().Set("Content-Type", "application/json") 105 123 json.NewEncoder(w).Encode(map[string]interface{}{ ··· 112 130 113 131 func (h *Handler) GetFeed(w http.ResponseWriter, r *http.Request) { 114 132 limit := parseIntParam(r, "limit", 50) 133 + tag := r.URL.Query().Get("tag") 134 + creator := r.URL.Query().Get("creator") 115 135 116 - annotations, _ := h.db.GetRecentAnnotations(limit, 0) 117 - highlights, _ := h.db.GetRecentHighlights(limit, 0) 118 - bookmarks, _ := h.db.GetRecentBookmarks(limit, 0) 119 - 120 - authAnnos, _ := hydrateAnnotations(annotations) 121 - authHighs, _ := hydrateHighlights(highlights) 122 - authBooks, _ := hydrateBookmarks(bookmarks) 136 + var annotations []db.Annotation 137 + var highlights []db.Highlight 138 + var bookmarks []db.Bookmark 139 + var collectionItems []db.CollectionItem 140 + var err error 123 141 124 - collectionItems, err := h.db.GetRecentCollectionItems(limit, 0) 125 - if err != nil { 126 - log.Printf("Error fetching collection items: %v\n", err) 142 + if tag != "" { 143 + if creator != "" { 144 + annotations, _ = h.db.GetAnnotationsByTagAndAuthor(tag, creator, limit, 0) 145 + highlights, _ = h.db.GetHighlightsByTagAndAuthor(tag, creator, limit, 0) 146 + bookmarks, _ = h.db.GetBookmarksByTagAndAuthor(tag, creator, limit, 0) 147 + collectionItems = []db.CollectionItem{} 148 + } else { 149 + annotations, _ = h.db.GetAnnotationsByTag(tag, limit, 0) 150 + highlights, _ = h.db.GetHighlightsByTag(tag, limit, 0) 151 + bookmarks, _ = h.db.GetBookmarksByTag(tag, limit, 0) 152 + collectionItems = []db.CollectionItem{} 153 + } 154 + } else if creator != "" { 155 + annotations, _ = h.db.GetAnnotationsByAuthor(creator, limit, 0) 156 + highlights, _ = h.db.GetHighlightsByAuthor(creator, limit, 0) 157 + bookmarks, _ = h.db.GetBookmarksByAuthor(creator, limit, 0) 158 + collectionItems = []db.CollectionItem{} 159 + } else { 160 + annotations, _ = h.db.GetRecentAnnotations(limit, 0) 161 + highlights, _ = h.db.GetRecentHighlights(limit, 0) 162 + bookmarks, _ = h.db.GetRecentBookmarks(limit, 0) 163 + collectionItems, err = h.db.GetRecentCollectionItems(limit, 0) 164 + if err != nil { 165 + log.Printf("Error fetching collection items: %v\n", err) 166 + } 127 167 } 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)) 168 + 169 + viewerDID := h.getViewerDID(r) 170 + authAnnos, _ := hydrateAnnotations(h.db, annotations, viewerDID) 171 + authHighs, _ := hydrateHighlights(h.db, highlights, viewerDID) 172 + authBooks, _ := hydrateBookmarks(h.db, bookmarks, viewerDID) 173 + 174 + authCollectionItems, _ := hydrateCollectionItems(h.db, collectionItems, viewerDID) 131 175 132 176 var feed []interface{} 133 177 for _, a := range authAnnos { ··· 188 232 return 189 233 } 190 234 191 - annotation, err := h.db.GetAnnotationByURI(uri) 192 - if err != nil { 193 - http.Error(w, "Annotation not found", http.StatusNotFound) 194 - return 235 + serveResponse := func(data interface{}, context string) { 236 + w.Header().Set("Content-Type", "application/json") 237 + response := map[string]interface{}{ 238 + "@context": context, 239 + } 240 + jsonData, _ := json.Marshal(data) 241 + json.Unmarshal(jsonData, &response) 242 + json.NewEncoder(w).Encode(response) 195 243 } 196 244 197 - enriched, _ := hydrateAnnotations([]db.Annotation{*annotation}) 198 - if len(enriched) == 0 { 199 - http.Error(w, "Annotation not found", http.StatusNotFound) 200 - return 245 + if annotation, err := h.db.GetAnnotationByURI(uri); err == nil { 246 + if enriched, _ := hydrateAnnotations(h.db, []db.Annotation{*annotation}, h.getViewerDID(r)); len(enriched) > 0 { 247 + serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld") 248 + return 249 + } 201 250 } 202 251 203 - w.Header().Set("Content-Type", "application/json") 204 - response := map[string]interface{}{ 205 - "@context": "http://www.w3.org/ns/anno.jsonld", 252 + if highlight, err := h.db.GetHighlightByURI(uri); err == nil { 253 + if enriched, _ := hydrateHighlights(h.db, []db.Highlight{*highlight}, h.getViewerDID(r)); len(enriched) > 0 { 254 + serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld") 255 + return 256 + } 206 257 } 207 - annJSON, _ := json.Marshal(enriched[0]) 208 - json.Unmarshal(annJSON, &response) 209 258 210 - json.NewEncoder(w).Encode(response) 259 + if strings.Contains(uri, "at.margin.annotation") { 260 + highlightURI := strings.Replace(uri, "at.margin.annotation", "at.margin.highlight", 1) 261 + if highlight, err := h.db.GetHighlightByURI(highlightURI); err == nil { 262 + if enriched, _ := hydrateHighlights(h.db, []db.Highlight{*highlight}, h.getViewerDID(r)); len(enriched) > 0 { 263 + serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld") 264 + return 265 + } 266 + } 267 + } 268 + 269 + if bookmark, err := h.db.GetBookmarkByURI(uri); err == nil { 270 + if enriched, _ := hydrateBookmarks(h.db, []db.Bookmark{*bookmark}, h.getViewerDID(r)); len(enriched) > 0 { 271 + serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld") 272 + return 273 + } 274 + } 275 + 276 + if strings.Contains(uri, "at.margin.annotation") { 277 + bookmarkURI := strings.Replace(uri, "at.margin.annotation", "at.margin.bookmark", 1) 278 + if bookmark, err := h.db.GetBookmarkByURI(bookmarkURI); err == nil { 279 + if enriched, _ := hydrateBookmarks(h.db, []db.Bookmark{*bookmark}, h.getViewerDID(r)); len(enriched) > 0 { 280 + serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld") 281 + return 282 + } 283 + } 284 + } 285 + 286 + http.Error(w, "Annotation, Highlight, or Bookmark not found", http.StatusNotFound) 287 + 211 288 } 212 289 213 290 func (h *Handler) GetByTarget(w http.ResponseWriter, r *http.Request) { ··· 228 305 annotations, _ := h.db.GetAnnotationsByTargetHash(urlHash, limit, offset) 229 306 highlights, _ := h.db.GetHighlightsByTargetHash(urlHash, limit, offset) 230 307 231 - enrichedAnnotations, _ := hydrateAnnotations(annotations) 232 - enrichedHighlights, _ := hydrateHighlights(highlights) 308 + enrichedAnnotations, _ := hydrateAnnotations(h.db, annotations, h.getViewerDID(r)) 309 + enrichedHighlights, _ := hydrateHighlights(h.db, highlights, h.getViewerDID(r)) 233 310 234 311 w.Header().Set("Content-Type", "application/json") 235 312 json.NewEncoder(w).Encode(map[string]interface{}{ ··· 243 320 244 321 func (h *Handler) GetHighlights(w http.ResponseWriter, r *http.Request) { 245 322 did := r.URL.Query().Get("creator") 323 + tag := r.URL.Query().Get("tag") 246 324 limit := parseIntParam(r, "limit", 50) 247 325 offset := parseIntParam(r, "offset", 0) 248 326 249 - if did == "" { 250 - http.Error(w, "creator parameter required", http.StatusBadRequest) 251 - return 327 + var highlights []db.Highlight 328 + var err error 329 + 330 + if did != "" { 331 + highlights, err = h.db.GetHighlightsByAuthor(did, limit, offset) 332 + } else if tag != "" { 333 + highlights, err = h.db.GetHighlightsByTag(tag, limit, offset) 334 + } else { 335 + highlights, err = h.db.GetRecentHighlights(limit, offset) 252 336 } 253 337 254 - highlights, err := h.db.GetHighlightsByAuthor(did, limit, offset) 255 338 if err != nil { 256 339 http.Error(w, err.Error(), http.StatusInternalServerError) 257 340 return 258 341 } 259 342 260 - enriched, _ := hydrateHighlights(highlights) 343 + enriched, _ := hydrateHighlights(h.db, highlights, h.getViewerDID(r)) 261 344 262 345 w.Header().Set("Content-Type", "application/json") 263 346 json.NewEncoder(w).Encode(map[string]interface{}{ ··· 284 367 return 285 368 } 286 369 287 - enriched, _ := hydrateBookmarks(bookmarks) 370 + enriched, _ := hydrateBookmarks(h.db, bookmarks, h.getViewerDID(r)) 288 371 289 372 w.Header().Set("Content-Type", "application/json") 290 373 json.NewEncoder(w).Encode(map[string]interface{}{ ··· 309 392 return 310 393 } 311 394 312 - enriched, _ := hydrateAnnotations(annotations) 395 + enriched, _ := hydrateAnnotations(h.db, annotations, h.getViewerDID(r)) 313 396 314 397 w.Header().Set("Content-Type", "application/json") 315 398 json.NewEncoder(w).Encode(map[string]interface{}{ ··· 335 418 return 336 419 } 337 420 338 - enriched, _ := hydrateHighlights(highlights) 421 + enriched, _ := hydrateHighlights(h.db, highlights, h.getViewerDID(r)) 339 422 340 423 w.Header().Set("Content-Type", "application/json") 341 424 json.NewEncoder(w).Encode(map[string]interface{}{ ··· 361 444 return 362 445 } 363 446 364 - enriched, _ := hydrateBookmarks(bookmarks) 447 + enriched, _ := hydrateBookmarks(h.db, bookmarks, h.getViewerDID(r)) 365 448 366 449 w.Header().Set("Content-Type", "application/json") 367 450 json.NewEncoder(w).Encode(map[string]interface{}{ ··· 515 598 return 516 599 } 517 600 518 - enriched, err := hydrateNotifications(notifications) 601 + enriched, err := hydrateNotifications(h.db, notifications) 519 602 if err != nil { 520 603 log.Printf("Failed to hydrate notifications: %v\n", err) 521 604 } ··· 560 643 w.Header().Set("Content-Type", "application/json") 561 644 json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) 562 645 } 646 + func (h *Handler) getViewerDID(r *http.Request) string { 647 + cookie, err := r.Cookie("margin_session") 648 + if err != nil { 649 + return "" 650 + } 651 + did, _, _, _, _, err := h.db.GetSession(cookie.Value) 652 + if err != nil { 653 + return "" 654 + } 655 + return did 656 + }
+132 -51
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 } ··· 246 270 CreatedAt: h.CreatedAt, 247 271 CID: cid, 248 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 + } 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 ··· 427 470 DID: p.DID, 428 471 Handle: p.Handle, 429 472 DisplayName: p.DisplayName, 430 - Avatar: p.Avatar, 473 + Avatar: getProxiedAvatarURL(p.DID, p.Avatar), 431 474 } 432 475 } 433 476 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 + }
+10 -4
backend/internal/api/token_refresh.go
··· 52 52 } 53 53 54 54 type SessionData struct { 55 + ID string 55 56 DID string 56 57 Handle string 57 58 AccessToken string ··· 94 95 } 95 96 96 97 return &SessionData{ 98 + ID: sessionID, 97 99 DID: did, 98 100 Handle: handle, 99 101 AccessToken: accessToken, ··· 104 106 } 105 107 106 108 func (tr *TokenRefresher) RefreshSessionToken(r *http.Request, session *SessionData) (*SessionData, error) { 107 - cookie, err := r.Cookie("margin_session") 108 - if err != nil { 109 - return nil, fmt.Errorf("not authenticated") 109 + if session.ID == "" { 110 + return nil, fmt.Errorf("invalid session ID") 110 111 } 111 112 112 113 oauthClient := tr.getOAuthClient(r) ··· 138 139 139 140 expiresAt := time.Now().Add(7 * 24 * time.Hour) 140 141 if err := tr.db.SaveSession( 141 - cookie.Value, 142 + session.ID, 142 143 session.DID, 143 144 session.Handle, 144 145 tokenResp.AccessToken, ··· 152 153 log.Printf("Successfully refreshed token for user %s", session.Handle) 153 154 154 155 return &SessionData{ 156 + ID: session.ID, 155 157 DID: session.DID, 156 158 Handle: session.Handle, 157 159 AccessToken: tokenResp.AccessToken, ··· 196 198 client = xrpc.NewClient(newSession.PDS, newSession.AccessToken, newSession.DPoPKey) 197 199 return fn(client, newSession.DID) 198 200 } 201 + 202 + func (tr *TokenRefresher) CreateClientFromSession(session *SessionData) *xrpc.Client { 203 + return xrpc.NewClient(session.PDS, session.AccessToken, session.DPoPKey) 204 + }
+25 -1
backend/internal/db/db.go
··· 120 120 ReadAt *time.Time `json:"readAt,omitempty"` 121 121 } 122 122 123 + type APIKey struct { 124 + ID string `json:"id"` 125 + OwnerDID string `json:"ownerDid"` 126 + Name string `json:"name"` 127 + KeyHash string `json:"-"` 128 + CreatedAt time.Time `json:"createdAt"` 129 + LastUsedAt *time.Time `json:"lastUsedAt,omitempty"` 130 + } 131 + 123 132 func New(dsn string) (*DB, error) { 124 133 driver := "sqlite3" 125 134 if strings.HasPrefix(dsn, "postgres://") || strings.HasPrefix(dsn, "postgresql://") { ··· 296 305 db.Exec(`CREATE INDEX IF NOT EXISTS idx_notifications_recipient ON notifications(recipient_did)`) 297 306 db.Exec(`CREATE INDEX IF NOT EXISTS idx_notifications_created_at ON notifications(created_at DESC)`) 298 307 308 + db.Exec(`CREATE TABLE IF NOT EXISTS api_keys ( 309 + id TEXT PRIMARY KEY, 310 + owner_did TEXT NOT NULL, 311 + name TEXT NOT NULL, 312 + key_hash TEXT NOT NULL, 313 + created_at ` + dateType + ` NOT NULL, 314 + last_used_at ` + dateType + ` 315 + )`) 316 + db.Exec(`CREATE INDEX IF NOT EXISTS idx_api_keys_owner ON api_keys(owner_did)`) 317 + db.Exec(`CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash)`) 318 + 299 319 db.runMigrations() 300 320 301 321 db.Exec(`CREATE TABLE IF NOT EXISTS cursors ( 302 322 id TEXT PRIMARY KEY, 303 - last_cursor INTEGER NOT NULL, 323 + last_cursor BIGINT NOT NULL, 304 324 updated_at ` + dateType + ` NOT NULL 305 325 )`) 306 326 ··· 353 373 db.Exec(`UPDATE annotations SET body_value = text WHERE body_value IS NULL AND text IS NOT NULL`) 354 374 db.Exec(`UPDATE annotations SET target_title = title WHERE target_title IS NULL AND title IS NOT NULL`) 355 375 db.Exec(`UPDATE annotations SET motivation = 'commenting' WHERE motivation IS NULL`) 376 + 377 + if db.driver == "postgres" { 378 + db.Exec(`ALTER TABLE cursors ALTER COLUMN last_cursor TYPE BIGINT`) 379 + } 356 380 } 357 381 358 382 func (db *DB) Close() error {
+197
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) ··· 767 910 768 911 return "", fmt.Errorf("uri not found or no author") 769 912 } 913 + 914 + func (db *DB) CreateAPIKey(key *APIKey) error { 915 + _, err := db.Exec(db.Rebind(` 916 + INSERT INTO api_keys (id, owner_did, name, key_hash, created_at) 917 + VALUES (?, ?, ?, ?, ?) 918 + `), key.ID, key.OwnerDID, key.Name, key.KeyHash, key.CreatedAt) 919 + return err 920 + } 921 + 922 + func (db *DB) GetAPIKeysByOwner(ownerDID string) ([]APIKey, error) { 923 + rows, err := db.Query(db.Rebind(` 924 + SELECT id, owner_did, name, key_hash, created_at, last_used_at 925 + FROM api_keys 926 + WHERE owner_did = ? 927 + ORDER BY created_at DESC 928 + `), ownerDID) 929 + if err != nil { 930 + return nil, err 931 + } 932 + defer rows.Close() 933 + 934 + var keys []APIKey 935 + for rows.Next() { 936 + var k APIKey 937 + if err := rows.Scan(&k.ID, &k.OwnerDID, &k.Name, &k.KeyHash, &k.CreatedAt, &k.LastUsedAt); err != nil { 938 + return nil, err 939 + } 940 + keys = append(keys, k) 941 + } 942 + return keys, nil 943 + } 944 + 945 + func (db *DB) GetAPIKeyByHash(keyHash string) (*APIKey, error) { 946 + var k APIKey 947 + err := db.QueryRow(db.Rebind(` 948 + SELECT id, owner_did, name, key_hash, created_at, last_used_at 949 + FROM api_keys 950 + WHERE key_hash = ? 951 + `), keyHash).Scan(&k.ID, &k.OwnerDID, &k.Name, &k.KeyHash, &k.CreatedAt, &k.LastUsedAt) 952 + if err != nil { 953 + return nil, err 954 + } 955 + return &k, nil 956 + } 957 + 958 + func (db *DB) DeleteAPIKey(id, ownerDID string) error { 959 + _, err := db.Exec(db.Rebind(`DELETE FROM api_keys WHERE id = ? AND owner_did = ?`), id, ownerDID) 960 + return err 961 + } 962 + 963 + func (db *DB) UpdateAPIKeyLastUsed(id string) error { 964 + _, err := db.Exec(db.Rebind(`UPDATE api_keys SET last_used_at = ? WHERE id = ?`), time.Now(), id) 965 + return err 966 + }
+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 + }
+236 -84
backend/internal/firehose/ingester.go
··· 3 3 import ( 4 4 "bytes" 5 5 "context" 6 + "encoding/binary" 6 7 "encoding/json" 7 8 "fmt" 8 9 "io" 9 10 "log" 10 - "net/http" 11 - "strings" 12 11 "time" 12 + 13 + "github.com/fxamacker/cbor/v2" 14 + "github.com/gorilla/websocket" 15 + "github.com/ipfs/go-cid" 13 16 14 17 "margin.at/internal/db" 15 18 ) ··· 56 59 return 57 60 default: 58 61 if err := i.subscribe(ctx); err != nil { 62 + log.Printf("Firehose error: %v, reconnecting in 5s...", err) 59 63 if ctx.Err() != nil { 60 64 return 61 65 } 62 - time.Sleep(30 * time.Second) 66 + time.Sleep(5 * time.Second) 63 67 } 64 68 } 65 69 } 66 70 } 67 71 72 + type FrameHeader struct { 73 + Op int `cbor:"op"` 74 + T string `cbor:"t"` 75 + } 76 + type Commit struct { 77 + Repo string `cbor:"repo"` 78 + Rev string `cbor:"rev"` 79 + Seq int64 `cbor:"seq"` 80 + Prev *cid.Cid `cbor:"prev"` 81 + Time string `cbor:"time"` 82 + Blocks []byte `cbor:"blocks"` 83 + Ops []RepoOp `cbor:"ops"` 84 + } 85 + 86 + type RepoOp struct { 87 + Action string `cbor:"action"` 88 + Path string `cbor:"path"` 89 + Cid *cid.Cid `cbor:"cid"` 90 + } 91 + 68 92 func (i *Ingester) subscribe(ctx context.Context) error { 69 93 cursor := i.getLastCursor() 70 94 ··· 73 97 url = fmt.Sprintf("%s?cursor=%d", RelayURL, cursor) 74 98 } 75 99 76 - req, err := http.NewRequestWithContext(ctx, "GET", strings.Replace(url, "wss://", "https://", 1), nil) 77 - if err != nil { 78 - return err 79 - } 100 + log.Printf("Connecting to firehose: %s", url) 80 101 81 - resp, err := http.DefaultClient.Do(req) 102 + conn, _, err := websocket.DefaultDialer.DialContext(ctx, url, nil) 82 103 if err != nil { 83 - return err 104 + return fmt.Errorf("websocket dial failed: %w", err) 84 105 } 85 - defer resp.Body.Close() 106 + defer conn.Close() 86 107 87 - if resp.StatusCode != 200 { 88 - body, _ := io.ReadAll(resp.Body) 89 - return fmt.Errorf("firehose returned %d: %s", resp.StatusCode, string(body)) 90 - } 108 + log.Printf("Connected to firehose") 91 109 92 - decoder := json.NewDecoder(resp.Body) 93 110 for { 94 111 select { 95 112 case <-ctx.Done(): ··· 97 114 default: 98 115 } 99 116 100 - var event FirehoseEvent 101 - if err := decoder.Decode(&event); err != nil { 102 - if err == io.EOF { 103 - return nil 104 - } 105 - return err 117 + _, message, err := conn.ReadMessage() 118 + if err != nil { 119 + return fmt.Errorf("websocket read failed: %w", err) 106 120 } 107 121 108 - i.handleEvent(&event) 122 + i.handleMessage(message) 109 123 } 110 124 } 111 125 112 - type FirehoseEvent struct { 113 - Repo string `json:"repo"` 114 - Collection string `json:"collection"` 115 - Rkey string `json:"rkey"` 116 - Record json.RawMessage `json:"record"` 117 - Operation string `json:"operation"` 118 - Cursor int64 `json:"cursor"` 119 - } 126 + func (i *Ingester) handleMessage(data []byte) { 127 + reader := bytes.NewReader(data) 120 128 121 - func (i *Ingester) handleEvent(event *FirehoseEvent) { 122 - uri := fmt.Sprintf("at://%s/%s/%s", event.Repo, event.Collection, event.Rkey) 129 + var header FrameHeader 130 + decoder := cbor.NewDecoder(reader) 131 + if err := decoder.Decode(&header); err != nil { 132 + return 133 + } 123 134 124 - switch event.Collection { 125 - case CollectionAnnotation: 126 - switch event.Operation { 127 - case "create", "update": 128 - i.handleAnnotation(event) 129 - case "delete": 130 - i.db.DeleteAnnotation(uri) 135 + if header.Op != 1 { 136 + return 137 + } 138 + 139 + if header.T != "#commit" { 140 + return 141 + } 142 + 143 + var commit Commit 144 + if err := decoder.Decode(&commit); err != nil { 145 + return 146 + } 147 + 148 + for _, op := range commit.Ops { 149 + collection, rkey := parseOpPath(op.Path) 150 + if !isMarginCollection(collection) { 151 + continue 131 152 } 132 - case CollectionHighlight: 133 - switch event.Operation { 153 + 154 + uri := fmt.Sprintf("at://%s/%s/%s", commit.Repo, collection, rkey) 155 + 156 + switch op.Action { 134 157 case "create", "update": 135 - i.handleHighlight(event) 158 + if op.Cid != nil && len(commit.Blocks) > 0 { 159 + record := extractRecord(commit.Blocks, *op.Cid) 160 + if record != nil { 161 + i.handleRecord(commit.Repo, collection, rkey, record, commit.Seq) 162 + } 163 + } 136 164 case "delete": 137 - i.db.DeleteHighlight(uri) 165 + i.handleDelete(collection, uri) 138 166 } 139 - case CollectionBookmark: 140 - switch event.Operation { 141 - case "create", "update": 142 - i.handleBookmark(event) 143 - case "delete": 144 - i.db.DeleteBookmark(uri) 167 + } 168 + 169 + if commit.Seq > 0 { 170 + if err := i.db.SetCursor("firehose_cursor", commit.Seq); err != nil { 171 + log.Printf("Failed to save cursor: %v", err) 145 172 } 146 - case CollectionReply: 147 - switch event.Operation { 148 - case "create", "update": 149 - i.handleReply(event) 150 - case "delete": 151 - i.db.DeleteReply(uri) 173 + } 174 + } 175 + 176 + func parseOpPath(path string) (collection, rkey string) { 177 + for i := len(path) - 1; i >= 0; i-- { 178 + if path[i] == '/' { 179 + return path[:i], path[i+1:] 152 180 } 153 - case CollectionLike: 154 - switch event.Operation { 155 - case "create": 156 - i.handleLike(event) 157 - case "delete": 158 - i.db.DeleteLike(uri) 181 + } 182 + return path, "" 183 + } 184 + 185 + func isMarginCollection(collection string) bool { 186 + switch collection { 187 + case CollectionAnnotation, CollectionHighlight, CollectionBookmark, 188 + CollectionReply, CollectionLike, CollectionCollection, CollectionCollectionItem: 189 + return true 190 + } 191 + return false 192 + } 193 + 194 + func extractRecord(blocks []byte, targetCid cid.Cid) map[string]interface{} { 195 + reader := bytes.NewReader(blocks) 196 + 197 + headerLen, err := binary.ReadUvarint(reader) 198 + if err != nil { 199 + return nil 200 + } 201 + reader.Seek(int64(headerLen), io.SeekCurrent) 202 + 203 + for reader.Len() > 0 { 204 + blockLen, err := binary.ReadUvarint(reader) 205 + if err != nil { 206 + break 159 207 } 160 - case CollectionCollection: 161 - switch event.Operation { 162 - case "create", "update": 163 - i.handleCollection(event) 164 - case "delete": 165 - i.db.DeleteCollection(uri) 208 + 209 + blockData := make([]byte, blockLen) 210 + if _, err := io.ReadFull(reader, blockData); err != nil { 211 + break 166 212 } 167 - case CollectionCollectionItem: 168 - switch event.Operation { 169 - case "create", "update": 170 - i.handleCollectionItem(event) 171 - case "delete": 172 - i.db.RemoveFromCollection(uri) 213 + 214 + blockCid, cidLen, err := parseCidFromBlock(blockData) 215 + if err != nil { 216 + continue 217 + } 218 + 219 + if blockCid.Equals(targetCid) { 220 + var record map[string]interface{} 221 + if err := cbor.Unmarshal(blockData[cidLen:], &record); err != nil { 222 + return nil 223 + } 224 + return record 173 225 } 174 226 } 175 227 176 - if event.Cursor > 0 { 177 - if err := i.db.SetCursor("firehose_cursor", event.Cursor); err != nil { 178 - log.Printf("Failed to save cursor: %v", err) 228 + return nil 229 + } 230 + 231 + func parseCidFromBlock(data []byte) (cid.Cid, int, error) { 232 + if len(data) < 2 { 233 + return cid.Cid{}, 0, fmt.Errorf("data too short") 234 + } 235 + version, n1 := binary.Uvarint(data) 236 + if n1 <= 0 { 237 + return cid.Cid{}, 0, fmt.Errorf("invalid version varint") 238 + } 239 + 240 + if version == 1 { 241 + codec, n2 := binary.Uvarint(data[n1:]) 242 + if n2 <= 0 { 243 + return cid.Cid{}, 0, fmt.Errorf("invalid codec varint") 179 244 } 245 + 246 + mhStart := n1 + n2 247 + hashType, n3 := binary.Uvarint(data[mhStart:]) 248 + if n3 <= 0 { 249 + return cid.Cid{}, 0, fmt.Errorf("invalid hash type varint") 250 + } 251 + 252 + hashLen, n4 := binary.Uvarint(data[mhStart+n3:]) 253 + if n4 <= 0 { 254 + return cid.Cid{}, 0, fmt.Errorf("invalid hash length varint") 255 + } 256 + 257 + totalCidLen := mhStart + n3 + n4 + int(hashLen) 258 + 259 + c, err := cid.Cast(data[:totalCidLen]) 260 + if err != nil { 261 + return cid.Cid{}, 0, err 262 + } 263 + 264 + _ = codec 265 + _ = hashType 266 + 267 + return c, totalCidLen, nil 180 268 } 269 + 270 + return cid.Cid{}, 0, fmt.Errorf("unsupported CID version") 181 271 } 182 272 183 - func (i *Ingester) handleAnnotation(event *FirehoseEvent) { 273 + func (i *Ingester) handleDelete(collection, uri string) { 274 + switch collection { 275 + case CollectionAnnotation: 276 + i.db.DeleteAnnotation(uri) 277 + case CollectionHighlight: 278 + i.db.DeleteHighlight(uri) 279 + case CollectionBookmark: 280 + i.db.DeleteBookmark(uri) 281 + case CollectionReply: 282 + i.db.DeleteReply(uri) 283 + case CollectionLike: 284 + i.db.DeleteLike(uri) 285 + case CollectionCollection: 286 + i.db.DeleteCollection(uri) 287 + case CollectionCollectionItem: 288 + i.db.RemoveFromCollection(uri) 289 + } 290 + } 184 291 292 + func (i *Ingester) handleRecord(repo, collection, rkey string, record map[string]interface{}, seq int64) { 293 + _ = fmt.Sprintf("at://%s/%s/%s", repo, collection, rkey) 294 + 295 + recordJSON, err := json.Marshal(record) 296 + if err != nil { 297 + return 298 + } 299 + 300 + event := &FirehoseEvent{ 301 + Repo: repo, 302 + Collection: collection, 303 + Rkey: rkey, 304 + Record: recordJSON, 305 + Operation: "create", 306 + Cursor: seq, 307 + } 308 + 309 + switch collection { 310 + case CollectionAnnotation: 311 + i.handleAnnotation(event) 312 + case CollectionHighlight: 313 + i.handleHighlight(event) 314 + case CollectionBookmark: 315 + i.handleBookmark(event) 316 + case CollectionReply: 317 + i.handleReply(event) 318 + case CollectionLike: 319 + i.handleLike(event) 320 + case CollectionCollection: 321 + i.handleCollection(event) 322 + case CollectionCollectionItem: 323 + i.handleCollectionItem(event) 324 + } 325 + } 326 + 327 + type FirehoseEvent struct { 328 + Repo string `json:"repo"` 329 + Collection string `json:"collection"` 330 + Rkey string `json:"rkey"` 331 + Record json.RawMessage `json:"record"` 332 + Operation string `json:"operation"` 333 + Cursor int64 `json:"cursor"` 334 + } 335 + 336 + func (i *Ingester) handleAnnotation(event *FirehoseEvent) { 185 337 var record struct { 186 338 Motivation string `json:"motivation"` 187 339 Body struct { ··· 205 357 Title string `json:"title"` 206 358 } 207 359 208 - if err := json.NewDecoder(bytes.NewReader(event.Record)).Decode(&record); err != nil { 360 + if err := json.Unmarshal(event.Record, &record); err != nil { 209 361 return 210 362 } 211 363 ··· 302 454 CreatedAt string `json:"createdAt"` 303 455 } 304 456 305 - if err := json.NewDecoder(bytes.NewReader(event.Record)).Decode(&record); err != nil { 457 + if err := json.Unmarshal(event.Record, &record); err != nil { 306 458 return 307 459 } 308 460 ··· 334 486 CreatedAt string `json:"createdAt"` 335 487 } 336 488 337 - if err := json.NewDecoder(bytes.NewReader(event.Record)).Decode(&record); err != nil { 489 + if err := json.Unmarshal(event.Record, &record); err != nil { 338 490 return 339 491 } 340 492 ··· 369 521 CreatedAt string `json:"createdAt"` 370 522 } 371 523 372 - if err := json.NewDecoder(bytes.NewReader(event.Record)).Decode(&record); err != nil { 524 + if err := json.Unmarshal(event.Record, &record); err != nil { 373 525 return 374 526 } 375 527 ··· 432 584 CreatedAt string `json:"createdAt"` 433 585 } 434 586 435 - if err := json.NewDecoder(bytes.NewReader(event.Record)).Decode(&record); err != nil { 587 + if err := json.Unmarshal(event.Record, &record); err != nil { 436 588 return 437 589 } 438 590 ··· 488 640 CreatedAt string `json:"createdAt"` 489 641 } 490 642 491 - if err := json.NewDecoder(bytes.NewReader(event.Record)).Decode(&record); err != nil { 643 + if err := json.Unmarshal(event.Record, &record); err != nil { 492 644 return 493 645 } 494 646 ··· 532 684 CreatedAt string `json:"createdAt"` 533 685 } 534 686 535 - if err := json.NewDecoder(bytes.NewReader(event.Record)).Decode(&record); err != nil { 687 + if err := json.Unmarshal(event.Record, &record); err != nil { 536 688 return 537 689 } 538 690
+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 }
+2 -1
docker-compose.yml
··· 9 9 - OAUTH_KEY_PATH=/data/oauth_private_key.pem 10 10 env_file: 11 11 - .env 12 + volumes: 13 + - margin-data:/data 12 14 depends_on: 13 15 db: 14 16 condition: service_healthy ··· 23 25 24 26 volumes: 25 27 - db-data:/var/lib/postgresql/data 26 - - margin-data:/data 27 28 healthcheck: 28 29 test: ["CMD-SHELL", "pg_isready -U margin"] 29 30 interval: 5s
+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 (e) { 191 + console.debug("Inline annotate failed, falling back to new tab:", e); 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();
+993 -240
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 + let activeItems = []; 7 + let currentSelection = null; 8 + 9 + const OVERLAY_STYLES = ` 10 + :host { all: initial; } 11 + .margin-overlay { 12 + position: absolute; 13 + top: 0; 14 + left: 0; 15 + width: 100%; 16 + height: 100%; 17 + pointer-events: none; 18 + } 8 19 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); 20 + .margin-popover { 21 + position: absolute; 22 + width: 320px; 23 + background: #09090b; 24 + border: 1px solid #27272a; 25 + border-radius: 12px; 26 + padding: 0; 27 + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.5), 0 10px 10px -5px rgba(0, 0, 0, 0.2); 28 + display: flex; 29 + flex-direction: column; 30 + pointer-events: auto; 31 + z-index: 2147483647; 32 + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 33 + color: #e4e4e7; 34 + opacity: 0; 35 + transform: scale(0.95); 36 + animation: popover-in 0.15s forwards; 37 + max-height: 480px; 38 + overflow: hidden; 39 + } 40 + @keyframes popover-in { to { opacity: 1; transform: scale(1); } } 41 + .popover-header { 42 + padding: 12px 16px; 43 + border-bottom: 1px solid #27272a; 44 + display: flex; 45 + justify-content: space-between; 46 + align-items: center; 47 + background: #0f0f12; 48 + border-radius: 12px 12px 0 0; 49 + font-weight: 600; 50 + font-size: 13px; 51 + } 52 + .popover-scroll-area { 53 + overflow-y: auto; 54 + max-height: 400px; 55 + } 56 + .popover-item-block { 57 + border-bottom: 1px solid #27272a; 58 + margin-bottom: 0; 59 + animation: fade-in 0.2s; 60 + } 61 + .popover-item-block:last-child { 62 + border-bottom: none; 63 + } 64 + .popover-item-header { 65 + padding: 12px 16px 4px; 66 + display: flex; 67 + align-items: center; 68 + gap: 8px; 69 + } 70 + .popover-avatar { 71 + width: 24px; height: 24px; border-radius: 50%; background: #27272a; 72 + display: flex; align-items: center; justify-content: center; 73 + font-size: 10px; color: #a1a1aa; 74 + } 75 + .popover-handle { font-size: 12px; font-weight: 600; color: #e4e4e7; } 76 + .popover-close { background: none; border: none; color: #71717a; cursor: pointer; padding: 4px; } 77 + .popover-close:hover { color: #e4e4e7; } 78 + .popover-content { padding: 4px 16px 12px; font-size: 13px; line-height: 1.5; color: #e4e4e7; } 79 + .popover-quote { 80 + margin-top: 8px; padding: 6px 10px; background: #18181b; 81 + border-left: 2px solid #6366f1; border-radius: 4px; 82 + font-size: 11px; color: #a1a1aa; font-style: italic; 83 + } 84 + .popover-actions { 85 + padding: 8px 16px; 86 + display: flex; justify-content: flex-end; gap: 8px; 87 + } 88 + .btn-action { 89 + background: none; border: 1px solid #27272a; border-radius: 4px; 90 + padding: 4px 8px; color: #a1a1aa; font-size: 11px; cursor: pointer; 91 + } 92 + .btn-action:hover { background: #27272a; color: #e4e4e7; } 93 + 94 + .margin-selection-popup { 95 + position: fixed; 96 + display: flex; 97 + gap: 4px; 98 + padding: 6px; 99 + background: #09090b; 100 + border: 1px solid #27272a; 101 + border-radius: 8px; 102 + box-shadow: 0 8px 16px rgba(0,0,0,0.4); 103 + z-index: 2147483647; 104 + pointer-events: auto; 105 + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 106 + animation: popover-in 0.15s forwards; 107 + } 108 + .selection-btn { 109 + display: flex; 110 + align-items: center; 111 + gap: 6px; 112 + padding: 6px 12px; 113 + background: transparent; 114 + border: none; 115 + border-radius: 6px; 116 + color: #e4e4e7; 117 + font-size: 12px; 118 + font-weight: 500; 119 + cursor: pointer; 120 + transition: background 0.15s; 121 + } 122 + .selection-btn:hover { 123 + background: #27272a; 124 + } 125 + .selection-btn svg { 126 + width: 14px; 127 + height: 14px; 128 + } 129 + .inline-compose-modal { 130 + position: fixed; 131 + width: 340px; 132 + max-width: calc(100vw - 40px); 133 + background: #09090b; 134 + border: 1px solid #27272a; 135 + border-radius: 12px; 136 + padding: 16px; 137 + box-sizing: border-box; 138 + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.5); 139 + z-index: 2147483647; 140 + pointer-events: auto; 141 + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 142 + color: #e4e4e7; 143 + animation: popover-in 0.15s forwards; 144 + overflow: hidden; 145 + } 146 + .inline-compose-modal * { 147 + box-sizing: border-box; 148 + } 149 + .inline-compose-quote { 150 + padding: 8px 12px; 151 + background: #18181b; 152 + border-left: 3px solid #6366f1; 153 + border-radius: 4px; 154 + font-size: 12px; 155 + color: #a1a1aa; 156 + font-style: italic; 157 + margin-bottom: 12px; 158 + max-height: 60px; 159 + overflow: hidden; 160 + word-break: break-word; 161 + } 162 + .inline-compose-textarea { 163 + width: 100%; 164 + min-height: 80px; 165 + padding: 10px 12px; 166 + background: #18181b; 167 + border: 1px solid #27272a; 168 + border-radius: 8px; 169 + color: #e4e4e7; 170 + font-family: inherit; 171 + font-size: 13px; 172 + resize: vertical; 173 + margin-bottom: 12px; 174 + box-sizing: border-box; 175 + } 176 + .inline-compose-textarea:focus { 177 + outline: none; 178 + border-color: #6366f1; 179 + } 180 + .inline-compose-actions { 181 + display: flex; 182 + justify-content: flex-end; 183 + gap: 8px; 184 + } 185 + .btn-cancel { 186 + padding: 8px 16px; 187 + background: transparent; 188 + border: 1px solid #27272a; 189 + border-radius: 6px; 190 + color: #a1a1aa; 191 + font-size: 13px; 192 + cursor: pointer; 193 + } 194 + .btn-cancel:hover { 195 + background: #27272a; 196 + color: #e4e4e7; 18 197 } 198 + .btn-submit { 199 + padding: 8px 16px; 200 + background: #6366f1; 201 + border: none; 202 + border-radius: 6px; 203 + color: white; 204 + font-size: 13px; 205 + font-weight: 500; 206 + cursor: pointer; 207 + } 208 + .btn-submit:hover { 209 + background: #4f46e5; 210 + } 211 + .btn-submit:disabled { 212 + opacity: 0.5; 213 + cursor: not-allowed; 214 + } 215 + .reply-section { 216 + border-top: 1px solid #27272a; 217 + padding: 12px 16px; 218 + background: #0f0f12; 219 + border-radius: 0 0 12px 12px; 220 + } 221 + .reply-textarea { 222 + width: 100%; 223 + min-height: 60px; 224 + padding: 8px 10px; 225 + background: #18181b; 226 + border: 1px solid #27272a; 227 + border-radius: 6px; 228 + color: #e4e4e7; 229 + font-family: inherit; 230 + font-size: 12px; 231 + resize: none; 232 + margin-bottom: 8px; 233 + } 234 + .reply-textarea:focus { 235 + outline: none; 236 + border-color: #6366f1; 237 + } 238 + .reply-submit { 239 + padding: 6px 12px; 240 + background: #6366f1; 241 + border: none; 242 + border-radius: 4px; 243 + color: white; 244 + font-size: 11px; 245 + font-weight: 500; 246 + cursor: pointer; 247 + float: right; 248 + } 249 + .reply-submit:disabled { 250 + opacity: 0.5; 251 + } 252 + .reply-item { 253 + padding: 8px 0; 254 + border-top: 1px solid #27272a; 255 + } 256 + .reply-item:first-child { 257 + border-top: none; 258 + } 259 + .reply-author { 260 + font-size: 11px; 261 + font-weight: 600; 262 + color: #a1a1aa; 263 + margin-bottom: 4px; 264 + } 265 + .reply-text { 266 + font-size: 12px; 267 + color: #e4e4e7; 268 + line-height: 1.4; 269 + } 270 + `; 19 271 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); 272 + class DOMTextMatcher { 273 + constructor() { 274 + this.textNodes = []; 275 + this.corpus = ""; 276 + this.indices = []; 277 + this.buildMap(); 29 278 } 30 279 31 - return { 32 - type: "TextQuoteSelector", 33 - exact: exact, 34 - prefix: prefix || undefined, 35 - suffix: suffix || undefined, 36 - }; 37 - } 280 + buildMap() { 281 + const walker = document.createTreeWalker( 282 + document.body, 283 + NodeFilter.SHOW_TEXT, 284 + { 285 + acceptNode: (node) => { 286 + if (!node.parentNode) return NodeFilter.FILTER_REJECT; 287 + const tag = node.parentNode.tagName; 288 + if ( 289 + ["SCRIPT", "STYLE", "NOSCRIPT", "TEXTAREA", "INPUT"].includes(tag) 290 + ) 291 + return NodeFilter.FILTER_REJECT; 292 + if (node.textContent.trim().length === 0) 293 + return NodeFilter.FILTER_SKIP; 38 294 39 - function findAndScrollToText(selector) { 40 - if (!selector || !selector.exact) return false; 295 + if (node.parentNode.offsetParent === null) 296 + return NodeFilter.FILTER_REJECT; 41 297 42 - const searchText = selector.exact.trim(); 43 - const normalizedSearch = searchText.replace(/\s+/g, " "); 298 + return NodeFilter.FILTER_ACCEPT; 299 + }, 300 + }, 301 + ); 302 + 303 + let currentNode; 304 + let index = 0; 305 + while ((currentNode = walker.nextNode())) { 306 + const text = currentNode.textContent; 307 + this.textNodes.push(currentNode); 308 + this.corpus += text; 309 + this.indices.push({ 310 + start: index, 311 + node: currentNode, 312 + length: text.length, 313 + }); 314 + index += text.length; 315 + } 316 + } 44 317 45 - const treeWalker = document.createTreeWalker( 46 - document.body, 47 - NodeFilter.SHOW_TEXT, 48 - null, 49 - false, 50 - ); 318 + findRange(searchText) { 319 + if (!searchText) return null; 51 320 52 - let currentNode; 53 - while ((currentNode = treeWalker.nextNode())) { 54 - const nodeText = currentNode.textContent; 55 - const normalizedNode = nodeText.replace(/\s+/g, " "); 321 + let matchIndex = this.corpus.indexOf(searchText); 56 322 57 - let index = nodeText.indexOf(searchText); 323 + if (matchIndex === -1) { 324 + const normalizedSearch = searchText.replace(/\s+/g, " ").trim(); 325 + matchIndex = this.corpus.indexOf(normalizedSearch); 58 326 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; 327 + if (matchIndex === -1) { 328 + const fuzzyMatch = this.fuzzyFindInCorpus(searchText); 329 + if (fuzzyMatch) { 330 + const start = this.mapIndexToPoint(fuzzyMatch.start); 331 + const end = this.mapIndexToPoint(fuzzyMatch.end); 332 + if (start && end) { 333 + const range = document.createRange(); 334 + range.setStart(start.node, start.offset); 335 + range.setEnd(end.node, end.offset); 336 + return range; 337 + } 338 + } 339 + return null; 64 340 } 65 341 } 66 342 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); 343 + const start = this.mapIndexToPoint(matchIndex); 344 + const end = this.mapIndexToPoint(matchIndex + searchText.length); 73 345 74 - if (typeof CSS !== "undefined" && CSS.highlights) { 75 - const highlight = new Highlight(range); 76 - CSS.highlights.set("margin-scroll-highlight", highlight); 346 + if (start && end) { 347 + const range = document.createRange(); 348 + range.setStart(start.node, start.offset); 349 + range.setEnd(end.node, end.offset); 350 + return range; 351 + } 352 + return null; 353 + } 354 + 355 + fuzzyFindInCorpus(searchText) { 356 + const searchWords = searchText 357 + .trim() 358 + .split(/\s+/) 359 + .filter((w) => w.length > 0); 360 + if (searchWords.length === 0) return null; 361 + 362 + const corpusLower = this.corpus.toLowerCase(); 363 + 364 + const firstWord = searchWords[0].toLowerCase(); 365 + let searchStart = 0; 366 + 367 + while (searchStart < corpusLower.length) { 368 + const wordStart = corpusLower.indexOf(firstWord, searchStart); 369 + if (wordStart === -1) break; 77 370 78 - setTimeout(() => { 79 - CSS.highlights.delete("margin-scroll-highlight"); 80 - }, 3000); 371 + let corpusPos = wordStart; 372 + let matched = true; 373 + let lastMatchEnd = wordStart; 374 + 375 + for (const word of searchWords) { 376 + const wordLower = word.toLowerCase(); 377 + while ( 378 + corpusPos < corpusLower.length && 379 + /\s/.test(this.corpus[corpusPos]) 380 + ) { 381 + corpusPos++; 382 + } 383 + const corpusSlice = corpusLower.slice( 384 + corpusPos, 385 + corpusPos + wordLower.length, 386 + ); 387 + if (corpusSlice !== wordLower) { 388 + matched = false; 389 + break; 81 390 } 82 391 83 - const rect = range.getBoundingClientRect(); 84 - window.scrollTo({ 85 - top: window.scrollY + rect.top - window.innerHeight / 3, 86 - behavior: "smooth", 87 - }); 88 - 89 - window.scrollTo({ 90 - top: window.scrollY + rect.top - window.innerHeight / 3, 91 - behavior: "smooth", 92 - }); 392 + corpusPos += wordLower.length; 393 + lastMatchEnd = corpusPos; 394 + } 93 395 94 - return true; 95 - } catch (e) { 96 - console.warn("Could not create range:", e); 396 + if (matched) { 397 + return { start: wordStart, end: lastMatchEnd }; 97 398 } 399 + 400 + searchStart = wordStart + 1; 98 401 } 402 + 403 + return null; 99 404 } 100 405 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 - }); 406 + mapIndexToPoint(corpusIndex) { 407 + for (const info of this.indices) { 408 + if ( 409 + corpusIndex >= info.start && 410 + corpusIndex < info.start + info.length 411 + ) { 412 + return { node: info.node, offset: corpusIndex - info.start }; 113 413 } 114 - return true; 115 414 } 415 + if (this.indices.length > 0) { 416 + const last = this.indices[this.indices.length - 1]; 417 + if (corpusIndex === last.start + last.length) { 418 + return { node: last.node, offset: last.length }; 419 + } 420 + } 421 + return null; 116 422 } 423 + } 117 424 118 - return false; 425 + function initOverlay() { 426 + sidebarHost = document.createElement("div"); 427 + sidebarHost.id = "margin-overlay-host"; 428 + const getScrollHeight = () => { 429 + const bodyH = document.body?.scrollHeight || 0; 430 + const docH = document.documentElement?.scrollHeight || 0; 431 + return Math.max(bodyH, docH); 432 + }; 433 + 434 + sidebarHost.style.cssText = ` 435 + position: absolute; top: 0; left: 0; width: 100%; 436 + height: ${getScrollHeight()}px; 437 + pointer-events: none; z-index: 2147483647; 438 + `; 439 + document.body?.appendChild(sidebarHost) || 440 + document.documentElement.appendChild(sidebarHost); 441 + 442 + sidebarShadow = sidebarHost.attachShadow({ mode: "open" }); 443 + const styleEl = document.createElement("style"); 444 + styleEl.textContent = OVERLAY_STYLES; 445 + sidebarShadow.appendChild(styleEl); 446 + 447 + const container = document.createElement("div"); 448 + container.className = "margin-overlay"; 449 + container.id = "margin-overlay-container"; 450 + sidebarShadow.appendChild(container); 451 + 452 + const observer = new ResizeObserver(() => { 453 + sidebarHost.style.height = `${getScrollHeight()}px`; 454 + }); 455 + if (document.body) observer.observe(document.body); 456 + if (document.documentElement) observer.observe(document.documentElement); 457 + 458 + if (typeof chrome !== "undefined" && chrome.storage) { 459 + chrome.storage.local.get(["showOverlay"], (result) => { 460 + if (result.showOverlay === false) { 461 + sidebarHost.style.display = "none"; 462 + } else { 463 + fetchAnnotations(); 464 + } 465 + }); 466 + } else { 467 + fetchAnnotations(); 468 + } 469 + 470 + document.addEventListener("mousemove", handleMouseMove); 471 + document.addEventListener("click", handleDocumentClick, true); 119 472 } 120 473 121 - function renderPageHighlights(highlights) { 122 - if (!highlights || !Array.isArray(highlights) || !CSS.highlights) return; 474 + function showInlineComposeModal() { 475 + if (!sidebarShadow || !currentSelection) return; 123 476 124 - const ranges = []; 477 + const container = sidebarShadow.getElementById("margin-overlay-container"); 478 + if (!container) return; 125 479 126 - highlights.forEach((item) => { 127 - const selector = item.target?.selector; 128 - if (!selector?.exact) return; 480 + const existingModal = container.querySelector(".inline-compose-modal"); 481 + if (existingModal) existingModal.remove(); 482 + 483 + const modal = document.createElement("div"); 484 + modal.className = "inline-compose-modal"; 485 + 486 + modal.style.left = `${Math.max(20, (window.innerWidth - 340) / 2)}px`; 487 + modal.style.top = `${Math.min(200, window.innerHeight / 4)}px`; 488 + 489 + const truncatedQuote = 490 + currentSelection.text.length > 100 491 + ? currentSelection.text.substring(0, 100) + "..." 492 + : currentSelection.text; 493 + 494 + modal.innerHTML = ` 495 + <div class="inline-compose-quote">"${truncatedQuote}"</div> 496 + <textarea class="inline-compose-textarea" placeholder="Add your annotation..." autofocus></textarea> 497 + <div class="inline-compose-actions"> 498 + <button class="btn-cancel">Cancel</button> 499 + <button class="btn-submit">Post Annotation</button> 500 + </div> 501 + `; 502 + 503 + const textarea = modal.querySelector("textarea"); 504 + const submitBtn = modal.querySelector(".btn-submit"); 505 + const cancelBtn = modal.querySelector(".btn-cancel"); 506 + 507 + cancelBtn.addEventListener("click", () => { 508 + modal.remove(); 509 + }); 510 + 511 + submitBtn.addEventListener("click", async () => { 512 + const text = textarea.value.trim(); 513 + if (!text) return; 514 + 515 + submitBtn.disabled = true; 516 + submitBtn.textContent = "Posting..."; 129 517 130 - const searchText = selector.exact; 131 - const treeWalker = document.createTreeWalker( 132 - document.body, 133 - NodeFilter.SHOW_TEXT, 134 - null, 135 - false, 518 + chrome.runtime.sendMessage( 519 + { 520 + type: "CREATE_ANNOTATION", 521 + data: { 522 + url: currentSelection.url || window.location.href, 523 + title: currentSelection.title || document.title, 524 + text: text, 525 + selector: currentSelection.selector, 526 + }, 527 + }, 528 + (res) => { 529 + if (res && res.success) { 530 + modal.remove(); 531 + fetchAnnotations(); 532 + } else { 533 + submitBtn.disabled = false; 534 + submitBtn.textContent = "Post Annotation"; 535 + alert( 536 + "Failed to create annotation: " + (res?.error || "Unknown error"), 537 + ); 538 + } 539 + }, 136 540 ); 541 + }); 137 542 138 - let currentNode; 139 - while ((currentNode = treeWalker.nextNode())) { 140 - const nodeText = currentNode.textContent; 141 - const index = nodeText.indexOf(searchText); 543 + container.appendChild(modal); 544 + textarea.focus(); 545 + 546 + const handleEscape = (e) => { 547 + if (e.key === "Escape") { 548 + modal.remove(); 549 + document.removeEventListener("keydown", handleEscape); 550 + } 551 + }; 552 + document.addEventListener("keydown", handleEscape); 553 + } 142 554 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); 555 + let hoverIndicator = null; 556 + 557 + function handleMouseMove(e) { 558 + const x = e.clientX; 559 + const y = e.clientY; 560 + let foundItems = []; 561 + let firstRange = null; 562 + for (const { range, item } of activeItems) { 563 + const rects = range.getClientRects(); 564 + for (const rect of rects) { 565 + if ( 566 + x >= rect.left && 567 + x <= rect.right && 568 + y >= rect.top && 569 + y <= rect.bottom 570 + ) { 571 + if (!firstRange) firstRange = range; 572 + if (!foundItems.some((f) => f.item === item)) { 573 + foundItems.push({ range, item, rect }); 151 574 } 152 575 break; 153 576 } 154 577 } 155 - }); 578 + } 156 579 157 - if (ranges.length > 0) { 158 - const highlight = new Highlight(...ranges); 159 - CSS.highlights.set("margin-page-highlights", highlight); 160 - } 161 - } 580 + if (foundItems.length > 0) { 581 + document.body.style.cursor = "pointer"; 162 582 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; 583 + if (!hoverIndicator && sidebarShadow) { 584 + const container = sidebarShadow.getElementById( 585 + "margin-overlay-container", 586 + ); 587 + if (container) { 588 + hoverIndicator = document.createElement("div"); 589 + hoverIndicator.className = "margin-hover-indicator"; 590 + hoverIndicator.style.cssText = ` 591 + position: fixed; 592 + display: flex; 593 + align-items: center; 594 + pointer-events: none; 595 + z-index: 2147483647; 596 + opacity: 0; 597 + transition: opacity 0.15s, transform 0.15s; 598 + transform: scale(0.8); 599 + `; 600 + container.appendChild(hoverIndicator); 601 + } 169 602 } 170 603 171 - const selector = buildTextQuoteSelector(selection); 172 - sendResponse({ selector: selector }); 173 - return true; 604 + if (hoverIndicator) { 605 + const authorsMap = new Map(); 606 + foundItems.forEach(({ item }) => { 607 + const author = item.author || item.creator || {}; 608 + const id = author.did || author.handle || "unknown"; 609 + if (!authorsMap.has(id)) { 610 + authorsMap.set(id, author); 611 + } 612 + }); 613 + const uniqueAuthors = Array.from(authorsMap.values()); 614 + 615 + const maxShow = 3; 616 + const displayAuthors = uniqueAuthors.slice(0, maxShow); 617 + const overflow = uniqueAuthors.length - maxShow; 618 + 619 + let html = displayAuthors 620 + .map((author, i) => { 621 + const avatar = author.avatar; 622 + const handle = author.handle || "U"; 623 + const marginLeft = i === 0 ? "0" : "-8px"; 624 + 625 + if (avatar) { 626 + return `<img src="${avatar}" style="width: 24px; height: 24px; border-radius: 50%; object-fit: cover; border: 2px solid #09090b; margin-left: ${marginLeft};">`; 627 + } else { 628 + 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>`; 629 + } 630 + }) 631 + .join(""); 632 + 633 + if (overflow > 0) { 634 + 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>`; 635 + } 636 + 637 + hoverIndicator.innerHTML = html; 638 + 639 + const firstRect = firstRange.getClientRects()[0]; 640 + const totalWidth = 641 + Math.min(uniqueAuthors.length, maxShow + (overflow > 0 ? 1 : 0)) * 642 + 18 + 643 + 8; 644 + const leftPos = firstRect.left - totalWidth; 645 + const topPos = firstRect.top + firstRect.height / 2 - 12; 646 + 647 + hoverIndicator.style.left = `${leftPos}px`; 648 + hoverIndicator.style.top = `${topPos}px`; 649 + hoverIndicator.style.opacity = "1"; 650 + hoverIndicator.style.transform = "scale(1)"; 651 + } 652 + } else { 653 + document.body.style.cursor = ""; 654 + if (hoverIndicator) { 655 + hoverIndicator.style.opacity = "0"; 656 + hoverIndicator.style.transform = "scale(0.8)"; 657 + } 174 658 } 659 + } 175 660 176 - if (request.type === "GET_SELECTOR_FOR_ANNOTATE") { 177 - const selection = window.getSelection(); 178 - if (!selection || selection.toString().trim().length === 0) { 661 + function handleDocumentClick(e) { 662 + const x = e.clientX; 663 + const y = e.clientY; 664 + if (popoverEl && sidebarShadow) { 665 + const rect = popoverEl.getBoundingClientRect(); 666 + if ( 667 + x >= rect.left && 668 + x <= rect.right && 669 + y >= rect.top && 670 + y <= rect.bottom 671 + ) { 179 672 return; 180 673 } 674 + } 181 675 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 - }); 676 + let clickedItems = []; 677 + for (const { range, item } of activeItems) { 678 + const rects = range.getClientRects(); 679 + for (const rect of rects) { 680 + if ( 681 + x >= rect.left && 682 + x <= rect.right && 683 + y >= rect.top && 684 + y <= rect.bottom 685 + ) { 686 + if (!clickedItems.includes(item)) { 687 + clickedItems.push(item); 688 + } 689 + break; 690 + } 191 691 } 192 692 } 193 693 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; 199 - } 694 + if (clickedItems.length > 0) { 695 + e.preventDefault(); 696 + e.stopPropagation(); 200 697 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"); 698 + if (popoverEl) { 699 + const currentIds = popoverEl.dataset.itemIds; 700 + const newIds = clickedItems 701 + .map((i) => i.uri || i.id) 702 + .sort() 703 + .join(","); 215 704 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 - } 705 + if (currentIds === newIds) { 706 + popoverEl.remove(); 707 + popoverEl = null; 708 + return; 709 + } 710 + } 225 711 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; 712 + const firstItem = clickedItems[0]; 713 + const match = activeItems.find((x) => x.item === firstItem); 714 + if (match) { 715 + const rects = match.range.getClientRects(); 716 + if (rects.length > 0) { 717 + const rect = rects[0]; 718 + const top = rect.top + window.scrollY; 719 + const left = rect.left + window.scrollX; 720 + showPopover(clickedItems, top, left); 721 + } 241 722 } 242 - sendResponse({ success: false, error: "Could not build selector" }); 243 - return true; 723 + } else { 724 + if (popoverEl) { 725 + popoverEl.remove(); 726 + popoverEl = null; 727 + } 244 728 } 729 + } 245 730 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"); 731 + function renderBadges(annotations) { 732 + if (!sidebarShadow) return; 733 + 734 + const itemsToRender = annotations || []; 735 + activeItems = []; 736 + const rangesByColor = {}; 737 + 738 + const matcher = new DOMTextMatcher(); 739 + 740 + itemsToRender.forEach((item) => { 741 + const selector = item.target?.selector || item.selector; 742 + if (!selector?.exact) return; 743 + 744 + const range = matcher.findRange(selector.exact); 745 + if (range) { 746 + activeItems.push({ range, item }); 747 + 748 + const color = item.color || "#6366f1"; 749 + if (!rangesByColor[color]) rangesByColor[color] = []; 750 + rangesByColor[color].push(range); 751 + } 752 + }); 753 + 754 + if (typeof CSS !== "undefined" && CSS.highlights) { 755 + CSS.highlights.clear(); 756 + for (const [color, ranges] of Object.entries(rangesByColor)) { 757 + const highlight = new Highlight(...ranges); 758 + const safeColor = color.replace(/[^a-zA-Z0-9]/g, ""); 759 + const name = `margin-hl-${safeColor}`; 760 + CSS.highlights.set(name, highlight); 761 + injectHighlightStyle(name, color); 250 762 } 251 763 } 764 + } 252 765 253 - if (request.type === "RENDER_HIGHLIGHTS") { 254 - renderPageHighlights(request.highlights); 766 + const injectedStyles = new Set(); 767 + function injectHighlightStyle(name, color) { 768 + if (injectedStyles.has(name)) return; 769 + const style = document.createElement("style"); 770 + style.textContent = ` 771 + ::highlight(${name}) { 772 + text-decoration: underline; 773 + text-decoration-color: ${color}; 774 + text-decoration-thickness: 2px; 775 + text-underline-offset: 2px; 776 + cursor: pointer; 777 + } 778 + `; 779 + document.head.appendChild(style); 780 + injectedStyles.add(name); 781 + } 782 + 783 + function showPopover(items, top, left) { 784 + if (popoverEl) popoverEl.remove(); 785 + const container = sidebarShadow.getElementById("margin-overlay-container"); 786 + popoverEl = document.createElement("div"); 787 + popoverEl.className = "margin-popover"; 788 + 789 + const ids = items 790 + .map((i) => i.uri || i.id) 791 + .sort() 792 + .join(","); 793 + popoverEl.dataset.itemIds = ids; 794 + 795 + const popWidth = 320; 796 + const screenWidth = window.innerWidth; 797 + let finalLeft = left; 798 + if (left + popWidth > screenWidth) finalLeft = screenWidth - popWidth - 20; 799 + 800 + popoverEl.style.top = `${top + 20}px`; 801 + popoverEl.style.left = `${finalLeft}px`; 802 + 803 + const hasHighlights = items.some((item) => item.type === "Highlight"); 804 + const hasAnnotations = items.some((item) => item.type !== "Highlight"); 805 + let title; 806 + if (items.length > 1) { 807 + if (hasHighlights && hasAnnotations) { 808 + title = `${items.length} Items`; 809 + } else if (hasHighlights) { 810 + title = `${items.length} Highlights`; 811 + } else { 812 + title = `${items.length} Annotations`; 813 + } 814 + } else { 815 + title = items[0]?.type === "Highlight" ? "Highlight" : "Annotation"; 255 816 } 256 817 257 - return true; 258 - }); 818 + let contentHtml = items 819 + .map((item) => { 820 + const author = item.author || item.creator || {}; 821 + const handle = author.handle || "User"; 822 + const avatar = author.avatar; 823 + const text = item.body?.value || item.text || ""; 824 + const quote = 825 + item.target?.selector?.exact || item.selector?.exact || ""; 826 + const id = item.id || item.uri; 827 + 828 + let avatarHtml = `<div class="popover-avatar">${handle[0]?.toUpperCase() || "U"}</div>`; 829 + if (avatar) { 830 + avatarHtml = `<img src="${avatar}" class="popover-avatar" style="object-fit: cover;">`; 831 + } 259 832 260 - function showNotification(message, type = "info") { 261 - const existing = document.querySelector(".margin-notification"); 262 - if (existing) existing.remove(); 833 + const isHighlight = item.type === "Highlight"; 263 834 264 - const notification = document.createElement("div"); 265 - notification.className = "margin-notification"; 266 - notification.textContent = message; 835 + let bodyHtml = ""; 836 + if (isHighlight) { 837 + bodyHtml = `<div class="popover-text" style="font-style: italic; color: #a1a1aa;">"${quote}"</div>`; 838 + } else { 839 + bodyHtml = `<div class="popover-text">${text}</div>`; 840 + if (quote) { 841 + bodyHtml += `<div class="popover-quote">"${quote}"</div>`; 842 + } 843 + } 267 844 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; 845 + return ` 846 + <div class="popover-item-block"> 847 + <div class="popover-item-header"> 848 + <div class="popover-author"> 849 + ${avatarHtml} 850 + <span class="popover-handle">@${handle}</span> 851 + </div> 852 + </div> 853 + <div class="popover-content"> 854 + ${bodyHtml} 855 + </div> 856 + <div class="popover-actions"> 857 + ${!isHighlight ? `<button class="btn-action btn-reply" data-id="${id}">Reply</button>` : ""} 858 + <button class="btn-action btn-share" data-id="${id}" data-text="${text}" data-quote="${quote}">Share</button> 859 + </div> 860 + </div> 284 861 `; 862 + }) 863 + .join(""); 864 + 865 + popoverEl.innerHTML = ` 866 + <div class="popover-header"> 867 + <span>${title}</span> 868 + <button class="popover-close">โœ•</button> 869 + </div> 870 + <div class="popover-scroll-area"> 871 + ${contentHtml} 872 + </div> 873 + `; 874 + 875 + popoverEl.querySelector(".popover-close").addEventListener("click", (e) => { 876 + e.stopPropagation(); 877 + popoverEl.remove(); 878 + popoverEl = null; 879 + }); 880 + 881 + const replyBtns = popoverEl.querySelectorAll(".btn-reply"); 882 + replyBtns.forEach((btn) => { 883 + btn.addEventListener("click", (e) => { 884 + e.stopPropagation(); 885 + const id = btn.getAttribute("data-id"); 886 + if (id) { 887 + chrome.runtime.sendMessage({ 888 + type: "OPEN_APP_URL", 889 + data: { path: `/annotation/${encodeURIComponent(id)}` }, 890 + }); 891 + } 892 + }); 893 + }); 285 894 286 - document.body.appendChild(notification); 895 + const shareBtns = popoverEl.querySelectorAll(".btn-share"); 896 + shareBtns.forEach((btn) => { 897 + btn.addEventListener("click", async () => { 898 + const id = btn.getAttribute("data-id"); 899 + const text = btn.getAttribute("data-text"); 900 + const quote = btn.getAttribute("data-quote"); 901 + const u = `https://margin.at/annotation/${encodeURIComponent(id)}`; 902 + const shareText = `${text ? text + "\n" : ""}${quote ? `"${quote}"\n` : ""}${u}`; 903 + 904 + try { 905 + await navigator.clipboard.writeText(shareText); 906 + const originalText = btn.innerText; 907 + btn.innerText = "Copied!"; 908 + setTimeout(() => (btn.innerText = originalText), 2000); 909 + } catch (e) { 910 + console.error("Failed to copy", e); 911 + } 912 + }); 913 + }); 914 + 915 + container.appendChild(popoverEl); 287 916 288 917 setTimeout(() => { 289 - notification.style.animation = "margin-slide-out 0.2s ease forwards"; 290 - setTimeout(() => notification.remove(), 200); 291 - }, 3000); 918 + document.addEventListener("click", closePopoverOutside); 919 + }, 0); 292 920 } 293 921 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); } 299 - } 300 - @keyframes margin-slide-out { 301 - from { opacity: 1; transform: translateY(0); } 302 - to { opacity: 0; transform: translateY(10px); } 922 + function closePopoverOutside() { 923 + if (popoverEl) { 924 + popoverEl.remove(); 925 + popoverEl = null; 926 + document.removeEventListener("click", closePopoverOutside); 927 + } 928 + } 929 + 930 + function fetchAnnotations(retryCount = 0) { 931 + if (typeof chrome !== "undefined" && chrome.runtime) { 932 + chrome.runtime.sendMessage( 933 + { 934 + type: "GET_ANNOTATIONS", 935 + data: { url: window.location.href }, 936 + }, 937 + (res) => { 938 + if (res && res.success && res.data && res.data.length > 0) { 939 + renderBadges(res.data); 940 + } else if (retryCount < 3) { 941 + setTimeout( 942 + () => fetchAnnotations(retryCount + 1), 943 + 1000 * (retryCount + 1), 944 + ); 945 + } 946 + }, 947 + ); 948 + } 949 + } 950 + 951 + chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { 952 + if (request.type === "GET_SELECTOR_FOR_ANNOTATE_INLINE") { 953 + const sel = window.getSelection(); 954 + if (!sel || !sel.toString()) { 955 + sendResponse({ selector: null }); 956 + return true; 957 + } 958 + const exact = sel.toString().trim(); 959 + sendResponse({ selector: { type: "TextQuoteSelector", exact } }); 960 + return true; 961 + } 962 + 963 + if (request.type === "SHOW_INLINE_ANNOTATE") { 964 + currentSelection = { 965 + text: request.data.selector?.exact || "", 966 + selector: request.data.selector, 967 + url: request.data.url, 968 + title: request.data.title, 969 + }; 970 + showInlineComposeModal(); 971 + sendResponse({ success: true }); 972 + return true; 973 + } 974 + 975 + if (request.type === "UPDATE_OVERLAY_VISIBILITY") { 976 + if (sidebarHost) { 977 + sidebarHost.style.display = request.show ? "block" : "none"; 978 + } 979 + if (request.show) { 980 + fetchAnnotations(); 981 + } else { 982 + if (typeof CSS !== "undefined" && CSS.highlights) { 983 + CSS.highlights.clear(); 303 984 } 304 - ::highlight(margin-highlight-preview) { 305 - background-color: rgba(168, 85, 247, 0.3); 306 - color: inherit; 985 + } 986 + sendResponse({ success: true }); 987 + return true; 988 + } 989 + 990 + if (request.type === "SCROLL_TO_TEXT") { 991 + const selector = request.selector; 992 + if (selector?.exact) { 993 + const matcher = new DOMTextMatcher(); 994 + const range = matcher.findRange(selector.exact); 995 + if (range) { 996 + const rect = range.getBoundingClientRect(); 997 + window.scrollTo({ 998 + top: window.scrollY + rect.top - window.innerHeight / 3, 999 + behavior: "smooth", 1000 + }); 1001 + const highlight = new Highlight(range); 1002 + CSS.highlights.set("margin-scroll-flash", highlight); 1003 + injectHighlightStyle("margin-scroll-flash", "#8b5cf6"); 1004 + setTimeout(() => CSS.highlights.delete("margin-scroll-flash"), 2000); 307 1005 } 308 - ::highlight(margin-scroll-highlight) { 309 - background-color: rgba(99, 102, 241, 0.4); 310 - color: inherit; 1006 + } 1007 + } 1008 + return true; 1009 + }); 1010 + 1011 + if (document.readyState === "loading") { 1012 + document.addEventListener("DOMContentLoaded", initOverlay); 1013 + } else { 1014 + initOverlay(); 1015 + } 1016 + 1017 + window.addEventListener("load", () => { 1018 + if (typeof chrome !== "undefined" && chrome.storage) { 1019 + chrome.storage.local.get(["showOverlay"], (result) => { 1020 + if (result.showOverlay !== false) { 1021 + setTimeout(() => fetchAnnotations(), 500); 311 1022 } 312 - ::highlight(margin-page-highlights) { 313 - background-color: rgba(252, 211, 77, 0.3); 314 - color: inherit; 1023 + }); 1024 + } else { 1025 + setTimeout(() => fetchAnnotations(), 500); 1026 + } 1027 + }); 1028 + 1029 + let lastUrl = window.location.href; 1030 + 1031 + function checkUrlChange() { 1032 + if (window.location.href !== lastUrl) { 1033 + lastUrl = window.location.href; 1034 + onUrlChange(); 1035 + } 1036 + } 1037 + 1038 + function onUrlChange() { 1039 + if (typeof CSS !== "undefined" && CSS.highlights) { 1040 + CSS.highlights.clear(); 1041 + } 1042 + activeItems = []; 1043 + 1044 + if (typeof chrome !== "undefined" && chrome.storage) { 1045 + chrome.storage.local.get(["showOverlay"], (result) => { 1046 + if (result.showOverlay !== false) { 1047 + fetchAnnotations(); 315 1048 } 316 - `; 317 - document.head.appendChild(style); 1049 + }); 1050 + } else { 1051 + fetchAnnotations(); 1052 + } 1053 + } 1054 + 1055 + window.addEventListener("popstate", onUrlChange); 1056 + 1057 + const originalPushState = history.pushState; 1058 + const originalReplaceState = history.replaceState; 1059 + 1060 + history.pushState = function (...args) { 1061 + originalPushState.apply(this, args); 1062 + checkUrlChange(); 1063 + }; 1064 + 1065 + history.replaceState = function (...args) { 1066 + originalReplaceState.apply(this, args); 1067 + checkUrlChange(); 1068 + }; 1069 + 1070 + setInterval(checkUrlChange, 1000); 318 1071 })();
+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 }
+47 -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"; ··· 13 15 import Collections from "./pages/Collections"; 14 16 import CollectionDetail from "./pages/CollectionDetail"; 15 17 import Privacy from "./pages/Privacy"; 18 + import Terms from "./pages/Terms"; 19 + import ScrollToTop from "./components/ScrollToTop"; 16 20 17 21 function AppContent() { 18 22 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> 23 + <div className="layout"> 24 + <ScrollToTop /> 25 + <Sidebar /> 26 + <div className="main-layout"> 27 + <main className="main-content-wrapper"> 28 + <Routes> 29 + <Route path="/" element={<Feed />} /> 30 + <Route path="/url" element={<Url />} /> 31 + <Route path="/new" element={<New />} /> 32 + <Route path="/bookmarks" element={<Bookmarks />} /> 33 + <Route path="/highlights" element={<Highlights />} /> 34 + <Route path="/notifications" element={<Notifications />} /> 35 + <Route path="/profile/:handle" element={<Profile />} /> 36 + <Route path="/login" element={<Login />} /> 37 + <Route path="/at/:did/:rkey" element={<AnnotationDetail />} /> 38 + <Route path="/annotation/:uri" element={<AnnotationDetail />} /> 39 + <Route path="/collections" element={<Collections />} /> 40 + <Route path="/collections/:rkey" element={<CollectionDetail />} /> 41 + <Route 42 + path="/:handle/collection/:rkey" 43 + element={<CollectionDetail />} 44 + /> 45 + <Route 46 + path="/:handle/annotation/:rkey" 47 + element={<AnnotationDetail />} 48 + /> 49 + <Route 50 + path="/:handle/highlight/:rkey" 51 + element={<AnnotationDetail />} 52 + /> 53 + <Route 54 + path="/:handle/bookmark/:rkey" 55 + element={<AnnotationDetail />} 56 + /> 57 + <Route path="/collection/*" element={<CollectionDetail />} /> 58 + <Route path="/privacy" element={<Privacy />} /> 59 + <Route path="/terms" element={<Terms />} /> 60 + </Routes> 61 + </main> 62 + </div> 63 + <RightSidebar /> 64 + <MobileNav /> 41 65 </div> 42 66 ); 43 67 }
+101 -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 + } 433 + 434 + export async function getAPIKeys() { 435 + return request(`${API_BASE}/keys`); 436 + } 437 + 438 + export async function createAPIKey(name) { 439 + return request(`${API_BASE}/keys`, { 440 + method: "POST", 441 + body: JSON.stringify({ name }), 442 + }); 443 + } 444 + 445 + export async function deleteAPIKey(id) { 446 + return request(`${API_BASE}/keys/${id}`, { method: "DELETE" }); 447 + }
+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
+195
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 { 5 + SiFirefox, 6 + SiGooglechrome, 7 + SiGithub, 8 + SiBluesky, 9 + SiApple, 10 + SiKofi, 11 + } from "react-icons/si"; 12 + import { FaEdge } from "react-icons/fa"; 13 + import { useAuth } from "../context/AuthContext"; 14 + import { getTrendingTags } from "../api/client"; 15 + 16 + const isFirefox = 17 + typeof navigator !== "undefined" && /Firefox/i.test(navigator.userAgent); 18 + const isEdge = 19 + typeof navigator !== "undefined" && /Edg/i.test(navigator.userAgent); 20 + const isMobileSafari = 21 + typeof navigator !== "undefined" && 22 + /iPhone|iPad|iPod/.test(navigator.userAgent) && 23 + /Safari/.test(navigator.userAgent) && 24 + !/CriOS|FxiOS|OPiOS|EdgiOS/.test(navigator.userAgent); 25 + 26 + function getExtensionInfo() { 27 + if (isMobileSafari) { 28 + return { 29 + url: "https://margin.at/soon", 30 + icon: SiApple, 31 + name: "iOS", 32 + label: "Coming Soon", 33 + }; 34 + } 35 + if (isFirefox) { 36 + return { 37 + url: "https://addons.mozilla.org/en-US/firefox/addon/margin/", 38 + icon: SiFirefox, 39 + name: "Firefox", 40 + label: "Install for Firefox", 41 + }; 42 + } 43 + if (isEdge) { 44 + return { 45 + url: "https://microsoftedge.microsoft.com/addons/detail/margin/nfjnmllpdgcdnhmmggjihjbidmeadddn", 46 + icon: FaEdge, 47 + name: "Edge", 48 + label: "Install for Edge", 49 + }; 50 + } 51 + return { 52 + url: "https://chromewebstore.google.com/detail/margin/cgpmbiiagnehkikhcbnhiagfomajncpa/", 53 + icon: SiGooglechrome, 54 + name: "Chrome", 55 + label: "Install for Chrome", 56 + }; 57 + } 58 + 59 + export default function RightSidebar() { 60 + const { isAuthenticated } = useAuth(); 61 + const ext = getExtensionInfo(); 62 + const ExtIcon = ext.icon; 63 + const [trendingTags, setTrendingTags] = useState([]); 64 + const [loading, setLoading] = useState(true); 65 + 66 + useEffect(() => { 67 + getTrendingTags() 68 + .then((tags) => setTrendingTags(tags)) 69 + .catch((err) => console.error("Failed to fetch trending tags:", err)) 70 + .finally(() => setLoading(false)); 71 + }, []); 72 + 73 + return ( 74 + <aside className="right-sidebar"> 75 + <div className="right-section"> 76 + <h3 className="right-section-title"> 77 + {isMobileSafari ? "Save from Safari" : "Get the Extension"} 78 + </h3> 79 + <p className="right-section-desc"> 80 + {isMobileSafari 81 + ? "Bookmark pages using Safari's share sheet" 82 + : "Annotate, highlight, and bookmark any webpage"} 83 + </p> 84 + <a 85 + href={ext.url} 86 + target="_blank" 87 + rel="noopener noreferrer" 88 + className="right-extension-btn" 89 + > 90 + <ExtIcon size={18} /> 91 + {ext.label} 92 + <ExternalLink size={14} /> 93 + </a> 94 + </div> 95 + 96 + {isAuthenticated ? ( 97 + <div className="right-section"> 98 + <h3 className="right-section-title">Trending Tags</h3> 99 + <div className="right-links"> 100 + {loading ? ( 101 + <span className="right-section-desc">Loading...</span> 102 + ) : trendingTags.length > 0 ? ( 103 + trendingTags.map(({ tag, count }) => ( 104 + <Link 105 + key={tag} 106 + to={`/?tag=${encodeURIComponent(tag)}`} 107 + className="right-link" 108 + > 109 + <span>#{tag}</span> 110 + <span style={{ fontSize: "0.75rem", opacity: 0.6 }}> 111 + {count} 112 + </span> 113 + </Link> 114 + )) 115 + ) : ( 116 + <span className="right-section-desc">No trending tags yet</span> 117 + )} 118 + </div> 119 + </div> 120 + ) : ( 121 + <div className="right-section"> 122 + <h3 className="right-section-title">Explore</h3> 123 + <nav className="right-links"> 124 + <Link to="/url" className="right-link"> 125 + Browse by URL 126 + </Link> 127 + <Link to="/highlights" className="right-link"> 128 + Public Highlights 129 + </Link> 130 + </nav> 131 + </div> 132 + )} 133 + 134 + <div className="right-section"> 135 + <h3 className="right-section-title">Resources</h3> 136 + <nav className="right-links"> 137 + <a 138 + href="https://github.com/margin-at/margin" 139 + target="_blank" 140 + rel="noopener noreferrer" 141 + className="right-link" 142 + > 143 + <div style={{ display: "flex", alignItems: "center", gap: "8px" }}> 144 + <SiGithub size={16} /> 145 + GitHub 146 + </div> 147 + <ExternalLink size={12} /> 148 + </a> 149 + <a 150 + href="https://tangled.org/margin.at/margin" 151 + target="_blank" 152 + rel="noopener noreferrer" 153 + className="right-link" 154 + > 155 + <div style={{ display: "flex", alignItems: "center", gap: "8px" }}> 156 + <div className="tangled-icon" /> 157 + Tangled 158 + </div> 159 + <ExternalLink size={12} /> 160 + </a> 161 + <a 162 + href="https://bsky.app/profile/margin.at" 163 + target="_blank" 164 + rel="noopener noreferrer" 165 + className="right-link" 166 + > 167 + <div style={{ display: "flex", alignItems: "center", gap: "8px" }}> 168 + <SiBluesky size={16} /> 169 + Bluesky 170 + </div> 171 + <ExternalLink size={12} /> 172 + </a> 173 + <a 174 + href="https://ko-fi.com/scan" 175 + target="_blank" 176 + rel="noopener noreferrer" 177 + className="right-link" 178 + > 179 + <div style={{ display: "flex", alignItems: "center", gap: "8px" }}> 180 + <SiKofi size={16} /> 181 + Donate 182 + </div> 183 + <ExternalLink size={12} /> 184 + </a> 185 + </nav> 186 + </div> 187 + 188 + <div className="right-footer"> 189 + <Link to="/privacy">Privacy</Link> 190 + <span>ยท</span> 191 + <Link to="/terms">Terms</Link> 192 + </div> 193 + </aside> 194 + ); 195 + }
+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) {
+506
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 + overflow-wrap: anywhere; 186 + word-break: break-all; 187 + padding-right: 4px; 188 + } 189 + 190 + .annotation-text { 191 + font-size: 0.95rem; 192 + line-height: 1.6; 193 + color: var(--text-primary); 194 + white-space: pre-wrap; 195 + } 196 + 197 + .annotation-tags { 198 + display: flex; 199 + flex-wrap: wrap; 200 + gap: 6px; 201 + margin-top: 4px; 202 + } 203 + 204 + .annotation-tag { 205 + font-size: 0.8rem; 206 + color: var(--accent); 207 + text-decoration: none; 208 + font-weight: 500; 209 + opacity: 0.9; 210 + transition: opacity 0.15s; 211 + } 212 + 213 + .annotation-tag:hover { 214 + opacity: 1; 215 + text-decoration: underline; 216 + } 217 + 218 + .annotation-actions { 219 + display: flex; 220 + align-items: center; 221 + justify-content: space-between; 222 + margin-top: 4px; 223 + padding-left: 46px; 224 + } 225 + 226 + .annotation-actions-left { 227 + display: flex; 228 + align-items: center; 229 + gap: 16px; 230 + } 231 + 232 + .annotation-action { 233 + display: flex; 234 + align-items: center; 235 + gap: 6px; 236 + color: var(--text-tertiary); 237 + font-size: 0.8rem; 238 + font-weight: 500; 239 + padding: 6px; 240 + margin-left: -6px; 241 + border-radius: var(--radius-sm); 242 + transition: all 0.15s ease; 243 + background: transparent; 244 + cursor: pointer; 245 + border: none; 246 + } 247 + 248 + .annotation-action:hover { 249 + color: var(--text-secondary); 250 + background: var(--bg-tertiary); 251 + } 252 + 253 + .annotation-action.liked { 254 + color: #ef4444; 255 + } 256 + 257 + .annotation-action.liked svg { 258 + fill: #ef4444; 259 + } 260 + 261 + .annotation-action.active { 262 + color: var(--accent); 263 + } 264 + 265 + .action-icon-only { 266 + padding: 6px; 267 + } 268 + 269 + .annotation-header-right { 270 + opacity: 0; 271 + transition: opacity 0.15s; 272 + } 273 + 274 + .annotation-card:hover .annotation-header-right { 275 + opacity: 1; 276 + } 277 + 278 + .inline-replies { 279 + margin-top: 12px; 280 + padding-left: 46px; 281 + } 282 + 283 + .annotation-text, 284 + .reply-text, 285 + .history-content { 286 + overflow-wrap: break-word; 287 + word-break: break-word; 288 + max-width: 100%; 289 + } 290 + 291 + .annotation-highlight mark { 292 + overflow-wrap: break-word; 293 + word-break: break-word; 294 + display: inline; 295 + } 296 + 297 + .annotation-header-left, 298 + .annotation-meta, 299 + .reply-meta { 300 + min-width: 0; 301 + max-width: 100%; 302 + } 303 + 304 + .annotation-author-row, 305 + .reply-author { 306 + max-width: 100%; 307 + } 308 + 309 + .annotation-source { 310 + max-width: 100%; 311 + } 312 + 313 + @media (max-width: 768px) { 314 + .annotation-content, 315 + .annotation-actions, 316 + .inline-replies { 317 + padding-left: 0; 318 + } 319 + 320 + .annotation-header-right { 321 + opacity: 1; 322 + } 323 + } 324 + 325 + .replies-list-threaded { 326 + margin-top: 16px; 327 + display: flex; 328 + flex-direction: column; 329 + } 330 + 331 + .reply-card-threaded { 332 + position: relative; 333 + padding-left: 0; 334 + transition: background 0.15s; 335 + } 336 + 337 + .reply-header { 338 + display: flex; 339 + align-items: center; 340 + gap: 10px; 341 + margin-bottom: 6px; 342 + } 343 + 344 + .reply-avatar { 345 + width: 28px; 346 + height: 28px; 347 + border-radius: 50%; 348 + background: var(--bg-tertiary); 349 + overflow: hidden; 350 + flex-shrink: 0; 351 + display: flex; 352 + align-items: center; 353 + justify-content: center; 354 + } 355 + 356 + .reply-avatar img { 357 + width: 100%; 358 + height: 100%; 359 + object-fit: cover; 360 + } 361 + 362 + .reply-avatar span { 363 + font-size: 0.7rem; 364 + font-weight: 600; 365 + color: var(--text-secondary); 366 + } 367 + 368 + .reply-meta { 369 + display: flex; 370 + align-items: baseline; 371 + gap: 6px; 372 + flex: 1; 373 + min-width: 0; 374 + } 375 + 376 + .reply-author { 377 + font-weight: 600; 378 + font-size: 0.85rem; 379 + color: var(--text-primary); 380 + white-space: nowrap; 381 + overflow: hidden; 382 + text-overflow: ellipsis; 383 + } 384 + 385 + .reply-handle { 386 + font-size: 0.8rem; 387 + color: var(--text-tertiary); 388 + text-decoration: none; 389 + white-space: nowrap; 390 + overflow: hidden; 391 + text-overflow: ellipsis; 392 + } 393 + 394 + .reply-time { 395 + font-size: 0.75rem; 396 + color: var(--text-tertiary); 397 + white-space: nowrap; 398 + } 399 + 400 + .reply-dot { 401 + color: var(--text-tertiary); 402 + font-size: 0.7rem; 403 + } 404 + 405 + .reply-text { 406 + font-size: 0.9rem; 407 + line-height: 1.5; 408 + color: var(--text-primary); 409 + margin: 0; 410 + padding-left: 38px; 411 + } 412 + 413 + .reply-actions { 414 + display: flex; 415 + align-items: center; 416 + gap: 4px; 417 + opacity: 0; 418 + transition: opacity 0.15s; 419 + } 420 + 421 + .reply-card-threaded:hover .reply-actions { 422 + opacity: 1; 423 + } 424 + 425 + .reply-action-btn { 426 + background: none; 427 + border: none; 428 + padding: 4px; 429 + color: var(--text-tertiary); 430 + cursor: pointer; 431 + border-radius: 4px; 432 + display: flex; 433 + align-items: center; 434 + justify-content: center; 435 + } 436 + 437 + .reply-action-btn:hover { 438 + background: var(--bg-tertiary); 439 + color: var(--text-secondary); 440 + } 441 + 442 + .reply-action-delete:hover { 443 + color: #ef4444; 444 + background: rgba(239, 68, 68, 0.1); 445 + } 446 + 447 + .reply-form { 448 + border: 1px solid var(--border); 449 + border-radius: var(--radius-md); 450 + padding: 16px; 451 + background: var(--bg-secondary); 452 + margin-bottom: 24px; 453 + } 454 + 455 + .replying-to-banner { 456 + display: flex; 457 + justify-content: space-between; 458 + align-items: center; 459 + background: var(--bg-tertiary); 460 + padding: 8px 12px; 461 + border-radius: var(--radius-sm); 462 + margin-bottom: 12px; 463 + font-size: 0.85rem; 464 + color: var(--text-secondary); 465 + } 466 + 467 + .cancel-reply { 468 + background: none; 469 + border: none; 470 + color: var(--text-tertiary); 471 + cursor: pointer; 472 + font-size: 1.2rem; 473 + padding: 0 4px; 474 + line-height: 1; 475 + } 476 + 477 + .cancel-reply:hover { 478 + color: var(--text-primary); 479 + } 480 + 481 + .reply-input { 482 + width: 100%; 483 + background: var(--bg-primary); 484 + border: 1px solid var(--border); 485 + border-radius: var(--radius-sm); 486 + padding: 12px; 487 + color: var(--text-primary); 488 + font-family: inherit; 489 + font-size: 0.95rem; 490 + resize: vertical; 491 + min-height: 80px; 492 + transition: border-color 0.15s; 493 + display: block; 494 + box-sizing: border-box; 495 + } 496 + 497 + .reply-input:focus { 498 + outline: none; 499 + border-color: var(--accent); 500 + } 501 + 502 + .reply-form-actions { 503 + display: flex; 504 + justify-content: flex-end; 505 + margin-top: 12px; 506 + }
+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 + }
+129
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 + white-space: pre; 12 + } 13 + 14 + .btn-primary { 15 + background: var(--accent); 16 + color: white; 17 + } 18 + 19 + .btn-primary:hover { 20 + background: var(--accent-hover); 21 + transform: translateY(-1px); 22 + box-shadow: var(--shadow-md); 23 + } 24 + 25 + .btn-secondary { 26 + background: var(--bg-tertiary); 27 + color: var(--text-primary); 28 + border: 1px solid var(--border); 29 + } 30 + 31 + .btn-secondary:hover { 32 + background: var(--bg-hover); 33 + border-color: var(--border-hover); 34 + } 35 + 36 + .btn-ghost { 37 + color: var(--text-secondary); 38 + padding: 8px 12px; 39 + } 40 + 41 + .btn-ghost:hover { 42 + color: var(--text-primary); 43 + background: var(--bg-tertiary); 44 + } 45 + 46 + .btn-bluesky { 47 + background: #0085ff; 48 + color: white; 49 + display: flex; 50 + align-items: center; 51 + justify-content: center; 52 + gap: 10px; 53 + transition: 54 + background 0.2s, 55 + transform 0.2s; 56 + } 57 + 58 + .btn-bluesky:hover { 59 + background: #0070dd; 60 + transform: translateY(-1px); 61 + } 62 + 63 + .btn-sm { 64 + padding: 6px 12px; 65 + font-size: 0.85rem; 66 + } 67 + 68 + .btn-text { 69 + background: none; 70 + border: none; 71 + color: var(--text-secondary); 72 + font-size: 0.9rem; 73 + padding: 8px 12px; 74 + cursor: pointer; 75 + transition: color 0.15s; 76 + } 77 + 78 + .btn-text:hover { 79 + color: var(--text-primary); 80 + } 81 + 82 + .btn-block { 83 + width: 100%; 84 + text-align: left; 85 + padding: 8px 12px; 86 + color: var(--text-secondary); 87 + background: var(--bg-tertiary); 88 + border-radius: var(--radius-md); 89 + margin-top: 8px; 90 + font-size: 0.9rem; 91 + cursor: pointer; 92 + transition: all 0.2s; 93 + } 94 + 95 + .btn-block:hover { 96 + background: var(--border); 97 + color: var(--text-primary); 98 + } 99 + 100 + .btn-icon-danger { 101 + padding: 8px; 102 + background: var(--error); 103 + color: white; 104 + border: none; 105 + border-radius: var(--radius-md); 106 + cursor: pointer; 107 + box-shadow: var(--shadow-md); 108 + transition: all 0.15s ease; 109 + display: flex; 110 + align-items: center; 111 + justify-content: center; 112 + } 113 + 114 + .btn-icon-danger:hover { 115 + background: #dc2626; 116 + transform: scale(1.05); 117 + } 118 + 119 + .action-buttons { 120 + display: flex; 121 + gap: 8px; 122 + flex-wrap: wrap; 123 + } 124 + 125 + .action-buttons-end { 126 + display: flex; 127 + justify-content: flex-end; 128 + gap: 8px; 129 + }
+326
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 + @media (max-width: 600px) { 175 + .collection-detail-header { 176 + flex-direction: column; 177 + padding: 16px; 178 + gap: 16px; 179 + } 180 + 181 + .collection-detail-actions { 182 + position: static; 183 + margin-top: -8px; 184 + justify-content: flex-end; 185 + } 186 + } 187 + 188 + .collection-detail-desc { 189 + color: var(--text-secondary); 190 + font-size: 1rem; 191 + line-height: 1.5; 192 + margin-bottom: 12px; 193 + max-width: 600px; 194 + overflow-wrap: break-word; 195 + word-break: break-word; 196 + } 197 + 198 + .collection-detail-stats { 199 + display: flex; 200 + align-items: center; 201 + gap: 8px; 202 + font-size: 0.85rem; 203 + color: var(--text-tertiary); 204 + } 205 + 206 + .collection-detail-actions { 207 + position: absolute; 208 + top: 20px; 209 + right: 20px; 210 + display: flex; 211 + align-items: center; 212 + gap: 8px; 213 + } 214 + 215 + .collection-detail-actions .share-menu-container { 216 + display: flex; 217 + align-items: center; 218 + } 219 + 220 + .collection-detail-actions .annotation-action { 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-actions .annotation-action:hover { 231 + color: var(--accent); 232 + background: var(--bg-tertiary); 233 + } 234 + 235 + .collection-detail-edit, 236 + .collection-detail-delete { 237 + padding: 10px; 238 + color: var(--text-tertiary); 239 + background: none; 240 + border: none; 241 + border-radius: var(--radius-sm); 242 + cursor: pointer; 243 + transition: all 0.15s ease; 244 + } 245 + 246 + .collection-detail-edit:hover { 247 + color: var(--accent); 248 + background: var(--bg-tertiary); 249 + } 250 + 251 + .collection-detail-delete:hover { 252 + color: var(--error); 253 + background: rgba(239, 68, 68, 0.1); 254 + } 255 + 256 + .collection-item-wrapper { 257 + position: relative; 258 + } 259 + 260 + .collection-item-remove { 261 + position: absolute; 262 + top: 12px; 263 + left: -40px; 264 + z-index: 10; 265 + padding: 8px; 266 + background: var(--bg-card); 267 + border: 1px solid var(--border); 268 + border-radius: var(--radius-sm); 269 + color: var(--text-tertiary); 270 + cursor: pointer; 271 + opacity: 0; 272 + transition: all 0.15s ease; 273 + } 274 + 275 + .collection-item-wrapper:hover .collection-item-remove { 276 + opacity: 1; 277 + } 278 + 279 + .collection-item-remove:hover { 280 + color: var(--error); 281 + border-color: var(--error); 282 + background: rgba(239, 68, 68, 0.05); 283 + } 284 + 285 + .collection-list-item { 286 + width: 100%; 287 + text-align: left; 288 + padding: 12px 16px; 289 + border-radius: var(--radius-md); 290 + background: var(--bg-primary); 291 + border: 1px solid transparent; 292 + color: var(--text-primary); 293 + transition: all 0.15s ease; 294 + display: flex; 295 + align-items: center; 296 + justify-content: space-between; 297 + cursor: pointer; 298 + } 299 + 300 + .collection-list-item:hover { 301 + background: var(--bg-hover); 302 + border-color: var(--border); 303 + } 304 + 305 + .collection-list-item:hover .collection-list-item-icon { 306 + opacity: 1; 307 + } 308 + 309 + .collection-list-item:disabled { 310 + opacity: 0.6; 311 + cursor: not-allowed; 312 + } 313 + 314 + .item-delete-overlay { 315 + position: absolute; 316 + top: 16px; 317 + right: 16px; 318 + z-index: 10; 319 + opacity: 0; 320 + transition: opacity 0.15s ease; 321 + } 322 + 323 + .card:hover .item-delete-overlay, 324 + div:hover > .item-delete-overlay { 325 + opacity: 1; 326 + }
+141
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 + max-width: 100%; 28 + flex-wrap: wrap; 29 + } 30 + 31 + .filter-tab { 32 + padding: 8px 16px; 33 + font-size: 0.9rem; 34 + font-weight: 500; 35 + color: var(--text-secondary); 36 + background: transparent; 37 + border: none; 38 + border-radius: var(--radius-md); 39 + cursor: pointer; 40 + transition: all 0.15s ease; 41 + } 42 + 43 + .filter-tab:hover { 44 + color: var(--text-primary); 45 + background: var(--bg-hover); 46 + } 47 + 48 + .filter-tab.active { 49 + color: var(--text-primary); 50 + background: var(--bg-card); 51 + box-shadow: var(--shadow-sm); 52 + } 53 + 54 + .page-header { 55 + margin-bottom: 32px; 56 + } 57 + 58 + .page-title { 59 + font-size: 2rem; 60 + font-weight: 700; 61 + margin-bottom: 8px; 62 + } 63 + 64 + .page-description { 65 + color: var(--text-secondary); 66 + font-size: 1.1rem; 67 + } 68 + 69 + .url-input-wrapper { 70 + margin-bottom: 24px; 71 + } 72 + 73 + .url-input-container { 74 + display: flex; 75 + gap: 12px; 76 + } 77 + 78 + .url-input { 79 + width: 100%; 80 + padding: 16px; 81 + background: var(--bg-secondary); 82 + border: 1px solid var(--border); 83 + border-radius: var(--radius-md); 84 + color: var(--text-primary); 85 + font-size: 1.1rem; 86 + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); 87 + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); 88 + } 89 + 90 + .url-input:focus { 91 + outline: none; 92 + border-color: var(--accent); 93 + box-shadow: 0 0 0 4px var(--accent-subtle); 94 + background: var(--bg-primary); 95 + } 96 + 97 + .url-input::placeholder { 98 + color: var(--text-tertiary); 99 + } 100 + 101 + .url-results-header { 102 + display: flex; 103 + align-items: center; 104 + justify-content: space-between; 105 + margin-bottom: 16px; 106 + flex-wrap: wrap; 107 + gap: 12px; 108 + } 109 + 110 + .back-link { 111 + display: inline-flex; 112 + align-items: center; 113 + gap: 8px; 114 + color: var(--text-secondary); 115 + font-size: 0.9rem; 116 + text-decoration: none; 117 + margin-bottom: 24px; 118 + transition: color 0.15s; 119 + } 120 + 121 + .back-link:hover { 122 + color: var(--accent); 123 + } 124 + 125 + .new-page { 126 + max-width: 600px; 127 + margin: 0 auto; 128 + display: flex; 129 + flex-direction: column; 130 + gap: 32px; 131 + } 132 + 133 + @media (max-width: 640px) { 134 + .main-content { 135 + padding: 16px 12px; 136 + } 137 + 138 + .page-title { 139 + font-size: 1.5rem; 140 + } 141 + }
+513
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 + width: 100%; 455 + min-width: 0; 456 + } 457 + 458 + .main-content-wrapper { 459 + padding: 20px 16px; 460 + max-width: 100%; 461 + width: 100%; 462 + overflow-x: hidden; 463 + min-width: 0; 464 + } 465 + 466 + .mobile-nav { 467 + display: block; 468 + max-width: 100vw; 469 + } 470 + 471 + .card, 472 + .annotation-card, 473 + .collection-card, 474 + .profile-header, 475 + .api-keys-section { 476 + overflow-x: hidden; 477 + max-width: 100%; 478 + } 479 + 480 + code { 481 + word-break: break-all; 482 + overflow-wrap: break-word; 483 + } 484 + 485 + pre { 486 + overflow-x: auto; 487 + max-width: 100%; 488 + } 489 + 490 + input, 491 + textarea { 492 + max-width: 100%; 493 + } 494 + 495 + .flex-row, 496 + [style*="display: flex"][style*="gap"] { 497 + flex-wrap: wrap; 498 + } 499 + 500 + .static-page { 501 + overflow-x: hidden; 502 + } 503 + 504 + .static-page ol, 505 + .static-page ul { 506 + padding-left: 1.25rem; 507 + } 508 + 509 + .static-page code { 510 + font-size: 0.75rem; 511 + word-break: break-all; 512 + } 513 + }
+317
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 + @media (max-width: 600px) { 14 + .login-page { 15 + padding: 40px 16px; 16 + } 17 + 18 + .login-at-logo { 19 + font-size: 4rem; 20 + } 21 + 22 + .login-brand-name { 23 + font-size: 1.25rem; 24 + } 25 + 26 + .login-brand-icon { 27 + width: 40px; 28 + height: 40px; 29 + font-size: 1.5rem; 30 + } 31 + } 32 + 33 + .login-at-logo { 34 + font-size: 5rem; 35 + font-weight: 800; 36 + color: var(--accent); 37 + margin-bottom: 24px; 38 + line-height: 1; 39 + } 40 + 41 + .login-logo-img { 42 + width: 80px; 43 + height: 80px; 44 + margin-bottom: 24px; 45 + object-fit: contain; 46 + } 47 + 48 + .login-heading { 49 + font-size: 1.5rem; 50 + font-weight: 600; 51 + margin-bottom: 32px; 52 + display: flex; 53 + align-items: center; 54 + gap: 10px; 55 + text-align: center; 56 + line-height: 1.4; 57 + } 58 + 59 + .login-help-btn { 60 + background: none; 61 + border: none; 62 + color: var(--text-tertiary); 63 + cursor: pointer; 64 + padding: 4px; 65 + display: flex; 66 + align-items: center; 67 + transition: color 0.15s; 68 + flex-shrink: 0; 69 + } 70 + 71 + .login-help-btn:hover { 72 + color: var(--accent); 73 + } 74 + 75 + .login-help-text { 76 + background: var(--bg-elevated); 77 + border: 1px solid var(--border); 78 + border-radius: var(--radius-md); 79 + padding: 16px 20px; 80 + margin-bottom: 24px; 81 + font-size: 0.95rem; 82 + color: var(--text-secondary); 83 + line-height: 1.6; 84 + text-align: center; 85 + } 86 + 87 + .login-help-text code { 88 + background: var(--bg-tertiary); 89 + padding: 2px 8px; 90 + border-radius: var(--radius-sm); 91 + font-size: 0.9rem; 92 + } 93 + 94 + .login-form { 95 + display: flex; 96 + flex-direction: column; 97 + gap: 16px; 98 + width: 100%; 99 + } 100 + 101 + .login-input-wrapper { 102 + position: relative; 103 + } 104 + 105 + .login-input { 106 + width: 100%; 107 + padding: 14px 16px; 108 + background: var(--bg-elevated); 109 + border: 1px solid var(--border); 110 + border-radius: var(--radius-md); 111 + color: var(--text-primary); 112 + font-size: 1rem; 113 + transition: 114 + border-color 0.15s, 115 + box-shadow 0.15s; 116 + } 117 + 118 + .login-input:focus { 119 + outline: none; 120 + border-color: var(--accent); 121 + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15); 122 + } 123 + 124 + .login-input::placeholder { 125 + color: var(--text-tertiary); 126 + } 127 + 128 + .login-suggestions { 129 + position: absolute; 130 + top: calc(100% + 4px); 131 + left: 0; 132 + right: 0; 133 + background: var(--bg-card); 134 + border: 1px solid var(--border); 135 + border-radius: var(--radius-md); 136 + box-shadow: var(--shadow-lg); 137 + overflow: hidden; 138 + z-index: 100; 139 + } 140 + 141 + .login-suggestion { 142 + display: flex; 143 + align-items: center; 144 + gap: 12px; 145 + width: 100%; 146 + padding: 12px 16px; 147 + background: transparent; 148 + border: none; 149 + cursor: pointer; 150 + text-align: left; 151 + transition: background 0.1s; 152 + } 153 + 154 + .login-suggestion:hover, 155 + .login-suggestion.selected { 156 + background: var(--bg-elevated); 157 + } 158 + 159 + .login-suggestion-avatar { 160 + width: 40px; 161 + height: 40px; 162 + border-radius: var(--radius-full); 163 + background: linear-gradient(135deg, var(--accent), #a855f7); 164 + display: flex; 165 + align-items: center; 166 + justify-content: center; 167 + flex-shrink: 0; 168 + overflow: hidden; 169 + font-size: 0.875rem; 170 + font-weight: 600; 171 + color: white; 172 + } 173 + 174 + .login-suggestion-avatar img { 175 + width: 100%; 176 + height: 100%; 177 + object-fit: cover; 178 + } 179 + 180 + .login-suggestion-info { 181 + display: flex; 182 + flex-direction: column; 183 + min-width: 0; 184 + } 185 + 186 + .login-suggestion-name { 187 + font-weight: 600; 188 + color: var(--text-primary); 189 + white-space: nowrap; 190 + overflow: hidden; 191 + text-overflow: ellipsis; 192 + } 193 + 194 + .login-suggestion-handle { 195 + font-size: 0.875rem; 196 + color: var(--text-secondary); 197 + white-space: nowrap; 198 + overflow: hidden; 199 + text-overflow: ellipsis; 200 + } 201 + 202 + .login-error { 203 + padding: 12px 16px; 204 + background: rgba(239, 68, 68, 0.1); 205 + border: 1px solid rgba(239, 68, 68, 0.3); 206 + border-radius: var(--radius-md); 207 + color: #ef4444; 208 + font-size: 0.875rem; 209 + } 210 + 211 + .login-legal { 212 + font-size: 0.75rem; 213 + color: var(--text-tertiary); 214 + line-height: 1.5; 215 + margin-top: 16px; 216 + } 217 + 218 + .login-brand { 219 + display: flex; 220 + align-items: center; 221 + justify-content: center; 222 + gap: 12px; 223 + margin-bottom: 24px; 224 + } 225 + 226 + .login-brand-icon { 227 + width: 48px; 228 + height: 48px; 229 + background: linear-gradient(135deg, var(--accent), #a855f7); 230 + border-radius: var(--radius-lg); 231 + display: flex; 232 + align-items: center; 233 + justify-content: center; 234 + font-size: 1.75rem; 235 + font-weight: 800; 236 + color: white; 237 + } 238 + 239 + .login-brand-name { 240 + font-size: 1.75rem; 241 + font-weight: 700; 242 + } 243 + 244 + .login-avatar { 245 + width: 72px; 246 + height: 72px; 247 + border-radius: var(--radius-full); 248 + background: linear-gradient(135deg, var(--accent), #a855f7); 249 + display: flex; 250 + align-items: center; 251 + justify-content: center; 252 + margin: 0 auto 16px; 253 + font-weight: 700; 254 + font-size: 1.5rem; 255 + color: white; 256 + overflow: hidden; 257 + } 258 + 259 + .login-avatar img { 260 + width: 100%; 261 + height: 100%; 262 + object-fit: cover; 263 + } 264 + 265 + .login-avatar-large { 266 + width: 100px; 267 + height: 100px; 268 + border-radius: var(--radius-full); 269 + background: linear-gradient(135deg, var(--accent), #a855f7); 270 + display: flex; 271 + align-items: center; 272 + justify-content: center; 273 + margin-bottom: 20px; 274 + font-weight: 700; 275 + font-size: 2rem; 276 + color: white; 277 + overflow: hidden; 278 + } 279 + 280 + .login-avatar-large img { 281 + width: 100%; 282 + height: 100%; 283 + object-fit: cover; 284 + } 285 + 286 + .login-welcome { 287 + font-size: 1.5rem; 288 + font-weight: 600; 289 + margin-bottom: 32px; 290 + text-align: center; 291 + } 292 + 293 + .login-welcome-name { 294 + font-size: 1.25rem; 295 + font-weight: 600; 296 + margin-bottom: 24px; 297 + } 298 + 299 + .login-actions { 300 + display: flex; 301 + flex-direction: column; 302 + gap: 12px; 303 + width: 100%; 304 + } 305 + 306 + .login-btn { 307 + width: 100%; 308 + padding: 14px 24px; 309 + font-size: 1rem; 310 + font-weight: 600; 311 + } 312 + 313 + .login-submit { 314 + padding: 18px 32px; 315 + font-size: 1.1rem; 316 + font-weight: 600; 317 + }
+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 + }
+67
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 + overflow-wrap: break-word; 57 + word-break: break-word; 58 + } 59 + 60 + .notification-text strong { 61 + font-weight: 600; 62 + } 63 + 64 + .notification-time { 65 + font-size: 0.85rem; 66 + color: var(--text-tertiary); 67 + }
+257
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 + overflow-wrap: break-word; 49 + word-break: break-word; 50 + } 51 + 52 + .profile-handle-row { 53 + display: flex; 54 + align-items: center; 55 + gap: 12px; 56 + margin-top: 4px; 57 + flex-wrap: wrap; 58 + } 59 + 60 + .profile-handle-link { 61 + color: var(--text-tertiary); 62 + text-decoration: none; 63 + font-size: 1rem; 64 + transition: color 0.15s; 65 + overflow-wrap: break-word; 66 + word-break: break-all; 67 + } 68 + 69 + .profile-handle-link:hover { 70 + color: var(--text-secondary); 71 + } 72 + 73 + .profile-bluesky-link { 74 + display: inline-flex; 75 + align-items: center; 76 + gap: 6px; 77 + color: #3b82f6; 78 + text-decoration: none; 79 + font-size: 0.85rem; 80 + font-weight: 500; 81 + padding: 2px 8px; 82 + border-radius: var(--radius-sm); 83 + background: rgba(59, 130, 246, 0.1); 84 + transition: all 0.15s ease; 85 + width: fit-content; 86 + } 87 + 88 + .profile-bluesky-link:hover { 89 + background: rgba(59, 130, 246, 0.15); 90 + } 91 + 92 + .profile-stats { 93 + display: flex; 94 + gap: 24px; 95 + margin-top: 12px; 96 + } 97 + 98 + .profile-stat { 99 + color: var(--text-tertiary); 100 + font-size: 0.9rem; 101 + } 102 + 103 + .profile-stat strong { 104 + color: var(--text-primary); 105 + font-weight: 600; 106 + } 107 + 108 + .profile-tabs { 109 + display: flex; 110 + gap: 24px; 111 + margin-bottom: 24px; 112 + border-bottom: 1px solid var(--border); 113 + flex-wrap: wrap; 114 + row-gap: 8px; 115 + } 116 + 117 + .profile-tab { 118 + padding: 12px 0; 119 + font-size: 0.95rem; 120 + font-weight: 500; 121 + color: var(--text-tertiary); 122 + background: transparent; 123 + border: none; 124 + cursor: pointer; 125 + transition: all 0.15s ease; 126 + position: relative; 127 + } 128 + 129 + .profile-tab:hover { 130 + color: var(--text-primary); 131 + } 132 + 133 + .profile-tab.active { 134 + color: var(--text-primary); 135 + } 136 + 137 + .profile-tab.active::after { 138 + content: ""; 139 + position: absolute; 140 + bottom: -1px; 141 + left: 0; 142 + right: 0; 143 + height: 2px; 144 + background: var(--text-primary); 145 + } 146 + 147 + .profile-badge-wrapper { 148 + display: inline-flex; 149 + align-items: center; 150 + } 151 + 152 + .profile-badge-clickable { 153 + position: relative; 154 + display: inline-flex; 155 + align-items: center; 156 + cursor: pointer; 157 + margin-left: 8px; 158 + } 159 + 160 + .badge-info-popover { 161 + position: absolute; 162 + top: calc(100% + 8px); 163 + left: 50%; 164 + transform: translateX(-50%); 165 + padding: 16px; 166 + background: var(--bg-elevated); 167 + border: 1px solid var(--border); 168 + border-radius: var(--radius-md); 169 + box-shadow: var(--shadow-lg); 170 + font-size: 0.85rem; 171 + white-space: nowrap; 172 + z-index: 100; 173 + min-width: 200px; 174 + } 175 + 176 + .badge-info-title { 177 + font-weight: 600; 178 + color: var(--text-primary); 179 + margin-bottom: 8px; 180 + } 181 + 182 + .verifier-link { 183 + display: flex; 184 + align-items: center; 185 + gap: 8px; 186 + padding: 8px; 187 + background: var(--bg-tertiary); 188 + border-radius: var(--radius-sm); 189 + text-decoration: none; 190 + transition: background 0.15s ease; 191 + } 192 + 193 + .verifier-link:hover { 194 + background: var(--bg-hover); 195 + } 196 + 197 + .verifier-avatar { 198 + width: 24px; 199 + height: 24px; 200 + border-radius: 50%; 201 + object-fit: cover; 202 + } 203 + 204 + .verifier-name { 205 + color: var(--text-primary); 206 + font-size: 0.85rem; 207 + font-weight: 500; 208 + } 209 + 210 + .profile-suspended { 211 + display: flex; 212 + flex-direction: column; 213 + align-items: center; 214 + justify-content: center; 215 + padding: 60px 20px; 216 + text-align: center; 217 + background: var(--bg-secondary); 218 + border-radius: var(--radius-lg); 219 + margin-top: 20px; 220 + border: 1px solid var(--border); 221 + } 222 + 223 + .suspended-icon { 224 + font-size: 40px; 225 + margin-bottom: 16px; 226 + color: var(--text-tertiary); 227 + } 228 + 229 + .profile-suspended h2 { 230 + color: var(--text-primary); 231 + margin-bottom: 8px; 232 + font-size: 1.25rem; 233 + } 234 + 235 + @media (max-width: 640px) { 236 + .profile-header { 237 + flex-direction: column; 238 + text-align: center; 239 + } 240 + 241 + .profile-info { 242 + align-items: center; 243 + } 244 + 245 + .profile-handle-row { 246 + justify-content: center; 247 + } 248 + 249 + .profile-stats { 250 + justify-content: center; 251 + } 252 + 253 + .profile-tabs { 254 + justify-content: center; 255 + gap: 16px; 256 + } 257 + }
+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 + }
+749
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 + overflow-wrap: break-word; 265 + word-break: break-word; 266 + max-width: 100%; 267 + } 268 + 269 + .composer-quote-remove { 270 + position: absolute; 271 + top: 8px; 272 + right: 8px; 273 + width: 24px; 274 + height: 24px; 275 + border-radius: var(--radius-full); 276 + background: var(--bg-tertiary); 277 + color: var(--text-secondary); 278 + font-size: 1rem; 279 + display: flex; 280 + align-items: center; 281 + justify-content: center; 282 + } 283 + 284 + .composer-quote-remove:hover { 285 + background: var(--bg-hover); 286 + color: var(--text-primary); 287 + } 288 + 289 + .composer-tags { 290 + flex: 1; 291 + } 292 + 293 + .composer-meta-row { 294 + display: flex; 295 + gap: 12px; 296 + margin-top: 12px; 297 + align-items: flex-start; 298 + } 299 + 300 + .composer-labels-wrapper { 301 + position: relative; 302 + } 303 + 304 + .composer-labels-btn { 305 + display: flex; 306 + align-items: center; 307 + justify-content: center; 308 + width: 42px; 309 + height: 42px; 310 + background: var(--bg-secondary); 311 + border: 1px solid var(--border); 312 + border-radius: var(--radius-md); 313 + cursor: pointer; 314 + color: var(--text-tertiary); 315 + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); 316 + position: relative; 317 + } 318 + 319 + .composer-labels-btn:hover { 320 + color: var(--text-primary); 321 + background: var(--bg-hover); 322 + border-color: var(--text-tertiary); 323 + } 324 + 325 + .composer-labels-btn.active { 326 + color: var(--accent); 327 + background: var(--accent-subtle); 328 + border-color: var(--accent); 329 + } 330 + 331 + .composer-labels-badge { 332 + position: absolute; 333 + top: -4px; 334 + right: -4px; 335 + background: var(--error); 336 + color: white; 337 + font-size: 0.7rem; 338 + width: 18px; 339 + height: 18px; 340 + border-radius: 50%; 341 + display: flex; 342 + align-items: center; 343 + justify-content: center; 344 + font-weight: bold; 345 + border: 2px solid var(--bg-primary); 346 + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); 347 + } 348 + 349 + .composer-labels-picker { 350 + position: absolute; 351 + bottom: 100%; 352 + right: 0; 353 + margin-bottom: 12px; 354 + background: var(--bg-elevated); 355 + border: 1px solid var(--border); 356 + border-radius: var(--radius-md); 357 + padding: 8px 0; 358 + min-width: 200px; 359 + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25); 360 + z-index: 50; 361 + animation: scaleIn 0.2s ease-out forwards; 362 + transform-origin: bottom right; 363 + } 364 + 365 + @keyframes scaleIn { 366 + from { 367 + opacity: 0; 368 + transform: scale(0.95) translateY(5px); 369 + } 370 + 371 + to { 372 + opacity: 1; 373 + transform: scale(1) translateY(0); 374 + } 375 + } 376 + 377 + .picker-header { 378 + font-size: 0.75rem; 379 + font-weight: 600; 380 + color: var(--text-tertiary); 381 + text-transform: uppercase; 382 + letter-spacing: 0.05em; 383 + margin-bottom: 4px; 384 + padding: 4px 12px 8px; 385 + border-bottom: 1px solid var(--border); 386 + } 387 + 388 + .picker-item { 389 + display: flex; 390 + align-items: center; 391 + gap: 10px; 392 + padding: 10px 14px; 393 + cursor: pointer; 394 + color: var(--text-secondary); 395 + font-size: 0.9rem; 396 + transition: all 0.15s ease; 397 + user-select: none; 398 + } 399 + 400 + .picker-item:hover { 401 + background: var(--bg-hover); 402 + color: var(--text-primary); 403 + } 404 + 405 + .picker-checkbox-wrapper { 406 + position: relative; 407 + width: 18px; 408 + height: 18px; 409 + display: flex; 410 + align-items: center; 411 + justify-content: center; 412 + } 413 + 414 + .picker-checkbox-wrapper input { 415 + position: absolute; 416 + opacity: 0; 417 + width: 100%; 418 + height: 100%; 419 + cursor: pointer; 420 + z-index: 10; 421 + } 422 + 423 + .picker-checkbox-custom { 424 + width: 18px; 425 + height: 18px; 426 + border: 2px solid var(--text-tertiary); 427 + border-radius: 4px; 428 + display: flex; 429 + align-items: center; 430 + justify-content: center; 431 + background: transparent; 432 + transition: all 0.2s ease; 433 + color: white; 434 + } 435 + 436 + .picker-item:hover .picker-checkbox-custom { 437 + border-color: var(--text-secondary); 438 + } 439 + 440 + .picker-checkbox-wrapper input:checked + .picker-checkbox-custom { 441 + background: var(--accent); 442 + border-color: var(--accent); 443 + color: white; 444 + } 445 + 446 + .composer-tags-input { 447 + width: 100%; 448 + padding: 12px 16px; 449 + background: var(--bg-secondary); 450 + border: 1px solid var(--border); 451 + border-radius: var(--radius-md); 452 + color: var(--text-primary); 453 + font-size: 0.95rem; 454 + transition: all 0.15s ease; 455 + } 456 + 457 + .composer-tags-input:focus { 458 + outline: none; 459 + border-color: var(--accent); 460 + box-shadow: 0 0 0 3px var(--accent-subtle); 461 + } 462 + 463 + .composer-tags-input::placeholder { 464 + color: var(--text-tertiary); 465 + } 466 + 467 + .history-panel { 468 + background: var(--bg-tertiary); 469 + border: 1px solid var(--border); 470 + border-radius: var(--radius-md); 471 + padding: 1rem; 472 + margin-bottom: 1rem; 473 + font-size: 0.9rem; 474 + animation: fadeIn 0.2s ease-out; 475 + } 476 + 477 + .history-header { 478 + display: flex; 479 + justify-content: space-between; 480 + align-items: center; 481 + margin-bottom: 1rem; 482 + padding-bottom: 0.5rem; 483 + border-bottom: 1px solid var(--border); 484 + } 485 + 486 + .history-title { 487 + font-weight: 600; 488 + text-transform: uppercase; 489 + letter-spacing: 0.05em; 490 + font-size: 0.75rem; 491 + color: var(--text-secondary); 492 + } 493 + 494 + .history-list { 495 + list-style: none; 496 + display: flex; 497 + flex-direction: column; 498 + gap: 1rem; 499 + } 500 + 501 + .history-item { 502 + position: relative; 503 + padding-left: 1rem; 504 + border-left: 2px solid var(--border); 505 + } 506 + 507 + .history-date { 508 + font-size: 0.75rem; 509 + color: var(--text-tertiary); 510 + margin-bottom: 0.25rem; 511 + } 512 + 513 + .history-content { 514 + color: var(--text-secondary); 515 + white-space: pre-wrap; 516 + } 517 + 518 + .history-close-btn { 519 + color: var(--text-tertiary); 520 + padding: 4px; 521 + border-radius: var(--radius-sm); 522 + transition: all 0.2s; 523 + display: flex; 524 + align-items: center; 525 + justify-content: center; 526 + } 527 + 528 + .history-close-btn:hover { 529 + background: var(--bg-hover); 530 + color: var(--text-primary); 531 + } 532 + 533 + .history-status { 534 + text-align: center; 535 + color: var(--text-tertiary); 536 + font-style: italic; 537 + padding: 1rem; 538 + } 539 + 540 + .share-menu-container { 541 + position: relative; 542 + } 543 + 544 + .share-menu { 545 + position: absolute; 546 + top: 100%; 547 + right: 0; 548 + margin-top: 8px; 549 + background: var(--bg-primary); 550 + border: 1px solid var(--border); 551 + border-radius: var(--radius-lg); 552 + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); 553 + min-width: 180px; 554 + padding: 8px 0; 555 + z-index: 100; 556 + animation: fadeInUp 0.15s ease; 557 + } 558 + 559 + @keyframes fadeInUp { 560 + from { 561 + opacity: 0; 562 + transform: translateY(-8px); 563 + } 564 + 565 + to { 566 + opacity: 1; 567 + transform: translateY(0); 568 + } 569 + } 570 + 571 + .share-menu-section { 572 + display: flex; 573 + flex-direction: column; 574 + } 575 + 576 + .share-menu-label { 577 + padding: 4px 12px 8px; 578 + font-size: 0.7rem; 579 + font-weight: 600; 580 + text-transform: uppercase; 581 + letter-spacing: 0.05em; 582 + color: var(--text-tertiary); 583 + } 584 + 585 + .share-menu-item { 586 + display: flex; 587 + align-items: center; 588 + gap: 10px; 589 + padding: 10px 14px; 590 + background: none; 591 + border: none; 592 + width: 100%; 593 + text-align: left; 594 + font-size: 0.9rem; 595 + color: var(--text-primary); 596 + cursor: pointer; 597 + transition: all 0.1s ease; 598 + } 599 + 600 + .share-menu-item:hover { 601 + background: var(--bg-tertiary); 602 + } 603 + 604 + .share-menu-icon { 605 + font-size: 1.1rem; 606 + width: 24px; 607 + text-align: center; 608 + } 609 + 610 + .share-menu-divider { 611 + height: 1px; 612 + background: var(--border); 613 + margin: 6px 0; 614 + } 615 + 616 + .bookmark-card { 617 + display: flex; 618 + flex-direction: column; 619 + gap: 16px; 620 + } 621 + 622 + .bookmark-preview { 623 + display: flex; 624 + flex-direction: column; 625 + background: var(--bg-secondary); 626 + border: 1px solid var(--border); 627 + border-radius: var(--radius-md); 628 + overflow: hidden; 629 + text-decoration: none; 630 + transition: all 0.2s ease; 631 + position: relative; 632 + } 633 + 634 + .bookmark-preview:hover { 635 + border-color: var(--accent); 636 + box-shadow: var(--shadow-sm); 637 + transform: translateY(-1px); 638 + } 639 + 640 + .bookmark-preview::before { 641 + content: ""; 642 + position: absolute; 643 + left: 0; 644 + top: 0; 645 + bottom: 0; 646 + width: 4px; 647 + background: var(--accent); 648 + opacity: 0.7; 649 + } 650 + 651 + .bookmark-preview-content { 652 + padding: 16px 20px; 653 + display: flex; 654 + flex-direction: column; 655 + gap: 8px; 656 + } 657 + 658 + .bookmark-preview-header { 659 + display: flex; 660 + align-items: center; 661 + gap: 8px; 662 + margin-bottom: 4px; 663 + } 664 + 665 + .bookmark-preview-site { 666 + display: flex; 667 + align-items: center; 668 + gap: 6px; 669 + font-size: 0.75rem; 670 + font-weight: 600; 671 + color: var(--accent); 672 + text-transform: uppercase; 673 + letter-spacing: 0.03em; 674 + } 675 + 676 + .bookmark-preview-title { 677 + font-size: 1rem; 678 + font-weight: 600; 679 + line-height: 1.4; 680 + color: var(--text-primary); 681 + margin: 0; 682 + display: -webkit-box; 683 + -webkit-line-clamp: 2; 684 + line-clamp: 2; 685 + -webkit-box-orient: vertical; 686 + overflow: hidden; 687 + } 688 + 689 + .bookmark-preview-desc { 690 + font-size: 0.875rem; 691 + color: var(--text-secondary); 692 + line-height: 1.5; 693 + margin: 0; 694 + display: -webkit-box; 695 + -webkit-line-clamp: 2; 696 + line-clamp: 2; 697 + -webkit-box-orient: vertical; 698 + overflow: hidden; 699 + } 700 + 701 + .bookmark-preview-arrow { 702 + display: flex; 703 + align-items: center; 704 + justify-content: center; 705 + color: var(--text-tertiary); 706 + padding: 0 4px; 707 + transition: all 0.2s ease; 708 + } 709 + 710 + .bookmark-preview:hover .bookmark-preview-arrow { 711 + color: var(--accent); 712 + transform: translateX(2px); 713 + } 714 + 715 + .bookmark-description { 716 + font-size: 0.9rem; 717 + color: var(--text-secondary); 718 + margin: 0; 719 + line-height: 1.5; 720 + } 721 + 722 + .bookmark-meta { 723 + display: flex; 724 + align-items: center; 725 + gap: 12px; 726 + margin-top: 12px; 727 + font-size: 0.85rem; 728 + color: var(--text-tertiary); 729 + } 730 + 731 + .bookmark-time { 732 + color: var(--text-tertiary); 733 + } 734 + 735 + .bookmark-preview { 736 + max-width: 100%; 737 + width: 100%; 738 + box-sizing: border-box; 739 + } 740 + 741 + @media (max-width: 600px) { 742 + .bookmark-preview-content { 743 + padding: 12px 14px; 744 + } 745 + 746 + .legal-content { 747 + padding: 16px; 748 + } 749 + }
+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
+251 -21
web/src/pages/Profile.jsx
··· 7 7 getUserHighlights, 8 8 getUserBookmarks, 9 9 getCollections, 10 + getAPIKeys, 11 + createAPIKey, 12 + deleteAPIKey, 10 13 } from "../api/client"; 14 + import { useAuth } from "../context/AuthContext"; 11 15 import CollectionIcon from "../components/CollectionIcon"; 12 16 import CollectionRow from "../components/CollectionRow"; 13 17 import { ··· 17 21 BlueskyIcon, 18 22 } from "../components/Icons"; 19 23 24 + function KeyIcon({ size = 16 }) { 25 + return ( 26 + <svg 27 + width={size} 28 + height={size} 29 + viewBox="0 0 24 24" 30 + fill="none" 31 + stroke="currentColor" 32 + strokeWidth="2" 33 + strokeLinecap="round" 34 + strokeLinejoin="round" 35 + > 36 + <path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4" /> 37 + </svg> 38 + ); 39 + } 40 + 20 41 export default function Profile() { 21 42 const { handle } = useParams(); 43 + const { user } = useAuth(); 22 44 const [activeTab, setActiveTab] = useState("annotations"); 23 45 const [profile, setProfile] = useState(null); 24 46 const [annotations, setAnnotations] = useState([]); 25 47 const [highlights, setHighlights] = useState([]); 26 48 const [bookmarks, setBookmarks] = useState([]); 27 49 const [collections, setCollections] = useState([]); 50 + const [apiKeys, setApiKeys] = useState([]); 51 + const [newKeyName, setNewKeyName] = useState(""); 52 + const [newKey, setNewKey] = useState(null); 53 + const [keysLoading, setKeysLoading] = useState(false); 28 54 const [loading, setLoading] = useState(true); 29 55 const [error, setError] = useState(null); 56 + 57 + const isOwnProfile = user && (user.did === handle || user.handle === handle); 30 58 31 59 useEffect(() => { 32 60 async function fetchProfile() { ··· 61 89 } 62 90 fetchProfile(); 63 91 }, [handle]); 92 + 93 + useEffect(() => { 94 + if (isOwnProfile && activeTab === "apikeys") { 95 + loadAPIKeys(); 96 + } 97 + }, [isOwnProfile, activeTab]); 98 + 99 + const loadAPIKeys = async () => { 100 + setKeysLoading(true); 101 + try { 102 + const data = await getAPIKeys(); 103 + setApiKeys(data.keys || []); 104 + } catch { 105 + setApiKeys([]); 106 + } finally { 107 + setKeysLoading(false); 108 + } 109 + }; 110 + 111 + const handleCreateKey = async () => { 112 + if (!newKeyName.trim()) return; 113 + try { 114 + const data = await createAPIKey(newKeyName.trim()); 115 + setNewKey(data.key); 116 + setNewKeyName(""); 117 + loadAPIKeys(); 118 + } catch (err) { 119 + alert("Failed to create key: " + err.message); 120 + } 121 + }; 122 + 123 + const handleDeleteKey = async (id) => { 124 + if (!confirm("Delete this API key? This cannot be undone.")) return; 125 + try { 126 + await deleteAPIKey(id); 127 + loadAPIKeys(); 128 + } catch (err) { 129 + alert("Failed to delete key: " + err.message); 130 + } 131 + }; 64 132 65 133 const displayName = profile?.displayName || profile?.handle || handle; 66 134 const displayHandle = ··· 89 157 </div> 90 158 <h3 className="empty-state-title">No annotations</h3> 91 159 <p className="empty-state-text"> 92 - This user hasn't posted any annotations. 160 + This user hasn&apos;t posted any annotations. 93 161 </p> 94 162 </div> 95 163 ); ··· 108 176 </div> 109 177 <h3 className="empty-state-title">No highlights</h3> 110 178 <p className="empty-state-text"> 111 - This user hasn't saved any highlights. 179 + This user hasn&apos;t saved any highlights. 112 180 </p> 113 181 </div> 114 182 ); ··· 125 193 </div> 126 194 <h3 className="empty-state-title">No bookmarks</h3> 127 195 <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. 196 + This user hasn&apos;t bookmarked any pages. 145 197 </p> 146 198 </div> 147 199 ); 148 200 } 149 - return bookmarks.map((b) => <BookmarkCard key={b.id} annotation={b} />); 201 + return bookmarks.map((b) => <BookmarkCard key={b.uri} bookmark={b} />); 150 202 } 151 203 152 204 if (activeTab === "collections") { ··· 158 210 </div> 159 211 <h3 className="empty-state-title">No collections</h3> 160 212 <p className="empty-state-text"> 161 - This user hasn't created any collections. 213 + This user hasn&apos;t created any collections. 162 214 </p> 163 215 </div> 164 216 ); ··· 171 223 </div> 172 224 ); 173 225 } 226 + 227 + if (activeTab === "apikeys" && isOwnProfile) { 228 + return ( 229 + <div className="api-keys-section"> 230 + <div className="card" style={{ marginBottom: "1rem" }}> 231 + <h3 style={{ marginBottom: "0.5rem" }}>Create API Key</h3> 232 + <p 233 + style={{ 234 + color: "var(--text-muted)", 235 + marginBottom: "1rem", 236 + fontSize: "0.875rem", 237 + }} 238 + > 239 + Use API keys to create bookmarks from iOS Shortcuts or other 240 + tools. 241 + </p> 242 + <div style={{ display: "flex", gap: "0.5rem" }}> 243 + <input 244 + type="text" 245 + value={newKeyName} 246 + onChange={(e) => setNewKeyName(e.target.value)} 247 + placeholder="Key name (e.g., iOS Shortcut)" 248 + className="input" 249 + style={{ flex: 1 }} 250 + /> 251 + <button className="btn btn-primary" onClick={handleCreateKey}> 252 + Generate 253 + </button> 254 + </div> 255 + {newKey && ( 256 + <div 257 + style={{ 258 + marginTop: "1rem", 259 + padding: "1rem", 260 + background: "var(--bg-secondary)", 261 + borderRadius: "8px", 262 + }} 263 + > 264 + <p 265 + style={{ 266 + color: "var(--text-success)", 267 + fontWeight: 500, 268 + marginBottom: "0.5rem", 269 + }} 270 + > 271 + โœ“ Key created! Copy it now, you won&apos;t see it again. 272 + </p> 273 + <code 274 + style={{ 275 + display: "block", 276 + padding: "0.75rem", 277 + background: "var(--bg-tertiary)", 278 + borderRadius: "4px", 279 + wordBreak: "break-all", 280 + fontSize: "0.8rem", 281 + }} 282 + > 283 + {newKey} 284 + </code> 285 + <button 286 + className="btn btn-secondary" 287 + style={{ marginTop: "0.5rem" }} 288 + onClick={() => { 289 + navigator.clipboard.writeText(newKey); 290 + alert("Copied!"); 291 + }} 292 + > 293 + Copy to clipboard 294 + </button> 295 + </div> 296 + )} 297 + </div> 298 + 299 + {keysLoading ? ( 300 + <div className="card"> 301 + <div className="skeleton skeleton-text" /> 302 + </div> 303 + ) : apiKeys.length === 0 ? ( 304 + <div className="empty-state"> 305 + <div className="empty-state-icon"> 306 + <KeyIcon size={32} /> 307 + </div> 308 + <h3 className="empty-state-title">No API keys</h3> 309 + <p className="empty-state-text"> 310 + Create a key to use with iOS Shortcuts. 311 + </p> 312 + </div> 313 + ) : ( 314 + <div className="card"> 315 + <h3 style={{ marginBottom: "1rem" }}>Your API Keys</h3> 316 + {apiKeys.map((key) => ( 317 + <div 318 + key={key.id} 319 + style={{ 320 + display: "flex", 321 + justifyContent: "space-between", 322 + alignItems: "center", 323 + padding: "0.75rem 0", 324 + borderBottom: "1px solid var(--border-color)", 325 + }} 326 + > 327 + <div> 328 + <strong>{key.name}</strong> 329 + <div 330 + style={{ 331 + fontSize: "0.75rem", 332 + color: "var(--text-muted)", 333 + }} 334 + > 335 + Created {new Date(key.createdAt).toLocaleDateString()} 336 + {key.lastUsedAt && 337 + ` โ€ข Last used ${new Date(key.lastUsedAt).toLocaleDateString()}`} 338 + </div> 339 + </div> 340 + <button 341 + className="btn btn-sm" 342 + style={{ 343 + fontSize: "0.75rem", 344 + padding: "0.25rem 0.5rem", 345 + color: "#ef4444", 346 + border: "1px solid #ef4444", 347 + }} 348 + onClick={() => handleDeleteKey(key.id)} 349 + > 350 + Revoke 351 + </button> 352 + </div> 353 + ))} 354 + </div> 355 + )} 356 + 357 + <div className="card" style={{ marginTop: "1rem" }}> 358 + <h3 style={{ marginBottom: "0.5rem" }}>iOS Shortcut</h3> 359 + <p 360 + style={{ 361 + color: "var(--text-muted)", 362 + marginBottom: "1rem", 363 + fontSize: "0.875rem", 364 + }} 365 + > 366 + Save bookmarks from Safari&apos;s share sheet. 367 + </p> 368 + <a 369 + href="#" 370 + className="btn btn-primary" 371 + style={{ 372 + display: "inline-flex", 373 + alignItems: "center", 374 + gap: "0.5rem", 375 + opacity: 0.5, 376 + pointerEvents: "none", 377 + cursor: "default", 378 + }} 379 + onClick={(e) => e.preventDefault()} 380 + > 381 + <AppleIcon size={16} /> Coming Soon 382 + </a> 383 + </div> 384 + </div> 385 + ); 386 + } 174 387 }; 175 388 176 389 const bskyProfileUrl = displayHandle ··· 246 459 > 247 460 Collections ({collections.length}) 248 461 </button> 462 + 463 + {isOwnProfile && ( 464 + <button 465 + className={`profile-tab ${activeTab === "apikeys" ? "active" : ""}`} 466 + onClick={() => setActiveTab("apikeys")} 467 + > 468 + <KeyIcon size={14} /> API Keys 469 + </button> 470 + )} 249 471 </div> 250 472 251 473 {loading && ( ··· 278 500 </div> 279 501 ); 280 502 } 503 + 504 + function AppleIcon({ size = 16 }) { 505 + return ( 506 + <svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor"> 507 + <path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z" /> 508 + </svg> 509 + ); 510 + }
+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 + }