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:
309
services/web-svc/src/app/library/page.tsx
Normal file
309
services/web-svc/src/app/library/page.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user