Files
gooseek/services/web-svc/src/app/library/page.tsx
home cd6b7857ba 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>
2026-02-23 15:10:38 +03:00

310 lines
12 KiB
TypeScript

'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;