kaneo (minimalist kanban) fork to experiment adding a tangled integration github.com/usekaneo/kaneo
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(web): internationalize routes and notification types

Tin 8132bab6 e67eaad4

+756 -515
+6 -1
apps/web/src/routes/_layout/_authenticated/dashboard.tsx
··· 1 1 import { createFileRoute, Outlet } from "@tanstack/react-router"; 2 + import { useTranslation } from "react-i18next"; 2 3 import PageTitle from "@/components/page-title"; 3 4 import useActiveWorkspace from "@/hooks/queries/workspace/use-active-workspace"; 4 5 ··· 7 8 }); 8 9 9 10 function DashboardLayoutComponent() { 11 + const { t } = useTranslation(); 10 12 const { data: workspace } = useActiveWorkspace(); 11 13 12 14 return ( 13 15 <> 14 - <PageTitle title="Projects" hideAppName={!workspace?.name} /> 16 + <PageTitle 17 + title={t("navigation:page.projectsTitle")} 18 + hideAppName={!workspace?.name} 19 + /> 15 20 <Outlet /> 16 21 </> 17 22 );
+25 -24
apps/web/src/routes/_layout/_authenticated/dashboard/invitations.tsx
··· 2 2 import { createFileRoute, useNavigate } from "@tanstack/react-router"; 3 3 import { CheckCircle, Loader2, Mail, X } from "lucide-react"; 4 4 import { useState } from "react"; 5 + import { useTranslation } from "react-i18next"; 5 6 import Layout from "@/components/common/layout"; 6 7 import PageTitle from "@/components/page-title"; 7 8 import { Badge } from "@/components/ui/badge"; ··· 19 20 import { usePendingInvitations } from "@/hooks/queries/invitation/use-pending-invitations"; 20 21 import { authClient } from "@/lib/auth-client"; 21 22 import { cn } from "@/lib/cn"; 23 + import { formatDateMedium } from "@/lib/format"; 22 24 import { toast } from "@/lib/toast"; 23 25 24 26 export const Route = createFileRoute( ··· 28 30 }); 29 31 30 32 function InvitationsPage() { 33 + const { t } = useTranslation(); 31 34 const navigate = useNavigate(); 32 35 const queryClient = useQueryClient(); 33 36 const { data: invitations = [], isLoading } = usePendingInvitations(); ··· 45 48 }); 46 49 47 50 if (error) { 48 - toast.error(error.message || "Failed to accept invitation"); 51 + toast.error(error.message || t("invitations:toast.acceptError")); 49 52 return; 50 53 } 51 54 ··· 53 56 organizationId: data?.invitation.organizationId || organizationId, 54 57 }); 55 58 56 - toast.success("Invitation accepted! Welcome to the team."); 59 + toast.success(t("invitations:toast.acceptSuccess")); 57 60 58 61 await queryClient.invalidateQueries({ 59 62 queryKey: ["invitations", "pending"], ··· 67 70 }); 68 71 } catch (error) { 69 72 toast.error( 70 - error instanceof Error ? error.message : "Failed to accept invitation", 73 + error instanceof Error 74 + ? error.message 75 + : t("invitations:toast.acceptError"), 71 76 ); 72 77 } finally { 73 78 setAcceptingId(null); ··· 82 87 }); 83 88 84 89 if (error) { 85 - toast.error(error.message || "Failed to reject invitation"); 90 + toast.error(error.message || t("invitations:toast.rejectError")); 86 91 return; 87 92 } 88 93 89 - toast.success("Invitation rejected"); 94 + toast.success(t("invitations:toast.rejectSuccess")); 90 95 91 96 await queryClient.invalidateQueries({ 92 97 queryKey: ["invitations", "pending"], 93 98 }); 94 99 } catch (error) { 95 100 toast.error( 96 - error instanceof Error ? error.message : "Failed to reject invitation", 101 + error instanceof Error 102 + ? error.message 103 + : t("invitations:toast.rejectError"), 97 104 ); 98 105 } finally { 99 106 setRejectingId(null); 100 107 } 101 108 }; 102 109 103 - const formatDate = (dateString: string) => { 104 - const date = new Date(dateString); 105 - return date.toLocaleDateString("en-US", { 106 - month: "short", 107 - day: "numeric", 108 - year: "numeric", 109 - }); 110 - }; 111 - 112 110 const getExpiryStatus = (expiresAt: string) => { 113 111 const expiryDate = new Date(expiresAt); 114 112 const now = new Date(); ··· 116 114 (expiryDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24), 117 115 ); 118 116 119 - const formattedDate = formatDate(expiresAt); 117 + const formattedDate = formatDateMedium(expiresAt); 120 118 121 119 if (daysDiff <= 1) { 122 120 return { ··· 141 139 142 140 return ( 143 141 <> 144 - <PageTitle title="Invitations" /> 142 + <PageTitle title={t("invitations:pageTitle")} /> 145 143 <Layout> 146 144 <Layout.Header> 147 145 <div className="flex items-center gap-1 w-full"> ··· 151 149 className="mx-1.5 data-[orientation=vertical]:h-2.5" 152 150 /> 153 151 <h1 className="text-xs text-card-foreground"> 154 - Pending Invitations 152 + {t("invitations:pendingInvitations")} 155 153 </h1> 156 154 </div> 157 155 </Layout.Header> ··· 167 165 <Mail className="h-8 w-8 text-muted-foreground/60" /> 168 166 </div> 169 167 <h3 className="text-base font-semibold mb-2"> 170 - No pending invitations 168 + {t("invitations:noPendingTitle")} 171 169 </h3> 172 170 <p className="text-sm text-muted-foreground max-w-md"> 173 - You don't have any pending workspace invitations at the 174 - moment. 171 + {t("invitations:noPendingDescription")} 175 172 </p> 176 173 </div> 177 174 ) : ( ··· 179 176 <Table> 180 177 <TableHeader> 181 178 <TableRow className="border-b"> 182 - <TableHead className="font-semibold">Workspace</TableHead> 183 179 <TableHead className="font-semibold"> 184 - Invited By 180 + {t("invitations:table.workspace")} 185 181 </TableHead> 186 - <TableHead className="font-semibold">Expires</TableHead> 182 + <TableHead className="font-semibold"> 183 + {t("invitations:table.invitedBy")} 184 + </TableHead> 185 + <TableHead className="font-semibold"> 186 + {t("invitations:table.expires")} 187 + </TableHead> 187 188 <TableHead className="w-[100px]" /> 188 189 </TableRow> 189 190 </TableHeader>
+10 -6
apps/web/src/routes/_layout/_authenticated/dashboard/settings.tsx
··· 5 5 useNavigate, 6 6 } from "@tanstack/react-router"; 7 7 import { ChevronLeft } from "lucide-react"; 8 + import { useTranslation } from "react-i18next"; 8 9 import PageTitle from "@/components/page-title"; 9 10 import { Button } from "@/components/ui/button"; 10 11 import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; ··· 18 19 }); 19 20 20 21 function SettingsLayout() { 22 + const { t } = useTranslation(); 21 23 const navigate = useNavigate(); 22 24 const location = useLocation(); 23 25 const { data: workspace } = useActiveWorkspace(); ··· 43 45 44 46 return ( 45 47 <> 46 - <PageTitle title="Settings" /> 48 + <PageTitle title={t("navigation:page.settingsTitle")} /> 47 49 <div className="flex flex-col gap-4 p-4 bg-sidebar w-full h-full"> 48 50 <div className="flex flex-col gap-4 bg-card h-full border border-border rounded-md p-4 relative overflow-hidden"> 49 51 <div> ··· 58 60 } 59 61 > 60 62 <ChevronLeft className=" border border-border rounded-md p-1 size-6" /> 61 - Back to Workspace 63 + {t("navigation:page.backToWorkspace")} 62 64 </Button> 63 65 64 - <h1 className="text-2xl font-semibold pl-2 mt-2">Settings</h1> 66 + <h1 className="text-2xl font-semibold pl-2 mt-2"> 67 + {t("navigation:page.settingsTitle")} 68 + </h1> 65 69 66 70 <Tabs value={activeTab} className="w-[400px] pt-2"> 67 71 <TabsList className="bg-sidebar gap-2"> ··· 72 76 navigate({ to: "/dashboard/settings/account/information" }) 73 77 } 74 78 > 75 - Account 79 + {t("settings:account")} 76 80 </TabsTrigger> 77 81 <TabsTrigger 78 82 value="workspace" ··· 81 85 navigate({ to: "/dashboard/settings/workspace/general" }) 82 86 } 83 87 > 84 - Workspace 88 + {t("navigation:page.settingsWorkspaceTab")} 85 89 </TabsTrigger> 86 90 <TabsTrigger 87 91 disabled={projects?.length === 0} ··· 91 95 navigate({ to: "/dashboard/settings/projects" }) 92 96 } 93 97 > 94 - Projects 98 + {t("navigation:sidebar.projects")} 95 99 </TabsTrigger> 96 100 </TabsList> 97 101 </Tabs>
+17 -16
apps/web/src/routes/_layout/_authenticated/dashboard/settings/account.tsx
··· 5 5 useLocation, 6 6 } from "@tanstack/react-router"; 7 7 import { Code, Settings, User } from "lucide-react"; 8 + import { useTranslation } from "react-i18next"; 8 9 import useAuth from "@/components/providers/auth-provider/hooks/use-auth"; 9 10 import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 10 11 import { Button } from "@/components/ui/button"; ··· 23 24 component: RouteComponent, 24 25 }); 25 26 26 - const menuItems = [ 27 - { 28 - title: "Information", 29 - url: "/dashboard/settings/account/information", 30 - icon: User, 31 - }, 32 - { 33 - title: "Preferences", 34 - url: "/dashboard/settings/account/preferences", 35 - icon: Settings, 36 - }, 37 - ]; 38 - 39 27 function RouteComponent() { 28 + const { t } = useTranslation(); 40 29 const { user } = useAuth(); 41 30 const location = useLocation(); 42 31 const isActivePath = (path: string) => location.pathname === path; 32 + const menuItems = [ 33 + { 34 + title: t("settings:information"), 35 + url: "/dashboard/settings/account/information", 36 + icon: User, 37 + }, 38 + { 39 + title: t("settings:preferences"), 40 + url: "/dashboard/settings/account/preferences", 41 + icon: Settings, 42 + }, 43 + ]; 43 44 44 45 return ( 45 46 <div className="flex gap-6 h-full"> ··· 62 63 63 64 <SidebarGroup className="gap-1 p-1"> 64 65 <SidebarGroupLabel className="h-7 px-2 text-[11px] uppercase tracking-wide text-sidebar-foreground/70"> 65 - Account 66 + {t("settings:account")} 66 67 </SidebarGroupLabel> 67 68 <SidebarGroupContent> 68 69 <SidebarMenu className="gap-0.5"> ··· 89 90 90 91 <SidebarGroup className="gap-1 p-1"> 91 92 <SidebarGroupLabel className="h-7 px-2 text-[11px] uppercase tracking-wide text-sidebar-foreground/70"> 92 - Developer 93 + {t("settings:developer")} 93 94 </SidebarGroupLabel> 94 95 <SidebarGroupContent> 95 96 <SidebarMenu className="gap-0.5"> ··· 105 106 )} 106 107 > 107 108 <Code className="h-4 w-4" /> 108 - <span>API Keys</span> 109 + <span>{t("settings:apiKeys")}</span> 109 110 </Button> 110 111 </SidebarMenuItem> 111 112 </SidebarMenu>
+11 -7
apps/web/src/routes/_layout/_authenticated/dashboard/settings/account/developer.tsx
··· 1 1 import { createFileRoute } from "@tanstack/react-router"; 2 2 import { KeyRound, Plus } from "lucide-react"; 3 3 import { useState } from "react"; 4 + import { useTranslation } from "react-i18next"; 4 5 import PageTitle from "@/components/page-title"; 5 6 import { ApiKeyCreatedModal } from "@/components/settings/api-key-created-modal"; 6 7 import { ApiKeyTable } from "@/components/settings/api-key-table"; ··· 25 26 }); 26 27 27 28 function RouteComponent() { 29 + const { t } = useTranslation(); 28 30 const { data: apiKeys = [], isLoading } = useGetApiKeys(); 29 31 const [createDialogOpen, setCreateDialogOpen] = useState(false); 30 32 const [createdKey, setCreatedKey] = useState<{ ··· 35 37 const handleCreateSuccess = (data: CreateApiKeyResponse) => { 36 38 setCreatedKey({ 37 39 key: data.key, 38 - name: data.name || "Unnamed Key", 40 + name: data.name || t("settings:developerPage.unnamedKey"), 39 41 }); 40 42 }; 41 43 ··· 45 47 46 48 return ( 47 49 <> 48 - <PageTitle title="Developer Settings" /> 50 + <PageTitle title={t("settings:developerPage.pageTitle")} /> 49 51 <div className="max-w-4xl mx-auto space-y-8"> 50 52 <div className="space-y-2"> 51 - <h1 className="text-2xl font-semibold">Developer Settings</h1> 53 + <h1 className="text-2xl font-semibold"> 54 + {t("settings:developerPage.title")} 55 + </h1> 52 56 <p className="text-muted-foreground"> 53 - Manage your API keys and developer resources. 57 + {t("settings:developerPage.subtitle")} 54 58 </p> 55 59 </div> 56 60 ··· 59 63 <CardHeader> 60 64 <CardTitle className="inline-flex items-center gap-2 text-base"> 61 65 <KeyRound className="size-4" /> 62 - API Keys 66 + {t("settings:developerPage.apiKeysCardTitle")} 63 67 </CardTitle> 64 68 <CardDescription> 65 - Create and manage API keys for programmatic access to Kaneo. 69 + {t("settings:developerPage.apiKeysCardDescription")} 66 70 </CardDescription> 67 71 <CardAction> 68 72 <Button ··· 70 74 className="gap-2" 71 75 > 72 76 <Plus className="size-4" /> 73 - Create API Key 77 + {t("settings:developerPage.createApiKey")} 74 78 </Button> 75 79 </CardAction> 76 80 </CardHeader>
+36 -21
apps/web/src/routes/_layout/_authenticated/dashboard/settings/account/information.tsx
··· 3 3 import { createFileRoute } from "@tanstack/react-router"; 4 4 import { useCallback, useEffect, useRef } from "react"; 5 5 import { useForm } from "react-hook-form"; 6 + import { useTranslation } from "react-i18next"; 6 7 import { z } from "zod"; 7 8 import PageTitle from "@/components/page-title"; 8 9 import useAuth from "@/components/providers/auth-provider/hooks/use-auth"; ··· 36 37 email: string; 37 38 }; 38 39 39 - const profileSchema = z.object({ 40 - name: z 41 - .string() 42 - .min(1, "Name is required") 43 - .min(2, "Name must be at least 2 characters"), 44 - email: z.string().email("Invalid email address"), 45 - }); 46 - 47 40 function normalizeProfileValues( 48 41 data: ProfileFormValues, 49 42 ): NormalizedProfileValues { ··· 54 47 } 55 48 56 49 function RouteComponent() { 50 + const { t } = useTranslation(); 57 51 const { user } = useAuth(); 58 52 const queryClient = useQueryClient(); 59 53 const { mutateAsync: updateProfile } = useUpdateUserProfile(); ··· 61 55 const isSavingRef = useRef(false); 62 56 const queuedSaveRef = useRef<ProfileFormValues | null>(null); 63 57 const lastSavedRef = useRef<NormalizedProfileValues | null>(null); 58 + const profileSchema = z.object({ 59 + name: z 60 + .string() 61 + .min(1, t("settings:informationPage.validation.nameRequired")) 62 + .min(2, t("settings:informationPage.validation.nameShort")), 63 + email: z 64 + .string() 65 + .email(t("settings:informationPage.validation.invalidEmail")), 66 + }); 64 67 65 68 const profileForm = useForm<ProfileFormValues>({ 66 69 resolver: standardSchemaResolver(profileSchema), ··· 110 113 queuedSaveRef.current = null; 111 114 112 115 await queryClient.invalidateQueries({ queryKey: ["session"] }); 113 - toast.success("Profile updated successfully"); 116 + toast.success(t("settings:informationPage.updateSuccess")); 114 117 } catch (error) { 115 118 toast.error( 116 - error instanceof Error ? error.message : "Failed to update profile", 119 + error instanceof Error 120 + ? error.message 121 + : t("settings:informationPage.updateError"), 117 122 ); 118 123 } finally { 119 124 isSavingRef.current = false; ··· 125 130 } 126 131 } 127 132 }, 128 - [updateProfile, queryClient, profileForm], 133 + [t, updateProfile, queryClient, profileForm], 129 134 ); 130 135 131 136 const debouncedSave = useCallback( ··· 161 166 162 167 return ( 163 168 <> 164 - <PageTitle title="Personal Information" /> 169 + <PageTitle title={t("settings:informationPage.pageTitle")} /> 165 170 <div className="max-w-4xl mx-auto space-y-8"> 166 171 <div className="space-y-2"> 167 - <h1 className="text-2xl font-semibold">Personal Information</h1> 172 + <h1 className="text-2xl font-semibold"> 173 + {t("settings:informationPage.title")} 174 + </h1> 168 175 <p className="text-muted-foreground"> 169 - Manage your personal details and account information. 176 + {t("settings:informationPage.subtitle")} 170 177 </p> 171 178 </div> 172 179 173 180 <div className="space-y-6"> 174 181 <div className="space-y-1"> 175 - <h2 className="text-md font-medium">Account Information</h2> 182 + <h2 className="text-md font-medium"> 183 + {t("settings:informationPage.sectionTitle")} 184 + </h2> 176 185 <p className="text-xs text-muted-foreground"> 177 - Manage your profile and account details. 186 + {t("settings:informationPage.sectionSubtitle")} 178 187 </p> 179 188 </div> 180 189 181 190 <div className="space-y-4 border border-border rounded-md p-4 bg-sidebar"> 182 191 <div className="flex items-center justify-between"> 183 192 <div className="space-y-0.5"> 184 - <p className="text-sm font-medium">Profile picture</p> 193 + <p className="text-sm font-medium"> 194 + {t("settings:informationPage.profilePicture")} 195 + </p> 185 196 </div> 186 197 <Avatar className="h-10 w-10"> 187 198 <AvatarImage src={user?.image ?? ""} alt={user?.name || ""} /> ··· 203 214 <div className="flex items-center justify-between"> 204 215 <div className="space-y-0.5"> 205 216 <FormLabel className="text-sm font-medium"> 206 - Full name 217 + {t("settings:informationPage.fullName")} 207 218 </FormLabel> 208 219 </div> 209 220 <FormControl> 210 221 <Input 211 222 className="w-48" 212 - placeholder="Enter your name" 223 + placeholder={t( 224 + "settings:informationPage.fullNamePlaceholder", 225 + )} 213 226 {...field} 214 227 /> 215 228 </FormControl> ··· 229 242 <div className="flex items-center justify-between"> 230 243 <div className="space-y-0.5"> 231 244 <FormLabel className="text-sm font-medium"> 232 - Email 245 + {t("settings:informationPage.email")} 233 246 </FormLabel> 234 247 </div> 235 248 <FormControl> 236 249 <Input 237 250 className="w-48" 238 - placeholder="Enter your email" 251 + placeholder={t( 252 + "settings:informationPage.emailPlaceholder", 253 + )} 239 254 {...field} 240 255 disabled 241 256 value={user?.email || ""}
+30 -27
apps/web/src/routes/_layout/_authenticated/dashboard/settings/projects.tsx
··· 7 7 } from "@tanstack/react-router"; 8 8 import { Eye, GitBranch, Plug, Settings } from "lucide-react"; 9 9 import { useEffect } from "react"; 10 + import { useTranslation } from "react-i18next"; 10 11 import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 11 12 import { Button } from "@/components/ui/button"; 12 13 import { ··· 32 33 component: RouteComponent, 33 34 }); 34 35 35 - const menuItems = [ 36 - { 37 - title: "General", 38 - icon: Settings, 39 - segment: "general", 40 - }, 41 - { 42 - title: "Visibility", 43 - icon: Eye, 44 - segment: "visibility", 45 - }, 46 - { 47 - title: "Integrations", 48 - icon: Plug, 49 - segment: "integrations", 50 - }, 51 - { 52 - title: "Workflow", 53 - icon: GitBranch, 54 - segment: "workflow", 55 - }, 56 - ]; 57 - 58 36 function RouteComponent() { 37 + const { t } = useTranslation(); 59 38 const { workspace, role } = useWorkspacePermission(); 60 39 const location = useLocation(); 61 40 const navigate = useNavigate(); 41 + const menuItems = [ 42 + { 43 + title: t("settings:projectGeneral.title"), 44 + icon: Settings, 45 + segment: "general", 46 + }, 47 + { 48 + title: t("settings:projectVisibility.title"), 49 + icon: Eye, 50 + segment: "visibility", 51 + }, 52 + { 53 + title: t("settings:projectIntegrations.title"), 54 + icon: Plug, 55 + segment: "integrations", 56 + }, 57 + { 58 + title: t("settings:projectWorkflow.title"), 59 + icon: GitBranch, 60 + segment: "workflow", 61 + }, 62 + ]; 62 63 const { data: projects } = useGetProjects({ 63 64 workspaceId: workspace?.id || "", 64 65 }); ··· 117 118 <div className="flex flex-col"> 118 119 <p className="text-sm">{workspace?.name}</p> 119 120 <p className="text-[11px] text-sidebar-foreground/60 capitalize"> 120 - {role} 121 + {t(`team:roles.${role}`, { defaultValue: role })} 121 122 </p> 122 123 </div> 123 124 </div> 124 125 125 126 <SidebarGroup className="gap-1 p-1"> 126 127 <SidebarGroupLabel className="h-7 px-2 text-[11px] uppercase tracking-wide text-sidebar-foreground/70"> 127 - Project 128 + {t("navigation:projectSettings.projectLabel")} 128 129 </SidebarGroupLabel> 129 130 <SidebarGroupContent> 130 131 <Select ··· 147 148 > 148 149 <span className="truncate font-normal text-foreground"> 149 150 {selectedProject?.name || 150 - (projects?.length ? "Select project" : "No projects")} 151 + (projects?.length 152 + ? t("settings:projectSwitcher.selectProject") 153 + : t("settings:projectSwitcher.noProjects"))} 151 154 </span> 152 155 </SelectTrigger> 153 156 <SelectContent ··· 171 174 172 175 <SidebarGroup className="gap-1 p-1"> 173 176 <SidebarGroupLabel className="h-7 px-2 text-[11px] uppercase tracking-wide text-sidebar-foreground/70"> 174 - Settings 177 + {t("navigation:page.settingsTitle")} 175 178 </SidebarGroupLabel> 176 179 <SidebarGroupContent> 177 180 <SidebarMenu className="gap-0.5">
+86 -49
apps/web/src/routes/_layout/_authenticated/dashboard/settings/projects/$projectId/general.tsx
··· 5 5 useNavigate, 6 6 useParams, 7 7 } from "@tanstack/react-router"; 8 - import { useCallback, useEffect, useRef, useState } from "react"; 8 + import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 9 9 import { useForm } from "react-hook-form"; 10 + import { useTranslation } from "react-i18next"; 10 11 import { z } from "zod"; 11 12 import PageTitle from "@/components/page-title"; 12 13 import { ··· 62 63 icon: string; 63 64 }; 64 65 65 - const projectSchema = z.object({ 66 - name: z 67 - .string() 68 - .min(1, "Project name is required") 69 - .min(2, "Project name must be at least 2 characters"), 70 - slug: z 71 - .string() 72 - .min(1, "Key is required") 73 - .min(2, "Key must be at least 2 characters") 74 - .max(8, "Key must be at most 8 characters"), 75 - description: z.string().optional(), 76 - icon: z.string().min(1, "Icon is required"), 77 - }); 78 - 79 66 function normalizeProjectValues( 80 67 data: ProjectFormValues, 81 68 ): NormalizedProjectValues { ··· 88 75 } 89 76 90 77 function RouteComponent() { 78 + const { t } = useTranslation(); 79 + const projectSchema = useMemo( 80 + () => 81 + z.object({ 82 + name: z 83 + .string() 84 + .min(1, t("settings:projectGeneral.validation.nameRequired")) 85 + .min(2, t("settings:projectGeneral.validation.nameShort")), 86 + slug: z 87 + .string() 88 + .min(1, t("settings:projectGeneral.validation.keyRequired")) 89 + .min(2, t("settings:projectGeneral.validation.keyShort")) 90 + .max(8, t("settings:projectGeneral.validation.keyMax")), 91 + description: z.string().optional(), 92 + icon: z 93 + .string() 94 + .min(1, t("settings:projectGeneral.validation.iconRequired")), 95 + }), 96 + [t], 97 + ); 98 + 91 99 const queryClient = useQueryClient(); 92 100 const navigate = useNavigate(); 93 101 const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null); ··· 190 198 queryKey: ["projects", workspace?.id, project.id], 191 199 }), 192 200 ]); 193 - toast.success("Project updated successfully"); 201 + toast.success(t("settings:projectGeneral.toastUpdated")); 194 202 } catch (error) { 195 203 toast.error( 196 - error instanceof Error ? error.message : "Failed to update project", 204 + error instanceof Error 205 + ? error.message 206 + : t("settings:projectGeneral.toastUpdateError"), 197 207 ); 198 208 } finally { 199 209 isSavingRef.current = false; ··· 216 226 queryClient, 217 227 workspace?.id, 218 228 projectForm, 229 + t, 219 230 ], 220 231 ); 221 232 ··· 256 267 257 268 try { 258 269 await deleteProject({ id: project.id }); 259 - toast.success("Project deleted successfully"); 270 + toast.success(t("settings:projectGeneral.toastDeleted")); 260 271 261 272 await queryClient.invalidateQueries({ queryKey: ["projects"] }); 262 273 ··· 266 277 }); 267 278 } catch (error) { 268 279 toast.error( 269 - error instanceof Error ? error.message : "Failed to delete project", 280 + error instanceof Error 281 + ? error.message 282 + : t("settings:projectGeneral.toastDeleteError"), 270 283 ); 271 284 } 272 - }, [project?.id, deleteProject, queryClient, navigate, workspace?.id]); 285 + }, [project?.id, deleteProject, queryClient, navigate, workspace?.id, t]); 273 286 274 287 return ( 275 288 <> 276 - <PageTitle title="Project Settings" /> 289 + <PageTitle title={t("settings:projectGeneral.pageTitle")} /> 277 290 <div className="max-w-4xl mx-auto space-y-8"> 278 291 <div className="space-y-2"> 279 - <h1 className="text-2xl font-semibold">General Settings</h1> 292 + <h1 className="text-2xl font-semibold"> 293 + {t("settings:projectGeneral.title")} 294 + </h1> 280 295 <p className="text-muted-foreground"> 281 - Manage your project name, key, icon and description. 296 + {t("settings:projectGeneral.subtitle")} 282 297 </p> 283 298 </div> 284 299 285 300 <div className="space-y-6"> 286 301 <div className="space-y-1"> 287 - <h2 className="text-md font-medium">Project Information</h2> 302 + <h2 className="text-md font-medium"> 303 + {t("settings:projectGeneral.projectInfoTitle")} 304 + </h2> 288 305 <p className="text-xs text-muted-foreground"> 289 - Configure your project details and preferences. 306 + {t("settings:projectGeneral.projectInfoSubtitle")} 290 307 </p> 291 308 </div> 292 309 293 310 <div className="space-y-4 border border-border rounded-md p-4 bg-sidebar"> 294 311 <div className="flex items-center justify-between"> 295 312 <div className="space-y-0.5"> 296 - <p className="text-sm font-medium">Icon</p> 313 + <p className="text-sm font-medium"> 314 + {t("settings:projectGeneral.iconLabel")} 315 + </p> 297 316 <p className="text-xs text-muted-foreground"> 298 - Displayed in the sidebar and project surfaces. 317 + {t("settings:projectGeneral.iconHint")} 299 318 </p> 300 319 </div> 301 320 <div className="flex items-center gap-3"> ··· 313 332 variant="outline" 314 333 size="sm" 315 334 className="h-8 w-auto justify-start gap-2 font-normal" 316 - title="Pick icon" 335 + title={t("settings:projectGeneral.pickIconTitle")} 317 336 > 318 337 {(() => { 319 338 const selectedKey = ··· 332 351 <Input 333 352 value={iconSearch} 334 353 onChange={(e) => setIconSearch(e.target.value)} 335 - placeholder="Search icons..." 354 + placeholder={t( 355 + "settings:projectGeneral.searchIconsPlaceholder", 356 + )} 336 357 className="h-8 text-xs" 337 358 /> 338 359 <div className="max-h-[280px] overflow-y-auto pr-1"> ··· 391 412 <div className="flex items-center justify-between"> 392 413 <div className="space-y-0.5"> 393 414 <FormLabel className="text-sm font-medium"> 394 - Project name 415 + {t("settings:projectGeneral.projectNameLabel")} 395 416 </FormLabel> 396 417 <p className="text-xs text-muted-foreground"> 397 - The name of your project 418 + {t("settings:projectGeneral.projectNameHint")} 398 419 </p> 399 420 </div> 400 421 <FormControl> 401 422 <Input 402 423 className="w-64" 403 - placeholder="Enter project name" 424 + placeholder={t( 425 + "settings:projectGeneral.projectNamePlaceholder", 426 + )} 404 427 {...field} 405 428 /> 406 429 </FormControl> ··· 420 443 <div className="flex items-center justify-between"> 421 444 <div className="space-y-0.5"> 422 445 <FormLabel className="text-sm font-medium"> 423 - Key 446 + {t("settings:projectGeneral.keyLabel")} 424 447 </FormLabel> 425 448 <p className="text-xs text-muted-foreground"> 426 - Used for ticket IDs (e.g.,{" "} 427 - {projectForm.watch("slug") || "ABC"}-123) 449 + {t("settings:projectGeneral.keyHint", { 450 + slug: projectForm.watch("slug") || "ABC", 451 + })} 428 452 </p> 429 453 </div> 430 454 <FormControl> 431 455 <Input 432 456 className="w-64" 433 - placeholder="PRO" 457 + placeholder={t( 458 + "settings:projectGeneral.keyPlaceholder", 459 + )} 434 460 {...field} 435 461 /> 436 462 </FormControl> ··· 450 476 <div className="flex items-center justify-between"> 451 477 <div className="space-y-0.5"> 452 478 <FormLabel className="text-sm font-medium"> 453 - Description 479 + {t("settings:projectGeneral.descriptionLabel")} 454 480 </FormLabel> 455 481 <p className="text-xs text-muted-foreground"> 456 - A brief description of your project 482 + {t("settings:projectGeneral.descriptionHint")} 457 483 </p> 458 484 </div> 459 485 <FormControl> 460 486 <Input 461 487 className="w-64" 462 - placeholder="Enter project description" 488 + placeholder={t( 489 + "settings:projectGeneral.descriptionPlaceholder", 490 + )} 463 491 {...field} 464 492 /> 465 493 </FormControl> ··· 475 503 476 504 <div className="space-y-6"> 477 505 <div className="space-y-1"> 478 - <h2 className="text-md font-medium">Danger zone</h2> 506 + <h2 className="text-md font-medium"> 507 + {t("settings:projectGeneral.dangerZone")} 508 + </h2> 479 509 <p className="text-xs text-muted-foreground"> 480 - Irreversible and destructive actions. 510 + {t("settings:projectGeneral.dangerZoneSubtitle")} 481 511 </p> 482 512 </div> 483 513 484 514 <div className="space-y-4 border border-border rounded-md p-4 bg-sidebar"> 485 515 <div className="flex items-center justify-between"> 486 516 <div className="space-y-0.5"> 487 - <p className="text-sm font-medium">Delete project</p> 517 + <p className="text-sm font-medium"> 518 + {t("settings:projectGeneral.deleteProject")} 519 + </p> 488 520 <p className="text-xs text-muted-foreground"> 489 - Schedule project to be permanently deleted 521 + {t("settings:projectGeneral.deleteProjectDescription")} 490 522 </p> 491 523 </div> 492 524 <Button ··· 497 529 onClick={() => setIsDeleteModalOpen(true)} 498 530 disabled={!project} 499 531 > 500 - Delete project 532 + {t("settings:projectGeneral.deleteProject")} 501 533 </Button> 502 534 </div> 503 535 </div> ··· 509 541 > 510 542 <AlertDialogContent> 511 543 <AlertDialogHeader> 512 - <AlertDialogTitle>Delete Project?</AlertDialogTitle> 544 + <AlertDialogTitle> 545 + {t("settings:projectGeneral.deleteModalTitle")} 546 + </AlertDialogTitle> 513 547 <AlertDialogDescription> 514 - This will permanently delete the project "{project?.name}" and 515 - all its data. This action cannot be undone. 548 + {t("settings:projectGeneral.deleteModalDescription", { 549 + name: project?.name ?? "", 550 + })} 516 551 </AlertDialogDescription> 517 552 </AlertDialogHeader> 518 553 <AlertDialogFooter> 519 554 <AlertDialogClose> 520 555 <Button variant="outline" size="sm"> 521 - Cancel 556 + {t("common:actions.cancel")} 522 557 </Button> 523 558 </AlertDialogClose> 524 559 <AlertDialogClose ··· 526 561 disabled={isDeleting} 527 562 > 528 563 <Button variant="destructive" size="sm" disabled={isDeleting}> 529 - {isDeleting ? "Deleting..." : "Delete Project"} 564 + {isDeleting 565 + ? t("common:actions.deleting") 566 + : t("settings:projectGeneral.deleteModalConfirm")} 530 567 </Button> 531 568 </AlertDialogClose> 532 569 </AlertDialogFooter>
+11 -6
apps/web/src/routes/_layout/_authenticated/dashboard/settings/projects/$projectId/integrations.tsx
··· 1 1 import { createFileRoute } from "@tanstack/react-router"; 2 + import { useTranslation } from "react-i18next"; 2 3 import PageTitle from "@/components/page-title"; 3 4 import { GitHubIntegrationSettings } from "@/components/project/github-integration-settings"; 4 5 ··· 9 10 }); 10 11 11 12 function RouteComponent() { 13 + const { t } = useTranslation(); 12 14 const { projectId } = Route.useParams(); 13 15 14 16 return ( 15 17 <> 16 - <PageTitle title="Project Integrations" /> 18 + <PageTitle title={t("settings:projectIntegrations.pageTitle")} /> 17 19 <div className="max-w-4xl mx-auto space-y-8"> 18 20 <div className="space-y-2"> 19 - <h1 className="text-2xl font-semibold">Project Integrations</h1> 21 + <h1 className="text-2xl font-semibold"> 22 + {t("settings:projectIntegrations.title")} 23 + </h1> 20 24 <p className="text-muted-foreground"> 21 - Connect your project with external tools and services to streamline 22 - your workflow. 25 + {t("settings:projectIntegrations.subtitle")} 23 26 </p> 24 27 </div> 25 28 26 29 <div className="space-y-6"> 27 30 <div className="space-y-1"> 28 - <h2 className="text-md font-medium">GitHub Integration</h2> 31 + <h2 className="text-md font-medium"> 32 + {t("settings:projectIntegrations.githubSectionTitle")} 33 + </h2> 29 34 <p className="text-xs text-muted-foreground"> 30 - Synchronize tasks with GitHub issues and enable two-way sync. 35 + {t("settings:projectIntegrations.githubSectionSubtitle")} 31 36 </p> 32 37 </div> 33 38
+30 -14
apps/web/src/routes/_layout/_authenticated/dashboard/settings/projects/$projectId/visibility.tsx
··· 1 1 import { useQueryClient } from "@tanstack/react-query"; 2 2 import { createFileRoute, useParams } from "@tanstack/react-router"; 3 3 import { useCallback, useRef } from "react"; 4 + import { useTranslation } from "react-i18next"; 4 5 import PageTitle from "@/components/page-title"; 5 6 import { Button } from "@/components/ui/button"; 6 7 import { Input } from "@/components/ui/input"; ··· 19 20 }); 20 21 21 22 function RouteComponent() { 23 + const { t } = useTranslation(); 22 24 const { projectId } = useParams({ strict: false }); 23 25 const { data: workspace } = useActiveWorkspace(); 24 26 const { data: project } = useGetProject({ ··· 52 54 queryKey: ["projects", workspace?.id, project.id], 53 55 }), 54 56 ]); 55 - toast.success("Visibility updated"); 57 + toast.success(t("settings:projectVisibility.toastUpdated")); 56 58 } catch (e) { 57 59 toast.error( 58 - e instanceof Error ? e.message : "Failed to update visibility", 60 + e instanceof Error 61 + ? e.message 62 + : t("settings:projectVisibility.toastUpdateError"), 59 63 ); 60 64 } finally { 61 65 savingRef.current = false; 62 66 } 63 - }, [project, updateProject, queryClient, workspace?.id]); 67 + }, [project, updateProject, queryClient, workspace?.id, t]); 64 68 65 69 const origin = window.location.origin; 66 70 ··· 68 72 69 73 return ( 70 74 <> 71 - <PageTitle title="Project Visibility" /> 75 + <PageTitle title={t("settings:projectVisibility.pageTitle")} /> 72 76 <div className="max-w-4xl mx-auto space-y-8"> 73 77 <div className="space-y-2"> 74 - <h1 className="text-2xl font-semibold">Visibility</h1> 78 + <h1 className="text-2xl font-semibold"> 79 + {t("settings:projectVisibility.title")} 80 + </h1> 75 81 <p className="text-muted-foreground"> 76 - Control who can view and access your project. 82 + {t("settings:projectVisibility.subtitle")} 77 83 </p> 78 84 </div> 79 85 80 86 <div className="space-y-6"> 81 87 <div className="space-y-1"> 82 - <h2 className="text-md font-medium">Visibility</h2> 88 + <h2 className="text-md font-medium"> 89 + {t("settings:projectVisibility.sectionTitle")} 90 + </h2> 83 91 <p className="text-xs text-muted-foreground"> 84 - Toggle public access and share the public URL. 92 + {t("settings:projectVisibility.sectionSubtitle")} 85 93 </p> 86 94 </div> 87 95 88 96 <div className="space-y-4 border border-border rounded-md p-4 bg-sidebar"> 89 97 <div className="flex items-center justify-between"> 90 98 <div className="space-y-0.5"> 91 - <Label className="text-sm font-medium">Public access</Label> 99 + <Label className="text-sm font-medium"> 100 + {t("settings:projectVisibility.publicAccess")} 101 + </Label> 92 102 <p className="text-xs text-muted-foreground"> 93 - Allow anyone with the URL to view this project 103 + {t("settings:projectVisibility.publicAccessHint")} 94 104 </p> 95 105 </div> 96 106 <Switch ··· 103 113 104 114 <div className="flex items-center justify-between"> 105 115 <div className="space-y-0.5"> 106 - <Label className="text-sm font-medium">Public URL</Label> 116 + <Label className="text-sm font-medium"> 117 + {t("settings:projectVisibility.publicUrl")} 118 + </Label> 107 119 <p className="text-xs text-muted-foreground"> 108 - Share this link if the project is public 120 + {t("settings:projectVisibility.publicUrlHint")} 109 121 </p> 110 122 </div> 111 123 <div className="flex items-center gap-2"> ··· 116 128 if (!publicUrl) return; 117 129 navigator.clipboard 118 130 .writeText(publicUrl) 119 - .then(() => toast.success("Copied")); 131 + .then(() => 132 + toast.success( 133 + t("settings:projectVisibility.copiedToast"), 134 + ), 135 + ); 120 136 }} 121 137 > 122 - Copy 138 + {t("settings:projectVisibility.copy")} 123 139 </Button> 124 140 </div> 125 141 </div>
+15 -9
apps/web/src/routes/_layout/_authenticated/dashboard/settings/projects/$projectId/workflow.tsx
··· 1 1 import { createFileRoute } from "@tanstack/react-router"; 2 + import { useTranslation } from "react-i18next"; 2 3 import PageTitle from "@/components/page-title"; 3 4 import ColumnEditor from "@/components/project/column-editor"; 4 5 import WorkflowEditor from "@/components/project/workflow-editor"; ··· 10 11 }); 11 12 12 13 function RouteComponent() { 14 + const { t } = useTranslation(); 13 15 const { projectId } = Route.useParams(); 14 16 15 17 return ( 16 18 <> 17 - <PageTitle title="Workflow Settings" /> 19 + <PageTitle title={t("settings:projectWorkflow.pageTitle")} /> 18 20 <div className="max-w-4xl mx-auto space-y-8"> 19 21 <div className="space-y-2"> 20 - <h1 className="text-2xl font-semibold">Workflow</h1> 22 + <h1 className="text-2xl font-semibold"> 23 + {t("settings:projectWorkflow.title")} 24 + </h1> 21 25 <p className="text-muted-foreground"> 22 - Configure board columns and automation rules for this project. 26 + {t("settings:projectWorkflow.subtitle")} 23 27 </p> 24 28 </div> 25 29 26 30 <div className="space-y-6"> 27 31 <div className="space-y-1"> 28 - <h2 className="text-md font-medium">Columns</h2> 32 + <h2 className="text-md font-medium"> 33 + {t("settings:projectWorkflow.columnsTitle")} 34 + </h2> 29 35 <p className="text-xs text-muted-foreground"> 30 - Manage the columns that appear on your board. Drag to reorder. 31 - Turn on "Done column" for stages that represent completed work. 36 + {t("settings:projectWorkflow.columnsDescription")} 32 37 </p> 33 38 </div> 34 39 <ColumnEditor projectId={projectId} /> ··· 36 41 37 42 <div className="space-y-6"> 38 43 <div className="space-y-1"> 39 - <h2 className="text-md font-medium">Automation Rules</h2> 44 + <h2 className="text-md font-medium"> 45 + {t("settings:projectWorkflow.automationTitle")} 46 + </h2> 40 47 <p className="text-xs text-muted-foreground"> 41 - Map integration events to columns. When an event occurs, the 42 - linked task moves to the specified column. 48 + {t("settings:projectWorkflow.automationDescription")} 43 49 </p> 44 50 </div> 45 51 <WorkflowEditor projectId={projectId} />
+11 -10
apps/web/src/routes/_layout/_authenticated/dashboard/settings/workspace.tsx
··· 5 5 useLocation, 6 6 } from "@tanstack/react-router"; 7 7 import { Settings } from "lucide-react"; 8 + import { useTranslation } from "react-i18next"; 8 9 import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 9 10 import { Button } from "@/components/ui/button"; 10 11 import { ··· 23 24 component: RouteComponent, 24 25 }); 25 26 26 - const menuItems = [ 27 - { 28 - title: "General", 29 - url: "/dashboard/settings/workspace/general", 30 - icon: Settings, 31 - }, 32 - ]; 33 - 34 27 function RouteComponent() { 28 + const { t } = useTranslation(); 35 29 const { workspace, role } = useWorkspacePermission(); 36 30 const location = useLocation(); 31 + const menuItems = [ 32 + { 33 + title: t("settings:workspaceGeneral.title"), 34 + url: "/dashboard/settings/workspace/general", 35 + icon: Settings, 36 + }, 37 + ]; 37 38 const isActivePath = (path: string) => location.pathname === path; 38 39 const workspaceInitials = 39 40 workspace?.name ··· 60 61 <div className="flex flex-col"> 61 62 <p className="text-sm">{workspace?.name}</p> 62 63 <p className="text-[11px] text-sidebar-foreground/60 capitalize"> 63 - {role} 64 + {t(`team:roles.${role}`, { defaultValue: role })} 64 65 </p> 65 66 </div> 66 67 </div> 67 68 68 69 <SidebarGroup className="gap-1 p-1"> 69 70 <SidebarGroupLabel className="h-7 px-2 text-[11px] uppercase tracking-wide text-sidebar-foreground/70"> 70 - Workspace 71 + {t("navigation:page.settingsWorkspaceTab")} 71 72 </SidebarGroupLabel> 72 73 <SidebarGroupContent> 73 74 <SidebarMenu className="gap-0.5">
+63 -36
apps/web/src/routes/_layout/_authenticated/dashboard/settings/workspace/general.tsx
··· 1 1 import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"; 2 2 import { useQueryClient } from "@tanstack/react-query"; 3 3 import { createFileRoute, useNavigate } from "@tanstack/react-router"; 4 - import { useCallback, useEffect, useRef, useState } from "react"; 4 + import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 5 5 import { useForm } from "react-hook-form"; 6 + import { useTranslation } from "react-i18next"; 6 7 import { z } from "zod"; 7 8 import PageTitle from "@/components/page-title"; 8 9 import { ··· 46 47 description: string; 47 48 }; 48 49 49 - const workspaceSchema = z.object({ 50 - name: z 51 - .string() 52 - .min(1, "Workspace name is required") 53 - .min(2, "Workspace name must be at least 2 characters"), 54 - description: z.string().optional(), 55 - }); 56 - 57 50 function normalizeWorkspaceValues( 58 51 data: WorkspaceFormValues, 59 52 ): NormalizedWorkspaceValues { ··· 64 57 } 65 58 66 59 function RouteComponent() { 60 + const { t } = useTranslation(); 61 + const workspaceSchema = useMemo( 62 + () => 63 + z.object({ 64 + name: z 65 + .string() 66 + .min(1, t("settings:workspaceGeneral.validation.nameRequired")) 67 + .min(2, t("settings:workspaceGeneral.validation.nameShort")), 68 + description: z.string().optional(), 69 + }), 70 + [t], 71 + ); 72 + 67 73 const queryClient = useQueryClient(); 68 74 const navigate = useNavigate(); 69 75 const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null); ··· 151 157 await queryClient.invalidateQueries({ 152 158 queryKey: ["active-organization"], 153 159 }); 154 - toast.success("Workspace updated successfully"); 160 + toast.success(t("settings:workspaceGeneral.toastUpdated")); 155 161 } catch (error) { 156 162 toast.error( 157 - error instanceof Error ? error.message : "Failed to update workspace", 163 + error instanceof Error 164 + ? error.message 165 + : t("settings:workspaceGeneral.toastUpdateError"), 158 166 ); 159 167 } finally { 160 168 isSavingRef.current = false; ··· 166 174 } 167 175 } 168 176 }, 169 - [workspace, updateWorkspace, queryClient, workspaceForm], 177 + [workspace, updateWorkspace, queryClient, workspaceForm, t], 170 178 ); 171 179 172 180 const handleDeleteWorkspace = useCallback(async () => { ··· 174 182 175 183 try { 176 184 await deleteWorkspace({ workspaceId: workspace.id }); 177 - toast.success("Workspace deleted successfully"); 185 + toast.success(t("settings:workspaceGeneral.toastDeleted")); 178 186 179 187 // Invalidate all workspace-related queries 180 188 await queryClient.invalidateQueries({ queryKey: ["workspaces"] }); ··· 185 193 navigate({ to: "/dashboard" }); 186 194 } catch (error) { 187 195 toast.error( 188 - error instanceof Error ? error.message : "Failed to delete workspace", 196 + error instanceof Error 197 + ? error.message 198 + : t("settings:workspaceGeneral.toastDeleteError"), 189 199 ); 190 200 } 191 - }, [workspace?.id, deleteWorkspace, queryClient, navigate]); 201 + }, [workspace?.id, deleteWorkspace, queryClient, navigate, t]); 192 202 193 203 const debouncedSave = useCallback( 194 204 (data: WorkspaceFormValues) => { ··· 223 233 224 234 return ( 225 235 <> 226 - <PageTitle title="General Settings" /> 236 + <PageTitle title={t("settings:workspaceGeneral.pageTitle")} /> 227 237 <div className="max-w-4xl mx-auto space-y-8"> 228 238 <div className="space-y-2"> 229 - <h1 className="text-2xl font-semibold">General Settings</h1> 239 + <h1 className="text-2xl font-semibold"> 240 + {t("settings:workspaceGeneral.title")} 241 + </h1> 230 242 <p className="text-muted-foreground"> 231 - Manage your workspace name and description. 243 + {t("settings:workspaceGeneral.subtitle")} 232 244 </p> 233 245 </div> 234 246 235 247 <div className="space-y-6"> 236 248 <div className="space-y-1"> 237 - <h2 className="text-md font-medium">Workspace Information</h2> 249 + <h2 className="text-md font-medium"> 250 + {t("settings:workspaceGeneral.workspaceInfoTitle")} 251 + </h2> 238 252 <p className="text-xs text-muted-foreground"> 239 - Configure your workspace details and preferences. 253 + {t("settings:workspaceGeneral.workspaceInfoSubtitle")} 240 254 </p> 241 255 </div> 242 256 ··· 251 265 <div className="flex items-center justify-between"> 252 266 <div className="space-y-0.5"> 253 267 <FormLabel className="text-sm font-medium"> 254 - Workspace name 268 + {t("settings:workspaceGeneral.nameLabel")} 255 269 </FormLabel> 256 270 <p className="text-xs text-muted-foreground"> 257 - The name of your workspace 271 + {t("settings:workspaceGeneral.nameHint")} 258 272 </p> 259 273 </div> 260 274 <FormControl> 261 275 <Input 262 276 className="w-64" 263 - placeholder="Enter workspace name" 277 + placeholder={t( 278 + "settings:workspaceGeneral.namePlaceholder", 279 + )} 264 280 {...field} 265 281 /> 266 282 </FormControl> ··· 280 296 <div className="flex items-center justify-between"> 281 297 <div className="space-y-0.5"> 282 298 <FormLabel className="text-sm font-medium"> 283 - Description 299 + {t("settings:workspaceGeneral.descriptionLabel")} 284 300 </FormLabel> 285 301 <p className="text-xs text-muted-foreground"> 286 - A brief description of your workspace 302 + {t("settings:workspaceGeneral.descriptionHint")} 287 303 </p> 288 304 </div> 289 305 <FormControl> 290 306 <Input 291 307 className="w-64" 292 - placeholder="Enter workspace description" 308 + placeholder={t( 309 + "settings:workspaceGeneral.descriptionPlaceholder", 310 + )} 293 311 {...field} 294 312 /> 295 313 </FormControl> ··· 305 323 306 324 <div className="space-y-6"> 307 325 <div className="space-y-1"> 308 - <h2 className="text-md font-medium">Danger zone</h2> 326 + <h2 className="text-md font-medium"> 327 + {t("settings:workspaceGeneral.dangerZone")} 328 + </h2> 309 329 <p className="text-xs text-muted-foreground"> 310 - Irreversible and destructive actions. 330 + {t("settings:workspaceGeneral.dangerZoneSubtitle")} 311 331 </p> 312 332 </div> 313 333 314 334 <div className="space-y-4 border border-border rounded-md p-4 bg-sidebar"> 315 335 <div className="flex items-center justify-between"> 316 336 <div className="space-y-0.5"> 317 - <p className="text-sm font-medium">Delete workspace</p> 337 + <p className="text-sm font-medium"> 338 + {t("settings:workspaceGeneral.deleteWorkspace")} 339 + </p> 318 340 <p className="text-xs text-muted-foreground"> 319 - Schedule workspace to be permanently deleted 341 + {t("settings:workspaceGeneral.deleteWorkspaceDescription")} 320 342 </p> 321 343 </div> 322 344 <Button ··· 326 348 type="button" 327 349 onClick={() => setIsDeleteModalOpen(true)} 328 350 > 329 - Delete workspace 351 + {t("settings:workspaceGeneral.deleteWorkspace")} 330 352 </Button> 331 353 </div> 332 354 </div> ··· 338 360 > 339 361 <AlertDialogContent> 340 362 <AlertDialogHeader> 341 - <AlertDialogTitle>Delete Workspace?</AlertDialogTitle> 363 + <AlertDialogTitle> 364 + {t("settings:workspaceGeneral.deleteModalTitle")} 365 + </AlertDialogTitle> 342 366 <AlertDialogDescription> 343 - This will permanently delete the workspace "{workspace?.name}" 344 - and all its data. This action cannot be undone. 367 + {t("settings:workspaceGeneral.deleteModalDescription", { 368 + name: workspace?.name ?? "", 369 + })} 345 370 </AlertDialogDescription> 346 371 </AlertDialogHeader> 347 372 <AlertDialogFooter> 348 373 <AlertDialogClose> 349 374 <Button variant="outline" size="sm"> 350 - Cancel 375 + {t("common:actions.cancel")} 351 376 </Button> 352 377 </AlertDialogClose> 353 378 <AlertDialogClose ··· 355 380 disabled={isDeleting} 356 381 > 357 382 <Button variant="destructive" size="sm" disabled={isDeleting}> 358 - {isDeleting ? "Deleting..." : "Delete Workspace"} 383 + {isDeleting 384 + ? t("common:actions.deleting") 385 + : t("settings:workspaceGeneral.deleteModalConfirm")} 359 386 </Button> 360 387 </AlertDialogClose> 361 388 </AlertDialogFooter>
+31 -31
apps/web/src/routes/_layout/_authenticated/dashboard/workspace/$workspaceId/index.tsx
··· 1 1 import { createFileRoute, useNavigate } from "@tanstack/react-router"; 2 2 import { LayoutGrid, Plus } from "lucide-react"; 3 3 import { useState } from "react"; 4 + import { useTranslation } from "react-i18next"; 4 5 import WorkspaceLayout from "@/components/common/workspace-layout"; 5 6 import PageTitle from "@/components/page-title"; 6 7 import CreateProjectModal from "@/components/shared/modals/create-project-modal"; ··· 20 21 import { shortcuts } from "@/constants/shortcuts"; 21 22 import useGetProjects from "@/hooks/queries/project/use-get-projects"; 22 23 import { useRegisterShortcuts } from "@/hooks/use-keyboard-shortcuts"; 24 + import { formatDateMedium } from "@/lib/format"; 23 25 24 26 export const Route = createFileRoute( 25 27 "/_layout/_authenticated/dashboard/workspace/$workspaceId/", ··· 28 30 }); 29 31 30 32 function RouteComponent() { 33 + const { t } = useTranslation(); 31 34 const [isCreateProjectOpen, setIsCreateProjectOpen] = useState(false); 32 35 const { workspaceId } = Route.useParams(); 33 36 const navigate = useNavigate(); ··· 57 60 if (isLoading) { 58 61 return ( 59 62 <> 60 - <PageTitle title="Projects" /> 63 + <PageTitle title={t("workspace:projects.pageTitle")} /> 61 64 <WorkspaceLayout 62 - title="Projects" 65 + title={t("workspace:projects.pageTitle")} 63 66 headerActions={ 64 67 <Button 65 68 variant="outline" ··· 68 71 className="gap-1" 69 72 > 70 73 <Plus className="w-3 h-3" /> 71 - Create project 74 + {t("workspace:projects.createProject")} 72 75 </Button> 73 76 } 74 77 > ··· 76 79 <TableHeader> 77 80 <TableRow> 78 81 <TableHead className="text-foreground font-medium"> 79 - Title 82 + {t("workspace:projects.title")} 80 83 </TableHead> 81 84 <TableHead className="text-foreground font-medium"> 82 - Progress 85 + {t("workspace:projects.progress")} 83 86 </TableHead> 84 87 <TableHead className="text-foreground font-medium"> 85 - Target date 88 + {t("workspace:projects.targetDate")} 86 89 </TableHead> 87 90 <TableHead className="text-foreground font-medium"> 88 - Status 91 + {t("workspace:projects.status")} 89 92 </TableHead> 90 93 </TableRow> 91 94 </TableHeader> ··· 119 122 if (!projects || projects.length === 0) { 120 123 return ( 121 124 <> 122 - <PageTitle title="Projects" /> 125 + <PageTitle title={t("workspace:projects.pageTitle")} /> 123 126 <WorkspaceLayout 124 - title="Projects" 127 + title={t("workspace:projects.pageTitle")} 125 128 headerActions={ 126 129 <Button 127 130 variant="outline" ··· 130 133 className="gap-1" 131 134 > 132 135 <Plus className="w-3 h-3" /> 133 - Create project 136 + {t("workspace:projects.createProject")} 134 137 </Button> 135 138 } 136 139 > ··· 140 143 <LayoutGrid className="w-8 h-8 text-muted-foreground" /> 141 144 </div> 142 145 <div className="space-y-2"> 143 - <h3 className="text-xl font-semibold">No projects yet</h3> 146 + <h3 className="text-xl font-semibold"> 147 + {t("workspace:projects.emptyTitle")} 148 + </h3> 144 149 <p className="text-muted-foreground"> 145 - Get started by creating your first project. 150 + {t("workspace:projects.emptyDescription")} 146 151 </p> 147 152 </div> 148 153 <Button onClick={handleCreateProject} className="gap-2 "> 149 154 <Plus className="w-4 h-4" /> 150 - Create project 155 + {t("workspace:projects.createProject")} 151 156 </Button> 152 157 </div> 153 158 </div> ··· 163 168 164 169 return ( 165 170 <> 166 - <PageTitle title="Projects" /> 171 + <PageTitle title={t("workspace:projects.pageTitle")} /> 167 172 <WorkspaceLayout 168 - title="Projects" 173 + title={t("workspace:projects.pageTitle")} 169 174 headerActions={ 170 175 <Button 171 176 variant="outline" ··· 174 179 className="gap-1" 175 180 > 176 181 <Plus className="w-3 h-3" /> 177 - Create project 182 + {t("workspace:projects.createProject")} 178 183 </Button> 179 184 } 180 185 > ··· 182 187 <TableHeader className="p-4"> 183 188 <TableRow> 184 189 <TableHead className="text-foreground font-medium"> 185 - Title 190 + {t("workspace:projects.title")} 186 191 </TableHead> 187 192 <TableHead className="text-foreground font-medium"> 188 - Progress 193 + {t("workspace:projects.progress")} 189 194 </TableHead> 190 195 <TableHead className="text-foreground font-medium"> 191 - Due date 196 + {t("workspace:projects.dueDate")} 192 197 </TableHead> 193 198 <TableHead className="text-foreground font-medium"> 194 - Status 199 + {t("workspace:projects.status")} 195 200 </TableHead> 196 201 </TableRow> 197 202 </TableHeader> ··· 203 208 icons[project.icon as keyof typeof icons] || icons.Layout; 204 209 205 210 const getStatusText = () => { 206 - if (project.statistics.totalTasks === 0) return "Not started"; 211 + if (project.statistics.totalTasks === 0) 212 + return t("workspace:projects.projectStatus.notStarted"); 207 213 if (project.statistics.completionPercentage === 100) 208 - return "Complete"; 209 - return "In progress"; 214 + return t("workspace:projects.projectStatus.complete"); 215 + return t("workspace:projects.projectStatus.inProgress"); 210 216 }; 211 217 212 218 const getStatusVariant = () => { ··· 242 248 <TableCell className="py-3"> 243 249 <span className="text-sm text-muted-foreground"> 244 250 {project.statistics.dueDate 245 - ? new Date( 246 - project.statistics.dueDate, 247 - ).toLocaleDateString("en-US", { 248 - month: "short", 249 - day: "numeric", 250 - year: "numeric", 251 - }) 252 - : "No due date"} 251 + ? formatDateMedium(project.statistics.dueDate) 252 + : t("workspace:projects.noDueDate")} 253 253 </span> 254 254 </TableCell> 255 255 <TableCell className="py-3">
+5 -3
apps/web/src/routes/_layout/_authenticated/dashboard/workspace/$workspaceId/members.tsx
··· 1 1 import { createFileRoute } from "@tanstack/react-router"; 2 2 import { UserPlus } from "lucide-react"; 3 3 import { useState } from "react"; 4 + import { useTranslation } from "react-i18next"; 4 5 import WorkspaceLayout from "@/components/common/workspace-layout"; 5 6 import PageTitle from "@/components/page-title"; 6 7 import InviteTeamMemberModal from "@/components/team/invite-team-member-modal"; ··· 15 16 }); 16 17 17 18 function RouteComponent() { 19 + const { t } = useTranslation(); 18 20 const { workspaceId } = Route.useParams(); 19 21 const { data: workspace } = useGetFullWorkspace({ workspaceId }); 20 22 ··· 26 28 27 29 return ( 28 30 <> 29 - <PageTitle title="Members" /> 31 + <PageTitle title={t("team:members.pageTitle")} /> 30 32 <WorkspaceLayout 31 - title="Members" 33 + title={t("team:members.pageTitle")} 32 34 headerActions={ 33 35 <Button 34 36 onClick={() => setIsInviteTeamMemberModalOpen(true)} ··· 37 39 className="gap-1 w-full md:w-auto" 38 40 > 39 41 <UserPlus className="w-3 h-3" /> 40 - Invite member 42 + {t("team:members.inviteMember")} 41 43 </Button> 42 44 } 43 45 >
+82 -39
apps/web/src/routes/_layout/_authenticated/dashboard/workspace/$workspaceId/project/$projectId/backlog.tsx
··· 3 3 import { produce } from "immer"; 4 4 import { ArrowRight, Calendar, Filter, Plus, User, X } from "lucide-react"; 5 5 import { useCallback, useEffect, useMemo, useState } from "react"; 6 + import { useTranslation } from "react-i18next"; 6 7 import BacklogListView from "@/components/backlog-list-view"; 7 8 import ProjectLayout from "@/components/common/project-layout"; 8 9 import SortControl from "@/components/common/sort-control"; ··· 28 29 import { useGetTasks } from "@/hooks/queries/task/use-get-tasks"; 29 30 import { useGetActiveWorkspaceUsers } from "@/hooks/queries/workspace-users/use-get-active-workspace-users"; 30 31 import { useRegisterShortcuts } from "@/hooks/use-keyboard-shortcuts"; 32 + import { DUE_DATE_FILTER_VALUES } from "@/hooks/use-task-filters"; 33 + import { getPriorityLabel } from "@/lib/i18n/domain"; 31 34 import { getPriorityIcon } from "@/lib/priority"; 32 35 import type { SortConfig } from "@/lib/sort-tasks"; 33 36 import { sortTasks } from "@/lib/sort-tasks"; ··· 50 53 }); 51 54 52 55 function RouteComponent() { 56 + const { t } = useTranslation(); 53 57 const { projectId, workspaceId } = Route.useParams(); 54 58 const { taskId } = Route.useSearch(); 55 59 const navigate = useNavigate(); ··· 149 153 150 154 const getAssigneeDisplayName = (userId: string) => { 151 155 const member = users?.members?.find((m) => m.userId === userId); 152 - return member?.user?.name || "Unknown"; 156 + return member?.user?.name || t("common:people.unknown"); 153 157 }; 154 158 155 159 const getTaskLabels = useCallback( ··· 181 185 const taskDate = new Date(task.dueDate); 182 186 183 187 switch (filters.dueDate) { 184 - case "Due this week": { 188 + case DUE_DATE_FILTER_VALUES.dueThisWeek: { 185 189 const weekStart = new Date( 186 190 today.getFullYear(), 187 191 today.getMonth(), ··· 195 199 } 196 200 break; 197 201 } 198 - case "Due next week": { 202 + case DUE_DATE_FILTER_VALUES.dueNextWeek: { 199 203 const nextWeekStart = new Date( 200 204 today.getFullYear(), 201 205 today.getMonth(), ··· 209 213 } 210 214 break; 211 215 } 212 - case "No due date": { 216 + case DUE_DATE_FILTER_VALUES.noDueDate: { 213 217 return false; 214 218 } 215 219 } 216 220 } 217 221 218 - if (filters.dueDate === "No due date" && task.dueDate) { 222 + if ( 223 + filters.dueDate === DUE_DATE_FILTER_VALUES.noDueDate && 224 + task.dueDate 225 + ) { 219 226 return false; 220 227 } 221 228 ··· 306 313 const plannedTasks = project.plannedTasks || []; 307 314 308 315 if (plannedTasks.length === 0) { 309 - toast.info("No planned tasks to move"); 316 + toast.info(t("tasks:backlog.noTasksToMove")); 310 317 return; 311 318 } 312 319 313 - if (!confirm(`Move all ${plannedTasks.length} planned tasks to To Do?`)) { 320 + if ( 321 + !confirm( 322 + t("tasks:backlog.moveAllConfirm", { count: plannedTasks.length }), 323 + ) 324 + ) { 314 325 return; 315 326 } 316 327 ··· 336 347 }); 337 348 338 349 setProject(updatedProject); 339 - toast.success(`Moved ${plannedTasks.length} tasks to To Do`); 350 + toast.success( 351 + t("tasks:backlog.moveAllSuccess", { count: plannedTasks.length }), 352 + ); 340 353 }; 341 354 342 355 return ( ··· 345 358 workspaceId={workspaceId} 346 359 activeView="backlog" 347 360 > 348 - <PageTitle title={`${project?.name}'s backlog`} /> 361 + <PageTitle 362 + title={t("tasks:backlog.pageTitle", { name: project?.name })} 363 + /> 349 364 <div className="relative flex flex-col h-full min-h-0 overflow-hidden"> 350 365 <div className="border-border/80 border-b bg-card/80 backdrop-blur supports-[backdrop-filter]:bg-card/70"> 351 366 <div className="flex min-h-12 items-center px-3 py-2 md:px-4"> ··· 358 373 className="h-6 px-2 text-xs text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground" 359 374 > 360 375 <Plus className="h-3 w-3 mr-1" /> 361 - Plan 376 + {t("tasks:backlog.plan")} 362 377 </Button> 363 378 364 379 <Button ··· 366 381 size="xs" 367 382 onClick={handleMoveAllPlannedToTodo} 368 383 className="h-6 px-2 text-xs text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground" 369 - title="Move All Planned to To Do" 384 + title={t("tasks:backlog.moveAllTooltip")} 370 385 > 371 386 <ArrowRight className="h-3 w-3 mr-1" /> 372 - Move All 387 + {t("tasks:backlog.moveAll")} 373 388 </Button> 374 389 375 390 {filters.priority && ( ··· 380 395 > 381 396 {getPriorityIcon(filters.priority)} 382 397 <span> 383 - Priority: {getPriorityDisplayName(filters.priority)} 398 + {t("tasks:backlog.filters.assignee", { 399 + name: getPriorityLabel(filters.priority), 400 + })} 384 401 </span> 385 402 <Button 386 403 variant="ghost" ··· 404 421 > 405 422 <User className="h-3 w-3" /> 406 423 <span> 407 - Assignee: {getAssigneeDisplayName(filters.assignee)} 424 + {t("tasks:backlog.filters.assignee", { 425 + name: getAssigneeDisplayName(filters.assignee), 426 + })} 408 427 </span> 409 428 <Button 410 429 variant="ghost" ··· 427 446 className="h-7 rounded-md px-2 text-xs font-medium gap-1.5" 428 447 > 429 448 <Calendar className="h-3 w-3" /> 430 - <span>Due: {filters.dueDate}</span> 449 + <span> 450 + {t("tasks:backlog.filters.due", { 451 + date: t( 452 + `tasks:backlog.filters.${filters.dueDate.toLowerCase().replace(/\s+/g, "")}`, 453 + { defaultValue: filters.dueDate }, 454 + ), 455 + })} 456 + </span> 431 457 <Button 432 458 variant="ghost" 433 459 size="icon" ··· 471 497 ?.color || "var(--color-neutral-400)", 472 498 }} 473 499 /> 474 - <span>Label: {label.name}</span> 500 + <span> 501 + {t("tasks:backlog.filters.label", { 502 + name: label.name, 503 + })} 504 + </span> 475 505 <Button 476 506 variant="ghost" 477 507 size="icon" ··· 499 529 } 500 530 > 501 531 <Filter className="h-3.5 w-3.5" /> 502 - Filter 532 + {t("tasks:backlog.filter")} 503 533 </DropdownMenuTrigger> 504 534 <DropdownMenuContent className="w-80" align="start"> 505 535 <DropdownMenuItem 506 536 disabled 507 537 className="h-8 rounded-md border border-border/80 bg-card text-sm text-muted-foreground" 508 538 > 509 - Add filter... 539 + {t("tasks:backlog.addFilter")} 510 540 </DropdownMenuItem> 511 541 <DropdownMenuSeparator /> 512 542 {hasActiveFilters && ( ··· 515 545 onClick={clearFilters} 516 546 className="h-8 text-sm text-muted-foreground" 517 547 > 518 - <span>Clear all filters</span> 548 + <span>{t("common:actions.clearAllFilters")}</span> 519 549 </DropdownMenuItem> 520 550 <DropdownMenuSeparator /> 521 551 </> 522 552 )} 523 553 <DropdownMenuGroup> 524 554 <DropdownMenuLabel className="text-[11px] uppercase tracking-wide"> 525 - Priority 555 + {t("tasks:priority.label")} 526 556 </DropdownMenuLabel> 527 557 </DropdownMenuGroup> 528 558 {["urgent", "high", "medium", "low"].map((priority) => ( ··· 535 565 className="h-8 rounded-md text-sm [&_svg]:text-sidebar-foreground" 536 566 > 537 567 {getPriorityIcon(priority)} 538 - <span className="capitalize">{priority}</span> 568 + <span className="capitalize"> 569 + {getPriorityLabel(priority)} 570 + </span> 539 571 </DropdownMenuCheckboxItem> 540 572 ))} 541 573 542 574 <DropdownMenuSeparator /> 543 575 <DropdownMenuGroup> 544 576 <DropdownMenuLabel className="text-[11px] uppercase tracking-wide"> 545 - Assignee 577 + {t("tasks:assignee.label")} 546 578 </DropdownMenuLabel> 547 579 </DropdownMenuGroup> 548 580 {users?.members?.map((member) => ( ··· 573 605 <DropdownMenuSeparator /> 574 606 <DropdownMenuGroup> 575 607 <DropdownMenuLabel className="text-[11px] uppercase tracking-wide"> 576 - Due date 608 + {t("tasks:dueDate.label")} 577 609 </DropdownMenuLabel> 578 610 </DropdownMenuGroup> 579 - {["Due this week", "Due next week", "No due date"].map( 580 - (dueDate) => ( 581 - <DropdownMenuCheckboxItem 582 - key={dueDate} 583 - checked={filters.dueDate === dueDate} 584 - onCheckedChange={(checked) => 585 - updateFilter("dueDate", checked ? dueDate : null) 586 - } 587 - className="h-8 rounded-md text-sm" 588 - > 589 - <span>{dueDate}</span> 590 - </DropdownMenuCheckboxItem> 591 - ), 592 - )} 611 + {[ 612 + { 613 + label: DUE_DATE_FILTER_VALUES.dueThisWeek, 614 + key: "dueThisWeek", 615 + }, 616 + { 617 + label: DUE_DATE_FILTER_VALUES.dueNextWeek, 618 + key: "dueNextWeek", 619 + }, 620 + { 621 + label: DUE_DATE_FILTER_VALUES.noDueDate, 622 + key: "noDueDate", 623 + }, 624 + ].map((item) => ( 625 + <DropdownMenuCheckboxItem 626 + key={item.label} 627 + checked={filters.dueDate === item.label} 628 + onCheckedChange={(checked) => 629 + updateFilter("dueDate", checked ? item.label : null) 630 + } 631 + className="h-8 rounded-md text-sm" 632 + > 633 + <span>{t(`tasks:backlog.filters.${item.key}`)}</span> 634 + </DropdownMenuCheckboxItem> 635 + ))} 593 636 594 637 <DropdownMenuSeparator /> 595 638 <DropdownMenuGroup> 596 639 <DropdownMenuLabel className="text-[11px] uppercase tracking-wide"> 597 - Labels 640 + {t("tasks:labels.label")} 598 641 </DropdownMenuLabel> 599 642 </DropdownMenuGroup> 600 643 {uniqueLabels.length > 0 ? ( ··· 628 671 disabled 629 672 className="h-8 rounded-md text-sm text-muted-foreground" 630 673 > 631 - <span>No labels available</span> 674 + <span>{t("tasks:labels.empty")}</span> 632 675 </DropdownMenuItem> 633 676 )} 634 677 </DropdownMenuContent>
+4 -2
apps/web/src/routes/_layout/_authenticated/dashboard/workspace/$workspaceId/project/$projectId/board.tsx
··· 1 1 import { createFileRoute, useNavigate } from "@tanstack/react-router"; 2 2 import { Search } from "lucide-react"; 3 3 import { useCallback, useEffect, useMemo, useState } from "react"; 4 + import { useTranslation } from "react-i18next"; 4 5 import BoardToolbar from "@/components/board/board-toolbar"; 5 6 import ProjectLayout from "@/components/common/project-layout"; 6 7 import KanbanBoard from "@/components/kanban-board"; ··· 34 35 }); 35 36 36 37 function RouteComponent() { 38 + const { t } = useTranslation(); 37 39 const { projectId, workspaceId } = Route.useParams(); 38 40 const { taskId } = Route.useSearch(); 39 41 const navigate = useNavigate(); ··· 160 162 closeBoardSearch(); 161 163 } 162 164 }} 163 - placeholder="Search tickets..." 165 + placeholder={t("tasks:boardSearchPlaceholder")} 164 166 className="h-7.5 [&_[data-slot=input]]:h-7 [&_[data-slot=input]]:leading-7 [&_[data-slot=input]]:pl-8 [&_[data-slot=input]]:text-xs [&_[data-slot=input]]:placeholder:text-xs [&_[data-slot=input]]:placeholder:leading-7" 165 167 /> 166 168 </div> ··· 174 176 headerActions={boardHeaderSearch} 175 177 > 176 178 <PageTitle 177 - title={`${project?.name} — ${viewMode === "board" ? "Board" : "List"}`} 179 + title={`${project?.name} — ${viewMode === "board" ? t("tasks:view.board") : t("tasks:view.list")}`} 178 180 hideAppName 179 181 /> 180 182 <div className="relative flex flex-col h-full min-h-0 overflow-hidden">
+18 -21
apps/web/src/routes/_layout/_authenticated/dashboard/workspace/$workspaceId/project/$projectId/gantt.tsx
··· 13 13 } from "date-fns"; 14 14 import { ChevronLeft, ChevronRight, Search } from "lucide-react"; 15 15 import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; 16 + import { useTranslation } from "react-i18next"; 16 17 import ProjectLayout from "@/components/common/project-layout"; 17 18 import { GanttTaskBar } from "@/components/gantt/gantt-task-bar"; 18 19 import PageTitle from "@/components/page-title"; ··· 22 23 import { useGetTasks } from "@/hooks/queries/task/use-get-tasks"; 23 24 import { useIsMobile } from "@/hooks/use-mobile"; 24 25 import { cn } from "@/lib/cn"; 26 + import { getStatusLabel } from "@/lib/i18n/domain"; 25 27 26 28 type GanttSearchParams = { 27 29 taskId?: string; ··· 36 38 }), 37 39 }); 38 40 39 - function toDisplayStatus(status: string) { 40 - return status 41 - .split("-") 42 - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) 43 - .join(" "); 44 - } 45 - 46 41 function parseTaskDate(value: string | null) { 47 42 if (!value) return null; 48 43 const parsed = parseISO(value); ··· 50 45 } 51 46 52 47 function RouteComponent() { 48 + const { t } = useTranslation(); 53 49 const { projectId, workspaceId } = Route.useParams(); 54 50 const { taskId } = Route.useSearch(); 55 51 const navigate = useNavigate(); ··· 176 172 workspaceId={workspaceId} 177 173 activeView="gantt" 178 174 > 179 - <PageTitle title={`${project?.name} — Gantt`} hideAppName /> 175 + <PageTitle 176 + title={t("tasks:gantt.pageTitle", { name: project?.name })} 177 + hideAppName 178 + /> 180 179 <div className="flex h-full min-h-0 flex-col bg-background"> 181 180 <div className="border-b border-border/80 px-3 py-3 sm:px-4"> 182 181 <div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between"> 183 182 <div className="space-y-1"> 184 183 <h1 className="text-sm font-semibold text-foreground"> 185 - Gantt Timeline 184 + {t("tasks:gantt.title")} 186 185 </h1> 187 186 </div> 188 187 ··· 191 190 <Input 192 191 value={searchQuery} 193 192 onChange={(event) => setSearchQuery(event.target.value)} 194 - placeholder="Search scheduled tickets..." 193 + placeholder={t("tasks:gantt.searchPlaceholder")} 195 194 className="h-9 min-h-11 touch-manipulation sm:h-8 sm:min-h-0 [&_[data-slot=input]]:pl-8 [&_[data-slot=input]]:text-xs" 196 195 /> 197 196 </div> ··· 207 206 ) : ( 208 207 <ChevronRight className="size-3.5" /> 209 208 )} 210 - {showTaskRail ? "Hide tasks" : "Show tasks"} 209 + {showTaskRail 210 + ? t("tasks:gantt.hideTasks") 211 + : t("tasks:gantt.showTasks")} 211 212 </Button> 212 213 </div> 213 214 </div> ··· 216 217 <div className="flex flex-1 items-center justify-center px-6"> 217 218 <div className="max-w-sm text-center"> 218 219 <h2 className="text-sm font-semibold text-foreground"> 219 - No scheduled tasks 220 + {t("tasks:gantt.noTasks")} 220 221 </h2> 221 222 <p className="mt-1 text-sm text-muted-foreground"> 222 - Add a start date, due date, or both to tasks to place them on 223 - the project timeline. 223 + {t("tasks:gantt.noTasksSubtitle")} 224 224 </p> 225 225 </div> 226 226 </div> ··· 228 228 <div className="flex flex-1 items-center justify-center px-6"> 229 229 <div className="max-w-sm text-center"> 230 230 <h2 className="text-sm font-semibold text-foreground"> 231 - No tasks found 231 + {t("tasks:gantt.noTasksFound")} 232 232 </h2> 233 233 <p className="mt-1 text-sm text-muted-foreground"> 234 - No scheduled tasks match{" "} 235 - <span className="font-medium text-foreground"> 236 - "{searchQuery}" 237 - </span> 234 + {t("tasks:gantt.noTasksMatch", { query: searchQuery })} 238 235 </p> 239 236 </div> 240 237 </div> ··· 250 247 }} 251 248 > 252 249 <p className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground"> 253 - Task 250 + {t("tasks:gantt.taskHeader")} 254 251 </p> 255 252 </div> 256 253 ) : null} ··· 346 343 > 347 344 <div className="flex w-full items-center gap-1.5"> 348 345 <span className="max-w-[7rem] truncate rounded-full bg-secondary px-1.5 py-px text-[10px] font-medium uppercase tracking-wide text-secondary-foreground sm:max-w-none"> 349 - {toDisplayStatus(task.status)} 346 + {getStatusLabel(task.status)} 350 347 </span> 351 348 <span className="truncate text-[10px] text-muted-foreground"> 352 349 {project?.slug}-{task.number}
+3 -1
apps/web/src/routes/_layout/_authenticated/dashboard/workspace/$workspaceId/project/$projectId/task/$taskId_.tsx
··· 1 1 import { createFileRoute } from "@tanstack/react-router"; 2 2 import { useEffect, useState } from "react"; 3 + import { useTranslation } from "react-i18next"; 3 4 import TaskLayout from "@/components/common/task-layout"; 4 5 import PageTitle from "@/components/page-title"; 5 6 import TaskDetailsContent from "@/components/task/task-details-content"; ··· 20 21 }); 21 22 22 23 function RouteComponent() { 24 + const { t } = useTranslation(); 23 25 const { projectId, workspaceId, taskId } = Route.useParams(); 24 26 const { data: task, isLoading: isTaskLoading } = useGetTask(taskId); 25 27 const { data: project, isLoading: isProjectLoading } = useGetProject({ ··· 66 68 <PageTitle 67 69 title={ 68 70 isLoading 69 - ? "Loading task..." 71 + ? t("tasks:common.loadingTask") 70 72 : `${project?.slug}-${task?.number} — ${task?.title}` 71 73 } 72 74 hideAppName
+33 -23
apps/web/src/routes/_layout/_authenticated/dashboard/workspace/$workspaceId/search.tsx
··· 1 1 import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; 2 2 import { ArrowLeft, Loader2, Search } from "lucide-react"; 3 - import { useCallback, useEffect, useRef, useState } from "react"; 3 + import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 4 + import { useTranslation } from "react-i18next"; 4 5 import WorkspaceLayout from "@/components/common/workspace-layout"; 5 6 import PageTitle from "@/components/page-title"; 6 7 import { Button } from "@/components/ui/button"; ··· 15 16 }); 16 17 17 18 function SearchComponent() { 19 + const { t } = useTranslation(); 18 20 const { workspaceId } = Route.useParams(); 19 21 const navigate = useNavigate(); 20 22 const [searchInput, setSearchInput] = useState(""); ··· 51 53 const hasQuery = debouncedQuery.length > 0; 52 54 const showLoading = hasQuery && (isLoading || isFetching); 53 55 56 + const quickSearchSuggestions = useMemo( 57 + () => [ 58 + t("workspace:search.suggestionHighPriority"), 59 + t("workspace:search.suggestionBug"), 60 + t("workspace:search.suggestionFeature"), 61 + t("workspace:search.suggestionInProgress"), 62 + t("workspace:search.suggestionCompleted"), 63 + ], 64 + [t], 65 + ); 66 + 54 67 return ( 55 68 <> 56 - <PageTitle title="Search" /> 69 + <PageTitle title={t("workspace:search.pageTitle")} /> 57 70 <WorkspaceLayout 58 - title="Search" 71 + title={t("workspace:search.pageTitle")} 59 72 headerActions={ 60 73 <Link to="/dashboard/workspace/$workspaceId" params={{ workspaceId }}> 61 74 <Button variant="ghost" size="sm" className="gap-2"> 62 75 <ArrowLeft className="w-4 h-4" /> 63 - Back to Dashboard 76 + {t("workspace:search.backToDashboard")} 64 77 </Button> 65 78 </Link> 66 79 } ··· 70 83 <div className="relative"> 71 84 <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" /> 72 85 <Input 73 - placeholder="Search tasks by title or short ID (e.g. DEP-23)..." 86 + placeholder={t("workspace:search.placeholder")} 74 87 value={searchInput} 75 88 onChange={(e) => handleInputChange(e.target.value)} 76 89 className="pl-10 h-12 text-lg" ··· 81 94 )} 82 95 </div> 83 96 <p className="text-sm text-muted-foreground mt-2"> 84 - Search across all projects in this workspace. Use short IDs like 85 - DEP-23 to find specific tasks. 97 + {t("workspace:search.hint")} 86 98 </p> 87 99 </div> 88 100 ··· 93 105 <div className="text-center space-y-4"> 94 106 <Loader2 className="w-8 h-8 animate-spin text-muted-foreground mx-auto" /> 95 107 <p className="text-sm text-muted-foreground"> 96 - Searching... 108 + {t("workspace:search.searching")} 97 109 </p> 98 110 </div> 99 111 </div> 100 112 ) : results.length > 0 ? ( 101 113 <div className="space-y-1"> 102 114 <p className="text-xs text-muted-foreground mb-3"> 103 - {data?.totalCount ?? results.length} result 104 - {(data?.totalCount ?? results.length) !== 1 ? "s" : ""}{" "} 105 - found 115 + {t("workspace:search.resultsFound", { 116 + count: data?.totalCount ?? results.length, 117 + })} 106 118 </p> 107 119 {results.map((result) => ( 108 120 <button ··· 155 167 ) : ( 156 168 <div className="text-center py-12"> 157 169 <Search className="w-12 h-12 text-muted-foreground mx-auto mb-4" /> 158 - <p className="text-lg font-medium mb-2">No results found</p> 170 + <p className="text-lg font-medium mb-2"> 171 + {t("workspace:search.noResultsTitle")} 172 + </p> 159 173 <p className="text-muted-foreground"> 160 - Try adjusting your search terms or search for something else 174 + {t("workspace:search.noResultsDescription")} 161 175 </p> 162 176 </div> 163 177 )} ··· 165 179 ) : ( 166 180 <div className="text-center py-12"> 167 181 <Search className="w-16 h-16 text-muted-foreground mx-auto mb-4" /> 168 - <h2 className="text-xl font-semibold mb-2">Start Searching</h2> 182 + <h2 className="text-xl font-semibold mb-2"> 183 + {t("workspace:search.startTitle")} 184 + </h2> 169 185 <p className="text-muted-foreground mb-6"> 170 - Enter a search term to find tasks across all projects 186 + {t("workspace:search.startDescription")} 171 187 </p> 172 188 173 189 <div className="space-y-2"> 174 190 <p className="text-sm font-medium text-muted-foreground"> 175 - Quick searches: 191 + {t("workspace:search.quickSearchesLabel")} 176 192 </p> 177 193 <div className="flex flex-wrap gap-2 justify-center"> 178 - {[ 179 - "High priority", 180 - "Bug", 181 - "Feature", 182 - "In progress", 183 - "Completed", 184 - ].map((suggestion) => ( 194 + {quickSearchSuggestions.map((suggestion) => ( 185 195 <Button 186 196 key={suggestion} 187 197 variant="outline"
+14 -11
apps/web/src/routes/_layout/_authenticated/dashboard/workspace/create.tsx
··· 2 2 import { createFileRoute, useNavigate } from "@tanstack/react-router"; 3 3 import { Building2 } from "lucide-react"; 4 4 import { useEffect, useRef, useState } from "react"; 5 + import { useTranslation } from "react-i18next"; 5 6 import PageTitle from "@/components/page-title"; 6 7 import { Button } from "@/components/ui/button"; 7 8 import { Card, CardContent } from "@/components/ui/card"; ··· 17 18 }); 18 19 19 20 function RouteComponent() { 21 + const { t } = useTranslation(); 20 22 const [name, setName] = useState(""); 21 23 const [description, setDescription] = useState(""); 22 24 const inputRef = useRef<HTMLInputElement>(null); ··· 38 40 39 41 try { 40 42 const createdWorkspace = await mutateAsync({ name, description }); 41 - toast.success("Workspace created successfully"); 43 + toast.success(t("workspace:create.success")); 42 44 await queryClient.invalidateQueries({ queryKey: ["workspaces"] }); 43 45 44 46 await authClient.organization.setActive({ ··· 54 56 }); 55 57 } catch (error) { 56 58 toast.error( 57 - error instanceof Error ? error.message : "Failed to create workspace", 59 + error instanceof Error ? error.message : t("workspace:create.error"), 58 60 ); 59 61 } 60 62 }; 61 63 62 64 return ( 63 65 <> 64 - <PageTitle title="Create Workspace" /> 66 + <PageTitle title={t("workspace:create.pageTitle")} /> 65 67 <div className="min-h-screen w-full bg-background flex items-center justify-center p-4 overflow-y-auto"> 66 68 <div className="w-full max-w-md"> 67 69 <Card className="shadow-sm"> ··· 74 76 </div> 75 77 76 78 <h1 className="text-2xl font-semibold text-foreground mb-2"> 77 - Create a new workspace 79 + {t("workspace:create.heading")} 78 80 </h1> 79 81 <p className="text-muted-foreground text-sm"> 80 - Workspaces are shared environments where teams can work on 81 - projects, cycles and issues. 82 + {t("workspace:create.subtitle")} 82 83 </p> 83 84 </div> 84 85 ··· 96 97 id="workspace-name" 97 98 value={name} 98 99 onChange={(e) => setName(e.target.value)} 99 - placeholder="Enter workspace name" 100 + placeholder={t("workspace:create.namePlaceholder")} 100 101 className="h-12 text-lg font-medium" 101 102 required 102 103 /> 103 104 {!name.trim() && ( 104 105 <p className="mt-1 text-destructive-foreground text-sm"> 105 - Required 106 + {t("workspace:create.required")} 106 107 </p> 107 108 )} 108 109 </div> ··· 112 113 htmlFor="workspace-description" 113 114 className="block text-sm font-medium text-foreground mb-2" 114 115 > 115 - Description (optional) 116 + {t("workspace:create.descriptionLabel")} 116 117 </label> 117 118 <Input 118 119 id="workspace-description" 119 120 value={description} 120 121 onChange={(e) => setDescription(e.target.value)} 121 - placeholder="Add a description for your workspace" 122 + placeholder={t("workspace:create.descriptionPlaceholder")} 122 123 className="h-10" 123 124 /> 124 125 </div> ··· 130 131 disabled={!name.trim() || isPending} 131 132 className="w-full h-12 font-medium disabled:opacity-50 disabled:cursor-not-allowed" 132 133 > 133 - {isPending ? "Creating..." : "Create workspace"} 134 + {isPending 135 + ? t("workspace:create.creating") 136 + : t("workspace:create.submit")} 134 137 </Button> 135 138 </div> 136 139 </form>
+30 -26
apps/web/src/routes/_layout/_authenticated/invitations.tsx
··· 2 2 import { createFileRoute, useNavigate } from "@tanstack/react-router"; 3 3 import { CheckCircle, Loader2, Mail, X } from "lucide-react"; 4 4 import { useState } from "react"; 5 + import { useTranslation } from "react-i18next"; 5 6 import PageTitle from "@/components/page-title"; 6 7 import useAuth from "@/components/providers/auth-provider/hooks/use-auth"; 7 8 import { Badge } from "@/components/ui/badge"; ··· 17 18 import { usePendingInvitations } from "@/hooks/queries/invitation/use-pending-invitations"; 18 19 import { authClient } from "@/lib/auth-client"; 19 20 import { cn } from "@/lib/cn"; 21 + import { formatDateMedium } from "@/lib/format"; 20 22 import { toast } from "@/lib/toast"; 21 23 22 24 export const Route = createFileRoute("/_layout/_authenticated/invitations")({ ··· 24 26 }); 25 27 26 28 function InvitationsPage() { 29 + const { t } = useTranslation(); 27 30 const navigate = useNavigate(); 28 31 const queryClient = useQueryClient(); 29 32 const { data: invitations = [], isLoading } = usePendingInvitations(); ··· 50 53 }); 51 54 52 55 if (error) { 53 - toast.error(error.message || "Failed to accept invitation"); 56 + toast.error(error.message || t("invitations:toast.acceptError")); 54 57 return; 55 58 } 56 59 ··· 58 61 organizationId: data?.invitation.organizationId || organizationId, 59 62 }); 60 63 61 - toast.success("Invitation accepted! Welcome to the team."); 64 + toast.success(t("invitations:toast.acceptSuccess")); 62 65 63 66 await queryClient.invalidateQueries({ 64 67 queryKey: ["invitations", "pending"], ··· 72 75 }); 73 76 } catch (error) { 74 77 toast.error( 75 - error instanceof Error ? error.message : "Failed to accept invitation", 78 + error instanceof Error 79 + ? error.message 80 + : t("invitations:toast.acceptError"), 76 81 ); 77 82 } finally { 78 83 setAcceptingId(null); ··· 87 92 }); 88 93 89 94 if (error) { 90 - toast.error(error.message || "Failed to reject invitation"); 95 + toast.error(error.message || t("invitations:toast.rejectError")); 91 96 return; 92 97 } 93 98 94 - toast.success("Invitation rejected"); 99 + toast.success(t("invitations:toast.rejectSuccess")); 95 100 96 101 await queryClient.invalidateQueries({ 97 102 queryKey: ["invitations", "pending"], 98 103 }); 99 104 } catch (error) { 100 105 toast.error( 101 - error instanceof Error ? error.message : "Failed to reject invitation", 106 + error instanceof Error 107 + ? error.message 108 + : t("invitations:toast.rejectError"), 102 109 ); 103 110 } finally { 104 111 setRejectingId(null); 105 112 } 106 - }; 107 - 108 - const formatDate = (dateString: string) => { 109 - const date = new Date(dateString); 110 - return date.toLocaleDateString("en-US", { 111 - month: "short", 112 - day: "numeric", 113 - year: "numeric", 114 - }); 115 113 }; 116 114 117 115 const getExpiryStatus = (expiresAt: string) => { ··· 121 119 (expiryDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24), 122 120 ); 123 121 124 - const formattedDate = formatDate(expiresAt); 122 + const formattedDate = formatDateMedium(expiresAt); 125 123 126 124 if (daysDiff <= 1) { 127 125 return { ··· 146 144 147 145 return ( 148 146 <> 149 - <PageTitle title="Invitations" /> 147 + <PageTitle title={t("invitations:pageTitle")} /> 150 148 <div className="flex w-full min-h-screen items-center justify-center p-6 bg-background"> 151 149 <div className="w-full max-w-2xl space-y-8"> 152 150 <div className="space-y-3 text-center"> 153 - <h1 className="text-3xl font-semibold">Pending Invitations</h1> 151 + <h1 className="text-3xl font-semibold"> 152 + {t("invitations:pendingInvitations")} 153 + </h1> 154 154 <p className="text-sm text-muted-foreground"> 155 - Accept invitations to join workspaces 155 + {t("invitations:acceptSubtitle")} 156 156 </p> 157 157 </div> 158 158 ··· 166 166 <Mail className="h-8 w-8 text-muted-foreground/60" /> 167 167 </div> 168 168 <h3 className="text-base font-semibold mb-2"> 169 - No pending invitations 169 + {t("invitations:noPendingTitle")} 170 170 </h3> 171 171 <p className="text-sm text-muted-foreground max-w-md mb-6"> 172 - You don't have any pending workspace invitations at the moment. 172 + {t("invitations:noPendingDescription")} 173 173 </p> 174 174 <Button onClick={handleSkip} variant="default"> 175 - Continue to Setup 175 + {t("invitations:continueToSetup")} 176 176 </Button> 177 177 </div> 178 178 ) : ( ··· 181 181 <Table> 182 182 <TableHeader> 183 183 <TableRow className="border-b"> 184 - <TableHead className="font-semibold">Workspace</TableHead> 184 + <TableHead className="font-semibold"> 185 + {t("invitations:table.workspace")} 186 + </TableHead> 187 + <TableHead className="font-semibold"> 188 + {t("invitations:table.invitedBy")} 189 + </TableHead> 185 190 <TableHead className="font-semibold"> 186 - Invited By 191 + {t("invitations:table.expires")} 187 192 </TableHead> 188 - <TableHead className="font-semibold">Expires</TableHead> 189 193 <TableHead className="w-[100px]" /> 190 194 </TableRow> 191 195 </TableHeader> ··· 276 280 onClick={handleSkip} 277 281 className="text-muted-foreground hover:text-foreground" 278 282 > 279 - Skip for now 283 + {t("invitations:skipForNow")} 280 284 </Button> 281 285 </div> 282 286 </div>
+3 -1
apps/web/src/routes/_layout/_authenticated/onboarding.tsx
··· 1 1 import { createFileRoute } from "@tanstack/react-router"; 2 + import { useTranslation } from "react-i18next"; 2 3 import { OnboardingFlow } from "@/components/onboarding/onboarding-flow"; 3 4 import PageTitle from "@/components/page-title"; 4 5 ··· 7 8 }); 8 9 9 10 function RouteComponent() { 11 + const { t } = useTranslation(); 10 12 return ( 11 13 <> 12 - <PageTitle title="Welcome to Kaneo" /> 14 + <PageTitle title={t("auth:onboarding.pageTitle")} /> 13 15 <OnboardingFlow /> 14 16 </> 15 17 );
+14 -8
apps/web/src/routes/auth/check-email.tsx
··· 1 1 import { createFileRoute, Link, useSearch } from "@tanstack/react-router"; 2 + import { Trans, useTranslation } from "react-i18next"; 2 3 import PageTitle from "@/components/page-title"; 3 4 import { Button } from "@/components/ui/button"; 4 5 import { AuthLayout } from "../../components/auth/layout"; ··· 11 12 }); 12 13 13 14 function CheckEmail() { 15 + const { t } = useTranslation(); 14 16 const { email } = useSearch({ from: "/auth/check-email" }); 15 17 16 18 return ( 17 19 <> 18 - <PageTitle title="Check Your Email" /> 19 - <AuthLayout title="Check your email"> 20 + <PageTitle title={t("auth:checkEmail.pageTitle")} /> 21 + <AuthLayout title={t("auth:checkEmail.title")}> 20 22 <div className="space-y-4 mt-4"> 21 23 <div className="space-y-2"> 22 24 <p className="text-sm text-muted-foreground leading-relaxed"> 23 - We've sent you a temporary login link. Please check your inbox at{" "} 24 - <span className="text-foreground font-medium"> 25 - {email || "your email address"} 26 - </span> 27 - . 25 + <Trans 26 + i18nKey="auth:checkEmail.inboxMessage" 27 + values={{ 28 + email: email || t("auth:checkEmail.emailFallback"), 29 + }} 30 + components={{ 31 + email: <span className="text-foreground font-medium" />, 32 + }} 33 + /> 28 34 </p> 29 35 </div> 30 36 ··· 34 40 asChild 35 41 className="w-full h-8 text-xs text-muted-foreground hover:text-foreground" 36 42 > 37 - <Link to="/auth/sign-in">Back to login</Link> 43 + <Link to="/auth/sign-in">{t("auth:checkEmail.backToLogin")}</Link> 38 44 </Button> 39 45 </div> 40 46 </div>
+40 -41
apps/web/src/routes/auth/sign-in.tsx
··· 5 5 } from "@tanstack/react-router"; 6 6 import { Github, KeyRound, UserCheck } from "lucide-react"; 7 7 import { useState } from "react"; 8 + import { useTranslation } from "react-i18next"; 8 9 import { z } from "zod/v4"; 9 10 import PageTitle from "@/components/page-title"; 10 11 import { Alert, AlertDescription } from "@/components/ui/alert"; ··· 30 31 }); 31 32 32 33 function SignIn() { 34 + const { t } = useTranslation(); 33 35 const navigate = useNavigate(); 34 36 const search = useSearch({ from: "/auth/sign-in" }); 35 37 const [isCustomOAuthLoading, setIsCustomOAuthLoading] = useState(false); ··· 58 60 if (result.error) { 59 61 throw new Error(result.error.message); 60 62 } 61 - toast.success("Signed in as guest"); 63 + toast.success(t("auth:signIn.guestSuccess")); 62 64 navigate({ to: "/dashboard" }); 63 65 } catch (error) { 64 66 toast.error( 65 - error instanceof Error ? error.message : "Failed to sign in as guest", 67 + error instanceof Error ? error.message : t("auth:signIn.guestError"), 66 68 ); 67 69 } finally { 68 70 setIsGuestLoading(false); ··· 82 84 } 83 85 } catch (error) { 84 86 toast.error( 85 - error instanceof Error ? error.message : "Failed to sign in with OIDC", 87 + error instanceof Error ? error.message : t("auth:signIn.oidcError"), 86 88 ); 87 89 } finally { 88 90 setIsCustomOAuthLoading(false); ··· 102 104 } 103 105 } catch (error) { 104 106 toast.error( 105 - error instanceof Error 106 - ? error.message 107 - : "Failed to sign in with Google", 107 + error instanceof Error ? error.message : t("auth:signIn.googleError"), 108 108 ); 109 109 } finally { 110 110 setIsGoogleLoading(false); ··· 124 124 } 125 125 } catch (error) { 126 126 toast.error( 127 - error instanceof Error 128 - ? error.message 129 - : "Failed to sign in with Github", 127 + error instanceof Error ? error.message : t("auth:signIn.githubError"), 130 128 ); 131 129 } finally { 132 130 setIsGithubLoading(false); ··· 146 144 } 147 145 } catch (error) { 148 146 toast.error( 149 - error instanceof Error 150 - ? error.message 151 - : "Failed to sign in with Discord", 147 + error instanceof Error ? error.message : t("auth:signIn.discordError"), 152 148 ); 153 149 } finally { 154 150 setIsDiscordLoading(false); ··· 166 162 if (isConfigLoading) { 167 163 return ( 168 164 <> 169 - <PageTitle title="Sign In" /> 165 + <PageTitle title={t("auth:signIn.pageTitle")} /> 170 166 <AuthLayout 171 - title="Welcome back" 172 - subtitle="Enter your credentials to access your workspace" 167 + title={t("auth:signIn.title")} 168 + subtitle={t("auth:signIn.subtitle")} 173 169 > 174 170 <SignInFormSkeleton /> 175 171 </AuthLayout> ··· 179 175 180 176 return ( 181 177 <> 182 - <PageTitle title="Sign In" /> 178 + <PageTitle title={t("auth:signIn.pageTitle")} /> 183 179 <AuthLayout 184 - title="Welcome back" 180 + title={t("auth:signIn.title")} 185 181 subtitle={ 186 182 invitationId 187 - ? "Sign in to accept your invitation" 188 - : "Enter your credentials to access your workspace" 183 + ? t("auth:signIn.invitationSubtitle") 184 + : t("auth:signIn.subtitle") 189 185 } 190 186 > 191 187 <div className="mt-6"> 192 188 {invitationId && ( 193 189 <Alert className="mb-4"> 194 190 <AlertDescription> 195 - After signing in, you'll be able to accept your workspace 196 - invitation. 191 + {t("auth:signIn.invitationAlert")} 197 192 </AlertDescription> 198 193 </Alert> 199 194 )} ··· 219 214 xmlns="http://www.w3.org/2000/svg" 220 215 viewBox="0 0 24 24" 221 216 className="w-5 h-5 mr-2" 222 - aria-label="Google" 217 + aria-label={t("auth:providers.google")} 223 218 > 224 219 <title>Google</title> 225 220 <path ··· 228 223 /> 229 224 </svg> 230 225 {isGoogleLoading 231 - ? "Signing in..." 232 - : "Continue with Google"} 226 + ? t("auth:signIn.signingIn") 227 + : t("auth:signIn.continueWithGoogle")} 233 228 </Button> 234 229 {lastLoginMethod === "google" && ( 235 230 <span className="absolute rounded-md -top-3 right-1 px-1.5 text-xs text-primary font-medium bg-sidebar border border-primary/50"> 236 - Last used 231 + {t("auth:signIn.lastUsed")} 237 232 </span> 238 233 )} 239 234 </div> ··· 252 247 > 253 248 <Github className="w-5 h-5 mr-2" /> 254 249 {isGithubLoading 255 - ? "Signing in..." 256 - : "Continue with GitHub"} 250 + ? t("auth:signIn.signingIn") 251 + : t("auth:signIn.continueWithGithub")} 257 252 </Button> 258 253 {lastLoginMethod === "github" && ( 259 254 <span className="absolute rounded-md -top-3 right-1 px-1.5 text-xs text-primary font-medium bg-sidebar border border-primary/50"> 260 - Last used 255 + {t("auth:signIn.lastUsed")} 261 256 </span> 262 257 )} 263 258 </div> ··· 279 274 viewBox="0 0 24 24" 280 275 className="w-5 h-5 mr-2" 281 276 fill="currentColor" 282 - aria-label="Discord" 277 + aria-label={t("auth:providers.discord")} 283 278 > 284 279 <title>Discord</title> 285 280 <path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515a.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0a12.64 12.64 0 0 0-.617-1.25a.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057a19.9 19.9 0 0 0 5.993 3.03a.078.078 0 0 0 .084-.028a14.09 14.09 0 0 0 1.226-1.994a.076.076 0 0 0-.041-.106a13.107 13.107 0 0 1-1.872-.892a.077.077 0 0 1-.008-.128a10.2 10.2 0 0 0 .372-.292a.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127a12.299 12.299 0 0 1-1.873.892a.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028a19.839 19.839 0 0 0 6.002-3.03a.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.956-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.955-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.946 2.418-2.157 2.418z" /> 286 281 </svg> 287 282 {isDiscordLoading 288 - ? "Signing in..." 289 - : "Continue with Discord"} 283 + ? t("auth:signIn.signingIn") 284 + : t("auth:signIn.continueWithDiscord")} 290 285 </Button> 291 286 {lastLoginMethod === "discord" && ( 292 287 <span className="absolute rounded-md -top-3 right-1 px-1.5 text-xs text-primary font-medium bg-sidebar border border-primary/50"> 293 - Last used 288 + {t("auth:signIn.lastUsed")} 294 289 </span> 295 290 )} 296 291 </div> ··· 309 304 > 310 305 <KeyRound className="w-5 h-5 mr-2" /> 311 306 {isCustomOAuthLoading 312 - ? "Signing in..." 313 - : "Continue with OIDC"} 307 + ? t("auth:signIn.signingIn") 308 + : t("auth:signIn.continueWithOidc")} 314 309 </Button> 315 310 {lastLoginMethod === "custom" && ( 316 311 <span className="absolute rounded-md -top-3 right-1 px-1.5 text-xs text-primary font-medium bg-sidebar border border-primary/50"> 317 - Last used 312 + {t("auth:signIn.lastUsed")} 318 313 </span> 319 314 )} 320 315 </div> ··· 328 323 className="w-full" 329 324 > 330 325 <UserCheck className="w-4 h-4 mr-2" /> 331 - {isGuestLoading ? "Signing in..." : "Continue as guest"} 326 + {isGuestLoading 327 + ? t("auth:signIn.signingIn") 328 + : t("auth:signUp.continueAsGuest")} 332 329 </Button> 333 330 )} 334 331 </div> ··· 338 335 <div className="w-full border-t border-border" /> 339 336 </div> 340 337 <div className="relative flex justify-center text-sm"> 341 - <span className="px-2 bg-card text-muted-foreground">or</span> 338 + <span className="px-2 bg-card text-muted-foreground"> 339 + {t("auth:forms.or")} 340 + </span> 342 341 </div> 343 342 </div> 344 343 </> ··· 360 359 <div className="text-center pt-4"> 361 360 <p className="text-sm text-muted-foreground"> 362 361 {config?.disableRegistration 363 - ? "Public registration is disabled. Use an invitation to create an account." 364 - : "Password registration is disabled. Use a configured social or OIDC sign-in method to create an account."} 362 + ? t("auth:signIn.registrationDisabled") 363 + : t("auth:signIn.passwordRegistrationDisabled")} 365 364 </p> 366 365 </div> 367 366 ) : ( 368 367 <AuthToggle 369 - message="Don't have an account?" 370 - linkText="Create account" 368 + message={t("auth:signIn.toggleMessage")} 369 + linkText={t("auth:signIn.toggleLink")} 371 370 linkTo="/auth/sign-up" 372 371 /> 373 372 )}
+21 -19
apps/web/src/routes/auth/sign-up.tsx
··· 5 5 } from "@tanstack/react-router"; 6 6 import { UserCheck } from "lucide-react"; 7 7 import { useState } from "react"; 8 + import { useTranslation } from "react-i18next"; 8 9 import { z } from "zod/v4"; 9 10 import { AuthLayout } from "@/components/auth/layout"; 10 11 import { SignUpForm } from "@/components/auth/sign-up-form"; ··· 27 28 }); 28 29 29 30 function SignUp() { 31 + const { t } = useTranslation(); 30 32 const navigate = useNavigate(); 31 33 const search = useSearch({ from: "/auth/sign-up" }); 32 34 const [isGuestLoading, setIsGuestLoading] = useState(false); ··· 42 44 if (result.error) { 43 45 throw new Error(result.error.message); 44 46 } 45 - toast.success("Signed in as guest"); 47 + toast.success(t("auth:signIn.guestSuccess")); 46 48 navigate({ to: "/dashboard" }); 47 49 } catch (error) { 48 50 toast.error( 49 - error instanceof Error ? error.message : "Failed to sign in as guest", 51 + error instanceof Error ? error.message : t("auth:signIn.guestError"), 50 52 ); 51 53 } finally { 52 54 setIsGuestLoading(false); ··· 55 57 56 58 return ( 57 59 <> 58 - <PageTitle title="Create Account" /> 60 + <PageTitle title={t("auth:signUp.pageTitle")} /> 59 61 <AuthLayout 60 - title="Create account" 62 + title={t("auth:signUp.title")} 61 63 subtitle={ 62 64 invitationId 63 - ? "Create an account to accept your invitation" 65 + ? t("auth:signUp.subtitleInvitation") 64 66 : config?.disableRegistration 65 - ? "Registration requires an invitation" 67 + ? t("auth:signUp.subtitleRegistrationDisabled") 66 68 : config?.disablePasswordRegistration 67 - ? "Use social or OIDC sign-in to create an account" 68 - : "Get started with your workspace" 69 + ? t("auth:signUp.subtitlePasswordDisabled") 70 + : t("auth:signUp.subtitleDefault") 69 71 } 70 72 > 71 73 <div className="space-y-4 mt-6"> 72 74 {invitationId && ( 73 75 <Alert> 74 76 <AlertDescription> 75 - After creating your account, you'll be able to accept your 76 - workspace invitation. 77 + {t("auth:signUp.invitationAlert")} 77 78 </AlertDescription> 78 79 </Alert> 79 80 )} 80 81 {config?.disableRegistration && !invitationId && ( 81 82 <Alert> 82 83 <AlertDescription> 83 - Registration is currently disabled. If you were invited, enter 84 - the email address that received the invitation to create your 85 - account. 84 + {t("auth:signUp.registrationDisabledAlert")} 86 85 </AlertDescription> 87 86 </Alert> 88 87 )} 89 88 {config?.disablePasswordRegistration && ( 90 89 <Alert> 91 90 <AlertDescription> 92 - Password-based account creation is disabled. Use a configured 93 - social or OIDC sign-in method from the sign-in page. 91 + {t("auth:signUp.passwordDisabledAlert")} 94 92 </AlertDescription> 95 93 </Alert> 96 94 )} ··· 107 105 size="sm" 108 106 > 109 107 <UserCheck className="w-4 h-4 mr-2" /> 110 - {isGuestLoading ? "Signing in..." : "Continue as guest"} 108 + {isGuestLoading 109 + ? t("auth:signUp.signingIn") 110 + : t("auth:signUp.continueAsGuest")} 111 111 </Button> 112 112 <div className="flex items-center gap-4 my-4"> 113 113 <div className="flex-1 h-px bg-border" /> 114 - <span className="text-sm text-muted-foreground">or</span> 114 + <span className="text-sm text-muted-foreground"> 115 + {t("auth:forms.or")} 116 + </span> 115 117 <div className="flex-1 h-px bg-border" /> 116 118 </div> 117 119 </> ··· 123 125 /> 124 126 )} 125 127 <AuthToggle 126 - message="Already have an account?" 127 - linkText="Sign in" 128 + message={t("auth:signUp.toggleMessage")} 129 + linkText={t("auth:signUp.toggleLink")} 128 130 linkTo="/auth/sign-in" 129 131 /> 130 132 </div>
+38 -23
apps/web/src/routes/auth/verify-otp.tsx
··· 2 2 import { createFileRoute, useRouter, useSearch } from "@tanstack/react-router"; 3 3 import { REGEXP_ONLY_DIGITS } from "input-otp"; 4 4 import { ArrowLeft, RefreshCcw } from "lucide-react"; 5 - import { useCallback, useEffect, useState } from "react"; 5 + import { useCallback, useEffect, useMemo, useState } from "react"; 6 6 import { useForm } from "react-hook-form"; 7 + import { useTranslation } from "react-i18next"; 7 8 import { z } from "zod/v4"; 8 9 import PageTitle from "@/components/page-title"; 9 10 import { Alert, AlertDescription } from "@/components/ui/alert"; ··· 25 26 import { toast } from "@/lib/toast"; 26 27 import { AuthLayout } from "../../components/auth/layout"; 27 28 28 - const verifyOtpSchema = z.object({ 29 - otp: z.string().length(6, "Code must be 6 digits"), 30 - }); 31 - 32 - type VerifyOtpFormValues = z.infer<typeof verifyOtpSchema>; 33 - 34 29 export const Route = createFileRoute("/auth/verify-otp")({ 35 30 component: VerifyOtp, 36 31 validateSearch: (search: Record<string, unknown>) => ({ ··· 40 35 }); 41 36 42 37 function VerifyOtp() { 38 + const { t } = useTranslation(); 43 39 const { history } = useRouter(); 44 40 const { email, invitationId } = useSearch({ from: "/auth/verify-otp" }); 45 41 const [isPending, setIsPending] = useState(false); 46 42 43 + const verifyOtpSchema = useMemo( 44 + () => 45 + z.object({ 46 + otp: z.string().length(6, t("auth:verifyOtp.validation.codeLength")), 47 + }), 48 + [t], 49 + ); 50 + 51 + type VerifyOtpFormValues = z.infer<typeof verifyOtpSchema>; 52 + 47 53 const form = useForm<VerifyOtpFormValues>({ 48 54 resolver: standardSchemaResolver(verifyOtpSchema), 49 55 defaultValues: { otp: "" }, ··· 59 65 }); 60 66 61 67 if (result.error) { 62 - toast.error(result.error.message || "Invalid verification code"); 68 + toast.error( 69 + result.error.message || t("auth:verifyOtp.toast.invalidCode"), 70 + ); 63 71 return; 64 72 } 65 73 66 - toast.success("Signed in successfully!"); 74 + toast.success(t("auth:verifyOtp.toast.signedInSuccess")); 67 75 if (invitationId) { 68 76 history.push(`/invitation/accept/${invitationId}`); 69 77 } else { ··· 71 79 } 72 80 } catch (error) { 73 81 toast.error( 74 - error instanceof Error ? error.message : "Failed to verify code", 82 + error instanceof Error 83 + ? error.message 84 + : t("auth:verifyOtp.toast.verifyFailed"), 75 85 ); 76 86 } finally { 77 87 setIsPending(false); 78 88 } 79 89 }, 80 - [email, invitationId, history], 90 + [email, invitationId, history, t], 81 91 ); 82 92 83 93 useEffect(() => { ··· 98 108 }); 99 109 100 110 if (result.error) { 101 - toast.error(result.error.message || "Failed to resend code"); 111 + toast.error( 112 + result.error.message || t("auth:verifyOtp.toast.resendFailed"), 113 + ); 102 114 return; 103 115 } 104 116 105 - toast.success("New verification code sent!"); 117 + toast.success(t("auth:verifyOtp.toast.resendSuccess")); 106 118 form.reset(); 107 119 } catch (error) { 108 120 toast.error( 109 - error instanceof Error ? error.message : "Failed to resend code", 121 + error instanceof Error 122 + ? error.message 123 + : t("auth:verifyOtp.toast.resendFailed"), 110 124 ); 111 125 } finally { 112 126 setIsPending(false); ··· 115 129 116 130 return ( 117 131 <> 118 - <PageTitle title="Verify Code" /> 132 + <PageTitle title={t("auth:verifyOtp.pageTitle")} /> 119 133 <AuthLayout 120 - title="Enter verification code" 121 - subtitle="Use the 6-digit code sent to your email to continue" 134 + title={t("auth:verifyOtp.title")} 135 + subtitle={t("auth:verifyOtp.subtitle")} 122 136 > 123 137 <div className="space-y-4"> 124 138 <Alert> 125 139 <AlertDescription className="text-xs"> 126 - Code sent to{" "} 127 - <span className="font-mono text-foreground">{email}</span> 140 + {t("auth:verifyOtp.codeSentTo", { email })} 128 141 </AlertDescription> 129 142 </Alert> 130 143 ··· 136 149 render={({ field, fieldState }) => ( 137 150 <FormItem> 138 151 <FormLabel className="text-sm font-medium sr-only"> 139 - Verification Code 152 + {t("auth:verifyOtp.verificationCodeLabel")} 140 153 </FormLabel> 141 154 <FormControl> 142 155 <InputOTP ··· 165 178 /> 166 179 167 180 <Button type="submit" disabled={isPending} className="w-full"> 168 - {isPending ? "Verifying..." : "Verify & Sign In"} 181 + {isPending 182 + ? t("auth:verifyOtp.verifying") 183 + : t("auth:verifyOtp.verifyAndSignIn")} 169 184 </Button> 170 185 171 186 <div className="grid grid-cols-2 gap-2"> ··· 176 191 className="w-full" 177 192 > 178 193 <ArrowLeft className="size-4" /> 179 - Change email 194 + {t("auth:verifyOtp.changeEmail")} 180 195 </Button> 181 196 <Button 182 197 type="button" ··· 186 201 className="w-full" 187 202 > 188 203 <RefreshCcw className="size-4" /> 189 - Resend 204 + {t("auth:verifyOtp.resend")} 190 205 </Button> 191 206 </div> 192 207 </form>
+60 -35
apps/web/src/routes/invitation/accept.$inviteId.tsx
··· 14 14 XCircle, 15 15 } from "lucide-react"; 16 16 import { useState } from "react"; 17 + import { Trans, useTranslation } from "react-i18next"; 17 18 import PageTitle from "@/components/page-title"; 18 19 import { Alert, AlertDescription } from "@/components/ui/alert"; 19 20 import { Button } from "@/components/ui/button"; ··· 27 28 }); 28 29 29 30 function AcceptInvitation() { 31 + const { t } = useTranslation(); 30 32 const { inviteId } = useParams({ 31 33 from: "/invitation/accept/$inviteId", 32 34 }); ··· 52 54 }); 53 55 54 56 if (error) { 55 - toast.error(error.message || "Failed to accept invitation"); 57 + toast.error(error.message || t("auth:invitation.toast.acceptFailed")); 56 58 return; 57 59 } 58 60 ··· 60 62 organizationId: data?.invitation.organizationId, 61 63 }); 62 64 63 - toast.success("Invitation accepted! Welcome to the team."); 65 + toast.success(t("auth:invitation.toast.acceptSuccess")); 64 66 65 67 if (!session?.user?.name) { 66 68 navigate({ to: "/profile-setup" }); ··· 73 75 }); 74 76 } catch (error) { 75 77 toast.error( 76 - error instanceof Error ? error.message : "Failed to accept invitation", 78 + error instanceof Error 79 + ? error.message 80 + : t("auth:invitation.toast.acceptFailed"), 77 81 ); 78 82 } finally { 79 83 setIsAccepting(false); ··· 91 95 if (isLoading) { 92 96 return ( 93 97 <> 94 - <PageTitle title="Accept Invitation" /> 95 - <AuthLayout title="Loading invitation..."> 98 + <PageTitle title={t("auth:invitation.pageTitleAccept")} /> 99 + <AuthLayout title={t("auth:invitation.loadingTitle")}> 96 100 <div className="flex items-center justify-center py-8"> 97 101 <Loader2 className="w-6 h-6 animate-spin text-primary" /> 98 102 </div> ··· 104 108 if (invitationError || !invitationData) { 105 109 return ( 106 110 <> 107 - <PageTitle title="Invitation Error" /> 108 - <AuthLayout title="Invitation Error"> 111 + <PageTitle title={t("auth:invitation.pageTitleError")} /> 112 + <AuthLayout title={t("auth:invitation.errorTitle")}> 109 113 <div className="space-y-4 mt-4"> 110 114 <div className="flex items-center justify-center w-12 h-12 mx-auto bg-destructive/10 rounded-full"> 111 115 <XCircle className="w-6 h-6 text-destructive" /> ··· 113 117 <Alert variant="error"> 114 118 <AlertCircle className="h-4 w-4" /> 115 119 <AlertDescription> 116 - Failed to load invitation details. The invitation may be invalid 117 - or expired. 120 + {t("auth:invitation.errorLoadDescription")} 118 121 </AlertDescription> 119 122 </Alert> 120 123 <Button asChild variant="outline" className="w-full"> 121 - <Link to="/auth/sign-in">Go to Sign In</Link> 124 + <Link to="/auth/sign-in">{t("auth:invitation.goToSignIn")}</Link> 122 125 </Button> 123 126 </div> 124 127 </AuthLayout> ··· 129 132 if (!invitationData.valid) { 130 133 return ( 131 134 <> 132 - <PageTitle title="Invalid Invitation" /> 133 - <AuthLayout title="Invalid Invitation"> 135 + <PageTitle title={t("auth:invitation.pageTitleInvalid")} /> 136 + <AuthLayout title={t("auth:invitation.invalidTitle")}> 134 137 <div className="space-y-4 mt-4"> 135 138 <div className="flex items-center justify-center w-12 h-12 mx-auto bg-destructive/10 rounded-full"> 136 139 {invitationData.invitation?.expired ? ( ··· 143 146 <div className="space-y-3 text-center"> 144 147 <h2 className="text-lg font-semibold text-foreground"> 145 148 {invitationData.invitation?.expired 146 - ? "Invitation Expired" 147 - : "Invalid Invitation"} 149 + ? t("auth:invitation.invitationExpired") 150 + : t("auth:invitation.invalidTitle")} 148 151 </h2> 149 152 <p className="text-sm text-muted-foreground"> 150 153 {invitationData.error} 151 154 </p> 152 155 {invitationData.invitation && ( 153 156 <p className="text-xs text-muted-foreground"> 154 - Workspace: {invitationData.invitation.workspaceName} 157 + {t("auth:invitation.workspaceLabel", { 158 + workspaceName: invitationData.invitation.workspaceName, 159 + })} 155 160 </p> 156 161 )} 157 162 </div> 158 163 159 164 <Button asChild variant="outline" className="w-full"> 160 - <Link to="/auth/sign-in">Go to Sign In</Link> 165 + <Link to="/auth/sign-in">{t("auth:invitation.goToSignIn")}</Link> 161 166 </Button> 162 167 </div> 163 168 </AuthLayout> ··· 170 175 if (!invitation) { 171 176 return ( 172 177 <> 173 - <PageTitle title="Invalid Invitation" /> 174 - <AuthLayout title="Invalid Invitation"> 178 + <PageTitle title={t("auth:invitation.pageTitleInvalid")} /> 179 + <AuthLayout title={t("auth:invitation.invalidTitle")}> 175 180 <div className="space-y-4 mt-4"> 176 181 <div className="flex items-center justify-center w-12 h-12 mx-auto bg-destructive/10 rounded-full"> 177 182 <XCircle className="w-6 h-6 text-destructive" /> ··· 185 190 if (isSignedIn) { 186 191 return ( 187 192 <> 188 - <PageTitle title="Accept Invitation" /> 189 - <AuthLayout title="Accept Invitation"> 193 + <PageTitle title={t("auth:invitation.pageTitleAccept")} /> 194 + <AuthLayout title={t("auth:invitation.pageTitleAccept")}> 190 195 <div className="space-y-4 mt-4"> 191 196 <div className="flex items-center justify-center w-12 h-12 mx-auto bg-primary/10 rounded-full"> 192 197 <Users className="w-6 h-6 text-primary" /> ··· 194 199 195 200 <div className="space-y-3 text-center"> 196 201 <h2 className="text-lg font-semibold text-foreground"> 197 - Join {invitation.workspaceName} 202 + {t("auth:invitation.joinWorkspace", { 203 + workspaceName: invitation.workspaceName, 204 + })} 198 205 </h2> 199 206 <p className="text-sm text-muted-foreground"> 200 - <strong>{invitation.inviterName}</strong> has invited you to 201 - join their workspace. 207 + <Trans 208 + i18nKey="auth:invitation.inviteBodySignedIn" 209 + values={{ inviterName: invitation.inviterName }} 210 + components={{ inviter: <strong /> }} 211 + /> 202 212 </p> 203 213 </div> 204 214 ··· 211 221 {isAccepting ? ( 212 222 <> 213 223 <Loader2 className="w-4 h-4 mr-2 animate-spin" /> 214 - Accepting... 224 + {t("auth:invitation.accepting")} 215 225 </> 216 226 ) : ( 217 227 <> 218 228 <CheckCircle className="w-4 h-4 mr-2" /> 219 - Accept Invitation 229 + {t("auth:invitation.acceptInvitation")} 220 230 </> 221 231 )} 222 232 </Button> 223 233 224 234 <Button asChild variant="outline" className="w-full"> 225 - <Link to="/dashboard">Go to Dashboard</Link> 235 + <Link to="/dashboard"> 236 + {t("auth:invitation.goToDashboard")} 237 + </Link> 226 238 </Button> 227 239 </div> 228 240 229 241 <div className="pt-4 border-t border-border"> 230 242 <p className="text-xs text-center text-muted-foreground"> 231 - Signed in as <strong>{session.user.email}</strong> 243 + <Trans 244 + i18nKey="auth:invitation.signedInAs" 245 + values={{ email: session.user.email }} 246 + components={{ email: <strong /> }} 247 + /> 232 248 </p> 233 249 </div> 234 250 </div> ··· 239 255 240 256 return ( 241 257 <> 242 - <PageTitle title="Accept Invitation" /> 243 - <AuthLayout title="You've been invited!"> 258 + <PageTitle title={t("auth:invitation.pageTitleAccept")} /> 259 + <AuthLayout title={t("auth:invitation.youveBeenInvited")}> 244 260 <div className="space-y-4 mt-4"> 245 261 <div className="flex items-center justify-center w-12 h-12 mx-auto bg-primary/10 rounded-full"> 246 262 <Users className="w-6 h-6 text-primary" /> ··· 248 264 249 265 <div className="space-y-3 text-center"> 250 266 <h2 className="text-lg font-semibold text-foreground"> 251 - Join {invitation.workspaceName} 267 + {t("auth:invitation.joinWorkspace", { 268 + workspaceName: invitation.workspaceName, 269 + })} 252 270 </h2> 253 271 <p className="text-sm text-muted-foreground"> 254 - <strong>{invitation.inviterName}</strong> has invited you to join 255 - their workspace on Kaneo. 272 + <Trans 273 + i18nKey="auth:invitation.inviteBodySignedOut" 274 + values={{ inviterName: invitation.inviterName }} 275 + components={{ inviter: <strong /> }} 276 + /> 256 277 </p> 257 278 <p className="text-sm text-muted-foreground"> 258 - Sign in to accept this invitation. 279 + {t("auth:invitation.signInToAccept")} 259 280 </p> 260 281 </div> 261 282 262 283 <div className="space-y-3 pt-2"> 263 284 <Button onClick={handleSignIn} className="w-full"> 264 285 <LogIn className="w-4 h-4 mr-2" /> 265 - Sign In 286 + {t("auth:invitation.signIn")} 266 287 </Button> 267 288 </div> 268 289 269 290 <div className="pt-4 border-t border-border"> 270 291 <div className="text-center space-y-1"> 271 292 <p className="text-xs text-muted-foreground"> 272 - Invitation for: <strong>{invitation.email}</strong> 293 + <Trans 294 + i18nKey="auth:invitation.invitationFor" 295 + values={{ email: invitation.email }} 296 + components={{ email: <strong /> }} 297 + /> 273 298 </p> 274 299 </div> 275 300 </div>
+7 -5
apps/web/src/routes/public-project.$projectId.tsx
··· 1 1 import { createFileRoute } from "@tanstack/react-router"; 2 2 import { Layout, List } from "lucide-react"; 3 3 import { createElement, useEffect, useState } from "react"; 4 + import { useTranslation } from "react-i18next"; 4 5 import PageTitle from "@/components/page-title"; 5 6 import { CopyUrlButton } from "@/components/public-project/copy-url-button"; 6 7 import { ErrorView } from "@/components/public-project/error-view"; ··· 24 25 const VIEW_MODE_STORAGE_KEY = "kaneo-public-view-mode"; 25 26 26 27 function RouteComponent() { 28 + const { t } = useTranslation(); 27 29 const { projectId } = Route.useParams(); 28 30 const { data: project, isLoading, error } = useGetPublicProject(projectId); 29 31 ··· 62 64 63 65 return ( 64 66 <> 65 - <PageTitle title="Public View" /> 67 + <PageTitle title={t("publicProject:pageTitle")} /> 66 68 <div className="min-h-screen bg-background flex flex-col w-full"> 67 69 <header className="border-b border-border sticky top-0 z-10 bg-background"> 68 70 <div className="px-6 py-2.5"> ··· 82 84 {project.name} 83 85 </h1> 84 86 <span className="px-1.5 py-0.5 text-[10px] bg-muted text-muted-foreground rounded font-medium shrink-0"> 85 - Public 87 + {t("publicProject:badge")} 86 88 </span> 87 89 </div> 88 90 {project.description && ( ··· 105 107 className={`h-8 gap-2 ${viewMode === "kanban" ? "bg-accent" : ""}`} 106 108 > 107 109 <Layout className="h-3 w-3" /> 108 - <span className="text-xs">Board</span> 110 + <span className="text-xs">{t("tasks:view.board")}</span> 109 111 </Button> 110 112 <Button 111 113 variant="ghost" ··· 114 116 className={`h-8 gap-2 ${viewMode === "list" ? "bg-accent" : ""}`} 115 117 > 116 118 <List className="h-3 w-3" /> 117 - <span className="text-xs">List</span> 119 + <span className="text-xs">{t("tasks:view.list")}</span> 118 120 </Button> 119 121 </div> 120 122 </div> ··· 134 136 <div className="px-6 py-3"> 135 137 <div className="flex items-center justify-between text-xs text-muted-foreground"> 136 138 <KaneoBranding /> 137 - <span>Read-only</span> 139 + <span>{t("publicProject:readOnly")}</span> 138 140 </div> 139 141 </div> 140 142 </footer>
+2
apps/web/src/types/notification.ts
··· 5 5 InferResponseType<(typeof client)["notification"]["$get"]>[number], 6 6 { id: string } 7 7 >; 8 + 9 + export type NotificationEventData = Record<string, unknown> | null | undefined;