diff --git a/app/page.tsx b/app/page.tsx index a2817afb..dc7215f1 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -3,6 +3,7 @@ import { useState, useCallback, useEffect, useRef } from "react"; import { useSession, signIn } from "next-auth/react"; import { useInfiniteQuery } from "@tanstack/react-query"; +import { useTranslations } from "next-intl"; import Link from "next/link"; import { Plus, Search, Globe, Lock, FolderOpen, LogIn, User } from "lucide-react"; import { Header } from "@/components/layout/header"; @@ -20,6 +21,9 @@ export default function HomePage() { const [filter, setFilter] = useState("public"); const [searchQuery, setSearchQuery] = useState(""); const [debouncedSearch, setDebouncedSearch] = useState(""); + const t = useTranslations("projects"); + const tCommon = useTranslations("common"); + const tAuth = useTranslations("auth"); const isAuthenticated = status === "authenticated"; @@ -79,10 +83,10 @@ export default function HomePage() { if (hasNextPage && !isFetchingNextPage) { setNextPageError(null); fetchNextPage().catch((err) => { - setNextPageError(err instanceof Error ? err.message : "Failed to load more projects"); + setNextPageError(err instanceof Error ? err.message : t("failedToLoad")); }); } - }, [hasNextPage, isFetchingNextPage, fetchNextPage]); + }, [hasNextPage, isFetchingNextPage, fetchNextPage, t]); return ( <> @@ -93,19 +97,17 @@ export default function HomePage() {

- Projects + {t("title")}

- {isAuthenticated - ? "Browse public projects or manage your own" - : "Browse public ontology projects"} + {isAuthenticated ? t("subtitleAuth") : t("subtitleGuest")}

{isAuthenticated && ( )} @@ -116,10 +118,10 @@ export default function HomePage() { {/* Filter Tabs */}
{([ - { value: "mine" as const, label: "My Projects", icon: User }, - { value: "public" as const, label: "Public", icon: Globe }, - { value: "private" as const, label: "Private", icon: Lock }, - { value: "all" as const, label: "All", icon: FolderOpen }, + { value: "mine" as const, label: t("filterMine"), icon: User }, + { value: "public" as const, label: t("filterPublic"), icon: Globe }, + { value: "private" as const, label: t("filterPrivate"), icon: Lock }, + { value: "all" as const, label: t("filterAll"), icon: FolderOpen }, ] as const).map(({ value, label, icon: Icon }) => (
) : isLoading ? ( @@ -191,14 +193,14 @@ export default function HomePage() {
) : error && !data ? (
-

{error instanceof Error ? error.message : "Failed to load projects"}

+

{error instanceof Error ? error.message : t("failedToLoad")}

) : projects.length === 0 ? ( @@ -206,27 +208,27 @@ export default function HomePage() {

{debouncedSearch - ? "No projects found" + ? t("noProjectsFound") : filter === "private" - ? "No private projects yet" + ? t("noPrivateProjectsYet") : filter === "mine" - ? "No projects yet" - : "No projects available"} + ? t("noProjectsYet") + : t("noProjectsAvailable")}

{debouncedSearch - ? "Try a different search term" + ? t("tryDifferentSearch") : filter === "private" && isAuthenticated - ? "You are not a member of any private projects, and do not own any private projects. Create your first private project to get started." + ? t("noPrivateProjectsDescription") : filter === "mine" && isAuthenticated - ? "Create your first project to get started" - : "Check back later for public projects"} + ? t("createFirstProject") + : t("checkBackLater")}

{(filter === "mine" || filter === "private") && isAuthenticated && !debouncedSearch && ( )} @@ -235,8 +237,8 @@ export default function HomePage() { <>
{debouncedSearch - ? `${total} ${total === 1 ? "result" : "results"} for "${debouncedSearch}"` - : `${total} ${total === 1 ? "project" : "projects"}`} + ? t("searchCount", { count: total, query: debouncedSearch }) + : t("count", { count: total })} {isFiltered && ( {` (of ${unfilteredTotal})`} @@ -258,7 +260,7 @@ export default function HomePage() { onClick={handleLoadMore} disabled={isFetchingNextPage} > - {isFetchingNextPage ? "Loading..." : nextPageError ? "Retry" : "Load More"} + {isFetchingNextPage ? t("loadingMore") : nextPageError ? t("retryLoad") : t("loadMore")}
)} diff --git a/components/auth/user-menu.tsx b/components/auth/user-menu.tsx index 53ab5aa8..66746078 100644 --- a/components/auth/user-menu.tsx +++ b/components/auth/user-menu.tsx @@ -4,6 +4,7 @@ import { signIn, signOut, useSession } from "next-auth/react"; import Image from "next/image"; import Link from "next/link"; import { useState, useRef, useEffect } from "react"; +import { useTranslations } from "next-intl"; // Zitadel configuration const ZITADEL_ISSUER = process.env.NEXT_PUBLIC_ZITADEL_ISSUER || "http://localhost:8080"; @@ -13,6 +14,7 @@ export function UserMenu() { const { data: session, status } = useSession(); const [isOpen, setIsOpen] = useState(false); const menuRef = useRef(null); + const t = useTranslations("auth"); // Handle federated logout (sign out from both NextAuth and Zitadel) const handleSignOut = async () => { @@ -47,7 +49,7 @@ export function UserMenu() { onClick={() => signIn("zitadel")} className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 focus:outline-hidden focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 transition-colors" > - Sign in + {t("signIn")} ); } @@ -61,7 +63,7 @@ export function UserMenu() { {session.user?.image ? ( {session.user.name setIsOpen(false)} className="block w-full px-4 py-2 text-left text-sm text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700" > - Settings + {t("settings")} diff --git a/components/layout/header.tsx b/components/layout/header.tsx index 1ceb6c57..853b152b 100644 --- a/components/layout/header.tsx +++ b/components/layout/header.tsx @@ -2,20 +2,23 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; +import { useTranslations } from "next-intl"; import { UserMenu } from "@/components/auth/user-menu"; import { NotificationBell } from "@/components/layout/notification-bell"; import { ThemeToggle } from "@/components/editor/ThemeToggle"; +import { LanguageSwitcher } from "@/components/ui/LanguageSwitcher"; import { cn } from "@/lib/utils"; -const navLinks = [ - { href: "/", label: "Projects" }, - { href: "/info", label: "Info" }, - { href: "/docs", label: "Documentation" }, - { href: "/api-docs", label: "API Reference" }, -]; - export function Header() { const pathname = usePathname(); + const t = useTranslations("nav"); + + const navLinks = [ + { href: "/", label: t("projects") }, + { href: "/info", label: t("info") }, + { href: "/docs", label: t("documentation") }, + { href: "/api-docs", label: t("apiReference") }, + ]; return (
@@ -49,6 +52,7 @@ export function Header() {
+ diff --git a/components/ui/ConnectionStatus.tsx b/components/ui/ConnectionStatus.tsx index 449c2490..2309f272 100644 --- a/components/ui/ConnectionStatus.tsx +++ b/components/ui/ConnectionStatus.tsx @@ -1,5 +1,6 @@ "use client"; +import { useTranslations } from "next-intl"; import { Loader2 } from "lucide-react"; import { WebSocketIcon } from "@/components/ui/icons/WebSocketIcon"; import { cn } from "@/lib/utils"; @@ -21,28 +22,28 @@ const stateConfig = { icon: Loader2, color: "text-amber-500", bgColor: "bg-amber-100 dark:bg-amber-900/30", - label: "Connecting", + labelKey: "connecting" as const, animate: true, }, connected: { icon: WebSocketIcon, color: "text-green-500", bgColor: "bg-green-100 dark:bg-green-900/30", - label: "Connected", + labelKey: "connected" as const, animate: false, }, disconnected: { icon: WebSocketIcon, color: "text-red-500", bgColor: "bg-red-100 dark:bg-red-900/30", - label: "Disconnected", + labelKey: "disconnected" as const, animate: false, }, disabled: { icon: WebSocketIcon, color: "text-slate-400 dark:text-slate-500", bgColor: "bg-slate-100 dark:bg-slate-800", - label: "Not Available", + labelKey: "notAvailable" as const, animate: false, }, }; @@ -54,17 +55,19 @@ export function ConnectionStatus({ purpose, endpoint, }: ConnectionStatusProps) { + const t = useTranslations("connection"); const config = stateConfig[state]; const Icon = config.icon; + const label = t(config.labelKey); // Build informative tooltip const buildTitle = () => { - const parts: string[] = [config.label]; + const parts: string[] = [label]; if (purpose) { - parts.push(`Purpose: ${purpose}`); + parts.push(t("purpose", { purpose })); } if (endpoint) { - parts.push(`Endpoint: ${endpoint}`); + parts.push(t("endpoint", { endpoint })); } return parts.join("\n"); }; @@ -87,7 +90,7 @@ export function ConnectionStatus({ /> {showLabel && ( - {config.label} + {label} )}
diff --git a/components/ui/LanguageSwitcher.tsx b/components/ui/LanguageSwitcher.tsx new file mode 100644 index 00000000..980e057a --- /dev/null +++ b/components/ui/LanguageSwitcher.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { useTranslations, useLocale } from "next-intl"; +import { useRouter } from "next/navigation"; +import { Globe } from "lucide-react"; + +const LOCALES = ["en", "pt"] as const; +type Locale = (typeof LOCALES)[number]; + +const LOCALE_COOKIE = "NEXT_LOCALE"; + +export function LanguageSwitcher() { + const t = useTranslations("language"); + const router = useRouter(); + const currentLocale = useLocale() as Locale; + + const handleChange = (locale: Locale) => { + document.cookie = `${LOCALE_COOKIE}=${locale}; path=/; max-age=31536000; SameSite=Lax`; + router.refresh(); + }; + + return ( +
+
+ ); +} diff --git a/lib/i18n/request.ts b/lib/i18n/request.ts index 63676d33..7738442b 100644 --- a/lib/i18n/request.ts +++ b/lib/i18n/request.ts @@ -1,7 +1,7 @@ import { getRequestConfig } from "next-intl/server"; import { cookies, headers } from "next/headers"; -const SUPPORTED_LOCALES = ["en"] as const; +const SUPPORTED_LOCALES = ["en", "pt"] as const; const DEFAULT_LOCALE = "en"; const LOCALE_COOKIE = "NEXT_LOCALE"; diff --git a/messages/en.json b/messages/en.json index 1a0db652..a547dcc2 100644 --- a/messages/en.json +++ b/messages/en.json @@ -11,19 +11,31 @@ "filter": "Filter", "export": "Export", "import": "Import", - "settings": "Settings" + "settings": "Settings", + "retry": "Try Again", + "retrying": "Retrying..." }, "auth": { "signIn": "Sign In", "signOut": "Sign Out", - "signInWith": "Sign in with {provider}" + "signInWith": "Sign in with {provider}", + "userAvatar": "User avatar", + "settings": "Settings" }, "nav": { "home": "Home", "projects": "Projects", + "info": "Info", + "documentation": "Documentation", + "apiReference": "API Reference", "settings": "Settings", "help": "Help" }, + "language": { + "label": "Language", + "en": "English", + "pt": "Português" + }, "ontology": { "title": "Ontology", "classes": "Classes", @@ -73,6 +85,14 @@ "userJoined": "{name} joined", "userLeft": "{name} left" }, + "connection": { + "connecting": "Connecting", + "connected": "Connected", + "disconnected": "Disconnected", + "notAvailable": "Not Available", + "purpose": "Purpose: {purpose}", + "endpoint": "Endpoint: {endpoint}" + }, "git": { "history": "History", "commit": "Commit", @@ -88,5 +108,36 @@ "forbidden": "Access denied", "serverError": "Server error", "networkError": "Network error" + }, + "projects": { + "title": "Projects", + "subtitleAuth": "Browse public projects or manage your own", + "subtitleGuest": "Browse public ontology projects", + "newProject": "New Project", + "createProject": "Create Project", + "filterMine": "My Projects", + "filterPublic": "Public", + "filterPrivate": "Private", + "filterAll": "All", + "searchPlaceholder": "Search projects...", + "signInPromptTitle": "Sign in to see your projects", + "signInPromptDescription": "View all projects you own or are a member of", + "signInPrivatePromptTitle": "Sign in to see private projects", + "signInPrivatePromptDescription": "View all private projects you own or are a member of", + "noProjectsFound": "No projects found", + "noProjectsYet": "No projects yet", + "noPrivateProjectsYet": "No private projects yet", + "noPrivateProjectsDescription": "You are not a member of any private projects, and do not own any private projects. Create your first private project to get started.", + "noProjectsAvailable": "No projects available", + "tryDifferentSearch": "Try a different search term", + "createFirstProject": "Create your first project to get started", + "createPrivateProject": "Create Private Project", + "checkBackLater": "Check back later for public projects", + "failedToLoad": "Failed to load projects", + "loadMore": "Load More", + "loadingMore": "Loading...", + "retryLoad": "Retry", + "count": "{count, plural, one {# project} other {# projects}}", + "searchCount": "{count, plural, one {# result} other {# results}} for \"{query}\"" } } diff --git a/messages/pt.json b/messages/pt.json new file mode 100644 index 00000000..591257b5 --- /dev/null +++ b/messages/pt.json @@ -0,0 +1,143 @@ +{ + "common": { + "loading": "Carregando...", + "error": "Ocorreu um erro", + "save": "Salvar", + "cancel": "Cancelar", + "delete": "Excluir", + "edit": "Editar", + "create": "Criar", + "search": "Buscar", + "filter": "Filtrar", + "export": "Exportar", + "import": "Importar", + "settings": "Configurações", + "retry": "Tentar Novamente", + "retrying": "Tentando novamente..." + }, + "auth": { + "signIn": "Entrar", + "signOut": "Sair", + "signInWith": "Entrar com {provider}", + "userAvatar": "Avatar do usuário", + "settings": "Configurações" + }, + "nav": { + "home": "Início", + "projects": "Projetos", + "info": "Info", + "documentation": "Documentação", + "apiReference": "Referência da API", + "settings": "Configurações", + "help": "Ajuda" + }, + "language": { + "label": "Idioma", + "en": "English", + "pt": "Português" + }, + "ontology": { + "title": "Ontologia", + "classes": "Classes", + "properties": "Propriedades", + "individuals": "Indivíduos", + "annotations": "Anotações", + "imports": "Importações", + "hierarchy": "Hierarquia", + "search": "Buscar ontologia...", + "noResults": "Nenhum resultado encontrado", + "addClass": "Adicionar Classe", + "addProperty": "Adicionar Propriedade", + "addIndividual": "Adicionar Indivíduo" + }, + "class": { + "label": "Label", + "comment": "Comentário", + "iri": "IRI", + "parent": "Classe Pai", + "children": "Subclasses", + "instances": "Instâncias", + "equivalent": "Equivalente A", + "disjoint": "Disjunto De", + "deprecated": "Descontinuado" + }, + "property": { + "domain": "Domínio", + "range": "Range", + "type": "Tipo", + "objectProperty": "Propriedade de Objeto", + "dataProperty": "Propriedade de Dado", + "annotationProperty": "Propriedade de Anotação", + "functional": "Funcional", + "inverseFunctional": "Inverso Funcional", + "transitive": "Transitiva", + "symmetric": "Simétrica", + "asymmetric": "Assimétrica", + "reflexive": "Reflexiva", + "irreflexive": "Irreflexiva", + "inverseOf": "Inverso De" + }, + "collab": { + "users": "Usuários", + "connected": "Conectado", + "disconnected": "Desconectado", + "reconnecting": "Reconectando...", + "userJoined": "{name} entrou", + "userLeft": "{name} saiu" + }, + "connection": { + "connecting": "Conectando", + "connected": "Conectado", + "disconnected": "Desconectado", + "notAvailable": "Não Disponível", + "purpose": "Finalidade: {purpose}", + "endpoint": "Endpoint: {endpoint}" + }, + "git": { + "history": "Histórico", + "commit": "Commit", + "push": "Push", + "pull": "Pull", + "branch": "Branch", + "merge": "Merge", + "diff": "Ver Mudanças" + }, + "errors": { + "notFound": "Não encontrado", + "unauthorized": "Não autorizado", + "forbidden": "Acesso negado", + "serverError": "Erro no servidor", + "networkError": "Erro de rede" + }, + "projects": { + "title": "Projetos", + "subtitleAuth": "Navegue por projetos públicos ou gerencie os seus", + "subtitleGuest": "Navegue por projetos de ontologia públicos", + "newProject": "Novo Projeto", + "createProject": "Criar Projeto", + "filterMine": "Meus Projetos", + "filterPublic": "Públicos", + "filterPrivate": "Privados", + "filterAll": "Todos", + "searchPlaceholder": "Buscar projetos...", + "signInPromptTitle": "Entre para ver seus projetos", + "signInPromptDescription": "Veja todos os projetos que você possui ou participa", + "signInPrivatePromptTitle": "Entre para ver projetos privados", + "signInPrivatePromptDescription": "Veja todos os projetos privados que você possui ou participa", + "noProjectsFound": "Nenhum projeto encontrado", + "noProjectsYet": "Ainda sem projetos", + "noPrivateProjectsYet": "Ainda sem projetos privados", + "noPrivateProjectsDescription": "Você não é membro de nenhum projeto privado e não possui nenhum projeto privado. Crie seu primeiro projeto privado para começar.", + "noProjectsAvailable": "Nenhum projeto disponível", + "tryDifferentSearch": "Tente um termo de busca diferente", + "createFirstProject": "Crie seu primeiro projeto para começar", + "createPrivateProject": "Criar Projeto Privado", + "checkBackLater": "Volte mais tarde para ver projetos públicos", + "failedToLoad": "Falha ao carregar projetos", + "loadMore": "Carregar Mais", + "loadingMore": "Carregando...", + "retryLoad": "Tentar Novamente", + "count": "{count, plural, one {# projeto} other {# projetos}}", + "searchCount": "{count, plural, one {# resultado} other {# resultados}} para \"{query}\"" + } +}