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

various backend and styling fixes and refactors

+8919 -6192
+2
.gitignore
··· 19 19 # Database 20 20 *.db 21 21 *.sqlite 22 + *.db-wal 23 + *.db-shm 22 24 23 25 # Keys 24 26 oauth_private_key.pem
+30 -1
backend/internal/api/apikey.go
··· 59 59 keyHash := hashAPIKey(rawKey) 60 60 keyID := generateKeyID() 61 61 62 + record := xrpc.NewAPIKeyRecord(req.Name, keyHash) 63 + if err := record.Validate(); err != nil { 64 + http.Error(w, "Validation error: "+err.Error(), http.StatusBadRequest) 65 + return 66 + } 67 + 68 + var result *xrpc.CreateRecordOutput 69 + err = h.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 70 + var createErr error 71 + result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionAPIKey, record) 72 + return createErr 73 + }) 74 + if err != nil { 75 + http.Error(w, "Failed to create key record: "+err.Error(), http.StatusInternalServerError) 76 + return 77 + } 78 + 79 + cid := result.CID 80 + 62 81 apiKey := &db.APIKey{ 63 82 ID: keyID, 64 83 OwnerDID: session.DID, 65 84 Name: req.Name, 66 85 KeyHash: keyHash, 67 86 CreatedAt: time.Now(), 87 + URI: result.URI, 88 + CID: &cid, 89 + IndexedAt: time.Now(), 68 90 } 69 91 70 92 if err := h.db.CreateAPIKey(apiKey); err != nil { ··· 115 137 return 116 138 } 117 139 118 - if err := h.db.DeleteAPIKey(keyID, session.DID); err != nil { 140 + uri, err := h.db.DeleteAPIKey(keyID, session.DID) 141 + if err != nil { 119 142 http.Error(w, "Failed to delete key", http.StatusInternalServerError) 120 143 return 144 + } 145 + 146 + if uri != "" { 147 + h.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 148 + return client.DeleteRecord(r.Context(), did, xrpc.CollectionAPIKey, strings.Split(uri, "/")[len(strings.Split(uri, "/"))-1]) 149 + }) 121 150 } 122 151 123 152 w.Header().Set("Content-Type", "application/json")
+166 -33
backend/internal/api/handler.go
··· 90 90 91 91 r.Post("/quick/bookmark", h.apiKeys.QuickBookmark) 92 92 r.Post("/quick/save", h.apiKeys.QuickSave) 93 + 94 + r.Get("/preferences", h.GetPreferences) 95 + r.Put("/preferences", h.UpdatePreferences) 93 96 }) 94 97 } 95 98 ··· 148 151 149 152 viewerDID := h.getViewerDID(r) 150 153 151 - if viewerDID != "" && (creator == viewerDID || (creator == "" && tag == "" && feedType == "my-feed")) { 152 - if creator == viewerDID { 153 - h.serveUserFeedFromPDS(w, r, viewerDID, tag, limit, offset) 154 - return 155 - } 154 + if viewerDID != "" && (feedType == "my-feed" || creator == viewerDID) { 155 + h.serveUserFeedFromPDS(w, r, viewerDID, tag, r.URL.Query().Get("motivation"), limit, offset) 156 + return 156 157 } 157 158 158 159 var annotations []db.Annotation ··· 322 323 323 324 authCollectionItems, _ := hydrateCollectionItems(h.db, collectionItems, viewerDID) 324 325 326 + collectionItemURIs := make(map[string]string) 327 + for _, ci := range authCollectionItems { 328 + var annotationURI string 329 + if ci.Annotation != nil { 330 + annotationURI = ci.Annotation.ID 331 + } else if ci.Highlight != nil { 332 + annotationURI = ci.Highlight.ID 333 + } else if ci.Bookmark != nil { 334 + annotationURI = ci.Bookmark.ID 335 + } 336 + if annotationURI != "" { 337 + collectionItemURIs[annotationURI] = ci.Author.DID 338 + } 339 + } 340 + 325 341 var feed []interface{} 326 342 for _, a := range authAnnos { 343 + if addedBy, exists := collectionItemURIs[a.ID]; exists && addedBy == a.Author.DID { 344 + continue 345 + } 327 346 feed = append(feed, a) 328 347 } 329 348 for _, h := range authHighs { 349 + if addedBy, exists := collectionItemURIs[h.ID]; exists && addedBy == h.Author.DID { 350 + continue 351 + } 330 352 feed = append(feed, h) 331 353 } 332 354 for _, b := range authBooks { 355 + if addedBy, exists := collectionItemURIs[b.ID]; exists && addedBy == b.Author.DID { 356 + continue 357 + } 333 358 feed = append(feed, b) 334 359 } 335 360 for _, ci := range authCollectionItems { ··· 403 428 }) 404 429 } 405 430 406 - func (h *Handler) serveUserFeedFromPDS(w http.ResponseWriter, r *http.Request, did, tag string, limit, offset int) { 431 + func (h *Handler) serveUserFeedFromPDS(w http.ResponseWriter, r *http.Request, did, tag, motivation string, limit, offset int) { 407 432 var wg sync.WaitGroup 408 433 var rawAnnos, rawHighs, rawBooks []interface{} 409 434 var errAnnos, errHighs, errBooks error ··· 413 438 fetchLimit = 50 414 439 } 415 440 416 - wg.Add(3) 417 - go func() { 418 - defer wg.Done() 419 - rawAnnos, errAnnos = h.FetchLatestUserRecords(r, did, xrpc.CollectionAnnotation, fetchLimit) 420 - }() 421 - go func() { 422 - defer wg.Done() 423 - rawHighs, errHighs = h.FetchLatestUserRecords(r, did, xrpc.CollectionHighlight, fetchLimit) 424 - }() 425 - go func() { 426 - defer wg.Done() 427 - rawBooks, errBooks = h.FetchLatestUserRecords(r, did, xrpc.CollectionBookmark, fetchLimit) 428 - }() 441 + if motivation == "" || motivation == "commenting" { 442 + wg.Add(1) 443 + go func() { 444 + defer wg.Done() 445 + rawAnnos, errAnnos = h.FetchLatestUserRecords(r, did, xrpc.CollectionAnnotation, fetchLimit) 446 + }() 447 + } 448 + if motivation == "" || motivation == "highlighting" { 449 + wg.Add(1) 450 + go func() { 451 + defer wg.Done() 452 + rawHighs, errHighs = h.FetchLatestUserRecords(r, did, xrpc.CollectionHighlight, fetchLimit) 453 + }() 454 + } 455 + if motivation == "" || motivation == "bookmarking" { 456 + wg.Add(1) 457 + go func() { 458 + defer wg.Done() 459 + rawBooks, errBooks = h.FetchLatestUserRecords(r, did, xrpc.CollectionBookmark, fetchLimit) 460 + }() 461 + } 429 462 wg.Wait() 430 463 431 464 if errAnnos != nil { ··· 477 510 }() 478 511 479 512 collectionItems := []db.CollectionItem{} 480 - if tag == "" { 513 + if tag == "" && motivation == "" { 481 514 items, err := h.db.GetCollectionItemsByAuthor(did) 482 515 if err != nil { 483 516 log.Printf("Error fetching collection items for user feed: %v", err) ··· 515 548 for _, b := range authBooks { 516 549 feed = append(feed, b) 517 550 } 518 - for _, ci := range authCollectionItems { 519 - feed = append(feed, ci) 551 + if motivation == "" { 552 + for _, ci := range authCollectionItems { 553 + feed = append(feed, ci) 554 + } 520 555 } 521 556 522 557 sortFeed(feed) ··· 1235 1270 } 1236 1271 1237 1272 func (h *Handler) GetURLMetadata(w http.ResponseWriter, r *http.Request) { 1238 - url := r.URL.Query().Get("url") 1239 - if url == "" { 1273 + targetURL := r.URL.Query().Get("url") 1274 + if targetURL == "" { 1240 1275 http.Error(w, "url parameter required", http.StatusBadRequest) 1241 1276 return 1242 1277 } 1243 1278 1244 1279 client := &http.Client{Timeout: 10 * time.Second} 1245 - resp, err := client.Get(url) 1280 + resp, err := client.Get(targetURL) 1246 1281 if err != nil { 1247 1282 w.Header().Set("Content-Type", "application/json") 1248 1283 json.NewEncoder(w).Encode(map[string]string{"title": "", "error": "failed to fetch"}) ··· 1250 1285 } 1251 1286 defer resp.Body.Close() 1252 1287 1253 - body, err := io.ReadAll(io.LimitReader(resp.Body, 100*1024)) 1288 + body, err := io.ReadAll(io.LimitReader(resp.Body, 500*1024)) 1254 1289 if err != nil { 1255 1290 w.Header().Set("Content-Type", "application/json") 1256 1291 json.NewEncoder(w).Encode(map[string]string{"title": ""}) 1257 1292 return 1258 1293 } 1259 1294 1260 - title := "" 1261 - htmlStr := string(body) 1262 - if idx := strings.Index(strings.ToLower(htmlStr), "<title>"); idx != -1 { 1263 - start := idx + 7 1264 - if endIdx := strings.Index(strings.ToLower(htmlStr[start:]), "</title>"); endIdx != -1 { 1265 - title = strings.TrimSpace(htmlStr[start : start+endIdx]) 1295 + content := string(body) 1296 + 1297 + extract := func(key string) string { 1298 + attr := fmt.Sprintf("property=\"og:%s\"", key) 1299 + if idx := strings.Index(content, attr); idx != -1 { 1300 + rest := content[idx:] 1301 + if contentIdx := strings.Index(rest, "content=\""); contentIdx != -1 { 1302 + start := contentIdx + 9 1303 + if end := strings.Index(rest[start:], "\""); end != -1 { 1304 + return rest[start : start+end] 1305 + } 1306 + } 1266 1307 } 1308 + 1309 + attr = fmt.Sprintf("name=\"%s\"", key) 1310 + if idx := strings.Index(content, attr); idx != -1 { 1311 + rest := content[idx:] 1312 + if contentIdx := strings.Index(rest, "content=\""); contentIdx != -1 { 1313 + start := contentIdx + 9 1314 + if end := strings.Index(rest[start:], "\""); end != -1 { 1315 + return rest[start : start+end] 1316 + } 1317 + } 1318 + } 1319 + return "" 1320 + } 1321 + 1322 + title := extract("title") 1323 + if title == "" { 1324 + if idx := strings.Index(content, "<title>"); idx != -1 { 1325 + start := idx + 7 1326 + if end := strings.Index(content[start:], "</title>"); end != -1 { 1327 + title = content[start : start+end] 1328 + } 1329 + } 1330 + } 1331 + 1332 + description := extract("description") 1333 + image := extract("image") 1334 + 1335 + var favicon string 1336 + findIcon := func(rel string) string { 1337 + search := fmt.Sprintf("rel=\"%s\"", rel) 1338 + if idx := strings.Index(content, search); idx != -1 { 1339 + startTag := strings.LastIndex(content[:idx], "<link") 1340 + if startTag != -1 { 1341 + endTag := strings.Index(content[startTag:], ">") 1342 + if endTag != -1 { 1343 + tag := content[startTag : startTag+endTag] 1344 + if hrefIdx := strings.Index(tag, "href=\""); hrefIdx != -1 { 1345 + start := hrefIdx + 6 1346 + if end := strings.Index(tag[start:], "\""); end != -1 { 1347 + return tag[start : start+end] 1348 + } 1349 + } 1350 + } 1351 + } 1352 + } 1353 + return "" 1354 + } 1355 + 1356 + favicon = findIcon("icon") 1357 + if favicon == "" { 1358 + favicon = findIcon("shortcut icon") 1359 + } 1360 + if favicon == "" { 1361 + favicon = findIcon("apple-touch-icon") 1362 + } 1363 + 1364 + resolveURL := func(base, target string) string { 1365 + if target == "" { 1366 + return "" 1367 + } 1368 + if strings.HasPrefix(target, "http") { 1369 + return target 1370 + } 1371 + if strings.HasPrefix(target, "//") { 1372 + return "https:" + target 1373 + } 1374 + u, err := url.Parse(base) 1375 + if err != nil { 1376 + return target 1377 + } 1378 + t, err := url.Parse(target) 1379 + if err != nil { 1380 + return target 1381 + } 1382 + return u.ResolveReference(t).String() 1383 + } 1384 + 1385 + image = resolveURL(targetURL, image) 1386 + favicon = resolveURL(targetURL, favicon) 1387 + 1388 + if favicon == "" { 1389 + u, err := url.Parse(targetURL) 1390 + if err == nil { 1391 + favicon = fmt.Sprintf("%s://%s/favicon.ico", u.Scheme, u.Host) 1392 + } 1393 + } 1394 + 1395 + data := map[string]string{ 1396 + "title": title, 1397 + "description": description, 1398 + "image": image, 1399 + "icon": favicon, 1267 1400 } 1268 1401 1269 1402 w.Header().Set("Content-Type", "application/json") 1270 - json.NewEncoder(w).Encode(map[string]string{"title": title, "url": url}) 1403 + json.NewEncoder(w).Encode(data) 1271 1404 } 1272 1405 1273 1406 func (h *Handler) GetNotifications(w http.ResponseWriter, r *http.Request) {
+7 -3
backend/internal/api/hydration.go
··· 81 81 type APIHighlight struct { 82 82 ID string `json:"id"` 83 83 Type string `json:"type"` 84 + Motivation string `json:"motivation"` 84 85 Author Author `json:"creator"` 85 86 Target APITarget `json:"target"` 86 87 Color string `json:"color,omitempty"` ··· 95 96 type APIBookmark struct { 96 97 ID string `json:"id"` 97 98 Type string `json:"type"` 99 + Motivation string `json:"motivation"` 98 100 Author Author `json:"creator"` 99 101 Source string `json:"source"` 100 102 Title string `json:"title,omitempty"` ··· 320 322 } 321 323 322 324 result[i] = APIHighlight{ 323 - ID: h.URI, 324 - Type: "Highlight", 325 - Author: profiles[h.AuthorDID], 325 + ID: h.URI, 326 + Type: "Highlight", 327 + Motivation: "highlighting", 328 + Author: profiles[h.AuthorDID], 326 329 Target: APITarget{ 327 330 Source: h.TargetSource, 328 331 Title: title, ··· 385 388 result[i] = APIBookmark{ 386 389 ID: b.URI, 387 390 Type: "Bookmark", 391 + Motivation: "bookmarking", 388 392 Author: profiles[b.AuthorDID], 389 393 Source: b.Source, 390 394 Title: title,
+56 -1
backend/internal/api/pds.go
··· 4 4 "encoding/json" 5 5 "fmt" 6 6 "net/http" 7 + "strings" 7 8 "time" 8 9 9 10 "margin.at/internal/db" ··· 50 51 for _, rec := range output.Records { 51 52 parsed, err := parseRecord(did, collection, rec.URI, rec.CID, rec.Value) 52 53 if err == nil && parsed != nil { 54 + switch v := parsed.(type) { 55 + case *db.Annotation: 56 + h.db.CreateAnnotation(v) 57 + case *db.Highlight: 58 + h.db.CreateHighlight(v) 59 + case *db.Bookmark: 60 + h.db.CreateBookmark(v) 61 + case *db.APIKey: 62 + h.db.CreateAPIKey(v) 63 + case *db.Preferences: 64 + } 53 65 results = append(results, parsed) 54 66 } 55 67 } ··· 165 177 tagsStr := string(tagsBytes) 166 178 tagsJSONPtr = &tagsStr 167 179 } 168 - 169 180 return &db.Highlight{ 170 181 URI: uri, 171 182 AuthorDID: did, ··· 179 190 IndexedAt: time.Now(), 180 191 CID: cidPtr, 181 192 }, nil 193 + case xrpc.CollectionAPIKey: 194 + var record xrpc.APIKeyRecord 195 + if err := json.Unmarshal(value, &record); err != nil { 196 + return nil, fmt.Errorf("failed to unmarshal api key record: %v", err) 197 + } 198 + 199 + createdAt, _ := time.Parse(time.RFC3339, record.CreatedAt) 200 + 201 + apiKey := &db.APIKey{ 202 + ID: strings.Split(uri, "/")[len(strings.Split(uri, "/"))-1], 203 + OwnerDID: did, 204 + Name: record.Name, 205 + KeyHash: record.KeyHash, 206 + CreatedAt: createdAt, 207 + URI: uri, 208 + CID: cidPtr, 209 + IndexedAt: time.Now(), 210 + } 211 + 212 + return apiKey, nil 182 213 183 214 case xrpc.CollectionBookmark: 184 215 var record xrpc.BookmarkRecord ··· 219 250 CreatedAt: createdAt, 220 251 IndexedAt: time.Now(), 221 252 CID: cidPtr, 253 + }, nil 254 + 255 + case xrpc.CollectionPreferences: 256 + var record xrpc.PreferencesRecord 257 + if err := json.Unmarshal(value, &record); err != nil { 258 + return nil, err 259 + } 260 + 261 + createdAt, _ := time.Parse(time.RFC3339, record.CreatedAt) 262 + 263 + var skippedHostnamesJSONPtr *string 264 + if len(record.ExternalLinkSkippedHostnames) > 0 { 265 + b, _ := json.Marshal(record.ExternalLinkSkippedHostnames) 266 + s := string(b) 267 + skippedHostnamesJSONPtr = &s 268 + } 269 + 270 + return &db.Preferences{ 271 + URI: uri, 272 + AuthorDID: did, 273 + ExternalLinkSkippedHostnames: skippedHostnamesJSONPtr, 274 + CreatedAt: createdAt, 275 + IndexedAt: time.Now(), 276 + CID: cidPtr, 222 277 }, nil 223 278 } 224 279
+124
backend/internal/api/preferences.go
··· 1 + package api 2 + 3 + import ( 4 + "bytes" 5 + "encoding/json" 6 + "fmt" 7 + "net/http" 8 + "time" 9 + 10 + "margin.at/internal/db" 11 + "margin.at/internal/xrpc" 12 + ) 13 + 14 + type PreferencesResponse struct { 15 + ExternalLinkSkippedHostnames []string `json:"externalLinkSkippedHostnames"` 16 + } 17 + 18 + func (h *Handler) GetPreferences(w http.ResponseWriter, r *http.Request) { 19 + session, err := h.refresher.GetSessionWithAutoRefresh(r) 20 + if err != nil { 21 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 22 + return 23 + } 24 + 25 + prefs, err := h.db.GetPreferences(session.DID) 26 + if err != nil { 27 + http.Error(w, "Failed to fetch preferences", http.StatusInternalServerError) 28 + return 29 + } 30 + 31 + hostnames := []string{} 32 + if prefs != nil && prefs.ExternalLinkSkippedHostnames != nil { 33 + json.Unmarshal([]byte(*prefs.ExternalLinkSkippedHostnames), &hostnames) 34 + } 35 + 36 + w.Header().Set("Content-Type", "application/json") 37 + json.NewEncoder(w).Encode(PreferencesResponse{ 38 + ExternalLinkSkippedHostnames: hostnames, 39 + }) 40 + } 41 + 42 + func (h *Handler) UpdatePreferences(w http.ResponseWriter, r *http.Request) { 43 + session, err := h.refresher.GetSessionWithAutoRefresh(r) 44 + if err != nil { 45 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 46 + return 47 + } 48 + 49 + var input PreferencesResponse 50 + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { 51 + http.Error(w, "Invalid input", http.StatusBadRequest) 52 + return 53 + } 54 + 55 + record := xrpc.NewPreferencesRecord(input.ExternalLinkSkippedHostnames) 56 + if err := record.Validate(); err != nil { 57 + http.Error(w, fmt.Sprintf("Invalid record: %v", err), http.StatusBadRequest) 58 + return 59 + } 60 + 61 + err = h.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, _ string) error { 62 + url := fmt.Sprintf("%s/xrpc/com.atproto.repo.putRecord", client.PDS) 63 + 64 + body := map[string]interface{}{ 65 + "repo": session.DID, 66 + "collection": xrpc.CollectionPreferences, 67 + "rkey": "self", 68 + "record": record, 69 + } 70 + 71 + jsonBody, err := json.Marshal(body) 72 + if err != nil { 73 + return err 74 + } 75 + 76 + req, err := http.NewRequestWithContext(r.Context(), "POST", url, bytes.NewBuffer(jsonBody)) 77 + if err != nil { 78 + return err 79 + } 80 + req.Header.Set("Authorization", "Bearer "+client.AccessToken) 81 + req.Header.Set("Content-Type", "application/json") 82 + 83 + resp, err := http.DefaultClient.Do(req) 84 + if err != nil { 85 + return err 86 + } 87 + defer resp.Body.Close() 88 + 89 + if resp.StatusCode != 200 { 90 + var errResp struct { 91 + Error string `json:"error"` 92 + Message string `json:"message"` 93 + } 94 + json.NewDecoder(resp.Body).Decode(&errResp) 95 + return fmt.Errorf("XRPC error %d: %s - %s", resp.StatusCode, errResp.Error, errResp.Message) 96 + } 97 + 98 + return nil 99 + }) 100 + 101 + if err != nil { 102 + http.Error(w, fmt.Sprintf("Failed to update preferences: %v", err), http.StatusInternalServerError) 103 + return 104 + } 105 + 106 + createdAt, _ := time.Parse(time.RFC3339, record.CreatedAt) 107 + hostnamesJSON, _ := json.Marshal(input.ExternalLinkSkippedHostnames) 108 + hostnamesStr := string(hostnamesJSON) 109 + uri := fmt.Sprintf("at://%s/%s/self", session.DID, xrpc.CollectionPreferences) 110 + 111 + err = h.db.UpsertPreferences(&db.Preferences{ 112 + URI: uri, 113 + AuthorDID: session.DID, 114 + ExternalLinkSkippedHostnames: &hostnamesStr, 115 + CreatedAt: createdAt, 116 + IndexedAt: time.Now(), 117 + }) 118 + 119 + if err != nil { 120 + fmt.Printf("Failed to update local db preferences: %v\n", err) 121 + } 122 + 123 + w.WriteHeader(http.StatusOK) 124 + }
+9 -3
backend/internal/api/profile.go
··· 139 139 resp := struct { 140 140 URI string `json:"uri"` 141 141 DID string `json:"did"` 142 + Handle string `json:"handle,omitempty"` 142 143 DisplayName string `json:"displayName,omitempty"` 143 144 Avatar string `json:"avatar,omitempty"` 144 - Bio string `json:"bio"` 145 - Website string `json:"website"` 145 + Description string `json:"description,omitempty"` 146 + Website string `json:"website,omitempty"` 146 147 Links []string `json:"links"` 147 148 CreatedAt string `json:"createdAt"` 148 149 IndexedAt string `json:"indexedAt"` ··· 153 154 IndexedAt: profile.IndexedAt.Format(time.RFC3339), 154 155 } 155 156 157 + var handle string 158 + if err := h.db.QueryRow("SELECT handle FROM sessions WHERE did = $1 LIMIT 1", profile.AuthorDID).Scan(&handle); err == nil { 159 + resp.Handle = handle 160 + } 161 + 156 162 if profile.DisplayName != nil { 157 163 resp.DisplayName = *profile.DisplayName 158 164 } ··· 160 166 resp.Avatar = *profile.Avatar 161 167 } 162 168 if profile.Bio != nil { 163 - resp.Bio = *profile.Bio 169 + resp.Description = *profile.Bio 164 170 } 165 171 if profile.Website != nil { 166 172 resp.Website = *profile.Website
+75 -2
backend/internal/db/db.go
··· 127 127 KeyHash string `json:"-"` 128 128 CreatedAt time.Time `json:"createdAt"` 129 129 LastUsedAt *time.Time `json:"lastUsedAt,omitempty"` 130 + URI string `json:"uri"` 131 + CID *string `json:"cid,omitempty"` 132 + IndexedAt time.Time `json:"indexedAt"` 130 133 } 131 134 132 135 type Profile struct { ··· 140 143 CreatedAt time.Time `json:"createdAt"` 141 144 IndexedAt time.Time `json:"indexedAt"` 142 145 CID *string `json:"cid,omitempty"` 146 + } 147 + 148 + type Preferences struct { 149 + URI string `json:"uri"` 150 + AuthorDID string `json:"authorDid"` 151 + ExternalLinkSkippedHostnames *string `json:"externalLinkSkippedHostnames,omitempty"` 152 + CreatedAt time.Time `json:"createdAt"` 153 + IndexedAt time.Time `json:"indexedAt"` 154 + CID *string `json:"cid,omitempty"` 143 155 } 144 156 145 157 func New(dsn string) (*DB, error) { ··· 336 348 name TEXT NOT NULL, 337 349 key_hash TEXT NOT NULL, 338 350 created_at ` + dateType + ` NOT NULL, 339 - last_used_at ` + dateType + ` 351 + last_used_at ` + dateType + `, 352 + uri TEXT, 353 + cid TEXT, 354 + indexed_at ` + dateType + ` DEFAULT CURRENT_TIMESTAMP 340 355 )`) 341 356 db.Exec(`CREATE INDEX IF NOT EXISTS idx_api_keys_owner ON api_keys(owner_did)`) 342 357 db.Exec(`CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash)`) ··· 354 369 cid TEXT 355 370 )`) 356 371 db.Exec(`CREATE INDEX IF NOT EXISTS idx_profiles_author_did ON profiles(author_did)`) 372 + 373 + db.Exec(`CREATE TABLE IF NOT EXISTS preferences ( 374 + uri TEXT PRIMARY KEY, 375 + author_did TEXT NOT NULL, 376 + external_link_skipped_hostnames TEXT, 377 + created_at ` + dateType + ` NOT NULL, 378 + indexed_at ` + dateType + ` NOT NULL, 379 + cid TEXT 380 + )`) 381 + db.Exec(`CREATE INDEX IF NOT EXISTS idx_preferences_author_did ON preferences(author_did)`) 357 382 358 383 db.runMigrations() 359 384 ··· 471 496 return err 472 497 } 473 498 474 - func (db *DB) runMigrations() { 499 + func (db *DB) DeleteAPIKey(id, ownerDID string) (string, error) { 500 + var uri string 501 + err := db.QueryRow("SELECT uri FROM api_keys WHERE id = $1 AND owner_did = $2", id, ownerDID).Scan(&uri) 502 + if err != nil { 503 + if err == sql.ErrNoRows { 504 + return "", nil 505 + } 506 + return "", err 507 + } 508 + 509 + _, err = db.Exec("DELETE FROM api_keys WHERE id = $1 AND owner_did = $2", id, ownerDID) 510 + return uri, err 511 + } 512 + 513 + func (db *DB) GetPreferences(did string) (*Preferences, error) { 514 + var p Preferences 515 + err := db.QueryRow("SELECT uri, author_did, external_link_skipped_hostnames, created_at, indexed_at, cid FROM preferences WHERE author_did = $1", did).Scan( 516 + &p.URI, &p.AuthorDID, &p.ExternalLinkSkippedHostnames, &p.CreatedAt, &p.IndexedAt, &p.CID, 517 + ) 518 + if err == sql.ErrNoRows { 519 + return nil, nil 520 + } 521 + if err != nil { 522 + return nil, err 523 + } 524 + return &p, nil 525 + } 475 526 527 + func (db *DB) UpsertPreferences(p *Preferences) error { 528 + query := ` 529 + INSERT INTO preferences (uri, author_did, external_link_skipped_hostnames, created_at, indexed_at, cid) 530 + VALUES ($1, $2, $3, $4, $5, $6) 531 + ON CONFLICT(uri) DO UPDATE SET 532 + external_link_skipped_hostnames = EXCLUDED.external_link_skipped_hostnames, 533 + indexed_at = EXCLUDED.indexed_at, 534 + cid = EXCLUDED.cid 535 + ` 536 + _, err := db.Exec(db.Rebind(query), p.URI, p.AuthorDID, p.ExternalLinkSkippedHostnames, p.CreatedAt, p.IndexedAt, p.CID) 537 + return err 538 + } 539 + 540 + func (db *DB) runMigrations() { 541 + dateType := "DATETIME" 542 + if db.driver == "postgres" { 543 + dateType = "TIMESTAMP" 544 + } 476 545 db.Exec(`ALTER TABLE sessions ADD COLUMN dpop_key TEXT`) 477 546 478 547 db.Exec(`ALTER TABLE annotations ADD COLUMN motivation TEXT`) ··· 499 568 if db.driver == "postgres" { 500 569 db.Exec(`ALTER TABLE cursors ALTER COLUMN last_cursor TYPE BIGINT`) 501 570 } 571 + 572 + db.Exec(`ALTER TABLE api_keys ADD COLUMN uri TEXT`) 573 + db.Exec(`ALTER TABLE api_keys ADD COLUMN cid TEXT`) 574 + db.Exec(`ALTER TABLE api_keys ADD COLUMN indexed_at ` + dateType + ` DEFAULT CURRENT_TIMESTAMP`) 502 575 } 503 576 504 577 func (db *DB) Close() error {
+3 -8
backend/internal/db/queries_keys.go
··· 6 6 7 7 func (db *DB) CreateAPIKey(key *APIKey) error { 8 8 _, err := db.Exec(db.Rebind(` 9 - INSERT INTO api_keys (id, owner_did, name, key_hash, created_at) 10 - VALUES (?, ?, ?, ?, ?) 11 - `), key.ID, key.OwnerDID, key.Name, key.KeyHash, key.CreatedAt) 9 + INSERT INTO api_keys (id, owner_did, name, key_hash, created_at, uri, cid, indexed_at) 10 + VALUES (?, ?, ?, ?, ?, ?, ?, ?) 11 + `), key.ID, key.OwnerDID, key.Name, key.KeyHash, key.CreatedAt, key.URI, key.CID, key.IndexedAt) 12 12 return err 13 13 } 14 14 ··· 46 46 return nil, err 47 47 } 48 48 return &k, nil 49 - } 50 - 51 - func (db *DB) DeleteAPIKey(id, ownerDID string) error { 52 - _, err := db.Exec(db.Rebind(`DELETE FROM api_keys WHERE id = ? AND owner_did = ?`), id, ownerDID) 53 - return err 54 49 } 55 50 56 51 func (db *DB) UpdateAPIKeyLastUsed(id string) error {
+54
backend/internal/xrpc/records.go
··· 16 16 CollectionCollection = "at.margin.collection" 17 17 CollectionCollectionItem = "at.margin.collectionItem" 18 18 CollectionProfile = "at.margin.profile" 19 + CollectionPreferences = "at.margin.preferences" 20 + CollectionAPIKey = "at.margin.apikey" 19 21 ) 20 22 21 23 const ( ··· 420 422 } 421 423 return nil 422 424 } 425 + 426 + type PreferencesRecord struct { 427 + Type string `json:"$type"` 428 + ExternalLinkSkippedHostnames []string `json:"externalLinkSkippedHostnames,omitempty"` 429 + CreatedAt string `json:"createdAt"` 430 + } 431 + 432 + func (r *PreferencesRecord) Validate() error { 433 + if len(r.ExternalLinkSkippedHostnames) > 100 { 434 + return fmt.Errorf("too many skipped hostnames") 435 + } 436 + for _, host := range r.ExternalLinkSkippedHostnames { 437 + if len(host) > 255 { 438 + return fmt.Errorf("hostname too long: %s", host) 439 + } 440 + } 441 + return nil 442 + } 443 + 444 + func NewPreferencesRecord(skippedHostnames []string) *PreferencesRecord { 445 + return &PreferencesRecord{ 446 + Type: CollectionPreferences, 447 + ExternalLinkSkippedHostnames: skippedHostnames, 448 + CreatedAt: time.Now().UTC().Format(time.RFC3339), 449 + } 450 + } 451 + 452 + type APIKeyRecord struct { 453 + Type string `json:"$type"` 454 + Name string `json:"name"` 455 + KeyHash string `json:"keyHash"` 456 + CreatedAt string `json:"createdAt"` 457 + } 458 + 459 + func (r *APIKeyRecord) Validate() error { 460 + if len(r.Name) > 64 { 461 + return fmt.Errorf("name too long") 462 + } 463 + if len(r.KeyHash) == 0 { 464 + return fmt.Errorf("key hash missing") 465 + } 466 + return nil 467 + } 468 + 469 + func NewAPIKeyRecord(name, keyHash string) *APIKeyRecord { 470 + return &APIKeyRecord{ 471 + Type: CollectionAPIKey, 472 + Name: name, 473 + KeyHash: keyHash, 474 + CreatedAt: time.Now().UTC().Format(time.RFC3339), 475 + } 476 + }
+30
lexicons/at/margin/apikey.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "at.margin.apikey", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "An API key hash for the Margin application.", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": ["name", "keyHash", "createdAt"], 12 + "properties": { 13 + "name": { 14 + "type": "string", 15 + "maxLength": 64, 16 + "description": "Human-readable name for the API key." 17 + }, 18 + "keyHash": { 19 + "type": "string", 20 + "description": "SHA256 hash of the API key." 21 + }, 22 + "createdAt": { 23 + "type": "string", 24 + "format": "datetime" 25 + } 26 + } 27 + } 28 + } 29 + } 30 + }
+30
lexicons/at/margin/preferences.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "at.margin.preferences", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "User preferences for the Margin application.", 8 + "key": "literal:self", 9 + "record": { 10 + "type": "object", 11 + "required": ["createdAt"], 12 + "properties": { 13 + "externalLinkSkippedHostnames": { 14 + "type": "array", 15 + "description": "List of hostnames to skip the external link warning modal for.", 16 + "items": { 17 + "type": "string", 18 + "maxLength": 255 19 + }, 20 + "maxLength": 100 21 + }, 22 + "createdAt": { 23 + "type": "string", 24 + "format": "datetime" 25 + } 26 + } 27 + } 28 + } 29 + } 30 + }
+11 -11
web/astro.config.mjs
··· 1 1 // @ts-check 2 - import { defineConfig } from 'astro/config'; 3 - import react from '@astrojs/react'; 4 - import tailwind from '@astrojs/tailwind'; 2 + import { defineConfig } from "astro/config"; 3 + import react from "@astrojs/react"; 4 + import tailwind from "@astrojs/tailwind"; 5 5 6 6 // https://astro.build/config 7 7 export default defineConfig({ ··· 9 9 vite: { 10 10 server: { 11 11 proxy: { 12 - '/api': { 13 - target: 'http://localhost:8080', 12 + "/api": { 13 + target: "http://localhost:8080", 14 14 changeOrigin: true, 15 15 }, 16 - '/auth': { 17 - target: 'http://localhost:8080', 16 + "/auth": { 17 + target: "http://localhost:8080", 18 18 changeOrigin: true, 19 19 }, 20 - } 21 - } 22 - } 23 - }); 20 + }, 21 + }, 22 + }, 23 + });
+196 -173
web/src/App.tsx
··· 1 + import React from "react"; 2 + import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; 3 + import { initAuth } from "./store/auth"; 4 + import { loadPreferences } from "./store/preferences"; 1 5 2 - import React, { useEffect } from 'react'; 3 - import { BrowserRouter, Routes, Route, Navigate, useParams } from 'react-router-dom'; 4 - import { useStore } from '@nanostores/react'; 5 - import { $user, initAuth } from './store/auth'; 6 - import { $theme } from './store/theme'; 6 + import AppLayout from "./layouts/AppLayout"; 7 + import Feed from "./views/core/Feed"; 8 + import Login from "./views/auth/Login"; 9 + import Notifications from "./views/core/Notifications"; 10 + import Collections from "./views/collections/Collections"; 11 + import Settings from "./views/core/Settings"; 12 + import UrlPage from "./views/content/Url"; 13 + import NewAnnotationPage from "./views/core/New"; 14 + import MasonryFeed from "./components/feed/MasonryFeed"; 15 + import { 16 + ProfileWrapper, 17 + SelfProfileWrapper, 18 + CollectionDetailWrapper, 19 + AnnotationDetailWrapper, 20 + UserUrlWrapper, 21 + } from "./routes/wrappers"; 7 22 8 - import AppLayout, { LandingLayout } from './layouts/AppLayout'; 9 - import Feed from './views/Feed'; 10 - import Profile from './views/Profile'; 11 - import Login from './views/Login'; 12 - import Notifications from './views/Notifications'; 13 - import MasonryFeed from './components/MasonryFeed'; 14 - import Collections from './views/Collections'; 15 - import CollectionDetail from './views/CollectionDetail'; 16 - import Settings from './views/Settings'; 17 - import UrlPage from './views/Url'; 18 - import UserUrlPage from './views/UserUrl'; 19 - import NewAnnotationPage from './views/New'; 20 - import AnnotationDetail from './views/AnnotationDetail'; 21 - import Privacy from './views/Privacy'; 22 - import Terms from './views/Terms'; 23 - 24 - 25 - const ProfileWrapper = () => { 26 - const { did } = useParams(); 27 - if (!did) return <Navigate to="/home" replace />; 28 - return <Profile did={did} />; 29 - } 30 - 31 - const SelfProfileWrapper = () => { 32 - const user = useStore($user); 33 - if (!user) return <Navigate to="/login" replace />; 34 - return <Navigate to={`/profile/${user.did}`} replace />; 35 - } 36 - 37 - const CollectionDetailWrapper = () => { 38 - const { handle, rkey } = useParams(); 39 - return <CollectionDetail handle={handle} rkey={rkey} />; 23 + function PageHeader({ title }: { title: string }) { 24 + return ( 25 + <div className="max-w-2xl mx-auto mb-6 text-center lg:text-left"> 26 + <h1 className="text-3xl font-display font-bold text-surface-900 dark:text-white"> 27 + {title} 28 + </h1> 29 + </div> 30 + ); 40 31 } 41 32 42 33 export default function App() { 43 - React.useEffect(() => { 44 - initAuth(); 45 - }, []); 34 + React.useEffect(() => { 35 + initAuth(); 36 + loadPreferences(); 37 + }, []); 46 38 47 - return ( 48 - <BrowserRouter> 49 - <Routes> 50 - <Route path="/settings" element={ 51 - <AppLayout> 52 - <Settings /> 53 - </AppLayout> 54 - } /> 39 + return ( 40 + <BrowserRouter> 41 + <Routes> 42 + <Route path="/login" element={<Login />} /> 43 + <Route path="/auth/*" element={<div>Redirecting...</div>} /> 55 44 56 - <Route path="/login" element={<Login />} /> 45 + <Route 46 + path="/home" 47 + element={ 48 + <AppLayout> 49 + <Feed initialType="all" /> 50 + </AppLayout> 51 + } 52 + /> 53 + <Route path="/my-feed" element={<Navigate to="/home" replace />} /> 57 54 58 - <Route path="/home" element={ 59 - <AppLayout> 60 - <Feed initialType="all" /> 61 - </AppLayout> 62 - } /> 55 + <Route 56 + path="/bookmarks" 57 + element={ 58 + <AppLayout> 59 + <MasonryFeed 60 + motivation="bookmarking" 61 + emptyMessage="You haven't bookmarked anything yet." 62 + showTabs={true} 63 + title="Bookmarks" 64 + /> 65 + </AppLayout> 66 + } 67 + /> 68 + <Route 69 + path="/highlights" 70 + element={ 71 + <AppLayout> 72 + <MasonryFeed 73 + motivation="highlighting" 74 + emptyMessage="You haven't highlighted anything yet." 75 + showTabs={true} 76 + title="Highlights" 77 + /> 78 + </AppLayout> 79 + } 80 + /> 63 81 64 - <Route path="/my-feed" element={<Navigate to="/home" replace />} /> 82 + <Route 83 + path="/collections" 84 + element={ 85 + <AppLayout> 86 + <Collections /> 87 + </AppLayout> 88 + } 89 + /> 90 + <Route 91 + path="/:handle/collection/:rkey" 92 + element={ 93 + <AppLayout> 94 + <CollectionDetailWrapper /> 95 + </AppLayout> 96 + } 97 + /> 98 + <Route 99 + path="/collections/:rkey" 100 + element={ 101 + <AppLayout> 102 + <CollectionDetailWrapper /> 103 + </AppLayout> 104 + } 105 + /> 65 106 66 - <Route path="/notifications" element={ 67 - <AppLayout> 68 - <Notifications /> 69 - </AppLayout> 70 - } /> 107 + <Route 108 + path="/profile/:did" 109 + element={ 110 + <AppLayout> 111 + <ProfileWrapper /> 112 + </AppLayout> 113 + } 114 + /> 115 + <Route 116 + path="/profile" 117 + element={ 118 + <AppLayout> 119 + <SelfProfileWrapper /> 120 + </AppLayout> 121 + } 122 + /> 71 123 72 - <Route path="/bookmarks" element={ 73 - <AppLayout> 74 - <div className="max-w-2xl mx-auto mb-6 text-center lg:text-left"> 75 - <h1 className="text-3xl font-display font-bold text-surface-900">Bookmarks</h1> 76 - </div> 77 - <MasonryFeed motivation="bookmarking" emptyMessage="You haven't bookmarked anything yet." /> 78 - </AppLayout> 79 - } /> 80 - 81 - <Route path="/highlights" element={ 82 - <AppLayout> 83 - <div className="max-w-2xl mx-auto mb-6 text-center lg:text-left"> 84 - <h1 className="text-3xl font-display font-bold text-surface-900">Highlights</h1> 85 - </div> 86 - <MasonryFeed motivation="highlighting" emptyMessage="You haven't highlighted anything yet." /> 87 - </AppLayout> 88 - } /> 89 - 90 - <Route path="/collections" element={ 91 - <AppLayout> 92 - <Collections /> 93 - </AppLayout> 94 - } /> 95 - 96 - <Route path="/:handle/collection/:rkey" element={ 97 - <AppLayout> 98 - <CollectionDetailWrapper /> 99 - </AppLayout> 100 - } /> 101 - 102 - <Route path="/profile/:did" element={ 103 - <AppLayout> 104 - <ProfileWrapper /> 105 - </AppLayout> 106 - } /> 107 - 108 - <Route path="/profile" element={ 109 - <AppLayout> 110 - <SelfProfileWrapper /> 111 - </AppLayout> 112 - } /> 113 - 114 - <Route path="/url" element={ 115 - <AppLayout> 116 - <UrlPage /> 117 - </AppLayout> 118 - } /> 119 - 120 - <Route path="/new" element={ 121 - <AppLayout> 122 - <NewAnnotationPage /> 123 - </AppLayout> 124 - } /> 125 - 126 - <Route path="/privacy" element={ 127 - <AppLayout> 128 - <Privacy /> 129 - </AppLayout> 130 - } /> 131 - 132 - <Route path="/terms" element={ 133 - <AppLayout> 134 - <Terms /> 135 - </AppLayout> 136 - } /> 137 - 138 - <Route path="/at/:did/:rkey" element={ 139 - <AppLayout> 140 - <AnnotationDetail /> 141 - </AppLayout> 142 - } /> 143 - 144 - <Route path="/annotation/:uri" element={ 145 - <AppLayout> 146 - <AnnotationDetail /> 147 - </AppLayout> 148 - } /> 149 - 150 - <Route path="/collections/:rkey" element={ 151 - <AppLayout> 152 - <CollectionDetail handle={undefined} rkey={undefined} /> 153 - </AppLayout> 154 - } /> 155 - 156 - <Route path="/:handle/annotation/:rkey" element={ 157 - <AppLayout> 158 - <AnnotationDetail /> 159 - </AppLayout> 160 - } /> 161 - 162 - <Route path="/:handle/highlight/:rkey" element={ 163 - <AppLayout> 164 - <AnnotationDetail /> 165 - </AppLayout> 166 - } /> 167 - 168 - <Route path="/:handle/bookmark/:rkey" element={ 169 - <AppLayout> 170 - <AnnotationDetail /> 171 - </AppLayout> 172 - } /> 173 - 174 - <Route path="/:handle/url/*" element={ 175 - <AppLayout> 176 - <UserUrlPage /> 177 - </AppLayout> 178 - } /> 179 - 180 - <Route path="/auth/*" element={<div>Redirecting...</div>} /> 124 + <Route 125 + path="/url" 126 + element={ 127 + <AppLayout> 128 + <UrlPage /> 129 + </AppLayout> 130 + } 131 + /> 132 + <Route 133 + path="/new" 134 + element={ 135 + <AppLayout> 136 + <NewAnnotationPage /> 137 + </AppLayout> 138 + } 139 + /> 140 + <Route 141 + path="/at/:did/:rkey" 142 + element={ 143 + <AppLayout> 144 + <AnnotationDetailWrapper /> 145 + </AppLayout> 146 + } 147 + /> 148 + <Route 149 + path="/annotation/:uri" 150 + element={ 151 + <AppLayout> 152 + <AnnotationDetailWrapper /> 153 + </AppLayout> 154 + } 155 + /> 156 + <Route 157 + path="/:handle/annotation/:rkey" 158 + element={ 159 + <AppLayout> 160 + <AnnotationDetailWrapper /> 161 + </AppLayout> 162 + } 163 + /> 164 + <Route 165 + path="/:handle/highlight/:rkey" 166 + element={ 167 + <AppLayout> 168 + <AnnotationDetailWrapper /> 169 + </AppLayout> 170 + } 171 + /> 172 + <Route 173 + path="/:handle/bookmark/:rkey" 174 + element={ 175 + <AppLayout> 176 + <AnnotationDetailWrapper /> 177 + </AppLayout> 178 + } 179 + /> 180 + <Route 181 + path="/:handle/url/*" 182 + element={ 183 + <AppLayout> 184 + <UserUrlWrapper /> 185 + </AppLayout> 186 + } 187 + /> 181 188 182 - <Route path="*" element={<Navigate to="/home" replace />} /> 189 + <Route 190 + path="/settings" 191 + element={ 192 + <AppLayout> 193 + <Settings /> 194 + </AppLayout> 195 + } 196 + /> 197 + <Route 198 + path="/notifications" 199 + element={ 200 + <AppLayout> 201 + <Notifications /> 202 + </AppLayout> 203 + } 204 + /> 183 205 184 - </Routes> 185 - </BrowserRouter> 186 - ); 206 + <Route path="*" element={<Navigate to="/home" replace />} /> 207 + </Routes> 208 + </BrowserRouter> 209 + ); 187 210 }
+719 -528
web/src/api/client.ts
··· 1 - 2 - import { atom } from 'nanostores'; 3 - import type { UserProfile, FeedResponse, AnnotationItem, Collection } from '../types'; 4 - export type { Collection } from '../types'; 1 + import { atom } from "nanostores"; 2 + import type { 3 + UserProfile, 4 + FeedResponse, 5 + AnnotationItem, 6 + Collection, 7 + } from "../types"; 8 + export type { Collection } from "../types"; 5 9 6 10 export const sessionAtom = atom<UserProfile | null>(null); 7 11 8 12 export async function checkSession(): Promise<UserProfile | null> { 9 - try { 10 - const res = await fetch('/auth/session'); 11 - if (!res.ok) { 12 - sessionAtom.set(null); 13 - return null; 14 - } 15 - const data = await res.json(); 16 - 17 - if (data.authenticated || data.did) { 18 - const baseProfile: UserProfile = { 19 - did: data.did, 20 - handle: data.handle, 21 - displayName: data.displayName, 22 - avatar: data.avatar, 23 - description: data.description, 24 - website: data.website, 25 - links: data.links, 26 - followersCount: data.followersCount, 27 - followsCount: data.followsCount, 28 - postsCount: data.postsCount 29 - }; 13 + try { 14 + const res = await fetch("/auth/session"); 15 + if (!res.ok) { 16 + sessionAtom.set(null); 17 + return null; 18 + } 19 + const data = await res.json(); 30 20 31 - try { 32 - const bskyRes = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(data.did)}`); 33 - if (bskyRes.ok) { 34 - const bskyData = await bskyRes.json(); 35 - if (bskyData.avatar) baseProfile.avatar = bskyData.avatar; 36 - if (bskyData.displayName) baseProfile.displayName = bskyData.displayName; 37 - } 38 - } catch (e) { 39 - console.warn("Failed to fetch Bsky profile for session", e); 40 - } 21 + if (data.authenticated || data.did) { 22 + const baseProfile: UserProfile = { 23 + did: data.did, 24 + handle: data.handle, 25 + displayName: data.displayName, 26 + avatar: data.avatar, 27 + description: data.description, 28 + website: data.website, 29 + links: data.links, 30 + followersCount: data.followersCount, 31 + followsCount: data.followsCount, 32 + postsCount: data.postsCount, 33 + }; 41 34 42 - try { 43 - const marginProfile = await getProfile(data.did); 44 - if (marginProfile) { 45 - if (marginProfile.description) baseProfile.description = marginProfile.description; 46 - if (marginProfile.followersCount) baseProfile.followersCount = marginProfile.followersCount; 47 - if (marginProfile.followsCount) baseProfile.followsCount = marginProfile.followsCount; 48 - if (marginProfile.postsCount) baseProfile.postsCount = marginProfile.postsCount; 49 - if (marginProfile.website) baseProfile.website = marginProfile.website; 50 - if (marginProfile.links) baseProfile.links = marginProfile.links; 51 - } 52 - } catch (e) { 53 - } 35 + try { 36 + const bskyRes = await fetch( 37 + `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(data.did)}`, 38 + ); 39 + if (bskyRes.ok) { 40 + const bskyData = await bskyRes.json(); 41 + if (bskyData.avatar) baseProfile.avatar = bskyData.avatar; 42 + if (bskyData.displayName) 43 + baseProfile.displayName = bskyData.displayName; 44 + } 45 + } catch (e) { 46 + console.warn("Failed to fetch Bsky profile for session", e); 47 + } 54 48 55 - sessionAtom.set(baseProfile); 56 - return baseProfile; 49 + try { 50 + const marginProfile = await getProfile(data.did); 51 + if (marginProfile) { 52 + if (marginProfile.description) 53 + baseProfile.description = marginProfile.description; 54 + if (marginProfile.followersCount) 55 + baseProfile.followersCount = marginProfile.followersCount; 56 + if (marginProfile.followsCount) 57 + baseProfile.followsCount = marginProfile.followsCount; 58 + if (marginProfile.postsCount) 59 + baseProfile.postsCount = marginProfile.postsCount; 60 + if (marginProfile.website) 61 + baseProfile.website = marginProfile.website; 62 + if (marginProfile.links) baseProfile.links = marginProfile.links; 57 63 } 64 + } catch (e) {} 58 65 59 - sessionAtom.set(null); 60 - return null; 61 - } catch (e) { 62 - sessionAtom.set(null); 63 - return null; 66 + sessionAtom.set(baseProfile); 67 + return baseProfile; 64 68 } 69 + 70 + sessionAtom.set(null); 71 + return null; 72 + } catch (e) { 73 + sessionAtom.set(null); 74 + return null; 75 + } 65 76 } 66 77 67 - async function apiRequest(path: string, options: RequestInit = {}): Promise<Response> { 68 - const headers = { 69 - 'Content-Type': 'application/json', 70 - ...(options.headers || {}), 71 - }; 78 + async function apiRequest( 79 + path: string, 80 + options: RequestInit = {}, 81 + ): Promise<Response> { 82 + const headers = { 83 + "Content-Type": "application/json", 84 + ...(options.headers || {}), 85 + }; 72 86 73 - const apiPath = path.startsWith('/api') || path.startsWith('/auth') ? path : `/api${path}`; 87 + const apiPath = 88 + path.startsWith("/api") || path.startsWith("/auth") ? path : `/api${path}`; 74 89 75 - const response = await fetch(apiPath, { 76 - ...options, 77 - headers, 78 - }); 90 + const response = await fetch(apiPath, { 91 + ...options, 92 + headers, 93 + }); 79 94 80 - if (response.status === 401) { 81 - sessionAtom.set(null); 82 - window.location.href = '/login'; 83 - } 95 + if (response.status === 401) { 96 + sessionAtom.set(null); 97 + window.location.href = "/login"; 98 + } 84 99 85 - return response; 100 + return response; 86 101 } 87 102 88 103 interface GetFeedParams { 89 - source?: string; 90 - type?: string; 91 - limit?: number; 92 - offset?: number; 93 - motivation?: string; 94 - tag?: string; 95 - creator?: string; 104 + source?: string; 105 + type?: string; 106 + limit?: number; 107 + offset?: number; 108 + motivation?: string; 109 + tag?: string; 110 + creator?: string; 96 111 } 97 112 98 113 function normalizeItem(raw: any): AnnotationItem { 99 - if (raw.type === 'CollectionItem' || raw.collectionUri) { 100 - const inner = raw.annotation || raw.highlight || raw.bookmark || {}; 101 - const normalizedInner = normalizeItem(inner); 114 + if (raw.type === "CollectionItem" || raw.collectionUri) { 115 + const inner = raw.annotation || raw.highlight || raw.bookmark || {}; 116 + const normalizedInner = normalizeItem(inner); 102 117 103 - return { 104 - ...normalizedInner, 105 - uri: normalizedInner.uri || raw.uri, 106 - author: normalizedInner.author || raw.author, 107 - collection: raw.collection ? { 108 - uri: raw.collection.uri, 109 - name: raw.collection.name, 110 - icon: raw.collection.icon 111 - } : undefined, 112 - addedBy: raw.creator || raw.author, 113 - createdAt: raw.created || raw.createdAt, 114 - collectionItemUri: raw.uri 115 - }; 116 - } 118 + return { 119 + ...normalizedInner, 120 + uri: normalizedInner.uri || raw.uri, 121 + author: normalizedInner.author || raw.author, 122 + collection: raw.collection 123 + ? { 124 + uri: raw.collection.uri, 125 + name: raw.collection.name, 126 + icon: raw.collection.icon, 127 + } 128 + : undefined, 129 + addedBy: raw.creator || raw.author, 130 + createdAt: raw.created || raw.createdAt, 131 + collectionItemUri: raw.uri, 132 + }; 133 + } 117 134 118 - let target = raw.target; 119 - if (!target || !target.source) { 120 - const url = raw.url || raw.targetUrl || (typeof raw.target === 'string' ? raw.target : raw.target?.source); 121 - if (url) { 122 - target = { 123 - source: url, 124 - title: raw.title || raw.target?.title, 125 - selector: raw.selector || raw.target?.selector 126 - }; 127 - } 135 + let target = raw.target; 136 + if (!target || !target.source) { 137 + const url = 138 + raw.url || 139 + raw.targetUrl || 140 + (typeof raw.target === "string" ? raw.target : raw.target?.source); 141 + if (url) { 142 + target = { 143 + source: url, 144 + title: raw.title || raw.target?.title, 145 + selector: raw.selector || raw.target?.selector, 146 + }; 128 147 } 148 + } 129 149 130 - return { 131 - ...raw, 132 - uri: raw.id || raw.uri, 133 - author: raw.creator || raw.author, 134 - createdAt: raw.created || raw.createdAt, 135 - target: target || raw.target, 136 - viewer: raw.viewer || { like: raw.viewerHasLiked ? 'true' : undefined } 137 - }; 150 + return { 151 + ...raw, 152 + uri: raw.id || raw.uri, 153 + author: raw.creator || raw.author, 154 + createdAt: raw.created || raw.createdAt, 155 + target: target || raw.target, 156 + viewer: raw.viewer || { like: raw.viewerHasLiked ? "true" : undefined }, 157 + }; 138 158 } 139 159 140 - export async function getFeed({ source, type = 'all', limit = 50, offset = 0, motivation, tag, creator }: GetFeedParams): Promise<FeedResponse> { 141 - const params = new URLSearchParams(); 142 - if (source) params.append('source', source); 143 - if (type) params.append('type', type); 144 - if (limit) params.append('limit', limit.toString()); 145 - if (offset) params.append('offset', offset.toString()); 146 - if (motivation) params.append('motivation', motivation); 147 - if (tag) params.append('tag', tag); 148 - if (creator) params.append('creator', creator); 160 + export async function getFeed({ 161 + source, 162 + type = "all", 163 + limit = 50, 164 + offset = 0, 165 + motivation, 166 + tag, 167 + creator, 168 + }: GetFeedParams): Promise<FeedResponse> { 169 + const params = new URLSearchParams(); 170 + if (source) params.append("source", source); 171 + if (type) params.append("type", type); 172 + if (limit) params.append("limit", limit.toString()); 173 + if (offset) params.append("offset", offset.toString()); 174 + if (motivation) params.append("motivation", motivation); 175 + if (tag) params.append("tag", tag); 176 + if (creator) params.append("creator", creator); 149 177 150 - const endpoint = source ? '/api/targets' : '/api/annotations/feed'; 178 + const endpoint = source ? "/api/targets" : "/api/annotations/feed"; 151 179 152 - try { 153 - const res = await apiRequest(`${endpoint}?${params.toString()}`); 154 - if (!res.ok) throw new Error('Failed to fetch feed'); 155 - const data = await res.json(); 156 - return { 157 - cursor: data.cursor, 158 - items: (data.items || []).map(normalizeItem) 159 - }; 160 - } catch (e) { 161 - console.error(e); 162 - return { items: [] }; 163 - } 180 + try { 181 + const res = await apiRequest(`${endpoint}?${params.toString()}`); 182 + if (!res.ok) throw new Error("Failed to fetch feed"); 183 + const data = await res.json(); 184 + return { 185 + cursor: data.cursor, 186 + items: (data.items || []).map(normalizeItem), 187 + }; 188 + } catch (e) { 189 + console.error(e); 190 + return { items: [] }; 191 + } 164 192 } 165 193 166 194 interface CreateAnnotationParams { 167 - url: string; 168 - text?: string; 169 - title?: string; 170 - selector?: { exact: string; prefix?: string; suffix?: string }; 171 - tags?: string[]; 195 + url: string; 196 + text?: string; 197 + title?: string; 198 + selector?: { exact: string; prefix?: string; suffix?: string }; 199 + tags?: string[]; 172 200 } 173 201 174 - export async function createAnnotation({ url, text, title, selector, tags }: CreateAnnotationParams) { 175 - try { 176 - const res = await apiRequest('/api/annotations', { 177 - method: 'POST', 178 - body: JSON.stringify({ url, text, title, selector, tags }) 179 - }); 180 - if (!res.ok) throw new Error(await res.text()); 181 - const raw = await res.json(); 182 - return normalizeItem(raw); 183 - } catch (e: any) { 184 - console.error(e); 185 - return { error: e.message }; 186 - } 202 + export async function createAnnotation({ 203 + url, 204 + text, 205 + title, 206 + selector, 207 + tags, 208 + }: CreateAnnotationParams) { 209 + try { 210 + const res = await apiRequest("/api/annotations", { 211 + method: "POST", 212 + body: JSON.stringify({ url, text, title, selector, tags }), 213 + }); 214 + if (!res.ok) throw new Error(await res.text()); 215 + const raw = await res.json(); 216 + return normalizeItem(raw); 217 + } catch (e: any) { 218 + console.error(e); 219 + return { error: e.message }; 220 + } 187 221 } 188 222 189 223 interface CreateHighlightParams { 190 - url: string; 191 - selector: { exact: string; prefix?: string; suffix?: string }; 192 - color?: string; 193 - tags?: string[]; 194 - title?: string; 224 + url: string; 225 + selector: { exact: string; prefix?: string; suffix?: string }; 226 + color?: string; 227 + tags?: string[]; 228 + title?: string; 195 229 } 196 230 197 - export async function createHighlight({ url, selector, color, tags, title }: CreateHighlightParams) { 198 - try { 199 - const res = await apiRequest('/api/highlights', { 200 - method: 'POST', 201 - body: JSON.stringify({ url, selector, color, tags, title }) 202 - }); 203 - if (!res.ok) throw new Error(await res.text()); 204 - const raw = await res.json(); 205 - return normalizeItem(raw); 206 - } catch (e: any) { 207 - console.error(e); 208 - return { error: e.message }; 209 - } 231 + export async function createHighlight({ 232 + url, 233 + selector, 234 + color, 235 + tags, 236 + title, 237 + }: CreateHighlightParams) { 238 + try { 239 + const res = await apiRequest("/api/highlights", { 240 + method: "POST", 241 + body: JSON.stringify({ url, selector, color, tags, title }), 242 + }); 243 + if (!res.ok) throw new Error(await res.text()); 244 + const raw = await res.json(); 245 + return normalizeItem(raw); 246 + } catch (e: any) { 247 + console.error(e); 248 + return { error: e.message }; 249 + } 210 250 } 211 251 212 - export async function createBookmark({ url, title, description }: { url: string; title?: string; description?: string }) { 213 - try { 214 - const res = await apiRequest('/api/bookmarks', { 215 - method: 'POST', 216 - body: JSON.stringify({ url, title, description }) 217 - }); 218 - if (!res.ok) throw new Error(await res.text()); 219 - const raw = await res.json(); 220 - return normalizeItem(raw); 221 - } catch (e: any) { 222 - console.error(e); 223 - return { error: e.message }; 224 - } 252 + export async function createBookmark({ 253 + url, 254 + title, 255 + description, 256 + }: { 257 + url: string; 258 + title?: string; 259 + description?: string; 260 + }) { 261 + try { 262 + const res = await apiRequest("/api/bookmarks", { 263 + method: "POST", 264 + body: JSON.stringify({ url, title, description }), 265 + }); 266 + if (!res.ok) throw new Error(await res.text()); 267 + const raw = await res.json(); 268 + return normalizeItem(raw); 269 + } catch (e: any) { 270 + console.error(e); 271 + return { error: e.message }; 272 + } 225 273 } 226 274 227 275 export async function uploadAvatar(file: File): Promise<{ blob: any }> { 228 - const formData = new FormData(); 229 - formData.append('file', file); 230 - const res = await fetch('/api/upload/avatar', { 231 - method: 'POST', 232 - headers: { 233 - 'Authorization': `Bearer ${(await checkSession())?.did}` 234 - }, 235 - body: formData 236 - }); 237 - if (!res.ok) throw new Error('Failed to upload avatar'); 238 - return res.json(); 276 + const formData = new FormData(); 277 + formData.append("file", file); 278 + const res = await fetch("/api/upload/avatar", { 279 + method: "POST", 280 + headers: { 281 + Authorization: `Bearer ${(await checkSession())?.did}`, 282 + }, 283 + body: formData, 284 + }); 285 + if (!res.ok) throw new Error("Failed to upload avatar"); 286 + return res.json(); 239 287 } 240 288 241 - export async function updateProfile(updates: { displayName?: string; description?: string; avatar?: any; website?: string; links?: string[] }): Promise<boolean> { 242 - try { 243 - const res = await apiRequest('/api/profile', { 244 - method: 'PUT', 245 - body: JSON.stringify(updates) 246 - }); 247 - return res.ok; 248 - } catch (e) { 249 - console.error(e); 250 - return false; 251 - } 289 + export async function updateProfile(updates: { 290 + displayName?: string; 291 + description?: string; 292 + avatar?: any; 293 + website?: string; 294 + links?: string[]; 295 + }): Promise<boolean> { 296 + try { 297 + const res = await apiRequest("/api/profile", { 298 + method: "PUT", 299 + body: JSON.stringify(updates), 300 + }); 301 + return res.ok; 302 + } catch (e) { 303 + console.error(e); 304 + return false; 305 + } 252 306 } 253 307 254 308 export async function likeItem(uri: string, cid: string): Promise<boolean> { 255 - try { 256 - const res = await apiRequest('/api/annotations/like', { 257 - method: 'POST', 258 - body: JSON.stringify({ subjectUri: uri, subjectCid: cid }) 259 - }); 260 - return res.ok; 261 - } catch (e) { 262 - return false; 263 - } 309 + try { 310 + const res = await apiRequest("/api/annotations/like", { 311 + method: "POST", 312 + body: JSON.stringify({ subjectUri: uri, subjectCid: cid }), 313 + }); 314 + return res.ok; 315 + } catch (e) { 316 + return false; 317 + } 264 318 } 265 319 266 320 export async function unlikeItem(uri: string): Promise<boolean> { 267 - try { 268 - const res = await apiRequest(`/api/annotations/like?uri=${encodeURIComponent(uri)}`, { 269 - method: 'DELETE' 270 - }); 271 - return res.ok; 272 - } catch (e) { 273 - return false; 274 - } 321 + try { 322 + const res = await apiRequest( 323 + `/api/annotations/like?uri=${encodeURIComponent(uri)}`, 324 + { 325 + method: "DELETE", 326 + }, 327 + ); 328 + return res.ok; 329 + } catch (e) { 330 + return false; 331 + } 275 332 } 276 333 277 - export async function deleteItem(uri: string, type: string = 'annotation'): Promise<boolean> { 278 - const rkey = uri.split('/').pop(); 279 - let endpoint = '/api/annotations'; 280 - if (uri.includes('highlight')) endpoint = '/api/highlights'; 281 - if (uri.includes('bookmark')) endpoint = '/api/bookmarks'; 334 + export async function deleteItem( 335 + uri: string, 336 + type: string = "annotation", 337 + ): Promise<boolean> { 338 + const rkey = (uri || "").split("/").pop(); 339 + let endpoint = "/api/annotations"; 340 + if (uri.includes("highlight")) endpoint = "/api/highlights"; 341 + if (uri.includes("bookmark")) endpoint = "/api/bookmarks"; 282 342 283 - try { 284 - const res = await apiRequest(`${endpoint}?rkey=${rkey}`, { method: 'DELETE' }); 285 - return res.ok; 286 - } catch (e) { 287 - return false; 288 - } 343 + try { 344 + const res = await apiRequest(`${endpoint}?rkey=${rkey}`, { 345 + method: "DELETE", 346 + }); 347 + return res.ok; 348 + } catch (e) { 349 + return false; 350 + } 289 351 } 290 352 291 - export async function updateAnnotation(uri: string, text: string, tags?: string[]): Promise<boolean> { 292 - try { 293 - const res = await apiRequest(`/api/annotations?uri=${encodeURIComponent(uri)}`, { 294 - method: 'PUT', 295 - body: JSON.stringify({ text, tags }) 296 - }); 297 - return res.ok; 298 - } catch (e) { 299 - return false; 300 - } 353 + export async function updateAnnotation( 354 + uri: string, 355 + text: string, 356 + tags?: string[], 357 + ): Promise<boolean> { 358 + try { 359 + const res = await apiRequest( 360 + `/api/annotations?uri=${encodeURIComponent(uri)}`, 361 + { 362 + method: "PUT", 363 + body: JSON.stringify({ text, tags }), 364 + }, 365 + ); 366 + return res.ok; 367 + } catch (e) { 368 + return false; 369 + } 301 370 } 302 371 303 - export async function updateHighlight(uri: string, color: string, tags?: string[]): Promise<boolean> { 304 - try { 305 - const res = await apiRequest(`/api/highlights?uri=${encodeURIComponent(uri)}`, { 306 - method: 'PUT', 307 - body: JSON.stringify({ color, tags }) 308 - }); 309 - return res.ok; 310 - } catch (e) { 311 - return false; 312 - } 372 + export async function updateHighlight( 373 + uri: string, 374 + color: string, 375 + tags?: string[], 376 + ): Promise<boolean> { 377 + try { 378 + const res = await apiRequest( 379 + `/api/highlights?uri=${encodeURIComponent(uri)}`, 380 + { 381 + method: "PUT", 382 + body: JSON.stringify({ color, tags }), 383 + }, 384 + ); 385 + return res.ok; 386 + } catch (e) { 387 + return false; 388 + } 313 389 } 314 390 315 - export async function updateBookmark(uri: string, title?: string, description?: string, tags?: string[]): Promise<boolean> { 316 - try { 317 - const res = await apiRequest(`/api/bookmarks?uri=${encodeURIComponent(uri)}`, { 318 - method: 'PUT', 319 - body: JSON.stringify({ title, description, tags }) 320 - }); 321 - return res.ok; 322 - } catch (e) { 323 - return false; 324 - } 391 + export async function updateBookmark( 392 + uri: string, 393 + title?: string, 394 + description?: string, 395 + tags?: string[], 396 + ): Promise<boolean> { 397 + try { 398 + const res = await apiRequest( 399 + `/api/bookmarks?uri=${encodeURIComponent(uri)}`, 400 + { 401 + method: "PUT", 402 + body: JSON.stringify({ title, description, tags }), 403 + }, 404 + ); 405 + return res.ok; 406 + } catch (e) { 407 + return false; 408 + } 325 409 } 326 410 327 - export async function getCollectionsContaining(annotationUri: string): Promise<string[]> { 328 - try { 329 - const res = await apiRequest(`/api/collections/containing?uri=${encodeURIComponent(annotationUri)}`); 330 - if (!res.ok) return []; 331 - return await res.json(); 332 - } catch (e) { 333 - return []; 334 - } 411 + export async function getCollectionsContaining( 412 + annotationUri: string, 413 + ): Promise<string[]> { 414 + try { 415 + const res = await apiRequest( 416 + `/api/collections/containing?uri=${encodeURIComponent(annotationUri)}`, 417 + ); 418 + if (!res.ok) return []; 419 + return await res.json(); 420 + } catch (e) { 421 + return []; 422 + } 335 423 } 336 424 337 425 export async function getEditHistory(uri: string): Promise<any[]> { 338 - try { 339 - const res = await apiRequest(`/api/annotations/history?uri=${encodeURIComponent(uri)}`); 340 - if (!res.ok) return []; 341 - return await res.json(); 342 - } catch (e) { 343 - return []; 344 - } 426 + try { 427 + const res = await apiRequest( 428 + `/api/annotations/history?uri=${encodeURIComponent(uri)}`, 429 + ); 430 + if (!res.ok) return []; 431 + return await res.json(); 432 + } catch (e) { 433 + return []; 434 + } 345 435 } 346 436 347 437 export async function getProfile(did: string): Promise<UserProfile | null> { 348 - try { 349 - const res = await apiRequest(`/api/profile/${did}`); 350 - if (!res.ok) return null; 351 - return await res.json(); 352 - } catch (e) { 353 - return null; 354 - } 438 + try { 439 + const res = await apiRequest(`/api/profile/${did}`); 440 + if (!res.ok) return null; 441 + return await res.json(); 442 + } catch (e) { 443 + return null; 444 + } 355 445 } 356 446 357 447 export interface ActorSearchItem { 358 - did: string; 359 - handle: string; 360 - displayName?: string; 361 - avatar?: string; 448 + did: string; 449 + handle: string; 450 + displayName?: string; 451 + avatar?: string; 362 452 } 363 453 364 - export function getAvatarUrl(did?: string, avatar?: string): string | undefined { 365 - if (!avatar && !did) return undefined; 366 - if (avatar && !avatar.includes('cdn.bsky.app')) return avatar; 367 - if (!did) return avatar; 454 + export function getAvatarUrl( 455 + did?: string, 456 + avatar?: string, 457 + ): string | undefined { 458 + if (!avatar && !did) return undefined; 459 + if (avatar && !avatar.includes("cdn.bsky.app")) return avatar; 460 + if (!did) return avatar; 368 461 369 - return `/api/avatar/${encodeURIComponent(did)}`; 462 + return `/api/avatar/${encodeURIComponent(did)}`; 370 463 } 371 464 372 - export async function searchActors(query: string): Promise<{ actors: ActorSearchItem[] }> { 373 - try { 374 - const res = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead?q=${encodeURIComponent(query)}&limit=5`); 375 - if (!res.ok) throw new Error('Search failed'); 376 - return await res.json(); 377 - } catch (e) { 378 - return { actors: [] }; 379 - } 465 + export async function searchActors( 466 + query: string, 467 + ): Promise<{ actors: ActorSearchItem[] }> { 468 + try { 469 + const res = await fetch( 470 + `https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead?q=${encodeURIComponent(query)}&limit=5`, 471 + ); 472 + if (!res.ok) throw new Error("Search failed"); 473 + return await res.json(); 474 + } catch (e) { 475 + return { actors: [] }; 476 + } 380 477 } 381 478 382 479 export async function resolveHandle(handle: string): Promise<string | null> { 383 - try { 384 - const res = await fetch(`https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`); 385 - if (!res.ok) throw new Error('Failed to resolve handle'); 386 - const data = await res.json(); 387 - return data.did; 388 - } catch (e) { 389 - return null; 390 - } 480 + try { 481 + const res = await fetch( 482 + `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`, 483 + ); 484 + if (!res.ok) throw new Error("Failed to resolve handle"); 485 + const data = await res.json(); 486 + return data.did; 487 + } catch (e) { 488 + return null; 489 + } 391 490 } 392 491 393 - export async function startLogin(handle: string): Promise<{ authorizationUrl?: string }> { 394 - const res = await apiRequest('/auth/start', { 395 - method: 'POST', 396 - body: JSON.stringify({ handle }) 397 - }); 398 - if (!res.ok) throw new Error('Failed to start login'); 399 - return await res.json(); 492 + export async function startLogin( 493 + handle: string, 494 + ): Promise<{ authorizationUrl?: string }> { 495 + const res = await apiRequest("/auth/start", { 496 + method: "POST", 497 + body: JSON.stringify({ handle }), 498 + }); 499 + if (!res.ok) throw new Error("Failed to start login"); 500 + return await res.json(); 400 501 } 401 502 402 - export async function startSignup(pdsUrl: string): Promise<{ authorizationUrl?: string }> { 403 - const res = await apiRequest('/auth/signup', { 404 - method: 'POST', 405 - body: JSON.stringify({ pds_url: pdsUrl }) 406 - }); 407 - if (!res.ok) throw new Error('Failed to start signup'); 408 - return await res.json(); 503 + export async function startSignup( 504 + pdsUrl: string, 505 + ): Promise<{ authorizationUrl?: string }> { 506 + const res = await apiRequest("/auth/signup", { 507 + method: "POST", 508 + body: JSON.stringify({ pds_url: pdsUrl }), 509 + }); 510 + if (!res.ok) throw new Error("Failed to start signup"); 511 + return await res.json(); 409 512 } 410 513 411 514 export async function getNotifications(limit = 50, offset = 0): Promise<any[]> { 412 - try { 413 - const res = await apiRequest(`/api/notifications?limit=${limit}&offset=${offset}`); 414 - if (!res.ok) throw new Error('Failed to fetch notifications'); 415 - const data = await res.json(); 416 - return (data.items || []).map((n: any) => ({ 417 - ...n, 418 - subject: n.subject ? normalizeItem(n.subject) : undefined 419 - })); 420 - } catch (e) { 421 - return []; 422 - } 515 + try { 516 + const res = await apiRequest( 517 + `/api/notifications?limit=${limit}&offset=${offset}`, 518 + ); 519 + if (!res.ok) throw new Error("Failed to fetch notifications"); 520 + const data = await res.json(); 521 + return (data.items || []).map((n: any) => ({ 522 + ...n, 523 + subject: n.subject ? normalizeItem(n.subject) : undefined, 524 + })); 525 + } catch (e) { 526 + return []; 527 + } 423 528 } 424 529 425 530 export async function getUnreadNotificationCount(): Promise<number> { 426 - try { 427 - const res = await apiRequest('/api/notifications/count'); 428 - if (!res.ok) return 0; 429 - const data = await res.json(); 430 - return data.count || 0; 431 - } catch (e) { 432 - return 0; 433 - } 531 + try { 532 + const res = await apiRequest("/api/notifications/count"); 533 + if (!res.ok) return 0; 534 + const data = await res.json(); 535 + return data.count || 0; 536 + } catch (e) { 537 + return 0; 538 + } 434 539 } 435 540 436 541 export async function markNotificationsRead(): Promise<boolean> { 437 - try { 438 - const res = await apiRequest('/api/notifications/read', { method: 'POST' }); 439 - return res.ok; 440 - } catch (e) { 441 - return false; 442 - } 542 + try { 543 + const res = await apiRequest("/api/notifications/read", { method: "POST" }); 544 + return res.ok; 545 + } catch (e) { 546 + return false; 547 + } 443 548 } 444 549 445 550 export interface APIKey { 446 - id: string; 447 - alias: string; 448 - key?: string; 449 - createdAt: string; 551 + id: string; 552 + alias: string; 553 + key?: string; 554 + createdAt: string; 450 555 } 451 556 452 557 export async function getAPIKeys(): Promise<APIKey[]> { 453 - try { 454 - const res = await apiRequest('/api/keys'); 455 - if (!res.ok) return []; 456 - const data = await res.json(); 457 - return Array.isArray(data) ? data : (data.keys || []); 458 - } catch (e) { 459 - return []; 460 - } 558 + try { 559 + const res = await apiRequest("/api/keys"); 560 + if (!res.ok) return []; 561 + const data = await res.json(); 562 + return Array.isArray(data) ? data : data.keys || []; 563 + } catch (e) { 564 + return []; 565 + } 461 566 } 462 567 463 568 export async function createAPIKey(alias: string): Promise<APIKey | null> { 464 - try { 465 - const res = await apiRequest('/api/keys', { 466 - method: 'POST', 467 - body: JSON.stringify({ alias }) 468 - }); 469 - if (!res.ok) return null; 470 - return await res.json(); 471 - } catch (e) { 472 - return null; 473 - } 569 + try { 570 + const res = await apiRequest("/api/keys", { 571 + method: "POST", 572 + body: JSON.stringify({ alias }), 573 + }); 574 + if (!res.ok) return null; 575 + return await res.json(); 576 + } catch (e) { 577 + return null; 578 + } 474 579 } 475 580 476 581 export async function deleteAPIKey(id: string): Promise<boolean> { 477 - try { 478 - const res = await apiRequest(`/api/keys/${id}`, { method: 'DELETE' }); 479 - return res.ok; 480 - } catch (e) { 481 - return false; 482 - } 582 + try { 583 + const res = await apiRequest(`/api/keys/${id}`, { method: "DELETE" }); 584 + return res.ok; 585 + } catch (e) { 586 + return false; 587 + } 483 588 } 484 589 485 590 export interface Tag { 486 - tag: string; 487 - count: number; 591 + tag: string; 592 + count: number; 488 593 } 489 594 490 595 export async function getTrendingTags(limit = 10): Promise<Tag[]> { 491 - try { 492 - const res = await apiRequest(`/api/tags/trending?limit=${limit}`); 493 - if (!res.ok) return []; 494 - const data = await res.json(); 495 - return Array.isArray(data) ? data : (data.tags || []); 496 - } catch (e) { 497 - return []; 498 - } 596 + try { 597 + const res = await apiRequest(`/api/tags/trending?limit=${limit}`); 598 + if (!res.ok) return []; 599 + const data = await res.json(); 600 + return Array.isArray(data) ? data : data.tags || []; 601 + } catch (e) { 602 + return []; 603 + } 499 604 } 500 605 501 606 export async function getCollections(creator?: string): Promise<Collection[]> { 502 - try { 503 - const query = creator ? `?creator=${encodeURIComponent(creator)}` : ''; 504 - const res = await apiRequest(`/api/collections${query}`); 505 - if (!res.ok) throw new Error('Failed to fetch collections'); 506 - const data = await res.json(); 507 - return Array.isArray(data) ? data : (data.items || []); 508 - } catch (e) { 509 - console.error(e); 510 - return []; 511 - } 607 + try { 608 + const query = creator ? `?creator=${encodeURIComponent(creator)}` : ""; 609 + const res = await apiRequest(`/api/collections${query}`); 610 + if (!res.ok) throw new Error("Failed to fetch collections"); 611 + const data = await res.json(); 612 + return Array.isArray(data) ? data : data.items || []; 613 + } catch (e) { 614 + console.error(e); 615 + return []; 616 + } 512 617 } 513 618 514 619 export async function getCollection(uri: string): Promise<Collection | null> { 515 - try { 516 - const res = await apiRequest(`/api/collection?uri=${encodeURIComponent(uri)}`); 517 - if (!res.ok) throw new Error('Failed to fetch collection'); 518 - return await res.json(); 519 - } catch (e) { 520 - console.error(e); 521 - return null; 522 - } 620 + try { 621 + const res = await apiRequest( 622 + `/api/collection?uri=${encodeURIComponent(uri)}`, 623 + ); 624 + if (!res.ok) throw new Error("Failed to fetch collection"); 625 + return await res.json(); 626 + } catch (e) { 627 + console.error(e); 628 + return null; 629 + } 523 630 } 524 631 525 - export async function createCollection(name: string, description?: string, icon?: string): Promise<Collection | null> { 526 - try { 527 - const res = await apiRequest('/api/collections', { 528 - method: 'POST', 529 - body: JSON.stringify({ name, description, icon }) 530 - }); 531 - if (!res.ok) throw new Error('Failed to create collection'); 532 - return await res.json(); 533 - } catch (e) { 534 - console.error(e); 535 - return null; 536 - } 632 + export async function createCollection( 633 + name: string, 634 + description?: string, 635 + icon?: string, 636 + ): Promise<Collection | null> { 637 + try { 638 + const res = await apiRequest("/api/collections", { 639 + method: "POST", 640 + body: JSON.stringify({ name, description, icon }), 641 + }); 642 + if (!res.ok) throw new Error("Failed to create collection"); 643 + return await res.json(); 644 + } catch (e) { 645 + console.error(e); 646 + return null; 647 + } 537 648 } 538 649 539 650 export async function deleteCollection(id: string): Promise<boolean> { 540 - try { 541 - const res = await apiRequest(`/api/collections?uri=${encodeURIComponent(id)}`, { method: 'DELETE' }); // Note: old code used uri param for delete too, despite 'id' arg name here 542 - return res.ok; 543 - } catch (e) { 544 - console.error(e); 545 - return false; 546 - } 651 + try { 652 + const res = await apiRequest( 653 + `/api/collections?uri=${encodeURIComponent(id)}`, 654 + { method: "DELETE" }, 655 + ); 656 + return res.ok; 657 + } catch (e) { 658 + console.error(e); 659 + return false; 660 + } 547 661 } 548 662 549 - export async function getCollectionItems(uri: string): Promise<AnnotationItem[]> { 550 - try { 551 - const res = await apiRequest(`/api/collections/${encodeURIComponent(uri)}/items`); 552 - if (!res.ok) throw new Error('Failed to fetch collection items'); 553 - const data = await res.json(); 554 - return (data || []).map(normalizeItem); 555 - } catch (e) { 556 - console.error(e); 557 - return []; 558 - } 663 + export async function getCollectionItems( 664 + uri: string, 665 + ): Promise<AnnotationItem[]> { 666 + try { 667 + const res = await apiRequest( 668 + `/api/collections/${encodeURIComponent(uri)}/items`, 669 + ); 670 + if (!res.ok) throw new Error("Failed to fetch collection items"); 671 + const data = await res.json(); 672 + return (data || []).map(normalizeItem); 673 + } catch (e) { 674 + console.error(e); 675 + return []; 676 + } 559 677 } 560 678 561 - export async function updateCollection(uri: string, name: string, description?: string, icon?: string): Promise<Collection | null> { 562 - try { 563 - const res = await apiRequest(`/api/collections?uri=${encodeURIComponent(uri)}`, { 564 - method: 'PUT', 565 - body: JSON.stringify({ name, description, icon }) 566 - }); 567 - if (!res.ok) throw new Error('Failed to update collection'); 568 - return await res.json(); 569 - } catch (e) { 570 - console.error(e); 571 - return null; 572 - } 679 + export async function updateCollection( 680 + uri: string, 681 + name: string, 682 + description?: string, 683 + icon?: string, 684 + ): Promise<Collection | null> { 685 + try { 686 + const res = await apiRequest( 687 + `/api/collections?uri=${encodeURIComponent(uri)}`, 688 + { 689 + method: "PUT", 690 + body: JSON.stringify({ name, description, icon }), 691 + }, 692 + ); 693 + if (!res.ok) throw new Error("Failed to update collection"); 694 + return await res.json(); 695 + } catch (e) { 696 + console.error(e); 697 + return null; 698 + } 573 699 } 574 700 575 - export async function addCollectionItem(collectionUri: string, annotationUri: string, position: number = 0): Promise<boolean> { 576 - try { 577 - const res = await apiRequest(`/api/collections/${encodeURIComponent(collectionUri)}/items`, { 578 - method: 'POST', 579 - body: JSON.stringify({ annotationUri, position }) 580 - }); 581 - return res.ok; 582 - } catch (e) { 583 - console.error(e); 584 - return false; 585 - } 701 + export async function addCollectionItem( 702 + collectionUri: string, 703 + annotationUri: string, 704 + position: number = 0, 705 + ): Promise<boolean> { 706 + try { 707 + const res = await apiRequest( 708 + `/api/collections/${encodeURIComponent(collectionUri)}/items`, 709 + { 710 + method: "POST", 711 + body: JSON.stringify({ annotationUri, position }), 712 + }, 713 + ); 714 + return res.ok; 715 + } catch (e) { 716 + console.error(e); 717 + return false; 718 + } 586 719 } 587 720 588 721 export async function removeCollectionItem(itemUri: string): Promise<boolean> { 589 - try { 590 - const res = await apiRequest(`/api/collections/items?uri=${encodeURIComponent(itemUri)}`, { 591 - method: 'DELETE' 592 - }); 593 - return res.ok; 594 - } catch (e) { 595 - console.error(e); 596 - return false; 597 - } 722 + try { 723 + const res = await apiRequest( 724 + `/api/collections/items?uri=${encodeURIComponent(itemUri)}`, 725 + { 726 + method: "DELETE", 727 + }, 728 + ); 729 + return res.ok; 730 + } catch (e) { 731 + console.error(e); 732 + return false; 733 + } 598 734 } 599 735 600 - export async function createReply(parentUri: string, parentCid: string, rootUri: string, rootCid: string, text: string): Promise<string | null> { 601 - try { 602 - const res = await apiRequest('/api/annotations/reply', { 603 - method: 'POST', 604 - body: JSON.stringify({ parentUri, parentCid, rootUri, rootCid, text }) 605 - }); 606 - if (!res.ok) throw new Error('Failed to create reply'); 607 - const data = await res.json(); 608 - return data.uri; 609 - } catch (e) { 610 - console.error(e); 611 - return null; 612 - } 736 + export async function createReply( 737 + parentUri: string, 738 + parentCid: string, 739 + rootUri: string, 740 + rootCid: string, 741 + text: string, 742 + ): Promise<string | null> { 743 + try { 744 + const res = await apiRequest("/api/annotations/reply", { 745 + method: "POST", 746 + body: JSON.stringify({ parentUri, parentCid, rootUri, rootCid, text }), 747 + }); 748 + if (!res.ok) throw new Error("Failed to create reply"); 749 + const data = await res.json(); 750 + return data.uri; 751 + } catch (e) { 752 + console.error(e); 753 + return null; 754 + } 613 755 } 614 756 615 757 export async function deleteReply(uri: string): Promise<boolean> { 616 - try { 617 - const res = await apiRequest(`/api/annotations/reply?uri=${encodeURIComponent(uri)}`, { 618 - method: 'DELETE' 619 - }); 620 - return res.ok; 621 - } catch (e) { 622 - console.error(e); 623 - return false; 624 - } 758 + try { 759 + const res = await apiRequest( 760 + `/api/annotations/reply?uri=${encodeURIComponent(uri)}`, 761 + { 762 + method: "DELETE", 763 + }, 764 + ); 765 + return res.ok; 766 + } catch (e) { 767 + console.error(e); 768 + return false; 769 + } 625 770 } 626 771 627 - export async function getAnnotation(uri: string): Promise<AnnotationItem | null> { 628 - try { 629 - const res = await apiRequest(`/api/annotation?uri=${encodeURIComponent(uri)}`); 630 - if (!res.ok) return null; 631 - return normalizeItem(await res.json()); 632 - } catch (e) { 633 - return null; 634 - } 772 + export async function getAnnotation( 773 + uri: string, 774 + ): Promise<AnnotationItem | null> { 775 + try { 776 + const res = await apiRequest( 777 + `/api/annotation?uri=${encodeURIComponent(uri)}`, 778 + ); 779 + if (!res.ok) return null; 780 + return normalizeItem(await res.json()); 781 + } catch (e) { 782 + return null; 783 + } 635 784 } 636 785 637 - export async function getReplies(uri: string): Promise<{ items: AnnotationItem[] }> { 638 - try { 639 - const res = await apiRequest(`/api/replies?uri=${encodeURIComponent(uri)}`); 640 - if (!res.ok) return { items: [] }; 641 - const data = await res.json(); 642 - return { items: (data.items || []).map(normalizeItem) }; 643 - } catch (e) { 644 - return { items: [] }; 645 - } 786 + export async function getReplies( 787 + uri: string, 788 + ): Promise<{ items: AnnotationItem[] }> { 789 + try { 790 + const res = await apiRequest(`/api/replies?uri=${encodeURIComponent(uri)}`); 791 + if (!res.ok) return { items: [] }; 792 + const data = await res.json(); 793 + return { items: (data.items || []).map(normalizeItem) }; 794 + } catch (e) { 795 + return { items: [] }; 796 + } 797 + } 798 + 799 + export async function getByTarget( 800 + url: string, 801 + limit = 50, 802 + offset = 0, 803 + ): Promise<{ annotations: AnnotationItem[]; highlights: AnnotationItem[] }> { 804 + try { 805 + const res = await apiRequest( 806 + `/api/targets?source=${encodeURIComponent(url)}&limit=${limit}&offset=${offset}`, 807 + ); 808 + if (!res.ok) return { annotations: [], highlights: [] }; 809 + const data = await res.json(); 810 + return { 811 + annotations: (data.annotations || []).map(normalizeItem), 812 + highlights: (data.highlights || []).map(normalizeItem), 813 + }; 814 + } catch (e) { 815 + return { annotations: [], highlights: [] }; 816 + } 646 817 } 647 818 648 - export async function getByTarget(url: string, limit = 50, offset = 0): Promise<{ annotations: AnnotationItem[], highlights: AnnotationItem[] }> { 649 - try { 650 - const res = await apiRequest(`/api/targets?source=${encodeURIComponent(url)}&limit=${limit}&offset=${offset}`); 651 - if (!res.ok) return { annotations: [], highlights: [] }; 652 - const data = await res.json(); 653 - return { 654 - annotations: (data.annotations || []).map(normalizeItem), 655 - highlights: (data.highlights || []).map(normalizeItem) 656 - }; 657 - } catch (e) { 658 - return { annotations: [], highlights: [] }; 659 - } 819 + export async function getUserTargetItems( 820 + did: string, 821 + url: string, 822 + limit = 50, 823 + offset = 0, 824 + ): Promise<{ annotations: AnnotationItem[]; highlights: AnnotationItem[] }> { 825 + try { 826 + const res = await apiRequest( 827 + `/api/users/${encodeURIComponent(did)}/targets?source=${encodeURIComponent(url)}&limit=${limit}&offset=${offset}`, 828 + ); 829 + if (!res.ok) return { annotations: [], highlights: [] }; 830 + const data = await res.json(); 831 + return { 832 + annotations: (data.annotations || []).map(normalizeItem), 833 + highlights: (data.highlights || []).map(normalizeItem), 834 + }; 835 + } catch (e) { 836 + return { annotations: [], highlights: [] }; 837 + } 838 + } 839 + export async function getPreferences(): Promise<{ 840 + externalLinkSkippedHostnames?: string[]; 841 + }> { 842 + try { 843 + const res = await apiRequest("/api/preferences"); 844 + if (!res.ok) return {}; 845 + return await res.json(); 846 + } catch (e) { 847 + console.error(e); 848 + return {}; 849 + } 660 850 } 661 851 662 - export async function getUserTargetItems(did: string, url: string, limit = 50, offset = 0): Promise<{ annotations: AnnotationItem[], highlights: AnnotationItem[] }> { 663 - try { 664 - const res = await apiRequest(`/api/users/${encodeURIComponent(did)}/targets?source=${encodeURIComponent(url)}&limit=${limit}&offset=${offset}`); 665 - if (!res.ok) return { annotations: [], highlights: [] }; 666 - const data = await res.json(); 667 - return { 668 - annotations: (data.annotations || []).map(normalizeItem), 669 - highlights: (data.highlights || []).map(normalizeItem) 670 - }; 671 - } catch (e) { 672 - return { annotations: [], highlights: [] }; 673 - } 852 + export async function updatePreferences(prefs: { 853 + externalLinkSkippedHostnames?: string[]; 854 + }): Promise<boolean> { 855 + try { 856 + const res = await apiRequest("/api/preferences", { 857 + method: "PUT", 858 + body: JSON.stringify(prefs), 859 + }); 860 + return res.ok; 861 + } catch (e) { 862 + console.error(e); 863 + return false; 864 + } 674 865 }
-154
web/src/assets/tangled.svg
··· 1 - <?xml version="1.0" encoding="UTF-8" standalone="no"?> 2 - <!-- Created with Inkscape (http://www.inkscape.org/) --> 3 - 4 - <svg 5 - version="1.1" 6 - id="svg1" 7 - width="24.122343" 8 - height="23.274094" 9 - viewBox="0 0 24.122343 23.274094" 10 - sodipodi:docname="tangled_dolly_face_only.svg" 11 - inkscape:export-filename="tangled_logotype_black_on_trans.svg" 12 - inkscape:export-xdpi="96" 13 - inkscape:export-ydpi="96" 14 - inkscape:version="1.4 (e7c3feb100, 2024-10-09)" 15 - xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" 16 - xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" 17 - xmlns="http://www.w3.org/2000/svg" 18 - xmlns:svg="http://www.w3.org/2000/svg"> 19 - <defs 20 - id="defs1"> 21 - <filter 22 - style="color-interpolation-filters:sRGB" 23 - inkscape:menu-tooltip="Fades hue progressively to white" 24 - inkscape:menu="Color" 25 - inkscape:label="Hue to White" 26 - id="filter24" 27 - x="0" 28 - y="0" 29 - width="1" 30 - height="1"> 31 - <feColorMatrix 32 - values="1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 0 0 0 0 1 " 33 - type="matrix" 34 - result="r" 35 - in="SourceGraphic" 36 - id="feColorMatrix17" /> 37 - <feColorMatrix 38 - values="0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 0 0 0 1 " 39 - type="matrix" 40 - result="g" 41 - in="SourceGraphic" 42 - id="feColorMatrix18" /> 43 - <feColorMatrix 44 - values="0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 0 0 1 " 45 - type="matrix" 46 - result="b" 47 - in="SourceGraphic" 48 - id="feColorMatrix19" /> 49 - <feBlend 50 - result="minrg" 51 - in="r" 52 - mode="darken" 53 - in2="g" 54 - id="feBlend19" /> 55 - <feBlend 56 - result="p" 57 - in="minrg" 58 - mode="darken" 59 - in2="b" 60 - id="feBlend20" /> 61 - <feBlend 62 - result="maxrg" 63 - in="r" 64 - mode="lighten" 65 - in2="g" 66 - id="feBlend21" /> 67 - <feBlend 68 - result="q" 69 - in="maxrg" 70 - mode="lighten" 71 - in2="b" 72 - id="feBlend22" /> 73 - <feComponentTransfer 74 - result="q2" 75 - in="q" 76 - id="feComponentTransfer22"> 77 - <feFuncR 78 - slope="0" 79 - type="linear" 80 - id="feFuncR22" /> 81 - </feComponentTransfer> 82 - <feBlend 83 - result="pq" 84 - in="p" 85 - mode="lighten" 86 - in2="q2" 87 - id="feBlend23" /> 88 - <feColorMatrix 89 - values="-1 1 0 0 0 -1 1 0 0 0 -1 1 0 0 0 0 0 0 0 1 " 90 - type="matrix" 91 - result="qminp" 92 - in="pq" 93 - id="feColorMatrix23" /> 94 - <feComposite 95 - k3="1" 96 - operator="arithmetic" 97 - result="qminpc" 98 - in="qminp" 99 - in2="qminp" 100 - id="feComposite23" 101 - k1="0" 102 - k2="0" 103 - k4="0" /> 104 - <feBlend 105 - result="result2" 106 - in2="SourceGraphic" 107 - mode="screen" 108 - id="feBlend24" /> 109 - <feComposite 110 - operator="in" 111 - in="result2" 112 - in2="SourceGraphic" 113 - result="result1" 114 - id="feComposite24" /> 115 - </filter> 116 - </defs> 117 - <sodipodi:namedview 118 - id="namedview1" 119 - pagecolor="#ffffff" 120 - bordercolor="#000000" 121 - borderopacity="0.25" 122 - inkscape:showpageshadow="2" 123 - inkscape:pageopacity="0.0" 124 - inkscape:pagecheckerboard="true" 125 - inkscape:deskcolor="#d5d5d5" 126 - inkscape:zoom="7.0916564" 127 - inkscape:cx="38.84847" 128 - inkscape:cy="31.515909" 129 - inkscape:window-width="1920" 130 - inkscape:window-height="1080" 131 - inkscape:window-x="0" 132 - inkscape:window-y="0" 133 - inkscape:window-maximized="0" 134 - inkscape:current-layer="g1"> 135 - <inkscape:page 136 - x="0" 137 - y="0" 138 - width="24.122343" 139 - height="23.274094" 140 - id="page2" 141 - margin="0" 142 - bleed="0" /> 143 - </sodipodi:namedview> 144 - <g 145 - inkscape:groupmode="layer" 146 - inkscape:label="Image" 147 - id="g1" 148 - transform="translate(-0.4388285,-0.8629527)"> 149 - <path 150 - style="fill:#ffffff;fill-opacity:1;stroke-width:0.111183;filter:url(#filter24)" 151 - d="m 16.348974,24.09935 -0.06485,-0.03766 -0.202005,-0.0106 -0.202008,-0.01048 -0.275736,-0.02601 -0.275734,-0.02602 v -0.02649 -0.02648 l -0.204577,-0.04019 -0.204578,-0.04019 -0.167616,-0.08035 -0.167617,-0.08035 -0.0014,-0.04137 -0.0014,-0.04137 -0.266473,-0.143735 -0.266475,-0.143735 -0.276098,-0.20335 -0.2761,-0.203347 -0.262064,-0.251949 -0.262064,-0.25195 -0.22095,-0.284628 -0.220948,-0.284629 -0.170253,-0.284631 -0.170252,-0.284628 -0.01341,-0.0144 -0.0134,-0.0144 -0.141982,0.161297 -0.14198,0.1613 -0.22313,0.21426 -0.223132,0.214264 -0.186025,0.146053 -0.186023,0.14605 -0.252501,0.163342 -0.252502,0.163342 -0.249014,0.115348 -0.249013,0.115336 0.0053,0.03241 0.0053,0.03241 -0.1716725,0.04599 -0.171669,0.046 -0.3379966,0.101058 -0.3379972,0.101058 -0.1778925,0.04506 -0.1778935,0.04508 -0.3913655,0.02601 -0.3913643,0.02603 -0.3557868,-0.03514 -0.3557863,-0.03514 -0.037426,-0.03029 -0.037427,-0.03029 -0.076924,0.02011 -0.076924,0.02011 -0.050508,-0.05051 -0.050405,-0.05056 L 6.6604532,23.110188 6.451745,23.063961 6.1546135,22.960559 5.8574835,22.857156 5.5319879,22.694039 5.2064938,22.530922 4.8793922,22.302961 4.5522905,22.075005 4.247598,21.786585 3.9429055,21.49817 3.7185335,21.208777 3.4941628,20.919385 3.3669822,20.705914 3.239803,20.492443 3.1335213,20.278969 3.0272397,20.065499 2.9015252,19.7275 2.7758105,19.389504 2.6925225,18.998139 2.6092345,18.606774 2.6096814,17.91299 2.6101284,17.219208 2.6744634,16.90029 2.7387984,16.581374 2.8474286,16.242088 2.9560588,15.9028 3.1137374,15.583492 3.2714148,15.264182 3.3415068,15.150766 3.4115988,15.03735 3.3127798,14.96945 3.2139618,14.90157 3.0360685,14.800239 2.8581753,14.698908 2.5913347,14.503228 2.3244955,14.307547 2.0621238,14.055599 1.7997507,13.803651 1.6111953,13.56878 1.4226411,13.333906 1.2632237,13.087474 1.1038089,12.841042 0.97442,12.575195 0.8450307,12.30935 0.724603,11.971351 0.6041766,11.633356 0.52150365,11.241991 0.4388285,10.850626 0.44091592,10.156842 0.44300333,9.4630594 0.54235911,9.0369608 0.6417149,8.6108622 0.7741173,8.2694368 0.9065196,7.9280115 1.0736303,7.6214262 1.2407515,7.3148397 1.45931,7.0191718 1.6778685,6.7235039 1.9300326,6.4611321 2.1821966,6.1987592 2.4134579,6.0137228 2.6447193,5.8286865 2.8759792,5.6776409 3.1072406,5.526594 3.4282004,5.3713977 3.7491603,5.2162016 3.9263009,5.1508695 4.1034416,5.0855373 4.2813348,4.7481598 4.4592292,4.4107823 4.6718,4.108422 4.8843733,3.8060618 5.198353,3.4805372 5.5123313,3.155014 5.7685095,2.9596425 6.0246877,2.7642722 6.329187,2.5851365 6.6336863,2.406002 6.9497657,2.2751596 7.2658453,2.1443184 7.4756394,2.0772947 7.6854348,2.01027 8.0825241,1.931086 8.4796139,1.851902 l 0.5870477,0.00291 0.5870469,0.00291 0.4447315,0.092455 0.444734,0.092455 0.302419,0.1105495 0.302417,0.1105495 0.329929,0.1646046 0.32993,0.1646033 0.239329,-0.2316919 0.239329,-0.2316919 0.160103,-0.1256767 0.160105,-0.1256767 0.160102,-0.1021909 0.160105,-0.1021899 0.142315,-0.082328 0.142314,-0.082328 0.231262,-0.1090091 0.231259,-0.1090091 0.26684,-0.098743 0.266839,-0.098743 0.320208,-0.073514 0.320209,-0.073527 0.355787,-0.041833 0.355785,-0.041834 0.426942,0.023827 0.426945,0.023828 0.355785,0.071179 0.355788,0.0711791 0.284627,0.09267 0.284629,0.09267 0.28514,0.1310267 0.28514,0.1310255 0.238179,0.1446969 0.238174,0.1446979 0.259413,0.1955332 0.259413,0.1955319 0.290757,0.296774 0.290758,0.2967753 0.151736,0.1941581 0.151734,0.1941594 0.135326,0.2149951 0.135327,0.2149952 0.154755,0.3202073 0.154758,0.3202085 0.09409,0.2677358 0.09409,0.267737 0.06948,0.3319087 0.06948,0.3319099 0.01111,0.00808 0.01111,0.00808 0.444734,0.2173653 0.444734,0.2173665 0.309499,0.2161102 0.309497,0.2161101 0.309694,0.2930023 0.309694,0.2930037 0.18752,0.2348726 0.187524,0.2348727 0.166516,0.2574092 0.166519,0.2574108 0.15273,0.3260252 0.152734,0.3260262 0.08972,0.2668403 0.08971,0.2668391 0.08295,0.3913655 0.08295,0.3913652 -6.21e-4,0.6582049 -6.21e-4,0.658204 -0.06362,0.315725 -0.06362,0.315725 -0.09046,0.289112 -0.09046,0.289112 -0.122759,0.281358 -0.12276,0.281356 -0.146626,0.252323 -0.146629,0.252322 -0.190443,0.258668 -0.190448,0.258671 -0.254911,0.268356 -0.254911,0.268355 -0.286872,0.223127 -0.286874,0.223127 -0.320203,0.187693 -0.320209,0.187693 -0.04347,0.03519 -0.04347,0.03521 0.0564,0.12989 0.0564,0.129892 0.08728,0.213472 0.08728,0.213471 0.189755,0.729363 0.189753,0.729362 0.0652,0.302417 0.0652,0.302419 -0.0018,0.675994 -0.0018,0.675995 -0.0801,0.373573 -0.08009,0.373577 -0.09,0.266839 -0.09,0.26684 -0.190389,0.391364 -0.19039,0.391366 -0.223169,0.320207 -0.223167,0.320209 -0.303585,0.315294 -0.303584,0.315291 -0.284631,0.220665 -0.284629,0.220663 -0.220128,0.132359 -0.220127,0.132358 -0.242395,0.106698 -0.242394,0.106699 -0.08895,0.04734 -0.08895,0.04733 -0.249052,0.07247 -0.24905,0.07247 -0.322042,0.0574 -0.322044,0.0574 -0.282794,-0.003 -0.282795,-0.003 -0.07115,-0.0031 -0.07115,-0.0031 -0.177894,-0.0033 -0.177893,-0.0033 -0.124528,0.02555 -0.124528,0.02555 z m -4.470079,-5.349839 0.214838,-0.01739 0.206601,-0.06782 0.206602,-0.06782 0.244389,-0.117874 0.244393,-0.11786 0.274473,-0.206822 0.27447,-0.20682 0.229308,-0.257201 0.229306,-0.2572 0.219161,-0.28463 0.219159,-0.284629 0.188541,-0.284628 0.188543,-0.28463 0.214594,-0.373574 0.214593,-0.373577 0.133861,-0.312006 0.133865,-0.312007 0.02861,-0.01769 0.02861,-0.01769 0.197275,0.26212 0.197278,0.262119 0.163613,0.150814 0.163614,0.150814 0.201914,0.09276 0.201914,0.09276 0.302417,0.01421 0.302418,0.01421 0.213472,-0.08025 0.213471,-0.08025 0.200606,-0.204641 0.200606,-0.204642 0.09242,-0.278887 0.09241,-0.278888 0.05765,-0.302418 0.05764,-0.302416 L 18.41327,13.768114 18.39502,13.34117 18.31849,12.915185 18.24196,12.4892 18.15595,12.168033 18.06994,11.846867 17.928869,11.444534 17.787801,11.042201 17.621278,10.73296 17.454757,10.423723 17.337388,10.263619 17.220021,10.103516 17.095645,9.9837986 16.971268,9.8640816 16.990048,9.6813736 17.008828,9.4986654 16.947568,9.249616 16.886308,9.0005655 16.752419,8.7159355 16.618521,8.4313217 16.435707,8.2294676 16.252892,8.0276114 16.079629,7.9004245 15.906366,7.773238 l -0.20429,0.1230127 -0.204289,0.1230121 -0.26702,0.059413 -0.267022,0.059413 -0.205761,-0.021508 -0.205766,-0.021508 -0.23495,-0.08844 -0.234953,-0.08844 -0.118429,-0.090334 -0.118428,-0.090333 h -0.03944 -0.03944 L 13.711268,7.8540732 13.655958,7.9706205 13.497227,8.1520709 13.338499,8.3335203 13.168394,8.4419112 12.998289,8.550301 12.777045,8.624223 12.5558,8.698155 H 12.275611 11.995429 L 11.799973,8.6309015 11.604513,8.5636472 11.491311,8.5051061 11.37811,8.446565 11.138172,8.2254579 10.898231,8.0043497 l -0.09565,-0.084618 -0.09565,-0.084613 -0.218822,0.198024 -0.218822,0.1980231 -0.165392,0.078387 -0.1653925,0.078387 -0.177894,0.047948 -0.177892,0.047948 L 9.3635263,8.4842631 9.144328,8.4846889 8.9195029,8.4147138 8.6946778,8.3447386 8.5931214,8.4414036 8.491565,8.5380686 8.3707618,8.7019598 8.2499597,8.8658478 8.0802403,8.9290726 7.9105231,8.9922974 7.7952769,9.0780061 7.6800299,9.1637148 7.5706169,9.2778257 7.4612038,9.3919481 7.1059768,9.9205267 6.7507497,10.449105 l -0.2159851,0.449834 -0.2159839,0.449834 -0.2216572,0.462522 -0.2216559,0.462523 -0.1459343,0.337996 -0.1459342,0.337998 -0.055483,0.220042 -0.055483,0.220041 -0.015885,0.206903 -0.015872,0.206901 0.034307,0.242939 0.034307,0.24294 0.096281,0.196632 0.096281,0.196634 0.143607,0.125222 0.1436071,0.125222 0.1873143,0.08737 0.1873141,0.08737 0.2752084,0.002 0.2752084,0.002 0.2312297,-0.09773 0.231231,-0.09772 0.1067615,-0.07603 0.1067614,-0.07603 0.3679062,-0.29377 0.3679065,-0.293771 0.026804,0.01656 0.026804,0.01656 0.023626,0.466819 0.023626,0.466815 0.088326,0.513195 0.088326,0.513193 0.08897,0.364413 0.08897,0.364411 0.1315362,0.302418 0.1315352,0.302418 0.1051964,0.160105 0.1051954,0.160103 0.1104741,0.11877 0.1104731,0.118769 0.2846284,0.205644 0.2846305,0.205642 0.144448,0.07312 0.144448,0.07312 0.214787,0.05566 0.214787,0.05566 0.245601,0.03075 0.245602,0.03075 0.204577,-0.0125 0.204578,-0.0125 z m 0.686342,-3.497495 -0.11281,-0.06077 -0.106155,-0.134033 -0.106155,-0.134031 -0.04406,-0.18371 -0.04406,-0.183707 0.02417,-0.553937 0.02417,-0.553936 0.03513,-0.426945 0.03513,-0.426942 0.07225,-0.373576 0.07225,-0.373575 0.05417,-0.211338 0.05417,-0.211339 0.0674,-0.132112 0.0674,-0.132112 0.132437,-0.10916 0.132437,-0.109161 0.187436,-0.04195 0.187438,-0.04195 0.170366,0.06469 0.170364,0.06469 0.114312,0.124073 0.114313,0.124086 0.04139,0.18495 0.04139,0.184951 -0.111218,0.459845 -0.111219,0.459844 -0.03383,0.26584 -0.03382,0.265841 -0.03986,0.818307 -0.03986,0.818309 -0.0378,0.15162 -0.03779,0.151621 -0.11089,0.110562 -0.110891,0.110561 -0.114489,0.04913 -0.114489,0.04913 -0.187932,-0.0016 -0.187929,-0.0016 z m -2.8087655,-0.358124 -0.146445,-0.06848 -0.088025,-0.119502 -0.088024,-0.119502 -0.038581,-0.106736 -0.038581,-0.106736 -0.02237,-0.134956 -0.02239,-0.134957 -0.031955,-0.46988 -0.031955,-0.469881 0.036203,-0.444733 0.036203,-0.444731 0.048862,-0.215257 0.048862,-0.215255 0.076082,-0.203349 0.076081,-0.203348 0.0936,-0.111244 0.0936,-0.111245 0.143787,-0.06531 0.1437865,-0.06532 h 0.142315 0.142314 l 0.142314,0.06588 0.142316,0.06588 0.093,0.102325 0.093,0.102325 0.04042,0.120942 0.04042,0.120942 v 0.152479 0.152477 l -0.03347,0.08804 -0.03347,0.08805 -0.05693,0.275653 -0.05693,0.275651 2.11e-4,0.430246 2.12e-4,0.430243 0.04294,0.392646 0.04295,0.392647 -0.09189,0.200702 -0.09189,0.200702 -0.148688,0.0984 -0.148687,0.0984 -0.20136,0.01212 -0.2013595,0.01212 z" 152 - id="path4" /> 153 - </g> 154 - </svg>
-238
web/src/components/AddToCollectionModal.tsx
··· 1 - 2 - import React, { useState, useEffect, useCallback } from 'react'; 3 - import { X, Plus, Check, Loader2, ChevronRight, FolderPlus } from 'lucide-react'; 4 - import { useStore } from '@nanostores/react'; 5 - import { $user } from '../store/auth'; 6 - import { getCollections, addCollectionItem, createCollection, type Collection } from '../api/client'; 7 - 8 - interface AddToCollectionModalProps { 9 - isOpen: boolean; 10 - onClose: () => void; 11 - annotationUri: string; 12 - } 13 - 14 - export default function AddToCollectionModal({ isOpen, onClose, annotationUri }: AddToCollectionModalProps) { 15 - const user = useStore($user); 16 - const [collections, setCollections] = useState<Collection[]>([]); 17 - const [loading, setLoading] = useState(true); 18 - const [addingTo, setAddingTo] = useState<string | null>(null); 19 - const [addedTo, setAddedTo] = useState<Set<string>>(new Set()); 20 - const [error, setError] = useState<string | null>(null); 21 - 22 - const [showNewForm, setShowNewForm] = useState(false); 23 - const [newName, setNewName] = useState(''); 24 - const [creating, setCreating] = useState(false); 25 - 26 - useEffect(() => { 27 - if (isOpen) { 28 - document.body.style.overflow = 'hidden'; 29 - } 30 - return () => { 31 - document.body.style.overflow = 'unset'; 32 - }; 33 - }, [isOpen]); 34 - 35 - const loadCollections = useCallback(async () => { 36 - if (!user) return; 37 - try { 38 - setLoading(true); 39 - const data = await getCollections(user.did); 40 - setCollections(data); 41 - } catch (err) { 42 - console.error(err); 43 - setError('Failed to load collections'); 44 - } finally { 45 - setLoading(false); 46 - } 47 - }, [user]); 48 - 49 - useEffect(() => { 50 - if (isOpen && user) { 51 - loadCollections(); 52 - setError(null); 53 - setAddedTo(new Set()); 54 - } 55 - }, [isOpen, user, loadCollections]); 56 - 57 - const handleAdd = async (collectionUri: string) => { 58 - if (addedTo.has(collectionUri)) return; 59 - 60 - try { 61 - setAddingTo(collectionUri); 62 - await addCollectionItem(collectionUri, annotationUri); 63 - setAddedTo(prev => new Set([...prev, collectionUri])); 64 - } catch (err) { 65 - console.error(err); 66 - setError('Failed to add to collection'); 67 - } finally { 68 - setAddingTo(null); 69 - } 70 - }; 71 - 72 - const handleCreate = async (e: React.FormEvent) => { 73 - e.preventDefault(); 74 - if (!newName.trim()) return; 75 - try { 76 - setCreating(true); 77 - const newCollection = await createCollection(newName.trim()); 78 - if (newCollection) { 79 - setCollections(prev => [newCollection, ...prev]); 80 - setNewName(''); 81 - setShowNewForm(false); 82 - } 83 - } catch (err) { 84 - console.error(err); 85 - setError('Failed to create collection'); 86 - } finally { 87 - setCreating(false); 88 - } 89 - }; 90 - 91 - if (!isOpen) return null; 92 - 93 - return ( 94 - <div 95 - className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-fade-in" 96 - onClick={onClose} 97 - > 98 - <div 99 - className="w-full max-w-md bg-white dark:bg-surface-900 rounded-3xl shadow-2xl overflow-hidden" 100 - onClick={e => e.stopPropagation()} 101 - > 102 - <div className="p-4 flex justify-between items-center border-b border-surface-100 dark:border-surface-800"> 103 - <h2 className="text-xl font-display font-bold text-surface-900 dark:text-white">Add to Collection</h2> 104 - <button 105 - onClick={onClose} 106 - className="p-2 text-surface-400 hover:text-surface-900 dark:hover:text-white hover:bg-surface-50 dark:hover:bg-surface-800 rounded-full transition-colors" 107 - > 108 - <X size={20} /> 109 - </button> 110 - </div> 111 - 112 - <div className="px-6 pb-6 pt-4"> 113 - {loading ? ( 114 - <div className="text-center py-10"> 115 - <Loader2 size={32} className="animate-spin text-primary-600 dark:text-primary-400 mx-auto mb-3" /> 116 - <p className="text-surface-500 dark:text-surface-400 font-medium">Loading collections...</p> 117 - </div> 118 - ) : showNewForm ? ( 119 - <form onSubmit={handleCreate} className="space-y-4"> 120 - <div> 121 - <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1"> 122 - Collection name 123 - </label> 124 - <input 125 - type="text" 126 - className="w-full px-4 py-3 bg-surface-50 dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-xl focus:border-primary-500 dark:focus:border-primary-400 focus:ring-4 focus:ring-primary-500/10 outline-none transition-all text-surface-900 dark:text-white placeholder-surface-400 dark:placeholder-surface-500" 127 - value={newName} 128 - onChange={e => setNewName(e.target.value)} 129 - placeholder="My Collection" 130 - autoFocus 131 - /> 132 - </div> 133 - 134 - {error && ( 135 - <div className="p-3 bg-red-50 dark:bg-red-900/30 text-red-600 dark:text-red-400 text-sm rounded-lg"> 136 - {error} 137 - </div> 138 - )} 139 - 140 - <div className="flex gap-3 pt-2"> 141 - <button 142 - type="button" 143 - className="flex-1 py-3 bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 text-surface-700 dark:text-surface-200 font-semibold rounded-xl hover:bg-surface-50 dark:hover:bg-surface-700 transition-colors" 144 - onClick={() => { 145 - setShowNewForm(false); 146 - setError(null); 147 - }} 148 - > 149 - Back 150 - </button> 151 - <button 152 - type="submit" 153 - className="flex-1 py-3 bg-primary-600 text-white font-semibold rounded-xl hover:bg-primary-700 transition-colors disabled:opacity-50 flex items-center justify-center gap-2" 154 - disabled={!newName.trim() || creating} 155 - > 156 - {creating && <Loader2 size={16} className="animate-spin" />} 157 - {creating ? 'Creating...' : 'Create'} 158 - </button> 159 - </div> 160 - </form> 161 - ) : ( 162 - <div> 163 - {error && ( 164 - <div className="mb-4 p-3 bg-red-50 dark:bg-red-900/30 text-red-600 dark:text-red-400 text-sm rounded-lg"> 165 - {error} 166 - </div> 167 - )} 168 - 169 - <button 170 - className="w-full flex items-center gap-4 p-4 bg-white dark:bg-surface-800 border-2 border-primary-100 dark:border-primary-900/50 hover:border-primary-300 dark:hover:border-primary-700 rounded-2xl shadow-sm hover:shadow-md transition-all group text-left mb-4" 171 - onClick={() => setShowNewForm(true)} 172 - > 173 - <div className="w-10 h-10 bg-primary-50 dark:bg-primary-900/30 rounded-full flex items-center justify-center text-primary-600 dark:text-primary-400 flex-shrink-0"> 174 - <FolderPlus size={20} /> 175 - </div> 176 - <div className="flex-1 min-w-0"> 177 - <h3 className="font-bold text-surface-900 dark:text-white group-hover:text-primary-700 dark:group-hover:text-primary-400 transition-colors">New Collection</h3> 178 - <span className="text-sm text-surface-500 dark:text-surface-400">Create a new collection</span> 179 - </div> 180 - <ChevronRight size={20} className="text-surface-300 dark:text-surface-600 group-hover:text-primary-500 dark:group-hover:text-primary-400" /> 181 - </button> 182 - 183 - {collections.length === 0 ? ( 184 - <div className="text-center py-6"> 185 - <p className="text-surface-500 dark:text-surface-400">No collections yet</p> 186 - </div> 187 - ) : ( 188 - <div className="space-y-2 max-h-[300px] overflow-y-auto"> 189 - {collections.map(col => { 190 - const isAdded = addedTo.has(col.uri); 191 - const isAdding = addingTo === col.uri; 192 - 193 - return ( 194 - <button 195 - key={col.uri} 196 - onClick={() => handleAdd(col.uri)} 197 - disabled={isAdding || isAdded} 198 - className="w-full flex items-center gap-3 p-3 bg-surface-50 dark:bg-surface-800/50 hover:bg-surface-100 dark:hover:bg-surface-800 rounded-xl transition-colors text-left group disabled:opacity-70" 199 - > 200 - <div className="w-8 h-8 flex items-center justify-center bg-white dark:bg-surface-700 rounded-full shadow-sm text-surface-600 dark:text-surface-300"> 201 - {col.icon ? ( 202 - <span className="text-base">{col.icon}</span> 203 - ) : ( 204 - <span className="font-bold text-xs">{col.name[0]}</span> 205 - )} 206 - </div> 207 - <div className="flex-1 min-w-0"> 208 - <h3 className="text-sm font-bold text-surface-900 dark:text-white">{col.name}</h3> 209 - {col.description && ( 210 - <p className="text-xs text-surface-500 dark:text-surface-400 line-clamp-1">{col.description}</p> 211 - )} 212 - </div> 213 - {isAdding ? ( 214 - <Loader2 size={16} className="animate-spin text-surface-400" /> 215 - ) : isAdded ? ( 216 - <Check size={16} className="text-green-500" /> 217 - ) : ( 218 - <Plus size={16} className="text-surface-300 dark:text-surface-500 group-hover:text-surface-600 dark:group-hover:text-surface-300" /> 219 - )} 220 - </button> 221 - ); 222 - })} 223 - </div> 224 - )} 225 - 226 - <button 227 - onClick={onClose} 228 - className="w-full mt-4 py-3 bg-surface-900 dark:bg-white text-white dark:text-surface-900 font-semibold rounded-xl hover:bg-surface-800 dark:hover:bg-surface-100 transition-colors" 229 - > 230 - Done 231 - </button> 232 - </div> 233 - )} 234 - </div> 235 - </div> 236 - </div> 237 - ); 238 - }
-239
web/src/components/Card.tsx
··· 1 - 2 - import React, { useState } from 'react'; 3 - import { formatDistanceToNow } from 'date-fns'; 4 - import { MessageSquare, Heart, ExternalLink, FolderPlus, Trash2, Edit3, Bookmark as BookmarkIcon, Globe } from 'lucide-react'; 5 - import ShareMenu from './ShareMenu'; 6 - import AddToCollectionModal from './AddToCollectionModal'; 7 - import { clsx } from 'clsx'; 8 - import { likeItem, unlikeItem, deleteItem, getAvatarUrl } from '../api/client'; 9 - import { $user } from '../store/auth'; 10 - import { useStore } from '@nanostores/react'; 11 - import type { AnnotationItem } from '../types'; 12 - import { Link } from 'react-router-dom'; 13 - 14 - interface CardProps { 15 - item: AnnotationItem; 16 - onDelete?: (uri: string) => void; 17 - hideShare?: boolean; 18 - } 19 - 20 - export default function Card({ item, onDelete, hideShare }: CardProps) { 21 - const user = useStore($user); 22 - const isAuthor = user && item.author?.did === user.did; 23 - 24 - const [liked, setLiked] = useState(!!item.viewer?.like); 25 - const [likes, setLikes] = useState(item.likeCount || 0); 26 - const [showCollectionModal, setShowCollectionModal] = useState(false); 27 - 28 - const type = item.motivation === 'highlighting' ? 'highlight' : 29 - item.motivation === 'bookmarking' ? 'bookmark' : 'annotation'; 30 - 31 - const isSemble = item.uri?.includes('network.cosmik') || item.uri?.includes('semble'); 32 - 33 - const handleLike = async () => { 34 - const prev = { liked, likes }; 35 - setLiked(!liked); 36 - setLikes(l => liked ? l - 1 : l + 1); 37 - 38 - const success = liked 39 - ? await unlikeItem(item.uri) 40 - : await likeItem(item.uri, item.cid); 41 - 42 - if (!success) { 43 - setLiked(prev.liked); 44 - setLikes(prev.likes); 45 - } 46 - }; 47 - 48 - const handleDelete = async () => { 49 - if (window.confirm('Delete this item?')) { 50 - const success = await deleteItem(item.uri, type); 51 - if (success && onDelete) onDelete(item.uri); 52 - } 53 - }; 54 - 55 - const timestamp = item.createdAt 56 - ? formatDistanceToNow(new Date(item.createdAt), { addSuffix: false }) 57 - .replace('about ', '') 58 - .replace(' hours', 'h') 59 - .replace(' hour', 'h') 60 - .replace(' minutes', 'm') 61 - .replace(' minute', 'm') 62 - .replace(' days', 'd') 63 - .replace(' day', 'd') 64 - : ''; 65 - 66 - const pageUrl = item.target?.source; 67 - const pageTitle = item.target?.title || (pageUrl ? new URL(pageUrl).hostname : null); 68 - const pageHostname = pageUrl ? new URL(pageUrl).hostname.replace('www.', '') : null; 69 - const isBookmark = type === 'bookmark' && !item.target?.selector && !item.body?.value; 70 - 71 - return ( 72 - <article className="bg-white dark:bg-surface-900 rounded-lg p-4 mb-3 shadow-sm ring-1 ring-black/5 dark:ring-white/5 hover:ring-black/10 dark:hover:ring-white/10 transition-all"> 73 - {item.collection && ( 74 - <Link 75 - to={`/${item.addedBy?.handle || ''}/collection/${item.collection.uri.split('/').pop()}`} 76 - className="inline-flex items-center gap-1 text-xs text-surface-400 dark:text-surface-500 hover:text-primary-600 dark:hover:text-primary-400 mb-2 transition-colors" 77 - > 78 - {item.collection.icon || '📁'} {item.collection.name} 79 - </Link> 80 - )} 81 - 82 - <div className="flex items-start gap-3"> 83 - <Link to={`/profile/${item.author?.did}`} className="shrink-0"> 84 - {getAvatarUrl(item.author?.did, item.author?.avatar) ? ( 85 - <img 86 - src={getAvatarUrl(item.author?.did, item.author?.avatar)} 87 - alt="" 88 - className="w-10 h-10 rounded-full bg-surface-100 dark:bg-surface-800 object-cover" 89 - /> 90 - ) : ( 91 - <div className="w-10 h-10 rounded-full bg-surface-100 dark:bg-surface-800" /> 92 - )} 93 - </Link> 94 - 95 - <div className="flex-1 min-w-0"> 96 - <div className="flex items-center gap-1.5 flex-wrap"> 97 - <Link to={`/profile/${item.author?.did}`} className="font-semibold text-surface-900 dark:text-white text-[15px] hover:underline"> 98 - {item.author?.displayName || item.author?.handle} 99 - </Link> 100 - <span className="text-surface-400 dark:text-surface-500 text-sm"> 101 - @{item.author?.handle} 102 - </span> 103 - <span className="text-surface-300 dark:text-surface-600">·</span> 104 - <span className="text-surface-400 dark:text-surface-500 text-sm"> 105 - {timestamp} 106 - </span> 107 - {isSemble && ( 108 - <span className="inline-flex items-center gap-0.5 text-[10px] text-surface-400 dark:text-surface-500 uppercase font-medium tracking-wide"> 109 - · via <img src="/semble-logo.svg" alt="Semble" className="h-3 inline opacity-70" /> 110 - </span> 111 - )} 112 - </div> 113 - 114 - {pageUrl && !isBookmark && ( 115 - <a 116 - href={pageUrl} 117 - target="_blank" 118 - rel="noopener noreferrer" 119 - className="inline-flex items-center gap-1 text-xs text-primary-600 dark:text-primary-400 hover:underline mt-0.5" 120 - > 121 - <ExternalLink size={10} /> 122 - {pageHostname} 123 - </a> 124 - )} 125 - </div> 126 - </div> 127 - 128 - <div className="mt-3 ml-[52px]"> 129 - {isBookmark && pageUrl && ( 130 - <a 131 - href={pageUrl} 132 - target="_blank" 133 - rel="noopener noreferrer" 134 - className="block p-3 bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 hover:border-primary-300 dark:hover:border-primary-600 hover:bg-surface-100 dark:hover:bg-surface-700/50 transition-all group" 135 - > 136 - <div className="flex items-start gap-3"> 137 - <div className="shrink-0 w-10 h-10 bg-surface-200 dark:bg-surface-700 rounded-lg flex items-center justify-center text-surface-400 dark:text-surface-500 group-hover:text-primary-500 dark:group-hover:text-primary-400"> 138 - <Globe size={20} /> 139 - </div> 140 - <div className="flex-1 min-w-0"> 141 - <h3 className="font-medium text-surface-900 dark:text-white text-sm leading-snug group-hover:text-primary-600 dark:group-hover:text-primary-400 line-clamp-2"> 142 - {pageTitle} 143 - </h3> 144 - <p className="text-xs text-surface-500 dark:text-surface-400 mt-1 truncate"> 145 - {pageUrl} 146 - </p> 147 - </div> 148 - <ExternalLink size={14} className="shrink-0 text-surface-300 dark:text-surface-600 group-hover:text-primary-500 dark:group-hover:text-primary-400" /> 149 - </div> 150 - </a> 151 - )} 152 - 153 - {item.target?.selector && ( 154 - <blockquote className={clsx( 155 - "pl-3 border-l-[3px] mb-2 text-[15px] italic text-surface-600 dark:text-surface-300", 156 - item.color === 'yellow' && "border-yellow-400 bg-yellow-50/50 dark:bg-yellow-900/20", 157 - item.color === 'green' && "border-green-400 bg-green-50/50 dark:bg-green-900/20", 158 - item.color === 'red' && "border-red-400 bg-red-50/50 dark:bg-red-900/20", 159 - item.color === 'blue' && "border-blue-400 bg-blue-50/50 dark:bg-blue-900/20", 160 - !item.color && "border-surface-300 dark:border-surface-600" 161 - )}> 162 - "{item.target.selector.exact}" 163 - </blockquote> 164 - )} 165 - 166 - {item.body?.value && ( 167 - <p className="text-surface-900 dark:text-surface-100 whitespace-pre-wrap leading-relaxed text-[15px]"> 168 - {item.body.value} 169 - </p> 170 - )} 171 - </div> 172 - 173 - <div className="flex items-center gap-1 mt-3 ml-[52px]"> 174 - <button 175 - onClick={handleLike} 176 - className={clsx( 177 - "flex items-center gap-1 px-2 py-1.5 rounded-md text-sm transition-colors", 178 - liked 179 - ? "text-red-500" 180 - : "text-surface-400 dark:text-surface-500 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20" 181 - )} 182 - > 183 - <Heart size={16} className={clsx(liked && "fill-current")} /> 184 - {likes > 0 && <span className="text-xs">{likes}</span>} 185 - </button> 186 - 187 - <button className="flex items-center gap-1 px-2 py-1.5 rounded-md text-sm text-surface-400 dark:text-surface-500 hover:text-primary-600 dark:hover:text-primary-400 hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-colors"> 188 - <MessageSquare size={16} /> 189 - {(item.replyCount || 0) > 0 && <span className="text-xs">{item.replyCount}</span>} 190 - </button> 191 - 192 - {user && ( 193 - <button 194 - onClick={() => setShowCollectionModal(true)} 195 - className="flex items-center px-2 py-1.5 rounded-md text-surface-400 dark:text-surface-500 hover:text-primary-600 dark:hover:text-primary-400 hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-colors" 196 - title="Add to Collection" 197 - > 198 - <FolderPlus size={16} /> 199 - </button> 200 - )} 201 - 202 - {!hideShare && ( 203 - <ShareMenu 204 - uri={item.uri} 205 - text={item.body?.value || ''} 206 - handle={item.author?.handle} 207 - type={type} 208 - url={pageUrl} 209 - /> 210 - )} 211 - 212 - {isAuthor && ( 213 - <> 214 - <div className="flex-1" /> 215 - <button 216 - className="flex items-center px-2 py-1.5 rounded-md text-surface-400 dark:text-surface-500 hover:text-surface-600 dark:hover:text-surface-300 hover:bg-surface-50 dark:hover:bg-surface-800 transition-colors" 217 - title="Edit" 218 - > 219 - <Edit3 size={14} /> 220 - </button> 221 - <button 222 - onClick={handleDelete} 223 - className="flex items-center px-2 py-1.5 rounded-md text-surface-400 dark:text-surface-500 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors" 224 - title="Delete" 225 - > 226 - <Trash2 size={14} /> 227 - </button> 228 - </> 229 - )} 230 - </div> 231 - 232 - <AddToCollectionModal 233 - isOpen={showCollectionModal} 234 - onClose={() => setShowCollectionModal(false)} 235 - annotationUri={item.uri} 236 - /> 237 - </article> 238 - ); 239 - }
-124
web/src/components/CollectionIcon.tsx
··· 1 - import React from 'react'; 2 - import { 3 - Folder, 4 - Star, 5 - Heart, 6 - Bookmark, 7 - Lightbulb, 8 - Zap, 9 - Coffee, 10 - Music, 11 - Camera, 12 - Code, 13 - Globe, 14 - Flag, 15 - Tag, 16 - Box, 17 - Archive, 18 - FileText, 19 - Image, 20 - Video, 21 - Mail, 22 - MapPin, 23 - Calendar, 24 - Clock, 25 - Search, 26 - Settings, 27 - User, 28 - Users, 29 - Home, 30 - Briefcase, 31 - Gift, 32 - Award, 33 - Target, 34 - TrendingUp, 35 - Activity, 36 - Cpu, 37 - Database, 38 - Cloud, 39 - Sun, 40 - Moon, 41 - Flame, 42 - Leaf, 43 - } from "lucide-react"; 44 - 45 - export const ICON_MAP: Record<string, React.ElementType> = { 46 - folder: Folder, 47 - star: Star, 48 - heart: Heart, 49 - bookmark: Bookmark, 50 - lightbulb: Lightbulb, 51 - zap: Zap, 52 - coffee: Coffee, 53 - music: Music, 54 - camera: Camera, 55 - code: Code, 56 - globe: Globe, 57 - flag: Flag, 58 - tag: Tag, 59 - box: Box, 60 - archive: Archive, 61 - file: FileText, 62 - image: Image, 63 - video: Video, 64 - mail: Mail, 65 - pin: MapPin, 66 - calendar: Calendar, 67 - clock: Clock, 68 - search: Search, 69 - settings: Settings, 70 - user: User, 71 - users: Users, 72 - home: Home, 73 - briefcase: Briefcase, 74 - gift: Gift, 75 - award: Award, 76 - target: Target, 77 - trending: TrendingUp, 78 - activity: Activity, 79 - cpu: Cpu, 80 - database: Database, 81 - cloud: Cloud, 82 - sun: Sun, 83 - moon: Moon, 84 - flame: Flame, 85 - leaf: Leaf, 86 - }; 87 - 88 - interface CollectionIconProps { 89 - icon?: string; 90 - size?: number; 91 - className?: string; 92 - } 93 - 94 - export default function CollectionIcon({ icon, size = 22, className = "" }: CollectionIconProps) { 95 - if (!icon) { 96 - return <Folder size={size} className={className} />; 97 - } 98 - 99 - if (icon === "icon:semble") { 100 - return ( 101 - <img 102 - src="/semble-logo.svg" 103 - alt="Semble" 104 - style={{ width: size, height: size, objectFit: "contain" }} 105 - className={className} 106 - /> 107 - ); 108 - } 109 - 110 - if (icon.startsWith("icon:")) { 111 - const iconName = icon.replace("icon:", ""); 112 - const IconComponent = ICON_MAP[iconName]; 113 - if (IconComponent) { 114 - return <IconComponent size={size} className={className} />; 115 - } 116 - return <Folder size={size} className={className} />; 117 - } 118 - 119 - return ( 120 - <span style={{ fontSize: `${size * 0.8}px`, lineHeight: 1 }} className={className}> 121 - {icon} 122 - </span> 123 - ); 124 - }
-162
web/src/components/Composer.tsx
··· 1 - 2 - import React, { useState } from 'react'; 3 - import { createAnnotation, createHighlight } from '../api/client'; 4 - import { X } from 'lucide-react'; 5 - 6 - interface ComposerProps { 7 - url: string; 8 - selector?: any; 9 - onSuccess?: () => void; 10 - onCancel?: () => void; 11 - } 12 - 13 - export default function Composer({ url, selector: initialSelector, onSuccess, onCancel }: ComposerProps) { 14 - const [text, setText] = useState(""); 15 - const [quoteText, setQuoteText] = useState(""); 16 - const [tags, setTags] = useState(""); 17 - const [selector, setSelector] = useState(initialSelector); 18 - const [loading, setLoading] = useState(false); 19 - const [error, setError] = useState<string | null>(null); 20 - const [showQuoteInput, setShowQuoteInput] = useState(false); 21 - 22 - const highlightedText = selector?.type === "TextQuoteSelector" ? selector.exact : null; 23 - 24 - const handleSubmit = async (e: React.FormEvent) => { 25 - e.preventDefault(); 26 - if (!text.trim() && !highlightedText && !quoteText.trim()) return; 27 - 28 - try { 29 - setLoading(true); 30 - setError(null); 31 - 32 - let finalSelector = selector; 33 - if (!finalSelector && quoteText.trim()) { 34 - finalSelector = { 35 - type: "TextQuoteSelector", 36 - exact: quoteText.trim(), 37 - }; 38 - } 39 - 40 - const tagList = tags.split(",").map((t) => t.trim()).filter(Boolean); 41 - 42 - if (!text.trim()) { 43 - await createHighlight({ url, selector: finalSelector, color: "yellow", tags: tagList }); 44 - } else { 45 - await createAnnotation({ url, text: text.trim(), selector: finalSelector || undefined, tags: tagList }); 46 - } 47 - 48 - setText(""); 49 - setQuoteText(""); 50 - setSelector(null); 51 - if (onSuccess) onSuccess(); 52 - } catch (err: any) { 53 - setError(err.message || "Failed to post"); 54 - } finally { 55 - setLoading(false); 56 - } 57 - }; 58 - 59 - const handleRemoveSelector = () => { 60 - setSelector(null); 61 - setQuoteText(""); 62 - setShowQuoteInput(false); 63 - }; 64 - 65 - return ( 66 - <form onSubmit={handleSubmit} className="flex flex-col gap-4"> 67 - <div className="flex items-center justify-between"> 68 - <h3 className="text-lg font-bold text-surface-900 dark:text-white">New Annotation</h3> 69 - {url && <div className="text-xs text-surface-400 dark:text-surface-500 max-w-[200px] truncate">{url}</div>} 70 - </div> 71 - 72 - {highlightedText && ( 73 - <div className="relative p-3 bg-surface-50 dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-lg"> 74 - <button 75 - type="button" 76 - className="absolute top-2 right-2 text-surface-400 dark:text-surface-500 hover:text-surface-600 dark:hover:text-surface-300" 77 - onClick={handleRemoveSelector} 78 - > 79 - <X size={16} /> 80 - </button> 81 - <blockquote className="italic text-surface-600 dark:text-surface-300 border-l-2 border-primary-400 dark:border-primary-500 pl-3 text-sm"> 82 - "{highlightedText}" 83 - </blockquote> 84 - </div> 85 - )} 86 - 87 - {!highlightedText && ( 88 - <> 89 - {!showQuoteInput ? ( 90 - <button 91 - type="button" 92 - className="text-left text-sm text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 font-medium py-1" 93 - onClick={() => setShowQuoteInput(true)} 94 - > 95 - + Add a quote from the page 96 - </button> 97 - ) : ( 98 - <div className="flex flex-col gap-2"> 99 - <textarea 100 - value={quoteText} 101 - onChange={(e) => setQuoteText(e.target.value)} 102 - placeholder="Paste or type the text you're annotating..." 103 - className="w-full text-sm p-3 bg-surface-50 dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-lg text-surface-900 dark:text-white placeholder:text-surface-400 dark:placeholder:text-surface-500 focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500 dark:focus:border-primary-400 outline-none" 104 - rows={2} 105 - /> 106 - <div className="flex justify-end"> 107 - <button type="button" className="text-xs text-red-500 dark:text-red-400 font-medium" onClick={handleRemoveSelector}> 108 - Remove Quote 109 - </button> 110 - </div> 111 - </div> 112 - )} 113 - </> 114 - )} 115 - 116 - <textarea 117 - value={text} 118 - onChange={(e) => setText(e.target.value)} 119 - placeholder={highlightedText || quoteText ? "Add your comment..." : "Write your annotation..."} 120 - className="w-full p-3 bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-lg text-surface-900 dark:text-white placeholder:text-surface-400 dark:placeholder:text-surface-500 focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500 dark:focus:border-primary-400 outline-none min-h-[100px] resize-none" 121 - maxLength={3000} 122 - disabled={loading} 123 - /> 124 - 125 - <input 126 - type="text" 127 - value={tags} 128 - onChange={(e) => setTags(e.target.value)} 129 - placeholder="Tags (comma separated)" 130 - className="w-full p-2.5 bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-lg text-surface-900 dark:text-white placeholder:text-surface-400 dark:placeholder:text-surface-500 focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500 dark:focus:border-primary-400 outline-none text-sm" 131 - disabled={loading} 132 - /> 133 - 134 - <div className="flex items-center justify-between pt-2"> 135 - <span className={text.length > 2900 ? "text-red-500 dark:text-red-400 text-xs font-medium" : "text-surface-400 dark:text-surface-500 text-xs"}> 136 - {text.length}/3000 137 - </span> 138 - <div className="flex items-center gap-2"> 139 - {onCancel && ( 140 - <button 141 - type="button" 142 - className="text-sm font-medium text-surface-500 dark:text-surface-400 hover:text-surface-800 dark:hover:text-surface-200 px-3 py-1.5" 143 - onClick={onCancel} 144 - disabled={loading} 145 - > 146 - Cancel 147 - </button> 148 - )} 149 - <button 150 - type="submit" 151 - className="bg-primary-600 hover:bg-primary-700 text-white font-medium px-4 py-1.5 rounded-lg transition-colors disabled:opacity-50 text-sm" 152 - disabled={loading || (!text.trim() && !highlightedText && !quoteText.trim())} 153 - > 154 - {loading ? "..." : "Post"} 155 - </button> 156 - </div> 157 - </div> 158 - 159 - {error && <div className="text-red-500 dark:text-red-400 text-sm text-center bg-red-50 dark:bg-red-900/20 py-2 rounded-lg">{error}</div>} 160 - </form> 161 - ); 162 - }
-205
web/src/components/EditProfileModal.tsx
··· 1 - import React, { useState, useRef } from 'react'; 2 - import { updateProfile, uploadAvatar, getAvatarUrl } from '../api/client'; 3 - import type { UserProfile } from '../types'; 4 - import { Loader2, X, Plus, User as UserIcon } from 'lucide-react'; 5 - 6 - interface EditProfileModalProps { 7 - profile: UserProfile; 8 - onClose: () => void; 9 - onUpdate: (updatedProfile: UserProfile) => void; 10 - } 11 - 12 - export default function EditProfileModal({ profile, onClose, onUpdate }: EditProfileModalProps) { 13 - const [displayName, setDisplayName] = useState(profile.displayName || ''); 14 - const [description, setDescription] = useState(profile.description || ''); 15 - const [website, setWebsite] = useState(profile.website || ''); 16 - const [links, setLinks] = useState<string[]>(profile.links || []); 17 - const [newLink, setNewLink] = useState(''); 18 - 19 - const [avatarBlob, setAvatarBlob] = useState<Blob | null>(null); 20 - const [avatarPreview, setAvatarPreview] = useState<string | null>(null); 21 - const [uploading, setUploading] = useState(false); 22 - 23 - const [saving, setSaving] = useState(false); 24 - const [error, setError] = useState<string | null>(null); 25 - const fileInputRef = useRef<HTMLInputElement>(null); 26 - 27 - const handleAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => { 28 - const file = e.target.files?.[0]; 29 - if (!file) return; 30 - 31 - if (!['image/jpeg', 'image/png'].includes(file.type)) { 32 - setError('Please select a JPEG or PNG image'); 33 - return; 34 - } 35 - 36 - if (file.size > 1024 * 1024 * 2) { 37 - setError('Image must be under 2MB'); 38 - return; 39 - } 40 - 41 - setAvatarPreview(URL.createObjectURL(file)); 42 - setAvatarBlob(file); 43 - 44 - setUploading(true); 45 - try { 46 - const result = await uploadAvatar(file); 47 - setAvatarBlob(result.blob); 48 - } catch (err: any) { 49 - setError('Failed to upload: ' + err.message); 50 - setAvatarPreview(null); 51 - } finally { 52 - setUploading(false); 53 - } 54 - }; 55 - 56 - const handleAddLink = () => { 57 - if (!newLink) return; 58 - if (!links.includes(newLink)) { 59 - setLinks([...links, newLink]); 60 - setNewLink(''); 61 - } 62 - }; 63 - 64 - const handleRemoveLink = (index: number) => { 65 - setLinks(links.filter((_, i) => i !== index)); 66 - }; 67 - 68 - const handleSubmit = async (e: React.FormEvent) => { 69 - e.preventDefault(); 70 - setSaving(true); 71 - setError(null); 72 - 73 - try { 74 - await updateProfile({ displayName, description, website, links, avatar: avatarBlob }); 75 - onUpdate({ ...profile, displayName, description, website, links, avatar: avatarPreview || profile.avatar }); 76 - onClose(); 77 - } catch (err: any) { 78 - setError(err.message); 79 - } finally { 80 - setSaving(false); 81 - } 82 - }; 83 - 84 - const currentAvatar = avatarPreview || getAvatarUrl(profile.did, profile.avatar); 85 - 86 - return ( 87 - <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4" onClick={onClose}> 88 - <div className="bg-white dark:bg-surface-900 rounded-xl w-full max-w-md overflow-hidden shadow-2xl ring-1 ring-black/5 dark:ring-white/10" onClick={e => e.stopPropagation()}> 89 - <div className="flex items-center justify-between p-4 border-b border-surface-100 dark:border-surface-800"> 90 - <h2 className="text-lg font-bold text-surface-900 dark:text-white">Edit Profile</h2> 91 - <button onClick={onClose} className="p-1.5 hover:bg-surface-100 dark:hover:bg-surface-800 rounded-lg transition-colors text-surface-500 dark:text-surface-400"> 92 - <X size={18} /> 93 - </button> 94 - </div> 95 - 96 - <form onSubmit={handleSubmit} className="p-5 overflow-y-auto max-h-[80vh]"> 97 - {error && ( 98 - <div className="mb-4 p-3 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-lg text-sm border border-red-100 dark:border-red-800"> 99 - {error} 100 - </div> 101 - )} 102 - 103 - <div className="mb-5"> 104 - <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2">Avatar</label> 105 - <div className="flex items-center gap-3"> 106 - <div 107 - className="relative w-16 h-16 rounded-full bg-surface-100 dark:bg-surface-800 overflow-hidden cursor-pointer group border border-surface-200 dark:border-surface-700" 108 - onClick={() => fileInputRef.current?.click()} 109 - > 110 - {currentAvatar ? ( 111 - <img src={currentAvatar} alt="" className="w-full h-full object-cover" /> 112 - ) : ( 113 - <div className="w-full h-full flex items-center justify-center text-surface-400 dark:text-surface-500"> 114 - <UserIcon size={24} /> 115 - </div> 116 - )} 117 - <div className="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"> 118 - <span className="text-white text-xs font-medium">Edit</span> 119 - </div> 120 - </div> 121 - <input ref={fileInputRef} type="file" accept="image/jpeg,image/png" onChange={handleAvatarChange} className="hidden" /> 122 - <button 123 - type="button" 124 - onClick={() => fileInputRef.current?.click()} 125 - className="px-3 py-1.5 rounded-lg bg-surface-100 dark:bg-surface-800 hover:bg-surface-200 dark:hover:bg-surface-700 text-surface-900 dark:text-white font-medium text-sm transition-colors" 126 - disabled={uploading} 127 - > 128 - {uploading ? 'Uploading...' : 'Upload'} 129 - </button> 130 - </div> 131 - </div> 132 - 133 - <div className="mb-4"> 134 - <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">Display Name</label> 135 - <input 136 - type="text" 137 - value={displayName} 138 - onChange={e => setDisplayName(e.target.value)} 139 - className="w-full px-3 py-2 rounded-lg bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 text-surface-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500 dark:focus:border-primary-400" 140 - maxLength={64} 141 - /> 142 - </div> 143 - 144 - <div className="mb-4"> 145 - <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">Bio</label> 146 - <textarea 147 - value={description} 148 - onChange={e => setDescription(e.target.value)} 149 - className="w-full px-3 py-2 rounded-lg bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 text-surface-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500 dark:focus:border-primary-400 min-h-[80px] resize-none" 150 - maxLength={300} 151 - /> 152 - </div> 153 - 154 - <div className="mb-4"> 155 - <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">Website</label> 156 - <input 157 - type="url" 158 - value={website} 159 - onChange={e => setWebsite(e.target.value)} 160 - placeholder="https://example.com" 161 - className="w-full px-3 py-2 rounded-lg bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 text-surface-900 dark:text-white placeholder:text-surface-400 dark:placeholder:text-surface-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500 dark:focus:border-primary-400 text-sm" 162 - /> 163 - </div> 164 - 165 - <div className="mb-5"> 166 - <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">Links</label> 167 - <div className="space-y-2"> 168 - {links.map((link, i) => ( 169 - <div key={i} className="flex items-center gap-2"> 170 - <input type="text" value={link} readOnly className="flex-1 px-3 py-2 rounded-lg bg-surface-50 dark:bg-surface-800 border border-surface-200 dark:border-surface-700 text-sm text-surface-600 dark:text-surface-300" /> 171 - <button type="button" onClick={() => handleRemoveLink(i)} className="p-2 text-surface-400 dark:text-surface-500 hover:text-red-500 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg"> 172 - <X size={14} /> 173 - </button> 174 - </div> 175 - ))} 176 - <div className="flex items-center gap-2"> 177 - <input 178 - type="url" 179 - value={newLink} 180 - onChange={e => setNewLink(e.target.value)} 181 - placeholder="Add a link..." 182 - className="flex-1 px-3 py-2 rounded-lg bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 text-surface-900 dark:text-white placeholder:text-surface-400 dark:placeholder:text-surface-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500 dark:focus:border-primary-400 text-sm" 183 - onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), handleAddLink())} 184 - /> 185 - <button type="button" onClick={handleAddLink} className="p-2 bg-surface-900 dark:bg-surface-700 text-white rounded-lg hover:bg-surface-800 dark:hover:bg-surface-600"> 186 - <Plus size={18} /> 187 - </button> 188 - </div> 189 - </div> 190 - </div> 191 - 192 - <div className="flex items-center justify-end gap-2 pt-4 border-t border-surface-100 dark:border-surface-800"> 193 - <button type="button" onClick={onClose} className="px-4 py-2 rounded-lg text-surface-600 dark:text-surface-300 font-medium hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors" disabled={saving}> 194 - Cancel 195 - </button> 196 - <button type="submit" className="px-4 py-2 rounded-lg bg-primary-600 text-white font-medium hover:bg-primary-700 transition-colors flex items-center gap-2" disabled={saving}> 197 - {saving && <Loader2 size={14} className="animate-spin" />} 198 - {saving ? 'Saving...' : 'Save'} 199 - </button> 200 - </div> 201 - </form> 202 - </div> 203 - </div> 204 - ); 205 - }
-546
web/src/components/Icons.tsx
··· 1 - 2 - import React from 'react'; 3 - import { FaGithub, FaLinkedin } from "react-icons/fa"; 4 - // @ts-ignore 5 - import tangledLogo from "../assets/tangled.svg"; 6 - 7 - interface IconProps { 8 - size?: number; 9 - color?: string; 10 - filled?: boolean; 11 - } 12 - 13 - export function HeartIcon({ filled = false, size = 18 }: IconProps) { 14 - return filled ? ( 15 - <svg 16 - width={size} 17 - height={size} 18 - viewBox="0 0 24 24" 19 - fill="currentColor" 20 - stroke="none" 21 - > 22 - <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" /> 23 - </svg> 24 - ) : ( 25 - <svg 26 - width={size} 27 - height={size} 28 - viewBox="0 0 24 24" 29 - fill="none" 30 - stroke="currentColor" 31 - strokeWidth="2" 32 - strokeLinecap="round" 33 - strokeLinejoin="round" 34 - > 35 - <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" /> 36 - </svg> 37 - ); 38 - } 39 - 40 - export function MessageIcon({ size = 18 }: IconProps) { 41 - return ( 42 - <svg 43 - width={size} 44 - height={size} 45 - viewBox="0 0 24 24" 46 - fill="none" 47 - stroke="currentColor" 48 - strokeWidth="2" 49 - strokeLinecap="round" 50 - strokeLinejoin="round" 51 - > 52 - <path d="m3 21 1.9-5.7a8.5 8.5 0 1 1 3.8 3.8z" /> 53 - </svg> 54 - ); 55 - } 56 - 57 - export function ShareIcon({ size = 18 }: IconProps) { 58 - return ( 59 - <svg 60 - width={size} 61 - height={size} 62 - viewBox="0 0 24 24" 63 - fill="none" 64 - stroke="currentColor" 65 - strokeWidth="2" 66 - strokeLinecap="round" 67 - strokeLinejoin="round" 68 - > 69 - <path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" /> 70 - <polyline points="16 6 12 2 8 6" /> 71 - <line x1="12" x2="12" y1="2" y2="15" /> 72 - </svg> 73 - ); 74 - } 75 - 76 - export function TrashIcon({ size = 18 }: IconProps) { 77 - return ( 78 - <svg 79 - width={size} 80 - height={size} 81 - viewBox="0 0 24 24" 82 - fill="none" 83 - stroke="currentColor" 84 - strokeWidth="2" 85 - strokeLinecap="round" 86 - strokeLinejoin="round" 87 - > 88 - <path d="M3 6h18" /> 89 - <path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" /> 90 - <path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" /> 91 - </svg> 92 - ); 93 - } 94 - 95 - export function LinkIcon({ size = 18 }: IconProps) { 96 - return ( 97 - <svg 98 - width={size} 99 - height={size} 100 - viewBox="0 0 24 24" 101 - fill="none" 102 - stroke="currentColor" 103 - strokeWidth="2" 104 - strokeLinecap="round" 105 - strokeLinejoin="round" 106 - > 107 - <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" /> 108 - <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" /> 109 - </svg> 110 - ); 111 - } 112 - 113 - export function ExternalLinkIcon({ size = 14 }: IconProps) { 114 - return ( 115 - <svg 116 - width={size} 117 - height={size} 118 - viewBox="0 0 24 24" 119 - fill="none" 120 - stroke="currentColor" 121 - strokeWidth="2" 122 - strokeLinecap="round" 123 - strokeLinejoin="round" 124 - > 125 - <path d="M15 3h6v6" /> 126 - <path d="M10 14 21 3" /> 127 - <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /> 128 - </svg> 129 - ); 130 - } 131 - 132 - export function PenIcon({ size = 18 }: IconProps) { 133 - return ( 134 - <svg 135 - width={size} 136 - height={size} 137 - viewBox="0 0 24 24" 138 - fill="none" 139 - stroke="currentColor" 140 - strokeWidth="2" 141 - strokeLinecap="round" 142 - strokeLinejoin="round" 143 - > 144 - <path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" /> 145 - </svg> 146 - ); 147 - } 148 - 149 - export function HighlightIcon({ size = 18 }: IconProps) { 150 - return ( 151 - <svg 152 - width={size} 153 - height={size} 154 - viewBox="0 0 24 24" 155 - fill="none" 156 - stroke="currentColor" 157 - strokeWidth="2" 158 - strokeLinecap="round" 159 - strokeLinejoin="round" 160 - > 161 - <path d="m9 11-6 6v3h9l3-3" /> 162 - <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" /> 163 - </svg> 164 - ); 165 - } 166 - 167 - export function BookmarkIcon({ size = 18 }: IconProps) { 168 - return ( 169 - <svg 170 - width={size} 171 - height={size} 172 - viewBox="0 0 24 24" 173 - fill="none" 174 - stroke="currentColor" 175 - strokeWidth="2" 176 - strokeLinecap="round" 177 - strokeLinejoin="round" 178 - > 179 - <path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z" /> 180 - </svg> 181 - ); 182 - } 183 - 184 - export function TagIcon({ size = 18 }: IconProps) { 185 - return ( 186 - <svg 187 - width={size} 188 - height={size} 189 - viewBox="0 0 24 24" 190 - fill="none" 191 - stroke="currentColor" 192 - strokeWidth="2" 193 - strokeLinecap="round" 194 - strokeLinejoin="round" 195 - > 196 - <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" /> 197 - <circle cx="7.5" cy="7.5" r=".5" fill="currentColor" /> 198 - </svg> 199 - ); 200 - } 201 - 202 - export function AlertIcon({ size = 18 }: IconProps) { 203 - return ( 204 - <svg 205 - width={size} 206 - height={size} 207 - viewBox="0 0 24 24" 208 - fill="none" 209 - stroke="currentColor" 210 - strokeWidth="2" 211 - strokeLinecap="round" 212 - strokeLinejoin="round" 213 - > 214 - <circle cx="12" cy="12" r="10" /> 215 - <line x1="12" x2="12" y1="8" y2="12" /> 216 - <line x1="12" x2="12.01" y1="16" y2="16" /> 217 - </svg> 218 - ); 219 - } 220 - 221 - export function FileTextIcon({ size = 18 }: IconProps) { 222 - return ( 223 - <svg 224 - width={size} 225 - height={size} 226 - viewBox="0 0 24 24" 227 - fill="none" 228 - stroke="currentColor" 229 - strokeWidth="2" 230 - strokeLinecap="round" 231 - strokeLinejoin="round" 232 - > 233 - <path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /> 234 - <path d="M14 2v4a2 2 0 0 0 2 2h4" /> 235 - <path d="M10 9H8" /> 236 - <path d="M16 13H8" /> 237 - <path d="M16 17H8" /> 238 - </svg> 239 - ); 240 - } 241 - 242 - export function SearchIcon({ size = 18 }: IconProps) { 243 - return ( 244 - <svg 245 - width={size} 246 - height={size} 247 - viewBox="0 0 24 24" 248 - fill="none" 249 - stroke="currentColor" 250 - strokeWidth="2" 251 - strokeLinecap="round" 252 - strokeLinejoin="round" 253 - > 254 - <circle cx="11" cy="11" r="8" /> 255 - <path d="m21 21-4.3-4.3" /> 256 - </svg> 257 - ); 258 - } 259 - 260 - export function InboxIcon({ size = 18 }: IconProps) { 261 - return ( 262 - <svg 263 - width={size} 264 - height={size} 265 - viewBox="0 0 24 24" 266 - fill="none" 267 - stroke="currentColor" 268 - strokeWidth="2" 269 - strokeLinecap="round" 270 - strokeLinejoin="round" 271 - > 272 - <polyline points="22 12 16 12 14 15 10 15 8 12 2 12" /> 273 - <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" /> 274 - </svg> 275 - ); 276 - } 277 - 278 - export function BlueskyIcon({ size = 18, color = "currentColor" }: IconProps) { 279 - return ( 280 - <svg 281 - xmlns="http://www.w3.org/2000/svg" 282 - viewBox="0 0 512 512" 283 - width={size} 284 - height={size} 285 - > 286 - <path 287 - fill={color} 288 - 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" 289 - /> 290 - </svg> 291 - ); 292 - } 293 - 294 - export function MarginIcon({ size = 18 }: IconProps) { 295 - return ( 296 - <svg 297 - width={size} 298 - height={size} 299 - viewBox="0 0 265 231" 300 - fill="currentColor" 301 - xmlns="http://www.w3.org/2000/svg" 302 - > 303 - <path d="M0 230 V0 H199 V65.7156 H149.5 V115.216 H182.5 L199 131.716 V230 Z" /> 304 - <path d="M215 214.224 V230 H264.5 V0 H215.07 V16.2242 H248.5 V214.224 H215 Z" /> 305 - </svg> 306 - ); 307 - } 308 - 309 - export function LogoutIcon({ size = 18 }: IconProps) { 310 - return ( 311 - <svg 312 - width={size} 313 - height={size} 314 - viewBox="0 0 24 24" 315 - fill="none" 316 - stroke="currentColor" 317 - strokeWidth="2" 318 - strokeLinecap="round" 319 - strokeLinejoin="round" 320 - > 321 - <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /> 322 - <polyline points="16 17 21 12 16 7" /> 323 - <line x1="21" x2="9" y1="12" y2="12" /> 324 - </svg> 325 - ); 326 - } 327 - 328 - export function BellIcon({ size = 18 }: IconProps) { 329 - return ( 330 - <svg 331 - width={size} 332 - height={size} 333 - viewBox="0 0 24 24" 334 - fill="none" 335 - stroke="currentColor" 336 - strokeWidth="2" 337 - strokeLinecap="round" 338 - strokeLinejoin="round" 339 - > 340 - <path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" /> 341 - <path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" /> 342 - </svg> 343 - ); 344 - } 345 - 346 - export function ReplyIcon({ size = 18 }: IconProps) { 347 - return ( 348 - <svg 349 - width={size} 350 - height={size} 351 - viewBox="0 0 24 24" 352 - fill="none" 353 - stroke="currentColor" 354 - strokeWidth="2" 355 - strokeLinecap="round" 356 - strokeLinejoin="round" 357 - > 358 - <polyline points="9 17 4 12 9 7" /> 359 - <path d="M20 18v-2a4 4 0 0 0-4-4H4" /> 360 - </svg> 361 - ); 362 - } 363 - 364 - export function AturiIcon({ size = 18 }: IconProps) { 365 - return ( 366 - <svg 367 - width={size} 368 - height={size} 369 - viewBox="0 0 24 24" 370 - fill="none" 371 - stroke="currentColor" 372 - strokeWidth="2" 373 - strokeLinecap="round" 374 - strokeLinejoin="round" 375 - > 376 - <path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10Z" /> 377 - <path d="M2 21c0-3 1.85-5.36 5.08-6C9.5 14.52 12 13 13 12" /> 378 - </svg> 379 - ); 380 - } 381 - 382 - export function BlackskyIcon({ size = 18 }: IconProps) { 383 - return ( 384 - <svg viewBox="0 0 285 285" width={size} height={size}> 385 - <path 386 - fill="currentColor" 387 - 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" 388 - /> 389 - <path 390 - fill="currentColor" 391 - 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" 392 - /> 393 - <path 394 - fill="currentColor" 395 - 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" 396 - /> 397 - <path 398 - fill="currentColor" 399 - 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" 400 - /> 401 - </svg> 402 - ); 403 - } 404 - 405 - export function NorthskyIcon({ size = 18 }: IconProps) { 406 - return ( 407 - <svg viewBox="0 0 1024 1024" width={size} height={size}> 408 - <defs> 409 - <linearGradient 410 - id="north_a" 411 - x1="564.17" 412 - y1="22.4" 413 - x2="374.54" 414 - y2="1187.29" 415 - gradientUnits="userSpaceOnUse" 416 - gradientTransform="matrix(1 0 0 1.03 31.9 91.01)" 417 - > 418 - <stop offset="0" stopColor="#2affba" /> 419 - <stop offset="0.02" stopColor="#31f4bd" /> 420 - <stop offset="0.14" stopColor="#53bccc" /> 421 - <stop offset="0.25" stopColor="#718ada" /> 422 - <stop offset="0.37" stopColor="#8a5fe5" /> 423 - <stop offset="0.49" stopColor="#9f3def" /> 424 - <stop offset="0.61" stopColor="#af22f6" /> 425 - <stop offset="0.74" stopColor="#bb0ffb" /> 426 - <stop offset="0.87" stopColor="#c204fe" /> 427 - <stop offset="1" stopColor="#c400ff" /> 428 - </linearGradient> 429 - <linearGradient 430 - id="north_b" 431 - x1="554.29" 432 - y1="20.79" 433 - x2="364.65" 434 - y2="1185.68" 435 - xlinkHref="#north_a" 436 - /> 437 - <linearGradient 438 - id="north_c" 439 - x1="561.1" 440 - y1="21.9" 441 - x2="371.47" 442 - y2="1186.79" 443 - xlinkHref="#north_a" 444 - /> 445 - <linearGradient 446 - id="north_d" 447 - x1="530.57" 448 - y1="16.93" 449 - x2="340.93" 450 - y2="1181.82" 451 - xlinkHref="#north_a" 452 - /> 453 - </defs> 454 - <path 455 - d="m275.87 880.64 272-184.16 120.79 114 78.55-56.88 184.6 125.1a485.5 485.5 0 0 0 55.81-138.27c-64.41-21.42-127-48.15-185.92-73.32-97-41.44-188.51-80.52-253.69-80.52-59.57 0-71.53 18.85-89.12 55-16.89 34.55-37.84 77.6-139.69 77.6-81.26 0-159.95-29.93-243.27-61.61-17.07-6.5-34.57-13.14-52.49-19.69A486.06 486.06 0 0 0 95.19 884l91.29-62.16Z" 456 - fill="url(#north_a)" 457 - /> 458 - <path 459 - d="M295.26 506.52c53.69 0 64.49-17.36 80.41-50.63 15.46-32.33 34.7-72.56 128.36-72.56 75 0 154.6 33.2 246.78 71.64 74.85 31.21 156.89 65.34 241 81.63a485.6 485.6 0 0 0-64.23-164.85c-108.88-6-201.82-43.35-284.6-76.69-66.77-26.89-129.69-52.22-182.84-52.22-46.88 0-56.43 15.74-70.55 45.89-13.41 28.65-31.79 67.87-118.24 67.87-44.25 0-90.68-13.48-141-33.11A488.3 488.3 0 0 0 62.86 435.7c8.3 3.38 16.55 6.74 24.68 10.08 76.34 31.22 148.3 60.74 207.72 60.74" 460 - fill="url(#north_b)" 461 - /> 462 - <path 463 - d="M319.2 687.81c61.24 0 73.38-19.09 91.18-55.66 16.7-34.28 37.48-76.95 137.58-76.95 81.4 0 174.78 39.89 282.9 86.09 52.19 22.29 107.38 45.84 163.42 65.43a483 483 0 0 0 2.72-136.5C898.41 554.4 806 516 722.27 481.05c-81.88-34.14-159.08-66.33-218.27-66.33-53.25 0-64 17.29-79.84 50.42-15.51 32.42-34.8 72.77-128.93 72.77-75.08 0-153.29-32-236.08-66l-8.91-3.64A487 487 0 0 0 24 601.68c27.31 9.55 53.55 19.52 79 29.19 80.24 30.55 149.61 56.94 216.2 56.94" 464 - fill="url(#north_c)" 465 - /> 466 - <path 467 - d="M341 279.65c13.49-28.78 31.95-68.19 119.16-68.19 68.59 0 137.73 27.84 210.92 57.32 70.14 28.22 148.13 59.58 233.72 69.37C815.77 218 673 140 511.88 140c-141.15 0-268.24 59.92-357.45 155.62 44 17.32 84.15 29.6 116.89 29.6 46.24 0 55.22-14.79 69.68-45.57" 468 - fill="url(#north_d)" 469 - /> 470 - </svg> 471 - ); 472 - } 473 - 474 - export function TophhieIcon({ size = 18 }: IconProps) { 475 - return ( 476 - <svg 477 - width={size} 478 - height={size} 479 - viewBox="0 0 344 538" 480 - fill="none" 481 - xmlns="http://www.w3.org/2000/svg" 482 - > 483 - <ellipse cx="268.5" cy="455.5" rx="34.5" ry="35.5" fill="currentColor" /> 484 - <ellipse cx="76" cy="75.5" rx="35" ry="35.5" fill="currentColor" /> 485 - <circle cx="268.5" cy="75.5" r="75.5" fill="currentColor" /> 486 - <ellipse cx="76" cy="274.5" rx="76" ry="75.5" fill="currentColor" /> 487 - <ellipse cx="76" cy="462.5" rx="76" ry="75.5" fill="currentColor" /> 488 - <circle cx="268.5" cy="269.5" r="75.5" fill="currentColor" /> 489 - </svg> 490 - ); 491 - } 492 - 493 - export function GithubIcon({ size = 18 }: IconProps) { 494 - return <FaGithub size={size} />; 495 - } 496 - 497 - export function LinkedinIcon({ size = 18 }: IconProps) { 498 - return <FaLinkedin size={size} />; 499 - } 500 - 501 - export function WitchskyIcon({ size = 18 }: IconProps) { 502 - return ( 503 - <svg fill="none" viewBox="0 0 512 512" width={size} height={size}> 504 - <path fill="#ee5346" 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" /> 505 - </svg> 506 - ); 507 - } 508 - 509 - export function CatskyIcon({ size = 18 }: IconProps) { 510 - return ( 511 - <svg fill="none" viewBox="0 0 67.733328 67.733329" width={size} height={size}> 512 - <path fill="#cba7f7" 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" /> 513 - <path fill="#cba7f7" 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" /> 514 - <path fill="#cba7f7" 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" /> 515 - <path fill="#cba7f7" 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" /> 516 - <path fill="#cba7f7" 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" /> 517 - <path fill="#cba7f7" 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.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" /> 518 - <path fill="#cba7f7" 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" /> 519 - <path fill="#cba7f7" 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.052073,6.1836069 51.974407,6.1337905 51.885945,6.1211124 51.79757,6.1189646 Z" /> 520 - <path fill="#cba7f7" 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" /> 521 - </svg> 522 - ); 523 - } 524 - 525 - export function DeerIcon({ size = 18 }: IconProps) { 526 - return ( 527 - <svg fill="none" viewBox="0 0 512 512" width={size} height={size}> 528 - <path fill="#739f7c" 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" transform="matrix(2.6921023,0,0,1.7145911,-396.58283,-308.01527)" /> 529 - </svg> 530 - ); 531 - } 532 - 533 - export function TangledIcon({ size = 18 }: IconProps) { 534 - return ( 535 - <div 536 - style={{ 537 - width: size, 538 - height: size, 539 - backgroundColor: "currentColor", 540 - WebkitMask: `url(${tangledLogo.src}) no-repeat center / contain`, 541 - mask: `url(${tangledLogo.src}) no-repeat center / contain`, 542 - display: "inline-block", 543 - }} 544 - /> 545 - ); 546 - }
-71
web/src/components/MasonryFeed.tsx
··· 1 - 2 - import React, { useEffect, useState } from 'react'; 3 - import { getFeed } from '../api/client'; 4 - import Card from './Card'; 5 - import { Loader2 } from 'lucide-react'; 6 - import { useStore } from '@nanostores/react'; 7 - import { $user, initAuth } from '../store/auth'; 8 - import type { AnnotationItem } from '../types'; 9 - 10 - interface MasonryFeedProps { 11 - motivation?: string; 12 - emptyMessage?: string; 13 - } 14 - 15 - export default function MasonryFeed({ 16 - motivation, 17 - emptyMessage = "No items found." 18 - }: MasonryFeedProps) { 19 - const user = useStore($user); 20 - const [items, setItems] = useState<AnnotationItem[]>([]); 21 - const [loading, setLoading] = useState(true); 22 - 23 - useEffect(() => { 24 - initAuth(); 25 - }, []); 26 - 27 - useEffect(() => { 28 - const fetchFeed = async () => { 29 - setLoading(true); 30 - try { 31 - const data = await getFeed({ type: 'my-feed', motivation }); 32 - setItems(data?.items || []); 33 - } catch (e) { 34 - console.error(e); 35 - } finally { 36 - setLoading(false); 37 - } 38 - }; 39 - fetchFeed(); 40 - }, [motivation]); 41 - 42 - const handleDelete = (uri: string) => { 43 - setItems((prev) => prev.filter(i => i.uri !== uri)); 44 - }; 45 - 46 - if (loading) { 47 - return ( 48 - <div className="flex justify-center py-20"> 49 - <Loader2 className="animate-spin text-primary-600 dark:text-primary-400" size={32} /> 50 - </div> 51 - ); 52 - } 53 - 54 - if (items.length === 0) { 55 - return ( 56 - <div className="text-center py-20 text-surface-500 dark:text-surface-400 bg-surface-50/50 dark:bg-surface-800/50 rounded-2xl border border-dashed border-surface-200 dark:border-surface-700"> 57 - <p>{emptyMessage}</p> 58 - </div> 59 - ); 60 - } 61 - 62 - return ( 63 - <div className="columns-1 xl:columns-2 gap-4 animate-fade-in"> 64 - {items.map((item) => ( 65 - <div key={item.uri || item.cid} className="break-inside-avoid mb-4"> 66 - <Card item={item} onDelete={handleDelete} /> 67 - </div> 68 - ))} 69 - </div> 70 - ); 71 - }
-195
web/src/components/MobileNav.tsx
··· 1 - 2 - import React, { useState, useEffect } from 'react'; 3 - import { Link, useLocation } from 'react-router-dom'; 4 - import { useStore } from '@nanostores/react'; 5 - import { $user, logout } from '../store/auth'; 6 - import { getUnreadNotificationCount } from '../api/client'; 7 - import { 8 - Home, 9 - Search, 10 - Folder, 11 - User, 12 - PenSquare, 13 - Bookmark, 14 - Settings, 15 - MoreHorizontal, 16 - LogOut, 17 - Bell, 18 - Highlighter, 19 - X 20 - } from 'lucide-react'; 21 - 22 - export default function MobileNav() { 23 - const user = useStore($user); 24 - const location = useLocation(); 25 - const [isMenuOpen, setIsMenuOpen] = useState(false); 26 - const [unreadCount, setUnreadCount] = useState(0); 27 - 28 - const isAuthenticated = !!user; 29 - 30 - const isActive = (path: string) => { 31 - if (path === '/') return location.pathname === '/'; 32 - return location.pathname.startsWith(path); 33 - }; 34 - 35 - useEffect(() => { 36 - if (isAuthenticated) { 37 - getUnreadNotificationCount() 38 - .then((count) => setUnreadCount(count || 0)) 39 - .catch(() => { }); 40 - } 41 - }, [isAuthenticated]); 42 - 43 - const closeMenu = () => setIsMenuOpen(false); 44 - 45 - return ( 46 - <> 47 - {isMenuOpen && ( 48 - <div 49 - className="fixed inset-0 bg-black/50 z-40 lg:hidden" 50 - onClick={closeMenu} 51 - /> 52 - )} 53 - 54 - {isMenuOpen && ( 55 - <div className="fixed bottom-16 left-0 right-0 bg-white dark:bg-surface-900 rounded-t-2xl shadow-2xl z-50 lg:hidden animate-slide-up"> 56 - <div className="p-4 space-y-1"> 57 - {isAuthenticated && user ? ( 58 - <> 59 - <Link 60 - to={`/profile/${user.did}`} 61 - className="flex items-center gap-3 p-3 rounded-xl hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors" 62 - onClick={closeMenu} 63 - > 64 - {user.avatar ? ( 65 - <img src={user.avatar} alt="" className="w-10 h-10 rounded-full object-cover" /> 66 - ) : ( 67 - <div className="w-10 h-10 rounded-full bg-surface-200 dark:bg-surface-700 flex items-center justify-center"> 68 - <User size={18} className="text-surface-500" /> 69 - </div> 70 - )} 71 - <div className="flex flex-col"> 72 - <span className="font-semibold text-surface-900 dark:text-white"> 73 - {user.displayName || user.handle} 74 - </span> 75 - <span className="text-sm text-surface-500">@{user.handle}</span> 76 - </div> 77 - </Link> 78 - 79 - <div className="h-px bg-surface-200 dark:bg-surface-700 my-2" /> 80 - 81 - <Link to="/highlights" className="flex items-center gap-3 p-3 rounded-xl hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors text-surface-700 dark:text-surface-200" onClick={closeMenu}> 82 - <Highlighter size={20} /> 83 - <span>Highlights</span> 84 - </Link> 85 - 86 - <Link to="/bookmarks" className="flex items-center gap-3 p-3 rounded-xl hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors text-surface-700 dark:text-surface-200" onClick={closeMenu}> 87 - <Bookmark size={20} /> 88 - <span>Bookmarks</span> 89 - </Link> 90 - 91 - <Link to="/collections" className="flex items-center gap-3 p-3 rounded-xl hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors text-surface-700 dark:text-surface-200" onClick={closeMenu}> 92 - <Folder size={20} /> 93 - <span>Collections</span> 94 - </Link> 95 - 96 - <Link to="/settings" className="flex items-center gap-3 p-3 rounded-xl hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors text-surface-700 dark:text-surface-200" onClick={closeMenu}> 97 - <Settings size={20} /> 98 - <span>Settings</span> 99 - </Link> 100 - 101 - <div className="h-px bg-surface-200 dark:bg-surface-700 my-2" /> 102 - 103 - <button 104 - className="flex items-center gap-3 p-3 rounded-xl hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors text-red-600 w-full" 105 - onClick={() => { 106 - logout(); 107 - closeMenu(); 108 - }} 109 - > 110 - <LogOut size={20} /> 111 - <span>Log Out</span> 112 - </button> 113 - </> 114 - ) : ( 115 - <> 116 - <Link to="/login" className="flex items-center gap-3 p-3 rounded-xl hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors text-surface-700 dark:text-surface-200" onClick={closeMenu}> 117 - <User size={20} /> 118 - <span>Sign In</span> 119 - </Link> 120 - <Link to="/collections" className="flex items-center gap-3 p-3 rounded-xl hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors text-surface-700 dark:text-surface-200" onClick={closeMenu}> 121 - <Folder size={20} /> 122 - <span>Collections</span> 123 - </Link> 124 - <Link to="/settings" className="flex items-center gap-3 p-3 rounded-xl hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors text-surface-700 dark:text-surface-200" onClick={closeMenu}> 125 - <Settings size={20} /> 126 - <span>Settings</span> 127 - </Link> 128 - </> 129 - )} 130 - </div> 131 - </div> 132 - )} 133 - 134 - <nav className="fixed bottom-0 left-0 right-0 h-14 bg-white dark:bg-surface-900 border-t border-surface-200 dark:border-surface-700 flex items-center justify-around px-2 z-50 lg:hidden safe-area-bottom"> 135 - <Link 136 - to="/home" 137 - className={`flex flex-col items-center justify-center w-14 h-14 rounded-xl transition-colors ${isActive('/home') ? 'text-primary-600' : 'text-surface-500 hover:text-surface-700' 138 - }`} 139 - onClick={closeMenu} 140 - > 141 - <Home size={24} strokeWidth={1.5} /> 142 - </Link> 143 - 144 - <Link 145 - to="/url" 146 - className={`flex flex-col items-center justify-center w-14 h-14 rounded-xl transition-colors ${isActive('/url') ? 'text-primary-600' : 'text-surface-500 hover:text-surface-700' 147 - }`} 148 - onClick={closeMenu} 149 - > 150 - <Search size={24} strokeWidth={1.5} /> 151 - </Link> 152 - 153 - {isAuthenticated ? ( 154 - <> 155 - <Link 156 - to="/new" 157 - className="flex items-center justify-center w-12 h-12 rounded-full bg-primary-600 text-white shadow-lg hover:bg-primary-500 transition-colors -mt-4" 158 - onClick={closeMenu} 159 - > 160 - <PenSquare size={20} strokeWidth={2} /> 161 - </Link> 162 - 163 - <Link 164 - to="/notifications" 165 - className={`relative flex flex-col items-center justify-center w-14 h-14 rounded-xl transition-colors ${isActive('/notifications') ? 'text-primary-600' : 'text-surface-500 hover:text-surface-700' 166 - }`} 167 - onClick={closeMenu} 168 - > 169 - <Bell size={24} strokeWidth={1.5} /> 170 - {unreadCount > 0 && ( 171 - <span className="absolute top-2 right-2 w-2 h-2 bg-red-500 rounded-full" /> 172 - )} 173 - </Link> 174 - </> 175 - ) : ( 176 - <Link 177 - to="/login" 178 - className="flex items-center justify-center w-12 h-12 rounded-full bg-primary-600 text-white shadow-lg hover:bg-primary-500 transition-colors -mt-4" 179 - onClick={closeMenu} 180 - > 181 - <User size={20} strokeWidth={2} /> 182 - </Link> 183 - )} 184 - 185 - <button 186 - className={`flex flex-col items-center justify-center w-14 h-14 rounded-xl transition-colors ${isMenuOpen ? 'text-primary-600' : 'text-surface-500 hover:text-surface-700' 187 - }`} 188 - onClick={() => setIsMenuOpen(!isMenuOpen)} 189 - > 190 - {isMenuOpen ? <X size={24} strokeWidth={1.5} /> : <MoreHorizontal size={24} strokeWidth={1.5} />} 191 - </button> 192 - </nav> 193 - </> 194 - ); 195 - }
-171
web/src/components/ReplyList.tsx
··· 1 - import React from 'react'; 2 - import { formatDistanceToNow } from 'date-fns'; 3 - import { MessageSquare, Trash2, Reply } from 'lucide-react'; 4 - import type { AnnotationItem, UserProfile } from '../types'; 5 - import { getAvatarUrl } from '../api/client'; 6 - import { clsx } from 'clsx'; 7 - 8 - interface ReplyListProps { 9 - replies: AnnotationItem[]; 10 - rootUri: string; 11 - user: UserProfile | null; 12 - onReply: (reply: AnnotationItem) => void; 13 - onDelete: (reply: AnnotationItem) => void; 14 - isInline?: boolean; 15 - } 16 - 17 - interface ReplyItemProps { 18 - reply: AnnotationItem & { children?: AnnotationItem[] }; 19 - depth: number; 20 - user: UserProfile | null; 21 - onReply: (reply: AnnotationItem) => void; 22 - onDelete: (reply: AnnotationItem) => void; 23 - isInline: boolean; 24 - } 25 - 26 - const ReplyItem: React.FC<ReplyItemProps> = ({ reply, depth = 0, user, onReply, onDelete, isInline }) => { 27 - const author = reply.author || reply.creator || {}; 28 - const isReplyOwner = user?.did && author.did === user.did; 29 - 30 - if (!author.handle && !author.did) return null; 31 - 32 - return ( 33 - <div key={reply.uri || reply.id}> 34 - <div 35 - className={clsx( 36 - "relative mb-2 transition-colors", 37 - isInline ? "flex gap-3" : "rounded-lg", 38 - depth > 0 && "ml-4 pl-3 border-l-2 border-surface-200 dark:border-surface-700" 39 - )} 40 - > 41 - {isInline ? ( 42 - <> 43 - <a href={`/profile/${author.handle}`} className="shrink-0"> 44 - {getAvatarUrl(author.did, author.avatar) ? ( 45 - <img 46 - src={getAvatarUrl(author.did, author.avatar)} 47 - alt="" 48 - className={clsx("rounded-full object-cover bg-surface-200 dark:bg-surface-700", depth > 0 ? "w-6 h-6" : "w-7 h-7")} 49 - /> 50 - ) : ( 51 - <div className={clsx("rounded-full bg-surface-200 dark:bg-surface-700 flex items-center justify-center text-surface-500 dark:text-surface-400 font-bold", depth > 0 ? "w-6 h-6 text-[10px]" : "w-7 h-7 text-xs")}> 52 - {(author.displayName || author.handle || "?")[0]?.toUpperCase()} 53 - </div> 54 - )} 55 - </a> 56 - <div className="flex-1 min-w-0"> 57 - <div className="flex items-baseline gap-2 mb-0.5 flex-wrap"> 58 - <span className={clsx("font-medium text-surface-900 dark:text-white", depth > 0 ? "text-xs" : "text-sm")}> 59 - {author.displayName || author.handle} 60 - </span> 61 - <span className="text-surface-400 dark:text-surface-500 text-xs"> 62 - {reply.createdAt ? formatDistanceToNow(new Date(reply.createdAt), { addSuffix: false }) : ''} 63 - </span> 64 - 65 - <div className="ml-auto flex gap-2"> 66 - <button 67 - onClick={() => onReply(reply)} 68 - className="text-surface-400 dark:text-surface-500 hover:text-surface-600 dark:hover:text-surface-300 transition-colors flex items-center gap-1 text-[10px] uppercase font-medium" 69 - > 70 - <MessageSquare size={12} /> 71 - </button> 72 - {isReplyOwner && ( 73 - <button 74 - onClick={() => onDelete(reply)} 75 - className="text-surface-400 dark:text-surface-500 hover:text-red-500 dark:hover:text-red-400 transition-colors" 76 - > 77 - <Trash2 size={12} /> 78 - </button> 79 - )} 80 - </div> 81 - </div> 82 - <p className={clsx("text-surface-800 dark:text-surface-200 whitespace-pre-wrap leading-relaxed", depth > 0 ? "text-sm" : "text-sm")}> 83 - {reply.text || reply.body?.value} 84 - </p> 85 - </div> 86 - </> 87 - ) : ( 88 - <div className="p-3 bg-white dark:bg-surface-900 rounded-lg ring-1 ring-black/5 dark:ring-white/5"> 89 - <div className="flex items-center gap-2 mb-2"> 90 - <a href={`/profile/${author.handle}`} className="shrink-0"> 91 - {getAvatarUrl(author.did, author.avatar) ? ( 92 - <img src={getAvatarUrl(author.did, author.avatar)} alt="" className="w-7 h-7 rounded-full object-cover bg-surface-200 dark:bg-surface-700" /> 93 - ) : ( 94 - <div className="w-7 h-7 rounded-full bg-surface-200 dark:bg-surface-700 flex items-center justify-center text-surface-500 dark:text-surface-400 font-bold text-xs"> 95 - {(author.displayName || author.handle || "?")[0]?.toUpperCase()} 96 - </div> 97 - )} 98 - </a> 99 - <div className="flex flex-col"> 100 - <span className="font-medium text-surface-900 dark:text-white text-sm">{author.displayName || author.handle}</span> 101 - </div> 102 - <span className="text-surface-400 dark:text-surface-500 text-xs ml-auto"> 103 - {reply.createdAt ? formatDistanceToNow(new Date(reply.createdAt), { addSuffix: false }) : ''} 104 - </span> 105 - </div> 106 - <p className="text-surface-800 dark:text-surface-200 text-sm pl-9 mb-2 whitespace-pre-wrap"> 107 - {reply.text || reply.body?.value} 108 - </p> 109 - <div className="flex items-center justify-end gap-2 pl-9"> 110 - <button onClick={() => onReply(reply)} className="text-surface-400 dark:text-surface-500 hover:text-primary-600 dark:hover:text-primary-400 transition-colors p-1"> 111 - <Reply size={14} /> 112 - </button> 113 - {isReplyOwner && ( 114 - <button onClick={() => onDelete(reply)} className="text-surface-400 dark:text-surface-500 hover:text-red-500 dark:hover:text-red-400 transition-colors p-1"> 115 - <Trash2 size={14} /> 116 - </button> 117 - )} 118 - </div> 119 - </div> 120 - )} 121 - </div> 122 - {reply.children && reply.children.length > 0 && ( 123 - <div className="flex flex-col"> 124 - {reply.children.map((child) => ( 125 - <ReplyItem key={child.uri || child.id} reply={child} depth={depth + 1} user={user} onReply={onReply} onDelete={onDelete} isInline={isInline} /> 126 - ))} 127 - </div> 128 - )} 129 - </div> 130 - ); 131 - } 132 - 133 - export default function ReplyList({ replies, rootUri, user, onReply, onDelete, isInline = false }: ReplyListProps) { 134 - if (!replies || replies.length === 0) { 135 - return ( 136 - <div className="py-8 text-center"> 137 - <p className="text-surface-500 dark:text-surface-400 text-sm">No replies yet</p> 138 - </div> 139 - ); 140 - } 141 - 142 - const buildReplyTree = () => { 143 - const replyMap: Record<string, any> = {}; 144 - const rootReplies: any[] = []; 145 - 146 - replies.forEach((r) => { 147 - replyMap[r.uri || r.id || ''] = { ...r, children: [] }; 148 - }); 149 - 150 - replies.forEach((r) => { 151 - const parentUri = (r as any).reply?.parent?.uri || (r as any).parentUri; 152 - if (parentUri === rootUri || !parentUri || !replyMap[parentUri]) { 153 - rootReplies.push(replyMap[r.uri || r.id || '']); 154 - } else { 155 - replyMap[parentUri].children.push(replyMap[r.uri || r.id || '']); 156 - } 157 - }); 158 - 159 - return rootReplies; 160 - }; 161 - 162 - const replyTree = buildReplyTree(); 163 - 164 - return ( 165 - <div className="flex flex-col gap-1"> 166 - {replyTree.map((reply) => ( 167 - <ReplyItem key={reply.uri || reply.id} reply={reply} depth={0} user={user} onReply={onReply} onDelete={onDelete} isInline={isInline} /> 168 - ))} 169 - </div> 170 - ); 171 - }
-83
web/src/components/RightSidebar.tsx
··· 1 - 2 - import React, { useEffect, useState } from 'react'; 3 - import { ArrowRight, Github, Twitter, ExternalLink, Loader2 } from 'lucide-react'; 4 - import { getTrendingTags, type Tag } from '../api/client'; 5 - 6 - export default function RightSidebar() { 7 - const [tags, setTags] = useState<Tag[]>([]); 8 - const [browser, setBrowser] = useState<'chrome' | 'firefox' | 'other'>('other'); 9 - 10 - useEffect(() => { 11 - const ua = navigator.userAgent.toLowerCase(); 12 - if (ua.includes('firefox')) setBrowser('firefox'); 13 - else if (ua.includes('chrome')) setBrowser('chrome'); 14 - getTrendingTags().then(setTags); 15 - }, []); 16 - 17 - const extensionLink = browser === 'firefox' 18 - ? 'https://addons.mozilla.org/en-US/firefox/addon/margin/' 19 - : 'https://chromewebstore.google.com/detail/margin/cgpmbiiagnehkikhcbnhiagfomajncpa'; 20 - 21 - return ( 22 - <aside className="hidden lg:block w-[280px] shrink-0 sticky top-0 h-screen overflow-y-auto px-4 py-4 border-l border-surface-100/50 dark:border-surface-800/50"> 23 - <div className="space-y-6"> 24 - 25 - <div className="relative"> 26 - <input 27 - type="text" 28 - placeholder="Search Margin..." 29 - className="w-full bg-surface-100 dark:bg-surface-800 rounded-full px-5 py-2.5 text-sm font-medium text-surface-900 dark:text-white placeholder:text-surface-500 dark:placeholder:text-surface-400 focus:outline-none focus:ring-2 focus:ring-primary-500/20 transition-all border-none" 30 - /> 31 - </div> 32 - 33 - <div className="bg-surface-50 dark:bg-surface-900 rounded-2xl p-4 border border-surface-100 dark:border-surface-800"> 34 - <h3 className="font-bold text-base mb-1 text-surface-900 dark:text-white">Get the Extension</h3> 35 - <p className="text-surface-500 dark:text-surface-400 text-sm mb-4 leading-snug"> 36 - Save anything, annotate anywhere. 37 - </p> 38 - <a 39 - href={extensionLink} 40 - target="_blank" 41 - rel="noopener noreferrer" 42 - className="flex items-center justify-center w-full px-4 py-2 bg-surface-900 dark:bg-white text-white dark:text-surface-900 rounded-full hover:bg-black dark:hover:bg-surface-100 transition-all text-sm font-semibold" 43 - > 44 - Download for {browser === 'firefox' ? 'Firefox' : 'Chrome'} 45 - </a> 46 - </div> 47 - 48 - <div className="py-2"> 49 - <h3 className="font-bold text-xl px-2 mb-4 text-surface-900 dark:text-white">Trending</h3> 50 - {tags.length > 0 ? ( 51 - <div className="flex flex-col"> 52 - {tags.map(t => ( 53 - <a key={t.tag} href={`/search?q=${t.tag}`} className="px-2 py-3 hover:bg-surface-50 dark:hover:bg-surface-800 rounded-xl transition-colors group"> 54 - <div className="flex justify-between items-center mb-0.5"> 55 - <span className="text-xs text-surface-500 dark:text-surface-400 font-medium">Trending</span> 56 - <span className="text-xs text-surface-400 dark:text-surface-500 opacity-0 group-hover:opacity-100 transition-opacity">...</span> 57 - </div> 58 - <div className="font-bold text-surface-900 dark:text-white">#{t.tag}</div> 59 - <div className="text-xs text-surface-500 dark:text-surface-400 mt-0.5">{t.count} posts</div> 60 - </a> 61 - ))} 62 - </div> 63 - ) : ( 64 - <div className="px-2"> 65 - <p className="text-sm text-surface-500 dark:text-surface-400">Nothing trending right now.</p> 66 - </div> 67 - )} 68 - </div> 69 - 70 - <div className="px-2 pt-2"> 71 - <div className="flex flex-wrap gap-x-3 gap-y-1 text-[13px] text-surface-400 dark:text-surface-500 leading-relaxed"> 72 - <a href="#" className="hover:underline hover:text-surface-600 dark:hover:text-surface-300">About</a> 73 - <a href="/privacy" className="hover:underline hover:text-surface-600 dark:hover:text-surface-300">Privacy</a> 74 - <a href="/terms" className="hover:underline hover:text-surface-600 dark:hover:text-surface-300">Terms</a> 75 - <a href="https://github.com/margin-at" target="_blank" rel="noreferrer" className="hover:underline hover:text-surface-600 dark:hover:text-surface-300">Code</a> 76 - <span>© 2026 Margin</span> 77 - </div> 78 - </div> 79 - 80 - </div> 81 - </aside> 82 - ); 83 - }
-218
web/src/components/ShareMenu.tsx
··· 1 - 2 - import React, { useState, useRef, useEffect } from "react"; 3 - import { Copy, ExternalLink, Check, Share2, MoreHorizontal } from "lucide-react"; 4 - import { AturiIcon, BlueskyIcon, BlackskyIcon, WitchskyIcon, CatskyIcon, DeerIcon } from "./Icons"; 5 - 6 - const SembleLogo = () => ( 7 - <img src="/semble-logo.svg" alt="Semble" className="w-4 h-4 opacity-90" /> 8 - ); 9 - 10 - const BLUESKY_COLOR = "#1185fe"; 11 - 12 - interface ShareOption { 13 - name: string; 14 - icon: React.ReactNode; 15 - action: () => void; 16 - highlight?: boolean; 17 - } 18 - 19 - interface ShareMenuProps { 20 - uri: string; 21 - text?: string; 22 - customUrl?: string; 23 - handle?: string; 24 - type?: string; 25 - url?: string; 26 - } 27 - 28 - export default function ShareMenu({ uri, text, customUrl, handle, type, url }: ShareMenuProps) { 29 - const [isOpen, setIsOpen] = useState(false); 30 - const [copied, setCopied] = useState<string | null>(null); 31 - const menuRef = useRef<HTMLDivElement>(null); 32 - const buttonRef = useRef<HTMLButtonElement>(null); 33 - const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0, alignRight: false }); 34 - 35 - const getShareUrl = () => { 36 - if (customUrl) return customUrl; 37 - if (!uri) return ""; 38 - 39 - const uriParts = uri.split("/"); 40 - const rkey = uriParts[uriParts.length - 1]; 41 - const did = uriParts[2]; 42 - 43 - if (uri.includes("network.cosmik.card")) return `${window.location.origin}/at/${did}/${rkey}`; 44 - if (handle && type) return `${window.location.origin}/${handle}/${type.toLowerCase()}/${rkey}`; 45 - return `${window.location.origin}/at/${did}/${rkey}`; 46 - }; 47 - 48 - const shareUrl = getShareUrl(); 49 - const isSemble = uri && uri.includes("network.cosmik"); 50 - 51 - const sembleUrl = (() => { 52 - if (!isSemble) return ""; 53 - const parts = uri.split("/"); 54 - const rkey = parts[parts.length - 1]; 55 - const userHandle = handle || (parts.length > 2 ? parts[2] : ""); 56 - 57 - if (uri.includes("network.cosmik.collection")) return `https://semble.so/profile/${userHandle}/collections/${rkey}`; 58 - if (uri.includes("network.cosmik.card") && url) return `https://semble.so/url?id=${encodeURIComponent(url)}`; 59 - return `https://semble.so/profile/${userHandle}`; 60 - })(); 61 - 62 - const handleCopy = async (textToCopy: string, key: string) => { 63 - try { 64 - await navigator.clipboard.writeText(textToCopy); 65 - setCopied(key); 66 - setTimeout(() => { 67 - setCopied(null); 68 - setIsOpen(false); 69 - }, 1000); 70 - } catch { 71 - prompt("Copy this link:", textToCopy); 72 - } 73 - }; 74 - 75 - const handleShareToFork = (domain: string) => { 76 - const composeText = text ? `${text.substring(0, 200)}...\n\n${shareUrl}` : shareUrl; 77 - const composeUrl = `https://${domain}/intent/compose?text=${encodeURIComponent(composeText)}`; 78 - window.open(composeUrl, "_blank"); 79 - setIsOpen(false); 80 - }; 81 - 82 - useEffect(() => { 83 - const handleClickOutside = (e: MouseEvent) => { 84 - if (menuRef.current && !menuRef.current.contains(e.target as Node) && !buttonRef.current?.contains(e.target as Node)) { 85 - setIsOpen(false); 86 - } 87 - }; 88 - if (isOpen) { 89 - document.addEventListener("mousedown", handleClickOutside); 90 - window.addEventListener("scroll", () => setIsOpen(false), true); 91 - window.addEventListener("resize", () => setIsOpen(false)); 92 - } 93 - return () => { 94 - document.removeEventListener("mousedown", handleClickOutside); 95 - window.removeEventListener("scroll", () => setIsOpen(false), true); 96 - window.removeEventListener("resize", () => setIsOpen(false)); 97 - }; 98 - }, [isOpen]); 99 - 100 - const calculatePosition = () => { 101 - if (!buttonRef.current) return; 102 - const rect = buttonRef.current.getBoundingClientRect(); 103 - const menuWidth = 240; 104 - 105 - let top = rect.bottom + 8; 106 - let left = rect.left; 107 - let alignRight = false; 108 - 109 - if (left + menuWidth > window.innerWidth - 16) { 110 - left = rect.right - menuWidth; 111 - alignRight = true; 112 - } 113 - 114 - if (top + 300 > window.innerHeight) { 115 - top = rect.top - 8; 116 - } 117 - 118 - setMenuPosition({ top, left, alignRight }); 119 - }; 120 - 121 - const toggleMenu = () => { 122 - if (!isOpen) calculatePosition(); 123 - setIsOpen(!isOpen); 124 - }; 125 - 126 - const renderMenuItem = (label: string, icon: React.ReactNode, onClick: () => void, isCopied: boolean = false, highlight: boolean = false) => ( 127 - <button 128 - onClick={onClick} 129 - className={`w-full flex items-center gap-3 px-3 py-2.5 text-[14px] font-medium transition-colors rounded-lg group 130 - ${highlight 131 - ? "text-primary-700 dark:text-primary-400 bg-primary-50/50 dark:bg-primary-900/20 hover:bg-primary-50 dark:hover:bg-primary-900/30" 132 - : "text-surface-700 dark:text-surface-200 hover:bg-surface-50 dark:hover:bg-surface-800 hover:text-surface-900 dark:hover:text-white" 133 - }`} 134 - > 135 - <span className={`flex items-center justify-center w-5 h-5 ${highlight ? "text-primary-600 dark:text-primary-400" : "text-surface-400 dark:text-surface-500 group-hover:text-surface-600 dark:group-hover:text-surface-300"}`}> 136 - {isCopied ? <Check size={16} className="text-green-600 dark:text-green-400" /> : icon} 137 - </span> 138 - <span className="flex-1 text-left">{isCopied ? "Copied!" : label}</span> 139 - </button> 140 - ); 141 - 142 - const shareForks = [ 143 - { name: "Bluesky", domain: "bsky.app", icon: <BlueskyIcon size={18} color={BLUESKY_COLOR} /> }, 144 - { name: "Witchsky", domain: "witchsky.app", icon: <WitchskyIcon size={18} /> }, 145 - { name: "Blacksky", domain: "blacksky.community", icon: <BlackskyIcon size={18} /> }, 146 - { name: "Catsky", domain: "catsky.social", icon: <CatskyIcon size={18} /> }, 147 - { name: "Deer", domain: "deer.social", icon: <DeerIcon size={18} /> }, 148 - ]; 149 - 150 - return ( 151 - <div className="relative inline-block"> 152 - <button 153 - ref={buttonRef} 154 - onClick={toggleMenu} 155 - className={`flex items-center gap-1.5 px-2 py-1.5 -ml-2 rounded-md transition-colors ${isOpen ? 'bg-surface-100 dark:bg-surface-800 text-surface-900 dark:text-white' : 'text-surface-500 dark:text-surface-400 hover:text-surface-900 dark:hover:text-white hover:bg-surface-50 dark:hover:bg-surface-800'}`} 156 - title="Share" 157 - > 158 - <Share2 size={18} /> 159 - </button> 160 - 161 - {isOpen && ( 162 - <div 163 - ref={menuRef} 164 - className="fixed z-[1000] w-[260px] bg-white dark:bg-surface-900 rounded-xl shadow-xl ring-1 ring-black/5 dark:ring-white/5 p-1.5 animate-in fade-in zoom-in-95 duration-150 origin-top-left" 165 - style={{ 166 - top: menuPosition.top, 167 - left: menuPosition.left, 168 - transformOrigin: menuPosition.alignRight ? 'top right' : 'top left' 169 - }} 170 - > 171 - <div className="flex flex-col gap-0.5"> 172 - {isSemble ? ( 173 - <> 174 - <div className="px-3 py-2 text-[11px] font-bold text-surface-400 dark:text-surface-500 uppercase tracking-wider flex items-center gap-1.5 select-none"> 175 - <SembleLogo /> 176 - Semble Integration 177 - </div> 178 - {renderMenuItem("Open on Semble", <ExternalLink size={16} />, () => window.open(sembleUrl, "_blank"), false, true)} 179 - {renderMenuItem("Copy Semble Link", <Copy size={16} />, () => handleCopy(sembleUrl, 'semble'), copied === 'semble')} 180 - <div className="h-px bg-surface-100 dark:bg-surface-800 my-1 mx-2" /> 181 - </> 182 - ) : null} 183 - 184 - {renderMenuItem("Copy Link", <Copy size={16} />, () => handleCopy(shareUrl, 'link'), copied === 'link')} 185 - 186 - <div className="px-3 pt-3 pb-1 text-[11px] font-bold text-surface-400 dark:text-surface-500 uppercase tracking-wider select-none"> 187 - Share via App 188 - </div> 189 - 190 - <div className="grid grid-cols-5 gap-1 px-1 mb-1"> 191 - {shareForks.map((fork) => ( 192 - <button 193 - key={fork.domain} 194 - onClick={() => handleShareToFork(fork.domain)} 195 - className="flex items-center justify-center p-2 rounded-lg hover:bg-surface-50 dark:hover:bg-surface-800 hover:scale-105 transition-all text-surface-400 dark:text-surface-500 hover:text-surface-900 dark:hover:text-white" 196 - title={`Share to ${fork.name}`} 197 - > 198 - {fork.icon} 199 - </button> 200 - ))} 201 - </div> 202 - 203 - <div className="h-px bg-surface-100 dark:bg-surface-800 my-1 mx-2" /> 204 - 205 - {renderMenuItem("Copy Universal Link", <AturiIcon size={16} />, () => handleCopy(uri.replace("at://", "https://aturi.to/"), 'aturi'), copied === 'aturi')} 206 - 207 - {navigator.share && ( 208 - renderMenuItem("More Options...", <MoreHorizontal size={16} />, () => { 209 - navigator.share({ title: "Margin", text, url: shareUrl }).catch(() => { }); 210 - setIsOpen(false); 211 - }) 212 - )} 213 - </div> 214 - </div> 215 - )} 216 - </div> 217 - ); 218 - }
-118
web/src/components/Sidebar.tsx
··· 1 - import React from 'react'; 2 - import { Home, Bookmark, PenTool, Settings, LogOut, User, Bell, Sun, Moon, Monitor } from 'lucide-react'; 3 - import { useStore } from '@nanostores/react'; 4 - import { $user, logout } from '../store/auth'; 5 - import { $theme, cycleTheme } from '../store/theme'; 6 - import { getAvatarUrl, getUnreadNotificationCount } from '../api/client'; 7 - import { Link, useLocation } from 'react-router-dom'; 8 - import { useEffect, useState } from 'react'; 9 - 10 - export default function Sidebar() { 11 - const user = useStore($user); 12 - const theme = useStore($theme); 13 - const location = useLocation(); 14 - const currentPath = location.pathname; 15 - const [unreadCount, setUnreadCount] = useState(0); 16 - 17 - useEffect(() => { 18 - if (!user) return; 19 - 20 - const checkNotifications = async () => { 21 - const count = await getUnreadNotificationCount(); 22 - setUnreadCount(count); 23 - }; 24 - 25 - checkNotifications(); 26 - const interval = setInterval(checkNotifications, 30000); 27 - return () => clearInterval(interval); 28 - }, [user]); 29 - 30 - const navItems = [ 31 - { icon: Home, label: 'Feed', href: '/home' }, 32 - { 33 - icon: Bell, 34 - label: 'Activity', 35 - href: '/notifications', 36 - badge: unreadCount > 0 ? unreadCount : undefined 37 - }, 38 - { icon: Bookmark, label: 'Bookmarks', href: '/bookmarks' }, 39 - { icon: PenTool, label: 'Highlights', href: '/highlights' }, 40 - { icon: Settings, label: 'Collections', href: '/collections' }, 41 - ]; 42 - 43 - if (!user) return null; 44 - 45 - return ( 46 - <aside className="sticky top-0 h-screen w-[240px] hidden md:flex flex-col justify-between py-4 px-4 z-50"> 47 - <div className="flex flex-col gap-6"> 48 - <Link to="/home" className="px-3 hover:opacity-80 transition-opacity w-fit"> 49 - <img src="/logo.svg" alt="Margin" className="w-8 h-8 dark:invert" /> 50 - </Link> 51 - 52 - <nav className="flex flex-col gap-1"> 53 - {navItems.map((item) => { 54 - const isActive = currentPath === item.href || currentPath.startsWith(item.href); 55 - return ( 56 - <Link 57 - key={item.href} 58 - to={item.href} 59 - className={`flex items-center gap-4 px-3 py-3 rounded-full transition-all duration-200 text-lg group ${isActive 60 - ? 'font-bold text-surface-900 dark:text-white' 61 - : 'font-medium text-surface-600 dark:text-surface-400 hover:bg-surface-100 dark:hover:bg-surface-800 hover:text-surface-900 dark:hover:text-white' 62 - }`} 63 - > 64 - <item.icon 65 - size={22} 66 - className={`${isActive ? 'stroke-[2.5px] text-primary-600 dark:text-primary-400' : 'stroke-[2px]'}`} 67 - /> 68 - <span>{item.label}</span> 69 - {item.badge && ( 70 - <span className="ml-auto bg-primary-600 text-white text-[10px] font-bold px-1.5 py-0.5 rounded-full min-w-[1.25rem] text-center"> 71 - {item.badge > 99 ? '99+' : item.badge} 72 - </span> 73 - )} 74 - </Link> 75 - ); 76 - })} 77 - </nav> 78 - </div> 79 - 80 - <div className="relative group"> 81 - <Link 82 - to={`/profile/${user.did}`} 83 - className="flex items-center gap-3 p-3 rounded-full hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors w-full" 84 - > 85 - {getAvatarUrl(user.did, user.avatar) ? ( 86 - <img src={getAvatarUrl(user.did, user.avatar)} className="h-10 w-10 rounded-full object-cover bg-surface-100 dark:bg-surface-800" /> 87 - ) : ( 88 - <div className="h-10 w-10 rounded-full bg-surface-100 dark:bg-surface-800 flex items-center justify-center text-surface-400 dark:text-surface-500"> 89 - <User size={20} /> 90 - </div> 91 - )} 92 - <div className="flex-1 min-w-0 pr-2"> 93 - <p className="font-bold text-surface-900 dark:text-white truncate text-[15px]">{user.displayName || user.handle}</p> 94 - <p className="text-sm text-surface-500 dark:text-surface-400 truncate">@{user.handle}</p> 95 - </div> 96 - </Link> 97 - 98 - <div className="absolute bottom-full left-0 w-full mb-2 bg-white dark:bg-surface-900 rounded-2xl shadow-xl border border-surface-100 dark:border-surface-800 p-2 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 transform origin-bottom scale-95 group-hover:scale-100"> 99 - <button 100 - onClick={cycleTheme} 101 - className="flex items-center gap-3 px-4 py-3 rounded-xl hover:bg-surface-50 dark:hover:bg-surface-800 text-sm font-medium text-surface-700 dark:text-surface-200 w-full" 102 - > 103 - {theme === 'light' ? <Sun size={18} /> : theme === 'dark' ? <Moon size={18} /> : <Monitor size={18} />} 104 - {theme === 'light' ? 'Light' : theme === 'dark' ? 'Dark' : 'System'} 105 - </button> 106 - <Link to="/settings" className="flex items-center gap-3 px-4 py-3 rounded-xl hover:bg-surface-50 dark:hover:bg-surface-800 text-sm font-medium text-surface-700 dark:text-surface-200"> 107 - <Settings size={18} /> 108 - Settings 109 - </Link> 110 - <button onClick={logout} className="flex items-center gap-3 px-4 py-3 rounded-xl hover:bg-red-50 dark:hover:bg-red-900/30 text-sm font-medium text-red-600 dark:text-red-400 w-full text-left"> 111 - <LogOut size={18} /> 112 - Log out 113 - </button> 114 - </div> 115 - </div> 116 - </aside> 117 - ); 118 - }
-283
web/src/components/SignUpModal.tsx
··· 1 - 2 - import React, { useState, useEffect } from "react"; 3 - import { X, ChevronRight, Loader2, AlertCircle } from "lucide-react"; 4 - import { 5 - BlackskyIcon, 6 - NorthskyIcon, 7 - BlueskyIcon, 8 - TophhieIcon, 9 - MarginIcon, 10 - } from "./Icons"; 11 - import { startSignup } from "../api/client"; 12 - 13 - interface Provider { 14 - id: string; 15 - name: string; 16 - service: string; 17 - Icon: any; 18 - description: string; 19 - custom?: boolean; 20 - wide?: boolean; 21 - } 22 - 23 - const RECOMMENDED_PROVIDER: Provider = { 24 - id: "margin", 25 - name: "Margin", 26 - service: "https://margin.cafe", 27 - Icon: MarginIcon, 28 - description: "Hosted by Margin, the easiest way to get started", 29 - }; 30 - 31 - const OTHER_PROVIDERS: Provider[] = [ 32 - { 33 - id: "bluesky", 34 - name: "Bluesky", 35 - service: "https://bsky.social", 36 - Icon: BlueskyIcon, 37 - description: "The most popular option on the AT Protocol", 38 - }, 39 - { 40 - id: "blacksky", 41 - name: "Blacksky", 42 - service: "https://blacksky.app", 43 - Icon: BlackskyIcon, 44 - description: "For the Culture. A safe space for users and allies", 45 - }, 46 - { 47 - id: "selfhosted.social", 48 - name: "selfhosted.social", 49 - service: "https://selfhosted.social", 50 - Icon: null, 51 - description: "For hackers, designers, and ATProto enthusiasts.", 52 - }, 53 - { 54 - id: "northsky", 55 - name: "Northsky", 56 - service: "https://northsky.social", 57 - Icon: NorthskyIcon, 58 - description: "A Canadian-based worker-owned cooperative", 59 - }, 60 - { 61 - id: "tophhie", 62 - name: "Tophhie", 63 - service: "https://tophhie.social", 64 - Icon: TophhieIcon, 65 - description: "A welcoming and friendly community", 66 - }, 67 - { 68 - id: "altq", 69 - name: "AltQ", 70 - service: "https://altq.net", 71 - Icon: null, 72 - description: "An independent, self-hosted PDS instance", 73 - }, 74 - { 75 - id: "custom", 76 - name: "Custom", 77 - service: "", 78 - custom: true, 79 - Icon: null, 80 - description: "Connect to your own or another custom PDS", 81 - }, 82 - ]; 83 - 84 - interface SignUpModalProps { 85 - onClose: () => void; 86 - } 87 - 88 - export default function SignUpModal({ onClose }: SignUpModalProps) { 89 - const [showOtherProviders, setShowOtherProviders] = useState(false); 90 - const [showCustomInput, setShowCustomInput] = useState(false); 91 - const [customService, setCustomService] = useState(""); 92 - const [loading, setLoading] = useState(false); 93 - const [error, setError] = useState<string | null>(null); 94 - 95 - useEffect(() => { 96 - document.body.style.overflow = "hidden"; 97 - return () => { 98 - document.body.style.overflow = "unset"; 99 - }; 100 - }, []); 101 - 102 - const handleProviderSelect = async (provider: Provider) => { 103 - if (provider.custom) { 104 - setShowCustomInput(true); 105 - return; 106 - } 107 - 108 - setLoading(true); 109 - setError(null); 110 - 111 - try { 112 - const result = await startSignup(provider.service); 113 - if (result.authorizationUrl) { 114 - window.location.href = result.authorizationUrl; 115 - } 116 - } catch (err) { 117 - console.error(err); 118 - setError("Could not connect to this provider. Please try again."); 119 - setLoading(false); 120 - } 121 - }; 122 - 123 - const handleCustomSubmit = async (e: React.FormEvent) => { 124 - e.preventDefault(); 125 - if (!customService.trim()) return; 126 - 127 - setLoading(true); 128 - setError(null); 129 - 130 - let serviceUrl = customService.trim(); 131 - if (!serviceUrl.startsWith("http")) { 132 - serviceUrl = `https://${serviceUrl}`; 133 - } 134 - 135 - try { 136 - const result = await startSignup(serviceUrl); 137 - if (result.authorizationUrl) { 138 - window.location.href = result.authorizationUrl; 139 - } 140 - } catch (err) { 141 - console.error(err); 142 - setError("Could not connect to this PDS. Please check the URL."); 143 - setLoading(false); 144 - } 145 - }; 146 - 147 - return ( 148 - <div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-fade-in"> 149 - <div className="w-full max-w-md bg-white rounded-3xl shadow-2xl overflow-hidden animate-slide-up"> 150 - <div className="p-4 flex justify-end"> 151 - <button onClick={onClose} className="p-2 text-surface-400 hover:text-surface-900 hover:bg-surface-50 rounded-full transition-colors"> 152 - <X size={20} /> 153 - </button> 154 - </div> 155 - 156 - <div className="px-8 pb-10"> 157 - {loading ? ( 158 - <div className="text-center py-10"> 159 - <Loader2 size={40} className="animate-spin text-primary-600 mx-auto mb-4" /> 160 - <p className="text-surface-600 font-medium">Connecting to provider...</p> 161 - </div> 162 - ) : showCustomInput ? ( 163 - <div> 164 - <h2 className="text-2xl font-display font-bold text-surface-900 mb-6">Custom Provider</h2> 165 - <form onSubmit={handleCustomSubmit} className="space-y-4"> 166 - <div> 167 - <label className="block text-sm font-medium text-surface-700 mb-1"> 168 - PDS address (e.g. pds.example.com) 169 - </label> 170 - <input 171 - type="text" 172 - className="w-full px-4 py-3 bg-surface-50 border border-surface-200 rounded-xl focus:border-primary-500 focus:ring-4 focus:ring-primary-500/10 outline-none transition-all" 173 - value={customService} 174 - onChange={(e) => setCustomService(e.target.value)} 175 - placeholder="pds.example.com" 176 - autoFocus 177 - /> 178 - </div> 179 - 180 - {error && ( 181 - <div className="p-3 bg-red-50 text-red-600 text-sm rounded-lg flex items-center gap-2"> 182 - <AlertCircle size={16} /> 183 - {error} 184 - </div> 185 - )} 186 - 187 - <div className="flex gap-3 pt-4"> 188 - <button 189 - type="button" 190 - className="flex-1 py-3 bg-white border border-surface-200 text-surface-700 font-semibold rounded-xl hover:bg-surface-50 transition-colors" 191 - onClick={() => { 192 - setShowCustomInput(false); 193 - setError(null); 194 - }} 195 - > 196 - Back 197 - </button> 198 - <button 199 - type="submit" 200 - className="flex-1 py-3 bg-primary-600 text-white font-semibold rounded-xl hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed" 201 - disabled={!customService.trim()} 202 - > 203 - Continue 204 - </button> 205 - </div> 206 - </form> 207 - </div> 208 - ) : ( 209 - <div> 210 - <h2 className="text-2xl font-display font-bold text-surface-900 mb-2">Create your account</h2> 211 - <p className="text-surface-500 mb-6"> 212 - Margin adheres to the AT Protocol. Choose a provider to host your account. 213 - </p> 214 - 215 - {error && ( 216 - <div className="mb-4 p-3 bg-red-50 text-red-600 text-sm rounded-lg flex items-center gap-2"> 217 - <AlertCircle size={16} /> 218 - {error} 219 - </div> 220 - )} 221 - 222 - <div className="mb-6"> 223 - <div className="inline-block px-2 py-0.5 bg-primary-50 text-primary-700 text-xs font-bold uppercase tracking-wider rounded-md mb-2">Recommended</div> 224 - <button 225 - className="w-full flex items-center gap-4 p-4 bg-white border-2 border-primary-100 hover:border-primary-300 rounded-2xl shadow-sm hover:shadow-md transition-all group text-left" 226 - onClick={() => handleProviderSelect(RECOMMENDED_PROVIDER)} 227 - > 228 - <div className="w-12 h-12 bg-primary-50 rounded-full flex items-center justify-center text-primary-600 flex-shrink-0"> 229 - {RECOMMENDED_PROVIDER.Icon && <RECOMMENDED_PROVIDER.Icon size={24} />} 230 - </div> 231 - <div className="flex-1 min-w-0"> 232 - <h3 className="font-bold text-surface-900 group-hover:text-primary-700 transition-colors">{RECOMMENDED_PROVIDER.name}</h3> 233 - <span className="text-sm text-surface-500 line-clamp-1">{RECOMMENDED_PROVIDER.description}</span> 234 - </div> 235 - <ChevronRight size={20} className="text-surface-300 group-hover:text-primary-500" /> 236 - </button> 237 - </div> 238 - 239 - <div className="border-t border-surface-100 pt-4"> 240 - <button 241 - type="button" 242 - className="flex items-center gap-2 text-sm font-medium text-surface-500 hover:text-surface-900 transition-colors mb-4" 243 - onClick={() => setShowOtherProviders(!showOtherProviders)} 244 - > 245 - {showOtherProviders ? "Hide other options" : "More options"} 246 - <ChevronRight 247 - size={14} 248 - className={`transition-transform duration-200 ${showOtherProviders ? "rotate-90" : ""}`} 249 - /> 250 - </button> 251 - 252 - {showOtherProviders && ( 253 - <div className="space-y-2 animate-fade-in"> 254 - {OTHER_PROVIDERS.map((p) => ( 255 - <button 256 - key={p.id} 257 - className="w-full flex items-center gap-3 p-3 bg-surface-50 hover:bg-surface-100 rounded-xl transition-colors text-left group" 258 - onClick={() => handleProviderSelect(p)} 259 - > 260 - <div className="w-8 h-8 flex items-center justify-center bg-white rounded-full shadow-sm text-surface-600"> 261 - {p.Icon ? ( 262 - <p.Icon size={18} /> 263 - ) : ( 264 - <span className="font-bold text-xs">{p.name[0]}</span> 265 - )} 266 - </div> 267 - <div className="flex-1 min-w-0"> 268 - <h3 className="text-sm font-bold text-surface-900">{p.name}</h3> 269 - <p className="text-xs text-surface-500 line-clamp-1">{p.description}</p> 270 - </div> 271 - <ChevronRight size={16} className="text-surface-300 group-hover:text-surface-600" /> 272 - </button> 273 - ))} 274 - </div> 275 - )} 276 - </div> 277 - </div> 278 - )} 279 - </div> 280 - </div> 281 - </div> 282 - ); 283 - }
+423
web/src/components/common/Card.tsx
··· 1 + import React, { useState } from "react"; 2 + import { formatDistanceToNow } from "date-fns"; 3 + import { 4 + MessageSquare, 5 + Heart, 6 + ExternalLink, 7 + FolderPlus, 8 + Trash2, 9 + Edit3, 10 + Globe, 11 + } from "lucide-react"; 12 + import ShareMenu from "../modals/ShareMenu"; 13 + import AddToCollectionModal from "../modals/AddToCollectionModal"; 14 + import ExternalLinkModal from "../modals/ExternalLinkModal"; 15 + import { clsx } from "clsx"; 16 + import { likeItem, unlikeItem, deleteItem } from "../../api/client"; 17 + import { $user } from "../../store/auth"; 18 + import { $preferences } from "../../store/preferences"; 19 + import { useStore } from "@nanostores/react"; 20 + import type { AnnotationItem } from "../../types"; 21 + import { Link } from "react-router-dom"; 22 + import { Avatar } from "../ui"; 23 + import CollectionIcon from "./CollectionIcon"; 24 + import ProfileHoverCard from "./ProfileHoverCard"; 25 + 26 + interface CardProps { 27 + item: AnnotationItem; 28 + onDelete?: (uri: string) => void; 29 + hideShare?: boolean; 30 + } 31 + 32 + export default function Card({ item, onDelete, hideShare }: CardProps) { 33 + const user = useStore($user); 34 + const isAuthor = user && item.author?.did === user.did; 35 + 36 + const [liked, setLiked] = useState(!!item.viewer?.like); 37 + const [likes, setLikes] = useState(item.likeCount || 0); 38 + const [showCollectionModal, setShowCollectionModal] = useState(false); 39 + const [showExternalLinkModal, setShowExternalLinkModal] = useState(false); 40 + const [externalLinkUrl, setExternalLinkUrl] = useState<string | null>(null); 41 + 42 + const type = 43 + item.motivation === "highlighting" 44 + ? "highlight" 45 + : item.motivation === "bookmarking" 46 + ? "bookmark" 47 + : "annotation"; 48 + 49 + const isSemble = 50 + item.uri?.includes("network.cosmik") || item.uri?.includes("semble"); 51 + 52 + const handleLike = async () => { 53 + const prev = { liked, likes }; 54 + setLiked(!liked); 55 + setLikes((l) => (liked ? l - 1 : l + 1)); 56 + 57 + const success = liked 58 + ? await unlikeItem(item.uri) 59 + : await likeItem(item.uri, item.cid); 60 + 61 + if (!success) { 62 + setLiked(prev.liked); 63 + setLikes(prev.likes); 64 + } 65 + }; 66 + 67 + const handleDelete = async () => { 68 + if (window.confirm("Delete this item?")) { 69 + const success = await deleteItem(item.uri, type); 70 + if (success && onDelete) onDelete(item.uri); 71 + } 72 + }; 73 + 74 + const handleExternalClick = (e: React.MouseEvent, url: string) => { 75 + e.preventDefault(); 76 + e.stopPropagation(); 77 + 78 + try { 79 + const hostname = new URL(url).hostname; 80 + const skipped = $preferences.get().externalLinkSkippedHostnames; 81 + if (skipped.includes(hostname)) { 82 + window.open(url, "_blank", "noopener,noreferrer"); 83 + return; 84 + } 85 + } catch { 86 + // ignore 87 + } 88 + 89 + setExternalLinkUrl(url); 90 + setShowExternalLinkModal(true); 91 + }; 92 + 93 + const timestamp = item.createdAt 94 + ? formatDistanceToNow(new Date(item.createdAt), { addSuffix: false }) 95 + .replace("about ", "") 96 + .replace(" hours", "h") 97 + .replace(" hour", "h") 98 + .replace(" minutes", "m") 99 + .replace(" minute", "m") 100 + .replace(" days", "d") 101 + .replace(" day", "d") 102 + : ""; 103 + 104 + const detailUrl = `/${item.author?.handle || item.author?.did}/${type}/${(item.uri || "").split("/").pop()}`; 105 + 106 + const pageUrl = item.target?.source || item.source; 107 + const pageTitle = 108 + item.target?.title || 109 + item.title || 110 + (pageUrl ? new URL(pageUrl).hostname : null); 111 + const pageHostname = pageUrl 112 + ? new URL(pageUrl).hostname.replace("www.", "") 113 + : null; 114 + const isBookmark = type === "bookmark"; 115 + 116 + const [ogData, setOgData] = useState<{ 117 + title?: string; 118 + description?: string; 119 + image?: string; 120 + icon?: string; 121 + } | null>(null); 122 + 123 + const [imgError, setImgError] = useState(false); 124 + const [iconError, setIconError] = useState(false); 125 + 126 + React.useEffect(() => { 127 + if (isBookmark && item.uri && !ogData && pageUrl) { 128 + const fetchMetadata = async () => { 129 + try { 130 + const res = await fetch( 131 + `/api/url-metadata?url=${encodeURIComponent(pageUrl)}`, 132 + ); 133 + if (res.ok) { 134 + const data = await res.json(); 135 + setOgData(data); 136 + } 137 + } catch (e) { 138 + console.error("Failed to fetch metadata", e); 139 + } 140 + }; 141 + fetchMetadata(); 142 + } 143 + }, [isBookmark, item.uri, pageUrl, ogData]); 144 + 145 + const displayTitle = 146 + item.title || ogData?.title || pageTitle || "Untitled Bookmark"; 147 + const displayDescription = item.description || ogData?.description; 148 + const displayImage = ogData?.image; 149 + 150 + return ( 151 + <article className="card p-4 hover:ring-black/10 dark:hover:ring-white/10 transition-all"> 152 + {item.collection && ( 153 + <div className="flex items-center gap-1.5 text-xs text-surface-400 dark:text-surface-500 mb-2"> 154 + {item.addedBy && item.addedBy.did !== item.author?.did ? ( 155 + <> 156 + <ProfileHoverCard did={item.addedBy.did}> 157 + <Link 158 + to={`/profile/${item.addedBy.did}`} 159 + className="flex items-center gap-1.5 font-medium hover:text-primary-600 dark:hover:text-primary-400 transition-colors" 160 + > 161 + <Avatar 162 + did={item.addedBy.did} 163 + avatar={item.addedBy.avatar} 164 + size="xs" 165 + /> 166 + <span> 167 + {item.addedBy.displayName || `@${item.addedBy.handle}`} 168 + </span> 169 + </Link> 170 + </ProfileHoverCard> 171 + <span>added to</span> 172 + </> 173 + ) : ( 174 + <span>Added to</span> 175 + )} 176 + <Link 177 + to={`/${item.addedBy?.handle || ""}/collection/${(item.collection.uri || "").split("/").pop()}`} 178 + className="inline-flex items-center gap-1 hover:text-primary-600 dark:hover:text-primary-400 transition-colors" 179 + > 180 + <CollectionIcon icon={item.collection.icon} size={14} /> 181 + <span className="font-medium">{item.collection.name}</span> 182 + </Link> 183 + </div> 184 + )} 185 + 186 + <div className="flex items-start gap-3"> 187 + <ProfileHoverCard did={item.author?.did}> 188 + <Link to={`/profile/${item.author?.did}`} className="shrink-0"> 189 + <Avatar 190 + did={item.author?.did} 191 + avatar={item.author?.avatar} 192 + size="md" 193 + /> 194 + </Link> 195 + </ProfileHoverCard> 196 + 197 + <div className="flex-1 min-w-0"> 198 + <div className="flex items-center gap-1.5 flex-wrap"> 199 + <ProfileHoverCard did={item.author?.did}> 200 + <Link 201 + to={`/profile/${item.author?.did}`} 202 + className="font-semibold text-surface-900 dark:text-white text-[15px] hover:underline" 203 + > 204 + {item.author?.displayName || item.author?.handle} 205 + </Link> 206 + </ProfileHoverCard> 207 + <span className="text-surface-400 dark:text-surface-500 text-sm"> 208 + @{item.author?.handle} 209 + </span> 210 + <span className="text-surface-300 dark:text-surface-600">·</span> 211 + <span className="text-surface-400 dark:text-surface-500 text-sm"> 212 + {timestamp} 213 + </span> 214 + {isSemble && ( 215 + <span className="inline-flex items-center gap-1 text-[10px] text-surface-400 dark:text-surface-500 uppercase font-medium tracking-wide"> 216 + · via{" "} 217 + <img 218 + src="/semble-logo.svg" 219 + alt="Semble" 220 + className="h-3 opacity-70" 221 + /> 222 + </span> 223 + )} 224 + </div> 225 + 226 + {pageUrl && !isBookmark && ( 227 + <a 228 + href={pageUrl} 229 + target="_blank" 230 + rel="noopener noreferrer" 231 + onClick={(e) => handleExternalClick(e, pageUrl)} 232 + className="inline-flex items-center gap-1 text-xs text-primary-600 dark:text-primary-400 hover:underline mt-0.5" 233 + > 234 + <ExternalLink size={10} /> 235 + {pageHostname} 236 + </a> 237 + )} 238 + </div> 239 + </div> 240 + 241 + <div className="mt-3 ml-[52px]"> 242 + {isBookmark && ( 243 + <a 244 + href={pageUrl || "#"} 245 + target={pageUrl ? "_blank" : undefined} 246 + rel="noopener noreferrer" 247 + onClick={(e) => pageUrl && handleExternalClick(e, pageUrl)} 248 + className="block bg-surface-50 dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 hover:border-primary-300 dark:hover:border-primary-600 hover:bg-surface-100 dark:hover:bg-surface-700 transition-all group overflow-hidden" 249 + > 250 + {displayImage && !imgError && ( 251 + <div className="h-32 w-full overflow-hidden bg-surface-200 dark:bg-surface-700 border-b border-surface-200 dark:border-surface-700"> 252 + <img 253 + src={displayImage} 254 + alt="" 255 + onError={() => setImgError(true)} 256 + className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" 257 + /> 258 + </div> 259 + )} 260 + <div className="p-4"> 261 + <h3 className="font-semibold text-surface-900 dark:text-white text-base leading-snug group-hover:text-primary-600 dark:group-hover:text-primary-400 mb-2 transition-colors"> 262 + {displayTitle} 263 + </h3> 264 + 265 + {displayDescription && ( 266 + <p className="text-surface-600 dark:text-surface-400 text-sm leading-relaxed mb-3 line-clamp-2"> 267 + {displayDescription} 268 + </p> 269 + )} 270 + 271 + <div className="flex items-center gap-2 text-xs text-surface-500 dark:text-surface-500"> 272 + <div className="w-5 h-5 rounded-full bg-surface-200 dark:bg-surface-700 flex items-center justify-center shrink-0 overflow-hidden"> 273 + {ogData?.icon && !iconError ? ( 274 + <img 275 + src={ogData.icon} 276 + alt="" 277 + onError={() => setIconError(true)} 278 + className="w-3.5 h-3.5 object-contain" 279 + /> 280 + ) : ( 281 + <Globe size={10} /> 282 + )} 283 + </div> 284 + <span className="truncate max-w-[200px]"> 285 + {pageHostname || pageUrl} 286 + </span> 287 + </div> 288 + </div> 289 + </a> 290 + )} 291 + 292 + {item.target?.selector?.exact && ( 293 + <blockquote 294 + className={clsx( 295 + "pl-4 py-2 border-l-[3px] mb-3 text-[15px] italic text-surface-600 dark:text-surface-300 rounded-r-lg hover:bg-surface-50 dark:hover:bg-surface-800/50 transition-colors", 296 + !item.color && 297 + type === "highlight" && 298 + "border-yellow-400 bg-yellow-50/50 dark:bg-yellow-900/20", 299 + item.color === "yellow" && 300 + "border-yellow-400 bg-yellow-50/50 dark:bg-yellow-900/20", 301 + item.color === "green" && 302 + "border-green-400 bg-green-50/50 dark:bg-green-900/20", 303 + item.color === "red" && 304 + "border-red-400 bg-red-50/50 dark:bg-red-900/20", 305 + item.color === "blue" && 306 + "border-blue-400 bg-blue-50/50 dark:bg-blue-900/20", 307 + !item.color && 308 + type !== "highlight" && 309 + "border-surface-300 dark:border-surface-600", 310 + )} 311 + style={ 312 + item.color?.startsWith("#") 313 + ? { 314 + borderColor: item.color, 315 + backgroundColor: `${item.color}15`, 316 + } 317 + : undefined 318 + } 319 + > 320 + <a 321 + href={`${pageUrl}#:~:text=${item.target.selector.prefix ? encodeURIComponent(item.target.selector.prefix) + "-," : ""}${encodeURIComponent(item.target.selector.exact)}${item.target.selector.suffix ? ",-" + encodeURIComponent(item.target.selector.suffix) : ""}`} 322 + target="_blank" 323 + rel="noopener noreferrer" 324 + onClick={(e) => { 325 + const sel = item.target?.selector; 326 + if (!sel) return; 327 + const url = `${pageUrl}#:~:text=${sel.prefix ? encodeURIComponent(sel.prefix) + "-," : ""}${encodeURIComponent(sel.exact)}${sel.suffix ? ",-" + encodeURIComponent(sel.suffix) : ""}`; 328 + handleExternalClick(e, url); 329 + }} 330 + className="block" 331 + > 332 + "{item.target?.selector?.exact}" 333 + </a> 334 + </blockquote> 335 + )} 336 + 337 + {item.body?.value && ( 338 + <p className="text-surface-900 dark:text-surface-100 whitespace-pre-wrap leading-relaxed text-[15px]"> 339 + {item.body.value} 340 + </p> 341 + )} 342 + </div> 343 + 344 + <div className="flex items-center gap-1 mt-3 ml-[52px]"> 345 + <button 346 + onClick={handleLike} 347 + className={clsx( 348 + "flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-sm transition-all", 349 + liked 350 + ? "text-red-500 bg-red-50 dark:bg-red-900/20" 351 + : "text-surface-400 dark:text-surface-500 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20", 352 + )} 353 + > 354 + <Heart size={16} className={clsx(liked && "fill-current")} /> 355 + {likes > 0 && <span className="text-xs font-medium">{likes}</span>} 356 + </button> 357 + 358 + {type === "annotation" && ( 359 + <Link 360 + to={detailUrl} 361 + className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-sm text-surface-400 dark:text-surface-500 hover:text-primary-600 dark:hover:text-primary-400 hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-all" 362 + > 363 + <MessageSquare size={16} /> 364 + {(item.replyCount || 0) > 0 && ( 365 + <span className="text-xs font-medium">{item.replyCount}</span> 366 + )} 367 + </Link> 368 + )} 369 + 370 + {user && ( 371 + <button 372 + onClick={() => setShowCollectionModal(true)} 373 + className="flex items-center px-2.5 py-1.5 rounded-lg text-surface-400 dark:text-surface-500 hover:text-primary-600 dark:hover:text-primary-400 hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-all" 374 + title="Add to Collection" 375 + > 376 + <FolderPlus size={16} /> 377 + </button> 378 + )} 379 + 380 + {!hideShare && ( 381 + <ShareMenu 382 + uri={item.uri} 383 + text={item.body?.value || ""} 384 + handle={item.author?.handle} 385 + type={type} 386 + url={pageUrl} 387 + /> 388 + )} 389 + 390 + {isAuthor && ( 391 + <> 392 + <div className="flex-1" /> 393 + <button 394 + className="flex items-center px-2.5 py-1.5 rounded-lg text-surface-400 dark:text-surface-500 hover:text-surface-600 dark:hover:text-surface-300 hover:bg-surface-50 dark:hover:bg-surface-800 transition-all" 395 + title="Edit" 396 + > 397 + <Edit3 size={14} /> 398 + </button> 399 + <button 400 + onClick={handleDelete} 401 + className="flex items-center px-2.5 py-1.5 rounded-lg text-surface-400 dark:text-surface-500 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-all" 402 + title="Delete" 403 + > 404 + <Trash2 size={14} /> 405 + </button> 406 + </> 407 + )} 408 + </div> 409 + 410 + <AddToCollectionModal 411 + isOpen={showCollectionModal} 412 + onClose={() => setShowCollectionModal(false)} 413 + annotationUri={item.uri} 414 + /> 415 + 416 + <ExternalLinkModal 417 + isOpen={showExternalLinkModal} 418 + onClose={() => setShowExternalLinkModal(false)} 419 + url={externalLinkUrl} 420 + /> 421 + </article> 422 + ); 423 + }
+131
web/src/components/common/CollectionIcon.tsx
··· 1 + import React from "react"; 2 + import { 3 + Folder, 4 + Star, 5 + Heart, 6 + Bookmark, 7 + Lightbulb, 8 + Zap, 9 + Coffee, 10 + Music, 11 + Camera, 12 + Code, 13 + Globe, 14 + Flag, 15 + Tag, 16 + Box, 17 + Archive, 18 + FileText, 19 + Image, 20 + Video, 21 + Mail, 22 + MapPin, 23 + Calendar, 24 + Clock, 25 + Search, 26 + Settings, 27 + User, 28 + Users, 29 + Home, 30 + Briefcase, 31 + Gift, 32 + Award, 33 + Target, 34 + TrendingUp, 35 + Activity, 36 + Cpu, 37 + Database, 38 + Cloud, 39 + Sun, 40 + Moon, 41 + Flame, 42 + Leaf, 43 + } from "lucide-react"; 44 + 45 + export const ICON_MAP: Record<string, React.ElementType> = { 46 + folder: Folder, 47 + star: Star, 48 + heart: Heart, 49 + bookmark: Bookmark, 50 + lightbulb: Lightbulb, 51 + zap: Zap, 52 + coffee: Coffee, 53 + music: Music, 54 + camera: Camera, 55 + code: Code, 56 + globe: Globe, 57 + flag: Flag, 58 + tag: Tag, 59 + box: Box, 60 + archive: Archive, 61 + file: FileText, 62 + image: Image, 63 + video: Video, 64 + mail: Mail, 65 + pin: MapPin, 66 + calendar: Calendar, 67 + clock: Clock, 68 + search: Search, 69 + settings: Settings, 70 + user: User, 71 + users: Users, 72 + home: Home, 73 + briefcase: Briefcase, 74 + gift: Gift, 75 + award: Award, 76 + target: Target, 77 + trending: TrendingUp, 78 + activity: Activity, 79 + cpu: Cpu, 80 + database: Database, 81 + cloud: Cloud, 82 + sun: Sun, 83 + moon: Moon, 84 + flame: Flame, 85 + leaf: Leaf, 86 + }; 87 + 88 + interface CollectionIconProps { 89 + icon?: string; 90 + size?: number; 91 + className?: string; 92 + } 93 + 94 + export default function CollectionIcon({ 95 + icon, 96 + size = 22, 97 + className = "", 98 + }: CollectionIconProps) { 99 + if (!icon) { 100 + return <Folder size={size} className={className} />; 101 + } 102 + 103 + if (icon === "icon:semble") { 104 + return ( 105 + <img 106 + src="/semble-logo.svg" 107 + alt="Semble" 108 + style={{ width: size, height: size, objectFit: "contain" }} 109 + className={className} 110 + /> 111 + ); 112 + } 113 + 114 + if (icon.startsWith("icon:")) { 115 + const iconName = icon.replace("icon:", ""); 116 + const IconComponent = ICON_MAP[iconName]; 117 + if (IconComponent) { 118 + return <IconComponent size={size} className={className} />; 119 + } 120 + return <Folder size={size} className={className} />; 121 + } 122 + 123 + return ( 124 + <span 125 + style={{ fontSize: `${size * 0.8}px`, lineHeight: 1 }} 126 + className={className} 127 + > 128 + {icon} 129 + </span> 130 + ); 131 + }
+585
web/src/components/common/Icons.tsx
··· 1 + import React from "react"; 2 + import { FaGithub, FaLinkedin } from "react-icons/fa"; 3 + 4 + interface IconProps { 5 + size?: number; 6 + color?: string; 7 + filled?: boolean; 8 + } 9 + 10 + export function HeartIcon({ filled = false, size = 18 }: IconProps) { 11 + return filled ? ( 12 + <svg 13 + width={size} 14 + height={size} 15 + viewBox="0 0 24 24" 16 + fill="currentColor" 17 + stroke="none" 18 + > 19 + <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" /> 20 + </svg> 21 + ) : ( 22 + <svg 23 + width={size} 24 + height={size} 25 + viewBox="0 0 24 24" 26 + fill="none" 27 + stroke="currentColor" 28 + strokeWidth="2" 29 + strokeLinecap="round" 30 + strokeLinejoin="round" 31 + > 32 + <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" /> 33 + </svg> 34 + ); 35 + } 36 + 37 + export function MessageIcon({ size = 18 }: IconProps) { 38 + return ( 39 + <svg 40 + width={size} 41 + height={size} 42 + viewBox="0 0 24 24" 43 + fill="none" 44 + stroke="currentColor" 45 + strokeWidth="2" 46 + strokeLinecap="round" 47 + strokeLinejoin="round" 48 + > 49 + <path d="m3 21 1.9-5.7a8.5 8.5 0 1 1 3.8 3.8z" /> 50 + </svg> 51 + ); 52 + } 53 + 54 + export function ShareIcon({ size = 18 }: IconProps) { 55 + return ( 56 + <svg 57 + width={size} 58 + height={size} 59 + viewBox="0 0 24 24" 60 + fill="none" 61 + stroke="currentColor" 62 + strokeWidth="2" 63 + strokeLinecap="round" 64 + strokeLinejoin="round" 65 + > 66 + <path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" /> 67 + <polyline points="16 6 12 2 8 6" /> 68 + <line x1="12" x2="12" y1="2" y2="15" /> 69 + </svg> 70 + ); 71 + } 72 + 73 + export function TrashIcon({ size = 18 }: IconProps) { 74 + return ( 75 + <svg 76 + width={size} 77 + height={size} 78 + viewBox="0 0 24 24" 79 + fill="none" 80 + stroke="currentColor" 81 + strokeWidth="2" 82 + strokeLinecap="round" 83 + strokeLinejoin="round" 84 + > 85 + <path d="M3 6h18" /> 86 + <path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" /> 87 + <path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" /> 88 + </svg> 89 + ); 90 + } 91 + 92 + export function LinkIcon({ size = 18 }: IconProps) { 93 + return ( 94 + <svg 95 + width={size} 96 + height={size} 97 + viewBox="0 0 24 24" 98 + fill="none" 99 + stroke="currentColor" 100 + strokeWidth="2" 101 + strokeLinecap="round" 102 + strokeLinejoin="round" 103 + > 104 + <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" /> 105 + <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" /> 106 + </svg> 107 + ); 108 + } 109 + 110 + export function ExternalLinkIcon({ size = 14 }: IconProps) { 111 + return ( 112 + <svg 113 + width={size} 114 + height={size} 115 + viewBox="0 0 24 24" 116 + fill="none" 117 + stroke="currentColor" 118 + strokeWidth="2" 119 + strokeLinecap="round" 120 + strokeLinejoin="round" 121 + > 122 + <path d="M15 3h6v6" /> 123 + <path d="M10 14 21 3" /> 124 + <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /> 125 + </svg> 126 + ); 127 + } 128 + 129 + export function PenIcon({ size = 18 }: IconProps) { 130 + return ( 131 + <svg 132 + width={size} 133 + height={size} 134 + viewBox="0 0 24 24" 135 + fill="none" 136 + stroke="currentColor" 137 + strokeWidth="2" 138 + strokeLinecap="round" 139 + strokeLinejoin="round" 140 + > 141 + <path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" /> 142 + </svg> 143 + ); 144 + } 145 + 146 + export function HighlightIcon({ size = 18 }: IconProps) { 147 + return ( 148 + <svg 149 + width={size} 150 + height={size} 151 + viewBox="0 0 24 24" 152 + fill="none" 153 + stroke="currentColor" 154 + strokeWidth="2" 155 + strokeLinecap="round" 156 + strokeLinejoin="round" 157 + > 158 + <path d="m9 11-6 6v3h9l3-3" /> 159 + <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" /> 160 + </svg> 161 + ); 162 + } 163 + 164 + export function BookmarkIcon({ size = 18 }: IconProps) { 165 + return ( 166 + <svg 167 + width={size} 168 + height={size} 169 + viewBox="0 0 24 24" 170 + fill="none" 171 + stroke="currentColor" 172 + strokeWidth="2" 173 + strokeLinecap="round" 174 + strokeLinejoin="round" 175 + > 176 + <path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z" /> 177 + </svg> 178 + ); 179 + } 180 + 181 + export function TagIcon({ size = 18 }: IconProps) { 182 + return ( 183 + <svg 184 + width={size} 185 + height={size} 186 + viewBox="0 0 24 24" 187 + fill="none" 188 + stroke="currentColor" 189 + strokeWidth="2" 190 + strokeLinecap="round" 191 + strokeLinejoin="round" 192 + > 193 + <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" /> 194 + <circle cx="7.5" cy="7.5" r=".5" fill="currentColor" /> 195 + </svg> 196 + ); 197 + } 198 + 199 + export function AlertIcon({ size = 18 }: IconProps) { 200 + return ( 201 + <svg 202 + width={size} 203 + height={size} 204 + viewBox="0 0 24 24" 205 + fill="none" 206 + stroke="currentColor" 207 + strokeWidth="2" 208 + strokeLinecap="round" 209 + strokeLinejoin="round" 210 + > 211 + <circle cx="12" cy="12" r="10" /> 212 + <line x1="12" x2="12" y1="8" y2="12" /> 213 + <line x1="12" x2="12.01" y1="16" y2="16" /> 214 + </svg> 215 + ); 216 + } 217 + 218 + export function FileTextIcon({ size = 18 }: IconProps) { 219 + return ( 220 + <svg 221 + width={size} 222 + height={size} 223 + viewBox="0 0 24 24" 224 + fill="none" 225 + stroke="currentColor" 226 + strokeWidth="2" 227 + strokeLinecap="round" 228 + strokeLinejoin="round" 229 + > 230 + <path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /> 231 + <path d="M14 2v4a2 2 0 0 0 2 2h4" /> 232 + <path d="M10 9H8" /> 233 + <path d="M16 13H8" /> 234 + <path d="M16 17H8" /> 235 + </svg> 236 + ); 237 + } 238 + 239 + export function SearchIcon({ size = 18 }: IconProps) { 240 + return ( 241 + <svg 242 + width={size} 243 + height={size} 244 + viewBox="0 0 24 24" 245 + fill="none" 246 + stroke="currentColor" 247 + strokeWidth="2" 248 + strokeLinecap="round" 249 + strokeLinejoin="round" 250 + > 251 + <circle cx="11" cy="11" r="8" /> 252 + <path d="m21 21-4.3-4.3" /> 253 + </svg> 254 + ); 255 + } 256 + 257 + export function InboxIcon({ size = 18 }: IconProps) { 258 + return ( 259 + <svg 260 + width={size} 261 + height={size} 262 + viewBox="0 0 24 24" 263 + fill="none" 264 + stroke="currentColor" 265 + strokeWidth="2" 266 + strokeLinecap="round" 267 + strokeLinejoin="round" 268 + > 269 + <polyline points="22 12 16 12 14 15 10 15 8 12 2 12" /> 270 + <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" /> 271 + </svg> 272 + ); 273 + } 274 + 275 + export function BlueskyIcon({ size = 18, color = "currentColor" }: IconProps) { 276 + return ( 277 + <svg 278 + xmlns="http://www.w3.org/2000/svg" 279 + viewBox="0 0 512 512" 280 + width={size} 281 + height={size} 282 + > 283 + <path 284 + fill={color} 285 + 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" 286 + /> 287 + </svg> 288 + ); 289 + } 290 + 291 + export function MarginIcon({ size = 18 }: IconProps) { 292 + return ( 293 + <svg 294 + width={size} 295 + height={size} 296 + viewBox="0 0 265 231" 297 + fill="currentColor" 298 + xmlns="http://www.w3.org/2000/svg" 299 + > 300 + <path d="M0 230 V0 H199 V65.7156 H149.5 V115.216 H182.5 L199 131.716 V230 Z" /> 301 + <path d="M215 214.224 V230 H264.5 V0 H215.07 V16.2242 H248.5 V214.224 H215 Z" /> 302 + </svg> 303 + ); 304 + } 305 + 306 + export function LogoutIcon({ size = 18 }: IconProps) { 307 + return ( 308 + <svg 309 + width={size} 310 + height={size} 311 + viewBox="0 0 24 24" 312 + fill="none" 313 + stroke="currentColor" 314 + strokeWidth="2" 315 + strokeLinecap="round" 316 + strokeLinejoin="round" 317 + > 318 + <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /> 319 + <polyline points="16 17 21 12 16 7" /> 320 + <line x1="21" x2="9" y1="12" y2="12" /> 321 + </svg> 322 + ); 323 + } 324 + 325 + export function BellIcon({ size = 18 }: IconProps) { 326 + return ( 327 + <svg 328 + width={size} 329 + height={size} 330 + viewBox="0 0 24 24" 331 + fill="none" 332 + stroke="currentColor" 333 + strokeWidth="2" 334 + strokeLinecap="round" 335 + strokeLinejoin="round" 336 + > 337 + <path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" /> 338 + <path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" /> 339 + </svg> 340 + ); 341 + } 342 + 343 + export function ReplyIcon({ size = 18 }: IconProps) { 344 + return ( 345 + <svg 346 + width={size} 347 + height={size} 348 + viewBox="0 0 24 24" 349 + fill="none" 350 + stroke="currentColor" 351 + strokeWidth="2" 352 + strokeLinecap="round" 353 + strokeLinejoin="round" 354 + > 355 + <polyline points="9 17 4 12 9 7" /> 356 + <path d="M20 18v-2a4 4 0 0 0-4-4H4" /> 357 + </svg> 358 + ); 359 + } 360 + 361 + export function AturiIcon({ size = 18 }: IconProps) { 362 + return ( 363 + <svg 364 + width={size} 365 + height={size} 366 + viewBox="0 0 24 24" 367 + fill="none" 368 + stroke="currentColor" 369 + strokeWidth="2" 370 + strokeLinecap="round" 371 + strokeLinejoin="round" 372 + > 373 + <path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10Z" /> 374 + <path d="M2 21c0-3 1.85-5.36 5.08-6C9.5 14.52 12 13 13 12" /> 375 + </svg> 376 + ); 377 + } 378 + 379 + export function BlackskyIcon({ size = 18 }: IconProps) { 380 + return ( 381 + <svg viewBox="0 0 285 285" width={size} height={size}> 382 + <path 383 + fill="currentColor" 384 + 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" 385 + /> 386 + <path 387 + fill="currentColor" 388 + 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" 389 + /> 390 + <path 391 + fill="currentColor" 392 + 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" 393 + /> 394 + <path 395 + fill="currentColor" 396 + 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" 397 + /> 398 + </svg> 399 + ); 400 + } 401 + 402 + export function NorthskyIcon({ size = 18 }: IconProps) { 403 + return ( 404 + <svg viewBox="0 0 1024 1024" width={size} height={size}> 405 + <defs> 406 + <linearGradient 407 + id="north_a" 408 + x1="564.17" 409 + y1="22.4" 410 + x2="374.54" 411 + y2="1187.29" 412 + gradientUnits="userSpaceOnUse" 413 + gradientTransform="matrix(1 0 0 1.03 31.9 91.01)" 414 + > 415 + <stop offset="0" stopColor="#2affba" /> 416 + <stop offset="0.02" stopColor="#31f4bd" /> 417 + <stop offset="0.14" stopColor="#53bccc" /> 418 + <stop offset="0.25" stopColor="#718ada" /> 419 + <stop offset="0.37" stopColor="#8a5fe5" /> 420 + <stop offset="0.49" stopColor="#9f3def" /> 421 + <stop offset="0.61" stopColor="#af22f6" /> 422 + <stop offset="0.74" stopColor="#bb0ffb" /> 423 + <stop offset="0.87" stopColor="#c204fe" /> 424 + <stop offset="1" stopColor="#c400ff" /> 425 + </linearGradient> 426 + <linearGradient 427 + id="north_b" 428 + x1="554.29" 429 + y1="20.79" 430 + x2="364.65" 431 + y2="1185.68" 432 + xlinkHref="#north_a" 433 + /> 434 + <linearGradient 435 + id="north_c" 436 + x1="561.1" 437 + y1="21.9" 438 + x2="371.47" 439 + y2="1186.79" 440 + xlinkHref="#north_a" 441 + /> 442 + <linearGradient 443 + id="north_d" 444 + x1="530.57" 445 + y1="16.93" 446 + x2="340.93" 447 + y2="1181.82" 448 + xlinkHref="#north_a" 449 + /> 450 + </defs> 451 + <path 452 + d="m275.87 880.64 272-184.16 120.79 114 78.55-56.88 184.6 125.1a485.5 485.5 0 0 0 55.81-138.27c-64.41-21.42-127-48.15-185.92-73.32-97-41.44-188.51-80.52-253.69-80.52-59.57 0-71.53 18.85-89.12 55-16.89 34.55-37.84 77.6-139.69 77.6-81.26 0-159.95-29.93-243.27-61.61-17.07-6.5-34.57-13.14-52.49-19.69A486.06 486.06 0 0 0 95.19 884l91.29-62.16Z" 453 + fill="url(#north_a)" 454 + /> 455 + <path 456 + d="M295.26 506.52c53.69 0 64.49-17.36 80.41-50.63 15.46-32.33 34.7-72.56 128.36-72.56 75 0 154.6 33.2 246.78 71.64 74.85 31.21 156.89 65.34 241 81.63a485.6 485.6 0 0 0-64.23-164.85c-108.88-6-201.82-43.35-284.6-76.69-66.77-26.89-129.69-52.22-182.84-52.22-46.88 0-56.43 15.74-70.55 45.89-13.41 28.65-31.79 67.87-118.24 67.87-44.25 0-90.68-13.48-141-33.11A488.3 488.3 0 0 0 62.86 435.7c8.3 3.38 16.55 6.74 24.68 10.08 76.34 31.22 148.3 60.74 207.72 60.74" 457 + fill="url(#north_b)" 458 + /> 459 + <path 460 + d="M319.2 687.81c61.24 0 73.38-19.09 91.18-55.66 16.7-34.28 37.48-76.95 137.58-76.95 81.4 0 174.78 39.89 282.9 86.09 52.19 22.29 107.38 45.84 163.42 65.43a483 483 0 0 0 2.72-136.5C898.41 554.4 806 516 722.27 481.05c-81.88-34.14-159.08-66.33-218.27-66.33-53.25 0-64 17.29-79.84 50.42-15.51 32.42-34.8 72.77-128.93 72.77-75.08 0-153.29-32-236.08-66l-8.91-3.64A487 487 0 0 0 24 601.68c27.31 9.55 53.55 19.52 79 29.19 80.24 30.55 149.61 56.94 216.2 56.94" 461 + fill="url(#north_c)" 462 + /> 463 + <path 464 + d="M341 279.65c13.49-28.78 31.95-68.19 119.16-68.19 68.59 0 137.73 27.84 210.92 57.32 70.14 28.22 148.13 59.58 233.72 69.37C815.77 218 673 140 511.88 140c-141.15 0-268.24 59.92-357.45 155.62 44 17.32 84.15 29.6 116.89 29.6 46.24 0 55.22-14.79 69.68-45.57" 465 + fill="url(#north_d)" 466 + /> 467 + </svg> 468 + ); 469 + } 470 + 471 + export function TophhieIcon({ size = 18 }: IconProps) { 472 + return ( 473 + <svg 474 + width={size} 475 + height={size} 476 + viewBox="0 0 344 538" 477 + fill="none" 478 + xmlns="http://www.w3.org/2000/svg" 479 + > 480 + <ellipse cx="268.5" cy="455.5" rx="34.5" ry="35.5" fill="currentColor" /> 481 + <ellipse cx="76" cy="75.5" rx="35" ry="35.5" fill="currentColor" /> 482 + <circle cx="268.5" cy="75.5" r="75.5" fill="currentColor" /> 483 + <ellipse cx="76" cy="274.5" rx="76" ry="75.5" fill="currentColor" /> 484 + <ellipse cx="76" cy="462.5" rx="76" ry="75.5" fill="currentColor" /> 485 + <circle cx="268.5" cy="269.5" r="75.5" fill="currentColor" /> 486 + </svg> 487 + ); 488 + } 489 + 490 + export function GithubIcon({ size = 18 }: IconProps) { 491 + return <FaGithub size={size} />; 492 + } 493 + 494 + export function LinkedinIcon({ size = 18 }: IconProps) { 495 + return <FaLinkedin size={size} />; 496 + } 497 + 498 + export function WitchskyIcon({ size = 18 }: IconProps) { 499 + return ( 500 + <svg fill="none" viewBox="0 0 512 512" width={size} height={size}> 501 + <path 502 + fill="#ee5346" 503 + 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" 504 + /> 505 + </svg> 506 + ); 507 + } 508 + 509 + export function CatskyIcon({ size = 18 }: IconProps) { 510 + return ( 511 + <svg 512 + fill="none" 513 + viewBox="0 0 67.733328 67.733329" 514 + width={size} 515 + height={size} 516 + > 517 + <path 518 + fill="#cba7f7" 519 + 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" 520 + /> 521 + <path 522 + fill="#cba7f7" 523 + 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" 524 + /> 525 + <path 526 + fill="#cba7f7" 527 + 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" 528 + /> 529 + <path 530 + fill="#cba7f7" 531 + 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" 532 + /> 533 + <path 534 + fill="#cba7f7" 535 + 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" 536 + /> 537 + <path 538 + fill="#cba7f7" 539 + 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.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" 540 + /> 541 + <path 542 + fill="#cba7f7" 543 + 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" 544 + /> 545 + <path 546 + fill="#cba7f7" 547 + 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.052073,6.1836069 51.974407,6.1337905 51.885945,6.1211124 51.79757,6.1189646 Z" 548 + /> 549 + <path 550 + fill="#cba7f7" 551 + 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" 552 + /> 553 + </svg> 554 + ); 555 + } 556 + 557 + export function DeerIcon({ size = 18 }: IconProps) { 558 + return ( 559 + <svg fill="none" viewBox="0 0 512 512" width={size} height={size}> 560 + <path 561 + fill="#739f7c" 562 + 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" 563 + transform="matrix(2.6921023,0,0,1.7145911,-396.58283,-308.01527)" 564 + /> 565 + </svg> 566 + ); 567 + } 568 + 569 + export function TangledIcon({ 570 + size = 18, 571 + className = "", 572 + }: IconProps & { className?: string }) { 573 + return ( 574 + <svg 575 + width={size} 576 + height={size} 577 + viewBox="0 0 24.122343 23.274094" 578 + fill="currentColor" 579 + xmlns="http://www.w3.org/2000/svg" 580 + className={className} 581 + > 582 + <path d="m 16.348974,24.09935 -0.06485,-0.03766 -0.202005,-0.0106 -0.202008,-0.01048 -0.275736,-0.02601 -0.275734,-0.02602 v -0.02649 -0.02648 l -0.204577,-0.04019 -0.204578,-0.04019 -0.167616,-0.08035 -0.167617,-0.08035 -0.0014,-0.04137 -0.0014,-0.04137 -0.266473,-0.143735 -0.266475,-0.143735 -0.276098,-0.20335 -0.2761,-0.203347 -0.262064,-0.251949 -0.262064,-0.25195 -0.22095,-0.284628 -0.220948,-0.284629 -0.170253,-0.284631 -0.170252,-0.284628 -0.01341,-0.0144 -0.0134,-0.0144 -0.141982,0.161297 -0.14198,0.1613 -0.22313,0.21426 -0.223132,0.214264 -0.186025,0.146053 -0.186023,0.14605 -0.252501,0.163342 -0.252502,0.163342 -0.249014,0.115348 -0.249013,0.115336 0.0053,0.03241 0.0053,0.03241 -0.1716725,0.04599 -0.171669,0.046 -0.3379966,0.101058 -0.3379972,0.101058 -0.1778925,0.04506 -0.1778935,0.04508 -0.3913655,0.02601 -0.3913643,0.02603 -0.3557868,-0.03514 -0.3557863,-0.03514 -0.037426,-0.03029 -0.037427,-0.03029 -0.076924,0.02011 -0.076924,0.02011 -0.050508,-0.05051 -0.050405,-0.05056 L 6.6604532,23.110188 6.451745,23.063961 6.1546135,22.960559 5.8574835,22.857156 5.5319879,22.694039 5.2064938,22.530922 4.8793922,22.302961 4.5522905,22.075005 4.247598,21.786585 3.9429055,21.49817 3.7185335,21.208777 3.4941628,20.919385 3.3669822,20.705914 3.239803,20.492443 3.1335213,20.278969 3.0272397,20.065499 2.9015252,19.7275 2.7758105,19.389504 2.6925225,18.998139 2.6092345,18.606774 2.6096814,17.91299 2.6101284,17.219208 2.6744634,16.90029 2.7387984,16.581374 2.8474286,16.242088 2.9560588,15.9028 3.1137374,15.583492 3.2714148,15.264182 3.3415068,15.150766 3.4115988,15.03735 3.3127798,14.96945 3.2139618,14.90157 3.0360685,14.800239 2.8581753,14.698908 2.5913347,14.503228 2.3244955,14.307547 2.0621238,14.055599 1.7997507,13.803651 1.6111953,13.56878 1.4226411,13.333906 1.2632237,13.087474 1.1038089,12.841042 0.97442,12.575195 0.8450307,12.30935 0.724603,11.971351 0.6041766,11.633356 0.52150365,11.241991 0.4388285,10.850626 0.44091592,10.156842 0.44300333,9.4630594 0.54235911,9.0369608 0.6417149,8.6108622 0.7741173,8.2694368 0.9065196,7.9280115 1.0736303,7.6214262 1.2407515,7.3148397 1.45931,7.0191718 1.6778685,6.7235039 1.9300326,6.4611321 2.1821966,6.1987592 2.4134579,6.0137228 2.6447193,5.8286865 2.8759792,5.6776409 3.1072406,5.526594 3.4282004,5.3713977 3.7491603,5.2162016 3.9263009,5.1508695 4.1034416,5.0855373 4.2813348,4.7481598 4.4592292,4.4107823 4.6718,4.108422 4.8843733,3.8060618 5.198353,3.4805372 5.5123313,3.155014 5.7685095,2.9596425 6.0246877,2.7642722 6.329187,2.5851365 6.6336863,2.406002 6.9497657,2.2751596 7.2658453,2.1443184 7.4756394,2.0772947 7.6854348,2.01027 8.0825241,1.931086 8.4796139,1.851902 l 0.5870477,0.00291 0.5870469,0.00291 0.4447315,0.092455 0.444734,0.092455 0.302419,0.1105495 0.302417,0.1105495 0.329929,0.1646046 0.32993,0.1646033 0.239329,-0.2316919 0.239329,-0.2316919 0.160103,-0.1256767 0.160105,-0.1256767 0.160102,-0.1021909 0.160105,-0.1021899 0.142315,-0.082328 0.142314,-0.082328 0.231262,-0.1090091 0.231259,-0.1090091 0.26684,-0.098743 0.266839,-0.098743 0.320208,-0.073514 0.320209,-0.073527 0.355787,-0.041833 0.355785,-0.041834 0.426942,0.023827 0.426945,0.023828 0.355785,0.071179 0.355788,0.0711791 0.284627,0.09267 0.284629,0.09267 0.28514,0.1310267 0.28514,0.1310255 0.238179,0.1446969 0.238174,0.1446979 0.259413,0.1955332 0.259413,0.1955319 0.290757,0.296774 0.290758,0.2967753 0.151736,0.1941581 0.151734,0.1941594 0.135326,0.2149951 0.135327,0.2149952 0.154755,0.3202073 0.154758,0.3202085 0.09409,0.2677358 0.09409,0.267737 0.06948,0.3319087 0.06948,0.3319099 0.01111,0.00808 0.01111,0.00808 0.444734,0.2173653 0.444734,0.2173665 0.309499,0.2161102 0.309497,0.2161101 0.309694,0.2930023 0.309694,0.2930037 0.18752,0.2348726 0.187524,0.2348727 0.166516,0.2574092 0.166519,0.2574108 0.15273,0.3260252 0.152734,0.3260262 0.08972,0.2668403 0.08971,0.2668391 0.08295,0.3913655 0.08295,0.3913652 -6.21e-4,0.6582049 -6.21e-4,0.658204 -0.06362,0.315725 -0.06362,0.315725 -0.09046,0.289112 -0.09046,0.289112 -0.122759,0.281358 -0.12276,0.281356 -0.146626,0.252323 -0.146629,0.252322 -0.190443,0.258668 -0.190448,0.258671 -0.254911,0.268356 -0.254911,0.268355 -0.286872,0.223127 -0.286874,0.223127 -0.320203,0.187693 -0.320209,0.187693 -0.04347,0.03519 -0.04347,0.03521 0.0564,0.12989 0.0564,0.129892 0.08728,0.213472 0.08728,0.213471 0.189755,0.729363 0.189753,0.729362 0.0652,0.302417 0.0652,0.302419 -0.0018,0.675994 -0.0018,0.675995 -0.0801,0.373573 -0.08009,0.373577 -0.09,0.266839 -0.09,0.26684 -0.190389,0.391364 -0.19039,0.391366 -0.223169,0.320207 -0.223167,0.320209 -0.303585,0.315294 -0.303584,0.315291 -0.284631,0.220665 -0.284629,0.220663 -0.220128,0.132359 -0.220127,0.132358 -0.242395,0.106698 -0.242394,0.106699 -0.08895,0.04734 -0.08895,0.04733 -0.249052,0.07247 -0.24905,0.07247 -0.322042,0.0574 -0.322044,0.0574 -0.282794,-0.003 -0.282795,-0.003 -0.07115,-0.0031 -0.07115,-0.0031 -0.177894,-0.0033 -0.177893,-0.0033 -0.124528,0.02555 -0.124528,0.02555 z m -4.470079,-5.349839 0.214838,-0.01739 0.206601,-0.06782 0.206602,-0.06782 0.244389,-0.117874 0.244393,-0.11786 0.274473,-0.206822 0.27447,-0.20682 0.229308,-0.257201 0.229306,-0.2572 0.219161,-0.28463 0.219159,-0.284629 0.188541,-0.284628 0.188543,-0.28463 0.214594,-0.373574 0.214593,-0.373577 0.133861,-0.312006 0.133865,-0.312007 0.02861,-0.01769 0.02861,-0.01769 0.197275,0.26212 0.197278,0.262119 0.163613,0.150814 0.163614,0.150814 0.201914,0.09276 0.201914,0.09276 0.302417,0.01421 0.302418,0.01421 0.213472,-0.08025 0.213471,-0.08025 0.200606,-0.204641 0.200606,-0.204642 0.09242,-0.278887 0.09241,-0.278888 0.05765,-0.302418 0.05764,-0.302416 L 18.41327,13.768114 18.39502,13.34117 18.31849,12.915185 18.24196,12.4892 18.15595,12.168033 18.06994,11.846867 17.928869,11.444534 17.787801,11.042201 17.621278,10.73296 17.454757,10.423723 17.337388,10.263619 17.220021,10.103516 17.095645,9.9837986 16.971268,9.8640816 16.990048,9.6813736 17.008828,9.4986654 16.947568,9.249616 16.886308,9.0005655 16.752419,8.7159355 16.618521,8.4313217 16.435707,8.2294676 16.252892,8.0276114 16.079629,7.9004245 15.906366,7.773238 l -0.20429,0.1230127 -0.204289,0.1230121 -0.26702,0.059413 -0.267022,0.059413 -0.205761,-0.021508 -0.205766,-0.021508 -0.23495,-0.08844 -0.234953,-0.08844 -0.118429,-0.090334 -0.118428,-0.090333 h -0.03944 -0.03944 L 13.711268,7.8540732 13.655958,7.9706205 13.497227,8.1520709 13.338499,8.3335203 13.168394,8.4419112 12.998289,8.550301 12.777045,8.624223 12.5558,8.698155 H 12.275611 11.995429 L 11.799973,8.6309015 11.604513,8.5636472 11.491311,8.5051061 11.37811,8.446565 11.138172,8.2254579 10.898231,8.0043497 l -0.09565,-0.084618 -0.09565,-0.084613 -0.218822,0.198024 -0.218822,0.1980231 -0.165392,0.078387 -0.1653925,0.078387 -0.177894,0.047948 -0.177892,0.047948 L 9.3635263,8.4842631 9.144328,8.4846889 8.9195029,8.4147138 8.6946778,8.3447386 8.5931214,8.4414036 8.491565,8.5380686 8.3707618,8.7019598 8.2499597,8.8658478 8.0802403,8.9290726 7.9105231,8.9922974 7.7952769,9.0780061 7.6800299,9.1637148 7.5706169,9.2778257 7.4612038,9.3919481 7.1059768,9.9205267 6.7507497,10.449105 l -0.2159851,0.449834 -0.2159839,0.449834 -0.2216572,0.462522 -0.2216559,0.462523 -0.1459343,0.337996 -0.1459342,0.337998 -0.055483,0.220042 -0.055483,0.220041 -0.015885,0.206903 -0.015872,0.206901 0.034307,0.242939 0.034307,0.24294 0.096281,0.196632 0.096281,0.196634 0.143607,0.125222 0.1436071,0.125222 0.1873143,0.08737 0.1873141,0.08737 0.2752084,0.002 0.2752084,0.002 0.2312297,-0.09773 0.231231,-0.09772 0.1067615,-0.07603 0.1067614,-0.07603 0.3679062,-0.29377 0.3679065,-0.293771 0.026804,0.01656 0.026804,0.01656 0.023626,0.466819 0.023626,0.466815 0.088326,0.513195 0.088326,0.513193 0.08897,0.364413 0.08897,0.364411 0.1315362,0.302418 0.1315352,0.302418 0.1051964,0.160105 0.1051954,0.160103 0.1104741,0.11877 0.1104731,0.118769 0.2846284,0.205644 0.2846305,0.205642 0.144448,0.07312 0.144448,0.07312 0.214787,0.05566 0.214787,0.05566 0.245601,0.03075 0.245602,0.03075 0.204577,-0.0125 0.204578,-0.0125 z m 0.686342,-3.497495 -0.11281,-0.06077 -0.106155,-0.134033 -0.106155,-0.134031 -0.04406,-0.18371 -0.04406,-0.183707 0.02417,-0.553937 0.02417,-0.553936 0.03513,-0.426945 0.03513,-0.426942 0.07225,-0.373576 0.07225,-0.373575 0.05417,-0.211338 0.05417,-0.211339 0.0674,-0.132112 0.0674,-0.132112 0.132437,-0.10916 0.132437,-0.109161 0.187436,-0.04195 0.187438,-0.04195 0.170366,0.06469 0.170364,0.06469 0.114312,0.124073 0.114313,0.124086 0.04139,0.18495 0.04139,0.184951 -0.111218,0.459845 -0.111219,0.459844 -0.03383,0.26584 -0.03382,0.265841 -0.03986,0.818307 -0.03986,0.818309 -0.0378,0.15162 -0.03779,0.151621 -0.11089,0.110562 -0.110891,0.110561 -0.114489,0.04913 -0.114489,0.04913 -0.187932,-0.0016 -0.187929,-0.0016 z m -2.8087655,-0.358124 -0.146445,-0.06848 -0.088025,-0.119502 -0.088024,-0.119502 -0.038581,-0.106736 -0.038581,-0.106736 -0.02237,-0.134956 -0.02239,-0.134957 -0.031955,-0.46988 -0.031955,-0.469881 0.036203,-0.444733 0.036203,-0.444731 0.048862,-0.215257 0.048862,-0.215255 0.076082,-0.203349 0.076081,-0.203348 0.0936,-0.111244 0.0936,-0.111245 0.143787,-0.06531 0.1437865,-0.06532 h 0.142315 0.142314 l 0.142314,0.06588 0.142316,0.06588 0.093,0.102325 0.093,0.102325 0.04042,0.120942 0.04042,0.120942 v 0.152479 0.152477 l -0.03347,0.08804 -0.03347,0.08805 -0.05693,0.275653 -0.05693,0.275651 2.11e-4,0.430246 2.12e-4,0.430243 0.04294,0.392646 0.04295,0.392647 -0.09189,0.200702 -0.09189,0.200702 -0.148688,0.0984 -0.148687,0.0984 -0.20136,0.01212 -0.2013595,0.01212 z" /> 583 + </svg> 584 + ); 585 + }
+158
web/src/components/common/ProfileHoverCard.tsx
··· 1 + import React, { useState, useEffect, useRef } from "react"; 2 + import { Link } from "react-router-dom"; 3 + import Avatar from "../ui/Avatar"; 4 + import { getProfile } from "../../api/client"; 5 + import type { UserProfile } from "../../types"; 6 + import { Loader2 } from "lucide-react"; 7 + 8 + interface ProfileHoverCardProps { 9 + did?: string; 10 + handle?: string; 11 + children: React.ReactNode; 12 + className?: string; 13 + } 14 + 15 + export default function ProfileHoverCard({ 16 + did, 17 + handle, 18 + children, 19 + className, 20 + }: ProfileHoverCardProps) { 21 + const [isOpen, setIsOpen] = useState(false); 22 + const [profile, setProfile] = useState<UserProfile | null>(null); 23 + const [loading, setLoading] = useState(false); 24 + const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); 25 + const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); 26 + const cardRef = useRef<HTMLDivElement>(null); 27 + 28 + const handleMouseEnter = () => { 29 + timeoutRef.current = setTimeout(async () => { 30 + setIsOpen(true); 31 + if (!profile && (did || handle)) { 32 + setLoading(true); 33 + try { 34 + const identifier = did || handle || ""; 35 + 36 + const [marginData, bskyData] = await Promise.all([ 37 + getProfile(identifier).catch(() => null), 38 + fetch( 39 + `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(identifier)}`, 40 + ) 41 + .then((res) => (res.ok ? res.json() : null)) 42 + .catch(() => null), 43 + ]); 44 + 45 + const merged: UserProfile = { 46 + did: marginData?.did || bskyData?.did || identifier, 47 + handle: marginData?.handle || bskyData?.handle || "", 48 + displayName: marginData?.displayName || bskyData?.displayName, 49 + avatar: marginData?.avatar || bskyData?.avatar, 50 + description: marginData?.description || bskyData?.description, 51 + }; 52 + 53 + setProfile(merged); 54 + } catch (e) { 55 + console.error("Failed to load profile", e); 56 + } finally { 57 + setLoading(false); 58 + } 59 + } 60 + }, 400); 61 + }; 62 + 63 + const handleMouseLeave = () => { 64 + if (timeoutRef.current) { 65 + clearTimeout(timeoutRef.current); 66 + timeoutRef.current = null; 67 + } 68 + closeTimeoutRef.current = setTimeout(() => { 69 + setIsOpen(false); 70 + }, 300); 71 + }; 72 + 73 + const handleCardMouseEnter = () => { 74 + if (closeTimeoutRef.current) { 75 + clearTimeout(closeTimeoutRef.current); 76 + closeTimeoutRef.current = null; 77 + } 78 + }; 79 + 80 + const handleCardMouseLeave = () => { 81 + setIsOpen(false); 82 + }; 83 + 84 + useEffect(() => { 85 + return () => { 86 + if (timeoutRef.current) { 87 + clearTimeout(timeoutRef.current); 88 + } 89 + if (closeTimeoutRef.current) { 90 + clearTimeout(closeTimeoutRef.current); 91 + } 92 + }; 93 + }, []); 94 + 95 + return ( 96 + <div 97 + className={`relative inline-block ${className || ""}`} 98 + onMouseEnter={handleMouseEnter} 99 + onMouseLeave={handleMouseLeave} 100 + ref={cardRef} 101 + > 102 + {children} 103 + 104 + {isOpen && ( 105 + <div 106 + className="absolute z-50 left-0 top-full mt-2 w-72 bg-white dark:bg-surface-800 rounded-xl shadow-xl border border-surface-200 dark:border-surface-700 p-4 animate-in fade-in slide-in-from-top-1 duration-150" 107 + onMouseEnter={handleCardMouseEnter} 108 + onMouseLeave={handleCardMouseLeave} 109 + > 110 + {loading ? ( 111 + <div className="flex items-center justify-center py-4"> 112 + <Loader2 size={20} className="animate-spin text-primary-600" /> 113 + </div> 114 + ) : profile ? ( 115 + <div className="space-y-3"> 116 + <Link 117 + to={`/profile/${profile.did}`} 118 + className="flex items-start gap-3 group" 119 + > 120 + <Avatar 121 + did={profile.did} 122 + avatar={profile.avatar} 123 + size="lg" 124 + className="shrink-0" 125 + /> 126 + <div className="flex-1 min-w-0"> 127 + <p className="font-semibold text-surface-900 dark:text-white truncate group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors"> 128 + {profile.displayName || profile.handle} 129 + </p> 130 + <p className="text-sm text-surface-500 dark:text-surface-400 truncate"> 131 + @{profile.handle} 132 + </p> 133 + </div> 134 + </Link> 135 + 136 + {profile.description && ( 137 + <p className="text-sm text-surface-600 dark:text-surface-300 line-clamp-3"> 138 + {profile.description} 139 + </p> 140 + )} 141 + 142 + <Link 143 + to={`/profile/${profile.did}`} 144 + className="block w-full text-center py-2 bg-primary-600 hover:bg-primary-700 text-white text-sm font-medium rounded-lg transition-colors" 145 + > 146 + View Profile 147 + </Link> 148 + </div> 149 + ) : ( 150 + <p className="text-sm text-surface-500 text-center py-2"> 151 + Profile not found 152 + </p> 153 + )} 154 + </div> 155 + )} 156 + </div> 157 + ); 158 + }
+206
web/src/components/feed/Composer.tsx
··· 1 + import React, { useState } from "react"; 2 + import { createAnnotation, createHighlight } from "../../api/client"; 3 + import { X } from "lucide-react"; 4 + 5 + interface ComposerProps { 6 + url: string; 7 + selector?: any; 8 + onSuccess?: () => void; 9 + onCancel?: () => void; 10 + } 11 + 12 + export default function Composer({ 13 + url, 14 + selector: initialSelector, 15 + onSuccess, 16 + onCancel, 17 + }: ComposerProps) { 18 + const [text, setText] = useState(""); 19 + const [quoteText, setQuoteText] = useState(""); 20 + const [tags, setTags] = useState(""); 21 + const [selector, setSelector] = useState(initialSelector); 22 + const [loading, setLoading] = useState(false); 23 + const [error, setError] = useState<string | null>(null); 24 + const [showQuoteInput, setShowQuoteInput] = useState(false); 25 + 26 + const highlightedText = 27 + selector?.type === "TextQuoteSelector" ? selector.exact : null; 28 + 29 + const handleSubmit = async (e: React.FormEvent) => { 30 + e.preventDefault(); 31 + if (!text.trim() && !highlightedText && !quoteText.trim()) return; 32 + 33 + try { 34 + setLoading(true); 35 + setError(null); 36 + 37 + let finalSelector = selector; 38 + if (!finalSelector && quoteText.trim()) { 39 + finalSelector = { 40 + type: "TextQuoteSelector", 41 + exact: quoteText.trim(), 42 + }; 43 + } 44 + 45 + const tagList = tags 46 + .split(",") 47 + .map((t) => t.trim()) 48 + .filter(Boolean); 49 + 50 + if (!text.trim()) { 51 + await createHighlight({ 52 + url, 53 + selector: finalSelector, 54 + color: "yellow", 55 + tags: tagList, 56 + }); 57 + } else { 58 + await createAnnotation({ 59 + url, 60 + text: text.trim(), 61 + selector: finalSelector || undefined, 62 + tags: tagList, 63 + }); 64 + } 65 + 66 + setText(""); 67 + setQuoteText(""); 68 + setSelector(null); 69 + if (onSuccess) onSuccess(); 70 + } catch (err: any) { 71 + setError(err.message || "Failed to post"); 72 + } finally { 73 + setLoading(false); 74 + } 75 + }; 76 + 77 + const handleRemoveSelector = () => { 78 + setSelector(null); 79 + setQuoteText(""); 80 + setShowQuoteInput(false); 81 + }; 82 + 83 + return ( 84 + <form onSubmit={handleSubmit} className="flex flex-col gap-4"> 85 + <div className="flex items-center justify-between"> 86 + <h3 className="text-lg font-bold text-surface-900 dark:text-white"> 87 + New Annotation 88 + </h3> 89 + {url && ( 90 + <div className="text-xs text-surface-400 dark:text-surface-500 max-w-[200px] truncate"> 91 + {url} 92 + </div> 93 + )} 94 + </div> 95 + 96 + {highlightedText && ( 97 + <div className="relative p-3 bg-surface-50 dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-lg"> 98 + <button 99 + type="button" 100 + className="absolute top-2 right-2 text-surface-400 dark:text-surface-500 hover:text-surface-600 dark:hover:text-surface-300" 101 + onClick={handleRemoveSelector} 102 + > 103 + <X size={16} /> 104 + </button> 105 + <blockquote className="italic text-surface-600 dark:text-surface-300 border-l-2 border-primary-400 dark:border-primary-500 pl-3 text-sm"> 106 + "{highlightedText}" 107 + </blockquote> 108 + </div> 109 + )} 110 + 111 + {!highlightedText && ( 112 + <> 113 + {!showQuoteInput ? ( 114 + <button 115 + type="button" 116 + className="text-left text-sm text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 font-medium py-1" 117 + onClick={() => setShowQuoteInput(true)} 118 + > 119 + + Add a quote from the page 120 + </button> 121 + ) : ( 122 + <div className="flex flex-col gap-2"> 123 + <textarea 124 + value={quoteText} 125 + onChange={(e) => setQuoteText(e.target.value)} 126 + placeholder="Paste or type the text you're annotating..." 127 + className="w-full text-sm p-3 bg-surface-50 dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-lg text-surface-900 dark:text-white placeholder:text-surface-400 dark:placeholder:text-surface-500 focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500 dark:focus:border-primary-400 outline-none" 128 + rows={2} 129 + /> 130 + <div className="flex justify-end"> 131 + <button 132 + type="button" 133 + className="text-xs text-red-500 dark:text-red-400 font-medium" 134 + onClick={handleRemoveSelector} 135 + > 136 + Remove Quote 137 + </button> 138 + </div> 139 + </div> 140 + )} 141 + </> 142 + )} 143 + 144 + <textarea 145 + value={text} 146 + onChange={(e) => setText(e.target.value)} 147 + placeholder={ 148 + highlightedText || quoteText 149 + ? "Add your comment..." 150 + : "Write your annotation..." 151 + } 152 + className="w-full p-3 bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-lg text-surface-900 dark:text-white placeholder:text-surface-400 dark:placeholder:text-surface-500 focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500 dark:focus:border-primary-400 outline-none min-h-[100px] resize-none" 153 + maxLength={3000} 154 + disabled={loading} 155 + /> 156 + 157 + <input 158 + type="text" 159 + value={tags} 160 + onChange={(e) => setTags(e.target.value)} 161 + placeholder="Tags (comma separated)" 162 + className="w-full p-2.5 bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-lg text-surface-900 dark:text-white placeholder:text-surface-400 dark:placeholder:text-surface-500 focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500 dark:focus:border-primary-400 outline-none text-sm" 163 + disabled={loading} 164 + /> 165 + 166 + <div className="flex items-center justify-between pt-2"> 167 + <span 168 + className={ 169 + text.length > 2900 170 + ? "text-red-500 dark:text-red-400 text-xs font-medium" 171 + : "text-surface-400 dark:text-surface-500 text-xs" 172 + } 173 + > 174 + {text.length}/3000 175 + </span> 176 + <div className="flex items-center gap-2"> 177 + {onCancel && ( 178 + <button 179 + type="button" 180 + className="text-sm font-medium text-surface-500 dark:text-surface-400 hover:text-surface-800 dark:hover:text-surface-200 px-3 py-1.5" 181 + onClick={onCancel} 182 + disabled={loading} 183 + > 184 + Cancel 185 + </button> 186 + )} 187 + <button 188 + type="submit" 189 + className="bg-primary-600 hover:bg-primary-700 text-white font-medium px-4 py-1.5 rounded-lg transition-colors disabled:opacity-50 text-sm" 190 + disabled={ 191 + loading || (!text.trim() && !highlightedText && !quoteText.trim()) 192 + } 193 + > 194 + {loading ? "..." : "Post"} 195 + </button> 196 + </div> 197 + </div> 198 + 199 + {error && ( 200 + <div className="text-red-500 dark:text-red-400 text-sm text-center bg-red-50 dark:bg-red-900/20 py-2 rounded-lg"> 201 + {error} 202 + </div> 203 + )} 204 + </form> 205 + ); 206 + }
+111
web/src/components/feed/MasonryFeed.tsx
··· 1 + import React, { useEffect, useState } from "react"; 2 + import { getFeed } from "../../api/client"; 3 + import Card from "../common/Card"; 4 + import { Loader2 } from "lucide-react"; 5 + import { useStore } from "@nanostores/react"; 6 + import { $user, initAuth } from "../../store/auth"; 7 + import type { AnnotationItem } from "../../types"; 8 + import { Tabs, EmptyState } from "../ui"; 9 + 10 + interface MasonryFeedProps { 11 + motivation?: string; 12 + emptyMessage?: string; 13 + showTabs?: boolean; 14 + title?: string; 15 + } 16 + 17 + export default function MasonryFeed({ 18 + motivation, 19 + emptyMessage = "No items found.", 20 + showTabs = false, 21 + title, 22 + }: MasonryFeedProps) { 23 + const user = useStore($user); 24 + const [items, setItems] = useState<AnnotationItem[]>([]); 25 + const [loading, setLoading] = useState(true); 26 + const [activeTab, setActiveTab] = useState("my"); 27 + 28 + useEffect(() => { 29 + initAuth(); 30 + }, []); 31 + 32 + useEffect(() => { 33 + const fetchFeed = async () => { 34 + setLoading(true); 35 + try { 36 + const params: { type?: string; motivation?: string; creator?: string } = 37 + { 38 + motivation, 39 + }; 40 + 41 + if (activeTab === "my" && user?.did) { 42 + params.creator = user.did; 43 + params.type = "my-feed"; 44 + } else { 45 + params.type = "all"; 46 + } 47 + 48 + const data = await getFeed(params); 49 + setItems(data?.items || []); 50 + } catch (e) { 51 + console.error(e); 52 + } finally { 53 + setLoading(false); 54 + } 55 + }; 56 + fetchFeed(); 57 + }, [motivation, activeTab, user?.did]); 58 + 59 + const handleDelete = (uri: string) => { 60 + setItems((prev) => prev.filter((i) => i.uri !== uri)); 61 + }; 62 + 63 + const tabs = [ 64 + { id: "my", label: "My" }, 65 + { id: "global", label: "Global" }, 66 + ]; 67 + 68 + return ( 69 + <div className="max-w-2xl mx-auto animate-slide-up"> 70 + {title && ( 71 + <h1 className="text-3xl font-display font-bold text-surface-900 dark:text-white mb-6 text-center lg:text-left"> 72 + {title} 73 + </h1> 74 + )} 75 + 76 + {showTabs && ( 77 + <Tabs 78 + tabs={tabs} 79 + activeTab={activeTab} 80 + onChange={setActiveTab} 81 + className="mb-6" 82 + /> 83 + )} 84 + 85 + {loading ? ( 86 + <div className="flex justify-center py-20"> 87 + <Loader2 88 + className="animate-spin text-primary-600 dark:text-primary-400" 89 + size={32} 90 + /> 91 + </div> 92 + ) : items.length === 0 ? ( 93 + <EmptyState 94 + message={ 95 + activeTab === "my" 96 + ? emptyMessage 97 + : `No ${motivation === "bookmarking" ? "bookmarks" : "highlights"} from the community yet.` 98 + } 99 + /> 100 + ) : ( 101 + <div className="columns-1 xl:columns-2 gap-4 animate-fade-in"> 102 + {items.map((item) => ( 103 + <div key={item.uri || item.cid} className="break-inside-avoid mb-4"> 104 + <Card item={item} onDelete={handleDelete} /> 105 + </div> 106 + ))} 107 + </div> 108 + )} 109 + </div> 110 + ); 111 + }
+246
web/src/components/feed/ReplyList.tsx
··· 1 + import React from "react"; 2 + import { formatDistanceToNow } from "date-fns"; 3 + import { MessageSquare, Trash2, Reply } from "lucide-react"; 4 + import type { AnnotationItem, UserProfile } from "../../types"; 5 + import { getAvatarUrl } from "../../api/client"; 6 + import { clsx } from "clsx"; 7 + 8 + interface ReplyListProps { 9 + replies: AnnotationItem[]; 10 + rootUri: string; 11 + user: UserProfile | null; 12 + onReply: (reply: AnnotationItem) => void; 13 + onDelete: (reply: AnnotationItem) => void; 14 + isInline?: boolean; 15 + } 16 + 17 + interface ReplyItemProps { 18 + reply: AnnotationItem & { children?: AnnotationItem[] }; 19 + depth: number; 20 + user: UserProfile | null; 21 + onReply: (reply: AnnotationItem) => void; 22 + onDelete: (reply: AnnotationItem) => void; 23 + isInline: boolean; 24 + } 25 + 26 + const ReplyItem: React.FC<ReplyItemProps> = ({ 27 + reply, 28 + depth = 0, 29 + user, 30 + onReply, 31 + onDelete, 32 + isInline, 33 + }) => { 34 + const author = reply.author || reply.creator || {}; 35 + const isReplyOwner = user?.did && author.did === user.did; 36 + 37 + if (!author.handle && !author.did) return null; 38 + 39 + return ( 40 + <div key={reply.uri || reply.id}> 41 + <div 42 + className={clsx( 43 + "relative mb-2 transition-colors", 44 + isInline ? "flex gap-3" : "rounded-lg", 45 + depth > 0 && 46 + "ml-4 pl-3 border-l-2 border-surface-200 dark:border-surface-700", 47 + )} 48 + > 49 + {isInline ? ( 50 + <> 51 + <a href={`/profile/${author.handle}`} className="shrink-0"> 52 + {getAvatarUrl(author.did, author.avatar) ? ( 53 + <img 54 + src={getAvatarUrl(author.did, author.avatar)} 55 + alt="" 56 + className={clsx( 57 + "rounded-full object-cover bg-surface-200 dark:bg-surface-700", 58 + depth > 0 ? "w-6 h-6" : "w-7 h-7", 59 + )} 60 + /> 61 + ) : ( 62 + <div 63 + className={clsx( 64 + "rounded-full bg-surface-200 dark:bg-surface-700 flex items-center justify-center text-surface-500 dark:text-surface-400 font-bold", 65 + depth > 0 ? "w-6 h-6 text-[10px]" : "w-7 h-7 text-xs", 66 + )} 67 + > 68 + {(author.displayName || 69 + author.handle || 70 + "?")[0]?.toUpperCase()} 71 + </div> 72 + )} 73 + </a> 74 + <div className="flex-1 min-w-0"> 75 + <div className="flex items-baseline gap-2 mb-0.5 flex-wrap"> 76 + <span 77 + className={clsx( 78 + "font-medium text-surface-900 dark:text-white", 79 + depth > 0 ? "text-xs" : "text-sm", 80 + )} 81 + > 82 + {author.displayName || author.handle} 83 + </span> 84 + <span className="text-surface-400 dark:text-surface-500 text-xs"> 85 + {reply.createdAt 86 + ? formatDistanceToNow(new Date(reply.createdAt), { 87 + addSuffix: false, 88 + }) 89 + : ""} 90 + </span> 91 + 92 + <div className="ml-auto flex gap-2"> 93 + <button 94 + onClick={() => onReply(reply)} 95 + className="text-surface-400 dark:text-surface-500 hover:text-surface-600 dark:hover:text-surface-300 transition-colors flex items-center gap-1 text-[10px] uppercase font-medium" 96 + > 97 + <MessageSquare size={12} /> 98 + </button> 99 + {isReplyOwner && ( 100 + <button 101 + onClick={() => onDelete(reply)} 102 + className="text-surface-400 dark:text-surface-500 hover:text-red-500 dark:hover:text-red-400 transition-colors" 103 + > 104 + <Trash2 size={12} /> 105 + </button> 106 + )} 107 + </div> 108 + </div> 109 + <p 110 + className={clsx( 111 + "text-surface-800 dark:text-surface-200 whitespace-pre-wrap leading-relaxed", 112 + depth > 0 ? "text-sm" : "text-sm", 113 + )} 114 + > 115 + {reply.text || reply.body?.value} 116 + </p> 117 + </div> 118 + </> 119 + ) : ( 120 + <div className="p-3 bg-white dark:bg-surface-900 rounded-lg ring-1 ring-black/5 dark:ring-white/5"> 121 + <div className="flex items-center gap-2 mb-2"> 122 + <a href={`/profile/${author.handle}`} className="shrink-0"> 123 + {getAvatarUrl(author.did, author.avatar) ? ( 124 + <img 125 + src={getAvatarUrl(author.did, author.avatar)} 126 + alt="" 127 + className="w-7 h-7 rounded-full object-cover bg-surface-200 dark:bg-surface-700" 128 + /> 129 + ) : ( 130 + <div className="w-7 h-7 rounded-full bg-surface-200 dark:bg-surface-700 flex items-center justify-center text-surface-500 dark:text-surface-400 font-bold text-xs"> 131 + {(author.displayName || 132 + author.handle || 133 + "?")[0]?.toUpperCase()} 134 + </div> 135 + )} 136 + </a> 137 + <div className="flex flex-col"> 138 + <span className="font-medium text-surface-900 dark:text-white text-sm"> 139 + {author.displayName || author.handle} 140 + </span> 141 + </div> 142 + <span className="text-surface-400 dark:text-surface-500 text-xs ml-auto"> 143 + {reply.createdAt 144 + ? formatDistanceToNow(new Date(reply.createdAt), { 145 + addSuffix: false, 146 + }) 147 + : ""} 148 + </span> 149 + </div> 150 + <p className="text-surface-800 dark:text-surface-200 text-sm pl-9 mb-2 whitespace-pre-wrap"> 151 + {reply.text || reply.body?.value} 152 + </p> 153 + <div className="flex items-center justify-end gap-2 pl-9"> 154 + <button 155 + onClick={() => onReply(reply)} 156 + className="text-surface-400 dark:text-surface-500 hover:text-primary-600 dark:hover:text-primary-400 transition-colors p-1" 157 + > 158 + <Reply size={14} /> 159 + </button> 160 + {isReplyOwner && ( 161 + <button 162 + onClick={() => onDelete(reply)} 163 + className="text-surface-400 dark:text-surface-500 hover:text-red-500 dark:hover:text-red-400 transition-colors p-1" 164 + > 165 + <Trash2 size={14} /> 166 + </button> 167 + )} 168 + </div> 169 + </div> 170 + )} 171 + </div> 172 + {reply.children && reply.children.length > 0 && ( 173 + <div className="flex flex-col"> 174 + {reply.children.map((child) => ( 175 + <ReplyItem 176 + key={child.uri || child.id} 177 + reply={child} 178 + depth={depth + 1} 179 + user={user} 180 + onReply={onReply} 181 + onDelete={onDelete} 182 + isInline={isInline} 183 + /> 184 + ))} 185 + </div> 186 + )} 187 + </div> 188 + ); 189 + }; 190 + 191 + export default function ReplyList({ 192 + replies, 193 + rootUri, 194 + user, 195 + onReply, 196 + onDelete, 197 + isInline = false, 198 + }: ReplyListProps) { 199 + if (!replies || replies.length === 0) { 200 + return ( 201 + <div className="py-8 text-center"> 202 + <p className="text-surface-500 dark:text-surface-400 text-sm"> 203 + No replies yet 204 + </p> 205 + </div> 206 + ); 207 + } 208 + 209 + const buildReplyTree = () => { 210 + const replyMap: Record<string, any> = {}; 211 + const rootReplies: any[] = []; 212 + 213 + replies.forEach((r) => { 214 + replyMap[r.uri || r.id || ""] = { ...r, children: [] }; 215 + }); 216 + 217 + replies.forEach((r) => { 218 + const parentUri = (r as any).reply?.parent?.uri || (r as any).parentUri; 219 + if (parentUri === rootUri || !parentUri || !replyMap[parentUri]) { 220 + rootReplies.push(replyMap[r.uri || r.id || ""]); 221 + } else { 222 + replyMap[parentUri].children.push(replyMap[r.uri || r.id || ""]); 223 + } 224 + }); 225 + 226 + return rootReplies; 227 + }; 228 + 229 + const replyTree = buildReplyTree(); 230 + 231 + return ( 232 + <div className="flex flex-col gap-1"> 233 + {replyTree.map((reply) => ( 234 + <ReplyItem 235 + key={reply.uri || reply.id} 236 + reply={reply} 237 + depth={0} 238 + user={user} 239 + onReply={onReply} 240 + onDelete={onDelete} 241 + isInline={isInline} 242 + /> 243 + ))} 244 + </div> 245 + ); 246 + }
+334
web/src/components/modals/AddToCollectionModal.tsx
··· 1 + import React, { useState, useEffect, useCallback } from "react"; 2 + import { 3 + X, 4 + Plus, 5 + Check, 6 + Loader2, 7 + ChevronRight, 8 + FolderPlus, 9 + } from "lucide-react"; 10 + import CollectionIcon, { ICON_MAP } from "../common/CollectionIcon"; 11 + import { useStore } from "@nanostores/react"; 12 + import { $user } from "../../store/auth"; 13 + import { 14 + getCollections, 15 + addCollectionItem, 16 + createCollection, 17 + getCollectionsContaining, 18 + type Collection, 19 + } from "../../api/client"; 20 + 21 + interface AddToCollectionModalProps { 22 + isOpen: boolean; 23 + onClose: () => void; 24 + annotationUri: string; 25 + } 26 + 27 + export default function AddToCollectionModal({ 28 + isOpen, 29 + onClose, 30 + annotationUri, 31 + }: AddToCollectionModalProps) { 32 + const user = useStore($user); 33 + const [collections, setCollections] = useState<Collection[]>([]); 34 + const [loading, setLoading] = useState(true); 35 + const [addingTo, setAddingTo] = useState<string | null>(null); 36 + const [addedTo, setAddedTo] = useState<Set<string>>(new Set()); 37 + const [error, setError] = useState<string | null>(null); 38 + 39 + const [showNewForm, setShowNewForm] = useState(false); 40 + const [newName, setNewName] = useState(""); 41 + const [newDescription, setNewDescription] = useState(""); 42 + const [newIcon, setNewIcon] = useState(""); 43 + const [creating, setCreating] = useState(false); 44 + 45 + useEffect(() => { 46 + if (isOpen) { 47 + document.body.style.overflow = "hidden"; 48 + } 49 + return () => { 50 + document.body.style.overflow = "unset"; 51 + }; 52 + }, [isOpen]); 53 + 54 + const loadCollections = useCallback(async () => { 55 + if (!user) return; 56 + try { 57 + setLoading(true); 58 + const data = await getCollections(user.did); 59 + setCollections(data); 60 + } catch (err) { 61 + console.error(err); 62 + setError("Failed to load collections"); 63 + } finally { 64 + setLoading(false); 65 + } 66 + }, [user]); 67 + 68 + useEffect(() => { 69 + if (isOpen && user) { 70 + loadCollections(); 71 + setError(null); 72 + getCollectionsContaining(annotationUri).then((uris) => { 73 + setAddedTo(new Set(uris)); 74 + }); 75 + } 76 + }, [isOpen, user, loadCollections, annotationUri]); 77 + 78 + const handleAdd = async (collectionUri: string) => { 79 + if (addedTo.has(collectionUri)) return; 80 + 81 + try { 82 + setAddingTo(collectionUri); 83 + await addCollectionItem(collectionUri, annotationUri); 84 + setAddedTo((prev) => new Set([...prev, collectionUri])); 85 + } catch (err) { 86 + console.error(err); 87 + setError("Failed to add to collection"); 88 + } finally { 89 + setAddingTo(null); 90 + } 91 + }; 92 + 93 + const handleCreate = async (e: React.FormEvent) => { 94 + e.preventDefault(); 95 + if (!newName.trim()) return; 96 + try { 97 + setCreating(true); 98 + const iconValue = newIcon ? `icon:${newIcon}` : undefined; 99 + const newCollection = await createCollection( 100 + newName.trim(), 101 + newDescription.trim() || undefined, 102 + iconValue, 103 + ); 104 + if (newCollection) { 105 + setCollections((prev) => [newCollection, ...prev]); 106 + setNewName(""); 107 + setNewDescription(""); 108 + setNewIcon(""); 109 + setShowNewForm(false); 110 + } 111 + } catch (err) { 112 + console.error(err); 113 + setError("Failed to create collection"); 114 + } finally { 115 + setCreating(false); 116 + } 117 + }; 118 + 119 + if (!isOpen) return null; 120 + 121 + return ( 122 + <div 123 + className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-fade-in" 124 + onClick={onClose} 125 + > 126 + <div 127 + className="w-full max-w-md bg-white dark:bg-surface-900 rounded-3xl shadow-2xl overflow-hidden" 128 + onClick={(e) => e.stopPropagation()} 129 + > 130 + <div className="p-4 flex justify-between items-center border-b border-surface-100 dark:border-surface-800"> 131 + <h2 className="text-xl font-display font-bold text-surface-900 dark:text-white"> 132 + Add to Collection 133 + </h2> 134 + <button 135 + onClick={onClose} 136 + className="p-2 text-surface-400 hover:text-surface-900 dark:hover:text-white hover:bg-surface-50 dark:hover:bg-surface-800 rounded-full transition-colors" 137 + > 138 + <X size={20} /> 139 + </button> 140 + </div> 141 + 142 + <div className="px-6 pb-6 pt-4"> 143 + {loading ? ( 144 + <div className="text-center py-10"> 145 + <Loader2 146 + size={32} 147 + className="animate-spin text-primary-600 dark:text-primary-400 mx-auto mb-3" 148 + /> 149 + <p className="text-surface-500 dark:text-surface-400 font-medium"> 150 + Loading collections... 151 + </p> 152 + </div> 153 + ) : showNewForm ? ( 154 + <form onSubmit={handleCreate} className="space-y-4"> 155 + <div> 156 + <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1"> 157 + Collection name 158 + </label> 159 + <input 160 + type="text" 161 + className="w-full px-4 py-3 bg-surface-50 dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-xl focus:border-primary-500 dark:focus:border-primary-400 focus:ring-4 focus:ring-primary-500/10 outline-none transition-all text-surface-900 dark:text-white placeholder-surface-400 dark:placeholder-surface-500" 162 + value={newName} 163 + onChange={(e) => setNewName(e.target.value)} 164 + placeholder="My Collection" 165 + autoFocus 166 + /> 167 + </div> 168 + 169 + <div> 170 + <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1"> 171 + Description (optional) 172 + </label> 173 + <textarea 174 + className="w-full px-4 py-3 bg-surface-50 dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-xl focus:border-primary-500 dark:focus:border-primary-400 focus:ring-4 focus:ring-primary-500/10 outline-none transition-all text-surface-900 dark:text-white placeholder-surface-400 dark:placeholder-surface-500 resize-none" 175 + value={newDescription} 176 + onChange={(e) => setNewDescription(e.target.value)} 177 + placeholder="What's this collection about?" 178 + rows={2} 179 + /> 180 + </div> 181 + 182 + <div> 183 + <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2"> 184 + Icon 185 + </label> 186 + <div className="grid grid-cols-8 gap-1.5 max-h-32 overflow-y-auto p-2 bg-surface-50 dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700"> 187 + {Object.keys(ICON_MAP).map((iconName) => { 188 + const isSelected = newIcon === iconName; 189 + return ( 190 + <button 191 + key={iconName} 192 + type="button" 193 + onClick={() => setNewIcon(isSelected ? "" : iconName)} 194 + className={`w-8 h-8 flex items-center justify-center rounded-lg transition-all ${ 195 + isSelected 196 + ? "bg-primary-600 text-white" 197 + : "hover:bg-surface-200 dark:hover:bg-surface-700 text-surface-600 dark:text-surface-400" 198 + }`} 199 + title={iconName} 200 + > 201 + <CollectionIcon icon={`icon:${iconName}`} size={16} /> 202 + </button> 203 + ); 204 + })} 205 + </div> 206 + {newIcon && ( 207 + <p className="mt-1 text-xs text-surface-500"> 208 + Selected: {newIcon} 209 + </p> 210 + )} 211 + </div> 212 + 213 + {error && ( 214 + <div className="p-3 bg-red-50 dark:bg-red-900/30 text-red-600 dark:text-red-400 text-sm rounded-lg"> 215 + {error} 216 + </div> 217 + )} 218 + 219 + <div className="flex gap-3 pt-2"> 220 + <button 221 + type="button" 222 + className="flex-1 py-3 bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 text-surface-700 dark:text-surface-200 font-semibold rounded-xl hover:bg-surface-50 dark:hover:bg-surface-700 transition-colors" 223 + onClick={() => { 224 + setShowNewForm(false); 225 + setNewDescription(""); 226 + setNewIcon(""); 227 + setError(null); 228 + }} 229 + > 230 + Back 231 + </button> 232 + <button 233 + type="submit" 234 + className="flex-1 py-3 bg-primary-600 text-white font-semibold rounded-xl hover:bg-primary-700 transition-colors disabled:opacity-50 flex items-center justify-center gap-2" 235 + disabled={!newName.trim() || creating} 236 + > 237 + {creating && <Loader2 size={16} className="animate-spin" />} 238 + {creating ? "Creating..." : "Create"} 239 + </button> 240 + </div> 241 + </form> 242 + ) : ( 243 + <div> 244 + {error && ( 245 + <div className="mb-4 p-3 bg-red-50 dark:bg-red-900/30 text-red-600 dark:text-red-400 text-sm rounded-lg"> 246 + {error} 247 + </div> 248 + )} 249 + 250 + <button 251 + className="w-full flex items-center gap-4 p-4 bg-white dark:bg-surface-800 border-2 border-primary-100 dark:border-primary-900/50 hover:border-primary-300 dark:hover:border-primary-700 rounded-2xl shadow-sm hover:shadow-md transition-all group text-left mb-4" 252 + onClick={() => setShowNewForm(true)} 253 + > 254 + <div className="w-10 h-10 bg-primary-50 dark:bg-primary-900/30 rounded-full flex items-center justify-center text-primary-600 dark:text-primary-400 flex-shrink-0"> 255 + <FolderPlus size={20} /> 256 + </div> 257 + <div className="flex-1 min-w-0"> 258 + <h3 className="font-bold text-surface-900 dark:text-white group-hover:text-primary-700 dark:group-hover:text-primary-400 transition-colors"> 259 + New Collection 260 + </h3> 261 + <span className="text-sm text-surface-500 dark:text-surface-400"> 262 + Create a new collection 263 + </span> 264 + </div> 265 + <ChevronRight 266 + size={20} 267 + className="text-surface-300 dark:text-surface-600 group-hover:text-primary-500 dark:group-hover:text-primary-400" 268 + /> 269 + </button> 270 + 271 + {collections.length === 0 ? ( 272 + <div className="text-center py-6"> 273 + <p className="text-surface-500 dark:text-surface-400"> 274 + No collections yet 275 + </p> 276 + </div> 277 + ) : ( 278 + <div className="space-y-2 max-h-[300px] overflow-y-auto"> 279 + {collections.map((col) => { 280 + const isAdded = addedTo.has(col.uri); 281 + const isAdding = addingTo === col.uri; 282 + 283 + return ( 284 + <button 285 + key={col.uri} 286 + onClick={() => handleAdd(col.uri)} 287 + disabled={isAdding || isAdded} 288 + className="w-full flex items-center gap-3 p-3 bg-surface-50 dark:bg-surface-800/50 hover:bg-surface-100 dark:hover:bg-surface-800 rounded-xl transition-colors text-left group disabled:opacity-70" 289 + > 290 + <div className="w-8 h-8 flex items-center justify-center bg-white dark:bg-surface-700 rounded-full shadow-sm text-surface-600 dark:text-surface-300"> 291 + <CollectionIcon icon={col.icon} size={18} /> 292 + </div> 293 + <div className="flex-1 min-w-0"> 294 + <h3 className="text-sm font-bold text-surface-900 dark:text-white"> 295 + {col.name} 296 + </h3> 297 + {col.description && ( 298 + <p className="text-xs text-surface-500 dark:text-surface-400 line-clamp-1"> 299 + {col.description} 300 + </p> 301 + )} 302 + </div> 303 + {isAdding ? ( 304 + <Loader2 305 + size={16} 306 + className="animate-spin text-surface-400" 307 + /> 308 + ) : isAdded ? ( 309 + <Check size={16} className="text-green-500" /> 310 + ) : ( 311 + <Plus 312 + size={16} 313 + className="text-surface-300 dark:text-surface-500 group-hover:text-surface-600 dark:group-hover:text-surface-300" 314 + /> 315 + )} 316 + </button> 317 + ); 318 + })} 319 + </div> 320 + )} 321 + 322 + <button 323 + onClick={onClose} 324 + className="w-full mt-4 py-3 bg-surface-900 dark:bg-white text-white dark:text-surface-900 font-semibold rounded-xl hover:bg-surface-800 dark:hover:bg-surface-100 transition-colors" 325 + > 326 + Done 327 + </button> 328 + </div> 329 + )} 330 + </div> 331 + </div> 332 + </div> 333 + ); 334 + }
+281
web/src/components/modals/EditProfileModal.tsx
··· 1 + import React, { useState, useRef } from "react"; 2 + import { updateProfile, uploadAvatar, getAvatarUrl } from "../../api/client"; 3 + import type { UserProfile } from "../../types"; 4 + import { Loader2, X, Plus, User as UserIcon } from "lucide-react"; 5 + 6 + interface EditProfileModalProps { 7 + profile: UserProfile; 8 + onClose: () => void; 9 + onUpdate: (updatedProfile: UserProfile) => void; 10 + } 11 + 12 + export default function EditProfileModal({ 13 + profile, 14 + onClose, 15 + onUpdate, 16 + }: EditProfileModalProps) { 17 + const [displayName, setDisplayName] = useState(profile.displayName || ""); 18 + const [description, setDescription] = useState(profile.description || ""); 19 + const [website, setWebsite] = useState(profile.website || ""); 20 + const [links, setLinks] = useState<string[]>(profile.links || []); 21 + const [newLink, setNewLink] = useState(""); 22 + 23 + const [avatarBlob, setAvatarBlob] = useState<Blob | null>(null); 24 + const [avatarPreview, setAvatarPreview] = useState<string | null>(null); 25 + const [uploading, setUploading] = useState(false); 26 + 27 + const [saving, setSaving] = useState(false); 28 + const [error, setError] = useState<string | null>(null); 29 + const fileInputRef = useRef<HTMLInputElement>(null); 30 + 31 + const handleAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => { 32 + const file = e.target.files?.[0]; 33 + if (!file) return; 34 + 35 + if (!["image/jpeg", "image/png"].includes(file.type)) { 36 + setError("Please select a JPEG or PNG image"); 37 + return; 38 + } 39 + 40 + if (file.size > 1024 * 1024 * 2) { 41 + setError("Image must be under 2MB"); 42 + return; 43 + } 44 + 45 + setAvatarPreview(URL.createObjectURL(file)); 46 + setAvatarBlob(file); 47 + 48 + setUploading(true); 49 + try { 50 + const result = await uploadAvatar(file); 51 + setAvatarBlob(result.blob); 52 + } catch (err: any) { 53 + setError("Failed to upload: " + err.message); 54 + setAvatarPreview(null); 55 + } finally { 56 + setUploading(false); 57 + } 58 + }; 59 + 60 + const handleAddLink = () => { 61 + if (!newLink) return; 62 + if (!links.includes(newLink)) { 63 + setLinks([...links, newLink]); 64 + setNewLink(""); 65 + } 66 + }; 67 + 68 + const handleRemoveLink = (index: number) => { 69 + setLinks(links.filter((_, i) => i !== index)); 70 + }; 71 + 72 + const handleSubmit = async (e: React.FormEvent) => { 73 + e.preventDefault(); 74 + setSaving(true); 75 + setError(null); 76 + 77 + try { 78 + await updateProfile({ 79 + displayName, 80 + description, 81 + website, 82 + links, 83 + avatar: avatarBlob, 84 + }); 85 + onUpdate({ 86 + ...profile, 87 + displayName, 88 + description, 89 + website, 90 + links, 91 + avatar: avatarPreview || profile.avatar, 92 + }); 93 + onClose(); 94 + } catch (err: any) { 95 + setError(err.message); 96 + } finally { 97 + setSaving(false); 98 + } 99 + }; 100 + 101 + const currentAvatar = 102 + avatarPreview || getAvatarUrl(profile.did, profile.avatar); 103 + 104 + return ( 105 + <div 106 + className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4" 107 + onClick={onClose} 108 + > 109 + <div 110 + className="bg-white dark:bg-surface-900 rounded-xl w-full max-w-md overflow-hidden shadow-2xl ring-1 ring-black/5 dark:ring-white/10" 111 + onClick={(e) => e.stopPropagation()} 112 + > 113 + <div className="flex items-center justify-between p-4 border-b border-surface-100 dark:border-surface-800"> 114 + <h2 className="text-lg font-bold text-surface-900 dark:text-white"> 115 + Edit Profile 116 + </h2> 117 + <button 118 + onClick={onClose} 119 + className="p-1.5 hover:bg-surface-100 dark:hover:bg-surface-800 rounded-lg transition-colors text-surface-500 dark:text-surface-400" 120 + > 121 + <X size={18} /> 122 + </button> 123 + </div> 124 + 125 + <form 126 + onSubmit={handleSubmit} 127 + className="p-5 overflow-y-auto max-h-[80vh]" 128 + > 129 + {error && ( 130 + <div className="mb-4 p-3 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-lg text-sm border border-red-100 dark:border-red-800"> 131 + {error} 132 + </div> 133 + )} 134 + 135 + <div className="mb-5"> 136 + <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2"> 137 + Avatar 138 + </label> 139 + <div className="flex items-center gap-3"> 140 + <div 141 + className="relative w-16 h-16 rounded-full bg-surface-100 dark:bg-surface-800 overflow-hidden cursor-pointer group border border-surface-200 dark:border-surface-700" 142 + onClick={() => fileInputRef.current?.click()} 143 + > 144 + {currentAvatar ? ( 145 + <img 146 + src={currentAvatar} 147 + alt="" 148 + className="w-full h-full object-cover" 149 + /> 150 + ) : ( 151 + <div className="w-full h-full flex items-center justify-center text-surface-400 dark:text-surface-500"> 152 + <UserIcon size={24} /> 153 + </div> 154 + )} 155 + <div className="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"> 156 + <span className="text-white text-xs font-medium">Edit</span> 157 + </div> 158 + </div> 159 + <input 160 + ref={fileInputRef} 161 + type="file" 162 + accept="image/jpeg,image/png" 163 + onChange={handleAvatarChange} 164 + className="hidden" 165 + /> 166 + <button 167 + type="button" 168 + onClick={() => fileInputRef.current?.click()} 169 + className="px-3 py-1.5 rounded-lg bg-surface-100 dark:bg-surface-800 hover:bg-surface-200 dark:hover:bg-surface-700 text-surface-900 dark:text-white font-medium text-sm transition-colors" 170 + disabled={uploading} 171 + > 172 + {uploading ? "Uploading..." : "Upload"} 173 + </button> 174 + </div> 175 + </div> 176 + 177 + <div className="mb-4"> 178 + <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1"> 179 + Display Name 180 + </label> 181 + <input 182 + type="text" 183 + value={displayName} 184 + onChange={(e) => setDisplayName(e.target.value)} 185 + className="w-full px-3 py-2 rounded-lg bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 text-surface-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500 dark:focus:border-primary-400" 186 + maxLength={64} 187 + /> 188 + </div> 189 + 190 + <div className="mb-4"> 191 + <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1"> 192 + Bio 193 + </label> 194 + <textarea 195 + value={description} 196 + onChange={(e) => setDescription(e.target.value)} 197 + className="w-full px-3 py-2 rounded-lg bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 text-surface-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500 dark:focus:border-primary-400 min-h-[80px] resize-none" 198 + maxLength={300} 199 + /> 200 + </div> 201 + 202 + <div className="mb-4"> 203 + <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1"> 204 + Website 205 + </label> 206 + <input 207 + type="url" 208 + value={website} 209 + onChange={(e) => setWebsite(e.target.value)} 210 + placeholder="https://example.com" 211 + className="w-full px-3 py-2 rounded-lg bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 text-surface-900 dark:text-white placeholder:text-surface-400 dark:placeholder:text-surface-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500 dark:focus:border-primary-400 text-sm" 212 + /> 213 + </div> 214 + 215 + <div className="mb-5"> 216 + <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1"> 217 + Links 218 + </label> 219 + <div className="space-y-2"> 220 + {links.map((link, i) => ( 221 + <div key={i} className="flex items-center gap-2"> 222 + <input 223 + type="text" 224 + value={link} 225 + readOnly 226 + className="flex-1 px-3 py-2 rounded-lg bg-surface-50 dark:bg-surface-800 border border-surface-200 dark:border-surface-700 text-sm text-surface-600 dark:text-surface-300" 227 + /> 228 + <button 229 + type="button" 230 + onClick={() => handleRemoveLink(i)} 231 + className="p-2 text-surface-400 dark:text-surface-500 hover:text-red-500 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg" 232 + > 233 + <X size={14} /> 234 + </button> 235 + </div> 236 + ))} 237 + <div className="flex items-center gap-2"> 238 + <input 239 + type="url" 240 + value={newLink} 241 + onChange={(e) => setNewLink(e.target.value)} 242 + placeholder="Add a link..." 243 + className="flex-1 px-3 py-2 rounded-lg bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 text-surface-900 dark:text-white placeholder:text-surface-400 dark:placeholder:text-surface-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500 dark:focus:border-primary-400 text-sm" 244 + onKeyDown={(e) => 245 + e.key === "Enter" && (e.preventDefault(), handleAddLink()) 246 + } 247 + /> 248 + <button 249 + type="button" 250 + onClick={handleAddLink} 251 + className="p-2 bg-surface-900 dark:bg-surface-700 text-white rounded-lg hover:bg-surface-800 dark:hover:bg-surface-600" 252 + > 253 + <Plus size={18} /> 254 + </button> 255 + </div> 256 + </div> 257 + </div> 258 + 259 + <div className="flex items-center justify-end gap-2 pt-4 border-t border-surface-100 dark:border-surface-800"> 260 + <button 261 + type="button" 262 + onClick={onClose} 263 + className="px-4 py-2 rounded-lg text-surface-600 dark:text-surface-300 font-medium hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors" 264 + disabled={saving} 265 + > 266 + Cancel 267 + </button> 268 + <button 269 + type="submit" 270 + className="px-4 py-2 rounded-lg bg-primary-600 text-white font-medium hover:bg-primary-700 transition-colors flex items-center gap-2" 271 + disabled={saving} 272 + > 273 + {saving && <Loader2 size={14} className="animate-spin" />} 274 + {saving ? "Saving..." : "Save"} 275 + </button> 276 + </div> 277 + </form> 278 + </div> 279 + </div> 280 + ); 281 + }
+102
web/src/components/modals/ExternalLinkModal.tsx
··· 1 + import React, { useState } from "react"; 2 + import { Button } from "../ui"; 3 + import { ExternalLink, AlertTriangle, X } from "lucide-react"; 4 + import { addSkippedHostname } from "../../store/preferences"; 5 + 6 + interface ExternalLinkModalProps { 7 + isOpen: boolean; 8 + onClose: () => void; 9 + url: string | null; 10 + } 11 + 12 + export default function ExternalLinkModal({ 13 + isOpen, 14 + onClose, 15 + url, 16 + }: ExternalLinkModalProps) { 17 + const [dontAskAgain, setDontAskAgain] = useState(false); 18 + 19 + if (!isOpen || !url) return null; 20 + 21 + const displayUrl = url.split("#:~:text=")[0]; 22 + 23 + const handleContinue = () => { 24 + if (dontAskAgain && url) { 25 + try { 26 + const hostname = new URL(url).hostname; 27 + addSkippedHostname(hostname); 28 + } catch (e) { 29 + console.error("Invalid URL", e); 30 + } 31 + } 32 + window.open(url, "_blank", "noopener,noreferrer"); 33 + onClose(); 34 + }; 35 + 36 + const hostname = (() => { 37 + try { 38 + return new URL(url).hostname; 39 + } catch { 40 + return "this site"; 41 + } 42 + })(); 43 + 44 + return ( 45 + <div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-fade-in"> 46 + <div className="bg-white dark:bg-surface-900 rounded-2xl shadow-2xl max-w-sm w-full animate-scale-in ring-1 ring-black/5 dark:ring-white/10 p-6"> 47 + <div className="flex flex-col items-center text-center"> 48 + <div className="w-12 h-12 bg-yellow-100 dark:bg-yellow-900/30 text-yellow-600 dark:text-yellow-400 rounded-full flex items-center justify-center mb-4"> 49 + <AlertTriangle size={24} /> 50 + </div> 51 + 52 + <h2 className="text-xl font-bold text-surface-900 dark:text-white mb-2"> 53 + You are leaving Margin 54 + </h2> 55 + 56 + <p className="text-surface-500 dark:text-surface-400 text-sm mb-6 leading-relaxed"> 57 + This link will take you to an external website: 58 + <br /> 59 + <span className="font-medium text-sm bg-surface-100 dark:bg-surface-800 text-surface-900 dark:text-surface-100 p-3 rounded-xl mt-3 block break-all border border-surface-200 dark:border-surface-700 shadow-sm"> 60 + {displayUrl} 61 + </span> 62 + </p> 63 + 64 + <div className="flex items-center gap-2 mb-6 w-full px-1"> 65 + <input 66 + type="checkbox" 67 + id="dontAskAgain" 68 + checked={dontAskAgain} 69 + onChange={(e) => setDontAskAgain(e.target.checked)} 70 + className="rounded border-surface-300 text-primary-600 focus:ring-primary-500 w-4 h-4 cursor-pointer" 71 + /> 72 + <label 73 + htmlFor="dontAskAgain" 74 + className="text-sm text-surface-600 dark:text-surface-300 cursor-pointer select-none" 75 + > 76 + Don't ask again for{" "} 77 + <span className="font-medium">{hostname}</span> 78 + </label> 79 + </div> 80 + 81 + <div className="flex flex-col gap-3 w-full"> 82 + <Button 83 + onClick={handleContinue} 84 + variant="primary" 85 + className="w-full justify-center" 86 + icon={<ExternalLink size={16} />} 87 + > 88 + Continue to Site 89 + </Button> 90 + <Button 91 + onClick={onClose} 92 + variant="ghost" 93 + className="w-full justify-center" 94 + > 95 + Go Back 96 + </Button> 97 + </div> 98 + </div> 99 + </div> 100 + </div> 101 + ); 102 + }
+303
web/src/components/modals/ShareMenu.tsx
··· 1 + import React, { useState, useRef, useEffect } from "react"; 2 + import { 3 + Copy, 4 + ExternalLink, 5 + Check, 6 + Share2, 7 + MoreHorizontal, 8 + } from "lucide-react"; 9 + import { 10 + AturiIcon, 11 + BlueskyIcon, 12 + BlackskyIcon, 13 + WitchskyIcon, 14 + CatskyIcon, 15 + DeerIcon, 16 + } from "../common/Icons"; 17 + 18 + const SembleLogo = () => ( 19 + <img src="/semble-logo.svg" alt="Semble" className="w-4 h-4 opacity-90" /> 20 + ); 21 + 22 + const BLUESKY_COLOR = "#1185fe"; 23 + 24 + interface ShareOption { 25 + name: string; 26 + icon: React.ReactNode; 27 + action: () => void; 28 + highlight?: boolean; 29 + } 30 + 31 + interface ShareMenuProps { 32 + uri: string; 33 + text?: string; 34 + customUrl?: string; 35 + handle?: string; 36 + type?: string; 37 + url?: string; 38 + } 39 + 40 + export default function ShareMenu({ 41 + uri, 42 + text, 43 + customUrl, 44 + handle, 45 + type, 46 + url, 47 + }: ShareMenuProps) { 48 + const [isOpen, setIsOpen] = useState(false); 49 + const [copied, setCopied] = useState<string | null>(null); 50 + const menuRef = useRef<HTMLDivElement>(null); 51 + const buttonRef = useRef<HTMLButtonElement>(null); 52 + const [menuPosition, setMenuPosition] = useState({ 53 + top: 0, 54 + left: 0, 55 + alignRight: false, 56 + }); 57 + 58 + const getShareUrl = () => { 59 + if (customUrl) return customUrl; 60 + if (!uri) return ""; 61 + 62 + const uriParts = (uri || "").split("/"); 63 + const rkey = uriParts[uriParts.length - 1]; 64 + const did = uriParts[2]; 65 + 66 + if (uri.includes("network.cosmik.card")) 67 + return `${window.location.origin}/at/${did}/${rkey}`; 68 + if (handle && type) 69 + return `${window.location.origin}/${handle}/${type.toLowerCase()}/${rkey}`; 70 + return `${window.location.origin}/at/${did}/${rkey}`; 71 + }; 72 + 73 + const shareUrl = getShareUrl(); 74 + const isSemble = uri && uri.includes("network.cosmik"); 75 + 76 + const sembleUrl = (() => { 77 + if (!isSemble) return ""; 78 + const parts = (uri || "").split("/"); 79 + const rkey = parts[parts.length - 1]; 80 + const userHandle = handle || (parts.length > 2 ? parts[2] : ""); 81 + 82 + if (uri.includes("network.cosmik.collection")) 83 + return `https://semble.so/profile/${userHandle}/collections/${rkey}`; 84 + if (uri.includes("network.cosmik.card") && url) 85 + return `https://semble.so/url?id=${encodeURIComponent(url)}`; 86 + return `https://semble.so/profile/${userHandle}`; 87 + })(); 88 + 89 + const handleCopy = async (textToCopy: string, key: string) => { 90 + try { 91 + await navigator.clipboard.writeText(textToCopy); 92 + setCopied(key); 93 + setTimeout(() => { 94 + setCopied(null); 95 + setIsOpen(false); 96 + }, 1000); 97 + } catch { 98 + prompt("Copy this link:", textToCopy); 99 + } 100 + }; 101 + 102 + const handleShareToFork = (domain: string) => { 103 + const composeText = text 104 + ? `${text.substring(0, 200)}...\n\n${shareUrl}` 105 + : shareUrl; 106 + const composeUrl = `https://${domain}/intent/compose?text=${encodeURIComponent(composeText)}`; 107 + window.open(composeUrl, "_blank"); 108 + setIsOpen(false); 109 + }; 110 + 111 + useEffect(() => { 112 + const handleClickOutside = (e: MouseEvent) => { 113 + if ( 114 + menuRef.current && 115 + !menuRef.current.contains(e.target as Node) && 116 + !buttonRef.current?.contains(e.target as Node) 117 + ) { 118 + setIsOpen(false); 119 + } 120 + }; 121 + if (isOpen) { 122 + document.addEventListener("mousedown", handleClickOutside); 123 + window.addEventListener("scroll", () => setIsOpen(false), true); 124 + window.addEventListener("resize", () => setIsOpen(false)); 125 + } 126 + return () => { 127 + document.removeEventListener("mousedown", handleClickOutside); 128 + window.removeEventListener("scroll", () => setIsOpen(false), true); 129 + window.removeEventListener("resize", () => setIsOpen(false)); 130 + }; 131 + }, [isOpen]); 132 + 133 + const calculatePosition = () => { 134 + if (!buttonRef.current) return; 135 + const rect = buttonRef.current.getBoundingClientRect(); 136 + const menuWidth = 240; 137 + 138 + let top = rect.bottom + 8; 139 + let left = rect.left; 140 + let alignRight = false; 141 + 142 + if (left + menuWidth > window.innerWidth - 16) { 143 + left = rect.right - menuWidth; 144 + alignRight = true; 145 + } 146 + 147 + if (top + 300 > window.innerHeight) { 148 + top = rect.top - 8; 149 + } 150 + 151 + setMenuPosition({ top, left, alignRight }); 152 + }; 153 + 154 + const toggleMenu = () => { 155 + if (!isOpen) calculatePosition(); 156 + setIsOpen(!isOpen); 157 + }; 158 + 159 + const renderMenuItem = ( 160 + label: string, 161 + icon: React.ReactNode, 162 + onClick: () => void, 163 + isCopied: boolean = false, 164 + highlight: boolean = false, 165 + ) => ( 166 + <button 167 + onClick={onClick} 168 + className={`w-full flex items-center gap-3 px-3 py-2.5 text-[14px] font-medium transition-colors rounded-lg group 169 + ${ 170 + highlight 171 + ? "text-primary-700 dark:text-primary-400 bg-primary-50/50 dark:bg-primary-900/20 hover:bg-primary-50 dark:hover:bg-primary-900/30" 172 + : "text-surface-700 dark:text-surface-200 hover:bg-surface-50 dark:hover:bg-surface-800 hover:text-surface-900 dark:hover:text-white" 173 + }`} 174 + > 175 + <span 176 + className={`flex items-center justify-center w-5 h-5 ${highlight ? "text-primary-600 dark:text-primary-400" : "text-surface-400 dark:text-surface-500 group-hover:text-surface-600 dark:group-hover:text-surface-300"}`} 177 + > 178 + {isCopied ? ( 179 + <Check size={16} className="text-green-600 dark:text-green-400" /> 180 + ) : ( 181 + icon 182 + )} 183 + </span> 184 + <span className="flex-1 text-left">{isCopied ? "Copied!" : label}</span> 185 + </button> 186 + ); 187 + 188 + const shareForks = [ 189 + { 190 + name: "Bluesky", 191 + domain: "bsky.app", 192 + icon: <BlueskyIcon size={18} color={BLUESKY_COLOR} />, 193 + }, 194 + { 195 + name: "Witchsky", 196 + domain: "witchsky.app", 197 + icon: <WitchskyIcon size={18} />, 198 + }, 199 + { 200 + name: "Blacksky", 201 + domain: "blacksky.community", 202 + icon: <BlackskyIcon size={18} />, 203 + }, 204 + { name: "Catsky", domain: "catsky.social", icon: <CatskyIcon size={18} /> }, 205 + { name: "Deer", domain: "deer.social", icon: <DeerIcon size={18} /> }, 206 + ]; 207 + 208 + return ( 209 + <div className="relative inline-block"> 210 + <button 211 + ref={buttonRef} 212 + onClick={toggleMenu} 213 + className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg transition-all ${isOpen ? "text-primary-600 dark:text-primary-400 bg-primary-50 dark:bg-primary-900/20" : "text-surface-400 dark:text-surface-500 hover:text-primary-600 dark:hover:text-primary-400 hover:bg-primary-50 dark:hover:bg-primary-900/20"}`} 214 + title="Share" 215 + > 216 + <Share2 size={16} /> 217 + </button> 218 + 219 + {isOpen && ( 220 + <div 221 + ref={menuRef} 222 + className="fixed z-[1000] w-[260px] bg-white dark:bg-surface-900 rounded-xl shadow-xl ring-1 ring-black/5 dark:ring-white/5 p-1.5 animate-in fade-in zoom-in-95 duration-150 origin-top-left" 223 + style={{ 224 + top: menuPosition.top, 225 + left: menuPosition.left, 226 + transformOrigin: menuPosition.alignRight ? "top right" : "top left", 227 + }} 228 + > 229 + <div className="flex flex-col gap-0.5"> 230 + {isSemble ? ( 231 + <> 232 + <div className="px-3 py-2 text-[11px] font-bold text-surface-400 dark:text-surface-500 uppercase tracking-wider flex items-center gap-1.5 select-none"> 233 + <SembleLogo /> 234 + Semble Integration 235 + </div> 236 + {renderMenuItem( 237 + "Open on Semble", 238 + <ExternalLink size={16} />, 239 + () => window.open(sembleUrl, "_blank"), 240 + false, 241 + true, 242 + )} 243 + {renderMenuItem( 244 + "Copy Semble Link", 245 + <Copy size={16} />, 246 + () => handleCopy(sembleUrl, "semble"), 247 + copied === "semble", 248 + )} 249 + <div className="h-px bg-surface-100 dark:bg-surface-800 my-1 mx-2" /> 250 + </> 251 + ) : null} 252 + 253 + {renderMenuItem( 254 + "Copy Link", 255 + <Copy size={16} />, 256 + () => handleCopy(shareUrl, "link"), 257 + copied === "link", 258 + )} 259 + 260 + <div className="px-3 pt-3 pb-1 text-[11px] font-bold text-surface-400 dark:text-surface-500 uppercase tracking-wider select-none"> 261 + Share via App 262 + </div> 263 + 264 + <div className="grid grid-cols-5 gap-1 px-1 mb-1"> 265 + {shareForks.map((fork) => ( 266 + <button 267 + key={fork.domain} 268 + onClick={() => handleShareToFork(fork.domain)} 269 + className="flex items-center justify-center p-2 rounded-lg hover:bg-surface-50 dark:hover:bg-surface-800 hover:scale-105 transition-all text-surface-400 dark:text-surface-500 hover:text-surface-900 dark:hover:text-white" 270 + title={`Share to ${fork.name}`} 271 + > 272 + {fork.icon} 273 + </button> 274 + ))} 275 + </div> 276 + 277 + <div className="h-px bg-surface-100 dark:bg-surface-800 my-1 mx-2" /> 278 + 279 + {renderMenuItem( 280 + "Copy Universal Link", 281 + <AturiIcon size={16} />, 282 + () => 283 + handleCopy(uri.replace("at://", "https://aturi.to/"), "aturi"), 284 + copied === "aturi", 285 + )} 286 + 287 + {navigator.share && 288 + renderMenuItem( 289 + "More Options...", 290 + <MoreHorizontal size={16} />, 291 + () => { 292 + navigator 293 + .share({ title: "Margin", text, url: shareUrl }) 294 + .catch(() => {}); 295 + setIsOpen(false); 296 + }, 297 + )} 298 + </div> 299 + </div> 300 + )} 301 + </div> 302 + ); 303 + }
+315
web/src/components/modals/SignUpModal.tsx
··· 1 + import React, { useState, useEffect } from "react"; 2 + import { X, ChevronRight, Loader2, AlertCircle } from "lucide-react"; 3 + import { 4 + BlackskyIcon, 5 + NorthskyIcon, 6 + BlueskyIcon, 7 + TophhieIcon, 8 + MarginIcon, 9 + } from "../common/Icons"; 10 + import { startSignup } from "../../api/client"; 11 + 12 + interface Provider { 13 + id: string; 14 + name: string; 15 + service: string; 16 + Icon: any; 17 + description: string; 18 + custom?: boolean; 19 + wide?: boolean; 20 + } 21 + 22 + const RECOMMENDED_PROVIDER: Provider = { 23 + id: "margin", 24 + name: "Margin", 25 + service: "https://margin.cafe", 26 + Icon: MarginIcon, 27 + description: "Hosted by Margin, the easiest way to get started", 28 + }; 29 + 30 + const OTHER_PROVIDERS: Provider[] = [ 31 + { 32 + id: "bluesky", 33 + name: "Bluesky", 34 + service: "https://bsky.social", 35 + Icon: BlueskyIcon, 36 + description: "The most popular option on the AT Protocol", 37 + }, 38 + { 39 + id: "blacksky", 40 + name: "Blacksky", 41 + service: "https://blacksky.app", 42 + Icon: BlackskyIcon, 43 + description: "For the Culture. A safe space for users and allies", 44 + }, 45 + { 46 + id: "selfhosted.social", 47 + name: "selfhosted.social", 48 + service: "https://selfhosted.social", 49 + Icon: null, 50 + description: "For hackers, designers, and ATProto enthusiasts.", 51 + }, 52 + { 53 + id: "northsky", 54 + name: "Northsky", 55 + service: "https://northsky.social", 56 + Icon: NorthskyIcon, 57 + description: "A Canadian-based worker-owned cooperative", 58 + }, 59 + { 60 + id: "tophhie", 61 + name: "Tophhie", 62 + service: "https://tophhie.social", 63 + Icon: TophhieIcon, 64 + description: "A welcoming and friendly community", 65 + }, 66 + { 67 + id: "altq", 68 + name: "AltQ", 69 + service: "https://altq.net", 70 + Icon: null, 71 + description: "An independent, self-hosted PDS instance", 72 + }, 73 + { 74 + id: "custom", 75 + name: "Custom", 76 + service: "", 77 + custom: true, 78 + Icon: null, 79 + description: "Connect to your own or another custom PDS", 80 + }, 81 + ]; 82 + 83 + interface SignUpModalProps { 84 + onClose: () => void; 85 + } 86 + 87 + export default function SignUpModal({ onClose }: SignUpModalProps) { 88 + const [showOtherProviders, setShowOtherProviders] = useState(false); 89 + const [showCustomInput, setShowCustomInput] = useState(false); 90 + const [customService, setCustomService] = useState(""); 91 + const [loading, setLoading] = useState(false); 92 + const [error, setError] = useState<string | null>(null); 93 + 94 + useEffect(() => { 95 + document.body.style.overflow = "hidden"; 96 + return () => { 97 + document.body.style.overflow = "unset"; 98 + }; 99 + }, []); 100 + 101 + const handleProviderSelect = async (provider: Provider) => { 102 + if (provider.custom) { 103 + setShowCustomInput(true); 104 + return; 105 + } 106 + 107 + setLoading(true); 108 + setError(null); 109 + 110 + try { 111 + const result = await startSignup(provider.service); 112 + if (result.authorizationUrl) { 113 + window.location.href = result.authorizationUrl; 114 + } 115 + } catch (err) { 116 + console.error(err); 117 + setError("Could not connect to this provider. Please try again."); 118 + setLoading(false); 119 + } 120 + }; 121 + 122 + const handleCustomSubmit = async (e: React.FormEvent) => { 123 + e.preventDefault(); 124 + if (!customService.trim()) return; 125 + 126 + setLoading(true); 127 + setError(null); 128 + 129 + let serviceUrl = customService.trim(); 130 + if (!serviceUrl.startsWith("http")) { 131 + serviceUrl = `https://${serviceUrl}`; 132 + } 133 + 134 + try { 135 + const result = await startSignup(serviceUrl); 136 + if (result.authorizationUrl) { 137 + window.location.href = result.authorizationUrl; 138 + } 139 + } catch (err) { 140 + console.error(err); 141 + setError("Could not connect to this PDS. Please check the URL."); 142 + setLoading(false); 143 + } 144 + }; 145 + 146 + return ( 147 + <div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-fade-in"> 148 + <div className="w-full max-w-md bg-white rounded-3xl shadow-2xl overflow-hidden animate-slide-up"> 149 + <div className="p-4 flex justify-end"> 150 + <button 151 + onClick={onClose} 152 + className="p-2 text-surface-400 hover:text-surface-900 hover:bg-surface-50 rounded-full transition-colors" 153 + > 154 + <X size={20} /> 155 + </button> 156 + </div> 157 + 158 + <div className="px-8 pb-10"> 159 + {loading ? ( 160 + <div className="text-center py-10"> 161 + <Loader2 162 + size={40} 163 + className="animate-spin text-primary-600 mx-auto mb-4" 164 + /> 165 + <p className="text-surface-600 font-medium"> 166 + Connecting to provider... 167 + </p> 168 + </div> 169 + ) : showCustomInput ? ( 170 + <div> 171 + <h2 className="text-2xl font-display font-bold text-surface-900 mb-6"> 172 + Custom Provider 173 + </h2> 174 + <form onSubmit={handleCustomSubmit} className="space-y-4"> 175 + <div> 176 + <label className="block text-sm font-medium text-surface-700 mb-1"> 177 + PDS address (e.g. pds.example.com) 178 + </label> 179 + <input 180 + type="text" 181 + className="w-full px-4 py-3 bg-surface-50 border border-surface-200 rounded-xl focus:border-primary-500 focus:ring-4 focus:ring-primary-500/10 outline-none transition-all" 182 + value={customService} 183 + onChange={(e) => setCustomService(e.target.value)} 184 + placeholder="pds.example.com" 185 + autoFocus 186 + /> 187 + </div> 188 + 189 + {error && ( 190 + <div className="p-3 bg-red-50 text-red-600 text-sm rounded-lg flex items-center gap-2"> 191 + <AlertCircle size={16} /> 192 + {error} 193 + </div> 194 + )} 195 + 196 + <div className="flex gap-3 pt-4"> 197 + <button 198 + type="button" 199 + className="flex-1 py-3 bg-white border border-surface-200 text-surface-700 font-semibold rounded-xl hover:bg-surface-50 transition-colors" 200 + onClick={() => { 201 + setShowCustomInput(false); 202 + setError(null); 203 + }} 204 + > 205 + Back 206 + </button> 207 + <button 208 + type="submit" 209 + className="flex-1 py-3 bg-primary-600 text-white font-semibold rounded-xl hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed" 210 + disabled={!customService.trim()} 211 + > 212 + Continue 213 + </button> 214 + </div> 215 + </form> 216 + </div> 217 + ) : ( 218 + <div> 219 + <h2 className="text-2xl font-display font-bold text-surface-900 mb-2"> 220 + Create your account 221 + </h2> 222 + <p className="text-surface-500 mb-6"> 223 + Margin adheres to the AT Protocol. Choose a provider to host 224 + your account. 225 + </p> 226 + 227 + {error && ( 228 + <div className="mb-4 p-3 bg-red-50 text-red-600 text-sm rounded-lg flex items-center gap-2"> 229 + <AlertCircle size={16} /> 230 + {error} 231 + </div> 232 + )} 233 + 234 + <div className="mb-6"> 235 + <div className="inline-block px-2 py-0.5 bg-primary-50 text-primary-700 text-xs font-bold uppercase tracking-wider rounded-md mb-2"> 236 + Recommended 237 + </div> 238 + <button 239 + className="w-full flex items-center gap-4 p-4 bg-white border-2 border-primary-100 hover:border-primary-300 rounded-2xl shadow-sm hover:shadow-md transition-all group text-left" 240 + onClick={() => handleProviderSelect(RECOMMENDED_PROVIDER)} 241 + > 242 + <div className="w-12 h-12 bg-primary-50 rounded-full flex items-center justify-center text-primary-600 flex-shrink-0"> 243 + {RECOMMENDED_PROVIDER.Icon && ( 244 + <RECOMMENDED_PROVIDER.Icon size={24} /> 245 + )} 246 + </div> 247 + <div className="flex-1 min-w-0"> 248 + <h3 className="font-bold text-surface-900 group-hover:text-primary-700 transition-colors"> 249 + {RECOMMENDED_PROVIDER.name} 250 + </h3> 251 + <span className="text-sm text-surface-500 line-clamp-1"> 252 + {RECOMMENDED_PROVIDER.description} 253 + </span> 254 + </div> 255 + <ChevronRight 256 + size={20} 257 + className="text-surface-300 group-hover:text-primary-500" 258 + /> 259 + </button> 260 + </div> 261 + 262 + <div className="border-t border-surface-100 pt-4"> 263 + <button 264 + type="button" 265 + className="flex items-center gap-2 text-sm font-medium text-surface-500 hover:text-surface-900 transition-colors mb-4" 266 + onClick={() => setShowOtherProviders(!showOtherProviders)} 267 + > 268 + {showOtherProviders ? "Hide other options" : "More options"} 269 + <ChevronRight 270 + size={14} 271 + className={`transition-transform duration-200 ${showOtherProviders ? "rotate-90" : ""}`} 272 + /> 273 + </button> 274 + 275 + {showOtherProviders && ( 276 + <div className="space-y-2 animate-fade-in"> 277 + {OTHER_PROVIDERS.map((p) => ( 278 + <button 279 + key={p.id} 280 + className="w-full flex items-center gap-3 p-3 bg-surface-50 hover:bg-surface-100 rounded-xl transition-colors text-left group" 281 + onClick={() => handleProviderSelect(p)} 282 + > 283 + <div className="w-8 h-8 flex items-center justify-center bg-white rounded-full shadow-sm text-surface-600"> 284 + {p.Icon ? ( 285 + <p.Icon size={18} /> 286 + ) : ( 287 + <span className="font-bold text-xs"> 288 + {p.name[0]} 289 + </span> 290 + )} 291 + </div> 292 + <div className="flex-1 min-w-0"> 293 + <h3 className="text-sm font-bold text-surface-900"> 294 + {p.name} 295 + </h3> 296 + <p className="text-xs text-surface-500 line-clamp-1"> 297 + {p.description} 298 + </p> 299 + </div> 300 + <ChevronRight 301 + size={16} 302 + className="text-surface-300 group-hover:text-surface-600" 303 + /> 304 + </button> 305 + ))} 306 + </div> 307 + )} 308 + </div> 309 + </div> 310 + )} 311 + </div> 312 + </div> 313 + </div> 314 + ); 315 + }
+244
web/src/components/navigation/MobileNav.tsx
··· 1 + import React, { useState, useEffect } from "react"; 2 + import { Link, useLocation } from "react-router-dom"; 3 + import { useStore } from "@nanostores/react"; 4 + import { $user, logout } from "../../store/auth"; 5 + import { getUnreadNotificationCount } from "../../api/client"; 6 + import { 7 + Home, 8 + Search, 9 + Folder, 10 + User, 11 + PenSquare, 12 + Bookmark, 13 + Settings, 14 + MoreHorizontal, 15 + LogOut, 16 + Bell, 17 + Highlighter, 18 + X, 19 + } from "lucide-react"; 20 + 21 + export default function MobileNav() { 22 + const user = useStore($user); 23 + const location = useLocation(); 24 + const [isMenuOpen, setIsMenuOpen] = useState(false); 25 + const [unreadCount, setUnreadCount] = useState(0); 26 + 27 + const isAuthenticated = !!user; 28 + 29 + const isActive = (path: string) => { 30 + if (path === "/") return location.pathname === "/"; 31 + return location.pathname.startsWith(path); 32 + }; 33 + 34 + useEffect(() => { 35 + if (isAuthenticated) { 36 + getUnreadNotificationCount() 37 + .then((count) => setUnreadCount(count || 0)) 38 + .catch(() => {}); 39 + } 40 + }, [isAuthenticated]); 41 + 42 + const closeMenu = () => setIsMenuOpen(false); 43 + 44 + return ( 45 + <> 46 + {isMenuOpen && ( 47 + <div 48 + className="fixed inset-0 bg-black/50 z-40 lg:hidden" 49 + onClick={closeMenu} 50 + /> 51 + )} 52 + 53 + {isMenuOpen && ( 54 + <div className="fixed bottom-16 left-0 right-0 bg-white dark:bg-surface-900 rounded-t-2xl shadow-2xl z-50 lg:hidden animate-slide-up"> 55 + <div className="p-4 space-y-1"> 56 + {isAuthenticated && user ? ( 57 + <> 58 + <Link 59 + to={`/profile/${user.did}`} 60 + className="flex items-center gap-3 p-3 rounded-xl hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors" 61 + onClick={closeMenu} 62 + > 63 + {user.avatar ? ( 64 + <img 65 + src={user.avatar} 66 + alt="" 67 + className="w-10 h-10 rounded-full object-cover" 68 + /> 69 + ) : ( 70 + <div className="w-10 h-10 rounded-full bg-surface-200 dark:bg-surface-700 flex items-center justify-center"> 71 + <User size={18} className="text-surface-500" /> 72 + </div> 73 + )} 74 + <div className="flex flex-col"> 75 + <span className="font-semibold text-surface-900 dark:text-white"> 76 + {user.displayName || user.handle} 77 + </span> 78 + <span className="text-sm text-surface-500"> 79 + @{user.handle} 80 + </span> 81 + </div> 82 + </Link> 83 + 84 + <div className="h-px bg-surface-200 dark:bg-surface-700 my-2" /> 85 + 86 + <Link 87 + to="/highlights" 88 + className="flex items-center gap-3 p-3 rounded-xl hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors text-surface-700 dark:text-surface-200" 89 + onClick={closeMenu} 90 + > 91 + <Highlighter size={20} /> 92 + <span>Highlights</span> 93 + </Link> 94 + 95 + <Link 96 + to="/bookmarks" 97 + className="flex items-center gap-3 p-3 rounded-xl hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors text-surface-700 dark:text-surface-200" 98 + onClick={closeMenu} 99 + > 100 + <Bookmark size={20} /> 101 + <span>Bookmarks</span> 102 + </Link> 103 + 104 + <Link 105 + to="/collections" 106 + className="flex items-center gap-3 p-3 rounded-xl hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors text-surface-700 dark:text-surface-200" 107 + onClick={closeMenu} 108 + > 109 + <Folder size={20} /> 110 + <span>Collections</span> 111 + </Link> 112 + 113 + <Link 114 + to="/settings" 115 + className="flex items-center gap-3 p-3 rounded-xl hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors text-surface-700 dark:text-surface-200" 116 + onClick={closeMenu} 117 + > 118 + <Settings size={20} /> 119 + <span>Settings</span> 120 + </Link> 121 + 122 + <div className="h-px bg-surface-200 dark:bg-surface-700 my-2" /> 123 + 124 + <button 125 + className="flex items-center gap-3 p-3 rounded-xl hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors text-red-600 w-full" 126 + onClick={() => { 127 + logout(); 128 + closeMenu(); 129 + }} 130 + > 131 + <LogOut size={20} /> 132 + <span>Log Out</span> 133 + </button> 134 + </> 135 + ) : ( 136 + <> 137 + <Link 138 + to="/login" 139 + className="flex items-center gap-3 p-3 rounded-xl hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors text-surface-700 dark:text-surface-200" 140 + onClick={closeMenu} 141 + > 142 + <User size={20} /> 143 + <span>Sign In</span> 144 + </Link> 145 + <Link 146 + to="/collections" 147 + className="flex items-center gap-3 p-3 rounded-xl hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors text-surface-700 dark:text-surface-200" 148 + onClick={closeMenu} 149 + > 150 + <Folder size={20} /> 151 + <span>Collections</span> 152 + </Link> 153 + <Link 154 + to="/settings" 155 + className="flex items-center gap-3 p-3 rounded-xl hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors text-surface-700 dark:text-surface-200" 156 + onClick={closeMenu} 157 + > 158 + <Settings size={20} /> 159 + <span>Settings</span> 160 + </Link> 161 + </> 162 + )} 163 + </div> 164 + </div> 165 + )} 166 + 167 + <nav className="fixed bottom-0 left-0 right-0 h-14 bg-white dark:bg-surface-900 border-t border-surface-200 dark:border-surface-700 flex items-center justify-around px-2 z-50 lg:hidden safe-area-bottom"> 168 + <Link 169 + to="/home" 170 + className={`flex flex-col items-center justify-center w-14 h-14 rounded-xl transition-colors ${ 171 + isActive("/home") 172 + ? "text-primary-600" 173 + : "text-surface-500 hover:text-surface-700" 174 + }`} 175 + onClick={closeMenu} 176 + > 177 + <Home size={24} strokeWidth={1.5} /> 178 + </Link> 179 + 180 + <Link 181 + to="/url" 182 + className={`flex flex-col items-center justify-center w-14 h-14 rounded-xl transition-colors ${ 183 + isActive("/url") 184 + ? "text-primary-600" 185 + : "text-surface-500 hover:text-surface-700" 186 + }`} 187 + onClick={closeMenu} 188 + > 189 + <Search size={24} strokeWidth={1.5} /> 190 + </Link> 191 + 192 + {isAuthenticated ? ( 193 + <> 194 + <Link 195 + to="/new" 196 + className="flex items-center justify-center w-12 h-12 rounded-full bg-primary-600 text-white shadow-lg hover:bg-primary-500 transition-colors -mt-4" 197 + onClick={closeMenu} 198 + > 199 + <PenSquare size={20} strokeWidth={2} /> 200 + </Link> 201 + 202 + <Link 203 + to="/notifications" 204 + className={`relative flex flex-col items-center justify-center w-14 h-14 rounded-xl transition-colors ${ 205 + isActive("/notifications") 206 + ? "text-primary-600" 207 + : "text-surface-500 hover:text-surface-700" 208 + }`} 209 + onClick={closeMenu} 210 + > 211 + <Bell size={24} strokeWidth={1.5} /> 212 + {unreadCount > 0 && ( 213 + <span className="absolute top-2 right-2 w-2 h-2 bg-red-500 rounded-full" /> 214 + )} 215 + </Link> 216 + </> 217 + ) : ( 218 + <Link 219 + to="/login" 220 + className="flex items-center justify-center w-12 h-12 rounded-full bg-primary-600 text-white shadow-lg hover:bg-primary-500 transition-colors -mt-4" 221 + onClick={closeMenu} 222 + > 223 + <User size={20} strokeWidth={2} /> 224 + </Link> 225 + )} 226 + 227 + <button 228 + className={`flex flex-col items-center justify-center w-14 h-14 rounded-xl transition-colors ${ 229 + isMenuOpen 230 + ? "text-primary-600" 231 + : "text-surface-500 hover:text-surface-700" 232 + }`} 233 + onClick={() => setIsMenuOpen(!isMenuOpen)} 234 + > 235 + {isMenuOpen ? ( 236 + <X size={24} strokeWidth={1.5} /> 237 + ) : ( 238 + <MoreHorizontal size={24} strokeWidth={1.5} /> 239 + )} 240 + </button> 241 + </nav> 242 + </> 243 + ); 244 + }
+148
web/src/components/navigation/RightSidebar.tsx
··· 1 + import React, { useEffect, useState } from "react"; 2 + import { useNavigate } from "react-router-dom"; 3 + import { 4 + ArrowRight, 5 + Github, 6 + Twitter, 7 + ExternalLink, 8 + Loader2, 9 + Search, 10 + } from "lucide-react"; 11 + import { getTrendingTags, type Tag } from "../../api/client"; 12 + 13 + export default function RightSidebar() { 14 + const navigate = useNavigate(); 15 + const [tags, setTags] = useState<Tag[]>([]); 16 + const [browser, setBrowser] = useState<"chrome" | "firefox" | "other">( 17 + "other", 18 + ); 19 + const [searchQuery, setSearchQuery] = useState(""); 20 + 21 + const handleSearch = (e: React.KeyboardEvent) => { 22 + if (e.key === "Enter" && searchQuery.trim()) { 23 + navigate(`/url?q=${encodeURIComponent(searchQuery.trim())}`); 24 + } 25 + }; 26 + 27 + useEffect(() => { 28 + const ua = navigator.userAgent.toLowerCase(); 29 + if (ua.includes("firefox")) setBrowser("firefox"); 30 + else if (ua.includes("chrome")) setBrowser("chrome"); 31 + getTrendingTags().then(setTags); 32 + }, []); 33 + 34 + const extensionLink = 35 + browser === "firefox" 36 + ? "https://addons.mozilla.org/en-US/firefox/addon/margin/" 37 + : "https://chromewebstore.google.com/detail/margin/cgpmbiiagnehkikhcbnhiagfomajncpa"; 38 + 39 + return ( 40 + <aside className="hidden lg:block w-[280px] shrink-0 sticky top-0 h-screen overflow-y-auto px-4 py-4 border-l border-surface-100/50 dark:border-surface-800/50"> 41 + <div className="space-y-6"> 42 + <div className="relative"> 43 + <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> 44 + <Search 45 + className="text-surface-400 dark:text-surface-500" 46 + size={16} 47 + /> 48 + </div> 49 + <input 50 + type="text" 51 + value={searchQuery} 52 + onChange={(e) => setSearchQuery(e.target.value)} 53 + onKeyDown={handleSearch} 54 + placeholder="Search Margin..." 55 + className="w-full bg-surface-100 dark:bg-surface-800 rounded-full pl-10 pr-5 py-2.5 text-sm font-medium text-surface-900 dark:text-white placeholder:text-surface-500 dark:placeholder:text-surface-400 focus:outline-none focus:ring-2 focus:ring-primary-500/20 transition-all border-none" 56 + /> 57 + </div> 58 + 59 + <div className="bg-surface-50 dark:bg-surface-900 rounded-2xl p-4 border border-surface-100 dark:border-surface-800"> 60 + <h3 className="font-bold text-base mb-1 text-surface-900 dark:text-white"> 61 + Get the Extension 62 + </h3> 63 + <p className="text-surface-500 dark:text-surface-400 text-sm mb-4 leading-snug"> 64 + Save anything, annotate anywhere. 65 + </p> 66 + <a 67 + href={extensionLink} 68 + target="_blank" 69 + rel="noopener noreferrer" 70 + className="flex items-center justify-center w-full px-4 py-2 bg-surface-900 dark:bg-white text-white dark:text-surface-900 rounded-full hover:bg-black dark:hover:bg-surface-100 transition-all text-sm font-semibold" 71 + > 72 + Download for {browser === "firefox" ? "Firefox" : "Chrome"} 73 + </a> 74 + </div> 75 + 76 + <div className="py-2"> 77 + <h3 className="font-bold text-xl px-2 mb-4 text-surface-900 dark:text-white"> 78 + Trending 79 + </h3> 80 + {tags.length > 0 ? ( 81 + <div className="flex flex-col"> 82 + {tags.map((t) => ( 83 + <a 84 + key={t.tag} 85 + href={`/search?q=${t.tag}`} 86 + className="px-2 py-3 hover:bg-surface-50 dark:hover:bg-surface-800 rounded-xl transition-colors group" 87 + > 88 + <div className="flex justify-between items-center mb-0.5"> 89 + <span className="text-xs text-surface-500 dark:text-surface-400 font-medium"> 90 + Trending 91 + </span> 92 + <span className="text-xs text-surface-400 dark:text-surface-500 opacity-0 group-hover:opacity-100 transition-opacity"> 93 + ... 94 + </span> 95 + </div> 96 + <div className="font-bold text-surface-900 dark:text-white"> 97 + #{t.tag} 98 + </div> 99 + <div className="text-xs text-surface-500 dark:text-surface-400 mt-0.5"> 100 + {t.count} posts 101 + </div> 102 + </a> 103 + ))} 104 + </div> 105 + ) : ( 106 + <div className="px-2"> 107 + <p className="text-sm text-surface-500 dark:text-surface-400"> 108 + Nothing trending right now. 109 + </p> 110 + </div> 111 + )} 112 + </div> 113 + 114 + <div className="px-2 pt-2"> 115 + <div className="flex flex-wrap gap-x-3 gap-y-1 text-[13px] text-surface-400 dark:text-surface-500 leading-relaxed"> 116 + <a 117 + href="#" 118 + className="hover:underline hover:text-surface-600 dark:hover:text-surface-300" 119 + > 120 + About 121 + </a> 122 + <a 123 + href="/privacy" 124 + className="hover:underline hover:text-surface-600 dark:hover:text-surface-300" 125 + > 126 + Privacy 127 + </a> 128 + <a 129 + href="/terms" 130 + className="hover:underline hover:text-surface-600 dark:hover:text-surface-300" 131 + > 132 + Terms 133 + </a> 134 + <a 135 + href="https://github.com/margin-at" 136 + target="_blank" 137 + rel="noreferrer" 138 + className="hover:underline hover:text-surface-600 dark:hover:text-surface-300" 139 + > 140 + Code 141 + </a> 142 + <span>© 2026 Margin</span> 143 + </div> 144 + </div> 145 + </div> 146 + </aside> 147 + ); 148 + }
+151
web/src/components/navigation/Sidebar.tsx
··· 1 + import React, { useEffect, useState } from "react"; 2 + import { 3 + Home, 4 + Bookmark, 5 + PenTool, 6 + Settings, 7 + LogOut, 8 + Bell, 9 + Sun, 10 + Moon, 11 + Monitor, 12 + Folder, 13 + } from "lucide-react"; 14 + import { useStore } from "@nanostores/react"; 15 + import { $user, logout } from "../../store/auth"; 16 + import { $theme, cycleTheme } from "../../store/theme"; 17 + import { getUnreadNotificationCount } from "../../api/client"; 18 + import { Link, useLocation } from "react-router-dom"; 19 + import { Avatar, CountBadge } from "../ui"; 20 + 21 + export default function Sidebar() { 22 + const user = useStore($user); 23 + const theme = useStore($theme); 24 + const location = useLocation(); 25 + const currentPath = location.pathname; 26 + const [unreadCount, setUnreadCount] = useState(0); 27 + 28 + useEffect(() => { 29 + if (!user) return; 30 + 31 + const checkNotifications = async () => { 32 + const count = await getUnreadNotificationCount(); 33 + setUnreadCount(count); 34 + }; 35 + 36 + checkNotifications(); 37 + const interval = setInterval(checkNotifications, 30000); 38 + return () => clearInterval(interval); 39 + }, [user]); 40 + 41 + const navItems = [ 42 + { icon: Home, label: "Feed", href: "/home" }, 43 + { 44 + icon: Bell, 45 + label: "Activity", 46 + href: "/notifications", 47 + badge: unreadCount, 48 + }, 49 + { icon: Bookmark, label: "Bookmarks", href: "/bookmarks" }, 50 + { icon: PenTool, label: "Highlights", href: "/highlights" }, 51 + { icon: Folder, label: "Collections", href: "/collections" }, 52 + ]; 53 + 54 + if (!user) return null; 55 + 56 + return ( 57 + <aside className="sticky top-0 h-screen w-[240px] hidden md:flex flex-col justify-between py-5 px-4 z-50"> 58 + <div className="flex flex-col gap-8"> 59 + <Link 60 + to="/home" 61 + className="px-3 hover:opacity-80 transition-opacity w-fit" 62 + > 63 + <img src="/logo.svg" alt="Margin" className="w-9 h-9" /> 64 + </Link> 65 + 66 + <nav className="flex flex-col gap-1"> 67 + {navItems.map((item) => { 68 + const isActive = 69 + currentPath === item.href || 70 + (item.href !== "/home" && currentPath.startsWith(item.href)); 71 + return ( 72 + <Link 73 + key={item.href} 74 + to={item.href} 75 + className={`flex items-center gap-4 px-4 py-3 rounded-xl transition-all duration-200 text-[15px] group ${ 76 + isActive 77 + ? "font-bold text-surface-900 dark:text-white bg-surface-100 dark:bg-surface-800" 78 + : "font-medium text-surface-600 dark:text-surface-400 hover:bg-surface-50 dark:hover:bg-surface-800/50 hover:text-surface-900 dark:hover:text-white" 79 + }`} 80 + > 81 + <item.icon 82 + size={22} 83 + className={`transition-colors ${isActive ? "text-primary-600 dark:text-primary-400" : ""}`} 84 + strokeWidth={isActive ? 2.5 : 2} 85 + /> 86 + <span className="flex-1">{item.label}</span> 87 + {(item.badge ?? 0) > 0 && ( 88 + <CountBadge count={item.badge ?? 0} /> 89 + )} 90 + </Link> 91 + ); 92 + })} 93 + </nav> 94 + </div> 95 + 96 + <div className="relative group"> 97 + <Link 98 + to={`/profile/${user.did}`} 99 + className="flex items-center gap-3 p-3 rounded-xl hover:bg-surface-100 dark:hover:bg-surface-800 transition-colors w-full" 100 + > 101 + <Avatar did={user.did} avatar={user.avatar} size="md" /> 102 + <div className="flex-1 min-w-0"> 103 + <p className="font-semibold text-surface-900 dark:text-white truncate text-sm"> 104 + {user.displayName || user.handle} 105 + </p> 106 + <p className="text-xs text-surface-500 dark:text-surface-400 truncate"> 107 + @{user.handle} 108 + </p> 109 + </div> 110 + </Link> 111 + 112 + <div className="absolute bottom-full left-0 w-full mb-2 bg-white dark:bg-surface-900 rounded-xl shadow-xl border border-surface-100 dark:border-surface-800 p-2 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 transform origin-bottom scale-95 group-hover:scale-100"> 113 + <button 114 + onClick={cycleTheme} 115 + className="flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-surface-50 dark:hover:bg-surface-800 text-sm font-medium text-surface-700 dark:text-surface-300 w-full transition-colors" 116 + > 117 + {theme === "light" ? ( 118 + <Sun size={18} /> 119 + ) : theme === "dark" ? ( 120 + <Moon size={18} /> 121 + ) : ( 122 + <Monitor size={18} /> 123 + )} 124 + <span className="flex-1 text-left"> 125 + {theme === "light" 126 + ? "Light" 127 + : theme === "dark" 128 + ? "Dark" 129 + : "System"} 130 + </span> 131 + </button> 132 + <Link 133 + to="/settings" 134 + className="flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-surface-50 dark:hover:bg-surface-800 text-sm font-medium text-surface-700 dark:text-surface-300 transition-colors" 135 + > 136 + <Settings size={18} /> 137 + <span>Settings</span> 138 + </Link> 139 + <div className="h-px bg-surface-100 dark:bg-surface-800 my-1" /> 140 + <button 141 + onClick={logout} 142 + className="flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 text-sm font-medium text-red-600 dark:text-red-400 w-full text-left transition-colors" 143 + > 144 + <LogOut size={18} /> 145 + <span>Log out</span> 146 + </button> 147 + </div> 148 + </div> 149 + </aside> 150 + ); 151 + }
+61
web/src/components/ui/Avatar.tsx
··· 1 + import React from "react"; 2 + import { User } from "lucide-react"; 3 + import { clsx } from "clsx"; 4 + import { getAvatarUrl } from "../../api/client"; 5 + 6 + type AvatarSize = "xs" | "sm" | "md" | "lg" | "xl"; 7 + 8 + interface AvatarProps { 9 + did?: string; 10 + avatar?: string; 11 + src?: string; 12 + alt?: string; 13 + size?: AvatarSize; 14 + className?: string; 15 + } 16 + 17 + const sizes: Record<AvatarSize, { container: string; icon: number }> = { 18 + xs: { container: "h-6 w-6", icon: 12 }, 19 + sm: { container: "h-8 w-8", icon: 14 }, 20 + md: { container: "h-10 w-10", icon: 18 }, 21 + lg: { container: "h-14 w-14", icon: 24 }, 22 + xl: { container: "h-20 w-20", icon: 32 }, 23 + }; 24 + 25 + export default function Avatar({ 26 + did, 27 + avatar, 28 + src, 29 + alt = "Avatar", 30 + size = "md", 31 + className, 32 + }: AvatarProps) { 33 + const imageUrl = src || getAvatarUrl(did, avatar); 34 + const { container, icon } = sizes[size]; 35 + 36 + if (imageUrl) { 37 + return ( 38 + <img 39 + src={imageUrl} 40 + alt={alt} 41 + className={clsx( 42 + container, 43 + "rounded-full object-cover bg-surface-100 dark:bg-surface-800", 44 + className, 45 + )} 46 + /> 47 + ); 48 + } 49 + 50 + return ( 51 + <div 52 + className={clsx( 53 + container, 54 + "rounded-full bg-surface-100 dark:bg-surface-800 flex items-center justify-center text-surface-400 dark:text-surface-500", 55 + className, 56 + )} 57 + > 58 + <User size={icon} /> 59 + </div> 60 + ); 61 + }
+62
web/src/components/ui/Badge.tsx
··· 1 + import React from "react"; 2 + import { clsx } from "clsx"; 3 + 4 + type BadgeVariant = "default" | "primary" | "success" | "warning" | "danger"; 5 + 6 + interface BadgeProps { 7 + children: React.ReactNode; 8 + variant?: BadgeVariant; 9 + className?: string; 10 + } 11 + 12 + const variants: Record<BadgeVariant, string> = { 13 + default: 14 + "bg-surface-100 dark:bg-surface-800 text-surface-600 dark:text-surface-400", 15 + primary: 16 + "bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300", 17 + success: 18 + "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300", 19 + warning: 20 + "bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300", 21 + danger: "bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300", 22 + }; 23 + 24 + export default function Badge({ 25 + children, 26 + variant = "default", 27 + className, 28 + }: BadgeProps) { 29 + return ( 30 + <span 31 + className={clsx( 32 + "inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium", 33 + variants[variant], 34 + className, 35 + )} 36 + > 37 + {children} 38 + </span> 39 + ); 40 + } 41 + 42 + interface CountBadgeProps { 43 + count: number; 44 + max?: number; 45 + className?: string; 46 + } 47 + 48 + export function CountBadge({ count, max = 99, className }: CountBadgeProps) { 49 + if (count <= 0) return null; 50 + 51 + return ( 52 + <span 53 + className={clsx( 54 + "inline-flex items-center justify-center min-w-[1.25rem] h-5 px-1.5", 55 + "text-[10px] font-bold rounded-full bg-primary-600 text-white", 56 + className, 57 + )} 58 + > 59 + {count > max ? `${max}+` : count} 60 + </span> 61 + ); 62 + }
+76
web/src/components/ui/Button.tsx
··· 1 + import React from "react"; 2 + import { clsx } from "clsx"; 3 + 4 + type ButtonVariant = "primary" | "secondary" | "ghost" | "danger"; 5 + type ButtonSize = "sm" | "md" | "lg"; 6 + 7 + interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> { 8 + variant?: ButtonVariant; 9 + size?: ButtonSize; 10 + loading?: boolean; 11 + icon?: React.ReactNode; 12 + children?: React.ReactNode; 13 + } 14 + 15 + const variants: Record<ButtonVariant, string> = { 16 + primary: "bg-primary-600 text-white hover:bg-primary-500 shadow-sm", 17 + secondary: 18 + "bg-surface-100 dark:bg-surface-800 text-surface-900 dark:text-white hover:bg-surface-200 dark:hover:bg-surface-700", 19 + ghost: 20 + "text-surface-600 dark:text-surface-400 hover:bg-surface-100 dark:hover:bg-surface-800 hover:text-surface-900 dark:hover:text-white", 21 + danger: "bg-red-600 text-white hover:bg-red-500", 22 + }; 23 + 24 + const sizes: Record<ButtonSize, string> = { 25 + sm: "px-3 py-1.5 text-sm gap-1.5", 26 + md: "px-4 py-2 text-sm gap-2", 27 + lg: "px-6 py-3 text-base gap-2", 28 + }; 29 + 30 + export default function Button({ 31 + variant = "primary", 32 + size = "md", 33 + loading = false, 34 + icon, 35 + children, 36 + className, 37 + disabled, 38 + ...props 39 + }: ButtonProps) { 40 + return ( 41 + <button 42 + className={clsx( 43 + "inline-flex items-center justify-center font-medium rounded-lg transition-all duration-200", 44 + "focus:outline-none focus:ring-2 focus:ring-primary-500/20", 45 + "disabled:opacity-50 disabled:cursor-not-allowed", 46 + variants[variant], 47 + sizes[size], 48 + className, 49 + )} 50 + disabled={disabled || loading} 51 + {...props} 52 + > 53 + {loading ? ( 54 + <svg className="animate-spin h-4 w-4" viewBox="0 0 24 24"> 55 + <circle 56 + className="opacity-25" 57 + cx="12" 58 + cy="12" 59 + r="10" 60 + stroke="currentColor" 61 + strokeWidth="4" 62 + fill="none" 63 + /> 64 + <path 65 + className="opacity-75" 66 + fill="currentColor" 67 + d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" 68 + /> 69 + </svg> 70 + ) : ( 71 + icon 72 + )} 73 + {children && <span>{children}</span>} 74 + </button> 75 + ); 76 + }
+59
web/src/components/ui/EmptyState.tsx
··· 1 + import React from "react"; 2 + import { clsx } from "clsx"; 3 + 4 + interface EmptyStateProps { 5 + icon?: React.ReactNode; 6 + title?: string; 7 + message: string; 8 + action?: React.ReactNode | { label: string; onClick: () => void }; 9 + className?: string; 10 + } 11 + 12 + export default function EmptyState({ 13 + icon, 14 + title, 15 + message, 16 + action, 17 + className, 18 + }: EmptyStateProps) { 19 + return ( 20 + <div 21 + className={clsx( 22 + "text-center py-16 px-6", 23 + "bg-surface-50/50 dark:bg-surface-800/50 rounded-2xl", 24 + "border border-dashed border-surface-200 dark:border-surface-700", 25 + className, 26 + )} 27 + > 28 + {icon && ( 29 + <div className="flex justify-center mb-4 text-surface-300 dark:text-surface-600"> 30 + {icon} 31 + </div> 32 + )} 33 + {title && ( 34 + <h3 className="text-lg font-display font-semibold text-surface-900 dark:text-white mb-2"> 35 + {title} 36 + </h3> 37 + )} 38 + <p className="text-surface-500 dark:text-surface-400 max-w-sm mx-auto"> 39 + {message} 40 + </p> 41 + {action && ( 42 + <div className="mt-6"> 43 + {typeof action === "object" && 44 + "label" in action && 45 + "onClick" in action ? ( 46 + <button 47 + onClick={action.onClick} 48 + className="inline-flex items-center gap-2 px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg transition-colors" 49 + > 50 + {action.label} 51 + </button> 52 + ) : ( 53 + action 54 + )} 55 + </div> 56 + )} 57 + </div> 58 + ); 59 + }
+50
web/src/components/ui/Input.tsx
··· 1 + import React from "react"; 2 + import { clsx } from "clsx"; 3 + 4 + interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> { 5 + label?: string; 6 + error?: string; 7 + icon?: React.ReactNode; 8 + } 9 + 10 + export default function Input({ 11 + label, 12 + error, 13 + icon, 14 + className, 15 + ...props 16 + }: InputProps) { 17 + return ( 18 + <div className="w-full"> 19 + {label && ( 20 + <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1.5"> 21 + {label} 22 + </label> 23 + )} 24 + <div className="relative"> 25 + {icon && ( 26 + <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-surface-400 dark:text-surface-500"> 27 + {icon} 28 + </div> 29 + )} 30 + <input 31 + className={clsx( 32 + "w-full px-3 py-2 bg-surface-50 dark:bg-surface-800", 33 + "border border-surface-200 dark:border-surface-700 rounded-lg", 34 + "text-surface-900 dark:text-white", 35 + "placeholder:text-surface-400 dark:placeholder:text-surface-500", 36 + "focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500 dark:focus:border-primary-400", 37 + "transition-colors text-sm", 38 + icon && "pl-10", 39 + error && "border-red-500 dark:border-red-400 focus:ring-red-500/20", 40 + className, 41 + )} 42 + {...props} 43 + /> 44 + </div> 45 + {error && ( 46 + <p className="mt-1.5 text-sm text-red-600 dark:text-red-400">{error}</p> 47 + )} 48 + </div> 49 + ); 50 + }
+45
web/src/components/ui/Skeleton.tsx
··· 1 + import React from "react"; 2 + import { clsx } from "clsx"; 3 + 4 + interface SkeletonProps { 5 + className?: string; 6 + variant?: "text" | "circular" | "rectangular"; 7 + width?: string | number; 8 + height?: string | number; 9 + } 10 + 11 + export default function Skeleton({ 12 + className, 13 + variant = "text", 14 + width, 15 + height, 16 + }: SkeletonProps) { 17 + return ( 18 + <div 19 + className={clsx( 20 + "animate-pulse bg-surface-200 dark:bg-surface-700", 21 + variant === "circular" && "rounded-full", 22 + variant === "rectangular" && "rounded-lg", 23 + variant === "text" && "rounded h-4", 24 + className, 25 + )} 26 + style={{ width, height }} 27 + /> 28 + ); 29 + } 30 + 31 + export function SkeletonCard() { 32 + return ( 33 + <div className="bg-white dark:bg-surface-900 rounded-lg p-4 mb-3 shadow-sm ring-1 ring-black/5 dark:ring-white/5"> 34 + <div className="flex items-center gap-3 mb-3"> 35 + <Skeleton variant="circular" className="h-10 w-10" /> 36 + <div className="flex-1 space-y-2"> 37 + <Skeleton width="40%" /> 38 + <Skeleton width="25%" /> 39 + </div> 40 + </div> 41 + <Skeleton className="h-4 mb-2" /> 42 + <Skeleton className="h-4 w-3/4" /> 43 + </div> 44 + ); 45 + }
+51
web/src/components/ui/Tabs.tsx
··· 1 + import React from "react"; 2 + import { clsx } from "clsx"; 3 + 4 + interface Tab { 5 + id: string; 6 + label: string; 7 + badge?: number; 8 + } 9 + 10 + interface TabsProps { 11 + tabs: Tab[]; 12 + activeTab: string; 13 + onChange: (id: string) => void; 14 + className?: string; 15 + } 16 + 17 + export default function Tabs({ 18 + tabs, 19 + activeTab, 20 + onChange, 21 + className, 22 + }: TabsProps) { 23 + return ( 24 + <div 25 + className={clsx( 26 + "flex gap-1 bg-surface-100 dark:bg-surface-800 p-1 rounded-lg w-fit", 27 + className, 28 + )} 29 + > 30 + {tabs.map((tab) => ( 31 + <button 32 + key={tab.id} 33 + onClick={() => onChange(tab.id)} 34 + className={clsx( 35 + "px-3 py-1.5 text-sm font-medium rounded-md transition-all relative", 36 + activeTab === tab.id 37 + ? "bg-white dark:bg-surface-700 text-surface-900 dark:text-white shadow-sm" 38 + : "text-surface-500 dark:text-surface-400 hover:text-surface-700 dark:hover:text-surface-200", 39 + )} 40 + > 41 + {tab.label} 42 + {tab.badge !== undefined && tab.badge > 0 && ( 43 + <span className="ml-1.5 inline-flex items-center justify-center min-w-[1.25rem] h-5 px-1.5 text-[10px] font-bold rounded-full bg-primary-600 text-white"> 44 + {tab.badge > 99 ? "99+" : tab.badge} 45 + </span> 46 + )} 47 + </button> 48 + ))} 49 + </div> 50 + ); 51 + }
+7
web/src/components/ui/index.ts
··· 1 + export { default as Button } from "./Button"; 2 + export { default as Avatar } from "./Avatar"; 3 + export { default as Tabs } from "./Tabs"; 4 + export { default as Input } from "./Input"; 5 + export { default as Skeleton, SkeletonCard } from "./Skeleton"; 6 + export { default as EmptyState } from "./EmptyState"; 7 + export { default as Badge, CountBadge } from "./Badge";
+25 -28
web/src/layouts/AppLayout.tsx
··· 1 - 2 - import React from 'react'; 3 - import { useStore } from '@nanostores/react'; 4 - import Sidebar from '../components/Sidebar'; 5 - import RightSidebar from '../components/RightSidebar'; 6 - import MobileNav from '../components/MobileNav'; 7 - import { $theme } from '../store/theme'; 1 + import React from "react"; 2 + import { useStore } from "@nanostores/react"; 3 + import Sidebar from "../components/navigation/Sidebar"; 4 + import RightSidebar from "../components/navigation/RightSidebar"; 5 + import MobileNav from "../components/navigation/MobileNav"; 6 + import { $theme } from "../store/theme"; 8 7 9 8 interface AppLayoutProps { 10 - children: React.ReactNode; 9 + children: React.ReactNode; 11 10 } 12 11 13 12 export default function AppLayout({ children }: AppLayoutProps) { 14 - useStore($theme); 13 + useStore($theme); 15 14 16 - return ( 17 - <div className="min-h-screen bg-surface-50 dark:bg-surface-950 flex"> 18 - <Sidebar /> 15 + return ( 16 + <div className="min-h-screen bg-surface-50 dark:bg-surface-950 flex"> 17 + <Sidebar /> 19 18 20 - <div className="flex-1 min-w-0 transition-all duration-300"> 21 - <div className="flex w-full max-w-[1100px] mx-auto"> 22 - <main className="flex-1 w-full min-w-0 py-6 px-3 md:px-6 lg:px-8 pb-20 lg:pb-6"> 23 - {children} 24 - </main> 19 + <div className="flex-1 min-w-0 transition-all duration-300"> 20 + <div className="flex w-full max-w-[1100px] mx-auto"> 21 + <main className="flex-1 w-full min-w-0 py-6 px-3 md:px-6 lg:px-8 pb-20 lg:pb-6"> 22 + {children} 23 + </main> 25 24 26 - <RightSidebar /> 27 - </div> 28 - </div> 29 - 30 - <MobileNav /> 25 + <RightSidebar /> 31 26 </div> 32 - ); 27 + </div> 28 + 29 + <MobileNav /> 30 + </div> 31 + ); 33 32 } 34 33 35 34 export function LandingLayout({ children }: AppLayoutProps) { 36 - return ( 37 - <div className="min-h-screen bg-white dark:bg-surface-950"> 38 - {children} 39 - </div> 40 - ); 35 + return ( 36 + <div className="min-h-screen bg-white dark:bg-surface-950">{children}</div> 37 + ); 41 38 }
+28
web/src/layouts/BaseLayout.astro
··· 1 + --- 2 + import '../styles/global.css'; 3 + 4 + interface Props { 5 + title?: string; 6 + description?: string; 7 + } 8 + 9 + const { title = 'Margin', description = 'Annotate the web' } = Astro.props; 10 + --- 11 + 12 + <!DOCTYPE html> 13 + <html lang="en"> 14 + <head> 15 + <meta charset="UTF-8" /> 16 + <meta name="description" content={description} /> 17 + <meta name="viewport" content="width=device-width" /> 18 + <link rel="icon" type="image/svg+xml" href="/logo.svg" /> 19 + <link rel="preconnect" href="https://fonts.googleapis.com"> 20 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 21 + <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Outfit:wght@500;600;700&display=swap" rel="stylesheet"> 22 + <meta name="generator" content={Astro.generator} /> 23 + <title>{title}</title> 24 + </head> 25 + <body class="bg-surface-50 dark:bg-surface-950 min-h-screen text-surface-900 dark:text-white"> 26 + <slot /> 27 + </body> 28 + </html>
-31
web/src/layouts/Layout.astro
··· 1 - 2 - --- 3 - import Sidebar from '../components/Sidebar.jsx'; 4 - import '../styles/global.css'; 5 - 6 - const { title = 'Margin' } = Astro.props; 7 - const currentPath = Astro.url.pathname; 8 - --- 9 - 10 - <!DOCTYPE html> 11 - <html lang="en"> 12 - <head> 13 - <meta charset="UTF-8" /> 14 - <meta name="description" content="Margin - Annotate the web" /> 15 - <meta name="viewport" content="width=device-width" /> 16 - <link rel="icon" type="image/svg+xml" href="/logo.svg" /> 17 - <link rel="preconnect" href="https://fonts.googleapis.com"> 18 - <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 19 - <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Outfit:wght@500;600;700&display=swap" rel="stylesheet"> 20 - <meta name="generator" content={Astro.generator} /> 21 - <title>{title}</title> 22 - </head> 23 - <body class="bg-surface-50 min-h-screen text-surface-900"> 24 - <div class="flex"> 25 - <Sidebar client:load currentPath={currentPath} /> 26 - <main class="flex-1 md:ml-64 p-4 md:p-8 max-w-4xl mx-auto w-full"> 27 - <slot /> 28 - </main> 29 - </div> 30 - </body> 31 - </html>
+4 -19
web/src/pages/index.astro
··· 1 - 2 1 --- 2 + import BaseLayout from '../layouts/BaseLayout.astro'; 3 3 import App from '../App'; 4 - import '../styles/global.css'; 5 4 --- 6 5 7 - <!DOCTYPE html> 8 - <html lang="en"> 9 - <head> 10 - <meta charset="UTF-8" /> 11 - <meta name="description" content="Margin - Annotate the web" /> 12 - <meta name="viewport" content="width=device-width" /> 13 - <link rel="icon" type="image/svg+xml" href="/logo.svg" /> 14 - <link rel="preconnect" href="https://fonts.googleapis.com"> 15 - <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 16 - <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Outfit:wght@500;600;700&display=swap" rel="stylesheet"> 17 - <meta name="generator" content={Astro.generator} /> 18 - <title>Margin</title> 19 - </head> 20 - <body class="bg-surface-50 min-h-screen text-surface-900"> 21 - <App client:only="react" /> 22 - </body> 23 - </html> 6 + <BaseLayout title="Margin" description="Annotate the web using the AT Protocol"> 7 + <App client:only="react" /> 8 + </BaseLayout>
+119
web/src/pages/privacy.astro
··· 1 + --- 2 + import BaseLayout from '../layouts/BaseLayout.astro'; 3 + --- 4 + 5 + <BaseLayout title="Privacy Policy - Margin" description="Margin Privacy Policy"> 6 + <div class="max-w-3xl mx-auto py-12 px-4"> 7 + <a href="/home" class="inline-flex items-center gap-2 text-sm font-medium text-surface-500 hover:text-surface-900 dark:hover:text-white transition-colors mb-8"> 8 + <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m12 19-7-7 7-7"/><path d="M19 12H5"/></svg> 9 + <span>Home</span> 10 + </a> 11 + 12 + <div class="prose prose-surface dark:prose-invert max-w-none"> 13 + <h1 class="font-display font-bold text-3xl mb-2 text-surface-900 dark:text-white">Privacy Policy</h1> 14 + <p class="text-surface-500 dark:text-surface-400 mb-8">Last updated: January 11, 2026</p> 15 + 16 + <section class="mb-8"> 17 + <h2 class="text-xl font-bold text-surface-900 dark:text-white mb-4">Overview</h2> 18 + <p class="text-surface-700 dark:text-surface-300 leading-relaxed"> 19 + Margin ("we", "our", or "us") is a web annotation tool that lets you highlight, annotate, and bookmark any webpage. Your data is stored on the decentralized AT Protocol network, giving you ownership and control over your content. 20 + </p> 21 + </section> 22 + 23 + <section class="mb-8"> 24 + <h2 class="text-xl font-bold text-surface-900 dark:text-white mb-4">Data We Collect</h2> 25 + <h3 class="text-lg font-semibold text-surface-900 dark:text-white mb-2">Account Information</h3> 26 + <p class="text-surface-700 dark:text-surface-300 mb-4"> 27 + When you log in with your Bluesky/AT Protocol account, we access your: 28 + </p> 29 + <ul class="list-disc pl-5 mb-4 text-surface-700 dark:text-surface-300 space-y-1"> 30 + <li>Decentralized Identifier (DID)</li> 31 + <li>Handle (username)</li> 32 + <li>Display name and avatar (for showing your profile)</li> 33 + </ul> 34 + 35 + <h3 class="text-lg font-semibold text-surface-900 dark:text-white mb-2">Annotations & Content</h3> 36 + <p class="text-surface-700 dark:text-surface-300 mb-4">When you use Margin, we store:</p> 37 + <ul class="list-disc pl-5 mb-4 text-surface-700 dark:text-surface-300 space-y-1"> 38 + <li>URLs of pages you annotate</li> 39 + <li>Text you highlight or select</li> 40 + <li>Annotations and comments you create</li> 41 + <li>Bookmarks you save</li> 42 + <li>Collections you organize content into</li> 43 + </ul> 44 + 45 + <h3 class="text-lg font-semibold text-surface-900 dark:text-white mb-2">Authentication</h3> 46 + <p class="text-surface-700 dark:text-surface-300 mb-4"> 47 + We store OAuth session tokens locally in your browser to keep you logged in. These tokens are used solely for authenticating API requests. 48 + </p> 49 + </section> 50 + 51 + <section class="mb-8"> 52 + <h2 class="text-xl font-bold text-surface-900 dark:text-white mb-4">How We Use Your Data</h2> 53 + <p class="text-surface-700 dark:text-surface-300 mb-4">Your data is used exclusively to:</p> 54 + <ul class="list-disc pl-5 mb-4 text-surface-700 dark:text-surface-300 space-y-1"> 55 + <li>Display your annotations on webpages</li> 56 + <li>Sync your content across devices</li> 57 + <li>Show your public annotations to other users</li> 58 + <li>Enable social features like replies and likes</li> 59 + </ul> 60 + </section> 61 + 62 + <section class="mb-8"> 63 + <h2 class="text-xl font-bold text-surface-900 dark:text-white mb-4">Data Storage</h2> 64 + <p class="text-surface-700 dark:text-surface-300 mb-4"> 65 + Your annotations are stored on the AT Protocol network through your Personal Data Server (PDS). This means: 66 + </p> 67 + <ul class="list-disc pl-5 mb-4 text-surface-700 dark:text-surface-300 space-y-1"> 68 + <li>You own your data</li> 69 + <li>You can export or delete it at any time</li> 70 + <li>Your data is portable across AT Protocol services</li> 71 + </ul> 72 + <p class="text-surface-700 dark:text-surface-300"> 73 + We also maintain a local index of annotations to provide faster search and discovery features. 74 + </p> 75 + </section> 76 + 77 + <section class="mb-8"> 78 + <h2 class="text-xl font-bold text-surface-900 dark:text-white mb-4">Data Sharing</h2> 79 + <p class="text-surface-700 dark:text-surface-300 mb-4"> 80 + <strong>We do not sell your data.</strong> We do not share your data with third parties for advertising or marketing purposes. 81 + </p> 82 + <p class="text-surface-700 dark:text-surface-300 mb-4">Your public annotations may be visible to:</p> 83 + <ul class="list-disc pl-5 mb-4 text-surface-700 dark:text-surface-300 space-y-1"> 84 + <li>Other Margin users viewing the same webpage</li> 85 + <li>Anyone on the AT Protocol network (for public content)</li> 86 + </ul> 87 + </section> 88 + 89 + <section class="mb-8"> 90 + <h2 class="text-xl font-bold text-surface-900 dark:text-white mb-4">Browser Extension Permissions</h2> 91 + <p class="text-surface-700 dark:text-surface-300 mb-4">The Margin browser extension requires certain permissions:</p> 92 + <ul class="list-disc pl-5 mb-4 text-surface-700 dark:text-surface-300 space-y-1"> 93 + <li><strong>All URLs:</strong> To display and create annotations on any webpage</li> 94 + <li><strong>Storage:</strong> To save your preferences and session locally</li> 95 + <li><strong>Cookies:</strong> To maintain your logged-in session</li> 96 + <li><strong>Tabs:</strong> To know which page you're viewing</li> 97 + </ul> 98 + </section> 99 + 100 + <section class="mb-8"> 101 + <h2 class="text-xl font-bold text-surface-900 dark:text-white mb-4">Your Rights</h2> 102 + <p class="text-surface-700 dark:text-surface-300 mb-4">You can:</p> 103 + <ul class="list-disc pl-5 mb-4 text-surface-700 dark:text-surface-300 space-y-1"> 104 + <li>Delete any annotation, highlight, or bookmark you've created</li> 105 + <li>Delete your collections</li> 106 + <li>Export your data from your PDS</li> 107 + <li>Revoke the extension's access at any time</li> 108 + </ul> 109 + </section> 110 + 111 + <section class="mb-8"> 112 + <h2 class="text-xl font-bold text-surface-900 dark:text-white mb-4">Contact</h2> 113 + <p class="text-surface-700 dark:text-surface-300"> 114 + For privacy questions or concerns, contact us at <a href="mailto:hello@margin.at" class="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 hover:underline">hello@margin.at</a> 115 + </p> 116 + </section> 117 + </div> 118 + </div> 119 + </BaseLayout>
+67
web/src/pages/terms.astro
··· 1 + --- 2 + import BaseLayout from '../layouts/BaseLayout.astro'; 3 + --- 4 + 5 + <BaseLayout title="Terms of Service - Margin" description="Margin Terms of Service"> 6 + <div class="max-w-3xl mx-auto py-12 px-4"> 7 + <a href="/home" class="inline-flex items-center gap-2 text-sm font-medium text-surface-500 hover:text-surface-900 dark:hover:text-white transition-colors mb-8"> 8 + <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m12 19-7-7 7-7"/><path d="M19 12H5"/></svg> 9 + <span>Home</span> 10 + </a> 11 + 12 + <div class="prose prose-surface dark:prose-invert max-w-none"> 13 + <h1 class="font-display font-bold text-3xl mb-2 text-surface-900 dark:text-white">Terms of Service</h1> 14 + <p class="text-surface-500 dark:text-surface-400 mb-8">Last updated: January 17, 2026</p> 15 + 16 + <section class="mb-8"> 17 + <h2 class="text-xl font-bold text-surface-900 dark:text-white mb-4">Overview</h2> 18 + <p class="text-surface-700 dark:text-surface-300 leading-relaxed"> 19 + Margin is an open-source project. By using our service, you agree to these terms ("Terms"). If you do not agree to these Terms, please do not use the Service. 20 + </p> 21 + </section> 22 + 23 + <section class="mb-8"> 24 + <h2 class="text-xl font-bold text-surface-900 dark:text-white mb-4">Open Source</h2> 25 + <p class="text-surface-700 dark:text-surface-300 leading-relaxed"> 26 + Margin is open source software. The code is available publicly and is provided "as is", without warranty of any kind, express or implied. 27 + </p> 28 + </section> 29 + 30 + <section class="mb-8"> 31 + <h2 class="text-xl font-bold text-surface-900 dark:text-white mb-4">User Conduct</h2> 32 + <p class="text-surface-700 dark:text-surface-300 mb-4"> 33 + You are responsible for your use of the Service and for any content you provide, including compliance with applicable laws, rules, and regulations. 34 + </p> 35 + <p class="text-surface-700 dark:text-surface-300 mb-4"> 36 + We reserve the right to remove any content that violates these terms, including but not limited to: 37 + </p> 38 + <ul class="list-disc pl-5 mb-4 text-surface-700 dark:text-surface-300 space-y-1"> 39 + <li>Illegal content</li> 40 + <li>Harassment or hate speech</li> 41 + <li>Spam or malicious content</li> 42 + </ul> 43 + </section> 44 + 45 + <section class="mb-8"> 46 + <h2 class="text-xl font-bold text-surface-900 dark:text-white mb-4">Decentralized Nature</h2> 47 + <p class="text-surface-700 dark:text-surface-300 leading-relaxed"> 48 + Margin interacts with the AT Protocol network. We do not control the network itself or the data stored on your Personal Data Server (PDS). Please refer to the terms of your PDS provider for data storage policies. 49 + </p> 50 + </section> 51 + 52 + <section class="mb-8"> 53 + <h2 class="text-xl font-bold text-surface-900 dark:text-white mb-4">Disclaimer</h2> 54 + <p class="text-surface-700 dark:text-surface-300 leading-relaxed uppercase"> 55 + THE SERVICE IS PROVIDED "AS IS" AND "AS AVAILABLE". WE DISCLAIM ALL CONDITIONS, REPRESENTATIONS AND WARRANTIES NOT EXPRESSLY SET OUT IN THESE TERMS. 56 + </p> 57 + </section> 58 + 59 + <section class="mb-8"> 60 + <h2 class="text-xl font-bold text-surface-900 dark:text-white mb-4">Contact</h2> 61 + <p class="text-surface-700 dark:text-surface-300"> 62 + For questions about these Terms, please contact us at <a href="mailto:hello@margin.at" class="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 hover:underline">hello@margin.at</a> 63 + </p> 64 + </section> 65 + </div> 66 + </div> 67 + </BaseLayout>
+20
web/src/routes/paths.ts
··· 1 + export const ROUTES = { 2 + HOME: "/home", 3 + LOGIN: "/login", 4 + SETTINGS: "/settings", 5 + NOTIFICATIONS: "/notifications", 6 + BOOKMARKS: "/bookmarks", 7 + HIGHLIGHTS: "/highlights", 8 + COLLECTIONS: "/collections", 9 + PROFILE: "/profile", 10 + PROFILE_DID: "/profile/:did", 11 + NEW: "/new", 12 + URL: "/url", 13 + COLLECTION_DETAIL: "/:handle/collection/:rkey", 14 + ANNOTATION_AT: "/at/:did/:rkey", 15 + ANNOTATION_URI: "/annotation/:uri", 16 + ANNOTATION_HANDLE: "/:handle/annotation/:rkey", 17 + HIGHLIGHT_HANDLE: "/:handle/highlight/:rkey", 18 + BOOKMARK_HANDLE: "/:handle/bookmark/:rkey", 19 + USER_URL: "/:handle/url/*", 20 + } as const;
+33
web/src/routes/wrappers.tsx
··· 1 + import React from "react"; 2 + import { Navigate, useParams } from "react-router-dom"; 3 + import { useStore } from "@nanostores/react"; 4 + import { $user } from "../store/auth"; 5 + import Profile from "../views/profile/Profile"; 6 + import CollectionDetail from "../views/collections/CollectionDetail"; 7 + import AnnotationDetail from "../views/content/AnnotationDetail"; 8 + import UserUrlPage from "../views/content/UserUrl"; 9 + 10 + export function ProfileWrapper() { 11 + const { did } = useParams(); 12 + if (!did) return <Navigate to="/home" replace />; 13 + return <Profile did={did} />; 14 + } 15 + 16 + export function SelfProfileWrapper() { 17 + const user = useStore($user); 18 + if (!user) return <Navigate to="/login" replace />; 19 + return <Navigate to={`/profile/${user.did}`} replace />; 20 + } 21 + 22 + export function CollectionDetailWrapper() { 23 + const { handle, rkey } = useParams(); 24 + return <CollectionDetail handle={handle} rkey={rkey} />; 25 + } 26 + 27 + export function AnnotationDetailWrapper() { 28 + return <AnnotationDetail />; 29 + } 30 + 31 + export function UserUrlWrapper() { 32 + return <UserUrlPage />; 33 + }
+10 -11
web/src/store/auth.ts
··· 1 - 2 - import { atom } from 'nanostores'; 3 - import { checkSession } from '../api/client'; 4 - import type { UserProfile } from '../types'; 1 + import { atom } from "nanostores"; 2 + import { checkSession } from "../api/client"; 3 + import type { UserProfile } from "../types"; 5 4 6 5 export const $user = atom<UserProfile | null>(null); 7 6 export const $isLoading = atom<boolean>(true); 8 7 9 8 export async function initAuth() { 10 - $isLoading.set(true); 11 - const session = await checkSession(); 12 - $user.set(session); 13 - $isLoading.set(false); 9 + $isLoading.set(true); 10 + const session = await checkSession(); 11 + $user.set(session); 12 + $isLoading.set(false); 14 13 } 15 14 16 15 export function logout() { 17 - fetch('/auth/logout', { method: 'POST' }).then(() => { 18 - window.location.href = '/'; 19 - }); 16 + fetch("/auth/logout", { method: "POST" }).then(() => { 17 + window.location.href = "/"; 18 + }); 20 19 }
+32
web/src/store/preferences.ts
··· 1 + import { atom } from "nanostores"; 2 + import { getPreferences, updatePreferences } from "../api/client"; 3 + 4 + export interface Preferences { 5 + externalLinkSkippedHostnames: string[]; 6 + } 7 + 8 + export const $preferences = atom<Preferences>({ 9 + externalLinkSkippedHostnames: [], 10 + }); 11 + 12 + export async function loadPreferences() { 13 + const prefs = await getPreferences(); 14 + $preferences.set({ 15 + externalLinkSkippedHostnames: prefs.externalLinkSkippedHostnames || [], 16 + }); 17 + } 18 + 19 + export async function addSkippedHostname(hostname: string) { 20 + const current = $preferences.get(); 21 + if (current.externalLinkSkippedHostnames.includes(hostname)) return; 22 + 23 + const newHostnames = [...current.externalLinkSkippedHostnames, hostname]; 24 + $preferences.set({ 25 + ...current, 26 + externalLinkSkippedHostnames: newHostnames, 27 + }); 28 + 29 + await updatePreferences({ 30 + externalLinkSkippedHostnames: newHostnames, 31 + }); 32 + }
+52 -51
web/src/store/theme.ts
··· 1 + import { atom, onMount } from "nanostores"; 1 2 2 - import { atom, onMount } from 'nanostores'; 3 + export type Theme = "light" | "dark" | "system"; 4 + export type Layout = "sidebar" | "compact"; 3 5 4 - export type Theme = 'light' | 'dark' | 'system'; 5 - export type Layout = 'sidebar' | 'compact'; 6 - 7 - export const $theme = atom<Theme>('system'); 8 - export const $layout = atom<Layout>('sidebar'); 6 + export const $theme = atom<Theme>("system"); 7 + export const $layout = atom<Layout>("sidebar"); 9 8 10 9 onMount($theme, () => { 11 - if (typeof window !== 'undefined') { 12 - const stored = localStorage.getItem('theme') as Theme | null; 13 - if (stored && ['light', 'dark', 'system'].includes(stored)) { 14 - $theme.set(stored); 15 - } 16 - applyTheme($theme.get()); 10 + if (typeof window !== "undefined") { 11 + const stored = localStorage.getItem("theme") as Theme | null; 12 + if (stored && ["light", "dark", "system"].includes(stored)) { 13 + $theme.set(stored); 17 14 } 15 + applyTheme($theme.get()); 16 + } 18 17 19 - return $theme.subscribe((theme) => { 20 - if (typeof window !== 'undefined') { 21 - localStorage.setItem('theme', theme); 22 - applyTheme(theme); 23 - } 24 - }); 18 + return $theme.subscribe((theme) => { 19 + if (typeof window !== "undefined") { 20 + localStorage.setItem("theme", theme); 21 + applyTheme(theme); 22 + } 23 + }); 25 24 }); 26 25 27 26 onMount($layout, () => { 28 - if (typeof window !== 'undefined') { 29 - const stored = localStorage.getItem('layout_preference') as Layout | null; 30 - if (stored && ['sidebar', 'compact'].includes(stored)) { 31 - $layout.set(stored); 32 - } 27 + if (typeof window !== "undefined") { 28 + const stored = localStorage.getItem("layout_preference") as Layout | null; 29 + if (stored && ["sidebar", "compact"].includes(stored)) { 30 + $layout.set(stored); 33 31 } 32 + } 34 33 35 - return $layout.subscribe((layout) => { 36 - if (typeof window !== 'undefined') { 37 - localStorage.setItem('layout_preference', layout); 38 - } 39 - }); 34 + return $layout.subscribe((layout) => { 35 + if (typeof window !== "undefined") { 36 + localStorage.setItem("layout_preference", layout); 37 + } 38 + }); 40 39 }); 41 40 42 41 function applyTheme(theme: Theme) { 43 - const root = window.document.documentElement; 44 - root.classList.remove('light', 'dark'); 45 - delete root.dataset.theme; 42 + const root = window.document.documentElement; 43 + root.classList.remove("light", "dark"); 44 + delete root.dataset.theme; 46 45 47 - if (theme === 'system') { 48 - const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches 49 - ? 'dark' 50 - : 'light'; 51 - root.dataset.theme = systemTheme; 52 - } else { 53 - root.dataset.theme = theme; 54 - } 46 + if (theme === "system") { 47 + const systemTheme = window.matchMedia("(prefers-color-scheme: dark)") 48 + .matches 49 + ? "dark" 50 + : "light"; 51 + root.dataset.theme = systemTheme; 52 + } else { 53 + root.dataset.theme = theme; 54 + } 55 55 } 56 56 57 - if (typeof window !== 'undefined') { 58 - const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); 59 - mediaQuery.addEventListener('change', () => { 60 - if ($theme.get() === 'system') { 61 - applyTheme('system'); 62 - } 63 - }); 57 + if (typeof window !== "undefined") { 58 + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); 59 + mediaQuery.addEventListener("change", () => { 60 + if ($theme.get() === "system") { 61 + applyTheme("system"); 62 + } 63 + }); 64 64 } 65 65 66 66 export function setTheme(theme: Theme) { 67 - $theme.set(theme); 67 + $theme.set(theme); 68 68 } 69 69 70 70 export function setLayout(layout: Layout) { 71 - $layout.set(layout); 71 + $layout.set(layout); 72 72 } 73 73 74 74 export function cycleTheme() { 75 - const current = $theme.get(); 76 - const next: Theme = current === 'system' ? 'light' : current === 'light' ? 'dark' : 'system'; 77 - $theme.set(next); 75 + const current = $theme.get(); 76 + const next: Theme = 77 + current === "system" ? "light" : current === "light" ? "dark" : "system"; 78 + $theme.set(next); 78 79 }
+89 -17
web/src/styles/global.css
··· 3 3 @tailwind utilities; 4 4 5 5 @layer base { 6 - html { 7 - font-family: 'Inter', system-ui, sans-serif; 8 - background-color: #fafafa; 9 - color: #18181b; 10 - -webkit-font-smoothing: antialiased; 11 - -moz-osx-font-smoothing: grayscale; 12 - } 6 + html { 7 + font-family: "Inter", system-ui, sans-serif; 8 + -webkit-font-smoothing: antialiased; 9 + -moz-osx-font-smoothing: grayscale; 10 + } 11 + 12 + html { 13 + background-color: #fafafa; 14 + color: #18181b; 15 + } 16 + 17 + html[data-theme="dark"] { 18 + background-color: #09090b; 19 + color: #fafafa; 20 + } 21 + 22 + h1, 23 + h2, 24 + h3, 25 + h4, 26 + h5, 27 + h6 { 28 + font-family: "Outfit", sans-serif; 29 + letter-spacing: -0.02em; 30 + } 31 + } 32 + 33 + @layer utilities { 34 + .animate-fade-in { 35 + animation: fadeIn 0.3s ease-out; 36 + } 13 37 14 - h1, 15 - h2, 16 - h3, 17 - h4, 18 - h5, 19 - h6 { 20 - font-family: 'Outfit', sans-serif; 21 - letter-spacing: -0.02em; 22 - } 23 - } 38 + .animate-slide-up { 39 + animation: slideUp 0.3s ease-out; 40 + } 41 + 42 + .animate-scale-in { 43 + animation: scaleIn 0.2s ease-out; 44 + } 45 + 46 + .focus-ring { 47 + @apply focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:ring-offset-2 focus:ring-offset-white dark:focus:ring-offset-surface-950; 48 + } 49 + 50 + .glass { 51 + @apply bg-white/80 dark:bg-surface-900/80 backdrop-blur-xl; 52 + } 53 + 54 + .card { 55 + @apply bg-white dark:bg-surface-900 rounded-lg shadow-sm ring-1 ring-black/5 dark:ring-white/5; 56 + } 57 + 58 + .transition-default { 59 + @apply transition-all duration-200 ease-out; 60 + } 61 + } 62 + 63 + @keyframes fadeIn { 64 + from { 65 + opacity: 0; 66 + } 67 + 68 + to { 69 + opacity: 1; 70 + } 71 + } 72 + 73 + @keyframes slideUp { 74 + from { 75 + opacity: 0; 76 + transform: translateY(8px); 77 + } 78 + 79 + to { 80 + opacity: 1; 81 + transform: translateY(0); 82 + } 83 + } 84 + 85 + @keyframes scaleIn { 86 + from { 87 + opacity: 0; 88 + transform: scale(0.95); 89 + } 90 + 91 + to { 92 + opacity: 1; 93 + transform: scale(1); 94 + } 95 + }
+84 -76
web/src/types.ts
··· 1 - 2 1 export interface UserProfile { 3 - did: string; 4 - handle: string; 5 - displayName?: string; 6 - description?: string; 7 - avatar?: string; 8 - banner?: string; 9 - website?: string; 10 - links?: string[]; 11 - followersCount?: number; 12 - followsCount?: number; 13 - postsCount?: number; 2 + did: string; 3 + handle: string; 4 + displayName?: string; 5 + description?: string; 6 + avatar?: string; 7 + banner?: string; 8 + website?: string; 9 + links?: string[]; 10 + followersCount?: number; 11 + followsCount?: number; 12 + postsCount?: number; 14 13 } 15 14 16 15 export interface Selector { 17 - exact: string; 18 - prefix?: string; 19 - suffix?: string; 20 - start?: number; 21 - end?: number; 16 + exact: string; 17 + prefix?: string; 18 + suffix?: string; 19 + start?: number; 20 + end?: number; 22 21 } 23 22 24 23 export interface Target { 25 - source: string; 26 - title?: string; 27 - selector?: Selector; 24 + source: string; 25 + title?: string; 26 + selector?: Selector; 28 27 } 29 28 30 29 export interface AnnotationBody { 31 - type: 'TextualBody'; 32 - value: string; 33 - format: 'text/plain'; 30 + type: "TextualBody"; 31 + value: string; 32 + format: "text/plain"; 34 33 } 35 34 36 35 export interface Param { 37 - id: string; 38 - value: string; 36 + id: string; 37 + value: string; 39 38 } 40 39 41 40 export interface AnnotationItem { 41 + uri: string; 42 + id?: string; 43 + cid: string; 44 + author: UserProfile; 45 + creator?: UserProfile; 46 + target?: Target; 47 + source?: string; 48 + body?: AnnotationBody; 49 + motivation: "highlighting" | "commenting" | "bookmarking" | string; 50 + type?: string; 51 + createdAt: string; 52 + text?: string; 53 + title?: string; 54 + description?: string; 55 + color?: string; 56 + tags?: string[]; 57 + likeCount?: number; 58 + replyCount?: number; 59 + repostCount?: number; 60 + children?: AnnotationItem[]; 61 + viewer?: { 62 + like?: string; 63 + }; 64 + collection?: { 42 65 uri: string; 43 - id?: string; 44 - cid: string; 45 - author: UserProfile; 46 - creator?: UserProfile; 47 - target: Target; 48 - body?: AnnotationBody; 49 - motivation: 'highlighting' | 'commenting' | 'bookmarking' | string; 50 - type?: string; 51 - createdAt: string; 52 - text?: string; 53 - title?: string; 54 - color?: 'yellow' | 'green' | 'red' | 'blue'; 55 - tags?: string[]; 56 - likeCount?: number; 57 - replyCount?: number; 58 - repostCount?: number; 59 - children?: AnnotationItem[]; 60 - viewer?: { 61 - like?: string; 62 - } 63 - collection?: { 64 - uri: string; 65 - name: string; 66 - icon?: string; 67 - }; 68 - addedBy?: UserProfile; 69 - collectionItemUri?: string; 66 + name: string; 67 + icon?: string; 68 + }; 69 + addedBy?: UserProfile; 70 + collectionItemUri?: string; 70 71 } 71 72 72 73 export type ActorSearchItem = UserProfile; 73 74 74 75 export interface FeedResponse { 75 - cursor?: string; 76 - items: AnnotationItem[]; 76 + cursor?: string; 77 + items: AnnotationItem[]; 77 78 } 78 79 79 80 export interface NotificationItem { 80 - id: number; 81 - recipient: UserProfile; 82 - actor: UserProfile; 83 - type: 'reply' | 'quote' | 'highlight' | 'bookmark' | 'annotation'; 84 - subjectUri: string; 85 - subject?: any; 86 - createdAt: string; 87 - readAt?: string; 81 + id: number; 82 + recipient: UserProfile; 83 + actor: UserProfile; 84 + type: 85 + | "reply" 86 + | "quote" 87 + | "highlight" 88 + | "bookmark" 89 + | "annotation" 90 + | "like" 91 + | "follow"; 92 + subjectUri: string; 93 + subject?: any; 94 + createdAt: string; 95 + readAt?: string; 88 96 } 89 97 90 98 export interface Collection { 91 - id: string; 92 - uri: string; 93 - name: string; 94 - description?: string; 95 - icon?: string; 96 - creator: UserProfile; 97 - createdAt: string; 98 - itemCount: number; 99 - items?: AnnotationItem[]; 99 + id: string; 100 + uri: string; 101 + name: string; 102 + description?: string; 103 + icon?: string; 104 + creator: UserProfile; 105 + createdAt: string; 106 + itemCount: number; 107 + items?: AnnotationItem[]; 100 108 } 101 109 102 110 export interface CollectionItem { 103 - id: string; 104 - collectionId: string; 105 - subjectUri: string; 106 - createdAt: string; 107 - annotation?: AnnotationItem; 111 + id: string; 112 + collectionId: string; 113 + subjectUri: string; 114 + createdAt: string; 115 + annotation?: AnnotationItem; 108 116 }
-234
web/src/views/AnnotationDetail.tsx
··· 1 - import React, { useEffect, useState } from 'react'; 2 - import { useParams, Link, useLocation, useNavigate } from 'react-router-dom'; 3 - import { useStore } from '@nanostores/react'; 4 - import { $user } from '../store/auth'; 5 - import { getAnnotation, getReplies, resolveHandle, createReply, deleteReply } from '../api/client'; 6 - import type { AnnotationItem } from '../types'; 7 - import Card from '../components/Card'; 8 - import ReplyList from '../components/ReplyList'; 9 - import { Loader2, MessageSquare, ArrowLeft, X, AlertTriangle } from 'lucide-react'; 10 - import { clsx } from 'clsx'; 11 - import { getAvatarUrl } from '../api/client'; 12 - 13 - export default function AnnotationDetail() { 14 - const { uri, did, rkey, handle, type } = useParams(); 15 - const location = useLocation(); 16 - const navigate = useNavigate(); 17 - const user = useStore($user); 18 - 19 - const [annotation, setAnnotation] = useState<AnnotationItem | null>(null); 20 - const [replies, setReplies] = useState<AnnotationItem[]>([]); 21 - const [loading, setLoading] = useState(true); 22 - const [error, setError] = useState<string | null>(null); 23 - 24 - const [replyText, setReplyText] = useState(""); 25 - const [posting, setPosting] = useState(false); 26 - const [replyingTo, setReplyingTo] = useState<AnnotationItem | null>(null); 27 - 28 - const [targetUri, setTargetUri] = useState<string | null>(uri || null); 29 - 30 - useEffect(() => { 31 - async function resolve() { 32 - if (uri) { 33 - setTargetUri(decodeURIComponent(uri)); 34 - return; 35 - } 36 - 37 - if (handle && rkey) { 38 - let collection = "at.margin.annotation"; 39 - if (type === "highlight" || location.pathname.includes("/highlight/")) collection = "at.margin.highlight"; 40 - if (type === "bookmark" || location.pathname.includes("/bookmark/")) collection = "at.margin.bookmark"; 41 - 42 - try { 43 - const resolvedDid = await resolveHandle(handle); 44 - if (resolvedDid) { 45 - setTargetUri(`at://${resolvedDid}/${collection}/${rkey}`); 46 - } else { 47 - throw new Error("Could not resolve handle"); 48 - } 49 - } catch (e: any) { 50 - setError("Failed to resolve handle: " + e.message); 51 - setLoading(false); 52 - } 53 - } else if (did && rkey) { 54 - setTargetUri(`at://${did}/at.margin.annotation/${rkey}`); 55 - } else { 56 - const pathParts = location.pathname.split("/"); 57 - const atIndex = pathParts.indexOf("at"); 58 - if (atIndex !== -1 && pathParts[atIndex + 1] && pathParts[atIndex + 2]) { 59 - setTargetUri(`at://${pathParts[atIndex + 1]}/at.margin.annotation/${pathParts[atIndex + 2]}`); 60 - } 61 - } 62 - } 63 - resolve(); 64 - }, [uri, did, rkey, handle, type, location.pathname]); 65 - 66 - const refreshReplies = async () => { 67 - if (!targetUri) return; 68 - const repliesData = await getReplies(targetUri); 69 - setReplies(repliesData.items || []); 70 - }; 71 - 72 - useEffect(() => { 73 - async function fetchData() { 74 - if (!targetUri) return; 75 - 76 - try { 77 - setLoading(true); 78 - const [annData, repliesData] = await Promise.all([ 79 - getAnnotation(targetUri), 80 - getReplies(targetUri).catch(() => ({ items: [] as AnnotationItem[] })), 81 - ]); 82 - 83 - if (!annData) { 84 - setError("Annotation not found"); 85 - } else { 86 - setAnnotation(annData); 87 - setReplies(repliesData.items || []); 88 - } 89 - } catch (err: any) { 90 - setError(err.message); 91 - } finally { 92 - setLoading(false); 93 - } 94 - } 95 - fetchData(); 96 - }, [targetUri]); 97 - 98 - const handleReply = async (e?: React.FormEvent) => { 99 - if (e) e.preventDefault(); 100 - if (!replyText.trim() || !annotation || !targetUri) return; 101 - 102 - try { 103 - setPosting(true); 104 - const parentUri = replyingTo ? (replyingTo.uri || replyingTo.id) : targetUri; 105 - const parentCid = replyingTo ? (replyingTo.cid) : annotation.cid; 106 - 107 - if (!parentUri || !parentCid || !annotation.cid) throw new Error("Missing parent info"); 108 - 109 - await createReply(parentUri, parentCid, targetUri, annotation.cid, replyText); 110 - 111 - setReplyText(""); 112 - setReplyingTo(null); 113 - await refreshReplies(); 114 - } catch (err: any) { 115 - alert("Failed to post reply: " + err.message); 116 - } finally { 117 - setPosting(false); 118 - } 119 - }; 120 - 121 - const handleDeleteReply = async (reply: AnnotationItem) => { 122 - if (!window.confirm("Delete this reply?")) return; 123 - try { 124 - await deleteReply(reply.uri || reply.id!); 125 - await refreshReplies(); 126 - } catch (err: any) { 127 - alert("Failed to delete: " + err.message); 128 - } 129 - }; 130 - 131 - if (loading) { 132 - return ( 133 - <div className="flex justify-center py-20"> 134 - <Loader2 className="animate-spin text-primary-600 dark:text-primary-400" size={32} /> 135 - </div> 136 - ); 137 - } 138 - 139 - if (error || !annotation) { 140 - return ( 141 - <div className="max-w-md mx-auto py-12 px-4 text-center"> 142 - <div className="w-14 h-14 bg-surface-100 dark:bg-surface-800 rounded-full flex items-center justify-center mx-auto mb-4 text-surface-400 dark:text-surface-500"> 143 - <AlertTriangle size={28} /> 144 - </div> 145 - <h3 className="text-xl font-bold text-surface-900 dark:text-white mb-2">Not found</h3> 146 - <p className="text-surface-500 dark:text-surface-400 text-sm mb-6">{error || "This may have been deleted."}</p> 147 - <Link to="/home" className="px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg transition-colors"> 148 - Back to Feed 149 - </Link> 150 - </div> 151 - ); 152 - } 153 - 154 - return ( 155 - <div className="max-w-2xl mx-auto pb-20"> 156 - <div className="mb-4"> 157 - <Link to="/home" className="inline-flex items-center gap-1.5 text-sm font-medium text-surface-500 dark:text-surface-400 hover:text-surface-900 dark:hover:text-white transition-colors"> 158 - <ArrowLeft size={16} /> 159 - Back 160 - </Link> 161 - </div> 162 - 163 - <Card item={annotation} onDelete={() => navigate("/home")} /> 164 - 165 - {annotation.type !== 'Bookmark' && annotation.type !== 'Highlight' && !annotation.motivation?.includes('bookmark') && !annotation.motivation?.includes('highlight') && ( 166 - <div className="mt-6"> 167 - <h3 className="flex items-center gap-2 text-sm font-semibold text-surface-500 dark:text-surface-400 uppercase tracking-wider mb-4"> 168 - <MessageSquare size={16} /> 169 - Replies ({replies.length}) 170 - </h3> 171 - 172 - {user ? ( 173 - <div className="bg-white dark:bg-surface-900 rounded-xl ring-1 ring-black/5 dark:ring-white/5 p-4 mb-4"> 174 - {replyingTo && ( 175 - <div className="flex items-center justify-between bg-surface-50 dark:bg-surface-800 px-3 py-2 rounded-lg mb-3 border border-surface-200 dark:border-surface-700"> 176 - <span className="text-sm text-surface-600 dark:text-surface-300"> 177 - Replying to <span className="font-medium text-surface-900 dark:text-white">@{(replyingTo.author || replyingTo.creator)?.handle || "unknown"}</span> 178 - </span> 179 - <button onClick={() => setReplyingTo(null)} className="text-surface-400 dark:text-surface-500 hover:text-surface-900 dark:hover:text-white p-1"> 180 - <X size={14} /> 181 - </button> 182 - </div> 183 - )} 184 - <div className="flex gap-3"> 185 - {getAvatarUrl(user.did, user.avatar) ? ( 186 - <img src={getAvatarUrl(user.did, user.avatar)} alt="" className="w-8 h-8 rounded-full object-cover bg-surface-100 dark:bg-surface-800" /> 187 - ) : ( 188 - <div className="w-8 h-8 rounded-full bg-surface-100 dark:bg-surface-800 flex items-center justify-center text-xs font-bold text-surface-400 dark:text-surface-500"> 189 - {user.handle?.[0]?.toUpperCase()} 190 - </div> 191 - )} 192 - <div className="flex-1"> 193 - <textarea 194 - value={replyText} 195 - onChange={(e) => setReplyText(e.target.value)} 196 - placeholder="Write a reply..." 197 - className="w-full p-0 border-0 focus:ring-0 text-surface-900 dark:text-white placeholder:text-surface-400 dark:placeholder:text-surface-500 resize-none min-h-[40px] appearance-none bg-transparent leading-relaxed" 198 - rows={2} 199 - disabled={posting} 200 - /> 201 - <div className="flex justify-end mt-2 pt-2 border-t border-surface-100 dark:border-surface-800"> 202 - <button 203 - className="px-4 py-1.5 bg-primary-600 hover:bg-primary-700 text-white text-sm font-medium rounded-full transition-colors disabled:opacity-50" 204 - disabled={posting || !replyText.trim()} 205 - onClick={() => handleReply()} 206 - > 207 - {posting ? "..." : "Reply"} 208 - </button> 209 - </div> 210 - </div> 211 - </div> 212 - </div> 213 - ) : ( 214 - <div className="bg-surface-50 dark:bg-surface-800/50 rounded-xl p-5 text-center mb-4 border border-dashed border-surface-200 dark:border-surface-700"> 215 - <p className="text-surface-500 dark:text-surface-400 text-sm mb-2">Sign in to reply</p> 216 - <Link to="/login" className="text-primary-600 dark:text-primary-400 font-medium hover:underline text-sm"> 217 - Log in 218 - </Link> 219 - </div> 220 - )} 221 - 222 - <ReplyList 223 - replies={replies} 224 - rootUri={targetUri || ""} 225 - user={user} 226 - onReply={(reply) => setReplyingTo(reply)} 227 - onDelete={handleDeleteReply} 228 - isInline={false} 229 - /> 230 - </div> 231 - )} 232 - </div> 233 - ); 234 - }
-159
web/src/views/CollectionDetail.tsx
··· 1 - import React, { useEffect, useState } from 'react'; 2 - import { getCollection, getCollectionItems, deleteCollection, removeCollectionItem, resolveHandle } from '../api/client'; 3 - import { Loader2, ArrowLeft, Trash2, Plus } from 'lucide-react'; 4 - import CollectionIcon from '../components/CollectionIcon'; 5 - import ShareMenu from '../components/ShareMenu'; 6 - import Card from '../components/Card'; 7 - import { useStore } from '@nanostores/react'; 8 - import { $user } from '../store/auth'; 9 - import type { Collection, AnnotationItem } from '../types'; 10 - 11 - interface CollectionDetailProps { 12 - handle?: string; 13 - rkey?: string; 14 - uri?: string; 15 - } 16 - 17 - export default function CollectionDetail({ handle, rkey, uri }: CollectionDetailProps) { 18 - const user = useStore($user); 19 - const [collection, setCollection] = useState<Collection | null>(null); 20 - const [items, setItems] = useState<AnnotationItem[]>([]); 21 - const [loading, setLoading] = useState(true); 22 - const [error, setError] = useState<string | null>(null); 23 - 24 - useEffect(() => { 25 - loadData(); 26 - }, [handle, rkey, uri]); 27 - 28 - const loadData = async () => { 29 - setLoading(true); 30 - try { 31 - let targetUri = uri; 32 - if (!targetUri && handle && rkey) { 33 - if (handle.startsWith('did:')) { 34 - targetUri = `at://${handle}/at.margin.collection/${rkey}`; 35 - } else { 36 - const did = await resolveHandle(handle); 37 - if (did) { 38 - targetUri = `at://${did}/at.margin.collection/${rkey}`; 39 - } else { 40 - setError("Collection not found"); 41 - setLoading(false); 42 - return; 43 - } 44 - } 45 - } 46 - 47 - if (targetUri) { 48 - const col = await getCollection(targetUri); 49 - if (col) { 50 - setCollection(col); 51 - const colItems = await getCollectionItems(col.uri); 52 - setItems(colItems); 53 - } else { 54 - setError("Collection not found"); 55 - } 56 - } 57 - } catch (e) { 58 - setError("Failed to load collection"); 59 - } finally { 60 - setLoading(false); 61 - } 62 - }; 63 - 64 - const handleDelete = async () => { 65 - if (!collection) return; 66 - if (window.confirm('Delete this collection?')) { 67 - await deleteCollection(collection.id); 68 - window.location.href = '/collections'; 69 - } 70 - }; 71 - 72 - const handleRemoveItem = async (item: AnnotationItem) => { 73 - if (!item.collectionItemUri) return; 74 - if (!window.confirm('Remove from collection?')) return; 75 - const success = await removeCollectionItem(item.collectionItemUri); 76 - if (success) { 77 - setItems(prev => prev.filter(i => i.collectionItemUri !== item.collectionItemUri)); 78 - } 79 - }; 80 - 81 - if (loading) { 82 - return ( 83 - <div className="flex justify-center py-20"> 84 - <Loader2 className="animate-spin text-primary-600 dark:text-primary-400" size={32} /> 85 - </div> 86 - ); 87 - } 88 - 89 - if (error || !collection) { 90 - return <div className="text-center py-20 text-red-500 dark:text-red-400">{error || "Collection not found"}</div>; 91 - } 92 - 93 - const isOwner = user?.did === collection.creator?.did; 94 - 95 - return ( 96 - <div className="animate-fade-in max-w-2xl mx-auto"> 97 - <a href="/collections" className="inline-flex items-center gap-1.5 text-sm font-medium text-surface-500 dark:text-surface-400 hover:text-surface-900 dark:hover:text-white mb-4 transition-colors"> 98 - <ArrowLeft size={16} /> 99 - Collections 100 - </a> 101 - 102 - <div className="bg-white dark:bg-surface-900 rounded-xl p-4 ring-1 ring-black/5 dark:ring-white/5 mb-4"> 103 - <div className="flex items-start gap-3"> 104 - <div className="p-2 bg-primary-50 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400 rounded-lg"> 105 - <CollectionIcon icon={collection.icon} size={24} /> 106 - </div> 107 - <div className="flex-1 min-w-0"> 108 - <h1 className="text-xl font-bold text-surface-900 dark:text-white truncate">{collection.name}</h1> 109 - {collection.description && ( 110 - <p className="text-surface-600 dark:text-surface-300 text-sm mt-1">{collection.description}</p> 111 - )} 112 - <div className="flex items-center gap-2 mt-2 text-xs text-surface-500 dark:text-surface-400"> 113 - <span className="font-medium bg-surface-100 dark:bg-surface-800 px-2 py-0.5 rounded"> 114 - {items.length} items 115 - </span> 116 - <span>by {collection.creator.displayName || collection.creator.handle}</span> 117 - </div> 118 - </div> 119 - <div className="flex items-center gap-1"> 120 - <ShareMenu 121 - uri={collection.uri} 122 - handle={collection.creator.handle} 123 - type="Collection" 124 - text={collection.name} 125 - /> 126 - {isOwner && ( 127 - <button onClick={handleDelete} className="p-2 text-surface-400 dark:text-surface-500 hover:text-red-500 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"> 128 - <Trash2 size={18} /> 129 - </button> 130 - )} 131 - </div> 132 - </div> 133 - </div> 134 - 135 - <div className="space-y-2"> 136 - {items.length === 0 ? ( 137 - <div className="text-center py-12 text-surface-500 dark:text-surface-400 bg-surface-50 dark:bg-surface-800/50 rounded-xl border border-dashed border-surface-200 dark:border-surface-700"> 138 - <Plus size={28} className="mx-auto mb-2 text-surface-300 dark:text-surface-600" /> 139 - <p className="text-sm">Collection is empty</p> 140 - </div> 141 - ) : ( 142 - items.map(item => ( 143 - <div key={item.uri} className="relative group"> 144 - <Card item={item} hideShare /> 145 - {isOwner && item.collectionItemUri && ( 146 - <button 147 - className="absolute top-3 right-3 p-1.5 bg-white/90 dark:bg-surface-800/90 backdrop-blur text-surface-400 dark:text-surface-500 hover:text-red-500 dark:hover:text-red-400 rounded-lg shadow-sm opacity-0 group-hover:opacity-100 transition-all" 148 - onClick={() => handleRemoveItem(item)} 149 - > 150 - <Trash2 size={14} /> 151 - </button> 152 - )} 153 - </div> 154 - )) 155 - )} 156 - </div> 157 - </div> 158 - ); 159 - }
-196
web/src/views/Collections.tsx
··· 1 - import React, { useEffect, useState } from 'react'; 2 - import { getCollections, createCollection, deleteCollection } from '../api/client'; 3 - import { Loader2, Plus, Folder, Trash2, X } from 'lucide-react'; 4 - import CollectionIcon, { ICON_MAP } from '../components/CollectionIcon'; 5 - import { useStore } from '@nanostores/react'; 6 - import { $user } from '../store/auth'; 7 - import type { Collection } from '../types'; 8 - import { formatDistanceToNow } from 'date-fns'; 9 - import { clsx } from 'clsx'; 10 - 11 - export default function Collections() { 12 - const user = useStore($user); 13 - const [collections, setCollections] = useState<Collection[]>([]); 14 - const [loading, setLoading] = useState(true); 15 - const [showCreateModal, setShowCreateModal] = useState(false); 16 - const [newItemName, setNewItemName] = useState(''); 17 - const [newItemDesc, setNewItemDesc] = useState(''); 18 - const [newItemIcon, setNewItemIcon] = useState('folder'); 19 - 20 - useEffect(() => { 21 - loadCollections(); 22 - }, []); 23 - 24 - const loadCollections = async () => { 25 - setLoading(true); 26 - const data = await getCollections(); 27 - setCollections(data); 28 - setLoading(false); 29 - }; 30 - 31 - const handleCreate = async (e: React.FormEvent) => { 32 - e.preventDefault(); 33 - if (!newItemName.trim()) return; 34 - 35 - const res = await createCollection(newItemName, newItemDesc); 36 - if (res) { 37 - setCollections([res, ...collections]); 38 - setShowCreateModal(false); 39 - setNewItemName(''); 40 - setNewItemDesc(''); 41 - setNewItemIcon('folder'); 42 - loadCollections(); 43 - } 44 - }; 45 - 46 - const handleDelete = async (id: string, e: React.MouseEvent) => { 47 - e.preventDefault(); 48 - if (window.confirm('Delete this collection?')) { 49 - const success = await deleteCollection(id); 50 - if (success) { 51 - setCollections(prev => prev.filter(c => c.id !== id)); 52 - } 53 - } 54 - }; 55 - 56 - if (loading) { 57 - return ( 58 - <div className="flex justify-center py-20"> 59 - <Loader2 className="animate-spin text-primary-600 dark:text-primary-400" size={32} /> 60 - </div> 61 - ); 62 - } 63 - 64 - return ( 65 - <div className="max-w-2xl mx-auto animate-fade-in"> 66 - <div className="flex items-center justify-between mb-6"> 67 - <div> 68 - <h1 className="text-2xl font-display font-bold text-surface-900 dark:text-white">Collections</h1> 69 - <p className="text-surface-500 dark:text-surface-400 text-sm mt-0.5">Organize your annotations and highlights</p> 70 - </div> 71 - <button 72 - onClick={() => setShowCreateModal(true)} 73 - className="flex items-center gap-1.5 px-3 py-2 bg-primary-600 text-white rounded-lg font-medium text-sm hover:bg-primary-500 transition-colors" 74 - > 75 - <Plus size={16} /> 76 - New 77 - </button> 78 - </div> 79 - 80 - {collections.length === 0 ? ( 81 - <div className="text-center py-16 text-surface-500 dark:text-surface-400 bg-surface-50/50 dark:bg-surface-800/50 rounded-xl border border-dashed border-surface-200 dark:border-surface-700"> 82 - <Folder size={40} className="mx-auto mb-3 text-surface-300 dark:text-surface-600" /> 83 - <p className="mb-3">No collections yet</p> 84 - <button 85 - onClick={() => setShowCreateModal(true)} 86 - className="text-primary-600 dark:text-primary-400 font-medium hover:underline" 87 - > 88 - Create your first collection 89 - </button> 90 - </div> 91 - ) : ( 92 - <div className="space-y-2"> 93 - {collections.map(collection => ( 94 - <a 95 - key={collection.id} 96 - href={`/${collection.creator?.handle || user?.handle}/collection/${collection.uri.split('/').pop()}`} 97 - className="group flex items-center gap-3 bg-white dark:bg-surface-900 rounded-lg p-3 ring-1 ring-black/5 dark:ring-white/5 hover:ring-primary-500/30 dark:hover:ring-primary-400/30 transition-all" 98 - > 99 - <div className="p-2 bg-primary-50 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400 rounded-lg"> 100 - <CollectionIcon icon={collection.icon} size={20} /> 101 - </div> 102 - <div className="flex-1 min-w-0"> 103 - <h3 className="font-medium text-surface-900 dark:text-white truncate">{collection.name}</h3> 104 - <p className="text-xs text-surface-500 dark:text-surface-400"> 105 - {collection.itemCount} items · {collection.createdAt && formatDistanceToNow(new Date(collection.createdAt), { addSuffix: true })} 106 - </p> 107 - </div> 108 - {!collection.uri.includes('network.cosmik') && ( 109 - <button 110 - onClick={(e) => handleDelete(collection.id, e)} 111 - className="p-2 text-surface-400 dark:text-surface-500 hover:text-red-500 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors opacity-0 group-hover:opacity-100" 112 - > 113 - <Trash2 size={16} /> 114 - </button> 115 - )} 116 - </a> 117 - ))} 118 - </div> 119 - )} 120 - 121 - {showCreateModal && ( 122 - <div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm"> 123 - <div className="bg-white dark:bg-surface-900 rounded-xl shadow-xl max-w-md w-full p-5 animate-scale-in ring-1 ring-black/5 dark:ring-white/10"> 124 - <div className="flex items-center justify-between mb-4"> 125 - <h2 className="text-lg font-bold text-surface-900 dark:text-white">New Collection</h2> 126 - <button onClick={() => setShowCreateModal(false)} className="p-1.5 text-surface-400 dark:text-surface-500 hover:bg-surface-100 dark:hover:bg-surface-800 rounded-lg"> 127 - <X size={18} /> 128 - </button> 129 - </div> 130 - <form onSubmit={handleCreate}> 131 - <div className="mb-4"> 132 - <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">Name</label> 133 - <input 134 - type="text" 135 - value={newItemName} 136 - onChange={e => setNewItemName(e.target.value)} 137 - className="w-full px-3 py-2 bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-lg text-surface-900 dark:text-white placeholder:text-surface-400 dark:placeholder:text-surface-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500 dark:focus:border-primary-400" 138 - placeholder="e.g. Design Inspiration" 139 - autoFocus 140 - required 141 - /> 142 - </div> 143 - <div className="mb-4"> 144 - <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">Icon</label> 145 - <div className="grid grid-cols-6 gap-2 p-2 bg-surface-50 dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-lg max-h-32 overflow-y-auto"> 146 - {Object.keys(ICON_MAP).map(key => { 147 - const Icon = ICON_MAP[key]; 148 - return ( 149 - <button 150 - key={key} 151 - type="button" 152 - onClick={() => setNewItemIcon(key)} 153 - className={clsx( 154 - "p-2 rounded-lg flex items-center justify-center transition-colors", 155 - newItemIcon === key 156 - ? "bg-primary-100 dark:bg-primary-900/50 text-primary-600 dark:text-primary-400 ring-1 ring-primary-500" 157 - : "hover:bg-surface-100 dark:hover:bg-surface-700 text-surface-500 dark:text-surface-400" 158 - )} 159 - > 160 - <Icon size={18} /> 161 - </button> 162 - ); 163 - })} 164 - </div> 165 - </div> 166 - <div className="mb-5"> 167 - <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">Description</label> 168 - <textarea 169 - value={newItemDesc} 170 - onChange={e => setNewItemDesc(e.target.value)} 171 - className="w-full px-3 py-2 bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-lg text-surface-900 dark:text-white placeholder:text-surface-400 dark:placeholder:text-surface-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500 dark:focus:border-primary-400 min-h-[60px] resize-none" 172 - placeholder="What's this collection for?" 173 - /> 174 - </div> 175 - <div className="flex justify-end gap-2"> 176 - <button 177 - type="button" 178 - onClick={() => setShowCreateModal(false)} 179 - className="px-4 py-2 text-surface-600 dark:text-surface-300 font-medium hover:bg-surface-100 dark:hover:bg-surface-800 rounded-lg transition-colors" 180 - > 181 - Cancel 182 - </button> 183 - <button 184 - type="submit" 185 - className="px-4 py-2 bg-primary-600 text-white font-medium rounded-lg hover:bg-primary-500 transition-colors" 186 - > 187 - Create 188 - </button> 189 - </div> 190 - </form> 191 - </div> 192 - </div> 193 - )} 194 - </div> 195 - ); 196 - }
-110
web/src/views/Feed.tsx
··· 1 - 2 - import React, { useEffect, useState } from 'react'; 3 - import { getFeed } from '../api/client'; 4 - import Card from '../components/Card'; 5 - import { Loader2 } from 'lucide-react'; 6 - import { useStore } from '@nanostores/react'; 7 - import { $user, initAuth } from '../store/auth'; 8 - import type { AnnotationItem } from '../types'; 9 - import { clsx } from 'clsx'; 10 - 11 - interface FeedProps { 12 - initialType?: string; 13 - motivation?: string; 14 - showTabs?: boolean; 15 - emptyMessage?: string; 16 - } 17 - 18 - export default function Feed({ 19 - initialType = 'all', 20 - motivation, 21 - showTabs = true, 22 - emptyMessage = "No items found." 23 - }: FeedProps) { 24 - const user = useStore($user); 25 - const [items, setItems] = useState<AnnotationItem[]>([]); 26 - const [loading, setLoading] = useState(true); 27 - const [activeTab, setActiveTab] = useState(initialType); 28 - 29 - useEffect(() => { 30 - initAuth(); 31 - }, []); 32 - 33 - useEffect(() => { 34 - const fetchFeed = async () => { 35 - setLoading(true); 36 - try { 37 - const type = activeTab === 'all' ? 'popular' : activeTab; 38 - const data = await getFeed({ type, motivation }); 39 - setItems(data?.items || []); 40 - } catch (e) { 41 - console.error(e); 42 - } finally { 43 - setLoading(false); 44 - } 45 - }; 46 - fetchFeed(); 47 - }, [activeTab, motivation]); 48 - 49 - const handleDelete = (uri: string) => { 50 - setItems((prev) => prev.filter(i => i.uri !== uri)); 51 - }; 52 - 53 - const tabs = [ 54 - { id: 'all', label: 'Popular' }, 55 - { id: 'shelved', label: 'Shelved' }, 56 - { id: 'margin', label: 'Margin' }, 57 - { id: 'semble', label: 'Semble' }, 58 - ]; 59 - 60 - if (!user && !loading) { 61 - return ( 62 - <div className="text-center py-20"> 63 - <h2 className="text-2xl font-display font-bold mb-4 tracking-tight text-surface-900 dark:text-white">Welcome to Margin</h2> 64 - <p className="text-surface-500 dark:text-surface-400 mb-8 text-lg">Your curated corner of the internet.</p> 65 - <a href="/login" className="px-6 py-3 bg-primary-600 text-white rounded-xl font-semibold shadow-sm hover:bg-primary-500 transition-colors"> 66 - Log In 67 - </a> 68 - </div> 69 - ) 70 - } 71 - 72 - return ( 73 - <div className="max-w-2xl mx-auto"> 74 - {showTabs && ( 75 - <div className="flex gap-1 bg-surface-100 dark:bg-surface-800 p-1 rounded-lg mb-6 w-fit"> 76 - {tabs.map(tab => ( 77 - <button 78 - key={tab.id} 79 - onClick={() => setActiveTab(tab.id)} 80 - className={clsx( 81 - "px-3 py-1.5 text-sm font-medium rounded-md transition-all", 82 - activeTab === tab.id 83 - ? "bg-white dark:bg-surface-700 text-surface-900 dark:text-white shadow-sm" 84 - : "text-surface-500 dark:text-surface-400 hover:text-surface-700 dark:hover:text-surface-200" 85 - )} 86 - > 87 - {tab.label} 88 - </button> 89 - ))} 90 - </div> 91 - )} 92 - 93 - {loading ? ( 94 - <div className="flex justify-center py-20"> 95 - <Loader2 className="animate-spin text-primary-600 dark:text-primary-400" size={32} /> 96 - </div> 97 - ) : items.length > 0 ? ( 98 - <div className="animate-fade-in"> 99 - {items.map((item) => ( 100 - <Card key={item.uri || item.cid} item={item} onDelete={handleDelete} /> 101 - ))} 102 - </div> 103 - ) : ( 104 - <div className="text-center py-20 text-surface-500 dark:text-surface-400 bg-surface-50/50 dark:bg-surface-800/50 rounded-2xl border border-dashed border-surface-200 dark:border-surface-700"> 105 - <p>{emptyMessage}</p> 106 - </div> 107 - )} 108 - </div> 109 - ); 110 - }
-238
web/src/views/Login.tsx
··· 1 - 2 - import React, { useState, useEffect, useRef } from 'react'; 3 - import { Link } from 'react-router-dom'; 4 - import { Loader2, AtSign } from 'lucide-react'; 5 - import { BlueskyIcon, MarginIcon } from '../components/Icons'; 6 - import SignUpModal from '../components/SignUpModal'; 7 - import { searchActors, startLogin, getAvatarUrl, type ActorSearchItem } from '../api/client'; 8 - 9 - export default function Login() { 10 - const [handle, setHandle] = useState(''); 11 - const [suggestions, setSuggestions] = useState<ActorSearchItem[]>([]); 12 - const [showSuggestions, setShowSuggestions] = useState(false); 13 - const [loading, setLoading] = useState(false); 14 - const [error, setError] = useState<string | null>(null); 15 - const [selectedIndex, setSelectedIndex] = useState(-1); 16 - const [showSignUp, setShowSignUp] = useState(false); 17 - 18 - const inputRef = useRef<HTMLInputElement>(null); 19 - const suggestionsRef = useRef<HTMLDivElement>(null); 20 - const isSelectionRef = useRef(false); 21 - 22 - const [providerIndex, setProviderIndex] = useState(0); 23 - const [morphClass, setMorphClass] = useState("opacity-100 translate-y-0 blur-0"); 24 - const providers = [ 25 - "AT Protocol", 26 - "Margin", 27 - "Bluesky", 28 - "Blacksky", 29 - "Tangled", 30 - "Northsky", 31 - "witchcraft.systems", 32 - "tophhie.social", 33 - "altq.net", 34 - ]; 35 - 36 - useEffect(() => { 37 - const cycleText = () => { 38 - setMorphClass("opacity-0 translate-y-2 blur-sm"); 39 - setTimeout(() => { 40 - setProviderIndex((prev) => (prev + 1) % providers.length); 41 - setMorphClass("opacity-100 translate-y-0 blur-0"); 42 - }, 400); 43 - }; 44 - const interval = setInterval(cycleText, 3000); 45 - return () => clearInterval(interval); 46 - }, [providers.length]); 47 - 48 - useEffect(() => { 49 - if (handle.length >= 3) { 50 - if (isSelectionRef.current) { 51 - isSelectionRef.current = false; 52 - return; 53 - } 54 - const timer = setTimeout(async () => { 55 - try { 56 - if (!handle.includes('.')) { 57 - const data = await searchActors(handle); 58 - setSuggestions(data.actors || []); 59 - setShowSuggestions(true); 60 - setSelectedIndex(-1); 61 - } 62 - } catch (e) { 63 - console.error("Search failed:", e); 64 - } 65 - }, 300); 66 - return () => clearTimeout(timer); 67 - } else { 68 - setSuggestions([]); 69 - setShowSuggestions(false); 70 - } 71 - }, [handle]); 72 - 73 - useEffect(() => { 74 - const handleClickOutside = (e: MouseEvent) => { 75 - if ( 76 - suggestionsRef.current && 77 - !suggestionsRef.current.contains(e.target as Node) && 78 - inputRef.current && 79 - !inputRef.current.contains(e.target as Node) 80 - ) { 81 - setShowSuggestions(false); 82 - } 83 - }; 84 - document.addEventListener("mousedown", handleClickOutside); 85 - return () => document.removeEventListener("mousedown", handleClickOutside); 86 - }, []); 87 - 88 - const handleKeyDown = (e: React.KeyboardEvent) => { 89 - if (!showSuggestions || suggestions.length === 0) return; 90 - 91 - if (e.key === "ArrowDown") { 92 - e.preventDefault(); 93 - setSelectedIndex((prev) => Math.min(prev + 1, suggestions.length - 1)); 94 - } else if (e.key === "ArrowUp") { 95 - e.preventDefault(); 96 - setSelectedIndex((prev) => Math.max(prev - 1, -1)); 97 - } else if (e.key === "Enter" && selectedIndex >= 0) { 98 - e.preventDefault(); 99 - selectSuggestion(suggestions[selectedIndex]); 100 - } else if (e.key === "Escape") { 101 - setShowSuggestions(false); 102 - } 103 - }; 104 - 105 - const selectSuggestion = (actor: ActorSearchItem) => { 106 - isSelectionRef.current = true; 107 - setHandle(actor.handle); 108 - setSuggestions([]); 109 - setShowSuggestions(false); 110 - inputRef.current?.blur(); 111 - }; 112 - 113 - const handleSubmit = async (e: React.FormEvent) => { 114 - e.preventDefault(); 115 - if (!handle.trim()) return; 116 - 117 - setLoading(true); 118 - setError(null); 119 - 120 - try { 121 - const result = await startLogin(handle.trim()); 122 - if (result.authorizationUrl) { 123 - window.location.href = result.authorizationUrl; 124 - } 125 - } catch (err: any) { 126 - setError(err.message || 'Failed to initiate login. Please try again.'); 127 - setLoading(false); 128 - } 129 - }; 130 - 131 - return ( 132 - <div className="min-h-screen flex items-center justify-center bg-surface-50 p-4"> 133 - <div className="w-full max-w-[440px] flex flex-col items-center"> 134 - 135 - <div className="flex items-center justify-center gap-6 mb-12"> 136 - <MarginIcon size={60} /> 137 - <span className="text-3xl font-light text-surface-300 pb-1">×</span> 138 - <div className="text-[#0285FF]"> 139 - <BlueskyIcon size={60} /> 140 - </div> 141 - </div> 142 - 143 - <h1 className="text-2xl font-bold font-display text-surface-900 mb-8 text-center leading-relaxed"> 144 - Sign in with your <br /> 145 - <span className={`inline-block transition-all duration-400 ease-out text-transparent bg-clip-text bg-gradient-to-r from-primary-600 to-indigo-600 ${morphClass}`}> 146 - {providers[providerIndex]} 147 - </span>{" "} 148 - handle 149 - </h1> 150 - 151 - <form onSubmit={handleSubmit} className="w-full flex flex-col gap-5"> 152 - <div className="relative"> 153 - <div className="absolute left-4 top-1/2 -translate-y-1/2 text-surface-400"> 154 - <AtSign size={20} className="stroke-[2.5]" /> 155 - </div> 156 - <input 157 - ref={inputRef} 158 - type="text" 159 - value={handle} 160 - onChange={(e) => setHandle(e.target.value)} 161 - onKeyDown={handleKeyDown} 162 - onFocus={() => handle.length >= 3 && suggestions.length > 0 && !handle.includes(".") && setShowSuggestions(true)} 163 - placeholder="handle.bsky.social" 164 - className="w-full pl-12 pr-4 py-3.5 bg-white border border-surface-200 rounded-xl shadow-sm outline-none focus:border-primary-500 focus:ring-4 focus:ring-primary-500/10 transition-all font-medium text-lg text-surface-900 placeholder:text-surface-400" 165 - autoCapitalize="none" 166 - autoCorrect="off" 167 - autoComplete="off" 168 - spellCheck={false} 169 - disabled={loading} 170 - /> 171 - 172 - {showSuggestions && suggestions.length > 0 && ( 173 - <div ref={suggestionsRef} className="absolute top-[calc(100%+8px)] left-0 right-0 bg-white/90 backdrop-blur-xl border border-surface-200 rounded-xl shadow-xl overflow-hidden z-50 animate-fade-in max-h-[300px] overflow-y-auto"> 174 - {suggestions.map((actor, index) => ( 175 - <button 176 - key={actor.did} 177 - type="button" 178 - className={`w-full flex items-center gap-3 px-4 py-3 border-b border-surface-100 last:border-0 hover:bg-surface-50 transition-colors text-left ${index === selectedIndex ? 'bg-surface-50' : ''}`} 179 - onClick={() => selectSuggestion(actor)} 180 - > 181 - {actor.avatar ? ( 182 - <img src={actor.avatar} alt={actor.handle} className="w-9 h-9 rounded-full bg-surface-100 object-cover shrink-0" /> 183 - ) : ( 184 - <div className="w-9 h-9 rounded-full bg-surface-100 flex items-center justify-center text-xs font-bold shrink-0"> 185 - {(actor.displayName || actor.handle).slice(0, 2).toUpperCase()} 186 - </div> 187 - )} 188 - <div className="min-w-0"> 189 - <div className="font-semibold text-surface-900 truncate text-sm"> 190 - {actor.displayName || actor.handle} 191 - </div> 192 - <div className="text-surface-500 text-xs truncate">@{actor.handle}</div> 193 - </div> 194 - </button> 195 - ))} 196 - </div> 197 - )} 198 - </div> 199 - 200 - {error && ( 201 - <div className="p-3 bg-red-50 text-red-600 text-sm rounded-lg border border-red-100 text-center font-medium"> 202 - {error} 203 - </div> 204 - )} 205 - 206 - <button 207 - type="submit" 208 - disabled={loading || !handle} 209 - className="w-full py-3.5 bg-surface-900 hover:bg-surface-800 text-white rounded-xl font-bold text-lg shadow-lg shadow-surface-900/10 disabled:opacity-50 disabled:cursor-not-allowed transition-all flex items-center justify-center gap-2" 210 - > 211 - {loading ? "Connecting..." : "Continue"} 212 - </button> 213 - 214 - <p className="text-center text-sm text-surface-400 mt-2"> 215 - By signing in, you agree to our <Link to="/terms" className="text-surface-900 hover:underline">Terms of Service</Link> and <Link to="/privacy" className="text-surface-900 hover:underline">Privacy Policy</Link>. 216 - </p> 217 - 218 - <div className="flex items-center gap-4 py-2"> 219 - <div className="h-px bg-surface-200 flex-1" /> 220 - <span className="text-xs font-bold text-surface-400 uppercase tracking-wider">or</span> 221 - <div className="h-px bg-surface-200 flex-1" /> 222 - </div> 223 - 224 - <button 225 - type="button" 226 - onClick={() => setShowSignUp(true)} 227 - className="w-full py-3.5 bg-transparent border-2 border-surface-200 hover:border-surface-400 hover:bg-surface-50 text-surface-700 rounded-xl font-bold transition-all" 228 - > 229 - Create New Account 230 - </button> 231 - </form> 232 - 233 - </div> 234 - 235 - {showSignUp && <SignUpModal onClose={() => setShowSignUp(false)} />} 236 - </div> 237 - ); 238 - }
-91
web/src/views/New.tsx
··· 1 - 2 - import React, { useState } from 'react'; 3 - import { useNavigate, useSearchParams, Link } from 'react-router-dom'; 4 - import { useStore } from '@nanostores/react'; 5 - import { $user } from '../store/auth'; 6 - import Composer from '../components/Composer'; 7 - 8 - export default function NewAnnotationPage() { 9 - const user = useStore($user); 10 - const navigate = useNavigate(); 11 - const [searchParams] = useSearchParams(); 12 - 13 - const initialUrl = searchParams.get("url") || ""; 14 - 15 - let initialSelector: any = null; 16 - const selectorParam = searchParams.get("selector"); 17 - if (selectorParam) { 18 - try { 19 - initialSelector = JSON.parse(selectorParam); 20 - } catch (e) { 21 - console.error("Failed to parse selector:", e); 22 - } 23 - } 24 - 25 - const legacyQuote = searchParams.get("quote") || ""; 26 - if (legacyQuote && !initialSelector) { 27 - initialSelector = { 28 - type: "TextQuoteSelector", 29 - exact: legacyQuote, 30 - }; 31 - } 32 - 33 - const [url, setUrl] = useState(initialUrl); 34 - 35 - if (!user) { 36 - return ( 37 - <div className="max-w-sm mx-auto py-16 px-4"> 38 - <div className="bg-white dark:bg-surface-900 rounded-xl ring-1 ring-black/5 dark:ring-white/5 p-6 text-center"> 39 - <h2 className="text-xl font-bold text-surface-900 dark:text-white mb-2">Sign in to create</h2> 40 - <p className="text-surface-500 dark:text-surface-400 text-sm mb-5">You need a Bluesky account</p> 41 - <Link to="/login" className="block w-full py-2.5 px-4 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg transition-colors"> 42 - Sign in with Bluesky 43 - </Link> 44 - </div> 45 - </div> 46 - ); 47 - } 48 - 49 - const handleSuccess = () => { 50 - navigate("/home"); 51 - }; 52 - 53 - return ( 54 - <div className="max-w-2xl mx-auto pb-20"> 55 - <div className="mb-6 text-center sm:text-left"> 56 - <h1 className="text-2xl font-display font-bold text-surface-900 dark:text-white mb-1">New Annotation</h1> 57 - <p className="text-surface-500 dark:text-surface-400">Write in the margins of the web</p> 58 - </div> 59 - 60 - {!initialUrl && ( 61 - <div className="mb-4"> 62 - <label htmlFor="url-input" className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1.5"> 63 - URL to annotate 64 - </label> 65 - <input 66 - id="url-input" 67 - type="url" 68 - value={url} 69 - onChange={(e) => setUrl(e.target.value)} 70 - placeholder="https://example.com/article" 71 - className="w-full p-3 bg-white dark:bg-surface-900 border border-surface-200 dark:border-surface-700 rounded-lg text-surface-900 dark:text-white placeholder:text-surface-400 dark:placeholder:text-surface-500 focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500 dark:focus:border-primary-400 outline-none transition-all" 72 - required 73 - /> 74 - </div> 75 - )} 76 - 77 - <div className="bg-white dark:bg-surface-900 rounded-xl ring-1 ring-black/5 dark:ring-white/5 p-5"> 78 - <Composer 79 - url={ 80 - (url || initialUrl) && !/^(?:f|ht)tps?:\/\//.test(url || initialUrl) 81 - ? `https://${url || initialUrl}` 82 - : url || initialUrl 83 - } 84 - selector={initialSelector} 85 - onSuccess={handleSuccess} 86 - onCancel={() => navigate(-1)} 87 - /> 88 - </div> 89 - </div> 90 - ); 91 - }
-105
web/src/views/Notifications.tsx
··· 1 - 2 - import React, { useEffect, useState } from 'react'; 3 - import { getNotifications, markNotificationsRead } from '../api/client'; 4 - import type { NotificationItem } from '../types'; 5 - import { Heart, MessageCircle, Star, User } from 'lucide-react'; 6 - import Card from '../components/Card'; 7 - import { formatDistanceToNow } from 'date-fns'; 8 - import { clsx } from 'clsx'; 9 - 10 - const NotificationIcon = ({ type }: { type: string }) => { 11 - switch (type) { 12 - case 'like': return <Heart size={18} className="text-red-500 fill-current" />; 13 - case 'reply': return <MessageCircle size={18} className="text-blue-500 fill-current" />; 14 - case 'follow': return <User size={18} className="text-primary-500 fill-current" />; 15 - case 'highlight': return <Star size={18} className="text-yellow-500 fill-current" />; 16 - default: return <Star size={18} className="text-surface-400 dark:text-surface-500" />; 17 - } 18 - }; 19 - 20 - export default function Notifications() { 21 - const [notifications, setNotifications] = useState<NotificationItem[]>([]); 22 - const [loading, setLoading] = useState(true); 23 - 24 - useEffect(() => { 25 - loadNotifications(); 26 - }, []); 27 - 28 - const loadNotifications = async () => { 29 - setLoading(true); 30 - const data = await getNotifications(); 31 - setNotifications(data); 32 - setLoading(false); 33 - markNotificationsRead(); 34 - }; 35 - 36 - if (loading) { 37 - return <div className="p-8 text-center text-surface-500 dark:text-surface-400">Loading activity...</div>; 38 - } 39 - 40 - if (notifications.length === 0) { 41 - return ( 42 - <div className="flex flex-col items-center justify-center py-16 bg-white dark:bg-surface-900 rounded-xl ring-1 ring-black/5 dark:ring-white/5"> 43 - <div className="w-12 h-12 bg-surface-100 dark:bg-surface-800 rounded-full flex items-center justify-center mb-3"> 44 - <Star size={24} className="text-surface-400 dark:text-surface-500" /> 45 - </div> 46 - <h3 className="text-lg font-semibold text-surface-900 dark:text-white mb-1">No activity yet</h3> 47 - <p className="text-surface-500 dark:text-surface-400 text-sm">Interactions with your content will appear here</p> 48 - </div> 49 - ); 50 - } 51 - 52 - return ( 53 - <div className="max-w-2xl mx-auto"> 54 - <h1 className="text-2xl font-display font-bold text-surface-900 dark:text-white mb-4">Activity</h1> 55 - <div className="space-y-2"> 56 - {notifications.map((n) => ( 57 - <div 58 - key={n.id} 59 - className={clsx( 60 - "bg-white dark:bg-surface-900 ring-1 ring-black/5 dark:ring-white/5 rounded-lg p-3 transition-colors", 61 - !n.readAt && "ring-primary-500/20 dark:ring-primary-400/20 bg-primary-50/30 dark:bg-primary-900/10" 62 - )} 63 - > 64 - <div className="flex gap-3"> 65 - <div className="shrink-0 mt-0.5"> 66 - <NotificationIcon type={n.type} /> 67 - </div> 68 - <div className="flex-1 min-w-0"> 69 - <div className="flex items-center gap-2 flex-wrap"> 70 - {n.actor.avatar ? ( 71 - <img src={n.actor.avatar} alt="" className="w-6 h-6 rounded-full" /> 72 - ) : ( 73 - <div className="w-6 h-6 rounded-full bg-surface-100 dark:bg-surface-800" /> 74 - )} 75 - <span className="font-medium text-surface-900 dark:text-white text-sm truncate"> 76 - {n.actor.displayName || n.actor.handle} 77 - </span> 78 - <span className="text-surface-500 dark:text-surface-400 text-sm"> 79 - {n.type === 'like' && 'liked your post'} 80 - {n.type === 'reply' && 'replied to you'} 81 - {n.type === 'follow' && 'followed you'} 82 - {n.type === 'highlight' && 'highlighted'} 83 - </span> 84 - <span className="text-surface-400 dark:text-surface-500 text-xs ml-auto"> 85 - {formatDistanceToNow(new Date(n.createdAt), { addSuffix: false })} 86 - </span> 87 - </div> 88 - 89 - {n.subject && ( 90 - <div className="mt-2 pl-3 border-l-2 border-surface-200 dark:border-surface-700"> 91 - {n.type === 'reply' ? ( 92 - <p className="text-surface-600 dark:text-surface-300 text-sm">{n.subject.text}</p> 93 - ) : ( 94 - <Card item={n.subject} /> 95 - )} 96 - </div> 97 - )} 98 - </div> 99 - </div> 100 - </div> 101 - ))} 102 - </div> 103 - </div> 104 - ); 105 - }
-122
web/src/views/Privacy.tsx
··· 1 - 2 - import React from 'react'; 3 - import { ArrowLeft } from 'lucide-react'; 4 - import { Link } from 'react-router-dom'; 5 - 6 - export default function Privacy() { 7 - return ( 8 - <div className="max-w-3xl mx-auto py-12 px-4"> 9 - <Link to="/home" className="inline-flex items-center gap-2 text-sm font-medium text-surface-500 hover:text-surface-900 transition-colors mb-8"> 10 - <ArrowLeft size={18} /> 11 - <span>Home</span> 12 - </Link> 13 - 14 - <div className="prose prose-surface max-w-none"> 15 - <h1 className="font-display font-bold text-3xl mb-2 text-surface-900">Privacy Policy</h1> 16 - <p className="text-surface-500 mb-8">Last updated: January 11, 2026</p> 17 - 18 - <section className="mb-8"> 19 - <h2 className="text-xl font-bold text-surface-900 mb-4">Overview</h2> 20 - <p className="text-surface-700 leading-relaxed"> 21 - Margin ("we", "our", or "us") is a web annotation tool that lets you highlight, annotate, and bookmark any webpage. Your data is stored on the decentralized AT Protocol network, giving you ownership and control over your content. 22 - </p> 23 - </section> 24 - 25 - <section className="mb-8"> 26 - <h2 className="text-xl font-bold text-surface-900 mb-4">Data We Collect</h2> 27 - <h3 className="text-lg font-semibold text-surface-900 mb-2">Account Information</h3> 28 - <p className="text-surface-700 mb-4"> 29 - When you log in with your Bluesky/AT Protocol account, we access your: 30 - </p> 31 - <ul className="list-disc pl-5 mb-4 text-surface-700 space-y-1"> 32 - <li>Decentralized Identifier (DID)</li> 33 - <li>Handle (username)</li> 34 - <li>Display name and avatar (for showing your profile)</li> 35 - </ul> 36 - 37 - <h3 className="text-lg font-semibold text-surface-900 mb-2">Annotations & Content</h3> 38 - <p className="text-surface-700 mb-4">When you use Margin, we store:</p> 39 - <ul className="list-disc pl-5 mb-4 text-surface-700 space-y-1"> 40 - <li>URLs of pages you annotate</li> 41 - <li>Text you highlight or select</li> 42 - <li>Annotations and comments you create</li> 43 - <li>Bookmarks you save</li> 44 - <li>Collections you organize content into</li> 45 - </ul> 46 - 47 - <h3 className="text-lg font-semibold text-surface-900 mb-2">Authentication</h3> 48 - <p className="text-surface-700 mb-4"> 49 - We store OAuth session tokens locally in your browser to keep you logged in. These tokens are used solely for authenticating API requests. 50 - </p> 51 - </section> 52 - 53 - <section className="mb-8"> 54 - <h2 className="text-xl font-bold text-surface-900 mb-4">How We Use Your Data</h2> 55 - <p className="text-surface-700 mb-4">Your data is used exclusively to:</p> 56 - <ul className="list-disc pl-5 mb-4 text-surface-700 space-y-1"> 57 - <li>Display your annotations on webpages</li> 58 - <li>Sync your content across devices</li> 59 - <li>Show your public annotations to other users</li> 60 - <li>Enable social features like replies and likes</li> 61 - </ul> 62 - </section> 63 - 64 - <section className="mb-8"> 65 - <h2 className="text-xl font-bold text-surface-900 mb-4">Data Storage</h2> 66 - <p className="text-surface-700 mb-4"> 67 - Your annotations are stored on the AT Protocol network through your Personal Data Server (PDS). This means: 68 - </p> 69 - <ul className="list-disc pl-5 mb-4 text-surface-700 space-y-1"> 70 - <li>You own your data</li> 71 - <li>You can export or delete it at any time</li> 72 - <li>Your data is portable across AT Protocol services</li> 73 - </ul> 74 - <p className="text-surface-700"> 75 - We also maintain a local index of annotations to provide faster search and discovery features. 76 - </p> 77 - </section> 78 - 79 - <section className="mb-8"> 80 - <h2 className="text-xl font-bold text-surface-900 mb-4">Data Sharing</h2> 81 - <p className="text-surface-700 mb-4"> 82 - <strong>We do not sell your data.</strong> We do not share your data with third parties for advertising or marketing purposes. 83 - </p> 84 - <p className="text-surface-700 mb-4">Your public annotations may be visible to:</p> 85 - <ul className="list-disc pl-5 mb-4 text-surface-700 space-y-1"> 86 - <li>Other Margin users viewing the same webpage</li> 87 - <li>Anyone on the AT Protocol network (for public content)</li> 88 - </ul> 89 - </section> 90 - 91 - <section className="mb-8"> 92 - <h2 className="text-xl font-bold text-surface-900 mb-4">Browser Extension Permissions</h2> 93 - <p className="text-surface-700 mb-4">The Margin browser extension requires certain permissions:</p> 94 - <ul className="list-disc pl-5 mb-4 text-surface-700 space-y-1"> 95 - <li><strong>All URLs:</strong> To display and create annotations on any webpage</li> 96 - <li><strong>Storage:</strong> To save your preferences and session locally</li> 97 - <li><strong>Cookies:</strong> To maintain your logged-in session</li> 98 - <li><strong>Tabs:</strong> To know which page you're viewing</li> 99 - </ul> 100 - </section> 101 - 102 - <section className="mb-8"> 103 - <h2 className="text-xl font-bold text-surface-900 mb-4">Your Rights</h2> 104 - <p className="text-surface-700 mb-4">You can:</p> 105 - <ul className="list-disc pl-5 mb-4 text-surface-700 space-y-1"> 106 - <li>Delete any annotation, highlight, or bookmark you've created</li> 107 - <li>Delete your collections</li> 108 - <li>Export your data from your PDS</li> 109 - <li>Revoke the extension's access at any time</li> 110 - </ul> 111 - </section> 112 - 113 - <section className="mb-8"> 114 - <h2 className="text-xl font-bold text-surface-900 mb-4">Contact</h2> 115 - <p className="text-surface-700"> 116 - For privacy questions or concerns, contact us at <a href="mailto:hello@margin.at" className="text-primary-600 hover:text-primary-700 hover:underline">hello@margin.at</a> 117 - </p> 118 - </section> 119 - </div> 120 - </div> 121 - ); 122 - }
-271
web/src/views/Profile.tsx
··· 1 - import React, { useEffect, useState } from 'react'; 2 - import { getProfile, getFeed, getAvatarUrl, getCollections } from '../api/client'; 3 - import Card from '../components/Card'; 4 - import { Loader2, User as UserIcon, Edit2, Grid, Bookmark, PenTool, MessageSquare, Folder, Link as LinkIcon, Globe } from 'lucide-react'; 5 - import type { UserProfile, AnnotationItem, Collection } from '../types'; 6 - import { useStore } from '@nanostores/react'; 7 - import { $user } from '../store/auth'; 8 - import EditProfileModal from '../components/EditProfileModal'; 9 - import CollectionIcon from '../components/CollectionIcon'; 10 - import { Link } from 'react-router-dom'; 11 - import { formatDistanceToNow } from 'date-fns'; 12 - import { clsx } from 'clsx'; 13 - 14 - interface ProfileProps { 15 - did: string; 16 - } 17 - 18 - type Tab = 'annotations' | 'highlights' | 'bookmarks' | 'collections'; 19 - 20 - export default function Profile({ did }: ProfileProps) { 21 - const [profile, setProfile] = useState<UserProfile | null>(null); 22 - const [loading, setLoading] = useState(true); 23 - const [activeTab, setActiveTab] = useState<Tab>('annotations'); 24 - 25 - const [annotations, setAnnotations] = useState<AnnotationItem[]>([]); 26 - const [highlights, setHighlights] = useState<AnnotationItem[]>([]); 27 - const [bookmarks, setBookmarks] = useState<AnnotationItem[]>([]); 28 - const [collections, setCollections] = useState<Collection[]>([]); 29 - const [dataLoading, setDataLoading] = useState(false); 30 - 31 - const user = useStore($user); 32 - const isOwner = user?.did === did; 33 - const [showEdit, setShowEdit] = useState(false); 34 - 35 - useEffect(() => { 36 - const loadProfile = async () => { 37 - setLoading(true); 38 - try { 39 - const marginPromise = getProfile(did); 40 - const bskyPromise = fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}`) 41 - .then(res => res.ok ? res.json() : null) 42 - .catch(() => null); 43 - 44 - const [marginData, bskyData] = await Promise.all([marginPromise, bskyPromise]); 45 - 46 - const merged: UserProfile = { 47 - did: did, 48 - handle: bskyData?.handle || marginData?.handle || '', 49 - displayName: bskyData?.displayName || marginData?.displayName, 50 - avatar: bskyData?.avatar || marginData?.avatar, 51 - description: bskyData?.description || marginData?.description, 52 - banner: bskyData?.banner || marginData?.banner, 53 - website: marginData?.website, 54 - links: marginData?.links || [], 55 - followersCount: bskyData?.followersCount || marginData?.followersCount, 56 - followsCount: bskyData?.followsCount || marginData?.followsCount, 57 - postsCount: bskyData?.postsCount || marginData?.postsCount 58 - }; 59 - 60 - setProfile(merged); 61 - } catch (e) { 62 - console.error("Profile load failed", e); 63 - } finally { 64 - setLoading(false); 65 - } 66 - }; 67 - if (did) loadProfile(); 68 - }, [did]); 69 - 70 - useEffect(() => { 71 - const loadTabContent = async () => { 72 - if (!did) return; 73 - setDataLoading(true); 74 - try { 75 - if (activeTab === 'annotations') { 76 - const res = await getFeed({ creator: did, motivation: 'commenting', limit: 50 }); 77 - setAnnotations(res.items || []); 78 - } else if (activeTab === 'highlights') { 79 - const res = await getFeed({ creator: did, motivation: 'highlighting', limit: 50 }); 80 - setHighlights(res.items || []); 81 - } else if (activeTab === 'bookmarks') { 82 - const res = await getFeed({ creator: did, motivation: 'bookmarking', limit: 50 }); 83 - setBookmarks(res.items || []); 84 - } else if (activeTab === 'collections') { 85 - const res = await getCollections(did); 86 - setCollections(res); 87 - } 88 - } catch (e) { 89 - console.error(e); 90 - } finally { 91 - setDataLoading(false); 92 - } 93 - }; 94 - loadTabContent(); 95 - }, [did, activeTab]); 96 - 97 - if (loading) { 98 - return ( 99 - <div className="flex justify-center py-20"> 100 - <Loader2 className="animate-spin text-primary-600 dark:text-primary-400" size={32} /> 101 - </div> 102 - ); 103 - } 104 - 105 - if (!profile) { 106 - return ( 107 - <div className="text-center py-20 text-surface-500 dark:text-surface-400"> 108 - <p>User not found.</p> 109 - </div> 110 - ); 111 - } 112 - 113 - const currentAvatar = getAvatarUrl(profile.did, profile.avatar); 114 - 115 - const tabs = [ 116 - { id: 'annotations', label: 'Notes', icon: MessageSquare }, 117 - { id: 'highlights', label: 'Highlights', icon: PenTool }, 118 - { id: 'bookmarks', label: 'Bookmarks', icon: Bookmark }, 119 - { id: 'collections', label: 'Collections', icon: Grid }, 120 - ]; 121 - 122 - return ( 123 - <div className="max-w-2xl mx-auto"> 124 - <div className="bg-white dark:bg-surface-900 rounded-xl shadow-sm ring-1 ring-black/5 dark:ring-white/5 p-4 mb-4"> 125 - <div className="flex items-start gap-4"> 126 - <div className="shrink-0"> 127 - {currentAvatar ? ( 128 - <img src={currentAvatar} alt={profile.handle} className="w-14 h-14 sm:w-16 sm:h-16 rounded-full object-cover ring-2 ring-surface-100 dark:ring-surface-800" /> 129 - ) : ( 130 - <div className="w-14 h-14 sm:w-16 sm:h-16 rounded-full bg-surface-100 dark:bg-surface-800 flex items-center justify-center text-surface-400 dark:text-surface-500"> 131 - <UserIcon size={28} /> 132 - </div> 133 - )} 134 - </div> 135 - 136 - <div className="flex-1 min-w-0"> 137 - <div className="flex items-start justify-between gap-2"> 138 - <div className="min-w-0"> 139 - <h1 className="text-lg sm:text-xl font-bold text-surface-900 dark:text-white truncate"> 140 - {profile.displayName || profile.handle} 141 - </h1> 142 - <p className="text-surface-500 dark:text-surface-400 text-sm">@{profile.handle}</p> 143 - </div> 144 - {isOwner && ( 145 - <button 146 - onClick={() => setShowEdit(true)} 147 - className="shrink-0 px-2.5 py-1.5 text-sm font-medium text-surface-600 dark:text-surface-300 bg-surface-100 dark:bg-surface-800 hover:bg-surface-200 dark:hover:bg-surface-700 rounded-lg transition-colors flex items-center gap-1" 148 - > 149 - <Edit2 size={14} /> 150 - <span className="hidden sm:inline">Edit</span> 151 - </button> 152 - )} 153 - </div> 154 - 155 - {profile.description && ( 156 - <p className="text-surface-600 dark:text-surface-300 text-sm mt-2 line-clamp-2">{profile.description}</p> 157 - )} 158 - 159 - <div className="flex items-center gap-4 mt-2 text-xs text-surface-500 dark:text-surface-400"> 160 - <span><strong className="text-surface-700 dark:text-surface-200">{profile.followersCount || 0}</strong> followers</span> 161 - <span><strong className="text-surface-700 dark:text-surface-200">{profile.followsCount || 0}</strong> following</span> 162 - </div> 163 - </div> 164 - </div> 165 - </div> 166 - 167 - <div className="flex gap-1 bg-surface-100 dark:bg-surface-800 p-1 rounded-lg mb-4 overflow-x-auto"> 168 - {tabs.map((tab) => ( 169 - <button 170 - key={tab.id} 171 - onClick={() => setActiveTab(tab.id as Tab)} 172 - className={clsx( 173 - "flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-md transition-all whitespace-nowrap", 174 - activeTab === tab.id 175 - ? "bg-white dark:bg-surface-700 text-surface-900 dark:text-white shadow-sm" 176 - : "text-surface-500 dark:text-surface-400 hover:text-surface-700 dark:hover:text-surface-200" 177 - )} 178 - > 179 - <tab.icon size={14} /> 180 - <span className="hidden sm:inline">{tab.label}</span> 181 - </button> 182 - ))} 183 - </div> 184 - 185 - <div className="min-h-[200px]"> 186 - {dataLoading ? ( 187 - <div className="flex justify-center py-12"> 188 - <Loader2 className="animate-spin text-surface-400" size={24} /> 189 - </div> 190 - ) : ( 191 - <> 192 - {activeTab === 'collections' ? ( 193 - collections.length === 0 ? ( 194 - <EmptyState type="collections" isOwner={isOwner} /> 195 - ) : ( 196 - <div className="grid grid-cols-1 gap-2"> 197 - {collections.map(collection => ( 198 - <Link 199 - key={collection.id} 200 - to={`/${collection.creator?.handle || profile.handle}/collection/${collection.uri.split('/').pop()}`} 201 - className="group bg-white dark:bg-surface-900 rounded-lg p-3 shadow-sm ring-1 ring-black/5 dark:ring-white/5 hover:ring-primary-300 dark:hover:ring-primary-600 transition-all flex items-center gap-3" 202 - > 203 - <div className="p-2 bg-primary-50 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400 rounded-lg"> 204 - <CollectionIcon icon={collection.icon} size={18} /> 205 - </div> 206 - <div className="flex-1 min-w-0"> 207 - <h3 className="font-medium text-surface-900 dark:text-white truncate group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors"> 208 - {collection.name} 209 - </h3> 210 - <p className="text-xs text-surface-500 dark:text-surface-400"> 211 - {collection.itemCount} items 212 - </p> 213 - </div> 214 - </Link> 215 - ))} 216 - </div> 217 - ) 218 - ) : ( 219 - <> 220 - {(activeTab === 'annotations' ? annotations : activeTab === 'highlights' ? highlights : bookmarks).length > 0 ? ( 221 - <div> 222 - {(activeTab === 'annotations' ? annotations : activeTab === 'highlights' ? highlights : bookmarks).map((item) => ( 223 - <Card key={item.uri || item.cid} item={item} /> 224 - ))} 225 - </div> 226 - ) : ( 227 - <EmptyState type={activeTab} isOwner={isOwner} /> 228 - )} 229 - </> 230 - )} 231 - </> 232 - )} 233 - </div> 234 - 235 - {showEdit && profile && ( 236 - <EditProfileModal 237 - profile={profile} 238 - onClose={() => setShowEdit(false)} 239 - onUpdate={(updated) => setProfile(updated)} 240 - /> 241 - )} 242 - </div> 243 - ); 244 - } 245 - 246 - function EmptyState({ type, isOwner }: { type: string, isOwner: boolean }) { 247 - const messages: Record<string, string> = { 248 - annotations: isOwner ? "No notes yet" : "No notes", 249 - highlights: isOwner ? "No highlights yet" : "No highlights", 250 - bookmarks: isOwner ? "No bookmarks yet" : "No bookmarks", 251 - collections: isOwner ? "No collections yet" : "No collections" 252 - }; 253 - 254 - const icons: Record<string, any> = { 255 - annotations: MessageSquare, 256 - highlights: PenTool, 257 - bookmarks: Bookmark, 258 - collections: Folder 259 - }; 260 - 261 - const Icon = icons[type] || MessageSquare; 262 - 263 - return ( 264 - <div className="text-center py-10 flex flex-col items-center"> 265 - <div className="w-10 h-10 bg-surface-100 dark:bg-surface-800 rounded-full flex items-center justify-center text-surface-400 dark:text-surface-500 mb-2"> 266 - <Icon size={20} /> 267 - </div> 268 - <p className="text-surface-500 dark:text-surface-400 text-sm">{messages[type]}</p> 269 - </div> 270 - ); 271 - }
-156
web/src/views/Settings.tsx
··· 1 - 2 - import React, { useEffect, useState } from 'react'; 3 - import { useStore } from '@nanostores/react'; 4 - import { $user } from '../store/auth'; 5 - import { getAPIKeys, createAPIKey, deleteAPIKey, type APIKey } from '../api/client'; 6 - import { Copy, Trash2, Key, Plus, Check, User as UserIcon } from 'lucide-react'; 7 - 8 - export default function Settings() { 9 - const user = useStore($user); 10 - const [keys, setKeys] = useState<APIKey[]>([]); 11 - const [loading, setLoading] = useState(true); 12 - const [newKeyName, setNewKeyName] = useState(''); 13 - const [createdKey, setCreatedKey] = useState<string | null>(null); 14 - const [justCopied, setJustCopied] = useState(false); 15 - 16 - useEffect(() => { 17 - loadKeys(); 18 - }, []); 19 - 20 - const loadKeys = async () => { 21 - setLoading(true); 22 - const data = await getAPIKeys(); 23 - setKeys(data); 24 - setLoading(false); 25 - }; 26 - 27 - const handleCreate = async (e: React.FormEvent) => { 28 - e.preventDefault(); 29 - if (!newKeyName.trim()) return; 30 - 31 - const res = await createAPIKey(newKeyName); 32 - if (res) { 33 - setKeys([res, ...keys]); 34 - setCreatedKey(res.key || null); 35 - setNewKeyName(''); 36 - } 37 - }; 38 - 39 - const handleDelete = async (id: string) => { 40 - if (window.confirm('Revoke this key? Apps using it will stop working.')) { 41 - const success = await deleteAPIKey(id); 42 - if (success) { 43 - setKeys(prev => prev.filter(k => k.id !== id)); 44 - } 45 - } 46 - }; 47 - 48 - const copyToClipboard = async (text: string) => { 49 - await navigator.clipboard.writeText(text); 50 - setJustCopied(true); 51 - setTimeout(() => setJustCopied(false), 2000); 52 - }; 53 - 54 - if (!user) return null; 55 - 56 - return ( 57 - <div className="max-w-2xl mx-auto animate-fade-in"> 58 - <h1 className="text-2xl font-display font-bold text-surface-900 dark:text-white mb-6">Settings</h1> 59 - 60 - <div className="space-y-4"> 61 - <section className="bg-white dark:bg-surface-900 rounded-xl ring-1 ring-black/5 dark:ring-white/5 p-4"> 62 - <h2 className="text-sm font-semibold text-surface-500 dark:text-surface-400 uppercase tracking-wider mb-3">Profile</h2> 63 - <div className="flex gap-4 items-center"> 64 - {user.avatar ? ( 65 - <img src={user.avatar} className="w-14 h-14 rounded-full bg-surface-100 dark:bg-surface-800 object-cover" /> 66 - ) : ( 67 - <div className="w-14 h-14 rounded-full bg-surface-100 dark:bg-surface-800 flex items-center justify-center text-surface-400 dark:text-surface-500"> 68 - <UserIcon size={24} /> 69 - </div> 70 - )} 71 - <div> 72 - <p className="font-medium text-surface-900 dark:text-white">{user.displayName || user.handle}</p> 73 - <p className="text-sm text-surface-500 dark:text-surface-400">@{user.handle}</p> 74 - </div> 75 - </div> 76 - </section> 77 - 78 - <section className="bg-white dark:bg-surface-900 rounded-xl ring-1 ring-black/5 dark:ring-white/5 p-4"> 79 - <h2 className="text-sm font-semibold text-surface-500 dark:text-surface-400 uppercase tracking-wider mb-1">API Keys</h2> 80 - <p className="text-xs text-surface-400 dark:text-surface-500 mb-4">For the browser extension and other apps</p> 81 - 82 - <form onSubmit={handleCreate} className="flex gap-2 mb-4"> 83 - <input 84 - type="text" 85 - value={newKeyName} 86 - onChange={e => setNewKeyName(e.target.value)} 87 - placeholder="Key name, e.g. Chrome Extension" 88 - className="flex-1 px-3 py-2 bg-surface-50 dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-lg text-surface-900 dark:text-white placeholder:text-surface-400 dark:placeholder:text-surface-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500 dark:focus:border-primary-400 text-sm" 89 - /> 90 - <button 91 - type="submit" 92 - disabled={!newKeyName.trim()} 93 - className="px-3 py-2 bg-primary-600 text-white font-medium rounded-lg hover:bg-primary-500 disabled:opacity-50 transition-colors text-sm flex items-center gap-1.5" 94 - > 95 - <Plus size={16} /> 96 - Generate 97 - </button> 98 - </form> 99 - 100 - {createdKey && ( 101 - <div className="mb-4 p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg"> 102 - <div className="flex items-start gap-2"> 103 - <Key size={16} className="text-green-600 dark:text-green-400 mt-0.5 shrink-0" /> 104 - <div className="flex-1 min-w-0"> 105 - <p className="text-green-800 dark:text-green-200 text-xs mb-2">Copy now - you won't see this again!</p> 106 - <div className="flex items-center gap-2"> 107 - <code className="flex-1 bg-white dark:bg-surface-900 border border-green-200 dark:border-green-800 px-2 py-1.5 rounded text-xs font-mono text-green-900 dark:text-green-100 break-all"> 108 - {createdKey} 109 - </code> 110 - <button 111 - onClick={() => copyToClipboard(createdKey)} 112 - className="p-1.5 text-green-700 dark:text-green-400 hover:bg-green-100 dark:hover:bg-green-900/40 rounded transition-colors" 113 - > 114 - {justCopied ? <Check size={16} /> : <Copy size={16} />} 115 - </button> 116 - </div> 117 - </div> 118 - </div> 119 - </div> 120 - )} 121 - 122 - {loading ? ( 123 - <div className="space-y-2"> 124 - <div className="h-12 bg-surface-100 dark:bg-surface-800 rounded-lg animate-pulse" /> 125 - <div className="h-12 bg-surface-100 dark:bg-surface-800 rounded-lg animate-pulse" /> 126 - </div> 127 - ) : keys.length === 0 ? ( 128 - <p className="text-center text-surface-500 dark:text-surface-400 text-sm py-6">No API keys yet</p> 129 - ) : ( 130 - <div className="space-y-2"> 131 - {keys.map(key => ( 132 - <div key={key.id} className="flex items-center justify-between p-3 bg-surface-50 dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-lg group"> 133 - <div className="flex items-center gap-3"> 134 - <Key size={16} className="text-surface-400 dark:text-surface-500" /> 135 - <div> 136 - <p className="font-medium text-surface-900 dark:text-white text-sm">{key.alias}</p> 137 - <p className="text-xs text-surface-500 dark:text-surface-400"> 138 - Created {new Date(key.createdAt).toLocaleDateString()} 139 - </p> 140 - </div> 141 - </div> 142 - <button 143 - onClick={() => handleDelete(key.id)} 144 - className="p-1.5 text-surface-400 dark:text-surface-500 hover:text-red-500 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors opacity-0 group-hover:opacity-100" 145 - > 146 - <Trash2 size={16} /> 147 - </button> 148 - </div> 149 - ))} 150 - </div> 151 - )} 152 - </section> 153 - </div> 154 - </div> 155 - ); 156 - }
-70
web/src/views/Terms.tsx
··· 1 - 2 - import React from 'react'; 3 - import { ArrowLeft } from 'lucide-react'; 4 - import { Link } from 'react-router-dom'; 5 - 6 - export default function Terms() { 7 - return ( 8 - <div className="max-w-3xl mx-auto py-12 px-4"> 9 - <Link to="/home" className="inline-flex items-center gap-2 text-sm font-medium text-surface-500 hover:text-surface-900 transition-colors mb-8"> 10 - <ArrowLeft size={18} /> 11 - <span>Home</span> 12 - </Link> 13 - 14 - <div className="prose prose-surface max-w-none"> 15 - <h1 className="font-display font-bold text-3xl mb-2 text-surface-900">Terms of Service</h1> 16 - <p className="text-surface-500 mb-8">Last updated: January 17, 2026</p> 17 - 18 - <section className="mb-8"> 19 - <h2 className="text-xl font-bold text-surface-900 mb-4">Overview</h2> 20 - <p className="text-surface-700 leading-relaxed"> 21 - Margin is an open-source project. By using our service, you agree to these terms ("Terms"). If you do not agree to these Terms, please do not use the Service. 22 - </p> 23 - </section> 24 - 25 - <section className="mb-8"> 26 - <h2 className="text-xl font-bold text-surface-900 mb-4">Open Source</h2> 27 - <p className="text-surface-700 leading-relaxed"> 28 - Margin is open source software. The code is available publicly and is provided "as is", without warranty of any kind, express or implied. 29 - </p> 30 - </section> 31 - 32 - <section className="mb-8"> 33 - <h2 className="text-xl font-bold text-surface-900 mb-4">User Conduct</h2> 34 - <p className="text-surface-700 mb-4"> 35 - You are responsible for your use of the Service and for any content you provide, including compliance with applicable laws, rules, and regulations. 36 - </p> 37 - <p className="text-surface-700 mb-4"> 38 - We reserve the right to remove any content that violates these terms, including but not limited to: 39 - </p> 40 - <ul className="list-disc pl-5 mb-4 text-surface-700 space-y-1"> 41 - <li>Illegal content</li> 42 - <li>Harassment or hate speech</li> 43 - <li>Spam or malicious content</li> 44 - </ul> 45 - </section> 46 - 47 - <section className="mb-8"> 48 - <h2 className="text-xl font-bold text-surface-900 mb-4">Decentralized Nature</h2> 49 - <p className="text-surface-700 leading-relaxed"> 50 - Margin interacts with the AT Protocol network. We do not control the network itself or the data stored on your Personal Data Server (PDS). Please refer to the terms of your PDS provider for data storage policies. 51 - </p> 52 - </section> 53 - 54 - <section className="mb-8"> 55 - <h2 className="text-xl font-bold text-surface-900 mb-4">Disclaimer</h2> 56 - <p className="text-surface-700 leading-relaxed uppercase"> 57 - THE SERVICE IS PROVIDED "AS IS" AND "AS AVAILABLE". WE DISCLAIM ALL CONDITIONS, REPRESENTATIONS AND WARRANTIES NOT EXPRESSLY SET OUT IN THESE TERMS. 58 - </p> 59 - </section> 60 - 61 - <section className="mb-8"> 62 - <h2 className="text-xl font-bold text-surface-900 mb-4">Contact</h2> 63 - <p className="text-surface-700"> 64 - For questions about these Terms, please contact us at <a href="mailto:hello@margin.at" className="text-primary-600 hover:text-primary-700 hover:underline">hello@margin.at</a> 65 - </p> 66 - </section> 67 - </div> 68 - </div> 69 - ); 70 - }
-354
web/src/views/Url.tsx
··· 1 - import React, { useState, useEffect, useRef } from 'react'; 2 - import { useNavigate, Link } from 'react-router-dom'; 3 - import { useStore } from '@nanostores/react'; 4 - import { $user } from '../store/auth'; 5 - import { getByTarget, searchActors, resolveHandle } from '../api/client'; 6 - import type { AnnotationItem, ActorSearchItem } from '../types'; 7 - import Card from '../components/Card'; 8 - import { Search, PenTool, Highlighter, Loader2, AlertTriangle, ExternalLink, Copy, Check, Link as LinkIcon, Clock, Globe } from 'lucide-react'; 9 - import { clsx } from 'clsx'; 10 - import { getAvatarUrl } from '../api/client'; 11 - 12 - export default function UrlPage() { 13 - const user = useStore($user); 14 - const navigate = useNavigate(); 15 - const [url, setUrl] = useState(""); 16 - const [annotations, setAnnotations] = useState<AnnotationItem[]>([]); 17 - const [highlights, setHighlights] = useState<AnnotationItem[]>([]); 18 - const [loading, setLoading] = useState(false); 19 - const [searched, setSearched] = useState(false); 20 - const [error, setError] = useState<string | null>(null); 21 - const [activeTab, setActiveTab] = useState<'all' | 'annotations' | 'highlights'>('all'); 22 - const [copied, setCopied] = useState(false); 23 - 24 - const [suggestions, setSuggestions] = useState<ActorSearchItem[]>([]); 25 - const [showSuggestions, setShowSuggestions] = useState(false); 26 - const [selectedIndex, setSelectedIndex] = useState(-1); 27 - const inputRef = useRef<HTMLInputElement>(null); 28 - const suggestionsRef = useRef<HTMLDivElement>(null); 29 - 30 - const [recentSearches, setRecentSearches] = useState<string[]>([]); 31 - 32 - useEffect(() => { 33 - const stored = localStorage.getItem('margin-recent-searches'); 34 - if (stored) { 35 - try { 36 - setRecentSearches(JSON.parse(stored).slice(0, 5)); 37 - } catch { } 38 - } 39 - }, []); 40 - 41 - const saveRecentSearch = (query: string) => { 42 - const updated = [query, ...recentSearches.filter(s => s !== query)].slice(0, 5); 43 - setRecentSearches(updated); 44 - localStorage.setItem('margin-recent-searches', JSON.stringify(updated)); 45 - }; 46 - 47 - useEffect(() => { 48 - const timer = setTimeout(async () => { 49 - const isUrl = url.includes("http") || url.includes("://"); 50 - if (url.length >= 2 && !isUrl) { 51 - try { 52 - const data = await searchActors(url); 53 - setSuggestions(data.actors || []); 54 - setShowSuggestions(true); 55 - } catch { } 56 - } else { 57 - setSuggestions([]); 58 - setShowSuggestions(false); 59 - } 60 - }, 300); 61 - return () => clearTimeout(timer); 62 - }, [url]); 63 - 64 - useEffect(() => { 65 - const handleClickOutside = (e: MouseEvent) => { 66 - if ( 67 - suggestionsRef.current && 68 - !suggestionsRef.current.contains(e.target as Node) && 69 - inputRef.current && 70 - !inputRef.current.contains(e.target as Node) 71 - ) { 72 - setShowSuggestions(false); 73 - } 74 - }; 75 - document.addEventListener("mousedown", handleClickOutside); 76 - return () => document.removeEventListener("mousedown", handleClickOutside); 77 - }, []); 78 - 79 - const handleKeyDown = (e: React.KeyboardEvent) => { 80 - if (!showSuggestions || suggestions.length === 0) return; 81 - 82 - if (e.key === "ArrowDown") { 83 - e.preventDefault(); 84 - setSelectedIndex((prev) => Math.min(prev + 1, suggestions.length - 1)); 85 - } else if (e.key === "ArrowUp") { 86 - e.preventDefault(); 87 - setSelectedIndex((prev) => Math.max(prev - 1, -1)); 88 - } else if (e.key === "Enter" && selectedIndex >= 0) { 89 - e.preventDefault(); 90 - selectSuggestion(suggestions[selectedIndex]); 91 - } else if (e.key === "Escape") { 92 - setShowSuggestions(false); 93 - } 94 - }; 95 - 96 - const selectSuggestion = (actor: ActorSearchItem) => { 97 - navigate(`/profile/${encodeURIComponent(actor.handle)}`); 98 - }; 99 - 100 - const handleSearch = async (e: React.FormEvent) => { 101 - e.preventDefault(); 102 - if (!url.trim()) return; 103 - 104 - setLoading(true); 105 - setError(null); 106 - setSearched(true); 107 - setAnnotations([]); 108 - setHighlights([]); 109 - 110 - const isProtocol = url.startsWith("http://") || url.startsWith("https://"); 111 - if (!isProtocol) { 112 - try { 113 - const actorRes = await searchActors(url); 114 - if (actorRes?.actors?.length > 0) { 115 - const match = actorRes.actors[0]; 116 - navigate(`/profile/${encodeURIComponent(match.handle)}`); 117 - return; 118 - } 119 - } catch { } 120 - } 121 - 122 - try { 123 - const data = await getByTarget(url); 124 - setAnnotations(data.annotations || []); 125 - setHighlights(data.highlights || []); 126 - saveRecentSearch(url); 127 - } catch (err: any) { 128 - setError(err.message); 129 - } finally { 130 - setLoading(false); 131 - setShowSuggestions(false); 132 - } 133 - }; 134 - 135 - const myAnnotations = user 136 - ? annotations.filter((a) => (a.author?.did || a.creator?.did) === user.did) 137 - : []; 138 - const myHighlights = user 139 - ? highlights.filter((h) => (h.author?.did || h.creator?.did) === user.did) 140 - : []; 141 - const myItemsCount = myAnnotations.length + myHighlights.length; 142 - 143 - const getShareUrl = () => { 144 - if (!user?.handle || !url) return null; 145 - return `${window.location.origin}/${user.handle}/url/${encodeURIComponent(url)}`; 146 - }; 147 - 148 - const handleCopyShareLink = async () => { 149 - const shareUrl = getShareUrl(); 150 - if (!shareUrl) return; 151 - try { 152 - await navigator.clipboard.writeText(shareUrl); 153 - setCopied(true); 154 - setTimeout(() => setCopied(false), 2000); 155 - } catch { 156 - prompt("Copy this link:", shareUrl); 157 - } 158 - }; 159 - 160 - const totalItems = annotations.length + highlights.length; 161 - 162 - const renderResults = () => { 163 - if (activeTab === "annotations" && annotations.length === 0) { 164 - return ( 165 - <div className="flex flex-col items-center justify-center p-12 text-center bg-surface-50 dark:bg-surface-800/50 border border-dashed border-surface-200 dark:border-surface-700 rounded-2xl"> 166 - <div className="w-12 h-12 bg-surface-100 dark:bg-surface-700 rounded-full flex items-center justify-center text-surface-400 dark:text-surface-500 mb-4"> 167 - <PenTool size={24} /> 168 - </div> 169 - <h3 className="text-lg font-medium text-surface-600 dark:text-surface-300">No annotations</h3> 170 - </div> 171 - ); 172 - } 173 - 174 - if (activeTab === "highlights" && highlights.length === 0) { 175 - return ( 176 - <div className="flex flex-col items-center justify-center p-12 text-center bg-surface-50 dark:bg-surface-800/50 border border-dashed border-surface-200 dark:border-surface-700 rounded-2xl"> 177 - <div className="w-12 h-12 bg-surface-100 dark:bg-surface-700 rounded-full flex items-center justify-center text-surface-400 dark:text-surface-500 mb-4"> 178 - <Highlighter size={24} /> 179 - </div> 180 - <h3 className="text-lg font-medium text-surface-600 dark:text-surface-300">No highlights</h3> 181 - </div> 182 - ); 183 - } 184 - 185 - return ( 186 - <div className="space-y-3"> 187 - {(activeTab === "all" || activeTab === "annotations") && 188 - annotations.map((a) => <Card key={a.uri} item={a} />)} 189 - {(activeTab === "all" || activeTab === "highlights") && 190 - highlights.map((h) => <Card key={h.uri} item={h} />)} 191 - </div> 192 - ); 193 - }; 194 - 195 - return ( 196 - <div className="max-w-2xl mx-auto pb-20"> 197 - <div className="mb-8 text-center"> 198 - <h1 className="text-3xl font-display font-bold text-surface-900 dark:text-white mb-2">Explore</h1> 199 - <p className="text-surface-500 dark:text-surface-400"> 200 - Search for a URL or find a user 201 - </p> 202 - </div> 203 - 204 - <form onSubmit={handleSearch} className="mb-6 relative z-10"> 205 - <div className="relative"> 206 - <div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none"> 207 - {loading ? ( 208 - <Loader2 className="animate-spin text-primary-500" size={20} /> 209 - ) : ( 210 - <Search className="text-surface-400 dark:text-surface-500" size={20} /> 211 - )} 212 - </div> 213 - <input 214 - ref={inputRef} 215 - type="text" 216 - value={url} 217 - onChange={(e) => setUrl(e.target.value)} 218 - onKeyDown={handleKeyDown} 219 - placeholder="https://... or @handle" 220 - className="w-full pl-12 pr-24 py-3.5 bg-white dark:bg-surface-900 border border-surface-200 dark:border-surface-700 rounded-xl shadow-sm focus:ring-4 focus:ring-primary-500/10 focus:border-primary-500 dark:focus:border-primary-400 outline-none transition-all text-surface-900 dark:text-white placeholder:text-surface-400 dark:placeholder:text-surface-500" 221 - autoComplete="off" 222 - required 223 - /> 224 - <div className="absolute inset-y-1.5 right-1.5"> 225 - <button 226 - type="submit" 227 - className="h-full px-5 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg transition-colors disabled:opacity-70" 228 - disabled={loading} 229 - > 230 - Search 231 - </button> 232 - </div> 233 - </div> 234 - 235 - {showSuggestions && suggestions.length > 0 && ( 236 - <div 237 - ref={suggestionsRef} 238 - className="absolute top-full left-0 right-0 mt-2 bg-white dark:bg-surface-900 rounded-xl shadow-xl border border-surface-200 dark:border-surface-700 overflow-hidden max-h-[280px] overflow-y-auto" 239 - > 240 - {suggestions.map((actor, index) => ( 241 - <button 242 - key={actor.did} 243 - type="button" 244 - className={clsx( 245 - "w-full text-left px-4 py-3 flex items-center gap-3 transition-colors border-b border-surface-100 dark:border-surface-800 last:border-0", 246 - index === selectedIndex ? "bg-surface-50 dark:bg-surface-800" : "hover:bg-surface-50 dark:hover:bg-surface-800" 247 - )} 248 - onClick={() => selectSuggestion(actor)} 249 - > 250 - {getAvatarUrl(actor.did, actor.avatar) ? ( 251 - <img src={getAvatarUrl(actor.did, actor.avatar)} alt="" className="w-10 h-10 rounded-full object-cover bg-surface-100 dark:bg-surface-800" /> 252 - ) : ( 253 - <div className="w-10 h-10 rounded-full bg-surface-100 dark:bg-surface-800 flex items-center justify-center font-bold text-surface-500 dark:text-surface-400"> 254 - {(actor.displayName || actor.handle || "?")[0]?.toUpperCase()} 255 - </div> 256 - )} 257 - <div className="flex flex-col"> 258 - <span className="font-semibold text-surface-900 dark:text-white">{actor.displayName || actor.handle}</span> 259 - <span className="text-sm text-surface-500 dark:text-surface-400">@{actor.handle}</span> 260 - </div> 261 - </button> 262 - ))} 263 - </div> 264 - )} 265 - </form> 266 - 267 - {!searched && recentSearches.length > 0 && ( 268 - <div className="mb-8"> 269 - <h3 className="text-sm font-medium text-surface-500 dark:text-surface-400 mb-3 flex items-center gap-2"> 270 - <Clock size={14} /> Recent 271 - </h3> 272 - <div className="flex flex-wrap gap-2"> 273 - {recentSearches.map((q, i) => ( 274 - <button 275 - key={i} 276 - onClick={() => { setUrl(q); }} 277 - className="px-3 py-1.5 bg-surface-100 dark:bg-surface-800 hover:bg-surface-200 dark:hover:bg-surface-700 rounded-lg text-sm text-surface-700 dark:text-surface-300 transition-colors flex items-center gap-1.5 max-w-[200px] truncate" 278 - > 279 - <Globe size={12} className="shrink-0" /> 280 - <span className="truncate">{q.replace(/^https?:\/\//, '')}</span> 281 - </button> 282 - ))} 283 - </div> 284 - </div> 285 - )} 286 - 287 - {error && ( 288 - <div className="mb-6 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 p-4 rounded-xl flex items-start gap-3 border border-red-100 dark:border-red-900/30"> 289 - <AlertTriangle className="shrink-0 mt-0.5" size={18} /> 290 - <p>{error}</p> 291 - </div> 292 - )} 293 - 294 - {searched && !loading && !error && totalItems === 0 && ( 295 - <div className="text-center py-12"> 296 - <div className="w-14 h-14 bg-surface-100 dark:bg-surface-800 rounded-full flex items-center justify-center mx-auto mb-4 text-surface-400 dark:text-surface-500"> 297 - <Search size={28} /> 298 - </div> 299 - <h3 className="text-xl font-bold text-surface-900 dark:text-white mb-2">No items found</h3> 300 - <p className="text-surface-500 dark:text-surface-400 text-sm"> 301 - Be the first to annotate this URL! 302 - </p> 303 - </div> 304 - )} 305 - 306 - {searched && totalItems > 0 && ( 307 - <div className="animate-fade-in"> 308 - <div className="flex items-center justify-between gap-4 mb-4"> 309 - <h2 className="text-lg font-bold text-surface-900 dark:text-white"> 310 - {totalItems} result{totalItems !== 1 ? "s" : ""} 311 - </h2> 312 - <div className="flex bg-surface-100 dark:bg-surface-800 p-1 rounded-lg"> 313 - {[ 314 - { id: 'all', label: `All (${totalItems})` }, 315 - { id: 'annotations', label: `Notes (${annotations.length})` }, 316 - { id: 'highlights', label: `Highlights (${highlights.length})` } 317 - ].map(tab => ( 318 - <button 319 - key={tab.id} 320 - className={clsx( 321 - "px-3 py-1.5 rounded-md text-sm font-medium transition-all", 322 - activeTab === tab.id 323 - ? "bg-white dark:bg-surface-700 text-surface-900 dark:text-white shadow-sm" 324 - : "text-surface-500 dark:text-surface-400 hover:text-surface-700 dark:hover:text-surface-200" 325 - )} 326 - onClick={() => setActiveTab(tab.id as any)} 327 - > 328 - {tab.label} 329 - </button> 330 - ))} 331 - </div> 332 - </div> 333 - 334 - {user && myItemsCount > 0 && ( 335 - <div className="bg-primary-50 dark:bg-primary-900/20 border border-primary-100 dark:border-primary-900/30 rounded-xl p-3 mb-4 flex items-center justify-between gap-3"> 336 - <span className="text-sm text-primary-900 dark:text-primary-100 font-medium"> 337 - You have {myItemsCount} note{myItemsCount !== 1 ? 's' : ''} here 338 - </span> 339 - <button 340 - onClick={handleCopyShareLink} 341 - className="flex items-center gap-1.5 px-3 py-1.5 bg-primary-600 hover:bg-primary-700 text-white text-sm font-medium rounded-lg transition-colors" 342 - > 343 - {copied ? <Check size={14} /> : <Copy size={14} />} 344 - {copied ? "Copied!" : "Share"} 345 - </button> 346 - </div> 347 - )} 348 - 349 - {renderResults()} 350 - </div> 351 - )} 352 - </div> 353 - ); 354 - }
-220
web/src/views/UserUrl.tsx
··· 1 - import React, { useState, useEffect } from 'react'; 2 - import { useParams, Link } from 'react-router-dom'; 3 - import { getUserTargetItems } from '../api/client'; 4 - import type { AnnotationItem, UserProfile } from '../types'; 5 - import Card from '../components/Card'; 6 - import { PenTool, Highlighter, Search, AlertTriangle, ExternalLink } from 'lucide-react'; 7 - import { clsx } from 'clsx'; 8 - import { getAvatarUrl } from '../api/client'; 9 - 10 - export default function UserUrlPage() { 11 - const params = useParams(); 12 - const handle = params.handle; 13 - const urlPath = params['*']; 14 - const targetUrl = urlPath || ""; 15 - 16 - const [profile, setProfile] = useState<UserProfile | null>(null); 17 - const [annotations, setAnnotations] = useState<AnnotationItem[]>([]); 18 - const [highlights, setHighlights] = useState<AnnotationItem[]>([]); 19 - const [loading, setLoading] = useState(true); 20 - const [error, setError] = useState<string | null>(null); 21 - const [activeTab, setActiveTab] = useState<'all' | 'annotations' | 'highlights'>('all'); 22 - 23 - useEffect(() => { 24 - async function fetchData() { 25 - if (!targetUrl || !handle) { 26 - setLoading(false); 27 - return; 28 - } 29 - 30 - try { 31 - setLoading(true); 32 - setError(null); 33 - 34 - const profileRes = await fetch( 35 - `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}` 36 - ); 37 - 38 - let did = handle; 39 - if (profileRes.ok) { 40 - const profileData = await profileRes.json(); 41 - setProfile(profileData); 42 - did = profileData.did; 43 - } 44 - 45 - const decodedUrl = decodeURIComponent(targetUrl); 46 - 47 - const data = await getUserTargetItems(did, decodedUrl); 48 - setAnnotations(data.annotations || []); 49 - setHighlights(data.highlights || []); 50 - } catch (err: any) { 51 - setError(err.message); 52 - } finally { 53 - setLoading(false); 54 - } 55 - } 56 - fetchData(); 57 - }, [handle, targetUrl]); 58 - 59 - const displayName = profile?.displayName || profile?.handle || handle; 60 - const displayHandle = profile?.handle || (handle?.startsWith("did:") ? null : handle); 61 - const avatarUrl = getAvatarUrl(profile?.did, profile?.avatar); 62 - 63 - const getInitial = () => { 64 - return (displayName || displayHandle || "??")?.substring(0, 2).toUpperCase(); 65 - }; 66 - 67 - const totalItems = annotations.length + highlights.length; 68 - const bskyProfileUrl = displayHandle 69 - ? `https://bsky.app/profile/${displayHandle}` 70 - : `https://bsky.app/profile/${handle}`; 71 - 72 - const renderResults = () => { 73 - if (activeTab === "annotations" && annotations.length === 0) { 74 - return ( 75 - <div className="flex flex-col items-center justify-center p-12 text-center bg-surface-50 border border-dashed border-surface-200 rounded-2xl"> 76 - <div className="w-12 h-12 bg-surface-100 rounded-full flex items-center justify-center text-surface-400 mb-4"> 77 - <PenTool size={24} /> 78 - </div> 79 - <h3 className="text-lg font-medium text-surface-600">No annotations</h3> 80 - </div> 81 - ); 82 - } 83 - 84 - if (activeTab === "highlights" && highlights.length === 0) { 85 - return ( 86 - <div className="flex flex-col items-center justify-center p-12 text-center bg-surface-50 border border-dashed border-surface-200 rounded-2xl"> 87 - <div className="w-12 h-12 bg-surface-100 rounded-full flex items-center justify-center text-surface-400 mb-4"> 88 - <Highlighter size={24} /> 89 - </div> 90 - <h3 className="text-lg font-medium text-surface-600">No highlights</h3> 91 - </div> 92 - ); 93 - } 94 - 95 - return ( 96 - <div className="space-y-6"> 97 - {(activeTab === "all" || activeTab === "annotations") && 98 - annotations.map((a) => <Card key={a.uri} item={a} />)} 99 - {(activeTab === "all" || activeTab === "highlights") && 100 - highlights.map((h) => <Card key={h.uri} item={h} />)} 101 - </div> 102 - ); 103 - }; 104 - 105 - if (!targetUrl) { 106 - return ( 107 - <div className="max-w-2xl mx-auto py-20 text-center"> 108 - <div className="w-16 h-16 bg-surface-100 rounded-full flex items-center justify-center mx-auto mb-4 text-surface-400"> 109 - <Search size={32} /> 110 - </div> 111 - <h3 className="text-xl font-bold text-surface-900 mb-2">No URL specified</h3> 112 - <p className="text-surface-500">Please provide a URL to view annotations.</p> 113 - </div> 114 - ); 115 - } 116 - 117 - return ( 118 - <div className="max-w-3xl mx-auto pb-20"> 119 - <header className="flex items-center gap-6 mb-8 p-6 bg-white rounded-2xl border border-surface-200 shadow-sm"> 120 - <a 121 - href={bskyProfileUrl} 122 - target="_blank" 123 - rel="noopener noreferrer" 124 - className="shrink-0 hover:opacity-80 transition-opacity" 125 - > 126 - {avatarUrl ? ( 127 - <img src={avatarUrl} alt={displayName} className="w-20 h-20 rounded-full object-cover border-4 border-surface-50" /> 128 - ) : ( 129 - <div className="w-20 h-20 rounded-full bg-surface-100 flex items-center justify-center text-2xl font-bold text-surface-500 border-4 border-surface-50"> 130 - {getInitial()} 131 - </div> 132 - )} 133 - </a> 134 - <div className="flex-1"> 135 - <h1 className="text-2xl font-bold text-surface-900 mb-1">{displayName}</h1> 136 - {displayHandle && ( 137 - <a 138 - href={bskyProfileUrl} 139 - target="_blank" 140 - rel="noopener noreferrer" 141 - className="text-surface-500 hover:text-primary-600 transition-colors bg-surface-50 hover:bg-primary-50 px-2 py-1 rounded-md text-sm inline-flex items-center gap-1" 142 - > 143 - @{displayHandle} <ExternalLink size={12} /> 144 - </a> 145 - )} 146 - </div> 147 - </header> 148 - 149 - <div className="mb-8 p-4 bg-surface-50 border border-surface-200 rounded-xl flex flex-col sm:flex-row sm:items-center gap-4"> 150 - <span className="text-sm font-semibold text-surface-500 uppercase tracking-wide">Annotations on:</span> 151 - <a 152 - href={decodeURIComponent(targetUrl)} 153 - target="_blank" 154 - rel="noopener noreferrer" 155 - className="text-primary-600 hover:text-primary-700 hover:underline font-medium truncate flex-1 block" 156 - > 157 - {decodeURIComponent(targetUrl)} 158 - </a> 159 - </div> 160 - 161 - {loading && ( 162 - <div className="flex justify-center py-12"> 163 - <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div> 164 - </div> 165 - )} 166 - 167 - {error && ( 168 - <div className="mb-8 bg-red-50 text-red-600 p-4 rounded-xl flex items-start gap-3 border border-red-100"> 169 - <AlertTriangle className="shrink-0 mt-0.5" size={18} /> 170 - <p>{error}</p> 171 - </div> 172 - )} 173 - 174 - {!loading && !error && totalItems === 0 && ( 175 - <div className="text-center py-16 bg-surface-50 rounded-2xl border border-dashed border-surface-200"> 176 - <div className="w-12 h-12 bg-surface-100 rounded-full flex items-center justify-center mx-auto mb-4 text-surface-400"> 177 - <PenTool size={24} /> 178 - </div> 179 - <h3 className="text-lg font-bold text-surface-900 mb-1">No items found</h3> 180 - <p className="text-surface-500"> 181 - {displayName} hasn&apos;t annotated this page yet. 182 - </p> 183 - </div> 184 - )} 185 - 186 - {!loading && !error && totalItems > 0 && ( 187 - <div className="animate-fade-in"> 188 - <div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-6"> 189 - <h2 className="text-xl font-bold text-surface-900"> 190 - {totalItems} item{totalItems !== 1 ? "s" : ""} 191 - </h2> 192 - <div className="flex bg-surface-100 p-1 rounded-xl self-start md:self-auto"> 193 - <button 194 - className={clsx("px-4 py-1.5 rounded-lg text-sm font-medium transition-all", activeTab === 'all' ? "bg-white text-surface-900 shadow-sm" : "text-surface-500 hover:text-surface-700")} 195 - onClick={() => setActiveTab('all')} 196 - > 197 - All ({totalItems}) 198 - </button> 199 - <button 200 - className={clsx("px-4 py-1.5 rounded-lg text-sm font-medium transition-all", activeTab === 'annotations' ? "bg-white text-surface-900 shadow-sm" : "text-surface-500 hover:text-surface-700")} 201 - onClick={() => setActiveTab('annotations')} 202 - > 203 - Annotations ({annotations.length}) 204 - </button> 205 - <button 206 - className={clsx("px-4 py-1.5 rounded-lg text-sm font-medium transition-all", activeTab === 'highlights' ? "bg-white text-surface-900 shadow-sm" : "text-surface-500 hover:text-surface-700")} 207 - onClick={() => setActiveTab('highlights')} 208 - > 209 - Highlights ({highlights.length}) 210 - </button> 211 - </div> 212 - </div> 213 - <div className="space-y-6"> 214 - {renderResults()} 215 - </div> 216 - </div> 217 - )} 218 - </div> 219 - ); 220 - }
+266
web/src/views/auth/Login.tsx
··· 1 + import React, { useState, useEffect, useRef } from "react"; 2 + import { Link } from "react-router-dom"; 3 + import { Loader2, AtSign } from "lucide-react"; 4 + import { BlueskyIcon, MarginIcon } from "../../components/common/Icons"; 5 + import SignUpModal from "../../components/modals/SignUpModal"; 6 + import { 7 + searchActors, 8 + startLogin, 9 + type ActorSearchItem, 10 + } from "../../api/client"; 11 + import { Avatar } from "../../components/ui"; 12 + 13 + export default function Login() { 14 + const [handle, setHandle] = useState(""); 15 + const [suggestions, setSuggestions] = useState<ActorSearchItem[]>([]); 16 + const [showSuggestions, setShowSuggestions] = useState(false); 17 + const [loading, setLoading] = useState(false); 18 + const [error, setError] = useState<string | null>(null); 19 + const [selectedIndex, setSelectedIndex] = useState(-1); 20 + const [showSignUp, setShowSignUp] = useState(false); 21 + 22 + const inputRef = useRef<HTMLInputElement>(null); 23 + const suggestionsRef = useRef<HTMLDivElement>(null); 24 + const isSelectionRef = useRef(false); 25 + 26 + const [providerIndex, setProviderIndex] = useState(0); 27 + const [morphClass, setMorphClass] = useState( 28 + "opacity-100 translate-y-0 blur-0", 29 + ); 30 + const providers = [ 31 + "AT Protocol", 32 + "Margin", 33 + "Bluesky", 34 + "Blacksky", 35 + "Tangled", 36 + "Northsky", 37 + "witchcraft.systems", 38 + "tophhie.social", 39 + "altq.net", 40 + ]; 41 + 42 + useEffect(() => { 43 + const cycleText = () => { 44 + setMorphClass("opacity-0 translate-y-2 blur-sm"); 45 + setTimeout(() => { 46 + setProviderIndex((prev) => (prev + 1) % providers.length); 47 + setMorphClass("opacity-100 translate-y-0 blur-0"); 48 + }, 400); 49 + }; 50 + const interval = setInterval(cycleText, 3000); 51 + return () => clearInterval(interval); 52 + }, [providers.length]); 53 + 54 + useEffect(() => { 55 + if (handle.length >= 3) { 56 + if (isSelectionRef.current) { 57 + isSelectionRef.current = false; 58 + return; 59 + } 60 + const timer = setTimeout(async () => { 61 + try { 62 + if (!handle.includes(".")) { 63 + const data = await searchActors(handle); 64 + setSuggestions(data.actors || []); 65 + setShowSuggestions(true); 66 + setSelectedIndex(-1); 67 + } 68 + } catch (e) { 69 + console.error("Search failed:", e); 70 + } 71 + }, 300); 72 + return () => clearTimeout(timer); 73 + } else { 74 + setSuggestions([]); 75 + setShowSuggestions(false); 76 + } 77 + }, [handle]); 78 + 79 + useEffect(() => { 80 + const handleClickOutside = (e: MouseEvent) => { 81 + if ( 82 + suggestionsRef.current && 83 + !suggestionsRef.current.contains(e.target as Node) && 84 + inputRef.current && 85 + !inputRef.current.contains(e.target as Node) 86 + ) { 87 + setShowSuggestions(false); 88 + } 89 + }; 90 + document.addEventListener("mousedown", handleClickOutside); 91 + return () => document.removeEventListener("mousedown", handleClickOutside); 92 + }, []); 93 + 94 + const handleKeyDown = (e: React.KeyboardEvent) => { 95 + if (!showSuggestions || suggestions.length === 0) return; 96 + 97 + if (e.key === "ArrowDown") { 98 + e.preventDefault(); 99 + setSelectedIndex((prev) => Math.min(prev + 1, suggestions.length - 1)); 100 + } else if (e.key === "ArrowUp") { 101 + e.preventDefault(); 102 + setSelectedIndex((prev) => Math.max(prev - 1, -1)); 103 + } else if (e.key === "Enter" && selectedIndex >= 0) { 104 + e.preventDefault(); 105 + selectSuggestion(suggestions[selectedIndex]); 106 + } else if (e.key === "Escape") { 107 + setShowSuggestions(false); 108 + } 109 + }; 110 + 111 + const selectSuggestion = (actor: ActorSearchItem) => { 112 + isSelectionRef.current = true; 113 + setHandle(actor.handle); 114 + setSuggestions([]); 115 + setShowSuggestions(false); 116 + inputRef.current?.blur(); 117 + }; 118 + 119 + const handleSubmit = async (e: React.FormEvent) => { 120 + e.preventDefault(); 121 + if (!handle.trim()) return; 122 + 123 + setLoading(true); 124 + setError(null); 125 + 126 + try { 127 + const result = await startLogin(handle.trim()); 128 + if (result.authorizationUrl) { 129 + window.location.href = result.authorizationUrl; 130 + } 131 + } catch (err: any) { 132 + setError(err.message || "Failed to initiate login. Please try again."); 133 + setLoading(false); 134 + } 135 + }; 136 + 137 + return ( 138 + <div className="min-h-screen flex items-center justify-center bg-surface-50 dark:bg-surface-950 p-4"> 139 + <div className="w-full max-w-[440px] flex flex-col items-center"> 140 + <div className="flex items-center justify-center gap-6 mb-12"> 141 + <MarginIcon size={60} /> 142 + <span className="text-3xl font-light text-surface-300 dark:text-surface-600 pb-1"> 143 + × 144 + </span> 145 + <div className="text-[#0285FF]"> 146 + <BlueskyIcon size={60} /> 147 + </div> 148 + </div> 149 + 150 + <h1 className="text-2xl font-bold font-display text-surface-900 dark:text-white mb-8 text-center leading-relaxed"> 151 + Sign in with your <br /> 152 + <span 153 + className={`inline-block transition-all duration-400 ease-out text-transparent bg-clip-text bg-gradient-to-r from-primary-600 to-indigo-600 ${morphClass}`} 154 + > 155 + {providers[providerIndex]} 156 + </span>{" "} 157 + handle 158 + </h1> 159 + 160 + <form onSubmit={handleSubmit} className="w-full flex flex-col gap-5"> 161 + <div className="relative"> 162 + <div className="absolute left-4 top-1/2 -translate-y-1/2 text-surface-400 dark:text-surface-500"> 163 + <AtSign size={20} className="stroke-[2.5]" /> 164 + </div> 165 + <input 166 + ref={inputRef} 167 + type="text" 168 + value={handle} 169 + onChange={(e) => setHandle(e.target.value)} 170 + onKeyDown={handleKeyDown} 171 + onFocus={() => 172 + handle.length >= 3 && 173 + suggestions.length > 0 && 174 + !handle.includes(".") && 175 + setShowSuggestions(true) 176 + } 177 + placeholder="handle.bsky.social" 178 + className="w-full pl-12 pr-4 py-3.5 bg-white dark:bg-surface-900 border border-surface-200 dark:border-surface-700 rounded-xl shadow-sm outline-none focus:border-primary-500 dark:focus:border-primary-400 focus:ring-4 focus:ring-primary-500/10 transition-all font-medium text-lg text-surface-900 dark:text-white placeholder:text-surface-400 dark:placeholder:text-surface-500" 179 + autoCapitalize="none" 180 + autoCorrect="off" 181 + autoComplete="off" 182 + spellCheck={false} 183 + disabled={loading} 184 + /> 185 + 186 + {showSuggestions && suggestions.length > 0 && ( 187 + <div 188 + ref={suggestionsRef} 189 + className="absolute top-[calc(100%+8px)] left-0 right-0 bg-white/90 dark:bg-surface-900/95 backdrop-blur-xl border border-surface-200 dark:border-surface-700 rounded-xl shadow-xl overflow-hidden z-50 animate-fade-in max-h-[300px] overflow-y-auto" 190 + > 191 + {suggestions.map((actor, index) => ( 192 + <button 193 + key={actor.did} 194 + type="button" 195 + className={`w-full flex items-center gap-3 px-4 py-3 border-b border-surface-100 dark:border-surface-800 last:border-0 hover:bg-surface-50 dark:hover:bg-surface-800 transition-colors text-left ${index === selectedIndex ? "bg-surface-50 dark:bg-surface-800" : ""}`} 196 + onClick={() => selectSuggestion(actor)} 197 + > 198 + <Avatar src={actor.avatar} size="sm" /> 199 + <div className="min-w-0"> 200 + <div className="font-semibold text-surface-900 dark:text-white truncate text-sm"> 201 + {actor.displayName || actor.handle} 202 + </div> 203 + <div className="text-surface-500 dark:text-surface-400 text-xs truncate"> 204 + @{actor.handle} 205 + </div> 206 + </div> 207 + </button> 208 + ))} 209 + </div> 210 + )} 211 + </div> 212 + 213 + {error && ( 214 + <div className="p-3 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-sm rounded-lg border border-red-100 dark:border-red-800 text-center font-medium"> 215 + {error} 216 + </div> 217 + )} 218 + 219 + <button 220 + type="submit" 221 + disabled={loading || !handle} 222 + className="w-full py-3.5 bg-surface-900 dark:bg-white hover:bg-surface-800 dark:hover:bg-surface-100 text-white dark:text-surface-900 rounded-xl font-bold text-lg shadow-lg shadow-surface-900/10 disabled:opacity-50 disabled:cursor-not-allowed transition-all flex items-center justify-center gap-2" 223 + > 224 + {loading ? "Connecting..." : "Continue"} 225 + </button> 226 + 227 + <p className="text-center text-sm text-surface-400 dark:text-surface-500 mt-2"> 228 + By signing in, you agree to our{" "} 229 + <Link 230 + to="/terms" 231 + className="text-surface-900 dark:text-white hover:underline" 232 + > 233 + Terms of Service 234 + </Link>{" "} 235 + and{" "} 236 + <Link 237 + to="/privacy" 238 + className="text-surface-900 dark:text-white hover:underline" 239 + > 240 + Privacy Policy 241 + </Link> 242 + . 243 + </p> 244 + 245 + <div className="flex items-center gap-4 py-2"> 246 + <div className="h-px bg-surface-200 dark:bg-surface-700 flex-1" /> 247 + <span className="text-xs font-bold text-surface-400 dark:text-surface-500 uppercase tracking-wider"> 248 + or 249 + </span> 250 + <div className="h-px bg-surface-200 dark:bg-surface-700 flex-1" /> 251 + </div> 252 + 253 + <button 254 + type="button" 255 + onClick={() => setShowSignUp(true)} 256 + className="w-full py-3.5 bg-transparent border-2 border-surface-200 dark:border-surface-700 hover:border-surface-400 dark:hover:border-surface-500 hover:bg-surface-50 dark:hover:bg-surface-900 text-surface-700 dark:text-surface-300 rounded-xl font-bold transition-all" 257 + > 258 + Create New Account 259 + </button> 260 + </form> 261 + </div> 262 + 263 + {showSignUp && <SignUpModal onClose={() => setShowSignUp(false)} />} 264 + </div> 265 + ); 266 + }
+193
web/src/views/collections/CollectionDetail.tsx
··· 1 + import React, { useEffect, useState } from "react"; 2 + import { 3 + getCollection, 4 + getCollectionItems, 5 + deleteCollection, 6 + removeCollectionItem, 7 + resolveHandle, 8 + } from "../../api/client"; 9 + import { Loader2, ArrowLeft, Trash2, Plus } from "lucide-react"; 10 + import CollectionIcon from "../../components/common/CollectionIcon"; 11 + import ShareMenu from "../../components/modals/ShareMenu"; 12 + import Card from "../../components/common/Card"; 13 + import { useStore } from "@nanostores/react"; 14 + import { $user } from "../../store/auth"; 15 + import type { Collection, AnnotationItem } from "../../types"; 16 + 17 + interface CollectionDetailProps { 18 + handle?: string; 19 + rkey?: string; 20 + uri?: string; 21 + } 22 + 23 + export default function CollectionDetail({ 24 + handle, 25 + rkey, 26 + uri, 27 + }: CollectionDetailProps) { 28 + const user = useStore($user); 29 + const [collection, setCollection] = useState<Collection | null>(null); 30 + const [items, setItems] = useState<AnnotationItem[]>([]); 31 + const [loading, setLoading] = useState(true); 32 + const [error, setError] = useState<string | null>(null); 33 + 34 + useEffect(() => { 35 + loadData(); 36 + }, [handle, rkey, uri]); 37 + 38 + const loadData = async () => { 39 + setLoading(true); 40 + try { 41 + let targetUri = uri; 42 + if (!targetUri && handle && rkey) { 43 + if (handle.startsWith("did:")) { 44 + targetUri = `at://${handle}/at.margin.collection/${rkey}`; 45 + } else { 46 + const did = await resolveHandle(handle); 47 + if (did) { 48 + targetUri = `at://${did}/at.margin.collection/${rkey}`; 49 + } else { 50 + setError("Collection not found"); 51 + setLoading(false); 52 + return; 53 + } 54 + } 55 + } 56 + 57 + if (targetUri) { 58 + const col = await getCollection(targetUri); 59 + if (col) { 60 + setCollection(col); 61 + const colItems = await getCollectionItems(col.uri); 62 + setItems(colItems.filter((i) => i && i.uri)); 63 + } else { 64 + setError("Collection not found"); 65 + } 66 + } 67 + } catch (e) { 68 + setError("Failed to load collection"); 69 + } finally { 70 + setLoading(false); 71 + } 72 + }; 73 + 74 + const handleDelete = async () => { 75 + if (!collection) return; 76 + if (window.confirm("Delete this collection?")) { 77 + await deleteCollection(collection.id); 78 + window.location.href = "/collections"; 79 + } 80 + }; 81 + 82 + const handleRemoveItem = async (item: AnnotationItem) => { 83 + if (!item.collectionItemUri) return; 84 + if (!window.confirm("Remove from collection?")) return; 85 + const success = await removeCollectionItem(item.collectionItemUri); 86 + if (success) { 87 + setItems((prev) => 88 + prev.filter((i) => i.collectionItemUri !== item.collectionItemUri), 89 + ); 90 + } 91 + }; 92 + 93 + if (loading) { 94 + return ( 95 + <div className="flex justify-center py-20"> 96 + <Loader2 97 + className="animate-spin text-primary-600 dark:text-primary-400" 98 + size={32} 99 + /> 100 + </div> 101 + ); 102 + } 103 + 104 + if (error || !collection) { 105 + return ( 106 + <div className="text-center py-20 text-red-500 dark:text-red-400"> 107 + {error || "Collection not found"} 108 + </div> 109 + ); 110 + } 111 + 112 + const isOwner = user?.did === collection.creator?.did; 113 + 114 + return ( 115 + <div className="animate-fade-in max-w-2xl mx-auto"> 116 + <a 117 + href="/collections" 118 + className="inline-flex items-center gap-1.5 text-sm font-medium text-surface-500 dark:text-surface-400 hover:text-surface-900 dark:hover:text-white mb-4 transition-colors" 119 + > 120 + <ArrowLeft size={16} /> 121 + Collections 122 + </a> 123 + 124 + <div className="bg-white dark:bg-surface-900 rounded-xl p-4 ring-1 ring-black/5 dark:ring-white/5 mb-4"> 125 + <div className="flex items-start gap-3"> 126 + <div className="p-2 bg-primary-50 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400 rounded-lg"> 127 + <CollectionIcon icon={collection.icon} size={24} /> 128 + </div> 129 + <div className="flex-1 min-w-0"> 130 + <h1 className="text-xl font-bold text-surface-900 dark:text-white truncate"> 131 + {collection.name} 132 + </h1> 133 + {collection.description && ( 134 + <p className="text-surface-600 dark:text-surface-300 text-sm mt-1"> 135 + {collection.description} 136 + </p> 137 + )} 138 + <div className="flex items-center gap-2 mt-2 text-xs text-surface-500 dark:text-surface-400"> 139 + <span className="font-medium bg-surface-100 dark:bg-surface-800 px-2 py-0.5 rounded"> 140 + {items.length} items 141 + </span> 142 + <span> 143 + by {collection.creator.displayName || collection.creator.handle} 144 + </span> 145 + </div> 146 + </div> 147 + <div className="flex items-center gap-1"> 148 + <ShareMenu 149 + uri={collection.uri} 150 + handle={collection.creator.handle} 151 + type="Collection" 152 + text={collection.name} 153 + /> 154 + {isOwner && ( 155 + <button 156 + onClick={handleDelete} 157 + className="p-2 text-surface-400 dark:text-surface-500 hover:text-red-500 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors" 158 + > 159 + <Trash2 size={18} /> 160 + </button> 161 + )} 162 + </div> 163 + </div> 164 + </div> 165 + 166 + <div className="space-y-2"> 167 + {items.length === 0 ? ( 168 + <div className="text-center py-12 text-surface-500 dark:text-surface-400 bg-surface-50 dark:bg-surface-800/50 rounded-xl border border-dashed border-surface-200 dark:border-surface-700"> 169 + <Plus 170 + size={28} 171 + className="mx-auto mb-2 text-surface-300 dark:text-surface-600" 172 + /> 173 + <p className="text-sm">Collection is empty</p> 174 + </div> 175 + ) : ( 176 + items.map((item) => ( 177 + <div key={item.uri} className="relative group"> 178 + <Card item={item} hideShare /> 179 + {isOwner && item.collectionItemUri && ( 180 + <button 181 + className="absolute top-3 right-3 p-1.5 bg-white/90 dark:bg-surface-800/90 backdrop-blur text-surface-400 dark:text-surface-500 hover:text-red-500 dark:hover:text-red-400 rounded-lg shadow-sm opacity-0 group-hover:opacity-100 transition-all" 182 + onClick={() => handleRemoveItem(item)} 183 + > 184 + <Trash2 size={14} /> 185 + </button> 186 + )} 187 + </div> 188 + )) 189 + )} 190 + </div> 191 + </div> 192 + ); 193 + }
+236
web/src/views/collections/Collections.tsx
··· 1 + import React, { useEffect, useState } from "react"; 2 + import { 3 + getCollections, 4 + createCollection, 5 + deleteCollection, 6 + } from "../../api/client"; 7 + import { Plus, Folder, Trash2, X } from "lucide-react"; 8 + import CollectionIcon, { 9 + ICON_MAP, 10 + } from "../../components/common/CollectionIcon"; 11 + import { useStore } from "@nanostores/react"; 12 + import { $user } from "../../store/auth"; 13 + import type { Collection } from "../../types"; 14 + import { formatDistanceToNow } from "date-fns"; 15 + import { clsx } from "clsx"; 16 + import { Button, Input, EmptyState, Skeleton } from "../../components/ui"; 17 + 18 + export default function Collections() { 19 + const user = useStore($user); 20 + const [collections, setCollections] = useState<Collection[]>([]); 21 + const [loading, setLoading] = useState(true); 22 + const [showCreateModal, setShowCreateModal] = useState(false); 23 + const [newItemName, setNewItemName] = useState(""); 24 + const [newItemDesc, setNewItemDesc] = useState(""); 25 + const [newItemIcon, setNewItemIcon] = useState("folder"); 26 + const [creating, setCreating] = useState(false); 27 + 28 + useEffect(() => { 29 + loadCollections(); 30 + }, []); 31 + 32 + const loadCollections = async () => { 33 + setLoading(true); 34 + const data = await getCollections(); 35 + setCollections(data); 36 + setLoading(false); 37 + }; 38 + 39 + const handleCreate = async (e: React.FormEvent) => { 40 + e.preventDefault(); 41 + if (!newItemName.trim()) return; 42 + 43 + setCreating(true); 44 + const res = await createCollection(newItemName, newItemDesc); 45 + if (res) { 46 + setCollections([res, ...collections]); 47 + setShowCreateModal(false); 48 + setNewItemName(""); 49 + setNewItemDesc(""); 50 + setNewItemIcon("folder"); 51 + loadCollections(); 52 + } 53 + setCreating(false); 54 + }; 55 + 56 + const handleDelete = async (id: string, e: React.MouseEvent) => { 57 + e.preventDefault(); 58 + if (window.confirm("Delete this collection?")) { 59 + const success = await deleteCollection(id); 60 + if (success) { 61 + setCollections((prev) => prev.filter((c) => c.id !== id)); 62 + } 63 + } 64 + }; 65 + 66 + if (loading) { 67 + return ( 68 + <div className="max-w-2xl mx-auto animate-fade-in"> 69 + <div className="flex items-center justify-between mb-6"> 70 + <div> 71 + <Skeleton width="180px" className="h-8 mb-2" /> 72 + <Skeleton width="240px" className="h-4" /> 73 + </div> 74 + <Skeleton width="90px" className="h-10 rounded-lg" /> 75 + </div> 76 + <div className="space-y-2"> 77 + {[1, 2, 3].map((i) => ( 78 + <div key={i} className="card p-4 flex gap-3 items-center"> 79 + <Skeleton className="w-10 h-10 rounded-lg" /> 80 + <div className="flex-1 space-y-2"> 81 + <Skeleton width="50%" /> 82 + <Skeleton width="30%" className="h-3" /> 83 + </div> 84 + </div> 85 + ))} 86 + </div> 87 + </div> 88 + ); 89 + } 90 + 91 + return ( 92 + <div className="max-w-2xl mx-auto animate-slide-up"> 93 + <div className="flex items-center justify-between mb-6"> 94 + <div> 95 + <h1 className="text-3xl font-display font-bold text-surface-900 dark:text-white"> 96 + Collections 97 + </h1> 98 + <p className="text-surface-500 dark:text-surface-400 mt-1"> 99 + Organize your annotations and highlights 100 + </p> 101 + </div> 102 + <Button 103 + onClick={() => setShowCreateModal(true)} 104 + icon={<Plus size={16} />} 105 + > 106 + New 107 + </Button> 108 + </div> 109 + 110 + {collections.length === 0 ? ( 111 + <EmptyState 112 + icon={<Folder size={48} />} 113 + title="No collections yet" 114 + message="Create a collection to organize your highlights and annotations." 115 + action={{ 116 + label: "Create collection", 117 + onClick: () => setShowCreateModal(true), 118 + }} 119 + /> 120 + ) : ( 121 + <div className="space-y-2"> 122 + {collections 123 + .filter((c) => c && c.id && c.name) 124 + .map((collection) => ( 125 + <a 126 + key={collection.id} 127 + href={`/${collection.creator?.handle || user?.handle}/collection/${(collection.uri || "").split("/").pop()}`} 128 + className="group card p-4 hover:ring-primary-300 dark:hover:ring-primary-600 transition-all flex items-center gap-4" 129 + > 130 + <div className="p-2.5 bg-primary-50 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400 rounded-xl"> 131 + <CollectionIcon icon={collection.icon} size={20} /> 132 + </div> 133 + <div className="flex-1 min-w-0"> 134 + <h3 className="font-semibold text-surface-900 dark:text-white truncate group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors"> 135 + {collection.name} 136 + </h3> 137 + <p className="text-sm text-surface-500 dark:text-surface-400"> 138 + {collection.itemCount}{" "} 139 + {collection.itemCount === 1 ? "item" : "items"} 140 + {collection.createdAt && 141 + ` · ${formatDistanceToNow(new Date(collection.createdAt), { addSuffix: true })}`} 142 + </p> 143 + </div> 144 + {!collection.uri.includes("network.cosmik") && ( 145 + <button 146 + onClick={(e) => handleDelete(collection.id, e)} 147 + className="p-2 text-surface-400 dark:text-surface-500 hover:text-red-500 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-all opacity-0 group-hover:opacity-100" 148 + > 149 + <Trash2 size={18} /> 150 + </button> 151 + )} 152 + </a> 153 + ))} 154 + </div> 155 + )} 156 + 157 + {showCreateModal && ( 158 + <div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-fade-in"> 159 + <div className="bg-white dark:bg-surface-900 rounded-2xl shadow-2xl max-w-md w-full animate-scale-in ring-1 ring-black/5 dark:ring-white/10"> 160 + <div className="flex items-center justify-between p-5 border-b border-surface-100 dark:border-surface-800"> 161 + <h2 className="text-xl font-bold text-surface-900 dark:text-white"> 162 + New Collection 163 + </h2> 164 + <button 165 + onClick={() => setShowCreateModal(false)} 166 + className="p-2 text-surface-400 dark:text-surface-500 hover:bg-surface-100 dark:hover:bg-surface-800 rounded-lg transition-colors" 167 + > 168 + <X size={18} /> 169 + </button> 170 + </div> 171 + <form onSubmit={handleCreate} className="p-5"> 172 + <div className="mb-4"> 173 + <Input 174 + label="Name" 175 + value={newItemName} 176 + onChange={(e) => setNewItemName(e.target.value)} 177 + placeholder="e.g. Design Inspiration" 178 + autoFocus 179 + required 180 + /> 181 + </div> 182 + <div className="mb-4"> 183 + <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2"> 184 + Icon 185 + </label> 186 + <div className="grid grid-cols-7 gap-1.5 p-3 bg-surface-50 dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-xl max-h-28 overflow-y-auto"> 187 + {Object.keys(ICON_MAP).map((key) => { 188 + const Icon = ICON_MAP[key]; 189 + return ( 190 + <button 191 + key={key} 192 + type="button" 193 + onClick={() => setNewItemIcon(key)} 194 + className={clsx( 195 + "p-2 rounded-lg flex items-center justify-center transition-all", 196 + newItemIcon === key 197 + ? "bg-primary-100 dark:bg-primary-900/50 text-primary-600 dark:text-primary-400 ring-2 ring-primary-500" 198 + : "hover:bg-surface-100 dark:hover:bg-surface-700 text-surface-500 dark:text-surface-400", 199 + )} 200 + > 201 + <Icon size={18} /> 202 + </button> 203 + ); 204 + })} 205 + </div> 206 + </div> 207 + <div className="mb-6"> 208 + <label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-2"> 209 + Description 210 + </label> 211 + <textarea 212 + value={newItemDesc} 213 + onChange={(e) => setNewItemDesc(e.target.value)} 214 + className="w-full px-3 py-2.5 bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-xl text-surface-900 dark:text-white placeholder:text-surface-400 dark:placeholder:text-surface-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500 dark:focus:border-primary-400 min-h-[80px] resize-none" 215 + placeholder="What's this collection for?" 216 + /> 217 + </div> 218 + <div className="flex justify-end gap-2"> 219 + <Button 220 + type="button" 221 + variant="ghost" 222 + onClick={() => setShowCreateModal(false)} 223 + > 224 + Cancel 225 + </Button> 226 + <Button type="submit" loading={creating}> 227 + Create Collection 228 + </Button> 229 + </div> 230 + </form> 231 + </div> 232 + </div> 233 + )} 234 + </div> 235 + ); 236 + }
+298
web/src/views/content/AnnotationDetail.tsx
··· 1 + import React, { useEffect, useState } from "react"; 2 + import { useParams, Link, useLocation, useNavigate } from "react-router-dom"; 3 + import { useStore } from "@nanostores/react"; 4 + import { $user } from "../../store/auth"; 5 + import { 6 + getAnnotation, 7 + getReplies, 8 + resolveHandle, 9 + createReply, 10 + deleteReply, 11 + } from "../../api/client"; 12 + import type { AnnotationItem } from "../../types"; 13 + import Card from "../../components/common/Card"; 14 + import ReplyList from "../../components/feed/ReplyList"; 15 + import { 16 + Loader2, 17 + MessageSquare, 18 + ArrowLeft, 19 + X, 20 + AlertTriangle, 21 + } from "lucide-react"; 22 + import { clsx } from "clsx"; 23 + import { getAvatarUrl } from "../../api/client"; 24 + 25 + export default function AnnotationDetail() { 26 + const { uri, did, rkey, handle, type } = useParams(); 27 + const location = useLocation(); 28 + const navigate = useNavigate(); 29 + const user = useStore($user); 30 + 31 + const [annotation, setAnnotation] = useState<AnnotationItem | null>(null); 32 + const [replies, setReplies] = useState<AnnotationItem[]>([]); 33 + const [loading, setLoading] = useState(true); 34 + const [error, setError] = useState<string | null>(null); 35 + 36 + const [replyText, setReplyText] = useState(""); 37 + const [posting, setPosting] = useState(false); 38 + const [replyingTo, setReplyingTo] = useState<AnnotationItem | null>(null); 39 + 40 + const [targetUri, setTargetUri] = useState<string | null>(uri || null); 41 + 42 + useEffect(() => { 43 + async function resolve() { 44 + if (uri) { 45 + setTargetUri(decodeURIComponent(uri)); 46 + return; 47 + } 48 + 49 + if (handle && rkey) { 50 + let collection = "at.margin.annotation"; 51 + if (type === "highlight" || location.pathname.includes("/highlight/")) 52 + collection = "at.margin.highlight"; 53 + if (type === "bookmark" || location.pathname.includes("/bookmark/")) 54 + collection = "at.margin.bookmark"; 55 + 56 + try { 57 + const resolvedDid = await resolveHandle(handle); 58 + if (resolvedDid) { 59 + setTargetUri(`at://${resolvedDid}/${collection}/${rkey}`); 60 + } else { 61 + throw new Error("Could not resolve handle"); 62 + } 63 + } catch (e: any) { 64 + setError("Failed to resolve handle: " + e.message); 65 + setLoading(false); 66 + } 67 + } else if (did && rkey) { 68 + setTargetUri(`at://${did}/at.margin.annotation/${rkey}`); 69 + } else { 70 + const pathParts = (location.pathname || "").split("/"); 71 + const atIndex = pathParts.indexOf("at"); 72 + if ( 73 + atIndex !== -1 && 74 + pathParts[atIndex + 1] && 75 + pathParts[atIndex + 2] 76 + ) { 77 + setTargetUri( 78 + `at://${pathParts[atIndex + 1]}/at.margin.annotation/${pathParts[atIndex + 2]}`, 79 + ); 80 + } 81 + } 82 + } 83 + resolve(); 84 + }, [uri, did, rkey, handle, type, location.pathname]); 85 + 86 + const refreshReplies = async () => { 87 + if (!targetUri) return; 88 + const repliesData = await getReplies(targetUri); 89 + setReplies(repliesData.items || []); 90 + }; 91 + 92 + useEffect(() => { 93 + async function fetchData() { 94 + if (!targetUri) return; 95 + 96 + try { 97 + setLoading(true); 98 + const [annData, repliesData] = await Promise.all([ 99 + getAnnotation(targetUri), 100 + getReplies(targetUri).catch(() => ({ 101 + items: [] as AnnotationItem[], 102 + })), 103 + ]); 104 + 105 + if (!annData) { 106 + setError("Annotation not found"); 107 + } else { 108 + setAnnotation(annData); 109 + setReplies(repliesData.items || []); 110 + } 111 + } catch (err: any) { 112 + setError(err.message); 113 + } finally { 114 + setLoading(false); 115 + } 116 + } 117 + fetchData(); 118 + }, [targetUri]); 119 + 120 + const handleReply = async (e?: React.FormEvent) => { 121 + if (e) e.preventDefault(); 122 + if (!replyText.trim() || !annotation || !targetUri) return; 123 + 124 + try { 125 + setPosting(true); 126 + const parentUri = replyingTo 127 + ? replyingTo.uri || replyingTo.id 128 + : targetUri; 129 + const parentCid = replyingTo ? replyingTo.cid : annotation.cid; 130 + 131 + if (!parentUri || !parentCid || !annotation.cid) 132 + throw new Error("Missing parent info"); 133 + 134 + await createReply( 135 + parentUri, 136 + parentCid, 137 + targetUri, 138 + annotation.cid, 139 + replyText, 140 + ); 141 + 142 + setReplyText(""); 143 + setReplyingTo(null); 144 + await refreshReplies(); 145 + } catch (err: any) { 146 + alert("Failed to post reply: " + err.message); 147 + } finally { 148 + setPosting(false); 149 + } 150 + }; 151 + 152 + const handleDeleteReply = async (reply: AnnotationItem) => { 153 + if (!window.confirm("Delete this reply?")) return; 154 + try { 155 + await deleteReply(reply.uri || reply.id!); 156 + await refreshReplies(); 157 + } catch (err: any) { 158 + alert("Failed to delete: " + err.message); 159 + } 160 + }; 161 + 162 + if (loading) { 163 + return ( 164 + <div className="flex justify-center py-20"> 165 + <Loader2 166 + className="animate-spin text-primary-600 dark:text-primary-400" 167 + size={32} 168 + /> 169 + </div> 170 + ); 171 + } 172 + 173 + if (error || !annotation) { 174 + return ( 175 + <div className="max-w-md mx-auto py-12 px-4 text-center"> 176 + <div className="w-14 h-14 bg-surface-100 dark:bg-surface-800 rounded-full flex items-center justify-center mx-auto mb-4 text-surface-400 dark:text-surface-500"> 177 + <AlertTriangle size={28} /> 178 + </div> 179 + <h3 className="text-xl font-bold text-surface-900 dark:text-white mb-2"> 180 + Not found 181 + </h3> 182 + <p className="text-surface-500 dark:text-surface-400 text-sm mb-6"> 183 + {error || "This may have been deleted."} 184 + </p> 185 + <Link 186 + to="/home" 187 + className="px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg transition-colors" 188 + > 189 + Back to Feed 190 + </Link> 191 + </div> 192 + ); 193 + } 194 + 195 + return ( 196 + <div className="max-w-2xl mx-auto pb-20"> 197 + <div className="mb-4"> 198 + <Link 199 + to="/home" 200 + className="inline-flex items-center gap-1.5 text-sm font-medium text-surface-500 dark:text-surface-400 hover:text-surface-900 dark:hover:text-white transition-colors" 201 + > 202 + <ArrowLeft size={16} /> 203 + Back 204 + </Link> 205 + </div> 206 + 207 + <Card item={annotation} onDelete={() => navigate("/home")} /> 208 + 209 + {annotation.type !== "Bookmark" && 210 + annotation.type !== "Highlight" && 211 + !annotation.motivation?.includes("bookmark") && 212 + !annotation.motivation?.includes("highlight") && ( 213 + <div className="mt-6"> 214 + <h3 className="flex items-center gap-2 text-sm font-semibold text-surface-500 dark:text-surface-400 uppercase tracking-wider mb-4"> 215 + <MessageSquare size={16} /> 216 + Replies ({replies.length}) 217 + </h3> 218 + 219 + {user ? ( 220 + <div className="bg-white dark:bg-surface-900 rounded-xl ring-1 ring-black/5 dark:ring-white/5 p-4 mb-4"> 221 + {replyingTo && ( 222 + <div className="flex items-center justify-between bg-surface-50 dark:bg-surface-800 px-3 py-2 rounded-lg mb-3 border border-surface-200 dark:border-surface-700"> 223 + <span className="text-sm text-surface-600 dark:text-surface-300"> 224 + Replying to{" "} 225 + <span className="font-medium text-surface-900 dark:text-white"> 226 + @ 227 + {(replyingTo.author || replyingTo.creator)?.handle || 228 + "unknown"} 229 + </span> 230 + </span> 231 + <button 232 + onClick={() => setReplyingTo(null)} 233 + className="text-surface-400 dark:text-surface-500 hover:text-surface-900 dark:hover:text-white p-1" 234 + > 235 + <X size={14} /> 236 + </button> 237 + </div> 238 + )} 239 + <div className="flex gap-3"> 240 + {getAvatarUrl(user.did, user.avatar) ? ( 241 + <img 242 + src={getAvatarUrl(user.did, user.avatar)} 243 + alt="" 244 + className="w-8 h-8 rounded-full object-cover bg-surface-100 dark:bg-surface-800" 245 + /> 246 + ) : ( 247 + <div className="w-8 h-8 rounded-full bg-surface-100 dark:bg-surface-800 flex items-center justify-center text-xs font-bold text-surface-400 dark:text-surface-500"> 248 + {user.handle?.[0]?.toUpperCase()} 249 + </div> 250 + )} 251 + <div className="flex-1"> 252 + <textarea 253 + value={replyText} 254 + onChange={(e) => setReplyText(e.target.value)} 255 + placeholder="Write a reply..." 256 + className="w-full p-0 border-0 focus:ring-0 text-surface-900 dark:text-white placeholder:text-surface-400 dark:placeholder:text-surface-500 resize-none min-h-[40px] appearance-none bg-transparent leading-relaxed" 257 + rows={2} 258 + disabled={posting} 259 + /> 260 + <div className="flex justify-end mt-2 pt-2 border-t border-surface-100 dark:border-surface-800"> 261 + <button 262 + className="px-4 py-1.5 bg-primary-600 hover:bg-primary-700 text-white text-sm font-medium rounded-full transition-colors disabled:opacity-50" 263 + disabled={posting || !replyText.trim()} 264 + onClick={() => handleReply()} 265 + > 266 + {posting ? "..." : "Reply"} 267 + </button> 268 + </div> 269 + </div> 270 + </div> 271 + </div> 272 + ) : ( 273 + <div className="bg-surface-50 dark:bg-surface-800/50 rounded-xl p-5 text-center mb-4 border border-dashed border-surface-200 dark:border-surface-700"> 274 + <p className="text-surface-500 dark:text-surface-400 text-sm mb-2"> 275 + Sign in to reply 276 + </p> 277 + <Link 278 + to="/login" 279 + className="text-primary-600 dark:text-primary-400 font-medium hover:underline text-sm" 280 + > 281 + Log in 282 + </Link> 283 + </div> 284 + )} 285 + 286 + <ReplyList 287 + replies={replies} 288 + rootUri={targetUri || ""} 289 + user={user} 290 + onReply={(reply) => setReplyingTo(reply)} 291 + onDelete={handleDeleteReply} 292 + isInline={false} 293 + /> 294 + </div> 295 + )} 296 + </div> 297 + ); 298 + }
+283
web/src/views/content/Url.tsx
··· 1 + import React, { useState, useEffect } from "react"; 2 + import { useNavigate, useSearchParams } from "react-router-dom"; 3 + import { useStore } from "@nanostores/react"; 4 + import { $user } from "../../store/auth"; 5 + import { getByTarget, searchActors } from "../../api/client"; 6 + import type { AnnotationItem } from "../../types"; 7 + import Card from "../../components/common/Card"; 8 + import { 9 + Search, 10 + PenTool, 11 + Highlighter, 12 + Loader2, 13 + AlertTriangle, 14 + Copy, 15 + Check, 16 + Clock, 17 + Globe, 18 + } from "lucide-react"; 19 + import { clsx } from "clsx"; 20 + import { EmptyState, Tabs } from "../../components/ui"; 21 + 22 + export default function UrlPage() { 23 + const user = useStore($user); 24 + const navigate = useNavigate(); 25 + const [searchParams] = useSearchParams(); 26 + const query = searchParams.get("q"); 27 + 28 + const [annotations, setAnnotations] = useState<AnnotationItem[]>([]); 29 + const [highlights, setHighlights] = useState<AnnotationItem[]>([]); 30 + const [loading, setLoading] = useState(false); 31 + const [error, setError] = useState<string | null>(null); 32 + const [activeTab, setActiveTab] = useState< 33 + "all" | "annotations" | "highlights" 34 + >("all"); 35 + const [copied, setCopied] = useState(false); 36 + const [recentSearches, setRecentSearches] = useState<string[]>([]); 37 + const [searched, setSearched] = useState(false); 38 + 39 + useEffect(() => { 40 + const stored = localStorage.getItem("margin-recent-searches"); 41 + if (stored) { 42 + try { 43 + setRecentSearches(JSON.parse(stored).slice(0, 5)); 44 + } catch {} 45 + } 46 + }, []); 47 + 48 + const saveRecentSearch = (q: string) => { 49 + const updated = [q, ...recentSearches.filter((s) => s !== q)].slice(0, 5); 50 + setRecentSearches(updated); 51 + localStorage.setItem("margin-recent-searches", JSON.stringify(updated)); 52 + }; 53 + 54 + useEffect(() => { 55 + if (query) { 56 + performSearch(query); 57 + } else { 58 + setSearched(false); 59 + setAnnotations([]); 60 + setHighlights([]); 61 + setLoading(false); 62 + } 63 + }, [query]); 64 + 65 + const performSearch = async (urlOrHandle: string) => { 66 + if (!urlOrHandle.trim()) return; 67 + 68 + setLoading(true); 69 + setError(null); 70 + setSearched(true); 71 + setAnnotations([]); 72 + setHighlights([]); 73 + 74 + const isProtocol = 75 + urlOrHandle.startsWith("http://") || urlOrHandle.startsWith("https://"); 76 + 77 + if (isProtocol) { 78 + try { 79 + const data = await getByTarget(urlOrHandle); 80 + setAnnotations(data.annotations || []); 81 + setHighlights(data.highlights || []); 82 + saveRecentSearch(urlOrHandle); 83 + } catch (err: any) { 84 + setError(err.message); 85 + } finally { 86 + setLoading(false); 87 + } 88 + } else { 89 + try { 90 + const actorRes = await searchActors(urlOrHandle); 91 + if (actorRes?.actors?.length > 0) { 92 + const match = actorRes.actors[0]; 93 + navigate(`/profile/${encodeURIComponent(match.handle)}`, { 94 + replace: true, 95 + }); 96 + return; 97 + } else { 98 + setError( 99 + "User not found. To search for a URL, please include 'http://' or 'https://'.", 100 + ); 101 + setLoading(false); 102 + } 103 + } catch (err: any) { 104 + setError("Failed to search user."); 105 + setLoading(false); 106 + } 107 + } 108 + }; 109 + 110 + const myAnnotations = user 111 + ? annotations.filter((a) => (a.author?.did || a.creator?.did) === user.did) 112 + : []; 113 + const myHighlights = user 114 + ? highlights.filter((h) => (h.author?.did || h.creator?.did) === user.did) 115 + : []; 116 + const myItemsCount = myAnnotations.length + myHighlights.length; 117 + 118 + const getShareUrl = () => { 119 + if (!user?.handle || !query) return null; 120 + return `${window.location.origin}/${user.handle}/url/${encodeURIComponent(query)}`; 121 + }; 122 + 123 + const handleCopyShareLink = async () => { 124 + const shareUrl = getShareUrl(); 125 + if (!shareUrl) return; 126 + try { 127 + await navigator.clipboard.writeText(shareUrl); 128 + setCopied(true); 129 + setTimeout(() => setCopied(false), 2000); 130 + } catch { 131 + prompt("Copy this link:", shareUrl); 132 + } 133 + }; 134 + 135 + const totalItems = annotations.length + highlights.length; 136 + 137 + const renderResults = () => { 138 + if (activeTab === "annotations" && annotations.length === 0) { 139 + return ( 140 + <EmptyState 141 + icon={<PenTool size={32} />} 142 + title="No annotations" 143 + message="There are no annotations for this URL yet." 144 + /> 145 + ); 146 + } 147 + 148 + if (activeTab === "highlights" && highlights.length === 0) { 149 + return ( 150 + <EmptyState 151 + icon={<Highlighter size={32} />} 152 + title="No highlights" 153 + message="There are no highlights for this URL yet." 154 + /> 155 + ); 156 + } 157 + 158 + return ( 159 + <div className="space-y-4"> 160 + {(activeTab === "all" || activeTab === "annotations") && 161 + annotations.map((a) => <Card key={a.uri} item={a} />)} 162 + {(activeTab === "all" || activeTab === "highlights") && 163 + highlights.map((h) => <Card key={h.uri} item={h} />)} 164 + </div> 165 + ); 166 + }; 167 + 168 + const handleRecentClick = (q: string) => { 169 + navigate(`/url?q=${encodeURIComponent(q)}`); 170 + }; 171 + 172 + return ( 173 + <div className="max-w-2xl mx-auto pb-20 animate-fade-in"> 174 + {!query && ( 175 + <div className="text-center py-10"> 176 + <div className="w-16 h-16 bg-primary-50 dark:bg-primary-900/20 rounded-2xl flex items-center justify-center mx-auto mb-6 rotate-3"> 177 + <Search 178 + size={32} 179 + className="text-primary-600 dark:text-primary-400" 180 + /> 181 + </div> 182 + <h1 className="text-3xl font-display font-bold text-surface-900 dark:text-white mb-3"> 183 + Explore 184 + </h1> 185 + <p className="text-surface-500 dark:text-surface-400 max-w-md mx-auto mb-8"> 186 + Search for any URL in the sidebar to specific see annotations and 187 + highlights. 188 + </p> 189 + 190 + {recentSearches.length > 0 && ( 191 + <div className="text-left max-w-lg mx-auto bg-surface-50 dark:bg-surface-800/50 rounded-2xl p-6 border border-surface-100 dark:border-surface-800"> 192 + <h3 className="text-sm font-bold text-surface-900 dark:text-white mb-4 flex items-center gap-2"> 193 + <Clock size={16} className="text-primary-500" /> 194 + Recent Searches 195 + </h3> 196 + <div className="flex flex-wrap gap-2"> 197 + {recentSearches.map((q, i) => ( 198 + <button 199 + key={i} 200 + onClick={() => handleRecentClick(q)} 201 + className="px-3 py-1.5 bg-white dark:bg-surface-700 hover:bg-surface-50 dark:hover:bg-surface-600 rounded-lg text-sm text-surface-700 dark:text-surface-200 transition-colors shadow-sm ring-1 ring-black/5 dark:ring-white/5 flex items-center gap-2" 202 + > 203 + <Globe size={12} className="opacity-50" /> 204 + <span className="truncate max-w-[200px]"> 205 + {q.replace(/^https?:\/\//, "")} 206 + </span> 207 + </button> 208 + ))} 209 + </div> 210 + </div> 211 + )} 212 + </div> 213 + )} 214 + 215 + {loading && ( 216 + <div className="flex flex-col items-center justify-center py-20"> 217 + <Loader2 218 + className="animate-spin text-primary-600 dark:text-primary-400 mb-4" 219 + size={32} 220 + /> 221 + <p className="text-surface-500 dark:text-surface-400">Searching...</p> 222 + </div> 223 + )} 224 + 225 + {error && ( 226 + <div className="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 p-4 rounded-xl flex items-start gap-3 border border-red-100 dark:border-red-900/30 mb-6"> 227 + <AlertTriangle className="shrink-0 mt-0.5" size={18} /> 228 + <p>{error}</p> 229 + </div> 230 + )} 231 + 232 + {searched && !loading && !error && totalItems === 0 && ( 233 + <EmptyState 234 + icon={<Search size={48} />} 235 + title="No results found" 236 + message="We couldn't find any annotations for this URL. Be the first to add one!" 237 + /> 238 + )} 239 + 240 + {searched && !loading && !error && totalItems > 0 && ( 241 + <div> 242 + <div className="flex items-center justify-between gap-4 mb-6"> 243 + <div> 244 + <h1 className="text-2xl font-bold text-surface-900 dark:text-white truncate max-w-md"> 245 + {query?.replace(/^https?:\/\//, "")} 246 + </h1> 247 + <p className="text-surface-500 dark:text-surface-400 text-sm"> 248 + {totalItems} result{totalItems !== 1 ? "s" : ""} found 249 + </p> 250 + </div> 251 + 252 + {user && myItemsCount > 0 && ( 253 + <button 254 + onClick={handleCopyShareLink} 255 + className="flex items-center gap-1.5 px-3 py-1.5 bg-surface-100 dark:bg-surface-800 hover:bg-surface-200 dark:hover:bg-surface-700 text-surface-900 dark:text-white text-sm font-medium rounded-lg transition-colors" 256 + > 257 + {copied ? <Check size={14} /> : <Copy size={14} />} 258 + {copied ? "Copied" : "Share"} 259 + </button> 260 + )} 261 + </div> 262 + 263 + <div className="mb-6"> 264 + <Tabs 265 + tabs={[ 266 + { id: "all", label: `All (${totalItems})` }, 267 + { id: "annotations", label: `Notes (${annotations.length})` }, 268 + { 269 + id: "highlights", 270 + label: `Highlights (${highlights.length})`, 271 + }, 272 + ]} 273 + activeTab={activeTab} 274 + onChange={(id) => setActiveTab(id as any)} 275 + /> 276 + </div> 277 + 278 + {renderResults()} 279 + </div> 280 + )} 281 + </div> 282 + ); 283 + }
+262
web/src/views/content/UserUrl.tsx
··· 1 + import React, { useState, useEffect } from "react"; 2 + import { useParams, Link } from "react-router-dom"; 3 + import { getUserTargetItems } from "../../api/client"; 4 + import type { AnnotationItem, UserProfile } from "../../types"; 5 + import Card from "../../components/common/Card"; 6 + import { 7 + PenTool, 8 + Highlighter, 9 + Search, 10 + AlertTriangle, 11 + ExternalLink, 12 + } from "lucide-react"; 13 + import { clsx } from "clsx"; 14 + import { getAvatarUrl } from "../../api/client"; 15 + 16 + export default function UserUrlPage() { 17 + const params = useParams(); 18 + const handle = params.handle; 19 + const urlPath = params["*"]; 20 + const targetUrl = urlPath || ""; 21 + 22 + const [profile, setProfile] = useState<UserProfile | null>(null); 23 + const [annotations, setAnnotations] = useState<AnnotationItem[]>([]); 24 + const [highlights, setHighlights] = useState<AnnotationItem[]>([]); 25 + const [loading, setLoading] = useState(true); 26 + const [error, setError] = useState<string | null>(null); 27 + const [activeTab, setActiveTab] = useState< 28 + "all" | "annotations" | "highlights" 29 + >("all"); 30 + 31 + useEffect(() => { 32 + async function fetchData() { 33 + if (!targetUrl || !handle) { 34 + setLoading(false); 35 + return; 36 + } 37 + 38 + try { 39 + setLoading(true); 40 + setError(null); 41 + 42 + const profileRes = await fetch( 43 + `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`, 44 + ); 45 + 46 + let did = handle; 47 + if (profileRes.ok) { 48 + const profileData = await profileRes.json(); 49 + setProfile(profileData); 50 + did = profileData.did; 51 + } 52 + 53 + const decodedUrl = decodeURIComponent(targetUrl); 54 + 55 + const data = await getUserTargetItems(did, decodedUrl); 56 + setAnnotations(data.annotations || []); 57 + setHighlights(data.highlights || []); 58 + } catch (err: any) { 59 + setError(err.message); 60 + } finally { 61 + setLoading(false); 62 + } 63 + } 64 + fetchData(); 65 + }, [handle, targetUrl]); 66 + 67 + const displayName = profile?.displayName || profile?.handle || handle; 68 + const displayHandle = 69 + profile?.handle || (handle?.startsWith("did:") ? null : handle); 70 + const avatarUrl = getAvatarUrl(profile?.did, profile?.avatar); 71 + 72 + const getInitial = () => { 73 + return (displayName || displayHandle || "??") 74 + ?.substring(0, 2) 75 + .toUpperCase(); 76 + }; 77 + 78 + const totalItems = annotations.length + highlights.length; 79 + const bskyProfileUrl = displayHandle 80 + ? `https://bsky.app/profile/${displayHandle}` 81 + : `https://bsky.app/profile/${handle}`; 82 + 83 + const renderResults = () => { 84 + if (activeTab === "annotations" && annotations.length === 0) { 85 + return ( 86 + <div className="flex flex-col items-center justify-center p-12 text-center bg-surface-50 border border-dashed border-surface-200 rounded-2xl"> 87 + <div className="w-12 h-12 bg-surface-100 rounded-full flex items-center justify-center text-surface-400 mb-4"> 88 + <PenTool size={24} /> 89 + </div> 90 + <h3 className="text-lg font-medium text-surface-600"> 91 + No annotations 92 + </h3> 93 + </div> 94 + ); 95 + } 96 + 97 + if (activeTab === "highlights" && highlights.length === 0) { 98 + return ( 99 + <div className="flex flex-col items-center justify-center p-12 text-center bg-surface-50 border border-dashed border-surface-200 rounded-2xl"> 100 + <div className="w-12 h-12 bg-surface-100 rounded-full flex items-center justify-center text-surface-400 mb-4"> 101 + <Highlighter size={24} /> 102 + </div> 103 + <h3 className="text-lg font-medium text-surface-600"> 104 + No highlights 105 + </h3> 106 + </div> 107 + ); 108 + } 109 + 110 + return ( 111 + <div className="space-y-6"> 112 + {(activeTab === "all" || activeTab === "annotations") && 113 + annotations.map((a) => <Card key={a.uri} item={a} />)} 114 + {(activeTab === "all" || activeTab === "highlights") && 115 + highlights.map((h) => <Card key={h.uri} item={h} />)} 116 + </div> 117 + ); 118 + }; 119 + 120 + if (!targetUrl) { 121 + return ( 122 + <div className="max-w-2xl mx-auto py-20 text-center"> 123 + <div className="w-16 h-16 bg-surface-100 rounded-full flex items-center justify-center mx-auto mb-4 text-surface-400"> 124 + <Search size={32} /> 125 + </div> 126 + <h3 className="text-xl font-bold text-surface-900 mb-2"> 127 + No URL specified 128 + </h3> 129 + <p className="text-surface-500"> 130 + Please provide a URL to view annotations. 131 + </p> 132 + </div> 133 + ); 134 + } 135 + 136 + return ( 137 + <div className="max-w-3xl mx-auto pb-20"> 138 + <header className="flex items-center gap-6 mb-8 p-6 bg-white rounded-2xl border border-surface-200 shadow-sm"> 139 + <a 140 + href={bskyProfileUrl} 141 + target="_blank" 142 + rel="noopener noreferrer" 143 + className="shrink-0 hover:opacity-80 transition-opacity" 144 + > 145 + {avatarUrl ? ( 146 + <img 147 + src={avatarUrl} 148 + alt={displayName} 149 + className="w-20 h-20 rounded-full object-cover border-4 border-surface-50" 150 + /> 151 + ) : ( 152 + <div className="w-20 h-20 rounded-full bg-surface-100 flex items-center justify-center text-2xl font-bold text-surface-500 border-4 border-surface-50"> 153 + {getInitial()} 154 + </div> 155 + )} 156 + </a> 157 + <div className="flex-1"> 158 + <h1 className="text-2xl font-bold text-surface-900 mb-1"> 159 + {displayName} 160 + </h1> 161 + {displayHandle && ( 162 + <a 163 + href={bskyProfileUrl} 164 + target="_blank" 165 + rel="noopener noreferrer" 166 + className="text-surface-500 hover:text-primary-600 transition-colors bg-surface-50 hover:bg-primary-50 px-2 py-1 rounded-md text-sm inline-flex items-center gap-1" 167 + > 168 + @{displayHandle} <ExternalLink size={12} /> 169 + </a> 170 + )} 171 + </div> 172 + </header> 173 + 174 + <div className="mb-8 p-4 bg-surface-50 border border-surface-200 rounded-xl flex flex-col sm:flex-row sm:items-center gap-4"> 175 + <span className="text-sm font-semibold text-surface-500 uppercase tracking-wide"> 176 + Annotations on: 177 + </span> 178 + <a 179 + href={decodeURIComponent(targetUrl)} 180 + target="_blank" 181 + rel="noopener noreferrer" 182 + className="text-primary-600 hover:text-primary-700 hover:underline font-medium truncate flex-1 block" 183 + > 184 + {decodeURIComponent(targetUrl)} 185 + </a> 186 + </div> 187 + 188 + {loading && ( 189 + <div className="flex justify-center py-12"> 190 + <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div> 191 + </div> 192 + )} 193 + 194 + {error && ( 195 + <div className="mb-8 bg-red-50 text-red-600 p-4 rounded-xl flex items-start gap-3 border border-red-100"> 196 + <AlertTriangle className="shrink-0 mt-0.5" size={18} /> 197 + <p>{error}</p> 198 + </div> 199 + )} 200 + 201 + {!loading && !error && totalItems === 0 && ( 202 + <div className="text-center py-16 bg-surface-50 rounded-2xl border border-dashed border-surface-200"> 203 + <div className="w-12 h-12 bg-surface-100 rounded-full flex items-center justify-center mx-auto mb-4 text-surface-400"> 204 + <PenTool size={24} /> 205 + </div> 206 + <h3 className="text-lg font-bold text-surface-900 mb-1"> 207 + No items found 208 + </h3> 209 + <p className="text-surface-500"> 210 + {displayName} hasn&apos;t annotated this page yet. 211 + </p> 212 + </div> 213 + )} 214 + 215 + {!loading && !error && totalItems > 0 && ( 216 + <div className="animate-fade-in"> 217 + <div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-6"> 218 + <h2 className="text-xl font-bold text-surface-900"> 219 + {totalItems} item{totalItems !== 1 ? "s" : ""} 220 + </h2> 221 + <div className="flex bg-surface-100 p-1 rounded-xl self-start md:self-auto"> 222 + <button 223 + className={clsx( 224 + "px-4 py-1.5 rounded-lg text-sm font-medium transition-all", 225 + activeTab === "all" 226 + ? "bg-white text-surface-900 shadow-sm" 227 + : "text-surface-500 hover:text-surface-700", 228 + )} 229 + onClick={() => setActiveTab("all")} 230 + > 231 + All ({totalItems}) 232 + </button> 233 + <button 234 + className={clsx( 235 + "px-4 py-1.5 rounded-lg text-sm font-medium transition-all", 236 + activeTab === "annotations" 237 + ? "bg-white text-surface-900 shadow-sm" 238 + : "text-surface-500 hover:text-surface-700", 239 + )} 240 + onClick={() => setActiveTab("annotations")} 241 + > 242 + Annotations ({annotations.length}) 243 + </button> 244 + <button 245 + className={clsx( 246 + "px-4 py-1.5 rounded-lg text-sm font-medium transition-all", 247 + activeTab === "highlights" 248 + ? "bg-white text-surface-900 shadow-sm" 249 + : "text-surface-500 hover:text-surface-700", 250 + )} 251 + onClick={() => setActiveTab("highlights")} 252 + > 253 + Highlights ({highlights.length}) 254 + </button> 255 + </div> 256 + </div> 257 + <div className="space-y-6">{renderResults()}</div> 258 + </div> 259 + )} 260 + </div> 261 + ); 262 + }
+175
web/src/views/core/Feed.tsx
··· 1 + import React, { useEffect, useState } from "react"; 2 + import { getFeed } from "../../api/client"; 3 + import Card from "../../components/common/Card"; 4 + import { Loader2, Sparkles, Clock, Bookmark, Users } from "lucide-react"; 5 + import { useStore } from "@nanostores/react"; 6 + import { $user, initAuth } from "../../store/auth"; 7 + import type { AnnotationItem } from "../../types"; 8 + import { Tabs, EmptyState, Button } from "../../components/ui"; 9 + 10 + interface FeedProps { 11 + initialType?: string; 12 + motivation?: string; 13 + showTabs?: boolean; 14 + emptyMessage?: string; 15 + } 16 + 17 + export default function Feed({ 18 + initialType = "all", 19 + motivation, 20 + showTabs = true, 21 + emptyMessage = "No items found.", 22 + }: FeedProps) { 23 + const user = useStore($user); 24 + const [items, setItems] = useState<AnnotationItem[]>([]); 25 + const [loading, setLoading] = useState(true); 26 + const [activeTab, setActiveTab] = useState(initialType); 27 + 28 + useEffect(() => { 29 + initAuth(); 30 + }, []); 31 + 32 + useEffect(() => { 33 + const fetchFeed = async () => { 34 + setLoading(true); 35 + try { 36 + const type = activeTab; 37 + const data = await getFeed({ type, motivation }); 38 + setItems(data?.items || []); 39 + } catch (e) { 40 + console.error(e); 41 + } finally { 42 + setLoading(false); 43 + } 44 + }; 45 + fetchFeed(); 46 + }, [activeTab, motivation]); 47 + 48 + const handleDelete = (uri: string) => { 49 + setItems((prev) => prev.filter((i) => i.uri !== uri)); 50 + }; 51 + 52 + const tabs = [ 53 + { id: "all", label: "Recent" }, 54 + { id: "popular", label: "Popular" }, 55 + { id: "shelved", label: "Shelved" }, 56 + { id: "margin", label: "Margin" }, 57 + { id: "semble", label: "Semble" }, 58 + ]; 59 + 60 + if (!user && !loading) { 61 + return ( 62 + <div className="max-w-2xl mx-auto animate-fade-in"> 63 + <div className="text-center py-20 px-6"> 64 + <div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-primary-500 to-primary-600 mb-6"> 65 + <Sparkles className="text-white" size={28} /> 66 + </div> 67 + <h1 className="text-3xl font-display font-bold mb-3 tracking-tight text-surface-900 dark:text-white"> 68 + Welcome to Margin 69 + </h1> 70 + <p className="text-surface-500 dark:text-surface-400 mb-8 text-lg max-w-md mx-auto"> 71 + Annotate, highlight, and bookmark anything on the web. Your curated 72 + corner of the internet. 73 + </p> 74 + <div className="flex flex-col sm:flex-row gap-3 justify-center"> 75 + <Button size="lg" onClick={() => (window.location.href = "/login")}> 76 + Get Started 77 + </Button> 78 + <Button 79 + variant="secondary" 80 + size="lg" 81 + onClick={() => 82 + window.open("https://github.com/margin-at", "_blank") 83 + } 84 + > 85 + Learn More 86 + </Button> 87 + </div> 88 + </div> 89 + 90 + <div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-8"> 91 + <div className="p-5 rounded-xl bg-surface-50 dark:bg-surface-900 border border-surface-100 dark:border-surface-800"> 92 + <div className="w-10 h-10 rounded-lg bg-yellow-100 dark:bg-yellow-900/30 flex items-center justify-center mb-3"> 93 + <Sparkles 94 + size={20} 95 + className="text-yellow-600 dark:text-yellow-400" 96 + /> 97 + </div> 98 + <h3 className="font-semibold text-surface-900 dark:text-white mb-1"> 99 + Highlight 100 + </h3> 101 + <p className="text-sm text-surface-500 dark:text-surface-400"> 102 + Save key passages from any page 103 + </p> 104 + </div> 105 + <div className="p-5 rounded-xl bg-surface-50 dark:bg-surface-900 border border-surface-100 dark:border-surface-800"> 106 + <div className="w-10 h-10 rounded-lg bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center mb-3"> 107 + <Bookmark 108 + size={20} 109 + className="text-blue-600 dark:text-blue-400" 110 + /> 111 + </div> 112 + <h3 className="font-semibold text-surface-900 dark:text-white mb-1"> 113 + Bookmark 114 + </h3> 115 + <p className="text-sm text-surface-500 dark:text-surface-400"> 116 + Keep pages for later reading 117 + </p> 118 + </div> 119 + <div className="p-5 rounded-xl bg-surface-50 dark:bg-surface-900 border border-surface-100 dark:border-surface-800"> 120 + <div className="w-10 h-10 rounded-lg bg-green-100 dark:bg-green-900/30 flex items-center justify-center mb-3"> 121 + <Users size={20} className="text-green-600 dark:text-green-400" /> 122 + </div> 123 + <h3 className="font-semibold text-surface-900 dark:text-white mb-1"> 124 + Share 125 + </h3> 126 + <p className="text-sm text-surface-500 dark:text-surface-400"> 127 + Discover what others are reading 128 + </p> 129 + </div> 130 + </div> 131 + </div> 132 + ); 133 + } 134 + 135 + return ( 136 + <div className="max-w-2xl mx-auto animate-slide-up"> 137 + {showTabs && ( 138 + <Tabs 139 + tabs={tabs} 140 + activeTab={activeTab} 141 + onChange={setActiveTab} 142 + className="mb-6" 143 + /> 144 + )} 145 + 146 + {loading ? ( 147 + <div className="flex flex-col items-center justify-center py-20 gap-3"> 148 + <Loader2 149 + className="animate-spin text-primary-600 dark:text-primary-400" 150 + size={32} 151 + /> 152 + <p className="text-sm text-surface-400 dark:text-surface-500"> 153 + Loading feed... 154 + </p> 155 + </div> 156 + ) : items.length > 0 ? ( 157 + <div className="space-y-3"> 158 + {items.map((item) => ( 159 + <Card 160 + key={item.uri || item.cid} 161 + item={item} 162 + onDelete={handleDelete} 163 + /> 164 + ))} 165 + </div> 166 + ) : ( 167 + <EmptyState 168 + icon={<Clock size={48} />} 169 + title="Nothing here yet" 170 + message={emptyMessage} 171 + /> 172 + )} 173 + </div> 174 + ); 175 + }
+104
web/src/views/core/New.tsx
··· 1 + import React, { useState } from "react"; 2 + import { useNavigate, useSearchParams, Link } from "react-router-dom"; 3 + import { useStore } from "@nanostores/react"; 4 + import { $user } from "../../store/auth"; 5 + import Composer from "../../components/feed/Composer"; 6 + 7 + export default function NewAnnotationPage() { 8 + const user = useStore($user); 9 + const navigate = useNavigate(); 10 + const [searchParams] = useSearchParams(); 11 + 12 + const initialUrl = searchParams.get("url") || ""; 13 + 14 + let initialSelector: any = null; 15 + const selectorParam = searchParams.get("selector"); 16 + if (selectorParam) { 17 + try { 18 + initialSelector = JSON.parse(selectorParam); 19 + } catch (e) { 20 + console.error("Failed to parse selector:", e); 21 + } 22 + } 23 + 24 + const legacyQuote = searchParams.get("quote") || ""; 25 + if (legacyQuote && !initialSelector) { 26 + initialSelector = { 27 + type: "TextQuoteSelector", 28 + exact: legacyQuote, 29 + }; 30 + } 31 + 32 + const [url, setUrl] = useState(initialUrl); 33 + 34 + if (!user) { 35 + return ( 36 + <div className="max-w-sm mx-auto py-16 px-4"> 37 + <div className="bg-white dark:bg-surface-900 rounded-xl ring-1 ring-black/5 dark:ring-white/5 p-6 text-center"> 38 + <h2 className="text-xl font-bold text-surface-900 dark:text-white mb-2"> 39 + Sign in to create 40 + </h2> 41 + <p className="text-surface-500 dark:text-surface-400 text-sm mb-5"> 42 + You need a Bluesky account 43 + </p> 44 + <Link 45 + to="/login" 46 + className="block w-full py-2.5 px-4 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg transition-colors" 47 + > 48 + Sign in with Bluesky 49 + </Link> 50 + </div> 51 + </div> 52 + ); 53 + } 54 + 55 + const handleSuccess = () => { 56 + navigate("/home"); 57 + }; 58 + 59 + return ( 60 + <div className="max-w-2xl mx-auto pb-20"> 61 + <div className="mb-6 text-center sm:text-left"> 62 + <h1 className="text-2xl font-display font-bold text-surface-900 dark:text-white mb-1"> 63 + New Annotation 64 + </h1> 65 + <p className="text-surface-500 dark:text-surface-400"> 66 + Write in the margins of the web 67 + </p> 68 + </div> 69 + 70 + {!initialUrl && ( 71 + <div className="mb-4"> 72 + <label 73 + htmlFor="url-input" 74 + className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1.5" 75 + > 76 + URL to annotate 77 + </label> 78 + <input 79 + id="url-input" 80 + type="url" 81 + value={url} 82 + onChange={(e) => setUrl(e.target.value)} 83 + placeholder="https://example.com/article" 84 + className="w-full p-3 bg-white dark:bg-surface-900 border border-surface-200 dark:border-surface-700 rounded-lg text-surface-900 dark:text-white placeholder:text-surface-400 dark:placeholder:text-surface-500 focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500 dark:focus:border-primary-400 outline-none transition-all" 85 + required 86 + /> 87 + </div> 88 + )} 89 + 90 + <div className="bg-white dark:bg-surface-900 rounded-xl ring-1 ring-black/5 dark:ring-white/5 p-5"> 91 + <Composer 92 + url={ 93 + (url || initialUrl) && !/^(?:f|ht)tps?:\/\//.test(url || initialUrl) 94 + ? `https://${url || initialUrl}` 95 + : url || initialUrl 96 + } 97 + selector={initialSelector} 98 + onSuccess={handleSuccess} 99 + onCancel={() => navigate(-1)} 100 + /> 101 + </div> 102 + </div> 103 + ); 104 + }
+148
web/src/views/core/Notifications.tsx
··· 1 + import React, { useEffect, useState } from "react"; 2 + import { getNotifications, markNotificationsRead } from "../../api/client"; 3 + import type { NotificationItem } from "../../types"; 4 + import { Heart, MessageCircle, Bell, PenTool, Loader2 } from "lucide-react"; 5 + import Card from "../../components/common/Card"; 6 + import { formatDistanceToNow } from "date-fns"; 7 + import { clsx } from "clsx"; 8 + import { Avatar, EmptyState, Skeleton } from "../../components/ui"; 9 + 10 + const NotificationIcon = ({ type }: { type: string }) => { 11 + const iconClass = "p-2 rounded-full"; 12 + switch (type) { 13 + case "like": 14 + return ( 15 + <div className={clsx(iconClass, "bg-red-100 dark:bg-red-900/30")}> 16 + <Heart size={16} className="text-red-500" /> 17 + </div> 18 + ); 19 + case "reply": 20 + return ( 21 + <div className={clsx(iconClass, "bg-blue-100 dark:bg-blue-900/30")}> 22 + <MessageCircle size={16} className="text-blue-500" /> 23 + </div> 24 + ); 25 + case "highlight": 26 + return ( 27 + <div className={clsx(iconClass, "bg-yellow-100 dark:bg-yellow-900/30")}> 28 + <PenTool size={16} className="text-yellow-600" /> 29 + </div> 30 + ); 31 + default: 32 + return ( 33 + <div className={clsx(iconClass, "bg-surface-100 dark:bg-surface-800")}> 34 + <Bell size={16} className="text-surface-500" /> 35 + </div> 36 + ); 37 + } 38 + }; 39 + 40 + export default function Notifications() { 41 + const [notifications, setNotifications] = useState<NotificationItem[]>([]); 42 + const [loading, setLoading] = useState(true); 43 + 44 + useEffect(() => { 45 + loadNotifications(); 46 + }, []); 47 + 48 + const loadNotifications = async () => { 49 + setLoading(true); 50 + const data = await getNotifications(); 51 + setNotifications(data); 52 + setLoading(false); 53 + markNotificationsRead(); 54 + }; 55 + 56 + if (loading) { 57 + return ( 58 + <div className="max-w-2xl mx-auto animate-fade-in"> 59 + <h1 className="text-3xl font-display font-bold text-surface-900 dark:text-white mb-6"> 60 + Activity 61 + </h1> 62 + <div className="space-y-3"> 63 + {[1, 2, 3].map((i) => ( 64 + <div key={i} className="card p-4 flex gap-3"> 65 + <Skeleton variant="circular" className="w-10 h-10" /> 66 + <div className="flex-1 space-y-2"> 67 + <Skeleton width="60%" /> 68 + <Skeleton width="40%" /> 69 + </div> 70 + </div> 71 + ))} 72 + </div> 73 + </div> 74 + ); 75 + } 76 + 77 + if (notifications.length === 0) { 78 + return ( 79 + <div className="max-w-2xl mx-auto animate-fade-in"> 80 + <h1 className="text-3xl font-display font-bold text-surface-900 dark:text-white mb-6"> 81 + Activity 82 + </h1> 83 + <EmptyState 84 + icon={<Bell size={48} />} 85 + title="No activity yet" 86 + message="Interactions with your content will appear here." 87 + /> 88 + </div> 89 + ); 90 + } 91 + 92 + return ( 93 + <div className="max-w-2xl mx-auto animate-slide-up"> 94 + <h1 className="text-3xl font-display font-bold text-surface-900 dark:text-white mb-6"> 95 + Activity 96 + </h1> 97 + <div className="space-y-2"> 98 + {notifications.map((n) => ( 99 + <div 100 + key={n.id} 101 + className={clsx( 102 + "card p-4 transition-all", 103 + !n.readAt && 104 + "ring-2 ring-primary-500/20 dark:ring-primary-400/20 bg-primary-50/30 dark:bg-primary-900/10", 105 + )} 106 + > 107 + <div className="flex gap-3"> 108 + <div className="shrink-0"> 109 + <NotificationIcon type={n.type} /> 110 + </div> 111 + <div className="flex-1 min-w-0"> 112 + <div className="flex items-center gap-2 flex-wrap"> 113 + <Avatar src={n.actor.avatar} size="xs" /> 114 + <span className="font-semibold text-surface-900 dark:text-white text-sm truncate"> 115 + {n.actor.displayName || n.actor.handle} 116 + </span> 117 + <span className="text-surface-500 dark:text-surface-400 text-sm"> 118 + {n.type === "like" && "liked your post"} 119 + {n.type === "reply" && "replied to you"} 120 + {n.type === "follow" && "followed you"} 121 + {n.type === "highlight" && "highlighted"} 122 + </span> 123 + <span className="text-surface-400 dark:text-surface-500 text-xs ml-auto"> 124 + {formatDistanceToNow(new Date(n.createdAt), { 125 + addSuffix: false, 126 + })} 127 + </span> 128 + </div> 129 + 130 + {n.subject && ( 131 + <div className="mt-3 pl-3 border-l-2 border-surface-200 dark:border-surface-700"> 132 + {n.type === "reply" && n.subject.text ? ( 133 + <p className="text-surface-600 dark:text-surface-300 text-sm"> 134 + {n.subject.text} 135 + </p> 136 + ) : n.subject.uri ? ( 137 + <Card item={n.subject} hideShare /> 138 + ) : null} 139 + </div> 140 + )} 141 + </div> 142 + </div> 143 + </div> 144 + ))} 145 + </div> 146 + </div> 147 + ); 148 + }
+264
web/src/views/core/Settings.tsx
··· 1 + import React, { useEffect, useState } from "react"; 2 + import { useStore } from "@nanostores/react"; 3 + import { $user, logout } from "../../store/auth"; 4 + import { $theme, setTheme, type Theme } from "../../store/theme"; 5 + import { 6 + getAPIKeys, 7 + createAPIKey, 8 + deleteAPIKey, 9 + type APIKey, 10 + } from "../../api/client"; 11 + import { 12 + Copy, 13 + Trash2, 14 + Key, 15 + Plus, 16 + Check, 17 + Sun, 18 + Moon, 19 + Monitor, 20 + LogOut, 21 + ChevronRight, 22 + } from "lucide-react"; 23 + import { 24 + Avatar, 25 + Button, 26 + Input, 27 + Skeleton, 28 + EmptyState, 29 + } from "../../components/ui"; 30 + 31 + export default function Settings() { 32 + const user = useStore($user); 33 + const theme = useStore($theme); 34 + const [keys, setKeys] = useState<APIKey[]>([]); 35 + const [loading, setLoading] = useState(true); 36 + const [newKeyName, setNewKeyName] = useState(""); 37 + const [createdKey, setCreatedKey] = useState<string | null>(null); 38 + const [justCopied, setJustCopied] = useState(false); 39 + const [creating, setCreating] = useState(false); 40 + 41 + useEffect(() => { 42 + loadKeys(); 43 + }, []); 44 + 45 + const loadKeys = async () => { 46 + setLoading(true); 47 + const data = await getAPIKeys(); 48 + setKeys(data); 49 + setLoading(false); 50 + }; 51 + 52 + const handleCreate = async (e: React.FormEvent) => { 53 + e.preventDefault(); 54 + if (!newKeyName.trim()) return; 55 + 56 + setCreating(true); 57 + const res = await createAPIKey(newKeyName); 58 + if (res) { 59 + setKeys([res, ...keys]); 60 + setCreatedKey(res.key || null); 61 + setNewKeyName(""); 62 + } 63 + setCreating(false); 64 + }; 65 + 66 + const handleDelete = async (id: string) => { 67 + if (window.confirm("Revoke this key? Apps using it will stop working.")) { 68 + const success = await deleteAPIKey(id); 69 + if (success) { 70 + setKeys((prev) => prev.filter((k) => k.id !== id)); 71 + } 72 + } 73 + }; 74 + 75 + const copyToClipboard = async (text: string) => { 76 + await navigator.clipboard.writeText(text); 77 + setJustCopied(true); 78 + setTimeout(() => setJustCopied(false), 2000); 79 + }; 80 + 81 + if (!user) return null; 82 + 83 + const themeOptions: { value: Theme; label: string; icon: typeof Sun }[] = [ 84 + { value: "light", label: "Light", icon: Sun }, 85 + { value: "dark", label: "Dark", icon: Moon }, 86 + { value: "system", label: "System", icon: Monitor }, 87 + ]; 88 + 89 + return ( 90 + <div className="max-w-2xl mx-auto animate-slide-up"> 91 + <h1 className="text-3xl font-display font-bold text-surface-900 dark:text-white mb-8"> 92 + Settings 93 + </h1> 94 + 95 + <div className="space-y-6"> 96 + <section className="card p-5"> 97 + <h2 className="text-xs font-semibold text-surface-500 dark:text-surface-400 uppercase tracking-wider mb-4"> 98 + Profile 99 + </h2> 100 + <div className="flex gap-4 items-center"> 101 + <Avatar did={user.did} avatar={user.avatar} size="lg" /> 102 + <div className="flex-1"> 103 + <p className="font-semibold text-surface-900 dark:text-white text-lg"> 104 + {user.displayName || user.handle} 105 + </p> 106 + <p className="text-surface-500 dark:text-surface-400"> 107 + @{user.handle} 108 + </p> 109 + </div> 110 + <ChevronRight 111 + className="text-surface-300 dark:text-surface-600" 112 + size={20} 113 + /> 114 + </div> 115 + </section> 116 + 117 + <section className="card p-5"> 118 + <h2 className="text-xs font-semibold text-surface-500 dark:text-surface-400 uppercase tracking-wider mb-4"> 119 + Appearance 120 + </h2> 121 + <div className="flex gap-2"> 122 + {themeOptions.map((opt) => ( 123 + <button 124 + key={opt.value} 125 + onClick={() => setTheme(opt.value)} 126 + className={`flex-1 flex flex-col items-center gap-2 p-4 rounded-xl border-2 transition-all ${ 127 + theme === opt.value 128 + ? "border-primary-500 bg-primary-50 dark:bg-primary-900/20" 129 + : "border-surface-200 dark:border-surface-700 hover:border-surface-300 dark:hover:border-surface-600" 130 + }`} 131 + > 132 + <opt.icon 133 + size={24} 134 + className={ 135 + theme === opt.value 136 + ? "text-primary-600 dark:text-primary-400" 137 + : "text-surface-400 dark:text-surface-500" 138 + } 139 + /> 140 + <span 141 + className={`text-sm font-medium ${theme === opt.value ? "text-primary-600 dark:text-primary-400" : "text-surface-600 dark:text-surface-400"}`} 142 + > 143 + {opt.label} 144 + </span> 145 + </button> 146 + ))} 147 + </div> 148 + </section> 149 + 150 + <section className="card p-5"> 151 + <h2 className="text-xs font-semibold text-surface-500 dark:text-surface-400 uppercase tracking-wider mb-1"> 152 + API Keys 153 + </h2> 154 + <p className="text-sm text-surface-400 dark:text-surface-500 mb-5"> 155 + For the browser extension and other apps 156 + </p> 157 + 158 + <form onSubmit={handleCreate} className="flex gap-2 mb-5"> 159 + <div className="flex-1"> 160 + <Input 161 + value={newKeyName} 162 + onChange={(e) => setNewKeyName(e.target.value)} 163 + placeholder="Key name, e.g. Chrome Extension" 164 + /> 165 + </div> 166 + <Button 167 + type="submit" 168 + disabled={!newKeyName.trim()} 169 + loading={creating} 170 + icon={<Plus size={16} />} 171 + > 172 + Generate 173 + </Button> 174 + </form> 175 + 176 + {createdKey && ( 177 + <div className="mb-5 p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-xl animate-scale-in"> 178 + <div className="flex items-start gap-3"> 179 + <div className="p-2 bg-green-100 dark:bg-green-900/40 rounded-lg"> 180 + <Key 181 + size={16} 182 + className="text-green-600 dark:text-green-400" 183 + /> 184 + </div> 185 + <div className="flex-1 min-w-0"> 186 + <p className="text-green-800 dark:text-green-200 text-sm font-medium mb-2"> 187 + Copy now - you won't see this again! 188 + </p> 189 + <div className="flex items-center gap-2"> 190 + <code className="flex-1 bg-white dark:bg-surface-900 border border-green-200 dark:border-green-800 px-3 py-2 rounded-lg text-xs font-mono text-green-900 dark:text-green-100 break-all"> 191 + {createdKey} 192 + </code> 193 + <Button 194 + variant="ghost" 195 + size="sm" 196 + onClick={() => copyToClipboard(createdKey)} 197 + icon={ 198 + justCopied ? <Check size={16} /> : <Copy size={16} /> 199 + } 200 + /> 201 + </div> 202 + </div> 203 + </div> 204 + </div> 205 + )} 206 + 207 + {loading ? ( 208 + <div className="space-y-3"> 209 + <Skeleton className="h-16 rounded-xl" /> 210 + <Skeleton className="h-16 rounded-xl" /> 211 + </div> 212 + ) : keys.length === 0 ? ( 213 + <EmptyState 214 + icon={<Key size={40} />} 215 + message="No API keys yet. Create one to use with the browser extension." 216 + /> 217 + ) : ( 218 + <div className="space-y-2"> 219 + {keys.map((key) => ( 220 + <div 221 + key={key.id} 222 + className="flex items-center justify-between p-4 bg-surface-50 dark:bg-surface-800 rounded-xl group transition-all hover:bg-surface-100 dark:hover:bg-surface-750" 223 + > 224 + <div className="flex items-center gap-3"> 225 + <div className="p-2 bg-surface-200 dark:bg-surface-700 rounded-lg"> 226 + <Key 227 + size={16} 228 + className="text-surface-500 dark:text-surface-400" 229 + /> 230 + </div> 231 + <div> 232 + <p className="font-medium text-surface-900 dark:text-white"> 233 + {key.alias} 234 + </p> 235 + <p className="text-xs text-surface-500 dark:text-surface-400"> 236 + Created {new Date(key.createdAt).toLocaleDateString()} 237 + </p> 238 + </div> 239 + </div> 240 + <button 241 + onClick={() => handleDelete(key.id)} 242 + className="p-2 text-surface-400 dark:text-surface-500 hover:text-red-500 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-all opacity-0 group-hover:opacity-100" 243 + > 244 + <Trash2 size={18} /> 245 + </button> 246 + </div> 247 + ))} 248 + </div> 249 + )} 250 + </section> 251 + 252 + <section className="card p-5"> 253 + <button 254 + onClick={logout} 255 + className="flex items-center gap-3 w-full text-left text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 p-3 -m-3 rounded-xl transition-colors" 256 + > 257 + <LogOut size={20} /> 258 + <span className="font-medium">Log out</span> 259 + </button> 260 + </section> 261 + </div> 262 + </div> 263 + ); 264 + }
+408
web/src/views/profile/Profile.tsx
··· 1 + import React, { useEffect, useState } from "react"; 2 + import { getProfile, getFeed, getCollections } from "../../api/client"; 3 + import Card from "../../components/common/Card"; 4 + import { 5 + Loader2, 6 + Edit2, 7 + Bookmark, 8 + PenTool, 9 + MessageSquare, 10 + Folder, 11 + Share2, 12 + MoreHorizontal, 13 + Plus, 14 + ArrowRight, 15 + Github, 16 + Linkedin, 17 + Link2, 18 + } from "lucide-react"; 19 + import { TangledIcon } from "../../components/common/Icons"; 20 + import type { UserProfile, AnnotationItem, Collection } from "../../types"; 21 + import { useStore } from "@nanostores/react"; 22 + import { $user } from "../../store/auth"; 23 + import EditProfileModal from "../../components/modals/EditProfileModal"; 24 + import ExternalLinkModal from "../../components/modals/ExternalLinkModal"; 25 + import CollectionIcon from "../../components/common/CollectionIcon"; 26 + import { $preferences, loadPreferences } from "../../store/preferences"; 27 + import { Link } from "react-router-dom"; 28 + import { 29 + Avatar, 30 + Tabs, 31 + EmptyState, 32 + Button, 33 + Skeleton, 34 + } from "../../components/ui"; 35 + 36 + interface ProfileProps { 37 + did: string; 38 + } 39 + 40 + type Tab = "annotations" | "highlights" | "bookmarks" | "collections"; 41 + 42 + export default function Profile({ did }: ProfileProps) { 43 + const [profile, setProfile] = useState<UserProfile | null>(null); 44 + const [loading, setLoading] = useState(true); 45 + const [activeTab, setActiveTab] = useState<Tab>("annotations"); 46 + 47 + const [annotations, setAnnotations] = useState<AnnotationItem[]>([]); 48 + const [highlights, setHighlights] = useState<AnnotationItem[]>([]); 49 + const [bookmarks, setBookmarks] = useState<AnnotationItem[]>([]); 50 + const [collections, setCollections] = useState<Collection[]>([]); 51 + const [dataLoading, setDataLoading] = useState(false); 52 + 53 + const user = useStore($user); 54 + const isOwner = user?.did === did; 55 + const [showEdit, setShowEdit] = useState(false); 56 + const [externalLink, setExternalLink] = useState<string | null>(null); 57 + 58 + const formatLinkText = (url: string) => { 59 + try { 60 + const urlObj = new URL(url.startsWith("http") ? url : `https://${url}`); 61 + const domain = urlObj.hostname.replace(/^www\./, ""); 62 + const path = urlObj.pathname.replace(/^\/|\/$/g, ""); 63 + 64 + if ( 65 + domain.includes("github.com") || 66 + domain.includes("twitter.com") || 67 + domain.includes("x.com") 68 + ) { 69 + return path ? `${domain}/${path}` : domain; 70 + } 71 + if (domain.includes("linkedin.com") && path.includes("in/")) { 72 + return `linkedin.com/${path.split("in/")[1]}`; 73 + } 74 + if (domain.includes("tangled")) { 75 + return path ? `${domain}/${path}` : domain; 76 + } 77 + 78 + return domain + (path && path.length < 20 ? `/${path}` : ""); 79 + } catch { 80 + return url; 81 + } 82 + }; 83 + 84 + useEffect(() => { 85 + const loadProfile = async () => { 86 + setLoading(true); 87 + try { 88 + const marginPromise = getProfile(did); 89 + const bskyPromise = fetch( 90 + `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}`, 91 + ) 92 + .then((res) => (res.ok ? res.json() : null)) 93 + .catch(() => null); 94 + 95 + const [marginData, bskyData] = await Promise.all([ 96 + marginPromise, 97 + bskyPromise, 98 + ]); 99 + 100 + const merged: UserProfile = { 101 + did: bskyData?.did || marginData?.did || did, 102 + handle: bskyData?.handle || marginData?.handle || "", 103 + displayName: bskyData?.displayName || marginData?.displayName, 104 + avatar: bskyData?.avatar || marginData?.avatar, 105 + description: bskyData?.description || marginData?.description, 106 + banner: bskyData?.banner || marginData?.banner, 107 + website: marginData?.website, 108 + links: marginData?.links || [], 109 + followersCount: 110 + bskyData?.followersCount || marginData?.followersCount, 111 + followsCount: bskyData?.followsCount || marginData?.followsCount, 112 + postsCount: bskyData?.postsCount || marginData?.postsCount, 113 + }; 114 + 115 + setProfile(merged); 116 + } catch (e) { 117 + console.error("Profile load failed", e); 118 + } finally { 119 + setLoading(false); 120 + } 121 + }; 122 + if (did) loadProfile(); 123 + }, [did]); 124 + 125 + useEffect(() => { 126 + loadPreferences(); 127 + }, []); 128 + 129 + useEffect(() => { 130 + const loadTabContent = async () => { 131 + const isHandle = !did.startsWith("did:"); 132 + const resolvedDid = isHandle ? profile?.did : did; 133 + 134 + if (!resolvedDid) return; 135 + 136 + setDataLoading(true); 137 + try { 138 + if (activeTab === "annotations") { 139 + const res = await getFeed({ 140 + creator: resolvedDid, 141 + motivation: "commenting", 142 + limit: 50, 143 + }); 144 + setAnnotations(res.items || []); 145 + } else if (activeTab === "highlights") { 146 + const res = await getFeed({ 147 + creator: resolvedDid, 148 + motivation: "highlighting", 149 + limit: 50, 150 + }); 151 + setHighlights(res.items || []); 152 + } else if (activeTab === "bookmarks") { 153 + const res = await getFeed({ 154 + creator: resolvedDid, 155 + motivation: "bookmarking", 156 + limit: 50, 157 + }); 158 + setBookmarks(res.items || []); 159 + } else if (activeTab === "collections") { 160 + const res = await getCollections(resolvedDid); 161 + setCollections(res); 162 + } 163 + } catch (e) { 164 + console.error(e); 165 + } finally { 166 + setDataLoading(false); 167 + } 168 + }; 169 + loadTabContent(); 170 + }, [profile?.did, did, activeTab]); 171 + 172 + if (loading) { 173 + return ( 174 + <div className="max-w-2xl mx-auto animate-fade-in"> 175 + <div className="card p-5 mb-4"> 176 + <div className="flex items-start gap-4"> 177 + <Skeleton variant="circular" className="w-16 h-16" /> 178 + <div className="flex-1 space-y-2"> 179 + <Skeleton width="40%" className="h-6" /> 180 + <Skeleton width="25%" className="h-4" /> 181 + <Skeleton width="60%" className="h-4" /> 182 + </div> 183 + </div> 184 + </div> 185 + <Skeleton className="h-10 mb-4" /> 186 + <div className="space-y-3"> 187 + <Skeleton className="h-32 rounded-lg" /> 188 + <Skeleton className="h-32 rounded-lg" /> 189 + </div> 190 + </div> 191 + ); 192 + } 193 + 194 + if (!profile) { 195 + return ( 196 + <EmptyState 197 + title="User not found" 198 + message="This profile doesn't exist or couldn't be loaded." 199 + /> 200 + ); 201 + } 202 + 203 + const tabs = [ 204 + { id: "annotations", label: "Notes" }, 205 + { id: "highlights", label: "Highlights" }, 206 + { id: "bookmarks", label: "Bookmarks" }, 207 + { id: "collections", label: "Collections" }, 208 + ]; 209 + 210 + const currentItems = 211 + activeTab === "annotations" 212 + ? annotations 213 + : activeTab === "highlights" 214 + ? highlights 215 + : bookmarks; 216 + 217 + return ( 218 + <div className="max-w-2xl mx-auto animate-slide-up"> 219 + <div className="card p-5 mb-4"> 220 + <div className="flex items-start gap-4"> 221 + <Avatar 222 + did={profile.did} 223 + avatar={profile.avatar} 224 + size="xl" 225 + className="ring-4 ring-surface-100 dark:ring-surface-800" 226 + /> 227 + 228 + <div className="flex-1 min-w-0"> 229 + <div className="flex items-start justify-between gap-3"> 230 + <div className="min-w-0"> 231 + <h1 className="text-xl font-bold text-surface-900 dark:text-white truncate"> 232 + {profile.displayName || profile.handle} 233 + </h1> 234 + <p className="text-surface-500 dark:text-surface-400"> 235 + @{profile.handle} 236 + </p> 237 + </div> 238 + {isOwner && ( 239 + <Button 240 + variant="secondary" 241 + size="sm" 242 + onClick={() => setShowEdit(true)} 243 + icon={<Edit2 size={14} />} 244 + > 245 + <span className="hidden sm:inline">Edit</span> 246 + </Button> 247 + )} 248 + </div> 249 + 250 + {profile.description && ( 251 + <p className="text-surface-600 dark:text-surface-300 text-sm mt-3 line-clamp-2"> 252 + {profile.description} 253 + </p> 254 + )} 255 + 256 + <div className="flex flex-wrap gap-3 mt-3"> 257 + {[ 258 + ...(profile.website ? [profile.website] : []), 259 + ...(profile.links || []), 260 + ] 261 + .filter((link, index, self) => self.indexOf(link) === index) 262 + .map((link) => { 263 + let icon; 264 + if (link.includes("github.com")) { 265 + icon = <Github size={16} />; 266 + } else if (link.includes("linkedin.com")) { 267 + icon = <Linkedin size={16} />; 268 + } else if ( 269 + link.includes("tangled.sh") || 270 + link.includes("tangled.org") 271 + ) { 272 + icon = <TangledIcon size={16} />; 273 + } else { 274 + icon = <Link2 size={16} />; 275 + } 276 + 277 + return ( 278 + <button 279 + key={link} 280 + onClick={() => { 281 + const fullUrl = link.startsWith("http") 282 + ? link 283 + : `https://${link}`; 284 + try { 285 + const hostname = new URL(fullUrl).hostname; 286 + const skipped = 287 + $preferences.get().externalLinkSkippedHostnames; 288 + if (skipped.includes(hostname)) { 289 + window.open( 290 + fullUrl, 291 + "_blank", 292 + "noopener,noreferrer", 293 + ); 294 + } else { 295 + setExternalLink(fullUrl); 296 + } 297 + } catch { 298 + setExternalLink(fullUrl); 299 + } 300 + }} 301 + className="flex items-center gap-1.5 text-sm text-surface-500 dark:text-surface-400 hover:text-primary-600 dark:hover:text-primary-400 transition-colors" 302 + > 303 + {icon} 304 + <span className="truncate max-w-[200px]"> 305 + {formatLinkText(link)} 306 + </span> 307 + </button> 308 + ); 309 + })} 310 + </div> 311 + </div> 312 + </div> 313 + </div> 314 + 315 + <Tabs 316 + tabs={tabs} 317 + activeTab={activeTab} 318 + onChange={(id) => setActiveTab(id as Tab)} 319 + className="mb-4" 320 + /> 321 + 322 + <div className="min-h-[200px]"> 323 + {dataLoading ? ( 324 + <div className="flex flex-col items-center justify-center py-12 gap-3"> 325 + <Loader2 326 + className="animate-spin text-primary-600 dark:text-primary-400" 327 + size={24} 328 + /> 329 + <p className="text-sm text-surface-400 dark:text-surface-500"> 330 + Loading... 331 + </p> 332 + </div> 333 + ) : activeTab === "collections" ? ( 334 + collections.length === 0 ? ( 335 + <EmptyState 336 + icon={<Folder size={40} />} 337 + message={ 338 + isOwner 339 + ? "You haven't created any collections yet." 340 + : "No collections" 341 + } 342 + /> 343 + ) : ( 344 + <div className="grid grid-cols-1 gap-2"> 345 + {collections.map((collection) => ( 346 + <Link 347 + key={collection.id} 348 + to={`/${collection.creator?.handle || profile.handle}/collection/${(collection.uri || "").split("/").pop()}`} 349 + className="group card p-4 hover:ring-primary-300 dark:hover:ring-primary-600 transition-all flex items-center gap-4" 350 + > 351 + <div className="p-2.5 bg-primary-50 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400 rounded-xl"> 352 + <CollectionIcon icon={collection.icon} size={20} /> 353 + </div> 354 + <div className="flex-1 min-w-0"> 355 + <h3 className="font-semibold text-surface-900 dark:text-white truncate group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors"> 356 + {collection.name} 357 + </h3> 358 + <p className="text-sm text-surface-500 dark:text-surface-400"> 359 + {collection.itemCount}{" "} 360 + {collection.itemCount === 1 ? "item" : "items"} 361 + </p> 362 + </div> 363 + </Link> 364 + ))} 365 + </div> 366 + ) 367 + ) : currentItems.length > 0 ? ( 368 + <div className="space-y-3"> 369 + {currentItems.map((item) => ( 370 + <Card key={item.uri || item.cid} item={item} /> 371 + ))} 372 + </div> 373 + ) : ( 374 + <EmptyState 375 + icon={ 376 + activeTab === "annotations" ? ( 377 + <MessageSquare size={40} /> 378 + ) : activeTab === "highlights" ? ( 379 + <PenTool size={40} /> 380 + ) : ( 381 + <Bookmark size={40} /> 382 + ) 383 + } 384 + message={ 385 + isOwner 386 + ? `You haven't added any ${activeTab} yet.` 387 + : `No ${activeTab}` 388 + } 389 + /> 390 + )} 391 + </div> 392 + 393 + {showEdit && profile && ( 394 + <EditProfileModal 395 + profile={profile} 396 + onClose={() => setShowEdit(false)} 397 + onUpdate={(updated) => setProfile(updated)} 398 + /> 399 + )} 400 + 401 + <ExternalLinkModal 402 + isOpen={!!externalLink} 403 + onClose={() => setExternalLink(null)} 404 + url={externalLink} 405 + /> 406 + </div> 407 + ); 408 + }
+55 -55
web/tailwind.config.mjs
··· 1 - 2 1 /** @type {import('tailwindcss').Config} */ 3 - import defaultTheme from 'tailwindcss/defaultTheme'; 2 + import defaultTheme from "tailwindcss/defaultTheme"; 4 3 5 4 export default { 6 - darkMode: ['selector', '[data-theme="dark"]'], 7 - content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'], 8 - theme: { 9 - extend: { 10 - fontFamily: { 11 - sans: ['Inter', ...defaultTheme.fontFamily.sans], 12 - display: ['Outfit', ...defaultTheme.fontFamily.sans], 13 - }, 14 - colors: { 15 - primary: { 16 - 50: '#f0f7ff', 17 - 100: '#e0effe', 18 - 200: '#bae0fd', 19 - 300: '#7cc2fc', 20 - 400: '#36a2fa', 21 - 500: '#0083f5', 22 - 600: '#0066d6', 23 - 700: '#0051ab', 24 - 800: '#00458d', 25 - 900: '#063a70', 26 - 950: '#04254d', 27 - }, 28 - surface: { 29 - 50: '#fafafa', 30 - 100: '#f4f4f5', 31 - 200: '#e4e4e7', 32 - 300: '#d4d4d8', 33 - 400: '#a1a1aa', 34 - 500: '#71717a', 35 - 600: '#52525b', 36 - 700: '#3f3f46', 37 - 800: '#27272a', 38 - 900: '#18181b', 39 - 950: '#09090b', 40 - } 41 - }, 42 - animation: { 43 - 'fade-in': 'fadeIn 0.3s ease-out', 44 - }, 45 - keyframes: { 46 - fadeIn: { 47 - '0%': { opacity: '0' }, 48 - '100%': { opacity: '1' }, 49 - } 50 - }, 51 - boxShadow: { 52 - 'sm': '0 1px 2px 0 rgb(0 0 0 / 0.05)', 53 - 'DEFAULT': '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)', 54 - 'md': '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)', 55 - 'lg': '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)', 56 - } 5 + darkMode: ["selector", '[data-theme="dark"]'], 6 + content: ["./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}"], 7 + theme: { 8 + extend: { 9 + fontFamily: { 10 + sans: ["Inter", ...defaultTheme.fontFamily.sans], 11 + display: ["Outfit", ...defaultTheme.fontFamily.sans], 12 + }, 13 + colors: { 14 + primary: { 15 + 50: "#f0f7ff", 16 + 100: "#e0effe", 17 + 200: "#bae0fd", 18 + 300: "#7cc2fc", 19 + 400: "#36a2fa", 20 + 500: "#0083f5", 21 + 600: "#0066d6", 22 + 700: "#0051ab", 23 + 800: "#00458d", 24 + 900: "#063a70", 25 + 950: "#04254d", 57 26 }, 27 + surface: { 28 + 50: "#fafafa", 29 + 100: "#f4f4f5", 30 + 200: "#e4e4e7", 31 + 300: "#d4d4d8", 32 + 400: "#a1a1aa", 33 + 500: "#71717a", 34 + 600: "#52525b", 35 + 700: "#3f3f46", 36 + 800: "#27272a", 37 + 900: "#18181b", 38 + 950: "#09090b", 39 + }, 40 + }, 41 + animation: { 42 + "fade-in": "fadeIn 0.3s ease-out", 43 + }, 44 + keyframes: { 45 + fadeIn: { 46 + "0%": { opacity: "0" }, 47 + "100%": { opacity: "1" }, 48 + }, 49 + }, 50 + boxShadow: { 51 + sm: "0 1px 2px 0 rgb(0 0 0 / 0.05)", 52 + DEFAULT: 53 + "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)", 54 + md: "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)", 55 + lg: "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)", 56 + }, 58 57 }, 59 - plugins: [], 60 - } 58 + }, 59 + plugins: [], 60 + };
+3 -8
web/tsconfig.json
··· 1 1 { 2 2 "extends": "astro/tsconfigs/strict", 3 - "include": [ 4 - ".astro/types.d.ts", 5 - "**/*" 6 - ], 7 - "exclude": [ 8 - "dist" 9 - ], 3 + "include": [".astro/types.d.ts", "**/*"], 4 + "exclude": ["dist"], 10 5 "compilerOptions": { 11 6 "jsx": "react-jsx", 12 7 "jsxImportSource": "react" 13 8 } 14 - } 9 + }