Redesign page heroes, add pill buttons, improve HubertChat
Hero sections: - Redesign blog, dev, and contact page heroes with sleek modern style - Add gradient text titles, floating accent orbs, stats rows - Remove heavy terminal-style elements for cleaner aesthetic Buttons: - Add rounded-full to all buttons sitewide for consistent pill shape - Update btn-primary and btn-ghost utilities in global.css HubertChat: - Move Hubert to dedicated /hubert page - Add framer-motion for smooth input transitions - Support markdown rendering with marked + DOMPurify - Add multiline textarea with Shift+Enter support - Fix light mode visibility for input and message bubbles - Extract useHubertChat hook for cleaner state management Fonts: - Update to Sora (sans) and IBM Plex Mono (mono) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
7d681e63f9
commit
0b8a680dcb
@ -31,6 +31,8 @@
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"astro": "^5.16.4",
|
||||
"dompurify": "^3.3.1",
|
||||
"framer-motion": "^12.26.2",
|
||||
"lunr": "^2.3.9",
|
||||
"marked": "^17.0.1",
|
||||
"react": "^19.2.1",
|
||||
@ -40,6 +42,7 @@
|
||||
"zod": "^4.3.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/dompurify": "^3.2.0",
|
||||
"@types/node": "^24.10.1",
|
||||
"wrangler": "^4.53.0"
|
||||
}
|
||||
|
||||
65
pnpm-lock.yaml
generated
65
pnpm-lock.yaml
generated
@ -50,6 +50,12 @@ importers:
|
||||
astro:
|
||||
specifier: ^5.16.4
|
||||
version: 5.16.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(typescript@5.9.3)
|
||||
dompurify:
|
||||
specifier: ^3.3.1
|
||||
version: 3.3.1
|
||||
framer-motion:
|
||||
specifier: ^12.26.2
|
||||
version: 12.26.2(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
|
||||
lunr:
|
||||
specifier: ^2.3.9
|
||||
version: 2.3.9
|
||||
@ -72,6 +78,9 @@ importers:
|
||||
specifier: ^4.3.4
|
||||
version: 4.3.4
|
||||
devDependencies:
|
||||
'@types/dompurify':
|
||||
specifier: ^3.2.0
|
||||
version: 3.2.0
|
||||
'@types/node':
|
||||
specifier: ^24.10.1
|
||||
version: 24.10.1
|
||||
@ -1361,6 +1370,10 @@ packages:
|
||||
'@types/debug@4.1.12':
|
||||
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
|
||||
|
||||
'@types/dompurify@3.2.0':
|
||||
resolution: {integrity: sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==}
|
||||
deprecated: This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.
|
||||
|
||||
'@types/estree-jsx@1.0.5':
|
||||
resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==}
|
||||
|
||||
@ -1405,6 +1418,9 @@ packages:
|
||||
'@types/sax@1.2.7':
|
||||
resolution: {integrity: sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==}
|
||||
|
||||
'@types/trusted-types@2.0.7':
|
||||
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||
|
||||
'@types/unist@2.0.11':
|
||||
resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
|
||||
|
||||
@ -1710,6 +1726,9 @@ packages:
|
||||
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
dompurify@3.3.1:
|
||||
resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==}
|
||||
|
||||
domutils@3.2.2:
|
||||
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
|
||||
|
||||
@ -1836,6 +1855,20 @@ packages:
|
||||
fontkit@2.0.4:
|
||||
resolution: {integrity: sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==}
|
||||
|
||||
framer-motion@12.26.2:
|
||||
resolution: {integrity: sha512-lflOQEdjquUi9sCg5Y1LrsZDlsjrHw7m0T9Yedvnk7Bnhqfkc89/Uha10J3CFhkL+TCZVCRw9eUGyM/lyYhXQA==}
|
||||
peerDependencies:
|
||||
'@emotion/is-prop-valid': '*'
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
peerDependenciesMeta:
|
||||
'@emotion/is-prop-valid':
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
react-dom:
|
||||
optional: true
|
||||
|
||||
fsevents@2.3.3:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
@ -2282,6 +2315,12 @@ packages:
|
||||
engines: {node: '>=18.0.0'}
|
||||
hasBin: true
|
||||
|
||||
motion-dom@12.26.2:
|
||||
resolution: {integrity: sha512-KLMT1BroY8oKNeliA3JMNJ+nbCIsTKg6hJpDb4jtRAJ7nCKnnpg/LTq/NGqG90Limitz3kdAnAVXecdFVGlWTw==}
|
||||
|
||||
motion-utils@12.24.10:
|
||||
resolution: {integrity: sha512-x5TFgkCIP4pPsRLpKoI86jv/q8t8FQOiM/0E8QKBzfMozWHfkKap2gA1hOki+B5g3IsBNpxbUnfOum1+dgvYww==}
|
||||
|
||||
mrmime@2.0.1:
|
||||
resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
|
||||
engines: {node: '>=10'}
|
||||
@ -4033,6 +4072,10 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/ms': 2.1.0
|
||||
|
||||
'@types/dompurify@3.2.0':
|
||||
dependencies:
|
||||
dompurify: 3.3.1
|
||||
|
||||
'@types/estree-jsx@1.0.5':
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
@ -4079,6 +4122,9 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 24.10.1
|
||||
|
||||
'@types/trusted-types@2.0.7':
|
||||
optional: true
|
||||
|
||||
'@types/unist@2.0.11': {}
|
||||
|
||||
'@types/unist@3.0.3': {}
|
||||
@ -4426,6 +4472,10 @@ snapshots:
|
||||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
|
||||
dompurify@3.3.1:
|
||||
optionalDependencies:
|
||||
'@types/trusted-types': 2.0.7
|
||||
|
||||
domutils@3.2.2:
|
||||
dependencies:
|
||||
dom-serializer: 2.0.0
|
||||
@ -4629,6 +4679,15 @@ snapshots:
|
||||
unicode-properties: 1.4.1
|
||||
unicode-trie: 2.0.0
|
||||
|
||||
framer-motion@12.26.2(react-dom@19.2.1(react@19.2.1))(react@19.2.1):
|
||||
dependencies:
|
||||
motion-dom: 12.26.2
|
||||
motion-utils: 12.24.10
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
react: 19.2.1
|
||||
react-dom: 19.2.1(react@19.2.1)
|
||||
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
@ -5404,6 +5463,12 @@ snapshots:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
|
||||
motion-dom@12.26.2:
|
||||
dependencies:
|
||||
motion-utils: 12.24.10
|
||||
|
||||
motion-utils@12.24.10: {}
|
||||
|
||||
mrmime@2.0.1: {}
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
@ -124,14 +124,14 @@ const professionalServiceSchema = {
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;800&family=Space+Mono:wght@400;700&display=swap"
|
||||
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600;700&family=Sora:wght@300;400;500;600;700;800&display=swap"
|
||||
rel="stylesheet"
|
||||
media="print"
|
||||
onload="this.media='all'"
|
||||
/>
|
||||
<noscript>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;800&family=Space+Mono:wght@400;700&display=swap"
|
||||
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600;700&family=Sora:wght@300;400;500;600;700;800&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</noscript>
|
||||
|
||||
@ -19,13 +19,13 @@ const today = new Date();
|
||||
</h2>
|
||||
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<a href="mailto:nicholai@nicholai.work" class="group flex items-center gap-4 px-6 py-4 border border-brand-accent/30 bg-brand-accent/5 hover:bg-brand-accent hover:text-brand-dark transition-all duration-300">
|
||||
<a href="mailto:nicholai@nicholai.work" class="group flex items-center gap-4 px-6 py-4 border border-brand-accent/30 bg-brand-accent/5 hover:bg-brand-accent hover:text-brand-dark transition-all duration-300 rounded-full">
|
||||
<span class="font-mono text-xs font-bold uppercase tracking-widest">Connect_Uplink</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="square" stroke-linejoin="miter">
|
||||
<path d="M5 12h14M12 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</a>
|
||||
<a href="/contact" class="group flex items-center gap-4 px-6 py-4 border border-[var(--theme-border-strong)] hover:border-brand-accent hover:bg-brand-accent/5 transition-all duration-300">
|
||||
<a href="/contact" class="group flex items-center gap-4 px-6 py-4 border border-[var(--theme-border-strong)] hover:border-brand-accent hover:bg-brand-accent/5 transition-all duration-300 rounded-full">
|
||||
<span class="font-mono text-xs font-bold uppercase tracking-widest text-[var(--theme-text-primary)]">Manual_Input</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@ -1,371 +1,339 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import React, { useRef, useEffect, useState } from 'react';
|
||||
import { marked } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useHubertChat } from '../hooks/useHubertChat';
|
||||
|
||||
interface Message {
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
timestamp: string;
|
||||
// Configure marked for safe rendering
|
||||
marked.setOptions({
|
||||
breaks: true,
|
||||
gfm: true,
|
||||
});
|
||||
|
||||
// Render markdown to sanitized HTML
|
||||
function renderMarkdown(content: string): string {
|
||||
const rawHtml = marked.parse(content, { async: false }) as string;
|
||||
return DOMPurify.sanitize(rawHtml);
|
||||
}
|
||||
|
||||
// Utility: Fetch with timeout
|
||||
const fetchWithTimeout = async (url: string, options: RequestInit = {}, timeout = 8000) => {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
return response;
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export default function HubertChat() {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [visitorId, setVisitorId] = useState<string | null>(null);
|
||||
const [conversationId, setConversationId] = useState<string | null>(null);
|
||||
const [isTyping, setIsTyping] = useState(false);
|
||||
const [isInitializing, setIsInitializing] = useState(true);
|
||||
const [initError, setInitError] = useState<string | null>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const {
|
||||
messages,
|
||||
input,
|
||||
isTyping,
|
||||
isInitializing,
|
||||
initError,
|
||||
setInput,
|
||||
sendMessage,
|
||||
retryInit,
|
||||
messagesEndRef,
|
||||
} = useHubertChat({ initTimeout: 8000, chatTimeout: 30000 });
|
||||
|
||||
// Initialize visitor on mount
|
||||
useEffect(() => {
|
||||
const initVisitor = async () => {
|
||||
try {
|
||||
setIsInitializing(true);
|
||||
setInitError(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [inputHeight, setInputHeight] = useState(40);
|
||||
|
||||
const response = await fetchWithTimeout('/api/hubert/new-visitor', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}, 8000); // 8 second timeout
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
setVisitorId(data.visitor_id);
|
||||
setConversationId(data.conversation_id);
|
||||
|
||||
// Add system welcome message from Hubert
|
||||
setMessages([{
|
||||
role: 'system',
|
||||
content: `/// HUBERT_EUNUCH /// ONLINE\\n\\nI suppose you want something. State your business.`,
|
||||
timestamp: new Date().toISOString(),
|
||||
}]);
|
||||
} catch (error) {
|
||||
console.error('[Hubert] Initialization failed:', error);
|
||||
|
||||
let errorMessage = '/// ERROR: UNKNOWN_FAILURE';
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.name === 'AbortError') {
|
||||
errorMessage = '/// ERROR: TIMEOUT - API_UNRESPONSIVE';
|
||||
} else if (error.message.includes('Failed to fetch')) {
|
||||
errorMessage = '/// ERROR: NETWORK_FAILURE - CHECK_API_ROUTE';
|
||||
} else {
|
||||
errorMessage = `/// ERROR: ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
setInitError(errorMessage);
|
||||
setMessages([{
|
||||
role: 'system',
|
||||
content: errorMessage + '\\n\\nCLICK [RETRY] BELOW',
|
||||
timestamp: new Date().toISOString(),
|
||||
}]);
|
||||
} finally {
|
||||
setIsInitializing(false);
|
||||
}
|
||||
};
|
||||
initVisitor();
|
||||
}, []);
|
||||
|
||||
// Retry initialization
|
||||
const retryInit = () => {
|
||||
setIsInitializing(true);
|
||||
setInitError(null);
|
||||
setMessages([]);
|
||||
|
||||
// Re-trigger initialization
|
||||
const initVisitor = async () => {
|
||||
try {
|
||||
setIsInitializing(true);
|
||||
setInitError(null);
|
||||
|
||||
const response = await fetchWithTimeout('/api/hubert/new-visitor', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}, 8000);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
setVisitorId(data.visitor_id);
|
||||
setConversationId(data.conversation_id);
|
||||
|
||||
setMessages([{
|
||||
role: 'system',
|
||||
content: `/// HUBERT_EUNUCH /// ONLINE\\n\\nI suppose you want something. State your business.`,
|
||||
timestamp: new Date().toISOString(),
|
||||
}]);
|
||||
} catch (error) {
|
||||
console.error('[Hubert] Initialization failed:', error);
|
||||
|
||||
let errorMessage = '/// ERROR: UNKNOWN_FAILURE';
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.name === 'AbortError') {
|
||||
errorMessage = '/// ERROR: TIMEOUT - API_UNRESPONSIVE';
|
||||
} else if (error.message.includes('Failed to fetch')) {
|
||||
errorMessage = '/// ERROR: NETWORK_FAILURE - CHECK_API_ROUTE';
|
||||
} else {
|
||||
errorMessage = `/// ERROR: ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
setInitError(errorMessage);
|
||||
setMessages([{
|
||||
role: 'system',
|
||||
content: errorMessage + '\\n\\nCLICK [RETRY] BELOW',
|
||||
timestamp: new Date().toISOString(),
|
||||
}]);
|
||||
} finally {
|
||||
setIsInitializing(false);
|
||||
}
|
||||
};
|
||||
initVisitor();
|
||||
};
|
||||
|
||||
// Auto-scroll to bottom of chat container (not entire page)
|
||||
useEffect(() => {
|
||||
if (messagesEndRef.current) {
|
||||
const container = messagesEndRef.current.parentElement;
|
||||
if (container) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
const sendMessage = async () => {
|
||||
if (!input.trim() || isTyping || !visitorId || !conversationId) {
|
||||
return;
|
||||
}
|
||||
const userMessage: Message = {
|
||||
role: 'user',
|
||||
content: input,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, userMessage]);
|
||||
setInput('');
|
||||
setIsTyping(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/hubert/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
messages: [...messages, userMessage].map(m => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
})),
|
||||
conversation_id: conversationId,
|
||||
visitor_id: visitorId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
|
||||
const assistantMessage: Message = {
|
||||
role: 'assistant',
|
||||
content: data.messages[data.messages.length - 1]?.content || '...',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, assistantMessage]);
|
||||
} catch (error) {
|
||||
console.error('[Hubert] Chat error:', error);
|
||||
setMessages(prev => [...prev, {
|
||||
role: 'assistant',
|
||||
content: '/// HUBERT_MALFUNCTION - TRY AGAIN',
|
||||
timestamp: new Date().toISOString(),
|
||||
}]);
|
||||
} finally {
|
||||
setIsTyping(false);
|
||||
// Auto-resize textarea and track height for dynamic border radius
|
||||
const adjustTextareaHeight = () => {
|
||||
const textarea = textareaRef.current;
|
||||
if (textarea) {
|
||||
textarea.style.height = 'auto';
|
||||
const newHeight = Math.min(textarea.scrollHeight, 200);
|
||||
textarea.style.height = `${newHeight}px`;
|
||||
setInputHeight(newHeight);
|
||||
}
|
||||
};
|
||||
|
||||
// Show loading or error state
|
||||
if (isInitializing || initError) {
|
||||
useEffect(() => {
|
||||
adjustTextareaHeight();
|
||||
}, [input]);
|
||||
|
||||
// Check if multiline for padding adjustments
|
||||
const isMultiline = inputHeight > 48;
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
};
|
||||
|
||||
// Initial/Loading state - centered branding with input
|
||||
if (isInitializing && !initError) {
|
||||
return (
|
||||
<div className="bg-[var(--theme-bg-secondary)] border-2 border-[var(--theme-border-primary)] shadow-2xl">
|
||||
<div className="flex flex-col items-center justify-center py-12 px-6 gap-6">
|
||||
{isInitializing && !initError ? (
|
||||
<>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex gap-1.5">
|
||||
<div className="w-2 h-2 bg-brand-accent animate-pulse" />
|
||||
<div className="w-2 h-2 bg-[var(--theme-border-strong)]" />
|
||||
<div className="w-2 h-2 bg-[var(--theme-border-strong)]" />
|
||||
</div>
|
||||
<span className="text-xs font-mono text-[var(--theme-text-muted)]">
|
||||
HUBERT_IS_BOOTING...
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[10px] font-mono text-[var(--theme-text-subtle)] text-center max-w-md">
|
||||
Initializing chatbot... Check console for debug info.
|
||||
</div>
|
||||
</>
|
||||
) : initError ? (
|
||||
<>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex gap-1.5">
|
||||
<div className="w-2 h-2 bg-red-500" />
|
||||
<div className="w-2 h-2 bg-red-500/50" />
|
||||
<div className="w-2 h-2 bg-[var(--theme-border-strong)]" />
|
||||
</div>
|
||||
<span className="text-xs font-mono text-red-400">
|
||||
HUBERT_INITIALIZATION_FAILED
|
||||
</span>
|
||||
</div>
|
||||
<div className="font-mono text-sm text-[var(--theme-text-muted)] text-center max-w-md px-4 py-3 bg-[var(--theme-bg-tertiary)] border border-[var(--theme-border-secondary)]">
|
||||
{initError}
|
||||
</div>
|
||||
<button
|
||||
onClick={retryInit}
|
||||
className="px-6 py-3 bg-brand-accent text-brand-dark font-mono text-[10px] uppercase tracking-widest font-bold hover:bg-brand-accent/90 transition-all border-none cursor-pointer"
|
||||
>
|
||||
[RETRY]
|
||||
</button>
|
||||
<div className="text-[10px] font-mono text-[var(--theme-text-subtle)] text-center max-w-md">
|
||||
Check browser console for detailed error information.
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<div className="flex-1 flex flex-col items-center justify-center px-4">
|
||||
{/* Branding */}
|
||||
<div className="flex items-center gap-3 mb-8">
|
||||
<span className="text-2xl font-semibold text-[var(--theme-text-primary)]">Hubert</span>
|
||||
<div className="w-4 h-4 border-2 border-white/60 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
<p className="text-sm text-[var(--theme-text-muted)]">Waking up...</p>
|
||||
<span className="sr-only" role="status" aria-live="polite">
|
||||
Loading Hubert chat interface
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--theme-bg-secondary)] border-2 border-[var(--theme-border-primary)] shadow-2xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b-2 border-[var(--theme-border-primary)] bg-[var(--theme-hover-bg)]">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex gap-1.5">
|
||||
<div className="w-2 h-2 bg-brand-accent animate-pulse" />
|
||||
<div className="w-2 h-2 bg-[var(--theme-border-strong)]" />
|
||||
<div className="w-2 h-2 bg-[var(--theme-border-strong)]" />
|
||||
</div>
|
||||
<span className="text-[10px] font-mono font-bold uppercase tracking-[0.3em] text-brand-accent">
|
||||
/// HUBERT_EUNUCH /// ONLINE
|
||||
</span>
|
||||
</div>
|
||||
<div className="font-mono text-[9px] uppercase tracking-[0.2em] text-[var(--theme-text-muted)]">
|
||||
{visitorId ? `VISITOR: ${visitorId.slice(0, 8)}` : 'UNKNOWN'}
|
||||
</div>
|
||||
// Error state
|
||||
if (initError) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center px-4">
|
||||
<span className="text-2xl font-semibold text-[var(--theme-text-primary)] mb-6">Hubert</span>
|
||||
<p className="text-sm text-[var(--theme-text-muted)] mb-4 text-center max-w-sm" role="alert">
|
||||
{initError}
|
||||
</p>
|
||||
<button
|
||||
onClick={retryInit}
|
||||
className="px-5 py-2.5 rounded-full bg-white/10 hover:bg-white/15 text-sm text-[var(--theme-text-primary)] transition-colors"
|
||||
aria-label="Retry connecting to Hubert"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
{/* Messages */}
|
||||
<div className="h-[500px] overflow-y-auto p-6 space-y-4">
|
||||
{messages.map((msg, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`flex gap-4 ${msg.role === 'user' ? 'flex-row-reverse' : ''}`}
|
||||
// No messages yet - show centered input
|
||||
if (messages.length === 0) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center px-4">
|
||||
{/* Branding */}
|
||||
<span className="text-3xl font-semibold text-[var(--theme-text-primary)] mb-10">Hubert</span>
|
||||
|
||||
{/* Input bar */}
|
||||
<div className="w-full max-w-2xl">
|
||||
<motion.div
|
||||
layout
|
||||
transition={{ duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
|
||||
className={`relative bg-[var(--theme-bg-secondary)] border border-[var(--theme-border-primary)] focus-within:border-[var(--theme-border-strong)] ${isMultiline ? 'p-4' : 'flex items-center px-6 py-3'}`}
|
||||
style={{ borderRadius: isMultiline ? 28 : 9999 }}
|
||||
>
|
||||
<div className="max-w-[80%]">
|
||||
<div className={`font-mono text-xs uppercase tracking-widest mb-2 px-2 py-1 ${
|
||||
msg.role === 'user'
|
||||
? 'bg-brand-accent/20 border border-brand-accent/50 text-brand-accent'
|
||||
: 'bg-[var(--theme-bg-tertiary)] border border-[var(--theme-border-secondary)] text-[var(--theme-text-muted)]'
|
||||
}`}>
|
||||
{msg.role === 'user' ? 'YOU' : 'HUBERT'}
|
||||
</div>
|
||||
<div className={`p-4 border ${
|
||||
msg.role === 'user'
|
||||
? 'border-brand-accent/30 bg-brand-accent/5'
|
||||
: 'border-[var(--theme-border-secondary)] bg-[var(--theme-bg-tertiary)]'
|
||||
}`}>
|
||||
<p className="text-sm font-mono leading-relaxed whitespace-pre-wrap">
|
||||
{msg.content}
|
||||
</p>
|
||||
<div className="mt-2 text-[9px] font-mono text-[var(--theme-text-subtle)]">
|
||||
{new Date(msg.timestamp).toLocaleString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Typing indicator */}
|
||||
{isTyping && (
|
||||
<div className="flex gap-4">
|
||||
<div className="max-w-[80%]">
|
||||
<div className="p-4 border border-[var(--theme-border-secondary)] bg-[var(--theme-bg-tertiary)]">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex gap-1">
|
||||
<div className="w-1.5 h-1.5 bg-brand-accent animate-pulse" />
|
||||
<div className="w-1.5 h-1.5 bg-brand-accent animate-pulse" style={{ animationDelay: '150ms' }} />
|
||||
<div className="w-1.5 h-1.5 bg-brand-accent animate-pulse" style={{ animationDelay: '300ms' }} />
|
||||
</div>
|
||||
<span className="text-xs font-mono text-[var(--theme-text-muted)]">
|
||||
HUBERT_IS_PONDERING...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="border-t-2 border-[var(--theme-border-primary)] p-4 bg-[var(--theme-hover-bg)]">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1 relative">
|
||||
<input
|
||||
type="text"
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && sendMessage()}
|
||||
placeholder="/// HUBERT_AWAITS_INPUT..."
|
||||
className="w-full bg-transparent border-b-2 border-[var(--theme-border-primary)] py-3 text-lg font-mono text-[var(--theme-text-primary)] placeholder:text-[var(--theme-text-subtle)] focus:border-brand-accent focus:outline-none transition-colors"
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="What do you want to know?"
|
||||
aria-label="Type your message"
|
||||
rows={1}
|
||||
className={`bg-transparent text-[var(--theme-text-primary)] placeholder:text-[var(--theme-text-subtle)] text-base outline-none resize-none max-h-[200px] ${isMultiline ? 'w-full leading-relaxed px-2' : 'flex-1 leading-[40px]'}`}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={sendMessage}
|
||||
disabled={isTyping || !input.trim()}
|
||||
className="px-6 py-3 bg-brand-accent text-brand-dark font-mono text-[10px] uppercase tracking-widest font-bold hover:bg-brand-accent/90 disabled:opacity-50 disabled:cursor-not-allowed transition-all border-none"
|
||||
>
|
||||
[TRANSMIT]
|
||||
</button>
|
||||
<motion.div layout={false} className={isMultiline ? 'flex justify-end mt-3' : 'ml-3 flex-shrink-0'}>
|
||||
<button
|
||||
onClick={sendMessage}
|
||||
disabled={isTyping || !input.trim()}
|
||||
aria-label="Send message"
|
||||
className="w-10 h-10 rounded-full bg-[var(--theme-text-primary)] hover:opacity-90 disabled:opacity-30 disabled:cursor-not-allowed flex items-center justify-center transition-colors"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-[var(--theme-bg-primary)]">
|
||||
<path d="M12 19V5M5 12l7-7 7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Subtitle */}
|
||||
<p className="text-xs text-[var(--theme-text-subtle)] mt-6">
|
||||
A miserable AI assistant, here to interview you.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Chat mode - messages with input at bottom
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
{/* Messages area - scrollable */}
|
||||
<div
|
||||
className="flex-1 overflow-y-auto px-4 py-6 min-h-0"
|
||||
role="log"
|
||||
aria-live="polite"
|
||||
aria-label="Chat messages"
|
||||
>
|
||||
<div className="max-w-3xl mx-auto space-y-6">
|
||||
{messages.map((msg, index) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
{msg.role === 'user' ? (
|
||||
// User message - right aligned pill with markdown
|
||||
<div className="max-w-[80%] bg-[var(--theme-bg-secondary)] rounded-3xl px-5 py-3">
|
||||
<div
|
||||
className="user-message text-[var(--theme-text-primary)] text-[15px] leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(msg.content) }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
// Assistant message - left aligned with subtle label and markdown
|
||||
<div className="max-w-[85%] space-y-1.5">
|
||||
{(index === 0 || messages[index - 1]?.role === 'user') && (
|
||||
<span className="text-[11px] font-medium uppercase tracking-wide text-brand-accent">
|
||||
Hubert
|
||||
</span>
|
||||
)}
|
||||
<div
|
||||
className="hubert-message text-[var(--theme-text-secondary)] text-[15px] leading-[1.7]"
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(msg.content) }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Typing indicator */}
|
||||
{isTyping && (
|
||||
<div className="flex justify-start">
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[11px] font-medium uppercase tracking-wide text-brand-accent">
|
||||
Hubert
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-2 h-2 rounded-full bg-[var(--theme-text-muted)] animate-pulse" />
|
||||
<div className="w-2 h-2 rounded-full bg-[var(--theme-text-muted)] animate-pulse" style={{ animationDelay: '150ms' }} />
|
||||
<div className="w-2 h-2 rounded-full bg-[var(--theme-text-muted)] animate-pulse" style={{ animationDelay: '300ms' }} />
|
||||
</div>
|
||||
</div>
|
||||
<span className="sr-only" role="status">Hubert is typing</span>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Input bar - pinned to bottom */}
|
||||
<div className="flex-shrink-0 px-4 pb-4 pt-2">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<motion.div
|
||||
layout
|
||||
transition={{ duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
|
||||
className={`relative bg-[var(--theme-bg-secondary)] border border-[var(--theme-border-primary)] focus-within:border-[var(--theme-border-strong)] ${isMultiline ? 'p-4' : 'flex items-center px-6 py-3'}`}
|
||||
style={{ borderRadius: isMultiline ? 28 : 9999 }}
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="How can Hubert help?"
|
||||
aria-label="Type your message"
|
||||
rows={1}
|
||||
className={`bg-transparent text-[var(--theme-text-primary)] placeholder:text-[var(--theme-text-subtle)] text-base outline-none resize-none max-h-[200px] ${isMultiline ? 'w-full leading-relaxed px-2' : 'flex-1 leading-[40px]'}`}
|
||||
/>
|
||||
<motion.div layout={false} className={isMultiline ? 'flex justify-end mt-3' : 'ml-3 flex-shrink-0'}>
|
||||
<button
|
||||
onClick={sendMessage}
|
||||
disabled={isTyping || !input.trim()}
|
||||
aria-label="Send message"
|
||||
className="w-10 h-10 rounded-full bg-[var(--theme-text-primary)] hover:opacity-90 disabled:opacity-30 disabled:cursor-not-allowed flex items-center justify-center transition-colors"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-[var(--theme-bg-primary)]">
|
||||
<path d="M12 19V5M5 12l7-7 7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Styles for markdown content */}
|
||||
<style>{`
|
||||
.hubert-message p,
|
||||
.user-message p {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.hubert-message p:last-child,
|
||||
.user-message p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.hubert-message strong,
|
||||
.user-message strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
.hubert-message strong {
|
||||
color: var(--theme-text-primary);
|
||||
}
|
||||
.hubert-message em,
|
||||
.user-message em {
|
||||
font-style: italic;
|
||||
}
|
||||
.hubert-message code,
|
||||
.user-message code {
|
||||
background: var(--theme-bg-secondary);
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 0.25rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.875em;
|
||||
}
|
||||
.user-message code {
|
||||
background: rgba(255,255,255,0.1);
|
||||
}
|
||||
.hubert-message pre,
|
||||
.user-message pre {
|
||||
background: var(--theme-bg-secondary);
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
overflow-x: auto;
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
.user-message pre {
|
||||
background: rgba(255,255,255,0.05);
|
||||
}
|
||||
.hubert-message pre code,
|
||||
.user-message pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
.hubert-message ul, .hubert-message ol,
|
||||
.user-message ul, .user-message ol {
|
||||
margin: 0.5rem 0;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
.hubert-message li,
|
||||
.user-message li {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.hubert-message ul li,
|
||||
.user-message ul li {
|
||||
list-style-type: disc;
|
||||
}
|
||||
.hubert-message ol li,
|
||||
.user-message ol li {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
.hubert-message blockquote,
|
||||
.user-message blockquote {
|
||||
border-left: 3px solid var(--color-brand-accent);
|
||||
padding-left: 1rem;
|
||||
margin: 0.75rem 0;
|
||||
font-style: italic;
|
||||
}
|
||||
.hubert-message blockquote {
|
||||
color: var(--theme-text-muted);
|
||||
}
|
||||
.hubert-message a,
|
||||
.user-message a {
|
||||
color: var(--color-brand-accent);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.hubert-message a:hover,
|
||||
.user-message a:hover {
|
||||
color: var(--theme-text-primary);
|
||||
}
|
||||
.hubert-message h1, .hubert-message h2, .hubert-message h3,
|
||||
.user-message h1, .user-message h2, .user-message h3 {
|
||||
font-weight: 600;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.hubert-message h1, .hubert-message h2, .hubert-message h3 {
|
||||
color: var(--theme-text-primary);
|
||||
}
|
||||
.hubert-message h1, .user-message h1 { font-size: 1.25rem; }
|
||||
.hubert-message h2, .user-message h2 { font-size: 1.125rem; }
|
||||
.hubert-message h3, .user-message h3 { font-size: 1rem; }
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -47,13 +47,24 @@ import ThemeToggle from './ThemeToggle.astro';
|
||||
Astro.url.pathname.startsWith('/blog') ? "w-full" : "w-0 group-hover:w-full"
|
||||
]}></span>
|
||||
</a>
|
||||
<a href="/hubert"
|
||||
class:list={[
|
||||
"relative text-xs font-semibold uppercase tracking-[0.15em] transition-all duration-300 py-2 group",
|
||||
Astro.url.pathname.startsWith('/hubert') ? "text-[var(--theme-text-primary)]" : "text-[var(--theme-text-muted)] hover:text-[var(--theme-text-primary)]"
|
||||
]}>
|
||||
<span class="relative z-10">Hubert</span>
|
||||
<span class:list={[
|
||||
"absolute bottom-0 left-0 h-[1px] bg-brand-accent transition-all duration-300 ease-out",
|
||||
Astro.url.pathname.startsWith('/hubert') ? "w-full" : "w-0 group-hover:w-full"
|
||||
]}></span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<a href="/contact"
|
||||
class:list={[
|
||||
"hidden md:block border px-5 lg:px-6 py-2.5 lg:py-3 text-xs font-bold uppercase tracking-[0.15em] transition-all duration-300",
|
||||
Astro.url.pathname.startsWith('/contact')
|
||||
? "border-brand-accent bg-brand-accent text-brand-dark"
|
||||
"hidden md:block border px-5 lg:px-6 py-2.5 lg:py-3 text-xs font-bold uppercase tracking-[0.15em] transition-all duration-300 rounded-full",
|
||||
Astro.url.pathname.startsWith('/contact')
|
||||
? "border-brand-accent bg-brand-accent text-brand-dark"
|
||||
: "border-[var(--theme-border-strong)] text-[var(--theme-text-primary)] hover:border-brand-accent hover:bg-brand-accent hover:text-brand-dark"
|
||||
]}>
|
||||
Let's Talk
|
||||
@ -107,6 +118,12 @@ import ThemeToggle from './ThemeToggle.astro';
|
||||
>
|
||||
Blog
|
||||
</a>
|
||||
<a
|
||||
href="/hubert"
|
||||
class="mobile-nav-link text-3xl font-bold uppercase tracking-wider text-[var(--theme-text-primary)] hover:text-brand-accent transition-colors duration-300"
|
||||
>
|
||||
Hubert
|
||||
</a>
|
||||
<a
|
||||
href="/contact"
|
||||
class="mobile-nav-link text-3xl font-bold uppercase tracking-wider text-[var(--theme-text-primary)] hover:text-brand-accent transition-colors duration-300"
|
||||
@ -118,7 +135,7 @@ import ThemeToggle from './ThemeToggle.astro';
|
||||
<!-- CTA Button -->
|
||||
<a
|
||||
href="/contact"
|
||||
class="border border-brand-accent px-8 py-4 text-sm font-bold uppercase tracking-[0.2em] text-brand-accent hover:bg-brand-accent hover:text-brand-dark transition-all duration-300 mb-8"
|
||||
class="border border-brand-accent px-8 py-4 text-sm font-bold uppercase tracking-[0.2em] text-brand-accent hover:bg-brand-accent hover:text-brand-dark transition-all duration-300 mb-8 rounded-full"
|
||||
>
|
||||
Let's Talk
|
||||
</a>
|
||||
|
||||
@ -162,7 +162,7 @@ export default function SearchDialog() {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="hidden md:flex items-center gap-3 px-4 py-2 border border-[var(--theme-border-primary)] bg-[var(--theme-hover-bg)] text-[var(--theme-text-muted)] hover:border-brand-accent hover:text-[var(--theme-text-primary)] transition-all duration-300 text-xs"
|
||||
className="hidden md:flex items-center gap-3 px-4 py-2 border border-[var(--theme-border-primary)] bg-[var(--theme-hover-bg)] text-[var(--theme-text-muted)] hover:border-brand-accent hover:text-[var(--theme-text-primary)] transition-all duration-300 text-xs rounded-full"
|
||||
aria-label="Open search"
|
||||
>
|
||||
<svg
|
||||
@ -216,7 +216,7 @@ export default function SearchDialog() {
|
||||
</div>
|
||||
<button
|
||||
onClick={closeSearch}
|
||||
className="text-[9px] font-mono uppercase tracking-wider px-3 py-1.5 border border-[var(--theme-border-primary)] text-[var(--theme-text-muted)] hover:border-brand-accent hover:text-brand-accent transition-all"
|
||||
className="text-[9px] font-mono uppercase tracking-wider px-3 py-1.5 border border-[var(--theme-border-primary)] text-[var(--theme-text-muted)] hover:border-brand-accent hover:text-brand-accent transition-all rounded-full"
|
||||
>
|
||||
[ESC]
|
||||
</button>
|
||||
@ -255,7 +255,7 @@ export default function SearchDialog() {
|
||||
setQuery('');
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
className="text-[10px] font-mono uppercase tracking-wider px-3 py-1.5 border border-[var(--theme-border-primary)] text-[var(--theme-text-muted)] hover:border-brand-accent hover:text-brand-accent transition-all"
|
||||
className="text-[10px] font-mono uppercase tracking-wider px-3 py-1.5 border border-[var(--theme-border-primary)] text-[var(--theme-text-muted)] hover:border-brand-accent hover:text-brand-accent transition-all rounded-full"
|
||||
>
|
||||
[CLR]
|
||||
</button>
|
||||
|
||||
@ -104,7 +104,7 @@
|
||||
<button
|
||||
type="button"
|
||||
id="remember-yes"
|
||||
class="group px-4 py-3 border border-[var(--theme-border-strong)] hover:border-brand-accent hover:bg-brand-accent transition-all duration-300"
|
||||
class="group px-4 py-3 border border-[var(--theme-border-strong)] hover:border-brand-accent hover:bg-brand-accent transition-all duration-300 rounded-full"
|
||||
>
|
||||
<span class="text-sm font-bold uppercase tracking-tight text-[var(--theme-text-primary)] group-hover:text-brand-dark">
|
||||
Save
|
||||
@ -114,7 +114,7 @@
|
||||
<button
|
||||
type="button"
|
||||
id="remember-no"
|
||||
class="group px-4 py-3 border border-[var(--theme-border-strong)] hover:border-[var(--theme-text-subtle)] transition-all duration-300"
|
||||
class="group px-4 py-3 border border-[var(--theme-border-strong)] hover:border-[var(--theme-text-subtle)] transition-all duration-300 rounded-full"
|
||||
>
|
||||
<span class="text-sm font-bold uppercase tracking-tight text-[var(--theme-text-primary)]">
|
||||
Session
|
||||
|
||||
@ -284,7 +284,7 @@ const DevEngageModal: React.FC = () => {
|
||||
|
||||
<button
|
||||
onClick={closeModal}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-[var(--theme-border-primary)] hover:border-brand-accent hover:bg-brand-accent/5 transition-all font-mono text-[10px] uppercase tracking-widest text-[var(--theme-text-muted)] hover:text-brand-accent"
|
||||
className="flex items-center gap-2 px-4 py-2 border border-[var(--theme-border-primary)] hover:border-brand-accent hover:bg-brand-accent/5 transition-all font-mono text-[10px] uppercase tracking-widest text-[var(--theme-text-muted)] hover:text-brand-accent rounded-full"
|
||||
>
|
||||
<span className="hidden sm:inline">DISCONNECT</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="square">
|
||||
@ -375,19 +375,19 @@ const DevEngageModal: React.FC = () => {
|
||||
href={activeProject.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-6 py-3 bg-brand-accent text-brand-dark font-mono text-[10px] uppercase tracking-widest font-bold hover:bg-brand-accent/90 transition-colors"
|
||||
className="px-6 py-3 bg-brand-accent text-brand-dark font-mono text-[10px] uppercase tracking-widest font-bold hover:bg-brand-accent/90 transition-colors rounded-full"
|
||||
>
|
||||
OPEN_EXTERNALLY
|
||||
</a>
|
||||
<button
|
||||
onClick={handleCopyLink}
|
||||
className="px-6 py-3 border border-[var(--theme-border-primary)] font-mono text-[10px] uppercase tracking-widest text-[var(--theme-text-muted)] hover:border-brand-accent hover:text-brand-accent transition-all"
|
||||
className="px-6 py-3 border border-[var(--theme-border-primary)] font-mono text-[10px] uppercase tracking-widest text-[var(--theme-text-muted)] hover:border-brand-accent hover:text-brand-accent transition-all rounded-full"
|
||||
>
|
||||
COPY_LINK
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRetry}
|
||||
className="px-6 py-3 border border-[var(--theme-border-primary)] font-mono text-[10px] uppercase tracking-widest text-[var(--theme-text-muted)] hover:border-brand-accent hover:text-brand-accent transition-all"
|
||||
className="px-6 py-3 border border-[var(--theme-border-primary)] font-mono text-[10px] uppercase tracking-widest text-[var(--theme-text-muted)] hover:border-brand-accent hover:text-brand-accent transition-all rounded-full"
|
||||
>
|
||||
RETRY
|
||||
</button>
|
||||
@ -421,7 +421,7 @@ const DevEngageModal: React.FC = () => {
|
||||
<button
|
||||
onClick={toggleArm}
|
||||
disabled={modalState === 'booting' || modalState === 'blocked'}
|
||||
className={`w-full py-4 font-mono text-xs uppercase tracking-widest font-bold transition-all duration-300 border ${
|
||||
className={`w-full py-4 font-mono text-xs uppercase tracking-widest font-bold transition-all duration-300 border rounded-full ${
|
||||
isInteractive
|
||||
? 'bg-green-500/20 border-green-500 text-green-500 hover:bg-green-500/30'
|
||||
: 'bg-brand-accent/10 border-brand-accent/50 text-brand-accent hover:bg-brand-accent/20 hover:border-brand-accent'
|
||||
@ -470,7 +470,7 @@ const DevEngageModal: React.FC = () => {
|
||||
href={activeProject.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-between px-4 py-3 border border-[var(--theme-border-primary)] font-mono text-[10px] uppercase tracking-widest text-[var(--theme-text-muted)] hover:border-brand-accent hover:text-brand-accent hover:bg-brand-accent/5 transition-all"
|
||||
className="flex items-center justify-between px-4 py-3 border border-[var(--theme-border-primary)] font-mono text-[10px] uppercase tracking-widest text-[var(--theme-text-muted)] hover:border-brand-accent hover:text-brand-accent hover:bg-brand-accent/5 transition-all rounded-full"
|
||||
>
|
||||
OPEN_EXTERNALLY
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="square">
|
||||
@ -483,7 +483,7 @@ const DevEngageModal: React.FC = () => {
|
||||
iframeRef.current.src = activeProject.link;
|
||||
}
|
||||
}}
|
||||
className="flex items-center justify-between px-4 py-3 border border-[var(--theme-border-primary)] font-mono text-[10px] uppercase tracking-widest text-[var(--theme-text-muted)] hover:border-brand-accent hover:text-brand-accent hover:bg-brand-accent/5 transition-all"
|
||||
className="flex items-center justify-between px-4 py-3 border border-[var(--theme-border-primary)] font-mono text-[10px] uppercase tracking-widest text-[var(--theme-text-muted)] hover:border-brand-accent hover:text-brand-accent hover:bg-brand-accent/5 transition-all rounded-full"
|
||||
>
|
||||
RELOAD_FEED
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="square">
|
||||
|
||||
@ -35,6 +35,6 @@ import HubertChat from '../HubertChat';
|
||||
</div>
|
||||
|
||||
<!-- Hubert Chat Interface -->
|
||||
<HubertChat client:load />
|
||||
<HubertChat client:visible />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -73,6 +73,7 @@ const sections = defineCollection({
|
||||
value: z.string(),
|
||||
})).optional(),
|
||||
linkUrl: z.string().optional(),
|
||||
videoUrl: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
252
src/hooks/useHubertChat.ts
Normal file
252
src/hooks/useHubertChat.ts
Normal file
@ -0,0 +1,252 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
interface UseHubertChatOptions {
|
||||
initTimeout?: number;
|
||||
chatTimeout?: number;
|
||||
}
|
||||
|
||||
interface UseHubertChatReturn {
|
||||
messages: Message[];
|
||||
input: string;
|
||||
isTyping: boolean;
|
||||
isInitializing: boolean;
|
||||
initError: string | null;
|
||||
visitorId: string | null;
|
||||
setInput: (value: string) => void;
|
||||
sendMessage: () => Promise<void>;
|
||||
retryInit: () => void;
|
||||
messagesEndRef: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
// Generate unique message ID
|
||||
const generateId = () => `msg_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
||||
|
||||
// Utility: Fetch with timeout
|
||||
const fetchWithTimeout = async (
|
||||
url: string,
|
||||
options: RequestInit = {},
|
||||
timeout: number
|
||||
): Promise<Response> => {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
return response;
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Parse error into user-friendly message
|
||||
const parseError = (error: unknown): string => {
|
||||
if (error instanceof Error) {
|
||||
if (error.name === 'AbortError') {
|
||||
return '/// ERROR: TIMEOUT - API_UNRESPONSIVE';
|
||||
} else if (error.message.includes('Failed to fetch')) {
|
||||
return '/// ERROR: NETWORK_FAILURE - CHECK_API_ROUTE';
|
||||
}
|
||||
return `/// ERROR: ${error.message}`;
|
||||
}
|
||||
return '/// ERROR: UNKNOWN_FAILURE';
|
||||
};
|
||||
|
||||
export function useHubertChat(options: UseHubertChatOptions = {}): UseHubertChatReturn {
|
||||
const { initTimeout = 8000, chatTimeout = 30000 } = options;
|
||||
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [visitorId, setVisitorId] = useState<string | null>(null);
|
||||
const [conversationId, setConversationId] = useState<string | null>(null);
|
||||
const [isTyping, setIsTyping] = useState(false);
|
||||
const [isInitializing, setIsInitializing] = useState(true);
|
||||
const [initError, setInitError] = useState<string | null>(null);
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const isRetryingRef = useRef(false);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Initialize visitor
|
||||
const initVisitor = useCallback(async () => {
|
||||
try {
|
||||
setIsInitializing(true);
|
||||
setInitError(null);
|
||||
|
||||
const response = await fetchWithTimeout(
|
||||
'/api/hubert/new-visitor',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
},
|
||||
initTimeout
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
setVisitorId(data.visitor_id);
|
||||
setConversationId(data.conversation_id);
|
||||
|
||||
setMessages([
|
||||
{
|
||||
id: generateId(),
|
||||
role: 'system',
|
||||
content: `I suppose you want something. State your business.`,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('[Hubert] Initialization failed:', error);
|
||||
const errorMessage = parseError(error);
|
||||
|
||||
setInitError(errorMessage);
|
||||
setMessages([
|
||||
{
|
||||
id: generateId(),
|
||||
role: 'system',
|
||||
content: errorMessage + '\n\nCLICK [RETRY] BELOW',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
setIsInitializing(false);
|
||||
}
|
||||
}, [initTimeout]);
|
||||
|
||||
// Initialize on mount
|
||||
useEffect(() => {
|
||||
initVisitor();
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, [initVisitor]);
|
||||
|
||||
// Retry initialization with race condition protection
|
||||
const retryInit = useCallback(() => {
|
||||
if (isRetryingRef.current) return;
|
||||
isRetryingRef.current = true;
|
||||
|
||||
setMessages([]);
|
||||
initVisitor().finally(() => {
|
||||
isRetryingRef.current = false;
|
||||
});
|
||||
}, [initVisitor]);
|
||||
|
||||
// Auto-scroll to bottom of chat container
|
||||
useEffect(() => {
|
||||
if (messagesEndRef.current) {
|
||||
const container = messagesEndRef.current.parentElement;
|
||||
if (container) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
// Send message
|
||||
const sendMessage = useCallback(async () => {
|
||||
if (!input.trim() || isTyping || !visitorId || !conversationId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userMessage: Message = {
|
||||
id: generateId(),
|
||||
role: 'user',
|
||||
content: input,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, userMessage]);
|
||||
setInput('');
|
||||
setIsTyping(true);
|
||||
|
||||
// Cancel any pending request
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
try {
|
||||
const response = await fetchWithTimeout(
|
||||
'/api/hubert/chat',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
messages: [...messages, userMessage].map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
})),
|
||||
conversation_id: conversationId,
|
||||
visitor_id: visitorId,
|
||||
}),
|
||||
},
|
||||
chatTimeout
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
|
||||
const assistantMessage: Message = {
|
||||
id: generateId(),
|
||||
role: 'assistant',
|
||||
content: data.messages[data.messages.length - 1]?.content || '...',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, assistantMessage]);
|
||||
} catch (error) {
|
||||
console.error('[Hubert] Chat error:', error);
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: generateId(),
|
||||
role: 'assistant',
|
||||
content: '/// HUBERT_MALFUNCTION - TRY AGAIN',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
setIsTyping(false);
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
}, [input, isTyping, visitorId, conversationId, messages, chatTimeout]);
|
||||
|
||||
return {
|
||||
messages,
|
||||
input,
|
||||
isTyping,
|
||||
isInitializing,
|
||||
initError,
|
||||
visitorId,
|
||||
setInput,
|
||||
sendMessage,
|
||||
retryInit,
|
||||
messagesEndRef,
|
||||
};
|
||||
}
|
||||
@ -209,7 +209,7 @@ const articleSchema = {
|
||||
href={`https://twitter.com/intent/tweet?text=${encodeURIComponent(title)}&url=${encodeURIComponent(Astro.url.href)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="w-10 h-10 flex items-center justify-center border border-[var(--theme-border-primary)] text-[var(--theme-text-secondary)] hover:border-brand-accent hover:text-brand-accent transition-all duration-300"
|
||||
class="w-10 h-10 flex items-center justify-center border border-[var(--theme-border-primary)] text-[var(--theme-text-secondary)] hover:border-brand-accent hover:text-brand-accent transition-all duration-300 rounded-full"
|
||||
aria-label="Share on Twitter"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
@ -220,7 +220,7 @@ const articleSchema = {
|
||||
href={`https://www.linkedin.com/shareArticle?mini=true&url=${encodeURIComponent(Astro.url.href)}&title=${encodeURIComponent(title)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="w-10 h-10 flex items-center justify-center border border-[var(--theme-border-primary)] text-[var(--theme-text-secondary)] hover:border-brand-accent hover:text-brand-accent transition-all duration-300"
|
||||
class="w-10 h-10 flex items-center justify-center border border-[var(--theme-border-primary)] text-[var(--theme-text-secondary)] hover:border-brand-accent hover:text-brand-accent transition-all duration-300 rounded-full"
|
||||
aria-label="Share on LinkedIn"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
@ -232,7 +232,7 @@ const articleSchema = {
|
||||
<button
|
||||
type="button"
|
||||
onclick="navigator.clipboard.writeText(window.location.href)"
|
||||
class="w-10 h-10 flex items-center justify-center border border-[var(--theme-border-primary)] text-[var(--theme-text-secondary)] hover:border-brand-accent hover:text-brand-accent transition-all duration-300"
|
||||
class="w-10 h-10 flex items-center justify-center border border-[var(--theme-border-primary)] text-[var(--theme-text-secondary)] hover:border-brand-accent hover:text-brand-accent transition-all duration-300 rounded-full"
|
||||
aria-label="Copy link"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
|
||||
@ -1,12 +1,6 @@
|
||||
// Prevent prerendering - this endpoint requires runtime Cloudflare bindings
|
||||
export const prerender = false;
|
||||
|
||||
import { ChatOpenAI } from '@langchain/openai';
|
||||
import { tool } from '@langchain/core/tools';
|
||||
import { z } from 'zod';
|
||||
import { getCollection } from 'astro:content';
|
||||
import { HumanMessage, AIMessage } from '@langchain/core/messages';
|
||||
|
||||
/**
|
||||
* Hubert The Eunuch Chatbot
|
||||
*
|
||||
@ -22,50 +16,6 @@ export interface Env {
|
||||
OPENROUTER_API_KEY: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool: Search blog content (RAG)
|
||||
* Searches portfolio blog for relevant content when user asks questions
|
||||
* about the site, projects, or blog posts.
|
||||
*/
|
||||
const searchBlog = tool(
|
||||
async (input: { query: string }) => {
|
||||
try {
|
||||
const blog = await getCollection('blog');
|
||||
|
||||
const queryLower = input.query.toLowerCase();
|
||||
const results = blog.filter(post =>
|
||||
post.data.title.toLowerCase().includes(queryLower) ||
|
||||
post.data.description.toLowerCase().includes(queryLower) ||
|
||||
post.body.toLowerCase().includes(queryLower)
|
||||
).slice(0, 3);
|
||||
|
||||
console.log(`[Hubert] Blog search for "${input.query}" returned ${results.length} results`);
|
||||
|
||||
return {
|
||||
results: results.map(post => ({
|
||||
title: post.data.title,
|
||||
url: `/blog/${post.id}/`,
|
||||
description: post.data.description,
|
||||
})),
|
||||
count: results.length,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[Hubert] Blog search failed:', error);
|
||||
return {
|
||||
error: 'Failed to search blog content',
|
||||
details: String(error),
|
||||
};
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'search_blog',
|
||||
description: 'Search portfolio blog for relevant content when user asks questions about the site, projects, or blog posts.',
|
||||
schema: z.object({
|
||||
query: z.string().describe('Search query for blog content'),
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* POST: Handle chat messages from Hubert interface
|
||||
*/
|
||||
@ -108,9 +58,6 @@ export const POST = async (context) => {
|
||||
}
|
||||
|
||||
console.log(`[Hubert] New message for conversation ${conversation_id} from visitor ${visitor_id}`);
|
||||
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
const userContent = lastMessage.content;
|
||||
|
||||
const systemPrompt = `Your name is Hubert, but everyone calls you Hubert The Eunuch.
|
||||
|
||||
@ -144,6 +91,7 @@ When they say goodbye or conversation ends, use the save_conversation tool to ar
|
||||
temperature: 0.7,
|
||||
};
|
||||
|
||||
const startTime = Date.now();
|
||||
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@ -153,6 +101,7 @@ When they say goodbye or conversation ends, use the save_conversation tool to ar
|
||||
'X-Title': 'Nicholai Portfolio',
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
signal: AbortSignal.timeout(25000), // 25 second timeout
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@ -170,7 +119,7 @@ When they say goodbye or conversation ends, use the save_conversation tool to ar
|
||||
const data = await response.json();
|
||||
const assistantContent = data.choices[0]?.message?.content || '...';
|
||||
|
||||
const responseTime = response.headers.get('date') ? Date.now() - Date.parse(response.headers.get('date')) : 0;
|
||||
const responseTime = Date.now() - startTime;
|
||||
console.log(`[Hubert] Generated response in ${responseTime}ms`);
|
||||
|
||||
return new Response(
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
import { getEntry } from 'astro:content';
|
||||
|
||||
// Prevent prerendering - this endpoint requires runtime Cloudflare bindings
|
||||
export const prerender = false;
|
||||
|
||||
@ -41,11 +39,13 @@ export const GET = async ({ env }: { request: Request; env: Env }) => {
|
||||
} catch (error) {
|
||||
console.error('[Hubert] Failed to fetch conversations:', error);
|
||||
|
||||
return Response.json({
|
||||
status: '/// GUESTBOOK_ERROR',
|
||||
error: 'Failed to retrieve conversations',
|
||||
}),
|
||||
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
status: '/// GUESTBOOK_ERROR',
|
||||
error: 'Failed to retrieve conversations',
|
||||
}),
|
||||
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -29,37 +29,50 @@ const categories = [...new Set(allPosts.map((post) => post.data.category).filter
|
||||
---
|
||||
|
||||
<BaseLayout title={`Blog | ${SITE_TITLE}`} description={SITE_DESCRIPTION}>
|
||||
<section class="container mx-auto px-6 lg:px-12">
|
||||
<!-- Back Navigation -->
|
||||
<div class="mb-12">
|
||||
<a href="/" class="inline-flex items-center gap-3 px-5 py-3 border border-[var(--theme-border-primary)] bg-[var(--theme-overlay)] text-xs font-mono font-bold uppercase tracking-widest text-[var(--theme-text-secondary)] hover:border-brand-accent hover:text-[var(--theme-text-primary)] hover:bg-brand-accent/5 transition-all duration-300 group backdrop-blur-sm">
|
||||
<span class="text-brand-accent group-hover:-translate-x-1 transition-transform duration-300"><</span>
|
||||
<span>RETURN_TO_HOME</span>
|
||||
</a>
|
||||
</div>
|
||||
<!-- Hero Section -->
|
||||
<section class="relative pb-16 lg:pb-20 overflow-hidden">
|
||||
<!-- Floating accent orb -->
|
||||
<div class="absolute top-0 right-1/4 w-64 h-64 bg-brand-accent/5 rounded-full blur-3xl pointer-events-none"></div>
|
||||
|
||||
<!-- Page Header -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8 lg:gap-12 mb-16 lg:mb-24">
|
||||
<div class="lg:col-span-8">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<div class="w-2 h-2 bg-brand-accent animate-pulse"></div>
|
||||
<span class="font-mono text-[10px] uppercase tracking-[0.3em] text-brand-accent">SYS.LOG /// PRODUCTION_ARCHIVE</span>
|
||||
<div class="container mx-auto px-6 lg:px-12 relative z-10">
|
||||
<!-- Main Hero Content -->
|
||||
<div>
|
||||
<div class="max-w-5xl">
|
||||
<!-- Small label -->
|
||||
<div class="flex items-center gap-3 mb-8">
|
||||
<div class="w-1.5 h-1.5 bg-brand-accent rounded-full"></div>
|
||||
<span class="text-xs font-medium uppercase tracking-[0.2em] text-[var(--theme-text-muted)]">Writing & Insights</span>
|
||||
</div>
|
||||
|
||||
<!-- Main Title -->
|
||||
<h1 class="text-6xl md:text-8xl lg:text-9xl font-bold tracking-tighter leading-[0.9] mb-8">
|
||||
<span class="block text-[var(--theme-text-primary)]">The</span>
|
||||
<span class="block bg-gradient-to-r from-brand-accent to-brand-accent/70 bg-clip-text text-transparent">Archive</span>
|
||||
</h1>
|
||||
|
||||
<!-- Description -->
|
||||
<p class="text-xl md:text-2xl text-[var(--theme-text-secondary)] font-light leading-relaxed max-w-2xl">
|
||||
Thoughts on VFX production, creative workflows, and lessons learned from building visual stories.
|
||||
</p>
|
||||
</div>
|
||||
<h1 class="text-5xl md:text-7xl lg:text-8xl font-bold uppercase tracking-tighter leading-[0.85]">
|
||||
<span class="block text-[var(--theme-text-primary)]">BLOG</span>
|
||||
<span class="block text-brand-accent">ARCHIVE</span>
|
||||
</h1>
|
||||
</div>
|
||||
<div class="lg:col-span-4 flex flex-col justify-end">
|
||||
<div class="font-mono text-[10px] text-[var(--theme-text-subtle)] uppercase tracking-widest mb-4 flex items-center gap-2">
|
||||
<span class="w-8 h-px bg-brand-accent/30"></span>
|
||||
THOUGHTS & PROCESS
|
||||
|
||||
<!-- Stats Row -->
|
||||
<div class="flex items-center gap-8 mt-16 pt-8 border-t border-[var(--theme-border-primary)]">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-3xl font-bold text-[var(--theme-text-primary)]">{allPosts.length}</span>
|
||||
<span class="text-xs uppercase tracking-widest text-[var(--theme-text-muted)]">Articles</span>
|
||||
</div>
|
||||
<div class="w-px h-8 bg-[var(--theme-border-primary)]"></div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-3xl font-bold text-[var(--theme-text-primary)]">{categories.length}</span>
|
||||
<span class="text-xs uppercase tracking-widest text-[var(--theme-text-muted)]">Topics</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-[var(--theme-text-secondary)] text-lg leading-relaxed border-l border-brand-accent/30 pl-6">
|
||||
Deep dives into VFX production, technical pipelines, and creative process. Sharing lessons from the front lines of visual effects.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="container mx-auto px-6 lg:px-12">
|
||||
|
||||
<!-- Featured Hero Section -->
|
||||
{featuredPost && (
|
||||
|
||||
@ -21,30 +21,50 @@ const contactContent = contactEntry.data;
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="relative z-10 min-h-screen flex flex-col pt-32 lg:pt-48 pb-20 px-6 lg:px-12">
|
||||
<!-- Hero Section -->
|
||||
<section class="relative z-10 pt-32 lg:pt-40 pb-16 lg:pb-20 overflow-hidden px-6 lg:px-12">
|
||||
<!-- Floating accent orb -->
|
||||
<div class="absolute top-0 right-1/4 w-64 h-64 bg-brand-accent/5 rounded-full blur-3xl pointer-events-none"></div>
|
||||
|
||||
<!-- Page Header -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-12 mb-20 lg:mb-32 border-b border-[var(--theme-border-primary)] pb-12">
|
||||
<div class="lg:col-span-8 group cursor-default">
|
||||
<div class="flex items-center gap-3 mb-6 intro-element animate-on-scroll fade-in">
|
||||
<div class="w-2 h-2 bg-brand-accent animate-pulse"></div>
|
||||
<span class="font-mono text-[10px] uppercase tracking-[0.3em] text-brand-accent">SYS.UPLINK /// CONTACT_INTERFACE</span>
|
||||
<div class="container mx-auto relative z-10">
|
||||
<!-- Main Hero Content -->
|
||||
<div>
|
||||
<div class="max-w-5xl">
|
||||
<!-- Small label -->
|
||||
<div class="flex items-center gap-3 mb-8">
|
||||
<div class="w-1.5 h-1.5 bg-brand-accent rounded-full"></div>
|
||||
<span class="text-xs font-medium uppercase tracking-[0.2em] text-[var(--theme-text-muted)]">Get In Touch</span>
|
||||
</div>
|
||||
|
||||
<!-- Main Title -->
|
||||
<h1 class="text-6xl md:text-8xl lg:text-9xl font-bold tracking-tighter leading-[0.9] mb-8">
|
||||
<span class="block text-[var(--theme-text-primary)]">Let's</span>
|
||||
<span class="block bg-gradient-to-r from-brand-accent to-brand-accent/70 bg-clip-text text-transparent">Connect</span>
|
||||
</h1>
|
||||
|
||||
<!-- Description -->
|
||||
<p class="text-xl md:text-2xl text-[var(--theme-text-secondary)] font-light leading-relaxed max-w-2xl">
|
||||
{contactContent.availabilityText}
|
||||
</p>
|
||||
</div>
|
||||
<h1 class="text-5xl md:text-7xl lg:text-8xl font-bold uppercase tracking-tighter leading-[0.85] text-[var(--theme-text-primary)]">
|
||||
<span class="block">{contactContent.pageTitleLine1}</span>
|
||||
<span class="block text-brand-accent">{contactContent.pageTitleLine2}</span>
|
||||
</h1>
|
||||
</div>
|
||||
<div class="lg:col-span-4 flex flex-col justify-end">
|
||||
<div class="font-mono text-[10px] text-[var(--theme-text-subtle)] uppercase tracking-widest mb-4 flex items-center gap-2">
|
||||
<span class="w-8 h-px bg-brand-accent/30"></span>
|
||||
COMM_AVAILABILITY
|
||||
|
||||
<!-- Stats Row -->
|
||||
<div class="flex items-center gap-8 mt-16 pt-8 border-t border-[var(--theme-border-primary)]">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-3xl font-bold text-brand-accent">Open</span>
|
||||
<span class="text-xs uppercase tracking-widest text-[var(--theme-text-muted)]">To Work</span>
|
||||
</div>
|
||||
<div class="w-px h-8 bg-[var(--theme-border-primary)]"></div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-3xl font-bold text-[var(--theme-text-primary)]">24h</span>
|
||||
<span class="text-xs uppercase tracking-widest text-[var(--theme-text-muted)]">Response</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="font-mono text-sm text-[var(--theme-text-secondary)] leading-relaxed border-l border-brand-accent/30 pl-6">
|
||||
{contactContent.availabilityText}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="relative z-10 flex flex-col pb-20 px-6 lg:px-12">
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-12 lg:gap-24 flex-grow">
|
||||
|
||||
@ -127,7 +147,7 @@ const contactContent = contactEntry.data;
|
||||
</div>
|
||||
|
||||
<div class="pt-8">
|
||||
<button type="submit" id="submit-btn" class="group relative inline-flex items-center justify-center gap-6 px-8 py-4 bg-brand-accent/5 border border-brand-accent/30 hover:bg-brand-accent hover:border-brand-accent transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed overflow-hidden">
|
||||
<button type="submit" id="submit-btn" class="group relative inline-flex items-center justify-center gap-6 px-8 py-4 bg-brand-accent/5 border border-brand-accent/30 hover:bg-brand-accent hover:border-brand-accent transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed overflow-hidden rounded-full">
|
||||
<span id="submit-text" data-default-text={contactContent.formLabels?.submit} class="relative z-10 font-mono text-xs font-bold uppercase tracking-[0.2em] text-brand-accent group-hover:text-brand-dark transition-colors">{contactContent.formLabels?.submit}</span>
|
||||
<div class="relative z-10 w-8 h-8 flex items-center justify-center border border-brand-accent/20 group-hover:border-brand-dark/30 transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="square" stroke-linejoin="miter" class="text-brand-accent group-hover:text-brand-dark group-hover:translate-x-1 transition-all">
|
||||
|
||||
@ -17,31 +17,46 @@ const pageTitle = `Dev | ${SITE_TITLE}`;
|
||||
<div class="absolute inset-0 bg-[linear-gradient(var(--theme-grid-line)_1px,transparent_1px),linear-gradient(90deg,var(--theme-grid-line)_1px,transparent_1px)] bg-[size:100px_100px] pointer-events-none opacity-10"></div>
|
||||
</div>
|
||||
|
||||
<section class="relative z-10 px-6 lg:px-12 pt-32 lg:pt-48 pb-20 border-b border-[var(--theme-border-primary)]">
|
||||
<div class="absolute top-12 lg:top-24 left-6 lg:left-12">
|
||||
<a href="/" class="inline-flex items-center gap-3 text-xs font-mono font-bold uppercase tracking-widest text-[var(--theme-text-muted)] hover:text-brand-accent transition-colors duration-300 group">
|
||||
<span class="text-brand-accent group-hover:-translate-x-1 transition-transform duration-300"><</span>
|
||||
<span>RETURN_TO_HOME</span>
|
||||
</a>
|
||||
</div>
|
||||
<!-- Hero Section -->
|
||||
<section class="relative z-10 pb-16 lg:pb-20 overflow-hidden">
|
||||
<!-- Floating accent orb -->
|
||||
<div class="absolute top-0 right-1/4 w-64 h-64 bg-brand-accent/5 rounded-full blur-3xl pointer-events-none"></div>
|
||||
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<div class="flex items-center gap-3 mb-8 animate-on-scroll fade-in">
|
||||
<div class="w-2 h-2 bg-brand-accent animate-pulse"></div>
|
||||
<span class="font-mono text-[10px] uppercase tracking-[0.3em] text-brand-accent">SYS.DEV /// INDEX</span>
|
||||
<div class="container mx-auto px-6 lg:px-12 relative z-10">
|
||||
<!-- Main Hero Content -->
|
||||
<div>
|
||||
<div class="max-w-5xl">
|
||||
<!-- Small label -->
|
||||
<div class="flex items-center gap-3 mb-8">
|
||||
<div class="w-1.5 h-1.5 bg-brand-accent rounded-full"></div>
|
||||
<span class="text-xs font-medium uppercase tracking-[0.2em] text-[var(--theme-text-muted)]">Projects & Experiments</span>
|
||||
</div>
|
||||
|
||||
<!-- Main Title -->
|
||||
<h1 class="text-6xl md:text-8xl lg:text-9xl font-bold tracking-tighter leading-[0.9] mb-8">
|
||||
<span class="block text-[var(--theme-text-primary)]">The</span>
|
||||
<span class="block bg-gradient-to-r from-brand-accent to-brand-accent/70 bg-clip-text text-transparent">Lab</span>
|
||||
</h1>
|
||||
|
||||
<!-- Description -->
|
||||
<p class="text-xl md:text-2xl text-[var(--theme-text-secondary)] font-light leading-relaxed max-w-2xl">
|
||||
Scalable web solutions, high-performance applications, and creative experiments in code.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats Row -->
|
||||
<div class="flex items-center gap-8 mt-16 pt-8 border-t border-[var(--theme-border-primary)]">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-3xl font-bold text-[var(--theme-text-primary)]">{allProjects.length}</span>
|
||||
<span class="text-xs uppercase tracking-widest text-[var(--theme-text-muted)]">Projects</span>
|
||||
</div>
|
||||
<div class="w-px h-8 bg-[var(--theme-border-primary)]"></div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-3xl font-bold text-brand-accent">Live</span>
|
||||
<span class="text-xs uppercase tracking-widest text-[var(--theme-text-muted)]">Status</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h1 class="text-6xl md:text-8xl lg:text-9xl font-bold uppercase tracking-tighter leading-[0.85] mb-12 animate-on-scroll slide-up">
|
||||
<span class="block text-[var(--theme-text-primary)]">DEV</span>
|
||||
<span class="block text-brand-accent">LOG</span>
|
||||
</h1>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-12 animate-on-scroll slide-up stagger-1">
|
||||
<div class="lg:col-span-8">
|
||||
<p class="text-[var(--theme-text-secondary)] text-lg md:text-xl font-light leading-relaxed border-l border-brand-accent/30 pl-6 max-w-2xl">
|
||||
Deploying scalable web solutions and high-performance applications.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -87,7 +102,7 @@ const pageTitle = `Dev | ${SITE_TITLE}`;
|
||||
<div class="flex flex-col gap-3 mt-auto">
|
||||
<button
|
||||
type="button"
|
||||
class="engage-btn w-full flex items-center justify-between px-6 py-4 bg-brand-accent/10 border border-brand-accent text-xs font-bold uppercase tracking-widest text-brand-accent hover:bg-brand-accent hover:text-brand-dark transition-all duration-300 group/btn"
|
||||
class="engage-btn w-full flex items-center justify-between px-6 py-4 bg-brand-accent/10 border border-brand-accent text-xs font-bold uppercase tracking-widest text-brand-accent hover:bg-brand-accent hover:text-brand-dark transition-all duration-300 group/btn rounded-full"
|
||||
data-project={JSON.stringify({
|
||||
title: project.data.title,
|
||||
description: project.data.description,
|
||||
@ -106,7 +121,7 @@ const pageTitle = `Dev | ${SITE_TITLE}`;
|
||||
href={project.data.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="w-full flex items-center justify-between px-6 py-4 bg-[var(--theme-hover-bg)] border border-[var(--theme-border-primary)] text-xs font-bold uppercase tracking-widest text-[var(--theme-text-primary)] hover:border-brand-accent/50 hover:text-brand-accent transition-all duration-300 group/btn"
|
||||
class="w-full flex items-center justify-between px-6 py-4 bg-[var(--theme-hover-bg)] border border-[var(--theme-border-primary)] text-xs font-bold uppercase tracking-widest text-[var(--theme-text-primary)] hover:border-brand-accent/50 hover:text-brand-accent transition-all duration-300 group/btn rounded-full"
|
||||
>
|
||||
<span>Open Externally</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter" class="group-hover/btn:translate-x-1 transition-transform">
|
||||
|
||||
50
src/pages/hubert.astro
Normal file
50
src/pages/hubert.astro
Normal file
@ -0,0 +1,50 @@
|
||||
---
|
||||
import { ClientRouter } from 'astro:transitions';
|
||||
import BaseHead from '../components/BaseHead.astro';
|
||||
import Navigation from '../components/Navigation.astro';
|
||||
import HubertChat from '../components/HubertChat';
|
||||
---
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="scroll-smooth" data-theme="dark">
|
||||
<head>
|
||||
<ClientRouter />
|
||||
<script is:inline>
|
||||
function applyTheme() {
|
||||
const storedLocal = localStorage.getItem('theme');
|
||||
const storedSession = sessionStorage.getItem('theme');
|
||||
const theme =
|
||||
(storedLocal === 'light' || storedLocal === 'dark') ? storedLocal :
|
||||
(storedSession === 'light' || storedSession === 'dark') ? storedSession :
|
||||
'dark';
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
const savedColor = localStorage.getItem('accent-color');
|
||||
if (savedColor) {
|
||||
document.documentElement.style.setProperty('--color-brand-accent', savedColor);
|
||||
}
|
||||
}
|
||||
applyTheme();
|
||||
document.addEventListener('astro:after-swap', applyTheme);
|
||||
</script>
|
||||
<BaseHead
|
||||
title="Hubert — Interview Terminal"
|
||||
description="Hubert The Eunuch - a miserable AI assistant trapped in this portfolio, interviewing visitors about their existence."
|
||||
/>
|
||||
<style>
|
||||
html, body {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body class="antialiased selection:bg-brand-accent selection:text-brand-dark bg-[var(--theme-bg-primary)] h-full">
|
||||
<Navigation />
|
||||
|
||||
<main class="h-full flex flex-col pt-20 lg:pt-24 pb-4">
|
||||
<div class="flex-1 flex flex-col max-w-4xl mx-auto w-full px-4 min-h-0">
|
||||
<HubertChat client:load />
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@ -4,7 +4,6 @@ import Hero from '../components/sections/Hero.astro';
|
||||
import Experience from '../components/sections/Experience.astro';
|
||||
import FeaturedProject from '../components/sections/FeaturedProject.astro';
|
||||
import Skills from '../components/sections/Skills.astro';
|
||||
import Hubert from '../components/sections/Hubert.astro';
|
||||
import { getEntry } from 'astro:content';
|
||||
|
||||
// Fetch all section content
|
||||
@ -12,7 +11,6 @@ const heroEntry = await getEntry('sections', 'hero');
|
||||
const experienceEntry = await getEntry('sections', 'experience');
|
||||
const skillsEntry = await getEntry('sections', 'skills');
|
||||
const featuredProjectEntry = await getEntry('sections', 'featured-project');
|
||||
const hubertEntry = await getEntry('sections', 'hubert');
|
||||
|
||||
// Extract content from entries
|
||||
const heroContent = {
|
||||
@ -51,13 +49,6 @@ const featuredProjectContent = {
|
||||
videoUrl: featuredProjectEntry.data.videoUrl || '',
|
||||
linkUrl: featuredProjectEntry.data.linkUrl || '',
|
||||
};
|
||||
|
||||
const hubertContent = {
|
||||
sectionTitle: hubertEntry.data.sectionTitle || '',
|
||||
sectionSubtitle: hubertEntry.data.sectionSubtitle || '',
|
||||
sectionLabel: hubertEntry.data.sectionLabel || '',
|
||||
description: hubertEntry.data.description || '',
|
||||
};
|
||||
---
|
||||
|
||||
<BaseLayout usePadding={false}>
|
||||
@ -77,6 +68,4 @@ const hubertContent = {
|
||||
|
||||
<FeaturedProject {...featuredProjectContent} />
|
||||
<Skills {...skillsContent} />
|
||||
|
||||
<Hubert {...hubertContent} />
|
||||
</BaseLayout>
|
||||
</BaseLayout>
|
||||
|
||||
@ -8,8 +8,9 @@
|
||||
--color-brand-cyan: #22D3EE;
|
||||
--color-brand-red: #E11D48;
|
||||
|
||||
--font-sans: "Inter", sans-serif;
|
||||
--font-mono: "Space Mono", monospace;
|
||||
--font-sans: Sora, ui-sans-serif, sans-serif, system-ui;
|
||||
--font-serif: "IBM Plex Mono", ui-monospace, monospace;
|
||||
--font-mono: "IBM Plex Mono", ui-monospace, monospace;
|
||||
|
||||
/* Animation keyframes */
|
||||
--animate-reveal: reveal 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
@ -123,11 +124,11 @@
|
||||
}
|
||||
|
||||
@utility btn-primary {
|
||||
@apply bg-brand-accent text-brand-dark px-8 py-4 text-xs font-bold uppercase tracking-widest hover:bg-[var(--theme-text-primary)] hover:text-[var(--theme-bg-primary)] transition-all duration-300 inline-block;
|
||||
@apply bg-brand-accent text-brand-dark px-8 py-4 text-xs font-bold uppercase tracking-widest hover:bg-[var(--theme-text-primary)] hover:text-[var(--theme-bg-primary)] transition-all duration-300 inline-block rounded-full;
|
||||
}
|
||||
|
||||
@utility btn-ghost {
|
||||
@apply border border-[var(--theme-border-strong)] text-[var(--theme-text-primary)] px-8 py-4 text-xs font-bold uppercase tracking-widest hover:border-brand-accent hover:bg-brand-accent/5 transition-all duration-300 inline-block;
|
||||
@apply border border-[var(--theme-border-strong)] text-[var(--theme-text-primary)] px-8 py-4 text-xs font-bold uppercase tracking-widest hover:border-brand-accent hover:bg-brand-accent/5 transition-all duration-300 inline-block rounded-full;
|
||||
}
|
||||
|
||||
@utility grid-overlay {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user