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": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"astro": "^5.16.4",
|
"astro": "^5.16.4",
|
||||||
|
"dompurify": "^3.3.1",
|
||||||
|
"framer-motion": "^12.26.2",
|
||||||
"lunr": "^2.3.9",
|
"lunr": "^2.3.9",
|
||||||
"marked": "^17.0.1",
|
"marked": "^17.0.1",
|
||||||
"react": "^19.2.1",
|
"react": "^19.2.1",
|
||||||
@ -40,6 +42,7 @@
|
|||||||
"zod": "^4.3.4"
|
"zod": "^4.3.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/dompurify": "^3.2.0",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
"wrangler": "^4.53.0"
|
"wrangler": "^4.53.0"
|
||||||
}
|
}
|
||||||
|
|||||||
65
pnpm-lock.yaml
generated
65
pnpm-lock.yaml
generated
@ -50,6 +50,12 @@ importers:
|
|||||||
astro:
|
astro:
|
||||||
specifier: ^5.16.4
|
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)
|
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:
|
lunr:
|
||||||
specifier: ^2.3.9
|
specifier: ^2.3.9
|
||||||
version: 2.3.9
|
version: 2.3.9
|
||||||
@ -72,6 +78,9 @@ importers:
|
|||||||
specifier: ^4.3.4
|
specifier: ^4.3.4
|
||||||
version: 4.3.4
|
version: 4.3.4
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@types/dompurify':
|
||||||
|
specifier: ^3.2.0
|
||||||
|
version: 3.2.0
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^24.10.1
|
specifier: ^24.10.1
|
||||||
version: 24.10.1
|
version: 24.10.1
|
||||||
@ -1361,6 +1370,10 @@ packages:
|
|||||||
'@types/debug@4.1.12':
|
'@types/debug@4.1.12':
|
||||||
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
|
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':
|
'@types/estree-jsx@1.0.5':
|
||||||
resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==}
|
resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==}
|
||||||
|
|
||||||
@ -1405,6 +1418,9 @@ packages:
|
|||||||
'@types/sax@1.2.7':
|
'@types/sax@1.2.7':
|
||||||
resolution: {integrity: sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==}
|
resolution: {integrity: sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==}
|
||||||
|
|
||||||
|
'@types/trusted-types@2.0.7':
|
||||||
|
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||||
|
|
||||||
'@types/unist@2.0.11':
|
'@types/unist@2.0.11':
|
||||||
resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
|
resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
|
||||||
|
|
||||||
@ -1710,6 +1726,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
|
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
|
||||||
engines: {node: '>= 4'}
|
engines: {node: '>= 4'}
|
||||||
|
|
||||||
|
dompurify@3.3.1:
|
||||||
|
resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==}
|
||||||
|
|
||||||
domutils@3.2.2:
|
domutils@3.2.2:
|
||||||
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
|
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
|
||||||
|
|
||||||
@ -1836,6 +1855,20 @@ packages:
|
|||||||
fontkit@2.0.4:
|
fontkit@2.0.4:
|
||||||
resolution: {integrity: sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==}
|
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:
|
fsevents@2.3.3:
|
||||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
@ -2282,6 +2315,12 @@ packages:
|
|||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
hasBin: true
|
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:
|
mrmime@2.0.1:
|
||||||
resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
|
resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@ -4033,6 +4072,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/ms': 2.1.0
|
'@types/ms': 2.1.0
|
||||||
|
|
||||||
|
'@types/dompurify@3.2.0':
|
||||||
|
dependencies:
|
||||||
|
dompurify: 3.3.1
|
||||||
|
|
||||||
'@types/estree-jsx@1.0.5':
|
'@types/estree-jsx@1.0.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/estree': 1.0.8
|
'@types/estree': 1.0.8
|
||||||
@ -4079,6 +4122,9 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 24.10.1
|
'@types/node': 24.10.1
|
||||||
|
|
||||||
|
'@types/trusted-types@2.0.7':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@types/unist@2.0.11': {}
|
'@types/unist@2.0.11': {}
|
||||||
|
|
||||||
'@types/unist@3.0.3': {}
|
'@types/unist@3.0.3': {}
|
||||||
@ -4426,6 +4472,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
domelementtype: 2.3.0
|
domelementtype: 2.3.0
|
||||||
|
|
||||||
|
dompurify@3.3.1:
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/trusted-types': 2.0.7
|
||||||
|
|
||||||
domutils@3.2.2:
|
domutils@3.2.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
dom-serializer: 2.0.0
|
dom-serializer: 2.0.0
|
||||||
@ -4629,6 +4679,15 @@ snapshots:
|
|||||||
unicode-properties: 1.4.1
|
unicode-properties: 1.4.1
|
||||||
unicode-trie: 2.0.0
|
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:
|
fsevents@2.3.3:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@ -5404,6 +5463,12 @@ snapshots:
|
|||||||
- bufferutil
|
- bufferutil
|
||||||
- utf-8-validate
|
- utf-8-validate
|
||||||
|
|
||||||
|
motion-dom@12.26.2:
|
||||||
|
dependencies:
|
||||||
|
motion-utils: 12.24.10
|
||||||
|
|
||||||
|
motion-utils@12.24.10: {}
|
||||||
|
|
||||||
mrmime@2.0.1: {}
|
mrmime@2.0.1: {}
|
||||||
|
|
||||||
ms@2.1.3: {}
|
ms@2.1.3: {}
|
||||||
|
|||||||
@ -124,14 +124,14 @@ const professionalServiceSchema = {
|
|||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link
|
<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"
|
rel="stylesheet"
|
||||||
media="print"
|
media="print"
|
||||||
onload="this.media='all'"
|
onload="this.media='all'"
|
||||||
/>
|
/>
|
||||||
<noscript>
|
<noscript>
|
||||||
<link
|
<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"
|
rel="stylesheet"
|
||||||
/>
|
/>
|
||||||
</noscript>
|
</noscript>
|
||||||
|
|||||||
@ -19,13 +19,13 @@ const today = new Date();
|
|||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-4">
|
<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>
|
<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">
|
<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"/>
|
<path d="M5 12h14M12 5l7 7-7 7"/>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</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>
|
<span class="font-mono text-xs font-bold uppercase tracking-widest text-[var(--theme-text-primary)]">Manual_Input</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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 {
|
// Configure marked for safe rendering
|
||||||
role: 'user' | 'assistant' | 'system';
|
marked.setOptions({
|
||||||
content: string;
|
breaks: true,
|
||||||
timestamp: string;
|
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() {
|
export default function HubertChat() {
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const {
|
||||||
const [input, setInput] = useState('');
|
messages,
|
||||||
const [visitorId, setVisitorId] = useState<string | null>(null);
|
input,
|
||||||
const [conversationId, setConversationId] = useState<string | null>(null);
|
isTyping,
|
||||||
const [isTyping, setIsTyping] = useState(false);
|
isInitializing,
|
||||||
const [isInitializing, setIsInitializing] = useState(true);
|
initError,
|
||||||
const [initError, setInitError] = useState<string | null>(null);
|
setInput,
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
sendMessage,
|
||||||
|
retryInit,
|
||||||
|
messagesEndRef,
|
||||||
|
} = useHubertChat({ initTimeout: 8000, chatTimeout: 30000 });
|
||||||
|
|
||||||
// Initialize visitor on mount
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
useEffect(() => {
|
const [inputHeight, setInputHeight] = useState(40);
|
||||||
const initVisitor = async () => {
|
|
||||||
try {
|
|
||||||
setIsInitializing(true);
|
|
||||||
setInitError(null);
|
|
||||||
|
|
||||||
const response = await fetchWithTimeout('/api/hubert/new-visitor', {
|
// Auto-resize textarea and track height for dynamic border radius
|
||||||
method: 'POST',
|
const adjustTextareaHeight = () => {
|
||||||
headers: { 'Content-Type': 'application/json' }
|
const textarea = textareaRef.current;
|
||||||
}, 8000); // 8 second timeout
|
if (textarea) {
|
||||||
|
textarea.style.height = 'auto';
|
||||||
if (!response.ok) {
|
const newHeight = Math.min(textarea.scrollHeight, 200);
|
||||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
textarea.style.height = `${newHeight}px`;
|
||||||
}
|
setInputHeight(newHeight);
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Show loading or error state
|
useEffect(() => {
|
||||||
if (isInitializing || initError) {
|
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 (
|
return (
|
||||||
<div className="bg-[var(--theme-bg-secondary)] border-2 border-[var(--theme-border-primary)] shadow-2xl">
|
<div className="flex-1 flex flex-col items-center justify-center px-4">
|
||||||
<div className="flex flex-col items-center justify-center py-12 px-6 gap-6">
|
{/* Branding */}
|
||||||
{isInitializing && !initError ? (
|
<div className="flex items-center gap-3 mb-8">
|
||||||
<>
|
<span className="text-2xl font-semibold text-[var(--theme-text-primary)]">Hubert</span>
|
||||||
<div className="flex items-center gap-3">
|
<div className="w-4 h-4 border-2 border-white/60 border-t-transparent rounded-full animate-spin" />
|
||||||
<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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
// Error state
|
||||||
<div className="bg-[var(--theme-bg-secondary)] border-2 border-[var(--theme-border-primary)] shadow-2xl">
|
if (initError) {
|
||||||
{/* Header */}
|
return (
|
||||||
<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-1 flex flex-col items-center justify-center px-4">
|
||||||
<div className="flex items-center gap-3">
|
<span className="text-2xl font-semibold text-[var(--theme-text-primary)] mb-6">Hubert</span>
|
||||||
<div className="flex gap-1.5">
|
<p className="text-sm text-[var(--theme-text-muted)] mb-4 text-center max-w-sm" role="alert">
|
||||||
<div className="w-2 h-2 bg-brand-accent animate-pulse" />
|
{initError}
|
||||||
<div className="w-2 h-2 bg-[var(--theme-border-strong)]" />
|
</p>
|
||||||
<div className="w-2 h-2 bg-[var(--theme-border-strong)]" />
|
<button
|
||||||
</div>
|
onClick={retryInit}
|
||||||
<span className="text-[10px] font-mono font-bold uppercase tracking-[0.3em] text-brand-accent">
|
className="px-5 py-2.5 rounded-full bg-white/10 hover:bg-white/15 text-sm text-[var(--theme-text-primary)] transition-colors"
|
||||||
/// HUBERT_EUNUCH /// ONLINE
|
aria-label="Retry connecting to Hubert"
|
||||||
</span>
|
>
|
||||||
</div>
|
Try again
|
||||||
<div className="font-mono text-[9px] uppercase tracking-[0.2em] text-[var(--theme-text-muted)]">
|
</button>
|
||||||
{visitorId ? `VISITOR: ${visitorId.slice(0, 8)}` : 'UNKNOWN'}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
{/* Messages */}
|
// No messages yet - show centered input
|
||||||
<div className="h-[500px] overflow-y-auto p-6 space-y-4">
|
if (messages.length === 0) {
|
||||||
{messages.map((msg, idx) => (
|
return (
|
||||||
<div
|
<div className="flex-1 flex flex-col items-center justify-center px-4">
|
||||||
key={idx}
|
{/* Branding */}
|
||||||
className={`flex gap-4 ${msg.role === 'user' ? 'flex-row-reverse' : ''}`}
|
<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%]">
|
<textarea
|
||||||
<div className={`font-mono text-xs uppercase tracking-widest mb-2 px-2 py-1 ${
|
ref={textareaRef}
|
||||||
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"
|
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && sendMessage()}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder="/// HUBERT_AWAITS_INPUT..."
|
placeholder="What do you want to know?"
|
||||||
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"
|
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>
|
<motion.div layout={false} className={isMultiline ? 'flex justify-end mt-3' : 'ml-3 flex-shrink-0'}>
|
||||||
<button
|
<button
|
||||||
onClick={sendMessage}
|
onClick={sendMessage}
|
||||||
disabled={isTyping || !input.trim()}
|
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"
|
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"
|
||||||
[TRANSMIT]
|
>
|
||||||
</button>
|
<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>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -47,13 +47,24 @@ import ThemeToggle from './ThemeToggle.astro';
|
|||||||
Astro.url.pathname.startsWith('/blog') ? "w-full" : "w-0 group-hover:w-full"
|
Astro.url.pathname.startsWith('/blog') ? "w-full" : "w-0 group-hover:w-full"
|
||||||
]}></span>
|
]}></span>
|
||||||
</a>
|
</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>
|
</div>
|
||||||
|
|
||||||
<a href="/contact"
|
<a href="/contact"
|
||||||
class:list={[
|
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",
|
"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')
|
Astro.url.pathname.startsWith('/contact')
|
||||||
? "border-brand-accent bg-brand-accent text-brand-dark"
|
? "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"
|
: "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
|
Let's Talk
|
||||||
@ -107,6 +118,12 @@ import ThemeToggle from './ThemeToggle.astro';
|
|||||||
>
|
>
|
||||||
Blog
|
Blog
|
||||||
</a>
|
</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
|
<a
|
||||||
href="/contact"
|
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"
|
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 -->
|
<!-- CTA Button -->
|
||||||
<a
|
<a
|
||||||
href="/contact"
|
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
|
Let's Talk
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@ -162,7 +162,7 @@ export default function SearchDialog() {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsOpen(true)}
|
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"
|
aria-label="Open search"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@ -216,7 +216,7 @@ export default function SearchDialog() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={closeSearch}
|
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]
|
[ESC]
|
||||||
</button>
|
</button>
|
||||||
@ -255,7 +255,7 @@ export default function SearchDialog() {
|
|||||||
setQuery('');
|
setQuery('');
|
||||||
inputRef.current?.focus();
|
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]
|
[CLR]
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -104,7 +104,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
id="remember-yes"
|
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">
|
<span class="text-sm font-bold uppercase tracking-tight text-[var(--theme-text-primary)] group-hover:text-brand-dark">
|
||||||
Save
|
Save
|
||||||
@ -114,7 +114,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
id="remember-no"
|
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)]">
|
<span class="text-sm font-bold uppercase tracking-tight text-[var(--theme-text-primary)]">
|
||||||
Session
|
Session
|
||||||
|
|||||||
@ -284,7 +284,7 @@ const DevEngageModal: React.FC = () => {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={closeModal}
|
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>
|
<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">
|
<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}
|
href={activeProject.link}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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
|
OPEN_EXTERNALLY
|
||||||
</a>
|
</a>
|
||||||
<button
|
<button
|
||||||
onClick={handleCopyLink}
|
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
|
COPY_LINK
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleRetry}
|
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
|
RETRY
|
||||||
</button>
|
</button>
|
||||||
@ -421,7 +421,7 @@ const DevEngageModal: React.FC = () => {
|
|||||||
<button
|
<button
|
||||||
onClick={toggleArm}
|
onClick={toggleArm}
|
||||||
disabled={modalState === 'booting' || modalState === 'blocked'}
|
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
|
isInteractive
|
||||||
? 'bg-green-500/20 border-green-500 text-green-500 hover:bg-green-500/30'
|
? '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'
|
: '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}
|
href={activeProject.link}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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
|
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">
|
<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;
|
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
|
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">
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Hubert Chat Interface -->
|
<!-- Hubert Chat Interface -->
|
||||||
<HubertChat client:load />
|
<HubertChat client:visible />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@ -73,6 +73,7 @@ const sections = defineCollection({
|
|||||||
value: z.string(),
|
value: z.string(),
|
||||||
})).optional(),
|
})).optional(),
|
||||||
linkUrl: 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)}`}
|
href={`https://twitter.com/intent/tweet?text=${encodeURIComponent(title)}&url=${encodeURIComponent(Astro.url.href)}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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"
|
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">
|
<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)}`}
|
href={`https://www.linkedin.com/shareArticle?mini=true&url=${encodeURIComponent(Astro.url.href)}&title=${encodeURIComponent(title)}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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"
|
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">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick="navigator.clipboard.writeText(window.location.href)"
|
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"
|
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">
|
<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
|
// Prevent prerendering - this endpoint requires runtime Cloudflare bindings
|
||||||
export const prerender = false;
|
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
|
* Hubert The Eunuch Chatbot
|
||||||
*
|
*
|
||||||
@ -22,50 +16,6 @@ export interface Env {
|
|||||||
OPENROUTER_API_KEY: string;
|
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
|
* 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}`);
|
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.
|
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,
|
temperature: 0.7,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@ -153,6 +101,7 @@ When they say goodbye or conversation ends, use the save_conversation tool to ar
|
|||||||
'X-Title': 'Nicholai Portfolio',
|
'X-Title': 'Nicholai Portfolio',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(requestBody),
|
body: JSON.stringify(requestBody),
|
||||||
|
signal: AbortSignal.timeout(25000), // 25 second timeout
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
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 data = await response.json();
|
||||||
const assistantContent = data.choices[0]?.message?.content || '...';
|
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`);
|
console.log(`[Hubert] Generated response in ${responseTime}ms`);
|
||||||
|
|
||||||
return new Response(
|
return new Response(
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
import { getEntry } from 'astro:content';
|
|
||||||
|
|
||||||
// Prevent prerendering - this endpoint requires runtime Cloudflare bindings
|
// Prevent prerendering - this endpoint requires runtime Cloudflare bindings
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
|
|
||||||
@ -41,11 +39,13 @@ export const GET = async ({ env }: { request: Request; env: Env }) => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Hubert] Failed to fetch conversations:', error);
|
console.error('[Hubert] Failed to fetch conversations:', error);
|
||||||
|
|
||||||
return Response.json({
|
return new Response(
|
||||||
status: '/// GUESTBOOK_ERROR',
|
JSON.stringify({
|
||||||
error: 'Failed to retrieve conversations',
|
status: '/// GUESTBOOK_ERROR',
|
||||||
}),
|
error: 'Failed to retrieve conversations',
|
||||||
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
}),
|
||||||
|
{ 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}>
|
<BaseLayout title={`Blog | ${SITE_TITLE}`} description={SITE_DESCRIPTION}>
|
||||||
<section class="container mx-auto px-6 lg:px-12">
|
<!-- Hero Section -->
|
||||||
<!-- Back Navigation -->
|
<section class="relative pb-16 lg:pb-20 overflow-hidden">
|
||||||
<div class="mb-12">
|
<!-- Floating accent orb -->
|
||||||
<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">
|
<div class="absolute top-0 right-1/4 w-64 h-64 bg-brand-accent/5 rounded-full blur-3xl pointer-events-none"></div>
|
||||||
<span class="text-brand-accent group-hover:-translate-x-1 transition-transform duration-300"><</span>
|
|
||||||
<span>RETURN_TO_HOME</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Page Header -->
|
<div class="container mx-auto px-6 lg:px-12 relative z-10">
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8 lg:gap-12 mb-16 lg:mb-24">
|
<!-- Main Hero Content -->
|
||||||
<div class="lg:col-span-8">
|
<div>
|
||||||
<div class="flex items-center gap-3 mb-6">
|
<div class="max-w-5xl">
|
||||||
<div class="w-2 h-2 bg-brand-accent animate-pulse"></div>
|
<!-- Small label -->
|
||||||
<span class="font-mono text-[10px] uppercase tracking-[0.3em] text-brand-accent">SYS.LOG /// PRODUCTION_ARCHIVE</span>
|
<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>
|
</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>
|
<!-- Stats Row -->
|
||||||
<span class="block text-brand-accent">ARCHIVE</span>
|
<div class="flex items-center gap-8 mt-16 pt-8 border-t border-[var(--theme-border-primary)]">
|
||||||
</h1>
|
<div class="flex items-center gap-3">
|
||||||
</div>
|
<span class="text-3xl font-bold text-[var(--theme-text-primary)]">{allPosts.length}</span>
|
||||||
<div class="lg:col-span-4 flex flex-col justify-end">
|
<span class="text-xs uppercase tracking-widest text-[var(--theme-text-muted)]">Articles</span>
|
||||||
<div class="font-mono text-[10px] text-[var(--theme-text-subtle)] uppercase tracking-widest mb-4 flex items-center gap-2">
|
</div>
|
||||||
<span class="w-8 h-px bg-brand-accent/30"></span>
|
<div class="w-px h-8 bg-[var(--theme-border-primary)]"></div>
|
||||||
THOUGHTS & PROCESS
|
<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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="container mx-auto px-6 lg:px-12">
|
||||||
|
|
||||||
<!-- Featured Hero Section -->
|
<!-- Featured Hero Section -->
|
||||||
{featuredPost && (
|
{featuredPost && (
|
||||||
|
|||||||
@ -21,30 +21,50 @@ const contactContent = contactEntry.data;
|
|||||||
</div>
|
</div>
|
||||||
</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="container mx-auto relative z-10">
|
||||||
<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">
|
<!-- Main Hero Content -->
|
||||||
<div class="lg:col-span-8 group cursor-default">
|
<div>
|
||||||
<div class="flex items-center gap-3 mb-6 intro-element animate-on-scroll fade-in">
|
<div class="max-w-5xl">
|
||||||
<div class="w-2 h-2 bg-brand-accent animate-pulse"></div>
|
<!-- Small label -->
|
||||||
<span class="font-mono text-[10px] uppercase tracking-[0.3em] text-brand-accent">SYS.UPLINK /// CONTACT_INTERFACE</span>
|
<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>
|
</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>
|
<!-- Stats Row -->
|
||||||
<span class="block text-brand-accent">{contactContent.pageTitleLine2}</span>
|
<div class="flex items-center gap-8 mt-16 pt-8 border-t border-[var(--theme-border-primary)]">
|
||||||
</h1>
|
<div class="flex items-center gap-3">
|
||||||
</div>
|
<span class="text-3xl font-bold text-brand-accent">Open</span>
|
||||||
<div class="lg:col-span-4 flex flex-col justify-end">
|
<span class="text-xs uppercase tracking-widest text-[var(--theme-text-muted)]">To Work</span>
|
||||||
<div class="font-mono text-[10px] text-[var(--theme-text-subtle)] uppercase tracking-widest mb-4 flex items-center gap-2">
|
</div>
|
||||||
<span class="w-8 h-px bg-brand-accent/30"></span>
|
<div class="w-px h-8 bg-[var(--theme-border-primary)]"></div>
|
||||||
COMM_AVAILABILITY
|
<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>
|
</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>
|
||||||
</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">
|
<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>
|
||||||
|
|
||||||
<div class="pt-8">
|
<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>
|
<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">
|
<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">
|
<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 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>
|
</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)]">
|
<!-- Hero Section -->
|
||||||
<div class="absolute top-12 lg:top-24 left-6 lg:left-12">
|
<section class="relative z-10 pb-16 lg:pb-20 overflow-hidden">
|
||||||
<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">
|
<!-- Floating accent orb -->
|
||||||
<span class="text-brand-accent group-hover:-translate-x-1 transition-transform duration-300"><</span>
|
<div class="absolute top-0 right-1/4 w-64 h-64 bg-brand-accent/5 rounded-full blur-3xl pointer-events-none"></div>
|
||||||
<span>RETURN_TO_HOME</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="max-w-7xl mx-auto">
|
<div class="container mx-auto px-6 lg:px-12 relative z-10">
|
||||||
<div class="flex items-center gap-3 mb-8 animate-on-scroll fade-in">
|
<!-- Main Hero Content -->
|
||||||
<div class="w-2 h-2 bg-brand-accent animate-pulse"></div>
|
<div>
|
||||||
<span class="font-mono text-[10px] uppercase tracking-[0.3em] text-brand-accent">SYS.DEV /// INDEX</span>
|
<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>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@ -87,7 +102,7 @@ const pageTitle = `Dev | ${SITE_TITLE}`;
|
|||||||
<div class="flex flex-col gap-3 mt-auto">
|
<div class="flex flex-col gap-3 mt-auto">
|
||||||
<button
|
<button
|
||||||
type="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({
|
data-project={JSON.stringify({
|
||||||
title: project.data.title,
|
title: project.data.title,
|
||||||
description: project.data.description,
|
description: project.data.description,
|
||||||
@ -106,7 +121,7 @@ const pageTitle = `Dev | ${SITE_TITLE}`;
|
|||||||
href={project.data.link}
|
href={project.data.link}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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>
|
<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">
|
<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 Experience from '../components/sections/Experience.astro';
|
||||||
import FeaturedProject from '../components/sections/FeaturedProject.astro';
|
import FeaturedProject from '../components/sections/FeaturedProject.astro';
|
||||||
import Skills from '../components/sections/Skills.astro';
|
import Skills from '../components/sections/Skills.astro';
|
||||||
import Hubert from '../components/sections/Hubert.astro';
|
|
||||||
import { getEntry } from 'astro:content';
|
import { getEntry } from 'astro:content';
|
||||||
|
|
||||||
// Fetch all section content
|
// Fetch all section content
|
||||||
@ -12,7 +11,6 @@ const heroEntry = await getEntry('sections', 'hero');
|
|||||||
const experienceEntry = await getEntry('sections', 'experience');
|
const experienceEntry = await getEntry('sections', 'experience');
|
||||||
const skillsEntry = await getEntry('sections', 'skills');
|
const skillsEntry = await getEntry('sections', 'skills');
|
||||||
const featuredProjectEntry = await getEntry('sections', 'featured-project');
|
const featuredProjectEntry = await getEntry('sections', 'featured-project');
|
||||||
const hubertEntry = await getEntry('sections', 'hubert');
|
|
||||||
|
|
||||||
// Extract content from entries
|
// Extract content from entries
|
||||||
const heroContent = {
|
const heroContent = {
|
||||||
@ -51,13 +49,6 @@ const featuredProjectContent = {
|
|||||||
videoUrl: featuredProjectEntry.data.videoUrl || '',
|
videoUrl: featuredProjectEntry.data.videoUrl || '',
|
||||||
linkUrl: featuredProjectEntry.data.linkUrl || '',
|
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}>
|
<BaseLayout usePadding={false}>
|
||||||
@ -77,6 +68,4 @@ const hubertContent = {
|
|||||||
|
|
||||||
<FeaturedProject {...featuredProjectContent} />
|
<FeaturedProject {...featuredProjectContent} />
|
||||||
<Skills {...skillsContent} />
|
<Skills {...skillsContent} />
|
||||||
|
</BaseLayout>
|
||||||
<Hubert {...hubertContent} />
|
|
||||||
</BaseLayout>
|
|
||||||
|
|||||||
@ -8,8 +8,9 @@
|
|||||||
--color-brand-cyan: #22D3EE;
|
--color-brand-cyan: #22D3EE;
|
||||||
--color-brand-red: #E11D48;
|
--color-brand-red: #E11D48;
|
||||||
|
|
||||||
--font-sans: "Inter", sans-serif;
|
--font-sans: Sora, ui-sans-serif, sans-serif, system-ui;
|
||||||
--font-mono: "Space Mono", monospace;
|
--font-serif: "IBM Plex Mono", ui-monospace, monospace;
|
||||||
|
--font-mono: "IBM Plex Mono", ui-monospace, monospace;
|
||||||
|
|
||||||
/* Animation keyframes */
|
/* Animation keyframes */
|
||||||
--animate-reveal: reveal 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
--animate-reveal: reveal 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||||
@ -123,11 +124,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@utility btn-primary {
|
@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 {
|
@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 {
|
@utility grid-overlay {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user