feat: Go backend, enhanced search, new widgets, Docker deploy

Major changes:
- Add Go backend (backend/) with microservices architecture
- Enhanced master-agents-svc: reranker, content-classifier, stealth-crawler,
  proxy-manager, media-search, fastClassifier, language detection
- New web-svc widgets: KnowledgeCard, ProductCard, ProfileCard, VideoCard,
  UnifiedCard, CardGallery, InlineImageGallery, SourcesPanel, RelatedQuestions
- Improved discover-svc with discover-db integration
- Docker deployment improvements (Caddyfile, vendor.sh, BUILD.md)
- Library-svc: project_id schema migration
- Remove deprecated finance-svc and travel-svc
- Localization improvements across services

Made-with: Cursor
This commit is contained in:
home
2026-02-27 04:15:32 +03:00
parent 328d968f3f
commit 06fe57c765
285 changed files with 53132 additions and 1871 deletions

View File

@@ -0,0 +1,208 @@
'use client';
import { useState, useRef, useCallback, KeyboardEvent } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Send,
Paperclip,
Sparkles,
Zap,
Scale,
ChevronDown,
Square,
Globe,
Image as ImageIcon,
ArrowUp,
} from 'lucide-react';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
type Mode = 'speed' | 'balanced' | 'quality';
interface ChatInputProps {
onSend: (message: string, mode: Mode) => void;
onStop?: () => void;
isLoading?: boolean;
placeholder?: string;
autoFocus?: boolean;
}
const modes: { value: Mode; label: string; icon: typeof Zap; desc: string }[] = [
{ value: 'speed', label: 'Быстрый', icon: Zap, desc: '~10 сек' },
{ value: 'balanced', label: 'Баланс', icon: Scale, desc: '~30 сек' },
{ value: 'quality', label: 'Качество', icon: Sparkles, desc: '~60 сек' },
];
export function ChatInput({ onSend, onStop, isLoading, placeholder, autoFocus }: ChatInputProps) {
const [message, setMessage] = useState('');
const [mode, setMode] = useState<Mode>('balanced');
const [isFocused, setIsFocused] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const handleSubmit = useCallback(() => {
if (message.trim() && !isLoading) {
onSend(message.trim(), mode);
setMessage('');
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
}
}
}, [message, mode, isLoading, onSend]);
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
};
const handleInput = () => {
const textarea = textareaRef.current;
if (textarea) {
textarea.style.height = 'auto';
textarea.style.height = `${Math.min(textarea.scrollHeight, 180)}px`;
}
};
const currentMode = modes.find((m) => m.value === mode)!;
return (
<div className="relative w-full">
<motion.div
animate={{
borderColor: isFocused ? 'hsl(239 84% 67% / 0.4)' : 'hsl(240 4% 16% / 0.8)',
}}
transition={{ duration: 0.15 }}
className={`
relative bg-elevated/60 backdrop-blur-xl border rounded-2xl overflow-hidden
shadow-card transition-shadow duration-200
${isFocused ? 'shadow-glow-sm' : ''}
`}
>
{/* Main Input Area */}
<div className="flex items-end gap-3 p-4 pb-3">
<textarea
ref={textareaRef}
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={handleKeyDown}
onInput={handleInput}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
placeholder={placeholder || 'Задайте любой вопрос...'}
autoFocus={autoFocus}
className="flex-1 bg-transparent text-[15px] text-primary resize-none focus:outline-none min-h-[28px] max-h-[180px] placeholder:text-muted leading-relaxed"
rows={1}
disabled={isLoading}
/>
{/* Submit Button */}
<div className="flex items-center gap-2">
{isLoading ? (
<button
onClick={onStop}
className="w-10 h-10 flex items-center justify-center rounded-xl bg-error/10 text-error border border-error/30 hover:bg-error/20 transition-all"
>
<Square className="w-4 h-4 fill-current" />
</button>
) : (
<button
onClick={handleSubmit}
disabled={!message.trim()}
className={`
w-10 h-10 flex items-center justify-center rounded-xl transition-all duration-200
${message.trim()
? 'btn-gradient'
: 'bg-surface/50 text-muted border border-border/50'
}
`}
>
<ArrowUp className={`w-5 h-5 ${message.trim() ? 'btn-gradient-text' : ''}`} />
</button>
)}
</div>
</div>
{/* Bottom Toolbar */}
<div className="px-4 pb-3 flex items-center justify-between">
<div className="flex items-center gap-1">
{/* Mode Selector */}
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button className="flex items-center gap-2 h-8 px-3 text-xs text-secondary hover:text-primary rounded-lg hover:bg-surface/50 transition-all">
<currentMode.icon className="w-4 h-4 text-accent-muted" />
<span className="font-medium">{currentMode.label}</span>
<ChevronDown className="w-3.5 h-3.5 opacity-50" />
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
className="min-w-[180px] bg-surface/95 backdrop-blur-xl border border-border rounded-xl p-1.5 shadow-dropdown z-50"
sideOffset={8}
>
{modes.map((m) => (
<DropdownMenu.Item
key={m.value}
onClick={() => setMode(m.value)}
className={`
flex items-center gap-3 px-3 py-2.5 text-sm rounded-lg cursor-pointer outline-none transition-colors
${mode === m.value
? 'bg-accent/10 text-primary'
: 'text-secondary hover:bg-elevated/80 hover:text-primary'
}
`}
>
<m.icon className={`w-4 h-4 ${mode === m.value ? 'text-accent' : 'text-muted'}`} />
<span className="flex-1 font-medium">{m.label}</span>
<span className="text-xs text-muted">{m.desc}</span>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
{/* Separator */}
<div className="w-px h-5 bg-border/50 mx-1" />
{/* Action Buttons */}
<button
className="btn-icon w-8 h-8"
title="Веб-поиск"
>
<Globe className="w-4 h-4" />
</button>
<button
className="btn-icon w-8 h-8"
title="Прикрепить файл"
>
<Paperclip className="w-4 h-4" />
</button>
<button
className="btn-icon w-8 h-8"
title="Изображение"
>
<ImageIcon className="w-4 h-4" />
</button>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-faint">
Enter для отправки · Shift+Enter для новой строки
</span>
</div>
</div>
</motion.div>
{/* Focus Glow Effect */}
<AnimatePresence>
{isFocused && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute -inset-0.5 bg-gradient-to-r from-accent/10 via-accent/20 to-accent/10 rounded-2xl -z-10 blur-xl"
/>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,275 @@
'use client';
import { useMemo, useState } from 'react';
import { motion } from 'framer-motion';
import ReactMarkdown from 'react-markdown';
import { User, Bot, Copy, Check, ExternalLink, ThumbsUp, ThumbsDown, Share2, Sparkles } from 'lucide-react';
import * as Tooltip from '@radix-ui/react-tooltip';
import type { Message, Citation as CitationType } from '@/lib/types';
interface ChatMessageProps {
message: Message;
}
export function ChatMessage({ message }: ChatMessageProps) {
const [copied, setCopied] = useState(false);
const [feedback, setFeedback] = useState<'up' | 'down' | null>(null);
const isUser = message.role === 'user';
const handleCopy = async () => {
await navigator.clipboard.writeText(message.content);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: 'easeOut' }}
className={`group ${isUser ? 'flex justify-end' : ''}`}
>
{isUser ? (
<div className="max-w-[85%] flex items-start gap-3 flex-row-reverse">
<div className="w-9 h-9 rounded-xl bg-accent/10 border border-accent/20 flex items-center justify-center flex-shrink-0">
<User className="w-4 h-4 text-accent" />
</div>
<div className="bg-surface/60 border border-border/50 rounded-2xl rounded-tr-sm px-4 py-3">
<p className="text-[15px] text-primary leading-relaxed">{message.content}</p>
</div>
</div>
) : (
<div className="max-w-full">
{/* Assistant Header */}
<div className="flex items-center gap-2.5 mb-4">
<div className="w-9 h-9 rounded-xl bg-accent/10 border border-accent/20 flex items-center justify-center">
<Sparkles className="w-4 h-4 text-accent" />
</div>
<span className="text-sm font-medium text-secondary">GooSeek</span>
</div>
{/* Citations - Show at top like Perplexity */}
{message.citations && message.citations.length > 0 && (
<div className="mb-5">
<div className="flex flex-wrap gap-2">
{message.citations.slice(0, 6).map((citation) => (
<CitationBadge key={citation.index} citation={citation} />
))}
{message.citations.length > 6 && (
<button className="text-xs text-muted hover:text-secondary px-2.5 py-1.5 rounded-lg hover:bg-surface/40 transition-colors">
+{message.citations.length - 6} ещё
</button>
)}
</div>
</div>
)}
{/* Message Content */}
<div className="prose prose-invert max-w-none">
<ReactMarkdown
components={{
p: ({ children }) => (
<p className="mb-4 last:mb-0 text-[15px] text-primary/90 leading-[1.7]">
{children}
</p>
),
a: ({ href, children }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:text-accent-hover underline underline-offset-2 decoration-accent/30 hover:decoration-accent/50 transition-colors"
>
{children}
</a>
),
code: ({ className, children }) => {
const isInline = !className;
if (isInline) {
return (
<code className="px-1.5 py-0.5 bg-surface/80 border border-border/50 rounded-md text-[13px] text-accent-secondary font-mono">
{children}
</code>
);
}
return <code className={className}>{children}</code>;
},
pre: ({ children }) => (
<pre className="bg-elevated/80 border border-border/50 p-4 rounded-xl overflow-x-auto text-[13px] my-5 font-mono">
{children}
</pre>
),
ul: ({ children }) => (
<ul className="list-none pl-0 my-4 space-y-2.5">{children}</ul>
),
ol: ({ children }) => (
<ol className="list-none pl-0 my-4 space-y-2.5 counter-reset-item">
{children}
</ol>
),
li: ({ children }) => (
<li className="flex gap-3 text-[15px] text-primary/85">
<span className="text-accent/60 select-none mt-0.5"></span>
<span className="leading-[1.6]">{children}</span>
</li>
),
h1: ({ children }) => (
<h1 className="text-xl font-semibold text-primary mt-8 mb-4">
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="text-lg font-semibold text-primary mt-6 mb-3">
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="text-base font-medium text-primary mt-5 mb-2">
{children}
</h3>
),
blockquote: ({ children }) => (
<blockquote className="border-l-2 border-accent/40 pl-4 my-5 text-secondary italic">
{children}
</blockquote>
),
strong: ({ children }) => (
<strong className="font-semibold text-primary">{children}</strong>
),
}}
>
{message.content}
</ReactMarkdown>
{/* Streaming Cursor */}
{message.isStreaming && (
<span className="inline-block w-2 h-5 bg-accent/60 rounded-sm animate-pulse-soft ml-1" />
)}
</div>
{/* Actions */}
{!message.isStreaming && (
<div className="flex items-center gap-1 mt-5 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<ActionButton
icon={copied ? Check : Copy}
label={copied ? 'Скопировано' : 'Копировать'}
onClick={handleCopy}
active={copied}
/>
<ActionButton
icon={Share2}
label="Поделиться"
onClick={() => {}}
/>
<div className="w-px h-4 bg-border/50 mx-1.5" />
<ActionButton
icon={ThumbsUp}
label="Полезно"
onClick={() => setFeedback('up')}
active={feedback === 'up'}
/>
<ActionButton
icon={ThumbsDown}
label="Не полезно"
onClick={() => setFeedback('down')}
active={feedback === 'down'}
/>
</div>
)}
</div>
)}
</motion.div>
);
}
interface ActionButtonProps {
icon: React.ElementType;
label: string;
onClick: () => void;
active?: boolean;
}
function ActionButton({ icon: Icon, label, onClick, active }: ActionButtonProps) {
return (
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
onClick={onClick}
className={`
p-2 rounded-lg transition-all
${active
? 'text-accent bg-accent/10'
: 'text-muted hover:text-secondary hover:bg-surface/50'
}
`}
>
<Icon className="w-4 h-4" />
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
side="top"
className="px-2.5 py-1.5 text-xs text-primary bg-surface border border-border rounded-lg shadow-dropdown z-50"
sideOffset={4}
>
{label}
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}
function CitationBadge({ citation }: { citation: CitationType }) {
return (
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<a
href={citation.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-3 py-2 bg-surface/40 hover:bg-surface/60 border border-border/50 hover:border-accent/30 rounded-xl transition-all group"
>
<span className="w-5 h-5 rounded-md bg-accent/15 text-accent flex items-center justify-center text-xs font-semibold">
{citation.index}
</span>
{citation.favicon && (
<img
src={citation.favicon}
alt=""
className="w-4 h-4 rounded"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
)}
<span className="text-sm text-secondary group-hover:text-primary max-w-[140px] truncate transition-colors">
{citation.domain}
</span>
<ExternalLink className="w-3 h-3 text-muted group-hover:text-secondary transition-colors" />
</a>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
side="top"
className="max-w-[300px] p-4 bg-surface/95 backdrop-blur-xl border border-border rounded-xl shadow-dropdown z-50"
sideOffset={8}
>
<p className="font-medium text-sm text-primary line-clamp-2 mb-2">
{citation.title}
</p>
{citation.snippet && (
<p className="text-xs text-secondary line-clamp-2 mb-2">
{citation.snippet}
</p>
)}
<p className="text-xs text-muted truncate">{citation.url}</p>
<Tooltip.Arrow className="fill-surface" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}

View File

@@ -0,0 +1,146 @@
'use client';
import { ExternalLink } from 'lucide-react';
import * as Tooltip from '@radix-ui/react-tooltip';
import type { Citation as CitationType } from '@/lib/types';
interface CitationProps {
citation: CitationType;
compact?: boolean;
}
export function Citation({ citation, compact }: CitationProps) {
if (compact) {
return (
<a
href={citation.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center w-5 h-5 text-2xs font-medium bg-cream-300/10 hover:bg-cream-300/20 text-cream-300 border border-cream-400/20 rounded transition-colors"
>
{citation.index}
</a>
);
}
return (
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<a
href={citation.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-2.5 py-1.5 bg-navy-800/40 hover:bg-navy-800/60 border border-navy-700/30 hover:border-cream-400/20 rounded-lg transition-all group"
>
<span className="w-4 h-4 rounded bg-cream-300/10 text-cream-300 flex items-center justify-center text-2xs font-medium">
{citation.index}
</span>
{citation.favicon && (
<img
src={citation.favicon}
alt=""
className="w-3.5 h-3.5 rounded"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
)}
<span className="text-xs text-cream-400/80 group-hover:text-cream-200 max-w-[120px] truncate transition-colors">
{citation.domain}
</span>
<ExternalLink className="w-2.5 h-2.5 text-cream-500/50 group-hover:text-cream-400/70 transition-colors" />
</a>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
side="top"
className="max-w-[300px] p-4 bg-navy-800/95 backdrop-blur-xl border border-navy-700/50 rounded-xl shadow-xl z-50"
sideOffset={8}
>
<p className="font-medium text-sm text-cream-100 line-clamp-2 mb-2">
{citation.title}
</p>
{citation.snippet && (
<p className="text-xs text-cream-400/70 line-clamp-3 mb-3">
{citation.snippet}
</p>
)}
<div className="flex items-center gap-2 text-2xs text-cream-500/60">
{citation.favicon && (
<img
src={citation.favicon}
alt=""
className="w-3 h-3 rounded"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
)}
<span className="truncate">{citation.domain}</span>
</div>
<Tooltip.Arrow className="fill-navy-800" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}
interface CitationListProps {
citations: CitationType[];
maxVisible?: number;
}
export function CitationList({ citations, maxVisible = 6 }: CitationListProps) {
if (!citations || citations.length === 0) return null;
const visible = citations.slice(0, maxVisible);
const remaining = citations.length - maxVisible;
return (
<div className="flex flex-wrap gap-2">
{visible.map((citation) => (
<Citation key={citation.index} citation={citation} />
))}
{remaining > 0 && (
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button className="text-xs text-cream-500/70 hover:text-cream-300 px-2.5 py-1.5 rounded-lg hover:bg-navy-800/30 transition-colors">
+{remaining} ещё
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
side="top"
className="max-w-[320px] p-3 bg-navy-800/95 backdrop-blur-xl border border-navy-700/50 rounded-xl shadow-xl z-50"
sideOffset={8}
>
<div className="space-y-2">
{citations.slice(maxVisible).map((citation) => (
<a
key={citation.index}
href={citation.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 p-2 rounded-lg hover:bg-navy-700/50 transition-colors"
>
<span className="w-4 h-4 rounded bg-cream-300/10 text-cream-300 flex items-center justify-center text-2xs font-medium flex-shrink-0">
{citation.index}
</span>
<span className="text-xs text-cream-200 truncate">
{citation.title}
</span>
</a>
))}
</div>
<Tooltip.Arrow className="fill-navy-800" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
)}
</div>
);
}

View File

@@ -0,0 +1,152 @@
'use client';
import { ExternalLink, FileText, Sparkles, Clock } from 'lucide-react';
import type { DiscoverItem } from '@/lib/types';
interface DiscoverCardProps {
item: DiscoverItem;
variant?: 'large' | 'medium' | 'small';
onSummarize?: (url: string) => void;
}
export function DiscoverCard({ item, variant = 'medium', onSummarize }: DiscoverCardProps) {
const domain = item.url ? new URL(item.url).hostname.replace('www.', '') : '';
if (variant === 'large') {
return (
<article className="group relative overflow-hidden rounded-2xl bg-navy-900/40 border border-navy-700/20 hover:border-cream-400/15 transition-all duration-300">
{item.thumbnail && (
<div className="aspect-video overflow-hidden">
<img
src={item.thumbnail}
alt={item.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
</div>
)}
<div className="p-6">
<div className="flex items-center gap-2 mb-3">
<img
src={`https://www.google.com/s2/favicons?domain=${domain}&sz=16`}
alt=""
className="w-4 h-4 rounded"
/>
<span className="text-xs text-cream-500/60">{domain}</span>
{item.sourcesCount && item.sourcesCount > 1 && (
<span className="text-xs text-cream-600/40">
{item.sourcesCount} источников
</span>
)}
</div>
<h2 className="text-xl font-semibold text-cream-100 mb-3 line-clamp-2 group-hover:text-cream-50 transition-colors">
{item.title}
</h2>
<p className="text-cream-400/70 text-sm line-clamp-3 mb-5">{item.content}</p>
<div className="flex items-center gap-4">
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-sm text-cream-400/80 hover:text-cream-200 transition-colors"
>
<ExternalLink className="w-4 h-4" />
Читать
</a>
{onSummarize && (
<button
onClick={() => onSummarize(item.url)}
className="flex items-center gap-2 text-sm text-cream-300 hover:text-cream-100 transition-colors"
>
<Sparkles className="w-4 h-4" />
AI Саммари
</button>
)}
</div>
</div>
</article>
);
}
if (variant === 'small') {
return (
<article className="group flex items-start gap-3 p-3 rounded-xl hover:bg-navy-800/30 transition-colors">
{item.thumbnail && (
<img
src={item.thumbnail}
alt=""
className="w-16 h-16 rounded-lg object-cover flex-shrink-0"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 mb-1">
<img
src={`https://www.google.com/s2/favicons?domain=${domain}&sz=16`}
alt=""
className="w-3 h-3 rounded"
/>
<span className="text-xs text-cream-600/50 truncate">{domain}</span>
</div>
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium text-cream-200 group-hover:text-cream-100 line-clamp-2 transition-colors"
>
{item.title}
</a>
</div>
</article>
);
}
return (
<article className="group p-4 bg-navy-900/30 border border-navy-700/20 rounded-xl hover:border-cream-400/15 hover:bg-navy-900/50 transition-all duration-200">
{item.thumbnail && (
<div className="aspect-video rounded-lg overflow-hidden mb-4 -mx-1 -mt-1">
<img
src={item.thumbnail}
alt=""
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
</div>
)}
<div className="flex items-center gap-2 mb-2">
<img
src={`https://www.google.com/s2/favicons?domain=${domain}&sz=16`}
alt=""
className="w-4 h-4 rounded"
/>
<span className="text-xs text-cream-600/50">{domain}</span>
</div>
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="block"
>
<h3 className="font-medium text-cream-100 mb-2 line-clamp-2 group-hover:text-cream-50 transition-colors">
{item.title}
</h3>
</a>
<p className="text-sm text-cream-500/60 line-clamp-2">{item.content}</p>
{onSummarize && (
<button
onClick={() => onSummarize(item.url)}
className="flex items-center gap-2 mt-3 text-xs text-cream-400 hover:text-cream-200 transition-colors"
>
<Sparkles className="w-3.5 h-3.5" />
Саммари
</button>
)}
</article>
);
}

View File

@@ -0,0 +1,198 @@
'use client';
import { useState, useCallback } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { motion, AnimatePresence } from 'framer-motion';
import {
Search,
Compass,
FolderOpen,
Clock,
Settings,
Plus,
ChevronLeft,
ChevronRight,
TrendingUp,
BookOpen,
Cpu,
X,
} from 'lucide-react';
interface SidebarProps {
onClose?: () => void;
}
const navItems = [
{ href: '/', icon: Search, label: 'Поиск' },
{ href: '/discover', icon: Compass, label: 'Discover' },
{ href: '/spaces', icon: FolderOpen, label: 'Пространства' },
{ href: '/history', icon: Clock, label: 'История' },
];
const toolItems = [
{ href: '/computer', icon: Cpu, label: 'Computer' },
{ href: '/finance', icon: TrendingUp, label: 'Финансы' },
{ href: '/learning', icon: BookOpen, label: 'Обучение' },
];
export function Sidebar({ onClose }: SidebarProps) {
const pathname = usePathname();
const [collapsed, setCollapsed] = useState(false);
const isMobile = !!onClose;
const toggleCollapse = useCallback(() => {
setCollapsed((prev) => !prev);
}, []);
const handleNavClick = () => {
if (onClose) {
onClose();
}
};
return (
<motion.aside
initial={false}
animate={{ width: isMobile ? 280 : collapsed ? 68 : 260 }}
transition={{ duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
className="h-full flex flex-col bg-base border-r border-border/50"
>
{/* Header */}
<div className="h-16 px-4 flex items-center justify-between">
<AnimatePresence mode="wait">
{(isMobile || !collapsed) && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<span className="font-bold italic text-primary tracking-tight text-lg">GooSeek</span>
</motion.div>
)}
</AnimatePresence>
{isMobile ? (
<button
onClick={onClose}
className="w-9 h-9 flex items-center justify-center rounded-xl text-muted hover:text-primary hover:bg-surface/50 transition-all"
>
<X className="w-5 h-5" />
</button>
) : (
<button
onClick={toggleCollapse}
className="w-8 h-8 flex items-center justify-center rounded-lg text-muted hover:text-secondary hover:bg-surface/50 transition-all"
>
{collapsed ? (
<ChevronRight className="w-4 h-4" />
) : (
<ChevronLeft className="w-4 h-4" />
)}
</button>
)}
</div>
{/* New Chat Button */}
<div className="px-3 mb-4">
<Link
href="/"
onClick={handleNavClick}
className={`
w-full flex items-center gap-3 h-11 btn-gradient
${isMobile || !collapsed ? 'px-3' : 'justify-center px-0'}
`}
>
<Plus className="w-[18px] h-[18px] flex-shrink-0 btn-gradient-text" />
{(isMobile || !collapsed) && (
<span className="text-sm font-medium truncate btn-gradient-text">Новый чат</span>
)}
</Link>
</div>
{/* Separator */}
<div className="mx-4 border-t border-border/50" />
{/* Navigation */}
<nav className="flex-1 px-3 py-4 space-y-1 overflow-y-auto">
{navItems.map((item) => (
<NavLink
key={item.href}
href={item.href}
icon={item.icon}
label={item.label}
collapsed={!isMobile && collapsed}
active={pathname === item.href}
onClick={handleNavClick}
/>
))}
{/* Tools Section */}
<div className="pt-6 pb-2">
{(isMobile || !collapsed) && (
<span className="px-3 text-[11px] font-semibold text-muted uppercase tracking-wider">
Инструменты
</span>
)}
{!isMobile && collapsed && <div className="h-px bg-border/30 mx-2" />}
</div>
{toolItems.map((item) => (
<NavLink
key={item.href}
href={item.href}
icon={item.icon}
label={item.label}
collapsed={!isMobile && collapsed}
active={pathname === item.href}
onClick={handleNavClick}
/>
))}
</nav>
{/* Footer */}
<div className="p-3 border-t border-border/30">
<NavLink
href="/settings"
icon={Settings}
label="Настройки"
collapsed={!isMobile && collapsed}
active={pathname === '/settings'}
onClick={handleNavClick}
/>
</div>
</motion.aside>
);
}
interface NavLinkProps {
href: string;
icon: React.ElementType;
label: string;
collapsed: boolean;
active: boolean;
onClick?: () => void;
}
function NavLink({ href, icon: Icon, label, collapsed, active, onClick }: NavLinkProps) {
return (
<Link
href={href}
onClick={onClick}
className={`
flex items-center gap-3 h-11 rounded-xl transition-all duration-150
${collapsed ? 'justify-center px-0' : 'px-3'}
${active
? 'bg-accent/10 text-primary border-l-2 border-accent ml-0'
: 'text-secondary hover:text-primary hover:bg-surface/50'
}
`}
>
<Icon className={`w-[18px] h-[18px] flex-shrink-0 ${active ? 'text-accent' : ''}`} />
{!collapsed && (
<span className="text-sm font-medium truncate">{label}</span>
)}
</Link>
);
}

View File

@@ -0,0 +1,5 @@
export { Sidebar } from './Sidebar';
export { ChatInput } from './ChatInput';
export { ChatMessage } from './ChatMessage';
export { Citation, CitationList } from './Citation';
export { DiscoverCard } from './DiscoverCard';