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:
@@ -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
|
||||
|
||||
@@ -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*` },
|
||||
|
||||
85
services/web-svc/src/app/api/chat/route.ts
Normal file
85
services/web-svc/src/app/api/chat/route.ts
Normal 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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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 />;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
583
services/web-svc/src/app/spaces/[projectId]/page.tsx
Normal file
583
services/web-svc/src/app/spaces/[projectId]/page.tsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
(~30–90 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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
194
services/web-svc/src/components/RelatedQuestions.tsx
Normal file
194
services/web-svc/src/components/RelatedQuestions.tsx
Normal 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;
|
||||
@@ -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'),
|
||||
|
||||
264
services/web-svc/src/components/SourcesPanel.tsx
Normal file
264
services/web-svc/src/components/SourcesPanel.tsx
Normal 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;
|
||||
@@ -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',
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
203
services/web-svc/src/components/Widgets/CardGallery.tsx
Normal file
203
services/web-svc/src/components/Widgets/CardGallery.tsx
Normal 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;
|
||||
266
services/web-svc/src/components/Widgets/InlineImageGallery.tsx
Normal file
266
services/web-svc/src/components/Widgets/InlineImageGallery.tsx
Normal 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;
|
||||
464
services/web-svc/src/components/Widgets/KnowledgeCard.tsx
Normal file
464
services/web-svc/src/components/Widgets/KnowledgeCard.tsx
Normal 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;
|
||||
231
services/web-svc/src/components/Widgets/ProductCard.tsx
Normal file
231
services/web-svc/src/components/Widgets/ProductCard.tsx
Normal 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;
|
||||
216
services/web-svc/src/components/Widgets/ProfileCard.tsx
Normal file
216
services/web-svc/src/components/Widgets/ProfileCard.tsx
Normal 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;
|
||||
245
services/web-svc/src/components/Widgets/PromoCard.tsx
Normal file
245
services/web-svc/src/components/Widgets/PromoCard.tsx
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
229
services/web-svc/src/components/Widgets/UnifiedCard.tsx
Normal file
229
services/web-svc/src/components/Widgets/UnifiedCard.tsx
Normal 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;
|
||||
234
services/web-svc/src/components/Widgets/VideoCard.tsx
Normal file
234
services/web-svc/src/components/Widgets/VideoCard.tsx
Normal 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;
|
||||
250
services/web-svc/src/components/Widgets/VideoEmbed.tsx
Normal file
250
services/web-svc/src/components/Widgets/VideoEmbed.tsx
Normal 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;
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
})(),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>(
|
||||
() => ({
|
||||
|
||||
103
services/web-svc/src/lib/project-files-db.ts
Normal file
103
services/web-svc/src/lib/project-files-db.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
79
services/web-svc/src/lib/project-storage.ts
Normal file
79
services/web-svc/src/lib/project-storage.ts
Normal 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);
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user