aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/App.tsx31
-rw-r--r--src/assets/3ds.jpegbin0 -> 265956 bytes
-rw-r--r--src/assets/404.pngbin0 -> 444707 bytes
-rw-r--r--src/assets/980pro.webpbin0 -> 13206 bytes
-rw-r--r--src/assets/B550MITX.webpbin0 -> 27598 bytes
-rw-r--r--src/assets/a1.pngbin0 -> 208717 bytes
-rw-r--r--src/assets/airpodspro.webpbin0 -> 11402 bytes
-rw-r--r--src/assets/amdryzen55600g.webpbin0 -> 17350 bytes
-rw-r--r--src/assets/amdryzen7700x.webpbin0 -> 284886 bytes
-rw-r--r--src/assets/authentication.webpbin0 -> 1928 bytes
-rw-r--r--src/assets/b650e-i.webpbin0 -> 41786 bytes
-rw-r--r--src/assets/esp32.jpegbin0 -> 157935 bytes
-rw-r--r--src/assets/github.pngbin0 -> 13001 bytes
-rw-r--r--src/assets/gskilltridentz5.webpbin0 -> 9714 bytes
-rw-r--r--src/assets/hackintosh.webpbin0 -> 16886 bytes
-rw-r--r--src/assets/iphone11.webpbin0 -> 40480 bytes
-rw-r--r--src/assets/jonsbon2.webpbin0 -> 16288 bytes
-rw-r--r--src/assets/ksm32ed8.webpbin0 -> 12068 bytes
-rw-r--r--src/assets/macbookair.webpbin0 -> 85954 bytes
-rw-r--r--src/assets/markdownparser.webpbin0 -> 12700 bytes
-rw-r--r--src/assets/nas.pngbin0 -> 248497 bytes
-rw-r--r--src/assets/ncasem2.webpbin0 -> 19282 bytes
-rw-r--r--src/assets/noctuanh-d9l.webpbin0 -> 42058 bytes
-rw-r--r--src/assets/p3plus.webpbin0 -> 8140 bytes
-rw-r--r--src/assets/patriop300.webpbin0 -> 10666 bytes
-rw-r--r--src/assets/pi.pngbin0 -> 836782 bytes
-rw-r--r--src/assets/quiz.webpbin0 -> 862 bytes
-rw-r--r--src/assets/raylibshooter.webpbin0 -> 6334 bytes
-rw-r--r--src/assets/rice.jpgbin0 -> 44900 bytes
-rw-r--r--src/assets/rx9070xt.webpbin0 -> 8250 bytes
-rw-r--r--src/assets/seagateironwolf.webpbin0 -> 10692 bytes
-rw-r--r--src/assets/setup.jpgbin0 -> 68727 bytes
-rw-r--r--src/assets/sf450.webpbin0 -> 16006 bytes
-rw-r--r--src/assets/sf750.webpbin0 -> 12926 bytes
-rw-r--r--src/assets/spaceinvaders.webpbin0 -> 15830 bytes
-rw-r--r--src/assets/specula.webpbin0 -> 17942 bytes
-rw-r--r--src/assets/todolist.webpbin0 -> 16534 bytes
-rw-r--r--src/components/CardLink.tsx29
-rw-r--r--src/components/CodeSnippet.tsx94
-rw-r--r--src/components/ExternalLink.tsx3
-rw-r--r--src/components/Footer.tsx21
-rw-r--r--src/components/GitHubIcon.tsx12
-rw-r--r--src/components/ImageGalleryGrid.tsx96
-rw-r--r--src/components/LinkWithIcon.tsx25
-rw-r--r--src/components/MailIcon.tsx12
-rw-r--r--src/components/Navbar.tsx25
-rw-r--r--src/components/ThemeToggle.tsx15
-rw-r--r--src/data/gear.ts72
-rw-r--r--src/data/projects.ts73
-rw-r--r--src/hooks/theme.tsx33
-rw-r--r--src/index.css17
-rw-r--r--src/main.tsx10
-rw-r--r--src/pages/404Page.tsx21
-rw-r--r--src/pages/Gear.tsx43
-rw-r--r--src/pages/Home.tsx31
-rw-r--r--src/pages/Homelab.tsx52
-rw-r--r--src/pages/Printing.tsx70
-rw-r--r--src/pages/Projects.tsx24
-rw-r--r--src/vite-env.d.ts1
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
new file mode 100644
index 0000000..effd886
--- /dev/null
+++ b/src/assets/3ds.jpeg
Binary files differ
diff --git a/src/assets/404.png b/src/assets/404.png
new file mode 100644
index 0000000..f8f7719
--- /dev/null
+++ b/src/assets/404.png
Binary files differ
diff --git a/src/assets/980pro.webp b/src/assets/980pro.webp
new file mode 100644
index 0000000..6e004bf
--- /dev/null
+++ b/src/assets/980pro.webp
Binary files differ
diff --git a/src/assets/B550MITX.webp b/src/assets/B550MITX.webp
new file mode 100644
index 0000000..aeaf364
--- /dev/null
+++ b/src/assets/B550MITX.webp
Binary files differ
diff --git a/src/assets/a1.png b/src/assets/a1.png
new file mode 100644
index 0000000..04c0133
--- /dev/null
+++ b/src/assets/a1.png
Binary files differ
diff --git a/src/assets/airpodspro.webp b/src/assets/airpodspro.webp
new file mode 100644
index 0000000..7426f84
--- /dev/null
+++ b/src/assets/airpodspro.webp
Binary files differ
diff --git a/src/assets/amdryzen55600g.webp b/src/assets/amdryzen55600g.webp
new file mode 100644
index 0000000..ffd55a3
--- /dev/null
+++ b/src/assets/amdryzen55600g.webp
Binary files differ
diff --git a/src/assets/amdryzen7700x.webp b/src/assets/amdryzen7700x.webp
new file mode 100644
index 0000000..22393d1
--- /dev/null
+++ b/src/assets/amdryzen7700x.webp
Binary files differ
diff --git a/src/assets/authentication.webp b/src/assets/authentication.webp
new file mode 100644
index 0000000..91980d2
--- /dev/null
+++ b/src/assets/authentication.webp
Binary files differ
diff --git a/src/assets/b650e-i.webp b/src/assets/b650e-i.webp
new file mode 100644
index 0000000..5cb20f6
--- /dev/null
+++ b/src/assets/b650e-i.webp
Binary files differ
diff --git a/src/assets/esp32.jpeg b/src/assets/esp32.jpeg
new file mode 100644
index 0000000..ee4331f
--- /dev/null
+++ b/src/assets/esp32.jpeg
Binary files differ
diff --git a/src/assets/github.png b/src/assets/github.png
new file mode 100644
index 0000000..e03d8dd
--- /dev/null
+++ b/src/assets/github.png
Binary files differ
diff --git a/src/assets/gskilltridentz5.webp b/src/assets/gskilltridentz5.webp
new file mode 100644
index 0000000..6017949
--- /dev/null
+++ b/src/assets/gskilltridentz5.webp
Binary files differ
diff --git a/src/assets/hackintosh.webp b/src/assets/hackintosh.webp
new file mode 100644
index 0000000..831e7b7
--- /dev/null
+++ b/src/assets/hackintosh.webp
Binary files differ
diff --git a/src/assets/iphone11.webp b/src/assets/iphone11.webp
new file mode 100644
index 0000000..6708e1d
--- /dev/null
+++ b/src/assets/iphone11.webp
Binary files differ
diff --git a/src/assets/jonsbon2.webp b/src/assets/jonsbon2.webp
new file mode 100644
index 0000000..a7f9459
--- /dev/null
+++ b/src/assets/jonsbon2.webp
Binary files differ
diff --git a/src/assets/ksm32ed8.webp b/src/assets/ksm32ed8.webp
new file mode 100644
index 0000000..5de5a94
--- /dev/null
+++ b/src/assets/ksm32ed8.webp
Binary files differ
diff --git a/src/assets/macbookair.webp b/src/assets/macbookair.webp
new file mode 100644
index 0000000..2713c6d
--- /dev/null
+++ b/src/assets/macbookair.webp
Binary files differ
diff --git a/src/assets/markdownparser.webp b/src/assets/markdownparser.webp
new file mode 100644
index 0000000..be7c23d
--- /dev/null
+++ b/src/assets/markdownparser.webp
Binary files differ
diff --git a/src/assets/nas.png b/src/assets/nas.png
new file mode 100644
index 0000000..0c50ba3
--- /dev/null
+++ b/src/assets/nas.png
Binary files differ
diff --git a/src/assets/ncasem2.webp b/src/assets/ncasem2.webp
new file mode 100644
index 0000000..f887f25
--- /dev/null
+++ b/src/assets/ncasem2.webp
Binary files differ
diff --git a/src/assets/noctuanh-d9l.webp b/src/assets/noctuanh-d9l.webp
new file mode 100644
index 0000000..3d9b00e
--- /dev/null
+++ b/src/assets/noctuanh-d9l.webp
Binary files differ
diff --git a/src/assets/p3plus.webp b/src/assets/p3plus.webp
new file mode 100644
index 0000000..f02ced1
--- /dev/null
+++ b/src/assets/p3plus.webp
Binary files differ
diff --git a/src/assets/patriop300.webp b/src/assets/patriop300.webp
new file mode 100644
index 0000000..aa81f3c
--- /dev/null
+++ b/src/assets/patriop300.webp
Binary files differ
diff --git a/src/assets/pi.png b/src/assets/pi.png
new file mode 100644
index 0000000..90760e9
--- /dev/null
+++ b/src/assets/pi.png
Binary files differ
diff --git a/src/assets/quiz.webp b/src/assets/quiz.webp
new file mode 100644
index 0000000..52990b7
--- /dev/null
+++ b/src/assets/quiz.webp
Binary files differ
diff --git a/src/assets/raylibshooter.webp b/src/assets/raylibshooter.webp
new file mode 100644
index 0000000..36a8559
--- /dev/null
+++ b/src/assets/raylibshooter.webp
Binary files differ
diff --git a/src/assets/rice.jpg b/src/assets/rice.jpg
new file mode 100644
index 0000000..38ada10
--- /dev/null
+++ b/src/assets/rice.jpg
Binary files differ
diff --git a/src/assets/rx9070xt.webp b/src/assets/rx9070xt.webp
new file mode 100644
index 0000000..cd3b89b
--- /dev/null
+++ b/src/assets/rx9070xt.webp
Binary files differ
diff --git a/src/assets/seagateironwolf.webp b/src/assets/seagateironwolf.webp
new file mode 100644
index 0000000..25fd27f
--- /dev/null
+++ b/src/assets/seagateironwolf.webp
Binary files differ
diff --git a/src/assets/setup.jpg b/src/assets/setup.jpg
new file mode 100644
index 0000000..e605735
--- /dev/null
+++ b/src/assets/setup.jpg
Binary files differ
diff --git a/src/assets/sf450.webp b/src/assets/sf450.webp
new file mode 100644
index 0000000..4c15b5d
--- /dev/null
+++ b/src/assets/sf450.webp
Binary files differ
diff --git a/src/assets/sf750.webp b/src/assets/sf750.webp
new file mode 100644
index 0000000..cf1afe1
--- /dev/null
+++ b/src/assets/sf750.webp
Binary files differ
diff --git a/src/assets/spaceinvaders.webp b/src/assets/spaceinvaders.webp
new file mode 100644
index 0000000..01065ec
--- /dev/null
+++ b/src/assets/spaceinvaders.webp
Binary files differ
diff --git a/src/assets/specula.webp b/src/assets/specula.webp
new file mode 100644
index 0000000..a148c4c
--- /dev/null
+++ b/src/assets/specula.webp
Binary files differ
diff --git a/src/assets/todolist.webp b/src/assets/todolist.webp
new file mode 100644
index 0000000..2a833d5
--- /dev/null
+++ b/src/assets/todolist.webp
Binary files differ
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}
+ >
+ &#8592;
+ </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}
+ >
+ &#8594;
+ </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" />