Initial import: was v0 exportiert hat
This commit is contained in:
commit
0194931215
67 changed files with 16465 additions and 0 deletions
120
components/about-section.tsx
Normal file
120
components/about-section.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
'use client'
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
const timeline = [
|
||||
{
|
||||
era: '1984',
|
||||
title: 'Der Anfang',
|
||||
description: 'Erste Programme in Fortran IV. Lochkarten, Großrechner, Batch-Verarbeitung. Die Grundlagen des algorithmischen Denkens.',
|
||||
tech: ['Fortran IV'],
|
||||
},
|
||||
{
|
||||
era: '1986',
|
||||
title: 'Systemnahe Programmierung',
|
||||
description: 'C und 68000-Assembler auf dem Atari ST. Direkte Hardware-Ansteuerung, Interrupt-Routinen, Speicherverwaltung von Hand.',
|
||||
tech: ['C', '68000 ASM', 'GFA Basic'],
|
||||
},
|
||||
{
|
||||
era: '1990',
|
||||
title: 'Unix an der Universität',
|
||||
description: 'SunOS, HP-UX, BSD. Die Philosophie kleiner, zusammenarbeitender Programme. Shell-Scripting als Werkzeug.',
|
||||
tech: ['Unix', 'Shell', 'vi'],
|
||||
},
|
||||
{
|
||||
era: '1994',
|
||||
title: 'Linux entdeckt',
|
||||
description: 'Slackware auf 40 Disketten. Der Beginn einer dauerhaften Begeisterung für offene Systeme und Selbstbestimmung.',
|
||||
tech: ['Linux', 'GNU Tools', 'X11'],
|
||||
},
|
||||
{
|
||||
era: '2000er',
|
||||
title: 'Professionelle Entwicklung',
|
||||
description: 'Verschiedene Sprachen, verschiedene Domänen. Server-Administration, Automatisierung, Web-Entwicklung.',
|
||||
tech: ['Perl', 'PHP', 'Bash', 'Python'],
|
||||
},
|
||||
{
|
||||
era: '2022',
|
||||
title: 'KI-Fokus',
|
||||
description: 'Seit Ende 2022 intensive Beschäftigung mit KI-Tools und KI-Programmierung. LLMs als Arbeitswerkzeug und Forschungsgegenstand.',
|
||||
tech: ['Python', 'Rust', 'LLMs', 'ML'],
|
||||
},
|
||||
]
|
||||
|
||||
export function AboutSection() {
|
||||
return (
|
||||
<section id="ueber" className="py-24 lg:py-32">
|
||||
<div className="max-w-6xl mx-auto px-6 lg:px-8">
|
||||
{/* Section Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="mb-16 lg:mb-24"
|
||||
>
|
||||
<span className="font-mono text-xs tracking-wider text-accent uppercase">
|
||||
Hintergrund
|
||||
</span>
|
||||
<h2 className="font-serif text-3xl sm:text-4xl lg:text-5xl mt-4 text-balance">
|
||||
Vier Jahrzehnte Programmierung
|
||||
</h2>
|
||||
<p className="mt-6 text-lg text-muted-foreground max-w-2xl">
|
||||
Eine Reise durch die Evolution der Software-Entwicklung –
|
||||
von Lochkarten bis zu Large Language Models.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="relative">
|
||||
{/* Vertical line */}
|
||||
<div className="absolute left-0 lg:left-1/2 top-0 bottom-0 w-px bg-border lg:-translate-x-px hidden sm:block" />
|
||||
|
||||
<div className="space-y-12 lg:space-y-16">
|
||||
{timeline.map((item, index) => (
|
||||
<motion.div
|
||||
key={item.era}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: '-50px' }}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
className={`relative grid lg:grid-cols-2 gap-8 lg:gap-16 ${
|
||||
index % 2 === 0 ? '' : 'lg:direction-rtl'
|
||||
}`}
|
||||
>
|
||||
{/* Timeline dot */}
|
||||
<div className="absolute left-0 lg:left-1/2 top-0 -translate-x-1/2 hidden sm:block">
|
||||
<div className="size-3 rounded-full bg-background border-2 border-accent" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className={`pl-8 sm:pl-12 lg:pl-0 ${index % 2 === 0 ? 'lg:pr-16 lg:text-right' : 'lg:col-start-2 lg:pl-16'}`}>
|
||||
<div className={`lg:direction-ltr ${index % 2 === 0 ? 'lg:ml-auto lg:max-w-md' : 'lg:max-w-md'}`}>
|
||||
<span className="font-mono text-sm text-accent">{item.era}</span>
|
||||
<h3 className="font-serif text-xl lg:text-2xl mt-2">{item.title}</h3>
|
||||
<p className="mt-3 text-muted-foreground leading-relaxed">
|
||||
{item.description}
|
||||
</p>
|
||||
<div className={`mt-4 flex flex-wrap gap-2 ${index % 2 === 0 ? 'lg:justify-end' : ''}`}>
|
||||
{item.tech.map((tech) => (
|
||||
<span
|
||||
key={tech}
|
||||
className="font-mono text-xs px-2 py-1 bg-secondary text-secondary-foreground rounded"
|
||||
>
|
||||
{tech}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Spacer for alternating layout */}
|
||||
{index % 2 !== 0 && <div className="hidden lg:block" />}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
122
components/focus-section.tsx
Normal file
122
components/focus-section.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
'use client'
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
const focusAreas = [
|
||||
{
|
||||
category: 'Sprachen',
|
||||
items: [
|
||||
{ name: 'Python', detail: 'Haupt-Sprache für KI und Automatisierung' },
|
||||
{ name: 'Rust', detail: 'Für performante Systemtools' },
|
||||
{ name: 'Shell/Bash', detail: 'Tägliches Werkzeug' },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'Systeme',
|
||||
items: [
|
||||
{ name: 'Linux', detail: 'Arch, Debian, NixOS' },
|
||||
{ name: 'Self-Hosting', detail: 'Eigene Infrastruktur' },
|
||||
{ name: 'Containers', detail: 'Docker, Podman' },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'KI & ML',
|
||||
items: [
|
||||
{ name: 'LLMs', detail: 'GPT, Claude, lokale Modelle' },
|
||||
{ name: 'KI-Tools', detail: 'Cursor, Copilot, Aider' },
|
||||
{ name: 'ML Ops', detail: 'Training, Deployment' },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'Interessen',
|
||||
items: [
|
||||
{ name: 'Automatisierung', detail: 'Workflows, Pipelines' },
|
||||
{ name: 'CLI Tools', detail: 'Terminal-first' },
|
||||
{ name: 'Open Source', detail: 'Nutzung & Beteiligung' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export function FocusSection() {
|
||||
return (
|
||||
<section id="fokus" className="py-24 lg:py-32">
|
||||
<div className="max-w-6xl mx-auto px-6 lg:px-8">
|
||||
{/* Section Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="mb-16"
|
||||
>
|
||||
<span className="font-mono text-xs tracking-wider text-accent uppercase">
|
||||
Aktuell
|
||||
</span>
|
||||
<h2 className="font-serif text-3xl sm:text-4xl lg:text-5xl mt-4 text-balance">
|
||||
Technologien & Fokus
|
||||
</h2>
|
||||
<p className="mt-6 text-lg text-muted-foreground max-w-2xl">
|
||||
Die Werkzeuge und Themen, mit denen ich aktuell arbeite.
|
||||
Kein Buzzword-Bingo, sondern tägliche Praxis.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Focus Grid */}
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-8 lg:gap-6">
|
||||
{focusAreas.map((area, areaIndex) => (
|
||||
<motion.div
|
||||
key={area.category}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: '-50px' }}
|
||||
transition={{ duration: 0.5, delay: areaIndex * 0.1 }}
|
||||
>
|
||||
<h3 className="font-mono text-sm text-accent mb-6 pb-3 border-b border-border">
|
||||
{area.category}
|
||||
</h3>
|
||||
<ul className="space-y-4">
|
||||
{area.items.map((item, itemIndex) => (
|
||||
<motion.li
|
||||
key={item.name}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.3, delay: areaIndex * 0.1 + itemIndex * 0.05 }}
|
||||
className="group"
|
||||
>
|
||||
<span className="block font-medium text-foreground group-hover:text-accent transition-colors">
|
||||
{item.name}
|
||||
</span>
|
||||
<span className="block text-sm text-muted-foreground mt-0.5">
|
||||
{item.detail}
|
||||
</span>
|
||||
</motion.li>
|
||||
))}
|
||||
</ul>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Secondary visual element */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
whileInView={{ opacity: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8, delay: 0.4 }}
|
||||
className="mt-20 pt-12 border-t border-border"
|
||||
>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{['vim', 'git', 'tmux', 'ssh', 'systemd', 'nginx', 'PostgreSQL', 'Redis', 'FastAPI', 'PyTorch', 'Transformers', 'tiktoken'].map((tool) => (
|
||||
<span
|
||||
key={tool}
|
||||
className="font-mono text-xs px-3 py-1.5 border border-border rounded text-muted-foreground hover:text-foreground hover:border-accent/50 transition-colors cursor-default"
|
||||
>
|
||||
{tool}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
128
components/footer.tsx
Normal file
128
components/footer.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Github, Mail, ArrowUpRight } from 'lucide-react'
|
||||
|
||||
const footerLinks = [
|
||||
{ label: 'Projekte', href: '#projekte' },
|
||||
{ label: 'Über', href: '#ueber' },
|
||||
{ label: 'Fokus', href: '#fokus' },
|
||||
{ label: 'Philosophie', href: '#philosophie' },
|
||||
]
|
||||
|
||||
const legalLinks = [
|
||||
{ label: 'Impressum', href: '/impressum' },
|
||||
{ label: 'Datenschutz', href: '/datenschutz' },
|
||||
]
|
||||
|
||||
const socialLinks = [
|
||||
{ label: 'GitHub', href: 'https://github.com', icon: Github },
|
||||
{ label: 'E-Mail', href: 'mailto:kontakt@jamulix.de', icon: Mail },
|
||||
]
|
||||
|
||||
export function Footer() {
|
||||
const currentYear = new Date().getFullYear()
|
||||
|
||||
return (
|
||||
<footer id="kontakt" className="py-16 lg:py-20 border-t border-border">
|
||||
<div className="max-w-6xl mx-auto px-6 lg:px-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
{/* Main footer content */}
|
||||
<div className="grid lg:grid-cols-12 gap-12 lg:gap-8">
|
||||
{/* Brand section */}
|
||||
<div className="lg:col-span-5">
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-block font-mono text-xl tracking-tight text-foreground hover:text-accent transition-colors"
|
||||
>
|
||||
Jamulix
|
||||
</Link>
|
||||
<p className="mt-4 text-muted-foreground max-w-sm leading-relaxed">
|
||||
40 Jahre Programmierung. Von Fortran bis KI.
|
||||
Linux-Enthusiast seit 1994. Aktuell Python und Rust.
|
||||
</p>
|
||||
|
||||
{/* Social links */}
|
||||
<div className="flex gap-4 mt-6">
|
||||
{socialLinks.map((link) => {
|
||||
const Icon = link.icon
|
||||
return (
|
||||
<Link
|
||||
key={link.label}
|
||||
href={link.href}
|
||||
target={link.href.startsWith('http') ? '_blank' : undefined}
|
||||
rel={link.href.startsWith('http') ? 'noopener noreferrer' : undefined}
|
||||
className="group flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label={link.label}
|
||||
>
|
||||
<Icon className="size-5" />
|
||||
<span className="text-sm">{link.label}</span>
|
||||
{link.href.startsWith('http') && (
|
||||
<ArrowUpRight className="size-3 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="lg:col-span-3 lg:col-start-7">
|
||||
<h3 className="font-mono text-sm text-accent mb-4">Navigation</h3>
|
||||
<ul className="space-y-3">
|
||||
{footerLinks.map((link) => (
|
||||
<li key={link.href}>
|
||||
<Link
|
||||
href={link.href}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Legal */}
|
||||
<div className="lg:col-span-3">
|
||||
<h3 className="font-mono text-sm text-accent mb-4">Rechtliches</h3>
|
||||
<ul className="space-y-3">
|
||||
{legalLinks.map((link) => (
|
||||
<li key={link.href}>
|
||||
<Link
|
||||
href={link.href}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
<li>
|
||||
<span className="text-muted-foreground/60 text-sm">
|
||||
Blog (bald)
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom bar */}
|
||||
<div className="mt-16 pt-8 border-t border-border flex flex-col sm:flex-row justify-between items-center gap-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
© {currentYear} Jamulix. Alle Rechte vorbehalten.
|
||||
</p>
|
||||
<p className="font-mono text-xs text-muted-foreground/60">
|
||||
Gebaut mit Next.js, Tailwind, und viel Kaffee.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
98
components/header.tsx
Normal file
98
components/header.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Menu, X } from 'lucide-react'
|
||||
import { ThemeToggle } from './theme-toggle'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const navLinks = [
|
||||
{ href: '#projekte', label: 'Projekte' },
|
||||
{ href: '#ueber', label: 'Über' },
|
||||
{ href: '#fokus', label: 'Fokus' },
|
||||
{ href: '#philosophie', label: 'Philosophie' },
|
||||
{ href: '#kontakt', label: 'Kontakt' },
|
||||
]
|
||||
|
||||
export function Header() {
|
||||
const [isScrolled, setIsScrolled] = useState(false)
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setIsScrolled(window.scrollY > 20)
|
||||
}
|
||||
window.addEventListener('scroll', handleScroll, { passive: true })
|
||||
return () => window.removeEventListener('scroll', handleScroll)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
'fixed top-0 left-0 right-0 z-50 transition-all duration-300',
|
||||
isScrolled
|
||||
? 'bg-background/80 backdrop-blur-md border-b border-border/50'
|
||||
: 'bg-transparent'
|
||||
)}
|
||||
>
|
||||
<div className="max-w-6xl mx-auto px-6 lg:px-8">
|
||||
<nav className="flex items-center justify-between h-16 lg:h-20">
|
||||
{/* Logo */}
|
||||
<Link
|
||||
href="/"
|
||||
className="font-mono text-lg tracking-tight text-foreground hover:text-accent transition-colors"
|
||||
>
|
||||
Jamulix
|
||||
</Link>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<div className="hidden md:flex items-center gap-8">
|
||||
{navLinks.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
<div className="flex items-center gap-2 md:hidden">
|
||||
<ThemeToggle />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||
className="size-9 text-muted-foreground"
|
||||
aria-label={isMobileMenuOpen ? 'Menü schließen' : 'Menü öffnen'}
|
||||
>
|
||||
{isMobileMenuOpen ? <X className="size-5" /> : <Menu className="size-5" />}
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Mobile Navigation */}
|
||||
{isMobileMenuOpen && (
|
||||
<div className="md:hidden pb-6 border-b border-border/50">
|
||||
<div className="flex flex-col gap-4">
|
||||
{navLinks.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors py-2"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
108
components/hero.tsx
Normal file
108
components/hero.tsx
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { motion } from 'framer-motion'
|
||||
import { ArrowDown, ArrowRight } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { SystemDiagram } from './system-diagram'
|
||||
|
||||
export function Hero() {
|
||||
return (
|
||||
<section className="relative min-h-screen flex items-center pt-20 lg:pt-0">
|
||||
{/* Subtle grid background */}
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.02] dark:opacity-[0.04]"
|
||||
style={{
|
||||
backgroundImage: `
|
||||
linear-gradient(to right, currentColor 1px, transparent 1px),
|
||||
linear-gradient(to bottom, currentColor 1px, transparent 1px)
|
||||
`,
|
||||
backgroundSize: '60px 60px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative max-w-6xl mx-auto px-6 lg:px-8 py-20 lg:py-32">
|
||||
<div className="grid lg:grid-cols-12 gap-12 lg:gap-16 items-center">
|
||||
{/* Text Content */}
|
||||
<div className="lg:col-span-7 space-y-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<span className="inline-block font-mono text-xs tracking-wider text-accent uppercase mb-6">
|
||||
1984 – heute
|
||||
</span>
|
||||
|
||||
<h1 className="font-serif text-4xl sm:text-5xl lg:text-6xl xl:text-7xl leading-[1.1] tracking-tight text-balance">
|
||||
Seit 40 Jahren Code. Von Fortran{'\u00A0'}IV bis KI-Projekte in Python und Rust.
|
||||
</h1>
|
||||
</motion.div>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.1 }}
|
||||
className="text-lg lg:text-xl text-muted-foreground leading-relaxed max-w-2xl"
|
||||
>
|
||||
Erste Programmiersprache: Fortran{'\u00A0'}IV. Dann C und 68000-Assembler auf dem Atari{'\u00A0'}ST.
|
||||
An der Universität Unix entdeckt – und Linux nie wieder losgelassen.
|
||||
Seit Ende 2022 konzentriert auf KI-Tools und KI-Programmierung.
|
||||
Heute: Python und Rust.
|
||||
</motion.p>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
className="flex flex-col sm:flex-row gap-4 pt-4"
|
||||
>
|
||||
<Button asChild size="lg" className="group">
|
||||
<Link href="#projekte">
|
||||
Projekte ansehen
|
||||
<ArrowDown className="ml-2 size-4 transition-transform group-hover:translate-y-0.5" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="lg" className="group">
|
||||
<Link href="#ueber">
|
||||
Über Jamulix
|
||||
<ArrowRight className="ml-2 size-4 transition-transform group-hover:translate-x-0.5" />
|
||||
</Link>
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* System Diagram */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.8, delay: 0.3 }}
|
||||
className="lg:col-span-5 hidden lg:block"
|
||||
>
|
||||
<SystemDiagram />
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Scroll indicator */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.6, delay: 0.8 }}
|
||||
className="absolute bottom-8 left-1/2 -translate-x-1/2 hidden lg:block"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
||||
<span className="font-mono text-xs tracking-wider">Scroll</span>
|
||||
<motion.div
|
||||
animate={{ y: [0, 6, 0] }}
|
||||
transition={{ duration: 1.5, repeat: Infinity, ease: 'easeInOut' }}
|
||||
>
|
||||
<ArrowDown className="size-4" />
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
88
components/philosophy-section.tsx
Normal file
88
components/philosophy-section.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
'use client'
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
const principles = [
|
||||
{
|
||||
title: 'Langfristige Neugier',
|
||||
text: 'Technologie verändert sich ständig. Das Interesse daran nicht. Wer 1984 mit Programmieren begonnen hat, hat viele Paradigmenwechsel erlebt – und überlebt.',
|
||||
},
|
||||
{
|
||||
title: 'Handwerkliche Sorgfalt',
|
||||
text: 'Code ist keine Einwegware. Lesbarkeit, Wartbarkeit, Einfachheit – das sind keine Nice-to-haves, sondern Grundanforderungen.',
|
||||
},
|
||||
{
|
||||
title: 'Systeme verstehen',
|
||||
text: 'Nicht nur nutzen, sondern begreifen. Vom Kernel bis zum Compiler. Nur wer versteht, wie Dinge zusammenhängen, kann sie wirklich beherrschen.',
|
||||
},
|
||||
{
|
||||
title: 'KI als Werkzeug',
|
||||
text: 'Künstliche Intelligenz ist kein Hype, sondern ein ernsthaftes Werkzeug. Es verändert, wie wir programmieren – aber nicht warum.',
|
||||
},
|
||||
]
|
||||
|
||||
export function PhilosophySection() {
|
||||
return (
|
||||
<section id="philosophie" className="py-24 lg:py-32 bg-secondary/30">
|
||||
<div className="max-w-6xl mx-auto px-6 lg:px-8">
|
||||
{/* Section Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="mb-16 lg:mb-20"
|
||||
>
|
||||
<span className="font-mono text-xs tracking-wider text-accent uppercase">
|
||||
Prinzipien
|
||||
</span>
|
||||
<h2 className="font-serif text-3xl sm:text-4xl lg:text-5xl mt-4 text-balance">
|
||||
Philosophie
|
||||
</h2>
|
||||
</motion.div>
|
||||
|
||||
{/* Principles Grid */}
|
||||
<div className="grid lg:grid-cols-2 gap-12 lg:gap-x-16 lg:gap-y-14">
|
||||
{principles.map((principle, index) => (
|
||||
<motion.article
|
||||
key={principle.title}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: '-50px' }}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
className="relative"
|
||||
>
|
||||
{/* Decorative number */}
|
||||
<span className="absolute -left-2 lg:-left-8 top-0 font-mono text-6xl lg:text-7xl font-bold text-border/50 select-none">
|
||||
{String(index + 1).padStart(2, '0')}
|
||||
</span>
|
||||
|
||||
<div className="relative pl-8 lg:pl-0">
|
||||
<h3 className="font-serif text-xl lg:text-2xl mb-4">
|
||||
{principle.title}
|
||||
</h3>
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
{principle.text}
|
||||
</p>
|
||||
</div>
|
||||
</motion.article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Closing statement */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: 0.4 }}
|
||||
className="mt-20 pt-12 border-t border-border"
|
||||
>
|
||||
<blockquote className="font-serif text-xl lg:text-2xl text-center max-w-3xl mx-auto text-balance">
|
||||
“Der beste Code ist der, den man in zehn Jahren noch versteht –
|
||||
geschrieben von jemandem, der in zehn Jahren noch lernt.”
|
||||
</blockquote>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
154
components/projects-section.tsx
Normal file
154
components/projects-section.tsx
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
'use client'
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
import { ArrowUpRight, ExternalLink } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import Link from 'next/link'
|
||||
|
||||
const projects = [
|
||||
{
|
||||
title: 'Hacker News Reader',
|
||||
slug: 'hackernews',
|
||||
description: 'Ein minimalistischer, schneller Hacker News Client. Fokus auf Lesbarkeit und schnelles Laden.',
|
||||
tech: ['Python', 'FastAPI', 'HTMX'],
|
||||
status: 'Aktiv',
|
||||
url: 'https://jamulix.de/hackernews/',
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
title: 'Sorting Visualizer',
|
||||
slug: 'sorting',
|
||||
description: 'Interaktive Visualisierung verschiedener Sortieralgorithmen. Didaktisches Werkzeug für Algorithmen-Verständnis.',
|
||||
tech: ['Rust', 'WASM', 'Canvas'],
|
||||
status: 'Aktiv',
|
||||
url: 'https://jamulix.de/sorting',
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
title: 'Projekt in Arbeit',
|
||||
slug: 'upcoming-1',
|
||||
description: 'KI-gestütztes Werkzeug für Code-Analyse und Dokumentation. Details folgen.',
|
||||
tech: ['Python', 'LLM', 'AST'],
|
||||
status: 'In Entwicklung',
|
||||
url: null,
|
||||
featured: false,
|
||||
},
|
||||
{
|
||||
title: 'Geplant',
|
||||
slug: 'upcoming-2',
|
||||
description: 'Self-hosted Infrastruktur-Monitoring. Lightweight, ohne Cloud-Abhängigkeiten.',
|
||||
tech: ['Rust', 'Linux', 'SQLite'],
|
||||
status: 'Geplant',
|
||||
url: null,
|
||||
featured: false,
|
||||
},
|
||||
]
|
||||
|
||||
function ProjectCard({ project, index }: { project: typeof projects[0]; index: number }) {
|
||||
const isClickable = project.url !== null
|
||||
|
||||
return (
|
||||
<motion.article
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: '-50px' }}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
className={`group relative ${project.featured ? 'lg:col-span-1' : ''}`}
|
||||
>
|
||||
<div
|
||||
className={`
|
||||
relative h-full p-6 lg:p-8 rounded-lg border border-border
|
||||
bg-card transition-all duration-300
|
||||
${isClickable ? 'hover:border-accent/50 hover:bg-card/80' : 'opacity-70'}
|
||||
`}
|
||||
>
|
||||
{/* Status badge */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span
|
||||
className={`
|
||||
font-mono text-xs px-2 py-1 rounded
|
||||
${project.status === 'Aktiv'
|
||||
? 'bg-accent/10 text-accent'
|
||||
: project.status === 'In Entwicklung'
|
||||
? 'bg-secondary text-muted-foreground'
|
||||
: 'bg-secondary/50 text-muted-foreground/70'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{project.status}
|
||||
</span>
|
||||
{isClickable && (
|
||||
<ArrowUpRight className="size-5 text-muted-foreground group-hover:text-accent transition-colors" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="font-serif text-xl lg:text-2xl mb-3 group-hover:text-accent transition-colors">
|
||||
{project.title}
|
||||
</h3>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-muted-foreground leading-relaxed mb-6">
|
||||
{project.description}
|
||||
</p>
|
||||
|
||||
{/* Tech stack */}
|
||||
<div className="flex flex-wrap gap-2 mb-6">
|
||||
{project.tech.map((tech) => (
|
||||
<span
|
||||
key={tech}
|
||||
className="font-mono text-xs px-2 py-1 bg-secondary text-secondary-foreground rounded"
|
||||
>
|
||||
{tech}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Link */}
|
||||
{isClickable && (
|
||||
<Button asChild variant="outline" size="sm" className="group/btn">
|
||||
<Link href={project.url} target="_blank" rel="noopener noreferrer">
|
||||
Ansehen
|
||||
<ExternalLink className="ml-2 size-3 transition-transform group-hover/btn:translate-x-0.5" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</motion.article>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProjectsSection() {
|
||||
return (
|
||||
<section id="projekte" className="py-24 lg:py-32 bg-secondary/30">
|
||||
<div className="max-w-6xl mx-auto px-6 lg:px-8">
|
||||
{/* Section Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="mb-16"
|
||||
>
|
||||
<span className="font-mono text-xs tracking-wider text-accent uppercase">
|
||||
Experimente
|
||||
</span>
|
||||
<h2 className="font-serif text-3xl sm:text-4xl lg:text-5xl mt-4 text-balance">
|
||||
Ausgewählte Projekte
|
||||
</h2>
|
||||
<p className="mt-6 text-lg text-muted-foreground max-w-2xl">
|
||||
Technische Experimente und Werkzeuge. Weniger Portfolio,
|
||||
mehr Labor für Ideen.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Projects Grid */}
|
||||
<div className="grid md:grid-cols-2 gap-6 lg:gap-8">
|
||||
{projects.map((project, index) => (
|
||||
<ProjectCard key={project.slug} project={project} index={index} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
119
components/system-diagram.tsx
Normal file
119
components/system-diagram.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
'use client'
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
const nodes = [
|
||||
{ id: 'fortran', label: 'Fortran IV', x: 20, y: 15, era: '1984' },
|
||||
{ id: 'c', label: 'C', x: 45, y: 25, era: '1986' },
|
||||
{ id: 'asm', label: '68K ASM', x: 75, y: 20, era: '1987' },
|
||||
{ id: 'unix', label: 'Unix', x: 30, y: 45, era: '1990' },
|
||||
{ id: 'linux', label: 'Linux', x: 60, y: 50, era: '1994' },
|
||||
{ id: 'python', label: 'Python', x: 25, y: 75, era: '2020' },
|
||||
{ id: 'rust', label: 'Rust', x: 55, y: 80, era: '2022' },
|
||||
{ id: 'ai', label: 'KI', x: 80, y: 70, era: '2023' },
|
||||
]
|
||||
|
||||
const connections = [
|
||||
['fortran', 'c'],
|
||||
['c', 'asm'],
|
||||
['c', 'unix'],
|
||||
['unix', 'linux'],
|
||||
['linux', 'python'],
|
||||
['linux', 'rust'],
|
||||
['python', 'ai'],
|
||||
['rust', 'ai'],
|
||||
]
|
||||
|
||||
export function SystemDiagram() {
|
||||
const getNodePosition = (id: string) => {
|
||||
const node = nodes.find(n => n.id === id)
|
||||
return node ? { x: node.x, y: node.y } : { x: 0, y: 0 }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative aspect-square w-full max-w-md mx-auto">
|
||||
{/* SVG for connections */}
|
||||
<svg className="absolute inset-0 w-full h-full" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid meet">
|
||||
<defs>
|
||||
<linearGradient id="lineGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="currentColor" stopOpacity="0.1" />
|
||||
<stop offset="50%" stopColor="currentColor" stopOpacity="0.3" />
|
||||
<stop offset="100%" stopColor="currentColor" stopOpacity="0.1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{connections.map(([from, to], index) => {
|
||||
const start = getNodePosition(from)
|
||||
const end = getNodePosition(to)
|
||||
return (
|
||||
<motion.line
|
||||
key={`${from}-${to}`}
|
||||
x1={start.x}
|
||||
y1={start.y}
|
||||
x2={end.x}
|
||||
y2={end.y}
|
||||
stroke="url(#lineGradient)"
|
||||
strokeWidth="0.5"
|
||||
className="text-accent"
|
||||
initial={{ pathLength: 0, opacity: 0 }}
|
||||
animate={{ pathLength: 1, opacity: 1 }}
|
||||
transition={{
|
||||
duration: 0.8,
|
||||
delay: 0.5 + index * 0.1,
|
||||
ease: 'easeOut'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{/* Nodes */}
|
||||
{nodes.map((node, index) => (
|
||||
<motion.div
|
||||
key={node.id}
|
||||
className="absolute transform -translate-x-1/2 -translate-y-1/2"
|
||||
style={{ left: `${node.x}%`, top: `${node.y}%` }}
|
||||
initial={{ opacity: 0, scale: 0 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{
|
||||
duration: 0.4,
|
||||
delay: 0.3 + index * 0.08,
|
||||
type: 'spring',
|
||||
stiffness: 200
|
||||
}}
|
||||
>
|
||||
<div className="group relative">
|
||||
{/* Node circle */}
|
||||
<div className="relative">
|
||||
<div className="size-3 rounded-full bg-accent/20 border border-accent/40 group-hover:bg-accent/30 transition-colors" />
|
||||
<div className="absolute inset-0 size-3 rounded-full bg-accent/60 animate-ping opacity-0 group-hover:opacity-30" />
|
||||
</div>
|
||||
|
||||
{/* Label */}
|
||||
<div className="absolute top-full left-1/2 -translate-x-1/2 mt-1.5 whitespace-nowrap">
|
||||
<span className="font-mono text-[10px] text-muted-foreground group-hover:text-foreground transition-colors">
|
||||
{node.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Era tooltip */}
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span className="font-mono text-[9px] text-accent bg-background/80 px-1.5 py-0.5 rounded border border-border/50">
|
||||
{node.era}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
{/* Decorative elements */}
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
{/* Corner accents */}
|
||||
<div className="absolute top-0 left-0 w-8 h-8 border-l border-t border-border/30" />
|
||||
<div className="absolute top-0 right-0 w-8 h-8 border-r border-t border-border/30" />
|
||||
<div className="absolute bottom-0 left-0 w-8 h-8 border-l border-b border-border/30" />
|
||||
<div className="absolute bottom-0 right-0 w-8 h-8 border-r border-b border-border/30" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
11
components/theme-provider.tsx
Normal file
11
components/theme-provider.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import {
|
||||
ThemeProvider as NextThemesProvider,
|
||||
type ThemeProviderProps,
|
||||
} from 'next-themes'
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
|
||||
}
|
||||
49
components/theme-toggle.tsx
Normal file
49
components/theme-toggle.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Moon, Sun } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
export function ThemeToggle() {
|
||||
const [theme, setTheme] = useState<'light' | 'dark'>('dark')
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
const stored = localStorage.getItem('theme') as 'light' | 'dark' | null
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
const initialTheme = stored || (prefersDark ? 'dark' : 'light')
|
||||
setTheme(initialTheme)
|
||||
}, [])
|
||||
|
||||
const toggleTheme = () => {
|
||||
const newTheme = theme === 'dark' ? 'light' : 'dark'
|
||||
setTheme(newTheme)
|
||||
localStorage.setItem('theme', newTheme)
|
||||
document.documentElement.classList.toggle('dark', newTheme === 'dark')
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<Button variant="ghost" size="icon" className="size-9 text-muted-foreground">
|
||||
<span className="sr-only">Theme umschalten</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={toggleTheme}
|
||||
className="size-9 text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label={theme === 'dark' ? 'Zum hellen Modus wechseln' : 'Zum dunklen Modus wechseln'}
|
||||
>
|
||||
{theme === 'dark' ? (
|
||||
<Sun className="size-5" />
|
||||
) : (
|
||||
<Moon className="size-5" />
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
66
components/ui/accordion.tsx
Normal file
66
components/ui/accordion.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as AccordionPrimitive from '@radix-ui/react-accordion'
|
||||
import { ChevronDownIcon } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Accordion({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
|
||||
}
|
||||
|
||||
function AccordionItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||
return (
|
||||
<AccordionPrimitive.Item
|
||||
data-slot="accordion-item"
|
||||
className={cn('border-b last:border-b-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||
return (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
'focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||
return (
|
||||
<AccordionPrimitive.Content
|
||||
data-slot="accordion-content"
|
||||
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn('pt-0 pb-4', className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
)
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
157
components/ui/alert-dialog.tsx
Normal file
157
components/ui/alert-dialog.tsx
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
|
||||
function AlertDialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
className={cn(
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn('text-lg font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Action
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
className={cn(buttonVariants({ variant: 'outline' }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
66
components/ui/alert.tsx
Normal file
66
components/ui/alert.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import * as React from 'react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const alertVariants = cva(
|
||||
'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-card text-card-foreground',
|
||||
destructive:
|
||||
'text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
'col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
'text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
11
components/ui/aspect-ratio.tsx
Normal file
11
components/ui/aspect-ratio.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
'use client'
|
||||
|
||||
import * as AspectRatioPrimitive from '@radix-ui/react-aspect-ratio'
|
||||
|
||||
function AspectRatio({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
|
||||
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />
|
||||
}
|
||||
|
||||
export { AspectRatio }
|
||||
53
components/ui/avatar.tsx
Normal file
53
components/ui/avatar.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as AvatarPrimitive from '@radix-ui/react-avatar'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
className={cn(
|
||||
'relative flex size-8 shrink-0 overflow-hidden rounded-full',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarImage({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn('aspect-square size-full', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
'bg-muted flex size-full items-center justify-center rounded-full',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
46
components/ui/badge.tsx
Normal file
46
components/ui/badge.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import * as React from 'react'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
|
||||
secondary:
|
||||
'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
|
||||
destructive:
|
||||
'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
||||
outline:
|
||||
'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'span'> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : 'span'
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
109
components/ui/breadcrumb.tsx
Normal file
109
components/ui/breadcrumb.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import * as React from 'react'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { ChevronRight, MoreHorizontal } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Breadcrumb({ ...props }: React.ComponentProps<'nav'>) {
|
||||
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
|
||||
}
|
||||
|
||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<'ol'>) {
|
||||
return (
|
||||
<ol
|
||||
data-slot="breadcrumb-list"
|
||||
className={cn(
|
||||
'text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<'li'>) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-item"
|
||||
className={cn('inline-flex items-center gap-1.5', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbLink({
|
||||
asChild,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'a'> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : 'a'
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="breadcrumb-link"
|
||||
className={cn('hover:text-foreground transition-colors', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-page"
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn('text-foreground font-normal', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'li'>) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-separator"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn('[&>svg]:size-3.5', className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn('flex size-9 items-center justify-center', className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
}
|
||||
83
components/ui/button-group.tsx
Normal file
83
components/ui/button-group.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
|
||||
const buttonGroupVariants = cva(
|
||||
"flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
horizontal:
|
||||
'[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none',
|
||||
vertical:
|
||||
'flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: 'horizontal',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
function ButtonGroup({
|
||||
className,
|
||||
orientation,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & VariantProps<typeof buttonGroupVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="button-group"
|
||||
data-orientation={orientation}
|
||||
className={cn(buttonGroupVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ButtonGroupText({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : 'div'
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={cn(
|
||||
"bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ButtonGroupSeparator({
|
||||
className,
|
||||
orientation = 'vertical',
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="button-group-separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
ButtonGroup,
|
||||
ButtonGroupSeparator,
|
||||
ButtonGroupText,
|
||||
buttonGroupVariants,
|
||||
}
|
||||
60
components/ui/button.tsx
Normal file
60
components/ui/button.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import * as React from 'react'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
||||
outline:
|
||||
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost:
|
||||
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
||||
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
|
||||
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
||||
icon: 'size-9',
|
||||
'icon-sm': 'size-8',
|
||||
'icon-lg': 'size-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'button'> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : 'button'
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
213
components/ui/calendar.tsx
Normal file
213
components/ui/calendar.tsx
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
} from 'lucide-react'
|
||||
import { DayButton, DayPicker, getDefaultClassNames } from 'react-day-picker'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button, buttonVariants } from '@/components/ui/button'
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
captionLayout = 'label',
|
||||
buttonVariant = 'ghost',
|
||||
formatters,
|
||||
components,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayPicker> & {
|
||||
buttonVariant?: React.ComponentProps<typeof Button>['variant']
|
||||
}) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn(
|
||||
'bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent',
|
||||
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||
className,
|
||||
)}
|
||||
captionLayout={captionLayout}
|
||||
formatters={{
|
||||
formatMonthDropdown: (date) =>
|
||||
date.toLocaleString('default', { month: 'short' }),
|
||||
...formatters,
|
||||
}}
|
||||
classNames={{
|
||||
root: cn('w-fit', defaultClassNames.root),
|
||||
months: cn(
|
||||
'flex gap-4 flex-col md:flex-row relative',
|
||||
defaultClassNames.months,
|
||||
),
|
||||
month: cn('flex flex-col w-full gap-4', defaultClassNames.month),
|
||||
nav: cn(
|
||||
'flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between',
|
||||
defaultClassNames.nav,
|
||||
),
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
'size-(--cell-size) aria-disabled:opacity-50 p-0 select-none',
|
||||
defaultClassNames.button_previous,
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
'size-(--cell-size) aria-disabled:opacity-50 p-0 select-none',
|
||||
defaultClassNames.button_next,
|
||||
),
|
||||
month_caption: cn(
|
||||
'flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)',
|
||||
defaultClassNames.month_caption,
|
||||
),
|
||||
dropdowns: cn(
|
||||
'w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5',
|
||||
defaultClassNames.dropdowns,
|
||||
),
|
||||
dropdown_root: cn(
|
||||
'relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md',
|
||||
defaultClassNames.dropdown_root,
|
||||
),
|
||||
dropdown: cn(
|
||||
'absolute bg-popover inset-0 opacity-0',
|
||||
defaultClassNames.dropdown,
|
||||
),
|
||||
caption_label: cn(
|
||||
'select-none font-medium',
|
||||
captionLayout === 'label'
|
||||
? 'text-sm'
|
||||
: 'rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5',
|
||||
defaultClassNames.caption_label,
|
||||
),
|
||||
table: 'w-full border-collapse',
|
||||
weekdays: cn('flex', defaultClassNames.weekdays),
|
||||
weekday: cn(
|
||||
'text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none',
|
||||
defaultClassNames.weekday,
|
||||
),
|
||||
week: cn('flex w-full mt-2', defaultClassNames.week),
|
||||
week_number_header: cn(
|
||||
'select-none w-(--cell-size)',
|
||||
defaultClassNames.week_number_header,
|
||||
),
|
||||
week_number: cn(
|
||||
'text-[0.8rem] select-none text-muted-foreground',
|
||||
defaultClassNames.week_number,
|
||||
),
|
||||
day: cn(
|
||||
'relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none',
|
||||
defaultClassNames.day,
|
||||
),
|
||||
range_start: cn(
|
||||
'rounded-l-md bg-accent',
|
||||
defaultClassNames.range_start,
|
||||
),
|
||||
range_middle: cn('rounded-none', defaultClassNames.range_middle),
|
||||
range_end: cn('rounded-r-md bg-accent', defaultClassNames.range_end),
|
||||
today: cn(
|
||||
'bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none',
|
||||
defaultClassNames.today,
|
||||
),
|
||||
outside: cn(
|
||||
'text-muted-foreground aria-selected:text-muted-foreground',
|
||||
defaultClassNames.outside,
|
||||
),
|
||||
disabled: cn(
|
||||
'text-muted-foreground opacity-50',
|
||||
defaultClassNames.disabled,
|
||||
),
|
||||
hidden: cn('invisible', defaultClassNames.hidden),
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Root: ({ className, rootRef, ...props }) => {
|
||||
return (
|
||||
<div
|
||||
data-slot="calendar"
|
||||
ref={rootRef}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
Chevron: ({ className, orientation, ...props }) => {
|
||||
if (orientation === 'left') {
|
||||
return (
|
||||
<ChevronLeftIcon className={cn('size-4', className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
if (orientation === 'right') {
|
||||
return (
|
||||
<ChevronRightIcon
|
||||
className={cn('size-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ChevronDownIcon className={cn('size-4', className)} {...props} />
|
||||
)
|
||||
},
|
||||
DayButton: CalendarDayButton,
|
||||
WeekNumber: ({ children, ...props }) => {
|
||||
return (
|
||||
<td {...props}>
|
||||
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
||||
{children}
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
},
|
||||
...components,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CalendarDayButton({
|
||||
className,
|
||||
day,
|
||||
modifiers,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayButton>) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
const ref = React.useRef<HTMLButtonElement>(null)
|
||||
React.useEffect(() => {
|
||||
if (modifiers.focused) ref.current?.focus()
|
||||
}, [modifiers.focused])
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-day={day.date.toLocaleDateString()}
|
||||
data-selected-single={
|
||||
modifiers.selected &&
|
||||
!modifiers.range_start &&
|
||||
!modifiers.range_end &&
|
||||
!modifiers.range_middle
|
||||
}
|
||||
data-range-start={modifiers.range_start}
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
className={cn(
|
||||
'data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70',
|
||||
defaultClassNames.day,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Calendar, CalendarDayButton }
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue