portfolio view

This commit is contained in:
2026-03-18 23:20:32 +01:00
parent e815c02f70
commit 908206b5cd
104 changed files with 3755 additions and 3689 deletions

View File

@@ -0,0 +1,21 @@
import { Header } from './components/layout/Header'
import { Footer } from './components/layout/Footer'
import { HeroSection } from './components/hero/HeroSection'
import { ProjectsSection } from './components/projects/ProjectsSection'
import { ChatWidget } from './components/chat/ChatWidget'
function App() {
return (
<div className="min-h-screen bg-zinc-950 text-zinc-50">
<Header />
<main>
<HeroSection />
<ProjectsSection />
</main>
<Footer />
<ChatWidget />
</div>
)
}
export default App

View File

@@ -0,0 +1,67 @@
import { useState, useRef, type KeyboardEvent } from 'react'
interface ChatInputProps {
onSend: (message: string) => void
disabled: boolean
}
export function ChatInput({ onSend, disabled }: ChatInputProps) {
const [value, setValue] = useState('')
const textareaRef = useRef<HTMLTextAreaElement>(null)
const handleSend = () => {
const trimmed = value.trim()
if (!trimmed || disabled) return
onSend(trimmed)
setValue('')
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
}
}
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
const handleInput = () => {
const el = textareaRef.current
if (!el) return
el.style.height = 'auto'
el.style.height = `${Math.min(el.scrollHeight, 120)}px`
}
return (
<div className="flex items-end gap-2 p-3 border-t border-zinc-800 bg-zinc-950/50">
<textarea
ref={textareaRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
onInput={handleInput}
disabled={disabled}
rows={1}
placeholder="Ask me about my experience, projects, or tech stack..."
className="flex-1 resize-none bg-zinc-800/60 border border-zinc-700 rounded-md px-3 py-2 text-sm text-zinc-100 placeholder-zinc-600 font-body focus:outline-none focus:border-accent/50 focus:ring-1 focus:ring-accent/20 transition-colors disabled:opacity-40 disabled:cursor-not-allowed min-h-[38px] max-h-[120px] leading-relaxed"
/>
<button
onClick={handleSend}
disabled={disabled || !value.trim()}
aria-label="Send message"
className="shrink-0 w-9 h-9 flex items-center justify-center rounded-md bg-accent text-zinc-950 hover:bg-accent-hover transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
>
<svg width="15" height="15" viewBox="0 0 15 15" fill="none">
<path
d="M7.5 1v13M1 7.5l6.5-6.5 6.5 6.5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</div>
)
}

View File

