Files
Portfolioo/src/components/PortfolioAssistantFAB.tsx
T
ssbg04 b1af4331fe feat: redesign portfolio into neo-brutalism design system
- adopt authentic neo-brutalist typography (Space Grotesk, Plus Jakarta Sans, Space Mono)
- default theme to light mode and remove lite mode/data saver
- redesign certification preview and fullscreen lightbox modals with frosted backdrop blur
- redesign photo gallery preview modal and grid into neo-brutalism
- update scroll animations to trigger at 1/4 bottom of viewport
- add spring pop-up entry and sticker slap animations to hero profile image
- overhaul section backgrounds and components into neo-brutalist cards and shadows
2026-09-20 00:52:16 +08:00

517 lines
20 KiB
TypeScript

import React, { useState, useRef, useEffect } from 'react';
import haptic from '../lib/haptics';
interface ChatMessage {
role: 'user' | 'assistant';
text: string;
}
function parseTokens(text: string): React.ReactNode {
// Matches **bold**, `code`, [link](url)
const regex = /(\*\*[^*]+\*\*|`[^`]+`|\[[^\]]+\]\([^)]+\))/g;
const parts = text.split(regex);
return parts.map((part, i) => {
if (part.startsWith('**') && part.endsWith('**')) {
return (
<strong key={i} className="font-semibold text-foreground-custom">
{part.slice(2, -2)}
</strong>
);
}
if (part.startsWith('`') && part.endsWith('`')) {
return (
<span
key={i}
className="px-1.5 py-0.5 rounded-md bg-foreground-custom/10 text-primary-custom font-mono text-[11px]"
>
{part.slice(1, -1)}
</span>
);
}
const linkMatch = part.match(/^\[([^\]]+)\]\(([^)]+)\)$/);
if (linkMatch) {
const isExt = linkMatch[2].startsWith('http') || linkMatch[2].startsWith('mailto:');
return (
<a
key={i}
href={linkMatch[2]}
target={isExt ? '_blank' : undefined}
rel={isExt ? 'noopener noreferrer' : undefined}
className="text-primary-custom underline underline-offset-2 font-medium hover:opacity-80"
>
{linkMatch[1]}
</a>
);
}
return part;
});
}
function renderMessageContent(text: string, isUser: boolean) {
if (isUser) {
return <div className="whitespace-pre-wrap">{text}</div>;
}
const lines = text.split('\n');
return (
<div className="flex flex-col gap-1 text-xs leading-relaxed">
{lines.map((line, idx) => {
const trimmed = line.trim();
if (!trimmed) {
return <div key={idx} className="h-1" />;
}
// Bullet item: • or -
if (trimmed.startsWith('• ') || trimmed.startsWith('- ')) {
const content = trimmed.replace(/^[•\-]\s*/, '');
return (
<div key={idx} className="flex items-start gap-2 pl-0.5">
<span className="w-1.5 h-1.5 rounded-full bg-primary-custom shrink-0 mt-1.5" />
<span className="flex-1">{parseTokens(content)}</span>
</div>
);
}
// Numbered list item: 1. 2. etc
const numMatch = trimmed.match(/^(\d+)\.\s*(.+)/);
if (numMatch) {
return (
<div key={idx} className="flex items-start gap-2 pl-0.5">
<span className="font-mono text-[10px] font-bold text-primary-custom shrink-0 mt-0.5">
{numMatch[1]}.
</span>
<span className="flex-1">{parseTokens(numMatch[2])}</span>
</div>
);
}
// Section header line (short and ends with a colon, e.g. "Featured Projects:")
if (/^[A-Za-z\s&–—]+:$/.test(trimmed) && trimmed.length < 40) {
return (
<div key={idx} className="font-semibold text-foreground-custom tracking-tight mt-1">
{parseTokens(trimmed)}
</div>
);
}
// Standard line
return (
<p key={idx} className="leading-relaxed">
{parseTokens(trimmed)}
</p>
);
})}
</div>
);
}
export default function PortfolioAssistantFAB() {
const [isOpen, setIsOpen] = useState(false);
const [messages, setMessages] = useState<ChatMessage[]>([
{
role: 'assistant',
text: "Hello! I'm Cris's Portfolio Assistant.\n\nAsk me anything about Cris's projects, technical skills, certifications, work experience, or contact information."
}
]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const [cooldown, setCooldown] = useState(0);
const [spamNotice, setSpamNotice] = useState<string | null>(null);
const [isBtnHidden, setIsBtnHidden] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Hide on scroll down, show on scroll up in mobile view
useEffect(() => {
let lastY = typeof window !== 'undefined' ? window.scrollY : 0;
let ticking = false;
const onScroll = () => {
if (window.innerWidth >= 768) {
setIsBtnHidden(false);
return;
}
if (!ticking) {
window.requestAnimationFrame(() => {
const currentY = window.scrollY;
// Always show at top of page or negative bounce
if (currentY <= 20) {
setIsBtnHidden(false);
lastY = currentY;
ticking = false;
return;
}
// Keep button visible while chat is open
if (isOpen) {
setIsBtnHidden(false);
lastY = currentY;
ticking = false;
return;
}
const diff = currentY - lastY;
// Scrolling down: hide
if (diff > 8 && currentY > 60) {
setIsBtnHidden(true);
} else if (diff < -8) {
// Scrolling up: reveal
setIsBtnHidden(false);
}
lastY = currentY;
ticking = false;
});
ticking = true;
}
};
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
}, [isOpen]);
useEffect(() => {
const onResize = () => {
if (window.innerWidth >= 768) {
setIsBtnHidden(false);
}
};
window.addEventListener('resize', onResize, { passive: true });
return () => window.removeEventListener('resize', onResize);
}, []);
useEffect(() => {
const handleRoute = () => {
setIsBtnHidden(false);
};
document.addEventListener('astro:page-load', handleRoute);
document.addEventListener('astro:after-swap', handleRoute);
return () => {
document.removeEventListener('astro:page-load', handleRoute);
document.removeEventListener('astro:after-swap', handleRoute);
};
}, []);
// Anti-spam countdown timer for pre-selections
useEffect(() => {
if (cooldown <= 0) return;
const interval = setInterval(() => {
setCooldown((prev) => Math.max(0, prev - 1));
}, 1000);
return () => clearInterval(interval);
}, [cooldown]);
// Auto-open if query param ?assistant=open is in URL
useEffect(() => {
if (typeof window !== 'undefined' && window.location.search.includes('assistant=open')) {
setIsOpen(true);
}
}, []);
useEffect(() => {
if (isOpen) {
setTimeout(() => inputRef.current?.focus(), 150);
}
}, [isOpen]);
useEffect(() => {
if (isOpen) {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}
}, [messages, loading, isOpen]);
const recentSubmits = useRef<number[]>([]);
const handleSend = async (textToSend?: string) => {
const query = (textToSend || input).trim();
if (!query || loading) return;
// 1. Anti-spam: Active cooldown check
if (cooldown > 0) {
haptic.tap();
setSpamNotice(`Please wait ${cooldown}s before sending another message.`);
setTimeout(() => setSpamNotice(null), 2500);
return;
}
// 2. Anti-spam: Large paste dump limit
if (query.length > 250) {
haptic.tap();
setSpamNotice("Query too long (max 250 characters). Please keep it brief.");
setTimeout(() => setSpamNotice(null), 3000);
return;
}
// 3. Anti-spam: Duplicate copy-paste detection against recent queries
const lastUserMsg = [...messages].reverse().find(m => m.role === 'user');
if (lastUserMsg && lastUserMsg.text.trim().toLowerCase() === query.toLowerCase()) {
haptic.tap();
setSpamNotice("Duplicate question! Review the answer above.");
setTimeout(() => setSpamNotice(null), 2500);
return;
}
// 4. Anti-spam: Repetitive characters or words spam
if (/(.)\1{9,}/.test(query)) {
haptic.tap();
setSpamNotice("Excessive repeating characters detected.");
setTimeout(() => setSpamNotice(null), 2500);
return;
}
const words = query.toLowerCase().split(/\s+/);
if (words.length >= 4 && new Set(words).size <= 2) {
haptic.tap();
setSpamNotice("Repeated word copy-paste detected.");
setTimeout(() => setSpamNotice(null), 2500);
return;
}
// 5. Anti-spam: Sliding-window rapid flood prevention (max 3 submits in 8s)
const now = Date.now();
recentSubmits.current = recentSubmits.current.filter(t => now - t < 8000);
if (recentSubmits.current.length >= 3) {
haptic.tap();
setCooldown(8); // 8-second penalty cooldown
setSpamNotice("Slow down! Too many rapid requests (8s pause).");
setTimeout(() => setSpamNotice(null), 3500);
return;
}
recentSubmits.current.push(now);
setInput('');
setMessages((prev) => [...prev, { role: 'user', text: query }]);
setLoading(true);
setCooldown(3); // 3-second cooldown across all query methods
setSpamNotice(null);
try {
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: query })
});
if (res.ok) {
const data = await res.json();
haptic.tick();
setMessages((prev) => [...prev, { role: 'assistant', text: data.text }]);
} else {
const errData = await res.json().catch(() => null);
const errorText = errData?.text || "Unable to retrieve records right now. Please try searching another keyword or contact Cris directly.";
setMessages((prev) => [
...prev,
{ role: 'assistant', text: errorText }
]);
}
} catch {
setMessages((prev) => [
...prev,
{ role: 'assistant', text: "Network error occurred while fetching information." }
]);
} finally {
setLoading(false);
}
};
const handleQuickPrompt = (prompt: string) => {
handleSend(prompt);
};
return (
<>
{/* ─── Floating Action Button (FAB) ─── */}
<div
className="fixed bottom-4 sm:bottom-6 right-4 sm:right-7 z-40"
style={{
transform: isBtnHidden && !isOpen ? 'translateY(calc(100% + 2rem))' : 'translateY(0)',
opacity: isBtnHidden && !isOpen ? 0 : 1,
pointerEvents: isBtnHidden && !isOpen ? 'none' : 'auto',
transition: 'transform 0.3s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.3s ease',
willChange: 'transform, opacity',
}}
>
<button
onClick={() => {
haptic.tap();
setIsOpen(!isOpen);
}}
aria-label={isOpen ? "Close Portfolio Assistant" : "Open Portfolio Assistant"}
aria-expanded={isOpen}
className="relative group p-3 sm:p-3.5 rounded-xl bg-[#facc15] text-black shadow-[4px_4px_0_0_var(--border-color)] hover:shadow-[5px_5px_0_0_var(--border-color)] active:translate-x-[2px] active:translate-y-[2px] active:shadow-none transition-all flex items-center gap-2 cursor-pointer border-2 sm:border-3 border-border-custom font-black"
>
{/* Animated ping badge */}
{!isOpen && (
<span className="absolute -top-1 -right-1 w-3.5 h-3.5 bg-emerald-400 rounded-full border-2 border-black animate-pulse" />
)}
{isOpen ? (
<svg className="w-5 h-5 stroke-[3]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
) : (
<>
<svg className="w-5 h-5 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M8.625 12a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 0 1-2.555-.337A5.972 5.972 0 0 1 5.41 20.97a.75.75 0 0 1-1.154-.63 4.887 4.887 0 0 0 1.28-3.138A7.834 7.834 0 0 1 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25Z" />
</svg>
<span className="hidden md:inline landscape:hidden lg:landscape:inline text-xs font-black font-heading pr-0.5 tracking-tight uppercase">
AI Assistant
</span>
</>
)}
</button>
</div>
{/* ─── Popup Assistant Modal / Drawer ─── */}
{isOpen && (
<div
className="fixed bottom-20 right-4 sm:right-7 z-50 w-[calc(100vw-2rem)] sm:w-96 max-w-sm h-[520px] rounded-2xl bg-card-custom border-3 border-border-custom shadow-[8px_8px_0_0_var(--border-color)] flex flex-col overflow-hidden animate-modal-scale"
>
{/* Header */}
<div className="p-4 border-b-2 border-border-custom bg-muted-custom flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-xl bg-[#facc15] text-black border-2 border-black flex items-center justify-center font-black text-xs shadow-[2px_2px_0_0_#000]">
PA
</div>
<div>
<h3 className="text-xs font-black font-heading text-foreground-custom tracking-tight uppercase">
Portfolio Assistant
</h3>
<p className="text-[10px] font-mono text-muted-foreground-custom font-medium">
Fast Keyword Knowledge Search
</p>
</div>
</div>
<button
onClick={() => {
haptic.tap();
setIsOpen(false);
}}
aria-label="Close Assistant"
className="p-1.5 rounded-lg bg-card-custom border-2 border-border-custom shadow-[2px_2px_0_0_var(--border-color)] text-foreground-custom hover:bg-rose-500 hover:text-white transition-all cursor-pointer active:translate-x-[1px] active:translate-y-[1px] active:shadow-none"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Messages Area */}
<div className="flex-1 overflow-y-auto p-4 flex flex-col gap-3 text-xs leading-relaxed bg-background-custom/50">
{messages.map((msg, i) => {
const isUser = msg.role === 'user';
return (
<div
key={i}
className={`flex flex-col ${isUser ? 'items-end' : 'items-start'}`}
>
<div
className={`p-3 rounded-xl max-w-[88%] ${
isUser
? 'bg-[#facc15] text-black font-medium border-2 border-black shadow-[2px_2px_0_0_#000] rounded-br-xs'
: 'bg-card-custom text-foreground-custom border-2 border-border-custom shadow-[2px_2px_0_0_var(--border-color)] rounded-bl-xs'
}`}
>
{renderMessageContent(msg.text, isUser)}
</div>
</div>
);
})}
{loading && (
<div className="flex items-center gap-1.5 p-3 rounded-xl bg-card-custom border-2 border-border-custom shadow-[2px_2px_0_0_var(--border-color)] w-fit text-xs text-foreground-custom">
<span className="w-2 h-2 bg-[#facc15] border border-black animate-bounce" />
<span className="w-2 h-2 bg-[#facc15] border border-black animate-bounce delay-100" />
<span className="w-2 h-2 bg-[#facc15] border border-black animate-bounce delay-200" />
<span className="text-[10px] font-mono font-bold ml-1">Searching records...</span>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Quick Suggestions Chips with Anti-Spam Cooldown */}
<div className="px-3 py-2 border-t-2 border-border-custom bg-muted-custom/40 flex flex-col gap-1.5">
<div className="flex items-center justify-between px-0.5">
<span className="text-[9px] font-mono font-bold text-muted-foreground-custom uppercase tracking-wider">
Quick Topics
</span>
{cooldown > 0 ? (
<span className="text-[9px] font-mono font-bold text-amber-500 flex items-center gap-1">
<span className="inline-block w-1.5 h-1.5 rounded-full bg-amber-500 animate-ping" />
Cooldown ({cooldown}s)
</span>
) : spamNotice ? (
<span className="text-[9px] font-mono text-rose-500 font-bold truncate max-w-[200px]">
{spamNotice}
</span>
) : null}
</div>
<div className="flex items-center gap-1.5 overflow-x-auto no-scrollbar">
{['Skills', 'Projects', 'Experience', 'Education', 'Contact'].map((q) => (
<button
key={q}
disabled={loading || cooldown > 0}
onClick={() => handleQuickPrompt(q)}
className="shrink-0 text-[10px] font-mono font-bold px-2.5 py-1 rounded-lg border-2 border-border-custom bg-card-custom text-foreground-custom hover:bg-[#facc15] hover:text-black shadow-[1px_1px_0_0_var(--border-color)] active:translate-x-[1px] active:translate-y-[1px] active:shadow-none disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer transition-all"
>
{q}
</button>
))}
</div>
</div>
{/* Input Form with Anti-Spam & Copy-Paste Throttling */}
<form
onSubmit={(e) => {
e.preventDefault();
handleSend();
}}
className="p-3 border-t-2 border-border-custom flex flex-col gap-1.5 bg-card-custom"
>
{spamNotice && (
<div className="text-[10px] font-mono font-bold text-rose-500 px-1 truncate flex items-center gap-1.5 animate-pulse">
<svg className="w-3.5 h-3.5 text-rose-500 shrink-0" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
<span>{spamNotice}</span>
</div>
)}
<div className="flex items-center gap-2">
<input
ref={inputRef}
type="text"
maxLength={250}
value={input}
disabled={loading || cooldown > 0}
onChange={(e) => setInput(e.target.value)}
placeholder={
cooldown > 0
? `Cooldown active (${cooldown}s)...`
: "Type keyword (e.g. skills, projects)..."
}
className="flex-1 bg-background-custom border-2 border-border-custom rounded-xl px-3 py-2 text-xs text-foreground-custom placeholder:text-muted-foreground-custom font-mono focus:outline-none focus:bg-[#facc15]/10 shadow-[2px_2px_0_0_var(--border-color)] disabled:opacity-50 disabled:cursor-not-allowed transition-all"
/>
<button
type="submit"
disabled={!input.trim() || loading || cooldown > 0}
aria-label="Send query"
className="p-2 rounded-xl bg-[#facc15] text-black border-2 border-border-custom shadow-[2px_2px_0_0_var(--border-color)] hover:shadow-[3px_3px_0_0_var(--border-color)] active:translate-x-[1px] active:translate-y-[1px] active:shadow-none disabled:opacity-40 disabled:cursor-not-allowed transition-all cursor-pointer"
>
<svg className="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 12 3.269 3.125A59.769 59.769 0 0 1 21.485 12 59.768 59.768 0 0 1 3.27 20.875L5.999 12Zm0 0h7.5" />
</svg>
</button>
</div>
</form>
</div>
)}
</>
);
}