mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-02 12:22:40 +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
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue