portfolio view
This commit is contained in:
25
portfolio-view/.gitignore
vendored
Normal file
25
portfolio-view/.gitignore
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
.pnp
|
||||
.pnp.js
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
dist-ssr/
|
||||
|
||||
# Editor
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
3
portfolio-view/.mvn/wrapper/maven-wrapper.properties
vendored
Normal file
3
portfolio-view/.mvn/wrapper/maven-wrapper.properties
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
wrapperVersion=3.3.4
|
||||
distributionType=only-script
|
||||
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/apache-maven-3.9.11-bin.zip
|
||||
11
portfolio-view/docker/Dockerfile
Normal file
11
portfolio-view/docker/Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
9
portfolio-view/docker/nginx.conf
Normal file
9
portfolio-view/docker/nginx.conf
Normal file
@@ -0,0 +1,9 @@
|
||||
server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
14
portfolio-view/index.html
Normal file
14
portfolio-view/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="Alexander — Java / Fullstack Developer. Switzerland, Bern. Spring Boot, React, AI." />
|
||||
<title>Alexander — Java / Fullstack Developer</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
2490
portfolio-view/package-lock.json
generated
Normal file
2490
portfolio-view/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
portfolio-view/package.json
Normal file
24
portfolio-view/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "portfolio-view",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "~5.7.2",
|
||||
"vite": "^6.0.5"
|
||||
}
|
||||
}
|
||||
21
portfolio-view/src/App.tsx
Normal file
21
portfolio-view/src/App.tsx
Normal 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
|
||||
67
portfolio-view/src/components/chat/ChatInput.tsx
Normal file
67
portfolio-view/src/components/chat/ChatInput.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
35
portfolio-view/src/components/chat/ChatMessage.tsx
Normal file
35
portfolio-view/src/components/chat/ChatMessage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
213
portfolio-view/src/components/chat/ChatWidget.tsx
Normal file
213
portfolio-view/src/components/chat/ChatWidget.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
113
portfolio-view/src/components/hero/HeroSection.tsx
Normal file
113
portfolio-view/src/components/hero/HeroSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
36
portfolio-view/src/components/layout/Footer.tsx
Normal file
36
portfolio-view/src/components/layout/Footer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
64
portfolio-view/src/components/layout/Header.tsx
Normal file
64
portfolio-view/src/components/layout/Header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
114
portfolio-view/src/components/projects/ProjectCard.tsx
Normal file
114
portfolio-view/src/components/projects/ProjectCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
73
portfolio-view/src/components/projects/ProjectsSection.tsx
Normal file
73
portfolio-view/src/components/projects/ProjectsSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
57
portfolio-view/src/hooks/useHrGuestAuth.ts
Normal file
57
portfolio-view/src/hooks/useHrGuestAuth.ts
Normal 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 }
|
||||
}
|
||||
118
portfolio-view/src/index.css
Normal file
118
portfolio-view/src/index.css
Normal 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;
|
||||
}
|
||||
13
portfolio-view/src/main.tsx
Normal file
13
portfolio-view/src/main.tsx
Normal 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>
|
||||
)
|
||||
109
portfolio-view/src/services/api.ts
Normal file
109
portfolio-view/src/services/api.ts
Normal 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()
|
||||
}
|
||||
23
portfolio-view/src/types/index.ts
Normal file
23
portfolio-view/src/types/index.ts
Normal 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
|
||||
}
|
||||
21
portfolio-view/tailwind.config.ts
Normal file
21
portfolio-view/tailwind.config.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
// Tailwind CSS v4 uses CSS-based configuration via @theme in index.css.
|
||||
// This file is a reference for theme tokens used in the project.
|
||||
// Actual configuration lives in src/index.css inside the @theme block.
|
||||
|
||||
export const theme = {
|
||||
fonts: {
|
||||
heading: '"Syne", sans-serif',
|
||||
body: '"DM Sans", sans-serif',
|
||||
mono: '"JetBrains Mono", monospace',
|
||||
},
|
||||
colors: {
|
||||
accent: '#22d3ee', // cyan-400
|
||||
accentHover: '#67e8f9', // cyan-300
|
||||
bg: '#09090b', // zinc-950
|
||||
surface: '#18181b', // zinc-900
|
||||
surface2: '#27272a', // zinc-800
|
||||
border: '#3f3f46', // zinc-700
|
||||
textPrimary: '#fafafa', // zinc-50
|
||||
textMuted: '#a1a1aa', // zinc-400
|
||||
},
|
||||
} as const
|
||||
20
portfolio-view/tsconfig.json
Normal file
20
portfolio-view/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
1
portfolio-view/tsconfig.tsbuildinfo
Normal file
1
portfolio-view/tsconfig.tsbuildinfo
Normal file
@@ -0,0 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/components/chat/chatinput.tsx","./src/components/chat/chatmessage.tsx","./src/components/chat/chatwidget.tsx","./src/components/hero/herosection.tsx","./src/components/layout/footer.tsx","./src/components/layout/header.tsx","./src/components/projects/projectcard.tsx","./src/components/projects/projectssection.tsx","./src/hooks/usehrguestauth.ts","./src/services/api.ts","./src/types/index.ts","./vite.config.ts"],"version":"5.7.3"}
|
||||
19
portfolio-view/vite.config.ts
Normal file
19
portfolio-view/vite.config.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
export default defineConfig({
|
||||
base: '/',
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user