+1
-1
.github/workflows/docker-publish.yml
+1
-1
.github/workflows/docker-publish.yml
+13
-3
.github/workflows/release-extension.yml
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+
}
+10
-4
backend/internal/api/token_refresh.go
+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
+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
+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
+
}
+236
-84
backend/internal/firehose/ingester.go
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+14
extension/package.json
+86
-20
extension/popup/popup.css
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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>"{highlightedText}"</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>"{highlightedText}"</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
+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
+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
+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
-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
+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
+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">"{highlightedText}"</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>
-1
web/src/components/ReplyList.jsx
-1
web/src/components/ReplyList.jsx
+195
web/src/components/RightSidebar.jsx
+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
+12
web/src/components/ScrollToTop.jsx
+189
web/src/components/Sidebar.jsx
+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
+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
+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
+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
+
}
+326
web/src/css/collections.css
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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'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 "Add Bookmark" 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
+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
+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
+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
+1
-1
web/src/pages/Highlights.jsx
+24
-22
web/src/pages/Login.jsx
+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
+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
+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'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
+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 ("we", "our", or "us") 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'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'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's access at any time</li>
129
129
</ul>
130
130
</section>
131
131
+251
-21
web/src/pages/Profile.jsx
+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'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'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'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'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'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'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
+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 ("Terms"). 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 "as is", 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 "AS IS" AND "AS
66
+
AVAILABLE". 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
+
}