Margin is an open annotation layer for the internet. Powered by the AT Protocol. margin.at
extension web atproto comments

Implement three column layout and settings page with layout options and API keys

+1449 -283
+34 -43
web/src/App.jsx
··· 1 1 import { Routes, Route } from "react-router-dom"; 2 2 import { useEffect } from "react"; 3 3 import { AuthProvider, useAuth } from "./context/AuthContext"; 4 - import TopNav from "./components/TopNav"; 5 - import MobileNav from "./components/MobileNav"; 4 + import AppLayout from "./components/AppLayout"; 6 5 import Feed from "./pages/Feed"; 7 6 import Url from "./pages/Url"; 8 7 import UserUrl from "./pages/UserUrl"; ··· 17 16 import CollectionDetail from "./pages/CollectionDetail"; 18 17 import Privacy from "./pages/Privacy"; 19 18 import Terms from "./pages/Terms"; 19 + import Settings from "./pages/Settings"; 20 20 import Landing from "./pages/Landing"; 21 21 import ScrollToTop from "./components/ScrollToTop"; 22 22 import { ThemeProvider } from "./context/ThemeContext"; ··· 31 31 }, [user]); 32 32 33 33 return ( 34 - <div className="app"> 34 + <AppLayout> 35 35 <ScrollToTop /> 36 - <TopNav /> 37 - <main className="main-content"> 38 - <Routes> 39 - <Route path="/home" element={<Feed />} /> 40 - <Route path="/url" element={<Url />} /> 41 - <Route path="/new" element={<New />} /> 42 - <Route path="/bookmarks" element={<Bookmarks />} /> 43 - <Route path="/highlights" element={<Highlights />} /> 44 - <Route path="/notifications" element={<Notifications />} /> 45 - <Route path="/profile" element={<Profile />} /> 46 - <Route path="/profile/:handle" element={<Profile />} /> 47 - <Route path="/login" element={<Login />} /> 48 - <Route path="/at/:did/:rkey" element={<AnnotationDetail />} /> 49 - <Route path="/annotation/:uri" element={<AnnotationDetail />} /> 50 - <Route path="/collections" element={<Collections />} /> 51 - <Route path="/collections/:rkey" element={<CollectionDetail />} /> 52 - <Route 53 - path="/:handle/collection/:rkey" 54 - element={<CollectionDetail />} 55 - /> 56 - <Route 57 - path="/:handle/annotation/:rkey" 58 - element={<AnnotationDetail />} 59 - /> 60 - <Route 61 - path="/:handle/highlight/:rkey" 62 - element={<AnnotationDetail />} 63 - /> 64 - <Route 65 - path="/:handle/bookmark/:rkey" 66 - element={<AnnotationDetail />} 67 - /> 68 - <Route path="/:handle/url/*" element={<UserUrl />} /> 69 - <Route path="/collection/*" element={<CollectionDetail />} /> 70 - <Route path="/privacy" element={<Privacy />} /> 71 - <Route path="/terms" element={<Terms />} /> 72 - </Routes> 73 - </main> 74 - <MobileNav /> 75 - </div> 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> 76 67 ); 77 68 } 78 69
+1 -1
web/src/assets/logo.svg
··· 1 - <svg width="265" height="231" viewBox="0 0 265 231" fill="#6366f1" xmlns="http://www.w3.org/2000/svg"> 1 + <svg width="265" height="231" viewBox="0 0 265 231" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> 2 2 <path d="M0 230 V0 H199 V65.7156 H149.5 V115.216 H182.5 L199 131.716 V230 Z"/> 3 3 <path d="M215 214.224 V230 H264.5 V0 H215.07 V16.2242 H248.5 V214.224 H215 Z"/> 4 4 </svg>
+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/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 logo from "../assets/logo.svg"; 18 + import { getUnreadNotificationCount } from "../api/client"; 19 + 20 + export default function LeftSidebar() { 21 + const { user, isAuthenticated, logout } = useAuth(); 22 + const location = useLocation(); 23 + const [unreadCount, setUnreadCount] = useState(0); 24 + const [userMenuOpen, setUserMenuOpen] = useState(false); 25 + const userMenuRef = useRef(null); 26 + 27 + const isActive = (path) => { 28 + if (path === "/") return location.pathname === "/"; 29 + return location.pathname.startsWith(path); 30 + }; 31 + 32 + useEffect(() => { 33 + if (isAuthenticated) { 34 + getUnreadNotificationCount() 35 + .then((data) => setUnreadCount(data.count || 0)) 36 + .catch(() => {}); 37 + const interval = setInterval(() => { 38 + getUnreadNotificationCount() 39 + .then((data) => setUnreadCount(data.count || 0)) 40 + .catch(() => {}); 41 + }, 60000); 42 + return () => clearInterval(interval); 43 + } 44 + }, [isAuthenticated]); 45 + 46 + useEffect(() => { 47 + const handleClickOutside = (e) => { 48 + if (userMenuRef.current && !userMenuRef.current.contains(e.target)) { 49 + setUserMenuOpen(false); 50 + } 51 + }; 52 + document.addEventListener("mousedown", handleClickOutside); 53 + return () => document.removeEventListener("mousedown", handleClickOutside); 54 + }, []); 55 + 56 + const handleLogout = () => { 57 + logout(); 58 + setUserMenuOpen(false); 59 + }; 60 + 61 + const navItems = [ 62 + { path: "/home", icon: Home, label: "Home" }, 63 + { path: "/url", icon: Search, label: "Browse" }, 64 + ]; 65 + 66 + const authNavItems = [ 67 + { path: "/highlights", icon: Highlighter, label: "Highlights" }, 68 + { path: "/bookmarks", icon: Bookmark, label: "Bookmarks" }, 69 + { path: "/collections", icon: Folder, label: "Collections" }, 70 + { 71 + path: "/notifications", 72 + icon: Bell, 73 + label: "Notifications", 74 + badge: unreadCount, 75 + }, 76 + ]; 77 + 78 + return ( 79 + <aside className="left-sidebar"> 80 + <div className="sidebar-header"> 81 + <Link to="/home" className="sidebar-logo"> 82 + <svg 83 + width="32" 84 + height="32" 85 + viewBox="0 0 265 231" 86 + fill="currentColor" 87 + xmlns="http://www.w3.org/2000/svg" 88 + > 89 + <path d="M0 230 V0 H199 V65.7156 H149.5 V115.216 H182.5 L199 131.716 V230 Z" /> 90 + <path d="M215 214.224 V230 H264.5 V0 H215.07 V16.2242 H248.5 V214.224 H215 Z" /> 91 + </svg> 92 + </Link> 93 + </div> 94 + 95 + <nav className="sidebar-nav"> 96 + {navItems.map(({ path, icon: Icon, label }) => ( 97 + <Link 98 + key={path} 99 + to={path} 100 + className={`sidebar-nav-item ${isActive(path) ? "active" : ""}`} 101 + > 102 + <Icon size={20} strokeWidth={1.75} /> 103 + <span>{label}</span> 104 + </Link> 105 + ))} 106 + 107 + {isAuthenticated && 108 + authNavItems.map(({ path, icon: Icon, label, badge }) => ( 109 + <Link 110 + key={path} 111 + to={path} 112 + className={`sidebar-nav-item ${isActive(path) ? "active" : ""}`} 113 + > 114 + <Icon size={20} strokeWidth={1.75} /> 115 + <span>{label}</span> 116 + {badge > 0 && <span className="sidebar-badge">{badge}</span>} 117 + </Link> 118 + ))} 119 + </nav> 120 + 121 + {isAuthenticated && ( 122 + <Link to="/new" className="sidebar-new-btn"> 123 + <PenSquare size={18} strokeWidth={2} /> 124 + <span>New Annotation</span> 125 + </Link> 126 + )} 127 + 128 + <div className="sidebar-footer" ref={userMenuRef}> 129 + {isAuthenticated ? ( 130 + <> 131 + <button 132 + className={`sidebar-user-btn ${userMenuOpen ? "active" : ""}`} 133 + onClick={() => setUserMenuOpen(!userMenuOpen)} 134 + > 135 + {user?.avatar ? ( 136 + <img src={user.avatar} alt="" className="sidebar-user-avatar" /> 137 + ) : ( 138 + <div className="sidebar-user-avatar-placeholder"> 139 + <User size={16} /> 140 + </div> 141 + )} 142 + <div className="sidebar-user-info"> 143 + <span className="sidebar-user-name"> 144 + {user?.displayName || user?.handle} 145 + </span> 146 + <span className="sidebar-user-handle">@{user?.handle}</span> 147 + </div> 148 + <ChevronUp 149 + size={16} 150 + className={`sidebar-user-chevron ${userMenuOpen ? "open" : ""}`} 151 + /> 152 + </button> 153 + 154 + {userMenuOpen && ( 155 + <div className="sidebar-user-menu"> 156 + <Link 157 + to={`/profile/${user?.did}`} 158 + className="sidebar-user-menu-item" 159 + onClick={() => setUserMenuOpen(false)} 160 + > 161 + <User size={16} /> 162 + <span>View Profile</span> 163 + </Link> 164 + <Link 165 + to="/settings" 166 + className="sidebar-user-menu-item" 167 + onClick={() => setUserMenuOpen(false)} 168 + > 169 + <Settings size={16} /> 170 + <span>Settings</span> 171 + </Link> 172 + <button 173 + className="sidebar-user-menu-item danger" 174 + onClick={handleLogout} 175 + > 176 + <LogOut size={16} /> 177 + <span>Log Out</span> 178 + </button> 179 + </div> 180 + )} 181 + </> 182 + ) : ( 183 + <Link to="/login" className="sidebar-signin-btn"> 184 + Sign In 185 + </Link> 186 + )} 187 + </div> 188 + </aside> 189 + ); 190 + }
+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 + }
+19 -1
web/src/components/TopNav.jsx
··· 9 9 Bell, 10 10 PenSquare, 11 11 User, 12 + Settings, 12 13 LogOut, 13 14 ChevronDown, 14 15 Highlighter, ··· 124 125 <header className="top-nav"> 125 126 <div className="top-nav-inner"> 126 127 <Link to="/home" className="top-nav-logo"> 127 - <img src={logo} alt="Margin" /> 128 + <svg 129 + width="26" 130 + height="26" 131 + viewBox="0 0 265 231" 132 + fill="currentColor" 133 + xmlns="http://www.w3.org/2000/svg" 134 + > 135 + <path d="M0 230 V0 H199 V65.7156 H149.5 V115.216 H182.5 L199 131.716 V230 Z" /> 136 + <path d="M215 214.224 V230 H264.5 V0 H215.07 V16.2242 H248.5 V214.224 H215 Z" /> 137 + </svg> 128 138 <span>Margin</span> 129 139 </Link> 130 140 ··· 305 315 > 306 316 <User size={16} /> 307 317 View Profile 318 + </Link> 319 + <Link 320 + to="/settings" 321 + className="dropdown-item" 322 + onClick={() => setUserMenuOpen(false)} 323 + > 324 + <Settings size={16} /> 325 + Settings 308 326 </Link> 309 327 <button 310 328 onClick={() => {
+13
web/src/context/ThemeContext.jsx
··· 3 3 const ThemeContext = createContext({ 4 4 theme: "system", 5 5 setTheme: () => null, 6 + layout: "sidebar", 7 + setLayout: () => null, 6 8 }); 7 9 8 10 export function ThemeProvider({ children }) { 9 11 const [theme, setTheme] = useState(() => { 10 12 return localStorage.getItem("theme") || "system"; 13 + }); 14 + const [layout, setLayout] = useState(() => { 15 + return localStorage.getItem("layout_preference") || "sidebar"; 11 16 }); 12 17 13 18 useEffect(() => { ··· 54 59 return () => mediaQuery.removeEventListener("change", handleChange); 55 60 }, [theme]); 56 61 62 + useEffect(() => { 63 + localStorage.setItem("layout_preference", layout); 64 + }, [layout]); 65 + 57 66 const value = { 58 67 theme, 59 68 setTheme: (newTheme) => { 60 69 setTheme(newTheme); 70 + }, 71 + layout, 72 + setLayout: (newLayout) => { 73 + setLayout(newLayout); 61 74 }, 62 75 }; 63 76
+11 -1
web/src/css/feed.css
··· 412 412 cursor: pointer; 413 413 box-shadow: var(--shadow-md); 414 414 transition: all 0.2s ease; 415 - z-index: 100; 415 + z-index: 9999; 416 416 opacity: 0; 417 417 visibility: hidden; 418 418 transform: translateY(10px); 419 + } 420 + 421 + .back-to-top-btn.has-sidebar { 422 + right: 24px; 423 + } 424 + 425 + @media (min-width: 1025px) { 426 + .back-to-top-btn.has-sidebar { 427 + right: 320px; 428 + } 419 429 } 420 430 421 431 .back-to-top-btn.visible {
+86 -3
web/src/css/layout.css
··· 3 3 background: var(--bg-primary); 4 4 } 5 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: 240px 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 + 6 28 .top-nav { 29 + display: none; 7 30 position: sticky; 8 31 top: 0; 9 32 z-index: 100; ··· 13 36 border-bottom: 1px solid var(--border); 14 37 } 15 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: 1600px; 48 + margin: 0 auto; 49 + height: auto; 50 + overflow: visible; 51 + } 52 + 53 + .layout-mode-topnav .top-nav { 54 + display: block; 55 + } 56 + 57 + .layout-mode-topnav .main-content { 58 + height: auto; 59 + overflow: visible; 60 + } 61 + 62 + .layout-mode-topnav .main-content > * { 63 + max-width: 1200px; 64 + margin: 0 auto; 65 + } 66 + 16 67 .top-nav-inner { 17 68 max-width: 1200px; 18 69 margin: 0 auto; ··· 34 85 flex-shrink: 0; 35 86 } 36 87 37 - .top-nav-logo img { 88 + .top-nav-logo svg { 38 89 width: 26px; 39 90 height: 26px; 91 + transition: color 0.2s; 92 + color: var(--accent); 93 + } 94 + 95 + .top-nav-logo:hover svg { 96 + color: var(--accent); 40 97 } 41 98 42 99 .top-nav-links { ··· 275 332 } 276 333 277 334 .main-content { 278 - max-width: 1300px; 335 + width: 100%; 336 + padding: 0; 337 + overflow-y: auto; 338 + height: 100%; 339 + scrollbar-width: thin; 340 + scrollbar-color: var(--bg-hover) transparent; 341 + } 342 + 343 + .main-content > * { 344 + max-width: 800px; 279 345 margin: 0 auto; 280 - padding: 32px 56px 80px; 346 + padding: 32px 32px 80px; 347 + } 348 + 349 + .main-content::-webkit-scrollbar { 350 + width: 8px; 351 + } 352 + 353 + .main-content::-webkit-scrollbar-track { 354 + background: var(--bg-secondary); 355 + } 356 + 357 + .main-content::-webkit-scrollbar-thumb { 358 + background: var(--bg-hover); 359 + border-radius: var(--radius-full); 360 + } 361 + 362 + .main-content::-webkit-scrollbar-thumb:hover { 363 + background: var(--text-tertiary); 281 364 } 282 365 283 366 .mobile-menu {
+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 + }
+1
web/src/index.css
··· 1 1 @import "./css/layout.css"; 2 + @import "./css/sidebar.css"; 2 3 @import "./css/base.css"; 3 4 @import "./css/buttons.css"; 4 5 @import "./css/cards.css";
+25 -13
web/src/pages/Feed.jsx
··· 8 8 import { getAnnotationFeed, deleteHighlight } from "../api/client"; 9 9 import { AlertIcon, InboxIcon } from "../components/Icons"; 10 10 import { useAuth } from "../context/AuthContext"; 11 + import { useTheme } from "../context/ThemeContext"; 11 12 import { X, ArrowUp } from "lucide-react"; 12 13 13 14 import AddToCollectionModal from "../components/AddToCollectionModal"; ··· 382 383 383 384 function BackToTopButton() { 384 385 const [isVisible, setIsVisible] = useState(false); 386 + const { layout } = useTheme(); 385 387 386 388 useEffect(() => { 389 + let scrollContainer = window; 390 + if (layout !== "topnav") { 391 + const mainContent = document.querySelector(".main-content"); 392 + if (mainContent) scrollContainer = mainContent; 393 + } 394 + 387 395 const toggleVisibility = () => { 388 - if (window.scrollY > 300) { 389 - setIsVisible(true); 390 - } else { 391 - setIsVisible(false); 392 - } 396 + const scrolled = 397 + scrollContainer instanceof Window 398 + ? scrollContainer.scrollY 399 + : scrollContainer.scrollTop; 400 + 401 + setIsVisible(scrolled > 300); 393 402 }; 394 403 395 - window.addEventListener("scroll", toggleVisibility); 396 - return () => window.removeEventListener("scroll", toggleVisibility); 397 - }, []); 404 + scrollContainer.addEventListener("scroll", toggleVisibility); 405 + return () => 406 + scrollContainer.removeEventListener("scroll", toggleVisibility); 407 + }, [layout]); 398 408 399 409 const scrollToTop = () => { 400 - window.scrollTo({ 401 - top: 0, 402 - behavior: "smooth", 403 - }); 410 + if (layout === "topnav") { 411 + window.scrollTo({ top: 0, behavior: "smooth" }); 412 + } else { 413 + const mainContent = document.querySelector(".main-content"); 414 + if (mainContent) mainContent.scrollTo({ top: 0, behavior: "smooth" }); 415 + } 404 416 }; 405 417 406 418 return ( 407 419 <button 408 - className={`back-to-top-btn ${isVisible ? "visible" : ""}`} 420 + className={`back-to-top-btn ${isVisible ? "visible" : ""} ${layout !== "topnav" ? "has-sidebar" : ""}`} 409 421 onClick={scrollToTop} 410 422 aria-label="Back to top" 411 423 >
+39 -5
web/src/pages/Landing.jsx
··· 318 318 <div className="demo-sidebar"> 319 319 <div className="demo-sidebar-header"> 320 320 <div className="demo-logo-section"> 321 - <span className="demo-logo-icon"> 322 - <img src={logo} alt="" style={{ width: 16, height: 16 }} /> 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> 323 335 </span> 324 336 <span className="demo-logo-text">Margin</span> 325 337 </div> ··· 406 418 return ( 407 419 <div className="landing-page"> 408 420 <nav className="landing-nav"> 409 - <Link to="/" className="landing-logo"> 410 - <img src={logo} alt="Margin" /> 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> 411 436 <span>Margin</span> 412 437 </Link> 413 438 <div className="landing-nav-links"> ··· 703 728 <div className="landing-footer-grid"> 704 729 <div className="landing-footer-brand"> 705 730 <Link to="/" className="landing-logo"> 706 - <img src={logo} alt="Margin" /> 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> 707 741 <span>Margin</span> 708 742 </Link> 709 743 <p>Write in the margins of the web.</p>
+10 -1
web/src/pages/Login.jsx
··· 174 174 return ( 175 175 <div className="login-page"> 176 176 <div className="login-header-group"> 177 - <img src={logo} alt="Margin Logo" className="login-logo-img" /> 177 + <svg 178 + viewBox="0 0 265 231" 179 + fill="currentColor" 180 + xmlns="http://www.w3.org/2000/svg" 181 + className="login-logo-img" 182 + style={{ color: "var(--accent)" }} 183 + > 184 + <path d="M0 230 V0 H199 V65.7156 H149.5 V115.216 H182.5 L199 131.716 V230 Z" /> 185 + <path d="M215 214.224 V230 H264.5 V0 H215.07 V16.2242 H248.5 V214.224 H215 Z" /> 186 + </svg> 178 187 <span className="login-x">X</span> 179 188 <div className="login-atproto-icon"> 180 189 <AtSign size={64} strokeWidth={2.4} />
+1 -215
web/src/pages/Profile.jsx
··· 9 9 getUserBookmarks, 10 10 getCollections, 11 11 getProfile, 12 - getAPIKeys, 13 - createAPIKey, 14 - deleteAPIKey, 15 12 } from "../api/client"; 16 13 import { useAuth } from "../context/AuthContext"; 17 14 import EditProfileModal from "../components/EditProfileModal"; ··· 70 67 const [highlights, setHighlights] = useState([]); 71 68 const [bookmarks, setBookmarks] = useState([]); 72 69 const [collections, setCollections] = useState([]); 73 - const [apiKeys, setApiKeys] = useState([]); 74 - const [newKeyName, setNewKeyName] = useState(""); 75 - const [newKey, setNewKey] = useState(null); 76 - const [keysLoading, setKeysLoading] = useState(false); 70 + 77 71 const [loading, setLoading] = useState(true); 78 72 const [error, setError] = useState(null); 79 73 const [showEditModal, setShowEditModal] = useState(false); ··· 146 140 fetchProfile(); 147 141 }, [handle]); 148 142 149 - useEffect(() => { 150 - if (isOwnProfile && activeTab === "apikeys") { 151 - loadAPIKeys(); 152 - } 153 - }, [isOwnProfile, activeTab]); 154 - 155 - const loadAPIKeys = async () => { 156 - setKeysLoading(true); 157 - try { 158 - const data = await getAPIKeys(); 159 - setApiKeys(data.keys || []); 160 - } catch { 161 - setApiKeys([]); 162 - } finally { 163 - setKeysLoading(false); 164 - } 165 - }; 166 - 167 - const handleCreateKey = async () => { 168 - if (!newKeyName.trim()) return; 169 - try { 170 - const data = await createAPIKey(newKeyName.trim()); 171 - setNewKey(data.key); 172 - setNewKeyName(""); 173 - loadAPIKeys(); 174 - } catch (err) { 175 - alert("Failed to create key: " + err.message); 176 - } 177 - }; 178 - 179 - const handleDeleteKey = async (id) => { 180 - if (!confirm("Delete this API key? This cannot be undone.")) return; 181 - try { 182 - await deleteAPIKey(id); 183 - loadAPIKeys(); 184 - } catch (err) { 185 - alert("Failed to delete key: " + err.message); 186 - } 187 - }; 188 - 189 143 if (authLoading) { 190 144 return ( 191 145 <div className="profile-page"> ··· 309 263 </div> 310 264 ); 311 265 } 312 - 313 - if (activeTab === "apikeys" && isOwnProfile) { 314 - return ( 315 - <div className="api-keys-section"> 316 - <div className="card" style={{ marginBottom: "1rem" }}> 317 - <h3 style={{ marginBottom: "0.5rem" }}>Create API Key</h3> 318 - <p 319 - style={{ 320 - color: "var(--text-muted)", 321 - marginBottom: "1rem", 322 - fontSize: "0.875rem", 323 - }} 324 - > 325 - Use API keys to create bookmarks from iOS Shortcuts or other 326 - tools. 327 - </p> 328 - <div style={{ display: "flex", gap: "0.5rem" }}> 329 - <input 330 - type="text" 331 - value={newKeyName} 332 - onChange={(e) => setNewKeyName(e.target.value)} 333 - placeholder="Key name (e.g., iOS Shortcut)" 334 - className="input" 335 - style={{ flex: 1 }} 336 - /> 337 - <button className="btn btn-primary" onClick={handleCreateKey}> 338 - Generate 339 - </button> 340 - </div> 341 - {newKey && ( 342 - <div 343 - style={{ 344 - marginTop: "1rem", 345 - padding: "1rem", 346 - background: "var(--bg-secondary)", 347 - borderRadius: "8px", 348 - }} 349 - > 350 - <p 351 - style={{ 352 - color: "var(--text-success)", 353 - fontWeight: 500, 354 - marginBottom: "0.5rem", 355 - }} 356 - > 357 - ✓ Key created! Copy it now, you won&apos;t see it again. 358 - </p> 359 - <code 360 - style={{ 361 - display: "block", 362 - padding: "0.75rem", 363 - background: "var(--bg-tertiary)", 364 - borderRadius: "4px", 365 - wordBreak: "break-all", 366 - fontSize: "0.8rem", 367 - }} 368 - > 369 - {newKey} 370 - </code> 371 - <button 372 - className="btn btn-secondary" 373 - style={{ marginTop: "0.5rem" }} 374 - onClick={() => { 375 - navigator.clipboard.writeText(newKey); 376 - alert("Copied!"); 377 - }} 378 - > 379 - Copy to clipboard 380 - </button> 381 - </div> 382 - )} 383 - </div> 384 - 385 - {keysLoading ? ( 386 - <div className="card"> 387 - <div className="skeleton skeleton-text" /> 388 - </div> 389 - ) : apiKeys.length === 0 ? ( 390 - <div className="empty-state"> 391 - <div className="empty-state-icon"> 392 - <KeyIcon size={32} /> 393 - </div> 394 - <h3 className="empty-state-title">No API keys</h3> 395 - <p className="empty-state-text"> 396 - Create a key to use with iOS Shortcuts. 397 - </p> 398 - </div> 399 - ) : ( 400 - <div className="card"> 401 - <h3 style={{ marginBottom: "1rem" }}>Your API Keys</h3> 402 - {apiKeys.map((key) => ( 403 - <div 404 - key={key.id} 405 - style={{ 406 - display: "flex", 407 - justifyContent: "space-between", 408 - alignItems: "center", 409 - padding: "0.75rem 0", 410 - borderBottom: "1px solid var(--border-color)", 411 - }} 412 - > 413 - <div> 414 - <strong>{key.name}</strong> 415 - <div 416 - style={{ 417 - fontSize: "0.75rem", 418 - color: "var(--text-muted)", 419 - }} 420 - > 421 - Created {new Date(key.createdAt).toLocaleDateString()} 422 - {key.lastUsedAt && 423 - ` • Last used ${new Date(key.lastUsedAt).toLocaleDateString()}`} 424 - </div> 425 - </div> 426 - <button 427 - className="btn btn-sm" 428 - style={{ 429 - fontSize: "0.75rem", 430 - padding: "0.25rem 0.5rem", 431 - color: "#ef4444", 432 - border: "1px solid #ef4444", 433 - }} 434 - onClick={() => handleDeleteKey(key.id)} 435 - > 436 - Revoke 437 - </button> 438 - </div> 439 - ))} 440 - </div> 441 - )} 442 - 443 - <div className="card" style={{ marginTop: "1rem" }}> 444 - <h3 style={{ marginBottom: "0.5rem" }}>iOS Shortcut</h3> 445 - <p 446 - style={{ 447 - color: "var(--text-muted)", 448 - marginBottom: "1rem", 449 - fontSize: "0.875rem", 450 - }} 451 - > 452 - Save bookmarks from Safari&apos;s share sheet. 453 - </p> 454 - <a 455 - href="https://www.icloud.com/shortcuts/21c87edf29b046db892c9e57dac6d1fd" 456 - target="_blank" 457 - rel="noopener noreferrer" 458 - className="btn btn-primary" 459 - style={{ 460 - display: "inline-flex", 461 - alignItems: "center", 462 - gap: "0.5rem", 463 - }} 464 - > 465 - <AppleIcon size={16} /> Get Shortcut 466 - </a> 467 - </div> 468 - </div> 469 - ); 470 - } 471 266 }; 472 267 473 268 const bskyProfileUrl = displayHandle ··· 592 387 > 593 388 Collections ({collections.length}) 594 389 </button> 595 - 596 - {isOwnProfile && ( 597 - <button 598 - className={`profile-tab ${activeTab === "apikeys" ? "active" : ""}`} 599 - onClick={() => setActiveTab("apikeys")} 600 - > 601 - <KeyIcon size={14} /> API Keys 602 - </button> 603 - )} 604 390 </div> 605 391 606 392 {loading && (
+339
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 { Monitor, 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"> 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 + `}</style> 337 + </div> 338 + ); 339 + }