Release v3.9.10
Published from npm package build Source: https://github.com/thedotmack/claude-mem-source
This commit is contained in:
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-mem",
|
||||
"version": "3.9.9",
|
||||
"version": "3.9.10",
|
||||
"description": "Memory compression system for Claude Code - persist context across sessions",
|
||||
"keywords": [
|
||||
"claude",
|
||||
|
||||
@@ -1,819 +0,0 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogBackdrop,
|
||||
DialogPanel,
|
||||
TransitionChild,
|
||||
} from '@headlessui/react';
|
||||
import {
|
||||
Bars3Icon,
|
||||
MagnifyingGlassIcon,
|
||||
XMarkIcon,
|
||||
} from '@heroicons/react/20/solid';
|
||||
import OverviewCard from './src/components/OverviewCard';
|
||||
|
||||
function classNames(...classes) {
|
||||
return classes.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
export default function MemoryStream() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [overviewsOpen, setOverviewsOpen] = useState(false);
|
||||
const [memories, setMemories] = useState([]);
|
||||
const [overviews, setOverviews] = useState([]);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [status, setStatus] = useState('connecting');
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [selectedProject, setSelectedProject] = useState('all');
|
||||
const [selectedTag, setSelectedTag] = useState(null);
|
||||
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [isAwaitingOverview, setIsAwaitingOverview] = useState(false);
|
||||
const [debugOverviewCard, setDebugOverviewCard] = useState(false);
|
||||
const eventSourceRef = useRef(null);
|
||||
|
||||
let filteredMemories = selectedProject === 'all'
|
||||
? memories
|
||||
: memories.filter(m => m.project === selectedProject);
|
||||
|
||||
if (selectedTag) {
|
||||
filteredMemories = filteredMemories.filter(m => m.concepts?.includes(selectedTag));
|
||||
}
|
||||
|
||||
const filteredOverviews = selectedProject === 'all'
|
||||
? overviews
|
||||
: overviews.filter(o => o.project === selectedProject);
|
||||
|
||||
const existingCount = filteredMemories.filter(m => !m.isNew).length;
|
||||
const newCount = filteredMemories.filter(m => m.isNew).length;
|
||||
|
||||
const stats = {
|
||||
total: filteredMemories.length,
|
||||
new: newCount,
|
||||
existing: existingCount,
|
||||
sessions: new Set(filteredMemories.map(m => m.session_id)).size,
|
||||
projects: new Set(memories.map(m => m.project)).size
|
||||
};
|
||||
|
||||
const projects = ['all', ...new Set(memories.map(m => m.project).filter(Boolean))];
|
||||
|
||||
useEffect(() => {
|
||||
setStatus('connecting');
|
||||
const eventSource = new EventSource('http://localhost:3001/stream');
|
||||
eventSourceRef.current = eventSource;
|
||||
|
||||
eventSource.onopen = () => {
|
||||
setStatus('connected');
|
||||
setConnected(true);
|
||||
};
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type === 'initial_load') {
|
||||
const existingMemories = data.memories.map(m => ({ ...m, isNew: false }));
|
||||
setMemories(existingMemories);
|
||||
const existingOverviews = data.overviews.map(o => ({ ...o, isNew: false }));
|
||||
setOverviews(existingOverviews);
|
||||
setInitialLoadComplete(true);
|
||||
setCurrentIndex(0);
|
||||
} else if (data.type === 'new_memories') {
|
||||
const newMemories = data.memories.map(m => ({ ...m, isNew: true }));
|
||||
setMemories(prev => [...newMemories, ...prev]);
|
||||
setCurrentIndex(0);
|
||||
} else if (data.type === 'new_overviews') {
|
||||
const newOverviews = data.overviews.map(o => ({ ...o, isNew: true }));
|
||||
// Remove placeholders for the same projects as the incoming real overviews
|
||||
const incomingProjects = new Set(newOverviews.map(o => o.project));
|
||||
setOverviews(prev => {
|
||||
const withoutPlaceholders = prev.filter(o =>
|
||||
!o.isPlaceholder || !incomingProjects.has(o.project)
|
||||
);
|
||||
return [...newOverviews, ...withoutPlaceholders];
|
||||
});
|
||||
setIsAwaitingOverview(false);
|
||||
} else if (data.type === 'session_start') {
|
||||
// Only process for current project (or 'all')
|
||||
if (selectedProject === 'all' || data.project === selectedProject) {
|
||||
setIsProcessing(true);
|
||||
setIsAwaitingOverview(true);
|
||||
|
||||
// Create placeholder overview card
|
||||
const placeholderOverview = {
|
||||
id: `placeholder-${Date.now()}`,
|
||||
project: data.project,
|
||||
content: '⏳ Session in progress...',
|
||||
created_at: new Date().toISOString(),
|
||||
session_id: null,
|
||||
isNew: true,
|
||||
isPlaceholder: true
|
||||
};
|
||||
setOverviews(prev => [placeholderOverview, ...prev]);
|
||||
}
|
||||
} else if (data.type === 'session_end') {
|
||||
// Only process for current project (or 'all')
|
||||
if (selectedProject === 'all' || data.project === selectedProject) {
|
||||
setIsProcessing(false);
|
||||
setIsAwaitingOverview(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = () => {
|
||||
setStatus('reconnecting');
|
||||
setConnected(false);
|
||||
eventSource.close();
|
||||
setTimeout(() => window.location.reload(), 2000);
|
||||
};
|
||||
|
||||
return () => eventSource.close();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
setCurrentIndex(i => (i - 1 + filteredMemories.length) % filteredMemories.length);
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
setCurrentIndex(i => (i + 1) % filteredMemories.length);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [filteredMemories.length]);
|
||||
|
||||
const formatTimestamp = (timestamp) => {
|
||||
const date = new Date(timestamp);
|
||||
const diff = Date.now() - date;
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
|
||||
const memory = filteredMemories[currentIndex] || {};
|
||||
|
||||
// Extract unique tags from all memories
|
||||
const allTags = [...new Set(memories.flatMap(m => m.concepts || []))];
|
||||
const tagCounts = allTags.reduce((acc, tag) => {
|
||||
acc[tag] = memories.filter(m => m.concepts?.includes(tag)).length;
|
||||
return acc;
|
||||
}, {});
|
||||
const sortedTags = allTags.sort((a, b) => tagCounts[b] - tagCounts[a]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="min-h-screen bg-black text-gray-100 relative overflow-hidden">
|
||||
{/* Background Effects */}
|
||||
<div className="fixed inset-0 opacity-20">
|
||||
<div className="absolute inset-0" style={{
|
||||
backgroundImage: 'linear-gradient(rgba(59, 130, 246, 0.1) 1px, transparent 1px), linear-gradient(90deg, rgba(59, 130, 246, 0.1) 1px, transparent 1px)',
|
||||
backgroundSize: '50px 50px'
|
||||
}} />
|
||||
</div>
|
||||
|
||||
<div className="fixed inset-0 pointer-events-none">
|
||||
<div className="absolute top-0 left-0 w-full h-full" style={{
|
||||
background: 'radial-gradient(ellipse at 20% 30%, rgba(59, 130, 246, 0.15) 0%, transparent 50%)'
|
||||
}} />
|
||||
<div className="absolute top-0 right-0 w-full h-full" style={{
|
||||
background: 'radial-gradient(ellipse at 80% 20%, rgba(139, 92, 246, 0.15) 0%, transparent 50%)'
|
||||
}} />
|
||||
<div className="absolute bottom-0 left-1/2 w-full h-full" style={{
|
||||
background: 'radial-gradient(ellipse at 50% 80%, rgba(16, 185, 129, 0.1) 0%, transparent 50%)'
|
||||
}} />
|
||||
</div>
|
||||
|
||||
{/* Mobile sidebar */}
|
||||
<Dialog open={sidebarOpen} onClose={setSidebarOpen} className="relative z-50 xl:hidden">
|
||||
<DialogBackdrop
|
||||
transition
|
||||
className="fixed inset-0 bg-gray-900/80 transition-opacity duration-300 ease-linear data-[closed]:opacity-0"
|
||||
/>
|
||||
|
||||
<div className="fixed inset-0 flex">
|
||||
<DialogPanel
|
||||
transition
|
||||
className="relative mr-16 flex w-full max-w-xs flex-1 transform transition duration-300 ease-in-out data-[closed]:-translate-x-full"
|
||||
>
|
||||
<TransitionChild>
|
||||
<div className="absolute left-full top-0 flex w-16 justify-center pt-5 duration-300 ease-in-out data-[closed]:opacity-0">
|
||||
<button type="button" onClick={() => setSidebarOpen(false)} className="-m-2.5 p-2.5">
|
||||
<span className="sr-only">Close sidebar</span>
|
||||
<XMarkIcon aria-hidden="true" className="size-6 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
</TransitionChild>
|
||||
|
||||
<div className="relative flex grow flex-col gap-y-5 overflow-y-auto bg-gray-900/90 backdrop-blur-xl px-6 border-r border-gray-800">
|
||||
<div className="relative flex h-16 shrink-0 items-center">
|
||||
<img src="/claude-mem-logo.webp" alt="claude-mem" className="h-10 w-auto" />
|
||||
</div>
|
||||
<nav className="relative flex flex-1 flex-col">
|
||||
<div className="space-y-6">
|
||||
<div className="relative">
|
||||
<div className="absolute -inset-2 bg-gradient-to-r from-blue-600/10 via-purple-600/10 to-emerald-600/10 rounded-xl blur-xl" />
|
||||
<div className="relative bg-gray-800/50 backdrop-blur-sm rounded-xl p-4 border border-gray-700/50">
|
||||
<h3 className="text-xs font-bold text-blue-400 mb-3 flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-blue-400" />
|
||||
STATISTICS
|
||||
</h3>
|
||||
<div className="space-y-2.5">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-400">Total</span>
|
||||
<span className="text-lg font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-300 to-blue-500">{stats.total}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-400">New</span>
|
||||
<span className="text-lg font-bold text-transparent bg-clip-text bg-gradient-to-r from-emerald-300 to-emerald-500">{stats.new}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-400">Sessions</span>
|
||||
<span className="text-lg font-bold text-transparent bg-clip-text bg-gradient-to-r from-purple-300 to-purple-500">{stats.sessions}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-400">Projects</span>
|
||||
<span className="text-lg font-bold text-gray-300">{stats.projects}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute -inset-2 bg-gradient-to-r from-purple-600/10 via-blue-600/10 to-purple-600/10 rounded-xl blur-xl" />
|
||||
<div className="relative bg-gray-800/50 backdrop-blur-sm rounded-xl p-4 border border-gray-700/50">
|
||||
<h3 className="text-xs font-bold text-purple-400 mb-3 flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-purple-400" />
|
||||
TAG CLOUD
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{sortedTags.slice(0, 20).map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
onClick={() => {
|
||||
setSelectedTag(selectedTag === tag ? null : tag);
|
||||
setCurrentIndex(0);
|
||||
}}
|
||||
className={classNames(
|
||||
"px-2.5 py-1 rounded-lg border text-xs font-medium transition-all cursor-pointer",
|
||||
selectedTag === tag
|
||||
? "bg-purple-500/30 border-purple-400/60 text-purple-200 shadow-lg shadow-purple-500/20"
|
||||
: "bg-purple-500/10 border-purple-400/30 text-purple-300 hover:bg-purple-500/20"
|
||||
)}
|
||||
>
|
||||
{tag} ({tagCounts[tag]})
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
{/* Desktop sidebar */}
|
||||
<div className="hidden xl:fixed xl:inset-y-0 xl:z-50 xl:flex xl:w-80 xl:flex-col">
|
||||
<div className="relative flex grow flex-col gap-y-5 overflow-y-auto bg-gray-900/90 backdrop-blur-xl px-6 border-r border-gray-800">
|
||||
<div className="flex h-16 shrink-0 items-center">
|
||||
<img src="/claude-mem-logo.webp" alt="claude-mem" className="h-10 w-auto" />
|
||||
</div>
|
||||
<nav className="flex flex-1 flex-col">
|
||||
<div className="space-y-6">
|
||||
<div className="relative">
|
||||
<div className="absolute -inset-2 bg-gradient-to-r from-blue-600/10 via-purple-600/10 to-emerald-600/10 rounded-xl blur-xl" />
|
||||
<div className="relative bg-gray-800/50 backdrop-blur-sm rounded-xl p-4 border border-gray-700/50">
|
||||
<h3 className="text-xs font-bold text-blue-400 mb-3 flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-blue-400" />
|
||||
STATISTICS
|
||||
</h3>
|
||||
<div className="space-y-2.5">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-400">Total</span>
|
||||
<span className="text-lg font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-300 to-blue-500">{stats.total}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-400">New</span>
|
||||
<span className="text-lg font-bold text-transparent bg-clip-text bg-gradient-to-r from-emerald-300 to-emerald-500">{stats.new}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-400">Sessions</span>
|
||||
<span className="text-lg font-bold text-transparent bg-clip-text bg-gradient-to-r from-purple-300 to-purple-500">{stats.sessions}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-400">Projects</span>
|
||||
<span className="text-lg font-bold text-gray-300">{stats.projects}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute -inset-2 bg-gradient-to-r from-purple-600/10 via-blue-600/10 to-purple-600/10 rounded-xl blur-xl" />
|
||||
<div className="relative bg-gray-800/50 backdrop-blur-sm rounded-xl p-4 border border-gray-700/50">
|
||||
<h3 className="text-xs font-bold text-purple-400 mb-3 flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-purple-400" />
|
||||
TAG CLOUD
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{sortedTags.slice(0, 20).map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
onClick={() => {
|
||||
setSelectedTag(selectedTag === tag ? null : tag);
|
||||
setCurrentIndex(0);
|
||||
}}
|
||||
className={classNames(
|
||||
"px-2.5 py-1 rounded-lg border text-xs font-medium transition-all cursor-pointer",
|
||||
selectedTag === tag
|
||||
? "bg-purple-500/30 border-purple-400/60 text-purple-200 shadow-lg shadow-purple-500/20"
|
||||
: "bg-purple-500/10 border-purple-400/30 text-purple-300 hover:bg-purple-500/20"
|
||||
)}
|
||||
>
|
||||
{tag} ({tagCounts[tag]})
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="xl:pl-80">
|
||||
{/* Fixed search header */}
|
||||
<div className="fixed top-0 left-0 right-0 xl:left-80 z-40 flex h-16 shrink-0 items-center gap-x-6 border-b border-gray-800 bg-gray-900/90 backdrop-blur-xl px-4 sm:px-6 lg:px-8">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
className="-m-2.5 p-2.5 text-gray-300 xl:hidden hover:text-white transition-colors"
|
||||
>
|
||||
<span className="sr-only">Open sidebar</span>
|
||||
<Bars3Icon aria-hidden="true" className="size-5" />
|
||||
</button>
|
||||
|
||||
<div className="flex flex-1 gap-x-4 self-stretch lg:gap-x-6">
|
||||
<form action="#" method="GET" className="grid flex-1 grid-cols-1 relative">
|
||||
<input
|
||||
name="search"
|
||||
placeholder="Search memories..."
|
||||
aria-label="Search"
|
||||
className="col-start-1 row-start-1 block size-full bg-gray-800/50 rounded-lg pl-10 pr-4 text-base text-gray-100 border border-gray-700 focus:border-blue-500/50 outline-none placeholder:text-gray-500 sm:text-sm/6 transition-colors"
|
||||
/>
|
||||
<MagnifyingGlassIcon
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none col-start-1 row-start-1 size-5 self-center ml-3 text-gray-500"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{connected && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-gradient-to-r from-purple-500/20 to-blue-500/20 border border-purple-400/30">
|
||||
<div className="w-2 h-2 bg-purple-400 rounded-full animate-pulse shadow-lg shadow-purple-400/50" />
|
||||
<span className="text-xs font-bold text-purple-300 tracking-wide">LIVE</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setDebugOverviewCard(!debugOverviewCard)}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-bold transition-all ${
|
||||
debugOverviewCard
|
||||
? 'bg-gradient-to-r from-blue-500/30 to-purple-500/30 border border-blue-400/60 text-blue-300'
|
||||
: 'bg-gray-800/50 border border-gray-700 text-gray-400 hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
DEBUG
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOverviewsOpen(true)}
|
||||
className="-m-2.5 p-2.5 text-gray-300 xl:hidden hover:text-white transition-colors"
|
||||
>
|
||||
<span className="sr-only">Open overviews</span>
|
||||
<Bars3Icon aria-hidden="true" className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<main className="pt-16">
|
||||
{/* Activity Indicator Bar */}
|
||||
<div className="h-1 fixed top-16 left-0 right-0 xl:left-80 z-30" style={{
|
||||
background: 'linear-gradient(90deg, transparent, #3b82f6, #8b5cf6, #10b981, transparent)',
|
||||
animation: isProcessing ? 'scan 3s ease-in-out infinite' : 'none',
|
||||
opacity: isProcessing ? 1 : 0,
|
||||
boxShadow: isProcessing ? '0 0 20px rgba(59, 130, 246, 0.8)' : 'none'
|
||||
}} />
|
||||
|
||||
{/* Debug Overview Card Mode */}
|
||||
{debugOverviewCard && (
|
||||
<OverviewCard debugMode={true} initialState="empty" />
|
||||
)}
|
||||
|
||||
{/* Normal Memory Stream View */}
|
||||
{!debugOverviewCard && (
|
||||
<div className="px-4 sm:px-6 lg:px-8 py-6">
|
||||
{!connected && (
|
||||
<div className="max-w-3xl mx-auto mb-12">
|
||||
<div className="relative group">
|
||||
<div className="absolute -inset-1 bg-gradient-to-r from-blue-600 via-purple-600 to-emerald-600 rounded-2xl blur opacity-25 animate-pulse" />
|
||||
<div className="relative bg-gray-900/90 backdrop-blur-xl rounded-2xl p-8 border border-gray-800">
|
||||
<div className="text-center">
|
||||
<div className="relative inline-block mb-4">
|
||||
<div className="absolute inset-0 bg-blue-500/20 blur-3xl animate-pulse" />
|
||||
<div className="relative text-6xl">📡</div>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold mb-2 bg-gradient-to-r from-blue-300 to-purple-300 bg-clip-text text-transparent">
|
||||
{status === 'connecting' ? 'Connecting to Memory Stream' : 'Reconnecting...'}
|
||||
</h2>
|
||||
<p className="text-gray-400">~/.claude-mem/claude-mem.db</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{connected && filteredMemories.length === 0 && (
|
||||
<div className="max-w-4xl mx-auto text-center py-20">
|
||||
<div className="relative inline-block">
|
||||
<div className="absolute inset-0 bg-purple-500/20 blur-3xl animate-pulse" />
|
||||
<div className="relative text-6xl mb-4">💭</div>
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-gray-300 mb-2">No Memories Found</h3>
|
||||
<p className="text-gray-500">
|
||||
{selectedProject === 'all'
|
||||
? 'No memories with titles in database'
|
||||
: `No memories for project: ${selectedProject}`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredMemories.length > 0 && (
|
||||
<div className="mb-8 max-w-6xl mx-auto relative z-50">
|
||||
<div className="flex items-center gap-4">
|
||||
<select
|
||||
value={selectedProject}
|
||||
onChange={(e) => {
|
||||
setSelectedProject(e.target.value);
|
||||
setCurrentIndex(0);
|
||||
}}
|
||||
className="px-4 py-2 rounded-lg bg-gray-800/50 border border-gray-700 text-gray-300 font-mono text-sm cursor-pointer hover:border-gray-600 focus:outline-none focus:border-blue-500/50 transition-colors"
|
||||
>
|
||||
{projects.map(project => (
|
||||
<option key={project} value={project}>
|
||||
{project === 'all' ? 'All Projects' : project}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<button
|
||||
onClick={() => setCurrentIndex(i => (i - 1 + filteredMemories.length) % filteredMemories.length)}
|
||||
className="w-10 h-10 rounded-full bg-gradient-to-br from-blue-600/20 to-purple-600/20 border border-blue-400/30 hover:border-blue-400/60 flex items-center justify-center transition-all duration-300 hover:scale-110 group"
|
||||
>
|
||||
<span className="text-blue-300 text-lg group-hover:text-blue-200">←</span>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<div className="flex-1 h-1.5 bg-gray-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-blue-500 via-purple-500 to-emerald-500 transition-all duration-300"
|
||||
style={{ width: `${((currentIndex + 1) / filteredMemories.length) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm font-mono text-gray-500 min-w-[80px] text-center">
|
||||
{currentIndex + 1} / {filteredMemories.length}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setCurrentIndex(i => (i + 1) % filteredMemories.length)}
|
||||
className="w-10 h-10 rounded-full bg-gradient-to-br from-purple-600/20 to-blue-600/20 border border-purple-400/30 hover:border-purple-400/60 flex items-center justify-center transition-all duration-300 hover:scale-110 group"
|
||||
>
|
||||
<span className="text-purple-300 text-lg group-hover:text-purple-200">→</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredMemories.length > 0 && (
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div key={memory.id} className="relative" style={{
|
||||
animation: 'slideIn 0.6s cubic-bezier(0.34, 1.56, 0.64, 1)'
|
||||
}}>
|
||||
<div className="absolute -inset-4 bg-gradient-to-r from-blue-600/20 via-purple-600/20 to-emerald-600/20 rounded-3xl blur-2xl" />
|
||||
|
||||
<div className="relative bg-gradient-to-br from-gray-900/90 to-gray-950/90 backdrop-blur-xl rounded-3xl p-12 border border-gray-800">
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3 mb-4 flex-wrap">
|
||||
<span className="px-4 py-1.5 rounded-full text-xs font-bold bg-gradient-to-r from-blue-500/20 to-blue-500/10 border border-blue-400/30 text-blue-300">
|
||||
#{memory.id}
|
||||
</span>
|
||||
<span className="px-4 py-1.5 rounded-full text-xs font-bold bg-gradient-to-r from-purple-500/20 to-purple-500/10 border border-purple-400/30 text-purple-300">
|
||||
{memory.project}
|
||||
</span>
|
||||
{memory.origin && (
|
||||
<span className="px-4 py-1.5 rounded-full text-xs font-bold bg-gradient-to-r from-emerald-500/20 to-emerald-500/10 border border-emerald-400/30 text-emerald-300">
|
||||
{memory.origin}
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto text-xs font-mono text-gray-500">
|
||||
{formatTimestamp(memory.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-4xl font-black text-transparent bg-clip-text bg-gradient-to-r from-blue-300 via-purple-300 to-emerald-300 mb-4 leading-tight">
|
||||
{memory.title}
|
||||
</h1>
|
||||
|
||||
{memory.subtitle && (
|
||||
<p className="text-xl text-gray-400 leading-relaxed">
|
||||
{memory.subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{memory.facts?.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<h3 className="text-sm font-bold text-blue-400 mb-4 flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-blue-400" />
|
||||
FACTS EXTRACTED
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{memory.facts.map((fact, i) => (
|
||||
<div key={i} className="flex gap-3 text-gray-300 leading-relaxed" style={{
|
||||
animation: 'fadeInUp 0.5s ease-out',
|
||||
animationDelay: `${i * 0.1}s`,
|
||||
animationFillMode: 'both'
|
||||
}}>
|
||||
<span className="text-blue-400 font-mono text-xs mt-1">▸</span>
|
||||
<span>{fact}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{memory.concepts?.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<h3 className="text-sm font-bold text-purple-400 mb-4 flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-purple-400" />
|
||||
CONCEPTS
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{memory.concepts.map((concept, i) => (
|
||||
<span key={i} className="px-3 py-1.5 rounded-lg bg-purple-500/10 border border-purple-400/30 text-purple-300 text-sm font-medium" style={{
|
||||
animation: 'fadeInUp 0.5s ease-out',
|
||||
animationDelay: `${i * 0.05}s`,
|
||||
animationFillMode: 'both'
|
||||
}}>
|
||||
{concept}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{memory.files_touched?.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-emerald-400 mb-4 flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
|
||||
FILES TOUCHED
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{memory.files_touched.map((file, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-sm font-mono text-emerald-300/80" style={{
|
||||
animation: 'fadeInUp 0.5s ease-out',
|
||||
animationDelay: `${i * 0.1}s`,
|
||||
animationFillMode: 'both'
|
||||
}}>
|
||||
<span>📄</span>
|
||||
<span>{file}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 pt-6 border-t border-gray-800 flex items-center justify-between">
|
||||
<div className="text-xs font-mono text-gray-600">
|
||||
session: {memory.session_id?.substring(0, 8)}...{memory.session_id?.slice(-4)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-center text-xs text-gray-600">
|
||||
<p>← → arrow keys to navigate</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Mobile overviews drawer */}
|
||||
<Dialog open={overviewsOpen} onClose={setOverviewsOpen} className="relative z-50 xl:hidden">
|
||||
<DialogBackdrop
|
||||
transition
|
||||
className="fixed inset-0 bg-gray-900/80 transition-opacity duration-300 ease-linear data-[closed]:opacity-0"
|
||||
/>
|
||||
|
||||
<div className="fixed inset-0 flex justify-end">
|
||||
<DialogPanel
|
||||
transition
|
||||
className="relative ml-16 flex w-full max-w-xs flex-1 transform transition duration-300 ease-in-out data-[closed]:translate-x-full"
|
||||
>
|
||||
<TransitionChild>
|
||||
<div className="absolute right-full top-0 flex w-16 justify-center pt-5 duration-300 ease-in-out data-[closed]:opacity-0">
|
||||
<button type="button" onClick={() => setOverviewsOpen(false)} className="-m-2.5 p-2.5">
|
||||
<span className="sr-only">Close overviews</span>
|
||||
<XMarkIcon aria-hidden="true" className="size-6 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
</TransitionChild>
|
||||
|
||||
<div className="relative flex grow flex-col overflow-y-auto bg-gray-900/90 backdrop-blur-xl border-l border-gray-800">
|
||||
<header className="flex items-center justify-between border-b border-gray-800 px-4 py-4 sm:px-6">
|
||||
<h2 className="text-base/7 font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-300 to-purple-300">Session Overviews</h2>
|
||||
<span className="text-sm font-mono text-gray-500">{filteredOverviews.length}</span>
|
||||
</header>
|
||||
<ul role="list" className="divide-y divide-gray-800">
|
||||
{filteredOverviews.length === 0 && (
|
||||
<li className="px-4 py-12 text-center">
|
||||
<div className="relative inline-block">
|
||||
<div className="absolute inset-0 bg-purple-500/10 blur-2xl" />
|
||||
<div className="relative text-4xl mb-3 opacity-50">📋</div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">No overviews yet</p>
|
||||
</li>
|
||||
)}
|
||||
|
||||
{filteredOverviews.map((overview) => (
|
||||
<li key={overview.id} className="px-4 py-4 sm:px-6 hover:bg-gray-800/30 transition-colors">
|
||||
<div className="flex items-start gap-3 mb-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="px-2 py-0.5 rounded text-xs font-bold bg-purple-500/10 border border-purple-400/30 text-purple-300">
|
||||
#{overview.id}
|
||||
</span>
|
||||
{overview.isNew && (
|
||||
<span className="px-2 py-0.5 rounded text-xs font-bold bg-blue-500/20 border border-blue-400/40 text-blue-300 animate-pulse">
|
||||
NEW
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs font-mono text-gray-500 truncate">
|
||||
{overview.project}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{formatTimestamp(overview.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{overview.promptTitle && (
|
||||
<div className="mb-3">
|
||||
<h3 className="text-sm font-bold text-blue-300 mb-1 leading-snug">
|
||||
{overview.promptTitle}
|
||||
</h3>
|
||||
{overview.promptSubtitle && (
|
||||
<p className="text-xs text-gray-400 leading-relaxed">
|
||||
{overview.promptSubtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-gray-300 leading-relaxed line-clamp-6">
|
||||
{overview.content}
|
||||
</p>
|
||||
|
||||
<div className="mt-2 pt-2 border-t border-gray-800">
|
||||
<div className="text-xs font-mono text-gray-600 truncate">
|
||||
session: {overview.session_id?.substring(0, 8)}...{overview.session_id?.slice(-4)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
{/* Desktop overviews sidebar */}
|
||||
<aside className="hidden xl:block bg-gray-900/90 backdrop-blur-xl xl:fixed xl:bottom-0 xl:right-0 xl:top-16 xl:w-96 xl:overflow-y-auto xl:border-l xl:border-gray-800">
|
||||
<header className="flex items-center justify-between border-b border-gray-800 px-4 py-4 sm:px-6 lg:px-8">
|
||||
<h2 className="text-base/7 font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-300 to-purple-300">Session Overviews</h2>
|
||||
<span className="text-sm font-mono text-gray-500">{filteredOverviews.length}</span>
|
||||
</header>
|
||||
<ul role="list" className="divide-y divide-gray-800">
|
||||
{filteredOverviews.length === 0 && (
|
||||
<li className="px-4 py-12 text-center">
|
||||
<div className="relative inline-block">
|
||||
<div className="absolute inset-0 bg-purple-500/10 blur-2xl" />
|
||||
<div className="relative text-4xl mb-3 opacity-50">📋</div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">No overviews yet</p>
|
||||
</li>
|
||||
)}
|
||||
|
||||
{filteredOverviews.map((overview) => (
|
||||
<li key={overview.id} className="px-4 py-4 sm:px-6 lg:px-8 hover:bg-gray-800/30 transition-colors">
|
||||
<div className="flex items-start gap-3 mb-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="px-2 py-0.5 rounded text-xs font-bold bg-purple-500/10 border border-purple-400/30 text-purple-300">
|
||||
#{overview.id}
|
||||
</span>
|
||||
{overview.isNew && (
|
||||
<span className="px-2 py-0.5 rounded text-xs font-bold bg-blue-500/20 border border-blue-400/40 text-blue-300 animate-pulse">
|
||||
NEW
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs font-mono text-gray-500 truncate">
|
||||
{overview.project}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{formatTimestamp(overview.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{overview.promptTitle && (
|
||||
<div className="mb-3">
|
||||
<h3 className="text-sm font-bold text-blue-300 mb-1 leading-snug">
|
||||
{overview.promptTitle}
|
||||
</h3>
|
||||
{overview.promptSubtitle && (
|
||||
<p className="text-xs text-gray-400 leading-relaxed">
|
||||
{overview.promptSubtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-gray-300 leading-relaxed line-clamp-6">
|
||||
{overview.content}
|
||||
</p>
|
||||
|
||||
<div className="mt-2 pt-2 border-t border-gray-800">
|
||||
<div className="text-xs font-mono text-gray-600 truncate">
|
||||
session: {overview.session_id?.substring(0, 8)}...{overview.session_id?.slice(-4)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes scan {
|
||||
0%, 100% {
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
transform: translateX(100%);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
# Memory Stream - Live Memory Viewer
|
||||
|
||||
A real-time slideshow viewer for claude-mem memories with SSE (Server-Sent Events) support.
|
||||
|
||||
## Features
|
||||
|
||||
- 📡 **Live streaming** - Automatically displays new memories as they're created
|
||||
- 🎬 **Auto-slideshow** - Cycles through memories every 5 seconds
|
||||
- ⏸️ **Pause/Resume** - Space bar or button controls
|
||||
- ⌨️ **Keyboard navigation** - Arrow keys to navigate
|
||||
- 🎨 **Beautiful UI** - Cyberpunk-themed neural network aesthetic
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Start the SSE server
|
||||
|
||||
```bash
|
||||
node src/ui/memory-stream/server.js
|
||||
# or use the package script:
|
||||
npm run memory-stream:server
|
||||
```
|
||||
|
||||
This will:
|
||||
- Watch `~/.claude-mem/claude-mem.db-wal` for changes
|
||||
- Serve SSE events on `http://localhost:3001/stream`
|
||||
- Automatically detect and broadcast new memories
|
||||
|
||||
### 2. Start your React dev server
|
||||
|
||||
```bash
|
||||
# In your React app directory
|
||||
npm run dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
### 3. Open the viewer
|
||||
|
||||
Navigate to your React app (usually `http://localhost:5173`)
|
||||
|
||||
## Usage
|
||||
|
||||
### Live Mode (Recommended)
|
||||
|
||||
1. Click **"CONNECT LIVE STREAM"**
|
||||
2. Server must be running (`node memory-stream-server.js`)
|
||||
3. New memories appear automatically as they're created
|
||||
4. Perfect for real-time monitoring during Claude Code sessions
|
||||
|
||||
### Presentation Mode (Alternative)
|
||||
|
||||
1. Click **"START PRESENTATION"**
|
||||
2. Select your `~/.claude-mem/claude-mem.db` file
|
||||
3. Static slideshow of existing memories
|
||||
4. No server required
|
||||
|
||||
## Controls
|
||||
|
||||
- **Space** - Pause/Resume slideshow
|
||||
- **←** - Previous memory
|
||||
- **→** - Next memory
|
||||
- **Click buttons** - Same as keyboard controls
|
||||
|
||||
## How It Works
|
||||
|
||||
### SSE Server
|
||||
- Uses `better-sqlite3` with WAL mode (already enabled in claude-mem)
|
||||
- Watches the `-wal` file for changes using `fs.watch()`
|
||||
- Queries for new memories when WAL changes detected
|
||||
- Broadcasts to all connected clients via Server-Sent Events
|
||||
|
||||
### React Client
|
||||
- Connects to SSE endpoint via `EventSource`
|
||||
- Auto-reconnects on connection loss
|
||||
- Appends new memories to the slideshow in real-time
|
||||
- No polling, pure event-driven updates
|
||||
|
||||
## Technical Details
|
||||
|
||||
**Database**: SQLite with WAL (Write-Ahead Logging) mode
|
||||
**Change Detection**: `fs.watch()` on `claude-mem.db-wal`
|
||||
**Transport**: Server-Sent Events (SSE)
|
||||
**Auto-reconnect**: 2-second retry on connection loss
|
||||
**CORS**: Enabled for local development
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"Connection lost"**
|
||||
- Ensure server is running: `node src/ui/memory-stream/server.js`
|
||||
- Check port 3001 is available
|
||||
- Look for server console output
|
||||
|
||||
**No memories showing**
|
||||
- Verify memories exist with `title` field
|
||||
- Check database path: `~/.claude-mem/claude-mem.db`
|
||||
- Try "START PRESENTATION" mode to verify database access
|
||||
|
||||
**WAL file not found**
|
||||
- WAL mode auto-enabled by claude-mem
|
||||
- File created automatically on first write
|
||||
- Check database exists at expected path
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 78 KiB |
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": false,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
-13
@@ -1,13 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Memory Stream - Claude Mem</title>
|
||||
<script type="module" crossorigin src="/assets/index-BjZoir4u.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-5_3SV7cT.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,120 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Memory Stream - Claude Mem</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './MemoryStream.jsx';
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,604 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogBackdrop,
|
||||
DialogPanel,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuItem,
|
||||
MenuItems,
|
||||
TransitionChild,
|
||||
} from '@headlessui/react'
|
||||
import {
|
||||
ChartBarSquareIcon,
|
||||
Cog6ToothIcon,
|
||||
FolderIcon,
|
||||
GlobeAltIcon,
|
||||
ServerIcon,
|
||||
SignalIcon,
|
||||
XMarkIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import { Bars3Icon, ChevronRightIcon, ChevronUpDownIcon, MagnifyingGlassIcon } from '@heroicons/react/20/solid'
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Projects', href: '#', icon: FolderIcon, current: false },
|
||||
{ name: 'Deployments', href: '#', icon: ServerIcon, current: true },
|
||||
{ name: 'Activity', href: '#', icon: SignalIcon, current: false },
|
||||
{ name: 'Domains', href: '#', icon: GlobeAltIcon, current: false },
|
||||
{ name: 'Usage', href: '#', icon: ChartBarSquareIcon, current: false },
|
||||
{ name: 'Settings', href: '#', icon: Cog6ToothIcon, current: false },
|
||||
]
|
||||
const teams = [
|
||||
{ id: 1, name: 'Planetaria', href: '#', initial: 'P', current: false },
|
||||
{ id: 2, name: 'Protocol', href: '#', initial: 'P', current: false },
|
||||
{ id: 3, name: 'Tailwind Labs', href: '#', initial: 'T', current: false },
|
||||
]
|
||||
const statuses = {
|
||||
offline: 'text-gray-400 bg-gray-100 dark:text-gray-500 dark:bg-gray-100/10',
|
||||
online: 'text-green-500 bg-green-500/10 dark:text-green-400 dark:bg-green-400/10',
|
||||
error: 'text-rose-500 bg-rose-500/10 dark:text-rose-400 dark:bg-rose-400/10',
|
||||
}
|
||||
const environments = {
|
||||
Preview: 'text-gray-500 bg-gray-50 ring-gray-200 dark:text-gray-400 dark:bg-gray-400/10 dark:ring-gray-400/20',
|
||||
Production:
|
||||
'text-indigo-500 bg-indigo-50 ring-indigo-200 dark:text-indigo-400 dark:bg-indigo-400/10 dark:ring-indigo-400/30',
|
||||
}
|
||||
const deployments = [
|
||||
{
|
||||
id: 1,
|
||||
href: '#',
|
||||
projectName: 'ios-app',
|
||||
teamName: 'Planetaria',
|
||||
status: 'offline',
|
||||
statusText: 'Initiated 1m 32s ago',
|
||||
description: 'Deploys from GitHub',
|
||||
environment: 'Preview',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
href: '#',
|
||||
projectName: 'mobile-api',
|
||||
teamName: 'Planetaria',
|
||||
status: 'online',
|
||||
statusText: 'Deployed 3m ago',
|
||||
description: 'Deploys from GitHub',
|
||||
environment: 'Production',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
href: '#',
|
||||
projectName: 'tailwindcss.com',
|
||||
teamName: 'Tailwind Labs',
|
||||
status: 'offline',
|
||||
statusText: 'Deployed 3h ago',
|
||||
description: 'Deploys from GitHub',
|
||||
environment: 'Preview',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
href: '#',
|
||||
projectName: 'company-website',
|
||||
teamName: 'Tailwind Labs',
|
||||
status: 'online',
|
||||
statusText: 'Deployed 1d ago',
|
||||
description: 'Deploys from GitHub',
|
||||
environment: 'Preview',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
href: '#',
|
||||
projectName: 'relay-service',
|
||||
teamName: 'Protocol',
|
||||
status: 'online',
|
||||
statusText: 'Deployed 1d ago',
|
||||
description: 'Deploys from GitHub',
|
||||
environment: 'Production',
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
href: '#',
|
||||
projectName: 'android-app',
|
||||
teamName: 'Planetaria',
|
||||
status: 'online',
|
||||
statusText: 'Deployed 5d ago',
|
||||
description: 'Deploys from GitHub',
|
||||
environment: 'Preview',
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
href: '#',
|
||||
projectName: 'api.protocol.chat',
|
||||
teamName: 'Protocol',
|
||||
status: 'error',
|
||||
statusText: 'Failed to deploy 6d ago',
|
||||
description: 'Deploys from GitHub',
|
||||
environment: 'Preview',
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
href: '#',
|
||||
projectName: 'planetaria.tech',
|
||||
teamName: 'Planetaria',
|
||||
status: 'online',
|
||||
statusText: 'Deployed 6d ago',
|
||||
description: 'Deploys from GitHub',
|
||||
environment: 'Preview',
|
||||
},
|
||||
]
|
||||
const activityItems = [
|
||||
{
|
||||
user: {
|
||||
name: 'Michael Foster',
|
||||
imageUrl:
|
||||
'https://images.unsplash.com/photo-1519244703995-f4e0f30006d5?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80',
|
||||
},
|
||||
projectName: 'ios-app',
|
||||
commit: '2d89f0c8',
|
||||
branch: 'main',
|
||||
date: '1h',
|
||||
dateTime: '2023-01-23T11:00',
|
||||
},
|
||||
{
|
||||
user: {
|
||||
name: 'Lindsay Walton',
|
||||
imageUrl:
|
||||
'https://images.unsplash.com/photo-1517841905240-472988babdf9?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80',
|
||||
},
|
||||
projectName: 'mobile-api',
|
||||
commit: '249df660',
|
||||
branch: 'main',
|
||||
date: '3h',
|
||||
dateTime: '2023-01-23T09:00',
|
||||
},
|
||||
{
|
||||
user: {
|
||||
name: 'Courtney Henry',
|
||||
imageUrl:
|
||||
'https://images.unsplash.com/photo-1438761681033-6461ffad8d80?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80',
|
||||
},
|
||||
projectName: 'ios-app',
|
||||
commit: '11464223',
|
||||
branch: 'main',
|
||||
date: '12h',
|
||||
dateTime: '2023-01-23T00:00',
|
||||
},
|
||||
{
|
||||
user: {
|
||||
name: 'Courtney Henry',
|
||||
imageUrl:
|
||||
'https://images.unsplash.com/photo-1438761681033-6461ffad8d80?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80',
|
||||
},
|
||||
projectName: 'company-website',
|
||||
commit: 'dad28e95',
|
||||
branch: 'main',
|
||||
date: '2d',
|
||||
dateTime: '2023-01-21T13:00',
|
||||
},
|
||||
{
|
||||
user: {
|
||||
name: 'Michael Foster',
|
||||
imageUrl:
|
||||
'https://images.unsplash.com/photo-1519244703995-f4e0f30006d5?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80',
|
||||
},
|
||||
projectName: 'relay-service',
|
||||
commit: '624bc94c',
|
||||
branch: 'main',
|
||||
date: '5d',
|
||||
dateTime: '2023-01-18T12:34',
|
||||
},
|
||||
{
|
||||
user: {
|
||||
name: 'Courtney Henry',
|
||||
imageUrl:
|
||||
'https://images.unsplash.com/photo-1438761681033-6461ffad8d80?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80',
|
||||
},
|
||||
projectName: 'api.protocol.chat',
|
||||
commit: 'e111f80e',
|
||||
branch: 'main',
|
||||
date: '1w',
|
||||
dateTime: '2023-01-16T15:54',
|
||||
},
|
||||
{
|
||||
user: {
|
||||
name: 'Michael Foster',
|
||||
imageUrl:
|
||||
'https://images.unsplash.com/photo-1519244703995-f4e0f30006d5?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80',
|
||||
},
|
||||
projectName: 'api.protocol.chat',
|
||||
commit: '5e136005',
|
||||
branch: 'main',
|
||||
date: '1w',
|
||||
dateTime: '2023-01-16T11:31',
|
||||
},
|
||||
{
|
||||
user: {
|
||||
name: 'Whitney Francis',
|
||||
imageUrl:
|
||||
'https://images.unsplash.com/photo-1517365830460-955ce3ccd263?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80',
|
||||
},
|
||||
projectName: 'ios-app',
|
||||
commit: '5c1fd07f',
|
||||
branch: 'main',
|
||||
date: '2w',
|
||||
dateTime: '2023-01-09T08:45',
|
||||
},
|
||||
]
|
||||
|
||||
function classNames(...classes) {
|
||||
return classes.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
export default function Example() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
{/*
|
||||
This example requires updating your template:
|
||||
|
||||
```
|
||||
<html class="h-full bg-white dark:bg-gray-900">
|
||||
<body class="h-full">
|
||||
```
|
||||
*/}
|
||||
<div>
|
||||
<Dialog open={sidebarOpen} onClose={setSidebarOpen} className="relative z-50 xl:hidden">
|
||||
<DialogBackdrop
|
||||
transition
|
||||
className="fixed inset-0 bg-gray-900/80 transition-opacity duration-300 ease-linear data-[closed]:opacity-0"
|
||||
/>
|
||||
|
||||
<div className="fixed inset-0 flex">
|
||||
<DialogPanel
|
||||
transition
|
||||
className="relative mr-16 flex w-full max-w-xs flex-1 transform transition duration-300 ease-in-out data-[closed]:-translate-x-full"
|
||||
>
|
||||
<TransitionChild>
|
||||
<div className="absolute left-full top-0 flex w-16 justify-center pt-5 duration-300 ease-in-out data-[closed]:opacity-0">
|
||||
<button type="button" onClick={() => setSidebarOpen(false)} className="-m-2.5 p-2.5">
|
||||
<span className="sr-only">Close sidebar</span>
|
||||
<XMarkIcon aria-hidden="true" className="size-6 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
</TransitionChild>
|
||||
|
||||
{/* Sidebar component, swap this element with another sidebar if you like */}
|
||||
<div className="relative flex grow flex-col gap-y-5 overflow-y-auto bg-gray-50 px-6 dark:bg-gray-900 dark:ring dark:ring-white/10 dark:before:pointer-events-none dark:before:absolute dark:before:inset-0 dark:before:bg-black/10">
|
||||
<div className="relative flex h-16 shrink-0 items-center">
|
||||
<img
|
||||
alt="Your Company"
|
||||
src="https://tailwindcss.com/plus-assets/img/logos/mark.svg?color=indigo&shade=600"
|
||||
className="h-8 w-auto dark:hidden"
|
||||
/>
|
||||
<img
|
||||
alt="Your Company"
|
||||
src="https://tailwindcss.com/plus-assets/img/logos/mark.svg?color=indigo&shade=500"
|
||||
className="hidden h-8 w-auto dark:block"
|
||||
/>
|
||||
</div>
|
||||
<nav className="relative flex flex-1 flex-col">
|
||||
<ul role="list" className="flex flex-1 flex-col gap-y-7">
|
||||
<li>
|
||||
<ul role="list" className="-mx-2 space-y-1">
|
||||
{navigation.map((item) => (
|
||||
<li key={item.name}>
|
||||
<a
|
||||
href={item.href}
|
||||
className={classNames(
|
||||
item.current
|
||||
? 'bg-gray-100 text-indigo-600 dark:bg-white/5 dark:text-white'
|
||||
: 'text-gray-700 hover:bg-gray-100 hover:text-indigo-600 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-white',
|
||||
'group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold',
|
||||
)}
|
||||
>
|
||||
<item.icon
|
||||
aria-hidden="true"
|
||||
className={classNames(
|
||||
item.current
|
||||
? 'text-indigo-600 dark:text-white'
|
||||
: 'text-gray-400 group-hover:text-indigo-600 dark:group-hover:text-white',
|
||||
'size-6 shrink-0',
|
||||
)}
|
||||
/>
|
||||
{item.name}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<div className="text-xs/6 font-semibold text-gray-400">Your teams</div>
|
||||
<ul role="list" className="-mx-2 mt-2 space-y-1">
|
||||
{teams.map((team) => (
|
||||
<li key={team.name}>
|
||||
<a
|
||||
href={team.href}
|
||||
className={classNames(
|
||||
team.current
|
||||
? 'bg-gray-100 text-indigo-600 dark:bg-white/5 dark:text-white'
|
||||
: 'text-gray-700 hover:bg-gray-100 hover:text-indigo-600 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-white',
|
||||
'group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={classNames(
|
||||
team.current
|
||||
? 'border-indigo-600 text-indigo-600 dark:border-white/20 dark:text-white'
|
||||
: 'border-gray-200 text-gray-400 group-hover:border-indigo-600 group-hover:text-indigo-600 dark:border-white/10 dark:group-hover:border-white/20 dark:group-hover:text-white',
|
||||
'flex size-6 shrink-0 items-center justify-center rounded-lg border bg-white text-[0.625rem] font-medium dark:bg-white/5',
|
||||
)}
|
||||
>
|
||||
{team.initial}
|
||||
</span>
|
||||
<span className="truncate">{team.name}</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
<li className="-mx-6 mt-auto">
|
||||
<a
|
||||
href="#"
|
||||
className="flex items-center gap-x-4 px-6 py-3 text-sm/6 font-semibold text-gray-900 hover:bg-gray-100 dark:text-white dark:hover:bg-white/5"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
src="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80"
|
||||
className="size-8 rounded-full bg-gray-100 outline outline-1 -outline-offset-1 outline-black/5 dark:bg-gray-800 dark:outline-white/10"
|
||||
/>
|
||||
<span className="sr-only">Your profile</span>
|
||||
<span aria-hidden="true">Tom Cook</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
{/* Static sidebar for desktop */}
|
||||
<div className="hidden xl:fixed xl:inset-y-0 xl:z-50 xl:flex xl:w-72 xl:flex-col dark:bg-gray-900">
|
||||
{/* Sidebar component, swap this element with another sidebar if you like */}
|
||||
<div className="flex grow flex-col gap-y-5 overflow-y-auto bg-gray-50 px-6 ring-1 ring-gray-200 dark:bg-black/10 dark:ring-white/5">
|
||||
<div className="flex h-16 shrink-0 items-center">
|
||||
<img
|
||||
alt="Your Company"
|
||||
src="https://tailwindcss.com/plus-assets/img/logos/mark.svg?color=indigo&shade=600"
|
||||
className="h-8 w-auto dark:hidden"
|
||||
/>
|
||||
<img
|
||||
alt="Your Company"
|
||||
src="https://tailwindcss.com/plus-assets/img/logos/mark.svg?color=indigo&shade=500"
|
||||
className="hidden h-8 w-auto dark:block"
|
||||
/>
|
||||
</div>
|
||||
<nav className="flex flex-1 flex-col">
|
||||
<ul role="list" className="flex flex-1 flex-col gap-y-7">
|
||||
<li>
|
||||
<ul role="list" className="-mx-2 space-y-1">
|
||||
{navigation.map((item) => (
|
||||
<li key={item.name}>
|
||||
<a
|
||||
href={item.href}
|
||||
className={classNames(
|
||||
item.current
|
||||
? 'bg-gray-100 text-indigo-600 dark:bg-white/5 dark:text-white'
|
||||
: 'text-gray-700 hover:bg-gray-100 hover:text-indigo-600 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-white',
|
||||
'group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold',
|
||||
)}
|
||||
>
|
||||
<item.icon
|
||||
aria-hidden="true"
|
||||
className={classNames(
|
||||
item.current
|
||||
? 'text-indigo-600 dark:text-white'
|
||||
: 'text-gray-400 group-hover:text-indigo-600 dark:group-hover:text-white',
|
||||
'size-6 shrink-0',
|
||||
)}
|
||||
/>
|
||||
{item.name}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<div className="text-xs/6 font-semibold text-gray-500 dark:text-gray-400">Your teams</div>
|
||||
<ul role="list" className="-mx-2 mt-2 space-y-1">
|
||||
{teams.map((team) => (
|
||||
<li key={team.name}>
|
||||
<a
|
||||
href={team.href}
|
||||
className={classNames(
|
||||
team.current
|
||||
? 'bg-gray-100 text-indigo-600 dark:bg-white/5 dark:text-white'
|
||||
: 'text-gray-700 hover:bg-gray-100 hover:text-indigo-600 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-white',
|
||||
'group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={classNames(
|
||||
team.current
|
||||
? 'border-indigo-600 text-indigo-600 dark:border-white/20 dark:text-white'
|
||||
: 'border-gray-200 text-gray-400 group-hover:border-indigo-600 group-hover:text-indigo-600 dark:border-white/10 dark:group-hover:border-white/20 dark:group-hover:text-white',
|
||||
'flex size-6 shrink-0 items-center justify-center rounded-lg border bg-white text-[0.625rem] font-medium dark:bg-white/5',
|
||||
)}
|
||||
>
|
||||
{team.initial}
|
||||
</span>
|
||||
<span className="truncate">{team.name}</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
<li className="-mx-6 mt-auto">
|
||||
<a
|
||||
href="#"
|
||||
className="flex items-center gap-x-4 px-6 py-3 text-sm/6 font-semibold text-gray-900 hover:bg-gray-100 dark:text-white dark:hover:bg-white/5"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
src="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80"
|
||||
className="size-8 rounded-full bg-gray-100 outline outline-1 -outline-offset-1 outline-black/5 dark:bg-gray-800 dark:outline-white/10"
|
||||
/>
|
||||
<span className="sr-only">Your profile</span>
|
||||
<span aria-hidden="true">Tom Cook</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="xl:pl-72">
|
||||
{/* Sticky search header */}
|
||||
<div className="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-x-6 border-b border-gray-200 bg-white px-4 shadow-sm sm:px-6 lg:px-8 dark:border-white/5 dark:bg-gray-900 dark:shadow-none">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
className="-m-2.5 p-2.5 text-gray-900 xl:hidden dark:text-white"
|
||||
>
|
||||
<span className="sr-only">Open sidebar</span>
|
||||
<Bars3Icon aria-hidden="true" className="size-5" />
|
||||
</button>
|
||||
|
||||
<div className="flex flex-1 gap-x-4 self-stretch lg:gap-x-6">
|
||||
<form action="#" method="GET" className="grid flex-1 grid-cols-1">
|
||||
<input
|
||||
name="search"
|
||||
placeholder="Search"
|
||||
aria-label="Search"
|
||||
className="col-start-1 row-start-1 block size-full bg-transparent pl-8 text-base text-gray-900 outline-none placeholder:text-gray-400 sm:text-sm/6 dark:text-white dark:placeholder:text-gray-500"
|
||||
/>
|
||||
<MagnifyingGlassIcon
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none col-start-1 row-start-1 size-5 self-center text-gray-400 dark:text-gray-500"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main className="lg:pr-96">
|
||||
<header className="flex items-center justify-between border-b border-gray-200 px-4 py-4 sm:px-6 sm:py-6 lg:px-8 dark:border-white/5">
|
||||
<h1 className="text-base/7 font-semibold text-gray-900 dark:text-white">Deployments</h1>
|
||||
|
||||
{/* Sort dropdown */}
|
||||
<Menu as="div" className="relative">
|
||||
<MenuButton className="flex items-center gap-x-1 text-sm/6 font-medium text-gray-900 dark:text-white">
|
||||
Sort by
|
||||
<ChevronUpDownIcon aria-hidden="true" className="size-5 text-gray-500" />
|
||||
</MenuButton>
|
||||
<MenuItems
|
||||
transition
|
||||
className="absolute right-0 z-10 mt-2.5 w-40 origin-top-right rounded-md bg-white py-2 shadow-lg outline outline-1 outline-gray-900/5 transition data-[closed]:scale-95 data-[closed]:transform data-[closed]:opacity-0 data-[enter]:duration-100 data-[leave]:duration-75 data-[enter]:ease-out data-[leave]:ease-in dark:bg-gray-800 dark:shadow-none dark:-outline-offset-1 dark:outline-white/10"
|
||||
>
|
||||
<MenuItem>
|
||||
<a
|
||||
href="#"
|
||||
className="block px-3 py-1 text-sm/6 text-gray-900 data-[focus]:bg-gray-50 data-[focus]:outline-none dark:text-white dark:data-[focus]:bg-white/5"
|
||||
>
|
||||
Name
|
||||
</a>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<a
|
||||
href="#"
|
||||
className="block px-3 py-1 text-sm/6 text-gray-900 data-[focus]:bg-gray-50 data-[focus]:outline-none dark:text-white dark:data-[focus]:bg-white/5"
|
||||
>
|
||||
Date updated
|
||||
</a>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<a
|
||||
href="#"
|
||||
className="block px-3 py-1 text-sm/6 text-gray-900 data-[focus]:bg-gray-50 data-[focus]:outline-none dark:text-white dark:data-[focus]:bg-white/5"
|
||||
>
|
||||
Environment
|
||||
</a>
|
||||
</MenuItem>
|
||||
</MenuItems>
|
||||
</Menu>
|
||||
</header>
|
||||
|
||||
{/* Deployment list */}
|
||||
<ul role="list" className="divide-y divide-gray-100 dark:divide-white/5">
|
||||
{deployments.map((deployment) => (
|
||||
<li key={deployment.id} className="relative flex items-center space-x-4 px-4 py-4 sm:px-6 lg:px-8">
|
||||
<div className="min-w-0 flex-auto">
|
||||
<div className="flex items-center gap-x-3">
|
||||
<div className={classNames(statuses[deployment.status], 'flex-none rounded-full p-1')}>
|
||||
<div className="size-2 rounded-full bg-current" />
|
||||
</div>
|
||||
<h2 className="min-w-0 text-sm/6 font-semibold text-gray-900 dark:text-white">
|
||||
<a href={deployment.href} className="flex gap-x-2">
|
||||
<span className="truncate">{deployment.teamName}</span>
|
||||
<span className="text-gray-400">/</span>
|
||||
<span className="whitespace-nowrap">{deployment.projectName}</span>
|
||||
<span className="absolute inset-0" />
|
||||
</a>
|
||||
</h2>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-x-2.5 text-xs/5 text-gray-500 dark:text-gray-400">
|
||||
<p className="truncate">{deployment.description}</p>
|
||||
<svg viewBox="0 0 2 2" className="size-0.5 flex-none fill-gray-300 dark:fill-gray-500">
|
||||
<circle r={1} cx={1} cy={1} />
|
||||
</svg>
|
||||
<p className="whitespace-nowrap">{deployment.statusText}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
environments[deployment.environment],
|
||||
'flex-none rounded-full px-2 py-1 text-xs font-medium ring-1 ring-inset',
|
||||
)}
|
||||
>
|
||||
{deployment.environment}
|
||||
</div>
|
||||
<ChevronRightIcon aria-hidden="true" className="size-5 flex-none text-gray-400" />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</main>
|
||||
|
||||
{/* Activity feed */}
|
||||
<aside className="bg-gray-50 lg:fixed lg:bottom-0 lg:right-0 lg:top-16 lg:w-96 lg:overflow-y-auto lg:border-l lg:border-gray-200 dark:bg-black/10 dark:lg:border-white/5">
|
||||
<header className="flex items-center justify-between border-b border-gray-200 px-4 py-4 sm:px-6 sm:py-6 lg:px-8 dark:border-white/5">
|
||||
<h2 className="text-base/7 font-semibold text-gray-900 dark:text-white">Activity feed</h2>
|
||||
<a href="#" className="text-sm/6 font-semibold text-indigo-600 dark:text-indigo-400">
|
||||
View all
|
||||
</a>
|
||||
</header>
|
||||
<ul role="list" className="divide-y divide-gray-100 dark:divide-white/5">
|
||||
{activityItems.map((item) => (
|
||||
<li key={item.commit} className="px-4 py-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center gap-x-3">
|
||||
<img
|
||||
alt=""
|
||||
src={item.user.imageUrl}
|
||||
className="size-6 flex-none rounded-full bg-gray-100 outline outline-1 -outline-offset-1 outline-black/5 dark:bg-gray-800 dark:outline-white/10"
|
||||
/>
|
||||
<h3 className="flex-auto truncate text-sm/6 font-semibold text-gray-900 dark:text-white">
|
||||
{item.user.name}
|
||||
</h3>
|
||||
<time dateTime={item.dateTime} className="flex-none text-xs text-gray-500 dark:text-gray-600">
|
||||
{item.date}
|
||||
</time>
|
||||
</div>
|
||||
<p className="mt-3 truncate text-sm text-gray-500">
|
||||
Pushed to <span className="text-gray-700 dark:text-gray-400">{item.projectName}</span> (
|
||||
<span className="font-mono text-gray-700 dark:text-gray-400">{item.commit}</span> on{' '}
|
||||
<span className="text-gray-700 dark:text-gray-400">{item.branch}</span>)
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import MemoryStream from './MemoryStream.jsx'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<MemoryStream />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
Generated
-2707
File diff suppressed because it is too large
Load Diff
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"name": "memory-stream-ui",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^2.2.9",
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.544.0",
|
||||
"ogl": "^1.0.11",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"three": "^0.180.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vite": "^5.0.8"
|
||||
}
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { watch, existsSync, readFileSync } from 'fs';
|
||||
import { createServer } from 'http';
|
||||
import { homedir } from 'os';
|
||||
import { join } from 'path';
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
const DB_PATH = join(homedir(), '.claude-mem/claude-mem.db');
|
||||
const SESSIONS_DIR = join(homedir(), '.claude-mem/sessions');
|
||||
const PORT = 3001;
|
||||
|
||||
let clients = [];
|
||||
let lastMaxId = 0;
|
||||
let lastOverviewId = 0;
|
||||
|
||||
function safeJsonParse(jsonString) {
|
||||
if (!jsonString) return [];
|
||||
try {
|
||||
return JSON.parse(jsonString);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function getMemories(minId = 0) {
|
||||
const db = new Database(DB_PATH, { readonly: true });
|
||||
const memories = db.prepare(`
|
||||
SELECT id, session_id, created_at, project, origin, title, subtitle, facts, concepts, files_touched
|
||||
FROM memories
|
||||
WHERE id > ? AND title IS NOT NULL
|
||||
ORDER BY id DESC
|
||||
`).all(minId);
|
||||
db.close();
|
||||
|
||||
return memories.map(m => ({
|
||||
...m,
|
||||
facts: safeJsonParse(m.facts),
|
||||
concepts: safeJsonParse(m.concepts),
|
||||
files_touched: safeJsonParse(m.files_touched)
|
||||
}));
|
||||
}
|
||||
|
||||
function getOverviews(minId = 0) {
|
||||
const db = new Database(DB_PATH, { readonly: true });
|
||||
const overviews = db.prepare(`
|
||||
SELECT id, session_id, content, created_at, project, origin
|
||||
FROM overviews
|
||||
WHERE id > ?
|
||||
ORDER BY id DESC
|
||||
`).all(minId);
|
||||
db.close();
|
||||
|
||||
// Enrich overviews with session titles/subtitles from session JSON files
|
||||
return overviews.map(overview => {
|
||||
const sessionFile = join(SESSIONS_DIR, `${overview.project}_streaming.json`);
|
||||
let promptTitle = null;
|
||||
let promptSubtitle = null;
|
||||
|
||||
try {
|
||||
if (existsSync(sessionFile)) {
|
||||
const sessionData = JSON.parse(readFileSync(sessionFile, 'utf8'));
|
||||
// Only attach title/subtitle if it's from the same Claude session
|
||||
if (sessionData.claudeSessionId === overview.session_id) {
|
||||
promptTitle = sessionData.promptTitle || null;
|
||||
promptSubtitle = sessionData.promptSubtitle || null;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore errors reading session file
|
||||
}
|
||||
|
||||
return {
|
||||
...overview,
|
||||
promptTitle,
|
||||
promptSubtitle
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function getSessions() {
|
||||
const db = new Database(DB_PATH, { readonly: true });
|
||||
|
||||
// Get unique sessions from overviews
|
||||
const sessions = db.prepare(`
|
||||
SELECT DISTINCT
|
||||
o.session_id,
|
||||
o.project,
|
||||
o.created_at,
|
||||
o.content as overview_content
|
||||
FROM overviews o
|
||||
ORDER BY o.created_at DESC
|
||||
LIMIT 50
|
||||
`).all();
|
||||
|
||||
db.close();
|
||||
|
||||
return sessions;
|
||||
}
|
||||
|
||||
function getSessionData(sessionId) {
|
||||
const db = new Database(DB_PATH, { readonly: true });
|
||||
|
||||
const overview = db.prepare(`
|
||||
SELECT id, session_id, content, created_at, project, origin
|
||||
FROM overviews
|
||||
WHERE session_id = ?
|
||||
LIMIT 1
|
||||
`).get(sessionId);
|
||||
|
||||
const memories = db.prepare(`
|
||||
SELECT id, session_id, created_at, project, origin, title, subtitle, facts, concepts, files_touched
|
||||
FROM memories
|
||||
WHERE session_id = ? AND title IS NOT NULL
|
||||
ORDER BY id ASC
|
||||
`).all(sessionId);
|
||||
|
||||
db.close();
|
||||
|
||||
return {
|
||||
overview,
|
||||
memories: memories.map(m => ({
|
||||
...m,
|
||||
facts: safeJsonParse(m.facts),
|
||||
concepts: safeJsonParse(m.concepts),
|
||||
files_touched: safeJsonParse(m.files_touched)
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
function broadcast(type, data) {
|
||||
const message = `data: ${JSON.stringify({ type, ...data })}\n\n`;
|
||||
clients.forEach(client => client.write(message));
|
||||
}
|
||||
|
||||
function broadcastSessionState(eventType, project) {
|
||||
const message = `data: ${JSON.stringify({ type: eventType, project })}\n\n`;
|
||||
clients.forEach(client => client.write(message));
|
||||
console.log(`📡 Broadcasting ${eventType} for project: ${project}`);
|
||||
}
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url === '/stream') {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive'
|
||||
});
|
||||
|
||||
clients.push(res);
|
||||
console.log(`🔌 Client connected (${clients.length} total)`);
|
||||
|
||||
const allMemories = getMemories(-1);
|
||||
lastMaxId = allMemories.length > 0 ? Math.max(...allMemories.map(m => m.id)) : 0;
|
||||
|
||||
const allOverviews = getOverviews(-1);
|
||||
lastOverviewId = allOverviews.length > 0 ? Math.max(...allOverviews.map(o => o.id)) : 0;
|
||||
|
||||
console.log(`📦 Sending ${allMemories.length} memories and ${allOverviews.length} overviews to new client`);
|
||||
broadcast('initial_load', { memories: allMemories, overviews: allOverviews });
|
||||
|
||||
req.on('close', () => {
|
||||
clients = clients.filter(client => client !== res);
|
||||
console.log(`🔌 Client disconnected (${clients.length} remaining)`);
|
||||
});
|
||||
} else if (req.url === '/api/sessions') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
const sessions = getSessions();
|
||||
res.end(JSON.stringify(sessions));
|
||||
} else if (req.url.startsWith('/api/session/')) {
|
||||
const sessionId = req.url.replace('/api/session/', '');
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
const sessionData = getSessionData(sessionId);
|
||||
res.end(JSON.stringify(sessionData));
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
watch(DB_PATH, (eventType) => {
|
||||
const newMemories = getMemories(lastMaxId);
|
||||
if (newMemories.length > 0) {
|
||||
lastMaxId = Math.max(...newMemories.map(m => m.id));
|
||||
console.log(`✨ Broadcasting ${newMemories.length} new memories`);
|
||||
broadcast('new_memories', { memories: newMemories });
|
||||
}
|
||||
|
||||
const newOverviews = getOverviews(lastOverviewId);
|
||||
if (newOverviews.length > 0) {
|
||||
lastOverviewId = Math.max(...newOverviews.map(o => o.id));
|
||||
console.log(`✨ Broadcasting ${newOverviews.length} new overviews`);
|
||||
broadcast('new_overviews', { overviews: newOverviews });
|
||||
}
|
||||
});
|
||||
|
||||
watch(SESSIONS_DIR, (eventType, filename) => {
|
||||
if (!filename || !filename.endsWith('_streaming.json')) return;
|
||||
|
||||
const project = filename.replace('_streaming.json', '');
|
||||
const sessionPath = join(SESSIONS_DIR, filename);
|
||||
|
||||
if (eventType === 'rename') {
|
||||
// Check if file exists to determine if it was created or deleted
|
||||
if (existsSync(sessionPath)) {
|
||||
broadcastSessionState('session_start', project);
|
||||
} else {
|
||||
broadcastSessionState('session_end', project);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`🚀 Memory Stream Server running on http://localhost:${PORT}`);
|
||||
console.log(`📡 SSE endpoint: http://localhost:${PORT}/stream`);
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
clients.forEach(client => client.end());
|
||||
server.close();
|
||||
process.exit(0);
|
||||
});
|
||||
@@ -1,570 +0,0 @@
|
||||
// Component ported and enhanced from https://codepen.io/JuanFuentes/pen/eYEeoyE
|
||||
|
||||
import { useRef, useEffect } from 'react';
|
||||
import * as THREE from 'three';
|
||||
|
||||
const vertexShader = `
|
||||
varying vec2 vUv;
|
||||
uniform float uTime;
|
||||
uniform float mouse;
|
||||
uniform float uEnableWaves;
|
||||
|
||||
void main() {
|
||||
vUv = uv;
|
||||
float time = uTime * 5.;
|
||||
|
||||
float waveFactor = uEnableWaves;
|
||||
|
||||
vec3 transformed = position;
|
||||
|
||||
transformed.x += sin(time + position.y) * 0.5 * waveFactor;
|
||||
transformed.y += cos(time + position.z) * 0.15 * waveFactor;
|
||||
transformed.z += sin(time + position.x) * waveFactor;
|
||||
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(transformed, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const fragmentShader = `
|
||||
varying vec2 vUv;
|
||||
uniform float mouse;
|
||||
uniform float uTime;
|
||||
uniform sampler2D uTexture;
|
||||
|
||||
void main() {
|
||||
float time = uTime;
|
||||
vec2 pos = vUv;
|
||||
|
||||
float move = sin(time + mouse) * 0.01;
|
||||
float r = texture2D(uTexture, pos + cos(time * 2. - time + pos.x) * .01).r;
|
||||
float g = texture2D(uTexture, pos + tan(time * .5 + pos.x - time) * .01).g;
|
||||
float b = texture2D(uTexture, pos - cos(time * 2. + time + pos.y) * .01).b;
|
||||
float a = texture2D(uTexture, pos).a;
|
||||
gl_FragColor = vec4(r, g, b, a);
|
||||
}
|
||||
`;
|
||||
|
||||
function map(n, start, stop, start2, stop2) {
|
||||
return ((n - start) / (stop - start)) * (stop2 - start2) + start2;
|
||||
}
|
||||
|
||||
const PX_RATIO = typeof window !== 'undefined' ? window.devicePixelRatio : 1;
|
||||
|
||||
class AsciiFilter {
|
||||
width = 0;
|
||||
height = 0;
|
||||
center = { x: 0, y: 0 };
|
||||
mouse = { x: 0, y: 0 };
|
||||
cols = 0;
|
||||
rows = 0;
|
||||
|
||||
constructor(renderer, {
|
||||
fontSize,
|
||||
fontFamily,
|
||||
charset,
|
||||
invert
|
||||
} = {}) {
|
||||
this.renderer = renderer;
|
||||
this.domElement = document.createElement('div');
|
||||
this.domElement.style.position = 'absolute';
|
||||
this.domElement.style.top = '0';
|
||||
this.domElement.style.left = '0';
|
||||
this.domElement.style.width = '100%';
|
||||
this.domElement.style.height = '100%';
|
||||
|
||||
this.pre = document.createElement('pre');
|
||||
this.domElement.appendChild(this.pre);
|
||||
|
||||
this.canvas = document.createElement('canvas');
|
||||
this.context = this.canvas.getContext('2d');
|
||||
this.domElement.appendChild(this.canvas);
|
||||
|
||||
this.deg = 0;
|
||||
this.invert = invert ?? true;
|
||||
this.fontSize = fontSize ?? 12;
|
||||
this.fontFamily = fontFamily ?? "'Courier New', monospace";
|
||||
this.charset = charset ?? ' .\'`^",:;Il!i~+_-?][}{1)(|/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$';
|
||||
|
||||
if (this.context) {
|
||||
this.context.imageSmoothingEnabled = false;
|
||||
this.context.imageSmoothingEnabled = false;
|
||||
}
|
||||
|
||||
this.onMouseMove = this.onMouseMove.bind(this);
|
||||
document.addEventListener('mousemove', this.onMouseMove);
|
||||
}
|
||||
|
||||
setSize(width, height) {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.renderer.setSize(width, height);
|
||||
this.reset();
|
||||
|
||||
this.center = { x: width / 2, y: height / 2 };
|
||||
this.mouse = { x: this.center.x, y: this.center.y };
|
||||
}
|
||||
|
||||
reset() {
|
||||
if (this.context) {
|
||||
this.context.font = `${this.fontSize}px ${this.fontFamily}`;
|
||||
const charWidth = this.context.measureText('A').width;
|
||||
|
||||
this.cols = Math.floor(this.width / (this.fontSize * (charWidth / this.fontSize)));
|
||||
this.rows = Math.floor(this.height / this.fontSize);
|
||||
|
||||
this.canvas.width = this.cols;
|
||||
this.canvas.height = this.rows;
|
||||
this.pre.style.fontFamily = this.fontFamily;
|
||||
this.pre.style.fontSize = `${this.fontSize}px`;
|
||||
this.pre.style.margin = '0';
|
||||
this.pre.style.padding = '0';
|
||||
this.pre.style.lineHeight = '1em';
|
||||
this.pre.style.position = 'absolute';
|
||||
this.pre.style.left = '50%';
|
||||
this.pre.style.top = '50%';
|
||||
this.pre.style.transform = 'translate(-50%, -50%)';
|
||||
this.pre.style.zIndex = '9';
|
||||
this.pre.style.backgroundAttachment = 'fixed';
|
||||
this.pre.style.mixBlendMode = 'difference';
|
||||
}
|
||||
}
|
||||
|
||||
render(scene, camera) {
|
||||
this.renderer.render(scene, camera);
|
||||
|
||||
const w = this.canvas.width;
|
||||
const h = this.canvas.height;
|
||||
if (this.context) {
|
||||
this.context.clearRect(0, 0, w, h);
|
||||
if (this.context && w && h) {
|
||||
this.context.drawImage(this.renderer.domElement, 0, 0, w, h);
|
||||
}
|
||||
|
||||
this.asciify(this.context, w, h);
|
||||
this.hue();
|
||||
}
|
||||
}
|
||||
|
||||
onMouseMove(e) {
|
||||
this.mouse = { x: e.clientX * PX_RATIO, y: e.clientY * PX_RATIO };
|
||||
}
|
||||
|
||||
get dx() {
|
||||
return this.mouse.x - this.center.x;
|
||||
}
|
||||
|
||||
get dy() {
|
||||
return this.mouse.y - this.center.y;
|
||||
}
|
||||
|
||||
hue() {
|
||||
const deg = (Math.atan2(this.dy, this.dx) * 180) / Math.PI;
|
||||
this.deg += (deg - this.deg) * 0.075;
|
||||
this.domElement.style.filter = `hue-rotate(${this.deg.toFixed(1)}deg)`;
|
||||
}
|
||||
|
||||
asciify(ctx, w, h) {
|
||||
if (w && h) {
|
||||
const imgData = ctx.getImageData(0, 0, w, h).data;
|
||||
let str = '';
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
const i = x * 4 + y * 4 * w;
|
||||
const [r, g, b, a] = [imgData[i], imgData[i + 1], imgData[i + 2], imgData[i + 3]];
|
||||
|
||||
if (a === 0) {
|
||||
str += ' ';
|
||||
continue;
|
||||
}
|
||||
|
||||
let gray = (0.3 * r + 0.6 * g + 0.1 * b) / 255;
|
||||
let idx = Math.floor((1 - gray) * (this.charset.length - 1));
|
||||
if (this.invert) idx = this.charset.length - idx - 1;
|
||||
str += this.charset[idx];
|
||||
}
|
||||
str += '\n';
|
||||
}
|
||||
this.pre.innerHTML = str;
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
document.removeEventListener('mousemove', this.onMouseMove);
|
||||
}
|
||||
}
|
||||
|
||||
class CanvasTxt {
|
||||
constructor(txt, {
|
||||
fontSize = 200,
|
||||
fontFamily = 'Arial',
|
||||
color = '#fdf9f3'
|
||||
} = {}) {
|
||||
this.canvas = document.createElement('canvas');
|
||||
this.context = this.canvas.getContext('2d');
|
||||
this.txt = txt;
|
||||
this.fontSize = fontSize;
|
||||
this.fontFamily = fontFamily;
|
||||
this.color = color;
|
||||
|
||||
this.font = `600 ${this.fontSize}px ${this.fontFamily}`;
|
||||
}
|
||||
|
||||
resize() {
|
||||
if (this.context) {
|
||||
this.context.font = this.font;
|
||||
|
||||
// Split text into lines
|
||||
const lines = this.txt.split('\n');
|
||||
|
||||
// Measure all lines to find max width
|
||||
let maxWidth = 0;
|
||||
for (const line of lines) {
|
||||
const metrics = this.context.measureText(line);
|
||||
maxWidth = Math.max(maxWidth, metrics.width);
|
||||
}
|
||||
|
||||
// Calculate total height (first line metrics for line height)
|
||||
const firstMetrics = this.context.measureText(lines[0] || 'A');
|
||||
const lineHeight = Math.ceil(firstMetrics.actualBoundingBoxAscent + firstMetrics.actualBoundingBoxDescent);
|
||||
|
||||
const textWidth = Math.ceil(maxWidth) + 20;
|
||||
const textHeight = lineHeight * lines.length + 20;
|
||||
|
||||
this.canvas.width = textWidth;
|
||||
this.canvas.height = textHeight;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.context) {
|
||||
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
this.context.fillStyle = this.color;
|
||||
this.context.font = this.font;
|
||||
|
||||
// Split text into lines and render each
|
||||
const lines = this.txt.split('\n');
|
||||
const metrics = this.context.measureText(lines[0] || 'A');
|
||||
const lineHeight = Math.ceil(metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent);
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
const yPos = 10 + metrics.actualBoundingBoxAscent + (index * lineHeight);
|
||||
this.context.fillText(line, 10, yPos);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
get width() {
|
||||
return this.canvas.width;
|
||||
}
|
||||
|
||||
get height() {
|
||||
return this.canvas.height;
|
||||
}
|
||||
|
||||
get texture() {
|
||||
return this.canvas;
|
||||
}
|
||||
}
|
||||
|
||||
class CanvAscii {
|
||||
animationFrameId = 0;
|
||||
|
||||
constructor(
|
||||
{
|
||||
text,
|
||||
asciiFontSize,
|
||||
textFontSize,
|
||||
textColor,
|
||||
planeBaseHeight,
|
||||
enableWaves,
|
||||
enableMouseRotation
|
||||
},
|
||||
containerElem,
|
||||
width,
|
||||
height
|
||||
) {
|
||||
this.textString = text;
|
||||
this.asciiFontSize = asciiFontSize;
|
||||
this.textFontSize = textFontSize;
|
||||
this.textColor = textColor;
|
||||
this.planeBaseHeight = planeBaseHeight;
|
||||
this.container = containerElem;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.enableWaves = enableWaves;
|
||||
this.enableMouseRotation = enableMouseRotation;
|
||||
|
||||
this.camera = new THREE.PerspectiveCamera(45, this.width / this.height, 1, 1000);
|
||||
this.camera.position.z = 30;
|
||||
|
||||
this.scene = new THREE.Scene();
|
||||
this.mouse = { x: 0, y: 0 };
|
||||
|
||||
this.onMouseMove = this.onMouseMove.bind(this);
|
||||
|
||||
this.setMesh();
|
||||
this.setRenderer();
|
||||
}
|
||||
|
||||
setMesh() {
|
||||
this.textCanvas = new CanvasTxt(this.textString, {
|
||||
fontSize: this.textFontSize,
|
||||
fontFamily: 'IBM Plex Mono',
|
||||
color: this.textColor
|
||||
});
|
||||
this.textCanvas.resize();
|
||||
this.textCanvas.render();
|
||||
|
||||
this.texture = new THREE.CanvasTexture(this.textCanvas.texture);
|
||||
this.texture.minFilter = THREE.NearestFilter;
|
||||
|
||||
const textAspect = this.textCanvas.width / this.textCanvas.height;
|
||||
const baseH = this.planeBaseHeight;
|
||||
const planeW = baseH * textAspect;
|
||||
const planeH = baseH;
|
||||
|
||||
this.geometry = new THREE.PlaneGeometry(planeW, planeH, 36, 36);
|
||||
this.material = new THREE.ShaderMaterial({
|
||||
vertexShader,
|
||||
fragmentShader,
|
||||
transparent: true,
|
||||
uniforms: {
|
||||
uTime: { value: 0 },
|
||||
mouse: { value: 1.0 },
|
||||
uTexture: { value: this.texture },
|
||||
uEnableWaves: { value: this.enableWaves ? 1.0 : 0.0 }
|
||||
}
|
||||
});
|
||||
|
||||
this.mesh = new THREE.Mesh(this.geometry, this.material);
|
||||
this.scene.add(this.mesh);
|
||||
}
|
||||
|
||||
setRenderer() {
|
||||
this.renderer = new THREE.WebGLRenderer({ antialias: false, alpha: true });
|
||||
this.renderer.setPixelRatio(1);
|
||||
this.renderer.setClearColor(0x000000, 0);
|
||||
|
||||
this.filter = new AsciiFilter(this.renderer, {
|
||||
fontFamily: 'IBM Plex Mono',
|
||||
fontSize: this.asciiFontSize,
|
||||
invert: true
|
||||
});
|
||||
|
||||
this.container.appendChild(this.filter.domElement);
|
||||
this.setSize(this.width, this.height);
|
||||
|
||||
this.container.addEventListener('mousemove', this.onMouseMove);
|
||||
this.container.addEventListener('touchmove', this.onMouseMove);
|
||||
}
|
||||
|
||||
setSize(w, h) {
|
||||
this.width = w;
|
||||
this.height = h;
|
||||
|
||||
this.camera.aspect = w / h;
|
||||
this.camera.updateProjectionMatrix();
|
||||
|
||||
this.filter.setSize(w, h);
|
||||
|
||||
this.center = { x: w / 2, y: h / 2 };
|
||||
}
|
||||
|
||||
load() {
|
||||
this.animate();
|
||||
}
|
||||
|
||||
onMouseMove(evt) {
|
||||
const e = (evt).touches ? (evt).touches[0] : (evt);
|
||||
const bounds = this.container.getBoundingClientRect();
|
||||
const x = e.clientX - bounds.left;
|
||||
const y = e.clientY - bounds.top;
|
||||
this.mouse = { x, y };
|
||||
}
|
||||
|
||||
animate() {
|
||||
const animateFrame = () => {
|
||||
this.animationFrameId = requestAnimationFrame(animateFrame);
|
||||
this.render();
|
||||
};
|
||||
animateFrame();
|
||||
}
|
||||
|
||||
render() {
|
||||
const time = new Date().getTime() * 0.001;
|
||||
|
||||
this.textCanvas.render();
|
||||
this.texture.needsUpdate = true;
|
||||
|
||||
(this.mesh.material).uniforms.uTime.value = Math.sin(time);
|
||||
|
||||
this.updateRotation();
|
||||
this.filter.render(this.scene, this.camera);
|
||||
}
|
||||
|
||||
updateRotation() {
|
||||
if (!this.enableMouseRotation) return;
|
||||
|
||||
const x = map(this.mouse.y, 0, this.height, 0.5, -0.5);
|
||||
const y = map(this.mouse.x, 0, this.width, -0.5, 0.5);
|
||||
|
||||
this.mesh.rotation.x += (x - this.mesh.rotation.x) * 0.05;
|
||||
this.mesh.rotation.y += (y - this.mesh.rotation.y) * 0.05;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.scene.traverse(object => {
|
||||
const obj = object;
|
||||
if (!obj.isMesh) return;
|
||||
[obj.material].flat().forEach(material => {
|
||||
material.dispose();
|
||||
Object.keys(material).forEach(key => {
|
||||
const matProp = material[key];
|
||||
if (matProp && typeof matProp === 'object' && 'dispose' in matProp && typeof matProp.dispose === 'function') {
|
||||
matProp.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
obj.geometry.dispose();
|
||||
});
|
||||
this.scene.clear();
|
||||
}
|
||||
|
||||
dispose() {
|
||||
cancelAnimationFrame(this.animationFrameId);
|
||||
this.filter.dispose();
|
||||
this.container.removeChild(this.filter.domElement);
|
||||
this.container.removeEventListener('mousemove', this.onMouseMove);
|
||||
this.container.removeEventListener('touchmove', this.onMouseMove);
|
||||
this.clear();
|
||||
this.renderer.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
export default function ASCIIText({
|
||||
text = 'David!',
|
||||
asciiFontSize = 8,
|
||||
textFontSize = 200,
|
||||
textColor = '#fdf9f3',
|
||||
planeBaseHeight = 8,
|
||||
enableWaves = true,
|
||||
enableMouseRotation = true
|
||||
}) {
|
||||
const containerRef = useRef(null);
|
||||
const asciiRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const { width, height } = containerRef.current.getBoundingClientRect();
|
||||
|
||||
if (width === 0 || height === 0) {
|
||||
const observer = new IntersectionObserver(([entry]) => {
|
||||
if (entry.isIntersecting && entry.boundingClientRect.width > 0 && entry.boundingClientRect.height > 0) {
|
||||
const { width: w, height: h } = entry.boundingClientRect;
|
||||
|
||||
asciiRef.current = new CanvAscii({
|
||||
text,
|
||||
asciiFontSize,
|
||||
textFontSize,
|
||||
textColor,
|
||||
planeBaseHeight,
|
||||
enableWaves,
|
||||
enableMouseRotation
|
||||
}, containerRef.current, w, h);
|
||||
asciiRef.current.load();
|
||||
|
||||
observer.disconnect();
|
||||
}
|
||||
}, { threshold: 0.1 });
|
||||
|
||||
observer.observe(containerRef.current);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
if (asciiRef.current) {
|
||||
asciiRef.current.dispose();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
asciiRef.current = new CanvAscii({
|
||||
text,
|
||||
asciiFontSize,
|
||||
textFontSize,
|
||||
textColor,
|
||||
planeBaseHeight,
|
||||
enableWaves,
|
||||
enableMouseRotation
|
||||
}, containerRef.current, width, height);
|
||||
asciiRef.current.load();
|
||||
|
||||
const ro = new ResizeObserver(entries => {
|
||||
if (!entries[0] || !asciiRef.current) return;
|
||||
const { width: w, height: h } = entries[0].contentRect;
|
||||
if (w > 0 && h > 0) {
|
||||
asciiRef.current.setSize(w, h);
|
||||
}
|
||||
});
|
||||
ro.observe(containerRef.current);
|
||||
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
if (asciiRef.current) {
|
||||
asciiRef.current.dispose();
|
||||
}
|
||||
};
|
||||
}, [text, asciiFontSize, textFontSize, textColor, planeBaseHeight, enableWaves, enableMouseRotation]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="ascii-text-container"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
height: '100%'
|
||||
}}>
|
||||
<style>{`
|
||||
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@500&display=swap');
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ascii-text-container canvas {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
image-rendering: optimizeSpeed;
|
||||
image-rendering: -moz-crisp-edges;
|
||||
image-rendering: -o-crisp-edges;
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
image-rendering: optimize-contrast;
|
||||
image-rendering: crisp-edges;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.ascii-text-container pre {
|
||||
margin: 0;
|
||||
user-select: none;
|
||||
padding: 0;
|
||||
line-height: 1em;
|
||||
text-align: left;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
background-image: radial-gradient(circle, #ff6188 0%, #fc9867 50%, #ffd866 100%);
|
||||
background-attachment: fixed;
|
||||
-webkit-text-fill-color: transparent;
|
||||
-webkit-background-clip: text;
|
||||
z-index: 9;
|
||||
mix-blend-mode: difference;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,274 +0,0 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Renderer, Program, Mesh, Triangle, Vec3 } from 'ogl';
|
||||
|
||||
export default function Orb({
|
||||
hue = 0,
|
||||
hoverIntensity = 0.2,
|
||||
rotateOnHover = true,
|
||||
forceHoverState = false
|
||||
}) {
|
||||
const ctnDom = useRef(null);
|
||||
|
||||
const vert = /* glsl */ `
|
||||
precision highp float;
|
||||
attribute vec2 position;
|
||||
attribute vec2 uv;
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = vec4(position, 0.0, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const frag = /* glsl */ `
|
||||
precision highp float;
|
||||
|
||||
uniform float iTime;
|
||||
uniform vec3 iResolution;
|
||||
uniform float hue;
|
||||
uniform float hover;
|
||||
uniform float rot;
|
||||
uniform float hoverIntensity;
|
||||
varying vec2 vUv;
|
||||
|
||||
vec3 rgb2yiq(vec3 c) {
|
||||
float y = dot(c, vec3(0.299, 0.587, 0.114));
|
||||
float i = dot(c, vec3(0.596, -0.274, -0.322));
|
||||
float q = dot(c, vec3(0.211, -0.523, 0.312));
|
||||
return vec3(y, i, q);
|
||||
}
|
||||
|
||||
vec3 yiq2rgb(vec3 c) {
|
||||
float r = c.x + 0.956 * c.y + 0.621 * c.z;
|
||||
float g = c.x - 0.272 * c.y - 0.647 * c.z;
|
||||
float b = c.x - 1.106 * c.y + 1.703 * c.z;
|
||||
return vec3(r, g, b);
|
||||
}
|
||||
|
||||
vec3 adjustHue(vec3 color, float hueDeg) {
|
||||
float hueRad = hueDeg * 3.14159265 / 180.0;
|
||||
vec3 yiq = rgb2yiq(color);
|
||||
float cosA = cos(hueRad);
|
||||
float sinA = sin(hueRad);
|
||||
float i = yiq.y * cosA - yiq.z * sinA;
|
||||
float q = yiq.y * sinA + yiq.z * cosA;
|
||||
yiq.y = i;
|
||||
yiq.z = q;
|
||||
return yiq2rgb(yiq);
|
||||
}
|
||||
|
||||
vec3 hash33(vec3 p3) {
|
||||
p3 = fract(p3 * vec3(0.1031, 0.11369, 0.13787));
|
||||
p3 += dot(p3, p3.yxz + 19.19);
|
||||
return -1.0 + 2.0 * fract(vec3(
|
||||
p3.x + p3.y,
|
||||
p3.x + p3.z,
|
||||
p3.y + p3.z
|
||||
) * p3.zyx);
|
||||
}
|
||||
|
||||
float snoise3(vec3 p) {
|
||||
const float K1 = 0.333333333;
|
||||
const float K2 = 0.166666667;
|
||||
vec3 i = floor(p + (p.x + p.y + p.z) * K1);
|
||||
vec3 d0 = p - (i - (i.x + i.y + i.z) * K2);
|
||||
vec3 e = step(vec3(0.0), d0 - d0.yzx);
|
||||
vec3 i1 = e * (1.0 - e.zxy);
|
||||
vec3 i2 = 1.0 - e.zxy * (1.0 - e);
|
||||
vec3 d1 = d0 - (i1 - K2);
|
||||
vec3 d2 = d0 - (i2 - K1);
|
||||
vec3 d3 = d0 - 0.5;
|
||||
vec4 h = max(0.6 - vec4(
|
||||
dot(d0, d0),
|
||||
dot(d1, d1),
|
||||
dot(d2, d2),
|
||||
dot(d3, d3)
|
||||
), 0.0);
|
||||
vec4 n = h * h * h * h * vec4(
|
||||
dot(d0, hash33(i)),
|
||||
dot(d1, hash33(i + i1)),
|
||||
dot(d2, hash33(i + i2)),
|
||||
dot(d3, hash33(i + 1.0))
|
||||
);
|
||||
return dot(vec4(31.316), n);
|
||||
}
|
||||
|
||||
vec4 extractAlpha(vec3 colorIn) {
|
||||
float a = max(max(colorIn.r, colorIn.g), colorIn.b);
|
||||
return vec4(colorIn.rgb / (a + 1e-5), a);
|
||||
}
|
||||
|
||||
const vec3 baseColor1 = vec3(0.611765, 0.262745, 0.996078);
|
||||
const vec3 baseColor2 = vec3(0.298039, 0.760784, 0.913725);
|
||||
const vec3 baseColor3 = vec3(0.062745, 0.078431, 0.600000);
|
||||
const float innerRadius = 0.6;
|
||||
const float noiseScale = 0.65;
|
||||
|
||||
float light1(float intensity, float attenuation, float dist) {
|
||||
return intensity / (1.0 + dist * attenuation);
|
||||
}
|
||||
|
||||
float light2(float intensity, float attenuation, float dist) {
|
||||
return intensity / (1.0 + dist * dist * attenuation);
|
||||
}
|
||||
|
||||
vec4 draw(vec2 uv) {
|
||||
vec3 color1 = adjustHue(baseColor1, hue);
|
||||
vec3 color2 = adjustHue(baseColor2, hue);
|
||||
vec3 color3 = adjustHue(baseColor3, hue);
|
||||
|
||||
float ang = atan(uv.y, uv.x);
|
||||
float len = length(uv);
|
||||
float invLen = len > 0.0 ? 1.0 / len : 0.0;
|
||||
|
||||
float n0 = snoise3(vec3(uv * noiseScale, iTime * 0.5)) * 0.5 + 0.5;
|
||||
float r0 = mix(mix(innerRadius, 1.0, 0.4), mix(innerRadius, 1.0, 0.6), n0);
|
||||
float d0 = distance(uv, (r0 * invLen) * uv);
|
||||
float v0 = light1(1.0, 10.0, d0);
|
||||
v0 *= smoothstep(r0 * 1.05, r0, len);
|
||||
float cl = cos(ang + iTime * 2.0) * 0.5 + 0.5;
|
||||
|
||||
float a = iTime * -1.0;
|
||||
vec2 pos = vec2(cos(a), sin(a)) * r0;
|
||||
float d = distance(uv, pos);
|
||||
float v1 = light2(1.5, 5.0, d);
|
||||
v1 *= light1(1.0, 50.0, d0);
|
||||
|
||||
float v2 = smoothstep(1.0, mix(innerRadius, 1.0, n0 * 0.5), len);
|
||||
float v3 = smoothstep(innerRadius, mix(innerRadius, 1.0, 0.5), len);
|
||||
|
||||
vec3 col = mix(color1, color2, cl);
|
||||
col = mix(color3, col, v0);
|
||||
col = (col + v1) * v2 * v3;
|
||||
col = clamp(col, 0.0, 1.0);
|
||||
|
||||
return extractAlpha(col);
|
||||
}
|
||||
|
||||
vec4 mainImage(vec2 fragCoord) {
|
||||
vec2 center = iResolution.xy * 0.5;
|
||||
float size = min(iResolution.x, iResolution.y);
|
||||
vec2 uv = (fragCoord - center) / size * 2.0;
|
||||
|
||||
float angle = rot;
|
||||
float s = sin(angle);
|
||||
float c = cos(angle);
|
||||
uv = vec2(c * uv.x - s * uv.y, s * uv.x + c * uv.y);
|
||||
|
||||
uv.x += hover * hoverIntensity * 0.1 * sin(uv.y * 10.0 + iTime);
|
||||
uv.y += hover * hoverIntensity * 0.1 * sin(uv.x * 10.0 + iTime);
|
||||
|
||||
return draw(uv);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 fragCoord = vUv * iResolution.xy;
|
||||
vec4 col = mainImage(fragCoord);
|
||||
gl_FragColor = vec4(col.rgb * col.a, col.a);
|
||||
}
|
||||
`;
|
||||
|
||||
useEffect(() => {
|
||||
const container = ctnDom.current;
|
||||
if (!container) return;
|
||||
|
||||
const renderer = new Renderer({ alpha: true, premultipliedAlpha: false });
|
||||
const gl = renderer.gl;
|
||||
gl.clearColor(0, 0, 0, 0);
|
||||
container.appendChild(gl.canvas);
|
||||
|
||||
const geometry = new Triangle(gl);
|
||||
const program = new Program(gl, {
|
||||
vertex: vert,
|
||||
fragment: frag,
|
||||
uniforms: {
|
||||
iTime: { value: 0 },
|
||||
iResolution: {
|
||||
value: new Vec3(gl.canvas.width, gl.canvas.height, gl.canvas.width / gl.canvas.height)
|
||||
},
|
||||
hue: { value: hue },
|
||||
hover: { value: 0 },
|
||||
rot: { value: 0 },
|
||||
hoverIntensity: { value: hoverIntensity }
|
||||
}
|
||||
});
|
||||
|
||||
const mesh = new Mesh(gl, { geometry, program });
|
||||
|
||||
function resize() {
|
||||
if (!container) return;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const width = container.clientWidth;
|
||||
const height = container.clientHeight;
|
||||
renderer.setSize(width * dpr, height * dpr);
|
||||
gl.canvas.style.width = width + 'px';
|
||||
gl.canvas.style.height = height + 'px';
|
||||
program.uniforms.iResolution.value.set(gl.canvas.width, gl.canvas.height, gl.canvas.width / gl.canvas.height);
|
||||
}
|
||||
window.addEventListener('resize', resize);
|
||||
resize();
|
||||
|
||||
let targetHover = 0;
|
||||
let lastTime = 0;
|
||||
let currentRot = 0;
|
||||
const rotationSpeed = 0.3;
|
||||
|
||||
const handleMouseMove = (e) => {
|
||||
const rect = container.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
const width = rect.width;
|
||||
const height = rect.height;
|
||||
const size = Math.min(width, height);
|
||||
const centerX = width / 2;
|
||||
const centerY = height / 2;
|
||||
const uvX = ((x - centerX) / size) * 2.0;
|
||||
const uvY = ((y - centerY) / size) * 2.0;
|
||||
|
||||
if (Math.sqrt(uvX * uvX + uvY * uvY) < 0.8) {
|
||||
targetHover = 1;
|
||||
} else {
|
||||
targetHover = 0;
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
targetHover = 0;
|
||||
};
|
||||
|
||||
container.addEventListener('mousemove', handleMouseMove);
|
||||
container.addEventListener('mouseleave', handleMouseLeave);
|
||||
|
||||
let rafId;
|
||||
const update = (t) => {
|
||||
rafId = requestAnimationFrame(update);
|
||||
const dt = (t - lastTime) * 0.001;
|
||||
lastTime = t;
|
||||
program.uniforms.iTime.value = t * 0.001;
|
||||
program.uniforms.hue.value = hue;
|
||||
program.uniforms.hoverIntensity.value = hoverIntensity;
|
||||
|
||||
const effectiveHover = forceHoverState ? 1 : targetHover;
|
||||
program.uniforms.hover.value += (effectiveHover - program.uniforms.hover.value) * 0.1;
|
||||
|
||||
if (rotateOnHover && effectiveHover > 0.5) {
|
||||
currentRot += dt * rotationSpeed;
|
||||
}
|
||||
program.uniforms.rot.value = currentRot;
|
||||
|
||||
renderer.render({ scene: mesh });
|
||||
};
|
||||
rafId = requestAnimationFrame(update);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(rafId);
|
||||
window.removeEventListener('resize', resize);
|
||||
container.removeEventListener('mousemove', handleMouseMove);
|
||||
container.removeEventListener('mouseleave', handleMouseLeave);
|
||||
container.removeChild(gl.canvas);
|
||||
gl.getExtension('WEBGL_lose_context')?.loseContext();
|
||||
};
|
||||
}, [hue, hoverIntensity, rotateOnHover, forceHoverState]);
|
||||
|
||||
return <div ref={ctnDom} className="w-full h-full" />;
|
||||
}
|
||||
@@ -1,987 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import Orb from './Orb';
|
||||
import ASCIIText from './ASCIIText';
|
||||
|
||||
const DUMMY_DATA = {
|
||||
title: 'Session Memory Processing',
|
||||
subtitle: 'Compressing conversation context into semantic memories',
|
||||
memories: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'First Memory',
|
||||
subtitle: 'Initial context capture',
|
||||
facts: ['Fact 1', 'Fact 2', 'Fact 3'],
|
||||
concepts: ['concept1', 'concept2']
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Second Memory',
|
||||
subtitle: 'Additional context',
|
||||
facts: ['Fact A', 'Fact B'],
|
||||
concepts: ['concept3']
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Third Memory',
|
||||
subtitle: 'More context',
|
||||
facts: ['Fact X', 'Fact Y', 'Fact Z'],
|
||||
concepts: ['concept4', 'concept5', 'concept6']
|
||||
}
|
||||
],
|
||||
overview: 'This session involved implementing a progressive UI visualization system for memory processing. The user requested a session card component with four distinct states showing the evolution from empty state through memory accumulation to final overview completion.'
|
||||
};
|
||||
|
||||
export default function OverviewCard({
|
||||
debugMode = true,
|
||||
initialState = 'empty',
|
||||
sessionData = null // { overview, memories }
|
||||
}) {
|
||||
const [uiState, setUiState] = useState(initialState);
|
||||
const [orbOpacity, setOrbOpacity] = useState(0);
|
||||
const [titleOpacity, setTitleOpacity] = useState(0);
|
||||
const [asciiFontSize, setAsciiFontSize] = useState(64);
|
||||
const [cardOpacity, setCardOpacity] = useState(0);
|
||||
const [titlePosition, setTitlePosition] = useState('center'); // 'center' or 'top'
|
||||
const [visibleMemories, setVisibleMemories] = useState(0);
|
||||
const [overviewOpacity, setOverviewOpacity] = useState(0);
|
||||
const [expandedMemoryId, setExpandedMemoryId] = useState(null); // null = show overview, number = show expanded memory
|
||||
const [selectedSessionId, setSelectedSessionId] = useState(null);
|
||||
const [sessions, setSessions] = useState([]);
|
||||
const [loadedSessionData, setLoadedSessionData] = useState(null);
|
||||
|
||||
// Use provided sessionData or loaded session data or fallback to dummy data
|
||||
const data = sessionData || loadedSessionData || DUMMY_DATA;
|
||||
|
||||
// Orb parameters
|
||||
const [orbHue, setOrbHue] = useState(0);
|
||||
const [orbHoverIntensity, setOrbHoverIntensity] = useState(0.05);
|
||||
const [orbRotateOnHover, setOrbRotateOnHover] = useState(false);
|
||||
const [orbForceHoverState, setOrbForceHoverState] = useState(false);
|
||||
|
||||
// Load settings from localStorage or use defaults
|
||||
const loadSetting = (key, defaultValue) => {
|
||||
const saved = localStorage.getItem(`overviewCard_${key}`);
|
||||
return saved !== null ? JSON.parse(saved) : defaultValue;
|
||||
};
|
||||
|
||||
// ASCIIText parameters - Title
|
||||
const [asciiText, setAsciiText] = useState(() => loadSetting('asciiText', DUMMY_DATA.title));
|
||||
const [asciiTitleFontSize, setAsciiTitleFontSize] = useState(() => loadSetting('asciiTitleFontSize', 12));
|
||||
const [asciiTitleTextFontSize, setAsciiTitleTextFontSize] = useState(() => loadSetting('asciiTitleTextFontSize', 200));
|
||||
const [asciiTitleColor, setAsciiTitleColor] = useState(() => loadSetting('asciiTitleColor', '#60a5fa'));
|
||||
const [asciiTitlePlaneHeight, setAsciiTitlePlaneHeight] = useState(() => loadSetting('asciiTitlePlaneHeight', 8));
|
||||
const [asciiTitleEnableWaves, setAsciiTitleEnableWaves] = useState(() => loadSetting('asciiTitleEnableWaves', false));
|
||||
const [asciiTitleEnableMouseRotation, setAsciiTitleEnableMouseRotation] = useState(() => loadSetting('asciiTitleEnableMouseRotation', false));
|
||||
const [asciiTitleOffsetY, setAsciiTitleOffsetY] = useState(() => loadSetting('asciiTitleOffsetY', 0));
|
||||
|
||||
// ASCIIText parameters - Subtitle
|
||||
const [asciiSubtitle, setAsciiSubtitle] = useState(() => loadSetting('asciiSubtitle', DUMMY_DATA.subtitle));
|
||||
const [asciiSubtitleFontSize, setAsciiSubtitleFontSize] = useState(() => loadSetting('asciiSubtitleFontSize', 6));
|
||||
const [asciiSubtitleTextFontSize, setAsciiSubtitleTextFontSize] = useState(() => loadSetting('asciiSubtitleTextFontSize', 120));
|
||||
const [asciiSubtitleColor, setAsciiSubtitleColor] = useState(() => loadSetting('asciiSubtitleColor', '#60a5fa'));
|
||||
const [asciiSubtitlePlaneHeight, setAsciiSubtitlePlaneHeight] = useState(() => loadSetting('asciiSubtitlePlaneHeight', 4.8));
|
||||
const [asciiSubtitleEnableWaves, setAsciiSubtitleEnableWaves] = useState(() => loadSetting('asciiSubtitleEnableWaves', false));
|
||||
const [asciiSubtitleEnableMouseRotation, setAsciiSubtitleEnableMouseRotation] = useState(() => loadSetting('asciiSubtitleEnableMouseRotation', false));
|
||||
const [asciiSubtitleOffsetY, setAsciiSubtitleOffsetY] = useState(() => loadSetting('asciiSubtitleOffsetY', 0));
|
||||
|
||||
// Debug panel section expansion state
|
||||
const [sectionsExpanded, setSectionsExpanded] = useState({
|
||||
animation: true,
|
||||
orb: false,
|
||||
asciiTitle: false,
|
||||
asciiSubtitle: false
|
||||
});
|
||||
|
||||
// Save to localStorage whenever settings change
|
||||
useEffect(() => {
|
||||
localStorage.setItem('overviewCard_asciiText', JSON.stringify(asciiText));
|
||||
}, [asciiText]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('overviewCard_asciiTitleFontSize', JSON.stringify(asciiTitleFontSize));
|
||||
}, [asciiTitleFontSize]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('overviewCard_asciiTitleTextFontSize', JSON.stringify(asciiTitleTextFontSize));
|
||||
}, [asciiTitleTextFontSize]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('overviewCard_asciiTitleColor', JSON.stringify(asciiTitleColor));
|
||||
}, [asciiTitleColor]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('overviewCard_asciiTitlePlaneHeight', JSON.stringify(asciiTitlePlaneHeight));
|
||||
}, [asciiTitlePlaneHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('overviewCard_asciiTitleEnableWaves', JSON.stringify(asciiTitleEnableWaves));
|
||||
}, [asciiTitleEnableWaves]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('overviewCard_asciiTitleEnableMouseRotation', JSON.stringify(asciiTitleEnableMouseRotation));
|
||||
}, [asciiTitleEnableMouseRotation]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('overviewCard_asciiTitleOffsetY', JSON.stringify(asciiTitleOffsetY));
|
||||
}, [asciiTitleOffsetY]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('overviewCard_asciiSubtitle', JSON.stringify(asciiSubtitle));
|
||||
}, [asciiSubtitle]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('overviewCard_asciiSubtitleFontSize', JSON.stringify(asciiSubtitleFontSize));
|
||||
}, [asciiSubtitleFontSize]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('overviewCard_asciiSubtitleTextFontSize', JSON.stringify(asciiSubtitleTextFontSize));
|
||||
}, [asciiSubtitleTextFontSize]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('overviewCard_asciiSubtitleColor', JSON.stringify(asciiSubtitleColor));
|
||||
}, [asciiSubtitleColor]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('overviewCard_asciiSubtitlePlaneHeight', JSON.stringify(asciiSubtitlePlaneHeight));
|
||||
}, [asciiSubtitlePlaneHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('overviewCard_asciiSubtitleEnableWaves', JSON.stringify(asciiSubtitleEnableWaves));
|
||||
}, [asciiSubtitleEnableWaves]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('overviewCard_asciiSubtitleEnableMouseRotation', JSON.stringify(asciiSubtitleEnableMouseRotation));
|
||||
}, [asciiSubtitleEnableMouseRotation]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('overviewCard_asciiSubtitleOffsetY', JSON.stringify(asciiSubtitleOffsetY));
|
||||
}, [asciiSubtitleOffsetY]);
|
||||
|
||||
// Fetch available sessions
|
||||
useEffect(() => {
|
||||
if (debugMode) {
|
||||
fetch('http://localhost:3001/api/sessions')
|
||||
.then(res => res.json())
|
||||
.then(data => setSessions(data))
|
||||
.catch(err => console.error('Failed to fetch sessions:', err));
|
||||
}
|
||||
}, [debugMode]);
|
||||
|
||||
// Load session data when selected
|
||||
useEffect(() => {
|
||||
if (selectedSessionId && debugMode) {
|
||||
fetch(`http://localhost:3001/api/session/${selectedSessionId}`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
// Transform data to match expected format
|
||||
const formattedData = {
|
||||
title: data.overview?.content?.split('.')[0] || 'Session Overview',
|
||||
subtitle: data.overview?.content?.substring(0, 100) || '',
|
||||
overview: data.overview?.content || '',
|
||||
memories: data.memories || []
|
||||
};
|
||||
setLoadedSessionData(formattedData);
|
||||
// Auto-transition to complete state to show the data
|
||||
if (data.memories?.length > 0) {
|
||||
setUiState('complete');
|
||||
setVisibleMemories(data.memories.length);
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('Failed to fetch session data:', err));
|
||||
}
|
||||
}, [selectedSessionId, debugMode]);
|
||||
|
||||
// State transition effects
|
||||
useEffect(() => {
|
||||
switch (uiState) {
|
||||
case 'empty':
|
||||
// Reset everything
|
||||
setOrbOpacity(0);
|
||||
setTitleOpacity(0);
|
||||
setAsciiFontSize(64);
|
||||
setCardOpacity(0);
|
||||
setTitlePosition('center');
|
||||
setVisibleMemories(0);
|
||||
setOverviewOpacity(0);
|
||||
setAsciiText(DUMMY_DATA.title);
|
||||
setAsciiSubtitle(DUMMY_DATA.subtitle);
|
||||
|
||||
// Fade in orb and title
|
||||
setTimeout(() => setOrbOpacity(1), 100);
|
||||
setTimeout(() => {
|
||||
setTitleOpacity(1);
|
||||
// Start animating font size down
|
||||
let size = 64;
|
||||
const interval = setInterval(() => {
|
||||
size -= 2;
|
||||
if (size <= 12) {
|
||||
size = 12;
|
||||
clearInterval(interval);
|
||||
}
|
||||
setAsciiFontSize(size);
|
||||
}, 30);
|
||||
}, 200);
|
||||
break;
|
||||
|
||||
case 'first-memory':
|
||||
// Card fades in, title moves to top
|
||||
setCardOpacity(1);
|
||||
setTitlePosition('top');
|
||||
setVisibleMemories(1);
|
||||
break;
|
||||
|
||||
case 'accumulating':
|
||||
// Show all memories
|
||||
setVisibleMemories(data.memories?.length || DUMMY_DATA.memories.length);
|
||||
break;
|
||||
|
||||
case 'complete':
|
||||
// Overview fades in, orb fades out, card becomes solid
|
||||
setOverviewOpacity(1);
|
||||
setOrbOpacity(0);
|
||||
// Make card fully opaque by increasing opacity even more
|
||||
setCardOpacity(1);
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}, [uiState]);
|
||||
|
||||
return (
|
||||
<div className="relative w-full min-h-screen">
|
||||
{/* Debug Controls */}
|
||||
{debugMode && (
|
||||
<div className="fixed bottom-4 right-4 z-50 bg-gray-900/95 backdrop-blur-xl border border-gray-700 rounded-xl w-96 max-h-[85vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-gray-700">
|
||||
<h3 className="text-sm font-bold text-blue-400 mb-3">Debug Controls</h3>
|
||||
|
||||
{/* Session Selector */}
|
||||
<div className="mb-3">
|
||||
<label className="text-xs text-gray-400 mb-1 block">Load Real Session</label>
|
||||
<select
|
||||
value={selectedSessionId || ''}
|
||||
onChange={(e) => setSelectedSessionId(e.target.value || null)}
|
||||
className="w-full px-2 py-1.5 rounded bg-gray-800 border border-gray-700 text-xs text-gray-300"
|
||||
>
|
||||
<option value="">-- Dummy Data --</option>
|
||||
{sessions.map((session) => (
|
||||
<option key={session.session_id} value={session.session_id}>
|
||||
{session.project} - {new Date(session.created_at).toLocaleDateString()}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* State Buttons - 2x2 Grid */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
onClick={() => setUiState('empty')}
|
||||
className={`px-2 py-1.5 rounded text-xs font-medium transition-all ${
|
||||
uiState === 'empty'
|
||||
? 'bg-blue-500/30 border border-blue-400/60 text-blue-200'
|
||||
: 'bg-gray-800/50 border border-gray-700 text-gray-300 hover:bg-gray-700/50'
|
||||
}`}
|
||||
>
|
||||
1. Empty
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setUiState('first-memory')}
|
||||
className={`px-2 py-1.5 rounded text-xs font-medium transition-all ${
|
||||
uiState === 'first-memory'
|
||||
? 'bg-blue-500/30 border border-blue-400/60 text-blue-200'
|
||||
: 'bg-gray-800/50 border border-gray-700 text-gray-300 hover:bg-gray-700/50'
|
||||
}`}
|
||||
>
|
||||
2. First
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setUiState('accumulating')}
|
||||
className={`px-2 py-1.5 rounded text-xs font-medium transition-all ${
|
||||
uiState === 'accumulating'
|
||||
? 'bg-blue-500/30 border border-blue-400/60 text-blue-200'
|
||||
: 'bg-gray-800/50 border border-gray-700 text-gray-300 hover:bg-gray-700/50'
|
||||
}`}
|
||||
>
|
||||
3. Accum
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setUiState('complete')}
|
||||
className={`px-2 py-1.5 rounded text-xs font-medium transition-all ${
|
||||
uiState === 'complete'
|
||||
? 'bg-blue-500/30 border border-blue-400/60 text-blue-200'
|
||||
: 'bg-gray-800/50 border border-gray-700 text-gray-300 hover:bg-gray-700/50'
|
||||
}`}
|
||||
>
|
||||
4. Complete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<div className="overflow-y-auto flex-1 p-4 space-y-2">
|
||||
|
||||
{/* Animation State Section */}
|
||||
<div className="border border-gray-700 rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => setSectionsExpanded(s => ({ ...s, animation: !s.animation }))}
|
||||
className="w-full px-3 py-2 bg-gray-800/30 hover:bg-gray-800/50 transition-colors flex items-center justify-between text-left"
|
||||
>
|
||||
<span className="text-xs font-bold text-purple-400">Animation State</span>
|
||||
<span className="text-xs text-gray-500">{sectionsExpanded.animation ? '▼' : '▶'}</span>
|
||||
</button>
|
||||
{sectionsExpanded.animation && (
|
||||
<div className="p-3 space-y-2 bg-gray-800/10">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-gray-400">Orb Opacity</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={orbOpacity}
|
||||
onChange={(e) => setOrbOpacity(parseFloat(e.target.value))}
|
||||
className="w-32"
|
||||
/>
|
||||
<span className="text-xs text-gray-500 w-10 text-right">{orbOpacity.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-gray-400">Title Opacity</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={titleOpacity}
|
||||
onChange={(e) => setTitleOpacity(parseFloat(e.target.value))}
|
||||
className="w-32"
|
||||
/>
|
||||
<span className="text-xs text-gray-500 w-10 text-right">{titleOpacity.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-gray-400">Card Opacity</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={cardOpacity}
|
||||
onChange={(e) => setCardOpacity(parseFloat(e.target.value))}
|
||||
className="w-32"
|
||||
/>
|
||||
<span className="text-xs text-gray-500 w-10 text-right">{cardOpacity.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-gray-400">Overview Opacity</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={overviewOpacity}
|
||||
onChange={(e) => setOverviewOpacity(parseFloat(e.target.value))}
|
||||
className="w-32"
|
||||
/>
|
||||
<span className="text-xs text-gray-500 w-10 text-right">{overviewOpacity.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-gray-400">Title Position</label>
|
||||
<select
|
||||
value={titlePosition}
|
||||
onChange={(e) => setTitlePosition(e.target.value)}
|
||||
className="px-2 py-1 rounded bg-gray-800 border border-gray-700 text-xs text-gray-300"
|
||||
>
|
||||
<option value="center">Center</option>
|
||||
<option value="top">Top</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-gray-400">Visible Memories</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={data.memories?.length || 0}
|
||||
step="1"
|
||||
value={visibleMemories}
|
||||
onChange={(e) => setVisibleMemories(parseInt(e.target.value))}
|
||||
className="w-32"
|
||||
/>
|
||||
<span className="text-xs text-gray-500 w-10 text-right">{visibleMemories}/{data.memories?.length || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Orb Parameters Section */}
|
||||
<div className="border border-gray-700 rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => setSectionsExpanded(s => ({ ...s, orb: !s.orb }))}
|
||||
className="w-full px-3 py-2 bg-gray-800/30 hover:bg-gray-800/50 transition-colors flex items-center justify-between text-left"
|
||||
>
|
||||
<span className="text-xs font-bold text-blue-400">Orb Parameters</span>
|
||||
<span className="text-xs text-gray-500">{sectionsExpanded.orb ? '▼' : '▶'}</span>
|
||||
</button>
|
||||
{sectionsExpanded.orb && (
|
||||
<div className="p-3 space-y-2 bg-gray-800/10">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-gray-400">Hue</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min="-180"
|
||||
max="180"
|
||||
step="1"
|
||||
value={orbHue}
|
||||
onChange={(e) => setOrbHue(parseFloat(e.target.value))}
|
||||
className="w-32"
|
||||
/>
|
||||
<span className="text-xs text-gray-500 w-10 text-right">{orbHue}°</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-gray-400">Hover Intensity</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={orbHoverIntensity}
|
||||
onChange={(e) => setOrbHoverIntensity(parseFloat(e.target.value))}
|
||||
className="w-32"
|
||||
/>
|
||||
<span className="text-xs text-gray-500 w-10 text-right">{orbHoverIntensity.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-gray-400 flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={orbRotateOnHover}
|
||||
onChange={(e) => setOrbRotateOnHover(e.target.checked)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
Rotate On Hover
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-gray-400 flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={orbForceHoverState}
|
||||
onChange={(e) => setOrbForceHoverState(e.target.checked)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
Force Hover State
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ASCII Title Parameters Section */}
|
||||
<div className="border border-gray-700 rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => setSectionsExpanded(s => ({ ...s, asciiTitle: !s.asciiTitle }))}
|
||||
className="w-full px-3 py-2 bg-gray-800/30 hover:bg-gray-800/50 transition-colors flex items-center justify-between text-left"
|
||||
>
|
||||
<span className="text-xs font-bold text-emerald-400">ASCII Title</span>
|
||||
<span className="text-xs text-gray-500">{sectionsExpanded.asciiTitle ? '▼' : '▶'}</span>
|
||||
</button>
|
||||
{sectionsExpanded.asciiTitle && (
|
||||
<div className="p-3 space-y-2 bg-gray-800/10">
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 mb-1 block">Text</label>
|
||||
<textarea
|
||||
value={asciiText}
|
||||
onChange={(e) => setAsciiText(e.target.value)}
|
||||
rows={2}
|
||||
className="w-full px-2 py-1 rounded bg-gray-800 border border-gray-700 text-xs text-gray-300 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-gray-400">ASCII Font Size</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min="4"
|
||||
max="64"
|
||||
step="1"
|
||||
value={asciiTitleFontSize}
|
||||
onChange={(e) => setAsciiTitleFontSize(parseInt(e.target.value))}
|
||||
className="w-32"
|
||||
/>
|
||||
<span className="text-xs text-gray-500 w-10 text-right">{asciiTitleFontSize}px</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-gray-400">Text Font Size</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min="50"
|
||||
max="400"
|
||||
step="10"
|
||||
value={asciiTitleTextFontSize}
|
||||
onChange={(e) => setAsciiTitleTextFontSize(parseInt(e.target.value))}
|
||||
className="w-32"
|
||||
/>
|
||||
<span className="text-xs text-gray-500 w-10 text-right">{asciiTitleTextFontSize}px</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 mb-1 block">Color</label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<input
|
||||
type="color"
|
||||
value={asciiTitleColor}
|
||||
onChange={(e) => setAsciiTitleColor(e.target.value)}
|
||||
className="w-8 h-8 rounded border border-gray-700 bg-gray-800 cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={asciiTitleColor}
|
||||
onChange={(e) => setAsciiTitleColor(e.target.value)}
|
||||
className="flex-1 px-2 py-1 rounded bg-gray-800 border border-gray-700 text-xs text-gray-300"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-gray-400">Plane Height</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="20"
|
||||
step="0.5"
|
||||
value={asciiTitlePlaneHeight}
|
||||
onChange={(e) => setAsciiTitlePlaneHeight(parseFloat(e.target.value))}
|
||||
className="w-32"
|
||||
/>
|
||||
<span className="text-xs text-gray-500 w-10 text-right">{asciiTitlePlaneHeight}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-gray-400">Y Offset</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min="-500"
|
||||
max="500"
|
||||
step="10"
|
||||
value={asciiTitleOffsetY}
|
||||
onChange={(e) => setAsciiTitleOffsetY(parseInt(e.target.value))}
|
||||
className="w-32"
|
||||
/>
|
||||
<span className="text-xs text-gray-500 w-10 text-right">{asciiTitleOffsetY}px</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-gray-400 flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={asciiTitleEnableWaves}
|
||||
onChange={(e) => setAsciiTitleEnableWaves(e.target.checked)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
Enable Waves
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-gray-400 flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={asciiTitleEnableMouseRotation}
|
||||
onChange={(e) => setAsciiTitleEnableMouseRotation(e.target.checked)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
Mouse Rotation
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ASCII Subtitle Parameters Section */}
|
||||
<div className="border border-gray-700 rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => setSectionsExpanded(s => ({ ...s, asciiSubtitle: !s.asciiSubtitle }))}
|
||||
className="w-full px-3 py-2 bg-gray-800/30 hover:bg-gray-800/50 transition-colors flex items-center justify-between text-left"
|
||||
>
|
||||
<span className="text-xs font-bold text-amber-400">ASCII Subtitle</span>
|
||||
<span className="text-xs text-gray-500">{sectionsExpanded.asciiSubtitle ? '▼' : '▶'}</span>
|
||||
</button>
|
||||
{sectionsExpanded.asciiSubtitle && (
|
||||
<div className="p-3 space-y-2 bg-gray-800/10">
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 mb-1 block">Text</label>
|
||||
<textarea
|
||||
value={asciiSubtitle}
|
||||
onChange={(e) => setAsciiSubtitle(e.target.value)}
|
||||
rows={2}
|
||||
className="w-full px-2 py-1 rounded bg-gray-800 border border-gray-700 text-xs text-gray-300 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-gray-400">ASCII Font Size</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min="4"
|
||||
max="64"
|
||||
step="1"
|
||||
value={asciiSubtitleFontSize}
|
||||
onChange={(e) => setAsciiSubtitleFontSize(parseInt(e.target.value))}
|
||||
className="w-32"
|
||||
/>
|
||||
<span className="text-xs text-gray-500 w-10 text-right">{asciiSubtitleFontSize}px</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-gray-400">Text Font Size</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min="50"
|
||||
max="400"
|
||||
step="10"
|
||||
value={asciiSubtitleTextFontSize}
|
||||
onChange={(e) => setAsciiSubtitleTextFontSize(parseInt(e.target.value))}
|
||||
className="w-32"
|
||||
/>
|
||||
<span className="text-xs text-gray-500 w-10 text-right">{asciiSubtitleTextFontSize}px</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 mb-1 block">Color</label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<input
|
||||
type="color"
|
||||
value={asciiSubtitleColor}
|
||||
onChange={(e) => setAsciiSubtitleColor(e.target.value)}
|
||||
className="w-8 h-8 rounded border border-gray-700 bg-gray-800 cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={asciiSubtitleColor}
|
||||
onChange={(e) => setAsciiSubtitleColor(e.target.value)}
|
||||
className="flex-1 px-2 py-1 rounded bg-gray-800 border border-gray-700 text-xs text-gray-300"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-gray-400">Plane Height</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="20"
|
||||
step="0.5"
|
||||
value={asciiSubtitlePlaneHeight}
|
||||
onChange={(e) => setAsciiSubtitlePlaneHeight(parseFloat(e.target.value))}
|
||||
className="w-32"
|
||||
/>
|
||||
<span className="text-xs text-gray-500 w-10 text-right">{asciiSubtitlePlaneHeight}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-gray-400">Y Offset</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min="-500"
|
||||
max="500"
|
||||
step="10"
|
||||
value={asciiSubtitleOffsetY}
|
||||
onChange={(e) => setAsciiSubtitleOffsetY(parseInt(e.target.value))}
|
||||
className="w-32"
|
||||
/>
|
||||
<span className="text-xs text-gray-500 w-10 text-right">{asciiSubtitleOffsetY}px</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-gray-400 flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={asciiSubtitleEnableWaves}
|
||||
onChange={(e) => setAsciiSubtitleEnableWaves(e.target.checked)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
Enable Waves
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-gray-400 flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={asciiSubtitleEnableMouseRotation}
|
||||
onChange={(e) => setAsciiSubtitleEnableMouseRotation(e.target.checked)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
Mouse Rotation
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Orb Background Overlay */}
|
||||
<div
|
||||
className="fixed inset-0 pointer-events-none transition-opacity duration-500"
|
||||
style={{ opacity: orbOpacity }}
|
||||
>
|
||||
<Orb
|
||||
hue={orbHue}
|
||||
hoverIntensity={orbHoverIntensity}
|
||||
rotateOnHover={orbRotateOnHover}
|
||||
forceHoverState={orbForceHoverState}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Floating Title (State 1: Empty) */}
|
||||
{titlePosition === 'center' && (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center pointer-events-none transition-opacity duration-500"
|
||||
style={{ opacity: titleOpacity }}
|
||||
>
|
||||
<div className="relative w-full flex flex-col items-center">
|
||||
<div
|
||||
className="relative w-full h-64"
|
||||
style={{ transform: `translateY(${asciiTitleOffsetY}px)` }}
|
||||
>
|
||||
<ASCIIText
|
||||
text={asciiText}
|
||||
asciiFontSize={asciiTitleFontSize}
|
||||
textFontSize={asciiTitleTextFontSize}
|
||||
textColor={asciiTitleColor}
|
||||
planeBaseHeight={asciiTitlePlaneHeight}
|
||||
enableWaves={asciiTitleEnableWaves}
|
||||
enableMouseRotation={asciiTitleEnableMouseRotation}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="relative w-full h-32"
|
||||
style={{ transform: `translateY(${asciiSubtitleOffsetY}px)` }}
|
||||
>
|
||||
<ASCIIText
|
||||
text={asciiSubtitle}
|
||||
asciiFontSize={asciiSubtitleFontSize}
|
||||
textFontSize={asciiSubtitleTextFontSize}
|
||||
textColor={asciiSubtitleColor}
|
||||
planeBaseHeight={asciiSubtitlePlaneHeight}
|
||||
enableWaves={asciiSubtitleEnableWaves}
|
||||
enableMouseRotation={asciiSubtitleEnableMouseRotation}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Session Card (States 2-4) */}
|
||||
<div
|
||||
className="max-w-6xl mx-auto px-4 py-20 transition-opacity duration-500"
|
||||
style={{ opacity: cardOpacity }}
|
||||
>
|
||||
<div className="relative">
|
||||
{/* Blur background effect */}
|
||||
<div className="absolute -inset-4 bg-gradient-to-r from-blue-600/20 via-purple-600/20 to-emerald-600/20 rounded-3xl blur-2xl" />
|
||||
|
||||
{/* Card with backdrop blur */}
|
||||
<div
|
||||
className="relative rounded-3xl p-12 border border-gray-800 transition-all duration-500"
|
||||
style={{
|
||||
backgroundColor: uiState === 'complete'
|
||||
? 'rgba(10, 10, 15, 0.95)'
|
||||
: 'rgba(10, 10, 15, 0.7)',
|
||||
backdropFilter: 'blur(20px)'
|
||||
}}
|
||||
>
|
||||
{/* Title at top of card (States 2-4) */}
|
||||
{titlePosition === 'top' && (
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl font-black text-transparent bg-clip-text bg-gradient-to-r from-blue-300 via-purple-300 to-emerald-300 mb-4 leading-tight">
|
||||
{data.title || 'Session Overview'}
|
||||
</h1>
|
||||
<p className="text-xl text-gray-400 leading-relaxed">
|
||||
{data.subtitle || ''}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overview Section (State 4: Complete) */}
|
||||
{uiState === 'complete' && data.overview && (
|
||||
<div
|
||||
className="mb-8 pb-8 border-b border-gray-800 transition-opacity duration-500"
|
||||
style={{ opacity: overviewOpacity }}
|
||||
>
|
||||
<h3 className="text-sm font-bold text-emerald-400 mb-4 flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
|
||||
SESSION OVERVIEW
|
||||
</h3>
|
||||
<p className="text-gray-300 leading-relaxed">
|
||||
{data.overview}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expanded Memory View */}
|
||||
{expandedMemoryId !== null && (
|
||||
<div>
|
||||
{/* Back Button */}
|
||||
<button
|
||||
onClick={() => setExpandedMemoryId(null)}
|
||||
className="flex items-center gap-2 mb-6 px-4 py-2 rounded-lg bg-gray-800/50 border border-gray-700 text-gray-300 hover:bg-gray-700/50 hover:border-gray-600 transition-all"
|
||||
>
|
||||
<span className="text-lg">←</span>
|
||||
<span className="text-sm font-medium">Back to Overview</span>
|
||||
</button>
|
||||
|
||||
{/* Full Memory Card */}
|
||||
{(() => {
|
||||
const memory = data.memories?.find(m => m.id === expandedMemoryId);
|
||||
if (!memory) return null;
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<h2 className="text-4xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-300 to-purple-300 mb-4">
|
||||
{memory.title}
|
||||
</h2>
|
||||
<p className="text-xl text-gray-400">
|
||||
{memory.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{memory.facts && memory.facts.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<h3 className="text-sm font-bold text-blue-400 mb-4 flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-blue-400" />
|
||||
FACTS EXTRACTED
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{memory.facts.map((fact, i) => (
|
||||
<div key={i} className="flex gap-3 text-gray-300 leading-relaxed">
|
||||
<span className="text-blue-400 font-mono text-xs mt-1">▸</span>
|
||||
<span>{fact}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{memory.concepts && memory.concepts.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-purple-400 mb-4 flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-purple-400" />
|
||||
CONCEPTS
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{memory.concepts.map((concept, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="px-3 py-1.5 rounded-lg bg-purple-500/10 border border-purple-400/30 text-purple-300 text-sm font-medium"
|
||||
>
|
||||
{concept}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Memory Mini-cards (Overview) */}
|
||||
{expandedMemoryId === null && (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{(data.memories || []).slice(0, visibleMemories).map((memory, index) => (
|
||||
<div
|
||||
key={memory.id}
|
||||
onClick={() => setExpandedMemoryId(memory.id)}
|
||||
className="border border-gray-700/50 rounded-xl p-4 bg-gray-900/30 cursor-pointer hover:bg-gray-800/40 hover:border-gray-600/50 transition-all"
|
||||
style={{
|
||||
animation: 'fadeInUp 0.5s ease-out',
|
||||
animationDelay: `${index * 0.1}s`,
|
||||
animationFillMode: 'both'
|
||||
}}
|
||||
>
|
||||
<h3 className="text-base font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-300 to-purple-300 mb-2">
|
||||
{memory.title}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-400 line-clamp-2 mb-3">
|
||||
{memory.subtitle}
|
||||
</p>
|
||||
|
||||
{/* Preview badges */}
|
||||
<div className="flex gap-2">
|
||||
{memory.facts && memory.facts.length > 0 && (
|
||||
<span className="px-2 py-0.5 rounded text-xs bg-blue-500/10 border border-blue-400/30 text-blue-300">
|
||||
{memory.facts.length} facts
|
||||
</span>
|
||||
)}
|
||||
{memory.concepts && memory.concepts.length > 0 && (
|
||||
<span className="px-2 py-0.5 rounded text-xs bg-purple-500/10 border border-purple-400/30 text-purple-300">
|
||||
{memory.concepts.length} concepts
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src')
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 5173
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user