Retro Bulletin Board Systems on atproto. Web app and TUI. lazy mirror of alyraffauf/atbbs atbbs.xyz
forums python tui atproto bbs
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

web: add icons

+137 -85
+32 -2
web/package-lock.json
··· 10 10 "dependencies": { 11 11 "@atcute/atproto": "^3.1.11", 12 12 "@atcute/client": "^4.2.1", 13 - "@atcute/lex-cli": "^2.7.0", 14 13 "@atcute/oauth-browser-client": "^3.0.0", 15 - "@tailwindcss/typography": "^0.5.19", 14 + "lucide-react": "^1.8.0", 16 15 "react": "^19.2.5", 17 16 "react-dom": "^19.2.5", 18 17 "react-markdown": "^10.1.0", 19 18 "react-router-dom": "^7.14.0" 20 19 }, 21 20 "devDependencies": { 21 + "@atcute/lex-cli": "^2.7.0", 22 + "@tailwindcss/typography": "^0.5.19", 22 23 "@tailwindcss/vite": "^4.2.2", 23 24 "@types/react": "^19.2.14", 24 25 "@types/react-dom": "^19.2.3", ··· 42 43 "version": "5.1.1", 43 44 "resolved": "https://registry.npmjs.org/@atcute/car/-/car-5.1.1.tgz", 44 45 "integrity": "sha512-MeRUJNXYgAHrJZw7mMoZJb9xIqv3LZLQw90rRRAVAo8SGNdICwyqe6Bf2LGesX73QM04MBuYO6Kqhvold3TFfg==", 46 + "dev": true, 45 47 "license": "0BSD", 46 48 "dependencies": { 47 49 "@atcute/cbor": "^2.3.2", ··· 54 56 "version": "2.3.2", 55 57 "resolved": "https://registry.npmjs.org/@atcute/cbor/-/cbor-2.3.2.tgz", 56 58 "integrity": "sha512-xP2SORSau/VVI00x2V4BjwIkHr6EQ7l/MXEOPaa4LGYtePFc4gnD4L1yN10dT5NEuUnvGEuCh6arLB7gz1smVQ==", 59 + "dev": true, 57 60 "license": "0BSD", 58 61 "dependencies": { 59 62 "@atcute/cid": "^2.4.1", ··· 65 68 "version": "2.4.1", 66 69 "resolved": "https://registry.npmjs.org/@atcute/cid/-/cid-2.4.1.tgz", 67 70 "integrity": "sha512-bwhna69RCv7yetXudtj+2qrMPYvhhIQqvJz6YUpUS98v7OdF3X2dnye9Nig2NDrklZcuyOsu7sQo7GOykJXRLQ==", 71 + "dev": true, 68 72 "license": "0BSD", 69 73 "dependencies": { 70 74 "@atcute/multibase": "^1.1.8", ··· 85 89 "version": "2.4.1", 86 90 "resolved": "https://registry.npmjs.org/@atcute/crypto/-/crypto-2.4.1.tgz", 87 91 "integrity": "sha512-tJ3Pi/XYcAsABKtqSlSOTKfO5YiQ4XdqlTuPS8HiRZSezOPcXBFFzAFWpSIJPURbVPFQL3LLrrK0Ea24wl5qeQ==", 92 + "dev": true, 88 93 "license": "0BSD", 89 94 "dependencies": { 90 95 "@atcute/multibase": "^1.2.0", ··· 120 125 "version": "2.7.0", 121 126 "resolved": "https://registry.npmjs.org/@atcute/lex-cli/-/lex-cli-2.7.0.tgz", 122 127 "integrity": "sha512-U/M2+KGYtxe5pXleDgGsmrByMg0i7QpNJQRsoP4zPp1sEtPeLK6+7I0axJA2wkRiw+426B9IuPygVcS2FHLaDQ==", 128 + "dev": true, 123 129 "license": "0BSD", 124 130 "dependencies": { 125 131 "@atcute/identity": "^1.1.4", ··· 141 147 "version": "2.2.0", 142 148 "resolved": "https://registry.npmjs.org/@atcute/lexicon-doc/-/lexicon-doc-2.2.0.tgz", 143 149 "integrity": "sha512-6l4lDlL6KPLDGknRh6HlfGbv98haUgQ0DFaAr1yA4vA95b8YYZUZ4/370ENpiq+d6Lv0tdDAMvOon2mynrp3pQ==", 150 + "dev": true, 144 151 "license": "0BSD", 145 152 "dependencies": { 146 153 "@atcute/identity": "^1.1.4", ··· 154 161 "version": "0.1.6", 155 162 "resolved": "https://registry.npmjs.org/@atcute/lexicon-resolver/-/lexicon-resolver-0.1.6.tgz", 156 163 "integrity": "sha512-wJC/ChmpP7k+ywpOd07CMvioXjIGaFpF3bDwXLi/086LYjSWHOvtW6pyC+mqP5wLhjyH2hn4wmi77Buew1l1aw==", 164 + "dev": true, 157 165 "license": "0BSD", 158 166 "dependencies": { 159 167 "@atcute/crypto": "^2.3.0", ··· 184 192 "version": "1.0.0", 185 193 "resolved": "https://registry.npmjs.org/@atcute/mst/-/mst-1.0.0.tgz", 186 194 "integrity": "sha512-pMce2efib+dmKtnGnIvJZitVncJkpr3AmhyfgfYllni8KzsaDGsJmuGavSVpuojAhQe+6jYwHFtpm/beiiH4uw==", 195 + "dev": true, 187 196 "license": "0BSD", 188 197 "dependencies": { 189 198 "@atcute/cbor": "^2.3.2", ··· 252 261 "version": "0.1.4", 253 262 "resolved": "https://registry.npmjs.org/@atcute/repo/-/repo-0.1.4.tgz", 254 263 "integrity": "sha512-uzbGJkE+1A8UFviosJrtw7HW87u8nCCH1V3yOQ79FPrRhS67EvEHF6GTg4aMkP21ze/pRtttJ1k9pFfDmyTlTg==", 264 + "dev": true, 255 265 "license": "0BSD", 256 266 "dependencies": { 257 267 "@atcute/car": "^5.1.1", ··· 291 301 "version": "2.0.0", 292 302 "resolved": "https://registry.npmjs.org/@atcute/varint/-/varint-2.0.0.tgz", 293 303 "integrity": "sha512-CEY/oVK/nVpL4e5y3sdenLETDL6/Xu5xsE/0TupK+f0Yv8jcD60t2gD8SHROWSvUwYLdkjczLCSA7YrtnjCzWw==", 304 + "dev": true, 294 305 "license": "0BSD" 295 306 }, 296 307 "node_modules/@babel/code-frame": { ··· 691 702 "version": "3.0.0", 692 703 "resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-3.0.0.tgz", 693 704 "integrity": "sha512-NJBaR352KyIvj3t6sgT/+7xrNyF9Xk9QlLSIqUGVUYlsnDTAUqY8LOmwpcgEx4AMJXRITQ5XEVHD+mMaPfr3mg==", 705 + "dev": true, 694 706 "license": "MIT", 695 707 "funding": { 696 708 "url": "https://paulmillr.com/funding/" ··· 700 712 "version": "0.10.7", 701 713 "resolved": "https://registry.npmjs.org/@optique/core/-/core-0.10.7.tgz", 702 714 "integrity": "sha512-FwSX8ILFqzcCqZi6Xetsa4flJp/yyqFG4d4eFD98BtqdzxxuylzdrKvsXj/ow8mcoVjYkTuaIkqHSBxonqMcQg==", 715 + "dev": true, 703 716 "funding": [ 704 717 "https://github.com/sponsors/dahlia" 705 718 ], ··· 714 727 "version": "0.10.7", 715 728 "resolved": "https://registry.npmjs.org/@optique/run/-/run-0.10.7.tgz", 716 729 "integrity": "sha512-1CVdH8uyptj1nFGS2MLacSmZceRClez4LD/G/Gm38wrAVnJq6I+9Fvyh2bVHErsZLQzR0a12CYMUWIgDKY3X1w==", 730 + "dev": true, 717 731 "funding": [ 718 732 "https://github.com/sponsors/dahlia" 719 733 ], ··· 1298 1312 "version": "0.5.19", 1299 1313 "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz", 1300 1314 "integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==", 1315 + "dev": true, 1301 1316 "license": "MIT", 1302 1317 "dependencies": { 1303 1318 "postcss-selector-parser": "6.0.10" ··· 1639 1654 "version": "3.0.0", 1640 1655 "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", 1641 1656 "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", 1657 + "dev": true, 1642 1658 "license": "MIT", 1643 1659 "bin": { 1644 1660 "cssesc": "bin/cssesc" ··· 2266 2282 "yallist": "^3.0.2" 2267 2283 } 2268 2284 }, 2285 + "node_modules/lucide-react": { 2286 + "version": "1.8.0", 2287 + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.8.0.tgz", 2288 + "integrity": "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw==", 2289 + "license": "ISC", 2290 + "peerDependencies": { 2291 + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" 2292 + } 2293 + }, 2269 2294 "node_modules/magic-string": { 2270 2295 "version": "0.30.21", 2271 2296 "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", ··· 2931 2956 "version": "1.1.1", 2932 2957 "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", 2933 2958 "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", 2959 + "dev": true, 2934 2960 "license": "ISC" 2935 2961 }, 2936 2962 "node_modules/picomatch": { ··· 2979 3005 "version": "6.0.10", 2980 3006 "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", 2981 3007 "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", 3008 + "dev": true, 2982 3009 "license": "MIT", 2983 3010 "dependencies": { 2984 3011 "cssesc": "^3.0.0", ··· 3011 3038 "version": "3.8.2", 3012 3039 "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.2.tgz", 3013 3040 "integrity": "sha512-8c3mgTe0ASwWAJK+78dpviD+A8EqhndQPUBpNUIPt6+xWlIigCwfN01lWr9MAede4uqXGTEKeQWTvzb3vjia0Q==", 3041 + "dev": true, 3014 3042 "license": "MIT", 3015 3043 "bin": { 3016 3044 "prettier": "bin/prettier.cjs" ··· 3280 3308 "version": "4.2.2", 3281 3309 "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", 3282 3310 "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", 3311 + "dev": true, 3283 3312 "license": "MIT" 3284 3313 }, 3285 3314 "node_modules/tapable": { ··· 3483 3512 "version": "1.0.2", 3484 3513 "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", 3485 3514 "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", 3515 + "dev": true, 3486 3516 "license": "MIT" 3487 3517 }, 3488 3518 "node_modules/vfile": {
+1
web/package.json
··· 12 12 "@atcute/atproto": "^3.1.11", 13 13 "@atcute/client": "^4.2.1", 14 14 "@atcute/oauth-browser-client": "^3.0.0", 15 + "lucide-react": "^1.8.0", 15 16 "react": "^19.2.5", 16 17 "react-dom": "^19.2.5", 17 18 "react-markdown": "^10.1.0",
+3 -2
web/src/components/ActivityList.tsx
··· 1 1 import { useState } from "react"; 2 + import { ChevronDown } from "lucide-react"; 2 3 import { Link } from "react-router-dom"; 3 4 import { parseAtUri } from "../lib/util"; 4 5 import PostBody from "./post/PostBody"; ··· 46 47 <div className="mt-4 text-center"> 47 48 <button 48 49 onClick={() => setShown((prev) => prev + PAGE_SIZE)} 49 - className="text-neutral-400 hover:text-neutral-300" 50 + className="text-neutral-400 hover:text-neutral-300 inline-flex items-center gap-1" 50 51 > 51 - show more 52 + <ChevronDown size={14} /> show more 52 53 </button> 53 54 </div> 54 55 )}
+6 -5
web/src/components/BBSPanel.tsx
··· 1 1 import { Link } from "react-router-dom"; 2 + import { Monitor, Pencil, Shield, Trash2, Plus } from "lucide-react"; 2 3 import { ActionLink } from "./nav/ActionButton"; 3 4 4 5 const cardStyle = ··· 17 18 <p className="text-neutral-400 mb-4"> 18 19 No BBS yet. 19 20 </p> 20 - <ActionLink to="/account/create">create a bbs</ActionLink> 21 + <ActionLink to="/account/create" icon={Plus}>create a bbs</ActionLink> 21 22 </> 22 23 ); 23 24 } ··· 25 26 return ( 26 27 <div className="grid grid-cols-2 gap-3 max-w-md"> 27 28 <Link to={`/bbs/${userHandle}`} className={cardStyle}> 28 - <div className="text-neutral-200 mb-1">Browse</div> 29 + <div className="flex items-center gap-2 text-neutral-200 mb-1"><Monitor size={14} /> Browse</div> 29 30 <div className="text-xs text-neutral-400">View your BBS.</div> 30 31 </Link> 31 32 <Link to="/account/edit" className={cardStyle}> 32 - <div className="text-neutral-200 mb-1">Edit</div> 33 + <div className="flex items-center gap-2 text-neutral-200 mb-1"><Pencil size={14} /> Edit</div> 33 34 <div className="text-xs text-neutral-400">Name, boards, intro.</div> 34 35 </Link> 35 36 <Link to="/account/moderate" className={cardStyle}> 36 - <div className="text-neutral-200 mb-1">Moderate</div> 37 + <div className="flex items-center gap-2 text-neutral-200 mb-1"><Shield size={14} /> Moderate</div> 37 38 <div className="text-xs text-neutral-400">Bans and hidden posts.</div> 38 39 </Link> 39 40 <button 40 41 onClick={onDelete} 41 42 className="text-left bg-neutral-900 border border-neutral-800 rounded px-4 py-3 hover:border-red-900" 42 43 > 43 - <div className="text-neutral-400 mb-1">Delete</div> 44 + <div className="flex items-center gap-2 text-neutral-400 mb-1"><Trash2 size={14} /> Delete</div> 44 45 <div className="text-xs text-neutral-400">Remove your BBS.</div> 45 46 </button> 46 47 </div>
+3 -2
web/src/components/DialBBS.tsx
··· 1 1 import { useState, type SyntheticEvent } from "react"; 2 + import { ArrowRight, Dices } from "lucide-react"; 2 3 import { Link, useNavigate } from "react-router-dom"; 3 4 import HandleInput from "./form/HandleInput"; 4 5 import { Button } from "./form/Form"; ··· 92 93 } 93 94 aria-label="Dial a BBS by handle" 94 95 /> 95 - <Button type="submit">go</Button> 96 - <Button type="button" onClick={onRandom}>random</Button> 96 + <Button type="submit"><ArrowRight size={16} /></Button> 97 + <Button type="button" onClick={onRandom}><Dices size={16} /></Button> 97 98 </form> 98 99 {dropdownOpen && ( 99 100 <div className="relative">
+3 -2
web/src/components/MyThreadList.tsx
··· 1 1 import { useState } from "react"; 2 + import { ChevronDown } from "lucide-react"; 2 3 import { Link } from "react-router-dom"; 3 4 import { parseAtUri, formatFullDate, relativeDate } from "../lib/util"; 4 5 import type { MyThread } from "../router/loaders"; ··· 49 50 <div className="mt-4 text-center"> 50 51 <button 51 52 onClick={() => setShown((prev) => prev + PAGE_SIZE)} 52 - className="text-neutral-400 hover:text-neutral-300" 53 + className="text-neutral-400 hover:text-neutral-300 inline-flex items-center gap-1" 53 54 > 54 - show more 55 + <ChevronDown size={14} /> show more 55 56 </button> 56 57 </div> 57 58 )}
+3 -2
web/src/components/PinButton.tsx
··· 4 4 import { parseAtUri } from "../lib/util"; 5 5 import { createPin, deleteRecord } from "../lib/writes"; 6 6 import { ActionButton } from "./nav/ActionButton"; 7 + import { Pin, PinOff } from "lucide-react"; 7 8 8 9 interface PinButtonProps { 9 10 bbsDid: string; ··· 29 30 if (!user) return null; 30 31 31 32 return ( 32 - <ActionButton onClick={handleTogglePin}> 33 - {pinRkey ? "✕ unpin" : "+ pin"} 33 + <ActionButton onClick={handleTogglePin} icon={pinRkey ? PinOff : Pin}> 34 + {pinRkey ? "unpin" : "pin"} 34 35 </ActionButton> 35 36 ); 36 37 }
+3 -2
web/src/components/PinnedList.tsx
··· 1 1 import { useState } from "react"; 2 + import { ChevronDown } from "lucide-react"; 2 3 import ListLink from "./nav/ListLink"; 3 4 import type { PinnedBBS } from "../router/loaders"; 4 5 ··· 31 32 {shown < pins.length && ( 32 33 <button 33 34 onClick={() => setShown((prev) => prev + PAGE_SIZE)} 34 - className="text-xs text-neutral-400 hover:text-neutral-300 mt-2" 35 + className="text-xs text-neutral-400 hover:text-neutral-300 mt-2 inline-flex items-center gap-1" 35 36 > 36 - show more 37 + <ChevronDown size={12} /> show more 37 38 </button> 38 39 )} 39 40 </div>
+3 -2
web/src/components/form/ComposeForm.tsx
··· 1 1 import type { SyntheticEvent } from "react"; 2 + import { Send, Paperclip } from "lucide-react"; 2 3 import { Input, Textarea, Button } from "./Form"; 3 4 import FileChips from "./FileChips"; 4 5 import { MAX_ATTACHMENTS } from "../../lib/limits"; ··· 103 104 104 105 <div className="flex items-center gap-3"> 105 106 <Button type="submit" disabled={posting}> 106 - {posting ? "posting..." : submitLabel} 107 + {posting ? "posting..." : <><Send size={14} className="inline -mt-0.5" /> {submitLabel}</>} 107 108 </Button> 108 109 {!attachmentsAtLimit && ( 109 110 <label className="text-neutral-200 cursor-pointer bg-neutral-800 hover:bg-neutral-700 px-4 py-2 rounded inline-block"> 110 - attach 111 + <span className="inline-flex items-center gap-1.5"><Paperclip size={14} /> attach</span> 111 112 <input 112 113 name="attachments" 113 114 type="file"
+8 -2
web/src/components/nav/ActionButton.tsx
··· 1 + import type { LucideIcon } from "lucide-react"; 1 2 import { Link } from "react-router-dom"; 2 3 3 4 const actionStyle = 4 - "bg-neutral-800 hover:bg-neutral-700 text-neutral-200 px-4 py-2 rounded whitespace-nowrap"; 5 + "inline-flex items-center gap-1.5 bg-neutral-800 hover:bg-neutral-700 text-neutral-200 px-4 py-2 rounded whitespace-nowrap"; 5 6 6 7 interface ActionButtonProps { 7 8 onClick: () => void; 8 9 children: React.ReactNode; 10 + icon?: LucideIcon; 9 11 className?: string; 10 12 } 11 13 12 14 interface ActionLinkProps { 13 15 to: string; 14 16 children: React.ReactNode; 17 + icon?: LucideIcon; 15 18 className?: string; 16 19 } 17 20 18 21 export function ActionButton({ 19 22 onClick, 20 23 children, 24 + icon: Icon, 21 25 className, 22 26 }: ActionButtonProps) { 23 27 return ( 24 28 <button onClick={onClick} className={`${actionStyle} ${className ?? ""}`}> 29 + {Icon && <Icon size={14} />} 25 30 {children} 26 31 </button> 27 32 ); 28 33 } 29 34 30 - export function ActionLink({ to, children, className }: ActionLinkProps) { 35 + export function ActionLink({ to, children, icon: Icon, className }: ActionLinkProps) { 31 36 return ( 32 37 <Link to={to} className={`${actionStyle} ${className ?? ""}`}> 38 + {Icon && <Icon size={14} />} 33 39 {children} 34 40 </Link> 35 41 );
+4 -2
web/src/components/post/AttachmentLink.tsx
··· 1 + import { Paperclip } from "lucide-react"; 2 + 1 3 interface AttachmentLinkProps { 2 4 pds: string; 3 5 did: string; ··· 37 39 <a 38 40 href={`${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}`} 39 41 onClick={download} 40 - className="text-xs text-neutral-400 hover:text-neutral-300 block mt-1 cursor-pointer" 42 + className="text-xs text-neutral-400 hover:text-neutral-300 inline-flex items-center gap-1 mt-3 cursor-pointer" 41 43 > 42 - [{name}] 44 + <Paperclip size={11} /> {name} 43 45 </a> 44 46 ); 45 47 }
+4 -3
web/src/components/profile/EditProfile.tsx
··· 1 1 import { useState } from "react"; 2 + import { Check, X } from "lucide-react"; 2 3 import { Input, Textarea, Button } from "../form/Form"; 3 4 import * as limits from "../../lib/limits"; 4 5 ··· 62 63 /> 63 64 </div> 64 65 <div className="flex gap-2"> 65 - <Button onClick={handleSubmit}>save</Button> 66 + <Button onClick={handleSubmit}><Check size={14} className="inline -mt-0.5" /> save</Button> 66 67 <button 67 68 onClick={onCancel} 68 - className="text-neutral-400 hover:text-neutral-300 text-xs" 69 + className="inline-flex items-center gap-1.5 text-neutral-400 hover:text-neutral-300 text-xs" 69 70 > 70 - cancel 71 + <X size={14} /> cancel 71 72 </button> 72 73 </div> 73 74 </div>
+5 -6
web/src/components/profile/ViewProfile.tsx
··· 1 1 import { Link } from "react-router-dom"; 2 + import { Pencil, ChevronRight, Monitor } from "lucide-react"; 2 3 import PostBody from "../post/PostBody"; 3 4 import { ActionButton } from "../nav/ActionButton"; 4 5 import type { Profile } from "../../lib/profile"; ··· 23 24 {profile?.name ?? handle} 24 25 </h1> 25 26 {isOwner && ( 26 - <ActionButton onClick={onEdit}>edit profile</ActionButton> 27 + <ActionButton onClick={onEdit} icon={Pencil}>edit</ActionButton> 27 28 )} 28 29 </div> 29 30 <p className="text-neutral-400"> ··· 42 43 )} 43 44 {profile?.bbsName && ( 44 45 <div className="mt-6"> 45 - <p className="text-xs text-neutral-400 uppercase tracking-wide mb-2"> 46 - BBS 46 + <p className="text-xs text-neutral-400 uppercase tracking-wide mb-2 inline-flex items-center gap-1.5"> 47 + <Monitor size={12} /> BBS 47 48 </p> 48 49 <Link 49 50 to={`/bbs/${handle}`} ··· 57 58 </div> 58 59 )} 59 60 </div> 60 - <span className="text-neutral-400 group-hover:text-neutral-300 text-lg ml-4"> 61 - 62 - </span> 61 + <ChevronRight size={18} className="text-neutral-400 group-hover:text-neutral-300 ml-4" /> 63 62 </Link> 64 63 </div> 65 64 )}
+13 -12
web/src/pages/BBS.tsx
··· 13 13 import ActionBar from "../components/nav/ActionBar"; 14 14 import { ActionLink } from "../components/nav/ActionButton"; 15 15 import PinButton from "../components/PinButton"; 16 + import { User, Pencil, Shield, LayoutGrid, Newspaper, Megaphone, ChevronDown } from "lucide-react"; 16 17 import type { News } from "../lib/bbs"; 17 18 import type { BBSLoaderData } from "../router/loaders"; 18 19 import PostBody from "../components/post/PostBody"; ··· 83 84 <p className="text-neutral-400 mb-3">{bbs.site.description}</p> 84 85 <ActionBar> 85 86 <PinButton bbsDid={bbs.identity.did} initialRkey={pinRkey} /> 86 - <ActionLink to={`/profile/${encodeURIComponent(handle)}`}> 87 - operator 87 + <ActionLink to={`/profile/${encodeURIComponent(handle)}`} icon={User}> 88 + owner 88 89 </ActionLink> 89 - {isSysop && <ActionLink to="/account/edit">edit</ActionLink>} 90 - {isSysop && <ActionLink to="/account/moderate">moderate</ActionLink>} 90 + {isSysop && <ActionLink to="/account/edit" icon={Pencil}>edit</ActionLink>} 91 + {isSysop && <ActionLink to="/account/moderate" icon={Shield}>moderate</ActionLink>} 91 92 </ActionBar> 92 93 </div> 93 94 ··· 98 99 )} 99 100 100 101 <section className="mb-8"> 101 - <h2 className="text-xs text-neutral-400 uppercase tracking-wide mb-3"> 102 - Boards 102 + <h2 className="text-xs text-neutral-400 uppercase tracking-wide mb-3 inline-flex items-center gap-1.5"> 103 + <LayoutGrid size={12} /> Boards 103 104 </h2> 104 105 <div className="space-y-1"> 105 106 {bbs.site.boards.map((board) => ( ··· 114 115 </section> 115 116 116 117 <section> 117 - <h2 className="text-xs text-neutral-400 uppercase tracking-wide mb-3"> 118 - News 118 + <h2 className="text-xs text-neutral-400 uppercase tracking-wide mb-3 inline-flex items-center gap-1.5"> 119 + <Newspaper size={12} /> News 119 120 </h2> 120 121 121 122 {isSysop && ( 122 123 <details className="mb-4 border border-neutral-800 rounded p-4"> 123 - <summary className="text-neutral-300 cursor-pointer"> 124 - post news 124 + <summary className="text-neutral-300 cursor-pointer inline-flex items-center gap-1.5"> 125 + <Megaphone size={14} /> post news 125 126 </summary> 126 127 <ComposeForm 127 128 className="mt-4" ··· 180 181 {!showAllNews && allNews.length > 3 && ( 181 182 <button 182 183 onClick={() => setShowAllNews(true)} 183 - className="text-neutral-400 hover:text-neutral-300 text-xs mt-2" 184 + className="text-neutral-400 hover:text-neutral-300 text-xs mt-2 inline-flex items-center gap-1" 184 185 > 185 - show more 186 + <ChevronDown size={12} /> show more 186 187 </button> 187 188 )} 188 189 </>
+3 -2
web/src/pages/Board.tsx
··· 1 1 import { useEffect, useState, type SyntheticEvent } from "react"; 2 + import { PenLine } from "lucide-react"; 2 3 import { 3 4 useLoaderData, 4 5 useNavigate, ··· 111 112 112 113 {user && ( 113 114 <details className="mb-6 border border-neutral-800 rounded p-4"> 114 - <summary className="text-neutral-300 cursor-pointer"> 115 - new thread 115 + <summary className="text-neutral-300 cursor-pointer inline-flex items-center gap-1.5"> 116 + <PenLine size={14} /> new thread 116 117 </summary> 117 118 <ComposeForm 118 119 className="mt-4"
+33 -31
web/src/pages/LoggedOutHome.tsx
··· 1 1 import { useMemo, useState } from "react"; 2 + import { Phone, Copy, Check } from "lucide-react"; 2 3 import { useDiscovery } from "../hooks/useDiscovery"; 3 4 import { usePageTitle } from "../hooks/usePageTitle"; 4 5 import DialBBS, { type Suggestion } from "../components/DialBBS"; ··· 16 17 [discovered], 17 18 ); 18 19 const [tab, setTab] = useState<"pip" | "uv" | "brew" | "telnet">("pip"); 20 + const [copied, setCopied] = useState(false); 19 21 usePageTitle("atbbs"); 20 22 23 + const installCommands: Record<string, string> = { 24 + pip: "pip install atbbs\natbbs", 25 + uv: "uv tool install atbbs\natbbs", 26 + brew: "brew install alyraffauf/tap/atbbs\natbbs", 27 + telnet: "telnet tel.atbbs.xyz", 28 + }; 29 + 30 + function handleCopy() { 31 + navigator.clipboard.writeText(installCommands[tab]); 32 + setCopied(true); 33 + setTimeout(() => setCopied(false), 1500); 34 + } 35 + 21 36 const activeTab = "py-2 border-b-2 text-neutral-200 border-neutral-200"; 22 37 const inactiveTab = 23 38 "py-2 border-b-2 text-neutral-400 hover:text-neutral-300 border-transparent"; 24 39 25 40 return ( 26 - <div className="h-full flex flex-col justify-center overflow-hidden"> 41 + <div className="h-full flex flex-col justify-center"> 27 42 <div className="text-center pb-4"> 28 43 <picture> 29 44 <source ··· 54 69 </div> 55 70 56 71 <div className="border-t border-neutral-800 py-4"> 57 - <h2 className="text-neutral-300 mb-4">Dial a BBS</h2> 72 + <h2 className="text-neutral-300 mb-4 flex items-center gap-2"><Phone size={16} /> Dial a BBS</h2> 58 73 <div className="mb-6"> 59 74 <DialBBS discovered={discovered} suggestions={suggestions} /> 60 75 </div> ··· 74 89 </button> 75 90 ))} 76 91 </div> 77 - {tab === "pip" && ( 78 - <pre className="bg-neutral-900 border border-neutral-800 rounded px-4 py-3 text-neutral-400 text-xs"> 79 - <span className="text-neutral-400 select-none">$ </span>pip install 80 - atbbs 81 - {"\n"} 82 - <span className="text-neutral-400 select-none">$ </span>atbbs 83 - </pre> 84 - )} 85 - {tab === "uv" && ( 86 - <pre className="bg-neutral-900 border border-neutral-800 rounded px-4 py-3 text-neutral-400 text-xs"> 87 - <span className="text-neutral-400 select-none">$ </span>uv tool 88 - install atbbs 89 - {"\n"} 90 - <span className="text-neutral-400 select-none">$ </span>atbbs 91 - </pre> 92 - )} 93 - {tab === "brew" && ( 94 - <pre className="bg-neutral-900 border border-neutral-800 rounded px-4 py-3 text-neutral-400 text-xs"> 95 - <span className="text-neutral-400 select-none">$ </span>brew install 96 - alyraffauf/tap/atbbs 97 - {"\n"} 98 - <span className="text-neutral-400 select-none">$ </span>atbbs 99 - </pre> 100 - )} 101 - {tab === "telnet" && ( 102 - <pre className="bg-neutral-900 border border-neutral-800 rounded px-4 py-3 text-neutral-400 text-xs"> 103 - <span className="text-neutral-400 select-none">$ </span>telnet 104 - tel.atbbs.xyz 92 + <div className="relative"> 93 + <pre className="bg-neutral-900 border border-neutral-800 rounded px-4 py-3 pr-12 text-neutral-400 text-xs"> 94 + {installCommands[tab].split("\n").map((line, i) => ( 95 + <span key={`${tab}-${i}`}> 96 + {i > 0 && "\n"} 97 + <span className="select-none">$ </span>{line} 98 + </span> 99 + ))} 105 100 </pre> 106 - )} 101 + <button 102 + onClick={handleCopy} 103 + className="absolute top-2.5 right-2.5 text-neutral-500 hover:text-neutral-300" 104 + aria-label="Copy to clipboard" 105 + > 106 + {copied ? <Check size={14} /> : <Copy size={14} />} 107 + </button> 108 + </div> 107 109 </div> 108 110 </div> 109 111 );
+7 -6
web/src/pages/Login.tsx
··· 1 1 import { useState, type SyntheticEvent } from "react"; 2 + import { LogIn, MessageSquare, Pin, User, Monitor } from "lucide-react"; 2 3 import { useAuth } from "../lib/auth"; 3 4 import { usePageTitle } from "../hooks/usePageTitle"; 4 5 import { useHandleSearch } from "../hooks/useHandleSearch"; ··· 88 89 aria-label="Enter your handle" 89 90 /> 90 91 <Button type="submit" disabled={busy}> 91 - {busy ? "..." : "log in"} 92 + {busy ? "..." : <LogIn size={16} />} 92 93 </Button> 93 94 </form> 94 95 {dropdownOpen && ( ··· 132 133 133 134 <div className="bg-neutral-900 border border-neutral-800 rounded p-4 text-sm text-neutral-400 space-y-3"> 134 135 <p>Once signed in, you can:</p> 135 - <ul className="list-disc list-inside space-y-1"> 136 - <li>Post threads and replies</li> 137 - <li>Pin boards you like</li> 138 - <li>Set up a profile</li> 139 - <li>Start your own community</li> 136 + <ul className="space-y-2"> 137 + <li className="flex items-center gap-2"><MessageSquare size={14} /> Post threads and replies</li> 138 + <li className="flex items-center gap-2"><Pin size={14} /> Pin boards you like</li> 139 + <li className="flex items-center gap-2"><User size={14} /> Set up a profile</li> 140 + <li className="flex items-center gap-2"><Monitor size={14} /> Start your own community</li> 140 141 </ul> 141 142 <p className="text-neutral-400 pt-3 border-t border-neutral-800"> 142 143 We'll redirect you to your provider to continue.
+3 -2
web/src/pages/Profile.tsx
··· 1 1 import { Suspense, useState } from "react"; 2 + import { MessageSquare } from "lucide-react"; 2 3 import { Await, useLoaderData, useRevalidator } from "react-router-dom"; 3 4 import { useAuth } from "../lib/auth"; 4 5 import { usePageTitle } from "../hooks/usePageTitle"; ··· 45 46 onEdit={() => setEditing(true)} 46 47 /> 47 48 <div className="mt-8"> 48 - <p className="text-xs text-neutral-400 uppercase tracking-wide mb-3"> 49 - Recent Threads 49 + <p className="text-xs text-neutral-400 uppercase tracking-wide mb-3 inline-flex items-center gap-1.5"> 50 + <MessageSquare size={12} /> Recent Threads 50 51 </p> 51 52 <Suspense 52 53 fallback={<p className="text-neutral-400">loading...</p>}