Perplexica/src/components/Navbar.tsx
wei840222 9a772d6abe feat(i18n): Integrate next-intl, localize core UI, add regional locales and zh-TW Discover sources
**Overview**
- Integrates next-intl (App Router, no i18n routing) with cookie-based locale and Accept-Language fallback.
- Adds message bundles and regional variants; sets en-US as the default.

**Key changes**
- i18n foundation
  - Adds request-scoped config to load messages per locale and injects NextIntlClientProvider in [layout.tsx]
  - Adds/updates messages for: en-US, en-GB, zh-TW, zh-HK, zh-CN, ja, ko, fr-FR, fr-CA, de.
Centralizes LOCALES, LOCALE_LABELS, and DEFAULT_LOCALE in [locales.ts]
  - Adds LocaleSwitcher (cookie-based) and [LocaleBootstrap]

- Pages and components
  - Localizes Sidebar, Home (including metadata/manifest), Settings, Discover, Library.
  - Localizes common components: MessageInput, Attach, Focus, Optimization, MessageBox, MessageSources, SearchImages, SearchVideos, EmptyChat, NewsArticleWidget, WeatherWidget.

- APIs
  - Weather API returns localized condition strings server-side.

- UX and quality
  - Converts all remaining <img> to Next Image.
  - Updates browserslist/caniuse DB to silence warnings.
  - Security: Settings API Key inputs are now password fields and placeholders were removed.
2025-08-16 12:27:18 +08:00

263 lines
8.5 KiB
TypeScript

