mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-30 21:59:46 +02:00
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:
parent
8aeaf419d0
commit
f58c7e4602
37 changed files with 2267 additions and 542 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 "{space.name}"? 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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue