feat: default locale Russian, geo determines language for other countries

- localization-svc: defaultLocale ru, resolveLocale only by geo
- web-svc: DEFAULT_LOCALE ru, layout lang=ru, embeddedTranslations fallback ru
- countryToLocale: default ru when no country or unknown country

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
home
2026-02-23 15:10:38 +03:00
parent 8fc82a3b90
commit cd6b7857ba
606 changed files with 26148 additions and 14297 deletions

View File

@@ -0,0 +1,11 @@
/**
* Health probe для K8s liveness
* docs/architecture: 05-gaps-and-best-practices.md §9
*/
export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';
export async function GET() {
return Response.json({ status: 'ok', service: 'web-svc' });
}

View File

@@ -0,0 +1,17 @@
/**
* Prometheus /metrics — docs/architecture: 05-gaps-and-best-practices.md §5
*/
export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';
export async function GET() {
const body =
'# HELP gooseek_up Service is up (1) or down (0)\n' +
'# TYPE gooseek_up gauge\n' +
'gooseek_up 1\n';
return new Response(body, {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
}

View File

@@ -0,0 +1,12 @@
/**
* Readiness probe для K8s
* web-svc не имеет критичных внешних зависимостей при старте
* docs/architecture: 05-gaps-and-best-practices.md §9
*/
export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';
export async function GET() {
return Response.json({ status: 'ready' });
}

View File

@@ -0,0 +1,94 @@
import SessionManager from '@/lib/session';
export const POST = async (
req: Request,
{ params }: { params: Promise<{ id: string }> },
) => {
try {
const { id } = await params;
const session = SessionManager.getSession(id);
if (!session) {
return Response.json({ message: 'Session not found' }, { status: 404 });
}
const responseStream = new TransformStream();
const writer = responseStream.writable.getWriter();
const encoder = new TextEncoder();
let unsub: () => void = () => {};
unsub = session.subscribe((event, data) => {
if (event === 'data') {
if (data.type === 'block') {
writer.write(
encoder.encode(
JSON.stringify({
type: 'block',
block: data.block,
}) + '\n',
),
);
} else if (data.type === 'updateBlock') {
writer.write(
encoder.encode(
JSON.stringify({
type: 'updateBlock',
blockId: data.blockId,
patch: data.patch,
}) + '\n',
),
);
} else if (data.type === 'researchComplete') {
writer.write(
encoder.encode(
JSON.stringify({
type: 'researchComplete',
}) + '\n',
),
);
}
} else if (event === 'end') {
writer.write(
encoder.encode(
JSON.stringify({
type: 'messageEnd',
}) + '\n',
),
);
writer.close();
setImmediate(() => unsub());
} else if (event === 'error') {
writer.write(
encoder.encode(
JSON.stringify({
type: 'error',
data: data.data,
}) + '\n',
),
);
writer.close();
setImmediate(() => unsub());
}
});
req.signal.addEventListener('abort', () => {
unsub();
writer.close();
});
return new Response(responseStream.readable, {
headers: {
'Content-Type': 'text/event-stream',
Connection: 'keep-alive',
'Cache-Control': 'no-cache, no-transform',
},
});
} catch (err) {
console.error('Error in reconnecting to session stream: ', err);
return Response.json(
{ message: 'An error has occurred.' },
{ status: 500 },
);
}
};

View File

@@ -0,0 +1,34 @@
/**
* Тонкий прокси к chat-svc. Вся логика uploads на бекенде.
*/
import { NextResponse } from 'next/server';
export async function POST(req: Request) {
try {
const formData = await req.formData();
const chatUrl = process.env.CHAT_SVC_URL ?? 'http://localhost:3005';
const newFormData = new FormData();
const providerId = formData.get('embedding_model_provider_id');
const modelKey = formData.get('embedding_model_key');
if (providerId) newFormData.set('embedding_model_provider_id', String(providerId));
if (modelKey) newFormData.set('embedding_model_key', String(modelKey));
const files = formData.getAll('files');
for (const f of files) {
if (f instanceof File) newFormData.append('files', f);
}
const res = await fetch(`${chatUrl}/api/v1/uploads`, {
method: 'POST',
body: newFormData,
signal: AbortSignal.timeout(300000),
});
const data = await res.json();
return NextResponse.json(data, { status: res.status });
} catch (err) {
console.error('Upload proxy error:', err);
return NextResponse.json({ message: 'An error has occurred.' }, { status: 500 });
}
}

View File

@@ -0,0 +1,5 @@
'use client';
import ChatWindow from '@/components/ChatWindow';
export default ChatWindow;

View File

@@ -0,0 +1,80 @@
'use client';
import { ChevronLeft, FolderOpen } from 'lucide-react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useEffect, useState } from 'react';
import { toast } from 'sonner';
interface Collection {
id: string;
title: string;
category?: string;
description?: string;
}
const Page = () => {
const params = useParams();
const id = params?.id as string;
const [collection, setCollection] = useState<Collection | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!id) return;
const fetchCollection = async () => {
try {
const res = await fetch(`/api/v1/collections/${id}`);
if (!res.ok) {
if (res.status === 404) setCollection(null);
else throw new Error('Failed to fetch');
return;
}
const data = await res.json();
setCollection(data);
} catch {
toast.error('Failed to load collection');
} finally {
setLoading(false);
}
};
fetchCollection();
}, [id]);
return (
<div className="flex flex-col pt-10 pb-28 lg:pb-8 px-4 max-w-4xl mx-auto w-full">
<Link
href="/spaces"
className="inline-flex items-center gap-1 text-sm text-black/60 dark:text-white/60 hover:text-black dark:hover:text-white mb-4"
>
<ChevronLeft size={16} />
Back to Spaces
</Link>
{loading ? (
<div className="h-48 rounded-2xl bg-light-secondary dark:bg-dark-secondary animate-pulse" />
) : collection ? (
<>
<div className="flex items-center gap-3 border-b border-light-200/20 dark:border-dark-200/20 pb-6">
<FolderOpen size={40} className="text-[#7C3AED]" />
<h1 className="text-3xl font-normal">{collection.title}</h1>
</div>
{collection.category && (
<span className="inline-block mt-4 text-sm px-2 py-1 rounded bg-light-200 dark:bg-dark-200">
{collection.category}
</span>
)}
{collection.description && (
<p className="mt-4 text-black/70 dark:text-white/70">{collection.description}</p>
)}
<div className="mt-8 p-6 rounded-2xl bg-light-secondary dark:bg-dark-secondary text-center text-black/60 dark:text-white/60">
<p>Read-only collection. Full content integration coming soon.</p>
</div>
</>
) : (
<p className="mt-6 text-black/60 dark:text-white/60">Collection not found.</p>
)}
</div>
);
};
export default Page;

View File

@@ -0,0 +1,436 @@
'use client';
import { Globe2Icon, Settings } from 'lucide-react';
import { useEffect, useState, useRef } from 'react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import SmallNewsCard from '@/components/Discover/SmallNewsCard';
import MajorNewsCard from '@/components/Discover/MajorNewsCard';
import DataFetchError from '@/components/DataFetchError';
import { fetchContextWithClient } from '@/lib/geoDevice';
const COUNTRY_TO_REGION: Record<string, string> = {
US: 'america', CA: 'america', MX: 'america',
RU: 'russia', BY: 'russia', KZ: 'russia',
CN: 'china', HK: 'china', TW: 'china',
DE: 'eu', FR: 'eu', IT: 'eu', ES: 'eu', GB: 'eu', UK: 'eu',
NL: 'eu', PL: 'eu', BE: 'eu', AT: 'eu', PT: 'eu', SE: 'eu',
FI: 'eu', IE: 'eu', GR: 'eu', RO: 'eu', CZ: 'eu', HU: 'eu',
BG: 'eu', HR: 'eu', SK: 'eu', SI: 'eu', LT: 'eu', LV: 'eu', EE: 'eu', DK: 'eu',
};
const getRegionFromGeo = (countryCode: string | undefined): string | null => {
if (!countryCode) return null;
return COUNTRY_TO_REGION[countryCode] ?? null;
};
const fetchRegion = async (): Promise<string | null> => {
let region: string | null = null;
try {
const ctx = await fetchContextWithClient();
region = getRegionFromGeo(ctx.geo?.countryCode);
} catch {
// geo-context недоступен
}
if (!region) {
try {
const res = await fetch('https://get.geojs.io/v1/ip/geo.json');
const d = await res.json();
region = getRegionFromGeo(d?.country_code);
} catch {
// GeoJS недоступен
}
}
return region;
};
export interface Discover {
title: string;
content: string;
url: string;
thumbnail: 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 FETCH_TIMEOUT_MS = 15000;
const Page = () => {
const [discover, setDiscover] = useState<Discover[] | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [activeTopic, setActiveTopic] = useState<string>(topics[0].key);
const [setupRequired, setSetupRequired] = useState(false);
const regionPromiseRef = useRef<Promise<string | null> | null>(null);
const getRegionPromise = (): Promise<string | null> => {
if (!regionPromiseRef.current) {
regionPromiseRef.current = fetchRegion();
}
return regionPromiseRef.current;
};
const fetchArticles = async (topic: string) => {
setLoading(true);
setError(null);
setSetupRequired(false);
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 data = await res.json();
if (!res.ok) {
throw new Error(data.message || 'Failed to load articles');
}
if (topic !== 'gooseek') {
data.blogs = data.blogs.filter((blog: Discover) => blog.thumbnail);
}
setDiscover(data.blogs);
} catch (err: unknown) {
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);
toast.error(message);
}
} finally {
setLoading(false);
}
};
useEffect(() => {
if (activeTopic === 'gooseek') {
fetchArticles('gooseek');
return;
}
fetchArticles(activeTopic);
}, [activeTopic]);
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>
))}
</div>
</div>
</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 */}
<div className="block lg:hidden">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{[1, 2, 3, 4].map((i) => (
<div
key={i}
className="rounded-3xl overflow-hidden bg-light-secondary dark:bg-dark-secondary shadow-sm shadow-light-200/10 dark:shadow-black/25 flex flex-col"
>
<div className="relative aspect-video overflow-hidden bg-light-200 dark:bg-dark-200 animate-pulse" />
<div className="p-4">
<div className="h-4 w-full rounded bg-light-200 dark:bg-dark-200 animate-pulse mb-2" />
<div className="h-4 w-3/4 rounded bg-light-200 dark:bg-dark-200 animate-pulse mb-2" />
<div className="h-3 w-2/3 rounded bg-light-200 dark:bg-dark-200 animate-pulse" />
<div className="h-3 w-1/2 rounded bg-light-200 dark:bg-dark-200 animate-pulse mt-1" />
</div>
</div>
))}
</div>
</div>
{/* Desktop: точно как реальный layout — 1st Major isLeft=false (текст слева, картинка справа) */}
<div className="hidden lg:block space-y-0">
{/* 1st MajorNewsCard isLeft=FALSE: content LEFT, image RIGHT */}
<div className="w-full flex flex-row items-stretch gap-6 h-60 py-3">
<div className="flex flex-col justify-center flex-1 py-4 space-y-3">
<div className="h-8 w-3/4 rounded bg-light-200 dark:bg-dark-200 animate-pulse" />
<div className="h-4 w-full rounded bg-light-200 dark:bg-dark-200 animate-pulse" />
<div className="h-4 w-3/4 rounded bg-light-200 dark:bg-dark-200 animate-pulse" />
<div className="h-4 w-1/2 rounded bg-light-200 dark:bg-dark-200 animate-pulse" />
</div>
<div className="relative w-80 h-full overflow-hidden rounded-2xl flex-shrink-0 bg-light-200 dark:bg-dark-200 animate-pulse" />
</div>
<hr className="border-t border-light-200/20 dark:border-dark-200/20 my-3 w-full" />
{/* 3× SmallNewsCard */}
<div className="grid lg:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-4">
{[1, 2, 3].map((i) => (
<div
key={i}
className="rounded-3xl overflow-hidden bg-light-secondary dark:bg-dark-secondary shadow-sm shadow-light-200/10 dark:shadow-black/25 flex flex-col"
>
<div className="relative aspect-video overflow-hidden bg-light-200 dark:bg-dark-200 animate-pulse" />
<div className="p-4">
<div className="h-4 w-full rounded bg-light-200 dark:bg-dark-200 animate-pulse mb-2" />
<div className="h-4 w-3/4 rounded bg-light-200 dark:bg-dark-200 animate-pulse mb-2" />
<div className="h-3 w-2/3 rounded bg-light-200 dark:bg-dark-200 animate-pulse" />
<div className="h-3 w-1/2 rounded bg-light-200 dark:bg-dark-200 animate-pulse mt-1" />
</div>
</div>
))}
</div>
<hr className="border-t border-light-200/20 dark:border-dark-200/20 my-3 w-full" />
{/* 2nd MajorNewsCard isLeft=TRUE: image LEFT, content RIGHT */}
<div className="w-full flex flex-row items-stretch gap-6 h-60 py-3">
<div className="relative w-80 h-full overflow-hidden rounded-2xl flex-shrink-0 bg-light-200 dark:bg-dark-200 animate-pulse" />
<div className="flex flex-col justify-center flex-1 py-4 space-y-3">
<div className="h-8 w-2/3 rounded bg-light-200 dark:bg-dark-200 animate-pulse" />
<div className="h-4 w-full rounded bg-light-200 dark:bg-dark-200 animate-pulse" />
<div className="h-4 w-3/4 rounded bg-light-200 dark:bg-dark-200 animate-pulse" />
</div>
</div>
<hr className="border-t border-light-200/20 dark:border-dark-200/20 my-3 w-full" />
{/* 3rd MajorNewsCard isLeft=FALSE: content LEFT, image RIGHT */}
<div className="w-full flex flex-row items-stretch gap-6 h-60 py-3">
<div className="flex flex-col justify-center flex-1 py-4 space-y-3">
<div className="h-8 w-2/3 rounded bg-light-200 dark:bg-dark-200 animate-pulse" />
<div className="h-4 w-full rounded bg-light-200 dark:bg-dark-200 animate-pulse" />
</div>
<div className="relative w-80 h-full overflow-hidden rounded-2xl flex-shrink-0 bg-light-200 dark:bg-dark-200 animate-pulse" />
</div>
<hr className="border-t border-light-200/20 dark:border-dark-200/20 my-3 w-full" />
{/* 3× SmallNewsCard */}
<div className="grid lg:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-4">
{[1, 2, 3].map((i) => (
<div
key={i}
className="rounded-3xl overflow-hidden bg-light-secondary dark:bg-dark-secondary shadow-sm shadow-light-200/10 dark:shadow-black/25 flex flex-col"
>
<div className="relative aspect-video overflow-hidden bg-light-200 dark:bg-dark-200 animate-pulse" />
<div className="p-4">
<div className="h-4 w-full rounded bg-light-200 dark:bg-dark-200 animate-pulse mb-2" />
<div className="h-4 w-3/4 rounded bg-light-200 dark:bg-dark-200 animate-pulse mb-2" />
<div className="h-3 w-2/3 rounded bg-light-200 dark:bg-dark-200 animate-pulse" />
<div className="h-3 w-1/2 rounded bg-light-200 dark:bg-dark-200 animate-pulse mt-1" />
</div>
</div>
))}
</div>
</div>
</div>
) : error ? (
<div className="pb-28 pt-5 lg:pb-8 px-4">
<DataFetchError message={error} onRetry={() => fetchArticles(activeTopic)} />
</div>
) : setupRequired ? (
<div className="flex flex-col items-center justify-center min-h-[50vh] gap-4 px-4 text-center">
<div className="rounded-full p-4 bg-[#EA580C]/10 dark:bg-[#EA580C]/5">
<Settings size={48} className="text-[#EA580C]" />
</div>
<div>
<p className="text-lg font-medium text-black dark:text-white">
Configure SearxNG to discover articles
</p>
<p className="mt-2 text-sm text-black/60 dark:text-white/60 max-w-md">
Open Settings (gear icon in the navbar), go to Search, and enter
your SearxNG URL (e.g. http://localhost:8080)
</p>
</div>
</div>
) : discover && discover.length > 0 ? (
<div className="flex flex-col gap-4 pb-28 pt-5 lg:pb-8 w-full">
<div className="block lg:hidden">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{discover?.map((item, i) => (
<SmallNewsCard key={`mobile-${i}`} item={item} />
))}
</div>
</div>
<div className="hidden lg:block">
{discover &&
discover.length > 0 &&
(() => {
const sections = [];
let index = 0;
while (index < discover.length) {
if (sections.length > 0) {
sections.push(
<hr
key={`sep-${index}`}
className="border-t border-light-200/20 dark:border-dark-200/20 my-3 w-full"
/>,
);
}
if (index < discover.length) {
sections.push(
<MajorNewsCard
key={`major-${index}`}
item={discover[index]}
isLeft={false}
/>,
);
index++;
}
if (index < discover.length) {
sections.push(
<hr
key={`sep-${index}-after`}
className="border-t border-light-200/20 dark:border-dark-200/20 my-3 w-full"
/>,
);
}
if (index < discover.length) {
const smallCards = discover.slice(index, index + 3);
sections.push(
<div
key={`small-group-${index}`}
className="grid lg:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-4"
>
{smallCards.map((item, i) => (
<SmallNewsCard
key={`small-${index + i}`}
item={item}
/>
))}
</div>,
);
index += 3;
}
if (index < discover.length) {
sections.push(
<hr
key={`sep-${index}-after-small`}
className="border-t border-light-200/20 dark:border-dark-200/20 my-3 w-full"
/>,
);
}
if (index < discover.length - 1) {
const twoMajorCards = discover.slice(index, index + 2);
twoMajorCards.forEach((item, i) => {
sections.push(
<MajorNewsCard
key={`double-${index + i}`}
item={item}
isLeft={i === 0}
/>,
);
if (i === 0) {
sections.push(
<hr
key={`sep-double-${index + i}`}
className="border-t border-light-200/20 dark:border-dark-200/20 my-3 w-full"
/>,
);
}
});
index += 2;
} else if (index < discover.length) {
sections.push(
<MajorNewsCard
key={`final-major-${index}`}
item={discover[index]}
isLeft={true}
/>,
);
index++;
}
if (index < discover.length) {
sections.push(
<hr
key={`sep-${index}-after-major`}
className="border-t border-light-200/20 dark:border-dark-200/20 my-3 w-full"
/>,
);
}
if (index < discover.length) {
const smallCards = discover.slice(index, index + 3);
sections.push(
<div
key={`small-group-2-${index}`}
className="grid lg:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-4"
>
{smallCards.map((item, i) => (
<SmallNewsCard
key={`small-2-${index + i}`}
item={item}
/>
))}
</div>,
);
index += 3;
}
}
return sections;
})()}
</div>
</div>
) : discover && discover.length === 0 ? (
<div className="flex flex-col items-center justify-center min-h-[40vh] px-4 text-center">
<p className="text-black/60 dark:text-white/60 text-sm">
No articles found for this topic.
</p>
</div>
) : null}
</div>
</>
);
};
export default Page;

View File

@@ -0,0 +1,243 @@
'use client';
import { ChevronLeft, TrendingUp, FileText, BarChart3, Sparkles, Star } from 'lucide-react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useEffect, useState } from 'react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
interface QuoteData {
symbol: string;
name: string;
price: number;
change: number;
high?: number;
low?: number;
}
interface NewsItem {
title?: string;
url?: string;
publishedDate?: string;
}
interface SecFiling {
type?: string;
fillingDate?: string;
finalLink?: string;
description?: string;
}
const WATCHLIST_KEY = 'gooseek_finance_watchlist';
const Page = () => {
const params = useParams();
const ticker = (params?.ticker as string)?.toUpperCase() ?? '';
const [quote, setQuote] = useState<QuoteData | null>(null);
const [inWatchlist, setInWatchlist] = useState(false);
const [news, setNews] = useState<NewsItem[]>([]);
const [filings, setFilings] = useState<SecFiling[]>([]);
const [priceContext, setPriceContext] = useState<{
summary?: string | null;
news?: string[];
} | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!ticker) return;
try {
const raw = localStorage.getItem(WATCHLIST_KEY);
const list = raw ? JSON.parse(raw) : [];
setInWatchlist(Array.isArray(list) && list.includes(ticker));
} catch {
setInWatchlist(false);
}
}, [ticker]);
const toggleWatchlist = () => {
try {
const raw = localStorage.getItem(WATCHLIST_KEY);
const list: string[] = raw ? JSON.parse(raw) : [];
const idx = list.indexOf(ticker);
if (idx >= 0) {
list.splice(idx, 1);
setInWatchlist(false);
} else {
list.push(ticker);
setInWatchlist(true);
}
localStorage.setItem(WATCHLIST_KEY, JSON.stringify(list));
} catch {
toast.error('Failed to update watchlist');
}
};
useEffect(() => {
if (!ticker) return;
const fetchData = async () => {
setLoading(true);
try {
const [quoteRes, newsRes, filingsRes, contextRes] = await Promise.all([
fetch(`/api/v1/finance/quote/${ticker}`),
fetch(`/api/v1/finance/news/${ticker}`),
fetch(`/api/v1/finance/sec-filings/${ticker}`),
fetch(`/api/v1/finance/price-context/${ticker}`),
]);
if (quoteRes.ok) setQuote(await quoteRes.json());
if (newsRes.ok) {
const d = await newsRes.json();
setNews(Array.isArray(d?.items) ? d.items : []);
}
if (filingsRes.ok) {
const d = await filingsRes.json();
setFilings(Array.isArray(d?.filings) ? d.filings : []);
}
if (contextRes.ok) {
const d = await contextRes.json();
if (d.summary || (d.news && d.news.length > 0)) {
setPriceContext({ summary: d.summary ?? null, news: d.news ?? [] });
}
}
} catch {
toast.error('Failed to load ticker data');
} finally {
setLoading(false);
}
};
fetchData();
}, [ticker]);
return (
<div className="flex flex-col pt-10 pb-28 lg:pb-8 px-4 max-w-4xl mx-auto w-full">
<Link
href="/finance"
className="inline-flex items-center gap-1 text-sm text-black/60 dark:text-white/60 hover:text-black dark:hover:text-white mb-4"
>
<ChevronLeft size={16} />
Back to Finance
</Link>
<div className="flex items-center gap-3 border-b border-light-200/20 dark:border-dark-200/20 pb-6">
<TrendingUp size={40} className="text-[#EA580C]" />
<h1 className="text-3xl font-normal">{ticker}</h1>
<button
type="button"
onClick={toggleWatchlist}
className={cn(
'p-2 rounded-lg transition-colors',
inWatchlist ? 'text-yellow-500 hover:text-yellow-600' : 'text-black/40 dark:text-white/40 hover:text-[#EA580C]'
)}
title={inWatchlist ? 'Remove from watchlist' : 'Add to watchlist'}
>
<Star size={24} fill={inWatchlist ? 'currentColor' : 'none'} />
</button>
</div>
{loading ? (
<div className="mt-6 space-y-4">
<div className="h-24 rounded-2xl bg-light-secondary dark:bg-dark-secondary animate-pulse" />
<div className="h-32 rounded-2xl bg-light-secondary dark:bg-dark-secondary animate-pulse" />
</div>
) : quote ? (
<>
<div className="mt-6 rounded-2xl bg-light-secondary dark:bg-dark-secondary p-6 shadow-sm border border-light-200/20 dark:border-dark-200/20">
<p className="text-sm text-black/60 dark:text-white/60">{quote.name || ticker}</p>
<p className="text-3xl font-medium mt-1">
{quote.price > 0 ? `$${quote.price.toLocaleString(undefined, { minimumFractionDigits: 2 })}` : '—'}
</p>
<p
className={cn(
'text-lg mt-1',
quote.change > 0 ? 'text-green-600' : quote.change < 0 ? 'text-red-600' : 'text-black/60 dark:text-white/60'
)}
>
{quote.change !== 0 ? `${quote.change > 0 ? '+' : ''}${quote.change.toFixed(2)}%` : '—'}
</p>
{quote.high != null && quote.low != null && quote.high > 0 && quote.low > 0 && (
<p className="text-sm text-black/60 dark:text-white/60 mt-2">
Day range: ${quote.low.toFixed(2)} ${quote.high.toFixed(2)}
</p>
)}
</div>
{priceContext && (priceContext.summary || (priceContext.news && priceContext.news.length > 0)) && (
<div className="mt-4 rounded-2xl bg-light-secondary/80 dark:bg-dark-secondary/80 p-4 border border-light-200/30 dark:border-dark-200/30">
<h3 className="text-sm font-medium flex items-center gap-2 mb-2">
<Sparkles size={16} className="text-[#EA580C]" />
Price movement context
</h3>
{priceContext.summary ? (
<p className="text-sm text-black/80 dark:text-white/80">{priceContext.summary}</p>
) : priceContext.news && priceContext.news.length > 0 ? (
<ul className="text-sm text-black/70 dark:text-white/70 space-y-1">
{priceContext.news.slice(0, 3).map((h, i) => (
<li key={i}> {h}</li>
))}
</ul>
) : null}
</div>
)}
{news.length > 0 && (
<div className="mt-6">
<h2 className="text-lg font-medium mb-3 flex items-center gap-2">
<BarChart3 size={20} /> News
</h2>
<ul className="space-y-2">
{news.slice(0, 5).map((item, i) => (
<li key={i}>
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 dark:text-blue-400 hover:underline block truncate"
>
{item.title ?? 'News'}
</a>
{item.publishedDate && (
<span className="text-xs text-black/50 dark:text-white/50">{item.publishedDate}</span>
)}
</li>
))}
</ul>
</div>
)}
{filings.length > 0 && (
<div className="mt-6">
<h2 className="text-lg font-medium mb-3 flex items-center gap-2">
<FileText size={20} /> SEC Filings
</h2>
<ul className="space-y-2">
{filings.slice(0, 10).map((f, i) => (
<li key={i} className="flex items-center justify-between gap-2">
<span className="text-sm">{f.type ?? 'Filing'}</span>
<a
href={f.finalLink}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 dark:text-blue-400 hover:underline text-sm truncate max-w-[200px]"
>
{f.fillingDate ?? 'View'}
</a>
</li>
))}
</ul>
</div>
)}
{news.length === 0 && filings.length === 0 && quote.price === 0 && (
<p className="mt-6 text-black/60 dark:text-white/60 text-sm">
Set FMP_API_KEY in finance-svc for full data.
</p>
)}
</>
) : (
<p className="mt-6 text-black/60 dark:text-white/60">Ticker not found.</p>
)}
</div>
);
};
export default Page;

View File

@@ -0,0 +1,386 @@
'use client';
import { TrendingUp, Coins, BarChart3, Star } from 'lucide-react';
import Link from 'next/link';
import { useEffect, useState } from 'react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import DataFetchError from '@/components/DataFetchError';
const FETCH_TIMEOUT_MS = 15000;
interface IndexItem {
symbol: string;
name: string;
price: number;
change: number;
}
interface SummaryData {
indices?: IndexItem[];
updated_at?: number;
}
interface Collection {
id: string;
title: string;
category?: string;
description?: string;
}
interface HeatmapSector {
name?: string;
symbol?: string;
change?: number;
[key: string]: unknown;
}
interface HeatmapData {
sectors?: HeatmapSector[];
updated_at?: number;
}
type FinanceTab = 'overview' | 'crypto' | 'movers' | 'watchlist';
interface MoverItem {
symbol?: string;
name?: string;
price?: number;
changesPercentage?: number;
change?: number;
}
const WATCHLIST_KEY = 'gooseek_finance_watchlist';
const Page = () => {
const [tab, setTab] = useState<FinanceTab>('overview');
const [summary, setSummary] = useState<SummaryData | null>(null);
const [heatmap, setHeatmap] = useState<HeatmapData | null>(null);
const [collections, setCollections] = useState<Collection[]>([]);
const [gainers, setGainers] = useState<MoverItem[]>([]);
const [losers, setLosers] = useState<MoverItem[]>([]);
const [crypto, setCrypto] = useState<MoverItem[]>([]);
const [watchlist, setWatchlist] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchSummary = async () => {
setLoading(true);
setError(null);
try {
const res = await fetch('/api/v1/finance/summary', {
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!res.ok) throw new Error('Failed to fetch');
const data = await res.json();
setSummary(data);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Error';
const isTimeout = msg.includes('timeout') || (err as Error)?.name === 'AbortError';
setError(isTimeout ? 'Request timed out. Try again.' : msg);
toast.error(msg);
setSummary({ indices: [] });
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchSummary();
}, []);
useEffect(() => {
fetch('/api/v1/finance/heatmap', { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) })
.then((r) => r.json())
.then((d: HeatmapData) => setHeatmap(d))
.catch(() => setHeatmap({ sectors: [] }));
}, []);
useEffect(() => {
fetch('/api/v1/collections?category=finance')
.then((r) => r.json())
.then((d) => setCollections(d.items ?? []))
.catch(() => {});
}, []);
useEffect(() => {
fetch('/api/v1/finance/gainers', { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) })
.then((r) => r.json())
.then((d) => setGainers(d.items ?? []))
.catch(() => setGainers([]));
}, []);
useEffect(() => {
fetch('/api/v1/finance/losers', { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) })
.then((r) => r.json())
.then((d) => setLosers(d.items ?? []))
.catch(() => setLosers([]));
}, []);
useEffect(() => {
fetch('/api/v1/finance/crypto', { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) })
.then((r) => r.json())
.then((d) => setCrypto(d.items ?? []))
.catch(() => setCrypto([]));
}, []);
useEffect(() => {
try {
const raw = localStorage.getItem(WATCHLIST_KEY);
setWatchlist(raw ? JSON.parse(raw) : []);
} catch {
setWatchlist([]);
}
}, []);
return (
<div className="flex flex-col pt-10 pb-28 lg:pb-8 px-4 max-w-4xl mx-auto w-full">
<div className="flex items-center gap-3 border-b border-light-200/20 dark:border-dark-200/20 pb-6">
<TrendingUp size={40} className="text-[#EA580C]" />
<h1 className="text-4xl font-normal">Finance</h1>
</div>
<div className="mt-4 flex flex-wrap gap-2">
{(['overview', 'crypto', 'movers', 'watchlist'] as const).map((t) => (
<button
key={t}
type="button"
onClick={() => setTab(t)}
className={cn(
'flex items-center gap-1.5 px-4 py-2 rounded-xl text-sm font-medium transition-colors',
tab === t
? 'bg-[#EA580C] text-white'
: 'bg-light-secondary dark:bg-dark-secondary text-black/70 dark:text-white/70 hover:bg-light-200 dark:hover:bg-dark-200'
)}
>
{t === 'overview' && <TrendingUp size={16} />}
{t === 'crypto' && <Coins size={16} />}
{t === 'movers' && <BarChart3 size={16} />}
{t === 'watchlist' && <Star size={16} />}
{t === 'overview' && 'Overview'}
{t === 'crypto' && 'Crypto'}
{t === 'movers' && 'Gainers & Losers'}
{t === 'watchlist' && `Watchlist (${watchlist.length})`}
</button>
))}
<Link
href="/finance/predictions/polymarket"
className="text-sm text-[#EA580C] hover:underline self-center ml-2"
>
Predictions
</Link>
</div>
{error ? (
<DataFetchError message={error} onRetry={fetchSummary} />
) : loading ? (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-6">
{[1, 2, 3].map((i) => (
<div
key={i}
className="rounded-2xl bg-light-secondary dark:bg-dark-secondary p-4 animate-pulse"
>
<div className="h-4 w-20 bg-light-200 dark:bg-dark-200 rounded mb-3" />
<div className="h-6 w-24 bg-light-200 dark:bg-dark-200 rounded mb-2" />
<div className="h-4 w-16 bg-light-200 dark:bg-dark-200 rounded" />
</div>
))}
</div>
) : (
<>
{tab === 'overview' && summary?.indices && summary.indices.length > 0 && (
<>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-6">
{summary.indices.map((idx) => (
<Link
key={idx.symbol}
href={`/finance/${encodeURIComponent(idx.symbol)}`}
className="rounded-2xl bg-light-secondary dark:bg-dark-secondary p-4 shadow-sm border border-light-200/20 dark:border-dark-200/20 block hover:border-[#EA580C]/30 transition-colors"
>
<p className="text-sm text-black/60 dark:text-white/60">{idx.name}</p>
<p className="text-xl font-medium mt-1">
{idx.price > 0 ? idx.price.toLocaleString() : '—'}
</p>
<p
className={cn(
'text-sm mt-1',
idx.change > 0 ? 'text-green-600' : idx.change < 0 ? 'text-red-600' : 'text-black/60 dark:text-white/60'
)}
>
{idx.change !== 0 ? `${idx.change > 0 ? '+' : ''}${idx.change.toFixed(2)}%` : '—'}
</p>
</Link>
))}
</div>
<section className="mt-10">
<h2 className="text-xl font-medium mb-4">S&P 500 Sector Heatmap</h2>
{heatmap?.sectors && heatmap.sectors.length > 0 ? (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{heatmap.sectors.map((s, i) => {
const ch = typeof s.change === 'number' ? s.change : 0;
const label = (s.name ?? s.symbol ?? `Sector ${i + 1}`).toString();
return (
<div
key={i}
className={cn(
'rounded-xl p-3 text-center text-sm font-medium',
ch > 0 && 'bg-green-500/20 text-green-700 dark:text-green-400',
ch < 0 && 'bg-red-500/20 text-red-700 dark:text-red-400',
ch === 0 && 'bg-light-secondary dark:bg-dark-secondary text-black/70 dark:text-white/70'
)}
>
<span className="truncate block">{label}</span>
<span className="text-xs mt-0.5">
{ch > 0 ? '+' : ''}{ch.toFixed(2)}%
</span>
</div>
);
})}
</div>
) : (
<div className="rounded-2xl bg-light-secondary dark:bg-dark-secondary p-6 text-black/60 dark:text-white/60 text-sm">
<p>Sector heatmap will appear when finance-svc has market data (FMP_API_KEY + cache-worker).</p>
</div>
)}
</section>
<section className="mt-10">
<h2 className="text-xl font-medium mb-4">Popular Spaces for Finance Research</h2>
{collections.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{collections.map((c) => (
<Link
key={c.id}
href={`/collections/${c.id}`}
className="rounded-2xl bg-light-secondary dark:bg-dark-secondary p-4 border border-light-200/20 dark:border-dark-200/20 hover:border-[#EA580C]/30 transition-colors block"
>
<h3 className="font-medium">{c.title}</h3>
{c.description && (
<p className="text-sm text-black/60 dark:text-white/60 mt-1 line-clamp-2">{c.description}</p>
)}
</Link>
))}
</div>
) : (
<div className="rounded-2xl bg-light-secondary dark:bg-dark-secondary p-4 text-black/60 dark:text-white/60 text-sm">
<p>Collections will appear when projects-svc is running.</p>
</div>
)}
</section>
</>
)}
{tab === 'crypto' && (
<div className="mt-6">
<h2 className="text-xl font-medium mb-4">Popular Cryptocurrencies</h2>
{crypto.length > 0 ? (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{crypto.map((c, i) => (
<Link
key={c.symbol ?? i}
href={`/finance/${encodeURIComponent(c.symbol ?? '')}`}
className="rounded-xl p-3 bg-light-secondary dark:bg-dark-secondary border border-light-200/20 dark:border-dark-200/20 hover:border-[#EA580C]/30 transition-colors"
>
<p className="text-sm font-medium truncate">{c.symbol ?? '—'}</p>
<p className="text-lg mt-0.5">
{c.price != null ? `$${Number(c.price).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 4 })}` : '—'}
</p>
<p className={cn(
'text-xs mt-0.5',
(c.changesPercentage ?? c.change ?? 0) > 0 ? 'text-green-600' : 'text-red-600'
)}>
{(c.changesPercentage ?? c.change ?? 0) !== 0
? `${(c.changesPercentage ?? c.change ?? 0) > 0 ? '+' : ''}${(c.changesPercentage ?? c.change ?? 0).toFixed(2)}%`
: '—'}
</p>
</Link>
))}
</div>
) : (
<p className="text-black/60 dark:text-white/60 text-sm">Set FMP_API_KEY for crypto data.</p>
)}
</div>
)}
{tab === 'movers' && (
<div className="mt-6 grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h3 className="text-lg font-medium mb-3 text-green-600">Top Gainers</h3>
{gainers.length > 0 ? (
<div className="space-y-2">
{gainers.slice(0, 8).map((g, i) => (
<Link
key={g.symbol ?? i}
href={`/finance/${encodeURIComponent(g.symbol ?? '')}`}
className="flex justify-between items-center rounded-lg p-2 hover:bg-light-secondary dark:hover:bg-dark-secondary"
>
<span className="font-medium truncate max-w-[120px]">{g.symbol ?? '—'}</span>
<span className="text-green-600 text-sm">
+{((g.changesPercentage ?? g.change) ?? 0).toFixed(2)}%
</span>
</Link>
))}
</div>
) : (
<p className="text-black/60 dark:text-white/60 text-sm">FMP_API_KEY required.</p>
)}
</div>
<div>
<h3 className="text-lg font-medium mb-3 text-red-600">Top Losers</h3>
{losers.length > 0 ? (
<div className="space-y-2">
{losers.slice(0, 8).map((l, i) => (
<Link
key={l.symbol ?? i}
href={`/finance/${encodeURIComponent(l.symbol ?? '')}`}
className="flex justify-between items-center rounded-lg p-2 hover:bg-light-secondary dark:hover:bg-dark-secondary"
>
<span className="font-medium truncate max-w-[120px]">{l.symbol ?? '—'}</span>
<span className="text-red-600 text-sm">
{((l.changesPercentage ?? l.change) ?? 0).toFixed(2)}%
</span>
</Link>
))}
</div>
) : (
<p className="text-black/60 dark:text-white/60 text-sm">FMP_API_KEY required.</p>
)}
</div>
</div>
)}
{tab === 'watchlist' && (
<div className="mt-6">
<h2 className="text-xl font-medium mb-4">My Watchlist</h2>
<p className="text-sm text-black/60 dark:text-white/60 mb-4">
Add tickers from search or ticker pages. Stored locally.
</p>
{watchlist.length > 0 ? (
<div className="flex flex-wrap gap-2">
{watchlist.map((ticker) => (
<Link
key={ticker}
href={`/finance/${ticker}`}
className="px-4 py-2 rounded-xl bg-light-secondary dark:bg-dark-secondary border border-light-200/20 dark:border-dark-200/20 hover:border-[#EA580C]/30"
>
{ticker}
</Link>
))}
</div>
) : (
<p className="text-black/60 dark:text-white/60">No tickers in watchlist yet.</p>
)}
</div>
)}
</>
)}
{!summary?.indices?.length && tab === 'overview' && (
<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>Market data will appear here when finance-svc is running with FMP_API_KEY.</p>
<p className="text-sm mt-2">Enable finance-svc in deploy.config.yaml</p>
</div>
)}
</div>
);
};
export default Page;

View File

@@ -0,0 +1,103 @@
'use client';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useEffect, useState } from 'react';
import { TrendingUp } from 'lucide-react';
interface Prediction {
id: string;
question: string;
outcomes: { name: string; probability?: number }[];
volume?: number;
endDate?: string | null;
_stub?: boolean;
}
export default function PredictionPage() {
const params = useParams();
const id = params?.id as string;
const [data, setData] = useState<Prediction | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!id) return;
const fetchData = async () => {
try {
const res = await fetch(`/api/v1/finance/predictions/${id}`);
if (res.ok) {
const json = await res.json();
setData(json);
}
} catch {
setData(null);
} finally {
setLoading(false);
}
};
fetchData();
}, [id]);
if (!id) {
return (
<div className="flex flex-col pt-10 pb-28 px-4 max-w-4xl mx-auto">
<p className="text-black/60 dark:text-white/60">Invalid prediction ID</p>
<Link href="/finance" className="mt-4 text-[#7C3AED] hover:underline">
Back to Finance
</Link>
</div>
);
}
if (loading || !data) {
return (
<div className="flex flex-col pt-10 pb-28 px-4 max-w-4xl mx-auto">
<div className="animate-pulse h-8 bg-light-200 dark:bg-dark-200 rounded w-48 mb-4" />
<div className="animate-pulse h-4 bg-light-200 dark:bg-dark-200 rounded w-full max-w-md" />
</div>
);
}
return (
<div className="flex flex-col pt-10 pb-28 px-4 max-w-4xl mx-auto">
<div className="flex items-center gap-3 border-b border-light-200/20 dark:border-dark-200/20 pb-6">
<TrendingUp size={32} className="text-[#7C3AED]" />
<h1 className="text-3xl font-normal">Prediction Market</h1>
</div>
{data._stub ? (
<div className="mt-6 p-6 rounded-2xl bg-light-secondary dark:bg-dark-secondary">
<p className="text-black/60 dark:text-white/60">
Polymarket prediction markets integration coming soon. Use the search to
explore prediction markets.
</p>
</div>
) : (
<div className="mt-6">
<h2 className="text-xl font-medium">{data.question}</h2>
{data.outcomes?.length > 0 && (
<ul className="mt-4 space-y-2">
{data.outcomes.map((o, i) => (
<li
key={i}
className="flex justify-between py-2 px-4 rounded-lg bg-light-secondary dark:bg-dark-secondary"
>
<span>{o.name}</span>
{o.probability != null && (
<span>{(o.probability * 100).toFixed(0)}%</span>
)}
</li>
))}
</ul>
)}
</div>
)}
<div className="mt-8">
<Link href="/finance" className="text-sm text-[#7C3AED] hover:underline">
Back to Finance
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,98 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
.overflow-hidden-scrollable {
-ms-overflow-style: none;
}
.overflow-hidden-scrollable::-webkit-scrollbar {
display: none;
}
* {
scrollbar-width: thin;
scrollbar-color: #e8edf1 transparent; /* light-200 */
}
*::-webkit-scrollbar {
width: 6px;
height: 6px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background: #e8edf1; /* light-200 */
border-radius: 3px;
transition: background 0.2s ease;
}
*::-webkit-scrollbar-thumb:hover {
background: #d0d7de; /* light-300 */
}
@media (prefers-color-scheme: dark) {
* {
scrollbar-color: #21262d transparent; /* dark-200 */
}
*::-webkit-scrollbar-thumb {
background: #21262d; /* dark-200 */
}
*::-webkit-scrollbar-thumb:hover {
background: #30363d; /* dark-300 */
}
}
:root.dark *,
html.dark *,
body.dark * {
scrollbar-color: #21262d transparent; /* dark-200 */
}
:root.dark *::-webkit-scrollbar-thumb,
html.dark *::-webkit-scrollbar-thumb,
body.dark *::-webkit-scrollbar-thumb {
background: #21262d; /* dark-200 */
}
:root.dark *::-webkit-scrollbar-thumb:hover,
html.dark *::-webkit-scrollbar-thumb:hover,
body.dark *::-webkit-scrollbar-thumb:hover {
background: #30363d; /* dark-300 */
}
html {
scroll-behavior: smooth;
}
}
@layer utilities {
.line-clamp-2 {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
overflow: hidden;
}
.text-fade {
overflow: hidden;
white-space: nowrap;
mask-image: linear-gradient(to right, black 75%, transparent 100%);
-webkit-mask-image: linear-gradient(to right, black 75%, transparent 100%);
}
}
@media screen and (-webkit-min-device-pixel-ratio: 0) {
select,
textarea,
input {
font-size: 16px !important;
}
}

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#EA580C"/>
<text x="16" y="22" font-family="Arial" font-size="18" font-weight="bold" fill="white" text-anchor="middle">G</text>
</svg>

After

Width:  |  Height:  |  Size: 242 B

View File

@@ -0,0 +1,96 @@
export const dynamic = 'force-dynamic';
import type { Metadata, Viewport } from 'next';
import { Roboto } from 'next/font/google';
import './globals.css';
import { cn } from '@/lib/utils';
import Sidebar from '@/components/Sidebar';
import { Toaster } from 'sonner';
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';
const roboto = Roboto({
weight: ['300', '400', '500', '700'],
subsets: ['latin'],
display: 'swap',
fallback: ['Arial', 'sans-serif'],
});
const APP_NAME = 'GooSeek';
const APP_DEFAULT_TITLE = 'GooSeek - Chat with the internet';
const APP_DESCRIPTION =
'GooSeek is an AI powered chatbot that is connected to the internet.';
export const metadata: Metadata = {
applicationName: APP_NAME,
title: {
default: APP_DEFAULT_TITLE,
template: `%s - ${APP_NAME}`,
},
description: APP_DESCRIPTION,
manifest: '/manifest.webmanifest',
appleWebApp: {
capable: true,
statusBarStyle: 'default',
title: APP_DEFAULT_TITLE,
},
formatDetection: {
telephone: false,
},
openGraph: {
type: 'website',
siteName: APP_NAME,
title: { default: APP_DEFAULT_TITLE, template: `%s - ${APP_NAME}` },
description: APP_DESCRIPTION,
},
twitter: {
card: 'summary',
title: { default: APP_DEFAULT_TITLE, template: `%s - ${APP_NAME}` },
description: APP_DESCRIPTION,
},
};
export const viewport: Viewport = {
themeColor: '#0a0a0a',
};
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html className="h-full" lang="ru" suppressHydrationWarning>
<body className={cn('h-full antialiased', roboto.className)}>
<ThemeProvider>
<LocalizationProvider>
<ClientOnly
fallback={
<div className="min-h-screen bg-light-primary dark:bg-dark-primary" />
}
>
<ChatProvider>
<Sidebar>{children}</Sidebar>
<GuestWarningBanner />
<GuestMigration />
<Toaster
toastOptions={{
unstyled: true,
classNames: {
toast:
'bg-light-secondary dark:bg-dark-secondary dark:text-white/70 text-black/70 rounded-lg p-4 flex flex-row items-center space-x-2',
},
}}
/>
</ChatProvider>
</ClientOnly>
</LocalizationProvider>
</ThemeProvider>
</body>
</html>
);
}

View File

@@ -0,0 +1,12 @@
import { Metadata } from 'next';
import React from 'react';
export const metadata: Metadata = {
title: 'History - GooSeek',
};
const Layout = ({ children }: { children: React.ReactNode }) => {
return <div>{children}</div>;
};
export default Layout;

View File

@@ -0,0 +1,309 @@
'use client';
import DeleteChat from '@/components/DeleteChat';
import { formatTimeDifference } from '@/lib/utils';
import {
ClockIcon,
FileText,
Globe2Icon,
MessagesSquare,
Search,
Filter,
} from 'lucide-react';
import Link from 'next/link';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from '@/lib/localization/context';
export interface Chat {
id: string;
title: string;
createdAt: string;
sources: string[];
files: { fileId: string; name: string }[];
}
const SOURCE_KEYS = ['web', 'academic', 'discussions'] as const;
type SourceKey = (typeof SOURCE_KEYS)[number];
type FilesFilter = 'all' | 'with' | 'without';
const Page = () => {
const { t } = useTranslation();
const [chats, setChats] = useState<Chat[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [sourceFilter, setSourceFilter] = useState<SourceKey | 'all'>('all');
const [filesFilter, setFilesFilter] = useState<FilesFilter>('all');
useEffect(() => {
const fetchChats = async () => {
setLoading(true);
const token =
typeof window !== 'undefined'
? localStorage.getItem('auth_token') ?? localStorage.getItem('access_token')
: null;
try {
if (token) {
const res = await fetch('/api/v1/library/threads', {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
const data = await res.json();
setChats(data.chats ?? []);
setLoading(false);
return;
}
}
const { getGuestChats } = await import('@/lib/guest-storage');
const guestChats = getGuestChats();
setChats(
guestChats.map((c) => ({
id: c.id,
title: c.title,
createdAt: c.createdAt,
sources: c.sources,
files: c.files,
})),
);
} catch {
setChats([]);
} finally {
setLoading(false);
}
};
fetchChats();
}, []);
useEffect(() => {
const onMigrated = () => {
const token =
typeof window !== 'undefined'
? localStorage.getItem('auth_token') ?? localStorage.getItem('access_token')
: null;
if (token) {
fetch('/api/v1/library/threads', {
headers: { Authorization: `Bearer ${token}` },
})
.then((r) => r.json())
.then((data) => setChats(data.chats ?? []))
.catch(() => setChats([]));
}
};
window.addEventListener('gooseek:guest-migrated', onMigrated);
return () => window.removeEventListener('gooseek:guest-migrated', onMigrated);
}, []);
const filteredChats = useMemo(() => {
return chats.filter((chat) => {
const matchesSearch =
!searchQuery.trim() ||
chat.title.toLowerCase().includes(searchQuery.trim().toLowerCase());
const matchesSource =
sourceFilter === 'all' ||
chat.sources.some((s) => s === sourceFilter);
const matchesFiles =
filesFilter === 'all' ||
(filesFilter === 'with' && chat.files.length > 0) ||
(filesFilter === 'without' && chat.files.length === 0);
return matchesSearch && matchesSource && matchesFiles;
});
}, [chats, searchQuery, sourceFilter, filesFilter]);
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-end lg:justify-between gap-3">
<div className="flex items-center justify-center">
<MessagesSquare size={45} className="mb-2.5" />
<div className="flex flex-col">
<h1 className="text-5xl font-normal p-2 pb-0">
{t('nav.messageHistory')}
</h1>
<div className="px-2 text-sm text-black/60 dark:text-white/60 text-center lg:text-left">
Past chats, sources, and uploads.
</div>
</div>
</div>
<div className="flex items-center justify-center lg:justify-end gap-2 text-xs text-black/60 dark:text-white/60">
<span className="inline-flex items-center gap-1 rounded-full border border-black/20 dark:border-white/20 px-2 py-0.5">
<MessagesSquare size={14} />
{loading && chats.length === 0
? '—'
: `${chats.length} ${chats.length === 1 ? 'chat' : 'chats'}`}
</span>
</div>
</div>
</div>
{(chats.length > 0 || loading) && (
<div className="sticky top-0 z-10 flex flex-row flex-wrap items-center gap-2 pt-4 pb-2 px-2 bg-light-primary dark:bg-dark-primary border-b border-light-200/20 dark:border-dark-200/20">
<div className="relative flex-1 min-w-[140px]">
<Search
size={18}
className="absolute left-3 top-1/2 -translate-y-1/2 text-black/40 dark:text-white/40"
/>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('app.searchPlaceholder')}
className="w-full pl-10 pr-4 py-2.5 text-sm bg-light-secondary dark:bg-dark-secondary border border-light-200 dark:border-dark-200 rounded-xl placeholder:text-black/40 dark:placeholder:text-white/40 text-black dark:text-white focus:outline-none focus:border-light-300 dark:focus:border-dark-300 transition-colors"
/>
</div>
<div className="flex flex-wrap items-center gap-2 shrink-0">
<Filter size={16} className="text-black/50 dark:text-white/50" />
<select
value={sourceFilter}
onChange={(e) =>
setSourceFilter(e.target.value as SourceKey | 'all')
}
className="text-xs py-1.5 px-3 rounded-lg bg-light-secondary dark:bg-dark-secondary border border-light-200 dark:border-dark-200 text-black dark:text-white focus:outline-none focus:ring-2 focus:ring-[#EA580C]/30"
>
<option value="all">{t('library.filterSourceAll')}</option>
<option value="web">{t('library.filterSourceWeb')}</option>
<option value="academic">{t('library.filterSourceAcademic')}</option>
<option value="discussions">{t('library.filterSourceSocial')}</option>
</select>
<select
value={filesFilter}
onChange={(e) => setFilesFilter(e.target.value as FilesFilter)}
className="text-xs py-1.5 px-3 rounded-lg bg-light-secondary dark:bg-dark-secondary border border-light-200 dark:border-dark-200 text-black dark:text-white focus:outline-none focus:ring-2 focus:ring-[#EA580C]/30"
>
<option value="all">{t('library.filterFilesAll')}</option>
<option value="with">{t('library.filterFilesWith')}</option>
<option value="without">{t('library.filterFilesWithout')}</option>
</select>
</div>
</div>
)}
{loading && chats.length === 0 ? (
<div className="pt-6 pb-28 px-2">
<div className="rounded-2xl border border-light-200 dark:border-dark-200 overflow-hidden bg-light-primary dark:bg-dark-primary">
{[1, 2, 3, 4].map((i) => (
<div
key={i}
className="flex flex-col gap-2 p-4 border-b border-light-200 dark:border-dark-200 last:border-b-0"
>
<div className="h-5 w-3/4 rounded bg-light-200 dark:bg-dark-200 animate-pulse" />
<div className="flex gap-2 mt-2">
<div className="h-4 w-16 rounded bg-light-200 dark:bg-dark-200 animate-pulse" />
<div className="h-4 w-24 rounded bg-light-200 dark:bg-dark-200 animate-pulse" />
</div>
</div>
))}
</div>
</div>
) : chats.length === 0 ? (
<div className="flex flex-col items-center justify-center min-h-[70vh] px-2 text-center">
<div className="flex items-center justify-center w-12 h-12 rounded-2xl border border-light-200 dark:border-dark-200 bg-light-secondary dark:bg-dark-secondary">
<MessagesSquare className="text-black/70 dark:text-white/70" />
</div>
<p className="mt-2 text-black/70 dark:text-white/70 text-sm">
No chats found.
</p>
<p className="mt-1 text-black/70 dark:text-white/70 text-sm">
<Link href="/" className="text-[#EA580C]">
Start a new chat
</Link>{' '}
to see it listed here.
</p>
</div>
) : filteredChats.length === 0 ? (
<div className="flex flex-col items-center justify-center min-h-[50vh] px-2 text-center">
<p className="text-black/70 dark:text-white/70 text-sm">
{t('library.noResults')}
</p>
<button
type="button"
onClick={() => {
setSearchQuery('');
setSourceFilter('all');
setFilesFilter('all');
}}
className="mt-2 text-sm text-[#EA580C] hover:underline"
>
{t('library.clearFilters')}
</button>
</div>
) : (
<div className="pt-6 pb-28 px-2">
<div className="rounded-2xl border border-light-200 dark:border-dark-200 overflow-hidden bg-light-primary dark:bg-dark-primary">
{filteredChats.map((chat, index) => {
const sourcesLabel =
chat.sources.length === 0
? null
: chat.sources.length <= 2
? chat.sources
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
.join(', ')
: `${chat.sources
.slice(0, 2)
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
.join(', ')} + ${chat.sources.length - 2}`;
return (
<div
key={chat.id}
className={
'group flex flex-col gap-2 p-4 hover:bg-light-secondary dark:hover:bg-dark-secondary transition-colors duration-200 ' +
(index !== chats.length - 1
? 'border-b border-light-200 dark:border-dark-200'
: '')
}
>
<div className="flex items-start justify-between gap-3">
<Link
href={`/c/${chat.id}`}
className="flex-1 text-black dark:text-white text-base lg:text-lg font-medium leading-snug line-clamp-2 group-hover:text-[#EA580C] transition duration-200"
title={chat.title}
>
{chat.title}
</Link>
<div className="pt-0.5 shrink-0">
<DeleteChat
chatId={chat.id}
chats={chats}
setChats={setChats}
/>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 text-black/70 dark:text-white/70">
<span className="inline-flex items-center gap-1 text-xs">
<ClockIcon size={14} />
{formatTimeDifference(new Date(), chat.createdAt)} Ago
</span>
{sourcesLabel && (
<span className="inline-flex items-center gap-1 text-xs border border-black/20 dark:border-white/20 rounded-full px-2 py-0.5">
<Globe2Icon size={14} />
{sourcesLabel}
</span>
)}
{chat.files.length > 0 && (
<span className="inline-flex items-center gap-1 text-xs border border-black/20 dark:border-white/20 rounded-full px-2 py-0.5">
<FileText size={14} />
{chat.files.length}{' '}
{chat.files.length === 1 ? 'file' : 'files'}
</span>
)}
</div>
</div>
);
})}
</div>
</div>
)}
</div>
);
};
export default Page;

View File

@@ -0,0 +1,54 @@
import type { MetadataRoute } from 'next';
export default function manifest(): MetadataRoute.Manifest {
return {
name: 'GooSeek - Chat with the internet',
short_name: 'GooSeek',
description:
'GooSeek is an AI powered chatbot that is connected to the internet.',
start_url: '/',
display: 'standalone',
background_color: '#0a0a0a',
theme_color: '#0a0a0a',
screenshots: [
{
src: '/screenshots/p1.png',
form_factor: 'wide',
sizes: '2560x1600',
},
{
src: '/screenshots/p2.png',
form_factor: 'wide',
sizes: '2560x1600',
},
{
src: '/screenshots/p1_small.png',
form_factor: 'narrow',
sizes: '828x1792',
},
{
src: '/screenshots/p2_small.png',
form_factor: 'narrow',
sizes: '828x1792',
},
],
icons: [
{
src: '/icon-50.png',
sizes: '50x50',
type: 'image/png' as const,
},
{
src: '/icon-100.png',
sizes: '100x100',
type: 'image/png',
},
{
src: '/icon.png',
sizes: '440x440',
type: 'image/png',
purpose: 'any',
},
],
};
}

View File

@@ -0,0 +1,29 @@
/**
* Offline fallback страница для PWA
* docs/architecture: 05-gaps-and-best-practices.md §8, 06-roadmap-specification §1.7
*/
import Link from 'next/link';
export const dynamic = 'force-static';
export default function OfflinePage() {
return (
<div className="min-h-screen bg-light-primary dark:bg-dark-primary flex flex-col items-center justify-center p-6">
<div className="text-center max-w-md">
<h1 className="text-2xl font-semibold text-black dark:text-white mb-2">
You&apos;re offline
</h1>
<p className="text-black/70 dark:text-white/70 mb-6">
Check your connection and try again. Some features require an internet connection.
</p>
<Link
href="/"
className="inline-flex items-center justify-center rounded-lg bg-light-accent dark:bg-dark-accent text-white px-4 py-2 font-medium hover:opacity-90 transition-opacity"
>
Retry
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,13 @@
import ChatWindow from '@/components/ChatWindow';
import { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Chat - GooSeek',
description: 'Chat with the internet, chat with GooSeek.',
};
const Home = () => {
return <ChatWindow />;
};
export default Home;

View File

@@ -0,0 +1,726 @@
'use client';
import {
User,
CreditCard,
Sliders,
ToggleRight,
Link2,
ChevronLeft,
Check,
LogOut,
} from 'lucide-react';
import Link from 'next/link';
import { useSearchParams, useRouter } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from '@/lib/localization/context';
import { cn } from '@/lib/utils';
import SettingsButton from '@/components/Settings/SettingsButton';
import { authClient, clearStoredAuthToken } from '@/lib/auth-client';
function SignOutButton() {
const router = useRouter();
const [loading, setLoading] = useState(false);
const handleSignOut = async () => {
setLoading(true);
clearStoredAuthToken();
await authClient.signOut();
router.push('/');
router.refresh();
setLoading(false);
};
return (
<button
type="button"
onClick={handleSignOut}
disabled={loading}
className="flex items-center gap-2 rounded-lg border border-light-200 dark:border-dark-200 px-3 py-2 text-sm text-black/70 dark:text-white/70 hover:bg-light-200 dark:hover:bg-dark-200 disabled:opacity-50"
>
<LogOut size={16} />
Выйти
</button>
);
}
interface BillingPlan {
id: string;
name: string;
priceMonthly: number;
priceYearly: number;
currency: string;
features: string[];
}
interface Subscription {
planId: string;
status: string;
period: string;
}
interface ConnectorItem {
id: string;
name: string;
description: string;
status: string;
}
interface MemoryItem {
id: string;
key: string;
value: string;
createdAt?: string;
}
function PersonalizeSection({
token,
t,
}: {
token: string | null;
t: (k: string) => string;
}) {
const [memories, setMemories] = useState<MemoryItem[]>([]);
const [loading, setLoading] = useState(true);
const [newKey, setNewKey] = useState('');
const [newValue, setNewValue] = useState('');
const [adding, setAdding] = useState(false);
const fetchMemories = useCallback(() => {
if (!token) {
setMemories([]);
setLoading(false);
return;
}
fetch('/api/v1/memory', {
headers: { Authorization: `Bearer ${token}` },
})
.then((r) => r.json())
.then((d) => setMemories(d.items ?? []))
.catch(() => setMemories([]))
.finally(() => setLoading(false));
}, [token]);
useEffect(() => {
setLoading(true);
fetchMemories();
}, [fetchMemories]);
const handleDelete = async (id: string) => {
if (!token) return;
const res = await fetch(`/api/v1/memory/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) fetchMemories();
};
const handleAdd = async (e: React.FormEvent) => {
e.preventDefault();
if (!token || !newKey.trim() || !newValue.trim()) return;
setAdding(true);
const res = await fetch('/api/v1/memory', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ key: newKey.trim(), value: newValue.trim() }),
});
setAdding(false);
if (res.ok) {
setNewKey('');
setNewValue('');
fetchMemories();
}
};
return (
<section className="space-y-4">
<h2 className="text-lg font-medium dark:text-white">{t('profile.personalize')}</h2>
<p className="text-sm text-black/60 dark:text-white/60">
{t('profile.personalizeDesc')}
</p>
{!token ? (
<div className="rounded-xl border border-light-200 dark:border-dark-200 bg-light-secondary dark:bg-dark-secondary p-6 space-y-3">
<p className="text-black/60 dark:text-white/60">{t('profile.signInToView')}</p>
<Link
href="/sign-in"
className="inline-flex items-center rounded-lg bg-[#EA580C] px-4 py-2 text-sm font-medium text-white hover:bg-[#EA580C]/90"
>
Войти
</Link>
</div>
) : loading ? (
<div className="rounded-xl border border-light-200 dark:border-dark-200 bg-light-secondary dark:bg-dark-secondary p-6 animate-pulse">
<div className="h-4 w-48 bg-light-200 dark:bg-dark-200 rounded" />
</div>
) : (
<div className="rounded-xl border border-light-200 dark:border-dark-200 bg-light-secondary dark:bg-dark-secondary p-6">
<h3 className="text-sm font-medium text-black/80 dark:text-white/80 mb-2">
AI Memory (Pro)
</h3>
<form onSubmit={handleAdd} className="mb-4 flex flex-wrap gap-2">
<input
type="text"
placeholder="Key (e.g. favorite_tech)"
value={newKey}
onChange={(e) => setNewKey(e.target.value)}
className="rounded-lg border border-light-200 dark:border-dark-200 bg-light-primary dark:bg-dark-primary px-3 py-1.5 text-sm min-w-[120px]"
/>
<input
type="text"
placeholder="Value"
value={newValue}
onChange={(e) => setNewValue(e.target.value)}
className="rounded-lg border border-light-200 dark:border-dark-200 bg-light-primary dark:bg-dark-primary px-3 py-1.5 text-sm min-w-[160px] flex-1"
/>
<button
type="submit"
disabled={adding || !newKey.trim() || !newValue.trim()}
className="rounded-lg bg-[#EA580C] px-3 py-1.5 text-sm text-white hover:bg-[#EA580C]/90 disabled:opacity-50"
>
Add
</button>
</form>
{memories.length > 0 ? (
<ul className="space-y-2">
{memories.map((m) => (
<li
key={m.id}
className="flex items-start justify-between gap-2 rounded-lg p-2 bg-light-primary dark:bg-dark-primary border border-light-200/50 dark:border-dark-200/50"
>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{m.key}</p>
<p className="text-xs text-black/60 dark:text-white/60 truncate">{m.value}</p>
</div>
<button
type="button"
onClick={() => handleDelete(m.id)}
className="px-2 py-1 text-xs text-red-600 hover:bg-red-500/10 rounded"
>
Delete
</button>
</li>
))}
</ul>
) : (
<p className="text-black/60 dark:text-white/60 text-sm">
No memory items yet. Add above or use Pro search (balanced/quality) with an account.
</p>
)}
</div>
)}
</section>
);
}
function ConnectorsSection({
t,
}: {
t: (k: string) => string;
}) {
const [connectors, setConnectors] = useState<{
available: ConnectorItem[];
connected: ConnectorItem[];
} | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch('/api/v1/connectors')
.then((r) => r.json())
.then((d) => setConnectors({ available: d.available ?? [], connected: d.connected ?? [] }))
.catch(() => setConnectors({ available: [], connected: [] }))
.finally(() => setLoading(false));
}, []);
const available = connectors?.available ?? [];
const connected = connectors?.connected ?? [];
return (
<section className="space-y-4">
<h2 className="text-lg font-medium dark:text-white">{t('profile.connectors')}</h2>
<p className="text-sm text-black/60 dark:text-white/60">
{t('profile.connectorsDesc')}
</p>
{loading ? (
<div className="rounded-xl border border-light-200 dark:border-dark-200 bg-light-secondary dark:bg-dark-secondary p-6 animate-pulse">
<div className="h-4 w-48 bg-light-200 dark:bg-dark-200 rounded" />
</div>
) : available.length > 0 ? (
<div className="space-y-3">
{available.map((c) => {
const isConnected = connected.some((x) => x.id === c.id);
const isComingSoon = c.status === 'coming_soon';
return (
<div
key={c.id}
className="flex items-center justify-between rounded-xl border border-light-200 dark:border-dark-200 bg-light-secondary dark:bg-dark-secondary p-4"
>
<div>
<h3 className="font-medium dark:text-white">{c.name}</h3>
<p className="text-sm text-black/60 dark:text-white/60 mt-0.5">{c.description}</p>
</div>
<button
type="button"
disabled={isComingSoon}
className={cn(
'px-3 py-1.5 rounded-lg text-sm font-medium',
isComingSoon &&
'bg-light-200 dark:bg-dark-200 text-black/50 dark:text-white/50 cursor-not-allowed',
isConnected && !isComingSoon &&
'bg-green-500/20 text-green-700 dark:text-green-400',
!isConnected && !isComingSoon &&
'bg-[#EA580C] text-white hover:bg-[#EA580C]/90'
)}
>
{isComingSoon ? t('profile.comingSoon') : isConnected ? t('profile.connected') ?? 'Connected' : t('profile.connect') ?? 'Connect'}
</button>
</div>
);
})}
</div>
) : (
<div className="rounded-xl border border-light-200 dark:border-dark-200 bg-light-secondary dark:bg-dark-secondary p-6">
<p className="text-black/60 dark:text-white/60">{t('profile.comingSoon')}</p>
</div>
)}
</section>
);
}
interface ProfileData {
userId: string;
displayName: string | null;
avatarUrl: string | null;
timezone: string | null;
locale: string | null;
profileData: Record<string, unknown>;
preferences: Record<string, unknown>;
personalization: Record<string, unknown>;
}
function AccountProfileContent({
session,
token,
SignOutButton,
}: {
session: { user?: { id: string; name?: string; email?: string; image?: string } };
token: string | null;
SignOutButton: React.ComponentType;
}) {
const [profile, setProfile] = useState<ProfileData | null>(null);
const [displayName, setDisplayName] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!token) return;
fetch('/api/v1/profile', { headers: { Authorization: `Bearer ${token}` } })
.then((r) => (r.ok ? r.json() : null))
.then((p) => {
if (p) {
setProfile(p);
setDisplayName(p.displayName ?? session.user?.name ?? '');
} else {
setDisplayName(session.user?.name ?? '');
}
})
.catch(() => setDisplayName(session.user?.name ?? ''));
}, [token, session.user?.name]);
const handleSaveProfile = async (e: React.FormEvent) => {
e.preventDefault();
if (!token) return;
setSaving(true);
try {
const res = await fetch('/api/v1/profile', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ displayName: displayName.trim() || null }),
});
if (res.ok) {
const data = await res.json();
setProfile(data);
}
} finally {
setSaving(false);
}
};
const avatarSrc = profile?.avatarUrl ?? session.user?.image ?? null;
return (
<div className="flex flex-col gap-6 sm:flex-row sm:items-start">
<div className="flex items-center gap-4">
{avatarSrc ? (
<img src={avatarSrc} alt="" className="h-16 w-16 rounded-full object-cover" />
) : (
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-light-200 dark:bg-dark-200">
<User size={28} className="text-black/50 dark:text-white/50" />
</div>
)}
<div className="flex-1">
<p className="font-medium text-black dark:text-white">
{profile?.displayName ?? session.user?.name ?? '—'}
</p>
<p className="text-sm text-black/60 dark:text-white/60">
{session.user?.email ?? '—'}
</p>
<p className="text-xs text-black/50 dark:text-white/50 mt-1">
ID: {session.user?.id}
</p>
</div>
<SignOutButton />
</div>
{token && (
<form onSubmit={handleSaveProfile} className="flex flex-col gap-3 sm:border-l sm:border-light-200 sm:dark:border-dark-200 sm:pl-6">
<label className="text-sm font-medium text-black dark:text-white">
Отображаемое имя
</label>
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder={session.user?.name ?? ''}
className="rounded-lg border border-light-200 dark:border-dark-200 bg-light-primary dark:bg-dark-primary px-3 py-2 text-sm text-black dark:text-white"
/>
<button
type="submit"
disabled={saving}
className="self-start rounded-lg bg-[#EA580C] px-4 py-2 text-sm font-medium text-white hover:bg-[#EA580C]/90 disabled:opacity-50"
>
{saving ? 'Сохранение…' : 'Сохранить'}
</button>
</form>
)}
</div>
);
}
const SECTIONS = [
{ key: 'account', labelKey: 'profile.account', icon: User },
{ key: 'preferences', labelKey: 'profile.preferences', icon: Sliders },
{ key: 'personalize', labelKey: 'profile.personalize', icon: ToggleRight },
{ key: 'billing', labelKey: 'profile.billing', icon: CreditCard },
{ key: 'connectors', labelKey: 'profile.connectors', icon: Link2 },
] as const;
export default function ProfilePage() {
const searchParams = useSearchParams();
const tab = (searchParams.get('tab') ?? 'account') as (typeof SECTIONS)[number]['key'];
const { t } = useTranslation();
const [session, setSession] = useState<{ user?: { id: string; name?: string; email?: string; image?: string } } | null>(null);
const [plans, setPlans] = useState<BillingPlan[]>([]);
const [subscription, setSubscription] = useState<Subscription | null>(null);
const [payments, setPayments] = useState<{ id: string; planId: string; amount: number; currency: string; status: string; createdAt: string }[]>([]);
const [loading, setLoading] = useState(true);
const [checkoutLoading, setCheckoutLoading] = useState<string | null>(null);
const token =
typeof window !== 'undefined'
? localStorage.getItem('auth_token') ?? localStorage.getItem('access_token')
: null;
useEffect(() => {
const fetchSession = async () => {
try {
const res = await fetch('/api/auth/get-session');
if (res.ok) {
const data = await res.json();
setSession(data?.data ?? null);
}
} catch {
setSession(null);
}
};
fetchSession();
}, []);
useEffect(() => {
const fetchPlans = async () => {
try {
const res = await fetch('/api/v1/billing/plans');
if (res.ok) {
const data = await res.json();
setPlans(Array.isArray(data) ? data : data.plans ?? []);
}
} catch {
setPlans([]);
}
};
fetchPlans();
}, []);
useEffect(() => {
if (!token) return;
const fetchSubscription = async () => {
try {
const res = await fetch('/api/v1/billing/subscription', {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
const data = await res.json();
setSubscription(data);
}
} catch {
setSubscription(null);
}
};
fetchSubscription();
}, [token]);
useEffect(() => {
if (!token) return;
const fetchPayments = async () => {
try {
const res = await fetch('/api/v1/billing/payments', {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
const data = await res.json();
setPayments(data.payments ?? []);
}
} catch {
setPayments([]);
}
};
fetchPayments();
}, [token]);
useEffect(() => {
setLoading(false);
}, [session, plans]);
const handleCheckout = async (planId: string, period: 'monthly' | 'yearly') => {
if (!token) return;
setCheckoutLoading(planId);
try {
const res = await fetch('/api/v1/billing/checkout', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ planId, period }),
});
const data = await res.json();
if (data.redirectUrl) {
window.location.href = data.redirectUrl;
} else if (res.ok) {
setSubscription({ planId, status: 'pending', period });
}
} catch {
// error handling
} finally {
setCheckoutLoading(null);
}
};
const formatPrice = (cents: number, currency: string) => {
const val = cents / 100;
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: currency === 'RUB' ? 'RUB' : 'USD',
minimumFractionDigits: 0,
}).format(val);
};
const currentPlanId = subscription?.planId ?? 'free';
return (
<div className="min-h-screen bg-light-primary dark:bg-dark-primary">
<div className="mx-auto max-w-4xl px-4 py-8">
<Link
href="/"
className="mb-6 inline-flex items-center gap-2 text-sm text-black/60 hover:text-black dark:text-white/60 hover:dark:text-white"
>
<ChevronLeft size={18} />
{t('nav.home')}
</Link>
<div className="flex flex-col gap-8 lg:flex-row">
<nav className="flex shrink-0 flex-col gap-1 lg:w-56">
{SECTIONS.map((s) => (
<Link
key={s.key}
href={`/profile?tab=${s.key}`}
className={cn(
'flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm transition-colors',
tab === s.key
? 'bg-light-200 text-black dark:bg-dark-200 dark:text-white'
: 'text-black/70 hover:bg-light-200/50 dark:text-white/70 hover:dark:bg-dark-200/50'
)}
>
<s.icon size={18} />
{t(s.labelKey)}
</Link>
))}
<div className="mt-4 border-t border-light-200 dark:border-dark-200 pt-4">
<div className="flex items-center gap-2">
<SettingsButton />
<span className="text-sm text-black/60 dark:text-white/60">
{t('profile.appSettings')}
</span>
</div>
</div>
</nav>
<main className="flex-1">
{tab === 'account' && (
<section className="space-y-4">
<h2 className="text-lg font-medium dark:text-white">{t('profile.account')}</h2>
<div className="rounded-xl border border-light-200 dark:border-dark-200 bg-light-secondary dark:bg-dark-secondary p-6">
{session?.user ? (
<AccountProfileContent
session={session}
token={token}
SignOutButton={SignOutButton}
/>
) : (
<div className="space-y-3">
<p className="text-black/60 dark:text-white/60">
{t('profile.signInToView')}
</p>
<Link
href="/sign-in"
className="inline-flex items-center rounded-lg bg-[#EA580C] px-4 py-2 text-sm font-medium text-white hover:bg-[#EA580C]/90"
>
Войти
</Link>
</div>
)}
</div>
</section>
)}
{tab === 'preferences' && (
<section className="space-y-4">
<h2 className="text-lg font-medium dark:text-white">{t('profile.preferences')}</h2>
<p className="text-sm text-black/60 dark:text-white/60">
{t('profile.preferencesDesc')}
</p>
<div className="rounded-xl border border-light-200 dark:border-dark-200 bg-light-secondary dark:bg-dark-secondary p-6">
<SettingsButton />
</div>
</section>
)}
{tab === 'personalize' && (
<PersonalizeSection token={token} t={t} />
)}
{tab === 'billing' && (
<section className="space-y-6">
<h2 className="text-lg font-medium dark:text-white">{t('profile.billing')}</h2>
{loading ? (
<div className="h-32 animate-pulse rounded-xl bg-light-200 dark:bg-dark-200" />
) : (
<>
<div className="grid gap-4 sm:grid-cols-3">
{plans.map((plan) => {
const isCurrent = currentPlanId === plan.id;
const isFree = plan.id === 'free';
return (
<div
key={plan.id}
className={cn(
'rounded-xl border p-6 transition-colors',
isCurrent
? 'border-black dark:border-white bg-light-200/50 dark:bg-dark-200/50'
: 'border-light-200 dark:border-dark-200 bg-light-secondary dark:bg-dark-secondary'
)}
>
<div className="flex items-center justify-between">
<h3 className="font-medium text-black dark:text-white">
{plan.name}
</h3>
{isCurrent && (
<span className="flex items-center gap-1 rounded-full bg-green-500/20 px-2 py-0.5 text-xs text-green-700 dark:text-green-400">
<Check size={12} />
{t('profile.current')}
</span>
)}
</div>
<div className="mt-2">
<span className="text-2xl font-semibold text-black dark:text-white">
{formatPrice(plan.priceMonthly, plan.currency)}
</span>
<span className="text-sm text-black/60 dark:text-white/60">
/{t('profile.month')}
</span>
</div>
<ul className="mt-4 space-y-1.5 text-sm text-black/80 dark:text-white/80">
{plan.features.slice(0, 4).map((f, i) => (
<li key={i} className="flex items-start gap-2">
<Check size={14} className="mt-0.5 shrink-0 text-green-600" />
{f}
</li>
))}
</ul>
{!isFree && !isCurrent && token && (
<button
onClick={() => handleCheckout(plan.id, 'monthly')}
disabled={!!checkoutLoading}
className="mt-4 w-full rounded-lg bg-black px-4 py-2 text-sm font-medium text-white hover:opacity-90 disabled:opacity-50 dark:bg-white dark:text-black"
>
{checkoutLoading === plan.id
? t('common.loading')
: t('profile.upgrade')}
</button>
)}
</div>
);
})}
</div>
{token && payments.length > 0 && (
<div className="rounded-xl border border-light-200 dark:border-dark-200 bg-light-secondary dark:bg-dark-secondary p-6">
<h3 className="mb-3 font-medium text-black dark:text-white">
{t('profile.paymentHistory')}
</h3>
<div className="space-y-2">
{payments.slice(0, 10).map((p) => (
<div
key={p.id}
className="flex flex-wrap items-center justify-between gap-2 rounded-lg bg-light-200/50 dark:bg-dark-200/50 px-3 py-2 text-sm"
>
<span className="text-black dark:text-white">
{p.planId} · {formatPrice(p.amount, p.currency)}
</span>
<span className="text-black/50 dark:text-white/50">
{new Date(p.createdAt).toLocaleDateString()}
</span>
<span
className={cn(
'rounded px-2 py-0.5 text-xs capitalize',
p.status === 'succeeded'
? 'bg-green-500/20 text-green-700 dark:text-green-400'
: p.status === 'pending'
? 'bg-amber-500/20 text-amber-700 dark:text-amber-400'
: 'bg-red-500/20 text-red-700 dark:text-red-400'
)}
>
{p.status}
</span>
</div>
))}
</div>
</div>
)}
</>
)}
</section>
)}
{tab === 'connectors' && (
<ConnectorsSection t={t} />
)}
</main>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,123 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { authClient } from '@/lib/auth-client';
import { useRouter } from 'next/navigation';
export default function SignInPage() {
const router = useRouter();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
const { data, error: err } = await authClient.signIn.email(
{
email,
password,
callbackURL: '/profile',
},
{
onSuccess: (ctx) => {
const token = (ctx as { response?: { headers?: { get?: (n: string) => string } } })?.response?.headers?.get?.('set-auth-token');
if (token && typeof window !== 'undefined') {
localStorage.setItem('auth_token', token);
}
},
}
);
setLoading(false);
if (err) {
setError(err.message ?? 'Неверный email или пароль');
return;
}
if (data) {
router.push('/profile');
router.refresh();
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-light-primary dark:bg-dark-primary px-4">
<div className="w-full max-w-sm space-y-6">
<div className="text-center">
<h1 className="text-2xl font-semibold text-black dark:text-white">
Вход в GooSeek
</h1>
<p className="mt-1 text-sm text-black/60 dark:text-white/60">
Войдите в аккаунт для доступа к профилю и памяти
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-black dark:text-white mb-1"
>
Email
</label>
<input
id="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full rounded-lg border border-light-200 dark:border-dark-200 bg-light-primary dark:bg-dark-primary px-3 py-2 text-black dark:text-white placeholder:text-black/40 dark:placeholder:text-white/40 focus:outline-none focus:ring-2 focus:ring-[#EA580C]"
placeholder="you@example.com"
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-black dark:text-white mb-1"
>
Пароль
</label>
<input
id="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded-lg border border-light-200 dark:border-dark-200 bg-light-primary dark:bg-dark-primary px-3 py-2 text-black dark:text-white placeholder:text-black/40 dark:placeholder:text-white/40 focus:outline-none focus:ring-2 focus:ring-[#EA580C]"
/>
</div>
{error && (
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
)}
<button
type="submit"
disabled={loading}
className="w-full rounded-lg bg-[#EA580C] px-4 py-2.5 text-white font-medium hover:bg-[#EA580C]/90 disabled:opacity-50 transition-colors"
>
{loading ? 'Вход...' : 'Войти'}
</button>
</form>
<p className="text-center text-sm text-black/60 dark:text-white/60">
Нет аккаунта?{' '}
<Link
href="/sign-up"
className="text-[#EA580C] hover:underline font-medium"
>
Зарегистрироваться
</Link>
</p>
<p className="text-center">
<Link
href="/"
className="text-sm text-black/60 dark:text-white/60 hover:underline"
>
На главную
</Link>
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,146 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { authClient } from '@/lib/auth-client';
import { useRouter } from 'next/navigation';
export default function SignUpPage() {
const router = useRouter();
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
const { data, error: err } = await authClient.signUp.email(
{
name,
email,
password,
callbackURL: '/profile',
},
{
onSuccess: (ctx) => {
const token = (ctx as { response?: { headers?: { get?: (n: string) => string } } })?.response?.headers?.get?.('set-auth-token');
if (token && typeof window !== 'undefined') {
localStorage.setItem('auth_token', token);
}
},
}
);
setLoading(false);
if (err) {
setError(err.message ?? 'Ошибка регистрации');
return;
}
if (data) {
router.push('/profile');
router.refresh();
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-light-primary dark:bg-dark-primary px-4">
<div className="w-full max-w-sm space-y-6">
<div className="text-center">
<h1 className="text-2xl font-semibold text-black dark:text-white">
Регистрация в GooSeek
</h1>
<p className="mt-1 text-sm text-black/60 dark:text-white/60">
Создайте аккаунт для сохранения истории и персонализации
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor="name"
className="block text-sm font-medium text-black dark:text-white mb-1"
>
Имя
</label>
<input
id="name"
type="text"
autoComplete="name"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full rounded-lg border border-light-200 dark:border-dark-200 bg-light-primary dark:bg-dark-primary px-3 py-2 text-black dark:text-white placeholder:text-black/40 dark:placeholder:text-white/40 focus:outline-none focus:ring-2 focus:ring-[#EA580C]"
placeholder="Иван"
/>
</div>
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-black dark:text-white mb-1"
>
Email
</label>
<input
id="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full rounded-lg border border-light-200 dark:border-dark-200 bg-light-primary dark:bg-dark-primary px-3 py-2 text-black dark:text-white placeholder:text-black/40 dark:placeholder:text-white/40 focus:outline-none focus:ring-2 focus:ring-[#EA580C]"
placeholder="you@example.com"
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-black dark:text-white mb-1"
>
Пароль
</label>
<input
id="password"
type="password"
autoComplete="new-password"
required
minLength={8}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded-lg border border-light-200 dark:border-dark-200 bg-light-primary dark:bg-dark-primary px-3 py-2 text-black dark:text-white placeholder:text-black/40 dark:placeholder:text-white/40 focus:outline-none focus:ring-2 focus:ring-[#EA580C]"
/>
<p className="mt-1 text-xs text-black/50 dark:text-white/50">
Минимум 8 символов
</p>
</div>
{error && (
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
)}
<button
type="submit"
disabled={loading}
className="w-full rounded-lg bg-[#EA580C] px-4 py-2.5 text-white font-medium hover:bg-[#EA580C]/90 disabled:opacity-50 transition-colors"
>
{loading ? 'Регистрация...' : 'Зарегистрироваться'}
</button>
</form>
<p className="text-center text-sm text-black/60 dark:text-white/60">
Уже есть аккаунт?{' '}
<Link
href="/sign-in"
className="text-[#EA580C] hover:underline font-medium"
>
Войти
</Link>
</p>
<p className="text-center">
<Link
href="/"
className="text-sm text-black/60 dark:text-white/60 hover:underline"
>
На главную
</Link>
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,93 @@
'use client';
import { FolderOpen } from 'lucide-react';
import Link from 'next/link';
import { useEffect, useState } from 'react';
import { toast } from 'sonner';
interface Collection {
id: string;
title: string;
category?: string;
description?: string;
}
const Page = () => {
const [collections, setCollections] = useState<Collection[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchCollections = 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 ?? []);
} catch {
toast.error('Failed to load collections');
} finally {
setLoading(false);
}
};
fetchCollections();
}, []);
return (
<div className="flex flex-col pt-10 pb-28 lg:pb-8 px-4 max-w-4xl mx-auto w-full">
<div className="flex items-center gap-3 border-b border-light-200/20 dark:border-dark-200/20 pb-6">
<FolderOpen size={40} className="text-[#7C3AED]" />
<h1 className="text-4xl font-normal">Spaces</h1>
</div>
<div className="mt-4 flex gap-4">
<Link
href="/spaces/templates"
className="text-sm text-[#7C3AED] hover:underline"
>
Space Templates
</Link>
</div>
<p className="mt-2 text-black/60 dark:text-white/60">
Popular collections for research and analysis.
</p>
{loading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-6">
{[1, 2, 3].map((i) => (
<div
key={i}
className="rounded-2xl bg-light-secondary dark:bg-dark-secondary p-4 animate-pulse h-24"
/>
))}
</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>
) : (
<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>
</div>
)}
</div>
);
};
export default Page;

View File

@@ -0,0 +1,93 @@
'use client';
import { LayoutTemplate } from 'lucide-react';
import Link from 'next/link';
import { useEffect, useState } from 'react';
import { toast } from 'sonner';
interface Template {
id: string;
title: string;
category?: string;
description?: string;
}
const Page = () => {
const [templates, setTemplates] = useState<Template[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchTemplates = async () => {
try {
const res = await fetch('/api/v1/templates');
if (!res.ok) throw new Error('Failed to fetch');
const data = await res.json();
setTemplates(data.items ?? []);
} catch {
toast.error('Failed to load templates');
} finally {
setLoading(false);
}
};
fetchTemplates();
}, []);
return (
<div className="flex flex-col pt-10 pb-28 lg:pb-8 px-4 max-w-4xl mx-auto w-full">
<div className="flex items-center gap-3 border-b border-light-200/20 dark:border-dark-200/20 pb-6">
<LayoutTemplate size={40} className="text-[#7C3AED]" />
<h1 className="text-4xl font-normal">Space Templates</h1>
</div>
<p className="mt-4 text-black/60 dark:text-white/60">
Ready-made Spaces for finance, marketing, product, and travel.
</p>
{loading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-6">
{[1, 2, 3, 4].map((i) => (
<div
key={i}
className="rounded-2xl bg-light-secondary dark:bg-dark-secondary p-4 animate-pulse h-24"
/>
))}
</div>
) : templates.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-6">
{templates.map((t) => (
<Link
key={t.id}
href={`/?template=${t.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">{t.title}</h2>
{t.description && (
<p className="text-sm text-black/60 dark:text-white/60 mt-1">{t.description}</p>
)}
{t.category && (
<span className="inline-block mt-2 text-xs px-2 py-0.5 rounded bg-light-200 dark:bg-dark-200">
{t.category}
</span>
)}
</Link>
))}
</div>
) : (
<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 templates available. Enable projects-svc in deploy.config.yaml</p>
</div>
)}
<div className="mt-8">
<Link
href="/spaces"
className="text-sm text-[#7C3AED] hover:underline"
>
Back to Spaces
</Link>
</div>
</div>
);
};
export default Page;

View File

@@ -0,0 +1,184 @@
'use client';
import { Plane, MapPin } from 'lucide-react';
import { useEffect, useState } from 'react';
import { toast } from 'sonner';
import Link from 'next/link';
import Image from 'next/image';
import { cn } from '@/lib/utils';
import DataFetchError from '@/components/DataFetchError';
import TravelStepper from '@/components/TravelStepper';
const FETCH_TIMEOUT_MS = 15000;
interface TrendItem {
id: string;
name: string;
image: string;
description?: string;
}
interface InspirationItem {
title: string;
summary: string;
image?: string;
url?: string;
}
const Page = () => {
const [trending, setTrending] = useState<{ items?: TrendItem[] } | null>(null);
const [inspiration, setInspiration] = useState<{ items?: InspirationItem[] } | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [stepperOpen, setStepperOpen] = useState(false);
const fetchData = async () => {
setLoading(true);
setError(null);
try {
const [trRes, inRes] = await Promise.all([
fetch('/api/v1/travel/trending', { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }),
fetch('/api/v1/travel/inspiration', { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }),
]);
const trData = await trRes.json().catch(() => ({}));
const inData = await inRes.json().catch(() => ({}));
setTrending(trData);
setInspiration(inData);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Error';
const isTimeout = msg.includes('timeout') || (err as Error)?.name === 'AbortError';
setError(isTimeout ? 'Request timed out. Try again.' : msg);
toast.error(msg);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, []);
const trendItems = trending?.items ?? [];
const inspItems = inspiration?.items ?? [];
return (
<div className="flex flex-col pt-10 pb-28 lg:pb-8 px-4 max-w-5xl mx-auto w-full">
<div className="flex items-center justify-between gap-3 border-b border-light-200/20 dark:border-dark-200/20 pb-6">
<div className="flex items-center gap-3">
<Plane size={40} className="text-[#EA580C]" />
<h1 className="text-4xl font-normal">Travel</h1>
</div>
<button
onClick={() => setStepperOpen(true)}
className={cn(
'flex items-center gap-2 px-4 py-2 rounded-xl font-medium',
'bg-[#EA580C] text-white hover:opacity-90 transition'
)}
>
<MapPin size={18} />
Plan a trip
</button>
</div>
{stepperOpen && <TravelStepper onClose={() => setStepperOpen(false)} />}
{error ? (
<DataFetchError message={error} onRetry={fetchData} />
) : loading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mt-6">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div
key={i}
className="rounded-2xl overflow-hidden bg-light-secondary dark:bg-dark-secondary animate-pulse"
>
<div className="aspect-video bg-light-200 dark:bg-dark-200" />
<div className="p-4">
<div className="h-5 w-3/4 bg-light-200 dark:bg-dark-200 rounded mb-2" />
<div className="h-4 w-1/2 bg-light-200 dark:bg-dark-200 rounded" />
</div>
</div>
))}
</div>
) : (
<>
{trendItems.length > 0 && (
<section className="mt-6">
<h2 className="text-xl font-medium mb-4">Trending Destinations</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{trendItems.map((item) => (
<Link
key={item.id}
href={`/?q=${encodeURIComponent(`travel to ${item.name}`)}&answerMode=travel`}
className="rounded-2xl overflow-hidden bg-light-secondary dark:bg-dark-secondary shadow-sm border border-light-200/20 dark:border-dark-200/20 hover:opacity-90 transition"
>
<div className="relative aspect-video">
<Image
src={item.image || 'https://placehold.co/400x225/e5e7eb/6b7280?text='}
alt={item.name}
fill
className="object-cover"
sizes="(max-width: 640px) 100vw, 33vw"
unoptimized={item.image?.includes('placehold')}
/>
</div>
<div className="p-4">
<h3 className="font-medium">{item.name}</h3>
{item.description && (
<p className="text-sm text-black/60 dark:text-white/60 mt-1 line-clamp-2">
{item.description}
</p>
)}
</div>
</Link>
))}
</div>
</section>
)}
{inspItems.length > 0 && (
<section className="mt-10">
<h2 className="text-xl font-medium mb-4">Inspiration</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{inspItems.map((item, i) => (
<div
key={i}
className={cn(
'rounded-2xl p-4 bg-light-secondary dark:bg-dark-secondary',
'border border-light-200/20 dark:border-dark-200/20'
)}
>
<h3 className="font-medium">{item.title}</h3>
{item.summary && (
<p className="text-sm text-black/60 dark:text-white/60 mt-2 line-clamp-2">
{item.summary}
</p>
)}
{item.url && (
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-[#EA580C] mt-2 inline-block hover:underline"
>
Read more
</a>
)}
</div>
))}
</div>
</section>
)}
{!loading && trendItems.length === 0 && inspItems.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>Travel content will appear here when travel-svc is running.</p>
<p className="text-sm mt-2">Enable travel-svc in deploy.config.yaml</p>
</div>
)}
</>
)}
</div>
);
};
export default Page;

View File

@@ -0,0 +1,282 @@
'use client';
import {
Brain,
Search,
FileText,
ChevronDown,
ChevronUp,
BookSearch,
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { useEffect, useState } 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" />;
}
return null;
};
const getStepTitle = (
step: ResearchBlockSubStep,
isStreaming: boolean,
t: (key: string) => string,
): string => {
if (step.type === 'reasoning') {
return isStreaming && !step.reasoning ? t('chat.brainstorming') : t('chat.thinking');
} else if (step.type === 'searching') {
const n = step.searching.length;
return t('chat.searchingQueries').replace('{count}', String(n)).replace('{plural}', n === 1 ? t('chat.query') : t('chat.queries'));
} else if (step.type === 'search_results') {
const n = step.reading.length;
return t('chat.foundResults').replace('{count}', String(n)).replace('{plural}', n === 1 ? t('chat.result') : t('chat.results'));
} else if (step.type === 'reading') {
const n = step.reading.length;
return t('chat.readingSources').replace('{count}', String(n)).replace('{plural}', n === 1 ? t('chat.source') : t('chat.sources'));
} else if (step.type === 'upload_searching') {
return t('chat.scanningDocs');
} else if (step.type === 'upload_search_results') {
const n = step.results.length;
return t('chat.readingDocs').replace('{count}', String(n)).replace('{plural}', n === 1 ? t('chat.document') : t('chat.documents'));
}
return t('chat.processing');
};
const AssistantSteps = ({
block,
status,
isLast,
}: {
block: ResearchBlock;
status: 'answering' | 'completed' | 'error';
isLast: boolean;
}) => {
const { t } = useTranslation();
const [isExpanded, setIsExpanded] = useState(
isLast && status === 'answering' ? true : false,
);
const { researchEnded, loading } = useChat();
useEffect(() => {
if (researchEnded && isLast) {
setIsExpanded(false);
} else if (status === 'answering' && isLast) {
setIsExpanded(true);
}
}, [researchEnded, status]);
if (!block || block.data.subSteps.length === 0) return null;
return (
<div className="rounded-lg bg-light-secondary dark:bg-dark-secondary border border-light-200 dark:border-dark-200 overflow-hidden">
<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"
>
<div className="flex items-center gap-2">
<Brain className="w-4 h-4 text-black dark:text-white" />
<span className="text-sm font-medium text-black dark:text-white">
{t('chat.researchProgress')} ({block.data.subSteps.length}{' '}
{block.data.subSteps.length === 1 ? t('chat.step') : t('chat.steps')})
{status === 'answering' && (
<span className="ml-2 font-normal text-black/60 dark:text-white/60">
(~3090 sec)
</span>
)}
</span>
</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>
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="border-t border-light-200 dark:border-dark-200"
>
<div className="p-3 space-y-2">
{block.data.subSteps.map((step, index) => {
const isLastStep = index === block.data.subSteps.length - 1;
const isStreaming = loading && isLastStep && !researchEnded;
return (
<motion.div
key={step.id}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.2, delay: 0 }}
className="flex gap-2"
>
<div className="flex flex-col items-center -mt-0.5">
<div
className={`rounded-full p-1.5 bg-light-100 dark:bg-dark-100 text-black/70 dark:text-white/70 ${isStreaming ? 'animate-pulse' : ''}`}
>
{getStepIcon(step)}
</div>
{index < block.data.subSteps.length - 1 && (
<div className="w-0.5 flex-1 min-h-[20px] bg-light-200 dark:bg-dark-200 mt-1.5" />
)}
</div>
<div className="flex-1 pb-1">
<span className="text-sm font-medium text-black dark:text-white">
{getStepTitle(step, isStreaming, t)}
</span>
{step.type === 'reasoning' && (
<>
{step.reasoning && (
<p className="text-xs text-black/70 dark:text-white/70 mt-0.5">
{step.reasoning}
</p>
)}
{isStreaming && !step.reasoning && (
<div className="flex items-center gap-1.5 mt-0.5">
<div
className="w-1.5 h-1.5 bg-black/40 dark:bg-white/40 rounded-full animate-bounce"
style={{ animationDelay: '0ms' }}
/>
<div
className="w-1.5 h-1.5 bg-black/40 dark:bg-white/40 rounded-full animate-bounce"
style={{ animationDelay: '150ms' }}
/>
<div
className="w-1.5 h-1.5 bg-black/40 dark:bg-white/40 rounded-full animate-bounce"
style={{ animationDelay: '300ms' }}
/>
</div>
)}
</>
)}
{step.type === 'searching' &&
step.searching.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-1.5">
{step.searching.map((query, idx) => (
<span
key={idx}
className="inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium bg-light-100 dark:bg-dark-100 text-black/70 dark:text-white/70 border border-light-200 dark:border-dark-200"
>
{query}
</span>
))}
</div>
)}
{(step.type === 'search_results' ||
step.type === 'reading') &&
step.reading.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-1.5">
{step.reading.slice(0, 4).map((result, idx) => {
const url = typeof result.metadata?.url === 'string' ? result.metadata.url : '';
const title = String(result.metadata?.title ?? 'Untitled');
let domain = '';
try {
if (url) domain = new URL(url).hostname;
} catch {
/* invalid url */
}
const faviconUrl = domain
? `https://s2.googleusercontent.com/s2/favicons?domain=${domain}&sz=128`
: '';
return (
<a
key={idx}
href={url}
target="_blank"
className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md text-xs font-medium bg-light-100 dark:bg-dark-100 text-black/70 dark:text-white/70 border border-light-200 dark:border-dark-200"
>
{faviconUrl && (
<img
src={faviconUrl}
alt=""
className="w-3 h-3 rounded-sm flex-shrink-0"
onError={(e) => {
e.currentTarget.style.display = 'none';
}}
/>
)}
<span className="line-clamp-1">{title}</span>
</a>
);
})}
</div>
)}
{step.type === 'upload_searching' &&
step.queries.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-1.5">
{step.queries.map((query, idx) => (
<span
key={idx}
className="inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium bg-light-100 dark:bg-dark-100 text-black/70 dark:text-white/70 border border-light-200 dark:border-dark-200"
>
{query}
</span>
))}
</div>
)}
{step.type === 'upload_search_results' &&
step.results.length > 0 && (
<div className="mt-1.5 grid gap-3 lg:grid-cols-3">
{step.results.slice(0, 4).map((result, idx) => {
const raw =
result.metadata?.title ?? result.metadata?.fileName;
const title =
typeof raw === 'string' ? raw : 'Untitled document';
return (
<div
key={idx}
className="flex flex-row space-x-3 rounded-lg border border-light-200 dark:border-dark-200 bg-light-100 dark:bg-dark-100 p-2 cursor-pointer"
>
<div className="mt-0.5 h-10 w-10 rounded-md bg-[#EA580C]/20 text-[#EA580C] dark:bg-[#EA580C] dark:text-white flex items-center justify-center">
<FileText className="w-5 h-5" />
</div>
<div className="flex flex-col justify-center">
<p className="text-[13px] text-black dark:text-white line-clamp-1">
{title}
</p>
</div>
</div>
);
})}
</div>
)}
</div>
</motion.div>
);
})}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
};
export default AssistantSteps;

View File

@@ -0,0 +1,108 @@
'use client';
import { Fragment, useEffect, useRef, useState } from 'react';
import MessageInput from './MessageInput';
import MessageBox from './MessageBox';
import MessageBoxLoading from './MessageBoxLoading';
import { useChat } from '@/lib/hooks/useChat';
const Chat = () => {
const { sections, loading, messageAppeared, messages } = useChat();
const [dividerWidth, setDividerWidth] = useState(0);
const dividerRef = useRef<HTMLDivElement | null>(null);
const messageEnd = useRef<HTMLDivElement | null>(null);
const lastScrolledRef = useRef<number>(0);
useEffect(() => {
const updateDividerWidth = () => {
if (dividerRef.current) {
setDividerWidth(dividerRef.current.offsetWidth);
}
};
updateDividerWidth();
const resizeObserver = new ResizeObserver(() => {
updateDividerWidth();
});
const currentRef = dividerRef.current;
if (currentRef) {
resizeObserver.observe(currentRef);
}
window.addEventListener('resize', updateDividerWidth);
return () => {
if (currentRef) {
resizeObserver.unobserve(currentRef);
}
resizeObserver.disconnect();
window.removeEventListener('resize', updateDividerWidth);
};
}, [sections.length]);
useEffect(() => {
const scroll = () => {
messageEnd.current?.scrollIntoView({ behavior: 'auto' });
};
if (messages.length === 1) {
document.title = `${messages[0].query.substring(0, 30)} - GooSeek`;
}
if (sections.length > lastScrolledRef.current) {
scroll();
lastScrolledRef.current = sections.length;
}
}, [messages]);
return (
<div className="flex flex-col space-y-6 pt-8 pb-44 lg:pb-28 sm:mx-4 md:mx-8">
{sections.map((section, i) => {
const isLast = i === sections.length - 1;
return (
<Fragment key={section.message.messageId}>
<MessageBox
section={section}
sectionIndex={i}
dividerRef={isLast ? dividerRef : undefined}
isLast={isLast}
/>
{!isLast && (
<div className="h-px w-full bg-light-secondary dark:bg-dark-secondary" />
)}
</Fragment>
);
})}
{loading && !messageAppeared && <MessageBoxLoading />}
<div ref={messageEnd} className="h-0" />
{dividerWidth > 0 && (
<div
className="fixed z-40 bottom-24 lg:bottom-6"
style={{ width: dividerWidth }}
>
<div
className="pointer-events-none absolute -bottom-6 left-0 right-0 h-[calc(100%+24px+24px)] dark:hidden"
style={{
background:
'linear-gradient(to top, #ffffff 0%, #ffffff 35%, rgba(255,255,255,0.95) 45%, rgba(255,255,255,0.85) 55%, rgba(255,255,255,0.7) 65%, rgba(255,255,255,0.5) 75%, rgba(255,255,255,0.3) 85%, rgba(255,255,255,0.1) 92%, transparent 100%)',
}}
/>
<div
className="pointer-events-none absolute -bottom-6 left-0 right-0 h-[calc(100%+24px+24px)] hidden dark:block"
style={{
background:
'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>
)}
</div>
);
};
export default Chat;

View File

@@ -0,0 +1,77 @@
'use client';
import Navbar from './Navbar';
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 {
chatId: string;
messageId: string;
createdAt: Date;
}
export interface Message extends BaseMessage {
backendId: string;
query: string;
responseBlocks: Block[];
status: 'answering' | 'completed' | 'error';
/** Заголовок статьи при переходе из Discover (Summary) */
articleTitle?: string;
}
export interface File {
fileName: string;
fileExtension: string;
fileId: string;
}
export interface Widget {
widgetType: string;
params: Record<string, any>;
}
const ChatWindow = () => {
const { hasError, notFound, messages, isReady } = useChat();
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.
</p>
</div>
</div>
);
}
return isReady ? (
notFound ? (
<NextError statusCode={404} />
) : (
<div>
{messages.length > 0 ? (
<>
<Navbar />
<Chat />
</>
) : (
<EmptyChat />
)}
</div>
)
) : (
<div className="min-h-screen w-full">
<EmptyChat />
</div>
);
};
export default ChatWindow;

View File

@@ -0,0 +1,25 @@
'use client';
import { useEffect, useState } from 'react';
/**
* Откладывает рендер детей до момента монтирования на клиенте.
* Устраняет ошибку "Can't perform a React state update on a component that hasn't mounted yet"
* при гидрации с next-themes и другими провайдерами.
*/
export function ClientOnly({
children,
fallback = null,
}: {
children: React.ReactNode;
fallback?: React.ReactNode;
}) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return fallback;
return <>{children}</>;
}

View File

@@ -0,0 +1,30 @@
/**
* Ошибка загрузки с кнопкой Retry
* docs/architecture: 05-gaps-and-best-practices.md §8
*/
import { RotateCcw } from 'lucide-react';
interface DataFetchErrorProps {
message?: string;
onRetry: () => void;
}
export default function DataFetchError({ message = 'Failed to load. Check your connection.', onRetry }: DataFetchErrorProps) {
return (
<div
role="alert"
className="mt-6 p-6 rounded-2xl bg-light-secondary dark:bg-dark-secondary border border-red-500/20 dark:border-red-500/30"
>
<p className="text-black/70 dark:text-white/70 mb-4">{message}</p>
<button
type="button"
onClick={onRetry}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-light-accent dark:bg-dark-accent text-white text-sm font-medium hover:opacity-90 transition-opacity focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#EA580C]"
>
<RotateCcw size={16} />
Retry
</button>
</div>
);
}

View File

@@ -0,0 +1,138 @@
import { Trash } from 'lucide-react';
import {
Description,
Dialog,
DialogBackdrop,
DialogPanel,
DialogTitle,
Transition,
TransitionChild,
} from '@headlessui/react';
import { Fragment, useState } from 'react';
import { toast } from 'sonner';
import { Chat } from '@/app/library/page';
const DeleteChat = ({
chatId,
chats,
setChats,
redirect = false,
}: {
chatId: string;
chats: Chat[];
setChats: (chats: Chat[]) => void;
redirect?: boolean;
}) => {
const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);
const [loading, setLoading] = useState(false);
const handleDelete = async () => {
setLoading(true);
try {
const token =
typeof window !== 'undefined'
? localStorage.getItem('auth_token') ?? localStorage.getItem('access_token')
: null;
if (!token) {
const { deleteGuestChat } = await import('@/lib/guest-storage');
deleteGuestChat(chatId);
const newChats = chats.filter((chat) => chat.id !== chatId);
setChats(newChats);
if (redirect) window.location.href = '/';
setConfirmationDialogOpen(false);
setLoading(false);
return;
}
const libRes = await fetch(`/api/v1/library/threads/${chatId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
});
if (!libRes.ok) {
throw new Error('Failed to delete chat');
}
const newChats = chats.filter((chat) => chat.id !== chatId);
setChats(newChats);
if (redirect) {
window.location.href = '/';
}
} catch (err: any) {
toast.error(err.message);
} finally {
setConfirmationDialogOpen(false);
setLoading(false);
}
};
return (
<>
<button
onClick={() => {
setConfirmationDialogOpen(true);
}}
className="bg-transparent text-red-400 hover:scale-105 transition duration-200"
>
<Trash size={17} />
</button>
<Transition appear show={confirmationDialogOpen} as={Fragment}>
<Dialog
as="div"
className="relative z-50"
onClose={() => {
if (!loading) {
setConfirmationDialogOpen(false);
}
}}
>
<DialogBackdrop className="fixed inset-0 bg-black/30" />
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center">
<TransitionChild
as={Fragment}
enter="ease-out duration-200"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-100"
leaveFrom="opacity-100 scale-200"
leaveTo="opacity-0 scale-95"
>
<DialogPanel className="w-full max-w-md transform rounded-2xl bg-light-secondary dark:bg-dark-secondary border border-light-200 dark:border-dark-200 p-6 text-left align-middle shadow-xl transition-all">
<DialogTitle className="text-lg font-medium leading-6 dark:text-white">
Delete Confirmation
</DialogTitle>
<Description className="text-sm dark:text-white/70 text-black/70">
Are you sure you want to delete this chat?
</Description>
<div className="flex flex-row items-end justify-end space-x-4 mt-6">
<button
onClick={() => {
if (!loading) {
setConfirmationDialogOpen(false);
}
}}
className="text-black/50 dark:text-white/50 text-sm hover:text-black/70 hover:dark:text-white/70 transition duration-200"
>
Cancel
</button>
<button
onClick={handleDelete}
className="text-red-400 text-sm hover:text-red-500 transition duration200"
>
Delete
</button>
</div>
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</Transition>
</>
);
};
export default DeleteChat;

View File

@@ -0,0 +1,60 @@
import { Discover } from '@/app/discover/page';
import Link from 'next/link';
const MajorNewsCard = ({
item,
isLeft = true,
}: {
item: Discover;
isLeft?: boolean;
}) => (
<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"
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>
</>
) : (
<>
<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}
/>
</div>
</>
)}
</Link>
);
export default MajorNewsCard;

View File

@@ -0,0 +1,28 @@
import { Discover } from '@/app/discover/page';
import Link from 'next/link';
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">
<img
className="object-cover w-full h-full group-hover:scale-105 transition-transform duration-300"
src={item.thumbnail}
alt={item.title}
/>
</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">
{item.title}
</h3>
<p className="text-black/60 dark:text-white/60 text-xs leading-relaxed line-clamp-2">
{item.content}
</p>
</div>
</Link>
);
export default SmallNewsCard;

View File

@@ -0,0 +1,81 @@
'use client';
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,
} from '@/lib/config/clientRegistry';
import { useTranslation } from '@/lib/localization/context';
const EmptyChat = () => {
const { t } = useTranslation();
const [showWeather, setShowWeather] = useState(() =>
typeof window !== 'undefined' ? getShowWeatherWidget() : true,
);
const [showNews, setShowNews] = useState(() =>
typeof window !== 'undefined' ? getShowNewsWidget() : true,
);
useEffect(() => {
const updateWidgetVisibility = () => {
setShowWeather(getShowWeatherWidget());
setShowNews(getShowNewsWidget());
};
updateWidgetVisibility();
window.addEventListener('client-config-changed', updateWidgetVisibility);
window.addEventListener('storage', updateWidgetVisibility);
return () => {
window.removeEventListener(
'client-config-changed',
updateWidgetVisibility,
);
window.removeEventListener('storage', updateWidgetVisibility);
};
}, []);
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">
<img
src={`/logo.svg?v=${process.env.NEXT_PUBLIC_VERSION ?? '1'}`}
alt="GooSeek"
className="h-12 sm:h-14 w-auto select-none"
/>
</div>
<h2 className="text-black/70 dark:text-white/70 text-3xl font-medium">
{t('empty.subtitle')}
</h2>
{(showWeather || showNews) && (
<div className="flex flex-row flex-wrap w-full gap-2 sm:gap-3 justify-center items-stretch">
{showWeather && (
<div className="flex-1 min-w-[120px] shrink-0">
<WeatherWidget />
</div>
)}
{showNews && (
<div className="flex-1 min-w-[120px] shrink-0">
<NewsArticleWidget />
</div>
)}
</div>
)}
<EmptyChatMessageInput />
</div>
</div>
</div>
);
};
export default EmptyChat;

View File

@@ -0,0 +1,96 @@
import { ArrowRight } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import TextareaAutosize from 'react-textarea-autosize';
import Sources from './MessageInputActions/Sources';
import Optimization from './MessageInputActions/Optimization';
import AnswerMode from './MessageInputActions/AnswerMode';
import InputBarPlus from './MessageInputActions/InputBarPlus';
import Attach from './MessageInputActions/Attach';
import { useChat } from '@/lib/hooks/useChat';
import { useTranslation } from '@/lib/localization/context';
import ModelSelector from './MessageInputActions/ChatModelSelector';
const EmptyChatMessageInput = () => {
const { sendMessage } = useChat();
const { t } = useTranslation();
/* const [copilotEnabled, setCopilotEnabled] = useState(false); */
const [message, setMessage] = useState('');
const inputRef = useRef<HTMLTextAreaElement | null>(null);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const activeElement = document.activeElement;
const isInputFocused =
activeElement?.tagName === 'INPUT' ||
activeElement?.tagName === 'TEXTAREA' ||
activeElement?.hasAttribute('contenteditable');
if (e.key === '/' && !isInputFocused) {
e.preventDefault();
inputRef.current?.focus();
}
};
document.addEventListener('keydown', handleKeyDown);
inputRef.current?.focus();
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, []);
return (
<form
onSubmit={(e) => {
e.preventDefault();
sendMessage(message);
setMessage('');
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage(message);
setMessage('');
}
}}
className="w-full"
>
<div className="flex flex-col bg-light-secondary dark:bg-dark-secondary px-3 pt-5 pb-3 rounded-2xl w-full border border-light-200 dark:border-dark-200 shadow-sm shadow-light-200/10 dark:shadow-black/20 transition-all duration-200 focus-within:border-light-300 dark:focus-within:border-dark-300">
<TextareaAutosize
ref={inputRef}
value={message}
onChange={(e) => setMessage(e.target.value)}
minRows={2}
className="px-2 bg-transparent placeholder:text-[15px] placeholder:text-black/50 dark:placeholder:text-white/50 text-sm text-black dark:text-white resize-none focus:outline-none w-full max-h-24 lg:max-h-36 xl:max-h-48"
placeholder={t('input.askAnything')}
/>
<div className="flex flex-row items-center justify-between mt-4">
<div className="flex flex-row items-center gap-1">
<InputBarPlus />
<Optimization />
<AnswerMode />
</div>
<div className="flex flex-row items-center space-x-2">
<div className="flex flex-row items-center space-x-1">
<Sources />
<ModelSelector />
<Attach />
</div>
<button
disabled={message.trim().length === 0}
className="bg-[#EA580C] text-white disabled:text-black/50 dark:disabled:text-white/50 disabled:bg-[#e0e0dc] dark:disabled:bg-[#ececec21] hover:bg-opacity-85 transition duration-100 rounded-full p-2"
>
<ArrowRight className="bg-background" size={17} />
</button>
</div>
</div>
</div>
</form>
);
};
export default EmptyChatMessageInput;

View File

@@ -0,0 +1,48 @@
'use client';
import { useEffect, useRef } from 'react';
import { toast } from 'sonner';
import { hasGuestData } from '@/lib/guest-storage';
import { migrateGuestToProfile } from '@/lib/guest-migration';
export default function GuestMigration() {
const migratedRef = useRef(false);
useEffect(() => {
if (typeof window === 'undefined') return;
const token =
localStorage.getItem('auth_token') ?? localStorage.getItem('access_token');
if (!token || !hasGuestData() || migratedRef.current) return;
migratedRef.current = true;
migrateGuestToProfile(token)
.then(({ migrated }) => {
if (migrated > 0) {
toast.success('Ваши чаты сохранены в профиль');
window.dispatchEvent(new CustomEvent('gooseek:guest-migrated'));
}
})
.catch(() => {
migratedRef.current = false;
});
}, []);
useEffect(() => {
const onStorage = (e: StorageEvent) => {
if (e.key === 'auth_token' || e.key === 'access_token') {
const token =
localStorage.getItem('auth_token') ??
localStorage.getItem('access_token');
if (token && hasGuestData()) {
migratedRef.current = false;
}
}
};
window.addEventListener('storage', onStorage);
return () => window.removeEventListener('storage', onStorage);
}, []);
return null;
}

View File

@@ -0,0 +1,55 @@
'use client';
/**
* Предупреждение гостям: история теряется при закрытии
* docs/architecture: 05-gaps-and-best-practices.md §8
*/
import { useChat } from '@/lib/hooks/useChat';
import { useEffect, useState } from 'react';
import Link from 'next/link';
export default function GuestWarningBanner() {
const { messages } = useChat();
const [isGuest, setIsGuest] = useState(false);
useEffect(() => {
const token =
typeof window !== 'undefined'
? localStorage.getItem('auth_token') ?? localStorage.getItem('access_token')
: null;
setIsGuest(!token);
}, []);
useEffect(() => {
if (!isGuest || messages.length === 0) return;
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
e.preventDefault();
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, [isGuest, messages.length]);
if (!isGuest || messages.length === 0) return null;
return (
<div
role="alert"
className="fixed bottom-4 left-1/2 -translate-x-1/2 z-50 max-w-lg w-[calc(100%-2rem)]"
>
<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>
</div>
);
}

View File

@@ -0,0 +1,9 @@
const Layout = ({ children }: { children: React.ReactNode }) => {
return (
<main className="lg:pl-16 bg-light-primary dark:bg-dark-primary min-h-screen">
<div className="max-w-screen-lg lg:mx-auto mx-4">{children}</div>
</main>
);
};
export default Layout;

View File

@@ -0,0 +1,51 @@
import { Check, ClipboardList } from 'lucide-react';
import { Message } from '../ChatWindow';
import { useState } from 'react';
import { Section } from '@/lib/hooks/useChat';
import { SourceBlock } from '@/lib/types';
const Copy = ({
section,
initialMessage,
}: {
section: Section;
initialMessage: string;
}) => {
const [copied, setCopied] = useState(false);
return (
<button
onClick={() => {
const sources = section.message.responseBlocks.filter(
(b) => b.type === 'source' && b.data.length > 0,
) as SourceBlock[];
const contentToCopy = `${initialMessage}${
sources.length > 0
? `\n\nCitations:\n${sources
.map((source) => source.data)
.flat()
.map((s, i) => {
const url = String(s.metadata?.url ?? '');
const fileName = String(s.metadata?.fileName ?? '');
const label =
url.startsWith('file_id://') ? (fileName || 'Uploaded File') : url;
return `[${i + 1}] ${label}`;
})
.join(`\n`)}`
: ''
}`;
navigator.clipboard.writeText(contentToCopy);
setCopied(true);
setTimeout(() => setCopied(false), 1000);
}}
className="p-2 text-black/70 dark:text-white/70 rounded-full hover:bg-light-secondary dark:hover:bg-dark-secondary transition duration-200 hover:text-black dark:hover:text-white"
>
{copied ? <Check size={16} /> : <ClipboardList size={16} />}
</button>
);
};
export default Copy;

View File

@@ -0,0 +1,20 @@
import { ArrowLeftRight, Repeat } from 'lucide-react';
const Rewrite = ({
rewrite,
messageId,
}: {
rewrite: (messageId: string) => void;
messageId: string;
}) => {
return (
<button
onClick={() => rewrite(messageId)}
className="p-2 text-black/70 dark:text-white/70 rounded-full hover:bg-light-secondary dark:hover:bg-dark-secondary transition duration-200 hover:text-black dark:hover:text-white flex flex-row items-center space-x-1"
>
<Repeat size={16} />
</button>
);
};
1;
export default Rewrite;

View File

@@ -0,0 +1,325 @@
'use client';
/* eslint-disable @next/next/no-img-element */
import React, { MutableRefObject } from 'react';
import { cn } from '@/lib/utils';
import {
BookCopy,
Disc3,
Volume2,
StopCircle,
Layers3,
Plus,
CornerDownRight,
} from 'lucide-react';
import Markdown, { MarkdownToJSX, RuleType } from 'markdown-to-jsx';
import Copy from './MessageActions/Copy';
import Rewrite from './MessageActions/Rewrite';
import MessageSources from './MessageSources';
import SearchImages from './SearchImages';
import SearchVideos from './SearchVideos';
import { useSpeech } from 'react-text-to-speech';
import ThinkBox from './ThinkBox';
import { useChat, Section } from '@/lib/hooks/useChat';
import { useTranslation } from '@/lib/localization/context';
import Citation from './MessageRenderer/Citation';
import AssistantSteps from './AssistantSteps';
import { ResearchBlock } from '@/lib/types';
import Renderer from './Widgets/Renderer';
import CodeBlock from './MessageRenderer/CodeBlock';
const ThinkTagProcessor = ({
children,
thinkingEnded,
}: {
children: React.ReactNode;
thinkingEnded: boolean;
}) => {
return (
<ThinkBox content={children as string} thinkingEnded={thinkingEnded} />
);
};
const MessageBox = ({
section,
sectionIndex,
dividerRef,
isLast,
}: {
section: Section;
sectionIndex: number;
dividerRef?: MutableRefObject<HTMLDivElement | null>;
isLast: boolean;
}) => {
const {
loading,
sendMessage,
rewrite,
messages,
researchEnded,
chatHistory,
} = useChat();
const { t } = useTranslation();
const parsedMessage = section.parsedTextBlocks.join('\n\n');
const speechMessage = section.speechMessage || '';
const thinkingEnded = section.thinkingEnded;
const sourceBlocks = section.message.responseBlocks.filter(
(block): block is typeof block & { type: 'source' } =>
block.type === 'source',
);
const sources = sourceBlocks.flatMap((block) => block.data);
const hasContent =
section.parsedTextBlocks.some(
(t) => typeof t === 'string' && t.trim().length > 0,
);
const { speechStatus, start, stop } = useSpeech({ text: speechMessage });
const markdownOverrides: MarkdownToJSX.Options = {
renderRule(next, node, renderChildren, state) {
if (node.type === RuleType.codeInline) {
return `\`${node.text}\``;
}
if (node.type === RuleType.codeBlock) {
return (
<CodeBlock key={state.key} language={node.lang || ''}>
{node.text}
</CodeBlock>
);
}
return next();
},
overrides: {
think: {
component: ThinkTagProcessor,
props: {
thinkingEnded: thinkingEnded,
},
},
citation: {
component: Citation,
},
},
};
const isSummaryArticle =
section.message.query.startsWith('Summary: ') &&
section.message.articleTitle;
const summaryUrl = isSummaryArticle
? section.message.query.slice(9)
: undefined;
return (
<div className="space-y-6">
<div className="w-full pt-8 break-words lg:max-w-[60%]">
{isSummaryArticle ? (
<div className="space-y-1 min-w-0 overflow-hidden">
<h2 className="text-black dark:text-white font-medium text-3xl">
{section.message.articleTitle}
</h2>
<a
href={summaryUrl}
target="_blank"
rel="noopener noreferrer"
className="block text-sm text-black/60 dark:text-white/60 hover:text-black/80 dark:hover:text-white/80 truncate min-w-0"
title={summaryUrl}
>
{summaryUrl}
</a>
</div>
) : (
<h2 className="text-black dark:text-white font-medium text-3xl">
{section.message.query}
</h2>
)}
</div>
<div className="flex flex-col space-y-9 lg:space-y-0 lg:flex-row lg:justify-between lg:space-x-9">
<div
ref={dividerRef}
className="flex flex-col space-y-6 w-full lg:w-9/12"
>
{sources.length > 0 && (
<div className="flex flex-col space-y-2">
<div className="flex flex-row items-center space-x-2">
<BookCopy className="text-black dark:text-white" size={20} />
<h3 className="text-black dark:text-white font-medium text-xl">
{t('chat.sources')}
</h3>
</div>
<MessageSources sources={sources} />
</div>
)}
{section.message.responseBlocks
.filter(
(block): block is ResearchBlock =>
block.type === 'research' && block.data.subSteps.length > 0,
)
.map((researchBlock) => (
<div key={researchBlock.id} className="flex flex-col space-y-2">
<AssistantSteps
block={researchBlock}
status={section.message.status}
isLast={isLast}
/>
</div>
))}
{isLast &&
loading &&
!researchEnded &&
!section.message.responseBlocks.some(
(b) => b.type === 'research' && b.data.subSteps.length > 0,
) && (
<div className="flex items-center gap-2 p-3 rounded-lg bg-light-secondary dark:bg-dark-secondary border border-light-200 dark:border-dark-200">
<Disc3 className="w-4 h-4 text-black dark:text-white" />
<span className="text-sm text-black/70 dark:text-white/70">
{t('chat.brainstorming')}
</span>
</div>
)}
{section.widgets.length > 0 && <Renderer widgets={section.widgets} />}
<div className="flex flex-col space-y-2">
{sources.length > 0 && (
<div className="flex flex-row items-center space-x-2">
<Disc3 className="text-black dark:text-white" size={20} />
<h3 className="text-black dark:text-white font-medium text-xl">
{t('chat.answer')}
</h3>
</div>
)}
{!hasContent && sources.length > 0 && isLast && loading && (
<p className="text-sm text-black/60 dark:text-white/60 italic">
{t('chat.formingAnswer')}
</p>
)}
{!hasContent && sources.length > 0 && isLast && !loading && (
<p className="text-sm text-black/60 dark:text-white/60 italic">
{t('chat.answerFailed')}
</p>
)}
{hasContent && (
<>
<Markdown
className={cn(
'prose prose-h1:mb-3 prose-h2:mb-2 prose-h2:mt-6 prose-h2:font-[800] prose-h3:mt-4 prose-h3:mb-1.5 prose-h3:font-[600] dark:prose-invert prose-p:leading-relaxed prose-pre:p-0 font-[400]',
'max-w-none break-words text-black dark:text-white',
)}
options={markdownOverrides}
>
{parsedMessage}
</Markdown>
{loading && isLast ? null : (
<div className="flex flex-row items-center justify-between w-full text-black dark:text-white py-4">
<div className="flex flex-row items-center -ml-2">
<Rewrite
rewrite={rewrite}
messageId={section.message.messageId}
/>
</div>
<div className="flex flex-row items-center -mr-2">
<Copy initialMessage={parsedMessage} section={section} />
<button
onClick={() => {
if (speechStatus === 'started') {
stop();
} else {
start();
}
}}
className="p-2 text-black/70 dark:text-white/70 rounded-full hover:bg-light-secondary dark:hover:bg-dark-secondary transition duration-200 hover:text-black dark:hover:text-white"
>
{speechStatus === 'started' ? (
<StopCircle size={16} />
) : (
<Volume2 size={16} />
)}
</button>
</div>
</div>
)}
{isLast &&
section.suggestions &&
section.suggestions.length > 0 &&
hasContent &&
!loading && (
<div className="mt-6">
<div className="flex flex-row items-center space-x-2 mb-4">
<Layers3
className="text-black dark:text-white"
size={20}
/>
<h3 className="text-black dark:text-white font-medium text-xl">
{t('chat.related')}
</h3>
</div>
<div className="space-y-0">
{section.suggestions.map(
(suggestion: string, i: number) => (
<div key={i}>
<div className="h-px bg-light-200/40 dark:bg-dark-200/40" />
<button
onClick={() => sendMessage(suggestion)}
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="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">
{suggestion}
</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>
),
)}
</div>
</div>
)}
</>
)}
</div>
</div>
{hasContent && (
<div className="lg:sticky lg:top-20 flex flex-col items-center space-y-3 w-full lg:w-3/12 z-30 h-full pb-4">
<SearchImages
query={section.message.query}
chatHistory={chatHistory}
messageId={section.message.messageId}
/>
<SearchVideos
chatHistory={chatHistory}
query={section.message.query}
messageId={section.message.messageId}
/>
</div>
)}
</div>
</div>
);
};
export default MessageBox;

View File

@@ -0,0 +1,11 @@
const MessageBoxLoading = () => {
return (
<div className="flex flex-col space-y-2 w-full lg:w-9/12 bg-light-primary dark:bg-dark-primary animate-pulse rounded-lg py-3">
<div className="h-2 rounded-full w-full bg-light-secondary dark:bg-dark-secondary" />
<div className="h-2 rounded-full w-9/12 bg-light-secondary dark:bg-dark-secondary" />
<div className="h-2 rounded-full w-10/12 bg-light-secondary dark:bg-dark-secondary" />
</div>
);
};
export default MessageBoxLoading;

View File

@@ -0,0 +1,104 @@
import { cn } from '@/lib/utils';
import { ArrowUp } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import TextareaAutosize from 'react-textarea-autosize';
import AttachSmall from './MessageInputActions/AttachSmall';
import { useChat } from '@/lib/hooks/useChat';
import { useTranslation } from '@/lib/localization/context';
const MessageInput = () => {
const { loading, sendMessage } = useChat();
const { t } = useTranslation();
const [copilotEnabled, setCopilotEnabled] = useState(false);
const [message, setMessage] = useState('');
const [textareaRows, setTextareaRows] = useState(1);
const [mode, setMode] = useState<'multi' | 'single'>('single');
useEffect(() => {
if (textareaRows >= 2 && message && mode === 'single') {
setMode('multi');
} else if (!message && mode === 'multi') {
setMode('single');
}
}, [textareaRows, mode, message]);
const inputRef = useRef<HTMLTextAreaElement | null>(null);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const activeElement = document.activeElement;
const isInputFocused =
activeElement?.tagName === 'INPUT' ||
activeElement?.tagName === 'TEXTAREA' ||
activeElement?.hasAttribute('contenteditable');
if (e.key === '/' && !isInputFocused) {
e.preventDefault();
inputRef.current?.focus();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, []);
return (
<form
onSubmit={(e) => {
if (loading) return;
e.preventDefault();
sendMessage(message);
setMessage('');
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey && !loading) {
e.preventDefault();
sendMessage(message);
setMessage('');
}
}}
className={cn(
'relative bg-light-secondary dark:bg-dark-secondary p-4 flex items-center overflow-visible border border-light-200 dark:border-dark-200 shadow-sm shadow-light-200/10 dark:shadow-black/20 transition-all duration-200 focus-within:border-light-300 dark:focus-within:border-dark-300',
mode === 'multi' ? 'flex-col rounded-2xl' : 'flex-row rounded-full',
)}
>
{mode === 'single' && <AttachSmall />}
<TextareaAutosize
ref={inputRef}
value={message}
onChange={(e) => setMessage(e.target.value)}
onHeightChange={(height, props) => {
setTextareaRows(Math.ceil(height / props.rowHeight));
}}
className="transition bg-transparent dark:placeholder:text-white/50 placeholder:text-sm text-sm dark:text-white resize-none focus:outline-none w-full px-2 max-h-24 lg:max-h-36 xl:max-h-48 flex-grow flex-shrink"
placeholder={t('input.askFollowUp')}
/>
{mode === 'single' && (
<button
disabled={message.trim().length === 0 || loading}
className="bg-[#EA580C] text-white disabled:text-black/50 dark:disabled:text-white/50 hover:bg-opacity-85 transition duration-100 disabled:bg-[#e0e0dc79] dark:disabled:bg-[#ececec21] rounded-full p-2"
>
<ArrowUp className="bg-background" size={17} />
</button>
)}
{mode === 'multi' && (
<div className="flex flex-row items-center justify-between w-full pt-2">
<AttachSmall />
<button
disabled={message.trim().length === 0 || loading}
className="bg-[#EA580C] text-white disabled:text-black/50 dark:disabled:text-white/50 hover:bg-opacity-85 transition duration-100 disabled:bg-[#e0e0dc79] dark:disabled:bg-[#ececec21] rounded-full p-2"
>
<ArrowUp className="bg-background" size={17} />
</button>
</div>
)}
</form>
);
};
export default MessageInput;

View File

@@ -0,0 +1,82 @@
import { ChevronDown, Globe, Plane, TrendingUp, BookOpen, PenLine } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
Popover,
PopoverButton,
PopoverPanel,
} from '@headlessui/react';
import { useChat } from '@/lib/hooks/useChat';
import { AnimatePresence, motion } from 'motion/react';
type AnswerModeKey = 'standard' | 'focus' | 'academic' | 'writing' | 'travel' | 'finance';
const AnswerModes: { key: AnswerModeKey; title: string; icon: React.ReactNode }[] = [
{ key: 'standard', title: 'Standard', icon: <Globe size={16} className="text-[#EA580C]" /> },
{ key: 'travel', title: 'Travel', icon: <Plane size={16} className="text-[#EA580C]" /> },
{ key: 'finance', title: 'Finance', icon: <TrendingUp size={16} className="text-[#EA580C]" /> },
{ key: 'academic', title: 'Academic', icon: <BookOpen size={16} className="text-[#EA580C]" /> },
{ key: 'writing', title: 'Writing', icon: <PenLine size={16} className="text-[#EA580C]" /> },
{ key: 'focus', title: 'Focus', icon: <Globe size={16} className="text-[#EA580C]" /> },
];
const AnswerMode = () => {
const { answerMode, setAnswerMode } = useChat();
const current = AnswerModes.find((m) => m.key === answerMode) ?? AnswerModes[0];
return (
<Popover className="relative">
{({ open }) => (
<>
<PopoverButton
type="button"
className="p-2 text-black/50 dark:text-white/50 rounded-xl hover:bg-light-secondary dark:hover:bg-dark-secondary active:scale-95 transition duration-200 hover:text-black dark:hover:text-white focus:outline-none"
title="Answer mode"
>
<div className="flex flex-row items-center space-x-1">
{current.icon}
<span className="text-xs hidden sm:inline">{current.title}</span>
<ChevronDown
size={14}
className={cn(open ? 'rotate-180' : 'rotate-0', 'transition')}
/>
</div>
</PopoverButton>
<AnimatePresence>
{open && (
<PopoverPanel
className="absolute z-10 w-48 left-0 bottom-full mb-2"
static
>
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ duration: 0.1, ease: 'easeOut' }}
className="origin-bottom-left flex flex-col bg-light-primary dark:bg-dark-primary border rounded-lg border-light-200 dark:border-dark-200 p-2"
>
{AnswerModes.map((mode) => (
<PopoverButton
key={mode.key}
onClick={() => setAnswerMode(mode.key)}
className={cn(
'p-2 rounded-lg flex flex-row items-center gap-2 text-start cursor-pointer transition focus:outline-none',
answerMode === mode.key
? 'bg-light-secondary dark:bg-dark-secondary'
: 'hover:bg-light-secondary dark:hover:bg-dark-secondary',
)}
>
{mode.icon}
<span className="text-sm font-medium">{mode.title}</span>
</PopoverButton>
))}
</motion.div>
</PopoverPanel>
)}
</AnimatePresence>
</>
)}
</Popover>
);
};
export default AnswerMode;

View File

@@ -0,0 +1,218 @@
import { cn } from '@/lib/utils';
import {
Popover,
PopoverButton,
PopoverPanel,
Transition,
} from '@headlessui/react';
import {
CopyPlus,
File,
Link,
Paperclip,
Plus,
Trash,
} from 'lucide-react';
import { Fragment, useRef, useState } from 'react';
import { toast } from 'sonner';
import { useChat } from '@/lib/hooks/useChat';
import { AnimatePresence } from 'motion/react';
import { motion } from 'framer-motion';
const UPLOAD_TIMEOUT_MS = 300000; // 5 min — docs/architecture: 05-gaps-and-best-practices.md §8
const Attach = () => {
const { files, setFiles, setFileIds, fileIds, embeddingModelProvider } = useChat();
if (!embeddingModelProvider?.providerId) return null;
const [loading, setLoading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const abortRef = useRef<AbortController | null>(null);
const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const fileList = e.target.files;
if (!fileList?.length) return;
const controller = new AbortController();
abortRef.current = controller;
const timeoutId = setTimeout(() => controller.abort(), UPLOAD_TIMEOUT_MS);
setLoading(true);
setUploadError(null);
const data = new FormData();
for (let i = 0; i < fileList.length; i++) {
data.append('files', fileList[i]);
}
const provider = localStorage.getItem('embeddingModelProviderId');
const model = localStorage.getItem('embeddingModelKey');
data.append('embedding_model_provider_id', provider!);
data.append('embedding_model_key', model!);
try {
const res = await fetch(`/api/uploads`, {
method: 'POST',
body: data,
signal: controller.signal,
});
const resData = await res.json();
if (!res.ok) {
throw new Error(resData.error ?? 'Upload failed');
}
setFiles([...files, ...(resData.files ?? [])]);
setFileIds([...fileIds, ...(resData.files ?? []).map((f: { fileId: string }) => f.fileId)]);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Upload failed';
const isAbort = (err as Error)?.name === 'AbortError';
setUploadError(isAbort ? 'Upload cancelled' : msg);
if (!isAbort) toast.error(msg);
} finally {
clearTimeout(timeoutId);
setLoading(false);
abortRef.current = null;
e.target.value = '';
}
};
const handleCancel = () => {
if (abortRef.current) {
abortRef.current.abort();
}
};
return loading ? (
<div className="flex items-center gap-2 p-2 rounded-lg text-black/50 dark:text-white/50 text-xs">
<span className="text-[#EA580C]">Uploading</span>
<button
type="button"
onClick={handleCancel}
className="text-red-500 hover:underline focus:outline-none"
>
Cancel
</button>
</div>
) : uploadError ? (
<div className="p-2 rounded-lg text-xs text-red-500">
{uploadError}
<button
type="button"
onClick={() => setUploadError(null)}
className="ml-2 underline hover:no-underline"
>
Dismiss
</button>
</div>
) : files.length > 0 ? (
<Popover className="relative w-full max-w-[15rem] md:max-w-md lg:max-w-lg">
{({ open }) => (
<>
<PopoverButton
type="button"
className="active:border-none hover:bg-light-200 hover:dark:bg-dark-200 p-2 rounded-lg focus:outline-none headless-open:text-black dark:headless-open:text-white text-black/50 dark:text-white/50 active:scale-95 transition duration-200 hover:text-black dark:hover:text-white"
>
<File size={16} className="text-[#EA580C]" />
</PopoverButton>
<AnimatePresence>
{open && (
<PopoverPanel
className="absolute z-10 w-64 md:w-[350px] right-0 bottom-full mb-2"
static
>
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ duration: 0.1, ease: 'easeOut' }}
className="origin-bottom-right bg-light-primary dark:bg-dark-primary border rounded-md border-light-200 dark:border-dark-200 w-full max-h-[200px] md:max-h-none overflow-y-auto flex flex-col"
>
<div className="flex flex-row items-center justify-between px-3 py-2">
<h4 className="text-black/70 dark:text-white/70 text-sm">
Attached files
</h4>
<div className="flex flex-row items-center space-x-4">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="flex flex-row items-center space-x-1 text-black/70 dark:text-white/70 hover:text-black hover:dark:text-white transition duration-200 focus:outline-none"
>
<input
type="file"
onChange={handleChange}
ref={fileInputRef}
accept=".pdf,.docx,.txt"
multiple
hidden
/>
<Plus size={16} />
<p className="text-xs">Add</p>
</button>
<button
onClick={() => {
setFiles([]);
setFileIds([]);
}}
className="flex flex-row items-center space-x-1 text-black/70 dark:text-white/70 hover:text-black hover:dark:text-white transition duration-200 focus:outline-none"
>
<Trash size={13} />
<p className="text-xs">Clear</p>
</button>
</div>
</div>
<div className="h-[0.5px] mx-2 bg-white/10" />
<div className="flex flex-col items-center">
{files.map((file, i) => (
<div
key={i}
className="flex flex-row items-center justify-start w-full space-x-3 p-3"
>
<div className="bg-light-100 dark:bg-dark-100 flex items-center justify-center w-9 h-9 rounded-md">
<File
size={16}
className="text-black/70 dark:text-white/70"
/>
</div>
<p className="text-black/70 dark:text-white/70 text-xs">
{file.fileName.length > 25
? file.fileName
.replace(/\.\w+$/, '')
.substring(0, 25) +
'...' +
file.fileExtension
: file.fileName}
</p>
</div>
))}
</div>
</motion.div>
</PopoverPanel>
)}
</AnimatePresence>
</>
)}
</Popover>
) : (
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className={cn(
'flex items-center justify-center active:border-none hover:bg-light-200 hover:dark:bg-dark-200 p-2 rounded-lg focus:outline-none headless-open:text-black dark:headless-open:text-white text-black/50 dark:text-white/50 active:scale-95 transition duration-200 hover:text-black dark:hover:text-white',
)}
>
<input
type="file"
onChange={handleChange}
ref={fileInputRef}
accept=".pdf,.docx,.txt"
multiple
hidden
/>
<Paperclip size={16} />
</button>
);
};
export default Attach;

View File

@@ -0,0 +1,186 @@
import {
Popover,
PopoverButton,
PopoverPanel,
Transition,
} from '@headlessui/react';
import { File, Paperclip, Plus, Trash } from 'lucide-react';
import { Fragment, useRef, useState } from 'react';
import { useChat } from '@/lib/hooks/useChat';
import { AnimatePresence } from 'motion/react';
import { motion } from 'framer-motion';
const UPLOAD_TIMEOUT_MS = 300000;
const AttachSmall = () => {
const { files, setFiles, setFileIds, fileIds, embeddingModelProvider } = useChat();
if (!embeddingModelProvider?.providerId) return null;
const [loading, setLoading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const abortRef = useRef<AbortController | null>(null);
const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const fileList = e.target.files;
if (!fileList?.length) return;
const controller = new AbortController();
abortRef.current = controller;
const timeoutId = setTimeout(() => controller.abort(), UPLOAD_TIMEOUT_MS);
setLoading(true);
setUploadError(null);
const data = new FormData();
for (let i = 0; i < fileList.length; i++) {
data.append('files', fileList[i]);
}
const provider = localStorage.getItem('embeddingModelProviderId');
const model = localStorage.getItem('embeddingModelKey');
data.append('embedding_model_provider_id', provider!);
data.append('embedding_model_key', model!);
try {
const res = await fetch(`/api/uploads`, {
method: 'POST',
body: data,
signal: controller.signal,
});
const resData = await res.json();
if (!res.ok) throw new Error(resData.error ?? 'Upload failed');
setFiles([...files, ...(resData.files ?? [])]);
setFileIds([...fileIds, ...(resData.files ?? []).map((f: { fileId: string }) => f.fileId)]);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Upload failed';
const isAbort = (err as Error)?.name === 'AbortError';
setUploadError(isAbort ? 'Cancelled' : msg);
} finally {
clearTimeout(timeoutId);
setLoading(false);
abortRef.current = null;
e.target.value = '';
}
};
return loading ? (
<div className="flex flex-row items-center space-x-2 p-1 text-xs text-[#EA580C]">
Uploading
</div>
) : uploadError ? (
<div className="p-1 text-xs text-red-500">
{uploadError}
<button type="button" onClick={() => setUploadError(null)} className="ml-1 underline">
Dismiss
</button>
</div>
) : files.length > 0 ? (
<Popover className="max-w-[15rem] md:max-w-md lg:max-w-lg">
{({ open }) => (
<>
<PopoverButton
type="button"
className="flex flex-row items-center justify-between space-x-1 p-1 text-black/50 dark:text-white/50 rounded-xl hover:bg-light-secondary dark:hover:bg-dark-secondary active:scale-95 transition duration-200 hover:text-black dark:hover:text-white"
>
<File size={20} className="text-[#EA580C]" />
</PopoverButton>
<AnimatePresence>
{open && (
<PopoverPanel
className="absolute z-10 w-64 md:w-[350px] right-0 bottom-full mb-2"
static
>
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ duration: 0.1, ease: 'easeOut' }}
className="origin-bottom-right bg-light-primary dark:bg-dark-primary border rounded-md border-light-200 dark:border-dark-200 w-full max-h-[200px] md:max-h-none overflow-y-auto flex flex-col"
>
<div className="flex flex-row items-center justify-between px-3 py-2">
<h4 className="text-black/70 dark:text-white/70 font-medium text-sm">
Attached files
</h4>
<div className="flex flex-row items-center space-x-4">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="flex flex-row items-center space-x-1 text-black/70 dark:text-white/70 hover:text-black hover:dark:text-white transition duration-200"
>
<input
type="file"
onChange={handleChange}
ref={fileInputRef}
accept=".pdf,.docx,.txt"
multiple
hidden
/>
<Plus size={16} />
<p className="text-xs">Add</p>
</button>
<button
onClick={() => {
setFiles([]);
setFileIds([]);
}}
className="flex flex-row items-center space-x-1 text-black/70 dark:text-white/70 hover:text-black hover:dark:text-white transition duration-200"
>
<Trash size={13} />
<p className="text-xs">Clear</p>
</button>
</div>
</div>
<div className="h-[0.5px] mx-2 bg-white/10" />
<div className="flex flex-col items-center">
{files.map((file, i) => (
<div
key={i}
className="flex flex-row items-center justify-start w-full space-x-3 p-3"
>
<div className="bg-light-100 dark:bg-dark-100 flex items-center justify-center w-9 h-9 rounded-md">
<File
size={16}
className="text-black/70 dark:text-white/70"
/>
</div>
<p className="text-black/70 dark:text-white/70 text-xs">
{file.fileName.length > 25
? file.fileName
.replace(/\.\w+$/, '')
.substring(0, 25) +
'...' +
file.fileExtension
: file.fileName}
</p>
</div>
))}
</div>
</motion.div>
</PopoverPanel>
)}
</AnimatePresence>
</>
)}
</Popover>
) : (
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="flex flex-row items-center space-x-1 text-black/50 dark:text-white/50 rounded-xl hover:bg-light-secondary dark:hover:bg-dark-secondary transition duration-200 hover:text-black dark:hover:text-white p-1"
>
<input
type="file"
onChange={handleChange}
ref={fileInputRef}
accept=".pdf,.docx,.txt"
multiple
hidden
/>
<Paperclip size={16} />
</button>
);
};
export default AttachSmall;

View File

@@ -0,0 +1,209 @@
'use client';
import { Cpu, Search } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react';
import { useEffect, useMemo, useState } from 'react';
import type { MinimalProvider } from '@/lib/types-ui';
import { useChat } from '@/lib/hooks/useChat';
import { AnimatePresence, motion } from 'motion/react';
const ModelSelector = () => {
const [providers, setProviders] = useState<MinimalProvider[]>([]);
const [envOnlyMode, setEnvOnlyMode] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const { setChatModelProvider, chatModelProvider } = useChat();
useEffect(() => {
const loadProviders = async () => {
try {
setIsLoading(true);
const res = await fetch('/api/providers');
if (!res.ok) {
throw new Error('Failed to fetch providers');
}
const data: { providers: MinimalProvider[]; envOnlyMode?: boolean } = await res.json();
setProviders(data.providers);
setEnvOnlyMode(data.envOnlyMode ?? false);
} catch (error) {
console.error('Error loading providers:', error);
} finally {
setIsLoading(false);
}
};
loadProviders();
}, []);
const orderedProviders = useMemo(() => {
if (!chatModelProvider?.providerId) return providers;
const currentProviderIndex = providers.findIndex(
(p) => p.id === chatModelProvider.providerId,
);
if (currentProviderIndex === -1) {
return providers;
}
const selectedProvider = providers[currentProviderIndex];
const remainingProviders = providers.filter(
(_, index) => index !== currentProviderIndex,
);
return [selectedProvider, ...remainingProviders];
}, [providers, chatModelProvider]);
const handleModelSelect = (providerId: string, modelKey: string) => {
setChatModelProvider({ providerId, key: modelKey });
localStorage.setItem('chatModelProviderId', providerId);
localStorage.setItem('chatModelKey', modelKey);
};
const filteredProviders = orderedProviders
.map((provider) => ({
...provider,
chatModels: provider.chatModels.filter(
(model) =>
model.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
provider.name.toLowerCase().includes(searchQuery.toLowerCase()),
),
}))
.filter((provider) => provider.chatModels.length > 0);
if (envOnlyMode) return null;
return (
<Popover className="relative w-full max-w-[15rem] md:max-w-md lg:max-w-lg">
{({ open }) => (
<>
<PopoverButton
type="button"
className="active:border-none hover:bg-light-200 hover:dark:bg-dark-200 p-2 rounded-lg focus:outline-none headless-open:text-black dark:headless-open:text-white text-black/50 dark:text-white/50 active:scale-95 transition duration-200 hover:text-black dark:hover:text-white"
>
<Cpu size={16} className="text-[#EA580C]" />
</PopoverButton>
<AnimatePresence>
{open && (
<PopoverPanel
className="absolute z-10 w-[230px] sm:w-[270px] md:w-[300px] right-0 bottom-full mb-2"
static
>
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ duration: 0.1, ease: 'easeOut' }}
className="origin-bottom-right bg-light-primary dark:bg-dark-primary max-h-[300px] sm:max-w-none border rounded-lg border-light-200 dark:border-dark-200 w-full flex flex-col shadow-lg overflow-hidden"
>
<div className="p-2 border-b border-light-200 dark:border-dark-200">
<div className="relative">
<Search
size={16}
className="absolute left-3 top-1/2 -translate-y-1/2 text-black/40 dark:text-white/40"
/>
<input
type="text"
placeholder="Search models..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-8 pr-3 py-2 bg-light-secondary dark:bg-dark-secondary rounded-lg placeholder:text-xs placeholder:-translate-y-[1.5px] text-xs text-black dark:text-white placeholder:text-black/40 dark:placeholder:text-white/40 focus:outline-none border border-transparent transition duration-200"
/>
</div>
</div>
<div className="max-h-[320px] overflow-y-auto">
{isLoading ? (
<div className="flex flex-col gap-2 py-16 px-4">
{[1, 2, 3, 4].map((i) => (
<div
key={i}
className="h-10 rounded-lg bg-light-200 dark:bg-dark-200 animate-pulse"
/>
))}
</div>
) : filteredProviders.length === 0 ? (
<div className="text-center py-16 px-4 text-black/60 dark:text-white/60 text-sm">
{searchQuery
? 'No models found'
: 'No chat models configured'}
</div>
) : (
<div className="flex flex-col">
{filteredProviders.map((provider, providerIndex) => (
<div key={provider.id}>
<div className="px-4 py-2.5 sticky top-0 bg-light-primary dark:bg-dark-primary border-b border-light-200/50 dark:border-dark-200/50">
<p className="text-xs text-black/50 dark:text-white/50 uppercase tracking-wider">
{provider.name}
</p>
</div>
<div className="flex flex-col px-2 py-2 space-y-0.5">
{provider.chatModels.map((model) => (
<button
key={model.key}
onClick={() =>
handleModelSelect(provider.id, model.key)
}
type="button"
className={cn(
'px-3 py-2 flex items-center justify-between text-start duration-200 cursor-pointer transition rounded-lg group',
chatModelProvider?.providerId ===
provider.id &&
chatModelProvider?.key === model.key
? 'bg-light-secondary dark:bg-dark-secondary'
: 'hover:bg-light-secondary dark:hover:bg-dark-secondary',
)}
>
<div className="flex items-center space-x-2.5 min-w-0 flex-1">
<Cpu
size={15}
className={cn(
'shrink-0',
chatModelProvider?.providerId ===
provider.id &&
chatModelProvider?.key === model.key
? 'text-[#EA580C]'
: 'text-black/50 dark:text-white/50 group-hover:text-black/70 group-hover:dark:text-white/70',
)}
/>
<p
className={cn(
'text-xs truncate',
chatModelProvider?.providerId ===
provider.id &&
chatModelProvider?.key === model.key
? 'text-[#EA580C] font-medium'
: 'text-black/70 dark:text-white/70 group-hover:text-black dark:group-hover:text-white',
)}
>
{model.name}
</p>
</div>
</button>
))}
</div>
{providerIndex < filteredProviders.length - 1 && (
<div className="h-px bg-light-200 dark:bg-dark-200" />
)}
</div>
))}
</div>
)}
</div>
</motion.div>
</PopoverPanel>
)}
</AnimatePresence>
</>
)}
</Popover>
);
};
export default ModelSelector;

View File

@@ -0,0 +1,194 @@
'use client';
/**
* Input bar «+» — меню: режимы, источники, Learn, Create, Model Council
* docs/architecture: 01-perplexity-analogue-design.md §2.2.A
*/
import { Plus, Zap, Sliders, Star, Globe, GraduationCap, Network, BookOpen, Users } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react';
import { useChat } from '@/lib/hooks/useChat';
import { AnimatePresence, motion } from 'motion/react';
import { useCallback, useEffect, useState } from 'react';
const MODES = [
{ key: 'speed', label: 'Quick', icon: Zap },
{ key: 'balanced', label: 'Pro', icon: Sliders },
{ key: 'quality', label: 'Deep', icon: Star },
] as const;
const SOURCES = [
{ key: 'web', label: 'Web', icon: Globe },
{ key: 'academic', label: 'Academic', icon: GraduationCap },
{ key: 'discussions', label: 'Social', icon: Network },
] as const;
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);
}, []);
const setLearningMode = useCallback((v: boolean) => {
localStorage.setItem('learningMode', String(v));
setLearningModeState(v);
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)) {
setSources(sources.filter((s) => s !== key));
} else {
setSources([...sources, key]);
}
},
[sources, setSources]
);
return (
<Popover className="relative">
{({ open }) => (
<>
<PopoverButton
type="button"
title="More options"
className={cn(
'p-2 rounded-xl transition duration-200 focus:outline-none',
'text-black/50 dark:text-white/50 hover:text-black dark:hover:text-white',
'hover:bg-light-secondary dark:hover:bg-dark-secondary active:scale-95',
open && 'bg-light-secondary dark:bg-dark-secondary text-[#EA580C]'
)}
>
<Plus size={18} strokeWidth={2.5} />
</PopoverButton>
<AnimatePresence>
{open && (
<PopoverPanel
static
className="absolute z-20 w-72 left-0 bottom-full mb-2"
>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.12 }}
className="origin-bottom-left rounded-xl border border-light-200 dark:border-dark-200 bg-light-primary dark:bg-dark-primary shadow-xl overflow-hidden"
>
<div className="p-2 border-b border-light-200/50 dark:border-dark-200/50">
<p className="text-xs font-medium text-black/60 dark:text-white/60 px-2 py-1">Mode</p>
<div className="flex gap-1">
{MODES.map((m) => {
const Icon = m.icon;
const isActive = optimizationMode === m.key;
return (
<button
key={m.key}
type="button"
onClick={() => setOptimizationMode(m.key)}
className={cn(
'flex-1 flex items-center justify-center gap-1.5 py-2 px-3 rounded-lg text-sm transition',
isActive
? 'bg-[#EA580C]/20 text-[#EA580C]'
: 'hover:bg-light-200/50 dark:hover:bg-dark-200/50'
)}
>
<Icon size={14} />
{m.label}
</button>
);
})}
</div>
</div>
<div className="p-2 border-b border-light-200/50 dark:border-dark-200/50">
<p className="text-xs font-medium text-black/60 dark:text-white/60 px-2 py-1">Sources</p>
<div className="flex flex-wrap gap-1">
{SOURCES.map((s) => {
const Icon = s.icon;
const checked = sources.includes(s.key);
return (
<button
key={s.key}
type="button"
onClick={() => toggleSource(s.key)}
className={cn(
'flex items-center gap-1.5 py-1.5 px-2.5 rounded-lg text-sm transition',
checked
? 'bg-[#EA580C]/20 text-[#EA580C]'
: 'hover:bg-light-200/50 dark:hover:bg-dark-200/50'
)}
>
<Icon size={14} />
{s.label}
</button>
);
})}
</div>
</div>
<div className="p-2 border-b border-light-200/50 dark:border-dark-200/50">
<button
type="button"
onClick={() => setLearningMode(!learningMode)}
className={cn(
'w-full flex items-center gap-2 py-2 px-3 rounded-lg text-sm transition',
learningMode ? 'bg-[#EA580C]/20 text-[#EA580C]' : 'hover:bg-light-200/50 dark:hover:bg-dark-200/50'
)}
>
<BookOpen size={16} />
Step-by-step Learning
{learningMode && <span className="ml-auto text-xs">On</span>}
</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">
Ask in chat: &quot;Create a table about...&quot; or &quot;Generate an image of...&quot;
</p>
</div>
</motion.div>
</PopoverPanel>
)}
</AnimatePresence>
</>
)}
</Popover>
);
};
export default InputBarPlus;

View File

@@ -0,0 +1,114 @@
import { ChevronDown, Sliders, Star, Zap } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
Popover,
PopoverButton,
PopoverPanel,
Transition,
} from '@headlessui/react';
import { Fragment } from 'react';
import { useChat } from '@/lib/hooks/useChat';
import { AnimatePresence, motion } from 'motion/react';
const OptimizationModes = [
{
key: 'speed',
title: 'Speed',
description: 'Prioritize speed and get the quickest possible answer.',
icon: <Zap size={16} className="text-[#EA580C]" />,
},
{
key: 'balanced',
title: 'Balanced',
description: 'Find the right balance between speed and accuracy',
icon: <Sliders size={16} className="text-[#EA580C]" />,
},
{
key: 'quality',
title: 'Quality',
description: 'Get the most thorough and accurate answer',
icon: (
<Star
size={16}
className="text-[#EA580C] fill-[#EA580C]"
/>
),
},
];
const Optimization = () => {
const { optimizationMode, setOptimizationMode } = useChat();
return (
<Popover className="relative w-full max-w-[15rem] md:max-w-md lg:max-w-lg">
{({ open }) => (
<>
<PopoverButton
type="button"
className="p-2 text-black/50 dark:text-white/50 rounded-xl hover:bg-light-secondary dark:hover:bg-dark-secondary active:scale-95 transition duration-200 hover:text-black dark:hover:text-white focus:outline-none"
>
<div className="flex flex-row items-center space-x-1">
{
OptimizationModes.find((mode) => mode.key === optimizationMode)
?.icon
}
<ChevronDown
size={16}
className={cn(
open ? 'rotate-180' : 'rotate-0',
'transition duration:200',
)}
/>
</div>
</PopoverButton>
<AnimatePresence>
{open && (
<PopoverPanel
className="absolute z-10 w-64 md:w-[250px] left-0 bottom-full mb-2"
static
>
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ duration: 0.1, ease: 'easeOut' }}
className="origin-bottom-left flex flex-col space-y-2 bg-light-primary dark:bg-dark-primary border rounded-lg border-light-200 dark:border-dark-200 w-full p-2 max-h-[200px] md:max-h-none overflow-y-auto"
>
{OptimizationModes.map((mode, i) => (
<PopoverButton
onClick={() => setOptimizationMode(mode.key)}
key={i}
className={cn(
'p-2 rounded-lg flex flex-col items-start justify-start text-start space-y-1 duration-200 cursor-pointer transition focus:outline-none',
optimizationMode === mode.key
? 'bg-light-secondary dark:bg-dark-secondary'
: 'hover:bg-light-secondary dark:hover:bg-dark-secondary',
)}
>
<div className="flex flex-row justify-between w-full text-black dark:text-white">
<div className="flex flex-row space-x-1">
{mode.icon}
<p className="text-xs font-medium">{mode.title}</p>
</div>
{mode.key === 'quality' && (
<span className="bg-[#EA580C]/70 dark:bg-[#EA580C]/40 border border-[#EA580C] px-1 rounded-full text-[10px] text-white">
Beta
</span>
)}
</div>
<p className="text-black/70 dark:text-white/70 text-xs">
{mode.description}
</p>
</PopoverButton>
))}
</motion.div>
</PopoverPanel>
)}
</AnimatePresence>
</>
)}
</Popover>
);
};
export default Optimization;

View File

@@ -0,0 +1,93 @@
import { useChat } from '@/lib/hooks/useChat';
import {
Popover,
PopoverButton,
PopoverPanel,
Switch,
} from '@headlessui/react';
import {
GlobeIcon,
GraduationCapIcon,
NetworkIcon,
} from '@phosphor-icons/react';
import { AnimatePresence, motion } from 'motion/react';
const sourcesList = [
{
name: 'Web',
key: 'web',
icon: <GlobeIcon className="h-[16px] w-auto" />,
},
{
name: 'Academic',
key: 'academic',
icon: <GraduationCapIcon className="h-[16px] w-auto" />,
},
{
name: 'Social',
key: 'discussions',
icon: <NetworkIcon className="h-[16px] w-auto" />,
},
];
const Sources = () => {
const { sources, setSources } = useChat();
return (
<Popover className="relative">
{({ open }) => (
<>
<PopoverButton className="flex items-center justify-center active:border-none hover:bg-light-200 hover:dark:bg-dark-200 p-2 rounded-lg focus:outline-none text-black/50 dark:text-white/50 active:scale-95 transition duration-200 hover:text-black dark:hover:text-white">
<GlobeIcon className="h-[18px] w-auto" />
</PopoverButton>
<AnimatePresence>
{open && (
<PopoverPanel
static
className="absolute z-10 w-64 md:w-[225px] right-0 bottom-full mb-2"
>
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ duration: 0.1, ease: 'easeOut' }}
className="origin-bottom-right flex flex-col bg-light-primary dark:bg-dark-primary border rounded-lg border-light-200 dark:border-dark-200 w-full p-1 max-h-[200px] md:max-h-none overflow-y-auto shadow-lg"
>
{sourcesList.map((source, i) => (
<div
key={i}
className="flex flex-row justify-between hover:bg-light-100 hover:dark:bg-dark-100 rounded-md py-3 px-2 cursor-pointer"
onClick={() => {
if (!sources.includes(source.key)) {
setSources([...sources, source.key]);
} else {
setSources(sources.filter((s) => s !== source.key));
}
}}
>
<div className="flex flex-row space-x-1.5 text-black/80 dark:text-white/80">
{source.icon}
<p className="text-xs">{source.name}</p>
</div>
<Switch
checked={sources.includes(source.key)}
className="group relative flex h-4 w-7 shrink-0 cursor-pointer rounded-full bg-light-200 dark:bg-white/10 p-0.5 duration-200 ease-in-out focus:outline-none transition-colors disabled:opacity-60 disabled:cursor-not-allowed data-[checked]:bg-[#EA580C] dark:data-[checked]:bg-[#EA580C]"
>
<span
aria-hidden="true"
className="pointer-events-none inline-block size-3 translate-x-[1px] group-data-[checked]:translate-x-3 rounded-full bg-white shadow-lg ring-0 transition duration-200 ease-in-out"
/>
</Switch>
</div>
))}
</motion.div>
</PopoverPanel>
)}
</AnimatePresence>
</>
)}
</Popover>
);
};
export default Sources;

View File

@@ -0,0 +1,19 @@
const Citation = ({
href,
children,
}: {
href: string;
children: React.ReactNode;
}) => {
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>
);
};
export default Citation;

View File

@@ -0,0 +1,102 @@
import type { CSSProperties } from 'react';
const darkTheme = {
'hljs-comment': {
color: '#8b949e',
},
'hljs-quote': {
color: '#8b949e',
},
'hljs-variable': {
color: '#ff7b72',
},
'hljs-template-variable': {
color: '#ff7b72',
},
'hljs-tag': {
color: '#ff7b72',
},
'hljs-name': {
color: '#ff7b72',
},
'hljs-selector-id': {
color: '#ff7b72',
},
'hljs-selector-class': {
color: '#ff7b72',
},
'hljs-regexp': {
color: '#ff7b72',
},
'hljs-deletion': {
color: '#ff7b72',
},
'hljs-number': {
color: '#f2cc60',
},
'hljs-built_in': {
color: '#f2cc60',
},
'hljs-builtin-name': {
color: '#f2cc60',
},
'hljs-literal': {
color: '#f2cc60',
},
'hljs-type': {
color: '#f2cc60',
},
'hljs-params': {
color: '#f2cc60',
},
'hljs-meta': {
color: '#f2cc60',
},
'hljs-link': {
color: '#f2cc60',
},
'hljs-attribute': {
color: '#58a6ff',
},
'hljs-string': {
color: '#7ee787',
},
'hljs-symbol': {
color: '#7ee787',
},
'hljs-bullet': {
color: '#7ee787',
},
'hljs-addition': {
color: '#7ee787',
},
'hljs-title': {
color: '#79c0ff',
},
'hljs-section': {
color: '#79c0ff',
},
'hljs-keyword': {
color: '#c297ff',
},
'hljs-selector-tag': {
color: '#c297ff',
},
hljs: {
display: 'block',
overflowX: 'auto',
background: '#0d1117',
color: '#c9d1d9',
padding: '0.75em',
border: '1px solid #21262d',
borderRadius: '10px',
},
'hljs-emphasis': {
fontStyle: 'italic',
},
'hljs-strong': {
fontWeight: 'bold',
},
} satisfies Record<string, CSSProperties>;
export default darkTheme;

View File

@@ -0,0 +1,102 @@
import type { CSSProperties } from 'react';
const lightTheme = {
'hljs-comment': {
color: '#6e7781',
},
'hljs-quote': {
color: '#6e7781',
},
'hljs-variable': {
color: '#d73a49',
},
'hljs-template-variable': {
color: '#d73a49',
},
'hljs-tag': {
color: '#d73a49',
},
'hljs-name': {
color: '#d73a49',
},
'hljs-selector-id': {
color: '#d73a49',
},
'hljs-selector-class': {
color: '#d73a49',
},
'hljs-regexp': {
color: '#d73a49',
},
'hljs-deletion': {
color: '#d73a49',
},
'hljs-number': {
color: '#b08800',
},
'hljs-built_in': {
color: '#b08800',
},
'hljs-builtin-name': {
color: '#b08800',
},
'hljs-literal': {
color: '#b08800',
},
'hljs-type': {
color: '#b08800',
},
'hljs-params': {
color: '#b08800',
},
'hljs-meta': {
color: '#b08800',
},
'hljs-link': {
color: '#b08800',
},
'hljs-attribute': {
color: '#0a64ae',
},
'hljs-string': {
color: '#22863a',
},
'hljs-symbol': {
color: '#22863a',
},
'hljs-bullet': {
color: '#22863a',
},
'hljs-addition': {
color: '#22863a',
},
'hljs-title': {
color: '#005cc5',
},
'hljs-section': {
color: '#005cc5',
},
'hljs-keyword': {
color: '#6f42c1',
},
'hljs-selector-tag': {
color: '#6f42c1',
},
hljs: {
display: 'block',
overflowX: 'auto',
background: '#ffffff',
color: '#24292f',
padding: '0.75em',
border: '1px solid #e8edf1',
borderRadius: '10px',
},
'hljs-emphasis': {
fontStyle: 'italic',
},
'hljs-strong': {
fontWeight: 'bold',
},
} satisfies Record<string, CSSProperties>;
export default lightTheme;

View File

@@ -0,0 +1,64 @@
'use client';
import { CheckIcon, CopyIcon } from '@phosphor-icons/react';
import React, { useEffect, useMemo, useState } from 'react';
import { useTheme } from 'next-themes';
import SyntaxHighlighter from 'react-syntax-highlighter';
import darkTheme from './CodeBlockDarkTheme';
import lightTheme from './CodeBlockLightTheme';
const CodeBlock = ({
language,
children,
}: {
language: string;
children: React.ReactNode;
}) => {
const { resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
const [copied, setCopied] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const syntaxTheme = useMemo(() => {
if (!mounted) return lightTheme;
return resolvedTheme === 'dark' ? darkTheme : lightTheme;
}, [mounted, resolvedTheme]);
return (
<div className="relative">
<button
className="absolute top-2 right-2 p-1"
onClick={() => {
navigator.clipboard.writeText(children as string);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}}
>
{copied ? (
<CheckIcon
size={16}
className="absolute top-2 right-2 text-black/70 dark:text-white/70"
/>
) : (
<CopyIcon
size={16}
className="absolute top-2 right-2 transition duration-200 text-black/70 dark:text-white/70 hover:text-gray-800/70 hover:dark:text-gray-300/70"
/>
)}
</button>
<SyntaxHighlighter
language={language}
style={syntaxTheme}
showInlineLineNumbers
>
{children as string}
</SyntaxHighlighter>
</div>
);
};
export default CodeBlock;

View File

@@ -0,0 +1,175 @@
/* eslint-disable @next/next/no-img-element */
import {
Dialog,
DialogPanel,
DialogTitle,
Transition,
TransitionChild,
} from '@headlessui/react';
import { File } from 'lucide-react';
import { Fragment, useState } from 'react';
import { Chunk } from '@/lib/types';
import { useTranslation } from '@/lib/localization/context';
const MessageSources = ({ sources }: { sources: Chunk[] }) => {
const { t } = useTranslation();
const [isDialogOpen, setIsDialogOpen] = useState(false);
const closeModal = () => {
setIsDialogOpen(false);
document.body.classList.remove('overflow-hidden-scrollable');
};
const openModal = () => {
setIsDialogOpen(true);
document.body.classList.add('overflow-hidden-scrollable');
};
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2">
{sources.slice(0, 3).map((source, i) => {
const url = source.metadata.url ?? '';
const title = source.metadata.title ?? '';
return (
<a
className="bg-light-100 hover:bg-light-200 dark:bg-dark-100 dark:hover:bg-dark-200 transition duration-200 rounded-lg p-3 flex flex-col space-y-2 font-medium"
key={i}
href={url || '#'}
target="_blank"
>
<p className="dark:text-white text-xs overflow-hidden whitespace-nowrap text-ellipsis">
{title}
</p>
<div className="flex flex-row items-center justify-between">
<div className="flex flex-row items-center space-x-1">
{url.includes('file_id://') ? (
<div className="bg-dark-200 hover:bg-dark-100 transition duration-200 flex items-center justify-center w-6 h-6 rounded-full">
<File size={12} className="text-white/70" />
</div>
) : (
<img
src={`https://s2.googleusercontent.com/s2/favicons?domain_url=${url}`}
width={16}
height={16}
alt="favicon"
className="rounded-lg h-4 w-4"
/>
)}
<p className="text-xs text-black/50 dark:text-white/50 overflow-hidden whitespace-nowrap text-ellipsis">
{url.includes('file_id://')
? t('chat.uploadedFile')
: url.replace(/.+\/\/|www.|\..+/g, '')}
</p>
</div>
<div className="flex flex-row items-center space-x-1 text-black/50 dark:text-white/50 text-xs">
<div className="bg-black/50 dark:bg-white/50 h-[4px] w-[4px] rounded-full" />
<span>{i + 1}</span>
</div>
</div>
</a>
);
})}
{sources.length > 3 && (
<button
onClick={openModal}
className="bg-light-100 hover:bg-light-200 dark:bg-dark-100 dark:hover:bg-dark-200 transition duration-200 rounded-lg p-3 flex flex-col space-y-2 font-medium"
>
<div className="flex flex-row items-center space-x-1">
{sources.slice(3, 6).map((source, i) => {
const u = source.metadata.url ?? '';
return u === 'File' ? (
<div
key={i}
className="bg-dark-200 hover:bg-dark-100 transition duration-200 flex items-center justify-center w-6 h-6 rounded-full"
>
<File size={12} className="text-white/70" />
</div>
) : (
<img
key={i}
src={`https://s2.googleusercontent.com/s2/favicons?domain_url=${u}`}
width={16}
height={16}
alt="favicon"
className="rounded-lg h-4 w-4"
/>
);
})}
</div>
<p className="text-xs text-black/50 dark:text-white/50">
{t('chat.viewMore').replace('{count}', String(sources.length - 3))}
</p>
</button>
)}
<Transition appear show={isDialogOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={closeModal}>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center">
<TransitionChild
as={Fragment}
enter="ease-out duration-200"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-100"
leaveFrom="opacity-100 scale-200"
leaveTo="opacity-0 scale-95"
>
<DialogPanel className="w-full max-w-md transform rounded-2xl bg-light-secondary dark:bg-dark-secondary border border-light-200 dark:border-dark-200 p-6 text-left align-middle shadow-xl transition-all">
<DialogTitle className="text-lg font-medium leading-6 dark:text-white">
{t('chat.sources')}
</DialogTitle>
<div className="grid grid-cols-2 gap-2 overflow-auto max-h-[300px] mt-2 pr-2">
{sources.map((source, i) => {
const u = source.metadata.url ?? '';
const tit = source.metadata.title ?? '';
return (
<a
className="bg-light-secondary hover:bg-light-200 dark:bg-dark-secondary dark:hover:bg-dark-200 border border-light-200 dark:border-dark-200 transition duration-200 rounded-lg p-3 flex flex-col space-y-2 font-medium"
key={i}
href={u || '#'}
target="_blank"
>
<p className="dark:text-white text-xs overflow-hidden whitespace-nowrap text-ellipsis">
{tit}
</p>
<div className="flex flex-row items-center justify-between">
<div className="flex flex-row items-center space-x-1">
{u === 'File' ? (
<div className="bg-dark-200 hover:bg-dark-100 transition duration-200 flex items-center justify-center w-6 h-6 rounded-full">
<File size={12} className="text-white/70" />
</div>
) : (
<img
src={`https://s2.googleusercontent.com/s2/favicons?domain_url=${u}`}
width={16}
height={16}
alt="favicon"
className="rounded-lg h-4 w-4"
/>
)}
<p className="text-xs text-black/50 dark:text-white/50 overflow-hidden whitespace-nowrap text-ellipsis">
{u.replace(
/.+\/\/|www.|\..+/g,
'',
)}
</p>
</div>
<div className="flex flex-row items-center space-x-1 text-black/50 dark:text-white/50 text-xs">
<div className="bg-black/50 dark:bg-white/50 h-[4px] w-[4px] rounded-full" />
<span>{i + 1}</span>
</div>
</div>
</a>
); })}
</div>
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</Transition>
</div>
);
};
export default MessageSources;

View File

@@ -0,0 +1,403 @@
import { Clock, Edit, Share, Trash, FileText, FileDown } from 'lucide-react';
import { Message } from './ChatWindow';
import { useEffect, useState, Fragment } from 'react';
import { formatTimeDifference } from '@/lib/utils';
import DeleteChat from './DeleteChat';
import {
Popover,
PopoverButton,
PopoverPanel,
Transition,
} from '@headlessui/react';
import { useChat, Section } from '@/lib/hooks/useChat';
import { SourceBlock } from '@/lib/types';
const downloadFile = (filename: string, content: string, type: string) => {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
setTimeout(() => {
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, 0);
};
const serializeSections = (sections: Section[]) =>
sections.map((s) => ({
query: s.message.query,
createdAt: s.message.createdAt,
parsedTextBlocks: s.parsedTextBlocks,
responseBlocks: s.message.responseBlocks,
}));
const exportViaApi = async (
format: 'pdf' | 'md',
sections: Section[],
title: string,
chatId?: string
) => {
if (chatId) {
const token =
typeof window !== 'undefined'
? localStorage.getItem('auth_token') ?? localStorage.getItem('access_token')
: null;
const res = await fetch(
`/api/v1/library/threads/${encodeURIComponent(chatId)}/export?format=${format}`,
{
method: 'GET',
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
}
);
if (res.ok) {
const blob = await res.blob();
const disposition = res.headers.get('Content-Disposition');
const match = disposition?.match(/filename="?([^";]+)"?/);
const filename = match?.[1] ?? `${title || 'chat'}.${format}`;
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
setTimeout(() => {
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, 0);
return true;
}
}
const res = await fetch('/api/v1/export', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
format,
title: title || 'chat',
sections: serializeSections(sections),
}),
});
if (res.ok) {
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${title || 'chat'}.${format}`;
document.body.appendChild(a);
a.click();
setTimeout(() => {
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, 0);
return true;
}
return false;
};
const exportAsMarkdown = (sections: Section[], title: string) => {
const date = new Date(
sections[0].message.createdAt || Date.now(),
).toLocaleString();
let md = `# 💬 Chat Export: ${title}\n\n`;
md += `*Exported on: ${date}*\n\n---\n`;
sections.forEach((section, idx) => {
md += `\n---\n`;
md += `**🧑 User**
`;
md += `*${new Date(section.message.createdAt).toLocaleString()}*\n\n`;
md += `> ${section.message.query.replace(/\n/g, '\n> ')}\n`;
if (section.message.responseBlocks.length > 0) {
md += `\n---\n`;
md += `**🤖 Assistant**
`;
md += `*${new Date(section.message.createdAt).toLocaleString()}*\n\n`;
md += `> ${section.message.responseBlocks
.filter((b) => b.type === 'text')
.map((block) => block.data)
.join('\n')
.replace(/\n/g, '\n> ')}\n`;
}
const sourceResponseBlock = section.message.responseBlocks.find(
(block) => block.type === 'source',
) as SourceBlock | undefined;
if (
sourceResponseBlock &&
sourceResponseBlock.data &&
sourceResponseBlock.data.length > 0
) {
md += `\n**Citations:**\n`;
sourceResponseBlock.data.forEach((src: any, i: number) => {
const url = src.metadata?.url || '';
md += `- [${i + 1}] [${url}](${url})\n`;
});
}
});
md += '\n---\n';
downloadFile(`${title || 'chat'}.md`, md, 'text/markdown');
};
const exportAsPDF = async (sections: Section[], title: string) => {
const { default: jsPDF } = await import('jspdf');
const doc = new jsPDF();
const date = new Date(
sections[0]?.message?.createdAt || Date.now(),
).toLocaleString();
let y = 15;
const pageHeight = doc.internal.pageSize.height;
doc.setFontSize(18);
doc.text(`Chat Export: ${title}`, 10, y);
y += 8;
doc.setFontSize(11);
doc.setTextColor(100);
doc.text(`Exported on: ${date}`, 10, y);
y += 8;
doc.setDrawColor(200);
doc.line(10, y, 200, y);
y += 6;
doc.setTextColor(30);
sections.forEach((section, idx) => {
if (y > pageHeight - 30) {
doc.addPage();
y = 15;
}
doc.setFont('helvetica', 'bold');
doc.text('User', 10, y);
doc.setFont('helvetica', 'normal');
doc.setFontSize(10);
doc.setTextColor(120);
doc.text(`${new Date(section.message.createdAt).toLocaleString()}`, 40, y);
y += 6;
doc.setTextColor(30);
doc.setFontSize(12);
const userLines = doc.splitTextToSize(section.message.query, 180);
for (let i = 0; i < userLines.length; i++) {
if (y > pageHeight - 20) {
doc.addPage();
y = 15;
}
doc.text(userLines[i], 12, y);
y += 6;
}
y += 6;
doc.setDrawColor(230);
if (y > pageHeight - 10) {
doc.addPage();
y = 15;
}
doc.line(10, y, 200, y);
y += 4;
if (section.message.responseBlocks.length > 0) {
if (y > pageHeight - 30) {
doc.addPage();
y = 15;
}
doc.setFont('helvetica', 'bold');
doc.text('Assistant', 10, y);
doc.setFont('helvetica', 'normal');
doc.setFontSize(10);
doc.setTextColor(120);
doc.text(
`${new Date(section.message.createdAt).toLocaleString()}`,
40,
y,
);
y += 6;
doc.setTextColor(30);
doc.setFontSize(12);
const assistantLines = doc.splitTextToSize(
section.parsedTextBlocks.join('\n'),
180,
);
for (let i = 0; i < assistantLines.length; i++) {
if (y > pageHeight - 20) {
doc.addPage();
y = 15;
}
doc.text(assistantLines[i], 12, y);
y += 6;
}
const sourceResponseBlock = section.message.responseBlocks.find(
(block) => block.type === 'source',
) as SourceBlock | undefined;
if (
sourceResponseBlock &&
sourceResponseBlock.data &&
sourceResponseBlock.data.length > 0
) {
doc.setFontSize(11);
doc.setTextColor(80);
if (y > pageHeight - 20) {
doc.addPage();
y = 15;
}
doc.text('Citations:', 12, y);
y += 5;
sourceResponseBlock.data.forEach((src: any, i: number) => {
const url = src.metadata?.url || '';
if (y > pageHeight - 15) {
doc.addPage();
y = 15;
}
doc.text(`- [${i + 1}] ${url}`, 15, y);
y += 5;
});
doc.setTextColor(30);
}
y += 6;
doc.setDrawColor(230);
if (y > pageHeight - 10) {
doc.addPage();
y = 15;
}
doc.line(10, y, 200, y);
y += 4;
}
});
doc.save(`${title || 'chat'}.pdf`);
};
const Navbar = () => {
const [title, setTitle] = useState<string>('');
const [timeAgo, setTimeAgo] = useState<string>('');
const { sections, chatId } = useChat();
useEffect(() => {
if (sections.length > 0 && sections[0].message) {
const newTitle =
sections[0].message.query.length > 30
? `${sections[0].message.query.substring(0, 30).trim()}...`
: sections[0].message.query || 'New Conversation';
setTitle(newTitle);
const newTimeAgo = formatTimeDifference(
new Date(),
sections[0].message.createdAt,
);
setTimeAgo(newTimeAgo);
}
}, [sections]);
useEffect(() => {
const intervalId = setInterval(() => {
if (sections.length > 0 && sections[0].message) {
const newTimeAgo = formatTimeDifference(
new Date(),
sections[0].message.createdAt,
);
setTimeAgo(newTimeAgo);
}
}, 1000);
return () => clearInterval(intervalId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className="sticky -mx-4 lg:mx-0 top-0 z-40 bg-light-primary/95 dark:bg-dark-primary/95 backdrop-blur-sm border-b border-light-200/50 dark:border-dark-200/30">
<div className="px-4 lg:px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center min-w-0">
<a
href="/"
className="lg:hidden mr-3 p-2 -ml-2 rounded-lg hover:bg-light-secondary dark:hover:bg-dark-secondary transition-colors duration-200"
>
<Edit size={18} className="text-black/70 dark:text-white/70" />
</a>
<div className="hidden lg:flex items-center gap-2 text-black/50 dark:text-white/50 min-w-0">
<Clock size={14} />
<span className="text-xs whitespace-nowrap">{timeAgo} ago</span>
</div>
</div>
<div className="flex-1 mx-4 min-w-0">
<h1 className="text-center text-sm font-medium text-black/80 dark:text-white/90 truncate">
{title || 'New Conversation'}
</h1>
</div>
<div className="flex items-center gap-1 min-w-0">
<Popover className="relative">
<PopoverButton className="p-2 rounded-lg hover:bg-light-secondary dark:hover:bg-dark-secondary transition-colors duration-200">
<Share size={16} className="text-black/60 dark:text-white/60" />
</PopoverButton>
<Transition
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<PopoverPanel className="absolute right-0 mt-2 w-64 origin-top-right rounded-2xl bg-light-primary dark:bg-dark-primary border border-light-200 dark:border-dark-200 shadow-xl shadow-black/10 dark:shadow-black/30 z-50">
<div className="p-3">
<div className="mb-2">
<p className="text-xs font-medium text-black/40 dark:text-white/40 uppercase tracking-wide">
Export Chat
</p>
</div>
<div className="space-y-1">
<button
className="w-full flex items-center gap-3 px-3 py-2 text-left rounded-xl hover:bg-light-secondary dark:hover:bg-dark-secondary transition-colors duration-200"
onClick={async () => {
const ok = await exportViaApi('md', sections, title || '', chatId);
if (!ok) exportAsMarkdown(sections, title || '');
}}
>
<FileText size={16} className="text-[#EA580C]" />
<div>
<p className="text-sm font-medium text-black dark:text-white">
Markdown
</p>
<p className="text-xs text-black/50 dark:text-white/50">
.md format
</p>
</div>
</button>
<button
className="w-full flex items-center gap-3 px-3 py-2 text-left rounded-xl hover:bg-light-secondary dark:hover:bg-dark-secondary transition-colors duration-200"
onClick={async () => {
const ok = await exportViaApi('pdf', sections, title || '', chatId);
if (!ok) void exportAsPDF(sections, title || '');
}}
>
<FileDown size={16} className="text-[#EA580C]" />
<div>
<p className="text-sm font-medium text-black dark:text-white">
PDF
</p>
<p className="text-xs text-black/50 dark:text-white/50">
Document format
</p>
</div>
</button>
</div>
</div>
</PopoverPanel>
</Transition>
</Popover>
<DeleteChat
redirect
chatId={chatId!}
chats={[]}
setChats={() => {}}
/>
</div>
</div>
</div>
</div>
);
};
export default Navbar;

View File

@@ -0,0 +1,71 @@
import { useEffect, useState } from 'react';
interface Article {
title: string;
content: string;
url: string;
thumbnail: string;
}
const NewsArticleWidget = () => {
const [article, setArticle] = useState<Article | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
useEffect(() => {
fetch('/api/v1/discover?mode=preview')
.then((res) => res.json())
.then((data) => {
const articles = (data.blogs || []).filter((a: Article) => a.thumbnail);
setArticle(articles[Math.floor(Math.random() * articles.length)]);
setLoading(false);
})
.catch(() => {
setError(true);
setLoading(false);
});
}, []);
return (
<div className="bg-light-secondary dark:bg-dark-secondary rounded-2xl border border-light-200 dark:border-dark-200 shadow-sm shadow-light-200/10 dark:shadow-black/25 flex flex-row items-stretch w-full h-20 min-h-[80px] max-h-[80px] p-0 overflow-hidden">
{loading ? (
<div className="animate-pulse flex flex-row items-stretch w-full h-full">
<div className="w-20 min-w-20 max-w-20 h-full bg-light-200 dark:bg-dark-200" />
<div className="flex flex-col justify-center flex-1 px-2.5 py-1.5 gap-1">
<div className="h-3 w-full max-w-[80%] rounded bg-light-200 dark:bg-dark-200" />
<div className="h-3 w-16 rounded bg-light-200 dark:bg-dark-200" />
</div>
</div>
) : error ? (
<div className="w-full text-xs text-red-400">Could not load news.</div>
) : article ? (
<a
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">
<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')}`
}
alt={article.title}
/>
</div>
<div className="flex flex-col justify-center flex-1 px-2.5 py-1.5 min-w-0">
<div className="font-semibold text-[11px] text-black dark:text-white leading-tight line-clamp-2 mb-0.5">
{article.title}
</div>
<p className="text-black/60 dark:text-white/60 text-[9px] leading-relaxed line-clamp-2">
{article.content}
</p>
</div>
</a>
) : null}
</div>
);
};
export default NewsArticleWidget;

View File

@@ -0,0 +1,165 @@
/* eslint-disable @next/next/no-img-element */
import { ImagesIcon, PlusIcon } from 'lucide-react';
import { useState } from 'react';
import Lightbox from 'yet-another-react-lightbox';
import 'yet-another-react-lightbox/styles.css';
import { Message } from './ChatWindow';
import { useTranslation } from '@/lib/localization/context';
type Image = {
url: string;
img_src: string;
title: string;
};
const MEDIA_TIMEOUT_MS = 15000; // docs/architecture: 05-gaps-and-best-practices.md §8
const SearchImages = ({
query,
chatHistory,
messageId,
}: {
query: string;
chatHistory: [string, string][];
messageId: string;
}) => {
const { t } = useTranslation();
const [images, setImages] = useState<Image[] | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [open, setOpen] = useState(false);
const [slides, setSlides] = useState<any[]>([]);
const searchImages = async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/images`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query,
chatHistory,
chatModel: {
providerId: localStorage.getItem('chatModelProviderId'),
key: localStorage.getItem('chatModelKey'),
},
}),
signal: AbortSignal.timeout(MEDIA_TIMEOUT_MS),
});
const data = await res.json();
const imgs = data.images ?? [];
setImages(imgs);
setSlides(imgs.map((img: Image) => ({ src: img.img_src })));
} catch (err) {
const msg = err instanceof Error ? err.message : 'Search failed';
const isTimeout = msg.includes('timeout') || (err as Error)?.name === 'AbortError';
setError(isTimeout ? 'Request timed out. Try again.' : msg);
} finally {
setLoading(false);
}
};
return (
<>
{!loading && images === null && !error && (
<button
id={`search-images-${messageId}`}
onClick={searchImages}
className="border border-dashed border-light-200 dark:border-dark-200 hover:bg-light-200 dark:hover:bg-dark-200 active:scale-95 duration-200 transition px-4 py-2 flex flex-row items-center justify-between rounded-lg dark:text-white text-sm w-full"
>
<div className="flex flex-row items-center space-x-2">
<ImagesIcon size={17} />
<p>{t('chat.searchImages')}</p>
</div>
<PlusIcon className="text-[#EA580C]" size={17} />
</button>
)}
{error && (
<div className="rounded-lg border border-red-500/20 dark:border-red-500/30 bg-light-secondary dark:bg-dark-secondary p-3 text-sm">
<p className="text-red-500 mb-2">{error}</p>
<button
type="button"
onClick={searchImages}
className="text-[#EA580C] hover:underline text-sm"
>
Retry
</button>
</div>
)}
{loading && (
<div className="grid grid-cols-2 gap-2">
{[...Array(4)].map((_, i) => (
<div
key={i}
className="bg-light-secondary dark:bg-dark-secondary h-32 w-full rounded-lg animate-pulse aspect-video object-cover"
/>
))}
</div>
)}
{images !== null && images.length > 0 && (
<>
<div className="grid grid-cols-2 gap-2">
{images.length > 4
? images.slice(0, 3).map((image, i) => (
<img
onClick={() => {
setOpen(true);
setSlides([
slides[i],
...slides.slice(0, i),
...slides.slice(i + 1),
]);
}}
key={i}
src={image.img_src}
alt={image.title}
className="h-full w-full aspect-video object-cover rounded-lg transition duration-200 active:scale-95 hover:scale-[1.02] cursor-zoom-in"
/>
))
: images.map((image, i) => (
<img
onClick={() => {
setOpen(true);
setSlides([
slides[i],
...slides.slice(0, i),
...slides.slice(i + 1),
]);
}}
key={i}
src={image.img_src}
alt={image.title}
className="h-full w-full aspect-video object-cover rounded-lg transition duration-200 active:scale-95 hover:scale-[1.02] cursor-zoom-in"
/>
))}
{images.length > 4 && (
<button
onClick={() => setOpen(true)}
className="bg-light-100 hover:bg-light-200 dark:bg-dark-100 dark:hover:bg-dark-200 transition duration-200 active:scale-95 hover:scale-[1.02] h-auto w-full rounded-lg flex flex-col justify-between text-white p-2"
>
<div className="flex flex-row items-center space-x-1">
{images.slice(3, 6).map((image, i) => (
<img
key={i}
src={image.img_src}
alt={image.title}
className="h-6 w-12 rounded-md lg:h-3 lg:w-6 lg:rounded-sm aspect-video object-cover"
/>
))}
</div>
<p className="text-black/70 dark:text-white/70 text-xs">
View {images.length - 3} more
</p>
</button>
)}
</div>
<Lightbox open={open} close={() => setOpen(false)} slides={slides} />
</>
)}
</>
);
};
export default SearchImages;

View File

@@ -0,0 +1,240 @@
/* eslint-disable @next/next/no-img-element */
import { PlayCircle, PlayIcon, PlusIcon, VideoIcon } from 'lucide-react';
import { useRef, useState } from 'react';
import Lightbox, { GenericSlide, VideoSlide } from 'yet-another-react-lightbox';
import 'yet-another-react-lightbox/styles.css';
import { Message } from './ChatWindow';
import { useTranslation } from '@/lib/localization/context';
type Video = {
url: string;
img_src: string;
title: string;
iframe_src: string;
};
declare module 'yet-another-react-lightbox' {
export interface VideoSlide extends GenericSlide {
type: 'video-slide';
src: string;
iframe_src: string;
}
interface SlideTypes {
'video-slide': VideoSlide;
}
}
const MEDIA_TIMEOUT_MS = 15000;
const SearchVideos = ({
query,
chatHistory,
messageId,
}: {
query: string;
chatHistory: [string, string][];
messageId: string;
}) => {
const { t } = useTranslation();
const [videos, setVideos] = useState<Video[] | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [open, setOpen] = useState(false);
const [slides, setSlides] = useState<VideoSlide[]>([]);
const [currentIndex, setCurrentIndex] = useState(0);
const videoRefs = useRef<(HTMLIFrameElement | null)[]>([]);
const searchVideos = async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/videos`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query,
chatHistory,
chatModel: {
providerId: localStorage.getItem('chatModelProviderId'),
key: localStorage.getItem('chatModelKey'),
},
}),
signal: AbortSignal.timeout(MEDIA_TIMEOUT_MS),
});
const data = await res.json();
const vids = data.videos ?? [];
setVideos(vids);
setSlides(
vids.map((v: Video) => ({
type: 'video-slide' as const,
iframe_src: v.iframe_src,
src: v.img_src,
})),
);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Search failed';
const isTimeout = msg.includes('timeout') || (err as Error)?.name === 'AbortError';
setError(isTimeout ? 'Request timed out. Try again.' : msg);
} finally {
setLoading(false);
}
};
return (
<>
{!loading && videos === null && !error && (
<button
id={`search-videos-${messageId}`}
onClick={searchVideos}
className="border border-dashed border-light-200 dark:border-dark-200 hover:bg-light-200 dark:hover:bg-dark-200 active:scale-95 duration-200 transition px-4 py-2 flex flex-row items-center justify-between rounded-lg dark:text-white text-sm w-full"
>
<div className="flex flex-row items-center space-x-2">
<VideoIcon size={17} />
<p>{t('chat.searchVideos')}</p>
</div>
<PlusIcon className="text-[#EA580C]" size={17} />
</button>
)}
{error && (
<div className="rounded-lg border border-red-500/20 dark:border-red-500/30 bg-light-secondary dark:bg-dark-secondary p-3 text-sm">
<p className="text-red-500 mb-2">{error}</p>
<button
type="button"
onClick={searchVideos}
className="text-[#EA580C] hover:underline text-sm"
>
Retry
</button>
</div>
)}
{loading && (
<div className="grid grid-cols-2 gap-2">
{[...Array(4)].map((_, i) => (
<div
key={i}
className="bg-light-secondary dark:bg-dark-secondary h-32 w-full rounded-lg animate-pulse aspect-video object-cover"
/>
))}
</div>
)}
{videos !== null && videos.length > 0 && (
<>
<div className="grid grid-cols-2 gap-2">
{videos.length > 4
? videos.slice(0, 3).map((video, i) => (
<div
onClick={() => {
setOpen(true);
setSlides([
slides[i],
...slides.slice(0, i),
...slides.slice(i + 1),
]);
}}
className="relative transition duration-200 active:scale-95 hover:scale-[1.02] cursor-pointer"
key={i}
>
<img
src={video.img_src}
alt={video.title}
className="relative h-full w-full aspect-video object-cover rounded-lg"
/>
<div className="absolute bg-white/70 dark:bg-black/70 text-black/70 dark:text-white/70 px-2 py-1 flex flex-row items-center space-x-1 bottom-1 right-1 rounded-md">
<PlayCircle size={15} />
<p className="text-xs">{t('chat.video')}</p>
</div>
</div>
))
: videos.map((video, i) => (
<div
onClick={() => {
setOpen(true);
setSlides([
slides[i],
...slides.slice(0, i),
...slides.slice(i + 1),
]);
}}
className="relative transition duration-200 active:scale-95 hover:scale-[1.02] cursor-pointer"
key={i}
>
<img
src={video.img_src}
alt={video.title}
className="relative h-full w-full aspect-video object-cover rounded-lg"
/>
<div className="absolute bg-white/70 dark:bg-black/70 text-black/70 dark:text-white/70 px-2 py-1 flex flex-row items-center space-x-1 bottom-1 right-1 rounded-md">
<PlayCircle size={15} />
<p className="text-xs">{t('chat.video')}</p>
</div>
</div>
))}
{videos.length > 4 && (
<button
onClick={() => setOpen(true)}
className="bg-light-100 hover:bg-light-200 dark:bg-dark-100 dark:hover:bg-dark-200 transition duration-200 active:scale-95 hover:scale-[1.02] h-auto w-full rounded-lg flex flex-col justify-between text-white p-2"
>
<div className="flex flex-row items-center space-x-1">
{videos.slice(3, 6).map((video, i) => (
<img
key={i}
src={video.img_src}
alt={video.title}
className="h-6 w-12 rounded-md lg:h-3 lg:w-6 lg:rounded-sm aspect-video object-cover"
/>
))}
</div>
<p className="text-black/70 dark:text-white/70 text-xs">
View {videos.length - 3} more
</p>
</button>
)}
</div>
<Lightbox
open={open}
close={() => setOpen(false)}
slides={slides}
index={currentIndex}
on={{
view: ({ index }) => {
const previousIframe = videoRefs.current[currentIndex];
if (previousIframe?.contentWindow) {
previousIframe.contentWindow.postMessage(
'{"event":"command","func":"pauseVideo","args":""}',
'*',
);
}
setCurrentIndex(index);
},
}}
render={{
slide: ({ slide }) => {
const index = slides.findIndex((s) => s === slide);
return slide.type === 'video-slide' ? (
<div className="h-full w-full flex flex-row items-center justify-center">
<iframe
src={`${slide.iframe_src}${slide.iframe_src.includes('?') ? '&' : '?'}enablejsapi=1`}
ref={(el) => {
if (el) {
videoRefs.current[index] = el;
}
}}
className="aspect-video max-h-[95vh] w-[95vw] rounded-2xl md:w-[80vw]"
allowFullScreen
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture"
/>
</div>
) : null;
},
}}
/>
</>
)}
</>
);
};
export default SearchVideos;

View File

@@ -0,0 +1,159 @@
import { Dialog, DialogPanel } from '@headlessui/react';
import { Plus } from 'lucide-react';
import { useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { ConfigModelProvider } from '@/lib/config/types';
import { toast } from 'sonner';
const AddModel = ({
providerId,
setProviders,
type,
}: {
providerId: string;
setProviders: React.Dispatch<React.SetStateAction<ConfigModelProvider[]>>;
type: 'chat' | 'embedding';
}) => {
const [open, setOpen] = useState(false);
const [modelName, setModelName] = useState('');
const [modelKey, setModelKey] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const res = await fetch(`/api/providers/${providerId}/models`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: modelName,
key: modelKey,
type: type,
}),
});
if (!res.ok) {
throw new Error('Failed to add model');
}
setProviders((prev) =>
prev.map((provider) => {
if (provider.id === providerId) {
const newModel = { name: modelName, key: modelKey };
return {
...provider,
chatModels:
type === 'chat'
? [...provider.chatModels, newModel]
: provider.chatModels,
embeddingModels:
type === 'embedding'
? [...provider.embeddingModels, newModel]
: provider.embeddingModels,
};
}
return provider;
}),
);
toast.success('Model added successfully.');
setModelName('');
setModelKey('');
setOpen(false);
} catch (error) {
console.error('Error adding model:', error);
toast.error('Failed to add model.');
} finally {
setLoading(false);
}
};
return (
<>
<button
onClick={() => setOpen(true)}
className="text-xs text-black/70 dark:text-white/70 hover:text-black hover:dark:text-white flex flex-row items-center space-x-1 active:scale-95 transition duration-200"
>
<Plus size={12} />
<span>Add</span>
</button>
<AnimatePresence>
{open && (
<Dialog
static
open={open}
onClose={() => setOpen(false)}
className="relative z-[60]"
>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.1 }}
className="fixed inset-0 flex w-screen items-center justify-center p-4 bg-black/30 backdrop-blur-sm"
>
<DialogPanel className="w-full mx-4 lg:w-[600px] max-h-[85vh] flex flex-col border bg-light-primary dark:bg-dark-primary border-light-secondary dark:border-dark-secondary rounded-lg">
<div className="px-6 pt-6 pb-4">
<h3 className="text-black/90 dark:text-white/90 font-medium text-sm">
Add new {type === 'chat' ? 'chat' : 'embedding'} model
</h3>
</div>
<div className="border-t border-light-200 dark:border-dark-200" />
<div className="flex-1 overflow-y-auto px-6 py-4">
<form
onSubmit={handleSubmit}
className="flex flex-col h-full"
>
<div className="flex flex-col space-y-4 flex-1">
<div className="flex flex-col items-start space-y-2">
<label className="text-xs text-black/70 dark:text-white/70">
Model name*
</label>
<input
value={modelName}
onChange={(e) => setModelName(e.target.value)}
className="w-full rounded-lg border border-light-200 dark:border-dark-200 bg-light-primary dark:bg-dark-primary px-4 py-3 text-[13px] text-black/80 dark:text-white/80 placeholder:text-black/40 dark:placeholder:text-white/40 focus-visible:outline-none focus-visible:border-light-300 dark:focus-visible:border-dark-300 transition-colors disabled:cursor-not-allowed disabled:opacity-60"
placeholder="e.g., GPT-4"
type="text"
required
/>
</div>
<div className="flex flex-col items-start space-y-2">
<label className="text-xs text-black/70 dark:text-white/70">
Model key*
</label>
<input
value={modelKey}
onChange={(e) => setModelKey(e.target.value)}
className="w-full rounded-lg border border-light-200 dark:border-dark-200 bg-light-primary dark:bg-dark-primary px-4 py-3 text-[13px] text-black/80 dark:text-white/80 placeholder:text-black/40 dark:placeholder:text-white/40 focus-visible:outline-none focus-visible:border-light-300 dark:focus-visible:border-dark-300 transition-colors disabled:cursor-not-allowed disabled:opacity-60"
placeholder="e.g., gpt-4"
type="text"
required
/>
</div>
</div>
<div className="border-t border-light-200 dark:border-dark-200 -mx-6 my-4" />
<div className="flex justify-end">
<button
type="submit"
disabled={loading}
className="px-4 py-2 rounded-lg text-[13px] bg-[#EA580C] text-white font-medium disabled:opacity-85 hover:opacity-85 active:scale-95 transition duration-200"
>
{loading ? 'Adding…' : 'Add Model'}
</button>
</div>
</form>
</div>
</DialogPanel>
</motion.div>
</Dialog>
)}
</AnimatePresence>
</>
);
};
export default AddModel;

View File

@@ -0,0 +1,212 @@
import {
Description,
Dialog,
DialogPanel,
DialogTitle,
} from '@headlessui/react';
import { Plus } from 'lucide-react';
import { useMemo, useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import {
ConfigModelProvider,
ModelProviderUISection,
StringUIConfigField,
UIConfigField,
} from '@/lib/config/types';
import Select from '@/components/ui/Select';
import { toast } from 'sonner';
const AddProvider = ({
modelProviders,
setProviders,
}: {
modelProviders: ModelProviderUISection[];
setProviders: React.Dispatch<React.SetStateAction<ConfigModelProvider[]>>;
}) => {
const [open, setOpen] = useState(false);
const [selectedProvider, setSelectedProvider] = useState<null | string>(
modelProviders[0]?.key || null,
);
const [config, setConfig] = useState<Record<string, any>>({});
const [name, setName] = useState('');
const [loading, setLoading] = useState(false);
const providerConfigMap = useMemo(() => {
const map: Record<string, { name: string; fields: UIConfigField[] }> = {};
modelProviders.forEach((p) => {
map[p.key] = {
name: p.name,
fields: p.fields,
};
});
return map;
}, [modelProviders]);
const selectedProviderFields = useMemo(() => {
if (!selectedProvider) return [];
const providerFields = providerConfigMap[selectedProvider]?.fields || [];
const config: Record<string, any> = {};
providerFields.forEach((field) => {
config[field.key] = field.default || '';
});
setConfig(config);
return providerFields;
}, [selectedProvider, providerConfigMap]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const res = await fetch('/api/providers', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
type: selectedProvider,
name: name,
config: config,
}),
});
if (!res.ok) {
throw new Error('Failed to add provider');
}
const data: ConfigModelProvider = (await res.json()).provider;
setProviders((prev) => [...prev, data]);
toast.success('Connection added successfully.');
} catch (error) {
console.error('Error adding provider:', error);
toast.error('Failed to add connection.');
} finally {
setLoading(false);
setOpen(false);
}
};
return (
<>
<button
onClick={() => setOpen(true)}
className="px-3 md:px-4 py-1.5 md:py-2 rounded-lg text-xs sm:text-xs border border-light-200 dark:border-dark-200 text-black dark:text-white bg-light-secondary/50 dark:bg-dark-secondary/50 hover:bg-light-secondary hover:dark:bg-dark-secondary hover:border-light-300 hover:dark:border-dark-300 flex flex-row items-center space-x-1 active:scale-95 transition duration-200"
>
<Plus className="w-3.5 h-3.5 md:w-4 md:h-4" />
<span>Add Connection</span>
</button>
<AnimatePresence>
{open && (
<Dialog
static
open={open}
onClose={() => setOpen(false)}
className="relative z-[60]"
>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.1 }}
className="fixed inset-0 flex w-screen items-center justify-center p-4 bg-black/30 backdrop-blur-sm"
>
<DialogPanel className="w-full mx-4 lg:w-[600px] max-h-[85vh] flex flex-col border bg-light-primary dark:bg-dark-primary border-light-secondary dark:border-dark-secondary rounded-lg">
<form onSubmit={handleSubmit} className="flex flex-col flex-1">
<div className="px-6 pt-6 pb-4">
<h3 className="text-black/90 dark:text-white/90 font-medium text-sm">
Add new connection
</h3>
</div>
<div className="border-t border-light-200 dark:border-dark-200" />
<div className="flex-1 overflow-y-auto px-6 py-4">
<div className="flex flex-col space-y-4">
<div className="flex flex-col items-start space-y-2">
<label className="text-xs text-black/70 dark:text-white/70">
Select connection type
</label>
<Select
value={selectedProvider ?? ''}
onChange={(e) => setSelectedProvider(e.target.value)}
options={Object.entries(providerConfigMap).map(
([key, val]) => {
return {
label: val.name,
value: key,
};
},
)}
/>
</div>
<div
key="name"
className="flex flex-col items-start space-y-2"
>
<label className="text-xs text-black/70 dark:text-white/70">
Connection Name*
</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full rounded-lg border border-light-200 dark:border-dark-200 bg-light-primary dark:bg-dark-primary px-4 py-3 pr-10 text-sm text-black/80 dark:text-white/80 placeholder:text-black/40 dark:placeholder:text-white/40 focus-visible:outline-none focus-visible:border-light-300 dark:focus-visible:border-dark-300 transition-colors disabled:cursor-not-allowed disabled:opacity-60"
placeholder={'e.g., My OpenAI Connection'}
type="text"
required={true}
/>
</div>
{selectedProviderFields.map((field: UIConfigField) => (
<div
key={field.key}
className="flex flex-col items-start space-y-2"
>
<label className="text-xs text-black/70 dark:text-white/70">
{field.name}
{field.required && '*'}
</label>
<input
value={config[field.key] ?? field.default ?? ''}
onChange={(event) =>
setConfig((prev) => ({
...prev,
[field.key]: event.target.value,
}))
}
className="w-full rounded-lg border border-light-200 dark:border-dark-200 bg-light-primary dark:bg-dark-primary px-4 py-3 pr-10 text-[13px] text-black/80 dark:text-white/80 placeholder:text-black/40 dark:placeholder:text-white/40 focus-visible:outline-none focus-visible:border-light-300 dark:focus-visible:border-dark-300 transition-colors disabled:cursor-not-allowed disabled:opacity-60"
placeholder={
(field as StringUIConfigField).placeholder
}
type="text"
required={field.required}
/>
</div>
))}
</div>
</div>
<div className="border-t border-light-200 dark:border-dark-200" />
<div className="px-6 py-4 flex justify-end">
<button
type="submit"
disabled={loading}
className="px-4 py-2 rounded-lg text-[13px] bg-[#EA580C] text-white font-medium disabled:opacity-85 hover:opacity-85 active:scale-95 transition duration-200"
>
{loading ? 'Adding…' : 'Add Connection'}
</button>
</div>
</form>
</DialogPanel>
</motion.div>
</Dialog>
)}
</AnimatePresence>
</>
);
};
export default AddProvider;

View File

@@ -0,0 +1,115 @@
import { Dialog, DialogPanel } from '@headlessui/react';
import { Trash2 } from 'lucide-react';
import { useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { ConfigModelProvider } from '@/lib/config/types';
import { toast } from 'sonner';
const DeleteProvider = ({
modelProvider,
setProviders,
}: {
modelProvider: ConfigModelProvider;
setProviders: React.Dispatch<React.SetStateAction<ConfigModelProvider[]>>;
}) => {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const handleDelete = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const res = await fetch(`/api/providers/${modelProvider.id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
});
if (!res.ok) {
throw new Error('Failed to delete provider');
}
setProviders((prev) => {
return prev.filter((p) => p.id !== modelProvider.id);
});
toast.success('Connection deleted successfully.');
} catch (error) {
console.error('Error deleting provider:', error);
toast.error('Failed to delete connection.');
} finally {
setLoading(false);
}
};
return (
<>
<button
onClick={(e) => {
e.stopPropagation();
setOpen(true);
}}
className="group p-1.5 rounded-md hover:bg-light-200 hover:dark:bg-dark-200 transition-colors group"
title="Delete connection"
>
<Trash2
size={14}
className="text-black/60 dark:text-white/60 group-hover:text-red-500 group-hover:dark:text-red-400"
/>
</button>
<AnimatePresence>
{open && (
<Dialog
static
open={open}
onClose={() => setOpen(false)}
className="relative z-[60]"
>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.1 }}
className="fixed inset-0 flex w-screen items-center justify-center p-4 bg-black/30 backdrop-blur-sm"
>
<DialogPanel className="w-full mx-4 lg:w-[600px] max-h-[85vh] flex flex-col border bg-light-primary dark:bg-dark-primary border-light-secondary dark:border-dark-secondary rounded-lg">
<div className="px-6 pt-6 pb-4">
<h3 className="text-black/90 dark:text-white/90 font-medium">
Delete connection
</h3>
</div>
<div className="border-t border-light-200 dark:border-dark-200" />
<div className="flex-1 overflow-y-auto px-6 py-4">
<p className="text-sm text-black/60 dark:text-white/60">
Are you sure you want to delete the connection &quot;
{modelProvider.name}&quot;? This action cannot be undone.
All associated models will also be removed.
</p>
</div>
<div className="px-6 py-6 flex justify-end space-x-2">
<button
disabled={loading}
onClick={() => setOpen(false)}
className="px-4 py-2 rounded-lg text-sm border border-light-200 dark:border-dark-200 text-black dark:text-white bg-light-secondary/50 dark:bg-dark-secondary/50 hover:bg-light-secondary hover:dark:bg-dark-secondary hover:border-light-300 hover:dark:border-dark-300 flex flex-row items-center space-x-1 active:scale-95 transition duration-200"
>
Cancel
</button>
<button
disabled={loading}
onClick={handleDelete}
className="px-4 py-2 rounded-lg text-sm bg-red-500 text-white font-medium disabled:opacity-85 hover:opacity-85 active:scale-95 transition duration-200"
>
{loading ? 'Deleting…' : 'Delete'}
</button>
</div>
</DialogPanel>
</motion.div>
</Dialog>
)}
</AnimatePresence>
</>
);
};
export default DeleteProvider;

View File

@@ -0,0 +1,224 @@
import { UIConfigField, ConfigModelProvider } from '@/lib/config/types';
import { cn } from '@/lib/utils';
import { AnimatePresence, motion } from 'framer-motion';
import { AlertCircle, Plug2, Plus, Pencil, Trash2, X } from 'lucide-react';
import { useState } from 'react';
import { toast } from 'sonner';
import AddModel from './AddModelDialog';
import UpdateProvider from './UpdateProviderDialog';
import DeleteProvider from './DeleteProviderDialog';
const ModelProvider = ({
modelProvider,
setProviders,
fields,
}: {
modelProvider: ConfigModelProvider;
fields: UIConfigField[];
setProviders: React.Dispatch<React.SetStateAction<ConfigModelProvider[]>>;
}) => {
const [open, setOpen] = useState(true);
const handleModelDelete = async (
type: 'chat' | 'embedding',
modelKey: string,
) => {
try {
const res = await fetch(`/api/providers/${modelProvider.id}/models`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ key: modelKey, type: type }),
});
if (!res.ok) {
throw new Error('Failed to delete model: ' + (await res.text()));
}
setProviders(
(prev) =>
prev.map((provider) => {
if (provider.id === modelProvider.id) {
return {
...provider,
...(type === 'chat'
? {
chatModels: provider.chatModels.filter(
(m) => m.key !== modelKey,
),
}
: {
embeddingModels: provider.embeddingModels.filter(
(m) => m.key !== modelKey,
),
}),
};
}
return provider;
}) as ConfigModelProvider[],
);
toast.success('Model deleted successfully.');
} catch (err) {
console.error('Failed to delete model', err);
toast.error('Failed to delete model.');
}
};
const modelCount =
modelProvider.chatModels.filter((m) => m.key !== 'error').length +
modelProvider.embeddingModels.filter((m) => m.key !== 'error').length;
const hasError =
modelProvider.chatModels.some((m) => m.key === 'error') ||
modelProvider.embeddingModels.some((m) => m.key === 'error');
return (
<div
key={modelProvider.id}
className="border border-light-200 dark:border-dark-200 rounded-lg overflow-hidden bg-light-primary dark:bg-dark-primary"
>
<div className="px-5 py-3.5 flex flex-row justify-between w-full items-center border-b border-light-200 dark:border-dark-200 bg-light-secondary/30 dark:bg-dark-secondary/30">
<div className="flex items-center gap-2.5">
<div className="p-1.5 rounded-md bg-[#EA580C]/10 dark:bg-[#EA580C]/10">
<Plug2 size={14} className="text-[#EA580C]" />
</div>
<div className="flex flex-col">
<p className="text-sm lg:text-sm text-black dark:text-white font-medium">
{modelProvider.name}
</p>
{modelCount > 0 && (
<p className="text-[10px] lg:text-[11px] text-black/50 dark:text-white/50">
{modelCount} model{modelCount !== 1 ? 's' : ''} configured
</p>
)}
</div>
</div>
<div className="flex flex-row items-center gap-1">
<UpdateProvider
fields={fields}
modelProvider={modelProvider}
setProviders={setProviders}
/>
<DeleteProvider
modelProvider={modelProvider}
setProviders={setProviders}
/>
</div>
</div>
<div className="flex flex-col gap-y-4 px-5 py-4">
<div className="flex flex-col gap-y-2">
<div className="flex flex-row w-full justify-between items-center">
<p className="text-[11px] lg:text-[11px] font-medium text-black/70 dark:text-white/70 uppercase tracking-wide">
Chat Models
</p>
{!modelProvider.chatModels.some((m) => m.key === 'error') && (
<AddModel
providerId={modelProvider.id}
setProviders={setProviders}
type="chat"
/>
)}
</div>
<div className="flex flex-col gap-2">
{modelProvider.chatModels.some((m) => m.key === 'error') ? (
<div className="flex flex-row items-center gap-2 text-xs lg:text-xs text-red-500 dark:text-red-400 rounded-lg bg-red-50 dark:bg-red-950/20 px-3 py-2 border border-red-200 dark:border-red-900/30">
<AlertCircle size={16} className="shrink-0" />
<span className="break-words">
{
modelProvider.chatModels.find((m) => m.key === 'error')
?.name
}
</span>
</div>
) : modelProvider.chatModels.filter((m) => m.key !== 'error')
.length === 0 && !hasError ? (
<div className="flex flex-col items-center justify-center py-4 px-4 rounded-lg border-2 border-dashed border-light-200 dark:border-dark-200 bg-light-secondary/20 dark:bg-dark-secondary/20">
<p className="text-xs text-black/50 dark:text-white/50 text-center">
No chat models configured
</p>
</div>
) : modelProvider.chatModels.filter((m) => m.key !== 'error')
.length > 0 ? (
<div className="flex flex-row flex-wrap gap-2">
{modelProvider.chatModels.map((model, index) => (
<div
key={`${modelProvider.id}-chat-${model.key}-${index}`}
className="flex flex-row items-center space-x-1.5 text-xs lg:text-xs text-black/70 dark:text-white/70 rounded-lg bg-light-secondary dark:bg-dark-secondary px-3 py-1.5 border border-light-200 dark:border-dark-200"
>
<span>{model.name}</span>
<button
onClick={() => {
handleModelDelete('chat', model.key);
}}
className="hover:text-red-500 dark:hover:text-red-400 transition-colors"
>
<X size={12} />
</button>
</div>
))}
</div>
) : null}
</div>
</div>
<div className="flex flex-col gap-y-2">
<div className="flex flex-row w-full justify-between items-center">
<p className="text-[11px] lg:text-[11px] font-medium text-black/70 dark:text-white/70 uppercase tracking-wide">
Embedding Models
</p>
{!modelProvider.embeddingModels.some((m) => m.key === 'error') && (
<AddModel
providerId={modelProvider.id}
setProviders={setProviders}
type="embedding"
/>
)}
</div>
<div className="flex flex-col gap-2">
{modelProvider.embeddingModels.some((m) => m.key === 'error') ? (
<div className="flex flex-row items-center gap-2 text-xs lg:text-xs text-red-500 dark:text-red-400 rounded-lg bg-red-50 dark:bg-red-950/20 px-3 py-2 border border-red-200 dark:border-red-900/30">
<AlertCircle size={16} className="shrink-0" />
<span className="break-words">
{
modelProvider.embeddingModels.find((m) => m.key === 'error')
?.name
}
</span>
</div>
) : modelProvider.embeddingModels.filter((m) => m.key !== 'error')
.length === 0 && !hasError ? (
<div className="flex flex-col items-center justify-center py-4 px-4 rounded-lg border-2 border-dashed border-light-200 dark:border-dark-200 bg-light-secondary/20 dark:bg-dark-secondary/20">
<p className="text-xs text-black/50 dark:text-white/50 text-center">
No embedding models configured
</p>
</div>
) : modelProvider.embeddingModels.filter((m) => m.key !== 'error')
.length > 0 ? (
<div className="flex flex-row flex-wrap gap-2">
{modelProvider.embeddingModels.map((model, index) => (
<div
key={`${modelProvider.id}-embedding-${model.key}-${index}`}
className="flex flex-row items-center space-x-1.5 text-xs lg:text-xs text-black/70 dark:text-white/70 rounded-lg bg-light-secondary dark:bg-dark-secondary px-3 py-1.5 border border-light-200 dark:border-dark-200"
>
<span>{model.name}</span>
<button
onClick={() => {
handleModelDelete('embedding', model.key);
}}
className="hover:text-red-500 dark:hover:text-red-400 transition-colors"
>
<X size={12} />
</button>
</div>
))}
</div>
) : null}
</div>
</div>
</div>
</div>
);
};
export default ModelProvider;

View File

@@ -0,0 +1,98 @@
import Select from '@/components/ui/Select';
import { ConfigModelProvider } from '@/lib/config/types';
import { useChat } from '@/lib/hooks/useChat';
import { useState } from 'react';
import { toast } from 'sonner';
const ModelSelect = ({
providers,
type,
}: {
providers: ConfigModelProvider[];
type: 'chat' | 'embedding';
}) => {
const [selectedModel, setSelectedModel] = useState<string>(
type === 'chat'
? `${localStorage.getItem('chatModelProviderId')}/${localStorage.getItem('chatModelKey')}`
: `${localStorage.getItem('embeddingModelProviderId')}/${localStorage.getItem('embeddingModelKey')}`,
);
const [loading, setLoading] = useState(false);
const { setChatModelProvider, setEmbeddingModelProvider } = useChat();
const handleSave = async (newValue: string) => {
setLoading(true);
setSelectedModel(newValue);
try {
if (type === 'chat') {
const providerId = newValue.split('/')[0];
const modelKey = newValue.split('/').slice(1).join('/');
localStorage.setItem('chatModelProviderId', providerId);
localStorage.setItem('chatModelKey', modelKey);
setChatModelProvider({
providerId: providerId,
key: modelKey,
});
} else {
const providerId = newValue.split('/')[0];
const modelKey = newValue.split('/').slice(1).join('/');
localStorage.setItem('embeddingModelProviderId', providerId);
localStorage.setItem('embeddingModelKey', modelKey);
setEmbeddingModelProvider({
providerId: providerId,
key: modelKey,
});
}
} catch (error) {
console.error('Error saving config:', error);
toast.error('Failed to save configuration.');
} finally {
setLoading(false);
}
};
return (
<section className="rounded-xl border border-light-200 bg-light-primary/80 p-4 lg:p-6 transition-colors dark:border-dark-200 dark:bg-dark-primary/80">
<div className="space-y-3 lg:space-y-5">
<div>
<h4 className="text-sm lg:text-sm text-black dark:text-white">
Select {type === 'chat' ? 'Chat Model' : 'Embedding Model'}
</h4>
<p className="text-[11px] lg:text-xs text-black/50 dark:text-white/50">
{type === 'chat'
? 'Choose which model to use for generating responses'
: 'Choose which model to use for generating embeddings'}
</p>
</div>
<Select
value={selectedModel}
onChange={(event) => handleSave(event.target.value)}
options={
type === 'chat'
? providers.flatMap((provider) =>
provider.chatModels.map((model) => ({
value: `${provider.id}/${model.key}`,
label: `${provider.name} - ${model.name}`,
})),
)
: providers.flatMap((provider) =>
provider.embeddingModels.map((model) => ({
value: `${provider.id}/${model.key}`,
label: `${provider.name} - ${model.name}`,
})),
)
}
className="!text-xs lg:!text-[13px]"
loading={loading}
disabled={loading}
/>
</div>
</section>
);
};
export default ModelSelect;

View File

@@ -0,0 +1,91 @@
import React, { useState } from 'react';
import AddProvider from './AddProviderDialog';
import {
ConfigModelProvider,
ModelProviderUISection,
UIConfigField,
} from '@/lib/config/types';
import ModelProvider from './ModelProvider';
import ModelSelect from './ModelSelect';
const Models = ({
fields,
values,
}: {
fields: ModelProviderUISection[];
values: ConfigModelProvider[];
}) => {
const [providers, setProviders] = useState<ConfigModelProvider[]>(values);
return (
<div className="flex-1 space-y-6 overflow-y-auto py-6">
<div className="flex flex-col px-6 gap-y-4">
<h3 className="text-xs lg:text-xs text-black/70 dark:text-white/70">
Select models
</h3>
<ModelSelect
providers={values.filter((p) =>
p.chatModels.some((m) => m.key != 'error'),
)}
type="chat"
/>
<ModelSelect
providers={values.filter((p) =>
p.embeddingModels.some((m) => m.key != 'error'),
)}
type="embedding"
/>
</div>
<div className="border-t border-light-200 dark:border-dark-200" />
<div className="flex flex-row justify-between items-center px-6 ">
<p className="text-xs lg:text-xs text-black/70 dark:text-white/70">
Manage connections
</p>
<AddProvider modelProviders={fields} setProviders={setProviders} />
</div>
<div className="flex flex-col px-6 gap-y-4">
{providers.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 px-4 rounded-lg border-2 border-dashed border-light-200 dark:border-dark-200 bg-light-secondary/10 dark:bg-dark-secondary/10">
<div className="p-3 rounded-full bg-[#EA580C]/10 dark:bg-[#EA580C]/10 mb-3">
<svg
xmlns="http://www.w3.org/2000/svg"
className="w-8 h-8 text-[#EA580C]"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
</div>
<p className="text-sm font-medium text-black/70 dark:text-white/70 mb-1">
No connections yet
</p>
<p className="text-xs text-black/50 dark:text-white/50 text-center max-w-sm mb-4">
Add your first connection to start using AI models. Connect to
OpenAI, Anthropic, Ollama, and more.
</p>
</div>
) : (
providers.map((provider) => (
<ModelProvider
key={`provider-${provider.id}`}
fields={
(fields.find((f) => f.key === provider.type)?.fields ??
[]) as UIConfigField[]
}
modelProvider={provider}
setProviders={setProviders}
/>
))
)}
</div>
</div>
);
};
export default Models;

View File

@@ -0,0 +1,184 @@
import { Dialog, DialogPanel } from '@headlessui/react';
import { Pencil } from 'lucide-react';
import { useEffect, useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import {
ConfigModelProvider,
StringUIConfigField,
UIConfigField,
} from '@/lib/config/types';
import { toast } from 'sonner';
const UpdateProvider = ({
modelProvider,
fields,
setProviders,
}: {
fields: UIConfigField[];
modelProvider: ConfigModelProvider;
setProviders: React.Dispatch<React.SetStateAction<ConfigModelProvider[]>>;
}) => {
const [open, setOpen] = useState(false);
const [config, setConfig] = useState<Record<string, any>>({});
const [name, setName] = useState(modelProvider.name);
const [loading, setLoading] = useState(false);
useEffect(() => {
const config: Record<string, any> = {
name: modelProvider.name,
};
fields.forEach((field) => {
config[field.key] =
modelProvider.config[field.key] || field.default || '';
});
setConfig(config);
}, [fields]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const res = await fetch(`/api/providers/${modelProvider.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: name,
config: config,
}),
});
if (!res.ok) {
throw new Error('Failed to update provider');
}
const data: ConfigModelProvider = (await res.json()).provider;
setProviders((prev) => {
return prev.map((p) => {
if (p.id === modelProvider.id) {
return data;
}
return p;
});
});
toast.success('Connection updated successfully.');
} catch (error) {
console.error('Error updating provider:', error);
toast.error('Failed to update connection.');
} finally {
setLoading(false);
setOpen(false);
}
};
return (
<>
<button
onClick={(e) => {
e.stopPropagation();
setOpen(true);
}}
className="group p-1.5 rounded-md hover:bg-light-200 hover:dark:bg-dark-200 transition-colors group"
>
<Pencil
size={14}
className="text-black/60 dark:text-white/60 group-hover:text-black group-hover:dark:text-white"
/>
</button>
<AnimatePresence>
{open && (
<Dialog
static
open={open}
onClose={() => setOpen(false)}
className="relative z-[60]"
>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.1 }}
className="fixed inset-0 flex w-screen items-center justify-center p-4 bg-black/30 backdrop-blur-sm"
>
<DialogPanel className="w-full mx-4 lg:w-[600px] max-h-[85vh] flex flex-col border bg-light-primary dark:bg-dark-primary border-light-secondary dark:border-dark-secondary rounded-lg">
<form onSubmit={handleSubmit} className="flex flex-col flex-1">
<div className="px-6 pt-6 pb-4">
<h3 className="text-black/90 dark:text-white/90 font-medium text-sm">
Update connection
</h3>
</div>
<div className="border-t border-light-200 dark:border-dark-200" />
<div className="flex-1 overflow-y-auto px-6 py-4">
<div className="flex flex-col space-y-4">
<div
key="name"
className="flex flex-col items-start space-y-2"
>
<label className="text-xs text-black/70 dark:text-white/70">
Connection Name*
</label>
<input
value={name}
onChange={(event) => setName(event.target.value)}
className="w-full rounded-lg border border-light-200 dark:border-dark-200 bg-light-primary dark:bg-dark-primary px-4 py-3 pr-10 text-sm text-black/80 dark:text-white/80 placeholder:text-black/40 dark:placeholder:text-white/40 focus-visible:outline-none focus-visible:border-light-300 dark:focus-visible:border-dark-300 transition-colors disabled:cursor-not-allowed disabled:opacity-60"
placeholder={'Connection Name'}
type="text"
required={true}
/>
</div>
{fields.map((field: UIConfigField) => (
<div
key={field.key}
className="flex flex-col items-start space-y-2"
>
<label className="text-xs text-black/70 dark:text-white/70">
{field.name}
{field.required && '*'}
</label>
<input
value={config[field.key] ?? field.default ?? ''}
onChange={(event) =>
setConfig((prev) => ({
...prev,
[field.key]: event.target.value,
}))
}
className="w-full rounded-lg border border-light-200 dark:border-dark-200 bg-light-primary dark:bg-dark-primary px-4 py-3 pr-10 text-[13px] text-black/80 dark:text-white/80 placeholder:text-black/40 dark:placeholder:text-white/40 focus-visible:outline-none focus-visible:border-light-300 dark:focus-visible:border-dark-300 transition-colors disabled:cursor-not-allowed disabled:opacity-60"
placeholder={
(field as StringUIConfigField).placeholder
}
type="text"
required={field.required}
/>
</div>
))}
</div>
</div>
<div className="border-t border-light-200 dark:border-dark-200" />
<div className="px-6 py-4 flex justify-end">
<button
type="submit"
disabled={loading}
className="px-4 py-2 rounded-lg text-[13px] bg-[#EA580C] text-white font-medium disabled:opacity-85 hover:opacity-85 active:scale-95 transition duration-200"
>
{loading ? 'Updating…' : 'Update Connection'}
</button>
</div>
</form>
</DialogPanel>
</motion.div>
</Dialog>
)}
</AnimatePresence>
</>
);
};
export default UpdateProvider;

View File

@@ -0,0 +1,32 @@
import { UIConfigField } from '@/lib/config/types';
import SettingsField from '../SettingsField';
const Personalization = ({
fields,
values,
token,
}: {
fields: UIConfigField[];
values: Record<string, unknown>;
token?: string | null;
}) => {
return (
<div className="flex-1 space-y-6 overflow-y-auto px-6 py-6">
{fields.map((field) => (
<SettingsField
key={field.key}
field={field}
value={
(values?.[field.key] as string | undefined) ??
(field.scope === 'client' ? localStorage.getItem(field.key) : undefined) ??
field.default
}
dataAdd="personalization"
token={token}
/>
))}
</div>
);
};
export default Personalization;

View File

@@ -0,0 +1,32 @@
import { UIConfigField } from '@/lib/config/types';
import SettingsField from '../SettingsField';
const Preferences = ({
fields,
values,
token,
}: {
fields: UIConfigField[];
values: Record<string, unknown>;
token?: string | null;
}) => {
return (
<div className="flex-1 space-y-6 overflow-y-auto px-6 py-6">
{fields.map((field) => (
<SettingsField
key={field.key}
field={field}
value={
(values?.[field.key] as string | undefined) ??
(field.scope === 'client' ? localStorage.getItem(field.key) : undefined) ??
field.default
}
dataAdd="preferences"
token={token}
/>
))}
</div>
);
};
export default Preferences;

View File

@@ -0,0 +1,32 @@
import { UIConfigField } from '@/lib/config/types';
import SettingsField from '../SettingsField';
const Search = ({
fields,
values,
token,
}: {
fields: UIConfigField[];
values: Record<string, unknown>;
token?: string | null;
}) => {
return (
<div className="flex-1 space-y-6 overflow-y-auto px-6 py-6">
{fields.map((field) => (
<SettingsField
key={field.key}
field={field}
value={
(values?.[field.key] as string | undefined) ??
(field.scope === 'client' ? localStorage.getItem(field.key) : undefined) ??
field.default
}
dataAdd="search"
token={token}
/>
))}
</div>
);
};
export default Search;

View File

@@ -0,0 +1,29 @@
import { Settings } from 'lucide-react';
import { useState, useEffect } from 'react';
import SettingsDialogue from './SettingsDialogue';
import { AnimatePresence } from 'framer-motion';
import { getStoredAuthToken } from '@/lib/auth-client';
const SettingsButton = () => {
const [isOpen, setIsOpen] = useState<boolean>(false);
const [token, setToken] = useState<string | null>(null);
useEffect(() => {
setToken(getStoredAuthToken());
}, [isOpen]);
return (
<>
<div
className="p-2.5 rounded-full bg-light-200 text-black/70 dark:bg-dark-200 dark:text-white/70 hover:opacity-70 hover:scale-105 transition duration-200 cursor-pointer active:scale-95"
onClick={() => setIsOpen(true)}
>
<Settings size={19} className="cursor-pointer" />
</div>
<AnimatePresence>
{isOpen && <SettingsDialogue isOpen={isOpen} setIsOpen={setIsOpen} token={token} />}
</AnimatePresence>
</>
);
};
export default SettingsButton;

View File

@@ -0,0 +1,26 @@
import { Settings } from 'lucide-react';
import { useState, useEffect } from 'react';
import SettingsDialogue from './SettingsDialogue';
import { AnimatePresence } from 'framer-motion';
import { getStoredAuthToken } from '@/lib/auth-client';
const SettingsButtonMobile = () => {
const [isOpen, setIsOpen] = useState<boolean>(false);
const [token, setToken] = useState<string | null>(null);
useEffect(() => {
setToken(getStoredAuthToken());
}, [isOpen]);
return (
<>
<button className="lg:hidden" onClick={() => setIsOpen(true)}>
<Settings size={18} />
</button>
<AnimatePresence>
{isOpen && <SettingsDialogue isOpen={isOpen} setIsOpen={setIsOpen} token={token} />}
</AnimatePresence>
</>
);
};
export default SettingsButtonMobile;

View File

@@ -0,0 +1,256 @@
import { Dialog, DialogPanel } from '@headlessui/react';
import {
ArrowLeft,
ChevronLeft,
ExternalLink,
Search,
Sliders,
ToggleRight,
} from 'lucide-react';
import Preferences from './Sections/Preferences';
import { motion } from 'framer-motion';
import { useEffect, useState } from 'react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import SearchSection from './Sections/Search';
import Select from '@/components/ui/Select';
import Personalization from './Sections/Personalization';
// SaaS: провайдеры настраиваются в бэкенде, пользователь не видит Models
const baseSections = [
{
key: 'preferences',
name: 'Preferences',
description: 'Customize your application preferences.',
icon: Sliders,
component: Preferences,
dataAdd: 'preferences',
},
{
key: 'personalization',
name: 'Personalization',
description: 'Customize the behavior and tone of the model.',
icon: ToggleRight,
component: Personalization,
dataAdd: 'personalization',
},
{
key: 'search',
name: 'Search',
description: 'Manage search settings.',
icon: Search,
component: SearchSection,
dataAdd: 'search',
},
];
const SettingsDialogue = ({
isOpen,
setIsOpen,
token,
}: {
isOpen: boolean;
setIsOpen: (active: boolean) => void;
token?: string | null;
}) => {
const [isLoading, setIsLoading] = useState(true);
const [config, setConfig] = useState<{
values: Record<string, unknown>;
fields: Record<string, unknown>;
envOnlyMode?: boolean;
} | null>(null);
const sections = baseSections;
const [activeSection, setActiveSection] = useState<string>(sections[0].key);
const [selectedSection, setSelectedSection] = useState(sections[0]);
useEffect(() => {
setSelectedSection(sections.find((s) => s.key === activeSection) ?? sections[0]);
}, [activeSection, sections]);
useEffect(() => {
if (isOpen) {
const fetchConfig = async () => {
try {
const [configRes, profileRes] = await Promise.all([
fetch('/api/config', {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
}),
token
? fetch('/api/v1/profile', {
headers: { Authorization: `Bearer ${token}` },
})
: Promise.resolve(null),
]);
const data = await configRes.json();
if (!configRes.ok) throw new Error('Failed to load config');
if (token && profileRes?.ok) {
const profile = await profileRes.json();
if (profile?.preferences && typeof data.values === 'object') {
data.values = {
...data.values,
preferences: { ...((data.values.preferences as object) ?? {}), ...profile.preferences },
};
}
if (profile?.personalization && typeof data.values === 'object') {
data.values = {
...data.values,
personalization: { ...((data.values.personalization as object) ?? {}), ...profile.personalization },
};
}
}
setConfig(data);
} catch (error) {
console.error('Error fetching config:', error);
toast.error('Failed to load configuration.');
} finally {
setIsLoading(false);
}
};
fetchConfig();
}
}, [isOpen, token]);
return (
<Dialog
open={isOpen}
onClose={() => setIsOpen(false)}
className="relative z-50"
>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.1 }}
className="fixed inset-0 flex w-screen items-center justify-center p-4 bg-black/30 backdrop-blur-sm h-screen"
>
<DialogPanel className="space-y-4 border border-light-200 dark:border-dark-200 bg-light-primary dark:bg-dark-primary backdrop-blur-lg rounded-xl h-[calc(100vh-2%)] w-[calc(100vw-2%)] md:h-[calc(100vh-7%)] md:w-[calc(100vw-7%)] lg:h-[calc(100vh-20%)] lg:w-[calc(100vw-30%)] overflow-hidden flex flex-col">
{isLoading ? (
<div className="flex flex-col gap-3 p-6 h-full w-full overflow-y-auto">
<div className="h-8 w-48 rounded-lg bg-light-200 dark:bg-dark-200 animate-pulse" />
<div className="flex gap-4 mt-4">
<div className="w-48 space-y-2">
{[1, 2, 3, 4].map((i) => (
<div
key={i}
className="h-10 rounded-lg bg-light-200 dark:bg-dark-200 animate-pulse"
/>
))}
</div>
<div className="flex-1 space-y-4">
<div className="h-24 rounded-lg bg-light-200 dark:bg-dark-200 animate-pulse" />
<div className="h-32 rounded-lg bg-light-200 dark:bg-dark-200 animate-pulse" />
</div>
</div>
</div>
) : (
<div className="flex flex-1 inset-0 h-full overflow-hidden">
<div className="hidden lg:flex flex-col justify-between w-[240px] border-r border-white-200 dark:border-dark-200 h-full px-3 pt-3 overflow-y-auto">
<div className="flex flex-col">
<button
onClick={() => setIsOpen(false)}
className="group flex flex-row items-center hover:bg-light-200 hover:dark:bg-dark-200 p-2 rounded-lg"
>
<ChevronLeft
size={18}
className="text-black/50 dark:text-white/50 group-hover:text-black/70 group-hover:dark:text-white/70"
/>
<p className="text-black/50 dark:text-white/50 group-hover:text-black/70 group-hover:dark:text-white/70 text-[14px]">
Back
</p>
</button>
<div className="flex flex-col items-start space-y-1 mt-8">
{sections.map((section) => (
<button
key={section.dataAdd}
className={cn(
`flex flex-row items-center space-x-2 px-2 py-1.5 rounded-lg w-full text-sm hover:bg-light-200 hover:dark:bg-dark-200 transition duration-200 active:scale-95`,
activeSection === section.key
? 'bg-light-200 dark:bg-dark-200 text-black/90 dark:text-white/90'
: ' text-black/70 dark:text-white/70',
)}
onClick={() => setActiveSection(section.key)}
>
<section.icon size={17} />
<p>{section.name}</p>
</button>
))}
</div>
</div>
<div className="flex flex-col space-y-1 py-[18px] px-2">
<p className="text-xs text-black/70 dark:text-white/70">
Version: {process.env.NEXT_PUBLIC_VERSION}
</p>
<a
href="https://github.com/itzcrazykns/gooseek"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-black/70 dark:text-white/70 flex flex-row space-x-1 items-center transition duration-200 hover:text-black/90 hover:dark:text-white/90"
>
<span>GitHub</span>
<ExternalLink size={12} />
</a>
</div>
</div>
<div className="w-full flex flex-col overflow-hidden">
<div className="flex flex-row lg:hidden w-full justify-between px-[20px] my-4 flex-shrink-0">
<button
onClick={() => setIsOpen(false)}
className="group flex flex-row items-center hover:bg-light-200 hover:dark:bg-dark-200 rounded-lg mr-[40%]"
>
<ArrowLeft
size={18}
className="text-black/50 dark:text-white/50 group-hover:text-black/70 group-hover:dark:text-white/70"
/>
</button>
<Select
options={sections.map((section) => {
return {
value: section.key,
key: section.key,
label: section.name,
};
})}
value={activeSection}
onChange={(e) => {
setActiveSection(e.target.value);
}}
className="!text-xs lg:!text-sm"
/>
</div>
{selectedSection.component && config && (
<div className="flex flex-1 flex-col overflow-hidden">
<div className="border-b border-light-200/60 px-6 pb-6 lg:pt-6 dark:border-dark-200/60 flex-shrink-0">
<div className="flex flex-col">
<h4 className="font-medium text-black dark:text-white text-sm lg:text-sm">
{selectedSection.name}
</h4>
<p className="text-[11px] lg:text-xs text-black/50 dark:text-white/50">
{selectedSection.description}
</p>
</div>
</div>
<div className="flex-1 overflow-y-auto">
<selectedSection.component
fields={config.fields[selectedSection.dataAdd] as never}
values={config.values[selectedSection.dataAdd] as never}
token={token}
/>
</div>
</div>
)}
</div>
</div>
)}
</DialogPanel>
</motion.div>
</Dialog>
);
};
export default SettingsDialogue;

View File

@@ -0,0 +1,410 @@
import {
SelectUIConfigField,
StringUIConfigField,
SwitchUIConfigField,
TextareaUIConfigField,
UIConfigField,
} from '@/lib/config/types';
import { useState } from 'react';
import Select from '../ui/Select';
import { toast } from 'sonner';
import { useTheme } from 'next-themes';
import { Switch } from '@headlessui/react';
const emitClientConfigChanged = () => {
if (typeof window !== 'undefined') {
window.dispatchEvent(new Event('client-config-changed'));
}
};
async function saveToProfile(
token: string,
dataAdd: string,
key: string,
value: unknown
): Promise<void> {
if (dataAdd !== 'preferences' && dataAdd !== 'personalization') return;
await fetch('/api/v1/profile', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ [dataAdd]: { [key]: value } }),
});
}
const SettingsSelect = ({
field,
value,
setValue,
dataAdd,
token,
}: {
field: SelectUIConfigField;
value?: unknown;
setValue: (value: unknown) => void;
dataAdd: string;
token?: string | null;
}) => {
const [loading, setLoading] = useState(false);
const { setTheme } = useTheme();
const handleSave = async (newValue: any) => {
setLoading(true);
setValue(newValue);
try {
if (field.scope === 'client') {
localStorage.setItem(field.key, newValue);
if (field.key === 'theme') {
setTheme(newValue);
}
emitClientConfigChanged();
if (token && (dataAdd === 'preferences' || dataAdd === 'personalization')) {
await saveToProfile(token, dataAdd, field.key, newValue);
}
} else {
const res = await fetch('/api/config', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
key: `${dataAdd}.${field.key}`,
value: newValue,
}),
});
if (!res.ok) {
console.error('Failed to save config:', await res.text());
throw new Error('Failed to save configuration');
}
}
} catch (error) {
console.error('Error saving config:', error);
toast.error('Failed to save configuration.');
} finally {
setTimeout(() => setLoading(false), 150);
}
};
return (
<section className="rounded-xl border border-light-200 bg-light-primary/80 p-4 lg:p-6 transition-colors dark:border-dark-200 dark:bg-dark-primary/80">
<div className="space-y-3 lg:space-y-5">
<div>
<h4 className="text-sm lg:text-sm text-black dark:text-white">
{field.name}
</h4>
<p className="text-[11px] lg:text-xs text-black/50 dark:text-white/50">
{field.description}
</p>
</div>
<Select
value={(value ?? '') as string}
onChange={(event) => handleSave(event.target.value)}
options={field.options.map((option) => ({
value: option.value,
label: option.name,
}))}
className="!text-xs lg:!text-sm"
loading={loading}
disabled={loading}
/>
</div>
</section>
);
};
const SettingsInput = ({
field,
value,
setValue,
dataAdd,
token,
}: {
field: StringUIConfigField;
value?: unknown;
setValue: (value: unknown) => void;
dataAdd: string;
token?: string | null;
}) => {
const [loading, setLoading] = useState(false);
const handleSave = async (newValue: any) => {
setLoading(true);
setValue(newValue);
try {
if (field.scope === 'client') {
localStorage.setItem(field.key, newValue);
emitClientConfigChanged();
if (token && (dataAdd === 'preferences' || dataAdd === 'personalization')) {
await saveToProfile(token, dataAdd, field.key, newValue);
}
} else {
const res = await fetch('/api/config', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
key: `${dataAdd}.${field.key}`,
value: newValue,
}),
});
if (!res.ok) {
console.error('Failed to save config:', await res.text());
throw new Error('Failed to save configuration');
}
}
} catch (error) {
console.error('Error saving config:', error);
toast.error('Failed to save configuration.');
} finally {
setTimeout(() => setLoading(false), 150);
}
};
return (
<section className="rounded-xl border border-light-200 bg-light-primary/80 p-4 lg:p-6 transition-colors dark:border-dark-200 dark:bg-dark-primary/80">
<div className="space-y-3 lg:space-y-5">
<div>
<h4 className="text-sm lg:text-sm text-black dark:text-white">
{field.name}
</h4>
<p className="text-[11px] lg:text-xs text-black/50 dark:text-white/50">
{field.description}
</p>
</div>
<div className="relative">
<input
value={String(value ?? field.default ?? '')}
onChange={(event) => setValue(event.target.value)}
onBlur={(event) => handleSave(event.target.value)}
className="w-full rounded-lg border border-light-200 dark:border-dark-200 bg-light-primary dark:bg-dark-primary px-3 py-2 lg:px-4 lg:py-3 pr-10 !text-xs lg:!text-[13px] text-black/80 dark:text-white/80 placeholder:text-black/40 dark:placeholder:text-white/40 focus-visible:outline-none focus-visible:border-light-300 dark:focus-visible:border-dark-300 transition-colors disabled:cursor-not-allowed disabled:opacity-60"
placeholder={field.placeholder}
type="text"
disabled={loading}
/>
</div>
</div>
</section>
);
};
const SettingsTextarea = ({
field,
value,
setValue,
dataAdd,
token,
}: {
field: TextareaUIConfigField;
value?: unknown;
setValue: (value: unknown) => void;
dataAdd: string;
token?: string | null;
}) => {
const [loading, setLoading] = useState(false);
const handleSave = async (newValue: unknown) => {
setLoading(true);
setValue(newValue);
try {
if (field.scope === 'client') {
localStorage.setItem(field.key, String(newValue));
emitClientConfigChanged();
if (token && (dataAdd === 'preferences' || dataAdd === 'personalization')) {
await saveToProfile(token, dataAdd, field.key, newValue);
}
} else {
const res = await fetch('/api/config', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
key: `${dataAdd}.${field.key}`,
value: newValue,
}),
});
if (!res.ok) {
console.error('Failed to save config:', await res.text());
throw new Error('Failed to save configuration');
}
}
} catch (error) {
console.error('Error saving config:', error);
toast.error('Failed to save configuration.');
} finally {
setTimeout(() => setLoading(false), 150);
}
};
return (
<section className="rounded-xl border border-light-200 bg-light-primary/80 p-4 lg:p-6 transition-colors dark:border-dark-200 dark:bg-dark-primary/80">
<div className="space-y-3 lg:space-y-5">
<div>
<h4 className="text-sm lg:text-sm text-black dark:text-white">
{field.name}
</h4>
<p className="text-[11px] lg:text-xs text-black/50 dark:text-white/50">
{field.description}
</p>
</div>
<div className="relative">
<textarea
value={String(value ?? field.default ?? '')}
onChange={(event) => setValue(event.target.value)}
onBlur={(event) => handleSave(event.target.value)}
className="w-full rounded-lg border border-light-200 dark:border-dark-200 bg-light-primary dark:bg-dark-primary px-3 py-2 lg:px-4 lg:py-3 pr-10 !text-xs lg:!text-[13px] text-black/80 dark:text-white/80 placeholder:text-black/40 dark:placeholder:text-white/40 focus-visible:outline-none focus-visible:border-light-300 dark:focus-visible:border-dark-300 transition-colors disabled:cursor-not-allowed disabled:opacity-60"
placeholder={field.placeholder}
rows={4}
disabled={loading}
/>
</div>
</div>
</section>
);
};
const SettingsSwitch = ({
field,
value,
setValue,
dataAdd,
token,
}: {
field: SwitchUIConfigField;
value?: unknown;
setValue: (value: unknown) => void;
dataAdd: string;
token?: string | null;
}) => {
const [loading, setLoading] = useState(false);
const handleSave = async (newValue: boolean) => {
setLoading(true);
setValue(newValue);
try {
if (field.scope === 'client') {
localStorage.setItem(field.key, String(newValue));
emitClientConfigChanged();
if (token && (dataAdd === 'preferences' || dataAdd === 'personalization')) {
await saveToProfile(token, dataAdd, field.key, newValue);
}
} else {
const res = await fetch('/api/config', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
key: `${dataAdd}.${field.key}`,
value: newValue,
}),
});
if (!res.ok) {
console.error('Failed to save config:', await res.text());
throw new Error('Failed to save configuration');
}
}
} catch (error) {
console.error('Error saving config:', error);
toast.error('Failed to save configuration.');
} finally {
setTimeout(() => setLoading(false), 150);
}
};
const isChecked = value === true || value === 'true';
return (
<section className="rounded-xl border border-light-200 bg-light-primary/80 p-4 lg:p-6 transition-colors dark:border-dark-200 dark:bg-dark-primary/80">
<div className="flex flex-row items-center space-x-3 lg:space-x-5 w-full justify-between">
<div>
<h4 className="text-sm lg:text-sm text-black dark:text-white">
{field.name}
</h4>
<p className="text-[11px] lg:text-xs text-black/50 dark:text-white/50">
{field.description}
</p>
</div>
<Switch
checked={isChecked}
onChange={handleSave}
disabled={loading}
className="group relative flex h-6 w-12 shrink-0 cursor-pointer rounded-full bg-light-200 dark:bg-white/10 p-1 duration-200 ease-in-out focus:outline-none transition-colors disabled:opacity-60 disabled:cursor-not-allowed data-[checked]:bg-[#EA580C] dark:data-[checked]:bg-[#EA580C]"
>
<span
aria-hidden="true"
className="pointer-events-none inline-block size-4 translate-x-0 rounded-full bg-white shadow-lg ring-0 transition duration-200 ease-in-out group-data-[checked]:translate-x-6"
/>
</Switch>
</div>
</section>
);
};
const SettingsField = ({
field,
value,
dataAdd,
token,
}: {
field: UIConfigField;
value: unknown;
dataAdd: string;
token?: string | null;
}) => {
const [val, setVal] = useState(value);
switch (field.type) {
case 'select':
return (
<SettingsSelect
field={field}
value={val}
setValue={setVal}
dataAdd={dataAdd}
token={token}
/>
);
case 'string':
return (
<SettingsInput
field={field}
value={val}
setValue={setVal}
dataAdd={dataAdd}
token={token}
/>
);
case 'textarea':
return (
<SettingsTextarea
field={field}
value={val}
setValue={setVal}
dataAdd={dataAdd}
token={token}
/>
);
case 'switch':
return (
<SettingsSwitch
field={field}
value={val}
setValue={setVal}
dataAdd={dataAdd}
token={token}
/>
);
default:
return <div>Unsupported field type: {field.type}</div>;
}
};
export default SettingsField;

View File

@@ -0,0 +1,806 @@
'use client';
import { cn } from '@/lib/utils';
import {
MessagesSquare,
Plus,
Search,
TrendingUp,
Plane,
FolderOpen,
User,
Stethoscope,
Building2,
Package,
SlidersHorizontal,
Baby,
GraduationCap,
HeartPulse,
Brain,
Trophy,
ShoppingCart,
Gamepad2,
Receipt,
Scale,
MoreHorizontal,
} from 'lucide-react';
import Link from 'next/link';
import { useSelectedLayoutSegments } from 'next/navigation';
import React, { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from '@/lib/localization/context';
import { getSidebarMenuConfig } from '@/lib/config/sidebarMenu';
import MenuSettingsPanel from './Sidebar/MenuSettingsPanel';
import Layout from './Layout';
interface ChatItem {
id: string;
title: string;
createdAt: string;
}
const VerticalIconContainer = ({ children }: { children: ReactNode }) => {
return <div className="flex flex-col items-center w-full">{children}</div>;
};
interface HistorySubmenuProps {
onMouseEnter: () => void;
onMouseLeave: () => void;
}
interface MoreSubmenuProps {
onMouseEnter: () => void;
onMouseLeave: () => void;
restIds: string[];
linkMap: Record<string, NavLink>;
itemLabels: Record<string, string>;
submenuStyle: { top: number; left: number };
isMobile: boolean;
cancelMoreHide: () => void;
}
const MoreSubmenu = ({
onMouseEnter,
onMouseLeave,
restIds,
linkMap,
itemLabels,
submenuStyle,
isMobile,
cancelMoreHide,
}: MoreSubmenuProps) => {
const { t } = useTranslation();
const [settingsHovered, setSettingsHovered] = useState(false);
return (
<div
className="min-w-[180px] w-56 h-screen overflow-y-auto bg-light-secondary dark:bg-dark-secondary border border-light-200 dark:border-dark-200 shadow-xl py-2 z-[9999]"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<nav className="flex flex-col">
{restIds.map((id) => {
const link = linkMap[id];
if (!link) return null;
const Icon = link.icon;
if (link.href) {
return (
<Link
key={id}
href={link.href}
className="px-4 py-2.5 text-sm text-black dark:text-white hover:bg-light-200 dark:hover:bg-dark-200 text-fade flex items-center gap-2 transition-colors"
title={link.label}
>
<Icon size={18} />
{link.label}
</Link>
);
}
return (
<span
key={id}
className="px-4 py-2.5 text-sm text-black/50 dark:text-white/50 flex items-center gap-2 cursor-default"
>
<Icon size={18} />
{link.label}
</span>
);
})}
<div
className="relative"
onMouseEnter={() => setSettingsHovered(true)}
onMouseLeave={() => setSettingsHovered(false)}
>
<button
type="button"
className="w-full px-4 py-2.5 text-sm text-left text-black dark:text-white hover:bg-light-200 dark:hover:bg-dark-200 transition-colors flex items-center gap-2"
>
<SlidersHorizontal size={18} />
{t('nav.configureMenu')}
</button>
{settingsHovered &&
typeof document !== 'undefined' &&
createPortal(
isMobile ? (
<div
className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/20 dark:bg-black/40"
onClick={() => setSettingsHovered(false)}
role="button"
tabIndex={0}
aria-label={t('common.close')}
>
<div
className="mx-4 max-h-[85vh] overflow-y-auto rounded-xl bg-light-secondary dark:bg-dark-secondary shadow-xl"
onClick={(e) => e.stopPropagation()}
onMouseEnter={cancelMoreHide}
onMouseLeave={() => setSettingsHovered(false)}
>
<MenuSettingsPanel
itemLabels={itemLabels}
onMouseEnter={cancelMoreHide}
onMouseLeave={() => {}}
/>
</div>
</div>
) : (
<div
style={{
top: submenuStyle.top,
left: submenuStyle.left + 220,
}}
className="fixed z-[10000]"
onMouseEnter={cancelMoreHide}
onMouseLeave={() => setSettingsHovered(false)}
>
<MenuSettingsPanel
itemLabels={itemLabels}
onMouseEnter={cancelMoreHide}
onMouseLeave={() => setSettingsHovered(false)}
/>
</div>
),
document.body,
)}
</div>
</nav>
</div>
);
};
const HistorySubmenu = ({ onMouseEnter, onMouseLeave }: HistorySubmenuProps) => {
const { t } = useTranslation();
const [chats, setChats] = useState<ChatItem[]>([]);
const [loading, setLoading] = useState(true);
const fetchChats = useCallback(async () => {
try {
const token =
typeof window !== 'undefined'
? localStorage.getItem('auth_token') ?? localStorage.getItem('access_token')
: null;
if (!token) {
const { getGuestChats } = await import('@/lib/guest-storage');
const guestChats = getGuestChats();
setChats(
guestChats.map((c) => ({
id: c.id,
title: c.title,
createdAt: c.createdAt,
sources: c.sources,
files: c.files,
})),
);
setLoading(false);
return;
}
const res = await fetch('/api/v1/library/threads', {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();
setChats((data.chats ?? []).reverse());
} catch {
setChats([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchChats();
}, [fetchChats]);
useEffect(() => {
const onMigrated = () => fetchChats();
window.addEventListener('gooseek:guest-migrated', onMigrated);
return () => window.removeEventListener('gooseek:guest-migrated', onMigrated);
}, []);
return (
<div
className="min-w-[180px] w-56 h-screen overflow-y-auto bg-light-secondary dark:bg-dark-secondary border border-light-200 dark:border-dark-200 shadow-xl py-2 z-[9999]"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{loading ? (
<div className="px-4 py-3 text-sm text-black/60 dark:text-white/60">
{t('common.loading')}
</div>
) : chats.length === 0 ? (
<div className="px-4 py-3 text-sm text-black/60 dark:text-white/60">
</div>
) : (
<nav className="flex flex-col">
{chats.map((chat) => (
<Link
key={chat.id}
href={`/c/${chat.id}`}
className="px-4 py-2.5 text-sm text-black dark:text-white hover:bg-light-200 dark:hover:bg-dark-200 text-fade block transition-colors"
title={chat.title}
>
{chat.title}
</Link>
))}
</nav>
)}
</div>
);
};
type NavLink = {
icon: React.ComponentType<{ size?: number | string; className?: string }>;
href?: string;
active: boolean;
label: string;
};
const Sidebar = ({ children }: { children: React.ReactNode }) => {
const segments = useSelectedLayoutSegments();
const { t } = useTranslation();
const [historyOpen, setHistoryOpen] = useState(false);
const [moreOpen, setMoreOpen] = useState(false);
const historyRef = useRef<HTMLDivElement>(null);
const moreRef = useRef<HTMLDivElement>(null);
const hideTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const moreHideRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [submenuStyle, setSubmenuStyle] = useState({ top: 0, left: 0 });
const [tooltip, setTooltip] = useState<{ label: string; x: number; y: number } | null>(null);
const [menuConfig, setMenuConfig] = useState(() =>
typeof window !== 'undefined' ? getSidebarMenuConfig() : { order: [], visible: {} },
);
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
if (typeof window === 'undefined') return;
const mq = window.matchMedia('(max-width: 1023px)');
setIsMobile(mq.matches);
const handler = () => setIsMobile(mq.matches);
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, []);
const discoverLink = {
icon: Search,
href: '/discover',
active: segments.includes('discover'),
label: t('nav.discover'),
};
const libraryLink = {
icon: MessagesSquare,
href: '/library',
active: segments.includes('library'),
label: t('nav.messageHistory'),
};
const financeLink = {
icon: TrendingUp,
href: '/finance',
active: segments.includes('finance'),
label: t('nav.finance'),
};
const travelLink = {
icon: Plane,
href: '/travel',
active: segments.includes('travel'),
label: t('nav.travel'),
};
const spacesLink = {
icon: FolderOpen,
href: '/spaces',
active: segments.includes('spaces'),
label: t('nav.spaces'),
};
const profileLink = {
icon: User,
href: '/profile',
active: segments.includes('profile'),
label: t('nav.profile'),
};
const placeholderLink = (
icon: React.ComponentType<{ size?: number | string; className?: string }>,
labelKey: string,
) => ({
icon,
href: undefined as string | undefined,
active: false,
label: t(labelKey),
});
const medicineLink = placeholderLink(Stethoscope, 'nav.medicine');
const realEstateLink = placeholderLink(Building2, 'nav.realEstate');
const goodsLink = placeholderLink(Package, 'nav.goods');
const childrenLink = placeholderLink(Baby, 'nav.children');
const educationLink = placeholderLink(GraduationCap, 'nav.education');
const healthLink = placeholderLink(HeartPulse, 'nav.health');
const psychologyLink = placeholderLink(Brain, 'nav.psychology');
const sportsLink = placeholderLink(Trophy, 'nav.sports');
const shoppingLink = placeholderLink(ShoppingCart, 'nav.shopping');
const gamesLink = placeholderLink(Gamepad2, 'nav.games');
const taxesLink = placeholderLink(Receipt, 'nav.taxes');
const legislationLink = placeholderLink(Scale, 'nav.legislation');
const placeholderLinks = [
medicineLink,
realEstateLink,
goodsLink,
childrenLink,
educationLink,
healthLink,
psychologyLink,
sportsLink,
shoppingLink,
gamesLink,
taxesLink,
legislationLink,
];
const linkMap: Record<string, NavLink> = {
discover: discoverLink,
library: libraryLink,
finance: financeLink,
travel: travelLink,
spaces: spacesLink,
medicine: medicineLink,
realEstate: realEstateLink,
goods: goodsLink,
children: childrenLink,
education: educationLink,
health: healthLink,
psychology: psychologyLink,
sports: sportsLink,
shopping: shoppingLink,
games: gamesLink,
taxes: taxesLink,
legislation: legislationLink,
profile: profileLink,
};
const itemLabels: Record<string, string> = Object.fromEntries(
Object.entries(linkMap).map(([id, link]) => [id, link.label]),
);
const orderedVisibleIds = menuConfig.order.filter((id) => menuConfig.visible[id] !== false);
const mainIds = orderedVisibleIds.filter((id) => id !== 'profile' && id !== 'spaces');
const topMainIds = mainIds.slice(0, 5);
const restMainIds = mainIds.slice(5);
const showSpaces = menuConfig.visible['spaces'] !== false;
const showProfile = menuConfig.visible['profile'] !== false;
const refreshMenuConfig = useCallback(() => {
setMenuConfig(getSidebarMenuConfig());
}, []);
useEffect(() => {
const handler = () => refreshMenuConfig();
window.addEventListener('client-config-changed', handler);
return () => window.removeEventListener('client-config-changed', handler);
}, [refreshMenuConfig]);
const handleHistoryMouseEnter = () => {
setTooltip(null);
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
hideTimeoutRef.current = null;
}
if (historyRef.current) {
const rect = historyRef.current.getBoundingClientRect();
setSubmenuStyle({ top: 0, left: rect.right + 4 });
}
setHistoryOpen(true);
};
const handleHistoryMouseLeave = () => {
hideTimeoutRef.current = setTimeout(() => setHistoryOpen(false), 150);
};
const cancelHide = () => {
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
hideTimeoutRef.current = null;
}
};
const handleMoreEnter = () => {
setTooltip(null);
if (moreHideRef.current) {
clearTimeout(moreHideRef.current);
moreHideRef.current = null;
}
if (moreRef.current) {
const rect = moreRef.current.getBoundingClientRect();
setSubmenuStyle({ top: 0, left: rect.right + 4 });
}
setMoreOpen(true);
};
const handleMoreLeave = () => {
moreHideRef.current = setTimeout(() => setMoreOpen(false), 150);
};
const cancelMoreHide = () => {
if (moreHideRef.current) {
clearTimeout(moreHideRef.current);
moreHideRef.current = null;
}
};
useEffect(() => {
return () => {
if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current);
if (moreHideRef.current) clearTimeout(moreHideRef.current);
};
}, []);
useEffect(() => {
if (!moreOpen || !isMobile) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setMoreOpen(false);
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [moreOpen, isMobile]);
const showTooltip = (label: string, e: React.MouseEvent<HTMLElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
setTooltip({ label, x: rect.right + 8, y: rect.top + rect.height / 2 });
};
const hideTooltip = () => setTooltip(null);
return (
<div>
{tooltip &&
typeof document !== 'undefined' &&
createPortal(
<div
className="fixed z-[9998] -translate-y-1/2 px-2.5 py-1.5 rounded-md bg-black/90 dark:bg-white/90 text-white dark:text-black text-xs font-medium whitespace-nowrap pointer-events-none"
style={{ left: tooltip.x, top: tooltip.y }}
>
{tooltip.label}
</div>,
document.body,
)}
<div className="hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-[56px] lg:flex-col border-r border-light-200 dark:border-dark-200">
<div className="flex grow flex-col items-center justify-start gap-y-1 overflow-y-auto bg-light-secondary dark:bg-dark-secondary px-2 py-6 shadow-sm shadow-light-200/10 dark:shadow-black/25">
<a
className="p-2.5 rounded-full bg-light-200 text-black/70 dark:bg-dark-200 dark:text-white/70 hover:opacity-70 hover:scale-105 transition duration-200 cursor-pointer"
href="/"
onMouseEnter={(e) => showTooltip(t('chat.newChat'), e)}
onMouseLeave={hideTooltip}
>
<Plus size={19} className="cursor-pointer" />
</a>
{showSpaces && (
<Link
href={spacesLink.href}
className={cn(
'relative flex flex-col items-center justify-center cursor-pointer w-full py-2 rounded-lg',
spacesLink.active
? 'text-black/70 dark:text-white/70'
: 'text-black/60 dark:text-white/60',
)}
onMouseEnter={(e) => showTooltip(spacesLink.label, e)}
onMouseLeave={hideTooltip}
>
<div
className={cn(
spacesLink.active && 'bg-light-200 dark:bg-dark-200',
'group rounded-lg hover:bg-light-200 hover:dark:bg-dark-200 transition duration-200',
)}
>
<spacesLink.icon
size={25}
className={cn(
!spacesLink.active && 'group-hover:scale-105',
'transition duration:200 m-1.5',
)}
/>
</div>
</Link>
)}
<VerticalIconContainer>
{topMainIds.map((id) => {
if (id === 'library') {
return (
<div
key={id}
ref={historyRef}
className="relative w-full"
onMouseEnter={handleHistoryMouseEnter}
onMouseLeave={handleHistoryMouseLeave}
>
<Link
href={libraryLink.href}
className={cn(
'relative flex flex-col items-center justify-center cursor-pointer w-full py-2 rounded-lg',
libraryLink.active
? 'text-black/70 dark:text-white/70'
: 'text-black/60 dark:text-white/60',
)}
onMouseEnter={(e) => showTooltip(libraryLink.label, e)}
onMouseLeave={hideTooltip}
>
<div
className={cn(
libraryLink.active && 'bg-light-200 dark:bg-dark-200',
'group rounded-lg hover:bg-light-200 hover:dark:bg-dark-200 transition duration-200',
)}
>
<libraryLink.icon
size={25}
className={cn(
!libraryLink.active && 'group-hover:scale-105',
'transition duration:200 m-1.5',
)}
/>
</div>
</Link>
{historyOpen &&
typeof document !== 'undefined' &&
createPortal(
<div
style={{ top: submenuStyle.top, left: submenuStyle.left }}
className="fixed z-[9999]"
>
<HistorySubmenu
onMouseEnter={cancelHide}
onMouseLeave={handleHistoryMouseLeave}
/>
</div>,
document.body,
)}
</div>
);
}
const link = linkMap[id];
if (!link) return null;
if (link.href) {
return (
<Link
key={id}
href={link.href}
className={cn(
'relative flex flex-col items-center justify-center cursor-pointer w-full py-2 rounded-lg',
link.active
? 'text-black/70 dark:text-white/70'
: 'text-black/60 dark:text-white/60',
)}
onMouseEnter={(e) => showTooltip(link.label, e)}
onMouseLeave={hideTooltip}
>
<div
className={cn(
link.active && 'bg-light-200 dark:bg-dark-200',
'group rounded-lg hover:bg-light-200 hover:dark:bg-dark-200 transition duration-200',
)}
>
<link.icon
size={25}
className={cn(
!link.active && 'group-hover:scale-105',
'transition duration:200 m-1.5',
)}
/>
</div>
</Link>
);
}
return (
<span
key={id}
className={cn(
'relative flex flex-col items-center justify-center w-full py-2 rounded-lg cursor-default',
'text-black/50 dark:text-white/50',
)}
onMouseEnter={(e) => showTooltip(link.label, e)}
onMouseLeave={hideTooltip}
>
<div className="group rounded-lg opacity-80">
<link.icon size={25} className="m-1.5" />
</div>
</span>
);
})}
<div
ref={moreRef}
className="relative w-full"
onMouseEnter={handleMoreEnter}
onMouseLeave={handleMoreLeave}
>
<button
type="button"
onClick={() => {
if (moreRef.current) {
const rect = moreRef.current.getBoundingClientRect();
setSubmenuStyle({ top: 0, left: rect.right + 4 });
}
setMoreOpen((prev) => !prev);
}}
className={cn(
'relative flex flex-col items-center justify-center w-full py-2 rounded-lg cursor-pointer',
'text-black/60 dark:text-white/60 hover:text-black/70 dark:hover:text-white/70',
)}
onMouseEnter={(e) => showTooltip(t('nav.more'), e)}
onMouseLeave={hideTooltip}
>
<div className="group rounded-lg hover:bg-light-200 hover:dark:bg-dark-200 transition duration-200">
<MoreHorizontal
size={25}
className="group-hover:scale-105 transition duration:200 m-1.5"
/>
</div>
</button>
{moreOpen &&
typeof document !== 'undefined' &&
createPortal(
isMobile ? (
<div
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/20 dark:bg-black/40"
onClick={() => setMoreOpen(false)}
role="button"
tabIndex={0}
aria-label={t('common.close')}
>
<div
className="mx-4 max-w-sm w-full max-h-[85vh] overflow-y-auto rounded-xl bg-light-secondary dark:bg-dark-secondary shadow-xl border border-light-200 dark:border-dark-200"
onClick={(e) => e.stopPropagation()}
onMouseEnter={cancelMoreHide}
onMouseLeave={() => {}}
>
<MoreSubmenu
restIds={restMainIds}
linkMap={linkMap}
itemLabels={itemLabels}
submenuStyle={submenuStyle}
isMobile={isMobile}
onMouseEnter={cancelMoreHide}
onMouseLeave={() => setMoreOpen(false)}
cancelMoreHide={cancelMoreHide}
/>
</div>
</div>
) : (
<div
style={{ top: submenuStyle.top, left: submenuStyle.left }}
className="fixed z-[9999]"
>
<MoreSubmenu
restIds={restMainIds}
linkMap={linkMap}
itemLabels={itemLabels}
submenuStyle={submenuStyle}
isMobile={isMobile}
onMouseEnter={cancelMoreHide}
onMouseLeave={handleMoreLeave}
cancelMoreHide={cancelMoreHide}
/>
</div>
),
document.body,
)}
</div>
</VerticalIconContainer>
<div className="mt-auto pt-4 flex flex-col items-center gap-y-1">
{showProfile && (
<Link
href={profileLink.href}
className={cn(
'relative flex flex-col items-center justify-center cursor-pointer w-full py-2 rounded-lg',
profileLink.active
? 'text-black/70 dark:text-white/70'
: 'text-black/60 dark:text-white/60',
)}
onMouseEnter={(e) => showTooltip(profileLink.label, e)}
onMouseLeave={hideTooltip}
>
<div
className={cn(
profileLink.active && 'bg-light-200 dark:bg-dark-200',
'group rounded-lg hover:bg-light-200 hover:dark:bg-dark-200 transition duration-200',
)}
>
<profileLink.icon
size={25}
className={cn(
!profileLink.active && 'group-hover:scale-105',
'transition duration:200 m-1.5',
)}
/>
</div>
</Link>
)}
</div>
</div>
</div>
<div className="fixed bottom-0 w-full z-50 flex flex-row items-center gap-x-6 bg-light-secondary dark:bg-dark-secondary px-4 py-4 shadow-sm lg:hidden">
{topMainIds.map((id) => {
const link = linkMap[id];
if (!link) return null;
return link.href ? (
<Link
href={link.href}
key={id}
className={cn(
'relative flex flex-col items-center space-y-1 text-center w-full',
link.active
? 'text-black dark:text-white'
: 'text-black dark:text-white/70',
)}
>
{link.active && (
<div className="absolute top-0 -mt-4 h-1 w-full rounded-b-lg bg-black dark:bg-white" />
)}
<link.icon />
<p className="text-xs text-fade min-w-0">{link.label}</p>
</Link>
) : (
<span
key={id}
className="relative flex flex-col items-center space-y-1 text-center w-full text-black/50 dark:text-white/50 cursor-default"
>
<link.icon />
<p className="text-xs text-fade min-w-0">{link.label}</p>
</span>
);
})}
<button
type="button"
onClick={() => setMoreOpen(true)}
className="relative flex flex-col items-center space-y-1 text-center shrink-0 text-black/70 dark:text-white/70"
title={t('nav.more')}
aria-label={t('nav.more')}
>
<MoreHorizontal size={20} />
<p className="text-xs text-fade">{t('nav.more')}</p>
</button>
{showProfile && (
<Link
href={profileLink.href}
className={cn(
'relative flex flex-col items-center space-y-1 text-center shrink-0',
profileLink.active
? 'text-black dark:text-white'
: 'text-black dark:text-white/70',
)}
title={profileLink.label}
>
{profileLink.active && (
<div className="absolute top-0 -mt-4 h-1 w-full rounded-b-lg bg-black dark:bg-white" />
)}
<profileLink.icon size={20} />
<p className="text-xs text-fade min-w-0">{profileLink.label}</p>
</Link>
)}
</div>
<Layout>{children}</Layout>
</div>
);
};
export default Sidebar;

View File

@@ -0,0 +1,220 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { GripVertical, ChevronUp, ChevronDown } from 'lucide-react';
import { useTranslation } from '@/lib/localization/context';
import {
getSidebarMenuConfig,
setSidebarMenuConfig,
DEFAULT_ORDER,
} from '@/lib/config/sidebarMenu';
import { cn } from '@/lib/utils';
interface MenuSettingsPanelProps {
onMouseEnter: () => void;
onMouseLeave: () => void;
itemLabels: Record<string, string>;
}
export default function MenuSettingsPanel({
onMouseEnter,
onMouseLeave,
itemLabels,
}: MenuSettingsPanelProps) {
const { t } = useTranslation();
const [config, setConfig] = useState(() => getSidebarMenuConfig());
const refreshConfig = useCallback(() => {
setConfig(getSidebarMenuConfig());
}, []);
useEffect(() => {
const handler = () => refreshConfig();
window.addEventListener('client-config-changed', handler);
return () => window.removeEventListener('client-config-changed', handler);
}, [refreshConfig]);
const toggleVisible = (id: string) => {
const next = { ...config.visible, [id]: !config.visible[id] };
setSidebarMenuConfig({ ...config, visible: next });
setConfig({ ...config, visible: next });
};
const mainIds = config.order.filter((id) => id !== 'profile' && id !== 'spaces');
const hasSpaces = config.order.includes('spaces');
const displayOrder = [...mainIds, ...(hasSpaces ? ['spaces'] : [])];
const moveUp = (displayIndex: number) => {
if (displayIndex <= 0) return;
const idA = displayOrder[displayIndex - 1];
const idB = displayOrder[displayIndex];
const order = [...config.order];
const idxA = order.indexOf(idA);
const idxB = order.indexOf(idB);
order[idxA] = idB;
order[idxB] = idA;
setSidebarMenuConfig({ ...config, order });
setConfig({ ...config, order });
};
const moveDown = (displayIndex: number) => {
if (displayIndex >= displayOrder.length - 1) return;
const idA = displayOrder[displayIndex];
const idB = displayOrder[displayIndex + 1];
const order = [...config.order];
const idxA = order.indexOf(idA);
const idxB = order.indexOf(idB);
order[idxA] = idB;
order[idxB] = idA;
setSidebarMenuConfig({ ...config, order });
setConfig({ ...config, order });
};
const moveToIndex = (fromIndex: number, toIndex: number) => {
if (fromIndex === toIndex || toIndex < 0 || toIndex >= displayOrder.length) return;
const newDisplayOrder = [...displayOrder];
const [removed] = newDisplayOrder.splice(fromIndex, 1);
newDisplayOrder.splice(toIndex, 0, removed);
const hasSpaces = config.order.includes('spaces');
const newMainIds = newDisplayOrder.filter((id) => id !== 'profile' && id !== 'spaces');
const newOrder = [...newMainIds, ...(hasSpaces ? ['spaces'] : []), 'profile'];
setSidebarMenuConfig({ ...config, order: newOrder });
setConfig({ ...config, order: newOrder });
};
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const handleDragStart = (e: React.DragEvent, index: number) => {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', String(index));
e.dataTransfer.setData('application/json', JSON.stringify({ index }));
setDraggedIndex(index);
};
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setDragOverIndex(index);
};
const handleDragLeave = () => {
setDragOverIndex(null);
};
const handleDrop = (e: React.DragEvent, toIndex: number) => {
e.preventDefault();
const fromIndex = parseInt(e.dataTransfer.getData('text/plain'), 10);
setDraggedIndex(null);
setDragOverIndex(null);
if (!Number.isNaN(fromIndex) && fromIndex !== toIndex) {
moveToIndex(fromIndex, toIndex);
}
};
const handleDragEnd = () => {
setDraggedIndex(null);
setDragOverIndex(null);
};
const resetToDefault = () => {
const defaultConfig = {
order: [...DEFAULT_ORDER],
visible: Object.fromEntries(DEFAULT_ORDER.map((id) => [id, true])),
};
setSidebarMenuConfig(defaultConfig);
setConfig(defaultConfig);
};
return (
<div
className="min-w-[180px] w-56 h-screen overflow-y-auto bg-light-secondary dark:bg-dark-secondary border border-light-200 dark:border-dark-200 shadow-xl py-2 z-[9999]"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<h3 className="text-sm font-medium text-black dark:text-white px-4 mb-1.5">
{t('nav.sidebarSettings')}
</h3>
<p className="text-xs text-black/60 dark:text-white/60 px-4 mb-2">
{t('nav.menuSettingsHint')}
</p>
<div className="flex flex-col gap-0.5 px-2">
{displayOrder.map((id, index) => (
<div
key={id}
onDragOver={(e) => handleDragOver(e, index)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, index)}
className={cn(
'flex items-center gap-1.5 py-1 px-2',
'hover:bg-light-200 dark:hover:bg-dark-200 transition-colors',
dragOverIndex === index && 'bg-light-200 dark:bg-dark-200 ring-1 ring-inset ring-black/10 dark:ring-white/10',
)}
>
<div
draggable
onDragStart={(e) => handleDragStart(e, index)}
onDragEnd={handleDragEnd}
className={cn(
'flex items-center gap-0.5 cursor-grab active:cursor-grabbing touch-none',
draggedIndex === index && 'opacity-50',
)}
title={t('nav.menuSettingsHint')}
>
<div className="flex flex-col gap-0.5">
<button
type="button"
onClick={() => moveUp(index)}
disabled={index === 0}
className="p-0.5 rounded hover:bg-light-200 dark:hover:bg-dark-200 disabled:opacity-30 disabled:cursor-not-allowed"
aria-label={t('nav.moveUp')}
>
<ChevronUp size={14} />
</button>
<button
type="button"
onClick={() => moveDown(index)}
disabled={index === displayOrder.length - 1}
className="p-0.5 rounded hover:bg-light-200 dark:hover:bg-dark-200 disabled:opacity-30 disabled:cursor-not-allowed"
aria-label={t('nav.moveDown')}
>
<ChevronDown size={14} />
</button>
</div>
<GripVertical size={14} className="text-black/40 dark:text-white/40 shrink-0" />
</div>
<span className="flex-1 text-sm text-black dark:text-white truncate min-w-0">
{itemLabels[id] ?? id}
</span>
<button
type="button"
role="switch"
aria-checked={config.visible[id] !== false}
onClick={() => toggleVisible(id)}
className={cn(
'relative flex h-4 w-7 shrink-0 rounded-full transition-colors',
config.visible[id] !== false
? 'bg-black dark:bg-white'
: 'bg-light-300 dark:bg-dark-300',
)}
>
<span
className={cn(
'absolute top-0.5 h-3 w-3 rounded-full bg-white dark:bg-black transition-transform',
config.visible[id] !== false ? 'translate-x-3 left-0.5' : 'translate-x-0 left-0.5',
)}
/>
</button>
</div>
))}
</div>
<button
type="button"
onClick={resetToDefault}
className="mt-2 mx-4 py-1.5 px-4 text-xs text-black/60 dark:text-white/60 hover:text-black dark:hover:text-white hover:bg-light-200 dark:hover:bg-dark-200 transition-colors"
>
{t('nav.resetMenu')}
</button>
</div>
);
}

View File

@@ -0,0 +1,51 @@
'use client';
import { useEffect, useState } from 'react';
import { ChevronDown, ChevronUp, BrainCircuit } from 'lucide-react';
interface ThinkBoxProps {
content: string;
thinkingEnded: boolean;
}
const ThinkBox = ({ content, thinkingEnded }: ThinkBoxProps) => {
const [isExpanded, setIsExpanded] = useState(true);
useEffect(() => {
if (thinkingEnded) {
setIsExpanded(false);
} else {
setIsExpanded(true);
}
}, [thinkingEnded]);
return (
<div className="my-4 bg-light-secondary/50 dark:bg-dark-secondary/50 rounded-xl border border-light-200 dark:border-dark-200 overflow-hidden">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between px-4 py-1 text-black/90 dark:text-white/90 hover:bg-light-200 dark:hover:bg-dark-200 transition duration-200"
>
<div className="flex items-center space-x-2">
<BrainCircuit
size={20}
className="text-[#9C27B0] dark:text-[#CE93D8]"
/>
<p className="font-medium text-sm">Thinking Process</p>
</div>
{isExpanded ? (
<ChevronUp size={18} className="text-black/70 dark:text-white/70" />
) : (
<ChevronDown size={18} className="text-black/70 dark:text-white/70" />
)}
</button>
{isExpanded && (
<div className="px-4 py-3 text-black/80 dark:text-white/80 text-sm border-t border-light-200 dark:border-dark-200 bg-light-100/50 dark:bg-dark-100/50 whitespace-pre-wrap">
{content}
</div>
)}
</div>
);
};
export default ThinkBox;

View File

@@ -0,0 +1,445 @@
'use client';
/**
* Travel Stepper — Поиск → Места → Маршрут → Отели → Билеты
* docs/architecture: 01-perplexity-analogue-design.md §2.2.D
* Состояние сохраняется в travel-svc (Redis) или sessionStorage (fallback)
*/
import { useCallback, useEffect, useMemo, useState } from 'react';
import { cn } from '@/lib/utils';
import {
Search,
MapPin,
Route,
Hotel,
Plane,
ChevronRight,
ChevronLeft,
X,
} from 'lucide-react';
const STEPS = [
{ id: 'search', label: 'Search', icon: Search },
{ id: 'places', label: 'Places', icon: MapPin },
{ id: 'route', label: 'Route', icon: Route },
{ id: 'hotels', label: 'Hotels', icon: Hotel },
{ id: 'tickets', label: 'Tickets', icon: Plane },
] as const;
export type TravelStepperStepId = (typeof STEPS)[number]['id'];
export interface ItineraryDay {
day: number;
title: string;
activities: string[];
tips?: string;
}
export interface TravelStepperState {
step: TravelStepperStepId;
searchQuery: string;
places: string[];
route: string | null;
itineraryDays: number;
hotels: string[];
tickets: string | null;
updatedAt: number;
}
const DEFAULT_STATE: TravelStepperState = {
step: 'search',
searchQuery: '',
places: [],
route: null,
itineraryDays: 3,
hotels: [],
tickets: null,
updatedAt: Date.now(),
};
const STORAGE_KEY = 'gooseek_travel_stepper_session';
const API_PREFIX = '/api/v1/travel';
function getOrCreateSessionId(): string {
if (typeof window === 'undefined') return '';
let id = sessionStorage.getItem(STORAGE_KEY);
if (!id) {
id = crypto.randomUUID();
sessionStorage.setItem(STORAGE_KEY, id);
}
return id;
}
async function saveState(sessionId: string, state: TravelStepperState): Promise<boolean> {
try {
const res = await fetch(`${API_PREFIX}/stepper/state`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId, state }),
});
return res.ok;
} catch {
return false;
}
}
async function fetchItinerary(query: string, days: number): Promise<{ days: ItineraryDay[]; summary?: string } | null> {
try {
const res = await fetch(`${API_PREFIX}/itinerary`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, days }),
signal: AbortSignal.timeout(90000),
});
const data = await res.json();
if (data?.days?.length) return data;
return null;
} catch {
return null;
}
}
async function loadState(sessionId: string): Promise<TravelStepperState | null> {
try {
const res = await fetch(`${API_PREFIX}/stepper/state/${sessionId}`);
const data = await res.json();
return data?.state ?? null;
} catch {
return null;
}
}
interface TravelStepperProps {
onClose: () => void;
}
export default function TravelStepper({ onClose }: TravelStepperProps) {
const [sessionId, setSessionId] = useState('');
const [state, setState] = useState<TravelStepperState>(DEFAULT_STATE);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [itineraryData, setItineraryData] = useState<{ days: ItineraryDay[]; summary?: string } | null>(null);
const [itineraryLoading, setItineraryLoading] = useState(false);
const [itineraryError, setItineraryError] = useState<string | null>(null);
const stepIndex = useMemo(
() => STEPS.findIndex((s) => s.id === state.step),
[state.step]
);
const canPrev = stepIndex > 0;
const canNext = stepIndex < STEPS.length - 1;
const persistState = useCallback(
async (next: TravelStepperState) => {
if (!sessionId) return;
setSaving(true);
const ok = await saveState(sessionId, next);
setSaving(false);
if (!ok && typeof window !== 'undefined') {
try {
sessionStorage.setItem('gooseek_travel_stepper_state', JSON.stringify(next));
} catch {
/* noop */
}
}
},
[sessionId]
);
useEffect(() => {
const id = getOrCreateSessionId();
setSessionId(id);
let cancelled = false;
(async () => {
const loaded = await loadState(id);
if (cancelled) return;
if (loaded && typeof loaded.step === 'string') {
setState({
...DEFAULT_STATE,
...loaded,
updatedAt: Date.now(),
});
} else if (typeof window !== 'undefined') {
try {
const raw = sessionStorage.getItem('gooseek_travel_stepper_state');
if (raw) {
const parsed = JSON.parse(raw) as Partial<TravelStepperState>;
if (parsed?.step) {
setState({ ...DEFAULT_STATE, ...parsed });
}
}
} catch {
/* noop */
}
}
setLoading(false);
})();
return () => {
cancelled = true;
};
}, []);
const goTo = useCallback(
(step: TravelStepperStepId) => {
const next = { ...state, step, updatedAt: Date.now() };
setState(next);
persistState(next);
},
[state, persistState]
);
const goPrev = useCallback(() => {
if (!canPrev) return;
const prevStep = STEPS[stepIndex - 1].id;
goTo(prevStep);
}, [canPrev, stepIndex, goTo]);
const goNext = useCallback(() => {
if (!canNext) return;
const nextStep = STEPS[stepIndex + 1].id;
goTo(nextStep);
}, [canNext, stepIndex, goTo]);
useEffect(() => {
if (state.step !== 'route' || !state.searchQuery.trim()) {
setItineraryData(null);
setItineraryError(null);
return;
}
let cancelled = false;
setItineraryLoading(true);
setItineraryError(null);
fetchItinerary(state.searchQuery, state.itineraryDays ?? 3)
.then((data) => {
if (!cancelled && data) setItineraryData(data);
else if (!cancelled) setItineraryError('Could not generate itinerary. OPENAI_API_KEY may be missing.');
})
.catch(() => {
if (!cancelled) setItineraryError('Request failed. Try again.');
})
.finally(() => {
if (!cancelled) setItineraryLoading(false);
});
return () => { cancelled = true; };
}, [state.step, state.searchQuery, state.itineraryDays]);
const updateSearch = useCallback(
(q: string) => {
const next = { ...state, searchQuery: q, updatedAt: Date.now() };
setState(next);
persistState(next);
},
[state, persistState]
);
const updateItineraryDays = useCallback(
(d: number) => {
const next = { ...state, itineraryDays: Math.min(14, Math.max(1, d)), updatedAt: Date.now() };
setState(next);
persistState(next);
setItineraryData(null);
},
[state, persistState]
);
if (loading) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="rounded-2xl bg-light-secondary dark:bg-dark-secondary p-8 animate-pulse">
<div className="h-6 w-48 bg-light-200 dark:bg-dark-200 rounded mb-4" />
<div className="h-4 w-32 bg-light-200 dark:bg-dark-200 rounded" />
</div>
</div>
);
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40">
<div
className={cn(
'w-full max-w-2xl rounded-2xl shadow-xl',
'bg-light-secondary dark:bg-dark-secondary border border-light-200/20 dark:border-dark-200/20',
'overflow-hidden'
)}
>
<div className="flex items-center justify-between p-4 border-b border-light-200/20 dark:border-dark-200/20">
<h2 className="text-lg font-semibold">Plan your trip</h2>
<button
onClick={onClose}
className="p-2 rounded-lg hover:bg-light-200/50 dark:hover:bg-dark-200/50 transition"
aria-label="Close"
>
<X size={20} />
</button>
</div>
<div className="flex gap-1 p-4 overflow-x-auto">
{STEPS.map((s, i) => {
const Icon = s.icon;
const isActive = s.id === state.step;
const isDone = i < stepIndex;
return (
<button
key={s.id}
onClick={() => goTo(s.id)}
className={cn(
'flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm whitespace-nowrap transition',
isActive && 'bg-[#EA580C]/20 text-[#EA580C]',
isDone && !isActive && 'text-black/60 dark:text-white/60',
!isActive && !isDone && 'text-black/40 dark:text-white/40 hover:bg-light-200/50 dark:hover:bg-dark-200/50'
)}
>
<Icon size={16} />
{s.label}
</button>
);
})}
</div>
<div className="p-6 min-h-[200px]">
{state.step === 'search' && (
<div>
<label className="block text-sm font-medium mb-2">Where would you like to go?</label>
<input
type="text"
value={state.searchQuery}
onChange={(e) => updateSearch(e.target.value)}
placeholder="e.g. Paris, Japan, Iceland"
className={cn(
'w-full px-4 py-3 rounded-xl border',
'bg-light-primary dark:bg-dark-primary border-light-200 dark:border-dark-200',
'focus:outline-none focus:ring-2 focus:ring-[#EA580C]/50'
)}
autoFocus
/>
</div>
)}
{state.step === 'places' && (
<div>
<p className="text-black/60 dark:text-white/60 text-sm">
Places step search results for &quot;{state.searchQuery || '...'}&quot; will appear here.
</p>
<p className="text-sm mt-2 text-black/40 dark:text-white/40">
Coming soon: integration with search and map APIs.
</p>
</div>
)}
{state.step === 'route' && (
<div>
<div className="flex items-center gap-3 mb-4">
<label className="text-sm font-medium">Duration:</label>
<select
value={state.itineraryDays ?? 3}
onChange={(e) => updateItineraryDays(parseInt(e.target.value, 10))}
className="px-3 py-2 rounded-lg border bg-light-primary dark:bg-dark-primary border-light-200 dark:border-dark-200 text-sm"
>
{[1, 3, 5, 7, 10, 14].map((d) => (
<option key={d} value={d}>{d} day{d > 1 ? 's' : ''}</option>
))}
</select>
<span className="text-black/50 dark:text-white/50 text-sm">
for &quot;{state.searchQuery || '...'}&quot;
</span>
</div>
{itineraryLoading ? (
<div className="space-y-3 animate-pulse">
<div className="h-4 w-3/4 bg-light-200 dark:bg-dark-200 rounded" />
<div className="h-3 w-full bg-light-200 dark:bg-dark-200 rounded" />
<div className="h-3 w-2/3 bg-light-200 dark:bg-dark-200 rounded" />
</div>
) : itineraryError ? (
<div>
<p className="text-sm text-red-600 dark:text-red-400">{itineraryError}</p>
<button
type="button"
onClick={() => {
setItineraryError(null);
setItineraryLoading(true);
fetchItinerary(state.searchQuery, state.itineraryDays ?? 3)
.then((data) => {
if (data) setItineraryData(data);
else setItineraryError('Could not generate itinerary.');
})
.catch(() => setItineraryError('Request failed. Try again.'))
.finally(() => setItineraryLoading(false));
}}
className="mt-2 text-sm text-[#EA580C] hover:underline"
>
Retry
</button>
</div>
) : itineraryData?.days?.length ? (
<div className="space-y-4 max-h-[280px] overflow-y-auto">
{itineraryData.summary && (
<p className="text-sm text-black/70 dark:text-white/70">{itineraryData.summary}</p>
)}
{itineraryData.days.map((d) => (
<div
key={d.day}
className="p-3 rounded-xl bg-light-primary dark:bg-dark-primary border border-light-200/50 dark:border-dark-200/50"
>
<h4 className="font-medium text-sm">{d.title}</h4>
<ul className="mt-2 space-y-1 text-sm text-black/70 dark:text-white/70">
{d.activities.map((a, i) => (
<li key={i}> {a}</li>
))}
</ul>
{d.tips && (
<p className="mt-2 text-xs text-[#EA580C]">{d.tips}</p>
)}
</div>
))}
</div>
) : (
<p className="text-sm text-black/50 dark:text-white/50">
Enter a destination in the Search step first, then return here.
</p>
)}
</div>
)}
{state.step === 'hotels' && (
<div>
<p className="text-black/60 dark:text-white/60 text-sm">Hotels Tripadvisor and Selfbook integration.</p>
<p className="text-sm mt-2 text-black/40 dark:text-white/40">Coming soon: hotel recommendations.</p>
</div>
)}
{state.step === 'tickets' && (
<div>
<p className="text-black/60 dark:text-white/60 text-sm">Tickets flight and transport options.</p>
<p className="text-sm mt-2 text-black/40 dark:text-white/40">Coming soon: booking integration.</p>
</div>
)}
</div>
<div className="flex items-center justify-between p-4 border-t border-light-200/20 dark:border-dark-200/20">
<button
onClick={goPrev}
disabled={!canPrev}
className={cn(
'flex items-center gap-1 px-4 py-2 rounded-lg text-sm font-medium transition',
canPrev
? 'hover:bg-light-200/50 dark:hover:bg-dark-200/50'
: 'opacity-40 cursor-not-allowed'
)}
>
<ChevronLeft size={18} />
Back
</button>
{saving && <span className="text-xs text-black/40 dark:text-white/40">Saving...</span>}
<button
onClick={goNext}
disabled={!canNext}
className={cn(
'flex items-center gap-1 px-4 py-2 rounded-lg text-sm font-medium bg-[#EA580C] text-white transition hover:opacity-90',
!canNext && 'opacity-50 cursor-not-allowed'
)}
>
Next
<ChevronRight size={18} />
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,222 @@
import { Wind } from 'lucide-react';
import { useEffect, useState } from 'react';
import { fetchContextWithGeolocation } from '@/lib/geoDevice';
const WeatherWidget = () => {
const [data, setData] = useState({
temperature: 0,
condition: '',
location: '',
humidity: 0,
windSpeed: 0,
icon: '',
temperatureUnit: 'C',
windSpeedUnit: 'm/s',
});
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const fetchWeather = async (
lat: number,
lng: number,
city: string,
) => {
const res = await fetch('/api/weather', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
lat,
lng,
city: city || undefined,
measureUnit: localStorage.getItem('measureUnit') ?? 'Metric',
}),
});
const weatherData = await res.json();
if (res.status !== 200) {
throw new Error(weatherData.message ?? 'Weather fetch failed');
}
setData({
temperature: weatherData.temperature,
condition: weatherData.condition,
location: weatherData.city ?? city ?? 'Unknown',
humidity: weatherData.humidity,
windSpeed: weatherData.windSpeed,
icon: weatherData.icon,
temperatureUnit: weatherData.temperatureUnit,
windSpeedUnit: weatherData.windSpeedUnit,
});
};
const updateWeather = async () => {
setError(false);
setLoading(true);
try {
let context: Awaited<ReturnType<typeof fetchContextWithGeolocation>> | null =
null;
try {
context = await fetchContextWithGeolocation();
} catch {
// geo-device не запущен (503) — пропускаем
}
if (context?.geo?.latitude != null && context.geo.longitude != null) {
const city =
context.geo.city ||
(await reverseGeocode(context.geo.latitude, context.geo.longitude));
await fetchWeather(
context.geo.latitude,
context.geo.longitude,
city,
);
} else {
await tryIpFallback();
}
} catch {
await tryIpFallback();
} finally {
setLoading(false);
}
};
const reverseGeocode = async (
lat: number,
lng: number,
): Promise<string> => {
try {
const res = await fetch(
`https://api-bdc.io/data/reverse-geocode-client?latitude=${lat}&longitude=${lng}&localityLanguage=en`,
);
const d = await res.json();
return d?.locality ?? '';
} catch {
return '';
}
};
const tryIpFallback = async () => {
const providers: Array<{
url: string;
getCoords: (d: Record<string, unknown>) => { lat: number; lng: number; city: string } | null;
}> = [
{
url: 'https://get.geojs.io/v1/ip/geo.json',
getCoords: (d) => {
const lat = Number(d.latitude);
const lng = Number(d.longitude);
if (Number.isFinite(lat) && Number.isFinite(lng)) {
return {
lat,
lng,
city: String(d.city ?? d.region ?? '').trim() || 'Unknown',
};
}
return null;
},
},
{
url: 'https://ipwhois.app/json/',
getCoords: (d) => {
const lat = Number(d.latitude);
const lng = Number(d.longitude);
if (Number.isFinite(lat) && Number.isFinite(lng)) {
return {
lat,
lng,
city: String(d.city ?? '').trim() || 'Unknown',
};
}
return null;
},
},
];
for (const p of providers) {
try {
const res = await fetch(p.url);
const d = (await res.json()) as Record<string, unknown>;
const coords = p.getCoords(d);
if (coords) {
await fetchWeather(coords.lat, coords.lng, coords.city || 'Unknown');
return;
}
} catch {
// следующий провайдер
}
}
setError(true);
};
useEffect(() => {
updateWeather();
const intervalId = setInterval(updateWeather, 30 * 60 * 1000);
return () => clearInterval(intervalId);
}, []);
return (
<div className="bg-light-secondary dark:bg-dark-secondary rounded-2xl border border-light-200 dark:border-dark-200 shadow-sm shadow-light-200/10 dark:shadow-black/25 flex flex-row items-center w-full h-20 min-h-[80px] max-h-[80px] px-2.5 py-1.5 gap-2">
{error ? (
<div className="flex items-center justify-center w-full h-full text-xs text-black/50 dark:text-white/50">
Weather unavailable
</div>
) : loading ? (
<>
<div className="flex flex-col items-center justify-center w-12 min-w-12 max-w-12 h-full animate-pulse">
<div className="h-8 w-8 rounded-full bg-light-200 dark:bg-dark-200 mb-1" />
<div className="h-3 w-8 rounded bg-light-200 dark:bg-dark-200" />
</div>
<div className="flex flex-col justify-between flex-1 h-full py-1 animate-pulse">
<div className="flex flex-row items-center justify-between">
<div className="h-3 w-20 rounded bg-light-200 dark:bg-dark-200" />
<div className="h-3 w-12 rounded bg-light-200 dark:bg-dark-200" />
</div>
<div className="h-3 w-16 rounded bg-light-200 dark:bg-dark-200 mt-1" />
<div className="flex flex-row justify-between w-full mt-auto pt-1 border-t border-light-200 dark:border-dark-200">
<div className="h-3 w-16 rounded bg-light-200 dark:bg-dark-200" />
<div className="h-3 w-8 rounded bg-light-200 dark:bg-dark-200" />
</div>
</div>
</>
) : (
<>
<div className="flex flex-col items-center justify-center w-12 min-w-12 max-w-12 h-full">
<img
src={`/weather-ico/${data.icon}.svg`}
alt={data.condition}
className="h-8 w-auto"
/>
<span className="text-sm font-semibold text-black dark:text-white">
{data.temperature}°{data.temperatureUnit}
</span>
</div>
<div className="flex flex-col justify-between flex-1 h-full py-1">
<div className="flex flex-row items-center justify-between">
<span className="text-xs font-semibold text-black dark:text-white truncate">
{data.location}
</span>
<span className="flex items-center text-[10px] text-black/60 dark:text-white/60 font-medium shrink-0">
<Wind className="w-2.5 h-2.5 mr-0.5" />
{data.windSpeed} {data.windSpeedUnit}
</span>
</div>
<span className="text-[10px] text-black/50 dark:text-white/50 italic truncate">
{data.condition}
</span>
<div className="flex flex-row justify-between w-full mt-auto pt-1 border-t border-light-200/50 dark:border-dark-200/50 text-[10px] text-black/50 dark:text-white/50 font-medium">
<span>Humidity {data.humidity}%</span>
<span className="font-semibold text-black/70 dark:text-white/70">
Now
</span>
</div>
</div>
</>
)}
</div>
);
};
export default WeatherWidget;

View File

@@ -0,0 +1,46 @@
'use client';
import { Calculator, Equal } from 'lucide-react';
type CalculationWidgetProps = {
expression: string;
result: number;
};
const Calculation = ({ expression, result }: CalculationWidgetProps) => {
return (
<div className="rounded-lg border border-light-200 dark:border-dark-200">
<div className="p-4 space-y-4">
<div className="space-y-2">
<div className="flex items-center gap-2 text-black/60 dark:text-white/70">
<Calculator className="w-4 h-4" />
<span className="text-xs uppercase font-semibold tracking-wide">
Expression
</span>
</div>
<div className="rounded-lg border border-light-200 dark:border-dark-200 bg-light-secondary dark:bg-dark-secondary p-3">
<code className="text-sm text-black dark:text-white font-mono break-all">
{expression}
</code>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2 text-black/60 dark:text-white/70">
<Equal className="w-4 h-4" />
<span className="text-xs uppercase font-semibold tracking-wide">
Result
</span>
</div>
<div className="rounded-xl border border-light-200 dark:border-dark-200 bg-light-secondary dark:bg-dark-secondary p-5">
<div className="text-4xl font-bold text-black dark:text-white font-mono tabular-nums">
{result.toLocaleString()}
</div>
</div>
</div>
</div>
</div>
);
};
export default Calculation;

View File

@@ -0,0 +1,78 @@
import React from 'react';
import dynamic from 'next/dynamic';
import { Widget } from '../ChatWindow';
import Weather from './Weather';
import Calculation from './Calculation';
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>;
}
});
};
export default Renderer;

View File

@@ -0,0 +1,517 @@
'use client';
import { Clock, ArrowUpRight, ArrowDownRight, Minus } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import {
createChart,
ColorType,
LineStyle,
BaselineSeries,
LineSeries,
} from 'lightweight-charts';
type StockWidgetProps = {
symbol: string;
shortName: string;
longName?: string;
exchange?: string;
currency?: string;
marketState?: string;
regularMarketPrice?: number;
regularMarketChange?: number;
regularMarketChangePercent?: number;
regularMarketPreviousClose?: number;
regularMarketOpen?: number;
regularMarketDayHigh?: number;
regularMarketDayLow?: number;
regularMarketVolume?: number;
averageDailyVolume3Month?: number;
marketCap?: number;
fiftyTwoWeekLow?: number;
fiftyTwoWeekHigh?: number;
trailingPE?: number;
forwardPE?: number;
dividendYield?: number;
earningsPerShare?: number;
website?: string;
postMarketPrice?: number;
postMarketChange?: number;
postMarketChangePercent?: number;
preMarketPrice?: number;
preMarketChange?: number;
preMarketChangePercent?: number;
chartData?: {
'1D'?: { timestamps: number[]; prices: number[] } | null;
'5D'?: { timestamps: number[]; prices: number[] } | null;
'1M'?: { timestamps: number[]; prices: number[] } | null;
'3M'?: { timestamps: number[]; prices: number[] } | null;
'6M'?: { timestamps: number[]; prices: number[] } | null;
'1Y'?: { timestamps: number[]; prices: number[] } | null;
MAX?: { timestamps: number[]; prices: number[] } | null;
} | null;
comparisonData?: Array<{
ticker: string;
name: string;
chartData: {
'1D'?: { timestamps: number[]; prices: number[] } | null;
'5D'?: { timestamps: number[]; prices: number[] } | null;
'1M'?: { timestamps: number[]; prices: number[] } | null;
'3M'?: { timestamps: number[]; prices: number[] } | null;
'6M'?: { timestamps: number[]; prices: number[] } | null;
'1Y'?: { timestamps: number[]; prices: number[] } | null;
MAX?: { timestamps: number[]; prices: number[] } | null;
};
}> | null;
error?: string;
};
const formatNumber = (num: number | undefined, decimals = 2): string => {
if (num === undefined || num === null) return 'N/A';
return num.toLocaleString(undefined, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
});
};
const formatLargeNumber = (num: number | undefined): string => {
if (num === undefined || num === null) return 'N/A';
if (num >= 1e12) return `$${(num / 1e12).toFixed(2)}T`;
if (num >= 1e9) return `$${(num / 1e9).toFixed(2)}B`;
if (num >= 1e6) return `$${(num / 1e6).toFixed(2)}M`;
if (num >= 1e3) return `$${(num / 1e3).toFixed(2)}K`;
return `$${num.toFixed(2)}`;
};
const Stock = (props: StockWidgetProps) => {
const [isDarkMode, setIsDarkMode] = useState(false);
const [selectedTimeframe, setSelectedTimeframe] = useState<
'1D' | '5D' | '1M' | '3M' | '6M' | '1Y' | 'MAX'
>('1M');
const chartContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const checkDarkMode = () => {
setIsDarkMode(document.documentElement.classList.contains('dark'));
};
checkDarkMode();
const observer = new MutationObserver(checkDarkMode);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class'],
});
return () => observer.disconnect();
}, []);
useEffect(() => {
const currentChartData = props.chartData?.[selectedTimeframe];
if (
!chartContainerRef.current ||
!currentChartData ||
currentChartData.timestamps.length === 0
) {
return;
}
const chart = createChart(chartContainerRef.current, {
width: chartContainerRef.current.clientWidth,
height: 280,
layout: {
background: { type: ColorType.Solid, color: 'transparent' },
textColor: isDarkMode ? '#6b7280' : '#9ca3af',
fontSize: 11,
attributionLogo: false,
},
grid: {
vertLines: {
color: isDarkMode ? '#21262d' : '#e8edf1',
style: LineStyle.Solid,
},
horzLines: {
color: isDarkMode ? '#21262d' : '#e8edf1',
style: LineStyle.Solid,
},
},
crosshair: {
vertLine: {
color: isDarkMode ? '#30363d' : '#d0d7de',
labelVisible: false,
},
horzLine: {
color: isDarkMode ? '#30363d' : '#d0d7de',
labelVisible: true,
},
},
rightPriceScale: {
borderVisible: false,
visible: false,
},
leftPriceScale: {
borderVisible: false,
visible: true,
},
timeScale: {
borderVisible: false,
timeVisible: false,
},
handleScroll: false,
handleScale: false,
});
const prices = currentChartData.prices;
let baselinePrice: number;
if (selectedTimeframe === '1D') {
baselinePrice = props.regularMarketPreviousClose ?? prices[0];
} else {
baselinePrice = prices[0];
}
const baselineSeries = chart.addSeries(BaselineSeries);
baselineSeries.applyOptions({
baseValue: { type: 'price', price: baselinePrice },
topLineColor: isDarkMode ? '#14b8a6' : '#0d9488',
topFillColor1: isDarkMode
? 'rgba(20, 184, 166, 0.28)'
: 'rgba(13, 148, 136, 0.24)',
topFillColor2: isDarkMode
? 'rgba(20, 184, 166, 0.05)'
: 'rgba(13, 148, 136, 0.05)',
bottomLineColor: isDarkMode ? '#f87171' : '#dc2626',
bottomFillColor1: isDarkMode
? 'rgba(248, 113, 113, 0.05)'
: 'rgba(220, 38, 38, 0.05)',
bottomFillColor2: isDarkMode
? 'rgba(248, 113, 113, 0.28)'
: 'rgba(220, 38, 38, 0.24)',
lineWidth: 2,
crosshairMarkerVisible: true,
crosshairMarkerRadius: 4,
crosshairMarkerBorderColor: '',
crosshairMarkerBackgroundColor: '',
});
const data = currentChartData.timestamps.map((timestamp, index) => {
const price = currentChartData.prices[index];
return {
time: (timestamp / 1000) as any,
value: price,
};
});
baselineSeries.setData(data);
const comparisonColors = ['#8b5cf6', '#f59e0b', '#ec4899'];
if (props.comparisonData && props.comparisonData.length > 0) {
props.comparisonData.forEach((comp, index) => {
const compChartData = comp.chartData[selectedTimeframe];
if (compChartData && compChartData.prices.length > 0) {
const compData = compChartData.timestamps.map((timestamp, i) => ({
time: (timestamp / 1000) as any,
value: compChartData.prices[i],
}));
const compSeries = chart.addSeries(LineSeries);
compSeries.applyOptions({
color: comparisonColors[index] || '#6b7280',
lineWidth: 2,
crosshairMarkerVisible: true,
crosshairMarkerRadius: 4,
priceScaleId: 'left',
});
compSeries.setData(compData);
}
});
}
chart.timeScale().fitContent();
const handleResize = () => {
if (chartContainerRef.current) {
chart.applyOptions({
width: chartContainerRef.current.clientWidth,
});
}
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
chart.remove();
};
}, [
props.chartData,
props.comparisonData,
selectedTimeframe,
isDarkMode,
props.regularMarketPreviousClose,
]);
const isPositive = (props.regularMarketChange ?? 0) >= 0;
const isMarketOpen = props.marketState === 'REGULAR';
const isPreMarket = props.marketState === 'PRE';
const isPostMarket = props.marketState === 'POST';
const displayPrice = isPostMarket
? props.postMarketPrice ?? props.regularMarketPrice
: isPreMarket
? props.preMarketPrice ?? props.regularMarketPrice
: props.regularMarketPrice;
const displayChange = isPostMarket
? props.postMarketChange ?? props.regularMarketChange
: isPreMarket
? props.preMarketChange ?? props.regularMarketChange
: props.regularMarketChange;
const displayChangePercent = isPostMarket
? props.postMarketChangePercent ?? props.regularMarketChangePercent
: isPreMarket
? props.preMarketChangePercent ?? props.regularMarketChangePercent
: props.regularMarketChangePercent;
const changeColor = isPositive
? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400';
if (props.error) {
return (
<div className="rounded-lg bg-light-secondary dark:bg-dark-secondary border border-light-200 dark:border-dark-200 p-4">
<p className="text-sm text-black dark:text-white">
Error: {props.error}
</p>
</div>
);
}
return (
<div className="rounded-lg border border-light-200 dark:border-dark-200 overflow-hidden">
<div className="p-4 space-y-4">
<div className="flex items-start justify-between gap-4 pb-4 border-b border-light-200 dark:border-dark-200">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
{props.website && (
<img
src={`https://logo.clearbit.com/${new URL(props.website).hostname}`}
alt={`${props.symbol} logo`}
className="w-8 h-8 rounded-lg"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
)}
<h3 className="text-2xl font-bold text-black dark:text-white">
{props.symbol}
</h3>
{props.exchange && (
<span className="px-2 py-0.5 text-xs font-medium rounded bg-light-100 dark:bg-dark-100 text-black/60 dark:text-white/60">
{props.exchange}
</span>
)}
{isMarketOpen && (
<div className="flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-green-100 dark:bg-green-950/40 border border-green-300 dark:border-green-800">
<div className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />
<span className="text-xs font-medium text-green-700 dark:text-green-400">
Live
</span>
</div>
)}
{isPreMarket && (
<div className="flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-blue-100 dark:bg-blue-950/40 border border-blue-300 dark:border-blue-800">
<Clock className="w-3 h-3 text-blue-600 dark:text-blue-400" />
<span className="text-xs font-medium text-blue-700 dark:text-blue-400">
Pre-Market
</span>
</div>
)}
{isPostMarket && (
<div className="flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-orange-100 dark:bg-orange-950/40 border border-orange-300 dark:border-orange-800">
<Clock className="w-3 h-3 text-orange-600 dark:text-orange-400" />
<span className="text-xs font-medium text-orange-700 dark:text-orange-400">
After Hours
</span>
</div>
)}
</div>
<p className="text-sm text-black/60 dark:text-white/60">
{props.longName || props.shortName}
</p>
</div>
<div className="text-right">
<div className="flex items-baseline gap-2 mb-1">
<span className="text-3xl font-medium text-black dark:text-white">
{props.currency === 'USD' ? '$' : ''}
{formatNumber(displayPrice)}
</span>
</div>
<div
className={`flex items-center justify-end gap-1 ${changeColor}`}
>
{isPositive ? (
<ArrowUpRight className="w-4 h-4" />
) : displayChange === 0 ? (
<Minus className="w-4 h-4" />
) : (
<ArrowDownRight className="w-4 h-4" />
)}
<span className="text-lg font-normal">
{displayChange !== undefined && displayChange >= 0 ? '+' : ''}
{formatNumber(displayChange)}
</span>
<span className="text-sm font-normal">
(
{displayChangePercent !== undefined && displayChangePercent >= 0
? '+'
: ''}
{formatNumber(displayChangePercent)}%)
</span>
</div>
</div>
</div>
{props.chartData && (
<div className="bg-light-secondary dark:bg-dark-secondary rounded-lg overflow-hidden">
<div className="flex items-center justify-between p-3 border-b border-light-200 dark:border-dark-200">
<div className="flex items-center gap-1">
{(['1D', '5D', '1M', '3M', '6M', '1Y', 'MAX'] as const).map(
(timeframe) => (
<button
key={timeframe}
onClick={() => setSelectedTimeframe(timeframe)}
disabled={!props.chartData?.[timeframe]}
className={`px-3 py-1.5 text-xs font-medium rounded transition-colors ${
selectedTimeframe === timeframe
? 'bg-black/10 dark:bg-white/10 text-black dark:text-white'
: 'text-black/50 dark:text-white/50 hover:text-black/80 dark:hover:text-white/80'
} disabled:opacity-30 disabled:cursor-not-allowed`}
>
{timeframe}
</button>
),
)}
</div>
{props.comparisonData && props.comparisonData.length > 0 && (
<div className="flex items-center gap-3 ml-auto">
<span className="text-xs text-black/50 dark:text-white/50">
{props.symbol}
</span>
{props.comparisonData.map((comp, index) => {
const colors = ['#8b5cf6', '#f59e0b', '#ec4899'];
return (
<div
key={comp.ticker}
className="flex items-center gap-1.5"
>
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: colors[index] }}
/>
<span className="text-xs text-black/70 dark:text-white/70">
{comp.ticker}
</span>
</div>
);
})}
</div>
)}
</div>
<div className="p-4">
<div ref={chartContainerRef} />
</div>
<div className="grid grid-cols-3 border-t border-light-200 dark:border-dark-200">
<div className="flex justify-between p-3 border-r border-light-200 dark:border-dark-200">
<span className="text-xs text-black/50 dark:text-white/50">
Prev Close
</span>
<span className="text-xs text-black dark:text-white font-medium">
${formatNumber(props.regularMarketPreviousClose)}
</span>
</div>
<div className="flex justify-between p-3 border-r border-light-200 dark:border-dark-200">
<span className="text-xs text-black/50 dark:text-white/50">
52W Range
</span>
<span className="text-xs text-black dark:text-white font-medium">
${formatNumber(props.fiftyTwoWeekLow, 2)}-$
{formatNumber(props.fiftyTwoWeekHigh, 2)}
</span>
</div>
<div className="flex justify-between p-3">
<span className="text-xs text-black/50 dark:text-white/50">
Market Cap
</span>
<span className="text-xs text-black dark:text-white font-medium">
{formatLargeNumber(props.marketCap)}
</span>
</div>
<div className="flex justify-between p-3 border-t border-r border-light-200 dark:border-dark-200">
<span className="text-xs text-black/50 dark:text-white/50">
Open
</span>
<span className="text-xs text-black dark:text-white font-medium">
${formatNumber(props.regularMarketOpen)}
</span>
</div>
<div className="flex justify-between p-3 border-t border-r border-light-200 dark:border-dark-200">
<span className="text-xs text-black/50 dark:text-white/50">
P/E Ratio
</span>
<span className="text-xs text-black dark:text-white font-medium">
{props.trailingPE ? formatNumber(props.trailingPE, 2) : 'N/A'}
</span>
</div>
<div className="flex justify-between p-3 border-t border-light-200 dark:border-dark-200">
<span className="text-xs text-black/50 dark:text-white/50">
Dividend Yield
</span>
<span className="text-xs text-black dark:text-white font-medium">
{props.dividendYield
? `${formatNumber(props.dividendYield * 100, 2)}%`
: 'N/A'}
</span>
</div>
<div className="flex justify-between p-3 border-t border-r border-light-200 dark:border-dark-200">
<span className="text-xs text-black/50 dark:text-white/50">
Day Range
</span>
<span className="text-xs text-black dark:text-white font-medium">
${formatNumber(props.regularMarketDayLow, 2)}-$
{formatNumber(props.regularMarketDayHigh, 2)}
</span>
</div>
<div className="flex justify-between p-3 border-t border-r border-light-200 dark:border-dark-200">
<span className="text-xs text-black/50 dark:text-white/50">
Volume
</span>
<span className="text-xs text-black dark:text-white font-medium">
{formatLargeNumber(props.regularMarketVolume)}
</span>
</div>
<div className="flex justify-between p-3 border-t border-light-200 dark:border-dark-200">
<span className="text-xs text-black/50 dark:text-white/50">
EPS
</span>
<span className="text-xs text-black dark:text-white font-medium">
$
{props.earningsPerShare
? formatNumber(props.earningsPerShare, 2)
: 'N/A'}
</span>
</div>
</div>
</div>
)}
</div>
</div>
);
};
export default Stock;

View File

@@ -0,0 +1,422 @@
'use client';
import { getMeasurementUnit } from '@/lib/config/clientRegistry';
import { Wind, Droplets, Gauge } from 'lucide-react';
import { useMemo, useEffect, useState } from 'react';
type WeatherWidgetProps = {
location: string;
current: {
time: string;
temperature_2m: number;
relative_humidity_2m: number;
apparent_temperature: number;
is_day: number;
precipitation: number;
weather_code: number;
wind_speed_10m: number;
wind_direction_10m: number;
wind_gusts_10m?: number;
};
daily: {
time: string[];
weather_code: number[];
temperature_2m_max: number[];
temperature_2m_min: number[];
precipitation_probability_max: number[];
};
timezone: string;
};
const getWeatherInfo = (code: number, isDay: boolean, isDarkMode: boolean) => {
const dayNight = isDay ? 'day' : 'night';
const weatherMap: Record<
number,
{ icon: string; description: string; gradient: string }
> = {
0: {
icon: `clear-${dayNight}.svg`,
description: 'Clear',
gradient: isDarkMode
? isDay
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #E8F1FA, #7A9DBF 35%, #4A7BA8 60%, #2F5A88)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #5A6A7E, #3E4E63 40%, #2A3544 65%, #1A2230)'
: isDay
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #FFFFFF, #DBEAFE 30%, #93C5FD 60%, #60A5FA)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #7B8694, #475569 45%, #334155 70%, #1E293B)',
},
1: {
icon: `clear-${dayNight}.svg`,
description: 'Mostly Clear',
gradient: isDarkMode
? isDay
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #E8F1FA, #7A9DBF 35%, #4A7BA8 60%, #2F5A88)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #5A6A7E, #3E4E63 40%, #2A3544 65%, #1A2230)'
: isDay
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #FFFFFF, #DBEAFE 30%, #93C5FD 60%, #60A5FA)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #7B8694, #475569 45%, #334155 70%, #1E293B)',
},
2: {
icon: `cloudy-1-${dayNight}.svg`,
description: 'Partly Cloudy',
gradient: isDarkMode
? isDay
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #D4E1ED, #8BA3B8 35%, #617A93 60%, #426070)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #6B7583, #4A5563 40%, #3A4450 65%, #2A3340)'
: isDay
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #FFFFFF, #E0F2FE 28%, #BFDBFE 58%, #93C5FD)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #8B99AB, #64748B 45%, #475569 70%, #334155)',
},
3: {
icon: `cloudy-1-${dayNight}.svg`,
description: 'Cloudy',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #B8C3CF, #758190 38%, #546270 65%, #3D4A58)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #F5F8FA, #CBD5E1 32%, #94A3B8 65%, #64748B)',
},
45: {
icon: `fog-${dayNight}.svg`,
description: 'Foggy',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #C5CDD8, #8892A0 38%, #697380 65%, #4F5A68)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #FFFFFF, #E2E8F0 30%, #CBD5E1 62%, #94A3B8)',
},
48: {
icon: `fog-${dayNight}.svg`,
description: 'Rime Fog',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #C5CDD8, #8892A0 38%, #697380 65%, #4F5A68)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #FFFFFF, #E2E8F0 30%, #CBD5E1 62%, #94A3B8)',
},
51: {
icon: `rainy-1-${dayNight}.svg`,
description: 'Light Drizzle',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #B8D4E5, #6FA4C5 35%, #4A85AC 60%, #356A8E)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #E5FBFF, #A5F3FC 28%, #67E8F9 60%, #22D3EE)',
},
53: {
icon: `rainy-1-${dayNight}.svg`,
description: 'Drizzle',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #B8D4E5, #6FA4C5 35%, #4A85AC 60%, #356A8E)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #E5FBFF, #A5F3FC 28%, #67E8F9 60%, #22D3EE)',
},
55: {
icon: `rainy-2-${dayNight}.svg`,
description: 'Heavy Drizzle',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #A5C5D8, #5E92B0 35%, #3F789D 60%, #2A5F82)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #D4F3FF, #7DD3FC 30%, #38BDF8 62%, #0EA5E9)',
},
61: {
icon: `rainy-2-${dayNight}.svg`,
description: 'Light Rain',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #A5C5D8, #5E92B0 35%, #3F789D 60%, #2A5F82)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #D4F3FF, #7DD3FC 30%, #38BDF8 62%, #0EA5E9)',
},
63: {
icon: `rainy-2-${dayNight}.svg`,
description: 'Rain',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #8DB3C8, #4D819F 38%, #326A87 65%, #215570)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #B8E8FF, #38BDF8 32%, #0EA5E9 65%, #0284C7)',
},
65: {
icon: `rainy-3-${dayNight}.svg`,
description: 'Heavy Rain',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #7BA3B8, #3D6F8A 38%, #295973 65%, #1A455D)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #9CD9F5, #0EA5E9 32%, #0284C7 65%, #0369A1)',
},
71: {
icon: `snowy-1-${dayNight}.svg`,
description: 'Light Snow',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #E5F0FA, #9BB5CE 32%, #7496B8 58%, #527A9E)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #FFFFFF, #F0F9FF 25%, #E0F2FE 55%, #BAE6FD)',
},
73: {
icon: `snowy-2-${dayNight}.svg`,
description: 'Snow',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #D4E5F3, #85A1BD 35%, #6584A8 60%, #496A8E)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #FAFEFF, #E0F2FE 28%, #BAE6FD 60%, #7DD3FC)',
},
75: {
icon: `snowy-3-${dayNight}.svg`,
description: 'Heavy Snow',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #BDD8EB, #6F92AE 35%, #4F7593 60%, #365A78)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #F0FAFF, #BAE6FD 30%, #7DD3FC 62%, #38BDF8)',
},
77: {
icon: `snowy-1-${dayNight}.svg`,
description: 'Snow Grains',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #E5F0FA, #9BB5CE 32%, #7496B8 58%, #527A9E)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #FFFFFF, #F0F9FF 25%, #E0F2FE 55%, #BAE6FD)',
},
80: {
icon: `rainy-2-${dayNight}.svg`,
description: 'Light Showers',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #A5C5D8, #5E92B0 35%, #3F789D 60%, #2A5F82)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #D4F3FF, #7DD3FC 30%, #38BDF8 62%, #0EA5E9)',
},
81: {
icon: `rainy-2-${dayNight}.svg`,
description: 'Showers',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #8DB3C8, #4D819F 38%, #326A87 65%, #215570)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #B8E8FF, #38BDF8 32%, #0EA5E9 65%, #0284C7)',
},
82: {
icon: `rainy-3-${dayNight}.svg`,
description: 'Heavy Showers',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #7BA3B8, #3D6F8A 38%, #295973 65%, #1A455D)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #9CD9F5, #0EA5E9 32%, #0284C7 65%, #0369A1)',
},
85: {
icon: `snowy-2-${dayNight}.svg`,
description: 'Light Snow Showers',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #D4E5F3, #85A1BD 35%, #6584A8 60%, #496A8E)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #FAFEFF, #E0F2FE 28%, #BAE6FD 60%, #7DD3FC)',
},
86: {
icon: `snowy-3-${dayNight}.svg`,
description: 'Snow Showers',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #BDD8EB, #6F92AE 35%, #4F7593 60%, #365A78)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #F0FAFF, #BAE6FD 30%, #7DD3FC 62%, #38BDF8)',
},
95: {
icon: `scattered-thunderstorms-${dayNight}.svg`,
description: 'Thunderstorm',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #8A95A3, #5F6A7A 38%, #475260 65%, #2F3A48)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #C8D1DD, #94A3B8 32%, #64748B 65%, #475569)',
},
96: {
icon: 'severe-thunderstorm.svg',
description: 'Thunderstorm + Hail',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #7A8593, #515C6D 38%, #3A4552 65%, #242D3A)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #B0BBC8, #64748B 32%, #475569 65%, #334155)',
},
99: {
icon: 'severe-thunderstorm.svg',
description: 'Severe Thunderstorm',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #6A7583, #434E5D 40%, #2F3A47 68%, #1C2530)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #9BA8B8, #475569 35%, #334155 68%, #1E293B)',
},
};
return weatherMap[code] || weatherMap[0];
};
const Weather = ({
location,
current,
daily,
timezone,
}: WeatherWidgetProps) => {
const [isDarkMode, setIsDarkMode] = useState(false);
const unit = getMeasurementUnit();
const isImperial = unit === 'imperial';
const tempUnitLabel = isImperial ? '°F' : '°C';
const windUnitLabel = isImperial ? 'mph' : 'km/h';
const formatTemp = (celsius: number) => {
if (!Number.isFinite(celsius)) return 0;
return Math.round(isImperial ? (celsius * 9) / 5 + 32 : celsius);
};
const formatWind = (speedKmh: number) => {
if (!Number.isFinite(speedKmh)) return 0;
return Math.round(isImperial ? speedKmh * 0.621371 : speedKmh);
};
useEffect(() => {
const checkDarkMode = () => {
setIsDarkMode(document.documentElement.classList.contains('dark'));
};
checkDarkMode();
const observer = new MutationObserver(checkDarkMode);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class'],
});
return () => observer.disconnect();
}, []);
const weatherInfo = useMemo(
() =>
getWeatherInfo(
current?.weather_code || 0,
current?.is_day === 1,
isDarkMode,
),
[current?.weather_code, current?.is_day, isDarkMode],
);
const forecast = useMemo(() => {
if (!daily?.time || daily.time.length === 0) return [];
return daily.time.slice(1, 7).map((time, idx) => {
const date = new Date(time);
const dayName = date.toLocaleDateString('en-US', { weekday: 'short' });
const isDay = true;
const weatherCode = daily.weather_code[idx + 1];
const info = getWeatherInfo(weatherCode, isDay, isDarkMode);
return {
day: dayName,
icon: info.icon,
high: formatTemp(daily.temperature_2m_max[idx + 1]),
low: formatTemp(daily.temperature_2m_min[idx + 1]),
precipitation: daily.precipitation_probability_max[idx + 1] || 0,
};
});
}, [daily, isDarkMode, isImperial]);
if (!current || !daily || !daily.time || daily.time.length === 0) {
return (
<div className="relative overflow-hidden rounded-lg shadow-md bg-gray-200 dark:bg-gray-800">
<div className="p-4 text-black dark:text-white">
<p className="text-sm">Weather data unavailable for {location}</p>
</div>
</div>
);
}
return (
<div className="relative overflow-hidden rounded-lg shadow-md">
<div
className="absolute inset-0"
style={{
background: weatherInfo.gradient,
}}
/>
<div className="relative p-4 text-gray-800 dark:text-white">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-3">
<img
src={`/weather-ico/${weatherInfo.icon}`}
alt={weatherInfo.description}
className="w-16 h-16 drop-shadow-lg"
/>
<div>
<div className="flex items-baseline gap-1">
<span className="text-4xl font-bold drop-shadow-md">
{formatTemp(current.temperature_2m)}°
</span>
<span className="text-lg">{tempUnitLabel}</span>
</div>
<p className="text-sm font-medium drop-shadow mt-0.5">
{weatherInfo.description}
</p>
</div>
</div>
<div className="text-right">
<p className="text-xs font-medium opacity-90">
{formatTemp(daily.temperature_2m_max[0])}°{' '}
{formatTemp(daily.temperature_2m_min[0])}°
</p>
</div>
</div>
<div className="mb-3 pb-3 border-b border-gray-800/20 dark:border-white/20">
<h3 className="text-base font-semibold drop-shadow-md">{location}</h3>
<p className="text-xs text-gray-700 dark:text-white/80 drop-shadow mt-0.5">
{new Date(current.time).toLocaleString('en-US', {
weekday: 'short',
hour: 'numeric',
minute: '2-digit',
})}
</p>
</div>
<div className="grid grid-cols-6 gap-2 mb-3 pb-3 border-b border-gray-800/20 dark:border-white/20">
{forecast.map((day, idx) => (
<div
key={idx}
className="flex flex-col items-center bg-gray-800/10 dark:bg-white/10 backdrop-blur-sm rounded-md p-2"
>
<p className="text-xs font-medium mb-1">{day.day}</p>
<img
src={`/weather-ico/${day.icon}`}
alt=""
className="w-8 h-8 mb-1"
/>
<div className="flex items-center gap-1 text-xs">
<span className="font-semibold">{day.high}°</span>
<span className="text-gray-600 dark:text-white/60">
{day.low}°
</span>
</div>
{day.precipitation > 0 && (
<div className="flex items-center gap-0.5 mt-1">
<Droplets className="w-3 h-3 text-gray-600 dark:text-white/70" />
<span className="text-[10px] text-gray-600 dark:text-white/70">
{day.precipitation}%
</span>
</div>
)}
</div>
))}
</div>
<div className="grid grid-cols-3 gap-2 text-xs">
<div className="flex items-center gap-2 bg-gray-800/10 dark:bg-white/10 backdrop-blur-sm rounded-md p-2">
<Wind className="w-4 h-4 text-gray-700 dark:text-white/80 flex-shrink-0" />
<div>
<p className="text-[10px] text-gray-600 dark:text-white/70">
Wind
</p>
<p className="font-semibold">
{formatWind(current.wind_speed_10m)} {windUnitLabel}
</p>
</div>
</div>
<div className="flex items-center gap-2 bg-gray-800/10 dark:bg-white/10 backdrop-blur-sm rounded-md p-2">
<Droplets className="w-4 h-4 text-gray-700 dark:text-white/80 flex-shrink-0" />
<div>
<p className="text-[10px] text-gray-600 dark:text-white/70">
Humidity
</p>
<p className="font-semibold">
{Math.round(current.relative_humidity_2m)}%
</p>
</div>
</div>
<div className="flex items-center gap-2 bg-gray-800/10 dark:bg-white/10 backdrop-blur-sm rounded-md p-2">
<Gauge className="w-4 h-4 text-gray-700 dark:text-white/80 flex-shrink-0" />
<div>
<p className="text-[10px] text-gray-600 dark:text-white/70">
Feels Like
</p>
<p className="font-semibold">
{formatTemp(current.apparent_temperature)}
{tempUnitLabel}
</p>
</div>
</div>
</div>
</div>
</div>
);
};
export default Weather;

View File

@@ -0,0 +1,16 @@
'use client';
import { ThemeProvider } from 'next-themes';
const ThemeProviderComponent = ({
children,
}: {
children: React.ReactNode;
}) => {
return (
<ThemeProvider attribute="class" enableSystem={false} defaultTheme="dark">
{children}
</ThemeProvider>
);
};
export default ThemeProviderComponent;

View File

@@ -0,0 +1,60 @@
'use client';
import { useTheme } from 'next-themes';
import { useCallback, useEffect, useState } from 'react';
import Select from '../ui/Select';
type Theme = 'dark' | 'light' | 'system';
const ThemeSwitcher = ({ className }: { className?: string }) => {
const [mounted, setMounted] = useState(false);
const { theme, setTheme } = useTheme();
const isTheme = useCallback((t: Theme) => t === theme, [theme]);
const handleThemeSwitch = (theme: Theme) => {
setTheme(theme);
};
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (isTheme('system')) {
const preferDarkScheme = window.matchMedia(
'(prefers-color-scheme: dark)',
);
const detectThemeChange = (event: MediaQueryListEvent) => {
const theme: Theme = event.matches ? 'dark' : 'light';
setTheme(theme);
};
preferDarkScheme.addEventListener('change', detectThemeChange);
return () => {
preferDarkScheme.removeEventListener('change', detectThemeChange);
};
}
}, [isTheme, setTheme, theme]);
// Avoid Hydration Mismatch
if (!mounted) {
return null;
}
return (
<Select
className={className}
value={theme}
onChange={(e) => handleThemeSwitch(e.target.value as Theme)}
options={[
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' },
]}
/>
);
};
export default ThemeSwitcher;

View File

@@ -0,0 +1,22 @@
const Loader = () => {
return (
<svg
aria-hidden="true"
className="w-8 h-8 text-light-200 fill-light-secondary dark:text-[#202020] animate-spin dark:fill-[#ffffff3b]"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100.003 78.2051 78.1951 100.003 50.5908 100C22.9765 99.9972 0.997224 78.018 1 50.4037C1.00281 22.7993 22.8108 0.997224 50.4251 1C78.0395 1.00281 100.018 22.8108 100 50.4251ZM9.08164 50.594C9.06312 73.3997 27.7909 92.1272 50.5966 92.1457C73.4023 92.1642 92.1298 73.4365 92.1483 50.6308C92.1669 27.8251 73.4392 9.0973 50.6335 9.07878C27.8278 9.06026 9.10003 27.787 9.08164 50.594Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4037 97.8624 35.9116 96.9801 33.5533C95.1945 28.8227 92.871 24.3692 90.0681 20.348C85.6237 14.1775 79.4473 9.36872 72.0454 6.45794C64.6435 3.54717 56.3134 2.65431 48.3133 3.89319C45.869 4.27179 44.3768 6.77534 45.014 9.20079C45.6512 11.6262 48.1343 13.0956 50.5786 12.717C56.5073 11.8281 62.5542 12.5399 68.0406 14.7911C73.527 17.0422 78.2187 20.7487 81.5841 25.4923C83.7976 28.5886 85.4467 32.059 86.4416 35.7474C87.1273 38.1189 89.5423 39.6781 91.9676 39.0409Z"
fill="currentFill"
/>
</svg>
);
};
export default Loader;

View File

@@ -0,0 +1,46 @@
import { cn } from '@/lib/utils';
import { ChevronDown } from 'lucide-react';
import { SelectHTMLAttributes, forwardRef } from 'react';
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
options: { value: any; label: string; disabled?: boolean }[];
loading?: boolean;
}
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
({ className, options, loading = false, disabled, ...restProps }, ref) => {
return (
<div
className={cn(
'relative inline-flex w-full items-center',
disabled && 'opacity-60',
)}
>
<select
{...restProps}
ref={ref}
disabled={disabled || loading}
className={cn(
'bg-light-secondary dark:bg-dark-secondary px-3 py-2 flex items-center overflow-hidden border border-light-200 dark:border-dark-200 dark:text-white rounded-lg appearance-none w-full pr-10 text-xs lg:text-sm',
className,
)}
>
{options.map(({ label, value, disabled: optionDisabled }) => {
return (
<option key={value} value={value} disabled={optionDisabled}>
{label}
</option>
);
})}
</select>
<span className="pointer-events-none absolute right-3 flex h-4 w-4 items-center justify-center text-black/50 dark:text-white/60">
<ChevronDown className="h-4 w-4" />
</span>
</div>
);
},
);
Select.displayName = 'Select';
export default Select;

View File

@@ -0,0 +1,6 @@
/**
* Next.js instrumentation. Конфиг на бекенде (chat-svc).
*/
export const register = async () => {
// Пусто — логика на бекенде
};

View File

@@ -0,0 +1,26 @@
export const getSuggestions = async (
chatHistory: [string, string][],
locale?: string,
) => {
const chatModel = localStorage.getItem('chatModelKey');
const chatModelProvider = localStorage.getItem('chatModelProviderId');
const res = await fetch(`/api/suggestions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
chatHistory,
chatModel: {
providerId: chatModelProvider,
key: chatModel,
},
locale,
}),
});
const data = (await res.json()) as { suggestions: string[] };
return data.suggestions;
};

View File

@@ -0,0 +1,36 @@
/**
* better-auth клиент для web-svc
* /api/auth проксируется через api-gateway в auth-svc
* Bearer token (set-auth-token) сохраняется в localStorage для library-svc, memory-svc и др.
*/
import { createAuthClient } from 'better-auth/react';
const baseURL =
typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000';
const AUTH_TOKEN_KEY = 'auth_token';
export const authClient = createAuthClient({
baseURL,
fetchOptions: {
onSuccess: (ctx) => {
const token = ctx.response?.headers?.get?.('set-auth-token');
if (token) {
if (typeof window !== 'undefined') {
localStorage.setItem(AUTH_TOKEN_KEY, token);
}
}
},
},
});
export function getStoredAuthToken(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem(AUTH_TOKEN_KEY);
}
export function clearStoredAuthToken(): void {
if (typeof window === 'undefined') return;
localStorage.removeItem(AUTH_TOKEN_KEY);
localStorage.removeItem('access_token');
}

View File

@@ -0,0 +1,29 @@
'use client';
const getClientConfig = (key: string, defaultVal?: any) => {
return localStorage.getItem(key) ?? defaultVal ?? undefined;
};
export const getTheme = () => getClientConfig('theme', 'dark');
export const getAutoMediaSearch = () =>
getClientConfig('autoMediaSearch', 'true') === 'true';
export const getSystemInstructions = () =>
getClientConfig('systemInstructions', '');
export const getShowWeatherWidget = () =>
getClientConfig('showWeatherWidget', 'true') === 'true';
export const getShowNewsWidget = () =>
getClientConfig('showNewsWidget', 'true') === 'true';
export const getMeasurementUnit = () => {
const value =
getClientConfig('measureUnit') ??
getClientConfig('measurementUnit', 'metric');
if (typeof value !== 'string') return 'metric';
return value.toLowerCase();
};

View File

@@ -0,0 +1,87 @@
'use client';
const STORAGE_KEY = 'gooseek_sidebar_menu_v2';
// Порядок по популярности для РФ: поиск, история, финансы, здоровье, путешествия, обучение, медицина, недвижимость, психология, спорт, дети, товары, покупки, игры, налоги, законодательство
export const DEFAULT_ORDER = [
'discover',
'library',
'finance',
'health',
'travel',
'education',
'medicine',
'spaces',
'realEstate',
'psychology',
'sports',
'children',
'goods',
'shopping',
'games',
'taxes',
'legislation',
'profile',
] as const;
export type SidebarItemId = (typeof DEFAULT_ORDER)[number];
export interface SidebarMenuConfig {
order: string[];
visible: Record<string, boolean>;
}
const defaultVisible: Record<string, boolean> = Object.fromEntries(
DEFAULT_ORDER.map((id) => [id, true]),
);
function parseConfig(raw: string | null): SidebarMenuConfig {
if (!raw) {
return { order: [...DEFAULT_ORDER], visible: { ...defaultVisible } };
}
try {
const parsed = JSON.parse(raw) as Partial<SidebarMenuConfig>;
const order = Array.isArray(parsed.order) ? parsed.order : [...DEFAULT_ORDER];
const visible =
parsed.visible && typeof parsed.visible === 'object'
? { ...defaultVisible, ...parsed.visible }
: { ...defaultVisible };
return { order, visible };
} catch {
return { order: [...DEFAULT_ORDER], visible: { ...defaultVisible } };
}
}
export function getSidebarMenuConfig(): SidebarMenuConfig {
if (typeof window === 'undefined') {
return { order: [...DEFAULT_ORDER], visible: { ...defaultVisible } };
}
return parseConfig(localStorage.getItem(STORAGE_KEY));
}
export function setSidebarMenuConfig(config: SidebarMenuConfig): void {
if (typeof window === 'undefined') return;
localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
window.dispatchEvent(new CustomEvent('client-config-changed'));
}
export function isSidebarItemVisible(id: string): boolean {
const config = getSidebarMenuConfig();
return config.visible[id] !== false;
}
export function setSidebarItemVisible(id: string, visible: boolean): void {
const config = getSidebarMenuConfig();
config.visible[id] = visible;
setSidebarMenuConfig(config);
}
export function getSidebarOrder(): string[] {
return getSidebarMenuConfig().order;
}
export function setSidebarOrder(order: string[]): void {
const config = getSidebarMenuConfig();
config.order = order;
setSidebarMenuConfig(config);
}

View File

@@ -0,0 +1,109 @@
import type { Model } from '../types-ui';
type BaseUIConfigField = {
name: string;
key: string;
required: boolean;
description: string;
scope: 'client' | 'server';
env?: string;
};
type StringUIConfigField = BaseUIConfigField & {
type: 'string';
placeholder?: string;
default?: string;
};
type SelectUIConfigFieldOptions = {
name: string;
value: string;
};
type SelectUIConfigField = BaseUIConfigField & {
type: 'select';
default?: string;
options: SelectUIConfigFieldOptions[];
};
type PasswordUIConfigField = BaseUIConfigField & {
type: 'password';
placeholder?: string;
default?: string;
};
type TextareaUIConfigField = BaseUIConfigField & {
type: 'textarea';
placeholder?: string;
default?: string;
};
type SwitchUIConfigField = BaseUIConfigField & {
type: 'switch';
default?: boolean;
};
type UIConfigField =
| StringUIConfigField
| SelectUIConfigField
| PasswordUIConfigField
| TextareaUIConfigField
| SwitchUIConfigField;
type ConfigModelProvider = {
id: string;
name: string;
type: string;
chatModels: Model[];
embeddingModels: Model[];
config: { [key: string]: any };
hash: string;
};
type Config = {
version: number;
setupComplete: boolean;
preferences: {
[key: string]: any;
};
personalization: {
[key: string]: any;
};
modelProviders: ConfigModelProvider[];
search: {
[key: string]: any;
};
};
type EnvMap = {
[key: string]: {
fieldKey: string;
providerKey: string;
};
};
type ModelProviderUISection = {
name: string;
key: string;
fields: UIConfigField[];
};
type UIConfigSections = {
preferences: UIConfigField[];
personalization: UIConfigField[];
modelProviders: ModelProviderUISection[];
search: UIConfigField[];
};
export type {
UIConfigField,
Config,
EnvMap,
UIConfigSections,
SelectUIConfigField,
StringUIConfigField,
ModelProviderUISection,
ConfigModelProvider,
TextareaUIConfigField,
SwitchUIConfigField,
};

View File

@@ -0,0 +1,131 @@
/**
* Клиент для Geo Device Service.
* Собирает данные с клиента и отправляет на сервис для полного контекста.
*/
const GEO_CONTEXT_API = '/api/geo-context';
export interface GeoDeviceContext {
geo: {
latitude: number;
longitude: number;
country: string;
countryCode: string;
region: string;
city: string;
timezone: string;
source: string;
} | null;
device: {
type: 'desktop' | 'mobile' | 'tablet' | 'wearable' | 'smarttv' | 'unknown';
vendor?: string;
model?: string;
};
browser: { name: string; version: string; full: string };
os: { name: string; version: string; full: string };
client: Record<string, unknown>;
ip?: string;
userAgent: string;
acceptLanguage?: string;
requestedAt: string;
}
const collectClientData = (): {
geo?: { latitude: number; longitude: number; city?: string; country?: string; timezone?: string };
client?: {
screenWidth?: number;
screenHeight?: number;
viewportWidth?: number;
viewportHeight?: number;
devicePixelRatio?: number;
timezone?: string;
language?: string;
languages?: string[];
platform?: string;
hardwareConcurrency?: number;
deviceMemory?: number;
cookieEnabled?: boolean;
doNotTrack?: string | null;
};
} => {
if (typeof window === 'undefined') return {};
const nav = navigator;
const screen = window.screen;
return {
client: {
screenWidth: screen?.width,
screenHeight: screen?.height,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
devicePixelRatio: window.devicePixelRatio,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
language: nav.language,
languages: nav.languages ? [...nav.languages] : undefined,
platform: nav.platform,
hardwareConcurrency: (nav as Navigator & { hardwareConcurrency?: number }).hardwareConcurrency,
deviceMemory: (nav as Navigator & { deviceMemory?: number }).deviceMemory,
cookieEnabled: nav.cookieEnabled,
doNotTrack: nav.doNotTrack ?? null,
},
};
};
/**
* Получить контекст (только серверные данные, вызывается с API route).
*/
export const fetchContext = async (): Promise<GeoDeviceContext> => {
const res = await fetch(GEO_CONTEXT_API);
if (!res.ok) throw new Error('Failed to fetch context');
return res.json();
};
/**
* Получить полный контекст с данными клиента (вызывать из браузера).
*/
export const fetchContextWithClient = async (): Promise<GeoDeviceContext> => {
const clientData = collectClientData();
const res = await fetch(GEO_CONTEXT_API, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(clientData),
});
if (!res.ok) throw new Error('Failed to fetch context');
return res.json();
};
/**
* С геолокацией из браузера (если пользователь разрешил).
*/
export const fetchContextWithGeolocation = async (): Promise<GeoDeviceContext> => {
const clientData = collectClientData();
return new Promise((resolve, reject) => {
if (!navigator.geolocation) {
fetchContextWithClient().then(resolve).catch(reject);
return;
}
navigator.geolocation.getCurrentPosition(
(position) => {
const geo = {
latitude: position.coords.latitude,
longitude: position.coords.longitude,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
};
fetch(GEO_CONTEXT_API, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...clientData, geo }),
})
.then((r) => r.json())
.then(resolve)
.catch(reject);
},
() => fetchContextWithClient().then(resolve).catch(reject),
);
});
};

View File

@@ -0,0 +1,87 @@
/**
* Миграция гостевых чатов в профиль при авторизации.
*/
import {
clearGuestData,
getGuestChats,
getGuestMessages,
hasGuestData,
} from './guest-storage';
const MIGRATION_DONE_KEY = 'gooseek_guest_migrated_at';
export async function migrateGuestToProfile(
token: string,
onProgress?: (current: number, total: number) => void,
): Promise<{ migrated: number; failed: number }> {
if (!hasGuestData()) return { migrated: 0, failed: 0 };
const chats = getGuestChats();
let migrated = 0;
let failed = 0;
const total = chats.length;
for (let i = 0; i < chats.length; i++) {
onProgress?.(i + 1, total);
const chat = chats[i];
try {
const createRes = await fetch('/api/v1/library/threads', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
id: chat.id,
title: chat.title,
sources: chat.sources,
files: chat.files,
}),
});
if (!createRes.ok) {
failed++;
continue;
}
const messages = getGuestMessages(chat.id);
for (const msg of messages) {
const msgRes = await fetch(
`/api/v1/library/threads/${encodeURIComponent(chat.id)}/messages`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
messageId: msg.messageId,
query: msg.query,
backendId: msg.backendId,
responseBlocks: msg.responseBlocks,
status: msg.status,
}),
},
);
if (!msgRes.ok) failed++;
else migrated++;
}
} catch {
failed++;
}
}
clearGuestData();
if (typeof window !== 'undefined') {
localStorage.setItem(MIGRATION_DONE_KEY, Date.now().toString());
}
return { migrated, failed };
}
export function wasRecentlyMigrated(): boolean {
if (typeof window === 'undefined') return false;
const t = localStorage.getItem(MIGRATION_DONE_KEY);
if (!t) return false;
const ts = parseInt(t, 10);
return !isNaN(ts) && Date.now() - ts < 60000;
}

View File

@@ -0,0 +1,160 @@
/**
* Локальное хранение чатов гостей (localStorage).
* При авторизации данные мигрируют в library-svc.
*/
import type { Block } from './types';
const KEY = 'gooseek_guest_data';
export interface GuestChat {
id: string;
title: string;
createdAt: string;
sources: string[];
files: { fileId: string; name: string }[];
}
export interface GuestMessage {
messageId: string;
chatId: string;
backendId: string;
query: string;
createdAt: string;
responseBlocks: Block[];
status: 'answering' | 'completed' | 'error';
}
interface GuestData {
threads: GuestChat[];
messages: Record<string, GuestMessage[]>;
}
function load(): GuestData {
if (typeof window === 'undefined') {
return { threads: [], messages: {} };
}
try {
const raw = localStorage.getItem(KEY);
if (!raw) return { threads: [], messages: {} };
const parsed = JSON.parse(raw) as GuestData;
return {
threads: Array.isArray(parsed.threads) ? parsed.threads : [],
messages: parsed.messages && typeof parsed.messages === 'object' ? parsed.messages : {},
};
} catch {
return { threads: [], messages: {} };
}
}
function save(data: GuestData): void {
if (typeof window === 'undefined') return;
try {
localStorage.setItem(KEY, JSON.stringify(data));
} catch (e) {
console.error('[guest-storage] save failed:', e);
}
}
export function getGuestChats(): GuestChat[] {
const data = load();
return [...data.threads].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
}
export function getGuestChat(id: string): GuestChat | null {
const data = load();
return data.threads.find((t) => t.id === id) ?? null;
}
export function getGuestMessages(chatId: string): GuestMessage[] {
const data = load();
return (data.messages[chatId] ?? []).sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
);
}
export function upsertGuestThread(chat: {
id: string;
title: string;
sources?: string[];
files?: { fileId: string; name: string }[];
}): void {
const data = load();
const existing = data.threads.findIndex((t) => t.id === chat.id);
const now = new Date().toISOString();
const thread: GuestChat = {
id: chat.id,
title: chat.title,
createdAt: existing >= 0 ? data.threads[existing].createdAt : now,
sources: chat.sources ?? [],
files: chat.files ?? [],
};
if (existing >= 0) {
data.threads[existing] = { ...data.threads[existing], ...thread };
} else {
data.threads.push(thread);
}
save(data);
}
export function upsertGuestMessage(
chatId: string,
message: {
messageId: string;
query: string;
backendId?: string;
responseBlocks?: Block[];
status?: 'answering' | 'completed' | 'error';
},
): void {
const data = load();
if (!data.messages[chatId]) data.messages[chatId] = [];
const idx = data.messages[chatId].findIndex((m) => m.messageId === message.messageId);
const now = new Date().toISOString();
const msg: GuestMessage = {
messageId: message.messageId,
chatId,
backendId: message.backendId ?? '',
query: message.query,
createdAt: idx >= 0 ? data.messages[chatId][idx].createdAt : now,
responseBlocks: message.responseBlocks ?? [],
status: message.status ?? 'answering',
};
if (idx >= 0) {
data.messages[chatId][idx] = msg;
} else {
data.messages[chatId].push(msg);
}
save(data);
}
export function setGuestMessages(
chatId: string,
messages: Omit<GuestMessage, 'chatId'>[],
): void {
const data = load();
data.messages[chatId] = messages.map((m) => ({
...m,
chatId,
}));
save(data);
}
export function deleteGuestChat(chatId: string): void {
const data = load();
data.threads = data.threads.filter((t) => t.id !== chatId);
delete data.messages[chatId];
save(data);
}
export function clearGuestData(): void {
if (typeof window === 'undefined') return;
localStorage.removeItem(KEY);
}
export function hasGuestData(): boolean {
const data = load();
return data.threads.length > 0;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,173 @@
/**
* Клиент для 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';
export interface LocalizationContext {
locale: string;
language: string;
region: string | null;
countryCode: string | null;
timezone: string | null;
source: 'geo' | 'accept-language' | 'client' | 'fallback';
}
const collectClientData = () => {
if (typeof window === 'undefined') return {};
const nav = navigator;
return {
client: {
language: nav.language,
languages: nav.languages ? [...nav.languages] : undefined,
},
};
};
/**
* Получить locale (серверный вызов — без client data).
*/
export async function fetchLocale(): Promise<LocalizationContext> {
const res = await fetch(LOCALE_API);
if (!res.ok) throw new Error('Failed to fetch locale');
return res.json();
}
/**
* Получить locale с данными клиента (браузер language).
*/
export async function fetchLocaleWithClient(): Promise<LocalizationContext> {
const clientData = collectClientData();
const res = await fetch(LOCALE_API, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(clientData),
});
if (!res.ok) throw new Error('Failed to fetch locale');
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,
region: null,
countryCode: null,
timezone: null,
source: 'fallback',
};
}
import {
getEmbeddedTranslations,
translationLocaleFor,
} from './localization/embeddedTranslations';
/**
* Получить переводы для locale.
* При недоступности API использует встроенные переводы.
*/
export async function fetchTranslations(
locale: string,
): Promise<Record<string, string>> {
const fetchLocale = translationLocaleFor(locale);
try {
const res = await fetch(
`${TRANSLATIONS_API}/${encodeURIComponent(fetchLocale)}`,
);
if (res.ok) return res.json();
} catch {
/* API недоступен */
}
return getEmbeddedTranslations(locale);
}
/**
* Получить locale и переводы одним вызовом.
*/
export async function fetchLocalization(): Promise<{
locale: LocalizationContext;
translations: Record<string, string>;
}> {
const locale = await fetchLocaleWithClient();
const translations = await fetchTranslations(locale.locale);
return { locale, translations };
}

View File

@@ -0,0 +1,208 @@
'use client';
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import {
fetchLocaleWithGeoFirst,
fetchTranslations,
type LocalizationContext as LocaleContext,
} from '@/lib/localization';
type Translations = Record<string, string>;
interface LocalizationState {
locale: LocaleContext | null;
translations: Translations | null;
loading: boolean;
error: boolean;
}
interface LocalizationContextValue extends LocalizationState {
t: (key: string) => string;
localeCode: string;
}
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',
'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',
};
const LocalizationContext = createContext<LocalizationContextValue | null>(null);
export function LocalizationProvider({
children,
}: {
children: React.ReactNode;
}) {
const [state, setState] = useState<LocalizationState>({
locale: null,
translations: null,
loading: true,
error: false,
});
useEffect(() => {
let cancelled = false;
async function load() {
try {
const loc = await fetchLocaleWithGeoFirst();
if (cancelled) return;
let trans: Record<string, string>;
try {
trans = await fetchTranslations(loc.locale);
} catch {
trans = (await import('./embeddedTranslations')).getEmbeddedTranslations(
loc.locale,
);
}
if (cancelled) return;
setState({
locale: loc,
translations: trans,
loading: false,
error: false,
});
if (typeof document !== 'undefined') {
document.documentElement.lang = loc.locale;
}
} catch {
if (cancelled) return;
setState({
locale: null,
translations: null,
loading: false,
error: true,
});
}
}
load();
return () => {
cancelled = true;
};
}, []);
const t = useCallback(
(key: string): string => {
if (state.translations && key in state.translations) {
return state.translations[key] ?? key;
}
return defaultTranslations[key] ?? key;
},
[state.translations],
);
const localeCode = state.locale?.locale ?? 'ru';
const value = useMemo<LocalizationContextValue>(
() => ({
...state,
t,
localeCode,
}),
[state, t, localeCode],
);
return (
<LocalizationContext.Provider value={value}>
{children}
</LocalizationContext.Provider>
);
}
export function useTranslation(): LocalizationContextValue {
const ctx = useContext(LocalizationContext);
if (!ctx) {
return {
locale: null,
translations: null,
loading: false,
error: true,
t: (key: string) => defaultTranslations[key] ?? key,
localeCode: 'ru',
};
}
return ctx;
}

View File

@@ -0,0 +1,62 @@
/**
* Маппинг ISO 3166-1 alpha-2 → locale (как в localization-service).
* Используется когда localization-service недоступен, а geo-context (погода) работает.
* По умолчанию русский; при геопозиции другой страны — язык этой страны.
*/
const COUNTRY_TO_LOCALE: Record<string, string> = {
RU: 'ru',
UA: 'uk',
BY: 'be',
KZ: 'kk',
US: 'en',
GB: 'en',
AU: 'en',
CA: 'en',
DE: 'de',
AT: 'de',
CH: 'de',
FR: 'fr',
BE: 'fr',
ES: 'es',
MX: 'es',
AR: 'es',
IT: 'it',
PT: 'pt',
BR: 'pt',
PL: 'pl',
NL: 'nl',
SE: 'sv',
NO: 'nb',
DK: 'da',
FI: 'fi',
CZ: 'cs',
SK: 'sk',
HU: 'hu',
RO: 'ro',
BG: 'bg',
HR: 'hr',
RS: 'sr',
GR: 'el',
TR: 'tr',
JP: 'ja',
CN: 'zh',
TW: 'zh-TW',
KR: 'ko',
IN: 'hi',
TH: 'th',
VI: 'vi',
ID: 'id',
MY: 'ms',
SA: 'ar',
AE: 'ar',
EG: 'ar',
IL: 'he',
IR: 'fa',
};
export function localeFromCountryCode(
countryCode: string | null | undefined,
): string {
if (!countryCode) return 'ru';
return COUNTRY_TO_LOCALE[countryCode.toUpperCase()] ?? 'ru';
}

View File

@@ -0,0 +1,616 @@
/**
* Встроенные переводы — fallback при недоступности localization-service.
* Синхронизировать с services/localization-svc/src/translations/index.ts
*/
export const embeddedTranslations: Record<
string,
Record<string, string>
> = {
en: {
'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',
'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',
'nav.profile': 'Profile',
'profile.account': 'Account',
'profile.preferences': 'Preferences',
'profile.personalize': 'Personalize',
'profile.billing': 'Billing',
'profile.connectors': 'My Connectors',
'profile.appSettings': 'App settings',
'profile.preferencesDesc': 'Appearance, Language, Autosuggest, Homepage widgets.',
'profile.personalizeDesc': 'Watchlists, AI memory, personalization settings.',
'profile.connectorsDesc': 'Google Drive, Dropbox and other integrations (Pro).',
'profile.signInToView': 'Sign in to view your account.',
'profile.current': 'Current',
'profile.month': 'mo',
'profile.upgrade': 'Upgrade',
'profile.paymentHistory': 'Payment history',
'profile.comingSoon': 'Coming soon.',
'profile.connect': 'Connect',
'profile.connected': 'Connected',
},
ru: {
'app.title': 'GooSeek',
'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': 'Законодательство',
'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.documents': 'документов',
'chat.document': 'документу',
'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': 'Подключено',
},
de: {
'app.title': 'GooSeek',
'app.searchPlaceholder': 'Suchen...',
'empty.subtitle': 'Die Recherche beginnt hier.',
'input.askAnything': 'Fragen Sie alles...',
'input.askFollowUp': 'Folgefrage stellen',
'chat.send': 'Senden',
'chat.newChat': 'Neuer Chat',
'chat.suggestions': 'Vorschläge',
'discover.title': 'Entdecken',
'discover.region': 'Region',
'weather.title': 'Wetter',
'weather.feelsLike': 'Gefühlt wie',
'common.loading': 'Laden...',
'common.error': 'Fehler',
'common.retry': 'Wiederholen',
'common.close': 'Schließen',
'nav.home': 'Start',
'nav.discover': 'Entdecken',
'nav.library': 'Bibliothek',
'nav.messageHistory': 'Verlauf',
'nav.finance': 'Finanzen',
'nav.travel': 'Reisen',
'nav.spaces': 'Bereiche',
'nav.medicine': 'Medizin',
'nav.realEstate': 'Immobilien',
'nav.goods': 'Waren',
'nav.children': 'Kinder',
'nav.education': 'Bildung',
'nav.health': 'Gesundheit',
'nav.psychology': 'Psychologie',
'nav.sports': 'Sport',
'nav.shopping': 'Einkaufen',
'nav.games': 'Spiele',
'nav.taxes': 'Steuern',
'nav.legislation': 'Gesetzgebung',
'nav.sidebarSettings': 'Einstellungen Hauptmenü',
'nav.configureMenu': 'Menü anpassen',
'nav.menuSettingsHint': 'Punkte mit Schalter ein/aus, Reihenfolge mit Pfeilen oben/unten ändern.',
'nav.moveUp': 'Nach oben',
'nav.moveDown': 'Nach unten',
'nav.resetMenu': 'Zurücksetzen',
'nav.more': 'Mehr',
'chat.related': 'Weitere Fragen',
'chat.searchImages': 'Bilder suchen',
'chat.searchVideos': 'Videos suchen',
'chat.video': 'Video',
'chat.uploadedFile': 'Hochgeladene Datei',
'chat.viewMore': '{count} weitere',
'chat.sources': 'Quellen',
'chat.researchProgress': 'Forschungsfortschritt',
'chat.step': 'Schritt',
'chat.steps': 'Schritte',
'chat.answer': 'Antwort',
'chat.brainstorming': 'Brainstorming...',
'chat.formingAnswer': 'Antwort wird formuliert...',
'chat.answerFailed': 'Antwort konnte nicht aus den gefundenen Quellen erstellt werden. Versuchen Sie es anders zu formulieren.',
'chat.thinking': 'Nachdenken',
'chat.searchingQueries': 'Suche {count} {plural}',
'chat.foundResults': '{count} {plural} gefunden',
'chat.readingSources': 'Lese {count} {plural}',
'chat.scanningDocs': 'Durchsuche hochgeladene Dokumente',
'chat.readingDocs': 'Lese {count} {plural}',
'chat.processing': 'Verarbeitung',
'chat.query': 'Abfrage',
'chat.queries': 'Abfragen',
'chat.result': 'Ergebnis',
'chat.results': 'Ergebnisse',
'chat.source': 'Quelle',
'chat.document': 'Dokument',
'chat.documents': 'Dokumente',
'library.filterSourceAll': 'Alle Quellen',
'library.filterSourceWeb': 'Web',
'library.filterSourceAcademic': 'Akademisch',
'library.filterSourceSocial': 'Sozial',
'library.filterFilesAll': 'Alle',
'library.filterFilesWith': 'Mit Dateien',
'library.filterFilesWithout': 'Ohne Dateien',
'library.noResults': 'Keine Chats entsprechen Ihren Filtern.',
'library.clearFilters': 'Filter zurücksetzen',
'nav.profile': 'Profil',
'profile.account': 'Konto',
'profile.preferences': 'Einstellungen',
'profile.personalize': 'Personalisierung',
'profile.billing': 'Abrechnung',
'profile.connectors': 'Meine Connectors',
'profile.appSettings': 'App-Einstellungen',
'profile.preferencesDesc': 'Erscheinungsbild, Sprache, Autosuggest, Startseiten-Widgets.',
'profile.personalizeDesc': 'Watchlists, KI-Speicher, Personalisierung.',
'profile.connectorsDesc': 'Google Drive, Dropbox und andere Integrationen (Pro).',
'profile.signInToView': 'Melden Sie sich an, um Ihr Konto anzuzeigen.',
'profile.current': 'Aktuell',
'profile.month': 'Mo',
'profile.upgrade': 'Upgrade',
'profile.paymentHistory': 'Zahlungshistorie',
'profile.comingSoon': 'Demnächst.',
'profile.connect': 'Verbinden',
'profile.connected': 'Verbunden',
},
fr: {
'app.title': 'GooSeek',
'app.searchPlaceholder': 'Rechercher...',
'empty.subtitle': 'La recherche commence ici.',
'input.askAnything': 'Posez une question...',
'input.askFollowUp': 'Poser une question de suivi',
'chat.send': 'Envoyer',
'chat.newChat': 'Nouveau chat',
'chat.suggestions': 'Suggestions',
'discover.title': 'Découvrir',
'discover.region': 'Région',
'weather.title': 'Météo',
'weather.feelsLike': 'Ressenti',
'common.loading': 'Chargement...',
'common.error': 'Erreur',
'common.retry': 'Réessayer',
'common.close': 'Fermer',
'nav.home': 'Accueil',
'nav.discover': 'Découvrir',
'nav.library': 'Bibliothèque',
'nav.messageHistory': 'Historique',
'nav.finance': 'Finance',
'nav.travel': 'Voyage',
'nav.spaces': 'Espaces',
'nav.medicine': 'Médecine',
'nav.realEstate': 'Immobilier',
'nav.goods': 'Produits',
'nav.children': 'Enfants',
'nav.education': 'Éducation',
'nav.health': 'Santé',
'nav.psychology': 'Psychologie',
'nav.sports': 'Sport',
'nav.shopping': 'Achats',
'nav.games': 'Jeux',
'nav.taxes': 'Impôts',
'nav.legislation': 'Législation',
'nav.sidebarSettings': 'Paramètres du menu principal',
'nav.configureMenu': 'Configurer le menu',
'nav.menuSettingsHint': 'Activer/désactiver avec l\'interrupteur, changer l\'ordre avec les flèches haut/bas.',
'nav.moveUp': 'Monter',
'nav.moveDown': 'Descendre',
'nav.resetMenu': 'Réinitialiser',
'nav.more': 'Plus',
'chat.related': 'Questions de suivi',
'chat.searchImages': 'Rechercher des images',
'chat.searchVideos': 'Rechercher des vidéos',
'chat.video': 'Vidéo',
'chat.uploadedFile': 'Fichier téléchargé',
'chat.viewMore': '{count} de plus',
'chat.sources': 'Sources',
'chat.researchProgress': 'Progression de la recherche',
'chat.step': 'étape',
'chat.steps': 'étapes',
'chat.answer': 'Réponse',
'chat.brainstorming': 'Brainstorming...',
'chat.formingAnswer': 'Formulation de la réponse...',
'chat.answerFailed': 'Impossible de formuler une réponse à partir des sources trouvées. Essayez de reformuler.',
'chat.thinking': 'Réflexion',
'chat.searchingQueries': 'Recherche de {count} {plural}',
'chat.foundResults': '{count} {plural} trouvés',
'chat.readingSources': 'Lecture de {count} {plural}',
'chat.scanningDocs': 'Analyse des documents téléchargés',
'chat.readingDocs': 'Lecture de {count} {plural}',
'chat.processing': 'Traitement',
'chat.query': 'requête',
'chat.queries': 'requêtes',
'chat.result': 'résultat',
'chat.results': 'résultats',
'chat.source': 'source',
'chat.document': 'document',
'chat.documents': 'documents',
'library.filterSourceAll': 'Toutes les sources',
'library.filterSourceWeb': 'Web',
'library.filterSourceAcademic': 'Académique',
'library.filterSourceSocial': 'Réseaux',
'library.filterFilesAll': 'Tous',
'library.filterFilesWith': 'Avec fichiers',
'library.filterFilesWithout': 'Sans fichiers',
'library.noResults': 'Aucun chat ne correspond à vos filtres.',
'library.clearFilters': 'Effacer les filtres',
'nav.profile': 'Profil',
'profile.account': 'Compte',
'profile.preferences': 'Préférences',
'profile.personalize': 'Personnalisation',
'profile.billing': 'Facturation',
'profile.connectors': 'Mes connecteurs',
'profile.appSettings': 'Paramètres de l\'app',
'profile.preferencesDesc': 'Apparence, Langue, Suggestions, Widgets d\'accueil.',
'profile.personalizeDesc': 'Listes de surveillance, mémoire IA, personnalisation.',
'profile.connectorsDesc': 'Google Drive, Dropbox et autres intégrations (Pro).',
'profile.signInToView': 'Connectez-vous pour voir votre compte.',
'profile.current': 'Actuel',
'profile.month': 'mois',
'profile.upgrade': 'Mettre à niveau',
'profile.paymentHistory': 'Historique des paiements',
'profile.comingSoon': 'Bientôt disponible.',
'profile.connect': 'Connecter',
'profile.connected': 'Connecté',
},
es: {
'app.title': 'GooSeek',
'app.searchPlaceholder': 'Buscar...',
'empty.subtitle': 'La investigación comienza aquí.',
'input.askAnything': 'Pregunte lo que sea...',
'input.askFollowUp': 'Hacer una pregunta de seguimiento',
'chat.send': 'Enviar',
'chat.newChat': 'Nuevo chat',
'chat.suggestions': 'Sugerencias',
'discover.title': 'Descubrir',
'discover.region': 'Región',
'weather.title': 'Clima',
'weather.feelsLike': 'Sensación térmica',
'common.loading': 'Cargando...',
'common.error': 'Error',
'common.retry': 'Reintentar',
'common.close': 'Cerrar',
'nav.home': 'Inicio',
'nav.discover': 'Descubrir',
'nav.library': 'Biblioteca',
'nav.messageHistory': 'Historial',
'nav.finance': 'Finanzas',
'nav.travel': 'Viajes',
'nav.spaces': 'Espacios',
'nav.medicine': 'Medicina',
'nav.realEstate': 'Inmuebles',
'nav.goods': 'Productos',
'nav.children': 'Niños',
'nav.education': 'Educación',
'nav.health': 'Salud',
'nav.psychology': 'Psicología',
'nav.sports': 'Deportes',
'nav.shopping': 'Compras',
'nav.games': 'Juegos',
'nav.taxes': 'Impuestos',
'nav.legislation': 'Legislación',
'nav.sidebarSettings': 'Configuración del menú principal',
'nav.configureMenu': 'Configurar menú',
'nav.menuSettingsHint': 'Activar/desactivar con el interruptor, cambiar orden con flechas arriba/abajo.',
'nav.moveUp': 'Subir',
'nav.moveDown': 'Bajar',
'nav.resetMenu': 'Restablecer',
'nav.more': 'Más',
'chat.related': 'Preguntas de seguimiento',
'chat.searchImages': 'Buscar imágenes',
'chat.searchVideos': 'Buscar videos',
'chat.video': 'Video',
'chat.uploadedFile': 'Archivo subido',
'chat.viewMore': '{count} más',
'chat.sources': 'Fuentes',
'chat.researchProgress': 'Progreso de investigación',
'chat.step': 'paso',
'chat.steps': 'pasos',
'chat.answer': 'Respuesta',
'chat.brainstorming': 'Lluvia de ideas...',
'chat.formingAnswer': 'Formando respuesta...',
'chat.answerFailed': 'No se pudo formar una respuesta a partir de las fuentes encontradas. Intente reformular.',
'chat.thinking': 'Pensando',
'chat.searchingQueries': 'Buscando {count} {plural}',
'chat.foundResults': 'Encontrados {count} {plural}',
'chat.readingSources': 'Leyendo {count} {plural}',
'chat.scanningDocs': 'Escaneando documentos subidos',
'chat.readingDocs': 'Leyendo {count} {plural}',
'chat.processing': 'Procesando',
'chat.query': 'consulta',
'chat.queries': 'consultas',
'chat.result': 'resultado',
'chat.results': 'resultados',
'chat.source': 'fuente',
'chat.document': 'documento',
'chat.documents': 'documentos',
'library.filterSourceAll': 'Todas las fuentes',
'library.filterSourceWeb': 'Web',
'library.filterSourceAcademic': 'Académico',
'library.filterSourceSocial': 'Social',
'library.filterFilesAll': 'Todos',
'library.filterFilesWith': 'Con archivos',
'library.filterFilesWithout': 'Sin archivos',
'library.noResults': 'Ningún chat coincide con sus filtros.',
'library.clearFilters': 'Borrar filtros',
'nav.profile': 'Perfil',
'profile.account': 'Cuenta',
'profile.preferences': 'Preferencias',
'profile.personalize': 'Personalizar',
'profile.billing': 'Facturación',
'profile.connectors': 'Mis conectores',
'profile.appSettings': 'Configuración de la app',
'profile.preferencesDesc': 'Apariencia, Idioma, Autosugerencias, Widgets de inicio.',
'profile.personalizeDesc': 'Listas de vigilancia, memoria IA, personalización.',
'profile.connectorsDesc': 'Google Drive, Dropbox y otras integraciones (Pro).',
'profile.signInToView': 'Inicie sesión para ver su cuenta.',
'profile.current': 'Actual',
'profile.month': 'mes',
'profile.upgrade': 'Actualizar',
'profile.paymentHistory': 'Historial de pagos',
'profile.comingSoon': 'Próximamente.',
'profile.connect': 'Conectar',
'profile.connected': 'Conectado',
},
uk: {
'app.title': 'GooSeek',
'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': 'Законодавство',
'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': 'Підключено',
},
};
/** Локаль для запроса переводов (be, kk → ru, т.к. отдельные переводы не храним) */
export function translationLocaleFor(locale: string): string {
const base = locale.split('-')[0]?.toLowerCase() ?? 'ru';
if (base === 'be' || base === 'kk') return 'ru';
return base;
}
export function getEmbeddedTranslations(locale: string): Record<string, string> {
const key = translationLocaleFor(locale);
return embeddedTranslations[key] ?? embeddedTranslations.ru;
}

Some files were not shown because too many files have changed in this diff Show More