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/components | |
| parent | 8f2c8393510dfefc22871661b0ef9964569e290b (diff) | |
rewrite site
Diffstat (limited to 'src/components')
| -rw-r--r-- | src/components/CardLink.tsx | 29 | ||||
| -rw-r--r-- | src/components/CodeSnippet.tsx | 94 | ||||
| -rw-r--r-- | src/components/ExternalLink.tsx | 3 | ||||
| -rw-r--r-- | src/components/Footer.tsx | 21 | ||||
| -rw-r--r-- | src/components/GitHubIcon.tsx | 12 | ||||
| -rw-r--r-- | src/components/ImageGalleryGrid.tsx | 96 | ||||
| -rw-r--r-- | src/components/LinkWithIcon.tsx | 25 | ||||
| -rw-r--r-- | src/components/MailIcon.tsx | 12 | ||||
| -rw-r--r-- | src/components/Navbar.tsx | 25 | ||||
| -rw-r--r-- | src/components/ThemeToggle.tsx | 15 |
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} + > + ← + </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> + ); +} |
