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

delete old frontend files

-16847
-40
web/eslint.config.js
··· 1 - import js from "@eslint/js"; 2 - import globals from "globals"; 3 - import react from "eslint-plugin-react"; 4 - import reactHooks from "eslint-plugin-react-hooks"; 5 - import reactRefresh from "eslint-plugin-react-refresh"; 6 - 7 - export default [ 8 - { ignores: ["dist"] }, 9 - { 10 - files: ["**/*.{js,jsx}"], 11 - languageOptions: { 12 - ecmaVersion: 2020, 13 - globals: globals.browser, 14 - parserOptions: { 15 - ecmaVersion: "latest", 16 - ecmaFeatures: { jsx: true }, 17 - sourceType: "module", 18 - }, 19 - }, 20 - settings: { react: { version: "18.3" } }, 21 - plugins: { 22 - react, 23 - "react-hooks": reactHooks, 24 - "react-refresh": reactRefresh, 25 - }, 26 - rules: { 27 - ...js.configs.recommended.rules, 28 - ...react.configs.recommended.rules, 29 - ...react.configs["jsx-runtime"].rules, 30 - ...reactHooks.configs.recommended.rules, 31 - "react/jsx-no-target-blank": "off", 32 - "react-refresh/only-export-components": [ 33 - "warn", 34 - { allowConstantExport: true }, 35 - ], 36 - "no-unused-vars": ["warn", { argsIgnorePattern: "^_" }], 37 - "react/prop-types": "off", 38 - }, 39 - }, 40 - ];
-24
web/index.html
··· 1 - <!doctype html> 2 - <html lang="en"> 3 - <head> 4 - <meta charset="UTF-8" /> 5 - <link rel="icon" href="/favicon.ico" /> 6 - <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 7 - <meta 8 - name="description" 9 - content="Margin - Write in the margins of the web. Comment on any URL with AT Protocol." 10 - /> 11 - <title>Margin - Write in the margins of the web</title> 12 - <link rel="preconnect" href="https://fonts.googleapis.com" /> 13 - <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> 14 - <link 15 - href="https://fonts.googleapis.com/css2?family=Instrument+Sans:ital,wght@0,400..700;1,400..700&family=Plus+Jakarta+Sans:ital,wght@0,200..800;1,200..800&display=swap" 16 - rel="stylesheet" 17 - /> 18 - </head> 19 - 20 - <body> 21 - <div id="root"></div> 22 - <script type="module" src="/src/main.jsx"></script> 23 - </body> 24 - </html>
-81
web/src/App.jsx
··· 1 - import { Routes, Route } from "react-router-dom"; 2 - import { useEffect } from "react"; 3 - import { AuthProvider, useAuth } from "./context/AuthContext"; 4 - import AppLayout from "./components/AppLayout"; 5 - import Feed from "./pages/Feed"; 6 - import Url from "./pages/Url"; 7 - import UserUrl from "./pages/UserUrl"; 8 - import Profile from "./pages/Profile"; 9 - import Login from "./pages/Login"; 10 - import New from "./pages/New"; 11 - import Bookmarks from "./pages/Bookmarks"; 12 - import Highlights from "./pages/Highlights"; 13 - import Notifications from "./pages/Notifications"; 14 - import AnnotationDetail from "./pages/AnnotationDetail"; 15 - import Collections from "./pages/Collections"; 16 - import CollectionDetail from "./pages/CollectionDetail"; 17 - import Privacy from "./pages/Privacy"; 18 - import Terms from "./pages/Terms"; 19 - import Settings from "./pages/Settings"; 20 - import Landing from "./pages/Landing"; 21 - import ScrollToTop from "./components/ScrollToTop"; 22 - import { ThemeProvider } from "./context/ThemeContext"; 23 - 24 - function AppContent() { 25 - const { user } = useAuth(); 26 - 27 - useEffect(() => { 28 - if (user) { 29 - fetch("/api/sync", { method: "POST" }).catch(console.error); 30 - } 31 - }, [user]); 32 - 33 - return ( 34 - <AppLayout> 35 - <ScrollToTop /> 36 - <Routes> 37 - <Route path="/home" element={<Feed />} /> 38 - <Route path="/url" element={<Url />} /> 39 - <Route path="/new" element={<New />} /> 40 - <Route path="/bookmarks" element={<Bookmarks />} /> 41 - <Route path="/highlights" element={<Highlights />} /> 42 - <Route path="/notifications" element={<Notifications />} /> 43 - <Route path="/profile" element={<Profile />} /> 44 - <Route path="/profile/:handle" element={<Profile />} /> 45 - <Route path="/login" element={<Login />} /> 46 - <Route path="/at/:did/:rkey" element={<AnnotationDetail />} /> 47 - <Route path="/annotation/:uri" element={<AnnotationDetail />} /> 48 - <Route path="/collections" element={<Collections />} /> 49 - <Route path="/collections/:rkey" element={<CollectionDetail />} /> 50 - <Route 51 - path="/:handle/collection/:rkey" 52 - element={<CollectionDetail />} 53 - /> 54 - <Route 55 - path="/:handle/annotation/:rkey" 56 - element={<AnnotationDetail />} 57 - /> 58 - <Route path="/:handle/highlight/:rkey" element={<AnnotationDetail />} /> 59 - <Route path="/:handle/bookmark/:rkey" element={<AnnotationDetail />} /> 60 - <Route path="/:handle/url/*" element={<UserUrl />} /> 61 - <Route path="/collection/*" element={<CollectionDetail />} /> 62 - <Route path="/settings" element={<Settings />} /> 63 - <Route path="/privacy" element={<Privacy />} /> 64 - <Route path="/terms" element={<Terms />} /> 65 - </Routes> 66 - </AppLayout> 67 - ); 68 - } 69 - 70 - export default function App() { 71 - return ( 72 - <ThemeProvider> 73 - <AuthProvider> 74 - <Routes> 75 - <Route path="/" element={<Landing />} /> 76 - <Route path="/*" element={<AppContent />} /> 77 - </Routes> 78 - </AuthProvider> 79 - </ThemeProvider> 80 - ); 81 - }
-503
web/src/api/client.js
··· 1 - const API_BASE = "/api"; 2 - const AUTH_BASE = "/auth"; 3 - 4 - async function request(endpoint, options = {}) { 5 - const response = await fetch(endpoint, { 6 - credentials: "include", 7 - headers: { 8 - "Content-Type": "application/json", 9 - ...options.headers, 10 - }, 11 - ...options, 12 - }); 13 - 14 - if (!response.ok) { 15 - const error = await response.text(); 16 - throw new Error(error || `HTTP ${response.status}`); 17 - } 18 - 19 - return response.json(); 20 - } 21 - 22 - export async function getURLMetadata(url) { 23 - return request(`${API_BASE}/url-metadata?url=${encodeURIComponent(url)}`); 24 - } 25 - 26 - export async function getAnnotationFeed( 27 - limit = 50, 28 - offset = 0, 29 - tag = "", 30 - creator = "", 31 - feedType = "", 32 - motivation = "", 33 - ) { 34 - let url = `${API_BASE}/annotations/feed?limit=${limit}&offset=${offset}`; 35 - if (tag) url += `&tag=${encodeURIComponent(tag)}`; 36 - if (creator) url += `&creator=${encodeURIComponent(creator)}`; 37 - if (feedType) url += `&type=${encodeURIComponent(feedType)}`; 38 - if (motivation) url += `&motivation=${encodeURIComponent(motivation)}`; 39 - return request(url); 40 - } 41 - 42 - export async function getAnnotations({ 43 - source, 44 - motivation, 45 - limit = 50, 46 - offset = 0, 47 - } = {}) { 48 - let url = `${API_BASE}/annotations?limit=${limit}&offset=${offset}`; 49 - if (source) url += `&source=${encodeURIComponent(source)}`; 50 - if (motivation) url += `&motivation=${motivation}`; 51 - return request(url); 52 - } 53 - 54 - export async function getByTarget(source, limit = 50, offset = 0) { 55 - return request( 56 - `${API_BASE}/targets?source=${encodeURIComponent(source)}&limit=${limit}&offset=${offset}`, 57 - ); 58 - } 59 - 60 - export async function getAnnotation(uri) { 61 - return request(`${API_BASE}/annotation?uri=${encodeURIComponent(uri)}`); 62 - } 63 - 64 - export async function getProfile(did) { 65 - return request(`${API_BASE}/profile/${encodeURIComponent(did)}`); 66 - } 67 - 68 - export async function getUserAnnotations(did, limit = 50, offset = 0) { 69 - return request( 70 - `${API_BASE}/users/${encodeURIComponent(did)}/annotations?limit=${limit}&offset=${offset}`, 71 - ); 72 - } 73 - 74 - export async function getUserHighlights(did, limit = 50, offset = 0) { 75 - return request( 76 - `${API_BASE}/users/${encodeURIComponent(did)}/highlights?limit=${limit}&offset=${offset}`, 77 - ); 78 - } 79 - 80 - export async function getUserBookmarks(did, limit = 50, offset = 0) { 81 - return request( 82 - `${API_BASE}/users/${encodeURIComponent(did)}/bookmarks?limit=${limit}&offset=${offset}`, 83 - ); 84 - } 85 - 86 - export async function getUserTargetItems(did, url, limit = 50, offset = 0) { 87 - return request( 88 - `${API_BASE}/users/${encodeURIComponent(did)}/targets?source=${encodeURIComponent(url)}&limit=${limit}&offset=${offset}`, 89 - ); 90 - } 91 - 92 - export async function getHighlights(creatorDid, limit = 50, offset = 0) { 93 - return request( 94 - `${API_BASE}/highlights?creator=${encodeURIComponent(creatorDid)}&limit=${limit}&offset=${offset}`, 95 - ); 96 - } 97 - 98 - export async function getBookmarks(creatorDid, limit = 50, offset = 0) { 99 - return request( 100 - `${API_BASE}/bookmarks?creator=${encodeURIComponent(creatorDid)}&limit=${limit}&offset=${offset}`, 101 - ); 102 - } 103 - 104 - export async function getReplies(annotationUri) { 105 - return request( 106 - `${API_BASE}/replies?uri=${encodeURIComponent(annotationUri)}`, 107 - ); 108 - } 109 - 110 - export async function updateAnnotation(uri, text, tags) { 111 - return request(`${API_BASE}/annotations?uri=${encodeURIComponent(uri)}`, { 112 - method: "PUT", 113 - body: JSON.stringify({ text, tags }), 114 - }); 115 - } 116 - 117 - export async function updateHighlight(uri, color, tags) { 118 - return request(`${API_BASE}/highlights?uri=${encodeURIComponent(uri)}`, { 119 - method: "PUT", 120 - body: JSON.stringify({ color, tags }), 121 - }); 122 - } 123 - 124 - export async function createBookmark(url, title, description) { 125 - return request(`${API_BASE}/bookmarks`, { 126 - method: "POST", 127 - body: JSON.stringify({ url, title, description }), 128 - }); 129 - } 130 - 131 - export async function updateBookmark(uri, title, description, tags) { 132 - return request(`${API_BASE}/bookmarks?uri=${encodeURIComponent(uri)}`, { 133 - method: "PUT", 134 - body: JSON.stringify({ title, description, tags }), 135 - }); 136 - } 137 - 138 - export async function getCollections(did) { 139 - let url = `${API_BASE}/collections`; 140 - if (did) url += `?author=${encodeURIComponent(did)}`; 141 - return request(url); 142 - } 143 - 144 - export async function getCollection(uri) { 145 - return request(`${API_BASE}/collection?uri=${encodeURIComponent(uri)}`); 146 - } 147 - 148 - export async function getCollectionsContaining(annotationUri) { 149 - return request( 150 - `${API_BASE}/collections/containing?uri=${encodeURIComponent(annotationUri)}`, 151 - ); 152 - } 153 - 154 - export async function getEditHistory(uri) { 155 - return request( 156 - `${API_BASE}/annotations/history?uri=${encodeURIComponent(uri)}`, 157 - ); 158 - } 159 - 160 - export async function getNotifications(limit = 50, offset = 0) { 161 - return request(`${API_BASE}/notifications?limit=${limit}&offset=${offset}`); 162 - } 163 - 164 - export async function getUnreadNotificationCount() { 165 - return request(`${API_BASE}/notifications/count`); 166 - } 167 - 168 - export async function markNotificationsRead() { 169 - return request(`${API_BASE}/notifications/read`, { method: "POST" }); 170 - } 171 - 172 - export async function updateCollection(uri, name, description, icon) { 173 - return request(`${API_BASE}/collections?uri=${encodeURIComponent(uri)}`, { 174 - method: "PUT", 175 - body: JSON.stringify({ name, description, icon }), 176 - }); 177 - } 178 - 179 - export async function updateProfile({ 180 - displayName, 181 - avatar, 182 - bio, 183 - website, 184 - links, 185 - }) { 186 - return request(`${API_BASE}/profile`, { 187 - method: "PUT", 188 - body: JSON.stringify({ displayName, avatar, bio, website, links }), 189 - }); 190 - } 191 - 192 - export async function uploadAvatar(file) { 193 - const formData = new FormData(); 194 - formData.append("avatar", file); 195 - 196 - const response = await fetch(`${API_BASE}/profile/avatar`, { 197 - method: "POST", 198 - credentials: "include", 199 - body: formData, 200 - }); 201 - 202 - if (!response.ok) { 203 - const error = await response.text(); 204 - throw new Error(error || `HTTP ${response.status}`); 205 - } 206 - 207 - return response.json(); 208 - } 209 - 210 - export async function createCollection(name, description, icon) { 211 - return request(`${API_BASE}/collections`, { 212 - method: "POST", 213 - body: JSON.stringify({ name, description, icon }), 214 - }); 215 - } 216 - 217 - export async function deleteCollection(uri) { 218 - return request(`${API_BASE}/collections?uri=${encodeURIComponent(uri)}`, { 219 - method: "DELETE", 220 - }); 221 - } 222 - 223 - export async function getCollectionItems(collectionUri) { 224 - return request( 225 - `${API_BASE}/collections/${encodeURIComponent(collectionUri)}/items`, 226 - ); 227 - } 228 - 229 - export async function addItemToCollection( 230 - collectionUri, 231 - annotationUri, 232 - position = 0, 233 - ) { 234 - return request( 235 - `${API_BASE}/collections/${encodeURIComponent(collectionUri)}/items`, 236 - { 237 - method: "POST", 238 - body: JSON.stringify({ annotationUri, position }), 239 - }, 240 - ); 241 - } 242 - 243 - export async function removeItemFromCollection(itemUri) { 244 - return request( 245 - `${API_BASE}/collections/items?uri=${encodeURIComponent(itemUri)}`, 246 - { 247 - method: "DELETE", 248 - }, 249 - ); 250 - } 251 - 252 - export async function getLikeCount(annotationUri) { 253 - return request(`${API_BASE}/likes?uri=${encodeURIComponent(annotationUri)}`); 254 - } 255 - 256 - export async function deleteHighlight(rkey) { 257 - return request(`${API_BASE}/highlights?rkey=${encodeURIComponent(rkey)}`, { 258 - method: "DELETE", 259 - }); 260 - } 261 - 262 - export async function deleteBookmark(rkey) { 263 - return request(`${API_BASE}/bookmarks?rkey=${encodeURIComponent(rkey)}`, { 264 - method: "DELETE", 265 - }); 266 - } 267 - 268 - export async function createHighlight({ url, title, selector, color, tags }) { 269 - return request(`${API_BASE}/highlights`, { 270 - method: "POST", 271 - body: JSON.stringify({ url, title, selector, color, tags }), 272 - }); 273 - } 274 - 275 - export async function createAnnotation({ 276 - url, 277 - text, 278 - quote, 279 - title, 280 - selector, 281 - tags, 282 - }) { 283 - return request(`${API_BASE}/annotations`, { 284 - method: "POST", 285 - body: JSON.stringify({ url, text, quote, title, selector, tags }), 286 - }); 287 - } 288 - 289 - export async function deleteAnnotation(rkey, type = "annotation") { 290 - return request( 291 - `${API_BASE}/annotations?rkey=${encodeURIComponent(rkey)}&type=${encodeURIComponent(type)}`, 292 - { 293 - method: "DELETE", 294 - }, 295 - ); 296 - } 297 - 298 - export async function likeAnnotation(subjectUri, subjectCid) { 299 - return request(`${API_BASE}/annotations/like`, { 300 - method: "POST", 301 - headers: { 302 - "Content-Type": "application/json", 303 - }, 304 - body: JSON.stringify({ 305 - subjectUri, 306 - subjectCid, 307 - }), 308 - }); 309 - } 310 - 311 - export async function unlikeAnnotation(subjectUri) { 312 - return request( 313 - `${API_BASE}/annotations/like?uri=${encodeURIComponent(subjectUri)}`, 314 - { 315 - method: "DELETE", 316 - }, 317 - ); 318 - } 319 - 320 - export async function createReply({ 321 - parentUri, 322 - parentCid, 323 - rootUri, 324 - rootCid, 325 - text, 326 - }) { 327 - return request(`${API_BASE}/annotations/reply`, { 328 - method: "POST", 329 - body: JSON.stringify({ parentUri, parentCid, rootUri, rootCid, text }), 330 - }); 331 - } 332 - 333 - export async function deleteReply(uri) { 334 - return request( 335 - `${API_BASE}/annotations/reply?uri=${encodeURIComponent(uri)}`, 336 - { 337 - method: "DELETE", 338 - }, 339 - ); 340 - } 341 - 342 - export async function getSession() { 343 - return request(`${AUTH_BASE}/session`); 344 - } 345 - 346 - export async function logout() { 347 - return request(`${AUTH_BASE}/logout`, { method: "POST" }); 348 - } 349 - 350 - export function normalizeAnnotation(item) { 351 - if (!item) return {}; 352 - 353 - if (item.type === "Annotation") { 354 - return { 355 - type: item.type, 356 - uri: item.uri || item.id, 357 - author: item.author || item.creator, 358 - url: item.url || item.target?.source, 359 - title: item.title || item.target?.title, 360 - text: item.text || item.body?.value, 361 - selector: item.selector || item.target?.selector, 362 - motivation: item.motivation, 363 - tags: item.tags || [], 364 - createdAt: item.createdAt || item.created, 365 - cid: item.cid || item.CID, 366 - likeCount: item.likeCount || 0, 367 - replyCount: item.replyCount || 0, 368 - viewerHasLiked: item.viewerHasLiked || false, 369 - }; 370 - } 371 - 372 - if (item.type === "Bookmark") { 373 - return { 374 - type: item.type, 375 - uri: item.uri || item.id, 376 - author: item.author || item.creator, 377 - url: item.url || item.source, 378 - title: item.title, 379 - description: item.description, 380 - tags: item.tags || [], 381 - createdAt: item.createdAt || item.created, 382 - cid: item.cid || item.CID, 383 - likeCount: item.likeCount || 0, 384 - replyCount: item.replyCount || 0, 385 - viewerHasLiked: item.viewerHasLiked || false, 386 - }; 387 - } 388 - 389 - if (item.type === "Highlight") { 390 - return { 391 - type: item.type, 392 - uri: item.uri || item.id, 393 - author: item.author || item.creator, 394 - url: item.url || item.target?.source, 395 - title: item.title || item.target?.title, 396 - selector: item.selector || item.target?.selector, 397 - color: item.color, 398 - tags: item.tags || [], 399 - createdAt: item.createdAt || item.created, 400 - cid: item.cid || item.CID, 401 - likeCount: item.likeCount || 0, 402 - replyCount: item.replyCount || 0, 403 - viewerHasLiked: item.viewerHasLiked || false, 404 - }; 405 - } 406 - 407 - return { 408 - uri: item.uri || item.id, 409 - author: item.author || item.creator, 410 - url: item.url || item.source || item.target?.source, 411 - title: item.title || item.target?.title, 412 - text: item.text || item.body?.value, 413 - description: item.description, 414 - selector: item.selector || item.target?.selector, 415 - color: item.color, 416 - tags: item.tags || [], 417 - createdAt: item.createdAt || item.created, 418 - cid: item.cid || item.CID, 419 - likeCount: item.likeCount || 0, 420 - replyCount: item.replyCount || 0, 421 - viewerHasLiked: item.viewerHasLiked || false, 422 - }; 423 - } 424 - 425 - export function normalizeHighlight(highlight) { 426 - return { 427 - uri: highlight.uri || highlight.id, 428 - author: highlight.author || highlight.creator, 429 - url: highlight.url || highlight.target?.source, 430 - title: highlight.title || highlight.target?.title, 431 - selector: highlight.selector || highlight.target?.selector, 432 - color: highlight.color, 433 - tags: highlight.tags || [], 434 - createdAt: highlight.createdAt || highlight.created, 435 - likeCount: highlight.likeCount || 0, 436 - replyCount: highlight.replyCount || 0, 437 - viewerHasLiked: highlight.viewerHasLiked || false, 438 - }; 439 - } 440 - 441 - export function normalizeBookmark(bookmark) { 442 - return { 443 - uri: bookmark.uri || bookmark.id, 444 - author: bookmark.author || bookmark.creator, 445 - url: bookmark.url || bookmark.source, 446 - title: bookmark.title, 447 - description: bookmark.description, 448 - tags: bookmark.tags || [], 449 - createdAt: bookmark.createdAt || bookmark.created, 450 - likeCount: bookmark.likeCount || 0, 451 - replyCount: bookmark.replyCount || 0, 452 - viewerHasLiked: bookmark.viewerHasLiked || false, 453 - }; 454 - } 455 - 456 - export async function searchActors(query) { 457 - const res = await fetch( 458 - `https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead?q=${encodeURIComponent(query)}&limit=5`, 459 - ); 460 - if (!res.ok) throw new Error("Search failed"); 461 - return res.json(); 462 - } 463 - 464 - export async function resolveHandle(handle) { 465 - const res = await fetch( 466 - `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`, 467 - ); 468 - if (!res.ok) throw new Error("Failed to resolve handle"); 469 - const data = await res.json(); 470 - return data.did; 471 - } 472 - 473 - export async function startLogin(handle) { 474 - return request(`${AUTH_BASE}/start`, { 475 - method: "POST", 476 - body: JSON.stringify({ handle }), 477 - }); 478 - } 479 - 480 - export async function startSignup(pdsUrl) { 481 - return request(`${AUTH_BASE}/signup`, { 482 - method: "POST", 483 - body: JSON.stringify({ pds_url: pdsUrl }), 484 - }); 485 - } 486 - export async function getTrendingTags(limit = 10) { 487 - return request(`${API_BASE}/tags/trending?limit=${limit}`); 488 - } 489 - 490 - export async function getAPIKeys() { 491 - return request(`${API_BASE}/keys`); 492 - } 493 - 494 - export async function createAPIKey(name) { 495 - return request(`${API_BASE}/keys`, { 496 - method: "POST", 497 - body: JSON.stringify({ name }), 498 - }); 499 - } 500 - 501 - export async function deleteAPIKey(id) { 502 - return request(`${API_BASE}/keys/${id}`, { method: "DELETE" }); 503 - }
-262
web/src/components/AddToCollectionModal.jsx
··· 1 - import { useState, useEffect, useCallback } from "react"; 2 - import { X, Plus, Check, Folder } from "lucide-react"; 3 - import { 4 - getCollections, 5 - addItemToCollection, 6 - getCollectionsContaining, 7 - } from "../api/client"; 8 - import { useAuth } from "../context/AuthContext"; 9 - import CollectionModal from "./CollectionModal"; 10 - 11 - export default function AddToCollectionModal({ 12 - isOpen, 13 - onClose, 14 - annotationUri, 15 - }) { 16 - const { user } = useAuth(); 17 - const [collections, setCollections] = useState([]); 18 - const [loading, setLoading] = useState(true); 19 - const [addingTo, setAddingTo] = useState(null); 20 - const [addedTo, setAddedTo] = useState(new Set()); 21 - const [createModalOpen, setCreateModalOpen] = useState(false); 22 - const [error, setError] = useState(null); 23 - 24 - const loadCollections = useCallback(async () => { 25 - try { 26 - setLoading(true); 27 - const [data, existingURIs] = await Promise.all([ 28 - getCollections(user?.did), 29 - annotationUri ? getCollectionsContaining(annotationUri) : [], 30 - ]); 31 - 32 - const items = Array.isArray(data) ? data : data.items || []; 33 - setCollections(items); 34 - setAddedTo(new Set(existingURIs || [])); 35 - } catch (err) { 36 - console.error(err); 37 - setError("Failed to load collections"); 38 - } finally { 39 - setLoading(false); 40 - } 41 - }, [user?.did, annotationUri]); 42 - 43 - useEffect(() => { 44 - if (isOpen && user) { 45 - if (!annotationUri) { 46 - setLoading(false); 47 - return; 48 - } 49 - loadCollections(); 50 - setError(null); 51 - } 52 - }, [isOpen, user, annotationUri, loadCollections]); 53 - 54 - const handleAdd = async (collectionUri) => { 55 - if (addedTo.has(collectionUri)) return; 56 - 57 - try { 58 - setAddingTo(collectionUri); 59 - await addItemToCollection(collectionUri, annotationUri); 60 - setAddedTo((prev) => new Set([...prev, collectionUri])); 61 - } catch (err) { 62 - console.error(err); 63 - alert("Failed to add to collection"); 64 - } finally { 65 - setAddingTo(null); 66 - } 67 - }; 68 - 69 - if (!isOpen) return null; 70 - 71 - return ( 72 - <> 73 - <div className="modal-overlay" onClick={onClose}> 74 - <div 75 - className="modal-container" 76 - style={{ 77 - maxWidth: "380px", 78 - maxHeight: "80dvh", 79 - display: "flex", 80 - flexDirection: "column", 81 - }} 82 - onClick={(e) => e.stopPropagation()} 83 - > 84 - <div className="modal-header"> 85 - <h2 86 - className="modal-title" 87 - style={{ display: "flex", alignItems: "center", gap: "8px" }} 88 - > 89 - <Folder size={20} style={{ color: "var(--accent)" }} /> 90 - Add to Collection 91 - </h2> 92 - <button onClick={onClose} className="modal-close-btn"> 93 - <X size={20} /> 94 - </button> 95 - </div> 96 - 97 - <div style={{ overflowY: "auto", padding: "8px", flex: 1 }}> 98 - {loading ? ( 99 - <div 100 - style={{ 101 - padding: "32px", 102 - display: "flex", 103 - alignItems: "center", 104 - justifyContent: "center", 105 - flexDirection: "column", 106 - gap: "12px", 107 - color: "var(--text-tertiary)", 108 - }} 109 - > 110 - <div className="spinner"></div> 111 - <span style={{ fontSize: "0.9rem" }}> 112 - Loading collections... 113 - </span> 114 - </div> 115 - ) : error ? ( 116 - <div style={{ padding: "24px", textAlign: "center" }}> 117 - <p 118 - className="text-error" 119 - style={{ fontSize: "0.9rem", marginBottom: "12px" }} 120 - > 121 - {error} 122 - </p> 123 - <button 124 - onClick={loadCollections} 125 - className="btn btn-secondary btn-sm" 126 - > 127 - Try Again 128 - </button> 129 - </div> 130 - ) : collections.length === 0 ? ( 131 - <div className="empty-state" style={{ padding: "32px" }}> 132 - <div className="empty-state-icon"> 133 - <Folder size={24} /> 134 - </div> 135 - <p className="empty-state-title" style={{ fontSize: "1rem" }}> 136 - No collections found 137 - </p> 138 - <p className="empty-state-text"> 139 - Create a collection to start organizing your items. 140 - </p> 141 - </div> 142 - ) : ( 143 - <div 144 - style={{ display: "flex", flexDirection: "column", gap: "4px" }} 145 - > 146 - {collections.map((col) => { 147 - const isAdded = addedTo.has(col.uri); 148 - const isAdding = addingTo === col.uri; 149 - 150 - return ( 151 - <button 152 - key={col.uri} 153 - onClick={() => handleAdd(col.uri)} 154 - disabled={isAdding || isAdded} 155 - className="collection-list-item" 156 - style={{ 157 - opacity: isAdded ? 0.7 : 1, 158 - cursor: isAdded ? "default" : "pointer", 159 - }} 160 - > 161 - <div 162 - style={{ 163 - display: "flex", 164 - flexDirection: "column", 165 - minWidth: 0, 166 - }} 167 - > 168 - <span 169 - style={{ 170 - fontWeight: 500, 171 - overflow: "hidden", 172 - textOverflow: "ellipsis", 173 - whiteSpace: "nowrap", 174 - }} 175 - > 176 - {col.name} 177 - </span> 178 - {col.description && ( 179 - <span 180 - style={{ 181 - fontSize: "0.75rem", 182 - color: "var(--text-tertiary)", 183 - overflow: "hidden", 184 - textOverflow: "ellipsis", 185 - whiteSpace: "nowrap", 186 - marginTop: "2px", 187 - }} 188 - > 189 - {col.description} 190 - </span> 191 - )} 192 - </div> 193 - 194 - {isAdding ? ( 195 - <span 196 - className="spinner spinner-sm" 197 - style={{ marginLeft: "12px" }} 198 - /> 199 - ) : isAdded ? ( 200 - <Check 201 - size={20} 202 - style={{ 203 - color: "var(--success)", 204 - marginLeft: "12px", 205 - }} 206 - /> 207 - ) : ( 208 - <Plus 209 - size={18} 210 - style={{ 211 - color: "var(--text-tertiary)", 212 - opacity: 0, 213 - marginLeft: "12px", 214 - }} 215 - className="collection-list-item-icon" 216 - /> 217 - )} 218 - </button> 219 - ); 220 - })} 221 - </div> 222 - )} 223 - </div> 224 - 225 - <div 226 - style={{ 227 - padding: "16px", 228 - borderTop: "1px solid var(--border)", 229 - background: "var(--bg-tertiary)", 230 - display: "flex", 231 - gap: "8px", 232 - }} 233 - > 234 - <button 235 - onClick={() => setCreateModalOpen(true)} 236 - className="btn btn-secondary" 237 - style={{ flex: 1 }} 238 - > 239 - <Plus size={18} /> 240 - New Collection 241 - </button> 242 - <button 243 - onClick={onClose} 244 - className="btn btn-primary" 245 - style={{ flex: 1 }} 246 - > 247 - Done 248 - </button> 249 - </div> 250 - </div> 251 - </div> 252 - 253 - <CollectionModal 254 - isOpen={createModalOpen} 255 - onClose={() => setCreateModalOpen(false)} 256 - onSuccess={() => { 257 - loadCollections(); 258 - }} 259 - /> 260 - </> 261 - ); 262 - }
-674
web/src/components/AnnotationCard.jsx
··· 1 - import { useState, useEffect } from "react"; 2 - import { useAuth } from "../context/AuthContext"; 3 - import ReplyList from "./ReplyList"; 4 - import { Link } from "react-router-dom"; 5 - import RichText from "./RichText"; 6 - import { 7 - normalizeAnnotation, 8 - normalizeHighlight, 9 - likeAnnotation, 10 - unlikeAnnotation, 11 - getReplies, 12 - createReply, 13 - deleteReply, 14 - updateAnnotation, 15 - updateHighlight, 16 - getEditHistory, 17 - deleteAnnotation, 18 - } from "../api/client"; 19 - import { 20 - MessageSquare, 21 - Heart, 22 - Trash2, 23 - Folder, 24 - Edit2, 25 - Save, 26 - X, 27 - Clock, 28 - } from "lucide-react"; 29 - import { HighlightIcon, TrashIcon } from "./Icons"; 30 - import ShareMenu from "./ShareMenu"; 31 - import UserMeta from "./UserMeta"; 32 - 33 - function buildTextFragmentUrl(baseUrl, selector) { 34 - if (!selector || selector.type !== "TextQuoteSelector" || !selector.exact) { 35 - return baseUrl; 36 - } 37 - let fragment = ":~:text="; 38 - if (selector.prefix) { 39 - fragment += encodeURIComponent(selector.prefix) + "-,"; 40 - } 41 - fragment += encodeURIComponent(selector.exact); 42 - if (selector.suffix) { 43 - fragment += ",-" + encodeURIComponent(selector.suffix); 44 - } 45 - return baseUrl + "#" + fragment; 46 - } 47 - 48 - const truncateUrl = (url, maxLength = 50) => { 49 - if (!url) return ""; 50 - try { 51 - const parsed = new URL(url); 52 - const fullPath = parsed.host + parsed.pathname; 53 - if (fullPath.length > maxLength) 54 - return fullPath.substring(0, maxLength) + "..."; 55 - return fullPath; 56 - } catch { 57 - return url.length > maxLength ? url.substring(0, maxLength) + "..." : url; 58 - } 59 - }; 60 - 61 - function SembleBadge() { 62 - return ( 63 - <div className="semble-badge" title="Added using Semble"> 64 - <span>via Semble</span> 65 - <img src="/semble-logo.svg" alt="Semble" /> 66 - </div> 67 - ); 68 - } 69 - 70 - export default function AnnotationCard({ 71 - annotation, 72 - onDelete, 73 - onAddToCollection, 74 - }) { 75 - const { user, login } = useAuth(); 76 - const data = normalizeAnnotation(annotation); 77 - 78 - const [likeCount, setLikeCount] = useState(data.likeCount || 0); 79 - const [isLiked, setIsLiked] = useState(data.viewerHasLiked || false); 80 - const [deleting, setDeleting] = useState(false); 81 - const [isEditing, setIsEditing] = useState(false); 82 - const [editText, setEditText] = useState(data.text || ""); 83 - const [editTags, setEditTags] = useState(data.tags?.join(", ") || ""); 84 - const [saving, setSaving] = useState(false); 85 - const [showHistory, setShowHistory] = useState(false); 86 - const [editHistory, setEditHistory] = useState([]); 87 - const [loadingHistory, setLoadingHistory] = useState(false); 88 - const [replies, setReplies] = useState([]); 89 - const [replyCount, setReplyCount] = useState(data.replyCount || 0); 90 - const [showReplies, setShowReplies] = useState(false); 91 - const [replyingTo, setReplyingTo] = useState(null); 92 - const [replyText, setReplyText] = useState(""); 93 - const [posting, setPosting] = useState(false); 94 - const [hasEditHistory, setHasEditHistory] = useState(false); 95 - 96 - const isOwner = user?.did && data.author?.did === user.did; 97 - const isSemble = data.uri?.includes("network.cosmik"); 98 - const highlightedText = 99 - data.selector?.type === "TextQuoteSelector" ? data.selector.exact : null; 100 - const fragmentUrl = buildTextFragmentUrl(data.url, data.selector); 101 - 102 - useEffect(() => { 103 - if (data.uri && !data.color && !data.description) { 104 - getEditHistory(data.uri) 105 - .then((history) => { 106 - if (history?.length > 0) setHasEditHistory(true); 107 - }) 108 - .catch(() => {}); 109 - } 110 - }, [data.uri, data.color, data.description]); 111 - 112 - const fetchHistory = async () => { 113 - if (showHistory) { 114 - setShowHistory(false); 115 - return; 116 - } 117 - try { 118 - setLoadingHistory(true); 119 - setShowHistory(true); 120 - const history = await getEditHistory(data.uri); 121 - setEditHistory(history); 122 - } catch (err) { 123 - console.error("Failed to fetch history:", err); 124 - } finally { 125 - setLoadingHistory(false); 126 - } 127 - }; 128 - 129 - const handlePostReply = async (parentReply) => { 130 - if (!replyText.trim()) return; 131 - try { 132 - setPosting(true); 133 - const parentUri = parentReply 134 - ? parentReply.id || parentReply.uri 135 - : data.uri; 136 - const parentCid = parentReply 137 - ? parentReply.cid 138 - : annotation.cid || data.cid; 139 - 140 - await createReply({ 141 - parentUri, 142 - parentCid: parentCid || "", 143 - rootUri: data.uri, 144 - rootCid: annotation.cid || data.cid || "", 145 - text: replyText, 146 - }); 147 - 148 - setReplyText(""); 149 - setReplyingTo(null); 150 - 151 - const res = await getReplies(data.uri); 152 - if (res.items) { 153 - setReplies(res.items); 154 - setReplyCount(res.items.length); 155 - } 156 - } catch (err) { 157 - alert("Failed to post reply: " + err.message); 158 - } finally { 159 - setPosting(false); 160 - } 161 - }; 162 - 163 - const handleSaveEdit = async () => { 164 - try { 165 - setSaving(true); 166 - const tagList = editTags 167 - .split(",") 168 - .map((t) => t.trim()) 169 - .filter(Boolean); 170 - await updateAnnotation(data.uri, editText, tagList); 171 - setIsEditing(false); 172 - if (annotation.body) annotation.body.value = editText; 173 - else if (annotation.text) annotation.text = editText; 174 - if (annotation.tags) annotation.tags = tagList; 175 - data.tags = tagList; 176 - } catch (err) { 177 - alert("Failed to update: " + err.message); 178 - } finally { 179 - setSaving(false); 180 - } 181 - }; 182 - 183 - const handleLike = async () => { 184 - if (!user) { 185 - login(); 186 - return; 187 - } 188 - try { 189 - if (isLiked) { 190 - setIsLiked(false); 191 - setLikeCount((prev) => Math.max(0, prev - 1)); 192 - await unlikeAnnotation(data.uri); 193 - } else { 194 - setIsLiked(true); 195 - setLikeCount((prev) => prev + 1); 196 - const cid = annotation.cid || data.cid || ""; 197 - if (data.uri && cid) await likeAnnotation(data.uri, cid); 198 - } 199 - } catch { 200 - setIsLiked(!isLiked); 201 - setLikeCount((prev) => (isLiked ? prev + 1 : prev - 1)); 202 - } 203 - }; 204 - 205 - const handleDelete = async () => { 206 - if (!confirm("Delete this annotation? This cannot be undone.")) return; 207 - try { 208 - setDeleting(true); 209 - const parts = data.uri.split("/"); 210 - const rkey = parts[parts.length - 1]; 211 - await deleteAnnotation(rkey); 212 - if (onDelete) onDelete(data.uri); 213 - else window.location.reload(); 214 - } catch (err) { 215 - alert("Failed to delete: " + err.message); 216 - } finally { 217 - setDeleting(false); 218 - } 219 - }; 220 - 221 - const loadReplies = async () => { 222 - if (!showReplies && replies.length === 0) { 223 - try { 224 - const res = await getReplies(data.uri); 225 - if (res.items) setReplies(res.items); 226 - } catch (err) { 227 - console.error("Failed to load replies:", err); 228 - } 229 - } 230 - setShowReplies(!showReplies); 231 - }; 232 - 233 - const handleCollect = () => { 234 - if (!user) { 235 - login(); 236 - return; 237 - } 238 - if (onAddToCollection) onAddToCollection(); 239 - }; 240 - 241 - return ( 242 - <article className="card annotation-card"> 243 - <header className="annotation-header"> 244 - <div className="annotation-header-left"> 245 - <UserMeta author={data.author} createdAt={data.createdAt} /> 246 - </div> 247 - <div className="annotation-header-right"> 248 - {isSemble && <SembleBadge />} 249 - {hasEditHistory && !data.color && !data.description && ( 250 - <button 251 - className="annotation-action action-icon-only" 252 - onClick={fetchHistory} 253 - title="View Edit History" 254 - > 255 - <Clock size={16} /> 256 - </button> 257 - )} 258 - {isOwner && !isSemble && ( 259 - <> 260 - {!data.color && !data.description && ( 261 - <button 262 - className="annotation-action action-icon-only" 263 - onClick={() => setIsEditing(!isEditing)} 264 - title="Edit" 265 - > 266 - <Edit2 size={16} /> 267 - </button> 268 - )} 269 - <button 270 - className="annotation-action action-icon-only" 271 - onClick={handleDelete} 272 - disabled={deleting} 273 - title="Delete" 274 - > 275 - <Trash2 size={16} /> 276 - </button> 277 - </> 278 - )} 279 - </div> 280 - </header> 281 - 282 - {showHistory && ( 283 - <div className="history-panel"> 284 - <div className="history-header"> 285 - <h4 className="history-title">Edit History</h4> 286 - <button 287 - className="annotation-action action-icon-only" 288 - onClick={() => setShowHistory(false)} 289 - > 290 - <X size={14} /> 291 - </button> 292 - </div> 293 - {loadingHistory ? ( 294 - <div className="history-status">Loading history...</div> 295 - ) : editHistory.length === 0 ? ( 296 - <div className="history-status">No edit history found.</div> 297 - ) : ( 298 - <ul className="history-list"> 299 - {editHistory.map((edit) => ( 300 - <li key={edit.id} className="history-item"> 301 - <div className="history-date"> 302 - {new Date(edit.editedAt).toLocaleString()} 303 - </div> 304 - <div className="history-content">{edit.previousContent}</div> 305 - </li> 306 - ))} 307 - </ul> 308 - )} 309 - </div> 310 - )} 311 - 312 - <div className="annotation-content"> 313 - <a 314 - href={data.url} 315 - target="_blank" 316 - rel="noopener noreferrer" 317 - className="annotation-source" 318 - > 319 - {truncateUrl(data.url)} 320 - {data.title && ( 321 - <span className="annotation-source-title"> · {data.title}</span> 322 - )} 323 - </a> 324 - 325 - {highlightedText && ( 326 - <a 327 - href={fragmentUrl} 328 - target="_blank" 329 - rel="noopener noreferrer" 330 - className="annotation-highlight" 331 - style={{ borderLeftColor: data.color || "var(--accent)" }} 332 - > 333 - <mark>&ldquo;{highlightedText}&rdquo;</mark> 334 - </a> 335 - )} 336 - 337 - {isEditing ? ( 338 - <div className="edit-form"> 339 - <textarea 340 - value={editText} 341 - onChange={(e) => setEditText(e.target.value)} 342 - className="reply-input" 343 - rows={3} 344 - placeholder="Your annotation..." 345 - /> 346 - <input 347 - type="text" 348 - className="reply-input" 349 - placeholder="Tags (comma separated)..." 350 - value={editTags} 351 - onChange={(e) => setEditTags(e.target.value)} 352 - style={{ marginTop: "8px" }} 353 - /> 354 - <div className="action-buttons-end" style={{ marginTop: "8px" }}> 355 - <button 356 - onClick={() => setIsEditing(false)} 357 - className="btn btn-ghost" 358 - > 359 - Cancel 360 - </button> 361 - <button 362 - onClick={handleSaveEdit} 363 - disabled={saving} 364 - className="btn btn-primary" 365 - > 366 - {saving ? ( 367 - "Saving..." 368 - ) : ( 369 - <> 370 - <Save size={14} /> Save 371 - </> 372 - )} 373 - </button> 374 - </div> 375 - </div> 376 - ) : ( 377 - <RichText text={data.text} facets={data.facets} /> 378 - )} 379 - 380 - {data.tags?.length > 0 && ( 381 - <div className="annotation-tags"> 382 - {data.tags.map((tag, i) => ( 383 - <Link 384 - key={i} 385 - to={`/home?tag=${encodeURIComponent(tag)}`} 386 - className="annotation-tag" 387 - > 388 - #{tag} 389 - </Link> 390 - ))} 391 - </div> 392 - )} 393 - </div> 394 - 395 - <footer className="annotation-actions"> 396 - <div className="annotation-actions-left"> 397 - <button 398 - className={`annotation-action ${isLiked ? "liked" : ""}`} 399 - onClick={handleLike} 400 - > 401 - <Heart size={16} fill={isLiked ? "currentColor" : "none"} /> 402 - {likeCount > 0 && <span>{likeCount}</span>} 403 - </button> 404 - 405 - <button 406 - className={`annotation-action ${showReplies ? "active" : ""}`} 407 - onClick={loadReplies} 408 - > 409 - <MessageSquare size={16} /> 410 - <span>{replyCount > 0 ? replyCount : "Reply"}</span> 411 - </button> 412 - 413 - <ShareMenu 414 - uri={data.uri} 415 - text={data.title || data.url} 416 - handle={data.author?.handle} 417 - type="Annotation" 418 - url={data.url} 419 - /> 420 - 421 - <button className="annotation-action" onClick={handleCollect}> 422 - <Folder size={16} /> 423 - <span>Collect</span> 424 - </button> 425 - </div> 426 - </footer> 427 - 428 - {showReplies && ( 429 - <div className="inline-replies"> 430 - <ReplyList 431 - replies={replies} 432 - rootUri={data.uri} 433 - user={user} 434 - onReply={(reply) => setReplyingTo(reply)} 435 - onDelete={async (reply) => { 436 - if (!confirm("Delete this reply?")) return; 437 - try { 438 - await deleteReply(reply.id || reply.uri); 439 - const res = await getReplies(data.uri); 440 - if (res.items) { 441 - setReplies(res.items); 442 - setReplyCount(res.items.length); 443 - } 444 - } catch (err) { 445 - alert("Failed to delete: " + err.message); 446 - } 447 - }} 448 - isInline={true} 449 - /> 450 - 451 - <div className="reply-form"> 452 - {replyingTo && ( 453 - <div className="replying-to-banner"> 454 - <span> 455 - Replying to @ 456 - {(replyingTo.creator || replyingTo.author)?.handle || 457 - "unknown"} 458 - </span> 459 - <button 460 - onClick={() => setReplyingTo(null)} 461 - className="cancel-reply" 462 - > 463 - × 464 - </button> 465 - </div> 466 - )} 467 - <textarea 468 - className="reply-input" 469 - placeholder={ 470 - replyingTo 471 - ? `Reply to @${(replyingTo.creator || replyingTo.author)?.handle}...` 472 - : "Write a reply..." 473 - } 474 - value={replyText} 475 - onChange={(e) => setReplyText(e.target.value)} 476 - rows={2} 477 - /> 478 - <div className="reply-form-actions"> 479 - <button 480 - className="btn btn-primary" 481 - disabled={posting || !replyText.trim()} 482 - onClick={() => { 483 - if (!user) { 484 - login(); 485 - return; 486 - } 487 - handlePostReply(replyingTo); 488 - }} 489 - > 490 - {posting ? "Posting..." : "Reply"} 491 - </button> 492 - </div> 493 - </div> 494 - </div> 495 - )} 496 - </article> 497 - ); 498 - } 499 - 500 - export function HighlightCard({ 501 - highlight, 502 - onDelete, 503 - onAddToCollection, 504 - onUpdate, 505 - }) { 506 - const { user, login } = useAuth(); 507 - const data = normalizeHighlight(highlight); 508 - const highlightedText = 509 - data.selector?.type === "TextQuoteSelector" ? data.selector.exact : null; 510 - const fragmentUrl = buildTextFragmentUrl(data.url, data.selector); 511 - const isOwner = user?.did && data.author?.did === user.did; 512 - const isSemble = data.uri?.includes("network.cosmik"); 513 - 514 - const [isEditing, setIsEditing] = useState(false); 515 - const [editColor, setEditColor] = useState(data.color || "#f59e0b"); 516 - const [editTags, setEditTags] = useState(data.tags?.join(", ") || ""); 517 - 518 - const handleSaveEdit = async () => { 519 - try { 520 - const tagList = editTags 521 - .split(",") 522 - .map((t) => t.trim()) 523 - .filter(Boolean); 524 - await updateHighlight(data.uri, editColor, tagList); 525 - setIsEditing(false); 526 - if (typeof onUpdate === "function") { 527 - onUpdate({ ...highlight, color: editColor, tags: tagList }); 528 - } 529 - } catch (err) { 530 - alert("Failed to update: " + err.message); 531 - } 532 - }; 533 - 534 - const handleCollect = () => { 535 - if (!user) { 536 - login(); 537 - return; 538 - } 539 - if (onAddToCollection) onAddToCollection(); 540 - }; 541 - 542 - return ( 543 - <article className="card annotation-card"> 544 - <header className="annotation-header"> 545 - <div className="annotation-header-left"> 546 - <UserMeta author={data.author} createdAt={data.createdAt} /> 547 - </div> 548 - <div className="annotation-header-right"> 549 - {isSemble && ( 550 - <div className="semble-badge" title="Added using Semble"> 551 - <span>via Semble</span> 552 - <img src="/semble-logo.svg" alt="Semble" /> 553 - </div> 554 - )} 555 - {isOwner && ( 556 - <> 557 - <button 558 - className="annotation-action action-icon-only" 559 - onClick={() => setIsEditing(!isEditing)} 560 - title="Edit Color" 561 - > 562 - <Edit2 size={16} /> 563 - </button> 564 - <button 565 - className="annotation-action action-icon-only" 566 - onClick={(e) => { 567 - e.preventDefault(); 568 - onDelete && onDelete(highlight.id || highlight.uri); 569 - }} 570 - title="Delete" 571 - > 572 - <TrashIcon size={16} /> 573 - </button> 574 - </> 575 - )} 576 - </div> 577 - </header> 578 - 579 - <div className="annotation-content"> 580 - <a 581 - href={data.url} 582 - target="_blank" 583 - rel="noopener noreferrer" 584 - className="annotation-source" 585 - > 586 - {truncateUrl(data.url)} 587 - </a> 588 - 589 - {highlightedText && ( 590 - <a 591 - href={fragmentUrl} 592 - target="_blank" 593 - rel="noopener noreferrer" 594 - className="annotation-highlight" 595 - style={{ 596 - borderLeftColor: isEditing ? editColor : data.color || "#f59e0b", 597 - }} 598 - > 599 - <mark>&ldquo;{highlightedText}&rdquo;</mark> 600 - </a> 601 - )} 602 - 603 - {isEditing && ( 604 - <div className="color-edit-form"> 605 - <div className="color-picker-wrapper"> 606 - <div 607 - className="color-preview" 608 - style={{ backgroundColor: editColor }} 609 - /> 610 - <input 611 - type="color" 612 - value={editColor} 613 - onChange={(e) => setEditColor(e.target.value)} 614 - className="color-input" 615 - /> 616 - </div> 617 - <input 618 - type="text" 619 - className="reply-input" 620 - placeholder="Tags (comma separated)" 621 - value={editTags} 622 - onChange={(e) => setEditTags(e.target.value)} 623 - style={{ flex: 1, margin: 0 }} 624 - /> 625 - <button 626 - onClick={handleSaveEdit} 627 - className="btn btn-primary" 628 - style={{ padding: "0 12px", height: "32px" }} 629 - > 630 - <Save size={16} /> 631 - </button> 632 - </div> 633 - )} 634 - 635 - {data.tags?.length > 0 && ( 636 - <div className="annotation-tags"> 637 - {data.tags.map((tag, i) => ( 638 - <Link 639 - key={i} 640 - to={`/home?tag=${encodeURIComponent(tag)}`} 641 - className="annotation-tag" 642 - > 643 - #{tag} 644 - </Link> 645 - ))} 646 - </div> 647 - )} 648 - </div> 649 - 650 - <footer className="annotation-actions"> 651 - <div className="annotation-actions-left"> 652 - <span 653 - className="annotation-action" 654 - style={{ color: data.color || "#f59e0b", cursor: "default" }} 655 - > 656 - <HighlightIcon size={14} /> Highlight 657 - </span> 658 - 659 - <ShareMenu 660 - uri={data.uri} 661 - text={data.title || data.description} 662 - handle={data.author?.handle} 663 - type="Highlight" 664 - /> 665 - 666 - <button className="annotation-action" onClick={handleCollect}> 667 - <Folder size={16} /> 668 - <span>Collect</span> 669 - </button> 670 - </div> 671 - </footer> 672 - </article> 673 - ); 674 - }
-26
web/src/components/AnnotationSkeleton.jsx
··· 1 - export default function AnnotationSkeleton() { 2 - return ( 3 - <div className="skeleton-card"> 4 - <div className="skeleton-header"> 5 - <div className="skeleton skeleton-avatar" /> 6 - <div className="skeleton-meta"> 7 - <div className="skeleton skeleton-name" /> 8 - <div className="skeleton skeleton-handle" /> 9 - </div> 10 - </div> 11 - 12 - <div className="skeleton-content"> 13 - <div className="skeleton skeleton-source" /> 14 - <div className="skeleton skeleton-highlight" /> 15 - <div className="skeleton skeleton-text-1" /> 16 - <div className="skeleton skeleton-text-2" /> 17 - </div> 18 - 19 - <div className="skeleton-actions"> 20 - <div className="skeleton skeleton-action" /> 21 - <div className="skeleton skeleton-action" /> 22 - <div className="skeleton skeleton-action" /> 23 - </div> 24 - </div> 25 - ); 26 - }
-26
web/src/components/AppLayout.jsx
··· 1 - import LeftSidebar from "./LeftSidebar"; 2 - import RightSidebar from "./RightSidebar"; 3 - import TopNav from "./TopNav"; 4 - import MobileNav from "./MobileNav"; 5 - 6 - import { useTheme } from "../context/ThemeContext"; 7 - 8 - export default function AppLayout({ children }) { 9 - const { layout } = useTheme(); 10 - 11 - return ( 12 - <> 13 - <div 14 - className={`layout-wrapper ${layout === "topnav" ? "layout-mode-topnav" : ""}`} 15 - > 16 - <TopNav /> 17 - <div className="app-layout"> 18 - {layout !== "topnav" && <LeftSidebar />} 19 - <main className="main-content">{children}</main> 20 - {layout !== "topnav" && <RightSidebar />} 21 - </div> 22 - </div> 23 - <MobileNav /> 24 - </> 25 - ); 26 - }
-190
web/src/components/BookmarkCard.jsx
··· 1 - import { useState, useEffect } from "react"; 2 - import { useAuth } from "../context/AuthContext"; 3 - import { 4 - normalizeAnnotation, 5 - normalizeBookmark, 6 - likeAnnotation, 7 - unlikeAnnotation, 8 - getLikeCount, 9 - deleteBookmark, 10 - } from "../api/client"; 11 - import { HeartIcon, TrashIcon } from "./Icons"; 12 - import { Folder, ExternalLink } from "lucide-react"; 13 - import ShareMenu from "./ShareMenu"; 14 - import UserMeta from "./UserMeta"; 15 - 16 - export default function BookmarkCard({ 17 - bookmark, 18 - onAddToCollection, 19 - onDelete, 20 - }) { 21 - const { user, login } = useAuth(); 22 - const raw = bookmark; 23 - const data = 24 - raw.type === "Bookmark" ? normalizeBookmark(raw) : normalizeAnnotation(raw); 25 - 26 - const [likeCount, setLikeCount] = useState(0); 27 - const [isLiked, setIsLiked] = useState(false); 28 - const [deleting, setDeleting] = useState(false); 29 - 30 - const isOwner = user?.did && data.author?.did === user.did; 31 - const isSemble = data.uri?.includes("network.cosmik"); 32 - 33 - let domain = ""; 34 - try { 35 - if (data.url) domain = new URL(data.url).hostname.replace("www.", ""); 36 - } catch { 37 - /* ignore */ 38 - } 39 - 40 - useEffect(() => { 41 - let mounted = true; 42 - async function fetchData() { 43 - try { 44 - const likeRes = await getLikeCount(data.uri); 45 - if (mounted) { 46 - if (likeRes.count !== undefined) setLikeCount(likeRes.count); 47 - if (likeRes.liked !== undefined) setIsLiked(likeRes.liked); 48 - } 49 - } catch { 50 - /* ignore */ 51 - } 52 - } 53 - if (data.uri) fetchData(); 54 - return () => { 55 - mounted = false; 56 - }; 57 - }, [data.uri]); 58 - 59 - const handleLike = async () => { 60 - if (!user) { 61 - login(); 62 - return; 63 - } 64 - try { 65 - if (isLiked) { 66 - setIsLiked(false); 67 - setLikeCount((prev) => Math.max(0, prev - 1)); 68 - await unlikeAnnotation(data.uri); 69 - } else { 70 - setIsLiked(true); 71 - setLikeCount((prev) => prev + 1); 72 - const cid = data.cid || ""; 73 - if (data.uri && cid) await likeAnnotation(data.uri, cid); 74 - } 75 - } catch { 76 - setIsLiked(!isLiked); 77 - setLikeCount((prev) => (isLiked ? prev + 1 : prev - 1)); 78 - } 79 - }; 80 - 81 - const handleDelete = async () => { 82 - if (onDelete) { 83 - onDelete(data.uri); 84 - return; 85 - } 86 - if (!confirm("Delete this bookmark?")) return; 87 - try { 88 - setDeleting(true); 89 - const parts = data.uri.split("/"); 90 - const rkey = parts[parts.length - 1]; 91 - await deleteBookmark(rkey); 92 - window.location.reload(); 93 - } catch (err) { 94 - alert("Failed to delete: " + err.message); 95 - } finally { 96 - setDeleting(false); 97 - } 98 - }; 99 - 100 - const handleCollect = () => { 101 - if (!user) { 102 - login(); 103 - return; 104 - } 105 - if (onAddToCollection) onAddToCollection(); 106 - }; 107 - 108 - return ( 109 - <article className="card annotation-card bookmark-card"> 110 - <header className="annotation-header"> 111 - <div className="annotation-header-left"> 112 - <UserMeta author={data.author} createdAt={data.createdAt} /> 113 - </div> 114 - <div className="annotation-header-right"> 115 - {isSemble && ( 116 - <div className="semble-badge" title="Added using Semble"> 117 - <span>via Semble</span> 118 - <img src="/semble-logo.svg" alt="Semble" /> 119 - </div> 120 - )} 121 - {((isOwner && !isSemble) || onDelete) && ( 122 - <button 123 - className="annotation-action action-icon-only" 124 - onClick={handleDelete} 125 - disabled={deleting} 126 - title="Delete" 127 - > 128 - <TrashIcon size={16} /> 129 - </button> 130 - )} 131 - </div> 132 - </header> 133 - 134 - <div className="annotation-content"> 135 - <a 136 - href={data.url} 137 - target="_blank" 138 - rel="noopener noreferrer" 139 - className="bookmark-preview" 140 - > 141 - <div className="bookmark-preview-content"> 142 - <div className="bookmark-preview-site"> 143 - <ExternalLink size={12} /> 144 - <span>{domain}</span> 145 - </div> 146 - <h3 className="bookmark-preview-title">{data.title || data.url}</h3> 147 - {data.description && ( 148 - <p className="bookmark-preview-desc">{data.description}</p> 149 - )} 150 - </div> 151 - </a> 152 - 153 - {data.tags?.length > 0 && ( 154 - <div className="annotation-tags"> 155 - {data.tags.map((tag, i) => ( 156 - <span key={i} className="annotation-tag"> 157 - #{tag} 158 - </span> 159 - ))} 160 - </div> 161 - )} 162 - </div> 163 - 164 - <footer className="annotation-actions"> 165 - <div className="annotation-actions-left"> 166 - <button 167 - className={`annotation-action ${isLiked ? "liked" : ""}`} 168 - onClick={handleLike} 169 - > 170 - <HeartIcon filled={isLiked} size={16} /> 171 - {likeCount > 0 && <span>{likeCount}</span>} 172 - </button> 173 - 174 - <ShareMenu 175 - uri={data.uri} 176 - text={data.title || data.description} 177 - handle={data.author?.handle} 178 - type="Bookmark" 179 - url={data.url} 180 - /> 181 - 182 - <button className="annotation-action" onClick={handleCollect}> 183 - <Folder size={16} /> 184 - <span>Collect</span> 185 - </button> 186 - </div> 187 - </footer> 188 - </article> 189 - ); 190 - }
-117
web/src/components/CollectionIcon.jsx
··· 1 - import { 2 - Folder, 3 - Star, 4 - Heart, 5 - Bookmark, 6 - Lightbulb, 7 - Zap, 8 - Coffee, 9 - Music, 10 - Camera, 11 - Code, 12 - Globe, 13 - Flag, 14 - Tag, 15 - Box, 16 - Archive, 17 - FileText, 18 - Image, 19 - Video, 20 - Mail, 21 - MapPin, 22 - Calendar, 23 - Clock, 24 - Search, 25 - Settings, 26 - User, 27 - Users, 28 - Home, 29 - Briefcase, 30 - Gift, 31 - Award, 32 - Target, 33 - TrendingUp, 34 - Activity, 35 - Cpu, 36 - Database, 37 - Cloud, 38 - Sun, 39 - Moon, 40 - Flame, 41 - Leaf, 42 - } from "lucide-react"; 43 - 44 - const ICON_MAP = { 45 - folder: Folder, 46 - star: Star, 47 - heart: Heart, 48 - bookmark: Bookmark, 49 - lightbulb: Lightbulb, 50 - zap: Zap, 51 - coffee: Coffee, 52 - music: Music, 53 - camera: Camera, 54 - code: Code, 55 - globe: Globe, 56 - flag: Flag, 57 - tag: Tag, 58 - box: Box, 59 - archive: Archive, 60 - file: FileText, 61 - image: Image, 62 - video: Video, 63 - mail: Mail, 64 - pin: MapPin, 65 - calendar: Calendar, 66 - clock: Clock, 67 - search: Search, 68 - settings: Settings, 69 - user: User, 70 - users: Users, 71 - home: Home, 72 - briefcase: Briefcase, 73 - gift: Gift, 74 - award: Award, 75 - target: Target, 76 - trending: TrendingUp, 77 - activity: Activity, 78 - cpu: Cpu, 79 - database: Database, 80 - cloud: Cloud, 81 - sun: Sun, 82 - moon: Moon, 83 - flame: Flame, 84 - leaf: Leaf, 85 - }; 86 - 87 - export default function CollectionIcon({ icon, size = 22, className = "" }) { 88 - if (!icon) { 89 - return <Folder size={size} className={className} />; 90 - } 91 - 92 - if (icon === "icon:semble") { 93 - return ( 94 - <img 95 - src="/semble-logo.svg" 96 - alt="Semble" 97 - style={{ width: size, height: size, objectFit: "contain" }} 98 - className={className} 99 - /> 100 - ); 101 - } 102 - 103 - if (icon.startsWith("icon:")) { 104 - const iconName = icon.replace("icon:", ""); 105 - const IconComponent = ICON_MAP[iconName]; 106 - if (IconComponent) { 107 - return <IconComponent size={size} className={className} />; 108 - } 109 - return <Folder size={size} className={className} />; 110 - } 111 - 112 - return ( 113 - <span style={{ fontSize: `${size * 0.065}rem`, lineHeight: 1 }}> 114 - {icon} 115 - </span> 116 - ); 117 - }
-75
web/src/components/CollectionItemCard.jsx
··· 1 - import { Link } from "react-router-dom"; 2 - import AnnotationCard, { HighlightCard } from "./AnnotationCard"; 3 - import BookmarkCard from "./BookmarkCard"; 4 - 5 - import CollectionIcon from "./CollectionIcon"; 6 - import ShareMenu from "./ShareMenu"; 7 - 8 - export default function CollectionItemCard({ item, onAddToCollection }) { 9 - const author = item.creator; 10 - const collection = item.collection; 11 - 12 - if (!author || !collection) return null; 13 - 14 - const innerItem = item.annotation || item.highlight || item.bookmark; 15 - if (!innerItem) return null; 16 - 17 - const innerUri = innerItem.uri || innerItem.id; 18 - 19 - return ( 20 - <div className="collection-feed-item"> 21 - <div className="collection-context-badge"> 22 - <div className="collection-context-inner"> 23 - {author.avatar && ( 24 - <img 25 - src={author.avatar} 26 - alt={author.handle} 27 - className="collection-context-avatar" 28 - /> 29 - )} 30 - <span className="collection-context-text"> 31 - <Link 32 - to={`/profile/${author.did}`} 33 - className="collection-context-author" 34 - > 35 - {author.displayName || author.handle} 36 - </Link>{" "} 37 - added to{" "} 38 - <Link 39 - to={`/${author.handle}/collection/${collection.uri.split("/").pop()}`} 40 - className="collection-context-link" 41 - > 42 - <CollectionIcon icon={collection.icon} size={14} /> 43 - {collection.name} 44 - </Link> 45 - </span> 46 - </div> 47 - <ShareMenu 48 - uri={collection.uri} 49 - handle={author.handle} 50 - type="Collection" 51 - text={`Check out this collection: ${collection.name}`} 52 - /> 53 - </div> 54 - 55 - {item.annotation && ( 56 - <AnnotationCard 57 - annotation={item.annotation} 58 - onAddToCollection={() => onAddToCollection?.(innerUri)} 59 - /> 60 - )} 61 - {item.highlight && ( 62 - <HighlightCard 63 - highlight={item.highlight} 64 - onAddToCollection={() => onAddToCollection?.(innerUri)} 65 - /> 66 - )} 67 - {item.bookmark && ( 68 - <BookmarkCard 69 - bookmark={item.bookmark} 70 - onAddToCollection={() => onAddToCollection?.(innerUri)} 71 - /> 72 - )} 73 - </div> 74 - ); 75 - }
-396
web/src/components/CollectionModal.jsx
··· 1 - import { useState, useEffect } from "react"; 2 - import { 3 - X, 4 - Folder, 5 - Star, 6 - Heart, 7 - Bookmark, 8 - Lightbulb, 9 - Zap, 10 - Coffee, 11 - Music, 12 - Camera, 13 - Code, 14 - Globe, 15 - Flag, 16 - Tag, 17 - Box, 18 - Archive, 19 - FileText, 20 - Image, 21 - Video, 22 - Mail, 23 - MapPin, 24 - Calendar, 25 - Clock, 26 - Search, 27 - Settings, 28 - User, 29 - Users, 30 - Home, 31 - Briefcase, 32 - Gift, 33 - Award, 34 - Target, 35 - TrendingUp, 36 - Activity, 37 - Cpu, 38 - Database, 39 - Cloud, 40 - Sun, 41 - Moon, 42 - Flame, 43 - Leaf, 44 - Trash2, 45 - } from "lucide-react"; 46 - import { 47 - createCollection, 48 - updateCollection, 49 - deleteCollection, 50 - } from "../api/client"; 51 - 52 - const EMOJI_OPTIONS = [ 53 - "📁", 54 - "📚", 55 - "💡", 56 - "⭐", 57 - "🔖", 58 - "💻", 59 - "🎨", 60 - "📝", 61 - "🔬", 62 - "🎯", 63 - "🚀", 64 - "💎", 65 - "🌟", 66 - "📌", 67 - "💼", 68 - "🎮", 69 - "🎵", 70 - "🎬", 71 - "❤️", 72 - "🔥", 73 - "🌈", 74 - "🌸", 75 - "🌿", 76 - "🧠", 77 - "🏆", 78 - "📊", 79 - "🎓", 80 - "✨", 81 - "🔧", 82 - "⚡", 83 - ]; 84 - 85 - const ICON_OPTIONS = [ 86 - { icon: Folder, name: "folder" }, 87 - { icon: Star, name: "star" }, 88 - { icon: Heart, name: "heart" }, 89 - { icon: Bookmark, name: "bookmark" }, 90 - { icon: Lightbulb, name: "lightbulb" }, 91 - { icon: Zap, name: "zap" }, 92 - { icon: Coffee, name: "coffee" }, 93 - { icon: Music, name: "music" }, 94 - { icon: Camera, name: "camera" }, 95 - { icon: Code, name: "code" }, 96 - { icon: Globe, name: "globe" }, 97 - { icon: Flag, name: "flag" }, 98 - { icon: Tag, name: "tag" }, 99 - { icon: Box, name: "box" }, 100 - { icon: Archive, name: "archive" }, 101 - { icon: FileText, name: "file" }, 102 - { icon: Image, name: "image" }, 103 - { icon: Video, name: "video" }, 104 - { icon: Mail, name: "mail" }, 105 - { icon: MapPin, name: "pin" }, 106 - { icon: Calendar, name: "calendar" }, 107 - { icon: Clock, name: "clock" }, 108 - { icon: Search, name: "search" }, 109 - { icon: Settings, name: "settings" }, 110 - { icon: User, name: "user" }, 111 - { icon: Users, name: "users" }, 112 - { icon: Home, name: "home" }, 113 - { icon: Briefcase, name: "briefcase" }, 114 - { icon: Gift, name: "gift" }, 115 - { icon: Award, name: "award" }, 116 - { icon: Target, name: "target" }, 117 - { icon: TrendingUp, name: "trending" }, 118 - { icon: Activity, name: "activity" }, 119 - { icon: Cpu, name: "cpu" }, 120 - { icon: Database, name: "database" }, 121 - { icon: Cloud, name: "cloud" }, 122 - { icon: Sun, name: "sun" }, 123 - { icon: Moon, name: "moon" }, 124 - { icon: Flame, name: "flame" }, 125 - { icon: Leaf, name: "leaf" }, 126 - ]; 127 - 128 - export default function CollectionModal({ 129 - isOpen, 130 - onClose, 131 - onSuccess, 132 - collectionToEdit, 133 - onDelete, 134 - }) { 135 - const [name, setName] = useState(""); 136 - const [description, setDescription] = useState(""); 137 - const [icon, setIcon] = useState(""); 138 - const [customEmoji, setCustomEmoji] = useState(""); 139 - const [activeTab, setActiveTab] = useState("emoji"); 140 - const [loading, setLoading] = useState(false); 141 - const [deleting, setDeleting] = useState(false); 142 - const [error, setError] = useState(null); 143 - 144 - useEffect(() => { 145 - if (collectionToEdit) { 146 - setName(collectionToEdit.name); 147 - setDescription(collectionToEdit.description || ""); 148 - const savedIcon = collectionToEdit.icon || ""; 149 - setIcon(savedIcon); 150 - setCustomEmoji(savedIcon); 151 - 152 - if (savedIcon.startsWith("icon:")) { 153 - setActiveTab("icons"); 154 - } 155 - } else { 156 - setName(""); 157 - setDescription(""); 158 - setIcon(""); 159 - setCustomEmoji(""); 160 - } 161 - setError(null); 162 - }, [collectionToEdit, isOpen]); 163 - 164 - if (!isOpen) return null; 165 - 166 - const handleEmojiSelect = (emoji) => { 167 - if (icon === emoji) { 168 - setIcon(""); 169 - setCustomEmoji(""); 170 - } else { 171 - setIcon(emoji); 172 - setCustomEmoji(emoji); 173 - } 174 - }; 175 - 176 - const handleIconSelect = (iconName) => { 177 - const value = `icon:${iconName}`; 178 - if (icon === value) { 179 - setIcon(""); 180 - setCustomEmoji(""); 181 - } else { 182 - setIcon(value); 183 - setCustomEmoji(value); 184 - } 185 - }; 186 - 187 - const handleCustomEmojiChange = (e) => { 188 - const value = e.target.value; 189 - setCustomEmoji(value); 190 - const emojiMatch = value.match( 191 - /(\p{Emoji_Presentation}|\p{Emoji}\uFE0F)/gu, 192 - ); 193 - if (emojiMatch && emojiMatch.length > 0) { 194 - setIcon(emojiMatch[emojiMatch.length - 1]); 195 - } else if (value === "") { 196 - setIcon(""); 197 - } 198 - }; 199 - 200 - const handleSubmit = async (e) => { 201 - e.preventDefault(); 202 - setLoading(true); 203 - setError(null); 204 - 205 - try { 206 - if (collectionToEdit) { 207 - await updateCollection(collectionToEdit.uri, name, description, icon); 208 - } else { 209 - await createCollection(name, description, icon); 210 - } 211 - onSuccess(); 212 - onClose(); 213 - } catch (err) { 214 - console.error(err); 215 - setError(err.message || "Failed to save collection"); 216 - } finally { 217 - setLoading(false); 218 - } 219 - }; 220 - 221 - const handleDelete = async () => { 222 - if ( 223 - !confirm( 224 - "Delete this collection and all its items? This cannot be undone.", 225 - ) 226 - ) { 227 - return; 228 - } 229 - setDeleting(true); 230 - setError(null); 231 - 232 - try { 233 - await deleteCollection(collectionToEdit.uri); 234 - if (onDelete) { 235 - onDelete(); 236 - } else { 237 - onSuccess(); 238 - } 239 - onClose(); 240 - } catch (err) { 241 - console.error(err); 242 - setError(err.message || "Failed to delete collection"); 243 - } finally { 244 - setDeleting(false); 245 - } 246 - }; 247 - 248 - return ( 249 - <div className="modal-overlay" onClick={onClose}> 250 - <div 251 - className="modal-container" 252 - style={{ maxWidth: "420px" }} 253 - onClick={(e) => e.stopPropagation()} 254 - > 255 - <div className="modal-header"> 256 - <h2 className="modal-title"> 257 - {collectionToEdit ? "Edit Collection" : "New Collection"} 258 - </h2> 259 - <button onClick={onClose} className="modal-close-btn"> 260 - <X size={20} /> 261 - </button> 262 - </div> 263 - 264 - <form onSubmit={handleSubmit} className="modal-form"> 265 - {error && ( 266 - <div 267 - className="card text-error" 268 - style={{ 269 - padding: "12px", 270 - background: "rgba(239, 68, 68, 0.1)", 271 - borderColor: "rgba(239, 68, 68, 0.2)", 272 - fontSize: "0.9rem", 273 - }} 274 - > 275 - {error} 276 - </div> 277 - )} 278 - 279 - <div className="form-group"> 280 - <label className="form-label">Icon</label> 281 - <div className="icon-picker-tabs"> 282 - <button 283 - type="button" 284 - className={`icon-picker-tab ${activeTab === "emoji" ? "active" : ""}`} 285 - onClick={() => setActiveTab("emoji")} 286 - > 287 - Emoji 288 - </button> 289 - <button 290 - type="button" 291 - className={`icon-picker-tab ${activeTab === "icons" ? "active" : ""}`} 292 - onClick={() => setActiveTab("icons")} 293 - > 294 - Icons 295 - </button> 296 - </div> 297 - 298 - {activeTab === "emoji" && ( 299 - <div className="emoji-picker-wrapper"> 300 - <div className="emoji-custom-input"> 301 - <input 302 - type="text" 303 - value={customEmoji.startsWith("icon:") ? "" : customEmoji} 304 - onChange={handleCustomEmojiChange} 305 - placeholder="Type any emoji..." 306 - className="form-input" 307 - /> 308 - </div> 309 - <div className="emoji-picker"> 310 - {EMOJI_OPTIONS.map((emoji) => ( 311 - <button 312 - key={emoji} 313 - type="button" 314 - className={`emoji-option ${icon === emoji ? "selected" : ""}`} 315 - onClick={() => handleEmojiSelect(emoji)} 316 - > 317 - {emoji} 318 - </button> 319 - ))} 320 - </div> 321 - </div> 322 - )} 323 - 324 - {activeTab === "icons" && ( 325 - <div className="icon-picker"> 326 - {ICON_OPTIONS.map(({ icon: IconComponent, name: iconName }) => ( 327 - <button 328 - key={iconName} 329 - type="button" 330 - className={`icon-option ${icon === `icon:${iconName}` ? "selected" : ""}`} 331 - onClick={() => handleIconSelect(iconName)} 332 - > 333 - <IconComponent size={20} /> 334 - </button> 335 - ))} 336 - </div> 337 - )} 338 - </div> 339 - 340 - <div className="form-group"> 341 - <label className="form-label">Name</label> 342 - <input 343 - type="text" 344 - value={name} 345 - onChange={(e) => setName(e.target.value)} 346 - required 347 - className="form-input" 348 - placeholder="My Favorites" 349 - /> 350 - </div> 351 - 352 - <div className="form-group"> 353 - <label className="form-label">Description</label> 354 - <textarea 355 - value={description} 356 - onChange={(e) => setDescription(e.target.value)} 357 - rows={2} 358 - className="form-textarea" 359 - placeholder="A collection of..." 360 - /> 361 - </div> 362 - 363 - <div className="modal-actions"> 364 - {collectionToEdit && ( 365 - <button 366 - type="button" 367 - onClick={handleDelete} 368 - disabled={deleting} 369 - className="btn btn-danger" 370 - > 371 - <Trash2 size={16} /> 372 - {deleting ? "Deleting..." : "Delete"} 373 - </button> 374 - )} 375 - <div style={{ flex: 1 }} /> 376 - <button type="button" onClick={onClose} className="btn btn-ghost"> 377 - Cancel 378 - </button> 379 - <button 380 - type="submit" 381 - disabled={loading} 382 - className="btn btn-primary" 383 - style={loading ? { opacity: 0.7, cursor: "wait" } : {}} 384 - > 385 - {loading 386 - ? "Saving..." 387 - : collectionToEdit 388 - ? "Save Changes" 389 - : "Create Collection"} 390 - </button> 391 - </div> 392 - </form> 393 - </div> 394 - </div> 395 - ); 396 - }
-42
web/src/components/CollectionRow.jsx
··· 1 - import { Link } from "react-router-dom"; 2 - import { ChevronRight, Edit2 } from "lucide-react"; 3 - import CollectionIcon from "./CollectionIcon"; 4 - 5 - export default function CollectionRow({ collection, onEdit }) { 6 - return ( 7 - <div className="collection-row"> 8 - <Link 9 - to={ 10 - collection.creator?.handle 11 - ? `/${collection.creator.handle}/collection/${collection.uri.split("/").pop()}` 12 - : `/collection/${encodeURIComponent(collection.uri)}` 13 - } 14 - className="collection-row-content" 15 - > 16 - <div className="collection-row-icon"> 17 - <CollectionIcon icon={collection.icon} size={22} /> 18 - </div> 19 - <div className="collection-row-info"> 20 - <h3 className="collection-row-name">{collection.name}</h3> 21 - {collection.description && ( 22 - <p className="collection-row-desc">{collection.description}</p> 23 - )} 24 - </div> 25 - <ChevronRight size={20} className="collection-row-arrow" /> 26 - </Link> 27 - {onEdit && !collection.uri.includes("network.cosmik") && ( 28 - <button 29 - onClick={(e) => { 30 - e.preventDefault(); 31 - e.stopPropagation(); 32 - onEdit(); 33 - }} 34 - className="collection-row-edit" 35 - title="Edit collection" 36 - > 37 - <Edit2 size={16} /> 38 - </button> 39 - )} 40 - </div> 41 - ); 42 - }
-184
web/src/components/Composer.jsx
··· 1 - import { useState } from "react"; 2 - import { createAnnotation, createHighlight } from "../api/client"; 3 - 4 - export default function Composer({ 5 - url, 6 - selector: initialSelector, 7 - onSuccess, 8 - onCancel, 9 - }) { 10 - const [text, setText] = useState(""); 11 - const [quoteText, setQuoteText] = useState(""); 12 - const [tags, setTags] = useState(""); 13 - const [selector, setSelector] = useState(initialSelector); 14 - const [loading, setLoading] = useState(false); 15 - const [error, setError] = useState(null); 16 - const [showQuoteInput, setShowQuoteInput] = useState(false); 17 - 18 - const highlightedText = 19 - selector?.type === "TextQuoteSelector" ? selector.exact : null; 20 - 21 - const handleSubmit = async (e) => { 22 - e.preventDefault(); 23 - if (!text.trim() && !highlightedText && !quoteText.trim()) return; 24 - 25 - try { 26 - setLoading(true); 27 - setError(null); 28 - 29 - let finalSelector = selector; 30 - if (!finalSelector && quoteText.trim()) { 31 - finalSelector = { 32 - type: "TextQuoteSelector", 33 - exact: quoteText.trim(), 34 - }; 35 - } 36 - 37 - const tagList = tags 38 - .split(",") 39 - .map((t) => t.trim()) 40 - .filter(Boolean); 41 - 42 - if (!text.trim()) { 43 - await createHighlight({ 44 - url, 45 - selector: finalSelector, 46 - color: "yellow", 47 - tags: tagList, 48 - }); 49 - } else { 50 - await createAnnotation({ 51 - url, 52 - text, 53 - selector: finalSelector || undefined, 54 - tags: tagList, 55 - }); 56 - } 57 - 58 - setText(""); 59 - setQuoteText(""); 60 - setSelector(null); 61 - if (onSuccess) onSuccess(); 62 - } catch (err) { 63 - setError(err.message); 64 - } finally { 65 - setLoading(false); 66 - } 67 - }; 68 - 69 - const handleRemoveSelector = () => { 70 - setSelector(null); 71 - setQuoteText(""); 72 - setShowQuoteInput(false); 73 - }; 74 - 75 - return ( 76 - <form onSubmit={handleSubmit} className="composer"> 77 - <div className="composer-header"> 78 - <h3 className="composer-title">New Annotation</h3> 79 - {url && <div className="composer-url">{url}</div>} 80 - </div> 81 - 82 - {} 83 - {highlightedText && ( 84 - <div className="composer-quote"> 85 - <button 86 - type="button" 87 - className="composer-quote-remove" 88 - onClick={handleRemoveSelector} 89 - title="Remove selection" 90 - > 91 - × 92 - </button> 93 - <blockquote> 94 - <mark className="quote-exact">&quot;{highlightedText}&quot;</mark> 95 - </blockquote> 96 - </div> 97 - )} 98 - 99 - {} 100 - {!highlightedText && ( 101 - <> 102 - {!showQuoteInput ? ( 103 - <button 104 - type="button" 105 - className="composer-add-quote" 106 - onClick={() => setShowQuoteInput(true)} 107 - > 108 - + Add a quote from the page 109 - </button> 110 - ) : ( 111 - <div className="composer-quote-input-wrapper"> 112 - <textarea 113 - value={quoteText} 114 - onChange={(e) => setQuoteText(e.target.value)} 115 - placeholder="Paste or type the text you're annotating..." 116 - className="composer-quote-input" 117 - rows={2} 118 - /> 119 - <button 120 - type="button" 121 - className="composer-quote-remove-btn" 122 - onClick={handleRemoveSelector} 123 - > 124 - Remove 125 - </button> 126 - </div> 127 - )} 128 - </> 129 - )} 130 - 131 - <textarea 132 - value={text} 133 - onChange={(e) => setText(e.target.value)} 134 - placeholder={ 135 - highlightedText || quoteText 136 - ? "Add your comment about this selection..." 137 - : "Write your annotation..." 138 - } 139 - className="composer-input" 140 - rows={4} 141 - maxLength={3000} 142 - disabled={loading} 143 - /> 144 - 145 - <div className="composer-tags"> 146 - <input 147 - type="text" 148 - value={tags} 149 - onChange={(e) => setTags(e.target.value)} 150 - placeholder="Add tags (comma separated)..." 151 - className="composer-tags-input" 152 - disabled={loading} 153 - /> 154 - </div> 155 - 156 - <div className="composer-footer"> 157 - <span className="composer-count">{text.length}/3000</span> 158 - <div className="composer-actions"> 159 - {onCancel && ( 160 - <button 161 - type="button" 162 - className="btn btn-ghost" 163 - onClick={onCancel} 164 - disabled={loading} 165 - > 166 - Cancel 167 - </button> 168 - )} 169 - <button 170 - type="submit" 171 - className="btn btn-primary" 172 - disabled={ 173 - loading || (!text.trim() && !highlightedText && !quoteText) 174 - } 175 - > 176 - {loading ? "Posting..." : "Post"} 177 - </button> 178 - </div> 179 - </div> 180 - 181 - {error && <div className="composer-error">{error}</div>} 182 - </form> 183 - ); 184 - }
-258
web/src/components/EditProfileModal.jsx
··· 1 - import { useState, useRef } from "react"; 2 - import { updateProfile, uploadAvatar } from "../api/client"; 3 - 4 - export default function EditProfileModal({ profile, onClose, onUpdate }) { 5 - const [displayName, setDisplayName] = useState(profile?.displayName || ""); 6 - const [avatarBlob, setAvatarBlob] = useState(null); 7 - const [avatarPreview, setAvatarPreview] = useState(null); 8 - const [bio, setBio] = useState(profile?.bio || ""); 9 - const [website, setWebsite] = useState(profile?.website || ""); 10 - const [links, setLinks] = useState(profile?.links || []); 11 - const [newLink, setNewLink] = useState(""); 12 - const [saving, setSaving] = useState(false); 13 - const [uploading, setUploading] = useState(false); 14 - const [error, setError] = useState(null); 15 - const fileInputRef = useRef(null); 16 - 17 - const handleAvatarChange = async (e) => { 18 - const file = e.target.files?.[0]; 19 - if (!file) return; 20 - 21 - if (!["image/jpeg", "image/png"].includes(file.type)) { 22 - setError("Please select a JPEG or PNG image"); 23 - return; 24 - } 25 - 26 - if (file.size > 1024 * 1024) { 27 - setError("Image must be under 1MB"); 28 - return; 29 - } 30 - 31 - setAvatarPreview(URL.createObjectURL(file)); 32 - setUploading(true); 33 - setError(null); 34 - 35 - try { 36 - const result = await uploadAvatar(file); 37 - setAvatarBlob(result.blob); 38 - } catch (err) { 39 - setError("Failed to upload avatar: " + err.message); 40 - setAvatarPreview(null); 41 - } finally { 42 - setUploading(false); 43 - } 44 - }; 45 - 46 - const handleSubmit = async (e) => { 47 - e.preventDefault(); 48 - setSaving(true); 49 - setError(null); 50 - 51 - try { 52 - await updateProfile({ 53 - displayName, 54 - avatar: avatarBlob, 55 - bio, 56 - website, 57 - links, 58 - }); 59 - onUpdate(); 60 - onClose(); 61 - } catch (err) { 62 - setError(err.message); 63 - } finally { 64 - setSaving(false); 65 - } 66 - }; 67 - 68 - const addLink = () => { 69 - if (!newLink) return; 70 - 71 - if (!links.includes(newLink)) { 72 - setLinks([...links, newLink]); 73 - setNewLink(""); 74 - setError(null); 75 - } 76 - }; 77 - 78 - const removeLink = (index) => { 79 - setLinks(links.filter((_, i) => i !== index)); 80 - }; 81 - 82 - const currentAvatar = 83 - avatarPreview || (profile?.did ? `/api/avatar/${profile.did}` : null); 84 - 85 - return ( 86 - <div className="modal-overlay" onClick={onClose}> 87 - <div className="modal-container" onClick={(e) => e.stopPropagation()}> 88 - <div className="modal-header"> 89 - <h2>Edit Profile</h2> 90 - <button className="modal-close-btn" onClick={onClose}> 91 - <svg 92 - width="20" 93 - height="20" 94 - viewBox="0 0 24 24" 95 - fill="none" 96 - stroke="currentColor" 97 - strokeWidth="2" 98 - strokeLinecap="round" 99 - strokeLinejoin="round" 100 - > 101 - <line x1="18" y1="6" x2="6" y2="18" /> 102 - <line x1="6" y1="6" x2="18" y2="18" /> 103 - </svg> 104 - </button> 105 - </div> 106 - <form onSubmit={handleSubmit} className="modal-body"> 107 - {error && <div className="error-message">{error}</div>} 108 - 109 - <div className="form-group"> 110 - <label>Avatar</label> 111 - <div className="avatar-upload-container"> 112 - <div 113 - className="avatar-preview" 114 - onClick={() => fileInputRef.current?.click()} 115 - style={{ cursor: "pointer" }} 116 - > 117 - {currentAvatar ? ( 118 - <img 119 - src={currentAvatar} 120 - alt="Avatar preview" 121 - className="avatar-preview-img" 122 - /> 123 - ) : ( 124 - <div className="avatar-placeholder"> 125 - <svg 126 - width="32" 127 - height="32" 128 - viewBox="0 0 24 24" 129 - fill="none" 130 - stroke="currentColor" 131 - strokeWidth="2" 132 - > 133 - <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" /> 134 - <circle cx="12" cy="7" r="4" /> 135 - </svg> 136 - </div> 137 - )} 138 - {uploading && ( 139 - <div className="avatar-uploading"> 140 - <span>Uploading...</span> 141 - </div> 142 - )} 143 - </div> 144 - <input 145 - ref={fileInputRef} 146 - type="file" 147 - accept="image/jpeg,image/png" 148 - onChange={handleAvatarChange} 149 - style={{ display: "none" }} 150 - /> 151 - <button 152 - type="button" 153 - className="btn btn-secondary btn-sm" 154 - onClick={() => fileInputRef.current?.click()} 155 - disabled={uploading} 156 - > 157 - {uploading ? "Uploading..." : "Change Avatar"} 158 - </button> 159 - </div> 160 - </div> 161 - 162 - <div className="form-group"> 163 - <label>Display Name</label> 164 - <input 165 - type="text" 166 - className="input" 167 - value={displayName} 168 - onChange={(e) => setDisplayName(e.target.value)} 169 - placeholder="Your name" 170 - maxLength={64} 171 - /> 172 - <div className="char-count">{displayName.length}/64</div> 173 - </div> 174 - 175 - <div className="form-group"> 176 - <label>Bio</label> 177 - <textarea 178 - className="input" 179 - value={bio} 180 - onChange={(e) => setBio(e.target.value)} 181 - placeholder="Tell us about yourself..." 182 - rows={4} 183 - maxLength={5000} 184 - /> 185 - <div className="char-count">{bio.length}/5000</div> 186 - </div> 187 - 188 - <div className="form-group"> 189 - <label>Website</label> 190 - <input 191 - type="url" 192 - className="input" 193 - value={website} 194 - onChange={(e) => setWebsite(e.target.value)} 195 - placeholder="https://example.com" 196 - maxLength={1000} 197 - /> 198 - </div> 199 - 200 - <div className="form-group"> 201 - <label>Links</label> 202 - <div className="links-input-group"> 203 - <input 204 - type="url" 205 - className="input" 206 - value={newLink} 207 - onChange={(e) => setNewLink(e.target.value)} 208 - placeholder="Add a link (e.g. GitHub, LinkedIn)..." 209 - onKeyDown={(e) => 210 - e.key === "Enter" && (e.preventDefault(), addLink()) 211 - } 212 - /> 213 - <button 214 - type="button" 215 - className="btn btn-secondary" 216 - onClick={addLink} 217 - > 218 - Add 219 - </button> 220 - </div> 221 - <ul className="links-list"> 222 - {links.map((link, i) => ( 223 - <li key={i} className="link-item"> 224 - <span>{link}</span> 225 - <button 226 - type="button" 227 - className="btn-icon-sm" 228 - onClick={() => removeLink(i)} 229 - > 230 - × 231 - </button> 232 - </li> 233 - ))} 234 - </ul> 235 - </div> 236 - 237 - <div className="modal-actions"> 238 - <button 239 - type="button" 240 - className="btn btn-secondary" 241 - onClick={onClose} 242 - disabled={saving || uploading} 243 - > 244 - Cancel 245 - </button> 246 - <button 247 - type="submit" 248 - className="btn btn-primary" 249 - disabled={saving || uploading} 250 - > 251 - {saving ? "Saving..." : "Save Profile"} 252 - </button> 253 - </div> 254 - </form> 255 - </div> 256 - </div> 257 - ); 258 - }
-52
web/src/components/IOSInstallBanner.jsx
··· 1 - import { useState } from "react"; 2 - import { X } from "lucide-react"; 3 - import { SiApple } from "react-icons/si"; 4 - 5 - function shouldShowBanner() { 6 - if (typeof window === "undefined") return false; 7 - const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent); 8 - if (!isIOS) return false; 9 - 10 - const dismissedAt = localStorage.getItem("ios-shortcut-dismissed"); 11 - const daysSinceDismissed = dismissedAt 12 - ? (Date.now() - parseInt(dismissedAt, 10)) / (1000 * 60 * 60 * 24) 13 - : Infinity; 14 - return daysSinceDismissed > 7; 15 - } 16 - 17 - export default function IOSInstallBanner() { 18 - const [show, setShow] = useState(shouldShowBanner); 19 - 20 - const handleDismiss = () => { 21 - setShow(false); 22 - localStorage.setItem("ios-shortcut-dismissed", Date.now().toString()); 23 - }; 24 - 25 - if (!show) return null; 26 - 27 - return ( 28 - <div className="ios-shortcut-banner"> 29 - <button 30 - className="ios-shortcut-banner-close" 31 - onClick={handleDismiss} 32 - aria-label="Dismiss" 33 - > 34 - <X size={14} /> 35 - </button> 36 - <div className="ios-shortcut-banner-content"> 37 - <div className="ios-shortcut-banner-text"> 38 - <p>Save pages directly from Safari</p> 39 - </div> 40 - <a 41 - href="https://www.icloud.com/shortcuts/21c87edf29b046db892c9e57dac6d1fd" 42 - target="_blank" 43 - rel="noopener noreferrer" 44 - className="ios-shortcut-banner-btn" 45 - > 46 - <SiApple size={14} /> 47 - Get iOS Shortcut 48 - </a> 49 - </div> 50 - </div> 51 - ); 52 - }
-505
web/src/components/Icons.jsx
··· 1 - import tangledLogo from "../assets/tangled.svg"; 2 - import { FaGithub, FaLinkedin } from "react-icons/fa"; 3 - 4 - export function HeartIcon({ filled = false, size = 18 }) { 5 - return filled ? ( 6 - <svg 7 - width={size} 8 - height={size} 9 - viewBox="0 0 24 24" 10 - fill="currentColor" 11 - stroke="none" 12 - > 13 - <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" /> 14 - </svg> 15 - ) : ( 16 - <svg 17 - width={size} 18 - height={size} 19 - viewBox="0 0 24 24" 20 - fill="none" 21 - stroke="currentColor" 22 - strokeWidth="2" 23 - strokeLinecap="round" 24 - strokeLinejoin="round" 25 - > 26 - <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" /> 27 - </svg> 28 - ); 29 - } 30 - 31 - export function MessageIcon({ size = 18 }) { 32 - return ( 33 - <svg 34 - width={size} 35 - height={size} 36 - viewBox="0 0 24 24" 37 - fill="none" 38 - stroke="currentColor" 39 - strokeWidth="2" 40 - strokeLinecap="round" 41 - strokeLinejoin="round" 42 - > 43 - <path d="m3 21 1.9-5.7a8.5 8.5 0 1 1 3.8 3.8z" /> 44 - </svg> 45 - ); 46 - } 47 - 48 - export function ShareIcon({ size = 18 }) { 49 - return ( 50 - <svg 51 - width={size} 52 - height={size} 53 - viewBox="0 0 24 24" 54 - fill="none" 55 - stroke="currentColor" 56 - strokeWidth="2" 57 - strokeLinecap="round" 58 - strokeLinejoin="round" 59 - > 60 - <path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" /> 61 - <polyline points="16 6 12 2 8 6" /> 62 - <line x1="12" x2="12" y1="2" y2="15" /> 63 - </svg> 64 - ); 65 - } 66 - 67 - export function TrashIcon({ size = 18 }) { 68 - return ( 69 - <svg 70 - width={size} 71 - height={size} 72 - viewBox="0 0 24 24" 73 - fill="none" 74 - stroke="currentColor" 75 - strokeWidth="2" 76 - strokeLinecap="round" 77 - strokeLinejoin="round" 78 - > 79 - <path d="M3 6h18" /> 80 - <path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" /> 81 - <path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" /> 82 - </svg> 83 - ); 84 - } 85 - 86 - export function LinkIcon({ size = 18 }) { 87 - return ( 88 - <svg 89 - width={size} 90 - height={size} 91 - viewBox="0 0 24 24" 92 - fill="none" 93 - stroke="currentColor" 94 - strokeWidth="2" 95 - strokeLinecap="round" 96 - strokeLinejoin="round" 97 - > 98 - <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" /> 99 - <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" /> 100 - </svg> 101 - ); 102 - } 103 - 104 - export function ExternalLinkIcon({ size = 14 }) { 105 - return ( 106 - <svg 107 - width={size} 108 - height={size} 109 - viewBox="0 0 24 24" 110 - fill="none" 111 - stroke="currentColor" 112 - strokeWidth="2" 113 - strokeLinecap="round" 114 - strokeLinejoin="round" 115 - > 116 - <path d="M15 3h6v6" /> 117 - <path d="M10 14 21 3" /> 118 - <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /> 119 - </svg> 120 - ); 121 - } 122 - 123 - export function PenIcon({ size = 18 }) { 124 - return ( 125 - <svg 126 - width={size} 127 - height={size} 128 - viewBox="0 0 24 24" 129 - fill="none" 130 - stroke="currentColor" 131 - strokeWidth="2" 132 - strokeLinecap="round" 133 - strokeLinejoin="round" 134 - > 135 - <path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" /> 136 - </svg> 137 - ); 138 - } 139 - 140 - export function HighlightIcon({ size = 18 }) { 141 - return ( 142 - <svg 143 - width={size} 144 - height={size} 145 - viewBox="0 0 24 24" 146 - fill="none" 147 - stroke="currentColor" 148 - strokeWidth="2" 149 - strokeLinecap="round" 150 - strokeLinejoin="round" 151 - > 152 - <path d="m9 11-6 6v3h9l3-3" /> 153 - <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" /> 154 - </svg> 155 - ); 156 - } 157 - 158 - export function BookmarkIcon({ size = 18 }) { 159 - return ( 160 - <svg 161 - width={size} 162 - height={size} 163 - viewBox="0 0 24 24" 164 - fill="none" 165 - stroke="currentColor" 166 - strokeWidth="2" 167 - strokeLinecap="round" 168 - strokeLinejoin="round" 169 - > 170 - <path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z" /> 171 - </svg> 172 - ); 173 - } 174 - 175 - export function TagIcon({ size = 18 }) { 176 - return ( 177 - <svg 178 - width={size} 179 - height={size} 180 - viewBox="0 0 24 24" 181 - fill="none" 182 - stroke="currentColor" 183 - strokeWidth="2" 184 - strokeLinecap="round" 185 - strokeLinejoin="round" 186 - > 187 - <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" /> 188 - <circle cx="7.5" cy="7.5" r=".5" fill="currentColor" /> 189 - </svg> 190 - ); 191 - } 192 - 193 - export function AlertIcon({ size = 18 }) { 194 - return ( 195 - <svg 196 - width={size} 197 - height={size} 198 - viewBox="0 0 24 24" 199 - fill="none" 200 - stroke="currentColor" 201 - strokeWidth="2" 202 - strokeLinecap="round" 203 - strokeLinejoin="round" 204 - > 205 - <circle cx="12" cy="12" r="10" /> 206 - <line x1="12" x2="12" y1="8" y2="12" /> 207 - <line x1="12" x2="12.01" y1="16" y2="16" /> 208 - </svg> 209 - ); 210 - } 211 - 212 - export function FileTextIcon({ size = 18 }) { 213 - return ( 214 - <svg 215 - width={size} 216 - height={size} 217 - viewBox="0 0 24 24" 218 - fill="none" 219 - stroke="currentColor" 220 - strokeWidth="2" 221 - strokeLinecap="round" 222 - strokeLinejoin="round" 223 - > 224 - <path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /> 225 - <path d="M14 2v4a2 2 0 0 0 2 2h4" /> 226 - <path d="M10 9H8" /> 227 - <path d="M16 13H8" /> 228 - <path d="M16 17H8" /> 229 - </svg> 230 - ); 231 - } 232 - 233 - export function SearchIcon({ size = 18 }) { 234 - return ( 235 - <svg 236 - width={size} 237 - height={size} 238 - viewBox="0 0 24 24" 239 - fill="none" 240 - stroke="currentColor" 241 - strokeWidth="2" 242 - strokeLinecap="round" 243 - strokeLinejoin="round" 244 - > 245 - <circle cx="11" cy="11" r="8" /> 246 - <path d="m21 21-4.3-4.3" /> 247 - </svg> 248 - ); 249 - } 250 - 251 - export function InboxIcon({ size = 18 }) { 252 - return ( 253 - <svg 254 - width={size} 255 - height={size} 256 - viewBox="0 0 24 24" 257 - fill="none" 258 - stroke="currentColor" 259 - strokeWidth="2" 260 - strokeLinecap="round" 261 - strokeLinejoin="round" 262 - > 263 - <polyline points="22 12 16 12 14 15 10 15 8 12 2 12" /> 264 - <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" /> 265 - </svg> 266 - ); 267 - } 268 - 269 - export function BlueskyIcon({ size = 18, color = "currentColor" }) { 270 - return ( 271 - <svg 272 - xmlns="http://www.w3.org/2000/svg" 273 - viewBox="0 0 512 512" 274 - width={size} 275 - height={size} 276 - > 277 - <path 278 - fill={color} 279 - 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" 280 - /> 281 - </svg> 282 - ); 283 - } 284 - 285 - export function MarginIcon({ size = 18 }) { 286 - return ( 287 - <svg 288 - width={size} 289 - height={size} 290 - viewBox="0 0 265 231" 291 - fill="currentColor" 292 - xmlns="http://www.w3.org/2000/svg" 293 - > 294 - <path d="M0 230 V0 H199 V65.7156 H149.5 V115.216 H182.5 L199 131.716 V230 Z" /> 295 - <path d="M215 214.224 V230 H264.5 V0 H215.07 V16.2242 H248.5 V214.224 H215 Z" /> 296 - </svg> 297 - ); 298 - } 299 - 300 - export function LogoutIcon({ size = 18 }) { 301 - return ( 302 - <svg 303 - width={size} 304 - height={size} 305 - viewBox="0 0 24 24" 306 - fill="none" 307 - stroke="currentColor" 308 - strokeWidth="2" 309 - strokeLinecap="round" 310 - strokeLinejoin="round" 311 - > 312 - <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /> 313 - <polyline points="16 17 21 12 16 7" /> 314 - <line x1="21" x2="9" y1="12" y2="12" /> 315 - </svg> 316 - ); 317 - } 318 - 319 - export function BellIcon({ size = 18 }) { 320 - return ( 321 - <svg 322 - width={size} 323 - height={size} 324 - viewBox="0 0 24 24" 325 - fill="none" 326 - stroke="currentColor" 327 - strokeWidth="2" 328 - strokeLinecap="round" 329 - strokeLinejoin="round" 330 - > 331 - <path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" /> 332 - <path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" /> 333 - </svg> 334 - ); 335 - } 336 - 337 - export function ReplyIcon({ size = 18 }) { 338 - return ( 339 - <svg 340 - width={size} 341 - height={size} 342 - viewBox="0 0 24 24" 343 - fill="none" 344 - stroke="currentColor" 345 - strokeWidth="2" 346 - strokeLinecap="round" 347 - strokeLinejoin="round" 348 - > 349 - <polyline points="9 17 4 12 9 7" /> 350 - <path d="M20 18v-2a4 4 0 0 0-4-4H4" /> 351 - </svg> 352 - ); 353 - } 354 - 355 - export function AturiIcon({ size = 18 }) { 356 - return ( 357 - <svg 358 - width={size} 359 - height={size} 360 - viewBox="0 0 24 24" 361 - fill="none" 362 - stroke="currentColor" 363 - strokeWidth="2" 364 - strokeLinecap="round" 365 - strokeLinejoin="round" 366 - > 367 - <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" /> 368 - <path d="M2 21c0-3 1.85-5.36 5.08-6C9.5 14.52 12 13 13 12" /> 369 - </svg> 370 - ); 371 - } 372 - 373 - export function BlackskyIcon({ size = 18 }) { 374 - return ( 375 - <svg viewBox="0 0 285 285" width={size} height={size}> 376 - <path 377 - fill="currentColor" 378 - 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" 379 - /> 380 - <path 381 - fill="currentColor" 382 - 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" 383 - /> 384 - <path 385 - fill="currentColor" 386 - 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" 387 - /> 388 - <path 389 - fill="currentColor" 390 - 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" 391 - /> 392 - </svg> 393 - ); 394 - } 395 - 396 - export function NorthskyIcon({ size = 18 }) { 397 - return ( 398 - <svg viewBox="0 0 1024 1024" width={size} height={size}> 399 - <defs> 400 - <linearGradient 401 - id="north_a" 402 - x1="564.17" 403 - y1="22.4" 404 - x2="374.54" 405 - y2="1187.29" 406 - gradientUnits="userSpaceOnUse" 407 - gradientTransform="matrix(1 0 0 1.03 31.9 91.01)" 408 - > 409 - <stop offset="0" stopColor="#2affba" /> 410 - <stop offset="0.02" stopColor="#31f4bd" /> 411 - <stop offset="0.14" stopColor="#53bccc" /> 412 - <stop offset="0.25" stopColor="#718ada" /> 413 - <stop offset="0.37" stopColor="#8a5fe5" /> 414 - <stop offset="0.49" stopColor="#9f3def" /> 415 - <stop offset="0.61" stopColor="#af22f6" /> 416 - <stop offset="0.74" stopColor="#bb0ffb" /> 417 - <stop offset="0.87" stopColor="#c204fe" /> 418 - <stop offset="1" stopColor="#c400ff" /> 419 - </linearGradient> 420 - <linearGradient 421 - id="north_b" 422 - x1="554.29" 423 - y1="20.79" 424 - x2="364.65" 425 - y2="1185.68" 426 - xlinkHref="#north_a" 427 - /> 428 - <linearGradient 429 - id="north_c" 430 - x1="561.1" 431 - y1="21.9" 432 - x2="371.47" 433 - y2="1186.79" 434 - xlinkHref="#north_a" 435 - /> 436 - <linearGradient 437 - id="north_d" 438 - x1="530.57" 439 - y1="16.93" 440 - x2="340.93" 441 - y2="1181.82" 442 - xlinkHref="#north_a" 443 - /> 444 - </defs> 445 - <path 446 - 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" 447 - fill="url(#north_a)" 448 - /> 449 - <path 450 - 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" 451 - fill="url(#north_b)" 452 - /> 453 - <path 454 - 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" 455 - fill="url(#north_c)" 456 - /> 457 - <path 458 - 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" 459 - fill="url(#north_d)" 460 - /> 461 - </svg> 462 - ); 463 - } 464 - 465 - export function TophhieIcon({ size = 18 }) { 466 - return ( 467 - <svg 468 - width={size} 469 - height={size} 470 - viewBox="0 0 344 538" 471 - fill="none" 472 - xmlns="http://www.w3.org/2000/svg" 473 - > 474 - <ellipse cx="268.5" cy="455.5" rx="34.5" ry="35.5" fill="currentColor" /> 475 - <ellipse cx="76" cy="75.5" rx="35" ry="35.5" fill="currentColor" /> 476 - <circle cx="268.5" cy="75.5" r="75.5" fill="currentColor" /> 477 - <ellipse cx="76" cy="274.5" rx="76" ry="75.5" fill="currentColor" /> 478 - <ellipse cx="76" cy="462.5" rx="76" ry="75.5" fill="currentColor" /> 479 - <circle cx="268.5" cy="269.5" r="75.5" fill="currentColor" /> 480 - </svg> 481 - ); 482 - } 483 - 484 - export function GithubIcon({ size = 18 }) { 485 - return <FaGithub size={size} />; 486 - } 487 - 488 - export function LinkedinIcon({ size = 18 }) { 489 - return <FaLinkedin size={size} />; 490 - } 491 - 492 - export function TangledIcon({ size = 18 }) { 493 - return ( 494 - <div 495 - style={{ 496 - width: size, 497 - height: size, 498 - backgroundColor: "currentColor", 499 - WebkitMask: `url(${tangledLogo}) no-repeat center / contain`, 500 - mask: `url(${tangledLogo}) no-repeat center / contain`, 501 - display: "inline-block", 502 - }} 503 - /> 504 - ); 505 - }
-189
web/src/components/LeftSidebar.jsx
··· 1 - import { Link, useLocation } from "react-router-dom"; 2 - import { useAuth } from "../context/AuthContext"; 3 - import { useState, useEffect, useRef } from "react"; 4 - import { 5 - Home, 6 - Search, 7 - Highlighter, 8 - Bookmark, 9 - Folder, 10 - Bell, 11 - PenSquare, 12 - User, 13 - LogOut, 14 - Settings, 15 - ChevronUp, 16 - } from "lucide-react"; 17 - import { getUnreadNotificationCount } from "../api/client"; 18 - 19 - export default function LeftSidebar() { 20 - const { user, isAuthenticated, logout } = useAuth(); 21 - const location = useLocation(); 22 - const [unreadCount, setUnreadCount] = useState(0); 23 - const [userMenuOpen, setUserMenuOpen] = useState(false); 24 - const userMenuRef = useRef(null); 25 - 26 - const isActive = (path) => { 27 - if (path === "/") return location.pathname === "/"; 28 - return location.pathname.startsWith(path); 29 - }; 30 - 31 - useEffect(() => { 32 - if (isAuthenticated) { 33 - getUnreadNotificationCount() 34 - .then((data) => setUnreadCount(data.count || 0)) 35 - .catch(() => {}); 36 - const interval = setInterval(() => { 37 - getUnreadNotificationCount() 38 - .then((data) => setUnreadCount(data.count || 0)) 39 - .catch(() => {}); 40 - }, 60000); 41 - return () => clearInterval(interval); 42 - } 43 - }, [isAuthenticated]); 44 - 45 - useEffect(() => { 46 - const handleClickOutside = (e) => { 47 - if (userMenuRef.current && !userMenuRef.current.contains(e.target)) { 48 - setUserMenuOpen(false); 49 - } 50 - }; 51 - document.addEventListener("mousedown", handleClickOutside); 52 - return () => document.removeEventListener("mousedown", handleClickOutside); 53 - }, []); 54 - 55 - const handleLogout = () => { 56 - logout(); 57 - setUserMenuOpen(false); 58 - }; 59 - 60 - const navItems = [ 61 - { path: "/home", icon: Home, label: "Home" }, 62 - { path: "/url", icon: Search, label: "Browse" }, 63 - ]; 64 - 65 - const authNavItems = [ 66 - { path: "/highlights", icon: Highlighter, label: "Highlights" }, 67 - { path: "/bookmarks", icon: Bookmark, label: "Bookmarks" }, 68 - { path: "/collections", icon: Folder, label: "Collections" }, 69 - { 70 - path: "/notifications", 71 - icon: Bell, 72 - label: "Notifications", 73 - badge: unreadCount, 74 - }, 75 - ]; 76 - 77 - return ( 78 - <aside className="left-sidebar"> 79 - <div className="sidebar-header"> 80 - <Link to="/home" className="sidebar-logo"> 81 - <svg 82 - width="32" 83 - height="32" 84 - viewBox="0 0 265 231" 85 - fill="currentColor" 86 - xmlns="http://www.w3.org/2000/svg" 87 - > 88 - <path d="M0 230 V0 H199 V65.7156 H149.5 V115.216 H182.5 L199 131.716 V230 Z" /> 89 - <path d="M215 214.224 V230 H264.5 V0 H215.07 V16.2242 H248.5 V214.224 H215 Z" /> 90 - </svg> 91 - </Link> 92 - </div> 93 - 94 - <nav className="sidebar-nav"> 95 - {navItems.map(({ path, icon: Icon, label }) => ( 96 - <Link 97 - key={path} 98 - to={path} 99 - className={`sidebar-nav-item ${isActive(path) ? "active" : ""}`} 100 - > 101 - <Icon size={20} strokeWidth={1.75} /> 102 - <span>{label}</span> 103 - </Link> 104 - ))} 105 - 106 - {isAuthenticated && 107 - authNavItems.map(({ path, icon: Icon, label, badge }) => ( 108 - <Link 109 - key={path} 110 - to={path} 111 - className={`sidebar-nav-item ${isActive(path) ? "active" : ""}`} 112 - > 113 - <Icon size={20} strokeWidth={1.75} /> 114 - <span>{label}</span> 115 - {badge > 0 && <span className="sidebar-badge">{badge}</span>} 116 - </Link> 117 - ))} 118 - </nav> 119 - 120 - {isAuthenticated && ( 121 - <Link to="/new" className="sidebar-new-btn"> 122 - <PenSquare size={18} strokeWidth={2} /> 123 - <span>New Annotation</span> 124 - </Link> 125 - )} 126 - 127 - <div className="sidebar-footer" ref={userMenuRef}> 128 - {isAuthenticated ? ( 129 - <> 130 - <button 131 - className={`sidebar-user-btn ${userMenuOpen ? "active" : ""}`} 132 - onClick={() => setUserMenuOpen(!userMenuOpen)} 133 - > 134 - {user?.avatar ? ( 135 - <img src={user.avatar} alt="" className="sidebar-user-avatar" /> 136 - ) : ( 137 - <div className="sidebar-user-avatar-placeholder"> 138 - <User size={16} /> 139 - </div> 140 - )} 141 - <div className="sidebar-user-info"> 142 - <span className="sidebar-user-name"> 143 - {user?.displayName || user?.handle} 144 - </span> 145 - <span className="sidebar-user-handle">@{user?.handle}</span> 146 - </div> 147 - <ChevronUp 148 - size={16} 149 - className={`sidebar-user-chevron ${userMenuOpen ? "open" : ""}`} 150 - /> 151 - </button> 152 - 153 - {userMenuOpen && ( 154 - <div className="sidebar-user-menu"> 155 - <Link 156 - to={`/profile/${user?.did}`} 157 - className="sidebar-user-menu-item" 158 - onClick={() => setUserMenuOpen(false)} 159 - > 160 - <User size={16} /> 161 - <span>View Profile</span> 162 - </Link> 163 - <Link 164 - to="/settings" 165 - className="sidebar-user-menu-item" 166 - onClick={() => setUserMenuOpen(false)} 167 - > 168 - <Settings size={16} /> 169 - <span>Settings</span> 170 - </Link> 171 - <button 172 - className="sidebar-user-menu-item danger" 173 - onClick={handleLogout} 174 - > 175 - <LogOut size={16} /> 176 - <span>Log Out</span> 177 - </button> 178 - </div> 179 - )} 180 - </> 181 - ) : ( 182 - <Link to="/login" className="sidebar-signin-btn"> 183 - Sign In 184 - </Link> 185 - )} 186 - </div> 187 - </aside> 188 - ); 189 - }
-248
web/src/components/MobileNav.jsx
··· 1 - import { Link, useLocation } from "react-router-dom"; 2 - import { useAuth } from "../context/AuthContext"; 3 - import { useState, useEffect } from "react"; 4 - import { getUnreadNotificationCount } from "../api/client"; 5 - import { 6 - Home, 7 - Search, 8 - Folder, 9 - User, 10 - PenSquare, 11 - Bookmark, 12 - Settings, 13 - MoreHorizontal, 14 - LogOut, 15 - Bell, 16 - Highlighter, 17 - } from "lucide-react"; 18 - 19 - export default function MobileNav() { 20 - const { user, isAuthenticated, logout } = useAuth(); 21 - const location = useLocation(); 22 - const [isMenuOpen, setIsMenuOpen] = useState(false); 23 - const [unreadCount, setUnreadCount] = useState(0); 24 - 25 - const isActive = (path) => { 26 - if (path === "/") return location.pathname === "/"; 27 - return location.pathname.startsWith(path); 28 - }; 29 - 30 - useEffect(() => { 31 - if (isAuthenticated) { 32 - getUnreadNotificationCount() 33 - .then((data) => setUnreadCount(data.count || 0)) 34 - .catch(() => {}); 35 - } 36 - }, [isAuthenticated]); 37 - 38 - const closeMenu = () => setIsMenuOpen(false); 39 - 40 - return ( 41 - <> 42 - {isMenuOpen && ( 43 - <> 44 - <div className="mobile-nav-overlay" onClick={closeMenu} /> 45 - <div className="mobile-nav-menu"> 46 - {isAuthenticated ? ( 47 - <> 48 - <Link 49 - to={`/profile/${user.did}`} 50 - className="mobile-menu-profile-card" 51 - onClick={closeMenu} 52 - > 53 - {user.avatar ? ( 54 - <img 55 - src={user.avatar} 56 - alt="" 57 - className="mobile-nav-avatar" 58 - /> 59 - ) : ( 60 - <div 61 - className="mobile-nav-avatar" 62 - style={{ 63 - background: "var(--bg-secondary)", 64 - display: "flex", 65 - alignItems: "center", 66 - justifyContent: "center", 67 - }} 68 - > 69 - <User size={14} /> 70 - </div> 71 - )} 72 - <div style={{ display: "flex", flexDirection: "column" }}> 73 - <span 74 - style={{ 75 - fontWeight: 600, 76 - fontSize: "0.9rem", 77 - color: "var(--text-primary)", 78 - }} 79 - > 80 - {user.displayName || user.handle} 81 - </span> 82 - <span 83 - style={{ 84 - fontSize: "0.8rem", 85 - color: "var(--text-tertiary)", 86 - }} 87 - > 88 - @{user.handle} 89 - </span> 90 - </div> 91 - </Link> 92 - 93 - <Link 94 - to="/highlights" 95 - className="mobile-menu-item" 96 - onClick={closeMenu} 97 - > 98 - <Highlighter size={20} /> 99 - <span>Highlights</span> 100 - </Link> 101 - 102 - <Link 103 - to="/bookmarks" 104 - className="mobile-menu-item" 105 - onClick={closeMenu} 106 - > 107 - <Bookmark size={20} /> 108 - <span>Bookmarks</span> 109 - </Link> 110 - 111 - <Link 112 - to="/collections" 113 - className="mobile-menu-item" 114 - onClick={closeMenu} 115 - > 116 - <Folder size={20} /> 117 - <span>Collections</span> 118 - </Link> 119 - 120 - <Link 121 - to="/settings" 122 - className="mobile-menu-item" 123 - onClick={closeMenu} 124 - > 125 - <Settings size={20} /> 126 - <span>Settings</span> 127 - </Link> 128 - 129 - <div className="dropdown-divider" /> 130 - 131 - <button 132 - className="mobile-menu-item danger" 133 - onClick={() => { 134 - logout(); 135 - closeMenu(); 136 - }} 137 - > 138 - <LogOut size={20} /> 139 - <span>Log Out</span> 140 - </button> 141 - </> 142 - ) : ( 143 - <> 144 - <Link 145 - to="/login" 146 - className="mobile-menu-item" 147 - onClick={closeMenu} 148 - > 149 - <User size={20} /> 150 - <span>Sign In</span> 151 - </Link> 152 - <Link 153 - to="/collections" 154 - className="mobile-menu-item" 155 - onClick={closeMenu} 156 - > 157 - <Folder size={20} /> 158 - <span>Collections</span> 159 - </Link> 160 - <Link 161 - to="/settings" 162 - className="mobile-menu-item" 163 - onClick={closeMenu} 164 - > 165 - <Settings size={20} /> 166 - <span>Settings</span> 167 - </Link> 168 - </> 169 - )} 170 - </div> 171 - </> 172 - )} 173 - 174 - <nav className="mobile-bottom-nav"> 175 - <Link 176 - to="/home" 177 - className={`mobile-bottom-nav-item ${isActive("/home") ? "active" : ""}`} 178 - onClick={closeMenu} 179 - > 180 - <Home size={24} strokeWidth={1.5} /> 181 - </Link> 182 - 183 - <Link 184 - to="/url" 185 - className={`mobile-bottom-nav-item ${isActive("/url") ? "active" : ""}`} 186 - onClick={closeMenu} 187 - > 188 - <Search size={24} strokeWidth={1.5} /> 189 - </Link> 190 - 191 - {isAuthenticated ? ( 192 - <> 193 - <Link 194 - to="/new" 195 - className="mobile-bottom-nav-item mobile-bottom-nav-new" 196 - onClick={closeMenu} 197 - > 198 - <div className="mobile-nav-new-btn"> 199 - <PenSquare size={20} strokeWidth={2} /> 200 - </div> 201 - </Link> 202 - 203 - <Link 204 - to="/notifications" 205 - className={`mobile-bottom-nav-item ${isActive("/notifications") ? "active" : ""}`} 206 - onClick={closeMenu} 207 - > 208 - <div style={{ position: "relative", display: "flex" }}> 209 - <Bell size={24} strokeWidth={1.5} /> 210 - {unreadCount > 0 && ( 211 - <span 212 - style={{ 213 - position: "absolute", 214 - top: -2, 215 - right: -2, 216 - width: 8, 217 - height: 8, 218 - background: "var(--accent)", 219 - borderRadius: "50%", 220 - border: "2px solid var(--nav-bg)", 221 - }} 222 - /> 223 - )} 224 - </div> 225 - </Link> 226 - </> 227 - ) : ( 228 - <Link 229 - to="/login" 230 - className="mobile-bottom-nav-item mobile-bottom-nav-new" 231 - onClick={closeMenu} 232 - > 233 - <div className="mobile-nav-new-btn"> 234 - <User size={20} strokeWidth={2} /> 235 - </div> 236 - </Link> 237 - )} 238 - 239 - <button 240 - className={`mobile-bottom-nav-item ${isMenuOpen ? "active" : ""}`} 241 - onClick={() => setIsMenuOpen(!isMenuOpen)} 242 - > 243 - <MoreHorizontal size={24} strokeWidth={1.5} /> 244 - </button> 245 - </nav> 246 - </> 247 - ); 248 - }
-337
web/src/components/ReplyList.jsx
··· 1 - import { Link } from "react-router-dom"; 2 - import { MessageSquare, Trash2, Reply } from "lucide-react"; 3 - 4 - function formatDate(dateString) { 5 - if (!dateString) return ""; 6 - const date = new Date(dateString); 7 - const now = new Date(); 8 - const diff = now - date; 9 - const minutes = Math.floor(diff / 60000); 10 - const hours = Math.floor(diff / 3600000); 11 - const days = Math.floor(diff / 86400000); 12 - if (minutes < 1) return "just now"; 13 - if (minutes < 60) return `${minutes}m`; 14 - if (hours < 24) return `${hours}h`; 15 - if (days < 7) return `${days}d`; 16 - return date.toLocaleDateString(); 17 - } 18 - 19 - function ReplyItem({ reply, depth = 0, user, onReply, onDelete, isInline }) { 20 - const author = reply.creator || reply.author || {}; 21 - const isReplyOwner = user?.did && author.did === user.did; 22 - 23 - const containerStyle = isInline 24 - ? { 25 - display: "flex", 26 - gap: "10px", 27 - padding: depth > 0 ? "10px 12px 10px 16px" : "12px 16px", 28 - marginLeft: depth * 20, 29 - borderLeft: depth > 0 ? "2px solid var(--accent-subtle)" : "none", 30 - background: depth > 0 ? "rgba(168, 85, 247, 0.03)" : "transparent", 31 - } 32 - : { 33 - marginLeft: depth * 24, 34 - borderLeft: depth > 0 ? "2px solid var(--accent-subtle)" : "none", 35 - paddingLeft: depth > 0 ? "16px" : "0", 36 - background: depth > 0 ? "rgba(168, 85, 247, 0.02)" : "transparent", 37 - marginBottom: "12px", 38 - }; 39 - 40 - const avatarSize = isInline ? (depth > 0 ? 28 : 32) : depth > 0 ? 28 : 36; 41 - 42 - return ( 43 - <div key={reply.id || reply.uri}> 44 - <div 45 - className={isInline ? "inline-reply" : "reply-card-threaded"} 46 - style={containerStyle} 47 - > 48 - {isInline ? ( 49 - <> 50 - <Link 51 - to={`/profile/${author.handle}`} 52 - className="inline-reply-avatar" 53 - style={{ 54 - width: avatarSize, 55 - height: avatarSize, 56 - minWidth: avatarSize, 57 - }} 58 - > 59 - {author.avatar ? ( 60 - <img 61 - src={author.avatar} 62 - alt="" 63 - style={{ 64 - width: "100%", 65 - height: "100%", 66 - borderRadius: "50%", 67 - objectFit: "cover", 68 - }} 69 - /> 70 - ) : ( 71 - <span 72 - style={{ 73 - width: "100%", 74 - height: "100%", 75 - borderRadius: "50%", 76 - background: 77 - "linear-gradient(135deg, var(--accent), #a855f7)", 78 - display: "flex", 79 - alignItems: "center", 80 - justifyContent: "center", 81 - fontSize: depth > 0 ? "0.65rem" : "0.75rem", 82 - fontWeight: 600, 83 - color: "white", 84 - }} 85 - > 86 - {(author.displayName || 87 - author.handle || 88 - "?")[0].toUpperCase()} 89 - </span> 90 - )} 91 - </Link> 92 - <div style={{ flex: 1, minWidth: 0 }}> 93 - <div 94 - style={{ 95 - display: "flex", 96 - alignItems: "center", 97 - gap: "6px", 98 - flexWrap: "wrap", 99 - marginBottom: "4px", 100 - }} 101 - > 102 - <span 103 - style={{ 104 - fontWeight: 600, 105 - fontSize: depth > 0 ? "0.8rem" : "0.85rem", 106 - color: "var(--text-primary)", 107 - }} 108 - > 109 - {author.displayName || author.handle} 110 - </span> 111 - <Link 112 - to={`/profile/${author.handle}`} 113 - style={{ 114 - color: "var(--text-tertiary)", 115 - fontSize: depth > 0 ? "0.75rem" : "0.8rem", 116 - textDecoration: "none", 117 - }} 118 - > 119 - @{author.handle} 120 - </Link> 121 - <span 122 - style={{ color: "var(--text-tertiary)", fontSize: "0.7rem" }} 123 - > 124 - · 125 - </span> 126 - <span 127 - style={{ color: "var(--text-tertiary)", fontSize: "0.7rem" }} 128 - > 129 - {formatDate(reply.created || reply.createdAt)} 130 - </span> 131 - 132 - <div 133 - style={{ marginLeft: "auto", display: "flex", gap: "4px" }} 134 - > 135 - <button 136 - onClick={() => onReply(reply)} 137 - style={{ 138 - background: "none", 139 - border: "none", 140 - color: "var(--text-tertiary)", 141 - cursor: "pointer", 142 - padding: "2px 6px", 143 - fontSize: "0.7rem", 144 - display: "flex", 145 - alignItems: "center", 146 - gap: "3px", 147 - borderRadius: "4px", 148 - }} 149 - > 150 - <MessageSquare size={11} /> 151 - </button> 152 - {isReplyOwner && ( 153 - <button 154 - onClick={() => onDelete(reply)} 155 - style={{ 156 - background: "none", 157 - border: "none", 158 - color: "var(--text-tertiary)", 159 - cursor: "pointer", 160 - padding: "2px 6px", 161 - fontSize: "0.7rem", 162 - display: "flex", 163 - alignItems: "center", 164 - gap: "3px", 165 - borderRadius: "4px", 166 - }} 167 - > 168 - <Trash2 size={11} /> 169 - </button> 170 - )} 171 - </div> 172 - </div> 173 - <p 174 - style={{ 175 - margin: 0, 176 - fontSize: depth > 0 ? "0.85rem" : "0.9rem", 177 - lineHeight: 1.5, 178 - color: "var(--text-primary)", 179 - }} 180 - > 181 - {reply.text || reply.body?.value} 182 - </p> 183 - </div> 184 - </> 185 - ) : ( 186 - <> 187 - <div className="reply-header"> 188 - <Link 189 - to={`/profile/${author.handle}`} 190 - className="reply-avatar-link" 191 - > 192 - <div 193 - className="reply-avatar" 194 - style={{ width: avatarSize, height: avatarSize }} 195 - > 196 - {author.avatar ? ( 197 - <img 198 - src={author.avatar} 199 - alt={author.displayName || author.handle} 200 - /> 201 - ) : ( 202 - <span> 203 - {(author.displayName || 204 - author.handle || 205 - "?")[0].toUpperCase()} 206 - </span> 207 - )} 208 - </div> 209 - </Link> 210 - <div className="reply-meta"> 211 - <span className="reply-author"> 212 - {author.displayName || author.handle} 213 - </span> 214 - {author.handle && ( 215 - <Link 216 - to={`/profile/${author.handle}`} 217 - className="reply-handle" 218 - > 219 - @{author.handle} 220 - </Link> 221 - )} 222 - <span className="reply-dot">·</span> 223 - <span className="reply-time"> 224 - {formatDate(reply.created || reply.createdAt)} 225 - </span> 226 - </div> 227 - <div className="reply-actions"> 228 - <button 229 - className="reply-action-btn" 230 - onClick={() => onReply(reply)} 231 - title="Reply" 232 - > 233 - <Reply size={14} /> 234 - </button> 235 - {isReplyOwner && ( 236 - <button 237 - className="reply-action-btn reply-action-delete" 238 - onClick={() => onDelete(reply)} 239 - title="Delete" 240 - > 241 - <Trash2 size={14} /> 242 - </button> 243 - )} 244 - </div> 245 - </div> 246 - <p className="reply-text">{reply.text || reply.body?.value}</p> 247 - </> 248 - )} 249 - </div> 250 - {reply.children && 251 - reply.children.map((child) => ( 252 - <ReplyItem 253 - key={child.id || child.uri} 254 - reply={child} 255 - depth={depth + 1} 256 - user={user} 257 - onReply={onReply} 258 - onDelete={onDelete} 259 - isInline={isInline} 260 - /> 261 - ))} 262 - </div> 263 - ); 264 - } 265 - 266 - export default function ReplyList({ 267 - replies, 268 - rootUri, 269 - user, 270 - onReply, 271 - onDelete, 272 - isInline = false, 273 - }) { 274 - if (!replies || replies.length === 0) { 275 - if (isInline) { 276 - return ( 277 - <div 278 - style={{ 279 - padding: "16px", 280 - textAlign: "center", 281 - fontSize: "0.9rem", 282 - color: "var(--text-secondary)", 283 - }} 284 - > 285 - No replies yet 286 - </div> 287 - ); 288 - } 289 - return ( 290 - <div className="empty-state" style={{ padding: "32px" }}> 291 - <p className="empty-state-text"> 292 - No replies yet. Be the first to reply! 293 - </p> 294 - </div> 295 - ); 296 - } 297 - 298 - const buildReplyTree = () => { 299 - const replyMap = {}; 300 - const rootReplies = []; 301 - 302 - replies.forEach((r) => { 303 - replyMap[r.id || r.uri] = { ...r, children: [] }; 304 - }); 305 - 306 - replies.forEach((r) => { 307 - const parentUri = r.inReplyTo || r.parentUri; 308 - if (parentUri === rootUri) { 309 - rootReplies.push(replyMap[r.id || r.uri]); 310 - } else if (replyMap[parentUri]) { 311 - replyMap[parentUri].children.push(replyMap[r.id || r.uri]); 312 - } else { 313 - rootReplies.push(replyMap[r.id || r.uri]); 314 - } 315 - }); 316 - 317 - return rootReplies; 318 - }; 319 - 320 - const replyTree = buildReplyTree(); 321 - 322 - return ( 323 - <div className={isInline ? "replies-list" : "replies-list-threaded"}> 324 - {replyTree.map((reply) => ( 325 - <ReplyItem 326 - key={reply.id || reply.uri} 327 - reply={reply} 328 - depth={0} 329 - user={user} 330 - onReply={onReply} 331 - onDelete={onDelete} 332 - isInline={isInline} 333 - /> 334 - ))} 335 - </div> 336 - ); 337 - }
-60
web/src/components/RichText.jsx
··· 1 - import React from "react"; 2 - import { Link } from "react-router-dom"; 3 - 4 - const URL_REGEX = /(https?:\/\/[^\s]+)/g; 5 - 6 - export default function RichText({ text }) { 7 - if (!text) return null; 8 - 9 - const parts = text.split(URL_REGEX); 10 - 11 - return ( 12 - <p className="annotation-text"> 13 - {parts.map((part, i) => { 14 - if (part.match(URL_REGEX)) { 15 - return ( 16 - <a 17 - key={i} 18 - href={part} 19 - target="_blank" 20 - rel="noopener noreferrer" 21 - onClick={(e) => e.stopPropagation()} 22 - className="rich-text-link" 23 - > 24 - {part} 25 - </a> 26 - ); 27 - } 28 - 29 - const subParts = part.split(/((?:^|\s)@[a-zA-Z0-9.-]+\b)/g); 30 - 31 - return ( 32 - <React.Fragment key={i}> 33 - {subParts.map((subPart, j) => { 34 - const mentionMatch = subPart.match(/^(\s*)@([a-zA-Z0-9.-]+)$/); 35 - if (mentionMatch) { 36 - const prefix = mentionMatch[1]; 37 - const handle = mentionMatch[2]; 38 - if (handle.includes(".")) { 39 - return ( 40 - <React.Fragment key={j}> 41 - {prefix} 42 - <Link 43 - to={`/profile/${handle}`} 44 - className="rich-text-mention" 45 - onClick={(e) => e.stopPropagation()} 46 - > 47 - @{handle} 48 - </Link> 49 - </React.Fragment> 50 - ); 51 - } 52 - } 53 - return subPart; 54 - })} 55 - </React.Fragment> 56 - ); 57 - })} 58 - </p> 59 - ); 60 - }
-155
web/src/components/RightSidebar.jsx
··· 1 - import { Link } from "react-router-dom"; 2 - import { useState, useEffect } from "react"; 3 - import { useTheme } from "../context/ThemeContext"; 4 - import { Sun, Moon, Monitor, ExternalLink } from "lucide-react"; 5 - import { 6 - SiFirefox, 7 - SiGooglechrome, 8 - SiGithub, 9 - SiBluesky, 10 - SiDiscord, 11 - } from "react-icons/si"; 12 - import { FaEdge } from "react-icons/fa"; 13 - import tangledLogo from "../assets/tangled.svg"; 14 - import { getTrendingTags } from "../api/client"; 15 - 16 - const isFirefox = 17 - typeof navigator !== "undefined" && /Firefox/i.test(navigator.userAgent); 18 - const isEdge = 19 - typeof navigator !== "undefined" && /Edg/i.test(navigator.userAgent); 20 - 21 - function getExtensionInfo() { 22 - if (isFirefox) { 23 - return { 24 - url: "https://addons.mozilla.org/en-US/firefox/addon/margin/", 25 - icon: SiFirefox, 26 - label: "Firefox", 27 - }; 28 - } 29 - if (isEdge) { 30 - return { 31 - url: "https://microsoftedge.microsoft.com/addons/detail/margin/nfjnmllpdgcdnhmmggjihjbidmeadddn", 32 - icon: FaEdge, 33 - label: "Edge", 34 - }; 35 - } 36 - return { 37 - url: "https://chromewebstore.google.com/detail/margin/cgpmbiiagnehkikhcbnhiagfomajncpa/", 38 - icon: SiGooglechrome, 39 - label: "Chrome", 40 - }; 41 - } 42 - 43 - export default function RightSidebar() { 44 - const { theme, setTheme } = useTheme(); 45 - const [trendingTags, setTrendingTags] = useState([]); 46 - const ext = getExtensionInfo(); 47 - const ExtIcon = ext.icon; 48 - 49 - useEffect(() => { 50 - getTrendingTags(10) 51 - .then((data) => setTrendingTags(data.tags || [])) 52 - .catch(() => {}); 53 - }, []); 54 - 55 - const cycleTheme = () => { 56 - const next = 57 - theme === "system" ? "light" : theme === "light" ? "dark" : "system"; 58 - setTheme(next); 59 - }; 60 - 61 - return ( 62 - <aside className="right-sidebar"> 63 - {trendingTags.length > 0 && ( 64 - <div className="sidebar-section"> 65 - <h3 className="sidebar-section-title">Trending Tags</h3> 66 - <div className="sidebar-tags"> 67 - {trendingTags.map((tag) => ( 68 - <Link 69 - key={tag} 70 - to={`/home?tag=${tag}`} 71 - className="sidebar-tag-pill" 72 - > 73 - #{tag} 74 - </Link> 75 - ))} 76 - </div> 77 - </div> 78 - )} 79 - 80 - <div className="sidebar-section"> 81 - <h3 className="sidebar-section-title">Get the Extension</h3> 82 - <a 83 - href={ext.url} 84 - target="_blank" 85 - rel="noopener noreferrer" 86 - className="sidebar-extension-link" 87 - > 88 - <ExtIcon size={18} /> 89 - <span>Install for {ext.label}</span> 90 - <ExternalLink size={14} className="sidebar-external-icon" /> 91 - </a> 92 - </div> 93 - 94 - <div className="sidebar-section"> 95 - <h3 className="sidebar-section-title">Links</h3> 96 - <div className="sidebar-links"> 97 - <a 98 - href="https://github.com/margin-at/margin" 99 - target="_blank" 100 - rel="noopener noreferrer" 101 - className="sidebar-link-item" 102 - > 103 - <SiGithub size={16} /> 104 - <span>GitHub</span> 105 - </a> 106 - <a 107 - href="https://tangled.sh/@margin.at/margin" 108 - target="_blank" 109 - rel="noopener noreferrer" 110 - className="sidebar-link-item" 111 - > 112 - <span 113 - className="sidebar-tangled-icon" 114 - style={{ "--tangled-logo": `url(${tangledLogo})` }} 115 - /> 116 - <span>Tangled</span> 117 - </a> 118 - <a 119 - href="https://bsky.app/profile/margin.at" 120 - target="_blank" 121 - rel="noopener noreferrer" 122 - className="sidebar-link-item" 123 - > 124 - <SiBluesky size={16} /> 125 - <span>Bluesky</span> 126 - </a> 127 - <a 128 - href="https://discord.gg/ZQbkGqwzBH" 129 - target="_blank" 130 - rel="noopener noreferrer" 131 - className="sidebar-link-item" 132 - > 133 - <SiDiscord size={16} /> 134 - <span>Discord</span> 135 - </a> 136 - </div> 137 - </div> 138 - 139 - <div className="sidebar-section"> 140 - <button className="sidebar-theme-toggle" onClick={cycleTheme}> 141 - {theme === "system" && <Monitor size={16} />} 142 - {theme === "dark" && <Moon size={16} />} 143 - {theme === "light" && <Sun size={16} />} 144 - <span>Theme: {theme}</span> 145 - </button> 146 - </div> 147 - 148 - <div className="sidebar-footer-links"> 149 - <Link to="/privacy">Privacy</Link> 150 - <span>·</span> 151 - <Link to="/terms">Terms</Link> 152 - </div> 153 - </aside> 154 - ); 155 - }
-12
web/src/components/ScrollToTop.jsx
··· 1 - import { useEffect } from "react"; 2 - import { useLocation } from "react-router-dom"; 3 - 4 - export default function ScrollToTop() { 5 - const { pathname } = useLocation(); 6 - 7 - useEffect(() => { 8 - window.scrollTo(0, 0); 9 - }, [pathname]); 10 - 11 - return null; 12 - }
-401
web/src/components/ShareMenu.jsx
··· 1 - import { useState, useRef, useEffect } from "react"; 2 - import { Copy, ExternalLink, Check } from "lucide-react"; 3 - import { BlueskyIcon, AturiIcon } from "./Icons"; 4 - 5 - const BLUESKY_COLOR = "#1185fe"; 6 - 7 - const WitchskyIcon = () => ( 8 - <svg fill="none" viewBox="0 0 512 512" width="18" height="18"> 9 - <path 10 - fill="#ee5346" 11 - d="M374.473 57.7173C367.666 50.7995 357.119 49.1209 348.441 53.1659C347.173 53.7567 342.223 56.0864 334.796 59.8613C326.32 64.1696 314.568 70.3869 301.394 78.0596C275.444 93.1728 242.399 114.83 218.408 139.477C185.983 172.786 158.719 225.503 140.029 267.661C130.506 289.144 122.878 308.661 117.629 322.81C116.301 326.389 115.124 329.63 114.104 332.478C87.1783 336.42 64.534 341.641 47.5078 348.101C37.6493 351.84 28.3222 356.491 21.0573 362.538C13.8818 368.511 6.00003 378.262 6.00003 391.822C6.00014 403.222 11.8738 411.777 17.4566 417.235C23.0009 422.655 29.9593 426.793 36.871 430.062C50.8097 436.653 69.5275 441.988 90.8362 446.249C133.828 454.846 192.21 460 256.001 460C319.79 460 378.172 454.846 421.164 446.249C442.472 441.988 461.19 436.653 475.129 430.062C482.041 426.793 488.999 422.655 494.543 417.235C500.039 411.862 505.817 403.489 505.996 392.353L506 391.822L505.995 391.188C505.754 377.959 498.012 368.417 490.945 362.534C483.679 356.485 474.35 351.835 464.491 348.095C446.749 341.366 422.906 335.982 394.476 331.987C393.6 330.57 392.633 328.995 391.595 327.273C386.477 318.777 379.633 306.842 372.737 293.115C358.503 264.781 345.757 232.098 344.756 206.636C343.87 184.121 351.638 154.087 360.819 127.789C365.27 115.041 369.795 103.877 373.207 95.9072C374.909 91.9309 376.325 88.7712 377.302 86.6328C377.79 85.5645 378.167 84.7524 378.416 84.2224C378.54 83.9579 378.632 83.7635 378.69 83.643C378.718 83.5829 378.739 83.5411 378.75 83.5181C378.753 83.5108 378.756 83.5049 378.757 83.5015C382.909 74.8634 381.196 64.5488 374.473 57.7173Z" 12 - /> 13 - </svg> 14 - ); 15 - 16 - const BlackskyIcon = () => ( 17 - <svg viewBox="0 0 285 285" width="18" height="18"> 18 - <path 19 - fill="#f9faf9" 20 - d="M148.846 144.562C148.846 159.75 161.158 172.062 176.346 172.062H207.012V185.865H176.346C161.158 185.865 148.846 198.177 148.846 213.365V243.045H136.029V213.365C136.029 198.177 123.717 185.865 108.529 185.865H77.8633V172.062H108.529C123.717 172.062 136.029 159.75 136.029 144.562V113.896H148.846V144.562Z" 21 - /> 22 - <path 23 - fill="#f9faf9" 24 - d="M170.946 31.8766C160.207 42.616 160.207 60.0281 170.946 70.7675L192.631 92.4516L182.871 102.212L161.186 80.5275C150.447 69.7881 133.035 69.7881 122.296 80.5275L101.309 101.514L92.2456 92.4509L113.232 71.4642C123.972 60.7248 123.972 43.3128 113.232 32.5733L91.5488 10.8899L101.309 1.12988L122.993 22.814C133.732 33.5533 151.144 33.5534 161.884 22.814L183.568 1.12988L192.631 10.1925L170.946 31.8766Z" 25 - /> 26 - <path 27 - fill="#f9faf9" 28 - d="M79.0525 75.3259C75.1216 89.9962 83.8276 105.076 98.498 109.006L128.119 116.943L124.547 130.275L94.9267 122.338C80.2564 118.407 65.1772 127.113 61.2463 141.784L53.5643 170.453L41.1837 167.136L48.8654 138.467C52.7963 123.797 44.0902 108.718 29.4199 104.787L-0.201172 96.8497L3.37124 83.5173L32.9923 91.4542C47.6626 95.3851 62.7419 86.679 66.6728 72.0088L74.6098 42.3877L86.9895 45.7048L79.0525 75.3259Z" 29 - /> 30 - <path 31 - fill="#f9faf9" 32 - d="M218.413 71.4229C222.344 86.093 237.423 94.7992 252.094 90.8683L281.715 82.9313L285.287 96.2628L255.666 104.2C240.995 108.131 232.29 123.21 236.22 137.88L243.902 166.55L231.522 169.867L223.841 141.198C219.91 126.528 204.831 117.822 190.16 121.753L160.539 129.69L156.967 116.357L186.588 108.42C201.258 104.49 209.964 89.4103 206.033 74.74L198.096 45.1189L210.476 41.8018L218.413 71.4229Z" 33 - /> 34 - </svg> 35 - ); 36 - 37 - const CatskyIcon = () => ( 38 - <svg fill="none" viewBox="0 0 67.733328 67.733329" width="18" height="18"> 39 - <path 40 - fill="#cba7f7" 41 - d="m 7.4595521,49.230487 -1.826355,1.186314 -0.00581,0.0064 c -0.6050542,0.41651 -1.129182,0.831427 -1.5159445,1.197382 -0.193382,0.182977 -0.3509469,0.347606 -0.4862911,0.535791 -0.067671,0.0941 -0.1322972,0.188188 -0.1933507,0.352343 -0.061048,0.164157 -0.1411268,0.500074 0.025624,0.844456 l 0.099589,0.200339 c 0.1666616,0.344173 0.4472046,0.428734 0.5969419,0.447854 0.1497358,0.01912 0.2507411,0.0024 0.352923,-0.02039 0.204367,-0.04555 0.4017284,-0.126033 0.6313049,-0.234117 0.4549828,-0.214229 1.0166476,-0.545006 1.6155328,-0.956275 l 0.014617,-0.01049 2.0855152,-1.357536 C 8.3399261,50.711052 7.8735929,49.979321 7.4596148,49.230532 Z" 42 - /> 43 - <path 44 - fill="#cba7f7" 45 - d="m 60.225246,49.199041 c -0.421632,0.744138 -0.895843,1.47112 -1.418104,2.178115 l 2.170542,1.413443 c 0.598885,0.411268 1.160549,0.742047 1.615532,0.956276 0.229578,0.108104 0.426937,0.188564 0.631304,0.234116 0.102186,0.02278 0.2061,0.03951 0.355838,0.02039 0.148897,-0.01901 0.427619,-0.104957 0.594612,-0.444358 l 0.0029,-0.0035 0.09667,-0.20034 h 0.0029 c 0.166756,-0.34438 0.08667,-0.680303 0.02562,-0.844455 -0.06104,-0.164158 -0.125675,-0.258251 -0.193352,-0.352343 -0.135356,-0.188186 -0.293491,-0.352814 -0.486873,-0.535792 -0.386891,-0.366 -0.911016,-0.780916 -1.516073,-1.197426 l -0.0082,-0.007 z" 46 - /> 47 - <path 48 - fill="#cba7f7" 49 - d="m 62.374822,42.996075 c -0.123437,0.919418 -0.330922,1.827482 -0.614997,2.71973 h 2.864745 c 0.698786,0 1.328766,-0.04848 1.817036,-0.1351 0.244137,-0.04331 0.449793,-0.09051 0.645864,-0.172979 0.09803,-0.04122 0.194035,-0.08458 0.315651,-0.190439 0.121618,-0.105868 0.330211,-0.348705 0.330211,-0.746032 v -0.233536 c 0,-0.397326 -0.208544,-0.637282 -0.330211,-0.743122 -0.121662,-0.105838 -0.217613,-0.152159 -0.315651,-0.193351 -0.196079,-0.08238 -0.401748,-0.129732 -0.645864,-0.17296 -0.488229,-0.08645 -1.118333,-0.132208 -1.817036,-0.132208 z" 50 - /> 51 - <path 52 - fill="#cba7f7" 53 - d="m 3.1074004,42.996075 c -0.6987018,0 -1.3264778,0.04576 -1.8147079,0.132208 -0.2441143,0.04324 -0.44978339,0.09059 -0.64586203,0.17296 -0.0980369,0.04118 -0.19398758,0.08751 -0.31565316,0.193351 C 0.20951466,43.600432 0.0015501,43.84039 0.0015501,44.237717 v 0.233535 c 0,0.397326 0.20800926,0.640175 0.32962721,0.746034 0.12161784,0.105867 0.21761904,0.149206 0.31565316,0.190437 0.19606972,0.08246 0.40172683,0.129657 0.64586203,0.172979 0.4882704,0.08663 1.1159226,0.1351 1.8147079,0.1351 H 5.9517617 C 5.6756425,44.822849 5.4740706,43.914705 5.3542351,42.996072 Z" 54 - /> 55 - <path 56 - fill="#cba7f7" 57 - d="m 64.667084,33.5073 c -0.430203,0 -0.690808,0.160181 -1.103618,0.372726 -0.41281,0.212535 -0.895004,0.507161 -1.40529,0.858434 l -0.84038,0.578305 c 0.360074,0.820951 0.644317,1.675211 0.844456,2.560741 l 1.136813,-0.78214 c 0.605058,-0.41651 1.12918,-0.834919 1.515944,-1.200875 0.193382,-0.182976 0.350947,-0.347609 0.486291,-0.535795 0.06767,-0.0941 0.132313,-0.188185 0.193351,-0.352341 0.06104,-0.164157 0.141126,-0.497171 -0.02562,-0.841544 L 65.369444,33.96156 C 65.163418,33.537073 64.829889,33.5073 64.669999,33.5073 Z" 58 - /> 59 - <path 60 - fill="#cba7f7" 61 - d="m 3.0648864,33.5073 c -0.1600423,3.64e-4 -0.4969719,0.0355 -0.7000249,0.45426 l -0.099589,0.203251 c -0.16676,0.344375 -0.089013,0.677388 -0.027951,0.841544 0.061047,0.164157 0.1285982,0.258248 0.1962636,0.352341 0.1353547,0.188186 0.2899962,0.352819 0.4833782,0.535795 0.386764,0.365956 0.9138003,0.784365 1.518856,1.200875 l 1.1478766,0.78971 c 0.2068,-0.879769 0.5000939,-1.727856 0.8706646,-2.542104 v -5.81e-4 L 5.5761273,34.73846 C 5.065553,34.38699 4.5814871,34.09259 4.1685053,33.880026 3.7555236,33.667462 3.4962107,33.506322 3.0648893,33.5073 Z" 62 - /> 63 - <path 64 - fill="#cba7f7" 65 - d="m 34.206496,25.930929 c -7.358038,0 -14.087814,1.669555 -18.851571,4.452678 -4.763758,2.783122 -7.4049994,6.472247 -7.4049994,10.665932 0,4.229683 2.6374854,8.946766 7.2694834,12.60017 4.631996,3.653402 11.153152,6.176813 18.420538,6.176813 7.267388,0 13.908863,-2.52485 18.657979,-6.185354 4.749117,-3.660501 7.485285,-8.390746 7.485285,-12.591629 0,-4.236884 -2.494219,-7.904081 -7.079874,-10.67732 -4.585655,-2.773237 -11.1388,-4.44129 -18.496841,-4.44129 z" 66 - /> 67 - <path 68 - fill="#cba7f7" 69 - d="m 51.797573,6.1189692 c -0.02945,-7.175e-4 -0.05836,4.17e-5 -0.08736,5.831e-4 -0.143066,0.00254 -0.278681,0.00746 -0.419898,0.094338 -0.483586,0.2975835 -0.980437,0.9277726 -1.446058,1.5345809 -1.170891,1.5259255 -2.372514,3.8701448 -4.229269,7.0095668 -0.839492,1.419423 -2.308256,4.55051 -3.891486,8.089307 4.831393,0.745951 9.148869,2.222975 12.643546,4.336427 2.130458,1.288425 3.976812,2.848736 5.416167,4.643344 C 58.614334,27.483611 57.260351,22.206768 56.421696,19.015263 55.149066,14.172268 54.241403,10.340754 53.185389,8.0524745 52.815225,7.2503647 52.540611,6.4969378 52.052073,6.1836069 51.974407,6.1337905 51.885945,6.1211124 51.79757,6.1189646 Z" 70 - /> 71 - <path 72 - fill="#cba7f7" 73 - d="m 15.935563,6.1189692 c -0.08837,0.00223 -0.176832,0.014766 -0.254502,0.064642 -0.48854,0.3133308 -0.763154,1.0667562 -1.13332,1.8688677 -1.056011,2.2882791 -1.963673,6.1197931 -3.236303,10.9627891 -0.85539,3.255187 -2.247014,8.680054 -3.4314032,13.071013 1.5346704,-1.910372 3.5390122,-3.56005 5.8517882,-4.91124 3.456591,-2.019439 7.668347,-3.458497 12.320324,-4.231015 C 24.452511,19.365796 22.96466,16.190327 22.117564,14.758042 20.260808,11.61862 19.059771,9.2744012 17.888878,7.7484762 17.423256,7.1416679 16.926404,6.5114787 16.442819,6.2138951 16.301603,6.127059 16.165987,6.1222115 16.02292,6.1195569 c -0.02901,-5.429e-4 -0.0579,-0.0013 -0.08734,-5.847e-4 z" 74 - /> 75 - </svg> 76 - ); 77 - 78 - const DeerIcon = () => ( 79 - <svg fill="none" viewBox="0 0 512 512" width="18" height="18"> 80 - <path 81 - fill="#739f7c" 82 - d="m 149.96484,186.56641 46.09766,152.95898 c 0,0 -6.30222,-9.61174 -15.60547,-17.47656 -8.87322,-7.50128 -28.4082,-4.04492 -28.4082,-4.04492 0,0 6.14721,39.88867 15.53125,44.39843 10.71251,5.1482 22.19726,0.16993 22.19726,0.16993 0,0 11.7613,-4.87282 22.82032,31.82421 5.26534,17.47196 15.33258,50.877 20.9707,69.58594 2.16717,7.1913 8.83789,7.25781 8.83789,7.25781 0,0 6.67072,-0.0665 8.83789,-7.25781 5.63812,-18.70894 15.70536,-52.11398 20.9707,-69.58594 11.05902,-36.69703 22.82032,-31.82421 22.82032,-31.82421 0,0 11.48475,4.97827 22.19726,-0.16993 9.38404,-4.50976 15.5332,-44.39843 15.5332,-44.39843 0,0 -19.53693,-3.45636 -28.41015,4.04492 -9.30325,7.86482 -15.60547,17.47656 -15.60547,17.47656 l 46.09766,-152.95898 -49.32618,83.84179 -20.34375,-31.1914 6.35547,54.96875 -23.1582,39.36132 c 0,0 -2.97595,5.06226 -5.94336,4.68946 -0.009,-0.001 -0.0169,0.003 -0.0254,0.01 -0.008,-0.007 -0.0167,-0.0109 -0.0254,-0.01 -2.96741,0.3728 -5.94336,-4.68946 -5.94336,-4.68946 l -23.1582,-39.36132 6.35547,-54.96875 -20.34375,31.1914 z" 83 - transform="matrix(2.6921023,0,0,1.7145911,-396.58283,-308.01527)" 84 - /> 85 - </svg> 86 - ); 87 - 88 - const BLUESKY_FORKS = [ 89 - { 90 - name: "Bluesky", 91 - domain: "bsky.app", 92 - Icon: () => <BlueskyIcon size={18} color={BLUESKY_COLOR} />, 93 - }, 94 - { name: "Witchsky", domain: "witchsky.app", Icon: WitchskyIcon }, 95 - { name: "Blacksky", domain: "blacksky.community", Icon: BlackskyIcon }, 96 - { name: "Catsky", domain: "catsky.social", Icon: CatskyIcon }, 97 - { name: "Deer", domain: "deer.social", Icon: DeerIcon }, 98 - ]; 99 - 100 - export default function ShareMenu({ uri, text, customUrl, handle, type, url }) { 101 - const [isOpen, setIsOpen] = useState(false); 102 - const [copied, setCopied] = useState(false); 103 - const [copiedAturi, setCopiedAturi] = useState(false); 104 - const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0 }); 105 - const menuRef = useRef(null); 106 - const buttonRef = useRef(null); 107 - 108 - const calculatePosition = () => { 109 - if (!buttonRef.current) return; 110 - const rect = buttonRef.current.getBoundingClientRect(); 111 - const menuWidth = 220; 112 - const menuHeight = 300; 113 - const padding = 16; 114 - 115 - let left = rect.left + rect.width / 2 - menuWidth / 2; 116 - let top = rect.bottom + 8; 117 - 118 - if (left < padding) { 119 - left = padding; 120 - } else if (left + menuWidth > window.innerWidth - padding) { 121 - left = window.innerWidth - menuWidth - padding; 122 - } 123 - 124 - if (top + menuHeight > window.innerHeight - padding) { 125 - top = rect.top - menuHeight - 8; 126 - if (top < padding) top = padding; 127 - } 128 - 129 - setMenuPosition({ top, left }); 130 - }; 131 - 132 - const handleToggle = () => { 133 - if (!isOpen) { 134 - calculatePosition(); 135 - } 136 - setIsOpen(!isOpen); 137 - }; 138 - 139 - const getShareUrl = () => { 140 - if (customUrl) return customUrl; 141 - if (!uri) return ""; 142 - 143 - const uriParts = uri.split("/"); 144 - const rkey = uriParts[uriParts.length - 1]; 145 - const did = uriParts[2]; 146 - 147 - if (uri.includes("network.cosmik.card")) { 148 - return `${window.location.origin}/at/${did}/${rkey}`; 149 - } 150 - 151 - if (handle && type) { 152 - return `${window.location.origin}/${handle}/${type.toLowerCase()}/${rkey}`; 153 - } 154 - 155 - return `${window.location.origin}/at/${did}/${rkey}`; 156 - }; 157 - 158 - const shareUrl = getShareUrl(); 159 - 160 - useEffect(() => { 161 - const handleClickOutside = (e) => { 162 - if ( 163 - menuRef.current && 164 - !menuRef.current.contains(e.target) && 165 - buttonRef.current && 166 - !buttonRef.current.contains(e.target) 167 - ) { 168 - setIsOpen(false); 169 - } 170 - }; 171 - 172 - const card = buttonRef.current?.closest( 173 - ".card, .annotation-card, .bookmark-card", 174 - ); 175 - if (card) { 176 - if (isOpen) { 177 - card.classList.add("has-open-menu"); 178 - } else { 179 - card.classList.remove("has-open-menu"); 180 - } 181 - } 182 - 183 - if (isOpen) { 184 - document.addEventListener("mousedown", handleClickOutside); 185 - window.addEventListener("scroll", calculatePosition, true); 186 - window.addEventListener("resize", calculatePosition); 187 - } 188 - return () => { 189 - document.removeEventListener("mousedown", handleClickOutside); 190 - window.removeEventListener("scroll", calculatePosition, true); 191 - window.removeEventListener("resize", calculatePosition); 192 - if (card) { 193 - card.classList.remove("has-open-menu"); 194 - } 195 - }; 196 - }, [isOpen]); 197 - 198 - const handleShareToFork = (domain) => { 199 - const composeText = text 200 - ? `${text.substring(0, 200)}...\n\n${shareUrl}` 201 - : shareUrl; 202 - const composeUrl = `https://${domain}/intent/compose?text=${encodeURIComponent(composeText)}`; 203 - window.open(composeUrl, "_blank"); 204 - setIsOpen(false); 205 - }; 206 - 207 - const handleCopy = async () => { 208 - try { 209 - await navigator.clipboard.writeText(shareUrl); 210 - setCopied(true); 211 - setTimeout(() => { 212 - setCopied(false); 213 - setIsOpen(false); 214 - }, 1500); 215 - } catch { 216 - prompt("Copy this link:", shareUrl); 217 - } 218 - }; 219 - 220 - const handleCopyAturi = async () => { 221 - const aturiUrl = uri ? uri.replace("at://", "https://aturi.to/") : ""; 222 - if (!aturiUrl) return; 223 - 224 - try { 225 - await navigator.clipboard.writeText(aturiUrl); 226 - setCopiedAturi(true); 227 - setTimeout(() => { 228 - setCopiedAturi(false); 229 - setIsOpen(false); 230 - }, 1500); 231 - } catch { 232 - prompt("Copy this link:", aturiUrl); 233 - } 234 - }; 235 - 236 - const handleSystemShare = async () => { 237 - if (navigator.share) { 238 - try { 239 - await navigator.share({ 240 - title: "Margin Annotation", 241 - text: text?.substring(0, 100), 242 - url: shareUrl, 243 - }); 244 - } catch { 245 - /* ignore */ 246 - } 247 - } 248 - setIsOpen(false); 249 - }; 250 - 251 - const isSemble = uri && uri.includes("network.cosmik"); 252 - const sembleUrl = (() => { 253 - if (!isSemble) return ""; 254 - const parts = uri.split("/"); 255 - const rkey = parts[parts.length - 1]; 256 - const userHandle = handle || (parts.length > 2 ? parts[2] : ""); 257 - 258 - if (uri.includes("network.cosmik.collection")) { 259 - return `https://semble.so/profile/${userHandle}/collections/${rkey}`; 260 - } 261 - 262 - if (uri.includes("network.cosmik.card") && url) { 263 - return `https://semble.so/url?id=${encodeURIComponent(url)}`; 264 - } 265 - 266 - return `https://semble.so/profile/${userHandle}`; 267 - })(); 268 - 269 - const handleCopySemble = async () => { 270 - try { 271 - await navigator.clipboard.writeText(sembleUrl); 272 - setCopied(true); 273 - setTimeout(() => { 274 - setCopied(false); 275 - setIsOpen(false); 276 - }, 1500); 277 - } catch { 278 - prompt("Copy this link:", sembleUrl); 279 - } 280 - }; 281 - 282 - return ( 283 - <div className="share-menu-container"> 284 - <button 285 - ref={buttonRef} 286 - className="annotation-action" 287 - onClick={handleToggle} 288 - title="Share" 289 - > 290 - <svg 291 - width="18" 292 - height="18" 293 - viewBox="0 0 24 24" 294 - fill="none" 295 - stroke="currentColor" 296 - strokeWidth="2" 297 - strokeLinecap="round" 298 - strokeLinejoin="round" 299 - > 300 - <circle cx="18" cy="5" r="3" /> 301 - <circle cx="6" cy="12" r="3" /> 302 - <circle cx="18" cy="19" r="3" /> 303 - <line x1="8.59" y1="13.51" x2="15.42" y2="17.49" /> 304 - <line x1="15.41" y1="6.51" x2="8.59" y2="10.49" /> 305 - </svg> 306 - </button> 307 - 308 - {isOpen && ( 309 - <div 310 - className="share-menu" 311 - ref={menuRef} 312 - style={{ 313 - position: "fixed", 314 - top: menuPosition.top, 315 - left: menuPosition.left, 316 - right: "auto", 317 - transform: "none", 318 - }} 319 - > 320 - {isSemble ? ( 321 - <> 322 - <div className="share-menu-section"> 323 - <div 324 - className="share-menu-label" 325 - style={{ display: "flex", alignItems: "center", gap: "6px" }} 326 - > 327 - <img 328 - src="/semble-logo.svg" 329 - alt="" 330 - style={{ width: "12px", height: "12px" }} 331 - /> 332 - Semble 333 - </div> 334 - <a 335 - href={sembleUrl} 336 - target="_blank" 337 - rel="noopener noreferrer" 338 - className="share-menu-item" 339 - style={{ textDecoration: "none" }} 340 - > 341 - <ExternalLink size={16} /> 342 - <span>Open on Semble</span> 343 - </a> 344 - <button className="share-menu-item" onClick={handleCopySemble}> 345 - {copied ? <Check size={16} /> : <Copy size={16} />} 346 - <span>{copied ? "Copied!" : "Copy Semble Link"}</span> 347 - </button> 348 - </div> 349 - <div className="share-menu-divider" /> 350 - <button 351 - className="share-menu-item" 352 - onClick={handleCopyAturi} 353 - title="Copy Universal URL" 354 - > 355 - {copiedAturi ? <Check size={16} /> : <AturiIcon size={16} />} 356 - <span>{copiedAturi ? "Copied!" : "Copy Universal URL"}</span> 357 - </button> 358 - </> 359 - ) : ( 360 - <> 361 - <div className="share-menu-section"> 362 - <div className="share-menu-label">Share to</div> 363 - {BLUESKY_FORKS.map((fork) => ( 364 - <button 365 - key={fork.domain} 366 - className="share-menu-item" 367 - onClick={() => handleShareToFork(fork.domain)} 368 - > 369 - <span className="share-menu-icon"> 370 - <fork.Icon /> 371 - </span> 372 - <span>{fork.name}</span> 373 - </button> 374 - ))} 375 - </div> 376 - <div className="share-menu-divider" /> 377 - <button className="share-menu-item" onClick={handleCopy}> 378 - {copied ? <Check size={16} /> : <Copy size={16} />} 379 - <span>{copied ? "Copied!" : "Copy Link"}</span> 380 - </button> 381 - <button 382 - className="share-menu-item" 383 - onClick={handleCopyAturi} 384 - title="Copy a universal link atproto link (via aturi.to)" 385 - > 386 - {copiedAturi ? <Check size={16} /> : <AturiIcon size={16} />} 387 - <span>{copiedAturi ? "Copied!" : "Copy Universal Link"}</span> 388 - </button> 389 - {navigator.share && ( 390 - <button className="share-menu-item" onClick={handleSystemShare}> 391 - <ExternalLink size={16} /> 392 - <span>More...</span> 393 - </button> 394 - )} 395 - </> 396 - )} 397 - </div> 398 - )} 399 - </div> 400 - ); 401 - }
-267
web/src/components/SignUpModal.jsx
··· 1 - import { 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 "./Icons"; 10 - import { startSignup } from "../api/client"; 11 - 12 - const RECOMMENDED_PROVIDER = { 13 - id: "margin", 14 - name: "Margin", 15 - service: "https://margin.cafe", 16 - Icon: MarginIcon, 17 - description: "Hosted by Margin, the easiest way to get started", 18 - }; 19 - 20 - const OTHER_PROVIDERS = [ 21 - { 22 - id: "bluesky", 23 - name: "Bluesky", 24 - service: "https://bsky.social", 25 - Icon: BlueskyIcon, 26 - description: "The most popular option on the AT Protocol", 27 - }, 28 - { 29 - id: "blacksky", 30 - name: "Blacksky", 31 - service: "https://blacksky.app", 32 - Icon: BlackskyIcon, 33 - description: "For the Culture. A safe space for Black users and allies", 34 - }, 35 - { 36 - id: "selfhosted.social", 37 - name: "selfhosted.social", 38 - service: "https://selfhosted.social", 39 - Icon: null, 40 - description: 41 - "For hackers, designers, developers, ATProto enthusiasts, scrobblers, tinkerers, friends, and curious minds.", 42 - }, 43 - { 44 - id: "northsky", 45 - name: "Northsky", 46 - service: "https://northsky.social", 47 - Icon: NorthskyIcon, 48 - description: "A Canadian-based worker-owned cooperative", 49 - }, 50 - { 51 - id: "tophhie", 52 - name: "Tophhie", 53 - service: "https://tophhie.social", 54 - Icon: TophhieIcon, 55 - description: "A welcoming and friendly community", 56 - }, 57 - { 58 - id: "altq", 59 - name: "AltQ", 60 - service: "https://altq.net", 61 - Icon: null, 62 - description: "An independent, self-hosted PDS instance", 63 - }, 64 - { 65 - id: "custom", 66 - name: "Custom", 67 - service: "", 68 - custom: true, 69 - Icon: null, 70 - description: "Connect to your own or another custom PDS", 71 - }, 72 - ]; 73 - 74 - export default function SignUpModal({ onClose }) { 75 - const [showOtherProviders, setShowOtherProviders] = useState(false); 76 - const [showCustomInput, setShowCustomInput] = useState(false); 77 - const [customService, setCustomService] = useState(""); 78 - const [loading, setLoading] = useState(false); 79 - const [error, setError] = useState(null); 80 - 81 - useEffect(() => { 82 - document.body.style.overflow = "hidden"; 83 - return () => { 84 - document.body.style.overflow = "unset"; 85 - }; 86 - }, []); 87 - 88 - const handleProviderSelect = async (provider) => { 89 - if (provider.custom) { 90 - setShowCustomInput(true); 91 - return; 92 - } 93 - 94 - setLoading(true); 95 - setError(null); 96 - 97 - try { 98 - const result = await startSignup(provider.service); 99 - if (result.authorizationUrl) { 100 - window.location.href = result.authorizationUrl; 101 - } 102 - } catch (err) { 103 - console.error(err); 104 - setError("Could not connect to this provider. Please try again."); 105 - setLoading(false); 106 - } 107 - }; 108 - 109 - const handleCustomSubmit = async (e) => { 110 - e.preventDefault(); 111 - if (!customService.trim()) return; 112 - 113 - setLoading(true); 114 - setError(null); 115 - 116 - let serviceUrl = customService.trim(); 117 - if (!serviceUrl.startsWith("http")) { 118 - serviceUrl = `https://${serviceUrl}`; 119 - } 120 - 121 - try { 122 - const result = await startSignup(serviceUrl); 123 - if (result.authorizationUrl) { 124 - window.location.href = result.authorizationUrl; 125 - } 126 - } catch (err) { 127 - console.error(err); 128 - setError("Could not connect to this PDS. Please check the URL."); 129 - setLoading(false); 130 - } 131 - }; 132 - 133 - return ( 134 - <div className="modal-overlay"> 135 - <div className="modal-content signup-modal"> 136 - <button className="modal-close" onClick={onClose}> 137 - <X size={20} /> 138 - </button> 139 - 140 - {loading ? ( 141 - <div className="signup-step" style={{ textAlign: "center" }}> 142 - <Loader2 size={32} className="spinner" /> 143 - <p style={{ marginTop: "1rem", color: "var(--text-secondary)" }}> 144 - Connecting to provider... 145 - </p> 146 - </div> 147 - ) : showCustomInput ? ( 148 - <div className="signup-step"> 149 - <h2>Custom Provider</h2> 150 - <form onSubmit={handleCustomSubmit}> 151 - <div className="form-group"> 152 - <label className="form-label"> 153 - PDS address (e.g. pds.example.com) 154 - </label> 155 - <input 156 - type="text" 157 - className="form-input" 158 - value={customService} 159 - onChange={(e) => setCustomService(e.target.value)} 160 - placeholder="pds.example.com" 161 - autoFocus 162 - /> 163 - </div> 164 - 165 - {error && ( 166 - <div className="error-message"> 167 - <AlertCircle size={16} /> 168 - {error} 169 - </div> 170 - )} 171 - 172 - <div className="modal-actions"> 173 - <button 174 - type="button" 175 - className="btn btn-secondary" 176 - onClick={() => { 177 - setShowCustomInput(false); 178 - setError(null); 179 - }} 180 - > 181 - Back 182 - </button> 183 - <button 184 - type="submit" 185 - className="btn btn-primary" 186 - disabled={!customService.trim()} 187 - > 188 - Continue 189 - </button> 190 - </div> 191 - </form> 192 - </div> 193 - ) : ( 194 - <div className="signup-step"> 195 - <h2>Create your account</h2> 196 - <p className="signup-subtitle"> 197 - Margin uses the AT Protocol, the same decentralized network that 198 - powers Bluesky. Your account will be hosted on a server of your 199 - choice. 200 - </p> 201 - 202 - {error && ( 203 - <div className="error-message" style={{ marginBottom: "1rem" }}> 204 - <AlertCircle size={16} /> 205 - {error} 206 - </div> 207 - )} 208 - 209 - <div className="signup-recommended"> 210 - <div className="signup-recommended-badge">Recommended</div> 211 - <button 212 - className="provider-card provider-card-featured" 213 - onClick={() => handleProviderSelect(RECOMMENDED_PROVIDER)} 214 - > 215 - <div className="provider-icon"> 216 - <RECOMMENDED_PROVIDER.Icon size={24} /> 217 - </div> 218 - <div className="provider-info"> 219 - <h3>{RECOMMENDED_PROVIDER.name}</h3> 220 - <span>{RECOMMENDED_PROVIDER.description}</span> 221 - </div> 222 - <ChevronRight size={16} className="provider-arrow" /> 223 - </button> 224 - </div> 225 - 226 - <button 227 - type="button" 228 - className="signup-toggle-others" 229 - onClick={() => setShowOtherProviders(!showOtherProviders)} 230 - > 231 - {showOtherProviders ? "Hide other options" : "More options"} 232 - <ChevronRight 233 - size={14} 234 - className={`toggle-chevron ${showOtherProviders ? "open" : ""}`} 235 - /> 236 - </button> 237 - 238 - {showOtherProviders && ( 239 - <div className="provider-grid"> 240 - {OTHER_PROVIDERS.map((p) => ( 241 - <button 242 - key={p.id} 243 - className="provider-card" 244 - onClick={() => handleProviderSelect(p)} 245 - > 246 - <div className={`provider-icon ${p.wide ? "wide" : ""}`}> 247 - {p.Icon ? ( 248 - <p.Icon size={32} /> 249 - ) : ( 250 - <span className="provider-initial">{p.name[0]}</span> 251 - )} 252 - </div> 253 - <div className="provider-info"> 254 - <h3>{p.name}</h3> 255 - <span>{p.description}</span> 256 - </div> 257 - <ChevronRight size={16} className="provider-arrow" /> 258 - </button> 259 - ))} 260 - </div> 261 - )} 262 - </div> 263 - )} 264 - </div> 265 - </div> 266 - ); 267 - }
-425
web/src/components/TopNav.jsx
··· 1 - import { useState, useRef, useEffect } from "react"; 2 - import { Link, useLocation } from "react-router-dom"; 3 - import { useAuth } from "../context/AuthContext"; 4 - import { useTheme } from "../context/ThemeContext"; 5 - import { 6 - Home, 7 - Search, 8 - Folder, 9 - Bell, 10 - PenSquare, 11 - User, 12 - Settings, 13 - LogOut, 14 - ChevronDown, 15 - Highlighter, 16 - Bookmark, 17 - Sun, 18 - Moon, 19 - Monitor, 20 - ExternalLink, 21 - Menu, 22 - X, 23 - } from "lucide-react"; 24 - import { 25 - SiFirefox, 26 - SiGooglechrome, 27 - SiGithub, 28 - SiBluesky, 29 - SiDiscord, 30 - } from "react-icons/si"; 31 - import { FaEdge } from "react-icons/fa"; 32 - import tangledLogo from "../assets/tangled.svg"; 33 - import { getUnreadNotificationCount } from "../api/client"; 34 - 35 - const isFirefox = 36 - typeof navigator !== "undefined" && /Firefox/i.test(navigator.userAgent); 37 - const isEdge = 38 - typeof navigator !== "undefined" && /Edg/i.test(navigator.userAgent); 39 - 40 - function getExtensionInfo() { 41 - if (isFirefox) { 42 - return { 43 - url: "https://addons.mozilla.org/en-US/firefox/addon/margin/", 44 - icon: SiFirefox, 45 - label: "Firefox", 46 - }; 47 - } 48 - if (isEdge) { 49 - return { 50 - url: "https://microsoftedge.microsoft.com/addons/detail/margin/nfjnmllpdgcdnhmmggjihjbidmeadddn", 51 - icon: FaEdge, 52 - label: "Edge", 53 - }; 54 - } 55 - return { 56 - url: "https://chromewebstore.google.com/detail/margin/cgpmbiiagnehkikhcbnhiagfomajncpa/", 57 - icon: SiGooglechrome, 58 - label: "Chrome", 59 - }; 60 - } 61 - 62 - export default function TopNav() { 63 - const { user, isAuthenticated, logout, loading } = useAuth(); 64 - const { theme, setTheme } = useTheme(); 65 - const location = useLocation(); 66 - const [userMenuOpen, setUserMenuOpen] = useState(false); 67 - const [moreMenuOpen, setMoreMenuOpen] = useState(false); 68 - const [mobileMenuOpen, setMobileMenuOpen] = useState(false); 69 - const [unreadCount, setUnreadCount] = useState(0); 70 - const userMenuRef = useRef(null); 71 - const moreMenuRef = useRef(null); 72 - 73 - const isActive = (path) => { 74 - if (path === "/") return location.pathname === "/"; 75 - return location.pathname.startsWith(path); 76 - }; 77 - 78 - const ext = getExtensionInfo(); 79 - const ExtIcon = ext.icon; 80 - 81 - useEffect(() => { 82 - if (isAuthenticated) { 83 - getUnreadNotificationCount() 84 - .then((data) => setUnreadCount(data.count || 0)) 85 - .catch(() => {}); 86 - const interval = setInterval(() => { 87 - getUnreadNotificationCount() 88 - .then((data) => setUnreadCount(data.count || 0)) 89 - .catch(() => {}); 90 - }, 60000); 91 - return () => clearInterval(interval); 92 - } 93 - }, [isAuthenticated]); 94 - 95 - useEffect(() => { 96 - const handleClickOutside = (e) => { 97 - if (userMenuRef.current && !userMenuRef.current.contains(e.target)) { 98 - setUserMenuOpen(false); 99 - } 100 - if (moreMenuRef.current && !moreMenuRef.current.contains(e.target)) { 101 - setMoreMenuOpen(false); 102 - } 103 - }; 104 - document.addEventListener("mousedown", handleClickOutside); 105 - return () => document.removeEventListener("mousedown", handleClickOutside); 106 - }, []); 107 - 108 - const closeMobileMenu = () => setMobileMenuOpen(false); 109 - 110 - const getInitials = () => { 111 - if (user?.displayName) 112 - return user.displayName.substring(0, 2).toUpperCase(); 113 - if (user?.handle) return user.handle.substring(0, 2).toUpperCase(); 114 - return "U"; 115 - }; 116 - 117 - const cycleTheme = () => { 118 - const next = 119 - theme === "system" ? "light" : theme === "light" ? "dark" : "system"; 120 - setTheme(next); 121 - }; 122 - 123 - return ( 124 - <header className="top-nav"> 125 - <div className="top-nav-inner"> 126 - <Link to="/home" className="top-nav-logo"> 127 - <svg 128 - width="26" 129 - height="26" 130 - viewBox="0 0 265 231" 131 - fill="currentColor" 132 - xmlns="http://www.w3.org/2000/svg" 133 - > 134 - <path d="M0 230 V0 H199 V65.7156 H149.5 V115.216 H182.5 L199 131.716 V230 Z" /> 135 - <path d="M215 214.224 V230 H264.5 V0 H215.07 V16.2242 H248.5 V214.224 H215 Z" /> 136 - </svg> 137 - <span>Margin</span> 138 - </Link> 139 - 140 - <nav className="top-nav-links"> 141 - <Link 142 - to="/home" 143 - className={`top-nav-link ${isActive("/home") ? "active" : ""}`} 144 - > 145 - Home 146 - </Link> 147 - <Link 148 - to="/url" 149 - className={`top-nav-link ${isActive("/url") ? "active" : ""}`} 150 - > 151 - Browse 152 - </Link> 153 - {isAuthenticated && ( 154 - <> 155 - <Link 156 - to="/highlights" 157 - className={`top-nav-link ${isActive("/highlights") ? "active" : ""}`} 158 - > 159 - Highlights 160 - </Link> 161 - <Link 162 - to="/bookmarks" 163 - className={`top-nav-link ${isActive("/bookmarks") ? "active" : ""}`} 164 - > 165 - Bookmarks 166 - </Link> 167 - <Link 168 - to="/collections" 169 - className={`top-nav-link ${isActive("/collections") ? "active" : ""}`} 170 - > 171 - Collections 172 - </Link> 173 - </> 174 - )} 175 - </nav> 176 - 177 - <div className="top-nav-actions"> 178 - <a 179 - href={ext.url} 180 - target="_blank" 181 - rel="noopener noreferrer" 182 - className="top-nav-link extension-link" 183 - title={`Get ${ext.label} Extension`} 184 - > 185 - <ExtIcon size={16} /> 186 - <span>Get Extension</span> 187 - </a> 188 - 189 - <div className="top-nav-dropdown" ref={moreMenuRef}> 190 - <button 191 - className="top-nav-icon-btn" 192 - onClick={() => setMoreMenuOpen(!moreMenuOpen)} 193 - title="More" 194 - > 195 - <ChevronDown size={18} /> 196 - </button> 197 - {moreMenuOpen && ( 198 - <div className="dropdown-menu dropdown-right"> 199 - <a 200 - href="https://github.com/margin-at/margin" 201 - target="_blank" 202 - rel="noopener noreferrer" 203 - className="dropdown-item" 204 - > 205 - <SiGithub size={16} /> 206 - GitHub 207 - <ExternalLink size={12} className="dropdown-external" /> 208 - </a> 209 - <a 210 - href="https://tangled.sh/@margin.at/margin" 211 - target="_blank" 212 - rel="noopener noreferrer" 213 - className="dropdown-item" 214 - > 215 - <span className="tangled-icon-wrapper"> 216 - <img src={tangledLogo} alt="" /> 217 - </span> 218 - Tangled 219 - <ExternalLink size={12} className="dropdown-external" /> 220 - </a> 221 - <a 222 - href="https://bsky.app/profile/margin.at" 223 - target="_blank" 224 - rel="noopener noreferrer" 225 - className="dropdown-item" 226 - > 227 - <SiBluesky size={16} /> 228 - Bluesky 229 - <ExternalLink size={12} className="dropdown-external" /> 230 - </a> 231 - <a 232 - href="https://discord.gg/ZQbkGqwzBH" 233 - target="_blank" 234 - rel="noopener noreferrer" 235 - className="dropdown-item" 236 - > 237 - <SiDiscord size={16} /> 238 - Discord 239 - <ExternalLink size={12} className="dropdown-external" /> 240 - </a> 241 - <div className="dropdown-divider" /> 242 - <button className="dropdown-item" onClick={cycleTheme}> 243 - {theme === "system" && <Monitor size={16} />} 244 - {theme === "dark" && <Moon size={16} />} 245 - {theme === "light" && <Sun size={16} />} 246 - Theme: {theme} 247 - </button> 248 - <div className="dropdown-divider" /> 249 - <Link 250 - to="/privacy" 251 - className="dropdown-item" 252 - onClick={() => setMoreMenuOpen(false)} 253 - > 254 - Privacy 255 - </Link> 256 - <Link 257 - to="/terms" 258 - className="dropdown-item" 259 - onClick={() => setMoreMenuOpen(false)} 260 - > 261 - Terms 262 - </Link> 263 - </div> 264 - )} 265 - </div> 266 - 267 - {isAuthenticated && ( 268 - <> 269 - <Link 270 - to="/notifications" 271 - className="top-nav-icon-btn" 272 - onClick={() => setUnreadCount(0)} 273 - title="Notifications" 274 - > 275 - <Bell size={18} /> 276 - {unreadCount > 0 && <span className="notif-dot" />} 277 - </Link> 278 - 279 - <Link to="/new" className="top-nav-new-btn"> 280 - <PenSquare size={16} /> 281 - <span>New</span> 282 - </Link> 283 - </> 284 - )} 285 - 286 - {!loading && 287 - (isAuthenticated ? ( 288 - <div className="top-nav-dropdown" ref={userMenuRef}> 289 - <button 290 - className="top-nav-avatar" 291 - onClick={() => setUserMenuOpen(!userMenuOpen)} 292 - > 293 - {user?.avatar ? ( 294 - <img src={user.avatar} alt={user.displayName} /> 295 - ) : ( 296 - <span>{getInitials()}</span> 297 - )} 298 - </button> 299 - {userMenuOpen && ( 300 - <div className="dropdown-menu dropdown-right"> 301 - <div className="dropdown-user-info"> 302 - <span className="dropdown-user-name"> 303 - {user?.displayName || user?.handle} 304 - </span> 305 - <span className="dropdown-user-handle"> 306 - @{user?.handle} 307 - </span> 308 - </div> 309 - <div className="dropdown-divider" /> 310 - <Link 311 - to={`/profile/${user?.did}`} 312 - className="dropdown-item" 313 - onClick={() => setUserMenuOpen(false)} 314 - > 315 - <User size={16} /> 316 - View Profile 317 - </Link> 318 - <Link 319 - to="/settings" 320 - className="dropdown-item" 321 - onClick={() => setUserMenuOpen(false)} 322 - > 323 - <Settings size={16} /> 324 - Settings 325 - </Link> 326 - <button 327 - onClick={() => { 328 - logout(); 329 - setUserMenuOpen(false); 330 - }} 331 - className="dropdown-item danger" 332 - > 333 - <LogOut size={16} /> 334 - Sign Out 335 - </button> 336 - </div> 337 - )} 338 - </div> 339 - ) : ( 340 - <Link to="/login" className="top-nav-new-btn"> 341 - Sign In 342 - </Link> 343 - ))} 344 - 345 - <button 346 - className="top-nav-mobile-toggle" 347 - onClick={() => setMobileMenuOpen(!mobileMenuOpen)} 348 - > 349 - {mobileMenuOpen ? <X size={22} /> : <Menu size={22} />} 350 - </button> 351 - </div> 352 - </div> 353 - 354 - {mobileMenuOpen && ( 355 - <div className="mobile-menu"> 356 - <Link 357 - to="/home" 358 - className={`mobile-menu-link ${isActive("/home") ? "active" : ""}`} 359 - onClick={closeMobileMenu} 360 - > 361 - <Home size={20} /> Home 362 - </Link> 363 - <Link 364 - to="/url" 365 - className={`mobile-menu-link ${isActive("/url") ? "active" : ""}`} 366 - onClick={closeMobileMenu} 367 - > 368 - <Search size={20} /> Browse 369 - </Link> 370 - {isAuthenticated && ( 371 - <> 372 - <Link 373 - to="/highlights" 374 - className={`mobile-menu-link ${isActive("/highlights") ? "active" : ""}`} 375 - onClick={closeMobileMenu} 376 - > 377 - <Highlighter size={20} /> Highlights 378 - </Link> 379 - <Link 380 - to="/bookmarks" 381 - className={`mobile-menu-link ${isActive("/bookmarks") ? "active" : ""}`} 382 - onClick={closeMobileMenu} 383 - > 384 - <Bookmark size={20} /> Bookmarks 385 - </Link> 386 - <Link 387 - to="/collections" 388 - className={`mobile-menu-link ${isActive("/collections") ? "active" : ""}`} 389 - onClick={closeMobileMenu} 390 - > 391 - <Folder size={20} /> Collections 392 - </Link> 393 - <Link 394 - to="/notifications" 395 - className={`mobile-menu-link ${isActive("/notifications") ? "active" : ""}`} 396 - onClick={closeMobileMenu} 397 - > 398 - <Bell size={20} /> Notifications 399 - {unreadCount > 0 && ( 400 - <span className="notification-badge">{unreadCount}</span> 401 - )} 402 - </Link> 403 - <Link 404 - to="/new" 405 - className={`mobile-menu-link ${isActive("/new") ? "active" : ""}`} 406 - onClick={closeMobileMenu} 407 - > 408 - <PenSquare size={20} /> New 409 - </Link> 410 - </> 411 - )} 412 - <div className="mobile-menu-divider" /> 413 - <a 414 - href={ext.url} 415 - target="_blank" 416 - rel="noopener noreferrer" 417 - className="mobile-menu-link" 418 - > 419 - <ExtIcon size={20} /> Get Extension 420 - </a> 421 - </div> 422 - )} 423 - </header> 424 - ); 425 - }
-63
web/src/components/UserMeta.jsx
··· 1 - import { Link } from "react-router-dom"; 2 - 3 - const formatDate = (dateString, simple = true) => { 4 - if (!dateString) return ""; 5 - const date = new Date(dateString); 6 - const now = new Date(); 7 - const diff = now - date; 8 - const minutes = Math.floor(diff / 60000); 9 - const hours = Math.floor(diff / 3600000); 10 - const days = Math.floor(diff / 86400000); 11 - if (minutes < 1) return "just now"; 12 - if (minutes < 60) return `${minutes}m`; 13 - if (hours < 24) return `${hours}h`; 14 - if (days < 7) return `${days}d`; 15 - if (simple) 16 - return date.toLocaleDateString("en-US", { 17 - month: "short", 18 - day: "numeric", 19 - }); 20 - return date.toLocaleString(); 21 - }; 22 - 23 - export default function UserMeta({ author, createdAt }) { 24 - const authorDisplayName = author?.displayName || author?.handle || "Unknown"; 25 - const authorHandle = author?.handle; 26 - const authorAvatar = author?.avatar; 27 - const authorDid = author?.did; 28 - const marginProfileUrl = authorDid ? `/profile/${authorDid}` : "#"; 29 - 30 - return ( 31 - <> 32 - <Link to={marginProfileUrl} className="annotation-avatar-link"> 33 - <div className="annotation-avatar"> 34 - {authorAvatar ? ( 35 - <img src={authorAvatar} alt={authorDisplayName} /> 36 - ) : ( 37 - <span> 38 - {authorDisplayName?.substring(0, 2).toUpperCase() || "??"} 39 - </span> 40 - )} 41 - </div> 42 - </Link> 43 - <div className="annotation-meta"> 44 - <div className="annotation-author-row"> 45 - <Link to={marginProfileUrl} className="annotation-author-link"> 46 - <span className="annotation-author">{authorDisplayName}</span> 47 - </Link> 48 - {authorHandle && ( 49 - <a 50 - href={`https://bsky.app/profile/${authorHandle}`} 51 - target="_blank" 52 - rel="noopener noreferrer" 53 - className="annotation-handle" 54 - > 55 - @{authorHandle} 56 - </a> 57 - )} 58 - </div> 59 - <div className="annotation-time">{formatDate(createdAt)}</div> 60 - </div> 61 - </> 62 - ); 63 - }
-76
web/src/context/AuthContext.jsx
··· 1 - import { useState, createContext, useContext, useEffect } from "react"; 2 - import { getSession, logout } from "../api/client"; 3 - 4 - const AuthContext = createContext(null); 5 - 6 - export function AuthProvider({ children }) { 7 - const [user, setUser] = useState(null); 8 - const [loading, setLoading] = useState(true); 9 - 10 - useEffect(() => { 11 - checkSession(); 12 - }, []); 13 - 14 - const checkSession = async () => { 15 - try { 16 - const data = await getSession(); 17 - if (data.authenticated) { 18 - let avatar = null; 19 - let displayName = null; 20 - try { 21 - const profileRes = await fetch( 22 - `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(data.did)}`, 23 - ); 24 - if (profileRes.ok) { 25 - const profile = await profileRes.json(); 26 - avatar = profile.avatar; 27 - displayName = profile.displayName; 28 - } 29 - } catch (e) { 30 - console.error("Failed to fetch profile:", e); 31 - } 32 - setUser({ 33 - did: data.did, 34 - handle: data.handle, 35 - avatar, 36 - displayName: displayName || data.handle, 37 - }); 38 - } else { 39 - setUser(null); 40 - } 41 - } catch { 42 - setUser(null); 43 - } finally { 44 - setLoading(false); 45 - } 46 - }; 47 - 48 - const handleLogout = async () => { 49 - try { 50 - await logout(); 51 - } catch (e) { 52 - console.warn("Logout failed", e); 53 - } 54 - setUser(null); 55 - }; 56 - 57 - const value = { 58 - user, 59 - loading, 60 - isAuthenticated: !!user, 61 - login: () => (window.location.href = "/login"), 62 - logout: handleLogout, 63 - refresh: checkSession, 64 - }; 65 - 66 - return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>; 67 - } 68 - 69 - // eslint-disable-next-line react-refresh/only-export-components 70 - export function useAuth() { 71 - const context = useContext(AuthContext); 72 - if (!context) { 73 - throw new Error("useAuth must be used within AuthProvider"); 74 - } 75 - return context; 76 - }
-88
web/src/context/ThemeContext.jsx
··· 1 - import { createContext, useContext, useEffect, useState } from "react"; 2 - 3 - const ThemeContext = createContext({ 4 - theme: "system", 5 - setTheme: () => null, 6 - layout: "sidebar", 7 - setLayout: () => null, 8 - }); 9 - 10 - export function ThemeProvider({ children }) { 11 - const [theme, setTheme] = useState(() => { 12 - return localStorage.getItem("theme") || "system"; 13 - }); 14 - const [layout, setLayout] = useState(() => { 15 - return localStorage.getItem("layout_preference") || "sidebar"; 16 - }); 17 - 18 - useEffect(() => { 19 - localStorage.setItem("theme", theme); 20 - 21 - const root = window.document.documentElement; 22 - root.classList.remove("light", "dark"); 23 - 24 - delete root.dataset.theme; 25 - 26 - if (theme === "system") { 27 - const systemTheme = window.matchMedia("(prefers-color-scheme: dark)") 28 - .matches 29 - ? "dark" 30 - : "light"; 31 - 32 - if (systemTheme === "light") { 33 - root.dataset.theme = "light"; 34 - } else { 35 - root.dataset.theme = "dark"; 36 - } 37 - return; 38 - } 39 - 40 - if (theme === "light") { 41 - root.dataset.theme = "light"; 42 - } 43 - }, [theme]); 44 - 45 - useEffect(() => { 46 - if (theme !== "system") return; 47 - 48 - const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); 49 - const handleChange = () => { 50 - const root = window.document.documentElement; 51 - if (mediaQuery.matches) { 52 - delete root.dataset.theme; 53 - } else { 54 - root.dataset.theme = "light"; 55 - } 56 - }; 57 - 58 - mediaQuery.addEventListener("change", handleChange); 59 - return () => mediaQuery.removeEventListener("change", handleChange); 60 - }, [theme]); 61 - 62 - useEffect(() => { 63 - localStorage.setItem("layout_preference", layout); 64 - }, [layout]); 65 - 66 - const value = { 67 - theme, 68 - setTheme: (newTheme) => { 69 - setTheme(newTheme); 70 - }, 71 - layout, 72 - setLayout: (newLayout) => { 73 - setLayout(newLayout); 74 - }, 75 - }; 76 - 77 - return ( 78 - <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider> 79 - ); 80 - } 81 - 82 - // eslint-disable-next-line react-refresh/only-export-components 83 - export function useTheme() { 84 - const context = useContext(ThemeContext); 85 - if (context === undefined) 86 - throw new Error("useTheme must be used within a ThemeProvider"); 87 - return context; 88 - }
-560
web/src/css/annotations.css
··· 1 - .annotation-detail-page { 2 - max-width: 640px; 3 - margin: 0 auto; 4 - min-height: 100vh; 5 - } 6 - 7 - .annotation-detail-header { 8 - margin-bottom: var(--spacing-md); 9 - } 10 - 11 - .back-link { 12 - display: inline-flex; 13 - align-items: center; 14 - color: var(--text-tertiary); 15 - text-decoration: none; 16 - font-size: 0.8rem; 17 - font-weight: 500; 18 - transition: color 0.15s; 19 - } 20 - 21 - .back-link:hover { 22 - color: var(--text-primary); 23 - } 24 - 25 - .replies-section { 26 - margin-top: var(--spacing-lg); 27 - border-top: 1px solid var(--border); 28 - padding-top: var(--spacing-md); 29 - } 30 - 31 - .replies-title { 32 - display: flex; 33 - align-items: center; 34 - gap: 6px; 35 - font-size: 0.9rem; 36 - font-weight: 600; 37 - color: var(--text-primary); 38 - margin-bottom: var(--spacing-md); 39 - } 40 - 41 - .annotation-card { 42 - display: flex; 43 - flex-direction: column; 44 - gap: 8px; 45 - padding: 16px 20px; 46 - transition: all 0.15s ease; 47 - width: 100%; 48 - box-sizing: border-box; 49 - overflow: visible; 50 - background: var(--bg-primary); 51 - border: none; 52 - position: relative; 53 - } 54 - 55 - .feed > .annotation-card, 56 - .feed > .card { 57 - border-radius: var(--radius-lg); 58 - border: 1px solid var(--border); 59 - background: var(--bg-card) !important; 60 - overflow: visible; 61 - } 62 - 63 - /* 64 - .feed > .annotation-card:first-child, 65 - .feed > .card:first-child { 66 - border-top-left-radius: var(--radius-lg) !important; 67 - border-top-right-radius: var(--radius-lg) !important; 68 - } 69 - 70 - .feed > .annotation-card:last-child, 71 - .feed > .card:last-child { 72 - border-bottom-left-radius: var(--radius-lg) !important; 73 - border-bottom-right-radius: var(--radius-lg) !important; 74 - } 75 - 76 - .feed > .annotation-card:only-child, 77 - .feed > .card:only-child { 78 - border-radius: var(--radius-lg) !important; 79 - } 80 - */ 81 - 82 - .annotation-header { 83 - display: flex; 84 - justify-content: space-between; 85 - align-items: flex-start; 86 - gap: var(--spacing-sm); 87 - } 88 - 89 - .annotation-header-left { 90 - display: flex; 91 - align-items: center; 92 - gap: 8px; 93 - flex: 1; 94 - min-width: 0; 95 - } 96 - 97 - .annotation-avatar { 98 - width: 32px; 99 - height: 32px; 100 - min-width: 32px; 101 - border-radius: var(--radius-full); 102 - background: var(--bg-tertiary); 103 - display: flex; 104 - align-items: center; 105 - justify-content: center; 106 - font-weight: 600; 107 - font-size: 0.75rem; 108 - color: var(--text-secondary); 109 - overflow: hidden; 110 - } 111 - 112 - .annotation-avatar img { 113 - width: 100%; 114 - height: 100%; 115 - object-fit: cover; 116 - } 117 - 118 - .annotation-meta { 119 - display: flex; 120 - flex-direction: column; 121 - justify-content: center; 122 - line-height: 1.4; 123 - min-width: 0; 124 - flex: 1; 125 - } 126 - 127 - .annotation-avatar-link { 128 - text-decoration: none; 129 - border-radius: var(--radius-full); 130 - } 131 - 132 - .annotation-author-row { 133 - display: flex; 134 - align-items: baseline; 135 - gap: 8px; 136 - flex-wrap: wrap; 137 - } 138 - 139 - .annotation-author { 140 - font-weight: 600; 141 - color: var(--text-primary); 142 - font-size: 0.875rem; 143 - } 144 - 145 - .annotation-handle { 146 - font-size: 0.8rem; 147 - color: var(--text-tertiary); 148 - text-decoration: none; 149 - } 150 - 151 - .annotation-handle:hover { 152 - color: var(--text-secondary); 153 - } 154 - 155 - .annotation-time { 156 - font-size: 0.75rem; 157 - color: var(--text-tertiary); 158 - } 159 - 160 - .annotation-content { 161 - display: flex; 162 - flex-direction: column; 163 - gap: 8px; 164 - padding-left: 0; 165 - max-width: 100%; 166 - overflow: visible; 167 - } 168 - 169 - .annotation-source { 170 - display: inline-flex; 171 - align-items: center; 172 - gap: 6px; 173 - font-size: 0.8rem; 174 - color: var(--accent); 175 - text-decoration: none; 176 - transition: color 0.15s ease; 177 - max-width: 100%; 178 - overflow: hidden; 179 - } 180 - 181 - .annotation-source:hover { 182 - text-decoration: underline; 183 - } 184 - 185 - .annotation-source-title { 186 - color: var(--text-primary); 187 - font-weight: 500; 188 - overflow: hidden; 189 - text-overflow: ellipsis; 190 - white-space: nowrap; 191 - } 192 - 193 - .annotation-highlight { 194 - display: block; 195 - position: relative; 196 - padding: 10px 14px; 197 - margin: 0; 198 - text-decoration: none; 199 - background: var(--bg-tertiary); 200 - border-left: 3px solid var(--accent); 201 - border-radius: 0 var(--radius-md) var(--radius-md) 0; 202 - transition: all 0.15s ease; 203 - max-width: 100%; 204 - overflow: hidden; 205 - } 206 - 207 - .annotation-highlight:hover { 208 - background: var(--bg-hover); 209 - } 210 - 211 - .annotation-highlight mark { 212 - background: transparent; 213 - color: var(--text-primary); 214 - font-style: italic; 215 - font-size: 0.875rem; 216 - line-height: 1.5; 217 - font-weight: 400; 218 - display: block; 219 - overflow-wrap: break-word; 220 - word-break: break-word; 221 - } 222 - 223 - .annotation-text { 224 - font-size: 1rem; 225 - line-height: 1.7; 226 - color: var(--text-primary); 227 - white-space: pre-wrap; 228 - } 229 - 230 - .annotation-tags { 231 - display: flex; 232 - flex-wrap: wrap; 233 - gap: 6px; 234 - margin-top: 2px; 235 - } 236 - 237 - .annotation-tag { 238 - font-size: 0.75rem; 239 - color: var(--accent); 240 - text-decoration: none; 241 - font-weight: 500; 242 - transition: opacity 0.15s; 243 - } 244 - 245 - .annotation-tag:hover { 246 - opacity: 0.8; 247 - text-decoration: underline; 248 - } 249 - 250 - .annotation-actions { 251 - display: flex; 252 - align-items: center; 253 - justify-content: flex-start; 254 - gap: 4px; 255 - padding-left: 0; 256 - margin-top: 4px; 257 - position: relative; 258 - } 259 - 260 - .annotation-actions-left { 261 - display: flex; 262 - align-items: center; 263 - gap: 8px; 264 - } 265 - 266 - .annotation-action { 267 - display: flex; 268 - align-items: center; 269 - gap: 5px; 270 - color: var(--text-tertiary); 271 - font-size: 0.8rem; 272 - font-weight: 500; 273 - padding: 6px 10px; 274 - border-radius: var(--radius-md); 275 - transition: all 0.15s ease; 276 - background: transparent; 277 - cursor: pointer; 278 - border: none; 279 - } 280 - 281 - .annotation-action:hover { 282 - color: var(--text-secondary); 283 - background: var(--bg-tertiary); 284 - } 285 - 286 - .annotation-action.liked { 287 - color: var(--error); 288 - } 289 - 290 - .annotation-action.liked svg { 291 - fill: var(--error); 292 - } 293 - 294 - .annotation-action.active { 295 - color: var(--accent); 296 - } 297 - 298 - .action-icon-only { 299 - padding: 4px; 300 - } 301 - 302 - .annotation-header-right { 303 - opacity: 0; 304 - transition: opacity 0.15s; 305 - } 306 - 307 - .annotation-card:hover .annotation-header-right { 308 - opacity: 1; 309 - } 310 - 311 - .inline-replies { 312 - margin-top: var(--spacing-sm); 313 - padding-left: 0; 314 - position: relative; 315 - } 316 - 317 - .annotation-text, 318 - .reply-text, 319 - .history-content { 320 - overflow-wrap: break-word; 321 - word-break: break-word; 322 - max-width: 100%; 323 - } 324 - 325 - .annotation-header-left, 326 - .annotation-meta, 327 - .reply-meta { 328 - min-width: 0; 329 - max-width: 100%; 330 - } 331 - 332 - .annotation-author-row, 333 - .reply-author { 334 - max-width: 100%; 335 - } 336 - 337 - @media (max-width: 768px) { 338 - .annotation-content, 339 - .annotation-actions, 340 - .inline-replies { 341 - padding-left: 0; 342 - } 343 - 344 - .annotation-header-right { 345 - opacity: 1; 346 - } 347 - 348 - .annotation-card { 349 - padding: 16px; 350 - } 351 - 352 - .annotation-avatar { 353 - width: 36px; 354 - height: 36px; 355 - min-width: 36px; 356 - } 357 - } 358 - 359 - .replies-list-threaded { 360 - margin-top: var(--spacing-md); 361 - display: flex; 362 - flex-direction: column; 363 - } 364 - 365 - .reply-card-threaded { 366 - position: relative; 367 - padding-left: 0; 368 - padding: var(--spacing-sm) 0; 369 - transition: background 0.15s; 370 - } 371 - 372 - .reply-header { 373 - display: flex; 374 - align-items: center; 375 - gap: 8px; 376 - margin-bottom: 4px; 377 - } 378 - 379 - .reply-avatar { 380 - width: 28px; 381 - height: 28px; 382 - border-radius: var(--radius-full); 383 - background: var(--bg-tertiary); 384 - overflow: hidden; 385 - flex-shrink: 0; 386 - display: flex; 387 - align-items: center; 388 - justify-content: center; 389 - } 390 - 391 - .reply-avatar img { 392 - width: 100%; 393 - height: 100%; 394 - object-fit: cover; 395 - } 396 - 397 - .reply-avatar span { 398 - font-size: 0.7rem; 399 - font-weight: 600; 400 - color: var(--text-secondary); 401 - } 402 - 403 - .reply-meta { 404 - display: flex; 405 - align-items: baseline; 406 - gap: 8px; 407 - flex: 1; 408 - min-width: 0; 409 - } 410 - 411 - .reply-author { 412 - font-weight: 600; 413 - font-size: 0.875rem; 414 - color: var(--text-primary); 415 - white-space: nowrap; 416 - overflow: hidden; 417 - text-overflow: ellipsis; 418 - } 419 - 420 - .reply-handle { 421 - font-size: 0.8rem; 422 - color: var(--text-tertiary); 423 - text-decoration: none; 424 - white-space: nowrap; 425 - overflow: hidden; 426 - text-overflow: ellipsis; 427 - } 428 - 429 - .reply-time { 430 - font-size: 0.8rem; 431 - color: var(--text-tertiary); 432 - white-space: nowrap; 433 - } 434 - 435 - .reply-dot { 436 - color: var(--text-tertiary); 437 - font-size: 0.7rem; 438 - } 439 - 440 - .reply-text { 441 - font-size: 0.9rem; 442 - line-height: 1.5; 443 - color: var(--text-primary); 444 - margin: 0; 445 - padding-left: 36px; 446 - } 447 - 448 - .reply-actions { 449 - display: flex; 450 - align-items: center; 451 - gap: 4px; 452 - opacity: 0; 453 - transition: opacity 0.15s; 454 - } 455 - 456 - .reply-card-threaded:hover .reply-actions { 457 - opacity: 1; 458 - } 459 - 460 - .reply-action-btn { 461 - background: none; 462 - border: none; 463 - padding: 4px; 464 - color: var(--text-tertiary); 465 - cursor: pointer; 466 - border-radius: var(--radius-sm); 467 - display: flex; 468 - align-items: center; 469 - justify-content: center; 470 - } 471 - 472 - .reply-action-btn:hover { 473 - background: var(--bg-tertiary); 474 - color: var(--text-secondary); 475 - } 476 - 477 - .reply-action-delete:hover { 478 - color: var(--error); 479 - background: rgba(255, 69, 58, 0.1); 480 - } 481 - 482 - .reply-form { 483 - border: 1px solid var(--border); 484 - border-radius: var(--radius-md); 485 - padding: var(--spacing-md); 486 - background: var(--bg-secondary); 487 - margin-bottom: var(--spacing-md); 488 - } 489 - 490 - .replying-to-banner { 491 - display: flex; 492 - justify-content: space-between; 493 - align-items: center; 494 - background: var(--bg-tertiary); 495 - padding: 6px 10px; 496 - border-radius: var(--radius-sm); 497 - margin-bottom: var(--spacing-sm); 498 - font-size: 0.8rem; 499 - color: var(--text-secondary); 500 - } 501 - 502 - .cancel-reply { 503 - background: none; 504 - border: none; 505 - color: var(--text-tertiary); 506 - cursor: pointer; 507 - font-size: 1rem; 508 - padding: 0 4px; 509 - line-height: 1; 510 - } 511 - 512 - .cancel-reply:hover { 513 - color: var(--text-primary); 514 - } 515 - 516 - .reply-input { 517 - width: 100%; 518 - background: var(--bg-primary); 519 - border: 1px solid var(--border); 520 - border-radius: var(--radius-sm); 521 - padding: 10px 12px; 522 - color: var(--text-primary); 523 - font-family: inherit; 524 - font-size: 0.875rem; 525 - resize: vertical; 526 - min-height: 60px; 527 - transition: border-color 0.15s; 528 - display: block; 529 - box-sizing: border-box; 530 - } 531 - 532 - .reply-input:focus { 533 - outline: none; 534 - border-color: var(--accent); 535 - } 536 - 537 - .reply-form-actions { 538 - display: flex; 539 - justify-content: flex-end; 540 - margin-top: var(--spacing-sm); 541 - } 542 - 543 - .rich-text-link { 544 - color: var(--accent); 545 - text-decoration: none; 546 - } 547 - 548 - .rich-text-link:hover { 549 - text-decoration: underline; 550 - } 551 - 552 - .rich-text-mention { 553 - color: var(--accent); 554 - font-weight: 500; 555 - text-decoration: none; 556 - } 557 - 558 - .rich-text-mention:hover { 559 - text-decoration: underline; 560 - }
-224
web/src/css/base.css
··· 1 - @import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,400;0,500;0,600;1,400&family=IBM+Plex+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap"); 2 - 3 - :root { 4 - --bg-primary: #0a0a0d; 5 - --bg-secondary: #121216; 6 - --bg-tertiary: #1a1a1f; 7 - --bg-card: #0f0f13; 8 - --bg-elevated: #18181d; 9 - --bg-hover: #1e1e24; 10 - 11 - --glass-border: rgba(234, 234, 238, 0.08); 12 - --glass-bg: rgba(10, 10, 13, 0.92); 13 - 14 - --text-primary: #eaeaee; 15 - --text-secondary: #b7b6c5; 16 - --text-tertiary: #6e6d7a; 17 - 18 - --border: rgba(183, 182, 197, 0.12); 19 - --border-hover: rgba(183, 182, 197, 0.2); 20 - --accent: #957a86; 21 - --accent-hover: #a98d98; 22 - --accent-subtle: rgba(149, 122, 134, 0.15); 23 - --accent-text: #c4a8b2; 24 - 25 - --success: #7fb069; 26 - --error: #d97766; 27 - --warning: #e8a54b; 28 - --info: #6eb5ff; 29 - 30 - --radius-xs: 4px; 31 - --radius-sm: 6px; 32 - --radius-md: 8px; 33 - --radius-lg: 12px; 34 - --radius-xl: 16px; 35 - --radius-full: 9999px; 36 - 37 - --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3); 38 - --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4); 39 - --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5); 40 - --shadow-glow: 0 0 20px rgba(149, 122, 134, 0.2); 41 - 42 - --font-sans: "IBM Plex Sans", -apple-system, BlinkMacSystemFont, sans-serif; 43 - --font-display: "IBM Plex Sans", sans-serif; 44 - --font-mono: "IBM Plex Mono", monospace; 45 - 46 - --nav-bg: rgba(10, 10, 13, 0.95); 47 - 48 - --sidebar-width: 200px; 49 - --right-sidebar-width: 260px; 50 - --content-max-width: 600px; 51 - --spacing-xs: 4px; 52 - --spacing-sm: 8px; 53 - --spacing-md: 12px; 54 - --spacing-lg: 20px; 55 - --spacing-xl: 28px; 56 - } 57 - 58 - [data-theme="light"] { 59 - --bg-primary: #f8f8fa; 60 - --bg-secondary: #ffffff; 61 - --bg-tertiary: #f0f0f4; 62 - --bg-card: #ffffff; 63 - --bg-elevated: #ffffff; 64 - --bg-hover: #eeeef2; 65 - 66 - --glass-border: rgba(92, 73, 90, 0.1); 67 - --glass-bg: rgba(248, 248, 250, 0.95); 68 - 69 - --text-primary: #18171c; 70 - --text-secondary: #5c495a; 71 - --text-tertiary: #8a8494; 72 - 73 - --border: rgba(92, 73, 90, 0.12); 74 - --border-hover: rgba(92, 73, 90, 0.22); 75 - 76 - --accent: #7a5f6d; 77 - --accent-hover: #664e5b; 78 - --accent-subtle: rgba(149, 122, 134, 0.12); 79 - --accent-text: #5c495a; 80 - 81 - --shadow-sm: 0 1px 3px rgba(92, 73, 90, 0.06); 82 - --shadow-md: 0 4px 12px rgba(92, 73, 90, 0.08); 83 - --shadow-lg: 0 8px 24px rgba(92, 73, 90, 0.1); 84 - --shadow-glow: 0 0 20px rgba(149, 122, 134, 0.1); 85 - 86 - --nav-bg: rgba(255, 255, 255, 0.95); 87 - } 88 - 89 - * { 90 - margin: 0; 91 - padding: 0; 92 - box-sizing: border-box; 93 - } 94 - 95 - html { 96 - font-size: 16px; 97 - -webkit-text-size-adjust: 100%; 98 - overflow-x: hidden; 99 - } 100 - 101 - body { 102 - font-family: var(--font-sans); 103 - background: var(--bg-primary); 104 - color: var(--text-primary); 105 - line-height: 1.55; 106 - min-height: 100vh; 107 - -webkit-font-smoothing: antialiased; 108 - -moz-osx-font-smoothing: grayscale; 109 - overflow-x: hidden; 110 - max-width: 100vw; 111 - font-size: 0.9375rem; 112 - } 113 - 114 - h1, 115 - h2, 116 - h3, 117 - h4, 118 - h5, 119 - h6 { 120 - font-family: var(--font-display); 121 - font-weight: 600; 122 - letter-spacing: -0.02em; 123 - color: var(--text-primary); 124 - line-height: 1.3; 125 - } 126 - 127 - h1 { 128 - font-size: 1.5rem; 129 - } 130 - h2 { 131 - font-size: 1.25rem; 132 - } 133 - h3 { 134 - font-size: 1.1rem; 135 - } 136 - 137 - p { 138 - color: var(--text-secondary); 139 - line-height: 1.6; 140 - } 141 - 142 - a { 143 - color: inherit; 144 - text-decoration: none; 145 - transition: color 0.2s ease; 146 - } 147 - 148 - button { 149 - font-family: inherit; 150 - cursor: pointer; 151 - border: none; 152 - background: none; 153 - } 154 - 155 - input, 156 - textarea, 157 - select { 158 - font-family: inherit; 159 - font-size: inherit; 160 - color: var(--text-primary); 161 - } 162 - 163 - ::selection { 164 - background: var(--accent-subtle); 165 - color: var(--accent-text); 166 - } 167 - 168 - ::-webkit-scrollbar { 169 - width: 10px; 170 - height: 10px; 171 - } 172 - 173 - ::-webkit-scrollbar-track { 174 - background: var(--bg-secondary); 175 - } 176 - 177 - ::-webkit-scrollbar-thumb { 178 - background: var(--bg-hover); 179 - border-radius: var(--radius-full); 180 - border: 2px solid var(--bg-secondary); 181 - } 182 - 183 - ::-webkit-scrollbar-thumb:hover { 184 - background: var(--text-tertiary); 185 - } 186 - 187 - :focus-visible { 188 - outline: 2px solid var(--accent); 189 - outline-offset: 3px; 190 - } 191 - 192 - .text-sm { 193 - font-size: 0.9rem; 194 - } 195 - 196 - .text-xs { 197 - font-size: 0.8rem; 198 - } 199 - 200 - .font-medium { 201 - font-weight: 500; 202 - } 203 - 204 - .font-semibold { 205 - font-weight: 600; 206 - } 207 - 208 - .font-mono { 209 - font-family: var(--font-mono); 210 - } 211 - 212 - .text-muted { 213 - color: var(--text-secondary); 214 - } 215 - 216 - .text-faint { 217 - color: var(--text-tertiary); 218 - } 219 - 220 - .card { 221 - background: var(--bg-card); 222 - border-radius: var(--radius-lg); 223 - border: 1px solid var(--border); 224 - }
-143
web/src/css/buttons.css
··· 1 - .btn { 2 - display: inline-flex; 3 - align-items: center; 4 - justify-content: center; 5 - gap: 6px; 6 - padding: 8px 16px; 7 - font-size: 0.85rem; 8 - font-weight: 500; 9 - border-radius: var(--radius-md); 10 - transition: all 0.15s ease; 11 - white-space: nowrap; 12 - border: none; 13 - cursor: pointer; 14 - } 15 - 16 - .btn-primary { 17 - background: var(--accent); 18 - color: white; 19 - } 20 - 21 - .btn-primary:hover { 22 - background: var(--accent-hover); 23 - box-shadow: var(--shadow-glow); 24 - } 25 - 26 - .btn-secondary { 27 - background: var(--bg-tertiary); 28 - color: var(--text-primary); 29 - border: 1px solid var(--border); 30 - } 31 - 32 - .btn-secondary:hover { 33 - background: var(--bg-hover); 34 - border-color: var(--border-hover); 35 - } 36 - 37 - .btn-ghost { 38 - color: var(--text-secondary); 39 - padding: 8px 12px; 40 - background: transparent; 41 - } 42 - 43 - .btn-ghost:hover { 44 - color: var(--text-primary); 45 - background: var(--bg-tertiary); 46 - } 47 - 48 - .btn-bluesky { 49 - background: #0085ff; 50 - color: white; 51 - display: flex; 52 - align-items: center; 53 - justify-content: center; 54 - gap: 8px; 55 - transition: all 0.15s; 56 - } 57 - 58 - .btn-bluesky:hover { 59 - background: #0070dd; 60 - } 61 - 62 - .btn-sm { 63 - padding: 6px 12px; 64 - font-size: 0.8rem; 65 - } 66 - 67 - .btn-text { 68 - background: none; 69 - border: none; 70 - color: var(--text-secondary); 71 - font-size: 0.85rem; 72 - padding: 6px 10px; 73 - cursor: pointer; 74 - transition: color 0.15s; 75 - border-radius: var(--radius-sm); 76 - } 77 - 78 - .btn-text:hover { 79 - color: var(--text-primary); 80 - background: var(--bg-tertiary); 81 - } 82 - 83 - .btn-block { 84 - width: 100%; 85 - text-align: left; 86 - padding: 10px 14px; 87 - color: var(--text-secondary); 88 - background: var(--bg-tertiary); 89 - border-radius: var(--radius-md); 90 - margin-top: 8px; 91 - font-size: 0.85rem; 92 - cursor: pointer; 93 - transition: all 0.15s; 94 - border: 1px solid transparent; 95 - } 96 - 97 - .btn-block:hover { 98 - background: var(--bg-hover); 99 - color: var(--text-primary); 100 - border-color: var(--border); 101 - } 102 - 103 - .btn-icon-danger { 104 - padding: 8px; 105 - background: rgba(255, 69, 58, 0.1); 106 - color: var(--error); 107 - border: none; 108 - border-radius: var(--radius-md); 109 - cursor: pointer; 110 - transition: all 0.15s ease; 111 - display: flex; 112 - align-items: center; 113 - justify-content: center; 114 - } 115 - 116 - .btn-icon-danger:hover { 117 - background: var(--error); 118 - color: white; 119 - } 120 - 121 - .btn-danger { 122 - background: rgba(255, 69, 58, 0.1); 123 - color: var(--error); 124 - border: 1px solid rgba(255, 69, 58, 0.2); 125 - } 126 - 127 - .btn-danger:hover { 128 - background: var(--error); 129 - color: white; 130 - border-color: var(--error); 131 - } 132 - 133 - .action-buttons { 134 - display: flex; 135 - gap: var(--spacing-sm); 136 - flex-wrap: wrap; 137 - } 138 - 139 - .action-buttons-end { 140 - display: flex; 141 - justify-content: flex-end; 142 - gap: var(--spacing-sm); 143 - }
-278
web/src/css/cards.css
··· 1 - .card { 2 - background: var(--bg-primary); 3 - border: none; 4 - border-radius: 0; 5 - transition: all 0.15s ease; 6 - position: relative; 7 - overflow: visible; 8 - } 9 - 10 - .semble-badge { 11 - display: flex; 12 - align-items: center; 13 - gap: 4px; 14 - font-size: 0.75rem; 15 - color: var(--text-tertiary); 16 - margin-right: 4px; 17 - } 18 - 19 - .semble-badge img { 20 - width: 14px; 21 - height: 14px; 22 - } 23 - 24 - .bookmark-preview { 25 - display: block; 26 - padding: 14px 16px; 27 - background: linear-gradient( 28 - 135deg, 29 - var(--bg-tertiary) 0%, 30 - var(--bg-secondary) 100% 31 - ); 32 - border: 1px solid var(--border); 33 - border-left: 3px solid var(--accent); 34 - border-radius: var(--radius-md); 35 - text-decoration: none; 36 - transition: all 0.2s ease; 37 - position: relative; 38 - } 39 - 40 - .bookmark-preview:hover { 41 - background: var(--bg-hover); 42 - border-left-color: var(--accent-hover); 43 - } 44 - 45 - .bookmark-preview-content { 46 - display: flex; 47 - flex-direction: column; 48 - gap: 4px; 49 - } 50 - 51 - .bookmark-preview-site { 52 - display: flex; 53 - align-items: center; 54 - gap: 6px; 55 - font-size: 0.7rem; 56 - color: var(--text-tertiary); 57 - text-transform: uppercase; 58 - letter-spacing: 0.06em; 59 - font-weight: 500; 60 - } 61 - 62 - .bookmark-preview-site svg { 63 - color: var(--accent); 64 - } 65 - 66 - .bookmark-preview-title { 67 - font-size: 0.95rem; 68 - font-weight: 600; 69 - color: var(--text-primary); 70 - line-height: 1.35; 71 - margin: 0; 72 - display: -webkit-box; 73 - -webkit-line-clamp: 2; 74 - line-clamp: 2; 75 - -webkit-box-orient: vertical; 76 - overflow: hidden; 77 - } 78 - 79 - .bookmark-preview-desc { 80 - font-size: 0.8rem; 81 - color: var(--text-secondary); 82 - line-height: 1.45; 83 - margin: 0; 84 - display: -webkit-box; 85 - -webkit-line-clamp: 2; 86 - line-clamp: 2; 87 - -webkit-box-orient: vertical; 88 - overflow: hidden; 89 - } 90 - 91 - .bookmark-card .annotation-content { 92 - padding-left: 0; 93 - overflow: visible; 94 - } 95 - 96 - .bookmark-card { 97 - overflow: visible !important; 98 - } 99 - 100 - .bookmark-card:hover { 101 - z-index: 10; 102 - overflow: visible !important; 103 - } 104 - 105 - .bookmark-card.has-open-menu, 106 - .annotation-card.has-open-menu, 107 - .card.has-open-menu { 108 - z-index: 200 !important; 109 - } 110 - 111 - .bookmark-site { 112 - display: flex; 113 - align-items: center; 114 - gap: 6px; 115 - font-size: 0.8rem; 116 - color: var(--text-tertiary); 117 - text-transform: uppercase; 118 - letter-spacing: 0.02em; 119 - } 120 - 121 - .bookmark-title { 122 - font-size: 1rem; 123 - font-weight: 600; 124 - color: var(--text-primary); 125 - line-height: 1.4; 126 - margin: 0; 127 - } 128 - 129 - .bookmark-desc { 130 - font-size: 0.875rem; 131 - color: var(--text-secondary); 132 - line-height: 1.5; 133 - margin: 0; 134 - display: -webkit-box; 135 - -webkit-line-clamp: 2; 136 - line-clamp: 2; 137 - -webkit-box-orient: vertical; 138 - overflow: hidden; 139 - } 140 - 141 - .edit-form { 142 - display: flex; 143 - flex-direction: column; 144 - gap: 8px; 145 - } 146 - 147 - .edit-textarea, 148 - .edit-input { 149 - width: 100%; 150 - padding: 10px 12px; 151 - background: var(--bg-primary); 152 - border: 1px solid var(--border); 153 - border-radius: var(--radius-md); 154 - color: var(--text-primary); 155 - font-family: inherit; 156 - font-size: 0.9rem; 157 - transition: border-color 0.15s ease; 158 - } 159 - 160 - .edit-textarea { 161 - resize: vertical; 162 - min-height: 80px; 163 - } 164 - 165 - .edit-textarea:focus, 166 - .edit-input:focus { 167 - outline: none; 168 - border-color: var(--accent); 169 - } 170 - 171 - .edit-actions { 172 - display: flex; 173 - justify-content: flex-end; 174 - gap: 8px; 175 - } 176 - 177 - .color-edit-form { 178 - display: flex; 179 - align-items: center; 180 - gap: 8px; 181 - padding: 10px 12px; 182 - background: var(--bg-secondary); 183 - border: 1px solid var(--border); 184 - border-radius: var(--radius-md); 185 - } 186 - 187 - .color-picker-wrapper { 188 - position: relative; 189 - width: 28px; 190 - height: 28px; 191 - flex-shrink: 0; 192 - } 193 - 194 - .color-preview { 195 - width: 100%; 196 - height: 100%; 197 - border-radius: 50%; 198 - border: 2px solid var(--bg-card); 199 - box-shadow: 0 0 0 1px var(--border); 200 - } 201 - 202 - .color-input { 203 - position: absolute; 204 - top: 0; 205 - left: 0; 206 - width: 100%; 207 - height: 100%; 208 - opacity: 0; 209 - cursor: pointer; 210 - } 211 - 212 - .color-edit-form .edit-input { 213 - margin: 0; 214 - flex: 1; 215 - padding: 6px 10px; 216 - height: 32px; 217 - border: none; 218 - background: transparent; 219 - } 220 - 221 - .btn-icon { 222 - padding: 0 10px; 223 - height: 32px; 224 - min-width: auto; 225 - } 226 - 227 - .history-panel { 228 - padding: 12px; 229 - background: var(--bg-secondary); 230 - border: 1px solid var(--border); 231 - border-radius: var(--radius-md); 232 - } 233 - 234 - .history-header { 235 - display: flex; 236 - justify-content: space-between; 237 - align-items: center; 238 - margin-bottom: 12px; 239 - } 240 - 241 - .history-title { 242 - font-size: 0.9rem; 243 - font-weight: 600; 244 - color: var(--text-primary); 245 - } 246 - 247 - .history-status { 248 - font-size: 0.85rem; 249 - color: var(--text-tertiary); 250 - font-style: italic; 251 - } 252 - 253 - .history-list { 254 - list-style: none; 255 - padding: 0; 256 - margin: 0; 257 - display: flex; 258 - flex-direction: column; 259 - gap: 8px; 260 - } 261 - 262 - .history-item { 263 - padding: 8px 10px; 264 - background: var(--bg-tertiary); 265 - border-radius: var(--radius-sm); 266 - } 267 - 268 - .history-date { 269 - font-size: 0.75rem; 270 - color: var(--text-tertiary); 271 - margin-bottom: 4px; 272 - } 273 - 274 - .history-content { 275 - font-size: 0.85rem; 276 - color: var(--text-secondary); 277 - line-height: 1.5; 278 - }
-340
web/src/css/collections.css
··· 1 - .collection-feed-item { 2 - display: flex; 3 - flex-direction: column; 4 - background: var(--bg-primary); 5 - overflow: visible; 6 - } 7 - 8 - .collection-context-badge { 9 - display: flex; 10 - align-items: center; 11 - justify-content: space-between; 12 - gap: var(--spacing-sm); 13 - padding: 10px 20px; 14 - background: var(--bg-secondary); 15 - border-bottom: 1px solid var(--border); 16 - border-top-left-radius: var(--radius-lg); 17 - border-top-right-radius: var(--radius-lg); 18 - } 19 - 20 - .collection-context-inner { 21 - display: flex; 22 - align-items: center; 23 - gap: 8px; 24 - font-size: 0.8rem; 25 - color: var(--text-secondary); 26 - } 27 - 28 - .collection-context-avatar { 29 - width: 20px; 30 - height: 20px; 31 - border-radius: var(--radius-full); 32 - object-fit: cover; 33 - } 34 - 35 - .collection-context-text { 36 - display: flex; 37 - align-items: center; 38 - gap: 4px; 39 - flex-wrap: wrap; 40 - } 41 - 42 - .collection-context-author { 43 - font-weight: 600; 44 - color: var(--text-primary); 45 - text-decoration: none; 46 - } 47 - 48 - .collection-context-author:hover { 49 - text-decoration: underline; 50 - } 51 - 52 - .collection-context-link { 53 - display: inline-flex; 54 - align-items: center; 55 - gap: 5px; 56 - font-weight: 600; 57 - color: var(--accent); 58 - text-decoration: none; 59 - background: var(--accent-subtle); 60 - padding: 2px 8px; 61 - border-radius: var(--radius-sm); 62 - } 63 - 64 - .collection-context-link:hover { 65 - background: var(--accent); 66 - color: var(--bg-primary); 67 - } 68 - 69 - .collections-list { 70 - display: flex; 71 - flex-direction: column; 72 - gap: 12px; 73 - } 74 - 75 - .collections-list > * { 76 - background: var(--bg-card); 77 - border: 1px solid var(--border); 78 - border-radius: var(--radius-lg); 79 - } 80 - 81 - .collection-row { 82 - display: flex; 83 - align-items: center; 84 - transition: background 0.15s ease; 85 - } 86 - 87 - .collection-row:hover { 88 - background: var(--bg-secondary); 89 - } 90 - 91 - .collection-row-content { 92 - flex: 1; 93 - display: flex; 94 - align-items: center; 95 - gap: var(--spacing-md); 96 - padding: var(--spacing-md); 97 - text-decoration: none; 98 - min-width: 0; 99 - } 100 - 101 - .collection-row-icon { 102 - width: 40px; 103 - height: 40px; 104 - min-width: 40px; 105 - display: flex; 106 - align-items: center; 107 - justify-content: center; 108 - background: var(--bg-tertiary); 109 - color: var(--accent); 110 - border-radius: var(--radius-md); 111 - transition: all 0.15s ease; 112 - font-size: 1.1rem; 113 - } 114 - 115 - .collection-row:hover .collection-row-icon { 116 - background: var(--accent-subtle); 117 - } 118 - 119 - .collection-row-info { 120 - flex: 1; 121 - min-width: 0; 122 - display: flex; 123 - flex-direction: column; 124 - gap: 2px; 125 - } 126 - 127 - .collection-row-name { 128 - font-size: 0.9rem; 129 - font-weight: 600; 130 - color: var(--text-primary); 131 - white-space: nowrap; 132 - overflow: hidden; 133 - text-overflow: ellipsis; 134 - } 135 - 136 - .collection-row-desc { 137 - font-size: 0.8rem; 138 - color: var(--text-secondary); 139 - white-space: nowrap; 140 - overflow: hidden; 141 - text-overflow: ellipsis; 142 - } 143 - 144 - .collection-row-arrow { 145 - color: var(--text-tertiary); 146 - opacity: 0; 147 - transition: opacity 0.15s; 148 - } 149 - 150 - .collection-row:hover .collection-row-arrow { 151 - opacity: 1; 152 - } 153 - 154 - .collection-row-edit { 155 - padding: 8px; 156 - margin-right: var(--spacing-sm); 157 - color: var(--text-tertiary); 158 - background: transparent; 159 - border-radius: var(--radius-sm); 160 - transition: all 0.15s; 161 - opacity: 0; 162 - border: none; 163 - cursor: pointer; 164 - } 165 - 166 - .collection-row:hover .collection-row-edit { 167 - opacity: 1; 168 - } 169 - 170 - .collection-row-edit:hover { 171 - background: var(--bg-tertiary); 172 - color: var(--text-primary); 173 - } 174 - 175 - .collection-detail-header { 176 - display: flex; 177 - flex-direction: column; 178 - gap: var(--spacing-md); 179 - padding: var(--spacing-lg); 180 - background: var(--bg-secondary); 181 - border: 1px solid var(--border); 182 - border-radius: var(--radius-lg); 183 - margin-bottom: var(--spacing-lg); 184 - position: relative; 185 - } 186 - 187 - .collection-detail-icon { 188 - width: 56px; 189 - height: 56px; 190 - min-width: 56px; 191 - display: flex; 192 - align-items: center; 193 - justify-content: center; 194 - background: var(--bg-tertiary); 195 - color: var(--accent); 196 - border-radius: var(--radius-lg); 197 - font-size: 1.5rem; 198 - } 199 - 200 - .collection-detail-info { 201 - display: flex; 202 - flex-direction: column; 203 - gap: 6px; 204 - } 205 - 206 - .collection-detail-visibility { 207 - display: inline-flex; 208 - align-items: center; 209 - gap: 4px; 210 - font-size: 0.65rem; 211 - font-weight: 600; 212 - letter-spacing: 0.05em; 213 - text-transform: uppercase; 214 - color: var(--accent); 215 - padding: 2px 8px; 216 - background: var(--accent-subtle); 217 - border-radius: var(--radius-full); 218 - width: fit-content; 219 - } 220 - 221 - .collection-detail-title { 222 - font-family: var(--font-display); 223 - font-size: 1.5rem; 224 - font-weight: 700; 225 - color: var(--text-primary); 226 - line-height: 1.2; 227 - letter-spacing: -0.02em; 228 - } 229 - 230 - .collection-detail-desc { 231 - color: var(--text-secondary); 232 - font-size: 0.9rem; 233 - line-height: 1.5; 234 - } 235 - 236 - .collection-detail-stats { 237 - display: flex; 238 - align-items: center; 239 - gap: var(--spacing-md); 240 - font-size: 0.8rem; 241 - color: var(--text-tertiary); 242 - margin-top: var(--spacing-xs); 243 - } 244 - 245 - .collection-detail-actions { 246 - position: absolute; 247 - top: var(--spacing-md); 248 - right: var(--spacing-md); 249 - display: flex; 250 - gap: var(--spacing-xs); 251 - } 252 - 253 - .collection-detail-actions .annotation-action, 254 - .collection-detail-edit, 255 - .collection-detail-delete { 256 - padding: 6px; 257 - color: var(--text-tertiary); 258 - background: var(--bg-tertiary); 259 - border-radius: var(--radius-sm); 260 - transition: all 0.15s; 261 - border: none; 262 - cursor: pointer; 263 - } 264 - 265 - .collection-detail-actions .annotation-action:hover, 266 - .collection-detail-edit:hover { 267 - background: var(--bg-hover); 268 - color: var(--text-primary); 269 - } 270 - 271 - .collection-detail-delete:hover { 272 - background: rgba(255, 69, 58, 0.1); 273 - color: var(--error); 274 - } 275 - 276 - .collection-list-item { 277 - width: 100%; 278 - text-align: left; 279 - padding: 12px 14px; 280 - border-radius: var(--radius-md); 281 - background: var(--bg-secondary); 282 - border: 1px solid var(--border); 283 - color: var(--text-primary); 284 - transition: all 0.15s; 285 - display: flex; 286 - align-items: center; 287 - justify-content: space-between; 288 - cursor: pointer; 289 - margin-bottom: var(--spacing-sm); 290 - } 291 - 292 - .collection-list-item:hover { 293 - background: var(--bg-hover); 294 - border-color: var(--accent); 295 - } 296 - 297 - .collection-list-item:disabled { 298 - opacity: 0.5; 299 - cursor: not-allowed; 300 - } 301 - 302 - .collection-item-wrapper { 303 - position: relative; 304 - } 305 - 306 - .collection-item-remove { 307 - position: absolute; 308 - left: -40px; 309 - top: 20px; 310 - width: 28px; 311 - height: 28px; 312 - display: flex; 313 - align-items: center; 314 - justify-content: center; 315 - background: var(--bg-secondary); 316 - border: 1px solid var(--border); 317 - border-radius: var(--radius-sm); 318 - color: var(--text-tertiary); 319 - cursor: pointer; 320 - transition: all 0.15s ease; 321 - opacity: 0; 322 - } 323 - 324 - .collection-item-wrapper:hover .collection-item-remove { 325 - opacity: 1; 326 - } 327 - 328 - .collection-item-remove:hover { 329 - background: rgba(255, 69, 58, 0.1); 330 - border-color: rgba(255, 69, 58, 0.3); 331 - color: var(--error); 332 - } 333 - 334 - .collection-item-wrapper .card, 335 - .collection-feed-item .card { 336 - background: transparent !important; 337 - border: none !important; 338 - box-shadow: none; 339 - border-radius: 0; 340 - }
-445
web/src/css/feed.css
··· 1 - .feed-container { 2 - /* background: var(--bg-elevated); */ 3 - /* border: 1px solid var(--border-hover); */ 4 - /* border-radius: var(--radius-xl); */ 5 - overflow: visible; 6 - /* padding: 8px; */ 7 - position: relative; 8 - } 9 - 10 - .feed { 11 - display: flex; 12 - flex-direction: column; 13 - gap: 12px; 14 - width: 100%; 15 - overflow: visible; 16 - border-radius: var(--radius-lg); 17 - position: relative; 18 - } 19 - 20 - .feed-load-more { 21 - display: inline-flex; 22 - align-items: center; 23 - justify-content: center; 24 - padding: 10px 24px; 25 - background: var(--bg-tertiary); 26 - border: none; 27 - border-radius: var(--radius-md); 28 - color: var(--text-secondary); 29 - font-weight: 500; 30 - font-size: 0.9rem; 31 - cursor: pointer; 32 - transition: all 0.15s ease; 33 - } 34 - 35 - .feed-load-more:hover { 36 - background: var(--bg-hover); 37 - color: var(--text-primary); 38 - } 39 - 40 - .feed-load-more:disabled { 41 - opacity: 0.6; 42 - cursor: not-allowed; 43 - } 44 - 45 - .feed > * { 46 - background: var(--bg-card); 47 - border: 1px solid var(--border); 48 - border-radius: var(--radius-lg); 49 - position: relative; 50 - overflow: visible; 51 - } 52 - 53 - .feed > *:last-child { 54 - border-bottom: 1px solid var(--border); 55 - } 56 - 57 - .feed > *.has-open-menu, 58 - .feed > *:focus-within { 59 - z-index: 200 !important; 60 - overflow: visible !important; 61 - } 62 - 63 - .feed-page { 64 - animation: fadeIn 0.3s ease-out; 65 - } 66 - 67 - @keyframes fadeIn { 68 - from { 69 - opacity: 0; 70 - } 71 - 72 - to { 73 - opacity: 1; 74 - } 75 - } 76 - 77 - .feed-header { 78 - display: flex; 79 - align-items: center; 80 - justify-content: space-between; 81 - margin-bottom: 20px; 82 - } 83 - 84 - .feed-title { 85 - font-family: var(--font-display); 86 - font-size: 1.25rem; 87 - font-weight: 600; 88 - letter-spacing: -0.02em; 89 - } 90 - 91 - .feed-filters { 92 - display: flex; 93 - gap: 4px; 94 - margin-bottom: 20px; 95 - background: transparent; 96 - padding: 0; 97 - border: none; 98 - flex-wrap: wrap; 99 - } 100 - 101 - .filter-tab { 102 - padding: 8px 14px; 103 - font-size: 0.875rem; 104 - font-weight: 500; 105 - color: var(--text-tertiary); 106 - background: transparent; 107 - border: none; 108 - border-radius: var(--radius-md); 109 - cursor: pointer; 110 - transition: all 0.15s ease; 111 - } 112 - 113 - .filter-tab:hover { 114 - color: var(--text-secondary); 115 - background: var(--bg-tertiary); 116 - } 117 - 118 - .filter-tab.active { 119 - color: var(--text-primary); 120 - background: var(--bg-tertiary); 121 - } 122 - 123 - .filter-pill { 124 - padding: 8px 14px; 125 - font-size: 0.8rem; 126 - font-weight: 600; 127 - color: var(--text-secondary); 128 - background: var(--bg-tertiary); 129 - border: none; 130 - border-radius: var(--radius-full); 131 - cursor: pointer; 132 - transition: all 0.15s; 133 - } 134 - 135 - .filter-pill:hover { 136 - background: var(--bg-hover); 137 - color: var(--text-primary); 138 - } 139 - 140 - .filter-pill.active { 141 - background: var(--accent); 142 - color: var(--bg-primary); 143 - } 144 - 145 - .page-header { 146 - margin-bottom: 28px; 147 - } 148 - 149 - .page-title { 150 - font-family: var(--font-display); 151 - font-size: 2rem; 152 - font-weight: 700; 153 - margin-bottom: 8px; 154 - letter-spacing: -0.02em; 155 - color: var(--text-primary); 156 - } 157 - 158 - .page-description { 159 - color: var(--text-secondary); 160 - font-size: 1.1rem; 161 - line-height: 1.5; 162 - } 163 - 164 - .url-input-wrapper { 165 - margin-bottom: var(--spacing-lg); 166 - position: relative; 167 - } 168 - 169 - .url-input-container { 170 - display: flex; 171 - gap: var(--spacing-sm); 172 - } 173 - 174 - .url-input { 175 - width: 100%; 176 - padding: 12px 16px; 177 - background: var(--bg-secondary); 178 - border: 1px solid var(--border); 179 - border-radius: var(--radius-md); 180 - color: var(--text-primary); 181 - font-size: 0.9rem; 182 - transition: all 0.15s ease; 183 - } 184 - 185 - .url-input:focus { 186 - outline: none; 187 - border-color: var(--accent); 188 - box-shadow: 0 0 0 3px var(--accent-subtle); 189 - } 190 - 191 - .url-input::placeholder { 192 - color: var(--text-tertiary); 193 - } 194 - 195 - .url-results-header { 196 - display: flex; 197 - align-items: center; 198 - justify-content: space-between; 199 - margin-bottom: var(--spacing-md); 200 - } 201 - 202 - .back-link { 203 - display: inline-flex; 204 - align-items: center; 205 - gap: 6px; 206 - color: var(--text-secondary); 207 - font-size: 0.8rem; 208 - font-weight: 500; 209 - text-decoration: none; 210 - margin-bottom: var(--spacing-lg); 211 - padding: 6px 12px; 212 - background: var(--bg-tertiary); 213 - border-radius: var(--radius-sm); 214 - transition: all 0.15s; 215 - } 216 - 217 - .back-link:hover { 218 - background: var(--bg-hover); 219 - color: var(--text-primary); 220 - } 221 - 222 - .url-target-info { 223 - display: flex; 224 - flex-direction: column; 225 - gap: 4px; 226 - padding: 12px 16px; 227 - background: var(--bg-secondary); 228 - border: 1px solid var(--border); 229 - border-radius: var(--radius-md); 230 - margin-bottom: var(--spacing-lg); 231 - } 232 - 233 - .url-target-label { 234 - font-size: 0.65rem; 235 - text-transform: uppercase; 236 - letter-spacing: 0.05em; 237 - font-weight: 600; 238 - color: var(--text-tertiary); 239 - } 240 - 241 - .url-target-link { 242 - color: var(--accent); 243 - font-size: 0.85rem; 244 - font-weight: 500; 245 - text-decoration: none; 246 - word-break: break-all; 247 - line-height: 1.4; 248 - } 249 - 250 - .url-target-link:hover { 251 - text-decoration: underline; 252 - } 253 - 254 - .share-notes-banner { 255 - display: flex; 256 - align-items: center; 257 - justify-content: space-between; 258 - gap: var(--spacing-md); 259 - padding: 12px 16px; 260 - background: var(--bg-secondary); 261 - border: 1px solid var(--border); 262 - border-radius: var(--radius-md); 263 - margin-bottom: var(--spacing-md); 264 - } 265 - 266 - .share-notes-info { 267 - display: flex; 268 - align-items: center; 269 - gap: var(--spacing-sm); 270 - color: var(--text-primary); 271 - font-size: 0.85rem; 272 - font-weight: 500; 273 - } 274 - 275 - .share-notes-actions { 276 - display: flex; 277 - gap: var(--spacing-sm); 278 - } 279 - 280 - .empty-state { 281 - display: flex; 282 - flex-direction: column; 283 - align-items: center; 284 - justify-content: center; 285 - padding: 48px 24px; 286 - text-align: center; 287 - } 288 - 289 - .empty-state-icon { 290 - width: 56px; 291 - height: 56px; 292 - display: flex; 293 - align-items: center; 294 - justify-content: center; 295 - background: var(--bg-tertiary); 296 - border-radius: var(--radius-lg); 297 - color: var(--text-tertiary); 298 - margin-bottom: 16px; 299 - } 300 - 301 - .empty-state-title { 302 - font-size: 1.1rem; 303 - font-weight: 600; 304 - color: var(--text-primary); 305 - margin-bottom: 6px; 306 - } 307 - 308 - .empty-state-text { 309 - font-size: 0.9rem; 310 - color: var(--text-secondary); 311 - max-width: 300px; 312 - line-height: 1.5; 313 - } 314 - 315 - @media (max-width: 640px) { 316 - .feed-filters { 317 - gap: 4px; 318 - } 319 - 320 - .filter-tab, 321 - .filter-pill { 322 - padding: 6px 10px; 323 - font-size: 0.75rem; 324 - } 325 - } 326 - 327 - .feed-controls { 328 - display: flex; 329 - flex-direction: column; 330 - gap: var(--spacing-sm); 331 - margin-bottom: var(--spacing-lg); 332 - } 333 - 334 - .active-filter-banner { 335 - display: inline-flex; 336 - align-items: center; 337 - gap: var(--spacing-sm); 338 - padding: 6px 10px 6px 12px; 339 - background: var(--accent-subtle); 340 - border: 1px solid var(--accent); 341 - border-radius: var(--radius-full); 342 - font-size: 0.8rem; 343 - color: var(--accent); 344 - margin-bottom: var(--spacing-md); 345 - width: fit-content; 346 - } 347 - 348 - .active-filter-banner strong { 349 - color: var(--accent-text); 350 - } 351 - 352 - .active-filter-clear { 353 - display: flex; 354 - align-items: center; 355 - justify-content: center; 356 - width: 20px; 357 - height: 20px; 358 - background: transparent; 359 - border: none; 360 - border-radius: var(--radius-full); 361 - color: var(--accent); 362 - cursor: pointer; 363 - transition: all 0.15s; 364 - } 365 - 366 - .active-filter-clear:hover { 367 - background: var(--accent); 368 - color: white; 369 - } 370 - 371 - .keyboard-hint { 372 - display: none; 373 - align-items: center; 374 - gap: 4px; 375 - font-size: 0.7rem; 376 - color: var(--text-tertiary); 377 - margin-left: auto; 378 - } 379 - 380 - @media (min-width: 768px) { 381 - .keyboard-hint { 382 - display: flex; 383 - } 384 - } 385 - 386 - .kbd { 387 - display: inline-flex; 388 - align-items: center; 389 - justify-content: center; 390 - min-width: 20px; 391 - height: 20px; 392 - padding: 0 6px; 393 - background: var(--bg-tertiary); 394 - border: 1px solid var(--border); 395 - border-radius: var(--radius-xs); 396 - font-size: 0.65rem; 397 - font-family: var(--font-mono); 398 - color: var(--text-secondary); 399 - } 400 - 401 - .back-to-top-btn { 402 - position: fixed; 403 - bottom: 24px; 404 - right: 24px; 405 - width: 44px; 406 - height: 44px; 407 - border-radius: var(--radius-full); 408 - background: var(--bg-tertiary); 409 - border: 1px solid var(--border); 410 - color: var(--text-secondary); 411 - display: flex; 412 - align-items: center; 413 - justify-content: center; 414 - cursor: pointer; 415 - box-shadow: var(--shadow-md); 416 - transition: all 0.2s ease; 417 - z-index: 9999; 418 - opacity: 0; 419 - visibility: hidden; 420 - transform: translateY(10px); 421 - } 422 - 423 - .back-to-top-btn.has-sidebar { 424 - right: 24px; 425 - } 426 - 427 - @media (min-width: 1025px) { 428 - .back-to-top-btn.has-sidebar { 429 - right: 320px; 430 - } 431 - } 432 - 433 - .back-to-top-btn.visible { 434 - opacity: 1; 435 - visibility: visible; 436 - transform: translateY(0); 437 - } 438 - 439 - .back-to-top-btn:hover { 440 - background: var(--bg-hover); 441 - color: var(--text-primary); 442 - border-color: var(--accent); 443 - transform: translateY(-2px); 444 - box-shadow: var(--shadow-lg); 445 - }
-925
web/src/css/landing.css
··· 1 - .landing-page { 2 - min-height: 100vh; 3 - background: var(--bg-primary); 4 - } 5 - 6 - .landing-nav { 7 - display: flex; 8 - justify-content: space-between; 9 - align-items: center; 10 - padding: 16px 32px; 11 - max-width: 1200px; 12 - margin: 0 auto; 13 - } 14 - 15 - .landing-logo { 16 - display: flex; 17 - align-items: center; 18 - gap: 10px; 19 - text-decoration: none; 20 - color: var(--text-primary); 21 - font-weight: 600; 22 - font-size: 1.1rem; 23 - } 24 - 25 - .landing-logo img { 26 - width: 28px; 27 - height: 28px; 28 - } 29 - 30 - .landing-nav-links { 31 - display: flex; 32 - align-items: center; 33 - gap: 24px; 34 - } 35 - 36 - .landing-nav-links a:not(.btn) { 37 - color: var(--text-secondary); 38 - text-decoration: none; 39 - font-size: 0.9rem; 40 - transition: color 0.15s; 41 - } 42 - 43 - .landing-nav-links a:not(.btn):hover { 44 - color: var(--text-primary); 45 - } 46 - 47 - .landing-hero { 48 - padding: 80px 32px 40px; 49 - max-width: 800px; 50 - margin: 0 auto; 51 - text-align: center; 52 - } 53 - 54 - .landing-hero-content { 55 - display: flex; 56 - flex-direction: column; 57 - align-items: center; 58 - gap: 24px; 59 - } 60 - 61 - .landing-badge { 62 - display: inline-flex; 63 - align-items: center; 64 - gap: 8px; 65 - font-size: 0.8rem; 66 - font-weight: 500; 67 - color: var(--accent); 68 - background: var(--accent-subtle); 69 - padding: 6px 14px; 70 - border-radius: var(--radius-full); 71 - } 72 - 73 - .landing-title { 74 - font-size: 3.5rem; 75 - font-weight: 700; 76 - line-height: 1.1; 77 - letter-spacing: -0.03em; 78 - color: var(--text-primary); 79 - margin: 0; 80 - } 81 - 82 - .landing-title-accent { 83 - color: var(--accent); 84 - } 85 - 86 - .landing-subtitle { 87 - font-size: 1.2rem; 88 - line-height: 1.7; 89 - color: var(--text-secondary); 90 - max-width: 580px; 91 - margin: 0; 92 - } 93 - 94 - .landing-cta { 95 - display: flex; 96 - gap: 12px; 97 - flex-wrap: wrap; 98 - justify-content: center; 99 - margin-top: 8px; 100 - } 101 - 102 - .btn-lg { 103 - padding: 10px 20px; 104 - font-size: 0.95rem; 105 - } 106 - 107 - .landing-browsers { 108 - font-size: 0.85rem; 109 - color: var(--text-tertiary); 110 - margin: 0; 111 - } 112 - 113 - .landing-browsers a { 114 - color: var(--text-secondary); 115 - text-decoration: underline; 116 - text-underline-offset: 2px; 117 - } 118 - 119 - .landing-browsers a:hover { 120 - color: var(--text-primary); 121 - } 122 - 123 - .landing-demo { 124 - padding: 40px 32px 80px; 125 - max-width: 1100px; 126 - margin: 0 auto; 127 - } 128 - 129 - .demo-window { 130 - background: var(--bg-secondary); 131 - border: 1px solid var(--border); 132 - border-radius: var(--radius-xl); 133 - overflow: hidden; 134 - box-shadow: var(--shadow-lg); 135 - } 136 - 137 - .demo-browser-bar { 138 - display: flex; 139 - align-items: center; 140 - gap: 16px; 141 - padding: 12px 16px; 142 - background: var(--bg-tertiary); 143 - border-bottom: 1px solid var(--border); 144 - } 145 - 146 - .demo-browser-dots { 147 - display: flex; 148 - gap: 6px; 149 - } 150 - 151 - .demo-browser-dots span { 152 - width: 12px; 153 - height: 12px; 154 - border-radius: 50%; 155 - background: var(--border); 156 - } 157 - 158 - .demo-browser-url { 159 - flex: 1; 160 - background: var(--bg-primary); 161 - border-radius: var(--radius-md); 162 - padding: 8px 14px; 163 - font-size: 0.8rem; 164 - color: var(--text-tertiary); 165 - } 166 - 167 - .demo-content { 168 - display: grid; 169 - grid-template-columns: 1fr 340px; 170 - min-height: 380px; 171 - } 172 - 173 - .demo-article { 174 - padding: 32px; 175 - border-right: 1px solid var(--border); 176 - } 177 - 178 - .demo-text { 179 - font-size: 1.05rem; 180 - line-height: 1.9; 181 - color: var(--text-primary); 182 - margin: 0 0 20px 0; 183 - } 184 - 185 - .demo-text:last-child { 186 - margin-bottom: 0; 187 - } 188 - 189 - .demo-highlight { 190 - background-color: transparent; 191 - color: inherit; 192 - border-bottom: 2px solid var(--accent); 193 - } 194 - 195 - .demo-sidebar { 196 - padding: 0; 197 - background: var(--bg-primary); 198 - display: flex; 199 - flex-direction: column; 200 - gap: 0; 201 - overflow-y: auto; 202 - font-family: 203 - "IBM Plex Sans", 204 - -apple-system, 205 - BlinkMacSystemFont, 206 - sans-serif; 207 - } 208 - 209 - .demo-sidebar-header { 210 - display: flex; 211 - align-items: center; 212 - justify-content: space-between; 213 - padding: 14px 16px; 214 - border-bottom: 1px solid var(--border); 215 - background: var(--bg-primary); 216 - } 217 - 218 - .demo-logo-section { 219 - display: flex; 220 - align-items: center; 221 - gap: 10px; 222 - } 223 - 224 - .demo-logo-icon { 225 - color: var(--accent); 226 - display: flex; 227 - align-items: center; 228 - } 229 - 230 - .demo-logo-text { 231 - font-weight: 600; 232 - font-size: 15px; 233 - color: var(--text-primary); 234 - letter-spacing: -0.02em; 235 - } 236 - 237 - .demo-user-section { 238 - display: flex; 239 - align-items: center; 240 - gap: 8px; 241 - } 242 - 243 - .demo-user-handle { 244 - font-size: 12px; 245 - color: var(--text-secondary); 246 - background: var(--bg-tertiary); 247 - padding: 4px 10px; 248 - border-radius: 9999px; 249 - } 250 - 251 - .demo-user-avatar { 252 - width: 24px; 253 - height: 24px; 254 - border-radius: 50%; 255 - background: var(--bg-hover); 256 - color: var(--text-secondary); 257 - display: flex; 258 - align-items: center; 259 - justify-content: center; 260 - font-size: 12px; 261 - font-weight: 600; 262 - } 263 - 264 - .demo-page-info { 265 - display: flex; 266 - align-items: center; 267 - gap: 8px; 268 - padding: 10px 16px; 269 - background: var(--bg-primary); 270 - border-bottom: 1px solid var(--border); 271 - font-size: 12px; 272 - color: var(--text-tertiary); 273 - } 274 - 275 - .demo-annotations-list { 276 - display: flex; 277 - flex-direction: column; 278 - gap: 1px; 279 - background: var(--border); 280 - } 281 - 282 - .demo-annotation { 283 - background: var(--bg-primary); 284 - border: none; 285 - border-radius: 0; 286 - padding: 14px 16px; 287 - } 288 - 289 - .demo-annotation-secondary { 290 - opacity: 1; 291 - } 292 - 293 - .demo-annotation-header { 294 - display: flex; 295 - align-items: center; 296 - gap: 10px; 297 - margin-bottom: 8px; 298 - } 299 - 300 - .demo-avatar { 301 - width: 26px; 302 - height: 26px; 303 - border-radius: 50%; 304 - background: var(--accent); 305 - color: var(--bg-primary); 306 - display: flex; 307 - align-items: center; 308 - justify-content: center; 309 - font-size: 10px; 310 - font-weight: 600; 311 - } 312 - 313 - .demo-meta { 314 - display: flex; 315 - flex-direction: column; 316 - gap: 0; 317 - } 318 - 319 - .demo-author { 320 - font-size: 12px; 321 - font-weight: 600; 322 - color: var(--text-primary); 323 - } 324 - 325 - .demo-time { 326 - font-size: 11px; 327 - color: var(--text-tertiary); 328 - } 329 - 330 - .demo-quote { 331 - font-size: 12px; 332 - font-style: italic; 333 - color: var(--text-secondary); 334 - padding: 8px 12px; 335 - border-left: 2px solid var(--accent); 336 - margin: 0 0 8px 0; 337 - background: var(--accent-subtle); 338 - border-radius: 0 6px 6px 0; 339 - line-height: 1.5; 340 - } 341 - 342 - .demo-comment { 343 - font-size: 13px; 344 - line-height: 1.5; 345 - color: var(--text-primary); 346 - margin: 0 0 12px 0; 347 - } 348 - 349 - .demo-jump-btn { 350 - background: transparent; 351 - border: none; 352 - padding: 0; 353 - color: var(--accent); 354 - font-size: 11px; 355 - font-weight: 500; 356 - cursor: pointer; 357 - display: inline-flex; 358 - align-items: center; 359 - margin-top: 4px; 360 - } 361 - 362 - .demo-jump-btn:hover { 363 - text-decoration: underline; 364 - text-underline-offset: 2px; 365 - } 366 - 367 - .landing-section { 368 - padding: 80px 32px; 369 - max-width: 1000px; 370 - margin: 0 auto; 371 - } 372 - 373 - .landing-section-alt { 374 - background: var(--bg-secondary); 375 - max-width: none; 376 - } 377 - 378 - .landing-section-alt > * { 379 - max-width: 1000px; 380 - margin-left: auto; 381 - margin-right: auto; 382 - } 383 - 384 - .landing-section-title { 385 - font-size: 2rem; 386 - font-weight: 700; 387 - text-align: center; 388 - margin: 0 0 48px 0; 389 - color: var(--text-primary); 390 - } 391 - 392 - .landing-steps { 393 - display: flex; 394 - flex-direction: column; 395 - gap: 32px; 396 - } 397 - 398 - .landing-step { 399 - display: flex; 400 - gap: 24px; 401 - align-items: flex-start; 402 - } 403 - 404 - .landing-step-num { 405 - width: 40px; 406 - height: 40px; 407 - border-radius: 50%; 408 - background: var(--accent); 409 - color: white; 410 - display: flex; 411 - align-items: center; 412 - justify-content: center; 413 - font-weight: 700; 414 - font-size: 1.1rem; 415 - flex-shrink: 0; 416 - } 417 - 418 - .landing-step-content h3 { 419 - font-size: 1.15rem; 420 - font-weight: 600; 421 - margin: 0 0 8px 0; 422 - color: var(--text-primary); 423 - } 424 - 425 - .landing-step-content p { 426 - font-size: 1rem; 427 - color: var(--text-secondary); 428 - margin: 0; 429 - line-height: 1.6; 430 - } 431 - 432 - .landing-features-grid { 433 - display: grid; 434 - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); 435 - gap: 32px; 436 - } 437 - 438 - .landing-feature { 439 - text-align: center; 440 - padding: 24px 16px; 441 - } 442 - 443 - .landing-feature-icon { 444 - width: 52px; 445 - height: 52px; 446 - border-radius: var(--radius-lg); 447 - background: var(--accent-subtle); 448 - color: var(--accent); 449 - display: flex; 450 - align-items: center; 451 - justify-content: center; 452 - margin: 0 auto 16px; 453 - } 454 - 455 - .landing-feature h3 { 456 - font-size: 1.05rem; 457 - font-weight: 600; 458 - margin: 0 0 8px 0; 459 - color: var(--text-primary); 460 - } 461 - 462 - .landing-feature p { 463 - font-size: 0.9rem; 464 - color: var(--text-secondary); 465 - margin: 0; 466 - line-height: 1.6; 467 - } 468 - 469 - .landing-protocol { 470 - background: var(--bg-secondary); 471 - max-width: none; 472 - border-top: 1px solid var(--border); 473 - border-bottom: 1px solid var(--border); 474 - } 475 - 476 - .landing-protocol-grid { 477 - display: grid; 478 - grid-template-columns: 1fr 1fr; 479 - gap: 64px; 480 - align-items: center; 481 - max-width: 1000px; 482 - margin: 0 auto; 483 - } 484 - 485 - .landing-protocol-main h2 { 486 - font-size: 1.75rem; 487 - font-weight: 700; 488 - margin: 0 0 16px 0; 489 - color: var(--text-primary); 490 - } 491 - 492 - .landing-protocol-main p { 493 - font-size: 1rem; 494 - color: var(--text-secondary); 495 - margin: 0 0 16px 0; 496 - line-height: 1.7; 497 - } 498 - 499 - .landing-protocol-main a { 500 - color: var(--accent); 501 - text-decoration: underline; 502 - text-underline-offset: 2px; 503 - } 504 - 505 - .landing-protocol-features { 506 - display: flex; 507 - flex-direction: column; 508 - gap: 20px; 509 - } 510 - 511 - .landing-protocol-item { 512 - display: flex; 513 - gap: 16px; 514 - align-items: flex-start; 515 - color: var(--accent); 516 - } 517 - 518 - .landing-protocol-item div { 519 - display: flex; 520 - flex-direction: column; 521 - } 522 - 523 - .landing-protocol-item strong { 524 - font-size: 0.95rem; 525 - font-weight: 600; 526 - color: var(--text-primary); 527 - } 528 - 529 - .landing-protocol-item span { 530 - font-size: 0.85rem; 531 - color: var(--text-tertiary); 532 - } 533 - 534 - .landing-final-cta { 535 - text-align: center; 536 - } 537 - 538 - .landing-final-cta h2 { 539 - font-size: 2rem; 540 - font-weight: 700; 541 - margin: 0 0 12px 0; 542 - color: var(--text-primary); 543 - } 544 - 545 - .landing-final-cta p { 546 - font-size: 1.1rem; 547 - color: var(--text-secondary); 548 - margin: 0 0 28px 0; 549 - } 550 - 551 - .landing-footer { 552 - border-top: 1px solid var(--border); 553 - padding: 48px 32px 32px; 554 - } 555 - 556 - .landing-footer-grid { 557 - display: flex; 558 - justify-content: space-between; 559 - max-width: 1000px; 560 - margin: 0 auto 40px; 561 - } 562 - 563 - .landing-footer-brand { 564 - max-width: 280px; 565 - } 566 - 567 - .landing-footer-brand p { 568 - font-size: 0.9rem; 569 - color: var(--text-tertiary); 570 - margin: 12px 0 0 0; 571 - } 572 - 573 - .landing-footer-links { 574 - display: flex; 575 - gap: 64px; 576 - } 577 - 578 - .landing-footer-col { 579 - display: flex; 580 - flex-direction: column; 581 - gap: 10px; 582 - } 583 - 584 - .landing-footer-col h4 { 585 - font-size: 0.75rem; 586 - font-weight: 600; 587 - text-transform: uppercase; 588 - letter-spacing: 0.08em; 589 - color: var(--text-tertiary); 590 - margin: 0 0 4px 0; 591 - } 592 - 593 - .landing-footer-col a { 594 - font-size: 0.9rem; 595 - color: var(--text-secondary); 596 - text-decoration: none; 597 - } 598 - 599 - .landing-footer-col a:hover { 600 - color: var(--text-primary); 601 - } 602 - 603 - .landing-footer-bottom { 604 - text-align: center; 605 - padding-top: 24px; 606 - border-top: 1px solid var(--border); 607 - max-width: 1000px; 608 - margin: 0 auto; 609 - } 610 - 611 - .landing-footer-bottom p { 612 - font-size: 0.85rem; 613 - color: var(--text-tertiary); 614 - margin: 0; 615 - } 616 - 617 - @media (max-width: 900px) { 618 - .demo-content { 619 - grid-template-columns: 1fr; 620 - } 621 - 622 - .demo-article { 623 - border-right: none; 624 - border-bottom: 1px solid var(--border); 625 - } 626 - 627 - .demo-sidebar { 628 - max-height: 340px; 629 - } 630 - 631 - .landing-protocol-grid { 632 - grid-template-columns: 1fr; 633 - gap: 40px; 634 - } 635 - } 636 - 637 - @media (max-width: 768px) { 638 - .landing-nav { 639 - padding: 16px 20px; 640 - } 641 - 642 - .landing-nav-links a:not(.btn) { 643 - display: none; 644 - } 645 - 646 - .landing-hero { 647 - padding: 60px 20px 30px; 648 - } 649 - 650 - .landing-title { 651 - font-size: 2.5rem; 652 - } 653 - 654 - .landing-subtitle { 655 - font-size: 1.1rem; 656 - } 657 - 658 - .landing-cta { 659 - flex-direction: column; 660 - width: 100%; 661 - } 662 - 663 - .landing-cta .btn { 664 - width: 100%; 665 - justify-content: center; 666 - } 667 - 668 - .landing-demo { 669 - padding: 30px 16px 60px; 670 - } 671 - 672 - .demo-browser-bar { 673 - padding: 10px 12px; 674 - } 675 - 676 - .demo-browser-dots { 677 - display: none; 678 - } 679 - 680 - .demo-article { 681 - padding: 20px; 682 - } 683 - 684 - .demo-text { 685 - font-size: 0.95rem; 686 - } 687 - 688 - .demo-sidebar { 689 - padding: 16px; 690 - } 691 - 692 - .landing-section { 693 - padding: 60px 20px; 694 - } 695 - 696 - .landing-section-title { 697 - font-size: 1.5rem; 698 - margin-bottom: 32px; 699 - } 700 - 701 - .landing-step { 702 - gap: 16px; 703 - } 704 - 705 - .landing-step-num { 706 - width: 32px; 707 - height: 32px; 708 - font-size: 0.95rem; 709 - } 710 - 711 - .landing-features-grid { 712 - grid-template-columns: 1fr; 713 - gap: 24px; 714 - } 715 - 716 - .landing-feature { 717 - text-align: left; 718 - display: flex; 719 - gap: 16px; 720 - padding: 16px 0; 721 - } 722 - 723 - .landing-feature-icon { 724 - margin: 0; 725 - width: 44px; 726 - height: 44px; 727 - flex-shrink: 0; 728 - } 729 - 730 - .landing-protocol-main h2 { 731 - font-size: 1.5rem; 732 - } 733 - 734 - .landing-footer { 735 - padding: 40px 20px 24px; 736 - } 737 - 738 - .landing-footer-grid { 739 - flex-direction: column; 740 - gap: 40px; 741 - } 742 - 743 - .landing-footer-links { 744 - flex-wrap: wrap; 745 - gap: 32px; 746 - } 747 - } 748 - 749 - .demo-hover-indicator { 750 - position: absolute; 751 - display: flex; 752 - align-items: center; 753 - z-index: 100; 754 - pointer-events: none; 755 - background: transparent; 756 - opacity: 0; 757 - transform: scale(0.8); 758 - transition: 759 - opacity 0.15s ease-out, 760 - transform 0.15s ease-out; 761 - } 762 - 763 - .demo-hover-indicator.visible { 764 - opacity: 1; 765 - transform: scale(1); 766 - } 767 - 768 - .demo-hover-avatar { 769 - width: 28px; 770 - height: 28px; 771 - border-radius: 50%; 772 - object-fit: cover; 773 - border: 2px solid var(--bg-primary); 774 - margin-left: -10px; 775 - background: var(--bg-elevated); 776 - } 777 - 778 - .demo-hover-avatar:first-child { 779 - margin-left: 0; 780 - } 781 - 782 - .demo-hover-avatar-fallback { 783 - width: 28px; 784 - height: 28px; 785 - border-radius: 50%; 786 - background: #6366f1; 787 - color: white; 788 - display: flex; 789 - align-items: center; 790 - justify-content: center; 791 - font-size: 12px; 792 - font-weight: 600; 793 - font-family: -apple-system, sans-serif; 794 - border: 2px solid var(--bg-primary); 795 - margin-left: -10px; 796 - } 797 - 798 - .demo-hover-avatar-fallback:first-child { 799 - margin-left: 0; 800 - } 801 - 802 - @keyframes demo-popover-in { 803 - from { 804 - opacity: 0; 805 - transform: translateY(-4px); 806 - } 807 - 808 - to { 809 - opacity: 1; 810 - transform: translateY(0); 811 - } 812 - } 813 - 814 - .demo-popover { 815 - position: absolute; 816 - width: 300px; 817 - background: var(--bg-card); 818 - border: 1px solid var(--border); 819 - border-radius: 12px; 820 - padding: 0; 821 - box-shadow: var(--shadow-lg); 822 - display: flex; 823 - flex-direction: column; 824 - z-index: 200; 825 - font-family: inherit; 826 - color: var(--text-primary); 827 - opacity: 0; 828 - animation: demo-popover-in 0.15s forwards; 829 - max-height: 400px; 830 - overflow: hidden; 831 - } 832 - 833 - .demo-popover-header { 834 - padding: 10px 14px; 835 - border-bottom: 1px solid var(--border); 836 - display: flex; 837 - justify-content: space-between; 838 - align-items: center; 839 - background: var(--bg-primary); 840 - border-radius: 12px 12px 0 0; 841 - font-weight: 500; 842 - font-size: 11px; 843 - color: var(--text-tertiary); 844 - text-transform: uppercase; 845 - letter-spacing: 0.5px; 846 - } 847 - 848 - .demo-popover-close { 849 - background: none; 850 - border: none; 851 - color: var(--text-tertiary); 852 - cursor: pointer; 853 - padding: 2px; 854 - font-size: 16px; 855 - line-height: 1; 856 - opacity: 0.6; 857 - transition: opacity 0.15s; 858 - } 859 - 860 - .demo-popover-close:hover { 861 - opacity: 1; 862 - } 863 - 864 - .demo-popover-scroll-area { 865 - overflow-y: auto; 866 - max-height: 340px; 867 - } 868 - 869 - .demo-comment-item { 870 - padding: 12px 14px; 871 - border-bottom: 1px solid var(--border); 872 - } 873 - 874 - .demo-comment-item:last-child { 875 - border-bottom: none; 876 - } 877 - 878 - .demo-comment-header { 879 - display: flex; 880 - align-items: center; 881 - gap: 8px; 882 - margin-bottom: 6px; 883 - } 884 - 885 - .demo-comment-avatar { 886 - width: 22px; 887 - height: 22px; 888 - border-radius: 50%; 889 - object-fit: cover; 890 - background: var(--accent); 891 - } 892 - 893 - .demo-comment-handle { 894 - font-size: 12px; 895 - font-weight: 600; 896 - color: var(--text-primary); 897 - } 898 - 899 - .demo-comment-text { 900 - font-size: 13px; 901 - line-height: 1.5; 902 - color: var(--text-primary); 903 - margin-bottom: 8px; 904 - } 905 - 906 - .demo-comment-actions { 907 - display: flex; 908 - gap: 8px; 909 - } 910 - 911 - .demo-comment-action-btn { 912 - background: none; 913 - border: none; 914 - padding: 4px 8px; 915 - color: var(--text-tertiary); 916 - font-size: 11px; 917 - cursor: pointer; 918 - border-radius: 4px; 919 - transition: all 0.15s; 920 - } 921 - 922 - .demo-comment-action-btn:hover { 923 - background: var(--bg-hover); 924 - color: var(--text-secondary); 925 - }
-737
web/src/css/layout.css
··· 1 - .app { 2 - min-height: 100vh; 3 - background: var(--bg-primary); 4 - } 5 - 6 - .app-layout { 7 - display: grid; 8 - grid-template-columns: 240px 1fr 280px; 9 - width: 100%; 10 - height: 100vh; 11 - overflow: hidden; 12 - } 13 - 14 - @media (max-width: 1024px) { 15 - .app-layout { 16 - grid-template-columns: 220px 1fr; 17 - } 18 - } 19 - 20 - @media (max-width: 768px) { 21 - .app-layout { 22 - grid-template-columns: 1fr; 23 - height: auto; 24 - overflow: visible; 25 - } 26 - } 27 - 28 - .top-nav { 29 - display: none; 30 - position: sticky; 31 - top: 0; 32 - z-index: 100; 33 - background: var(--nav-bg); 34 - backdrop-filter: blur(12px); 35 - -webkit-backdrop-filter: blur(12px); 36 - border-bottom: 1px solid var(--border); 37 - } 38 - 39 - @media (max-width: 768px) { 40 - .top-nav { 41 - display: block; 42 - } 43 - } 44 - 45 - .layout-mode-topnav .app-layout { 46 - grid-template-columns: 1fr; 47 - max-width: 100%; 48 - height: auto; 49 - overflow: visible; 50 - } 51 - 52 - .layout-mode-topnav .top-nav { 53 - display: block; 54 - } 55 - 56 - .layout-mode-topnav .main-content { 57 - height: auto; 58 - overflow: visible; 59 - } 60 - 61 - .layout-mode-topnav .main-content > * { 62 - max-width: 1100px; 63 - margin: 0 auto; 64 - padding: 32px 32px 80px; 65 - } 66 - 67 - @media (min-width: 1400px) { 68 - .layout-mode-topnav .main-content > * { 69 - max-width: 1300px; 70 - } 71 - } 72 - 73 - @media (min-width: 1700px) { 74 - .layout-mode-topnav .main-content > * { 75 - max-width: 1500px; 76 - } 77 - } 78 - 79 - @media (max-width: 768px) { 80 - .layout-mode-topnav .app-layout { 81 - grid-template-columns: 1fr; 82 - max-width: none; 83 - height: auto; 84 - overflow: visible; 85 - } 86 - 87 - .layout-mode-topnav .top-nav { 88 - display: none; 89 - } 90 - 91 - .layout-mode-topnav .main-content { 92 - padding: 16px 12px 100px; 93 - } 94 - 95 - .layout-mode-topnav .main-content > * { 96 - max-width: 100%; 97 - padding: 0 4px; 98 - } 99 - } 100 - 101 - .top-nav-inner { 102 - max-width: 1200px; 103 - margin: 0 auto; 104 - padding: 0 32px; 105 - height: 56px; 106 - display: flex; 107 - align-items: center; 108 - gap: 32px; 109 - } 110 - 111 - .top-nav-logo { 112 - display: flex; 113 - align-items: center; 114 - gap: 10px; 115 - text-decoration: none; 116 - color: var(--text-primary); 117 - font-weight: 700; 118 - font-size: 1.1rem; 119 - flex-shrink: 0; 120 - } 121 - 122 - .top-nav-logo svg { 123 - width: 26px; 124 - height: 26px; 125 - transition: color 0.2s; 126 - color: var(--accent); 127 - } 128 - 129 - .top-nav-logo:hover svg { 130 - color: var(--accent); 131 - } 132 - 133 - .top-nav-links { 134 - display: flex; 135 - align-items: center; 136 - gap: 4px; 137 - flex: 1; 138 - } 139 - 140 - .top-nav-link { 141 - padding: 8px 14px; 142 - color: var(--text-secondary); 143 - text-decoration: none; 144 - font-size: 0.9rem; 145 - font-weight: 500; 146 - border-radius: var(--radius-md); 147 - transition: all 0.15s; 148 - } 149 - 150 - .top-nav-link:hover { 151 - color: var(--text-primary); 152 - background: var(--bg-hover); 153 - } 154 - 155 - .top-nav-link.active { 156 - color: var(--text-primary); 157 - background: var(--bg-tertiary); 158 - } 159 - 160 - .top-nav-link.extension-link { 161 - display: flex; 162 - align-items: center; 163 - gap: 6px; 164 - } 165 - 166 - .top-nav-actions { 167 - display: flex; 168 - align-items: center; 169 - gap: 8px; 170 - } 171 - 172 - .top-nav-icon-btn { 173 - display: flex; 174 - align-items: center; 175 - justify-content: center; 176 - width: 36px; 177 - height: 36px; 178 - border-radius: var(--radius-md); 179 - background: transparent; 180 - border: none; 181 - color: var(--text-secondary); 182 - cursor: pointer; 183 - transition: all 0.15s; 184 - position: relative; 185 - text-decoration: none; 186 - } 187 - 188 - .top-nav-icon-btn:hover { 189 - background: var(--bg-hover); 190 - color: var(--text-primary); 191 - } 192 - 193 - .notif-dot { 194 - position: absolute; 195 - top: 6px; 196 - right: 6px; 197 - width: 8px; 198 - height: 8px; 199 - background: var(--accent); 200 - border-radius: 50%; 201 - border: 2px solid var(--bg-primary); 202 - } 203 - 204 - .top-nav-new-btn { 205 - display: flex; 206 - align-items: center; 207 - gap: 6px; 208 - padding: 8px 16px; 209 - background: var(--accent); 210 - color: var(--bg-primary); 211 - border-radius: var(--radius-md); 212 - font-size: 0.875rem; 213 - font-weight: 600; 214 - text-decoration: none; 215 - transition: all 0.15s; 216 - } 217 - 218 - .top-nav-new-btn:hover { 219 - background: var(--accent-hover); 220 - } 221 - 222 - .top-nav-avatar { 223 - width: 34px; 224 - height: 34px; 225 - border-radius: var(--radius-md); 226 - background: var(--bg-tertiary); 227 - border: none; 228 - cursor: pointer; 229 - overflow: hidden; 230 - display: flex; 231 - align-items: center; 232 - justify-content: center; 233 - color: var(--text-secondary); 234 - font-size: 0.8rem; 235 - font-weight: 600; 236 - transition: opacity 0.15s; 237 - } 238 - 239 - .top-nav-avatar:hover { 240 - opacity: 0.85; 241 - } 242 - 243 - .top-nav-avatar img { 244 - width: 100%; 245 - height: 100%; 246 - object-fit: cover; 247 - } 248 - 249 - .top-nav-mobile-toggle { 250 - display: none; 251 - align-items: center; 252 - justify-content: center; 253 - width: 40px; 254 - height: 40px; 255 - border: none; 256 - background: transparent; 257 - color: var(--text-primary); 258 - cursor: pointer; 259 - } 260 - 261 - .top-nav-dropdown { 262 - position: relative; 263 - } 264 - 265 - .dropdown-menu { 266 - position: absolute; 267 - top: calc(100% + 8px); 268 - min-width: 200px; 269 - background: var(--bg-elevated); 270 - border: 1px solid var(--border); 271 - border-radius: var(--radius-lg); 272 - padding: 6px; 273 - box-shadow: var(--shadow-lg); 274 - z-index: 100; 275 - } 276 - 277 - .dropdown-right { 278 - right: 0; 279 - } 280 - 281 - .dropdown-item { 282 - display: flex; 283 - align-items: center; 284 - gap: 10px; 285 - width: 100%; 286 - padding: 10px 12px; 287 - border-radius: var(--radius-md); 288 - color: var(--text-secondary); 289 - font-size: 0.875rem; 290 - font-weight: 500; 291 - text-decoration: none; 292 - transition: all 0.15s; 293 - background: none; 294 - border: none; 295 - cursor: pointer; 296 - text-align: left; 297 - } 298 - 299 - .dropdown-item:hover { 300 - background: var(--bg-hover); 301 - color: var(--text-primary); 302 - } 303 - 304 - .dropdown-item.danger:hover { 305 - background: rgba(217, 119, 102, 0.12); 306 - color: var(--error); 307 - } 308 - 309 - .dropdown-external { 310 - margin-left: auto; 311 - opacity: 0.4; 312 - } 313 - 314 - .tangled-icon-wrapper { 315 - width: 16px; 316 - height: 16px; 317 - display: flex; 318 - align-items: center; 319 - justify-content: center; 320 - } 321 - 322 - .tangled-icon-wrapper img { 323 - width: 16px; 324 - height: 16px; 325 - filter: grayscale(100%) brightness(1.5); 326 - opacity: 0.6; 327 - transition: all 0.15s; 328 - } 329 - 330 - .dropdown-item:hover .tangled-icon-wrapper img { 331 - opacity: 0.9; 332 - } 333 - 334 - [data-theme="light"] .tangled-icon-wrapper img { 335 - filter: grayscale(100%) brightness(0) invert(0.35); 336 - opacity: 1; 337 - } 338 - 339 - [data-theme="light"] .dropdown-item:hover .tangled-icon-wrapper img { 340 - filter: grayscale(100%) brightness(0) invert(0.1); 341 - opacity: 1; 342 - } 343 - 344 - .dropdown-divider { 345 - height: 1px; 346 - background: var(--border); 347 - margin: 6px 0; 348 - } 349 - 350 - .dropdown-user-info { 351 - padding: 8px 12px; 352 - display: flex; 353 - flex-direction: column; 354 - gap: 2px; 355 - } 356 - 357 - .dropdown-user-name { 358 - font-weight: 600; 359 - color: var(--text-primary); 360 - font-size: 0.9rem; 361 - } 362 - 363 - .dropdown-user-handle { 364 - color: var(--text-tertiary); 365 - font-size: 0.8rem; 366 - } 367 - 368 - .main-content { 369 - width: 100%; 370 - padding: 0; 371 - overflow-y: auto; 372 - overflow-x: hidden; 373 - height: 100%; 374 - scrollbar-width: thin; 375 - scrollbar-color: var(--bg-hover) transparent; 376 - } 377 - 378 - .main-content > * { 379 - max-width: 100%; 380 - margin: 0 auto; 381 - padding: 32px 40px 80px; 382 - } 383 - 384 - .main-content::-webkit-scrollbar { 385 - width: 8px; 386 - } 387 - 388 - .main-content::-webkit-scrollbar-track { 389 - background: var(--bg-secondary); 390 - } 391 - 392 - .main-content::-webkit-scrollbar-thumb { 393 - background: var(--bg-hover); 394 - border-radius: var(--radius-full); 395 - } 396 - 397 - .main-content::-webkit-scrollbar-thumb:hover { 398 - background: var(--text-tertiary); 399 - } 400 - 401 - .mobile-menu { 402 - display: none; 403 - position: absolute; 404 - top: 100%; 405 - left: 0; 406 - right: 0; 407 - background: var(--bg-secondary); 408 - border-bottom: 1px solid var(--border); 409 - padding: 12px 16px; 410 - } 411 - 412 - .mobile-menu-link { 413 - display: flex; 414 - align-items: center; 415 - gap: 12px; 416 - padding: 12px 16px; 417 - color: var(--text-secondary); 418 - text-decoration: none; 419 - font-size: 0.95rem; 420 - font-weight: 500; 421 - border-radius: var(--radius-md); 422 - transition: all 0.15s; 423 - } 424 - 425 - .mobile-menu-link:hover, 426 - .mobile-menu-link.active { 427 - background: var(--bg-hover); 428 - color: var(--text-primary); 429 - } 430 - 431 - .mobile-menu-link.active { 432 - color: var(--accent); 433 - } 434 - 435 - .mobile-menu-divider { 436 - height: 1px; 437 - background: var(--border); 438 - margin: 8px 0; 439 - } 440 - 441 - .notification-badge { 442 - background: var(--accent); 443 - color: var(--bg-primary); 444 - font-size: 0.7rem; 445 - font-weight: 700; 446 - padding: 2px 6px; 447 - border-radius: var(--radius-full); 448 - margin-left: auto; 449 - } 450 - 451 - .mobile-bottom-nav { 452 - display: none; 453 - position: fixed; 454 - bottom: 0; 455 - left: 0; 456 - right: 0; 457 - background: var(--nav-bg); 458 - backdrop-filter: blur(12px); 459 - -webkit-backdrop-filter: blur(12px); 460 - border-top: 1px solid var(--border); 461 - padding: 8px 8px calc(8px + env(safe-area-inset-bottom)); 462 - z-index: 200; 463 - justify-content: space-around; 464 - align-items: center; 465 - } 466 - 467 - .mobile-bottom-nav-item { 468 - display: flex; 469 - flex: 1; 470 - flex-direction: column; 471 - align-items: center; 472 - justify-content: center; 473 - gap: 4px; 474 - padding: 6px 0; 475 - color: var(--text-tertiary); 476 - text-decoration: none; 477 - font-size: 0.65rem; 478 - font-weight: 500; 479 - transition: color 0.15s; 480 - min-width: 0; 481 - } 482 - 483 - .mobile-bottom-nav-item.active { 484 - color: var(--accent); 485 - } 486 - 487 - .mobile-bottom-nav-item:active { 488 - transform: scale(0.95); 489 - } 490 - 491 - .mobile-bottom-nav-new { 492 - padding: 6px 0; 493 - } 494 - 495 - .mobile-nav-new-btn { 496 - display: flex; 497 - align-items: center; 498 - justify-content: center; 499 - width: 44px; 500 - height: 44px; 501 - background: var(--accent); 502 - color: var(--bg-primary); 503 - border-radius: var(--radius-full); 504 - box-shadow: var(--shadow-md); 505 - } 506 - 507 - .mobile-nav-avatar { 508 - width: 24px; 509 - height: 24px; 510 - border-radius: var(--radius-full); 511 - object-fit: cover; 512 - } 513 - 514 - .ios-shortcut-banner { 515 - display: none; 516 - position: relative; 517 - padding: 20px; 518 - margin-bottom: 12px; 519 - text-align: center; 520 - } 521 - 522 - .ios-shortcut-banner-close { 523 - position: absolute; 524 - top: 8px; 525 - right: 8px; 526 - background: none; 527 - border: none; 528 - color: var(--text-tertiary); 529 - cursor: pointer; 530 - padding: 6px; 531 - display: flex; 532 - align-items: center; 533 - justify-content: center; 534 - opacity: 0.5; 535 - transition: opacity 0.15s; 536 - } 537 - 538 - .ios-shortcut-banner-close:hover { 539 - opacity: 1; 540 - } 541 - 542 - .ios-shortcut-banner-content { 543 - display: flex; 544 - flex-direction: column; 545 - align-items: center; 546 - gap: 12px; 547 - } 548 - 549 - .ios-shortcut-banner-icon { 550 - display: none; 551 - } 552 - 553 - .ios-shortcut-banner-text { 554 - text-align: center; 555 - } 556 - 557 - .ios-shortcut-banner-text strong { 558 - display: none; 559 - } 560 - 561 - .ios-shortcut-banner-text p { 562 - font-size: 0.8rem; 563 - color: var(--text-tertiary); 564 - margin: 0; 565 - line-height: 1.4; 566 - } 567 - 568 - .ios-shortcut-banner-btn { 569 - display: inline-flex; 570 - align-items: center; 571 - gap: 6px; 572 - padding: 10px 20px; 573 - background: transparent; 574 - color: var(--text-secondary); 575 - font-size: 0.85rem; 576 - font-weight: 500; 577 - border: 1px solid var(--border); 578 - border-radius: 100px; 579 - text-decoration: none; 580 - transition: all 0.15s; 581 - } 582 - 583 - .ios-shortcut-banner-btn:hover { 584 - background: var(--bg-hover); 585 - color: var(--text-primary); 586 - } 587 - 588 - @media (max-width: 768px) { 589 - .ios-shortcut-banner { 590 - display: block; 591 - } 592 - } 593 - 594 - @media (max-width: 768px) { 595 - .top-nav { 596 - display: none; 597 - } 598 - 599 - .mobile-bottom-nav { 600 - display: flex; 601 - } 602 - 603 - .main-content { 604 - padding: 16px 12px 100px; 605 - } 606 - 607 - .main-content > * { 608 - padding: 0 4px; 609 - max-width: 100%; 610 - } 611 - 612 - .feed-container { 613 - border-radius: var(--radius-md); 614 - padding: 0; 615 - } 616 - } 617 - 618 - @media (max-width: 480px) { 619 - .main-content { 620 - padding: 16px 12px 100px; 621 - } 622 - 623 - .page-title { 624 - font-size: 1.25rem; 625 - } 626 - 627 - .page-description { 628 - font-size: 0.85rem; 629 - } 630 - } 631 - 632 - .mobile-nav-overlay { 633 - position: fixed; 634 - inset: 0; 635 - background: rgba(0, 0, 0, 0.5); 636 - z-index: 300; 637 - backdrop-filter: blur(2px); 638 - -webkit-backdrop-filter: blur(2px); 639 - animation: fadeIn 0.15s ease-out; 640 - } 641 - 642 - .mobile-nav-menu { 643 - position: fixed; 644 - bottom: calc(70px + env(safe-area-inset-bottom)); 645 - right: 12px; 646 - width: 250px; 647 - background: var(--bg-elevated); 648 - border: 1px solid var(--border); 649 - border-radius: var(--radius-xl); 650 - padding: 6px; 651 - z-index: 301; 652 - box-shadow: var(--shadow-2xl); 653 - animation: mobileMenuSlide 0.2s cubic-bezier(0.16, 1, 0.3, 1); 654 - display: flex; 655 - flex-direction: column; 656 - gap: 2px; 657 - } 658 - 659 - @keyframes mobileMenuSlide { 660 - from { 661 - opacity: 0; 662 - transform: translateY(20px) scale(0.95); 663 - } 664 - 665 - to { 666 - opacity: 1; 667 - transform: translateY(0) scale(1); 668 - } 669 - } 670 - 671 - .mobile-menu-item { 672 - display: flex; 673 - align-items: center; 674 - gap: 12px; 675 - padding: 12px 14px; 676 - color: var(--text-secondary); 677 - text-decoration: none; 678 - font-size: 0.95rem; 679 - font-weight: 500; 680 - border-radius: var(--radius-md); 681 - transition: all 0.1s; 682 - background: transparent; 683 - border: none; 684 - width: 100%; 685 - text-align: left; 686 - cursor: pointer; 687 - } 688 - 689 - .mobile-menu-item:hover, 690 - .mobile-menu-item:active { 691 - background: var(--bg-hover); 692 - color: var(--text-primary); 693 - } 694 - 695 - .mobile-menu-item svg { 696 - opacity: 0.8; 697 - } 698 - 699 - .mobile-menu-item.active { 700 - background: var(--bg-tertiary); 701 - color: var(--accent); 702 - } 703 - 704 - .mobile-menu-item.danger { 705 - color: var(--error); 706 - } 707 - 708 - .mobile-menu-item.danger:hover { 709 - background: rgba(239, 68, 68, 0.1); 710 - } 711 - 712 - .mobile-menu-profile-card { 713 - display: flex; 714 - align-items: center; 715 - gap: 12px; 716 - padding: 12px; 717 - background: var(--bg-tertiary); 718 - border-radius: var(--radius-lg); 719 - margin-bottom: 6px; 720 - text-decoration: none; 721 - border: 1px solid transparent; 722 - } 723 - 724 - .mobile-menu-profile-card:active { 725 - background: var(--bg-hover); 726 - transform: scale(0.98); 727 - } 728 - 729 - .mobile-menu-badge { 730 - margin-left: auto; 731 - background: var(--accent); 732 - color: white; 733 - font-size: 0.75rem; 734 - font-weight: 700; 735 - padding: 2px 8px; 736 - border-radius: 99px; 737 - }
-380
web/src/css/login.css
··· 1 - .login-page { 2 - display: flex; 3 - flex-direction: column; 4 - align-items: center; 5 - justify-content: center; 6 - min-height: 80vh; 7 - padding: 40px 20px; 8 - width: 100%; 9 - max-width: 440px; 10 - margin: 0 auto; 11 - } 12 - 13 - .login-header-group { 14 - display: flex; 15 - flex-direction: row; 16 - align-items: center; 17 - justify-content: center; 18 - gap: 24px; 19 - margin-bottom: 48px; 20 - width: auto; 21 - } 22 - 23 - .login-logo-img { 24 - width: 60px; 25 - height: 60px; 26 - object-fit: contain; 27 - display: block; 28 - } 29 - 30 - .login-x { 31 - font-size: 2rem; 32 - color: var(--text-tertiary); 33 - font-weight: 300; 34 - line-height: 1; 35 - padding-bottom: 4px; 36 - } 37 - 38 - .login-atproto-icon { 39 - color: #3b83f6 !important; 40 - display: flex; 41 - align-items: center; 42 - justify-content: center; 43 - } 44 - 45 - .login-heading { 46 - font-size: 1.5rem; 47 - font-weight: 700; 48 - margin-bottom: 32px; 49 - display: flex; 50 - align-items: center; 51 - justify-content: center; 52 - flex-wrap: wrap; 53 - gap: 8px; 54 - text-align: center; 55 - line-height: 1.3; 56 - color: var(--text-primary); 57 - } 58 - 59 - .login-help-btn { 60 - background: none; 61 - border: none; 62 - color: var(--text-tertiary); 63 - cursor: pointer; 64 - padding: 4px; 65 - display: flex; 66 - align-items: center; 67 - transition: color 0.15s; 68 - flex-shrink: 0; 69 - } 70 - 71 - .login-help-btn:hover { 72 - color: var(--accent); 73 - } 74 - 75 - .login-help-text { 76 - background: var(--bg-tertiary); 77 - border: 1px solid var(--border); 78 - border-radius: var(--radius-md); 79 - padding: 16px; 80 - margin-bottom: 24px; 81 - font-size: 0.9rem; 82 - color: var(--text-secondary); 83 - line-height: 1.5; 84 - text-align: center; 85 - width: 100%; 86 - } 87 - 88 - .login-help-text code { 89 - background: rgba(255, 255, 255, 0.05); 90 - padding: 2px 6px; 91 - border-radius: var(--radius-sm); 92 - font-size: 0.85rem; 93 - font-family: var(--font-mono); 94 - } 95 - 96 - .login-form { 97 - display: flex; 98 - flex-direction: column; 99 - gap: 20px; 100 - width: 100%; 101 - } 102 - 103 - .login-input-wrapper { 104 - position: relative; 105 - } 106 - 107 - .login-input { 108 - width: 100%; 109 - padding: 14px 16px; 110 - background: var(--bg-secondary); 111 - border: 1px solid var(--border); 112 - border-radius: var(--radius-md); 113 - color: var(--text-primary); 114 - font-size: 1rem; 115 - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); 116 - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); 117 - } 118 - 119 - .login-input:focus { 120 - outline: none; 121 - border-color: var(--accent); 122 - box-shadow: 0 0 0 4px var(--accent-subtle); 123 - background: var(--bg-primary); 124 - } 125 - 126 - .login-input::placeholder { 127 - color: var(--text-tertiary); 128 - } 129 - 130 - .login-suggestions { 131 - position: absolute; 132 - top: calc(100% + 8px); 133 - left: 0; 134 - right: 0; 135 - background: var(--bg-elevated); 136 - border: 1px solid var(--border); 137 - border-radius: var(--radius-md); 138 - box-shadow: var(--shadow-lg); 139 - overflow: hidden; 140 - z-index: 100; 141 - max-height: 300px; 142 - overflow-y: auto; 143 - } 144 - 145 - .login-suggestion { 146 - display: flex; 147 - align-items: center; 148 - gap: 12px; 149 - width: 100%; 150 - padding: 12px 16px; 151 - background: transparent; 152 - border: none; 153 - cursor: pointer; 154 - text-align: left; 155 - transition: background 0.1s; 156 - border-bottom: 1px solid var(--border); 157 - } 158 - 159 - .login-suggestion:last-child { 160 - border-bottom: none; 161 - } 162 - 163 - .login-suggestion:hover, 164 - .login-suggestion.selected { 165 - background: var(--bg-tertiary); 166 - } 167 - 168 - .login-suggestion-avatar { 169 - width: 36px; 170 - height: 36px; 171 - border-radius: var(--radius-full); 172 - background: linear-gradient(135deg, var(--accent), #a855f7); 173 - display: flex; 174 - align-items: center; 175 - justify-content: center; 176 - flex-shrink: 0; 177 - overflow: hidden; 178 - font-size: 0.8rem; 179 - font-weight: 600; 180 - color: white; 181 - } 182 - 183 - .login-suggestion-avatar img { 184 - width: 100%; 185 - height: 100%; 186 - object-fit: cover; 187 - } 188 - 189 - .login-suggestion-info { 190 - display: flex; 191 - flex-direction: column; 192 - min-width: 0; 193 - gap: 2px; 194 - } 195 - 196 - .login-suggestion-name { 197 - font-weight: 600; 198 - font-size: 0.95rem; 199 - color: var(--text-primary); 200 - white-space: nowrap; 201 - overflow: hidden; 202 - text-overflow: ellipsis; 203 - } 204 - 205 - .login-suggestion-handle { 206 - font-size: 0.85rem; 207 - color: var(--text-secondary); 208 - white-space: nowrap; 209 - overflow: hidden; 210 - text-overflow: ellipsis; 211 - } 212 - 213 - .login-error { 214 - padding: 12px 16px; 215 - background: rgba(239, 68, 68, 0.1); 216 - border: 1px solid rgba(239, 68, 68, 0.2); 217 - border-radius: var(--radius-md); 218 - color: var(--error); 219 - font-size: 0.875rem; 220 - text-align: center; 221 - } 222 - 223 - .login-submit { 224 - padding: 14px 24px; 225 - font-size: 1rem; 226 - font-weight: 600; 227 - width: 100%; 228 - justify-content: center; 229 - } 230 - 231 - .login-avatar-large { 232 - width: 80px; 233 - height: 80px; 234 - border-radius: var(--radius-full); 235 - background: linear-gradient(135deg, var(--accent), #a855f7); 236 - display: flex; 237 - align-items: center; 238 - justify-content: center; 239 - margin-bottom: 20px; 240 - font-weight: 700; 241 - font-size: 2rem; 242 - color: white; 243 - overflow: hidden; 244 - box-shadow: var(--shadow-md); 245 - } 246 - 247 - .login-avatar-large img { 248 - width: 100%; 249 - height: 100%; 250 - object-fit: cover; 251 - } 252 - 253 - .login-welcome { 254 - font-size: 1.25rem; 255 - font-weight: 600; 256 - margin-bottom: 32px; 257 - text-align: center; 258 - color: var(--text-primary); 259 - } 260 - 261 - .login-actions { 262 - display: flex; 263 - flex-direction: column; 264 - gap: 12px; 265 - width: 100%; 266 - } 267 - 268 - .morph-container { 269 - display: inline-block; 270 - color: var(--text-primary); 271 - font-weight: 700; 272 - transition: 273 - opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1), 274 - transform 0.4s cubic-bezier(0.4, 0, 0.2, 1), 275 - filter 0.4s cubic-bezier(0.4, 0, 0.2, 1); 276 - white-space: nowrap; 277 - vertical-align: bottom; 278 - } 279 - 280 - .morph-out { 281 - opacity: 0; 282 - transform: translateY(8px) scale(0.95); 283 - filter: blur(4px); 284 - } 285 - 286 - .morph-in { 287 - opacity: 1; 288 - transform: translateY(0) scale(1); 289 - filter: blur(0); 290 - } 291 - 292 - .login-legal { 293 - margin-top: 24px; 294 - font-size: 0.85rem; 295 - color: var(--text-tertiary); 296 - text-align: center; 297 - line-height: 1.5; 298 - } 299 - 300 - .login-legal a { 301 - color: var(--accent); 302 - text-decoration: underline; 303 - text-decoration-color: var(--accent); 304 - text-underline-offset: 4px; 305 - font-weight: 500; 306 - } 307 - 308 - .login-legal a:hover { 309 - text-decoration-thickness: 2px; 310 - opacity: 0.8; 311 - } 312 - 313 - @media (max-width: 480px) { 314 - .login-heading { 315 - font-size: 1.25rem; 316 - flex-wrap: wrap; 317 - gap: 4px; 318 - padding: 0 10px; 319 - } 320 - 321 - .login-header-group { 322 - margin-bottom: 32px; 323 - gap: 16px; 324 - } 325 - 326 - .login-logo-img { 327 - width: 48px; 328 - height: 48px; 329 - } 330 - 331 - .login-x { 332 - font-size: 1.5rem; 333 - } 334 - 335 - .login-atproto-icon svg { 336 - width: 48px; 337 - height: 48px; 338 - } 339 - } 340 - 341 - .login-divider { 342 - display: flex; 343 - align-items: center; 344 - text-align: center; 345 - margin: 24px 0; 346 - color: var(--text-tertiary); 347 - font-size: 13px; 348 - font-weight: 500; 349 - text-transform: uppercase; 350 - letter-spacing: 0.5px; 351 - } 352 - 353 - .login-divider::before, 354 - .login-divider::after { 355 - content: ""; 356 - flex: 1; 357 - border-bottom: 1px solid var(--border); 358 - } 359 - 360 - .login-divider::before { 361 - margin-right: 16px; 362 - } 363 - 364 - .login-divider::after { 365 - margin-left: 16px; 366 - } 367 - 368 - .login-signup-btn { 369 - width: 100%; 370 - border: 1px solid var(--border); 371 - background: transparent; 372 - color: var(--text-primary); 373 - transition: all 0.2s; 374 - } 375 - 376 - .login-signup-btn:hover { 377 - border-color: var(--accent); 378 - background: var(--bg-hover); 379 - color: var(--accent); 380 - }
-630
web/src/css/modals.css
··· 1 - .modal-overlay { 2 - position: fixed; 3 - inset: 0; 4 - background: rgba(0, 0, 0, 0.6); 5 - display: flex; 6 - align-items: center; 7 - justify-content: center; 8 - padding: var(--spacing-md); 9 - z-index: 500; 10 - animation: fadeIn 0.15s ease-out; 11 - } 12 - 13 - .spinner { 14 - animation: spin 1s linear infinite; 15 - } 16 - 17 - @keyframes spin { 18 - from { 19 - transform: rotate(0deg); 20 - } 21 - 22 - to { 23 - transform: rotate(360deg); 24 - } 25 - } 26 - 27 - .modal-container { 28 - background: var(--bg-secondary); 29 - border-radius: var(--radius-lg); 30 - width: 100%; 31 - max-width: 420px; 32 - border: 1px solid var(--border); 33 - box-shadow: var(--shadow-lg); 34 - animation: modalIn 0.2s ease-out; 35 - } 36 - 37 - .modal-header { 38 - display: flex; 39 - align-items: center; 40 - justify-content: space-between; 41 - padding: var(--spacing-md); 42 - border-bottom: 1px solid var(--border); 43 - } 44 - 45 - .modal-title { 46 - font-size: 1rem; 47 - font-weight: 600; 48 - color: var(--text-primary); 49 - } 50 - 51 - .modal-close-btn { 52 - padding: 6px; 53 - color: var(--text-tertiary); 54 - border-radius: var(--radius-sm); 55 - transition: all 0.15s; 56 - background: none; 57 - border: none; 58 - cursor: pointer; 59 - } 60 - 61 - .modal-close-btn:hover { 62 - color: var(--text-primary); 63 - background: var(--bg-tertiary); 64 - } 65 - 66 - .modal-form { 67 - padding: var(--spacing-md); 68 - display: flex; 69 - flex-direction: column; 70 - gap: var(--spacing-md); 71 - } 72 - 73 - .modal-body { 74 - padding: var(--spacing-md); 75 - display: flex; 76 - flex-direction: column; 77 - gap: var(--spacing-md); 78 - } 79 - 80 - .modal-actions { 81 - display: flex; 82 - justify-content: flex-end; 83 - gap: var(--spacing-sm); 84 - padding-top: var(--spacing-sm); 85 - } 86 - 87 - @keyframes fadeIn { 88 - from { 89 - opacity: 0; 90 - } 91 - 92 - to { 93 - opacity: 1; 94 - } 95 - } 96 - 97 - @keyframes modalIn { 98 - from { 99 - opacity: 0; 100 - transform: scale(0.96) translateY(-8px); 101 - } 102 - 103 - to { 104 - opacity: 1; 105 - transform: scale(1) translateY(0); 106 - } 107 - } 108 - 109 - .form-group { 110 - margin-bottom: 0; 111 - } 112 - 113 - .form-label { 114 - display: block; 115 - font-size: 0.8rem; 116 - font-weight: 500; 117 - color: var(--text-secondary); 118 - margin-bottom: 6px; 119 - } 120 - 121 - .form-input, 122 - .form-textarea, 123 - .form-select { 124 - width: 100%; 125 - padding: 10px 12px; 126 - background: var(--bg-primary); 127 - border: 1px solid var(--border); 128 - border-radius: var(--radius-md); 129 - color: var(--text-primary); 130 - font-size: 0.875rem; 131 - transition: all 0.15s; 132 - } 133 - 134 - .form-input:focus, 135 - .form-textarea:focus, 136 - .form-select:focus { 137 - outline: none; 138 - border-color: var(--accent); 139 - box-shadow: 0 0 0 2px var(--accent-subtle); 140 - } 141 - 142 - .form-textarea { 143 - resize: none; 144 - min-height: 80px; 145 - } 146 - 147 - .input { 148 - width: 100%; 149 - padding: 10px 12px; 150 - font-size: 0.875rem; 151 - color: var(--text-primary); 152 - background: var(--bg-primary); 153 - border: 1px solid var(--border); 154 - border-radius: var(--radius-md); 155 - outline: none; 156 - transition: all 0.15s ease; 157 - } 158 - 159 - .input:focus { 160 - border-color: var(--accent); 161 - box-shadow: 0 0 0 2px var(--accent-subtle); 162 - } 163 - 164 - .input::placeholder { 165 - color: var(--text-tertiary); 166 - } 167 - 168 - .icon-picker-tabs { 169 - display: flex; 170 - gap: 4px; 171 - margin-bottom: var(--spacing-sm); 172 - } 173 - 174 - .icon-picker-tab { 175 - flex: 1; 176 - padding: 8px 12px; 177 - background: var(--bg-tertiary); 178 - border: none; 179 - border-radius: var(--radius-sm); 180 - color: var(--text-secondary); 181 - font-size: 0.8rem; 182 - font-weight: 500; 183 - cursor: pointer; 184 - transition: all 0.15s ease; 185 - } 186 - 187 - .icon-picker-tab:hover { 188 - background: var(--bg-hover); 189 - } 190 - 191 - .icon-picker-tab.active { 192 - background: var(--accent); 193 - color: white; 194 - } 195 - 196 - .emoji-picker-wrapper { 197 - display: flex; 198 - flex-direction: column; 199 - gap: var(--spacing-sm); 200 - } 201 - 202 - .emoji-picker, 203 - .icon-picker { 204 - display: flex; 205 - flex-wrap: wrap; 206 - gap: 4px; 207 - max-height: 120px; 208 - overflow-y: auto; 209 - padding: var(--spacing-sm); 210 - background: var(--bg-primary); 211 - border: 1px solid var(--border); 212 - border-radius: var(--radius-md); 213 - } 214 - 215 - .emoji-option, 216 - .icon-option { 217 - width: 32px; 218 - height: 32px; 219 - display: flex; 220 - align-items: center; 221 - justify-content: center; 222 - font-size: 1rem; 223 - background: transparent; 224 - border: 2px solid transparent; 225 - border-radius: var(--radius-sm); 226 - cursor: pointer; 227 - transition: all 0.15s ease; 228 - color: var(--text-secondary); 229 - } 230 - 231 - .emoji-option:hover, 232 - .icon-option:hover { 233 - background: var(--bg-tertiary); 234 - color: var(--text-primary); 235 - } 236 - 237 - .emoji-option.selected, 238 - .icon-option.selected { 239 - border-color: var(--accent); 240 - background: var(--accent-subtle); 241 - color: var(--accent); 242 - } 243 - 244 - .color-input-container { 245 - display: flex; 246 - align-items: center; 247 - gap: var(--spacing-sm); 248 - background: var(--bg-tertiary); 249 - padding: 8px 12px; 250 - border-radius: var(--radius-md); 251 - border: 1px solid var(--border); 252 - width: fit-content; 253 - } 254 - 255 - .color-input-wrapper { 256 - position: relative; 257 - width: 28px; 258 - height: 28px; 259 - border-radius: var(--radius-full); 260 - overflow: hidden; 261 - border: 2px solid var(--border); 262 - cursor: pointer; 263 - transition: transform 0.1s; 264 - } 265 - 266 - .color-input-wrapper:hover { 267 - transform: scale(1.1); 268 - border-color: var(--accent); 269 - } 270 - 271 - .color-input-wrapper input[type="color"] { 272 - position: absolute; 273 - top: -50%; 274 - left: -50%; 275 - width: 200%; 276 - height: 200%; 277 - padding: 0; 278 - margin: 0; 279 - border: none; 280 - cursor: pointer; 281 - opacity: 0; 282 - } 283 - 284 - .signup-modal { 285 - background: var(--bg-secondary); 286 - width: 100%; 287 - max-width: 440px; 288 - border-radius: var(--radius-lg); 289 - padding: var(--spacing-lg); 290 - border: 1px solid var(--border); 291 - position: relative; 292 - max-height: 85vh; 293 - overflow-y: auto; 294 - box-shadow: var(--shadow-lg); 295 - } 296 - 297 - .modal-close { 298 - position: absolute; 299 - top: var(--spacing-md); 300 - right: var(--spacing-md); 301 - background: none; 302 - border: none; 303 - color: var(--text-secondary); 304 - cursor: pointer; 305 - padding: 4px; 306 - border-radius: var(--radius-sm); 307 - } 308 - 309 - .modal-close:hover { 310 - background: var(--bg-tertiary); 311 - color: var(--text-primary); 312 - } 313 - 314 - .signup-step h2 { 315 - font-size: 1.25rem; 316 - margin-bottom: 8px; 317 - font-weight: 600; 318 - } 319 - 320 - .signup-subtitle { 321 - color: var(--text-secondary); 322 - font-size: 0.875rem; 323 - margin-bottom: var(--spacing-lg); 324 - } 325 - 326 - .provider-grid { 327 - display: grid; 328 - grid-template-columns: 1fr; 329 - gap: var(--spacing-sm); 330 - } 331 - 332 - .provider-card { 333 - display: flex; 334 - align-items: center; 335 - gap: var(--spacing-md); 336 - padding: var(--spacing-md); 337 - border: 1px solid var(--border); 338 - border-radius: var(--radius-md); 339 - background: var(--bg-primary); 340 - cursor: pointer; 341 - text-align: left; 342 - transition: all 0.15s ease; 343 - } 344 - 345 - .provider-card:hover { 346 - border-color: var(--accent); 347 - background: var(--bg-tertiary); 348 - } 349 - 350 - .provider-icon { 351 - width: 40px; 352 - height: 40px; 353 - border-radius: var(--radius-md); 354 - background: var(--bg-tertiary); 355 - display: flex; 356 - align-items: center; 357 - justify-content: center; 358 - border: 1px solid var(--border); 359 - color: var(--text-primary); 360 - flex-shrink: 0; 361 - } 362 - 363 - .provider-icon.wide { 364 - width: auto; 365 - padding: 0 10px; 366 - border: none; 367 - background: transparent; 368 - } 369 - 370 - .provider-icon.wide img { 371 - max-height: 36px !important; 372 - height: 36px !important; 373 - width: auto !important; 374 - } 375 - 376 - .provider-initial { 377 - font-size: 1rem; 378 - font-weight: 600; 379 - } 380 - 381 - .provider-info { 382 - flex: 1; 383 - } 384 - 385 - .provider-info h3 { 386 - font-weight: 600; 387 - font-size: 0.9rem; 388 - margin-bottom: 2px; 389 - } 390 - 391 - .provider-info span { 392 - color: var(--text-secondary); 393 - font-size: 0.8rem; 394 - } 395 - 396 - .provider-arrow { 397 - color: var(--text-tertiary); 398 - } 399 - 400 - .signup-recommended { 401 - position: relative; 402 - margin-bottom: var(--spacing-md); 403 - } 404 - 405 - .signup-recommended-badge { 406 - position: absolute; 407 - top: -8px; 408 - left: 12px; 409 - background: var(--accent); 410 - color: white; 411 - font-size: 0.7rem; 412 - font-weight: 600; 413 - padding: 2px 8px; 414 - border-radius: var(--radius-sm); 415 - text-transform: uppercase; 416 - letter-spacing: 0.5px; 417 - z-index: 1; 418 - } 419 - 420 - .provider-card-featured { 421 - border-color: var(--accent); 422 - background: var(--accent-subtle); 423 - } 424 - 425 - .provider-card-featured:hover { 426 - border-color: var(--accent); 427 - background: var(--bg-tertiary); 428 - } 429 - 430 - .signup-toggle-others { 431 - display: flex; 432 - align-items: center; 433 - justify-content: center; 434 - gap: 6px; 435 - width: 100%; 436 - padding: 10px; 437 - background: transparent; 438 - border: none; 439 - color: var(--text-secondary); 440 - font-size: 0.85rem; 441 - cursor: pointer; 442 - transition: color 0.15s; 443 - } 444 - 445 - .signup-toggle-others:hover { 446 - color: var(--text-primary); 447 - } 448 - 449 - .toggle-chevron { 450 - transition: transform 0.2s ease; 451 - transform: rotate(90deg); 452 - } 453 - 454 - .toggle-chevron.open { 455 - transform: rotate(-90deg); 456 - } 457 - 458 - .signup-form { 459 - display: flex; 460 - flex-direction: column; 461 - gap: var(--spacing-md); 462 - } 463 - 464 - .handle-input-group { 465 - display: flex; 466 - align-items: center; 467 - gap: var(--spacing-sm); 468 - } 469 - 470 - .handle-suffix { 471 - color: var(--text-tertiary); 472 - font-size: 0.85rem; 473 - white-space: nowrap; 474 - } 475 - 476 - .error-message { 477 - color: var(--error); 478 - background: rgba(255, 69, 58, 0.1); 479 - padding: 10px 12px; 480 - border-radius: var(--radius-md); 481 - font-size: 0.8rem; 482 - display: flex; 483 - align-items: center; 484 - gap: var(--spacing-sm); 485 - } 486 - 487 - .step-header { 488 - display: flex; 489 - align-items: center; 490 - gap: var(--spacing-sm); 491 - margin-bottom: var(--spacing-lg); 492 - } 493 - 494 - .step-header h2 { 495 - margin: 0; 496 - font-size: 1.1rem; 497 - } 498 - 499 - .btn-back { 500 - background: none; 501 - border: none; 502 - color: var(--text-secondary); 503 - cursor: pointer; 504 - font-size: 0.85rem; 505 - padding: 0; 506 - } 507 - 508 - .btn-back:hover { 509 - color: var(--text-primary); 510 - } 511 - 512 - .legal-text { 513 - font-size: 0.75rem; 514 - color: var(--text-tertiary); 515 - text-align: center; 516 - margin-top: var(--spacing-sm); 517 - } 518 - 519 - .links-input-group { 520 - display: flex; 521 - gap: var(--spacing-sm); 522 - margin-bottom: var(--spacing-sm); 523 - } 524 - 525 - .links-input-group input { 526 - flex: 1; 527 - } 528 - 529 - .links-list { 530 - list-style: none; 531 - padding: 0; 532 - margin: 0; 533 - display: flex; 534 - flex-direction: column; 535 - gap: var(--spacing-sm); 536 - } 537 - 538 - .link-item { 539 - display: flex; 540 - align-items: center; 541 - justify-content: space-between; 542 - gap: var(--spacing-sm); 543 - padding: 8px 12px; 544 - background: var(--bg-tertiary); 545 - border: 1px solid var(--border); 546 - border-radius: var(--radius-md); 547 - font-size: 0.85rem; 548 - color: var(--text-primary); 549 - word-break: break-all; 550 - } 551 - 552 - .link-item span { 553 - flex: 1; 554 - } 555 - 556 - .btn-icon-sm { 557 - background: none; 558 - border: none; 559 - color: var(--text-tertiary); 560 - cursor: pointer; 561 - padding: 4px; 562 - border-radius: var(--radius-sm); 563 - display: flex; 564 - align-items: center; 565 - justify-content: center; 566 - font-size: 1rem; 567 - line-height: 1; 568 - } 569 - 570 - .btn-icon-sm:hover { 571 - background: var(--bg-hover); 572 - color: var(--error); 573 - } 574 - 575 - .char-count { 576 - text-align: right; 577 - font-size: 0.7rem; 578 - color: var(--text-tertiary); 579 - margin-top: 4px; 580 - } 581 - 582 - .avatar-upload-container { 583 - display: flex; 584 - align-items: center; 585 - gap: var(--spacing-md); 586 - } 587 - 588 - .avatar-preview { 589 - width: 72px; 590 - height: 72px; 591 - border-radius: var(--radius-full); 592 - background: var(--bg-tertiary); 593 - border: 2px solid var(--border); 594 - overflow: hidden; 595 - display: flex; 596 - align-items: center; 597 - justify-content: center; 598 - position: relative; 599 - transition: border-color 0.15s ease; 600 - } 601 - 602 - .avatar-preview:hover { 603 - border-color: var(--accent); 604 - } 605 - 606 - .avatar-preview-img { 607 - width: 100%; 608 - height: 100%; 609 - object-fit: cover; 610 - } 611 - 612 - .avatar-placeholder { 613 - color: var(--text-tertiary); 614 - } 615 - 616 - .avatar-uploading { 617 - position: absolute; 618 - inset: 0; 619 - background: rgba(0, 0, 0, 0.6); 620 - display: flex; 621 - align-items: center; 622 - justify-content: center; 623 - color: white; 624 - font-size: 0.7rem; 625 - } 626 - 627 - .btn-sm { 628 - padding: 6px 12px; 629 - font-size: 0.8rem; 630 - }
-67
web/src/css/notifications.css
··· 1 - .notifications-page { 2 - max-width: 680px; 3 - margin: 0 auto; 4 - } 5 - 6 - .notifications-list { 7 - display: flex; 8 - flex-direction: column; 9 - gap: 12px; 10 - } 11 - 12 - .notification-item { 13 - display: flex; 14 - gap: 16px; 15 - align-items: flex-start; 16 - text-decoration: none; 17 - color: inherit; 18 - } 19 - 20 - .notification-item:hover { 21 - background: var(--bg-hover); 22 - } 23 - 24 - .notification-icon { 25 - width: 36px; 26 - height: 36px; 27 - border-radius: var(--radius-full); 28 - display: flex; 29 - align-items: center; 30 - justify-content: center; 31 - background: var(--bg-tertiary); 32 - color: var(--text-secondary); 33 - flex-shrink: 0; 34 - } 35 - 36 - .notification-icon[data-type="like"] { 37 - color: #ef4444; 38 - background: rgba(239, 68, 68, 0.1); 39 - } 40 - 41 - .notification-icon[data-type="reply"] { 42 - color: #3b82f6; 43 - background: rgba(59, 130, 246, 0.1); 44 - } 45 - 46 - .notification-content { 47 - flex: 1; 48 - min-width: 0; 49 - } 50 - 51 - .notification-text { 52 - font-size: 0.95rem; 53 - margin-bottom: 4px; 54 - line-height: 1.4; 55 - color: var(--text-primary); 56 - overflow-wrap: break-word; 57 - word-break: break-word; 58 - } 59 - 60 - .notification-text strong { 61 - font-weight: 600; 62 - } 63 - 64 - .notification-time { 65 - font-size: 0.85rem; 66 - color: var(--text-tertiary); 67 - }
-311
web/src/css/profile.css
··· 1 - .profile-header { 2 - display: flex; 3 - align-items: flex-start; 4 - gap: 24px; 5 - margin-bottom: 32px; 6 - padding-bottom: 24px; 7 - border-bottom: 1px solid var(--border); 8 - } 9 - 10 - .profile-avatar { 11 - width: 80px; 12 - height: 80px; 13 - min-width: 80px; 14 - border-radius: 50%; 15 - background: var(--bg-tertiary); 16 - display: flex; 17 - align-items: center; 18 - justify-content: center; 19 - font-weight: 600; 20 - font-size: 2rem; 21 - color: var(--text-secondary); 22 - overflow: hidden; 23 - border: 1px solid var(--border); 24 - } 25 - 26 - .profile-avatar img { 27 - width: 100%; 28 - height: 100%; 29 - object-fit: cover; 30 - } 31 - 32 - .profile-avatar-link { 33 - text-decoration: none; 34 - } 35 - 36 - .profile-info { 37 - flex: 1; 38 - display: flex; 39 - flex-direction: column; 40 - gap: 4px; 41 - } 42 - 43 - .profile-name { 44 - font-size: 1.5rem; 45 - font-weight: 700; 46 - color: var(--text-primary); 47 - line-height: 1.2; 48 - overflow-wrap: break-word; 49 - word-break: break-word; 50 - } 51 - 52 - .profile-handle-row { 53 - display: flex; 54 - align-items: center; 55 - gap: 12px; 56 - margin-top: 4px; 57 - flex-wrap: wrap; 58 - } 59 - 60 - .profile-handle-link { 61 - color: var(--text-tertiary); 62 - text-decoration: none; 63 - font-size: 1rem; 64 - transition: color 0.15s; 65 - overflow-wrap: break-word; 66 - word-break: break-all; 67 - } 68 - 69 - .profile-handle-link:hover { 70 - color: var(--text-secondary); 71 - } 72 - 73 - .profile-bluesky-link { 74 - display: inline-flex; 75 - align-items: center; 76 - gap: 6px; 77 - color: #3b82f6; 78 - text-decoration: none; 79 - font-size: 0.85rem; 80 - font-weight: 500; 81 - padding: 2px 8px; 82 - border-radius: var(--radius-sm); 83 - background: rgba(59, 130, 246, 0.1); 84 - transition: all 0.15s ease; 85 - width: fit-content; 86 - } 87 - 88 - .profile-bluesky-link:hover { 89 - background: rgba(59, 130, 246, 0.15); 90 - } 91 - 92 - .profile-stats { 93 - display: flex; 94 - gap: 24px; 95 - margin-top: 12px; 96 - } 97 - 98 - .profile-stat { 99 - color: var(--text-tertiary); 100 - font-size: 0.9rem; 101 - } 102 - 103 - .profile-stat strong { 104 - color: var(--text-primary); 105 - font-weight: 600; 106 - } 107 - 108 - .profile-tabs { 109 - display: flex; 110 - gap: 24px; 111 - margin-bottom: 24px; 112 - border-bottom: 1px solid var(--border); 113 - flex-wrap: wrap; 114 - row-gap: 8px; 115 - } 116 - 117 - .profile-tab { 118 - padding: 12px 0; 119 - font-size: 0.95rem; 120 - font-weight: 500; 121 - color: var(--text-tertiary); 122 - background: transparent; 123 - border: none; 124 - cursor: pointer; 125 - transition: all 0.15s ease; 126 - position: relative; 127 - } 128 - 129 - .profile-tab:hover { 130 - color: var(--text-primary); 131 - } 132 - 133 - .profile-tab.active { 134 - color: var(--text-primary); 135 - } 136 - 137 - .profile-tab.active::after { 138 - content: ""; 139 - position: absolute; 140 - bottom: -1px; 141 - left: 0; 142 - right: 0; 143 - height: 2px; 144 - background: var(--text-primary); 145 - } 146 - 147 - .profile-badge-wrapper { 148 - display: inline-flex; 149 - align-items: center; 150 - } 151 - 152 - .profile-badge-clickable { 153 - position: relative; 154 - display: inline-flex; 155 - align-items: center; 156 - cursor: pointer; 157 - margin-left: 8px; 158 - } 159 - 160 - .badge-info-popover { 161 - position: absolute; 162 - top: calc(100% + 8px); 163 - left: 50%; 164 - transform: translateX(-50%); 165 - padding: 16px; 166 - background: var(--bg-elevated); 167 - border: 1px solid var(--border); 168 - border-radius: var(--radius-md); 169 - box-shadow: var(--shadow-lg); 170 - font-size: 0.85rem; 171 - white-space: nowrap; 172 - z-index: 100; 173 - min-width: 200px; 174 - } 175 - 176 - .badge-info-title { 177 - font-weight: 600; 178 - color: var(--text-primary); 179 - margin-bottom: 8px; 180 - } 181 - 182 - .verifier-link { 183 - display: flex; 184 - align-items: center; 185 - gap: 8px; 186 - padding: 8px; 187 - background: var(--bg-tertiary); 188 - border-radius: var(--radius-sm); 189 - text-decoration: none; 190 - transition: background 0.15s ease; 191 - } 192 - 193 - .verifier-link:hover { 194 - background: var(--bg-hover); 195 - } 196 - 197 - .verifier-avatar { 198 - width: 24px; 199 - height: 24px; 200 - border-radius: 50%; 201 - object-fit: cover; 202 - } 203 - 204 - .verifier-name { 205 - color: var(--text-primary); 206 - font-size: 0.85rem; 207 - font-weight: 500; 208 - } 209 - 210 - .profile-suspended { 211 - display: flex; 212 - flex-direction: column; 213 - align-items: center; 214 - justify-content: center; 215 - padding: 60px 20px; 216 - text-align: center; 217 - background: var(--bg-secondary); 218 - border-radius: var(--radius-lg); 219 - margin-top: 20px; 220 - border: 1px solid var(--border); 221 - } 222 - 223 - .suspended-icon { 224 - font-size: 40px; 225 - margin-bottom: 16px; 226 - color: var(--text-tertiary); 227 - } 228 - 229 - .profile-suspended h2 { 230 - color: var(--text-primary); 231 - margin-bottom: 8px; 232 - font-size: 1.25rem; 233 - } 234 - 235 - @media (max-width: 640px) { 236 - .profile-header { 237 - flex-direction: column; 238 - text-align: center; 239 - } 240 - 241 - .profile-info { 242 - align-items: center; 243 - } 244 - 245 - .profile-handle-row { 246 - justify-content: center; 247 - } 248 - 249 - .profile-stats { 250 - justify-content: center; 251 - } 252 - 253 - .profile-tabs { 254 - justify-content: center; 255 - gap: 16px; 256 - } 257 - } 258 - 259 - .profile-margin-details { 260 - margin-top: 16px; 261 - display: flex; 262 - flex-direction: column; 263 - gap: 12px; 264 - } 265 - 266 - .profile-bio { 267 - font-size: 0.95rem; 268 - color: var(--text-primary); 269 - line-height: 1.5; 270 - white-space: pre-wrap; 271 - max-width: 600px; 272 - } 273 - 274 - .profile-links { 275 - display: flex; 276 - flex-wrap: wrap; 277 - gap: 8px; 278 - align-items: center; 279 - } 280 - 281 - .profile-link-chip { 282 - display: inline-flex; 283 - align-items: center; 284 - gap: 6px; 285 - padding: 6px 12px; 286 - background: var(--bg-tertiary); 287 - border: 1px solid var(--border); 288 - border-radius: 8px; 289 - color: var(--text-secondary); 290 - text-decoration: none; 291 - font-size: 0.85rem; 292 - font-weight: 500; 293 - transition: all 0.2s ease; 294 - } 295 - 296 - .profile-link-chip:hover { 297 - background: var(--bg-hover); 298 - color: var(--text-primary); 299 - border-color: var(--text-tertiary); 300 - transform: translateY(-1px); 301 - } 302 - 303 - .profile-link-chip.main-website { 304 - background: rgba(var(--accent-rgb), 0.1); 305 - color: var(--accent); 306 - border-color: var(--accent); 307 - } 308 - 309 - .profile-link-chip.main-website:hover { 310 - background: rgba(var(--accent-rgb), 0.15); 311 - }
-499
web/src/css/sidebar.css
··· 1 - .left-sidebar { 2 - height: 100%; 3 - display: flex; 4 - flex-direction: column; 5 - background: var(--bg-primary); 6 - border-right: 1px solid var(--border); 7 - padding: 20px 16px; 8 - font-family: var(--font-sans); 9 - } 10 - 11 - .sidebar-header { 12 - margin-bottom: 24px; 13 - padding: 0 8px; 14 - display: flex; 15 - justify-content: center; 16 - } 17 - 18 - .sidebar-logo { 19 - display: flex; 20 - align-items: center; 21 - justify-content: center; 22 - text-decoration: none; 23 - opacity: 0.9; 24 - transition: all 0.2s ease; 25 - padding: 8px; 26 - border-radius: var(--radius-md); 27 - } 28 - 29 - .sidebar-logo-icon { 30 - display: none; 31 - } 32 - 33 - .sidebar-logo { 34 - display: flex; 35 - align-items: center; 36 - justify-content: center; 37 - text-decoration: none; 38 - color: var(--accent); 39 - opacity: 0.9; 40 - transition: all 0.2s ease; 41 - padding: 8px; 42 - border-radius: var(--radius-md); 43 - } 44 - 45 - .sidebar-logo svg { 46 - width: 32px; 47 - height: 32px; 48 - } 49 - 50 - .sidebar-logo:hover { 51 - opacity: 1; 52 - background: var(--bg-hover); 53 - color: var(--accent); 54 - } 55 - 56 - .sidebar-logo:hover .sidebar-logo-icon { 57 - background-color: var(--accent); 58 - } 59 - 60 - .sidebar-nav { 61 - display: flex; 62 - flex-direction: column; 63 - gap: 4px; 64 - flex: 1; 65 - } 66 - 67 - .sidebar-nav-item { 68 - display: flex; 69 - align-items: center; 70 - gap: 12px; 71 - padding: 8px 12px; 72 - color: var(--text-secondary); 73 - text-decoration: none; 74 - font-size: 0.9rem; 75 - font-weight: 500; 76 - border-radius: var(--radius-md); 77 - transition: all 0.15s ease; 78 - border: 1px solid transparent; 79 - } 80 - 81 - .sidebar-nav-item:hover { 82 - color: var(--text-primary); 83 - background: var(--bg-hover); 84 - } 85 - 86 - .sidebar-nav-item.active { 87 - background: var(--bg-card); 88 - color: var(--text-primary); 89 - font-weight: 600; 90 - border-color: var(--border); 91 - box-shadow: var(--shadow-sm); 92 - } 93 - 94 - .sidebar-nav-item svg { 95 - flex-shrink: 0; 96 - width: 18px; 97 - height: 18px; 98 - opacity: 0.8; 99 - } 100 - 101 - .sidebar-nav-item.active svg { 102 - opacity: 1; 103 - color: var(--accent); 104 - } 105 - 106 - .sidebar-badge { 107 - margin-left: auto; 108 - background: var(--accent); 109 - color: #fff; 110 - font-size: 0.7rem; 111 - font-weight: 600; 112 - padding: 2px 8px; 113 - border-radius: 99px; 114 - min-width: 20px; 115 - text-align: center; 116 - } 117 - 118 - .sidebar-new-btn { 119 - display: flex; 120 - align-items: center; 121 - justify-content: center; 122 - gap: 8px; 123 - padding: 10px; 124 - margin-bottom: 16px; 125 - background: var(--accent); 126 - color: #fff; 127 - border-radius: var(--radius-md); 128 - font-size: 0.9rem; 129 - font-weight: 600; 130 - text-decoration: none; 131 - transition: all 0.15s ease; 132 - box-shadow: var(--shadow-sm); 133 - } 134 - 135 - .sidebar-new-btn:hover { 136 - background: var(--accent-hover); 137 - transform: translateY(-1px); 138 - box-shadow: var(--shadow-md); 139 - } 140 - 141 - .sidebar-footer { 142 - padding-top: 16px; 143 - border-top: 1px solid var(--border); 144 - position: relative; 145 - } 146 - 147 - .sidebar-signin-btn { 148 - display: flex; 149 - align-items: center; 150 - justify-content: center; 151 - width: 100%; 152 - padding: 10px; 153 - background: var(--accent); 154 - color: white; 155 - border-radius: var(--radius-md); 156 - font-weight: 600; 157 - text-decoration: none; 158 - transition: all 0.2s; 159 - box-shadow: var(--shadow-sm); 160 - } 161 - 162 - .sidebar-signin-btn:hover { 163 - background: var(--accent-hover); 164 - transform: translateY(-1px); 165 - box-shadow: var(--shadow-md); 166 - } 167 - 168 - .sidebar-user-btn { 169 - display: flex; 170 - align-items: center; 171 - gap: 12px; 172 - width: 100%; 173 - padding: 8px; 174 - border-radius: var(--radius-md); 175 - background: transparent; 176 - border: 1px solid transparent; 177 - cursor: pointer; 178 - transition: all 0.15s ease; 179 - text-align: left; 180 - } 181 - 182 - .sidebar-user-btn:hover, 183 - .sidebar-user-btn.active { 184 - background: var(--bg-card); 185 - border-color: var(--border); 186 - } 187 - 188 - .sidebar-user-avatar { 189 - width: 32px; 190 - height: 32px; 191 - border-radius: var(--radius-full); 192 - object-fit: cover; 193 - border: 1px solid var(--border); 194 - } 195 - 196 - .sidebar-user-avatar-placeholder { 197 - width: 32px; 198 - height: 32px; 199 - border-radius: var(--radius-full); 200 - background: var(--bg-tertiary); 201 - display: flex; 202 - align-items: center; 203 - justify-content: center; 204 - color: var(--text-tertiary); 205 - border: 1px solid var(--border); 206 - } 207 - 208 - .sidebar-user-info { 209 - display: flex; 210 - flex-direction: column; 211 - overflow: hidden; 212 - flex: 1; 213 - } 214 - 215 - .sidebar-user-name { 216 - font-size: 0.85rem; 217 - font-weight: 600; 218 - color: var(--text-primary); 219 - white-space: nowrap; 220 - overflow: hidden; 221 - text-overflow: ellipsis; 222 - } 223 - 224 - .sidebar-user-handle { 225 - font-size: 0.75rem; 226 - color: var(--text-tertiary); 227 - white-space: nowrap; 228 - overflow: hidden; 229 - text-overflow: ellipsis; 230 - } 231 - 232 - .sidebar-user-menu { 233 - position: absolute; 234 - bottom: calc(100% + 12px); 235 - left: 0; 236 - right: 0; 237 - background: var(--bg-elevated); 238 - border: 1px solid var(--border); 239 - border-radius: var(--radius-lg); 240 - padding: 6px; 241 - box-shadow: var(--shadow-lg); 242 - z-index: 50; 243 - animation: slideUp 0.15s ease-out; 244 - } 245 - 246 - @keyframes slideUp { 247 - from { 248 - opacity: 0; 249 - transform: translateY(4px); 250 - } 251 - 252 - to { 253 - opacity: 1; 254 - transform: translateY(0); 255 - } 256 - } 257 - 258 - .sidebar-user-menu-item { 259 - display: flex; 260 - align-items: center; 261 - gap: 10px; 262 - width: 100%; 263 - padding: 8px 12px; 264 - border: none; 265 - background: transparent; 266 - color: var(--text-secondary); 267 - font-size: 0.85rem; 268 - font-weight: 500; 269 - border-radius: var(--radius-md); 270 - cursor: pointer; 271 - transition: all 0.1s; 272 - text-decoration: none; 273 - } 274 - 275 - .sidebar-user-menu-item:hover { 276 - background: var(--bg-hover); 277 - color: var(--text-primary); 278 - } 279 - 280 - .sidebar-user-menu-item.danger { 281 - color: var(--error); 282 - } 283 - 284 - .sidebar-user-menu-item.danger:hover { 285 - background: rgba(217, 119, 102, 0.1); 286 - } 287 - 288 - .right-sidebar { 289 - height: 100%; 290 - display: flex; 291 - flex-direction: column; 292 - gap: 32px; 293 - background: var(--bg-primary); 294 - border-left: 1px solid var(--border); 295 - padding: 24px 20px; 296 - overflow-y: auto; 297 - font-family: var(--font-sans); 298 - } 299 - 300 - .sidebar-section { 301 - display: flex; 302 - flex-direction: column; 303 - gap: 12px; 304 - } 305 - 306 - .sidebar-section-title { 307 - font-size: 0.75rem; 308 - font-weight: 700; 309 - text-transform: uppercase; 310 - letter-spacing: 0.05em; 311 - color: var(--text-tertiary); 312 - margin-bottom: 4px; 313 - } 314 - 315 - .sidebar-tags { 316 - display: flex; 317 - flex-wrap: wrap; 318 - gap: 8px; 319 - } 320 - 321 - .sidebar-tag-pill { 322 - padding: 6px 12px; 323 - background: var(--bg-tertiary); 324 - color: var(--text-secondary); 325 - border-radius: var(--radius-md); 326 - font-size: 0.8rem; 327 - font-weight: 500; 328 - text-decoration: none; 329 - transition: all 0.15s ease; 330 - border: 1px solid transparent; 331 - } 332 - 333 - .sidebar-tag-pill:hover { 334 - background: var(--bg-card); 335 - border-color: var(--border); 336 - color: var(--text-primary); 337 - transform: translateY(-1px); 338 - box-shadow: var(--shadow-sm); 339 - } 340 - 341 - .sidebar-extension-link { 342 - display: flex; 343 - align-items: center; 344 - gap: 12px; 345 - padding: 12px 14px; 346 - background: var(--bg-card); 347 - border: 1px solid var(--border); 348 - border-radius: var(--radius-lg); 349 - color: var(--text-primary); 350 - font-size: 0.85rem; 351 - font-weight: 600; 352 - text-decoration: none; 353 - transition: all 0.15s ease; 354 - box-shadow: var(--shadow-sm); 355 - } 356 - 357 - .sidebar-extension-link:hover { 358 - background: var(--bg-hover); 359 - border-color: var(--border-hover); 360 - transform: translateY(-1px); 361 - box-shadow: var(--shadow-md); 362 - } 363 - 364 - .sidebar-external-icon { 365 - margin-left: auto; 366 - opacity: 0.5; 367 - transition: transform 0.2s; 368 - } 369 - 370 - .sidebar-extension-link:hover .sidebar-external-icon { 371 - opacity: 1; 372 - color: var(--accent); 373 - transform: translate(2px, -2px); 374 - } 375 - 376 - .sidebar-links { 377 - display: flex; 378 - flex-direction: column; 379 - gap: 2px; 380 - } 381 - 382 - .sidebar-link-item { 383 - display: flex; 384 - align-items: center; 385 - gap: 12px; 386 - padding: 8px 12px; 387 - width: fit-content; 388 - color: var(--text-secondary); 389 - font-size: 0.85rem; 390 - font-weight: 500; 391 - text-decoration: none; 392 - transition: all 0.15s ease; 393 - border-radius: var(--radius-md); 394 - } 395 - 396 - .sidebar-link-item:hover { 397 - color: var(--text-primary); 398 - background: var(--bg-hover); 399 - transform: translateX(4px); 400 - } 401 - 402 - .sidebar-link-item svg { 403 - width: 18px; 404 - height: 18px; 405 - opacity: 0.7; 406 - } 407 - 408 - .sidebar-tangled-icon { 409 - width: 18px; 410 - height: 18px; 411 - background-color: var(--text-secondary); 412 - -webkit-mask-image: var(--tangled-logo); 413 - mask-image: var(--tangled-logo); 414 - -webkit-mask-size: contain; 415 - mask-size: contain; 416 - -webkit-mask-repeat: no-repeat; 417 - mask-repeat: no-repeat; 418 - -webkit-mask-position: center; 419 - mask-position: center; 420 - opacity: 0.7; 421 - transition: 422 - background-color 0.15s, 423 - opacity 0.15s; 424 - } 425 - 426 - .sidebar-link-item:hover svg { 427 - opacity: 1; 428 - color: var(--accent); 429 - } 430 - 431 - .sidebar-link-item:hover .sidebar-tangled-icon { 432 - background-color: var(--accent); 433 - opacity: 1; 434 - } 435 - 436 - .sidebar-theme-toggle { 437 - display: flex; 438 - align-items: center; 439 - gap: 12px; 440 - padding: 10px; 441 - background: var(--bg-tertiary); 442 - border: 1px solid transparent; 443 - border-radius: var(--radius-md); 444 - color: var(--text-secondary); 445 - font-size: 0.85rem; 446 - font-weight: 500; 447 - cursor: pointer; 448 - transition: all 0.15s ease; 449 - width: 100%; 450 - text-align: left; 451 - } 452 - 453 - .sidebar-theme-toggle:hover { 454 - background: var(--bg-card); 455 - border-color: var(--border); 456 - color: var(--text-primary); 457 - } 458 - 459 - .sidebar-footer-links { 460 - display: flex; 461 - align-items: center; 462 - gap: 12px; 463 - font-size: 0.75rem; 464 - color: var(--text-tertiary); 465 - margin-top: auto; 466 - padding-top: 12px; 467 - border-top: 1px solid var(--border); 468 - } 469 - 470 - .sidebar-footer-links a { 471 - color: var(--text-tertiary); 472 - text-decoration: none; 473 - transition: color 0.15s; 474 - } 475 - 476 - .sidebar-footer-links a:hover { 477 - color: var(--text-secondary); 478 - text-decoration: underline; 479 - } 480 - 481 - @media (max-width: 1024px) { 482 - .right-sidebar { 483 - display: none; 484 - } 485 - 486 - .app-layout { 487 - grid-template-columns: 240px 1fr; 488 - } 489 - } 490 - 491 - @media (max-width: 768px) { 492 - .left-sidebar { 493 - display: none; 494 - } 495 - 496 - .app-layout { 497 - grid-template-columns: 1fr; 498 - } 499 - }
-104
web/src/css/skeleton.css
··· 1 - @keyframes shimmer { 2 - 0% { 3 - background-position: -200% 0; 4 - } 5 - 100% { 6 - background-position: 200% 0; 7 - } 8 - } 9 - 10 - .skeleton { 11 - background: linear-gradient( 12 - 90deg, 13 - var(--bg-tertiary) 25%, 14 - var(--bg-hover) 50%, 15 - var(--bg-tertiary) 75% 16 - ); 17 - background-size: 200% 100%; 18 - animation: shimmer 1.5s infinite; 19 - border-radius: var(--radius-sm); 20 - } 21 - 22 - .skeleton-card { 23 - padding: var(--spacing-md); 24 - display: flex; 25 - flex-direction: column; 26 - gap: var(--spacing-sm); 27 - } 28 - 29 - .skeleton-header { 30 - display: flex; 31 - align-items: center; 32 - gap: var(--spacing-sm); 33 - } 34 - 35 - .skeleton-avatar { 36 - width: 32px; 37 - height: 32px; 38 - border-radius: var(--radius-full); 39 - flex-shrink: 0; 40 - } 41 - 42 - .skeleton-meta { 43 - display: flex; 44 - flex-direction: column; 45 - gap: 4px; 46 - } 47 - 48 - .skeleton-name { 49 - width: 100px; 50 - height: 12px; 51 - } 52 - 53 - .skeleton-handle { 54 - width: 70px; 55 - height: 10px; 56 - } 57 - 58 - .skeleton-content { 59 - display: flex; 60 - flex-direction: column; 61 - gap: var(--spacing-sm); 62 - padding-left: 40px; 63 - } 64 - 65 - .skeleton-source { 66 - width: 140px; 67 - height: 10px; 68 - } 69 - 70 - .skeleton-highlight { 71 - width: 100%; 72 - height: 48px; 73 - border-radius: var(--radius-sm); 74 - } 75 - 76 - .skeleton-text-1 { 77 - width: 85%; 78 - height: 12px; 79 - } 80 - 81 - .skeleton-text-2 { 82 - width: 55%; 83 - height: 12px; 84 - } 85 - 86 - .skeleton-actions { 87 - display: flex; 88 - gap: var(--spacing-md); 89 - padding-left: 40px; 90 - margin-top: var(--spacing-xs); 91 - } 92 - 93 - .skeleton-action { 94 - width: 20px; 95 - height: 20px; 96 - border-radius: var(--radius-sm); 97 - } 98 - 99 - @media (max-width: 768px) { 100 - .skeleton-content, 101 - .skeleton-actions { 102 - padding-left: 0; 103 - } 104 - }
-747
web/src/css/utilities.css
··· 1 - .legal-content { 2 - max-width: 800px; 3 - margin: 0 auto; 4 - padding: 20px; 5 - } 6 - 7 - .legal-content h1 { 8 - font-size: 2rem; 9 - margin-bottom: 8px; 10 - color: var(--text-primary); 11 - } 12 - 13 - .legal-content h2 { 14 - font-size: 1.4rem; 15 - margin-top: 32px; 16 - margin-bottom: 12px; 17 - color: var(--text-primary); 18 - } 19 - 20 - .legal-content h3 { 21 - font-size: 1.1rem; 22 - margin-top: 20px; 23 - margin-bottom: 8px; 24 - color: var(--text-primary); 25 - } 26 - 27 - .legal-content p { 28 - color: var(--text-secondary); 29 - line-height: 1.7; 30 - margin-bottom: 12px; 31 - } 32 - 33 - .legal-content ul { 34 - color: var(--text-secondary); 35 - line-height: 1.7; 36 - margin-left: 24px; 37 - margin-bottom: 12px; 38 - } 39 - 40 - .legal-content li { 41 - margin-bottom: 6px; 42 - } 43 - 44 - .legal-content a { 45 - color: var(--accent); 46 - text-decoration: none; 47 - } 48 - 49 - .legal-content a:hover { 50 - text-decoration: underline; 51 - } 52 - 53 - .legal-content section { 54 - margin-bottom: 24px; 55 - } 56 - 57 - .text-secondary { 58 - color: var(--text-secondary); 59 - } 60 - 61 - .text-error { 62 - color: var(--error); 63 - } 64 - 65 - .text-center { 66 - text-align: center; 67 - } 68 - 69 - .flex { 70 - display: flex; 71 - } 72 - 73 - .items-center { 74 - align-items: center; 75 - } 76 - 77 - .justify-center { 78 - justify-content: center; 79 - } 80 - 81 - .justify-end { 82 - justify-content: flex-end; 83 - } 84 - 85 - .gap-2 { 86 - gap: 8px; 87 - } 88 - 89 - .gap-3 { 90 - gap: 12px; 91 - } 92 - 93 - .mt-3 { 94 - margin-top: 12px; 95 - } 96 - 97 - .mb-6 { 98 - margin-bottom: 24px; 99 - } 100 - 101 - .composer { 102 - margin-bottom: 24px; 103 - } 104 - 105 - .composer-header { 106 - display: flex; 107 - justify-content: space-between; 108 - align-items: center; 109 - margin-bottom: 12px; 110 - } 111 - 112 - .composer-title { 113 - font-size: 1.1rem; 114 - font-weight: 600; 115 - color: var(--text-primary); 116 - margin: 0; 117 - } 118 - 119 - .composer-input { 120 - width: 100%; 121 - min-height: 120px; 122 - padding: 16px; 123 - background: var(--bg-secondary); 124 - border: 1px solid var(--border); 125 - border-radius: var(--radius-md); 126 - color: var(--text-primary); 127 - font-size: 1rem; 128 - resize: vertical; 129 - transition: all 0.15s ease; 130 - } 131 - 132 - .composer-input:focus { 133 - outline: none; 134 - border-color: var(--accent); 135 - box-shadow: 0 0 0 3px var(--accent-subtle); 136 - } 137 - 138 - .composer-footer { 139 - display: flex; 140 - justify-content: space-between; 141 - align-items: center; 142 - margin-top: 12px; 143 - } 144 - 145 - .composer-actions { 146 - display: flex; 147 - justify-content: flex-end; 148 - gap: 8px; 149 - } 150 - 151 - .composer-count { 152 - font-size: 0.85rem; 153 - color: var(--text-tertiary); 154 - } 155 - 156 - .composer-count.warning { 157 - color: var(--warning); 158 - } 159 - 160 - .composer-count.error { 161 - color: var(--error); 162 - } 163 - 164 - .composer-char-count.warning { 165 - color: var(--warning); 166 - } 167 - 168 - .composer-char-count.error { 169 - color: var(--error); 170 - } 171 - 172 - .composer-add-quote { 173 - width: 100%; 174 - padding: 12px 16px; 175 - margin-bottom: 12px; 176 - background: var(--bg-tertiary); 177 - border: 1px dashed var(--border); 178 - border-radius: var(--radius-md); 179 - color: var(--text-secondary); 180 - font-size: 0.9rem; 181 - cursor: pointer; 182 - transition: all 0.15s ease; 183 - } 184 - 185 - .composer-add-quote:hover { 186 - border-color: var(--accent); 187 - color: var(--accent); 188 - background: var(--accent-subtle); 189 - } 190 - 191 - .composer-quote-input-wrapper { 192 - margin-bottom: 12px; 193 - } 194 - 195 - .composer-quote-input { 196 - width: 100%; 197 - padding: 12px 16px; 198 - background: linear-gradient( 199 - 135deg, 200 - rgba(79, 70, 229, 0.05), 201 - rgba(168, 85, 247, 0.05) 202 - ); 203 - border: 1px solid var(--border); 204 - border-left: 3px solid var(--accent); 205 - border-radius: 0 var(--radius-md) var(--radius-md) 0; 206 - color: var(--text-primary); 207 - font-size: 0.95rem; 208 - font-style: italic; 209 - resize: vertical; 210 - font-family: inherit; 211 - transition: all 0.15s ease; 212 - } 213 - 214 - .composer-quote-input:focus { 215 - outline: none; 216 - border-color: var(--accent); 217 - } 218 - 219 - .composer-quote-input::placeholder { 220 - color: var(--text-tertiary); 221 - font-style: italic; 222 - } 223 - 224 - .composer-quote-remove-btn { 225 - margin-top: 8px; 226 - padding: 6px 12px; 227 - background: none; 228 - border: none; 229 - color: var(--text-tertiary); 230 - font-size: 0.85rem; 231 - cursor: pointer; 232 - } 233 - 234 - .composer-quote-remove-btn:hover { 235 - color: var(--error); 236 - } 237 - 238 - .composer-error { 239 - margin-top: 12px; 240 - padding: 12px; 241 - background: rgba(239, 68, 68, 0.1); 242 - border: 1px solid rgba(239, 68, 68, 0.3); 243 - border-radius: var(--radius-md); 244 - color: var(--error); 245 - font-size: 0.9rem; 246 - } 247 - 248 - .composer-url { 249 - font-size: 0.85rem; 250 - color: var(--text-secondary); 251 - word-break: break-all; 252 - } 253 - 254 - .composer-quote { 255 - position: relative; 256 - padding: 12px 16px; 257 - padding-right: 36px; 258 - background: var(--bg-secondary); 259 - border-left: 3px solid var(--accent); 260 - border-radius: 0 var(--radius-sm) var(--radius-sm) 0; 261 - margin-bottom: 16px; 262 - font-style: italic; 263 - color: var(--text-secondary); 264 - overflow-wrap: break-word; 265 - word-break: break-word; 266 - max-width: 100%; 267 - } 268 - 269 - .composer-quote-remove { 270 - position: absolute; 271 - top: 8px; 272 - right: 8px; 273 - width: 24px; 274 - height: 24px; 275 - border-radius: var(--radius-full); 276 - background: var(--bg-tertiary); 277 - color: var(--text-secondary); 278 - font-size: 1rem; 279 - display: flex; 280 - align-items: center; 281 - justify-content: center; 282 - } 283 - 284 - .composer-quote-remove:hover { 285 - background: var(--bg-hover); 286 - color: var(--text-primary); 287 - } 288 - 289 - .composer-tags { 290 - flex: 1; 291 - } 292 - 293 - .composer-meta-row { 294 - display: flex; 295 - gap: 12px; 296 - margin-top: 12px; 297 - align-items: flex-start; 298 - } 299 - 300 - .composer-labels-wrapper { 301 - position: relative; 302 - } 303 - 304 - .composer-labels-btn { 305 - display: flex; 306 - align-items: center; 307 - justify-content: center; 308 - width: 42px; 309 - height: 42px; 310 - background: var(--bg-secondary); 311 - border: 1px solid var(--border); 312 - border-radius: var(--radius-md); 313 - cursor: pointer; 314 - color: var(--text-tertiary); 315 - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); 316 - position: relative; 317 - } 318 - 319 - .composer-labels-btn:hover { 320 - color: var(--text-primary); 321 - background: var(--bg-hover); 322 - border-color: var(--text-tertiary); 323 - } 324 - 325 - .composer-labels-btn.active { 326 - color: var(--accent); 327 - background: var(--accent-subtle); 328 - border-color: var(--accent); 329 - } 330 - 331 - .composer-labels-badge { 332 - position: absolute; 333 - top: -4px; 334 - right: -4px; 335 - background: var(--error); 336 - color: white; 337 - font-size: 0.7rem; 338 - width: 18px; 339 - height: 18px; 340 - border-radius: 50%; 341 - display: flex; 342 - align-items: center; 343 - justify-content: center; 344 - font-weight: bold; 345 - border: 2px solid var(--bg-primary); 346 - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); 347 - } 348 - 349 - .composer-labels-picker { 350 - position: absolute; 351 - bottom: 100%; 352 - right: 0; 353 - margin-bottom: 12px; 354 - background: var(--bg-elevated); 355 - border: 1px solid var(--border); 356 - border-radius: var(--radius-md); 357 - padding: 8px 0; 358 - min-width: 200px; 359 - box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25); 360 - z-index: 50; 361 - animation: scaleIn 0.2s ease-out forwards; 362 - transform-origin: bottom right; 363 - } 364 - 365 - @keyframes scaleIn { 366 - from { 367 - opacity: 0; 368 - transform: scale(0.95) translateY(5px); 369 - } 370 - 371 - to { 372 - opacity: 1; 373 - transform: scale(1) translateY(0); 374 - } 375 - } 376 - 377 - .picker-header { 378 - font-size: 0.75rem; 379 - font-weight: 600; 380 - color: var(--text-tertiary); 381 - text-transform: uppercase; 382 - letter-spacing: 0.05em; 383 - margin-bottom: 4px; 384 - padding: 4px 12px 8px; 385 - border-bottom: 1px solid var(--border); 386 - } 387 - 388 - .picker-item { 389 - display: flex; 390 - align-items: center; 391 - gap: 10px; 392 - padding: 10px 14px; 393 - cursor: pointer; 394 - color: var(--text-secondary); 395 - font-size: 0.9rem; 396 - transition: all 0.15s ease; 397 - user-select: none; 398 - } 399 - 400 - .picker-item:hover { 401 - background: var(--bg-hover); 402 - color: var(--text-primary); 403 - } 404 - 405 - .picker-checkbox-wrapper { 406 - position: relative; 407 - width: 18px; 408 - height: 18px; 409 - display: flex; 410 - align-items: center; 411 - justify-content: center; 412 - } 413 - 414 - .picker-checkbox-wrapper input { 415 - position: absolute; 416 - opacity: 0; 417 - width: 100%; 418 - height: 100%; 419 - cursor: pointer; 420 - z-index: 10; 421 - } 422 - 423 - .picker-checkbox-custom { 424 - width: 18px; 425 - height: 18px; 426 - border: 2px solid var(--text-tertiary); 427 - border-radius: 4px; 428 - display: flex; 429 - align-items: center; 430 - justify-content: center; 431 - background: transparent; 432 - transition: all 0.2s ease; 433 - color: white; 434 - } 435 - 436 - .picker-item:hover .picker-checkbox-custom { 437 - border-color: var(--text-secondary); 438 - } 439 - 440 - .picker-checkbox-wrapper input:checked + .picker-checkbox-custom { 441 - background: var(--accent); 442 - border-color: var(--accent); 443 - color: white; 444 - } 445 - 446 - .composer-tags-input { 447 - width: 100%; 448 - padding: 12px 16px; 449 - background: var(--bg-secondary); 450 - border: 1px solid var(--border); 451 - border-radius: var(--radius-md); 452 - color: var(--text-primary); 453 - font-size: 0.95rem; 454 - transition: all 0.15s ease; 455 - } 456 - 457 - .composer-tags-input:focus { 458 - outline: none; 459 - border-color: var(--accent); 460 - box-shadow: 0 0 0 3px var(--accent-subtle); 461 - } 462 - 463 - .composer-tags-input::placeholder { 464 - color: var(--text-tertiary); 465 - } 466 - 467 - .history-panel { 468 - background: var(--bg-tertiary); 469 - border: 1px solid var(--border); 470 - border-radius: var(--radius-md); 471 - padding: 1rem; 472 - margin-bottom: 1rem; 473 - font-size: 0.9rem; 474 - animation: fadeIn 0.2s ease-out; 475 - } 476 - 477 - .history-header { 478 - display: flex; 479 - justify-content: space-between; 480 - align-items: center; 481 - margin-bottom: 1rem; 482 - padding-bottom: 0.5rem; 483 - border-bottom: 1px solid var(--border); 484 - } 485 - 486 - .history-title { 487 - font-weight: 600; 488 - text-transform: uppercase; 489 - letter-spacing: 0.05em; 490 - font-size: 0.75rem; 491 - color: var(--text-secondary); 492 - } 493 - 494 - .history-list { 495 - list-style: none; 496 - display: flex; 497 - flex-direction: column; 498 - gap: 1rem; 499 - } 500 - 501 - .history-item { 502 - position: relative; 503 - padding-left: 1rem; 504 - border-left: 2px solid var(--border); 505 - } 506 - 507 - .history-date { 508 - font-size: 0.75rem; 509 - color: var(--text-tertiary); 510 - margin-bottom: 0.25rem; 511 - } 512 - 513 - .history-content { 514 - color: var(--text-secondary); 515 - white-space: pre-wrap; 516 - } 517 - 518 - .history-close-btn { 519 - color: var(--text-tertiary); 520 - padding: 4px; 521 - border-radius: var(--radius-sm); 522 - transition: all 0.2s; 523 - display: flex; 524 - align-items: center; 525 - justify-content: center; 526 - } 527 - 528 - .history-close-btn:hover { 529 - background: var(--bg-hover); 530 - color: var(--text-primary); 531 - } 532 - 533 - .history-status { 534 - text-align: center; 535 - color: var(--text-tertiary); 536 - font-style: italic; 537 - padding: 1rem; 538 - } 539 - 540 - .share-menu-container { 541 - position: relative; 542 - } 543 - 544 - .share-menu { 545 - background: var(--bg-elevated); 546 - border: 1px solid var(--border); 547 - border-radius: var(--radius-lg); 548 - box-shadow: var(--shadow-lg); 549 - min-width: 220px; 550 - max-width: calc(100vw - 32px); 551 - padding: 8px; 552 - z-index: 9999; 553 - animation: fadeInUp 0.15s ease; 554 - } 555 - 556 - @keyframes fadeInUp { 557 - from { 558 - opacity: 0; 559 - transform: translateY(-8px); 560 - } 561 - 562 - to { 563 - opacity: 1; 564 - transform: translateY(0); 565 - } 566 - } 567 - 568 - .share-menu-section { 569 - display: flex; 570 - flex-direction: column; 571 - } 572 - 573 - .share-menu-label { 574 - padding: 4px 12px 8px; 575 - font-size: 0.7rem; 576 - font-weight: 600; 577 - text-transform: uppercase; 578 - letter-spacing: 0.05em; 579 - color: var(--text-tertiary); 580 - } 581 - 582 - .share-menu-item { 583 - display: flex; 584 - align-items: center; 585 - gap: 10px; 586 - padding: 10px 14px; 587 - background: none; 588 - border: none; 589 - border-radius: var(--radius-md); 590 - width: 100%; 591 - text-align: left; 592 - font-size: 0.875rem; 593 - color: var(--text-primary); 594 - cursor: pointer; 595 - transition: all 0.1s ease; 596 - } 597 - 598 - .share-menu-item:hover { 599 - background: var(--bg-hover); 600 - } 601 - 602 - .share-menu-icon { 603 - font-size: 1.1rem; 604 - width: 24px; 605 - text-align: center; 606 - } 607 - 608 - .share-menu-divider { 609 - height: 1px; 610 - background: var(--border); 611 - margin: 6px 0; 612 - } 613 - 614 - .bookmark-card { 615 - display: flex; 616 - flex-direction: column; 617 - gap: 16px; 618 - } 619 - 620 - .bookmark-preview { 621 - display: flex; 622 - flex-direction: column; 623 - background: var(--bg-secondary); 624 - border: 1px solid var(--border); 625 - border-radius: var(--radius-md); 626 - overflow: hidden; 627 - text-decoration: none; 628 - transition: all 0.2s ease; 629 - position: relative; 630 - } 631 - 632 - .bookmark-preview:hover { 633 - border-color: var(--accent); 634 - box-shadow: var(--shadow-sm); 635 - transform: translateY(-1px); 636 - } 637 - 638 - .bookmark-preview::before { 639 - content: ""; 640 - position: absolute; 641 - left: 0; 642 - top: 0; 643 - bottom: 0; 644 - width: 4px; 645 - background: var(--accent); 646 - opacity: 0.7; 647 - } 648 - 649 - .bookmark-preview-content { 650 - padding: 16px 20px; 651 - display: flex; 652 - flex-direction: column; 653 - gap: 8px; 654 - } 655 - 656 - .bookmark-preview-header { 657 - display: flex; 658 - align-items: center; 659 - gap: 8px; 660 - margin-bottom: 4px; 661 - } 662 - 663 - .bookmark-preview-site { 664 - display: flex; 665 - align-items: center; 666 - gap: 6px; 667 - font-size: 0.75rem; 668 - font-weight: 600; 669 - color: var(--accent); 670 - text-transform: uppercase; 671 - letter-spacing: 0.03em; 672 - } 673 - 674 - .bookmark-preview-title { 675 - font-size: 1rem; 676 - font-weight: 600; 677 - line-height: 1.4; 678 - color: var(--text-primary); 679 - margin: 0; 680 - display: -webkit-box; 681 - -webkit-line-clamp: 2; 682 - line-clamp: 2; 683 - -webkit-box-orient: vertical; 684 - overflow: hidden; 685 - } 686 - 687 - .bookmark-preview-desc { 688 - font-size: 0.875rem; 689 - color: var(--text-secondary); 690 - line-height: 1.5; 691 - margin: 0; 692 - display: -webkit-box; 693 - -webkit-line-clamp: 2; 694 - line-clamp: 2; 695 - -webkit-box-orient: vertical; 696 - overflow: hidden; 697 - } 698 - 699 - .bookmark-preview-arrow { 700 - display: flex; 701 - align-items: center; 702 - justify-content: center; 703 - color: var(--text-tertiary); 704 - padding: 0 4px; 705 - transition: all 0.2s ease; 706 - } 707 - 708 - .bookmark-preview:hover .bookmark-preview-arrow { 709 - color: var(--accent); 710 - transform: translateX(2px); 711 - } 712 - 713 - .bookmark-description { 714 - font-size: 0.9rem; 715 - color: var(--text-secondary); 716 - margin: 0; 717 - line-height: 1.5; 718 - } 719 - 720 - .bookmark-meta { 721 - display: flex; 722 - align-items: center; 723 - gap: 12px; 724 - margin-top: 12px; 725 - font-size: 0.85rem; 726 - color: var(--text-tertiary); 727 - } 728 - 729 - .bookmark-time { 730 - color: var(--text-tertiary); 731 - } 732 - 733 - .bookmark-preview { 734 - max-width: 100%; 735 - width: 100%; 736 - box-sizing: border-box; 737 - } 738 - 739 - @media (max-width: 600px) { 740 - .bookmark-preview-content { 741 - padding: 12px 14px; 742 - } 743 - 744 - .legal-content { 745 - padding: 16px; 746 - } 747 - }
-15
web/src/index.css
··· 1 - @import "./css/layout.css"; 2 - @import "./css/sidebar.css"; 3 - @import "./css/base.css"; 4 - @import "./css/buttons.css"; 5 - @import "./css/cards.css"; 6 - @import "./css/feed.css"; 7 - @import "./css/profile.css"; 8 - @import "./css/login.css"; 9 - @import "./css/annotations.css"; 10 - @import "./css/collections.css"; 11 - @import "./css/modals.css"; 12 - @import "./css/notifications.css"; 13 - @import "./css/skeleton.css"; 14 - @import "./css/utilities.css"; 15 - @import "./css/landing.css";
-13
web/src/main.jsx
··· 1 - import React from "react"; 2 - import ReactDOM from "react-dom/client"; 3 - import { BrowserRouter } from "react-router-dom"; 4 - import App from "./App"; 5 - import "./index.css"; 6 - 7 - ReactDOM.createRoot(document.getElementById("root")).render( 8 - <React.StrictMode> 9 - <BrowserRouter> 10 - <App /> 11 - </BrowserRouter> 12 - </React.StrictMode>, 13 - );
-254
web/src/pages/AnnotationDetail.jsx
··· 1 - import { useState, useEffect } from "react"; 2 - import { useParams, Link, useLocation, useNavigate } from "react-router-dom"; 3 - import AnnotationCard, { HighlightCard } from "../components/AnnotationCard"; 4 - import BookmarkCard from "../components/BookmarkCard"; 5 - import ReplyList from "../components/ReplyList"; 6 - import { 7 - getAnnotation, 8 - getReplies, 9 - createReply, 10 - deleteReply, 11 - resolveHandle, 12 - normalizeAnnotation, 13 - } from "../api/client"; 14 - import { useAuth } from "../context/AuthContext"; 15 - import { MessageSquare } from "lucide-react"; 16 - 17 - export default function AnnotationDetail() { 18 - const { uri, did, rkey, handle, type } = useParams(); 19 - const location = useLocation(); 20 - const navigate = useNavigate(); 21 - const { isAuthenticated, user } = useAuth(); 22 - const [annotation, setAnnotation] = useState(null); 23 - const [replies, setReplies] = useState([]); 24 - const [loading, setLoading] = useState(true); 25 - const [error, setError] = useState(null); 26 - 27 - const [replyText, setReplyText] = useState(""); 28 - const [posting, setPosting] = useState(false); 29 - const [replyingTo, setReplyingTo] = useState(null); 30 - 31 - const [targetUri, setTargetUri] = useState(uri); 32 - 33 - useEffect(() => { 34 - async function resolve() { 35 - if (uri) { 36 - setTargetUri(uri); 37 - return; 38 - } 39 - 40 - if (handle && rkey) { 41 - let collection = "at.margin.annotation"; 42 - if (type === "highlight") collection = "at.margin.highlight"; 43 - if (type === "bookmark") collection = "at.margin.bookmark"; 44 - 45 - try { 46 - const resolvedDid = await resolveHandle(handle); 47 - if (resolvedDid) { 48 - setTargetUri(`at://${resolvedDid}/${collection}/${rkey}`); 49 - } 50 - } catch (e) { 51 - console.error("Failed to resolve handle:", e); 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 ( 59 - atIndex !== -1 && 60 - pathParts[atIndex + 1] && 61 - pathParts[atIndex + 2] 62 - ) { 63 - setTargetUri( 64 - `at://${pathParts[atIndex + 1]}/at.margin.annotation/${pathParts[atIndex + 2]}`, 65 - ); 66 - } 67 - } 68 - } 69 - resolve(); 70 - }, [uri, did, rkey, handle, type, location.pathname]); 71 - 72 - const refreshReplies = async () => { 73 - if (!targetUri) return; 74 - const repliesData = await getReplies(targetUri); 75 - setReplies(repliesData.items || []); 76 - }; 77 - 78 - useEffect(() => { 79 - async function fetchData() { 80 - if (!targetUri) return; 81 - 82 - try { 83 - setLoading(true); 84 - const [annData, repliesData] = await Promise.all([ 85 - getAnnotation(targetUri), 86 - getReplies(targetUri).catch(() => ({ items: [] })), 87 - ]); 88 - setAnnotation(normalizeAnnotation(annData)); 89 - setReplies(repliesData.items || []); 90 - } catch (err) { 91 - setError(err.message); 92 - } finally { 93 - setLoading(false); 94 - } 95 - } 96 - fetchData(); 97 - }, [targetUri]); 98 - 99 - const handleReply = async (e) => { 100 - if (e) e.preventDefault(); 101 - if (!replyText.trim()) return; 102 - 103 - try { 104 - setPosting(true); 105 - const parentUri = replyingTo 106 - ? replyingTo.id || replyingTo.uri 107 - : targetUri; 108 - const parentCid = replyingTo 109 - ? replyingTo.cid || "" 110 - : annotation?.cid || ""; 111 - 112 - await createReply({ 113 - parentUri, 114 - parentCid, 115 - rootUri: targetUri, 116 - rootCid: annotation?.cid || "", 117 - text: replyText, 118 - }); 119 - setReplyText(""); 120 - setReplyingTo(null); 121 - await refreshReplies(); 122 - } catch (err) { 123 - alert("Failed to post reply: " + err.message); 124 - } finally { 125 - setPosting(false); 126 - } 127 - }; 128 - 129 - const handleDeleteReply = async (reply) => { 130 - if (!confirm("Delete this reply?")) return; 131 - try { 132 - await deleteReply(reply.id || reply.uri); 133 - await refreshReplies(); 134 - } catch (err) { 135 - alert("Failed to delete: " + err.message); 136 - } 137 - }; 138 - 139 - if (loading) { 140 - return ( 141 - <div className="annotation-detail-page"> 142 - <div className="card"> 143 - <div className="skeleton skeleton-text" style={{ width: "40%" }} /> 144 - <div className="skeleton skeleton-text" /> 145 - <div className="skeleton skeleton-text" style={{ width: "60%" }} /> 146 - </div> 147 - </div> 148 - ); 149 - } 150 - 151 - if (error || !annotation) { 152 - return ( 153 - <div className="annotation-detail-page"> 154 - <div className="empty-state"> 155 - <div className="empty-state-icon">⚠️</div> 156 - <h3 className="empty-state-title">Annotation not found</h3> 157 - <p className="empty-state-text"> 158 - {error || "This annotation may have been deleted."} 159 - </p> 160 - <Link 161 - to="/home" 162 - className="btn btn-primary" 163 - style={{ marginTop: "16px" }} 164 - > 165 - Back to Feed 166 - </Link> 167 - </div> 168 - </div> 169 - ); 170 - } 171 - 172 - return ( 173 - <div className="annotation-detail-page"> 174 - <div className="annotation-detail-header"> 175 - <Link to="/home" className="back-link"> 176 - ← Back to Feed 177 - </Link> 178 - </div> 179 - 180 - {annotation.type === "Highlight" ? ( 181 - <HighlightCard 182 - highlight={annotation} 183 - onDelete={() => navigate("/home")} 184 - /> 185 - ) : annotation.type === "Bookmark" ? ( 186 - <BookmarkCard 187 - bookmark={annotation} 188 - onDelete={() => navigate("/home")} 189 - /> 190 - ) : ( 191 - <AnnotationCard annotation={annotation} /> 192 - )} 193 - 194 - {annotation.type !== "Bookmark" && annotation.type !== "Highlight" && ( 195 - <div className="replies-section"> 196 - <h3 className="replies-title"> 197 - <MessageSquare size={18} /> 198 - Replies ({replies.length}) 199 - </h3> 200 - 201 - {isAuthenticated && ( 202 - <div className="reply-form card"> 203 - {replyingTo && ( 204 - <div className="replying-to-banner"> 205 - <span> 206 - Replying to @ 207 - {(replyingTo.creator || replyingTo.author)?.handle || 208 - "unknown"} 209 - </span> 210 - <button 211 - onClick={() => setReplyingTo(null)} 212 - className="cancel-reply" 213 - > 214 - × 215 - </button> 216 - </div> 217 - )} 218 - <textarea 219 - value={replyText} 220 - onChange={(e) => setReplyText(e.target.value)} 221 - placeholder={ 222 - replyingTo 223 - ? `Reply to @${(replyingTo.creator || replyingTo.author)?.handle}...` 224 - : "Write a reply..." 225 - } 226 - className="reply-input" 227 - rows={3} 228 - disabled={posting} 229 - /> 230 - <div className="reply-form-actions"> 231 - <button 232 - className="btn btn-primary" 233 - disabled={posting || !replyText.trim()} 234 - onClick={() => handleReply()} 235 - > 236 - {posting ? "Posting..." : "Reply"} 237 - </button> 238 - </div> 239 - </div> 240 - )} 241 - 242 - <ReplyList 243 - replies={replies} 244 - rootUri={targetUri} 245 - user={user} 246 - onReply={(reply) => setReplyingTo(reply)} 247 - onDelete={handleDeleteReply} 248 - isInline={false} 249 - /> 250 - </div> 251 - )} 252 - </div> 253 - ); 254 - }
-333
web/src/pages/Bookmarks.jsx
··· 1 - import { useState, useEffect, useCallback } from "react"; 2 - import { Link } from "react-router-dom"; 3 - import { Plus } from "lucide-react"; 4 - import { useAuth } from "../context/AuthContext"; 5 - import { 6 - getUserBookmarks, 7 - deleteBookmark, 8 - createBookmark, 9 - getURLMetadata, 10 - } from "../api/client"; 11 - import { BookmarkIcon } from "../components/Icons"; 12 - import BookmarkCard from "../components/BookmarkCard"; 13 - import CollectionItemCard from "../components/CollectionItemCard"; 14 - import AddToCollectionModal from "../components/AddToCollectionModal"; 15 - 16 - export default function Bookmarks() { 17 - const { user, isAuthenticated, loading } = useAuth(); 18 - const [bookmarks, setBookmarks] = useState([]); 19 - const [loadingBookmarks, setLoadingBookmarks] = useState(true); 20 - const [error, setError] = useState(null); 21 - const [showAddForm, setShowAddForm] = useState(false); 22 - const [newUrl, setNewUrl] = useState(""); 23 - const [newTitle, setNewTitle] = useState(""); 24 - const [submitting, setSubmitting] = useState(false); 25 - const [fetchingTitle, setFetchingTitle] = useState(false); 26 - const [collectionModalState, setCollectionModalState] = useState({ 27 - isOpen: false, 28 - uri: null, 29 - }); 30 - 31 - const loadBookmarks = useCallback(async () => { 32 - if (!user?.did) return; 33 - 34 - try { 35 - setLoadingBookmarks(true); 36 - const data = await getUserBookmarks(user.did); 37 - setBookmarks(data.items || []); 38 - } catch (err) { 39 - console.error("Failed to load bookmarks:", err); 40 - setError(err.message); 41 - } finally { 42 - setLoadingBookmarks(false); 43 - } 44 - }, [user]); 45 - 46 - useEffect(() => { 47 - if (isAuthenticated && user) { 48 - loadBookmarks(); 49 - } 50 - }, [isAuthenticated, user, loadBookmarks]); 51 - 52 - const handleDelete = async (uri) => { 53 - if (!confirm("Delete this bookmark?")) return; 54 - 55 - try { 56 - const parts = uri.split("/"); 57 - const rkey = parts[parts.length - 1]; 58 - await deleteBookmark(rkey); 59 - setBookmarks((prev) => prev.filter((b) => (b.id || b.uri) !== uri)); 60 - } catch (err) { 61 - alert("Failed to delete: " + err.message); 62 - } 63 - }; 64 - 65 - const handleUrlBlur = async () => { 66 - if (!newUrl.trim() || newTitle.trim()) return; 67 - try { 68 - new URL(newUrl); 69 - } catch { 70 - return; 71 - } 72 - try { 73 - setFetchingTitle(true); 74 - const data = await getURLMetadata(newUrl.trim()); 75 - if (data.title && !newTitle) { 76 - setNewTitle(data.title); 77 - } 78 - } catch (err) { 79 - console.error("Failed to fetch title:", err); 80 - } finally { 81 - setFetchingTitle(false); 82 - } 83 - }; 84 - 85 - const handleAddBookmark = async (e) => { 86 - e.preventDefault(); 87 - if (!newUrl.trim()) return; 88 - 89 - try { 90 - setSubmitting(true); 91 - await createBookmark(newUrl.trim(), newTitle.trim() || undefined); 92 - setNewUrl(""); 93 - setNewTitle(""); 94 - setShowAddForm(false); 95 - await loadBookmarks(); 96 - } catch (err) { 97 - alert("Failed to add bookmark: " + err.message); 98 - } finally { 99 - setSubmitting(false); 100 - } 101 - }; 102 - 103 - if (loading) 104 - return ( 105 - <div className="page-loading"> 106 - <div className="spinner"></div> 107 - </div> 108 - ); 109 - 110 - if (!isAuthenticated) { 111 - return ( 112 - <div className="new-page"> 113 - <div className="card" style={{ textAlign: "center", padding: "48px" }}> 114 - <h2>Sign in to view your bookmarks</h2> 115 - <p style={{ color: "var(--text-secondary)", marginTop: "8px" }}> 116 - You need to be logged in with your Bluesky account 117 - </p> 118 - <Link 119 - to="/login" 120 - className="btn btn-primary" 121 - style={{ marginTop: "24px" }} 122 - > 123 - Sign in with Bluesky 124 - </Link> 125 - </div> 126 - </div> 127 - ); 128 - } 129 - 130 - return ( 131 - <div className="feed-page"> 132 - <div 133 - className="page-header" 134 - style={{ 135 - display: "flex", 136 - justifyContent: "space-between", 137 - alignItems: "flex-start", 138 - }} 139 - > 140 - <div> 141 - <h1 className="page-title">My Bookmarks</h1> 142 - <p className="page-description">Pages you&apos;ve saved for later</p> 143 - </div> 144 - <button 145 - onClick={() => setShowAddForm(!showAddForm)} 146 - className="btn btn-primary" 147 - > 148 - <Plus size={20} /> 149 - Add Bookmark 150 - </button> 151 - </div> 152 - 153 - {showAddForm && ( 154 - <div className="card" style={{ marginBottom: "20px", padding: "24px" }}> 155 - <h3 156 - style={{ 157 - marginBottom: "16px", 158 - fontSize: "1.1rem", 159 - color: "var(--text-primary)", 160 - }} 161 - > 162 - Add a Bookmark 163 - </h3> 164 - <form onSubmit={handleAddBookmark}> 165 - <div 166 - style={{ display: "flex", flexDirection: "column", gap: "16px" }} 167 - > 168 - <div> 169 - <label 170 - style={{ 171 - display: "block", 172 - marginBottom: "6px", 173 - fontSize: "0.85rem", 174 - color: "var(--text-secondary)", 175 - }} 176 - > 177 - URL * 178 - </label> 179 - <input 180 - type="url" 181 - placeholder="https://example.com/article" 182 - value={newUrl} 183 - onChange={(e) => setNewUrl(e.target.value)} 184 - onBlur={handleUrlBlur} 185 - className="input" 186 - style={{ width: "100%" }} 187 - required 188 - autoFocus 189 - /> 190 - </div> 191 - <div> 192 - <label 193 - style={{ 194 - display: "block", 195 - marginBottom: "6px", 196 - fontSize: "0.85rem", 197 - color: "var(--text-secondary)", 198 - }} 199 - > 200 - Title{" "} 201 - {fetchingTitle ? ( 202 - <span style={{ color: "var(--accent)" }}>Fetching...</span> 203 - ) : ( 204 - <span style={{ color: "var(--text-tertiary)" }}> 205 - (auto-fetched) 206 - </span> 207 - )} 208 - </label> 209 - <input 210 - type="text" 211 - placeholder={ 212 - fetchingTitle 213 - ? "Fetching title..." 214 - : "Page title will be fetched automatically" 215 - } 216 - value={newTitle} 217 - onChange={(e) => setNewTitle(e.target.value)} 218 - className="input" 219 - style={{ width: "100%" }} 220 - /> 221 - </div> 222 - <div 223 - style={{ 224 - display: "flex", 225 - gap: "10px", 226 - justifyContent: "flex-end", 227 - marginTop: "8px", 228 - }} 229 - > 230 - <button 231 - type="button" 232 - onClick={() => { 233 - setShowAddForm(false); 234 - setNewUrl(""); 235 - setNewTitle(""); 236 - }} 237 - className="btn btn-secondary" 238 - > 239 - Cancel 240 - </button> 241 - <button 242 - type="submit" 243 - className="btn btn-primary" 244 - disabled={submitting || !newUrl.trim()} 245 - > 246 - {submitting ? "Adding..." : "Save Bookmark"} 247 - </button> 248 - </div> 249 - </div> 250 - </form> 251 - </div> 252 - )} 253 - 254 - {loadingBookmarks ? ( 255 - <div className="feed-container"> 256 - <div className="feed"> 257 - {[1, 2, 3].map((i) => ( 258 - <div key={i} className="card"> 259 - <div 260 - className="skeleton skeleton-text" 261 - style={{ width: "40%" }} 262 - ></div> 263 - <div className="skeleton skeleton-text"></div> 264 - <div 265 - className="skeleton skeleton-text" 266 - style={{ width: "60%" }} 267 - ></div> 268 - </div> 269 - ))} 270 - </div> 271 - </div> 272 - ) : error ? ( 273 - <div className="empty-state"> 274 - <div className="empty-state-icon">⚠️</div> 275 - <h3 className="empty-state-title">Error loading bookmarks</h3> 276 - <p className="empty-state-text">{error}</p> 277 - </div> 278 - ) : bookmarks.length === 0 ? ( 279 - <div className="empty-state"> 280 - <div className="empty-state-icon"> 281 - <BookmarkIcon size={32} /> 282 - </div> 283 - <h3 className="empty-state-title">No bookmarks yet</h3> 284 - <p className="empty-state-text"> 285 - Click &quot;Add Bookmark&quot; above to save a page, or use the 286 - browser extension. 287 - </p> 288 - </div> 289 - ) : ( 290 - <div className="feed-container"> 291 - <div className="feed"> 292 - {bookmarks.map((bookmark) => { 293 - if (bookmark.type === "CollectionItem") { 294 - return ( 295 - <CollectionItemCard 296 - key={bookmark.id} 297 - item={bookmark} 298 - onAddToCollection={(uri) => 299 - setCollectionModalState({ 300 - isOpen: true, 301 - uri: uri, 302 - }) 303 - } 304 - /> 305 - ); 306 - } 307 - return ( 308 - <BookmarkCard 309 - key={bookmark.id} 310 - bookmark={bookmark} 311 - onDelete={handleDelete} 312 - onAddToCollection={() => 313 - setCollectionModalState({ 314 - isOpen: true, 315 - uri: bookmark.uri || bookmark.id, 316 - }) 317 - } 318 - /> 319 - ); 320 - })} 321 - </div> 322 - </div> 323 - )} 324 - {collectionModalState.isOpen && ( 325 - <AddToCollectionModal 326 - isOpen={collectionModalState.isOpen} 327 - onClose={() => setCollectionModalState({ isOpen: false, uri: null })} 328 - annotationUri={collectionModalState.uri} 329 - /> 330 - )} 331 - </div> 332 - ); 333 - }
-314
web/src/pages/CollectionDetail.jsx
··· 1 - import { useState, useEffect } from "react"; 2 - import { useParams, useNavigate, Link, useLocation } from "react-router-dom"; 3 - import { ArrowLeft, Edit2, Trash2, Plus, ExternalLink } from "lucide-react"; 4 - import { 5 - getCollection, 6 - getCollectionItems, 7 - removeItemFromCollection, 8 - deleteCollection, 9 - resolveHandle, 10 - } from "../api/client"; 11 - import { useAuth } from "../context/AuthContext"; 12 - import CollectionModal from "../components/CollectionModal"; 13 - import CollectionIcon from "../components/CollectionIcon"; 14 - import AnnotationCard, { HighlightCard } from "../components/AnnotationCard"; 15 - import BookmarkCard from "../components/BookmarkCard"; 16 - import ShareMenu from "../components/ShareMenu"; 17 - 18 - export default function CollectionDetail() { 19 - const { rkey, handle, "*": wildcardPath } = useParams(); 20 - const location = useLocation(); 21 - const navigate = useNavigate(); 22 - const { user } = useAuth(); 23 - 24 - const [collection, setCollection] = useState(null); 25 - const [items, setItems] = useState([]); 26 - const [loading, setLoading] = useState(true); 27 - const [error, setError] = useState(null); 28 - const [isEditModalOpen, setIsEditModalOpen] = useState(false); 29 - 30 - const [refreshTrigger, setRefreshTrigger] = useState(0); 31 - 32 - const searchParams = new URLSearchParams(location.search); 33 - const paramAuthorDid = searchParams.get("author"); 34 - 35 - const isOwner = 36 - user?.did && 37 - (collection?.creator?.did === user.did || paramAuthorDid === user.did); 38 - 39 - useEffect(() => { 40 - let active = true; 41 - 42 - const fetchContext = async () => { 43 - if (active) { 44 - setLoading(true); 45 - setError(null); 46 - } 47 - 48 - try { 49 - let targetUri = null; 50 - let targetDid = paramAuthorDid || user?.did; 51 - 52 - if (handle && rkey) { 53 - try { 54 - targetDid = await resolveHandle(handle); 55 - if (!active) return; 56 - targetUri = `at://${targetDid}/at.margin.collection/${rkey}`; 57 - } catch (e) { 58 - console.error("Failed to resolve handle", e); 59 - if (active) setError("Could not resolve user handle"); 60 - } 61 - } else if (wildcardPath) { 62 - targetUri = decodeURIComponent(wildcardPath); 63 - } else if (rkey && targetDid) { 64 - targetUri = `at://${targetDid}/at.margin.collection/${rkey}`; 65 - } 66 - 67 - if (!targetUri) { 68 - if (active) { 69 - if (!user && !handle && !paramAuthorDid) { 70 - setError("Please log in to view your collections"); 71 - } else if (!error) { 72 - setError("Invalid collection URL"); 73 - } 74 - } 75 - return; 76 - } 77 - 78 - if (!targetDid && targetUri.startsWith("at://")) { 79 - const parts = targetUri.split("/"); 80 - if (parts.length > 2) targetDid = parts[2]; 81 - } 82 - 83 - const collectionData = await getCollection(targetUri); 84 - if (!active) return; 85 - 86 - setCollection(collectionData); 87 - 88 - const itemsData = await getCollectionItems(collectionData.uri); 89 - if (!active) return; 90 - 91 - setItems(itemsData || []); 92 - } catch (err) { 93 - console.error("Fetch failed:", err); 94 - if (active) { 95 - if ( 96 - err.message.includes("404") || 97 - err.message.includes("not found") 98 - ) { 99 - setError("Collection not found"); 100 - } else { 101 - setError(err.message || "Failed to load collection"); 102 - } 103 - } 104 - } finally { 105 - if (active) setLoading(false); 106 - } 107 - }; 108 - 109 - fetchContext(); 110 - 111 - return () => { 112 - active = false; 113 - }; 114 - }, [ 115 - paramAuthorDid, 116 - user?.did, 117 - handle, 118 - rkey, 119 - wildcardPath, 120 - refreshTrigger, 121 - error, 122 - user, 123 - ]); 124 - 125 - const handleEditSuccess = () => { 126 - setIsEditModalOpen(false); 127 - setRefreshTrigger((v) => v + 1); 128 - }; 129 - 130 - const handleDeleteItem = async (itemUri) => { 131 - if (!confirm("Remove this item from the collection?")) return; 132 - try { 133 - await removeItemFromCollection(itemUri); 134 - setItems((prev) => prev.filter((i) => i.uri !== itemUri)); 135 - } catch (err) { 136 - console.error(err); 137 - alert("Failed to remove item"); 138 - } 139 - }; 140 - 141 - if (loading) { 142 - return ( 143 - <div className="feed-page"> 144 - <div 145 - style={{ 146 - display: "flex", 147 - justifyContent: "center", 148 - padding: "60px 0", 149 - }} 150 - > 151 - <div className="spinner"></div> 152 - </div> 153 - </div> 154 - ); 155 - } 156 - 157 - if (error || !collection) { 158 - return ( 159 - <div className="feed-page"> 160 - <div className="empty-state card"> 161 - <div className="empty-state-icon">⚠️</div> 162 - <h3 className="empty-state-title"> 163 - {error || "Collection not found"} 164 - </h3> 165 - <button 166 - onClick={() => navigate("/collections")} 167 - className="btn btn-secondary" 168 - style={{ marginTop: "16px" }} 169 - > 170 - Back to Collections 171 - </button> 172 - </div> 173 - </div> 174 - ); 175 - } 176 - 177 - return ( 178 - <div className="feed-page"> 179 - <Link to="/collections" className="back-link"> 180 - <ArrowLeft size={18} /> 181 - <span>Collections</span> 182 - </Link> 183 - 184 - <div className="collection-detail-header"> 185 - <div className="collection-detail-icon"> 186 - <CollectionIcon icon={collection.icon} size={28} /> 187 - </div> 188 - <div className="collection-detail-info"> 189 - <h1 className="collection-detail-title">{collection.name}</h1> 190 - {collection.description && ( 191 - <p className="collection-detail-desc">{collection.description}</p> 192 - )} 193 - <div className="collection-detail-stats"> 194 - <span> 195 - {items.length} {items.length === 1 ? "item" : "items"} 196 - </span> 197 - <span>·</span> 198 - <span> 199 - Created {new Date(collection.createdAt).toLocaleDateString()} 200 - </span> 201 - </div> 202 - </div> 203 - <div className="collection-detail-actions"> 204 - <ShareMenu 205 - uri={collection.uri} 206 - handle={collection.creator?.handle} 207 - type="Collection" 208 - text={`Check out this collection: ${collection.name}`} 209 - /> 210 - {isOwner && ( 211 - <> 212 - {collection.uri.includes("network.cosmik.collection") ? ( 213 - <a 214 - href={`https://semble.so/profile/${collection.creator?.handle || collection.creator?.did}/collections/${collection.uri.split("/").pop()}`} 215 - target="_blank" 216 - rel="noopener noreferrer" 217 - className="collection-detail-edit btn btn-secondary btn-sm" 218 - style={{ 219 - textDecoration: "none", 220 - display: "flex", 221 - gap: "6px", 222 - alignItems: "center", 223 - }} 224 - title="Manage on Semble" 225 - > 226 - <span>Manage on Semble</span> 227 - <ExternalLink size={16} /> 228 - </a> 229 - ) : ( 230 - <> 231 - <button 232 - onClick={() => setIsEditModalOpen(true)} 233 - className="collection-detail-edit" 234 - title="Edit Collection" 235 - > 236 - <Edit2 size={18} /> 237 - </button> 238 - <button 239 - onClick={async () => { 240 - if ( 241 - confirm("Delete this collection and all its items?") 242 - ) { 243 - await deleteCollection(collection.uri); 244 - navigate("/collections"); 245 - } 246 - }} 247 - className="collection-detail-delete" 248 - title="Delete Collection" 249 - > 250 - <Trash2 size={18} /> 251 - </button> 252 - </> 253 - )} 254 - </> 255 - )} 256 - </div> 257 - </div> 258 - 259 - <div className="feed-container"> 260 - <div className="feed"> 261 - {items.length === 0 ? ( 262 - <div className="empty-state card" style={{ borderStyle: "dashed" }}> 263 - <div className="empty-state-icon"> 264 - <Plus size={32} /> 265 - </div> 266 - <h3 className="empty-state-title">Collection is empty</h3> 267 - <p className="empty-state-text"> 268 - {isOwner 269 - ? 'Add items to this collection from your feed or bookmarks using the "Collect" button.' 270 - : "This collection has no items yet."} 271 - </p> 272 - </div> 273 - ) : ( 274 - items.map((item) => ( 275 - <div key={item.uri} className="collection-item-wrapper"> 276 - {isOwner && 277 - !collection.uri.includes("network.cosmik.collection") && ( 278 - <button 279 - onClick={() => handleDeleteItem(item.uri)} 280 - className="collection-item-remove" 281 - title="Remove from collection" 282 - > 283 - <Trash2 size={14} /> 284 - </button> 285 - )} 286 - 287 - {item.annotation ? ( 288 - <AnnotationCard annotation={item.annotation} /> 289 - ) : item.highlight ? ( 290 - <HighlightCard highlight={item.highlight} /> 291 - ) : item.bookmark ? ( 292 - <BookmarkCard bookmark={item.bookmark} /> 293 - ) : ( 294 - <div className="card" style={{ padding: "16px" }}> 295 - <p className="text-secondary">Item could not be loaded</p> 296 - </div> 297 - )} 298 - </div> 299 - )) 300 - )} 301 - </div> 302 - </div> 303 - 304 - {isOwner && ( 305 - <CollectionModal 306 - isOpen={isEditModalOpen} 307 - onClose={() => setIsEditModalOpen(false)} 308 - onSuccess={handleEditSuccess} 309 - collectionToEdit={collection} 310 - /> 311 - )} 312 - </div> 313 - ); 314 - }
-134
web/src/pages/Collections.jsx
··· 1 - import { useState, useEffect, useCallback } from "react"; 2 - import { Folder, Plus } from "lucide-react"; 3 - import { getCollections } from "../api/client"; 4 - import { useAuth } from "../context/AuthContext"; 5 - import CollectionModal from "../components/CollectionModal"; 6 - import CollectionRow from "../components/CollectionRow"; 7 - 8 - export default function Collections() { 9 - const { user } = useAuth(); 10 - const [collections, setCollections] = useState([]); 11 - const [loading, setLoading] = useState(true); 12 - const [error, setError] = useState(null); 13 - const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); 14 - const [editingCollection, setEditingCollection] = useState(null); 15 - 16 - const fetchCollections = useCallback(async () => { 17 - try { 18 - setLoading(true); 19 - const data = await getCollections(user.did); 20 - setCollections(data.items || []); 21 - } catch (err) { 22 - console.error(err); 23 - setError("Failed to load collections"); 24 - } finally { 25 - setLoading(false); 26 - } 27 - }, [user]); 28 - 29 - useEffect(() => { 30 - if (user) { 31 - fetchCollections(); 32 - } 33 - }, [user, fetchCollections]); 34 - 35 - const handleCreateSuccess = () => { 36 - fetchCollections(); 37 - setIsCreateModalOpen(false); 38 - setEditingCollection(null); 39 - }; 40 - 41 - const handleDelete = () => { 42 - fetchCollections(); 43 - setEditingCollection(null); 44 - }; 45 - 46 - if (loading) { 47 - return ( 48 - <div className="feed-page"> 49 - <div 50 - style={{ 51 - display: "flex", 52 - justifyContent: "center", 53 - padding: "60px 0", 54 - }} 55 - > 56 - <div className="spinner"></div> 57 - </div> 58 - </div> 59 - ); 60 - } 61 - 62 - return ( 63 - <div className="feed-page"> 64 - <div 65 - className="page-header" 66 - style={{ 67 - display: "flex", 68 - justifyContent: "space-between", 69 - alignItems: "flex-start", 70 - }} 71 - > 72 - <div> 73 - <h1 className="page-title">Collections</h1> 74 - <p className="page-description"> 75 - Organize your annotations, highlights, and bookmarks 76 - </p> 77 - </div> 78 - <button 79 - onClick={() => setIsCreateModalOpen(true)} 80 - className="btn btn-primary" 81 - > 82 - <Plus size={20} /> 83 - New Collection 84 - </button> 85 - </div> 86 - 87 - {error ? ( 88 - <div className="empty-state card"> 89 - <div className="empty-state-icon">⚠️</div> 90 - <h3 className="empty-state-title">Something went wrong</h3> 91 - <p className="empty-state-text">{error}</p> 92 - </div> 93 - ) : collections.length === 0 ? ( 94 - <div className="empty-state card"> 95 - <div className="empty-state-icon"> 96 - <Folder size={32} /> 97 - </div> 98 - <h3 className="empty-state-title">No collections yet</h3> 99 - <p className="empty-state-text mb-6"> 100 - Create your first collection to start organizing your web 101 - annotations. 102 - </p> 103 - <button 104 - onClick={() => setIsCreateModalOpen(true)} 105 - className="btn btn-secondary" 106 - > 107 - Create Collection 108 - </button> 109 - </div> 110 - ) : ( 111 - <div className="collections-list"> 112 - {collections.map((collection) => ( 113 - <CollectionRow 114 - key={collection.uri} 115 - collection={collection} 116 - onEdit={() => setEditingCollection(collection)} 117 - /> 118 - ))} 119 - </div> 120 - )} 121 - 122 - <CollectionModal 123 - isOpen={isCreateModalOpen || !!editingCollection} 124 - onClose={() => { 125 - setIsCreateModalOpen(false); 126 - setEditingCollection(null); 127 - }} 128 - onSuccess={handleCreateSuccess} 129 - onDelete={handleDelete} 130 - collectionToEdit={editingCollection} 131 - /> 132 - </div> 133 - ); 134 - }
-429
web/src/pages/Feed.jsx
··· 1 - import { useState, useEffect, useMemo, useCallback } from "react"; 2 - import { useSearchParams } from "react-router-dom"; 3 - import AnnotationCard, { HighlightCard } from "../components/AnnotationCard"; 4 - import BookmarkCard from "../components/BookmarkCard"; 5 - import CollectionItemCard from "../components/CollectionItemCard"; 6 - import AnnotationSkeleton from "../components/AnnotationSkeleton"; 7 - import IOSInstallBanner from "../components/IOSInstallBanner"; 8 - import { getAnnotationFeed, deleteHighlight } from "../api/client"; 9 - import { AlertIcon, InboxIcon } from "../components/Icons"; 10 - import { useAuth } from "../context/AuthContext"; 11 - import { useTheme } from "../context/ThemeContext"; 12 - import { X, ArrowUp } from "lucide-react"; 13 - 14 - import AddToCollectionModal from "../components/AddToCollectionModal"; 15 - 16 - export default function Feed() { 17 - const [searchParams, setSearchParams] = useSearchParams(); 18 - const tagFilter = searchParams.get("tag"); 19 - 20 - const [filter, setFilter] = useState(() => { 21 - return localStorage.getItem("feedFilter") || "all"; 22 - }); 23 - 24 - const [feedType, setFeedType] = useState(() => { 25 - return localStorage.getItem("feedType") || "all"; 26 - }); 27 - 28 - const [annotations, setAnnotations] = useState([]); 29 - const [loading, setLoading] = useState(true); 30 - const [error, setError] = useState(null); 31 - const [hasMore, setHasMore] = useState(true); 32 - const [loadingMore, setLoadingMore] = useState(false); 33 - 34 - useEffect(() => { 35 - localStorage.setItem("feedFilter", filter); 36 - }, [filter]); 37 - 38 - useEffect(() => { 39 - localStorage.setItem("feedType", feedType); 40 - }, [feedType]); 41 - 42 - const [collectionModalState, setCollectionModalState] = useState({ 43 - isOpen: false, 44 - uri: null, 45 - }); 46 - 47 - const { user } = useAuth(); 48 - 49 - const fetchFeed = useCallback( 50 - async (offset = 0) => { 51 - try { 52 - const isLoadMore = offset > 0; 53 - if (isLoadMore) { 54 - setLoadingMore(true); 55 - } else { 56 - setLoading(true); 57 - } 58 - 59 - let creatorDid = ""; 60 - 61 - if (feedType === "my-feed") { 62 - if (user?.did) { 63 - creatorDid = user.did; 64 - } else { 65 - setAnnotations([]); 66 - setLoading(false); 67 - setLoadingMore(false); 68 - return; 69 - } 70 - } 71 - 72 - const motivationMap = { 73 - commenting: "commenting", 74 - highlighting: "highlighting", 75 - bookmarking: "bookmarking", 76 - }; 77 - const motivation = motivationMap[filter] || ""; 78 - const limit = 50; 79 - 80 - const data = await getAnnotationFeed( 81 - limit, 82 - offset, 83 - tagFilter || "", 84 - creatorDid, 85 - feedType, 86 - motivation, 87 - ); 88 - 89 - const newItems = data.items || []; 90 - if (newItems.length < limit) { 91 - setHasMore(false); 92 - } else { 93 - setHasMore(true); 94 - } 95 - 96 - if (isLoadMore) { 97 - setAnnotations((prev) => [...prev, ...newItems]); 98 - } else { 99 - setAnnotations(newItems); 100 - } 101 - } catch (err) { 102 - setError(err.message); 103 - } finally { 104 - setLoading(false); 105 - setLoadingMore(false); 106 - } 107 - }, 108 - [tagFilter, feedType, filter, user], 109 - ); 110 - 111 - useEffect(() => { 112 - fetchFeed(0); 113 - }, [fetchFeed]); 114 - 115 - const deduplicatedAnnotations = useMemo(() => { 116 - const inCollectionUris = new Set(); 117 - for (const item of annotations) { 118 - if (item.type === "CollectionItem") { 119 - const inner = item.annotation || item.highlight || item.bookmark; 120 - if (inner) { 121 - if (inner.uri) inCollectionUris.add(inner.uri.trim()); 122 - if (inner.id) inCollectionUris.add(inner.id.trim()); 123 - } 124 - } 125 - } 126 - 127 - const result = []; 128 - 129 - for (const item of annotations) { 130 - if (item.type !== "CollectionItem") { 131 - const itemUri = (item.uri || "").trim(); 132 - const itemId = (item.id || "").trim(); 133 - if ( 134 - (itemUri && inCollectionUris.has(itemUri)) || 135 - (itemId && inCollectionUris.has(itemId)) 136 - ) { 137 - continue; 138 - } 139 - } 140 - 141 - result.push(item); 142 - } 143 - 144 - return result; 145 - }, [annotations]); 146 - 147 - const filteredAnnotations = 148 - feedType === "all" || 149 - feedType === "popular" || 150 - feedType === "semble" || 151 - feedType === "margin" || 152 - feedType === "shelved" || 153 - feedType === "my-feed" 154 - ? filter === "all" 155 - ? deduplicatedAnnotations 156 - : deduplicatedAnnotations.filter((a) => { 157 - if (a.type === "CollectionItem") { 158 - if (filter === "commenting") return !!a.annotation; 159 - if (filter === "highlighting") return !!a.highlight; 160 - if (filter === "bookmarking") return !!a.bookmark; 161 - } 162 - if (filter === "commenting") 163 - return a.motivation === "commenting" || a.type === "Annotation"; 164 - if (filter === "highlighting") 165 - return a.motivation === "highlighting" || a.type === "Highlight"; 166 - if (filter === "bookmarking") 167 - return a.motivation === "bookmarking" || a.type === "Bookmark"; 168 - return a.motivation === filter; 169 - }) 170 - : deduplicatedAnnotations; 171 - 172 - return ( 173 - <div className="feed-page"> 174 - <div className="page-header"> 175 - <h1 className="page-title">Feed</h1> 176 - <p className="page-description"> 177 - See what people are annotating and bookmarking 178 - </p> 179 - </div> 180 - 181 - {tagFilter && ( 182 - <div className="active-filter-banner"> 183 - <span> 184 - Filtering by <strong>#{tagFilter}</strong> 185 - </span> 186 - <button 187 - onClick={() => 188 - setSearchParams((prev) => { 189 - const next = new URLSearchParams(prev); 190 - next.delete("tag"); 191 - return next; 192 - }) 193 - } 194 - className="active-filter-clear" 195 - aria-label="Clear filter" 196 - > 197 - <X size={14} /> 198 - </button> 199 - </div> 200 - )} 201 - 202 - <div className="feed-controls"> 203 - <div className="feed-filters"> 204 - {[ 205 - { key: "all", label: "All" }, 206 - { key: "popular", label: "Popular" }, 207 - { key: "shelved", label: "Shelved" }, 208 - { key: "margin", label: "Margin" }, 209 - { key: "semble", label: "Semble" }, 210 - ...(user ? [{ key: "my-feed", label: "Mine" }] : []), 211 - ].map(({ key, label }) => ( 212 - <button 213 - key={key} 214 - className={`filter-tab ${feedType === key ? "active" : ""}`} 215 - onClick={() => setFeedType(key)} 216 - > 217 - {label} 218 - </button> 219 - ))} 220 - </div> 221 - 222 - <div className="feed-filters"> 223 - {[ 224 - { key: "all", label: "All" }, 225 - { key: "commenting", label: "Notes" }, 226 - { key: "highlighting", label: "Highlights" }, 227 - { key: "bookmarking", label: "Bookmarks" }, 228 - ].map(({ key, label }) => ( 229 - <button 230 - key={key} 231 - className={`filter-pill ${filter === key ? "active" : ""}`} 232 - onClick={() => setFilter(key)} 233 - > 234 - {label} 235 - </button> 236 - ))} 237 - </div> 238 - </div> 239 - 240 - <IOSInstallBanner /> 241 - 242 - {loading ? ( 243 - <div className="feed-container"> 244 - <div className="feed"> 245 - {[1, 2, 3, 4, 5].map((i) => ( 246 - <AnnotationSkeleton key={i} /> 247 - ))} 248 - </div> 249 - </div> 250 - ) : ( 251 - <> 252 - {error && ( 253 - <div className="empty-state"> 254 - <div className="empty-state-icon"> 255 - <AlertIcon size={24} /> 256 - </div> 257 - <h3 className="empty-state-title">Something went wrong</h3> 258 - <p className="empty-state-text">{error}</p> 259 - </div> 260 - )} 261 - 262 - {!error && filteredAnnotations.length === 0 && ( 263 - <div className="empty-state"> 264 - <div className="empty-state-icon"> 265 - <InboxIcon size={24} /> 266 - </div> 267 - <h3 className="empty-state-title">No items yet</h3> 268 - <p className="empty-state-text"> 269 - {filter === "all" 270 - ? "Be the first to annotate something!" 271 - : `No ${filter} items found.`} 272 - </p> 273 - </div> 274 - )} 275 - 276 - {!error && filteredAnnotations.length > 0 && ( 277 - <div className="feed-container"> 278 - <div className="feed"> 279 - {filteredAnnotations.map((item) => { 280 - if (item.type === "CollectionItem") { 281 - return ( 282 - <CollectionItemCard 283 - key={item.id} 284 - item={item} 285 - onAddToCollection={(uri) => 286 - setCollectionModalState({ 287 - isOpen: true, 288 - uri: uri, 289 - }) 290 - } 291 - /> 292 - ); 293 - } 294 - if ( 295 - item.type === "Highlight" || 296 - item.motivation === "highlighting" 297 - ) { 298 - return ( 299 - <HighlightCard 300 - key={item.id} 301 - highlight={item} 302 - onDelete={async (uri) => { 303 - const rkey = uri.split("/").pop(); 304 - await deleteHighlight(rkey); 305 - setAnnotations((prev) => 306 - prev.filter((a) => a.id !== item.id), 307 - ); 308 - }} 309 - onAddToCollection={() => 310 - setCollectionModalState({ 311 - isOpen: true, 312 - uri: item.uri || item.id, 313 - }) 314 - } 315 - /> 316 - ); 317 - } 318 - if ( 319 - item.type === "Bookmark" || 320 - item.motivation === "bookmarking" 321 - ) { 322 - return ( 323 - <BookmarkCard 324 - key={item.id} 325 - bookmark={item} 326 - onAddToCollection={() => 327 - setCollectionModalState({ 328 - isOpen: true, 329 - uri: item.uri || item.id, 330 - }) 331 - } 332 - /> 333 - ); 334 - } 335 - return ( 336 - <AnnotationCard 337 - key={item.id} 338 - annotation={item} 339 - onAddToCollection={() => 340 - setCollectionModalState({ 341 - isOpen: true, 342 - uri: item.uri || item.id, 343 - }) 344 - } 345 - /> 346 - ); 347 - })} 348 - </div> 349 - 350 - {hasMore && ( 351 - <div 352 - style={{ 353 - display: "flex", 354 - justifyContent: "center", 355 - marginTop: "12px", 356 - paddingBottom: "24px", 357 - }} 358 - > 359 - <button 360 - onClick={() => fetchFeed(annotations.length)} 361 - disabled={loadingMore} 362 - className="feed-load-more" 363 - > 364 - {loadingMore ? "Loading..." : "View More"} 365 - </button> 366 - </div> 367 - )} 368 - </div> 369 - )} 370 - </> 371 - )} 372 - 373 - {collectionModalState.isOpen && ( 374 - <AddToCollectionModal 375 - isOpen={collectionModalState.isOpen} 376 - onClose={() => setCollectionModalState({ isOpen: false, uri: null })} 377 - annotationUri={collectionModalState.uri} 378 - /> 379 - )} 380 - 381 - <BackToTopButton /> 382 - </div> 383 - ); 384 - } 385 - 386 - function BackToTopButton() { 387 - const [isVisible, setIsVisible] = useState(false); 388 - const { layout } = useTheme(); 389 - 390 - useEffect(() => { 391 - let scrollContainer = window; 392 - if (layout !== "topnav") { 393 - const mainContent = document.querySelector(".main-content"); 394 - if (mainContent) scrollContainer = mainContent; 395 - } 396 - 397 - const toggleVisibility = () => { 398 - const scrolled = 399 - scrollContainer instanceof Window 400 - ? scrollContainer.scrollY 401 - : scrollContainer.scrollTop; 402 - 403 - setIsVisible(scrolled > 300); 404 - }; 405 - 406 - scrollContainer.addEventListener("scroll", toggleVisibility); 407 - return () => 408 - scrollContainer.removeEventListener("scroll", toggleVisibility); 409 - }, [layout]); 410 - 411 - const scrollToTop = () => { 412 - if (layout === "topnav") { 413 - window.scrollTo({ top: 0, behavior: "smooth" }); 414 - } else { 415 - const mainContent = document.querySelector(".main-content"); 416 - if (mainContent) mainContent.scrollTo({ top: 0, behavior: "smooth" }); 417 - } 418 - }; 419 - 420 - return ( 421 - <button 422 - className={`back-to-top-btn ${isVisible ? "visible" : ""} ${layout !== "topnav" ? "has-sidebar" : ""}`} 423 - onClick={scrollToTop} 424 - aria-label="Back to top" 425 - > 426 - <ArrowUp size={20} /> 427 - </button> 428 - ); 429 - }
-133
web/src/pages/Highlights.jsx
··· 1 - import { useState, useEffect } from "react"; 2 - import { Link } from "react-router-dom"; 3 - import { useAuth } from "../context/AuthContext"; 4 - import { getUserHighlights, deleteHighlight } from "../api/client"; 5 - import { HighlightIcon } from "../components/Icons"; 6 - import { HighlightCard } from "../components/AnnotationCard"; 7 - 8 - export default function Highlights() { 9 - const { user, isAuthenticated, loading } = useAuth(); 10 - const [highlights, setHighlights] = useState([]); 11 - const [loadingHighlights, setLoadingHighlights] = useState(true); 12 - const [error, setError] = useState(null); 13 - 14 - useEffect(() => { 15 - async function loadHighlights() { 16 - if (!user?.did) return; 17 - 18 - try { 19 - setLoadingHighlights(true); 20 - const data = await getUserHighlights(user.did); 21 - setHighlights(data.items || []); 22 - } catch (err) { 23 - console.error("Failed to load highlights:", err); 24 - setError(err.message); 25 - } finally { 26 - setLoadingHighlights(false); 27 - } 28 - } 29 - 30 - if (isAuthenticated && user) { 31 - loadHighlights(); 32 - } 33 - }, [isAuthenticated, user]); 34 - 35 - const handleDelete = async (uri) => { 36 - if (!confirm("Delete this highlight?")) return; 37 - 38 - try { 39 - const parts = uri.split("/"); 40 - const rkey = parts[parts.length - 1]; 41 - await deleteHighlight(rkey); 42 - setHighlights((prev) => prev.filter((h) => (h.id || h.uri) !== uri)); 43 - } catch (err) { 44 - alert("Failed to delete: " + err.message); 45 - } 46 - }; 47 - 48 - if (loading) 49 - return ( 50 - <div className="page-loading"> 51 - <div className="spinner"></div> 52 - </div> 53 - ); 54 - 55 - if (!isAuthenticated) { 56 - return ( 57 - <div className="new-page"> 58 - <div className="card" style={{ textAlign: "center", padding: "48px" }}> 59 - <h2>Sign in to view your highlights</h2> 60 - <p style={{ color: "var(--text-secondary)", marginTop: "8px" }}> 61 - You need to be logged in with your Bluesky account 62 - </p> 63 - <Link 64 - to="/login" 65 - className="btn btn-primary" 66 - style={{ marginTop: "24px" }} 67 - > 68 - Sign in with Bluesky 69 - </Link> 70 - </div> 71 - </div> 72 - ); 73 - } 74 - 75 - return ( 76 - <div className="feed-page"> 77 - <div className="page-header"> 78 - <h1 className="page-title">My Highlights</h1> 79 - <p className="page-description"> 80 - Text you&apos;ve highlighted across the web 81 - </p> 82 - </div> 83 - 84 - {loadingHighlights ? ( 85 - <div className="feed-container"> 86 - <div className="feed"> 87 - {[1, 2, 3].map((i) => ( 88 - <div key={i} className="card"> 89 - <div 90 - className="skeleton skeleton-text" 91 - style={{ width: "40%" }} 92 - ></div> 93 - <div className="skeleton skeleton-text"></div> 94 - <div 95 - className="skeleton skeleton-text" 96 - style={{ width: "60%" }} 97 - ></div> 98 - </div> 99 - ))} 100 - </div> 101 - </div> 102 - ) : error ? ( 103 - <div className="empty-state"> 104 - <div className="empty-state-icon">⚠️</div> 105 - <h3 className="empty-state-title">Error loading highlights</h3> 106 - <p className="empty-state-text">{error}</p> 107 - </div> 108 - ) : highlights.length === 0 ? ( 109 - <div className="empty-state"> 110 - <div className="empty-state-icon"> 111 - <HighlightIcon size={32} /> 112 - </div> 113 - <h3 className="empty-state-title">No highlights yet</h3> 114 - <p className="empty-state-text"> 115 - Highlight text on any page using the browser extension. 116 - </p> 117 - </div> 118 - ) : ( 119 - <div className="feed-container"> 120 - <div className="feed"> 121 - {highlights.map((highlight) => ( 122 - <HighlightCard 123 - key={highlight.id} 124 - highlight={highlight} 125 - onDelete={handleDelete} 126 - /> 127 - ))} 128 - </div> 129 - </div> 130 - )} 131 - </div> 132 - ); 133 - }
-790
web/src/pages/Landing.jsx
··· 1 - import { useState, useEffect, useRef } from "react"; 2 - import { Link } from "react-router-dom"; 3 - import { useAuth } from "../context/AuthContext"; 4 - import { 5 - MessageSquare, 6 - Highlighter, 7 - Users, 8 - ArrowRight, 9 - Github, 10 - Database, 11 - Shield, 12 - Zap, 13 - } from "lucide-react"; 14 - import { SiFirefox, SiGooglechrome, SiBluesky, SiApple } from "react-icons/si"; 15 - import { FaEdge } from "react-icons/fa"; 16 - import logo from "../assets/logo.svg"; 17 - 18 - const isFirefox = 19 - typeof navigator !== "undefined" && /Firefox/i.test(navigator.userAgent); 20 - const isEdge = 21 - typeof navigator !== "undefined" && /Edg/i.test(navigator.userAgent); 22 - const isIOS = 23 - typeof navigator !== "undefined" && 24 - /iPad|iPhone|iPod/.test(navigator.userAgent); 25 - const isSafari = 26 - typeof navigator !== "undefined" && 27 - /Safari/.test(navigator.userAgent) && 28 - !/Chrome/.test(navigator.userAgent); 29 - const isIOSSafari = isIOS && isSafari; 30 - 31 - function getExtensionInfo() { 32 - if (isIOSSafari) { 33 - return { 34 - url: "https://www.icloud.com/shortcuts/21c87edf29b046db892c9e57dac6d1fd", 35 - Icon: SiApple, 36 - label: "iOS", 37 - isShortcut: true, 38 - }; 39 - } 40 - if (isFirefox) { 41 - return { 42 - url: "https://addons.mozilla.org/en-US/firefox/addon/margin/", 43 - Icon: SiFirefox, 44 - label: "Firefox", 45 - }; 46 - } 47 - if (isEdge) { 48 - return { 49 - url: "https://microsoftedge.microsoft.com/addons/detail/margin/nfjnmllpdgcdnhmmggjihjbidmeadddn", 50 - Icon: FaEdge, 51 - label: "Edge", 52 - }; 53 - } 54 - return { 55 - url: "https://chromewebstore.google.com/detail/margin/cgpmbiiagnehkikhcbnhiagfomajncpa/", 56 - Icon: SiGooglechrome, 57 - label: "Chrome", 58 - }; 59 - } 60 - 61 - import { getAnnotations, normalizeAnnotation } from "../api/client"; 62 - import { formatDistanceToNow } from "date-fns"; 63 - 64 - function DemoAnnotation() { 65 - const [annotations, setAnnotations] = useState([]); 66 - const [loading, setLoading] = useState(true); 67 - const [hoverPos, setHoverPos] = useState(null); 68 - const [hoverVisible, setHoverVisible] = useState(false); 69 - const [hoverAuthors, setHoverAuthors] = useState([]); 70 - 71 - const [showPopover, setShowPopover] = useState(false); 72 - const [popoverPos, setPopoverPos] = useState(null); 73 - const [popoverAnnotations, setPopoverAnnotations] = useState([]); 74 - 75 - const highlightRef = useRef(null); 76 - const articleRef = useRef(null); 77 - 78 - useEffect(() => { 79 - getAnnotations({ source: "https://en.wikipedia.org/wiki/AT_Protocol" }) 80 - .then((res) => { 81 - const rawItems = res.items || (Array.isArray(res) ? res : []); 82 - const normalized = rawItems.map(normalizeAnnotation); 83 - setAnnotations(normalized); 84 - }) 85 - .catch((err) => { 86 - console.error("Failed to fetch demo annotations:", err); 87 - }) 88 - .finally(() => { 89 - setLoading(false); 90 - }); 91 - }, []); 92 - 93 - useEffect(() => { 94 - if (!showPopover) return; 95 - const handleClickOutside = () => setShowPopover(false); 96 - document.addEventListener("click", handleClickOutside); 97 - return () => document.removeEventListener("click", handleClickOutside); 98 - }, [showPopover]); 99 - 100 - const getMatches = () => { 101 - return annotations.filter( 102 - (a) => 103 - (a.selector?.exact && 104 - a.selector.exact.includes("A handle serves as")) || 105 - (a.quote && a.quote.includes("A handle serves as")), 106 - ); 107 - }; 108 - 109 - const handleMouseEnter = () => { 110 - const matches = getMatches(); 111 - const authorsMap = new Map(); 112 - matches.forEach((a) => { 113 - const author = a.author || a.creator || { handle: "unknown" }; 114 - const id = author.did || author.handle; 115 - if (!authorsMap.has(id)) authorsMap.set(id, author); 116 - }); 117 - const unique = Array.from(authorsMap.values()); 118 - 119 - setHoverAuthors(unique); 120 - 121 - if (highlightRef.current && articleRef.current) { 122 - const spanRect = highlightRef.current.getBoundingClientRect(); 123 - const articleRect = articleRef.current.getBoundingClientRect(); 124 - 125 - const visibleCount = Math.min(unique.length, 3); 126 - const hasOverflow = unique.length > 3; 127 - const countForCalc = visibleCount + (hasOverflow ? 1 : 0); 128 - const width = countForCalc > 0 ? countForCalc * 18 + 10 : 0; 129 - 130 - const top = spanRect.top - articleRect.top + spanRect.height / 2 - 14; 131 - const left = spanRect.left - articleRect.left - width; 132 - 133 - setHoverPos({ top, left }); 134 - setHoverVisible(true); 135 - } 136 - }; 137 - 138 - const handleMouseLeave = () => { 139 - setHoverVisible(false); 140 - }; 141 - 142 - const handleHighlightClick = (e) => { 143 - e.stopPropagation(); 144 - const matches = getMatches(); 145 - setPopoverAnnotations(matches); 146 - 147 - if (highlightRef.current && articleRef.current) { 148 - const spanRect = highlightRef.current.getBoundingClientRect(); 149 - const articleRect = articleRef.current.getBoundingClientRect(); 150 - 151 - const top = spanRect.top - articleRect.top + spanRect.height + 10; 152 - let left = spanRect.left - articleRect.left; 153 - 154 - if (left + 300 > articleRect.width) { 155 - left = articleRect.width - 300; 156 - } 157 - 158 - setPopoverPos({ top, left }); 159 - setShowPopover(true); 160 - } 161 - }; 162 - 163 - const maxShow = 3; 164 - const displayHoverAuthors = hoverAuthors.slice(0, maxShow); 165 - const hoverOverflow = hoverAuthors.length - maxShow; 166 - 167 - return ( 168 - <div className="demo-window"> 169 - <div className="demo-browser-bar"> 170 - <div className="demo-browser-dots"> 171 - <span></span> 172 - <span></span> 173 - <span></span> 174 - </div> 175 - <div className="demo-browser-url"> 176 - <span>en.wikipedia.org/wiki/AT_Protocol</span> 177 - </div> 178 - </div> 179 - <div className="demo-content"> 180 - <div 181 - className="demo-article" 182 - ref={articleRef} 183 - style={{ position: "relative" }} 184 - > 185 - {hoverPos && hoverAuthors.length > 0 && ( 186 - <div 187 - className={`demo-hover-indicator ${hoverVisible ? "visible" : ""}`} 188 - style={{ 189 - top: hoverPos.top, 190 - left: hoverPos.left, 191 - cursor: "pointer", 192 - }} 193 - onClick={handleHighlightClick} 194 - > 195 - {displayHoverAuthors.map((author, i) => 196 - author.avatar ? ( 197 - <img 198 - key={i} 199 - src={author.avatar} 200 - className="demo-hover-avatar" 201 - alt={author.handle} 202 - onError={(e) => { 203 - e.target.style.display = "none"; 204 - e.target.nextSibling.style.display = "flex"; 205 - }} 206 - /> 207 - ) : ( 208 - <div key={i} className="demo-hover-avatar-fallback"> 209 - {author.handle?.[0]?.toUpperCase() || "U"} 210 - </div> 211 - ), 212 - )} 213 - {hoverOverflow > 0 && ( 214 - <div 215 - className="demo-hover-avatar-fallback" 216 - style={{ 217 - background: "var(--bg-elevated)", 218 - color: "var(--text-secondary)", 219 - fontSize: 10, 220 - }} 221 - > 222 - +{hoverOverflow} 223 - </div> 224 - )} 225 - </div> 226 - )} 227 - 228 - {showPopover && popoverPos && ( 229 - <div 230 - className="demo-popover" 231 - style={{ 232 - top: popoverPos.top, 233 - left: popoverPos.left, 234 - }} 235 - onClick={(e) => e.stopPropagation()} 236 - > 237 - <div className="demo-popover-header"> 238 - <span> 239 - {popoverAnnotations.length}{" "} 240 - {popoverAnnotations.length === 1 ? "Comment" : "Comments"} 241 - </span> 242 - <button 243 - className="demo-popover-close" 244 - onClick={() => setShowPopover(false)} 245 - > 246 - 247 - </button> 248 - </div> 249 - <div className="demo-popover-scroll-area"> 250 - {popoverAnnotations.length === 0 ? ( 251 - <div style={{ padding: 14, fontSize: 13, color: "#666" }}> 252 - No comments 253 - </div> 254 - ) : ( 255 - popoverAnnotations.map((ann, i) => ( 256 - <div key={ann.uri || i} className="demo-comment-item"> 257 - <div className="demo-comment-header"> 258 - <img 259 - src={ann.author?.avatar || logo} 260 - className="demo-comment-avatar" 261 - onError={(e) => (e.target.src = logo)} 262 - alt="" 263 - /> 264 - <span className="demo-comment-handle"> 265 - @{ann.author?.handle || "user"} 266 - </span> 267 - </div> 268 - <div className="demo-comment-text"> 269 - {ann.text || ann.body?.value} 270 - </div> 271 - <div className="demo-comment-actions"> 272 - <button className="demo-comment-action-btn"> 273 - Reply 274 - </button> 275 - <button className="demo-comment-action-btn"> 276 - Share 277 - </button> 278 - </div> 279 - </div> 280 - )) 281 - )} 282 - </div> 283 - </div> 284 - )} 285 - <p className="demo-text"> 286 - The AT Protocol utilizes a dual identifier system: a mutable handle, 287 - in the form of a domain name, and an immutable decentralized 288 - identifier (DID). 289 - </p> 290 - <p className="demo-text"> 291 - <span 292 - className="demo-highlight" 293 - ref={highlightRef} 294 - onMouseEnter={handleMouseEnter} 295 - onMouseLeave={handleMouseLeave} 296 - onClick={handleHighlightClick} 297 - style={{ cursor: "pointer" }} 298 - > 299 - A handle serves as a verifiable user identifier. 300 - </span>{" "} 301 - Verification is by either of two equivalent methods proving control 302 - of the domain name: Either a DNS query of a resource record with the 303 - same name as the handle, or a request for a text file from a Web 304 - service with the same name. 305 - </p> 306 - <p className="demo-text"> 307 - DIDs resolve to DID documents, which contain references to key user 308 - metadata, such as the user&apos;s handle, public keys, and data 309 - repository. While any DID method could, in theory, be used by the 310 - protocol if its components provide support for the method, in 311 - practice only two methods are supported (&apos;blessed&apos;) by the 312 - protocol&apos;s reference implementations: did:plc and did:web. The 313 - validity of these identifiers can be verified by a registry which 314 - hosts the DID&apos;s associated document and a file that is hosted 315 - at a well-known location on the connected domain name, respectively. 316 - </p> 317 - </div> 318 - <div className="demo-sidebar"> 319 - <div className="demo-sidebar-header"> 320 - <div className="demo-logo-section"> 321 - <span 322 - className="demo-logo-icon" 323 - style={{ color: "var(--accent)" }} 324 - > 325 - <svg 326 - width="16" 327 - height="16" 328 - viewBox="0 0 265 231" 329 - fill="currentColor" 330 - xmlns="http://www.w3.org/2000/svg" 331 - > 332 - <path d="M0 230 V0 H199 V65.7156 H149.5 V115.216 H182.5 L199 131.716 V230 Z" /> 333 - <path d="M215 214.224 V230 H264.5 V0 H215.07 V16.2242 H248.5 V214.224 H215 Z" /> 334 - </svg> 335 - </span> 336 - <span className="demo-logo-text">Margin</span> 337 - </div> 338 - <div className="demo-user-section"> 339 - <span className="demo-user-handle">@margin.at</span> 340 - </div> 341 - </div> 342 - <div className="demo-page-info"> 343 - <span>en.wikipedia.org</span> 344 - </div> 345 - <div className="demo-annotations-list"> 346 - {loading ? ( 347 - <div style={{ padding: 20, textAlign: "center", color: "#666" }}> 348 - Loading... 349 - </div> 350 - ) : annotations.length > 0 ? ( 351 - annotations.map((ann, i) => ( 352 - <div 353 - key={ann.uri || i} 354 - className={`demo-annotation ${i > 0 ? "demo-annotation-secondary" : ""}`} 355 - > 356 - <div className="demo-annotation-header"> 357 - <div 358 - className="demo-avatar" 359 - style={{ background: "transparent" }} 360 - > 361 - <img 362 - src={ann.author?.avatar || logo} 363 - alt={ann.author?.handle || "User"} 364 - style={{ 365 - width: "100%", 366 - height: "100%", 367 - borderRadius: "50%", 368 - }} 369 - onError={(e) => { 370 - e.target.src = logo; 371 - }} 372 - /> 373 - </div> 374 - <div className="demo-meta"> 375 - <span className="demo-author"> 376 - @{ann.author?.handle || "margin.at"} 377 - </span> 378 - <span className="demo-time"> 379 - {ann.createdAt 380 - ? formatDistanceToNow(new Date(ann.createdAt), { 381 - addSuffix: true, 382 - }) 383 - : "recently"} 384 - </span> 385 - </div> 386 - </div> 387 - {ann.selector?.exact && ( 388 - <p className="demo-quote"> 389 - &ldquo;{ann.selector.exact}&rdquo; 390 - </p> 391 - )} 392 - <p className="demo-comment">{ann.text || ann.body?.value}</p> 393 - <button className="demo-jump-btn">Jump to text →</button> 394 - </div> 395 - )) 396 - ) : ( 397 - <div 398 - style={{ 399 - padding: 20, 400 - textAlign: "center", 401 - color: "var(--text-tertiary)", 402 - }} 403 - > 404 - No annotations found. 405 - </div> 406 - )} 407 - </div> 408 - </div> 409 - </div> 410 - </div> 411 - ); 412 - } 413 - 414 - export default function Landing() { 415 - const { user } = useAuth(); 416 - const ext = getExtensionInfo(); 417 - 418 - return ( 419 - <div className="landing-page"> 420 - <nav className="landing-nav"> 421 - <Link 422 - to="/" 423 - className="landing-logo" 424 - style={{ color: "var(--accent)" }} 425 - > 426 - <svg 427 - width="24" 428 - height="24" 429 - viewBox="0 0 265 231" 430 - fill="currentColor" 431 - xmlns="http://www.w3.org/2000/svg" 432 - > 433 - <path d="M0 230 V0 H199 V65.7156 H149.5 V115.216 H182.5 L199 131.716 V230 Z" /> 434 - <path d="M215 214.224 V230 H264.5 V0 H215.07 V16.2242 H248.5 V214.224 H215 Z" /> 435 - </svg> 436 - <span>Margin</span> 437 - </Link> 438 - <div className="landing-nav-links"> 439 - <a 440 - href="https://github.com/margin-at/margin" 441 - target="_blank" 442 - rel="noreferrer" 443 - > 444 - GitHub 445 - </a> 446 - <a 447 - href="https://tangled.org/margin.at/margin" 448 - target="_blank" 449 - rel="noreferrer" 450 - > 451 - Tangled 452 - </a> 453 - <a 454 - href="https://bsky.app/profile/margin.at" 455 - target="_blank" 456 - rel="noreferrer" 457 - > 458 - Bluesky 459 - </a> 460 - {user ? ( 461 - <Link to="/home" className="btn btn-primary"> 462 - Open App 463 - </Link> 464 - ) : ( 465 - <Link to="/login" className="btn btn-primary"> 466 - Sign In 467 - </Link> 468 - )} 469 - </div> 470 - </nav> 471 - 472 - <section className="landing-hero"> 473 - <div className="landing-hero-content"> 474 - <div className="landing-badge"> 475 - <SiBluesky size={14} /> 476 - Built on ATProto 477 - </div> 478 - <h1 className="landing-title"> 479 - Write in the margins 480 - <br /> 481 - <span className="landing-title-accent">of the web.</span> 482 - </h1> 483 - <p className="landing-subtitle"> 484 - Margin is a social layer for reading online. Highlight passages, 485 - leave thoughts in the margins, and see what others are thinking 486 - about the pages you read. 487 - </p> 488 - <div className="landing-cta"> 489 - <a 490 - href={ext.url} 491 - target="_blank" 492 - rel="noreferrer" 493 - className="btn btn-primary btn-lg" 494 - > 495 - {ext.Icon && <ext.Icon size={18} />} 496 - {ext.isShortcut ? "Get iOS Shortcut" : `Install for ${ext.label}`} 497 - </a> 498 - {user ? ( 499 - <Link to="/home" className="btn btn-secondary btn-lg"> 500 - Open App 501 - <ArrowRight size={18} /> 502 - </Link> 503 - ) : ( 504 - <Link to="/login" className="btn btn-secondary btn-lg"> 505 - Sign In with ATProto 506 - <ArrowRight size={18} /> 507 - </Link> 508 - )} 509 - </div> 510 - {!isIOSSafari && ( 511 - <p className="landing-browsers"> 512 - Also available for{" "} 513 - {isFirefox ? ( 514 - <> 515 - <a 516 - href="https://microsoftedge.microsoft.com/addons/detail/margin/nfjnmllpdgcdnhmmggjihjbidmeadddn" 517 - target="_blank" 518 - rel="noreferrer" 519 - > 520 - Edge 521 - </a>{" "} 522 - and{" "} 523 - <a 524 - href="https://chromewebstore.google.com/detail/margin/cgpmbiiagnehkikhcbnhiagfomajncpa/" 525 - target="_blank" 526 - rel="noreferrer" 527 - > 528 - Chrome 529 - </a> 530 - </> 531 - ) : isEdge ? ( 532 - <> 533 - <a 534 - href="https://addons.mozilla.org/en-US/firefox/addon/margin/" 535 - target="_blank" 536 - rel="noreferrer" 537 - > 538 - Firefox 539 - </a>{" "} 540 - and{" "} 541 - <a 542 - href="https://chromewebstore.google.com/detail/margin/cgpmbiiagnehkikhcbnhiagfomajncpa/" 543 - target="_blank" 544 - rel="noreferrer" 545 - > 546 - Chrome 547 - </a> 548 - </> 549 - ) : ( 550 - <> 551 - <a 552 - href="https://addons.mozilla.org/en-US/firefox/addon/margin/" 553 - target="_blank" 554 - rel="noreferrer" 555 - > 556 - Firefox 557 - </a>{" "} 558 - and{" "} 559 - <a 560 - href="https://microsoftedge.microsoft.com/addons/detail/margin/nfjnmllpdgcdnhmmggjihjbidmeadddn" 561 - target="_blank" 562 - rel="noreferrer" 563 - > 564 - Edge 565 - </a> 566 - </> 567 - )} 568 - </p> 569 - )} 570 - </div> 571 - </section> 572 - 573 - <section className="landing-demo"> 574 - <DemoAnnotation /> 575 - </section> 576 - 577 - <section className="landing-section"> 578 - <h2 className="landing-section-title">How it works</h2> 579 - <div className="landing-steps"> 580 - <div className="landing-step"> 581 - <div className="landing-step-num">1</div> 582 - <div className="landing-step-content"> 583 - <h3>Install & Login</h3> 584 - <p> 585 - Add Margin to your browser and sign in with your AT Protocol 586 - handle. No new account needed, just your existing handle. 587 - </p> 588 - </div> 589 - </div> 590 - <div className="landing-step"> 591 - <div className="landing-step-num">2</div> 592 - <div className="landing-step-content"> 593 - <h3>Annotate the Web</h3> 594 - <p> 595 - Highlight text on any page. Leave notes in the margins, ask 596 - questions, or add context to the conversation precisely where it 597 - belongs. 598 - </p> 599 - </div> 600 - </div> 601 - <div className="landing-step"> 602 - <div className="landing-step-num">3</div> 603 - <div className="landing-step-content"> 604 - <h3>Share & Discover</h3> 605 - <p> 606 - Your annotations are published to your PDS. Discover what the 607 - community is reading and discussing across the web. 608 - </p> 609 - </div> 610 - </div> 611 - </div> 612 - </section> 613 - 614 - <section className="landing-section landing-section-alt"> 615 - <div className="landing-features-grid"> 616 - <div className="landing-feature"> 617 - <div className="landing-feature-icon"> 618 - <Highlighter size={20} /> 619 - </div> 620 - <h3>Universal Highlights</h3> 621 - <p> 622 - Save passages from any article, paper, or post. Your collection 623 - travels with you, independent of any single platform. 624 - </p> 625 - </div> 626 - <div className="landing-feature"> 627 - <div className="landing-feature-icon"> 628 - <MessageSquare size={20} /> 629 - </div> 630 - <h3>Universal Notes</h3> 631 - <p> 632 - Move the discussion out of the comments section. Contextual 633 - conversations that live right alongside the content. 634 - </p> 635 - </div> 636 - <div className="landing-feature"> 637 - <div className="landing-feature-icon"> 638 - <Shield size={20} /> 639 - </div> 640 - <h3>Open Identity</h3> 641 - <p> 642 - Your data, your handle, your graph. Built on the AT Protocol for 643 - true ownership and portability. 644 - </p> 645 - </div> 646 - <div className="landing-feature"> 647 - <div className="landing-feature-icon"> 648 - <Users size={20} /> 649 - </div> 650 - <h3>Community Context</h3> 651 - <p> 652 - See the web with fresh eyes. Discover highlights and notes from 653 - other readers directly on the page. 654 - </p> 655 - </div> 656 - </div> 657 - </section> 658 - 659 - <section className="landing-section landing-protocol"> 660 - <div className="landing-protocol-grid"> 661 - <div className="landing-protocol-main"> 662 - <h2>Your data, your identity</h2> 663 - <p> 664 - Margin is built on the{" "} 665 - <a href="https://atproto.com" target="_blank" rel="noreferrer"> 666 - AT Protocol 667 - </a> 668 - , the same open protocol that powers Bluesky. Sign in with your 669 - existing Bluesky account or create a new one in your preferred 670 - PDS. 671 - </p> 672 - <p> 673 - Your annotations are stored in your PDS. You can export them 674 - anytime, use them with other apps, or self-host your own server. 675 - No vendor lock-in. 676 - </p> 677 - </div> 678 - <div className="landing-protocol-features"> 679 - <div className="landing-protocol-item"> 680 - <Database size={20} /> 681 - <div> 682 - <strong>Portable data</strong> 683 - <span>Export or migrate anytime</span> 684 - </div> 685 - </div> 686 - <div className="landing-protocol-item"> 687 - <Shield size={20} /> 688 - <div> 689 - <strong>You own your identity</strong> 690 - <span>Use your own domain as handle</span> 691 - </div> 692 - </div> 693 - <div className="landing-protocol-item"> 694 - <Zap size={20} /> 695 - <div> 696 - <strong>Interoperable</strong> 697 - <span>Works with the ATProto ecosystem</span> 698 - </div> 699 - </div> 700 - <div className="landing-protocol-item"> 701 - <Github size={20} /> 702 - <div> 703 - <strong>Open source</strong> 704 - <span>Audit, contribute, self-host</span> 705 - </div> 706 - </div> 707 - </div> 708 - </div> 709 - </section> 710 - 711 - <section className="landing-section landing-final-cta"> 712 - <h2>Start annotating today</h2> 713 - <p>Free and open source. Sign in with ATProto to get started.</p> 714 - <div className="landing-cta"> 715 - <a 716 - href={ext.url} 717 - target="_blank" 718 - rel="noreferrer" 719 - className="btn btn-primary btn-lg" 720 - > 721 - {ext.Icon && <ext.Icon size={18} />} 722 - {ext.isShortcut ? "Get iOS Shortcut" : "Get the Extension"} 723 - </a> 724 - </div> 725 - </section> 726 - 727 - <footer className="landing-footer"> 728 - <div className="landing-footer-grid"> 729 - <div className="landing-footer-brand"> 730 - <Link to="/" className="landing-logo"> 731 - <svg 732 - width="24" 733 - height="24" 734 - viewBox="0 0 265 231" 735 - fill="currentColor" 736 - xmlns="http://www.w3.org/2000/svg" 737 - > 738 - <path d="M0 230 V0 H199 V65.7156 H149.5 V115.216 H182.5 L199 131.716 V230 Z" /> 739 - <path d="M215 214.224 V230 H264.5 V0 H215.07 V16.2242 H248.5 V214.224 H215 Z" /> 740 - </svg> 741 - <span>Margin</span> 742 - </Link> 743 - <p>Write in the margins of the web.</p> 744 - </div> 745 - <div className="landing-footer-links"> 746 - <div className="landing-footer-col"> 747 - <h4>Product</h4> 748 - <a href={ext.url} target="_blank" rel="noreferrer"> 749 - Browser Extension 750 - </a> 751 - <Link to="/home">Web App</Link> 752 - </div> 753 - <div className="landing-footer-col"> 754 - <h4>Community</h4> 755 - <a 756 - href="https://github.com/margin-at/margin" 757 - target="_blank" 758 - rel="noreferrer" 759 - > 760 - GitHub 761 - </a> 762 - <a 763 - href="https://tangled.org/margin.at/margin" 764 - target="_blank" 765 - rel="noreferrer" 766 - > 767 - Tangled 768 - </a> 769 - <a 770 - href="https://bsky.app/profile/margin.at" 771 - target="_blank" 772 - rel="noreferrer" 773 - > 774 - Bluesky 775 - </a> 776 - </div> 777 - <div className="landing-footer-col"> 778 - <h4>Legal</h4> 779 - <Link to="/privacy">Privacy Policy</Link> 780 - <Link to="/terms">Terms of Service</Link> 781 - </div> 782 - </div> 783 - </div> 784 - <div className="landing-footer-bottom"> 785 - <p>© {new Date().getFullYear()} Margin. Open source under MIT.</p> 786 - </div> 787 - </footer> 788 - </div> 789 - ); 790 - }
-282
web/src/pages/Login.jsx
··· 1 - import { useState, useEffect, useRef } from "react"; 2 - import { Link } from "react-router-dom"; 3 - import { useAuth } from "../context/AuthContext"; 4 - import { searchActors, startLogin } from "../api/client"; 5 - import { AtSign } from "lucide-react"; 6 - import SignUpModal from "../components/SignUpModal"; 7 - 8 - export default function Login() { 9 - const { isAuthenticated, user, logout } = useAuth(); 10 - const [showSignUp, setShowSignUp] = useState(false); 11 - const [handle, setHandle] = useState(""); 12 - const [suggestions, setSuggestions] = useState([]); 13 - const [showSuggestions, setShowSuggestions] = useState(false); 14 - const [loading, setLoading] = useState(false); 15 - const [error, setError] = useState(null); 16 - const [selectedIndex, setSelectedIndex] = useState(-1); 17 - const inputRef = useRef(null); 18 - const suggestionsRef = useRef(null); 19 - 20 - const [providerIndex, setProviderIndex] = useState(0); 21 - const [morphClass, setMorphClass] = useState("morph-in"); 22 - const providers = [ 23 - "AT Protocol", 24 - "Margin", 25 - "Bluesky", 26 - "Blacksky", 27 - "Tangled", 28 - "Northsky", 29 - "witchcraft.systems", 30 - "tophhie.social", 31 - "altq.net", 32 - ]; 33 - 34 - useEffect(() => { 35 - const cycleText = () => { 36 - setMorphClass("morph-out"); 37 - 38 - setTimeout(() => { 39 - setProviderIndex((prev) => (prev + 1) % providers.length); 40 - setMorphClass("morph-in"); 41 - }, 400); 42 - }; 43 - 44 - const interval = setInterval(cycleText, 3000); 45 - return () => clearInterval(interval); 46 - }, [providers.length]); 47 - 48 - const isSelectionRef = useRef(false); 49 - 50 - useEffect(() => { 51 - if (handle.length >= 3) { 52 - if (isSelectionRef.current) { 53 - isSelectionRef.current = false; 54 - return; 55 - } 56 - 57 - const timer = setTimeout(async () => { 58 - try { 59 - const data = await searchActors(handle); 60 - setSuggestions(data.actors || []); 61 - setShowSuggestions(true); 62 - setSelectedIndex(-1); 63 - } catch (e) { 64 - console.error("Search failed:", e); 65 - } 66 - }, 300); 67 - return () => clearTimeout(timer); 68 - } 69 - }, [handle]); 70 - 71 - useEffect(() => { 72 - const handleClickOutside = (e) => { 73 - if ( 74 - suggestionsRef.current && 75 - !suggestionsRef.current.contains(e.target) && 76 - inputRef.current && 77 - !inputRef.current.contains(e.target) 78 - ) { 79 - setShowSuggestions(false); 80 - } 81 - }; 82 - document.addEventListener("mousedown", handleClickOutside); 83 - return () => document.removeEventListener("mousedown", handleClickOutside); 84 - }, []); 85 - 86 - if (isAuthenticated) { 87 - return ( 88 - <div className="login-page"> 89 - <div className="login-avatar-large"> 90 - {user?.avatar ? ( 91 - <img src={user.avatar} alt={user.displayName || user.handle} /> 92 - ) : ( 93 - <span> 94 - {(user?.displayName || user?.handle || "??") 95 - .substring(0, 2) 96 - .toUpperCase()} 97 - </span> 98 - )} 99 - </div> 100 - <h1 className="login-welcome"> 101 - Welcome back, {user?.displayName || user?.handle} 102 - </h1> 103 - <div className="login-actions"> 104 - <Link to={`/profile/${user?.did}`} className="btn btn-primary"> 105 - View Profile 106 - </Link> 107 - <button onClick={logout} className="btn btn-ghost"> 108 - Sign out 109 - </button> 110 - </div> 111 - </div> 112 - ); 113 - } 114 - 115 - const handleKeyDown = (e) => { 116 - if (!showSuggestions || suggestions.length === 0) return; 117 - 118 - if (e.key === "ArrowDown") { 119 - e.preventDefault(); 120 - setSelectedIndex((prev) => Math.min(prev + 1, suggestions.length - 1)); 121 - } else if (e.key === "ArrowUp") { 122 - e.preventDefault(); 123 - setSelectedIndex((prev) => Math.max(prev - 1, -1)); 124 - } else if (e.key === "Enter" && selectedIndex >= 0) { 125 - e.preventDefault(); 126 - selectSuggestion(suggestions[selectedIndex]); 127 - } else if (e.key === "Escape") { 128 - setShowSuggestions(false); 129 - } 130 - }; 131 - 132 - const selectSuggestion = (actor) => { 133 - isSelectionRef.current = true; 134 - setHandle(actor.handle); 135 - setSuggestions([]); 136 - setShowSuggestions(false); 137 - inputRef.current?.blur(); 138 - }; 139 - 140 - const handleSubmit = async (e) => { 141 - e.preventDefault(); 142 - if (!handle.trim()) return; 143 - 144 - setLoading(true); 145 - setError(null); 146 - 147 - try { 148 - const result = await startLogin(handle.trim()); 149 - if (result.authorizationUrl) { 150 - window.location.href = result.authorizationUrl; 151 - } 152 - } catch (err) { 153 - console.error("Login error:", err); 154 - setError(err.message || "Failed to start login"); 155 - setLoading(false); 156 - } 157 - }; 158 - 159 - return ( 160 - <div className="login-page"> 161 - <div className="login-header-group"> 162 - <svg 163 - viewBox="0 0 265 231" 164 - fill="currentColor" 165 - xmlns="http://www.w3.org/2000/svg" 166 - className="login-logo-img" 167 - style={{ color: "var(--accent)" }} 168 - > 169 - <path d="M0 230 V0 H199 V65.7156 H149.5 V115.216 H182.5 L199 131.716 V230 Z" /> 170 - <path d="M215 214.224 V230 H264.5 V0 H215.07 V16.2242 H248.5 V214.224 H215 Z" /> 171 - </svg> 172 - <span className="login-x">X</span> 173 - <div className="login-atproto-icon"> 174 - <AtSign size={64} strokeWidth={2.4} /> 175 - </div> 176 - </div> 177 - 178 - <h1 className="login-heading"> 179 - Sign in with your{" "} 180 - <span className={`morph-container ${morphClass}`}> 181 - {providers[providerIndex]} 182 - </span>{" "} 183 - handle 184 - </h1> 185 - 186 - <form onSubmit={handleSubmit} className="login-form"> 187 - <div className="login-input-wrapper"> 188 - <input 189 - ref={inputRef} 190 - type="text" 191 - className="login-input" 192 - placeholder="yourname.bsky.social" 193 - value={handle} 194 - onChange={(e) => { 195 - const val = e.target.value; 196 - setHandle(val); 197 - if (val.length < 3) { 198 - setSuggestions([]); 199 - setShowSuggestions(false); 200 - } 201 - }} 202 - onKeyDown={handleKeyDown} 203 - onFocus={() => 204 - handle.length >= 3 && 205 - suggestions.length > 0 && 206 - !handle.includes(".") && 207 - setShowSuggestions(true) 208 - } 209 - autoComplete="off" 210 - autoCapitalize="off" 211 - autoCorrect="off" 212 - spellCheck="false" 213 - disabled={loading} 214 - /> 215 - 216 - {showSuggestions && suggestions.length > 0 && ( 217 - <div className="login-suggestions" ref={suggestionsRef}> 218 - {suggestions.map((actor, index) => ( 219 - <button 220 - key={actor.did} 221 - type="button" 222 - className={`login-suggestion ${index === selectedIndex ? "selected" : ""}`} 223 - onClick={() => selectSuggestion(actor)} 224 - > 225 - <div className="login-suggestion-avatar"> 226 - {actor.avatar ? ( 227 - <img src={actor.avatar} alt="" /> 228 - ) : ( 229 - <span> 230 - {(actor.displayName || actor.handle) 231 - .substring(0, 2) 232 - .toUpperCase()} 233 - </span> 234 - )} 235 - </div> 236 - <div className="login-suggestion-info"> 237 - <span className="login-suggestion-name"> 238 - {actor.displayName || actor.handle} 239 - </span> 240 - <span className="login-suggestion-handle"> 241 - @{actor.handle} 242 - </span> 243 - </div> 244 - </button> 245 - ))} 246 - </div> 247 - )} 248 - </div> 249 - 250 - {error && <p className="login-error">{error}</p>} 251 - 252 - <button 253 - type="submit" 254 - className="btn btn-primary login-submit" 255 - disabled={loading || !handle.trim()} 256 - > 257 - {loading ? "Connecting..." : "Continue"} 258 - </button> 259 - 260 - <p className="login-legal"> 261 - By signing in, you agree to our{" "} 262 - <Link to="/terms">Terms of Service</Link> and{" "} 263 - <Link to="/privacy">Privacy Policy</Link>. 264 - </p> 265 - 266 - <div className="login-divider"> 267 - <span>or</span> 268 - </div> 269 - 270 - <button 271 - type="button" 272 - className="btn btn-secondary login-signup-btn" 273 - onClick={() => setShowSignUp(true)} 274 - > 275 - Create New Account 276 - </button> 277 - </form> 278 - 279 - {showSignUp && <SignUpModal onClose={() => setShowSignUp(false)} />} 280 - </div> 281 - ); 282 - }
-99
web/src/pages/New.jsx
··· 1 - import { useState } from "react"; 2 - import { useNavigate, useSearchParams, Link } from "react-router-dom"; 3 - import { useAuth } from "../context/AuthContext"; 4 - import Composer from "../components/Composer"; 5 - 6 - export default function New() { 7 - const { isAuthenticated, loading } = useAuth(); 8 - const navigate = useNavigate(); 9 - const [searchParams] = useSearchParams(); 10 - 11 - const initialUrl = searchParams.get("url") || ""; 12 - 13 - let initialSelector = null; 14 - const selectorParam = searchParams.get("selector"); 15 - if (selectorParam) { 16 - try { 17 - initialSelector = JSON.parse(selectorParam); 18 - } catch (e) { 19 - console.error("Failed to parse selector:", e); 20 - } 21 - } 22 - 23 - const legacyQuote = searchParams.get("quote") || ""; 24 - if (legacyQuote && !initialSelector) { 25 - initialSelector = { 26 - type: "TextQuoteSelector", 27 - exact: legacyQuote, 28 - }; 29 - } 30 - 31 - const [url, setUrl] = useState(initialUrl); 32 - 33 - if (loading) { 34 - return ( 35 - <div className="new-page"> 36 - <div className="loading-spinner" /> 37 - </div> 38 - ); 39 - } 40 - 41 - if (!isAuthenticated) { 42 - return ( 43 - <div className="new-page"> 44 - <div className="card" style={{ textAlign: "center", padding: "48px" }}> 45 - <h2>Sign in to create annotations</h2> 46 - <p style={{ color: "var(--text-secondary)", marginTop: "8px" }}> 47 - You need to be logged in with your Bluesky account 48 - </p> 49 - <Link 50 - to="/login" 51 - className="btn btn-primary" 52 - style={{ marginTop: "24px" }} 53 - > 54 - Sign in with Bluesky 55 - </Link> 56 - </div> 57 - </div> 58 - ); 59 - } 60 - 61 - const handleSuccess = () => { 62 - navigate("/home"); 63 - }; 64 - 65 - return ( 66 - <div className="new-page"> 67 - <div className="page-header"> 68 - <h1 className="page-title">New Annotation</h1> 69 - <p className="page-description">Write in the margins of the web</p> 70 - </div> 71 - 72 - {!initialUrl && ( 73 - <div className="url-input-wrapper"> 74 - <input 75 - type="url" 76 - value={url} 77 - onChange={(e) => setUrl(e.target.value)} 78 - placeholder="https://example.com/article" 79 - className="url-input" 80 - required 81 - /> 82 - </div> 83 - )} 84 - 85 - <div className="card"> 86 - <Composer 87 - url={ 88 - (url || initialUrl) && !/^(?:f|ht)tps?:\/\//.test(url || initialUrl) 89 - ? `https://${url || initialUrl}` 90 - : url || initialUrl 91 - } 92 - selector={initialSelector} 93 - onSuccess={handleSuccess} 94 - onCancel={() => navigate(-1)} 95 - /> 96 - </div> 97 - </div> 98 - ); 99 - }
-233
web/src/pages/Notifications.jsx
··· 1 - import { useState, useEffect } from "react"; 2 - import { Link } from "react-router-dom"; 3 - import { useAuth } from "../context/AuthContext"; 4 - import { getNotifications, markNotificationsRead } from "../api/client"; 5 - import { BellIcon, HeartIcon, ReplyIcon } from "../components/Icons"; 6 - 7 - function getNotificationRoute(n) { 8 - if (n.type === "reply" && n.subject?.inReplyTo) { 9 - return `/annotation/${encodeURIComponent(n.subject.inReplyTo)}`; 10 - } 11 - if (!n.subjectUri) return "/"; 12 - if (n.subjectUri.includes("at.margin.bookmark")) { 13 - return `/bookmarks`; 14 - } 15 - if (n.subjectUri.includes("at.margin.highlight")) { 16 - return `/highlights`; 17 - } 18 - return `/annotation/${encodeURIComponent(n.subjectUri)}`; 19 - } 20 - 21 - export default function Notifications() { 22 - const { user } = useAuth(); 23 - const [notifications, setNotifications] = useState([]); 24 - const [loading, setLoading] = useState(true); 25 - const [error, setError] = useState(null); 26 - 27 - useEffect(() => { 28 - if (!user?.did) return; 29 - 30 - async function load() { 31 - try { 32 - setLoading(true); 33 - const data = await getNotifications(); 34 - setNotifications(data.items || []); 35 - await markNotificationsRead(); 36 - } catch (err) { 37 - setError(err.message); 38 - } finally { 39 - setLoading(false); 40 - } 41 - } 42 - load(); 43 - }, [user?.did]); 44 - 45 - const formatTime = (dateStr) => { 46 - const date = new Date(dateStr); 47 - const now = new Date(); 48 - const diffMs = now - date; 49 - const diffMins = Math.floor(diffMs / 60000); 50 - const diffHours = Math.floor(diffMs / 3600000); 51 - const diffDays = Math.floor(diffMs / 86400000); 52 - 53 - if (diffMins < 1) return "just now"; 54 - if (diffMins < 60) return `${diffMins}m ago`; 55 - if (diffHours < 24) return `${diffHours}h ago`; 56 - if (diffDays < 7) return `${diffDays}d ago`; 57 - return date.toLocaleDateString(); 58 - }; 59 - 60 - const getNotificationIcon = (type) => { 61 - switch (type) { 62 - case "like": 63 - return <HeartIcon size={16} />; 64 - case "reply": 65 - return <ReplyIcon size={16} />; 66 - default: 67 - return <BellIcon size={16} />; 68 - } 69 - }; 70 - 71 - const getNotificationText = (n) => { 72 - const name = n.actor?.displayName || n.actor?.handle || "Unknown"; 73 - const handle = n.actor?.handle; 74 - 75 - switch (n.type) { 76 - case "like": 77 - return ( 78 - <span> 79 - <Link 80 - to={`/profile/${handle}`} 81 - className="notification-author-link" 82 - onClick={(e) => e.stopPropagation()} 83 - > 84 - {name} 85 - </Link>{" "} 86 - liked your annotation 87 - </span> 88 - ); 89 - case "reply": 90 - return ( 91 - <span> 92 - <Link 93 - to={`/profile/${handle}`} 94 - className="notification-author-link" 95 - onClick={(e) => e.stopPropagation()} 96 - > 97 - {name} 98 - </Link>{" "} 99 - replied to your annotation 100 - </span> 101 - ); 102 - default: 103 - return ( 104 - <span> 105 - <Link 106 - to={`/profile/${handle}`} 107 - className="notification-author-link" 108 - onClick={(e) => e.stopPropagation()} 109 - > 110 - {name} 111 - </Link>{" "} 112 - interacted with your content 113 - </span> 114 - ); 115 - } 116 - }; 117 - 118 - if (!user) { 119 - return ( 120 - <div className="notifications-page"> 121 - <div className="page-header"> 122 - <h1 className="page-title">Notifications</h1> 123 - </div> 124 - <div className="empty-state"> 125 - <BellIcon size={48} /> 126 - <h3>Sign in to see notifications</h3> 127 - <p>Get notified when people like or reply to your content</p> 128 - </div> 129 - </div> 130 - ); 131 - } 132 - 133 - return ( 134 - <div className="notifications-page"> 135 - <div className="page-header"> 136 - <h1 className="page-title">Notifications</h1> 137 - <p className="page-description"> 138 - Likes and replies on your annotations 139 - </p> 140 - </div> 141 - 142 - {loading && ( 143 - <div className="loading-container"> 144 - <div className="loading-spinner"></div> 145 - </div> 146 - )} 147 - 148 - {error && ( 149 - <div className="error-message"> 150 - <p>Error: {error}</p> 151 - </div> 152 - )} 153 - 154 - {!loading && !error && notifications.length === 0 && ( 155 - <div className="empty-state"> 156 - <BellIcon size={48} /> 157 - <h3>No notifications yet</h3> 158 - <p> 159 - When someone likes or replies to your content, you&apos;ll see it 160 - here 161 - </p> 162 - </div> 163 - )} 164 - 165 - {!loading && !error && notifications.length > 0 && ( 166 - <div className="notifications-list"> 167 - {notifications.map((n, i) => ( 168 - <Link 169 - key={n.id || i} 170 - to={getNotificationRoute(n)} 171 - className="notification-item card" 172 - style={{ alignItems: "center" }} 173 - > 174 - <div 175 - className="notification-avatar-container" 176 - style={{ marginRight: 12, position: "relative" }} 177 - > 178 - {n.actor?.avatar ? ( 179 - <img 180 - src={n.actor.avatar} 181 - alt={n.actor.handle} 182 - style={{ 183 - width: 40, 184 - height: 40, 185 - borderRadius: "50%", 186 - objectFit: "cover", 187 - }} 188 - /> 189 - ) : ( 190 - <div 191 - style={{ 192 - width: 40, 193 - height: 40, 194 - borderRadius: "50%", 195 - background: "#eee", 196 - display: "flex", 197 - alignItems: "center", 198 - justifyContent: "center", 199 - }} 200 - > 201 - {(n.actor?.handle || "?")[0].toUpperCase()} 202 - </div> 203 - )} 204 - <div 205 - className="notification-icon-badge" 206 - data-type={n.type} 207 - style={{ 208 - position: "absolute", 209 - bottom: -4, 210 - right: -4, 211 - background: "var(--bg-primary)", 212 - borderRadius: "50%", 213 - padding: 2, 214 - display: "flex", 215 - boxShadow: "0 2px 4px rgba(0,0,0,0.1)", 216 - }} 217 - > 218 - {getNotificationIcon(n.type)} 219 - </div> 220 - </div> 221 - <div className="notification-content"> 222 - <p className="notification-text">{getNotificationText(n)}</p> 223 - <span className="notification-time"> 224 - {formatTime(n.createdAt)} 225 - </span> 226 - </div> 227 - </Link> 228 - ))} 229 - </div> 230 - )} 231 - </div> 232 - ); 233 - }
-142
web/src/pages/Privacy.jsx
··· 1 - import { ArrowLeft } from "lucide-react"; 2 - import { Link } from "react-router-dom"; 3 - 4 - export default function Privacy() { 5 - return ( 6 - <div className="feed-page"> 7 - <Link to="/home" className="back-link"> 8 - <ArrowLeft size={18} /> 9 - <span>Home</span> 10 - </Link> 11 - 12 - <div className="legal-content"> 13 - <h1>Privacy Policy</h1> 14 - <p className="text-secondary">Last updated: January 11, 2026</p> 15 - 16 - <section> 17 - <h2>Overview</h2> 18 - <p> 19 - Margin (&quot;we&quot;, &quot;our&quot;, or &quot;us&quot;) is a web 20 - annotation tool that lets you highlight, annotate, and bookmark any 21 - webpage. Your data is stored on the decentralized AT Protocol 22 - network, giving you ownership and control over your content. 23 - </p> 24 - </section> 25 - 26 - <section> 27 - <h2>Data We Collect</h2> 28 - <h3>Account Information</h3> 29 - <p> 30 - When you log in with your Bluesky/AT Protocol account, we access 31 - your: 32 - </p> 33 - <ul> 34 - <li>Decentralized Identifier (DID)</li> 35 - <li>Handle (username)</li> 36 - <li>Display name and avatar (for showing your profile)</li> 37 - </ul> 38 - 39 - <h3>Annotations & Content</h3> 40 - <p>When you use Margin, we store:</p> 41 - <ul> 42 - <li>URLs of pages you annotate</li> 43 - <li>Text you highlight or select</li> 44 - <li>Annotations and comments you create</li> 45 - <li>Bookmarks you save</li> 46 - <li>Collections you organize content into</li> 47 - </ul> 48 - 49 - <h3>Authentication</h3> 50 - <p> 51 - We store OAuth session tokens locally in your browser to keep you 52 - logged in. These tokens are used solely for authenticating API 53 - requests. 54 - </p> 55 - </section> 56 - 57 - <section> 58 - <h2>How We Use Your Data</h2> 59 - <p>Your data is used exclusively to:</p> 60 - <ul> 61 - <li>Display your annotations on webpages</li> 62 - <li>Sync your content across devices</li> 63 - <li>Show your public annotations to other users</li> 64 - <li>Enable social features like replies and likes</li> 65 - </ul> 66 - </section> 67 - 68 - <section> 69 - <h2>Data Storage</h2> 70 - <p> 71 - Your annotations are stored on the AT Protocol network through your 72 - Personal Data Server (PDS). This means: 73 - </p> 74 - <ul> 75 - <li>You own your data</li> 76 - <li>You can export or delete it at any time</li> 77 - <li>Your data is portable across AT Protocol services</li> 78 - </ul> 79 - <p> 80 - We also maintain a local index of annotations to provide faster 81 - search and discovery features. 82 - </p> 83 - </section> 84 - 85 - <section> 86 - <h2>Data Sharing</h2> 87 - <p> 88 - <strong>We do not sell your data.</strong> We do not share your data 89 - with third parties for advertising or marketing purposes. 90 - </p> 91 - <p>Your public annotations may be visible to:</p> 92 - <ul> 93 - <li>Other Margin users viewing the same webpage</li> 94 - <li>Anyone on the AT Protocol network (for public content)</li> 95 - </ul> 96 - </section> 97 - 98 - <section> 99 - <h2>Browser Extension Permissions</h2> 100 - <p>The Margin browser extension requires certain permissions:</p> 101 - <ul> 102 - <li> 103 - <strong>All URLs:</strong> To display and create annotations on 104 - any webpage 105 - </li> 106 - <li> 107 - <strong>Storage:</strong> To save your preferences and session 108 - locally 109 - </li> 110 - <li> 111 - <strong>Cookies:</strong> To maintain your logged-in session 112 - </li> 113 - <li> 114 - <strong>Tabs:</strong> To know which page you&apos;re viewing 115 - </li> 116 - </ul> 117 - </section> 118 - 119 - <section> 120 - <h2>Your Rights</h2> 121 - <p>You can:</p> 122 - <ul> 123 - <li> 124 - Delete any annotation, highlight, or bookmark you&apos;ve created 125 - </li> 126 - <li>Delete your collections</li> 127 - <li>Export your data from your PDS</li> 128 - <li>Revoke the extension&apos;s access at any time</li> 129 - </ul> 130 - </section> 131 - 132 - <section> 133 - <h2>Contact</h2> 134 - <p> 135 - For privacy questions or concerns, contact us at{" "} 136 - <a href="mailto:hello@margin.at">hello@margin.at</a> 137 - </p> 138 - </section> 139 - </div> 140 - </div> 141 - ); 142 - }
-410
web/src/pages/Profile.jsx
··· 1 - import { useState, useEffect } from "react"; 2 - import { useParams, Navigate } from "react-router-dom"; 3 - import AnnotationCard, { HighlightCard } from "../components/AnnotationCard"; 4 - import BookmarkCard from "../components/BookmarkCard"; 5 - import { getLinkIconType, formatUrl } from "../utils/formatting"; 6 - import { 7 - getUserAnnotations, 8 - getUserHighlights, 9 - getUserBookmarks, 10 - getCollections, 11 - getProfile, 12 - } from "../api/client"; 13 - import { useAuth } from "../context/AuthContext"; 14 - import EditProfileModal from "../components/EditProfileModal"; 15 - import CollectionIcon from "../components/CollectionIcon"; 16 - import CollectionRow from "../components/CollectionRow"; 17 - import { 18 - PenIcon, 19 - HighlightIcon, 20 - BookmarkIcon, 21 - BlueskyIcon, 22 - GithubIcon, 23 - LinkedinIcon, 24 - TangledIcon, 25 - LinkIcon, 26 - } from "../components/Icons"; 27 - 28 - function LinkIconComponent({ url }) { 29 - const type = getLinkIconType(url); 30 - switch (type) { 31 - case "github": 32 - return <GithubIcon size={14} />; 33 - case "bluesky": 34 - return <BlueskyIcon size={14} />; 35 - case "linkedin": 36 - return <LinkedinIcon size={14} />; 37 - case "tangled": 38 - return <TangledIcon size={14} />; 39 - default: 40 - return <LinkIcon size={14} />; 41 - } 42 - } 43 - 44 - export default function Profile() { 45 - const { handle: routeHandle } = useParams(); 46 - const { user, loading: authLoading } = useAuth(); 47 - const [activeTab, setActiveTab] = useState("annotations"); 48 - const [profile, setProfile] = useState(null); 49 - const [annotations, setAnnotations] = useState([]); 50 - const [highlights, setHighlights] = useState([]); 51 - const [bookmarks, setBookmarks] = useState([]); 52 - const [collections, setCollections] = useState([]); 53 - 54 - const [loading, setLoading] = useState(true); 55 - const [error, setError] = useState(null); 56 - const [showEditModal, setShowEditModal] = useState(false); 57 - 58 - const handle = routeHandle || user?.did || user?.handle; 59 - const isOwnProfile = user && (user.did === handle || user.handle === handle); 60 - 61 - useEffect(() => { 62 - if (!handle) return; 63 - async function fetchProfile() { 64 - try { 65 - setLoading(true); 66 - 67 - const bskyPromise = fetch( 68 - `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`, 69 - ).then((res) => (res.ok ? res.json() : null)); 70 - 71 - const marginPromise = getProfile(handle).catch(() => null); 72 - 73 - const marginData = await marginPromise; 74 - let did = handle.startsWith("did:") ? handle : marginData?.did; 75 - if (!did) { 76 - const bskyData = await bskyPromise; 77 - if (bskyData) { 78 - did = bskyData.did; 79 - setProfile(bskyData); 80 - } 81 - } else { 82 - if (marginData) { 83 - setProfile((prev) => ({ ...prev, ...marginData })); 84 - } 85 - } 86 - 87 - if (did) { 88 - const [annData, hlData, bmData, collData] = await Promise.all([ 89 - getUserAnnotations(did), 90 - getUserHighlights(did).catch(() => ({ items: [] })), 91 - getUserBookmarks(did).catch(() => ({ items: [] })), 92 - getCollections(did).catch(() => ({ items: [] })), 93 - ]); 94 - setAnnotations(annData.items || []); 95 - setHighlights(hlData.items || []); 96 - setBookmarks(bmData.items || []); 97 - setCollections(collData.items || []); 98 - 99 - const bskyData = await bskyPromise; 100 - if (bskyData || marginData) { 101 - const merged = { 102 - ...(bskyData || {}), 103 - }; 104 - if (marginData) { 105 - merged.did = marginData.did || merged.did; 106 - if (marginData.displayName) 107 - merged.displayName = marginData.displayName; 108 - if (marginData.avatar) merged.avatar = marginData.avatar; 109 - if (marginData.bio) merged.bio = marginData.bio; 110 - if (marginData.website) merged.website = marginData.website; 111 - if (marginData.links?.length) merged.links = marginData.links; 112 - } 113 - setProfile(merged); 114 - } 115 - } 116 - } catch (err) { 117 - console.error(err); 118 - setError(err.message); 119 - } finally { 120 - setLoading(false); 121 - } 122 - } 123 - fetchProfile(); 124 - }, [handle]); 125 - 126 - if (authLoading) { 127 - return ( 128 - <div className="profile-page"> 129 - <div className="feed-container"> 130 - <div className="feed"> 131 - {[1, 2, 3].map((i) => ( 132 - <div key={i} className="card"> 133 - <div 134 - className="skeleton skeleton-text" 135 - style={{ width: "40%" }} 136 - /> 137 - <div className="skeleton skeleton-text" /> 138 - <div 139 - className="skeleton skeleton-text" 140 - style={{ width: "60%" }} 141 - /> 142 - </div> 143 - ))} 144 - </div> 145 - </div> 146 - </div> 147 - ); 148 - } 149 - 150 - if (!handle) { 151 - return <Navigate to="/login" replace />; 152 - } 153 - 154 - const displayName = profile?.displayName || profile?.handle || handle; 155 - const displayHandle = 156 - profile?.handle || (handle?.startsWith("did:") ? null : handle); 157 - const avatarUrl = profile?.did 158 - ? `/api/avatar/${encodeURIComponent(profile.did)}` 159 - : null; 160 - 161 - const getInitial = () => { 162 - return (displayName || displayHandle || "??") 163 - ?.substring(0, 2) 164 - .toUpperCase(); 165 - }; 166 - 167 - const totalItems = 168 - annotations.length + 169 - highlights.length + 170 - bookmarks.length + 171 - collections.length; 172 - 173 - const renderContent = () => { 174 - if (activeTab === "annotations") { 175 - if (annotations.length === 0) { 176 - return ( 177 - <div className="empty-state"> 178 - <div className="empty-state-icon"> 179 - <PenIcon size={32} /> 180 - </div> 181 - <h3 className="empty-state-title">No annotations</h3> 182 - <p className="empty-state-text"> 183 - This user hasn&apos;t posted any annotations. 184 - </p> 185 - </div> 186 - ); 187 - } 188 - return annotations.map((a) => ( 189 - <AnnotationCard key={a.id} annotation={a} /> 190 - )); 191 - } 192 - 193 - if (activeTab === "highlights") { 194 - if (highlights.length === 0) { 195 - return ( 196 - <div className="empty-state"> 197 - <div className="empty-state-icon"> 198 - <HighlightIcon size={32} /> 199 - </div> 200 - <h3 className="empty-state-title">No highlights</h3> 201 - <p className="empty-state-text"> 202 - This user hasn&apos;t saved any highlights. 203 - </p> 204 - </div> 205 - ); 206 - } 207 - return highlights.map((h) => <HighlightCard key={h.id} highlight={h} />); 208 - } 209 - 210 - if (activeTab === "bookmarks") { 211 - if (bookmarks.length === 0) { 212 - return ( 213 - <div className="empty-state"> 214 - <div className="empty-state-icon"> 215 - <BookmarkIcon size={32} /> 216 - </div> 217 - <h3 className="empty-state-title">No bookmarks</h3> 218 - <p className="empty-state-text"> 219 - This user hasn&apos;t bookmarked any pages. 220 - </p> 221 - </div> 222 - ); 223 - } 224 - return bookmarks.map((b) => <BookmarkCard key={b.uri} bookmark={b} />); 225 - } 226 - 227 - if (activeTab === "collections") { 228 - if (collections.length === 0) { 229 - return ( 230 - <div className="empty-state"> 231 - <div className="empty-state-icon"> 232 - <CollectionIcon icon="folder" size={32} /> 233 - </div> 234 - <h3 className="empty-state-title">No collections</h3> 235 - <p className="empty-state-text"> 236 - This user hasn&apos;t created any collections. 237 - </p> 238 - </div> 239 - ); 240 - } 241 - return ( 242 - <div className="collections-list"> 243 - {collections.map((c) => ( 244 - <CollectionRow key={c.uri} collection={c} /> 245 - ))} 246 - </div> 247 - ); 248 - } 249 - }; 250 - 251 - const bskyProfileUrl = displayHandle 252 - ? `https://bsky.app/profile/${displayHandle}` 253 - : `https://bsky.app/profile/${handle}`; 254 - 255 - return ( 256 - <div className="profile-page"> 257 - <header className="profile-header"> 258 - <a 259 - href={bskyProfileUrl} 260 - target="_blank" 261 - rel="noopener noreferrer" 262 - className="profile-avatar-link" 263 - > 264 - <div className="profile-avatar"> 265 - {avatarUrl ? ( 266 - <img src={avatarUrl} alt={displayName} /> 267 - ) : ( 268 - <span>{getInitial()}</span> 269 - )} 270 - </div> 271 - </a> 272 - <div className="profile-info"> 273 - <h1 className="profile-name">{displayName}</h1> 274 - {displayHandle && ( 275 - <a 276 - href={bskyProfileUrl} 277 - target="_blank" 278 - rel="noopener noreferrer" 279 - className="profile-bluesky-link" 280 - > 281 - <BlueskyIcon size={16} />@{displayHandle} 282 - </a> 283 - )} 284 - <div className="profile-stats"> 285 - <span className="profile-stat"> 286 - <strong>{totalItems}</strong> items 287 - </span> 288 - <span className="profile-stat"> 289 - <strong>{annotations.length}</strong> annotations 290 - </span> 291 - <span className="profile-stat"> 292 - <strong>{highlights.length}</strong> highlights 293 - </span> 294 - </div> 295 - 296 - {(profile?.bio || profile?.website || profile?.links?.length > 0) && ( 297 - <div className="profile-margin-details"> 298 - {profile.bio && <p className="profile-bio">{profile.bio}</p>} 299 - <div className="profile-links"> 300 - {profile.website && ( 301 - <a 302 - href={profile.website} 303 - target="_blank" 304 - rel="noopener noreferrer" 305 - className="profile-link-chip main-website" 306 - > 307 - <LinkIcon size={14} /> {formatUrl(profile.website)} 308 - </a> 309 - )} 310 - {profile.links?.map((link, i) => ( 311 - <a 312 - key={i} 313 - href={link} 314 - target="_blank" 315 - rel="noopener noreferrer" 316 - className="profile-link-chip" 317 - > 318 - <LinkIconComponent url={link} /> {formatUrl(link)} 319 - </a> 320 - ))} 321 - </div> 322 - </div> 323 - )} 324 - 325 - {isOwnProfile && ( 326 - <button 327 - className="btn btn-secondary btn-sm" 328 - style={{ marginTop: "1rem", alignSelf: "flex-start" }} 329 - onClick={() => setShowEditModal(true)} 330 - > 331 - Edit Profile 332 - </button> 333 - )} 334 - </div> 335 - </header> 336 - 337 - {showEditModal && ( 338 - <EditProfileModal 339 - profile={profile} 340 - onClose={() => setShowEditModal(false)} 341 - onUpdate={() => { 342 - window.location.reload(); 343 - }} 344 - /> 345 - )} 346 - 347 - <div className="profile-tabs"> 348 - <button 349 - className={`profile-tab ${activeTab === "annotations" ? "active" : ""}`} 350 - onClick={() => setActiveTab("annotations")} 351 - > 352 - Annotations ({annotations.length}) 353 - </button> 354 - <button 355 - className={`profile-tab ${activeTab === "highlights" ? "active" : ""}`} 356 - onClick={() => setActiveTab("highlights")} 357 - > 358 - Highlights ({highlights.length}) 359 - </button> 360 - <button 361 - className={`profile-tab ${activeTab === "bookmarks" ? "active" : ""}`} 362 - onClick={() => setActiveTab("bookmarks")} 363 - > 364 - Bookmarks ({bookmarks.length}) 365 - </button> 366 - 367 - <button 368 - className={`profile-tab ${activeTab === "collections" ? "active" : ""}`} 369 - onClick={() => setActiveTab("collections")} 370 - > 371 - Collections ({collections.length}) 372 - </button> 373 - </div> 374 - 375 - {loading && ( 376 - <div className="feed-container"> 377 - <div className="feed"> 378 - {[1, 2, 3].map((i) => ( 379 - <div key={i} className="card"> 380 - <div 381 - className="skeleton skeleton-text" 382 - style={{ width: "40%" }} 383 - /> 384 - <div className="skeleton skeleton-text" /> 385 - <div 386 - className="skeleton skeleton-text" 387 - style={{ width: "60%" }} 388 - /> 389 - </div> 390 - ))} 391 - </div> 392 - </div> 393 - )} 394 - 395 - {error && ( 396 - <div className="empty-state"> 397 - <div className="empty-state-icon">⚠️</div> 398 - <h3 className="empty-state-title">Error loading profile</h3> 399 - <p className="empty-state-text">{error}</p> 400 - </div> 401 - )} 402 - 403 - {!loading && !error && ( 404 - <div className="feed-container"> 405 - <div className="feed">{renderContent()}</div> 406 - </div> 407 - )} 408 - </div> 409 - ); 410 - }
-344
web/src/pages/Settings.jsx
··· 1 - import { useState, useEffect } from "react"; 2 - import { getAPIKeys, createAPIKey, deleteAPIKey } from "../api/client"; 3 - import { useTheme } from "../context/ThemeContext"; 4 - import { useAuth } from "../context/AuthContext"; 5 - import { Navigate } from "react-router-dom"; 6 - import { Columns, Layout } from "lucide-react"; 7 - 8 - function KeyIcon({ size = 16 }) { 9 - return ( 10 - <svg 11 - width={size} 12 - height={size} 13 - viewBox="0 0 24 24" 14 - fill="none" 15 - stroke="currentColor" 16 - strokeWidth="2" 17 - strokeLinecap="round" 18 - strokeLinejoin="round" 19 - > 20 - <path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4" /> 21 - </svg> 22 - ); 23 - } 24 - 25 - function AppleIcon({ size = 16 }) { 26 - return ( 27 - <svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor"> 28 - <path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z" /> 29 - </svg> 30 - ); 31 - } 32 - 33 - export default function Settings() { 34 - const { isAuthenticated, loading } = useAuth(); 35 - const { layout, setLayout } = useTheme(); 36 - const [apiKeys, setApiKeys] = useState([]); 37 - const [newKeyName, setNewKeyName] = useState(""); 38 - const [newKey, setNewKey] = useState(null); 39 - const [keysLoading, setKeysLoading] = useState(false); 40 - 41 - useEffect(() => { 42 - if (isAuthenticated) { 43 - loadAPIKeys(); 44 - } 45 - }, [isAuthenticated]); 46 - 47 - const loadAPIKeys = async () => { 48 - setKeysLoading(true); 49 - try { 50 - const data = await getAPIKeys(); 51 - setApiKeys(data.keys || []); 52 - } catch { 53 - setApiKeys([]); 54 - } finally { 55 - setKeysLoading(false); 56 - } 57 - }; 58 - 59 - const handleCreateKey = async () => { 60 - if (!newKeyName.trim()) return; 61 - try { 62 - const data = await createAPIKey(newKeyName.trim()); 63 - setNewKey(data.key); 64 - setNewKeyName(""); 65 - loadAPIKeys(); 66 - } catch (err) { 67 - alert("Failed to create key: " + err.message); 68 - } 69 - }; 70 - 71 - const handleDeleteKey = async (id) => { 72 - if (!confirm("Delete this API key? This cannot be undone.")) return; 73 - try { 74 - await deleteAPIKey(id); 75 - loadAPIKeys(); 76 - } catch (err) { 77 - alert("Failed to delete key: " + err.message); 78 - } 79 - }; 80 - 81 - if (loading) return null; 82 - if (!isAuthenticated) return <Navigate to="/login" replace />; 83 - 84 - return ( 85 - <div className="settings-page"> 86 - <h1 className="page-title">Settings</h1> 87 - <p className="page-description">Manage your preferences and API keys.</p> 88 - 89 - <div className="settings-section layout-settings-section"> 90 - <h2>Layout</h2> 91 - <div className="layout-options"> 92 - <button 93 - className={`layout-option ${layout === "sidebar" ? "active" : ""}`} 94 - onClick={() => setLayout("sidebar")} 95 - > 96 - <Columns size={24} /> 97 - <div className="layout-info"> 98 - <h3>Three Column (Default)</h3> 99 - <p>Sidebars for navigation and tools</p> 100 - </div> 101 - </button> 102 - <button 103 - className={`layout-option ${layout === "topnav" ? "active" : ""}`} 104 - onClick={() => setLayout("topnav")} 105 - > 106 - <Layout size={24} /> 107 - <div className="layout-info"> 108 - <h3>Top Navigation</h3> 109 - <p>Cleaner view with top menu</p> 110 - </div> 111 - </button> 112 - </div> 113 - </div> 114 - 115 - <div className="settings-section"> 116 - <h2>API Keys</h2> 117 - <p className="section-description"> 118 - Use API keys to create bookmarks from iOS Shortcuts or other tools. 119 - </p> 120 - 121 - <div className="card" style={{ marginBottom: "1rem" }}> 122 - <h3 style={{ marginBottom: "0.5rem" }}>Create API Key</h3> 123 - <div style={{ display: "flex", gap: "0.5rem" }}> 124 - <input 125 - type="text" 126 - value={newKeyName} 127 - onChange={(e) => setNewKeyName(e.target.value)} 128 - placeholder="Key name (e.g., iOS Shortcut)" 129 - className="input" 130 - style={{ flex: 1 }} 131 - /> 132 - <button className="btn btn-primary" onClick={handleCreateKey}> 133 - Generate 134 - </button> 135 - </div> 136 - {newKey && ( 137 - <div 138 - style={{ 139 - marginTop: "1rem", 140 - padding: "1rem", 141 - background: "var(--bg-secondary)", 142 - borderRadius: "8px", 143 - }} 144 - > 145 - <p 146 - style={{ 147 - color: "var(--text-success)", 148 - fontWeight: 500, 149 - marginBottom: "0.5rem", 150 - }} 151 - > 152 - ✓ Key created! Copy it now, you won&apos;t see it again. 153 - </p> 154 - <code 155 - style={{ 156 - display: "block", 157 - padding: "0.75rem", 158 - background: "var(--bg-tertiary)", 159 - borderRadius: "4px", 160 - wordBreak: "break-all", 161 - fontSize: "0.8rem", 162 - }} 163 - > 164 - {newKey} 165 - </code> 166 - <button 167 - className="btn btn-secondary" 168 - style={{ marginTop: "0.5rem" }} 169 - onClick={() => { 170 - navigator.clipboard.writeText(newKey); 171 - alert("Copied!"); 172 - }} 173 - > 174 - Copy to clipboard 175 - </button> 176 - </div> 177 - )} 178 - </div> 179 - 180 - {keysLoading ? ( 181 - <div className="card"> 182 - <div className="skeleton skeleton-text" /> 183 - </div> 184 - ) : apiKeys.length === 0 ? ( 185 - <div className="empty-state"> 186 - <div className="empty-state-icon"> 187 - <KeyIcon size={32} /> 188 - </div> 189 - <h3 className="empty-state-title">No API keys</h3> 190 - <p className="empty-state-text"> 191 - Create a key to use with customized tools. 192 - </p> 193 - </div> 194 - ) : ( 195 - <div className="card"> 196 - <h3 style={{ marginBottom: "1rem" }}>Your API Keys</h3> 197 - {apiKeys.map((key) => ( 198 - <div 199 - key={key.id} 200 - style={{ 201 - display: "flex", 202 - justifyContent: "space-between", 203 - alignItems: "center", 204 - padding: "0.75rem 0", 205 - borderBottom: "1px solid var(--border-color)", 206 - }} 207 - > 208 - <div> 209 - <strong>{key.name}</strong> 210 - <div 211 - style={{ 212 - fontSize: "0.75rem", 213 - color: "var(--text-muted)", 214 - }} 215 - > 216 - Created {new Date(key.createdAt).toLocaleDateString()} 217 - {key.lastUsedAt && 218 - ` • Last used ${new Date(key.lastUsedAt).toLocaleDateString()}`} 219 - </div> 220 - </div> 221 - <button 222 - className="btn btn-sm" 223 - style={{ 224 - fontSize: "0.75rem", 225 - padding: "0.25rem 0.5rem", 226 - color: "#ef4444", 227 - border: "1px solid #ef4444", 228 - }} 229 - onClick={() => handleDeleteKey(key.id)} 230 - > 231 - Revoke 232 - </button> 233 - </div> 234 - ))} 235 - </div> 236 - )} 237 - 238 - <div className="card" style={{ marginTop: "1rem" }}> 239 - <h3 style={{ marginBottom: "0.5rem" }}>iOS Shortcut</h3> 240 - <p 241 - style={{ 242 - color: "var(--text-muted)", 243 - marginBottom: "1rem", 244 - fontSize: "0.875rem", 245 - }} 246 - > 247 - Save bookmarks from Safari&apos;s share sheet. 248 - </p> 249 - <a 250 - href="https://www.icloud.com/shortcuts/21c87edf29b046db892c9e57dac6d1fd" 251 - target="_blank" 252 - rel="noopener noreferrer" 253 - className="btn btn-primary" 254 - style={{ 255 - display: "inline-flex", 256 - alignItems: "center", 257 - gap: "0.5rem", 258 - }} 259 - > 260 - <AppleIcon size={16} /> Get Shortcut 261 - </a> 262 - </div> 263 - </div> 264 - 265 - <style>{` 266 - .settings-page { 267 - max-width: 800px; 268 - margin: 0 auto; 269 - } 270 - .page-title { 271 - font-size: 1.8rem; 272 - font-weight: 700; 273 - margin-bottom: 0.5rem; 274 - } 275 - .page-description { 276 - color: var(--text-secondary); 277 - margin-bottom: 2rem; 278 - } 279 - .settings-section { 280 - margin-bottom: 3rem; 281 - } 282 - .settings-section h2 { 283 - font-size: 1.2rem; 284 - margin-bottom: 1rem; 285 - padding-bottom: 0.5rem; 286 - border-bottom: 1px solid var(--border); 287 - } 288 - .section-description { 289 - color: var(--text-secondary); 290 - margin-bottom: 1.5rem; 291 - font-size: 0.9rem; 292 - } 293 - .layout-options { 294 - display: grid; 295 - grid-template-columns: 1fr 1fr; 296 - gap: 1rem; 297 - } 298 - .layout-option { 299 - display: flex; 300 - align-items: center; 301 - gap: 1rem; 302 - padding: 1.5rem; 303 - background: var(--bg-card); 304 - border: 2px solid var(--border); 305 - border-radius: var(--radius-lg); 306 - cursor: pointer; 307 - text-align: left; 308 - transition: all 0.2s; 309 - color: var(--text-primary); 310 - } 311 - .layout-option:hover { 312 - border-color: var(--border-hover); 313 - background: var(--bg-hover); 314 - } 315 - .layout-option.active { 316 - border-color: var(--accent); 317 - background: var(--bg-secondary); 318 - } 319 - .layout-option.active svg { 320 - color: var(--accent); 321 - } 322 - .layout-info h3 { 323 - font-size: 1rem; 324 - margin-bottom: 0.25rem; 325 - } 326 - .layout-info p { 327 - font-size: 0.8rem; 328 - color: var(--text-secondary); 329 - margin: 0; 330 - } 331 - @media (max-width: 600px) { 332 - .layout-options { 333 - grid-template-columns: 1fr; 334 - } 335 - } 336 - @media (max-width: 768px) { 337 - .layout-settings-section { 338 - display: none; 339 - } 340 - } 341 - `}</style> 342 - </div> 343 - ); 344 - }
-81
web/src/pages/Terms.jsx
··· 1 - import { ArrowLeft } from "lucide-react"; 2 - import { Link } from "react-router-dom"; 3 - 4 - export default function Terms() { 5 - return ( 6 - <div className="feed-page"> 7 - <Link to="/home" className="back-link"> 8 - <ArrowLeft size={18} /> 9 - <span>Home</span> 10 - </Link> 11 - 12 - <div className="legal-content"> 13 - <h1>Terms of Service</h1> 14 - <p className="text-secondary">Last updated: January 17, 2026</p> 15 - 16 - <section> 17 - <h2>Overview</h2> 18 - <p> 19 - Margin is an open-source project. By using our service, you agree to 20 - these terms (&quot;Terms&quot;). If you do not agree to these Terms, 21 - please do not use the Service. 22 - </p> 23 - </section> 24 - 25 - <section> 26 - <h2>Open Source</h2> 27 - <p> 28 - Margin is open source software. The code is available publicly and 29 - is provided &quot;as is&quot;, without warranty of any kind, express 30 - or implied. 31 - </p> 32 - </section> 33 - 34 - <section> 35 - <h2>User Conduct</h2> 36 - <p> 37 - You are responsible for your use of the Service and for any content 38 - you provide, including compliance with applicable laws, rules, and 39 - regulations. 40 - </p> 41 - <p> 42 - We reserve the right to remove any content that violates these 43 - terms, including but not limited to: 44 - </p> 45 - <ul> 46 - <li>Illegal content</li> 47 - <li>Harassment or hate speech</li> 48 - <li>Spam or malicious content</li> 49 - </ul> 50 - </section> 51 - 52 - <section> 53 - <h2>Decentralized Nature</h2> 54 - <p> 55 - Margin interacts with the AT Protocol network. We do not control the 56 - network itself or the data stored on your Personal Data Server 57 - (PDS). Please refer to the terms of your PDS provider for data 58 - storage policies. 59 - </p> 60 - </section> 61 - 62 - <section> 63 - <h2>Disclaimer</h2> 64 - <p> 65 - THE SERVICE IS PROVIDED &quot;AS IS&quot; AND &quot;AS 66 - AVAILABLE&quot;. WE DISCLAIM ALL CONDITIONS, REPRESENTATIONS AND 67 - WARRANTIES NOT EXPRESSLY SET OUT IN THESE TERMS. 68 - </p> 69 - </section> 70 - 71 - <section> 72 - <h2>Contact</h2> 73 - <p> 74 - For questions about these Terms, please contact us at{" "} 75 - <a href="mailto:hello@margin.at">hello@margin.at</a> 76 - </p> 77 - </section> 78 - </div> 79 - </div> 80 - ); 81 - }
-390
web/src/pages/Url.jsx
··· 1 - import { useState, useEffect, useRef } from "react"; 2 - import { Link, useNavigate } from "react-router-dom"; 3 - import AnnotationCard, { HighlightCard } from "../components/AnnotationCard"; 4 - import { getByTarget, searchActors } from "../api/client"; 5 - import { useAuth } from "../context/AuthContext"; 6 - import { PenIcon, AlertIcon, SearchIcon } from "../components/Icons"; 7 - import { Copy, Check, ExternalLink } from "lucide-react"; 8 - 9 - export default function Url() { 10 - const { user } = useAuth(); 11 - const navigate = useNavigate(); 12 - const [url, setUrl] = useState(""); 13 - const [annotations, setAnnotations] = useState([]); 14 - const [highlights, setHighlights] = useState([]); 15 - const [loading, setLoading] = useState(false); 16 - const [searched, setSearched] = useState(false); 17 - const [error, setError] = useState(null); 18 - const [activeTab, setActiveTab] = useState("all"); 19 - const [copied, setCopied] = useState(false); 20 - 21 - const [suggestions, setSuggestions] = useState([]); 22 - const [showSuggestions, setShowSuggestions] = useState(false); 23 - const [selectedIndex, setSelectedIndex] = useState(-1); 24 - const inputRef = useRef(null); 25 - const suggestionsRef = useRef(null); 26 - 27 - useEffect(() => { 28 - const timer = setTimeout(async () => { 29 - const isUrl = url.includes("http") || url.includes("://"); 30 - if (url.length >= 2 && !isUrl) { 31 - try { 32 - const data = await searchActors(url); 33 - setSuggestions(data.actors || []); 34 - setShowSuggestions(true); 35 - } catch { 36 - // ignore 37 - } 38 - } else { 39 - setSuggestions([]); 40 - setShowSuggestions(false); 41 - } 42 - }, 300); 43 - return () => clearTimeout(timer); 44 - }, [url]); 45 - 46 - useEffect(() => { 47 - const handleClickOutside = (e) => { 48 - if ( 49 - suggestionsRef.current && 50 - !suggestionsRef.current.contains(e.target) && 51 - inputRef.current && 52 - !inputRef.current.contains(e.target) 53 - ) { 54 - setShowSuggestions(false); 55 - } 56 - }; 57 - document.addEventListener("mousedown", handleClickOutside); 58 - return () => document.removeEventListener("mousedown", handleClickOutside); 59 - }, []); 60 - 61 - const handleKeyDown = (e) => { 62 - if (!showSuggestions || suggestions.length === 0) return; 63 - 64 - if (e.key === "ArrowDown") { 65 - e.preventDefault(); 66 - setSelectedIndex((prev) => Math.min(prev + 1, suggestions.length - 1)); 67 - } else if (e.key === "ArrowUp") { 68 - e.preventDefault(); 69 - setSelectedIndex((prev) => Math.max(prev - 1, -1)); 70 - } else if (e.key === "Enter" && selectedIndex >= 0) { 71 - e.preventDefault(); 72 - selectSuggestion(suggestions[selectedIndex]); 73 - } else if (e.key === "Escape") { 74 - setShowSuggestions(false); 75 - } 76 - }; 77 - 78 - const selectSuggestion = (actor) => { 79 - navigate(`/profile/${encodeURIComponent(actor.handle)}`); 80 - }; 81 - 82 - const handleSearch = async (e) => { 83 - e.preventDefault(); 84 - if (!url.trim()) return; 85 - 86 - setLoading(true); 87 - setError(null); 88 - setSearched(true); 89 - 90 - const isProtocol = url.startsWith("http://") || url.startsWith("https://"); 91 - if (!isProtocol) { 92 - try { 93 - const actorRes = await searchActors(url); 94 - if (actorRes?.actors?.length > 0) { 95 - const match = actorRes.actors[0]; 96 - navigate(`/profile/${encodeURIComponent(match.handle)}`); 97 - return; 98 - } 99 - } catch { 100 - // ignore 101 - } 102 - } 103 - 104 - try { 105 - const data = await getByTarget(url); 106 - setAnnotations(data.annotations || []); 107 - setHighlights(data.highlights || []); 108 - } catch (err) { 109 - setError(err.message); 110 - } finally { 111 - setLoading(false); 112 - } 113 - }; 114 - 115 - const myAnnotations = user 116 - ? annotations.filter((a) => (a.creator?.did || a.author?.did) === user.did) 117 - : []; 118 - const myHighlights = user 119 - ? highlights.filter((h) => (h.creator?.did || h.author?.did) === user.did) 120 - : []; 121 - const myItemsCount = myAnnotations.length + myHighlights.length; 122 - 123 - const getShareUrl = () => { 124 - if (!user?.handle || !url) return null; 125 - return `${window.location.origin}/${user.handle}/url/${url}`; 126 - }; 127 - 128 - const handleCopyShareLink = async () => { 129 - const shareUrl = getShareUrl(); 130 - if (!shareUrl) return; 131 - try { 132 - await navigator.clipboard.writeText(shareUrl); 133 - setCopied(true); 134 - setTimeout(() => setCopied(false), 2000); 135 - } catch { 136 - prompt("Copy this link:", shareUrl); 137 - } 138 - }; 139 - 140 - const totalItems = annotations.length + highlights.length; 141 - 142 - const renderResults = () => { 143 - if (activeTab === "annotations" && annotations.length === 0) { 144 - return ( 145 - <div className="empty-state"> 146 - <div className="empty-state-icon"> 147 - <PenIcon size={32} /> 148 - </div> 149 - <h3 className="empty-state-title">No annotations</h3> 150 - </div> 151 - ); 152 - } 153 - 154 - return ( 155 - <> 156 - {(activeTab === "all" || activeTab === "annotations") && 157 - annotations.map((a) => <AnnotationCard key={a.id} annotation={a} />)} 158 - {(activeTab === "all" || activeTab === "highlights") && 159 - highlights.map((h) => <HighlightCard key={h.id} highlight={h} />)} 160 - </> 161 - ); 162 - }; 163 - 164 - return ( 165 - <div className="url-page"> 166 - <div className="page-header"> 167 - <h1 className="page-title">Explore</h1> 168 - <p className="page-description"> 169 - Search for a URL to view its context layer, or find a user by their 170 - handle 171 - </p> 172 - </div> 173 - 174 - <form 175 - onSubmit={handleSearch} 176 - className="url-input-wrapper" 177 - style={{ position: "relative" }} 178 - > 179 - <div className="url-input-container"> 180 - <input 181 - ref={inputRef} 182 - type="text" 183 - value={url} 184 - onChange={(e) => setUrl(e.target.value)} 185 - onKeyDown={handleKeyDown} 186 - placeholder="https://... or handle" 187 - className="url-input" 188 - autoComplete="off" 189 - required 190 - /> 191 - <button type="submit" className="btn btn-primary" disabled={loading}> 192 - {loading ? "Searching..." : "Search"} 193 - </button> 194 - </div> 195 - 196 - {showSuggestions && suggestions.length > 0 && ( 197 - <div 198 - className="login-suggestions" 199 - ref={suggestionsRef} 200 - style={{ 201 - position: "absolute", 202 - top: "100%", 203 - left: 0, 204 - right: 0, 205 - marginTop: "8px", 206 - width: "100%", 207 - zIndex: 50, 208 - background: "var(--bg-primary)", 209 - borderRadius: "12px", 210 - boxShadow: "var(--shadow-lg)", 211 - border: "1px solid var(--border)", 212 - maxHeight: "300px", 213 - overflowY: "auto", 214 - }} 215 - > 216 - {suggestions.map((actor, index) => ( 217 - <button 218 - key={actor.did} 219 - type="button" 220 - className={`login-suggestion ${index === selectedIndex ? "selected" : ""}`} 221 - onClick={() => selectSuggestion(actor)} 222 - style={{ 223 - width: "100%", 224 - textAlign: "left", 225 - padding: "12px", 226 - display: "flex", 227 - alignItems: "center", 228 - gap: "12px", 229 - border: "none", 230 - background: 231 - index === selectedIndex 232 - ? "var(--bg-secondary)" 233 - : "transparent", 234 - cursor: "pointer", 235 - }} 236 - > 237 - <div 238 - className="login-suggestion-avatar" 239 - style={{ 240 - width: 32, 241 - height: 32, 242 - borderRadius: "50%", 243 - overflow: "hidden", 244 - background: "var(--bg-tertiary)", 245 - }} 246 - > 247 - {actor.avatar ? ( 248 - <img 249 - src={actor.avatar} 250 - alt="" 251 - style={{ 252 - width: "100%", 253 - height: "100%", 254 - objectFit: "cover", 255 - }} 256 - /> 257 - ) : ( 258 - <div 259 - style={{ 260 - display: "flex", 261 - alignItems: "center", 262 - justifyContent: "center", 263 - height: "100%", 264 - fontSize: "0.8rem", 265 - }} 266 - > 267 - {(actor.displayName || actor.handle) 268 - .substring(0, 2) 269 - .toUpperCase()} 270 - </div> 271 - )} 272 - </div> 273 - <div 274 - className="login-suggestion-info" 275 - style={{ display: "flex", flexDirection: "column" }} 276 - > 277 - <span 278 - className="login-suggestion-name" 279 - style={{ fontWeight: 600, fontSize: "0.95rem" }} 280 - > 281 - {actor.displayName || actor.handle} 282 - </span> 283 - <span 284 - className="login-suggestion-handle" 285 - style={{ 286 - color: "var(--text-secondary)", 287 - fontSize: "0.85rem", 288 - }} 289 - > 290 - @{actor.handle} 291 - </span> 292 - </div> 293 - </button> 294 - ))} 295 - </div> 296 - )} 297 - </form> 298 - 299 - {error && ( 300 - <div className="empty-state"> 301 - <div className="empty-state-icon"> 302 - <AlertIcon size={32} /> 303 - </div> 304 - <h3 className="empty-state-title">Error</h3> 305 - <p className="empty-state-text">{error}</p> 306 - </div> 307 - )} 308 - 309 - {searched && !loading && !error && totalItems === 0 && ( 310 - <div className="empty-state"> 311 - <div className="empty-state-icon"> 312 - <SearchIcon size={32} /> 313 - </div> 314 - <h3 className="empty-state-title">No annotations found</h3> 315 - <p className="empty-state-text"> 316 - Be the first to annotate this URL! Sign in to add your thoughts. 317 - </p> 318 - </div> 319 - )} 320 - 321 - {searched && totalItems > 0 && ( 322 - <> 323 - <div className="url-results-header"> 324 - <h2 className="feed-title"> 325 - {totalItems} item{totalItems !== 1 ? "s" : ""} 326 - </h2> 327 - <div className="feed-filters"> 328 - <button 329 - className={`filter-tab ${activeTab === "all" ? "active" : ""}`} 330 - onClick={() => setActiveTab("all")} 331 - > 332 - All ({totalItems}) 333 - </button> 334 - <button 335 - className={`filter-tab ${activeTab === "annotations" ? "active" : ""}`} 336 - onClick={() => setActiveTab("annotations")} 337 - > 338 - Annotations ({annotations.length}) 339 - </button> 340 - <button 341 - className={`filter-tab ${activeTab === "highlights" ? "active" : ""}`} 342 - onClick={() => setActiveTab("highlights")} 343 - > 344 - Highlights ({highlights.length}) 345 - </button> 346 - </div> 347 - </div> 348 - 349 - {user && myItemsCount > 0 && ( 350 - <div className="share-notes-banner"> 351 - <div className="share-notes-info"> 352 - <ExternalLink size={16} /> 353 - <span> 354 - You have {myItemsCount} note{myItemsCount !== 1 ? "s" : ""} on 355 - this page 356 - </span> 357 - </div> 358 - <div className="share-notes-actions"> 359 - <Link 360 - to={`/${user.handle}/url/${encodeURIComponent(url)}`} 361 - className="btn btn-ghost btn-sm" 362 - > 363 - View 364 - </Link> 365 - <button 366 - onClick={handleCopyShareLink} 367 - className="btn btn-primary btn-sm" 368 - > 369 - {copied ? ( 370 - <> 371 - <Check size={14} /> Copied! 372 - </> 373 - ) : ( 374 - <> 375 - <Copy size={14} /> Copy Share Link 376 - </> 377 - )} 378 - </button> 379 - </div> 380 - </div> 381 - )} 382 - 383 - <div className="feed-container"> 384 - <div className="feed">{renderResults()}</div> 385 - </div> 386 - </> 387 - )} 388 - </div> 389 - ); 390 - }
-239
web/src/pages/UserUrl.jsx
··· 1 - import { useState, useEffect } from "react"; 2 - import { useParams } from "react-router-dom"; 3 - import AnnotationCard, { HighlightCard } from "../components/AnnotationCard"; 4 - import { getUserTargetItems } from "../api/client"; 5 - import { 6 - PenIcon, 7 - HighlightIcon, 8 - SearchIcon, 9 - BlueskyIcon, 10 - } from "../components/Icons"; 11 - 12 - export default function UserUrl() { 13 - const { handle, "*": urlPath } = useParams(); 14 - const targetUrl = urlPath || ""; 15 - 16 - const [profile, setProfile] = useState(null); 17 - const [annotations, setAnnotations] = useState([]); 18 - const [highlights, setHighlights] = useState([]); 19 - const [loading, setLoading] = useState(true); 20 - const [error, setError] = useState(null); 21 - const [activeTab, setActiveTab] = useState("all"); 22 - 23 - useEffect(() => { 24 - async function fetchData() { 25 - if (!targetUrl) { 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 - let did = handle; 38 - if (profileRes.ok) { 39 - const profileData = await profileRes.json(); 40 - setProfile(profileData); 41 - did = profileData.did; 42 - } 43 - 44 - const data = await getUserTargetItems(did, targetUrl); 45 - setAnnotations(data.annotations || []); 46 - setHighlights(data.highlights || []); 47 - } catch (err) { 48 - setError(err.message); 49 - } finally { 50 - setLoading(false); 51 - } 52 - } 53 - fetchData(); 54 - }, [handle, targetUrl]); 55 - 56 - const displayName = profile?.displayName || profile?.handle || handle; 57 - const displayHandle = 58 - profile?.handle || (handle?.startsWith("did:") ? null : handle); 59 - const avatarUrl = profile?.avatar; 60 - 61 - const getInitial = () => { 62 - return (displayName || displayHandle || "??") 63 - ?.substring(0, 2) 64 - .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="empty-state"> 76 - <div className="empty-state-icon"> 77 - <PenIcon size={32} /> 78 - </div> 79 - <h3 className="empty-state-title">No annotations</h3> 80 - </div> 81 - ); 82 - } 83 - 84 - if (activeTab === "highlights" && highlights.length === 0) { 85 - return ( 86 - <div className="empty-state"> 87 - <div className="empty-state-icon"> 88 - <HighlightIcon size={32} /> 89 - </div> 90 - <h3 className="empty-state-title">No highlights</h3> 91 - </div> 92 - ); 93 - } 94 - 95 - return ( 96 - <> 97 - {(activeTab === "all" || activeTab === "annotations") && 98 - annotations.map((a) => <AnnotationCard key={a.uri} annotation={a} />)} 99 - {(activeTab === "all" || activeTab === "highlights") && 100 - highlights.map((h) => <HighlightCard key={h.uri} highlight={h} />)} 101 - </> 102 - ); 103 - }; 104 - 105 - if (!targetUrl) { 106 - return ( 107 - <div className="user-url-page"> 108 - <div className="empty-state"> 109 - <div className="empty-state-icon"> 110 - <SearchIcon size={32} /> 111 - </div> 112 - <h3 className="empty-state-title">No URL specified</h3> 113 - <p className="empty-state-text"> 114 - Please provide a URL to view annotations. 115 - </p> 116 - </div> 117 - </div> 118 - ); 119 - } 120 - 121 - return ( 122 - <div className="user-url-page"> 123 - <header className="profile-header"> 124 - <a 125 - href={bskyProfileUrl} 126 - target="_blank" 127 - rel="noopener noreferrer" 128 - className="profile-avatar-link" 129 - > 130 - <div className="profile-avatar"> 131 - {avatarUrl ? ( 132 - <img src={avatarUrl} alt={displayName} /> 133 - ) : ( 134 - <span>{getInitial()}</span> 135 - )} 136 - </div> 137 - </a> 138 - <div className="profile-info"> 139 - <h1 className="profile-name">{displayName}</h1> 140 - {displayHandle && ( 141 - <a 142 - href={bskyProfileUrl} 143 - target="_blank" 144 - rel="noopener noreferrer" 145 - className="profile-bluesky-link" 146 - > 147 - <BlueskyIcon size={16} />@{displayHandle} 148 - </a> 149 - )} 150 - </div> 151 - </header> 152 - 153 - <div className="url-target-info"> 154 - <span className="url-target-label">Annotations on:</span> 155 - <a 156 - href={targetUrl} 157 - target="_blank" 158 - rel="noopener noreferrer" 159 - className="url-target-link" 160 - > 161 - {targetUrl} 162 - </a> 163 - </div> 164 - 165 - {loading && ( 166 - <div className="feed-container"> 167 - <div className="feed"> 168 - {[1, 2, 3].map((i) => ( 169 - <div key={i} className="card"> 170 - <div 171 - className="skeleton skeleton-text" 172 - style={{ width: "40%" }} 173 - /> 174 - <div className="skeleton skeleton-text" /> 175 - <div 176 - className="skeleton skeleton-text" 177 - style={{ width: "60%" }} 178 - /> 179 - </div> 180 - ))} 181 - </div> 182 - </div> 183 - )} 184 - 185 - {error && ( 186 - <div className="empty-state"> 187 - <div className="empty-state-icon">⚠️</div> 188 - <h3 className="empty-state-title">Error</h3> 189 - <p className="empty-state-text">{error}</p> 190 - </div> 191 - )} 192 - 193 - {!loading && !error && totalItems === 0 && ( 194 - <div className="empty-state"> 195 - <div className="empty-state-icon"> 196 - <SearchIcon size={32} /> 197 - </div> 198 - <h3 className="empty-state-title">No items found</h3> 199 - <p className="empty-state-text"> 200 - {displayName} hasn&apos;t annotated this page yet. 201 - </p> 202 - </div> 203 - )} 204 - 205 - {!loading && !error && totalItems > 0 && ( 206 - <> 207 - <div className="url-results-header"> 208 - <h2 className="feed-title"> 209 - {totalItems} item{totalItems !== 1 ? "s" : ""} 210 - </h2> 211 - <div className="feed-filters"> 212 - <button 213 - className={`filter-tab ${activeTab === "all" ? "active" : ""}`} 214 - onClick={() => setActiveTab("all")} 215 - > 216 - All ({totalItems}) 217 - </button> 218 - <button 219 - className={`filter-tab ${activeTab === "annotations" ? "active" : ""}`} 220 - onClick={() => setActiveTab("annotations")} 221 - > 222 - Annotations ({annotations.length}) 223 - </button> 224 - <button 225 - className={`filter-tab ${activeTab === "highlights" ? "active" : ""}`} 226 - onClick={() => setActiveTab("highlights")} 227 - > 228 - Highlights ({highlights.length}) 229 - </button> 230 - </div> 231 - </div> 232 - <div className="feed-container"> 233 - <div className="feed">{renderResults()}</div> 234 - </div> 235 - </> 236 - )} 237 - </div> 238 - ); 239 - }
-23
web/src/utils/formatting.js
··· 1 - export function getLinkIconType(url) { 2 - if (!url) return "link"; 3 - try { 4 - const hostname = new URL(url).hostname; 5 - if (hostname.includes("github.com")) return "github"; 6 - if (hostname.includes("bsky.app")) return "bluesky"; 7 - if (hostname.includes("linkedin.com")) return "linkedin"; 8 - if (hostname.includes("tangled.org")) return "tangled"; 9 - if (hostname.includes("youtube.com")) return "youtube"; 10 - } catch { 11 - /* ignore */ 12 - } 13 - return "link"; 14 - } 15 - 16 - export function formatUrl(url) { 17 - try { 18 - return new URL(url).hostname; 19 - } catch { 20 - /* ignore */ 21 - return url; 22 - } 23 - }
-23
web/vite.config.js
··· 1 - import { defineConfig } from "vite"; 2 - import react from "@vitejs/plugin-react"; 3 - 4 - export default defineConfig({ 5 - plugins: [react()], 6 - build: { 7 - outDir: "dist", 8 - emptyOutDir: true, 9 - }, 10 - server: { 11 - port: 3000, 12 - proxy: { 13 - "/api": { 14 - target: "http://localhost:8080", 15 - changeOrigin: true, 16 - }, 17 - "/auth": { 18 - target: "http://localhost:8080", 19 - changeOrigin: true, 20 - }, 21 - }, 22 - }, 23 - });