feat(i18n): Add next-intl framework with full bilingual support (EN/ZH)

- Implement next-intl framework for scalable i18n
- Add complete Chinese (Simplified) localization
- Support 400+ translated strings across all pages
- Add language switcher with persistent preference
- Zero breaking changes to existing functionality

Framework additions:
- i18n routing and middleware
- LocaleContext for client-side state
- LanguageSwitcher component
- Translation files (en.json, zh.json)

Translated components:
- Homepage: Hero, features, CTA, navbar
- Auth: Login, register
- Dashboard: Main page, layout
- Connectors: Management, add page (all categories)
- Documents: Upload, manage, filters
- Settings: LLM configs, role assignments
- Onboarding: Add provider, assign roles
- Logs: Task logs viewer

Adding a new language now requires only:
1. Create messages/<locale>.json
2. Add locale to i18n/routing.ts
This commit is contained in:
Differ 2025-10-26 14:05:46 +08:00
parent 8aeaf419d0
commit f58c7e4602
37 changed files with 2267 additions and 542 deletions

View file

@ -1,10 +1,13 @@
"use client";
import { IconBrandGoogleFilled } from "@tabler/icons-react";
import { motion } from "motion/react";
import { useTranslations } from "next-intl";
import { Logo } from "@/components/Logo";
import { AmbientBackground } from "./AmbientBackground";
export function GoogleLoginButton() {
const t = useTranslations('auth');
const handleGoogleLogin = () => {
// Redirect to Google OAuth authorization URL
fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/google/authorize`)
@ -31,7 +34,7 @@ export function GoogleLoginButton() {
<div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center">
<Logo className="rounded-md" />
<h1 className="my-8 text-xl font-bold text-neutral-800 dark:text-neutral-100 md:text-4xl">
Welcome Back
{t('welcome_back')}
</h1>
<motion.div
@ -65,14 +68,14 @@ export function GoogleLoginButton() {
</svg>
<div className="ml-1">
<p className="text-sm font-medium">
SurfSense Cloud is currently in development. Check{" "}
{t('cloud_dev_notice')}{" "}
<a
href="/docs"
className="text-blue-600 underline dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300"
>
Docs
{t('docs')}
</a>{" "}
for more information on Self-Hosted version.
{t('cloud_dev_self_hosted')}
</p>
</div>
</motion.div>
@ -91,7 +94,7 @@ export function GoogleLoginButton() {
<div className="absolute -bottom-px -right-px h-4 w-4 rounded-br-lg border-b-2 border-r-2 border-blue-500 bg-transparent transition-all duration-200 group-hover/btn:-bottom-2 group-hover/btn:-right-2"></div>
</div>
<IconBrandGoogleFilled className="h-5 w-5 text-neutral-700 dark:text-neutral-200" />
<span className="text-base font-medium">Continue with Google</span>
<span className="text-base font-medium">{t('continue_with_google')}</span>
</motion.button>
</div>
</div>

View file

@ -5,9 +5,12 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
export function LocalLoginForm() {
const t = useTranslations('auth');
const tCommon = useTranslations('common');
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
@ -29,7 +32,7 @@ export function LocalLoginForm() {
setErrorTitle(null);
// Show loading toast
const loadingToast = toast.loading("Signing you in...");
const loadingToast = toast.loading(tCommon('loading'));
try {
// Create form data for the API request
@ -56,7 +59,7 @@ export function LocalLoginForm() {
}
// Success toast
toast.success("Login successful!", {
toast.success(t('login_success'), {
id: loadingToast,
description: "Redirecting to dashboard...",
duration: 2000,
@ -167,84 +170,84 @@ export function LocalLoginForm() {
</div>
</motion.div>
)}
</AnimatePresence>
</AnimatePresence>
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Email
</label>
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{t('email')}
</label>
<input
id="email"
type="email"
required
value={username}
onChange={(e) => setUsername(e.target.value)}
className={`mt-1 block w-full rounded-md border px-3 py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:bg-gray-800 dark:text-white transition-colors ${
error
? "border-red-300 focus:border-red-500 focus:ring-red-500 dark:border-red-700"
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-700"
}`}
disabled={isLoading}
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{t('password')}
</label>
<div className="relative">
<input
id="email"
type="email"
id="password"
type={showPassword ? "text" : "password"}
required
value={username}
onChange={(e) => setUsername(e.target.value)}
className={`mt-1 block w-full rounded-md border px-3 py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:bg-gray-800 dark:text-white transition-colors ${
value={password}
onChange={(e) => setPassword(e.target.value)}
className={`mt-1 block w-full rounded-md border pr-10 px-3 py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:bg-gray-800 dark:text-white transition-colors ${
error
? "border-red-300 focus:border-red-500 focus:ring-red-500 dark:border-red-700"
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-700"
}`}
disabled={isLoading}
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
<button
type="button"
onClick={() => setShowPassword((prev) => !prev)}
className="absolute inset-y-0 right-0 flex items-center pr-3 mt-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
aria-label={showPassword ? t('hide_password') : t('show_password')}
>
Password
</label>
<div className="relative">
<input
id="password"
type={showPassword ? "text" : "password"}
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className={`mt-1 block w-full rounded-md border pr-10 px-3 py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:bg-gray-800 dark:text-white transition-colors ${
error
? "border-red-300 focus:border-red-500 focus:ring-red-500 dark:border-red-700"
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-700"
}`}
disabled={isLoading}
/>
<button
type="button"
onClick={() => setShowPassword((prev) => !prev)}
className="absolute inset-y-0 right-0 flex items-center pr-3 mt-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
aria-label={showPassword ? "Hide password" : "Show password"}
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 transition-colors"
>
{isLoading ? "Signing in..." : "Sign in"}
</button>
</form>
<button
type="submit"
disabled={isLoading}
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 transition-colors"
>
{isLoading ? tCommon('loading') : t('sign_in')}
</button>
</form>
{authType === "LOCAL" && (
<div className="mt-4 text-center text-sm">
<p className="text-gray-600 dark:text-gray-400">
Don&apos;t have an account?{" "}
<Link
href="/register"
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"
>
Register here
</Link>
</p>
</div>
)}
{authType === "LOCAL" && (
<div className="mt-4 text-center text-sm">
<p className="text-gray-600 dark:text-gray-400">
{t('dont_have_account')}{" "}
<Link
href="/register"
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"
>
{t('sign_up')}
</Link>
</p>
</div>
)}
</div>
);
}

View file

@ -5,6 +5,7 @@ import { AnimatePresence, motion } from "motion/react";
import { useSearchParams } from "next/navigation";
import { Suspense, useEffect, useState } from "react";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
import { Logo } from "@/components/Logo";
import { getAuthErrorDetails, shouldRetry } from "@/lib/auth-errors";
import { AmbientBackground } from "./AmbientBackground";
@ -12,6 +13,8 @@ import { GoogleLoginButton } from "./GoogleLoginButton";
import { LocalLoginForm } from "./LocalLoginForm";
function LoginContent() {
const t = useTranslations('auth');
const tCommon = useTranslations('common');
const [authType, setAuthType] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [urlError, setUrlError] = useState<{ title: string; message: string } | null>(null);
@ -26,15 +29,15 @@ function LoginContent() {
// Show registration success message
if (registered === "true") {
toast.success("Registration successful!", {
description: "You can now sign in with your credentials",
toast.success(t('register_success'), {
description: t('login_subtitle'),
duration: 5000,
});
}
// Show logout confirmation
if (logout === "true") {
toast.success("Logged out successfully", {
toast.success(tCommon('success'), {
description: "You have been securely logged out",
duration: 3000,
});
@ -93,7 +96,7 @@ function LoginContent() {
<Logo className="rounded-md" />
<div className="mt-8 flex items-center space-x-2">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
<span className="text-muted-foreground">Loading...</span>
<span className="text-muted-foreground">{tCommon('loading')}</span>
</div>
</div>
</div>
@ -110,7 +113,7 @@ function LoginContent() {
<div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center">
<Logo className="rounded-md" />
<h1 className="my-8 text-xl font-bold text-neutral-800 dark:text-neutral-100 md:text-4xl">
Sign In
{t('sign_in')}
</h1>
{/* URL Error Display */}

View file

@ -5,11 +5,14 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
import { Logo } from "@/components/Logo";
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
import { AmbientBackground } from "../login/AmbientBackground";
export default function RegisterPage() {
const t = useTranslations('auth');
const tCommon = useTranslations('common');
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
@ -31,10 +34,10 @@ export default function RegisterPage() {
// Form validation
if (password !== confirmPassword) {
setError("Passwords do not match");
setErrorTitle("Password Mismatch");
toast.error("Password Mismatch", {
description: "The passwords you entered do not match",
setError(t('passwords_no_match'));
setErrorTitle(t('password_mismatch'));
toast.error(t('password_mismatch'), {
description: t('passwords_no_match_desc'),
duration: 4000,
});
return;
@ -45,7 +48,7 @@ export default function RegisterPage() {
setErrorTitle(null);
// Show loading toast
const loadingToast = toast.loading("Creating your account...");
const loadingToast = toast.loading(t('creating_account'));
try {
const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/register`, {
@ -83,9 +86,9 @@ export default function RegisterPage() {
}
// Success toast
toast.success("Account created successfully!", {
toast.success(t('register_success'), {
id: loadingToast,
description: "Redirecting to login page...",
description: t('redirecting_login'),
duration: 2000,
});
@ -120,7 +123,7 @@ export default function RegisterPage() {
// Add retry action if the error is retryable
if (shouldRetry(errorCode)) {
toastOptions.action = {
label: "Retry",
label: tCommon('retry'),
onClick: () => handleSubmit(e),
};
}
@ -137,7 +140,7 @@ export default function RegisterPage() {
<div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center">
<Logo className="rounded-md" />
<h1 className="my-8 text-xl font-bold text-neutral-800 dark:text-neutral-100 md:text-4xl">
Create an Account
{t('create_account')}
</h1>
<div className="w-full max-w-md">
@ -209,7 +212,7 @@ export default function RegisterPage() {
htmlFor="email"
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Email
{t('email')}
</label>
<input
id="email"
@ -231,7 +234,7 @@ export default function RegisterPage() {
htmlFor="password"
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Password
{t('password')}
</label>
<input
id="password"
@ -253,7 +256,7 @@ export default function RegisterPage() {
htmlFor="confirmPassword"
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Confirm Password
{t('confirm_password')}
</label>
<input
id="confirmPassword"
@ -275,18 +278,18 @@ export default function RegisterPage() {
disabled={isLoading}
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 transition-colors"
>
{isLoading ? "Creating account..." : "Register"}
{isLoading ? t('creating_account_btn') : t('register')}
</button>
</form>
<div className="mt-4 text-center text-sm">
<p className="text-gray-600 dark:text-gray-400">
Already have an account?{" "}
{t('already_have_account')}{" "}
<Link
href="/login"
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"
>
Sign in
{t('sign_in')}
</Link>
</p>
</div>

View file

@ -3,7 +3,8 @@
import { Loader2 } from "lucide-react";
import { usePathname, useRouter } from "next/navigation";
import type React from "react";
import { useEffect, useState } from "react";
import { useEffect, useState, useMemo } from "react";
import { useTranslations } from "next-intl";
import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb";
import { AppSidebarProvider } from "@/components/sidebar/AppSidebarProvider";
import { ThemeTogglerComponent } from "@/components/theme/theme-toggle";
@ -11,6 +12,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Separator } from "@/components/ui/separator";
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { useLLMPreferences } from "@/hooks/use-llm-configs";
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
export function DashboardClientLayout({
children,
@ -23,6 +25,7 @@ export function DashboardClientLayout({
navSecondary: any[];
navMain: any[];
}) {
const t = useTranslations('dashboard');
const router = useRouter();
const pathname = usePathname();
const searchSpaceIdNum = Number(searchSpaceId);
@ -33,6 +36,26 @@ export function DashboardClientLayout({
// Skip onboarding check if we're already on the onboarding page
const isOnboardingPage = pathname?.includes("/onboard");
// Translate navigation items
const tNavMenu = useTranslations('nav_menu');
const translatedNavMain = useMemo(() => {
return navMain.map((item) => ({
...item,
title: tNavMenu(item.title.toLowerCase().replace(/ /g, '_')),
items: item.items?.map((subItem: any) => ({
...subItem,
title: tNavMenu(subItem.title.toLowerCase().replace(/ /g, '_')),
})),
}));
}, [navMain, tNavMenu]);
const translatedNavSecondary = useMemo(() => {
return navSecondary.map((item) => ({
...item,
title: item.title === 'All Search Spaces' ? tNavMenu('all_search_spaces') : item.title,
}));
}, [navSecondary, tNavMenu]);
const [open, setOpen] = useState<boolean>(() => {
try {
const match = document.cookie.match(/(?:^|; )sidebar_state=([^;]+)/);
@ -75,8 +98,8 @@ export function DashboardClientLayout({
<div className="flex flex-col items-center justify-center min-h-screen space-y-4">
<Card className="w-[350px] bg-background/60 backdrop-blur-sm">
<CardHeader className="pb-2">
<CardTitle className="text-xl font-medium">Loading Configuration</CardTitle>
<CardDescription>Checking your LLM preferences...</CardDescription>
<CardTitle className="text-xl font-medium">{t('loading_config')}</CardTitle>
<CardDescription>{t('checking_llm_prefs')}</CardDescription>
</CardHeader>
<CardContent className="flex justify-center py-6">
<Loader2 className="h-12 w-12 text-primary animate-spin" />
@ -93,9 +116,9 @@ export function DashboardClientLayout({
<Card className="w-[400px] bg-background/60 backdrop-blur-sm border-destructive/20">
<CardHeader className="pb-2">
<CardTitle className="text-xl font-medium text-destructive">
Configuration Error
{t('config_error')}
</CardTitle>
<CardDescription>Failed to load your LLM configuration</CardDescription>
<CardDescription>{t('failed_load_llm_config')}</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">{error}</p>
@ -110,8 +133,8 @@ export function DashboardClientLayout({
{/* Use AppSidebarProvider which fetches user, search space, and recent chats */}
<AppSidebarProvider
searchSpaceId={searchSpaceId}
navSecondary={navSecondary}
navMain={navMain}
navSecondary={translatedNavSecondary}
navMain={translatedNavMain}
/>
<SidebarInset>
<header className="sticky top-0 z-50 flex h-16 shrink-0 items-center gap-2 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b">
@ -121,7 +144,10 @@ export function DashboardClientLayout({
<Separator orientation="vertical" className="h-6" />
<DashboardBreadcrumb />
</div>
<ThemeTogglerComponent />
<div className="flex items-center gap-2">
<LanguageSwitcher />
<ThemeTogglerComponent />
</div>
</div>
</header>
{children}

View file

@ -14,6 +14,7 @@ import { motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
import {
AlertDialog,
AlertDialogAction,
@ -61,21 +62,23 @@ import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors";
import { cn } from "@/lib/utils";
// Helper function to format date with time
const formatDateTime = (dateString: string | null): string => {
if (!dateString) return "Never";
const date = new Date(dateString);
return new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(date);
};
export default function ConnectorsPage() {
const t = useTranslations('connectors');
const tCommon = useTranslations('common');
// Helper function to format date with time
const formatDateTime = (dateString: string | null): string => {
if (!dateString) return t('never');
const date = new Date(dateString);
return new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(date);
};
const router = useRouter();
const params = useParams();
const searchSpaceId = params.search_space_id as string;
@ -104,10 +107,10 @@ export default function ConnectorsPage() {
useEffect(() => {
if (error) {
toast.error("Failed to load connectors");
toast.error(t('failed_load'));
console.error("Error fetching connectors:", error);
}
}, [error]);
}, [error, t]);
// Handle connector deletion
const handleDeleteConnector = async () => {
@ -115,10 +118,10 @@ export default function ConnectorsPage() {
try {
await deleteConnector(connectorToDelete);
toast.success("Connector deleted successfully");
toast.success(t('delete_success'));
} catch (error) {
console.error("Error deleting connector:", error);
toast.error("Failed to delete connector");
toast.error(t('delete_failed'));
} finally {
setConnectorToDelete(null);
}
@ -142,10 +145,10 @@ export default function ConnectorsPage() {
const endDateStr = endDate ? format(endDate, "yyyy-MM-dd") : undefined;
await indexConnector(selectedConnectorForIndexing, searchSpaceId, startDateStr, endDateStr);
toast.success("Connector content indexing started");
toast.success(t('indexing_started'));
} catch (error) {
console.error("Error indexing connector content:", error);
toast.error(error instanceof Error ? error.message : "Failed to index connector content");
toast.error(error instanceof Error ? error.message : t('indexing_failed'));
} finally {
setIndexingConnectorId(null);
setSelectedConnectorForIndexing(null);
@ -159,10 +162,10 @@ export default function ConnectorsPage() {
setIndexingConnectorId(connectorId);
try {
await indexConnector(connectorId, searchSpaceId);
toast.success("Connector content indexing started");
toast.success(t('indexing_started'));
} catch (error) {
console.error("Error indexing connector content:", error);
toast.error(error instanceof Error ? error.message : "Failed to index connector content");
toast.error(error instanceof Error ? error.message : t('indexing_failed'));
} finally {
setIndexingConnectorId(null);
}
@ -255,21 +258,21 @@ export default function ConnectorsPage() {
className="mb-8 flex items-center justify-between"
>
<div>
<h1 className="text-3xl font-bold tracking-tight">Connectors</h1>
<h1 className="text-3xl font-bold tracking-tight">{t('title')}</h1>
<p className="text-muted-foreground mt-2">
Manage your connected services and data sources.
{t('subtitle')}
</p>
</div>
<Button onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}>
<Plus className="mr-2 h-4 w-4" />
Add Connector
{t('add_connector')}
</Button>
</motion.div>
<Card>
<CardHeader className="pb-3">
<CardTitle>Your Connectors</CardTitle>
<CardDescription>View and manage all your connected services.</CardDescription>
<CardTitle>{t('your_connectors')}</CardTitle>
<CardDescription>{t('view_manage')}</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
@ -281,13 +284,13 @@ export default function ConnectorsPage() {
</div>
) : connectors.length === 0 ? (
<div className="text-center py-12">
<h3 className="text-lg font-medium mb-2">No connectors found</h3>
<h3 className="text-lg font-medium mb-2">{t('no_connectors')}</h3>
<p className="text-muted-foreground mb-6">
You haven't added any connectors yet. Add one to enhance your search capabilities.
{t('no_connectors_desc')}
</p>
<Button onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}>
<Plus className="mr-2 h-4 w-4" />
Add Your First Connector
{t('add_first')}
</Button>
</div>
) : (
@ -295,11 +298,11 @@ export default function ConnectorsPage() {
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Type</TableHead>
<TableHead>Last Indexed</TableHead>
<TableHead>Periodic</TableHead>
<TableHead className="text-right">Actions</TableHead>
<TableHead>{t('name')}</TableHead>
<TableHead>{t('type')}</TableHead>
<TableHead>{t('last_indexed')}</TableHead>
<TableHead>{t('periodic')}</TableHead>
<TableHead className="text-right">{t('actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@ -310,7 +313,7 @@ export default function ConnectorsPage() {
<TableCell>
{connector.is_indexable
? formatDateTime(connector.last_indexed_at)
: "Not indexable"}
: t('not_indexable')}
</TableCell>
<TableCell>
{connector.is_indexable ? (
@ -365,11 +368,11 @@ export default function ConnectorsPage() {
) : (
<CalendarIcon className="h-4 w-4" />
)}
<span className="sr-only">Index with Date Range</span>
<span className="sr-only">{t('index_date_range')}</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Index with Date Range</p>
<p>{t('index_date_range')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
@ -387,11 +390,11 @@ export default function ConnectorsPage() {
) : (
<RefreshCw className="h-4 w-4" />
)}
<span className="sr-only">Quick Index</span>
<span className="sr-only">{t('quick_index')}</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Quick Index (Auto Date Range)</p>
<p>{t('quick_index_auto')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
@ -426,7 +429,7 @@ export default function ConnectorsPage() {
}
>
<Edit className="h-4 w-4" />
<span className="sr-only">Edit</span>
<span className="sr-only">{tCommon('edit')}</span>
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
@ -437,26 +440,25 @@ export default function ConnectorsPage() {
onClick={() => setConnectorToDelete(connector.id)}
>
<Trash2 className="h-4 w-4" />
<span className="sr-only">Delete</span>
<span className="sr-only">{tCommon('delete')}</span>
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Connector</AlertDialogTitle>
<AlertDialogTitle>{t('delete_connector')}</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this connector? This action cannot
be undone.
{t('delete_confirm')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setConnectorToDelete(null)}>
Cancel
{tCommon('cancel')}
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={handleDeleteConnector}
>
Delete
{tCommon('delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@ -476,15 +478,15 @@ export default function ConnectorsPage() {
<Dialog open={datePickerOpen} onOpenChange={setDatePickerOpen}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Select Date Range for Indexing</DialogTitle>
<DialogTitle>{t('select_date_range')}</DialogTitle>
<DialogDescription>
Choose the start and end dates for indexing content. Leave empty to use default range.
{t('select_date_range_desc')}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="start-date">Start Date</Label>
<Label htmlFor="start-date">{t('start_date')}</Label>
<Popover>
<PopoverTrigger asChild>
<Button
@ -496,7 +498,7 @@ export default function ConnectorsPage() {
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{startDate ? format(startDate, "PPP") : "Pick a date"}
{startDate ? format(startDate, "PPP") : t('pick_date')}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
@ -510,7 +512,7 @@ export default function ConnectorsPage() {
</Popover>
</div>
<div className="space-y-2">
<Label htmlFor="end-date">End Date</Label>
<Label htmlFor="end-date">{t('end_date')}</Label>
<Popover>
<PopoverTrigger asChild>
<Button
@ -522,7 +524,7 @@ export default function ConnectorsPage() {
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{endDate ? format(endDate, "PPP") : "Pick a date"}
{endDate ? format(endDate, "PPP") : t('pick_date')}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
@ -540,7 +542,7 @@ export default function ConnectorsPage() {
setEndDate(undefined);
}}
>
Clear Dates
{t('clear_dates')}
</Button>
<Button
variant="outline"
@ -552,7 +554,7 @@ export default function ConnectorsPage() {
setEndDate(today);
}}
>
Last 30 Days
{t('last_30_days')}
</Button>
<Button
variant="outline"
@ -564,7 +566,7 @@ export default function ConnectorsPage() {
setEndDate(today);
}}
>
Last Year
{t('last_year')}
</Button>
</div>
</div>
@ -578,9 +580,9 @@ export default function ConnectorsPage() {
setEndDate(undefined);
}}
>
Cancel
{tCommon('cancel')}
</Button>
<Button onClick={handleIndexConnector}>Start Indexing</Button>
<Button onClick={handleIndexConnector}>{t('start_indexing')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View file

@ -10,6 +10,7 @@ import { AnimatePresence, motion, type Variants } from "motion/react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card";
@ -36,40 +37,40 @@ interface ConnectorCategory {
const connectorCategories: ConnectorCategory[] = [
{
id: "search-engines",
title: "Search Engines",
title: "search_engines",
connectors: [
{
id: "tavily-api",
title: "Tavily API",
description: "Search the web using the Tavily API",
description: "tavily_desc",
icon: getConnectorIcon(EnumConnectorName.TAVILY_API, "h-6 w-6"),
status: "available",
},
{
id: "searxng",
title: "SearxNG",
description: "Use your own SearxNG meta-search instance for web results.",
description: "searxng_desc",
icon: getConnectorIcon(EnumConnectorName.SEARXNG_API, "h-6 w-6"),
status: "available",
},
{
id: "linkup-api",
title: "Linkup API",
description: "Search the web using the Linkup API",
description: "linkup_desc",
icon: getConnectorIcon(EnumConnectorName.LINKUP_API, "h-6 w-6"),
status: "available",
},
{
id: "elasticsearch-connector",
title: "Elasticsearch",
description: "Connect to Elasticsearch to index and search documents, logs and metrics.",
description: "elasticsearch_desc",
icon: getConnectorIcon(EnumConnectorName.ELASTICSEARCH_CONNECTOR, "h-6 w-6"),
status: "available",
},
{
id: "baidu-search-api",
title: "Baidu Search",
description: "Search the Chinese web using Baidu AI Search API",
description: "baidu_desc",
icon: getConnectorIcon(EnumConnectorName.BAIDU_SEARCH_API, "h-6 w-6"),
status: "available",
},
@ -77,26 +78,26 @@ const connectorCategories: ConnectorCategory[] = [
},
{
id: "team-chats",
title: "Team Chats",
title: "team_chats",
connectors: [
{
id: "slack-connector",
title: "Slack",
description: "Connect to your Slack workspace to access messages and channels.",
description: "slack_desc",
icon: getConnectorIcon(EnumConnectorName.SLACK_CONNECTOR, "h-6 w-6"),
status: "available",
},
{
id: "ms-teams",
title: "Microsoft Teams",
description: "Connect to Microsoft Teams to access your team's conversations.",
description: "teams_desc",
icon: <IconBrandWindows className="h-6 w-6" />,
status: "coming-soon",
},
{
id: "discord-connector",
title: "Discord",
description: "Connect to Discord servers to access messages and channels.",
description: "discord_desc",
icon: getConnectorIcon(EnumConnectorName.DISCORD_CONNECTOR, "h-6 w-6"),
status: "available",
},
@ -104,26 +105,26 @@ const connectorCategories: ConnectorCategory[] = [
},
{
id: "project-management",
title: "Project Management",
title: "project_management",
connectors: [
{
id: "linear-connector",
title: "Linear",
description: "Connect to Linear to search issues, comments and project data.",
description: "linear_desc",
icon: getConnectorIcon(EnumConnectorName.LINEAR_CONNECTOR, "h-6 w-6"),
status: "available",
},
{
id: "jira-connector",
title: "Jira",
description: "Connect to Jira to search issues, tickets and project data.",
description: "jira_desc",
icon: getConnectorIcon(EnumConnectorName.JIRA_CONNECTOR, "h-6 w-6"),
status: "available",
},
{
id: "clickup-connector",
title: "ClickUp",
description: "Connect to ClickUp to search tasks, comments and project data.",
description: "clickup_desc",
icon: getConnectorIcon(EnumConnectorName.CLICKUP_CONNECTOR, "h-6 w-6"),
status: "available",
},
@ -131,40 +132,40 @@ const connectorCategories: ConnectorCategory[] = [
},
{
id: "knowledge-bases",
title: "Knowledge Bases",
title: "knowledge_bases",
connectors: [
{
id: "notion-connector",
title: "Notion",
description: "Connect to your Notion workspace to access pages and databases.",
description: "notion_desc",
icon: getConnectorIcon(EnumConnectorName.NOTION_CONNECTOR, "h-6 w-6"),
status: "available",
},
{
id: "github-connector",
title: "GitHub",
description: "Connect a GitHub PAT to index code and docs from accessible repositories.",
description: "github_desc",
icon: getConnectorIcon(EnumConnectorName.GITHUB_CONNECTOR, "h-6 w-6"),
status: "available",
},
{
id: "confluence-connector",
title: "Confluence",
description: "Connect to Confluence to search pages, comments and documentation.",
description: "confluence_desc",
icon: getConnectorIcon(EnumConnectorName.CONFLUENCE_CONNECTOR, "h-6 w-6"),
status: "available",
},
{
id: "airtable-connector",
title: "Airtable",
description: "Connect to Airtable to search records, tables and database content.",
description: "airtable_desc",
icon: getConnectorIcon(EnumConnectorName.AIRTABLE_CONNECTOR, "h-6 w-6"),
status: "available",
},
{
id: "luma-connector",
title: "Luma",
description: "Connect to Luma to search events",
description: "luma_desc",
icon: getConnectorIcon(EnumConnectorName.LUMA_CONNECTOR, "h-6 w-6"),
status: "available",
},
@ -172,26 +173,26 @@ const connectorCategories: ConnectorCategory[] = [
},
{
id: "communication",
title: "Communication",
title: "communication",
connectors: [
{
id: "google-calendar-connector",
title: "Google Calendar",
description: "Connect to Google Calendar to search events, meetings and schedules.",
description: "calendar_desc",
icon: getConnectorIcon(EnumConnectorName.GOOGLE_CALENDAR_CONNECTOR, "h-6 w-6"),
status: "available",
},
{
id: "google-gmail-connector",
title: "Gmail",
description: "Connect to your Gmail account to search through your emails.",
description: "gmail_desc",
icon: getConnectorIcon(EnumConnectorName.GOOGLE_GMAIL_CONNECTOR, "h-6 w-6"),
status: "available",
},
{
id: "zoom",
title: "Zoom",
description: "Connect to Zoom to access meeting recordings and transcripts.",
description: "zoom_desc",
icon: <IconBrandZoom className="h-6 w-6" />,
status: "coming-soon",
},
@ -238,6 +239,7 @@ const cardVariants: Variants = {
};
export default function ConnectorsPage() {
const t = useTranslations('add_connector');
const params = useParams();
const searchSpaceId = params.search_space_id as string;
const [expandedCategories, setExpandedCategories] = useState<string[]>([
@ -266,10 +268,10 @@ export default function ConnectorsPage() {
className="mb-12 text-center"
>
<h1 className="text-4xl font-bold tracking-tight bg-gradient-to-r from-indigo-500 to-purple-500 bg-clip-text text-transparent">
Connect Your Tools
{t('title')}
</h1>
<p className="text-muted-foreground mt-3 text-lg max-w-2xl mx-auto">
Integrate with your favorite services to enhance your research capabilities.
{t('subtitle')}
</p>
</motion.div>
@ -291,7 +293,7 @@ export default function ConnectorsPage() {
className="w-full"
>
<div className="flex items-center justify-between space-x-4 p-4">
<h3 className="text-xl font-semibold">{category.title}</h3>
<h3 className="text-xl font-semibold">{t(category.title)}</h3>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="w-9 p-0 hover:bg-muted">
<motion.div
@ -341,7 +343,7 @@ export default function ConnectorsPage() {
variant="outline"
className="text-xs bg-amber-100 dark:bg-amber-950 text-amber-800 dark:text-amber-300 border-amber-200 dark:border-amber-800"
>
Coming soon
{t('coming_soon')}
</Badge>
)}
{connector.status === "connected" && (
@ -349,7 +351,7 @@ export default function ConnectorsPage() {
variant="outline"
className="text-xs bg-green-100 dark:bg-green-950 text-green-800 dark:text-green-300 border-green-200 dark:border-green-800"
>
Connected
{t('connected')}
</Badge>
)}
</div>
@ -357,7 +359,7 @@ export default function ConnectorsPage() {
</CardHeader>
<CardContent className="pb-4">
<p className="text-sm text-muted-foreground">{connector.description}</p>
<p className="text-sm text-muted-foreground">{t(connector.description)}</p>
</CardContent>
<CardFooter className="mt-auto pt-2">
@ -367,7 +369,7 @@ export default function ConnectorsPage() {
className="w-full"
>
<Button variant="default" className="w-full group">
<span>Connect</span>
<span>{t('connect')}</span>
<motion.div
className="ml-1"
initial={{ x: 0 }}
@ -385,7 +387,7 @@ export default function ConnectorsPage() {
)}
{connector.status === "coming-soon" && (
<Button variant="outline" disabled className="w-full opacity-70">
Coming Soon
{t('coming_soon')}
</Button>
)}
{connector.status === "connected" && (
@ -393,7 +395,7 @@ export default function ConnectorsPage() {
variant="outline"
className="w-full border-green-500 text-green-600 hover:bg-green-50 dark:hover:bg-green-950"
>
Manage
{t('manage')}
</Button>
)}
</CardFooter>

View file

@ -3,6 +3,7 @@
import { CircleAlert, CircleX, Columns3, Filter, ListFilter, Trash } from "lucide-react";
import { AnimatePresence, motion, type Variants } from "motion/react";
import React, { useMemo, useRef } from "react";
import { useTranslations } from "next-intl";
import {
AlertDialog,
AlertDialogAction,
@ -55,6 +56,7 @@ export function DocumentsFilters({
columnVisibility: ColumnVisibility;
onToggleColumn: (id: keyof ColumnVisibility, checked: boolean) => void;
}) {
const t = useTranslations('documents');
const id = React.useId();
const inputRef = useRef<HTMLInputElement>(null);
@ -90,9 +92,9 @@ export function DocumentsFilters({
className="peer min-w-60 ps-9"
value={searchValue}
onChange={(e) => onSearch(e.target.value)}
placeholder="Filter by title..."
placeholder={t('filter_placeholder')}
type="text"
aria-label="Filter by title"
aria-label={t('filter_placeholder')}
/>
<motion.div
className="pointer-events-none absolute inset-y-0 start-0 flex items-center justify-center ps-3 text-muted-foreground/80 peer-disabled:opacity-50"

View file

@ -3,6 +3,7 @@
import { ChevronDown, ChevronUp, FileX } from "lucide-react";
import { motion } from "motion/react";
import React from "react";
import { useTranslations } from "next-intl";
import { DocumentViewer } from "@/components/document-viewer";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
@ -66,6 +67,7 @@ export function DocumentsTableShell({
sortDesc: boolean;
onSortChange: (key: SortKey) => void;
}) {
const t = useTranslations('documents');
const sorted = React.useMemo(
() => sortDocuments(documents, sortKey, sortDesc),
[documents, sortKey, sortDesc]
@ -101,15 +103,15 @@ export function DocumentsTableShell({
<div className="flex h-[400px] w-full items-center justify-center">
<div className="flex flex-col items-center gap-2">
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-primary"></div>
<p className="text-sm text-muted-foreground">Loading documents...</p>
<p className="text-sm text-muted-foreground">{t('loading')}</p>
</div>
</div>
) : error ? (
<div className="flex h-[400px] w-full items-center justify-center">
<div className="flex flex-col items-center gap-2">
<p className="text-sm text-destructive">Error loading documents</p>
<p className="text-sm text-destructive">{t('error_loading')}</p>
<Button variant="outline" size="sm" onClick={() => onRefresh()} className="mt-2">
Retry
{t('retry')}
</Button>
</div>
</div>
@ -117,7 +119,7 @@ export function DocumentsTableShell({
<div className="flex h-[400px] w-full items-center justify-center">
<div className="flex flex-col items-center gap-2">
<FileX className="h-8 w-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">No documents found</p>
<p className="text-sm text-muted-foreground">{t('no_documents')}</p>
</div>
</div>
) : (
@ -140,7 +142,7 @@ export function DocumentsTableShell({
className="flex h-full w-full cursor-pointer select-none items-center justify-between gap-2"
onClick={() => onSortHeader("title")}
>
Title
{t('title')}
{sortKey === "title" ? (
sortDesc ? (
<ChevronDown className="shrink-0 opacity-60" size={16} />
@ -158,7 +160,7 @@ export function DocumentsTableShell({
className="flex h-full w-full cursor-pointer select-none items-center justify-between gap-2"
onClick={() => onSortHeader("document_type")}
>
Type
{t('type')}
{sortKey === "document_type" ? (
sortDesc ? (
<ChevronDown className="shrink-0 opacity-60" size={16} />
@ -170,7 +172,7 @@ export function DocumentsTableShell({
</TableHead>
)}
{columnVisibility.content && (
<TableHead style={{ width: 300 }}>Content Summary</TableHead>
<TableHead style={{ width: 300 }}>{t('content_summary')}</TableHead>
)}
{columnVisibility.created_at && (
<TableHead style={{ width: 120 }}>
@ -264,7 +266,7 @@ export function DocumentsTableShell({
content={doc.content}
trigger={
<Button variant="ghost" size="sm" className="w-fit text-xs">
View Full Content
{t('view_full')}
</Button>
}
/>
@ -335,7 +337,7 @@ export function DocumentsTableShell({
size="sm"
className="w-fit text-xs p-0 h-auto"
>
View Full Content
{t('view_full')}
</Button>
}
/>

View file

@ -2,6 +2,7 @@
import { ChevronFirst, ChevronLast, ChevronLeft, ChevronRight } from "lucide-react";
import { motion } from "motion/react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Pagination, PaginationContent, PaginationItem } from "@/components/ui/pagination";
@ -38,6 +39,7 @@ export function PaginationControls({
canNext: boolean;
id: string;
}) {
const t = useTranslations('documents');
const start = total === 0 ? 0 : pageIndex * pageSize + 1;
const end = Math.min((pageIndex + 1) * pageSize, total);
@ -50,7 +52,7 @@ export function PaginationControls({
transition={{ type: "spring", stiffness: 300, damping: 30 }}
>
<Label htmlFor={id} className="max-sm:sr-only">
Rows per page
{t('rows_per_page')}
</Label>
<Select value={String(pageSize)} onValueChange={(v) => onPageSizeChange(Number(v))}>
<SelectTrigger id={id} className="w-fit whitespace-nowrap">

View file

@ -4,6 +4,7 @@ import { motion } from "motion/react";
import { useParams } from "next/navigation";
import { useCallback, useEffect, useId, useMemo, useState } from "react";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
import { useDocuments } from "@/hooks/use-documents";
@ -22,6 +23,7 @@ function useDebounced<T>(value: T, delay = 250) {
}
export default function DocumentsTable() {
const t = useTranslations('documents');
const id = useId();
const params = useParams();
const searchSpaceId = Number(params.search_space_id);
@ -120,21 +122,21 @@ export default function DocumentsTable() {
const onBulkDelete = async () => {
if (selectedIds.size === 0) {
toast.error("No rows selected");
toast.error(t('no_rows_selected'));
return;
}
try {
const results = await Promise.all(Array.from(selectedIds).map((id) => deleteDocument?.(id)));
const okCount = results.filter((r) => r === true).length;
if (okCount === selectedIds.size)
toast.success(`Successfully deleted ${okCount} document(s)`);
else toast.error("Some documents could not be deleted");
toast.success(t('delete_success_count', { count: okCount }));
else toast.error(t('delete_partial_failed'));
// Refetch the current page with appropriate method
await refreshCurrentView();
setSelectedIds(new Set());
} catch (e) {
console.error(e);
toast.error("Error deleting documents");
toast.error(t('delete_error'));
}
};

View file

@ -6,6 +6,7 @@ import { useParams, useRouter } from "next/navigation";
import { useCallback, useState } from "react";
import { useDropzone } from "react-dropzone";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@ -39,6 +40,7 @@ function GridPattern() {
}
export default function FileUploader() {
const t = useTranslations('upload_documents');
const params = useParams();
const search_space_id = params.search_space_id as string;
@ -274,16 +276,16 @@ export default function FileUploader() {
await response.json();
toast("Upload Task Initiated", {
description: "Files Uploading Initiated",
toast(t('upload_initiated'), {
description: t('upload_initiated_desc'),
});
router.push(`/dashboard/${search_space_id}/documents`);
} catch (error: any) {
setIsUploading(false);
setUploadProgress(0);
toast("Upload Error", {
description: `Error uploading files: ${error.message}`,
toast(t('upload_error'), {
description: `${t('upload_error_desc')}: ${error.message}`,
});
}
};
@ -330,19 +332,17 @@ export default function FileUploader() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Upload className="h-5 w-5" />
Upload Documents
{t('title')}
</CardTitle>
<CardDescription>
Upload your files to make them searchable and accessible through AI-powered
conversations.
{t('subtitle')}
</CardDescription>
</CardHeader>
<CardContent>
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
Maximum file size: 50MB per file. Supported formats vary based on your ETL service
configuration.
{t('file_size_limit')}
</AlertDescription>
</Alert>
</CardContent>
@ -371,7 +371,7 @@ export default function FileUploader() {
className="flex flex-col items-center gap-4"
>
<Upload className="h-12 w-12 text-primary" />
<p className="text-lg font-medium text-primary">Drop files here</p>
<p className="text-lg font-medium text-primary">{t('drop_files')}</p>
</motion.div>
) : (
<motion.div
@ -381,8 +381,8 @@ export default function FileUploader() {
>
<Upload className="h-12 w-12 text-muted-foreground" />
<div className="text-center">
<p className="text-lg font-medium">Drag & drop files here</p>
<p className="text-sm text-muted-foreground mt-1">or click to browse</p>
<p className="text-lg font-medium">{t('drag_drop')}</p>
<p className="text-sm text-muted-foreground mt-1">{t('or_browse')}</p>
</div>
</motion.div>
)}
@ -400,7 +400,7 @@ export default function FileUploader() {
if (input) input.click();
}}
>
Browse Files
{t('browse_files')}
</Button>
</div>
</div>
@ -422,9 +422,9 @@ export default function FileUploader() {
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Selected Files ({files.length})</CardTitle>
<CardTitle>{t('selected_files', { count: files.length })}</CardTitle>
<CardDescription>
Total size: {formatFileSize(getTotalFileSize())}
{t('total_size')}: {formatFileSize(getTotalFileSize())}
</CardDescription>
</div>
<Button
@ -433,7 +433,7 @@ export default function FileUploader() {
onClick={() => setFiles([])}
disabled={isUploading}
>
Clear all
{t('clear_all')}
</Button>
</div>
</CardHeader>
@ -490,7 +490,7 @@ export default function FileUploader() {
<Separator />
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span>Uploading files...</span>
<span>{t('uploading_files')}</span>
<span>{Math.round(uploadProgress)}%</span>
</div>
<Progress value={uploadProgress} className="h-2" />
@ -521,7 +521,7 @@ export default function FileUploader() {
>
<Upload className="h-5 w-5" />
</motion.div>
<span>Uploading...</span>
<span>{t('uploading')}</span>
</motion.div>
) : (
<motion.div
@ -531,7 +531,7 @@ export default function FileUploader() {
>
<CheckCircle2 className="h-5 w-5" />
<span>
Upload {files.length} {files.length === 1 ? "file" : "files"}
{t('upload_button', { count: files.length })}
</span>
</motion.div>
)}
@ -549,10 +549,10 @@ export default function FileUploader() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Tag className="h-5 w-5" />
Supported File Types
{t('supported_file_types')}
</CardTitle>
<CardDescription>
These file types are supported based on your current ETL service configuration.
{t('file_types_desc')}
</CardDescription>
</CardHeader>
<CardContent>

View file

@ -5,6 +5,7 @@ import { Globe, Loader2 } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import {
Card,
@ -20,6 +21,7 @@ import { Label } from "@/components/ui/label";
const urlRegex = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w .-]*)*\/?$/;
export default function WebpageCrawler() {
const t = useTranslations('add_webpage');
const params = useParams();
const router = useRouter();
const search_space_id = params.search_space_id as string;
@ -38,14 +40,14 @@ export default function WebpageCrawler() {
const handleSubmit = async () => {
// Validate that we have at least one URL
if (urlTags.length === 0) {
setError("Please add at least one URL");
setError(t('error_no_url'));
return;
}
// Validate all URLs
const invalidUrls = urlTags.filter((tag) => !isValidUrl(tag.text));
if (invalidUrls.length > 0) {
setError(`Invalid URLs detected: ${invalidUrls.map((tag) => tag.text).join(", ")}`);
setError(t('error_invalid_urls', { urls: invalidUrls.map((tag) => tag.text).join(", ") }));
return;
}
@ -53,8 +55,8 @@ export default function WebpageCrawler() {
setIsSubmitting(true);
try {
toast("URL Crawling", {
description: "Starting URL crawling process...",
toast(t('crawling_toast'), {
description: t('crawling_toast_desc'),
});
// Extract URLs from tags
@ -83,16 +85,16 @@ export default function WebpageCrawler() {
await response.json();
toast("Crawling Successful", {
description: "URLs have been submitted for crawling",
toast(t('success_toast'), {
description: t('success_toast_desc'),
});
// Redirect to documents page
router.push(`/dashboard/${search_space_id}/documents`);
} catch (error: any) {
setError(error.message || "An error occurred while crawling URLs");
toast("Crawling Error", {
description: `Error crawling URLs: ${error.message}`,
setError(error.message || t('error_generic'));
toast(t('error_toast'), {
description: `${t('error_toast_desc')}: ${error.message}`,
});
} finally {
setIsSubmitting(false);
@ -103,16 +105,16 @@ export default function WebpageCrawler() {
const handleAddTag = (text: string) => {
// Basic URL validation
if (!isValidUrl(text)) {
toast("Invalid URL", {
description: "Please enter a valid URL",
toast(t('invalid_url_toast'), {
description: t('invalid_url_toast_desc'),
});
return;
}
// Check for duplicates
if (urlTags.some((tag) => tag.text === text)) {
toast("Duplicate URL", {
description: "This URL has already been added",
toast(t('duplicate_url_toast'), {
description: t('duplicate_url_toast_desc'),
});
return;
}
@ -132,19 +134,19 @@ export default function WebpageCrawler() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Globe className="h-5 w-5" />
Add Webpages for Crawling
{t('title')}
</CardTitle>
<CardDescription>Enter URLs to crawl and add to your document collection</CardDescription>
<CardDescription>{t('subtitle')}</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="url-input">Enter URLs to crawl</Label>
<Label htmlFor="url-input">{t('label')}</Label>
<TagInput
id="url-input"
tags={urlTags}
setTags={setUrlTags}
placeholder="Enter a URL and press Enter"
placeholder={t('placeholder')}
onAddTag={handleAddTag}
styleClasses={{
inlineTagsContainer:
@ -160,19 +162,19 @@ export default function WebpageCrawler() {
setActiveTagIndex={setActiveTagIndex}
/>
<p className="text-xs text-muted-foreground mt-1">
Add multiple URLs by pressing Enter after each one
{t('hint')}
</p>
</div>
{error && <div className="text-sm text-red-500 mt-2">{error}</div>}
<div className="bg-muted/50 rounded-lg p-4 text-sm">
<h4 className="font-medium mb-2">Tips for URL crawling:</h4>
<h4 className="font-medium mb-2">{t('tips_title')}</h4>
<ul className="list-disc pl-5 space-y-1 text-muted-foreground">
<li>Enter complete URLs including http:// or https://</li>
<li>Make sure the websites allow crawling</li>
<li>Public webpages work best</li>
<li>Crawling may take some time depending on the website size</li>
<li>{t('tip_1')}</li>
<li>{t('tip_2')}</li>
<li>{t('tip_3')}</li>
<li>{t('tip_4')}</li>
</ul>
</div>
</div>
@ -182,16 +184,16 @@ export default function WebpageCrawler() {
variant="outline"
onClick={() => router.push(`/dashboard/${search_space_id}/documents`)}
>
Cancel
{t('cancel')}
</Button>
<Button onClick={handleSubmit} disabled={isSubmitting || urlTags.length === 0}>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Submitting...
{t('submitting')}
</>
) : (
"Submit URLs for Crawling"
t('submit')
)}
</Button>
</CardFooter>

View file

@ -7,6 +7,7 @@ import { motion, type Variants } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import {
Card,
@ -23,6 +24,7 @@ const youtubeRegex =
/^(https:\/\/)?(www\.)?(youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})$/;
export default function YouTubeVideoAdder() {
const t = useTranslations('add_youtube');
const params = useParams();
const router = useRouter();
const search_space_id = params.search_space_id as string;
@ -47,14 +49,14 @@ export default function YouTubeVideoAdder() {
const handleSubmit = async () => {
// Validate that we have at least one video URL
if (videoTags.length === 0) {
setError("Please add at least one YouTube video URL");
setError(t('error_no_video'));
return;
}
// Validate all URLs
const invalidUrls = videoTags.filter((tag) => !isValidYoutubeUrl(tag.text));
if (invalidUrls.length > 0) {
setError(`Invalid YouTube URLs detected: ${invalidUrls.map((tag) => tag.text).join(", ")}`);
setError(t('error_invalid_urls', { urls: invalidUrls.map((tag) => tag.text).join(", ") }));
return;
}
@ -62,8 +64,8 @@ export default function YouTubeVideoAdder() {
setIsSubmitting(true);
try {
toast("YouTube Video Processing", {
description: "Starting YouTube video processing...",
toast(t('processing_toast'), {
description: t('processing_toast_desc'),
});
// Extract URLs from tags
@ -92,16 +94,16 @@ export default function YouTubeVideoAdder() {
await response.json();
toast("Processing Successful", {
description: "YouTube videos have been submitted for processing",
toast(t('success_toast'), {
description: t('success_toast_desc'),
});
// Redirect to documents page
router.push(`/dashboard/${search_space_id}/documents`);
} catch (error: any) {
setError(error.message || "An error occurred while processing YouTube videos");
toast("Processing Error", {
description: `Error processing YouTube videos: ${error.message}`,
setError(error.message || t('error_generic'));
toast(t('error_toast'), {
description: `${t('error_toast_desc')}: ${error.message}`,
});
} finally {
setIsSubmitting(false);
@ -112,16 +114,16 @@ export default function YouTubeVideoAdder() {
const handleAddTag = (text: string) => {
// Basic URL validation
if (!isValidYoutubeUrl(text)) {
toast("Invalid YouTube URL", {
description: "Please enter a valid YouTube video URL",
toast(t('invalid_url_toast'), {
description: t('invalid_url_toast_desc'),
});
return;
}
// Check for duplicates
if (videoTags.some((tag) => tag.text === text)) {
toast("Duplicate URL", {
description: "This YouTube video has already been added",
toast(t('duplicate_url_toast'), {
description: t('duplicate_url_toast_desc'),
});
return;
}
@ -167,10 +169,10 @@ export default function YouTubeVideoAdder() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<IconBrandYoutube className="h-5 w-5" />
Add YouTube Videos
{t('title')}
</CardTitle>
<CardDescription>
Enter YouTube video URLs to add to your document collection
{t('subtitle')}
</CardDescription>
</CardHeader>
</motion.div>
@ -179,12 +181,12 @@ export default function YouTubeVideoAdder() {
<CardContent>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="video-input">Enter YouTube Video URLs</Label>
<Label htmlFor="video-input">{t('label')}</Label>
<TagInput
id="video-input"
tags={videoTags}
setTags={setVideoTags}
placeholder="Enter a YouTube URL and press Enter"
placeholder={t('placeholder')}
onAddTag={handleAddTag}
styleClasses={{
inlineTagsContainer:
@ -200,7 +202,7 @@ export default function YouTubeVideoAdder() {
setActiveTagIndex={setActiveTagIndex}
/>
<p className="text-xs text-muted-foreground mt-1">
Add multiple YouTube URLs by pressing Enter after each one
{t('hint')}
</p>
</div>
@ -216,18 +218,18 @@ export default function YouTubeVideoAdder() {
)}
<motion.div variants={itemVariants} className="bg-muted/50 rounded-lg p-4 text-sm">
<h4 className="font-medium mb-2">Tips for adding YouTube videos:</h4>
<h4 className="font-medium mb-2">{t('tips_title')}</h4>
<ul className="list-disc pl-5 space-y-1 text-muted-foreground">
<li>Use standard YouTube URLs (youtube.com/watch?v= or youtu.be/)</li>
<li>Make sure videos are publicly accessible</li>
<li>Supported formats: youtube.com/watch?v=VIDEO_ID or youtu.be/VIDEO_ID</li>
<li>Processing may take some time depending on video length</li>
<li>{t('tip_1')}</li>
<li>{t('tip_2')}</li>
<li>{t('tip_3')}</li>
<li>{t('tip_4')}</li>
</ul>
</motion.div>
{videoTags.length > 0 && (
<motion.div variants={itemVariants} className="mt-4 space-y-2">
<h4 className="font-medium">Preview:</h4>
<h4 className="font-medium">{t('preview')}:</h4>
<div className="grid grid-cols-1 gap-3">
{videoTags.map((tag, index) => {
const videoId = extractVideoId(tag.text);
@ -263,7 +265,7 @@ export default function YouTubeVideoAdder() {
variant="outline"
onClick={() => router.push(`/dashboard/${search_space_id}/documents`)}
>
Cancel
{t('cancel')}
</Button>
<Button
onClick={handleSubmit}
@ -273,7 +275,7 @@ export default function YouTubeVideoAdder() {
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Processing...
{t('processing')}
</>
) : (
<>
@ -285,7 +287,7 @@ export default function YouTubeVideoAdder() {
>
<IconBrandYoutube className="h-4 w-4" />
</motion.span>
Submit YouTube Videos
{t('submit')}
</>
)}
<motion.div

View file

@ -45,6 +45,7 @@ import { AnimatePresence, motion, type Variants } from "motion/react";
import { useParams } from "next/navigation";
import React, { useContext, useId, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
import { JsonMetadataViewer } from "@/components/json-metadata-viewer";
import {
AlertDialog,
@ -171,7 +172,7 @@ function MessageDetails({
);
}
const columns: ColumnDef<Log>[] = [
const createColumns = (t: (key: string) => string): ColumnDef<Log>[] => [
{
id: "select",
header: ({ table }) => (
@ -195,7 +196,7 @@ const columns: ColumnDef<Log>[] = [
enableHiding: false,
},
{
header: "Level",
header: t('level'),
accessorKey: "level",
cell: ({ row }) => {
const level = row.getValue("level") as LogLevel;
@ -219,7 +220,7 @@ const columns: ColumnDef<Log>[] = [
size: 120,
},
{
header: "Status",
header: t('status'),
accessorKey: "status",
cell: ({ row }) => {
const status = row.getValue("status") as LogStatus;
@ -245,7 +246,7 @@ const columns: ColumnDef<Log>[] = [
size: 140,
},
{
header: "Source",
header: t('source'),
accessorKey: "source",
cell: ({ row }) => {
const source = row.getValue("source") as string;
@ -256,14 +257,14 @@ const columns: ColumnDef<Log>[] = [
transition={{ type: "spring", stiffness: 300 }}
>
<Terminal size={14} className="text-muted-foreground" />
<span className="text-sm font-mono">{source || "System"}</span>
<span className="text-sm font-mono">{source || t('system')}</span>
</motion.div>
);
},
size: 150,
},
{
header: "Message",
header: t('message'),
accessorKey: "message",
cell: ({ row }) => {
const message = row.getValue("message") as string;
@ -296,7 +297,7 @@ const columns: ColumnDef<Log>[] = [
size: 400,
},
{
header: "Created At",
header: t('created_at'),
accessorKey: "created_at",
cell: ({ row }) => {
const date = new Date(row.getValue("created_at"));
@ -311,13 +312,16 @@ const columns: ColumnDef<Log>[] = [
},
{
id: "actions",
header: () => <span className="sr-only">Actions</span>,
cell: ({ row }) => <LogRowActions row={row} />,
header: () => <span className="sr-only">{t('actions')}</span>,
cell: ({ row }) => <LogRowActions row={row} t={t} />,
size: 60,
enableHiding: false,
},
];
// Default columns for backward compatibility
const columns: ColumnDef<Log>[] = createColumns((key) => key);
// Create a context to share functions
const LogsContext = React.createContext<{
deleteLog: (id: number) => Promise<boolean>;
@ -325,6 +329,7 @@ const LogsContext = React.createContext<{
} | null>(null);
export default function LogsManagePage() {
const t = useTranslations('logs');
const id = useId();
const params = useParams();
const searchSpaceId = Number(params.search_space_id);
@ -358,9 +363,12 @@ export default function LogsManagePage() {
const inputRef = useRef<HTMLInputElement>(null);
// Create translated columns
const translatedColumns = useMemo(() => createColumns(t), [t]);
const table = useReactTable({
data: logs,
columns,
columns: translatedColumns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
onSortingChange: setSorting,
@ -454,12 +462,12 @@ export default function LogsManagePage() {
transition={{ delay: 0.1 }}
>
<div>
<h2 className="text-2xl font-bold tracking-tight">Task Logs</h2>
<p className="text-muted-foreground">Monitor and analyze all task execution logs</p>
<h2 className="text-2xl font-bold tracking-tight">{t('title')}</h2>
<p className="text-muted-foreground">{t('subtitle')}</p>
</div>
<Button onClick={handleRefresh} variant="outline" size="sm">
<RefreshCw className="w-4 h-4 mr-2" />
Refresh
{t('refresh')}
</Button>
</motion.div>
@ -484,7 +492,7 @@ export default function LogsManagePage() {
<AlertDialogTrigger asChild>
<Button variant="outline">
<Trash className="-ms-1 me-2 opacity-60" size={16} strokeWidth={2} />
Delete Selected
{t('delete_selected')}
<span className="-me-1 ms-3 inline-flex h-5 max-h-full items-center rounded border border-border bg-background px-1 font-[inherit] text-[0.625rem] font-medium text-muted-foreground/70">
{table.getSelectedRowModel().rows.length}
</span>
@ -496,16 +504,15 @@ export default function LogsManagePage() {
<CircleAlert className="opacity-80" size={16} strokeWidth={2} />
</div>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete{" "}
{table.getSelectedRowModel().rows.length} selected log(s).
</AlertDialogDescription>
<AlertDialogTitle>{t('confirm_title')}</AlertDialogTitle>
<AlertDialogDescription>
{t('confirm_delete_desc', { count: table.getSelectedRowModel().rows.length })}
</AlertDialogDescription>
</AlertDialogHeader>
</div>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteRows}>Delete</AlertDialogAction>
<AlertDialogCancel>{t('cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteRows}>{t('delete')}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
@ -520,6 +527,7 @@ export default function LogsManagePage() {
error={logsError}
onRefresh={refreshLogs}
id={id}
t={t}
/>
</motion.div>
</LogsContext.Provider>
@ -538,6 +546,7 @@ function LogsSummaryDashboard({
error: string | null;
onRefresh: () => void;
}) {
const t = useTranslations('logs');
if (loading) {
return (
<motion.div
@ -565,9 +574,9 @@ function LogsSummaryDashboard({
<CardContent className="flex items-center justify-center h-32">
<div className="flex flex-col items-center gap-2">
<AlertCircle className="h-8 w-8 text-destructive" />
<p className="text-sm text-destructive">Failed to load summary</p>
<p className="text-sm text-destructive">{t('failed_load_summary')}</p>
<Button variant="outline" size="sm" onClick={onRefresh}>
Retry
{t('retry')}
</Button>
</div>
</CardContent>
@ -586,12 +595,12 @@ function LogsSummaryDashboard({
<motion.div variants={fadeInScale}>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Logs</CardTitle>
<CardTitle className="text-sm font-medium">{t('total_logs')}</CardTitle>
<Activity className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{summary.total_logs}</div>
<p className="text-xs text-muted-foreground">Last {summary.time_window_hours} hours</p>
<p className="text-xs text-muted-foreground">{t('last_hours', { hours: summary.time_window_hours })}</p>
</CardContent>
</Card>
</motion.div>
@ -600,14 +609,14 @@ function LogsSummaryDashboard({
<motion.div variants={fadeInScale}>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Active Tasks</CardTitle>
<CardTitle className="text-sm font-medium">{t('active_tasks')}</CardTitle>
<Clock className="h-4 w-4 text-blue-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-blue-600">
{summary.active_tasks?.length || 0}
</div>
<p className="text-xs text-muted-foreground">Currently running</p>
<p className="text-xs text-muted-foreground">{t('currently_running')}</p>
</CardContent>
</Card>
</motion.div>
@ -616,7 +625,7 @@ function LogsSummaryDashboard({
<motion.div variants={fadeInScale}>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Success Rate</CardTitle>
<CardTitle className="text-sm font-medium">{t('success_rate')}</CardTitle>
<CheckCircle2 className="h-4 w-4 text-green-600" />
</CardHeader>
<CardContent>
@ -627,7 +636,7 @@ function LogsSummaryDashboard({
%
</div>
<p className="text-xs text-muted-foreground">
{summary.by_status?.SUCCESS || 0} successful
{summary.by_status?.SUCCESS || 0} {t('successful')}
</p>
</CardContent>
</Card>
@ -637,14 +646,14 @@ function LogsSummaryDashboard({
<motion.div variants={fadeInScale}>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Recent Failures</CardTitle>
<CardTitle className="text-sm font-medium">{t('recent_failures')}</CardTitle>
<AlertCircle className="h-4 w-4 text-red-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-red-600">
{summary.recent_failures?.length || 0}
</div>
<p className="text-xs text-muted-foreground">Need attention</p>
<p className="text-xs text-muted-foreground">{t('need_attention')}</p>
</CardContent>
</Card>
</motion.div>
@ -666,6 +675,7 @@ function LogsFilters({
inputRef: React.RefObject<HTMLInputElement | null>;
id: string;
}) {
const t = useTranslations('logs');
return (
<motion.div
className="flex flex-wrap items-center justify-between gap-3"
@ -684,7 +694,7 @@ function LogsFilters({
)}
value={(table.getColumn("message")?.getFilterValue() ?? "") as string}
onChange={(e) => table.getColumn("message")?.setFilterValue(e.target.value)}
placeholder="Filter by message..."
placeholder={t('filter_by_message')}
type="text"
/>
<div className="pointer-events-none absolute inset-y-0 start-0 flex items-center justify-center ps-3 text-muted-foreground/80">
@ -707,18 +717,20 @@ function LogsFilters({
{/* Level Filter */}
<FilterDropdown
title="Level"
title={t('level')}
column={table.getColumn("level")}
options={uniqueLevels}
id={`${id}-level`}
t={t}
/>
{/* Status Filter */}
<FilterDropdown
title="Status"
title={t('status')}
column={table.getColumn("status")}
options={uniqueStatuses}
id={`${id}-status`}
t={t}
/>
{/* Column Visibility */}
@ -726,11 +738,11 @@ function LogsFilters({
<DropdownMenuTrigger asChild>
<Button variant="outline">
<Columns3 className="-ms-1 me-2 opacity-60" size={16} strokeWidth={2} />
View
{t('view')}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
<DropdownMenuLabel>{t('toggle_columns')}</DropdownMenuLabel>
{table
.getAllColumns()
.filter((column: any) => column.getCanHide())
@ -758,11 +770,13 @@ function FilterDropdown({
column,
options,
id,
t,
}: {
title: string;
column: any;
options: string[];
id: string;
t: (key: string) => string;
}) {
const selectedValues = useMemo(() => {
const filterValue = column?.getFilterValue() as string[];
@ -800,7 +814,7 @@ function FilterDropdown({
</PopoverTrigger>
<PopoverContent className="min-w-36 p-3" align="start">
<div className="space-y-3">
<div className="text-xs font-medium text-muted-foreground">Filter by {title}</div>
<div className="text-xs font-medium text-muted-foreground">{t('filter_by')} {title}</div>
<div className="space-y-2">
{options.map((value, i) => (
<div key={value} className="flex items-center gap-2">
@ -829,6 +843,7 @@ function LogsTable({
error,
onRefresh,
id,
t,
}: {
table: any;
logs: Log[];
@ -836,6 +851,7 @@ function LogsTable({
error: string | null;
onRefresh: () => void;
id: string;
t: (key: string, params?: any) => string;
}) {
if (loading) {
return (
@ -881,12 +897,12 @@ function LogsTable({
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
>
<div className="flex h-[400px] w-full items-center justify-center">
<div className="flex flex-col items-center gap-2">
<Terminal className="h-8 w-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">No logs found</p>
</div>
<div className="flex h-[400px] w-full items-center justify-center">
<div className="flex flex-col items-center gap-2">
<Terminal className="h-8 w-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">{t('no_logs')}</p>
</div>
</div>
</motion.div>
);
}
@ -979,26 +995,26 @@ function LogsTable({
})}
</motion.tr>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No logs found.
</TableCell>
</TableRow>
)}
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
{t('no_logs')}
</TableCell>
</TableRow>
)}
</AnimatePresence>
</TableBody>
</Table>
</motion.div>
{/* Pagination */}
<LogsPagination table={table} id={id} />
<LogsPagination table={table} id={id} t={t} />
</>
);
}
// Pagination Component
function LogsPagination({ table, id }: { table: any; id: string }) {
function LogsPagination({ table, id, t }: { table: any; id: string; t: (key: string) => string }) {
return (
<div className="flex items-center justify-between gap-8 mt-6">
<motion.div
@ -1007,7 +1023,7 @@ function LogsPagination({ table, id }: { table: any; id: string }) {
animate={{ opacity: 1, x: 0 }}
>
<Label htmlFor={id} className="max-sm:sr-only">
Rows per page
{t('rows_per_page')}
</Label>
<Select
value={table.getState().pagination.pageSize.toString()}
@ -1096,7 +1112,7 @@ function LogsPagination({ table, id }: { table: any; id: string }) {
}
// Row Actions Component
function LogRowActions({ row }: { row: Row<Log> }) {
function LogRowActions({ row, t }: { row: Row<Log>; t: (key: string) => string }) {
const [isOpen, setIsOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const { deleteLog, refreshLogs } = useContext(LogsContext)!;
@ -1106,11 +1122,11 @@ function LogRowActions({ row }: { row: Row<Log> }) {
setIsDeleting(true);
try {
await deleteLog(log.id);
toast.success("Log deleted successfully");
toast.success(t('log_deleted_success'));
await refreshLogs();
} catch (error) {
console.error("Error deleting log:", error);
toast.error("Failed to delete log");
toast.error(t('log_deleted_error'));
} finally {
setIsDeleting(false);
setIsOpen(false);
@ -1131,7 +1147,7 @@ function LogRowActions({ row }: { row: Row<Log> }) {
metadata={log.log_metadata}
trigger={
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
View Metadata
{t('view_metadata')}
</DropdownMenuItem>
}
/>
@ -1145,20 +1161,20 @@ function LogRowActions({ row }: { row: Row<Log> }) {
setIsOpen(true);
}}
>
Delete
{t('delete')}
</DropdownMenuItem>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
<AlertDialogTitle>{t('confirm_delete_log_title')}</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the log entry.
{t('confirm_delete_log_desc')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogCancel>{t('cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} disabled={isDeleting}>
{isDeleting ? "Deleting..." : "Delete"}
{isDeleting ? t('deleting') : t('delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>

View file

@ -4,6 +4,7 @@ import { ArrowLeft, ArrowRight, Bot, CheckCircle, Sparkles } from "lucide-react"
import { AnimatePresence, motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { Logo } from "@/components/Logo";
import { AddProviderStep } from "@/components/onboard/add-provider-step";
import { AssignRolesStep } from "@/components/onboard/assign-roles-step";
@ -16,6 +17,7 @@ import { useLLMConfigs, useLLMPreferences } from "@/hooks/use-llm-configs";
const TOTAL_STEPS = 3;
const OnboardPage = () => {
const t = useTranslations('onboard');
const router = useRouter();
const params = useParams();
const searchSpaceId = Number(params.search_space_id);
@ -67,12 +69,12 @@ const OnboardPage = () => {
const progress = (currentStep / TOTAL_STEPS) * 100;
const stepTitles = ["Add LLM Provider", "Assign LLM Roles", "Setup Complete"];
const stepTitles = [t('add_llm_provider'), t('assign_llm_roles'), t('setup_complete')];
const stepDescriptions = [
"Configure your first model provider",
"Assign specific roles to your LLM configurations",
"You're all set to start using SurfSense!",
t('configure_first_provider'),
t('assign_specific_roles'),
t('all_set'),
];
const canProceedToStep2 = !configsLoading && llmConfigs.length > 0;
@ -104,7 +106,7 @@ const OnboardPage = () => {
<Card className="w-[350px] bg-background/60 backdrop-blur-sm">
<CardContent className="flex flex-col items-center justify-center py-12">
<Bot className="h-12 w-12 text-primary animate-pulse mb-4" />
<p className="text-sm text-muted-foreground">Loading your configuration...</p>
<p className="text-sm text-muted-foreground">{t('loading_config')}</p>
</CardContent>
</Card>
</div>
@ -123,10 +125,10 @@ const OnboardPage = () => {
<div className="text-center mb-8">
<div className="flex items-center justify-center mb-4">
<Logo className="w-12 h-12 mr-3 rounded-full" />
<h1 className="text-3xl font-bold">Welcome to SurfSense</h1>
<h1 className="text-3xl font-bold">{t('welcome_title')}</h1>
</div>
<p className="text-muted-foreground text-lg">
Let's configure your LLM configurations to get started
{t('welcome_subtitle')}
</p>
</div>
@ -135,9 +137,9 @@ const OnboardPage = () => {
<CardContent className="pt-6">
<div className="flex items-center justify-between mb-4">
<div className="text-sm font-medium">
Step {currentStep} of {TOTAL_STEPS}
{t('step_of', { current: currentStep, total: TOTAL_STEPS })}
</div>
<div className="text-sm text-muted-foreground">{Math.round(progress)}% Complete</div>
<div className="text-sm text-muted-foreground">{t('percent_complete', { percent: Math.round(progress) })}</div>
</div>
<Progress value={progress} className="mb-4" />
<div className="grid grid-cols-3 gap-4">
@ -225,7 +227,7 @@ const OnboardPage = () => {
className="flex items-center gap-2"
>
<ArrowLeft className="w-4 h-4" />
Previous
{t('previous')}
</Button>
<div className="flex gap-2">
@ -238,14 +240,14 @@ const OnboardPage = () => {
}
className="flex items-center gap-2"
>
Next
{t('next')}
<ArrowRight className="w-4 h-4" />
</Button>
)}
{currentStep === TOTAL_STEPS && (
<Button onClick={handleComplete} className="flex items-center gap-2">
Complete Setup
{t('complete_setup')}
<CheckCircle className="w-4 h-4" />
</Button>
)}

View file

@ -7,6 +7,7 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
import { Logo } from "@/components/Logo";
import { ThemeTogglerComponent } from "@/components/theme/theme-toggle";
import { UserDropdown } from "@/components/UserDropdown";
@ -61,6 +62,7 @@ const formatDate = (dateString: string): string => {
* Loading screen component with animation
*/
const LoadingScreen = () => {
const t = useTranslations('dashboard');
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] space-y-4">
<motion.div
@ -70,8 +72,8 @@ const LoadingScreen = () => {
>
<Card className="w-[350px] bg-background/60 backdrop-blur-sm">
<CardHeader className="pb-2">
<CardTitle className="text-xl font-medium">Loading</CardTitle>
<CardDescription>Fetching your search spaces...</CardDescription>
<CardTitle className="text-xl font-medium">{t('loading')}</CardTitle>
<CardDescription>{t('fetching_spaces')}</CardDescription>
</CardHeader>
<CardContent className="flex justify-center py-6">
<motion.div
@ -82,7 +84,7 @@ const LoadingScreen = () => {
</motion.div>
</CardContent>
<CardFooter className="border-t pt-4 text-sm text-muted-foreground">
This may take a moment
{t('may_take_moment')}
</CardFooter>
</Card>
</motion.div>
@ -94,6 +96,7 @@ const LoadingScreen = () => {
* Error screen component with animation
*/
const ErrorScreen = ({ message }: { message: string }) => {
const t = useTranslations('dashboard');
const router = useRouter();
return (
@ -107,22 +110,22 @@ const ErrorScreen = ({ message }: { message: string }) => {
<CardHeader className="pb-2">
<div className="flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-destructive" />
<CardTitle className="text-xl font-medium">Error</CardTitle>
<CardTitle className="text-xl font-medium">{t('error')}</CardTitle>
</div>
<CardDescription>Something went wrong</CardDescription>
<CardDescription>{t('something_wrong')}</CardDescription>
</CardHeader>
<CardContent>
<Alert variant="destructive" className="bg-destructive/10 border-destructive/30">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error Details</AlertTitle>
<AlertTitle>{t('error_details')}</AlertTitle>
<AlertDescription className="mt-2">{message}</AlertDescription>
</Alert>
</CardContent>
<CardFooter className="flex justify-end gap-2 border-t pt-4">
<Button variant="outline" onClick={() => router.refresh()}>
Try Again
{t('try_again')}
</Button>
<Button onClick={() => router.push("/")}>Go Home</Button>
<Button onClick={() => router.push("/")}>{t('go_home')}</Button>
</CardFooter>
</Card>
</motion.div>
@ -131,6 +134,9 @@ const ErrorScreen = ({ message }: { message: string }) => {
};
const DashboardPage = () => {
const t = useTranslations('dashboard');
const tCommon = useTranslations('common');
// Animation variants
const containerVariants: Variants = {
hidden: { opacity: 0 },
@ -239,8 +245,8 @@ const DashboardPage = () => {
<div className="flex flex-row space-x-4">
<Logo className="w-10 h-10 rounded-md" />
<div className="flex flex-col space-y-2">
<h1 className="text-4xl font-bold">SurfSense Dashboard</h1>
<p className="text-muted-foreground">Welcome to your SurfSense dashboard.</p>
<h1 className="text-4xl font-bold">{t('surfsense_dashboard')}</h1>
<p className="text-muted-foreground">{t('welcome_message')}</p>
</div>
</div>
<div className="flex items-center space-x-3">
@ -251,12 +257,12 @@ const DashboardPage = () => {
<div className="flex flex-col space-y-6 mt-6">
<div className="flex justify-between items-center">
<h2 className="text-2xl font-semibold">Your Search Spaces</h2>
<h2 className="text-2xl font-semibold">{t('your_search_spaces')}</h2>
<motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
<Link href="/dashboard/searchspaces">
<Button className="h-10">
<Plus className="mr-2 h-4 w-4" />
Create Search Space
{t('create_search_space')}
</Button>
</Link>
</motion.div>
@ -312,20 +318,18 @@ const DashboardPage = () => {
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Search Space</AlertDialogTitle>
<AlertDialogTitle>{t('delete_search_space')}</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete &quot;{space.name}&quot;? This
action cannot be undone. All documents, chats, and podcasts in
this search space will be permanently deleted.
{t('delete_space_confirm', { name: space.name })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogCancel>{tCommon('cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleDeleteSearchSpace(space.id)}
className="bg-destructive hover:bg-destructive/90"
>
Delete
{tCommon('delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@ -343,7 +347,7 @@ const DashboardPage = () => {
</div>
<div className="mt-4 flex justify-between text-xs text-muted-foreground">
{/* <span>{space.title}</span> */}
<span>Created {formatDate(space.created_at)}</span>
<span>{t('created')} {formatDate(space.created_at)}</span>
</div>
</div>
</Link>
@ -360,14 +364,14 @@ const DashboardPage = () => {
<div className="rounded-full bg-muted/50 p-4 mb-4">
<Search className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="text-lg font-medium mb-2">No search spaces found</h3>
<h3 className="text-lg font-medium mb-2">{t('no_spaces_found')}</h3>
<p className="text-muted-foreground mb-6">
Create your first search space to get started
{t('create_first_space')}
</p>
<Link href="/dashboard/searchspaces">
<Button>
<Plus className="mr-2 h-4 w-4" />
Create Search Space
{t('create_search_space')}
</Button>
</Link>
</motion.div>
@ -388,7 +392,7 @@ const DashboardPage = () => {
<Link href="/dashboard/searchspaces" className="flex h-full">
<div className="flex flex-col items-center justify-center h-full w-full rounded-xl border border-dashed bg-muted/10 hover:border-primary/50 transition-colors">
<Plus className="h-10 w-10 mb-3 text-muted-foreground" />
<span className="text-sm font-medium">Add New Search Space</span>
<span className="text-sm font-medium">{t('add_new_search_space')}</span>
</div>
</Link>
</Tilt>

View file

@ -5,6 +5,8 @@ import { Roboto } from "next/font/google";
import { ThemeProvider } from "@/components/theme/theme-provider";
import { Toaster } from "@/components/ui/sonner";
import { cn } from "@/lib/utils";
import { LocaleProvider } from "@/contexts/LocaleContext";
import { I18nProvider } from "@/components/providers/I18nProvider";
const roboto = Roboto({
subsets: ["latin"],
@ -77,25 +79,32 @@ export const metadata: Metadata = {
},
};
export default async function RootLayout({
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
// Using client-side i18n
// Language can be switched dynamically through LanguageSwitcher component
// Locale state is managed by LocaleContext and persisted in localStorage
return (
<html lang="en" suppressHydrationWarning>
<body className={cn(roboto.className, "bg-white dark:bg-black antialiased h-full w-full")}>
<ThemeProvider
attribute="class"
enableSystem
disableTransitionOnChange
defaultTheme="light"
>
<RootProvider>
{children}
<Toaster />
</RootProvider>
</ThemeProvider>
<LocaleProvider>
<I18nProvider>
<ThemeProvider
attribute="class"
enableSystem
disableTransitionOnChange
defaultTheme="light"
>
<RootProvider>
{children}
<Toaster />
</RootProvider>
</ThemeProvider>
</I18nProvider>
</LocaleProvider>
</body>
</html>
);