@@ -0,0 +1,35 @@
import type { ChatMessageType } from '../../types'
interface ChatMessageProps {
message: ChatMessageType
}
export function ChatMessage({ message }: ChatMessageProps) {
const isUser = message.role === 'user'
return (
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'} mb-3`}>
{!isUser && (
<div className="shrink-0 w-6 h-6 rounded-full bg-accent/20 border border-accent/30 flex items-center justify-center mr-2 mt-0.5">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<circle cx="6" cy="6" r="3" fill="#22d3ee" />
<circle cx="6" cy="6" r="5.5" stroke="#22d3ee" strokeOpacity="0.4" />
</svg>
</div>
)}
<div
className={`max-w-[80%] px-3 py-2 rounded-lg text-sm leading-relaxed font-body ${
isUser
? 'bg-accent/15 text-zinc-100 border border-accent/20'
: 'bg-zinc-800 text-zinc-200 border border-zinc-700'
}`}
>
{message.content}
{message.isStreaming && (
<span className="inline-block w-1 h-3.5 bg-accent ml-0.5 align-text-bottom animate-pulse" />
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,213 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import type { ChatMessageType } from '../../types'
import { useHrGuestAuth } from '../../hooks/useHrGuestAuth'
import { createChat, streamChatQuery } from '../../services/api'
import { ChatMessage } from './ChatMessage'
import { ChatInput } from './ChatInput'
type ChatStatus = 'idle' | 'creating' | 'ready' | 'error'
export function ChatWidget() {
const [isOpen, setIsOpen] = useState(false)
const [messages, setMessages] = useState<ChatMessageType[]>([])
const [chatId, setChatId] = useState<string | null>(null)
const [chatStatus, setChatStatus] = useState<ChatStatus>('idle')
const [isSending, setIsSending] = useState(false)
const { token, isLoading: tokenLoading, error: tokenError } = useHrGuestAuth()
const messagesEndRef = useRef<HTMLDivElement>(null)
const abortRef = useRef<(() => void) | null>(null)
// Auto-scroll to latest message
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
// Create chat session when widget opens and token is available
useEffect(() => {
if (!isOpen || !token || chatId || chatStatus !== 'idle') return
setChatStatus('creating')
createChat(token)
.then((id) => {
setChatId(id)
setChatStatus('ready')
})
.catch(() => setChatStatus('error'))
}, [isOpen, token, chatId, chatStatus])
const handleSend = useCallback(
(text: string) => {
if (!token || !chatId || isSending) return
const userMsg: ChatMessageType = {
id: `u-${Date.now()}`,
role: 'user',
content: text,
}
const assistantMsgId = `a-${Date.now()}`
const assistantMsg: ChatMessageType = {
id: assistantMsgId,
role: 'assistant',
content: '',
isStreaming: true,
}
setMessages((prev) => [...prev, userMsg, assistantMsg])
setIsSending(true)
const abort = streamChatQuery(
chatId,
text,
token,
(chunk) => {
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMsgId
? { ...m, content: m.content + chunk }
: m
)
)
},
() => {
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMsgId ? { ...m, isStreaming: false } : m
)
)
setIsSending(false)
abortRef.current = null
},
(err) => {
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMsgId
? { ...m, content: `Error: ${err.message}`, isStreaming: false }
: m
)
)
setIsSending(false)
abortRef.current = null
}
)
abortRef.current = abort
},
[token, chatId, isSending]
)
// Clean up on unmount
useEffect(() => {
return () => {
abortRef.current?.()
}
}, [])
const isInputDisabled =
tokenLoading ||
!!tokenError ||
chatStatus !== 'ready' ||
isSending
const statusText = (() => {
if (tokenLoading) return 'Connecting...'
if (tokenError) return 'Connection failed'
if (chatStatus === 'creating') return 'Starting session...'
if (chatStatus === 'error') return 'Session error'
return null
})()
return (
<>
{/* Floating toggle button */}
<button
onClick={() => setIsOpen((v) => !v)}
aria-label={isOpen ? 'Close chat' : 'Open chat'}
className="fixed bottom-6 right-6 z-50 w-14 h-14 rounded-full bg-accent text-zinc-950 shadow-lg shadow-accent/20 hover:bg-accent-hover transition-all duration-200 flex items-center justify-center glow-accent"
>
{isOpen ? (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path
d="M4 4l12 12M16 4L4 16"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
) : (
<svg width="22" height="22" viewBox="0 0 22 22" fill="none">
<path
d="M19 4H3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h4l4 4 4-4h4a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1z"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</button>
{/* Chat panel */}
{isOpen && (
<div className="fixed bottom-24 right-6 z-50 w-80 sm:w-96 flex flex-col rounded-xl border border-zinc-800 bg-zinc-950 shadow-2xl shadow-black/60 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-800 bg-zinc-900/80">
<div>
<p className="font-heading font-semibold text-sm text-white">
Ask about me
</p>
<p className="font-mono text-[10px] text-accent tracking-wider mt-0.5">
Powered by RAG
</p>
</div>
<div className="flex items-center gap-1.5">
<span
className={`w-1.5 h-1.5 rounded-full ${
chatStatus === 'ready' ? 'bg-emerald-400' : 'bg-zinc-600 animate-pulse'
}`}
/>
<span className="text-xs text-zinc-500 font-mono">
{chatStatus === 'ready' ? 'online' : 'connecting'}
</span>
</div>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto chat-scroll px-3 py-4 min-h-[280px] max-h-[400px]">
{messages.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center gap-3 text-center px-4">
<div className="w-10 h-10 rounded-full bg-accent/10 border border-accent/20 flex items-center justify-center">
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
<circle cx="9" cy="9" r="4" fill="#22d3ee" fillOpacity="0.6" />
<circle cx="9" cy="9" r="7.5" stroke="#22d3ee" strokeOpacity="0.25" />
</svg>
</div>
<p className="text-xs text-zinc-500 font-body leading-relaxed">
Ask me about my experience,<br />
projects, or tech stack.
</p>
</div>
) : (
<>
{messages.map((msg) => (
<ChatMessage key={msg.id} message={msg} />
))}
<div ref={messagesEndRef} />
</>
)}
</div>
{/* Status banner */}
{statusText && (
<div className="px-4 py-1.5 bg-zinc-900/80 border-t border-zinc-800">
<p className="text-xs text-zinc-500 font-mono">{statusText}</p>
</div>
)}
{/* Input */}
<ChatInput onSend={handleSend} disabled={isInputDisabled} />
</div>
)}
</>
)
}