import { Clock, Edit, Share, Trash, FileText, FileDown } from 'lucide-react';
import { Message } from './ChatWindow';
import { useEffect, useState, Fragment } from 'react';
import {
formatTimeDifference,
formatRelativeTime,
formatDate,
} from '@/lib/utils';
import { useLocale, useTranslations } from 'next-intl';
import DeleteChat from './DeleteChat';
import {
Popover,
PopoverButton,
PopoverPanel,
Transition,
} from '@headlessui/react';
import jsPDF from 'jspdf';
import { ensureNotoSansTC } from '@/lib/pdfFont';
type ExportLabels = {
chatExportTitle: (p: { title: string }) => string;
exportedOn: string;
user: string;
assistant: string;
citations: string;
};
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 exportAsMarkdown = (
messages: Message[],
title: string,
labels: ExportLabels,
locale: string,
) => {
const date = formatDate(messages[0]?.createdAt || Date.now(), locale);
let md = `# 💬 ${labels.chatExportTitle({ title })}\n\n`;
md += `*${labels.exportedOn} ${date}*\n\n---\n`;
messages.forEach((msg) => {
md += `\n---\n`;
md += `**${msg.role === 'user' ? `🧑 ${labels.user}` : `🤖 ${labels.assistant}`}** \n`;
md += `*${formatDate(msg.createdAt, locale)}*\n\n`;
md += `> ${msg.content.replace(/\n/g, '\n> ')}\n`;
if (msg.sources && msg.sources.length > 0) {
md += `\n**${labels.citations}**\n`;
msg.sources.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 (
messages: Message[],
title: string,
labels: ExportLabels,
locale: string,
) => {
const doc = new jsPDF();
// Ensure CJK-capable font is available, then set fonts
try {
await ensureNotoSansTC(doc);
doc.setFont('NotoSansTC', 'normal');
} catch (e) {
// If network fails, fallback to default font (may garble CJK)
}
const date = formatDate(messages[0]?.createdAt || Date.now(), locale);
let y = 15;
const pageHeight = doc.internal.pageSize.height;
doc.setFontSize(18);
doc.text(labels.chatExportTitle({ title }), 10, y);
y += 8;
doc.setFontSize(11);
doc.setTextColor(100);
doc.text(`${labels.exportedOn} ${date}`, 10, y);
y += 8;
doc.setDrawColor(200);
doc.line(10, y, 200, y);
y += 6;
doc.setTextColor(30);
messages.forEach((msg) => {
if (y > pageHeight - 30) {
doc.addPage();
y = 15;
}
doc.setFont('NotoSansTC', 'bold');
doc.text(`${msg.role === 'user' ? labels.user : labels.assistant}`, 10, y);
doc.setFont('NotoSansTC', 'normal');
doc.setFontSize(10);
doc.setTextColor(120);
doc.text(`${formatDate(msg.createdAt, locale)}`, 40, y);
y += 6;
doc.setTextColor(30);
doc.setFontSize(12);
const lines = doc.splitTextToSize(msg.content, 180);
for (let i = 0; i < lines.length; i++) {
if (y > pageHeight - 20) {
doc.addPage();
y = 15;
}
doc.text(lines[i], 12, y);
y += 6;
}
if (msg.sources && msg.sources.length > 0) {
doc.setFontSize(11);
doc.setTextColor(80);
if (y > pageHeight - 20) {
doc.addPage();
y = 15;
}
doc.text(labels.citations, 12, y);
y += 5;
msg.sources.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 = ({
chatId,
messages,
}: {
messages: Message[];
chatId: string;
}) => {
const [title, setTitle] = useState<string>('');
const tCommon = useTranslations('common');
const tNavbar = useTranslations('navbar');
const tExport = useTranslations('export');
const locale = useLocale();
useEffect(() => {
if (messages.length > 0) {
const newTitle =
messages[0].content.length > 20
? `${messages[0].content.substring(0, 20).trim()}...`
: messages[0].content;
setTitle(newTitle);
// title already set above
}
}, [messages]);
// Removed per-locale relative time uses render-time computation
return (
<div className="fixed z-40 top-0 left-0 right-0 px-4 lg:pl-[104px] lg:pr-6 lg:px-8 flex flex-row items-center justify-between w-full py-4 text-sm text-black dark:text-white/70 border-b bg-light-primary dark:bg-dark-primary border-light-100 dark:border-dark-200">
<a
href="/"
className="active:scale-95 transition duration-100 cursor-pointer lg:hidden"
>
<Edit size={17} />
</a>
<div className="hidden lg:flex flex-row items-center justify-center space-x-2">
<Clock size={17} />
<p className="text-xs">
{formatRelativeTime(
new Date(),
messages[0]?.createdAt || new Date(),
locale,
)}
</p>
</div>
<p className="hidden lg:flex">{title}</p>
<div className="flex flex-row items-center space-x-4">
<Popover className="relative">
<PopoverButton className="active:scale-95 transition duration-100 cursor-pointer p-2 rounded-full hover:bg-light-secondary dark:hover:bg-dark-secondary">
<Share size={17} />
</PopoverButton>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-75"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<PopoverPanel className="absolute right-0 mt-2 w-64 rounded-xl shadow-xl bg-light-primary dark:bg-dark-primary border border-light-200 dark:border-dark-200 z-50">
<div className="flex flex-col py-3 px-3 gap-2">
<button
className="flex items-center gap-2 px-4 py-2 text-left hover:bg-light-secondary dark:hover:bg-dark-secondary transition-colors text-black dark:text-white rounded-lg font-medium"
onClick={async () => {
exportAsMarkdown(
messages,
title || '',
{
chatExportTitle: (p) => tExport('chatExportTitle', p),
exportedOn: tCommon('exportedOn'),
user: tCommon('user'),
assistant: tCommon('assistant'),
citations: tCommon('citations'),
},
locale,
);
}}
>
<FileText size={17} className="text-[#24A0ED]" />
{tNavbar('exportAsMarkdown')}
</button>
<button
className="flex items-center gap-2 px-4 py-2 text-left hover:bg-light-secondary dark:hover:bg-dark-secondary transition-colors text-black dark:text-white rounded-lg font-medium"
onClick={async () => {
await exportAsPDF(
messages,
title || '',
{
chatExportTitle: (p) => tExport('chatExportTitle', p),
exportedOn: tCommon('exportedOn'),
user: tCommon('user'),
assistant: tCommon('assistant'),
citations: tCommon('citations'),
},
locale,
);
}}
>
<FileDown size={17} className="text-[#24A0ED]" />
{tNavbar('exportAsPDF')}
</button>
</div>
</PopoverPanel>
</Transition>
</Popover>
<DeleteChat redirect chatId={chatId} chats={[]} setChats={() => {}} />
</div>
</div>
);
};
export default Navbar;