feat: add i18n support, avaiable languages are 'en' and 'ja'

This commit is contained in:
d-kimsuon
2025-10-19 19:51:16 +09:00
parent 170c6ec759
commit 4a4354fe63
56 changed files with 5151 additions and 279 deletions

View File

@@ -22,20 +22,28 @@ export const useConfig = () => {
});
return await response.json();
},
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: configQuery.queryKey,
});
},
});
return {
config: data?.config,
updateConfig: useCallback(
(config: UserConfig) => {
updateConfigMutation.mutate(config);
(
config: UserConfig,
callbacks?: {
onSuccess: () => void | Promise<void>;
},
) => {
updateConfigMutation.mutate(config, {
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: configQuery.queryKey,
});
await callbacks?.onSuccess?.();
},
});
},
[updateConfigMutation],
[updateConfigMutation, queryClient],
),
} as const;
};

View File

@@ -3,16 +3,18 @@ import { Geist, Geist_Mono } from "next/font/google";
import { ThemeProvider } from "next-themes";
import { Toaster } from "../components/ui/sonner";
import { QueryClientProviderWrapper } from "../lib/api/QueryClientProviderWrapper";
import { SSEProvider } from "../lib/sse/components/SSEProvider";
import { RootErrorBoundary } from "./components/RootErrorBoundary";
import "./globals.css";
import { honoClient } from "../lib/api/client";
import { QueryClientProviderWrapper } from "../lib/api/QueryClientProviderWrapper";
import { configQuery } from "../lib/api/queries";
import { LinguiServerProvider } from "../lib/i18n/LinguiServerProvider";
import { SSEProvider } from "../lib/sse/components/SSEProvider";
import { getUserConfigOnServerComponent } from "../server/lib/config/getUserConfigOnServerComponent";
import { RootErrorBoundary } from "./components/RootErrorBoundary";
import { SSEEventListeners } from "./components/SSEEventListeners";
import { SyncSessionProcess } from "./components/SyncSessionProcess";
import "./globals.css";
export const dynamic = "force-dynamic";
export const fetchCache = "force-no-store";
@@ -36,6 +38,7 @@ export default async function RootLayout({
}: Readonly<{
children: React.ReactNode;
}>) {
const userConfig = await getUserConfigOnServerComponent();
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
@@ -48,26 +51,28 @@ export default async function RootLayout({
.then((response) => response.json());
return (
<html lang="en" suppressHydrationWarning>
<html lang={userConfig.locale} suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<RootErrorBoundary>
<QueryClientProviderWrapper>
<SSEProvider>
<SSEEventListeners>
<SyncSessionProcess
initProcesses={initSessionProcesses.processes}
>
{children}
</SyncSessionProcess>
</SSEEventListeners>
</SSEProvider>
</QueryClientProviderWrapper>
</RootErrorBoundary>
<Toaster position="top-right" />
</ThemeProvider>
<LinguiServerProvider locale={userConfig.locale}>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<RootErrorBoundary>
<QueryClientProviderWrapper>
<SSEProvider>
<SSEEventListeners>
<SyncSessionProcess
initProcesses={initSessionProcesses.processes}
>
{children}
</SyncSessionProcess>
</SSEEventListeners>
</SSEProvider>
</QueryClientProviderWrapper>
</RootErrorBoundary>
<Toaster position="top-right" />
</ThemeProvider>
</LinguiServerProvider>
</body>
</html>
);

View File

@@ -1,3 +1,4 @@
import { Trans, useLingui } from "@lingui/react";
import {
AlertCircleIcon,
LoaderIcon,
@@ -18,7 +19,7 @@ export interface ChatInputProps {
isPending: boolean;
error?: Error | null;
placeholder: string;
buttonText: string;
buttonText: React.ReactNode;
minHeight?: string;
containerClassName?: string;
disabled?: boolean;
@@ -37,6 +38,7 @@ export const ChatInput: FC<ChatInputProps> = ({
disabled = false,
buttonSize = "lg",
}) => {
const { i18n } = useLingui();
const [message, setMessage] = useState("");
const [cursorPosition, setCursorPosition] = useState<{
relative: { top: number; left: number };
@@ -168,7 +170,10 @@ export const ChatInput: FC<ChatInputProps> = ({
<div className="flex items-center gap-2.5 px-4 py-3 text-sm text-red-600 dark:text-red-400 bg-gradient-to-r from-red-50 to-red-100/50 dark:from-red-950/30 dark:to-red-900/20 border border-red-200/50 dark:border-red-800/50 rounded-xl mb-4 animate-in fade-in slide-in-from-top-2 duration-300 shadow-sm">
<AlertCircleIcon className="w-4 h-4 shrink-0 mt-0.5" />
<span className="font-medium">
Failed to send message. Please try again.
<Trans
id="chat.error.send_failed"
message="Failed to send message. Please try again."
/>
</span>
</div>
)}
@@ -202,7 +207,7 @@ export const ChatInput: FC<ChatInputProps> = ({
className={`${minHeight} resize-none border-0 focus-visible:ring-0 focus-visible:ring-offset-0 bg-transparent px-5 py-4 text-lg transition-all duration-200 placeholder:text-muted-foreground/60`}
disabled={isPending || disabled}
maxLength={4000}
aria-label="Message input with completion support"
aria-label={i18n._("Message input with completion support")}
aria-describedby={helpId}
aria-expanded={message.startsWith("/") || message.includes("@")}
aria-haspopup="listbox"
@@ -223,7 +228,10 @@ export const ChatInput: FC<ChatInputProps> = ({
{(message.startsWith("/") || message.includes("@")) && (
<span className="text-xs text-blue-600 dark:text-blue-400 font-medium flex items-center gap-1">
<SparklesIcon className="w-3 h-3" />
Autocomplete active
<Trans
id="chat.autocomplete.active"
message="Autocomplete active"
/>
</span>
)}
</div>
@@ -237,7 +245,12 @@ export const ChatInput: FC<ChatInputProps> = ({
{isPending ? (
<>
<LoaderIcon className="w-4 h-4 animate-spin" />
<span>Processing...</span>
<span>
<Trans
id="chat.status.processing"
message="Processing..."
/>
</span>
</>
) : (
<>

View File

@@ -1,3 +1,4 @@
import { useLingui } from "@lingui/react";
import { useQuery } from "@tanstack/react-query";
import { CheckIcon, TerminalIcon } from "lucide-react";
import type React from "react";
@@ -33,6 +34,7 @@ export const CommandCompletion = forwardRef<
CommandCompletionRef,
CommandCompletionProps
>(({ projectId, inputValue, onCommandSelect, className }, ref) => {
const { i18n } = useLingui();
const [isOpen, setIsOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
@@ -188,7 +190,7 @@ export const CommandCompletion = forwardRef<
className="absolute z-50 w-full bg-popover border border-border rounded-lg shadow-xl overflow-hidden"
style={{ height: "15rem" }}
role="listbox"
aria-label="Available commands"
aria-label={i18n._("Available commands")}
>
<div className="h-full overflow-y-auto">
{filteredCommands.length > 0 && (

View File

@@ -1,3 +1,4 @@
import { useLingui } from "@lingui/react";
import { CheckIcon, FileIcon, FolderIcon } from "lucide-react";
import type React from "react";
import {
@@ -63,6 +64,7 @@ export const FileCompletion = forwardRef<
FileCompletionRef,
FileCompletionProps
>(({ projectId, inputValue, onFileSelect, className }, ref) => {
const { i18n } = useLingui();
const [isOpen, setIsOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
@@ -262,7 +264,7 @@ export const FileCompletion = forwardRef<
className="absolute z-50 w-full bg-popover border border-border rounded-lg shadow-xl overflow-hidden"
style={{ height: "15rem" }}
role="listbox"
aria-label="Available files and directories"
aria-label={i18n._("Available files and directories")}
>
<div className="h-full overflow-y-auto">
{filteredEntries.length > 0 && (

View File

@@ -1,3 +1,4 @@
import { Trans, useLingui } from "@lingui/react";
import type { FC } from "react";
import { useConfig } from "../../../../hooks/useConfig";
import { ChatInput, useCreateSessionProcessMutation } from "../chatForm";
@@ -6,6 +7,7 @@ export const NewChat: FC<{
projectId: string;
onSuccess?: () => void;
}> = ({ projectId, onSuccess }) => {
const { i18n } = useLingui();
const createSessionProcess = useCreateSessionProcessMutation(
projectId,
onSuccess,
@@ -19,12 +21,18 @@ export const NewChat: FC<{
const getPlaceholder = () => {
const behavior = config?.enterKeyBehavior;
if (behavior === "enter-send") {
return "Type your message here... (Start with / for commands, @ for files, Enter to send)";
return i18n._(
"Type your message here... (Start with / for commands, @ for files, Enter to send)",
);
}
if (behavior === "command-enter-send") {
return "Type your message here... (Start with / for commands, @ for files, Command+Enter to send)";
return i18n._(
"Type your message here... (Start with / for commands, @ for files, Command+Enter to send)",
);
}
return "Type your message here... (Start with / for commands, @ for files, Shift+Enter to send)";
return i18n._(
"Type your message here... (Start with / for commands, @ for files, Shift+Enter to send)",
);
};
return (
@@ -34,7 +42,7 @@ export const NewChat: FC<{
isPending={createSessionProcess.isPending}
error={createSessionProcess.error}
placeholder={getPlaceholder()}
buttonText="Start Chat"
buttonText={<Trans id="chat.button.start" message="Start Chat" />}
minHeight="min-h-[200px]"
containerClassName="p-6"
buttonSize="lg"

View File

@@ -1,3 +1,4 @@
import { Trans } from "@lingui/react";
import { MessageSquareIcon } from "lucide-react";
import { type FC, type ReactNode, useState } from "react";
import {
@@ -29,7 +30,7 @@ export const NewChatModal: FC<{
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<MessageSquareIcon className="w-5 h-5" />
Start New Chat
<Trans id="chat.modal.title" message="Start New Chat" />
</DialogTitle>
</DialogHeader>
<NewChat projectId={projectId} onSuccess={handleSuccess} />

View File

@@ -1,5 +1,6 @@
"use client";
import { Trans } from "@lingui/react";
import { AlertCircle, ArrowLeft, RefreshCw } from "lucide-react";
import { useRouter } from "next/navigation";
@@ -29,9 +30,17 @@ export default function ProjectErrorPage({
<div className="flex items-center gap-3">
<AlertCircle className="size-6 text-destructive" />
<div>
<CardTitle>Failed to load project</CardTitle>
<CardTitle>
<Trans
id="project.error.title"
message="Failed to load project"
/>
</CardTitle>
<CardDescription>
We encountered an error while loading this project
<Trans
id="project.error.description"
message="We encountered an error while loading this project"
/>
</CardDescription>
</div>
</div>
@@ -39,12 +48,15 @@ export default function ProjectErrorPage({
<CardContent className="space-y-4">
<Alert variant="destructive">
<AlertCircle />
<AlertTitle>Error Details</AlertTitle>
<AlertTitle>
<Trans id="project.error.details_title" message="Error Details" />
</AlertTitle>
<AlertDescription>
<code className="text-xs">{error.message}</code>
{error.digest && (
<div className="mt-2 text-xs text-muted-foreground">
Error ID: {error.digest}
<Trans id="project.error.error_id" message="Error ID:" />{" "}
{error.digest}
</div>
)}
</AlertDescription>
@@ -53,11 +65,14 @@ export default function ProjectErrorPage({
<div className="flex gap-2">
<Button onClick={reset} variant="default">
<RefreshCw />
Try Again
<Trans id="project.error.try_again" message="Try Again" />
</Button>
<Button onClick={() => router.push("/projects")} variant="outline">
<ArrowLeft />
Back to Projects
<Trans
id="project.error.back_to_projects"
message="Back to Projects"
/>
</Button>
</div>
</CardContent>

View File

@@ -1,6 +1,7 @@
import { QueryClient } from "@tanstack/react-query";
import { redirect } from "next/navigation";
import { latestSessionQuery } from "../../../../lib/api/queries";
import { initializeI18n } from "../../../../lib/i18n/initializeI18n";
interface LatestSessionPageProps {
params: Promise<{ projectId: string }>;
@@ -9,6 +10,8 @@ interface LatestSessionPageProps {
export default async function LatestSessionPage({
params,
}: LatestSessionPageProps) {
await initializeI18n();
const { projectId } = await params;
const queryClient = new QueryClient();

View File

@@ -1,3 +1,4 @@
import { Trans } from "@lingui/react";
import { FolderSearch, Home } from "lucide-react";
import Link from "next/link";
@@ -9,8 +10,11 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { initializeI18n } from "../../../lib/i18n/initializeI18n";
export default async function ProjectNotFoundPage() {
await initializeI18n();
export default function ProjectNotFoundPage() {
return (
<div className="flex min-h-screen items-center justify-center p-4">
<Card className="w-full max-w-2xl">
@@ -18,10 +22,17 @@ export default function ProjectNotFoundPage() {
<div className="flex items-center gap-3">
<FolderSearch className="size-6 text-muted-foreground" />
<div>
<CardTitle>Project Not Found</CardTitle>
<CardTitle>
<Trans
id="project.not_found.title"
message="Project Not Found"
/>
</CardTitle>
<CardDescription>
The project you are looking for does not exist or has been
removed
<Trans
id="project.not_found.description"
message="The project you are looking for does not exist or has been removed"
/>
</CardDescription>
</div>
</div>
@@ -31,7 +42,10 @@ export default function ProjectNotFoundPage() {
<Button asChild variant="default">
<Link href="/projects">
<Home />
Back to Projects
<Trans
id="project.not_found.back_to_projects"
message="Back to Projects"
/>
</Link>
</Button>
</div>

View File

@@ -1,10 +0,0 @@
import { redirect } from "next/navigation";
interface ProjectPageProps {
params: Promise<{ projectId: string }>;
}
export default async function ProjectPage({ params }: ProjectPageProps) {
const { projectId } = await params;
redirect(`/projects/${projectId}/latest`);
}

View File

@@ -1,5 +1,6 @@
"use client";
import { Trans } from "@lingui/react";
import { useMutation } from "@tanstack/react-query";
import {
GitCompareIcon,
@@ -145,7 +146,10 @@ export const SessionPageContent: FC<{
<LoaderIcon className="w-3 h-3 sm:w-4 sm:h-4 animate-spin text-primary" />
<div className="flex-1">
<p className="text-xs sm:text-sm font-medium">
Conversation is in progress...
<Trans
id="session.conversation.in.progress"
message="Conversation is in progress..."
/>
</p>
<div className="w-full bg-primary/10 rounded-full h-1 mt-1 overflow-hidden">
<div
@@ -167,7 +171,9 @@ export const SessionPageContent: FC<{
) : (
<XIcon className="w-3 h-3 sm:w-4 sm:h-4" />
)}
<span className="hidden sm:inline">Abort</span>
<span className="hidden sm:inline">
<Trans id="session.conversation.abort" message="Abort" />
</span>
</Button>
</div>
)}
@@ -177,7 +183,10 @@ export const SessionPageContent: FC<{
<PauseIcon className="w-3 h-3 sm:w-4 sm:h-4 text-orange-600 dark:text-orange-400 animate-pulse" />
<div className="flex-1">
<p className="text-xs sm:text-sm font-medium text-orange-900 dark:text-orange-200">
Conversation is paused...
<Trans
id="session.conversation.paused"
message="Conversation is paused..."
/>
</p>
</div>
<Button
@@ -194,7 +203,9 @@ export const SessionPageContent: FC<{
) : (
<XIcon className="w-3 h-3 sm:w-4 sm:h-4" />
)}
<span className="hidden sm:inline">Abort</span>
<span className="hidden sm:inline">
<Trans id="session.conversation.abort" message="Abort" />
</span>
</Button>
</div>
)}
@@ -220,7 +231,10 @@ export const SessionPageContent: FC<{
<div className="absolute inset-0 rounded-full bg-primary/20 animate-ping" />
</div>
<p className="text-sm text-muted-foreground font-medium animate-pulse">
Claude Code is processing...
<Trans
id="session.processing"
message="Claude Code is processing..."
/>
</p>
</div>
</div>

View File

@@ -1,5 +1,6 @@
"use client";
import { Trans } from "@lingui/react";
import { ChevronDown, Lightbulb, Settings } from "lucide-react";
import Image from "next/image";
import { useTheme } from "next-themes";
@@ -49,7 +50,7 @@ export const AssistantConversationContent: FC<{
<div className="flex items-center gap-2">
<Lightbulb className="h-4 w-4 text-muted-foreground group-hover:text-yellow-600 transition-colors" />
<CardTitle className="text-sm font-medium group-hover:text-foreground transition-colors">
Thinking
<Trans id="assistant.thinking" message="Thinking" />
</CardTitle>
<ChevronDown className="h-4 w-4 text-muted-foreground transition-transform duration-200 group-data-[state=open]:rotate-180" />
</div>
@@ -75,7 +76,9 @@ export const AssistantConversationContent: FC<{
<CardHeader className="py-0 px-4">
<div className="flex items-center gap-2">
<Settings className="h-4 w-4 text-blue-600 dark:text-blue-400" />
<CardTitle className="text-sm font-medium">Tool Use</CardTitle>
<CardTitle className="text-sm font-medium">
<Trans id="assistant.tool_use" message="Tool Use" />
</CardTitle>
<Badge
variant="outline"
className="border-blue-300 text-blue-700 dark:border-blue-700 dark:text-blue-300"
@@ -92,7 +95,10 @@ export const AssistantConversationContent: FC<{
<CollapsibleTrigger asChild>
<div className="flex items-center justify-between cursor-pointer hover:bg-muted/50 rounded p-2 -mx-2 transition-all duration-200 group">
<h4 className="text-xs font-medium text-muted-foreground group-hover:text-foreground">
Input Parameters
<Trans
id="assistant.tool.input_parameters"
message="Input Parameters"
/>
</h4>
<ChevronDown className="h-3 w-3 text-muted-foreground transition-transform duration-200 group-data-[state=open]:rotate-180" />
</div>
@@ -113,7 +119,7 @@ export const AssistantConversationContent: FC<{
<CollapsibleTrigger asChild>
<div className="flex items-center justify-between cursor-pointer hover:bg-muted/50 rounded p-2 -mx-2 transition-all duration-200 group">
<h4 className="text-xs font-medium text-muted-foreground group-hover:text-foreground">
Tool Result
<Trans id="assistant.tool.result" message="Tool Result" />
</h4>
<ChevronDown className="h-3 w-3 text-muted-foreground transition-transform duration-200 group-data-[state=open]:rotate-180" />
</div>

View File

@@ -1,5 +1,6 @@
"use client";
import { Trans } from "@lingui/react";
import { AlertTriangle, ChevronDown, ExternalLink } from "lucide-react";
import { type FC, useMemo } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
@@ -49,7 +50,10 @@ const SchemaErrorDisplay: FC<{ errorLine: string }> = ({ errorLine }) => {
<div className="flex items-center gap-2">
<AlertTriangle className="h-3 w-3 text-red-500" />
<span className="text-xs font-medium text-red-600">
Schema Error
<Trans
id="conversation.error.schema"
message="Schema Error"
/>
</span>
</div>
<ChevronDown className="h-3 w-3 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
@@ -64,25 +68,36 @@ const SchemaErrorDisplay: FC<{ errorLine: string }> = ({ errorLine }) => {
>
<AlertTriangle className="h-4 w-4" />
<AlertTitle className="text-red-800">
Schema Validation Error
<Trans
id="conversation.error.schema_validation"
message="Schema Validation Error"
/>
</AlertTitle>
<AlertDescription className="text-red-700">
This conversation entry failed to parse correctly. This
might indicate a format change or parsing issue.{" "}
<Trans
id="conversation.error.schema_validation.description"
message="This conversation entry failed to parse correctly. This might indicate a format change or parsing issue."
/>{" "}
<a
href="https://github.com/d-kimuson/claude-code-viewer/issues"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-red-600 hover:text-red-800 underline underline-offset-4"
>
Report this issue
<Trans
id="conversation.error.report_issue"
message="Report this issue"
/>
<ExternalLink className="h-3 w-3" />
</a>
</AlertDescription>
</Alert>
<div className="bg-gray-50 border rounded px-3 py-2">
<h5 className="text-xs font-medium text-gray-700 mb-2">
Raw Content:
<Trans
id="conversation.error.raw_content"
message="Raw Content:"
/>
</h5>
<pre className="text-xs overflow-x-auto whitespace-pre-wrap break-all font-mono text-gray-800">
{errorLine}

View File

@@ -6,6 +6,8 @@ import {
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import type { FileHistorySnapshotEntry } from "@/lib/conversation-schema/entry/FileHIstorySnapshotEntrySchema";
import { formatLocaleDate } from "../../../../../../../lib/date/formatLocaleDate";
import { useConfig } from "../../../../../../hooks/useConfig";
export const FileHistorySnapshotConversationContent: FC<{
conversation: FileHistorySnapshotEntry;
@@ -13,6 +15,7 @@ export const FileHistorySnapshotConversationContent: FC<{
const fileCount = Object.keys(
conversation.snapshot.trackedFileBackups,
).length;
const { config } = useConfig();
return (
<Collapsible>
@@ -30,7 +33,9 @@ export const FileHistorySnapshotConversationContent: FC<{
<div className="text-xs">
<span className="text-muted-foreground">Timestamp: </span>
<span>
{new Date(conversation.snapshot.timestamp).toLocaleString()}
{formatLocaleDate(conversation.snapshot.timestamp, {
locale: config.locale,
})}
</span>
</div>
<div className="text-xs">

View File

@@ -1,3 +1,4 @@
import { Trans } from "@lingui/react";
import { AlertCircle, Image as ImageIcon } from "lucide-react";
import Image from "next/image";
import type { FC } from "react";
@@ -34,7 +35,9 @@ export const UserConversationContent: FC<{
<CardHeader>
<div className="flex items-center gap-2">
<ImageIcon className="h-4 w-4 text-purple-600 dark:text-purple-400" />
<CardTitle className="text-sm font-medium">Image</CardTitle>
<CardTitle className="text-sm font-medium">
<Trans id="user.content.image" message="Image" />
</CardTitle>
<Badge
variant="outline"
className="border-purple-300 text-purple-700 dark:border-purple-700 dark:text-purple-300"
@@ -43,7 +46,10 @@ export const UserConversationContent: FC<{
</Badge>
</div>
<CardDescription className="text-xs">
User uploaded image content
<Trans
id="user.content.image.description"
message="User uploaded image content"
/>
</CardDescription>
</CardHeader>
<CardContent>
@@ -68,12 +74,20 @@ export const UserConversationContent: FC<{
<div className="flex items-center gap-2">
<AlertCircle className="h-4 w-4 text-red-600 dark:text-red-400" />
<CardTitle className="text-sm font-medium">
Unsupported Media
<Trans
id="user.content.unsupported_media"
message="Unsupported Media"
/>
</CardTitle>
<Badge variant="destructive">Error</Badge>
<Badge variant="destructive">
<Trans id="common.error" message="Error" />
</Badge>
</div>
<CardDescription className="text-xs">
Media type not supported for display
<Trans
id="user.content.unsupported_media.description"
message="Media type not supported for display"
/>
</CardDescription>
</CardHeader>
</Card>

View File

@@ -1,5 +1,6 @@
"use client";
import { Trans, useLingui } from "@lingui/react";
import {
ChevronDown,
ChevronUp,
@@ -52,9 +53,12 @@ const DiffSummaryComponent: FC<DiffSummaryProps> = ({ summary, className }) => {
<FileText className="h-4 w-4 text-gray-600 dark:text-gray-400" />
<span className="font-medium">
<span className="hidden sm:inline">
{summary.filesChanged} files changed
{summary.filesChanged}{" "}
<Trans id="diff.files.changed" message="files changed" />
</span>
<span className="sm:hidden">
{summary.filesChanged} <Trans id="diff.files" message="files" />
</span>
<span className="sm:hidden">{summary.filesChanged} files</span>
</span>
</div>
<div className="flex items-center gap-3">
@@ -111,7 +115,7 @@ const RefSelector: FC<RefSelectorProps> = ({
</label>
<Select value={value} onValueChange={onValueChange}>
<SelectTrigger className="w-full sm:w-80">
<SelectValue placeholder={`Select ${label.toLowerCase()}`} />
<SelectValue />
</SelectTrigger>
<SelectContent id={id}>
{refs.map((ref) => (
@@ -140,6 +144,7 @@ export const DiffModal: FC<DiffModalProps> = ({
defaultCompareFrom = "HEAD",
defaultCompareTo = "working",
}) => {
const { i18n } = useLingui();
const commitMessageId = useId();
const [compareFrom, setCompareFrom] = useState(defaultCompareFrom);
const [compareTo, setCompareTo] = useState(defaultCompareTo);
@@ -177,7 +182,7 @@ export const DiffModal: FC<DiffModalProps> = ({
{
name: "working" as const,
type: "working" as const,
displayName: "Uncommitted changes",
displayName: i18n._("Uncommitted changes"),
},
{
name: "HEAD" as const,
@@ -295,7 +300,7 @@ export const DiffModal: FC<DiffModalProps> = ({
}
} catch (_error) {
console.error("[DiffModal.handleCommit] Error:", _error);
toast.error("Failed to commit");
toast.error(i18n._("Failed to commit"));
}
};
@@ -313,7 +318,7 @@ export const DiffModal: FC<DiffModalProps> = ({
}
} catch (_error) {
console.error("[DiffModal.handlePush] Error:", _error);
toast.error("Failed to push");
toast.error(i18n._("Failed to push"));
}
};
@@ -348,7 +353,7 @@ export const DiffModal: FC<DiffModalProps> = ({
`Committed (${result.commitSha?.slice(0, 7)}), but push failed: ${result.error}`,
{
action: {
label: "Retry Push",
label: i18n._("Retry Push"),
onClick: handlePush,
},
},
@@ -361,7 +366,7 @@ export const DiffModal: FC<DiffModalProps> = ({
}
} catch (_error) {
console.error("[DiffModal.handleCommitAndPush] Error:", _error);
toast.error("Failed to commit and push");
toast.error(i18n._("Failed to commit and push"));
}
};
@@ -380,13 +385,13 @@ export const DiffModal: FC<DiffModalProps> = ({
<div className="flex flex-col sm:flex-row gap-2 sm:items-end">
<div className="flex flex-col sm:flex-row gap-2 flex-1">
<RefSelector
label="Compare from"
label={i18n._("Compare from")}
value={compareFrom}
onValueChange={setCompareFrom}
refs={gitRefs.filter((ref) => ref.name !== "working")}
/>
<RefSelector
label="Compare to"
label={i18n._("Compare to")}
value={compareTo}
onValueChange={setCompareTo}
refs={gitRefs}
@@ -405,7 +410,7 @@ export const DiffModal: FC<DiffModalProps> = ({
{isDiffLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Loading...
<Trans id="common.loading" message="Loading..." />
</>
) : (
<RefreshCcwIcon className="w-4 h-4" />
@@ -455,7 +460,7 @@ export const DiffModal: FC<DiffModalProps> = ({
className="w-full flex items-center justify-between p-2 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors rounded-t-lg"
>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
Commit Changes
<Trans id="diff.commit.changes" message="Commit Changes" />
</span>
{isCommitSectionExpanded ? (
<ChevronUp className="h-4 w-4 text-gray-600 dark:text-gray-400" />
@@ -476,7 +481,7 @@ export const DiffModal: FC<DiffModalProps> = ({
onClick={handleSelectAll}
disabled={commitMutation.isPending}
>
Select All
<Trans id="diff.select.all" message="Select All" />
</Button>
<Button
size="sm"
@@ -484,7 +489,10 @@ export const DiffModal: FC<DiffModalProps> = ({
onClick={handleDeselectAll}
disabled={commitMutation.isPending}
>
Deselect All
<Trans
id="diff.deselect.all"
message="Deselect All"
/>
</Button>
<span className="text-sm text-gray-600 dark:text-gray-400">
{selectedCount} / {diffData.data.files.length} files
@@ -524,7 +532,10 @@ export const DiffModal: FC<DiffModalProps> = ({
htmlFor={commitMessageId}
className="text-sm font-medium text-gray-700 dark:text-gray-300"
>
Commit message
<Trans
id="diff.commit.message"
message="Commit message"
/>
</label>
<Textarea
id={commitMessageId}
@@ -547,10 +558,13 @@ export const DiffModal: FC<DiffModalProps> = ({
{commitMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Committing...
<Trans
id="diff.committing"
message="Committing..."
/>
</>
) : (
"Commit"
<Trans id="diff.commit" message="Commit" />
)}
</Button>
<Button
@@ -562,10 +576,10 @@ export const DiffModal: FC<DiffModalProps> = ({
{pushMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Pushing...
<Trans id="diff.pushing" message="Pushing..." />
</>
) : (
"Push"
<Trans id="diff.push" message="Push" />
)}
</Button>
<Button
@@ -579,19 +593,33 @@ export const DiffModal: FC<DiffModalProps> = ({
{commitAndPushMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Committing & Pushing...
<Trans
id="diff.committing.pushing"
message="Committing & Pushing..."
/>
</>
) : (
"Commit & Push"
<Trans
id="diff.commit.push"
message="Commit & Push"
/>
)}
</Button>
{isCommitDisabled &&
!commitMutation.isPending &&
!commitAndPushMutation.isPending && (
<span className="text-xs text-gray-500 dark:text-gray-400">
{selectedCount === 0
? "Select at least one file"
: "Enter a commit message"}
{selectedCount === 0 ? (
<Trans
id="diff.select.file"
message="Select at least one file"
/>
) : (
<Trans
id="diff.enter.message"
message="Enter a commit message"
/>
)}
</span>
)}
</div>
@@ -626,7 +654,7 @@ export const DiffModal: FC<DiffModalProps> = ({
<div className="text-center space-y-2">
<Loader2 className="w-8 h-8 animate-spin mx-auto" />
<p className="text-sm text-gray-500 dark:text-gray-400">
Loading diff...
<Trans id="diff.loading" message="Loading diff..." />
</p>
</div>
</div>

View File

@@ -1,3 +1,4 @@
import { Trans, useLingui } from "@lingui/react";
import type { FC } from "react";
import { useConfig } from "../../../../../../hooks/useConfig";
import {
@@ -10,6 +11,7 @@ export const ContinueChat: FC<{
sessionId: string;
sessionProcessId: string;
}> = ({ projectId, sessionId, sessionProcessId }) => {
const { i18n } = useLingui();
const continueSessionProcess = useContinueSessionProcessMutation(
projectId,
sessionId,
@@ -23,14 +25,22 @@ export const ContinueChat: FC<{
const getPlaceholder = () => {
const behavior = config?.enterKeyBehavior;
if (behavior === "enter-send") {
return "Type your message... (Start with / for commands, @ for files, Enter to send)";
return i18n._(
"Type your message... (Start with / for commands, @ for files, Enter to send)",
);
}
if (behavior === "command-enter-send") {
return "Type your message... (Start with / for commands, @ for files, Command+Enter to send)";
return i18n._(
"Type your message... (Start with / for commands, @ for files, Command+Enter to send)",
);
}
return "Type your message... (Start with / for commands, @ for files, Shift+Enter to send)";
return i18n._(
"Type your message... (Start with / for commands, @ for files, Shift+Enter to send)",
);
};
const buttonText = <Trans id="chat.send" message="Send" />;
return (
<div className="relative mt-8 mb-6">
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-border to-transparent h-px top-0" />
@@ -41,7 +51,7 @@ export const ContinueChat: FC<{
isPending={continueSessionProcess.isPending}
error={continueSessionProcess.error}
placeholder={getPlaceholder()}
buttonText={"Send"}
buttonText={buttonText}
minHeight="min-h-[120px]"
containerClassName=""
buttonSize="lg"

View File

@@ -1,3 +1,4 @@
import { Trans, useLingui } from "@lingui/react";
import type { FC } from "react";
import { useConfig } from "../../../../../../hooks/useConfig";
import {
@@ -9,6 +10,7 @@ export const ResumeChat: FC<{
projectId: string;
sessionId: string;
}> = ({ projectId, sessionId }) => {
const { i18n } = useLingui();
const createSessionProcess = useCreateSessionProcessMutation(projectId);
const { config } = useConfig();
@@ -22,14 +24,22 @@ export const ResumeChat: FC<{
const getPlaceholder = () => {
const behavior = config?.enterKeyBehavior;
if (behavior === "enter-send") {
return "Type your message... (Start with / for commands, @ for files, Enter to send)";
return i18n._(
"Type your message... (Start with / for commands, @ for files, Enter to send)",
);
}
if (behavior === "command-enter-send") {
return "Type your message... (Start with / for commands, @ for files, Command+Enter to send)";
return i18n._(
"Type your message... (Start with / for commands, @ for files, Command+Enter to send)",
);
}
return "Type your message... (Start with / for commands, @ for files, Shift+Enter to send)";
return i18n._(
"Type your message... (Start with / for commands, @ for files, Shift+Enter to send)",
);
};
const buttonText = <Trans id="chat.resume" message="Resume" />;
return (
<div className="relative mt-8 mb-6">
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-border to-transparent h-px top-0" />
@@ -40,7 +50,7 @@ export const ResumeChat: FC<{
isPending={createSessionProcess.isPending}
error={createSessionProcess.error}
placeholder={getPlaceholder()}
buttonText={"Resume"}
buttonText={buttonText}
minHeight="min-h-[120px]"
containerClassName=""
buttonSize="lg"

View File

@@ -1,5 +1,6 @@
"use client";
import { Trans, useLingui } from "@lingui/react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { RefreshCwIcon } from "lucide-react";
import type { FC } from "react";
@@ -8,6 +9,7 @@ import { mcpListQuery } from "../../../../../../../lib/api/queries";
export const McpTab: FC<{ projectId: string }> = ({ projectId }) => {
const queryClient = useQueryClient();
const { i18n } = useLingui();
const {
data: mcpData,
@@ -29,7 +31,7 @@ export const McpTab: FC<{ projectId: string }> = ({ projectId }) => {
<div className="p-3 border-b border-sidebar-border">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-sidebar-foreground">
MCP Servers
<Trans id="mcp.title" message="MCP Servers" />
</h2>
<Button
onClick={handleReload}
@@ -37,7 +39,7 @@ export const McpTab: FC<{ projectId: string }> = ({ projectId }) => {
size="sm"
className="h-7 w-7 p-0"
disabled={isLoading}
title="Reload MCP servers"
title={i18n._("Reload MCP servers")}
>
<RefreshCwIcon
className={`w-3 h-3 ${isLoading ? "animate-spin" : ""}`}
@@ -49,19 +51,25 @@ export const McpTab: FC<{ projectId: string }> = ({ projectId }) => {
<div className="flex-1 overflow-auto p-3">
{isLoading && (
<div className="flex items-center justify-center h-32">
<div className="text-sm text-muted-foreground">Loading...</div>
<div className="text-sm text-muted-foreground">
<Trans id="common.loading" message="Loading..." />
</div>
</div>
)}
{error && (
<div className="text-sm text-red-500">
Failed to load MCP servers: {(error as Error).message}
<Trans
id="mcp.error.load_failed"
message="Failed to load MCP servers: {error}"
values={{ error: (error as Error).message }}
/>
</div>
)}
{mcpData && mcpData.servers.length === 0 && (
<div className="text-sm text-muted-foreground text-center py-8">
No MCP servers found
<Trans id="mcp.no.servers" message="No MCP servers found" />
</div>
)}

View File

@@ -1,5 +1,6 @@
"use client";
import { Trans, useLingui } from "@lingui/react";
import {
ArrowLeftIcon,
MessageSquareIcon,
@@ -37,6 +38,7 @@ export const MobileSidebar: FC<MobileSidebarProps> = ({
isOpen,
onClose,
}) => {
const { i18n } = useLingui();
const {
data: projectData,
fetchNextPage,
@@ -110,7 +112,10 @@ export const MobileSidebar: FC<MobileSidebarProps> = ({
fallback={
<div className="flex-1 flex items-center justify-center p-4">
<div className="text-sm text-sidebar-foreground/70">
Loading settings...
<Trans
id="settings.loading"
message="Loading settings..."
/>
</div>
</div>
}
@@ -118,14 +123,20 @@ export const MobileSidebar: FC<MobileSidebarProps> = ({
<div className="flex-1 overflow-y-auto p-4 space-y-6">
<div className="space-y-4">
<h3 className="font-medium text-sm text-sidebar-foreground">
Session Display
<Trans
id="settings.session.display"
message="Session Display"
/>
</h3>
<SettingsControls openingProjectId={projectId} />
</div>
<div className="space-y-4">
<h3 className="font-medium text-sm text-sidebar-foreground">
Notifications
<Trans
id="settings.notifications"
message="Notifications"
/>
</h3>
<NotificationSettings />
</div>
@@ -159,7 +170,7 @@ export const MobileSidebar: FC<MobileSidebarProps> = ({
onClose();
}
}}
aria-label="Close sidebar"
aria-label={i18n._("Close sidebar")}
/>
{/* Sidebar */}
@@ -182,7 +193,12 @@ export const MobileSidebar: FC<MobileSidebarProps> = ({
</Link>
</TooltipTrigger>
<TooltipContent side="right">
<p></p>
<p>
<Trans
id="sidebar.back.to.projects"
message="Back to projects"
/>
</p>
</TooltipContent>
</Tooltip>
@@ -205,7 +221,12 @@ export const MobileSidebar: FC<MobileSidebarProps> = ({
</button>
</TooltipTrigger>
<TooltipContent side="right">
<p></p>
<p>
<Trans
id="sidebar.show.session.list"
message="Show session list"
/>
</p>
</TooltipContent>
</Tooltip>
@@ -227,7 +248,12 @@ export const MobileSidebar: FC<MobileSidebarProps> = ({
</button>
</TooltipTrigger>
<TooltipContent side="right">
<p>MCPサーバー設定を表示</p>
<p>
<Trans
id="sidebar.show.mcp.settings"
message="Show MCP server settings"
/>
</p>
</TooltipContent>
</Tooltip>
@@ -249,7 +275,10 @@ export const MobileSidebar: FC<MobileSidebarProps> = ({
</button>
</TooltipTrigger>
<TooltipContent side="right">
<p></p>
<Trans
id="settings.tab.title"
message="Settings for display and notifications"
/>
</TooltipContent>
</Tooltip>
</div>

View File

@@ -1,5 +1,6 @@
"use client";
import { Trans } from "@lingui/react";
import { ArrowLeftIcon, MessageSquareIcon, PlugIcon } from "lucide-react";
import Link from "next/link";
import { type FC, useMemo } from "react";
@@ -43,7 +44,7 @@ export const SessionSidebar: FC<{
{
id: "sessions",
icon: MessageSquareIcon,
title: "セッション一覧を表示",
title: "Show session list",
content: (
<SessionsTab
sessions={sessions.map((session) => ({
@@ -61,7 +62,7 @@ export const SessionSidebar: FC<{
{
id: "mcp",
icon: PlugIcon,
title: "MCPサーバー設定を表示",
title: "Show MCP server settings",
content: <McpTab projectId={projectId} />,
},
],
@@ -95,7 +96,12 @@ export const SessionSidebar: FC<{
</Link>
</TooltipTrigger>
<TooltipContent side="right">
<p></p>
<p>
<Trans
id="sidebar.back.to.projects"
message="Back to projects"
/>
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>

View File

@@ -1,5 +1,6 @@
"use client";
import { Trans } from "@lingui/react";
import { useAtomValue } from "jotai";
import { MessageSquareIcon, PlusIcon } from "lucide-react";
import Link from "next/link";
@@ -7,7 +8,9 @@ import type { FC } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { formatLocaleDate } from "../../../../../../../lib/date/formatLocaleDate";
import type { Session } from "../../../../../../../server/core/types";
import { useConfig } from "../../../../../../hooks/useConfig";
import { NewChatModal } from "../../../../components/newChat/NewChatModal";
import { firstUserMessageToTitle } from "../../../../services/firstCommandToTitle";
import { sessionProcessesAtom } from "../../store/sessionProcessesAtom";
@@ -30,6 +33,7 @@ export const SessionsTab: FC<{
isMobile = false,
}) => {
const sessionProcesses = useAtomValue(sessionProcessesAtom);
const { config } = useConfig();
// Sort sessions: Running > Paused > Others, then by lastModifiedAt (newest first)
const sortedSessions = [...sessions].sort((a, b) => {
@@ -68,7 +72,9 @@ export const SessionsTab: FC<{
<div className="h-full flex flex-col">
<div className="border-b border-sidebar-border p-4">
<div className="flex items-center justify-between mb-2">
<h2 className="font-semibold text-lg">Sessions</h2>
<h2 className="font-semibold text-lg">
<Trans id="sessions.title" message="Sessions" />
</h2>
<NewChatModal
projectId={projectId}
trigger={
@@ -83,13 +89,13 @@ export const SessionsTab: FC<{
}
>
<PlusIcon className="w-3.5 h-3.5" />
New
<Trans id="sessions.new" message="New" />
</Button>
}
/>
</div>
<p className="text-xs text-sidebar-foreground/70">
{sessions.length} total
{sessions.length} <Trans id="sessions.total" message="total" />
</p>
</div>
@@ -131,7 +137,11 @@ export const SessionsTab: FC<{
isPaused && "bg-yellow-500 text-white",
)}
>
{isRunning ? "Running" : "Paused"}
{isRunning ? (
<Trans id="session.status.running" message="Running" />
) : (
<Trans id="session.status.paused" message="Paused" />
)}
</Badge>
)}
</div>
@@ -142,13 +152,10 @@ export const SessionsTab: FC<{
</div>
{session.lastModifiedAt && (
<span className="text-xs text-sidebar-foreground/60">
{new Date(session.lastModifiedAt).toLocaleDateString(
"en-US",
{
month: "short",
day: "numeric",
},
)}
{formatLocaleDate(session.lastModifiedAt, {
locale: config.locale,
target: "time",
})}
</span>
)}
</div>
@@ -167,7 +174,11 @@ export const SessionsTab: FC<{
size="sm"
className="w-full"
>
{isFetchingNextPage ? "Loading..." : "Load More"}
{isFetchingNextPage ? (
<Trans id="common.loading" message="Loading..." />
) : (
<Trans id="sessions.load.more" message="Load More" />
)}
</Button>
</div>
)}

View File

@@ -58,7 +58,7 @@ export default function SessionErrorPage({
Try Again
</Button>
<Button
onClick={() => router.push(`/projects/${projectId}`)}
onClick={() => router.push(`/projects/${projectId}/latest`)}
variant="outline"
>
<ArrowLeft />

View File

@@ -4,6 +4,7 @@ import {
projectDetailQuery,
sessionDetailQuery,
} from "../../../../../lib/api/queries";
import { initializeI18n } from "../../../../../lib/i18n/initializeI18n";
import { SessionPageContent } from "./components/SessionPageContent";
type PageParams = {
@@ -41,6 +42,7 @@ interface SessionPageProps {
export default async function SessionPage({ params }: SessionPageProps) {
const { projectId, sessionId } = await params;
await initializeI18n();
return <SessionPageContent projectId={projectId} sessionId={sessionId} />;
}

View File

@@ -1,5 +1,6 @@
"use client";
import { Trans } from "@lingui/react";
import { useMutation } from "@tanstack/react-query";
import { Loader2, Plus } from "lucide-react";
import { useRouter } from "next/navigation";
@@ -54,17 +55,22 @@ export const CreateProjectDialog: FC = () => {
<DialogTrigger asChild>
<Button data-testid="new-project-button">
<Plus className="w-4 h-4 mr-2" />
New Project
<Trans id="project.new" message="New Project" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl" data-testid="new-project-modal">
<DialogHeader>
<DialogTitle>Create New Project</DialogTitle>
<DialogTitle>
<Trans id="project.create.title" message="Create New Project" />
</DialogTitle>
<DialogDescription>
Select a directory to initialize as a Claude Code project. This will
run{" "}
<code className="text-sm bg-muted px-1 py-0.5 rounded">/init</code>{" "}
in the selected directory.
<Trans
id="project.create.description"
message="Select a directory to initialize as a Claude Code project. This will run <0>/init</0> in the selected directory."
components={{
0: <code className="text-sm bg-muted px-1 py-0.5 rounded" />,
}}
/>
</DialogDescription>
</DialogHeader>
<div className="py-4">
@@ -74,7 +80,12 @@ export const CreateProjectDialog: FC = () => {
/>
{selectedPath ? (
<div className="mt-4 p-3 bg-muted rounded-md">
<p className="text-sm font-medium mb-1">Selected directory:</p>
<p className="text-sm font-medium mb-1">
<Trans
id="project.create.selected_directory"
message="Selected directory:"
/>
</p>
<p className="text-sm text-muted-foreground font-mono">
{selectedPath}
</p>
@@ -83,7 +94,7 @@ export const CreateProjectDialog: FC = () => {
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
Cancel
<Trans id="common.action.cancel" message="Cancel" />
</Button>
<Button
onClick={async () => await createProjectMutation.mutateAsync()}
@@ -92,10 +103,16 @@ export const CreateProjectDialog: FC = () => {
{createProjectMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Creating...
<Trans
id="project.create.action.creating"
message="Creating..."
/>
</>
) : (
"Create Project"
<Trans
id="project.create.action.create"
message="Create Project"
/>
)}
</Button>
</DialogFooter>

View File

@@ -1,5 +1,6 @@
"use client";
import { Trans } from "@lingui/react";
import { useQuery } from "@tanstack/react-query";
import { ChevronRight, Folder } from "lucide-react";
import { type FC, useState } from "react";
@@ -34,16 +35,17 @@ export const DirectoryPicker: FC<DirectoryPickerProps> = ({ onPathChange }) => {
<div className="border rounded-md">
<div className="p-3 border-b bg-muted/50 flex items-center justify-between">
<p className="text-sm font-medium">
Current: <span className="font-mono">{data?.currentPath || "~"}</span>
<Trans id="directory_picker.current" message="Current:" />{" "}
<span className="font-mono">{data?.currentPath || "~"}</span>
</p>
<Button size="sm" onClick={handleSelect}>
Select This Directory
<Trans id="directory_picker.select" message="Select This Directory" />
</Button>
</div>
<div className="max-h-96 overflow-auto">
{isLoading ? (
<div className="p-8 text-center text-sm text-muted-foreground">
Loading...
<Trans id="directory_picker.loading" message="Loading..." />
</div>
) : data?.entries && data.entries.length > 0 ? (
<div className="divide-y">
@@ -67,7 +69,10 @@ export const DirectoryPicker: FC<DirectoryPickerProps> = ({ onPathChange }) => {
</div>
) : (
<div className="p-8 text-center text-sm text-muted-foreground">
No directories found
<Trans
id="directory_picker.no_directories"
message="No directories found"
/>
</div>
)}
</div>

View File

@@ -1,5 +1,6 @@
"use client";
import { Trans } from "@lingui/react";
import { FolderIcon } from "lucide-react";
import Link from "next/link";
import type { FC } from "react";
@@ -11,21 +12,31 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { formatLocaleDate } from "../../../lib/date/formatLocaleDate";
import { useConfig } from "../../hooks/useConfig";
import { useProjects } from "../hooks/useProjects";
export const ProjectList: FC = () => {
const {
data: { projects },
} = useProjects();
const { config } = useConfig();
if (projects.length === 0) {
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<FolderIcon className="w-12 h-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium mb-2">No projects found</h3>
<h3 className="text-lg font-medium mb-2">
<Trans
id="project_list.no_projects.title"
message="No projects found"
/>
</h3>
<p className="text-muted-foreground text-center max-w-md">
No Claude Code projects found in your ~/.claude/projects directory.
Start a conversation with Claude Code to create your first project.
<Trans
id="project_list.no_projects.description"
message="No Claude Code projects found in your ~/.claude/projects directory. Start a conversation with Claude Code to create your first project."
/>
</p>
</CardContent>
</Card>;
@@ -48,19 +59,26 @@ export const ProjectList: FC = () => {
</CardHeader>
<CardContent className="space-y-2">
<p className="text-sm text-muted-foreground">
Last modified:{" "}
<Trans id="project_list.last_modified" message="Last modified:" />{" "}
{project.lastModifiedAt
? new Date(project.lastModifiedAt).toLocaleDateString()
? formatLocaleDate(project.lastModifiedAt, {
locale: config.locale,
target: "time",
})
: ""}
</p>
<p className="text-xs text-muted-foreground">
Messages: {project.meta.sessionCount}
<Trans id="project_list.messages" message="Messages:" />{" "}
{project.meta.sessionCount}
</p>
</CardContent>
<CardContent className="pt-0">
<Button asChild className="w-full">
<Link href={`/projects/${project.id}/latest`}>
View Conversations
<Trans
id="project_list.view_conversations"
message="View Conversations"
/>
</Link>
</Button>
</CardContent>

View File

@@ -1,15 +1,17 @@
"use client";
import { Trans } from "@lingui/react";
import { HistoryIcon } from "lucide-react";
import { Suspense } from "react";
import { GlobalSidebar } from "@/components/GlobalSidebar";
import { initializeI18n } from "../../lib/i18n/initializeI18n";
import { CreateProjectDialog } from "./components/CreateProjectDialog";
import { ProjectList } from "./components/ProjectList";
export const dynamic = "force-dynamic";
export const fetchCache = "force-no-store";
export default function ProjectsPage() {
export default async function ProjectsPage() {
await initializeI18n();
return (
<div className="flex h-screen max-h-screen overflow-hidden">
<GlobalSidebar className="hidden md:flex" />
@@ -21,22 +23,29 @@ export default function ProjectsPage() {
Claude Code Viewer
</h1>
<p className="text-muted-foreground">
Browse your Claude Code conversation history and project
interactions
<Trans
id="projects.page.description"
message="Browse your Claude Code conversation history and project interactions"
/>
</p>
</header>
<main>
<section>
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold">Your Projects</h2>
<h2 className="text-xl font-semibold">
<Trans id="projects.page.title" message="Your Projects" />
</h2>
<CreateProjectDialog />
</div>
<Suspense
fallback={
<div className="flex items-center justify-center py-12">
<div className="text-muted-foreground">
Loading projects...
<Trans
id="projects.page.loading"
message="Loading projects..."
/>
</div>
</div>
}