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

Init Margin

+23556
+35
.dockerignore
··· 1 + # Build artifacts 2 + dist/ 3 + build/ 4 + node_modules/ 5 + vendor/ 6 + 7 + # Database 8 + *.db 9 + *.sqlite 10 + *.sqlite3 11 + 12 + # Environment 13 + .env 14 + .env.local 15 + .env.* 16 + 17 + # IDE 18 + .idea/ 19 + .vscode/ 20 + 21 + # OS 22 + .DS_Store 23 + Thumbs.db 24 + 25 + # Git 26 + .git/ 27 + .gitignore 28 + 29 + # Docs 30 + README.md 31 + NOTES.md 32 + *.md 33 + 34 + # Test files 35 + *_test.go
+22
.env.example
··· 1 + # Environment Configuration 2 + 3 + # Server 4 + PORT=8080 5 + BASE_URL=https://example.com 6 + 7 + # Database 8 + DATABASE_URL=margin.db 9 + 10 + # Static Files (path to built frontend) 11 + STATIC_DIR=../web/dist 12 + 13 + # AT Protocol OAuth 14 + OAUTH_CLIENT_ID=https://example.com/client-metadata.json 15 + OAUTH_CALLBACK_URL=https://example.com/auth/callback 16 + OAUTH_KEY_PATH=./oauth_private_key.pem 17 + 18 + # Production Example: 19 + # PORT=443 20 + # BASE_URL=https://margin.at 21 + # OAUTH_CLIENT_ID=https://margin.at/client-metadata.json 22 + # OAUTH_CALLBACK_URL=https://margin.at/auth/callback
+50
.github/workflows/docker-publish.yml
··· 1 + name: Build and Publish Docker Image 2 + 3 + on: 4 + push: 5 + branches: [ "main" ] 6 + workflow_dispatch: 7 + 8 + env: 9 + REGISTRY: ghcr.io 10 + IMAGE_NAME: ${{ github.repository }} 11 + 12 + jobs: 13 + build-and-push: 14 + runs-on: ubuntu-latest 15 + permissions: 16 + contents: read 17 + packages: write 18 + 19 + steps: 20 + - name: Checkout repository 21 + uses: actions/checkout@v4 22 + 23 + - name: Log in to the Container registry 24 + uses: docker/login-action@v3 25 + with: 26 + registry: ${{ env.REGISTRY }} 27 + username: ${{ github.actor }} 28 + password: ${{ secrets.GITHUB_TOKEN }} 29 + 30 + - name: Extract metadata (tags, labels) for Docker 31 + id: meta 32 + uses: docker/metadata-action@v5 33 + with: 34 + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 35 + tags: | 36 + type=raw,value=latest,enable={{is_default_branch}} 37 + type=sha 38 + 39 + - name: Set up Docker Buildx 40 + uses: docker/setup-buildx-action@v3 41 + 42 + - name: Build and push Docker image 43 + uses: docker/build-push-action@v5 44 + with: 45 + context: . 46 + push: true 47 + tags: ${{ steps.meta.outputs.tags }} 48 + labels: ${{ steps.meta.outputs.labels }} 49 + cache-from: type=gha 50 + cache-to: type=gha,mode=max
+88
.github/workflows/release-extension.yml
··· 1 + name: Release Extension 2 + 3 + on: 4 + push: 5 + tags: 6 + - 'v*' 7 + 8 + jobs: 9 + release: 10 + runs-on: ubuntu-latest 11 + permissions: 12 + contents: write 13 + 14 + steps: 15 + - name: Checkout code 16 + uses: actions/checkout@v4 17 + 18 + - name: Update Manifest Version 19 + run: | 20 + VERSION=${GITHUB_REF_NAME#v} 21 + echo "Updating manifests to version $VERSION" 22 + 23 + cd extension 24 + for manifest in manifest.json manifest.chrome.json manifest.firefox.json; do 25 + if [ -f "$manifest" ]; then 26 + tmp=$(mktemp) 27 + jq --arg v "$VERSION" '.version = $v' "$manifest" > "$tmp" && mv "$tmp" "$manifest" 28 + echo "Updated $manifest" 29 + fi 30 + done 31 + cd .. 32 + 33 + - name: Build Extension (Chrome) 34 + run: | 35 + cd extension 36 + cp manifest.chrome.json manifest.json 37 + zip -r ../margin-extension-chrome.zip . -x "*.DS_Store" -x "*.git*" -x "manifest.*.json" 38 + cd .. 39 + 40 + - name: Build Extension (Firefox) 41 + run: | 42 + cd extension 43 + cp manifest.firefox.json manifest.json 44 + zip -r ../margin-extension-firefox.xpi . -x "*.DS_Store" -x "*.git*" -x "manifest.*.json" 45 + cd .. 46 + 47 + - name: Publish to Chrome Web Store 48 + continue-on-error: true 49 + uses: mobilefirstllc/cws-publish@latest 50 + with: 51 + action: publish 52 + client_id: ${{ secrets.CHROME_CLIENT_ID }} 53 + client_secret: ${{ secrets.CHROME_CLIENT_SECRET }} 54 + refresh_token: ${{ secrets.CHROME_REFRESH_TOKEN }} 55 + extension_id: ${{ secrets.CHROME_EXTENSION_ID }} 56 + zip_file: margin-extension-chrome.zip 57 + 58 + # - name: Publish to Edge Add-ons 59 + # uses: nicholaslee119/edge-addon-upload@master 60 + # with: 61 + # product_id: ${{ secrets.EDGE_PRODUCT_ID }} 62 + # client_id: ${{ secrets.EDGE_CLIENT_ID }} 63 + # client_secret: ${{ secrets.EDGE_CLIENT_SECRET }} 64 + # access_token_url: ${{ secrets.EDGE_ACCESS_TOKEN_URL }} 65 + # file_path: margin-extension-chrome.zip 66 + 67 + - name: Publish to Firefox AMO 68 + continue-on-error: true 69 + env: 70 + AMO_JWT_ISSUER: ${{ secrets.AMO_JWT_ISSUER }} 71 + AMO_JWT_SECRET: ${{ secrets.AMO_JWT_SECRET }} 72 + run: | 73 + cd extension 74 + COMMIT_MSG=$(git log -1 --pretty=%s) 75 + echo "{\"release_notes\": {\"en-US\": \"$COMMIT_MSG\"}, \"notes\": \"Thank you!\"}" > amo-metadata.json 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 + rm amo-metadata.json 78 + cd .. 79 + 80 + - name: Create Release 81 + uses: softprops/action-gh-release@v1 82 + with: 83 + files: | 84 + margin-extension-chrome.zip 85 + margin-extension-firefox.xpi 86 + generate_release_notes: true 87 + draft: false 88 + prerelease: false
+37
.gitignore
··· 1 + # Dependencies 2 + node_modules/ 3 + 4 + # Build outputs 5 + dist/ 6 + build/ 7 + main 8 + margin-backend 9 + backend/server 10 + 11 + # Environment 12 + .env 13 + .env.local 14 + 15 + # Database 16 + *.db 17 + *.sqlite 18 + 19 + # Keys 20 + oauth_private_key.pem 21 + *.pem 22 + 23 + # IDE 24 + .idea/ 25 + .vscode/ 26 + *.swp 27 + 28 + # OS 29 + .DS_Store 30 + Thumbs.db 31 + 32 + # Go 33 + backend/vendor/ 34 + 35 + # other 36 + NOTES.md 37 + extension.zip
+34
Dockerfile
··· 1 + FROM node:20-alpine AS frontend-builder 2 + 3 + WORKDIR /app/web 4 + COPY web/package*.json ./ 5 + RUN npm ci 6 + COPY web/ ./ 7 + RUN npm run build 8 + 9 + FROM golang:1.24-alpine AS backend-builder 10 + 11 + RUN apk add --no-cache gcc musl-dev 12 + 13 + WORKDIR /app 14 + COPY backend/go.mod backend/go.sum ./ 15 + RUN go mod download 16 + 17 + COPY backend/ ./ 18 + RUN CGO_ENABLED=1 GOOS=linux go build -a -ldflags '-linkmode external -extldflags "-static"' -o margin-server ./cmd/server 19 + 20 + FROM alpine:3.19 21 + 22 + RUN apk add --no-cache ca-certificates tzdata 23 + 24 + WORKDIR /app 25 + 26 + COPY --from=backend-builder /app/margin-server . 27 + COPY --from=frontend-builder /app/web/dist ./dist 28 + 29 + ENV PORT=8080 30 + ENV DATABASE_URL=margin.db 31 + 32 + EXPOSE 8080 33 + 34 + CMD ["./margin-server"]
+21
LICENSE
··· 1 + MIT License 2 + 3 + Copyright (c) 2026 Margin 4 + 5 + Permission is hereby granted, free of charge, to any person obtaining a copy 6 + of this software and associated documentation files (the "Software"), to deal 7 + in the Software without restriction, including without limitation the rights 8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 + copies of the Software, and to permit persons to whom the Software is 10 + furnished to do so, subject to the following conditions: 11 + 12 + The above copyright notice and this permission notice shall be included in all 13 + copies or substantial portions of the Software. 14 + 15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 + SOFTWARE.
+93
README.md
··· 1 + # Margin 2 + 3 + *Write in the margins of the web* 4 + 5 + A web comments layer built on [AT Protocol](https://atproto.com) that lets you annotate any URL on the internet. 6 + 7 + ## Project Structure 8 + 9 + ``` 10 + project-agua/ 11 + ├── lexicons/ # AT Protocol lexicon schemas 12 + │ └── at/margin/ 13 + │ ├── annotation.json 14 + │ ├── bookmark.json 15 + │ ├── collection.json 16 + │ └── collectionItem.json 17 + │ └── highlight.json 18 + │ └── like.json 19 + │ └── reply.json 20 + ├── backend/ # Go API server 21 + │ ├── cmd/server/ 22 + │ └── internal/ 23 + ├── web/ # React web app 24 + │ └── src/ 25 + └── extension/ # Browser extension 26 + ├── popup/ 27 + ├── content/ 28 + └── background/ 29 + ``` 30 + 31 + ## Getting Started 32 + 33 + ### Backend 34 + 35 + ```bash 36 + cd backend 37 + go mod tidy 38 + go run ./cmd/server 39 + ``` 40 + 41 + Server runs on http://localhost:8080 42 + 43 + Server runs on http://localhost:8080 44 + 45 + ### Docker (Recommended) 46 + 47 + Run the full stack (Backend + Postgres) with Docker: 48 + 49 + ```bash 50 + docker-compose up -d --build 51 + ``` 52 + 53 + ### Web App 54 + 55 + ```bash 56 + cd web 57 + npm install 58 + npm run dev 59 + ``` 60 + 61 + App runs on http://localhost:3000 62 + 63 + ### Browser Extension 64 + 65 + #### Chrome 66 + 67 + 1. Open Chrome → `chrome://extensions` 68 + 2. Enable "Developer mode" 69 + 3. Click "Load unpacked" 70 + 4. Select the `extension/` folder 71 + 72 + #### Firefox 73 + 74 + 1. Open Firefox → `about:debugging` 75 + 2. Click "This Firefox" 76 + 3. Click "Load Temporary Add-on" 77 + 4. Select the `manifest.firefox.json` file in the `extension/` folder 78 + 79 + ## Domain 80 + 81 + **Domain**: `margin.at` 82 + **Lexicon Namespace**: `at.margin.*` 83 + 84 + ## Tech Stack 85 + 86 + - **Backend**: Go + Chi + SQLite / PostgreSQL 87 + - **Frontend**: React 18 + Vite 88 + - **Extension**: Manifest v3 89 + - **Protocol**: AT Protocol (Bluesky) 90 + 91 + ## License 92 + 93 + MIT
+191
backend/cmd/server/main.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "log" 6 + "net/http" 7 + "os" 8 + "os/signal" 9 + "path/filepath" 10 + "strings" 11 + "syscall" 12 + "time" 13 + 14 + "github.com/go-chi/chi/v5" 15 + "github.com/go-chi/chi/v5/middleware" 16 + "github.com/go-chi/cors" 17 + "github.com/joho/godotenv" 18 + 19 + "margin.at/internal/api" 20 + "margin.at/internal/db" 21 + "margin.at/internal/firehose" 22 + "margin.at/internal/oauth" 23 + ) 24 + 25 + func main() { 26 + godotenv.Load("../.env", ".env") 27 + 28 + database, err := db.New(getEnv("DATABASE_URL", "margin.db")) 29 + if err != nil { 30 + log.Fatalf("Failed to connect to database: %v", err) 31 + } 32 + defer database.Close() 33 + 34 + if err := database.Migrate(); err != nil { 35 + log.Fatalf("Failed to run migrations: %v", err) 36 + } 37 + 38 + oauthHandler, err := oauth.NewHandler(database) 39 + if err != nil { 40 + log.Fatalf("Failed to initialize OAuth: %v", err) 41 + } 42 + 43 + ingester := firehose.NewIngester(database) 44 + go func() { 45 + if err := ingester.Start(context.Background()); err != nil { 46 + log.Printf("Firehose ingester error: %v", err) 47 + } 48 + }() 49 + 50 + r := chi.NewRouter() 51 + 52 + r.Use(middleware.Logger) 53 + r.Use(middleware.Recoverer) 54 + r.Use(middleware.RequestID) 55 + r.Use(middleware.RealIP) 56 + r.Use(middleware.Timeout(60 * time.Second)) 57 + r.Use(middleware.Throttle(100)) 58 + 59 + r.Use(cors.Handler(cors.Options{ 60 + AllowedOrigins: []string{"https://*", "http://*", "chrome-extension://*"}, 61 + AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, 62 + AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token", "X-Session-Token"}, 63 + ExposedHeaders: []string{"Link"}, 64 + AllowCredentials: true, 65 + MaxAge: 300, 66 + })) 67 + 68 + tokenRefresher := api.NewTokenRefresher(database, oauthHandler.GetPrivateKey()) 69 + annotationSvc := api.NewAnnotationService(database, tokenRefresher) 70 + 71 + handler := api.NewHandler(database, annotationSvc, tokenRefresher) 72 + handler.RegisterRoutes(r) 73 + 74 + r.Post("/api/annotations", annotationSvc.CreateAnnotation) 75 + r.Put("/api/annotations", annotationSvc.UpdateAnnotation) 76 + r.Delete("/api/annotations", annotationSvc.DeleteAnnotation) 77 + r.Post("/api/annotations/like", annotationSvc.LikeAnnotation) 78 + r.Delete("/api/annotations/like", annotationSvc.UnlikeAnnotation) 79 + r.Post("/api/annotations/reply", annotationSvc.CreateReply) 80 + r.Delete("/api/annotations/reply", annotationSvc.DeleteReply) 81 + r.Post("/api/highlights", annotationSvc.CreateHighlight) 82 + r.Put("/api/highlights", annotationSvc.UpdateHighlight) 83 + r.Delete("/api/highlights", annotationSvc.DeleteHighlight) 84 + r.Post("/api/bookmarks", annotationSvc.CreateBookmark) 85 + r.Put("/api/bookmarks", annotationSvc.UpdateBookmark) 86 + r.Delete("/api/bookmarks", annotationSvc.DeleteBookmark) 87 + 88 + r.Get("/auth/login", oauthHandler.HandleLogin) 89 + r.Post("/auth/start", oauthHandler.HandleStart) 90 + r.Get("/auth/callback", oauthHandler.HandleCallback) 91 + r.Post("/auth/logout", oauthHandler.HandleLogout) 92 + r.Get("/auth/session", oauthHandler.HandleSession) 93 + r.Get("/client-metadata.json", oauthHandler.HandleClientMetadata) 94 + r.Get("/jwks.json", oauthHandler.HandleJWKS) 95 + 96 + ogHandler := api.NewOGHandler(database) 97 + r.Get("/og-image", ogHandler.HandleOGImage) 98 + r.Get("/annotation/{did}/{rkey}", ogHandler.HandleAnnotationPage) 99 + r.Get("/at/{did}/{rkey}", ogHandler.HandleAnnotationPage) 100 + 101 + staticDir := getEnv("STATIC_DIR", "../web/dist") 102 + serveStatic(r, staticDir) 103 + 104 + port := getEnv("PORT", "8080") 105 + server := &http.Server{ 106 + Addr: ":" + port, 107 + Handler: r, 108 + } 109 + 110 + baseURL := getEnv("BASE_URL", "http://localhost:"+port) 111 + go func() { 112 + log.Printf("🚀 Margin server running on %s", baseURL) 113 + log.Printf("📝 App: %s", baseURL) 114 + log.Printf("🔗 API: %s/api/annotations", baseURL) 115 + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { 116 + log.Fatalf("Server error: %v", err) 117 + } 118 + }() 119 + 120 + quit := make(chan os.Signal, 1) 121 + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) 122 + <-quit 123 + 124 + log.Println("Shutting down server...") 125 + ingester.Stop() 126 + 127 + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 128 + defer cancel() 129 + 130 + if err := server.Shutdown(ctx); err != nil { 131 + log.Fatalf("Server forced to shutdown: %v", err) 132 + } 133 + 134 + log.Println("Server exited") 135 + } 136 + 137 + func getEnv(key, fallback string) string { 138 + if value, ok := os.LookupEnv(key); ok { 139 + return value 140 + } 141 + return fallback 142 + } 143 + 144 + func serveStatic(r chi.Router, staticDir string) { 145 + absPath, err := filepath.Abs(staticDir) 146 + if err != nil { 147 + log.Printf("Warning: Could not resolve static directory: %v", err) 148 + return 149 + } 150 + 151 + if _, err := os.Stat(absPath); os.IsNotExist(err) { 152 + log.Printf("Warning: Static directory does not exist: %s", absPath) 153 + log.Printf("Run 'npm run build' in the web directory first") 154 + return 155 + } 156 + 157 + log.Printf("📂 Serving static files from: %s", absPath) 158 + 159 + fileServer := http.FileServer(http.Dir(absPath)) 160 + 161 + r.Get("/*", func(w http.ResponseWriter, req *http.Request) { 162 + path := req.URL.Path 163 + 164 + if strings.HasPrefix(path, "/api/") || strings.HasPrefix(path, "/auth/") { 165 + http.NotFound(w, req) 166 + return 167 + } 168 + 169 + filePath := filepath.Join(absPath, path) 170 + if _, err := os.Stat(filePath); err == nil { 171 + fileServer.ServeHTTP(w, req) 172 + return 173 + } 174 + 175 + lastSlash := strings.LastIndex(path, "/") 176 + lastSegment := path 177 + if lastSlash >= 0 { 178 + lastSegment = path[lastSlash+1:] 179 + } 180 + 181 + staticExts := []string{".js", ".css", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico", ".woff", ".woff2", ".ttf", ".eot", ".map"} 182 + for _, ext := range staticExts { 183 + if strings.HasSuffix(lastSegment, ext) { 184 + http.NotFound(w, req) 185 + return 186 + } 187 + } 188 + 189 + http.ServeFile(w, req, filepath.Join(absPath, "index.html")) 190 + }) 191 + }
+21
backend/go.mod
··· 1 + module margin.at 2 + 3 + go 1.24.0 4 + 5 + require ( 6 + github.com/go-chi/chi/v5 v5.1.0 7 + github.com/go-chi/cors v1.2.1 8 + github.com/go-jose/go-jose/v4 v4.0.4 9 + github.com/joho/godotenv v1.5.1 10 + github.com/lib/pq v1.10.9 11 + github.com/mattn/go-sqlite3 v1.14.22 12 + golang.org/x/image v0.34.0 13 + ) 14 + 15 + require ( 16 + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 17 + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 18 + github.com/stretchr/testify v1.10.0 // indirect 19 + golang.org/x/crypto v0.31.0 // indirect 20 + golang.org/x/text v0.32.0 // indirect 21 + )
+28
backend/go.sum
··· 1 + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 2 + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 + github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= 4 + github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= 5 + github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= 6 + github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= 7 + github.com/go-jose/go-jose/v4 v4.0.4 h1:VsjPI33J0SB9vQM6PLmNjoHqMQNGPiZ0rHL7Ni7Q6/E= 8 + github.com/go-jose/go-jose/v4 v4.0.4/go.mod h1:NKb5HO1EZccyMpiZNbdUw/14tiXNyUJh188dfnMCAfc= 9 + github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 10 + github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 11 + github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 12 + github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 13 + github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= 14 + github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 15 + github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= 16 + github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 17 + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 18 + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 19 + github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 20 + 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= 23 + golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8= 24 + golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU= 25 + golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= 26 + golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= 27 + gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 28 + gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+855
backend/internal/api/annotations.go
··· 1 + package api 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "log" 7 + "net/http" 8 + "strings" 9 + "time" 10 + 11 + "margin.at/internal/db" 12 + "margin.at/internal/xrpc" 13 + ) 14 + 15 + type AnnotationService struct { 16 + db *db.DB 17 + refresher *TokenRefresher 18 + } 19 + 20 + func NewAnnotationService(database *db.DB, refresher *TokenRefresher) *AnnotationService { 21 + return &AnnotationService{db: database, refresher: refresher} 22 + } 23 + 24 + type CreateAnnotationRequest struct { 25 + URL string `json:"url"` 26 + Text string `json:"text"` 27 + Selector interface{} `json:"selector,omitempty"` 28 + Title string `json:"title,omitempty"` 29 + Tags []string `json:"tags,omitempty"` 30 + } 31 + 32 + type CreateAnnotationResponse struct { 33 + URI string `json:"uri"` 34 + CID string `json:"cid"` 35 + } 36 + 37 + func (s *AnnotationService) CreateAnnotation(w http.ResponseWriter, r *http.Request) { 38 + session, err := s.refresher.GetSessionWithAutoRefresh(r) 39 + if err != nil { 40 + http.Error(w, err.Error(), http.StatusUnauthorized) 41 + return 42 + } 43 + 44 + var req CreateAnnotationRequest 45 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 46 + http.Error(w, "Invalid request body", http.StatusBadRequest) 47 + return 48 + } 49 + 50 + if req.URL == "" || req.Text == "" { 51 + http.Error(w, "URL and text are required", http.StatusBadRequest) 52 + return 53 + } 54 + 55 + if len(req.Text) > 3000 { 56 + http.Error(w, "Text too long (max 3000 chars)", http.StatusBadRequest) 57 + return 58 + } 59 + 60 + urlHash := db.HashURL(req.URL) 61 + 62 + motivation := "commenting" 63 + if req.Selector != nil && req.Text == "" { 64 + motivation = "highlighting" 65 + } else if len(req.Tags) > 0 { 66 + motivation = "tagging" 67 + } 68 + 69 + record := xrpc.NewAnnotationRecordWithMotivation(req.URL, urlHash, req.Text, req.Selector, req.Title, motivation) 70 + 71 + var result *xrpc.CreateRecordOutput 72 + err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 73 + var createErr error 74 + result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionAnnotation, record) 75 + return createErr 76 + }) 77 + if err != nil { 78 + http.Error(w, "Failed to create annotation: "+err.Error(), http.StatusInternalServerError) 79 + return 80 + } 81 + 82 + bodyValue := req.Text 83 + var bodyValuePtr, targetTitlePtr, selectorJSONPtr *string 84 + if bodyValue != "" { 85 + bodyValuePtr = &bodyValue 86 + } 87 + if req.Title != "" { 88 + targetTitlePtr = &req.Title 89 + } 90 + if req.Selector != nil { 91 + selectorBytes, _ := json.Marshal(req.Selector) 92 + selectorStr := string(selectorBytes) 93 + selectorJSONPtr = &selectorStr 94 + } 95 + 96 + cid := result.CID 97 + did := session.DID 98 + annotation := &db.Annotation{ 99 + URI: result.URI, 100 + CID: &cid, 101 + AuthorDID: did, 102 + Motivation: motivation, 103 + BodyValue: bodyValuePtr, 104 + TargetSource: req.URL, 105 + TargetHash: urlHash, 106 + TargetTitle: targetTitlePtr, 107 + SelectorJSON: selectorJSONPtr, 108 + CreatedAt: time.Now(), 109 + IndexedAt: time.Now(), 110 + } 111 + 112 + if err := s.db.CreateAnnotation(annotation); err != nil { 113 + log.Printf("Warning: failed to index annotation in local DB: %v", err) 114 + } 115 + 116 + w.Header().Set("Content-Type", "application/json") 117 + json.NewEncoder(w).Encode(CreateAnnotationResponse{ 118 + URI: result.URI, 119 + CID: result.CID, 120 + }) 121 + } 122 + 123 + func (s *AnnotationService) DeleteAnnotation(w http.ResponseWriter, r *http.Request) { 124 + session, err := s.refresher.GetSessionWithAutoRefresh(r) 125 + if err != nil { 126 + http.Error(w, err.Error(), http.StatusUnauthorized) 127 + return 128 + } 129 + 130 + rkey := r.URL.Query().Get("rkey") 131 + collectionType := r.URL.Query().Get("type") 132 + 133 + if rkey == "" { 134 + http.Error(w, "rkey required", http.StatusBadRequest) 135 + return 136 + } 137 + 138 + collection := xrpc.CollectionAnnotation 139 + if collectionType == "reply" { 140 + collection = xrpc.CollectionReply 141 + } 142 + 143 + err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 144 + return client.DeleteRecord(r.Context(), did, collection, rkey) 145 + }) 146 + if err != nil { 147 + http.Error(w, "Failed to delete record: "+err.Error(), http.StatusInternalServerError) 148 + return 149 + } 150 + 151 + did := session.DID 152 + if collectionType == "reply" { 153 + uri := "at://" + did + "/" + xrpc.CollectionReply + "/" + rkey 154 + s.db.DeleteReply(uri) 155 + } else { 156 + uri := "at://" + did + "/" + xrpc.CollectionAnnotation + "/" + rkey 157 + s.db.DeleteAnnotation(uri) 158 + } 159 + 160 + w.Header().Set("Content-Type", "application/json") 161 + json.NewEncoder(w).Encode(map[string]bool{"success": true}) 162 + } 163 + 164 + type UpdateAnnotationRequest struct { 165 + Text string `json:"text"` 166 + Tags []string `json:"tags"` 167 + } 168 + 169 + func (s *AnnotationService) UpdateAnnotation(w http.ResponseWriter, r *http.Request) { 170 + uri := r.URL.Query().Get("uri") 171 + if uri == "" { 172 + http.Error(w, "uri query parameter required", http.StatusBadRequest) 173 + return 174 + } 175 + 176 + session, err := s.refresher.GetSessionWithAutoRefresh(r) 177 + if err != nil { 178 + http.Error(w, err.Error(), http.StatusUnauthorized) 179 + return 180 + } 181 + 182 + annotation, err := s.db.GetAnnotationByURI(uri) 183 + if err != nil || annotation == nil { 184 + http.Error(w, "Annotation not found", http.StatusNotFound) 185 + return 186 + } 187 + 188 + if annotation.AuthorDID != session.DID { 189 + http.Error(w, "Not authorized to edit this annotation", http.StatusForbidden) 190 + return 191 + } 192 + 193 + var req UpdateAnnotationRequest 194 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 195 + http.Error(w, "Invalid request body", http.StatusBadRequest) 196 + return 197 + } 198 + 199 + parts := parseATURI(uri) 200 + if len(parts) < 3 { 201 + http.Error(w, "Invalid URI format", http.StatusBadRequest) 202 + return 203 + } 204 + rkey := parts[2] 205 + 206 + var selector interface{} = nil 207 + if annotation.SelectorJSON != nil && *annotation.SelectorJSON != "" { 208 + json.Unmarshal([]byte(*annotation.SelectorJSON), &selector) 209 + } 210 + 211 + tagsJSON := "" 212 + if len(req.Tags) > 0 { 213 + tagsBytes, _ := json.Marshal(req.Tags) 214 + tagsJSON = string(tagsBytes) 215 + } 216 + 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 + if annotation.BodyValue != nil { 234 + previousContent := *annotation.BodyValue 235 + s.db.SaveEditHistory(uri, "annotation", previousContent, annotation.CID) 236 + } 237 + 238 + var result *xrpc.PutRecordOutput 239 + err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 240 + var updateErr error 241 + result, updateErr = client.PutRecord(r.Context(), did, xrpc.CollectionAnnotation, rkey, record) 242 + if updateErr != nil { 243 + log.Printf("UpdateAnnotation failed: %v. Retrying with delete-then-create workaround.", updateErr) 244 + _ = client.DeleteRecord(r.Context(), did, xrpc.CollectionAnnotation, rkey) 245 + result, updateErr = client.PutRecord(r.Context(), did, xrpc.CollectionAnnotation, rkey, record) 246 + } 247 + return updateErr 248 + }) 249 + 250 + if err != nil { 251 + http.Error(w, "Failed to update record: "+err.Error(), http.StatusInternalServerError) 252 + return 253 + } 254 + 255 + s.db.UpdateAnnotation(uri, req.Text, tagsJSON, result.CID) 256 + 257 + w.Header().Set("Content-Type", "application/json") 258 + json.NewEncoder(w).Encode(map[string]interface{}{ 259 + "success": true, 260 + "uri": result.URI, 261 + "cid": result.CID, 262 + }) 263 + } 264 + 265 + func parseATURI(uri string) []string { 266 + 267 + if len(uri) < 5 || uri[:5] != "at://" { 268 + return nil 269 + } 270 + return strings.Split(uri[5:], "/") 271 + } 272 + 273 + type CreateLikeRequest struct { 274 + SubjectURI string `json:"subjectUri"` 275 + SubjectCID string `json:"subjectCid"` 276 + } 277 + 278 + func (s *AnnotationService) LikeAnnotation(w http.ResponseWriter, r *http.Request) { 279 + session, err := s.refresher.GetSessionWithAutoRefresh(r) 280 + if err != nil { 281 + http.Error(w, err.Error(), http.StatusUnauthorized) 282 + return 283 + } 284 + 285 + var req CreateLikeRequest 286 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 287 + http.Error(w, "Invalid request body", http.StatusBadRequest) 288 + return 289 + } 290 + 291 + existingLike, _ := s.db.GetLikeByUserAndSubject(session.DID, req.SubjectURI) 292 + if existingLike != nil { 293 + w.Header().Set("Content-Type", "application/json") 294 + json.NewEncoder(w).Encode(map[string]string{"uri": existingLike.URI, "existing": "true"}) 295 + return 296 + } 297 + 298 + record := xrpc.NewLikeRecord(req.SubjectURI, req.SubjectCID) 299 + 300 + var result *xrpc.CreateRecordOutput 301 + err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 302 + var createErr error 303 + result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionLike, record) 304 + return createErr 305 + }) 306 + if err != nil { 307 + http.Error(w, "Failed to create like: "+err.Error(), http.StatusInternalServerError) 308 + return 309 + } 310 + 311 + did := session.DID 312 + like := &db.Like{ 313 + URI: result.URI, 314 + AuthorDID: did, 315 + SubjectURI: req.SubjectURI, 316 + CreatedAt: time.Now(), 317 + IndexedAt: time.Now(), 318 + } 319 + s.db.CreateLike(like) 320 + 321 + if authorDID, err := s.db.GetAuthorByURI(req.SubjectURI); err == nil && authorDID != did { 322 + s.db.CreateNotification(&db.Notification{ 323 + RecipientDID: authorDID, 324 + ActorDID: did, 325 + Type: "like", 326 + SubjectURI: req.SubjectURI, 327 + CreatedAt: time.Now(), 328 + }) 329 + } 330 + 331 + w.Header().Set("Content-Type", "application/json") 332 + json.NewEncoder(w).Encode(map[string]string{"uri": result.URI}) 333 + } 334 + 335 + func (s *AnnotationService) UnlikeAnnotation(w http.ResponseWriter, r *http.Request) { 336 + session, err := s.refresher.GetSessionWithAutoRefresh(r) 337 + if err != nil { 338 + http.Error(w, err.Error(), http.StatusUnauthorized) 339 + return 340 + } 341 + 342 + subjectURI := r.URL.Query().Get("uri") 343 + if subjectURI == "" { 344 + http.Error(w, "uri query parameter required", http.StatusBadRequest) 345 + return 346 + } 347 + 348 + userLike, err := s.db.GetLikeByUserAndSubject(session.DID, subjectURI) 349 + if err != nil { 350 + http.Error(w, "Like not found", http.StatusNotFound) 351 + return 352 + } 353 + 354 + parts := strings.Split(userLike.URI, "/") 355 + rkey := parts[len(parts)-1] 356 + 357 + err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 358 + return client.DeleteRecord(r.Context(), did, xrpc.CollectionLike, rkey) 359 + }) 360 + if err != nil { 361 + http.Error(w, "Failed to delete like: "+err.Error(), http.StatusInternalServerError) 362 + return 363 + } 364 + 365 + s.db.DeleteLike(userLike.URI) 366 + 367 + w.Header().Set("Content-Type", "application/json") 368 + json.NewEncoder(w).Encode(map[string]bool{"success": true}) 369 + } 370 + 371 + type CreateReplyRequest struct { 372 + ParentURI string `json:"parentUri"` 373 + ParentCID string `json:"parentCid"` 374 + RootURI string `json:"rootUri"` 375 + RootCID string `json:"rootCid"` 376 + Text string `json:"text"` 377 + } 378 + 379 + func (s *AnnotationService) CreateReply(w http.ResponseWriter, r *http.Request) { 380 + session, err := s.refresher.GetSessionWithAutoRefresh(r) 381 + if err != nil { 382 + http.Error(w, err.Error(), http.StatusUnauthorized) 383 + return 384 + } 385 + 386 + var req CreateReplyRequest 387 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 388 + http.Error(w, "Invalid request body", http.StatusBadRequest) 389 + return 390 + } 391 + 392 + record := xrpc.NewReplyRecord(req.ParentURI, req.ParentCID, req.RootURI, req.RootCID, req.Text) 393 + 394 + var result *xrpc.CreateRecordOutput 395 + err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 396 + var createErr error 397 + result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionReply, record) 398 + return createErr 399 + }) 400 + if err != nil { 401 + http.Error(w, "Failed to create reply: "+err.Error(), http.StatusInternalServerError) 402 + return 403 + } 404 + 405 + reply := &db.Reply{ 406 + URI: result.URI, 407 + AuthorDID: session.DID, 408 + ParentURI: req.ParentURI, 409 + RootURI: req.RootURI, 410 + Text: req.Text, 411 + CreatedAt: time.Now(), 412 + IndexedAt: time.Now(), 413 + CID: &result.CID, 414 + } 415 + s.db.CreateReply(reply) 416 + 417 + if authorDID, err := s.db.GetAuthorByURI(req.ParentURI); err == nil && authorDID != session.DID { 418 + s.db.CreateNotification(&db.Notification{ 419 + RecipientDID: authorDID, 420 + ActorDID: session.DID, 421 + Type: "reply", 422 + SubjectURI: result.URI, 423 + CreatedAt: time.Now(), 424 + }) 425 + } 426 + 427 + w.Header().Set("Content-Type", "application/json") 428 + json.NewEncoder(w).Encode(map[string]string{"uri": result.URI}) 429 + } 430 + 431 + func (s *AnnotationService) DeleteReply(w http.ResponseWriter, r *http.Request) { 432 + uri := r.URL.Query().Get("uri") 433 + if uri == "" { 434 + http.Error(w, "uri query parameter required", http.StatusBadRequest) 435 + return 436 + } 437 + 438 + session, err := s.refresher.GetSessionWithAutoRefresh(r) 439 + if err != nil { 440 + http.Error(w, err.Error(), http.StatusUnauthorized) 441 + return 442 + } 443 + 444 + reply, err := s.db.GetReplyByURI(uri) 445 + if err != nil || reply == nil { 446 + http.Error(w, "reply not found", http.StatusNotFound) 447 + return 448 + } 449 + 450 + if reply.AuthorDID != session.DID { 451 + http.Error(w, "not authorized to delete this reply", http.StatusForbidden) 452 + return 453 + } 454 + 455 + parts := strings.Split(uri, "/") 456 + if len(parts) >= 2 { 457 + rkey := parts[len(parts)-1] 458 + _ = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 459 + return client.DeleteRecord(r.Context(), did, "at.margin.reply", rkey) 460 + }) 461 + } 462 + 463 + s.db.DeleteReply(uri) 464 + 465 + w.Header().Set("Content-Type", "application/json") 466 + json.NewEncoder(w).Encode(map[string]bool{"success": true}) 467 + } 468 + 469 + func resolveDIDToPDS(did string) (string, error) { 470 + if strings.HasPrefix(did, "did:plc:") { 471 + resp, err := http.Get("https://plc.directory/" + did) 472 + if err != nil { 473 + return "", err 474 + } 475 + defer resp.Body.Close() 476 + 477 + var doc struct { 478 + Service []struct { 479 + Type string `json:"type"` 480 + ServiceEndpoint string `json:"serviceEndpoint"` 481 + } `json:"service"` 482 + } 483 + if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil { 484 + return "", err 485 + } 486 + 487 + for _, svc := range doc.Service { 488 + if svc.Type == "AtprotoPersonalDataServer" { 489 + return svc.ServiceEndpoint, nil 490 + } 491 + } 492 + } 493 + return "", nil 494 + } 495 + 496 + type CreateHighlightRequest struct { 497 + URL string `json:"url"` 498 + Title string `json:"title,omitempty"` 499 + Selector interface{} `json:"selector"` 500 + Color string `json:"color,omitempty"` 501 + } 502 + 503 + func (s *AnnotationService) CreateHighlight(w http.ResponseWriter, r *http.Request) { 504 + session, err := s.refresher.GetSessionWithAutoRefresh(r) 505 + if err != nil { 506 + http.Error(w, err.Error(), http.StatusUnauthorized) 507 + return 508 + } 509 + 510 + var req CreateHighlightRequest 511 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 512 + http.Error(w, "Invalid request body", http.StatusBadRequest) 513 + return 514 + } 515 + 516 + if req.URL == "" || req.Selector == nil { 517 + http.Error(w, "URL and selector are required", http.StatusBadRequest) 518 + return 519 + } 520 + 521 + urlHash := db.HashURL(req.URL) 522 + record := xrpc.NewHighlightRecord(req.URL, urlHash, req.Selector, req.Color) 523 + 524 + var result *xrpc.CreateRecordOutput 525 + err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 526 + var createErr error 527 + result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionHighlight, record) 528 + return createErr 529 + }) 530 + if err != nil { 531 + http.Error(w, "Failed to create highlight: "+err.Error(), http.StatusInternalServerError) 532 + return 533 + } 534 + 535 + var selectorJSONPtr *string 536 + if req.Selector != nil { 537 + selectorBytes, _ := json.Marshal(req.Selector) 538 + selectorStr := string(selectorBytes) 539 + selectorJSONPtr = &selectorStr 540 + } 541 + 542 + var titlePtr *string 543 + if req.Title != "" { 544 + titlePtr = &req.Title 545 + } 546 + 547 + var colorPtr *string 548 + if req.Color != "" { 549 + colorPtr = &req.Color 550 + } 551 + 552 + cid := result.CID 553 + highlight := &db.Highlight{ 554 + URI: result.URI, 555 + AuthorDID: session.DID, 556 + TargetSource: req.URL, 557 + TargetHash: urlHash, 558 + TargetTitle: titlePtr, 559 + SelectorJSON: selectorJSONPtr, 560 + Color: colorPtr, 561 + CreatedAt: time.Now(), 562 + IndexedAt: time.Now(), 563 + CID: &cid, 564 + } 565 + if err := s.db.CreateHighlight(highlight); err != nil { 566 + http.Error(w, "Failed to index highlight", http.StatusInternalServerError) 567 + return 568 + } 569 + 570 + w.Header().Set("Content-Type", "application/json") 571 + json.NewEncoder(w).Encode(map[string]string{"uri": result.URI, "cid": result.CID}) 572 + } 573 + 574 + type CreateBookmarkRequest struct { 575 + URL string `json:"url"` 576 + Title string `json:"title,omitempty"` 577 + Description string `json:"description,omitempty"` 578 + } 579 + 580 + func (s *AnnotationService) CreateBookmark(w http.ResponseWriter, r *http.Request) { 581 + session, err := s.refresher.GetSessionWithAutoRefresh(r) 582 + if err != nil { 583 + http.Error(w, err.Error(), http.StatusUnauthorized) 584 + return 585 + } 586 + 587 + var req CreateBookmarkRequest 588 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 589 + http.Error(w, "Invalid request body", http.StatusBadRequest) 590 + return 591 + } 592 + 593 + if req.URL == "" { 594 + http.Error(w, "URL is required", http.StatusBadRequest) 595 + return 596 + } 597 + 598 + urlHash := db.HashURL(req.URL) 599 + record := xrpc.NewBookmarkRecord(req.URL, urlHash, req.Title, req.Description) 600 + 601 + var result *xrpc.CreateRecordOutput 602 + err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 603 + var createErr error 604 + result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionBookmark, record) 605 + return createErr 606 + }) 607 + if err != nil { 608 + http.Error(w, "Failed to create bookmark: "+err.Error(), http.StatusInternalServerError) 609 + return 610 + } 611 + 612 + var titlePtr *string 613 + if req.Title != "" { 614 + titlePtr = &req.Title 615 + } 616 + var descPtr *string 617 + if req.Description != "" { 618 + descPtr = &req.Description 619 + } 620 + 621 + cid := result.CID 622 + bookmark := &db.Bookmark{ 623 + URI: result.URI, 624 + AuthorDID: session.DID, 625 + Source: req.URL, 626 + SourceHash: urlHash, 627 + Title: titlePtr, 628 + Description: descPtr, 629 + CreatedAt: time.Now(), 630 + IndexedAt: time.Now(), 631 + CID: &cid, 632 + } 633 + s.db.CreateBookmark(bookmark) 634 + 635 + w.Header().Set("Content-Type", "application/json") 636 + json.NewEncoder(w).Encode(map[string]string{"uri": result.URI, "cid": result.CID}) 637 + } 638 + 639 + func (s *AnnotationService) DeleteHighlight(w http.ResponseWriter, r *http.Request) { 640 + session, err := s.refresher.GetSessionWithAutoRefresh(r) 641 + if err != nil { 642 + http.Error(w, err.Error(), http.StatusUnauthorized) 643 + return 644 + } 645 + 646 + rkey := r.URL.Query().Get("rkey") 647 + if rkey == "" { 648 + http.Error(w, "rkey required", http.StatusBadRequest) 649 + return 650 + } 651 + 652 + err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 653 + return client.DeleteRecord(r.Context(), did, xrpc.CollectionHighlight, rkey) 654 + }) 655 + if err != nil { 656 + http.Error(w, "Failed to delete highlight: "+err.Error(), http.StatusInternalServerError) 657 + return 658 + } 659 + 660 + uri := "at://" + session.DID + "/" + xrpc.CollectionHighlight + "/" + rkey 661 + s.db.DeleteHighlight(uri) 662 + 663 + w.Header().Set("Content-Type", "application/json") 664 + json.NewEncoder(w).Encode(map[string]bool{"success": true}) 665 + } 666 + 667 + func (s *AnnotationService) DeleteBookmark(w http.ResponseWriter, r *http.Request) { 668 + session, err := s.refresher.GetSessionWithAutoRefresh(r) 669 + if err != nil { 670 + http.Error(w, err.Error(), http.StatusUnauthorized) 671 + return 672 + } 673 + 674 + rkey := r.URL.Query().Get("rkey") 675 + if rkey == "" { 676 + http.Error(w, "rkey required", http.StatusBadRequest) 677 + return 678 + } 679 + 680 + err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 681 + return client.DeleteRecord(r.Context(), did, xrpc.CollectionBookmark, rkey) 682 + }) 683 + if err != nil { 684 + http.Error(w, "Failed to delete bookmark: "+err.Error(), http.StatusInternalServerError) 685 + return 686 + } 687 + 688 + uri := "at://" + session.DID + "/" + xrpc.CollectionBookmark + "/" + rkey 689 + s.db.DeleteBookmark(uri) 690 + 691 + w.Header().Set("Content-Type", "application/json") 692 + json.NewEncoder(w).Encode(map[string]bool{"success": true}) 693 + } 694 + 695 + type UpdateHighlightRequest struct { 696 + Color string `json:"color"` 697 + Tags []string `json:"tags,omitempty"` 698 + } 699 + 700 + func (s *AnnotationService) UpdateHighlight(w http.ResponseWriter, r *http.Request) { 701 + uri := r.URL.Query().Get("uri") 702 + if uri == "" { 703 + http.Error(w, "uri query parameter required", http.StatusBadRequest) 704 + return 705 + } 706 + 707 + session, err := s.refresher.GetSessionWithAutoRefresh(r) 708 + if err != nil { 709 + http.Error(w, err.Error(), http.StatusUnauthorized) 710 + return 711 + } 712 + 713 + if len(uri) < 5 || !strings.HasPrefix(uri[5:], session.DID) { 714 + http.Error(w, "Not authorized", http.StatusForbidden) 715 + return 716 + } 717 + 718 + var req UpdateHighlightRequest 719 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 720 + http.Error(w, "Invalid request body", http.StatusBadRequest) 721 + return 722 + } 723 + 724 + parts := parseATURI(uri) 725 + if len(parts) < 3 { 726 + http.Error(w, "Invalid URI", http.StatusBadRequest) 727 + return 728 + } 729 + rkey := parts[2] 730 + 731 + var result *xrpc.PutRecordOutput 732 + err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 733 + existing, getErr := client.GetRecord(r.Context(), did, xrpc.CollectionHighlight, rkey) 734 + if getErr != nil { 735 + return fmt.Errorf("failed to fetch record: %w", getErr) 736 + } 737 + 738 + var record map[string]interface{} 739 + json.Unmarshal(existing.Value, &record) 740 + 741 + if req.Color != "" { 742 + record["color"] = req.Color 743 + } 744 + if req.Tags != nil { 745 + record["tags"] = req.Tags 746 + } 747 + 748 + var updateErr error 749 + result, updateErr = client.PutRecord(r.Context(), did, xrpc.CollectionHighlight, rkey, record) 750 + if updateErr != nil { 751 + log.Printf("UpdateHighlight failed: %v. Retrying with delete-then-create workaround.", updateErr) 752 + _ = client.DeleteRecord(r.Context(), did, xrpc.CollectionHighlight, rkey) 753 + result, updateErr = client.PutRecord(r.Context(), did, xrpc.CollectionHighlight, rkey, record) 754 + } 755 + return updateErr 756 + }) 757 + 758 + if err != nil { 759 + http.Error(w, "Failed to update: "+err.Error(), http.StatusInternalServerError) 760 + return 761 + } 762 + 763 + tagsJSON := "" 764 + if req.Tags != nil { 765 + b, _ := json.Marshal(req.Tags) 766 + tagsJSON = string(b) 767 + } 768 + s.db.UpdateHighlight(uri, req.Color, tagsJSON, result.CID) 769 + 770 + w.Header().Set("Content-Type", "application/json") 771 + json.NewEncoder(w).Encode(map[string]interface{}{"success": true, "uri": result.URI, "cid": result.CID}) 772 + } 773 + 774 + type UpdateBookmarkRequest struct { 775 + Title string `json:"title"` 776 + Description string `json:"description"` 777 + Tags []string `json:"tags,omitempty"` 778 + } 779 + 780 + func (s *AnnotationService) UpdateBookmark(w http.ResponseWriter, r *http.Request) { 781 + uri := r.URL.Query().Get("uri") 782 + if uri == "" { 783 + http.Error(w, "uri query parameter required", http.StatusBadRequest) 784 + return 785 + } 786 + 787 + session, err := s.refresher.GetSessionWithAutoRefresh(r) 788 + if err != nil { 789 + http.Error(w, err.Error(), http.StatusUnauthorized) 790 + return 791 + } 792 + 793 + if len(uri) < 5 || !strings.HasPrefix(uri[5:], session.DID) { 794 + http.Error(w, "Not authorized", http.StatusForbidden) 795 + return 796 + } 797 + 798 + var req UpdateBookmarkRequest 799 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 800 + http.Error(w, "Invalid request body", http.StatusBadRequest) 801 + return 802 + } 803 + 804 + parts := parseATURI(uri) 805 + if len(parts) < 3 { 806 + http.Error(w, "Invalid URI", http.StatusBadRequest) 807 + return 808 + } 809 + rkey := parts[2] 810 + 811 + var result *xrpc.PutRecordOutput 812 + err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 813 + existing, getErr := client.GetRecord(r.Context(), did, xrpc.CollectionBookmark, rkey) 814 + if getErr != nil { 815 + return fmt.Errorf("failed to fetch record: %w", getErr) 816 + } 817 + 818 + var record map[string]interface{} 819 + json.Unmarshal(existing.Value, &record) 820 + 821 + if req.Title != "" { 822 + record["title"] = req.Title 823 + } 824 + if req.Description != "" { 825 + record["description"] = req.Description 826 + } 827 + if req.Tags != nil { 828 + record["tags"] = req.Tags 829 + } 830 + 831 + var updateErr error 832 + result, updateErr = client.PutRecord(r.Context(), did, xrpc.CollectionBookmark, rkey, record) 833 + if updateErr != nil { 834 + log.Printf("UpdateBookmark failed: %v. Retrying with delete-then-create workaround.", updateErr) 835 + _ = client.DeleteRecord(r.Context(), did, xrpc.CollectionBookmark, rkey) 836 + result, updateErr = client.PutRecord(r.Context(), did, xrpc.CollectionBookmark, rkey, record) 837 + } 838 + return updateErr 839 + }) 840 + 841 + if err != nil { 842 + http.Error(w, "Failed to update: "+err.Error(), http.StatusInternalServerError) 843 + return 844 + } 845 + 846 + tagsJSON := "" 847 + if req.Tags != nil { 848 + b, _ := json.Marshal(req.Tags) 849 + tagsJSON = string(b) 850 + } 851 + s.db.UpdateBookmark(uri, req.Title, req.Description, tagsJSON, result.CID) 852 + 853 + w.Header().Set("Content-Type", "application/json") 854 + json.NewEncoder(w).Encode(map[string]interface{}{"success": true, "uri": result.URI, "cid": result.CID}) 855 + }
backend/internal/api/assets/logo.png

This is a binary file and will not be displayed.

+422
backend/internal/api/collections.go
··· 1 + package api 2 + 3 + import ( 4 + "encoding/json" 5 + "log" 6 + "net/http" 7 + "net/url" 8 + "strings" 9 + "time" 10 + 11 + "github.com/go-chi/chi/v5" 12 + 13 + "margin.at/internal/db" 14 + "margin.at/internal/xrpc" 15 + ) 16 + 17 + type CollectionService struct { 18 + db *db.DB 19 + refresher *TokenRefresher 20 + } 21 + 22 + func NewCollectionService(database *db.DB, refresher *TokenRefresher) *CollectionService { 23 + return &CollectionService{db: database, refresher: refresher} 24 + } 25 + 26 + type CreateCollectionRequest struct { 27 + Name string `json:"name"` 28 + Description string `json:"description"` 29 + Icon string `json:"icon"` 30 + } 31 + 32 + type AddCollectionItemRequest struct { 33 + AnnotationURI string `json:"annotationUri"` 34 + Position int `json:"position"` 35 + } 36 + 37 + func (s *CollectionService) CreateCollection(w http.ResponseWriter, r *http.Request) { 38 + session, err := s.refresher.GetSessionWithAutoRefresh(r) 39 + if err != nil { 40 + http.Error(w, err.Error(), http.StatusUnauthorized) 41 + return 42 + } 43 + 44 + var req CreateCollectionRequest 45 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 46 + http.Error(w, "Invalid request body", http.StatusBadRequest) 47 + return 48 + } 49 + 50 + if req.Name == "" { 51 + http.Error(w, "Name is required", http.StatusBadRequest) 52 + return 53 + } 54 + 55 + record := xrpc.NewCollectionRecord(req.Name, req.Description, req.Icon) 56 + 57 + var result *xrpc.CreateRecordOutput 58 + err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 59 + var createErr error 60 + result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionCollection, record) 61 + return createErr 62 + }) 63 + if err != nil { 64 + http.Error(w, "Failed to create collection: "+err.Error(), http.StatusInternalServerError) 65 + return 66 + } 67 + 68 + did := session.DID 69 + var descPtr, iconPtr *string 70 + if req.Description != "" { 71 + descPtr = &req.Description 72 + } 73 + if req.Icon != "" { 74 + iconPtr = &req.Icon 75 + } 76 + collection := &db.Collection{ 77 + URI: result.URI, 78 + AuthorDID: did, 79 + Name: req.Name, 80 + Description: descPtr, 81 + Icon: iconPtr, 82 + CreatedAt: time.Now(), 83 + IndexedAt: time.Now(), 84 + } 85 + s.db.CreateCollection(collection) 86 + 87 + w.Header().Set("Content-Type", "application/json") 88 + json.NewEncoder(w).Encode(result) 89 + } 90 + 91 + func (s *CollectionService) AddCollectionItem(w http.ResponseWriter, r *http.Request) { 92 + collectionURIRaw := chi.URLParam(r, "collection") 93 + if collectionURIRaw == "" { 94 + http.Error(w, "Collection URI required", http.StatusBadRequest) 95 + return 96 + } 97 + 98 + collectionURI, _ := url.QueryUnescape(collectionURIRaw) 99 + 100 + session, err := s.refresher.GetSessionWithAutoRefresh(r) 101 + if err != nil { 102 + http.Error(w, err.Error(), http.StatusUnauthorized) 103 + return 104 + } 105 + 106 + var req AddCollectionItemRequest 107 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 108 + http.Error(w, "Invalid request body", http.StatusBadRequest) 109 + return 110 + } 111 + 112 + if req.AnnotationURI == "" { 113 + http.Error(w, "Annotation URI required", http.StatusBadRequest) 114 + return 115 + } 116 + 117 + record := xrpc.NewCollectionItemRecord(collectionURI, req.AnnotationURI, req.Position) 118 + 119 + var result *xrpc.CreateRecordOutput 120 + err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 121 + var createErr error 122 + result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionCollectionItem, record) 123 + return createErr 124 + }) 125 + if err != nil { 126 + http.Error(w, "Failed to add item: "+err.Error(), http.StatusInternalServerError) 127 + return 128 + } 129 + 130 + did := session.DID 131 + item := &db.CollectionItem{ 132 + URI: result.URI, 133 + AuthorDID: did, 134 + CollectionURI: collectionURI, 135 + AnnotationURI: req.AnnotationURI, 136 + Position: req.Position, 137 + CreatedAt: time.Now(), 138 + IndexedAt: time.Now(), 139 + } 140 + if err := s.db.AddToCollection(item); err != nil { 141 + log.Printf("Failed to add to collection in DB: %v", err) 142 + } 143 + 144 + w.Header().Set("Content-Type", "application/json") 145 + json.NewEncoder(w).Encode(result) 146 + } 147 + 148 + func (s *CollectionService) RemoveCollectionItem(w http.ResponseWriter, r *http.Request) { 149 + itemURI := r.URL.Query().Get("uri") 150 + if itemURI == "" { 151 + http.Error(w, "Item URI required", http.StatusBadRequest) 152 + return 153 + } 154 + 155 + session, err := s.refresher.GetSessionWithAutoRefresh(r) 156 + if err != nil { 157 + http.Error(w, err.Error(), http.StatusUnauthorized) 158 + return 159 + } 160 + 161 + err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 162 + return client.DeleteRecordByURI(r.Context(), itemURI) 163 + }) 164 + if err != nil { 165 + log.Printf("Warning: PDS delete failed for %s: %v", itemURI, err) 166 + } 167 + 168 + s.db.RemoveFromCollection(itemURI) 169 + 170 + w.Header().Set("Content-Type", "application/json") 171 + w.WriteHeader(http.StatusOK) 172 + json.NewEncoder(w).Encode(map[string]string{"status": "deleted"}) 173 + } 174 + 175 + func (s *CollectionService) GetAnnotationCollections(w http.ResponseWriter, r *http.Request) { 176 + annotationURI := r.URL.Query().Get("uri") 177 + if annotationURI == "" { 178 + http.Error(w, "uri parameter required", http.StatusBadRequest) 179 + return 180 + } 181 + 182 + uris, err := s.db.GetCollectionURIsForAnnotation(annotationURI) 183 + if err != nil { 184 + http.Error(w, err.Error(), http.StatusInternalServerError) 185 + return 186 + } 187 + 188 + if uris == nil { 189 + uris = []string{} 190 + } 191 + 192 + w.Header().Set("Content-Type", "application/json") 193 + json.NewEncoder(w).Encode(uris) 194 + } 195 + 196 + func (s *CollectionService) GetCollections(w http.ResponseWriter, r *http.Request) { 197 + authorDID := r.URL.Query().Get("author") 198 + if authorDID == "" { 199 + session, err := s.refresher.GetSessionWithAutoRefresh(r) 200 + if err == nil { 201 + authorDID = session.DID 202 + } 203 + } 204 + 205 + if authorDID == "" { 206 + http.Error(w, "Author DID required", http.StatusBadRequest) 207 + return 208 + } 209 + 210 + collections, err := s.db.GetCollectionsByAuthor(authorDID) 211 + if err != nil { 212 + http.Error(w, err.Error(), http.StatusInternalServerError) 213 + return 214 + } 215 + 216 + w.Header().Set("Content-Type", "application/json") 217 + json.NewEncoder(w).Encode(map[string]interface{}{ 218 + "@context": "http://www.w3.org/ns/anno.jsonld", 219 + "type": "Collection", 220 + "items": collections, 221 + "totalItems": len(collections), 222 + }) 223 + } 224 + 225 + type EnrichedCollectionItem struct { 226 + URI string `json:"uri"` 227 + CollectionURI string `json:"collectionUri"` 228 + AnnotationURI string `json:"annotationUri"` 229 + Position int `json:"position"` 230 + CreatedAt time.Time `json:"createdAt"` 231 + Type string `json:"type"` 232 + Annotation *APIAnnotation `json:"annotation,omitempty"` 233 + Highlight *APIHighlight `json:"highlight,omitempty"` 234 + Bookmark *APIBookmark `json:"bookmark,omitempty"` 235 + } 236 + 237 + func (s *CollectionService) GetCollectionItems(w http.ResponseWriter, r *http.Request) { 238 + collectionURI := r.URL.Query().Get("collection") 239 + if collectionURI == "" { 240 + collectionURIRaw := chi.URLParam(r, "collection") 241 + collectionURI, _ = url.QueryUnescape(collectionURIRaw) 242 + } 243 + 244 + if collectionURI == "" { 245 + http.Error(w, "Collection URI required", http.StatusBadRequest) 246 + return 247 + } 248 + 249 + items, err := s.db.GetCollectionItems(collectionURI) 250 + if err != nil { 251 + http.Error(w, err.Error(), http.StatusInternalServerError) 252 + return 253 + } 254 + 255 + enrichedItems := make([]EnrichedCollectionItem, 0, len(items)) 256 + 257 + for _, item := range items { 258 + enriched := EnrichedCollectionItem{ 259 + URI: item.URI, 260 + CollectionURI: item.CollectionURI, 261 + AnnotationURI: item.AnnotationURI, 262 + Position: item.Position, 263 + CreatedAt: item.CreatedAt, 264 + } 265 + 266 + if strings.Contains(item.AnnotationURI, "at.margin.annotation") { 267 + enriched.Type = "annotation" 268 + if a, err := s.db.GetAnnotationByURI(item.AnnotationURI); err == nil { 269 + hydrated, _ := hydrateAnnotations([]db.Annotation{*a}) 270 + if len(hydrated) > 0 { 271 + enriched.Annotation = &hydrated[0] 272 + } 273 + } 274 + } else if strings.Contains(item.AnnotationURI, "at.margin.highlight") { 275 + enriched.Type = "highlight" 276 + if h, err := s.db.GetHighlightByURI(item.AnnotationURI); err == nil { 277 + hydrated, _ := hydrateHighlights([]db.Highlight{*h}) 278 + if len(hydrated) > 0 { 279 + enriched.Highlight = &hydrated[0] 280 + } 281 + } 282 + } else if strings.Contains(item.AnnotationURI, "at.margin.bookmark") { 283 + enriched.Type = "bookmark" 284 + if b, err := s.db.GetBookmarkByURI(item.AnnotationURI); err == nil { 285 + hydrated, _ := hydrateBookmarks([]db.Bookmark{*b}) 286 + if len(hydrated) > 0 { 287 + enriched.Bookmark = &hydrated[0] 288 + } 289 + } else { 290 + log.Printf("GetBookmarkByURI failed for %s: %v\n", item.AnnotationURI, err) 291 + } 292 + } else { 293 + log.Printf("Unknown annotation type for URI: %s\n", item.AnnotationURI) 294 + } 295 + 296 + if enriched.Annotation != nil || enriched.Highlight != nil || enriched.Bookmark != nil { 297 + enrichedItems = append(enrichedItems, enriched) 298 + } 299 + } 300 + 301 + w.Header().Set("Content-Type", "application/json") 302 + json.NewEncoder(w).Encode(enrichedItems) 303 + } 304 + 305 + type UpdateCollectionRequest struct { 306 + Name string `json:"name"` 307 + Description string `json:"description"` 308 + Icon string `json:"icon"` 309 + } 310 + 311 + func (s *CollectionService) UpdateCollection(w http.ResponseWriter, r *http.Request) { 312 + uri := r.URL.Query().Get("uri") 313 + if uri == "" { 314 + http.Error(w, "URI required", http.StatusBadRequest) 315 + return 316 + } 317 + 318 + session, err := s.refresher.GetSessionWithAutoRefresh(r) 319 + if err != nil { 320 + http.Error(w, err.Error(), http.StatusUnauthorized) 321 + return 322 + } 323 + 324 + if len(uri) < len(session.DID)+5 || uri[5:5+len(session.DID)] != session.DID { 325 + http.Error(w, "Not authorized to update this collection", http.StatusForbidden) 326 + return 327 + } 328 + 329 + var req UpdateCollectionRequest 330 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 331 + http.Error(w, "Invalid request body", http.StatusBadRequest) 332 + return 333 + } 334 + 335 + if req.Name == "" { 336 + http.Error(w, "Name is required", http.StatusBadRequest) 337 + return 338 + } 339 + 340 + record := xrpc.NewCollectionRecord(req.Name, req.Description, req.Icon) 341 + parts := strings.Split(uri, "/") 342 + rkey := parts[len(parts)-1] 343 + 344 + var result *xrpc.PutRecordOutput 345 + err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 346 + var updateErr error 347 + result, updateErr = client.PutRecord(r.Context(), did, xrpc.CollectionCollection, rkey, record) 348 + if updateErr != nil { 349 + log.Printf("DEBUG PutRecord failed: %v. Retrying with delete-then-create workaround for buggy PDS.", updateErr) 350 + _ = client.DeleteRecord(r.Context(), did, xrpc.CollectionCollection, rkey) 351 + result, updateErr = client.PutRecord(r.Context(), did, xrpc.CollectionCollection, rkey, record) 352 + } 353 + return updateErr 354 + }) 355 + 356 + if err != nil { 357 + http.Error(w, "Failed to update collection: "+err.Error(), http.StatusInternalServerError) 358 + return 359 + } 360 + 361 + var descPtr, iconPtr *string 362 + if req.Description != "" { 363 + descPtr = &req.Description 364 + } 365 + if req.Icon != "" { 366 + iconPtr = &req.Icon 367 + } 368 + 369 + collection := &db.Collection{ 370 + URI: result.URI, 371 + AuthorDID: session.DID, 372 + Name: req.Name, 373 + Description: descPtr, 374 + Icon: iconPtr, 375 + CreatedAt: time.Now(), 376 + IndexedAt: time.Now(), 377 + } 378 + s.db.CreateCollection(collection) 379 + 380 + w.Header().Set("Content-Type", "application/json") 381 + json.NewEncoder(w).Encode(result) 382 + } 383 + 384 + func (s *CollectionService) DeleteCollection(w http.ResponseWriter, r *http.Request) { 385 + uri := r.URL.Query().Get("uri") 386 + if uri == "" { 387 + http.Error(w, "URI required", http.StatusBadRequest) 388 + return 389 + } 390 + 391 + session, err := s.refresher.GetSessionWithAutoRefresh(r) 392 + if err != nil { 393 + http.Error(w, err.Error(), http.StatusUnauthorized) 394 + return 395 + } 396 + 397 + if len(uri) < len(session.DID)+5 || uri[5:5+len(session.DID)] != session.DID { 398 + http.Error(w, "Not authorized to delete this collection", http.StatusForbidden) 399 + return 400 + } 401 + 402 + items, _ := s.db.GetCollectionItems(uri) 403 + 404 + err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 405 + for _, item := range items { 406 + client.DeleteRecordByURI(r.Context(), item.URI) 407 + } 408 + 409 + parts := strings.Split(uri, "/") 410 + rkey := parts[len(parts)-1] 411 + return client.DeleteRecord(r.Context(), did, xrpc.CollectionCollection, rkey) 412 + }) 413 + if err != nil { 414 + http.Error(w, "Failed to delete collection: "+err.Error(), http.StatusInternalServerError) 415 + return 416 + } 417 + 418 + s.db.DeleteCollection(uri) 419 + 420 + w.WriteHeader(http.StatusOK) 421 + json.NewEncoder(w).Encode(map[string]string{"status": "deleted"}) 422 + }
backend/internal/api/fonts/Inter-Bold.ttf

This is a binary file and will not be displayed.

backend/internal/api/fonts/Inter-Regular.ttf

This is a binary file and will not be displayed.

+562
backend/internal/api/handler.go
··· 1 + package api 2 + 3 + import ( 4 + "encoding/json" 5 + "io" 6 + "log" 7 + "net/http" 8 + "net/url" 9 + "strconv" 10 + "strings" 11 + "time" 12 + 13 + "github.com/go-chi/chi/v5" 14 + 15 + "margin.at/internal/db" 16 + ) 17 + 18 + type Handler struct { 19 + db *db.DB 20 + annotationService *AnnotationService 21 + refresher *TokenRefresher 22 + } 23 + 24 + func NewHandler(database *db.DB, annotationService *AnnotationService, refresher *TokenRefresher) *Handler { 25 + return &Handler{db: database, annotationService: annotationService, refresher: refresher} 26 + } 27 + 28 + func (h *Handler) RegisterRoutes(r chi.Router) { 29 + r.Get("/health", h.Health) 30 + 31 + r.Route("/api", func(r chi.Router) { 32 + r.Get("/annotations", h.GetAnnotations) 33 + r.Get("/annotations/feed", h.GetFeed) 34 + r.Get("/annotation", h.GetAnnotation) 35 + r.Get("/annotations/history", h.GetEditHistory) 36 + r.Put("/annotations", h.annotationService.UpdateAnnotation) 37 + 38 + r.Get("/highlights", h.GetHighlights) 39 + r.Put("/highlights", h.annotationService.UpdateHighlight) 40 + 41 + r.Get("/bookmarks", h.GetBookmarks) 42 + r.Post("/bookmarks", h.annotationService.CreateBookmark) 43 + r.Put("/bookmarks", h.annotationService.UpdateBookmark) 44 + 45 + collectionService := NewCollectionService(h.db, h.refresher) 46 + r.Post("/collections", collectionService.CreateCollection) 47 + r.Get("/collections", collectionService.GetCollections) 48 + r.Put("/collections", collectionService.UpdateCollection) 49 + r.Delete("/collections", collectionService.DeleteCollection) 50 + r.Post("/collections/{collection}/items", collectionService.AddCollectionItem) 51 + r.Get("/collections/{collection}/items", collectionService.GetCollectionItems) 52 + r.Delete("/collections/items", collectionService.RemoveCollectionItem) 53 + r.Get("/collections/containing", collectionService.GetAnnotationCollections) 54 + 55 + r.Get("/targets", h.GetByTarget) 56 + 57 + r.Get("/users/{did}/annotations", h.GetUserAnnotations) 58 + r.Get("/users/{did}/highlights", h.GetUserHighlights) 59 + r.Get("/users/{did}/bookmarks", h.GetUserBookmarks) 60 + 61 + r.Get("/replies", h.GetReplies) 62 + r.Get("/likes", h.GetLikeCount) 63 + r.Get("/url-metadata", h.GetURLMetadata) 64 + r.Get("/notifications", h.GetNotifications) 65 + r.Get("/notifications/count", h.GetUnreadNotificationCount) 66 + r.Post("/notifications/read", h.MarkNotificationsRead) 67 + }) 68 + } 69 + 70 + func (h *Handler) Health(w http.ResponseWriter, r *http.Request) { 71 + w.Header().Set("Content-Type", "application/json") 72 + json.NewEncoder(w).Encode(map[string]string{"status": "ok", "version": "1.0"}) 73 + } 74 + 75 + func (h *Handler) GetAnnotations(w http.ResponseWriter, r *http.Request) { 76 + source := r.URL.Query().Get("source") 77 + if source == "" { 78 + source = r.URL.Query().Get("url") 79 + } 80 + 81 + limit := parseIntParam(r, "limit", 50) 82 + offset := parseIntParam(r, "offset", 0) 83 + motivation := r.URL.Query().Get("motivation") 84 + 85 + var annotations []db.Annotation 86 + var err error 87 + 88 + if source != "" { 89 + urlHash := db.HashURL(source) 90 + annotations, err = h.db.GetAnnotationsByTargetHash(urlHash, limit, offset) 91 + } else if motivation != "" { 92 + annotations, err = h.db.GetAnnotationsByMotivation(motivation, limit, offset) 93 + } else { 94 + annotations, err = h.db.GetRecentAnnotations(limit, offset) 95 + } 96 + 97 + if err != nil { 98 + http.Error(w, err.Error(), http.StatusInternalServerError) 99 + return 100 + } 101 + 102 + enriched, _ := hydrateAnnotations(annotations) 103 + 104 + w.Header().Set("Content-Type", "application/json") 105 + json.NewEncoder(w).Encode(map[string]interface{}{ 106 + "@context": "http://www.w3.org/ns/anno.jsonld", 107 + "type": "AnnotationCollection", 108 + "items": enriched, 109 + "totalItems": len(enriched), 110 + }) 111 + } 112 + 113 + func (h *Handler) GetFeed(w http.ResponseWriter, r *http.Request) { 114 + limit := parseIntParam(r, "limit", 50) 115 + 116 + annotations, _ := h.db.GetRecentAnnotations(limit, 0) 117 + highlights, _ := h.db.GetRecentHighlights(limit, 0) 118 + bookmarks, _ := h.db.GetRecentBookmarks(limit, 0) 119 + 120 + authAnnos, _ := hydrateAnnotations(annotations) 121 + authHighs, _ := hydrateHighlights(highlights) 122 + authBooks, _ := hydrateBookmarks(bookmarks) 123 + 124 + collectionItems, err := h.db.GetRecentCollectionItems(limit, 0) 125 + if err != nil { 126 + log.Printf("Error fetching collection items: %v\n", err) 127 + } 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)) 131 + 132 + var feed []interface{} 133 + for _, a := range authAnnos { 134 + feed = append(feed, a) 135 + } 136 + for _, h := range authHighs { 137 + feed = append(feed, h) 138 + } 139 + for _, b := range authBooks { 140 + feed = append(feed, b) 141 + } 142 + for _, ci := range authCollectionItems { 143 + feed = append(feed, ci) 144 + } 145 + 146 + for i := 0; i < len(feed); i++ { 147 + for j := i + 1; j < len(feed); j++ { 148 + t1 := getCreatedAt(feed[i]) 149 + t2 := getCreatedAt(feed[j]) 150 + if t1.Before(t2) { 151 + feed[i], feed[j] = feed[j], feed[i] 152 + } 153 + } 154 + } 155 + 156 + if len(feed) > limit { 157 + feed = feed[:limit] 158 + } 159 + 160 + w.Header().Set("Content-Type", "application/json") 161 + json.NewEncoder(w).Encode(map[string]interface{}{ 162 + "@context": "http://www.w3.org/ns/anno.jsonld", 163 + "type": "Collection", 164 + "items": feed, 165 + "totalItems": len(feed), 166 + }) 167 + } 168 + 169 + func getCreatedAt(item interface{}) time.Time { 170 + switch v := item.(type) { 171 + case APIAnnotation: 172 + return v.CreatedAt 173 + case APIHighlight: 174 + return v.CreatedAt 175 + case APIBookmark: 176 + return v.CreatedAt 177 + case APICollectionItem: 178 + return v.CreatedAt 179 + default: 180 + return time.Time{} 181 + } 182 + } 183 + 184 + func (h *Handler) GetAnnotation(w http.ResponseWriter, r *http.Request) { 185 + uri := r.URL.Query().Get("uri") 186 + if uri == "" { 187 + http.Error(w, "uri query parameter required", http.StatusBadRequest) 188 + return 189 + } 190 + 191 + annotation, err := h.db.GetAnnotationByURI(uri) 192 + if err != nil { 193 + http.Error(w, "Annotation not found", http.StatusNotFound) 194 + return 195 + } 196 + 197 + enriched, _ := hydrateAnnotations([]db.Annotation{*annotation}) 198 + if len(enriched) == 0 { 199 + http.Error(w, "Annotation not found", http.StatusNotFound) 200 + return 201 + } 202 + 203 + w.Header().Set("Content-Type", "application/json") 204 + response := map[string]interface{}{ 205 + "@context": "http://www.w3.org/ns/anno.jsonld", 206 + } 207 + annJSON, _ := json.Marshal(enriched[0]) 208 + json.Unmarshal(annJSON, &response) 209 + 210 + json.NewEncoder(w).Encode(response) 211 + } 212 + 213 + func (h *Handler) GetByTarget(w http.ResponseWriter, r *http.Request) { 214 + source := r.URL.Query().Get("source") 215 + if source == "" { 216 + source = r.URL.Query().Get("url") 217 + } 218 + if source == "" { 219 + http.Error(w, "source or url parameter required", http.StatusBadRequest) 220 + return 221 + } 222 + 223 + limit := parseIntParam(r, "limit", 50) 224 + offset := parseIntParam(r, "offset", 0) 225 + 226 + urlHash := db.HashURL(source) 227 + 228 + annotations, _ := h.db.GetAnnotationsByTargetHash(urlHash, limit, offset) 229 + highlights, _ := h.db.GetHighlightsByTargetHash(urlHash, limit, offset) 230 + 231 + enrichedAnnotations, _ := hydrateAnnotations(annotations) 232 + enrichedHighlights, _ := hydrateHighlights(highlights) 233 + 234 + w.Header().Set("Content-Type", "application/json") 235 + json.NewEncoder(w).Encode(map[string]interface{}{ 236 + "@context": "http://www.w3.org/ns/anno.jsonld", 237 + "source": source, 238 + "sourceHash": urlHash, 239 + "annotations": enrichedAnnotations, 240 + "highlights": enrichedHighlights, 241 + }) 242 + } 243 + 244 + func (h *Handler) GetHighlights(w http.ResponseWriter, r *http.Request) { 245 + did := r.URL.Query().Get("creator") 246 + limit := parseIntParam(r, "limit", 50) 247 + offset := parseIntParam(r, "offset", 0) 248 + 249 + if did == "" { 250 + http.Error(w, "creator parameter required", http.StatusBadRequest) 251 + return 252 + } 253 + 254 + highlights, err := h.db.GetHighlightsByAuthor(did, limit, offset) 255 + if err != nil { 256 + http.Error(w, err.Error(), http.StatusInternalServerError) 257 + return 258 + } 259 + 260 + enriched, _ := hydrateHighlights(highlights) 261 + 262 + w.Header().Set("Content-Type", "application/json") 263 + json.NewEncoder(w).Encode(map[string]interface{}{ 264 + "@context": "http://www.w3.org/ns/anno.jsonld", 265 + "type": "HighlightCollection", 266 + "items": enriched, 267 + "totalItems": len(enriched), 268 + }) 269 + } 270 + 271 + func (h *Handler) GetBookmarks(w http.ResponseWriter, r *http.Request) { 272 + did := r.URL.Query().Get("creator") 273 + limit := parseIntParam(r, "limit", 50) 274 + offset := parseIntParam(r, "offset", 0) 275 + 276 + if did == "" { 277 + http.Error(w, "creator parameter required", http.StatusBadRequest) 278 + return 279 + } 280 + 281 + bookmarks, err := h.db.GetBookmarksByAuthor(did, limit, offset) 282 + if err != nil { 283 + http.Error(w, err.Error(), http.StatusInternalServerError) 284 + return 285 + } 286 + 287 + enriched, _ := hydrateBookmarks(bookmarks) 288 + 289 + w.Header().Set("Content-Type", "application/json") 290 + json.NewEncoder(w).Encode(map[string]interface{}{ 291 + "@context": "http://www.w3.org/ns/anno.jsonld", 292 + "type": "BookmarkCollection", 293 + "items": enriched, 294 + "totalItems": len(enriched), 295 + }) 296 + } 297 + 298 + func (h *Handler) GetUserAnnotations(w http.ResponseWriter, r *http.Request) { 299 + did := chi.URLParam(r, "did") 300 + if decoded, err := url.QueryUnescape(did); err == nil { 301 + did = decoded 302 + } 303 + limit := parseIntParam(r, "limit", 50) 304 + offset := parseIntParam(r, "offset", 0) 305 + 306 + annotations, err := h.db.GetAnnotationsByAuthor(did, limit, offset) 307 + if err != nil { 308 + http.Error(w, err.Error(), http.StatusInternalServerError) 309 + return 310 + } 311 + 312 + enriched, _ := hydrateAnnotations(annotations) 313 + 314 + w.Header().Set("Content-Type", "application/json") 315 + json.NewEncoder(w).Encode(map[string]interface{}{ 316 + "@context": "http://www.w3.org/ns/anno.jsonld", 317 + "type": "AnnotationCollection", 318 + "creator": did, 319 + "items": enriched, 320 + "totalItems": len(enriched), 321 + }) 322 + } 323 + 324 + func (h *Handler) GetUserHighlights(w http.ResponseWriter, r *http.Request) { 325 + did := chi.URLParam(r, "did") 326 + if decoded, err := url.QueryUnescape(did); err == nil { 327 + did = decoded 328 + } 329 + limit := parseIntParam(r, "limit", 50) 330 + offset := parseIntParam(r, "offset", 0) 331 + 332 + highlights, err := h.db.GetHighlightsByAuthor(did, limit, offset) 333 + if err != nil { 334 + http.Error(w, err.Error(), http.StatusInternalServerError) 335 + return 336 + } 337 + 338 + enriched, _ := hydrateHighlights(highlights) 339 + 340 + w.Header().Set("Content-Type", "application/json") 341 + json.NewEncoder(w).Encode(map[string]interface{}{ 342 + "@context": "http://www.w3.org/ns/anno.jsonld", 343 + "type": "HighlightCollection", 344 + "creator": did, 345 + "items": enriched, 346 + "totalItems": len(enriched), 347 + }) 348 + } 349 + 350 + func (h *Handler) GetUserBookmarks(w http.ResponseWriter, r *http.Request) { 351 + did := chi.URLParam(r, "did") 352 + if decoded, err := url.QueryUnescape(did); err == nil { 353 + did = decoded 354 + } 355 + limit := parseIntParam(r, "limit", 50) 356 + offset := parseIntParam(r, "offset", 0) 357 + 358 + bookmarks, err := h.db.GetBookmarksByAuthor(did, limit, offset) 359 + if err != nil { 360 + http.Error(w, err.Error(), http.StatusInternalServerError) 361 + return 362 + } 363 + 364 + enriched, _ := hydrateBookmarks(bookmarks) 365 + 366 + w.Header().Set("Content-Type", "application/json") 367 + json.NewEncoder(w).Encode(map[string]interface{}{ 368 + "@context": "http://www.w3.org/ns/anno.jsonld", 369 + "type": "BookmarkCollection", 370 + "creator": did, 371 + "items": enriched, 372 + "totalItems": len(enriched), 373 + }) 374 + } 375 + 376 + func (h *Handler) GetReplies(w http.ResponseWriter, r *http.Request) { 377 + uri := r.URL.Query().Get("uri") 378 + if uri == "" { 379 + http.Error(w, "uri query parameter required", http.StatusBadRequest) 380 + return 381 + } 382 + 383 + replies, err := h.db.GetRepliesByRoot(uri) 384 + if err != nil { 385 + http.Error(w, err.Error(), http.StatusInternalServerError) 386 + return 387 + } 388 + 389 + enriched, _ := hydrateReplies(replies) 390 + 391 + w.Header().Set("Content-Type", "application/json") 392 + json.NewEncoder(w).Encode(map[string]interface{}{ 393 + "@context": "http://www.w3.org/ns/anno.jsonld", 394 + "type": "ReplyCollection", 395 + "inReplyTo": uri, 396 + "items": enriched, 397 + "totalItems": len(enriched), 398 + }) 399 + } 400 + 401 + func (h *Handler) GetLikeCount(w http.ResponseWriter, r *http.Request) { 402 + uri := r.URL.Query().Get("uri") 403 + if uri == "" { 404 + http.Error(w, "uri query parameter required", http.StatusBadRequest) 405 + return 406 + } 407 + 408 + count, err := h.db.GetLikeCount(uri) 409 + if err != nil { 410 + http.Error(w, err.Error(), http.StatusInternalServerError) 411 + return 412 + } 413 + 414 + liked := false 415 + cookie, err := r.Cookie("margin_session") 416 + if err == nil && cookie != nil { 417 + session, err := h.refresher.GetSessionWithAutoRefresh(r) 418 + if err == nil { 419 + userLike, err := h.db.GetLikeByUserAndSubject(session.DID, uri) 420 + if err == nil && userLike != nil { 421 + liked = true 422 + } 423 + } 424 + } 425 + 426 + w.Header().Set("Content-Type", "application/json") 427 + json.NewEncoder(w).Encode(map[string]interface{}{ 428 + "count": count, 429 + "liked": liked, 430 + }) 431 + } 432 + 433 + func (h *Handler) GetEditHistory(w http.ResponseWriter, r *http.Request) { 434 + uri := r.URL.Query().Get("uri") 435 + if uri == "" { 436 + http.Error(w, "uri query parameter required", http.StatusBadRequest) 437 + return 438 + } 439 + 440 + history, err := h.db.GetEditHistory(uri) 441 + if err != nil { 442 + http.Error(w, "Failed to fetch edit history", http.StatusInternalServerError) 443 + return 444 + } 445 + 446 + if history == nil { 447 + history = []db.EditHistory{} 448 + } 449 + 450 + w.Header().Set("Content-Type", "application/json") 451 + json.NewEncoder(w).Encode(history) 452 + } 453 + 454 + func parseIntParam(r *http.Request, name string, defaultVal int) int { 455 + val := r.URL.Query().Get(name) 456 + if val == "" { 457 + return defaultVal 458 + } 459 + i, err := strconv.Atoi(val) 460 + if err != nil { 461 + return defaultVal 462 + } 463 + return i 464 + } 465 + 466 + func (h *Handler) GetURLMetadata(w http.ResponseWriter, r *http.Request) { 467 + url := r.URL.Query().Get("url") 468 + if url == "" { 469 + http.Error(w, "url parameter required", http.StatusBadRequest) 470 + return 471 + } 472 + 473 + client := &http.Client{Timeout: 10 * time.Second} 474 + resp, err := client.Get(url) 475 + if err != nil { 476 + w.Header().Set("Content-Type", "application/json") 477 + json.NewEncoder(w).Encode(map[string]string{"title": "", "error": "failed to fetch"}) 478 + return 479 + } 480 + defer resp.Body.Close() 481 + 482 + body, err := io.ReadAll(io.LimitReader(resp.Body, 100*1024)) 483 + if err != nil { 484 + w.Header().Set("Content-Type", "application/json") 485 + json.NewEncoder(w).Encode(map[string]string{"title": ""}) 486 + return 487 + } 488 + 489 + title := "" 490 + htmlStr := string(body) 491 + if idx := strings.Index(strings.ToLower(htmlStr), "<title>"); idx != -1 { 492 + start := idx + 7 493 + if endIdx := strings.Index(strings.ToLower(htmlStr[start:]), "</title>"); endIdx != -1 { 494 + title = strings.TrimSpace(htmlStr[start : start+endIdx]) 495 + } 496 + } 497 + 498 + w.Header().Set("Content-Type", "application/json") 499 + json.NewEncoder(w).Encode(map[string]string{"title": title, "url": url}) 500 + } 501 + 502 + func (h *Handler) GetNotifications(w http.ResponseWriter, r *http.Request) { 503 + session, err := h.refresher.GetSessionWithAutoRefresh(r) 504 + if err != nil { 505 + http.Error(w, err.Error(), http.StatusUnauthorized) 506 + return 507 + } 508 + 509 + limit := parseIntParam(r, "limit", 50) 510 + offset := parseIntParam(r, "offset", 0) 511 + 512 + notifications, err := h.db.GetNotifications(session.DID, limit, offset) 513 + if err != nil { 514 + http.Error(w, "Failed to get notifications", http.StatusInternalServerError) 515 + return 516 + } 517 + 518 + enriched, err := hydrateNotifications(notifications) 519 + if err != nil { 520 + log.Printf("Failed to hydrate notifications: %v\n", err) 521 + } 522 + 523 + w.Header().Set("Content-Type", "application/json") 524 + if enriched != nil { 525 + json.NewEncoder(w).Encode(map[string]interface{}{"items": enriched}) 526 + } else { 527 + json.NewEncoder(w).Encode(map[string]interface{}{"items": notifications}) 528 + } 529 + } 530 + 531 + func (h *Handler) GetUnreadNotificationCount(w http.ResponseWriter, r *http.Request) { 532 + session, err := h.refresher.GetSessionWithAutoRefresh(r) 533 + if err != nil { 534 + http.Error(w, err.Error(), http.StatusUnauthorized) 535 + return 536 + } 537 + 538 + count, err := h.db.GetUnreadNotificationCount(session.DID) 539 + if err != nil { 540 + http.Error(w, "Failed to get count", http.StatusInternalServerError) 541 + return 542 + } 543 + 544 + w.Header().Set("Content-Type", "application/json") 545 + json.NewEncoder(w).Encode(map[string]int{"count": count}) 546 + } 547 + 548 + func (h *Handler) MarkNotificationsRead(w http.ResponseWriter, r *http.Request) { 549 + session, err := h.refresher.GetSessionWithAutoRefresh(r) 550 + if err != nil { 551 + http.Error(w, err.Error(), http.StatusUnauthorized) 552 + return 553 + } 554 + 555 + if err := h.db.MarkNotificationsRead(session.DID); err != nil { 556 + http.Error(w, "Failed to mark as read", http.StatusInternalServerError) 557 + return 558 + } 559 + 560 + w.Header().Set("Content-Type", "application/json") 561 + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) 562 + }
+535
backend/internal/api/hydration.go
··· 1 + package api 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "log" 7 + "net/http" 8 + "net/url" 9 + "strings" 10 + "sync" 11 + "time" 12 + 13 + "margin.at/internal/db" 14 + ) 15 + 16 + type Author struct { 17 + DID string `json:"did"` 18 + Handle string `json:"handle"` 19 + DisplayName string `json:"displayName,omitempty"` 20 + Avatar string `json:"avatar,omitempty"` 21 + } 22 + 23 + type APISelector struct { 24 + Type string `json:"type"` 25 + Exact string `json:"exact,omitempty"` 26 + Prefix string `json:"prefix,omitempty"` 27 + Suffix string `json:"suffix,omitempty"` 28 + Start *int `json:"start,omitempty"` 29 + End *int `json:"end,omitempty"` 30 + Value string `json:"value,omitempty"` 31 + ConformsTo string `json:"conformsTo,omitempty"` 32 + } 33 + 34 + type APIBody struct { 35 + Value string `json:"value,omitempty"` 36 + Format string `json:"format,omitempty"` 37 + URI string `json:"uri,omitempty"` 38 + } 39 + 40 + type APITarget struct { 41 + Source string `json:"source"` 42 + Title string `json:"title,omitempty"` 43 + Selector *APISelector `json:"selector,omitempty"` 44 + } 45 + 46 + type APIGenerator struct { 47 + ID string `json:"id"` 48 + Type string `json:"type"` 49 + Name string `json:"name"` 50 + } 51 + 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"` 64 + } 65 + 66 + 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"` 75 + } 76 + 77 + 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"` 87 + } 88 + 89 + type APIReply struct { 90 + ID string `json:"id"` 91 + Type string `json:"type"` 92 + Author Author `json:"creator"` 93 + ParentURI string `json:"inReplyTo"` 94 + RootURI string `json:"rootUri"` 95 + Text string `json:"text"` 96 + Format string `json:"format,omitempty"` 97 + CreatedAt time.Time `json:"created"` 98 + CID string `json:"cid,omitempty"` 99 + } 100 + 101 + type APICollection struct { 102 + URI string `json:"uri"` 103 + Name string `json:"name"` 104 + Icon string `json:"icon,omitempty"` 105 + } 106 + 107 + type APICollectionItem struct { 108 + ID string `json:"id"` 109 + Type string `json:"type"` 110 + Author Author `json:"creator"` 111 + CollectionURI string `json:"collectionUri"` 112 + Collection *APICollection `json:"collection,omitempty"` 113 + Annotation *APIAnnotation `json:"annotation,omitempty"` 114 + Highlight *APIHighlight `json:"highlight,omitempty"` 115 + Bookmark *APIBookmark `json:"bookmark,omitempty"` 116 + CreatedAt time.Time `json:"created"` 117 + Position int `json:"position"` 118 + } 119 + 120 + 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"` 128 + } 129 + 130 + func hydrateAnnotations(annotations []db.Annotation) ([]APIAnnotation, error) { 131 + if len(annotations) == 0 { 132 + return []APIAnnotation{}, nil 133 + } 134 + 135 + profiles := fetchProfilesForDIDs(collectDIDs(annotations, func(a db.Annotation) string { return a.AuthorDID })) 136 + 137 + result := make([]APIAnnotation, len(annotations)) 138 + for i, a := range annotations { 139 + var body *APIBody 140 + if a.BodyValue != nil || a.BodyURI != nil { 141 + body = &APIBody{} 142 + if a.BodyValue != nil { 143 + body.Value = *a.BodyValue 144 + } 145 + if a.BodyFormat != nil { 146 + body.Format = *a.BodyFormat 147 + } 148 + if a.BodyURI != nil { 149 + body.URI = *a.BodyURI 150 + } 151 + } 152 + 153 + var selector *APISelector 154 + if a.SelectorJSON != nil && *a.SelectorJSON != "" { 155 + selector = &APISelector{} 156 + json.Unmarshal([]byte(*a.SelectorJSON), selector) 157 + } 158 + 159 + var tags []string 160 + if a.TagsJSON != nil && *a.TagsJSON != "" { 161 + json.Unmarshal([]byte(*a.TagsJSON), &tags) 162 + } 163 + 164 + title := "" 165 + if a.TargetTitle != nil { 166 + title = *a.TargetTitle 167 + } 168 + 169 + cid := "" 170 + if a.CID != nil { 171 + cid = *a.CID 172 + } 173 + 174 + result[i] = APIAnnotation{ 175 + ID: a.URI, 176 + CID: cid, 177 + Type: "Annotation", 178 + Motivation: a.Motivation, 179 + Author: profiles[a.AuthorDID], 180 + Body: body, 181 + Target: APITarget{ 182 + Source: a.TargetSource, 183 + Title: title, 184 + Selector: selector, 185 + }, 186 + Tags: tags, 187 + Generator: &APIGenerator{ 188 + ID: "https://margin.at", 189 + Type: "Software", 190 + Name: "Margin", 191 + }, 192 + CreatedAt: a.CreatedAt, 193 + IndexedAt: a.IndexedAt, 194 + } 195 + } 196 + 197 + return result, nil 198 + } 199 + 200 + func hydrateHighlights(highlights []db.Highlight) ([]APIHighlight, error) { 201 + if len(highlights) == 0 { 202 + return []APIHighlight{}, nil 203 + } 204 + 205 + profiles := fetchProfilesForDIDs(collectDIDs(highlights, func(h db.Highlight) string { return h.AuthorDID })) 206 + 207 + result := make([]APIHighlight, len(highlights)) 208 + for i, h := range highlights { 209 + var selector *APISelector 210 + if h.SelectorJSON != nil && *h.SelectorJSON != "" { 211 + selector = &APISelector{} 212 + json.Unmarshal([]byte(*h.SelectorJSON), selector) 213 + } 214 + 215 + var tags []string 216 + if h.TagsJSON != nil && *h.TagsJSON != "" { 217 + json.Unmarshal([]byte(*h.TagsJSON), &tags) 218 + } 219 + 220 + title := "" 221 + if h.TargetTitle != nil { 222 + title = *h.TargetTitle 223 + } 224 + 225 + color := "" 226 + if h.Color != nil { 227 + color = *h.Color 228 + } 229 + 230 + cid := "" 231 + if h.CID != nil { 232 + cid = *h.CID 233 + } 234 + 235 + result[i] = APIHighlight{ 236 + ID: h.URI, 237 + Type: "Highlight", 238 + Author: profiles[h.AuthorDID], 239 + Target: APITarget{ 240 + Source: h.TargetSource, 241 + Title: title, 242 + Selector: selector, 243 + }, 244 + Color: color, 245 + Tags: tags, 246 + CreatedAt: h.CreatedAt, 247 + CID: cid, 248 + } 249 + } 250 + 251 + return result, nil 252 + } 253 + 254 + func hydrateBookmarks(bookmarks []db.Bookmark) ([]APIBookmark, error) { 255 + if len(bookmarks) == 0 { 256 + return []APIBookmark{}, nil 257 + } 258 + 259 + profiles := fetchProfilesForDIDs(collectDIDs(bookmarks, func(b db.Bookmark) string { return b.AuthorDID })) 260 + 261 + result := make([]APIBookmark, len(bookmarks)) 262 + for i, b := range bookmarks { 263 + var tags []string 264 + if b.TagsJSON != nil && *b.TagsJSON != "" { 265 + json.Unmarshal([]byte(*b.TagsJSON), &tags) 266 + } 267 + 268 + title := "" 269 + if b.Title != nil { 270 + title = *b.Title 271 + } 272 + 273 + desc := "" 274 + if b.Description != nil { 275 + desc = *b.Description 276 + } 277 + 278 + cid := "" 279 + if b.CID != nil { 280 + cid = *b.CID 281 + } 282 + 283 + result[i] = APIBookmark{ 284 + ID: b.URI, 285 + Type: "Bookmark", 286 + Author: profiles[b.AuthorDID], 287 + Source: b.Source, 288 + Title: title, 289 + Description: desc, 290 + Tags: tags, 291 + CreatedAt: b.CreatedAt, 292 + CID: cid, 293 + } 294 + } 295 + 296 + return result, nil 297 + } 298 + 299 + func hydrateReplies(replies []db.Reply) ([]APIReply, error) { 300 + if len(replies) == 0 { 301 + return []APIReply{}, nil 302 + } 303 + 304 + profiles := fetchProfilesForDIDs(collectDIDs(replies, func(r db.Reply) string { return r.AuthorDID })) 305 + 306 + result := make([]APIReply, len(replies)) 307 + for i, r := range replies { 308 + format := "text/plain" 309 + if r.Format != nil { 310 + format = *r.Format 311 + } 312 + 313 + cid := "" 314 + if r.CID != nil { 315 + cid = *r.CID 316 + } 317 + 318 + result[i] = APIReply{ 319 + ID: r.URI, 320 + Type: "Reply", 321 + Author: profiles[r.AuthorDID], 322 + ParentURI: r.ParentURI, 323 + RootURI: r.RootURI, 324 + Text: r.Text, 325 + Format: format, 326 + CreatedAt: r.CreatedAt, 327 + CID: cid, 328 + } 329 + } 330 + return result, nil 331 + } 332 + 333 + func collectDIDs[T any](items []T, getDID func(T) string) []string { 334 + uniqueDIDs := make(map[string]bool) 335 + for _, item := range items { 336 + uniqueDIDs[getDID(item)] = true 337 + } 338 + 339 + dids := make([]string, 0, len(uniqueDIDs)) 340 + for did := range uniqueDIDs { 341 + dids = append(dids, did) 342 + } 343 + return dids 344 + } 345 + 346 + func fetchProfilesForDIDs(dids []string) map[string]Author { 347 + profiles := make(map[string]Author) 348 + 349 + for _, did := range dids { 350 + profiles[did] = Author{ 351 + DID: did, 352 + Handle: "unknown", 353 + } 354 + } 355 + 356 + if len(dids) == 0 { 357 + return profiles 358 + } 359 + 360 + batchSize := 25 361 + var wg sync.WaitGroup 362 + var mu sync.Mutex 363 + 364 + for i := 0; i < len(dids); i += batchSize { 365 + end := i + batchSize 366 + if end > len(dids) { 367 + end = len(dids) 368 + } 369 + batch := dids[i:end] 370 + 371 + wg.Add(1) 372 + go func(actors []string) { 373 + defer wg.Done() 374 + fetched, err := fetchProfiles(actors) 375 + if err == nil { 376 + mu.Lock() 377 + for k, v := range fetched { 378 + profiles[k] = v 379 + } 380 + mu.Unlock() 381 + } 382 + }(batch) 383 + } 384 + wg.Wait() 385 + 386 + return profiles 387 + } 388 + 389 + func fetchProfiles(dids []string) (map[string]Author, error) { 390 + if len(dids) == 0 { 391 + return nil, nil 392 + } 393 + 394 + q := url.Values{} 395 + for _, did := range dids { 396 + q.Add("actors", did) 397 + } 398 + 399 + resp, err := http.Get("https://public.api.bsky.app/xrpc/app.bsky.actor.getProfiles?" + q.Encode()) 400 + if err != nil { 401 + log.Printf("Hydration fetch error: %v\n", err) 402 + return nil, err 403 + } 404 + defer resp.Body.Close() 405 + 406 + if resp.StatusCode != 200 { 407 + log.Printf("Hydration fetch status error: %d\n", resp.StatusCode) 408 + return nil, fmt.Errorf("failed to fetch profiles: %d", resp.StatusCode) 409 + } 410 + 411 + var output struct { 412 + Profiles []struct { 413 + DID string `json:"did"` 414 + Handle string `json:"handle"` 415 + DisplayName string `json:"displayName"` 416 + Avatar string `json:"avatar"` 417 + } `json:"profiles"` 418 + } 419 + 420 + if err := json.NewDecoder(resp.Body).Decode(&output); err != nil { 421 + return nil, err 422 + } 423 + 424 + result := make(map[string]Author) 425 + for _, p := range output.Profiles { 426 + result[p.DID] = Author{ 427 + DID: p.DID, 428 + Handle: p.Handle, 429 + DisplayName: p.DisplayName, 430 + Avatar: p.Avatar, 431 + } 432 + } 433 + 434 + return result, nil 435 + } 436 + 437 + func hydrateCollectionItems(database *db.DB, items []db.CollectionItem) ([]APICollectionItem, error) { 438 + if len(items) == 0 { 439 + return []APICollectionItem{}, nil 440 + } 441 + 442 + profiles := fetchProfilesForDIDs(collectDIDs(items, func(i db.CollectionItem) string { return i.AuthorDID })) 443 + 444 + result := make([]APICollectionItem, len(items)) 445 + for i, item := range items { 446 + apiItem := APICollectionItem{ 447 + ID: item.URI, 448 + Type: "CollectionItem", 449 + Author: profiles[item.AuthorDID], 450 + CollectionURI: item.CollectionURI, 451 + CreatedAt: item.CreatedAt, 452 + Position: item.Position, 453 + } 454 + 455 + if coll, err := database.GetCollectionByURI(item.CollectionURI); err == nil { 456 + icon := "" 457 + if coll.Icon != nil { 458 + icon = *coll.Icon 459 + } 460 + apiItem.Collection = &APICollection{ 461 + URI: coll.URI, 462 + Name: coll.Name, 463 + Icon: icon, 464 + } 465 + } 466 + 467 + if strings.Contains(item.AnnotationURI, "at.margin.annotation") { 468 + if a, err := database.GetAnnotationByURI(item.AnnotationURI); err == nil { 469 + hydrated, _ := hydrateAnnotations([]db.Annotation{*a}) 470 + if len(hydrated) > 0 { 471 + apiItem.Annotation = &hydrated[0] 472 + } 473 + } 474 + } else if strings.Contains(item.AnnotationURI, "at.margin.highlight") { 475 + if h, err := database.GetHighlightByURI(item.AnnotationURI); err == nil { 476 + hydrated, _ := hydrateHighlights([]db.Highlight{*h}) 477 + if len(hydrated) > 0 { 478 + apiItem.Highlight = &hydrated[0] 479 + } 480 + } 481 + } else if strings.Contains(item.AnnotationURI, "at.margin.bookmark") { 482 + if b, err := database.GetBookmarkByURI(item.AnnotationURI); err == nil { 483 + hydrated, _ := hydrateBookmarks([]db.Bookmark{*b}) 484 + if len(hydrated) > 0 { 485 + apiItem.Bookmark = &hydrated[0] 486 + } else { 487 + log.Printf("Failed to hydrate bookmark %s: empty hydration result\n", item.AnnotationURI) 488 + } 489 + } else { 490 + log.Printf("GetBookmarkByURI failed for %s: %v\n", item.AnnotationURI, err) 491 + } 492 + } else { 493 + log.Printf("Unknown item type for URI: %s\n", item.AnnotationURI) 494 + } 495 + 496 + result[i] = apiItem 497 + } 498 + return result, nil 499 + } 500 + 501 + func hydrateNotifications(notifications []db.Notification) ([]APINotification, error) { 502 + if len(notifications) == 0 { 503 + return []APINotification{}, nil 504 + } 505 + 506 + dids := make([]string, 0) 507 + uniqueDIDs := make(map[string]bool) 508 + for _, n := range notifications { 509 + if !uniqueDIDs[n.ActorDID] { 510 + dids = append(dids, n.ActorDID) 511 + uniqueDIDs[n.ActorDID] = true 512 + } 513 + if !uniqueDIDs[n.RecipientDID] { 514 + dids = append(dids, n.RecipientDID) 515 + uniqueDIDs[n.RecipientDID] = true 516 + } 517 + } 518 + 519 + profiles := fetchProfilesForDIDs(dids) 520 + 521 + result := make([]APINotification, len(notifications)) 522 + for i, n := range notifications { 523 + result[i] = APINotification{ 524 + ID: n.ID, 525 + Recipient: profiles[n.RecipientDID], 526 + Actor: profiles[n.ActorDID], 527 + Type: n.Type, 528 + SubjectURI: n.SubjectURI, 529 + CreatedAt: n.CreatedAt, 530 + ReadAt: n.ReadAt, 531 + } 532 + } 533 + 534 + return result, nil 535 + }
+664
backend/internal/api/og.go
··· 1 + package api 2 + 3 + import ( 4 + "bytes" 5 + _ "embed" 6 + "encoding/json" 7 + "fmt" 8 + "html" 9 + "image" 10 + "image/color" 11 + "image/draw" 12 + _ "image/jpeg" 13 + "image/png" 14 + "log" 15 + "net/http" 16 + "net/url" 17 + "os" 18 + "regexp" 19 + "strings" 20 + 21 + "golang.org/x/image/font" 22 + "golang.org/x/image/font/opentype" 23 + "golang.org/x/image/math/fixed" 24 + 25 + "margin.at/internal/db" 26 + ) 27 + 28 + //go:embed fonts/Inter-Regular.ttf 29 + var interRegularTTF []byte 30 + 31 + //go:embed fonts/Inter-Bold.ttf 32 + var interBoldTTF []byte 33 + 34 + //go:embed assets/logo.png 35 + var logoPNG []byte 36 + 37 + var ( 38 + fontRegular *opentype.Font 39 + fontBold *opentype.Font 40 + logoImage image.Image 41 + ) 42 + 43 + func init() { 44 + var err error 45 + fontRegular, err = opentype.Parse(interRegularTTF) 46 + if err != nil { 47 + log.Printf("Warning: failed to parse Inter-Regular font: %v", err) 48 + } 49 + fontBold, err = opentype.Parse(interBoldTTF) 50 + if err != nil { 51 + log.Printf("Warning: failed to parse Inter-Bold font: %v", err) 52 + } 53 + 54 + if len(logoPNG) > 0 { 55 + img, _, err := image.Decode(bytes.NewReader(logoPNG)) 56 + if err != nil { 57 + log.Printf("Warning: failed to decode logo PNG: %v", err) 58 + } else { 59 + logoImage = img 60 + } 61 + } 62 + } 63 + 64 + type OGHandler struct { 65 + db *db.DB 66 + baseURL string 67 + staticDir string 68 + } 69 + 70 + func NewOGHandler(database *db.DB) *OGHandler { 71 + baseURL := os.Getenv("BASE_URL") 72 + if baseURL == "" { 73 + baseURL = "https://margin.at" 74 + } 75 + staticDir := os.Getenv("STATIC_DIR") 76 + if staticDir == "" { 77 + staticDir = "../web/dist" 78 + } 79 + return &OGHandler{ 80 + db: database, 81 + baseURL: strings.TrimSuffix(baseURL, "/"), 82 + staticDir: staticDir, 83 + } 84 + } 85 + 86 + var crawlerUserAgents = []string{ 87 + "facebookexternalhit", 88 + "Facebot", 89 + "Twitterbot", 90 + "LinkedInBot", 91 + "WhatsApp", 92 + "Slackbot", 93 + "TelegramBot", 94 + "Discordbot", 95 + "applebot", 96 + "bot", 97 + "crawler", 98 + "spider", 99 + "preview", 100 + "Cardyb", 101 + "Bluesky", 102 + } 103 + 104 + func isCrawler(userAgent string) bool { 105 + ua := strings.ToLower(userAgent) 106 + for _, bot := range crawlerUserAgents { 107 + if strings.Contains(ua, strings.ToLower(bot)) { 108 + return true 109 + } 110 + } 111 + return false 112 + } 113 + 114 + func (h *OGHandler) HandleAnnotationPage(w http.ResponseWriter, r *http.Request) { 115 + path := r.URL.Path 116 + 117 + var annotationMatch = regexp.MustCompile(`^/at/([^/]+)/([^/]+)$`) 118 + matches := annotationMatch.FindStringSubmatch(path) 119 + 120 + if len(matches) != 3 { 121 + h.serveIndexHTML(w, r) 122 + return 123 + } 124 + 125 + did, _ := url.QueryUnescape(matches[1]) 126 + rkey := matches[2] 127 + 128 + if !isCrawler(r.UserAgent()) { 129 + h.serveIndexHTML(w, r) 130 + return 131 + } 132 + 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 138 + } 139 + 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) 144 + return 145 + } 146 + 147 + h.serveIndexHTML(w, r) 148 + } 149 + 150 + func (h *OGHandler) serveBookmarkOG(w http.ResponseWriter, bookmark *db.Bookmark) { 151 + title := "Bookmark on Margin" 152 + if bookmark.Title != nil && *bookmark.Title != "" { 153 + title = *bookmark.Title 154 + } 155 + 156 + description := "" 157 + if bookmark.Description != nil && *bookmark.Description != "" { 158 + description = *bookmark.Description 159 + } else { 160 + description = "A saved bookmark on Margin" 161 + } 162 + 163 + sourceDomain := "" 164 + if bookmark.Source != "" { 165 + if parsed, err := url.Parse(bookmark.Source); err == nil { 166 + sourceDomain = parsed.Host 167 + } 168 + } 169 + 170 + if sourceDomain != "" { 171 + description += " from " + sourceDomain 172 + } 173 + 174 + authorHandle := bookmark.AuthorDID 175 + profiles := fetchProfilesForDIDs([]string{bookmark.AuthorDID}) 176 + if profile, ok := profiles[bookmark.AuthorDID]; ok && profile.Handle != "" { 177 + authorHandle = "@" + profile.Handle 178 + } 179 + 180 + pageURL := fmt.Sprintf("%s/at/%s", h.baseURL, url.PathEscape(bookmark.URI[5:])) 181 + ogImageURL := fmt.Sprintf("%s/og-image?uri=%s", h.baseURL, url.QueryEscape(bookmark.URI)) 182 + 183 + htmlContent := fmt.Sprintf(`<!DOCTYPE html> 184 + <html lang="en"> 185 + <head> 186 + <meta charset="UTF-8"> 187 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 188 + <title>%s - Margin</title> 189 + <meta name="description" content="%s"> 190 + 191 + <!-- Open Graph --> 192 + <meta property="og:type" content="article"> 193 + <meta property="og:title" content="%s"> 194 + <meta property="og:description" content="%s"> 195 + <meta property="og:url" content="%s"> 196 + <meta property="og:image" content="%s"> 197 + <meta property="og:image:width" content="1200"> 198 + <meta property="og:image:height" content="630"> 199 + <meta property="og:site_name" content="Margin"> 200 + 201 + <!-- Twitter Card --> 202 + <meta name="twitter:card" content="summary_large_image"> 203 + <meta name="twitter:title" content="%s"> 204 + <meta name="twitter:description" content="%s"> 205 + <meta name="twitter:image" content="%s"> 206 + 207 + <!-- Author --> 208 + <meta property="article:author" content="%s"> 209 + 210 + <meta http-equiv="refresh" content="0; url=%s"> 211 + </head> 212 + <body> 213 + <p>Redirecting to <a href="%s">%s</a>...</p> 214 + </body> 215 + </html>`, 216 + html.EscapeString(title), 217 + html.EscapeString(description), 218 + html.EscapeString(title), 219 + html.EscapeString(description), 220 + html.EscapeString(pageURL), 221 + html.EscapeString(ogImageURL), 222 + html.EscapeString(title), 223 + html.EscapeString(description), 224 + html.EscapeString(ogImageURL), 225 + html.EscapeString(authorHandle), 226 + html.EscapeString(pageURL), 227 + html.EscapeString(pageURL), 228 + html.EscapeString(title), 229 + ) 230 + 231 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 232 + w.Write([]byte(htmlContent)) 233 + } 234 + 235 + func (h *OGHandler) serveAnnotationOG(w http.ResponseWriter, annotation *db.Annotation) { 236 + title := "Annotation on Margin" 237 + description := "" 238 + 239 + if annotation.BodyValue != nil && *annotation.BodyValue != "" { 240 + description = *annotation.BodyValue 241 + if len(description) > 200 { 242 + description = description[:197] + "..." 243 + } 244 + } 245 + 246 + if annotation.TargetTitle != nil && *annotation.TargetTitle != "" { 247 + title = fmt.Sprintf("Comment on: %s", *annotation.TargetTitle) 248 + if len(title) > 60 { 249 + title = title[:57] + "..." 250 + } 251 + } 252 + 253 + sourceDomain := "" 254 + if annotation.TargetSource != "" { 255 + if parsed, err := url.Parse(annotation.TargetSource); err == nil { 256 + sourceDomain = parsed.Host 257 + } 258 + } 259 + 260 + authorHandle := annotation.AuthorDID 261 + profiles := fetchProfilesForDIDs([]string{annotation.AuthorDID}) 262 + if profile, ok := profiles[annotation.AuthorDID]; ok && profile.Handle != "" { 263 + authorHandle = "@" + profile.Handle 264 + } 265 + 266 + pageURL := fmt.Sprintf("%s/at/%s", h.baseURL, url.PathEscape(annotation.URI[5:])) 267 + 268 + var selectorText string 269 + if annotation.SelectorJSON != nil && *annotation.SelectorJSON != "" { 270 + var selector struct { 271 + Exact string `json:"exact"` 272 + } 273 + if err := json.Unmarshal([]byte(*annotation.SelectorJSON), &selector); err == nil && selector.Exact != "" { 274 + selectorText = selector.Exact 275 + if len(selectorText) > 100 { 276 + selectorText = selectorText[:97] + "..." 277 + } 278 + } 279 + } 280 + 281 + if selectorText != "" && description != "" { 282 + description = fmt.Sprintf("\"%s\"\n\n%s", selectorText, description) 283 + } else if selectorText != "" { 284 + description = fmt.Sprintf("Highlighted: \"%s\"", selectorText) 285 + } 286 + 287 + if description == "" { 288 + description = fmt.Sprintf("An annotation by %s", authorHandle) 289 + if sourceDomain != "" { 290 + description += fmt.Sprintf(" on %s", sourceDomain) 291 + } 292 + } 293 + 294 + ogImageURL := fmt.Sprintf("%s/og-image?uri=%s", h.baseURL, url.QueryEscape(annotation.URI)) 295 + 296 + htmlContent := fmt.Sprintf(`<!DOCTYPE html> 297 + <html lang="en"> 298 + <head> 299 + <meta charset="UTF-8"> 300 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 301 + <title>%s - Margin</title> 302 + <meta name="description" content="%s"> 303 + 304 + <!-- Open Graph --> 305 + <meta property="og:type" content="article"> 306 + <meta property="og:title" content="%s"> 307 + <meta property="og:description" content="%s"> 308 + <meta property="og:url" content="%s"> 309 + <meta property="og:image" content="%s"> 310 + <meta property="og:image:width" content="1200"> 311 + <meta property="og:image:height" content="630"> 312 + <meta property="og:site_name" content="Margin"> 313 + 314 + <!-- Twitter Card --> 315 + <meta name="twitter:card" content="summary_large_image"> 316 + <meta name="twitter:title" content="%s"> 317 + <meta name="twitter:description" content="%s"> 318 + <meta name="twitter:image" content="%s"> 319 + 320 + <!-- Author --> 321 + <meta property="article:author" content="%s"> 322 + 323 + <meta http-equiv="refresh" content="0; url=%s"> 324 + </head> 325 + <body> 326 + <p>Redirecting to <a href="%s">%s</a>...</p> 327 + </body> 328 + </html>`, 329 + html.EscapeString(title), 330 + html.EscapeString(description), 331 + html.EscapeString(title), 332 + html.EscapeString(description), 333 + html.EscapeString(pageURL), 334 + html.EscapeString(ogImageURL), 335 + html.EscapeString(title), 336 + html.EscapeString(description), 337 + html.EscapeString(ogImageURL), 338 + html.EscapeString(authorHandle), 339 + html.EscapeString(pageURL), 340 + html.EscapeString(pageURL), 341 + html.EscapeString(title), 342 + ) 343 + 344 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 345 + w.Write([]byte(htmlContent)) 346 + } 347 + 348 + func (h *OGHandler) serveIndexHTML(w http.ResponseWriter, r *http.Request) { 349 + http.ServeFile(w, r, h.staticDir+"/index.html") 350 + } 351 + 352 + func (h *OGHandler) HandleOGImage(w http.ResponseWriter, r *http.Request) { 353 + uri := r.URL.Query().Get("uri") 354 + if uri == "" { 355 + http.Error(w, "uri parameter required", http.StatusBadRequest) 356 + return 357 + } 358 + 359 + var authorHandle, text, quote, sourceDomain, avatarURL string 360 + 361 + annotation, err := h.db.GetAnnotationByURI(uri) 362 + if err == nil && annotation != nil { 363 + authorHandle = annotation.AuthorDID 364 + profiles := fetchProfilesForDIDs([]string{annotation.AuthorDID}) 365 + if profile, ok := profiles[annotation.AuthorDID]; ok { 366 + if profile.Handle != "" { 367 + authorHandle = "@" + profile.Handle 368 + } 369 + if profile.Avatar != "" { 370 + avatarURL = profile.Avatar 371 + } 372 + } 373 + 374 + if annotation.BodyValue != nil { 375 + text = *annotation.BodyValue 376 + } 377 + 378 + if annotation.SelectorJSON != nil && *annotation.SelectorJSON != "" { 379 + var selector struct { 380 + Exact string `json:"exact"` 381 + } 382 + if err := json.Unmarshal([]byte(*annotation.SelectorJSON), &selector); err == nil { 383 + quote = selector.Exact 384 + } 385 + } 386 + 387 + if annotation.TargetSource != "" { 388 + if parsed, err := url.Parse(annotation.TargetSource); err == nil { 389 + sourceDomain = parsed.Host 390 + } 391 + } 392 + } else { 393 + bookmark, err := h.db.GetBookmarkByURI(uri) 394 + if err == nil && bookmark != nil { 395 + authorHandle = bookmark.AuthorDID 396 + profiles := fetchProfilesForDIDs([]string{bookmark.AuthorDID}) 397 + if profile, ok := profiles[bookmark.AuthorDID]; ok { 398 + if profile.Handle != "" { 399 + authorHandle = "@" + profile.Handle 400 + } 401 + if profile.Avatar != "" { 402 + avatarURL = profile.Avatar 403 + } 404 + } 405 + 406 + text = "Bookmark" 407 + if bookmark.Description != nil { 408 + quote = *bookmark.Description 409 + } 410 + if bookmark.Title != nil { 411 + text = *bookmark.Title 412 + } 413 + 414 + if bookmark.Source != "" { 415 + if parsed, err := url.Parse(bookmark.Source); err == nil { 416 + sourceDomain = parsed.Host 417 + } 418 + } 419 + } else { 420 + http.Error(w, "Record not found", http.StatusNotFound) 421 + return 422 + } 423 + } 424 + 425 + img := generateOGImagePNG(authorHandle, text, quote, sourceDomain, avatarURL) 426 + 427 + w.Header().Set("Content-Type", "image/png") 428 + w.Header().Set("Cache-Control", "public, max-age=86400") 429 + png.Encode(w, img) 430 + } 431 + 432 + func generateOGImagePNG(author, text, quote, source, avatarURL string) image.Image { 433 + width := 1200 434 + height := 630 435 + padding := 120 436 + 437 + bgPrimary := color.RGBA{12, 10, 20, 255} 438 + accent := color.RGBA{168, 85, 247, 255} 439 + textPrimary := color.RGBA{244, 240, 255, 255} 440 + textSecondary := color.RGBA{168, 158, 200, 255} 441 + textTertiary := color.RGBA{107, 95, 138, 255} 442 + border := color.RGBA{45, 38, 64, 255} 443 + 444 + img := image.NewRGBA(image.Rect(0, 0, width, height)) 445 + 446 + 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 + } 456 + 457 + avatarSize := 80 458 + avatarX := padding 459 + avatarY := 180 460 + avatarImg := fetchAvatarImage(avatarURL) 461 + if avatarImg != nil { 462 + drawCircularAvatar(img, avatarImg, avatarX, avatarY, avatarSize) 463 + } else { 464 + drawDefaultAvatar(img, author, avatarX, avatarY, avatarSize, accent) 465 + } 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 473 + 474 + contentWidth := width - (padding * 2) 475 + 476 + if quote != "" { 477 + if len(quote) > 100 { 478 + quote = quote[:97] + "..." 479 + } 480 + 481 + lines := wrapTextToWidth(quote, contentWidth-30, 24) 482 + numLines := min(len(lines), 2) 483 + barHeight := numLines*32 + 10 484 + 485 + draw.Draw(img, image.Rect(padding, yPos, padding+6, yPos+barHeight), &image.Uniform{accent}, image.Point{}, draw.Src) 486 + 487 + for i, line := range lines { 488 + if i >= 2 { 489 + break 490 + } 491 + drawText(img, "\""+line+"\"", padding+24, yPos+28+(i*32), textTertiary, 24, true) 492 + } 493 + yPos += 30 + (numLines * 32) + 30 494 + } 495 + 496 + if text != "" { 497 + if len(text) > 300 { 498 + text = text[:297] + "..." 499 + } 500 + lines := wrapTextToWidth(text, contentWidth, 32) 501 + for i, line := range lines { 502 + if i >= 6 { 503 + break 504 + } 505 + drawText(img, line, padding, yPos+(i*42), textPrimary, 32, false) 506 + } 507 + } 508 + 509 + drawText(img, source, padding, 580, textTertiary, 20, false) 510 + 511 + return img 512 + } 513 + 514 + func drawScaledImage(dst *image.RGBA, src image.Image, x, y, w, h int) { 515 + bounds := src.Bounds() 516 + srcW := bounds.Dx() 517 + srcH := bounds.Dy() 518 + 519 + for dy := 0; dy < h; dy++ { 520 + for dx := 0; dx < w; dx++ { 521 + srcX := bounds.Min.X + (dx * srcW / w) 522 + srcY := bounds.Min.Y + (dy * srcH / h) 523 + c := src.At(srcX, srcY) 524 + _, _, _, a := c.RGBA() 525 + if a > 0 { 526 + dst.Set(x+dx, y+dy, c) 527 + } 528 + } 529 + } 530 + } 531 + 532 + func fetchAvatarImage(avatarURL string) image.Image { 533 + if avatarURL == "" { 534 + return nil 535 + } 536 + 537 + resp, err := http.Get(avatarURL) 538 + if err != nil { 539 + return nil 540 + } 541 + defer resp.Body.Close() 542 + 543 + if resp.StatusCode != 200 { 544 + return nil 545 + } 546 + 547 + img, _, err := image.Decode(resp.Body) 548 + if err != nil { 549 + return nil 550 + } 551 + 552 + return img 553 + } 554 + 555 + func drawCircularAvatar(dst *image.RGBA, src image.Image, x, y, size int) { 556 + bounds := src.Bounds() 557 + srcW := bounds.Dx() 558 + srcH := bounds.Dy() 559 + 560 + centerX := size / 2 561 + centerY := size / 2 562 + radius := size / 2 563 + 564 + for dy := 0; dy < size; dy++ { 565 + for dx := 0; dx < size; dx++ { 566 + distX := dx - centerX 567 + distY := dy - centerY 568 + if distX*distX+distY*distY <= radius*radius { 569 + srcX := bounds.Min.X + (dx * srcW / size) 570 + srcY := bounds.Min.Y + (dy * srcH / size) 571 + dst.Set(x+dx, y+dy, src.At(srcX, srcY)) 572 + } 573 + } 574 + } 575 + } 576 + 577 + func drawDefaultAvatar(dst *image.RGBA, author string, x, y, size int, accentColor color.RGBA) { 578 + centerX := size / 2 579 + centerY := size / 2 580 + radius := size / 2 581 + 582 + for dy := 0; dy < size; dy++ { 583 + for dx := 0; dx < size; dx++ { 584 + distX := dx - centerX 585 + distY := dy - centerY 586 + if distX*distX+distY*distY <= radius*radius { 587 + dst.Set(x+dx, y+dy, accentColor) 588 + } 589 + } 590 + } 591 + 592 + initial := "?" 593 + if len(author) > 1 { 594 + if author[0] == '@' && len(author) > 1 { 595 + initial = strings.ToUpper(string(author[1])) 596 + } else { 597 + initial = strings.ToUpper(string(author[0])) 598 + } 599 + } 600 + drawText(dst, initial, x+size/2-10, y+size/2+12, color.RGBA{255, 255, 255, 255}, 32, true) 601 + } 602 + 603 + func min(a, b int) int { 604 + if a < b { 605 + return a 606 + } 607 + return b 608 + } 609 + 610 + func drawText(img *image.RGBA, text string, x, y int, c color.Color, size float64, bold bool) { 611 + if fontRegular == nil || fontBold == nil { 612 + return 613 + } 614 + 615 + selectedFont := fontRegular 616 + if bold { 617 + selectedFont = fontBold 618 + } 619 + 620 + face, err := opentype.NewFace(selectedFont, &opentype.FaceOptions{ 621 + Size: size, 622 + DPI: 72, 623 + Hinting: font.HintingFull, 624 + }) 625 + if err != nil { 626 + return 627 + } 628 + defer face.Close() 629 + 630 + d := &font.Drawer{ 631 + Dst: img, 632 + Src: image.NewUniform(c), 633 + Face: face, 634 + Dot: fixed.Point26_6{X: fixed.I(x), Y: fixed.I(y)}, 635 + } 636 + d.DrawString(text) 637 + } 638 + 639 + func wrapTextToWidth(text string, maxWidth int, fontSize int) []string { 640 + words := strings.Fields(text) 641 + var lines []string 642 + var currentLine string 643 + 644 + charWidth := fontSize * 6 / 10 645 + 646 + for _, word := range words { 647 + testLine := currentLine 648 + if testLine != "" { 649 + testLine += " " 650 + } 651 + testLine += word 652 + 653 + if len(testLine)*charWidth > maxWidth && currentLine != "" { 654 + lines = append(lines, currentLine) 655 + currentLine = word 656 + } else { 657 + currentLine = testLine 658 + } 659 + } 660 + if currentLine != "" { 661 + lines = append(lines, currentLine) 662 + } 663 + return lines 664 + }
+198
backend/internal/api/token_refresh.go
··· 1 + package api 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "crypto/ecdsa" 7 + "crypto/x509" 8 + "encoding/pem" 9 + "fmt" 10 + "log" 11 + "net/http" 12 + "os" 13 + "time" 14 + 15 + "margin.at/internal/db" 16 + "margin.at/internal/oauth" 17 + "margin.at/internal/xrpc" 18 + ) 19 + 20 + type TokenRefresher struct { 21 + db *db.DB 22 + privateKey *ecdsa.PrivateKey 23 + baseURL string 24 + } 25 + 26 + func NewTokenRefresher(database *db.DB, privateKey *ecdsa.PrivateKey) *TokenRefresher { 27 + return &TokenRefresher{ 28 + db: database, 29 + privateKey: privateKey, 30 + baseURL: os.Getenv("BASE_URL"), 31 + } 32 + } 33 + 34 + func (tr *TokenRefresher) getOAuthClient(r *http.Request) *oauth.Client { 35 + baseURL := tr.baseURL 36 + if baseURL == "" { 37 + scheme := "http" 38 + if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { 39 + scheme = "https" 40 + } 41 + baseURL = fmt.Sprintf("%s://%s", scheme, r.Host) 42 + } 43 + 44 + if len(baseURL) > 0 && baseURL[len(baseURL)-1] == '/' { 45 + baseURL = baseURL[:len(baseURL)-1] 46 + } 47 + 48 + clientID := baseURL + "/client-metadata.json" 49 + redirectURI := baseURL + "/auth/callback" 50 + 51 + return oauth.NewClient(clientID, redirectURI, tr.privateKey) 52 + } 53 + 54 + type SessionData struct { 55 + DID string 56 + Handle string 57 + AccessToken string 58 + RefreshToken string 59 + DPoPKey *ecdsa.PrivateKey 60 + PDS string 61 + } 62 + 63 + func (tr *TokenRefresher) GetSessionWithAutoRefresh(r *http.Request) (*SessionData, error) { 64 + sessionID := "" 65 + 66 + cookie, err := r.Cookie("margin_session") 67 + if err == nil { 68 + sessionID = cookie.Value 69 + } else { 70 + sessionID = r.Header.Get("X-Session-Token") 71 + } 72 + 73 + if sessionID == "" { 74 + return nil, fmt.Errorf("not authenticated") 75 + } 76 + 77 + did, handle, accessToken, refreshToken, dpopKeyStr, err := tr.db.GetSession(sessionID) 78 + if err != nil { 79 + return nil, fmt.Errorf("session expired") 80 + } 81 + 82 + block, _ := pem.Decode([]byte(dpopKeyStr)) 83 + if block == nil { 84 + return nil, fmt.Errorf("invalid session DPoP key") 85 + } 86 + dpopKey, err := x509.ParseECPrivateKey(block.Bytes) 87 + if err != nil { 88 + return nil, fmt.Errorf("invalid session DPoP key") 89 + } 90 + 91 + pds, err := resolveDIDToPDS(did) 92 + if err != nil { 93 + return nil, fmt.Errorf("failed to resolve PDS") 94 + } 95 + 96 + return &SessionData{ 97 + DID: did, 98 + Handle: handle, 99 + AccessToken: accessToken, 100 + RefreshToken: refreshToken, 101 + DPoPKey: dpopKey, 102 + PDS: pds, 103 + }, nil 104 + } 105 + 106 + 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") 110 + } 111 + 112 + oauthClient := tr.getOAuthClient(r) 113 + ctx := context.Background() 114 + 115 + meta, err := oauthClient.GetAuthServerMetadata(ctx, session.PDS) 116 + if err != nil { 117 + return nil, fmt.Errorf("failed to get auth server metadata: %w", err) 118 + } 119 + 120 + tokenResp, _, err := oauthClient.RefreshToken(meta, session.RefreshToken, session.DPoPKey, "") 121 + if err != nil { 122 + return nil, fmt.Errorf("failed to refresh token: %w", err) 123 + } 124 + 125 + dpopKeyBytes, err := x509.MarshalECPrivateKey(session.DPoPKey) 126 + if err != nil { 127 + return nil, fmt.Errorf("failed to marshal DPoP key: %w", err) 128 + } 129 + dpopKeyPEM := pem.EncodeToMemory(&pem.Block{ 130 + Type: "EC PRIVATE KEY", 131 + Bytes: dpopKeyBytes, 132 + }) 133 + 134 + newRefreshToken := tokenResp.RefreshToken 135 + if newRefreshToken == "" { 136 + newRefreshToken = session.RefreshToken 137 + } 138 + 139 + expiresAt := time.Now().Add(7 * 24 * time.Hour) 140 + if err := tr.db.SaveSession( 141 + cookie.Value, 142 + session.DID, 143 + session.Handle, 144 + tokenResp.AccessToken, 145 + newRefreshToken, 146 + string(dpopKeyPEM), 147 + expiresAt, 148 + ); err != nil { 149 + return nil, fmt.Errorf("failed to save refreshed session: %w", err) 150 + } 151 + 152 + log.Printf("Successfully refreshed token for user %s", session.Handle) 153 + 154 + return &SessionData{ 155 + DID: session.DID, 156 + Handle: session.Handle, 157 + AccessToken: tokenResp.AccessToken, 158 + RefreshToken: newRefreshToken, 159 + DPoPKey: session.DPoPKey, 160 + PDS: session.PDS, 161 + }, nil 162 + } 163 + 164 + func IsTokenExpiredError(err error) bool { 165 + if err == nil { 166 + return false 167 + } 168 + errStr := err.Error() 169 + return bytes.Contains([]byte(errStr), []byte("invalid_token")) && 170 + bytes.Contains([]byte(errStr), []byte("exp")) 171 + } 172 + 173 + func (tr *TokenRefresher) ExecuteWithAutoRefresh( 174 + r *http.Request, 175 + session *SessionData, 176 + fn func(client *xrpc.Client, did string) error, 177 + ) error { 178 + client := xrpc.NewClient(session.PDS, session.AccessToken, session.DPoPKey) 179 + 180 + err := fn(client, session.DID) 181 + if err == nil { 182 + return nil 183 + } 184 + 185 + if !IsTokenExpiredError(err) { 186 + return err 187 + } 188 + 189 + log.Printf("Token expired for user %s, attempting refresh...", session.Handle) 190 + 191 + newSession, refreshErr := tr.RefreshSessionToken(r, session) 192 + if refreshErr != nil { 193 + return fmt.Errorf("original error: %w; refresh failed: %v", err, refreshErr) 194 + } 195 + 196 + client = xrpc.NewClient(newSession.PDS, newSession.AccessToken, newSession.DPoPKey) 197 + return fn(client, newSession.DID) 198 + }
+408
backend/internal/db/db.go
··· 1 + package db 2 + 3 + import ( 4 + "database/sql" 5 + "encoding/json" 6 + "fmt" 7 + "strings" 8 + "time" 9 + 10 + _ "github.com/lib/pq" 11 + _ "github.com/mattn/go-sqlite3" 12 + ) 13 + 14 + type DB struct { 15 + *sql.DB 16 + driver string 17 + } 18 + 19 + type Annotation struct { 20 + URI string `json:"uri"` 21 + AuthorDID string `json:"authorDid"` 22 + Motivation string `json:"motivation,omitempty"` 23 + BodyValue *string `json:"bodyValue,omitempty"` 24 + BodyFormat *string `json:"bodyFormat,omitempty"` 25 + BodyURI *string `json:"bodyUri,omitempty"` 26 + TargetSource string `json:"targetSource"` 27 + TargetHash string `json:"targetHash"` 28 + TargetTitle *string `json:"targetTitle,omitempty"` 29 + SelectorJSON *string `json:"selector,omitempty"` 30 + TagsJSON *string `json:"tags,omitempty"` 31 + CreatedAt time.Time `json:"createdAt"` 32 + IndexedAt time.Time `json:"indexedAt"` 33 + CID *string `json:"cid,omitempty"` 34 + } 35 + 36 + type Selector struct { 37 + Type string `json:"type"` 38 + Exact string `json:"exact,omitempty"` 39 + Prefix string `json:"prefix,omitempty"` 40 + Suffix string `json:"suffix,omitempty"` 41 + Start *int `json:"start,omitempty"` 42 + End *int `json:"end,omitempty"` 43 + Value string `json:"value,omitempty"` 44 + } 45 + 46 + type Highlight struct { 47 + URI string `json:"uri"` 48 + AuthorDID string `json:"authorDid"` 49 + TargetSource string `json:"targetSource"` 50 + TargetHash string `json:"targetHash"` 51 + TargetTitle *string `json:"targetTitle,omitempty"` 52 + SelectorJSON *string `json:"selector,omitempty"` 53 + Color *string `json:"color,omitempty"` 54 + TagsJSON *string `json:"tags,omitempty"` 55 + CreatedAt time.Time `json:"createdAt"` 56 + IndexedAt time.Time `json:"indexedAt"` 57 + CID *string `json:"cid,omitempty"` 58 + } 59 + 60 + type Bookmark struct { 61 + URI string `json:"uri"` 62 + AuthorDID string `json:"authorDid"` 63 + Source string `json:"source"` 64 + SourceHash string `json:"sourceHash"` 65 + Title *string `json:"title,omitempty"` 66 + Description *string `json:"description,omitempty"` 67 + TagsJSON *string `json:"tags,omitempty"` 68 + CreatedAt time.Time `json:"createdAt"` 69 + IndexedAt time.Time `json:"indexedAt"` 70 + CID *string `json:"cid,omitempty"` 71 + } 72 + 73 + type Reply struct { 74 + URI string `json:"uri"` 75 + AuthorDID string `json:"authorDid"` 76 + ParentURI string `json:"parentUri"` 77 + RootURI string `json:"rootUri"` 78 + Text string `json:"text"` 79 + Format *string `json:"format,omitempty"` 80 + CreatedAt time.Time `json:"createdAt"` 81 + IndexedAt time.Time `json:"indexedAt"` 82 + CID *string `json:"cid,omitempty"` 83 + } 84 + 85 + type Like struct { 86 + URI string `json:"uri"` 87 + AuthorDID string `json:"authorDid"` 88 + SubjectURI string `json:"subjectUri"` 89 + CreatedAt time.Time `json:"createdAt"` 90 + IndexedAt time.Time `json:"indexedAt"` 91 + } 92 + 93 + type Collection struct { 94 + URI string `json:"uri"` 95 + AuthorDID string `json:"authorDid"` 96 + Name string `json:"name"` 97 + Description *string `json:"description,omitempty"` 98 + Icon *string `json:"icon,omitempty"` 99 + CreatedAt time.Time `json:"createdAt"` 100 + IndexedAt time.Time `json:"indexedAt"` 101 + } 102 + 103 + type CollectionItem struct { 104 + URI string `json:"uri"` 105 + AuthorDID string `json:"authorDid"` 106 + CollectionURI string `json:"collectionUri"` 107 + AnnotationURI string `json:"annotationUri"` 108 + Position int `json:"position"` 109 + CreatedAt time.Time `json:"createdAt"` 110 + IndexedAt time.Time `json:"indexedAt"` 111 + } 112 + 113 + type Notification struct { 114 + ID int `json:"id"` 115 + RecipientDID string `json:"recipientDid"` 116 + ActorDID string `json:"actorDid"` 117 + Type string `json:"type"` 118 + SubjectURI string `json:"subjectUri"` 119 + CreatedAt time.Time `json:"createdAt"` 120 + ReadAt *time.Time `json:"readAt,omitempty"` 121 + } 122 + 123 + func New(dsn string) (*DB, error) { 124 + driver := "sqlite3" 125 + if strings.HasPrefix(dsn, "postgres://") || strings.HasPrefix(dsn, "postgresql://") { 126 + driver = "postgres" 127 + } 128 + 129 + db, err := sql.Open(driver, dsn) 130 + if err != nil { 131 + return nil, err 132 + } 133 + 134 + if driver == "sqlite3" { 135 + db.SetMaxOpenConns(1) 136 + } else { 137 + db.SetMaxOpenConns(25) 138 + db.SetMaxIdleConns(5) 139 + } 140 + 141 + if err := db.Ping(); err != nil { 142 + return nil, err 143 + } 144 + 145 + return &DB{DB: db, driver: driver}, nil 146 + } 147 + 148 + func (db *DB) Migrate() error { 149 + 150 + dateType := "DATETIME" 151 + if db.driver == "postgres" { 152 + dateType = "TIMESTAMP" 153 + } 154 + 155 + _, err := db.Exec(` 156 + CREATE TABLE IF NOT EXISTS annotations ( 157 + uri TEXT PRIMARY KEY, 158 + author_did TEXT NOT NULL, 159 + motivation TEXT, 160 + body_value TEXT, 161 + body_format TEXT DEFAULT 'text/plain', 162 + body_uri TEXT, 163 + target_source TEXT NOT NULL, 164 + target_hash TEXT NOT NULL, 165 + target_title TEXT, 166 + selector_json TEXT, 167 + tags_json TEXT, 168 + created_at ` + dateType + ` NOT NULL, 169 + indexed_at ` + dateType + ` NOT NULL, 170 + cid TEXT 171 + )`) 172 + if err != nil { 173 + return err 174 + } 175 + 176 + db.Exec(`CREATE INDEX IF NOT EXISTS idx_annotations_target_hash ON annotations(target_hash)`) 177 + db.Exec(`CREATE INDEX IF NOT EXISTS idx_annotations_author_did ON annotations(author_did)`) 178 + db.Exec(`CREATE INDEX IF NOT EXISTS idx_annotations_motivation ON annotations(motivation)`) 179 + db.Exec(`CREATE INDEX IF NOT EXISTS idx_annotations_created_at ON annotations(created_at DESC)`) 180 + 181 + db.Exec(`CREATE TABLE IF NOT EXISTS highlights ( 182 + uri TEXT PRIMARY KEY, 183 + author_did TEXT NOT NULL, 184 + target_source TEXT NOT NULL, 185 + target_hash TEXT NOT NULL, 186 + target_title TEXT, 187 + selector_json TEXT, 188 + color TEXT, 189 + tags_json TEXT, 190 + created_at ` + dateType + ` NOT NULL, 191 + indexed_at ` + dateType + ` NOT NULL, 192 + cid TEXT 193 + )`) 194 + db.Exec(`CREATE INDEX IF NOT EXISTS idx_highlights_target_hash ON highlights(target_hash)`) 195 + db.Exec(`CREATE INDEX IF NOT EXISTS idx_highlights_author_did ON highlights(author_did)`) 196 + 197 + db.Exec(`CREATE TABLE IF NOT EXISTS bookmarks ( 198 + uri TEXT PRIMARY KEY, 199 + author_did TEXT NOT NULL, 200 + source TEXT NOT NULL, 201 + source_hash TEXT NOT NULL, 202 + title TEXT, 203 + description TEXT, 204 + tags_json TEXT, 205 + created_at ` + dateType + ` NOT NULL, 206 + indexed_at ` + dateType + ` NOT NULL, 207 + cid TEXT 208 + )`) 209 + db.Exec(`CREATE INDEX IF NOT EXISTS idx_bookmarks_source_hash ON bookmarks(source_hash)`) 210 + db.Exec(`CREATE INDEX IF NOT EXISTS idx_bookmarks_author_did ON bookmarks(author_did)`) 211 + 212 + db.Exec(`CREATE TABLE IF NOT EXISTS replies ( 213 + uri TEXT PRIMARY KEY, 214 + author_did TEXT NOT NULL, 215 + parent_uri TEXT NOT NULL, 216 + root_uri TEXT NOT NULL, 217 + text TEXT NOT NULL, 218 + format TEXT DEFAULT 'text/plain', 219 + created_at ` + dateType + ` NOT NULL, 220 + indexed_at ` + dateType + ` NOT NULL, 221 + cid TEXT 222 + )`) 223 + db.Exec(`CREATE INDEX IF NOT EXISTS idx_replies_parent_uri ON replies(parent_uri)`) 224 + db.Exec(`CREATE INDEX IF NOT EXISTS idx_replies_root_uri ON replies(root_uri)`) 225 + 226 + db.Exec(`CREATE TABLE IF NOT EXISTS likes ( 227 + uri TEXT PRIMARY KEY, 228 + author_did TEXT NOT NULL, 229 + subject_uri TEXT NOT NULL, 230 + created_at ` + dateType + ` NOT NULL, 231 + indexed_at ` + dateType + ` NOT NULL 232 + )`) 233 + db.Exec(`CREATE INDEX IF NOT EXISTS idx_likes_subject_uri ON likes(subject_uri)`) 234 + db.Exec(`CREATE INDEX IF NOT EXISTS idx_likes_author_did ON likes(author_did)`) 235 + 236 + db.Exec(`CREATE TABLE IF NOT EXISTS collections ( 237 + uri TEXT PRIMARY KEY, 238 + author_did TEXT NOT NULL, 239 + name TEXT NOT NULL, 240 + description TEXT, 241 + icon TEXT, 242 + created_at ` + dateType + ` NOT NULL, 243 + indexed_at ` + dateType + ` NOT NULL 244 + )`) 245 + db.Exec(`CREATE INDEX IF NOT EXISTS idx_collections_author_did ON collections(author_did)`) 246 + 247 + db.Exec(`CREATE TABLE IF NOT EXISTS collection_items ( 248 + uri TEXT PRIMARY KEY, 249 + author_did TEXT NOT NULL, 250 + collection_uri TEXT NOT NULL, 251 + annotation_uri TEXT NOT NULL, 252 + position INTEGER DEFAULT 0, 253 + created_at ` + dateType + ` NOT NULL, 254 + indexed_at ` + dateType + ` NOT NULL 255 + )`) 256 + db.Exec(`CREATE INDEX IF NOT EXISTS idx_collection_items_collection ON collection_items(collection_uri)`) 257 + db.Exec(`CREATE INDEX IF NOT EXISTS idx_collection_items_annotation ON collection_items(annotation_uri)`) 258 + 259 + db.Exec(`CREATE TABLE IF NOT EXISTS sessions ( 260 + id TEXT PRIMARY KEY, 261 + did TEXT NOT NULL, 262 + handle TEXT NOT NULL, 263 + access_token TEXT NOT NULL, 264 + refresh_token TEXT NOT NULL, 265 + dpop_key TEXT, 266 + created_at ` + dateType + ` NOT NULL, 267 + expires_at ` + dateType + ` NOT NULL 268 + )`) 269 + db.Exec(`CREATE INDEX IF NOT EXISTS idx_sessions_did ON sessions(did)`) 270 + 271 + autoInc := "INTEGER PRIMARY KEY AUTOINCREMENT" 272 + if db.driver == "postgres" { 273 + autoInc = "SERIAL PRIMARY KEY" 274 + } 275 + 276 + db.Exec(`CREATE TABLE IF NOT EXISTS edit_history ( 277 + id ` + autoInc + `, 278 + uri TEXT NOT NULL, 279 + record_type TEXT NOT NULL, 280 + previous_content TEXT NOT NULL, 281 + previous_cid TEXT, 282 + edited_at ` + dateType + ` NOT NULL 283 + )`) 284 + db.Exec(`CREATE INDEX IF NOT EXISTS idx_edit_history_uri ON edit_history(uri)`) 285 + db.Exec(`CREATE INDEX IF NOT EXISTS idx_edit_history_edited_at ON edit_history(edited_at DESC)`) 286 + 287 + db.Exec(`CREATE TABLE IF NOT EXISTS notifications ( 288 + id ` + autoInc + `, 289 + recipient_did TEXT NOT NULL, 290 + actor_did TEXT NOT NULL, 291 + type TEXT NOT NULL, 292 + subject_uri TEXT NOT NULL, 293 + created_at ` + dateType + ` NOT NULL, 294 + read_at ` + dateType + ` 295 + )`) 296 + db.Exec(`CREATE INDEX IF NOT EXISTS idx_notifications_recipient ON notifications(recipient_did)`) 297 + db.Exec(`CREATE INDEX IF NOT EXISTS idx_notifications_created_at ON notifications(created_at DESC)`) 298 + 299 + db.runMigrations() 300 + 301 + db.Exec(`CREATE TABLE IF NOT EXISTS cursors ( 302 + id TEXT PRIMARY KEY, 303 + last_cursor INTEGER NOT NULL, 304 + updated_at ` + dateType + ` NOT NULL 305 + )`) 306 + 307 + db.runMigrations() 308 + 309 + return nil 310 + } 311 + 312 + func (db *DB) GetCursor(id string) (int64, error) { 313 + var cursor int64 314 + err := db.QueryRow("SELECT last_cursor FROM cursors WHERE id = $1", id).Scan(&cursor) 315 + if err == sql.ErrNoRows { 316 + return 0, nil 317 + } 318 + if err != nil { 319 + return 0, err 320 + } 321 + return cursor, nil 322 + } 323 + 324 + func (db *DB) SetCursor(id string, cursor int64) error { 325 + query := ` 326 + INSERT INTO cursors (id, last_cursor, updated_at) 327 + VALUES ($1, $2, $3) 328 + ON CONFLICT(id) DO UPDATE SET 329 + last_cursor = EXCLUDED.last_cursor, 330 + updated_at = EXCLUDED.updated_at 331 + ` 332 + _, err := db.Exec(query, id, cursor, time.Now()) 333 + return err 334 + } 335 + 336 + func (db *DB) runMigrations() { 337 + 338 + db.Exec(`ALTER TABLE sessions ADD COLUMN dpop_key TEXT`) 339 + 340 + db.Exec(`ALTER TABLE annotations ADD COLUMN motivation TEXT`) 341 + db.Exec(`ALTER TABLE annotations ADD COLUMN body_value TEXT`) 342 + db.Exec(`ALTER TABLE annotations ADD COLUMN body_format TEXT DEFAULT 'text/plain'`) 343 + db.Exec(`ALTER TABLE annotations ADD COLUMN body_uri TEXT`) 344 + db.Exec(`ALTER TABLE annotations ADD COLUMN target_source TEXT`) 345 + db.Exec(`ALTER TABLE annotations ADD COLUMN target_hash TEXT`) 346 + db.Exec(`ALTER TABLE annotations ADD COLUMN target_title TEXT`) 347 + db.Exec(`ALTER TABLE annotations ADD COLUMN selector_json TEXT`) 348 + db.Exec(`ALTER TABLE annotations ADD COLUMN tags_json TEXT`) 349 + db.Exec(`ALTER TABLE annotations ADD COLUMN cid TEXT`) 350 + 351 + db.Exec(`UPDATE annotations SET target_source = url WHERE target_source IS NULL AND url IS NOT NULL`) 352 + db.Exec(`UPDATE annotations SET target_hash = url_hash WHERE target_hash IS NULL AND url_hash IS NOT NULL`) 353 + db.Exec(`UPDATE annotations SET body_value = text WHERE body_value IS NULL AND text IS NOT NULL`) 354 + db.Exec(`UPDATE annotations SET target_title = title WHERE target_title IS NULL AND title IS NOT NULL`) 355 + db.Exec(`UPDATE annotations SET motivation = 'commenting' WHERE motivation IS NULL`) 356 + } 357 + 358 + func (db *DB) Close() error { 359 + return db.DB.Close() 360 + } 361 + 362 + func (db *DB) Rebind(query string) string { 363 + if db.driver != "postgres" { 364 + return query 365 + } 366 + 367 + if !strings.Contains(query, "?") { 368 + return query 369 + } 370 + 371 + var builder strings.Builder 372 + builder.Grow(len(query) + 20) 373 + 374 + paramCount := 1 375 + for _, r := range query { 376 + if r == '?' { 377 + fmt.Fprintf(&builder, "$%d", paramCount) 378 + paramCount++ 379 + } else { 380 + builder.WriteRune(r) 381 + } 382 + } 383 + return builder.String() 384 + } 385 + 386 + func ParseSelector(selectorJSON *string) (*Selector, error) { 387 + if selectorJSON == nil || *selectorJSON == "" { 388 + return nil, nil 389 + } 390 + var s Selector 391 + err := json.Unmarshal([]byte(*selectorJSON), &s) 392 + if err != nil { 393 + return nil, err 394 + } 395 + return &s, nil 396 + } 397 + 398 + func ParseTags(tagsJSON *string) ([]string, error) { 399 + if tagsJSON == nil || *tagsJSON == "" { 400 + return nil, nil 401 + } 402 + var tags []string 403 + err := json.Unmarshal([]byte(*tagsJSON), &tags) 404 + if err != nil { 405 + return nil, err 406 + } 407 + return tags, nil 408 + }
+769
backend/internal/db/queries.go
··· 1 + package db 2 + 3 + import ( 4 + "crypto/sha256" 5 + "encoding/hex" 6 + "encoding/json" 7 + "fmt" 8 + "net/url" 9 + "strings" 10 + "time" 11 + ) 12 + 13 + func (db *DB) CreateAnnotation(a *Annotation) error { 14 + _, err := db.Exec(db.Rebind(` 15 + INSERT INTO annotations (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) 16 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 17 + ON CONFLICT(uri) DO UPDATE SET 18 + motivation = excluded.motivation, 19 + body_value = excluded.body_value, 20 + body_format = excluded.body_format, 21 + body_uri = excluded.body_uri, 22 + target_title = excluded.target_title, 23 + selector_json = excluded.selector_json, 24 + tags_json = excluded.tags_json, 25 + indexed_at = excluded.indexed_at, 26 + cid = excluded.cid 27 + `), a.URI, a.AuthorDID, a.Motivation, a.BodyValue, a.BodyFormat, a.BodyURI, a.TargetSource, a.TargetHash, a.TargetTitle, a.SelectorJSON, a.TagsJSON, a.CreatedAt, a.IndexedAt, a.CID) 28 + return err 29 + } 30 + 31 + func (db *DB) GetAnnotationByURI(uri string) (*Annotation, error) { 32 + var a Annotation 33 + err := db.QueryRow(db.Rebind(` 34 + 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 35 + FROM annotations 36 + WHERE uri = ? 37 + `), uri).Scan(&a.URI, &a.AuthorDID, &a.Motivation, &a.BodyValue, &a.BodyFormat, &a.BodyURI, &a.TargetSource, &a.TargetHash, &a.TargetTitle, &a.SelectorJSON, &a.TagsJSON, &a.CreatedAt, &a.IndexedAt, &a.CID) 38 + if err != nil { 39 + return nil, err 40 + } 41 + return &a, nil 42 + } 43 + 44 + func (db *DB) GetAnnotationsByTargetHash(targetHash string, limit, offset int) ([]Annotation, error) { 45 + rows, err := db.Query(db.Rebind(` 46 + 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 47 + FROM annotations 48 + WHERE target_hash = ? 49 + ORDER BY created_at DESC 50 + LIMIT ? OFFSET ? 51 + `), targetHash, limit, offset) 52 + if err != nil { 53 + return nil, err 54 + } 55 + defer rows.Close() 56 + 57 + return scanAnnotations(rows) 58 + } 59 + 60 + func (db *DB) GetAnnotationsByAuthor(authorDID string, limit, offset int) ([]Annotation, error) { 61 + rows, err := db.Query(db.Rebind(` 62 + 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 63 + FROM annotations 64 + WHERE author_did = ? 65 + ORDER BY created_at DESC 66 + LIMIT ? OFFSET ? 67 + `), authorDID, limit, offset) 68 + if err != nil { 69 + return nil, err 70 + } 71 + defer rows.Close() 72 + 73 + return scanAnnotations(rows) 74 + } 75 + 76 + func (db *DB) GetAnnotationsByMotivation(motivation string, limit, offset int) ([]Annotation, error) { 77 + rows, err := db.Query(db.Rebind(` 78 + 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 79 + FROM annotations 80 + WHERE motivation = ? 81 + ORDER BY created_at DESC 82 + LIMIT ? OFFSET ? 83 + `), motivation, limit, offset) 84 + if err != nil { 85 + return nil, err 86 + } 87 + defer rows.Close() 88 + 89 + return scanAnnotations(rows) 90 + } 91 + 92 + func (db *DB) GetRecentAnnotations(limit, offset int) ([]Annotation, error) { 93 + rows, err := db.Query(db.Rebind(` 94 + 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 95 + FROM annotations 96 + ORDER BY created_at DESC 97 + LIMIT ? OFFSET ? 98 + `), limit, offset) 99 + if err != nil { 100 + return nil, err 101 + } 102 + defer rows.Close() 103 + 104 + return scanAnnotations(rows) 105 + } 106 + 107 + func (db *DB) DeleteAnnotation(uri string) error { 108 + _, err := db.Exec(db.Rebind(`DELETE FROM annotations WHERE uri = ?`), uri) 109 + return err 110 + } 111 + 112 + func (db *DB) UpdateAnnotation(uri, bodyValue, tagsJSON, cid string) error { 113 + _, err := db.Exec(db.Rebind(` 114 + UPDATE annotations 115 + SET body_value = ?, tags_json = ?, cid = ?, indexed_at = ? 116 + WHERE uri = ? 117 + `), bodyValue, tagsJSON, cid, time.Now(), uri) 118 + return err 119 + } 120 + 121 + func (db *DB) UpdateHighlight(uri, color, tagsJSON, cid string) error { 122 + _, err := db.Exec(db.Rebind(` 123 + UPDATE highlights 124 + SET color = ?, tags_json = ?, cid = ?, indexed_at = ? 125 + WHERE uri = ? 126 + `), color, tagsJSON, cid, time.Now(), uri) 127 + return err 128 + } 129 + 130 + func (db *DB) UpdateBookmark(uri, title, description, tagsJSON, cid string) error { 131 + _, err := db.Exec(db.Rebind(` 132 + UPDATE bookmarks 133 + SET title = ?, description = ?, tags_json = ?, cid = ?, indexed_at = ? 134 + WHERE uri = ? 135 + `), title, description, tagsJSON, cid, time.Now(), uri) 136 + return err 137 + } 138 + 139 + type EditHistory struct { 140 + ID int `json:"id"` 141 + URI string `json:"uri"` 142 + RecordType string `json:"recordType"` 143 + PreviousContent string `json:"previousContent"` 144 + PreviousCID *string `json:"previousCid"` 145 + EditedAt time.Time `json:"editedAt"` 146 + } 147 + 148 + func (db *DB) SaveEditHistory(uri, recordType, previousContent string, previousCID *string) error { 149 + _, err := db.Exec(db.Rebind(` 150 + INSERT INTO edit_history (uri, record_type, previous_content, previous_cid, edited_at) 151 + VALUES (?, ?, ?, ?, ?) 152 + `), uri, recordType, previousContent, previousCID, time.Now()) 153 + return err 154 + } 155 + 156 + func (db *DB) GetEditHistory(uri string) ([]EditHistory, error) { 157 + rows, err := db.Query(db.Rebind(` 158 + SELECT id, uri, record_type, previous_content, previous_cid, edited_at 159 + FROM edit_history 160 + WHERE uri = ? 161 + ORDER BY edited_at DESC 162 + `), uri) 163 + if err != nil { 164 + return nil, err 165 + } 166 + defer rows.Close() 167 + 168 + var history []EditHistory 169 + for rows.Next() { 170 + var h EditHistory 171 + if err := rows.Scan(&h.ID, &h.URI, &h.RecordType, &h.PreviousContent, &h.PreviousCID, &h.EditedAt); err != nil { 172 + return nil, err 173 + } 174 + history = append(history, h) 175 + } 176 + return history, nil 177 + } 178 + 179 + func scanAnnotations(rows interface { 180 + Next() bool 181 + Scan(...interface{}) error 182 + }) ([]Annotation, error) { 183 + var annotations []Annotation 184 + for rows.Next() { 185 + var a Annotation 186 + if err := rows.Scan(&a.URI, &a.AuthorDID, &a.Motivation, &a.BodyValue, &a.BodyFormat, &a.BodyURI, &a.TargetSource, &a.TargetHash, &a.TargetTitle, &a.SelectorJSON, &a.TagsJSON, &a.CreatedAt, &a.IndexedAt, &a.CID); err != nil { 187 + return nil, err 188 + } 189 + annotations = append(annotations, a) 190 + } 191 + return annotations, nil 192 + } 193 + 194 + func (db *DB) CreateHighlight(h *Highlight) error { 195 + _, err := db.Exec(db.Rebind(` 196 + INSERT INTO highlights (uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid) 197 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 198 + ON CONFLICT(uri) DO UPDATE SET 199 + target_title = excluded.target_title, 200 + selector_json = excluded.selector_json, 201 + color = excluded.color, 202 + tags_json = excluded.tags_json, 203 + indexed_at = excluded.indexed_at, 204 + cid = excluded.cid 205 + `), h.URI, h.AuthorDID, h.TargetSource, h.TargetHash, h.TargetTitle, h.SelectorJSON, h.Color, h.TagsJSON, h.CreatedAt, h.IndexedAt, h.CID) 206 + return err 207 + } 208 + 209 + func (db *DB) GetHighlightByURI(uri string) (*Highlight, error) { 210 + var h Highlight 211 + err := db.QueryRow(db.Rebind(` 212 + SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid 213 + FROM highlights 214 + WHERE uri = ? 215 + `), uri).Scan(&h.URI, &h.AuthorDID, &h.TargetSource, &h.TargetHash, &h.TargetTitle, &h.SelectorJSON, &h.Color, &h.TagsJSON, &h.CreatedAt, &h.IndexedAt, &h.CID) 216 + if err != nil { 217 + return nil, err 218 + } 219 + return &h, nil 220 + } 221 + 222 + func (db *DB) GetRecentHighlights(limit, offset int) ([]Highlight, error) { 223 + rows, err := db.Query(db.Rebind(` 224 + SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid 225 + FROM highlights 226 + ORDER BY created_at DESC 227 + LIMIT ? OFFSET ? 228 + `), limit, offset) 229 + if err != nil { 230 + return nil, err 231 + } 232 + defer rows.Close() 233 + 234 + var highlights []Highlight 235 + for rows.Next() { 236 + var h Highlight 237 + 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 { 238 + return nil, err 239 + } 240 + highlights = append(highlights, h) 241 + } 242 + return highlights, nil 243 + } 244 + 245 + func (db *DB) GetRecentBookmarks(limit, offset int) ([]Bookmark, error) { 246 + rows, err := db.Query(db.Rebind(` 247 + SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid 248 + FROM bookmarks 249 + ORDER BY created_at DESC 250 + LIMIT ? OFFSET ? 251 + `), limit, offset) 252 + if err != nil { 253 + return nil, err 254 + } 255 + defer rows.Close() 256 + 257 + var bookmarks []Bookmark 258 + for rows.Next() { 259 + var b Bookmark 260 + 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 { 261 + return nil, err 262 + } 263 + bookmarks = append(bookmarks, b) 264 + } 265 + return bookmarks, nil 266 + } 267 + 268 + func (db *DB) GetHighlightsByTargetHash(targetHash string, limit, offset int) ([]Highlight, error) { 269 + rows, err := db.Query(db.Rebind(` 270 + SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid 271 + FROM highlights 272 + WHERE target_hash = ? 273 + ORDER BY created_at DESC 274 + LIMIT ? OFFSET ? 275 + `), targetHash, limit, offset) 276 + if err != nil { 277 + return nil, err 278 + } 279 + defer rows.Close() 280 + 281 + var highlights []Highlight 282 + for rows.Next() { 283 + var h Highlight 284 + 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 { 285 + return nil, err 286 + } 287 + highlights = append(highlights, h) 288 + } 289 + return highlights, nil 290 + } 291 + 292 + func (db *DB) GetHighlightsByAuthor(authorDID string, limit, offset int) ([]Highlight, error) { 293 + rows, err := db.Query(db.Rebind(` 294 + SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid 295 + FROM highlights 296 + WHERE author_did = ? 297 + ORDER BY created_at DESC 298 + LIMIT ? OFFSET ? 299 + `), authorDID, limit, offset) 300 + if err != nil { 301 + return nil, err 302 + } 303 + defer rows.Close() 304 + 305 + var highlights []Highlight 306 + for rows.Next() { 307 + var h Highlight 308 + 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 { 309 + return nil, err 310 + } 311 + highlights = append(highlights, h) 312 + } 313 + return highlights, nil 314 + } 315 + 316 + func (db *DB) DeleteHighlight(uri string) error { 317 + _, err := db.Exec(db.Rebind(`DELETE FROM highlights WHERE uri = ?`), uri) 318 + return err 319 + } 320 + 321 + func (db *DB) CreateBookmark(b *Bookmark) error { 322 + _, err := db.Exec(db.Rebind(` 323 + INSERT INTO bookmarks (uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid) 324 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 325 + ON CONFLICT(uri) DO UPDATE SET 326 + title = excluded.title, 327 + description = excluded.description, 328 + tags_json = excluded.tags_json, 329 + indexed_at = excluded.indexed_at, 330 + cid = excluded.cid 331 + `), b.URI, b.AuthorDID, b.Source, b.SourceHash, b.Title, b.Description, b.TagsJSON, b.CreatedAt, b.IndexedAt, b.CID) 332 + return err 333 + } 334 + 335 + func (db *DB) GetBookmarkByURI(uri string) (*Bookmark, error) { 336 + var b Bookmark 337 + err := db.QueryRow(db.Rebind(` 338 + SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid 339 + FROM bookmarks 340 + WHERE uri = ? 341 + `), uri).Scan(&b.URI, &b.AuthorDID, &b.Source, &b.SourceHash, &b.Title, &b.Description, &b.TagsJSON, &b.CreatedAt, &b.IndexedAt, &b.CID) 342 + if err != nil { 343 + return nil, err 344 + } 345 + return &b, nil 346 + } 347 + 348 + func (db *DB) GetBookmarksByAuthor(authorDID string, limit, offset int) ([]Bookmark, error) { 349 + rows, err := db.Query(db.Rebind(` 350 + SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid 351 + FROM bookmarks 352 + WHERE author_did = ? 353 + ORDER BY created_at DESC 354 + LIMIT ? OFFSET ? 355 + `), authorDID, limit, offset) 356 + if err != nil { 357 + return nil, err 358 + } 359 + defer rows.Close() 360 + 361 + var bookmarks []Bookmark 362 + for rows.Next() { 363 + var b Bookmark 364 + 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 { 365 + return nil, err 366 + } 367 + bookmarks = append(bookmarks, b) 368 + } 369 + return bookmarks, nil 370 + } 371 + 372 + func (db *DB) DeleteBookmark(uri string) error { 373 + _, err := db.Exec(db.Rebind(`DELETE FROM bookmarks WHERE uri = ?`), uri) 374 + return err 375 + } 376 + 377 + func (db *DB) CreateReply(r *Reply) error { 378 + _, err := db.Exec(db.Rebind(` 379 + INSERT INTO replies (uri, author_did, parent_uri, root_uri, text, format, created_at, indexed_at, cid) 380 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) 381 + ON CONFLICT(uri) DO UPDATE SET 382 + text = excluded.text, 383 + format = excluded.format, 384 + indexed_at = excluded.indexed_at, 385 + cid = excluded.cid 386 + `), r.URI, r.AuthorDID, r.ParentURI, r.RootURI, r.Text, r.Format, r.CreatedAt, r.IndexedAt, r.CID) 387 + return err 388 + } 389 + 390 + func (db *DB) GetRepliesByRoot(rootURI string) ([]Reply, error) { 391 + rows, err := db.Query(db.Rebind(` 392 + SELECT uri, author_did, parent_uri, root_uri, text, format, created_at, indexed_at, cid 393 + FROM replies 394 + WHERE root_uri = ? 395 + ORDER BY created_at ASC 396 + `), rootURI) 397 + if err != nil { 398 + return nil, err 399 + } 400 + defer rows.Close() 401 + 402 + var replies []Reply 403 + for rows.Next() { 404 + var r Reply 405 + if err := rows.Scan(&r.URI, &r.AuthorDID, &r.ParentURI, &r.RootURI, &r.Text, &r.Format, &r.CreatedAt, &r.IndexedAt, &r.CID); err != nil { 406 + return nil, err 407 + } 408 + replies = append(replies, r) 409 + } 410 + return replies, nil 411 + } 412 + 413 + func (db *DB) GetReplyByURI(uri string) (*Reply, error) { 414 + var r Reply 415 + err := db.QueryRow(db.Rebind(` 416 + SELECT uri, author_did, parent_uri, root_uri, text, format, created_at, indexed_at, cid 417 + FROM replies 418 + WHERE uri = ? 419 + `), uri).Scan(&r.URI, &r.AuthorDID, &r.ParentURI, &r.RootURI, &r.Text, &r.Format, &r.CreatedAt, &r.IndexedAt, &r.CID) 420 + if err != nil { 421 + return nil, err 422 + } 423 + return &r, nil 424 + } 425 + 426 + func (db *DB) DeleteReply(uri string) error { 427 + _, err := db.Exec(db.Rebind(`DELETE FROM replies WHERE uri = ?`), uri) 428 + return err 429 + } 430 + 431 + func (db *DB) GetRepliesByAuthor(authorDID string) ([]Reply, error) { 432 + rows, err := db.Query(db.Rebind(` 433 + SELECT uri, author_did, parent_uri, root_uri, text, format, created_at, indexed_at, cid 434 + FROM replies 435 + WHERE author_did = ? 436 + ORDER BY created_at DESC 437 + `), authorDID) 438 + if err != nil { 439 + return nil, err 440 + } 441 + defer rows.Close() 442 + 443 + var replies []Reply 444 + for rows.Next() { 445 + var r Reply 446 + if err := rows.Scan(&r.URI, &r.AuthorDID, &r.ParentURI, &r.RootURI, &r.Text, &r.Format, &r.CreatedAt, &r.IndexedAt, &r.CID); err != nil { 447 + return nil, err 448 + } 449 + replies = append(replies, r) 450 + } 451 + return replies, nil 452 + } 453 + 454 + func (db *DB) AnnotationExists(uri string) bool { 455 + var count int 456 + db.QueryRow(db.Rebind(`SELECT COUNT(*) FROM annotations WHERE uri = ?`), uri).Scan(&count) 457 + return count > 0 458 + } 459 + 460 + func (db *DB) GetOrphanedRepliesByAuthor(authorDID string) ([]Reply, error) { 461 + rows, err := db.Query(db.Rebind(` 462 + SELECT r.uri, r.author_did, r.parent_uri, r.root_uri, r.text, r.format, r.created_at, r.indexed_at, r.cid 463 + FROM replies r 464 + LEFT JOIN annotations a ON r.root_uri = a.uri 465 + WHERE r.author_did = ? AND a.uri IS NULL 466 + `), authorDID) 467 + if err != nil { 468 + return nil, err 469 + } 470 + defer rows.Close() 471 + 472 + var replies []Reply 473 + for rows.Next() { 474 + var r Reply 475 + if err := rows.Scan(&r.URI, &r.AuthorDID, &r.ParentURI, &r.RootURI, &r.Text, &r.Format, &r.CreatedAt, &r.IndexedAt, &r.CID); err != nil { 476 + return nil, err 477 + } 478 + replies = append(replies, r) 479 + } 480 + return replies, nil 481 + } 482 + 483 + func (db *DB) CreateLike(l *Like) error { 484 + _, err := db.Exec(db.Rebind(` 485 + INSERT INTO likes (uri, author_did, subject_uri, created_at, indexed_at) 486 + VALUES (?, ?, ?, ?, ?) 487 + ON CONFLICT(uri) DO NOTHING 488 + `), l.URI, l.AuthorDID, l.SubjectURI, l.CreatedAt, l.IndexedAt) 489 + return err 490 + } 491 + 492 + func (db *DB) DeleteLike(uri string) error { 493 + _, err := db.Exec(db.Rebind(`DELETE FROM likes WHERE uri = ?`), uri) 494 + return err 495 + } 496 + 497 + func (db *DB) GetLikeCount(subjectURI string) (int, error) { 498 + var count int 499 + err := db.QueryRow(db.Rebind(`SELECT COUNT(*) FROM likes WHERE subject_uri = ?`), subjectURI).Scan(&count) 500 + return count, err 501 + } 502 + 503 + func (db *DB) GetLikeByUserAndSubject(userDID, subjectURI string) (*Like, error) { 504 + var like Like 505 + err := db.QueryRow(db.Rebind(` 506 + SELECT uri, author_did, subject_uri, created_at, indexed_at 507 + FROM likes 508 + WHERE author_did = ? AND subject_uri = ? 509 + `), userDID, subjectURI).Scan(&like.URI, &like.AuthorDID, &like.SubjectURI, &like.CreatedAt, &like.IndexedAt) 510 + if err != nil { 511 + return nil, err 512 + } 513 + return &like, nil 514 + } 515 + 516 + func (db *DB) CreateCollection(c *Collection) error { 517 + _, err := db.Exec(db.Rebind(` 518 + INSERT INTO collections (uri, author_did, name, description, icon, created_at, indexed_at) 519 + VALUES (?, ?, ?, ?, ?, ?, ?) 520 + ON CONFLICT(uri) DO UPDATE SET 521 + name = excluded.name, 522 + description = excluded.description, 523 + icon = excluded.icon, 524 + indexed_at = excluded.indexed_at 525 + `), c.URI, c.AuthorDID, c.Name, c.Description, c.Icon, c.CreatedAt, c.IndexedAt) 526 + return err 527 + } 528 + 529 + func (db *DB) GetCollectionsByAuthor(authorDID string) ([]Collection, error) { 530 + rows, err := db.Query(db.Rebind(` 531 + SELECT uri, author_did, name, description, icon, created_at, indexed_at 532 + FROM collections 533 + WHERE author_did = ? 534 + ORDER BY created_at DESC 535 + `), authorDID) 536 + if err != nil { 537 + return nil, err 538 + } 539 + defer rows.Close() 540 + 541 + var collections []Collection 542 + for rows.Next() { 543 + var c Collection 544 + if err := rows.Scan(&c.URI, &c.AuthorDID, &c.Name, &c.Description, &c.Icon, &c.CreatedAt, &c.IndexedAt); err != nil { 545 + return nil, err 546 + } 547 + collections = append(collections, c) 548 + } 549 + return collections, nil 550 + } 551 + 552 + func (db *DB) GetCollectionByURI(uri string) (*Collection, error) { 553 + var c Collection 554 + err := db.QueryRow(db.Rebind(` 555 + SELECT uri, author_did, name, description, icon, created_at, indexed_at 556 + FROM collections 557 + WHERE uri = ? 558 + `), uri).Scan(&c.URI, &c.AuthorDID, &c.Name, &c.Description, &c.Icon, &c.CreatedAt, &c.IndexedAt) 559 + if err != nil { 560 + return nil, err 561 + } 562 + return &c, nil 563 + } 564 + 565 + func (db *DB) DeleteCollection(uri string) error { 566 + 567 + db.Exec(db.Rebind(`DELETE FROM collection_items WHERE collection_uri = ?`), uri) 568 + _, err := db.Exec(db.Rebind(`DELETE FROM collections WHERE uri = ?`), uri) 569 + return err 570 + } 571 + 572 + func (db *DB) AddToCollection(item *CollectionItem) error { 573 + _, err := db.Exec(db.Rebind(` 574 + INSERT INTO collection_items (uri, author_did, collection_uri, annotation_uri, position, created_at, indexed_at) 575 + VALUES (?, ?, ?, ?, ?, ?, ?) 576 + ON CONFLICT(uri) DO UPDATE SET 577 + position = excluded.position, 578 + indexed_at = excluded.indexed_at 579 + `), item.URI, item.AuthorDID, item.CollectionURI, item.AnnotationURI, item.Position, item.CreatedAt, item.IndexedAt) 580 + return err 581 + } 582 + 583 + func (db *DB) GetCollectionItems(collectionURI string) ([]CollectionItem, error) { 584 + rows, err := db.Query(db.Rebind(` 585 + SELECT uri, author_did, collection_uri, annotation_uri, position, created_at, indexed_at 586 + FROM collection_items 587 + WHERE collection_uri = ? 588 + ORDER BY position ASC, created_at DESC 589 + `), collectionURI) 590 + if err != nil { 591 + return nil, err 592 + } 593 + defer rows.Close() 594 + 595 + var items []CollectionItem 596 + for rows.Next() { 597 + var item CollectionItem 598 + if err := rows.Scan(&item.URI, &item.AuthorDID, &item.CollectionURI, &item.AnnotationURI, &item.Position, &item.CreatedAt, &item.IndexedAt); err != nil { 599 + return nil, err 600 + } 601 + items = append(items, item) 602 + } 603 + return items, nil 604 + } 605 + 606 + func (db *DB) RemoveFromCollection(uri string) error { 607 + _, err := db.Exec(db.Rebind(`DELETE FROM collection_items WHERE uri = ?`), uri) 608 + return err 609 + } 610 + 611 + func (db *DB) GetRecentCollectionItems(limit, offset int) ([]CollectionItem, error) { 612 + rows, err := db.Query(db.Rebind(` 613 + SELECT uri, author_did, collection_uri, annotation_uri, position, created_at, indexed_at 614 + FROM collection_items 615 + ORDER BY created_at DESC 616 + LIMIT ? OFFSET ? 617 + `), limit, offset) 618 + if err != nil { 619 + return nil, err 620 + } 621 + defer rows.Close() 622 + 623 + var items []CollectionItem 624 + for rows.Next() { 625 + var item CollectionItem 626 + if err := rows.Scan(&item.URI, &item.AuthorDID, &item.CollectionURI, &item.AnnotationURI, &item.Position, &item.CreatedAt, &item.IndexedAt); err != nil { 627 + return nil, err 628 + } 629 + items = append(items, item) 630 + } 631 + return items, nil 632 + } 633 + 634 + func (db *DB) GetCollectionURIsForAnnotation(annotationURI string) ([]string, error) { 635 + rows, err := db.Query(db.Rebind(` 636 + SELECT collection_uri FROM collection_items WHERE annotation_uri = ? 637 + `), annotationURI) 638 + if err != nil { 639 + return nil, err 640 + } 641 + defer rows.Close() 642 + 643 + var uris []string 644 + for rows.Next() { 645 + var uri string 646 + if err := rows.Scan(&uri); err != nil { 647 + return nil, err 648 + } 649 + uris = append(uris, uri) 650 + } 651 + return uris, nil 652 + } 653 + 654 + func (db *DB) SaveSession(id, did, handle, accessToken, refreshToken, dpopKey string, expiresAt time.Time) error { 655 + _, err := db.Exec(db.Rebind(` 656 + INSERT INTO sessions (id, did, handle, access_token, refresh_token, dpop_key, created_at, expires_at) 657 + VALUES (?, ?, ?, ?, ?, ?, ?, ?) 658 + ON CONFLICT(id) DO UPDATE SET 659 + access_token = excluded.access_token, 660 + refresh_token = excluded.refresh_token, 661 + dpop_key = excluded.dpop_key, 662 + expires_at = excluded.expires_at 663 + `), id, did, handle, accessToken, refreshToken, dpopKey, time.Now(), expiresAt) 664 + return err 665 + } 666 + 667 + func (db *DB) GetSession(id string) (did, handle, accessToken, refreshToken, dpopKey string, err error) { 668 + err = db.QueryRow(db.Rebind(` 669 + SELECT did, handle, access_token, refresh_token, COALESCE(dpop_key, '') 670 + FROM sessions 671 + WHERE id = ? AND expires_at > ? 672 + `), id, time.Now()).Scan(&did, &handle, &accessToken, &refreshToken, &dpopKey) 673 + return 674 + } 675 + 676 + func (db *DB) DeleteSession(id string) error { 677 + _, err := db.Exec(db.Rebind(`DELETE FROM sessions WHERE id = ?`), id) 678 + return err 679 + } 680 + 681 + func HashURL(rawURL string) string { 682 + parsed, err := url.Parse(rawURL) 683 + if err != nil { 684 + return hashString(rawURL) 685 + } 686 + 687 + normalized := strings.ToLower(parsed.Host) + parsed.Path 688 + normalized = strings.TrimSuffix(normalized, "/") 689 + 690 + return hashString(normalized) 691 + } 692 + 693 + func hashString(s string) string { 694 + h := sha256.New() 695 + h.Write([]byte(s)) 696 + return hex.EncodeToString(h.Sum(nil)) 697 + } 698 + 699 + func ToJSON(v interface{}) string { 700 + b, _ := json.Marshal(v) 701 + return string(b) 702 + } 703 + 704 + func (db *DB) CreateNotification(n *Notification) error { 705 + _, err := db.Exec(db.Rebind(` 706 + INSERT INTO notifications (recipient_did, actor_did, type, subject_uri, created_at) 707 + VALUES (?, ?, ?, ?, ?) 708 + `), n.RecipientDID, n.ActorDID, n.Type, n.SubjectURI, n.CreatedAt) 709 + return err 710 + } 711 + 712 + func (db *DB) GetNotifications(recipientDID string, limit, offset int) ([]Notification, error) { 713 + rows, err := db.Query(db.Rebind(` 714 + SELECT id, recipient_did, actor_did, type, subject_uri, created_at, read_at 715 + FROM notifications 716 + WHERE recipient_did = ? 717 + ORDER BY created_at DESC 718 + LIMIT ? OFFSET ? 719 + `), recipientDID, limit, offset) 720 + if err != nil { 721 + return nil, err 722 + } 723 + defer rows.Close() 724 + 725 + var notifications []Notification 726 + for rows.Next() { 727 + var n Notification 728 + if err := rows.Scan(&n.ID, &n.RecipientDID, &n.ActorDID, &n.Type, &n.SubjectURI, &n.CreatedAt, &n.ReadAt); err != nil { 729 + continue 730 + } 731 + notifications = append(notifications, n) 732 + } 733 + return notifications, nil 734 + } 735 + 736 + func (db *DB) GetUnreadNotificationCount(recipientDID string) (int, error) { 737 + var count int 738 + err := db.QueryRow(db.Rebind(` 739 + SELECT COUNT(*) FROM notifications WHERE recipient_did = ? AND read_at IS NULL 740 + `), recipientDID).Scan(&count) 741 + return count, err 742 + } 743 + 744 + func (db *DB) MarkNotificationsRead(recipientDID string) error { 745 + _, err := db.Exec(db.Rebind(` 746 + UPDATE notifications SET read_at = ? WHERE recipient_did = ? AND read_at IS NULL 747 + `), time.Now(), recipientDID) 748 + return err 749 + } 750 + 751 + func (db *DB) GetAuthorByURI(uri string) (string, error) { 752 + var authorDID string 753 + err := db.QueryRow(db.Rebind(`SELECT author_did FROM annotations WHERE uri = ?`), uri).Scan(&authorDID) 754 + if err == nil { 755 + return authorDID, nil 756 + } 757 + 758 + err = db.QueryRow(db.Rebind(`SELECT author_did FROM highlights WHERE uri = ?`), uri).Scan(&authorDID) 759 + if err == nil { 760 + return authorDID, nil 761 + } 762 + 763 + err = db.QueryRow(db.Rebind(`SELECT author_did FROM bookmarks WHERE uri = ?`), uri).Scan(&authorDID) 764 + if err == nil { 765 + return authorDID, nil 766 + } 767 + 768 + return "", fmt.Errorf("uri not found or no author") 769 + }
+570
backend/internal/firehose/ingester.go
··· 1 + package firehose 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "fmt" 8 + "io" 9 + "log" 10 + "net/http" 11 + "strings" 12 + "time" 13 + 14 + "margin.at/internal/db" 15 + ) 16 + 17 + const ( 18 + CollectionAnnotation = "at.margin.annotation" 19 + CollectionHighlight = "at.margin.highlight" 20 + CollectionBookmark = "at.margin.bookmark" 21 + CollectionReply = "at.margin.reply" 22 + CollectionLike = "at.margin.like" 23 + CollectionCollection = "at.margin.collection" 24 + CollectionCollectionItem = "at.margin.collectionItem" 25 + ) 26 + 27 + var RelayURL = "wss://bsky.network/xrpc/com.atproto.sync.subscribeRepos" 28 + 29 + type Ingester struct { 30 + db *db.DB 31 + cancel context.CancelFunc 32 + } 33 + 34 + func NewIngester(database *db.DB) *Ingester { 35 + return &Ingester{db: database} 36 + } 37 + 38 + func (i *Ingester) Start(ctx context.Context) error { 39 + ctx, cancel := context.WithCancel(ctx) 40 + i.cancel = cancel 41 + 42 + go i.run(ctx) 43 + return nil 44 + } 45 + 46 + func (i *Ingester) Stop() { 47 + if i.cancel != nil { 48 + i.cancel() 49 + } 50 + } 51 + 52 + func (i *Ingester) run(ctx context.Context) { 53 + for { 54 + select { 55 + case <-ctx.Done(): 56 + return 57 + default: 58 + if err := i.subscribe(ctx); err != nil { 59 + if ctx.Err() != nil { 60 + return 61 + } 62 + time.Sleep(30 * time.Second) 63 + } 64 + } 65 + } 66 + } 67 + 68 + func (i *Ingester) subscribe(ctx context.Context) error { 69 + cursor := i.getLastCursor() 70 + 71 + url := RelayURL 72 + if cursor > 0 { 73 + url = fmt.Sprintf("%s?cursor=%d", RelayURL, cursor) 74 + } 75 + 76 + req, err := http.NewRequestWithContext(ctx, "GET", strings.Replace(url, "wss://", "https://", 1), nil) 77 + if err != nil { 78 + return err 79 + } 80 + 81 + resp, err := http.DefaultClient.Do(req) 82 + if err != nil { 83 + return err 84 + } 85 + defer resp.Body.Close() 86 + 87 + if resp.StatusCode != 200 { 88 + body, _ := io.ReadAll(resp.Body) 89 + return fmt.Errorf("firehose returned %d: %s", resp.StatusCode, string(body)) 90 + } 91 + 92 + decoder := json.NewDecoder(resp.Body) 93 + for { 94 + select { 95 + case <-ctx.Done(): 96 + return nil 97 + default: 98 + } 99 + 100 + var event FirehoseEvent 101 + if err := decoder.Decode(&event); err != nil { 102 + if err == io.EOF { 103 + return nil 104 + } 105 + return err 106 + } 107 + 108 + i.handleEvent(&event) 109 + } 110 + } 111 + 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 + } 120 + 121 + func (i *Ingester) handleEvent(event *FirehoseEvent) { 122 + uri := fmt.Sprintf("at://%s/%s/%s", event.Repo, event.Collection, event.Rkey) 123 + 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) 131 + } 132 + case CollectionHighlight: 133 + switch event.Operation { 134 + case "create", "update": 135 + i.handleHighlight(event) 136 + case "delete": 137 + i.db.DeleteHighlight(uri) 138 + } 139 + case CollectionBookmark: 140 + switch event.Operation { 141 + case "create", "update": 142 + i.handleBookmark(event) 143 + case "delete": 144 + i.db.DeleteBookmark(uri) 145 + } 146 + case CollectionReply: 147 + switch event.Operation { 148 + case "create", "update": 149 + i.handleReply(event) 150 + case "delete": 151 + i.db.DeleteReply(uri) 152 + } 153 + case CollectionLike: 154 + switch event.Operation { 155 + case "create": 156 + i.handleLike(event) 157 + case "delete": 158 + i.db.DeleteLike(uri) 159 + } 160 + case CollectionCollection: 161 + switch event.Operation { 162 + case "create", "update": 163 + i.handleCollection(event) 164 + case "delete": 165 + i.db.DeleteCollection(uri) 166 + } 167 + case CollectionCollectionItem: 168 + switch event.Operation { 169 + case "create", "update": 170 + i.handleCollectionItem(event) 171 + case "delete": 172 + i.db.RemoveFromCollection(uri) 173 + } 174 + } 175 + 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) 179 + } 180 + } 181 + } 182 + 183 + func (i *Ingester) handleAnnotation(event *FirehoseEvent) { 184 + 185 + var record struct { 186 + Motivation string `json:"motivation"` 187 + Body struct { 188 + Value string `json:"value"` 189 + Format string `json:"format"` 190 + URI string `json:"uri"` 191 + } `json:"body"` 192 + Target struct { 193 + Source string `json:"source"` 194 + SourceHash string `json:"sourceHash"` 195 + Title string `json:"title"` 196 + Selector json.RawMessage `json:"selector"` 197 + } `json:"target"` 198 + Tags []string `json:"tags"` 199 + CreatedAt string `json:"createdAt"` 200 + 201 + URL string `json:"url"` 202 + URLHash string `json:"urlHash"` 203 + Text string `json:"text"` 204 + Quote string `json:"quote"` 205 + Title string `json:"title"` 206 + } 207 + 208 + if err := json.NewDecoder(bytes.NewReader(event.Record)).Decode(&record); err != nil { 209 + return 210 + } 211 + 212 + uri := fmt.Sprintf("at://%s/%s/%s", event.Repo, event.Collection, event.Rkey) 213 + 214 + createdAt, err := time.Parse(time.RFC3339, record.CreatedAt) 215 + if err != nil { 216 + createdAt = time.Now() 217 + } 218 + 219 + targetSource := record.Target.Source 220 + if targetSource == "" { 221 + targetSource = record.URL 222 + } 223 + 224 + targetHash := record.Target.SourceHash 225 + if targetHash == "" { 226 + targetHash = record.URLHash 227 + } 228 + if targetHash == "" && targetSource != "" { 229 + targetHash = db.HashURL(targetSource) 230 + } 231 + 232 + bodyValue := record.Body.Value 233 + if bodyValue == "" { 234 + bodyValue = record.Text 235 + } 236 + 237 + targetTitle := record.Target.Title 238 + if targetTitle == "" { 239 + targetTitle = record.Title 240 + } 241 + 242 + motivation := record.Motivation 243 + if motivation == "" { 244 + motivation = "commenting" 245 + } 246 + 247 + var bodyValuePtr, bodyFormatPtr, bodyURIPtr, targetTitlePtr, selectorJSONPtr, tagsJSONPtr *string 248 + if bodyValue != "" { 249 + bodyValuePtr = &bodyValue 250 + } 251 + if record.Body.Format != "" { 252 + bodyFormatPtr = &record.Body.Format 253 + } 254 + if record.Body.URI != "" { 255 + bodyURIPtr = &record.Body.URI 256 + } 257 + if targetTitle != "" { 258 + targetTitlePtr = &targetTitle 259 + } 260 + if len(record.Target.Selector) > 0 && string(record.Target.Selector) != "null" { 261 + selectorStr := string(record.Target.Selector) 262 + selectorJSONPtr = &selectorStr 263 + } 264 + if len(record.Tags) > 0 { 265 + tagsBytes, _ := json.Marshal(record.Tags) 266 + tagsStr := string(tagsBytes) 267 + tagsJSONPtr = &tagsStr 268 + } 269 + 270 + annotation := &db.Annotation{ 271 + URI: uri, 272 + AuthorDID: event.Repo, 273 + Motivation: motivation, 274 + BodyValue: bodyValuePtr, 275 + BodyFormat: bodyFormatPtr, 276 + BodyURI: bodyURIPtr, 277 + TargetSource: targetSource, 278 + TargetHash: targetHash, 279 + TargetTitle: targetTitlePtr, 280 + SelectorJSON: selectorJSONPtr, 281 + TagsJSON: tagsJSONPtr, 282 + CreatedAt: createdAt, 283 + IndexedAt: time.Now(), 284 + } 285 + 286 + if err := i.db.CreateAnnotation(annotation); err != nil { 287 + log.Printf("Failed to index annotation: %v", err) 288 + } else { 289 + log.Printf("Indexed annotation from %s on %s", event.Repo, targetSource) 290 + } 291 + } 292 + 293 + func (i *Ingester) handleReply(event *FirehoseEvent) { 294 + var record struct { 295 + Parent struct { 296 + URI string `json:"uri"` 297 + } `json:"parent"` 298 + Root struct { 299 + URI string `json:"uri"` 300 + } `json:"root"` 301 + Text string `json:"text"` 302 + CreatedAt string `json:"createdAt"` 303 + } 304 + 305 + if err := json.NewDecoder(bytes.NewReader(event.Record)).Decode(&record); err != nil { 306 + return 307 + } 308 + 309 + uri := fmt.Sprintf("at://%s/%s/%s", event.Repo, event.Collection, event.Rkey) 310 + 311 + createdAt, err := time.Parse(time.RFC3339, record.CreatedAt) 312 + if err != nil { 313 + createdAt = time.Now() 314 + } 315 + 316 + reply := &db.Reply{ 317 + URI: uri, 318 + AuthorDID: event.Repo, 319 + ParentURI: record.Parent.URI, 320 + RootURI: record.Root.URI, 321 + Text: record.Text, 322 + CreatedAt: createdAt, 323 + IndexedAt: time.Now(), 324 + } 325 + 326 + i.db.CreateReply(reply) 327 + } 328 + 329 + func (i *Ingester) handleLike(event *FirehoseEvent) { 330 + var record struct { 331 + Subject struct { 332 + URI string `json:"uri"` 333 + } `json:"subject"` 334 + CreatedAt string `json:"createdAt"` 335 + } 336 + 337 + if err := json.NewDecoder(bytes.NewReader(event.Record)).Decode(&record); err != nil { 338 + return 339 + } 340 + 341 + uri := fmt.Sprintf("at://%s/%s/%s", event.Repo, event.Collection, event.Rkey) 342 + 343 + createdAt, err := time.Parse(time.RFC3339, record.CreatedAt) 344 + if err != nil { 345 + createdAt = time.Now() 346 + } 347 + 348 + like := &db.Like{ 349 + URI: uri, 350 + AuthorDID: event.Repo, 351 + SubjectURI: record.Subject.URI, 352 + CreatedAt: createdAt, 353 + IndexedAt: time.Now(), 354 + } 355 + 356 + i.db.CreateLike(like) 357 + } 358 + 359 + func (i *Ingester) handleHighlight(event *FirehoseEvent) { 360 + var record struct { 361 + Target struct { 362 + Source string `json:"source"` 363 + SourceHash string `json:"sourceHash"` 364 + Title string `json:"title"` 365 + Selector json.RawMessage `json:"selector"` 366 + } `json:"target"` 367 + Color string `json:"color"` 368 + Tags []string `json:"tags"` 369 + CreatedAt string `json:"createdAt"` 370 + } 371 + 372 + if err := json.NewDecoder(bytes.NewReader(event.Record)).Decode(&record); err != nil { 373 + return 374 + } 375 + 376 + uri := fmt.Sprintf("at://%s/%s/%s", event.Repo, event.Collection, event.Rkey) 377 + 378 + createdAt, err := time.Parse(time.RFC3339, record.CreatedAt) 379 + if err != nil { 380 + createdAt = time.Now() 381 + } 382 + 383 + targetHash := record.Target.SourceHash 384 + if targetHash == "" && record.Target.Source != "" { 385 + targetHash = db.HashURL(record.Target.Source) 386 + } 387 + 388 + var titlePtr, selectorJSONPtr, colorPtr, tagsJSONPtr *string 389 + if record.Target.Title != "" { 390 + titlePtr = &record.Target.Title 391 + } 392 + if len(record.Target.Selector) > 0 && string(record.Target.Selector) != "null" { 393 + selectorStr := string(record.Target.Selector) 394 + selectorJSONPtr = &selectorStr 395 + } 396 + if record.Color != "" { 397 + colorPtr = &record.Color 398 + } 399 + if len(record.Tags) > 0 { 400 + tagsBytes, _ := json.Marshal(record.Tags) 401 + tagsStr := string(tagsBytes) 402 + tagsJSONPtr = &tagsStr 403 + } 404 + 405 + highlight := &db.Highlight{ 406 + URI: uri, 407 + AuthorDID: event.Repo, 408 + TargetSource: record.Target.Source, 409 + TargetHash: targetHash, 410 + TargetTitle: titlePtr, 411 + SelectorJSON: selectorJSONPtr, 412 + Color: colorPtr, 413 + TagsJSON: tagsJSONPtr, 414 + CreatedAt: createdAt, 415 + IndexedAt: time.Now(), 416 + } 417 + 418 + if err := i.db.CreateHighlight(highlight); err != nil { 419 + log.Printf("Failed to index highlight: %v", err) 420 + } else { 421 + log.Printf("Indexed highlight from %s on %s", event.Repo, record.Target.Source) 422 + } 423 + } 424 + 425 + func (i *Ingester) handleBookmark(event *FirehoseEvent) { 426 + var record struct { 427 + Source string `json:"source"` 428 + SourceHash string `json:"sourceHash"` 429 + Title string `json:"title"` 430 + Description string `json:"description"` 431 + Tags []string `json:"tags"` 432 + CreatedAt string `json:"createdAt"` 433 + } 434 + 435 + if err := json.NewDecoder(bytes.NewReader(event.Record)).Decode(&record); err != nil { 436 + return 437 + } 438 + 439 + uri := fmt.Sprintf("at://%s/%s/%s", event.Repo, event.Collection, event.Rkey) 440 + 441 + createdAt, err := time.Parse(time.RFC3339, record.CreatedAt) 442 + if err != nil { 443 + createdAt = time.Now() 444 + } 445 + 446 + sourceHash := record.SourceHash 447 + if sourceHash == "" && record.Source != "" { 448 + sourceHash = db.HashURL(record.Source) 449 + } 450 + 451 + var titlePtr, descPtr, tagsJSONPtr *string 452 + if record.Title != "" { 453 + titlePtr = &record.Title 454 + } 455 + if record.Description != "" { 456 + descPtr = &record.Description 457 + } 458 + if len(record.Tags) > 0 { 459 + tagsBytes, _ := json.Marshal(record.Tags) 460 + tagsStr := string(tagsBytes) 461 + tagsJSONPtr = &tagsStr 462 + } 463 + 464 + bookmark := &db.Bookmark{ 465 + URI: uri, 466 + AuthorDID: event.Repo, 467 + Source: record.Source, 468 + SourceHash: sourceHash, 469 + Title: titlePtr, 470 + Description: descPtr, 471 + TagsJSON: tagsJSONPtr, 472 + CreatedAt: createdAt, 473 + IndexedAt: time.Now(), 474 + } 475 + 476 + if err := i.db.CreateBookmark(bookmark); err != nil { 477 + log.Printf("Failed to index bookmark: %v", err) 478 + } else { 479 + log.Printf("Indexed bookmark from %s: %s", event.Repo, record.Source) 480 + } 481 + } 482 + 483 + func (i *Ingester) handleCollection(event *FirehoseEvent) { 484 + var record struct { 485 + Name string `json:"name"` 486 + Description string `json:"description"` 487 + Icon string `json:"icon"` 488 + CreatedAt string `json:"createdAt"` 489 + } 490 + 491 + if err := json.NewDecoder(bytes.NewReader(event.Record)).Decode(&record); err != nil { 492 + return 493 + } 494 + 495 + uri := fmt.Sprintf("at://%s/%s/%s", event.Repo, event.Collection, event.Rkey) 496 + 497 + createdAt, err := time.Parse(time.RFC3339, record.CreatedAt) 498 + if err != nil { 499 + createdAt = time.Now() 500 + } 501 + 502 + var descPtr, iconPtr *string 503 + if record.Description != "" { 504 + descPtr = &record.Description 505 + } 506 + if record.Icon != "" { 507 + iconPtr = &record.Icon 508 + } 509 + 510 + collection := &db.Collection{ 511 + URI: uri, 512 + AuthorDID: event.Repo, 513 + Name: record.Name, 514 + Description: descPtr, 515 + Icon: iconPtr, 516 + CreatedAt: createdAt, 517 + IndexedAt: time.Now(), 518 + } 519 + 520 + if err := i.db.CreateCollection(collection); err != nil { 521 + log.Printf("Failed to index collection: %v", err) 522 + } else { 523 + log.Printf("Indexed collection from %s: %s", event.Repo, record.Name) 524 + } 525 + } 526 + 527 + func (i *Ingester) handleCollectionItem(event *FirehoseEvent) { 528 + var record struct { 529 + Collection string `json:"collection"` 530 + Annotation string `json:"annotation"` 531 + Position int `json:"position"` 532 + CreatedAt string `json:"createdAt"` 533 + } 534 + 535 + if err := json.NewDecoder(bytes.NewReader(event.Record)).Decode(&record); err != nil { 536 + return 537 + } 538 + 539 + uri := fmt.Sprintf("at://%s/%s/%s", event.Repo, event.Collection, event.Rkey) 540 + 541 + createdAt, err := time.Parse(time.RFC3339, record.CreatedAt) 542 + if err != nil { 543 + createdAt = time.Now() 544 + } 545 + 546 + item := &db.CollectionItem{ 547 + URI: uri, 548 + AuthorDID: event.Repo, 549 + CollectionURI: record.Collection, 550 + AnnotationURI: record.Annotation, 551 + Position: record.Position, 552 + CreatedAt: createdAt, 553 + IndexedAt: time.Now(), 554 + } 555 + 556 + if err := i.db.AddToCollection(item); err != nil { 557 + log.Printf("Failed to index collection item: %v", err) 558 + } else { 559 + log.Printf("Indexed collection item from %s", event.Repo) 560 + } 561 + } 562 + 563 + func (i *Ingester) getLastCursor() int64 { 564 + cursor, err := i.db.GetCursor("firehose_cursor") 565 + if err != nil { 566 + log.Printf("Failed to get last cursor from DB: %v", err) 567 + return 0 568 + } 569 + return cursor 570 + }
+457
backend/internal/oauth/client.go
··· 1 + package oauth 2 + 3 + import ( 4 + "context" 5 + "crypto" 6 + "crypto/ecdsa" 7 + "crypto/elliptic" 8 + "crypto/rand" 9 + "crypto/sha256" 10 + "encoding/base64" 11 + "encoding/json" 12 + "fmt" 13 + "io" 14 + "net/http" 15 + "net/url" 16 + "strings" 17 + "time" 18 + 19 + "github.com/go-jose/go-jose/v4" 20 + "github.com/go-jose/go-jose/v4/jwt" 21 + ) 22 + 23 + type Client struct { 24 + ClientID string 25 + RedirectURI string 26 + PrivateKey *ecdsa.PrivateKey 27 + PublicJWK jose.JSONWebKey 28 + } 29 + 30 + type AuthServerMetadata struct { 31 + Issuer string `json:"issuer"` 32 + AuthorizationEndpoint string `json:"authorization_endpoint"` 33 + TokenEndpoint string `json:"token_endpoint"` 34 + PushedAuthorizationRequestEndpoint string `json:"pushed_authorization_request_endpoint"` 35 + ScopesSupported []string `json:"scopes_supported"` 36 + ResponseTypesSupported []string `json:"response_types_supported"` 37 + DPoPSigningAlgValuesSupported []string `json:"dpop_signing_alg_values_supported"` 38 + } 39 + 40 + type PARResponse struct { 41 + RequestURI string `json:"request_uri"` 42 + ExpiresIn int `json:"expires_in"` 43 + } 44 + 45 + type TokenResponse struct { 46 + AccessToken string `json:"access_token"` 47 + TokenType string `json:"token_type"` 48 + ExpiresIn int `json:"expires_in"` 49 + RefreshToken string `json:"refresh_token"` 50 + Scope string `json:"scope"` 51 + Sub string `json:"sub"` 52 + } 53 + 54 + type PendingAuth struct { 55 + State string 56 + DID string 57 + Handle string 58 + PDS string 59 + AuthServer string 60 + Issuer string 61 + PKCEVerifier string 62 + DPoPKey *ecdsa.PrivateKey 63 + DPoPNonce string 64 + CreatedAt time.Time 65 + } 66 + 67 + func NewClient(clientID, redirectURI string, privateKey *ecdsa.PrivateKey) *Client { 68 + publicJWK := jose.JSONWebKey{ 69 + Key: &privateKey.PublicKey, 70 + Algorithm: string(jose.ES256), 71 + Use: "sig", 72 + } 73 + thumbprint, _ := publicJWK.Thumbprint(crypto.SHA256) 74 + publicJWK.KeyID = base64.RawURLEncoding.EncodeToString(thumbprint) 75 + 76 + return &Client{ 77 + ClientID: clientID, 78 + RedirectURI: redirectURI, 79 + PrivateKey: privateKey, 80 + PublicJWK: publicJWK, 81 + } 82 + } 83 + 84 + func GenerateKey() (*ecdsa.PrivateKey, error) { 85 + return ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 86 + } 87 + 88 + func (c *Client) ResolveHandle(ctx context.Context, handle string) (string, error) { 89 + url := fmt.Sprintf("https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=%s", url.QueryEscape(handle)) 90 + resp, err := http.Get(url) 91 + if err != nil { 92 + return "", err 93 + } 94 + defer resp.Body.Close() 95 + 96 + if resp.StatusCode != 200 { 97 + return "", fmt.Errorf("failed to resolve handle: %d", resp.StatusCode) 98 + } 99 + 100 + var result struct { 101 + DID string `json:"did"` 102 + } 103 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 104 + return "", err 105 + } 106 + return result.DID, nil 107 + } 108 + 109 + func (c *Client) ResolveDIDToPDS(ctx context.Context, did string) (string, error) { 110 + var docURL string 111 + if strings.HasPrefix(did, "did:plc:") { 112 + docURL = fmt.Sprintf("https://plc.directory/%s", did) 113 + } else if strings.HasPrefix(did, "did:web:") { 114 + domain := strings.TrimPrefix(did, "did:web:") 115 + docURL = fmt.Sprintf("https://%s/.well-known/did.json", domain) 116 + } else { 117 + return "", fmt.Errorf("unsupported DID method: %s", did) 118 + } 119 + 120 + resp, err := http.Get(docURL) 121 + if err != nil { 122 + return "", err 123 + } 124 + defer resp.Body.Close() 125 + 126 + var doc struct { 127 + Service []struct { 128 + ID string `json:"id"` 129 + Type string `json:"type"` 130 + ServiceEndpoint string `json:"serviceEndpoint"` 131 + } `json:"service"` 132 + } 133 + if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil { 134 + return "", err 135 + } 136 + 137 + for _, svc := range doc.Service { 138 + if svc.Type == "AtprotoPersonalDataServer" { 139 + return svc.ServiceEndpoint, nil 140 + } 141 + } 142 + return "", fmt.Errorf("no PDS found in DID document") 143 + } 144 + 145 + func (c *Client) GetAuthServerMetadata(ctx context.Context, pds string) (*AuthServerMetadata, error) { 146 + resourceURL := fmt.Sprintf("%s/.well-known/oauth-protected-resource", strings.TrimSuffix(pds, "/")) 147 + resp, err := http.Get(resourceURL) 148 + if err != nil { 149 + return nil, err 150 + } 151 + defer resp.Body.Close() 152 + 153 + var resource struct { 154 + AuthorizationServers []string `json:"authorization_servers"` 155 + } 156 + if err := json.NewDecoder(resp.Body).Decode(&resource); err != nil { 157 + return nil, err 158 + } 159 + 160 + if len(resource.AuthorizationServers) == 0 { 161 + return nil, fmt.Errorf("no authorization servers found") 162 + } 163 + 164 + authServerURL := resource.AuthorizationServers[0] 165 + metaURL := fmt.Sprintf("%s/.well-known/oauth-authorization-server", strings.TrimSuffix(authServerURL, "/")) 166 + 167 + metaResp, err := http.Get(metaURL) 168 + if err != nil { 169 + return nil, err 170 + } 171 + defer metaResp.Body.Close() 172 + 173 + var meta AuthServerMetadata 174 + if err := json.NewDecoder(metaResp.Body).Decode(&meta); err != nil { 175 + return nil, err 176 + } 177 + return &meta, nil 178 + } 179 + 180 + func (c *Client) GeneratePKCE() (verifier, challenge string) { 181 + b := make([]byte, 32) 182 + rand.Read(b) 183 + verifier = base64.RawURLEncoding.EncodeToString(b) 184 + 185 + h := sha256.Sum256([]byte(verifier)) 186 + challenge = base64.RawURLEncoding.EncodeToString(h[:]) 187 + return 188 + } 189 + 190 + func (c *Client) GenerateDPoPKey() (*ecdsa.PrivateKey, error) { 191 + return ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 192 + } 193 + 194 + func (c *Client) CreateDPoPProof(dpopKey *ecdsa.PrivateKey, method, uri, nonce, ath string) (string, error) { 195 + now := time.Now() 196 + jti := make([]byte, 16) 197 + rand.Read(jti) 198 + 199 + publicJWK := jose.JSONWebKey{ 200 + Key: &dpopKey.PublicKey, 201 + Algorithm: string(jose.ES256), 202 + } 203 + 204 + claims := map[string]interface{}{ 205 + "jti": base64.RawURLEncoding.EncodeToString(jti), 206 + "htm": method, 207 + "htu": uri, 208 + "iat": now.Unix(), 209 + "exp": now.Add(5 * time.Minute).Unix(), 210 + } 211 + if nonce != "" { 212 + claims["nonce"] = nonce 213 + } 214 + if ath != "" { 215 + claims["ath"] = ath 216 + } 217 + 218 + signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.ES256, Key: dpopKey}, &jose.SignerOptions{ 219 + ExtraHeaders: map[jose.HeaderKey]interface{}{ 220 + "typ": "dpop+jwt", 221 + "jwk": publicJWK, 222 + }, 223 + }) 224 + if err != nil { 225 + return "", err 226 + } 227 + 228 + claimsBytes, _ := json.Marshal(claims) 229 + sig, err := signer.Sign(claimsBytes) 230 + if err != nil { 231 + return "", err 232 + } 233 + 234 + return sig.CompactSerialize() 235 + } 236 + 237 + func (c *Client) CreateClientAssertion(issuer string) (string, error) { 238 + now := time.Now() 239 + jti := make([]byte, 16) 240 + rand.Read(jti) 241 + 242 + claims := jwt.Claims{ 243 + Issuer: c.ClientID, 244 + Subject: c.ClientID, 245 + Audience: jwt.Audience{issuer}, 246 + IssuedAt: jwt.NewNumericDate(now), 247 + Expiry: jwt.NewNumericDate(now.Add(5 * time.Minute)), 248 + ID: base64.RawURLEncoding.EncodeToString(jti), 249 + } 250 + 251 + signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.ES256, Key: c.PrivateKey}, &jose.SignerOptions{ 252 + ExtraHeaders: map[jose.HeaderKey]interface{}{ 253 + "kid": c.PublicJWK.KeyID, 254 + }, 255 + }) 256 + if err != nil { 257 + return "", err 258 + } 259 + 260 + return jwt.Signed(signer).Claims(claims).Serialize() 261 + } 262 + 263 + func (c *Client) SendPAR(meta *AuthServerMetadata, loginHint, scope string, dpopKey *ecdsa.PrivateKey, pkceChallenge string) (*PARResponse, string, string, error) { 264 + stateBytes := make([]byte, 16) 265 + rand.Read(stateBytes) 266 + state := base64.RawURLEncoding.EncodeToString(stateBytes) 267 + 268 + parResp, dpopNonce, err := c.sendPARRequest(meta, loginHint, scope, dpopKey, pkceChallenge, state, "") 269 + if err != nil { 270 + 271 + if strings.Contains(err.Error(), "use_dpop_nonce") && dpopNonce != "" { 272 + 273 + parResp, dpopNonce, err = c.sendPARRequest(meta, loginHint, scope, dpopKey, pkceChallenge, state, dpopNonce) 274 + if err != nil { 275 + return nil, "", "", err 276 + } 277 + } else { 278 + return nil, "", "", err 279 + } 280 + } 281 + 282 + return parResp, state, dpopNonce, nil 283 + } 284 + 285 + func (c *Client) sendPARRequest(meta *AuthServerMetadata, loginHint, scope string, dpopKey *ecdsa.PrivateKey, pkceChallenge, state, dpopNonce string) (*PARResponse, string, error) { 286 + dpopProof, err := c.CreateDPoPProof(dpopKey, "POST", meta.PushedAuthorizationRequestEndpoint, dpopNonce, "") 287 + if err != nil { 288 + return nil, "", err 289 + } 290 + 291 + clientAssertion, err := c.CreateClientAssertion(meta.Issuer) 292 + if err != nil { 293 + return nil, "", err 294 + } 295 + 296 + data := url.Values{} 297 + data.Set("client_id", c.ClientID) 298 + data.Set("redirect_uri", c.RedirectURI) 299 + data.Set("response_type", "code") 300 + data.Set("scope", scope) 301 + data.Set("state", state) 302 + data.Set("code_challenge", pkceChallenge) 303 + data.Set("code_challenge_method", "S256") 304 + data.Set("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer") 305 + data.Set("client_assertion", clientAssertion) 306 + if loginHint != "" { 307 + data.Set("login_hint", loginHint) 308 + } 309 + 310 + req, err := http.NewRequest("POST", meta.PushedAuthorizationRequestEndpoint, strings.NewReader(data.Encode())) 311 + if err != nil { 312 + return nil, "", err 313 + } 314 + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 315 + req.Header.Set("DPoP", dpopProof) 316 + 317 + resp, err := http.DefaultClient.Do(req) 318 + if err != nil { 319 + return nil, "", err 320 + } 321 + defer resp.Body.Close() 322 + 323 + responseNonce := resp.Header.Get("DPoP-Nonce") 324 + 325 + if resp.StatusCode != 200 && resp.StatusCode != 201 { 326 + body, _ := io.ReadAll(resp.Body) 327 + return nil, responseNonce, fmt.Errorf("PAR failed: %d - %s", resp.StatusCode, string(body)) 328 + } 329 + 330 + var parResp PARResponse 331 + if err := json.NewDecoder(resp.Body).Decode(&parResp); err != nil { 332 + return nil, responseNonce, err 333 + } 334 + 335 + return &parResp, responseNonce, nil 336 + } 337 + 338 + func (c *Client) ExchangeCode(meta *AuthServerMetadata, code, pkceVerifier string, dpopKey *ecdsa.PrivateKey, dpopNonce string) (*TokenResponse, string, error) { 339 + return c.exchangeCodeInternal(meta, code, pkceVerifier, dpopKey, dpopNonce, false) 340 + } 341 + 342 + func (c *Client) exchangeCodeInternal(meta *AuthServerMetadata, code, pkceVerifier string, dpopKey *ecdsa.PrivateKey, dpopNonce string, isRetry bool) (*TokenResponse, string, error) { 343 + accessTokenHash := "" 344 + dpopProof, err := c.CreateDPoPProof(dpopKey, "POST", meta.TokenEndpoint, dpopNonce, accessTokenHash) 345 + if err != nil { 346 + return nil, "", err 347 + } 348 + 349 + clientAssertion, err := c.CreateClientAssertion(meta.Issuer) 350 + if err != nil { 351 + return nil, "", err 352 + } 353 + 354 + data := url.Values{} 355 + data.Set("grant_type", "authorization_code") 356 + data.Set("code", code) 357 + data.Set("redirect_uri", c.RedirectURI) 358 + data.Set("client_id", c.ClientID) 359 + data.Set("code_verifier", pkceVerifier) 360 + data.Set("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer") 361 + data.Set("client_assertion", clientAssertion) 362 + 363 + req, err := http.NewRequest("POST", meta.TokenEndpoint, strings.NewReader(data.Encode())) 364 + if err != nil { 365 + return nil, "", err 366 + } 367 + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 368 + req.Header.Set("DPoP", dpopProof) 369 + 370 + resp, err := http.DefaultClient.Do(req) 371 + if err != nil { 372 + return nil, "", err 373 + } 374 + defer resp.Body.Close() 375 + 376 + newNonce := resp.Header.Get("DPoP-Nonce") 377 + 378 + if resp.StatusCode != 200 { 379 + body, _ := io.ReadAll(resp.Body) 380 + bodyStr := string(body) 381 + 382 + if !isRetry && strings.Contains(bodyStr, "use_dpop_nonce") && newNonce != "" { 383 + return c.exchangeCodeInternal(meta, code, pkceVerifier, dpopKey, newNonce, true) 384 + } 385 + 386 + return nil, newNonce, fmt.Errorf("token exchange failed: %d - %s", resp.StatusCode, bodyStr) 387 + } 388 + 389 + var tokenResp TokenResponse 390 + if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { 391 + return nil, newNonce, err 392 + } 393 + 394 + return &tokenResp, newNonce, nil 395 + } 396 + 397 + func (c *Client) RefreshToken(meta *AuthServerMetadata, refreshToken string, dpopKey *ecdsa.PrivateKey, dpopNonce string) (*TokenResponse, string, error) { 398 + return c.refreshTokenInternal(meta, refreshToken, dpopKey, dpopNonce, false) 399 + } 400 + 401 + func (c *Client) refreshTokenInternal(meta *AuthServerMetadata, refreshToken string, dpopKey *ecdsa.PrivateKey, dpopNonce string, isRetry bool) (*TokenResponse, string, error) { 402 + dpopProof, err := c.CreateDPoPProof(dpopKey, "POST", meta.TokenEndpoint, dpopNonce, "") 403 + if err != nil { 404 + return nil, "", err 405 + } 406 + 407 + clientAssertion, err := c.CreateClientAssertion(meta.Issuer) 408 + if err != nil { 409 + return nil, "", err 410 + } 411 + 412 + data := url.Values{} 413 + data.Set("grant_type", "refresh_token") 414 + data.Set("refresh_token", refreshToken) 415 + data.Set("client_id", c.ClientID) 416 + data.Set("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer") 417 + data.Set("client_assertion", clientAssertion) 418 + 419 + req, err := http.NewRequest("POST", meta.TokenEndpoint, strings.NewReader(data.Encode())) 420 + if err != nil { 421 + return nil, "", err 422 + } 423 + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 424 + req.Header.Set("DPoP", dpopProof) 425 + 426 + resp, err := http.DefaultClient.Do(req) 427 + if err != nil { 428 + return nil, "", err 429 + } 430 + defer resp.Body.Close() 431 + 432 + newNonce := resp.Header.Get("DPoP-Nonce") 433 + 434 + if resp.StatusCode != 200 { 435 + body, _ := io.ReadAll(resp.Body) 436 + bodyStr := string(body) 437 + 438 + if !isRetry && strings.Contains(bodyStr, "use_dpop_nonce") && newNonce != "" { 439 + return c.refreshTokenInternal(meta, refreshToken, dpopKey, newNonce, true) 440 + } 441 + 442 + return nil, newNonce, fmt.Errorf("refresh failed: %d - %s", resp.StatusCode, bodyStr) 443 + } 444 + 445 + var tokenResp TokenResponse 446 + if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { 447 + return nil, newNonce, err 448 + } 449 + 450 + return &tokenResp, newNonce, nil 451 + } 452 + 453 + func (c *Client) GetPublicJWKS() map[string]interface{} { 454 + return map[string]interface{}{ 455 + "keys": []interface{}{c.PublicJWK}, 456 + } 457 + }
+515
backend/internal/oauth/handler.go
··· 1 + package oauth 2 + 3 + import ( 4 + "context" 5 + "crypto/ecdsa" 6 + "crypto/elliptic" 7 + "crypto/rand" 8 + "crypto/x509" 9 + "encoding/json" 10 + "encoding/pem" 11 + "fmt" 12 + "log" 13 + "net/http" 14 + "net/url" 15 + "os" 16 + "sync" 17 + "time" 18 + 19 + "margin.at/internal/db" 20 + "margin.at/internal/xrpc" 21 + ) 22 + 23 + type Handler struct { 24 + db *db.DB 25 + configuredBaseURL string 26 + privateKey *ecdsa.PrivateKey 27 + pending map[string]*PendingAuth 28 + pendingMu sync.RWMutex 29 + } 30 + 31 + func NewHandler(database *db.DB) (*Handler, error) { 32 + 33 + configuredBaseURL := os.Getenv("BASE_URL") 34 + 35 + privateKey, err := loadOrGenerateKey() 36 + if err != nil { 37 + return nil, fmt.Errorf("failed to load/generate key: %w", err) 38 + } 39 + 40 + return &Handler{ 41 + db: database, 42 + configuredBaseURL: configuredBaseURL, 43 + privateKey: privateKey, 44 + pending: make(map[string]*PendingAuth), 45 + }, nil 46 + } 47 + 48 + func loadOrGenerateKey() (*ecdsa.PrivateKey, error) { 49 + keyPath := os.Getenv("OAUTH_KEY_PATH") 50 + if keyPath == "" { 51 + keyPath = "./oauth_private_key.pem" 52 + } 53 + 54 + if data, err := os.ReadFile(keyPath); err == nil { 55 + block, _ := pem.Decode(data) 56 + if block != nil { 57 + key, err := x509.ParseECPrivateKey(block.Bytes) 58 + if err == nil { 59 + return key, nil 60 + } 61 + } 62 + } 63 + 64 + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 65 + if err != nil { 66 + return nil, err 67 + } 68 + 69 + keyBytes, err := x509.MarshalECPrivateKey(key) 70 + if err != nil { 71 + return nil, err 72 + } 73 + 74 + block := &pem.Block{ 75 + Type: "EC PRIVATE KEY", 76 + Bytes: keyBytes, 77 + } 78 + 79 + if err := os.WriteFile(keyPath, pem.EncodeToMemory(block), 0600); err != nil { 80 + log.Printf("Warning: could not save key to %s: %v\n", keyPath, err) 81 + } 82 + 83 + return key, nil 84 + } 85 + 86 + func (h *Handler) getDynamicClient(r *http.Request) *Client { 87 + baseURL := h.configuredBaseURL 88 + if baseURL == "" { 89 + scheme := "http" 90 + if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { 91 + scheme = "https" 92 + } 93 + baseURL = fmt.Sprintf("%s://%s", scheme, r.Host) 94 + } 95 + 96 + if len(baseURL) > 0 && baseURL[len(baseURL)-1] == '/' { 97 + baseURL = baseURL[:len(baseURL)-1] 98 + } 99 + 100 + clientID := baseURL + "/client-metadata.json" 101 + redirectURI := baseURL + "/auth/callback" 102 + 103 + return NewClient(clientID, redirectURI, h.privateKey) 104 + } 105 + 106 + func (h *Handler) HandleLogin(w http.ResponseWriter, r *http.Request) { 107 + client := h.getDynamicClient(r) 108 + 109 + handle := r.URL.Query().Get("handle") 110 + if handle == "" { 111 + http.Redirect(w, r, "/login", http.StatusFound) 112 + return 113 + } 114 + 115 + ctx := r.Context() 116 + 117 + did, err := client.ResolveHandle(ctx, handle) 118 + if err != nil { 119 + http.Error(w, fmt.Sprintf("Failed to resolve handle: %v", err), http.StatusBadRequest) 120 + return 121 + } 122 + 123 + pds, err := client.ResolveDIDToPDS(ctx, did) 124 + if err != nil { 125 + http.Error(w, fmt.Sprintf("Failed to resolve PDS: %v", err), http.StatusBadRequest) 126 + return 127 + } 128 + 129 + meta, err := client.GetAuthServerMetadata(ctx, pds) 130 + if err != nil { 131 + http.Error(w, fmt.Sprintf("Failed to get auth server metadata: %v", err), http.StatusBadRequest) 132 + return 133 + } 134 + 135 + dpopKey, err := client.GenerateDPoPKey() 136 + if err != nil { 137 + http.Error(w, fmt.Sprintf("Failed to generate DPoP key: %v", err), http.StatusInternalServerError) 138 + return 139 + } 140 + 141 + pkceVerifier, pkceChallenge := client.GeneratePKCE() 142 + 143 + scope := "atproto transition:generic" 144 + 145 + parResp, state, dpopNonce, err := client.SendPAR(meta, handle, scope, dpopKey, pkceChallenge) 146 + if err != nil { 147 + http.Error(w, fmt.Sprintf("PAR request failed: %v", err), http.StatusInternalServerError) 148 + return 149 + } 150 + 151 + pending := &PendingAuth{ 152 + State: state, 153 + DID: did, 154 + PDS: pds, 155 + AuthServer: meta.TokenEndpoint, 156 + Issuer: meta.Issuer, 157 + PKCEVerifier: pkceVerifier, 158 + DPoPKey: dpopKey, 159 + DPoPNonce: dpopNonce, 160 + CreatedAt: time.Now(), 161 + } 162 + 163 + h.pendingMu.Lock() 164 + h.pending[state] = pending 165 + h.pendingMu.Unlock() 166 + 167 + authURL, _ := url.Parse(meta.AuthorizationEndpoint) 168 + q := authURL.Query() 169 + q.Set("client_id", client.ClientID) 170 + q.Set("request_uri", parResp.RequestURI) 171 + authURL.RawQuery = q.Encode() 172 + 173 + http.Redirect(w, r, authURL.String(), http.StatusFound) 174 + } 175 + 176 + func (h *Handler) HandleStart(w http.ResponseWriter, r *http.Request) { 177 + if r.Method != "POST" { 178 + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 179 + return 180 + } 181 + 182 + var req struct { 183 + Handle string `json:"handle"` 184 + InviteCode string `json:"invite_code"` 185 + } 186 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 187 + http.Error(w, "Invalid request body", http.StatusBadRequest) 188 + return 189 + } 190 + 191 + if req.Handle == "" { 192 + http.Error(w, "Handle is required", http.StatusBadRequest) 193 + return 194 + } 195 + 196 + requiredCode := os.Getenv("INVITE_CODE") 197 + if requiredCode != "" && req.InviteCode != requiredCode { 198 + w.Header().Set("Content-Type", "application/json") 199 + w.WriteHeader(http.StatusForbidden) 200 + json.NewEncoder(w).Encode(map[string]string{ 201 + "error": "Invite code required", 202 + "code": "invite_required", 203 + }) 204 + return 205 + } 206 + 207 + client := h.getDynamicClient(r) 208 + ctx := r.Context() 209 + 210 + did, err := client.ResolveHandle(ctx, req.Handle) 211 + if err != nil { 212 + w.Header().Set("Content-Type", "application/json") 213 + w.WriteHeader(http.StatusBadRequest) 214 + json.NewEncoder(w).Encode(map[string]string{"error": "Could not find that Bluesky account"}) 215 + return 216 + } 217 + 218 + pds, err := client.ResolveDIDToPDS(ctx, did) 219 + if err != nil { 220 + w.Header().Set("Content-Type", "application/json") 221 + w.WriteHeader(http.StatusBadRequest) 222 + json.NewEncoder(w).Encode(map[string]string{"error": "Failed to resolve PDS"}) 223 + return 224 + } 225 + 226 + meta, err := client.GetAuthServerMetadata(ctx, pds) 227 + if err != nil { 228 + w.Header().Set("Content-Type", "application/json") 229 + w.WriteHeader(http.StatusInternalServerError) 230 + json.NewEncoder(w).Encode(map[string]string{"error": "Failed to get auth server"}) 231 + return 232 + } 233 + 234 + dpopKey, err := client.GenerateDPoPKey() 235 + if err != nil { 236 + w.Header().Set("Content-Type", "application/json") 237 + w.WriteHeader(http.StatusInternalServerError) 238 + json.NewEncoder(w).Encode(map[string]string{"error": "Internal error"}) 239 + return 240 + } 241 + 242 + pkceVerifier, pkceChallenge := client.GeneratePKCE() 243 + scope := "atproto transition:generic" 244 + 245 + parResp, state, dpopNonce, err := client.SendPAR(meta, req.Handle, scope, dpopKey, pkceChallenge) 246 + if err != nil { 247 + w.Header().Set("Content-Type", "application/json") 248 + w.WriteHeader(http.StatusInternalServerError) 249 + json.NewEncoder(w).Encode(map[string]string{"error": "Failed to initiate authentication"}) 250 + return 251 + } 252 + 253 + pending := &PendingAuth{ 254 + State: state, 255 + DID: did, 256 + Handle: req.Handle, 257 + PDS: pds, 258 + AuthServer: meta.TokenEndpoint, 259 + Issuer: meta.Issuer, 260 + PKCEVerifier: pkceVerifier, 261 + DPoPKey: dpopKey, 262 + DPoPNonce: dpopNonce, 263 + CreatedAt: time.Now(), 264 + } 265 + 266 + h.pendingMu.Lock() 267 + h.pending[state] = pending 268 + h.pendingMu.Unlock() 269 + 270 + authURL, _ := url.Parse(meta.AuthorizationEndpoint) 271 + q := authURL.Query() 272 + q.Set("client_id", client.ClientID) 273 + q.Set("request_uri", parResp.RequestURI) 274 + authURL.RawQuery = q.Encode() 275 + 276 + w.Header().Set("Content-Type", "application/json") 277 + json.NewEncoder(w).Encode(map[string]string{ 278 + "authorizationUrl": authURL.String(), 279 + }) 280 + } 281 + 282 + func (h *Handler) HandleCallback(w http.ResponseWriter, r *http.Request) { 283 + client := h.getDynamicClient(r) 284 + 285 + state := r.URL.Query().Get("state") 286 + code := r.URL.Query().Get("code") 287 + iss := r.URL.Query().Get("iss") 288 + 289 + if state == "" || code == "" { 290 + http.Error(w, "Missing state or code parameter", http.StatusBadRequest) 291 + return 292 + } 293 + 294 + h.pendingMu.Lock() 295 + pending, ok := h.pending[state] 296 + if ok { 297 + delete(h.pending, state) 298 + } 299 + h.pendingMu.Unlock() 300 + 301 + if !ok { 302 + http.Error(w, "Invalid or expired state", http.StatusBadRequest) 303 + return 304 + } 305 + 306 + if time.Since(pending.CreatedAt) > 10*time.Minute { 307 + http.Error(w, "Authentication request expired", http.StatusBadRequest) 308 + return 309 + } 310 + 311 + if iss != "" && iss != pending.Issuer { 312 + http.Error(w, "Issuer mismatch", http.StatusBadRequest) 313 + return 314 + } 315 + 316 + ctx := r.Context() 317 + meta, err := client.GetAuthServerMetadata(ctx, pending.PDS) 318 + if err != nil { 319 + http.Error(w, fmt.Sprintf("Failed to get auth metadata: %v", err), http.StatusInternalServerError) 320 + return 321 + } 322 + 323 + tokenResp, newNonce, err := client.ExchangeCode(meta, code, pending.PKCEVerifier, pending.DPoPKey, pending.DPoPNonce) 324 + if err != nil { 325 + http.Error(w, fmt.Sprintf("Token exchange failed: %v", err), http.StatusInternalServerError) 326 + return 327 + } 328 + 329 + _ = newNonce 330 + 331 + sessionID := generateSessionID() 332 + expiresAt := time.Now().Add(7 * 24 * time.Hour) 333 + 334 + dpopKeyBytes, err := x509.MarshalECPrivateKey(pending.DPoPKey) 335 + if err != nil { 336 + http.Error(w, "Failed to marshal DPoP key", http.StatusInternalServerError) 337 + return 338 + } 339 + dpopKeyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: dpopKeyBytes}) 340 + 341 + err = h.db.SaveSession( 342 + sessionID, 343 + tokenResp.Sub, 344 + pending.Handle, 345 + tokenResp.AccessToken, 346 + tokenResp.RefreshToken, 347 + string(dpopKeyPEM), 348 + expiresAt, 349 + ) 350 + if err != nil { 351 + http.Error(w, "Failed to save session", http.StatusInternalServerError) 352 + return 353 + } 354 + 355 + http.SetCookie(w, &http.Cookie{ 356 + Name: "margin_session", 357 + Value: sessionID, 358 + Path: "/", 359 + HttpOnly: true, 360 + Secure: true, 361 + SameSite: http.SameSiteNoneMode, 362 + MaxAge: 86400 * 7, 363 + }) 364 + 365 + go h.cleanupOrphanedReplies(tokenResp.Sub, tokenResp.AccessToken, string(dpopKeyPEM), pending.PDS) 366 + 367 + http.Redirect(w, r, "/?logged_in=true", http.StatusFound) 368 + } 369 + 370 + func (h *Handler) cleanupOrphanedReplies(did, accessToken, dpopKeyPEM, pds string) { 371 + orphans, err := h.db.GetOrphanedRepliesByAuthor(did) 372 + if err != nil || len(orphans) == 0 { 373 + return 374 + } 375 + 376 + block, _ := pem.Decode([]byte(dpopKeyPEM)) 377 + if block == nil { 378 + return 379 + } 380 + dpopKey, err := x509.ParseECPrivateKey(block.Bytes) 381 + if err != nil { 382 + return 383 + } 384 + 385 + for _, reply := range orphans { 386 + 387 + parts := url.PathEscape(reply.URI) 388 + _ = parts 389 + uriParts := splitURI(reply.URI) 390 + if len(uriParts) < 2 { 391 + continue 392 + } 393 + rkey := uriParts[len(uriParts)-1] 394 + 395 + deleteFromPDS(pds, accessToken, dpopKey, "at.margin.reply", did, rkey) 396 + 397 + h.db.DeleteReply(reply.URI) 398 + } 399 + } 400 + 401 + func splitURI(uri string) []string { 402 + 403 + return splitBySlash(uri) 404 + } 405 + 406 + func splitBySlash(s string) []string { 407 + var result []string 408 + current := "" 409 + for _, c := range s { 410 + if c == '/' { 411 + if current != "" { 412 + result = append(result, current) 413 + } 414 + current = "" 415 + } else { 416 + current += string(c) 417 + } 418 + } 419 + if current != "" { 420 + result = append(result, current) 421 + } 422 + return result 423 + } 424 + 425 + func deleteFromPDS(pds, accessToken string, dpopKey *ecdsa.PrivateKey, collection, did, rkey string) { 426 + 427 + client := xrpc.NewClient(pds, accessToken, dpopKey) 428 + err := client.DeleteRecord(context.Background(), collection, did, rkey) 429 + if err != nil { 430 + log.Printf("Failed to delete orphaned reply from PDS: %v", err) 431 + } else { 432 + log.Printf("Cleaned up orphaned reply %s/%s from PDS", collection, rkey) 433 + } 434 + } 435 + 436 + func (h *Handler) HandleLogout(w http.ResponseWriter, r *http.Request) { 437 + cookie, err := r.Cookie("margin_session") 438 + if err == nil { 439 + h.db.DeleteSession(cookie.Value) 440 + } 441 + 442 + http.SetCookie(w, &http.Cookie{ 443 + Name: "margin_session", 444 + Value: "", 445 + Path: "/", 446 + HttpOnly: true, 447 + MaxAge: -1, 448 + }) 449 + 450 + w.Header().Set("Content-Type", "application/json") 451 + json.NewEncoder(w).Encode(map[string]bool{"success": true}) 452 + } 453 + 454 + func (h *Handler) HandleSession(w http.ResponseWriter, r *http.Request) { 455 + cookie, err := r.Cookie("margin_session") 456 + if err != nil { 457 + w.Header().Set("Content-Type", "application/json") 458 + json.NewEncoder(w).Encode(map[string]interface{}{"authenticated": false}) 459 + return 460 + } 461 + 462 + did, handle, _, _, _, err := h.db.GetSession(cookie.Value) 463 + if err != nil { 464 + w.Header().Set("Content-Type", "application/json") 465 + json.NewEncoder(w).Encode(map[string]interface{}{"authenticated": false}) 466 + return 467 + } 468 + 469 + w.Header().Set("Content-Type", "application/json") 470 + json.NewEncoder(w).Encode(map[string]interface{}{ 471 + "authenticated": true, 472 + "did": did, 473 + "handle": handle, 474 + }) 475 + } 476 + 477 + func (h *Handler) HandleClientMetadata(w http.ResponseWriter, r *http.Request) { 478 + client := h.getDynamicClient(r) 479 + baseURL := client.ClientID[:len(client.ClientID)-len("/client-metadata.json")] 480 + 481 + w.Header().Set("Content-Type", "application/json") 482 + json.NewEncoder(w).Encode(map[string]interface{}{ 483 + "client_id": client.ClientID, 484 + "client_name": "Margin", 485 + "client_uri": baseURL, 486 + "logo_uri": baseURL + "/logo.svg", 487 + "tos_uri": baseURL + "/terms", 488 + "policy_uri": baseURL + "/privacy", 489 + "redirect_uris": []string{client.RedirectURI}, 490 + "grant_types": []string{"authorization_code", "refresh_token"}, 491 + "response_types": []string{"code"}, 492 + "scope": "atproto transition:generic", 493 + "token_endpoint_auth_method": "private_key_jwt", 494 + "token_endpoint_auth_signing_alg": "ES256", 495 + "dpop_bound_access_tokens": true, 496 + "jwks_uri": baseURL + "/jwks.json", 497 + "application_type": "web", 498 + }) 499 + } 500 + 501 + func (h *Handler) HandleJWKS(w http.ResponseWriter, r *http.Request) { 502 + client := h.getDynamicClient(r) 503 + w.Header().Set("Content-Type", "application/json") 504 + json.NewEncoder(w).Encode(client.GetPublicJWKS()) 505 + } 506 + 507 + func (h *Handler) GetPrivateKey() *ecdsa.PrivateKey { 508 + return h.privateKey 509 + } 510 + 511 + func generateSessionID() string { 512 + b := make([]byte, 32) 513 + rand.Read(b) 514 + return fmt.Sprintf("%x", b) 515 + }
+278
backend/internal/xrpc/client.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "crypto/ecdsa" 7 + "crypto/rand" 8 + "crypto/sha256" 9 + "encoding/base64" 10 + "encoding/json" 11 + "fmt" 12 + "io" 13 + "net/http" 14 + "strings" 15 + "time" 16 + 17 + "github.com/go-jose/go-jose/v4" 18 + ) 19 + 20 + type Client struct { 21 + PDS string 22 + AccessToken string 23 + DPoPKey *ecdsa.PrivateKey 24 + DPoPNonce string 25 + } 26 + 27 + func NewClient(pds, accessToken string, dpopKey *ecdsa.PrivateKey) *Client { 28 + return &Client{ 29 + PDS: pds, 30 + AccessToken: accessToken, 31 + DPoPKey: dpopKey, 32 + } 33 + } 34 + 35 + func (c *Client) createDPoPProof(method, uri string) (string, error) { 36 + now := time.Now() 37 + jti := make([]byte, 16) 38 + if _, err := io.ReadFull(rand.Reader, jti); err != nil { 39 + 40 + for i := range jti { 41 + jti[i] = byte(now.UnixNano() >> (i * 8)) 42 + } 43 + } 44 + 45 + publicJWK := jose.JSONWebKey{ 46 + Key: &c.DPoPKey.PublicKey, 47 + Algorithm: string(jose.ES256), 48 + } 49 + 50 + ath := "" 51 + if c.AccessToken != "" { 52 + hash := sha256.Sum256([]byte(c.AccessToken)) 53 + ath = base64.RawURLEncoding.EncodeToString(hash[:]) 54 + } 55 + 56 + claims := map[string]interface{}{ 57 + "jti": base64.RawURLEncoding.EncodeToString(jti), 58 + "htm": method, 59 + "htu": uri, 60 + "iat": now.Unix(), 61 + "exp": now.Add(5 * time.Minute).Unix(), 62 + } 63 + if c.DPoPNonce != "" { 64 + claims["nonce"] = c.DPoPNonce 65 + } 66 + if ath != "" { 67 + claims["ath"] = ath 68 + } 69 + 70 + signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.ES256, Key: c.DPoPKey}, &jose.SignerOptions{ 71 + ExtraHeaders: map[jose.HeaderKey]interface{}{ 72 + "typ": "dpop+jwt", 73 + "jwk": publicJWK, 74 + }, 75 + }) 76 + if err != nil { 77 + return "", err 78 + } 79 + 80 + claimsBytes, _ := json.Marshal(claims) 81 + sig, err := signer.Sign(claimsBytes) 82 + if err != nil { 83 + return "", err 84 + } 85 + 86 + return sig.CompactSerialize() 87 + } 88 + 89 + func (c *Client) Call(ctx context.Context, method, nsid string, input, output interface{}) error { 90 + url := fmt.Sprintf("%s/xrpc/%s", c.PDS, nsid) 91 + 92 + maxRetries := 2 93 + for i := 0; i < maxRetries; i++ { 94 + var reqBody io.Reader 95 + if input != nil { 96 + 97 + data, err := json.Marshal(input) 98 + if err != nil { 99 + return err 100 + } 101 + reqBody = bytes.NewReader(data) 102 + } 103 + 104 + req, err := http.NewRequestWithContext(ctx, method, url, reqBody) 105 + if err != nil { 106 + return err 107 + } 108 + 109 + if input != nil { 110 + req.Header.Set("Content-Type", "application/json") 111 + } 112 + 113 + dpopProof, err := c.createDPoPProof(method, url) 114 + if err != nil { 115 + return fmt.Errorf("failed to create DPoP proof: %w", err) 116 + } 117 + 118 + req.Header.Set("Authorization", "DPoP "+c.AccessToken) 119 + req.Header.Set("DPoP", dpopProof) 120 + 121 + resp, err := http.DefaultClient.Do(req) 122 + if err != nil { 123 + return err 124 + } 125 + defer resp.Body.Close() 126 + 127 + if nonce := resp.Header.Get("DPoP-Nonce"); nonce != "" { 128 + c.DPoPNonce = nonce 129 + } 130 + 131 + if resp.StatusCode < 400 { 132 + if output != nil { 133 + return json.NewDecoder(resp.Body).Decode(output) 134 + } 135 + return nil 136 + } 137 + 138 + bodyBytes, _ := io.ReadAll(resp.Body) 139 + bodyStr := string(bodyBytes) 140 + 141 + if resp.StatusCode == 401 && (bytes.Contains(bodyBytes, []byte("use_dpop_nonce")) || bytes.Contains(bodyBytes, []byte("UseDpopNonce"))) { 142 + continue 143 + } 144 + 145 + return fmt.Errorf("XRPC error %d: %s", resp.StatusCode, bodyStr) 146 + } 147 + 148 + return fmt.Errorf("XRPC failed after retries") 149 + } 150 + 151 + type CreateRecordInput struct { 152 + Repo string `json:"repo"` 153 + Collection string `json:"collection"` 154 + RKey string `json:"rkey,omitempty"` 155 + Record interface{} `json:"record"` 156 + } 157 + 158 + type CreateRecordOutput struct { 159 + URI string `json:"uri"` 160 + CID string `json:"cid"` 161 + } 162 + 163 + func (c *Client) CreateRecord(ctx context.Context, repo, collection string, record interface{}) (*CreateRecordOutput, error) { 164 + input := CreateRecordInput{ 165 + Repo: repo, 166 + Collection: collection, 167 + Record: record, 168 + } 169 + 170 + var output CreateRecordOutput 171 + err := c.Call(ctx, "POST", "com.atproto.repo.createRecord", input, &output) 172 + if err != nil { 173 + return nil, err 174 + } 175 + 176 + return &output, nil 177 + } 178 + 179 + type DeleteRecordInput struct { 180 + Repo string `json:"repo"` 181 + Collection string `json:"collection"` 182 + RKey string `json:"rkey"` 183 + } 184 + 185 + func (c *Client) DeleteRecord(ctx context.Context, repo, collection, rkey string) error { 186 + input := DeleteRecordInput{ 187 + Repo: repo, 188 + Collection: collection, 189 + RKey: rkey, 190 + } 191 + 192 + return c.Call(ctx, "POST", "com.atproto.repo.deleteRecord", input, nil) 193 + } 194 + 195 + func (c *Client) DeleteRecordByURI(ctx context.Context, uri string) error { 196 + 197 + if !strings.HasPrefix(uri, "at://") { 198 + return fmt.Errorf("invalid AT URI format") 199 + } 200 + 201 + parts := strings.Split(strings.TrimPrefix(uri, "at://"), "/") 202 + if len(parts) != 3 { 203 + return fmt.Errorf("invalid AT URI format") 204 + } 205 + 206 + return c.DeleteRecord(ctx, parts[0], parts[1], parts[2]) 207 + } 208 + 209 + type PutRecordInput struct { 210 + Repo string `json:"repo"` 211 + Collection string `json:"collection"` 212 + RKey string `json:"rkey"` 213 + Record interface{} `json:"record"` 214 + } 215 + 216 + type PutRecordOutput struct { 217 + URI string `json:"uri"` 218 + CID string `json:"cid"` 219 + } 220 + 221 + func (c *Client) PutRecord(ctx context.Context, repo, collection, rkey string, record interface{}) (*PutRecordOutput, error) { 222 + input := PutRecordInput{ 223 + Repo: repo, 224 + Collection: collection, 225 + RKey: rkey, 226 + Record: record, 227 + } 228 + 229 + var output PutRecordOutput 230 + err := c.Call(ctx, "POST", "com.atproto.repo.putRecord", input, &output) 231 + if err != nil { 232 + return nil, err 233 + } 234 + 235 + return &output, nil 236 + } 237 + 238 + type GetRecordOutput struct { 239 + URI string `json:"uri"` 240 + CID string `json:"cid"` 241 + Value json.RawMessage `json:"value"` 242 + } 243 + 244 + func (c *Client) GetRecord(ctx context.Context, repo, collection, rkey string) (*GetRecordOutput, error) { 245 + url := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s", 246 + c.PDS, repo, collection, rkey) 247 + 248 + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) 249 + if err != nil { 250 + return nil, err 251 + } 252 + 253 + dpopProof, err := c.createDPoPProof("GET", url) 254 + if err != nil { 255 + return nil, err 256 + } 257 + 258 + req.Header.Set("Authorization", "DPoP "+c.AccessToken) 259 + req.Header.Set("DPoP", dpopProof) 260 + 261 + resp, err := http.DefaultClient.Do(req) 262 + if err != nil { 263 + return nil, err 264 + } 265 + defer resp.Body.Close() 266 + 267 + if resp.StatusCode >= 400 { 268 + bodyBytes, _ := io.ReadAll(resp.Body) 269 + return nil, fmt.Errorf("XRPC error %d: %s", resp.StatusCode, string(bodyBytes)) 270 + } 271 + 272 + var output GetRecordOutput 273 + if err := json.NewDecoder(resp.Body).Decode(&output); err != nil { 274 + return nil, err 275 + } 276 + 277 + return &output, nil 278 + }
+193
backend/internal/xrpc/records.go
··· 1 + package xrpc 2 + 3 + import "time" 4 + 5 + const ( 6 + CollectionAnnotation = "at.margin.annotation" 7 + CollectionHighlight = "at.margin.highlight" 8 + CollectionBookmark = "at.margin.bookmark" 9 + CollectionReply = "at.margin.reply" 10 + CollectionLike = "at.margin.like" 11 + CollectionCollection = "at.margin.collection" 12 + CollectionCollectionItem = "at.margin.collectionItem" 13 + ) 14 + 15 + type AnnotationRecord struct { 16 + Type string `json:"$type"` 17 + Motivation string `json:"motivation,omitempty"` 18 + Body *AnnotationBody `json:"body,omitempty"` 19 + Target AnnotationTarget `json:"target"` 20 + Tags []string `json:"tags,omitempty"` 21 + CreatedAt string `json:"createdAt"` 22 + } 23 + 24 + type AnnotationBody struct { 25 + Value string `json:"value,omitempty"` 26 + Format string `json:"format,omitempty"` 27 + } 28 + 29 + type AnnotationTarget struct { 30 + Source string `json:"source"` 31 + SourceHash string `json:"sourceHash"` 32 + Title string `json:"title,omitempty"` 33 + Selector interface{} `json:"selector,omitempty"` 34 + } 35 + 36 + type TextQuoteSelector struct { 37 + Type string `json:"type"` 38 + Exact string `json:"exact"` 39 + Prefix string `json:"prefix,omitempty"` 40 + Suffix string `json:"suffix,omitempty"` 41 + } 42 + 43 + func NewAnnotationRecord(url, urlHash, text string, selector interface{}, title string) *AnnotationRecord { 44 + return NewAnnotationRecordWithMotivation(url, urlHash, text, selector, title, "commenting") 45 + } 46 + 47 + func NewAnnotationRecordWithMotivation(url, urlHash, text string, selector interface{}, title string, motivation string) *AnnotationRecord { 48 + record := &AnnotationRecord{ 49 + Type: CollectionAnnotation, 50 + Motivation: motivation, 51 + Target: AnnotationTarget{ 52 + Source: url, 53 + SourceHash: urlHash, 54 + Title: title, 55 + }, 56 + CreatedAt: time.Now().UTC().Format(time.RFC3339), 57 + } 58 + 59 + if text != "" { 60 + record.Body = &AnnotationBody{ 61 + Value: text, 62 + Format: "text/plain", 63 + } 64 + } 65 + 66 + if selector != nil { 67 + record.Target.Selector = selector 68 + } 69 + 70 + return record 71 + } 72 + 73 + type HighlightRecord struct { 74 + Type string `json:"$type"` 75 + Target AnnotationTarget `json:"target"` 76 + Color string `json:"color,omitempty"` 77 + Tags []string `json:"tags,omitempty"` 78 + CreatedAt string `json:"createdAt"` 79 + } 80 + 81 + func NewHighlightRecord(url, urlHash string, selector interface{}, color string) *HighlightRecord { 82 + return &HighlightRecord{ 83 + Type: CollectionHighlight, 84 + Target: AnnotationTarget{ 85 + Source: url, 86 + SourceHash: urlHash, 87 + Selector: selector, 88 + }, 89 + Color: color, 90 + CreatedAt: time.Now().UTC().Format(time.RFC3339), 91 + } 92 + } 93 + 94 + type ReplyRef struct { 95 + URI string `json:"uri"` 96 + CID string `json:"cid"` 97 + } 98 + 99 + type ReplyRecord struct { 100 + Type string `json:"$type"` 101 + Parent ReplyRef `json:"parent"` 102 + Root ReplyRef `json:"root"` 103 + Text string `json:"text"` 104 + Format string `json:"format,omitempty"` 105 + CreatedAt string `json:"createdAt"` 106 + } 107 + 108 + func NewReplyRecord(parentURI, parentCID, rootURI, rootCID, text string) *ReplyRecord { 109 + return &ReplyRecord{ 110 + Type: CollectionReply, 111 + Parent: ReplyRef{URI: parentURI, CID: parentCID}, 112 + Root: ReplyRef{URI: rootURI, CID: rootCID}, 113 + Text: text, 114 + Format: "text/plain", 115 + CreatedAt: time.Now().UTC().Format(time.RFC3339), 116 + } 117 + } 118 + 119 + type SubjectRef struct { 120 + URI string `json:"uri"` 121 + CID string `json:"cid"` 122 + } 123 + 124 + type LikeRecord struct { 125 + Type string `json:"$type"` 126 + Subject SubjectRef `json:"subject"` 127 + CreatedAt string `json:"createdAt"` 128 + } 129 + 130 + func NewLikeRecord(subjectURI, subjectCID string) *LikeRecord { 131 + return &LikeRecord{ 132 + Type: CollectionLike, 133 + Subject: SubjectRef{URI: subjectURI, CID: subjectCID}, 134 + CreatedAt: time.Now().UTC().Format(time.RFC3339), 135 + } 136 + } 137 + 138 + type BookmarkRecord struct { 139 + Type string `json:"$type"` 140 + Source string `json:"source"` 141 + SourceHash string `json:"sourceHash"` 142 + Title string `json:"title,omitempty"` 143 + Description string `json:"description,omitempty"` 144 + Tags []string `json:"tags,omitempty"` 145 + CreatedAt string `json:"createdAt"` 146 + } 147 + 148 + func NewBookmarkRecord(url, urlHash, title, description string) *BookmarkRecord { 149 + return &BookmarkRecord{ 150 + Type: CollectionBookmark, 151 + Source: url, 152 + SourceHash: urlHash, 153 + Title: title, 154 + Description: description, 155 + CreatedAt: time.Now().UTC().Format(time.RFC3339), 156 + } 157 + } 158 + 159 + type CollectionRecord struct { 160 + Type string `json:"$type"` 161 + Name string `json:"name"` 162 + Description string `json:"description,omitempty"` 163 + Icon string `json:"icon,omitempty"` 164 + CreatedAt string `json:"createdAt"` 165 + } 166 + 167 + func NewCollectionRecord(name, description, icon string) *CollectionRecord { 168 + return &CollectionRecord{ 169 + Type: CollectionCollection, 170 + Name: name, 171 + Description: description, 172 + Icon: icon, 173 + CreatedAt: time.Now().UTC().Format(time.RFC3339), 174 + } 175 + } 176 + 177 + type CollectionItemRecord struct { 178 + Type string `json:"$type"` 179 + Collection string `json:"collection"` 180 + Annotation string `json:"annotation"` 181 + Position int `json:"position,omitempty"` 182 + CreatedAt string `json:"createdAt"` 183 + } 184 + 185 + func NewCollectionItemRecord(collection, annotation string, position int) *CollectionItemRecord { 186 + return &CollectionItemRecord{ 187 + Type: CollectionCollectionItem, 188 + Collection: collection, 189 + Annotation: annotation, 190 + Position: position, 191 + CreatedAt: time.Now().UTC().Format(time.RFC3339), 192 + } 193 + }
+36
docker-compose.yml
··· 1 + services: 2 + margin: 3 + image: ghcr.io/margin-at/margin:latest 4 + container_name: margin-at 5 + environment: 6 + - PORT=8080 7 + - DATABASE_URL=postgres://margin:margin@margin-db:5432/margin?sslmode=disable 8 + - STATIC_DIR=dist 9 + - OAUTH_KEY_PATH=/data/oauth_private_key.pem 10 + env_file: 11 + - .env 12 + depends_on: 13 + db: 14 + condition: service_healthy 15 + 16 + db: 17 + image: postgres:17-alpine 18 + container_name: margin-db 19 + environment: 20 + - POSTGRES_USER=margin 21 + - POSTGRES_PASSWORD=margin 22 + - POSTGRES_DB=margin 23 + 24 + volumes: 25 + - db-data:/var/lib/postgresql/data 26 + - margin-data:/data 27 + healthcheck: 28 + test: ["CMD-SHELL", "pg_isready -U margin"] 29 + interval: 5s 30 + timeout: 5s 31 + retries: 5 32 + restart: always 33 + 34 + volumes: 35 + db-data: 36 + margin-data:
+648
extension/background/service-worker.js
··· 1 + let API_BASE = "https://margin.at"; 2 + let WEB_BASE = "https://margin.at"; 3 + 4 + const hasSidePanel = 5 + typeof chrome !== "undefined" && typeof chrome.sidePanel !== "undefined"; 6 + const hasSidebarAction = 7 + typeof browser !== "undefined" && 8 + typeof browser.sidebarAction !== "undefined"; 9 + const hasSessionStorage = 10 + typeof chrome !== "undefined" && 11 + chrome.storage && 12 + typeof chrome.storage.session !== "undefined"; 13 + const hasNotifications = 14 + typeof chrome !== "undefined" && typeof chrome.notifications !== "undefined"; 15 + 16 + chrome.storage.local.get(["apiUrl"], (result) => { 17 + if (result.apiUrl) { 18 + updateBaseUrls(result.apiUrl); 19 + } 20 + }); 21 + 22 + function updateBaseUrls(url) { 23 + let cleanUrl = url.replace(/\/$/, ""); 24 + 25 + if (cleanUrl.includes("ngrok") && cleanUrl.startsWith("http://")) { 26 + cleanUrl = cleanUrl.replace("http://", "https://"); 27 + } 28 + 29 + API_BASE = cleanUrl; 30 + WEB_BASE = cleanUrl; 31 + API_BASE = cleanUrl; 32 + WEB_BASE = cleanUrl; 33 + } 34 + 35 + function showNotification(title, message) { 36 + if (hasNotifications) { 37 + chrome.notifications.create({ 38 + type: "basic", 39 + iconUrl: "icons/android-chrome-192x192.png", 40 + title: title, 41 + message: message, 42 + }); 43 + } 44 + } 45 + 46 + async function openAnnotationUI(tabId) { 47 + if (hasSidePanel) { 48 + 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 }); 56 + return true; 57 + } catch (err) { 58 + console.error("Could not open Chrome side panel:", err); 59 + } 60 + } 61 + 62 + if (hasSidebarAction) { 63 + try { 64 + await browser.sidebarAction.open(); 65 + return true; 66 + } catch (err) { 67 + console.warn("Could not open Firefox sidebar:", err); 68 + } 69 + } 70 + 71 + return false; 72 + } 73 + 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 + chrome.runtime.onInstalled.addListener(async () => { 86 + const stored = await chrome.storage.local.get(["apiUrl"]); 87 + if (!stored.apiUrl) { 88 + await chrome.storage.local.set({ apiUrl: "https://margin.at" }); 89 + updateBaseUrls("https://margin.at"); 90 + } 91 + 92 + await chrome.contextMenus.removeAll(); 93 + 94 + chrome.contextMenus.create({ 95 + id: "margin-annotate", 96 + title: 'Annotate "%s"', 97 + contexts: ["selection"], 98 + }); 99 + 100 + chrome.contextMenus.create({ 101 + id: "margin-highlight", 102 + title: 'Highlight "%s"', 103 + contexts: ["selection"], 104 + }); 105 + 106 + chrome.contextMenus.create({ 107 + id: "margin-bookmark", 108 + title: "Bookmark this page", 109 + contexts: ["page"], 110 + }); 111 + 112 + chrome.contextMenus.create({ 113 + id: "margin-open-sidebar", 114 + title: "Open Margin Sidebar", 115 + contexts: ["page", "selection", "link"], 116 + }); 117 + 118 + if (hasSidebarAction) { 119 + try { 120 + await browser.sidebarAction.close(); 121 + } catch (e) {} 122 + } 123 + }); 124 + 125 + chrome.action.onClicked.addListener(async (tab) => { 126 + const stored = await chrome.storage.local.get(["apiUrl"]); 127 + const webUrl = stored.apiUrl || WEB_BASE; 128 + chrome.tabs.create({ url: webUrl }); 129 + }); 130 + 131 + chrome.contextMenus.onClicked.addListener(async (info, tab) => { 132 + 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 + } 146 + return; 147 + } 148 + 149 + if (info.menuItemId === "margin-bookmark") { 150 + const cookie = await chrome.cookies.get({ 151 + url: API_BASE, 152 + name: "margin_session", 153 + }); 154 + 155 + if (!cookie) { 156 + showNotification("Margin", "Please sign in to bookmark pages"); 157 + return; 158 + } 159 + 160 + try { 161 + const res = await fetch(`${API_BASE}/api/bookmarks`, { 162 + method: "POST", 163 + credentials: "include", 164 + headers: { 165 + "Content-Type": "application/json", 166 + "X-Session-Token": cookie.value, 167 + }, 168 + body: JSON.stringify({ 169 + url: tab.url, 170 + title: tab.title, 171 + }), 172 + }); 173 + 174 + if (res.ok) { 175 + showNotification("Margin", "Page bookmarked!"); 176 + } 177 + } catch (err) { 178 + console.error("Bookmark error:", err); 179 + } 180 + return; 181 + } 182 + 183 + if (info.menuItemId === "margin-annotate") { 184 + let selector = null; 185 + 186 + try { 187 + const response = await chrome.tabs.sendMessage(tab.id, { 188 + type: "GET_SELECTOR_FOR_ANNOTATE_INLINE", 189 + selectionText: info.selectionText, 190 + }); 191 + 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; 202 + } 203 + 204 + if (!selector && info.selectionText) { 205 + selector = { 206 + type: "TextQuoteSelector", 207 + exact: info.selectionText, 208 + }; 209 + } 210 + 211 + if (WEB_BASE) { 212 + let composeUrl = `${WEB_BASE}/new?url=${encodeURIComponent(tab.url)}`; 213 + if (selector) { 214 + composeUrl += `&selector=${encodeURIComponent(JSON.stringify(selector))}`; 215 + } 216 + chrome.tabs.create({ url: composeUrl }); 217 + } 218 + return; 219 + } 220 + 221 + if (info.menuItemId === "margin-highlight") { 222 + let selector = null; 223 + 224 + try { 225 + const response = await chrome.tabs.sendMessage(tab.id, { 226 + type: "GET_SELECTOR_FOR_HIGHLIGHT", 227 + selectionText: info.selectionText, 228 + }); 229 + if (response && response.success) return; 230 + } catch (err) {} 231 + 232 + if (info.selectionText) { 233 + selector = { 234 + type: "TextQuoteSelector", 235 + exact: info.selectionText, 236 + }; 237 + 238 + try { 239 + const cookie = await chrome.cookies.get({ 240 + url: API_BASE, 241 + name: "margin_session", 242 + }); 243 + 244 + if (!cookie) { 245 + showNotification("Margin", "Please sign in to create highlights"); 246 + return; 247 + } 248 + 249 + const res = await fetch(`${API_BASE}/api/highlights`, { 250 + method: "POST", 251 + headers: { 252 + "Content-Type": "application/json", 253 + }, 254 + credentials: "include", 255 + body: JSON.stringify({ 256 + url: tab.url, 257 + title: tab.title, 258 + selector: selector, 259 + }), 260 + }); 261 + 262 + if (res.ok) { 263 + showNotification("Margin", "Text highlighted!"); 264 + } else { 265 + const errText = await res.text(); 266 + console.error("Highlight API error:", res.status, errText); 267 + showNotification("Margin", "Failed to create highlight"); 268 + } 269 + } catch (err) { 270 + console.error("Highlight API error:", err); 271 + showNotification("Margin", "Error creating highlight"); 272 + } 273 + } else { 274 + showNotification("Margin", "No text selected"); 275 + } 276 + } 277 + }); 278 + 279 + chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { 280 + handleMessage(request, sender, sendResponse); 281 + return true; 282 + }); 283 + 284 + async function handleMessage(request, sender, sendResponse) { 285 + try { 286 + if (request.type === "UPDATE_SETTINGS") { 287 + const result = await chrome.storage.local.get(["apiUrl"]); 288 + if (result.apiUrl) updateBaseUrls(result.apiUrl); 289 + sendResponse({ success: true }); 290 + return; 291 + } 292 + 293 + switch (request.type) { 294 + case "CHECK_SESSION": { 295 + if (!API_BASE) { 296 + sendResponse({ success: true, data: { authenticated: false } }); 297 + return; 298 + } 299 + 300 + const cookie = await chrome.cookies.get({ 301 + url: API_BASE, 302 + name: "margin_session", 303 + }); 304 + 305 + if (!cookie) { 306 + sendResponse({ success: true, data: { authenticated: false } }); 307 + return; 308 + } 309 + 310 + try { 311 + const response = await fetch(`${API_BASE}/auth/session`, { 312 + credentials: "include", 313 + }); 314 + 315 + if (!response.ok) { 316 + sendResponse({ success: true, data: { authenticated: false } }); 317 + return; 318 + } 319 + 320 + const sessionData = await response.json(); 321 + sendResponse({ 322 + success: true, 323 + data: { 324 + authenticated: true, 325 + did: sessionData.did, 326 + handle: sessionData.handle, 327 + }, 328 + }); 329 + } catch (err) { 330 + console.error("Session check error:", err); 331 + sendResponse({ success: true, data: { authenticated: false } }); 332 + } 333 + break; 334 + } 335 + 336 + case "GET_ANNOTATIONS": { 337 + const pageUrl = request.data.url; 338 + const res = await fetch( 339 + `${API_BASE}/api/targets?source=${encodeURIComponent(pageUrl)}`, 340 + ); 341 + const data = await res.json(); 342 + 343 + const items = [...(data.annotations || []), ...(data.highlights || [])]; 344 + sendResponse({ success: true, data: items }); 345 + 346 + if (sender.tab) { 347 + const count = items.length; 348 + chrome.action.setBadgeText({ 349 + text: count > 0 ? count.toString() : "", 350 + tabId: sender.tab.id, 351 + }); 352 + chrome.action.setBadgeBackgroundColor({ 353 + color: "#6366f1", 354 + tabId: sender.tab.id, 355 + }); 356 + } 357 + break; 358 + } 359 + 360 + case "CREATE_ANNOTATION": { 361 + const cookie = await chrome.cookies.get({ 362 + url: API_BASE, 363 + name: "margin_session", 364 + }); 365 + 366 + if (!cookie) { 367 + sendResponse({ success: false, error: "Not authenticated" }); 368 + return; 369 + } 370 + 371 + const payload = { 372 + url: request.data.url, 373 + text: request.data.text, 374 + title: request.data.title, 375 + }; 376 + 377 + if (request.data.selector) { 378 + payload.selector = request.data.selector; 379 + } 380 + 381 + const createRes = await fetch(`${API_BASE}/api/annotations`, { 382 + method: "POST", 383 + credentials: "include", 384 + headers: { 385 + "Content-Type": "application/json", 386 + "X-Session-Token": cookie.value, 387 + }, 388 + body: JSON.stringify(payload), 389 + }); 390 + 391 + if (!createRes.ok) { 392 + const errorText = await createRes.text(); 393 + throw new Error( 394 + `Failed to create annotation: ${createRes.status} ${errorText}`, 395 + ); 396 + } 397 + 398 + const createData = await createRes.json(); 399 + sendResponse({ success: true, data: createData }); 400 + break; 401 + } 402 + 403 + case "OPEN_LOGIN": 404 + if (!WEB_BASE) { 405 + chrome.runtime.openOptionsPage(); 406 + return; 407 + } 408 + chrome.tabs.create({ url: `${WEB_BASE}/login` }); 409 + break; 410 + 411 + case "OPEN_WEB": 412 + if (!WEB_BASE) { 413 + chrome.runtime.openOptionsPage(); 414 + return; 415 + } 416 + chrome.tabs.create({ url: `${WEB_BASE}` }); 417 + break; 418 + 419 + case "OPEN_COMPOSE": { 420 + if (!WEB_BASE) { 421 + chrome.runtime.openOptionsPage(); 422 + return; 423 + } 424 + const { url, selector } = request.data; 425 + 426 + let composeUrl = `${WEB_BASE}/new?url=${encodeURIComponent(url)}`; 427 + if (selector) { 428 + composeUrl += `&selector=${encodeURIComponent(JSON.stringify(selector))}`; 429 + } 430 + chrome.tabs.create({ url: composeUrl }); 431 + break; 432 + } 433 + 434 + case "CREATE_BOOKMARK": { 435 + if (!API_BASE) { 436 + sendResponse({ success: false, error: "API URL not configured" }); 437 + return; 438 + } 439 + 440 + const cookie = await chrome.cookies.get({ 441 + url: API_BASE, 442 + name: "margin_session", 443 + }); 444 + 445 + if (!cookie) { 446 + sendResponse({ success: false, error: "Not authenticated" }); 447 + return; 448 + } 449 + 450 + const bookmarkRes = await fetch(`${API_BASE}/api/bookmarks`, { 451 + method: "POST", 452 + credentials: "include", 453 + headers: { 454 + "Content-Type": "application/json", 455 + "X-Session-Token": cookie.value, 456 + }, 457 + body: JSON.stringify({ 458 + url: request.data.url, 459 + title: request.data.title, 460 + }), 461 + }); 462 + 463 + if (!bookmarkRes.ok) { 464 + const errorText = await bookmarkRes.text(); 465 + throw new Error( 466 + `Failed to create bookmark: ${bookmarkRes.status} ${errorText}`, 467 + ); 468 + } 469 + 470 + const bookmarkData = await bookmarkRes.json(); 471 + sendResponse({ success: true, data: bookmarkData }); 472 + break; 473 + } 474 + 475 + case "CREATE_HIGHLIGHT": { 476 + if (!API_BASE) { 477 + sendResponse({ success: false, error: "API URL not configured" }); 478 + return; 479 + } 480 + 481 + const cookie = await chrome.cookies.get({ 482 + url: API_BASE, 483 + name: "margin_session", 484 + }); 485 + 486 + if (!cookie) { 487 + sendResponse({ success: false, error: "Not authenticated" }); 488 + return; 489 + } 490 + 491 + const highlightRes = await fetch(`${API_BASE}/api/highlights`, { 492 + method: "POST", 493 + credentials: "include", 494 + headers: { 495 + "Content-Type": "application/json", 496 + "X-Session-Token": cookie.value, 497 + }, 498 + body: JSON.stringify({ 499 + url: request.data.url, 500 + title: request.data.title, 501 + selector: request.data.selector, 502 + color: request.data.color || "yellow", 503 + }), 504 + }); 505 + 506 + if (!highlightRes.ok) { 507 + const errorText = await highlightRes.text(); 508 + throw new Error( 509 + `Failed to create highlight: ${highlightRes.status} ${errorText}`, 510 + ); 511 + } 512 + 513 + const highlightData = await highlightRes.json(); 514 + sendResponse({ success: true, data: highlightData }); 515 + break; 516 + } 517 + 518 + case "GET_USER_BOOKMARKS": { 519 + if (!API_BASE) { 520 + sendResponse({ success: false, error: "API URL not configured" }); 521 + return; 522 + } 523 + 524 + const did = request.data.did; 525 + const res = await fetch( 526 + `${API_BASE}/api/users/${encodeURIComponent(did)}/bookmarks`, 527 + ); 528 + 529 + if (!res.ok) { 530 + throw new Error(`Failed to fetch bookmarks: ${res.status}`); 531 + } 532 + 533 + const data = await res.json(); 534 + sendResponse({ success: true, data: data.items || [] }); 535 + break; 536 + } 537 + 538 + case "GET_USER_HIGHLIGHTS": { 539 + if (!API_BASE) { 540 + sendResponse({ success: false, error: "API URL not configured" }); 541 + return; 542 + } 543 + 544 + const did = request.data.did; 545 + const res = await fetch( 546 + `${API_BASE}/api/users/${encodeURIComponent(did)}/highlights`, 547 + ); 548 + 549 + if (!res.ok) { 550 + throw new Error(`Failed to fetch highlights: ${res.status}`); 551 + } 552 + 553 + const data = await res.json(); 554 + sendResponse({ success: true, data: data.items || [] }); 555 + break; 556 + } 557 + 558 + case "GET_USER_COLLECTIONS": { 559 + if (!API_BASE) { 560 + sendResponse({ success: false, error: "API URL not configured" }); 561 + return; 562 + } 563 + 564 + const did = request.data.did; 565 + const res = await fetch( 566 + `${API_BASE}/api/collections?author=${encodeURIComponent(did)}`, 567 + ); 568 + 569 + if (!res.ok) { 570 + throw new Error(`Failed to fetch collections: ${res.status}`); 571 + } 572 + 573 + const data = await res.json(); 574 + sendResponse({ success: true, data: data.items || [] }); 575 + break; 576 + } 577 + 578 + case "GET_CONTAINING_COLLECTIONS": { 579 + if (!API_BASE) { 580 + sendResponse({ success: false, error: "API URL not configured" }); 581 + return; 582 + } 583 + 584 + const uri = request.data.uri; 585 + const res = await fetch( 586 + `${API_BASE}/api/collections/containing?uri=${encodeURIComponent(uri)}`, 587 + ); 588 + 589 + if (!res.ok) { 590 + throw new Error( 591 + `Failed to fetch containing collections: ${res.status}`, 592 + ); 593 + } 594 + 595 + const data = await res.json(); 596 + sendResponse({ success: true, data: data || [] }); 597 + break; 598 + } 599 + 600 + case "ADD_TO_COLLECTION": { 601 + if (!API_BASE) { 602 + sendResponse({ success: false, error: "API URL not configured" }); 603 + return; 604 + } 605 + 606 + const cookie = await chrome.cookies.get({ 607 + url: API_BASE, 608 + name: "margin_session", 609 + }); 610 + 611 + if (!cookie) { 612 + sendResponse({ success: false, error: "Not authenticated" }); 613 + return; 614 + } 615 + 616 + const { collectionUri, annotationUri } = request.data; 617 + const res = await fetch( 618 + `${API_BASE}/api/collections/${encodeURIComponent(collectionUri)}/items`, 619 + { 620 + method: "POST", 621 + credentials: "include", 622 + headers: { 623 + "Content-Type": "application/json", 624 + "X-Session-Token": cookie.value, 625 + }, 626 + body: JSON.stringify({ 627 + annotationUri: annotationUri, 628 + }), 629 + }, 630 + ); 631 + 632 + if (!res.ok) { 633 + const errText = await res.text(); 634 + throw new Error( 635 + `Failed to add to collection: ${res.status} ${errText}`, 636 + ); 637 + } 638 + 639 + const data = await res.json(); 640 + sendResponse({ success: true, data }); 641 + break; 642 + } 643 + } 644 + } catch (error) { 645 + console.error("Service worker error:", error); 646 + sendResponse({ success: false, error: error.message }); 647 + } 648 + }
+21
extension/content/content.css
··· 1 + ::highlight(margin-highlight-preview) { 2 + background-color: rgba(168, 85, 247, 0.3); 3 + color: inherit; 4 + } 5 + 6 + ::highlight(margin-scroll-highlight) { 7 + background-color: rgba(99, 102, 241, 0.4); 8 + color: inherit; 9 + } 10 + 11 + ::highlight(margin-page-highlights) { 12 + background-color: rgba(252, 211, 77, 0.3); 13 + color: inherit; 14 + } 15 + 16 + .margin-notification { 17 + position: fixed; 18 + bottom: 24px; 19 + right: 24px; 20 + z-index: 999999; 21 + }
+318
extension/content/content.js
··· 1 + (() => { 2 + function buildTextQuoteSelector(selection) { 3 + const exact = selection.toString().trim(); 4 + if (!exact) return null; 5 + 6 + const range = selection.getRangeAt(0); 7 + const contextLength = 32; 8 + 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); 18 + } 19 + 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); 29 + } 30 + 31 + return { 32 + type: "TextQuoteSelector", 33 + exact: exact, 34 + prefix: prefix || undefined, 35 + suffix: suffix || undefined, 36 + }; 37 + } 38 + 39 + function findAndScrollToText(selector) { 40 + if (!selector || !selector.exact) return false; 41 + 42 + const searchText = selector.exact.trim(); 43 + const normalizedSearch = searchText.replace(/\s+/g, " "); 44 + 45 + const treeWalker = document.createTreeWalker( 46 + document.body, 47 + NodeFilter.SHOW_TEXT, 48 + null, 49 + false, 50 + ); 51 + 52 + let currentNode; 53 + while ((currentNode = treeWalker.nextNode())) { 54 + const nodeText = currentNode.textContent; 55 + const normalizedNode = nodeText.replace(/\s+/g, " "); 56 + 57 + let index = nodeText.indexOf(searchText); 58 + 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; 64 + } 65 + } 66 + 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); 73 + 74 + if (typeof CSS !== "undefined" && CSS.highlights) { 75 + const highlight = new Highlight(range); 76 + CSS.highlights.set("margin-scroll-highlight", highlight); 77 + 78 + setTimeout(() => { 79 + CSS.highlights.delete("margin-scroll-highlight"); 80 + }, 3000); 81 + } 82 + 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 + }); 93 + 94 + return true; 95 + } catch (e) { 96 + console.warn("Could not create range:", e); 97 + } 98 + } 99 + } 100 + 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 + }); 113 + } 114 + return true; 115 + } 116 + } 117 + 118 + return false; 119 + } 120 + 121 + function renderPageHighlights(highlights) { 122 + if (!highlights || !Array.isArray(highlights) || !CSS.highlights) return; 123 + 124 + const ranges = []; 125 + 126 + highlights.forEach((item) => { 127 + const selector = item.target?.selector; 128 + if (!selector?.exact) return; 129 + 130 + const searchText = selector.exact; 131 + const treeWalker = document.createTreeWalker( 132 + document.body, 133 + NodeFilter.SHOW_TEXT, 134 + null, 135 + false, 136 + ); 137 + 138 + let currentNode; 139 + while ((currentNode = treeWalker.nextNode())) { 140 + const nodeText = currentNode.textContent; 141 + const index = nodeText.indexOf(searchText); 142 + 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); 151 + } 152 + break; 153 + } 154 + } 155 + }); 156 + 157 + if (ranges.length > 0) { 158 + const highlight = new Highlight(...ranges); 159 + CSS.highlights.set("margin-page-highlights", highlight); 160 + } 161 + } 162 + 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; 169 + } 170 + 171 + const selector = buildTextQuoteSelector(selection); 172 + sendResponse({ selector: selector }); 173 + return true; 174 + } 175 + 176 + if (request.type === "GET_SELECTOR_FOR_ANNOTATE") { 177 + const selection = window.getSelection(); 178 + if (!selection || selection.toString().trim().length === 0) { 179 + return; 180 + } 181 + 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 + }); 191 + } 192 + } 193 + 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 + } 200 + 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"); 215 + 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 + } 225 + 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; 241 + } 242 + sendResponse({ success: false, error: "Could not build selector" }); 243 + return true; 244 + } 245 + 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"); 250 + } 251 + } 252 + 253 + if (request.type === "RENDER_HIGHLIGHTS") { 254 + renderPageHighlights(request.highlights); 255 + } 256 + 257 + return true; 258 + }); 259 + 260 + function showNotification(message, type = "info") { 261 + const existing = document.querySelector(".margin-notification"); 262 + if (existing) existing.remove(); 263 + 264 + const notification = document.createElement("div"); 265 + notification.className = "margin-notification"; 266 + notification.textContent = message; 267 + 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; 284 + `; 285 + 286 + document.body.appendChild(notification); 287 + 288 + setTimeout(() => { 289 + notification.style.animation = "margin-slide-out 0.2s ease forwards"; 290 + setTimeout(() => notification.remove(), 200); 291 + }, 3000); 292 + } 293 + 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); } 303 + } 304 + ::highlight(margin-highlight-preview) { 305 + background-color: rgba(168, 85, 247, 0.3); 306 + color: inherit; 307 + } 308 + ::highlight(margin-scroll-highlight) { 309 + background-color: rgba(99, 102, 241, 0.4); 310 + color: inherit; 311 + } 312 + ::highlight(margin-page-highlights) { 313 + background-color: rgba(252, 211, 77, 0.3); 314 + color: inherit; 315 + } 316 + `; 317 + document.head.appendChild(style); 318 + })();
extension/icons/favicon-16x16.png

This is a binary file and will not be displayed.

extension/icons/favicon-32x32.png

This is a binary file and will not be displayed.

extension/icons/icon-128x128.png

This is a binary file and will not be displayed.

extension/icons/icon-48x48.png

This is a binary file and will not be displayed.

extension/icons/icon-64x64.png

This is a binary file and will not be displayed.

+4
extension/icons/logo.svg
··· 1 + <svg width="265" height="231" viewBox="0 0 265 231" fill="#6366f1" xmlns="http://www.w3.org/2000/svg"> 2 + <path d="M0 230 V0 H199 V65.7156 H149.5 V115.216 H182.5 L199 131.716 V230 Z"/> 3 + <path d="M215 214.224 V230 H264.5 V0 H215.07 V16.2242 H248.5 V214.224 H215 Z"/> 4 + </svg>
+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"}
+56
extension/manifest.chrome.json
··· 1 + { 2 + "manifest_version": 3, 3 + "name": "Margin", 4 + "description": "Write in the margins of the web. Annotate any URL with AT Protocol.", 5 + "version": "0.0.11", 6 + "icons": { 7 + "16": "icons/favicon-16x16.png", 8 + "32": "icons/favicon-32x32.png", 9 + "48": "icons/icon-48x48.png", 10 + "128": "icons/icon-128x128.png" 11 + }, 12 + "action": { 13 + "default_popup": "popup/popup.html", 14 + "default_icon": { 15 + "16": "icons/favicon-16x16.png", 16 + "32": "icons/favicon-32x32.png", 17 + "48": "icons/icon-48x48.png" 18 + }, 19 + "default_title": "Margin - View annotations" 20 + }, 21 + "background": { 22 + "service_worker": "background/service-worker.js", 23 + "type": "module" 24 + }, 25 + "content_scripts": [ 26 + { 27 + "matches": ["<all_urls>"], 28 + "js": ["content/content.js"], 29 + "css": ["content/content.css"], 30 + "run_at": "document_idle" 31 + } 32 + ], 33 + "permissions": [ 34 + "storage", 35 + "activeTab", 36 + "tabs", 37 + "cookies", 38 + "contextMenus", 39 + "sidePanel" 40 + ], 41 + "side_panel": { 42 + "default_path": "sidepanel/sidepanel.html" 43 + }, 44 + "optional_permissions": ["notifications"], 45 + "host_permissions": ["<all_urls>"], 46 + "options_ui": { 47 + "page": "popup/popup.html", 48 + "open_in_tab": true 49 + }, 50 + "browser_specific_settings": { 51 + "gecko": { 52 + "id": "hello@margin.at", 53 + "strict_min_version": "109.0" 54 + } 55 + } 56 + }
+59
extension/manifest.firefox.json
··· 1 + { 2 + "manifest_version": 3, 3 + "name": "Margin", 4 + "description": "Write in the margins of the web. Annotate any URL with AT Protocol.", 5 + "version": "0.0.11", 6 + "icons": { 7 + "16": "icons/favicon-16x16.png", 8 + "32": "icons/favicon-32x32.png", 9 + "48": "icons/icon-48x48.png", 10 + "64": "icons/icon-64x64.png", 11 + "128": "icons/icon-128x128.png" 12 + }, 13 + "action": { 14 + "default_popup": "popup/popup.html", 15 + "default_icon": { 16 + "16": "icons/favicon-16x16.png", 17 + "32": "icons/favicon-32x32.png", 18 + "48": "icons/icon-48x48.png" 19 + }, 20 + "default_title": "Margin - View annotations" 21 + }, 22 + "sidebar_action": { 23 + "default_panel": "sidepanel/sidepanel.html", 24 + "default_icon": { 25 + "16": "icons/favicon-16x16.png", 26 + "32": "icons/favicon-32x32.png" 27 + }, 28 + "default_title": "Margin Sidebar", 29 + "open_at_install": false 30 + }, 31 + "background": { 32 + "scripts": ["background/service-worker.js"], 33 + "type": "module" 34 + }, 35 + "content_scripts": [ 36 + { 37 + "matches": ["<all_urls>"], 38 + "js": ["content/content.js"], 39 + "css": ["content/content.css"], 40 + "run_at": "document_idle" 41 + } 42 + ], 43 + "permissions": ["storage", "activeTab", "tabs", "cookies", "contextMenus"], 44 + "optional_permissions": ["notifications"], 45 + "host_permissions": ["<all_urls>"], 46 + "options_ui": { 47 + "page": "popup/popup.html", 48 + "open_in_tab": true 49 + }, 50 + "browser_specific_settings": { 51 + "gecko": { 52 + "id": "hello@margin.at", 53 + "strict_min_version": "109.0" 54 + }, 55 + "gecko_android": { 56 + "strict_min_version": "113.0" 57 + } 58 + } 59 + }
+52
extension/manifest.json
··· 1 + { 2 + "manifest_version": 3, 3 + "name": "Margin", 4 + "description": "Write in the margins of the web. Annotate any URL with AT Protocol.", 5 + "version": "0.0.11", 6 + "icons": { 7 + "16": "icons/favicon-16x16.png", 8 + "32": "icons/favicon-32x32.png", 9 + "48": "icons/icon-48x48.png", 10 + "128": "icons/icon-128x128.png" 11 + }, 12 + "action": { 13 + "default_popup": "popup/popup.html", 14 + "default_icon": { 15 + "16": "icons/favicon-16x16.png", 16 + "32": "icons/favicon-32x32.png", 17 + "48": "icons/icon-48x48.png" 18 + }, 19 + "default_title": "Margin - View annotations" 20 + }, 21 + "background": { 22 + "service_worker": "background/service-worker.js", 23 + "type": "module" 24 + }, 25 + "content_scripts": [ 26 + { 27 + "matches": ["<all_urls>"], 28 + "js": ["content/content.js"], 29 + "css": ["content/content.css"], 30 + "run_at": "document_idle" 31 + } 32 + ], 33 + "permissions": [ 34 + "storage", 35 + "activeTab", 36 + "tabs", 37 + "cookies", 38 + "contextMenus", 39 + "sidePanel" 40 + ], 41 + "side_panel": { 42 + "default_path": "sidepanel/sidepanel.html" 43 + }, 44 + "optional_permissions": ["notifications"], 45 + "host_permissions": ["<all_urls>"], 46 + "browser_specific_settings": { 47 + "gecko": { 48 + "id": "hello@margin.at", 49 + "strict_min_version": "109.0" 50 + } 51 + } 52 + }
+646
extension/popup/popup.css
··· 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); 15 + 16 + --border: #2d2640; 17 + --border-hover: #3d3560; 18 + 19 + --success: #22c55e; 20 + --danger: #ef4444; 21 + --warning: #f59e0b; 22 + 23 + --radius-sm: 6px; 24 + --radius-md: 10px; 25 + --radius-lg: 16px; 26 + } 27 + 28 + * { 29 + box-sizing: border-box; 30 + margin: 0; 31 + padding: 0; 32 + } 33 + 34 + body { 35 + width: 380px; 36 + height: 520px; 37 + font-family: "Inter", sans-serif; 38 + color: var(--text-primary); 39 + background-color: var(--bg-primary); 40 + overflow: hidden; 41 + } 42 + 43 + .popup { 44 + display: flex; 45 + flex-direction: column; 46 + height: 100%; 47 + } 48 + 49 + .popup-header { 50 + padding: 14px 16px; 51 + border-bottom: 1px solid var(--border); 52 + display: flex; 53 + justify-content: space-between; 54 + align-items: center; 55 + background: var(--bg-secondary); 56 + z-index: 10; 57 + } 58 + 59 + .popup-brand { 60 + display: flex; 61 + align-items: center; 62 + gap: 10px; 63 + } 64 + 65 + .popup-logo { 66 + color: var(--accent); 67 + } 68 + 69 + .popup-title { 70 + font-weight: 600; 71 + font-size: 16px; 72 + color: var(--text-primary); 73 + } 74 + 75 + .user-info { 76 + display: flex; 77 + align-items: center; 78 + gap: 8px; 79 + } 80 + 81 + .user-handle { 82 + font-size: 12px; 83 + color: var(--text-secondary); 84 + background: var(--bg-tertiary); 85 + padding: 4px 8px; 86 + border-radius: var(--radius-sm); 87 + } 88 + 89 + .tabs { 90 + display: flex; 91 + border-bottom: 1px solid var(--border); 92 + background: var(--bg-tertiary); 93 + padding: 4px; 94 + gap: 4px; 95 + } 96 + 97 + .tab-btn { 98 + flex: 1; 99 + padding: 10px 8px; 100 + background: transparent; 101 + border: none; 102 + font-size: 12px; 103 + font-weight: 500; 104 + color: var(--text-secondary); 105 + cursor: pointer; 106 + border-radius: var(--radius-sm); 107 + transition: all 0.15s; 108 + } 109 + 110 + .tab-btn:hover { 111 + color: var(--text-primary); 112 + background: var(--bg-hover); 113 + } 114 + 115 + .tab-btn.active { 116 + color: var(--text-primary); 117 + background: var(--bg-card); 118 + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); 119 + } 120 + 121 + .tab-content { 122 + display: none; 123 + flex: 1; 124 + flex-direction: column; 125 + overflow-y: auto; 126 + } 127 + 128 + .tab-content.active { 129 + display: flex; 130 + } 131 + 132 + .popup-content { 133 + flex: 1; 134 + overflow-y: auto; 135 + display: flex; 136 + flex-direction: column; 137 + background: var(--bg-primary); 138 + } 139 + 140 + .loading { 141 + display: none; 142 + flex-direction: column; 143 + align-items: center; 144 + justify-content: center; 145 + height: 100%; 146 + color: var(--text-secondary); 147 + gap: 12px; 148 + } 149 + 150 + .spinner { 151 + width: 24px; 152 + height: 24px; 153 + border: 3px solid var(--border); 154 + border-top-color: var(--accent); 155 + border-radius: 50%; 156 + animation: spin 1s linear infinite; 157 + } 158 + 159 + @keyframes spin { 160 + to { 161 + transform: rotate(360deg); 162 + } 163 + } 164 + 165 + .login-prompt { 166 + display: flex; 167 + flex-direction: column; 168 + align-items: center; 169 + justify-content: center; 170 + height: 100%; 171 + padding: 32px; 172 + text-align: center; 173 + gap: 20px; 174 + } 175 + 176 + .login-at-logo { 177 + font-size: 4rem; 178 + font-weight: 800; 179 + color: var(--accent); 180 + line-height: 1; 181 + } 182 + 183 + .login-title { 184 + font-size: 1.1rem; 185 + font-weight: 600; 186 + color: var(--text-primary); 187 + } 188 + 189 + .login-text { 190 + font-size: 14px; 191 + color: var(--text-secondary); 192 + line-height: 1.5; 193 + } 194 + 195 + .quick-actions { 196 + padding: 12px 16px; 197 + border-bottom: 1px solid var(--border); 198 + background: var(--bg-secondary); 199 + } 200 + 201 + .create-form { 202 + padding: 16px; 203 + border-bottom: 1px solid var(--border); 204 + background: var(--bg-secondary); 205 + } 206 + 207 + .form-header { 208 + display: flex; 209 + justify-content: space-between; 210 + align-items: center; 211 + margin-bottom: 10px; 212 + } 213 + 214 + .form-title { 215 + font-size: 13px; 216 + font-weight: 600; 217 + color: var(--text-primary); 218 + } 219 + 220 + .current-url { 221 + font-size: 11px; 222 + color: var(--text-tertiary); 223 + max-width: 150px; 224 + overflow: hidden; 225 + text-overflow: ellipsis; 226 + white-space: nowrap; 227 + } 228 + 229 + .annotation-input { 230 + width: 100%; 231 + padding: 12px; 232 + border: 1px solid var(--border); 233 + border-radius: var(--radius-md); 234 + font-family: inherit; 235 + font-size: 13px; 236 + resize: none; 237 + margin-bottom: 10px; 238 + background: var(--bg-tertiary); 239 + color: var(--text-primary); 240 + transition: 241 + border-color 0.15s, 242 + box-shadow 0.15s; 243 + } 244 + 245 + .annotation-input::placeholder { 246 + color: var(--text-tertiary); 247 + } 248 + 249 + .annotation-input:focus { 250 + outline: none; 251 + border-color: var(--accent); 252 + box-shadow: 0 0 0 3px var(--accent-subtle); 253 + } 254 + 255 + .form-actions { 256 + display: flex; 257 + justify-content: flex-end; 258 + } 259 + 260 + .quote-preview { 261 + margin-bottom: 12px; 262 + padding: 10px 12px; 263 + background: var(--accent-subtle); 264 + border: 1px solid var(--accent); 265 + border-radius: var(--radius-sm); 266 + } 267 + 268 + .quote-preview-header { 269 + display: flex; 270 + justify-content: space-between; 271 + align-items: center; 272 + margin-bottom: 6px; 273 + font-size: 10px; 274 + font-weight: 600; 275 + text-transform: uppercase; 276 + letter-spacing: 0.5px; 277 + color: var(--accent); 278 + } 279 + 280 + .quote-preview-clear { 281 + background: none; 282 + border: none; 283 + color: var(--text-tertiary); 284 + font-size: 14px; 285 + cursor: pointer; 286 + padding: 0 4px; 287 + line-height: 1; 288 + } 289 + 290 + .quote-preview-clear:hover { 291 + color: var(--text-primary); 292 + } 293 + 294 + .quote-preview-text { 295 + font-size: 12px; 296 + font-style: italic; 297 + color: var(--text-primary); 298 + line-height: 1.4; 299 + max-height: 60px; 300 + overflow: hidden; 301 + } 302 + 303 + .annotations-section { 304 + flex: 1; 305 + } 306 + 307 + .section-header { 308 + display: flex; 309 + justify-content: space-between; 310 + align-items: center; 311 + padding: 14px 16px; 312 + background: var(--bg-secondary); 313 + } 314 + 315 + .section-title { 316 + font-size: 11px; 317 + font-weight: 600; 318 + text-transform: uppercase; 319 + color: var(--text-tertiary); 320 + letter-spacing: 0.5px; 321 + } 322 + 323 + .annotation-count { 324 + font-size: 11px; 325 + background: var(--bg-tertiary); 326 + padding: 3px 8px; 327 + border-radius: 10px; 328 + color: var(--text-secondary); 329 + } 330 + 331 + .annotations { 332 + display: flex; 333 + flex-direction: column; 334 + gap: 10px; 335 + padding: 12px 16px; 336 + } 337 + 338 + .annotation-item { 339 + border: 1px solid var(--border); 340 + border-radius: var(--radius-md); 341 + padding: 12px; 342 + background: var(--bg-card); 343 + transition: border-color 0.15s; 344 + } 345 + 346 + .annotation-item:hover { 347 + border-color: var(--border-hover); 348 + } 349 + 350 + .annotation-item-header { 351 + display: flex; 352 + align-items: center; 353 + margin-bottom: 8px; 354 + gap: 10px; 355 + } 356 + 357 + .annotation-item-avatar { 358 + width: 28px; 359 + height: 28px; 360 + border-radius: 50%; 361 + background: linear-gradient(135deg, var(--accent), #c084fc); 362 + color: white; 363 + display: flex; 364 + align-items: center; 365 + justify-content: center; 366 + font-size: 11px; 367 + font-weight: 600; 368 + } 369 + 370 + .annotation-item-meta { 371 + flex: 1; 372 + } 373 + 374 + .annotation-item-author { 375 + font-size: 12px; 376 + font-weight: 600; 377 + color: var(--text-primary); 378 + } 379 + 380 + .annotation-item-time { 381 + font-size: 11px; 382 + color: var(--text-tertiary); 383 + } 384 + 385 + .annotation-type-badge { 386 + font-size: 10px; 387 + padding: 3px 8px; 388 + border-radius: var(--radius-sm); 389 + font-weight: 500; 390 + } 391 + 392 + .annotation-type-badge.highlight { 393 + background: rgba(251, 191, 36, 0.2); 394 + color: #fbbf24; 395 + } 396 + 397 + .annotation-item-quote { 398 + padding: 10px 12px; 399 + border-left: 3px solid #fbbf24; 400 + margin-bottom: 10px; 401 + font-size: 13px; 402 + color: var(--text-secondary); 403 + font-style: italic; 404 + background: rgba(251, 191, 36, 0.1); 405 + border-radius: 0 var(--radius-sm) var(--radius-sm) 0; 406 + } 407 + 408 + .annotation-item-text { 409 + font-size: 13px; 410 + line-height: 1.5; 411 + color: var(--text-primary); 412 + } 413 + 414 + .bookmark-item { 415 + border: 1px solid var(--border); 416 + border-radius: var(--radius-md); 417 + padding: 12px; 418 + background: var(--bg-card); 419 + text-decoration: none; 420 + color: inherit; 421 + display: block; 422 + transition: border-color 0.15s; 423 + } 424 + 425 + .bookmark-item:hover { 426 + border-color: var(--accent); 427 + } 428 + 429 + .bookmark-title { 430 + font-size: 14px; 431 + font-weight: 500; 432 + margin-bottom: 4px; 433 + white-space: nowrap; 434 + overflow: hidden; 435 + text-overflow: ellipsis; 436 + color: var(--text-primary); 437 + } 438 + 439 + .bookmark-url { 440 + font-size: 11px; 441 + color: var(--text-tertiary); 442 + white-space: nowrap; 443 + overflow: hidden; 444 + text-overflow: ellipsis; 445 + } 446 + 447 + .empty-state { 448 + display: flex; 449 + flex-direction: column; 450 + align-items: center; 451 + justify-content: center; 452 + padding: 40px 16px; 453 + text-align: center; 454 + color: var(--text-tertiary); 455 + } 456 + 457 + .empty-icon { 458 + margin-bottom: 12px; 459 + color: var(--text-tertiary); 460 + opacity: 0.5; 461 + } 462 + 463 + .empty-text { 464 + font-size: 13px; 465 + color: var(--text-secondary); 466 + } 467 + 468 + .btn { 469 + padding: 10px 18px; 470 + border-radius: var(--radius-md); 471 + border: none; 472 + font-weight: 600; 473 + cursor: pointer; 474 + font-size: 13px; 475 + transition: all 0.15s; 476 + display: inline-flex; 477 + align-items: center; 478 + justify-content: center; 479 + gap: 8px; 480 + } 481 + 482 + .btn-small { 483 + padding: 8px 14px; 484 + font-size: 12px; 485 + } 486 + 487 + .btn-primary { 488 + background: var(--accent); 489 + color: white; 490 + } 491 + 492 + .btn-primary:hover { 493 + background: var(--accent-hover); 494 + transform: translateY(-1px); 495 + } 496 + 497 + .btn-secondary { 498 + background: var(--bg-tertiary); 499 + border: 1px solid var(--border); 500 + color: var(--text-primary); 501 + width: 100%; 502 + } 503 + 504 + .btn-secondary:hover { 505 + background: var(--bg-hover); 506 + border-color: var(--border-hover); 507 + } 508 + 509 + .btn-icon { 510 + background: none; 511 + border: none; 512 + color: var(--text-secondary); 513 + cursor: pointer; 514 + padding: 6px; 515 + border-radius: var(--radius-sm); 516 + } 517 + 518 + .btn-icon:hover { 519 + color: var(--text-primary); 520 + background: var(--bg-hover); 521 + } 522 + 523 + .popup-link { 524 + font-size: 12px; 525 + color: var(--text-secondary); 526 + text-decoration: none; 527 + } 528 + 529 + .popup-link:hover { 530 + color: var(--accent); 531 + text-decoration: underline; 532 + } 533 + 534 + .popup-footer { 535 + padding: 12px 16px; 536 + border-top: 1px solid var(--border); 537 + background: var(--bg-secondary); 538 + } 539 + 540 + .settings-view { 541 + position: absolute; 542 + top: 0; 543 + left: 0; 544 + width: 100%; 545 + height: 100%; 546 + background: var(--bg-primary); 547 + z-index: 20; 548 + display: flex; 549 + flex-direction: column; 550 + padding: 16px; 551 + } 552 + 553 + .settings-header { 554 + display: flex; 555 + justify-content: space-between; 556 + align-items: center; 557 + margin-bottom: 24px; 558 + color: var(--text-primary); 559 + } 560 + 561 + .setting-item { 562 + margin-bottom: 20px; 563 + } 564 + 565 + .setting-label { 566 + font-size: 13px; 567 + font-weight: 500; 568 + color: var(--text-primary); 569 + margin-bottom: 6px; 570 + } 571 + 572 + .setting-help { 573 + font-size: 11px; 574 + color: var(--text-tertiary); 575 + margin-top: 4px; 576 + } 577 + 578 + ::-webkit-scrollbar { 579 + width: 6px; 580 + } 581 + 582 + ::-webkit-scrollbar-track { 583 + background: var(--bg-secondary); 584 + } 585 + 586 + ::-webkit-scrollbar-thumb { 587 + background: var(--border); 588 + border-radius: 3px; 589 + } 590 + 591 + ::-webkit-scrollbar-thumb:hover { 592 + background: var(--border-hover); 593 + } 594 + 595 + .collection-selector { 596 + position: absolute; 597 + top: 0; 598 + left: 0; 599 + width: 100%; 600 + height: 100%; 601 + background: var(--bg-primary); 602 + z-index: 30; 603 + display: flex; 604 + flex-direction: column; 605 + padding: 16px; 606 + } 607 + 608 + .collection-list { 609 + display: flex; 610 + flex-direction: column; 611 + gap: 8px; 612 + overflow-y: auto; 613 + flex: 1; 614 + } 615 + 616 + .collection-select-btn { 617 + display: flex; 618 + align-items: center; 619 + gap: 12px; 620 + padding: 12px; 621 + background: var(--bg-card); 622 + border: 1px solid var(--border); 623 + border-radius: var(--radius-md); 624 + color: var(--text-primary); 625 + font-size: 14px; 626 + cursor: pointer; 627 + text-align: left; 628 + transition: all 0.15s; 629 + } 630 + 631 + .collection-select-btn:hover { 632 + border-color: var(--accent); 633 + background: var(--bg-hover); 634 + } 635 + 636 + .collection-select-btn:disabled { 637 + opacity: 0.7; 638 + cursor: not-allowed; 639 + } 640 + 641 + .annotation-item-actions { 642 + display: flex; 643 + align-items: center; 644 + gap: 8px; 645 + margin-left: auto; 646 + }
+280
extension/popup/popup.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 + <title>Margin</title> 7 + <link rel="preconnect" href="https://fonts.googleapis.com" /> 8 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> 9 + <link 10 + href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" 11 + rel="stylesheet" 12 + /> 13 + <link rel="stylesheet" href="popup.css" /> 14 + </head> 15 + 16 + <body> 17 + <div class="popup"> 18 + <header class="popup-header"> 19 + <div class="popup-brand"> 20 + <span class="popup-logo"> 21 + <img src="../icons/logo.svg" alt="Margin" width="20" height="20" /> 22 + </span> 23 + <span class="popup-title">Margin</span> 24 + </div> 25 + <div id="user-info" class="user-info" style="display: none"> 26 + <span id="user-handle" class="user-handle"></span> 27 + </div> 28 + </header> 29 + 30 + <div class="popup-content"> 31 + <div id="loading" class="loading"> 32 + <div class="spinner"></div> 33 + <span>Loading...</span> 34 + </div> 35 + 36 + <div id="login-prompt" class="login-prompt" style="display: none"> 37 + <span class="login-at-logo">@</span> 38 + <h2 class="login-title">Sign in with AT Protocol</h2> 39 + <p class="login-text"> 40 + Connect your Bluesky account to annotate, highlight, and bookmark 41 + the web. 42 + </p> 43 + <button id="sign-in" class="btn btn-primary">Continue</button> 44 + </div> 45 + 46 + <div id="main-content" style="display: none"> 47 + <div class="tabs"> 48 + <button class="tab-btn active" data-tab="page">Page</button> 49 + <button class="tab-btn" data-tab="bookmarks">My Bookmarks</button> 50 + <button class="tab-btn" data-tab="highlights">My Highlights</button> 51 + </div> 52 + 53 + <div id="tab-page" class="tab-content active"> 54 + <div class="quick-actions"> 55 + <button 56 + id="bookmark-page" 57 + class="btn btn-secondary btn-small" 58 + title="Bookmark this page" 59 + > 60 + <svg 61 + width="14" 62 + height="14" 63 + viewBox="0 0 24 24" 64 + fill="none" 65 + stroke="currentColor" 66 + stroke-width="2" 67 + stroke-linecap="round" 68 + stroke-linejoin="round" 69 + > 70 + <path 71 + d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z" 72 + ></path> 73 + </svg> 74 + Bookmark Page 75 + </button> 76 + </div> 77 + 78 + <div id="create-form" class="create-form"> 79 + <div class="form-header"> 80 + <span class="form-title">New Annotation</span> 81 + <span id="current-url" class="current-url"></span> 82 + </div> 83 + <textarea 84 + id="annotation-text" 85 + class="annotation-input" 86 + placeholder="Write your annotation..." 87 + rows="3" 88 + ></textarea> 89 + <div class="form-actions"> 90 + <button 91 + id="submit-annotation" 92 + class="btn btn-primary btn-small" 93 + > 94 + Post 95 + </button> 96 + </div> 97 + </div> 98 + 99 + <div class="annotations-section"> 100 + <div class="section-header"> 101 + <span class="section-title">Annotations on this page</span> 102 + <span id="annotation-count" class="annotation-count">0</span> 103 + </div> 104 + <div id="annotations" class="annotations"></div> 105 + <div id="empty" class="empty-state" style="display: none"> 106 + <span class="empty-icon"> 107 + <svg 108 + width="32" 109 + height="32" 110 + viewBox="0 0 24 24" 111 + fill="none" 112 + stroke="currentColor" 113 + stroke-width="2" 114 + stroke-linecap="round" 115 + stroke-linejoin="round" 116 + > 117 + <path d="M22 12h-6l-2 3h-4l-2-3H2"></path> 118 + <path 119 + d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" 120 + ></path> 121 + </svg> 122 + </span> 123 + <p class="empty-text">No annotations yet</p> 124 + </div> 125 + </div> 126 + </div> 127 + 128 + <div id="tab-bookmarks" class="tab-content"> 129 + <div class="section-header"> 130 + <span class="section-title">My Saved Pages</span> 131 + </div> 132 + <div id="bookmarks-list" class="annotations"></div> 133 + <div id="bookmarks-empty" class="empty-state" style="display: none"> 134 + <span class="empty-icon"> 135 + <svg 136 + width="32" 137 + height="32" 138 + viewBox="0 0 24 24" 139 + fill="none" 140 + stroke="currentColor" 141 + stroke-width="2" 142 + stroke-linecap="round" 143 + stroke-linejoin="round" 144 + > 145 + <path 146 + d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z" 147 + ></path> 148 + </svg> 149 + </span> 150 + <p class="empty-text">You haven't bookmarked any pages yet</p> 151 + </div> 152 + </div> 153 + 154 + <div id="tab-highlights" class="tab-content"> 155 + <div class="section-header"> 156 + <span class="section-title">My Highlights</span> 157 + </div> 158 + <div id="highlights-list" class="annotations"></div> 159 + <div 160 + id="highlights-empty" 161 + class="empty-state" 162 + style="display: none" 163 + > 164 + <span class="empty-icon"> 165 + <svg 166 + width="32" 167 + height="32" 168 + viewBox="0 0 24 24" 169 + fill="none" 170 + stroke="currentColor" 171 + stroke-width="2" 172 + stroke-linecap="round" 173 + stroke-linejoin="round" 174 + > 175 + <path 176 + d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z" 177 + ></path> 178 + </svg> 179 + </span> 180 + <p class="empty-text">You haven't highlighted anything yet</p> 181 + </div> 182 + </div> 183 + </div> 184 + </div> 185 + 186 + <footer class="popup-footer"> 187 + <div 188 + style=" 189 + display: flex; 190 + justify-content: space-between; 191 + align-items: center; 192 + " 193 + > 194 + <a href="#" id="open-web" class="popup-link">Open Margin</a> 195 + <button id="toggle-settings" class="btn-icon" title="Settings"> 196 + <svg 197 + width="16" 198 + height="16" 199 + viewBox="0 0 24 24" 200 + fill="none" 201 + stroke="currentColor" 202 + stroke-width="2" 203 + stroke-linecap="round" 204 + stroke-linejoin="round" 205 + > 206 + <circle cx="12" cy="12" r="3"></circle> 207 + <path 208 + d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" 209 + ></path> 210 + </svg> 211 + </button> 212 + </div> 213 + </footer> 214 + 215 + <div id="settings-view" class="settings-view" style="display: none"> 216 + <div class="settings-header"> 217 + <h3 class="settings-title">Extension Settings</h3> 218 + <button id="close-settings" class="btn-icon">×</button> 219 + </div> 220 + <div class="setting-item"> 221 + <label for="api-url">API URL (for self-hosting)</label> 222 + <input 223 + type="url" 224 + id="api-url" 225 + placeholder="http://localhost:8080" 226 + class="settings-input" 227 + /> 228 + <p class="setting-help">Enter your backend URL</p> 229 + </div> 230 + <button id="save-settings" class="btn btn-primary" style="width: 100%"> 231 + Save 232 + </button> 233 + </div> 234 + 235 + <div 236 + id="collection-selector" 237 + class="collection-selector" 238 + style="display: none" 239 + > 240 + <div 241 + class="selector-header" 242 + style=" 243 + display: flex; 244 + justify-content: space-between; 245 + align-items: center; 246 + margin-bottom: 12px; 247 + padding-bottom: 8px; 248 + border-bottom: 1px solid var(--border); 249 + " 250 + > 251 + <h3 class="selector-title" style="margin: 0; font-size: 14px"> 252 + Add to Collection 253 + </h3> 254 + <button id="close-collection-selector" class="btn-icon">×</button> 255 + </div> 256 + <div 257 + id="collection-loading" 258 + class="spinner" 259 + style="display: none; margin: 20px auto" 260 + ></div> 261 + <div 262 + id="collection-list" 263 + class="collection-list" 264 + style=" 265 + display: flex; 266 + flex-direction: column; 267 + gap: 4px; 268 + max-height: 200px; 269 + overflow-y: auto; 270 + " 271 + ></div> 272 + <div id="collections-empty" class="empty-state" style="display: none"> 273 + <p class="empty-text">No collections found</p> 274 + </div> 275 + </div> 276 + </div> 277 + 278 + <script src="popup.js"></script> 279 + </body> 280 + </html>
+764
extension/popup/popup.js
··· 1 + const browserAPI = typeof browser !== "undefined" ? browser : chrome; 2 + 3 + document.addEventListener("DOMContentLoaded", async () => { 4 + const views = { 5 + loading: document.getElementById("loading"), 6 + login: document.getElementById("login-prompt"), 7 + main: document.getElementById("main-content"), 8 + settings: document.getElementById("settings-view"), 9 + collectionSelector: document.getElementById("collection-selector"), 10 + }; 11 + 12 + const els = { 13 + userHandle: document.getElementById("user-handle"), 14 + userInfo: document.getElementById("user-info"), 15 + signInBtn: document.getElementById("sign-in"), 16 + openWebBtn: document.getElementById("open-web"), 17 + submitBtn: document.getElementById("submit-annotation"), 18 + textInput: document.getElementById("annotation-text"), 19 + currentUrl: document.getElementById("current-url"), 20 + annotationsList: document.getElementById("annotations"), 21 + annotationCount: document.getElementById("annotation-count"), 22 + emptyState: document.getElementById("empty"), 23 + toggleSettings: document.getElementById("toggle-settings"), 24 + closeSettings: document.getElementById("close-settings"), 25 + saveSettings: document.getElementById("save-settings"), 26 + apiUrlInput: document.getElementById("api-url"), 27 + bookmarkBtn: document.getElementById("bookmark-page"), 28 + 29 + tabs: document.querySelectorAll(".tab-btn"), 30 + tabContents: document.querySelectorAll(".tab-content"), 31 + bookmarksList: document.getElementById("bookmarks-list"), 32 + bookmarksEmpty: document.getElementById("bookmarks-empty"), 33 + highlightsList: document.getElementById("highlights-list"), 34 + highlightsEmpty: document.getElementById("highlights-empty"), 35 + 36 + closeCollectionSelector: document.getElementById( 37 + "close-collection-selector", 38 + ), 39 + collectionList: document.getElementById("collection-list"), 40 + collectionLoading: document.getElementById("collection-loading"), 41 + collectionsEmpty: document.getElementById("collections-empty"), 42 + }; 43 + 44 + let currentTab = null; 45 + let apiUrl = "https://margin.at"; 46 + let currentUserDid = null; 47 + let pendingSelector = null; 48 + let activeAnnotationUriForCollection = null; 49 + 50 + const storage = await browserAPI.storage.local.get(["apiUrl"]); 51 + if (storage.apiUrl) { 52 + apiUrl = storage.apiUrl; 53 + } 54 + els.apiUrlInput.value = apiUrl; 55 + 56 + try { 57 + const [tab] = await browserAPI.tabs.query({ 58 + active: true, 59 + currentWindow: true, 60 + }); 61 + currentTab = tab; 62 + 63 + if (els.currentUrl) { 64 + els.currentUrl.textContent = new URL(tab.url).hostname; 65 + } 66 + 67 + let pendingData = null; 68 + if (browserAPI.storage.session) { 69 + try { 70 + const sessionData = await browserAPI.storage.session.get([ 71 + "pendingAnnotation", 72 + ]); 73 + if (sessionData.pendingAnnotation) { 74 + pendingData = sessionData.pendingAnnotation; 75 + await browserAPI.storage.session.remove(["pendingAnnotation"]); 76 + } 77 + } catch (e) {} 78 + } 79 + 80 + if (!pendingData) { 81 + const localData = await browserAPI.storage.local.get([ 82 + "pendingAnnotation", 83 + "pendingAnnotationExpiry", 84 + ]); 85 + if ( 86 + localData.pendingAnnotation && 87 + localData.pendingAnnotationExpiry > Date.now() 88 + ) { 89 + pendingData = localData.pendingAnnotation; 90 + } 91 + await browserAPI.storage.local.remove([ 92 + "pendingAnnotation", 93 + "pendingAnnotationExpiry", 94 + ]); 95 + } 96 + 97 + if (pendingData?.selector) { 98 + pendingSelector = pendingData.selector; 99 + showQuotePreview(pendingSelector); 100 + } 101 + 102 + checkSession(); 103 + } catch (err) { 104 + console.error("Init error:", err); 105 + showView("login"); 106 + } 107 + 108 + els.signInBtn?.addEventListener("click", () => { 109 + browserAPI.runtime.sendMessage({ type: "OPEN_LOGIN" }); 110 + }); 111 + 112 + els.openWebBtn?.addEventListener("click", () => { 113 + browserAPI.runtime.sendMessage({ type: "OPEN_WEB" }); 114 + }); 115 + 116 + els.tabs.forEach((btn) => { 117 + btn.addEventListener("click", () => { 118 + els.tabs.forEach((t) => t.classList.remove("active")); 119 + els.tabContents.forEach((c) => c.classList.remove("active")); 120 + 121 + const tabId = btn.getAttribute("data-tab"); 122 + btn.classList.add("active"); 123 + document.getElementById(`tab-${tabId}`).classList.add("active"); 124 + 125 + if (tabId === "bookmarks") loadBookmarks(); 126 + if (tabId === "highlights") loadHighlights(); 127 + }); 128 + }); 129 + 130 + els.submitBtn?.addEventListener("click", async () => { 131 + const text = els.textInput.value.trim(); 132 + if (!text) return; 133 + 134 + els.submitBtn.disabled = true; 135 + els.submitBtn.textContent = "Posting..."; 136 + 137 + try { 138 + const annotationData = { 139 + url: currentTab.url, 140 + text: text, 141 + title: currentTab.title, 142 + }; 143 + 144 + if (pendingSelector) { 145 + annotationData.selector = pendingSelector; 146 + } 147 + 148 + const res = await sendMessage({ 149 + type: "CREATE_ANNOTATION", 150 + data: annotationData, 151 + }); 152 + 153 + if (res.success) { 154 + els.textInput.value = ""; 155 + pendingSelector = null; 156 + hideQuotePreview(); 157 + loadAnnotations(); 158 + } else { 159 + alert("Failed to post annotation"); 160 + } 161 + } catch (err) { 162 + console.error("Post error:", err); 163 + alert("Error posting annotation"); 164 + } finally { 165 + els.submitBtn.disabled = false; 166 + els.submitBtn.textContent = "Post"; 167 + } 168 + }); 169 + 170 + els.bookmarkBtn?.addEventListener("click", async () => { 171 + els.bookmarkBtn.disabled = true; 172 + els.bookmarkBtn.textContent = "Saving..."; 173 + 174 + try { 175 + const res = await sendMessage({ 176 + type: "CREATE_BOOKMARK", 177 + data: { 178 + url: currentTab.url, 179 + title: currentTab.title, 180 + }, 181 + }); 182 + 183 + if (res.success) { 184 + els.bookmarkBtn.textContent = "✓ Bookmarked"; 185 + setTimeout(() => { 186 + els.bookmarkBtn.textContent = "Bookmark Page"; 187 + els.bookmarkBtn.disabled = false; 188 + }, 2000); 189 + } else { 190 + alert("Failed to bookmark page"); 191 + els.bookmarkBtn.textContent = "Bookmark Page"; 192 + els.bookmarkBtn.disabled = false; 193 + } 194 + } catch (err) { 195 + console.error("Bookmark error:", err); 196 + alert("Error bookmarking page"); 197 + els.bookmarkBtn.textContent = "Bookmark Page"; 198 + els.bookmarkBtn.disabled = false; 199 + } 200 + }); 201 + 202 + els.toggleSettings?.addEventListener("click", () => { 203 + views.settings.style.display = "flex"; 204 + }); 205 + 206 + els.closeSettings?.addEventListener("click", () => { 207 + views.settings.style.display = "none"; 208 + }); 209 + 210 + els.saveSettings?.addEventListener("click", async () => { 211 + const newUrl = els.apiUrlInput.value.replace(/\/$/, ""); 212 + if (newUrl) { 213 + await browserAPI.storage.local.set({ apiUrl: newUrl }); 214 + apiUrl = newUrl; 215 + await sendMessage({ type: "UPDATE_SETTINGS" }); 216 + views.settings.style.display = "none"; 217 + checkSession(); 218 + } 219 + }); 220 + 221 + els.closeCollectionSelector?.addEventListener("click", () => { 222 + views.collectionSelector.style.display = "none"; 223 + activeAnnotationUriForCollection = null; 224 + }); 225 + 226 + async function openCollectionSelector(annotationUri) { 227 + if (!currentUserDid) { 228 + console.error("No currentUserDid, returning early"); 229 + return; 230 + } 231 + activeAnnotationUriForCollection = annotationUri; 232 + views.collectionSelector.style.display = "flex"; 233 + els.collectionList.innerHTML = ""; 234 + els.collectionLoading.style.display = "block"; 235 + els.collectionsEmpty.style.display = "none"; 236 + 237 + try { 238 + const [collectionsRes, containingRes] = await Promise.all([ 239 + sendMessage({ 240 + type: "GET_USER_COLLECTIONS", 241 + data: { did: currentUserDid }, 242 + }), 243 + sendMessage({ 244 + type: "GET_CONTAINING_COLLECTIONS", 245 + data: { uri: annotationUri }, 246 + }), 247 + ]); 248 + 249 + if (collectionsRes.success) { 250 + const containingUris = containingRes.success 251 + ? new Set(containingRes.data) 252 + : new Set(); 253 + renderCollectionList( 254 + collectionsRes.data, 255 + annotationUri, 256 + containingUris, 257 + ); 258 + } 259 + } catch (err) { 260 + console.error("Load collections error:", err); 261 + els.collectionList.innerHTML = 262 + '<p class="error">Failed to load collections</p>'; 263 + } finally { 264 + els.collectionLoading.style.display = "none"; 265 + } 266 + } 267 + 268 + function renderCollectionList( 269 + items, 270 + annotationUri, 271 + containingUris = new Set(), 272 + ) { 273 + els.collectionList.innerHTML = ""; 274 + els.collectionList.dataset.annotationUri = annotationUri; 275 + 276 + if (!items || items.length === 0) { 277 + els.collectionsEmpty.style.display = "block"; 278 + return; 279 + } 280 + 281 + items.forEach((col) => { 282 + const btn = document.createElement("button"); 283 + btn.className = "collection-select-btn"; 284 + const isAdded = containingUris.has(col.uri); 285 + 286 + const iconSpan = document.createElement("span"); 287 + iconSpan.textContent = isAdded ? "✓" : "📁"; 288 + btn.appendChild(iconSpan); 289 + 290 + const nameSpan = document.createElement("span"); 291 + nameSpan.textContent = col.name; 292 + btn.appendChild(nameSpan); 293 + 294 + if (isAdded) { 295 + btn.classList.add("added"); 296 + btn.disabled = true; 297 + } 298 + 299 + btn.addEventListener("click", async () => { 300 + if (btn.disabled) return; 301 + const annUri = els.collectionList.dataset.annotationUri; 302 + await handleAddToCollection(col.uri, btn, annUri); 303 + }); 304 + 305 + els.collectionList.appendChild(btn); 306 + }); 307 + } 308 + 309 + async function handleAddToCollection( 310 + collectionUri, 311 + btnElement, 312 + annotationUri, 313 + ) { 314 + if (!annotationUri) { 315 + console.error("No annotationUri provided!"); 316 + alert("Error: No item selected to add"); 317 + return; 318 + } 319 + 320 + const originalText = btnElement.textContent; 321 + btnElement.disabled = true; 322 + btnElement.textContent = "Adding..."; 323 + 324 + try { 325 + const res = await sendMessage({ 326 + type: "ADD_TO_COLLECTION", 327 + data: { 328 + collectionUri: collectionUri, 329 + annotationUri: annotationUri, 330 + }, 331 + }); 332 + 333 + if (res && res.success) { 334 + btnElement.textContent = "✓ Added"; 335 + btnElement.textContent = "✓ Added"; 336 + setTimeout(() => { 337 + btnElement.textContent = originalText; 338 + btnElement.disabled = false; 339 + }, 2000); 340 + } else { 341 + alert( 342 + "Failed to add to collection: " + (res?.error || "Unknown error"), 343 + ); 344 + btnElement.textContent = originalText; 345 + btnElement.disabled = false; 346 + } 347 + } catch (err) { 348 + console.error("Add to collection error:", err); 349 + alert("Error adding to collection: " + err.message); 350 + btnElement.textContent = originalText; 351 + btnElement.disabled = false; 352 + } 353 + } 354 + 355 + async function checkSession() { 356 + showView("loading"); 357 + try { 358 + const res = await sendMessage({ type: "CHECK_SESSION" }); 359 + 360 + if (res.success && res.data?.authenticated) { 361 + if (els.userHandle) els.userHandle.textContent = "@" + res.data.handle; 362 + els.userInfo.style.display = "flex"; 363 + currentUserDid = res.data.did; 364 + showView("main"); 365 + loadAnnotations(); 366 + } else { 367 + els.userInfo.style.display = "none"; 368 + showView("login"); 369 + } 370 + } catch (err) { 371 + console.error("Session check error:", err); 372 + els.userInfo.style.display = "none"; 373 + showView("login"); 374 + } 375 + } 376 + 377 + async function loadAnnotations() { 378 + try { 379 + const res = await sendMessage({ 380 + type: "GET_ANNOTATIONS", 381 + data: { url: currentTab.url }, 382 + }); 383 + 384 + if (res.success) { 385 + renderAnnotations(res.data); 386 + } 387 + } catch (err) { 388 + console.error("Load annotations error:", err); 389 + } 390 + } 391 + 392 + async function loadBookmarks() { 393 + if (!currentUserDid) return; 394 + els.bookmarksList.innerHTML = 395 + '<div class="spinner" style="margin: 20px auto;"></div>'; 396 + els.bookmarksEmpty.style.display = "none"; 397 + 398 + try { 399 + const res = await sendMessage({ 400 + type: "GET_USER_BOOKMARKS", 401 + data: { did: currentUserDid }, 402 + }); 403 + 404 + if (res.success) { 405 + renderBookmarks(res.data); 406 + } 407 + } catch (err) { 408 + console.error("Load bookmarks error:", err); 409 + els.bookmarksList.innerHTML = 410 + '<p class="error">Failed to load bookmarks</p>'; 411 + } 412 + } 413 + 414 + async function loadHighlights() { 415 + if (!currentUserDid) return; 416 + els.highlightsList.innerHTML = 417 + '<div class="spinner" style="margin: 20px auto;"></div>'; 418 + els.highlightsEmpty.style.display = "none"; 419 + 420 + try { 421 + const res = await sendMessage({ 422 + type: "GET_USER_HIGHLIGHTS", 423 + data: { did: currentUserDid }, 424 + }); 425 + 426 + if (res.success) { 427 + renderHighlights(res.data); 428 + } 429 + } catch (err) { 430 + console.error("Load highlights error:", err); 431 + els.highlightsList.innerHTML = 432 + '<p class="error">Failed to load highlights</p>'; 433 + } 434 + } 435 + 436 + function renderAnnotations(items) { 437 + els.annotationsList.innerHTML = ""; 438 + els.annotationCount.textContent = items?.length || 0; 439 + 440 + if (!items || items.length === 0) { 441 + els.emptyState.style.display = "block"; 442 + return; 443 + } 444 + 445 + els.emptyState.style.display = "none"; 446 + items.forEach((item) => { 447 + const el = document.createElement("div"); 448 + el.className = "annotation-item"; 449 + 450 + const author = item.creator || item.author || {}; 451 + const authorName = author.handle || author.displayName || "Unknown"; 452 + const authorInitial = authorName[0]?.toUpperCase() || "?"; 453 + const createdAt = item.created || item.createdAt; 454 + const text = item.body?.value || item.text || ""; 455 + const selector = item.target?.selector; 456 + 457 + const isHighlight = item.type === "Highlight"; 458 + const quote = selector?.exact || ""; 459 + 460 + const header = document.createElement("div"); 461 + header.className = "annotation-item-header"; 462 + 463 + const avatar = document.createElement("div"); 464 + avatar.className = "annotation-item-avatar"; 465 + if (author.avatar) { 466 + const img = document.createElement("img"); 467 + img.src = author.avatar; 468 + img.alt = authorName; 469 + img.style.width = "100%"; 470 + img.style.height = "100%"; 471 + img.style.borderRadius = "50%"; 472 + img.style.objectFit = "cover"; 473 + avatar.appendChild(img); 474 + avatar.style.background = "none"; 475 + } else { 476 + avatar.textContent = authorInitial; 477 + } 478 + header.appendChild(avatar); 479 + 480 + const meta = document.createElement("div"); 481 + meta.className = "annotation-item-meta"; 482 + 483 + const authorEl = document.createElement("div"); 484 + authorEl.className = "annotation-item-author"; 485 + authorEl.textContent = "@" + authorName; 486 + meta.appendChild(authorEl); 487 + 488 + const timeEl = document.createElement("div"); 489 + timeEl.className = "annotation-item-time"; 490 + timeEl.textContent = formatDate(createdAt); 491 + meta.appendChild(timeEl); 492 + 493 + header.appendChild(meta); 494 + 495 + if (isHighlight) { 496 + const badge = document.createElement("span"); 497 + badge.className = "annotation-type-badge highlight"; 498 + badge.textContent = "Highlight"; 499 + header.appendChild(badge); 500 + } 501 + 502 + el.appendChild(header); 503 + 504 + const actions = document.createElement("div"); 505 + actions.className = "annotation-item-actions"; 506 + 507 + if ( 508 + item.author?.did === currentUserDid || 509 + item.creator?.did === currentUserDid 510 + ) { 511 + const folderBtn = document.createElement("button"); 512 + folderBtn.className = "btn-icon"; 513 + folderBtn.innerHTML = 514 + '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>'; 515 + folderBtn.title = "Add to Collection"; 516 + folderBtn.addEventListener("click", (e) => { 517 + e.stopPropagation(); 518 + const uri = item.id || item.uri; 519 + openCollectionSelector(uri); 520 + }); 521 + actions.appendChild(folderBtn); 522 + } 523 + 524 + header.appendChild(actions); 525 + 526 + if (quote) { 527 + const quoteEl = document.createElement("div"); 528 + quoteEl.className = "annotation-item-quote"; 529 + quoteEl.textContent = '"' + quote + '"'; 530 + el.appendChild(quoteEl); 531 + } 532 + 533 + if (text) { 534 + const textEl = document.createElement("div"); 535 + textEl.className = "annotation-item-text"; 536 + textEl.textContent = text; 537 + el.appendChild(textEl); 538 + } 539 + 540 + els.annotationsList.appendChild(el); 541 + }); 542 + } 543 + 544 + function renderBookmarks(items) { 545 + els.bookmarksList.innerHTML = ""; 546 + 547 + if (!items || items.length === 0) { 548 + els.bookmarksEmpty.style.display = "flex"; 549 + return; 550 + } 551 + 552 + els.bookmarksEmpty.style.display = "none"; 553 + items.forEach((item) => { 554 + const el = document.createElement("a"); 555 + el.className = "bookmark-item"; 556 + el.href = item.source; 557 + el.target = "_blank"; 558 + 559 + const row = document.createElement("div"); 560 + row.style.display = "flex"; 561 + row.style.justifyContent = "space-between"; 562 + row.style.alignItems = "center"; 563 + 564 + const content = document.createElement("div"); 565 + content.style.flex = "1"; 566 + content.style.overflow = "hidden"; 567 + 568 + const titleEl = document.createElement("div"); 569 + titleEl.className = "bookmark-title"; 570 + titleEl.textContent = item.title || item.source; 571 + content.appendChild(titleEl); 572 + 573 + const urlEl = document.createElement("div"); 574 + urlEl.className = "bookmark-url"; 575 + urlEl.textContent = new URL(item.source).hostname; 576 + content.appendChild(urlEl); 577 + 578 + row.appendChild(content); 579 + 580 + if ( 581 + item.author?.did === currentUserDid || 582 + item.creator?.did === currentUserDid 583 + ) { 584 + const folderBtn = document.createElement("button"); 585 + folderBtn.className = "btn-icon"; 586 + folderBtn.innerHTML = 587 + '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>'; 588 + folderBtn.title = "Add to Collection"; 589 + folderBtn.addEventListener("click", (e) => { 590 + e.preventDefault(); 591 + e.stopPropagation(); 592 + const uri = item.id || item.uri; 593 + openCollectionSelector(uri); 594 + }); 595 + row.appendChild(folderBtn); 596 + } 597 + 598 + el.appendChild(row); 599 + els.bookmarksList.appendChild(el); 600 + }); 601 + } 602 + 603 + function renderHighlights(items) { 604 + els.highlightsList.innerHTML = ""; 605 + 606 + if (!items || items.length === 0) { 607 + els.highlightsEmpty.style.display = "flex"; 608 + return; 609 + } 610 + 611 + els.highlightsEmpty.style.display = "none"; 612 + items.forEach((item) => { 613 + const el = document.createElement("div"); 614 + el.className = "annotation-item"; 615 + 616 + const target = item.target || {}; 617 + const selector = target.selector || {}; 618 + const quote = selector.exact || ""; 619 + const url = target.source || ""; 620 + 621 + const header = document.createElement("div"); 622 + header.className = "annotation-item-header"; 623 + 624 + const meta = document.createElement("div"); 625 + meta.className = "annotation-item-meta"; 626 + 627 + const authorEl = document.createElement("div"); 628 + authorEl.className = "annotation-item-author"; 629 + authorEl.textContent = new URL(url).hostname; 630 + meta.appendChild(authorEl); 631 + 632 + const timeEl = document.createElement("div"); 633 + timeEl.className = "annotation-item-time"; 634 + timeEl.textContent = formatDate(item.created); 635 + meta.appendChild(timeEl); 636 + 637 + header.appendChild(meta); 638 + 639 + const actions = document.createElement("div"); 640 + actions.className = "annotation-item-actions"; 641 + 642 + const folderBtn = document.createElement("button"); 643 + folderBtn.className = "btn-icon"; 644 + folderBtn.innerHTML = 645 + '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>'; 646 + folderBtn.title = "Add to Collection"; 647 + folderBtn.addEventListener("click", (e) => { 648 + e.stopPropagation(); 649 + const uri = item.id || item.uri; 650 + openCollectionSelector(uri); 651 + }); 652 + actions.appendChild(folderBtn); 653 + 654 + header.appendChild(actions); 655 + el.appendChild(header); 656 + 657 + if (quote) { 658 + const quoteEl = document.createElement("div"); 659 + quoteEl.className = "annotation-item-quote"; 660 + quoteEl.style.marginLeft = "0"; 661 + quoteEl.style.borderColor = item.color || "#fcd34d"; 662 + quoteEl.textContent = '"' + quote + '"'; 663 + el.appendChild(quoteEl); 664 + } 665 + 666 + el.style.cursor = "pointer"; 667 + el.addEventListener("click", () => { 668 + const textFragment = createTextFragment(url, selector); 669 + browserAPI.tabs.create({ url: textFragment }); 670 + }); 671 + 672 + els.highlightsList.appendChild(el); 673 + }); 674 + } 675 + 676 + function createTextFragment(url, selector) { 677 + if (!selector || selector.type !== "TextQuoteSelector" || !selector.exact) 678 + return url; 679 + 680 + let fragment = ":~:text="; 681 + if (selector.prefix) fragment += encodeURIComponent(selector.prefix) + "-,"; 682 + fragment += encodeURIComponent(selector.exact); 683 + if (selector.suffix) fragment += ",-" + encodeURIComponent(selector.suffix); 684 + 685 + return url + "#" + fragment; 686 + } 687 + 688 + function showQuotePreview(selector) { 689 + if (!selector?.exact) return; 690 + 691 + let preview = document.getElementById("quote-preview"); 692 + if (!preview) { 693 + preview = document.createElement("div"); 694 + preview.id = "quote-preview"; 695 + preview.className = "quote-preview"; 696 + const form = document.getElementById("create-form"); 697 + if (form) { 698 + form.insertBefore(preview, els.textInput); 699 + } 700 + } 701 + 702 + const header = document.createElement("div"); 703 + header.className = "quote-preview-header"; 704 + 705 + const label = document.createElement("span"); 706 + label.textContent = "Annotating:"; 707 + header.appendChild(label); 708 + 709 + const clearBtn = document.createElement("button"); 710 + clearBtn.className = "quote-preview-clear"; 711 + clearBtn.title = "Clear"; 712 + clearBtn.textContent = "×"; 713 + clearBtn.addEventListener("click", () => { 714 + pendingSelector = null; 715 + hideQuotePreview(); 716 + }); 717 + header.appendChild(clearBtn); 718 + 719 + preview.appendChild(header); 720 + 721 + const text = document.createElement("div"); 722 + text.className = "quote-preview-text"; 723 + text.textContent = '"' + selector.exact + '"'; 724 + preview.appendChild(text); 725 + 726 + els.textInput?.focus(); 727 + } 728 + 729 + function hideQuotePreview() { 730 + const preview = document.getElementById("quote-preview"); 731 + if (preview) preview.remove(); 732 + } 733 + 734 + function formatDate(dateString) { 735 + if (!dateString) return ""; 736 + try { 737 + return new Date(dateString).toLocaleDateString(); 738 + } catch { 739 + return dateString; 740 + } 741 + } 742 + 743 + function showView(viewName) { 744 + Object.keys(views).forEach((key) => { 745 + if (views[key]) views[key].style.display = "none"; 746 + }); 747 + if (views[viewName]) { 748 + views[viewName].style.display = 749 + viewName === "loading" || viewName === "settings" ? "flex" : "block"; 750 + } 751 + } 752 + 753 + function sendMessage(message) { 754 + return new Promise((resolve, reject) => { 755 + browserAPI.runtime.sendMessage(message, (response) => { 756 + if (browserAPI.runtime.lastError) { 757 + reject(browserAPI.runtime.lastError); 758 + } else { 759 + resolve(response); 760 + } 761 + }); 762 + }); 763 + } 764 + });
+734
extension/sidepanel/sidepanel.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 + } 32 + 33 + * { 34 + margin: 0; 35 + padding: 0; 36 + box-sizing: border-box; 37 + } 38 + 39 + body { 40 + font-family: 41 + "Inter", 42 + -apple-system, 43 + BlinkMacSystemFont, 44 + "Segoe UI", 45 + sans-serif; 46 + background: var(--bg-primary); 47 + color: var(--text-primary); 48 + min-height: 100vh; 49 + -webkit-font-smoothing: antialiased; 50 + } 51 + 52 + .sidebar { 53 + display: flex; 54 + flex-direction: column; 55 + height: 100vh; 56 + background: var(--bg-primary); 57 + } 58 + 59 + .sidebar-header { 60 + display: flex; 61 + align-items: center; 62 + justify-content: space-between; 63 + padding: 14px 16px; 64 + border-bottom: 1px solid var(--border); 65 + background: var(--bg-secondary); 66 + } 67 + 68 + .sidebar-brand { 69 + display: flex; 70 + align-items: center; 71 + gap: 10px; 72 + } 73 + 74 + .sidebar-logo { 75 + color: var(--accent); 76 + } 77 + 78 + .sidebar-title { 79 + font-weight: 600; 80 + font-size: 16px; 81 + color: var(--text-primary); 82 + } 83 + 84 + .user-info { 85 + display: flex; 86 + align-items: center; 87 + gap: 8px; 88 + } 89 + 90 + .user-handle { 91 + font-size: 12px; 92 + color: var(--text-secondary); 93 + background: var(--bg-tertiary); 94 + padding: 4px 8px; 95 + border-radius: var(--radius-sm); 96 + } 97 + 98 + .current-page-info { 99 + display: flex; 100 + align-items: center; 101 + gap: 8px; 102 + padding: 10px 16px; 103 + background: var(--bg-tertiary); 104 + border-bottom: 1px solid var(--border); 105 + } 106 + 107 + .page-url { 108 + font-size: 12px; 109 + color: var(--text-secondary); 110 + white-space: nowrap; 111 + overflow: hidden; 112 + text-overflow: ellipsis; 113 + } 114 + 115 + .sidebar-content { 116 + flex: 1; 117 + overflow-y: auto; 118 + display: flex; 119 + flex-direction: column; 120 + } 121 + 122 + .loading { 123 + display: flex; 124 + flex-direction: column; 125 + align-items: center; 126 + justify-content: center; 127 + height: 100%; 128 + color: var(--text-secondary); 129 + gap: 12px; 130 + } 131 + 132 + .spinner { 133 + width: 24px; 134 + height: 24px; 135 + border: 3px solid var(--border); 136 + border-top-color: var(--accent); 137 + border-radius: 50%; 138 + animation: spin 1s linear infinite; 139 + } 140 + 141 + @keyframes spin { 142 + to { 143 + transform: rotate(360deg); 144 + } 145 + } 146 + 147 + .login-prompt { 148 + display: flex; 149 + flex-direction: column; 150 + align-items: center; 151 + justify-content: center; 152 + height: 100%; 153 + padding: 32px; 154 + text-align: center; 155 + gap: 20px; 156 + } 157 + 158 + .login-at-logo { 159 + font-size: 4rem; 160 + font-weight: 800; 161 + color: var(--accent); 162 + line-height: 1; 163 + } 164 + 165 + .login-title { 166 + font-size: 1.1rem; 167 + font-weight: 600; 168 + color: var(--text-primary); 169 + } 170 + 171 + .login-text { 172 + font-size: 14px; 173 + color: var(--text-secondary); 174 + line-height: 1.5; 175 + } 176 + 177 + .tabs { 178 + display: flex; 179 + border-bottom: 1px solid var(--border); 180 + background: var(--bg-tertiary); 181 + padding: 4px; 182 + gap: 4px; 183 + margin: 0; 184 + } 185 + 186 + .tab-btn { 187 + flex: 1; 188 + padding: 10px 8px; 189 + background: transparent; 190 + border: none; 191 + font-size: 12px; 192 + font-weight: 500; 193 + color: var(--text-secondary); 194 + cursor: pointer; 195 + border-radius: var(--radius-sm); 196 + transition: all 0.15s; 197 + } 198 + 199 + .tab-btn:hover { 200 + color: var(--text-primary); 201 + background: var(--bg-hover); 202 + } 203 + 204 + .tab-btn.active { 205 + color: var(--text-primary); 206 + background: var(--bg-card); 207 + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); 208 + } 209 + 210 + .tab-content { 211 + display: none; 212 + flex: 1; 213 + flex-direction: column; 214 + overflow-y: auto; 215 + } 216 + 217 + .tab-content.active { 218 + display: flex; 219 + } 220 + 221 + .quick-actions { 222 + display: flex; 223 + gap: 8px; 224 + padding: 12px 16px; 225 + border-bottom: 1px solid var(--border); 226 + background: var(--bg-secondary); 227 + } 228 + 229 + .btn { 230 + padding: 10px 18px; 231 + border-radius: var(--radius-md); 232 + border: none; 233 + font-weight: 600; 234 + cursor: pointer; 235 + font-size: 13px; 236 + transition: all 0.15s; 237 + display: inline-flex; 238 + align-items: center; 239 + justify-content: center; 240 + gap: 8px; 241 + } 242 + 243 + .btn-small { 244 + padding: 8px 14px; 245 + font-size: 12px; 246 + } 247 + 248 + .btn-primary { 249 + background: var(--accent); 250 + color: white; 251 + } 252 + 253 + .btn-primary:hover { 254 + background: var(--accent-hover); 255 + transform: translateY(-1px); 256 + } 257 + 258 + .btn-primary:disabled { 259 + opacity: 0.5; 260 + cursor: not-allowed; 261 + transform: none; 262 + } 263 + 264 + .btn-secondary { 265 + background: var(--bg-tertiary); 266 + border: 1px solid var(--border); 267 + color: var(--text-primary); 268 + flex: 1; 269 + } 270 + 271 + .btn-secondary:hover { 272 + background: var(--bg-hover); 273 + border-color: var(--border-hover); 274 + } 275 + 276 + .btn-icon-text { 277 + flex: 1; 278 + } 279 + 280 + .btn-icon { 281 + background: none; 282 + border: none; 283 + color: var(--text-secondary); 284 + cursor: pointer; 285 + padding: 6px; 286 + border-radius: var(--radius-sm); 287 + } 288 + 289 + .btn-icon:hover { 290 + color: var(--text-primary); 291 + background: var(--bg-hover); 292 + } 293 + 294 + .create-form { 295 + padding: 16px; 296 + border-bottom: 1px solid var(--border); 297 + background: var(--bg-secondary); 298 + } 299 + 300 + .form-header { 301 + display: flex; 302 + justify-content: space-between; 303 + align-items: center; 304 + margin-bottom: 10px; 305 + } 306 + 307 + .form-title { 308 + font-size: 13px; 309 + font-weight: 600; 310 + color: var(--text-primary); 311 + } 312 + 313 + .annotation-input { 314 + width: 100%; 315 + padding: 12px; 316 + border: 1px solid var(--border); 317 + border-radius: var(--radius-md); 318 + font-family: inherit; 319 + font-size: 13px; 320 + resize: none; 321 + margin-bottom: 10px; 322 + background: var(--bg-tertiary); 323 + color: var(--text-primary); 324 + transition: 325 + border-color 0.15s, 326 + box-shadow 0.15s; 327 + } 328 + 329 + .annotation-input::placeholder { 330 + color: var(--text-tertiary); 331 + } 332 + 333 + .annotation-input:focus { 334 + outline: none; 335 + border-color: var(--accent); 336 + box-shadow: 0 0 0 3px var(--accent-subtle); 337 + } 338 + 339 + .form-actions { 340 + display: flex; 341 + justify-content: flex-end; 342 + } 343 + 344 + .quote-preview { 345 + margin-bottom: 12px; 346 + padding: 12px; 347 + background: var(--accent-subtle); 348 + border: 1px solid var(--accent); 349 + border-radius: var(--radius-md); 350 + } 351 + 352 + .quote-preview-header { 353 + display: flex; 354 + justify-content: space-between; 355 + align-items: center; 356 + margin-bottom: 8px; 357 + font-size: 11px; 358 + font-weight: 600; 359 + text-transform: uppercase; 360 + letter-spacing: 0.5px; 361 + color: var(--accent); 362 + } 363 + 364 + .quote-preview-clear { 365 + background: none; 366 + border: none; 367 + color: var(--text-tertiary); 368 + font-size: 16px; 369 + cursor: pointer; 370 + padding: 0 4px; 371 + line-height: 1; 372 + } 373 + 374 + .quote-preview-clear:hover { 375 + color: var(--text-primary); 376 + } 377 + 378 + .quote-preview-text { 379 + font-size: 13px; 380 + font-style: italic; 381 + color: var(--text-primary); 382 + line-height: 1.5; 383 + } 384 + 385 + .annotations-section { 386 + flex: 1; 387 + } 388 + 389 + .section-header { 390 + display: flex; 391 + justify-content: space-between; 392 + align-items: center; 393 + padding: 14px 16px; 394 + background: var(--bg-secondary); 395 + } 396 + 397 + .section-title { 398 + font-size: 11px; 399 + font-weight: 600; 400 + text-transform: uppercase; 401 + color: var(--text-tertiary); 402 + letter-spacing: 0.5px; 403 + } 404 + 405 + .annotation-count { 406 + font-size: 11px; 407 + background: var(--bg-tertiary); 408 + padding: 3px 8px; 409 + border-radius: 10px; 410 + color: var(--text-secondary); 411 + } 412 + 413 + .annotations-list { 414 + display: flex; 415 + flex-direction: column; 416 + gap: 10px; 417 + padding: 12px 16px; 418 + } 419 + 420 + .annotation-item { 421 + border: 1px solid var(--border); 422 + border-radius: var(--radius-md); 423 + padding: 12px; 424 + background: var(--bg-card); 425 + transition: border-color 0.15s; 426 + } 427 + 428 + .annotation-item:hover { 429 + border-color: var(--border-hover); 430 + } 431 + 432 + .annotation-item-header { 433 + display: flex; 434 + align-items: center; 435 + margin-bottom: 8px; 436 + gap: 10px; 437 + } 438 + 439 + .annotation-item-avatar { 440 + width: 28px; 441 + height: 28px; 442 + border-radius: 50%; 443 + background: linear-gradient(135deg, var(--accent), #c084fc); 444 + color: white; 445 + display: flex; 446 + align-items: center; 447 + justify-content: center; 448 + font-size: 11px; 449 + font-weight: 600; 450 + } 451 + 452 + .annotation-item-meta { 453 + flex: 1; 454 + } 455 + 456 + .annotation-item-author { 457 + font-size: 12px; 458 + font-weight: 600; 459 + color: var(--text-primary); 460 + } 461 + 462 + .annotation-item-time { 463 + font-size: 11px; 464 + color: var(--text-tertiary); 465 + } 466 + 467 + .annotation-type-badge { 468 + font-size: 10px; 469 + padding: 3px 8px; 470 + border-radius: var(--radius-sm); 471 + font-weight: 500; 472 + } 473 + 474 + .annotation-type-badge.highlight { 475 + background: rgba(251, 191, 36, 0.2); 476 + color: #fbbf24; 477 + } 478 + 479 + .annotation-item-quote { 480 + padding: 10px 12px; 481 + border-left: 3px solid #fbbf24; 482 + margin-bottom: 10px; 483 + font-size: 13px; 484 + color: var(--text-secondary); 485 + font-style: italic; 486 + background: rgba(251, 191, 36, 0.1); 487 + border-radius: 0 var(--radius-sm) var(--radius-sm) 0; 488 + } 489 + 490 + .annotation-item-text { 491 + font-size: 13px; 492 + line-height: 1.5; 493 + color: var(--text-primary); 494 + } 495 + 496 + .bookmarks-list { 497 + display: flex; 498 + flex-direction: column; 499 + gap: 10px; 500 + padding: 12px 16px; 501 + } 502 + 503 + .bookmark-item { 504 + border: 1px solid var(--border); 505 + border-radius: var(--radius-md); 506 + padding: 12px; 507 + background: var(--bg-card); 508 + text-decoration: none; 509 + color: inherit; 510 + display: block; 511 + transition: border-color 0.15s; 512 + } 513 + 514 + .bookmark-item:hover { 515 + border-color: var(--accent); 516 + } 517 + 518 + .bookmark-title { 519 + font-size: 14px; 520 + font-weight: 500; 521 + margin-bottom: 4px; 522 + white-space: nowrap; 523 + overflow: hidden; 524 + text-overflow: ellipsis; 525 + color: var(--text-primary); 526 + } 527 + 528 + .bookmark-url { 529 + font-size: 11px; 530 + color: var(--text-tertiary); 531 + white-space: nowrap; 532 + overflow: hidden; 533 + text-overflow: ellipsis; 534 + } 535 + 536 + .empty-state { 537 + display: flex; 538 + flex-direction: column; 539 + align-items: center; 540 + justify-content: center; 541 + padding: 40px 16px; 542 + text-align: center; 543 + color: var(--text-tertiary); 544 + } 545 + 546 + .empty-icon { 547 + margin-bottom: 12px; 548 + color: var(--text-tertiary); 549 + opacity: 0.5; 550 + } 551 + 552 + .empty-text { 553 + font-size: 13px; 554 + color: var(--text-secondary); 555 + margin-bottom: 4px; 556 + } 557 + 558 + .empty-hint { 559 + font-size: 12px; 560 + color: var(--text-tertiary); 561 + } 562 + 563 + .sidebar-footer { 564 + display: flex; 565 + align-items: center; 566 + justify-content: space-between; 567 + padding: 12px 16px; 568 + border-top: 1px solid var(--border); 569 + background: var(--bg-secondary); 570 + } 571 + 572 + .sidebar-link { 573 + font-size: 12px; 574 + color: var(--text-secondary); 575 + text-decoration: none; 576 + } 577 + 578 + .sidebar-link:hover { 579 + color: var(--accent); 580 + text-decoration: underline; 581 + } 582 + 583 + .settings-view { 584 + position: absolute; 585 + top: 0; 586 + left: 0; 587 + width: 100%; 588 + height: 100%; 589 + background: var(--bg-primary); 590 + z-index: 20; 591 + display: flex; 592 + flex-direction: column; 593 + padding: 16px; 594 + } 595 + 596 + .settings-header { 597 + display: flex; 598 + justify-content: space-between; 599 + align-items: center; 600 + margin-bottom: 24px; 601 + color: var(--text-primary); 602 + } 603 + 604 + .settings-title { 605 + font-size: 18px; 606 + font-weight: 600; 607 + } 608 + 609 + .setting-item { 610 + margin-bottom: 20px; 611 + } 612 + 613 + .setting-item label { 614 + font-size: 13px; 615 + font-weight: 500; 616 + color: var(--text-primary); 617 + margin-bottom: 6px; 618 + display: block; 619 + } 620 + 621 + .settings-input { 622 + width: 100%; 623 + padding: 12px; 624 + border: 1px solid var(--border); 625 + border-radius: var(--radius-md); 626 + font-family: inherit; 627 + font-size: 13px; 628 + background: var(--bg-tertiary); 629 + color: var(--text-primary); 630 + transition: 631 + border-color 0.15s, 632 + box-shadow 0.15s; 633 + } 634 + 635 + .settings-input:focus { 636 + outline: none; 637 + border-color: var(--accent); 638 + box-shadow: 0 0 0 3px var(--accent-subtle); 639 + } 640 + 641 + .setting-help { 642 + font-size: 11px; 643 + color: var(--text-tertiary); 644 + margin-top: 4px; 645 + } 646 + 647 + .scroll-to-btn { 648 + display: inline-flex; 649 + align-items: center; 650 + gap: 4px; 651 + padding: 6px 10px; 652 + font-size: 11px; 653 + color: var(--accent); 654 + background: var(--accent-subtle); 655 + border: none; 656 + border-radius: var(--radius-sm); 657 + cursor: pointer; 658 + margin-top: 8px; 659 + transition: all 0.15s; 660 + } 661 + 662 + .scroll-to-btn:hover { 663 + background: rgba(168, 85, 247, 0.25); 664 + } 665 + 666 + ::-webkit-scrollbar { 667 + width: 6px; 668 + } 669 + 670 + ::-webkit-scrollbar-track { 671 + background: var(--bg-secondary); 672 + } 673 + 674 + ::-webkit-scrollbar-thumb { 675 + background: var(--border); 676 + border-radius: 3px; 677 + } 678 + 679 + ::-webkit-scrollbar-thumb:hover { 680 + background: var(--border-hover); 681 + } 682 + 683 + .collection-selector { 684 + position: absolute; 685 + top: 0; 686 + left: 0; 687 + width: 100%; 688 + height: 100%; 689 + background: var(--bg-primary); 690 + z-index: 30; 691 + display: flex; 692 + flex-direction: column; 693 + padding: 16px; 694 + } 695 + 696 + .collection-list { 697 + display: flex; 698 + flex-direction: column; 699 + gap: 8px; 700 + overflow-y: auto; 701 + flex: 1; 702 + } 703 + 704 + .collection-select-btn { 705 + display: flex; 706 + align-items: center; 707 + gap: 12px; 708 + padding: 12px; 709 + background: var(--bg-card); 710 + border: 1px solid var(--border); 711 + border-radius: var(--radius-md); 712 + color: var(--text-primary); 713 + font-size: 14px; 714 + cursor: pointer; 715 + text-align: left; 716 + transition: all 0.15s; 717 + } 718 + 719 + .collection-select-btn:hover { 720 + border-color: var(--accent); 721 + background: var(--bg-hover); 722 + } 723 + 724 + .collection-select-btn:disabled { 725 + opacity: 0.7; 726 + cursor: not-allowed; 727 + } 728 + 729 + .annotation-item-actions { 730 + display: flex; 731 + align-items: center; 732 + gap: 8px; 733 + margin-left: auto; 734 + }
+319
extension/sidepanel/sidepanel.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 + <title>Margin</title> 7 + <link rel="preconnect" href="https://fonts.googleapis.com" /> 8 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> 9 + <link 10 + href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" 11 + rel="stylesheet" 12 + /> 13 + <link rel="stylesheet" href="sidepanel.css" /> 14 + </head> 15 + 16 + <body> 17 + <div class="sidebar"> 18 + <header class="sidebar-header"> 19 + <div class="sidebar-brand"> 20 + <span class="sidebar-logo"> 21 + <img src="../icons/logo.svg" alt="Margin" width="20" height="20" /> 22 + </span> 23 + <span class="sidebar-title">Margin</span> 24 + </div> 25 + <div id="user-info" class="user-info" style="display: none"> 26 + <span id="user-handle" class="user-handle"></span> 27 + </div> 28 + </header> 29 + 30 + <div id="current-page-info" class="current-page-info"> 31 + <div class="page-favicon"></div> 32 + <span id="current-page-url" class="page-url">Loading...</span> 33 + </div> 34 + 35 + <div class="sidebar-content"> 36 + <div id="loading" class="loading"> 37 + <div class="spinner"></div> 38 + <span>Loading...</span> 39 + </div> 40 + 41 + <div id="login-prompt" class="login-prompt" style="display: none"> 42 + <span class="login-at-logo">@</span> 43 + <h2 class="login-title">Sign in with AT Protocol</h2> 44 + <p class="login-text"> 45 + Connect your Bluesky account to annotate, highlight, and bookmark 46 + the web. 47 + </p> 48 + <button id="sign-in" class="btn btn-primary">Sign In</button> 49 + </div> 50 + 51 + <div id="main-content" style="display: none"> 52 + <div class="tabs"> 53 + <button class="tab-btn active" data-tab="page">This Page</button> 54 + <button class="tab-btn" data-tab="highlights">Highlights</button> 55 + <button class="tab-btn" data-tab="bookmarks">Bookmarks</button> 56 + </div> 57 + 58 + <div id="tab-page" class="tab-content active"> 59 + <div class="quick-actions"> 60 + <button 61 + id="bookmark-page" 62 + class="btn btn-secondary btn-icon-text" 63 + title="Bookmark this page" 64 + > 65 + <svg 66 + width="14" 67 + height="14" 68 + viewBox="0 0 24 24" 69 + fill="none" 70 + stroke="currentColor" 71 + stroke-width="2" 72 + stroke-linecap="round" 73 + stroke-linejoin="round" 74 + > 75 + <path 76 + d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z" 77 + ></path> 78 + </svg> 79 + Bookmark 80 + </button> 81 + <button 82 + id="refresh-annotations" 83 + class="btn btn-secondary btn-icon-text" 84 + title="Refresh" 85 + > 86 + <svg 87 + width="14" 88 + height="14" 89 + viewBox="0 0 24 24" 90 + fill="none" 91 + stroke="currentColor" 92 + stroke-width="2" 93 + stroke-linecap="round" 94 + stroke-linejoin="round" 95 + > 96 + <path 97 + d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" 98 + ></path> 99 + <path d="M3 3v5h5"></path> 100 + <path 101 + d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" 102 + ></path> 103 + <path d="M16 21h5v-5"></path> 104 + </svg> 105 + Refresh 106 + </button> 107 + </div> 108 + 109 + <div class="create-form"> 110 + <div class="form-header"> 111 + <span class="form-title">New Annotation</span> 112 + </div> 113 + <textarea 114 + id="annotation-text" 115 + class="annotation-input" 116 + placeholder="Write your annotation..." 117 + rows="3" 118 + ></textarea> 119 + <div class="form-actions"> 120 + <button 121 + id="submit-annotation" 122 + class="btn btn-primary btn-small" 123 + > 124 + Post 125 + </button> 126 + </div> 127 + </div> 128 + 129 + <div class="annotations-section"> 130 + <div class="section-header"> 131 + <span class="section-title">Annotations on this page</span> 132 + <span id="annotation-count" class="annotation-count">0</span> 133 + </div> 134 + <div id="annotations" class="annotations-list"></div> 135 + <div id="empty" class="empty-state" style="display: none"> 136 + <span class="empty-icon"> 137 + <svg 138 + width="32" 139 + height="32" 140 + viewBox="0 0 24 24" 141 + fill="none" 142 + stroke="currentColor" 143 + stroke-width="2" 144 + stroke-linecap="round" 145 + stroke-linejoin="round" 146 + > 147 + <path d="M22 12h-6l-2 3h-4l-2-3H2"></path> 148 + <path 149 + d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" 150 + ></path> 151 + </svg> 152 + </span> 153 + <p class="empty-text">No annotations yet</p> 154 + <p class="empty-hint"> 155 + Select text and right-click to annotate or highlight 156 + </p> 157 + </div> 158 + </div> 159 + </div> 160 + 161 + <div id="tab-highlights" class="tab-content"> 162 + <div class="section-header"> 163 + <span class="section-title">My Highlights</span> 164 + </div> 165 + <div id="highlights-list" class="annotations-list"></div> 166 + <div 167 + id="highlights-empty" 168 + class="empty-state" 169 + style="display: none" 170 + > 171 + <span class="empty-icon"> 172 + <svg 173 + width="32" 174 + height="32" 175 + viewBox="0 0 24 24" 176 + fill="none" 177 + stroke="currentColor" 178 + stroke-width="2" 179 + stroke-linecap="round" 180 + stroke-linejoin="round" 181 + > 182 + <path d="m9 11-6 6v3h9l3-3"></path> 183 + <path 184 + d="m22 12-4.6 4.6a2 2 0 0 1-2.8 0l-5.2-5.2a2 2 0 0 1 0-2.8L14 4" 185 + ></path> 186 + </svg> 187 + </span> 188 + <p class="empty-text">No highlights yet</p> 189 + <p class="empty-hint"> 190 + Select text on any page and right-click → Highlight 191 + </p> 192 + </div> 193 + </div> 194 + 195 + <div id="tab-bookmarks" class="tab-content"> 196 + <div class="section-header"> 197 + <span class="section-title">My Bookmarks</span> 198 + </div> 199 + <div id="bookmarks-list" class="bookmarks-list"></div> 200 + <div id="bookmarks-empty" class="empty-state" style="display: none"> 201 + <span class="empty-icon"> 202 + <svg 203 + width="32" 204 + height="32" 205 + viewBox="0 0 24 24" 206 + fill="none" 207 + stroke="currentColor" 208 + stroke-width="2" 209 + stroke-linecap="round" 210 + stroke-linejoin="round" 211 + > 212 + <path 213 + d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z" 214 + ></path> 215 + </svg> 216 + </span> 217 + <p class="empty-text">No bookmarks yet</p> 218 + <p class="empty-hint"> 219 + Right-click on any page → Bookmark this page 220 + </p> 221 + </div> 222 + </div> 223 + </div> 224 + </div> 225 + 226 + <footer class="sidebar-footer"> 227 + <a href="#" id="open-web" class="sidebar-link">Open Margin Web</a> 228 + <button id="toggle-settings" class="btn-icon" title="Settings"> 229 + <svg 230 + width="16" 231 + height="16" 232 + viewBox="0 0 24 24" 233 + fill="none" 234 + stroke="currentColor" 235 + stroke-width="2" 236 + stroke-linecap="round" 237 + stroke-linejoin="round" 238 + > 239 + <circle cx="12" cy="12" r="3"></circle> 240 + <path 241 + d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" 242 + ></path> 243 + </svg> 244 + </button> 245 + </footer> 246 + 247 + <div id="settings-view" class="settings-view" style="display: none"> 248 + <div class="settings-header"> 249 + <h3 class="settings-title">Settings</h3> 250 + <button id="close-settings" class="btn-icon">×</button> 251 + </div> 252 + <div class="setting-item"> 253 + <label for="api-url">API URL</label> 254 + <input 255 + type="url" 256 + id="api-url" 257 + placeholder="http://localhost:8080" 258 + class="settings-input" 259 + /> 260 + <p class="setting-help">Enter your Margin backend URL</p> 261 + </div> 262 + <button id="save-settings" class="btn btn-primary" style="width: 100%"> 263 + Save 264 + </button> 265 + <button 266 + id="sign-out" 267 + class="btn btn-secondary" 268 + style="width: 100%; margin-top: 8px" 269 + > 270 + Sign Out 271 + </button> 272 + </div> 273 + 274 + <div 275 + id="collection-selector" 276 + class="collection-selector" 277 + style="display: none" 278 + > 279 + <div 280 + class="selector-header" 281 + style=" 282 + display: flex; 283 + justify-content: space-between; 284 + align-items: center; 285 + margin-bottom: 12px; 286 + padding-bottom: 8px; 287 + border-bottom: 1px solid var(--border); 288 + " 289 + > 290 + <h3 class="selector-title" style="margin: 0; font-size: 14px"> 291 + Add to Collection 292 + </h3> 293 + <button id="close-collection-selector" class="btn-icon">×</button> 294 + </div> 295 + <div 296 + id="collection-loading" 297 + class="spinner" 298 + style="display: none; margin: 20px auto" 299 + ></div> 300 + <div 301 + id="collection-list" 302 + class="collection-list" 303 + style=" 304 + display: flex; 305 + flex-direction: column; 306 + gap: 4px; 307 + max-height: 200px; 308 + overflow-y: auto; 309 + " 310 + ></div> 311 + <div id="collections-empty" class="empty-state" style="display: none"> 312 + <p class="empty-text">No collections found</p> 313 + </div> 314 + </div> 315 + </div> 316 + 317 + <script src="sidepanel.js"></script> 318 + </body> 319 + </html>
+899
extension/sidepanel/sidepanel.js
··· 1 + document.addEventListener("DOMContentLoaded", async () => { 2 + const views = { 3 + loading: document.getElementById("loading"), 4 + login: document.getElementById("login-prompt"), 5 + main: document.getElementById("main-content"), 6 + settings: document.getElementById("settings-view"), 7 + collectionSelector: document.getElementById("collection-selector"), 8 + }; 9 + 10 + const els = { 11 + userHandle: document.getElementById("user-handle"), 12 + userInfo: document.getElementById("user-info"), 13 + signInBtn: document.getElementById("sign-in"), 14 + openWebBtn: document.getElementById("open-web"), 15 + submitBtn: document.getElementById("submit-annotation"), 16 + textInput: document.getElementById("annotation-text"), 17 + currentPageUrl: document.getElementById("current-page-url"), 18 + annotationsList: document.getElementById("annotations"), 19 + annotationCount: document.getElementById("annotation-count"), 20 + emptyState: document.getElementById("empty"), 21 + toggleSettings: document.getElementById("toggle-settings"), 22 + closeSettings: document.getElementById("close-settings"), 23 + saveSettings: document.getElementById("save-settings"), 24 + signOutBtn: document.getElementById("sign-out"), 25 + apiUrlInput: document.getElementById("api-url"), 26 + bookmarkBtn: document.getElementById("bookmark-page"), 27 + refreshBtn: document.getElementById("refresh-annotations"), 28 + tabs: document.querySelectorAll(".tab-btn"), 29 + tabContents: document.querySelectorAll(".tab-content"), 30 + bookmarksList: document.getElementById("bookmarks-list"), 31 + bookmarksEmpty: document.getElementById("bookmarks-empty"), 32 + highlightsList: document.getElementById("highlights-list"), 33 + highlightsEmpty: document.getElementById("highlights-empty"), 34 + closeCollectionSelector: document.getElementById( 35 + "close-collection-selector", 36 + ), 37 + collectionList: document.getElementById("collection-list"), 38 + collectionLoading: document.getElementById("collection-loading"), 39 + collectionsEmpty: document.getElementById("collections-empty"), 40 + }; 41 + 42 + let currentTab = null; 43 + let apiUrl = ""; 44 + let currentUserDid = null; 45 + let pendingSelector = null; 46 + let activeAnnotationUriForCollection = null; 47 + 48 + const storage = await chrome.storage.local.get(["apiUrl"]); 49 + if (storage.apiUrl) { 50 + apiUrl = storage.apiUrl; 51 + } 52 + 53 + els.apiUrlInput.value = apiUrl; 54 + 55 + chrome.storage.onChanged.addListener((changes, area) => { 56 + if (area === "local" && changes.apiUrl) { 57 + apiUrl = changes.apiUrl.newValue || ""; 58 + 59 + els.apiUrlInput.value = apiUrl; 60 + checkSession(); 61 + } 62 + }); 63 + 64 + try { 65 + const [tab] = await chrome.tabs.query({ 66 + active: true, 67 + currentWindow: true, 68 + }); 69 + currentTab = tab; 70 + if (els.currentPageUrl) { 71 + try { 72 + els.currentPageUrl.textContent = new URL(tab.url).hostname; 73 + } catch { 74 + els.currentPageUrl.textContent = tab.url; 75 + } 76 + } 77 + 78 + let pendingData = null; 79 + if (chrome.storage.session) { 80 + const sessionData = await chrome.storage.session.get([ 81 + "pendingAnnotation", 82 + ]); 83 + if (sessionData.pendingAnnotation) { 84 + pendingData = sessionData.pendingAnnotation; 85 + await chrome.storage.session.remove(["pendingAnnotation"]); 86 + } 87 + } 88 + 89 + if (!pendingData) { 90 + const localData = await chrome.storage.local.get([ 91 + "pendingAnnotation", 92 + "pendingAnnotationExpiry", 93 + ]); 94 + if ( 95 + localData.pendingAnnotation && 96 + localData.pendingAnnotationExpiry > Date.now() 97 + ) { 98 + pendingData = localData.pendingAnnotation; 99 + } 100 + await chrome.storage.local.remove([ 101 + "pendingAnnotation", 102 + "pendingAnnotationExpiry", 103 + ]); 104 + } 105 + 106 + if (pendingData?.selector) { 107 + pendingSelector = pendingData.selector; 108 + showQuotePreview(pendingSelector); 109 + } 110 + 111 + checkSession(); 112 + } catch (err) { 113 + console.error("Init error:", err); 114 + showView("login"); 115 + } 116 + 117 + chrome.tabs.onActivated.addListener(async (activeInfo) => { 118 + try { 119 + const tab = await chrome.tabs.get(activeInfo.tabId); 120 + currentTab = tab; 121 + if (els.currentPageUrl) { 122 + try { 123 + els.currentPageUrl.textContent = new URL(tab.url).hostname; 124 + } catch { 125 + els.currentPageUrl.textContent = tab.url; 126 + } 127 + } 128 + loadAnnotations(); 129 + } catch (err) { 130 + console.error("Tab change error:", err); 131 + } 132 + }); 133 + 134 + chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { 135 + if (currentTab && tabId === currentTab.id && changeInfo.url) { 136 + currentTab = tab; 137 + if (els.currentPageUrl) { 138 + try { 139 + els.currentPageUrl.textContent = new URL(tab.url).hostname; 140 + } catch { 141 + els.currentPageUrl.textContent = tab.url; 142 + } 143 + } 144 + loadAnnotations(); 145 + } 146 + }); 147 + 148 + els.signInBtn?.addEventListener("click", () => { 149 + chrome.runtime.sendMessage({ type: "OPEN_LOGIN" }); 150 + }); 151 + 152 + els.openWebBtn?.addEventListener("click", (e) => { 153 + e.preventDefault(); 154 + chrome.runtime.sendMessage({ type: "OPEN_WEB" }); 155 + }); 156 + 157 + els.tabs.forEach((btn) => { 158 + btn.addEventListener("click", () => { 159 + els.tabs.forEach((t) => t.classList.remove("active")); 160 + els.tabContents.forEach((c) => c.classList.remove("active")); 161 + const tabId = btn.getAttribute("data-tab"); 162 + btn.classList.add("active"); 163 + document.getElementById(`tab-${tabId}`).classList.add("active"); 164 + if (tabId === "bookmarks") loadBookmarks(); 165 + if (tabId === "highlights") loadHighlights(); 166 + }); 167 + }); 168 + 169 + els.submitBtn?.addEventListener("click", async () => { 170 + const text = els.textInput.value.trim(); 171 + if (!text) return; 172 + 173 + els.submitBtn.disabled = true; 174 + els.submitBtn.textContent = "Posting..."; 175 + 176 + try { 177 + const annotationData = { 178 + url: currentTab.url, 179 + text: text, 180 + title: currentTab.title, 181 + }; 182 + 183 + if (pendingSelector) { 184 + annotationData.selector = pendingSelector; 185 + } 186 + 187 + const res = await sendMessage({ 188 + type: "CREATE_ANNOTATION", 189 + data: annotationData, 190 + }); 191 + 192 + if (res.success) { 193 + els.textInput.value = ""; 194 + pendingSelector = null; 195 + hideQuotePreview(); 196 + loadAnnotations(); 197 + } else { 198 + alert("Failed to post annotation: " + (res.error || "Unknown error")); 199 + } 200 + } catch (err) { 201 + console.error("Post error:", err); 202 + alert("Error posting annotation"); 203 + } finally { 204 + els.submitBtn.disabled = false; 205 + els.submitBtn.textContent = "Post"; 206 + } 207 + }); 208 + 209 + els.bookmarkBtn?.addEventListener("click", async () => { 210 + els.bookmarkBtn.disabled = true; 211 + const originalText = els.bookmarkBtn.textContent; 212 + els.bookmarkBtn.textContent = "Saving..."; 213 + 214 + try { 215 + const res = await sendMessage({ 216 + type: "CREATE_BOOKMARK", 217 + data: { 218 + url: currentTab.url, 219 + title: currentTab.title, 220 + }, 221 + }); 222 + 223 + if (res.success) { 224 + els.bookmarkBtn.textContent = "✓ Bookmarked"; 225 + setTimeout(() => { 226 + els.bookmarkBtn.textContent = originalText; 227 + els.bookmarkBtn.disabled = false; 228 + }, 2000); 229 + } else { 230 + alert("Failed to bookmark page: " + (res.error || "Unknown error")); 231 + els.bookmarkBtn.textContent = originalText; 232 + els.bookmarkBtn.disabled = false; 233 + } 234 + } catch (err) { 235 + console.error("Bookmark error:", err); 236 + alert("Error bookmarking page"); 237 + els.bookmarkBtn.textContent = originalText; 238 + els.bookmarkBtn.disabled = false; 239 + } 240 + }); 241 + 242 + els.refreshBtn?.addEventListener("click", () => { 243 + loadAnnotations(); 244 + }); 245 + 246 + els.toggleSettings?.addEventListener("click", () => { 247 + views.settings.style.display = "flex"; 248 + }); 249 + 250 + els.closeSettings?.addEventListener("click", () => { 251 + views.settings.style.display = "none"; 252 + }); 253 + 254 + els.closeCollectionSelector?.addEventListener("click", () => { 255 + views.collectionSelector.style.display = "none"; 256 + activeAnnotationUriForCollection = null; 257 + }); 258 + 259 + els.saveSettings?.addEventListener("click", async () => { 260 + const newUrl = els.apiUrlInput.value.replace(/\/$/, ""); 261 + if (newUrl) { 262 + await chrome.storage.local.set({ apiUrl: newUrl }); 263 + apiUrl = newUrl; 264 + await sendMessage({ type: "UPDATE_SETTINGS" }); 265 + views.settings.style.display = "none"; 266 + checkSession(); 267 + } 268 + }); 269 + 270 + els.signOutBtn?.addEventListener("click", async () => { 271 + if (apiUrl) { 272 + await chrome.cookies.remove({ 273 + url: apiUrl, 274 + name: "margin_session", 275 + }); 276 + } 277 + views.settings.style.display = "none"; 278 + showView("login"); 279 + els.userInfo.style.display = "none"; 280 + }); 281 + 282 + async function checkSession() { 283 + showView("loading"); 284 + try { 285 + const res = await sendMessage({ type: "CHECK_SESSION" }); 286 + 287 + if (res.success && res.data?.authenticated) { 288 + if (els.userHandle) els.userHandle.textContent = "@" + res.data.handle; 289 + els.userInfo.style.display = "flex"; 290 + currentUserDid = res.data.did; 291 + showView("main"); 292 + loadAnnotations(); 293 + } else { 294 + els.userInfo.style.display = "none"; 295 + showView("login"); 296 + } 297 + } catch (err) { 298 + console.error("Session check error:", err); 299 + els.userInfo.style.display = "none"; 300 + showView("login"); 301 + } 302 + } 303 + 304 + async function loadAnnotations() { 305 + if (!currentTab?.url) return; 306 + 307 + try { 308 + const res = await sendMessage({ 309 + type: "GET_ANNOTATIONS", 310 + data: { url: currentTab.url }, 311 + }); 312 + 313 + if (res.success) { 314 + renderAnnotations(res.data); 315 + } 316 + } catch (err) { 317 + console.error("Load annotations error:", err); 318 + } 319 + } 320 + 321 + async function loadBookmarks() { 322 + if (!currentUserDid) return; 323 + els.bookmarksList.innerHTML = 324 + '<div class="loading"><div class="spinner"></div></div>'; 325 + els.bookmarksEmpty.style.display = "none"; 326 + 327 + try { 328 + const res = await sendMessage({ 329 + type: "GET_USER_BOOKMARKS", 330 + data: { did: currentUserDid }, 331 + }); 332 + 333 + if (res.success) { 334 + renderBookmarks(res.data); 335 + } 336 + } catch (err) { 337 + console.error("Load bookmarks error:", err); 338 + els.bookmarksList.innerHTML = 339 + '<p style="color: #ef4444; text-align: center;">Failed to load bookmarks</p>'; 340 + } 341 + } 342 + 343 + async function loadHighlights() { 344 + if (!currentUserDid) return; 345 + els.highlightsList.innerHTML = 346 + '<div class="loading"><div class="spinner"></div></div>'; 347 + els.highlightsEmpty.style.display = "none"; 348 + 349 + try { 350 + const res = await sendMessage({ 351 + type: "GET_USER_HIGHLIGHTS", 352 + data: { did: currentUserDid }, 353 + }); 354 + 355 + if (res.success) { 356 + renderHighlights(res.data); 357 + } 358 + } catch (err) { 359 + console.error("Load highlights error:", err); 360 + els.highlightsList.innerHTML = 361 + '<p style="color: #ef4444; text-align: center;">Failed to load highlights</p>'; 362 + } 363 + } 364 + 365 + async function openCollectionSelector(annotationUri) { 366 + if (!currentUserDid) { 367 + console.error("No currentUserDid, returning early"); 368 + return; 369 + } 370 + activeAnnotationUriForCollection = annotationUri; 371 + views.collectionSelector.style.display = "flex"; 372 + els.collectionList.innerHTML = ""; 373 + els.collectionLoading.style.display = "block"; 374 + els.collectionsEmpty.style.display = "none"; 375 + 376 + try { 377 + const [collectionsRes, containingRes] = await Promise.all([ 378 + sendMessage({ 379 + type: "GET_USER_COLLECTIONS", 380 + data: { did: currentUserDid }, 381 + }), 382 + sendMessage({ 383 + type: "GET_CONTAINING_COLLECTIONS", 384 + data: { uri: annotationUri }, 385 + }), 386 + ]); 387 + 388 + if (collectionsRes.success) { 389 + const containingUris = containingRes.success 390 + ? new Set(containingRes.data) 391 + : new Set(); 392 + renderCollectionList( 393 + collectionsRes.data, 394 + annotationUri, 395 + containingUris, 396 + ); 397 + } 398 + } catch (err) { 399 + console.error("Load collections error:", err); 400 + els.collectionList.innerHTML = 401 + '<p class="error">Failed to load collections</p>'; 402 + } finally { 403 + els.collectionLoading.style.display = "none"; 404 + } 405 + } 406 + 407 + function renderCollectionList( 408 + items, 409 + annotationUri, 410 + containingUris = new Set(), 411 + ) { 412 + els.collectionList.innerHTML = ""; 413 + els.collectionList.dataset.annotationUri = annotationUri; 414 + 415 + if (!items || items.length === 0) { 416 + els.collectionsEmpty.style.display = "block"; 417 + return; 418 + } 419 + 420 + items.forEach((collection) => { 421 + const btn = document.createElement("button"); 422 + btn.className = "collection-select-btn"; 423 + const isAdded = containingUris.has(collection.uri); 424 + 425 + const icon = document.createElement("span"); 426 + if (isAdded) { 427 + icon.textContent = "✓"; 428 + } else { 429 + icon.innerHTML = 430 + '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>'; 431 + } 432 + btn.appendChild(icon); 433 + 434 + const name = document.createElement("span"); 435 + name.textContent = collection.name; 436 + btn.appendChild(name); 437 + 438 + if (isAdded) { 439 + btn.classList.add("added"); 440 + btn.disabled = true; 441 + } 442 + 443 + btn.addEventListener("click", async () => { 444 + if (btn.disabled) return; 445 + const annUri = els.collectionList.dataset.annotationUri; 446 + await handleAddToCollection(collection.uri, btn, annUri); 447 + }); 448 + 449 + els.collectionList.appendChild(btn); 450 + }); 451 + } 452 + 453 + async function handleAddToCollection( 454 + collectionUri, 455 + btnElement, 456 + annotationUri, 457 + ) { 458 + if (!annotationUri) { 459 + console.error("No annotationUri provided!"); 460 + alert("Error: No item selected to add"); 461 + return; 462 + } 463 + 464 + const originalText = btnElement.textContent; 465 + btnElement.disabled = true; 466 + btnElement.textContent = "Adding..."; 467 + 468 + try { 469 + const res = await sendMessage({ 470 + type: "ADD_TO_COLLECTION", 471 + data: { 472 + collectionUri: collectionUri, 473 + annotationUri: annotationUri, 474 + }, 475 + }); 476 + 477 + if (res && res.success) { 478 + btnElement.textContent = "✓ Added"; 479 + setTimeout(() => { 480 + btnElement.textContent = originalText; 481 + btnElement.disabled = false; 482 + }, 2000); 483 + } else { 484 + alert( 485 + "Failed to add to collection: " + (res?.error || "Unknown error"), 486 + ); 487 + btnElement.textContent = originalText; 488 + btnElement.disabled = false; 489 + } 490 + } catch (err) { 491 + console.error("Add to collection error:", err); 492 + alert("Error adding to collection: " + err.message); 493 + btnElement.textContent = originalText; 494 + btnElement.disabled = false; 495 + } 496 + } 497 + 498 + function renderAnnotations(items) { 499 + els.annotationsList.innerHTML = ""; 500 + els.annotationCount.textContent = items?.length || 0; 501 + 502 + if (!items || items.length === 0) { 503 + els.emptyState.style.display = "flex"; 504 + return; 505 + } 506 + 507 + els.emptyState.style.display = "none"; 508 + items.forEach((item) => { 509 + const el = document.createElement("div"); 510 + el.className = "annotation-item"; 511 + 512 + const author = item.creator || item.author || {}; 513 + const authorName = author.handle || author.displayName || "Unknown"; 514 + const authorInitial = authorName[0]?.toUpperCase() || "?"; 515 + const createdAt = item.created || item.createdAt; 516 + const text = item.body?.value || item.text || ""; 517 + const selector = item.target?.selector; 518 + const isHighlight = item.type === "Highlight"; 519 + const quote = selector?.exact || ""; 520 + 521 + const header = document.createElement("div"); 522 + header.className = "annotation-item-header"; 523 + 524 + const avatar = document.createElement("div"); 525 + avatar.className = "annotation-item-avatar"; 526 + 527 + if (author.avatar) { 528 + const img = document.createElement("img"); 529 + img.src = author.avatar; 530 + img.alt = authorName; 531 + img.style.width = "100%"; 532 + img.style.height = "100%"; 533 + img.style.borderRadius = "50%"; 534 + img.style.objectFit = "cover"; 535 + avatar.appendChild(img); 536 + avatar.style.background = "none"; 537 + } else { 538 + avatar.textContent = authorInitial; 539 + } 540 + header.appendChild(avatar); 541 + 542 + const meta = document.createElement("div"); 543 + meta.className = "annotation-item-meta"; 544 + 545 + const authorEl = document.createElement("div"); 546 + authorEl.className = "annotation-item-author"; 547 + authorEl.textContent = "@" + authorName; 548 + meta.appendChild(authorEl); 549 + 550 + const timeEl = document.createElement("div"); 551 + timeEl.className = "annotation-item-time"; 552 + timeEl.textContent = formatDate(createdAt); 553 + meta.appendChild(timeEl); 554 + 555 + header.appendChild(meta); 556 + 557 + if (isHighlight) { 558 + const badge = document.createElement("span"); 559 + badge.className = "annotation-type-badge highlight"; 560 + badge.textContent = "Highlight"; 561 + header.appendChild(badge); 562 + } 563 + 564 + if ( 565 + item.author?.did === currentUserDid || 566 + item.creator?.did === currentUserDid 567 + ) { 568 + const actions = document.createElement("div"); 569 + actions.className = "annotation-item-actions"; 570 + 571 + const folderBtn = document.createElement("button"); 572 + folderBtn.className = "btn-icon"; 573 + folderBtn.innerHTML = 574 + '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>'; 575 + folderBtn.title = "Add to Collection"; 576 + folderBtn.addEventListener("click", (e) => { 577 + e.stopPropagation(); 578 + openCollectionSelector(item.uri); 579 + }); 580 + actions.appendChild(folderBtn); 581 + header.appendChild(actions); 582 + } 583 + 584 + el.appendChild(header); 585 + 586 + if (quote) { 587 + const quoteEl = document.createElement("div"); 588 + quoteEl.className = "annotation-item-quote"; 589 + quoteEl.textContent = '"' + quote + '"'; 590 + el.appendChild(quoteEl); 591 + } 592 + 593 + if (text) { 594 + const textEl = document.createElement("div"); 595 + textEl.className = "annotation-item-text"; 596 + textEl.textContent = text; 597 + el.appendChild(textEl); 598 + } 599 + 600 + if (selector) { 601 + const jumpBtn = document.createElement("button"); 602 + jumpBtn.className = "scroll-to-btn"; 603 + jumpBtn.textContent = "Jump to text →"; 604 + el.appendChild(jumpBtn); 605 + } 606 + 607 + if (selector) { 608 + el.querySelector(".scroll-to-btn")?.addEventListener("click", (e) => { 609 + e.stopPropagation(); 610 + scrollToText(selector); 611 + }); 612 + } 613 + 614 + els.annotationsList.appendChild(el); 615 + }); 616 + } 617 + 618 + function renderBookmarks(items) { 619 + els.bookmarksList.innerHTML = ""; 620 + 621 + if (!items || items.length === 0) { 622 + els.bookmarksEmpty.style.display = "flex"; 623 + return; 624 + } 625 + 626 + els.bookmarksEmpty.style.display = "none"; 627 + items.forEach((item) => { 628 + const el = document.createElement("div"); 629 + el.className = "bookmark-item"; 630 + el.style.cursor = "pointer"; 631 + el.addEventListener("click", () => { 632 + window.open(item.source, "_blank"); 633 + }); 634 + 635 + let hostname = item.source; 636 + try { 637 + hostname = new URL(item.source).hostname; 638 + } catch {} 639 + 640 + const row = document.createElement("div"); 641 + row.style.display = "flex"; 642 + row.style.justifyContent = "space-between"; 643 + row.style.alignItems = "center"; 644 + 645 + const content = document.createElement("div"); 646 + content.style.flex = "1"; 647 + content.style.overflow = "hidden"; 648 + 649 + const titleEl = document.createElement("div"); 650 + titleEl.className = "bookmark-title"; 651 + titleEl.textContent = item.title || item.source; 652 + content.appendChild(titleEl); 653 + 654 + const urlEl = document.createElement("div"); 655 + urlEl.className = "bookmark-url"; 656 + urlEl.textContent = hostname; 657 + content.appendChild(urlEl); 658 + 659 + row.appendChild(content); 660 + 661 + if ( 662 + item.author?.did === currentUserDid || 663 + item.creator?.did === currentUserDid 664 + ) { 665 + const folderBtn = document.createElement("button"); 666 + folderBtn.className = "btn-icon"; 667 + folderBtn.innerHTML = 668 + '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>'; 669 + folderBtn.title = "Add to Collection"; 670 + folderBtn.addEventListener("click", (e) => { 671 + e.preventDefault(); 672 + e.stopPropagation(); 673 + openCollectionSelector(item.uri); 674 + }); 675 + row.appendChild(folderBtn); 676 + } 677 + 678 + el.appendChild(row); 679 + els.bookmarksList.appendChild(el); 680 + }); 681 + } 682 + 683 + function renderHighlights(items) { 684 + els.highlightsList.innerHTML = ""; 685 + 686 + if (!items || items.length === 0) { 687 + els.highlightsEmpty.style.display = "flex"; 688 + return; 689 + } 690 + 691 + els.highlightsEmpty.style.display = "none"; 692 + items.forEach((item) => { 693 + const el = document.createElement("div"); 694 + el.className = "annotation-item"; 695 + 696 + const target = item.target || {}; 697 + const selector = target.selector || {}; 698 + const quote = selector.exact || ""; 699 + const url = target.source || ""; 700 + 701 + let hostname = url; 702 + try { 703 + hostname = new URL(url).hostname; 704 + } catch {} 705 + 706 + const header = document.createElement("div"); 707 + header.className = "annotation-item-header"; 708 + 709 + const meta = document.createElement("div"); 710 + meta.className = "annotation-item-meta"; 711 + 712 + const authorEl = document.createElement("div"); 713 + authorEl.className = "annotation-item-author"; 714 + authorEl.textContent = hostname; 715 + meta.appendChild(authorEl); 716 + 717 + const timeEl = document.createElement("div"); 718 + timeEl.className = "annotation-item-time"; 719 + timeEl.textContent = formatDate(item.created); 720 + meta.appendChild(timeEl); 721 + 722 + header.appendChild(meta); 723 + 724 + if ( 725 + item.author?.did === currentUserDid || 726 + item.creator?.did === currentUserDid 727 + ) { 728 + const actions = document.createElement("div"); 729 + actions.className = "annotation-item-actions"; 730 + 731 + const folderBtn = document.createElement("button"); 732 + folderBtn.className = "btn-icon"; 733 + folderBtn.innerHTML = 734 + '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>'; 735 + folderBtn.title = "Add to Collection"; 736 + folderBtn.addEventListener("click", (e) => { 737 + e.stopPropagation(); 738 + openCollectionSelector(item.uri); 739 + }); 740 + actions.appendChild(folderBtn); 741 + header.appendChild(actions); 742 + } 743 + 744 + el.appendChild(header); 745 + 746 + if (quote) { 747 + const quoteEl = document.createElement("div"); 748 + quoteEl.className = "annotation-item-quote"; 749 + quoteEl.style.borderColor = item.color || "#fcd34d"; 750 + quoteEl.textContent = '"' + quote + '"'; 751 + el.appendChild(quoteEl); 752 + } 753 + 754 + const openBtn = document.createElement("button"); 755 + openBtn.className = "scroll-to-btn"; 756 + openBtn.textContent = "Open page →"; 757 + el.appendChild(openBtn); 758 + 759 + el.querySelector(".scroll-to-btn")?.addEventListener("click", (e) => { 760 + e.stopPropagation(); 761 + const textFragment = createTextFragment(url, selector); 762 + chrome.tabs.create({ url: textFragment }); 763 + }); 764 + 765 + els.highlightsList.appendChild(el); 766 + }); 767 + } 768 + 769 + async function scrollToText(selector) { 770 + let tabId = currentTab?.id; 771 + if (!tabId) { 772 + try { 773 + const [tab] = await chrome.tabs.query({ 774 + active: true, 775 + currentWindow: true, 776 + }); 777 + tabId = tab?.id; 778 + } catch (e) { 779 + console.error("Could not get active tab:", e); 780 + } 781 + } 782 + 783 + if (!tabId) { 784 + console.error("No tab ID available for scroll"); 785 + return; 786 + } 787 + 788 + try { 789 + await chrome.tabs.sendMessage(tabId, { 790 + type: "SCROLL_TO_TEXT", 791 + selector: selector, 792 + }); 793 + } catch (err) { 794 + console.error("Error sending SCROLL_TO_TEXT:", err); 795 + } 796 + } 797 + 798 + function createTextFragment(url, selector) { 799 + if (!selector || selector.type !== "TextQuoteSelector" || !selector.exact) 800 + return url; 801 + 802 + let fragment = ":~:text="; 803 + if (selector.prefix) fragment += encodeURIComponent(selector.prefix) + "-,"; 804 + fragment += encodeURIComponent(selector.exact); 805 + if (selector.suffix) fragment += ",-" + encodeURIComponent(selector.suffix); 806 + 807 + return url + "#" + fragment; 808 + } 809 + 810 + function formatDate(dateString) { 811 + if (!dateString) return ""; 812 + try { 813 + const date = new Date(dateString); 814 + const now = new Date(); 815 + const diffMs = now - date; 816 + const diffMins = Math.floor(diffMs / 60000); 817 + const diffHours = Math.floor(diffMs / 3600000); 818 + const diffDays = Math.floor(diffMs / 86400000); 819 + 820 + if (diffMins < 1) return "Just now"; 821 + if (diffMins < 60) return `${diffMins}m ago`; 822 + if (diffHours < 24) return `${diffHours}h ago`; 823 + if (diffDays < 7) return `${diffDays}d ago`; 824 + return date.toLocaleDateString(); 825 + } catch { 826 + return dateString; 827 + } 828 + } 829 + 830 + function showQuotePreview(selector) { 831 + if (!selector?.exact) return; 832 + 833 + let preview = document.getElementById("quote-preview"); 834 + if (!preview) { 835 + preview = document.createElement("div"); 836 + preview.id = "quote-preview"; 837 + preview.className = "quote-preview"; 838 + const form = document.querySelector(".create-form"); 839 + if (form) { 840 + form.insertBefore(preview, form.querySelector(".annotation-input")); 841 + } 842 + } 843 + 844 + const header = document.createElement("div"); 845 + header.className = "quote-preview-header"; 846 + 847 + const label = document.createElement("span"); 848 + label.textContent = "Annotating selection:"; 849 + header.appendChild(label); 850 + 851 + const clearBtn = document.createElement("button"); 852 + clearBtn.className = "quote-preview-clear"; 853 + clearBtn.title = "Clear selection"; 854 + clearBtn.textContent = "×"; 855 + clearBtn.addEventListener("click", () => { 856 + pendingSelector = null; 857 + hideQuotePreview(); 858 + }); 859 + header.appendChild(clearBtn); 860 + 861 + preview.appendChild(header); 862 + 863 + const text = document.createElement("div"); 864 + text.className = "quote-preview-text"; 865 + text.textContent = '"' + selector.exact + '"'; 866 + preview.appendChild(text); 867 + 868 + els.textInput?.focus(); 869 + } 870 + 871 + function hideQuotePreview() { 872 + const preview = document.getElementById("quote-preview"); 873 + if (preview) { 874 + preview.remove(); 875 + } 876 + } 877 + 878 + function showView(viewName) { 879 + Object.keys(views).forEach((key) => { 880 + if (views[key]) views[key].style.display = "none"; 881 + }); 882 + if (views[viewName]) { 883 + views[viewName].style.display = 884 + viewName === "loading" || viewName === "settings" ? "flex" : "block"; 885 + } 886 + } 887 + 888 + function sendMessage(message) { 889 + return new Promise((resolve, reject) => { 890 + chrome.runtime.sendMessage(message, (response) => { 891 + if (chrome.runtime.lastError) { 892 + reject(chrome.runtime.lastError); 893 + } else { 894 + resolve(response); 895 + } 896 + }); 897 + }); 898 + } 899 + });
+292
lexicons/at/margin/annotation.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "at.margin.annotation", 4 + "revision": 2, 5 + "description": "W3C Web Annotation Data Model compliant annotation record for ATProto", 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "description": "A W3C-compliant web annotation stored on the AT Protocol", 10 + "key": "tid", 11 + "record": { 12 + "type": "object", 13 + "required": [ 14 + "target", 15 + "createdAt" 16 + ], 17 + "properties": { 18 + "motivation": { 19 + "type": "string", 20 + "description": "W3C motivation for the annotation", 21 + "knownValues": [ 22 + "commenting", 23 + "highlighting", 24 + "bookmarking", 25 + "tagging", 26 + "describing", 27 + "linking", 28 + "replying", 29 + "editing", 30 + "questioning", 31 + "assessing" 32 + ] 33 + }, 34 + "body": { 35 + "type": "ref", 36 + "ref": "#body", 37 + "description": "The annotation content (text or reference)" 38 + }, 39 + "target": { 40 + "type": "ref", 41 + "ref": "#target", 42 + "description": "The resource being annotated with optional selector" 43 + }, 44 + "tags": { 45 + "type": "array", 46 + "description": "Tags for categorization", 47 + "items": { 48 + "type": "string", 49 + "maxLength": 64, 50 + "maxGraphemes": 32 51 + }, 52 + "maxLength": 10 53 + }, 54 + "createdAt": { 55 + "type": "string", 56 + "format": "datetime" 57 + } 58 + } 59 + } 60 + }, 61 + "body": { 62 + "type": "object", 63 + "description": "Annotation body - the content of the annotation", 64 + "properties": { 65 + "value": { 66 + "type": "string", 67 + "maxLength": 10000, 68 + "maxGraphemes": 3000, 69 + "description": "Text content of the annotation" 70 + }, 71 + "format": { 72 + "type": "string", 73 + "description": "MIME type of the body content", 74 + "default": "text/plain" 75 + }, 76 + "language": { 77 + "type": "string", 78 + "description": "BCP47 language tag" 79 + }, 80 + "uri": { 81 + "type": "string", 82 + "format": "uri", 83 + "description": "Reference to external body content" 84 + } 85 + } 86 + }, 87 + "target": { 88 + "type": "object", 89 + "description": "W3C SpecificResource - the target with optional selector", 90 + "required": [ 91 + "source" 92 + ], 93 + "properties": { 94 + "source": { 95 + "type": "string", 96 + "format": "uri", 97 + "description": "The URL being annotated" 98 + }, 99 + "sourceHash": { 100 + "type": "string", 101 + "description": "SHA256 hash of normalized URL for indexing" 102 + }, 103 + "title": { 104 + "type": "string", 105 + "maxLength": 500, 106 + "description": "Page title at time of annotation" 107 + }, 108 + "selector": { 109 + "type": "union", 110 + "description": "Selector to identify the specific segment", 111 + "refs": [ 112 + "#textQuoteSelector", 113 + "#textPositionSelector", 114 + "#cssSelector", 115 + "#xpathSelector", 116 + "#fragmentSelector", 117 + "#rangeSelector" 118 + ] 119 + }, 120 + "state": { 121 + "type": "ref", 122 + "ref": "#timeState", 123 + "description": "State of the resource at annotation time" 124 + } 125 + } 126 + }, 127 + "textQuoteSelector": { 128 + "type": "object", 129 + "description": "W3C TextQuoteSelector - select text by quoting it with context", 130 + "required": [ 131 + "exact" 132 + ], 133 + "properties": { 134 + "type": { 135 + "type": "string", 136 + "const": "TextQuoteSelector" 137 + }, 138 + "exact": { 139 + "type": "string", 140 + "maxLength": 5000, 141 + "maxGraphemes": 1500, 142 + "description": "The exact text to match" 143 + }, 144 + "prefix": { 145 + "type": "string", 146 + "maxLength": 500, 147 + "maxGraphemes": 150, 148 + "description": "Text immediately before the selection" 149 + }, 150 + "suffix": { 151 + "type": "string", 152 + "maxLength": 500, 153 + "maxGraphemes": 150, 154 + "description": "Text immediately after the selection" 155 + } 156 + } 157 + }, 158 + "textPositionSelector": { 159 + "type": "object", 160 + "description": "W3C TextPositionSelector - select by character offsets", 161 + "required": [ 162 + "start", 163 + "end" 164 + ], 165 + "properties": { 166 + "type": { 167 + "type": "string", 168 + "const": "TextPositionSelector" 169 + }, 170 + "start": { 171 + "type": "integer", 172 + "minimum": 0, 173 + "description": "Starting character position (0-indexed, inclusive)" 174 + }, 175 + "end": { 176 + "type": "integer", 177 + "minimum": 0, 178 + "description": "Ending character position (exclusive)" 179 + } 180 + } 181 + }, 182 + "cssSelector": { 183 + "type": "object", 184 + "description": "W3C CssSelector - select DOM elements by CSS selector", 185 + "required": [ 186 + "value" 187 + ], 188 + "properties": { 189 + "type": { 190 + "type": "string", 191 + "const": "CssSelector" 192 + }, 193 + "value": { 194 + "type": "string", 195 + "maxLength": 2000, 196 + "description": "CSS selector string" 197 + } 198 + } 199 + }, 200 + "xpathSelector": { 201 + "type": "object", 202 + "description": "W3C XPathSelector - select by XPath expression", 203 + "required": [ 204 + "value" 205 + ], 206 + "properties": { 207 + "type": { 208 + "type": "string", 209 + "const": "XPathSelector" 210 + }, 211 + "value": { 212 + "type": "string", 213 + "maxLength": 2000, 214 + "description": "XPath expression" 215 + } 216 + } 217 + }, 218 + "fragmentSelector": { 219 + "type": "object", 220 + "description": "W3C FragmentSelector - select by URI fragment", 221 + "required": [ 222 + "value" 223 + ], 224 + "properties": { 225 + "type": { 226 + "type": "string", 227 + "const": "FragmentSelector" 228 + }, 229 + "value": { 230 + "type": "string", 231 + "maxLength": 1000, 232 + "description": "Fragment identifier value" 233 + }, 234 + "conformsTo": { 235 + "type": "string", 236 + "format": "uri", 237 + "description": "Specification the fragment conforms to" 238 + } 239 + } 240 + }, 241 + "rangeSelector": { 242 + "type": "object", 243 + "description": "W3C RangeSelector - select range between two selectors", 244 + "required": [ 245 + "startSelector", 246 + "endSelector" 247 + ], 248 + "properties": { 249 + "type": { 250 + "type": "string", 251 + "const": "RangeSelector" 252 + }, 253 + "startSelector": { 254 + "type": "union", 255 + "description": "Selector for range start", 256 + "refs": [ 257 + "#textQuoteSelector", 258 + "#textPositionSelector", 259 + "#cssSelector", 260 + "#xpathSelector" 261 + ] 262 + }, 263 + "endSelector": { 264 + "type": "union", 265 + "description": "Selector for range end", 266 + "refs": [ 267 + "#textQuoteSelector", 268 + "#textPositionSelector", 269 + "#cssSelector", 270 + "#xpathSelector" 271 + ] 272 + } 273 + } 274 + }, 275 + "timeState": { 276 + "type": "object", 277 + "description": "W3C TimeState - record when content was captured", 278 + "properties": { 279 + "sourceDate": { 280 + "type": "string", 281 + "format": "datetime", 282 + "description": "When the source was accessed" 283 + }, 284 + "cached": { 285 + "type": "string", 286 + "format": "uri", 287 + "description": "URL to cached/archived version" 288 + } 289 + } 290 + } 291 + } 292 + }
+55
lexicons/at/margin/bookmark.json
··· 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 + } 53 + } 54 + } 55 + }
+43
lexicons/at/margin/collection.json
··· 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 + } 41 + } 42 + } 43 + }
+41
lexicons/at/margin/collectionItem.json
··· 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 + } 39 + } 40 + } 41 + }
+45
lexicons/at/margin/highlight.json
··· 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 + } 43 + } 44 + } 45 + }
+46
lexicons/at/margin/like.json
··· 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 + } 27 + }, 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 + } 44 + } 45 + } 46 + }
+67
lexicons/at/margin/reply.json
··· 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 + } 47 + }, 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 + } 65 + } 66 + } 67 + }
+24
web/index.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8" /> 5 + <link rel="icon" href="/favicon.ico" /> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 7 + <meta 8 + name="description" 9 + content="Margin - Write in the margins of the web. Comment on any URL with AT Protocol." 10 + /> 11 + <title>Margin - Write in the margins of the web</title> 12 + <link rel="preconnect" href="https://fonts.googleapis.com" /> 13 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> 14 + <link 15 + href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" 16 + rel="stylesheet" 17 + /> 18 + </head> 19 + 20 + <body> 21 + <div id="root"></div> 22 + <script type="module" src="/src/main.jsx"></script> 23 + </body> 24 + </html>
+1852
web/package-lock.json
··· 1 + { 2 + "name": "margin-web", 3 + "version": "0.0.1", 4 + "lockfileVersion": 3, 5 + "requires": true, 6 + "packages": { 7 + "": { 8 + "name": "margin-web", 9 + "version": "0.0.1", 10 + "dependencies": { 11 + "lucide-react": "^0.562.0", 12 + "react": "^18.3.1", 13 + "react-dom": "^18.3.1", 14 + "react-icons": "^5.5.0", 15 + "react-router-dom": "^6.28.0" 16 + }, 17 + "devDependencies": { 18 + "@types/react": "^18.3.12", 19 + "@types/react-dom": "^18.3.1", 20 + "@vitejs/plugin-react": "^4.3.3", 21 + "vite": "^6.0.3" 22 + } 23 + }, 24 + "node_modules/@babel/code-frame": { 25 + "version": "7.27.1", 26 + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", 27 + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", 28 + "dev": true, 29 + "license": "MIT", 30 + "dependencies": { 31 + "@babel/helper-validator-identifier": "^7.27.1", 32 + "js-tokens": "^4.0.0", 33 + "picocolors": "^1.1.1" 34 + }, 35 + "engines": { 36 + "node": ">=6.9.0" 37 + } 38 + }, 39 + "node_modules/@babel/compat-data": { 40 + "version": "7.28.5", 41 + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", 42 + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", 43 + "dev": true, 44 + "license": "MIT", 45 + "engines": { 46 + "node": ">=6.9.0" 47 + } 48 + }, 49 + "node_modules/@babel/core": { 50 + "version": "7.28.5", 51 + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", 52 + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", 53 + "dev": true, 54 + "license": "MIT", 55 + "peer": true, 56 + "dependencies": { 57 + "@babel/code-frame": "^7.27.1", 58 + "@babel/generator": "^7.28.5", 59 + "@babel/helper-compilation-targets": "^7.27.2", 60 + "@babel/helper-module-transforms": "^7.28.3", 61 + "@babel/helpers": "^7.28.4", 62 + "@babel/parser": "^7.28.5", 63 + "@babel/template": "^7.27.2", 64 + "@babel/traverse": "^7.28.5", 65 + "@babel/types": "^7.28.5", 66 + "@jridgewell/remapping": "^2.3.5", 67 + "convert-source-map": "^2.0.0", 68 + "debug": "^4.1.0", 69 + "gensync": "^1.0.0-beta.2", 70 + "json5": "^2.2.3", 71 + "semver": "^6.3.1" 72 + }, 73 + "engines": { 74 + "node": ">=6.9.0" 75 + }, 76 + "funding": { 77 + "type": "opencollective", 78 + "url": "https://opencollective.com/babel" 79 + } 80 + }, 81 + "node_modules/@babel/generator": { 82 + "version": "7.28.5", 83 + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", 84 + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", 85 + "dev": true, 86 + "license": "MIT", 87 + "dependencies": { 88 + "@babel/parser": "^7.28.5", 89 + "@babel/types": "^7.28.5", 90 + "@jridgewell/gen-mapping": "^0.3.12", 91 + "@jridgewell/trace-mapping": "^0.3.28", 92 + "jsesc": "^3.0.2" 93 + }, 94 + "engines": { 95 + "node": ">=6.9.0" 96 + } 97 + }, 98 + "node_modules/@babel/helper-compilation-targets": { 99 + "version": "7.27.2", 100 + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", 101 + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", 102 + "dev": true, 103 + "license": "MIT", 104 + "dependencies": { 105 + "@babel/compat-data": "^7.27.2", 106 + "@babel/helper-validator-option": "^7.27.1", 107 + "browserslist": "^4.24.0", 108 + "lru-cache": "^5.1.1", 109 + "semver": "^6.3.1" 110 + }, 111 + "engines": { 112 + "node": ">=6.9.0" 113 + } 114 + }, 115 + "node_modules/@babel/helper-globals": { 116 + "version": "7.28.0", 117 + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", 118 + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", 119 + "dev": true, 120 + "license": "MIT", 121 + "engines": { 122 + "node": ">=6.9.0" 123 + } 124 + }, 125 + "node_modules/@babel/helper-module-imports": { 126 + "version": "7.27.1", 127 + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", 128 + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", 129 + "dev": true, 130 + "license": "MIT", 131 + "dependencies": { 132 + "@babel/traverse": "^7.27.1", 133 + "@babel/types": "^7.27.1" 134 + }, 135 + "engines": { 136 + "node": ">=6.9.0" 137 + } 138 + }, 139 + "node_modules/@babel/helper-module-transforms": { 140 + "version": "7.28.3", 141 + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", 142 + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", 143 + "dev": true, 144 + "license": "MIT", 145 + "dependencies": { 146 + "@babel/helper-module-imports": "^7.27.1", 147 + "@babel/helper-validator-identifier": "^7.27.1", 148 + "@babel/traverse": "^7.28.3" 149 + }, 150 + "engines": { 151 + "node": ">=6.9.0" 152 + }, 153 + "peerDependencies": { 154 + "@babel/core": "^7.0.0" 155 + } 156 + }, 157 + "node_modules/@babel/helper-plugin-utils": { 158 + "version": "7.27.1", 159 + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", 160 + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", 161 + "dev": true, 162 + "license": "MIT", 163 + "engines": { 164 + "node": ">=6.9.0" 165 + } 166 + }, 167 + "node_modules/@babel/helper-string-parser": { 168 + "version": "7.27.1", 169 + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", 170 + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", 171 + "dev": true, 172 + "license": "MIT", 173 + "engines": { 174 + "node": ">=6.9.0" 175 + } 176 + }, 177 + "node_modules/@babel/helper-validator-identifier": { 178 + "version": "7.28.5", 179 + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", 180 + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", 181 + "dev": true, 182 + "license": "MIT", 183 + "engines": { 184 + "node": ">=6.9.0" 185 + } 186 + }, 187 + "node_modules/@babel/helper-validator-option": { 188 + "version": "7.27.1", 189 + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", 190 + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", 191 + "dev": true, 192 + "license": "MIT", 193 + "engines": { 194 + "node": ">=6.9.0" 195 + } 196 + }, 197 + "node_modules/@babel/helpers": { 198 + "version": "7.28.4", 199 + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", 200 + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", 201 + "dev": true, 202 + "license": "MIT", 203 + "dependencies": { 204 + "@babel/template": "^7.27.2", 205 + "@babel/types": "^7.28.4" 206 + }, 207 + "engines": { 208 + "node": ">=6.9.0" 209 + } 210 + }, 211 + "node_modules/@babel/parser": { 212 + "version": "7.28.5", 213 + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", 214 + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", 215 + "dev": true, 216 + "license": "MIT", 217 + "dependencies": { 218 + "@babel/types": "^7.28.5" 219 + }, 220 + "bin": { 221 + "parser": "bin/babel-parser.js" 222 + }, 223 + "engines": { 224 + "node": ">=6.0.0" 225 + } 226 + }, 227 + "node_modules/@babel/plugin-transform-react-jsx-self": { 228 + "version": "7.27.1", 229 + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", 230 + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", 231 + "dev": true, 232 + "license": "MIT", 233 + "dependencies": { 234 + "@babel/helper-plugin-utils": "^7.27.1" 235 + }, 236 + "engines": { 237 + "node": ">=6.9.0" 238 + }, 239 + "peerDependencies": { 240 + "@babel/core": "^7.0.0-0" 241 + } 242 + }, 243 + "node_modules/@babel/plugin-transform-react-jsx-source": { 244 + "version": "7.27.1", 245 + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", 246 + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", 247 + "dev": true, 248 + "license": "MIT", 249 + "dependencies": { 250 + "@babel/helper-plugin-utils": "^7.27.1" 251 + }, 252 + "engines": { 253 + "node": ">=6.9.0" 254 + }, 255 + "peerDependencies": { 256 + "@babel/core": "^7.0.0-0" 257 + } 258 + }, 259 + "node_modules/@babel/template": { 260 + "version": "7.27.2", 261 + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", 262 + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", 263 + "dev": true, 264 + "license": "MIT", 265 + "dependencies": { 266 + "@babel/code-frame": "^7.27.1", 267 + "@babel/parser": "^7.27.2", 268 + "@babel/types": "^7.27.1" 269 + }, 270 + "engines": { 271 + "node": ">=6.9.0" 272 + } 273 + }, 274 + "node_modules/@babel/traverse": { 275 + "version": "7.28.5", 276 + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", 277 + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", 278 + "dev": true, 279 + "license": "MIT", 280 + "dependencies": { 281 + "@babel/code-frame": "^7.27.1", 282 + "@babel/generator": "^7.28.5", 283 + "@babel/helper-globals": "^7.28.0", 284 + "@babel/parser": "^7.28.5", 285 + "@babel/template": "^7.27.2", 286 + "@babel/types": "^7.28.5", 287 + "debug": "^4.3.1" 288 + }, 289 + "engines": { 290 + "node": ">=6.9.0" 291 + } 292 + }, 293 + "node_modules/@babel/types": { 294 + "version": "7.28.5", 295 + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", 296 + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", 297 + "dev": true, 298 + "license": "MIT", 299 + "dependencies": { 300 + "@babel/helper-string-parser": "^7.27.1", 301 + "@babel/helper-validator-identifier": "^7.28.5" 302 + }, 303 + "engines": { 304 + "node": ">=6.9.0" 305 + } 306 + }, 307 + "node_modules/@esbuild/aix-ppc64": { 308 + "version": "0.25.12", 309 + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", 310 + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", 311 + "cpu": [ 312 + "ppc64" 313 + ], 314 + "dev": true, 315 + "license": "MIT", 316 + "optional": true, 317 + "os": [ 318 + "aix" 319 + ], 320 + "engines": { 321 + "node": ">=18" 322 + } 323 + }, 324 + "node_modules/@esbuild/android-arm": { 325 + "version": "0.25.12", 326 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", 327 + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", 328 + "cpu": [ 329 + "arm" 330 + ], 331 + "dev": true, 332 + "license": "MIT", 333 + "optional": true, 334 + "os": [ 335 + "android" 336 + ], 337 + "engines": { 338 + "node": ">=18" 339 + } 340 + }, 341 + "node_modules/@esbuild/android-arm64": { 342 + "version": "0.25.12", 343 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", 344 + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", 345 + "cpu": [ 346 + "arm64" 347 + ], 348 + "dev": true, 349 + "license": "MIT", 350 + "optional": true, 351 + "os": [ 352 + "android" 353 + ], 354 + "engines": { 355 + "node": ">=18" 356 + } 357 + }, 358 + "node_modules/@esbuild/android-x64": { 359 + "version": "0.25.12", 360 + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", 361 + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", 362 + "cpu": [ 363 + "x64" 364 + ], 365 + "dev": true, 366 + "license": "MIT", 367 + "optional": true, 368 + "os": [ 369 + "android" 370 + ], 371 + "engines": { 372 + "node": ">=18" 373 + } 374 + }, 375 + "node_modules/@esbuild/darwin-arm64": { 376 + "version": "0.25.12", 377 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", 378 + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", 379 + "cpu": [ 380 + "arm64" 381 + ], 382 + "dev": true, 383 + "license": "MIT", 384 + "optional": true, 385 + "os": [ 386 + "darwin" 387 + ], 388 + "engines": { 389 + "node": ">=18" 390 + } 391 + }, 392 + "node_modules/@esbuild/darwin-x64": { 393 + "version": "0.25.12", 394 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", 395 + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", 396 + "cpu": [ 397 + "x64" 398 + ], 399 + "dev": true, 400 + "license": "MIT", 401 + "optional": true, 402 + "os": [ 403 + "darwin" 404 + ], 405 + "engines": { 406 + "node": ">=18" 407 + } 408 + }, 409 + "node_modules/@esbuild/freebsd-arm64": { 410 + "version": "0.25.12", 411 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", 412 + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", 413 + "cpu": [ 414 + "arm64" 415 + ], 416 + "dev": true, 417 + "license": "MIT", 418 + "optional": true, 419 + "os": [ 420 + "freebsd" 421 + ], 422 + "engines": { 423 + "node": ">=18" 424 + } 425 + }, 426 + "node_modules/@esbuild/freebsd-x64": { 427 + "version": "0.25.12", 428 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", 429 + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", 430 + "cpu": [ 431 + "x64" 432 + ], 433 + "dev": true, 434 + "license": "MIT", 435 + "optional": true, 436 + "os": [ 437 + "freebsd" 438 + ], 439 + "engines": { 440 + "node": ">=18" 441 + } 442 + }, 443 + "node_modules/@esbuild/linux-arm": { 444 + "version": "0.25.12", 445 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", 446 + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", 447 + "cpu": [ 448 + "arm" 449 + ], 450 + "dev": true, 451 + "license": "MIT", 452 + "optional": true, 453 + "os": [ 454 + "linux" 455 + ], 456 + "engines": { 457 + "node": ">=18" 458 + } 459 + }, 460 + "node_modules/@esbuild/linux-arm64": { 461 + "version": "0.25.12", 462 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", 463 + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", 464 + "cpu": [ 465 + "arm64" 466 + ], 467 + "dev": true, 468 + "license": "MIT", 469 + "optional": true, 470 + "os": [ 471 + "linux" 472 + ], 473 + "engines": { 474 + "node": ">=18" 475 + } 476 + }, 477 + "node_modules/@esbuild/linux-ia32": { 478 + "version": "0.25.12", 479 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", 480 + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", 481 + "cpu": [ 482 + "ia32" 483 + ], 484 + "dev": true, 485 + "license": "MIT", 486 + "optional": true, 487 + "os": [ 488 + "linux" 489 + ], 490 + "engines": { 491 + "node": ">=18" 492 + } 493 + }, 494 + "node_modules/@esbuild/linux-loong64": { 495 + "version": "0.25.12", 496 + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", 497 + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", 498 + "cpu": [ 499 + "loong64" 500 + ], 501 + "dev": true, 502 + "license": "MIT", 503 + "optional": true, 504 + "os": [ 505 + "linux" 506 + ], 507 + "engines": { 508 + "node": ">=18" 509 + } 510 + }, 511 + "node_modules/@esbuild/linux-mips64el": { 512 + "version": "0.25.12", 513 + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", 514 + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", 515 + "cpu": [ 516 + "mips64el" 517 + ], 518 + "dev": true, 519 + "license": "MIT", 520 + "optional": true, 521 + "os": [ 522 + "linux" 523 + ], 524 + "engines": { 525 + "node": ">=18" 526 + } 527 + }, 528 + "node_modules/@esbuild/linux-ppc64": { 529 + "version": "0.25.12", 530 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", 531 + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", 532 + "cpu": [ 533 + "ppc64" 534 + ], 535 + "dev": true, 536 + "license": "MIT", 537 + "optional": true, 538 + "os": [ 539 + "linux" 540 + ], 541 + "engines": { 542 + "node": ">=18" 543 + } 544 + }, 545 + "node_modules/@esbuild/linux-riscv64": { 546 + "version": "0.25.12", 547 + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", 548 + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", 549 + "cpu": [ 550 + "riscv64" 551 + ], 552 + "dev": true, 553 + "license": "MIT", 554 + "optional": true, 555 + "os": [ 556 + "linux" 557 + ], 558 + "engines": { 559 + "node": ">=18" 560 + } 561 + }, 562 + "node_modules/@esbuild/linux-s390x": { 563 + "version": "0.25.12", 564 + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", 565 + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", 566 + "cpu": [ 567 + "s390x" 568 + ], 569 + "dev": true, 570 + "license": "MIT", 571 + "optional": true, 572 + "os": [ 573 + "linux" 574 + ], 575 + "engines": { 576 + "node": ">=18" 577 + } 578 + }, 579 + "node_modules/@esbuild/linux-x64": { 580 + "version": "0.25.12", 581 + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", 582 + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", 583 + "cpu": [ 584 + "x64" 585 + ], 586 + "dev": true, 587 + "license": "MIT", 588 + "optional": true, 589 + "os": [ 590 + "linux" 591 + ], 592 + "engines": { 593 + "node": ">=18" 594 + } 595 + }, 596 + "node_modules/@esbuild/netbsd-arm64": { 597 + "version": "0.25.12", 598 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", 599 + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", 600 + "cpu": [ 601 + "arm64" 602 + ], 603 + "dev": true, 604 + "license": "MIT", 605 + "optional": true, 606 + "os": [ 607 + "netbsd" 608 + ], 609 + "engines": { 610 + "node": ">=18" 611 + } 612 + }, 613 + "node_modules/@esbuild/netbsd-x64": { 614 + "version": "0.25.12", 615 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", 616 + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", 617 + "cpu": [ 618 + "x64" 619 + ], 620 + "dev": true, 621 + "license": "MIT", 622 + "optional": true, 623 + "os": [ 624 + "netbsd" 625 + ], 626 + "engines": { 627 + "node": ">=18" 628 + } 629 + }, 630 + "node_modules/@esbuild/openbsd-arm64": { 631 + "version": "0.25.12", 632 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", 633 + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", 634 + "cpu": [ 635 + "arm64" 636 + ], 637 + "dev": true, 638 + "license": "MIT", 639 + "optional": true, 640 + "os": [ 641 + "openbsd" 642 + ], 643 + "engines": { 644 + "node": ">=18" 645 + } 646 + }, 647 + "node_modules/@esbuild/openbsd-x64": { 648 + "version": "0.25.12", 649 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", 650 + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", 651 + "cpu": [ 652 + "x64" 653 + ], 654 + "dev": true, 655 + "license": "MIT", 656 + "optional": true, 657 + "os": [ 658 + "openbsd" 659 + ], 660 + "engines": { 661 + "node": ">=18" 662 + } 663 + }, 664 + "node_modules/@esbuild/openharmony-arm64": { 665 + "version": "0.25.12", 666 + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", 667 + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", 668 + "cpu": [ 669 + "arm64" 670 + ], 671 + "dev": true, 672 + "license": "MIT", 673 + "optional": true, 674 + "os": [ 675 + "openharmony" 676 + ], 677 + "engines": { 678 + "node": ">=18" 679 + } 680 + }, 681 + "node_modules/@esbuild/sunos-x64": { 682 + "version": "0.25.12", 683 + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", 684 + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", 685 + "cpu": [ 686 + "x64" 687 + ], 688 + "dev": true, 689 + "license": "MIT", 690 + "optional": true, 691 + "os": [ 692 + "sunos" 693 + ], 694 + "engines": { 695 + "node": ">=18" 696 + } 697 + }, 698 + "node_modules/@esbuild/win32-arm64": { 699 + "version": "0.25.12", 700 + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", 701 + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", 702 + "cpu": [ 703 + "arm64" 704 + ], 705 + "dev": true, 706 + "license": "MIT", 707 + "optional": true, 708 + "os": [ 709 + "win32" 710 + ], 711 + "engines": { 712 + "node": ">=18" 713 + } 714 + }, 715 + "node_modules/@esbuild/win32-ia32": { 716 + "version": "0.25.12", 717 + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", 718 + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", 719 + "cpu": [ 720 + "ia32" 721 + ], 722 + "dev": true, 723 + "license": "MIT", 724 + "optional": true, 725 + "os": [ 726 + "win32" 727 + ], 728 + "engines": { 729 + "node": ">=18" 730 + } 731 + }, 732 + "node_modules/@esbuild/win32-x64": { 733 + "version": "0.25.12", 734 + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", 735 + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", 736 + "cpu": [ 737 + "x64" 738 + ], 739 + "dev": true, 740 + "license": "MIT", 741 + "optional": true, 742 + "os": [ 743 + "win32" 744 + ], 745 + "engines": { 746 + "node": ">=18" 747 + } 748 + }, 749 + "node_modules/@jridgewell/gen-mapping": { 750 + "version": "0.3.13", 751 + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", 752 + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", 753 + "dev": true, 754 + "license": "MIT", 755 + "dependencies": { 756 + "@jridgewell/sourcemap-codec": "^1.5.0", 757 + "@jridgewell/trace-mapping": "^0.3.24" 758 + } 759 + }, 760 + "node_modules/@jridgewell/remapping": { 761 + "version": "2.3.5", 762 + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", 763 + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", 764 + "dev": true, 765 + "license": "MIT", 766 + "dependencies": { 767 + "@jridgewell/gen-mapping": "^0.3.5", 768 + "@jridgewell/trace-mapping": "^0.3.24" 769 + } 770 + }, 771 + "node_modules/@jridgewell/resolve-uri": { 772 + "version": "3.1.2", 773 + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", 774 + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", 775 + "dev": true, 776 + "license": "MIT", 777 + "engines": { 778 + "node": ">=6.0.0" 779 + } 780 + }, 781 + "node_modules/@jridgewell/sourcemap-codec": { 782 + "version": "1.5.5", 783 + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", 784 + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", 785 + "dev": true, 786 + "license": "MIT" 787 + }, 788 + "node_modules/@jridgewell/trace-mapping": { 789 + "version": "0.3.31", 790 + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", 791 + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", 792 + "dev": true, 793 + "license": "MIT", 794 + "dependencies": { 795 + "@jridgewell/resolve-uri": "^3.1.0", 796 + "@jridgewell/sourcemap-codec": "^1.4.14" 797 + } 798 + }, 799 + "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==", 803 + "license": "MIT", 804 + "engines": { 805 + "node": ">=14.0.0" 806 + } 807 + }, 808 + "node_modules/@rolldown/pluginutils": { 809 + "version": "1.0.0-beta.27", 810 + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", 811 + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", 812 + "dev": true, 813 + "license": "MIT" 814 + }, 815 + "node_modules/@rollup/rollup-android-arm-eabi": { 816 + "version": "4.54.0", 817 + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz", 818 + "integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==", 819 + "cpu": [ 820 + "arm" 821 + ], 822 + "dev": true, 823 + "license": "MIT", 824 + "optional": true, 825 + "os": [ 826 + "android" 827 + ] 828 + }, 829 + "node_modules/@rollup/rollup-android-arm64": { 830 + "version": "4.54.0", 831 + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.54.0.tgz", 832 + "integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==", 833 + "cpu": [ 834 + "arm64" 835 + ], 836 + "dev": true, 837 + "license": "MIT", 838 + "optional": true, 839 + "os": [ 840 + "android" 841 + ] 842 + }, 843 + "node_modules/@rollup/rollup-darwin-arm64": { 844 + "version": "4.54.0", 845 + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz", 846 + "integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==", 847 + "cpu": [ 848 + "arm64" 849 + ], 850 + "dev": true, 851 + "license": "MIT", 852 + "optional": true, 853 + "os": [ 854 + "darwin" 855 + ] 856 + }, 857 + "node_modules/@rollup/rollup-darwin-x64": { 858 + "version": "4.54.0", 859 + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.54.0.tgz", 860 + "integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==", 861 + "cpu": [ 862 + "x64" 863 + ], 864 + "dev": true, 865 + "license": "MIT", 866 + "optional": true, 867 + "os": [ 868 + "darwin" 869 + ] 870 + }, 871 + "node_modules/@rollup/rollup-freebsd-arm64": { 872 + "version": "4.54.0", 873 + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.54.0.tgz", 874 + "integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==", 875 + "cpu": [ 876 + "arm64" 877 + ], 878 + "dev": true, 879 + "license": "MIT", 880 + "optional": true, 881 + "os": [ 882 + "freebsd" 883 + ] 884 + }, 885 + "node_modules/@rollup/rollup-freebsd-x64": { 886 + "version": "4.54.0", 887 + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.54.0.tgz", 888 + "integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==", 889 + "cpu": [ 890 + "x64" 891 + ], 892 + "dev": true, 893 + "license": "MIT", 894 + "optional": true, 895 + "os": [ 896 + "freebsd" 897 + ] 898 + }, 899 + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { 900 + "version": "4.54.0", 901 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.54.0.tgz", 902 + "integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==", 903 + "cpu": [ 904 + "arm" 905 + ], 906 + "dev": true, 907 + "license": "MIT", 908 + "optional": true, 909 + "os": [ 910 + "linux" 911 + ] 912 + }, 913 + "node_modules/@rollup/rollup-linux-arm-musleabihf": { 914 + "version": "4.54.0", 915 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.54.0.tgz", 916 + "integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==", 917 + "cpu": [ 918 + "arm" 919 + ], 920 + "dev": true, 921 + "license": "MIT", 922 + "optional": true, 923 + "os": [ 924 + "linux" 925 + ] 926 + }, 927 + "node_modules/@rollup/rollup-linux-arm64-gnu": { 928 + "version": "4.54.0", 929 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.54.0.tgz", 930 + "integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==", 931 + "cpu": [ 932 + "arm64" 933 + ], 934 + "dev": true, 935 + "license": "MIT", 936 + "optional": true, 937 + "os": [ 938 + "linux" 939 + ] 940 + }, 941 + "node_modules/@rollup/rollup-linux-arm64-musl": { 942 + "version": "4.54.0", 943 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.54.0.tgz", 944 + "integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==", 945 + "cpu": [ 946 + "arm64" 947 + ], 948 + "dev": true, 949 + "license": "MIT", 950 + "optional": true, 951 + "os": [ 952 + "linux" 953 + ] 954 + }, 955 + "node_modules/@rollup/rollup-linux-loong64-gnu": { 956 + "version": "4.54.0", 957 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.54.0.tgz", 958 + "integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==", 959 + "cpu": [ 960 + "loong64" 961 + ], 962 + "dev": true, 963 + "license": "MIT", 964 + "optional": true, 965 + "os": [ 966 + "linux" 967 + ] 968 + }, 969 + "node_modules/@rollup/rollup-linux-ppc64-gnu": { 970 + "version": "4.54.0", 971 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.54.0.tgz", 972 + "integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==", 973 + "cpu": [ 974 + "ppc64" 975 + ], 976 + "dev": true, 977 + "license": "MIT", 978 + "optional": true, 979 + "os": [ 980 + "linux" 981 + ] 982 + }, 983 + "node_modules/@rollup/rollup-linux-riscv64-gnu": { 984 + "version": "4.54.0", 985 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.54.0.tgz", 986 + "integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==", 987 + "cpu": [ 988 + "riscv64" 989 + ], 990 + "dev": true, 991 + "license": "MIT", 992 + "optional": true, 993 + "os": [ 994 + "linux" 995 + ] 996 + }, 997 + "node_modules/@rollup/rollup-linux-riscv64-musl": { 998 + "version": "4.54.0", 999 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.54.0.tgz", 1000 + "integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==", 1001 + "cpu": [ 1002 + "riscv64" 1003 + ], 1004 + "dev": true, 1005 + "license": "MIT", 1006 + "optional": true, 1007 + "os": [ 1008 + "linux" 1009 + ] 1010 + }, 1011 + "node_modules/@rollup/rollup-linux-s390x-gnu": { 1012 + "version": "4.54.0", 1013 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.54.0.tgz", 1014 + "integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==", 1015 + "cpu": [ 1016 + "s390x" 1017 + ], 1018 + "dev": true, 1019 + "license": "MIT", 1020 + "optional": true, 1021 + "os": [ 1022 + "linux" 1023 + ] 1024 + }, 1025 + "node_modules/@rollup/rollup-linux-x64-gnu": { 1026 + "version": "4.54.0", 1027 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz", 1028 + "integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==", 1029 + "cpu": [ 1030 + "x64" 1031 + ], 1032 + "dev": true, 1033 + "license": "MIT", 1034 + "optional": true, 1035 + "os": [ 1036 + "linux" 1037 + ] 1038 + }, 1039 + "node_modules/@rollup/rollup-linux-x64-musl": { 1040 + "version": "4.54.0", 1041 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz", 1042 + "integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==", 1043 + "cpu": [ 1044 + "x64" 1045 + ], 1046 + "dev": true, 1047 + "license": "MIT", 1048 + "optional": true, 1049 + "os": [ 1050 + "linux" 1051 + ] 1052 + }, 1053 + "node_modules/@rollup/rollup-openharmony-arm64": { 1054 + "version": "4.54.0", 1055 + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.54.0.tgz", 1056 + "integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==", 1057 + "cpu": [ 1058 + "arm64" 1059 + ], 1060 + "dev": true, 1061 + "license": "MIT", 1062 + "optional": true, 1063 + "os": [ 1064 + "openharmony" 1065 + ] 1066 + }, 1067 + "node_modules/@rollup/rollup-win32-arm64-msvc": { 1068 + "version": "4.54.0", 1069 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz", 1070 + "integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==", 1071 + "cpu": [ 1072 + "arm64" 1073 + ], 1074 + "dev": true, 1075 + "license": "MIT", 1076 + "optional": true, 1077 + "os": [ 1078 + "win32" 1079 + ] 1080 + }, 1081 + "node_modules/@rollup/rollup-win32-ia32-msvc": { 1082 + "version": "4.54.0", 1083 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz", 1084 + "integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==", 1085 + "cpu": [ 1086 + "ia32" 1087 + ], 1088 + "dev": true, 1089 + "license": "MIT", 1090 + "optional": true, 1091 + "os": [ 1092 + "win32" 1093 + ] 1094 + }, 1095 + "node_modules/@rollup/rollup-win32-x64-gnu": { 1096 + "version": "4.54.0", 1097 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz", 1098 + "integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==", 1099 + "cpu": [ 1100 + "x64" 1101 + ], 1102 + "dev": true, 1103 + "license": "MIT", 1104 + "optional": true, 1105 + "os": [ 1106 + "win32" 1107 + ] 1108 + }, 1109 + "node_modules/@rollup/rollup-win32-x64-msvc": { 1110 + "version": "4.54.0", 1111 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz", 1112 + "integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==", 1113 + "cpu": [ 1114 + "x64" 1115 + ], 1116 + "dev": true, 1117 + "license": "MIT", 1118 + "optional": true, 1119 + "os": [ 1120 + "win32" 1121 + ] 1122 + }, 1123 + "node_modules/@types/babel__core": { 1124 + "version": "7.20.5", 1125 + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", 1126 + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", 1127 + "dev": true, 1128 + "license": "MIT", 1129 + "dependencies": { 1130 + "@babel/parser": "^7.20.7", 1131 + "@babel/types": "^7.20.7", 1132 + "@types/babel__generator": "*", 1133 + "@types/babel__template": "*", 1134 + "@types/babel__traverse": "*" 1135 + } 1136 + }, 1137 + "node_modules/@types/babel__generator": { 1138 + "version": "7.27.0", 1139 + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", 1140 + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", 1141 + "dev": true, 1142 + "license": "MIT", 1143 + "dependencies": { 1144 + "@babel/types": "^7.0.0" 1145 + } 1146 + }, 1147 + "node_modules/@types/babel__template": { 1148 + "version": "7.4.4", 1149 + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", 1150 + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", 1151 + "dev": true, 1152 + "license": "MIT", 1153 + "dependencies": { 1154 + "@babel/parser": "^7.1.0", 1155 + "@babel/types": "^7.0.0" 1156 + } 1157 + }, 1158 + "node_modules/@types/babel__traverse": { 1159 + "version": "7.28.0", 1160 + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", 1161 + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", 1162 + "dev": true, 1163 + "license": "MIT", 1164 + "dependencies": { 1165 + "@babel/types": "^7.28.2" 1166 + } 1167 + }, 1168 + "node_modules/@types/estree": { 1169 + "version": "1.0.8", 1170 + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", 1171 + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", 1172 + "dev": true, 1173 + "license": "MIT" 1174 + }, 1175 + "node_modules/@types/prop-types": { 1176 + "version": "15.7.15", 1177 + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", 1178 + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", 1179 + "dev": true, 1180 + "license": "MIT" 1181 + }, 1182 + "node_modules/@types/react": { 1183 + "version": "18.3.27", 1184 + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", 1185 + "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", 1186 + "dev": true, 1187 + "license": "MIT", 1188 + "peer": true, 1189 + "dependencies": { 1190 + "@types/prop-types": "*", 1191 + "csstype": "^3.2.2" 1192 + } 1193 + }, 1194 + "node_modules/@types/react-dom": { 1195 + "version": "18.3.7", 1196 + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", 1197 + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", 1198 + "dev": true, 1199 + "license": "MIT", 1200 + "peerDependencies": { 1201 + "@types/react": "^18.0.0" 1202 + } 1203 + }, 1204 + "node_modules/@vitejs/plugin-react": { 1205 + "version": "4.7.0", 1206 + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", 1207 + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", 1208 + "dev": true, 1209 + "license": "MIT", 1210 + "dependencies": { 1211 + "@babel/core": "^7.28.0", 1212 + "@babel/plugin-transform-react-jsx-self": "^7.27.1", 1213 + "@babel/plugin-transform-react-jsx-source": "^7.27.1", 1214 + "@rolldown/pluginutils": "1.0.0-beta.27", 1215 + "@types/babel__core": "^7.20.5", 1216 + "react-refresh": "^0.17.0" 1217 + }, 1218 + "engines": { 1219 + "node": "^14.18.0 || >=16.0.0" 1220 + }, 1221 + "peerDependencies": { 1222 + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" 1223 + } 1224 + }, 1225 + "node_modules/baseline-browser-mapping": { 1226 + "version": "2.9.11", 1227 + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz", 1228 + "integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==", 1229 + "dev": true, 1230 + "license": "Apache-2.0", 1231 + "bin": { 1232 + "baseline-browser-mapping": "dist/cli.js" 1233 + } 1234 + }, 1235 + "node_modules/browserslist": { 1236 + "version": "4.28.1", 1237 + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", 1238 + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", 1239 + "dev": true, 1240 + "funding": [ 1241 + { 1242 + "type": "opencollective", 1243 + "url": "https://opencollective.com/browserslist" 1244 + }, 1245 + { 1246 + "type": "tidelift", 1247 + "url": "https://tidelift.com/funding/github/npm/browserslist" 1248 + }, 1249 + { 1250 + "type": "github", 1251 + "url": "https://github.com/sponsors/ai" 1252 + } 1253 + ], 1254 + "license": "MIT", 1255 + "peer": true, 1256 + "dependencies": { 1257 + "baseline-browser-mapping": "^2.9.0", 1258 + "caniuse-lite": "^1.0.30001759", 1259 + "electron-to-chromium": "^1.5.263", 1260 + "node-releases": "^2.0.27", 1261 + "update-browserslist-db": "^1.2.0" 1262 + }, 1263 + "bin": { 1264 + "browserslist": "cli.js" 1265 + }, 1266 + "engines": { 1267 + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" 1268 + } 1269 + }, 1270 + "node_modules/caniuse-lite": { 1271 + "version": "1.0.30001762", 1272 + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001762.tgz", 1273 + "integrity": "sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==", 1274 + "dev": true, 1275 + "funding": [ 1276 + { 1277 + "type": "opencollective", 1278 + "url": "https://opencollective.com/browserslist" 1279 + }, 1280 + { 1281 + "type": "tidelift", 1282 + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" 1283 + }, 1284 + { 1285 + "type": "github", 1286 + "url": "https://github.com/sponsors/ai" 1287 + } 1288 + ], 1289 + "license": "CC-BY-4.0" 1290 + }, 1291 + "node_modules/convert-source-map": { 1292 + "version": "2.0.0", 1293 + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", 1294 + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", 1295 + "dev": true, 1296 + "license": "MIT" 1297 + }, 1298 + "node_modules/csstype": { 1299 + "version": "3.2.3", 1300 + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", 1301 + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", 1302 + "dev": true, 1303 + "license": "MIT" 1304 + }, 1305 + "node_modules/debug": { 1306 + "version": "4.4.3", 1307 + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", 1308 + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", 1309 + "dev": true, 1310 + "license": "MIT", 1311 + "dependencies": { 1312 + "ms": "^2.1.3" 1313 + }, 1314 + "engines": { 1315 + "node": ">=6.0" 1316 + }, 1317 + "peerDependenciesMeta": { 1318 + "supports-color": { 1319 + "optional": true 1320 + } 1321 + } 1322 + }, 1323 + "node_modules/electron-to-chromium": { 1324 + "version": "1.5.267", 1325 + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", 1326 + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", 1327 + "dev": true, 1328 + "license": "ISC" 1329 + }, 1330 + "node_modules/esbuild": { 1331 + "version": "0.25.12", 1332 + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", 1333 + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", 1334 + "dev": true, 1335 + "hasInstallScript": true, 1336 + "license": "MIT", 1337 + "bin": { 1338 + "esbuild": "bin/esbuild" 1339 + }, 1340 + "engines": { 1341 + "node": ">=18" 1342 + }, 1343 + "optionalDependencies": { 1344 + "@esbuild/aix-ppc64": "0.25.12", 1345 + "@esbuild/android-arm": "0.25.12", 1346 + "@esbuild/android-arm64": "0.25.12", 1347 + "@esbuild/android-x64": "0.25.12", 1348 + "@esbuild/darwin-arm64": "0.25.12", 1349 + "@esbuild/darwin-x64": "0.25.12", 1350 + "@esbuild/freebsd-arm64": "0.25.12", 1351 + "@esbuild/freebsd-x64": "0.25.12", 1352 + "@esbuild/linux-arm": "0.25.12", 1353 + "@esbuild/linux-arm64": "0.25.12", 1354 + "@esbuild/linux-ia32": "0.25.12", 1355 + "@esbuild/linux-loong64": "0.25.12", 1356 + "@esbuild/linux-mips64el": "0.25.12", 1357 + "@esbuild/linux-ppc64": "0.25.12", 1358 + "@esbuild/linux-riscv64": "0.25.12", 1359 + "@esbuild/linux-s390x": "0.25.12", 1360 + "@esbuild/linux-x64": "0.25.12", 1361 + "@esbuild/netbsd-arm64": "0.25.12", 1362 + "@esbuild/netbsd-x64": "0.25.12", 1363 + "@esbuild/openbsd-arm64": "0.25.12", 1364 + "@esbuild/openbsd-x64": "0.25.12", 1365 + "@esbuild/openharmony-arm64": "0.25.12", 1366 + "@esbuild/sunos-x64": "0.25.12", 1367 + "@esbuild/win32-arm64": "0.25.12", 1368 + "@esbuild/win32-ia32": "0.25.12", 1369 + "@esbuild/win32-x64": "0.25.12" 1370 + } 1371 + }, 1372 + "node_modules/escalade": { 1373 + "version": "3.2.0", 1374 + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", 1375 + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", 1376 + "dev": true, 1377 + "license": "MIT", 1378 + "engines": { 1379 + "node": ">=6" 1380 + } 1381 + }, 1382 + "node_modules/fdir": { 1383 + "version": "6.5.0", 1384 + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", 1385 + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", 1386 + "dev": true, 1387 + "license": "MIT", 1388 + "engines": { 1389 + "node": ">=12.0.0" 1390 + }, 1391 + "peerDependencies": { 1392 + "picomatch": "^3 || ^4" 1393 + }, 1394 + "peerDependenciesMeta": { 1395 + "picomatch": { 1396 + "optional": true 1397 + } 1398 + } 1399 + }, 1400 + "node_modules/fsevents": { 1401 + "version": "2.3.3", 1402 + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", 1403 + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", 1404 + "dev": true, 1405 + "hasInstallScript": true, 1406 + "license": "MIT", 1407 + "optional": true, 1408 + "os": [ 1409 + "darwin" 1410 + ], 1411 + "engines": { 1412 + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" 1413 + } 1414 + }, 1415 + "node_modules/gensync": { 1416 + "version": "1.0.0-beta.2", 1417 + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", 1418 + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", 1419 + "dev": true, 1420 + "license": "MIT", 1421 + "engines": { 1422 + "node": ">=6.9.0" 1423 + } 1424 + }, 1425 + "node_modules/js-tokens": { 1426 + "version": "4.0.0", 1427 + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", 1428 + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", 1429 + "license": "MIT" 1430 + }, 1431 + "node_modules/jsesc": { 1432 + "version": "3.1.0", 1433 + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", 1434 + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", 1435 + "dev": true, 1436 + "license": "MIT", 1437 + "bin": { 1438 + "jsesc": "bin/jsesc" 1439 + }, 1440 + "engines": { 1441 + "node": ">=6" 1442 + } 1443 + }, 1444 + "node_modules/json5": { 1445 + "version": "2.2.3", 1446 + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", 1447 + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", 1448 + "dev": true, 1449 + "license": "MIT", 1450 + "bin": { 1451 + "json5": "lib/cli.js" 1452 + }, 1453 + "engines": { 1454 + "node": ">=6" 1455 + } 1456 + }, 1457 + "node_modules/loose-envify": { 1458 + "version": "1.4.0", 1459 + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", 1460 + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", 1461 + "license": "MIT", 1462 + "dependencies": { 1463 + "js-tokens": "^3.0.0 || ^4.0.0" 1464 + }, 1465 + "bin": { 1466 + "loose-envify": "cli.js" 1467 + } 1468 + }, 1469 + "node_modules/lru-cache": { 1470 + "version": "5.1.1", 1471 + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", 1472 + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", 1473 + "dev": true, 1474 + "license": "ISC", 1475 + "dependencies": { 1476 + "yallist": "^3.0.2" 1477 + } 1478 + }, 1479 + "node_modules/lucide-react": { 1480 + "version": "0.562.0", 1481 + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.562.0.tgz", 1482 + "integrity": "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==", 1483 + "license": "ISC", 1484 + "peerDependencies": { 1485 + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" 1486 + } 1487 + }, 1488 + "node_modules/ms": { 1489 + "version": "2.1.3", 1490 + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", 1491 + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", 1492 + "dev": true, 1493 + "license": "MIT" 1494 + }, 1495 + "node_modules/nanoid": { 1496 + "version": "3.3.11", 1497 + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", 1498 + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", 1499 + "dev": true, 1500 + "funding": [ 1501 + { 1502 + "type": "github", 1503 + "url": "https://github.com/sponsors/ai" 1504 + } 1505 + ], 1506 + "license": "MIT", 1507 + "bin": { 1508 + "nanoid": "bin/nanoid.cjs" 1509 + }, 1510 + "engines": { 1511 + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" 1512 + } 1513 + }, 1514 + "node_modules/node-releases": { 1515 + "version": "2.0.27", 1516 + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", 1517 + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", 1518 + "dev": true, 1519 + "license": "MIT" 1520 + }, 1521 + "node_modules/picocolors": { 1522 + "version": "1.1.1", 1523 + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", 1524 + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", 1525 + "dev": true, 1526 + "license": "ISC" 1527 + }, 1528 + "node_modules/picomatch": { 1529 + "version": "4.0.3", 1530 + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", 1531 + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", 1532 + "dev": true, 1533 + "license": "MIT", 1534 + "peer": true, 1535 + "engines": { 1536 + "node": ">=12" 1537 + }, 1538 + "funding": { 1539 + "url": "https://github.com/sponsors/jonschlinkert" 1540 + } 1541 + }, 1542 + "node_modules/postcss": { 1543 + "version": "8.5.6", 1544 + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", 1545 + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", 1546 + "dev": true, 1547 + "funding": [ 1548 + { 1549 + "type": "opencollective", 1550 + "url": "https://opencollective.com/postcss/" 1551 + }, 1552 + { 1553 + "type": "tidelift", 1554 + "url": "https://tidelift.com/funding/github/npm/postcss" 1555 + }, 1556 + { 1557 + "type": "github", 1558 + "url": "https://github.com/sponsors/ai" 1559 + } 1560 + ], 1561 + "license": "MIT", 1562 + "dependencies": { 1563 + "nanoid": "^3.3.11", 1564 + "picocolors": "^1.1.1", 1565 + "source-map-js": "^1.2.1" 1566 + }, 1567 + "engines": { 1568 + "node": "^10 || ^12 || >=14" 1569 + } 1570 + }, 1571 + "node_modules/react": { 1572 + "version": "18.3.1", 1573 + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", 1574 + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", 1575 + "license": "MIT", 1576 + "peer": true, 1577 + "dependencies": { 1578 + "loose-envify": "^1.1.0" 1579 + }, 1580 + "engines": { 1581 + "node": ">=0.10.0" 1582 + } 1583 + }, 1584 + "node_modules/react-dom": { 1585 + "version": "18.3.1", 1586 + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", 1587 + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", 1588 + "license": "MIT", 1589 + "peer": true, 1590 + "dependencies": { 1591 + "loose-envify": "^1.1.0", 1592 + "scheduler": "^0.23.2" 1593 + }, 1594 + "peerDependencies": { 1595 + "react": "^18.3.1" 1596 + } 1597 + }, 1598 + "node_modules/react-icons": { 1599 + "version": "5.5.0", 1600 + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", 1601 + "integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==", 1602 + "license": "MIT", 1603 + "peerDependencies": { 1604 + "react": "*" 1605 + } 1606 + }, 1607 + "node_modules/react-refresh": { 1608 + "version": "0.17.0", 1609 + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", 1610 + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", 1611 + "dev": true, 1612 + "license": "MIT", 1613 + "engines": { 1614 + "node": ">=0.10.0" 1615 + } 1616 + }, 1617 + "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==", 1621 + "license": "MIT", 1622 + "dependencies": { 1623 + "@remix-run/router": "1.23.1" 1624 + }, 1625 + "engines": { 1626 + "node": ">=14.0.0" 1627 + }, 1628 + "peerDependencies": { 1629 + "react": ">=16.8" 1630 + } 1631 + }, 1632 + "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==", 1636 + "license": "MIT", 1637 + "dependencies": { 1638 + "@remix-run/router": "1.23.1", 1639 + "react-router": "6.30.2" 1640 + }, 1641 + "engines": { 1642 + "node": ">=14.0.0" 1643 + }, 1644 + "peerDependencies": { 1645 + "react": ">=16.8", 1646 + "react-dom": ">=16.8" 1647 + } 1648 + }, 1649 + "node_modules/rollup": { 1650 + "version": "4.54.0", 1651 + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz", 1652 + "integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==", 1653 + "dev": true, 1654 + "license": "MIT", 1655 + "dependencies": { 1656 + "@types/estree": "1.0.8" 1657 + }, 1658 + "bin": { 1659 + "rollup": "dist/bin/rollup" 1660 + }, 1661 + "engines": { 1662 + "node": ">=18.0.0", 1663 + "npm": ">=8.0.0" 1664 + }, 1665 + "optionalDependencies": { 1666 + "@rollup/rollup-android-arm-eabi": "4.54.0", 1667 + "@rollup/rollup-android-arm64": "4.54.0", 1668 + "@rollup/rollup-darwin-arm64": "4.54.0", 1669 + "@rollup/rollup-darwin-x64": "4.54.0", 1670 + "@rollup/rollup-freebsd-arm64": "4.54.0", 1671 + "@rollup/rollup-freebsd-x64": "4.54.0", 1672 + "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", 1673 + "@rollup/rollup-linux-arm-musleabihf": "4.54.0", 1674 + "@rollup/rollup-linux-arm64-gnu": "4.54.0", 1675 + "@rollup/rollup-linux-arm64-musl": "4.54.0", 1676 + "@rollup/rollup-linux-loong64-gnu": "4.54.0", 1677 + "@rollup/rollup-linux-ppc64-gnu": "4.54.0", 1678 + "@rollup/rollup-linux-riscv64-gnu": "4.54.0", 1679 + "@rollup/rollup-linux-riscv64-musl": "4.54.0", 1680 + "@rollup/rollup-linux-s390x-gnu": "4.54.0", 1681 + "@rollup/rollup-linux-x64-gnu": "4.54.0", 1682 + "@rollup/rollup-linux-x64-musl": "4.54.0", 1683 + "@rollup/rollup-openharmony-arm64": "4.54.0", 1684 + "@rollup/rollup-win32-arm64-msvc": "4.54.0", 1685 + "@rollup/rollup-win32-ia32-msvc": "4.54.0", 1686 + "@rollup/rollup-win32-x64-gnu": "4.54.0", 1687 + "@rollup/rollup-win32-x64-msvc": "4.54.0", 1688 + "fsevents": "~2.3.2" 1689 + } 1690 + }, 1691 + "node_modules/scheduler": { 1692 + "version": "0.23.2", 1693 + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", 1694 + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", 1695 + "license": "MIT", 1696 + "dependencies": { 1697 + "loose-envify": "^1.1.0" 1698 + } 1699 + }, 1700 + "node_modules/semver": { 1701 + "version": "6.3.1", 1702 + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", 1703 + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", 1704 + "dev": true, 1705 + "license": "ISC", 1706 + "bin": { 1707 + "semver": "bin/semver.js" 1708 + } 1709 + }, 1710 + "node_modules/source-map-js": { 1711 + "version": "1.2.1", 1712 + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", 1713 + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", 1714 + "dev": true, 1715 + "license": "BSD-3-Clause", 1716 + "engines": { 1717 + "node": ">=0.10.0" 1718 + } 1719 + }, 1720 + "node_modules/tinyglobby": { 1721 + "version": "0.2.15", 1722 + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", 1723 + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", 1724 + "dev": true, 1725 + "license": "MIT", 1726 + "dependencies": { 1727 + "fdir": "^6.5.0", 1728 + "picomatch": "^4.0.3" 1729 + }, 1730 + "engines": { 1731 + "node": ">=12.0.0" 1732 + }, 1733 + "funding": { 1734 + "url": "https://github.com/sponsors/SuperchupuDev" 1735 + } 1736 + }, 1737 + "node_modules/update-browserslist-db": { 1738 + "version": "1.2.3", 1739 + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", 1740 + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", 1741 + "dev": true, 1742 + "funding": [ 1743 + { 1744 + "type": "opencollective", 1745 + "url": "https://opencollective.com/browserslist" 1746 + }, 1747 + { 1748 + "type": "tidelift", 1749 + "url": "https://tidelift.com/funding/github/npm/browserslist" 1750 + }, 1751 + { 1752 + "type": "github", 1753 + "url": "https://github.com/sponsors/ai" 1754 + } 1755 + ], 1756 + "license": "MIT", 1757 + "dependencies": { 1758 + "escalade": "^3.2.0", 1759 + "picocolors": "^1.1.1" 1760 + }, 1761 + "bin": { 1762 + "update-browserslist-db": "cli.js" 1763 + }, 1764 + "peerDependencies": { 1765 + "browserslist": ">= 4.21.0" 1766 + } 1767 + }, 1768 + "node_modules/vite": { 1769 + "version": "6.4.1", 1770 + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", 1771 + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", 1772 + "dev": true, 1773 + "license": "MIT", 1774 + "peer": true, 1775 + "dependencies": { 1776 + "esbuild": "^0.25.0", 1777 + "fdir": "^6.4.4", 1778 + "picomatch": "^4.0.2", 1779 + "postcss": "^8.5.3", 1780 + "rollup": "^4.34.9", 1781 + "tinyglobby": "^0.2.13" 1782 + }, 1783 + "bin": { 1784 + "vite": "bin/vite.js" 1785 + }, 1786 + "engines": { 1787 + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" 1788 + }, 1789 + "funding": { 1790 + "url": "https://github.com/vitejs/vite?sponsor=1" 1791 + }, 1792 + "optionalDependencies": { 1793 + "fsevents": "~2.3.3" 1794 + }, 1795 + "peerDependencies": { 1796 + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", 1797 + "jiti": ">=1.21.0", 1798 + "less": "*", 1799 + "lightningcss": "^1.21.0", 1800 + "sass": "*", 1801 + "sass-embedded": "*", 1802 + "stylus": "*", 1803 + "sugarss": "*", 1804 + "terser": "^5.16.0", 1805 + "tsx": "^4.8.1", 1806 + "yaml": "^2.4.2" 1807 + }, 1808 + "peerDependenciesMeta": { 1809 + "@types/node": { 1810 + "optional": true 1811 + }, 1812 + "jiti": { 1813 + "optional": true 1814 + }, 1815 + "less": { 1816 + "optional": true 1817 + }, 1818 + "lightningcss": { 1819 + "optional": true 1820 + }, 1821 + "sass": { 1822 + "optional": true 1823 + }, 1824 + "sass-embedded": { 1825 + "optional": true 1826 + }, 1827 + "stylus": { 1828 + "optional": true 1829 + }, 1830 + "sugarss": { 1831 + "optional": true 1832 + }, 1833 + "terser": { 1834 + "optional": true 1835 + }, 1836 + "tsx": { 1837 + "optional": true 1838 + }, 1839 + "yaml": { 1840 + "optional": true 1841 + } 1842 + } 1843 + }, 1844 + "node_modules/yallist": { 1845 + "version": "3.1.1", 1846 + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", 1847 + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", 1848 + "dev": true, 1849 + "license": "ISC" 1850 + } 1851 + } 1852 + }
+24
web/package.json
··· 1 + { 2 + "name": "margin-web", 3 + "private": true, 4 + "version": "0.0.1", 5 + "type": "module", 6 + "scripts": { 7 + "dev": "vite", 8 + "build": "vite build", 9 + "preview": "vite preview" 10 + }, 11 + "dependencies": { 12 + "lucide-react": "^0.562.0", 13 + "react": "^18.3.1", 14 + "react-dom": "^18.3.1", 15 + "react-icons": "^5.5.0", 16 + "react-router-dom": "^6.28.0" 17 + }, 18 + "devDependencies": { 19 + "@types/react": "^18.3.12", 20 + "@types/react-dom": "^18.3.1", 21 + "@vitejs/plugin-react": "^4.3.3", 22 + "vite": "^6.0.3" 23 + } 24 + }
web/public/favicon.ico

This is a binary file and will not be displayed.

web/public/logo.png

This is a binary file and will not be displayed.

+4
web/public/logo.svg
··· 1 + <svg width="265" height="231" viewBox="0 0 265 231" fill="#6366f1" xmlns="http://www.w3.org/2000/svg"> 2 + <path d="M0 230 V0 H199 V65.7156 H149.5 V115.216 H182.5 L199 131.716 V230 Z"/> 3 + <path d="M215 214.224 V230 H264.5 V0 H215.07 V16.2242 H248.5 V214.224 H215 Z"/> 4 + </svg>
+53
web/src/App.jsx
··· 1 + import { Routes, Route } from "react-router-dom"; 2 + import { AuthProvider } from "./context/AuthContext"; 3 + import Navbar from "./components/Navbar"; 4 + import Feed from "./pages/Feed"; 5 + import Url from "./pages/Url"; 6 + import Profile from "./pages/Profile"; 7 + import Login from "./pages/Login"; 8 + import New from "./pages/New"; 9 + import Bookmarks from "./pages/Bookmarks"; 10 + import Highlights from "./pages/Highlights"; 11 + import Notifications from "./pages/Notifications"; 12 + import AnnotationDetail from "./pages/AnnotationDetail"; 13 + import Collections from "./pages/Collections"; 14 + import CollectionDetail from "./pages/CollectionDetail"; 15 + import Privacy from "./pages/Privacy"; 16 + 17 + function AppContent() { 18 + 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> 41 + </div> 42 + ); 43 + } 44 + 45 + export default function App() { 46 + return ( 47 + <AuthProvider> 48 + <Routes> 49 + <Route path="/*" element={<AppContent />} /> 50 + </Routes> 51 + </AuthProvider> 52 + ); 53 + }
+379
web/src/api/client.js
··· 1 + const API_BASE = "/api"; 2 + const AUTH_BASE = "/auth"; 3 + 4 + async function request(endpoint, options = {}) { 5 + const response = await fetch(endpoint, { 6 + credentials: "include", 7 + headers: { 8 + "Content-Type": "application/json", 9 + ...options.headers, 10 + }, 11 + ...options, 12 + }); 13 + 14 + if (!response.ok) { 15 + const error = await response.text(); 16 + throw new Error(error || `HTTP ${response.status}`); 17 + } 18 + 19 + return response.json(); 20 + } 21 + 22 + export async function getURLMetadata(url) { 23 + return request(`${API_BASE}/url-metadata?url=${encodeURIComponent(url)}`); 24 + } 25 + 26 + export async function getAnnotationFeed(limit = 50, offset = 0) { 27 + return request( 28 + `${API_BASE}/annotations/feed?limit=${limit}&offset=${offset}`, 29 + ); 30 + } 31 + 32 + export async function getAnnotations({ 33 + source, 34 + motivation, 35 + limit = 50, 36 + offset = 0, 37 + } = {}) { 38 + let url = `${API_BASE}/annotations?limit=${limit}&offset=${offset}`; 39 + if (source) url += `&source=${encodeURIComponent(source)}`; 40 + if (motivation) url += `&motivation=${motivation}`; 41 + return request(url); 42 + } 43 + 44 + export async function getByTarget(source, limit = 50, offset = 0) { 45 + return request( 46 + `${API_BASE}/targets?source=${encodeURIComponent(source)}&limit=${limit}&offset=${offset}`, 47 + ); 48 + } 49 + 50 + export async function getAnnotation(uri) { 51 + return request(`${API_BASE}/annotation?uri=${encodeURIComponent(uri)}`); 52 + } 53 + 54 + export async function getUserAnnotations(did, limit = 50, offset = 0) { 55 + return request( 56 + `${API_BASE}/users/${encodeURIComponent(did)}/annotations?limit=${limit}&offset=${offset}`, 57 + ); 58 + } 59 + 60 + export async function getUserHighlights(did, limit = 50, offset = 0) { 61 + return request( 62 + `${API_BASE}/users/${encodeURIComponent(did)}/highlights?limit=${limit}&offset=${offset}`, 63 + ); 64 + } 65 + 66 + export async function getUserBookmarks(did, limit = 50, offset = 0) { 67 + return request( 68 + `${API_BASE}/users/${encodeURIComponent(did)}/bookmarks?limit=${limit}&offset=${offset}`, 69 + ); 70 + } 71 + 72 + export async function getHighlights(creatorDid, limit = 50, offset = 0) { 73 + return request( 74 + `${API_BASE}/highlights?creator=${encodeURIComponent(creatorDid)}&limit=${limit}&offset=${offset}`, 75 + ); 76 + } 77 + 78 + export async function getBookmarks(creatorDid, limit = 50, offset = 0) { 79 + return request( 80 + `${API_BASE}/bookmarks?creator=${encodeURIComponent(creatorDid)}&limit=${limit}&offset=${offset}`, 81 + ); 82 + } 83 + 84 + export async function getReplies(annotationUri) { 85 + return request( 86 + `${API_BASE}/replies?uri=${encodeURIComponent(annotationUri)}`, 87 + ); 88 + } 89 + 90 + export async function updateAnnotation(uri, text, tags) { 91 + return request(`${API_BASE}/annotations?uri=${encodeURIComponent(uri)}`, { 92 + method: "PUT", 93 + body: JSON.stringify({ text, tags }), 94 + }); 95 + } 96 + 97 + export async function updateHighlight(uri, color, tags) { 98 + return request(`${API_BASE}/highlights?uri=${encodeURIComponent(uri)}`, { 99 + method: "PUT", 100 + body: JSON.stringify({ color, tags }), 101 + }); 102 + } 103 + 104 + export async function createBookmark(url, title, description) { 105 + return request(`${API_BASE}/bookmarks`, { 106 + method: "POST", 107 + body: JSON.stringify({ url, title, description }), 108 + }); 109 + } 110 + 111 + export async function updateBookmark(uri, title, description, tags) { 112 + return request(`${API_BASE}/bookmarks?uri=${encodeURIComponent(uri)}`, { 113 + method: "PUT", 114 + body: JSON.stringify({ title, description, tags }), 115 + }); 116 + } 117 + 118 + export async function getCollections(did) { 119 + let url = `${API_BASE}/collections`; 120 + if (did) url += `?author=${encodeURIComponent(did)}`; 121 + return request(url); 122 + } 123 + 124 + export async function getCollectionsContaining(annotationUri) { 125 + return request( 126 + `${API_BASE}/collections/containing?uri=${encodeURIComponent(annotationUri)}`, 127 + ); 128 + } 129 + 130 + export async function getEditHistory(uri) { 131 + return request( 132 + `${API_BASE}/annotations/history?uri=${encodeURIComponent(uri)}`, 133 + ); 134 + } 135 + 136 + export async function getNotifications(limit = 50, offset = 0) { 137 + return request(`${API_BASE}/notifications?limit=${limit}&offset=${offset}`); 138 + } 139 + 140 + export async function getUnreadNotificationCount() { 141 + return request(`${API_BASE}/notifications/count`); 142 + } 143 + 144 + export async function markNotificationsRead() { 145 + return request(`${API_BASE}/notifications/read`, { method: "POST" }); 146 + } 147 + 148 + export async function updateCollection(uri, name, description, icon) { 149 + return request(`${API_BASE}/collections?uri=${encodeURIComponent(uri)}`, { 150 + method: "PUT", 151 + body: JSON.stringify({ name, description, icon }), 152 + }); 153 + } 154 + 155 + export async function createCollection(name, description, icon) { 156 + return request(`${API_BASE}/collections`, { 157 + method: "POST", 158 + body: JSON.stringify({ name, description, icon }), 159 + }); 160 + } 161 + 162 + export async function deleteCollection(uri) { 163 + return request(`${API_BASE}/collections?uri=${encodeURIComponent(uri)}`, { 164 + method: "DELETE", 165 + }); 166 + } 167 + 168 + export async function getCollectionItems(collectionUri) { 169 + return request( 170 + `${API_BASE}/collections/${encodeURIComponent(collectionUri)}/items`, 171 + ); 172 + } 173 + 174 + export async function addItemToCollection( 175 + collectionUri, 176 + annotationUri, 177 + position = 0, 178 + ) { 179 + return request( 180 + `${API_BASE}/collections/${encodeURIComponent(collectionUri)}/items`, 181 + { 182 + method: "POST", 183 + body: JSON.stringify({ annotationUri, position }), 184 + }, 185 + ); 186 + } 187 + 188 + export async function removeItemFromCollection(itemUri) { 189 + return request( 190 + `${API_BASE}/collections/items?uri=${encodeURIComponent(itemUri)}`, 191 + { 192 + method: "DELETE", 193 + }, 194 + ); 195 + } 196 + 197 + export async function getLikeCount(annotationUri) { 198 + return request(`${API_BASE}/likes?uri=${encodeURIComponent(annotationUri)}`); 199 + } 200 + 201 + export async function deleteHighlight(rkey) { 202 + return request(`${API_BASE}/highlights?rkey=${encodeURIComponent(rkey)}`, { 203 + method: "DELETE", 204 + }); 205 + } 206 + 207 + export async function deleteBookmark(rkey) { 208 + return request(`${API_BASE}/bookmarks?rkey=${encodeURIComponent(rkey)}`, { 209 + method: "DELETE", 210 + }); 211 + } 212 + 213 + export async function createAnnotation({ url, text, quote, title, selector }) { 214 + return request(`${API_BASE}/annotations`, { 215 + method: "POST", 216 + body: JSON.stringify({ url, text, quote, title, selector }), 217 + }); 218 + } 219 + 220 + export async function deleteAnnotation(rkey, type = "annotation") { 221 + return request( 222 + `${API_BASE}/annotations?rkey=${encodeURIComponent(rkey)}&type=${encodeURIComponent(type)}`, 223 + { 224 + method: "DELETE", 225 + }, 226 + ); 227 + } 228 + 229 + export async function likeAnnotation(subjectUri, subjectCid) { 230 + return request(`${API_BASE}/annotations/like`, { 231 + method: "POST", 232 + headers: { 233 + "Content-Type": "application/json", 234 + }, 235 + body: JSON.stringify({ 236 + subjectUri, 237 + subjectCid, 238 + }), 239 + }); 240 + } 241 + 242 + export async function unlikeAnnotation(subjectUri) { 243 + return request( 244 + `${API_BASE}/annotations/like?uri=${encodeURIComponent(subjectUri)}`, 245 + { 246 + method: "DELETE", 247 + }, 248 + ); 249 + } 250 + 251 + export async function createReply({ 252 + parentUri, 253 + parentCid, 254 + rootUri, 255 + rootCid, 256 + text, 257 + }) { 258 + return request(`${API_BASE}/annotations/reply`, { 259 + method: "POST", 260 + body: JSON.stringify({ parentUri, parentCid, rootUri, rootCid, text }), 261 + }); 262 + } 263 + 264 + export async function deleteReply(uri) { 265 + return request( 266 + `${API_BASE}/annotations/reply?uri=${encodeURIComponent(uri)}`, 267 + { 268 + method: "DELETE", 269 + }, 270 + ); 271 + } 272 + 273 + export async function getSession() { 274 + return request(`${AUTH_BASE}/session`); 275 + } 276 + 277 + export async function logout() { 278 + return request(`${AUTH_BASE}/logout`, { method: "POST" }); 279 + } 280 + 281 + export function normalizeAnnotation(item) { 282 + if (!item) return {}; 283 + 284 + if (item.type === "Annotation") { 285 + 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, 292 + motivation: item.motivation, 293 + tags: item.tags || [], 294 + createdAt: item.created, 295 + cid: item.cid || item.CID, 296 + }; 297 + } 298 + 299 + if (item.type === "Bookmark") { 300 + return { 301 + uri: item.id, 302 + author: item.creator, 303 + url: item.source, 304 + title: item.title, 305 + description: item.description, 306 + tags: item.tags || [], 307 + createdAt: item.created, 308 + cid: item.cid || item.CID, 309 + }; 310 + } 311 + 312 + if (item.type === "Highlight") { 313 + return { 314 + uri: item.id, 315 + author: item.creator, 316 + url: item.target?.source, 317 + title: item.target?.title, 318 + selector: item.target?.selector, 319 + color: item.color, 320 + tags: item.tags || [], 321 + createdAt: item.created, 322 + cid: item.cid || item.CID, 323 + }; 324 + } 325 + 326 + return { 327 + uri: item.uri || item.id, 328 + author: item.author || item.creator, 329 + url: item.url || item.source || item.target?.source, 330 + title: item.title || item.target?.title, 331 + text: item.text || item.body?.value, 332 + description: item.description, 333 + selector: item.selector || item.target?.selector, 334 + color: item.color, 335 + tags: item.tags || [], 336 + createdAt: item.createdAt || item.created, 337 + cid: item.cid || item.CID, 338 + }; 339 + } 340 + 341 + export function normalizeHighlight(highlight) { 342 + return { 343 + uri: highlight.id, 344 + author: highlight.creator, 345 + url: highlight.target?.source, 346 + title: highlight.target?.title, 347 + selector: highlight.target?.selector, 348 + color: highlight.color, 349 + tags: highlight.tags || [], 350 + createdAt: highlight.created, 351 + }; 352 + } 353 + 354 + export function normalizeBookmark(bookmark) { 355 + return { 356 + uri: bookmark.id, 357 + author: bookmark.creator, 358 + url: bookmark.source, 359 + title: bookmark.title, 360 + description: bookmark.description, 361 + tags: bookmark.tags || [], 362 + createdAt: bookmark.created, 363 + }; 364 + } 365 + 366 + export async function searchActors(query) { 367 + const res = await fetch( 368 + `https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead?q=${encodeURIComponent(query)}&limit=5`, 369 + ); 370 + if (!res.ok) throw new Error("Search failed"); 371 + return res.json(); 372 + } 373 + 374 + export async function startLogin(handle, inviteCode) { 375 + return request(`${AUTH_BASE}/start`, { 376 + method: "POST", 377 + body: JSON.stringify({ handle, invite_code: inviteCode }), 378 + }); 379 + }
+4
web/src/assets/logo.svg
··· 1 + <svg width="265" height="231" viewBox="0 0 265 231" fill="#6366f1" xmlns="http://www.w3.org/2000/svg"> 2 + <path d="M0 230 V0 H199 V65.7156 H149.5 V115.216 H182.5 L199 131.716 V230 Z"/> 3 + <path d="M215 214.224 V230 H264.5 V0 H215.07 V16.2242 H248.5 V214.224 H215 Z"/> 4 + </svg>
+258
web/src/components/AddToCollectionModal.jsx
··· 1 + import { useState, useEffect } from "react"; 2 + import { X, Plus, Check, Folder } from "lucide-react"; 3 + import { 4 + getCollections, 5 + addItemToCollection, 6 + getCollectionsContaining, 7 + } from "../api/client"; 8 + import { useAuth } from "../context/AuthContext"; 9 + import CollectionModal from "./CollectionModal"; 10 + 11 + export default function AddToCollectionModal({ 12 + isOpen, 13 + onClose, 14 + annotationUri, 15 + }) { 16 + const { user } = useAuth(); 17 + const [collections, setCollections] = useState([]); 18 + const [loading, setLoading] = useState(true); 19 + const [addingTo, setAddingTo] = useState(null); 20 + const [addedTo, setAddedTo] = useState(new Set()); 21 + const [createModalOpen, setCreateModalOpen] = useState(false); 22 + const [error, setError] = useState(null); 23 + 24 + useEffect(() => { 25 + if (isOpen && user) { 26 + loadCollections(); 27 + setError(null); 28 + } 29 + }, [isOpen, user]); 30 + 31 + const loadCollections = async () => { 32 + try { 33 + setLoading(true); 34 + const [data, existingURIs] = await Promise.all([ 35 + getCollections(user?.did), 36 + annotationUri ? getCollectionsContaining(annotationUri) : [], 37 + ]); 38 + 39 + const items = Array.isArray(data) ? data : data.items || []; 40 + setCollections(items); 41 + setAddedTo(new Set(existingURIs || [])); 42 + } catch (err) { 43 + console.error(err); 44 + setError("Failed to load collections"); 45 + } finally { 46 + setLoading(false); 47 + } 48 + }; 49 + 50 + const handleAdd = async (collectionUri) => { 51 + if (addedTo.has(collectionUri)) return; 52 + 53 + try { 54 + setAddingTo(collectionUri); 55 + await addItemToCollection(collectionUri, annotationUri); 56 + setAddedTo((prev) => new Set([...prev, collectionUri])); 57 + } catch (err) { 58 + console.error(err); 59 + alert("Failed to add to collection"); 60 + } finally { 61 + setAddingTo(null); 62 + } 63 + }; 64 + 65 + if (!isOpen) return null; 66 + 67 + return ( 68 + <> 69 + <div className="modal-overlay" onClick={onClose}> 70 + <div 71 + className="modal-container" 72 + style={{ 73 + maxWidth: "380px", 74 + maxHeight: "80vh", 75 + display: "flex", 76 + flexDirection: "column", 77 + }} 78 + onClick={(e) => e.stopPropagation()} 79 + > 80 + <div className="modal-header"> 81 + <h2 82 + className="modal-title" 83 + style={{ display: "flex", alignItems: "center", gap: "8px" }} 84 + > 85 + <Folder size={20} style={{ color: "var(--accent)" }} /> 86 + Add to Collection 87 + </h2> 88 + <button onClick={onClose} className="modal-close-btn"> 89 + <X size={20} /> 90 + </button> 91 + </div> 92 + 93 + <div style={{ overflowY: "auto", padding: "8px", flex: 1 }}> 94 + {loading ? ( 95 + <div 96 + style={{ 97 + padding: "32px", 98 + display: "flex", 99 + alignItems: "center", 100 + justifyContent: "center", 101 + flexDirection: "column", 102 + gap: "12px", 103 + color: "var(--text-tertiary)", 104 + }} 105 + > 106 + <div className="spinner"></div> 107 + <span style={{ fontSize: "0.9rem" }}> 108 + Loading collections... 109 + </span> 110 + </div> 111 + ) : error ? ( 112 + <div style={{ padding: "24px", textAlign: "center" }}> 113 + <p 114 + className="text-error" 115 + style={{ fontSize: "0.9rem", marginBottom: "12px" }} 116 + > 117 + {error} 118 + </p> 119 + <button 120 + onClick={loadCollections} 121 + className="btn btn-secondary btn-sm" 122 + > 123 + Try Again 124 + </button> 125 + </div> 126 + ) : collections.length === 0 ? ( 127 + <div className="empty-state" style={{ padding: "32px" }}> 128 + <div className="empty-state-icon"> 129 + <Folder size={24} /> 130 + </div> 131 + <p className="empty-state-title" style={{ fontSize: "1rem" }}> 132 + No collections found 133 + </p> 134 + <p className="empty-state-text"> 135 + Create a collection to start organizing your items. 136 + </p> 137 + </div> 138 + ) : ( 139 + <div 140 + style={{ display: "flex", flexDirection: "column", gap: "4px" }} 141 + > 142 + {collections.map((col) => { 143 + const isAdded = addedTo.has(col.uri); 144 + const isAdding = addingTo === col.uri; 145 + 146 + return ( 147 + <button 148 + key={col.uri} 149 + onClick={() => handleAdd(col.uri)} 150 + disabled={isAdding || isAdded} 151 + className="collection-list-item" 152 + style={{ 153 + opacity: isAdded ? 0.7 : 1, 154 + cursor: isAdded ? "default" : "pointer", 155 + }} 156 + > 157 + <div 158 + style={{ 159 + display: "flex", 160 + flexDirection: "column", 161 + minWidth: 0, 162 + }} 163 + > 164 + <span 165 + style={{ 166 + fontWeight: 500, 167 + overflow: "hidden", 168 + textOverflow: "ellipsis", 169 + whiteSpace: "nowrap", 170 + }} 171 + > 172 + {col.name} 173 + </span> 174 + {col.description && ( 175 + <span 176 + style={{ 177 + fontSize: "0.75rem", 178 + color: "var(--text-tertiary)", 179 + overflow: "hidden", 180 + textOverflow: "ellipsis", 181 + whiteSpace: "nowrap", 182 + marginTop: "2px", 183 + }} 184 + > 185 + {col.description} 186 + </span> 187 + )} 188 + </div> 189 + 190 + {isAdding ? ( 191 + <span 192 + className="spinner spinner-sm" 193 + style={{ marginLeft: "12px" }} 194 + /> 195 + ) : isAdded ? ( 196 + <Check 197 + size={20} 198 + style={{ 199 + color: "var(--success)", 200 + marginLeft: "12px", 201 + }} 202 + /> 203 + ) : ( 204 + <Plus 205 + size={18} 206 + style={{ 207 + color: "var(--text-tertiary)", 208 + opacity: 0, 209 + marginLeft: "12px", 210 + }} 211 + className="collection-list-item-icon" 212 + /> 213 + )} 214 + </button> 215 + ); 216 + })} 217 + </div> 218 + )} 219 + </div> 220 + 221 + <div 222 + style={{ 223 + padding: "16px", 224 + borderTop: "1px solid var(--border)", 225 + background: "var(--bg-tertiary)", 226 + display: "flex", 227 + gap: "8px", 228 + }} 229 + > 230 + <button 231 + onClick={() => setCreateModalOpen(true)} 232 + className="btn btn-secondary" 233 + style={{ flex: 1 }} 234 + > 235 + <Plus size={18} /> 236 + New Collection 237 + </button> 238 + <button 239 + onClick={onClose} 240 + className="btn btn-primary" 241 + style={{ flex: 1 }} 242 + > 243 + Done 244 + </button> 245 + </div> 246 + </div> 247 + </div> 248 + 249 + <CollectionModal 250 + isOpen={createModalOpen} 251 + onClose={() => setCreateModalOpen(false)} 252 + onSuccess={() => { 253 + loadCollections(); 254 + }} 255 + /> 256 + </> 257 + ); 258 + }
+760
web/src/components/AnnotationCard.jsx
··· 1 + import { useState, useEffect } from "react"; 2 + import { useAuth } from "../context/AuthContext"; 3 + import ReplyList from "./ReplyList"; 4 + import { Link } from "react-router-dom"; 5 + import { 6 + normalizeAnnotation, 7 + normalizeHighlight, 8 + deleteAnnotation, 9 + likeAnnotation, 10 + unlikeAnnotation, 11 + getReplies, 12 + createReply, 13 + deleteReply, 14 + getLikeCount, 15 + updateAnnotation, 16 + updateHighlight, 17 + updateBookmark, 18 + getEditHistory, 19 + } from "../api/client"; 20 + 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"; 30 + import ShareMenu from "./ShareMenu"; 31 + 32 + function buildTextFragmentUrl(baseUrl, selector) { 33 + if (!selector || selector.type !== "TextQuoteSelector" || !selector.exact) { 34 + return baseUrl; 35 + } 36 + 37 + let fragment = ":~:text="; 38 + if (selector.prefix) { 39 + fragment += encodeURIComponent(selector.prefix) + "-,"; 40 + } 41 + fragment += encodeURIComponent(selector.exact); 42 + if (selector.suffix) { 43 + fragment += ",-" + encodeURIComponent(selector.suffix); 44 + } 45 + 46 + return baseUrl + "#" + fragment; 47 + } 48 + 49 + const truncateUrl = (url, maxLength = 60) => { 50 + if (!url) return ""; 51 + try { 52 + const parsed = new URL(url); 53 + const fullPath = parsed.host + parsed.pathname; 54 + if (fullPath.length > maxLength) 55 + return fullPath.substring(0, maxLength) + "..."; 56 + return fullPath; 57 + } catch { 58 + return url.length > maxLength ? url.substring(0, maxLength) + "..." : url; 59 + } 60 + }; 61 + 62 + export default function AnnotationCard({ annotation, onDelete }) { 63 + const { user, login } = useAuth(); 64 + const data = normalizeAnnotation(annotation); 65 + 66 + const [likeCount, setLikeCount] = useState(0); 67 + const [isLiked, setIsLiked] = useState(false); 68 + const [deleting, setDeleting] = useState(false); 69 + const [showAddToCollection, setShowAddToCollection] = useState(false); 70 + const [isEditing, setIsEditing] = useState(false); 71 + const [editText, setEditText] = useState(data.text || ""); 72 + const [saving, setSaving] = useState(false); 73 + 74 + const [showHistory, setShowHistory] = useState(false); 75 + const [editHistory, setEditHistory] = useState([]); 76 + const [loadingHistory, setLoadingHistory] = useState(false); 77 + 78 + const [replies, setReplies] = useState([]); 79 + const [replyCount, setReplyCount] = useState(0); 80 + const [showReplies, setShowReplies] = useState(false); 81 + const [replyingTo, setReplyingTo] = useState(null); 82 + const [replyText, setReplyText] = useState(""); 83 + const [posting, setPosting] = useState(false); 84 + 85 + const isOwner = user?.did && data.author?.did === user.did; 86 + 87 + const [hasEditHistory, setHasEditHistory] = useState(false); 88 + 89 + 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); 106 + } 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(); 123 + } 124 + return () => { 125 + mounted = false; 126 + }; 127 + }, [data.uri]); 128 + 129 + const fetchHistory = async () => { 130 + if (showHistory) { 131 + setShowHistory(false); 132 + return; 133 + } 134 + try { 135 + setLoadingHistory(true); 136 + setShowHistory(true); 137 + const history = await getEditHistory(data.uri); 138 + setEditHistory(history); 139 + } catch (err) { 140 + console.error("Failed to fetch history:", err); 141 + } finally { 142 + setLoadingHistory(false); 143 + } 144 + }; 145 + 146 + const handlePostReply = async (parentReply) => { 147 + if (!replyText.trim()) return; 148 + 149 + try { 150 + setPosting(true); 151 + const parentUri = parentReply 152 + ? parentReply.id || parentReply.uri 153 + : data.uri; 154 + const parentCid = parentReply 155 + ? parentReply.cid 156 + : annotation.cid || data.cid; 157 + 158 + await createReply({ 159 + parentUri, 160 + parentCid: parentCid || "", 161 + rootUri: data.uri, 162 + rootCid: annotation.cid || data.cid || "", 163 + text: replyText, 164 + }); 165 + 166 + setReplyText(""); 167 + setReplyingTo(null); 168 + 169 + const res = await getReplies(data.uri); 170 + if (res.items) { 171 + setReplies(res.items); 172 + setReplyCount(res.items.length); 173 + } 174 + } catch (err) { 175 + alert("Failed to post reply: " + err.message); 176 + } finally { 177 + setPosting(false); 178 + } 179 + }; 180 + 181 + const handleSaveEdit = async () => { 182 + try { 183 + setSaving(true); 184 + await updateAnnotation(data.uri, editText, data.tags); 185 + setIsEditing(false); 186 + if (annotation.body) annotation.body.value = editText; 187 + else if (annotation.text) annotation.text = editText; 188 + } catch (err) { 189 + alert("Failed to update: " + err.message); 190 + } finally { 191 + setSaving(false); 192 + } 193 + }; 194 + 195 + const formatDate = (dateString, simple = true) => { 196 + if (!dateString) return ""; 197 + const date = new Date(dateString); 198 + const now = new Date(); 199 + const diff = now - date; 200 + const minutes = Math.floor(diff / 60000); 201 + const hours = Math.floor(diff / 3600000); 202 + const days = Math.floor(diff / 86400000); 203 + if (minutes < 1) return "just now"; 204 + if (minutes < 60) return `${minutes}m`; 205 + if (hours < 24) return `${hours}h`; 206 + if (days < 7) return `${days}d`; 207 + if (simple) 208 + return date.toLocaleDateString("en-US", { 209 + month: "short", 210 + day: "numeric", 211 + }); 212 + return date.toLocaleString(); 213 + }; 214 + 215 + const authorDisplayName = data.author?.displayName || data.author?.handle; 216 + const authorHandle = data.author?.handle; 217 + const authorAvatar = data.author?.avatar; 218 + const authorDid = data.author?.did; 219 + const marginProfileUrl = authorDid ? `/profile/${authorDid}` : null; 220 + const highlightedText = 221 + data.selector?.type === "TextQuoteSelector" ? data.selector.exact : null; 222 + const fragmentUrl = buildTextFragmentUrl(data.url, data.selector); 223 + 224 + const handleLike = async () => { 225 + if (!user) { 226 + login(); 227 + return; 228 + } 229 + try { 230 + if (isLiked) { 231 + setIsLiked(false); 232 + setLikeCount((prev) => Math.max(0, prev - 1)); 233 + await unlikeAnnotation(data.uri); 234 + } else { 235 + setIsLiked(true); 236 + setLikeCount((prev) => prev + 1); 237 + const cid = annotation.cid || data.cid || ""; 238 + if (data.uri && cid) await likeAnnotation(data.uri, cid); 239 + } 240 + } catch (err) { 241 + setIsLiked(!isLiked); 242 + setLikeCount((prev) => (isLiked ? prev + 1 : prev - 1)); 243 + console.error("Failed to toggle like:", err); 244 + } 245 + }; 246 + 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 + const handleDelete = async () => { 272 + if (!confirm("Delete this annotation? This cannot be undone.")) return; 273 + try { 274 + setDeleting(true); 275 + const parts = data.uri.split("/"); 276 + const rkey = parts[parts.length - 1]; 277 + await deleteAnnotation(rkey); 278 + if (onDelete) onDelete(data.uri); 279 + else window.location.reload(); 280 + } catch (err) { 281 + alert("Failed to delete: " + err.message); 282 + } finally { 283 + setDeleting(false); 284 + } 285 + }; 286 + 287 + return ( 288 + <article className="card annotation-card"> 289 + <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" 317 + > 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" 343 + > 344 + <Edit2 size={16} /> 345 + </button> 346 + )} 347 + <button 348 + className="annotation-delete" 349 + onClick={handleDelete} 350 + disabled={deleting} 351 + title="Delete" 352 + > 353 + <TrashIcon size={16} /> 354 + </button> 355 + </> 356 + )} 357 + </div> 358 + </header> 359 + 360 + {} 361 + {} 362 + {showHistory && ( 363 + <div className="history-panel"> 364 + <div className="history-header"> 365 + <h4 className="history-title">Edit History</h4> 366 + <button 367 + className="history-close-btn" 368 + onClick={() => setShowHistory(false)} 369 + title="Close History" 370 + > 371 + <X size={14} /> 372 + </button> 373 + </div> 374 + {loadingHistory ? ( 375 + <div className="history-status">Loading history...</div> 376 + ) : editHistory.length === 0 ? ( 377 + <div className="history-status">No edit history found.</div> 378 + ) : ( 379 + <ul className="history-list"> 380 + {editHistory.map((edit) => ( 381 + <li key={edit.id} className="history-item"> 382 + <div className="history-date"> 383 + {new Date(edit.editedAt).toLocaleString()} 384 + </div> 385 + <div className="history-content">{edit.previousContent}</div> 386 + </li> 387 + ))} 388 + </ul> 389 + )} 390 + </div> 391 + )} 392 + 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 && ( 406 + <a 407 + href={fragmentUrl} 408 + target="_blank" 409 + rel="noopener noreferrer" 410 + className="annotation-highlight" 411 + > 412 + <mark>"{highlightedText}"</mark> 413 + </a> 414 + )} 415 + 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> 445 + </div> 446 + </div> 447 + ) : ( 448 + data.text && <p className="annotation-text">{data.text}</p> 449 + )} 450 + 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 + )} 460 + 461 + <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> 490 + </footer> 491 + 492 + {showReplies && ( 493 + <div className="inline-replies"> 494 + <ReplyList 495 + replies={replies} 496 + rootUri={data.uri} 497 + user={user} 498 + onReply={(reply) => setReplyingTo(reply)} 499 + onDelete={async (reply) => { 500 + if (!confirm("Delete this reply?")) return; 501 + try { 502 + await deleteReply(reply.id || reply.uri); 503 + const res = await getReplies(data.uri); 504 + if (res.items) { 505 + setReplies(res.items); 506 + setReplyCount(res.items.length); 507 + } 508 + } catch (err) { 509 + alert("Failed to delete: " + err.message); 510 + } 511 + }} 512 + isInline={true} 513 + /> 514 + 515 + <div className="reply-form"> 516 + {replyingTo && ( 517 + <div 518 + style={{ 519 + display: "flex", 520 + alignItems: "center", 521 + gap: "8px", 522 + marginBottom: "8px", 523 + fontSize: "0.85rem", 524 + color: "var(--text-secondary)", 525 + }} 526 + > 527 + <span> 528 + Replying to @ 529 + {(replyingTo.creator || replyingTo.author)?.handle || 530 + "unknown"} 531 + </span> 532 + <button 533 + onClick={() => setReplyingTo(null)} 534 + style={{ 535 + background: "none", 536 + border: "none", 537 + color: "var(--text-tertiary)", 538 + cursor: "pointer", 539 + padding: "2px 6px", 540 + }} 541 + > 542 + × 543 + </button> 544 + </div> 545 + )} 546 + <textarea 547 + className="reply-input" 548 + placeholder={ 549 + replyingTo 550 + ? `Reply to @${(replyingTo.creator || replyingTo.author)?.handle}...` 551 + : "Write a reply..." 552 + } 553 + value={replyText} 554 + onChange={(e) => setReplyText(e.target.value)} 555 + onFocus={(e) => { 556 + if (!user) { 557 + e.target.blur(); 558 + login(); 559 + } 560 + }} 561 + rows={2} 562 + /> 563 + <div className="action-buttons-end"> 564 + <button 565 + className="btn btn-primary btn-sm" 566 + disabled={posting || !replyText.trim()} 567 + onClick={() => { 568 + if (!user) { 569 + login(); 570 + return; 571 + } 572 + handlePostReply(replyingTo); 573 + }} 574 + > 575 + {posting ? "Posting..." : "Reply"} 576 + </button> 577 + </div> 578 + </div> 579 + </div> 580 + )} 581 + 582 + <AddToCollectionModal 583 + isOpen={showAddToCollection} 584 + onClose={() => setShowAddToCollection(false)} 585 + annotationUri={data.uri} 586 + /> 587 + </article> 588 + ); 589 + } 590 + 591 + export function HighlightCard({ highlight, onDelete }) { 592 + const { user, login } = useAuth(); 593 + const data = normalizeHighlight(highlight); 594 + const highlightedText = 595 + data.selector?.type === "TextQuoteSelector" ? data.selector.exact : null; 596 + const fragmentUrl = buildTextFragmentUrl(data.url, data.selector); 597 + const isOwner = user?.did && data.author?.did === user.did; 598 + const [showAddToCollection, setShowAddToCollection] = useState(false); 599 + const [isEditing, setIsEditing] = useState(false); 600 + const [editColor, setEditColor] = useState(data.color || "#f59e0b"); 601 + 602 + const handleSaveEdit = async () => { 603 + try { 604 + await updateHighlight(data.uri, editColor, []); 605 + setIsEditing(false); 606 + 607 + if (highlight.color) highlight.color = editColor; 608 + } catch (err) { 609 + alert("Failed to update: " + err.message); 610 + } 611 + }; 612 + 613 + const formatDate = (dateString, simple = true) => { 614 + if (!dateString) return ""; 615 + const date = new Date(dateString); 616 + const now = new Date(); 617 + const diff = now - date; 618 + const minutes = Math.floor(diff / 60000); 619 + const hours = Math.floor(diff / 3600000); 620 + const days = Math.floor(diff / 86400000); 621 + if (minutes < 1) return "just now"; 622 + if (minutes < 60) return `${minutes}m`; 623 + if (hours < 24) return `${hours}h`; 624 + if (days < 7) return `${days}d`; 625 + if (simple) 626 + return date.toLocaleDateString("en-US", { 627 + month: "short", 628 + day: "numeric", 629 + }); 630 + return date.toLocaleString(); 631 + }; 632 + 633 + return ( 634 + <article className="card annotation-card"> 635 + <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> 645 + )} 646 + </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 + </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 + )} 677 + </div> 678 + </header> 679 + 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 && ( 690 + <a 691 + href={fragmentUrl} 692 + target="_blank" 693 + rel="noopener noreferrer" 694 + className="annotation-highlight" 695 + style={{ 696 + borderLeftColor: isEditing ? editColor : data.color || "#f59e0b", 697 + }} 698 + > 699 + <mark>"{highlightedText}"</mark> 700 + </a> 701 + )} 702 + 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)} 713 + style={{ 714 + height: "32px", 715 + width: "64px", 716 + padding: 0, 717 + border: "none", 718 + borderRadius: "var(--radius-sm)", 719 + overflow: "hidden", 720 + }} 721 + /> 722 + <button 723 + onClick={handleSaveEdit} 724 + className="btn btn-primary btn-sm" 725 + style={{ marginLeft: "auto" }} 726 + > 727 + Save 728 + </button> 729 + </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 + </footer> 753 + <AddToCollectionModal 754 + isOpen={showAddToCollection} 755 + onClose={() => setShowAddToCollection(false)} 756 + annotationUri={data.uri} 757 + /> 758 + </article> 759 + ); 760 + }
+248
web/src/components/BookmarkCard.jsx
··· 1 + import { useState, useEffect } from "react"; 2 + import { useAuth } from "../context/AuthContext"; 3 + import { Link } from "react-router-dom"; 4 + import { 5 + normalizeAnnotation, 6 + likeAnnotation, 7 + unlikeAnnotation, 8 + getLikeCount, 9 + deleteBookmark, 10 + } from "../api/client"; 11 + import { HeartIcon, TrashIcon, ExternalLinkIcon, BookmarkIcon } from "./Icons"; 12 + import { Folder } from "lucide-react"; 13 + import AddToCollectionModal from "./AddToCollectionModal"; 14 + import ShareMenu from "./ShareMenu"; 15 + 16 + export default function BookmarkCard({ bookmark, annotation, onDelete }) { 17 + const { user, login } = useAuth(); 18 + const data = normalizeAnnotation(bookmark || annotation); 19 + 20 + const [likeCount, setLikeCount] = useState(0); 21 + const [isLiked, setIsLiked] = useState(false); 22 + const [deleting, setDeleting] = useState(false); 23 + const [showAddToCollection, setShowAddToCollection] = useState(false); 24 + 25 + const isOwner = user?.did && data.author?.did === user.did; 26 + 27 + useEffect(() => { 28 + let mounted = true; 29 + async function fetchData() { 30 + try { 31 + const likeRes = await getLikeCount(data.uri); 32 + if (mounted) { 33 + if (likeRes.count !== undefined) setLikeCount(likeRes.count); 34 + if (likeRes.liked !== undefined) setIsLiked(likeRes.liked); 35 + } 36 + } catch (err) { 37 + console.error("Failed to fetch data:", err); 38 + } 39 + } 40 + if (data.uri) fetchData(); 41 + return () => { 42 + mounted = false; 43 + }; 44 + }, [data.uri]); 45 + 46 + const handleLike = async () => { 47 + if (!user) { 48 + login(); 49 + return; 50 + } 51 + try { 52 + if (isLiked) { 53 + setIsLiked(false); 54 + setLikeCount((prev) => Math.max(0, prev - 1)); 55 + await unlikeAnnotation(data.uri); 56 + } else { 57 + setIsLiked(true); 58 + setLikeCount((prev) => prev + 1); 59 + const cid = data.cid || ""; 60 + if (data.uri && cid) await likeAnnotation(data.uri, cid); 61 + } 62 + } catch (err) { 63 + setIsLiked(!isLiked); 64 + setLikeCount((prev) => (isLiked ? prev + 1 : prev - 1)); 65 + } 66 + }; 67 + 68 + const handleDelete = async () => { 69 + if (!confirm("Delete this bookmark?")) return; 70 + try { 71 + setDeleting(true); 72 + const parts = data.uri.split("/"); 73 + const rkey = parts[parts.length - 1]; 74 + await deleteBookmark(rkey); 75 + if (onDelete) onDelete(data.uri); 76 + else window.location.reload(); 77 + } catch (err) { 78 + alert("Failed to delete: " + err.message); 79 + } finally { 80 + setDeleting(false); 81 + } 82 + }; 83 + 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 + const formatDate = (dateString) => { 104 + if (!dateString) return ""; 105 + const date = new Date(dateString); 106 + const now = new Date(); 107 + const diff = now - date; 108 + const minutes = Math.floor(diff / 60000); 109 + const hours = Math.floor(diff / 3600000); 110 + const days = Math.floor(diff / 86400000); 111 + if (minutes < 1) return "just now"; 112 + if (minutes < 60) return `${minutes}m`; 113 + if (hours < 24) return `${hours}h`; 114 + if (days < 7) return `${days}d`; 115 + return date.toLocaleDateString("en-US", { month: "short", day: "numeric" }); 116 + }; 117 + 118 + let domain = ""; 119 + try { 120 + if (data.url) domain = new URL(data.url).hostname.replace("www.", ""); 121 + } catch {} 122 + 123 + const authorDisplayName = data.author?.displayName || data.author?.handle; 124 + const authorHandle = data.author?.handle; 125 + const authorAvatar = data.author?.avatar; 126 + const authorDid = data.author?.did; 127 + const marginProfileUrl = authorDid ? `/profile/${authorDid}` : null; 128 + 129 + return ( 130 + <article className="card bookmark-card"> 131 + {} 132 + <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 + )} 144 + </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" 160 + > 161 + @{authorHandle} <ExternalLinkIcon size={12} /> 162 + </a> 163 + )} 164 + </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 + </div> 179 + </header> 180 + 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> 192 + </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> 202 + 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 + )} 213 + 214 + {} 215 + <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> 237 + </footer> 238 + 239 + {showAddToCollection && ( 240 + <AddToCollectionModal 241 + isOpen={showAddToCollection} 242 + annotationUri={data.uri} 243 + onClose={() => setShowAddToCollection(false)} 244 + /> 245 + )} 246 + </article> 247 + ); 248 + }
+106
web/src/components/CollectionIcon.jsx
··· 1 + import { 2 + Folder, 3 + Star, 4 + Heart, 5 + Bookmark, 6 + Lightbulb, 7 + Zap, 8 + Coffee, 9 + Music, 10 + Camera, 11 + Code, 12 + Globe, 13 + Flag, 14 + Tag, 15 + Box, 16 + Archive, 17 + FileText, 18 + Image, 19 + Video, 20 + Mail, 21 + MapPin, 22 + Calendar, 23 + Clock, 24 + Search, 25 + Settings, 26 + User, 27 + Users, 28 + Home, 29 + Briefcase, 30 + Gift, 31 + Award, 32 + Target, 33 + TrendingUp, 34 + Activity, 35 + Cpu, 36 + Database, 37 + Cloud, 38 + Sun, 39 + Moon, 40 + Flame, 41 + Leaf, 42 + } from "lucide-react"; 43 + 44 + const ICON_MAP = { 45 + folder: Folder, 46 + star: Star, 47 + heart: Heart, 48 + bookmark: Bookmark, 49 + lightbulb: Lightbulb, 50 + zap: Zap, 51 + coffee: Coffee, 52 + music: Music, 53 + camera: Camera, 54 + code: Code, 55 + globe: Globe, 56 + flag: Flag, 57 + tag: Tag, 58 + box: Box, 59 + archive: Archive, 60 + file: FileText, 61 + image: Image, 62 + video: Video, 63 + mail: Mail, 64 + pin: MapPin, 65 + calendar: Calendar, 66 + clock: Clock, 67 + search: Search, 68 + settings: Settings, 69 + user: User, 70 + users: Users, 71 + home: Home, 72 + briefcase: Briefcase, 73 + gift: Gift, 74 + award: Award, 75 + target: Target, 76 + trending: TrendingUp, 77 + activity: Activity, 78 + cpu: Cpu, 79 + database: Database, 80 + cloud: Cloud, 81 + sun: Sun, 82 + moon: Moon, 83 + flame: Flame, 84 + leaf: Leaf, 85 + }; 86 + 87 + export default function CollectionIcon({ icon, size = 22, className = "" }) { 88 + if (!icon) { 89 + return <Folder size={size} className={className} />; 90 + } 91 + 92 + if (icon.startsWith("icon:")) { 93 + const iconName = icon.replace("icon:", ""); 94 + const IconComponent = ICON_MAP[iconName]; 95 + if (IconComponent) { 96 + return <IconComponent size={size} className={className} />; 97 + } 98 + return <Folder size={size} className={className} />; 99 + } 100 + 101 + return ( 102 + <span style={{ fontSize: `${size * 0.065}rem`, lineHeight: 1 }}> 103 + {icon} 104 + </span> 105 + ); 106 + }
+89
web/src/components/CollectionItemCard.jsx
··· 1 + import React from "react"; 2 + import { Link } from "react-router-dom"; 3 + import AnnotationCard, { HighlightCard } from "./AnnotationCard"; 4 + import BookmarkCard from "./BookmarkCard"; 5 + 6 + import CollectionIcon from "./CollectionIcon"; 7 + import ShareMenu from "./ShareMenu"; 8 + 9 + export default function CollectionItemCard({ item }) { 10 + const author = item.creator; 11 + const collection = item.collection; 12 + 13 + if (!author || !collection) return null; 14 + 15 + let inner = null; 16 + if (item.annotation) { 17 + inner = <AnnotationCard annotation={item.annotation} />; 18 + } else if (item.highlight) { 19 + inner = <HighlightCard highlight={item.highlight} />; 20 + } else if (item.bookmark) { 21 + inner = <BookmarkCard bookmark={item.bookmark} />; 22 + } 23 + 24 + if (!inner) return null; 25 + 26 + return ( 27 + <div className="collection-feed-item" style={{ marginBottom: "20px" }}> 28 + <div 29 + className="feed-context-header" 30 + style={{ 31 + display: "flex", 32 + alignItems: "center", 33 + gap: "8px", 34 + marginBottom: "8px", 35 + fontSize: "14px", 36 + color: "var(--text-secondary)", 37 + }} 38 + > 39 + {author.avatar && ( 40 + <img 41 + src={author.avatar} 42 + alt={author.handle} 43 + style={{ 44 + width: "24px", 45 + height: "24px", 46 + borderRadius: "50%", 47 + objectFit: "cover", 48 + }} 49 + /> 50 + )} 51 + <span> 52 + <span style={{ fontWeight: 600, color: "var(--text-primary)" }}> 53 + {author.displayName || author.handle} 54 + </span>{" "} 55 + added to{" "} 56 + <Link 57 + to={`/collection/${encodeURIComponent(collection.uri)}?author=${encodeURIComponent(author.did)}`} 58 + style={{ 59 + display: "inline-flex", 60 + alignItems: "center", 61 + gap: "4px", 62 + fontWeight: 500, 63 + color: "var(--primary)", 64 + textDecoration: "none", 65 + }} 66 + > 67 + <CollectionIcon icon={collection.icon} size={14} /> 68 + {collection.name} 69 + </Link> 70 + </span> 71 + <div style={{ marginLeft: "auto" }}> 72 + <ShareMenu 73 + customUrl={`${window.location.origin}/collection/${encodeURIComponent(collection.uri)}?author=${encodeURIComponent(author.did)}`} 74 + text={`Check out this collection by ${author.displayName}: ${collection.name}`} 75 + /> 76 + </div> 77 + </div> 78 + <div 79 + className="feed-context-body" 80 + style={{ 81 + paddingLeft: "16px", 82 + borderLeft: "2px solid var(--border-color)", 83 + }} 84 + > 85 + {inner} 86 + </div> 87 + </div> 88 + ); 89 + }
+357
web/src/components/CollectionModal.jsx
··· 1 + import { useState, useEffect } from "react"; 2 + import { 3 + X, 4 + Folder, 5 + Star, 6 + Heart, 7 + Bookmark, 8 + Lightbulb, 9 + Zap, 10 + Coffee, 11 + Music, 12 + Camera, 13 + Code, 14 + Globe, 15 + Lock, 16 + Flag, 17 + Tag, 18 + Box, 19 + Archive, 20 + FileText, 21 + Image, 22 + Video, 23 + Mail, 24 + Phone, 25 + MapPin, 26 + Calendar, 27 + Clock, 28 + Search, 29 + Settings, 30 + User, 31 + Users, 32 + Home, 33 + Briefcase, 34 + ShoppingBag, 35 + Gift, 36 + Award, 37 + Target, 38 + TrendingUp, 39 + BarChart, 40 + PieChart, 41 + Activity, 42 + Cpu, 43 + Database, 44 + Cloud, 45 + Sun, 46 + Moon, 47 + Flame, 48 + Leaf, 49 + Droplet, 50 + Snowflake, 51 + } from "lucide-react"; 52 + import { createCollection, updateCollection } from "../api/client"; 53 + 54 + const EMOJI_OPTIONS = [ 55 + "📁", 56 + "📚", 57 + "💡", 58 + "⭐", 59 + "🔖", 60 + "💻", 61 + "🎨", 62 + "📝", 63 + "🔬", 64 + "🎯", 65 + "🚀", 66 + "💎", 67 + "🌟", 68 + "📌", 69 + "💼", 70 + "🎮", 71 + "🎵", 72 + "🎬", 73 + "❤️", 74 + "🔥", 75 + "🌈", 76 + "🌸", 77 + "🌿", 78 + "🧠", 79 + "🏆", 80 + "📊", 81 + "🎓", 82 + "✨", 83 + "🔧", 84 + "⚡", 85 + ]; 86 + 87 + const ICON_OPTIONS = [ 88 + { icon: Folder, name: "folder" }, 89 + { icon: Star, name: "star" }, 90 + { icon: Heart, name: "heart" }, 91 + { icon: Bookmark, name: "bookmark" }, 92 + { icon: Lightbulb, name: "lightbulb" }, 93 + { icon: Zap, name: "zap" }, 94 + { icon: Coffee, name: "coffee" }, 95 + { icon: Music, name: "music" }, 96 + { icon: Camera, name: "camera" }, 97 + { icon: Code, name: "code" }, 98 + { icon: Globe, name: "globe" }, 99 + { icon: Flag, name: "flag" }, 100 + { icon: Tag, name: "tag" }, 101 + { icon: Box, name: "box" }, 102 + { icon: Archive, name: "archive" }, 103 + { icon: FileText, name: "file" }, 104 + { icon: Image, name: "image" }, 105 + { icon: Video, name: "video" }, 106 + { icon: Mail, name: "mail" }, 107 + { icon: MapPin, name: "pin" }, 108 + { icon: Calendar, name: "calendar" }, 109 + { icon: Clock, name: "clock" }, 110 + { icon: Search, name: "search" }, 111 + { icon: Settings, name: "settings" }, 112 + { icon: User, name: "user" }, 113 + { icon: Users, name: "users" }, 114 + { icon: Home, name: "home" }, 115 + { icon: Briefcase, name: "briefcase" }, 116 + { icon: Gift, name: "gift" }, 117 + { icon: Award, name: "award" }, 118 + { icon: Target, name: "target" }, 119 + { icon: TrendingUp, name: "trending" }, 120 + { icon: Activity, name: "activity" }, 121 + { icon: Cpu, name: "cpu" }, 122 + { icon: Database, name: "database" }, 123 + { icon: Cloud, name: "cloud" }, 124 + { icon: Sun, name: "sun" }, 125 + { icon: Moon, name: "moon" }, 126 + { icon: Flame, name: "flame" }, 127 + { icon: Leaf, name: "leaf" }, 128 + ]; 129 + 130 + export default function CollectionModal({ 131 + isOpen, 132 + onClose, 133 + onSuccess, 134 + collectionToEdit, 135 + }) { 136 + const [name, setName] = useState(""); 137 + const [description, setDescription] = useState(""); 138 + const [icon, setIcon] = useState(""); 139 + const [customEmoji, setCustomEmoji] = useState(""); 140 + const [activeTab, setActiveTab] = useState("emoji"); 141 + const [loading, setLoading] = useState(false); 142 + const [error, setError] = useState(null); 143 + 144 + useEffect(() => { 145 + if (collectionToEdit) { 146 + setName(collectionToEdit.name); 147 + setDescription(collectionToEdit.description || ""); 148 + const savedIcon = collectionToEdit.icon || ""; 149 + setIcon(savedIcon); 150 + setCustomEmoji(savedIcon); 151 + 152 + if (savedIcon.startsWith("icon:")) { 153 + setActiveTab("icons"); 154 + } 155 + } else { 156 + setName(""); 157 + setDescription(""); 158 + setIcon(""); 159 + setCustomEmoji(""); 160 + } 161 + setError(null); 162 + }, [collectionToEdit, isOpen]); 163 + 164 + if (!isOpen) return null; 165 + 166 + const handleEmojiSelect = (emoji) => { 167 + if (icon === emoji) { 168 + setIcon(""); 169 + setCustomEmoji(""); 170 + } else { 171 + setIcon(emoji); 172 + setCustomEmoji(emoji); 173 + } 174 + }; 175 + 176 + const handleIconSelect = (iconName) => { 177 + const value = `icon:${iconName}`; 178 + if (icon === value) { 179 + setIcon(""); 180 + setCustomEmoji(""); 181 + } else { 182 + setIcon(value); 183 + setCustomEmoji(value); 184 + } 185 + }; 186 + 187 + const handleCustomEmojiChange = (e) => { 188 + const value = e.target.value; 189 + setCustomEmoji(value); 190 + const emojiMatch = value.match( 191 + /(\p{Emoji_Presentation}|\p{Emoji}\uFE0F)/gu, 192 + ); 193 + if (emojiMatch && emojiMatch.length > 0) { 194 + setIcon(emojiMatch[emojiMatch.length - 1]); 195 + } else if (value === "") { 196 + setIcon(""); 197 + } 198 + }; 199 + 200 + const handleSubmit = async (e) => { 201 + e.preventDefault(); 202 + setLoading(true); 203 + setError(null); 204 + 205 + try { 206 + if (collectionToEdit) { 207 + await updateCollection(collectionToEdit.uri, name, description, icon); 208 + } else { 209 + await createCollection(name, description, icon); 210 + } 211 + onSuccess(); 212 + onClose(); 213 + } catch (err) { 214 + console.error(err); 215 + setError(err.message || "Failed to save collection"); 216 + } finally { 217 + setLoading(false); 218 + } 219 + }; 220 + 221 + return ( 222 + <div className="modal-overlay" onClick={onClose}> 223 + <div 224 + className="modal-container" 225 + style={{ maxWidth: "420px" }} 226 + onClick={(e) => e.stopPropagation()} 227 + > 228 + <div className="modal-header"> 229 + <h2 className="modal-title"> 230 + {collectionToEdit ? "Edit Collection" : "New Collection"} 231 + </h2> 232 + <button onClick={onClose} className="modal-close-btn"> 233 + <X size={20} /> 234 + </button> 235 + </div> 236 + 237 + <form onSubmit={handleSubmit} className="modal-form"> 238 + {error && ( 239 + <div 240 + className="card text-error" 241 + style={{ 242 + padding: "12px", 243 + background: "rgba(239, 68, 68, 0.1)", 244 + borderColor: "rgba(239, 68, 68, 0.2)", 245 + fontSize: "0.9rem", 246 + }} 247 + > 248 + {error} 249 + </div> 250 + )} 251 + 252 + <div className="form-group"> 253 + <label className="form-label">Icon</label> 254 + <div className="icon-picker-tabs"> 255 + <button 256 + type="button" 257 + className={`icon-picker-tab ${activeTab === "emoji" ? "active" : ""}`} 258 + onClick={() => setActiveTab("emoji")} 259 + > 260 + Emoji 261 + </button> 262 + <button 263 + type="button" 264 + className={`icon-picker-tab ${activeTab === "icons" ? "active" : ""}`} 265 + onClick={() => setActiveTab("icons")} 266 + > 267 + Icons 268 + </button> 269 + </div> 270 + 271 + {activeTab === "emoji" && ( 272 + <div className="emoji-picker-wrapper"> 273 + <div className="emoji-custom-input"> 274 + <input 275 + type="text" 276 + value={customEmoji.startsWith("icon:") ? "" : customEmoji} 277 + onChange={handleCustomEmojiChange} 278 + placeholder="Type any emoji..." 279 + className="form-input" 280 + /> 281 + </div> 282 + <div className="emoji-picker"> 283 + {EMOJI_OPTIONS.map((emoji) => ( 284 + <button 285 + key={emoji} 286 + type="button" 287 + className={`emoji-option ${icon === emoji ? "selected" : ""}`} 288 + onClick={() => handleEmojiSelect(emoji)} 289 + > 290 + {emoji} 291 + </button> 292 + ))} 293 + </div> 294 + </div> 295 + )} 296 + 297 + {activeTab === "icons" && ( 298 + <div className="icon-picker"> 299 + {ICON_OPTIONS.map(({ icon: IconComponent, name: iconName }) => ( 300 + <button 301 + key={iconName} 302 + type="button" 303 + className={`icon-option ${icon === `icon:${iconName}` ? "selected" : ""}`} 304 + onClick={() => handleIconSelect(iconName)} 305 + > 306 + <IconComponent size={20} /> 307 + </button> 308 + ))} 309 + </div> 310 + )} 311 + </div> 312 + 313 + <div className="form-group"> 314 + <label className="form-label">Name</label> 315 + <input 316 + type="text" 317 + value={name} 318 + onChange={(e) => setName(e.target.value)} 319 + required 320 + className="form-input" 321 + placeholder="My Favorites" 322 + /> 323 + </div> 324 + 325 + <div className="form-group"> 326 + <label className="form-label">Description</label> 327 + <textarea 328 + value={description} 329 + onChange={(e) => setDescription(e.target.value)} 330 + rows={2} 331 + className="form-textarea" 332 + placeholder="A collection of..." 333 + /> 334 + </div> 335 + 336 + <div className="modal-actions"> 337 + <button type="button" onClick={onClose} className="btn btn-ghost"> 338 + Cancel 339 + </button> 340 + <button 341 + type="submit" 342 + disabled={loading} 343 + className="btn btn-primary" 344 + style={loading ? { opacity: 0.7, cursor: "wait" } : {}} 345 + > 346 + {loading 347 + ? "Saving..." 348 + : collectionToEdit 349 + ? "Save Changes" 350 + : "Create Collection"} 351 + </button> 352 + </div> 353 + </form> 354 + </div> 355 + </div> 356 + ); 357 + }
+40
web/src/components/CollectionRow.jsx
··· 1 + import { Link } from "react-router-dom"; 2 + import { ChevronRight, Edit2 } from "lucide-react"; 3 + import CollectionIcon from "./CollectionIcon"; 4 + 5 + export default function CollectionRow({ collection, onEdit }) { 6 + return ( 7 + <div className="collection-row"> 8 + <Link 9 + to={`/collection/${encodeURIComponent(collection.uri)}?author=${encodeURIComponent( 10 + collection.authorDid || collection.author?.did, 11 + )}`} 12 + className="collection-row-content" 13 + > 14 + <div className="collection-row-icon"> 15 + <CollectionIcon icon={collection.icon} size={22} /> 16 + </div> 17 + <div className="collection-row-info"> 18 + <h3 className="collection-row-name">{collection.name}</h3> 19 + {collection.description && ( 20 + <p className="collection-row-desc">{collection.description}</p> 21 + )} 22 + </div> 23 + <ChevronRight size={20} className="collection-row-arrow" /> 24 + </Link> 25 + {onEdit && ( 26 + <button 27 + onClick={(e) => { 28 + e.preventDefault(); 29 + e.stopPropagation(); 30 + onEdit(); 31 + }} 32 + className="collection-row-edit" 33 + title="Edit collection" 34 + > 35 + <Edit2 size={16} /> 36 + </button> 37 + )} 38 + </div> 39 + ); 40 + }
+156
web/src/components/Composer.jsx
··· 1 + import { useState } from "react"; 2 + import { createAnnotation } from "../api/client"; 3 + 4 + export default function Composer({ 5 + url, 6 + selector: initialSelector, 7 + onSuccess, 8 + onCancel, 9 + }) { 10 + const [text, setText] = useState(""); 11 + const [quoteText, setQuoteText] = useState(""); 12 + const [selector, setSelector] = useState(initialSelector); 13 + const [loading, setLoading] = useState(false); 14 + const [error, setError] = useState(null); 15 + const [showQuoteInput, setShowQuoteInput] = useState(false); 16 + 17 + const highlightedText = 18 + selector?.type === "TextQuoteSelector" ? selector.exact : null; 19 + 20 + const handleSubmit = async (e) => { 21 + e.preventDefault(); 22 + if (!text.trim()) return; 23 + 24 + try { 25 + setLoading(true); 26 + setError(null); 27 + 28 + let finalSelector = selector; 29 + if (!finalSelector && quoteText.trim()) { 30 + finalSelector = { 31 + type: "TextQuoteSelector", 32 + exact: quoteText.trim(), 33 + }; 34 + } 35 + 36 + await createAnnotation({ 37 + url, 38 + text, 39 + selector: finalSelector || undefined, 40 + }); 41 + 42 + setText(""); 43 + setQuoteText(""); 44 + setSelector(null); 45 + if (onSuccess) onSuccess(); 46 + } catch (err) { 47 + setError(err.message); 48 + } finally { 49 + setLoading(false); 50 + } 51 + }; 52 + 53 + const handleRemoveSelector = () => { 54 + setSelector(null); 55 + setQuoteText(""); 56 + setShowQuoteInput(false); 57 + }; 58 + 59 + return ( 60 + <form onSubmit={handleSubmit} className="composer"> 61 + <div className="composer-header"> 62 + <h3 className="composer-title">New Annotation</h3> 63 + {url && <div className="composer-url">{url}</div>} 64 + </div> 65 + 66 + {} 67 + {highlightedText && ( 68 + <div className="composer-quote"> 69 + <button 70 + type="button" 71 + className="composer-quote-remove" 72 + onClick={handleRemoveSelector} 73 + title="Remove selection" 74 + > 75 + × 76 + </button> 77 + <blockquote> 78 + <mark className="quote-exact">"{highlightedText}"</mark> 79 + </blockquote> 80 + </div> 81 + )} 82 + 83 + {} 84 + {!highlightedText && ( 85 + <> 86 + {!showQuoteInput ? ( 87 + <button 88 + type="button" 89 + className="composer-add-quote" 90 + onClick={() => setShowQuoteInput(true)} 91 + > 92 + + Add a quote from the page 93 + </button> 94 + ) : ( 95 + <div className="composer-quote-input-wrapper"> 96 + <textarea 97 + value={quoteText} 98 + onChange={(e) => setQuoteText(e.target.value)} 99 + placeholder="Paste or type the text you're annotating..." 100 + className="composer-quote-input" 101 + rows={2} 102 + /> 103 + <button 104 + type="button" 105 + className="composer-quote-remove-btn" 106 + onClick={handleRemoveSelector} 107 + > 108 + Remove 109 + </button> 110 + </div> 111 + )} 112 + </> 113 + )} 114 + 115 + <textarea 116 + value={text} 117 + onChange={(e) => setText(e.target.value)} 118 + placeholder={ 119 + highlightedText || quoteText 120 + ? "Add your comment about this selection..." 121 + : "Write your annotation..." 122 + } 123 + className="composer-input" 124 + rows={4} 125 + maxLength={3000} 126 + required 127 + disabled={loading} 128 + /> 129 + 130 + <div className="composer-footer"> 131 + <span className="composer-count">{text.length}/3000</span> 132 + <div className="composer-actions"> 133 + {onCancel && ( 134 + <button 135 + type="button" 136 + className="btn btn-ghost" 137 + onClick={onCancel} 138 + disabled={loading} 139 + > 140 + Cancel 141 + </button> 142 + )} 143 + <button 144 + type="submit" 145 + className="btn btn-primary" 146 + disabled={loading || !text.trim()} 147 + > 148 + {loading ? "Posting..." : "Post"} 149 + </button> 150 + </div> 151 + </div> 152 + 153 + {error && <div className="composer-error">{error}</div>} 154 + </form> 155 + ); 156 + }
+335
web/src/components/Icons.jsx
··· 1 + export function HeartIcon({ filled = false, size = 18 }) { 2 + return filled ? ( 3 + <svg 4 + width={size} 5 + height={size} 6 + viewBox="0 0 24 24" 7 + fill="currentColor" 8 + stroke="none" 9 + > 10 + <path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" /> 11 + </svg> 12 + ) : ( 13 + <svg 14 + width={size} 15 + height={size} 16 + viewBox="0 0 24 24" 17 + fill="none" 18 + stroke="currentColor" 19 + strokeWidth="2" 20 + strokeLinecap="round" 21 + strokeLinejoin="round" 22 + > 23 + <path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z" /> 24 + </svg> 25 + ); 26 + } 27 + 28 + export function MessageIcon({ size = 18 }) { 29 + return ( 30 + <svg 31 + width={size} 32 + height={size} 33 + viewBox="0 0 24 24" 34 + fill="none" 35 + stroke="currentColor" 36 + strokeWidth="2" 37 + strokeLinecap="round" 38 + strokeLinejoin="round" 39 + > 40 + <path d="m3 21 1.9-5.7a8.5 8.5 0 1 1 3.8 3.8z" /> 41 + </svg> 42 + ); 43 + } 44 + 45 + export function ShareIcon({ size = 18 }) { 46 + return ( 47 + <svg 48 + width={size} 49 + height={size} 50 + viewBox="0 0 24 24" 51 + fill="none" 52 + stroke="currentColor" 53 + strokeWidth="2" 54 + strokeLinecap="round" 55 + strokeLinejoin="round" 56 + > 57 + <path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" /> 58 + <polyline points="16 6 12 2 8 6" /> 59 + <line x1="12" x2="12" y1="2" y2="15" /> 60 + </svg> 61 + ); 62 + } 63 + 64 + export function TrashIcon({ size = 18 }) { 65 + return ( 66 + <svg 67 + width={size} 68 + height={size} 69 + viewBox="0 0 24 24" 70 + fill="none" 71 + stroke="currentColor" 72 + strokeWidth="2" 73 + strokeLinecap="round" 74 + strokeLinejoin="round" 75 + > 76 + <path d="M3 6h18" /> 77 + <path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" /> 78 + <path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" /> 79 + </svg> 80 + ); 81 + } 82 + 83 + export function LinkIcon({ size = 18 }) { 84 + return ( 85 + <svg 86 + width={size} 87 + height={size} 88 + viewBox="0 0 24 24" 89 + fill="none" 90 + stroke="currentColor" 91 + strokeWidth="2" 92 + strokeLinecap="round" 93 + strokeLinejoin="round" 94 + > 95 + <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" /> 96 + <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" /> 97 + </svg> 98 + ); 99 + } 100 + 101 + export function ExternalLinkIcon({ size = 14 }) { 102 + return ( 103 + <svg 104 + width={size} 105 + height={size} 106 + viewBox="0 0 24 24" 107 + fill="none" 108 + stroke="currentColor" 109 + strokeWidth="2" 110 + strokeLinecap="round" 111 + strokeLinejoin="round" 112 + > 113 + <path d="M15 3h6v6" /> 114 + <path d="M10 14 21 3" /> 115 + <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /> 116 + </svg> 117 + ); 118 + } 119 + 120 + export function PenIcon({ size = 18 }) { 121 + return ( 122 + <svg 123 + width={size} 124 + height={size} 125 + viewBox="0 0 24 24" 126 + fill="none" 127 + stroke="currentColor" 128 + strokeWidth="2" 129 + strokeLinecap="round" 130 + strokeLinejoin="round" 131 + > 132 + <path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" /> 133 + </svg> 134 + ); 135 + } 136 + 137 + export function HighlightIcon({ size = 18 }) { 138 + return ( 139 + <svg 140 + width={size} 141 + height={size} 142 + viewBox="0 0 24 24" 143 + fill="none" 144 + stroke="currentColor" 145 + strokeWidth="2" 146 + strokeLinecap="round" 147 + strokeLinejoin="round" 148 + > 149 + <path d="m9 11-6 6v3h9l3-3" /> 150 + <path d="m22 12-4.6 4.6a2 2 0 0 1-2.8 0l-5.2-5.2a2 2 0 0 1 0-2.8L14 4" /> 151 + </svg> 152 + ); 153 + } 154 + 155 + export function BookmarkIcon({ size = 18 }) { 156 + return ( 157 + <svg 158 + width={size} 159 + height={size} 160 + viewBox="0 0 24 24" 161 + fill="none" 162 + stroke="currentColor" 163 + strokeWidth="2" 164 + strokeLinecap="round" 165 + strokeLinejoin="round" 166 + > 167 + <path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z" /> 168 + </svg> 169 + ); 170 + } 171 + 172 + export function TagIcon({ size = 18 }) { 173 + return ( 174 + <svg 175 + width={size} 176 + height={size} 177 + viewBox="0 0 24 24" 178 + fill="none" 179 + stroke="currentColor" 180 + strokeWidth="2" 181 + strokeLinecap="round" 182 + strokeLinejoin="round" 183 + > 184 + <path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z" /> 185 + <circle cx="7.5" cy="7.5" r=".5" fill="currentColor" /> 186 + </svg> 187 + ); 188 + } 189 + 190 + export function AlertIcon({ size = 18 }) { 191 + return ( 192 + <svg 193 + width={size} 194 + height={size} 195 + viewBox="0 0 24 24" 196 + fill="none" 197 + stroke="currentColor" 198 + strokeWidth="2" 199 + strokeLinecap="round" 200 + strokeLinejoin="round" 201 + > 202 + <circle cx="12" cy="12" r="10" /> 203 + <line x1="12" x2="12" y1="8" y2="12" /> 204 + <line x1="12" x2="12.01" y1="16" y2="16" /> 205 + </svg> 206 + ); 207 + } 208 + 209 + export function FileTextIcon({ size = 18 }) { 210 + return ( 211 + <svg 212 + width={size} 213 + height={size} 214 + viewBox="0 0 24 24" 215 + fill="none" 216 + stroke="currentColor" 217 + strokeWidth="2" 218 + strokeLinecap="round" 219 + strokeLinejoin="round" 220 + > 221 + <path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /> 222 + <path d="M14 2v4a2 2 0 0 0 2 2h4" /> 223 + <path d="M10 9H8" /> 224 + <path d="M16 13H8" /> 225 + <path d="M16 17H8" /> 226 + </svg> 227 + ); 228 + } 229 + 230 + export function SearchIcon({ size = 18 }) { 231 + return ( 232 + <svg 233 + width={size} 234 + height={size} 235 + viewBox="0 0 24 24" 236 + fill="none" 237 + stroke="currentColor" 238 + strokeWidth="2" 239 + strokeLinecap="round" 240 + strokeLinejoin="round" 241 + > 242 + <circle cx="11" cy="11" r="8" /> 243 + <path d="m21 21-4.3-4.3" /> 244 + </svg> 245 + ); 246 + } 247 + 248 + export function InboxIcon({ size = 18 }) { 249 + return ( 250 + <svg 251 + width={size} 252 + height={size} 253 + viewBox="0 0 24 24" 254 + fill="none" 255 + stroke="currentColor" 256 + strokeWidth="2" 257 + strokeLinecap="round" 258 + strokeLinejoin="round" 259 + > 260 + <polyline points="22 12 16 12 14 15 10 15 8 12 2 12" /> 261 + <path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" /> 262 + </svg> 263 + ); 264 + } 265 + 266 + export function BlueskyIcon({ size = 18, color = "currentColor" }) { 267 + return ( 268 + <svg 269 + xmlns="http://www.w3.org/2000/svg" 270 + viewBox="0 0 512 512" 271 + width={size} 272 + height={size} 273 + > 274 + <path 275 + fill={color} 276 + d="M111.8 62.2C170.2 105.9 233 194.7 256 242.4c23-47.6 85.8-136.4 144.2-180.2c42.1-31.6 110.3-56 110.3 21.8c0 15.5-8.9 130.5-14.1 149.2C478.2 298 412 314.6 353.1 304.5c102.9 17.5 129.1 75.5 72.5 133.5c-107.4 110.2-154.3-27.6-166.3-62.9l0 0c-1.7-4.9-2.6-7.8-3.3-7.8s-1.6 3-3.3 7.8l0 0c-12 35.3-59 173.1-166.3 62.9c-56.5-58-30.4-116 72.5-133.5C100 314.6 33.8 298 15.7 233.1C10.4 214.4 1.5 99.4 1.5 83.9c0-77.8 68.2-53.4 110.3-21.8z" 277 + /> 278 + </svg> 279 + ); 280 + } 281 + 282 + export function LogoutIcon({ size = 18 }) { 283 + return ( 284 + <svg 285 + width={size} 286 + height={size} 287 + viewBox="0 0 24 24" 288 + fill="none" 289 + stroke="currentColor" 290 + strokeWidth="2" 291 + strokeLinecap="round" 292 + strokeLinejoin="round" 293 + > 294 + <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /> 295 + <polyline points="16 17 21 12 16 7" /> 296 + <line x1="21" x2="9" y1="12" y2="12" /> 297 + </svg> 298 + ); 299 + } 300 + 301 + export function BellIcon({ size = 18 }) { 302 + return ( 303 + <svg 304 + width={size} 305 + height={size} 306 + viewBox="0 0 24 24" 307 + fill="none" 308 + stroke="currentColor" 309 + strokeWidth="2" 310 + strokeLinecap="round" 311 + strokeLinejoin="round" 312 + > 313 + <path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" /> 314 + <path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" /> 315 + </svg> 316 + ); 317 + } 318 + 319 + export function ReplyIcon({ size = 18 }) { 320 + return ( 321 + <svg 322 + width={size} 323 + height={size} 324 + viewBox="0 0 24 24" 325 + fill="none" 326 + stroke="currentColor" 327 + strokeWidth="2" 328 + strokeLinecap="round" 329 + strokeLinejoin="round" 330 + > 331 + <polyline points="9 17 4 12 9 7" /> 332 + <path d="M20 18v-2a4 4 0 0 0-4-4H4" /> 333 + </svg> 334 + ); 335 + }
+245
web/src/components/Navbar.jsx
··· 1 + import { useState, useRef, useEffect } from "react"; 2 + import { Link, useLocation } from "react-router-dom"; 3 + import { Folder } from "lucide-react"; 4 + import { useAuth } from "../context/AuthContext"; 5 + import { 6 + PenIcon, 7 + BookmarkIcon, 8 + HighlightIcon, 9 + SearchIcon, 10 + LogoutIcon, 11 + BellIcon, 12 + } from "./Icons"; 13 + import { getUnreadNotificationCount } from "../api/client"; 14 + import { SiFirefox, SiGooglechrome } from "react-icons/si"; 15 + import { FaEdge } from "react-icons/fa"; 16 + 17 + import logo from "../assets/logo.svg"; 18 + 19 + const isFirefox = 20 + typeof navigator !== "undefined" && /Firefox/i.test(navigator.userAgent); 21 + const isEdge = 22 + typeof navigator !== "undefined" && /Edg/i.test(navigator.userAgent); 23 + const isChrome = 24 + typeof navigator !== "undefined" && 25 + /Chrome/i.test(navigator.userAgent) && 26 + !isEdge; 27 + 28 + export default function Navbar() { 29 + const { user, isAuthenticated, logout, loading } = useAuth(); 30 + const location = useLocation(); 31 + const [menuOpen, setMenuOpen] = useState(false); 32 + const [unreadCount, setUnreadCount] = useState(0); 33 + const menuRef = useRef(null); 34 + 35 + const isActive = (path) => location.pathname === path; 36 + 37 + useEffect(() => { 38 + if (isAuthenticated) { 39 + getUnreadNotificationCount() 40 + .then((data) => setUnreadCount(data.count || 0)) 41 + .catch(() => {}); 42 + const interval = setInterval(() => { 43 + getUnreadNotificationCount() 44 + .then((data) => setUnreadCount(data.count || 0)) 45 + .catch(() => {}); 46 + }, 60000); 47 + return () => clearInterval(interval); 48 + } 49 + }, [isAuthenticated]); 50 + 51 + useEffect(() => { 52 + const handleClickOutside = (e) => { 53 + if (menuRef.current && !menuRef.current.contains(e.target)) { 54 + setMenuOpen(false); 55 + } 56 + }; 57 + document.addEventListener("mousedown", handleClickOutside); 58 + return () => document.removeEventListener("mousedown", handleClickOutside); 59 + }, []); 60 + 61 + const getInitials = () => { 62 + if (user?.displayName) { 63 + return user.displayName.substring(0, 2).toUpperCase(); 64 + } 65 + if (user?.handle) { 66 + return user.handle.substring(0, 2).toUpperCase(); 67 + } 68 + return "U"; 69 + }; 70 + 71 + return ( 72 + <nav className="navbar"> 73 + <div className="navbar-inner"> 74 + {} 75 + <Link to="/" className="navbar-brand"> 76 + <img src={logo} alt="Margin Logo" className="navbar-logo-img" /> 77 + <span className="navbar-title">Margin</span> 78 + </Link> 79 + 80 + {} 81 + <div className="navbar-center"> 82 + <Link 83 + to="/" 84 + className={`navbar-link ${isActive("/") ? "active" : ""}`} 85 + > 86 + Feed 87 + </Link> 88 + <Link 89 + to="/url" 90 + className={`navbar-link ${isActive("/url") ? "active" : ""}`} 91 + > 92 + <SearchIcon size={16} /> 93 + Browse 94 + </Link> 95 + {isFirefox ? ( 96 + <a 97 + href="https://addons.mozilla.org/en-US/firefox/addon/margin/" 98 + target="_blank" 99 + rel="noopener noreferrer" 100 + className="navbar-link navbar-extension-link" 101 + > 102 + <SiFirefox size={16} /> 103 + Get Extension 104 + </a> 105 + ) : isEdge ? ( 106 + <a 107 + href="https://microsoftedge.microsoft.com/addons/detail/margin/nfjnmllpdgcdnhmmggjihjbidmeadddn" 108 + target="_blank" 109 + rel="noopener noreferrer" 110 + className="navbar-link navbar-extension-link" 111 + > 112 + <FaEdge size={16} /> 113 + Get Extension 114 + </a> 115 + ) : isChrome ? ( 116 + <a 117 + href="https://chromewebstore.google.com/detail/margin/cgpmbiiagnehkikhcbnhiagfomajncpa/" 118 + target="_blank" 119 + rel="noopener noreferrer" 120 + className="navbar-link navbar-extension-link" 121 + > 122 + <SiGooglechrome size={16} /> 123 + Get Extension 124 + </a> 125 + ) : ( 126 + <a 127 + href="https://addons.mozilla.org/en-US/firefox/addon/margin/" 128 + target="_blank" 129 + rel="noopener noreferrer" 130 + className="navbar-link navbar-extension-link" 131 + > 132 + <SiFirefox size={16} /> 133 + Get Extension 134 + </a> 135 + )} 136 + </div> 137 + 138 + {} 139 + <div className="navbar-right"> 140 + {!loading && 141 + (isAuthenticated ? ( 142 + <> 143 + <Link 144 + to="/highlights" 145 + className={`navbar-icon-link ${isActive("/highlights") ? "active" : ""}`} 146 + title="Highlights" 147 + > 148 + <HighlightIcon size={20} /> 149 + </Link> 150 + <Link 151 + to="/bookmarks" 152 + className={`navbar-icon-link ${isActive("/bookmarks") ? "active" : ""}`} 153 + title="Bookmarks" 154 + > 155 + <BookmarkIcon size={20} /> 156 + </Link> 157 + <Link 158 + to="/collections" 159 + className={`navbar-icon-link ${isActive("/collections") ? "active" : ""}`} 160 + title="Collections" 161 + > 162 + <Folder size={20} /> 163 + </Link> 164 + <Link 165 + to="/notifications" 166 + className={`navbar-icon-link notification-link ${isActive("/notifications") ? "active" : ""}`} 167 + title="Notifications" 168 + onClick={() => setUnreadCount(0)} 169 + > 170 + <BellIcon size={20} /> 171 + {unreadCount > 0 && ( 172 + <span className="notification-badge">{unreadCount}</span> 173 + )} 174 + </Link> 175 + <Link 176 + to="/new" 177 + className="navbar-new-btn" 178 + title="New Annotation" 179 + > 180 + <PenIcon size={16} /> 181 + <span>New</span> 182 + </Link> 183 + 184 + {} 185 + <div className="navbar-user-menu" ref={menuRef}> 186 + <button 187 + className="navbar-avatar-btn" 188 + onClick={() => setMenuOpen(!menuOpen)} 189 + title={user?.handle} 190 + > 191 + {user?.avatar ? ( 192 + <img 193 + src={user.avatar} 194 + alt={user.displayName} 195 + className="navbar-avatar-img" 196 + /> 197 + ) : ( 198 + <span className="navbar-avatar-text"> 199 + {getInitials()} 200 + </span> 201 + )} 202 + </button> 203 + 204 + {menuOpen && ( 205 + <div className="navbar-dropdown"> 206 + <div className="navbar-dropdown-header"> 207 + <span className="navbar-dropdown-name"> 208 + {user?.displayName} 209 + </span> 210 + <span className="navbar-dropdown-handle"> 211 + @{user?.handle} 212 + </span> 213 + </div> 214 + <div className="navbar-dropdown-divider" /> 215 + <Link 216 + to={`/profile/${user?.did}`} 217 + className="navbar-dropdown-item" 218 + onClick={() => setMenuOpen(false)} 219 + > 220 + View Profile 221 + </Link> 222 + <button 223 + onClick={() => { 224 + logout(); 225 + setMenuOpen(false); 226 + }} 227 + className="navbar-dropdown-item navbar-dropdown-logout" 228 + > 229 + <LogoutIcon size={16} /> 230 + Sign Out 231 + </button> 232 + </div> 233 + )} 234 + </div> 235 + </> 236 + ) : ( 237 + <Link to="/login" className="navbar-signin"> 238 + Sign In 239 + </Link> 240 + ))} 241 + </div> 242 + </div> 243 + </nav> 244 + ); 245 + }
+338
web/src/components/ReplyList.jsx
··· 1 + import React from "react"; 2 + import { Link } from "react-router-dom"; 3 + import { MessageSquare, Trash2, Reply } from "lucide-react"; 4 + 5 + function formatDate(dateString) { 6 + if (!dateString) return ""; 7 + const date = new Date(dateString); 8 + const now = new Date(); 9 + const diff = now - date; 10 + const minutes = Math.floor(diff / 60000); 11 + const hours = Math.floor(diff / 3600000); 12 + const days = Math.floor(diff / 86400000); 13 + if (minutes < 1) return "just now"; 14 + if (minutes < 60) return `${minutes}m`; 15 + if (hours < 24) return `${hours}h`; 16 + if (days < 7) return `${days}d`; 17 + return date.toLocaleDateString(); 18 + } 19 + 20 + function ReplyItem({ reply, depth = 0, user, onReply, onDelete, isInline }) { 21 + const author = reply.creator || reply.author || {}; 22 + const isReplyOwner = user?.did && author.did === user.did; 23 + 24 + const containerStyle = isInline 25 + ? { 26 + display: "flex", 27 + gap: "10px", 28 + padding: depth > 0 ? "10px 12px 10px 16px" : "12px 16px", 29 + marginLeft: depth * 20, 30 + borderLeft: depth > 0 ? "2px solid var(--accent-subtle)" : "none", 31 + background: depth > 0 ? "rgba(168, 85, 247, 0.03)" : "transparent", 32 + } 33 + : { 34 + marginLeft: depth * 24, 35 + borderLeft: depth > 0 ? "2px solid var(--accent-subtle)" : "none", 36 + paddingLeft: depth > 0 ? "16px" : "0", 37 + background: depth > 0 ? "rgba(168, 85, 247, 0.02)" : "transparent", 38 + marginBottom: "12px", 39 + }; 40 + 41 + const avatarSize = isInline ? (depth > 0 ? 28 : 32) : depth > 0 ? 28 : 36; 42 + 43 + return ( 44 + <div key={reply.id || reply.uri}> 45 + <div 46 + className={isInline ? "inline-reply" : "reply-card-threaded"} 47 + style={containerStyle} 48 + > 49 + {isInline ? ( 50 + <> 51 + <Link 52 + to={`/profile/${author.handle}`} 53 + className="inline-reply-avatar" 54 + style={{ 55 + width: avatarSize, 56 + height: avatarSize, 57 + minWidth: avatarSize, 58 + }} 59 + > 60 + {author.avatar ? ( 61 + <img 62 + src={author.avatar} 63 + alt="" 64 + style={{ 65 + width: "100%", 66 + height: "100%", 67 + borderRadius: "50%", 68 + objectFit: "cover", 69 + }} 70 + /> 71 + ) : ( 72 + <span 73 + style={{ 74 + width: "100%", 75 + height: "100%", 76 + borderRadius: "50%", 77 + background: 78 + "linear-gradient(135deg, var(--accent), #a855f7)", 79 + display: "flex", 80 + alignItems: "center", 81 + justifyContent: "center", 82 + fontSize: depth > 0 ? "0.65rem" : "0.75rem", 83 + fontWeight: 600, 84 + color: "white", 85 + }} 86 + > 87 + {(author.displayName || 88 + author.handle || 89 + "?")[0].toUpperCase()} 90 + </span> 91 + )} 92 + </Link> 93 + <div style={{ flex: 1, minWidth: 0 }}> 94 + <div 95 + style={{ 96 + display: "flex", 97 + alignItems: "center", 98 + gap: "6px", 99 + flexWrap: "wrap", 100 + marginBottom: "4px", 101 + }} 102 + > 103 + <span 104 + style={{ 105 + fontWeight: 600, 106 + fontSize: depth > 0 ? "0.8rem" : "0.85rem", 107 + color: "var(--text-primary)", 108 + }} 109 + > 110 + {author.displayName || author.handle} 111 + </span> 112 + <Link 113 + to={`/profile/${author.handle}`} 114 + style={{ 115 + color: "var(--text-tertiary)", 116 + fontSize: depth > 0 ? "0.75rem" : "0.8rem", 117 + textDecoration: "none", 118 + }} 119 + > 120 + @{author.handle} 121 + </Link> 122 + <span 123 + style={{ color: "var(--text-tertiary)", fontSize: "0.7rem" }} 124 + > 125 + · 126 + </span> 127 + <span 128 + style={{ color: "var(--text-tertiary)", fontSize: "0.7rem" }} 129 + > 130 + {formatDate(reply.created || reply.createdAt)} 131 + </span> 132 + 133 + <div 134 + style={{ marginLeft: "auto", display: "flex", gap: "4px" }} 135 + > 136 + <button 137 + onClick={() => onReply(reply)} 138 + style={{ 139 + background: "none", 140 + border: "none", 141 + color: "var(--text-tertiary)", 142 + cursor: "pointer", 143 + padding: "2px 6px", 144 + fontSize: "0.7rem", 145 + display: "flex", 146 + alignItems: "center", 147 + gap: "3px", 148 + borderRadius: "4px", 149 + }} 150 + > 151 + <MessageSquare size={11} /> 152 + </button> 153 + {isReplyOwner && ( 154 + <button 155 + onClick={() => onDelete(reply)} 156 + style={{ 157 + background: "none", 158 + border: "none", 159 + color: "var(--text-tertiary)", 160 + cursor: "pointer", 161 + padding: "2px 6px", 162 + fontSize: "0.7rem", 163 + display: "flex", 164 + alignItems: "center", 165 + gap: "3px", 166 + borderRadius: "4px", 167 + }} 168 + > 169 + <Trash2 size={11} /> 170 + </button> 171 + )} 172 + </div> 173 + </div> 174 + <p 175 + style={{ 176 + margin: 0, 177 + fontSize: depth > 0 ? "0.85rem" : "0.9rem", 178 + lineHeight: 1.5, 179 + color: "var(--text-primary)", 180 + }} 181 + > 182 + {reply.text || reply.body?.value} 183 + </p> 184 + </div> 185 + </> 186 + ) : ( 187 + <> 188 + <div className="reply-header"> 189 + <Link 190 + to={`/profile/${author.handle}`} 191 + className="reply-avatar-link" 192 + > 193 + <div 194 + className="reply-avatar" 195 + style={{ width: avatarSize, height: avatarSize }} 196 + > 197 + {author.avatar ? ( 198 + <img 199 + src={author.avatar} 200 + alt={author.displayName || author.handle} 201 + /> 202 + ) : ( 203 + <span> 204 + {(author.displayName || 205 + author.handle || 206 + "?")[0].toUpperCase()} 207 + </span> 208 + )} 209 + </div> 210 + </Link> 211 + <div className="reply-meta"> 212 + <span className="reply-author"> 213 + {author.displayName || author.handle} 214 + </span> 215 + {author.handle && ( 216 + <Link 217 + to={`/profile/${author.handle}`} 218 + className="reply-handle" 219 + > 220 + @{author.handle} 221 + </Link> 222 + )} 223 + <span className="reply-dot">·</span> 224 + <span className="reply-time"> 225 + {formatDate(reply.created || reply.createdAt)} 226 + </span> 227 + </div> 228 + <div className="reply-actions"> 229 + <button 230 + className="reply-action-btn" 231 + onClick={() => onReply(reply)} 232 + title="Reply" 233 + > 234 + <Reply size={14} /> 235 + </button> 236 + {isReplyOwner && ( 237 + <button 238 + className="reply-action-btn reply-action-delete" 239 + onClick={() => onDelete(reply)} 240 + title="Delete" 241 + > 242 + <Trash2 size={14} /> 243 + </button> 244 + )} 245 + </div> 246 + </div> 247 + <p className="reply-text">{reply.text || reply.body?.value}</p> 248 + </> 249 + )} 250 + </div> 251 + {reply.children && 252 + reply.children.map((child) => ( 253 + <ReplyItem 254 + key={child.id || child.uri} 255 + reply={child} 256 + depth={depth + 1} 257 + user={user} 258 + onReply={onReply} 259 + onDelete={onDelete} 260 + isInline={isInline} 261 + /> 262 + ))} 263 + </div> 264 + ); 265 + } 266 + 267 + export default function ReplyList({ 268 + replies, 269 + rootUri, 270 + user, 271 + onReply, 272 + onDelete, 273 + isInline = false, 274 + }) { 275 + if (!replies || replies.length === 0) { 276 + if (isInline) { 277 + return ( 278 + <div 279 + style={{ 280 + padding: "16px", 281 + textAlign: "center", 282 + fontSize: "0.9rem", 283 + color: "var(--text-secondary)", 284 + }} 285 + > 286 + No replies yet 287 + </div> 288 + ); 289 + } 290 + return ( 291 + <div className="empty-state" style={{ padding: "32px" }}> 292 + <p className="empty-state-text"> 293 + No replies yet. Be the first to reply! 294 + </p> 295 + </div> 296 + ); 297 + } 298 + 299 + const buildReplyTree = () => { 300 + const replyMap = {}; 301 + const rootReplies = []; 302 + 303 + replies.forEach((r) => { 304 + replyMap[r.id || r.uri] = { ...r, children: [] }; 305 + }); 306 + 307 + replies.forEach((r) => { 308 + const parentUri = r.inReplyTo || r.parentUri; 309 + if (parentUri === rootUri) { 310 + rootReplies.push(replyMap[r.id || r.uri]); 311 + } else if (replyMap[parentUri]) { 312 + replyMap[parentUri].children.push(replyMap[r.id || r.uri]); 313 + } else { 314 + rootReplies.push(replyMap[r.id || r.uri]); 315 + } 316 + }); 317 + 318 + return rootReplies; 319 + }; 320 + 321 + const replyTree = buildReplyTree(); 322 + 323 + return ( 324 + <div className={isInline ? "replies-list" : "replies-list-threaded"}> 325 + {replyTree.map((reply) => ( 326 + <ReplyItem 327 + key={reply.id || reply.uri} 328 + reply={reply} 329 + depth={0} 330 + user={user} 331 + onReply={onReply} 332 + onDelete={onDelete} 333 + isInline={isInline} 334 + /> 335 + ))} 336 + </div> 337 + ); 338 + }
+220
web/src/components/ShareMenu.jsx
··· 1 + import { useState, useRef, useEffect } from "react"; 2 + import { Copy, ExternalLink, Check } from "lucide-react"; 3 + import { BlueskyIcon } from "./Icons"; 4 + 5 + const BLUESKY_COLOR = "#1185fe"; 6 + 7 + const WitchskyIcon = () => ( 8 + <svg fill="none" viewBox="0 0 512 512" width="18" height="18"> 9 + <path 10 + fill="#ee5346" 11 + d="M374.473 57.7173C367.666 50.7995 357.119 49.1209 348.441 53.1659C347.173 53.7567 342.223 56.0864 334.796 59.8613C326.32 64.1696 314.568 70.3869 301.394 78.0596C275.444 93.1728 242.399 114.83 218.408 139.477C185.983 172.786 158.719 225.503 140.029 267.661C130.506 289.144 122.878 308.661 117.629 322.81C116.301 326.389 115.124 329.63 114.104 332.478C87.1783 336.42 64.534 341.641 47.5078 348.101C37.6493 351.84 28.3222 356.491 21.0573 362.538C13.8818 368.511 6.00003 378.262 6.00003 391.822C6.00014 403.222 11.8738 411.777 17.4566 417.235C23.0009 422.655 29.9593 426.793 36.871 430.062C50.8097 436.653 69.5275 441.988 90.8362 446.249C133.828 454.846 192.21 460 256.001 460C319.79 460 378.172 454.846 421.164 446.249C442.472 441.988 461.19 436.653 475.129 430.062C482.041 426.793 488.999 422.655 494.543 417.235C500.039 411.862 505.817 403.489 505.996 392.353L506 391.822L505.995 391.188C505.754 377.959 498.012 368.417 490.945 362.534C483.679 356.485 474.35 351.835 464.491 348.095C446.749 341.366 422.906 335.982 394.476 331.987C393.6 330.57 392.633 328.995 391.595 327.273C386.477 318.777 379.633 306.842 372.737 293.115C358.503 264.781 345.757 232.098 344.756 206.636C343.87 184.121 351.638 154.087 360.819 127.789C365.27 115.041 369.795 103.877 373.207 95.9072C374.909 91.9309 376.325 88.7712 377.302 86.6328C377.79 85.5645 378.167 84.7524 378.416 84.2224C378.54 83.9579 378.632 83.7635 378.69 83.643C378.718 83.5829 378.739 83.5411 378.75 83.5181C378.753 83.5108 378.756 83.5049 378.757 83.5015C382.909 74.8634 381.196 64.5488 374.473 57.7173Z" 12 + /> 13 + </svg> 14 + ); 15 + 16 + const BlackskyIcon = () => ( 17 + <svg viewBox="0 0 285 285" width="18" height="18"> 18 + <path 19 + fill="#f9faf9" 20 + d="M148.846 144.562C148.846 159.75 161.158 172.062 176.346 172.062H207.012V185.865H176.346C161.158 185.865 148.846 198.177 148.846 213.365V243.045H136.029V213.365C136.029 198.177 123.717 185.865 108.529 185.865H77.8633V172.062H108.529C123.717 172.062 136.029 159.75 136.029 144.562V113.896H148.846V144.562Z" 21 + /> 22 + <path 23 + fill="#f9faf9" 24 + d="M170.946 31.8766C160.207 42.616 160.207 60.0281 170.946 70.7675L192.631 92.4516L182.871 102.212L161.186 80.5275C150.447 69.7881 133.035 69.7881 122.296 80.5275L101.309 101.514L92.2456 92.4509L113.232 71.4642C123.972 60.7248 123.972 43.3128 113.232 32.5733L91.5488 10.8899L101.309 1.12988L122.993 22.814C133.732 33.5533 151.144 33.5534 161.884 22.814L183.568 1.12988L192.631 10.1925L170.946 31.8766Z" 25 + /> 26 + <path 27 + fill="#f9faf9" 28 + d="M79.0525 75.3259C75.1216 89.9962 83.8276 105.076 98.498 109.006L128.119 116.943L124.547 130.275L94.9267 122.338C80.2564 118.407 65.1772 127.113 61.2463 141.784L53.5643 170.453L41.1837 167.136L48.8654 138.467C52.7963 123.797 44.0902 108.718 29.4199 104.787L-0.201172 96.8497L3.37124 83.5173L32.9923 91.4542C47.6626 95.3851 62.7419 86.679 66.6728 72.0088L74.6098 42.3877L86.9895 45.7048L79.0525 75.3259Z" 29 + /> 30 + <path 31 + fill="#f9faf9" 32 + d="M218.413 71.4229C222.344 86.093 237.423 94.7992 252.094 90.8683L281.715 82.9313L285.287 96.2628L255.666 104.2C240.995 108.131 232.29 123.21 236.22 137.88L243.902 166.55L231.522 169.867L223.841 141.198C219.91 126.528 204.831 117.822 190.16 121.753L160.539 129.69L156.967 116.357L186.588 108.42C201.258 104.49 209.964 89.4103 206.033 74.74L198.096 45.1189L210.476 41.8018L218.413 71.4229Z" 33 + /> 34 + </svg> 35 + ); 36 + 37 + const CatskyIcon = () => ( 38 + <svg fill="none" viewBox="0 0 67.733328 67.733329" width="18" height="18"> 39 + <path 40 + fill="#cba7f7" 41 + d="m 7.4595521,49.230487 -1.826355,1.186314 -0.00581,0.0064 c -0.6050542,0.41651 -1.129182,0.831427 -1.5159445,1.197382 -0.193382,0.182977 -0.3509469,0.347606 -0.4862911,0.535791 -0.067671,0.0941 -0.1322972,0.188188 -0.1933507,0.352343 -0.061048,0.164157 -0.1411268,0.500074 0.025624,0.844456 l 0.099589,0.200339 c 0.1666616,0.344173 0.4472046,0.428734 0.5969419,0.447854 0.1497358,0.01912 0.2507411,0.0024 0.352923,-0.02039 0.204367,-0.04555 0.4017284,-0.126033 0.6313049,-0.234117 0.4549828,-0.214229 1.0166476,-0.545006 1.6155328,-0.956275 l 0.014617,-0.01049 2.0855152,-1.357536 C 8.3399261,50.711052 7.8735929,49.979321 7.4596148,49.230532 Z" 42 + /> 43 + <path 44 + fill="#cba7f7" 45 + d="m 60.225246,49.199041 c -0.421632,0.744138 -0.895843,1.47112 -1.418104,2.178115 l 2.170542,1.413443 c 0.598885,0.411268 1.160549,0.742047 1.615532,0.956276 0.229578,0.108104 0.426937,0.188564 0.631304,0.234116 0.102186,0.02278 0.2061,0.03951 0.355838,0.02039 0.148897,-0.01901 0.427619,-0.104957 0.594612,-0.444358 l 0.0029,-0.0035 0.09667,-0.20034 h 0.0029 c 0.166756,-0.34438 0.08667,-0.680303 0.02562,-0.844455 -0.06104,-0.164158 -0.125675,-0.258251 -0.193352,-0.352343 -0.135356,-0.188186 -0.293491,-0.352814 -0.486873,-0.535792 -0.386891,-0.366 -0.911016,-0.780916 -1.516073,-1.197426 l -0.0082,-0.007 z" 46 + /> 47 + <path 48 + fill="#cba7f7" 49 + d="m 62.374822,42.996075 c -0.123437,0.919418 -0.330922,1.827482 -0.614997,2.71973 h 2.864745 c 0.698786,0 1.328766,-0.04848 1.817036,-0.1351 0.244137,-0.04331 0.449793,-0.09051 0.645864,-0.172979 0.09803,-0.04122 0.194035,-0.08458 0.315651,-0.190439 0.121618,-0.105868 0.330211,-0.348705 0.330211,-0.746032 v -0.233536 c 0,-0.397326 -0.208544,-0.637282 -0.330211,-0.743122 -0.121662,-0.105838 -0.217613,-0.152159 -0.315651,-0.193351 -0.196079,-0.08238 -0.401748,-0.129732 -0.645864,-0.17296 -0.488229,-0.08645 -1.118333,-0.132208 -1.817036,-0.132208 z" 50 + /> 51 + <path 52 + fill="#cba7f7" 53 + d="m 3.1074004,42.996075 c -0.6987018,0 -1.3264778,0.04576 -1.8147079,0.132208 -0.2441143,0.04324 -0.44978339,0.09059 -0.64586203,0.17296 -0.0980369,0.04118 -0.19398758,0.08751 -0.31565316,0.193351 C 0.20951466,43.600432 0.0015501,43.84039 0.0015501,44.237717 v 0.233535 c 0,0.397326 0.20800926,0.640175 0.32962721,0.746034 0.12161784,0.105867 0.21761904,0.149206 0.31565316,0.190437 0.19606972,0.08246 0.40172683,0.129657 0.64586203,0.172979 0.4882704,0.08663 1.1159226,0.1351 1.8147079,0.1351 H 5.9517617 C 5.6756425,44.822849 5.4740706,43.914705 5.3542351,42.996072 Z" 54 + /> 55 + <path 56 + fill="#cba7f7" 57 + d="m 64.667084,33.5073 c -0.430203,0 -0.690808,0.160181 -1.103618,0.372726 -0.41281,0.212535 -0.895004,0.507161 -1.40529,0.858434 l -0.84038,0.578305 c 0.360074,0.820951 0.644317,1.675211 0.844456,2.560741 l 1.136813,-0.78214 c 0.605058,-0.41651 1.12918,-0.834919 1.515944,-1.200875 0.193382,-0.182976 0.350947,-0.347609 0.486291,-0.535795 0.06767,-0.0941 0.132313,-0.188185 0.193351,-0.352341 0.06104,-0.164157 0.141126,-0.497171 -0.02562,-0.841544 L 65.369444,33.96156 C 65.163418,33.537073 64.829889,33.5073 64.669999,33.5073 Z" 58 + /> 59 + <path 60 + fill="#cba7f7" 61 + d="m 3.0648864,33.5073 c -0.1600423,3.64e-4 -0.4969719,0.0355 -0.7000249,0.45426 l -0.099589,0.203251 c -0.16676,0.344375 -0.089013,0.677388 -0.027951,0.841544 0.061047,0.164157 0.1285982,0.258248 0.1962636,0.352341 0.1353547,0.188186 0.2899962,0.352819 0.4833782,0.535795 0.386764,0.365956 0.9138003,0.784365 1.518856,1.200875 l 1.1478766,0.78971 c 0.2068,-0.879769 0.5000939,-1.727856 0.8706646,-2.542104 v -5.81e-4 L 5.5761273,34.73846 C 5.065553,34.38699 4.5814871,34.09259 4.1685053,33.880026 3.7555236,33.667462 3.4962107,33.506322 3.0648893,33.5073 Z" 62 + /> 63 + <path 64 + fill="#cba7f7" 65 + d="m 34.206496,25.930929 c -7.358038,0 -14.087814,1.669555 -18.851571,4.452678 -4.763758,2.783122 -7.4049994,6.472247 -7.4049994,10.665932 0,4.229683 2.6374854,8.946766 7.2694834,12.60017 4.631996,3.653402 11.153152,6.176813 18.420538,6.176813 7.267388,0 13.908863,-2.52485 18.657979,-6.185354 4.749117,-3.660501 7.485285,-8.390746 7.485285,-12.591629 0,-4.236884 -2.494219,-7.904081 -7.079874,-10.67732 -4.585655,-2.773237 -11.1388,-4.44129 -18.496841,-4.44129 z" 66 + /> 67 + <path 68 + fill="#cba7f7" 69 + d="m 51.797573,6.1189692 c -0.02945,-7.175e-4 -0.05836,4.17e-5 -0.08736,5.831e-4 -0.143066,0.00254 -0.278681,0.00746 -0.419898,0.094338 -0.483586,0.2975835 -0.980437,0.9277726 -1.446058,1.5345809 -1.170891,1.5259255 -2.372514,3.8701448 -4.229269,7.0095668 -0.839492,1.419423 -2.308256,4.55051 -3.891486,8.089307 4.831393,0.745951 9.148869,2.222975 12.643546,4.336427 2.130458,1.288425 3.976812,2.848736 5.416167,4.643344 C 58.614334,27.483611 57.260351,22.206768 56.421696,19.015263 55.149066,14.172268 54.241403,10.340754 53.185389,8.0524745 52.815225,7.2503647 52.540611,6.4969378 52.052073,6.1836069 51.974407,6.1337905 51.885945,6.1211124 51.79757,6.1189646 Z" 70 + /> 71 + <path 72 + fill="#cba7f7" 73 + d="m 15.935563,6.1189692 c -0.08837,0.00223 -0.176832,0.014766 -0.254502,0.064642 -0.48854,0.3133308 -0.763154,1.0667562 -1.13332,1.8688677 -1.056011,2.2882791 -1.963673,6.1197931 -3.236303,10.9627891 -0.85539,3.255187 -2.247014,8.680054 -3.4314032,13.071013 1.5346704,-1.910372 3.5390122,-3.56005 5.8517882,-4.91124 3.456591,-2.019439 7.668347,-3.458497 12.320324,-4.231015 C 24.452511,19.365796 22.96466,16.190327 22.117564,14.758042 20.260808,11.61862 19.059771,9.2744012 17.888878,7.7484762 17.423256,7.1416679 16.926404,6.5114787 16.442819,6.2138951 16.301603,6.127059 16.165987,6.1222115 16.02292,6.1195569 c -0.02901,-5.429e-4 -0.0579,-0.0013 -0.08734,-5.847e-4 z" 74 + /> 75 + </svg> 76 + ); 77 + 78 + const DeerIcon = () => ( 79 + <svg fill="none" viewBox="0 0 512 512" width="18" height="18"> 80 + <path 81 + fill="#739f7c" 82 + d="m 149.96484,186.56641 46.09766,152.95898 c 0,0 -6.30222,-9.61174 -15.60547,-17.47656 -8.87322,-7.50128 -28.4082,-4.04492 -28.4082,-4.04492 0,0 6.14721,39.88867 15.53125,44.39843 10.71251,5.1482 22.19726,0.16993 22.19726,0.16993 0,0 11.7613,-4.87282 22.82032,31.82421 5.26534,17.47196 15.33258,50.877 20.9707,69.58594 2.16717,7.1913 8.83789,7.25781 8.83789,7.25781 0,0 6.67072,-0.0665 8.83789,-7.25781 5.63812,-18.70894 15.70536,-52.11398 20.9707,-69.58594 11.05902,-36.69703 22.82032,-31.82421 22.82032,-31.82421 0,0 11.48475,4.97827 22.19726,-0.16993 9.38404,-4.50976 15.5332,-44.39843 15.5332,-44.39843 0,0 -19.53693,-3.45636 -28.41015,4.04492 -9.30325,7.86482 -15.60547,17.47656 -15.60547,17.47656 l 46.09766,-152.95898 -49.32618,83.84179 -20.34375,-31.1914 6.35547,54.96875 -23.1582,39.36132 c 0,0 -2.97595,5.06226 -5.94336,4.68946 -0.009,-0.001 -0.0169,0.003 -0.0254,0.01 -0.008,-0.007 -0.0167,-0.0109 -0.0254,-0.01 -2.96741,0.3728 -5.94336,-4.68946 -5.94336,-4.68946 l -23.1582,-39.36132 6.35547,-54.96875 -20.34375,31.1914 z" 83 + transform="matrix(2.6921023,0,0,1.7145911,-396.58283,-308.01527)" 84 + /> 85 + </svg> 86 + ); 87 + 88 + const BLUESKY_FORKS = [ 89 + { 90 + name: "Bluesky", 91 + domain: "bsky.app", 92 + Icon: () => <BlueskyIcon size={18} color={BLUESKY_COLOR} />, 93 + }, 94 + { name: "Witchsky", domain: "witchsky.app", Icon: WitchskyIcon }, 95 + { name: "Blacksky", domain: "blacksky.community", Icon: BlackskyIcon }, 96 + { name: "Catsky", domain: "catsky.social", Icon: CatskyIcon }, 97 + { name: "Deer", domain: "deer.social", Icon: DeerIcon }, 98 + ]; 99 + 100 + export default function ShareMenu({ uri, text, customUrl }) { 101 + const [isOpen, setIsOpen] = useState(false); 102 + const [copied, setCopied] = useState(false); 103 + const menuRef = useRef(null); 104 + 105 + const getShareUrl = () => { 106 + if (customUrl) return customUrl; 107 + if (!uri) return ""; 108 + const uriParts = uri.split("/"); 109 + const did = uriParts[2]; 110 + const rkey = uriParts[uriParts.length - 1]; 111 + return `${window.location.origin}/at/${did}/${rkey}`; 112 + }; 113 + 114 + const shareUrl = getShareUrl(); 115 + 116 + useEffect(() => { 117 + const handleClickOutside = (e) => { 118 + if (menuRef.current && !menuRef.current.contains(e.target)) { 119 + setIsOpen(false); 120 + } 121 + }; 122 + if (isOpen) { 123 + document.addEventListener("mousedown", handleClickOutside); 124 + } 125 + return () => document.removeEventListener("mousedown", handleClickOutside); 126 + }, [isOpen]); 127 + 128 + const handleShareToFork = (domain) => { 129 + const composeText = text 130 + ? `${text.substring(0, 200)}...\n\n${shareUrl}` 131 + : shareUrl; 132 + const composeUrl = `https://${domain}/intent/compose?text=${encodeURIComponent(composeText)}`; 133 + window.open(composeUrl, "_blank"); 134 + setIsOpen(false); 135 + }; 136 + 137 + const handleCopy = async () => { 138 + try { 139 + await navigator.clipboard.writeText(shareUrl); 140 + setCopied(true); 141 + setTimeout(() => { 142 + setCopied(false); 143 + setIsOpen(false); 144 + }, 1500); 145 + } catch { 146 + prompt("Copy this link:", shareUrl); 147 + } 148 + }; 149 + 150 + const handleSystemShare = async () => { 151 + if (navigator.share) { 152 + try { 153 + await navigator.share({ 154 + title: "Margin Annotation", 155 + text: text?.substring(0, 100), 156 + url: shareUrl, 157 + }); 158 + } catch {} 159 + } 160 + setIsOpen(false); 161 + }; 162 + 163 + return ( 164 + <div className="share-menu-container" ref={menuRef}> 165 + <button 166 + className="annotation-action" 167 + onClick={() => setIsOpen(!isOpen)} 168 + title="Share" 169 + > 170 + <svg 171 + width="18" 172 + height="18" 173 + viewBox="0 0 24 24" 174 + fill="none" 175 + stroke="currentColor" 176 + strokeWidth="2" 177 + strokeLinecap="round" 178 + strokeLinejoin="round" 179 + > 180 + <circle cx="18" cy="5" r="3" /> 181 + <circle cx="6" cy="12" r="3" /> 182 + <circle cx="18" cy="19" r="3" /> 183 + <line x1="8.59" y1="13.51" x2="15.42" y2="17.49" /> 184 + <line x1="15.41" y1="6.51" x2="8.59" y2="10.49" /> 185 + </svg> 186 + </button> 187 + 188 + {isOpen && ( 189 + <div className="share-menu"> 190 + <div className="share-menu-section"> 191 + <div className="share-menu-label">Share to</div> 192 + {BLUESKY_FORKS.map((fork) => ( 193 + <button 194 + key={fork.domain} 195 + className="share-menu-item" 196 + onClick={() => handleShareToFork(fork.domain)} 197 + > 198 + <span className="share-menu-icon"> 199 + <fork.Icon /> 200 + </span> 201 + <span>{fork.name}</span> 202 + </button> 203 + ))} 204 + </div> 205 + <div className="share-menu-divider" /> 206 + <button className="share-menu-item" onClick={handleCopy}> 207 + {copied ? <Check size={16} /> : <Copy size={16} />} 208 + <span>{copied ? "Copied!" : "Copy Link"}</span> 209 + </button> 210 + {navigator.share && ( 211 + <button className="share-menu-item" onClick={handleSystemShare}> 212 + <ExternalLink size={16} /> 213 + <span>More...</span> 214 + </button> 215 + )} 216 + </div> 217 + )} 218 + </div> 219 + ); 220 + }
+73
web/src/context/AuthContext.jsx
··· 1 + import { useState, createContext, useContext, useEffect } from "react"; 2 + import { getSession, logout } from "../api/client"; 3 + 4 + const AuthContext = createContext(null); 5 + 6 + export function AuthProvider({ children }) { 7 + const [user, setUser] = useState(null); 8 + const [loading, setLoading] = useState(true); 9 + 10 + useEffect(() => { 11 + checkSession(); 12 + }, []); 13 + 14 + const checkSession = async () => { 15 + try { 16 + const data = await getSession(); 17 + if (data.authenticated) { 18 + let avatar = null; 19 + let displayName = null; 20 + try { 21 + const profileRes = await fetch( 22 + `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(data.did)}`, 23 + ); 24 + if (profileRes.ok) { 25 + const profile = await profileRes.json(); 26 + avatar = profile.avatar; 27 + displayName = profile.displayName; 28 + } 29 + } catch (e) { 30 + console.error("Failed to fetch profile:", e); 31 + } 32 + setUser({ 33 + did: data.did, 34 + handle: data.handle, 35 + avatar, 36 + displayName: displayName || data.handle, 37 + }); 38 + } else { 39 + setUser(null); 40 + } 41 + } catch { 42 + setUser(null); 43 + } finally { 44 + setLoading(false); 45 + } 46 + }; 47 + 48 + const handleLogout = async () => { 49 + try { 50 + await logout(); 51 + } catch {} 52 + setUser(null); 53 + }; 54 + 55 + const value = { 56 + user, 57 + loading, 58 + isAuthenticated: !!user, 59 + login: () => (window.location.href = "/login"), 60 + logout: handleLogout, 61 + refresh: checkSession, 62 + }; 63 + 64 + return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>; 65 + } 66 + 67 + export function useAuth() { 68 + const context = useContext(AuthContext); 69 + if (!context) { 70 + throw new Error("useAuth must be used within AuthProvider"); 71 + } 72 + return context; 73 + }
+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 + }
+13
web/src/main.jsx
··· 1 + import React from "react"; 2 + import ReactDOM from "react-dom/client"; 3 + import { BrowserRouter } from "react-router-dom"; 4 + import App from "./App"; 5 + import "./index.css"; 6 + 7 + ReactDOM.createRoot(document.getElementById("root")).render( 8 + <React.StrictMode> 9 + <BrowserRouter> 10 + <App /> 11 + </BrowserRouter> 12 + </React.StrictMode>, 13 + );
+194
web/src/pages/AnnotationDetail.jsx
··· 1 + import { useState, useEffect } from "react"; 2 + import { useParams, Link } from "react-router-dom"; 3 + import AnnotationCard from "../components/AnnotationCard"; 4 + import ReplyList from "../components/ReplyList"; 5 + import { 6 + getAnnotation, 7 + getReplies, 8 + createReply, 9 + deleteReply, 10 + } from "../api/client"; 11 + import { useAuth } from "../context/AuthContext"; 12 + import { MessageSquare } from "lucide-react"; 13 + 14 + export default function AnnotationDetail() { 15 + const { uri, did, rkey } = useParams(); 16 + const { isAuthenticated, user } = useAuth(); 17 + const [annotation, setAnnotation] = useState(null); 18 + const [replies, setReplies] = useState([]); 19 + const [loading, setLoading] = useState(true); 20 + const [error, setError] = useState(null); 21 + 22 + const [replyText, setReplyText] = useState(""); 23 + const [posting, setPosting] = useState(false); 24 + const [replyingTo, setReplyingTo] = useState(null); 25 + 26 + const annotationUri = uri || `at://${did}/at.margin.annotation/${rkey}`; 27 + 28 + const refreshReplies = async () => { 29 + const repliesData = await getReplies(annotationUri); 30 + setReplies(repliesData.items || []); 31 + }; 32 + 33 + useEffect(() => { 34 + async function fetchData() { 35 + try { 36 + setLoading(true); 37 + const [annData, repliesData] = await Promise.all([ 38 + getAnnotation(annotationUri), 39 + getReplies(annotationUri).catch(() => ({ items: [] })), 40 + ]); 41 + setAnnotation(annData); 42 + setReplies(repliesData.items || []); 43 + } catch (err) { 44 + setError(err.message); 45 + } finally { 46 + setLoading(false); 47 + } 48 + } 49 + fetchData(); 50 + }, [annotationUri]); 51 + 52 + const handleReply = async (e) => { 53 + if (e) e.preventDefault(); 54 + if (!replyText.trim()) return; 55 + 56 + try { 57 + setPosting(true); 58 + const parentUri = replyingTo 59 + ? replyingTo.id || replyingTo.uri 60 + : annotationUri; 61 + const parentCid = replyingTo 62 + ? replyingTo.cid || "" 63 + : annotation?.cid || ""; 64 + 65 + await createReply({ 66 + parentUri, 67 + parentCid, 68 + rootUri: annotationUri, 69 + rootCid: annotation?.cid || "", 70 + text: replyText, 71 + }); 72 + setReplyText(""); 73 + setReplyingTo(null); 74 + await refreshReplies(); 75 + } catch (err) { 76 + alert("Failed to post reply: " + err.message); 77 + } finally { 78 + setPosting(false); 79 + } 80 + }; 81 + 82 + const handleDeleteReply = async (reply) => { 83 + if (!confirm("Delete this reply?")) return; 84 + try { 85 + await deleteReply(reply.id || reply.uri); 86 + await refreshReplies(); 87 + } catch (err) { 88 + alert("Failed to delete: " + err.message); 89 + } 90 + }; 91 + 92 + if (loading) { 93 + return ( 94 + <div className="annotation-detail-page"> 95 + <div className="card"> 96 + <div className="skeleton skeleton-text" style={{ width: "40%" }} /> 97 + <div className="skeleton skeleton-text" /> 98 + <div className="skeleton skeleton-text" style={{ width: "60%" }} /> 99 + </div> 100 + </div> 101 + ); 102 + } 103 + 104 + if (error || !annotation) { 105 + return ( 106 + <div className="annotation-detail-page"> 107 + <div className="empty-state"> 108 + <div className="empty-state-icon">⚠️</div> 109 + <h3 className="empty-state-title">Annotation not found</h3> 110 + <p className="empty-state-text"> 111 + {error || "This annotation may have been deleted."} 112 + </p> 113 + <Link 114 + to="/" 115 + className="btn btn-primary" 116 + style={{ marginTop: "16px" }} 117 + > 118 + Back to Feed 119 + </Link> 120 + </div> 121 + </div> 122 + ); 123 + } 124 + 125 + return ( 126 + <div className="annotation-detail-page"> 127 + <div className="annotation-detail-header"> 128 + <Link to="/" className="back-link"> 129 + ← Back to Feed 130 + </Link> 131 + </div> 132 + 133 + <AnnotationCard annotation={annotation} /> 134 + 135 + {} 136 + <div className="replies-section"> 137 + <h3 className="replies-title"> 138 + <MessageSquare size={18} /> 139 + Replies ({replies.length}) 140 + </h3> 141 + 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> 151 + <button 152 + onClick={() => setReplyingTo(null)} 153 + className="cancel-reply" 154 + > 155 + × 156 + </button> 157 + </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 + </div> 180 + </div> 181 + )} 182 + 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> 192 + </div> 193 + ); 194 + }
+294
web/src/pages/Bookmarks.jsx
··· 1 + import { useState, useEffect } from "react"; 2 + import { Link } from "react-router-dom"; 3 + import { Plus } from "lucide-react"; 4 + import { useAuth } from "../context/AuthContext"; 5 + import { 6 + getUserBookmarks, 7 + deleteBookmark, 8 + createBookmark, 9 + getURLMetadata, 10 + } from "../api/client"; 11 + import { BookmarkIcon } from "../components/Icons"; 12 + import BookmarkCard from "../components/BookmarkCard"; 13 + 14 + export default function Bookmarks() { 15 + const { user, isAuthenticated, loading } = useAuth(); 16 + const [bookmarks, setBookmarks] = useState([]); 17 + const [loadingBookmarks, setLoadingBookmarks] = useState(true); 18 + const [error, setError] = useState(null); 19 + const [showAddForm, setShowAddForm] = useState(false); 20 + const [newUrl, setNewUrl] = useState(""); 21 + const [newTitle, setNewTitle] = useState(""); 22 + const [submitting, setSubmitting] = useState(false); 23 + const [fetchingTitle, setFetchingTitle] = useState(false); 24 + 25 + const loadBookmarks = async () => { 26 + if (!user?.did) return; 27 + 28 + try { 29 + setLoadingBookmarks(true); 30 + const data = await getUserBookmarks(user.did); 31 + setBookmarks(data.items || []); 32 + } catch (err) { 33 + console.error("Failed to load bookmarks:", err); 34 + setError(err.message); 35 + } finally { 36 + setLoadingBookmarks(false); 37 + } 38 + }; 39 + 40 + useEffect(() => { 41 + if (isAuthenticated && user) { 42 + loadBookmarks(); 43 + } 44 + }, [isAuthenticated, user]); 45 + 46 + const handleDelete = async (uri) => { 47 + if (!confirm("Delete this bookmark?")) return; 48 + 49 + try { 50 + const parts = uri.split("/"); 51 + const rkey = parts[parts.length - 1]; 52 + await deleteBookmark(rkey); 53 + setBookmarks((prev) => prev.filter((b) => (b.id || b.uri) !== uri)); 54 + } catch (err) { 55 + alert("Failed to delete: " + err.message); 56 + } 57 + }; 58 + 59 + const handleUrlBlur = async () => { 60 + if (!newUrl.trim() || newTitle.trim()) return; 61 + try { 62 + new URL(newUrl); 63 + } catch { 64 + return; 65 + } 66 + try { 67 + setFetchingTitle(true); 68 + const data = await getURLMetadata(newUrl.trim()); 69 + if (data.title && !newTitle) { 70 + setNewTitle(data.title); 71 + } 72 + } catch (err) { 73 + console.error("Failed to fetch title:", err); 74 + } finally { 75 + setFetchingTitle(false); 76 + } 77 + }; 78 + 79 + const handleAddBookmark = async (e) => { 80 + e.preventDefault(); 81 + if (!newUrl.trim()) return; 82 + 83 + try { 84 + setSubmitting(true); 85 + await createBookmark(newUrl.trim(), newTitle.trim() || undefined); 86 + setNewUrl(""); 87 + setNewTitle(""); 88 + setShowAddForm(false); 89 + await loadBookmarks(); 90 + } catch (err) { 91 + alert("Failed to add bookmark: " + err.message); 92 + } finally { 93 + setSubmitting(false); 94 + } 95 + }; 96 + 97 + if (loading) 98 + return ( 99 + <div className="page-loading"> 100 + <div className="spinner"></div> 101 + </div> 102 + ); 103 + 104 + if (!isAuthenticated) { 105 + return ( 106 + <div className="new-page"> 107 + <div className="card" style={{ textAlign: "center", padding: "48px" }}> 108 + <h2>Sign in to view your bookmarks</h2> 109 + <p style={{ color: "var(--text-secondary)", marginTop: "8px" }}> 110 + You need to be logged in with your Bluesky account 111 + </p> 112 + <Link 113 + to="/login" 114 + className="btn btn-primary" 115 + style={{ marginTop: "24px" }} 116 + > 117 + Sign in with Bluesky 118 + </Link> 119 + </div> 120 + </div> 121 + ); 122 + } 123 + 124 + return ( 125 + <div className="feed-page"> 126 + <div 127 + className="page-header" 128 + style={{ 129 + display: "flex", 130 + justifyContent: "space-between", 131 + alignItems: "flex-start", 132 + }} 133 + > 134 + <div> 135 + <h1 className="page-title">My Bookmarks</h1> 136 + <p className="page-description">Pages you've saved for later</p> 137 + </div> 138 + <button 139 + onClick={() => setShowAddForm(!showAddForm)} 140 + className="btn btn-primary" 141 + > 142 + <Plus size={20} /> 143 + Add Bookmark 144 + </button> 145 + </div> 146 + 147 + {showAddForm && ( 148 + <div className="card" style={{ marginBottom: "20px", padding: "24px" }}> 149 + <h3 150 + style={{ 151 + marginBottom: "16px", 152 + fontSize: "1.1rem", 153 + color: "var(--text-primary)", 154 + }} 155 + > 156 + Add a Bookmark 157 + </h3> 158 + <form onSubmit={handleAddBookmark}> 159 + <div 160 + style={{ display: "flex", flexDirection: "column", gap: "16px" }} 161 + > 162 + <div> 163 + <label 164 + style={{ 165 + display: "block", 166 + marginBottom: "6px", 167 + fontSize: "0.85rem", 168 + color: "var(--text-secondary)", 169 + }} 170 + > 171 + URL * 172 + </label> 173 + <input 174 + type="url" 175 + placeholder="https://example.com/article" 176 + value={newUrl} 177 + onChange={(e) => setNewUrl(e.target.value)} 178 + onBlur={handleUrlBlur} 179 + className="input" 180 + style={{ width: "100%" }} 181 + required 182 + autoFocus 183 + /> 184 + </div> 185 + <div> 186 + <label 187 + style={{ 188 + display: "block", 189 + marginBottom: "6px", 190 + fontSize: "0.85rem", 191 + color: "var(--text-secondary)", 192 + }} 193 + > 194 + Title{" "} 195 + {fetchingTitle ? ( 196 + <span style={{ color: "var(--accent)" }}>Fetching...</span> 197 + ) : ( 198 + <span style={{ color: "var(--text-tertiary)" }}> 199 + (auto-fetched) 200 + </span> 201 + )} 202 + </label> 203 + <input 204 + type="text" 205 + placeholder={ 206 + fetchingTitle 207 + ? "Fetching title..." 208 + : "Page title will be fetched automatically" 209 + } 210 + value={newTitle} 211 + onChange={(e) => setNewTitle(e.target.value)} 212 + className="input" 213 + style={{ width: "100%" }} 214 + /> 215 + </div> 216 + <div 217 + style={{ 218 + display: "flex", 219 + gap: "10px", 220 + justifyContent: "flex-end", 221 + marginTop: "8px", 222 + }} 223 + > 224 + <button 225 + type="button" 226 + onClick={() => { 227 + setShowAddForm(false); 228 + setNewUrl(""); 229 + setNewTitle(""); 230 + }} 231 + className="btn btn-secondary" 232 + > 233 + Cancel 234 + </button> 235 + <button 236 + type="submit" 237 + className="btn btn-primary" 238 + disabled={submitting || !newUrl.trim()} 239 + > 240 + {submitting ? "Adding..." : "Save Bookmark"} 241 + </button> 242 + </div> 243 + </div> 244 + </form> 245 + </div> 246 + )} 247 + 248 + {loadingBookmarks ? ( 249 + <div className="feed"> 250 + {[1, 2, 3].map((i) => ( 251 + <div key={i} className="card"> 252 + <div 253 + className="skeleton skeleton-text" 254 + style={{ width: "40%" }} 255 + ></div> 256 + <div className="skeleton skeleton-text"></div> 257 + <div 258 + className="skeleton skeleton-text" 259 + style={{ width: "60%" }} 260 + ></div> 261 + </div> 262 + ))} 263 + </div> 264 + ) : error ? ( 265 + <div className="empty-state"> 266 + <div className="empty-state-icon">⚠️</div> 267 + <h3 className="empty-state-title">Error loading bookmarks</h3> 268 + <p className="empty-state-text">{error}</p> 269 + </div> 270 + ) : bookmarks.length === 0 ? ( 271 + <div className="empty-state"> 272 + <div className="empty-state-icon"> 273 + <BookmarkIcon size={32} /> 274 + </div> 275 + <h3 className="empty-state-title">No bookmarks yet</h3> 276 + <p className="empty-state-text"> 277 + Click "Add Bookmark" above to save a page, or use the browser 278 + extension. 279 + </p> 280 + </div> 281 + ) : ( 282 + <div className="feed"> 283 + {bookmarks.map((bookmark) => ( 284 + <BookmarkCard 285 + key={bookmark.id} 286 + bookmark={bookmark} 287 + onDelete={handleDelete} 288 + /> 289 + ))} 290 + </div> 291 + )} 292 + </div> 293 + ); 294 + }
+255
web/src/pages/CollectionDetail.jsx
··· 1 + import { useState, useEffect } from "react"; 2 + import { useParams, useNavigate, Link, useLocation } from "react-router-dom"; 3 + import { ArrowLeft, Edit2, Trash2, Plus } from "lucide-react"; 4 + import { 5 + getCollections, 6 + getCollectionItems, 7 + removeItemFromCollection, 8 + deleteCollection, 9 + } from "../api/client"; 10 + import { useAuth } from "../context/AuthContext"; 11 + import CollectionModal from "../components/CollectionModal"; 12 + import CollectionIcon from "../components/CollectionIcon"; 13 + import AnnotationCard, { HighlightCard } from "../components/AnnotationCard"; 14 + import BookmarkCard from "../components/BookmarkCard"; 15 + import ShareMenu from "../components/ShareMenu"; 16 + 17 + export default function CollectionDetail() { 18 + const { rkey, "*": wildcardPath } = useParams(); 19 + const location = useLocation(); 20 + const navigate = useNavigate(); 21 + const { user } = useAuth(); 22 + 23 + const [collection, setCollection] = useState(null); 24 + const [items, setItems] = useState([]); 25 + const [loading, setLoading] = useState(true); 26 + const [error, setError] = useState(null); 27 + const [isEditModalOpen, setIsEditModalOpen] = useState(false); 28 + 29 + const searchParams = new URLSearchParams(location.search); 30 + const authorDid = searchParams.get("author") || user?.did; 31 + 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 + }; 41 + 42 + const collectionUri = getCollectionUri(); 43 + const isOwner = user?.did && authorDid === user.did; 44 + 45 + const fetchContext = async () => { 46 + if (!collectionUri || !authorDid) { 47 + setError("Invalid collection URL"); 48 + setLoading(false); 49 + return; 50 + } 51 + 52 + try { 53 + setLoading(true); 54 + const [cols, itemsData] = await Promise.all([ 55 + getCollections(authorDid), 56 + getCollectionItems(collectionUri), 57 + ]); 58 + 59 + const found = 60 + cols.items?.find((c) => c.uri === collectionUri) || 61 + cols.items?.find( 62 + (c) => 63 + collectionUri && c.uri.endsWith(collectionUri.split("/").pop()), 64 + ); 65 + if (!found) { 66 + console.error( 67 + "Collection not found. Looking for:", 68 + collectionUri, 69 + "Available:", 70 + cols.items?.map((c) => c.uri), 71 + ); 72 + setError("Collection not found"); 73 + return; 74 + } 75 + setCollection(found); 76 + setItems(itemsData || []); 77 + } catch (err) { 78 + console.error(err); 79 + setError("Failed to load collection"); 80 + } finally { 81 + setLoading(false); 82 + } 83 + }; 84 + 85 + 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]); 93 + 94 + const handleEditSuccess = () => { 95 + fetchContext(); 96 + setIsEditModalOpen(false); 97 + }; 98 + 99 + const handleDeleteItem = async (itemUri) => { 100 + if (!confirm("Remove this item from the collection?")) return; 101 + try { 102 + await removeItemFromCollection(itemUri); 103 + setItems((prev) => prev.filter((i) => i.uri !== itemUri)); 104 + } catch (err) { 105 + console.error(err); 106 + alert("Failed to remove item"); 107 + } 108 + }; 109 + 110 + if (loading) { 111 + return ( 112 + <div className="feed-page"> 113 + <div 114 + style={{ 115 + display: "flex", 116 + justifyContent: "center", 117 + padding: "60px 0", 118 + }} 119 + > 120 + <div className="spinner"></div> 121 + </div> 122 + </div> 123 + ); 124 + } 125 + 126 + if (error || !collection) { 127 + return ( 128 + <div className="feed-page"> 129 + <div className="empty-state card"> 130 + <div className="empty-state-icon">⚠️</div> 131 + <h3 className="empty-state-title"> 132 + {error || "Collection not found"} 133 + </h3> 134 + <button 135 + onClick={() => navigate("/collections")} 136 + className="btn btn-secondary" 137 + style={{ marginTop: "16px" }} 138 + > 139 + Back to Collections 140 + </button> 141 + </div> 142 + </div> 143 + ); 144 + } 145 + 146 + return ( 147 + <div className="feed-page"> 148 + <Link to="/collections" className="back-link"> 149 + <ArrowLeft size={18} /> 150 + <span>Collections</span> 151 + </Link> 152 + 153 + <div className="collection-detail-header"> 154 + <div className="collection-detail-icon"> 155 + <CollectionIcon icon={collection.icon} size={28} /> 156 + </div> 157 + <div className="collection-detail-info"> 158 + <h1 className="collection-detail-title">{collection.name}</h1> 159 + {collection.description && ( 160 + <p className="collection-detail-desc">{collection.description}</p> 161 + )} 162 + <div className="collection-detail-stats"> 163 + <span> 164 + {items.length} {items.length === 1 ? "item" : "items"} 165 + </span> 166 + <span>·</span> 167 + <span> 168 + Created {new Date(collection.createdAt).toLocaleDateString()} 169 + </span> 170 + </div> 171 + </div> 172 + <div className="collection-detail-actions"> 173 + <ShareMenu 174 + customUrl={`${window.location.origin}/collection/${encodeURIComponent(collection.uri)}?author=${encodeURIComponent(authorDid)}`} 175 + text={`Check out this collection: ${collection.name}`} 176 + /> 177 + {isOwner && ( 178 + <> 179 + <button 180 + onClick={() => setIsEditModalOpen(true)} 181 + className="collection-detail-edit" 182 + title="Edit Collection" 183 + > 184 + <Edit2 size={18} /> 185 + </button> 186 + <button 187 + onClick={async () => { 188 + if (confirm("Delete this collection and all its items?")) { 189 + await deleteCollection(collection.uri); 190 + navigate("/collections"); 191 + } 192 + }} 193 + className="collection-detail-delete" 194 + title="Delete Collection" 195 + > 196 + <Trash2 size={18} /> 197 + </button> 198 + </> 199 + )} 200 + </div> 201 + </div> 202 + 203 + <div className="feed"> 204 + {items.length === 0 ? ( 205 + <div className="empty-state card" style={{ borderStyle: "dashed" }}> 206 + <div className="empty-state-icon"> 207 + <Plus size={32} /> 208 + </div> 209 + <h3 className="empty-state-title">Collection is empty</h3> 210 + <p className="empty-state-text"> 211 + {isOwner 212 + ? 'Add items to this collection from your feed or bookmarks using the "Collect" button.' 213 + : "This collection has no items yet."} 214 + </p> 215 + </div> 216 + ) : ( 217 + items.map((item) => ( 218 + <div key={item.uri} className="collection-item-wrapper"> 219 + {isOwner && ( 220 + <button 221 + onClick={() => handleDeleteItem(item.uri)} 222 + className="collection-item-remove" 223 + title="Remove from collection" 224 + > 225 + <Trash2 size={14} /> 226 + </button> 227 + )} 228 + 229 + {item.annotation ? ( 230 + <AnnotationCard annotation={item.annotation} /> 231 + ) : item.highlight ? ( 232 + <HighlightCard highlight={item.highlight} /> 233 + ) : item.bookmark ? ( 234 + <BookmarkCard bookmark={item.bookmark} /> 235 + ) : ( 236 + <div className="card" style={{ padding: "16px" }}> 237 + <p className="text-secondary">Item could not be loaded</p> 238 + </div> 239 + )} 240 + </div> 241 + )) 242 + )} 243 + </div> 244 + 245 + {isOwner && ( 246 + <CollectionModal 247 + isOpen={isEditModalOpen} 248 + onClose={() => setIsEditModalOpen(false)} 249 + onSuccess={handleEditSuccess} 250 + collectionToEdit={collection} 251 + /> 252 + )} 253 + </div> 254 + ); 255 + }
+129
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"; 4 + import { getCollections } from "../api/client"; 5 + import { useAuth } from "../context/AuthContext"; 6 + import CollectionModal from "../components/CollectionModal"; 7 + import CollectionRow from "../components/CollectionRow"; 8 + 9 + export default function Collections() { 10 + const { user } = useAuth(); 11 + const [collections, setCollections] = useState([]); 12 + const [loading, setLoading] = useState(true); 13 + const [error, setError] = useState(null); 14 + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); 15 + const [editingCollection, setEditingCollection] = useState(null); 16 + 17 + const fetchCollections = async () => { 18 + try { 19 + setLoading(true); 20 + const data = await getCollections(user.did); 21 + setCollections(data.items || []); 22 + } catch (err) { 23 + console.error(err); 24 + setError("Failed to load collections"); 25 + } finally { 26 + setLoading(false); 27 + } 28 + }; 29 + 30 + useEffect(() => { 31 + if (user) { 32 + fetchCollections(); 33 + } 34 + }, [user]); 35 + 36 + const handleCreateSuccess = () => { 37 + fetchCollections(); 38 + setIsCreateModalOpen(false); 39 + setEditingCollection(null); 40 + }; 41 + 42 + if (loading) { 43 + return ( 44 + <div className="feed-page"> 45 + <div 46 + style={{ 47 + display: "flex", 48 + justifyContent: "center", 49 + padding: "60px 0", 50 + }} 51 + > 52 + <div className="spinner"></div> 53 + </div> 54 + </div> 55 + ); 56 + } 57 + 58 + return ( 59 + <div className="feed-page"> 60 + <div 61 + className="page-header" 62 + style={{ 63 + display: "flex", 64 + justifyContent: "space-between", 65 + alignItems: "flex-start", 66 + }} 67 + > 68 + <div> 69 + <h1 className="page-title">Collections</h1> 70 + <p className="page-description"> 71 + Organize your annotations, highlights, and bookmarks 72 + </p> 73 + </div> 74 + <button 75 + onClick={() => setIsCreateModalOpen(true)} 76 + className="btn btn-primary" 77 + > 78 + <Plus size={20} /> 79 + New Collection 80 + </button> 81 + </div> 82 + 83 + {error ? ( 84 + <div className="empty-state card"> 85 + <div className="empty-state-icon">⚠️</div> 86 + <h3 className="empty-state-title">Something went wrong</h3> 87 + <p className="empty-state-text">{error}</p> 88 + </div> 89 + ) : collections.length === 0 ? ( 90 + <div className="empty-state card"> 91 + <div className="empty-state-icon"> 92 + <Folder size={32} /> 93 + </div> 94 + <h3 className="empty-state-title">No collections yet</h3> 95 + <p className="empty-state-text mb-6"> 96 + Create your first collection to start organizing your web 97 + annotations. 98 + </p> 99 + <button 100 + onClick={() => setIsCreateModalOpen(true)} 101 + className="btn btn-secondary" 102 + > 103 + Create Collection 104 + </button> 105 + </div> 106 + ) : ( 107 + <div className="collections-list"> 108 + {collections.map((collection) => ( 109 + <CollectionRow 110 + key={collection.uri} 111 + collection={collection} 112 + onEdit={() => setEditingCollection(collection)} 113 + /> 114 + ))} 115 + </div> 116 + )} 117 + 118 + <CollectionModal 119 + isOpen={isCreateModalOpen || !!editingCollection} 120 + onClose={() => { 121 + setIsCreateModalOpen(false); 122 + setEditingCollection(null); 123 + }} 124 + onSuccess={handleCreateSuccess} 125 + collectionToEdit={editingCollection} 126 + /> 127 + </div> 128 + ); 129 + }
+143
web/src/pages/Feed.jsx
··· 1 + import { useState, useEffect } from "react"; 2 + import AnnotationCard, { HighlightCard } from "../components/AnnotationCard"; 3 + import BookmarkCard from "../components/BookmarkCard"; 4 + import CollectionItemCard from "../components/CollectionItemCard"; 5 + import { getAnnotationFeed } from "../api/client"; 6 + import { AlertIcon, InboxIcon } from "../components/Icons"; 7 + 8 + export default function Feed() { 9 + const [annotations, setAnnotations] = useState([]); 10 + const [loading, setLoading] = useState(true); 11 + const [error, setError] = useState(null); 12 + const [filter, setFilter] = useState("all"); 13 + 14 + useEffect(() => { 15 + async function fetchFeed() { 16 + try { 17 + setLoading(true); 18 + const data = await getAnnotationFeed(); 19 + setAnnotations(data.items || []); 20 + } catch (err) { 21 + setError(err.message); 22 + } finally { 23 + setLoading(false); 24 + } 25 + } 26 + fetchFeed(); 27 + }, []); 28 + 29 + const filteredAnnotations = 30 + filter === "all" 31 + ? annotations 32 + : annotations.filter((a) => { 33 + if (filter === "commenting") 34 + return a.motivation === "commenting" || a.type === "Annotation"; 35 + if (filter === "highlighting") 36 + return a.motivation === "highlighting" || a.type === "Highlight"; 37 + if (filter === "bookmarking") 38 + return a.motivation === "bookmarking" || a.type === "Bookmark"; 39 + return a.motivation === filter; 40 + }); 41 + 42 + return ( 43 + <div className="feed-page"> 44 + <div className="page-header"> 45 + <h1 className="page-title">Feed</h1> 46 + <p className="page-description"> 47 + See what people are annotating, highlighting, and bookmarking 48 + </p> 49 + </div> 50 + 51 + {} 52 + <div className="feed-filters"> 53 + <button 54 + className={`filter-tab ${filter === "all" ? "active" : ""}`} 55 + onClick={() => setFilter("all")} 56 + > 57 + All 58 + </button> 59 + <button 60 + className={`filter-tab ${filter === "commenting" ? "active" : ""}`} 61 + onClick={() => setFilter("commenting")} 62 + > 63 + Annotations 64 + </button> 65 + <button 66 + className={`filter-tab ${filter === "highlighting" ? "active" : ""}`} 67 + onClick={() => setFilter("highlighting")} 68 + > 69 + Highlights 70 + </button> 71 + <button 72 + className={`filter-tab ${filter === "bookmarking" ? "active" : ""}`} 73 + onClick={() => setFilter("bookmarking")} 74 + > 75 + Bookmarks 76 + </button> 77 + </div> 78 + 79 + {loading && ( 80 + <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> 94 + ))} 95 + </div> 96 + )} 97 + 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 + )} 107 + 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> 120 + )} 121 + 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> 140 + )} 141 + </div> 142 + ); 143 + }
+129
web/src/pages/Highlights.jsx
··· 1 + import { useState, useEffect } from "react"; 2 + import { Link } from "react-router-dom"; 3 + import { useAuth } from "../context/AuthContext"; 4 + import { getUserHighlights, deleteHighlight } from "../api/client"; 5 + import { HighlightIcon } from "../components/Icons"; 6 + import { HighlightCard } from "../components/AnnotationCard"; 7 + 8 + export default function Highlights() { 9 + const { user, isAuthenticated, loading } = useAuth(); 10 + const [highlights, setHighlights] = useState([]); 11 + const [loadingHighlights, setLoadingHighlights] = useState(true); 12 + const [error, setError] = useState(null); 13 + 14 + useEffect(() => { 15 + async function loadHighlights() { 16 + if (!user?.did) return; 17 + 18 + try { 19 + setLoadingHighlights(true); 20 + const data = await getUserHighlights(user.did); 21 + setHighlights(data.items || []); 22 + } catch (err) { 23 + console.error("Failed to load highlights:", err); 24 + setError(err.message); 25 + } finally { 26 + setLoadingHighlights(false); 27 + } 28 + } 29 + 30 + if (isAuthenticated && user) { 31 + loadHighlights(); 32 + } 33 + }, [isAuthenticated, user]); 34 + 35 + const handleDelete = async (uri) => { 36 + if (!confirm("Delete this highlight?")) return; 37 + 38 + try { 39 + const parts = uri.split("/"); 40 + const rkey = parts[parts.length - 1]; 41 + await deleteHighlight(rkey); 42 + setHighlights((prev) => prev.filter((h) => (h.id || h.uri) !== uri)); 43 + } catch (err) { 44 + alert("Failed to delete: " + err.message); 45 + } 46 + }; 47 + 48 + if (loading) 49 + return ( 50 + <div className="page-loading"> 51 + <div className="spinner"></div> 52 + </div> 53 + ); 54 + 55 + if (!isAuthenticated) { 56 + return ( 57 + <div className="new-page"> 58 + <div className="card" style={{ textAlign: "center", padding: "48px" }}> 59 + <h2>Sign in to view your highlights</h2> 60 + <p style={{ color: "var(--text-secondary)", marginTop: "8px" }}> 61 + You need to be logged in with your Bluesky account 62 + </p> 63 + <Link 64 + to="/login" 65 + className="btn btn-primary" 66 + style={{ marginTop: "24px" }} 67 + > 68 + Sign in with Bluesky 69 + </Link> 70 + </div> 71 + </div> 72 + ); 73 + } 74 + 75 + return ( 76 + <div className="feed-page"> 77 + <div className="page-header"> 78 + <h1 className="page-title">My Highlights</h1> 79 + <p className="page-description"> 80 + Text you've highlighted across the web 81 + </p> 82 + </div> 83 + 84 + {loadingHighlights ? ( 85 + <div className="feed"> 86 + {[1, 2, 3].map((i) => ( 87 + <div key={i} className="card"> 88 + <div 89 + className="skeleton skeleton-text" 90 + style={{ width: "40%" }} 91 + ></div> 92 + <div className="skeleton skeleton-text"></div> 93 + <div 94 + className="skeleton skeleton-text" 95 + style={{ width: "60%" }} 96 + ></div> 97 + </div> 98 + ))} 99 + </div> 100 + ) : error ? ( 101 + <div className="empty-state"> 102 + <div className="empty-state-icon">⚠️</div> 103 + <h3 className="empty-state-title">Error loading highlights</h3> 104 + <p className="empty-state-text">{error}</p> 105 + </div> 106 + ) : highlights.length === 0 ? ( 107 + <div className="empty-state"> 108 + <div className="empty-state-icon"> 109 + <HighlightIcon size={32} /> 110 + </div> 111 + <h3 className="empty-state-title">No highlights yet</h3> 112 + <p className="empty-state-text"> 113 + Highlight text on any page using the browser extension. 114 + </p> 115 + </div> 116 + ) : ( 117 + <div className="feed"> 118 + {highlights.map((highlight) => ( 119 + <HighlightCard 120 + key={highlight.id} 121 + highlight={highlight} 122 + onDelete={handleDelete} 123 + /> 124 + ))} 125 + </div> 126 + )} 127 + </div> 128 + ); 129 + }
+267
web/src/pages/Login.jsx
··· 1 + import { useState, useEffect, useRef } from "react"; 2 + import { Link } from "react-router-dom"; 3 + import { useAuth } from "../context/AuthContext"; 4 + import { searchActors, startLogin } from "../api/client"; 5 + import { HelpCircle } from "lucide-react"; 6 + import logo from "../assets/logo.svg"; 7 + 8 + export default function Login() { 9 + const { isAuthenticated, user, logout } = useAuth(); 10 + const [handle, setHandle] = useState(""); 11 + const [inviteCode, setInviteCode] = useState(""); 12 + const [showInviteInput, setShowInviteInput] = useState(false); 13 + const [suggestions, setSuggestions] = useState([]); 14 + const [showSuggestions, setShowSuggestions] = useState(false); 15 + const [showHelp, setShowHelp] = useState(false); 16 + const [loading, setLoading] = useState(false); 17 + const [error, setError] = useState(null); 18 + const [selectedIndex, setSelectedIndex] = useState(-1); 19 + const inputRef = useRef(null); 20 + const inviteRef = useRef(null); 21 + const suggestionsRef = useRef(null); 22 + 23 + const isSelectionRef = useRef(false); 24 + 25 + useEffect(() => { 26 + if (handle.length < 3) { 27 + setSuggestions([]); 28 + setShowSuggestions(false); 29 + return; 30 + } 31 + 32 + if (isSelectionRef.current) { 33 + isSelectionRef.current = false; 34 + return; 35 + } 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 + }, [handle]); 50 + 51 + useEffect(() => { 52 + const handleClickOutside = (e) => { 53 + if ( 54 + suggestionsRef.current && 55 + !suggestionsRef.current.contains(e.target) && 56 + inputRef.current && 57 + !inputRef.current.contains(e.target) 58 + ) { 59 + setShowSuggestions(false); 60 + } 61 + }; 62 + document.addEventListener("mousedown", handleClickOutside); 63 + return () => document.removeEventListener("mousedown", handleClickOutside); 64 + }, []); 65 + 66 + const handleKeyDown = (e) => { 67 + if (!showSuggestions || suggestions.length === 0) return; 68 + 69 + if (e.key === "ArrowDown") { 70 + e.preventDefault(); 71 + setSelectedIndex((prev) => Math.min(prev + 1, suggestions.length - 1)); 72 + } else if (e.key === "ArrowUp") { 73 + e.preventDefault(); 74 + setSelectedIndex((prev) => Math.max(prev - 1, -1)); 75 + } else if (e.key === "Enter" && selectedIndex >= 0) { 76 + e.preventDefault(); 77 + selectSuggestion(suggestions[selectedIndex]); 78 + } else if (e.key === "Escape") { 79 + setShowSuggestions(false); 80 + } 81 + }; 82 + 83 + const selectSuggestion = (actor) => { 84 + isSelectionRef.current = true; 85 + setHandle(actor.handle); 86 + setSuggestions([]); 87 + setShowSuggestions(false); 88 + inputRef.current?.blur(); 89 + }; 90 + 91 + const handleSubmit = async (e) => { 92 + e.preventDefault(); 93 + if (!handle.trim()) return; 94 + if (showInviteInput && !inviteCode.trim()) return; 95 + 96 + setLoading(true); 97 + setError(null); 98 + 99 + try { 100 + const result = await startLogin(handle.trim(), inviteCode.trim()); 101 + if (result.authorizationUrl) { 102 + window.location.href = result.authorizationUrl; 103 + } 104 + } catch (err) { 105 + console.error("Login error:", err); 106 + if ( 107 + err.message && 108 + (err.message.includes("invite_required") || 109 + err.message.includes("Invite code required")) 110 + ) { 111 + setShowInviteInput(true); 112 + setError("Please enter an invite code to continue."); 113 + setTimeout(() => inviteRef.current?.focus(), 100); 114 + } else { 115 + setError(err.message || "Failed to start login"); 116 + } 117 + setLoading(false); 118 + } 119 + }; 120 + 121 + if (isAuthenticated) { 122 + return ( 123 + <div className="login-page"> 124 + <div className="login-avatar-large"> 125 + {user?.avatar ? ( 126 + <img src={user.avatar} alt={user.displayName || user.handle} /> 127 + ) : ( 128 + <span> 129 + {(user?.displayName || user?.handle || "??") 130 + .substring(0, 2) 131 + .toUpperCase()} 132 + </span> 133 + )} 134 + </div> 135 + <h1 className="login-welcome"> 136 + Welcome back, {user?.displayName || user?.handle} 137 + </h1> 138 + <div className="login-actions"> 139 + <Link to={`/profile/${user?.did}`} className="btn btn-primary"> 140 + View Profile 141 + </Link> 142 + <button onClick={logout} className="btn btn-ghost"> 143 + Sign out 144 + </button> 145 + </div> 146 + </div> 147 + ); 148 + } 149 + 150 + return ( 151 + <div className="login-page"> 152 + <img src={logo} alt="Margin Logo" className="login-logo-img" /> 153 + 154 + <h1 className="login-heading"> 155 + Use the AT Protocol to login to Margin 156 + <button 157 + className="login-help-btn" 158 + onClick={() => setShowHelp(!showHelp)} 159 + type="button" 160 + > 161 + <HelpCircle size={20} /> 162 + </button> 163 + </h1> 164 + 165 + {showHelp && ( 166 + <p className="login-help-text"> 167 + The AT Protocol is an open, decentralized network for social apps. 168 + Your handle looks like <code>name.bsky.social</code> or your own 169 + domain. 170 + </p> 171 + )} 172 + 173 + <form onSubmit={handleSubmit} className="login-form"> 174 + <div className="login-input-wrapper"> 175 + <input 176 + ref={inputRef} 177 + type="text" 178 + className="login-input" 179 + placeholder="yourname.bsky.social" 180 + value={handle} 181 + onChange={(e) => setHandle(e.target.value)} 182 + onKeyDown={handleKeyDown} 183 + onFocus={() => 184 + handle.length >= 3 && 185 + suggestions.length > 0 && 186 + !handle.includes(".") && 187 + setShowSuggestions(true) 188 + } 189 + autoComplete="off" 190 + autoCapitalize="off" 191 + autoCorrect="off" 192 + spellCheck="false" 193 + disabled={loading} 194 + /> 195 + 196 + {showSuggestions && suggestions.length > 0 && ( 197 + <div className="login-suggestions" ref={suggestionsRef}> 198 + {suggestions.map((actor, index) => ( 199 + <button 200 + key={actor.did} 201 + type="button" 202 + className={`login-suggestion ${index === selectedIndex ? "selected" : ""}`} 203 + onClick={() => selectSuggestion(actor)} 204 + > 205 + <div className="login-suggestion-avatar"> 206 + {actor.avatar ? ( 207 + <img src={actor.avatar} alt="" /> 208 + ) : ( 209 + <span> 210 + {(actor.displayName || actor.handle) 211 + .substring(0, 2) 212 + .toUpperCase()} 213 + </span> 214 + )} 215 + </div> 216 + <div className="login-suggestion-info"> 217 + <span className="login-suggestion-name"> 218 + {actor.displayName || actor.handle} 219 + </span> 220 + <span className="login-suggestion-handle"> 221 + @{actor.handle} 222 + </span> 223 + </div> 224 + </button> 225 + ))} 226 + </div> 227 + )} 228 + </div> 229 + 230 + {showInviteInput && ( 231 + <div 232 + className="login-input-wrapper" 233 + style={{ marginTop: "12px", animation: "fadeIn 0.3s ease" }} 234 + > 235 + <input 236 + ref={inviteRef} 237 + type="text" 238 + className="login-input" 239 + placeholder="Enter invite code" 240 + value={inviteCode} 241 + onChange={(e) => setInviteCode(e.target.value)} 242 + autoComplete="off" 243 + disabled={loading} 244 + style={{ borderColor: "var(--accent)" }} 245 + /> 246 + </div> 247 + )} 248 + 249 + {error && <p className="login-error">{error}</p>} 250 + 251 + <button 252 + type="submit" 253 + className="btn btn-primary login-submit" 254 + disabled={ 255 + loading || !handle.trim() || (showInviteInput && !inviteCode.trim()) 256 + } 257 + > 258 + {loading 259 + ? "Connecting..." 260 + : showInviteInput 261 + ? "Submit Code" 262 + : "Continue"} 263 + </button> 264 + </form> 265 + </div> 266 + ); 267 + }
+95
web/src/pages/New.jsx
··· 1 + import { useState } from "react"; 2 + import { useNavigate, useSearchParams, Link } from "react-router-dom"; 3 + import { useAuth } from "../context/AuthContext"; 4 + import Composer from "../components/Composer"; 5 + 6 + export default function New() { 7 + const { isAuthenticated, loading } = useAuth(); 8 + const navigate = useNavigate(); 9 + const [searchParams] = useSearchParams(); 10 + 11 + const initialUrl = searchParams.get("url") || ""; 12 + 13 + let initialSelector = null; 14 + const selectorParam = searchParams.get("selector"); 15 + if (selectorParam) { 16 + try { 17 + initialSelector = JSON.parse(selectorParam); 18 + } catch (e) { 19 + console.error("Failed to parse selector:", e); 20 + } 21 + } 22 + 23 + const legacyQuote = searchParams.get("quote") || ""; 24 + if (legacyQuote && !initialSelector) { 25 + initialSelector = { 26 + type: "TextQuoteSelector", 27 + exact: legacyQuote, 28 + }; 29 + } 30 + 31 + const [url, setUrl] = useState(initialUrl); 32 + 33 + if (loading) { 34 + return ( 35 + <div className="new-page"> 36 + <div className="loading-spinner" /> 37 + </div> 38 + ); 39 + } 40 + 41 + if (!isAuthenticated) { 42 + return ( 43 + <div className="new-page"> 44 + <div className="card" style={{ textAlign: "center", padding: "48px" }}> 45 + <h2>Sign in to create annotations</h2> 46 + <p style={{ color: "var(--text-secondary)", marginTop: "8px" }}> 47 + You need to be logged in with your Bluesky account 48 + </p> 49 + <Link 50 + to="/login" 51 + className="btn btn-primary" 52 + style={{ marginTop: "24px" }} 53 + > 54 + Sign in with Bluesky 55 + </Link> 56 + </div> 57 + </div> 58 + ); 59 + } 60 + 61 + const handleSuccess = () => { 62 + navigate("/"); 63 + }; 64 + 65 + return ( 66 + <div className="new-page"> 67 + <div className="page-header"> 68 + <h1 className="page-title">New Annotation</h1> 69 + <p className="page-description">Write in the margins of the web</p> 70 + </div> 71 + 72 + {!initialUrl && ( 73 + <div className="url-input-wrapper"> 74 + <input 75 + type="url" 76 + value={url} 77 + onChange={(e) => setUrl(e.target.value)} 78 + placeholder="https://example.com/article" 79 + className="url-input" 80 + required 81 + /> 82 + </div> 83 + )} 84 + 85 + <div className="card"> 86 + <Composer 87 + url={url || initialUrl} 88 + selector={initialSelector} 89 + onSuccess={handleSuccess} 90 + onCancel={() => navigate(-1)} 91 + /> 92 + </div> 93 + </div> 94 + ); 95 + }
+229
web/src/pages/Notifications.jsx
··· 1 + import { useState, useEffect } from "react"; 2 + import { Link } from "react-router-dom"; 3 + import { useAuth } from "../context/AuthContext"; 4 + import { getNotifications, markNotificationsRead } from "../api/client"; 5 + import { BellIcon, HeartIcon, ReplyIcon } from "../components/Icons"; 6 + 7 + function getContentRoute(subjectUri) { 8 + if (!subjectUri) return "/"; 9 + if (subjectUri.includes("at.margin.bookmark")) { 10 + return `/bookmarks`; 11 + } 12 + if (subjectUri.includes("at.margin.highlight")) { 13 + return `/highlights`; 14 + } 15 + return `/annotation/${encodeURIComponent(subjectUri)}`; 16 + } 17 + 18 + export default function Notifications() { 19 + const { user } = useAuth(); 20 + const [notifications, setNotifications] = useState([]); 21 + const [loading, setLoading] = useState(true); 22 + const [error, setError] = useState(null); 23 + 24 + useEffect(() => { 25 + if (!user?.did) return; 26 + 27 + async function load() { 28 + try { 29 + setLoading(true); 30 + const data = await getNotifications(); 31 + setNotifications(data.items || []); 32 + await markNotificationsRead(); 33 + } catch (err) { 34 + setError(err.message); 35 + } finally { 36 + setLoading(false); 37 + } 38 + } 39 + load(); 40 + }, [user?.did]); 41 + 42 + const formatTime = (dateStr) => { 43 + const date = new Date(dateStr); 44 + const now = new Date(); 45 + const diffMs = now - date; 46 + const diffMins = Math.floor(diffMs / 60000); 47 + const diffHours = Math.floor(diffMs / 3600000); 48 + const diffDays = Math.floor(diffMs / 86400000); 49 + 50 + if (diffMins < 1) return "just now"; 51 + if (diffMins < 60) return `${diffMins}m ago`; 52 + if (diffHours < 24) return `${diffHours}h ago`; 53 + if (diffDays < 7) return `${diffDays}d ago`; 54 + return date.toLocaleDateString(); 55 + }; 56 + 57 + const getNotificationIcon = (type) => { 58 + switch (type) { 59 + case "like": 60 + return <HeartIcon size={16} />; 61 + case "reply": 62 + return <ReplyIcon size={16} />; 63 + default: 64 + return <BellIcon size={16} />; 65 + } 66 + }; 67 + 68 + const getNotificationText = (n) => { 69 + const name = n.actor?.displayName || n.actor?.handle || "Unknown"; 70 + const handle = n.actor?.handle; 71 + 72 + switch (n.type) { 73 + case "like": 74 + return ( 75 + <span> 76 + <Link 77 + to={`/profile/${handle}`} 78 + className="notification-author-link" 79 + onClick={(e) => e.stopPropagation()} 80 + > 81 + {name} 82 + </Link>{" "} 83 + liked your annotation 84 + </span> 85 + ); 86 + case "reply": 87 + return ( 88 + <span> 89 + <Link 90 + to={`/profile/${handle}`} 91 + className="notification-author-link" 92 + onClick={(e) => e.stopPropagation()} 93 + > 94 + {name} 95 + </Link>{" "} 96 + replied to your annotation 97 + </span> 98 + ); 99 + default: 100 + return ( 101 + <span> 102 + <Link 103 + to={`/profile/${handle}`} 104 + className="notification-author-link" 105 + onClick={(e) => e.stopPropagation()} 106 + > 107 + {name} 108 + </Link>{" "} 109 + interacted with your content 110 + </span> 111 + ); 112 + } 113 + }; 114 + 115 + if (!user) { 116 + return ( 117 + <div className="notifications-page"> 118 + <div className="page-header"> 119 + <h1 className="page-title">Notifications</h1> 120 + </div> 121 + <div className="empty-state"> 122 + <BellIcon size={48} /> 123 + <h3>Sign in to see notifications</h3> 124 + <p>Get notified when people like or reply to your content</p> 125 + </div> 126 + </div> 127 + ); 128 + } 129 + 130 + return ( 131 + <div className="notifications-page"> 132 + <div className="page-header"> 133 + <h1 className="page-title">Notifications</h1> 134 + <p className="page-description"> 135 + Likes and replies on your annotations 136 + </p> 137 + </div> 138 + 139 + {loading && ( 140 + <div className="loading-container"> 141 + <div className="loading-spinner"></div> 142 + </div> 143 + )} 144 + 145 + {error && ( 146 + <div className="error-message"> 147 + <p>Error: {error}</p> 148 + </div> 149 + )} 150 + 151 + {!loading && !error && notifications.length === 0 && ( 152 + <div className="empty-state"> 153 + <BellIcon size={48} /> 154 + <h3>No notifications yet</h3> 155 + <p> 156 + When someone likes or replies to your content, you'll see it here 157 + </p> 158 + </div> 159 + )} 160 + 161 + {!loading && !error && notifications.length > 0 && ( 162 + <div className="notifications-list"> 163 + {notifications.map((n, i) => ( 164 + <Link 165 + key={n.id || i} 166 + to={getContentRoute(n.subjectUri)} 167 + className="notification-item card" 168 + style={{ alignItems: "center" }} 169 + > 170 + <div 171 + className="notification-avatar-container" 172 + style={{ marginRight: 12 }} 173 + > 174 + {n.actor?.avatar ? ( 175 + <img 176 + src={n.actor.avatar} 177 + alt={n.actor.handle} 178 + style={{ 179 + width: 40, 180 + height: 40, 181 + borderRadius: "50%", 182 + objectFit: "cover", 183 + }} 184 + /> 185 + ) : ( 186 + <div 187 + style={{ 188 + width: 40, 189 + height: 40, 190 + borderRadius: "50%", 191 + background: "#eee", 192 + display: "flex", 193 + alignItems: "center", 194 + justifyContent: "center", 195 + }} 196 + > 197 + {(n.actor?.handle || "?")[0].toUpperCase()} 198 + </div> 199 + )} 200 + <div 201 + className="notification-icon-badge" 202 + data-type={n.type} 203 + style={{ 204 + position: "absolute", 205 + bottom: -4, 206 + right: -4, 207 + background: "var(--bg-primary)", 208 + borderRadius: "50%", 209 + padding: 2, 210 + display: "flex", 211 + boxShadow: "0 2px 4px rgba(0,0,0,0.1)", 212 + }} 213 + > 214 + {getNotificationIcon(n.type)} 215 + </div> 216 + </div> 217 + <div className="notification-content"> 218 + <p className="notification-text">{getNotificationText(n)}</p> 219 + <span className="notification-time"> 220 + {formatTime(n.createdAt)} 221 + </span> 222 + </div> 223 + </Link> 224 + ))} 225 + </div> 226 + )} 227 + </div> 228 + ); 229 + }
+142
web/src/pages/Privacy.jsx
··· 1 + import { ArrowLeft } from "lucide-react"; 2 + import { Link } from "react-router-dom"; 3 + 4 + export default function Privacy() { 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>Privacy Policy</h1> 14 + <p className="text-secondary">Last updated: January 11, 2026</p> 15 + 16 + <section> 17 + <h2>Overview</h2> 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. 23 + </p> 24 + </section> 25 + 26 + <section> 27 + <h2>Data We Collect</h2> 28 + <h3>Account Information</h3> 29 + <p> 30 + When you log in with your Bluesky/AT Protocol account, we access 31 + your: 32 + </p> 33 + <ul> 34 + <li>Decentralized Identifier (DID)</li> 35 + <li>Handle (username)</li> 36 + <li>Display name and avatar (for showing your profile)</li> 37 + </ul> 38 + 39 + <h3>Annotations & Content</h3> 40 + <p>When you use Margin, we store:</p> 41 + <ul> 42 + <li>URLs of pages you annotate</li> 43 + <li>Text you highlight or select</li> 44 + <li>Annotations and comments you create</li> 45 + <li>Bookmarks you save</li> 46 + <li>Collections you organize content into</li> 47 + </ul> 48 + 49 + <h3>Authentication</h3> 50 + <p> 51 + We store OAuth session tokens locally in your browser to keep you 52 + logged in. These tokens are used solely for authenticating API 53 + requests. 54 + </p> 55 + </section> 56 + 57 + <section> 58 + <h2>How We Use Your Data</h2> 59 + <p>Your data is used exclusively to:</p> 60 + <ul> 61 + <li>Display your annotations on webpages</li> 62 + <li>Sync your content across devices</li> 63 + <li>Show your public annotations to other users</li> 64 + <li>Enable social features like replies and likes</li> 65 + </ul> 66 + </section> 67 + 68 + <section> 69 + <h2>Data Storage</h2> 70 + <p> 71 + Your annotations are stored on the AT Protocol network through your 72 + Personal Data Server (PDS). This means: 73 + </p> 74 + <ul> 75 + <li>You own your data</li> 76 + <li>You can export or delete it at any time</li> 77 + <li>Your data is portable across AT Protocol services</li> 78 + </ul> 79 + <p> 80 + We also maintain a local index of annotations to provide faster 81 + search and discovery features. 82 + </p> 83 + </section> 84 + 85 + <section> 86 + <h2>Data Sharing</h2> 87 + <p> 88 + <strong>We do not sell your data.</strong> We do not share your data 89 + with third parties for advertising or marketing purposes. 90 + </p> 91 + <p>Your public annotations may be visible to:</p> 92 + <ul> 93 + <li>Other Margin users viewing the same webpage</li> 94 + <li>Anyone on the AT Protocol network (for public content)</li> 95 + </ul> 96 + </section> 97 + 98 + <section> 99 + <h2>Browser Extension Permissions</h2> 100 + <p>The Margin browser extension requires certain permissions:</p> 101 + <ul> 102 + <li> 103 + <strong>All URLs:</strong> To display and create annotations on 104 + any webpage 105 + </li> 106 + <li> 107 + <strong>Storage:</strong> To save your preferences and session 108 + locally 109 + </li> 110 + <li> 111 + <strong>Cookies:</strong> To maintain your logged-in session 112 + </li> 113 + <li> 114 + <strong>Tabs:</strong> To know which page you're viewing 115 + </li> 116 + </ul> 117 + </section> 118 + 119 + <section> 120 + <h2>Your Rights</h2> 121 + <p>You can:</p> 122 + <ul> 123 + <li> 124 + Delete any annotation, highlight, or bookmark you've created 125 + </li> 126 + <li>Delete your collections</li> 127 + <li>Export your data from your PDS</li> 128 + <li>Revoke the extension's access at any time</li> 129 + </ul> 130 + </section> 131 + 132 + <section> 133 + <h2>Contact</h2> 134 + <p> 135 + For privacy questions or concerns, contact us at{" "} 136 + <a href="mailto:hello@margin.at">hello@margin.at</a> 137 + </p> 138 + </section> 139 + </div> 140 + </div> 141 + ); 142 + }
+280
web/src/pages/Profile.jsx
··· 1 + import { useState, useEffect } from "react"; 2 + import { useParams } from "react-router-dom"; 3 + import AnnotationCard, { HighlightCard } from "../components/AnnotationCard"; 4 + import BookmarkCard from "../components/BookmarkCard"; 5 + import { 6 + getUserAnnotations, 7 + getUserHighlights, 8 + getUserBookmarks, 9 + getCollections, 10 + } from "../api/client"; 11 + import CollectionIcon from "../components/CollectionIcon"; 12 + import CollectionRow from "../components/CollectionRow"; 13 + import { 14 + PenIcon, 15 + HighlightIcon, 16 + BookmarkIcon, 17 + BlueskyIcon, 18 + } from "../components/Icons"; 19 + 20 + export default function Profile() { 21 + const { handle } = useParams(); 22 + const [activeTab, setActiveTab] = useState("annotations"); 23 + const [profile, setProfile] = useState(null); 24 + const [annotations, setAnnotations] = useState([]); 25 + const [highlights, setHighlights] = useState([]); 26 + const [bookmarks, setBookmarks] = useState([]); 27 + const [collections, setCollections] = useState([]); 28 + const [loading, setLoading] = useState(true); 29 + const [error, setError] = useState(null); 30 + 31 + useEffect(() => { 32 + async function fetchProfile() { 33 + try { 34 + setLoading(true); 35 + 36 + const profileRes = await fetch( 37 + `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`, 38 + ); 39 + let did = handle; 40 + if (profileRes.ok) { 41 + const profileData = await profileRes.json(); 42 + setProfile(profileData); 43 + did = profileData.did; 44 + } 45 + 46 + const [annData, hlData, bmData, collData] = await Promise.all([ 47 + getUserAnnotations(did), 48 + getUserHighlights(did).catch(() => ({ items: [] })), 49 + getUserBookmarks(did).catch(() => ({ items: [] })), 50 + getCollections(did).catch(() => ({ items: [] })), 51 + ]); 52 + setAnnotations(annData.items || []); 53 + setHighlights(hlData.items || []); 54 + setBookmarks(bmData.items || []); 55 + setCollections(collData.items || []); 56 + } catch (err) { 57 + setError(err.message); 58 + } finally { 59 + setLoading(false); 60 + } 61 + } 62 + fetchProfile(); 63 + }, [handle]); 64 + 65 + const displayName = profile?.displayName || profile?.handle || handle; 66 + const displayHandle = 67 + profile?.handle || (handle?.startsWith("did:") ? null : handle); 68 + const avatarUrl = profile?.avatar; 69 + 70 + const getInitial = () => { 71 + return (displayName || displayHandle || "??") 72 + ?.substring(0, 2) 73 + .toUpperCase(); 74 + }; 75 + 76 + const totalItems = 77 + annotations.length + 78 + highlights.length + 79 + bookmarks.length + 80 + collections.length; 81 + 82 + const renderContent = () => { 83 + if (activeTab === "annotations") { 84 + if (annotations.length === 0) { 85 + return ( 86 + <div className="empty-state"> 87 + <div className="empty-state-icon"> 88 + <PenIcon size={32} /> 89 + </div> 90 + <h3 className="empty-state-title">No annotations</h3> 91 + <p className="empty-state-text"> 92 + This user hasn't posted any annotations. 93 + </p> 94 + </div> 95 + ); 96 + } 97 + return annotations.map((a) => ( 98 + <AnnotationCard key={a.id} annotation={a} /> 99 + )); 100 + } 101 + 102 + if (activeTab === "highlights") { 103 + if (highlights.length === 0) { 104 + return ( 105 + <div className="empty-state"> 106 + <div className="empty-state-icon"> 107 + <HighlightIcon size={32} /> 108 + </div> 109 + <h3 className="empty-state-title">No highlights</h3> 110 + <p className="empty-state-text"> 111 + This user hasn't saved any highlights. 112 + </p> 113 + </div> 114 + ); 115 + } 116 + return highlights.map((h) => <HighlightCard key={h.id} highlight={h} />); 117 + } 118 + 119 + if (activeTab === "bookmarks") { 120 + if (bookmarks.length === 0) { 121 + return ( 122 + <div className="empty-state"> 123 + <div className="empty-state-icon"> 124 + <BookmarkIcon size={32} /> 125 + </div> 126 + <h3 className="empty-state-title">No bookmarks</h3> 127 + <p className="empty-state-text"> 128 + This user hasn't bookmarked any pages. 129 + </p> 130 + </div> 131 + ); 132 + } 133 + return bookmarks.map((b) => <BookmarkCard key={b.id} annotation={b} />); 134 + } 135 + if (activeTab === "bookmarks") { 136 + if (bookmarks.length === 0) { 137 + return ( 138 + <div className="empty-state"> 139 + <div className="empty-state-icon"> 140 + <BookmarkIcon size={32} /> 141 + </div> 142 + <h3 className="empty-state-title">No bookmarks</h3> 143 + <p className="empty-state-text"> 144 + This user hasn't bookmarked any pages. 145 + </p> 146 + </div> 147 + ); 148 + } 149 + return bookmarks.map((b) => <BookmarkCard key={b.id} annotation={b} />); 150 + } 151 + 152 + if (activeTab === "collections") { 153 + if (collections.length === 0) { 154 + return ( 155 + <div className="empty-state"> 156 + <div className="empty-state-icon"> 157 + <CollectionIcon icon="folder" size={32} /> 158 + </div> 159 + <h3 className="empty-state-title">No collections</h3> 160 + <p className="empty-state-text"> 161 + This user hasn't created any collections. 162 + </p> 163 + </div> 164 + ); 165 + } 166 + return ( 167 + <div className="collections-list"> 168 + {collections.map((c) => ( 169 + <CollectionRow key={c.uri} collection={c} /> 170 + ))} 171 + </div> 172 + ); 173 + } 174 + }; 175 + 176 + const bskyProfileUrl = displayHandle 177 + ? `https://bsky.app/profile/${displayHandle}` 178 + : `https://bsky.app/profile/${handle}`; 179 + 180 + return ( 181 + <div className="profile-page"> 182 + <header className="profile-header"> 183 + <a 184 + href={bskyProfileUrl} 185 + target="_blank" 186 + rel="noopener noreferrer" 187 + className="profile-avatar-link" 188 + > 189 + <div className="profile-avatar"> 190 + {avatarUrl ? ( 191 + <img src={avatarUrl} alt={displayName} /> 192 + ) : ( 193 + <span>{getInitial()}</span> 194 + )} 195 + </div> 196 + </a> 197 + <div className="profile-info"> 198 + <h1 className="profile-name">{displayName}</h1> 199 + {displayHandle && ( 200 + <a 201 + href={bskyProfileUrl} 202 + target="_blank" 203 + rel="noopener noreferrer" 204 + className="profile-bluesky-link" 205 + > 206 + <BlueskyIcon size={16} />@{displayHandle} 207 + </a> 208 + )} 209 + <div className="profile-stats"> 210 + <span className="profile-stat"> 211 + <strong>{totalItems}</strong> items 212 + </span> 213 + <span className="profile-stat"> 214 + <strong>{annotations.length}</strong> annotations 215 + </span> 216 + <span className="profile-stat"> 217 + <strong>{highlights.length}</strong> highlights 218 + </span> 219 + </div> 220 + </div> 221 + </header> 222 + 223 + <div className="profile-tabs"> 224 + <button 225 + className={`profile-tab ${activeTab === "annotations" ? "active" : ""}`} 226 + onClick={() => setActiveTab("annotations")} 227 + > 228 + Annotations ({annotations.length}) 229 + </button> 230 + <button 231 + className={`profile-tab ${activeTab === "highlights" ? "active" : ""}`} 232 + onClick={() => setActiveTab("highlights")} 233 + > 234 + Highlights ({highlights.length}) 235 + </button> 236 + <button 237 + className={`profile-tab ${activeTab === "bookmarks" ? "active" : ""}`} 238 + onClick={() => setActiveTab("bookmarks")} 239 + > 240 + Bookmarks ({bookmarks.length}) 241 + </button> 242 + 243 + <button 244 + className={`profile-tab ${activeTab === "collections" ? "active" : ""}`} 245 + onClick={() => setActiveTab("collections")} 246 + > 247 + Collections ({collections.length}) 248 + </button> 249 + </div> 250 + 251 + {loading && ( 252 + <div className="feed"> 253 + {[1, 2, 3].map((i) => ( 254 + <div key={i} className="card"> 255 + <div 256 + className="skeleton skeleton-text" 257 + style={{ width: "40%" }} 258 + /> 259 + <div className="skeleton skeleton-text" /> 260 + <div 261 + className="skeleton skeleton-text" 262 + style={{ width: "60%" }} 263 + /> 264 + </div> 265 + ))} 266 + </div> 267 + )} 268 + 269 + {error && ( 270 + <div className="empty-state"> 271 + <div className="empty-state-icon">⚠️</div> 272 + <h3 className="empty-state-title">Error loading profile</h3> 273 + <p className="empty-state-text">{error}</p> 274 + </div> 275 + )} 276 + 277 + {!loading && !error && <div className="feed">{renderContent()}</div>} 278 + </div> 279 + ); 280 + }
+136
web/src/pages/Url.jsx
··· 1 + import { useState } from "react"; 2 + import AnnotationCard, { HighlightCard } from "../components/AnnotationCard"; 3 + import { getByTarget } from "../api/client"; 4 + import { PenIcon, AlertIcon, SearchIcon } from "../components/Icons"; 5 + 6 + export default function Url() { 7 + const [url, setUrl] = useState(""); 8 + const [annotations, setAnnotations] = useState([]); 9 + const [highlights, setHighlights] = useState([]); 10 + const [loading, setLoading] = useState(false); 11 + const [searched, setSearched] = useState(false); 12 + const [error, setError] = useState(null); 13 + const [activeTab, setActiveTab] = useState("all"); 14 + 15 + const handleSearch = async (e) => { 16 + e.preventDefault(); 17 + if (!url.trim()) return; 18 + 19 + try { 20 + setLoading(true); 21 + setError(null); 22 + setSearched(true); 23 + const data = await getByTarget(url); 24 + setAnnotations(data.annotations || []); 25 + setHighlights(data.highlights || []); 26 + } catch (err) { 27 + setError(err.message); 28 + } finally { 29 + setLoading(false); 30 + } 31 + }; 32 + 33 + const totalItems = annotations.length + highlights.length; 34 + 35 + const renderResults = () => { 36 + if (activeTab === "annotations" && annotations.length === 0) { 37 + return ( 38 + <div className="empty-state"> 39 + <div className="empty-state-icon"> 40 + <PenIcon size={32} /> 41 + </div> 42 + <h3 className="empty-state-title">No annotations</h3> 43 + </div> 44 + ); 45 + } 46 + 47 + return ( 48 + <> 49 + {(activeTab === "all" || activeTab === "annotations") && 50 + annotations.map((a) => <AnnotationCard key={a.id} annotation={a} />)} 51 + {(activeTab === "all" || activeTab === "highlights") && 52 + highlights.map((h) => <HighlightCard key={h.id} highlight={h} />)} 53 + </> 54 + ); 55 + }; 56 + 57 + return ( 58 + <div className="url-page"> 59 + <div className="page-header"> 60 + <h1 className="page-title">Browse by URL</h1> 61 + <p className="page-description"> 62 + See annotations and highlights for any webpage 63 + </p> 64 + </div> 65 + 66 + <form onSubmit={handleSearch} className="url-input-wrapper"> 67 + <div className="url-input-container"> 68 + <input 69 + type="url" 70 + value={url} 71 + onChange={(e) => setUrl(e.target.value)} 72 + placeholder="https://example.com/article" 73 + className="url-input" 74 + required 75 + /> 76 + <button type="submit" className="btn btn-primary" disabled={loading}> 77 + {loading ? "Searching..." : "Search"} 78 + </button> 79 + </div> 80 + </form> 81 + 82 + {error && ( 83 + <div className="empty-state"> 84 + <div className="empty-state-icon"> 85 + <AlertIcon size={32} /> 86 + </div> 87 + <h3 className="empty-state-title">Error</h3> 88 + <p className="empty-state-text">{error}</p> 89 + </div> 90 + )} 91 + 92 + {searched && !loading && !error && totalItems === 0 && ( 93 + <div className="empty-state"> 94 + <div className="empty-state-icon"> 95 + <SearchIcon size={32} /> 96 + </div> 97 + <h3 className="empty-state-title">No annotations found</h3> 98 + <p className="empty-state-text"> 99 + Be the first to annotate this URL! Sign in to add your thoughts. 100 + </p> 101 + </div> 102 + )} 103 + 104 + {searched && totalItems > 0 && ( 105 + <> 106 + <div className="url-results-header"> 107 + <h2 className="feed-title"> 108 + {totalItems} item{totalItems !== 1 ? "s" : ""} 109 + </h2> 110 + <div className="feed-filters"> 111 + <button 112 + className={`filter-tab ${activeTab === "all" ? "active" : ""}`} 113 + onClick={() => setActiveTab("all")} 114 + > 115 + All ({totalItems}) 116 + </button> 117 + <button 118 + className={`filter-tab ${activeTab === "annotations" ? "active" : ""}`} 119 + onClick={() => setActiveTab("annotations")} 120 + > 121 + Annotations ({annotations.length}) 122 + </button> 123 + <button 124 + className={`filter-tab ${activeTab === "highlights" ? "active" : ""}`} 125 + onClick={() => setActiveTab("highlights")} 126 + > 127 + Highlights ({highlights.length}) 128 + </button> 129 + </div> 130 + </div> 131 + <div className="feed">{renderResults()}</div> 132 + </> 133 + )} 134 + </div> 135 + ); 136 + }
+23
web/vite.config.js
··· 1 + import { defineConfig } from "vite"; 2 + import react from "@vitejs/plugin-react"; 3 + 4 + export default defineConfig({ 5 + plugins: [react()], 6 + build: { 7 + outDir: "dist", 8 + emptyOutDir: true, 9 + }, 10 + server: { 11 + port: 3000, 12 + proxy: { 13 + "/api": { 14 + target: "http://localhost:8080", 15 + changeOrigin: true, 16 + }, 17 + "/auth": { 18 + target: "http://localhost:8080", 19 + changeOrigin: true, 20 + }, 21 + }, 22 + }, 23 + });