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

@@ -4,13 +4,11 @@ FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
COPY services ./services
# npm cache — ускоряет npm ci, НЕ трогает билд
RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY --from=npm-cache / /tmp/npm-cache
RUN npm ci --cache /tmp/npm-cache
ENV NEXT_TELEMETRY_DISABLED=1
ARG API_GATEWAY_URL=http://api-gateway.gooseek:3015
ENV API_GATEWAY_URL=${API_GATEWAY_URL}
# БЕЗ cache для .next — иначе старый билд попадает в прод
RUN npm run build -w web-svc
FROM node:22-alpine AS runner

View File

@@ -49,7 +49,7 @@ const nextConfig = {
async rewrites() {
const gateway = process.env.API_GATEWAY_URL ?? 'http://localhost:3015';
return [
{ source: '/api/chat', destination: `${gateway}/api/chat` },
// /api/chat handled by app/api/chat/route.ts for proper streaming support
{ source: '/api/v1/:path*', destination: `${gateway}/api/v1/:path*` },
{ source: '/api/config', destination: `${gateway}/api/v1/config` },
{ source: '/api/config/:path*', destination: `${gateway}/api/v1/config/:path*` },

View File

@@ -0,0 +1,85 @@
import { NextRequest } from 'next/server';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export const maxDuration = 300;
const API_GATEWAY_URL = process.env.API_GATEWAY_URL ?? 'http://api-gateway:3015';
export async function POST(request: NextRequest): Promise<Response> {
try {
const body = await request.json();
const authHeader = request.headers.get('Authorization');
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (authHeader) {
headers['Authorization'] = authHeader;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 300000);
const response = await fetch(`${API_GATEWAY_URL}/api/chat`, {
method: 'POST',
headers,
body: JSON.stringify(body),
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
const errorText = await response.text();
return new Response(errorText || 'Backend error', {
status: response.status,
headers: { 'Content-Type': 'text/plain' },
});
}
if (!response.body) {
return new Response('No response body', {
status: 502,
headers: { 'Content-Type': 'text/plain' },
});
}
const reader = response.body.getReader();
const stream = new ReadableStream({
async start(streamController) {
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
streamController.enqueue(value);
}
} catch (err) {
console.error('Stream read error:', err);
} finally {
streamController.close();
}
},
cancel() {
reader.cancel();
},
});
return new Response(stream, {
status: 200,
headers: {
'Content-Type': 'application/x-ndjson',
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Transfer-Encoding': 'chunked',
'Connection': 'keep-alive',
},
});
} catch (err) {
console.error('Chat proxy error:', err);
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
return new Response(JSON.stringify({ error: errorMessage }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
}

View File

@@ -1,7 +1,8 @@
'use client';
import { Globe2Icon, Settings } from 'lucide-react';
import { useEffect, useState, useRef } from 'react';
import { Lock, Settings } from 'lucide-react';
import { useEffect, useState, useRef, useCallback } from 'react';
import { useSearchParams, useRouter, usePathname } from 'next/navigation';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import SmallNewsCard from '@/components/Discover/SmallNewsCard';
@@ -44,137 +45,380 @@ const fetchRegion = async (): Promise<string | null> => {
return region;
};
export interface DiscoverSource {
url: string;
title: string;
}
export interface Discover {
title: string;
content: string;
url: string;
thumbnail: string;
sources?: DiscoverSource[];
summary?: string;
sourcesCount?: number;
digestId?: string;
}
const topics: { key: string; display: string }[] = [
{
display: 'GooSeek',
key: 'gooseek',
},
{
display: 'Tech & Science',
key: 'tech',
},
{
display: 'Finance',
key: 'finance',
},
{
display: 'Art & Culture',
key: 'art',
},
{
display: 'Sports',
key: 'sports',
},
{
display: 'Entertainment',
key: 'entertainment',
},
const topics: { key: string; display: string; icon?: 'lock' }[] = [
{ display: 'GooSeek', key: 'gooseek' },
{ display: 'BA!T', key: 'bait', icon: 'lock' },
{ display: 'Наука и технологии', key: 'tech' },
{ display: 'Финансы', key: 'finance' },
{ display: 'Искусство и культура', key: 'art' },
{ display: 'Спорт', key: 'sports' },
{ display: 'Развлечения', key: 'entertainment' },
];
const TOPIC_KEYS = new Set(topics.map((t) => t.key));
const DEFAULT_TOPIC = 'gooseek';
const FETCH_TIMEOUT_MS = 15000;
const DISCOVER_CACHE_TTL_MS = 5 * 60 * 1000; // 5 min
const STALE_CACHE_TTL_MS = 30 * 60 * 1000; // 30 min - показываем stale данные пока загружаем свежие
const CACHE_KEY_PREFIX = 'gooseek_discover_';
function getDiscoverCacheKey(topic: string, region: string | null, sourceMode: 'ru' | 'world'): string {
return `${CACHE_KEY_PREFIX}${topic}:${region ?? 'default'}:${sourceMode}`;
}
interface CacheEntry {
data: Discover[];
ts: number;
}
function readDiscoverFromSessionStorage(
topic: string,
region: string | null,
sourceMode: 'ru' | 'world',
allowStale = false
): { data: Discover[]; isStale: boolean } | null {
if (typeof sessionStorage === 'undefined') return null;
try {
const key = getDiscoverCacheKey(topic, region, sourceMode);
const raw = sessionStorage.getItem(key);
if (!raw) return null;
const { data, ts } = JSON.parse(raw) as CacheEntry;
if (!Array.isArray(data)) return null;
const age = Date.now() - ts;
if (age > STALE_CACHE_TTL_MS) return null;
const isStale = age > DISCOVER_CACHE_TTL_MS;
if (isStale && !allowStale) return null;
return { data, isStale };
} catch {
return null;
}
}
function writeDiscoverToSessionStorage(topic: string, region: string | null, sourceMode: 'ru' | 'world', items: Discover[]): void {
if (typeof sessionStorage === 'undefined') return;
try {
const key = getDiscoverCacheKey(topic, region, sourceMode);
sessionStorage.setItem(key, JSON.stringify({ data: items, ts: Date.now() }));
} catch {
// ignore
}
}
type DiscoverCache = Map<string, { data: Discover[]; ts: number }>;
const memoryCache: DiscoverCache = new Map();
const Page = () => {
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const [discover, setDiscover] = useState<Discover[] | null>(null);
const [loading, setLoading] = useState(false);
const [isRevalidating, setIsRevalidating] = useState(false);
const [error, setError] = useState<string | null>(null);
const [activeTopic, setActiveTopic] = useState<string>(topics[0].key);
const [activeTopic, setActiveTopic] = useState<string>(DEFAULT_TOPIC);
const [sourceMode, setSourceMode] = useState<'ru' | 'world'>('ru');
const [setupRequired, setSetupRequired] = useState(false);
const regionPromiseRef = useRef<Promise<string | null> | null>(null);
const regionRef = useRef<string | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const lastFetchKeyRef = useRef<string>('');
const getRegionPromise = (): Promise<string | null> => {
const setActiveTopicAndUrl = useCallback((key: string) => {
setActiveTopic(key);
const params = new URLSearchParams(searchParams.toString());
params.set('tab', key);
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
}, [searchParams, pathname, router]);
const setSourceModeAndUrl = useCallback((mode: 'ru' | 'world') => {
setSourceMode(mode);
const params = new URLSearchParams(searchParams.toString());
params.set('source', mode);
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
}, [searchParams, pathname, router]);
const getRegion = useCallback(async (): Promise<string | null> => {
if (regionRef.current !== null) return regionRef.current;
if (!regionPromiseRef.current) {
regionPromiseRef.current = fetchRegion();
}
return regionPromiseRef.current;
};
const region = await regionPromiseRef.current;
regionRef.current = region;
return region;
}, []);
const getCacheKey = useCallback((topic: string, mode: 'ru' | 'world') => {
return `${topic}:${mode}`;
}, []);
const getInstantCache = useCallback((topic: string, mode: 'ru' | 'world'): Discover[] | null => {
const key = getCacheKey(topic, mode);
const memEntry = memoryCache.get(key);
if (memEntry && Date.now() - memEntry.ts < STALE_CACHE_TTL_MS) {
return memEntry.data;
}
const sessionEntry = readDiscoverFromSessionStorage(topic, regionRef.current, mode, true);
if (sessionEntry) {
memoryCache.set(key, { data: sessionEntry.data, ts: Date.now() - (sessionEntry.isStale ? DISCOVER_CACHE_TTL_MS : 0) });
return sessionEntry.data;
}
return null;
}, [getCacheKey]);
const fetchArticlesFromNetwork = useCallback(async (
topic: string,
mode: 'ru' | 'world',
signal: AbortSignal
): Promise<Discover[]> => {
const region = await getRegion();
const url = new URL('/api/v1/discover', window.location.origin);
url.searchParams.set('topic', topic);
if (region) url.searchParams.set('region', region);
if (topic !== 'bait' && topic !== 'gooseek') {
url.searchParams.set('source', mode);
}
const res = await fetch(url.toString(), {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
signal,
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.message || 'Failed to load articles');
}
const blogs = Array.isArray(data?.blogs) ? data.blogs : [];
return topic === 'bait' || topic === 'gooseek'
? blogs
: blogs.filter((blog: Discover) => blog?.thumbnail);
}, [getRegion]);
const prefetchTopic = useCallback(async (topic: string, mode: 'ru' | 'world') => {
const key = getCacheKey(topic, mode);
const existing = memoryCache.get(key);
if (existing && Date.now() - existing.ts < DISCOVER_CACHE_TTL_MS) return;
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
const data = await fetchArticlesFromNetwork(topic, mode, controller.signal);
clearTimeout(timeoutId);
const region = regionRef.current;
memoryCache.set(key, { data, ts: Date.now() });
writeDiscoverToSessionStorage(topic, region, mode, data);
} catch {
// Prefetch failed silently
}
}, [getCacheKey, fetchArticlesFromNetwork]);
const fetchArticles = useCallback(async (topic: string, mode: 'ru' | 'world') => {
const fetchKey = getCacheKey(topic, mode);
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
lastFetchKeyRef.current = fetchKey;
const fetchArticles = async (topic: string) => {
setLoading(true);
setError(null);
setSetupRequired(false);
const cachedData = getInstantCache(topic, mode);
if (cachedData?.length) {
setDiscover(cachedData);
setLoading(false);
const memEntry = memoryCache.get(fetchKey);
const isStale = !memEntry || Date.now() - memEntry.ts > DISCOVER_CACHE_TTL_MS;
if (isStale) {
setIsRevalidating(true);
try {
const freshData = await fetchArticlesFromNetwork(topic, mode, abortControllerRef.current.signal);
if (lastFetchKeyRef.current === fetchKey) {
setDiscover(freshData);
const region = regionRef.current;
memoryCache.set(fetchKey, { data: freshData, ts: Date.now() });
writeDiscoverToSessionStorage(topic, region, mode, freshData);
}
} catch (err: unknown) {
if ((err as Error)?.name !== 'AbortError') {
// Silent fail on revalidation - we already have stale data
}
} finally {
if (lastFetchKeyRef.current === fetchKey) {
setIsRevalidating(false);
}
}
}
return;
}
setDiscover(null);
setLoading(true);
try {
const region = await getRegionPromise();
const url = new URL('/api/v1/discover', window.location.origin);
url.searchParams.set('topic', topic);
if (region) url.searchParams.set('region', region);
const res = await fetch(url.toString(), {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
const timeoutId = setTimeout(() => {
abortControllerRef.current?.abort();
}, FETCH_TIMEOUT_MS);
const data = await res.json();
const data = await fetchArticlesFromNetwork(topic, mode, abortControllerRef.current.signal);
clearTimeout(timeoutId);
if (!res.ok) {
throw new Error(data.message || 'Failed to load articles');
if (lastFetchKeyRef.current === fetchKey) {
setDiscover(data);
const region = regionRef.current;
memoryCache.set(fetchKey, { data, ts: Date.now() });
writeDiscoverToSessionStorage(topic, region, mode, data);
}
if (topic !== 'gooseek') {
data.blogs = data.blogs.filter((blog: Discover) => blog.thumbnail);
}
setDiscover(data.blogs);
} catch (err: unknown) {
if ((err as Error)?.name === 'AbortError') return;
const message = err instanceof Error ? err.message : 'Error fetching data';
if (message.includes('SearxNG is not configured')) {
setSetupRequired(true);
setDiscover(null);
} else {
setError(message.includes('timeout') || (err as Error)?.name === 'AbortError'
? 'Request timed out. Try again.'
: message);
setError(message.includes('timeout') ? 'Request timed out. Try again.' : message);
toast.error(message);
}
} finally {
setLoading(false);
if (lastFetchKeyRef.current === fetchKey) {
setLoading(false);
}
}
};
}, [getCacheKey, getInstantCache, fetchArticlesFromNetwork]);
useEffect(() => {
if (activeTopic === 'gooseek') {
fetchArticles('gooseek');
return;
const tab = searchParams.get('tab');
const source = searchParams.get('source');
if (tab && TOPIC_KEYS.has(tab)) {
setActiveTopic(tab);
} else if (!tab) {
router.replace(`${pathname}?tab=${DEFAULT_TOPIC}`, { scroll: false });
}
fetchArticles(activeTopic);
}, [activeTopic]);
if (source === 'ru' || source === 'world') {
setSourceMode(source);
}
}, [searchParams, pathname, router]);
useEffect(() => {
fetchArticles(activeTopic, sourceMode);
return () => {
abortControllerRef.current?.abort();
};
}, [activeTopic, sourceMode, fetchArticles]);
useEffect(() => {
const currentIndex = topics.findIndex(t => t.key === activeTopic);
if (currentIndex === -1) return;
const adjacentTopics: string[] = [];
if (currentIndex > 0) adjacentTopics.push(topics[currentIndex - 1].key);
if (currentIndex < topics.length - 1) adjacentTopics.push(topics[currentIndex + 1].key);
const timer = setTimeout(() => {
adjacentTopics.forEach(topic => {
prefetchTopic(topic, sourceMode);
});
}, 500);
return () => clearTimeout(timer);
}, [activeTopic, sourceMode, prefetchTopic]);
return (
<>
<div>
<div className="flex flex-col pt-10 border-b border-light-200/20 dark:border-dark-200/20 pb-6 px-2">
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
<div className="flex items-center justify-center">
<Globe2Icon size={45} className="mb-2.5" />
<h1 className="text-5xl font-normal p-2">
Discover
</h1>
</div>
<div className="flex flex-row items-center space-x-2 overflow-x-auto">
{topics.map((t, i) => (
<div
key={i}
className={cn(
'border-[0.1px] rounded-full text-sm px-3 py-1 text-nowrap transition duration-200 cursor-pointer',
activeTopic === t.key
? 'text-[#EA580C] bg-[#EA580C]/20 border-[#EA580C]/60 dark:bg-[#EA580C]/30 dark:border-[#EA580C]/40'
: 'border-black/30 dark:border-white/30 text-black/70 dark:text-white/70 hover:text-black dark:hover:text-white hover:border-black/40 dark:hover:border-white/40 hover:bg-black/5 dark:hover:bg-white/5',
)}
onClick={() => setActiveTopic(t.key)}
>
<span>{t.display}</span>
<div className="flex flex-row items-center flex-wrap gap-2">
<div className="flex flex-row items-center space-x-2 overflow-x-auto">
{topics.map((t, i) => (
<div
key={i}
className={cn(
'border-[0.1px] rounded-full text-sm px-3 py-1 text-nowrap transition duration-200 cursor-pointer flex items-center gap-1.5',
activeTopic === t.key
? 'text-[#EA580C] bg-[#EA580C]/20 border-[#EA580C]/60 dark:bg-[#EA580C]/30 dark:border-[#EA580C]/40'
: 'border-black/30 dark:border-white/30 text-black/70 dark:text-white/70 hover:text-black dark:hover:text-white hover:border-black/40 dark:hover:border-white/40 hover:bg-black/5 dark:hover:bg-white/5',
)}
onClick={() => setActiveTopicAndUrl(t.key)}
>
{t.icon === 'lock' && <Lock size={14} className="shrink-0" />}
<span>{t.display}</span>
</div>
))}
</div>
{(activeTopic !== 'bait' && activeTopic !== 'gooseek') && (
<div className="flex flex-row items-center border-l border-light-200/40 dark:border-dark-200/40 pl-3 ml-1">
<div
role="group"
aria-label="Источники: Россия или Мир"
className="inline-flex rounded-full bg-black/10 dark:bg-white/10 p-0.5 border border-black/10 dark:border-white/10"
>
<button
type="button"
onClick={() => setSourceModeAndUrl('ru')}
className={cn(
'rounded-full px-4 py-1.5 text-sm font-medium transition-all duration-200',
sourceMode === 'ru'
? 'bg-[#EA580C] text-white shadow-sm'
: 'text-black/60 dark:text-white/60 hover:text-black dark:hover:text-white'
)}
>
Russia
</button>
<button
type="button"
onClick={() => setSourceModeAndUrl('world')}
className={cn(
'rounded-full px-4 py-1.5 text-sm font-medium transition-all duration-200',
sourceMode === 'world'
? 'bg-[#EA580C] text-white shadow-sm'
: 'text-black/60 dark:text-white/60 hover:text-black dark:hover:text-white'
)}
>
World
</button>
</div>
</div>
))}
)}
</div>
</div>
</div>
{isRevalidating && (
<div className="fixed top-4 right-4 z-50 flex items-center gap-2 bg-black/80 dark:bg-white/90 text-white dark:text-black px-3 py-1.5 rounded-full text-xs font-medium shadow-lg animate-pulse">
<div className="w-2 h-2 bg-[#EA580C] rounded-full animate-ping" />
Обновление...
</div>
)}
{loading ? (
<div className="flex flex-col gap-4 pb-28 pt-5 lg:pb-8 w-full">
{/* Mobile: SmallNewsCard — rounded-3xl, aspect-video, p-4, h3 mb-2 + p */}
@@ -267,7 +511,7 @@ const Page = () => {
</div>
) : error ? (
<div className="pb-28 pt-5 lg:pb-8 px-4">
<DataFetchError message={error} onRetry={() => fetchArticles(activeTopic)} />
<DataFetchError message={error} onRetry={() => fetchArticles(activeTopic, sourceMode)} />
</div>
) : setupRequired ? (
<div className="flex flex-col items-center justify-center min-h-[50vh] gap-4 px-4 text-center">

View File

@@ -10,7 +10,6 @@ import ThemeProvider from '@/components/theme/Provider';
import { LocalizationProvider } from '@/lib/localization/context';
import { ChatProvider } from '@/lib/hooks/useChat';
import { ClientOnly } from '@/components/ClientOnly';
import GuestWarningBanner from '@/components/GuestWarningBanner';
import GuestMigration from '@/components/GuestMigration';
import UnregisterSW from '@/components/UnregisterSW';
@@ -22,9 +21,9 @@ const roboto = Roboto({
});
const APP_NAME = 'GooSeek';
const APP_DEFAULT_TITLE = 'GooSeek - Chat with the internet';
const APP_DEFAULT_TITLE = 'GooSeek — Чат с интернетом';
const APP_DESCRIPTION =
'GooSeek is an AI powered chatbot that is connected to the internet.';
'GooSeek — AI-чатбот, подключённый к интернету.';
export const metadata: Metadata = {
applicationName: APP_NAME,
@@ -77,7 +76,6 @@ export default async function RootLayout({
<ChatProvider>
<UnregisterSW />
<Sidebar>{children}</Sidebar>
<GuestWarningBanner />
<GuestMigration />
<Toaster
toastOptions={{

View File

@@ -1,11 +1,12 @@
'use client';
import ChatWindow from '@/components/ChatWindow';
import { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Chat - GooSeek',
description: 'Chat with the internet, chat with GooSeek.',
};
/**
* Главная страница: standalone-чат (без проекта).
* Кнопка «Новый чат» ведёт сюда — пользователь может начать чат вне проекта.
* Чат в контексте проекта — через /spaces/[projectId].
*/
const Home = () => {
return <ChatWindow />;
};

View File

@@ -272,7 +272,7 @@ function ConnectorsSection({
'bg-[#EA580C] text-white hover:bg-[#EA580C]/90'
)}
>
{isComingSoon ? t('profile.comingSoon') : isConnected ? t('profile.connected') ?? 'Connected' : t('profile.connect') ?? 'Connect'}
{isComingSoon ? t('profile.comingSoon') : isConnected ? t('profile.connected') : t('profile.connect')}
</button>
</div>
);

View File

@@ -0,0 +1,47 @@
'use client';
import { useEffect, useState } from 'react';
interface Template {
id: string;
title: string;
category?: string;
description?: string;
}
const SpaceCarousels = () => {
const [templates, setTemplates] = useState<Template[]>([]);
useEffect(() => {
fetch('/api/v1/templates')
.then((r) => r.json())
.then((tData) => setTemplates(tData.items ?? []))
.catch(() => {});
}, []);
if (templates.length === 0) return null;
return (
<div>
<p className="text-xs font-medium text-black/50 dark:text-white/50 mb-2">Popular Templates</p>
<div className="flex gap-2 overflow-x-auto pb-2 -mx-4 px-4">
{templates.map((item) => (
<button
key={item.id}
type="button"
className="flex-shrink-0 w-[140px] rounded-lg bg-light-secondary dark:bg-dark-secondary p-3 border border-light-200/20 dark:border-dark-200/20 hover:border-[#7C3AED]/30 transition-colors text-left"
>
<p className="font-medium text-sm truncate">{item.title}</p>
{item.description && (
<p className="text-xs text-black/50 dark:text-white/50 mt-0.5 line-clamp-2">
{item.description}
</p>
)}
</button>
))}
</div>
</div>
);
};
export default SpaceCarousels;

View File

@@ -0,0 +1,33 @@
'use client';
import ChatWindow from '@/components/ChatWindow';
import { useParams } from 'next/navigation';
import Link from 'next/link';
import { ChevronLeft } from 'lucide-react';
/**
* Chat view within a Space (project).
* ChatProvider reads chatId from params — this route provides both projectId and chatId.
*/
const Page = () => {
const params = useParams();
const projectId = params.projectId as string;
const chatId = params.chatId as string;
return (
<div className="relative">
<div className="sticky top-0 z-10 flex items-center gap-2 px-4 py-2 bg-light-primary dark:bg-dark-primary border-b border-light-200/20 dark:border-dark-200/20">
<Link
href={`/spaces/${projectId}`}
className="flex items-center gap-1 text-sm text-black/70 dark:text-white/70 hover:text-[#7C3AED] transition"
>
<ChevronLeft size={18} />
К проекту
</Link>
</div>
<ChatWindow />
</div>
);
};
export default Page;

View File

@@ -0,0 +1,583 @@
'use client';
import { useRouter, useParams } from 'next/navigation';
import { useEffect, useState } from 'react';
import {
FolderOpen,
Users,
Plus,
Clock,
MessageSquare,
ChevronRight,
FileText,
Link2,
Upload,
MoreHorizontal,
FileEdit,
} from 'lucide-react';
import Link from 'next/link';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import {
getProjectFiles,
addProjectFile,
deleteProjectFile,
type ProjectFile,
} from '@/lib/project-files-db';
import { getGuestProject, setGuestProject, createGuestProject, setCurrentProjectId } from '@/lib/project-storage';
import EmptyChatMessageInput from '@/components/EmptyChatMessageInput';
import SpaceCarousels from './SpaceCarousels';
interface Project {
id: string;
title: string;
description: string;
links: string[];
instructions?: string;
}
type TabKey = 'my' | 'shared';
const Page = () => {
const params = useParams();
const router = useRouter();
const projectId = params.projectId as string;
const [project, setProject] = useState<Project | null>(null);
const [loading, setLoading] = useState(true);
const [tab, setTab] = useState<TabKey>('my');
const [threads, setThreads] = useState<{ id: string; title: string; createdAt: string }[]>([]);
const [files, setFiles] = useState<ProjectFile[]>([]);
const [links, setLinks] = useState<string[]>([]);
const [showLinksInput, setShowLinksInput] = useState(false);
const [newLinkUrl, setNewLinkUrl] = useState('');
const [editingDesc, setEditingDesc] = useState(false);
const [descValue, setDescValue] = useState('');
const [instructions, setInstructions] = useState('');
const [editingInstructions, setEditingInstructions] = useState(false);
const token =
typeof window !== 'undefined'
? localStorage.getItem('auth_token') ?? localStorage.getItem('access_token')
: null;
// Create new project if "new"
useEffect(() => {
if (projectId === 'new') {
const createProject = async () => {
if (token) {
try {
const res = await fetch('/api/v1/projects', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ title: 'Новый проект', description: 'Тут описание проекта' }),
});
if (res.ok) {
const p = await res.json();
setCurrentProjectId(p.id);
router.replace(`/spaces/${p.id}`);
return;
}
} catch {
toast.error('Не удалось создать проект');
}
}
const guest = createGuestProject('Новый проект', 'Тут описание проекта');
setCurrentProjectId(guest.id);
router.replace(`/spaces/${guest.id}`);
};
createProject();
return;
}
}, [projectId, token, router]);
// Load project
useEffect(() => {
if (!projectId || projectId === 'new') return;
const load = async () => {
setLoading(true);
if (token) {
try {
const [projRes, threadsRes] = await Promise.all([
fetch(`/api/v1/projects/${projectId}`, {
headers: { Authorization: `Bearer ${token}` },
}),
fetch(`/api/v1/library/threads?projectId=${projectId}`, {
headers: { Authorization: `Bearer ${token}` },
}),
]);
if (projRes.ok) {
const p = await projRes.json();
setProject(p);
setLinks(p.links ?? []);
setDescValue(p.description ?? '');
setInstructions(p.instructions ?? '');
} else {
setProject(null);
}
if (threadsRes.ok) {
const tData = await threadsRes.json();
setThreads((tData.chats ?? []).map((c: { id: string; title: string; createdAt: string }) => ({
id: c.id,
title: c.title,
createdAt: c.createdAt,
})));
}
} catch {
setProject(null);
}
} else {
const guest = getGuestProject(projectId);
if (guest) {
setProject({
id: guest.id,
title: guest.title,
description: guest.description,
links: guest.links,
instructions: guest.instructions,
});
setLinks(guest.links);
setDescValue(guest.description);
setInstructions(guest.instructions ?? '');
} else {
setProject(null);
}
const { getGuestChatsByProject } = await import('@/lib/guest-storage');
const guestThreads = getGuestChatsByProject(projectId);
setThreads(
guestThreads.map((t) => ({
id: t.id,
title: t.title,
createdAt: t.createdAt,
})),
);
}
const projectFiles = await getProjectFiles(projectId);
setFiles(projectFiles);
setCurrentProjectId(projectId);
setLoading(false);
};
load();
}, [projectId, token]);
const handleSaveDescription = () => {
if (!project) return;
const desc = descValue.trim();
setEditingDesc(false);
if (token) {
fetch(`/api/v1/projects/${projectId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ description: desc }),
})
.then((r) => r.json())
.then((p) => setProject((prev) => (prev ? { ...prev, description: p.description } : null)))
.catch(() => toast.error('Не удалось сохранить'));
} else {
setGuestProject({ ...project, description: desc });
setProject((prev) => (prev ? { ...prev, description: desc } : null));
}
};
const handleAddLink = () => {
const url = newLinkUrl.trim();
if (!url) return;
const normalized = url.startsWith('http') ? url : `https://${url}`;
const next = [...links, normalized];
setLinks(next);
setNewLinkUrl('');
setShowLinksInput(false);
if (project && token) {
fetch(`/api/v1/projects/${projectId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ links: next }),
}).catch(() => toast.error('Не удалось сохранить'));
} else if (project) {
setGuestProject({ ...project, links: next });
}
};
const handleSaveInstructions = () => {
if (!project) return;
setEditingInstructions(false);
if (token) {
fetch(`/api/v1/projects/${projectId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ instructions }),
})
.then((r) => r.json())
.then((p) => setProject((prev) => (prev ? { ...prev, instructions: p.instructions } : null)))
.catch(() => toast.error('Не удалось сохранить'));
} else {
setGuestProject({ ...project, instructions });
setProject((prev) => (prev ? { ...prev, instructions } : null));
}
};
const handleRemoveLink = (idx: number) => {
const next = links.filter((_, i) => i !== idx);
setLinks(next);
if (project && token) {
fetch(`/api/v1/projects/${projectId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ links: next }),
}).catch(() => toast.error('Не удалось сохранить'));
} else if (project) {
setGuestProject({ ...project, links: next });
}
};
const handleFileAdd = async (e: React.ChangeEvent<HTMLInputElement>) => {
const fileList = e.target.files;
if (!fileList?.length || !projectId) return;
try {
for (let i = 0; i < fileList.length; i++) {
await addProjectFile(projectId, fileList[i]);
}
setFiles(await getProjectFiles(projectId));
} catch {
toast.error('Не удалось добавить файл');
}
e.target.value = '';
};
const handleFileRemove = async (id: string) => {
try {
await deleteProjectFile(id);
setFiles(await getProjectFiles(projectId));
} catch {
toast.error('Не удалось удалить');
}
};
const formatTime = (iso: string) => {
const d = new Date(iso);
const now = new Date();
const diff = (now.getTime() - d.getTime()) / 60000;
if (diff < 60) return `${Math.floor(diff)} мин назад`;
if (diff < 1440) return `${Math.floor(diff / 60)} ч назад`;
return d.toLocaleDateString();
};
if (projectId === 'new' || loading) {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh]">
<div className="animate-pulse rounded-2xl bg-light-secondary dark:bg-dark-secondary h-32 w-96" />
</div>
);
}
if (!project) {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] px-4">
<p className="text-black/60 dark:text-white/60 mb-4">Проект не найден</p>
<Link href="/spaces" className="text-[#7C3AED] hover:underline">
К списку Spaces
</Link>
</div>
);
}
return (
<div className="flex flex-col min-h-screen">
{/* Header */}
<div className="flex items-start justify-between gap-4 border-b border-light-200/20 dark:border-dark-200/20 pb-6 px-4 pt-6">
<div className="flex items-start gap-3 min-w-0">
<FolderOpen size={40} className="text-[#7C3AED] flex-shrink-0" />
<div className="min-w-0">
<h1 className="text-2xl font-semibold truncate">{project.title}</h1>
{editingDesc ? (
<input
value={descValue}
onChange={(e) => setDescValue(e.target.value)}
onBlur={handleSaveDescription}
onKeyDown={(e) => e.key === 'Enter' && handleSaveDescription()}
className="mt-1 w-full bg-transparent border-b border-light-200 dark:border-dark-200 focus:outline-none focus:border-[#7C3AED] text-sm text-black/70 dark:text-white/70"
placeholder="Тут описание проекта"
autoFocus
/>
) : (
<p
onClick={() => setEditingDesc(true)}
className="mt-1 text-sm text-black/60 dark:text-white/60 cursor-text hover:text-black/80 dark:hover:text-white/80"
>
{project.description || 'Тут описание проекта'}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<button
type="button"
className="p-2 rounded-lg hover:bg-light-200 dark:hover:bg-dark-200 transition"
aria-label="Совместный доступ"
>
<Users size={20} className="text-black/60 dark:text-white/60" />
</button>
<button
type="button"
className="p-2 rounded-lg hover:bg-light-200 dark:hover:bg-dark-200 transition"
aria-label="Добавить"
>
<Plus size={20} className="text-black/60 dark:text-white/60" />
</button>
</div>
</div>
<div className="flex flex-1 gap-6 px-4 pb-8">
{/* Main */}
<div className="flex-1 min-w-0 flex flex-col">
{/* Карусели: режимы ответа + шаблоны/коллекции */}
<div className="mt-4">
<SpaceCarousels />
</div>
{/* Chat input */}
<div className="mt-4">
<EmptyChatMessageInput />
</div>
{/* Tabs */}
<div className="mt-6 flex gap-6 border-b border-light-200/20 dark:border-dark-200/20">
<button
type="button"
onClick={() => setTab('my')}
className={cn(
'flex items-center gap-2 pb-3 text-sm font-medium transition',
tab === 'my'
? 'text-black dark:text-white border-b-2 border-black dark:border-white'
: 'text-black/60 dark:text-white/60 hover:text-black/80 dark:hover:text-white/80'
)}
>
<Clock size={18} />
Мои беседы
</button>
<button
type="button"
onClick={() => setTab('shared')}
className={cn(
'flex items-center gap-2 pb-3 text-sm font-medium transition',
tab === 'shared'
? 'text-black dark:text-white border-b-2 border-black dark:border-white'
: 'text-black/60 dark:text-white/60 hover:text-black/80 dark:hover:text-white/80'
)}
>
<MessageSquare size={18} />
Обсуждаемые темы
</button>
</div>
{/* Content */}
<div className="mt-4">
{tab === 'my' && (
<>
{threads.length > 0 ? (
<div className="space-y-2">
{threads.map((t) => (
<Link
key={t.id}
href={`/spaces/${projectId}/chat/${t.id}`}
className="flex items-center justify-between p-4 rounded-xl bg-light-secondary dark:bg-dark-secondary hover:bg-light-200/50 dark:hover:bg-dark-200/50 transition group"
>
<div className="min-w-0 flex-1">
<p className="font-medium truncate">{t.title}</p>
<p className="text-xs text-black/50 dark:text-white/50 mt-0.5">
{formatTime(t.createdAt)}
</p>
</div>
<MoreHorizontal
size={18}
className="flex-shrink-0 opacity-0 group-hover:opacity-100 text-black/50 dark:text-white/50"
/>
</Link>
))}
</div>
) : (
<p className="text-sm text-black/50 dark:text-white/50">
Задайте первый вопрос выше беседа появится здесь.
</p>
)}
</>
)}
{tab === 'shared' && (
<p className="text-sm text-black/50 dark:text-white/50">
Когда вы или коллега делитесь темой, она появится здесь.
</p>
)}
</div>
</div>
{/* Right sidebar — Files & Links */}
<aside className="hidden lg:block w-72 flex-shrink-0 space-y-6">
{/* Files */}
<div className="rounded-xl border border-light-200/20 dark:border-dark-200/20 p-4">
<h3 className="flex items-center gap-2 font-medium text-sm mb-1">
<ChevronRight size={16} />
Файлы
</h3>
<p className="text-xs text-black/60 dark:text-white/60 mb-3">
Файлы для использования в качестве контекста для поисковых запросов
</p>
{files.length > 0 && (
<ul className="space-y-2 mb-3">
{files.map((f) => (
<li
key={f.id}
className="flex items-center gap-2 text-sm group"
>
<FileText size={16} className="text-black/50 dark:text-white/50" />
<span className="truncate flex-1">{f.name}</span>
<button
type="button"
onClick={() => handleFileRemove(f.id)}
className="opacity-0 group-hover:opacity-100 text-red-500 hover:text-red-600 text-xs"
>
×
</button>
</li>
))}
</ul>
)}
<div className="flex flex-col gap-2">
<label className="flex items-center gap-2 text-sm text-[#7C3AED] cursor-pointer hover:underline">
<Upload size={16} />
<span>Загрузить файлы</span>
<input
type="file"
multiple
className="hidden"
onChange={handleFileAdd}
/>
</label>
</div>
</div>
{/* Links */}
<div className="rounded-xl border border-light-200/20 dark:border-dark-200/20 p-4">
<h3 className="flex items-center gap-2 font-medium text-sm mb-1">
<ChevronRight size={16} />
Ссылки
</h3>
<p className="text-xs text-black/60 dark:text-white/60 mb-3">
Сайты, которые нужно включать в каждый поиск
</p>
{links.length > 0 && (
<ul className="space-y-2 mb-3">
{links.map((url, i) => {
const host = (() => {
try {
return new URL(url).host.replace(/^www\./, '');
} catch {
return url;
}
})();
return (
<li key={i} className="flex items-center gap-2 text-sm group">
<Link2 size={16} className="text-black/50 dark:text-white/50" />
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="truncate flex-1 text-[#7C3AED] hover:underline"
>
{host}
</a>
<button
type="button"
onClick={() => handleRemoveLink(i)}
className="opacity-0 group-hover:opacity-100 text-red-500 hover:text-red-600 text-xs"
>
×
</button>
</li>
);
})}
</ul>
)}
{showLinksInput ? (
<div className="flex gap-2">
<input
type="url"
value={newLinkUrl}
onChange={(e) => setNewLinkUrl(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAddLink()}
placeholder="https://..."
className="flex-1 px-2 py-1.5 rounded-lg bg-light-secondary dark:bg-dark-secondary text-sm border border-light-200 dark:border-dark-200 focus:outline-none focus:border-[#7C3AED]"
autoFocus
/>
<button
type="button"
onClick={handleAddLink}
className="px-3 py-1.5 rounded-lg bg-[#7C3AED] text-white text-sm"
>
Добавить
</button>
</div>
) : (
<button
type="button"
onClick={() => setShowLinksInput(true)}
className="flex items-center gap-2 text-sm text-[#7C3AED] hover:underline"
>
<Plus size={16} />
Добавить ссылки
</button>
)}
</div>
{/* Инструкции для нейросети */}
<div className="rounded-xl border border-light-200/20 dark:border-dark-200/20 p-4">
<h3 className="flex items-center gap-2 font-medium text-sm mb-1">
<ChevronRight size={16} />
<FileEdit size={16} />
Инструкции для нейросети
</h3>
<p className="text-xs text-black/60 dark:text-white/60 mb-3">
Правила и контекст, которые AI будет учитывать в каждом ответе
</p>
{editingInstructions ? (
<textarea
value={instructions}
onChange={(e) => setInstructions(e.target.value)}
onBlur={handleSaveInstructions}
placeholder="Например: Отвечай кратко. Фокус на фактах. Используй русский язык."
className="w-full min-h-[80px] px-2 py-2 rounded-lg bg-light-secondary dark:bg-dark-secondary text-sm border border-light-200 dark:border-dark-200 focus:outline-none focus:border-[#7C3AED] resize-y"
autoFocus
/>
) : (
<p
onClick={() => setEditingInstructions(true)}
className="text-sm text-black/70 dark:text-white/70 cursor-text hover:text-black/90 dark:hover:text-white/90 min-h-[40px]"
>
{instructions || 'Нажмите, чтобы добавить инструкции...'}
</p>
)}
</div>
</aside>
</div>
</div>
);
};
export default Page;

View File

@@ -1,36 +1,104 @@
'use client';
import { FolderOpen } from 'lucide-react';
import {
FolderOpen,
Search,
PenLine,
Code2,
TrendingUp,
FileText,
Megaphone,
Plane,
GraduationCap,
Scale,
Heart,
Users,
Target,
BarChart3,
FileEdit,
Briefcase,
Headphones,
type LucideIcon,
} from 'lucide-react';
import Link from 'next/link';
import { useEffect, useState } from 'react';
import { toast } from 'sonner';
import { getAllGuestProjects } from '@/lib/project-storage';
interface Collection {
interface Template {
id: string;
title: string;
category?: string;
description?: string;
}
const CATEGORY_ICONS: Record<string, LucideIcon> = {
research: Search,
writing: PenLine,
coding: Code2,
finance: TrendingUp,
product: FileText,
marketing: Megaphone,
travel: Plane,
academic: GraduationCap,
legal: Scale,
healthcare: Heart,
hr: Users,
sales: Target,
data: BarChart3,
content: FileEdit,
career: Briefcase,
support: Headphones,
};
interface UserProject {
id: string;
title: string;
description: string;
}
const Page = () => {
const [collections, setCollections] = useState<Collection[]>([]);
const [templates, setTemplates] = useState<Template[]>([]);
const [projects, setProjects] = useState<UserProject[]>([]);
const [loading, setLoading] = useState(true);
const token =
typeof window !== 'undefined'
? localStorage.getItem('auth_token') ?? localStorage.getItem('access_token')
: null;
useEffect(() => {
const fetchCollections = async () => {
const fetchData = async () => {
try {
const res = await fetch('/api/v1/collections');
if (!res.ok) throw new Error('Failed to fetch');
const data = await res.json();
setCollections(data.items ?? []);
const [tmplRes, projRes] = await Promise.all([
fetch('/api/v1/templates'),
token
? fetch('/api/v1/projects', { headers: { Authorization: `Bearer ${token}` } })
: Promise.resolve({ ok: false }),
]);
if (!tmplRes.ok) throw new Error('Failed to fetch templates');
const tmplData = await tmplRes.json();
setTemplates(tmplData.items ?? []);
if (projRes && 'json' in projRes && projRes.ok) {
const pData = await (projRes as Response).json();
setProjects(pData.items ?? []);
} else if (!token) {
setProjects(
getAllGuestProjects().map((p) => ({
id: p.id,
title: p.title,
description: p.description,
})),
);
}
} catch {
toast.error('Failed to load collections');
toast.error('Failed to load data');
} finally {
setLoading(false);
}
};
fetchCollections();
}, []);
fetchData();
}, [token]);
return (
<div className="flex flex-col pt-10 pb-28 lg:pb-8 px-4 max-w-4xl mx-auto w-full">
@@ -39,7 +107,13 @@ const Page = () => {
<h1 className="text-4xl font-normal">Spaces</h1>
</div>
<div className="mt-4 flex gap-4">
<div className="mt-4 flex flex-wrap gap-4 items-center">
<Link
href="/spaces/new"
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl bg-[#7C3AED] text-white text-sm font-medium hover:opacity-90 transition"
>
+ Новый проект
</Link>
<Link
href="/spaces/templates"
className="text-sm text-[#7C3AED] hover:underline"
@@ -49,10 +123,63 @@ const Page = () => {
</div>
<p className="mt-2 text-black/60 dark:text-white/60">
Popular collections for research and analysis.
Создайте проект и начните исследование.
</p>
{loading ? (
{/* Popular Templates — compact carousel */}
{templates.length > 0 && (
<section className="mt-6">
<h2 className="text-lg font-medium mb-3">Popular Templates</h2>
<div className="flex gap-3 overflow-x-auto pb-2 -mx-4 px-4">
{templates.map((t) => {
const Icon = t.category ? CATEGORY_ICONS[t.category] ?? FileText : FileText;
return (
<Link
key={t.id}
href={`/spaces/new?template=${t.id}`}
className="flex-shrink-0 w-[200px] rounded-xl bg-light-secondary dark:bg-dark-secondary p-4 border border-light-200/20 dark:border-dark-200/20 hover:border-[#7C3AED]/40 transition-colors group"
>
<div className="flex items-center gap-2 mb-2">
<div className="p-1.5 rounded-lg bg-[#7C3AED]/10 group-hover:bg-[#7C3AED]/20 transition-colors">
<Icon size={18} className="text-[#7C3AED]" />
</div>
<span className="font-medium text-sm line-clamp-1">{t.title}</span>
</div>
{t.description && (
<p className="text-xs text-black/60 dark:text-white/60 line-clamp-2">
{t.description}
</p>
)}
</Link>
);
})}
</div>
</section>
)}
{projects.length > 0 && (
<section className="mt-6">
<h2 className="text-lg font-medium mb-3">Мои проекты</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{projects.map((p) => (
<Link
key={p.id}
href={`/spaces/${p.id}`}
className="rounded-2xl bg-light-secondary dark:bg-dark-secondary p-5 shadow-sm border border-light-200/20 dark:border-dark-200/20 block hover:border-[#7C3AED]/30 transition-colors"
>
<h2 className="font-medium text-lg">{p.title}</h2>
{p.description && (
<p className="text-sm text-black/60 dark:text-white/60 mt-1 line-clamp-2">
{p.description}
</p>
)}
</Link>
))}
</div>
</section>
)}
{loading && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-6">
{[1, 2, 3].map((i) => (
<div
@@ -61,29 +188,11 @@ const Page = () => {
/>
))}
</div>
) : collections.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-6">
{collections.map((c) => (
<Link
key={c.id}
href={`/collections/${c.id}`}
className="rounded-2xl bg-light-secondary dark:bg-dark-secondary p-5 shadow-sm border border-light-200/20 dark:border-dark-200/20 block hover:border-[#7C3AED]/30 transition-colors"
>
<h2 className="font-medium text-lg">{c.title}</h2>
{c.description && (
<p className="text-sm text-black/60 dark:text-white/60 mt-1">{c.description}</p>
)}
{c.category && (
<span className="inline-block mt-2 text-xs px-2 py-0.5 rounded bg-light-200 dark:bg-dark-200">
{c.category}
</span>
)}
</Link>
))}
</div>
) : (
)}
{!loading && projects.length === 0 && templates.length === 0 && (
<div className="mt-6 p-6 rounded-2xl bg-light-secondary dark:bg-dark-secondary text-center text-black/60 dark:text-white/60">
<p>No collections available. Enable projects-svc in deploy.config.yaml</p>
<p>Создайте первый проект, чтобы начать.</p>
</div>
)}
</div>

View File

@@ -7,28 +7,29 @@ import {
ChevronDown,
ChevronUp,
BookSearch,
CheckCircle2,
Clock,
Sparkles,
Globe,
Loader2,
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { useEffect, useState } from 'react';
import { useEffect, useState, useRef, useMemo } from 'react';
import { ResearchBlock, ResearchBlockSubStep } from '@/lib/types';
import { useChat } from '@/lib/hooks/useChat';
import { useTranslation } from '@/lib/localization/context';
const getStepIcon = (step: ResearchBlockSubStep) => {
if (step.type === 'reasoning') {
return <Brain className="w-4 h-4" />;
} else if (step.type === 'searching' || step.type === 'upload_searching') {
return <Search className="w-4 h-4" />;
} else if (
step.type === 'search_results' ||
step.type === 'upload_search_results'
) {
return <FileText className="w-4 h-4" />;
} else if (step.type === 'reading') {
return <BookSearch className="w-4 h-4" />;
}
const STEP_ICONS: Record<string, React.ReactNode> = {
reasoning: <Brain className="w-4 h-4" />,
searching: <Search className="w-4 h-4" />,
upload_searching: <Search className="w-4 h-4" />,
search_results: <Globe className="w-4 h-4" />,
upload_search_results: <FileText className="w-4 h-4" />,
reading: <BookSearch className="w-4 h-4" />,
};
return null;
const getStepIcon = (step: ResearchBlockSubStep) => {
return STEP_ICONS[step.type] || <Sparkles className="w-4 h-4" />;
};
const getStepTitle = (
@@ -57,6 +58,15 @@ const getStepTitle = (
return t('chat.processing');
};
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
const sec = Math.round(ms / 1000);
if (sec < 60) return `${sec}s`;
const min = Math.floor(sec / 60);
const remSec = sec % 60;
return `${min}m ${remSec}s`;
}
const AssistantSteps = ({
block,
status,
@@ -71,41 +81,108 @@ const AssistantSteps = ({
isLast && status === 'answering' ? true : false,
);
const { researchEnded, loading } = useChat();
const startTimeRef = useRef<number>(Date.now());
const [elapsedTime, setElapsedTime] = useState(0);
useEffect(() => {
if (researchEnded && isLast) {
setIsExpanded(false);
} else if (status === 'answering' && isLast) {
setIsExpanded(true);
startTimeRef.current = Date.now();
}
}, [researchEnded, status]);
}, [researchEnded, status, isLast]);
useEffect(() => {
if (status !== 'answering' || !isLast) return;
const interval = setInterval(() => {
setElapsedTime(Date.now() - startTimeRef.current);
}, 100);
return () => clearInterval(interval);
}, [status, isLast]);
const progress = useMemo(() => {
const stepsCount = block?.data.subSteps.length || 0;
if (status === 'completed') return 100;
if (stepsCount === 0) return 5;
const searchResults = block?.data.subSteps.filter(
(s) => s.type === 'search_results' || s.type === 'upload_search_results'
).length || 0;
const readingSteps = block?.data.subSteps.filter((s) => s.type === 'reading').length || 0;
return Math.min(95, 10 + stepsCount * 15 + searchResults * 20 + readingSteps * 10);
}, [block?.data.subSteps, status]);
if (!block || block.data.subSteps.length === 0) return null;
const isInProgress = status === 'answering' && isLast && !researchEnded;
return (
<div className="rounded-lg bg-light-secondary dark:bg-dark-secondary border border-light-200 dark:border-dark-200 overflow-hidden">
<div className="rounded-xl bg-light-secondary dark:bg-dark-secondary border border-light-200 dark:border-dark-200 overflow-hidden shadow-sm">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between p-3 hover:bg-light-200 dark:hover:bg-dark-200 transition duration-200"
className="w-full flex items-center justify-between p-3 hover:bg-light-200/50 dark:hover:bg-dark-200/50 transition duration-200"
>
<div className="flex items-center gap-2">
<Brain className="w-4 h-4 text-black dark:text-white" />
<span className="text-sm font-medium text-black dark:text-white">
{t('chat.researchProgress')} ({block.data.subSteps.length}{' '}
{block.data.subSteps.length === 1 ? t('chat.step') : t('chat.steps')})
{status === 'answering' && (
<span className="ml-2 font-normal text-black/60 dark:text-white/60">
(~3090 sec)
</span>
<div className="flex items-center gap-3">
<div className={`relative ${isInProgress ? 'animate-pulse' : ''}`}>
{isInProgress ? (
<Loader2 className="w-5 h-5 text-[#24A0ED] animate-spin" />
) : status === 'completed' ? (
<CheckCircle2 className="w-5 h-5 text-green-500" />
) : (
<Brain className="w-5 h-5 text-black dark:text-white" />
)}
</span>
</div>
<div className="flex flex-col items-start">
<span className="text-sm font-medium text-black dark:text-white">
{isInProgress
? t('chat.researching') || 'Исследуем...'
: status === 'completed'
? t('chat.researchComplete') || 'Исследование завершено'
: t('chat.researchProgress')}
</span>
<div className="flex items-center gap-2 text-xs text-black/60 dark:text-white/60">
<span>
{block.data.subSteps.length} {block.data.subSteps.length === 1 ? t('chat.step') : t('chat.steps')}
</span>
{isInProgress && (
<>
<span className="w-1 h-1 rounded-full bg-black/40 dark:bg-white/40" />
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{formatDuration(elapsedTime)}
</span>
</>
)}
</div>
</div>
</div>
<div className="flex items-center gap-3">
{isInProgress && (
<div className="hidden sm:flex items-center gap-2 px-2 py-1 rounded-full bg-[#24A0ED]/10 text-[#24A0ED] text-xs font-medium">
<span className="w-1.5 h-1.5 rounded-full bg-[#24A0ED] animate-pulse" />
{Math.round(progress)}%
</div>
)}
{isExpanded ? (
<ChevronUp className="w-4 h-4 text-black/70 dark:text-white/70" />
) : (
<ChevronDown className="w-4 h-4 text-black/70 dark:text-white/70" />
)}
</div>
{isExpanded ? (
<ChevronUp className="w-4 h-4 text-black/70 dark:text-white/70" />
) : (
<ChevronDown className="w-4 h-4 text-black/70 dark:text-white/70" />
)}
</button>
{isInProgress && (
<div className="h-1 bg-light-200 dark:bg-dark-200">
<motion.div
className="h-full bg-gradient-to-r from-[#24A0ED] to-[#24A0ED]/60"
initial={{ width: 0 }}
animate={{ width: `${progress}%` }}
transition={{ duration: 0.3, ease: 'easeOut' }}
/>
</div>
)}
<AnimatePresence>
{isExpanded && (

View File

@@ -3,6 +3,7 @@
import { Fragment, useEffect, useRef, useState } from 'react';
import MessageInput from './MessageInput';
import MessageBox from './MessageBox';
import GuestWarningBanner from './GuestWarningBanner';
import MessageBoxLoading from './MessageBoxLoading';
import { useChat } from '@/lib/hooks/useChat';
@@ -98,7 +99,10 @@ const Chat = () => {
'linear-gradient(to top, #0d1117 0%, #0d1117 35%, rgba(13,17,23,0.95) 45%, rgba(13,17,23,0.85) 55%, rgba(13,17,23,0.7) 65%, rgba(13,17,23,0.5) 75%, rgba(13,17,23,0.3) 85%, rgba(13,17,23,0.1) 92%, transparent 100%)',
}}
/>
<MessageInput />
<div className="flex flex-col">
<MessageInput />
<GuestWarningBanner />
</div>
</div>
)}
</div>

View File

@@ -5,7 +5,6 @@ import Chat from './Chat';
import EmptyChat from './EmptyChat';
import NextError from 'next/error';
import { useChat } from '@/lib/hooks/useChat';
import SettingsButtonMobile from './Settings/SettingsButtonMobile';
import { Block } from '@/lib/types';
export interface BaseMessage {
@@ -29,9 +28,26 @@ export interface File {
fileId: string;
}
export type WidgetType =
| 'weather'
| 'stock'
| 'calculation_result'
| 'products'
| 'videos'
| 'profiles'
| 'promos'
| 'product'
| 'video'
| 'profile'
| 'promo'
| 'knowledge_card'
| 'image_gallery'
| 'video_embed'
| 'knowledge_card_hint';
export interface Widget {
widgetType: string;
params: Record<string, any>;
widgetType: WidgetType;
params: Record<string, unknown>;
}
const ChatWindow = () => {
@@ -40,9 +56,6 @@ const ChatWindow = () => {
if (hasError) {
return (
<div className="relative">
<div className="absolute w-full flex flex-row items-center justify-end mr-5 mt-5">
<SettingsButtonMobile />
</div>
<div className="flex flex-col items-center justify-center min-h-screen">
<p className="dark:text-white/70 text-black/70 text-sm">
Failed to connect to the server. Please try again later.

View File

@@ -1,60 +1,87 @@
'use client';
import { Discover } from '@/app/discover/page';
import Link from 'next/link';
import { useState, memo } from 'react';
import { ExternalLink, NewspaperIcon } from 'lucide-react';
const MajorNewsCard = ({
const PLACEHOLDER_SVG =
'data:image/svg+xml,' +
encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="400" height="225" viewBox="0 0 400 225"><rect fill="%23e5e7eb" width="400" height="225"/><text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" fill="%236b7280" font-family="sans-serif" font-size="16">Post</text></svg>'
);
const MajorNewsCard = memo(function MajorNewsCard({
item,
isLeft = true,
}: {
item: Discover;
isLeft?: boolean;
}) => (
}) {
const [imgSrc, setImgSrc] = useState(item.thumbnail);
const chatQuery = item.digestId
? `/?q=${encodeURIComponent(`Summary: ${item.url}`)}&title=${encodeURIComponent(item.title)}&digestId=${encodeURIComponent(item.digestId)}`
: `/?q=${encodeURIComponent(`Summary: ${item.url}`)}&title=${encodeURIComponent(item.title)}`;
const imgProps = {
className: 'object-cover w-full h-full group-hover:scale-105 transition-transform duration-500',
src: imgSrc,
alt: item.title,
referrerPolicy: 'no-referrer' as const,
onError: () => setImgSrc(PLACEHOLDER_SVG),
};
const contentBlock = (
<div className="flex flex-col justify-center flex-1 py-4">
<h2
className="text-3xl font-light mb-3 leading-tight line-clamp-3 group-hover:text-[#EA580C] transition duration-200"
>
{item.title}
</h2>
<p className="text-black/60 dark:text-white/60 text-base leading-relaxed line-clamp-4">
{item.content}
</p>
{item.sourcesCount != null && item.sourcesCount > 0 && (
<span className="inline-flex items-center gap-1.5 mt-3 text-xs text-[#EA580C] font-medium">
<NewspaperIcon className="w-3.5 h-3.5" aria-hidden />
{item.sourcesCount} источников
</span>
)}
</div>
);
return (
<div className="w-full group flex flex-row items-stretch gap-6 h-60 py-3 relative">
<Link
href={`/?q=${encodeURIComponent(`Summary: ${item.url}`)}&title=${encodeURIComponent(item.title)}`}
className="w-full group flex flex-row items-stretch gap-6 h-60 py-3"
href={chatQuery}
className="flex-1 flex flex-row items-stretch gap-6 min-w-0 min-h-0"
target="_blank"
>
{isLeft ? (
<>
<div className="relative w-80 h-full overflow-hidden rounded-2xl flex-shrink-0">
<img
className="object-cover w-full h-full group-hover:scale-105 transition-transform duration-500"
src={item.thumbnail}
alt={item.title}
/>
</div>
<div className="flex flex-col justify-center flex-1 py-4">
<h2
className="text-3xl font-light mb-3 leading-tight line-clamp-3 group-hover:text-[#EA580C] transition duration-200"
>
{item.title}
</h2>
<p className="text-black/60 dark:text-white/60 text-base leading-relaxed line-clamp-4">
{item.content}
</p>
<div className="relative w-80 h-full overflow-hidden rounded-2xl flex-shrink-0 bg-light-200 dark:bg-dark-200">
<img {...imgProps} />
</div>
{contentBlock}
</>
) : (
<>
<div className="flex flex-col justify-center flex-1 py-4">
<h2
className="text-3xl font-light mb-3 leading-tight line-clamp-3 group-hover:text-[#EA580C] transition duration-200"
>
{item.title}
</h2>
<p className="text-black/60 dark:text-white/60 text-base leading-relaxed line-clamp-4">
{item.content}
</p>
</div>
<div className="relative w-80 h-full overflow-hidden rounded-2xl flex-shrink-0">
<img
className="object-cover w-full h-full group-hover:scale-105 transition-transform duration-500"
src={item.thumbnail}
alt={item.title}
/>
{contentBlock}
<div className="relative w-80 h-full overflow-hidden rounded-2xl flex-shrink-0 bg-light-200 dark:bg-dark-200">
<img {...imgProps} />
</div>
</>
)}
</Link>
);
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="absolute top-2 right-2 p-1.5 rounded-full bg-black/40 dark:bg-white/40 hover:bg-[#EA580C] text-white dark:text-black transition-colors"
title="Оригинал"
aria-label="Оригинал"
>
<ExternalLink className="w-4 h-4" />
</a>
</div>
);
});
export default MajorNewsCard;

View File

@@ -1,18 +1,42 @@
'use client';
import { Discover } from '@/app/discover/page';
import Link from 'next/link';
import { useState, memo } from 'react';
import { NewspaperIcon, ExternalLink } from 'lucide-react';
const SmallNewsCard = ({ item }: { item: Discover }) => (
<Link
href={`/?q=${encodeURIComponent(`Summary: ${item.url}`)}&title=${encodeURIComponent(item.title)}`}
className="rounded-3xl overflow-hidden bg-light-secondary dark:bg-dark-secondary shadow-sm shadow-light-200/10 dark:shadow-black/25 group flex flex-col"
target="_blank"
>
<div className="relative aspect-video overflow-hidden">
const PLACEHOLDER_SVG =
'data:image/svg+xml,' +
encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="400" height="225" viewBox="0 0 400 225"><rect fill="%23e5e7eb" width="400" height="225"/><text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" fill="%236b7280" font-family="sans-serif" font-size="16">Post</text></svg>'
);
const SmallNewsCard = memo(function SmallNewsCard({ item }: { item: Discover }) {
const [imgSrc, setImgSrc] = useState(item.thumbnail);
const chatQuery = item.digestId
? `/?q=${encodeURIComponent(`Summary: ${item.url}`)}&title=${encodeURIComponent(item.title)}&digestId=${encodeURIComponent(item.digestId)}`
: `/?q=${encodeURIComponent(`Summary: ${item.url}`)}&title=${encodeURIComponent(item.title)}`;
return (
<div className="rounded-3xl overflow-hidden bg-light-secondary dark:bg-dark-secondary shadow-sm shadow-light-200/10 dark:shadow-black/25 group flex flex-col relative">
<Link
href={chatQuery}
className="flex flex-col flex-1"
target="_blank"
>
<div className="relative aspect-video overflow-hidden bg-light-200 dark:bg-dark-200">
<img
className="object-cover w-full h-full group-hover:scale-105 transition-transform duration-300"
src={item.thumbnail}
src={imgSrc}
alt={item.title}
referrerPolicy="no-referrer"
onError={() => setImgSrc(PLACEHOLDER_SVG)}
/>
{item.sourcesCount != null && item.sourcesCount > 0 && (
<span className="absolute bottom-2 left-2 inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-black/60 backdrop-blur-sm text-white text-[10px] font-medium">
<NewspaperIcon className="w-3 h-3" aria-hidden />
{item.sourcesCount} источников
</span>
)}
</div>
<div className="p-4">
<h3 className="font-semibold text-sm mb-2 leading-tight line-clamp-2 group-hover:text-[#EA580C] transition duration-200">
@@ -22,7 +46,39 @@ const SmallNewsCard = ({ item }: { item: Discover }) => (
{item.content}
</p>
</div>
</Link>
);
</Link>
{item.sources && item.sources.length > 0 && !item.sourcesCount && (
<div className="px-4 pb-3 mt-0 flex items-center gap-1 flex-wrap">
<NewspaperIcon className="w-3.5 h-3.5 text-black/40 dark:text-white/40 flex-shrink-0" aria-hidden />
<span className="text-[10px] text-black/50 dark:text-white/50">Источники:</span>
{item.sources.slice(0, 2).map((s, i) => (
<a
key={i}
href={s.url}
target="_blank"
rel="noopener noreferrer"
className="text-[10px] text-[#EA580C] hover:underline truncate max-w-[80px]"
>
{s.title || (() => { try { return new URL(s.url).hostname; } catch { return s.url.slice(0, 15) + (s.url.length > 15 ? '…' : ''); } })()}
</a>
))}
{item.sources.length > 2 && (
<span className="text-[10px] text-black/40 dark:text-white/40">+{item.sources.length - 2}</span>
)}
</div>
)}
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="absolute top-2 right-2 p-1.5 rounded-full bg-black/40 dark:bg-white/40 hover:bg-[#EA580C] text-white dark:text-black transition-colors"
title="Оригинал"
aria-label="Оригинал"
>
<ExternalLink className="w-4 h-4" />
</a>
</div>
);
});
export default SmallNewsCard;

View File

@@ -4,7 +4,6 @@ import { useEffect, useState } from 'react';
import EmptyChatMessageInput from './EmptyChatMessageInput';
import WeatherWidget from './WeatherWidget';
import NewsArticleWidget from './NewsArticleWidget';
import SettingsButtonMobile from '@/components/Settings/SettingsButtonMobile';
import {
getShowNewsWidget,
getShowWeatherWidget,
@@ -42,9 +41,6 @@ const EmptyChat = () => {
return (
<div className="relative">
<div className="absolute w-full flex flex-row items-center justify-end mr-5 mt-5">
<SettingsButtonMobile />
</div>
<div className="flex flex-col items-center justify-center min-h-screen max-w-screen-sm mx-auto p-2 space-y-4">
<div className="flex flex-col items-center justify-center w-full space-y-6">
<div className="flex flex-row items-center justify-center -mt-8 mb-2">

View File

@@ -37,19 +37,15 @@ export default function GuestWarningBanner() {
return (
<div
role="alert"
className="fixed bottom-4 left-1/2 -translate-x-1/2 z-50 max-w-lg w-[calc(100%-2rem)]"
className="flex items-center justify-center gap-1.5 text-[10px] text-amber-700 dark:text-amber-400 mt-1.5"
>
<div className="rounded-xl bg-amber-500/95 dark:bg-amber-600/95 text-amber-950 dark:text-amber-50 px-4 py-3 shadow-lg flex flex-col sm:flex-row items-center justify-between gap-2">
<span className="text-sm font-medium">
Your chat history will be lost when you close the browser.
</span>
<Link
href="/profile"
className="text-sm font-semibold underline underline-offset-2 hover:no-underline whitespace-nowrap"
>
Save to account
</Link>
</div>
<span>Your chat history will be lost when you close the browser.</span>
<Link
href="/profile"
className="underline underline-offset-1 hover:no-underline whitespace-nowrap"
>
Save to account
</Link>
</div>
);
}

View File

@@ -28,6 +28,21 @@ import { ResearchBlock } from '@/lib/types';
import Renderer from './Widgets/Renderer';
import CodeBlock from './MessageRenderer/CodeBlock';
function extractFollowUp(text: string): { mainContent: string; followUpQuestions: string[] } {
const dividerIdx = text.lastIndexOf('\n---');
if (dividerIdx === -1) return { mainContent: text, followUpQuestions: [] };
const afterDivider = text.slice(dividerIdx + 4).trim();
const lines = afterDivider.split('\n').map((l) => l.trim()).filter(Boolean);
const questions = lines
.filter((l) => l.startsWith('> '))
.map((l) => l.slice(2).trim())
.filter((q) => q.length > 5);
if (questions.length === 0) return { mainContent: text, followUpQuestions: [] };
return { mainContent: text.slice(0, dividerIdx).trim(), followUpQuestions: questions };
}
const ThinkTagProcessor = ({
children,
thinkingEnded,
@@ -61,7 +76,8 @@ const MessageBox = ({
} = useChat();
const { t } = useTranslation();
const parsedMessage = section.parsedTextBlocks.join('\n\n');
const rawParsedMessage = section.parsedTextBlocks.join('\n\n');
const { mainContent: parsedMessage, followUpQuestions } = extractFollowUp(rawParsedMessage);
const speechMessage = section.speechMessage || '';
const thinkingEnded = section.thinkingEnded;
@@ -252,6 +268,24 @@ const MessageBox = ({
</div>
)}
{followUpQuestions.length > 0 && !loading && (
<div className="mt-4">
<div className="flex flex-wrap gap-2">
{followUpQuestions.map((q, i) => (
<button
key={i}
onClick={() => sendMessage(q)}
className="text-sm px-3.5 py-2 rounded-full border border-[#EA580C]/30
text-[#EA580C] hover:bg-[#EA580C]/10 hover:border-[#EA580C]/50
transition-colors duration-200 text-left leading-snug"
>
{q}
</button>
))}
</div>
</div>
)}
{isLast &&
section.suggestions &&
section.suggestions.length > 0 &&

View File

@@ -1,10 +1,10 @@
'use client';
/**
* Input bar «+» — меню: режимы, источники, Learn, Create, Model Council
* Input bar «+» — меню: режимы, источники, Learn, Create
*/
import { Plus, Zap, Sliders, Star, Globe, GraduationCap, Network, BookOpen, Users } from 'lucide-react';
import { Plus, Zap, Sliders, Star, Globe, GraduationCap, Network, BookOpen } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react';
import { useChat } from '@/lib/hooks/useChat';
@@ -26,14 +26,11 @@ const SOURCES = [
const InputBarPlus = () => {
const { optimizationMode, setOptimizationMode, sources, setSources } = useChat();
const [learningMode, setLearningModeState] = useState(false);
const [modelCouncil, setModelCouncilState] = useState(false);
useEffect(() => {
setLearningModeState(typeof window !== 'undefined' && localStorage.getItem('learningMode') === 'true');
setModelCouncilState(typeof window !== 'undefined' && localStorage.getItem('modelCouncil') === 'true');
const handler = () => {
setLearningModeState(localStorage.getItem('learningMode') === 'true');
setModelCouncilState(localStorage.getItem('modelCouncil') === 'true');
};
window.addEventListener('client-config-changed', handler);
return () => window.removeEventListener('client-config-changed', handler);
@@ -45,15 +42,10 @@ const InputBarPlus = () => {
window.dispatchEvent(new Event('client-config-changed'));
}, []);
const setModelCouncil = useCallback((v: boolean) => {
localStorage.setItem('modelCouncil', String(v));
setModelCouncilState(v);
window.dispatchEvent(new Event('client-config-changed'));
}, []);
const toggleSource = useCallback(
(key: string) => {
if (sources.includes(key)) {
if (sources.length <= 1) return;
setSources(sources.filter((s) => s !== key));
} else {
setSources([...sources, key]);
@@ -158,22 +150,6 @@ const InputBarPlus = () => {
</button>
</div>
<div className="p-2 border-b border-light-200/50 dark:border-dark-200/50">
<button
type="button"
onClick={() => setModelCouncil(!modelCouncil)}
className={cn(
'w-full flex items-center gap-2 py-2 px-3 rounded-lg text-sm transition',
modelCouncil ? 'bg-[#EA580C]/20 text-[#EA580C]' : 'hover:bg-light-200/50 dark:hover:bg-dark-200/50'
)}
title="Model Council (Max): 3 models in parallel → synthesis"
>
<Users size={16} />
Model Council
{modelCouncil && <span className="ml-auto text-xs">On</span>}
</button>
</div>
<div className="p-2">
<p className="text-xs font-medium text-black/60 dark:text-white/60 px-2 py-1">Create</p>
<p className="text-xs text-black/50 dark:text-white/50 px-2 pb-2">

View File

@@ -1,19 +1,161 @@
'use client';
import { useState, useCallback, useMemo } from 'react';
import { ExternalLink } from 'lucide-react';
interface CitationProps {
href: string;
children: React.ReactNode;
title?: string;
description?: string;
favicon?: string;
}
const Citation = ({
href,
children,
}: {
href: string;
children: React.ReactNode;
}) => {
title,
description,
favicon,
}: CitationProps) => {
const [showTooltip, setShowTooltip] = useState(false);
const [imageError, setImageError] = useState(false);
const { domain, faviconUrl, platformIcon } = useMemo(() => {
let d = '';
try {
d = new URL(href).hostname.replace(/^www\./, '');
} catch {
d = href.slice(0, 30);
}
const icon = getPlatformIcon(d);
const fav = favicon || `https://www.google.com/s2/favicons?domain=${d}&sz=32`;
return { domain: d, faviconUrl: fav, platformIcon: icon };
}, [href, favicon]);
const handleMouseEnter = useCallback(() => setShowTooltip(true), []);
const handleMouseLeave = useCallback(() => setShowTooltip(false), []);
const handleImageError = useCallback(() => setImageError(true), []);
const displayTitle = title || domain;
const truncatedDescription = description
? description.length > 120
? description.slice(0, 120) + '...'
: description
: null;
return (
<a
href={href}
target="_blank"
className="bg-light-secondary dark:bg-dark-secondary px-1 rounded ml-1 no-underline text-xs text-black/70 dark:text-white/70 relative"
>
{children}
</a>
<span className="relative inline-block">
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center bg-[#EA580C]/10 hover:bg-[#EA580C]/20
w-5 h-5 rounded-full text-xs text-[#EA580C] font-semibold
no-underline transition-all cursor-pointer ml-0.5 align-super
hover:scale-110 hover:shadow-sm"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
aria-label={`Источник ${children}: ${displayTitle}`}
>
{children}
</a>
{showTooltip && (
<div
className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2
w-72 max-w-[90vw] p-3
bg-white dark:bg-dark-secondary
border border-light-200 dark:border-dark-200
rounded-xl shadow-xl
pointer-events-none z-50
animate-in fade-in-0 zoom-in-95 slide-in-from-bottom-2 duration-200"
>
<div className="flex items-start gap-2.5">
<div className="flex-shrink-0 w-8 h-8 rounded-lg bg-light-100 dark:bg-dark-100
flex items-center justify-center overflow-hidden">
{imageError ? (
<span className="text-base">{platformIcon}</span>
) : (
<img
src={faviconUrl}
alt=""
className="w-5 h-5 rounded"
loading="lazy"
onError={handleImageError}
/>
)}
</div>
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-center gap-1.5">
<span className="text-[10px] text-black/50 dark:text-white/50
bg-light-100 dark:bg-dark-100 px-1.5 py-0.5 rounded">
{domain}
</span>
</div>
<p className="text-sm font-medium text-black dark:text-white
leading-tight line-clamp-2">
{displayTitle}
</p>
{truncatedDescription && (
<p className="text-xs text-black/60 dark:text-white/60
leading-relaxed line-clamp-2">
{truncatedDescription}
</p>
)}
</div>
<ExternalLink
size={12}
className="flex-shrink-0 text-black/30 dark:text-white/30 mt-0.5"
/>
</div>
<div
className="absolute -bottom-1.5 left-1/2 -translate-x-1/2
w-3 h-3 rotate-45
bg-white dark:bg-dark-secondary
border-r border-b border-light-200 dark:border-dark-200"
/>
</div>
)}
</span>
);
};
function getPlatformIcon(domain: string): string {
const icons: Record<string, string> = {
'yandex.ru': '🔍',
'ya.ru': '🔍',
'vk.com': '💙',
'rutube.ru': '🎬',
'youtube.com': '▶️',
'youtu.be': '▶️',
'ozon.ru': '🛒',
'wildberries.ru': '🟣',
'aliexpress.ru': '🛍️',
'aliexpress.com': '🛍️',
'dzen.ru': '📰',
't.me': '✈️',
'telegram.org': '✈️',
'wikipedia.org': '📚',
'github.com': '🐙',
'stackoverflow.com': '📋',
'habr.com': '💻',
};
for (const [key, icon] of Object.entries(icons)) {
if (domain.includes(key.replace('www.', ''))) {
return icon;
}
}
return '🌐';
}
export default Citation;

View File

@@ -1,5 +1,13 @@
'use client';
import { useEffect, useState } from 'react';
const PLACEHOLDER_SVG =
'data:image/svg+xml,' +
encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80" viewBox="0 0 80 80"><rect fill="%23e5e7eb" width="80" height="80"/><text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" fill="%236b7280" font-family="sans-serif" font-size="10">Post</text></svg>'
);
interface Article {
title: string;
content: string;
@@ -9,6 +17,7 @@ interface Article {
const NewsArticleWidget = () => {
const [article, setArticle] = useState<Article | null>(null);
const [imgSrc, setImgSrc] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
@@ -17,7 +26,9 @@ const NewsArticleWidget = () => {
.then((res) => res.json())
.then((data) => {
const articles = (data.blogs || []).filter((a: Article) => a.thumbnail);
setArticle(articles[Math.floor(Math.random() * articles.length)]);
const a = articles[Math.floor(Math.random() * articles.length)];
setArticle(a);
setImgSrc(a?.thumbnail ?? null);
setLoading(false);
})
.catch(() => {
@@ -43,15 +54,13 @@ const NewsArticleWidget = () => {
href={`/?q=${encodeURIComponent(`Summary: ${article.url}`)}&title=${encodeURIComponent(article.title)}`}
className="flex flex-row items-stretch w-full h-full relative overflow-hidden group"
>
<div className="relative w-20 min-w-20 max-w-20 h-full overflow-hidden">
<div className="relative w-20 min-w-20 max-w-20 h-full overflow-hidden bg-light-200 dark:bg-dark-200">
<img
className="object-cover w-full h-full bg-light-200 dark:bg-dark-200 group-hover:scale-110 transition-transform duration-300"
src={
new URL(article.thumbnail).origin +
new URL(article.thumbnail).pathname +
`?id=${new URL(article.thumbnail).searchParams.get('id')}`
}
className="object-cover w-full h-full group-hover:scale-110 transition-transform duration-300"
src={imgSrc ?? article.thumbnail}
alt={article.title}
referrerPolicy="no-referrer"
onError={() => setImgSrc(PLACEHOLDER_SVG)}
/>
</div>
<div className="flex flex-col justify-center flex-1 px-2.5 py-1.5 min-w-0">

View File

@@ -0,0 +1,194 @@
'use client';
import { useState, useCallback } from 'react';
import { CornerDownRight, Plus, ChevronDown, ChevronUp, Sparkles } from 'lucide-react';
import { cn } from '@/lib/utils';
interface RelatedQuestionsProps {
questions: string[];
onSelect: (question: string) => void;
style?: 'inline' | 'panel' | 'chips';
title?: string;
maxVisible?: number;
className?: string;
}
export function RelatedQuestions({
questions,
onSelect,
style = 'inline',
title = 'Связанные вопросы',
maxVisible = 4,
className,
}: RelatedQuestionsProps) {
const [expanded, setExpanded] = useState(false);
const displayQuestions = expanded ? questions : questions.slice(0, maxVisible);
const hasMore = questions.length > maxVisible;
const handleSelect = useCallback(
(question: string) => {
onSelect(question);
},
[onSelect]
);
if (!questions || questions.length === 0) return null;
if (style === 'chips') {
return (
<div className={cn('space-y-3', className)}>
{title && (
<div className="flex items-center gap-2">
<Sparkles size={16} className="text-[#EA580C]" />
<h3 className="text-sm font-semibold text-black dark:text-white">
{title}
</h3>
</div>
)}
<div className="flex flex-wrap gap-2">
{displayQuestions.map((q, i) => (
<button
key={i}
onClick={() => handleSelect(q)}
className="text-sm px-3.5 py-2 rounded-full border border-[#EA580C]/30
text-[#EA580C] hover:bg-[#EA580C]/10 hover:border-[#EA580C]/50
transition-colors duration-200 text-left leading-snug"
>
{q}
</button>
))}
{hasMore && !expanded && (
<button
onClick={() => setExpanded(true)}
className="text-sm px-3.5 py-2 rounded-full border border-light-200 dark:border-dark-200
text-black/50 dark:text-white/50 hover:border-[#EA580C]/30 hover:text-[#EA580C]
transition-colors duration-200"
>
+{questions.length - maxVisible}
</button>
)}
</div>
</div>
);
}
if (style === 'panel') {
return (
<div
className={cn(
'bg-light-50 dark:bg-dark-100 rounded-xl border border-light-200 dark:border-dark-200 p-4',
className
)}
>
<div className="flex items-center gap-2 mb-3">
<Sparkles size={16} className="text-[#EA580C]" />
<h3 className="text-sm font-semibold text-black dark:text-white">
{title}
</h3>
</div>
<div className="space-y-2">
{displayQuestions.map((q, i) => (
<button
key={i}
onClick={() => handleSelect(q)}
className="w-full group flex items-center gap-3 p-3 rounded-lg
bg-white dark:bg-dark-secondary
border border-light-200 dark:border-dark-200
hover:border-[#EA580C]/30 hover:shadow-sm
transition-all duration-200 text-left"
>
<CornerDownRight
size={14}
className="text-black/30 dark:text-white/30 group-hover:text-[#EA580C] transition-colors flex-shrink-0"
/>
<span className="flex-1 text-sm text-black/70 dark:text-white/70 group-hover:text-black dark:group-hover:text-white transition-colors">
{q}
</span>
<Plus
size={14}
className="text-black/20 dark:text-white/20 group-hover:text-[#EA580C] transition-colors flex-shrink-0"
/>
</button>
))}
</div>
{hasMore && (
<button
onClick={() => setExpanded(!expanded)}
className="w-full flex items-center justify-center gap-1 mt-3 pt-3 border-t border-light-200 dark:border-dark-200 text-xs text-black/50 dark:text-white/50 hover:text-[#EA580C] transition-colors"
>
{expanded ? (
<>
<ChevronUp size={14} />
Свернуть
</>
) : (
<>
<ChevronDown size={14} />
Показать ещё {questions.length - maxVisible}
</>
)}
</button>
)}
</div>
);
}
return (
<div className={cn('space-y-0', className)}>
{title && (
<div className="flex items-center gap-2 mb-4">
<Sparkles size={18} className="text-[#EA580C]" />
<h3 className="text-lg font-semibold text-black dark:text-white">
{title}
</h3>
</div>
)}
{displayQuestions.map((q, i) => (
<div key={i}>
<div className="h-px bg-light-200/40 dark:bg-dark-200/40" />
<button
onClick={() => handleSelect(q)}
className="group w-full py-4 text-left transition-colors duration-200"
>
<div className="flex items-center justify-between gap-3">
<div className="flex flex-row space-x-3 items-center">
<CornerDownRight
size={15}
className="text-black/30 dark:text-white/30 group-hover:text-[#EA580C] transition-colors duration-200 flex-shrink-0"
/>
<p className="text-sm text-black/70 dark:text-white/70 group-hover:text-[#EA580C] transition-colors duration-200 leading-relaxed">
{q}
</p>
</div>
<Plus
size={16}
className="text-black/40 dark:text-white/40 group-hover:text-[#EA580C] transition-colors duration-200 flex-shrink-0"
/>
</div>
</button>
</div>
))}
{hasMore && (
<button
onClick={() => setExpanded(!expanded)}
className="w-full flex items-center justify-center gap-1 py-3 text-xs text-black/50 dark:text-white/50 hover:text-[#EA580C] transition-colors"
>
{expanded ? (
<>
<ChevronUp size={14} />
Свернуть
</>
) : (
<>
<ChevronDown size={14} />
Показать ещё {questions.length - maxVisible}
</>
)}
</button>
)}
</div>
);
}
export default RelatedQuestions;

View File

@@ -4,10 +4,10 @@ import { cn } from '@/lib/utils';
import {
MessagesSquare,
Plus,
Search,
Newspaper,
TrendingUp,
Plane,
FolderOpen,
LayoutPanelLeft,
User,
Stethoscope,
Building2,
@@ -279,7 +279,7 @@ const Sidebar = ({ children }: { children: React.ReactNode }) => {
}, []);
const discoverLink = {
icon: Search,
icon: Newspaper,
href: '/discover',
active: segments.includes('discover'),
label: t('nav.discover'),
@@ -307,7 +307,7 @@ const Sidebar = ({ children }: { children: React.ReactNode }) => {
};
const spacesLink = {
icon: FolderOpen,
icon: LayoutPanelLeft,
href: '/spaces',
active: segments.includes('spaces'),
label: t('nav.spaces'),

View File

@@ -0,0 +1,264 @@
'use client';
import { useState, useMemo, useCallback } from 'react';
import { ChevronDown, ChevronUp, ExternalLink, Search, Globe } from 'lucide-react';
import { cn } from '@/lib/utils';
export interface SourceItem {
index: number;
url: string;
title: string;
domain?: string;
favicon?: string;
snippet?: string;
publishedAt?: string;
author?: string;
}
interface SourcesPanelProps {
sources: SourceItem[];
expanded?: boolean;
maxVisibleCollapsed?: number;
groupByDomain?: boolean;
searchable?: boolean;
className?: string;
}
function getDomain(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./, '');
} catch {
return url.slice(0, 30);
}
}
function getFaviconUrl(domain: string): string {
return `https://www.google.com/s2/favicons?domain=${domain}&sz=32`;
}
export function SourcesPanel({
sources,
expanded: initialExpanded = false,
maxVisibleCollapsed = 4,
groupByDomain = false,
searchable = false,
className,
}: SourcesPanelProps) {
const [expanded, setExpanded] = useState(initialExpanded);
const [searchQuery, setSearchQuery] = useState('');
const [imageErrors, setImageErrors] = useState<Set<number>>(new Set());
const handleImageError = useCallback((index: number) => {
setImageErrors((prev) => new Set(prev).add(index));
}, []);
const processedSources = useMemo(() => {
return sources.map((s) => ({
...s,
domain: s.domain || getDomain(s.url),
favicon: s.favicon || getFaviconUrl(s.domain || getDomain(s.url)),
}));
}, [sources]);
const filteredSources = useMemo(() => {
if (!searchQuery) return processedSources;
const q = searchQuery.toLowerCase();
return processedSources.filter(
(s) =>
s.title.toLowerCase().includes(q) ||
s.domain?.toLowerCase().includes(q) ||
s.snippet?.toLowerCase().includes(q)
);
}, [processedSources, searchQuery]);
const groupedSources = useMemo(() => {
if (!groupByDomain) return null;
const groups: Record<string, typeof filteredSources> = {};
for (const source of filteredSources) {
const domain = source.domain || 'other';
if (!groups[domain]) groups[domain] = [];
groups[domain].push(source);
}
return groups;
}, [filteredSources, groupByDomain]);
const displaySources = expanded
? filteredSources
: filteredSources.slice(0, maxVisibleCollapsed);
const hasMore = filteredSources.length > maxVisibleCollapsed;
if (!sources || sources.length === 0) return null;
return (
<div
className={cn(
'bg-light-50 dark:bg-dark-100 rounded-xl border border-light-200 dark:border-dark-200',
className
)}
>
{searchable && sources.length > 5 && (
<div className="p-3 border-b border-light-200 dark:border-dark-200">
<div className="relative">
<Search
size={14}
className="absolute left-3 top-1/2 -translate-y-1/2 text-black/40 dark:text-white/40"
/>
<input
type="text"
placeholder="Поиск по источникам..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-9 pr-3 py-2 text-xs bg-white dark:bg-dark-secondary rounded-lg border border-light-200 dark:border-dark-200 text-black dark:text-white placeholder:text-black/40 dark:placeholder:text-white/40 focus:outline-none focus:ring-2 focus:ring-[#EA580C]/30"
/>
</div>
</div>
)}
<div className="p-3 space-y-2">
{groupByDomain && groupedSources ? (
Object.entries(groupedSources).map(([domain, domainSources]) => (
<div key={domain} className="space-y-1">
<div className="flex items-center gap-2 px-2 py-1">
<img
src={getFaviconUrl(domain)}
alt=""
className="w-4 h-4 rounded"
loading="lazy"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
<span className="text-xs font-medium text-black/70 dark:text-white/70">
{domain}
</span>
<span className="text-[10px] text-black/40 dark:text-white/40">
({domainSources.length})
</span>
</div>
{domainSources.map((source) => (
<SourceCard
key={source.index}
source={source}
imageError={imageErrors.has(source.index)}
onImageError={() => handleImageError(source.index)}
compact
/>
))}
</div>
))
) : (
displaySources.map((source) => (
<SourceCard
key={source.index}
source={source}
imageError={imageErrors.has(source.index)}
onImageError={() => handleImageError(source.index)}
/>
))
)}
{hasMore && !groupByDomain && (
<button
onClick={() => setExpanded(!expanded)}
className="w-full flex items-center justify-center gap-1.5 py-2 text-xs font-medium text-black/60 dark:text-white/60 hover:text-[#EA580C] transition-colors"
>
{expanded ? (
<>
<ChevronUp size={14} />
Свернуть
</>
) : (
<>
<ChevronDown size={14} />
Показать ещё {filteredSources.length - maxVisibleCollapsed}
</>
)}
</button>
)}
{searchQuery && filteredSources.length === 0 && (
<div className="py-4 text-center text-xs text-black/40 dark:text-white/40">
Ничего не найдено
</div>
)}
</div>
</div>
);
}
interface SourceCardProps {
source: SourceItem & { domain: string; favicon: string };
imageError: boolean;
onImageError: () => void;
compact?: boolean;
}
function SourceCard({ source, imageError, onImageError, compact = false }: SourceCardProps) {
return (
<a
href={source.url}
target="_blank"
rel="noopener noreferrer"
className={cn(
'flex items-start gap-3 p-2 rounded-lg transition-colors',
'hover:bg-light-100 dark:hover:bg-dark-200 group'
)}
>
<div className="flex-shrink-0 flex items-center justify-center w-8 h-8 rounded-lg bg-white dark:bg-dark-secondary border border-light-200 dark:border-dark-200">
{imageError ? (
<Globe size={14} className="text-black/40 dark:text-white/40" />
) : (
<img
src={source.favicon}
alt=""
className="w-4 h-4 rounded"
loading="lazy"
onError={onImageError}
/>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="inline-flex items-center justify-center w-5 h-5 rounded bg-[#EA580C]/10 text-[10px] font-bold text-[#EA580C]">
{source.index}
</span>
<span className="text-[10px] text-black/40 dark:text-white/40 truncate">
{source.domain}
</span>
</div>
<h4
className={cn(
'font-medium text-black dark:text-white leading-tight mt-1',
compact ? 'text-xs line-clamp-1' : 'text-sm line-clamp-2'
)}
>
{source.title}
</h4>
{source.snippet && !compact && (
<p className="text-xs text-black/50 dark:text-white/50 line-clamp-2 mt-1">
{source.snippet}
</p>
)}
{(source.author || source.publishedAt) && !compact && (
<div className="flex items-center gap-2 mt-1.5 text-[10px] text-black/40 dark:text-white/40">
{source.author && <span>{source.author}</span>}
{source.author && source.publishedAt && <span></span>}
{source.publishedAt && <span>{source.publishedAt}</span>}
</div>
)}
</div>
<ExternalLink
size={12}
className="flex-shrink-0 text-black/20 dark:text-white/20 opacity-0 group-hover:opacity-100 transition-opacity mt-1"
/>
</a>
);
}
export default SourcesPanel;

View File

@@ -33,21 +33,37 @@ const WeatherWidget = () => {
}),
});
const weatherData = await res.json();
const weatherData = (await res.json()) as {
temperature?: number;
condition?: string;
city?: string;
humidity?: number;
windSpeed?: number;
icon?: string;
temperatureUnit?: string;
windSpeedUnit?: string;
message?: string;
};
if (res.status !== 200) {
throw new Error(weatherData.message ?? 'Weather fetch failed');
}
const temp = weatherData.temperature;
const icon = weatherData.icon;
if (temp == null || !icon) {
throw new Error('Invalid weather response');
}
setData({
temperature: weatherData.temperature,
condition: weatherData.condition,
temperature: temp,
condition: weatherData.condition ?? '',
location: weatherData.city ?? city ?? 'Unknown',
humidity: weatherData.humidity,
windSpeed: weatherData.windSpeed,
icon: weatherData.icon,
temperatureUnit: weatherData.temperatureUnit,
windSpeedUnit: weatherData.windSpeedUnit,
humidity: weatherData.humidity ?? 0,
windSpeed: weatherData.windSpeed ?? 0,
icon,
temperatureUnit: weatherData.temperatureUnit ?? 'C',
windSpeedUnit: weatherData.windSpeedUnit ?? 'm/s',
});
};

View File

@@ -0,0 +1,203 @@
'use client';
import { useRef, useState, useCallback, useEffect } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import ProductCard, { ProductCardProps } from './ProductCard';
import VideoCard, { VideoCardProps } from './VideoCard';
import ProfileCard, { ProfileCardProps } from './ProfileCard';
import PromoCard, { PromoCardProps } from './PromoCard';
type CardType = 'product' | 'video' | 'profile' | 'promo';
type CardData<T extends CardType> = T extends 'product'
? ProductCardProps
: T extends 'video'
? VideoCardProps
: T extends 'profile'
? ProfileCardProps
: T extends 'promo'
? PromoCardProps
: never;
interface CardGalleryProps<T extends CardType> {
type: T;
items: CardData<T>[];
title?: string;
subtitle?: string;
maxVisible?: number;
compact?: boolean;
}
const cardComponents = {
product: ProductCard,
video: VideoCard,
profile: ProfileCard,
promo: PromoCard,
};
const typeLabels: Record<CardType, string> = {
product: 'Товары',
video: 'Видео',
profile: 'Профили',
promo: 'Промокоды',
};
export function CardGallery<T extends CardType>({
type,
items,
title,
subtitle,
maxVisible = 10,
compact = false,
}: CardGalleryProps<T>) {
const scrollRef = useRef<HTMLDivElement>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
const checkScroll = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
setCanScrollLeft(el.scrollLeft > 0);
setCanScrollRight(el.scrollLeft < el.scrollWidth - el.clientWidth - 1);
}, []);
useEffect(() => {
checkScroll();
const el = scrollRef.current;
if (el) {
el.addEventListener('scroll', checkScroll);
window.addEventListener('resize', checkScroll);
return () => {
el.removeEventListener('scroll', checkScroll);
window.removeEventListener('resize', checkScroll);
};
}
}, [checkScroll, items]);
const scroll = useCallback((direction: 'left' | 'right') => {
const el = scrollRef.current;
if (!el) return;
const scrollAmount = el.clientWidth * 0.8;
el.scrollBy({
left: direction === 'left' ? -scrollAmount : scrollAmount,
behavior: 'smooth',
});
}, []);
if (!items || items.length === 0) return null;
const displayItems = items.slice(0, maxVisible);
const CardComponent = cardComponents[type] as React.ComponentType<CardData<T> & { compact?: boolean }>;
const displayTitle = title || typeLabels[type];
return (
<div className="relative w-full">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="text-base font-semibold text-black dark:text-white">
{displayTitle}
</h3>
{subtitle && (
<p className="text-xs text-black/50 dark:text-white/50 mt-0.5">
{subtitle}
</p>
)}
</div>
{items.length > 3 && (
<div className="flex items-center gap-1">
<button
onClick={() => scroll('left')}
disabled={!canScrollLeft}
className={cn(
'p-1.5 rounded-lg transition-all',
canScrollLeft
? 'bg-light-100 dark:bg-dark-100 text-black/70 dark:text-white/70 hover:bg-light-200 dark:hover:bg-dark-200'
: 'bg-light-100/50 dark:bg-dark-100/50 text-black/20 dark:text-white/20 cursor-not-allowed'
)}
>
<ChevronLeft size={16} />
</button>
<button
onClick={() => scroll('right')}
disabled={!canScrollRight}
className={cn(
'p-1.5 rounded-lg transition-all',
canScrollRight
? 'bg-light-100 dark:bg-dark-100 text-black/70 dark:text-white/70 hover:bg-light-200 dark:hover:bg-dark-200'
: 'bg-light-100/50 dark:bg-dark-100/50 text-black/20 dark:text-white/20 cursor-not-allowed'
)}
>
<ChevronRight size={16} />
</button>
</div>
)}
</div>
<div className="relative">
{canScrollLeft && (
<div className="absolute left-0 top-0 bottom-0 w-12 bg-gradient-to-r from-white dark:from-dark-primary to-transparent z-10 pointer-events-none" />
)}
<div
ref={scrollRef}
className="flex gap-3 overflow-x-auto scrollbar-hide scroll-smooth pb-2"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{displayItems.map((item, index) => (
<div
key={index}
className={cn(
'flex-shrink-0',
type === 'product' && (compact ? 'w-40' : 'w-72'),
type === 'video' && (compact ? 'w-44' : 'w-64'),
type === 'profile' && (compact ? 'w-48' : 'w-52'),
type === 'promo' && (compact ? 'w-56' : 'w-72')
)}
>
<CardComponent {...(item as CardData<T>)} compact={compact} />
</div>
))}
{items.length > maxVisible && (
<div className="flex-shrink-0 w-32 flex items-center justify-center">
<button className="px-4 py-2 rounded-lg bg-light-100 dark:bg-dark-100 text-sm text-black/70 dark:text-white/70 hover:bg-light-200 dark:hover:bg-dark-200 transition-colors">
+{items.length - maxVisible} ещё
</button>
</div>
)}
</div>
{canScrollRight && (
<div className="absolute right-0 top-0 bottom-0 w-12 bg-gradient-to-l from-white dark:from-dark-primary to-transparent z-10 pointer-events-none" />
)}
</div>
{items.length > 1 && (
<div className="flex justify-center gap-1 mt-2">
{Array.from({ length: Math.min(items.length, 5) }).map((_, i) => (
<div
key={i}
className={cn(
'w-1.5 h-1.5 rounded-full transition-colors',
i === 0
? 'bg-[#EA580C]'
: 'bg-light-200 dark:bg-dark-200'
)}
/>
))}
{items.length > 5 && (
<span className="text-[10px] text-black/40 dark:text-white/40 ml-1">
+{items.length - 5}
</span>
)}
</div>
)}
</div>
);
}
export default CardGallery;

View File

@@ -0,0 +1,266 @@
'use client';
import { useState, useCallback } from 'react';
import { X, ChevronLeft, ChevronRight, ExternalLink, ZoomIn } from 'lucide-react';
import { cn } from '@/lib/utils';
export interface ImageData {
url: string;
thumbnailUrl?: string;
title?: string;
alt?: string;
source: string;
sourceUrl: string;
width?: number;
height?: number;
}
interface InlineImageGalleryProps {
images: ImageData[];
layout?: 'grid' | 'carousel' | 'masonry';
maxVisible?: number;
title?: string;
}
const PLACEHOLDER_IMAGE =
'data:image/svg+xml,' +
encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="300" height="200" viewBox="0 0 300 200"><rect fill="%23f3f4f6" width="300" height="200"/><text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" fill="%239ca3af" font-family="sans-serif" font-size="14">No Image</text></svg>'
);
export function InlineImageGallery({
images,
layout = 'grid',
maxVisible = 6,
title,
}: InlineImageGalleryProps) {
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const [loadedImages, setLoadedImages] = useState<Set<number>>(new Set());
const displayImages = images.slice(0, maxVisible);
const hasMore = images.length > maxVisible;
const openLightbox = useCallback((index: number) => {
setLightboxIndex(index);
document.body.style.overflow = 'hidden';
}, []);
const closeLightbox = useCallback(() => {
setLightboxIndex(null);
document.body.style.overflow = '';
}, []);
const goToPrevious = useCallback(() => {
if (lightboxIndex === null) return;
setLightboxIndex(lightboxIndex === 0 ? images.length - 1 : lightboxIndex - 1);
}, [lightboxIndex, images.length]);
const goToNext = useCallback(() => {
if (lightboxIndex === null) return;
setLightboxIndex(lightboxIndex === images.length - 1 ? 0 : lightboxIndex + 1);
}, [lightboxIndex, images.length]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (lightboxIndex === null) return;
if (e.key === 'Escape') closeLightbox();
if (e.key === 'ArrowLeft') goToPrevious();
if (e.key === 'ArrowRight') goToNext();
},
[lightboxIndex, closeLightbox, goToPrevious, goToNext]
);
const handleImageLoad = useCallback((index: number) => {
setLoadedImages((prev) => new Set(prev).add(index));
}, []);
if (!images || images.length === 0) return null;
return (
<div className="w-full" onKeyDown={handleKeyDown} tabIndex={0}>
{title && (
<h3 className="text-sm font-semibold text-black dark:text-white mb-3">
{title}
</h3>
)}
{layout === 'grid' && (
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
{displayImages.map((img, index) => (
<div
key={index}
className={cn(
'relative group cursor-pointer overflow-hidden rounded-lg',
'bg-light-100 dark:bg-dark-100',
index === 0 && displayImages.length >= 3 && 'md:col-span-2 md:row-span-2'
)}
style={{ aspectRatio: index === 0 && displayImages.length >= 3 ? '16/9' : '4/3' }}
onClick={() => openLightbox(index)}
>
{!loadedImages.has(index) && (
<div className="absolute inset-0 animate-pulse bg-light-200 dark:bg-dark-200" />
)}
<img
src={img.thumbnailUrl || img.url}
alt={img.alt || img.title || 'Image'}
className={cn(
'w-full h-full object-cover transition-transform duration-300 group-hover:scale-105',
!loadedImages.has(index) && 'opacity-0'
)}
loading="lazy"
onLoad={() => handleImageLoad(index)}
onError={(e) => {
(e.target as HTMLImageElement).src = PLACEHOLDER_IMAGE;
handleImageLoad(index);
}}
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition-colors flex items-center justify-center">
<ZoomIn
size={24}
className="text-white opacity-0 group-hover:opacity-100 transition-opacity"
/>
</div>
{img.source && (
<div className="absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity">
<span className="text-[10px] text-white truncate block">
{img.source}
</span>
</div>
)}
{hasMore && index === displayImages.length - 1 && (
<div className="absolute inset-0 bg-black/60 flex items-center justify-center">
<span className="text-white text-lg font-bold">
+{images.length - maxVisible}
</span>
</div>
)}
</div>
))}
</div>
)}
{layout === 'carousel' && (
<div className="flex gap-2 overflow-x-auto scrollbar-hide pb-2">
{images.map((img, index) => (
<div
key={index}
className="relative flex-shrink-0 w-48 h-32 rounded-lg overflow-hidden cursor-pointer group bg-light-100 dark:bg-dark-100"
onClick={() => openLightbox(index)}
>
<img
src={img.thumbnailUrl || img.url}
alt={img.alt || img.title || 'Image'}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
loading="lazy"
onError={(e) => {
(e.target as HTMLImageElement).src = PLACEHOLDER_IMAGE;
}}
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
</div>
))}
</div>
)}
{layout === 'masonry' && (
<div className="columns-2 md:columns-3 gap-2 space-y-2">
{displayImages.map((img, index) => (
<div
key={index}
className="relative break-inside-avoid rounded-lg overflow-hidden cursor-pointer group bg-light-100 dark:bg-dark-100"
onClick={() => openLightbox(index)}
>
<img
src={img.thumbnailUrl || img.url}
alt={img.alt || img.title || 'Image'}
className="w-full h-auto transition-transform duration-300 group-hover:scale-105"
loading="lazy"
onError={(e) => {
(e.target as HTMLImageElement).src = PLACEHOLDER_IMAGE;
}}
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
</div>
))}
</div>
)}
{lightboxIndex !== null && (
<div
className="fixed inset-0 z-50 bg-black/95 flex items-center justify-center"
onClick={closeLightbox}
>
<button
onClick={closeLightbox}
className="absolute top-4 right-4 p-2 text-white/70 hover:text-white transition-colors"
>
<X size={24} />
</button>
{images.length > 1 && (
<>
<button
onClick={(e) => {
e.stopPropagation();
goToPrevious();
}}
className="absolute left-4 top-1/2 -translate-y-1/2 p-2 text-white/70 hover:text-white transition-colors"
>
<ChevronLeft size={32} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
goToNext();
}}
className="absolute right-4 top-1/2 -translate-y-1/2 p-2 text-white/70 hover:text-white transition-colors"
>
<ChevronRight size={32} />
</button>
</>
)}
<div
className="max-w-[90vw] max-h-[85vh] flex flex-col items-center"
onClick={(e) => e.stopPropagation()}
>
<img
src={images[lightboxIndex].url}
alt={images[lightboxIndex].alt || images[lightboxIndex].title || 'Image'}
className="max-w-full max-h-[75vh] object-contain rounded-lg"
/>
<div className="mt-4 text-center">
{images[lightboxIndex].title && (
<h4 className="text-white text-sm font-medium mb-1">
{images[lightboxIndex].title}
</h4>
)}
<div className="flex items-center justify-center gap-3 text-white/60 text-xs">
<span>
{lightboxIndex + 1} / {images.length}
</span>
{images[lightboxIndex].sourceUrl && (
<a
href={images[lightboxIndex].sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 hover:text-white transition-colors"
>
{images[lightboxIndex].source}
<ExternalLink size={10} />
</a>
)}
</div>
</div>
</div>
</div>
)}
</div>
);
}
export default InlineImageGallery;

View File

@@ -0,0 +1,464 @@
'use client';
import { useMemo } from 'react';
import { ExternalLink, TrendingUp, TrendingDown, Minus, BookOpen, Quote, BarChart3, PieChart, Table2, Clock } from 'lucide-react';
import { cn } from '@/lib/utils';
type KnowledgeCardType =
| 'comparison_table'
| 'line_chart'
| 'bar_chart'
| 'pie_chart'
| 'stat_card'
| 'timeline'
| 'quote'
| 'definition';
interface ComparisonTableData {
type: 'comparison_table';
columns: string[];
rows: Array<{
label: string;
values: (string | number | null)[];
highlight?: boolean;
}>;
footer?: string;
}
interface ChartDataPoint {
label: string;
value: number;
color?: string;
}
interface BarChartData {
type: 'bar_chart';
title?: string;
data: ChartDataPoint[];
horizontal?: boolean;
maxValue?: number;
}
interface PieChartData {
type: 'pie_chart';
title?: string;
data: ChartDataPoint[];
showPercent?: boolean;
}
interface StatCardData {
type: 'stat_card';
stats: Array<{
label: string;
value: string | number;
change?: number;
changeType?: 'positive' | 'negative' | 'neutral';
icon?: string;
}>;
}
interface TimelineData {
type: 'timeline';
events: Array<{
date: string;
title: string;
description?: string;
icon?: string;
}>;
}
interface QuoteData {
type: 'quote';
text: string;
author?: string;
source?: string;
sourceUrl?: string;
}
interface DefinitionData {
type: 'definition';
term: string;
definition: string;
examples?: string[];
synonyms?: string[];
source?: string;
}
type KnowledgeCardData =
| ComparisonTableData
| BarChartData
| PieChartData
| StatCardData
| TimelineData
| QuoteData
| DefinitionData;
interface KnowledgeCardProps {
title: string;
data: KnowledgeCardData;
source?: string;
sourceUrl?: string;
lastUpdated?: string;
}
const defaultColors = [
'#EA580C',
'#3B82F6',
'#10B981',
'#8B5CF6',
'#F59E0B',
'#EF4444',
'#06B6D4',
'#EC4899',
];
export function KnowledgeCard({
title,
data,
source,
sourceUrl,
lastUpdated,
}: KnowledgeCardProps) {
const icon = useMemo(() => {
switch (data.type) {
case 'comparison_table':
return <Table2 size={16} />;
case 'bar_chart':
return <BarChart3 size={16} />;
case 'pie_chart':
return <PieChart size={16} />;
case 'stat_card':
return <TrendingUp size={16} />;
case 'timeline':
return <Clock size={16} />;
case 'quote':
return <Quote size={16} />;
case 'definition':
return <BookOpen size={16} />;
default:
return <BarChart3 size={16} />;
}
}, [data.type]);
return (
<div className="w-full bg-white dark:bg-dark-secondary border border-light-200 dark:border-dark-200 rounded-xl shadow-sm overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 border-b border-light-200 dark:border-dark-200 bg-light-50 dark:bg-dark-100">
<div className="flex items-center gap-2">
<span className="text-[#EA580C]">{icon}</span>
<h3 className="font-semibold text-sm text-black dark:text-white">
{title}
</h3>
</div>
{sourceUrl && (
<a
href={sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="text-black/40 dark:text-white/40 hover:text-[#EA580C] transition-colors"
>
<ExternalLink size={14} />
</a>
)}
</div>
<div className="p-4">
{data.type === 'comparison_table' && <ComparisonTable data={data} />}
{data.type === 'bar_chart' && <BarChart data={data} />}
{data.type === 'pie_chart' && <PieChartComponent data={data} />}
{data.type === 'stat_card' && <StatCard data={data} />}
{data.type === 'timeline' && <Timeline data={data} />}
{data.type === 'quote' && <QuoteBlock data={data} />}
{data.type === 'definition' && <Definition data={data} />}
</div>
{(source || lastUpdated) && (
<div className="px-4 py-2 border-t border-light-200/50 dark:border-dark-200/50 flex items-center justify-between text-[10px] text-black/40 dark:text-white/40">
{source && <span>Источник: {source}</span>}
{lastUpdated && <span>Обновлено: {lastUpdated}</span>}
</div>
)}
</div>
);
}
function ComparisonTable({ data }: { data: ComparisonTableData }) {
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-light-200 dark:border-dark-200">
<th className="text-left py-2 px-3 font-semibold text-black/70 dark:text-white/70"></th>
{data.columns.map((col, i) => (
<th
key={i}
className="text-center py-2 px-3 font-semibold text-black dark:text-white"
>
{col}
</th>
))}
</tr>
</thead>
<tbody>
{data.rows.map((row, i) => (
<tr
key={i}
className={cn(
'border-b border-light-200/50 dark:border-dark-200/50 last:border-0',
row.highlight && 'bg-[#EA580C]/5'
)}
>
<td className="py-2 px-3 font-medium text-black/70 dark:text-white/70">
{row.label}
</td>
{row.values.map((val, j) => (
<td
key={j}
className="text-center py-2 px-3 text-black dark:text-white"
>
{val ?? '—'}
</td>
))}
</tr>
))}
</tbody>
</table>
{data.footer && (
<p className="text-xs text-black/50 dark:text-white/50 mt-2 px-3">
{data.footer}
</p>
)}
</div>
);
}
function BarChart({ data }: { data: BarChartData }) {
const maxVal = data.maxValue || Math.max(...data.data.map((d) => d.value));
return (
<div className="space-y-2">
{data.data.map((item, i) => (
<div key={i} className="flex items-center gap-3">
<span className="w-24 text-xs text-black/70 dark:text-white/70 truncate">
{item.label}
</span>
<div className="flex-1 h-6 bg-light-100 dark:bg-dark-100 rounded overflow-hidden">
<div
className="h-full rounded transition-all duration-500"
style={{
width: `${(item.value / maxVal) * 100}%`,
backgroundColor: item.color || defaultColors[i % defaultColors.length],
}}
/>
</div>
<span className="w-16 text-right text-xs font-medium text-black dark:text-white">
{typeof item.value === 'number' ? item.value.toLocaleString('ru-RU') : item.value}
</span>
</div>
))}
</div>
);
}
function PieChartComponent({ data }: { data: PieChartData }) {
const total = data.data.reduce((sum, d) => sum + d.value, 0);
return (
<div className="flex items-center gap-6">
<div className="relative w-32 h-32">
<svg viewBox="0 0 100 100" className="transform -rotate-90">
{data.data.reduce(
(acc, item, i) => {
const percent = (item.value / total) * 100;
const offset = acc.offset;
acc.offset += percent;
acc.elements.push(
<circle
key={i}
cx="50"
cy="50"
r="40"
fill="transparent"
stroke={item.color || defaultColors[i % defaultColors.length]}
strokeWidth="20"
strokeDasharray={`${percent * 2.51} ${251 - percent * 2.51}`}
strokeDashoffset={-offset * 2.51}
/>
);
return acc;
},
{ offset: 0, elements: [] as JSX.Element[] }
).elements}
</svg>
</div>
<div className="flex-1 space-y-1.5">
{data.data.map((item, i) => (
<div key={i} className="flex items-center gap-2">
<div
className="w-3 h-3 rounded"
style={{ backgroundColor: item.color || defaultColors[i % defaultColors.length] }}
/>
<span className="flex-1 text-xs text-black/70 dark:text-white/70 truncate">
{item.label}
</span>
<span className="text-xs font-medium text-black dark:text-white">
{data.showPercent
? `${((item.value / total) * 100).toFixed(1)}%`
: item.value.toLocaleString('ru-RU')}
</span>
</div>
))}
</div>
</div>
);
}
function StatCard({ data }: { data: StatCardData }) {
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{data.stats.map((stat, i) => (
<div key={i} className="text-center p-3 bg-light-50 dark:bg-dark-100 rounded-lg">
<p className="text-xs text-black/50 dark:text-white/50 mb-1">
{stat.label}
</p>
<p className="text-xl font-bold text-black dark:text-white">
{stat.value}
</p>
{stat.change !== undefined && (
<div
className={cn(
'flex items-center justify-center gap-1 text-xs mt-1',
stat.changeType === 'positive' && 'text-green-500',
stat.changeType === 'negative' && 'text-red-500',
stat.changeType === 'neutral' && 'text-black/50 dark:text-white/50'
)}
>
{stat.changeType === 'positive' && <TrendingUp size={12} />}
{stat.changeType === 'negative' && <TrendingDown size={12} />}
{stat.changeType === 'neutral' && <Minus size={12} />}
{stat.change > 0 ? '+' : ''}{stat.change}%
</div>
)}
</div>
))}
</div>
);
}
function Timeline({ data }: { data: TimelineData }) {
return (
<div className="relative pl-6 space-y-4">
<div className="absolute left-2 top-2 bottom-2 w-0.5 bg-light-200 dark:bg-dark-200" />
{data.events.map((event, i) => (
<div key={i} className="relative">
<div className="absolute -left-6 top-1 w-4 h-4 rounded-full bg-[#EA580C] border-2 border-white dark:border-dark-secondary flex items-center justify-center">
{event.icon ? (
<span className="text-[8px]">{event.icon}</span>
) : (
<div className="w-1.5 h-1.5 rounded-full bg-white" />
)}
</div>
<div>
<span className="text-[10px] text-black/50 dark:text-white/50">
{event.date}
</span>
<h4 className="text-sm font-medium text-black dark:text-white mt-0.5">
{event.title}
</h4>
{event.description && (
<p className="text-xs text-black/60 dark:text-white/60 mt-1">
{event.description}
</p>
)}
</div>
</div>
))}
</div>
);
}
function QuoteBlock({ data }: { data: QuoteData }) {
return (
<blockquote className="relative pl-4 border-l-4 border-[#EA580C]">
<p className="text-base italic text-black dark:text-white leading-relaxed">
"{data.text}"
</p>
{(data.author || data.source) && (
<footer className="mt-3 text-sm text-black/60 dark:text-white/60">
{data.author && <span className="font-medium"> {data.author}</span>}
{data.source && (
<>
{data.author && ', '}
{data.sourceUrl ? (
<a
href={data.sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="hover:text-[#EA580C] transition-colors"
>
{data.source}
</a>
) : (
<span>{data.source}</span>
)}
</>
)}
</footer>
)}
</blockquote>
);
}
function Definition({ data }: { data: DefinitionData }) {
return (
<div className="space-y-3">
<div>
<h4 className="text-lg font-bold text-black dark:text-white">
{data.term}
</h4>
<p className="text-sm text-black/70 dark:text-white/70 mt-1 leading-relaxed">
{data.definition}
</p>
</div>
{data.examples && data.examples.length > 0 && (
<div>
<h5 className="text-xs font-semibold text-black/50 dark:text-white/50 uppercase tracking-wide mb-1">
Примеры
</h5>
<ul className="space-y-1">
{data.examples.map((ex, i) => (
<li key={i} className="text-sm text-black/60 dark:text-white/60 pl-3 border-l-2 border-light-200 dark:border-dark-200">
{ex}
</li>
))}
</ul>
</div>
)}
{data.synonyms && data.synonyms.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-black/50 dark:text-white/50">Синонимы:</span>
{data.synonyms.map((syn, i) => (
<span
key={i}
className="px-2 py-0.5 bg-light-100 dark:bg-dark-100 rounded text-xs text-black/70 dark:text-white/70"
>
{syn}
</span>
))}
</div>
)}
{data.source && (
<p className="text-[10px] text-black/40 dark:text-white/40">
Источник: {data.source}
</p>
)}
</div>
);
}
export default KnowledgeCard;

View File

@@ -0,0 +1,231 @@
'use client';
import { Star, ShoppingCart, Heart, Truck, Check, X } from 'lucide-react';
import { cn } from '@/lib/utils';
export interface ProductCardProps {
title: string;
price: number;
currency?: string;
oldPrice?: number;
discount?: number;
image: string;
url: string;
rating?: number;
reviewCount?: number;
seller?: string;
marketplace: 'ozon' | 'wildberries' | 'aliexpress' | 'yandex_market' | 'other';
inStock?: boolean;
deliveryInfo?: string;
badges?: string[];
compact?: boolean;
}
const marketplaceColors: Record<string, string> = {
ozon: 'bg-blue-500',
wildberries: 'bg-purple-600',
aliexpress: 'bg-red-500',
yandex_market: 'bg-yellow-500',
other: 'bg-gray-500',
};
const marketplaceNames: Record<string, string> = {
ozon: 'Ozon',
wildberries: 'Wildberries',
aliexpress: 'AliExpress',
yandex_market: 'Яндекс Маркет',
other: 'Магазин',
};
function formatPrice(price: number, currency: string = '₽'): string {
return new Intl.NumberFormat('ru-RU').format(price) + ' ' + currency;
}
export function ProductCard({
title,
price,
currency = '₽',
oldPrice,
discount,
image,
url,
rating,
reviewCount,
seller,
marketplace,
inStock = true,
deliveryInfo,
badges,
compact = false,
}: ProductCardProps) {
const calculatedDiscount = discount || (oldPrice ? Math.round((1 - price / oldPrice) * 100) : 0);
return (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className={cn(
'group flex overflow-hidden',
'bg-white dark:bg-dark-secondary',
'border border-light-200 dark:border-dark-200',
'rounded-xl shadow-sm',
'transition-all duration-200',
'hover:shadow-lg hover:border-[#EA580C]/30',
compact ? 'flex-col w-40' : 'flex-row w-full'
)}
>
<div
className={cn(
'relative overflow-hidden bg-light-100 dark:bg-dark-100 flex-shrink-0',
compact ? 'w-full h-32' : 'w-28 h-28'
)}
>
<img
src={image}
alt={title}
className="w-full h-full object-contain p-2 transition-transform duration-300 group-hover:scale-105"
loading="lazy"
/>
{calculatedDiscount > 0 && (
<span className="absolute top-1.5 left-1.5 px-1.5 py-0.5 rounded bg-red-500 text-white text-[10px] font-bold">
-{calculatedDiscount}%
</span>
)}
<div
className={cn(
'absolute top-1.5 right-1.5 w-1.5 h-1.5 rounded-full',
marketplaceColors[marketplace]
)}
title={marketplaceNames[marketplace]}
/>
</div>
<div className={cn('flex flex-col flex-1 min-w-0', compact ? 'p-2' : 'p-3')}>
<h4
className={cn(
'font-medium text-black dark:text-white leading-tight',
compact ? 'text-xs line-clamp-2' : 'text-sm line-clamp-2'
)}
>
{title}
</h4>
<div className="flex items-baseline gap-2 mt-1.5">
<span
className={cn(
'font-bold text-black dark:text-white',
compact ? 'text-sm' : 'text-base'
)}
>
{formatPrice(price, currency)}
</span>
{oldPrice && (
<span className="text-xs text-black/40 dark:text-white/40 line-through">
{formatPrice(oldPrice, currency)}
</span>
)}
</div>
{rating !== undefined && (
<div className="flex items-center gap-1 mt-1.5">
<div className="flex items-center">
{[1, 2, 3, 4, 5].map((star) => (
<Star
key={star}
size={10}
className={cn(
star <= Math.round(rating)
? 'text-yellow-400 fill-yellow-400'
: 'text-gray-300 dark:text-gray-600'
)}
/>
))}
</div>
<span className="text-[10px] text-black/50 dark:text-white/50">
{rating.toFixed(1)}
</span>
{reviewCount !== undefined && (
<span className="text-[10px] text-black/40 dark:text-white/40">
({reviewCount.toLocaleString('ru-RU')})
</span>
)}
</div>
)}
<div className="flex items-center gap-2 mt-1.5">
{inStock ? (
<span className="flex items-center gap-0.5 text-[10px] text-green-600 dark:text-green-400">
<Check size={10} />В наличии
</span>
) : (
<span className="flex items-center gap-0.5 text-[10px] text-red-500">
<X size={10} />Нет в наличии
</span>
)}
{deliveryInfo && (
<span className="flex items-center gap-0.5 text-[10px] text-black/50 dark:text-white/50">
<Truck size={10} />
{deliveryInfo}
</span>
)}
</div>
{seller && !compact && (
<span className="text-[10px] text-black/40 dark:text-white/40 mt-1 truncate">
Продавец: {seller}
</span>
)}
{badges && badges.length > 0 && !compact && (
<div className="flex flex-wrap gap-1 mt-2">
{badges.slice(0, 3).map((badge, i) => (
<span
key={i}
className="px-1.5 py-0.5 rounded text-[9px] font-medium
bg-light-100 dark:bg-dark-100
text-black/60 dark:text-white/60"
>
{badge}
</span>
))}
</div>
)}
{!compact && (
<div className="flex items-center gap-2 mt-auto pt-2">
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
window.open(url, '_blank');
}}
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-xs font-medium
bg-[#EA580C] text-white hover:bg-[#EA580C]/90 transition-colors"
>
<ShoppingCart size={12} />
Купить
</button>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
className="p-1.5 rounded-lg
bg-light-100 dark:bg-dark-100
text-black/50 dark:text-white/50
hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20
transition-colors"
>
<Heart size={14} />
</button>
</div>
)}
</div>
</a>
);
}
export default ProductCard;

View File

@@ -0,0 +1,216 @@
'use client';
import { CheckCircle2, Users, UserPlus, ExternalLink, MessageCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
export interface ProfileCardProps {
name: string;
username?: string;
avatar?: string;
url: string;
platform: 'vk' | 'telegram' | 'instagram' | 'youtube' | 'dzen' | 'other';
followers?: number;
following?: number;
verified?: boolean;
description?: string;
isOnline?: boolean;
compact?: boolean;
}
const platformInfo: Record<string, { icon: string; color: string; name: string; buttonText: string }> = {
vk: { icon: '💙', color: 'bg-blue-500', name: 'ВКонтакте', buttonText: 'Подписаться' },
telegram: { icon: '✈️', color: 'bg-sky-500', name: 'Telegram', buttonText: 'Открыть' },
instagram: { icon: '📷', color: 'bg-gradient-to-r from-purple-500 to-pink-500', name: 'Instagram', buttonText: 'Подписаться' },
youtube: { icon: '▶️', color: 'bg-red-500', name: 'YouTube', buttonText: 'Подписаться' },
dzen: { icon: '📰', color: 'bg-yellow-500', name: 'Дзен', buttonText: 'Подписаться' },
other: { icon: '👤', color: 'bg-gray-500', name: 'Профиль', buttonText: 'Открыть' },
};
function formatFollowers(count: number): string {
if (count >= 1_000_000) {
return (count / 1_000_000).toFixed(1) + 'M';
}
if (count >= 1_000) {
return (count / 1_000).toFixed(1) + 'K';
}
return count.toString();
}
const AVATAR_PLACEHOLDER =
'data:image/svg+xml,' +
encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><rect fill="%23e5e7eb" width="100" height="100"/><circle cx="50" cy="35" r="20" fill="%239ca3af"/><ellipse cx="50" cy="85" rx="35" ry="25" fill="%239ca3af"/></svg>'
);
export function ProfileCard({
name,
username,
avatar,
url,
platform,
followers,
following,
verified = false,
description,
isOnline,
compact = false,
}: ProfileCardProps) {
const info = platformInfo[platform] || platformInfo.other;
return (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className={cn(
'group flex overflow-hidden',
'bg-white dark:bg-dark-secondary',
'border border-light-200 dark:border-dark-200',
'rounded-xl shadow-sm',
'transition-all duration-200',
'hover:shadow-lg hover:border-[#EA580C]/30',
compact ? 'flex-row items-center p-2 gap-2' : 'flex-col p-4'
)}
>
<div className={cn('relative flex-shrink-0', compact ? '' : 'mx-auto')}>
<div
className={cn(
'relative rounded-full overflow-hidden bg-light-100 dark:bg-dark-100',
compact ? 'w-10 h-10' : 'w-16 h-16'
)}
>
<img
src={avatar || AVATAR_PLACEHOLDER}
alt={name}
className="w-full h-full object-cover"
loading="lazy"
onError={(e) => {
(e.target as HTMLImageElement).src = AVATAR_PLACEHOLDER;
}}
/>
</div>
{isOnline && (
<span
className={cn(
'absolute bottom-0 right-0 rounded-full bg-green-500 border-2 border-white dark:border-dark-secondary',
compact ? 'w-2.5 h-2.5' : 'w-3.5 h-3.5'
)}
/>
)}
<span
className={cn(
'absolute -top-0.5 -right-0.5 rounded-full flex items-center justify-center text-[8px]',
info.color,
compact ? 'w-4 h-4' : 'w-5 h-5'
)}
>
{info.icon}
</span>
</div>
<div className={cn('min-w-0 flex-1', compact ? '' : 'text-center mt-3')}>
<div className={cn('flex items-center gap-1', compact ? '' : 'justify-center')}>
<h4
className={cn(
'font-semibold text-black dark:text-white truncate',
compact ? 'text-sm' : 'text-base'
)}
>
{name}
</h4>
{verified && (
<CheckCircle2
size={compact ? 12 : 14}
className="text-blue-500 flex-shrink-0"
fill="currentColor"
/>
)}
</div>
{username && (
<p
className={cn(
'text-black/50 dark:text-white/50 truncate',
compact ? 'text-xs' : 'text-sm'
)}
>
@{username}
</p>
)}
{(followers !== undefined || following !== undefined) && (
<div
className={cn(
'flex items-center gap-3 text-black/60 dark:text-white/60',
compact ? 'text-[10px] mt-0.5' : 'text-xs mt-2 justify-center'
)}
>
{followers !== undefined && (
<span className="flex items-center gap-1">
<Users size={10} />
{formatFollowers(followers)}
</span>
)}
{following !== undefined && !compact && (
<span className="flex items-center gap-1">
<UserPlus size={10} />
{formatFollowers(following)}
</span>
)}
</div>
)}
{description && !compact && (
<p className="text-xs text-black/50 dark:text-white/50 line-clamp-2 mt-2">
{description}
</p>
)}
</div>
{!compact && (
<div className="flex items-center gap-2 mt-4 pt-3 border-t border-light-200/50 dark:border-dark-200/50">
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
window.open(url, '_blank');
}}
className={cn(
'flex-1 flex items-center justify-center gap-1 px-3 py-2 rounded-lg text-xs font-medium text-white transition-colors',
info.color,
'hover:opacity-90'
)}
>
<UserPlus size={12} />
{info.buttonText}
</button>
{platform === 'telegram' && (
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
className="p-2 rounded-lg bg-light-100 dark:bg-dark-100
text-black/50 dark:text-white/50
hover:text-[#EA580C] transition-colors"
>
<MessageCircle size={14} />
</button>
)}
</div>
)}
{compact && (
<ExternalLink
size={12}
className="text-black/30 dark:text-white/30 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
/>
)}
</a>
);
}
export default ProfileCard;

View File

@@ -0,0 +1,245 @@
'use client';
import { useState, useCallback } from 'react';
import { Copy, Check, Clock, ExternalLink, Percent, Tag } from 'lucide-react';
import { cn } from '@/lib/utils';
export interface PromoCardProps {
code: string;
discount: string;
discountType?: 'percent' | 'fixed' | 'freeShipping' | 'other';
discountValue?: number;
store: string;
storeLogo?: string;
storeUrl?: string;
url: string;
expiresAt?: string;
conditions?: string;
minOrderAmount?: number;
verified?: boolean;
compact?: boolean;
}
function formatExpiry(dateStr: string): { text: string; urgent: boolean } {
try {
const date = new Date(dateStr);
const now = new Date();
const diffMs = date.getTime() - now.getTime();
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
if (diffDays < 0) return { text: 'Истёк', urgent: true };
if (diffDays === 0) return { text: 'Сегодня', urgent: true };
if (diffDays === 1) return { text: 'Завтра', urgent: true };
if (diffDays <= 3) return { text: `${diffDays} дн.`, urgent: true };
if (diffDays <= 7) return { text: `${diffDays} дн.`, urgent: false };
if (diffDays <= 30) return { text: `${Math.ceil(diffDays / 7)} нед.`, urgent: false };
return { text: date.toLocaleDateString('ru-RU'), urgent: false };
} catch {
return { text: dateStr, urgent: false };
}
}
const STORE_PLACEHOLDER =
'data:image/svg+xml,' +
encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40"><rect fill="%23f3f4f6" width="40" height="40" rx="8"/><text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" fill="%239ca3af" font-family="sans-serif" font-size="16">🏪</text></svg>'
);
export function PromoCard({
code,
discount,
discountType = 'other',
discountValue,
store,
storeLogo,
storeUrl,
url,
expiresAt,
conditions,
minOrderAmount,
verified = false,
compact = false,
}: PromoCardProps) {
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
},
[code]
);
const expiry = expiresAt ? formatExpiry(expiresAt) : null;
const discountIcon = {
percent: <Percent size={14} />,
fixed: <Tag size={14} />,
freeShipping: <Tag size={14} />,
other: <Tag size={14} />,
}[discountType];
return (
<div
className={cn(
'group flex overflow-hidden',
'bg-white dark:bg-dark-secondary',
'border border-light-200 dark:border-dark-200',
'rounded-xl shadow-sm',
'transition-all duration-200',
'hover:shadow-lg hover:border-[#EA580C]/30',
compact ? 'flex-row items-center p-2' : 'flex-col p-4'
)}
>
<div className={cn('flex items-center gap-3', compact ? 'flex-1 min-w-0' : 'w-full')}>
<div
className={cn(
'flex-shrink-0 rounded-lg overflow-hidden bg-light-100 dark:bg-dark-100',
compact ? 'w-10 h-10' : 'w-12 h-12'
)}
>
<img
src={storeLogo || STORE_PLACEHOLDER}
alt={store}
className="w-full h-full object-contain p-1"
loading="lazy"
onError={(e) => {
(e.target as HTMLImageElement).src = STORE_PLACEHOLDER;
}}
/>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span
className={cn(
'font-bold text-[#EA580C]',
compact ? 'text-sm' : 'text-lg'
)}
>
{discount}
</span>
{verified && (
<span className="px-1.5 py-0.5 rounded text-[9px] font-medium bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">
Проверено
</span>
)}
</div>
<p
className={cn(
'text-black/70 dark:text-white/70 truncate',
compact ? 'text-xs' : 'text-sm'
)}
>
{store}
</p>
</div>
</div>
<div
className={cn(
'flex items-center',
compact ? 'gap-2' : 'w-full mt-4 gap-2'
)}
>
<div
className={cn(
'flex-1 flex items-center justify-between',
'bg-light-100 dark:bg-dark-100 rounded-lg',
'border-2 border-dashed border-light-200 dark:border-dark-200',
compact ? 'px-2 py-1' : 'px-3 py-2'
)}
>
<code
className={cn(
'font-mono font-bold text-black dark:text-white tracking-wider',
compact ? 'text-xs' : 'text-sm'
)}
>
{code}
</code>
</div>
<button
onClick={handleCopy}
className={cn(
'flex items-center justify-center rounded-lg transition-all',
copied
? 'bg-green-500 text-white'
: 'bg-[#EA580C] text-white hover:bg-[#EA580C]/90',
compact ? 'w-8 h-8' : 'px-4 py-2 gap-1.5'
)}
>
{copied ? (
<>
<Check size={compact ? 14 : 12} />
{!compact && <span className="text-xs font-medium">Скопировано</span>}
</>
) : (
<>
<Copy size={compact ? 14 : 12} />
{!compact && <span className="text-xs font-medium">Копировать</span>}
</>
)}
</button>
</div>
{!compact && (
<div className="mt-3 pt-3 border-t border-light-200/50 dark:border-dark-200/50">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[10px] text-black/50 dark:text-white/50">
{expiry && (
<span
className={cn(
'flex items-center gap-1',
expiry.urgent && 'text-orange-500 font-medium'
)}
>
<Clock size={10} />
до {expiry.text}
</span>
)}
{minOrderAmount && (
<span>от {minOrderAmount.toLocaleString('ru-RU')} </span>
)}
{conditions && (
<span className="truncate max-w-[200px]" title={conditions}>
{conditions}
</span>
)}
</div>
<div className="flex items-center gap-2 mt-3">
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-xs text-[#EA580C] hover:underline"
>
Перейти в магазин
<ExternalLink size={10} />
</a>
{storeUrl && storeUrl !== url && (
<a
href={storeUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-xs text-black/50 dark:text-white/50 hover:text-[#EA580C]"
>
Все промокоды
</a>
)}
</div>
</div>
)}
</div>
);
}
export default PromoCard;

View File

@@ -3,76 +3,244 @@ import dynamic from 'next/dynamic';
import { Widget } from '../ChatWindow';
import Weather from './Weather';
import Calculation from './Calculation';
import CardGallery from './CardGallery';
import KnowledgeCard from './KnowledgeCard';
import InlineImageGallery from './InlineImageGallery';
import VideoEmbed from './VideoEmbed';
import ProductCard from './ProductCard';
import VideoCard from './VideoCard';
import ProfileCard from './ProfileCard';
import PromoCard from './PromoCard';
const Stock = dynamic(() => import('./Stock'), { ssr: false });
const Renderer = ({ widgets }: { widgets: Widget[] }) => {
return widgets.map((widget, index) => {
switch (widget.widgetType) {
case 'weather':
return (
<Weather
key={index}
location={widget.params.location}
current={widget.params.current}
daily={widget.params.daily}
timezone={widget.params.timezone}
/>
);
case 'calculation_result':
return (
<Calculation
expression={widget.params.expression}
result={widget.params.result}
key={index}
/>
);
case 'stock':
return (
<Stock
key={index}
symbol={widget.params.symbol}
shortName={widget.params.shortName}
longName={widget.params.longName}
exchange={widget.params.exchange}
currency={widget.params.currency}
marketState={widget.params.marketState}
regularMarketPrice={widget.params.regularMarketPrice}
regularMarketChange={widget.params.regularMarketChange}
regularMarketChangePercent={
widget.params.regularMarketChangePercent
}
regularMarketPreviousClose={
widget.params.regularMarketPreviousClose
}
regularMarketOpen={widget.params.regularMarketOpen}
regularMarketDayHigh={widget.params.regularMarketDayHigh}
regularMarketDayLow={widget.params.regularMarketDayLow}
regularMarketVolume={widget.params.regularMarketVolume}
averageDailyVolume3Month={widget.params.averageDailyVolume3Month}
marketCap={widget.params.marketCap}
fiftyTwoWeekLow={widget.params.fiftyTwoWeekLow}
fiftyTwoWeekHigh={widget.params.fiftyTwoWeekHigh}
trailingPE={widget.params.trailingPE}
forwardPE={widget.params.forwardPE}
dividendYield={widget.params.dividendYield}
earningsPerShare={widget.params.earningsPerShare}
website={widget.params.website}
postMarketPrice={widget.params.postMarketPrice}
postMarketChange={widget.params.postMarketChange}
postMarketChangePercent={widget.params.postMarketChangePercent}
preMarketPrice={widget.params.preMarketPrice}
preMarketChange={widget.params.preMarketChange}
preMarketChangePercent={widget.params.preMarketChangePercent}
chartData={widget.params.chartData}
comparisonData={widget.params.comparisonData}
error={widget.params.error}
/>
);
default:
return <div key={index}>Unknown widget type: {widget.widgetType}</div>;
}
});
return (
<div className="space-y-4">
{widgets.map((widget, index) => {
switch (widget.widgetType) {
case 'weather':
return (
<Weather
key={index}
location={widget.params.location}
current={widget.params.current}
daily={widget.params.daily}
timezone={widget.params.timezone}
/>
);
case 'calculation_result':
return (
<Calculation
key={index}
expression={widget.params.expression}
result={widget.params.result}
/>
);
case 'stock':
return (
<Stock
key={index}
symbol={widget.params.symbol}
shortName={widget.params.shortName}
longName={widget.params.longName}
exchange={widget.params.exchange}
currency={widget.params.currency}
marketState={widget.params.marketState}
regularMarketPrice={widget.params.regularMarketPrice}
regularMarketChange={widget.params.regularMarketChange}
regularMarketChangePercent={widget.params.regularMarketChangePercent}
regularMarketPreviousClose={widget.params.regularMarketPreviousClose}
regularMarketOpen={widget.params.regularMarketOpen}
regularMarketDayHigh={widget.params.regularMarketDayHigh}
regularMarketDayLow={widget.params.regularMarketDayLow}
regularMarketVolume={widget.params.regularMarketVolume}
averageDailyVolume3Month={widget.params.averageDailyVolume3Month}
marketCap={widget.params.marketCap}
fiftyTwoWeekLow={widget.params.fiftyTwoWeekLow}
fiftyTwoWeekHigh={widget.params.fiftyTwoWeekHigh}
trailingPE={widget.params.trailingPE}
forwardPE={widget.params.forwardPE}
dividendYield={widget.params.dividendYield}
earningsPerShare={widget.params.earningsPerShare}
website={widget.params.website}
postMarketPrice={widget.params.postMarketPrice}
postMarketChange={widget.params.postMarketChange}
postMarketChangePercent={widget.params.postMarketChangePercent}
preMarketPrice={widget.params.preMarketPrice}
preMarketChange={widget.params.preMarketChange}
preMarketChangePercent={widget.params.preMarketChangePercent}
chartData={widget.params.chartData}
comparisonData={widget.params.comparisonData}
error={widget.params.error}
/>
);
case 'products':
return (
<CardGallery
key={index}
type="product"
items={widget.params.items}
title={widget.params.title}
/>
);
case 'videos':
return (
<CardGallery
key={index}
type="video"
items={widget.params.items}
title={widget.params.title}
/>
);
case 'profiles':
return (
<CardGallery
key={index}
type="profile"
items={widget.params.items}
title={widget.params.title}
/>
);
case 'promos':
return (
<CardGallery
key={index}
type="promo"
items={widget.params.items}
title={widget.params.title}
/>
);
case 'knowledge_card':
return (
<KnowledgeCard
key={index}
title={widget.params.card.title}
data={widget.params.card.data}
source={widget.params.card.source}
sourceUrl={widget.params.card.sourceUrl}
lastUpdated={widget.params.card.lastUpdated}
/>
);
case 'image_gallery':
return (
<InlineImageGallery
key={index}
images={widget.params.images}
layout={widget.params.layout || 'grid'}
/>
);
case 'video_embed':
return (
<VideoEmbed
key={index}
platform={widget.params.video.platform}
videoId={widget.params.video.id}
url={widget.params.video.url}
embedUrl={widget.params.video.embedUrl}
thumbnail={widget.params.video.thumbnail}
title={widget.params.video.title}
duration={widget.params.video.duration}
views={widget.params.video.views}
likes={widget.params.video.likes}
author={widget.params.video.author}
authorUrl={widget.params.video.authorUrl}
publishedAt={widget.params.video.publishedAt}
autoplay={widget.params.autoplay}
/>
);
case 'product':
return (
<ProductCard
key={index}
title={widget.params.title}
price={widget.params.price}
currency={widget.params.currency}
oldPrice={widget.params.oldPrice}
discount={widget.params.discount}
image={widget.params.image}
url={widget.params.url}
rating={widget.params.rating}
reviewCount={widget.params.reviewCount}
seller={widget.params.seller}
marketplace={widget.params.marketplace}
inStock={widget.params.inStock}
deliveryInfo={widget.params.deliveryInfo}
badges={widget.params.badges}
/>
);
case 'video':
return (
<VideoCard
key={index}
title={widget.params.title}
thumbnail={widget.params.thumbnail}
url={widget.params.url}
duration={widget.params.duration}
views={widget.params.views}
likes={widget.params.likes}
author={widget.params.author}
authorUrl={widget.params.authorUrl}
authorAvatar={widget.params.authorAvatar}
platform={widget.params.platform}
publishedAt={widget.params.publishedAt}
description={widget.params.description}
/>
);
case 'profile':
return (
<ProfileCard
key={index}
name={widget.params.name}
username={widget.params.username}
avatar={widget.params.avatar}
url={widget.params.url}
platform={widget.params.platform}
followers={widget.params.followers}
following={widget.params.following}
verified={widget.params.verified}
description={widget.params.description}
isOnline={widget.params.isOnline}
/>
);
case 'promo':
return (
<PromoCard
key={index}
code={widget.params.code}
discount={widget.params.discount}
discountType={widget.params.discountType}
discountValue={widget.params.discountValue}
store={widget.params.store}
storeLogo={widget.params.storeLogo}
storeUrl={widget.params.storeUrl}
url={widget.params.url}
expiresAt={widget.params.expiresAt}
conditions={widget.params.conditions}
minOrderAmount={widget.params.minOrderAmount}
verified={widget.params.verified}
/>
);
default:
return null;
}
})}
</div>
);
};
export default Renderer;

View File

@@ -0,0 +1,229 @@
'use client';
import { ReactNode } from 'react';
import { ExternalLink } from 'lucide-react';
import { cn } from '@/lib/utils';
export interface UnifiedCardProps {
type: 'product' | 'video' | 'profile' | 'promo' | 'article';
image?: string;
imageAlt?: string;
title: string;
subtitle?: string;
badge?: {
text: string;
variant: 'success' | 'warning' | 'error' | 'info' | 'default';
};
meta?: string[];
action?: {
label: string;
url: string;
variant?: 'primary' | 'secondary';
};
secondaryAction?: {
label: string;
onClick: () => void;
};
children?: ReactNode;
className?: string;
href?: string;
compact?: boolean;
horizontal?: boolean;
}
const badgeVariants = {
success: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
warning: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400',
error: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
info: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
default: 'bg-light-200 text-black/70 dark:bg-dark-200 dark:text-white/70',
};
const PLACEHOLDER_IMAGE =
'data:image/svg+xml,' +
encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 200 200"><rect fill="%23f3f4f6" width="200" height="200"/><text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" fill="%239ca3af" font-family="sans-serif" font-size="14">No Image</text></svg>'
);
export function UnifiedCard({
type,
image,
imageAlt,
title,
subtitle,
badge,
meta,
action,
secondaryAction,
children,
className,
href,
compact = false,
horizontal = false,
}: UnifiedCardProps) {
const CardWrapper = href ? 'a' : 'div';
const wrapperProps = href
? { href, target: '_blank', rel: 'noopener noreferrer' }
: {};
return (
<CardWrapper
{...wrapperProps}
className={cn(
'group relative flex overflow-hidden',
'bg-white dark:bg-dark-secondary',
'border border-light-200 dark:border-dark-200',
'rounded-xl shadow-sm',
'transition-all duration-200',
href && 'cursor-pointer hover:shadow-md hover:border-[#EA580C]/30',
horizontal ? 'flex-row' : 'flex-col',
compact ? 'p-2' : 'p-0',
className
)}
>
{image && (
<div
className={cn(
'relative overflow-hidden bg-light-100 dark:bg-dark-100',
horizontal
? compact
? 'w-16 h-16 rounded-lg flex-shrink-0'
: 'w-24 h-24 rounded-l-xl flex-shrink-0'
: compact
? 'w-full h-24'
: 'w-full aspect-video'
)}
>
<img
src={image}
alt={imageAlt || title}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
loading="lazy"
onError={(e) => {
(e.target as HTMLImageElement).src = PLACEHOLDER_IMAGE;
}}
/>
{badge && (
<span
className={cn(
'absolute top-2 left-2 px-2 py-0.5 rounded-full text-[10px] font-semibold',
badgeVariants[badge.variant]
)}
>
{badge.text}
</span>
)}
</div>
)}
<div
className={cn(
'flex flex-col flex-1 min-w-0',
horizontal ? (compact ? 'pl-2' : 'p-3') : compact ? 'pt-2' : 'p-3'
)}
>
<div className="flex-1 min-w-0">
{!image && badge && (
<span
className={cn(
'inline-block mb-1.5 px-2 py-0.5 rounded-full text-[10px] font-semibold',
badgeVariants[badge.variant]
)}
>
{badge.text}
</span>
)}
<h4
className={cn(
'font-semibold text-black dark:text-white leading-tight',
compact ? 'text-xs line-clamp-2' : 'text-sm line-clamp-2'
)}
>
{title}
</h4>
{subtitle && (
<p
className={cn(
'text-black/60 dark:text-white/60 mt-0.5',
compact ? 'text-[10px] line-clamp-1' : 'text-xs line-clamp-2'
)}
>
{subtitle}
</p>
)}
{meta && meta.length > 0 && (
<div
className={cn(
'flex flex-wrap items-center gap-x-2 gap-y-0.5 mt-1.5',
compact ? 'text-[9px]' : 'text-[10px]'
)}
>
{meta.map((item, i) => (
<span
key={i}
className="text-black/50 dark:text-white/50 whitespace-nowrap"
>
{item}
</span>
))}
</div>
)}
{children}
</div>
{(action || secondaryAction) && !compact && (
<div className="flex items-center gap-2 mt-3 pt-2 border-t border-light-200/50 dark:border-dark-200/50">
{action && (
<a
href={action.url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className={cn(
'flex items-center gap-1 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors',
action.variant === 'secondary'
? 'bg-light-100 dark:bg-dark-100 text-black/70 dark:text-white/70 hover:bg-light-200 dark:hover:bg-dark-200'
: 'bg-[#EA580C] text-white hover:bg-[#EA580C]/90'
)}
>
{action.label}
<ExternalLink size={10} />
</a>
)}
{secondaryAction && (
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
secondaryAction.onClick();
}}
className="px-3 py-1.5 rounded-lg text-xs font-medium
bg-light-100 dark:bg-dark-100
text-black/70 dark:text-white/70
hover:bg-light-200 dark:hover:bg-dark-200
transition-colors"
>
{secondaryAction.label}
</button>
)}
</div>
)}
</div>
{href && (
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
<ExternalLink
size={14}
className="text-black/30 dark:text-white/30"
/>
</div>
)}
</CardWrapper>
);
}
export default UnifiedCard;

View File

@@ -0,0 +1,234 @@
'use client';
import { Play, Eye, ThumbsUp, Clock, ExternalLink } from 'lucide-react';
import { cn } from '@/lib/utils';
export interface VideoCardProps {
title: string;
thumbnail: string;
url: string;
embedUrl?: string;
duration: number;
views?: number;
likes?: number;
author: string;
authorUrl?: string;
authorAvatar?: string;
platform: 'rutube' | 'vk' | 'youtube' | 'dzen' | 'other';
publishedAt?: string;
description?: string;
compact?: boolean;
horizontal?: boolean;
}
const platformLogos: Record<string, { icon: string; color: string; name: string }> = {
rutube: { icon: '🎬', color: 'bg-green-500', name: 'Rutube' },
vk: { icon: '💙', color: 'bg-blue-500', name: 'VK Видео' },
youtube: { icon: '▶️', color: 'bg-red-500', name: 'YouTube' },
dzen: { icon: '📰', color: 'bg-yellow-500', name: 'Дзен' },
other: { icon: '🎥', color: 'bg-gray-500', name: 'Видео' },
};
function formatDuration(seconds: number): string {
if (!seconds || seconds <= 0) return '0:00';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
return `${minutes}:${secs.toString().padStart(2, '0')}`;
}
function formatViews(views: number): string {
if (views >= 1_000_000) {
return (views / 1_000_000).toFixed(1) + 'M';
}
if (views >= 1_000) {
return (views / 1_000).toFixed(1) + 'K';
}
return views.toString();
}
function formatDate(dateStr: string): string {
try {
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) return 'Сегодня';
if (diffDays === 1) return 'Вчера';
if (diffDays < 7) return `${diffDays} дн. назад`;
if (diffDays < 30) return `${Math.floor(diffDays / 7)} нед. назад`;
if (diffDays < 365) return `${Math.floor(diffDays / 30)} мес. назад`;
return `${Math.floor(diffDays / 365)} г. назад`;
} catch {
return '';
}
}
export function VideoCard({
title,
thumbnail,
url,
duration,
views,
likes,
author,
authorUrl,
authorAvatar,
platform,
publishedAt,
description,
compact = false,
horizontal = false,
}: VideoCardProps) {
const platformInfo = platformLogos[platform] || platformLogos.other;
return (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className={cn(
'group flex overflow-hidden',
'bg-white dark:bg-dark-secondary',
'border border-light-200 dark:border-dark-200',
'rounded-xl shadow-sm',
'transition-all duration-200',
'hover:shadow-lg hover:border-[#EA580C]/30',
horizontal ? 'flex-row' : 'flex-col',
compact ? (horizontal ? 'h-20' : 'w-44') : 'w-full'
)}
>
<div
className={cn(
'relative overflow-hidden bg-black flex-shrink-0',
horizontal
? compact
? 'w-32 h-20'
: 'w-40 h-24'
: compact
? 'w-full h-24'
: 'w-full aspect-video'
)}
>
<img
src={thumbnail}
alt={title}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
loading="lazy"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors flex items-center justify-center">
<div className="w-12 h-12 rounded-full bg-black/60 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<Play size={20} className="text-white ml-1" fill="white" />
</div>
</div>
{duration > 0 && (
<span className="absolute bottom-1.5 right-1.5 px-1.5 py-0.5 rounded bg-black/80 text-white text-[10px] font-medium flex items-center gap-1">
<Clock size={10} />
{formatDuration(duration)}
</span>
)}
<span
className={cn(
'absolute top-1.5 left-1.5 w-5 h-5 rounded flex items-center justify-center text-[10px]',
platformInfo.color
)}
title={platformInfo.name}
>
{platformInfo.icon}
</span>
</div>
<div
className={cn(
'flex flex-col flex-1 min-w-0',
horizontal ? 'p-2' : compact ? 'p-2' : 'p-3'
)}
>
<h4
className={cn(
'font-semibold text-black dark:text-white leading-tight',
compact ? 'text-xs line-clamp-2' : 'text-sm line-clamp-2'
)}
>
{title}
</h4>
<div className="flex items-center gap-2 mt-1.5">
{authorAvatar && !compact && (
<img
src={authorAvatar}
alt={author}
className="w-5 h-5 rounded-full object-cover"
loading="lazy"
/>
)}
<div className="min-w-0">
{authorUrl ? (
<a
href={authorUrl}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className={cn(
'text-black/70 dark:text-white/70 hover:text-[#EA580C] transition-colors truncate block',
compact ? 'text-[10px]' : 'text-xs'
)}
>
@{author}
</a>
) : (
<span
className={cn(
'text-black/70 dark:text-white/70 truncate block',
compact ? 'text-[10px]' : 'text-xs'
)}
>
@{author}
</span>
)}
</div>
</div>
<div
className={cn(
'flex items-center gap-3 mt-1.5 text-black/50 dark:text-white/50',
compact ? 'text-[9px]' : 'text-[10px]'
)}
>
{views !== undefined && (
<span className="flex items-center gap-1">
<Eye size={10} />
{formatViews(views)}
</span>
)}
{likes !== undefined && !compact && (
<span className="flex items-center gap-1">
<ThumbsUp size={10} />
{formatViews(likes)}
</span>
)}
{publishedAt && (
<span>{formatDate(publishedAt)}</span>
)}
<span className="ml-auto">{platformInfo.name}</span>
</div>
{description && !compact && !horizontal && (
<p className="text-xs text-black/50 dark:text-white/50 line-clamp-2 mt-2">
{description}
</p>
)}
</div>
</a>
);
}
export default VideoCard;

View File

@@ -0,0 +1,250 @@
'use client';
import { useState, useCallback, useMemo } from 'react';
import { Play, ExternalLink, Eye, ThumbsUp, Clock } from 'lucide-react';
import { cn } from '@/lib/utils';
export interface VideoEmbedProps {
platform: 'rutube' | 'vk' | 'youtube' | 'dzen' | 'other';
videoId?: string;
url: string;
embedUrl?: string;
thumbnail: string;
title: string;
duration?: number;
views?: number;
likes?: number;
author?: string;
authorUrl?: string;
publishedAt?: string;
autoplay?: boolean;
compact?: boolean;
}
const platformInfo: Record<string, { name: string; color: string; icon: string }> = {
rutube: { name: 'Rutube', color: 'bg-green-500', icon: '🎬' },
vk: { name: 'VK Видео', color: 'bg-blue-500', icon: '💙' },
youtube: { name: 'YouTube', color: 'bg-red-500', icon: '▶️' },
dzen: { name: 'Дзен', color: 'bg-yellow-500', icon: '📰' },
other: { name: 'Видео', color: 'bg-gray-500', icon: '🎥' },
};
function formatDuration(seconds: number): string {
if (!seconds || seconds <= 0) return '';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
return `${minutes}:${secs.toString().padStart(2, '0')}`;
}
function formatViews(views: number): string {
if (views >= 1_000_000) return (views / 1_000_000).toFixed(1) + 'M';
if (views >= 1_000) return (views / 1_000).toFixed(1) + 'K';
return views.toString();
}
function getEmbedUrl(platform: string, videoId?: string, url?: string, customEmbedUrl?: string): string {
if (customEmbedUrl) return customEmbedUrl;
switch (platform) {
case 'youtube':
if (videoId) return `https://www.youtube.com/embed/${videoId}?rel=0`;
break;
case 'rutube':
if (videoId) return `https://rutube.ru/play/embed/${videoId}`;
break;
case 'vk':
return url || '';
case 'dzen':
return url || '';
default:
return url || '';
}
return url || '';
}
export function VideoEmbed({
platform,
videoId,
url,
embedUrl: customEmbedUrl,
thumbnail,
title,
duration,
views,
likes,
author,
authorUrl,
publishedAt,
autoplay = false,
compact = false,
}: VideoEmbedProps) {
const [isPlaying, setIsPlaying] = useState(autoplay);
const [thumbnailError, setThumbnailError] = useState(false);
const info = platformInfo[platform] || platformInfo.other;
const embedUrl = useMemo(
() => getEmbedUrl(platform, videoId, url, customEmbedUrl),
[platform, videoId, url, customEmbedUrl]
);
const canEmbed = useMemo(() => {
return platform === 'youtube' || platform === 'rutube';
}, [platform]);
const handlePlay = useCallback(() => {
if (canEmbed) {
setIsPlaying(true);
} else {
window.open(url, '_blank');
}
}, [canEmbed, url]);
const handleThumbnailError = useCallback(() => {
setThumbnailError(true);
}, []);
return (
<div
className={cn(
'w-full bg-white dark:bg-dark-secondary',
'border border-light-200 dark:border-dark-200',
'rounded-xl shadow-sm overflow-hidden',
'transition-all duration-200 hover:shadow-md'
)}
>
<div
className={cn(
'relative bg-black',
compact ? 'aspect-video' : 'aspect-video md:aspect-[16/9]'
)}
>
{isPlaying && canEmbed ? (
<iframe
src={`${embedUrl}${embedUrl.includes('?') ? '&' : '?'}autoplay=1`}
title={title}
className="absolute inset-0 w-full h-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
) : (
<>
{!thumbnailError ? (
<img
src={thumbnail}
alt={title}
className="absolute inset-0 w-full h-full object-cover"
loading="lazy"
onError={handleThumbnailError}
/>
) : (
<div className="absolute inset-0 flex items-center justify-center bg-gray-800">
<span className="text-6xl">{info.icon}</span>
</div>
)}
<div
className="absolute inset-0 bg-black/30 flex items-center justify-center cursor-pointer group"
onClick={handlePlay}
>
<div className="w-16 h-16 md:w-20 md:h-20 rounded-full bg-white/90 flex items-center justify-center transition-transform group-hover:scale-110 shadow-lg">
<Play
size={compact ? 24 : 32}
className="text-black ml-1"
fill="black"
/>
</div>
</div>
{duration && duration > 0 && (
<span className="absolute bottom-3 right-3 px-2 py-1 rounded bg-black/80 text-white text-xs font-medium flex items-center gap-1">
<Clock size={12} />
{formatDuration(duration)}
</span>
)}
<span
className={cn(
'absolute top-3 left-3 px-2 py-1 rounded text-white text-xs font-medium flex items-center gap-1',
info.color
)}
>
<span>{info.icon}</span>
{info.name}
</span>
</>
)}
</div>
<div className={cn('p-4', compact && 'p-3')}>
<h4
className={cn(
'font-semibold text-black dark:text-white leading-tight',
compact ? 'text-sm line-clamp-2' : 'text-base line-clamp-2'
)}
>
{title}
</h4>
{author && (
<div className="mt-2">
{authorUrl ? (
<a
href={authorUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-black/70 dark:text-white/70 hover:text-[#EA580C] transition-colors"
>
@{author}
</a>
) : (
<span className="text-sm text-black/70 dark:text-white/70">
@{author}
</span>
)}
</div>
)}
<div className="flex items-center gap-4 mt-2 text-xs text-black/50 dark:text-white/50">
{views !== undefined && (
<span className="flex items-center gap-1">
<Eye size={12} />
{formatViews(views)} просмотров
</span>
)}
{likes !== undefined && (
<span className="flex items-center gap-1">
<ThumbsUp size={12} />
{formatViews(likes)}
</span>
)}
{publishedAt && <span>{publishedAt}</span>}
</div>
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-light-200/50 dark:border-dark-200/50">
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-xs font-medium
bg-light-100 dark:bg-dark-100
text-black/70 dark:text-white/70
hover:bg-light-200 dark:hover:bg-dark-200
transition-colors"
>
Открыть на {info.name}
<ExternalLink size={10} />
</a>
</div>
</div>
</div>
);
}
export default VideoEmbed;

View File

@@ -13,6 +13,7 @@ export interface GuestChat {
createdAt: string;
sources: string[];
files: { fileId: string; name: string }[];
projectId?: string;
}
export interface GuestMessage {
@@ -63,6 +64,10 @@ export function getGuestChats(): GuestChat[] {
);
}
export function getGuestChatsByProject(projectId: string): GuestChat[] {
return getGuestChats().filter((t) => t.projectId === projectId);
}
export function getGuestChat(id: string): GuestChat | null {
const data = load();
return data.threads.find((t) => t.id === id) ?? null;
@@ -80,6 +85,7 @@ export function upsertGuestThread(chat: {
title: string;
sources?: string[];
files?: { fileId: string; name: string }[];
projectId?: string;
}): void {
const data = load();
const existing = data.threads.findIndex((t) => t.id === chat.id);
@@ -90,6 +96,7 @@ export function upsertGuestThread(chat: {
createdAt: existing >= 0 ? data.threads[existing].createdAt : now,
sources: chat.sources ?? [],
files: chat.files ?? [],
projectId: chat.projectId,
};
if (existing >= 0) {
data.threads[existing] = { ...data.threads[existing], ...thread };

View File

@@ -11,7 +11,7 @@ import {
useState,
} from 'react';
import crypto from 'crypto';
import { useParams, useSearchParams } from 'next/navigation';
import { useParams, useSearchParams, useRouter } from 'next/navigation';
import { toast } from 'sonner';
import { getSuggestions } from '../actions';
import type { MinimalProvider } from '../types-ui';
@@ -117,9 +117,12 @@ const checkConfig = async (
);
}
const data = await res.json();
const providers: MinimalProvider[] = data.providers ?? [];
const envOnlyMode = data.envOnlyMode ?? false;
const data = (await res.json()) as { providers?: unknown[]; envOnlyMode?: boolean };
const rawProviders = data?.providers;
const providers: MinimalProvider[] = Array.isArray(rawProviders)
? rawProviders.filter((p): p is MinimalProvider => p != null && typeof p === 'object')
: [];
const envOnlyMode = data?.envOnlyMode ?? false;
if (providers.length === 0 && !envOnlyMode) {
throw new Error('Сервис настраивается. Попробуйте позже.');
@@ -127,7 +130,7 @@ const checkConfig = async (
let chatModelProvider =
providers.find((p) => p.id === chatModelProviderId) ??
providers.find((p) => p.chatModels.length > 0);
providers.find((p) => (p.chatModels?.length ?? 0) > 0);
if (!chatModelProvider && !envOnlyMode) {
throw new Error('Сервис настраивается. Попробуйте позже.');
@@ -135,18 +138,19 @@ const checkConfig = async (
if (chatModelProvider) {
chatModelProviderId = chatModelProvider.id;
const chatModels = chatModelProvider.chatModels ?? [];
const chatModel =
chatModelProvider.chatModels.find((m) => m.key === chatModelKey) ??
chatModelProvider.chatModels[0];
chatModelKey = chatModel.key;
chatModels.find((m) => m.key === chatModelKey) ?? chatModels[0];
chatModelKey = chatModel?.key ?? 'default';
} else if (envOnlyMode && providers.length > 0) {
const envProvider = providers.find((p) => p.id.startsWith('env-'));
const envProvider = providers.find((p) => p.id?.startsWith('env-'));
if (envProvider) {
chatModelProviderId = envProvider.id;
chatModelKey = envProvider.chatModels[0]?.key ?? 'default';
chatModelProviderId = envProvider.id ?? 'env';
chatModelKey = envProvider.chatModels?.[0]?.key ?? 'default';
} else {
chatModelProviderId = providers[0].id;
chatModelKey = providers[0].chatModels[0]?.key ?? 'default';
const first = providers[0];
chatModelProviderId = first?.id ?? 'env';
chatModelKey = first?.chatModels?.[0]?.key ?? 'default';
}
} else {
chatModelProviderId = 'env';
@@ -155,20 +159,20 @@ const checkConfig = async (
const embeddingModelProvider =
providers.find((p) => p.id === embeddingModelProviderId) ??
providers.find((p) => p.embeddingModels.length > 0);
providers.find((p) => (p.embeddingModels?.length ?? 0) > 0);
if (!embeddingModelProvider && !envOnlyMode) {
throw new Error('Сервис настраивается. Попробуйте позже.');
}
if (embeddingModelProvider) {
embeddingModelProviderId = embeddingModelProvider.id;
embeddingModelProviderId = embeddingModelProvider.id ?? '';
const embeddingModels = embeddingModelProvider.embeddingModels ?? [];
const embeddingModel =
embeddingModelProvider.embeddingModels.find(
(m) => m.key === embeddingModelKey,
) ?? embeddingModelProvider.embeddingModels[0];
embeddingModelKey = embeddingModel.key;
embeddingModels.find((m) => m.key === embeddingModelKey) ??
embeddingModels[0];
embeddingModelKey = embeddingModel?.key ?? '';
localStorage.setItem('embeddingModelKey', embeddingModelKey);
localStorage.setItem('embeddingModelProviderId', embeddingModelProviderId);
@@ -261,7 +265,7 @@ const loadMessages = async (
),
);
chatHistory.current = history;
setSources(chat?.sources ?? []);
setSources(chat?.sources?.length ? chat.sources : ['web']);
setIsMessagesLoaded(true);
return;
}
@@ -319,7 +323,7 @@ const loadMessages = async (
);
chatHistory.current = history;
setSources(data.chat?.sources ?? []);
setSources(data.chat?.sources?.length ? data.chat.sources : ['web']);
setIsMessagesLoaded(true);
};
@@ -354,8 +358,14 @@ export const chatContext = createContext<ChatContext>({
setResearchEnded: () => {},
});
/** Принудительно русский язык везде */
function getEffectiveResponseLocale(_localeCode: string): string {
return 'ru';
}
export const ChatProvider = ({ children }: { children: React.ReactNode }) => {
const params: { chatId: string } = useParams();
const params = useParams() as { chatId?: string; projectId?: string };
const router = useRouter();
const { localeCode } = useTranslation();
const searchParams = useSearchParams();
@@ -612,11 +622,29 @@ export const ChatProvider = ({ children }: { children: React.ReactNode }) => {
}
}, [isMessagesLoaded, isConfigReady, newChatCreated]);
const rewrite = (messageId: string) => {
const rewrite = async (messageId: string) => {
const index = messages.findIndex((msg) => msg.messageId === messageId);
if (index === -1) return;
const messageToRewrite = messages[index];
if (
messageToRewrite.query.startsWith('Summary: ') &&
messageToRewrite.query.length > 9
) {
const summaryUrl = messageToRewrite.query.slice(9).trim();
if (summaryUrl) {
try {
await fetch(
`/api/v1/discover/article-summary?url=${encodeURIComponent(summaryUrl)}`,
{ method: 'DELETE' }
);
} catch {
// ignore
}
}
}
const isGuest =
typeof window !== 'undefined' &&
!localStorage.getItem('auth_token') &&
@@ -642,7 +670,6 @@ export const ChatProvider = ({ children }: { children: React.ReactNode }) => {
chatHistory.current = chatHistory.current.slice(0, index * 2);
const messageToRewrite = messages[index];
sendMessage(
messageToRewrite.query,
messageToRewrite.messageId,
@@ -757,6 +784,25 @@ export const ChatProvider = ({ children }: { children: React.ReactNode }) => {
);
}
// Обработка стримингового чанка текста для немедленного отображения
if (data.type === 'textChunk') {
setMessages((prev) =>
prev.map((msg) => {
if (msg.messageId === messageId) {
const updatedBlocks = msg.responseBlocks.map((block) => {
if (block.id === data.blockId && block.type === 'text') {
return { ...block, data: block.data + (data.chunk || '') };
}
return block;
});
return { ...msg, responseBlocks: updatedBlocks };
}
return msg;
}),
);
setMessageAppeared(true);
}
if (data.type === 'messageEnd') {
if (handledMessageEndRef.current.has(messageId)) {
return;
@@ -830,7 +876,7 @@ export const ChatProvider = ({ children }: { children: React.ReactNode }) => {
);
if (hasSourceBlocks && !hasSuggestions) {
const suggestions = await getSuggestions(newHistory, localeCode);
const suggestions = await getSuggestions(newHistory, getEffectiveResponseLocale(localeCode));
const suggestionBlock: Block = {
id: crypto.randomBytes(7).toString('hex'),
type: 'suggestion',
@@ -871,8 +917,10 @@ export const ChatProvider = ({ children }: { children: React.ReactNode }) => {
setResearchEnded(false);
setMessageAppeared(false);
if (messages.length <= 1) {
window.history.replaceState(null, '', `/c/${chatId}`);
if (messages.length <= 1 && chatId) {
const projectId = params.projectId;
const path = projectId ? `/spaces/${projectId}/chat/${chatId}` : `/c/${chatId}`;
router.replace(path);
}
messageId = messageId ?? crypto.randomBytes(7).toString('hex');
@@ -898,6 +946,7 @@ export const ChatProvider = ({ children }: { children: React.ReactNode }) => {
title: message,
sources,
files: files.map((f) => ({ fileId: f.fileId, name: f.fileName })),
projectId: params.projectId,
});
}
@@ -935,31 +984,13 @@ export const ChatProvider = ({ children }: { children: React.ReactNode }) => {
providerId: embeddingModelProvider.providerId,
},
systemInstructions: localStorage.getItem('systemInstructions'),
locale: localeCode,
locale: getEffectiveResponseLocale(localeCode),
responsePrefs: {
format: localStorage.getItem('responseFormat') || undefined,
length: localStorage.getItem('responseLength') || undefined,
tone: localStorage.getItem('responseTone') || undefined,
},
learningMode: localStorage.getItem('learningMode') === 'true',
modelCouncil: localStorage.getItem('modelCouncil') === 'true',
councilModels: (() => {
if (localStorage.getItem('modelCouncil') !== 'true') return undefined;
const stored = localStorage.getItem('councilModels');
if (stored) {
try {
const arr = JSON.parse(stored) as { providerId: string; key: string }[];
if (Array.isArray(arr) && arr.length === 3) return arr;
} catch {
/* ignore */
}
}
return [
{ providerId: chatModelProvider.providerId, key: chatModelProvider.key },
{ providerId: chatModelProvider.providerId, key: chatModelProvider.key },
{ providerId: chatModelProvider.providerId, key: chatModelProvider.key },
];
})(),
}),
});

View File

@@ -1,11 +1,8 @@
/**
* Клиент для Localization Service.
* Получает locale на основе геопозиции (через geo-device-service) и переводы.
* Принудительно русский язык везде.
*/
import { fetchContextWithGeolocation } from './geoDevice';
import { localeFromCountryCode } from './localization/countryToLocale';
const LOCALE_API = '/api/locale';
const TRANSLATIONS_API = '/api/translations';
@@ -52,83 +49,11 @@ export async function fetchLocaleWithClient(): Promise<LocalizationContext> {
return res.json();
}
/** IP-провайдеры возвращают country_code — fallback когда geo-device недоступен */
async function fetchCountryFromIp(): Promise<string | null> {
const providers: Array<{
url: string;
getCountry: (d: Record<string, unknown>) => string | null;
}> = [
{
url: 'https://get.geojs.io/v1/ip/geo.json',
getCountry: (d) => {
const cc = d.country_code ?? d.country;
return typeof cc === 'string' ? cc.toUpperCase() : null;
},
},
{
url: 'https://ipwhois.app/json/',
getCountry: (d) => {
const cc = d.country_code ?? d.country;
return typeof cc === 'string' ? cc.toUpperCase() : null;
},
},
];
for (const p of providers) {
try {
const res = await fetch(p.url);
const d = (await res.json()) as Record<string, unknown>;
const country = p.getCountry(d);
if (country) return country;
} catch {
/* следующий провайдер */
}
}
return null;
}
const DEFAULT_LOCALE = 'ru';
/**
* Получить locale с приоритетом geo-context.
* По умолчанию — русский. Только если геопозиция из другой страны — используем её язык.
*/
/** Принудительно русский язык везде */
export async function fetchLocaleWithGeoFirst(): Promise<LocalizationContext> {
try {
const ctx = await fetchContextWithGeolocation();
const cc = ctx.geo?.countryCode;
if (cc) {
const locale = localeFromCountryCode(cc);
return {
locale,
language: locale,
region: ctx.geo?.region ?? null,
countryCode: cc,
timezone: ctx.geo?.timezone ?? null,
source: 'geo',
};
}
} catch {
/* geo-context недоступен */
}
try {
const countryCode = await fetchCountryFromIp();
if (countryCode) {
const locale = localeFromCountryCode(countryCode);
return {
locale,
language: locale,
region: null,
countryCode,
timezone: null,
source: 'geo',
};
}
} catch {
/* IP fallback недоступен */
}
return {
locale: DEFAULT_LOCALE,
language: DEFAULT_LOCALE,
locale: 'ru',
language: 'ru',
region: null,
countryCode: null,
timezone: null,

View File

@@ -28,90 +28,109 @@ interface LocalizationContextValue extends LocalizationState {
localeCode: string;
}
/** Принудительно русский — fallback при загрузке/ошибке */
const defaultTranslations: Translations = {
'app.title': 'GooSeek',
'app.searchPlaceholder': 'Search...',
'empty.subtitle': 'Research begins here.',
'input.askAnything': 'Ask anything...',
'input.askFollowUp': 'Ask a follow-up',
'chat.send': 'Send',
'chat.newChat': 'New Chat',
'chat.suggestions': 'Suggestions',
'discover.title': 'Discover',
'discover.region': 'Region',
'weather.title': 'Weather',
'weather.feelsLike': 'Feels like',
'common.loading': 'Loading...',
'common.error': 'Error',
'common.retry': 'Retry',
'common.close': 'Close',
'nav.home': 'Home',
'nav.discover': 'Discover',
'nav.library': 'Library',
'nav.messageHistory': 'History',
'nav.finance': 'Finance',
'nav.travel': 'Travel',
'nav.spaces': 'Spaces',
'nav.medicine': 'Medicine',
'nav.realEstate': 'Real Estate',
'nav.goods': 'Goods',
'nav.children': 'Children',
'nav.education': 'Education',
'nav.health': 'Health',
'nav.psychology': 'Psychology',
'nav.sports': 'Sports',
'nav.shopping': 'Shopping',
'nav.games': 'Games',
'nav.taxes': 'Taxes',
'nav.legislation': 'Legislation',
'answerMode.standard': 'Standard',
'answerMode.focus': 'Focus',
'answerMode.academic': 'Academic',
'answerMode.writing': 'Writing',
'nav.sidebarSettings': 'Main menu settings',
'nav.configureMenu': 'Configure menu',
'nav.menuSettingsHint': 'Toggle items on/off with the switch, change order with up/down arrows.',
'nav.moveUp': 'Move up',
'nav.moveDown': 'Move down',
'nav.resetMenu': 'Reset to default',
'nav.more': 'More',
'chat.related': 'Follow-up questions',
'chat.searchImages': 'Search images',
'chat.searchVideos': 'Search videos',
'chat.video': 'Video',
'chat.uploadedFile': 'Uploaded File',
'chat.viewMore': 'View {count} more',
'chat.sources': 'Sources',
'chat.researchProgress': 'Research Progress',
'chat.step': 'step',
'chat.steps': 'steps',
'chat.answer': 'Answer',
'chat.brainstorming': 'Brainstorming...',
'chat.formingAnswer': 'Forming answer...',
'chat.answerFailed': 'Could not form an answer from the sources found. Try rephrasing or shortening your query.',
'chat.thinking': 'Thinking',
'chat.searchingQueries': 'Searching {count} {plural}',
'chat.foundResults': 'Found {count} {plural}',
'chat.readingSources': 'Reading {count} {plural}',
'chat.scanningDocs': 'Scanning your uploaded documents',
'chat.readingDocs': 'Reading {count} {plural}',
'chat.processing': 'Processing',
'chat.query': 'query',
'chat.queries': 'queries',
'chat.result': 'result',
'chat.results': 'results',
'chat.source': 'source',
'chat.document': 'document',
'chat.documents': 'documents',
'library.filterSourceAll': 'All sources',
'library.filterSourceWeb': 'Web',
'library.filterSourceAcademic': 'Academic',
'library.filterSourceSocial': 'Social',
'library.filterFilesAll': 'All',
'library.filterFilesWith': 'With files',
'library.filterFilesWithout': 'No files',
'library.noResults': 'No chats match your filters.',
'library.clearFilters': 'Clear filters',
'app.searchPlaceholder': 'Поиск...',
'empty.subtitle': 'Исследование начинается здесь.',
'input.askAnything': 'Спросите что угодно...',
'input.askFollowUp': 'Задайте уточняющий вопрос',
'chat.send': 'Отправить',
'chat.newChat': 'Новый чат',
'chat.suggestions': 'Подсказки',
'discover.title': 'Обзор',
'discover.region': 'Регион',
'weather.title': 'Погода',
'weather.feelsLike': 'Ощущается как',
'common.loading': 'Загрузка...',
'common.error': 'Ошибка',
'common.retry': 'Повторить',
'common.close': 'Закрыть',
'nav.home': 'Главная',
'nav.discover': 'Обзор',
'nav.library': 'Библиотека',
'nav.messageHistory': 'История',
'nav.finance': 'Финансы',
'nav.travel': 'Путешествия',
'nav.spaces': 'Пространства',
'nav.medicine': 'Медицина',
'nav.realEstate': 'Недвижимость',
'nav.goods': 'Товары',
'nav.children': 'Дети',
'nav.education': 'Обучение',
'nav.health': 'Здоровье',
'nav.psychology': 'Психология',
'nav.sports': 'Спорт',
'nav.shopping': 'Покупки',
'nav.games': 'Игры',
'nav.taxes': 'Налоги',
'nav.legislation': 'Законодательство',
'answerMode.standard': 'Стандарт',
'answerMode.focus': 'Фокус',
'answerMode.academic': 'Академический',
'answerMode.writing': 'Письмо',
'nav.sidebarSettings': 'Настройка главного меню',
'nav.configureMenu': 'Настроить меню',
'nav.menuSettingsHint': 'Включайте и выключайте пункты переключателем, меняйте порядок стрелками вверх и вниз.',
'nav.moveUp': 'Вверх',
'nav.moveDown': 'Вниз',
'nav.resetMenu': 'Сбросить по умолчанию',
'nav.more': 'Прочее',
'chat.related': 'Что ещё спросить',
'chat.searchImages': 'Поиск изображений',
'chat.searchVideos': 'Поиск видео',
'chat.video': 'Видео',
'chat.uploadedFile': 'Загруженный файл',
'chat.viewMore': 'Ещё {count}',
'chat.sources': 'Источники',
'chat.researchProgress': 'Прогресс исследования',
'chat.step': 'шаг',
'chat.steps': 'шагов',
'chat.answer': 'Ответ',
'chat.brainstorming': 'Размышляю...',
'chat.formingAnswer': 'Формирование ответа...',
'chat.answerFailed': 'Не удалось сформировать ответ на основе найденных источников. Попробуйте переформулировать или сократить запрос.',
'chat.thinking': 'Размышляю',
'chat.searchingQueries': 'Поиск по {count} {plural}',
'chat.foundResults': 'Найдено {count} {plural}',
'chat.readingSources': 'Чтение {count} {plural}',
'chat.scanningDocs': 'Сканирование загруженных документов',
'chat.readingDocs': 'Чтение {count} {plural}',
'chat.processing': 'Обработка',
'chat.query': 'запросу',
'chat.queries': 'запросам',
'chat.result': 'результату',
'chat.results': 'результатов',
'chat.source': 'источнику',
'chat.document': 'документу',
'chat.documents': 'документов',
'library.filterSourceAll': 'Все источники',
'library.filterSourceWeb': 'Веб',
'library.filterSourceAcademic': 'Академия',
'library.filterSourceSocial': 'Соцсети',
'library.filterFilesAll': 'Все',
'library.filterFilesWith': 'С файлами',
'library.filterFilesWithout': 'Без файлов',
'library.noResults': 'Нет чатов по вашим фильтрам.',
'library.clearFilters': 'Сбросить фильтры',
'nav.profile': 'Профиль',
'profile.account': 'Аккаунт',
'profile.preferences': 'Настройки',
'profile.personalize': 'Персонализация',
'profile.billing': 'Оплата',
'profile.connectors': 'Мои коннекторы',
'profile.appSettings': 'Настройки приложения',
'profile.preferencesDesc': 'Внешний вид, Язык, Подсказки, Виджеты на главной.',
'profile.personalizeDesc': 'Списки наблюдения, память AI, персонализация.',
'profile.connectorsDesc': 'Google Drive, Dropbox и другие интеграции (Pro).',
'profile.signInToView': 'Войдите, чтобы просмотреть профиль.',
'profile.current': 'Текущий',
'profile.month': 'мес',
'profile.upgrade': 'Перейти',
'profile.paymentHistory': 'История платежей',
'profile.comingSoon': 'Скоро.',
'profile.connect': 'Подключить',
'profile.connected': 'Подключено',
};
const LocalizationContext = createContext<LocalizationContextValue | null>(null);
@@ -136,11 +155,9 @@ export function LocalizationProvider({
if (cancelled) return;
let trans: Record<string, string>;
try {
trans = await fetchTranslations(loc.locale);
trans = await fetchTranslations('ru');
} catch {
trans = (await import('./embeddedTranslations')).getEmbeddedTranslations(
loc.locale,
);
trans = (await import('./embeddedTranslations')).getEmbeddedTranslations('ru');
}
if (cancelled) return;
setState({
@@ -178,7 +195,7 @@ export function LocalizationProvider({
[state.translations],
);
const localeCode = state.locale?.locale ?? 'ru';
const localeCode = 'ru';
const value = useMemo<LocalizationContextValue>(
() => ({

View File

@@ -0,0 +1,103 @@
/**
* IndexedDB — локальное хранение файлов проекта (на сервер не грузим)
* Ключ: projectId
*/
const DB_NAME = 'gooseek_project_files';
const DB_VERSION = 1;
const STORE_NAME = 'files';
export interface ProjectFile {
id: string;
projectId: string;
name: string;
type: string;
size: number;
data: ArrayBuffer;
createdAt: string;
}
function openDb(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onerror = () => reject(req.error);
req.onsuccess = () => resolve(req.result);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
const store = db.createObjectStore(STORE_NAME, { keyPath: 'id' });
store.createIndex('projectId', 'projectId', { unique: false });
}
};
});
}
export async function getProjectFiles(projectId: string): Promise<ProjectFile[]> {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
const index = store.index('projectId');
const req = index.getAll(projectId);
req.onsuccess = () => resolve(req.result ?? []);
req.onerror = () => reject(req.error);
tx.oncomplete = () => db.close();
});
}
export async function addProjectFile(
projectId: string,
file: File
): Promise<ProjectFile> {
const data = await file.arrayBuffer();
const id = crypto.randomUUID();
const item: ProjectFile = {
id,
projectId,
name: file.name,
type: file.type,
size: file.size,
data,
createdAt: new Date().toISOString(),
};
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
const req = store.add(item);
req.onsuccess = () => resolve(item);
req.onerror = () => reject(req.error);
tx.oncomplete = () => db.close();
});
}
export async function deleteProjectFile(id: string): Promise<void> {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
const req = store.delete(id);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
tx.oncomplete = () => db.close();
});
}
export async function getProjectFileAsBlob(id: string): Promise<Blob | null> {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
const req = store.get(id);
req.onsuccess = () => {
const item = req.result as ProjectFile | undefined;
if (!item) {
resolve(null);
return;
}
resolve(new Blob([item.data], { type: item.type }));
};
req.onerror = () => reject(req.error);
tx.oncomplete = () => db.close();
});
}

View File

@@ -0,0 +1,79 @@
/**
* Локальное хранение метаданных проекта для гостей (localStorage)
* При авторизации — синхронизация с projects-svc
*/
const KEY_PREFIX = 'gooseek_project_';
export interface ProjectMeta {
id: string;
title: string;
description: string;
links: string[];
instructions?: string;
}
export function getAllGuestProjects(): ProjectMeta[] {
if (typeof window === 'undefined') return [];
const result: ProjectMeta[] = [];
try {
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key?.startsWith(KEY_PREFIX) && key.length > KEY_PREFIX.length) {
const raw = localStorage.getItem(key);
if (raw) {
const parsed = JSON.parse(raw) as ProjectMeta;
if (parsed.id && parsed.title) result.push(parsed);
}
}
}
return result.sort((a, b) => (a.title ?? '').localeCompare(b.title ?? ''));
} catch {
return [];
}
}
export function getGuestProject(projectId: string): ProjectMeta | null {
if (typeof window === 'undefined') return null;
try {
const raw = localStorage.getItem(KEY_PREFIX + projectId);
if (!raw) return null;
return JSON.parse(raw) as ProjectMeta;
} catch {
return null;
}
}
export function setGuestProject(project: ProjectMeta): void {
if (typeof window === 'undefined') return;
try {
localStorage.setItem(KEY_PREFIX + project.id, JSON.stringify(project));
} catch (e) {
console.error('[project-storage] save failed:', e);
}
}
export function createGuestProject(title: string, description: string): ProjectMeta {
const id = crypto.randomUUID();
const project: ProjectMeta = {
id,
title: title || 'Новый проект',
description: description || '',
links: [],
instructions: '',
};
setGuestProject(project);
return project;
}
const CURRENT_PROJECT_KEY = 'gooseek_current_project_id';
export function getCurrentProjectId(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem(CURRENT_PROJECT_KEY);
}
export function setCurrentProjectId(projectId: string): void {
if (typeof window === 'undefined') return;
localStorage.setItem(CURRENT_PROJECT_KEY, projectId);
}

View File

@@ -62,12 +62,29 @@ export type SuggestionBlock = {
data: string[];
};
export type WidgetType =
| 'weather'
| 'stock'
| 'calculation_result'
| 'products'
| 'videos'
| 'profiles'
| 'promos'
| 'product'
| 'video'
| 'profile'
| 'promo'
| 'knowledge_card'
| 'image_gallery'
| 'video_embed'
| 'knowledge_card_hint';
export type WidgetBlock = {
id: string;
type: 'widget';
data: {
widgetType: string;
params: Record<string, any>;
widgetType: WidgetType;
params: Record<string, unknown>;
};
};

File diff suppressed because one or more lines are too long