aboutsummaryrefslogtreecommitdiff
path: root/src/components
diff options
context:
space:
mode:
authorschererleander <leander@schererleander.de>2025-05-30 01:01:17 +0200
committerschererleander <leander@schererleander.de>2025-05-30 01:01:17 +0200
commitafdc982863b6cca573f1db58e1795aa8c45fabca (patch)
tree6b94d2ffdcb0e1b5ccbaf584c825763ab72ab99d /src/components
parent8f2c8393510dfefc22871661b0ef9964569e290b (diff)
rewrite site
Diffstat (limited to 'src/components')
-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
10 files changed, 332 insertions, 0 deletions
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>
+ );
+}