Write on the margins of 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 + }