View File

@@ -0,0 +1,113 @@
export function HeroSection() {
return (
<section
id="hero"
className="relative min-h-screen flex items-center justify-center overflow-hidden"
>
{/* Background grid */}
<div
className="absolute inset-0 opacity-[0.03]"
style={{
backgroundImage:
'linear-gradient(#fafafa 1px, transparent 1px), linear-gradient(90deg, #fafafa 1px, transparent 1px)',
backgroundSize: '48px 48px',
}}
/>
{/* Accent glow */}
<div className="absolute top-1/3 left-1/2 -translate-x-1/2 -translate-y-1/2 w-96 h-96 rounded-full bg-accent/5 blur-3xl pointer-events-none" />
<div className="relative z-10 max-w-4xl mx-auto px-6 py-24 text-center">
{/* Location badge */}
<div className="inline-flex items-center gap-2 px-3 py-1.5 mb-8 rounded-full border border-zinc-800 bg-zinc-900/60 text-xs text-zinc-400 font-mono tracking-wider">
<span className="w-1.5 h-1.5 rounded-full bg-accent animate-pulse" />
Switzerland, Bern
</div>
{/* Name */}
<h1 className="font-heading font-extrabold text-6xl md:text-8xl tracking-tight mb-4 leading-none">
<span className="text-white">Alexander</span>
</h1>
{/* Role */}
<p className="font-heading font-medium text-2xl md:text-3xl text-gradient mb-6 tracking-tight">
Java / Fullstack Developer
</p>
{/* Description */}
<p className="text-zinc-400 text-lg max-w-xl mx-auto mb-10 font-body leading-relaxed">
Building backend systems and intelligent applications with{' '}
<span className="text-zinc-200 font-mono text-base">Spring Boot</span>,{' '}
<span className="text-zinc-200 font-mono text-base">React</span>, and{' '}
<span className="text-zinc-200 font-mono text-base">AI</span>.
</p>
{/* CTA buttons */}
<div className="flex flex-wrap items-center justify-center gap-4">
<a
href="/static/cv.pdf"
className="inline-flex items-center gap-2.5 px-6 py-3 bg-accent text-zinc-950 font-heading font-semibold text-sm tracking-wide hover:bg-accent-hover transition-colors duration-200 rounded-sm glow-accent"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M8 1v9M4 7l4 4 4-4M2 14h12"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
Download CV
</a>
<a
href="https://github.com/Mirage74/post-hub-platform"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub repository"
className="inline-flex items-center gap-2.5 px-6 py-3 border border-zinc-700 text-zinc-300 font-heading font-medium text-sm tracking-wide hover:border-zinc-500 hover:text-white transition-all duration-200 rounded-sm"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0 1 12 6.844a9.59 9.59 0 0 1 2.504.337c1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.02 10.02 0 0 0 22 12.017C22 6.484 17.522 2 12 2z" />
</svg>
GitHub
</a>
<a
href="#"
aria-label="LinkedIn profile"
className="inline-flex items-center gap-2.5 px-6 py-3 border border-zinc-700 text-zinc-300 font-heading font-medium text-sm tracking-wide hover:border-zinc-500 hover:text-white transition-all duration-200 rounded-sm"
>
<svg width="16" height="16" viewBox="0 0 20 20" fill="currentColor">
<path d="M16.7 1.7H3.3C2.4 1.7 1.7 2.4 1.7 3.3v13.4c0 .9.7 1.6 1.6 1.6h13.4c.9 0 1.6-.7 1.6-1.6V3.3c0-.9-.7-1.6-1.6-1.6zM6.7 15H4.4V7.8h2.3V15zm-1.2-8.2c-.7 0-1.3-.6-1.3-1.3S4.8 4.2 5.5 4.2s1.3.6 1.3 1.3-.6 1.3-1.3 1.3zm10 8.2h-2.3v-3.5c0-.8 0-1.9-1.2-1.9s-1.3.9-1.3 1.8V15H8.4V7.8h2.2v1h.1c.3-.6 1-1.2 2.1-1.2 2.3 0 2.7 1.5 2.7 3.4V15z" />
</svg>
LinkedIn
</a>
</div>
{/* Scroll hint */}
<div className="mt-16 flex flex-col items-center gap-2 text-zinc-600 text-xs font-mono tracking-widest">
<span>SCROLL</span>
<svg
width="14"
height="20"
viewBox="0 0 14 20"
fill="none"
className="animate-bounce"
>
<rect
x="1"
y="1"
width="12"
height="18"
rx="6"
stroke="currentColor"
strokeWidth="1.5"
/>
<circle cx="7" cy="6" r="1.5" fill="currentColor" className="animate-pulse" />
</svg>
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,36 @@
export function Footer() {
const year = new Date().getFullYear()
return (
<footer id="footer" className="border-t border-zinc-800 bg-zinc-950 py-10">
<div className="max-w-6xl mx-auto px-6 flex flex-col md:flex-row items-center justify-between gap-4">
<p className="text-zinc-500 text-sm font-body">
© {year} Alexander Java / Fullstack Developer
</p>
<div className="flex items-center gap-6">
<a
href="https://github.com/Mirage74/post-hub-platform"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub"
className="text-zinc-500 hover:text-accent transition-colors"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0 1 12 6.844a9.59 9.59 0 0 1 2.504.337c1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.02 10.02 0 0 0 22 12.017C22 6.484 17.522 2 12 2z" />
</svg>
</a>
<a
href="#"
aria-label="LinkedIn"
className="text-zinc-500 hover:text-accent transition-colors"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
<path d="M16.7 1.7H3.3C2.4 1.7 1.7 2.4 1.7 3.3v13.4c0 .9.7 1.6 1.6 1.6h13.4c.9 0 1.6-.7 1.6-1.6V3.3c0-.9-.7-1.6-1.6-1.6zM6.7 15H4.4V7.8h2.3V15zm-1.2-8.2c-.7 0-1.3-.6-1.3-1.3S4.8 4.2 5.5 4.2s1.3.6 1.3 1.3-.6 1.3-1.3 1.3zm10 8.2h-2.3v-3.5c0-.8 0-1.9-1.2-1.9s-1.3.9-1.3 1.8V15H8.4V7.8h2.2v1h.1c.3-.6 1-1.2 2.1-1.2 2.3 0 2.7 1.5 2.7 3.4V15z" />
</svg>
</a>
</div>
</div>
</footer>
)
}

View File

@@ -0,0 +1,64 @@
import { useState, useEffect } from 'react'
const navLinks = [
{ label: 'About', href: '#hero' },
{ label: 'Projects', href: '#projects' },
{ label: 'Contact', href: '#footer' },
]
export function Header() {
const [scrolled, setScrolled] = useState(false)
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 24)
window.addEventListener('scroll', onScroll, { passive: true })
return () => window.removeEventListener('scroll', onScroll)
}, [])
return (
<header
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${
scrolled
? 'bg-zinc-950/90 backdrop-blur-md border-b border-zinc-800'
: 'bg-transparent'
}`}
>
<div className="max-w-6xl mx-auto px-6 h-16 flex items-center justify-between">
<a
href="#hero"
className="font-heading font-bold text-lg tracking-tight text-white hover:text-accent transition-colors"
>
Alexander<span className="text-accent">.</span>
</a>
<nav className="hidden md:flex items-center gap-8">
{navLinks.map((link) => (
<a
key={link.href}
href={link.href}
className="text-sm text-zinc-400 hover:text-white transition-colors font-body tracking-wide"
>
{link.label}
</a>
))}
</nav>
<a
href="/static/cv.pdf"
className="hidden md:inline-flex items-center gap-2 px-4 py-2 text-sm font-medium border border-accent text-accent hover:bg-accent hover:text-zinc-950 transition-all duration-200 rounded-sm"
>
<span>Download CV</span>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path
d="M7 1v8M3.5 5.5L7 9l3.5-3.5M2 12h10"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</a>
</div>
</header>
)
}

View File

@@ -0,0 +1,114 @@
import { useEffect, useRef } from 'react'
import type { Project } from '../../types'
interface ProjectCardProps {
project: Project
index: number
}
const INFRA_ICON = (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="2" y="3" width="20" height="14" rx="2" />
<path d="M8 21h8M12 17v4" strokeLinecap="round" />
<path d="M7 8h10M7 11h6" strokeLinecap="round" />
</svg>
)
const DEFAULT_ICON = (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<line x1="10" y1="9" x2="8" y2="9" />
</svg>
)
export function ProjectCard({ project, index }: ProjectCardProps) {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
const el = ref.current
if (!el) return
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
el.classList.add('visible')
observer.disconnect()
}
},
{ threshold: 0.12 }
)
observer.observe(el)
return () => observer.disconnect()
}, [])
const delayClass = `reveal-delay-${Math.min(index + 1, 5)}`
return (
<div
ref={ref}
className={`reveal ${delayClass} group relative flex flex-col bg-zinc-900 border border-zinc-800 rounded-lg p-6 hover:border-zinc-700 hover:bg-zinc-900/80 transition-all duration-300`}
>
{/* Top: icon + title + demo link */}
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className="text-accent/70 group-hover:text-accent transition-colors">
{project.isInfrastructure ? INFRA_ICON : DEFAULT_ICON}
</div>
<h3 className="font-heading font-semibold text-lg text-zinc-100 group-hover:text-white transition-colors">
{project.title}
</h3>
</div>
{project.demoUrl && (
<a
href={project.demoUrl}
target="_blank"
rel="noopener noreferrer"
aria-label={`Open ${project.title} demo`}
className="ml-2 shrink-0 text-zinc-600 hover:text-accent transition-colors"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M6 2H2v12h12v-4M9 2h5v5M14 2L7 9"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</a>
)}
</div>
{/* Description */}
<p className="text-zinc-400 text-sm leading-relaxed mb-5 flex-1 font-body">
{project.description}
</p>
{/* Stack tags */}
<div className="flex flex-wrap gap-1.5 mt-auto">
{project.stack.map((tech) => (
<span
key={tech}
className="font-mono text-xs px-2 py-0.5 rounded-sm bg-zinc-800 text-zinc-400 border border-zinc-700/60 group-hover:border-zinc-600/60 transition-colors"
>
{tech}
</span>
))}
</div>
{/* Demo badge */}
{project.demoUrl && (
<div className="absolute top-4 right-4">
<span className="inline-flex items-center gap-1 text-[10px] font-mono px-1.5 py-0.5 rounded-sm bg-accent/10 text-accent border border-accent/20">
<span className="w-1 h-1 rounded-full bg-accent" />
LIVE
</span>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,73 @@
import type { Project } from '../../types'
import { ProjectCard } from './ProjectCard'
const PROJECTS: Project[] = [
{
id: 'gateway',
title: 'Gateway Service',
description:
'API Gateway with JWT authentication, OAuth2 (Google, GitHub, Facebook), reactive routing, and per-user rate limiting built on Spring Cloud Gateway.',
stack: ['Java 25', 'Spring Boot 3.5', 'Spring Cloud Gateway', 'WebFlux', 'R2DBC', 'PostgreSQL', 'jjwt'],
},
{
id: 'rag',
title: 'RAG Service',
description:
'AI-powered chat with document context. Upload PDF/DOCX, vector semantic search, BM25 reranking, and real-time SSE streaming responses.',
stack: ['Java 25', 'Spring Boot 3.5', 'Spring AI', 'pgvector', 'Kafka', 'TikaDocumentReader'],
demoUrl: '/ragview/',
},
{
id: 'analytics',
title: 'Analytics Service',
description:
'Kafka consumer that aggregates platform events into PostgreSQL with a dynamic REST API supporting complex filtering and pagination.',
stack: ['Java 25', 'Spring Boot 3.5', 'Kafka', 'JPA', 'Flyway', 'JpaSpecificationExecutor'],
demoUrl: '/analyticsview/',
},
{
id: 'audio-foreign',
title: 'Audio Foreign',
description:
'Voice-based language learning app: speak — get instant AI feedback. Speech-to-text via Whisper, grammar correction via Claude, playback via ElevenLabs.',
stack: ['Node.js', 'Express', 'OpenAI Whisper', 'Anthropic Claude API', 'ElevenLabs TTS'],
demoUrl: '/deutsch/',
},
{
id: 'infra',
title: 'Infrastructure',
description:
'Monorepo DevOps: Docker Compose orchestration, GitLab CI/CD with path-based triggers, Nginx reverse proxy, Let\'s Encrypt SSL, and Consul service discovery.',
stack: ['Docker', 'Docker Compose', 'GitLab CI/CD', 'Nginx', 'Let\'s Encrypt', 'Consul'],
isInfrastructure: true,
},
]
export function ProjectsSection() {
return (
<section id="projects" className="py-24 px-6">
<div className="max-w-6xl mx-auto">
{/* Section header */}
<div className="mb-14">
<p className="font-mono text-xs text-accent tracking-widest mb-3 uppercase">
Work
</p>
<h2 className="font-heading font-bold text-4xl md:text-5xl text-white tracking-tight">
Projects
</h2>
<p className="mt-4 text-zinc-400 max-w-xl font-body text-base leading-relaxed">
Microservices platform each service independently deployable, all
connected through the gateway.
</p>
</div>
{/* Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-5">
{PROJECTS.map((project, index) => (
<ProjectCard key={project.id} project={project} index={index} />
))}
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,57 @@
import { useState, useEffect, useRef } from 'react'
import { fetchHrGuestToken } from '../services/api'
const MAX_RETRIES = 3
const RETRY_DELAY_MS = 3000
interface HrGuestAuthResult {
token: string | null
isLoading: boolean
error: string | null
}
export function useHrGuestAuth(): HrGuestAuthResult {
const [token, setToken] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const attemptsRef = useRef(0)
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const mountedRef = useRef(true)
useEffect(() => {
mountedRef.current = true
const attempt = async () => {
if (!mountedRef.current) return
attemptsRef.current += 1
try {
const t = await fetchHrGuestToken()
if (!mountedRef.current) return
setToken(t)
setIsLoading(false)
setError(null)
} catch (err) {
if (!mountedRef.current) return
const message = err instanceof Error ? err.message : 'Unknown error'
if (attemptsRef.current < MAX_RETRIES) {
timeoutRef.current = setTimeout(attempt, RETRY_DELAY_MS)
} else {
setError(`Auth failed after ${MAX_RETRIES} attempts: ${message}`)
setIsLoading(false)
}
}
}
attempt()
return () => {
mountedRef.current = false
if (timeoutRef.current) clearTimeout(timeoutRef.current)
}
}, [])
return { token, isLoading, error }
}

View File

@@ -0,0 +1,118 @@
@import url('https://fonts.googleapis.com/css2?family=Syne:wght@400;500;600;700;800&family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;1,9..40,300&family=JetBrains+Mono:wght@400;500&display=swap');
@import "tailwindcss";
@theme {
--font-heading: "Syne", sans-serif;
--font-body: "DM Sans", sans-serif;
--font-mono: "JetBrains Mono", monospace;
--color-accent: #22d3ee;
--color-accent-hover: #67e8f9;
--color-surface: #18181b;
--color-surface-2: #27272a;
--color-border: #3f3f46;
}
@layer base {
html {
scroll-behavior: smooth;
}
body {
background-color: #09090b;
color: #fafafa;
font-family: var(--font-body);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-heading);
}
code, pre, .mono {
font-family: var(--font-mono);
}
* {
border-color: #3f3f46;
}
::selection {
background-color: rgba(34, 211, 238, 0.25);
color: #fafafa;
}
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: #09090b;
}
::-webkit-scrollbar-thumb {
background: #3f3f46;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #52525b;
}
}
/* Scroll-triggered animation */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(28px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.reveal {
opacity: 0;
transform: translateY(28px);
}
.reveal.visible {
animation: fadeInUp 0.55s ease-out forwards;
}
/* Stagger delays for project cards */
.reveal-delay-1 { animation-delay: 0.05s; }
.reveal-delay-2 { animation-delay: 0.12s; }
.reveal-delay-3 { animation-delay: 0.19s; }
.reveal-delay-4 { animation-delay: 0.26s; }
.reveal-delay-5 { animation-delay: 0.33s; }
/* Gradient text utility */
.text-gradient {
background: linear-gradient(135deg, #22d3ee 0%, #67e8f9 50%, #a5f3fc 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Accent glow effect */
.glow-accent {
box-shadow: 0 0 24px rgba(34, 211, 238, 0.18);
}
/* Chat scrollbar */
.chat-scroll::-webkit-scrollbar {
width: 4px;
}
.chat-scroll::-webkit-scrollbar-track {
background: transparent;
}
.chat-scroll::-webkit-scrollbar-thumb {
background: #3f3f46;
border-radius: 2px;
}

View File

@@ -0,0 +1,13 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App'
const root = document.getElementById('root')
if (!root) throw new Error('Root element not found')
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>
)

View File

@@ -0,0 +1,109 @@
const BASE_URL = ''
export async function fetchHrGuestToken(): Promise<string> {
const response = await fetch(`${BASE_URL}/api/auth/hr-guest-token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
})
if (!response.ok) {
throw new Error(`Token request failed: ${response.status}`)
}
const data: { token: string } = await response.json()
return data.token
}
export async function createChat(token: string): Promise<string> {
const response = await fetch(`${BASE_URL}/api/rag/chats`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
})
if (!response.ok) {
throw new Error(`Chat creation failed: ${response.status}`)
}
const data: { id: string } = await response.json()
return data.id
}
/**
* Sends a chat query and streams the SSE response token by token.
* Returns a cleanup function that aborts the request.
*/
export function streamChatQuery(
chatId: string,
message: string,
token: string,
onChunk: (chunk: string) => void,
onDone: () => void,
onError: (error: Error) => void
): () => void {
const controller = new AbortController()
fetch(`${BASE_URL}/api/rag/chats/${chatId}/query`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
Accept: 'text/event-stream',
},
body: JSON.stringify({ message }),
signal: controller.signal,
})
.then(async (response) => {
if (!response.ok) {
throw new Error(`Query failed: ${response.status}`)
}
if (!response.body) {
throw new Error('Empty response body')
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) {
onDone()
break
}
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
// Keep the last (possibly incomplete) line in the buffer
buffer = lines.pop() ?? ''
for (const line of lines) {
if (!line.startsWith('data:')) continue
const data = line.slice(5).trim()
if (data === '[DONE]' || data === '') continue
// Handle both plain text and JSON payloads: {"content": "..."}
try {
const parsed: unknown = JSON.parse(data)
if (
parsed !== null &&
typeof parsed === 'object' &&
'content' in parsed &&
typeof (parsed as Record<string, unknown>).content === 'string'
) {
onChunk((parsed as { content: string }).content)
} else {
onChunk(data)
}
} catch {
onChunk(data)
}
}
}
})
.catch((error: unknown) => {
if (error instanceof Error && error.name !== 'AbortError') {
onError(error)
}
})
return () => controller.abort()
}

View File

@@ -0,0 +1,23 @@
export interface Project {
id: string
title: string
description: string
stack: string[]
demoUrl?: string
isInfrastructure?: boolean
}
export interface ChatMessageType {
id: string
role: 'user' | 'assistant'
content: string
isStreaming?: boolean
}
export interface HrGuestTokenResponse {
token: string
}
export interface ChatCreateResponse {
id: string
}