diff options
| author | schererleander <leander@schererleander.de> | 2025-05-30 01:01:17 +0200 |
|---|---|---|
| committer | schererleander <leander@schererleander.de> | 2025-05-30 01:01:17 +0200 |
| commit | afdc982863b6cca573f1db58e1795aa8c45fabca (patch) | |
| tree | 6b94d2ffdcb0e1b5ccbaf584c825763ab72ab99d /src | |
| parent | 8f2c8393510dfefc22871661b0ef9964569e290b (diff) | |
rewrite site
Diffstat (limited to 'src')
59 files changed, 810 insertions, 0 deletions
diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..c6e88bd --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,31 @@ +import { Routes, Route } from 'react-router-dom'; +import Navbar from './components/Navbar'; +import GearPage from './pages/Gear'; +import HomePage from './pages/Home'; +import ProjectsPage from './pages/Projects'; +import HomelabPage from './pages/Homelab'; +import Footer from './components/Footer'; +import PrintingPage from './pages/Printing'; +import NotFoundPage from './pages/404Page'; + +function App() { + + return ( + <> + <Navbar /> + <section className="max-w-xl mx-auto py-5 px-4"> + <Routes> + <Route path="/" element={<HomePage />} /> + <Route path="/gear" element={<GearPage />} /> + <Route path='/projects' element={<ProjectsPage />} /> + <Route path='/homelab' element={<HomelabPage />} /> + <Route path='/printing' element={<PrintingPage />} /> + <Route path='*' element={<NotFoundPage />} /> + </Routes> + </section> + <Footer /> + </> + ); +} + +export default App diff --git a/src/assets/3ds.jpeg b/src/assets/3ds.jpeg Binary files differnew file mode 100644 index 0000000..effd886 --- /dev/null +++ b/src/assets/3ds.jpeg diff --git a/src/assets/404.png b/src/assets/404.png Binary files differnew file mode 100644 index 0000000..f8f7719 --- /dev/null +++ b/src/assets/404.png diff --git a/src/assets/980pro.webp b/src/assets/980pro.webp Binary files differnew file mode 100644 index 0000000..6e004bf --- /dev/null +++ b/src/assets/980pro.webp diff --git a/src/assets/B550MITX.webp b/src/assets/B550MITX.webp Binary files differnew file mode 100644 index 0000000..aeaf364 --- /dev/null +++ b/src/assets/B550MITX.webp diff --git a/src/assets/a1.png b/src/assets/a1.png Binary files differnew file mode 100644 index 0000000..04c0133 --- /dev/null +++ b/src/assets/a1.png diff --git a/src/assets/airpodspro.webp b/src/assets/airpodspro.webp Binary files differnew file mode 100644 index 0000000..7426f84 --- /dev/null +++ b/src/assets/airpodspro.webp diff --git a/src/assets/amdryzen55600g.webp b/src/assets/amdryzen55600g.webp Binary files differnew file mode 100644 index 0000000..ffd55a3 --- /dev/null +++ b/src/assets/amdryzen55600g.webp diff --git a/src/assets/amdryzen7700x.webp b/src/assets/amdryzen7700x.webp Binary files differnew file mode 100644 index 0000000..22393d1 --- /dev/null +++ b/src/assets/amdryzen7700x.webp diff --git a/src/assets/authentication.webp b/src/assets/authentication.webp Binary files differnew file mode 100644 index 0000000..91980d2 --- /dev/null +++ b/src/assets/authentication.webp diff --git a/src/assets/b650e-i.webp b/src/assets/b650e-i.webp Binary files differnew file mode 100644 index 0000000..5cb20f6 --- /dev/null +++ b/src/assets/b650e-i.webp diff --git a/src/assets/esp32.jpeg b/src/assets/esp32.jpeg Binary files differnew file mode 100644 index 0000000..ee4331f --- /dev/null +++ b/src/assets/esp32.jpeg diff --git a/src/assets/github.png b/src/assets/github.png Binary files differnew file mode 100644 index 0000000..e03d8dd --- /dev/null +++ b/src/assets/github.png diff --git a/src/assets/gskilltridentz5.webp b/src/assets/gskilltridentz5.webp Binary files differnew file mode 100644 index 0000000..6017949 --- /dev/null +++ b/src/assets/gskilltridentz5.webp diff --git a/src/assets/hackintosh.webp b/src/assets/hackintosh.webp Binary files differnew file mode 100644 index 0000000..831e7b7 --- /dev/null +++ b/src/assets/hackintosh.webp diff --git a/src/assets/iphone11.webp b/src/assets/iphone11.webp Binary files differnew file mode 100644 index 0000000..6708e1d --- /dev/null +++ b/src/assets/iphone11.webp diff --git a/src/assets/jonsbon2.webp b/src/assets/jonsbon2.webp Binary files differnew file mode 100644 index 0000000..a7f9459 --- /dev/null +++ b/src/assets/jonsbon2.webp diff --git a/src/assets/ksm32ed8.webp b/src/assets/ksm32ed8.webp Binary files differnew file mode 100644 index 0000000..5de5a94 --- /dev/null +++ b/src/assets/ksm32ed8.webp diff --git a/src/assets/macbookair.webp b/src/assets/macbookair.webp Binary files differnew file mode 100644 index 0000000..2713c6d --- /dev/null +++ b/src/assets/macbookair.webp diff --git a/src/assets/markdownparser.webp b/src/assets/markdownparser.webp Binary files differnew file mode 100644 index 0000000..be7c23d --- /dev/null +++ b/src/assets/markdownparser.webp diff --git a/src/assets/nas.png b/src/assets/nas.png Binary files differnew file mode 100644 index 0000000..0c50ba3 --- /dev/null +++ b/src/assets/nas.png diff --git a/src/assets/ncasem2.webp b/src/assets/ncasem2.webp Binary files differnew file mode 100644 index 0000000..f887f25 --- /dev/null +++ b/src/assets/ncasem2.webp diff --git a/src/assets/noctuanh-d9l.webp b/src/assets/noctuanh-d9l.webp Binary files differnew file mode 100644 index 0000000..3d9b00e --- /dev/null +++ b/src/assets/noctuanh-d9l.webp diff --git a/src/assets/p3plus.webp b/src/assets/p3plus.webp Binary files differnew file mode 100644 index 0000000..f02ced1 --- /dev/null +++ b/src/assets/p3plus.webp diff --git a/src/assets/patriop300.webp b/src/assets/patriop300.webp Binary files differnew file mode 100644 index 0000000..aa81f3c --- /dev/null +++ b/src/assets/patriop300.webp diff --git a/src/assets/pi.png b/src/assets/pi.png Binary files differnew file mode 100644 index 0000000..90760e9 --- /dev/null +++ b/src/assets/pi.png diff --git a/src/assets/quiz.webp b/src/assets/quiz.webp Binary files differnew file mode 100644 index 0000000..52990b7 --- /dev/null +++ b/src/assets/quiz.webp diff --git a/src/assets/raylibshooter.webp b/src/assets/raylibshooter.webp Binary files differnew file mode 100644 index 0000000..36a8559 --- /dev/null +++ b/src/assets/raylibshooter.webp diff --git a/src/assets/rice.jpg b/src/assets/rice.jpg Binary files differnew file mode 100644 index 0000000..38ada10 --- /dev/null +++ b/src/assets/rice.jpg diff --git a/src/assets/rx9070xt.webp b/src/assets/rx9070xt.webp Binary files differnew file mode 100644 index 0000000..cd3b89b --- /dev/null +++ b/src/assets/rx9070xt.webp diff --git a/src/assets/seagateironwolf.webp b/src/assets/seagateironwolf.webp Binary files differnew file mode 100644 index 0000000..25fd27f --- /dev/null +++ b/src/assets/seagateironwolf.webp diff --git a/src/assets/setup.jpg b/src/assets/setup.jpg Binary files differnew file mode 100644 index 0000000..e605735 --- /dev/null +++ b/src/assets/setup.jpg diff --git a/src/assets/sf450.webp b/src/assets/sf450.webp Binary files differnew file mode 100644 index 0000000..4c15b5d --- /dev/null +++ b/src/assets/sf450.webp diff --git a/src/assets/sf750.webp b/src/assets/sf750.webp Binary files differnew file mode 100644 index 0000000..cf1afe1 --- /dev/null +++ b/src/assets/sf750.webp diff --git a/src/assets/spaceinvaders.webp b/src/assets/spaceinvaders.webp Binary files differnew file mode 100644 index 0000000..01065ec --- /dev/null +++ b/src/assets/spaceinvaders.webp diff --git a/src/assets/specula.webp b/src/assets/specula.webp Binary files differnew file mode 100644 index 0000000..a148c4c --- /dev/null +++ b/src/assets/specula.webp diff --git a/src/assets/todolist.webp b/src/assets/todolist.webp Binary files differnew file mode 100644 index 0000000..2a833d5 --- /dev/null +++ b/src/assets/todolist.webp diff --git a/src/components/CardLink.tsx b/src/components/CardLink.tsx new file mode 100644 index 0000000..3492038 --- /dev/null +++ b/src/components/CardLink.tsx @@ -0,0 +1,29 @@ +import ExternalLinkIcon from "./ExternalLink"; + +interface Props { + title: string; + body: string; + href?: string; + imgSrc?: string; +} + +export default function CardLink({ title, body, href, imgSrc }: Props) { + const Wrapper = href ? 'a' : 'div'; + return ( + <Wrapper + {...(href && { + href, + target: '_blank', + rel: 'noopener noreferrer', + })} + className="group flex items-center gap-4 py-2 px-2 rounded-lg" + > + <img src={imgSrc || ''} className="w-20 h-20 object-cover rounded-lg transition-transform duration-200 group-hover:scale-105"/> + <div className="flex-1"> + <h3 className="font-medium">{title}</h3> + <p className="text-sm text-gray-600 black:text-gray-300">{body}</p> + </div> + {href && <ExternalLinkIcon />} + </Wrapper> + ); +}
\ No newline at end of file diff --git a/src/components/CodeSnippet.tsx b/src/components/CodeSnippet.tsx new file mode 100644 index 0000000..73ac1e7 --- /dev/null +++ b/src/components/CodeSnippet.tsx @@ -0,0 +1,94 @@ +import React, { useState, useMemo, useCallback } from 'react'; + +interface CodeSnippetProps { + code: string; + initialLines?: number; +} + +const ExpandIcon = () => ( + <svg width="16" height="16" viewBox="0 0 24 24"> + <path d="M19 13H13v6h-2v-6H5v-2h6V5h2v6h6v2z" fill="currentColor" /> + </svg> +); + +const CollapseIcon = () => ( + <svg width="16" height="16" viewBox="0 0 24 24"> + <path d="M19 13H5v-2h14v2z" fill="currentColor" /> + </svg> +); + +const CopyIcon = () => ( + <svg width="16" height="16" viewBox="0 0 24 24"> + <path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z" fill="currentColor" /> + </svg> +); + +export const CodeSnippet: React.FC<CodeSnippetProps> = React.memo( + ({ code, initialLines = 5 }) => { + const [expanded, setExpanded] = useState(false); + const [copied, setCopied] = useState(false); + + const lines = useMemo(() => code.split('\n'), [code]); + const shouldTruncate = useMemo( + () => lines.length > initialLines, + [lines.length, initialLines] + ); + const displayedLines = useMemo( + () => (expanded || !shouldTruncate ? lines : lines.slice(0, initialLines)), + [expanded, shouldTruncate, lines, initialLines] + ); + + const toggleExpanded = useCallback( + () => setExpanded(prev => !prev), + [] + ); + + const handleCopy = useCallback(() => { + navigator.clipboard.writeText(code) + .then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }) + .catch(() => {}); + }, [code]); + + return ( + <div className="relative rounded-lg p-4 my-2 font-mono bg-gray-200 dark:bg-white/5"> + {shouldTruncate && ( + <div className="absolute top-2 right-2 flex space-x-2"> + <button + onClick={handleCopy} + aria-label={copied ? 'Copied!' : 'Copy code'} + className="p-0 cursor-pointer text-gray-600 hover:text-gray-800" + > + <CopyIcon /> + </button> + <button + onClick={toggleExpanded} + aria-label={expanded ? 'Collapse code' : 'Expand code'} + className="p-0 cursor-pointer text-gray-600 hover:text-gray-800" + > + {expanded ? <CollapseIcon /> : <ExpandIcon />} + </button> + </div> + )} + + <pre className="overflow-x-auto m-0 text-sm"> + {displayedLines.map((line, idx) => ( + <div key={idx} className="flex"> + <span className="text-gray-500 w-8 text-right pr-2"> + {idx + 1} + </span> + <code className="flex-1 whitespace-pre-wrap">{line}</code> + </div> + ))} + {shouldTruncate && !expanded && ( + <div className="text-center text-gray-400">...</div> + )} + </pre> + </div> + ); + } +); + +export default CodeSnippet;
\ No newline at end of file diff --git a/src/components/ExternalLink.tsx b/src/components/ExternalLink.tsx new file mode 100644 index 0000000..2b35a12 --- /dev/null +++ b/src/components/ExternalLink.tsx @@ -0,0 +1,3 @@ +export default function ExternalLinkIcon() { + return <svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M18 13v6a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1h6M15 6h4m0 0v4m0-4L10 15" /></svg>; +}
\ No newline at end of file diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 0000000..13ba637 --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,21 @@ +import GitHubIcon from "./GitHubIcon"; +import MailIcon from "./MailIcon"; + +export default function Footer() { + const year = new Date().getFullYear(); + return ( + <footer className="flex flex-col items-center gap-2 py-4"> + <div className="flex gap-6"> + <a href="https://github.com/schererleander" target="_blank" rel="noopener noreferrer"> + <GitHubIcon /> + </a> + + <a href="mailto:leander@schererleander.de"> + <MailIcon /> + </a> + </div> + + <span className="text-sm">© {year} Leander Scherer — ʕっ•ᴥ•ʔっ</span> + </footer> + ); +}
\ No newline at end of file diff --git a/src/components/GitHubIcon.tsx b/src/components/GitHubIcon.tsx new file mode 100644 index 0000000..c93dbc7 --- /dev/null +++ b/src/components/GitHubIcon.tsx @@ -0,0 +1,12 @@ +export default function GitHubIcon() { + return ( + <svg + viewBox="0 0 24 24" + fill="currentColor" + className="w-4 h-4" + aria-hidden="true" + > + <path d="M12 0a12 12 0 0 0-3.79 23.4c.6.11.82-.26.82-.58v-2.02c-3.34.73-4.04-1.61-4.04-1.61-.55-1.4-1.34-1.77-1.34-1.77-1.1-.75.08-.74.08-.74 1.22.09 1.86 1.25 1.86 1.25 1.08 1.85 2.84 1.31 3.53 1 .11-.78.42-1.31.76-1.61-2.67-.3-5.47-1.33-5.47-5.9 0-1.3.47-2.36 1.24-3.19-.12-.3-.54-1.51.12-3.15 0 0 1.01-.32 3.3 1.22a11.48 11.48 0 0 1 6 0c2.28-1.54 3.29-1.22 3.29-1.22.66 1.64.24 2.85.12 3.15.77.83 1.23 1.89 1.23 3.19 0 4.58-2.81 5.59-5.5 5.89.43.37.81 1.11.81 2.24v3.32c0 .32.21.7.82.58A12 12 0 0 0 12 0Z" /> + </svg> + ); +}
\ No newline at end of file diff --git a/src/components/ImageGalleryGrid.tsx b/src/components/ImageGalleryGrid.tsx new file mode 100644 index 0000000..fc651bf --- /dev/null +++ b/src/components/ImageGalleryGrid.tsx @@ -0,0 +1,96 @@ +import React, { useState, useEffect } from 'react'; + +interface Props { + images: Array<{ + src: string; + alt?: string; + id?: string | number; + }>; +} + +export default function ImageGalleryGrid({ images }: Props) { + const [selectedIndex, setSelectedIndex] = useState<number | null>(null); + const closeModal = () => setSelectedIndex(null); + const showPrev = (e: React.MouseEvent) => { + e.stopPropagation(); + setSelectedIndex((prev) => (prev !== null && prev > 0 ? prev - 1 : prev)); + }; + const showNext = (e: React.MouseEvent) => { + e.stopPropagation(); + setSelectedIndex((prev) => + prev !== null && prev < images.length - 1 ? prev + 1 : prev + ); + }; + + useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + if (selectedIndex === null) return; + if (event.key === 'Escape') { + closeModal; + } else if (event.key === 'ArrowLeft') { + setSelectedIndex((prev) => (prev && prev > 0 ? prev - 1 : prev)); + } else if (event.key === 'ArrowRight') { + setSelectedIndex((prev) => + prev !== null && prev < images.length - 1 ? prev + 1 : prev + ); + } + } + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [selectedIndex, images.length]); + + return ( + <> + <div className="grid gap-2 grid-cols-4 p-4"> + {images.map((image, idx) => ( + <div + key={image.id ?? image.src} + className="relative group overflow-hidden rounded-xl shadow-lg cursor-pointer aspect-square" + onClick={() => setSelectedIndex(idx)} + > + <img + src={image.src} + alt={image.alt || 'Gallery image'} + className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" + /> + </div> + ))} + </div> + + {/* Overlay */} + {selectedIndex !== null && ( + <div + className="fixed inset-0 bg-white/50 dark:bg-black/50 backdrop-blur-sm flex items-center justify-center z-50" + onClick={closeModal} + role="dialog" + aria-modal="true" + > + {/* Prev arrow */} + <button + onClick={showPrev} + className="absolute left-4 text-black dark:text-white text-4xl focus:outline-none cursor-pointer" + disabled={selectedIndex === 0} + > + ← + </button> + + <img + src={images[selectedIndex].src} + alt={images[selectedIndex].alt || 'Enlarged gallery'} + className="w-1/2 h-1/2 object-contain rounded-lg" + /> + + {/* Next arrow */} + <button + onClick={showNext} + className="absolute right-4 text-black dark:text-white text-4xl focus:outline-none cursor-pointer" + disabled={selectedIndex === images.length - 1} + > + → + </button> + </div> + )} + </> + ); +}
\ No newline at end of file diff --git a/src/components/LinkWithIcon.tsx b/src/components/LinkWithIcon.tsx new file mode 100644 index 0000000..0046125 --- /dev/null +++ b/src/components/LinkWithIcon.tsx @@ -0,0 +1,25 @@ +import ExternalLinkIcon from "./ExternalLink"; + +export default function LinkWithIcon({ + href, + children, + className = 'inline-flex items-center gap-1 underline text-blue-400', + target = '_blank', +}: { + href: string; + children: React.ReactNode; + className?: string; + target?: React.HTMLAttributeAnchorTarget; +}) { + return ( + <a + href={href} + target={target} + rel={target === '_blank' ? 'noopener noreferrer' : undefined} + className={className} + > + {children} + <ExternalLinkIcon /> + </a> + ); +} diff --git a/src/components/MailIcon.tsx b/src/components/MailIcon.tsx new file mode 100644 index 0000000..cff91e1 --- /dev/null +++ b/src/components/MailIcon.tsx @@ -0,0 +1,12 @@ +export default function MailIcon() { + return ( + <svg + viewBox="0 0 24 24" + fill="currentColor" + className="w-4 h-4" + aria-hidden="true" + > + <path d="M20 4H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2Zm0 2-8 6-8-6h16Zm0 12H4V8l8 6 8-6v10Z" /> + </svg> + ); +} diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx new file mode 100644 index 0000000..624f9f1 --- /dev/null +++ b/src/components/Navbar.tsx @@ -0,0 +1,25 @@ +import { Link } from "react-router-dom"; +import ThemeToggle from "./ThemeToggle"; + +export default function Navbar() { + const navItems = [ + { label: 'Home', href: '/' }, + { label: 'Gear', href: '/gear' }, + { label: 'Projects', href: '/projects' }, + { label: 'Homelab', href: '/homelab' }, + { label: '3D Printing', href: '/printing' } + ]; + + return ( + <nav className="sticky top-0 backdrop-blur flex items-center"> + <div className="max-w-2xl mx-auto flex px-4 py-4 text-sm"> + <div className="flex gap-6 items-center"> + {navItems.map(({ label, href }) => ( + <Link className="hover:underline" key={label} to={href}>{label}</Link> + ))} + <ThemeToggle /> + </div> + </div> + </nav> + ); +}
\ No newline at end of file diff --git a/src/components/ThemeToggle.tsx b/src/components/ThemeToggle.tsx new file mode 100644 index 0000000..74dbf57 --- /dev/null +++ b/src/components/ThemeToggle.tsx @@ -0,0 +1,15 @@ +// components/ThemeToggle.tsx +import { useTheme } from "../hooks/theme"; + +export default function ThemeToggle() { + const { theme, toggleTheme } = useTheme(); + return ( + <button type="button" onClick={toggleTheme}> + {theme === 'dark' ? ( + <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"></path></svg> + ) : ( + <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"></path></svg> + )} + </button> + ); +} diff --git a/src/data/gear.ts b/src/data/gear.ts new file mode 100644 index 0000000..f4e0b6d --- /dev/null +++ b/src/data/gear.ts @@ -0,0 +1,72 @@ +import iphone11Img from "../assets/iphone11.webp"; +import airpodsProImg from "../assets/airpodspro.webp"; +import macbookAirImg from "../assets/macbookair.webp"; + +import ryzen7700xImg from "../assets/amdryzen7700x.webp"; +import ryzen5600gImg from "../assets/amdryzen55600g.webp"; +import noctuaNhD9lImg from "../assets/noctuanh-d9l.webp"; +import gskillTridentZ5Img from "../assets/gskilltridentz5.webp"; +import b650eIImg from "../assets/b650e-i.webp"; +import b550mItxImg from "../assets/B550MITX.webp"; +import pro980Img from "../assets/980pro.webp"; +import p3plusImg from "../assets/p3plus.webp"; +import patriotP300Img from "../assets/patriop300.webp"; +import sf750Img from "../assets/sf750.webp"; +import sf450Img from "../assets/sf450.webp"; +import rx9070xtImg from "../assets/rx9070xt.webp"; +import ncaseM2Img from "../assets/ncasem2.webp"; + +import ksm32ed8Img from "../assets/ksm32ed8.webp"; +import seagateIronWolfImg from "../assets/seagateironwolf.webp"; +import jonsboN2Img from "../assets/jonsbon2.webp"; + +export interface Part { + name: string; + description: string; + /** string URL returned by the static import above */ + image?: string; + url?: string; +} + +export const dailyDrivers: Part[] = [ + { + name: "iPhone 11", + description: "64 GB - White", + url: "https://support.apple.com/111865", + image: iphone11Img, + }, + { + name: "AirPods Pro", + description: "1st Gen", + url: "https://support.apple.com/111861", + image: airpodsProImg, + }, + { + name: "MacBook Air", + description: "13\" - M2 - 512 GB", + url: "https://support.apple.com/111867", + image: macbookAirImg, + }, +]; + +export const desktopParts: Part[] = [ + { name: "AMD Ryzen 7 7700X", description: "CPU", image: ryzen7700xImg }, + { name: "Noctua NH-D9L chromax.black", description: "CPU cooler", image: noctuaNhD9lImg }, + { name: "G.Skill Trident Z5 NEO 32 GB DDR5-6000", description: "RAM", image: gskillTridentZ5Img }, + { name: "ROG STRIX B650E-I", description: "Motherboard", image: b650eIImg }, + { name: "Samsung 980 Pro 2 TB", description: "NVMe (Windows)", image: pro980Img }, + { name: "Crucial P3 Plus 500 GB", description: "NVMe (Linux)", image: p3plusImg }, + { name: "Corsair SF750", description: "SFX PSU", image: sf750Img }, + { name: "XFX Radeon RX 9070 XT QuickSilver", description: "GPU", image: rx9070xtImg }, + { name: "NCASE M2 - Round", description: "Case", image: ncaseM2Img }, +]; + +export const nasParts: Part[] = [ + { name: "AMD Ryzen 5 5600G", description: "CPU", image: ryzen5600gImg }, + { name: "Kingston KSM32ED8 2x 8 GB ECC", description: "RAM", image: ksm32ed8Img }, + { name: "ASRock B550M-ITX", description: "Motherboard", image: b550mItxImg }, + { name: "Seagate IronWolf 8 TB x 2", description: "Storage", image: seagateIronWolfImg }, + { name: "Patriot P300 128 GB", description: "Cache SSD", image: patriotP300Img }, + { name: "Corsair SF450", description: "SFX PSU", image: sf450Img }, + { name: "Jonsbo N2", description: "Mini-NAS case", image: jonsboN2Img }, +];
\ No newline at end of file diff --git a/src/data/projects.ts b/src/data/projects.ts new file mode 100644 index 0000000..6c645f3 --- /dev/null +++ b/src/data/projects.ts @@ -0,0 +1,73 @@ +import spaceInvadersImg from "../assets/spaceinvaders.webp"; +import hackintoshImg from "../assets/hackintosh.webp"; +import markdownPreviewImg from "../assets/markdownparser.webp"; +import quizImg from "../assets/quiz.webp"; +import raylibShooterImg from "../assets/raylibshooter.webp"; +import authenticationImg from "../assets/authentication.webp"; +import todoListImg from "../assets/todolist.webp"; +import speculaImg from "../assets/specula.webp"; +import gitHubImg from "../assets/github.png"; + +export interface Project { + name: string; + description: string; + image?: string; + url?: string; +} + +export const projects: Project[] = [ + { + name: "Space Invaders", + description: "Old-school shooter coded long ago", + url: "https://github.com/schererleander/space-invaders", + image: spaceInvadersImg, + }, + { + name: "Hackintosh", + description: "macOS on PC hardware (pre-MacBook era)", + url: "https://github.com/schererleander/opencore-config", + image: hackintoshImg, + }, + { + name: "Markdown Preview", + description: "Live Markdown preview (basic syntax)", + url: "https://github.com/schererleander/markdown-preview", + image: markdownPreviewImg, + }, + { + name: "Quiz Website", + description: "Plain but functional school project", + url: "https://github.com/schererleander/quiz", + image: quizImg, + }, + { + name: "raylib-shooter", + description: "Unfinished FPS built with raylib", + url: "https://github.com/schererleander/raylib-shooter", + image: raylibShooterImg, + }, + { + name: "Authentication", + description: "bcrypt + salted hashes", + url: "https://github.com/schererleander/authentication", + image: authenticationImg, + }, + { + name: "Python ICS Modifier", + description: "Adds reminders to .ics files", + url: "https://github.com/schererleander/pyhton-ics-modifier", + image: gitHubImg + }, + { + name: "todolist (curses)", + description: "Simple ncurses CLI todo list (C)", + url: "https://github.com/schererleander/todolist", + image: todoListImg, + }, + { + name: "specula", + description: "Minimal TUI for file metadata", + url: "https://github.com/schererleander/specula", + image: speculaImg, + }, +];
\ No newline at end of file diff --git a/src/hooks/theme.tsx b/src/hooks/theme.tsx new file mode 100644 index 0000000..d65b18c --- /dev/null +++ b/src/hooks/theme.tsx @@ -0,0 +1,33 @@ +// hooks/theme.ts +import { useState, useEffect, useCallback } from 'react'; + +type Theme = 'light' | 'dark'; + +const STORAGE_KEY = 'theme'; + +export function useTheme() { + const [theme, setTheme] = useState<Theme>(() => { + const stored = typeof window !== 'undefined' ? localStorage.getItem(STORAGE_KEY) : null; + if (stored === 'light' || stored === 'dark') { + return stored; + } + + if (typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches) { + return 'dark'; + } + return 'light'; + }); + + useEffect(() => { + const root = document.documentElement; + root.setAttribute('data-theme', theme); + root.classList.toggle('dark', theme === 'dark'); + localStorage.setItem(STORAGE_KEY, theme); + }, [theme]); + + const toggleTheme = useCallback(() => { + setTheme(prev => (prev === 'dark' ? 'light' : 'dark')); + }, []); + + return { theme, toggleTheme }; +} diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..40a7f01 --- /dev/null +++ b/src/index.css @@ -0,0 +1,17 @@ + +@import "tailwindcss"; + +@custom-variant dark (&:where(.dark, .dark *)); + +@layer base { + body { @apply bg-white dark:bg-black/95; } + span, p, a, li, code { @apply dark:text-gray-200; } + svg { @apply transform duration-200 ease-in-out hover:scale-110 dark:text-gray-200 hover:text-gray-400 hover:dark:text-white;} + h1 { @apply text-4xl font-bold mb-4 dark:text-gray-200; } + h2 { @apply text-2xl font-semibold mb-2 dark:text-gray-200; } + h3 { @apply text-xl font-semibold dark:text-gray-200; } + + * { + transition: background-color 0.25s, color 0.25s, border-color 0.25s; + } +}
\ No newline at end of file diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..32ac14c --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,10 @@ +import { BrowserRouter } from 'react-router-dom'; +import ReactDOM from 'react-dom/client'; +import './index.css' +import App from './App.tsx' + +ReactDOM.createRoot(document.getElementById('root')!).render( + <BrowserRouter> + <App /> + </BrowserRouter> +); diff --git a/src/pages/404Page.tsx b/src/pages/404Page.tsx new file mode 100644 index 0000000..3eabe6b --- /dev/null +++ b/src/pages/404Page.tsx @@ -0,0 +1,21 @@ +import { useNavigate } from "react-router-dom"; +import notFoundImg from "../assets/404.png"; +import { useEffect } from "react"; + +export default function NotFoundPage() { + const navigate = useNavigate(); + + useEffect(() => { + const timer = setTimeout(() => { + navigate('/', { replace: true }); + }, 4000); + + return () => clearTimeout(timer); + }, [navigate]); + return( + <> + <h1>404 - Not found</h1> + <img src={notFoundImg} className="rounded-lg"/> + </> + ); +}
\ No newline at end of file diff --git a/src/pages/Gear.tsx b/src/pages/Gear.tsx new file mode 100644 index 0000000..dbbcdfb --- /dev/null +++ b/src/pages/Gear.tsx @@ -0,0 +1,43 @@ +import CardLink from '../components/CardLink'; + +import { + dailyDrivers, + desktopParts, + nasParts, + type Part, +} from '../data/gear'; + +function PartsGroup({ title, parts }: { title?: string; parts: Part[] }) { + return ( + <> + {title && <h2 className="text-2xl font-semibold my-8">{title}</h2>} + <ul className="space-y-2"> + {parts.map((p) => ( + <li key={p.name}> + <CardLink + title={p.name} + body={p.description} + href={p.url} + imgSrc={p.image} + /> + </li> + ))} + </ul> + </> + ); +} + +export default function GearPage() { + return ( + <> + <title>߸ gear</title> + <h1>Gear</h1> + + <PartsGroup parts={dailyDrivers} /> + + <PartsGroup title="Desktop" parts={desktopParts} /> + + <PartsGroup title="NAS" parts={nasParts} /> + </> + ); +}
\ No newline at end of file diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx new file mode 100644 index 0000000..912b0c5 --- /dev/null +++ b/src/pages/Home.tsx @@ -0,0 +1,31 @@ +import ImageGalleryGrid from "../components/ImageGalleryGrid"; + +import dsImg from "../assets/3ds.jpeg"; +import esp32Img from "../assets/esp32.jpeg"; +import riceImg from "../assets/rice.jpg"; +import setupImg from "../assets/setup.jpg"; + +export default function HomePage() { + return ( + <> + <title>߸ hi</title> + <h1>Hi, <span className="text-blue-500 dark:text-purple-500">I'm Leander.</span></h1> + + <p className="leading-relaxed mb-6"> + I have a passion for hardware and software, studying computer science. Currently building own 3d printing projects and learning nix. + </p> + + <ImageGalleryGrid images={[{ src: dsImg, alt: "Nintendo 3DS", id: 1}, { src: esp32Img, alt: "ESP 32", id: 2}, { src: riceImg, alt: "Linux rice", id: 3}, { src: setupImg, alt: "Setup", id: 4}]} /> + + <p className="mb-4">A few things I'm interrested in:</p> + + <ul className="list-disc pl-6 space-y-1"> + <li>Digital minimalism</li> + <li>*nix systems</li> + <li>3D printing</li> + <li>Homelab / self-hosting</li> + <li>Seinen manga</li> + </ul> + </> + ); +}
\ No newline at end of file diff --git a/src/pages/Homelab.tsx b/src/pages/Homelab.tsx new file mode 100644 index 0000000..580babc --- /dev/null +++ b/src/pages/Homelab.tsx @@ -0,0 +1,52 @@ +import nasImg from '../assets/nas.png'; +import piImg from '../assets/pi.png'; +import LinkWithIcon from '../components/LinkWithIcon'; + +const nasServices = [ + "Jellyfin: Media library", + "Kavita: Ebooks and manga", + "AdGuard Home: Ad blocking", + "Nginx: Reverse proxy", + "Ollama: Enough for small LLM testing", +]; + +export default function HomelabPage() { + return ( + <> + <title>߸ homelab</title> + <h1>Home lab</h1> + + <section className="mb-12"> + <h2>NAS</h2> + <img src={nasImg} alt="NAS" className="mx-auto mb-4 w-64 rounded-lg shadow" /> + <p className="mb-4 leading-relaxed"> + My custom-built NAS running <strong>Unraid</strong> hosts the following services. See <LinkWithIcon href='/gear'>gear</LinkWithIcon> for specs. + </p> + <ul className="list-disc pl-6 space-y-1"> + {nasServices.map((svc) => ( + <li key={svc}>{svc}</li> + ))} + </ul> + <p className="mb-4 leading-relaxed"> + For remote access, I use a VPN to connect to the machine. I also back up my MacBook using Time Machine, and for my desktop and VPS I use rsync. + </p> + </section> + + <section className="mb-12"> + <h2>Raspberry Pi</h2> + <img src={piImg} alt="Raspberry Pi 5" className="mx-auto mb-4 w-64 rounded-lg shadow" /> + <p className="mb-4 leading-relaxed"> + Raspberry Pi 5 (8GB) running Homebridge to integrate non-HomeKit devices. It also serves as a precision NTP server using a <LinkWithIcon href='https://store.uputronics.com/products/raspberry-pi-gps-rtc-expansion-board' target='_blank'>Uputronics GPS module</LinkWithIcon>. + </p> + </section> + + {/* VPS */} + <section> + <h2>VPS</h2> + <p className="mb-4 leading-relaxed"> + Cheap Ionos VPS for services exposed to the internet. Mainly using it for hosting this website and Nextcloud. + </p> + </section> + </> + ); +}
\ No newline at end of file diff --git a/src/pages/Printing.tsx b/src/pages/Printing.tsx new file mode 100644 index 0000000..61dbe71 --- /dev/null +++ b/src/pages/Printing.tsx @@ -0,0 +1,70 @@ +import a1Img from "../assets/a1.png"; +import CodeSnippet from "../components/CodeSnippet"; +import LinkWithIcon from "../components/LinkWithIcon"; + +export default function PrintingPage() { + const roboArmCode = `#include <Bluepad32.h> +#include <ESP32Servo.h> + +#define DEADZONE 30 +#define BASE_PIN 15 +#define SHOULDER_PIN 2 +#define ELBOW_PIN 4 +#define WRIST_PIN 16 +#define HAND_PIN 17 + +Servo base, shoulder, elbow, wrist, hand; +ControllerPtr pad; + +void onConnectedGamepad(ControllerPtr ctl) { + Serial.printf("Gamepad #%d verbunden\n", ctl->index()); + pad = ctl; +} + +void onDisconnectedGamepad(ControllerPtr ctl) { + Serial.printf("Gamepad getrennt\n"); +} + +int16_t mapAxis(int16_t v) { + if (abs(v) < DEADZONE) v = 0; + return map(v, -512, 512, 0, 180); +} +void setup() { + BP32.setup(&onConnectedGamepad, &onDisconnectedGamepad); + BP32.enableNewBluetoothConnections(true); + + base.attach(BASE_PIN); + shoulder.attach(SHOULDER_PIN); + elbow.attach(ELBOW_PIN); + wrist.attach(WRIST_PIN); + hand.attach(HAND_PIN); +} + +void loop() { + BP32.update(); + if (pad && pad->isConnected()) { + base.write(mapAxis(pad->axisX())); + shoulder.write(mapAxis(-pad->axisY())); + elbow.write(mapAxis(-pad->axisRY())); + hand.write(mapAxis(pad->throttle() - pad->brake())); + + if (pad->l1()) wrist.write(0); + else if (pad->r1()) wrist.write(180); + else wrist.write(90); + } + delay(15); +}`; + return ( + <> + <title>߸ 3d printing</title> + <h1>3D Printing</h1> + <img src={a1Img} alt="Bambu Lab A1" className="mx-auto mb-4 w-64 rounded-lg shadow"/> + <h2>Projects</h2> + <h3>Robot Arm</h3> + <p><LinkWithIcon href="https://makerworld.com/en/models/528885-robotic-arm#profileId-445995" target="_blank">3D Model</LinkWithIcon> changed the model to work with my servo motors.</p> + <CodeSnippet code={roboArmCode} initialLines={5} /> + <h3>Diy Drone</h3> + <p>WIP</p> + </> + ); +}
\ No newline at end of file diff --git a/src/pages/Projects.tsx b/src/pages/Projects.tsx new file mode 100644 index 0000000..d49a3d2 --- /dev/null +++ b/src/pages/Projects.tsx @@ -0,0 +1,24 @@ +import CardLink from '../components/CardLink'; +import { projects, type Project } from '../data/projects'; + +export default function ProjectsPage() { + return ( + <> + <title>߸ projects</title> + <h1>Projects</h1> + + <ul className="space-y-2"> + {projects.map((p: Project) => ( + <li key={p.name}> + <CardLink + title={p.name} + body={p.description} + href={p.url} + imgSrc={p.image} + /> + </li> + ))} + </ul> + </> + ); +}
\ No newline at end of file diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +/// <reference types="vite/client" /> |
