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.
This commit is contained in:
parent
0dc17286b9
commit
9a772d6abe
56 changed files with 3673 additions and 365 deletions
|
|
@ -1,7 +1,12 @@
|
|||
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 {
|
||||
formatTimeDifference,
|
||||
formatRelativeTime,
|
||||
formatDate,
|
||||
} from '@/lib/utils';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import DeleteChat from './DeleteChat';
|
||||
import {
|
||||
Popover,
|
||||
|
|
@ -10,6 +15,15 @@ import {
|
|||
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 });
|
||||
|
|
@ -25,18 +39,22 @@ const downloadFile = (filename: string, content: string, type: string) => {
|
|||
}, 0);
|
||||
};
|
||||
|
||||
const exportAsMarkdown = (messages: Message[], title: string) => {
|
||||
const date = new Date(messages[0]?.createdAt || Date.now()).toLocaleString();
|
||||
let md = `# 💬 Chat Export: ${title}\n\n`;
|
||||
md += `*Exported on: ${date}*\n\n---\n`;
|
||||
messages.forEach((msg, idx) => {
|
||||
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' ? '🧑 User' : '🤖 Assistant'}**
|
||||
`;
|
||||
md += `*${new Date(msg.createdAt).toLocaleString()}*\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**Citations:**\n`;
|
||||
md += `\n**${labels.citations}**\n`;
|
||||
msg.sources.forEach((src: any, i: number) => {
|
||||
const url = src.metadata?.url || '';
|
||||
md += `- [${i + 1}] [${url}](${url})\n`;
|
||||
|
|
@ -47,33 +65,45 @@ const exportAsMarkdown = (messages: Message[], title: string) => {
|
|||
downloadFile(`${title || 'chat'}.md`, md, 'text/markdown');
|
||||
};
|
||||
|
||||
const exportAsPDF = (messages: Message[], title: string) => {
|
||||
const exportAsPDF = async (
|
||||
messages: Message[],
|
||||
title: string,
|
||||
labels: ExportLabels,
|
||||
locale: string,
|
||||
) => {
|
||||
const doc = new jsPDF();
|
||||
const date = new Date(messages[0]?.createdAt || Date.now()).toLocaleString();
|
||||
// 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(`Chat Export: ${title}`, 10, y);
|
||||
doc.text(labels.chatExportTitle({ title }), 10, y);
|
||||
y += 8;
|
||||
doc.setFontSize(11);
|
||||
doc.setTextColor(100);
|
||||
doc.text(`Exported on: ${date}`, 10, y);
|
||||
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, idx) => {
|
||||
messages.forEach((msg) => {
|
||||
if (y > pageHeight - 30) {
|
||||
doc.addPage();
|
||||
y = 15;
|
||||
}
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text(`${msg.role === 'user' ? 'User' : 'Assistant'}`, 10, y);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
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(`${new Date(msg.createdAt).toLocaleString()}`, 40, y);
|
||||
doc.text(`${formatDate(msg.createdAt, locale)}`, 40, y);
|
||||
y += 6;
|
||||
doc.setTextColor(30);
|
||||
doc.setFontSize(12);
|
||||
|
|
@ -93,7 +123,7 @@ const exportAsPDF = (messages: Message[], title: string) => {
|
|||
doc.addPage();
|
||||
y = 15;
|
||||
}
|
||||
doc.text('Citations:', 12, y);
|
||||
doc.text(labels.citations, 12, y);
|
||||
y += 5;
|
||||
msg.sources.forEach((src: any, i: number) => {
|
||||
const url = src.metadata?.url || '';
|
||||
|
|
@ -126,7 +156,10 @@ const Navbar = ({
|
|||
chatId: string;
|
||||
}) => {
|
||||
const [title, setTitle] = useState<string>('');
|
||||
const [timeAgo, setTimeAgo] = useState<string>('');
|
||||
const tCommon = useTranslations('common');
|
||||
const tNavbar = useTranslations('navbar');
|
||||
const tExport = useTranslations('export');
|
||||
const locale = useLocale();
|
||||
|
||||
useEffect(() => {
|
||||
if (messages.length > 0) {
|
||||
|
|
@ -135,28 +168,11 @@ const Navbar = ({
|
|||
? `${messages[0].content.substring(0, 20).trim()}...`
|
||||
: messages[0].content;
|
||||
setTitle(newTitle);
|
||||
const newTimeAgo = formatTimeDifference(
|
||||
new Date(),
|
||||
messages[0].createdAt,
|
||||
);
|
||||
setTimeAgo(newTimeAgo);
|
||||
// title already set above
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
const intervalId = setInterval(() => {
|
||||
if (messages.length > 0) {
|
||||
const newTimeAgo = formatTimeDifference(
|
||||
new Date(),
|
||||
messages[0].createdAt,
|
||||
);
|
||||
setTimeAgo(newTimeAgo);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
// 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">
|
||||
|
|
@ -168,7 +184,13 @@ const Navbar = ({
|
|||
</a>
|
||||
<div className="hidden lg:flex flex-row items-center justify-center space-x-2">
|
||||
<Clock size={17} />
|
||||
<p className="text-xs">{timeAgo} ago</p>
|
||||
<p className="text-xs">
|
||||
{formatRelativeTime(
|
||||
new Date(),
|
||||
messages[0]?.createdAt || new Date(),
|
||||
locale,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<p className="hidden lg:flex">{title}</p>
|
||||
|
||||
|
|
@ -190,17 +212,43 @@ const Navbar = ({
|
|||
<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={() => exportAsMarkdown(messages, title || '')}
|
||||
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]" />
|
||||
Export as Markdown
|
||||
{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={() => exportAsPDF(messages, title || '')}
|
||||
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]" />
|
||||
Export as PDF
|
||||
{tNavbar('exportAsPDF')}
|
||||
</button>
|
||||
</div>
|
||||
</PopoverPanel>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue