experimental bluesky client
0
fork

Configure Feed

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

Add logout button, begin reply implementation, add Biome for formatting/linting

+1253 -871
+15 -15
.cta.json
··· 1 1 { 2 - "projectName": "dudesky", 3 - "mode": "file-router", 4 - "typescript": true, 5 - "tailwind": true, 6 - "packageManager": "npm", 7 - "git": true, 8 - "install": true, 9 - "addOnOptions": {}, 10 - "includeExamples": true, 11 - "envVarValues": {}, 12 - "routerOnly": false, 13 - "version": 1, 14 - "framework": "react", 15 - "chosenAddOns": [] 16 - } 2 + "projectName": "dudesky", 3 + "mode": "file-router", 4 + "typescript": true, 5 + "tailwind": true, 6 + "packageManager": "npm", 7 + "git": true, 8 + "install": true, 9 + "addOnOptions": {}, 10 + "includeExamples": true, 11 + "envVarValues": {}, 12 + "routerOnly": false, 13 + "version": 1, 14 + "framework": "react", 15 + "chosenAddOns": [] 16 + }
+11 -9
.vscode/settings.json
··· 1 1 { 2 - "files.watcherExclude": { 3 - "**/routeTree.gen.ts": true 4 - }, 5 - "search.exclude": { 6 - "**/routeTree.gen.ts": true 7 - }, 8 - "files.readonlyInclude": { 9 - "**/routeTree.gen.ts": true 10 - } 2 + "files.watcherExclude": { 3 + "**/routeTree.gen.ts": true 4 + }, 5 + "search.exclude": { 6 + "**/routeTree.gen.ts": true 7 + }, 8 + "files.readonlyInclude": { 9 + "**/routeTree.gen.ts": true 10 + }, 11 + "editor.defaultFormatter": "biomejs.biome", 12 + "editor.formatOnSave": true 11 13 }
+41
biome.json
··· 1 + { 2 + "$schema": "https://biomejs.dev/schemas/2.4.11/schema.json", 3 + "vcs": { 4 + "enabled": true, 5 + "clientKind": "git", 6 + "useIgnoreFile": true 7 + }, 8 + "files": { 9 + "ignoreUnknown": false, 10 + "includes": ["**", "!src/routeTree.gen.ts"] 11 + }, 12 + "css": { 13 + "parser": { 14 + "tailwindDirectives": true 15 + } 16 + }, 17 + "formatter": { 18 + "enabled": true, 19 + "indentStyle": "tab" 20 + }, 21 + "linter": { 22 + "enabled": true, 23 + "rules": { 24 + "recommended": true 25 + } 26 + }, 27 + "javascript": { 28 + "formatter": { 29 + "quoteStyle": "single", 30 + "semicolons": "asNeeded" 31 + } 32 + }, 33 + "assist": { 34 + "enabled": true, 35 + "actions": { 36 + "source": { 37 + "organizeImports": "on" 38 + } 39 + } 40 + } 41 + }
+176
package-lock.json
··· 23 23 "tailwindcss": "^4.1.18" 24 24 }, 25 25 "devDependencies": { 26 + "@biomejs/biome": "2.4.11", 26 27 "@tailwindcss/typography": "^0.5.16", 27 28 "@tanstack/devtools-vite": "latest", 28 29 "@testing-library/dom": "^10.4.1", ··· 681 682 }, 682 683 "engines": { 683 684 "node": ">=6.9.0" 685 + } 686 + }, 687 + "node_modules/@biomejs/biome": { 688 + "version": "2.4.11", 689 + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.11.tgz", 690 + "integrity": "sha512-nWxHX8tf3Opb/qRgZpBbsTOqOodkbrkJ7S+JxJAruxOReaDPPmPuLBAGQ8vigyUgo0QBB+oQltNEAvalLcjggA==", 691 + "dev": true, 692 + "license": "MIT OR Apache-2.0", 693 + "bin": { 694 + "biome": "bin/biome" 695 + }, 696 + "engines": { 697 + "node": ">=14.21.3" 698 + }, 699 + "funding": { 700 + "type": "opencollective", 701 + "url": "https://opencollective.com/biome" 702 + }, 703 + "optionalDependencies": { 704 + "@biomejs/cli-darwin-arm64": "2.4.11", 705 + "@biomejs/cli-darwin-x64": "2.4.11", 706 + "@biomejs/cli-linux-arm64": "2.4.11", 707 + "@biomejs/cli-linux-arm64-musl": "2.4.11", 708 + "@biomejs/cli-linux-x64": "2.4.11", 709 + "@biomejs/cli-linux-x64-musl": "2.4.11", 710 + "@biomejs/cli-win32-arm64": "2.4.11", 711 + "@biomejs/cli-win32-x64": "2.4.11" 712 + } 713 + }, 714 + "node_modules/@biomejs/cli-darwin-arm64": { 715 + "version": "2.4.11", 716 + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.11.tgz", 717 + "integrity": "sha512-wOt+ed+L2dgZanWyL6i29qlXMc088N11optzpo10peayObBaAshbTcxKUchzEMp9QSY8rh5h6VfAFE3WTS1rqg==", 718 + "cpu": [ 719 + "arm64" 720 + ], 721 + "dev": true, 722 + "license": "MIT OR Apache-2.0", 723 + "optional": true, 724 + "os": [ 725 + "darwin" 726 + ], 727 + "engines": { 728 + "node": ">=14.21.3" 729 + } 730 + }, 731 + "node_modules/@biomejs/cli-darwin-x64": { 732 + "version": "2.4.11", 733 + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.11.tgz", 734 + "integrity": "sha512-gZ6zR8XmZlExfi/Pz/PffmdpWOQ8Qhy7oBztgkR8/ylSRyLwfRPSadmiVCV8WQ8PoJ2MWUy2fgID9zmtgUUJmw==", 735 + "cpu": [ 736 + "x64" 737 + ], 738 + "dev": true, 739 + "license": "MIT OR Apache-2.0", 740 + "optional": true, 741 + "os": [ 742 + "darwin" 743 + ], 744 + "engines": { 745 + "node": ">=14.21.3" 746 + } 747 + }, 748 + "node_modules/@biomejs/cli-linux-arm64": { 749 + "version": "2.4.11", 750 + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.11.tgz", 751 + "integrity": "sha512-avdJaEElXrKceK0va9FkJ4P5ci3N01TGkc6ni3P8l3BElqbOz42Wg2IyX3gbh0ZLEd4HVKEIrmuVu/AMuSeFFA==", 752 + "cpu": [ 753 + "arm64" 754 + ], 755 + "dev": true, 756 + "libc": [ 757 + "glibc" 758 + ], 759 + "license": "MIT OR Apache-2.0", 760 + "optional": true, 761 + "os": [ 762 + "linux" 763 + ], 764 + "engines": { 765 + "node": ">=14.21.3" 766 + } 767 + }, 768 + "node_modules/@biomejs/cli-linux-arm64-musl": { 769 + "version": "2.4.11", 770 + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.11.tgz", 771 + "integrity": "sha512-+Sbo1OAmlegtdwqFE8iOxFIWLh1B3OEgsuZfBpyyN/kWuqZ8dx9ZEes6zVnDMo+zRHF2wLynRVhoQmV7ohxl2Q==", 772 + "cpu": [ 773 + "arm64" 774 + ], 775 + "dev": true, 776 + "libc": [ 777 + "musl" 778 + ], 779 + "license": "MIT OR Apache-2.0", 780 + "optional": true, 781 + "os": [ 782 + "linux" 783 + ], 784 + "engines": { 785 + "node": ">=14.21.3" 786 + } 787 + }, 788 + "node_modules/@biomejs/cli-linux-x64": { 789 + "version": "2.4.11", 790 + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.11.tgz", 791 + "integrity": "sha512-TagWV0iomp5LnEnxWFg4nQO+e52Fow349vaX0Q/PIcX6Zhk4GGBgp3qqZ8PVkpC+cuehRctMf3+6+FgQ8jCEFQ==", 792 + "cpu": [ 793 + "x64" 794 + ], 795 + "dev": true, 796 + "libc": [ 797 + "glibc" 798 + ], 799 + "license": "MIT OR Apache-2.0", 800 + "optional": true, 801 + "os": [ 802 + "linux" 803 + ], 804 + "engines": { 805 + "node": ">=14.21.3" 806 + } 807 + }, 808 + "node_modules/@biomejs/cli-linux-x64-musl": { 809 + "version": "2.4.11", 810 + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.11.tgz", 811 + "integrity": "sha512-bexd2IklK7ZgPhrz6jXzpIL6dEAH9MlJU1xGTrypx+FICxrXUp4CqtwfiuoDKse+UlgAlWtzML3jrMqeEAHEhA==", 812 + "cpu": [ 813 + "x64" 814 + ], 815 + "dev": true, 816 + "libc": [ 817 + "musl" 818 + ], 819 + "license": "MIT OR Apache-2.0", 820 + "optional": true, 821 + "os": [ 822 + "linux" 823 + ], 824 + "engines": { 825 + "node": ">=14.21.3" 826 + } 827 + }, 828 + "node_modules/@biomejs/cli-win32-arm64": { 829 + "version": "2.4.11", 830 + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.11.tgz", 831 + "integrity": "sha512-RJhaTnY8byzxDt4bDVb7AFPHkPcjOPK3xBip4ZRTrN3TEfyhjLRm3r3mqknqydgVTB74XG8l4jMLwEACEeihVg==", 832 + "cpu": [ 833 + "arm64" 834 + ], 835 + "dev": true, 836 + "license": "MIT OR Apache-2.0", 837 + "optional": true, 838 + "os": [ 839 + "win32" 840 + ], 841 + "engines": { 842 + "node": ">=14.21.3" 843 + } 844 + }, 845 + "node_modules/@biomejs/cli-win32-x64": { 846 + "version": "2.4.11", 847 + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.11.tgz", 848 + "integrity": "sha512-A8D3JM/00C2KQgUV3oj8Ba15EHEYwebAGCy5Sf9GAjr5Y3+kJIYOiESoqRDeuRZueuMdCsbLZIUqmPhpYXJE9A==", 849 + "cpu": [ 850 + "x64" 851 + ], 852 + "dev": true, 853 + "license": "MIT OR Apache-2.0", 854 + "optional": true, 855 + "os": [ 856 + "win32" 857 + ], 858 + "engines": { 859 + "node": ">=14.21.3" 684 860 } 685 861 }, 686 862 "node_modules/@bramus/specificity": {
+53 -52
package.json
··· 1 1 { 2 - "name": "dudesky", 3 - "private": true, 4 - "type": "module", 5 - "imports": { 6 - "#/*": "./src/*" 7 - }, 8 - "scripts": { 9 - "dev": "vite dev --port 3000", 10 - "build": "vite build", 11 - "preview": "vite preview", 12 - "test": "vitest run" 13 - }, 14 - "dependencies": { 15 - "@atproto/api": "^0.19.8", 16 - "@atproto/oauth-client-node": "^0.3.17", 17 - "@playwright/test": "^1.59.1", 18 - "@tailwindcss/vite": "^4.1.18", 19 - "@tanstack/react-devtools": "latest", 20 - "@tanstack/react-router": "latest", 21 - "@tanstack/react-router-devtools": "latest", 22 - "@tanstack/react-router-ssr-query": "latest", 23 - "@tanstack/react-start": "latest", 24 - "@tanstack/router-plugin": "^1.132.0", 25 - "better-sqlite3": "^12.8.0", 26 - "lucide-react": "^0.545.0", 27 - "react": "^19.2.0", 28 - "react-dom": "^19.2.0", 29 - "tailwindcss": "^4.1.18" 30 - }, 31 - "devDependencies": { 32 - "@tailwindcss/typography": "^0.5.16", 33 - "@tanstack/devtools-vite": "latest", 34 - "@testing-library/dom": "^10.4.1", 35 - "@testing-library/react": "^16.3.0", 36 - "@types/better-sqlite3": "^7.6.13", 37 - "@types/node": "^22.10.2", 38 - "@types/react": "^19.2.0", 39 - "@types/react-dom": "^19.2.0", 40 - "@vitejs/plugin-react": "^5.1.4", 41 - "jsdom": "^28.1.0", 42 - "typescript": "^5.7.2", 43 - "vite": "^7.3.1", 44 - "vite-tsconfig-paths": "^5.1.4", 45 - "vitest": "^3.0.5" 46 - }, 47 - "pnpm": { 48 - "onlyBuiltDependencies": [ 49 - "better-sqlite3", 50 - "esbuild", 51 - "lightningcss" 52 - ] 53 - } 2 + "name": "dudesky", 3 + "private": true, 4 + "type": "module", 5 + "imports": { 6 + "#/*": "./src/*" 7 + }, 8 + "scripts": { 9 + "dev": "vite dev --port 3000", 10 + "build": "vite build", 11 + "preview": "vite preview", 12 + "test": "vitest run" 13 + }, 14 + "dependencies": { 15 + "@atproto/api": "^0.19.8", 16 + "@atproto/oauth-client-node": "^0.3.17", 17 + "@playwright/test": "^1.59.1", 18 + "@tailwindcss/vite": "^4.1.18", 19 + "@tanstack/react-devtools": "latest", 20 + "@tanstack/react-router": "latest", 21 + "@tanstack/react-router-devtools": "latest", 22 + "@tanstack/react-router-ssr-query": "latest", 23 + "@tanstack/react-start": "latest", 24 + "@tanstack/router-plugin": "^1.132.0", 25 + "better-sqlite3": "^12.8.0", 26 + "lucide-react": "^0.545.0", 27 + "react": "^19.2.0", 28 + "react-dom": "^19.2.0", 29 + "tailwindcss": "^4.1.18" 30 + }, 31 + "devDependencies": { 32 + "@biomejs/biome": "2.4.11", 33 + "@tailwindcss/typography": "^0.5.16", 34 + "@tanstack/devtools-vite": "latest", 35 + "@testing-library/dom": "^10.4.1", 36 + "@testing-library/react": "^16.3.0", 37 + "@types/better-sqlite3": "^7.6.13", 38 + "@types/node": "^22.10.2", 39 + "@types/react": "^19.2.0", 40 + "@types/react-dom": "^19.2.0", 41 + "@vitejs/plugin-react": "^5.1.4", 42 + "jsdom": "^28.1.0", 43 + "typescript": "^5.7.2", 44 + "vite": "^7.3.1", 45 + "vite-tsconfig-paths": "^5.1.4", 46 + "vitest": "^3.0.5" 47 + }, 48 + "pnpm": { 49 + "onlyBuiltDependencies": [ 50 + "better-sqlite3", 51 + "esbuild", 52 + "lightningcss" 53 + ] 54 + } 54 55 }
+23 -23
public/manifest.json
··· 1 1 { 2 - "short_name": "TanStack App", 3 - "name": "Create TanStack App Sample", 4 - "icons": [ 5 - { 6 - "src": "favicon.ico", 7 - "sizes": "64x64 32x32 24x24 16x16", 8 - "type": "image/x-icon" 9 - }, 10 - { 11 - "src": "logo192.png", 12 - "type": "image/png", 13 - "sizes": "192x192" 14 - }, 15 - { 16 - "src": "logo512.png", 17 - "type": "image/png", 18 - "sizes": "512x512" 19 - } 20 - ], 21 - "start_url": ".", 22 - "display": "standalone", 23 - "theme_color": "#000000", 24 - "background_color": "#ffffff" 2 + "short_name": "TanStack App", 3 + "name": "Create TanStack App Sample", 4 + "icons": [ 5 + { 6 + "src": "favicon.ico", 7 + "sizes": "64x64 32x32 24x24 16x16", 8 + "type": "image/x-icon" 9 + }, 10 + { 11 + "src": "logo192.png", 12 + "type": "image/png", 13 + "sizes": "192x192" 14 + }, 15 + { 16 + "src": "logo512.png", 17 + "type": "image/png", 18 + "sizes": "512x512" 19 + } 20 + ], 21 + "start_url": ".", 22 + "display": "standalone", 23 + "theme_color": "#000000", 24 + "background_color": "#ffffff" 25 25 }
+11 -11
src/components/Footer.tsx
··· 1 1 export default function Footer() { 2 - const year = new Date().getFullYear() 2 + const year = new Date().getFullYear() 3 3 4 - return ( 5 - <footer className="mt-20 border-t border-[var(--line)] px-4 pb-14 pt-10 text-[var(--sea-ink-soft)]"> 6 - <div className="page-wrap flex flex-col items-center justify-between gap-4 text-center sm:flex-row sm:text-left"> 7 - <p className="m-0 text-sm"> 8 - &copy; {year} dude.computer. All rights reserved. 9 - </p> 10 - <p className="island-kicker m-0">Built with TanStack Start</p> 11 - </div> 12 - </footer> 13 - ) 4 + return ( 5 + <footer className="mt-20 border-t border-[var(--line)] px-4 pb-14 pt-10 text-[var(--sea-ink-soft)]"> 6 + <div className="page-wrap flex flex-col items-center justify-between gap-4 text-center sm:flex-row sm:text-left"> 7 + <p className="m-0 text-sm"> 8 + &copy; {year} dude.computer. All rights reserved. 9 + </p> 10 + <p className="island-kicker m-0">Built with TanStack Start</p> 11 + </div> 12 + </footer> 13 + ) 14 14 }
+28 -16
src/components/Header.tsx
··· 1 1 import { Link } from '@tanstack/react-router' 2 2 3 - export default function Header() { 4 - return ( 5 - <header className="sticky top-0 z-50 border-b border-[var(--line)] bg-[var(--header-bg)] px-4 backdrop-blur-lg"> 6 - <nav className="page-wrap flex flex-wrap items-center gap-x-3 gap-y-2 py-3 sm:py-4"> 7 - <h2 className="m-0 flex-shrink-0 text-base font-semibold tracking-tight"> 8 - <Link 9 - to="/feed" 10 - className="inline-flex items-center gap-2 rounded-full border border-[var(--chip-line)] bg-[var(--chip-bg)] px-3 py-1.5 text-sm text-[var(--sea-ink)] no-underline shadow-[0_8px_24px_rgba(30,90,72,0.08)] sm:px-4 sm:py-2" 11 - > 12 - <span className="h-2 w-2 rounded-full bg-[linear-gradient(90deg,#56c6be,#7ed3bf)]" /> 13 - Feed 14 - </Link> 15 - </h2> 16 - </nav> 17 - </header> 18 - ) 3 + export default function Header({ did }: { did: string | null }) { 4 + return ( 5 + <header className="sticky top-0 z-50 border-b border-[var(--line)] bg-[var(--header-bg)] px-4 backdrop-blur-lg"> 6 + <nav className="page-wrap flex flex-wrap items-center gap-x-3 gap-y-2 py-3 sm:py-4"> 7 + {did && ( 8 + <h2 className="m-0 flex-shrink-0 text-base font-semibold tracking-tight"> 9 + <Link 10 + to="/feed" 11 + className="inline-flex items-center gap-2 rounded-full border border-[var(--chip-line)] bg-[var(--chip-bg)] px-3 py-1.5 text-sm text-[var(--sea-ink)] no-underline shadow-[0_8px_24px_rgba(30,90,72,0.08)] sm:px-4 sm:py-2" 12 + > 13 + <span className="h-2 w-2 rounded-full bg-[linear-gradient(90deg,#56c6be,#7ed3bf)]" /> 14 + Feed 15 + </Link> 16 + </h2> 17 + )} 18 + {did && ( 19 + <form method="POST" action="/logout" className="ml-auto"> 20 + <button 21 + type="submit" 22 + className="rounded-full border border-[var(--chip-line)] bg-[var(--chip-bg)] px-3 py-1.5 text-sm text-[var(--sea-ink-soft)] hover:text-[var(--sea-ink)] transition-colors sm:px-4 sm:py-2" 23 + > 24 + Sign out 25 + </button> 26 + </form> 27 + )} 28 + </nav> 29 + </header> 30 + ) 19 31 }
+57 -57
src/components/ThemeToggle.tsx
··· 3 3 type ThemeMode = 'light' | 'dark' | 'auto' 4 4 5 5 function getInitialMode(): ThemeMode { 6 - if (typeof window === 'undefined') { 7 - return 'auto' 8 - } 6 + if (typeof window === 'undefined') { 7 + return 'auto' 8 + } 9 9 10 - const stored = window.localStorage.getItem('theme') 11 - if (stored === 'light' || stored === 'dark' || stored === 'auto') { 12 - return stored 13 - } 10 + const stored = window.localStorage.getItem('theme') 11 + if (stored === 'light' || stored === 'dark' || stored === 'auto') { 12 + return stored 13 + } 14 14 15 - return 'auto' 15 + return 'auto' 16 16 } 17 17 18 18 function applyThemeMode(mode: ThemeMode) { 19 - const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches 20 - const resolved = mode === 'auto' ? (prefersDark ? 'dark' : 'light') : mode 19 + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches 20 + const resolved = mode === 'auto' ? (prefersDark ? 'dark' : 'light') : mode 21 21 22 - document.documentElement.classList.remove('light', 'dark') 23 - document.documentElement.classList.add(resolved) 22 + document.documentElement.classList.remove('light', 'dark') 23 + document.documentElement.classList.add(resolved) 24 24 25 - if (mode === 'auto') { 26 - document.documentElement.removeAttribute('data-theme') 27 - } else { 28 - document.documentElement.setAttribute('data-theme', mode) 29 - } 25 + if (mode === 'auto') { 26 + document.documentElement.removeAttribute('data-theme') 27 + } else { 28 + document.documentElement.setAttribute('data-theme', mode) 29 + } 30 30 31 - document.documentElement.style.colorScheme = resolved 31 + document.documentElement.style.colorScheme = resolved 32 32 } 33 33 34 34 export default function ThemeToggle() { 35 - const [mode, setMode] = useState<ThemeMode>('auto') 35 + const [mode, setMode] = useState<ThemeMode>('auto') 36 36 37 - useEffect(() => { 38 - const initialMode = getInitialMode() 39 - setMode(initialMode) 40 - applyThemeMode(initialMode) 41 - }, []) 37 + useEffect(() => { 38 + const initialMode = getInitialMode() 39 + setMode(initialMode) 40 + applyThemeMode(initialMode) 41 + }, []) 42 42 43 - useEffect(() => { 44 - if (mode !== 'auto') { 45 - return 46 - } 43 + useEffect(() => { 44 + if (mode !== 'auto') { 45 + return 46 + } 47 47 48 - const media = window.matchMedia('(prefers-color-scheme: dark)') 49 - const onChange = () => applyThemeMode('auto') 48 + const media = window.matchMedia('(prefers-color-scheme: dark)') 49 + const onChange = () => applyThemeMode('auto') 50 50 51 - media.addEventListener('change', onChange) 52 - return () => { 53 - media.removeEventListener('change', onChange) 54 - } 55 - }, [mode]) 51 + media.addEventListener('change', onChange) 52 + return () => { 53 + media.removeEventListener('change', onChange) 54 + } 55 + }, [mode]) 56 56 57 - function toggleMode() { 58 - const nextMode: ThemeMode = 59 - mode === 'light' ? 'dark' : mode === 'dark' ? 'auto' : 'light' 60 - setMode(nextMode) 61 - applyThemeMode(nextMode) 62 - window.localStorage.setItem('theme', nextMode) 63 - } 57 + function toggleMode() { 58 + const nextMode: ThemeMode = 59 + mode === 'light' ? 'dark' : mode === 'dark' ? 'auto' : 'light' 60 + setMode(nextMode) 61 + applyThemeMode(nextMode) 62 + window.localStorage.setItem('theme', nextMode) 63 + } 64 64 65 - const label = 66 - mode === 'auto' 67 - ? 'Theme mode: auto (system). Click to switch to light mode.' 68 - : `Theme mode: ${mode}. Click to switch mode.` 65 + const label = 66 + mode === 'auto' 67 + ? 'Theme mode: auto (system). Click to switch to light mode.' 68 + : `Theme mode: ${mode}. Click to switch mode.` 69 69 70 - return ( 71 - <button 72 - type="button" 73 - onClick={toggleMode} 74 - aria-label={label} 75 - title={label} 76 - className="rounded-full border border-[var(--chip-line)] bg-[var(--chip-bg)] px-3 py-1.5 text-sm font-semibold text-[var(--sea-ink)] shadow-[0_8px_22px_rgba(30,90,72,0.08)] transition hover:-translate-y-0.5" 77 - > 78 - {mode === 'auto' ? 'Auto' : mode === 'dark' ? 'Dark' : 'Light'} 79 - </button> 80 - ) 70 + return ( 71 + <button 72 + type="button" 73 + onClick={toggleMode} 74 + aria-label={label} 75 + title={label} 76 + className="rounded-full border border-[var(--chip-line)] bg-[var(--chip-bg)] px-3 py-1.5 text-sm font-semibold text-[var(--sea-ink)] shadow-[0_8px_22px_rgba(30,90,72,0.08)] transition hover:-translate-y-0.5" 77 + > 78 + {mode === 'auto' ? 'Auto' : mode === 'dark' ? 'Dark' : 'Light'} 79 + </button> 80 + ) 81 81 }
+1 -1
src/lib/db.ts
··· 1 - import Database from 'better-sqlite3' 2 1 import path from 'node:path' 2 + import Database from 'better-sqlite3' 3 3 4 4 const DB_PATH = process.env.DB_PATH ?? path.join(process.cwd(), 'dudesky.db') 5 5
+60 -48
src/lib/oauth-client.ts
··· 1 - import { JoseKey, NodeOAuthClient, type NodeSavedState, type NodeSavedSession } from '@atproto/oauth-client-node' 1 + import { 2 + JoseKey, 3 + NodeOAuthClient, 4 + type NodeSavedSession, 5 + type NodeSavedState, 6 + } from '@atproto/oauth-client-node' 2 7 import { db } from '#/lib/db' 3 8 4 9 // OAuth PKCE/DPoP state is only needed for the ~10 minutes between /login and ··· 8 13 const rootUrl = process.env.VITE_APP_URL 9 14 10 15 export const client = new NodeOAuthClient({ 11 - clientMetadata: { 12 - client_id: `${rootUrl}/client-metadata`, 13 - client_name: "Dudesky", 14 - client_uri: rootUrl, 15 - redirect_uris: [`${rootUrl}/callback`], 16 - grant_types: ["authorization_code", "refresh_token"], 17 - scope: "atproto transition:generic", 18 - application_type: "web", 19 - token_endpoint_auth_method: "private_key_jwt", 20 - token_endpoint_auth_signing_alg: "ES256", 21 - dpop_bound_access_tokens: true, 22 - jwks_uri: `${rootUrl}/jwks`, 23 - }, 16 + clientMetadata: { 17 + client_id: `${rootUrl}/client-metadata`, 18 + client_name: 'Dudesky', 19 + client_uri: rootUrl, 20 + redirect_uris: [`${rootUrl}/callback`], 21 + grant_types: ['authorization_code', 'refresh_token'], 22 + scope: 'atproto transition:generic', 23 + application_type: 'web', 24 + token_endpoint_auth_method: 'private_key_jwt', 25 + token_endpoint_auth_signing_alg: 'ES256', 26 + dpop_bound_access_tokens: true, 27 + jwks_uri: `${rootUrl}/jwks`, 28 + }, 24 29 25 - keyset: await Promise.all([ 26 - JoseKey.fromImportable(process.env.PRIVATE_KEY_0!, 'key1'), 27 - JoseKey.fromImportable(process.env.PRIVATE_KEY_1!, 'key2'), 28 - JoseKey.fromImportable(process.env.PRIVATE_KEY_2!, 'key3'), 29 - ]), 30 + keyset: await Promise.all([ 31 + // biome-ignore lint/style/noNonNullAssertion: required env vars 32 + JoseKey.fromImportable(process.env.PRIVATE_KEY_0!, 'key1'), 33 + // biome-ignore lint/style/noNonNullAssertion: required env vars 34 + JoseKey.fromImportable(process.env.PRIVATE_KEY_1!, 'key2'), 35 + // biome-ignore lint/style/noNonNullAssertion: required env vars 36 + JoseKey.fromImportable(process.env.PRIVATE_KEY_2!, 'key3'), 37 + ]), 30 38 31 - stateStore: { 32 - async set(key: string, value: NodeSavedState): Promise<void> { 33 - db.prepare(` 39 + stateStore: { 40 + async set(key: string, value: NodeSavedState): Promise<void> { 41 + db.prepare(` 34 42 INSERT INTO oauth_state (key, value, created_at) 35 43 VALUES (?, ?, unixepoch()) 36 44 ON CONFLICT(key) DO UPDATE SET value = excluded.value, created_at = excluded.created_at 37 45 `).run(key, JSON.stringify(value)) 38 46 39 - // Prune expired state entries 40 - db.prepare(`DELETE FROM oauth_state WHERE created_at < unixepoch() - ?`) 41 - .run(STATE_TTL_SECONDS) 42 - }, 43 - async get(key: string): Promise<NodeSavedState | undefined> { 44 - const row = db.prepare(` 47 + // Prune expired state entries 48 + db.prepare( 49 + `DELETE FROM oauth_state WHERE created_at < unixepoch() - ?`, 50 + ).run(STATE_TTL_SECONDS) 51 + }, 52 + async get(key: string): Promise<NodeSavedState | undefined> { 53 + const row = db 54 + .prepare(` 45 55 SELECT value FROM oauth_state 46 56 WHERE key = ? AND created_at >= unixepoch() - ? 47 - `).get(key, STATE_TTL_SECONDS) as { value: string } | undefined 48 - return row ? JSON.parse(row.value) : undefined 49 - }, 50 - async del(key: string): Promise<void> { 51 - db.prepare(`DELETE FROM oauth_state WHERE key = ?`).run(key) 52 - }, 53 - }, 57 + `) 58 + .get(key, STATE_TTL_SECONDS) as { value: string } | undefined 59 + return row ? JSON.parse(row.value) : undefined 60 + }, 61 + async del(key: string): Promise<void> { 62 + db.prepare(`DELETE FROM oauth_state WHERE key = ?`).run(key) 63 + }, 64 + }, 54 65 55 - sessionStore: { 56 - async set(did: string, value: NodeSavedSession): Promise<void> { 57 - db.prepare(` 66 + sessionStore: { 67 + async set(did: string, value: NodeSavedSession): Promise<void> { 68 + db.prepare(` 58 69 INSERT INTO oauth_session (did, value, updated_at) 59 70 VALUES (?, ?, unixepoch()) 60 71 ON CONFLICT(did) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at 61 72 `).run(did, JSON.stringify(value)) 62 - }, 63 - async get(did: string): Promise<NodeSavedSession | undefined> { 64 - const row = db.prepare(`SELECT value FROM oauth_session WHERE did = ?`) 65 - .get(did) as { value: string } | undefined 66 - return row ? JSON.parse(row.value) : undefined 67 - }, 68 - async del(did: string): Promise<void> { 69 - db.prepare(`DELETE FROM oauth_session WHERE did = ?`).run(did) 70 - }, 71 - }, 73 + }, 74 + async get(did: string): Promise<NodeSavedSession | undefined> { 75 + const row = db 76 + .prepare(`SELECT value FROM oauth_session WHERE did = ?`) 77 + .get(did) as { value: string } | undefined 78 + return row ? JSON.parse(row.value) : undefined 79 + }, 80 + async del(did: string): Promise<void> { 81 + db.prepare(`DELETE FROM oauth_session WHERE did = ?`).run(did) 82 + }, 83 + }, 72 84 })
+170 -148
src/routeTree.gen.ts
··· 9 9 // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. 10 10 11 11 import { Route as rootRouteImport } from './routes/__root' 12 - import { Route as LoginRouteImport } from './routes/login' 13 - import { Route as JwksRouteImport } from './routes/jwks' 14 - import { Route as FeedRouteImport } from './routes/feed' 15 - import { Route as ClientMetadataRouteImport } from './routes/client-metadata' 16 - import { Route as CallbackRouteImport } from './routes/callback' 17 12 import { Route as AboutRouteImport } from './routes/about' 13 + import { Route as CallbackRouteImport } from './routes/callback' 14 + import { Route as ClientMetadataRouteImport } from './routes/client-metadata' 15 + import { Route as FeedRouteImport } from './routes/feed' 18 16 import { Route as IndexRouteImport } from './routes/index' 17 + import { Route as JwksRouteImport } from './routes/jwks' 18 + import { Route as LoginRouteImport } from './routes/login' 19 + import { Route as LogoutRouteImport } from './routes/logout' 19 20 21 + const LogoutRoute = LogoutRouteImport.update({ 22 + id: '/logout', 23 + path: '/logout', 24 + getParentRoute: () => rootRouteImport, 25 + } as any) 20 26 const LoginRoute = LoginRouteImport.update({ 21 - id: '/login', 22 - path: '/login', 23 - getParentRoute: () => rootRouteImport, 27 + id: '/login', 28 + path: '/login', 29 + getParentRoute: () => rootRouteImport, 24 30 } as any) 25 31 const JwksRoute = JwksRouteImport.update({ 26 - id: '/jwks', 27 - path: '/jwks', 28 - getParentRoute: () => rootRouteImport, 32 + id: '/jwks', 33 + path: '/jwks', 34 + getParentRoute: () => rootRouteImport, 29 35 } as any) 30 36 const FeedRoute = FeedRouteImport.update({ 31 - id: '/feed', 32 - path: '/feed', 33 - getParentRoute: () => rootRouteImport, 37 + id: '/feed', 38 + path: '/feed', 39 + getParentRoute: () => rootRouteImport, 34 40 } as any) 35 41 const ClientMetadataRoute = ClientMetadataRouteImport.update({ 36 - id: '/client-metadata', 37 - path: '/client-metadata', 38 - getParentRoute: () => rootRouteImport, 42 + id: '/client-metadata', 43 + path: '/client-metadata', 44 + getParentRoute: () => rootRouteImport, 39 45 } as any) 40 46 const CallbackRoute = CallbackRouteImport.update({ 41 - id: '/callback', 42 - path: '/callback', 43 - getParentRoute: () => rootRouteImport, 47 + id: '/callback', 48 + path: '/callback', 49 + getParentRoute: () => rootRouteImport, 44 50 } as any) 45 51 const AboutRoute = AboutRouteImport.update({ 46 - id: '/about', 47 - path: '/about', 48 - getParentRoute: () => rootRouteImport, 52 + id: '/about', 53 + path: '/about', 54 + getParentRoute: () => rootRouteImport, 49 55 } as any) 50 56 const IndexRoute = IndexRouteImport.update({ 51 - id: '/', 52 - path: '/', 53 - getParentRoute: () => rootRouteImport, 57 + id: '/', 58 + path: '/', 59 + getParentRoute: () => rootRouteImport, 54 60 } as any) 55 61 56 62 export interface FileRoutesByFullPath { 57 - '/': typeof IndexRoute 58 - '/about': typeof AboutRoute 59 - '/callback': typeof CallbackRoute 60 - '/client-metadata': typeof ClientMetadataRoute 61 - '/feed': typeof FeedRoute 62 - '/jwks': typeof JwksRoute 63 - '/login': typeof LoginRoute 63 + '/': typeof IndexRoute 64 + '/about': typeof AboutRoute 65 + '/callback': typeof CallbackRoute 66 + '/client-metadata': typeof ClientMetadataRoute 67 + '/feed': typeof FeedRoute 68 + '/jwks': typeof JwksRoute 69 + '/login': typeof LoginRoute 70 + '/logout': typeof LogoutRoute 64 71 } 65 72 export interface FileRoutesByTo { 66 - '/': typeof IndexRoute 67 - '/about': typeof AboutRoute 68 - '/callback': typeof CallbackRoute 69 - '/client-metadata': typeof ClientMetadataRoute 70 - '/feed': typeof FeedRoute 71 - '/jwks': typeof JwksRoute 72 - '/login': typeof LoginRoute 73 + '/': typeof IndexRoute 74 + '/about': typeof AboutRoute 75 + '/callback': typeof CallbackRoute 76 + '/client-metadata': typeof ClientMetadataRoute 77 + '/feed': typeof FeedRoute 78 + '/jwks': typeof JwksRoute 79 + '/login': typeof LoginRoute 80 + '/logout': typeof LogoutRoute 73 81 } 74 82 export interface FileRoutesById { 75 - __root__: typeof rootRouteImport 76 - '/': typeof IndexRoute 77 - '/about': typeof AboutRoute 78 - '/callback': typeof CallbackRoute 79 - '/client-metadata': typeof ClientMetadataRoute 80 - '/feed': typeof FeedRoute 81 - '/jwks': typeof JwksRoute 82 - '/login': typeof LoginRoute 83 + __root__: typeof rootRouteImport 84 + '/': typeof IndexRoute 85 + '/about': typeof AboutRoute 86 + '/callback': typeof CallbackRoute 87 + '/client-metadata': typeof ClientMetadataRoute 88 + '/feed': typeof FeedRoute 89 + '/jwks': typeof JwksRoute 90 + '/login': typeof LoginRoute 91 + '/logout': typeof LogoutRoute 83 92 } 84 93 export interface FileRouteTypes { 85 - fileRoutesByFullPath: FileRoutesByFullPath 86 - fullPaths: 87 - | '/' 88 - | '/about' 89 - | '/callback' 90 - | '/client-metadata' 91 - | '/feed' 92 - | '/jwks' 93 - | '/login' 94 - fileRoutesByTo: FileRoutesByTo 95 - to: 96 - | '/' 97 - | '/about' 98 - | '/callback' 99 - | '/client-metadata' 100 - | '/feed' 101 - | '/jwks' 102 - | '/login' 103 - id: 104 - | '__root__' 105 - | '/' 106 - | '/about' 107 - | '/callback' 108 - | '/client-metadata' 109 - | '/feed' 110 - | '/jwks' 111 - | '/login' 112 - fileRoutesById: FileRoutesById 94 + fileRoutesByFullPath: FileRoutesByFullPath 95 + fullPaths: 96 + | '/' 97 + | '/about' 98 + | '/callback' 99 + | '/client-metadata' 100 + | '/feed' 101 + | '/jwks' 102 + | '/login' 103 + | '/logout' 104 + fileRoutesByTo: FileRoutesByTo 105 + to: 106 + | '/' 107 + | '/about' 108 + | '/callback' 109 + | '/client-metadata' 110 + | '/feed' 111 + | '/jwks' 112 + | '/login' 113 + | '/logout' 114 + id: 115 + | '__root__' 116 + | '/' 117 + | '/about' 118 + | '/callback' 119 + | '/client-metadata' 120 + | '/feed' 121 + | '/jwks' 122 + | '/login' 123 + | '/logout' 124 + fileRoutesById: FileRoutesById 113 125 } 114 126 export interface RootRouteChildren { 115 - IndexRoute: typeof IndexRoute 116 - AboutRoute: typeof AboutRoute 117 - CallbackRoute: typeof CallbackRoute 118 - ClientMetadataRoute: typeof ClientMetadataRoute 119 - FeedRoute: typeof FeedRoute 120 - JwksRoute: typeof JwksRoute 121 - LoginRoute: typeof LoginRoute 127 + IndexRoute: typeof IndexRoute 128 + AboutRoute: typeof AboutRoute 129 + CallbackRoute: typeof CallbackRoute 130 + ClientMetadataRoute: typeof ClientMetadataRoute 131 + FeedRoute: typeof FeedRoute 132 + JwksRoute: typeof JwksRoute 133 + LoginRoute: typeof LoginRoute 134 + LogoutRoute: typeof LogoutRoute 122 135 } 123 136 124 137 declare module '@tanstack/react-router' { 125 - interface FileRoutesByPath { 126 - '/login': { 127 - id: '/login' 128 - path: '/login' 129 - fullPath: '/login' 130 - preLoaderRoute: typeof LoginRouteImport 131 - parentRoute: typeof rootRouteImport 132 - } 133 - '/jwks': { 134 - id: '/jwks' 135 - path: '/jwks' 136 - fullPath: '/jwks' 137 - preLoaderRoute: typeof JwksRouteImport 138 - parentRoute: typeof rootRouteImport 139 - } 140 - '/feed': { 141 - id: '/feed' 142 - path: '/feed' 143 - fullPath: '/feed' 144 - preLoaderRoute: typeof FeedRouteImport 145 - parentRoute: typeof rootRouteImport 146 - } 147 - '/client-metadata': { 148 - id: '/client-metadata' 149 - path: '/client-metadata' 150 - fullPath: '/client-metadata' 151 - preLoaderRoute: typeof ClientMetadataRouteImport 152 - parentRoute: typeof rootRouteImport 153 - } 154 - '/callback': { 155 - id: '/callback' 156 - path: '/callback' 157 - fullPath: '/callback' 158 - preLoaderRoute: typeof CallbackRouteImport 159 - parentRoute: typeof rootRouteImport 160 - } 161 - '/about': { 162 - id: '/about' 163 - path: '/about' 164 - fullPath: '/about' 165 - preLoaderRoute: typeof AboutRouteImport 166 - parentRoute: typeof rootRouteImport 167 - } 168 - '/': { 169 - id: '/' 170 - path: '/' 171 - fullPath: '/' 172 - preLoaderRoute: typeof IndexRouteImport 173 - parentRoute: typeof rootRouteImport 174 - } 175 - } 138 + interface FileRoutesByPath { 139 + '/logout': { 140 + id: '/logout' 141 + path: '/logout' 142 + fullPath: '/logout' 143 + preLoaderRoute: typeof LogoutRouteImport 144 + parentRoute: typeof rootRouteImport 145 + } 146 + '/login': { 147 + id: '/login' 148 + path: '/login' 149 + fullPath: '/login' 150 + preLoaderRoute: typeof LoginRouteImport 151 + parentRoute: typeof rootRouteImport 152 + } 153 + '/jwks': { 154 + id: '/jwks' 155 + path: '/jwks' 156 + fullPath: '/jwks' 157 + preLoaderRoute: typeof JwksRouteImport 158 + parentRoute: typeof rootRouteImport 159 + } 160 + '/feed': { 161 + id: '/feed' 162 + path: '/feed' 163 + fullPath: '/feed' 164 + preLoaderRoute: typeof FeedRouteImport 165 + parentRoute: typeof rootRouteImport 166 + } 167 + '/client-metadata': { 168 + id: '/client-metadata' 169 + path: '/client-metadata' 170 + fullPath: '/client-metadata' 171 + preLoaderRoute: typeof ClientMetadataRouteImport 172 + parentRoute: typeof rootRouteImport 173 + } 174 + '/callback': { 175 + id: '/callback' 176 + path: '/callback' 177 + fullPath: '/callback' 178 + preLoaderRoute: typeof CallbackRouteImport 179 + parentRoute: typeof rootRouteImport 180 + } 181 + '/about': { 182 + id: '/about' 183 + path: '/about' 184 + fullPath: '/about' 185 + preLoaderRoute: typeof AboutRouteImport 186 + parentRoute: typeof rootRouteImport 187 + } 188 + '/': { 189 + id: '/' 190 + path: '/' 191 + fullPath: '/' 192 + preLoaderRoute: typeof IndexRouteImport 193 + parentRoute: typeof rootRouteImport 194 + } 195 + } 176 196 } 177 197 178 198 const rootRouteChildren: RootRouteChildren = { 179 - IndexRoute: IndexRoute, 180 - AboutRoute: AboutRoute, 181 - CallbackRoute: CallbackRoute, 182 - ClientMetadataRoute: ClientMetadataRoute, 183 - FeedRoute: FeedRoute, 184 - JwksRoute: JwksRoute, 185 - LoginRoute: LoginRoute, 199 + IndexRoute: IndexRoute, 200 + AboutRoute: AboutRoute, 201 + CallbackRoute: CallbackRoute, 202 + ClientMetadataRoute: ClientMetadataRoute, 203 + FeedRoute: FeedRoute, 204 + JwksRoute: JwksRoute, 205 + LoginRoute: LoginRoute, 206 + LogoutRoute: LogoutRoute, 186 207 } 187 208 export const routeTree = rootRouteImport 188 - ._addFileChildren(rootRouteChildren) 189 - ._addFileTypes<FileRouteTypes>() 209 + ._addFileChildren(rootRouteChildren) 210 + ._addFileTypes<FileRouteTypes>() 190 211 191 212 import type { getRouter } from './router.tsx' 192 213 import type { startInstance } from './start.ts' 214 + 193 215 declare module '@tanstack/react-start' { 194 - interface Register { 195 - ssr: true 196 - router: Awaited<ReturnType<typeof getRouter>> 197 - config: Awaited<ReturnType<typeof startInstance.getOptions>> 198 - } 216 + interface Register { 217 + ssr: true 218 + router: Awaited<ReturnType<typeof getRouter>> 219 + config: Awaited<ReturnType<typeof startInstance.getOptions>> 220 + } 199 221 }
+10 -10
src/router.tsx
··· 2 2 import { routeTree } from './routeTree.gen' 3 3 4 4 export function getRouter() { 5 - const router = createTanStackRouter({ 6 - routeTree, 7 - scrollRestoration: true, 8 - defaultPreload: 'intent', 9 - defaultPreloadStaleTime: 0, 10 - }) 5 + const router = createTanStackRouter({ 6 + routeTree, 7 + scrollRestoration: true, 8 + defaultPreload: 'intent', 9 + defaultPreloadStaleTime: 0, 10 + }) 11 11 12 - return router 12 + return router 13 13 } 14 14 15 15 declare module '@tanstack/react-router' { 16 - interface Register { 17 - router: ReturnType<typeof getRouter> 18 - } 16 + interface Register { 17 + router: ReturnType<typeof getRouter> 18 + } 19 19 }
+73 -49
src/routes/__root.tsx
··· 1 - import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router' 2 - import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools' 3 1 import { TanStackDevtools } from '@tanstack/react-devtools' 2 + import { 3 + createRootRoute, 4 + HeadContent, 5 + Outlet, 6 + Scripts, 7 + } from '@tanstack/react-router' 8 + import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools' 9 + import { createServerFn } from '@tanstack/react-start' 10 + import { getCookie } from '@tanstack/react-start/server' 4 11 import Footer from '../components/Footer' 5 12 import Header from '../components/Header' 6 13 7 14 import appCss from '../styles.css?url' 8 15 9 16 const THEME_INIT_SCRIPT = `(function(){try{var stored=window.localStorage.getItem('theme');var mode=(stored==='light'||stored==='dark'||stored==='auto')?stored:'auto';var prefersDark=window.matchMedia('(prefers-color-scheme: dark)').matches;var resolved=mode==='auto'?(prefersDark?'dark':'light'):mode;var root=document.documentElement;root.classList.remove('light','dark');root.classList.add(resolved);if(mode==='auto'){root.removeAttribute('data-theme')}else{root.setAttribute('data-theme',mode)}root.style.colorScheme=resolved;}catch(e){}})();` 17 + 18 + const getAuthState = createServerFn({ method: 'GET' }).handler(async () => { 19 + const did = getCookie('did') 20 + return { did: did ?? null } 21 + }) 10 22 11 23 export const Route = createRootRoute({ 12 - head: () => ({ 13 - meta: [ 14 - { 15 - charSet: 'utf-8', 16 - }, 17 - { 18 - name: 'viewport', 19 - content: 'width=device-width, initial-scale=1', 20 - }, 21 - { 22 - title: 'Dudesky', 23 - }, 24 - ], 25 - links: [ 26 - { 27 - rel: 'stylesheet', 28 - href: appCss, 29 - }, 30 - ], 31 - }), 32 - shellComponent: RootDocument, 33 - notFoundComponent: () => <div>404 Not Found, Try Again</div>, 24 + loader: () => getAuthState(), 25 + head: () => ({ 26 + meta: [ 27 + { 28 + charSet: 'utf-8', 29 + }, 30 + { 31 + name: 'viewport', 32 + content: 'width=device-width, initial-scale=1', 33 + }, 34 + { 35 + title: 'Dudesky', 36 + }, 37 + ], 38 + links: [ 39 + { 40 + rel: 'stylesheet', 41 + href: appCss, 42 + }, 43 + ], 44 + }), 45 + shellComponent: RootDocument, 46 + component: RootLayout, 47 + notFoundComponent: () => <div>404 Not Found, Try Again</div>, 34 48 }) 35 49 36 50 function RootDocument({ children }: { children: React.ReactNode }) { 37 - return ( 38 - <html lang="en" suppressHydrationWarning> 39 - <head> 40 - <script dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }} /> 41 - <HeadContent /> 42 - </head> 43 - <body className="font-sans antialiased [overflow-wrap:anywhere] selection:bg-[rgba(79,184,178,0.24)]"> 44 - <Header /> 45 - {children} 46 - <Footer /> 47 - <TanStackDevtools 48 - config={{ 49 - position: 'bottom-right', 50 - }} 51 - plugins={[ 52 - { 53 - name: 'Tanstack Router', 54 - render: <TanStackRouterDevtoolsPanel />, 55 - }, 56 - ]} 57 - /> 58 - <Scripts /> 59 - </body> 60 - </html> 61 - ) 51 + return ( 52 + <html lang="en" suppressHydrationWarning> 53 + <head> 54 + {/* biome-ignore lint/security/noDangerouslySetInnerHtml: intentional theme init script */} 55 + <script dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }} /> 56 + <HeadContent /> 57 + </head> 58 + <body className="font-sans antialiased [overflow-wrap:anywhere] selection:bg-[rgba(79,184,178,0.24)]"> 59 + {children} 60 + <Scripts /> 61 + </body> 62 + </html> 63 + ) 64 + } 65 + 66 + function RootLayout() { 67 + const { did } = Route.useLoaderData() 68 + return ( 69 + <> 70 + <Header did={did} /> 71 + <Outlet /> 72 + <Footer /> 73 + <TanStackDevtools 74 + config={{ 75 + position: 'bottom-right', 76 + }} 77 + plugins={[ 78 + { 79 + name: 'Tanstack Router', 80 + render: <TanStackRouterDevtoolsPanel />, 81 + }, 82 + ]} 83 + /> 84 + </> 85 + ) 62 86 }
+16 -16
src/routes/about.tsx
··· 1 1 import { createFileRoute } from '@tanstack/react-router' 2 2 3 3 export const Route = createFileRoute('/about')({ 4 - component: About, 4 + component: About, 5 5 }) 6 6 7 7 function About() { 8 - return ( 9 - <main className="page-wrap px-4 py-12"> 10 - <section className="island-shell rounded-2xl p-6 sm:p-8"> 11 - <p className="island-kicker mb-2">About</p> 12 - <h1 className="display-title mb-3 text-4xl font-bold text-[var(--sea-ink)] sm:text-5xl"> 13 - A small starter with room to grow. 14 - </h1> 15 - <p className="m-0 max-w-3xl text-base leading-8 text-[var(--sea-ink-soft)]"> 16 - TanStack Start gives you type-safe routing, server functions, and 17 - modern SSR defaults. Use this as a clean foundation, then layer in 18 - your own routes, styling, and add-ons. 19 - </p> 20 - </section> 21 - </main> 22 - ) 8 + return ( 9 + <main className="page-wrap px-4 py-12"> 10 + <section className="island-shell rounded-2xl p-6 sm:p-8"> 11 + <p className="island-kicker mb-2">About</p> 12 + <h1 className="display-title mb-3 text-4xl font-bold text-[var(--sea-ink)] sm:text-5xl"> 13 + A small starter with room to grow. 14 + </h1> 15 + <p className="m-0 max-w-3xl text-base leading-8 text-[var(--sea-ink-soft)]"> 16 + TanStack Start gives you type-safe routing, server functions, and 17 + modern SSR defaults. Use this as a clean foundation, then layer in 18 + your own routes, styling, and add-ons. 19 + </p> 20 + </section> 21 + </main> 22 + ) 23 23 }
+21 -21
src/routes/callback.tsx
··· 2 2 import { client } from '#/lib/oauth-client' 3 3 4 4 export const Route = createFileRoute('/callback')({ 5 - server: { 6 - handlers: { 7 - GET: async ({ request }) => { 8 - try { 9 - const params = new URL(request.url).searchParams 10 - const { session } = await client.callback(params) 11 - console.log('[/callback] authenticated:', session.sub) 5 + server: { 6 + handlers: { 7 + GET: async ({ request }) => { 8 + try { 9 + const params = new URL(request.url).searchParams 10 + const { session } = await client.callback(params) 11 + console.log('[/callback] authenticated:', session.sub) 12 12 13 - return new Response(null, { 14 - status: 302, 15 - headers: { 16 - 'Location': new URL('/feed', request.url).toString(), 17 - 'Set-Cookie': `did=${encodeURIComponent(session.sub)}; Path=/; HttpOnly; SameSite=Lax`, 18 - }, 19 - }) 20 - } catch (e) { 21 - console.error('[/callback]', e) 22 - return new Response(String(e), { status: 500 }) 23 - } 24 - } 25 - } 26 - } 13 + return new Response(null, { 14 + status: 302, 15 + headers: { 16 + Location: new URL('/feed', request.url).toString(), 17 + 'Set-Cookie': `did=${encodeURIComponent(session.sub)}; Path=/; HttpOnly; SameSite=Lax`, 18 + }, 19 + }) 20 + } catch (e) { 21 + console.error('[/callback]', e) 22 + return new Response(String(e), { status: 500 }) 23 + } 24 + }, 25 + }, 26 + }, 27 27 })
+9 -9
src/routes/client-metadata.tsx
··· 2 2 import { client } from '#/lib/oauth-client' 3 3 4 4 export const Route = createFileRoute('/client-metadata')({ 5 - server: { 6 - handlers: { 7 - GET: async() => { 8 - return new Response(JSON.stringify(client.clientMetadata), { 9 - headers: { 'Content-Type': 'application/json' }, 10 - }) 11 - } 12 - } 13 - } 5 + server: { 6 + handlers: { 7 + GET: async () => { 8 + return new Response(JSON.stringify(client.clientMetadata), { 9 + headers: { 'Content-Type': 'application/json' }, 10 + }) 11 + }, 12 + }, 13 + }, 14 14 })
+78 -43
src/routes/feed.tsx
··· 1 + import { Agent } from '@atproto/api' 1 2 import { createFileRoute, redirect } from '@tanstack/react-router' 2 3 import { createServerFn } from '@tanstack/react-start' 3 4 import { getCookie } from '@tanstack/react-start/server' 4 - import { Agent } from '@atproto/api' 5 5 import { client } from '#/lib/oauth-client' 6 6 7 7 const getTimeline = createServerFn({ method: 'GET' }).handler(async () => { 8 - const did = getCookie('did') 8 + const did = getCookie('did') 9 9 10 - if (!did) { 11 - throw redirect({ to: '/login' }) 12 - } 10 + if (!did) { 11 + throw redirect({ to: '/login' }) 12 + } 13 13 14 - const session = await client.restore(did) 15 - const agent = new Agent(session) 16 - const { data } = await agent.getTimeline({ limit: 50 }) 17 - return { 18 - feed: data.feed.map((item) => ({ 19 - uri: item.post.uri, 20 - text: (item.post.record as { text?: string }).text ?? '', 21 - author: { 22 - handle: item.post.author.handle, 23 - displayName: item.post.author.displayName ?? null, 24 - avatar: item.post.author.avatar ?? null, 25 - }, 26 - createdAt: (item.post.record as { createdAt?: string }).createdAt, 27 - })), 28 - } 14 + const session = await client.restore(did) 15 + const agent = new Agent(session) 16 + const { data } = await agent.getTimeline({ limit: 50 }) 17 + return { 18 + feed: data.feed.map((item) => ({ 19 + uri: item.post.uri, 20 + text: (item.post.record as { text?: string }).text ?? '', 21 + author: { 22 + handle: item.post.author.handle, 23 + displayName: item.post.author.displayName ?? null, 24 + avatar: item.post.author.avatar ?? null, 25 + }, 26 + createdAt: (item.post.record as { createdAt?: string }).createdAt, 27 + reply: (() => { 28 + const parent = item.reply?.parent as 29 + | { 30 + $type: string 31 + uri: string 32 + record: unknown 33 + author: { 34 + handle: string 35 + displayName?: string | null 36 + avatar?: string | null 37 + } 38 + } 39 + | undefined 40 + if (!parent || parent.$type !== 'app.bsky.feed.defs#postView') 41 + return null 42 + return { 43 + uri: parent.uri, 44 + text: (parent.record as { text?: string }).text ?? '', 45 + author: { 46 + handle: parent.author.handle, 47 + displayName: parent.author.displayName ?? null, 48 + avatar: parent.author.avatar ?? null, 49 + }, 50 + } 51 + })(), 52 + })), 53 + } 29 54 }) 30 55 31 56 export const Route = createFileRoute('/feed')({ 32 - loader: () => getTimeline(), 33 - component: FeedPage, 57 + loader: () => getTimeline(), 58 + component: FeedPage, 34 59 }) 35 60 36 61 function FeedPage() { 37 - const { feed } = Route.useLoaderData() 62 + const { feed } = Route.useLoaderData() 38 63 39 - return ( 40 - <div className="bg-[--bg-base] max-w-2xl mx-auto py-8 px-4 space-y-4"> 41 - {feed.map((item) => ( 42 - <article key={item.uri} className="island-shell p-4 space-y-2"> 43 - <div className="flex items-center gap-2"> 44 - {item.author.avatar && ( 45 - <img src={item.author.avatar} alt="" className="w-8 h-8 rounded-full" /> 46 - )} 47 - <div> 48 - <span className="font-semibold">{item.author.displayName ?? item.author.handle}</span> 49 - <span className="text-sm text-[--sea-ink-soft] mx-1">@{item.author.handle}</span> 50 - || 51 - <span className="text-sm text-[--sea-ink-soft] ml-1">{item.createdAt}</span> 52 - </div> 53 - </div> 54 - <p className="whitespace-pre-wrap">{item.text}</p> 55 - </article> 56 - ))} 57 - </div> 58 - ) 64 + return ( 65 + <div className="bg-[--bg-base] max-w-2xl mx-auto py-8 px-4 space-y-4"> 66 + {feed.map((item) => ( 67 + <article key={item.uri} className="island-shell p-4 space-y-2"> 68 + <div className="flex items-center gap-2"> 69 + {item.author.avatar && ( 70 + <img 71 + src={item.author.avatar} 72 + alt="" 73 + className="w-8 h-8 rounded-full" 74 + /> 75 + )} 76 + <div> 77 + <span className="font-semibold"> 78 + {item.author.displayName ?? item.author.handle} 79 + </span> 80 + <span className="text-sm text-[--sea-ink-soft] mx-1"> 81 + @{item.author.handle} 82 + </span> 83 + || 84 + <span className="text-sm text-[--sea-ink-soft] ml-1"> 85 + {item.createdAt} 86 + </span> 87 + </div> 88 + </div> 89 + <p className="whitespace-pre-wrap">{item.text}</p> 90 + </article> 91 + ))} 92 + </div> 93 + ) 59 94 }
+79 -79
src/routes/index.tsx
··· 3 3 export const Route = createFileRoute('/')({ component: App }) 4 4 5 5 function App() { 6 - return ( 7 - <main className="page-wrap px-4 pb-8 pt-14"> 8 - <section className="island-shell rise-in relative overflow-hidden rounded-[2rem] px-6 py-10 sm:px-10 sm:py-14"> 9 - <div className="pointer-events-none absolute -left-20 -top-24 h-56 w-56 rounded-full bg-[radial-gradient(circle,rgba(79,184,178,0.32),transparent_66%)]" /> 10 - <div className="pointer-events-none absolute -bottom-20 -right-20 h-56 w-56 rounded-full bg-[radial-gradient(circle,rgba(47,106,74,0.18),transparent_66%)]" /> 11 - <p className="island-kicker mb-3">TanStack Start Base Template</p> 12 - <h1 className="display-title mb-5 max-w-3xl text-4xl leading-[1.02] font-bold tracking-tight text-[var(--sea-ink)] sm:text-6xl"> 13 - Start simple, ship quickly. 14 - </h1> 15 - <p className="mb-8 max-w-2xl text-base text-[var(--sea-ink-soft)] sm:text-lg"> 16 - This base starter intentionally keeps things light: two routes, clean 17 - structure, and the essentials you need to build from scratch. 18 - </p> 19 - <div className="flex flex-wrap gap-3"> 20 - <a 21 - href="/about" 22 - className="rounded-full border border-[rgba(50,143,151,0.3)] bg-[rgba(79,184,178,0.14)] px-5 py-2.5 text-sm font-semibold text-[var(--lagoon-deep)] no-underline transition hover:-translate-y-0.5 hover:bg-[rgba(79,184,178,0.24)]" 23 - > 24 - About This Starter 25 - </a> 26 - <a 27 - href="https://tanstack.com/router" 28 - target="_blank" 29 - rel="noopener noreferrer" 30 - className="rounded-full border border-[rgba(23,58,64,0.2)] bg-white/50 px-5 py-2.5 text-sm font-semibold text-[var(--sea-ink)] no-underline transition hover:-translate-y-0.5 hover:border-[rgba(23,58,64,0.35)]" 31 - > 32 - Router Guide 33 - </a> 34 - </div> 35 - </section> 6 + return ( 7 + <main className="page-wrap px-4 pb-8 pt-14"> 8 + <section className="island-shell rise-in relative overflow-hidden rounded-[2rem] px-6 py-10 sm:px-10 sm:py-14"> 9 + <div className="pointer-events-none absolute -left-20 -top-24 h-56 w-56 rounded-full bg-[radial-gradient(circle,rgba(79,184,178,0.32),transparent_66%)]" /> 10 + <div className="pointer-events-none absolute -bottom-20 -right-20 h-56 w-56 rounded-full bg-[radial-gradient(circle,rgba(47,106,74,0.18),transparent_66%)]" /> 11 + <p className="island-kicker mb-3">TanStack Start Base Template</p> 12 + <h1 className="display-title mb-5 max-w-3xl text-4xl leading-[1.02] font-bold tracking-tight text-[var(--sea-ink)] sm:text-6xl"> 13 + Start simple, ship quickly. 14 + </h1> 15 + <p className="mb-8 max-w-2xl text-base text-[var(--sea-ink-soft)] sm:text-lg"> 16 + This base starter intentionally keeps things light: two routes, clean 17 + structure, and the essentials you need to build from scratch. 18 + </p> 19 + <div className="flex flex-wrap gap-3"> 20 + <a 21 + href="/about" 22 + className="rounded-full border border-[rgba(50,143,151,0.3)] bg-[rgba(79,184,178,0.14)] px-5 py-2.5 text-sm font-semibold text-[var(--lagoon-deep)] no-underline transition hover:-translate-y-0.5 hover:bg-[rgba(79,184,178,0.24)]" 23 + > 24 + About This Starter 25 + </a> 26 + <a 27 + href="https://tanstack.com/router" 28 + target="_blank" 29 + rel="noopener noreferrer" 30 + className="rounded-full border border-[rgba(23,58,64,0.2)] bg-white/50 px-5 py-2.5 text-sm font-semibold text-[var(--sea-ink)] no-underline transition hover:-translate-y-0.5 hover:border-[rgba(23,58,64,0.35)]" 31 + > 32 + Router Guide 33 + </a> 34 + </div> 35 + </section> 36 36 37 - <section className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> 38 - {[ 39 - [ 40 - 'Type-Safe Routing', 41 - 'Routes and links stay in sync across every page.', 42 - ], 43 - [ 44 - 'Server Functions', 45 - 'Call server code from your UI without creating API boilerplate.', 46 - ], 47 - [ 48 - 'Streaming by Default', 49 - 'Ship progressively rendered responses for faster experiences.', 50 - ], 51 - [ 52 - 'Tailwind Native', 53 - 'Design quickly with utility-first styling and reusable tokens.', 54 - ], 55 - ].map(([title, desc], index) => ( 56 - <article 57 - key={title} 58 - className="island-shell feature-card rise-in rounded-2xl p-5" 59 - style={{ animationDelay: `${index * 90 + 80}ms` }} 60 - > 61 - <h2 className="mb-2 text-base font-semibold text-[var(--sea-ink)]"> 62 - {title} 63 - </h2> 64 - <p className="m-0 text-sm text-[var(--sea-ink-soft)]">{desc}</p> 65 - </article> 66 - ))} 67 - </section> 37 + <section className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> 38 + {[ 39 + [ 40 + 'Type-Safe Routing', 41 + 'Routes and links stay in sync across every page.', 42 + ], 43 + [ 44 + 'Server Functions', 45 + 'Call server code from your UI without creating API boilerplate.', 46 + ], 47 + [ 48 + 'Streaming by Default', 49 + 'Ship progressively rendered responses for faster experiences.', 50 + ], 51 + [ 52 + 'Tailwind Native', 53 + 'Design quickly with utility-first styling and reusable tokens.', 54 + ], 55 + ].map(([title, desc], index) => ( 56 + <article 57 + key={title} 58 + className="island-shell feature-card rise-in rounded-2xl p-5" 59 + style={{ animationDelay: `${index * 90 + 80}ms` }} 60 + > 61 + <h2 className="mb-2 text-base font-semibold text-[var(--sea-ink)]"> 62 + {title} 63 + </h2> 64 + <p className="m-0 text-sm text-[var(--sea-ink-soft)]">{desc}</p> 65 + </article> 66 + ))} 67 + </section> 68 68 69 - <section className="island-shell mt-8 rounded-2xl p-6"> 70 - <p className="island-kicker mb-2">Quick Start</p> 71 - <ul className="m-0 list-disc space-y-2 pl-5 text-sm text-[var(--sea-ink-soft)]"> 72 - <li> 73 - Edit <code>src/routes/index.tsx</code> to customize the home page. 74 - </li> 75 - <li> 76 - Update <code>src/components/Header.tsx</code> and{' '} 77 - <code>src/components/Footer.tsx</code> for brand links. 78 - </li> 79 - <li> 80 - Add routes in <code>src/routes</code> and tweak visual tokens in{' '} 81 - <code>src/styles.css</code>. 82 - </li> 83 - </ul> 84 - </section> 85 - </main> 86 - ) 69 + <section className="island-shell mt-8 rounded-2xl p-6"> 70 + <p className="island-kicker mb-2">Quick Start</p> 71 + <ul className="m-0 list-disc space-y-2 pl-5 text-sm text-[var(--sea-ink-soft)]"> 72 + <li> 73 + Edit <code>src/routes/index.tsx</code> to customize the home page. 74 + </li> 75 + <li> 76 + Update <code>src/components/Header.tsx</code> and{' '} 77 + <code>src/components/Footer.tsx</code> for brand links. 78 + </li> 79 + <li> 80 + Add routes in <code>src/routes</code> and tweak visual tokens in{' '} 81 + <code>src/styles.css</code>. 82 + </li> 83 + </ul> 84 + </section> 85 + </main> 86 + ) 87 87 }
+9 -9
src/routes/jwks.tsx
··· 2 2 import { client } from '#/lib/oauth-client' 3 3 4 4 export const Route = createFileRoute('/jwks')({ 5 - server: { 6 - handlers: { 7 - GET: async () => { 8 - return new Response(JSON.stringify(client.jwks), { 9 - headers: { 'Content-Type': 'application/json' }, 10 - }) 11 - } 12 - } 13 - } 5 + server: { 6 + handlers: { 7 + GET: async () => { 8 + return new Response(JSON.stringify(client.jwks), { 9 + headers: { 'Content-Type': 'application/json' }, 10 + }) 11 + }, 12 + }, 13 + }, 14 14 })
+46 -24
src/routes/login.tsx
··· 2 2 import { client } from '#/lib/oauth-client' 3 3 4 4 function LoginPage() { 5 - return ( 6 - <form method="POST" action="/login"> 7 - <input name="handle" placeholder="you.bsky.social" /> 8 - <button type="submit">Login with Bluesky</button> 9 - </form> 10 - ) 5 + return ( 6 + <div className="min-h-[calc(100vh-var(--header-height,64px))] flex items-center justify-center"> 7 + <div className="island-shell p-8 w-full max-w-sm"> 8 + <div className="space-y-1 mb-6"> 9 + <h1 className="display-title text-3xl font-semibold text-[--sea-ink]"> 10 + Dudesky 11 + </h1> 12 + <p className="text-sm text-[--sea-ink-soft]"> 13 + Sign in with your Atmosphere account 14 + </p> 15 + </div> 16 + 17 + <form method="POST" action="/login" className="space-y-3"> 18 + <input 19 + className="border border-[--line] bg-[--surface] rounded-lg px-4 py-2 w-full focus:outline-none focus:ring-2 focus:ring-[--lagoon]" 20 + name="handle" 21 + placeholder="you.bsky.social" 22 + /> 23 + <button 24 + className="bg-[--lagoon] text-white rounded-lg px-4 py-2 w-full font-semibold hover:bg-[--lagoon-deep] transition-colors" 25 + type="submit" 26 + > 27 + Login with Atmosphere 28 + </button> 29 + </form> 30 + </div> 31 + </div> 32 + ) 11 33 } 12 34 13 35 export const Route = createFileRoute('/login')({ 14 - component: LoginPage, 15 - server: { 16 - handlers: { 17 - POST: async ({ request }) => { 18 - try { 19 - const body = await request.formData() 20 - const handle = body.get('handle') as string 21 - const url = await client.authorize(handle, { 22 - scope: 'atproto transition:generic', 23 - }) 24 - return Response.redirect(url.toString(), 302) 25 - } catch (e) { 26 - console.error('[/login POST]', e) 27 - return new Response(String(e), { status: 500 }) 28 - } 29 - } 30 - } 31 - } 36 + component: LoginPage, 37 + server: { 38 + handlers: { 39 + POST: async ({ request }) => { 40 + try { 41 + const body = await request.formData() 42 + const handle = body.get('handle') as string 43 + const url = await client.authorize(handle, { 44 + scope: 'atproto transition:generic', 45 + }) 46 + return Response.redirect(url.toString(), 302) 47 + } catch (e) { 48 + console.error('[/login POST]', e) 49 + return new Response(String(e), { status: 500 }) 50 + } 51 + }, 52 + }, 53 + }, 32 54 })
+18
src/routes/logout.tsx
··· 1 + import { createFileRoute } from '@tanstack/react-router' 2 + 3 + export const Route = createFileRoute('/logout')({ 4 + component: () => null, 5 + server: { 6 + handlers: { 7 + POST: async ({ request }) => { 8 + return new Response(null, { 9 + status: 302, 10 + headers: { 11 + Location: new URL('/login', request.url).toString(), 12 + 'Set-Cookie': 'did=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0', 13 + }, 14 + }) 15 + }, 16 + }, 17 + }, 18 + })
+18 -18
src/start.ts
··· 1 - import { createStart, createMiddleware } from '@tanstack/react-start' 1 + import { createMiddleware, createStart } from '@tanstack/react-start' 2 2 3 3 const isProd = process.env.NODE_ENV === 'production' 4 4 5 5 const requestLogger = createMiddleware({ type: 'request' }).server( 6 - async ({ next, request }) => { 7 - const { method, url } = request 8 - const pathname = new URL(url).pathname 9 - const start = Date.now() 10 - const result = await next() 11 - const status = result.response.status 12 - const ms = Date.now() - start 6 + async ({ next, request }) => { 7 + const { method, url } = request 8 + const pathname = new URL(url).pathname 9 + const start = Date.now() 10 + const result = await next() 11 + const status = result.response.status 12 + const ms = Date.now() - start 13 13 14 - if (isProd) { 15 - console.log(JSON.stringify({ method, pathname, status, ms })) 16 - } else { 17 - console.log(`req: ${method} ${pathname}`) 18 - console.log(`res: ${method} ${pathname} ${status} (${ms}ms)`) 19 - } 14 + if (isProd) { 15 + console.log(JSON.stringify({ method, pathname, status, ms })) 16 + } else { 17 + console.log(`req: ${method} ${pathname}`) 18 + console.log(`res: ${method} ${pathname} ${status} (${ms}ms)`) 19 + } 20 20 21 - return result 22 - } 21 + return result 22 + }, 23 23 ) 24 24 25 25 export const startInstance = createStart(() => ({ 26 - requestMiddleware: [requestLogger], 27 - })) 26 + requestMiddleware: [requestLogger], 27 + }))
+185 -166
src/styles.css
··· 3 3 @plugin "@tailwindcss/typography"; 4 4 5 5 @theme { 6 - --font-sans: "Manrope", ui-sans-serif, system-ui, sans-serif; 6 + --font-sans: "Manrope", ui-sans-serif, system-ui, sans-serif; 7 7 } 8 8 9 9 :root { 10 - --sea-ink: #173a40; 11 - --sea-ink-soft: #416166; 12 - --lagoon: #4fb8b2; 13 - --lagoon-deep: #328f97; 14 - --palm: #2f6a4a; 15 - --sand: #e7f0e8; 16 - --foam: #f3faf5; 17 - --surface: rgba(255, 255, 255, 0.74); 18 - --surface-strong: rgba(255, 255, 255, 0.9); 19 - --line: rgba(23, 58, 64, 0.14); 20 - --inset-glint: rgba(255, 255, 255, 0.82); 21 - --kicker: rgba(47, 106, 74, 0.9); 22 - --bg-base: #e7f3ec; 23 - --header-bg: rgba(251, 255, 248, 0.84); 24 - --chip-bg: rgba(255, 255, 255, 0.8); 25 - --chip-line: rgba(47, 106, 74, 0.18); 26 - --link-bg-hover: rgba(255, 255, 255, 0.9); 27 - --hero-a: rgba(79, 184, 178, 0.36); 28 - --hero-b: rgba(47, 106, 74, 0.2); 10 + --sea-ink: #173a40; 11 + --sea-ink-soft: #416166; 12 + --lagoon: #4fb8b2; 13 + --lagoon-deep: #328f97; 14 + --palm: #2f6a4a; 15 + --sand: #e7f0e8; 16 + --foam: #f3faf5; 17 + --surface: rgba(255, 255, 255, 0.74); 18 + --surface-strong: rgba(255, 255, 255, 0.9); 19 + --line: rgba(23, 58, 64, 0.14); 20 + --inset-glint: rgba(255, 255, 255, 0.82); 21 + --kicker: rgba(47, 106, 74, 0.9); 22 + --bg-base: #e7f3ec; 23 + --header-bg: rgba(251, 255, 248, 0.84); 24 + --chip-bg: rgba(255, 255, 255, 0.8); 25 + --chip-line: rgba(47, 106, 74, 0.18); 26 + --link-bg-hover: rgba(255, 255, 255, 0.9); 27 + --hero-a: rgba(79, 184, 178, 0.36); 28 + --hero-b: rgba(47, 106, 74, 0.2); 29 29 } 30 30 31 31 :root[data-theme="dark"] { 32 - --sea-ink: #d7ece8; 33 - --sea-ink-soft: #afcdc8; 34 - --lagoon: #60d7cf; 35 - --lagoon-deep: #8de5db; 36 - --palm: #6ec89a; 37 - --sand: #0f1a1e; 38 - --foam: #101d22; 39 - --surface: rgba(16, 30, 34, 0.8); 40 - --surface-strong: rgba(15, 27, 31, 0.92); 41 - --line: rgba(141, 229, 219, 0.18); 42 - --inset-glint: rgba(194, 247, 238, 0.14); 43 - --kicker: #b8efe5; 44 - --bg-base: #0a1418; 45 - --header-bg: rgba(10, 20, 24, 0.8); 46 - --chip-bg: rgba(13, 28, 32, 0.9); 47 - --chip-line: rgba(141, 229, 219, 0.24); 48 - --link-bg-hover: rgba(24, 44, 49, 0.8); 49 - --hero-a: rgba(96, 215, 207, 0.18); 50 - --hero-b: rgba(110, 200, 154, 0.12); 32 + --sea-ink: #d7ece8; 33 + --sea-ink-soft: #afcdc8; 34 + --lagoon: #60d7cf; 35 + --lagoon-deep: #8de5db; 36 + --palm: #6ec89a; 37 + --sand: #0f1a1e; 38 + --foam: #101d22; 39 + --surface: rgba(16, 30, 34, 0.8); 40 + --surface-strong: rgba(15, 27, 31, 0.92); 41 + --line: rgba(141, 229, 219, 0.18); 42 + --inset-glint: rgba(194, 247, 238, 0.14); 43 + --kicker: #b8efe5; 44 + --bg-base: #0a1418; 45 + --header-bg: rgba(10, 20, 24, 0.8); 46 + --chip-bg: rgba(13, 28, 32, 0.9); 47 + --chip-line: rgba(141, 229, 219, 0.24); 48 + --link-bg-hover: rgba(24, 44, 49, 0.8); 49 + --hero-a: rgba(96, 215, 207, 0.18); 50 + --hero-b: rgba(110, 200, 154, 0.12); 51 51 } 52 52 53 53 @media (prefers-color-scheme: dark) { 54 - :root:not([data-theme="light"]) { 55 - --sea-ink: #d7ece8; 56 - --sea-ink-soft: #afcdc8; 57 - --lagoon: #60d7cf; 58 - --lagoon-deep: #8de5db; 59 - --palm: #6ec89a; 60 - --sand: #0f1a1e; 61 - --foam: #101d22; 62 - --surface: rgba(16, 30, 34, 0.8); 63 - --surface-strong: rgba(15, 27, 31, 0.92); 64 - --line: rgba(141, 229, 219, 0.18); 65 - --inset-glint: rgba(194, 247, 238, 0.14); 66 - --kicker: #b8efe5; 67 - --bg-base: #0a1418; 68 - --header-bg: rgba(10, 20, 24, 0.8); 69 - --chip-bg: rgba(13, 28, 32, 0.9); 70 - --chip-line: rgba(141, 229, 219, 0.24); 71 - --link-bg-hover: rgba(24, 44, 49, 0.8); 72 - --hero-a: rgba(96, 215, 207, 0.18); 73 - --hero-b: rgba(110, 200, 154, 0.12); 74 - } 54 + :root:not([data-theme="light"]) { 55 + --sea-ink: #d7ece8; 56 + --sea-ink-soft: #afcdc8; 57 + --lagoon: #60d7cf; 58 + --lagoon-deep: #8de5db; 59 + --palm: #6ec89a; 60 + --sand: #0f1a1e; 61 + --foam: #101d22; 62 + --surface: rgba(16, 30, 34, 0.8); 63 + --surface-strong: rgba(15, 27, 31, 0.92); 64 + --line: rgba(141, 229, 219, 0.18); 65 + --inset-glint: rgba(194, 247, 238, 0.14); 66 + --kicker: #b8efe5; 67 + --bg-base: #0a1418; 68 + --header-bg: rgba(10, 20, 24, 0.8); 69 + --chip-bg: rgba(13, 28, 32, 0.9); 70 + --chip-line: rgba(141, 229, 219, 0.24); 71 + --link-bg-hover: rgba(24, 44, 49, 0.8); 72 + --hero-a: rgba(96, 215, 207, 0.18); 73 + --hero-b: rgba(110, 200, 154, 0.12); 74 + } 75 75 } 76 76 77 77 * { 78 - box-sizing: border-box; 78 + box-sizing: border-box; 79 79 } 80 80 81 81 html, 82 82 body, 83 83 #app { 84 - min-height: 100%; 84 + min-height: 100%; 85 85 } 86 86 87 87 body { 88 - margin: 0; 89 - color: var(--sea-ink); 90 - font-family: var(--font-sans); 91 - background-color: var(--bg-base); 92 - background: 93 - radial-gradient(1100px 620px at -8% -10%, var(--hero-a), transparent 58%), 94 - radial-gradient(1050px 620px at 112% -12%, var(--hero-b), transparent 62%), 95 - radial-gradient(720px 380px at 50% 115%, rgba(79, 184, 178, 0.1), transparent 68%), 96 - linear-gradient(180deg, color-mix(in oklab, var(--sand) 68%, white) 0%, var(--foam) 44%, var(--bg-base) 100%); 97 - overflow-x: hidden; 98 - -webkit-font-smoothing: antialiased; 99 - -moz-osx-font-smoothing: grayscale; 88 + margin: 0; 89 + color: var(--sea-ink); 90 + font-family: var(--font-sans); 91 + background: 92 + radial-gradient(1100px 620px at -8% -10%, var(--hero-a), transparent 58%), 93 + radial-gradient(1050px 620px at 112% -12%, var(--hero-b), transparent 62%), 94 + radial-gradient( 95 + 720px 380px at 50% 115%, 96 + rgba(79, 184, 178, 0.1), 97 + transparent 68% 98 + ), 99 + linear-gradient( 100 + 180deg, 101 + color-mix(in oklab, var(--sand) 68%, white) 0%, 102 + var(--foam) 44%, 103 + var(--bg-base) 100% 104 + ); 105 + overflow-x: hidden; 106 + -webkit-font-smoothing: antialiased; 107 + -moz-osx-font-smoothing: grayscale; 100 108 } 101 109 102 110 body::before { 103 - content: ""; 104 - position: fixed; 105 - inset: 0; 106 - pointer-events: none; 107 - z-index: -1; 108 - opacity: 0.28; 109 - background: 110 - radial-gradient(circle at 20% 15%, rgba(255, 255, 255, 0.8), transparent 34%), 111 - radial-gradient(circle at 78% 26%, rgba(79, 184, 178, 0.2), transparent 42%), 112 - radial-gradient(circle at 42% 82%, rgba(47, 106, 74, 0.14), transparent 36%); 111 + content: ""; 112 + position: fixed; 113 + inset: 0; 114 + pointer-events: none; 115 + z-index: -1; 116 + opacity: 0.28; 117 + background: 118 + radial-gradient( 119 + circle at 20% 15%, 120 + rgba(255, 255, 255, 0.8), 121 + transparent 34% 122 + ), 123 + radial-gradient(circle at 78% 26%, rgba(79, 184, 178, 0.2), transparent 42%), 124 + radial-gradient(circle at 42% 82%, rgba(47, 106, 74, 0.14), transparent 36%); 113 125 } 114 126 115 127 body::after { 116 - content: ""; 117 - position: fixed; 118 - inset: 0; 119 - pointer-events: none; 120 - z-index: -1; 121 - opacity: 0.14; 122 - background-image: 123 - linear-gradient(rgba(255, 255, 255, 0.07) 1px, transparent 1px), 124 - linear-gradient(90deg, rgba(255, 255, 255, 0.06) 1px, transparent 1px); 125 - background-size: 28px 28px; 126 - mask-image: radial-gradient(circle at 50% 30%, black, transparent 78%); 128 + content: ""; 129 + position: fixed; 130 + inset: 0; 131 + pointer-events: none; 132 + z-index: -1; 133 + opacity: 0.14; 134 + background-image: 135 + linear-gradient(rgba(255, 255, 255, 0.07) 1px, transparent 1px), 136 + linear-gradient(90deg, rgba(255, 255, 255, 0.06) 1px, transparent 1px); 137 + background-size: 28px 28px; 138 + mask-image: radial-gradient(circle at 50% 30%, black, transparent 78%); 127 139 } 128 140 129 141 a { 130 - color: var(--lagoon-deep); 131 - text-decoration-color: rgba(50, 143, 151, 0.4); 132 - text-decoration-thickness: 1px; 133 - text-underline-offset: 2px; 142 + color: var(--lagoon-deep); 143 + text-decoration-color: rgba(50, 143, 151, 0.4); 144 + text-decoration-thickness: 1px; 145 + text-underline-offset: 2px; 134 146 } 135 147 136 148 a:hover { 137 - color: #246f76; 149 + color: #246f76; 138 150 } 139 151 140 152 code { 141 - font-size: 0.9em; 142 - border: 1px solid var(--line); 143 - background: color-mix(in oklab, var(--surface-strong) 82%, white 18%); 144 - border-radius: 7px; 145 - padding: 2px 7px; 153 + font-size: 0.9em; 154 + border: 1px solid var(--line); 155 + background: color-mix(in oklab, var(--surface-strong) 82%, white 18%); 156 + border-radius: 7px; 157 + padding: 2px 7px; 146 158 } 147 159 148 160 pre code { 149 - border: 0; 150 - background: transparent; 151 - padding: 0; 152 - border-radius: 0; 153 - font-size: inherit; 154 - color: inherit; 161 + border: 0; 162 + background: transparent; 163 + padding: 0; 164 + border-radius: 0; 165 + font-size: inherit; 166 + color: inherit; 155 167 } 156 168 157 169 .page-wrap { 158 - width: min(1080px, calc(100% - 2rem)); 159 - margin-inline: auto; 170 + width: min(1080px, calc(100% - 2rem)); 171 + margin-inline: auto; 160 172 } 161 173 162 174 .display-title { 163 - font-family: "Fraunces", Georgia, serif; 175 + font-family: "Fraunces", Georgia, serif; 164 176 } 165 177 166 178 .island-shell { 167 - border: 1px solid var(--line); 168 - background: linear-gradient(165deg, var(--surface-strong), var(--surface)); 169 - box-shadow: 170 - 0 1px 0 var(--inset-glint) inset, 171 - 0 22px 44px rgba(30, 90, 72, 0.1), 172 - 0 6px 18px rgba(23, 58, 64, 0.08); 173 - backdrop-filter: blur(4px); 179 + border: 1px solid var(--line); 180 + background: linear-gradient(165deg, var(--surface-strong), var(--surface)); 181 + box-shadow: 182 + 0 1px 0 var(--inset-glint) inset, 183 + 0 22px 44px rgba(30, 90, 72, 0.1), 184 + 0 6px 18px rgba(23, 58, 64, 0.08); 185 + backdrop-filter: blur(4px); 174 186 } 175 187 176 188 .feature-card { 177 - background: linear-gradient(165deg, color-mix(in oklab, var(--surface-strong) 93%, white 7%), var(--surface)); 178 - box-shadow: 179 - 0 1px 0 var(--inset-glint) inset, 180 - 0 18px 34px rgba(30, 90, 72, 0.1), 181 - 0 4px 14px rgba(23, 58, 64, 0.06); 189 + background: linear-gradient( 190 + 165deg, 191 + color-mix(in oklab, var(--surface-strong) 93%, white 7%), 192 + var(--surface) 193 + ); 194 + box-shadow: 195 + 0 1px 0 var(--inset-glint) inset, 196 + 0 18px 34px rgba(30, 90, 72, 0.1), 197 + 0 4px 14px rgba(23, 58, 64, 0.06); 182 198 } 183 199 184 200 .feature-card:hover { 185 - transform: translateY(-2px); 186 - border-color: color-mix(in oklab, var(--lagoon-deep) 35%, var(--line)); 201 + transform: translateY(-2px); 202 + border-color: color-mix(in oklab, var(--lagoon-deep) 35%, var(--line)); 187 203 } 188 204 189 205 button, 190 206 .island-shell, 191 207 a { 192 - transition: background-color 180ms ease, color 180ms ease, border-color 180ms ease, 193 - transform 180ms ease; 208 + transition: 209 + background-color 180ms ease, 210 + color 180ms ease, 211 + border-color 180ms ease, 212 + transform 180ms ease; 194 213 } 195 214 196 215 .island-kicker { 197 - letter-spacing: 0.16em; 198 - text-transform: uppercase; 199 - font-weight: 700; 200 - font-size: 0.69rem; 201 - color: var(--kicker); 216 + letter-spacing: 0.16em; 217 + text-transform: uppercase; 218 + font-weight: 700; 219 + font-size: 0.69rem; 220 + color: var(--kicker); 202 221 } 203 222 204 223 .nav-link { 205 - position: relative; 206 - display: inline-flex; 207 - align-items: center; 208 - text-decoration: none; 209 - color: var(--sea-ink-soft); 224 + position: relative; 225 + display: inline-flex; 226 + align-items: center; 227 + text-decoration: none; 228 + color: var(--sea-ink-soft); 210 229 } 211 230 212 231 .nav-link::after { 213 - content: ""; 214 - position: absolute; 215 - left: 0; 216 - bottom: -6px; 217 - width: 100%; 218 - height: 2px; 219 - transform: scaleX(0); 220 - transform-origin: left; 221 - background: linear-gradient(90deg, var(--lagoon), #7ed3bf); 222 - transition: transform 170ms ease; 232 + content: ""; 233 + position: absolute; 234 + left: 0; 235 + bottom: -6px; 236 + width: 100%; 237 + height: 2px; 238 + transform: scaleX(0); 239 + transform-origin: left; 240 + background: linear-gradient(90deg, var(--lagoon), #7ed3bf); 241 + transition: transform 170ms ease; 223 242 } 224 243 225 244 .nav-link:hover, 226 245 .nav-link.is-active { 227 - color: var(--sea-ink); 246 + color: var(--sea-ink); 228 247 } 229 248 230 249 .nav-link:hover::after, 231 250 .nav-link.is-active::after { 232 - transform: scaleX(1); 251 + transform: scaleX(1); 233 252 } 234 253 235 254 @media (max-width: 640px) { 236 - .nav-link::after { 237 - bottom: -4px; 238 - } 255 + .nav-link::after { 256 + bottom: -4px; 257 + } 239 258 } 240 259 241 260 .site-footer { 242 - border-top: 1px solid var(--line); 243 - background: color-mix(in oklab, var(--header-bg) 84%, transparent 16%); 261 + border-top: 1px solid var(--line); 262 + background: color-mix(in oklab, var(--header-bg) 84%, transparent 16%); 244 263 } 245 264 246 265 .rise-in { 247 - animation: rise-in 700ms cubic-bezier(0.16, 1, 0.3, 1) both; 266 + animation: rise-in 700ms cubic-bezier(0.16, 1, 0.3, 1) both; 248 267 } 249 268 250 269 @keyframes rise-in { 251 - from { 252 - opacity: 0; 253 - transform: translateY(12px); 254 - } 255 - to { 256 - opacity: 1; 257 - transform: translateY(0); 258 - } 270 + from { 271 + opacity: 0; 272 + transform: translateY(12px); 273 + } 274 + to { 275 + opacity: 1; 276 + transform: translateY(0); 277 + } 259 278 }
+24 -24
tsconfig.json
··· 1 1 { 2 - "include": ["**/*.ts", "**/*.tsx"], 3 - "compilerOptions": { 4 - "target": "ES2022", 5 - "jsx": "react-jsx", 6 - "module": "ESNext", 7 - "paths": { 8 - "#/*": ["./src/*"], 9 - "@/*": ["./src/*"] 10 - }, 11 - "lib": ["ES2022", "DOM", "DOM.Iterable"], 12 - "types": ["vite/client"], 2 + "include": ["**/*.ts", "**/*.tsx"], 3 + "compilerOptions": { 4 + "target": "ES2022", 5 + "jsx": "react-jsx", 6 + "module": "ESNext", 7 + "paths": { 8 + "#/*": ["./src/*"], 9 + "@/*": ["./src/*"] 10 + }, 11 + "lib": ["ES2022", "DOM", "DOM.Iterable"], 12 + "types": ["vite/client"], 13 13 14 - /* Bundler mode */ 15 - "moduleResolution": "bundler", 16 - "allowImportingTsExtensions": true, 17 - "verbatimModuleSyntax": true, 18 - "noEmit": true, 14 + /* Bundler mode */ 15 + "moduleResolution": "bundler", 16 + "allowImportingTsExtensions": true, 17 + "verbatimModuleSyntax": true, 18 + "noEmit": true, 19 19 20 - /* Linting */ 21 - "skipLibCheck": true, 22 - "strict": true, 23 - "noUnusedLocals": true, 24 - "noUnusedParameters": true, 25 - "noFallthroughCasesInSwitch": true, 26 - "noUncheckedSideEffectImports": true 27 - } 20 + /* Linting */ 21 + "skipLibCheck": true, 22 + "strict": true, 23 + "noUnusedLocals": true, 24 + "noUnusedParameters": true, 25 + "noFallthroughCasesInSwitch": true, 26 + "noUncheckedSideEffectImports": true 27 + } 28 28 }
+21 -23
vite.config.ts
··· 1 - import { defineConfig } from 'vite' 1 + import tailwindcss from '@tailwindcss/vite' 2 2 import { devtools } from '@tanstack/devtools-vite' 3 - import tsconfigPaths from 'vite-tsconfig-paths' 4 - 5 3 import { tanstackStart } from '@tanstack/react-start/plugin/vite' 6 - 7 4 import viteReact from '@vitejs/plugin-react' 8 - import tailwindcss from '@tailwindcss/vite' 5 + import { defineConfig } from 'vite' 6 + import tsconfigPaths from 'vite-tsconfig-paths' 9 7 10 8 const config = defineConfig({ 11 - plugins: [ 12 - devtools(), 13 - tsconfigPaths({ projects: ['./tsconfig.json'] }), 14 - tailwindcss(), 15 - tanstackStart(), 16 - viteReact(), 17 - ], 18 - server: { 19 - host: 'localhost', 20 - port: 3001, 21 - allowedHosts: ['local.dudesky.app'], 22 - proxy: { 23 - '/api': { 24 - target: 'http://localhost:4000', 25 - changeOrigin: true, 26 - }, 27 - }, 28 - }, 9 + plugins: [ 10 + devtools(), 11 + tsconfigPaths({ projects: ['./tsconfig.json'] }), 12 + tailwindcss(), 13 + tanstackStart(), 14 + viteReact(), 15 + ], 16 + server: { 17 + host: 'localhost', 18 + port: 3001, 19 + allowedHosts: ['local.dudesky.app'], 20 + proxy: { 21 + '/api': { 22 + target: 'http://localhost:4000', 23 + changeOrigin: true, 24 + }, 25 + }, 26 + }, 29 27 }) 30 28 31 29 export default config