Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 33 additions & 31 deletions app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -20,6 +21,9 @@ export default function HomePage() {
const [filter, setFilter] = useState<FilterType>("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";

Expand Down Expand Up @@ -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 (
<>
Expand All @@ -93,19 +97,17 @@ export default function HomePage() {
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">
Projects
{t("title")}
</h1>
<p className="mt-1 text-sm text-slate-600 dark:text-slate-400">
{isAuthenticated
? "Browse public projects or manage your own"
: "Browse public ontology projects"}
{isAuthenticated ? t("subtitleAuth") : t("subtitleGuest")}
</p>
</div>
{isAuthenticated && (
<Link href="/projects/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
New Project
{t("newProject")}
</Button>
</Link>
)}
Expand All @@ -116,10 +118,10 @@ export default function HomePage() {
{/* Filter Tabs */}
<div className="flex rounded-lg border border-slate-200 bg-white p-1 dark:border-slate-700 dark:bg-slate-800">
{([
{ 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 }) => (
<button
key={value}
Expand All @@ -142,7 +144,7 @@ export default function HomePage() {
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input
type="text"
placeholder="Search projects..."
placeholder={t("searchPlaceholder")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className={cn(
Expand All @@ -167,17 +169,17 @@ export default function HomePage() {
)}
<h3 className="mt-4 text-lg font-medium text-slate-900 dark:text-slate-100">
{filter === "private"
? "Sign in to see private projects"
: "Sign in to see your projects"}
? t("signInPrivatePromptTitle")
: t("signInPromptTitle")}
</h3>
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">
{filter === "private"
? "View all private projects you own or are a member of"
: "View all projects you own or are a member of"}
? t("signInPrivatePromptDescription")
: t("signInPromptDescription")}
</p>
<Button className="mt-6" onClick={() => signIn()}>
<LogIn className="mr-2 h-4 w-4" />
Sign In
{tAuth("signIn")}
</Button>
</div>
) : isLoading ? (
Expand All @@ -191,42 +193,42 @@ export default function HomePage() {
</div>
) : error && !data ? (
<div className="rounded-lg border border-red-200 bg-red-50 p-6 text-center dark:border-red-900/50 dark:bg-red-900/20">
<p className="text-red-700 dark:text-red-400">{error instanceof Error ? error.message : "Failed to load projects"}</p>
<p className="text-red-700 dark:text-red-400">{error instanceof Error ? error.message : t("failedToLoad")}</p>
<Button
variant="outline"
className="mt-4"
onClick={() => refetch()}
disabled={isFetching}
>
{isFetching ? "Retrying..." : "Try Again"}
{isFetching ? tCommon("retrying") : tCommon("retry")}
</Button>
</div>
) : projects.length === 0 ? (
<div className="rounded-lg border border-slate-200 bg-white p-12 text-center dark:border-slate-700 dark:bg-slate-800">
<FolderOpen className="mx-auto h-12 w-12 text-slate-400" />
<h3 className="mt-4 text-lg font-medium text-slate-900 dark:text-slate-100">
{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")}
</h3>
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">
{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")}
</p>
{(filter === "mine" || filter === "private") && isAuthenticated && !debouncedSearch && (
<Link href="/projects/new" className="mt-4 inline-block">
<Button>
<Plus className="mr-2 h-4 w-4" />
{filter === "private" ? "Create Private Project" : "Create Project"}
{filter === "private" ? t("createPrivateProject") : t("createProject")}
</Button>
</Link>
)}
Expand All @@ -235,8 +237,8 @@ export default function HomePage() {
<>
<div className="mb-4 text-sm text-slate-600 dark:text-slate-400">
{debouncedSearch
? `${total} ${total === 1 ? "result" : "results"} for "${debouncedSearch}"`
: `${total} ${total === 1 ? "project" : "projects"}`}
? t("searchCount", { count: total, query: debouncedSearch })
: t("count", { count: total })}
{isFiltered && (
<span className="text-slate-400 dark:text-slate-500">
{` (of ${unfilteredTotal})`}
Expand All @@ -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")}
</Button>
</div>
)}
Expand Down
10 changes: 6 additions & 4 deletions components/auth/user-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -13,6 +14,7 @@ export function UserMenu() {
const { data: session, status } = useSession();
const [isOpen, setIsOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const t = useTranslations("auth");

// Handle federated logout (sign out from both NextAuth and Zitadel)
const handleSignOut = async () => {
Expand Down Expand Up @@ -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")}
</button>
);
}
Expand All @@ -61,7 +63,7 @@ export function UserMenu() {
{session.user?.image ? (
<Image
src={session.user.image}
alt={session.user.name || "User avatar"}
alt={session.user.name || t("userAvatar")}
width={32}
height={32}
className="rounded-full"
Expand Down Expand Up @@ -89,13 +91,13 @@ export function UserMenu() {
onClick={() => 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")}
</Link>
<button
onClick={handleSignOut}
className="w-full text-left px-4 py-2 text-sm text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700"
>
Sign out
{t("signOut")}
</button>
</div>
</div>
Expand Down
18 changes: 11 additions & 7 deletions components/layout/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<header className="sticky top-0 z-40 w-full border-b border-slate-200 dark:border-slate-700 bg-white/95 dark:bg-slate-900/95 backdrop-blur-sm supports-backdrop-filter:bg-white/60">
Expand Down Expand Up @@ -49,6 +52,7 @@ export function Header() {
</nav>
</div>
<div className="flex items-center gap-4">
<LanguageSwitcher />
<ThemeToggle />
<NotificationBell />
<UserMenu />
Expand Down
19 changes: 11 additions & 8 deletions components/ui/ConnectionStatus.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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,
},
};
Expand All @@ -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");
};
Expand All @@ -87,7 +90,7 @@ export function ConnectionStatus({
/>
{showLabel && (
<span className={cn("text-xs font-medium", config.color)}>
{config.label}
{label}
</span>
)}
</div>
Expand Down
39 changes: 39 additions & 0 deletions components/ui/LanguageSwitcher.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex items-center gap-1.5" title={t("label")}>
<Globe className="h-4 w-4 text-slate-500 dark:text-slate-400" aria-hidden="true" />
<select
value={currentLocale}
onChange={(e) => handleChange(e.target.value as Locale)}
className="bg-transparent text-sm text-slate-600 dark:text-slate-400 cursor-pointer focus:outline-hidden"
aria-label={t("label")}
>
{LOCALES.map((locale) => (
<option key={locale} value={locale}>
{t(locale)}
</option>
))}
</select>
</div>
);
}
2 changes: 1 addition & 1 deletion lib/i18n/request.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down
Loading
Loading