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 components and task filter hooks

Tin 06aee41a 8132bab6

+2245 -1318
+16 -13
apps/web/src/components/activity/comment-card.tsx
··· 1 1 import { useQueryClient } from "@tanstack/react-query"; 2 - import { formatDistanceToNow } from "date-fns"; 3 2 import { ExternalLink, Github, Pencil } from "lucide-react"; 4 3 import { useCallback, useState } from "react"; 4 + import { useTranslation } from "react-i18next"; 5 5 import CommentEditor from "@/components/activity/comment-editor"; 6 6 import { useAuth } from "@/components/providers/auth-provider/hooks/use-auth"; 7 7 import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; ··· 18 18 TooltipTrigger, 19 19 } from "@/components/ui/tooltip"; 20 20 import useUpdateComment from "@/hooks/mutations/comment/use-update-comment"; 21 + import { formatRelativeTime } from "@/lib/format"; 21 22 import { toast } from "@/lib/toast"; 22 23 23 24 type CommentCardProps = { ··· 44 45 externalSource, 45 46 externalUrl, 46 47 }: CommentCardProps) { 48 + const { t } = useTranslation(); 47 49 const { user: currentUser } = useAuth(); 48 50 const [isEditing, setIsEditing] = useState(false); 49 51 const [editedContent, setEditedContent] = useState(content); ··· 68 70 69 71 const handleSave = useCallback(async () => { 70 72 if (!editedContent.trim()) { 71 - toast.error("Comment cannot be empty"); 73 + toast.error(t("activity:comment.cannotBeEmpty")); 72 74 return; 73 75 } 74 76 75 77 if (!currentUser?.id) { 76 - toast.error("You must be logged in to edit comments"); 78 + toast.error(t("activity:comment.mustBeLoggedInToEdit")); 77 79 return; 78 80 } 79 81 ··· 85 87 86 88 setIsEditing(false); 87 89 await queryClient.invalidateQueries({ queryKey: ["activities", taskId] }); 88 - toast.success("Comment updated"); 90 + toast.success(t("activity:comment.updated")); 89 91 } catch (error) { 90 92 console.error("Failed to update comment:", error); 91 - toast.error("Failed to update comment"); 93 + toast.error(t("activity:comment.failedToUpdate")); 92 94 } 93 95 }, [ 94 96 commentId, 95 97 currentUser?.id, 96 98 editedContent, 97 99 queryClient, 100 + t, 98 101 taskId, 99 102 updateComment, 100 103 ]); ··· 137 140 <div className="mt-1.5 flex items-center gap-1"> 138 141 <Github className="size-3 text-muted-foreground" /> 139 142 <span className="text-xs text-muted-foreground"> 140 - GitHub 143 + {t("activity:comment.github")} 141 144 </span> 142 145 </div> 143 146 )} ··· 151 154 className="mt-3 flex items-center gap-1.5 border-t border-border pt-3 text-xs text-muted-foreground transition-colors hover:text-foreground" 152 155 > 153 156 <ExternalLink className="size-3" /> 154 - View GitHub Profile 157 + {t("activity:comment.viewGithubProfile")} 155 158 </a> 156 159 )} 157 160 </HoverCardContent> 158 161 </HoverCard> 159 162 160 163 <span className="text-xs text-muted-foreground/62"> 161 - {formatDistanceToNow(createdAt, { addSuffix: true })} 164 + {formatRelativeTime(createdAt)} 162 165 </span> 163 166 164 167 {commentUrl && ( ··· 171 174 className="flex items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground" 172 175 > 173 176 <Github className="size-3" /> 174 - commented on GitHub 177 + {t("activity:comment.commentedOnGithub")} 175 178 </a> 176 179 </> 177 180 )} ··· 191 194 </Button> 192 195 </TooltipTrigger> 193 196 <TooltipContent> 194 - <p className="text-xs">Edit comment</p> 197 + <p className="text-xs">{t("activity:comment.edit")}</p> 195 198 </TooltipContent> 196 199 </Tooltip> 197 200 </TooltipProvider> ··· 201 204 <CommentEditor 202 205 value={isEditing ? editedContent : content} 203 206 onChange={isEditing ? setEditedContent : undefined} 204 - placeholder="Edit comment..." 207 + placeholder={t("activity:comment.editPlaceholder")} 205 208 taskId={taskId} 206 209 uploadSurface="comment" 207 210 className={ ··· 225 228 disabled={isPending} 226 229 className="h-7 px-2.5 text-xs" 227 230 > 228 - Cancel 231 + {t("common:actions.cancel")} 229 232 </Button> 230 233 <Button 231 234 variant="default" ··· 234 237 disabled={isPending || !editedContent.trim()} 235 238 className="h-7 px-2.5 text-xs" 236 239 > 237 - Save 240 + {t("activity:comment.save")} 238 241 </Button> 239 242 </div> 240 243 )}
+128 -86
apps/web/src/components/activity/comment-editor.tsx
··· 28 28 } from "lucide-react"; 29 29 import type { MouseEvent as ReactMouseEvent } from "react"; 30 30 import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 31 + import { useTranslation } from "react-i18next"; 31 32 import { bundledLanguages, type Highlighter } from "shiki"; 32 33 import { AttachmentCard } from "@/components/task/extensions/attachment-card"; 33 34 import { EmbedBlock } from "@/components/task/extensions/embed-block"; ··· 108 109 left: number; 109 110 }; 110 111 111 - const COMMENT_CODE_LANGUAGE_OPTIONS = [ 112 - { value: "bash", label: "Bash" }, 113 - { value: "csharp", label: "C#" }, 114 - { value: "cpp", label: "C++" }, 115 - { value: "css", label: "CSS" }, 116 - { value: "go", label: "Golang" }, 117 - { value: "graphql", label: "GraphQL" }, 118 - { value: "html", label: "HTML" }, 119 - { value: "json", label: "JSON" }, 120 - { value: "java", label: "Java" }, 121 - { value: "javascript", label: "JavaScript" }, 122 - { value: "markdown", label: "Markdown" }, 123 - { value: "plaintext", label: "Plaintext" }, 124 - { value: "python", label: "Python" }, 125 - { value: "rust", label: "Rust" }, 126 - { value: "sql", label: "SQL" }, 127 - { value: "swift", label: "Swift" }, 128 - { value: "typescript", label: "TypeScript" }, 129 - { value: "yaml", label: "YAML" }, 130 - ]; 112 + const CODE_LANG_VALUES = [ 113 + "bash", 114 + "csharp", 115 + "cpp", 116 + "css", 117 + "go", 118 + "graphql", 119 + "html", 120 + "json", 121 + "java", 122 + "javascript", 123 + "markdown", 124 + "plaintext", 125 + "python", 126 + "rust", 127 + "sql", 128 + "swift", 129 + "typescript", 130 + "yaml", 131 + ] as const; 132 + 133 + type EmbedComposerErrorKey = "embedErrorInvalidUrl" | "embedErrorYoutubeOnly"; 131 134 132 135 const COMMENT_SHIKI_LANGUAGE_ALIASES: Record<string, string> = { 133 136 plaintext: "text", ··· 154 157 export default function CommentEditor({ 155 158 value, 156 159 onChange, 157 - placeholder = "Leave a comment...", 160 + placeholder, 158 161 className, 159 162 contentClassName, 160 163 proseClassName, ··· 171 174 showQuickAttachButton = true, 172 175 onAttachActionChange, 173 176 }: CommentEditorProps) { 177 + const { t } = useTranslation(); 178 + const resolvedPlaceholder = 179 + placeholder ?? t("activity:comment.leavePlaceholder"); 174 180 const editorShellRef = useRef<HTMLDivElement | null>(null); 175 181 const imageInputRef = useRef<HTMLInputElement | null>(null); 176 182 const dragDepthRef = useRef(0); ··· 200 206 const [embedComposer, setEmbedComposer] = useState<EmbedComposerState | null>( 201 207 null, 202 208 ); 203 - const [embedComposerError, setEmbedComposerError] = useState(""); 209 + const [embedComposerError, setEmbedComposerError] = 210 + useState<EmbedComposerErrorKey | null>(null); 204 211 const [isDragActive, setIsDragActive] = useState(false); 205 212 const [previewImage, setPreviewImage] = useState<{ 206 213 src: string; 207 214 alt: string; 208 215 } | null>(null); 209 - const codeLanguages = useMemo(() => COMMENT_CODE_LANGUAGE_OPTIONS, []); 216 + const codeLanguages = useMemo( 217 + () => 218 + CODE_LANG_VALUES.map((value) => ({ 219 + value, 220 + label: t(`activity:comment.editor.codeLang.${value}`), 221 + })), 222 + [t], 223 + ); 210 224 const availableShikiLanguages = useMemo( 211 225 () => new Set(Object.keys(bundledLanguages)), 212 226 [], ··· 293 307 taskIdRef.current ?? (await ensureTaskIdRef.current?.()); 294 308 295 309 if (!activeEditor || !resolvedTaskId) { 296 - toast.error("File uploads are only available on saved tasks."); 310 + toast.error(t("activity:comment.editor.uploadsOnlyOnSavedTasks")); 297 311 return; 298 312 } 299 313 300 - const loadingToast = toast.loading("Uploading file..."); 314 + const loadingToast = toast.loading( 315 + t("activity:comment.editor.uploadingFile"), 316 + ); 301 317 302 318 try { 303 319 const uploadedAsset = await uploadTaskImage({ ··· 309 325 310 326 toast.dismiss(loadingToast); 311 327 toast.success( 312 - uploadedAsset.kind === "image" ? "Image uploaded" : "File attached", 328 + uploadedAsset.kind === "image" 329 + ? t("activity:comment.editor.imageUploaded") 330 + : t("activity:comment.editor.fileAttached"), 313 331 ); 314 332 } catch (error) { 315 333 toast.dismiss(loadingToast); 316 334 toast.error( 317 - error instanceof Error ? error.message : "Failed to upload file", 335 + error instanceof Error 336 + ? error.message 337 + : t("activity:comment.editor.failedToUploadFile"), 318 338 ); 319 339 } 320 340 }, 321 - [insertUploadedAsset], 341 + [insertUploadedAsset, t], 322 342 ); 323 343 324 344 const canUploadFiles = Boolean(taskId || ensureTaskId); ··· 394 414 () => [ 395 415 { 396 416 id: "paragraph", 397 - label: "Text", 417 + label: t("activity:comment.editor.slashParagraph"), 398 418 group: "text", 399 - search: "text paragraph normal", 419 + search: t("activity:comment.editor.searchParagraph"), 400 420 run: (activeEditor, range) => { 401 421 activeEditor.chain().focus().deleteRange(range).setParagraph().run(); 402 422 }, 403 423 }, 404 424 { 405 425 id: "heading-2", 406 - label: "Heading", 426 + label: t("activity:comment.editor.slashHeading"), 407 427 group: "text", 408 428 shortcut: "Ctrl Alt 2", 409 - search: "heading title h2", 429 + search: t("activity:comment.editor.searchHeading"), 410 430 run: (activeEditor, range) => { 411 431 activeEditor 412 432 .chain() ··· 418 438 }, 419 439 { 420 440 id: "bullet-list", 421 - label: "Bulleted list", 441 + label: t("activity:comment.editor.slashBulletList"), 422 442 group: "lists", 423 443 shortcut: "Ctrl Alt 8", 424 - search: "list bullet unordered", 444 + search: t("activity:comment.editor.searchBulletList"), 425 445 run: (activeEditor, range) => { 426 446 activeEditor 427 447 .chain() ··· 433 453 }, 434 454 { 435 455 id: "task-list", 436 - label: "To-do list", 456 + label: t("activity:comment.editor.slashTaskList"), 437 457 group: "lists", 438 - search: "todo to-do checklist checkbox task list", 458 + search: t("activity:comment.editor.searchTaskList"), 439 459 run: (activeEditor, range) => { 440 460 activeEditor 441 461 .chain() ··· 447 467 }, 448 468 { 449 469 id: "ordered-list", 450 - label: "Numbered list", 470 + label: t("activity:comment.editor.slashOrderedList"), 451 471 group: "lists", 452 472 shortcut: "Ctrl Alt 9", 453 - search: "list ordered numbered", 473 + search: t("activity:comment.editor.searchOrderedList"), 454 474 run: (activeEditor, range) => { 455 475 activeEditor 456 476 .chain() ··· 462 482 }, 463 483 { 464 484 id: "blockquote", 465 - label: "Quote", 485 + label: t("activity:comment.editor.slashQuote"), 466 486 group: "insert", 467 - search: "quote blockquote", 487 + search: t("activity:comment.editor.searchQuote"), 468 488 run: (activeEditor, range) => { 469 489 activeEditor 470 490 .chain() ··· 476 496 }, 477 497 { 478 498 id: "code-block", 479 - label: "Code block", 499 + label: t("activity:comment.editor.slashCodeBlock"), 480 500 group: "insert", 481 501 shortcut: "Ctrl Alt \\", 482 - search: "code snippet", 502 + search: t("activity:comment.editor.searchCodeBlock"), 483 503 run: (activeEditor, range) => { 484 504 activeEditor 485 505 .chain() ··· 491 511 }, 492 512 { 493 513 id: "table", 494 - label: "Table", 514 + label: t("activity:comment.editor.slashTable"), 495 515 group: "insert", 496 - search: "table grid", 516 + search: t("activity:comment.editor.searchTable"), 497 517 run: (activeEditor, range) => { 498 518 activeEditor 499 519 .chain() ··· 505 525 }, 506 526 { 507 527 id: "file", 508 - label: "File", 528 + label: t("activity:comment.editor.slashFile"), 509 529 group: "insert", 510 - search: "file attachment image photo picture upload", 530 + search: t("activity:comment.editor.searchFile"), 511 531 run: (activeEditor, range) => { 512 532 activeEditor.chain().focus().deleteRange(range).run(); 513 533 openImagePicker(activeEditor); 514 534 }, 515 535 }, 516 536 ], 517 - [openImagePicker], 537 + [openImagePicker, t], 518 538 ); 519 539 520 540 useEffect(() => { ··· 585 605 nested: true, 586 606 }), 587 607 Placeholder.configure({ 588 - placeholder, 608 + placeholder: resolvedPlaceholder, 589 609 }), 590 610 Table.configure({ 591 611 resizable: true, ··· 673 693 left: coords.left, 674 694 linkRange: { from, to: from + url.length }, 675 695 }); 676 - setEmbedComposerError(""); 696 + setEmbedComposerError(null); 677 697 return true; 678 698 }, 679 699 handleDrop: (view, event) => { ··· 835 855 onChange(markdown); 836 856 }, 837 857 }, 838 - [handleAssetFileUpload, toShikiLanguage], 858 + [handleAssetFileUpload, resolvedPlaceholder, toShikiLanguage], 839 859 ); 840 860 841 861 useEffect(() => { ··· 891 911 event.preventDefault(); 892 912 setPreviewImage({ 893 913 src: target.currentSrc || target.src, 894 - alt: target.alt || "Preview image", 914 + alt: target.alt || t("activity:comment.editor.previewImageAlt"), 895 915 }); 896 916 }; 897 917 ··· 901 921 return () => { 902 922 dom.removeEventListener("click", handleImagePreviewClick); 903 923 }; 904 - }, [editor]); 924 + }, [editor, t]); 905 925 906 926 const updateSlashMenu = useCallback( 907 927 (activeEditor: Editor) => { ··· 1010 1030 const setLink = useCallback(() => { 1011 1031 if (readOnly || disabled || !editor) return; 1012 1032 const previousUrl = editor.getAttributes("link").href as string | undefined; 1013 - const url = window.prompt("Enter URL", previousUrl || ""); 1033 + const url = window.prompt( 1034 + t("activity:comment.editor.enterUrl"), 1035 + previousUrl || "", 1036 + ); 1014 1037 if (url === null) return; 1015 1038 if (!url.trim()) { 1016 1039 editor.chain().focus().extendMarkRange("link").unsetLink().run(); 1017 1040 return; 1018 1041 } 1019 1042 editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run(); 1020 - }, [disabled, editor, readOnly]); 1043 + }, [disabled, editor, readOnly, t]); 1021 1044 1022 1045 const resolveCodeBlockNodeData = useCallback( 1023 1046 (pos: number) => { ··· 1115 1138 ); 1116 1139 1117 1140 const activeCodeLanguageLabel = useMemo(() => { 1118 - if (!hoveredCodeBlock) return "Plaintext"; 1119 - if (hoveredCodeBlock.language === "auto") return "Auto detect"; 1141 + if (!hoveredCodeBlock) return t("activity:comment.editor.plaintext"); 1142 + if (hoveredCodeBlock.language === "auto") 1143 + return t("activity:comment.editor.autoDetect"); 1120 1144 1121 1145 const match = codeLanguages.find( 1122 1146 (option) => option.value === hoveredCodeBlock.language, 1123 1147 ); 1124 1148 return match?.label || hoveredCodeBlock.language; 1125 - }, [codeLanguages, hoveredCodeBlock]); 1149 + }, [codeLanguages, hoveredCodeBlock, t]); 1126 1150 1127 1151 useEffect(() => { 1128 1152 if (!hoveredCodeBlock || isCodeLanguageMenuOpen) return; ··· 1148 1172 const groupedSlashCommands = useMemo( 1149 1173 () => [ 1150 1174 { 1151 - title: "Text", 1175 + title: t("activity:comment.editor.slashGroupText"), 1152 1176 items: filteredSlashCommands.filter( 1153 1177 (command) => command.group === "text", 1154 1178 ), 1155 1179 }, 1156 1180 { 1157 - title: "Lists", 1181 + title: t("activity:comment.editor.slashGroupLists"), 1158 1182 items: filteredSlashCommands.filter( 1159 1183 (command) => command.group === "lists", 1160 1184 ), 1161 1185 }, 1162 1186 { 1163 - title: "Insert", 1187 + title: t("activity:comment.editor.slashGroupInsert"), 1164 1188 items: filteredSlashCommands.filter( 1165 1189 (command) => command.group === "insert", 1166 1190 ), 1167 1191 }, 1168 1192 ], 1169 - [filteredSlashCommands], 1193 + [filteredSlashCommands, t], 1170 1194 ); 1171 1195 1172 1196 const submitEmbedComposer = useCallback( ··· 1174 1198 if (!editor || !embedComposer) return; 1175 1199 const url = normalizeUrl(embedComposer.url); 1176 1200 if (!url) { 1177 - setEmbedComposerError("Enter a valid URL"); 1201 + setEmbedComposerError("embedErrorInvalidUrl"); 1178 1202 return; 1179 1203 } 1180 1204 1181 1205 const chain = editor.chain().focus(); 1182 1206 if (embedComposer.mode === "choice" && mode === "link") { 1183 1207 setEmbedComposer(null); 1184 - setEmbedComposerError(""); 1208 + setEmbedComposerError(null); 1185 1209 return; 1186 1210 } 1187 1211 ··· 1208 1232 .run(); 1209 1233 } else { 1210 1234 if (!isYouTubeUrl(url)) { 1211 - setEmbedComposerError("Only YouTube links can be embedded."); 1235 + setEmbedComposerError("embedErrorYoutubeOnly"); 1212 1236 return; 1213 1237 } 1214 1238 chain ··· 1223 1247 } 1224 1248 1225 1249 setEmbedComposer(null); 1226 - setEmbedComposerError(""); 1250 + setEmbedComposerError(null); 1227 1251 }, 1228 1252 [editor, embedComposer], 1229 1253 ); ··· 1251 1275 if (event.key === "Escape") { 1252 1276 event.preventDefault(); 1253 1277 setEmbedComposer(null); 1254 - setEmbedComposerError(""); 1278 + setEmbedComposerError(null); 1255 1279 return; 1256 1280 } 1257 1281 ··· 1265 1289 if (event.key === "Escape") { 1266 1290 event.preventDefault(); 1267 1291 setEmbedComposer(null); 1268 - setEmbedComposerError(""); 1292 + setEmbedComposerError(null); 1269 1293 } 1270 1294 }; 1271 1295 ··· 1362 1386 return ( 1363 1387 <section 1364 1388 ref={editorShellRef} 1365 - aria-label={readOnly ? "Comment content" : "Comment editor"} 1389 + aria-label={ 1390 + readOnly 1391 + ? t("activity:comment.editor.ariaCommentContent") 1392 + : t("activity:comment.editor.ariaCommentEditor") 1393 + } 1366 1394 className={cn( 1367 1395 "kaneo-comment-editor-shell", 1368 1396 isDragActive && "is-drag-active", ··· 1407 1435 <button 1408 1436 type="button" 1409 1437 className="kaneo-codeblock-language-trigger kaneo-codeblock-copy-trigger" 1410 - aria-label={isCodeCopied ? "Copied" : "Copy code"} 1438 + aria-label={ 1439 + isCodeCopied 1440 + ? t("activity:comment.editor.ariaCopied") 1441 + : t("activity:comment.editor.ariaCopyCode") 1442 + } 1411 1443 onMouseDown={(event) => { 1412 1444 event.preventDefault(); 1413 1445 }} ··· 1420 1452 ) : ( 1421 1453 <Copy className="size-3.5" /> 1422 1454 )} 1423 - <span>{isCodeCopied ? "Copied" : "Copy"}</span> 1455 + <span> 1456 + {isCodeCopied 1457 + ? t("activity:comment.editor.copied") 1458 + : t("activity:comment.editor.copy")} 1459 + </span> 1424 1460 </button> 1425 1461 {!readOnly && ( 1426 1462 <DropdownMenu ··· 1447 1483 onValueChange={setCodeLanguage} 1448 1484 > 1449 1485 <DropdownMenuRadioItem value="auto"> 1450 - Auto detect 1486 + {t("activity:comment.editor.autoDetect")} 1451 1487 </DropdownMenuRadioItem> 1452 1488 <DropdownMenuSeparator /> 1453 1489 {codeLanguages.map(({ value, label }) => ( ··· 1631 1667 ); 1632 1668 }) 1633 1669 ) : ( 1634 - <div className="kaneo-tiptap-slash-empty">No commands</div> 1670 + <div className="kaneo-tiptap-slash-empty"> 1671 + {t("activity:comment.editor.noCommands")} 1672 + </div> 1635 1673 )} 1636 1674 </div> 1637 1675 )} ··· 1654 1692 submitEmbedComposer("embed"); 1655 1693 }} 1656 1694 > 1657 - <span>Embed video</span> 1658 - <span className="kaneo-embed-choice-hint">Tab</span> 1695 + <span>{t("activity:comment.editor.embedVideo")}</span> 1696 + <span className="kaneo-embed-choice-hint"> 1697 + {t("activity:comment.editor.hintTab")} 1698 + </span> 1659 1699 </button> 1660 1700 <button 1661 1701 type="button" ··· 1663 1703 onMouseDown={(event) => { 1664 1704 event.preventDefault(); 1665 1705 setEmbedComposer(null); 1666 - setEmbedComposerError(""); 1706 + setEmbedComposerError(null); 1667 1707 }} 1668 1708 > 1669 - <span>Keep as link</span> 1670 - <span className="kaneo-embed-choice-hint">Esc</span> 1709 + <span>{t("activity:comment.editor.keepAsLink")}</span> 1710 + <span className="kaneo-embed-choice-hint"> 1711 + {t("activity:comment.editor.hintEsc")} 1712 + </span> 1671 1713 </button> 1672 1714 </div> 1673 1715 ) : ( ··· 1685 1727 setEmbedComposer((current) => 1686 1728 current ? { ...current, url: event.target.value } : current, 1687 1729 ); 1688 - if (embedComposerError) setEmbedComposerError(""); 1730 + if (embedComposerError) setEmbedComposerError(null); 1689 1731 }} 1690 - placeholder="Paste URL" 1732 + placeholder={t("activity:comment.editor.pasteUrl")} 1691 1733 autoFocus 1692 1734 /> 1693 1735 <div className="kaneo-embed-composer-actions"> ··· 1697 1739 variant="ghost" 1698 1740 onClick={() => submitEmbedComposer("link")} 1699 1741 > 1700 - As link 1742 + {t("activity:comment.editor.asLink")} 1701 1743 </Button> 1702 1744 <Button type="submit" size="xs"> 1703 - Embed 1745 + {t("activity:comment.editor.embed")} 1704 1746 </Button> 1705 1747 <Button 1706 1748 type="button" ··· 1708 1750 variant="ghost" 1709 1751 onClick={() => { 1710 1752 setEmbedComposer(null); 1711 - setEmbedComposerError(""); 1753 + setEmbedComposerError(null); 1712 1754 }} 1713 1755 > 1714 - Cancel 1756 + {t("common:actions.cancel")} 1715 1757 </Button> 1716 1758 </div> 1717 1759 {embedComposerError && ( 1718 1760 <p className="kaneo-embed-composer-error"> 1719 - {embedComposerError} 1761 + {t(`activity:comment.editor.${embedComposerError}`)} 1720 1762 </p> 1721 1763 )} 1722 1764 </form> ··· 1737 1779 event.preventDefault(); 1738 1780 }} 1739 1781 onClick={() => openImagePicker(editor)} 1740 - aria-label="Attach file" 1782 + aria-label={t("activity:comment.attachFile")} 1741 1783 > 1742 1784 <Paperclip className="size-3.5" /> 1743 1785 </button> 1744 1786 )} 1745 1787 {isDragActive && ( 1746 1788 <div className="kaneo-editor-drop-indicator"> 1747 - <span>Drop image to upload</span> 1789 + <span>{t("activity:comment.editor.dropImageToUpload")}</span> 1748 1790 </div> 1749 1791 )} 1750 1792 <Dialog
+9 -7
apps/web/src/components/activity/comment-input.tsx
··· 1 1 import { useQueryClient } from "@tanstack/react-query"; 2 2 import { ArrowUp, Paperclip } from "lucide-react"; 3 3 import { useCallback, useState } from "react"; 4 + import { useTranslation } from "react-i18next"; 4 5 import CommentEditor from "@/components/activity/comment-editor"; 5 6 import { Button } from "@/components/ui/button"; 6 7 import { KbdSequence } from "@/components/ui/kbd"; ··· 20 21 }; 21 22 22 23 export default function CommentInput({ taskId }: CommentInputProps) { 24 + const { t } = useTranslation(); 23 25 const [content, setContent] = useState(""); 24 26 const [attachAction, setAttachAction] = useState<(() => void) | null>(null); 25 27 const { mutateAsync: createComment, isPending } = useCreateComment(); ··· 27 29 28 30 const handleSubmit = useCallback(async () => { 29 31 if (!content.trim()) { 30 - toast.error("Comment cannot be empty"); 32 + toast.error(t("activity:comment.cannotBeEmpty")); 31 33 return; 32 34 } 33 35 ··· 40 42 setContent(""); 41 43 await queryClient.invalidateQueries({ queryKey: ["activities", taskId] }); 42 44 43 - toast.success("Comment added"); 45 + toast.success(t("activity:comment.added")); 44 46 } catch (error) { 45 47 console.error("Failed to create comment:", error); 46 - toast.error("Failed to add comment"); 48 + toast.error(t("activity:comment.failedToAdd")); 47 49 } 48 - }, [content, createComment, taskId, queryClient]); 50 + }, [content, createComment, queryClient, t, taskId]); 49 51 50 52 const handleAttachActionChange = useCallback( 51 53 (nextAttachAction: (() => void) | null) => { ··· 60 62 <CommentEditor 61 63 value={content} 62 64 onChange={setContent} 63 - placeholder="Leave a comment..." 65 + placeholder={t("activity:comment.leavePlaceholder")} 64 66 taskId={taskId} 65 67 uploadSurface="comment" 66 68 showQuickAttachButton={false} ··· 75 77 onClick={() => attachAction?.()} 76 78 disabled={!attachAction} 77 79 className="text-muted-foreground" 78 - aria-label="Attach file" 80 + aria-label={t("activity:comment.attachFile")} 79 81 > 80 82 <Paperclip className="size-3.5" /> 81 83 </Button> ··· 100 102 <TooltipContent> 101 103 <KbdSequence 102 104 keys={[getModifierKeyText(), "Enter"]} 103 - description="Submit comment" 105 + description={t("activity:comment.submitShortcut")} 104 106 /> 105 107 </TooltipContent> 106 108 </Tooltip>
+180 -51
apps/web/src/components/activity/index.tsx
··· 1 - import { formatDistanceToNow } from "date-fns"; 2 1 import { Calendar, CircleAlert, History, UserRound } from "lucide-react"; 2 + import { useTranslation } from "react-i18next"; 3 3 import useActiveWorkspace from "@/hooks/queries/workspace/use-active-workspace"; 4 4 import useGetWorkspaceUsers from "@/hooks/queries/workspace-users/use-get-workspace-users"; 5 + import { formatDateMedium, formatRelativeTime } from "@/lib/format"; 6 + import { getPriorityLabel, getStatusLabel } from "@/lib/i18n/domain"; 5 7 import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar"; 6 8 import { 7 9 HoverCard, ··· 15 17 type ActivityItem = { 16 18 type: string; 17 19 content: string | null; 20 + eventData?: unknown; 18 21 id: string; 19 22 createdAt: string; 20 23 userId: string | null; ··· 24 27 externalSource?: string | null; 25 28 externalUrl?: string | null; 26 29 }; 30 + 31 + function getEventDataRecord( 32 + eventData: unknown, 33 + ): Record<string, unknown> | null { 34 + if (!eventData || typeof eventData !== "object" || Array.isArray(eventData)) { 35 + return null; 36 + } 37 + 38 + return eventData as Record<string, unknown>; 39 + } 27 40 28 41 type WorkspaceUser = { 29 42 user?: { ··· 51 64 } 52 65 } 53 66 54 - function toDisplayCase(value: string) { 55 - return value 56 - .replace(/[-_]/g, " ") 57 - .split(" ") 58 - .filter(Boolean) 59 - .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()) 60 - .join(" "); 61 - } 62 - 63 67 function formatActivityDateText(value: string) { 64 68 const parsed = new Date(value); 65 69 if (!Number.isNaN(parsed.getTime())) { 66 - return parsed.toLocaleDateString("en-US", { 67 - month: "short", 68 - day: "numeric", 69 - year: "numeric", 70 - }); 70 + return formatDateMedium(parsed); 71 71 } 72 72 73 73 const slashMatch = value.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/); ··· 75 75 const [, month, day, year] = slashMatch; 76 76 const fromSlashDate = new Date(`${year}-${month}-${day}T00:00:00`); 77 77 if (Number.isNaN(fromSlashDate.getTime())) return value; 78 - return fromSlashDate.toLocaleDateString("en-US", { 79 - month: "short", 80 - day: "numeric", 81 - year: "numeric", 82 - }); 78 + return formatDateMedium(fromSlashDate); 79 + } 80 + 81 + function toDisplayCase(value: string) { 82 + return value 83 + .replace(/[-_]/g, " ") 84 + .split(" ") 85 + .filter(Boolean) 86 + .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()) 87 + .join(" "); 83 88 } 84 89 85 90 function findUserByName(users: WorkspaceUser[] | undefined, name: string) { ··· 156 161 function renderActivityContent({ 157 162 activity, 158 163 workspaceUsers, 164 + t, 159 165 }: { 160 166 activity: ActivityItem; 161 167 workspaceUsers: WorkspaceUser[] | undefined; 168 + t: (key: string, options?: Record<string, unknown>) => string; 162 169 }) { 163 170 const content = activity.content || ""; 171 + const eventData = getEventDataRecord(activity.eventData); 164 172 165 173 if (activity.type === "priority_changed") { 174 + if (eventData) { 175 + return ( 176 + <span className="text-sm text-muted-foreground"> 177 + {t("activity:changedPriority", { 178 + from: getPriorityLabel(String(eventData.oldPriority ?? "")), 179 + to: getPriorityLabel(String(eventData.newPriority ?? "")), 180 + })} 181 + </span> 182 + ); 183 + } 184 + 166 185 const match = content.match( 167 186 /changed priority from "?(.+?)"? to "?(.+?)"?$/i, 168 187 ); 169 - if (!match) 188 + if (!match) { 170 189 return <span className="text-sm text-muted-foreground">{content}</span>; 190 + } 191 + 171 192 return ( 172 193 <span className="text-sm text-muted-foreground"> 173 - changed priority from{" "} 174 - <span className="text-foreground">{toDisplayCase(match[1])}</span> to{" "} 175 - <span className="text-foreground">{toDisplayCase(match[2])}</span> 194 + {t("activity:changedPriority", { 195 + from: getPriorityLabel(match[1]), 196 + to: getPriorityLabel(match[2]), 197 + })} 176 198 </span> 177 199 ); 178 200 } 179 201 180 202 if (activity.type === "status_changed") { 203 + if (eventData) { 204 + return ( 205 + <span className="text-sm text-muted-foreground"> 206 + {t("activity:changedStatus", { 207 + from: getStatusLabel(String(eventData.oldStatus ?? "")), 208 + to: getStatusLabel(String(eventData.newStatus ?? "")), 209 + })} 210 + </span> 211 + ); 212 + } 213 + 181 214 const match = content.match(/changed status from "?(.+?)"? to "?(.+?)"?$/i); 182 - if (!match) 215 + if (!match) { 183 216 return <span className="text-sm text-muted-foreground">{content}</span>; 217 + } 218 + 184 219 return ( 185 220 <span className="text-sm text-muted-foreground"> 186 - changed status from{" "} 187 - <span className="text-foreground">{toDisplayCase(match[1])}</span> to{" "} 188 - <span className="text-foreground">{toDisplayCase(match[2])}</span> 221 + {t("activity:changedStatus", { 222 + from: getStatusLabel(match[1]), 223 + to: getStatusLabel(match[2]), 224 + })} 189 225 </span> 190 226 ); 191 227 } 192 228 193 229 if (activity.type === "due_date_changed") { 230 + if (eventData) { 231 + const oldDueDate = eventData.oldDueDate 232 + ? formatActivityDateText(String(eventData.oldDueDate)) 233 + : null; 234 + const newDueDate = eventData.newDueDate 235 + ? formatActivityDateText(String(eventData.newDueDate)) 236 + : null; 237 + 238 + return ( 239 + <span className="text-sm text-muted-foreground"> 240 + {newDueDate 241 + ? oldDueDate 242 + ? t("activity:changedDueDate", { 243 + from: oldDueDate, 244 + to: newDueDate, 245 + }) 246 + : t("activity:setDueDate", { date: newDueDate }) 247 + : t("activity:clearedDueDate")} 248 + </span> 249 + ); 250 + } 251 + 194 252 const changeMatch = content.match(/changed due date from (.+) to (.+)$/i); 195 253 if (changeMatch) { 196 254 return ( 197 255 <span className="text-sm text-muted-foreground"> 198 - changed due date from{" "} 199 - <span className="text-foreground"> 200 - {formatActivityDateText(changeMatch[1])} 201 - </span>{" "} 202 - to{" "} 203 - <span className="text-foreground"> 204 - {formatActivityDateText(changeMatch[2])} 205 - </span> 256 + {t("activity:changedDueDate", { 257 + from: formatActivityDateText(changeMatch[1]), 258 + to: formatActivityDateText(changeMatch[2]), 259 + })} 206 260 </span> 207 261 ); 208 262 } 263 + 209 264 const setMatch = content.match(/set due date to (.+)$/i); 210 265 if (setMatch) { 211 266 return ( 212 267 <span className="text-sm text-muted-foreground"> 213 - set due date to{" "} 214 - <span className="text-foreground"> 215 - {formatActivityDateText(setMatch[1])} 216 - </span> 268 + {t("activity:setDueDate", { 269 + date: formatActivityDateText(setMatch[1]), 270 + })} 271 + </span> 272 + ); 273 + } 274 + 275 + if (content.includes("cleared the due date")) { 276 + return ( 277 + <span className="text-sm text-muted-foreground"> 278 + {t("activity:clearedDueDate")} 217 279 </span> 218 280 ); 219 281 } 282 + 220 283 return <span className="text-sm text-muted-foreground">{content}</span>; 221 284 } 222 285 286 + if (activity.type === "unassigned") { 287 + return ( 288 + <span className="text-sm text-muted-foreground"> 289 + {t("activity:unassigned")} 290 + </span> 291 + ); 292 + } 293 + 223 294 if (activity.type === "assignee_changed") { 295 + if (eventData) { 296 + if (eventData.isSelfAssigned) { 297 + return ( 298 + <span className="text-sm text-muted-foreground"> 299 + {t("activity:assignedToSelf")} 300 + </span> 301 + ); 302 + } 303 + 304 + const targetId = String(eventData.newAssigneeId ?? ""); 305 + const targetName = String(eventData.newAssignee ?? ""); 306 + const targetUser = 307 + workspaceUsers?.find((member) => member.user?.id === targetId) || null; 308 + 309 + return ( 310 + <span className="text-sm text-muted-foreground"> 311 + {t("activity:assignedTo", { 312 + name: targetUser?.user?.name ?? targetName, 313 + })} 314 + </span> 315 + ); 316 + } 317 + 224 318 if (content.includes("themselves")) { 225 319 return ( 226 320 <span className="text-sm text-muted-foreground"> 227 - assigned the task to themselves 321 + {t("activity:assignedToSelf")} 228 322 </span> 229 323 ); 230 324 } ··· 236 330 const [, targetId, targetName] = tokenMatch; 237 331 const targetUser = 238 332 workspaceUsers?.find((member) => member.user?.id === targetId) || null; 333 + 239 334 return ( 240 335 <span className="text-sm text-muted-foreground"> 241 - assigned the task to{" "} 242 - <UserHoverName user={targetUser} fallbackName={targetName} /> 336 + {t("activity:assignedTo", { 337 + name: targetUser?.user?.name ?? targetName, 338 + })} 243 339 </span> 244 340 ); 245 341 } ··· 250 346 const targetUser = findUserByName(workspaceUsers, targetName); 251 347 return ( 252 348 <span className="text-sm text-muted-foreground"> 253 - assigned the task to{" "} 254 - <UserHoverName user={targetUser} fallbackName={targetName} /> 349 + {t("activity:assignedTo", { 350 + name: targetUser?.user?.name ?? targetName, 351 + })} 352 + </span> 353 + ); 354 + } 355 + } 356 + 357 + if (activity.type === "title_changed") { 358 + if (eventData) { 359 + return ( 360 + <span className="text-sm text-muted-foreground"> 361 + {t("activity:changedTitle", { 362 + from: String(eventData.oldTitle ?? ""), 363 + to: String(eventData.newTitle ?? ""), 364 + })} 365 + </span> 366 + ); 367 + } 368 + 369 + const legacyMatch = content.match(/changed title from "(.+)" to "(.+)"$/i); 370 + if (legacyMatch) { 371 + return ( 372 + <span className="text-sm text-muted-foreground"> 373 + {t("activity:changedTitle", { 374 + from: legacyMatch[1], 375 + to: legacyMatch[2], 376 + })} 255 377 </span> 256 378 ); 257 379 } 258 380 } 259 381 260 - return <span className="text-sm text-muted-foreground">{content}</span>; 382 + return ( 383 + <span className="text-sm text-muted-foreground"> 384 + {content || toDisplayCase(activity.type)} 385 + </span> 386 + ); 261 387 } 262 388 263 389 function Activity({ ··· 269 395 step: number; 270 396 showConnector?: boolean; 271 397 }) { 398 + const { t } = useTranslation(); 272 399 const { data: workspace } = useActiveWorkspace(); 273 - 274 400 const { data: workspaceUsers } = useGetWorkspaceUsers({ 275 401 workspaceId: workspace?.id, 276 402 }); 277 403 278 404 const user = activity.userId 279 - ? workspaceUsers?.find((user) => user.user?.id === activity.userId) 405 + ? workspaceUsers?.find( 406 + (workspaceUser) => workspaceUser.user?.id === activity.userId, 407 + ) 280 408 : null; 281 409 282 410 const isExternalComment = Boolean(activity.externalSource); 283 - const actorName = user?.user?.name || "Someone"; 411 + const actorName = user?.user?.name || t("common:people.someone"); 284 412 285 413 if (isCommentActivity(activity)) { 286 414 const commentUser = isExternalComment 287 415 ? { 288 416 id: undefined, 289 - name: activity.externalUserName ?? "GitHub User", 417 + name: activity.externalUserName ?? t("activity:githubUser"), 290 418 email: undefined, 291 419 image: activity.externalUserAvatar ?? undefined, 292 420 } ··· 333 461 {renderActivityContent({ 334 462 activity, 335 463 workspaceUsers: workspaceUsers as WorkspaceUser[] | undefined, 464 + t, 336 465 })}{" "} 337 466 <span className="whitespace-nowrap text-muted-foreground/70 text-xs"> 338 - {formatDistanceToNow(activity.createdAt, { addSuffix: true })} 467 + {formatRelativeTime(activity.createdAt)} 339 468 </span> 340 469 </TimelineContent> 341 470 </TimelineItem>
+11 -5
apps/web/src/components/auth/otp-sign-in-form.tsx
··· 2 2 import { useRouter } from "@tanstack/react-router"; 3 3 import { useState } from "react"; 4 4 import { useForm } from "react-hook-form"; 5 + import { useTranslation } from "react-i18next"; 5 6 import { z } from "zod/v4"; 6 7 import { Button } from "@/components/ui/button"; 7 8 import { ··· 32 33 invitationId, 33 34 defaultEmail, 34 35 }: OtpSignInFormProps) { 36 + const { t } = useTranslation(); 35 37 const [isPending, setIsPending] = useState(false); 36 38 const { history } = useRouter(); 37 39 ··· 49 51 }); 50 52 51 53 if (result.error) { 52 - toast.error(result.error.message || "Failed to send verification code"); 54 + toast.error(result.error.message || t("auth:otpSignIn.sendFailed")); 53 55 return; 54 56 } 55 57 56 - toast.success("Verification code sent! Check your email."); 58 + toast.success(t("auth:otpSignIn.codeSent")); 57 59 58 60 const searchParams = new URLSearchParams({ 59 61 email: data.email, ··· 73 75 name="email" 74 76 render={({ field, fieldState }) => ( 75 77 <FormItem> 76 - <FormLabel className="text-sm font-medium">Email</FormLabel> 78 + <FormLabel className="text-sm font-medium"> 79 + {t("auth:forms.email")} 80 + </FormLabel> 77 81 <FormControl> 78 82 <Input 79 - placeholder="me@example.com" 83 + placeholder={t("auth:forms.emailPlaceholder")} 80 84 type="email" 81 85 autoComplete="email" 82 86 {...field} ··· 88 92 /> 89 93 90 94 <Button type="submit" disabled={isPending} className="w-full mt-4"> 91 - {isPending ? "Sending..." : "Send Verification Code"} 95 + {isPending 96 + ? t("auth:otpSignIn.sending") 97 + : t("auth:otpSignIn.sendVerificationCode")} 92 98 </Button> 93 99 </form> 94 100 </Form>
+5 -1
apps/web/src/components/auth/sign-in-form-skeleton.tsx
··· 1 + import { useTranslation } from "react-i18next"; 1 2 import { Skeleton } from "@/components/ui/skeleton"; 2 3 3 4 export function SignInFormSkeleton() { 5 + const { t } = useTranslation(); 4 6 return ( 5 7 <div className="space-y-4 mt-6"> 6 8 <div className="flex flex-col gap-2"> ··· 10 12 11 13 <div className="flex items-center gap-4 my-4"> 12 14 <div className="flex-1 h-px bg-border" /> 13 - <span className="text-sm text-muted-foreground">or</span> 15 + <span className="text-sm text-muted-foreground"> 16 + {t("auth:forms.or")} 17 + </span> 14 18 <div className="flex-1 h-px bg-border" /> 15 19 </div> 16 20
+23 -9
apps/web/src/components/auth/sign-in-form.tsx
··· 2 2 import { Eye, EyeOff } from "lucide-react"; 3 3 import { useState } from "react"; 4 4 import { useForm } from "react-hook-form"; 5 + import { useTranslation } from "react-i18next"; 5 6 import { z } from "zod/v4"; 6 7 import { Button } from "@/components/ui/button"; 7 8 import { ··· 32 33 }); 33 34 34 35 export function SignInForm({ onSuccess, defaultEmail }: SignInFormProps) { 36 + const { t } = useTranslation(); 35 37 const [showPassword, setShowPassword] = useState(false); 36 38 const [isPending, setIsPending] = useState(false); 37 39 const form = useForm<SignInFormValues>({ ··· 51 53 }); 52 54 53 55 if (result.error) { 54 - toast.error(result.error.message || "Failed to sign in"); 56 + toast.error(result.error.message || t("auth:signInForm.failedSignIn")); 55 57 return; 56 58 } 57 59 58 - toast.success("Signed in successfully"); 60 + toast.success(t("auth:signInForm.signedInSuccess")); 59 61 setTimeout(() => { 60 62 onSuccess?.(); 61 63 }, 500); 62 64 } catch (error) { 63 - toast.error(error instanceof Error ? error.message : "Failed to sign in"); 65 + toast.error( 66 + error instanceof Error 67 + ? error.message 68 + : t("auth:signInForm.failedSignIn"), 69 + ); 64 70 } finally { 65 71 setIsPending(false); 66 72 } ··· 75 81 name="email" 76 82 render={({ field }) => ( 77 83 <FormItem> 78 - <FormLabel className="text-sm font-medium">Email</FormLabel> 84 + <FormLabel className="text-sm font-medium"> 85 + {t("auth:forms.email")} 86 + </FormLabel> 79 87 <FormControl> 80 88 <Input 81 - placeholder="me@example.com" 89 + placeholder={t("auth:forms.emailPlaceholder")} 82 90 type="email" 83 91 autoComplete="email" 84 92 {...field} ··· 94 102 name="password" 95 103 render={({ field }) => ( 96 104 <FormItem> 97 - <FormLabel className="text-sm font-medium">Password</FormLabel> 105 + <FormLabel className="text-sm font-medium"> 106 + {t("auth:forms.password")} 107 + </FormLabel> 98 108 <FormControl> 99 109 <div className="relative"> 100 110 <Input 101 - placeholder="••••••••" 111 + placeholder={t("auth:forms.passwordPlaceholder")} 102 112 type={showPassword ? "text" : "password"} 103 113 autoComplete="current-password" 104 114 {...field} ··· 108 118 onClick={() => setShowPassword(!showPassword)} 109 119 className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground" 110 120 aria-label={ 111 - showPassword ? "Hide password" : "Show password" 121 + showPassword 122 + ? t("auth:forms.hidePassword") 123 + : t("auth:forms.showPassword") 112 124 } 113 125 aria-pressed={showPassword} 114 126 > ··· 128 140 size="sm" 129 141 className="w-full mt-4" 130 142 > 131 - {isPending ? "Signing In..." : "Sign In"} 143 + {isPending 144 + ? t("auth:signInForm.signingIn") 145 + : t("auth:signInForm.signIn")} 132 146 </Button> 133 147 </form> 134 148 </Form>
+41 -18
apps/web/src/components/auth/sign-up-form.tsx
··· 1 1 import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"; 2 2 import { useRouter } from "@tanstack/react-router"; 3 3 import { Eye, EyeOff } from "lucide-react"; 4 - import { useState } from "react"; 4 + import { useMemo, useState } from "react"; 5 5 import { useForm } from "react-hook-form"; 6 + import { useTranslation } from "react-i18next"; 6 7 import { z } from "zod/v4"; 7 8 import { Button } from "@/components/ui/button"; 8 9 import { ··· 28 29 defaultEmail?: string; 29 30 }; 30 31 31 - const signUpSchema = z.object({ 32 - email: z.email(), 33 - password: z.string().min(8, { message: "Password is too short" }), 34 - name: z.string(), 35 - }); 36 - 37 32 export function SignUpForm({ invitationId, defaultEmail }: SignUpFormProps) { 33 + const { t } = useTranslation(); 38 34 const [showPassword, setShowPassword] = useState(false); 39 35 const [isPending, setIsPending] = useState(false); 40 36 const { history } = useRouter(); 37 + 38 + const signUpSchema = useMemo( 39 + () => 40 + z.object({ 41 + email: z.email(), 42 + password: z.string().min(8, { 43 + message: t("auth:signUpForm.passwordTooShort"), 44 + }), 45 + name: z.string(), 46 + }), 47 + [t], 48 + ); 49 + 41 50 const form = useForm<SignUpFormValues>({ 42 51 resolver: standardSchemaResolver(signUpSchema), 43 52 defaultValues: { ··· 57 66 }); 58 67 59 68 if (result.error) { 60 - toast.error(result.error.message || "Failed to sign up"); 69 + toast.error(result.error.message || t("auth:signUpForm.failedSignUp")); 61 70 return; 62 71 } 63 72 64 - toast.success("Account created successfully"); 73 + toast.success(t("auth:signUpForm.accountCreated")); 65 74 66 75 if (invitationId) { 67 76 history.push(`/invitation/accept/${invitationId}`); ··· 69 78 history.push("/dashboard"); 70 79 } 71 80 } catch (error) { 72 - toast.error(error instanceof Error ? error.message : "Failed to sign up"); 81 + toast.error( 82 + error instanceof Error 83 + ? error.message 84 + : t("auth:signUpForm.failedSignUp"), 85 + ); 73 86 } finally { 74 87 setIsPending(false); 75 88 } ··· 84 97 name="name" 85 98 render={({ field, fieldState }) => ( 86 99 <FormItem> 87 - <FormLabel className="text-sm font-medium">Full Name</FormLabel> 100 + <FormLabel className="text-sm font-medium"> 101 + {t("auth:signUpForm.fullName")} 102 + </FormLabel> 88 103 <FormControl> 89 104 <Input 90 - placeholder="John Doe" 105 + placeholder={t("auth:signUpForm.namePlaceholder")} 91 106 type="text" 92 107 autoComplete="name" 93 108 {...field} ··· 103 118 name="email" 104 119 render={({ field, fieldState }) => ( 105 120 <FormItem> 106 - <FormLabel className="text-sm font-medium">Email</FormLabel> 121 + <FormLabel className="text-sm font-medium"> 122 + {t("auth:forms.email")} 123 + </FormLabel> 107 124 <FormControl> 108 125 <Input 109 - placeholder="me@example.com" 126 + placeholder={t("auth:forms.emailPlaceholder")} 110 127 type="email" 111 128 autoComplete="email" 112 129 disabled={!!defaultEmail} ··· 123 140 name="password" 124 141 render={({ field, fieldState }) => ( 125 142 <FormItem> 126 - <FormLabel className="text-sm font-medium">Password</FormLabel> 143 + <FormLabel className="text-sm font-medium"> 144 + {t("auth:forms.password")} 145 + </FormLabel> 127 146 <FormControl> 128 147 <div className="relative"> 129 148 <Input 130 - placeholder="••••••••" 149 + placeholder={t("auth:forms.passwordPlaceholder")} 131 150 type={showPassword ? "text" : "password"} 132 151 autoComplete="new-password" 133 152 {...field} ··· 137 156 onClick={() => setShowPassword(!showPassword)} 138 157 className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground" 139 158 aria-label={ 140 - showPassword ? "Hide password" : "Show password" 159 + showPassword 160 + ? t("auth:forms.hidePassword") 161 + : t("auth:forms.showPassword") 141 162 } 142 163 aria-pressed={showPassword} 143 164 > ··· 152 173 </div> 153 174 154 175 <Button type="submit" disabled={isPending} className="w-full mt-4"> 155 - {isPending ? "Creating Account..." : "Create Account"} 176 + {isPending 177 + ? t("auth:signUpForm.creatingAccount") 178 + : t("auth:signUpForm.createAccount")} 156 179 </Button> 157 180 </form> 158 181 </Form>
+9 -8
apps/web/src/components/backlog-list-view/backlog-task-row.tsx
··· 4 4 import { format } from "date-fns"; 5 5 import { Calendar, CalendarClock, CalendarX } from "lucide-react"; 6 6 import { type CSSProperties, useMemo, useState } from "react"; 7 + import { useTranslation } from "react-i18next"; 7 8 import { 8 9 AlertDialog, 9 10 AlertDialogClose, ··· 36 37 }; 37 38 38 39 export default function BacklogTaskRow({ task }: BacklogTaskRowProps) { 40 + const { t } = useTranslation(); 39 41 const navigate = useNavigate(); 40 42 const { 41 43 attributes, ··· 119 121 }); 120 122 } catch (error) { 121 123 toast.error( 122 - error instanceof Error ? error.message : "Failed to delete task", 124 + error instanceof Error ? error.message : t("tasks:delete.error"), 123 125 ); 124 126 } finally { 125 - toast.success("Task deleted successfully"); 127 + toast.success(t("tasks:delete.success")); 126 128 } 127 129 }; 128 130 ··· 208 210 ) : ( 209 211 <div 210 212 className="w-6 h-6 rounded-full bg-muted border border-border flex items-center justify-center" 211 - title="Unassigned" 213 + title={t("tasks:assignee.unassigned")} 212 214 > 213 215 <span className="text-[10px] font-medium text-muted-foreground"> 214 216 ? ··· 238 240 > 239 241 <AlertDialogContent> 240 242 <AlertDialogHeader> 241 - <AlertDialogTitle>Delete Task?</AlertDialogTitle> 243 + <AlertDialogTitle>{t("tasks:delete.title")}</AlertDialogTitle> 242 244 <AlertDialogDescription> 243 - This will permanently remove the task and all its data. You can't 244 - undo this action. 245 + {t("tasks:delete.description")} 245 246 </AlertDialogDescription> 246 247 </AlertDialogHeader> 247 248 <AlertDialogFooter> 248 249 <AlertDialogClose> 249 250 <Button variant="outline" size="sm"> 250 - Cancel 251 + {t("common:actions.cancel")} 251 252 </Button> 252 253 </AlertDialogClose> 253 254 <AlertDialogClose onClick={handleDeleteTask}> 254 255 <Button variant="destructive" size="sm"> 255 - Delete Task 256 + {t("tasks:delete.action")} 256 257 </Button> 257 258 </AlertDialogClose> 258 259 </AlertDialogFooter>
+15 -5
apps/web/src/components/backlog-list-view/index.tsx
··· 22 22 import { produce } from "immer"; 23 23 import { Archive, ChevronRight, Clock, Flag, Plus } from "lucide-react"; 24 24 import { useEffect, useState } from "react"; 25 + import { useTranslation } from "react-i18next"; 25 26 import { priorityColorsTaskCard } from "@/constants/priority-colors"; 26 27 import { useUpdateTask } from "@/hooks/mutations/task/use-update-task"; 27 28 import { useRegisterShortcuts } from "@/hooks/use-keyboard-shortcuts"; ··· 43 44 project, 44 45 disableDragDrop = false, 45 46 }: BacklogListViewProps) { 47 + const { t } = useTranslation(); 46 48 const { mutate: updateTask } = useUpdateTask(); 47 49 const { setProject } = useProjectStore(); 48 50 const { ··· 330 332 <div className="flex items-center gap-2 h-4"> 331 333 <IconComponent className="w-4 h-4 flex-shrink-0 text-muted-foreground" /> 332 334 <div className="flex items-center gap-1"> 333 - <span className="mt-1 mr-1">{title}</span> 335 + <span className="mt-1 mr-1"> 336 + {t(`tasks:backlog.sections.${sectionId}`, { 337 + defaultValue: title, 338 + })} 339 + </span> 334 340 <span className="text-xs text-muted-foreground mt-0.5"> 335 341 {tasks.length} 336 342 </span> ··· 347 353 setActiveColumn("planned"); 348 354 }} 349 355 className="p-1 hover:bg-accent rounded text-muted-foreground hover:text-foreground transition-colors" 350 - title="Add task" 356 + title={t("tasks:backlog.addTask")} 351 357 > 352 358 <Plus className="w-3 h-3" /> 353 359 </button> ··· 368 374 369 375 {tasks.length === 0 && ( 370 376 <div className="py-6 px-4 text-center text-xs text-muted-foreground"> 371 - No {title.toLowerCase()} tasks 377 + {t("tasks:backlog.noTasksInSection", { 378 + section: t(`tasks:backlog.sections.${sectionId}`, { 379 + defaultValue: title, 380 + }).toLowerCase(), 381 + })} 372 382 </div> 373 383 )} 374 384 </div> ··· 401 411 <div className="divide-y divide-border/50"> 402 412 <BacklogSection 403 413 sectionId="planned" 404 - title="Planned" 414 + title={t("tasks:backlog.sections.planned")} 405 415 icon={Clock} 406 416 tasks={plannedTasks} 407 417 showAddButton={true} ··· 409 419 410 420 <BacklogSection 411 421 sectionId="archived" 412 - title="Archived" 422 + title={t("tasks:backlog.sections.archived")} 413 423 icon={Archive} 414 424 tasks={archivedTasks} 415 425 />
+89 -53
apps/web/src/components/board/board-toolbar.tsx
··· 1 1 import { Filter, PanelsTopLeft, Rows3, X } from "lucide-react"; 2 2 import type { ReactNode } from "react"; 3 + import { useTranslation } from "react-i18next"; 3 4 import SortControl from "@/components/common/sort-control"; 4 5 import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 5 6 import { ··· 15 16 DropdownMenuTrigger, 16 17 } from "@/components/ui/menu"; 17 18 import labelColors from "@/constants/label-colors"; 18 - import type { BoardFilters } from "@/hooks/use-task-filters"; 19 + import { 20 + type BoardFilters, 21 + DUE_DATE_FILTER_VALUES, 22 + } from "@/hooks/use-task-filters"; 19 23 import { getColumnIcon } from "@/lib/column"; 24 + import { getPriorityLabel } from "@/lib/i18n/domain"; 20 25 import { getPriorityIcon } from "@/lib/priority"; 21 26 import type { SortConfig } from "@/lib/sort-tasks"; 22 27 import type { ProjectWithTasks } from "@/types/project"; ··· 138 143 sort, 139 144 onSortChange, 140 145 }: BoardToolbarProps) { 146 + const { t } = useTranslation(); 141 147 const selectedStatusIds = filters.status ?? []; 142 148 const selectedPriorityIds = filters.priority ?? []; 143 149 const selectedAssigneeIds = filters.assignee ?? []; ··· 153 159 }; 154 160 155 161 const getPriorityDisplayName = (priority: string) => 156 - priority.charAt(0).toUpperCase() + priority.slice(1); 162 + getPriorityLabel(priority); 157 163 158 164 const getAssigneeDisplayName = (userId: string) => { 159 165 const member = users?.members?.find((m) => m.userId === userId); 160 - return member?.user?.name || "Unknown"; 166 + return member?.user?.name || t("common:people.unknown"); 161 167 }; 162 168 const getAssigneeAvatar = (userId: string) => { 163 169 const member = users?.members?.find((m) => m.userId === userId); ··· 259 265 } 260 266 > 261 267 <Filter className="h-3 w-3" /> 262 - Filter 268 + {t("common:actions.filter")} 263 269 </DropdownMenuTrigger> 264 270 <DropdownMenuContent className="w-56" align="start"> 265 271 <DropdownMenuGroup> 266 272 <DropdownMenuLabel className="text-[11px] uppercase tracking-wide"> 267 - Filter By 273 + {t("tasks:boardFilters.filterBy")} 268 274 </DropdownMenuLabel> 269 275 </DropdownMenuGroup> 270 276 <DropdownMenuSeparator /> 271 277 272 278 <DropdownMenuSub> 273 279 <DropdownMenuSubTrigger className="h-8 rounded-md text-sm"> 274 - Status 280 + {t("tasks:boardFilters.subjects.status")} 275 281 </DropdownMenuSubTrigger> 276 282 <DropdownMenuSubContent className="w-72"> 277 283 <div className="grid grid-cols-1 gap-1 p-1"> ··· 285 291 type="button" 286 292 > 287 293 <CheckSlot checked={selectedStatusIds.length === 0} /> 288 - All statuses 294 + {t("tasks:boardFilters.allStatuses")} 289 295 </button> 290 296 {project?.columns?.map((column) => ( 291 297 <button ··· 313 319 314 320 <DropdownMenuSub> 315 321 <DropdownMenuSubTrigger className="h-8 rounded-md text-sm"> 316 - Priority 322 + {t("tasks:boardFilters.subjects.priority")} 317 323 </DropdownMenuSubTrigger> 318 324 <DropdownMenuSubContent className="w-72"> 319 325 <div className="grid grid-cols-1 gap-1 p-1"> ··· 327 333 type="button" 328 334 > 329 335 <CheckSlot checked={selectedPriorityIds.length === 0} /> 330 - All priorities 336 + {t("tasks:boardFilters.allPriorities")} 331 337 </button> 332 338 {["urgent", "high", "medium", "low"].map((priority) => ( 333 339 <button ··· 347 353 {getPriorityIcon(priority)} 348 354 </span> 349 355 <span className="truncate capitalize"> 350 - {priority} 356 + {getPriorityDisplayName(priority)} 351 357 </span> 352 358 </button> 353 359 ))} ··· 357 363 358 364 <DropdownMenuSub> 359 365 <DropdownMenuSubTrigger className="h-8 rounded-md text-sm"> 360 - Assignee 366 + {t("tasks:boardFilters.subjects.assignee")} 361 367 </DropdownMenuSubTrigger> 362 368 <DropdownMenuSubContent className="w-64"> 363 369 <div className="grid grid-cols-1 gap-1 p-1"> ··· 371 377 type="button" 372 378 > 373 379 <CheckSlot checked={selectedAssigneeIds.length === 0} /> 374 - All assignees 380 + {t("tasks:boardFilters.allAssignees")} 375 381 </button> 376 382 {users?.members?.map((member) => ( 377 383 <button ··· 409 415 410 416 <DropdownMenuSub> 411 417 <DropdownMenuSubTrigger className="h-8 rounded-md text-sm"> 412 - Due date 418 + {t("tasks:boardFilters.subjects.dueDate")} 413 419 </DropdownMenuSubTrigger> 414 420 <DropdownMenuSubContent className="w-56"> 415 421 <div className="grid grid-cols-1 gap-1 p-1"> ··· 425 431 <CheckSlot 426 432 checked={selectedDueDateFilters.length === 0} 427 433 /> 428 - All due dates 434 + {t("tasks:boardFilters.allDueDates")} 429 435 </button> 430 - {["Due this week", "Due next week", "No due date"].map( 431 - (dueDate) => ( 432 - <button 433 - key={dueDate} 434 - className={`inline-flex h-7 items-center gap-1.5 rounded-md px-2 text-left text-xs ${ 435 - selectedDueDateFilters.includes(dueDate) 436 - ? "bg-accent text-accent-foreground" 437 - : "text-foreground/90 hover:bg-accent/60 hover:text-foreground" 438 - }`} 439 - onClick={() => toggleDueDateFilter(dueDate)} 440 - type="button" 441 - > 442 - <CheckSlot 443 - checked={selectedDueDateFilters.includes(dueDate)} 444 - /> 445 - {dueDate} 446 - </button> 447 - ), 448 - )} 436 + {[ 437 + DUE_DATE_FILTER_VALUES.dueThisWeek, 438 + DUE_DATE_FILTER_VALUES.dueNextWeek, 439 + DUE_DATE_FILTER_VALUES.noDueDate, 440 + ].map((dueDate) => ( 441 + <button 442 + key={dueDate} 443 + className={`inline-flex h-7 items-center gap-1.5 rounded-md px-2 text-left text-xs ${ 444 + selectedDueDateFilters.includes(dueDate) 445 + ? "bg-accent text-accent-foreground" 446 + : "text-foreground/90 hover:bg-accent/60 hover:text-foreground" 447 + }`} 448 + onClick={() => toggleDueDateFilter(dueDate)} 449 + type="button" 450 + > 451 + <CheckSlot 452 + checked={selectedDueDateFilters.includes(dueDate)} 453 + /> 454 + {t( 455 + `tasks:backlog.filters.${ 456 + dueDate === DUE_DATE_FILTER_VALUES.dueThisWeek 457 + ? "dueThisWeek" 458 + : dueDate === DUE_DATE_FILTER_VALUES.dueNextWeek 459 + ? "dueNextWeek" 460 + : "noDueDate" 461 + }`, 462 + )} 463 + </button> 464 + ))} 449 465 </div> 450 466 </DropdownMenuSubContent> 451 467 </DropdownMenuSub> 452 468 453 469 <DropdownMenuSub> 454 470 <DropdownMenuSubTrigger className="h-8 rounded-md text-sm"> 455 - Labels 471 + {t("tasks:properties.labels")} 456 472 </DropdownMenuSubTrigger> 457 473 <DropdownMenuSubContent className="w-64"> 458 474 <DropdownMenuItem ··· 462 478 <CheckSlot 463 479 checked={!filters.labels || filters.labels.length === 0} 464 480 /> 465 - All labels 481 + {t("tasks:boardFilters.allLabels")} 466 482 </DropdownMenuItem> 467 483 <DropdownMenuSeparator /> 468 484 {uniqueLabels.length > 0 ? ( ··· 489 505 disabled 490 506 className="h-8 rounded-md text-sm text-muted-foreground" 491 507 > 492 - No labels available 508 + {t("tasks:labels.empty")} 493 509 </DropdownMenuItem> 494 510 )} 495 511 </DropdownMenuSubContent> ··· 502 518 onClick={clearFilters} 503 519 className="h-8 rounded-md text-sm text-muted-foreground" 504 520 > 505 - Clear all filters 521 + {t("common:actions.clearAllFilters")} 506 522 </DropdownMenuItem> 507 523 </> 508 524 )} ··· 513 529 514 530 {selectedStatusIds.length > 0 && ( 515 531 <ActiveFilterChip 516 - subject="Status" 517 - operator="is any of" 532 + subject={t("tasks:boardFilters.subjects.status")} 533 + operator={t("tasks:boardFilters.operators.isAnyOf")} 518 534 value={ 519 535 <span className="inline-flex items-center gap-1.5"> 520 536 <StackedIcons ··· 527 543 <span> 528 544 {selectedStatusIds.length === 1 529 545 ? getStatusDisplayName(selectedStatusIds[0]) 530 - : `${selectedStatusIds.length} selected`} 546 + : t("tasks:boardFilters.selectedCount", { 547 + count: selectedStatusIds.length, 548 + })} 531 549 </span> 532 550 </span> 533 551 } ··· 537 555 538 556 {selectedPriorityIds.length > 0 && ( 539 557 <ActiveFilterChip 540 - subject="Priority" 541 - operator="is any of" 558 + subject={t("tasks:boardFilters.subjects.priority")} 559 + operator={t("tasks:boardFilters.operators.isAnyOf")} 542 560 value={ 543 561 <span className="inline-flex items-center gap-1.5"> 544 562 <StackedIcons ··· 550 568 <span> 551 569 {selectedPriorityIds.length === 1 552 570 ? getPriorityDisplayName(selectedPriorityIds[0]) 553 - : `${selectedPriorityIds.length} selected`} 571 + : t("tasks:boardFilters.selectedCount", { 572 + count: selectedPriorityIds.length, 573 + })} 554 574 </span> 555 575 </span> 556 576 } ··· 560 580 561 581 {selectedAssigneeIds.length > 0 && ( 562 582 <ActiveFilterChip 563 - subject="Assignee" 564 - operator="is any of" 583 + subject={t("tasks:boardFilters.subjects.assignee")} 584 + operator={t("tasks:boardFilters.operators.isAnyOf")} 565 585 value={ 566 586 <span className="inline-flex items-center gap-1.5"> 567 587 <StackedIcons ··· 573 593 <span> 574 594 {selectedAssigneeIds.length === 1 575 595 ? getAssigneeDisplayName(selectedAssigneeIds[0]) 576 - : `${selectedAssigneeIds.length} selected`} 596 + : t("tasks:boardFilters.selectedCount", { 597 + count: selectedAssigneeIds.length, 598 + })} 577 599 </span> 578 600 </span> 579 601 } ··· 583 605 584 606 {selectedDueDateFilters.length > 0 && ( 585 607 <ActiveFilterChip 586 - subject="Due date" 587 - operator="is any of" 608 + subject={t("tasks:boardFilters.subjects.dueDate")} 609 + operator={t("tasks:boardFilters.operators.isAnyOf")} 588 610 value={ 589 611 selectedDueDateFilters.length === 1 590 - ? selectedDueDateFilters[0] 591 - : `${selectedDueDateFilters.length} selected` 612 + ? t( 613 + `tasks:backlog.filters.${ 614 + selectedDueDateFilters[0] === 615 + DUE_DATE_FILTER_VALUES.dueThisWeek 616 + ? "dueThisWeek" 617 + : selectedDueDateFilters[0] === 618 + DUE_DATE_FILTER_VALUES.dueNextWeek 619 + ? "dueNextWeek" 620 + : "noDueDate" 621 + }`, 622 + ) 623 + : t("tasks:boardFilters.selectedCount", { 624 + count: selectedDueDateFilters.length, 625 + }) 592 626 } 593 627 onClear={() => updateFilter("dueDate", null)} 594 628 /> ··· 596 630 597 631 {filters.labels && filters.labels.length > 0 && ( 598 632 <ActiveFilterChip 599 - subject="Labels" 600 - operator="include any of" 601 - value={`${filters.labels.length} selected`} 633 + subject={t("tasks:boardFilters.subjects.labels")} 634 + operator={t("tasks:boardFilters.operators.includeAnyOf")} 635 + value={t("tasks:boardFilters.selectedCount", { 636 + count: filters.labels.length, 637 + })} 602 638 onClear={clearLabelFilters} 603 639 /> 604 640 )}
+56 -46
apps/web/src/components/bulk-selection/backlog-bulk-toolbar.tsx
··· 15 15 useMemo, 16 16 useState, 17 17 } from "react"; 18 + import { useTranslation } from "react-i18next"; 18 19 import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 19 20 import { Calendar } from "@/components/ui/calendar"; 20 21 import { ··· 48 49 import useActiveWorkspace from "@/hooks/queries/workspace/use-active-workspace"; 49 50 import { useGetActiveWorkspaceUsers } from "@/hooks/queries/workspace-users/use-get-active-workspace-users"; 50 51 import { getColumnIcon } from "@/lib/column"; 52 + import { getPriorityLabel } from "@/lib/i18n/domain"; 51 53 import { getPriorityIcon } from "@/lib/priority"; 52 54 import { toast } from "@/lib/toast"; 53 55 import useBacklogBulkSelectionStore from "@/store/backlog-bulk-selection"; ··· 68 70 items: BacklogActionItem[]; 69 71 }; 70 72 71 - const priorityOptions = [ 72 - { value: "urgent", label: "Urgent" }, 73 - { value: "high", label: "High" }, 74 - { value: "medium", label: "Medium" }, 75 - { value: "low", label: "Low" }, 76 - { value: "no-priority", label: "No Priority" }, 77 - ]; 78 - 79 73 function BacklogBulkToolbar() { 74 + const { t } = useTranslation(); 80 75 const { selectedTaskIds, clearSelection, selectAll } = 81 76 useBacklogBulkSelectionStore(); 77 + 78 + const priorityOptions = useMemo( 79 + () => [ 80 + { value: "urgent", label: getPriorityLabel("urgent") }, 81 + { value: "high", label: getPriorityLabel("high") }, 82 + { value: "medium", label: getPriorityLabel("medium") }, 83 + { value: "low", label: getPriorityLabel("low") }, 84 + { value: "no-priority", label: getPriorityLabel("no-priority") }, 85 + ], 86 + [], 87 + ); 82 88 const { project } = useProjectStore(); 83 89 const { 84 90 bulkMoveToBoard, ··· 144 150 taskIds: Array.from(selectedTaskIds), 145 151 status, 146 152 }); 147 - toast.success(`${selectedCount} tasks moved to board`); 153 + toast.success( 154 + t("tasks:bulk.moveToBoardSuccess", { count: selectedCount }), 155 + ); 148 156 clearSelection(); 149 157 } catch (_error) { 150 - toast.error("Failed to move tasks to board"); 158 + toast.error(t("tasks:bulk.moveToBoardError")); 151 159 } 152 160 }, 153 - [bulkMoveToBoard, selectedTaskIds, selectedCount, clearSelection], 161 + [bulkMoveToBoard, selectedTaskIds, selectedCount, clearSelection, t], 154 162 ); 155 163 156 164 const handleBulkDelete = useCallback(async () => { 157 - if ( 158 - !confirm(`Delete ${selectedCount} tasks? This action cannot be undone.`) 159 - ) { 165 + if (!confirm(t("tasks:bulk.deleteConfirm", { count: selectedCount }))) { 160 166 return; 161 167 } 162 168 163 169 try { 164 170 await bulkDelete(Array.from(selectedTaskIds)); 165 - toast.success(`${selectedCount} tasks deleted`); 171 + toast.success(t("tasks:bulk.deleteSuccess", { count: selectedCount })); 166 172 clearSelection(); 167 173 setIsActionsOpen(false); 168 174 } catch (_error) { 169 - toast.error("Failed to delete tasks"); 175 + toast.error(t("tasks:bulk.deleteError")); 170 176 } 171 - }, [bulkDelete, selectedTaskIds, selectedCount, clearSelection]); 177 + }, [bulkDelete, selectedTaskIds, selectedCount, clearSelection, t]); 172 178 173 179 const handleBulkArchive = useCallback(async () => { 174 180 try { 175 181 await bulkArchive(Array.from(selectedTaskIds)); 176 - toast.success(`${selectedCount} tasks archived`); 182 + toast.success(t("tasks:bulk.archiveSuccess", { count: selectedCount })); 177 183 clearSelection(); 178 184 setIsActionsOpen(false); 179 185 } catch (_error) { 180 - toast.error("Failed to archive tasks"); 186 + toast.error(t("tasks:bulk.archiveError")); 181 187 } 182 - }, [bulkArchive, selectedTaskIds, selectedCount, clearSelection]); 188 + }, [bulkArchive, selectedTaskIds, selectedCount, clearSelection, t]); 183 189 184 190 const handleBulkAssign = useCallback( 185 191 async (userId: string) => { 186 192 try { 187 193 await bulkAssign({ taskIds: Array.from(selectedTaskIds), userId }); 188 - toast.success(`${selectedCount} tasks assigned`); 194 + toast.success(t("tasks:bulk.assignSuccess", { count: selectedCount })); 189 195 clearSelection(); 190 196 setIsActionsOpen(false); 191 197 } catch (_error) { 192 - toast.error("Failed to assign tasks"); 198 + toast.error(t("tasks:bulk.assignError")); 193 199 } 194 200 }, 195 - [bulkAssign, selectedTaskIds, selectedCount, clearSelection], 201 + [bulkAssign, selectedTaskIds, selectedCount, clearSelection, t], 196 202 ); 197 203 198 204 const handleBulkPriority = useCallback( ··· 202 208 taskIds: Array.from(selectedTaskIds), 203 209 priority, 204 210 }); 205 - toast.success(`${selectedCount} tasks updated`); 211 + toast.success(t("tasks:bulk.updateSuccess", { count: selectedCount })); 206 212 clearSelection(); 207 213 setIsActionsOpen(false); 208 214 } catch (_error) { 209 - toast.error("Failed to update priority"); 215 + toast.error(t("tasks:bulk.updatePriorityError")); 210 216 } 211 217 }, 212 - [bulkPriority, selectedTaskIds, selectedCount, clearSelection], 218 + [bulkPriority, selectedTaskIds, selectedCount, clearSelection, t], 213 219 ); 214 220 215 221 const handleBulkAddLabel = useCallback( ··· 219 225 taskIds: Array.from(selectedTaskIds), 220 226 labelId, 221 227 }); 222 - toast.success(`Label added to ${selectedCount} tasks`); 228 + toast.success( 229 + t("tasks:bulk.addLabelSuccess", { count: selectedCount }), 230 + ); 223 231 clearSelection(); 224 232 setIsActionsOpen(false); 225 233 } catch (_error) { 226 - toast.error("Failed to add label"); 234 + toast.error(t("tasks:bulk.addLabelError")); 227 235 } 228 236 }, 229 - [bulkAddLabel, selectedTaskIds, selectedCount, clearSelection], 237 + [bulkAddLabel, selectedTaskIds, selectedCount, clearSelection, t], 230 238 ); 231 239 232 240 const handleBulkDueDate = useCallback( ··· 236 244 taskIds: Array.from(selectedTaskIds), 237 245 dueDate: date?.toISOString() ?? null, 238 246 }); 239 - toast.success(`${selectedCount} tasks updated`); 247 + toast.success(t("tasks:bulk.updateSuccess", { count: selectedCount })); 240 248 clearSelection(); 241 249 setIsDatePickerOpen(false); 242 250 } catch (_error) { 243 - toast.error("Failed to update due date"); 251 + toast.error(t("tasks:bulk.updateDueDateError")); 244 252 } 245 253 }, 246 - [bulkDueDate, selectedTaskIds, selectedCount, clearSelection], 254 + [bulkDueDate, selectedTaskIds, selectedCount, clearSelection, t], 247 255 ); 248 256 249 257 const groupedItems = useMemo<BacklogActionGroup[]>( 250 258 () => [ 251 259 { 252 260 value: "actions", 253 - label: "Actions", 261 + label: t("tasks:bulk.actions"), 254 262 items: [ 255 263 { 256 264 value: "bulk-delete", 257 - label: "Delete tasks", 265 + label: t("tasks:bulk.delete"), 258 266 icon: <Trash2 className="h-4 w-4 text-muted-foreground" />, 259 267 onRun: () => { 260 268 void handleBulkDelete(); ··· 262 270 }, 263 271 { 264 272 value: "bulk-archive", 265 - label: "Archive tasks", 273 + label: t("tasks:bulk.archive"), 266 274 icon: <Archive className="h-4 w-4 text-muted-foreground" />, 267 275 onRun: () => { 268 276 void handleBulkArchive(); ··· 272 280 }, 273 281 { 274 282 value: "assign", 275 - label: "Assign to", 283 + label: t("tasks:bulk.assignTo"), 276 284 items: (workspaceUsers?.members ?? []).map((member) => ({ 277 285 value: `assign-${member.userId}`, 278 - label: member.user?.name || "Unknown User", 286 + label: member.user?.name || t("common:people.someone"), 279 287 icon: ( 280 288 <Avatar className="h-5 w-5"> 281 289 <AvatarImage ··· 294 302 }, 295 303 { 296 304 value: "priority", 297 - label: "Set Priority", 305 + label: t("tasks:bulk.setPriority"), 298 306 items: priorityOptions.map((opt) => ({ 299 307 value: `priority-${opt.value}`, 300 308 label: opt.label, ··· 306 314 }, 307 315 { 308 316 value: "label", 309 - label: "Add Label", 317 + label: t("tasks:bulk.addLabel"), 310 318 items: uniqueLabels.map((label) => ({ 311 319 value: `label-${label.id}`, 312 320 label: label.name, ··· 334 342 handleBulkAssign, 335 343 handleBulkPriority, 336 344 handleBulkAddLabel, 345 + priorityOptions, 346 + t, 337 347 ], 338 348 ); 339 349 ··· 344 354 <Toolbar className="items-center gap-1 rounded-xl border-border/80 bg-background px-1.5 py-1 shadow-lg/8"> 345 355 <ToolbarGroup className="px-1.5"> 346 356 <span className="text-sm font-medium text-foreground"> 347 - {selectedCount} selected 357 + {t("tasks:bulk.selectedCount", { count: selectedCount })} 348 358 </span> 349 359 </ToolbarGroup> 350 360 ··· 355 365 <DropdownMenuTrigger asChild> 356 366 <Button size="sm" variant="ghost" className="gap-1.5"> 357 367 <ArrowUpToLine className="size-4" /> 358 - Move to Board 368 + {t("tasks:bulk.moveToBoard")} 359 369 <ChevronDown className="size-3 opacity-60" /> 360 370 </Button> 361 371 </DropdownMenuTrigger> ··· 380 390 <PopoverTrigger asChild> 381 391 <Button size="sm" variant="ghost"> 382 392 <CalendarIcon className="size-4" /> 383 - Set Due Date 393 + {t("tasks:bulk.setDueDate")} 384 394 </Button> 385 395 </PopoverTrigger> 386 396 <PopoverContent className="p-0" align="center"> ··· 397 407 onClick={() => handleBulkDueDate(undefined)} 398 408 > 399 409 <X className="h-4 w-4" /> 400 - Clear date 410 + {t("tasks:dueDate.clear")} 401 411 </Button> 402 412 </div> 403 413 </PopoverContent> ··· 413 423 onClick={() => setIsActionsOpen(true)} 414 424 > 415 425 <Menu className="size-4" /> 416 - Actions 426 + {t("tasks:bulk.actions")} 417 427 </Button> 418 428 </ToolbarGroup> 419 429 ··· 429 439 <CommandDialog open={isActionsOpen} onOpenChange={setIsActionsOpen}> 430 440 <CommandDialogPopup> 431 441 <Command items={groupedItems}> 432 - <CommandInput placeholder="Search actions..." /> 442 + <CommandInput placeholder={t("tasks:bulk.searchActions")} /> 433 443 <CommandPanel> 434 - <CommandEmpty>No actions found.</CommandEmpty> 444 + <CommandEmpty>{t("tasks:bulk.noActionsFound")}</CommandEmpty> 435 445 <CommandList> 436 446 {(group: BacklogActionGroup, groupIndex: number) => ( 437 447 <Fragment key={group.value}>
+60 -50
apps/web/src/components/bulk-selection/bulk-toolbar.tsx
··· 14 14 useMemo, 15 15 useState, 16 16 } from "react"; 17 + import { useTranslation } from "react-i18next"; 17 18 import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 18 19 import { Calendar } from "@/components/ui/calendar"; 19 20 import { ··· 41 42 import useActiveWorkspace from "@/hooks/queries/workspace/use-active-workspace"; 42 43 import { useGetActiveWorkspaceUsers } from "@/hooks/queries/workspace-users/use-get-active-workspace-users"; 43 44 import { getColumnIcon } from "@/lib/column"; 45 + import { getPriorityLabel } from "@/lib/i18n/domain"; 44 46 import { getPriorityIcon } from "@/lib/priority"; 45 47 import { toast } from "@/lib/toast"; 46 48 import useBulkSelectionStore from "@/store/bulk-selection"; ··· 61 63 items: BulkActionItem[]; 62 64 }; 63 65 64 - const priorityOptions = [ 65 - { value: "urgent", label: "Urgent" }, 66 - { value: "high", label: "High" }, 67 - { value: "medium", label: "Medium" }, 68 - { value: "low", label: "Low" }, 69 - { value: "no-priority", label: "No Priority" }, 70 - ]; 71 - 72 66 function BulkToolbar() { 67 + const { t } = useTranslation(); 73 68 const { selectedTaskIds, clearSelection, selectAll } = 74 69 useBulkSelectionStore(); 70 + 71 + const priorityOptions = useMemo( 72 + () => [ 73 + { value: "urgent", label: getPriorityLabel("urgent") }, 74 + { value: "high", label: getPriorityLabel("high") }, 75 + { value: "medium", label: getPriorityLabel("medium") }, 76 + { value: "low", label: getPriorityLabel("low") }, 77 + { value: "no-priority", label: getPriorityLabel("no-priority") }, 78 + ], 79 + [], 80 + ); 75 81 const { project } = useProjectStore(); 76 82 const { 77 83 bulkMoveToBacklog, ··· 134 140 const handleMoveToBacklog = useCallback(async () => { 135 141 try { 136 142 await bulkMoveToBacklog(Array.from(selectedTaskIds)); 137 - toast.success(`${selectedCount} tasks moved to backlog`); 143 + toast.success( 144 + t("tasks:bulk.moveToBacklogSuccess", { count: selectedCount }), 145 + ); 138 146 clearSelection(); 139 147 } catch (_error) { 140 - toast.error("Failed to move tasks to backlog"); 148 + toast.error(t("tasks:bulk.moveToBacklogError")); 141 149 } 142 - }, [bulkMoveToBacklog, selectedTaskIds, selectedCount, clearSelection]); 150 + }, [bulkMoveToBacklog, selectedTaskIds, selectedCount, clearSelection, t]); 143 151 144 152 const handleBulkDelete = useCallback(async () => { 145 - if ( 146 - !confirm(`Delete ${selectedCount} tasks? This action cannot be undone.`) 147 - ) { 153 + if (!confirm(t("tasks:bulk.deleteConfirm", { count: selectedCount }))) { 148 154 return; 149 155 } 150 156 151 157 try { 152 158 await bulkDelete(Array.from(selectedTaskIds)); 153 - toast.success(`${selectedCount} tasks deleted`); 159 + toast.success(t("tasks:bulk.deleteSuccess", { count: selectedCount })); 154 160 clearSelection(); 155 161 setIsActionsOpen(false); 156 162 } catch (_error) { 157 - toast.error("Failed to delete tasks"); 163 + toast.error(t("tasks:bulk.deleteError")); 158 164 } 159 - }, [bulkDelete, selectedTaskIds, selectedCount, clearSelection]); 165 + }, [bulkDelete, selectedTaskIds, selectedCount, clearSelection, t]); 160 166 161 167 const handleBulkArchive = useCallback(async () => { 162 168 try { 163 169 await bulkArchive(Array.from(selectedTaskIds)); 164 - toast.success(`${selectedCount} tasks archived`); 170 + toast.success(t("tasks:bulk.archiveSuccess", { count: selectedCount })); 165 171 clearSelection(); 166 172 setIsActionsOpen(false); 167 173 } catch (_error) { 168 - toast.error("Failed to archive tasks"); 174 + toast.error(t("tasks:bulk.archiveError")); 169 175 } 170 - }, [bulkArchive, selectedTaskIds, selectedCount, clearSelection]); 176 + }, [bulkArchive, selectedTaskIds, selectedCount, clearSelection, t]); 171 177 172 178 const handleBulkChangeStatus = useCallback( 173 179 async (status: string) => { ··· 176 182 taskIds: Array.from(selectedTaskIds), 177 183 status, 178 184 }); 179 - toast.success(`${selectedCount} tasks updated`); 185 + toast.success(t("tasks:bulk.updateSuccess", { count: selectedCount })); 180 186 clearSelection(); 181 187 setIsActionsOpen(false); 182 188 } catch (_error) { 183 - toast.error("Failed to update tasks"); 189 + toast.error(t("tasks:bulk.updateError")); 184 190 } 185 191 }, 186 - [bulkChangeStatus, selectedTaskIds, selectedCount, clearSelection], 192 + [bulkChangeStatus, selectedTaskIds, selectedCount, clearSelection, t], 187 193 ); 188 194 189 195 const handleBulkAssign = useCallback( 190 196 async (userId: string) => { 191 197 try { 192 198 await bulkAssign({ taskIds: Array.from(selectedTaskIds), userId }); 193 - toast.success(`${selectedCount} tasks assigned`); 199 + toast.success(t("tasks:bulk.assignSuccess", { count: selectedCount })); 194 200 clearSelection(); 195 201 setIsActionsOpen(false); 196 202 } catch (_error) { 197 - toast.error("Failed to assign tasks"); 203 + toast.error(t("tasks:bulk.assignError")); 198 204 } 199 205 }, 200 - [bulkAssign, selectedTaskIds, selectedCount, clearSelection], 206 + [bulkAssign, selectedTaskIds, selectedCount, clearSelection, t], 201 207 ); 202 208 203 209 const handleBulkPriority = useCallback( ··· 207 213 taskIds: Array.from(selectedTaskIds), 208 214 priority, 209 215 }); 210 - toast.success(`${selectedCount} tasks updated`); 216 + toast.success(t("tasks:bulk.updateSuccess", { count: selectedCount })); 211 217 clearSelection(); 212 218 setIsActionsOpen(false); 213 219 } catch (_error) { 214 - toast.error("Failed to update priority"); 220 + toast.error(t("tasks:bulk.updatePriorityError")); 215 221 } 216 222 }, 217 - [bulkPriority, selectedTaskIds, selectedCount, clearSelection], 223 + [bulkPriority, selectedTaskIds, selectedCount, clearSelection, t], 218 224 ); 219 225 220 226 const handleBulkAddLabel = useCallback( ··· 224 230 taskIds: Array.from(selectedTaskIds), 225 231 labelId, 226 232 }); 227 - toast.success(`Label added to ${selectedCount} tasks`); 233 + toast.success( 234 + t("tasks:bulk.addLabelSuccess", { count: selectedCount }), 235 + ); 228 236 clearSelection(); 229 237 setIsActionsOpen(false); 230 238 } catch (_error) { 231 - toast.error("Failed to add label"); 239 + toast.error(t("tasks:bulk.addLabelError")); 232 240 } 233 241 }, 234 - [bulkAddLabel, selectedTaskIds, selectedCount, clearSelection], 242 + [bulkAddLabel, selectedTaskIds, selectedCount, clearSelection, t], 235 243 ); 236 244 237 245 const handleBulkDueDate = useCallback( ··· 241 249 taskIds: Array.from(selectedTaskIds), 242 250 dueDate: date?.toISOString() ?? null, 243 251 }); 244 - toast.success(`${selectedCount} tasks updated`); 252 + toast.success(t("tasks:bulk.updateSuccess", { count: selectedCount })); 245 253 clearSelection(); 246 254 setIsDatePickerOpen(false); 247 255 } catch (_error) { 248 - toast.error("Failed to update due date"); 256 + toast.error(t("tasks:bulk.updateDueDateError")); 249 257 } 250 258 }, 251 - [bulkDueDate, selectedTaskIds, selectedCount, clearSelection], 259 + [bulkDueDate, selectedTaskIds, selectedCount, clearSelection, t], 252 260 ); 253 261 254 262 const groupedItems = useMemo<BulkActionGroup[]>( 255 263 () => [ 256 264 { 257 265 value: "actions", 258 - label: "Actions", 266 + label: t("tasks:bulk.actions"), 259 267 items: [ 260 268 { 261 269 value: "bulk-delete", 262 - label: "Delete tasks", 270 + label: t("tasks:bulk.delete"), 263 271 icon: <Trash2 className="h-4 w-4 text-muted-foreground" />, 264 272 onRun: () => { 265 273 void handleBulkDelete(); ··· 267 275 }, 268 276 { 269 277 value: "bulk-archive", 270 - label: "Archive tasks", 278 + label: t("tasks:bulk.archive"), 271 279 icon: <Archive className="h-4 w-4 text-muted-foreground" />, 272 280 onRun: () => { 273 281 void handleBulkArchive(); ··· 277 285 }, 278 286 { 279 287 value: "status", 280 - label: "Change Status", 288 + label: t("tasks:bulk.changeStatus"), 281 289 items: (project?.columns ?? []).map((col) => ({ 282 290 value: `status-${col.id}`, 283 291 label: col.name, ··· 289 297 }, 290 298 { 291 299 value: "assign", 292 - label: "Assign to", 300 + label: t("tasks:bulk.assignTo"), 293 301 items: (workspaceUsers?.members ?? []).map((member) => ({ 294 302 value: `assign-${member.userId}`, 295 - label: member.user?.name || "Unknown User", 303 + label: member.user?.name || t("common:people.someone"), 296 304 icon: ( 297 305 <Avatar className="h-5 w-5"> 298 306 <AvatarImage ··· 311 319 }, 312 320 { 313 321 value: "priority", 314 - label: "Set Priority", 322 + label: t("tasks:bulk.setPriority"), 315 323 items: priorityOptions.map((opt) => ({ 316 324 value: `priority-${opt.value}`, 317 325 label: opt.label, ··· 323 331 }, 324 332 { 325 333 value: "label", 326 - label: "Add Label", 334 + label: t("tasks:bulk.addLabel"), 327 335 items: uniqueLabels.map((label) => ({ 328 336 value: `label-${label.id}`, 329 337 label: label.name, ··· 353 361 handleBulkAssign, 354 362 handleBulkPriority, 355 363 handleBulkAddLabel, 364 + priorityOptions, 365 + t, 356 366 ], 357 367 ); 358 368 ··· 363 373 <Toolbar className="items-center gap-1 rounded-xl border-border/80 bg-background px-1.5 py-1 shadow-lg/8"> 364 374 <ToolbarGroup className="px-1.5"> 365 375 <span className="text-sm font-medium text-foreground"> 366 - {selectedCount} selected 376 + {t("tasks:bulk.selectedCount", { count: selectedCount })} 367 377 </span> 368 378 </ToolbarGroup> 369 379 ··· 372 382 <ToolbarGroup> 373 383 <Button size="sm" variant="ghost" onClick={handleMoveToBacklog}> 374 384 <ArrowDownToLine className="size-4" /> 375 - Move to Backlog 385 + {t("tasks:bulk.moveToBacklog")} 376 386 </Button> 377 387 </ToolbarGroup> 378 388 ··· 383 393 <PopoverTrigger asChild> 384 394 <Button size="sm" variant="ghost"> 385 395 <CalendarIcon className="size-4" /> 386 - Set Due Date 396 + {t("tasks:bulk.setDueDate")} 387 397 </Button> 388 398 </PopoverTrigger> 389 399 <PopoverContent className="p-0" align="center"> ··· 400 410 onClick={() => handleBulkDueDate(undefined)} 401 411 > 402 412 <X className="h-4 w-4" /> 403 - Clear date 413 + {t("tasks:dueDate.clear")} 404 414 </Button> 405 415 </div> 406 416 </PopoverContent> ··· 416 426 onClick={() => setIsActionsOpen(true)} 417 427 > 418 428 <Menu className="size-4" /> 419 - Actions 429 + {t("tasks:bulk.actions")} 420 430 </Button> 421 431 </ToolbarGroup> 422 432 ··· 432 442 <CommandDialog open={isActionsOpen} onOpenChange={setIsActionsOpen}> 433 443 <CommandDialogPopup> 434 444 <Command items={groupedItems}> 435 - <CommandInput placeholder="Search actions..." /> 445 + <CommandInput placeholder={t("tasks:bulk.searchActions")} /> 436 446 <CommandPanel> 437 - <CommandEmpty>No actions found.</CommandEmpty> 447 + <CommandEmpty>{t("tasks:bulk.noActionsFound")}</CommandEmpty> 438 448 <CommandList> 439 449 {(group: BulkActionGroup, groupIndex: number) => ( 440 450 <Fragment key={group.value}>
+24 -18
apps/web/src/components/command-palette/index.tsx
··· 1 1 import { useLocation, useNavigate } from "@tanstack/react-router"; 2 2 import { ArrowDownIcon, ArrowUpIcon, CornerDownLeftIcon } from "lucide-react"; 3 3 import { Fragment, useCallback, useEffect, useMemo, useState } from "react"; 4 + import { useTranslation } from "react-i18next"; 4 5 import SearchCommandMenu from "@/components/search-command-menu"; 5 6 import CreateTaskModal from "@/components/shared/modals/create-task-modal"; 6 7 import CreateWorkspaceModal from "@/components/shared/modals/create-workspace-modal"; ··· 41 42 }; 42 43 43 44 function CommandPalette() { 45 + const { t } = useTranslation(); 44 46 const { setTheme } = useUserPreferencesStore(); 45 47 const navigate = useNavigate(); 46 48 const location = useLocation(); ··· 97 99 () => [ 98 100 { 99 101 value: "suggestions", 100 - label: "Suggestions", 102 + label: t("navigation:commandPalette.suggestions"), 101 103 items: [ 102 104 { 103 105 value: "projects", 104 - label: "Projects", 106 + label: t("navigation:commandPalette.projects"), 105 107 shortcut: `${shortcuts.project.prefix} ${shortcuts.project.list}`, 106 108 onRun: () => { 107 109 if (!workspace?.id) return; ··· 113 115 }, 114 116 { 115 117 value: "search", 116 - label: "Search", 118 + label: t("navigation:commandPalette.search"), 117 119 shortcut: shortcuts.search.prefix, 118 120 onRun: () => setIsSearchOpen(true), 119 121 }, 120 122 { 121 123 value: "members", 122 - label: "Members", 124 + label: t("navigation:commandPalette.members"), 123 125 onRun: () => { 124 126 if (!workspace?.id) return; 125 127 navigate({ ··· 130 132 }, 131 133 { 132 134 value: "create-task", 133 - label: "Create task", 135 + label: t("navigation:commandPalette.createTask"), 134 136 shortcut: `${shortcuts.task.prefix} ${shortcuts.task.create}`, 135 137 onRun: () => setIsCreateTaskOpen(true), 136 138 }, 137 139 { 138 140 value: "create-project", 139 - label: "Create project", 141 + label: t("navigation:commandPalette.createProject"), 140 142 shortcut: `${shortcuts.project.prefix} ${shortcuts.project.create}`, 141 143 onRun: () => setIsCreateProjectOpen(true), 142 144 }, ··· 144 146 }, 145 147 { 146 148 value: "commands", 147 - label: "Commands", 149 + label: t("navigation:commandPalette.commands"), 148 150 items: [ 149 151 { 150 152 value: "create-workspace", 151 - label: "Create workspace", 153 + label: t("navigation:commandPalette.createWorkspace"), 152 154 shortcut: `${shortcuts.workspace.prefix} ${shortcuts.workspace.create}`, 153 155 onRun: () => setIsCreateWorkspaceOpen(true), 154 156 }, 155 157 { 156 158 value: "theme-light", 157 - label: "Light theme", 159 + label: t("navigation:commandPalette.lightTheme"), 158 160 onRun: () => setTheme("light"), 159 161 }, 160 162 { 161 163 value: "theme-dark", 162 - label: "Dark theme", 164 + label: t("navigation:commandPalette.darkTheme"), 163 165 onRun: () => setTheme("dark"), 164 166 }, 165 167 { 166 168 value: "theme-system", 167 - label: "System theme", 169 + label: t("navigation:commandPalette.systemTheme"), 168 170 onRun: () => setTheme("system"), 169 171 }, 170 172 { 171 173 value: "keyboard-shortcuts", 172 - label: "Keyboard shortcuts", 174 + label: t("navigation:commandPalette.keyboardShortcuts"), 173 175 shortcut: "?", 174 176 onRun: () => { 175 177 setTimeout(() => { ··· 182 184 ], 183 185 }, 184 186 ], 185 - [navigate, setTheme, workspace?.id], 187 + [navigate, setTheme, t, workspace?.id], 186 188 ); 187 189 188 190 const shortcutHandlers = useMemo(() => { ··· 246 248 <CommandDialog open={open} onOpenChange={setOpen}> 247 249 <CommandDialogPopup> 248 250 <Command items={groupedItems}> 249 - <CommandInput placeholder="Search for apps and commands..." /> 251 + <CommandInput 252 + placeholder={t("navigation:commandPalette.inputPlaceholder")} 253 + /> 250 254 <CommandPanel> 251 - <CommandEmpty>No results found.</CommandEmpty> 255 + <CommandEmpty> 256 + {t("navigation:commandPalette.empty")} 257 + </CommandEmpty> 252 258 <CommandList> 253 259 {(group: PaletteGroup, groupIndex: number) => ( 254 260 <Fragment key={group.value}> ··· 292 298 <ArrowDownIcon /> 293 299 </Kbd> 294 300 </KbdGroup> 295 - <span>Navigate</span> 301 + <span>{t("navigation:commandPalette.footer.navigate")}</span> 296 302 </div> 297 303 <div className="flex items-center gap-2"> 298 304 <Kbd> 299 305 <CornerDownLeftIcon /> 300 306 </Kbd> 301 - <span>Open</span> 307 + <span>{t("navigation:commandPalette.footer.open")}</span> 302 308 </div> 303 309 </div> 304 310 <div className="flex items-center gap-2"> 305 311 <Kbd>Esc</Kbd> 306 - <span>Close</span> 312 + <span>{t("navigation:commandPalette.footer.close")}</span> 307 313 </div> 308 314 </CommandFooter> 309 315 </Command>
+6 -4
apps/web/src/components/common/header/project-crumb-select.tsx
··· 1 1 import { ChevronsUpDown, Plus } from "lucide-react"; 2 + import { useTranslation } from "react-i18next"; 2 3 import { Button } from "@/components/ui/button"; 3 4 import { 4 5 DropdownMenu, ··· 26 27 onSelectProject, 27 28 onAddProject, 28 29 }: ProjectCrumbSelectProps) { 30 + const { t } = useTranslation(); 29 31 const { data: projects = [] } = useGetProjects({ workspaceId }); 30 32 31 33 return ( ··· 40 42 } 41 43 > 42 44 <span className="truncate text-left"> 43 - {projectName || "Select project"} 45 + {projectName || t("settings:projectSwitcher.selectProject")} 44 46 </span> 45 47 <ChevronsUpDown className="size-3 text-foreground/70" /> 46 48 </DropdownMenuTrigger> 47 49 <DropdownMenuContent className="w-72" align="start"> 48 50 <DropdownMenuGroup> 49 51 <DropdownMenuLabel className="text-[11px] uppercase tracking-wide"> 50 - Projects 52 + {t("navigation:sidebar.projects")} 51 53 </DropdownMenuLabel> 52 54 </DropdownMenuGroup> 53 55 <DropdownMenuSeparator /> ··· 70 72 disabled 71 73 className="h-8 text-sm text-muted-foreground" 72 74 > 73 - No projects 75 + {t("settings:projectSwitcher.noProjects")} 74 76 </DropdownMenuItem> 75 77 )} 76 78 </DropdownMenuGroup> ··· 81 83 className="h-8 gap-2 text-sm" 82 84 > 83 85 <Plus className="size-3.5" /> 84 - Add project 86 + {t("navigation:projectList.addProject")} 85 87 </DropdownMenuItem> 86 88 </DropdownMenuGroup> 87 89 </DropdownMenuContent>
+7 -3
apps/web/src/components/common/header/task-crumb-select.tsx
··· 1 1 import { ChevronsUpDown } from "lucide-react"; 2 + import { useTranslation } from "react-i18next"; 2 3 import { Button } from "@/components/ui/button"; 3 4 import { 4 5 DropdownMenu, ··· 24 25 taskLabel, 25 26 onSelectTask, 26 27 }: TaskCrumbSelectProps) { 28 + const { t } = useTranslation(); 27 29 const { data: project } = useGetTasks(projectId); 28 30 const tasks = [ 29 31 ...(project?.columns?.flatMap((column) => column.tasks) ?? []), ··· 42 44 /> 43 45 } 44 46 > 45 - <span className="truncate text-left">{taskLabel || "Select task"}</span> 47 + <span className="truncate text-left"> 48 + {taskLabel || t("tasks:common.selectTask")} 49 + </span> 46 50 <ChevronsUpDown className="size-3.5 text-muted-foreground" /> 47 51 </DropdownMenuTrigger> 48 52 <DropdownMenuContent className="w-80" align="start"> 49 53 <DropdownMenuGroup> 50 54 <DropdownMenuLabel className="text-[11px] uppercase tracking-wide"> 51 - Tasks 55 + {t("navigation:search.groups.task")} 52 56 </DropdownMenuLabel> 53 57 </DropdownMenuGroup> 54 58 <DropdownMenuSeparator /> ··· 72 76 disabled 73 77 className="h-8 text-sm text-muted-foreground" 74 78 > 75 - No tasks 79 + {t("tasks:listView.noTasks")} 76 80 </DropdownMenuItem> 77 81 )} 78 82 </DropdownMenuGroup>
+3 -1
apps/web/src/components/common/header/workspace-crumb-select.tsx
··· 1 1 import { useNavigate } from "@tanstack/react-router"; 2 + import { useTranslation } from "react-i18next"; 2 3 import { Button } from "@/components/ui/button"; 3 4 import useActiveWorkspace from "@/hooks/queries/workspace/use-active-workspace"; 4 5 5 6 export default function WorkspaceCrumbSelect() { 7 + const { t } = useTranslation(); 6 8 const { data: workspace } = useActiveWorkspace(); 7 9 const navigate = useNavigate(); 8 10 ··· 19 21 }} 20 22 > 21 23 <span className="truncate text-left"> 22 - {workspace?.name || "Select workspace"} 24 + {workspace?.name || t("navigation:workspaceSwitcher.selectWorkspace")} 23 25 </span> 24 26 </Button> 25 27 );
+20 -15
apps/web/src/components/common/sort-control.tsx
··· 1 1 import { ArrowDownAZ, ArrowUpAZ } from "lucide-react"; 2 + import { useTranslation } from "react-i18next"; 2 3 import { 3 4 DropdownMenu, 4 5 DropdownMenuContent, ··· 15 16 onSortChange: (sort: SortConfig) => void; 16 17 }; 17 18 18 - const sortFields: { field: SortField; label: string }[] = [ 19 - { field: "position", label: "Manual (position)" }, 20 - { field: "createdAt", label: "Created date" }, 21 - { field: "priority", label: "Priority" }, 22 - { field: "dueDate", label: "Due date" }, 23 - { field: "title", label: "Title" }, 24 - { field: "number", label: "Task number" }, 25 - ]; 26 - 27 19 function CheckSlot({ checked }: { checked: boolean }) { 28 20 return ( 29 21 <span ··· 39 31 } 40 32 41 33 export default function SortControl({ sort, onSortChange }: SortControlProps) { 34 + const { t } = useTranslation(); 35 + const sortFields: { field: SortField; label: string }[] = [ 36 + { field: "position", label: t("tasks:sort.fields.position") }, 37 + { field: "createdAt", label: t("tasks:sort.fields.createdAt") }, 38 + { field: "priority", label: t("tasks:sort.fields.priority") }, 39 + { field: "dueDate", label: t("tasks:sort.fields.dueDate") }, 40 + { field: "title", label: t("tasks:sort.fields.title") }, 41 + { field: "number", label: t("tasks:sort.fields.number") }, 42 + ]; 42 43 const isActive = sort.field !== "position"; 43 44 const activeLabel = sortFields.find((f) => f.field === sort.field)?.label; 44 45 ··· 79 80 ) : ( 80 81 <ArrowDownAZ className="h-3 w-3" /> 81 82 )} 82 - {isActive ? activeLabel : "Sort"} 83 + {isActive ? activeLabel : t("tasks:sort.label")} 83 84 </DropdownMenuTrigger> 84 85 <DropdownMenuContent className="w-48" align="start"> 85 86 <DropdownMenuGroup> 86 87 <DropdownMenuLabel className="text-[11px] uppercase tracking-wide"> 87 - Sort By 88 + {t("tasks:sort.by")} 88 89 </DropdownMenuLabel> 89 90 </DropdownMenuGroup> 90 91 <DropdownMenuSeparator /> ··· 104 105 <DropdownMenuSeparator /> 105 106 <DropdownMenuGroup> 106 107 <DropdownMenuLabel className="text-[11px] uppercase tracking-wide"> 107 - Direction 108 + {t("tasks:sort.direction")} 108 109 </DropdownMenuLabel> 109 110 </DropdownMenuGroup> 110 111 <DropdownMenuItem ··· 112 113 className="h-8 rounded-md text-sm" 113 114 > 114 115 <CheckSlot checked={sort.direction === "asc"} /> 115 - Ascending 116 + {t("tasks:sort.ascending")} 116 117 </DropdownMenuItem> 117 118 <DropdownMenuItem 118 119 onClick={() => onSortChange({ ...sort, direction: "desc" })} 119 120 className="h-8 rounded-md text-sm" 120 121 > 121 122 <CheckSlot checked={sort.direction === "desc"} /> 122 - Descending 123 + {t("tasks:sort.descending")} 123 124 </DropdownMenuItem> 124 125 </> 125 126 )} ··· 131 132 type="button" 132 133 onClick={toggleDirection} 133 134 className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-border bg-background text-foreground hover:bg-accent/60" 134 - title={sort.direction === "asc" ? "Ascending" : "Descending"} 135 + title={ 136 + sort.direction === "asc" 137 + ? t("tasks:sort.ascending") 138 + : t("tasks:sort.descending") 139 + } 135 140 > 136 141 {sort.direction === "asc" ? ( 137 142 <ArrowUpAZ className="h-3 w-3" />
+4 -2
apps/web/src/components/common/task-layout.tsx
··· 1 1 import { useNavigate } from "@tanstack/react-router"; 2 2 import type { ReactNode } from "react"; 3 + import { useTranslation } from "react-i18next"; 3 4 import TaskCrumbSelect from "@/components/common/header/task-crumb-select"; 4 5 import Layout from "@/components/common/layout"; 5 6 import { KbdSequence } from "@/components/ui/kbd"; ··· 31 32 children, 32 33 rightSidebar, 33 34 }: TaskLayoutProps) { 35 + const { t } = useTranslation(); 34 36 const navigate = useNavigate(); 35 37 const { data: project } = useGetProject({ id: projectId, workspaceId }); 36 38 const { data: task } = useGetTask(taskId); 37 39 const taskLabel = 38 40 project?.slug && task?.number != null 39 41 ? `${project.slug}-${task.number}` 40 - : "Select task"; 42 + : t("tasks:common.selectTask"); 41 43 42 44 const handleTaskSwitch = (nextTaskId: string) => { 43 45 navigate({ ··· 84 86 } 85 87 className="max-w-40 truncate text-left text-xs text-foreground hover:underline" 86 88 > 87 - {project?.name || "Project"} 89 + {project?.name || t("navigation:sidebar.projects")} 88 90 </button> 89 91 <span className="text-foreground/70 text-xs">/</span> 90 92 <TaskCrumbSelect
+7 -5
apps/web/src/components/gantt/gantt-task-bar.tsx
··· 1 1 import { addDays, differenceInCalendarDays, startOfDay } from "date-fns"; 2 2 import { useCallback, useEffect, useState } from "react"; 3 + import { useTranslation } from "react-i18next"; 3 4 import { useUpdateTask } from "@/hooks/mutations/task/use-update-task"; 4 5 import { cn } from "@/lib/cn"; 5 6 import { toast } from "@/lib/toast"; ··· 56 57 isMobile = false, 57 58 onOpenTask, 58 59 }: GanttTaskBarProps) { 60 + const { t } = useTranslation(); 59 61 const { mutateAsync: updateTask } = useUpdateTask(); 60 62 const [dragDisplay, setDragDisplay] = useState<{ 61 63 start: Date; ··· 98 100 toast.error( 99 101 error instanceof Error 100 102 ? error.message 101 - : "Failed to update task dates", 103 + : t("tasks:gantt.updateDatesError"), 102 104 ); 103 105 return false; 104 106 } 105 107 }, 106 - [task, updateTask], 108 + [task, updateTask, t], 107 109 ); 108 110 109 111 const pxPerDay = Math.max(pixelsPerDay, 1e-6); ··· 291 293 > 292 294 <button 293 295 type="button" 294 - aria-label="Resize start date" 296 + aria-label={t("tasks:gantt.resizeStart")} 295 297 onPointerDown={handleResizeLeftPointerDown} 296 298 className={cn( 297 299 "relative z-20 shrink-0 cursor-ew-resize touch-none border-r border-primary/15 bg-primary/8 hover:bg-primary/18", ··· 301 303 /> 302 304 <button 303 305 type="button" 304 - aria-label={`${task.title} — open or drag to move`} 306 + aria-label={t("tasks:gantt.taskAriaLabel", { title: task.title })} 305 307 className="relative z-10 min-h-[44px] min-w-0 flex-1 cursor-grab touch-manipulation overflow-hidden px-2 text-left active:cursor-grabbing sm:min-h-0 sm:px-2.5" 306 308 onPointerDown={handleMovePointerDown} 307 309 onKeyDown={(e) => { ··· 316 318 </button> 317 319 <button 318 320 type="button" 319 - aria-label="Resize due date" 321 + aria-label={t("tasks:gantt.resizeDue")} 320 322 onPointerDown={handleResizeRightPointerDown} 321 323 className={cn( 322 324 "relative z-20 shrink-0 cursor-ew-resize touch-none border-l border-primary/15 bg-primary/8 hover:bg-primary/18",
+3 -1
apps/web/src/components/kanban-board/column/column-footer.tsx
··· 1 1 import { Plus } from "lucide-react"; 2 2 import { useState } from "react"; 3 + import { useTranslation } from "react-i18next"; 3 4 import CreateTaskModal from "@/components/shared/modals/create-task-modal"; 4 5 import type { ProjectWithTasks } from "@/types/project"; 5 6 ··· 8 9 }; 9 10 10 11 export function ColumnFooter({ column }: ColumnFooterProps) { 12 + const { t } = useTranslation(); 11 13 const [isTaskModalOpen, setIsTaskModalOpen] = useState(false); 12 14 13 15 return ( ··· 24 26 className="w-full text-left px-3 py-2 text-sm text-muted-foreground hover:bg-accent/50 rounded-md flex items-center gap-2 transition-all" 25 27 > 26 28 <Plus className="w-4 h-4 text-muted-foreground" /> 27 - <span>Add task</span> 29 + <span>{t("tasks:kanban.addTask")}</span> 28 30 </button> 29 31 </div> 30 32 </>
+4 -2
apps/web/src/components/kanban-board/column/column-header.tsx
··· 1 1 import { produce } from "immer"; 2 2 import { Archive } from "lucide-react"; 3 3 import { useState } from "react"; 4 + import { useTranslation } from "react-i18next"; 4 5 import { useUpdateTask } from "@/hooks/mutations/task/use-update-task"; 5 6 import { getColumnIcon } from "@/lib/column"; 6 7 import { toast } from "@/lib/toast"; ··· 13 14 }; 14 15 15 16 export function ColumnHeader({ column }: ColumnHeaderProps) { 17 + const { t } = useTranslation(); 16 18 const { project, setProject } = useProjectStore(); 17 19 const { mutate: updateTask } = useUpdateTask(); 18 20 ··· 38 40 }); 39 41 40 42 setProject(updatedProject); 41 - toast.success(`Archived ${column.tasks.length} tasks`); 43 + toast.success(t("tasks:archive.success", { count: column.tasks.length })); 42 44 setIsArchiveModalOpen(false); 43 45 }; 44 46 ··· 62 64 type="button" 63 65 onClick={() => setIsArchiveModalOpen(true)} 64 66 className="flex items-center rounded-md px-2 py-1 text-left text-muted-foreground transition-all hover:bg-accent/50" 65 - title="Archive all completed tasks" 67 + title={t("tasks:listView.archiveAllTooltip")} 66 68 > 67 69 <Archive className="w-4 h-4 text-muted-foreground" /> 68 70 </button>
+3 -1
apps/web/src/components/kanban-board/column/index.tsx
··· 1 1 import { Plus } from "lucide-react"; 2 2 import { useState } from "react"; 3 + import { useTranslation } from "react-i18next"; 3 4 import CreateTaskModal from "@/components/shared/modals/create-task-modal"; 4 5 import useProjectStore from "@/store/project"; 5 6 import type { ProjectWithTasks } from "@/types/project"; ··· 11 12 }; 12 13 13 14 function Column({ column }: ColumnProps) { 15 + const { t } = useTranslation(); 14 16 const [isTaskModalOpen, setIsTaskModalOpen] = useState(false); 15 17 const [isDropzoneOver, setIsDropzoneOver] = useState(false); 16 18 const { project } = useProjectStore(); ··· 45 47 className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-left text-sm text-muted-foreground transition-all hover:bg-accent/50 hover:text-foreground" 46 48 > 47 49 <Plus className="w-4 h-4" /> 48 - <span>Add task</span> 50 + <span>{t("tasks:kanban.addTask")}</span> 49 51 </button> 50 52 </div> 51 53 </div>
+23 -20
apps/web/src/components/kanban-board/task-card-context-menu/task-card-context-menu-content.tsx
··· 1 1 import { X } from "lucide-react"; 2 2 import { useMemo } from "react"; 3 + import { useTranslation } from "react-i18next"; 3 4 import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 4 5 import { Calendar } from "@/components/ui/calendar"; 5 6 import { ··· 22 23 import { useGetActiveWorkspaceUsers } from "@/hooks/queries/workspace-users/use-get-active-workspace-users"; 23 24 import { getColumnIcon } from "@/lib/column"; 24 25 import { generateLink } from "@/lib/generate-link"; 26 + import { getPriorityLabel } from "@/lib/i18n/domain"; 25 27 import { getPriorityIcon } from "@/lib/priority"; 26 28 import { toast } from "@/lib/toast"; 27 29 import useProjectStore from "@/store/project"; ··· 43 45 taskCardContext, 44 46 onDeleteClick, 45 47 }: TaskCardContextMenuContentProps) { 48 + const { t } = useTranslation(); 46 49 const { project } = useProjectStore(); 47 50 const { data: columnsData = [] } = useGetColumns(taskCardContext.projectId); 48 51 const columns = ··· 82 85 const taskLink = generateLink(path); 83 86 84 87 navigator.clipboard.writeText(taskLink); 85 - toast.success("Task link copied!"); 88 + toast.success(t("tasks:contextMenu.copyLinkSuccess")); 86 89 }; 87 90 88 91 const handleChange = async (field: keyof Task, value: string | Date) => { ··· 114 117 } 115 118 } catch (error) { 116 119 toast.error( 117 - error instanceof Error ? error.message : "Failed to update task", 120 + error instanceof Error ? error.message : t("tasks:update.error"), 118 121 ); 119 122 } finally { 120 - toast.success("Task updated successfully"); 123 + toast.success(t("tasks:update.success")); 121 124 } 122 125 }; 123 126 124 127 return ( 125 128 <ContextMenuContent className="w-46"> 126 129 <ContextMenuItem onClick={handleCopyTaskLink}> 127 - <span>Copy link</span> 130 + <span>{t("tasks:contextMenu.copyLink")}</span> 128 131 </ContextMenuItem> 129 132 130 133 <ContextMenuSeparator /> 131 134 132 135 <ContextMenuSub> 133 136 <ContextMenuSubTrigger className="gap-2"> 134 - <span>Priority</span> 137 + <span>{t("tasks:priority.label")}</span> 135 138 </ContextMenuSubTrigger> 136 139 <ContextMenuSubContent className="w-48"> 137 140 <ContextMenuCheckboxItem ··· 142 145 className="[&_svg]:text-muted-foreground" 143 146 > 144 147 {getPriorityIcon("no-priority")} 145 - <span>No Priority</span> 148 + <span>{getPriorityLabel("no-priority")}</span> 146 149 </ContextMenuCheckboxItem> 147 150 {["low", "medium", "high", "urgent"].map((priority) => ( 148 151 <ContextMenuCheckboxItem ··· 153 156 className="[&_svg]:text-muted-foreground" 154 157 > 155 158 {getPriorityIcon(priority)} 156 - <span className="capitalize">{priority}</span> 159 + <span className="capitalize">{getPriorityLabel(priority)}</span> 157 160 </ContextMenuCheckboxItem> 158 161 ))} 159 162 </ContextMenuSubContent> ··· 161 164 162 165 <ContextMenuSub> 163 166 <ContextMenuSubTrigger> 164 - <span>Status</span> 167 + <span>{t("tasks:status.label")}</span> 165 168 </ContextMenuSubTrigger> 166 169 <ContextMenuSubContent className="w-48"> 167 170 {columns.map((col) => ( ··· 181 184 182 185 <ContextMenuSub> 183 186 <ContextMenuSubTrigger> 184 - <span>Due date</span> 187 + <span>{t("tasks:dueDate.label")}</span> 185 188 </ContextMenuSubTrigger> 186 189 <ContextMenuSubContent className="w-fit min-w-0 p-0"> 187 190 <div className="p-2"> ··· 194 197 ...task, 195 198 dueDate: date?.toISOString() || null, 196 199 }); 197 - toast.success("Task due date updated successfully"); 200 + toast.success(t("tasks:dueDate.updateSuccess")); 198 201 } catch (error) { 199 202 toast.error( 200 203 error instanceof Error 201 204 ? error.message 202 - : "Failed to update task due date", 205 + : t("tasks:dueDate.updateError"), 203 206 ); 204 207 } 205 208 }} ··· 217 220 ...task, 218 221 dueDate: null, 219 222 }); 220 - toast.success("Task due date cleared"); 223 + toast.success(t("tasks:dueDate.clearSuccess")); 221 224 } catch (error) { 222 225 toast.error( 223 226 error instanceof Error 224 227 ? error.message 225 - : "Failed to clear due date", 228 + : t("tasks:dueDate.clearError"), 226 229 ); 227 230 } 228 231 }} 229 232 > 230 233 <X className="h-4 w-4" /> 231 - <span>Clear date</span> 234 + <span>{t("tasks:dueDate.clear")}</span> 232 235 </ContextMenuItem> 233 236 </> 234 237 )} ··· 238 241 {usersOptions && ( 239 242 <ContextMenuSub> 240 243 <ContextMenuSubTrigger> 241 - <span>Assignee</span> 244 + <span>{t("tasks:assignee.label")}</span> 242 245 </ContextMenuSubTrigger> 243 246 <ContextMenuSubContent className="w-48"> 244 247 <ContextMenuCheckboxItem ··· 248 251 > 249 252 <div 250 253 className="w-6 h-6 rounded-full bg-muted border border-border flex items-center justify-center" 251 - title="Unassigned" 254 + title={t("tasks:assignee.unassigned")} 252 255 > 253 256 <span className="text-[10px] font-medium text-muted-foreground"> 254 257 ? 255 258 </span>{" "} 256 259 </div> 257 - Unassigned 260 + {t("tasks:assignee.unassigned")} 258 261 </ContextMenuCheckboxItem> 259 262 {usersOptions.map((user) => ( 260 263 <ContextMenuCheckboxItem ··· 280 283 <ContextMenuSeparator /> 281 284 282 285 <ContextMenuItem onClick={() => handleChange("status", "archived")}> 283 - <span>Archive</span> 286 + <span>{t("tasks:actions.archive")}</span> 284 287 </ContextMenuItem> 285 288 286 289 <ContextMenuItem onClick={() => handleChange("status", "planned")}> 287 - <span>Mark as planned</span> 290 + <span>{t("tasks:actions.markAsPlanned")}</span> 288 291 </ContextMenuItem> 289 292 290 293 <ContextMenuSeparator /> ··· 298 301 }, 0); 299 302 }} 300 303 > 301 - <span>Delete...</span> 304 + <span>{t("tasks:actions.delete")}</span> 302 305 </ContextMenuItem> 303 306 </ContextMenuContent> 304 307 );
+19 -14
apps/web/src/components/kanban-board/task-card.tsx
··· 10 10 GitPullRequest, 11 11 } from "lucide-react"; 12 12 import { type CSSProperties, useMemo, useState } from "react"; 13 + import { useTranslation } from "react-i18next"; 13 14 import { 14 15 AlertDialog, 15 16 AlertDialogClose, ··· 47 48 }; 48 49 49 50 function TaskCard({ task }: TaskCardProps) { 51 + const { t } = useTranslation(); 50 52 const { 51 53 attributes, 52 54 listeners, ··· 84 86 if (isMerged) { 85 87 return { 86 88 icon: <GitMerge className="h-3 w-3 text-info-foreground" />, 87 - status: "Merged", 89 + status: t("tasks:pr.merged"), 88 90 statusClass: "text-info-foreground", 89 91 }; 90 92 } ··· 92 94 if (isDraft) { 93 95 return { 94 96 icon: <GitPullRequest className="h-3 w-3 text-muted-foreground" />, 95 - status: "Draft", 97 + status: t("tasks:pr.draft"), 96 98 statusClass: "text-muted-foreground", 97 99 }; 98 100 } 99 101 100 102 return { 101 103 icon: <GitPullRequest className="h-3 w-3 text-success-foreground" />, 102 - status: "Open", 104 + status: t("tasks:pr.open"), 103 105 statusClass: "text-success-foreground", 104 106 }; 105 107 }; ··· 163 165 }); 164 166 } catch (error) { 165 167 toast.error( 166 - error instanceof Error ? error.message : "Failed to delete task", 168 + error instanceof Error ? error.message : t("tasks:delete.error"), 167 169 ); 168 170 } finally { 169 - toast.success("Task deleted successfully"); 171 + toast.success(t("tasks:delete.success")); 170 172 } 171 173 }; 172 174 ··· 215 217 ) : ( 216 218 <div 217 219 className="flex h-5 w-5 items-center justify-center rounded-full border border-border bg-muted" 218 - title="Unassigned" 220 + title={t("tasks:assignee.unassigned")} 219 221 > 220 222 <span className="text-[10px] font-medium text-muted-foreground"> 221 223 ? ··· 300 302 <span>#{pullRequests[0].externalId}</span> 301 303 </div> 302 304 <p className="text-sm font-medium leading-snug"> 303 - {pullRequests[0].title || "Pull Request"} 305 + {pullRequests[0].title || t("tasks:pr.label")} 304 306 </p> 305 307 </div> 306 308 </HoverCardContent> ··· 330 332 className="inline-flex items-center gap-1.5 rounded border border-border/70 bg-muted/55 px-2 py-1 text-[10px] font-medium text-muted-foreground" 331 333 > 332 334 <GitPullRequest className={`h-3 w-3 ${iconColor}`} /> 333 - <span>{pullRequests.length} PRs</span> 335 + <span> 336 + {t("tasks:pr.count", { 337 + count: pullRequests.length, 338 + })} 339 + </span> 334 340 </button> 335 341 </HoverCardTrigger> 336 342 <HoverCardContent ··· 362 368 </span> 363 369 </div> 364 370 <p className="text-xs leading-tight line-clamp-2 mt-0.5"> 365 - {pr.title || "Pull Request"} 371 + {pr.title || t("tasks:pr.label")} 366 372 </p> 367 373 <span className="text-[10px] text-muted-foreground"> 368 374 {prInfo.status} ··· 397 403 > 398 404 <AlertDialogContent> 399 405 <AlertDialogHeader> 400 - <AlertDialogTitle>Delete Task?</AlertDialogTitle> 406 + <AlertDialogTitle>{t("tasks:delete.title")}</AlertDialogTitle> 401 407 <AlertDialogDescription> 402 - This will permanently remove the task and all its data. You can't 403 - undo this action. 408 + {t("tasks:delete.description")} 404 409 </AlertDialogDescription> 405 410 </AlertDialogHeader> 406 411 <AlertDialogFooter> 407 412 <AlertDialogClose> 408 413 <Button variant="outline" size="sm"> 409 - Cancel 414 + {t("common:actions.cancel")} 410 415 </Button> 411 416 </AlertDialogClose> 412 417 <AlertDialogClose onClick={handleDeleteTask}> 413 418 <Button variant="destructive" size="sm"> 414 - Delete Task 419 + {t("tasks:delete.action")} 415 420 </Button> 416 421 </AlertDialogClose> 417 422 </AlertDialogFooter>
+102 -82
apps/web/src/components/keyboard-shortcuts-help.tsx
··· 1 - import { useEffect, useState } from "react"; 1 + import { useEffect, useMemo, useState } from "react"; 2 + import { Trans, useTranslation } from "react-i18next"; 2 3 import { 3 4 Dialog, 4 5 DialogContent, ··· 21 22 shortcuts: ShortcutItem[]; 22 23 }; 23 24 24 - const shortcutCategories: ShortcutCategory[] = [ 25 - { 26 - title: "General", 27 - shortcuts: [ 28 - { 29 - keys: [shortcuts.palette.prefix, shortcuts.palette.open], 30 - description: "Open command palette", 31 - }, 32 - { 33 - keys: [shortcuts.search.prefix], 34 - description: "Global search", 35 - }, 36 - { 37 - keys: [shortcuts.sidebar.prefix, shortcuts.sidebar.toggle], 38 - description: "Toggle sidebar", 39 - }, 40 - { 41 - keys: ["?"], 42 - description: "Show keyboard shortcuts", 43 - }, 44 - { 45 - keys: ["Escape"], 46 - description: "Close modal/popover", 47 - }, 48 - ], 49 - }, 50 - { 51 - title: "Create", 52 - shortcuts: [ 53 - { 54 - keys: [shortcuts.task.prefix, shortcuts.task.create], 55 - description: "Create task", 56 - }, 57 - { 58 - keys: [shortcuts.project.prefix, shortcuts.project.create], 59 - description: "Create project", 60 - }, 61 - { 62 - keys: [shortcuts.workspace.prefix, shortcuts.workspace.create], 63 - description: "Create workspace", 64 - }, 65 - ], 66 - }, 67 - { 68 - title: "Views", 69 - shortcuts: [ 25 + function useShortcutCategories(): ShortcutCategory[] { 26 + const { t } = useTranslation(); 27 + 28 + return useMemo( 29 + () => [ 70 30 { 71 - keys: [shortcuts.view.prefix, shortcuts.view.board], 72 - description: "Switch to board view", 73 - }, 74 - { 75 - keys: [shortcuts.view.prefix, shortcuts.view.list], 76 - description: "Switch to list view", 31 + title: t("navigation:keyboardShortcuts.categories.general"), 32 + shortcuts: [ 33 + { 34 + keys: [shortcuts.palette.prefix, shortcuts.palette.open], 35 + description: t( 36 + "navigation:keyboardShortcuts.items.openCommandPalette", 37 + ), 38 + }, 39 + { 40 + keys: [shortcuts.search.prefix], 41 + description: t("navigation:keyboardShortcuts.items.globalSearch"), 42 + }, 43 + { 44 + keys: [shortcuts.sidebar.prefix, shortcuts.sidebar.toggle], 45 + description: t("navigation:keyboardShortcuts.items.toggleSidebar"), 46 + }, 47 + { 48 + keys: ["?"], 49 + description: t("navigation:keyboardShortcuts.items.showShortcuts"), 50 + }, 51 + { 52 + keys: ["Escape"], 53 + description: t("navigation:keyboardShortcuts.items.closeModal"), 54 + }, 55 + ], 77 56 }, 78 57 { 79 - keys: [shortcuts.view.prefix, shortcuts.view.backlog], 80 - description: "Switch to backlog view", 81 - }, 82 - ], 83 - }, 84 - { 85 - title: "Navigation", 86 - shortcuts: [ 87 - { 88 - keys: ["j"], 89 - description: "Next task", 58 + title: t("navigation:keyboardShortcuts.categories.create"), 59 + shortcuts: [ 60 + { 61 + keys: [shortcuts.task.prefix, shortcuts.task.create], 62 + description: t("navigation:keyboardShortcuts.items.createTask"), 63 + }, 64 + { 65 + keys: [shortcuts.project.prefix, shortcuts.project.create], 66 + description: t("navigation:keyboardShortcuts.items.createProject"), 67 + }, 68 + { 69 + keys: [shortcuts.workspace.prefix, shortcuts.workspace.create], 70 + description: t( 71 + "navigation:keyboardShortcuts.items.createWorkspace", 72 + ), 73 + }, 74 + ], 90 75 }, 91 76 { 92 - keys: ["k"], 93 - description: "Previous task", 77 + title: t("navigation:keyboardShortcuts.categories.views"), 78 + shortcuts: [ 79 + { 80 + keys: [shortcuts.view.prefix, shortcuts.view.board], 81 + description: t("navigation:keyboardShortcuts.items.boardView"), 82 + }, 83 + { 84 + keys: [shortcuts.view.prefix, shortcuts.view.list], 85 + description: t("navigation:keyboardShortcuts.items.listView"), 86 + }, 87 + { 88 + keys: [shortcuts.view.prefix, shortcuts.view.backlog], 89 + description: t("navigation:keyboardShortcuts.items.backlogView"), 90 + }, 91 + ], 94 92 }, 95 93 { 96 - keys: ["Enter"], 97 - description: "Open selected task", 94 + title: t("navigation:keyboardShortcuts.categories.navigation"), 95 + shortcuts: [ 96 + { 97 + keys: ["j"], 98 + description: t("navigation:keyboardShortcuts.items.nextTask"), 99 + }, 100 + { 101 + keys: ["k"], 102 + description: t("navigation:keyboardShortcuts.items.prevTask"), 103 + }, 104 + { 105 + keys: ["Enter"], 106 + description: t("navigation:keyboardShortcuts.items.openTask"), 107 + }, 108 + ], 98 109 }, 99 - ], 100 - }, 101 - { 102 - title: "Quick Select (in popovers)", 103 - shortcuts: [ 104 110 { 105 - keys: ["1", "2", "3", "..."], 106 - description: "Select option by number", 111 + title: t("navigation:keyboardShortcuts.categories.quickSelect"), 112 + shortcuts: [ 113 + { 114 + keys: ["1", "2", "3", "..."], 115 + description: t( 116 + "navigation:keyboardShortcuts.items.quickSelectNumber", 117 + ), 118 + }, 119 + ], 107 120 }, 108 121 ], 109 - }, 110 - ]; 122 + [t], 123 + ); 124 + } 111 125 112 126 export function KeyboardShortcutsHelp() { 127 + const { t } = useTranslation(); 128 + const shortcutCategories = useShortcutCategories(); 113 129 const [open, setOpen] = useState(false); 114 130 const [searchQuery, setSearchQuery] = useState(""); 115 131 ··· 150 166 <Dialog open={open} onOpenChange={setOpen}> 151 167 <DialogContent className="px-4 max-w-2xl max-h-[80vh] overflow-hidden flex flex-col"> 152 168 <DialogHeader> 153 - <DialogTitle>Keyboard Shortcuts</DialogTitle> 169 + <DialogTitle>{t("navigation:keyboardShortcuts.title")}</DialogTitle> 154 170 <DialogDescription> 155 - Speed up your workflow with keyboard shortcuts 171 + {t("navigation:keyboardShortcuts.subtitle")} 156 172 </DialogDescription> 157 173 </DialogHeader> 158 174 159 175 <Input 160 - placeholder="Search shortcuts..." 176 + placeholder={t("navigation:keyboardShortcuts.searchPlaceholder")} 161 177 value={searchQuery} 162 178 onChange={(e) => setSearchQuery(e.target.value)} 163 179 className="mb-4" ··· 187 203 </div> 188 204 189 205 <div className="mt-4 pt-4 border-t text-xs text-muted-foreground mb-4"> 190 - Press <kbd className="px-1.5 py-0.5 rounded bg-muted">Escape</kbd> to 191 - close 206 + <Trans 207 + i18nKey="navigation:keyboardShortcuts.footer" 208 + components={{ 209 + kbd: <kbd className="px-1.5 py-0.5 rounded bg-muted" />, 210 + }} 211 + /> 192 212 </div> 193 213 </DialogContent> 194 214 </Dialog>
+10 -9
apps/web/src/components/list-view/index.tsx
··· 14 14 useSensors, 15 15 } from "@dnd-kit/core"; 16 16 import { snapCenterToCursor } from "@dnd-kit/modifiers"; 17 - import { 18 - SortableContext, 19 - verticalListSortingStrategy, 20 - } from "@dnd-kit/sortable"; 17 + import { verticalListSortingStrategy } from "@dnd-kit/sortable"; 21 18 import { useNavigate } from "@tanstack/react-router"; 22 19 import { produce } from "immer"; 23 20 import { Archive, ChevronRight, Flag, Plus } from "lucide-react"; 24 21 import { useEffect, useState } from "react"; 22 + import { useTranslation } from "react-i18next"; 25 23 import { priorityColorsTaskCard } from "@/constants/priority-colors"; 26 24 import { useUpdateTask } from "@/hooks/mutations/task/use-update-task"; 27 25 import { useRegisterShortcuts } from "@/hooks/use-keyboard-shortcuts"; ··· 42 40 }; 43 41 44 42 function ListView({ project, disableDragDrop = false }: ListViewProps) { 43 + const { t } = useTranslation(); 45 44 const { setProject } = useProjectStore(); 46 45 const { 47 46 setAvailableTasks, ··· 265 264 }); 266 265 267 266 setProject(updatedProject); 268 - toast.success(`Archived ${columnToArchive.tasks.length} tasks`); 267 + toast.success( 268 + t("tasks:archive.success", { count: columnToArchive.tasks.length }), 269 + ); 269 270 270 271 setIsArchiveModalOpen(false); 271 272 setColumnToArchive(null); ··· 324 325 setActiveColumn(column.id); 325 326 }} 326 327 className="p-1 hover:bg-accent rounded text-muted-foreground hover:text-foreground transition-colors" 327 - title="Add task" 328 + title={t("tasks:listView.addTask")} 328 329 > 329 330 <Plus className="w-3 h-3" /> 330 331 </button> ··· 332 333 {column.isFinal && column.tasks.length > 0 && ( 333 334 <button 334 335 type="button" 335 - onClick={() => handleArchiveClick(column)} // Verander dit naar de nieuwe naam 336 + onClick={() => handleArchiveClick(column)} // Verander dit naar de neue naam 336 337 className="p-1 hover:bg-accent rounded text-muted-foreground hover:text-foreground transition-colors" 337 - title="Archive all completed tasks" 338 + title={t("tasks:listView.archiveAllTooltip")} 338 339 > 339 340 <Archive className="w-3 h-3" /> 340 341 </button> ··· 359 360 360 361 {column.tasks.length === 0 && ( 361 362 <div className="py-6 px-4 text-center text-xs text-muted-foreground"> 362 - No tasks 363 + {t("tasks:listView.noTasks")} 363 364 </div> 364 365 )} 365 366 </div>
+19 -14
apps/web/src/components/list-view/task-row.tsx
··· 10 10 GitPullRequest, 11 11 } from "lucide-react"; 12 12 import { type CSSProperties, useMemo, useState } from "react"; 13 + import { useTranslation } from "react-i18next"; 13 14 import { 14 15 AlertDialog, 15 16 AlertDialogClose, ··· 49 50 }; 50 51 51 52 function TaskRow({ task, projectSlug }: TaskRowProps) { 53 + const { t } = useTranslation(); 52 54 const navigate = useNavigate(); 53 55 const { 54 56 attributes, ··· 97 99 if (isMerged) { 98 100 return { 99 101 icon: <GitMerge className="h-3 w-3 text-info-foreground" />, 100 - status: "Merged", 102 + status: t("tasks:pr.merged"), 101 103 statusClass: "text-info-foreground", 102 104 }; 103 105 } ··· 105 107 if (isDraft) { 106 108 return { 107 109 icon: <GitPullRequest className="h-3 w-3 text-muted-foreground" />, 108 - status: "Draft", 110 + status: t("tasks:pr.draft"), 109 111 statusClass: "text-muted-foreground", 110 112 }; 111 113 } 112 114 113 115 return { 114 116 icon: <GitPullRequest className="h-3 w-3 text-success-foreground" />, 115 - status: "Open", 117 + status: t("tasks:pr.open"), 116 118 statusClass: "text-success-foreground", 117 119 }; 118 120 }; ··· 164 166 }); 165 167 } catch (error) { 166 168 toast.error( 167 - error instanceof Error ? error.message : "Failed to delete task", 169 + error instanceof Error ? error.message : t("tasks:delete.error"), 168 170 ); 169 171 } finally { 170 - toast.success("Task deleted successfully"); 172 + toast.success(t("tasks:delete.success")); 171 173 } 172 174 }; 173 175 ··· 243 245 <span>#{pullRequests[0].externalId}</span> 244 246 </div> 245 247 <p className="text-sm font-medium leading-snug"> 246 - {pullRequests[0].title || "Pull Request"} 248 + {pullRequests[0].title || t("tasks:pr.label")} 247 249 </p> 248 250 </div> 249 251 </HoverCardContent> ··· 275 277 <GitPullRequest 276 278 className={`h-3 w-3 ${iconColor}`} 277 279 /> 278 - <span>{pullRequests.length} PRs</span> 280 + <span> 281 + {t("tasks:pr.count", { 282 + count: pullRequests.length, 283 + })} 284 + </span> 279 285 </button> 280 286 </HoverCardTrigger> 281 287 <HoverCardContent ··· 308 314 </span> 309 315 </div> 310 316 <p className="text-xs leading-tight line-clamp-2 mt-0.5"> 311 - {pr.title || "Pull Request"} 317 + {pr.title || t("tasks:pr.label")} 312 318 </p> 313 319 <span className="text-[10px] text-muted-foreground"> 314 320 {prInfo.status} ··· 358 364 ) : ( 359 365 <div 360 366 className="w-6 h-6 rounded-full bg-muted border border-border flex items-center justify-center" 361 - title="Unassigned" 367 + title={t("tasks:assignee.unassigned")} 362 368 > 363 369 <span className="text-[10px] font-medium text-muted-foreground"> 364 370 ? ··· 388 394 > 389 395 <AlertDialogContent> 390 396 <AlertDialogHeader> 391 - <AlertDialogTitle>Delete Task?</AlertDialogTitle> 397 + <AlertDialogTitle>{t("tasks:delete.title")}</AlertDialogTitle> 392 398 <AlertDialogDescription> 393 - This will permanently remove the task and all its data. You can't 394 - undo this action. 399 + {t("tasks:delete.description")} 395 400 </AlertDialogDescription> 396 401 </AlertDialogHeader> 397 402 <AlertDialogFooter> 398 403 <AlertDialogClose> 399 404 <Button variant="outline" size="sm"> 400 - Cancel 405 + {t("common:actions.cancel")} 401 406 </Button> 402 407 </AlertDialogClose> 403 408 <AlertDialogClose onClick={handleDeleteTask}> 404 409 <Button variant="destructive" size="sm"> 405 - Delete Task 410 + {t("tasks:delete.action")} 406 411 </Button> 407 412 </AlertDialogClose> 408 413 </AlertDialogFooter>
+7 -5
apps/web/src/components/nav-main.tsx
··· 1 1 import { useNavigate } from "@tanstack/react-router"; 2 2 import { ChevronRight } from "lucide-react"; 3 + import { useTranslation } from "react-i18next"; 3 4 import { 4 5 Collapsible, 5 6 CollapsiblePanel, ··· 17 18 import useActiveWorkspace from "@/hooks/queries/workspace/use-active-workspace"; 18 19 19 20 export function NavMain() { 21 + const { t } = useTranslation(); 20 22 const { data: workspace } = useActiveWorkspace(); 21 23 const navigate = useNavigate(); 22 24 const { data: invitations = [] } = usePendingInvitations(); ··· 27 29 28 30 const navItems = [ 29 31 { 30 - title: "Projects", 32 + title: t("navigation:sidebar.projects"), 31 33 url: `/dashboard/workspace/${workspace.id}`, 32 34 isActive: 33 35 window.location.pathname === `/dashboard/workspace/${workspace.id}`, 34 36 badge: null, 35 37 }, 36 38 { 37 - title: "Members", 39 + title: t("navigation:sidebar.members"), 38 40 url: `/dashboard/workspace/${workspace.id}/members`, 39 41 isActive: 40 42 window.location.pathname === ··· 42 44 badge: null, 43 45 }, 44 46 { 45 - title: "Invitations", 47 + title: t("navigation:sidebar.invitations"), 46 48 url: "/dashboard/invitations", 47 49 isActive: window.location.pathname === "/dashboard/invitations", 48 50 badge: pendingCount > 0 ? pendingCount : null, ··· 58 60 <SidebarGroupLabel className="h-7 cursor-pointer justify-between px-0 text-sidebar-accent-foreground" /> 59 61 } 60 62 > 61 - <span>Overview</span> 63 + <span>{t("navigation:sidebar.overview")}</span> 62 64 <ChevronRight className="h-3.5 w-3.5 text-sidebar-foreground/60 transition-transform duration-200" /> 63 65 </CollapsibleTrigger> 64 66 <CollapsiblePanel> 65 67 <SidebarGroupContent> 66 68 <SidebarMenu className="gap-0.5"> 67 69 {navItems.map((item) => ( 68 - <SidebarMenuItem key={item.title}> 70 + <SidebarMenuItem key={item.url}> 69 71 <SidebarMenuButton 70 72 tooltip={item.title} 71 73 isActive={item.isActive}
+26 -13
apps/web/src/components/nav-projects.tsx
··· 8 8 Trash2, 9 9 } from "lucide-react"; 10 10 import { useState } from "react"; 11 + import { useTranslation } from "react-i18next"; 11 12 import { 12 13 Collapsible, 13 14 CollapsiblePanel, ··· 47 48 import { Button } from "./ui/button"; 48 49 49 50 export function NavProjects() { 51 + const { t } = useTranslation(); 50 52 const { isMobile } = useSidebar(); 51 53 const { data: workspace } = useActiveWorkspace(); 52 54 const { data: projects } = useGetProjects({ ··· 96 98 <SidebarGroupLabel className="h-7 cursor-pointer justify-between px-0 text-sidebar-accent-foreground" /> 97 99 } 98 100 > 99 - <span>Projects</span> 101 + <span>{t("navigation:sidebar.projects")}</span> 100 102 <ChevronRight className="h-3.5 w-3.5 text-sidebar-foreground/60 transition-transform duration-200" /> 101 103 </CollapsibleTrigger> 102 104 <CollapsiblePanel> ··· 124 126 } 125 127 > 126 128 <MoreHorizontal /> 127 - <span className="sr-only">More</span> 129 + <span className="sr-only"> 130 + {t("navigation:sidebar.more")} 131 + </span> 128 132 </DropdownMenuTrigger> 129 133 <DropdownMenuContent 130 134 className="w-44 rounded-lg" ··· 136 140 onClick={() => handleProjectClick(project)} 137 141 > 138 142 <Folder className="text-muted-foreground" /> 139 - <span>View Project</span> 143 + <span> 144 + {t("navigation:projectList.viewProject")} 145 + </span> 140 146 </DropdownMenuItem> 141 147 <DropdownMenuItem 142 148 className="h-7 items-start cursor-pointer text-sm" ··· 144 150 navigator.clipboard.writeText( 145 151 `${window.location.origin}/dashboard/workspace/${workspace?.id}/project/${project.id}`, 146 152 ); 147 - toast.success("Project link copied to clipboard"); 153 + toast.success( 154 + t("navigation:projectList.linkCopied"), 155 + ); 148 156 }} 149 157 > 150 158 <Forward className="text-muted-foreground" /> 151 - <span>Share Project</span> 159 + <span> 160 + {t("navigation:projectList.shareProject")} 161 + </span> 152 162 </DropdownMenuItem> 153 163 <DropdownMenuSeparator /> 154 164 <DropdownMenuItem ··· 159 169 }} 160 170 > 161 171 <Trash2 className="text-destructive" /> 162 - <span>Delete Project</span> 172 + <span> 173 + {t("navigation:projectList.deleteProject")} 174 + </span> 163 175 </DropdownMenuItem> 164 176 </DropdownMenuContent> 165 177 </DropdownMenu> ··· 173 185 className="h-8 ps-3.5 text-sm hover:bg-transparent hover:text-sidebar-accent-foreground active:bg-transparent" 174 186 onClick={() => setIsCreateProjectModalOpen(true)} 175 187 > 176 - <span>Add project</span> 188 + <span>{t("navigation:projectList.addProject")}</span> 177 189 </SidebarMenuButton> 178 190 </SidebarMenuItem> 179 191 </SidebarMenu> ··· 193 205 > 194 206 <AlertDialogContent> 195 207 <AlertDialogHeader> 196 - <AlertDialogTitle>Delete Project?</AlertDialogTitle> 208 + <AlertDialogTitle> 209 + {t("navigation:projectList.deleteConfirmTitle")} 210 + </AlertDialogTitle> 197 211 <AlertDialogDescription> 198 - This will permanently remove the project and all its data. You 199 - can't undo this action. 212 + {t("navigation:projectList.deleteConfirmDescription")} 200 213 </AlertDialogDescription> 201 214 </AlertDialogHeader> 202 215 <AlertDialogFooter> 203 216 <AlertDialogClose> 204 217 <Button variant="outline" size="sm"> 205 - Cancel 218 + {t("common:actions.cancel")} 206 219 </Button> 207 220 </AlertDialogClose> 208 221 <AlertDialogClose ··· 210 223 await deleteProject({ 211 224 id: projectToDeleteId || "", 212 225 }); 213 - toast.success("Project deleted"); 226 + toast.success(t("navigation:projectList.deletedToast")); 214 227 queryClient.invalidateQueries({ 215 228 queryKey: ["projects"], 216 229 }); ··· 223 236 }} 224 237 > 225 238 <Button variant="destructive" size="sm"> 226 - Delete Project 239 + {t("navigation:projectList.deleteProject")} 227 240 </Button> 228 241 </AlertDialogClose> 229 242 </AlertDialogFooter>
+126 -20
apps/web/src/components/notification/notification-dropdown.tsx
··· 1 - import { formatDistanceToNow } from "date-fns"; 2 1 import { Bell } from "lucide-react"; 3 2 import { forwardRef, useImperativeHandle, useState } from "react"; 3 + import { useTranslation } from "react-i18next"; 4 4 import { 5 5 AlertDialog, 6 6 AlertDialogClose, ··· 30 30 import useGetNotifications from "@/hooks/queries/notification/use-get-notifications"; 31 31 import { useRegisterShortcuts } from "@/hooks/use-keyboard-shortcuts"; 32 32 import { cn } from "@/lib/cn"; 33 + import { formatRelativeTime } from "@/lib/format"; 34 + import { getStatusLabel } from "@/lib/i18n/domain"; 35 + import type { Notification } from "@/types/notification"; 33 36 34 37 export type NotificationDropdownRef = { 35 38 toggle: () => void; 36 39 }; 37 40 41 + function getEventDataRecord( 42 + eventData: unknown, 43 + ): Record<string, unknown> | null { 44 + if (!eventData || typeof eventData !== "object" || Array.isArray(eventData)) { 45 + return null; 46 + } 47 + 48 + return eventData as Record<string, unknown>; 49 + } 50 + 51 + function getNotificationTitle( 52 + notification: Notification, 53 + t: (key: string, options?: Record<string, unknown>) => string, 54 + ) { 55 + const eventData = getEventDataRecord(notification.eventData); 56 + if (eventData) { 57 + switch (notification.type) { 58 + case "task_created": 59 + return t("notifications:events.task_created.title", { 60 + ...eventData, 61 + defaultValue: notification.title ?? notification.type, 62 + }); 63 + case "workspace_created": 64 + return t("notifications:events.workspace_created.title", { 65 + ...eventData, 66 + defaultValue: notification.title ?? notification.type, 67 + }); 68 + case "task_status_changed": 69 + return t("notifications:events.task_status_changed.title", { 70 + ...eventData, 71 + defaultValue: notification.title ?? notification.type, 72 + }); 73 + case "task_assignee_changed": 74 + return t("notifications:events.task_assignee_changed.title", { 75 + ...eventData, 76 + defaultValue: notification.title ?? notification.type, 77 + }); 78 + case "time_entry_created": 79 + return t("notifications:events.time_entry_created.title", { 80 + ...eventData, 81 + defaultValue: notification.title ?? notification.type, 82 + }); 83 + default: 84 + break; 85 + } 86 + } 87 + 88 + return notification.title ?? notification.type; 89 + } 90 + 91 + function getNotificationContent( 92 + notification: Notification, 93 + t: (key: string, options?: Record<string, unknown>) => string, 94 + ) { 95 + const eventData = getEventDataRecord(notification.eventData); 96 + if (eventData) { 97 + switch (notification.type) { 98 + case "task_created": 99 + return t("notifications:events.task_created.content", { 100 + ...eventData, 101 + defaultValue: notification.content ?? "", 102 + }); 103 + case "workspace_created": 104 + return t("notifications:events.workspace_created.content", { 105 + ...eventData, 106 + defaultValue: notification.content ?? "", 107 + }); 108 + case "task_status_changed": 109 + return t("notifications:events.task_status_changed.content", { 110 + ...eventData, 111 + oldStatus: getStatusLabel(String(eventData.oldStatus ?? "")), 112 + newStatus: getStatusLabel(String(eventData.newStatus ?? "")), 113 + defaultValue: notification.content ?? "", 114 + }); 115 + case "task_assignee_changed": 116 + return t("notifications:events.task_assignee_changed.content", { 117 + ...eventData, 118 + defaultValue: notification.content ?? "", 119 + }); 120 + case "time_entry_created": 121 + return eventData.taskTitle 122 + ? t("notifications:events.time_entry_created.contentWithTask", { 123 + ...eventData, 124 + defaultValue: notification.content ?? "", 125 + }) 126 + : t("notifications:events.time_entry_created.contentWithoutTask", { 127 + ...eventData, 128 + defaultValue: notification.content ?? "", 129 + }); 130 + default: 131 + break; 132 + } 133 + } 134 + 135 + return notification.content ?? ""; 136 + } 137 + 38 138 const NotificationDropdown = forwardRef<NotificationDropdownRef>( 39 139 (_props, ref) => { 140 + const { t } = useTranslation(); 40 141 const { data: notifications } = useGetNotifications(); 41 142 const [isOpen, setIsOpen] = useState(false); 42 143 const [showClearDialog, setShowClearDialog] = useState(false); ··· 80 181 {unreadNotifications.length > 0 && ( 81 182 <span className="absolute top-0 right-0 h-2 w-2 rounded-full bg-destructive" /> 82 183 )} 83 - <span className="sr-only">Notifications</span> 184 + <span className="sr-only"> 185 + {t("navigation:notifications")} 186 + </span> 84 187 </Button> 85 188 </DropdownMenuTrigger> 86 189 </TooltipTrigger> ··· 91 194 shortcuts.notification.prefix, 92 195 shortcuts.notification.open, 93 196 ]} 94 - description="Open notifications" 197 + description={t("notifications:shortcuts.open")} 95 198 /> 96 199 </p> 97 200 </TooltipContent> ··· 100 203 101 204 <DropdownMenuContent align="end" className="w-80 p-0"> 102 205 <div className="flex items-center justify-between px-3 py-2 border-b"> 103 - <h3 className="font-medium text-sm">Notifications</h3> 206 + <h3 className="font-medium text-sm"> 207 + {t("notifications:title")} 208 + </h3> 104 209 {unreadNotifications.length > 0 && ( 105 210 <div className="flex items-center gap-2"> 106 211 <Badge variant="secondary" className="text-xs"> 107 - {unreadNotifications.length} new 212 + {t("notifications:newCount", { 213 + count: unreadNotifications.length, 214 + })} 108 215 </Badge> 109 216 <Button 110 217 variant="ghost" ··· 112 219 onClick={() => markAllAsRead()} 113 220 className="text-xs h-6 px-2" 114 221 > 115 - Mark all read 222 + {t("common:actions.markAllRead")} 116 223 </Button> 117 224 </div> 118 225 )} ··· 122 229 {!hasNotifications ? ( 123 230 <div className="p-6 text-center text-sm text-muted-foreground"> 124 231 <Bell className="mx-auto h-12 w-12 opacity-50 mb-2" /> 125 - <p>No notifications yet</p> 232 + <p>{t("notifications:emptyTitle")}</p> 126 233 <p className="text-xs mt-1"> 127 - You'll see updates and activity here. 234 + {t("notifications:emptySubtitle")} 128 235 </p> 129 236 </div> 130 237 ) : ( ··· 140 247 <div className="flex-1 min-w-0"> 141 248 <div className="flex items-center gap-2 mb-1"> 142 249 <h4 className="text-sm font-medium text-foreground"> 143 - {notification.title} 250 + {getNotificationTitle(notification, t)} 144 251 </h4> 145 252 {!notification.isRead && ( 146 253 <div className="w-2 h-2 bg-primary rounded-full flex-shrink-0" /> 147 254 )} 148 255 </div> 149 - {notification.content && ( 256 + {getNotificationContent(notification, t) && ( 150 257 <p className="text-xs text-muted-foreground line-clamp-2"> 151 - {notification.content} 258 + {getNotificationContent(notification, t)} 152 259 </p> 153 260 )} 154 261 <p className="text-xs text-muted-foreground mt-2"> 155 - {formatDistanceToNow(notification.createdAt, { 156 - addSuffix: true, 157 - })} 262 + {formatRelativeTime(notification.createdAt)} 158 263 </p> 159 264 </div> 160 265 </div> ··· 170 275 onClick={() => setShowClearDialog(true)} 171 276 className="w-full text-xs text-destructive hover:text-destructive hover:bg-destructive/10" 172 277 > 173 - Clear all notifications 278 + {t("notifications:clearAll")} 174 279 </Button> 175 280 </div> 176 281 )} ··· 180 285 <AlertDialog open={showClearDialog} onOpenChange={setShowClearDialog}> 181 286 <AlertDialogContent> 182 287 <AlertDialogHeader> 183 - <AlertDialogTitle>Clear all notifications?</AlertDialogTitle> 288 + <AlertDialogTitle> 289 + {t("notifications:clearDialogTitle")} 290 + </AlertDialogTitle> 184 291 <AlertDialogDescription> 185 - This will permanently remove all notifications. You can't undo 186 - this action. 292 + {t("notifications:clearDialogDescription")} 187 293 </AlertDialogDescription> 188 294 </AlertDialogHeader> 189 295 <AlertDialogFooter> 190 296 <AlertDialogClose> 191 297 <Button variant="outline" size="sm"> 192 - Cancel 298 + {t("common:actions.cancel")} 193 299 </Button> 194 300 </AlertDialogClose> 195 301 <AlertDialogClose onClick={handleClearAll}> 196 302 <Button variant="destructive" size="sm"> 197 - Clear all 303 + {t("common:actions.clearAll")} 198 304 </Button> 199 305 </AlertDialogClose> 200 306 </AlertDialogFooter>
+38 -18
apps/web/src/components/onboarding/onboarding-flow.tsx
··· 3 3 import { useNavigate } from "@tanstack/react-router"; 4 4 import { AnimatePresence, motion } from "framer-motion"; 5 5 import { CheckCircle2 } from "lucide-react"; 6 - import { useState } from "react"; 6 + import { useMemo, useState } from "react"; 7 7 import { useForm } from "react-hook-form"; 8 + import { Trans, useTranslation } from "react-i18next"; 8 9 import { z } from "zod/v4"; 9 10 import { Logo } from "@/components/common/logo"; 10 11 import PageTitle from "@/components/page-title"; ··· 30 31 description?: string; 31 32 }; 32 33 33 - const workspaceSchema = z.object({ 34 - name: z.string().min(1, "Workspace name is required"), 35 - description: z.string().optional(), 36 - }); 37 - 38 34 const fadeTransition = { 39 35 initial: { opacity: 0, y: 20 }, 40 36 animate: { opacity: 1, y: 0 }, ··· 42 38 }; 43 39 44 40 export function OnboardingFlow() { 41 + const { t } = useTranslation(); 45 42 const [step, setStep] = useState<OnboardingStep>("workspace"); 46 43 const [createdWorkspaceName, setCreatedWorkspaceName] = useState(""); 47 44 const navigate = useNavigate(); ··· 49 46 const { mutateAsync: createWorkspace, isPending } = useCreateWorkspace(); 50 47 const { user } = useAuth(); 51 48 49 + const workspaceSchema = useMemo( 50 + () => 51 + z.object({ 52 + name: z 53 + .string() 54 + .min(1, t("auth:onboarding.validation.workspaceNameRequired")), 55 + description: z.string().optional(), 56 + }), 57 + [t], 58 + ); 59 + 52 60 const form = useForm<WorkspaceFormValues>({ 53 61 resolver: standardSchemaResolver(workspaceSchema), 54 62 defaultValues: { ··· 70 78 organizationId: workspace.id, 71 79 }); 72 80 setCreatedWorkspaceName(data.name); 73 - toast.success("Workspace created successfully"); 81 + toast.success(t("auth:onboarding.toast.workspaceCreated")); 74 82 75 83 setStep("success"); 76 84 ··· 83 91 }, 1500); 84 92 } catch (error) { 85 93 toast.error( 86 - error instanceof Error ? error.message : "Failed to create workspace", 94 + error instanceof Error 95 + ? error.message 96 + : t("auth:onboarding.toast.createFailed"), 87 97 ); 88 98 } 89 99 }; ··· 103 113 <div className="rounded-xl border border-border bg-card p-6 shadow-sm"> 104 114 <div className="text-center mb-6"> 105 115 <h1 className="text-xl font-semibold text-foreground mb-2"> 106 - Create workspace 116 + {t("auth:onboarding.createWorkspaceTitle")} 107 117 </h1> 108 118 <p className="text-muted-foreground text-sm"> 109 - Set up your workspace to start managing projects 119 + {t("auth:onboarding.createWorkspaceSubtitle")} 110 120 </p> 111 121 </div> 112 122 ··· 119 129 render={({ field }) => ( 120 130 <FormItem> 121 131 <FormLabel className="text-sm font-medium"> 122 - Workspace name 132 + {t("auth:onboarding.workspaceName")} 123 133 </FormLabel> 124 134 <FormControl> 125 135 <Input 126 - placeholder="e.g. Acme Inc, My Team" 136 + placeholder={t( 137 + "auth:onboarding.workspaceNamePlaceholder", 138 + )} 127 139 autoFocus 128 140 {...field} 129 141 /> ··· 139 151 render={({ field }) => ( 140 152 <FormItem> 141 153 <FormLabel className="text-sm font-medium text-muted-foreground"> 142 - Description (optional) 154 + {t("auth:onboarding.descriptionOptional")} 143 155 </FormLabel> 144 156 <FormControl> 145 157 <Input 146 - placeholder="What does your team work on?" 158 + placeholder={t( 159 + "auth:onboarding.descriptionPlaceholder", 160 + )} 147 161 {...field} 148 162 /> 149 163 </FormControl> ··· 154 168 </div> 155 169 156 170 <Button type="submit" disabled={isPending} className="w-full mt-4"> 157 - {isPending ? "Creating..." : "Create workspace"} 171 + {isPending 172 + ? t("auth:onboarding.creating") 173 + : t("auth:onboarding.createWorkspace")} 158 174 </Button> 159 175 </form> 160 176 </Form> ··· 182 198 183 199 <div className="space-y-2"> 184 200 <h1 className="text-xl font-semibold text-foreground"> 185 - Workspace created 201 + {t("auth:onboarding.workspaceCreatedTitle")} 186 202 </h1> 187 203 <p className="text-muted-foreground text-sm"> 188 - Taking you to <strong>{createdWorkspaceName}</strong>... 204 + <Trans 205 + i18nKey="auth:onboarding.redirectingToWorkspace" 206 + values={{ name: createdWorkspaceName }} 207 + components={{ name: <strong /> }} 208 + /> 189 209 </p> 190 210 </div> 191 211 ··· 199 219 200 220 return ( 201 221 <> 202 - <PageTitle title="Create Workspace" /> 222 + <PageTitle title={t("auth:onboarding.workspacePageTitle")} /> 203 223 <div className="min-h-screen w-full bg-background flex flex-col items-center justify-center p-4"> 204 224 <AnimatePresence mode="wait"> 205 225 {step === "workspace" && renderWorkspaceStep()}
+32 -18
apps/web/src/components/profile-setup/profile-setup-flow.tsx
··· 3 3 import { useNavigate } from "@tanstack/react-router"; 4 4 import { AnimatePresence, motion } from "framer-motion"; 5 5 import { CheckCircle2, User } from "lucide-react"; 6 - import { useState } from "react"; 6 + import { useMemo, useState } from "react"; 7 7 import { useForm } from "react-hook-form"; 8 + import { useTranslation } from "react-i18next"; 8 9 import { z } from "zod/v4"; 9 10 import { Logo } from "@/components/common/logo"; 10 11 import PageTitle from "@/components/page-title"; ··· 28 29 name: string; 29 30 }; 30 31 31 - const profileSchema = z.object({ 32 - name: z 33 - .string() 34 - .min(1, "Name is required") 35 - .min(2, "Name must be at least 2 characters"), 36 - }); 37 - 38 32 const fadeTransition = { 39 33 initial: { opacity: 0, y: 20 }, 40 34 animate: { opacity: 1, y: 0 }, ··· 42 36 }; 43 37 44 38 export function ProfileSetupFlow() { 39 + const { t } = useTranslation(); 45 40 const [step, setStep] = useState<ProfileSetupStep>("profile"); 46 41 const [userName, setUserName] = useState(""); 47 42 const navigate = useNavigate(); ··· 49 44 const { mutateAsync: updateProfile, isPending } = useUpdateUserProfile(); 50 45 const { user } = useAuth(); 51 46 47 + const profileSchema = useMemo( 48 + () => 49 + z.object({ 50 + name: z 51 + .string() 52 + .min(1, t("auth:profileSetup.validation.nameRequired")) 53 + .min(2, t("auth:profileSetup.validation.nameShort")), 54 + }), 55 + [t], 56 + ); 57 + 52 58 const form = useForm<ProfileFormValues>({ 53 59 resolver: standardSchemaResolver(profileSchema), 54 60 defaultValues: { ··· 64 70 65 71 await queryClient.invalidateQueries({ queryKey: ["session"] }); 66 72 setUserName(data.name); 67 - toast.success("Profile updated successfully"); 73 + toast.success(t("auth:profileSetup.toast.updateSuccess")); 68 74 69 75 setStep("success"); 70 76 ··· 76 82 }, 1500); 77 83 } catch (error) { 78 84 toast.error( 79 - error instanceof Error ? error.message : "Failed to update profile", 85 + error instanceof Error 86 + ? error.message 87 + : t("auth:profileSetup.toast.updateFailed"), 80 88 ); 81 89 } 82 90 }; ··· 99 107 <User className="h-6 w-6 text-primary" /> 100 108 </div> 101 109 <h1 className="text-xl font-semibold text-foreground mb-2"> 102 - Complete your profile 110 + {t("auth:profileSetup.completeTitle")} 103 111 </h1> 104 112 <p className="text-muted-foreground text-sm"> 105 - Please enter your name to get started 113 + {t("auth:profileSetup.subtitle")} 106 114 </p> 107 115 </div> 108 116 ··· 114 122 render={({ field }) => ( 115 123 <FormItem> 116 124 <FormLabel className="text-sm font-medium"> 117 - Your name 125 + {t("auth:profileSetup.yourName")} 118 126 </FormLabel> 119 127 <FormControl> 120 - <Input placeholder="e.g. John Doe" autoFocus {...field} /> 128 + <Input 129 + placeholder={t("auth:profileSetup.namePlaceholder")} 130 + autoFocus 131 + {...field} 132 + /> 121 133 </FormControl> 122 134 <FormMessage /> 123 135 </FormItem> ··· 125 137 /> 126 138 127 139 <Button type="submit" disabled={isPending} className="w-full mt-6"> 128 - {isPending ? "Saving..." : "Continue"} 140 + {isPending 141 + ? t("auth:profileSetup.saving") 142 + : t("auth:profileSetup.continue")} 129 143 </Button> 130 144 </form> 131 145 </Form> ··· 153 167 154 168 <div className="space-y-2"> 155 169 <h1 className="text-xl font-semibold text-foreground"> 156 - Welcome, {userName}! 170 + {t("auth:profileSetup.welcome", { name: userName })} 157 171 </h1> 158 172 <p className="text-muted-foreground text-sm"> 159 - Taking you to your dashboard... 173 + {t("auth:profileSetup.redirecting")} 160 174 </p> 161 175 </div> 162 176 ··· 170 184 171 185 return ( 172 186 <> 173 - <PageTitle title="Complete Profile" /> 187 + <PageTitle title={t("auth:profileSetup.pageTitle")} /> 174 188 <div className="min-h-screen w-full bg-background flex flex-col items-center justify-center p-4"> 175 189 <AnimatePresence mode="wait"> 176 190 {step === "profile" && renderProfileStep()}
+33 -15
apps/web/src/components/project/column-editor.tsx
··· 1 1 import { CheckCircle2, Circle, GripVertical, Plus, Trash2 } from "lucide-react"; 2 2 import { useState } from "react"; 3 + import { useTranslation } from "react-i18next"; 3 4 import { Button } from "@/components/ui/button"; 4 5 import { Input } from "@/components/ui/input"; 5 6 import { Switch } from "@/components/ui/switch"; ··· 15 16 }; 16 17 17 18 export default function ColumnEditor({ projectId }: ColumnEditorProps) { 19 + const { t } = useTranslation(); 18 20 const { data: columns, isLoading } = useGetColumns(projectId); 19 21 const { mutateAsync: createColumn } = useCreateColumn(); 20 22 const { mutateAsync: updateColumn } = useUpdateColumn(); ··· 31 33 data: { name: newColumnName.trim() }, 32 34 }); 33 35 setNewColumnName(""); 34 - toast.success("Column created"); 36 + toast.success(t("settings:columnEditor.toastCreated")); 35 37 } catch (error) { 36 38 toast.error( 37 - error instanceof Error ? error.message : "Failed to create column", 39 + error instanceof Error 40 + ? error.message 41 + : t("settings:columnEditor.toastCreateError"), 38 42 ); 39 43 } 40 44 }; ··· 42 46 const handleRename = async (id: string, name: string) => { 43 47 try { 44 48 await updateColumn({ id, projectId, data: { name } }); 45 - toast.success("Column renamed"); 49 + toast.success(t("settings:columnEditor.toastRenamed")); 46 50 } catch (error) { 47 51 toast.error( 48 - error instanceof Error ? error.message : "Failed to update column", 52 + error instanceof Error 53 + ? error.message 54 + : t("settings:columnEditor.toastRenameError"), 49 55 ); 50 56 } 51 57 }; ··· 54 60 try { 55 61 await updateColumn({ id, projectId, data: { isFinal } }); 56 62 toast.success( 57 - isFinal ? "Column marked as final" : "Column unmarked as final", 63 + isFinal 64 + ? t("settings:columnEditor.toastFinalOn") 65 + : t("settings:columnEditor.toastFinalOff"), 58 66 ); 59 67 } catch (error) { 60 68 toast.error( 61 - error instanceof Error ? error.message : "Failed to update column", 69 + error instanceof Error 70 + ? error.message 71 + : t("settings:columnEditor.toastUpdateError"), 62 72 ); 63 73 } 64 74 }; ··· 66 76 const handleDelete = async (id: string) => { 67 77 try { 68 78 await deleteColumn({ id, projectId }); 69 - toast.success("Column deleted"); 79 + toast.success(t("settings:columnEditor.toastDeleted")); 70 80 } catch (error) { 71 81 toast.error( 72 - error instanceof Error ? error.message : "Failed to delete column", 82 + error instanceof Error 83 + ? error.message 84 + : t("settings:columnEditor.toastDeleteError"), 73 85 ); 74 86 } 75 87 }; ··· 97 109 98 110 if (isLoading) { 99 111 return ( 100 - <div className="text-sm text-muted-foreground">Loading columns...</div> 112 + <div className="text-sm text-muted-foreground"> 113 + {t("settings:columnEditor.loading")} 114 + </div> 101 115 ); 102 116 } 103 117 ··· 134 148 <div className="flex items-center gap-1.5 shrink-0"> 135 149 <div 136 150 className="flex items-center gap-2" 137 - title="Treat this as a done column" 151 + title={t("settings:columnEditor.doneColumnTooltip")} 138 152 > 139 153 {col.isFinal ? ( 140 154 <CheckCircle2 className="w-3.5 h-3.5 text-muted-foreground" /> ··· 142 156 <Circle className="w-3.5 h-3.5 text-muted-foreground" /> 143 157 )} 144 158 <span className="text-xs text-muted-foreground whitespace-nowrap"> 145 - Done column 159 + {t("settings:columnEditor.doneColumn")} 146 160 </span> 147 161 <Switch 148 162 checked={col.isFinal} 149 163 onCheckedChange={(checked) => 150 164 handleToggleFinal(col.id, checked) 151 165 } 152 - aria-label={`Mark ${col.name} as done column`} 166 + aria-label={t("settings:columnEditor.markDoneAria", { 167 + name: col.name, 168 + })} 153 169 className="scale-75" 154 170 /> 155 171 <span className="text-[11px] text-muted-foreground w-8"> 156 - {col.isFinal ? "On" : "Off"} 172 + {col.isFinal 173 + ? t("settings:columnEditor.on") 174 + : t("settings:columnEditor.off")} 157 175 </span> 158 176 </div> 159 177 <Button ··· 171 189 172 190 <div className="flex items-center gap-2"> 173 191 <Input 174 - placeholder="New column name..." 192 + placeholder={t("settings:columnEditor.newColumnPlaceholder")} 175 193 value={newColumnName} 176 194 onChange={(e) => setNewColumnName(e.target.value)} 177 195 className="h-8 text-sm flex-1" ··· 187 205 className="h-8 gap-1" 188 206 > 189 207 <Plus className="w-3.5 h-3.5" /> 190 - Add 208 + {t("settings:columnEditor.add")} 191 209 </Button> 192 210 </div> 193 211 </div>
+107 -63
apps/web/src/components/project/github-integration-settings.tsx
··· 13 13 } from "lucide-react"; 14 14 import React from "react"; 15 15 import { useForm } from "react-hook-form"; 16 + import { useTranslation } from "react-i18next"; 16 17 import { z } from "zod/v4"; 17 18 import { RepositoryBrowserModal } from "@/components/project/repository-browser-modal"; 18 19 import { Badge } from "@/components/ui/badge"; ··· 40 41 import { cn } from "@/lib/cn"; 41 42 import { toast } from "@/lib/toast"; 42 43 43 - const githubIntegrationSchema = z.object({ 44 - repositoryOwner: z 45 - .string() 46 - .min(1, "Repository owner is required") 47 - .regex(/^[a-zA-Z0-9-]+$/, "Invalid repository owner format"), 48 - repositoryName: z 49 - .string() 50 - .min(1, "Repository name is required") 51 - .regex(/^[a-zA-Z0-9._-]+$/, "Invalid repository name format"), 52 - }); 53 - 54 - type GithubIntegrationFormValues = z.infer<typeof githubIntegrationSchema>; 44 + type GithubIntegrationFormValues = { 45 + repositoryOwner: string; 46 + repositoryName: string; 47 + }; 55 48 56 49 export function GitHubIntegrationSettings({ 57 50 projectId, 58 51 }: { 59 52 projectId: string; 60 53 }) { 54 + const { t } = useTranslation(); 55 + const githubIntegrationSchema = React.useMemo( 56 + () => 57 + z.object({ 58 + repositoryOwner: z 59 + .string() 60 + .min(1, t("settings:githubIntegration.validation.ownerRequired")) 61 + .regex( 62 + /^[a-zA-Z0-9-]+$/, 63 + t("settings:githubIntegration.validation.ownerInvalid"), 64 + ), 65 + repositoryName: z 66 + .string() 67 + .min(1, t("settings:githubIntegration.validation.nameRequired")) 68 + .regex( 69 + /^[a-zA-Z0-9._-]+$/, 70 + t("settings:githubIntegration.validation.nameInvalid"), 71 + ), 72 + }), 73 + [t], 74 + ); 75 + 61 76 const { data: integration, isLoading } = useGetGithubIntegration(projectId); 62 77 const { mutateAsync: createIntegration, isPending: isCreating } = 63 78 useCreateGithubIntegration(); ··· 103 118 104 119 if (showToast) { 105 120 if (result.isInstalled && result.hasRequiredPermissions) { 106 - toast.success("GitHub App is properly installed!"); 121 + toast.success(t("settings:githubIntegration.toast.installedOk")); 107 122 } else if (result.isInstalled) { 108 123 toast.warning( 109 - "GitHub App is installed but missing required permissions", 124 + t("settings:githubIntegration.toast.installedMissingPerms"), 110 125 ); 111 126 } else if (result.repositoryExists) { 112 127 toast.warning( 113 - "GitHub App needs to be installed on this repository", 128 + t("settings:githubIntegration.toast.needsInstallOnRepo"), 114 129 ); 115 130 } else { 116 - toast.error("Repository not found or not accessible"); 131 + toast.error(t("settings:githubIntegration.toast.repoNotFound")); 117 132 } 118 133 } 119 134 } catch (error) { ··· 121 136 toast.error( 122 137 error instanceof Error 123 138 ? error.message 124 - : "Failed to verify GitHub installation", 139 + : t("settings:githubIntegration.toast.verifyError"), 125 140 ); 126 141 } 127 142 setVerificationResult(null); 128 143 } 129 144 }, 130 - [verifyInstallation], 145 + [verifyInstallation, t], 131 146 ); 132 147 133 148 React.useEffect(() => { ··· 165 180 const verification = await verifyInstallation(data); 166 181 167 182 if (!verification.isInstalled) { 168 - toast.error("Please install the GitHub App on this repository first"); 183 + toast.error(t("settings:githubIntegration.toast.installAppFirst")); 169 184 return; 170 185 } 171 186 172 187 if (!verification.hasRequiredPermissions) { 173 188 toast.error( 174 - `GitHub App is missing required permissions: ${verification.missingPermissions?.join(", ") || "issues"}. Please update the app permissions.`, 189 + t("settings:githubIntegration.toast.missingPermsDetail", { 190 + list: verification.missingPermissions?.join(", ") || "issues", 191 + }), 175 192 ); 176 193 return; 177 194 } ··· 180 197 projectId, 181 198 data, 182 199 }); 183 - toast.success("GitHub integration updated successfully"); 200 + toast.success(t("settings:githubIntegration.toast.updated")); 184 201 } catch (error) { 185 202 toast.error( 186 203 error instanceof Error 187 204 ? error.message 188 - : "Failed to update GitHub integration", 205 + : t("settings:githubIntegration.toast.updateError"), 189 206 ); 190 207 } 191 208 }; ··· 195 212 await deleteIntegration(projectId); 196 213 form.reset({ repositoryOwner: "", repositoryName: "" }); 197 214 setVerificationResult(null); 198 - toast.success("GitHub integration removed successfully"); 215 + toast.success(t("settings:githubIntegration.toast.removed")); 199 216 } catch (error) { 200 217 toast.error( 201 218 error instanceof Error 202 219 ? error.message 203 - : "Failed to remove GitHub integration", 220 + : t("settings:githubIntegration.toast.removeError"), 204 221 ); 205 222 } 206 223 }; ··· 208 225 const handleImportIssues = async () => { 209 226 try { 210 227 await importIssues({ projectId }); 211 - toast.success("Issues imported successfully"); 228 + toast.success(t("settings:githubIntegration.toast.issuesImported")); 212 229 } catch (error) { 213 230 toast.error( 214 - error instanceof Error ? error.message : "Failed to import issues", 231 + error instanceof Error 232 + ? error.message 233 + : t("settings:githubIntegration.toast.importError"), 215 234 ); 216 235 } 217 236 }; ··· 248 267 <div className="space-y-4 border border-border rounded-md p-4 bg-sidebar"> 249 268 <div className="flex items-center justify-between"> 250 269 <div className="space-y-0.5"> 251 - <p className="text-sm font-medium">Connection Status</p> 270 + <p className="text-sm font-medium"> 271 + {t("settings:githubIntegration.connectionStatus")} 272 + </p> 252 273 {isConnected ? ( 253 274 <p className="text-xs text-muted-foreground"> 254 - Repository connected and active 275 + {t("settings:githubIntegration.connectedActive")} 255 276 </p> 256 277 ) : ( 257 278 <p className="text-xs text-muted-foreground"> 258 - No repository connected 279 + {t("settings:githubIntegration.notConnectedHint")} 259 280 </p> 260 281 )} 261 282 </div> ··· 264 285 <div className="flex items-center gap-2"> 265 286 <Badge variant="secondary" className="gap-1"> 266 287 <CheckCircle className="w-3 h-3" /> 267 - Connected 288 + {t("settings:githubIntegration.badgeConnected")} 268 289 </Badge> 269 290 </div> 270 291 ) : ( 271 292 <Badge variant="outline" className="gap-1"> 272 293 <XCircle className="w-3 h-3" /> 273 - Not Connected 294 + {t("settings:githubIntegration.badgeNotConnected")} 274 295 </Badge> 275 296 )} 276 297 </div> ··· 281 302 <Separator /> 282 303 <div className="flex items-center justify-between"> 283 304 <div className="space-y-0.5"> 284 - <p className="text-sm font-medium">Repository</p> 305 + <p className="text-sm font-medium"> 306 + {t("settings:githubIntegration.repository")} 307 + </p> 285 308 <p className="text-xs text-muted-foreground"> 286 - Connected GitHub repository 309 + {t("settings:githubIntegration.repositoryHint")} 287 310 </p> 288 311 </div> 289 312 <div className="flex items-center gap-2 text-sm"> ··· 306 329 <div className="flex items-center justify-between gap-4"> 307 330 <div className="min-w-0 flex-1 space-y-0.5"> 308 331 <p className="text-sm font-medium"> 309 - Comment Kaneo link on new issues 332 + {t("settings:githubIntegration.commentTaskLinkTitle")} 310 333 </p> 311 334 <p className="text-xs text-muted-foreground"> 312 - When enabled, Kaneo posts a comment on each new GitHub issue 313 - with a link to the task. 335 + {t("settings:githubIntegration.commentTaskLinkHint")} 314 336 </p> 315 337 </div> 316 338 <Switch ··· 323 345 }); 324 346 toast.success( 325 347 checked 326 - ? "Kaneo will comment with a task link on new issues" 327 - : "Task link comments on new issues are turned off", 348 + ? t("settings:githubIntegration.toast.commentOnEnabled") 349 + : t( 350 + "settings:githubIntegration.toast.commentOnDisabled", 351 + ), 328 352 ); 329 353 } catch (error) { 330 354 toast.error( 331 355 error instanceof Error 332 356 ? error.message 333 - : "Failed to update GitHub integration", 357 + : t( 358 + "settings:githubIntegration.toast.settingsUpdateError", 359 + ), 334 360 ); 335 361 } 336 362 }} ··· 345 371 <Separator /> 346 372 <div className="flex items-center justify-between"> 347 373 <div className="space-y-0.5"> 348 - <p className="text-sm font-medium">GitHub App Status</p> 374 + <p className="text-sm font-medium"> 375 + {t("settings:githubIntegration.appStatusTitle")} 376 + </p> 349 377 <p className="text-xs text-muted-foreground"> 350 - Installation and permissions status 378 + {t("settings:githubIntegration.appStatusHint")} 351 379 </p> 352 380 </div> 353 381 <div className="flex items-center gap-2 text-sm"> ··· 356 384 <> 357 385 <CheckCircle className="h-4 w-4 text-success-foreground" /> 358 386 <span className="font-medium text-success-foreground"> 359 - Properly configured 387 + {t("settings:githubIntegration.statusProperlyConfigured")} 360 388 </span> 361 389 </> 362 390 ) : verificationResult.isInstalled ? ( 363 391 <> 364 392 <AlertTriangle className="h-4 w-4 text-warning-foreground" /> 365 393 <span className="font-medium text-warning-foreground"> 366 - Missing permissions 394 + {t("settings:githubIntegration.statusMissingPermissions")} 367 395 </span> 368 396 </> 369 397 ) : ( 370 398 <> 371 399 <XCircle className="h-4 w-4 text-destructive-foreground" /> 372 400 <span className="font-medium text-destructive-foreground"> 373 - Not installed 401 + {t("settings:githubIntegration.statusNotInstalled")} 374 402 </span> 375 403 </> 376 404 )} ··· 390 418 <div className="flex items-center justify-between"> 391 419 <div className="space-y-0.5"> 392 420 <FormLabel className="text-sm font-medium"> 393 - Repository Owner 421 + {t("settings:githubIntegration.ownerLabel")} 394 422 </FormLabel> 395 423 <p className="text-xs text-muted-foreground"> 396 - GitHub username or organization 424 + {t("settings:githubIntegration.ownerHint")} 397 425 </p> 398 426 </div> 399 427 <FormControl> 400 428 <Input 401 429 className="w-64" 402 - placeholder="e.g., octocat" 430 + placeholder={t( 431 + "settings:githubIntegration.ownerPlaceholder", 432 + )} 403 433 {...field} 404 434 disabled={isCreating || isDeleting} 405 435 /> ··· 420 450 <div className="flex items-center justify-between"> 421 451 <div className="space-y-0.5"> 422 452 <FormLabel className="text-sm font-medium"> 423 - Repository Name 453 + {t("settings:githubIntegration.repoNameLabel")} 424 454 </FormLabel> 425 455 <p className="text-xs text-muted-foreground"> 426 - The repository name 456 + {t("settings:githubIntegration.repoNameHint")} 427 457 </p> 428 458 </div> 429 459 <FormControl> 430 460 <Input 431 461 className="w-64" 432 - placeholder="e.g., my-project" 462 + placeholder={t( 463 + "settings:githubIntegration.repoNamePlaceholder", 464 + )} 433 465 {...field} 434 466 disabled={isCreating || isDeleting} 435 467 /> ··· 444 476 445 477 <div className="flex items-center justify-between"> 446 478 <div className="space-y-0.5"> 447 - <p className="text-sm font-medium">Actions</p> 479 + <p className="text-sm font-medium"> 480 + {t("settings:githubIntegration.actionsTitle")} 481 + </p> 448 482 <p className="text-xs text-muted-foreground"> 449 - Manage your repository connection 483 + {t("settings:githubIntegration.actionsHint")} 450 484 </p> 451 485 </div> 452 486 <div className="flex flex-wrap gap-2"> ··· 458 492 className="gap-2" 459 493 > 460 494 <GitBranch className="size-3" /> 461 - Browse 495 + {t("settings:githubIntegration.browse")} 462 496 </Button> 463 497 464 498 <Button ··· 472 506 <RefreshCw 473 507 className={cn("size-3", isVerifying && "animate-spin")} 474 508 /> 475 - Verify 509 + {t("settings:githubIntegration.verify")} 476 510 </Button> 477 511 478 512 <Button ··· 490 524 className="gap-2" 491 525 > 492 526 <Link className="size-3" /> 493 - {isConnected ? "Update" : "Connect"} 527 + {isConnected 528 + ? t("settings:githubIntegration.update") 529 + : t("settings:githubIntegration.connect")} 494 530 </Button> 495 531 496 532 {isConnected && ( ··· 503 539 className="gap-2" 504 540 > 505 541 <Unlink className="size-3" /> 506 - Disconnect 542 + {t("settings:githubIntegration.disconnect")} 507 543 </Button> 508 544 )} 509 545 </div> ··· 545 581 verificationResult.missingPermissions && ( 546 582 <div className="mt-2"> 547 583 <p className="text-xs mb-2"> 548 - Missing permissions:{" "} 584 + {t( 585 + "settings:githubIntegration.missingPermissionsLabel", 586 + )}{" "} 549 587 <strong> 550 588 {verificationResult.missingPermissions.join(", ")} 551 589 </strong> ··· 564 602 className="gap-2" 565 603 > 566 604 <ExternalLink className="w-3 h-3" /> 567 - Update Permissions 605 + {t( 606 + "settings:githubIntegration.updatePermissions", 607 + )} 568 608 </Button> 569 609 )} 570 610 </div> ··· 587 627 className="gap-2" 588 628 > 589 629 <ExternalLink className="w-3 h-3" /> 590 - Install GitHub App 630 + {t("settings:githubIntegration.installGithubApp")} 591 631 </Button> 592 632 )} 593 633 </div> ··· 603 643 <div className="space-y-4 border border-border rounded-md p-4 bg-sidebar"> 604 644 <div className="flex items-center justify-between"> 605 645 <div className="space-y-0.5"> 606 - <p className="text-sm font-medium">Import GitHub Issues</p> 646 + <p className="text-sm font-medium"> 647 + {t("settings:githubIntegration.importSectionTitle")} 648 + </p> 607 649 <p className="text-xs text-muted-foreground"> 608 - Import existing issues from your GitHub repository as tasks 650 + {t("settings:githubIntegration.importSectionHint")} 609 651 </p> 610 652 </div> 611 653 <div className="flex items-center gap-2"> ··· 621 663 ) : ( 622 664 <Import className="size-3" /> 623 665 )} 624 - {isImporting ? "Importing..." : "Import Issues"} 666 + {isImporting 667 + ? t("settings:githubIntegration.importing") 668 + : t("settings:githubIntegration.importIssues")} 625 669 </Button> 626 670 </div> 627 671 </div> ··· 629 673 <> 630 674 <Separator /> 631 675 <p className="text-xs text-muted-foreground"> 632 - Complete the repository configuration above to enable importing 676 + {t("settings:githubIntegration.importDisabledHint")} 633 677 </p> 634 678 </> 635 679 )}
+36 -23
apps/web/src/components/project/repository-browser-modal.tsx
··· 10 10 Settings, 11 11 } from "lucide-react"; 12 12 import React from "react"; 13 + import { useTranslation } from "react-i18next"; 13 14 import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 14 15 import { Badge } from "@/components/ui/badge"; 15 16 import { Button } from "@/components/ui/button"; ··· 41 42 onSelectRepository, 42 43 selectedRepository, 43 44 }: RepositoryBrowserModalProps) { 45 + const { t } = useTranslation(); 44 46 const [searchTerm, setSearchTerm] = React.useState(""); 45 47 46 48 const { data, isLoading, error, refetch } = useQuery({ ··· 83 85 const now = new Date(); 84 86 const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000); 85 87 86 - if (diffInSeconds < 60) return "just now"; 87 - if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}m ago`; 88 - if (diffInSeconds < 86400) 89 - return `${Math.floor(diffInSeconds / 3600)}h ago`; 90 - if (diffInSeconds < 2592000) 91 - return `${Math.floor(diffInSeconds / 86400)}d ago`; 88 + if (diffInSeconds < 60) 89 + return t("settings:repositoryBrowser.relativeJustNow"); 90 + if (diffInSeconds < 3600) { 91 + return t("settings:repositoryBrowser.relativeMinutesAgo", { 92 + count: Math.floor(diffInSeconds / 60), 93 + }); 94 + } 95 + if (diffInSeconds < 86400) { 96 + return t("settings:repositoryBrowser.relativeHoursAgo", { 97 + count: Math.floor(diffInSeconds / 3600), 98 + }); 99 + } 100 + if (diffInSeconds < 2592000) { 101 + return t("settings:repositoryBrowser.relativeDaysAgo", { 102 + count: Math.floor(diffInSeconds / 86400), 103 + }); 104 + } 92 105 93 106 return date.toLocaleDateString(); 94 107 }; ··· 106 119 <DialogHeader className="px-6 pt-6 pb-4"> 107 120 <DialogTitle className="flex items-center gap-2"> 108 121 <GitBranch className="w-5 h-5" /> 109 - Select Repository 122 + {t("settings:repositoryBrowser.title")} 110 123 </DialogTitle> 111 124 <DialogDescription className="mt-1.5"> 112 - Choose a repository where your GitHub App is installed to enable 113 - issue synchronization. 125 + {t("settings:repositoryBrowser.description")} 114 126 </DialogDescription> 115 127 </DialogHeader> 116 128 ··· 120 132 <div className="relative"> 121 133 <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" /> 122 134 <Input 123 - placeholder="Search repositories..." 135 + placeholder={t("settings:repositoryBrowser.searchPlaceholder")} 124 136 value={searchTerm} 125 137 onChange={(e) => setSearchTerm(e.target.value)} 126 138 className="pl-10" ··· 153 165 {error && ( 154 166 <div className="text-center py-12 px-6"> 155 167 <div className="text-destructive mb-3 font-medium"> 156 - Failed to load repositories 168 + {t("settings:repositoryBrowser.loadError")} 157 169 </div> 158 170 <Button variant="outline" onClick={() => refetch()}> 159 - Try Again 171 + {t("settings:repositoryBrowser.tryAgain")} 160 172 </Button> 161 173 </div> 162 174 )} ··· 167 179 <div className="text-center py-12 px-6"> 168 180 <GitBranch className="w-12 h-12 text-muted-foreground mx-auto mb-4" /> 169 181 <h3 className="text-lg font-medium mb-2"> 170 - No repositories found 182 + {t("settings:repositoryBrowser.emptyTitle")} 171 183 </h3> 172 184 <p className="text-muted-foreground text-sm mb-6 max-w-md mx-auto"> 173 - Install the GitHub App on your repositories to see them 174 - here. 185 + {t("settings:repositoryBrowser.emptyHint")} 175 186 </p> 176 187 {appInfo?.appName && ( 177 188 <Button ··· 183 194 } 184 195 > 185 196 <ExternalLink className="w-4 h-4 mr-2" /> 186 - Install GitHub App 197 + {t("settings:repositoryBrowser.installGithubApp")} 187 198 </Button> 188 199 )} 189 200 </div> ··· 239 250 <div className="flex items-center gap-4 text-xs text-muted-foreground"> 240 251 <div className="flex items-center gap-1"> 241 252 <Clock className="w-3 h-3" /> 242 - Updated {formatTimeAgo(repository.updated_at)} 253 + {t("settings:repositoryBrowser.updatedPrefix")}{" "} 254 + {formatTimeAgo(repository.updated_at)} 243 255 </div> 244 256 </div> 245 257 </div> ··· 272 284 <div className="text-center py-12 px-6"> 273 285 <Search className="w-12 h-12 text-muted-foreground mx-auto mb-4" /> 274 286 <h3 className="text-lg font-medium mb-2"> 275 - No repositories match your search 287 + {t("settings:repositoryBrowser.noSearchMatchTitle")} 276 288 </h3> 277 289 <p className="text-muted-foreground text-sm"> 278 - Try adjusting your search terms or clear the search to see 279 - all repositories. 290 + {t("settings:repositoryBrowser.noSearchMatchHint")} 280 291 </p> 281 292 </div> 282 293 )} ··· 290 301 <div className="px-6 py-4"> 291 302 <div className="flex items-center justify-between text-sm text-muted-foreground"> 292 303 <span> 293 - {data.repositories.length} repositories across{" "} 294 - {data.installations.length} installations 304 + {t("settings:repositoryBrowser.footerSummary", { 305 + repoCount: data.repositories.length, 306 + installationCount: data.installations.length, 307 + })} 295 308 </span> 296 309 <Button 297 310 variant="ghost" ··· 304 317 } 305 318 > 306 319 <Settings className="w-4 h-4 mr-2" /> 307 - Manage Installations 320 + {t("settings:repositoryBrowser.manageInstallations")} 308 321 </Button> 309 322 </div> 310 323 </div>
+28 -18
apps/web/src/components/project/tasks-import-export.tsx
··· 2 2 import { saveAs } from "file-saver"; 3 3 import { Download, Loader2, Upload, X } from "lucide-react"; 4 4 import { useRef, useState } from "react"; 5 + import { useTranslation } from "react-i18next"; 5 6 import { Button } from "@/components/ui/button"; 6 7 import { 7 8 Dialog, ··· 20 21 }; 21 22 22 23 export function TasksImportExport({ project }: TasksImportExportProps) { 24 + const { t } = useTranslation(); 23 25 const [isImportOpen, setIsImportOpen] = useState(false); 24 26 const fileInputRef = useRef<HTMLInputElement>(null); 25 27 const queryClient = useQueryClient(); ··· 31 33 32 34 const handleExport = async () => { 33 35 try { 34 - toast.loading("Exporting tasks..."); 36 + toast.loading(t("settings:tasksImportExport.exporting")); 35 37 const exportData = await exportTasksMutation(project.id); 36 38 37 39 const blob = new Blob([JSON.stringify(exportData, null, 2)], { ··· 41 43 saveAs(blob, `${project.slug}-tasks-export.json`); 42 44 43 45 toast.dismiss(); 44 - toast.success("Tasks exported successfully"); 46 + toast.success(t("settings:tasksImportExport.exportSuccess")); 45 47 } catch (error) { 46 48 toast.dismiss(); 47 - toast.error("Failed to export tasks"); 49 + toast.error(t("settings:tasksImportExport.exportError")); 48 50 console.error(error); 49 51 } 50 52 }; ··· 71 73 const jsonData = JSON.parse(content); 72 74 73 75 if (!jsonData.tasks || !Array.isArray(jsonData.tasks)) { 74 - toast.error("Invalid import file format"); 76 + toast.error(t("settings:tasksImportExport.invalidFormat")); 75 77 return; 76 78 } 77 79 78 - toast.loading("Importing tasks..."); 80 + toast.loading(t("settings:tasksImportExport.importing")); 79 81 80 82 const result = await importTasksMutation({ 81 83 projectId: project.id, ··· 88 90 89 91 setIsImportOpen(false); 90 92 toast.dismiss(); 91 - toast.success(`Imported ${result.results.successful} tasks successfully`); 93 + toast.success( 94 + t("settings:tasksImportExport.importSuccess", { 95 + count: result.results.successful, 96 + }), 97 + ); 92 98 93 99 if (result.results.failed > 0) { 94 - toast.error(`Failed to import ${result.results.failed} tasks`); 100 + toast.error( 101 + t("settings:tasksImportExport.importPartialError", { 102 + count: result.results.failed, 103 + }), 104 + ); 95 105 } 96 106 } catch (error) { 97 107 toast.dismiss(); 98 - toast.error("Failed to import tasks"); 108 + toast.error(t("settings:tasksImportExport.importError")); 99 109 console.error(error); 100 110 } 101 111 }; ··· 104 114 event.preventDefault(); 105 115 const file = event.dataTransfer.files[0]; 106 116 if (!file) { 107 - toast.error("No file was dropped"); 117 + toast.error(t("settings:tasksImportExport.noFileDropped")); 108 118 return; 109 119 } 110 120 111 121 if (file.type === "application/json" || file.name.endsWith(".json")) { 112 122 confirmImport(file); 113 123 } else { 114 - toast.error("Please upload a JSON file"); 124 + toast.error(t("settings:tasksImportExport.notJsonFile")); 115 125 } 116 126 }; 117 127 ··· 137 147 ) : ( 138 148 <Download className="h-4 w-4" /> 139 149 )} 140 - Export Tasks 150 + {t("settings:tasksImportExport.exportTasks")} 141 151 </Button> 142 152 143 153 <Button ··· 146 156 onClick={() => setIsImportOpen(true)} 147 157 > 148 158 <Upload className="h-4 w-4" /> 149 - Import Tasks 159 + {t("settings:tasksImportExport.importTasks")} 150 160 </Button> 151 161 152 162 <input ··· 163 173 <div className="bg-card rounded-lg shadow-xl border border-border"> 164 174 <div className="flex items-center justify-between p-4 border-b border-border"> 165 175 <DialogTitle className="text-lg font-semibold text-foreground"> 166 - Import Tasks 176 + {t("settings:tasksImportExport.dialogTitle")} 167 177 </DialogTitle> 168 178 <DialogClose 169 179 className="text-muted-foreground hover:text-foreground" ··· 175 185 176 186 <div className="p-4"> 177 187 <p className="text-sm text-muted-foreground mb-2"> 178 - Upload a JSON file containing tasks to import into this project. 188 + {t("settings:tasksImportExport.dialogDescription")} 179 189 </p> 180 190 181 191 <div className="mb-4 p-3 bg-muted rounded-md border border-border/50 font-mono text-sm"> 182 192 <p className="text-muted-foreground mb-1 text-xs"> 183 - Expected format: 193 + {t("settings:tasksImportExport.expectedFormat")} 184 194 </p> 185 195 <pre className="text-foreground overflow-auto max-h-32 text-xs"> 186 196 {`{ ··· 214 224 <div className="flex flex-col items-center justify-center gap-2"> 215 225 <Upload className="h-8 w-8 text-muted-foreground" /> 216 226 <p className="text-sm text-muted-foreground"> 217 - Drag and drop your JSON file here 227 + {t("settings:tasksImportExport.dropHint")} 218 228 </p> 219 229 <Button 220 230 className="mt-2 bg-card hover:bg-accent text-foreground border border-border" ··· 225 235 {isImporting ? ( 226 236 <Loader2 className="h-4 w-4 mr-2 animate-spin" /> 227 237 ) : ( 228 - "Select File" 238 + t("settings:tasksImportExport.selectFile") 229 239 )} 230 240 </Button> 231 241 </div> ··· 237 247 <Button className="bg-card hover:bg-accent text-foreground border border-border" /> 238 248 } 239 249 > 240 - Cancel 250 + {t("common:actions.cancel")} 241 251 </DialogClose> 242 252 </div> 243 253 </div>
+35 -19
apps/web/src/components/project/workflow-editor.tsx
··· 1 + import { useTranslation } from "react-i18next"; 1 2 import { 2 3 Select, 3 4 SelectContent, ··· 10 11 import { useGetWorkflowRules } from "@/hooks/queries/workflow-rule/use-get-workflow-rules"; 11 12 import { toast } from "@/lib/toast"; 12 13 13 - const GITHUB_EVENTS = [ 14 - { eventType: "branch_push", label: "Branch Push" }, 15 - { eventType: "pr_opened", label: "PR Opened" }, 16 - { eventType: "pr_merged", label: "PR Merged" }, 17 - { eventType: "issue_opened", label: "Issue Opened" }, 18 - { eventType: "issue_closed", label: "Issue Closed" }, 19 - ]; 14 + const GITHUB_EVENT_TYPES = [ 15 + "branch_push", 16 + "pr_opened", 17 + "pr_merged", 18 + "issue_opened", 19 + "issue_closed", 20 + ] as const; 20 21 21 22 type WorkflowEditorProps = { 22 23 projectId: string; 23 24 }; 24 25 25 26 export default function WorkflowEditor({ projectId }: WorkflowEditorProps) { 27 + const { t } = useTranslation(); 26 28 const { data: columns, isLoading: columnsLoading } = useGetColumns(projectId); 27 29 const { data: rules, isLoading: rulesLoading } = 28 30 useGetWorkflowRules(projectId); ··· 40 42 columnId, 41 43 }, 42 44 }); 43 - toast.success("Workflow rule updated"); 45 + toast.success(t("settings:workflowEditor.toastUpdated")); 44 46 } catch (error) { 45 47 toast.error( 46 - error instanceof Error ? error.message : "Failed to update rule", 48 + error instanceof Error 49 + ? error.message 50 + : t("settings:workflowEditor.toastError"), 47 51 ); 48 52 } 49 53 }; 50 54 51 55 if (columnsLoading || rulesLoading) { 52 - return <div className="text-sm text-muted-foreground">Loading...</div>; 56 + return ( 57 + <div className="text-sm text-muted-foreground"> 58 + {t("settings:workflowEditor.loading")} 59 + </div> 60 + ); 53 61 } 54 62 55 63 if (!columns || columns.length === 0) { 56 64 return ( 57 65 <div className="text-sm text-muted-foreground"> 58 - Create columns first to configure automation rules. 66 + {t("settings:workflowEditor.createColumnsFirst")} 59 67 </div> 60 68 ); 61 69 } ··· 65 73 return ( 66 74 <div className="space-y-4"> 67 75 <div className="space-y-1"> 68 - <h3 className="text-sm font-medium">GitHub</h3> 76 + <h3 className="text-sm font-medium"> 77 + {t("settings:workflowEditor.githubHeading")} 78 + </h3> 69 79 <p className="text-xs text-muted-foreground"> 70 - When a GitHub event occurs, move the linked task to a column. 80 + {t("settings:workflowEditor.githubHint")} 71 81 </p> 72 82 </div> 73 83 74 84 <div className="space-y-2"> 75 - {GITHUB_EVENTS.map((event) => { 85 + {GITHUB_EVENT_TYPES.map((eventType) => { 76 86 const currentRule = githubRules?.find( 77 - (r) => r.eventType === event.eventType, 87 + (r) => r.eventType === eventType, 78 88 ); 79 89 80 90 return ( 81 91 <div 82 - key={event.eventType} 92 + key={eventType} 83 93 className="flex items-center justify-between gap-4 p-3 border border-border rounded-md bg-sidebar" 84 94 > 85 - <span className="text-sm">{event.label}</span> 95 + <span className="text-sm"> 96 + {t(`settings:workflowEditor.events.${eventType}`)} 97 + </span> 86 98 <Select 87 99 value={currentRule?.columnId ?? ""} 88 - onValueChange={(value) => handleChange(event.eventType, value)} 100 + onValueChange={(value) => handleChange(eventType, value)} 89 101 > 90 102 <SelectTrigger className="w-48 h-8 text-sm"> 91 - <SelectValue placeholder="Select column..." /> 103 + <SelectValue 104 + placeholder={t( 105 + "settings:workflowEditor.selectColumnPlaceholder", 106 + )} 107 + /> 92 108 </SelectTrigger> 93 109 <SelectContent> 94 110 {columns.map((col) => (
+6 -4
apps/web/src/components/public-project/copy-url-button.tsx
··· 1 1 import { Check, Copy } from "lucide-react"; 2 2 import { useState } from "react"; 3 + import { useTranslation } from "react-i18next"; 3 4 import { Button } from "@/components/ui/button"; 4 5 import { toast } from "@/lib/toast"; 5 6 6 7 export function CopyUrlButton() { 8 + const { t } = useTranslation(); 7 9 const [copied, setCopied] = useState(false); 8 10 9 11 const handleCopyUrl = async () => { 10 12 try { 11 13 await navigator.clipboard.writeText(window.location.href); 12 14 setCopied(true); 13 - toast.success("URL copied"); 15 + toast.success(t("publicProject:copyUrl.successToast")); 14 16 setTimeout(() => setCopied(false), 2000); 15 17 } catch (error) { 16 18 console.error("Failed to copy URL:", error); 17 - toast.error("Failed to copy URL"); 19 + toast.error(t("publicProject:copyUrl.errorToast")); 18 20 } 19 21 }; 20 22 ··· 28 30 {copied ? ( 29 31 <> 30 32 <Check className="h-3 w-3" /> 31 - <span className="text-xs">Copied</span> 33 + <span className="text-xs">{t("publicProject:copyUrl.copied")}</span> 32 34 </> 33 35 ) : ( 34 36 <> 35 37 <Copy className="h-3 w-3" /> 36 - <span className="text-xs">Share</span> 38 + <span className="text-xs">{t("publicProject:copyUrl.share")}</span> 37 39 </> 38 40 )} 39 41 </Button>
+4 -2
apps/web/src/components/public-project/error-view.tsx
··· 1 1 import { ExternalLink } from "lucide-react"; 2 + import { useTranslation } from "react-i18next"; 2 3 import { KaneoBranding } from "./kaneo-branding"; 3 4 4 5 export function ErrorView() { 6 + const { t } = useTranslation(); 5 7 return ( 6 8 <div className="min-h-screen bg-background flex flex-col w-full"> 7 9 <div className="flex-1 flex items-center justify-center"> ··· 11 13 </div> 12 14 <div className="space-y-2"> 13 15 <h1 className="text-3xl font-semibold text-foreground"> 14 - Project Not Found 16 + {t("publicProject:error.title")} 15 17 </h1> 16 18 <p className="text-muted-foreground max-w-md mx-auto"> 17 - This project doesn't exist or is not publicly accessible. 19 + {t("publicProject:error.description")} 18 20 </p> 19 21 </div> 20 22 <KaneoBranding />
+6 -1
apps/web/src/components/public-project/kaneo-branding.tsx
··· 1 + import { useTranslation } from "react-i18next"; 2 + 1 3 export function KaneoBranding() { 4 + const { t } = useTranslation(); 5 + 2 6 return ( 3 7 <a 4 8 href="https://kaneo.app" ··· 6 10 rel="noopener noreferrer" 7 11 className="hover:text-foreground transition-colors" 8 12 > 9 - Powered by <span className="font-medium">Kaneo</span> 13 + {t("publicProject:branding.poweredBy")}{" "} 14 + <span className="font-medium">{t("common:appName")}</span> 10 15 </a> 11 16 ); 12 17 }
+8 -6
apps/web/src/components/public-project/public-pr-badge.tsx
··· 1 1 import { GitMerge, GitPullRequest } from "lucide-react"; 2 2 import { useMemo } from "react"; 3 + import { useTranslation } from "react-i18next"; 3 4 import { 4 5 HoverCard, 5 6 HoverCardContent, ··· 12 13 }; 13 14 14 15 export function PublicPRBadge({ externalLinks }: PublicPRBadgeProps) { 16 + const { t } = useTranslation(); 15 17 const pullRequests = useMemo(() => { 16 18 if (!externalLinks) return []; 17 19 return externalLinks.filter((link) => link.resourceType === "pull_request"); ··· 26 28 if (isMerged) { 27 29 return { 28 30 icon: <GitMerge className="h-3 w-3 text-info-foreground" />, 29 - status: "Merged", 31 + status: t("tasks:pr.merged"), 30 32 statusClass: "text-info-foreground", 31 33 }; 32 34 } ··· 34 36 if (isDraft) { 35 37 return { 36 38 icon: <GitPullRequest className="h-3 w-3 text-muted-foreground" />, 37 - status: "Draft", 39 + status: t("tasks:pr.draft"), 38 40 statusClass: "text-muted-foreground", 39 41 }; 40 42 } 41 43 42 44 return { 43 45 icon: <GitPullRequest className="h-3 w-3 text-success-foreground" />, 44 - status: "Open", 46 + status: t("tasks:pr.open"), 45 47 statusClass: "text-success-foreground", 46 48 }; 47 49 }; ··· 75 77 <span>#{pullRequests[0].externalId}</span> 76 78 </div> 77 79 <p className="text-sm font-medium leading-snug"> 78 - {pullRequests[0].title || "Pull Request"} 80 + {pullRequests[0].title || t("tasks:pr.label")} 79 81 </p> 80 82 </div> 81 83 </HoverCardContent> ··· 103 105 className="inline-flex items-center gap-1.5 px-2 py-1 rounded border border-border bg-sidebar text-[10px] font-medium text-muted-foreground hover:bg-muted/50 transition-colors" 104 106 > 105 107 <GitPullRequest className={`h-3 w-3 ${iconColor}`} /> 106 - <span>{pullRequests.length} PRs</span> 108 + <span>{t("tasks:pr.count", { count: pullRequests.length })}</span> 107 109 </button> 108 110 </HoverCardTrigger> 109 111 <HoverCardContent ··· 130 132 </span> 131 133 </div> 132 134 <p className="text-xs leading-tight line-clamp-2 mt-0.5"> 133 - {pr.title || "Pull Request"} 135 + {pr.title || t("tasks:pr.label")} 134 136 </p> 135 137 <span className="text-[10px] text-muted-foreground"> 136 138 {prInfo.status}
+4 -2
apps/web/src/components/public-project/task-card.tsx
··· 1 - import { format } from "date-fns"; 2 1 import { Calendar, CalendarClock, CalendarX } from "lucide-react"; 3 2 import { useRef } from "react"; 3 + import { useTranslation } from "react-i18next"; 4 4 import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 5 5 import { dueDateStatusColors, getDueDateStatus } from "@/lib/due-date-status"; 6 + import { formatDateShort } from "@/lib/format"; 6 7 import { getPriorityIcon } from "@/lib/priority"; 7 8 import type { ExternalLink } from "@/types/external-link"; 8 9 import type Task from "@/types/task"; ··· 23 24 projectSlug, 24 25 onTaskClick, 25 26 }: PublicTaskCardProps) { 27 + const { t } = useTranslation(); 26 28 const labels = task.labels || []; 27 29 const externalLinks = task.externalLinks || []; 28 30 const touchStartRef = useRef<{ x: number; y: number; time: number } | null>( ··· 133 135 getDueDateStatus(task.dueDate) === "no-due-date") && ( 134 136 <Calendar className="w-3 h-3" /> 135 137 )} 136 - <span>{format(new Date(task.dueDate), "MMM d")}</span> 138 + <span>{formatDateShort(task.dueDate)}</span> 137 139 </div> 138 140 )} 139 141
+56 -36
apps/web/src/components/public-project/task-detail-modal.tsx
··· 1 - import { format } from "date-fns"; 2 1 import { 3 2 Calendar, 4 3 CalendarClock, ··· 9 8 GitPullRequest, 10 9 X, 11 10 } from "lucide-react"; 11 + import { useMemo } from "react"; 12 + import { useTranslation } from "react-i18next"; 12 13 import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 13 14 import { Dialog, DialogClose, DialogPopup } from "@/components/ui/dialog"; 14 15 import { dueDateStatusColors, getDueDateStatus } from "@/lib/due-date-status"; 16 + import { formatDateMedium, formatDateShort } from "@/lib/format"; 15 17 import { getPriorityIcon } from "@/lib/priority"; 16 18 import type { ExternalLink } from "@/types/external-link"; 17 19 import type Task from "@/types/task"; ··· 36 38 open, 37 39 onOpenChange, 38 40 }: PublicTaskDetailModalProps) { 41 + const { t } = useTranslation(); 42 + 43 + const getPRStatus = useMemo( 44 + () => (pr: { metadata?: { merged?: boolean; draft?: boolean } | null }) => { 45 + if (pr.metadata?.merged) { 46 + return { 47 + icon: <GitMerge className="w-3.5 h-3.5" />, 48 + label: t("publicProject:taskDetail.prStatusMerged"), 49 + className: "text-info-foreground", 50 + }; 51 + } 52 + if (pr.metadata?.draft) { 53 + return { 54 + icon: <GitPullRequest className="w-3.5 h-3.5" />, 55 + label: t("publicProject:taskDetail.prStatusDraft"), 56 + className: "text-muted-foreground", 57 + }; 58 + } 59 + return { 60 + icon: <GitPullRequest className="w-3.5 h-3.5" />, 61 + label: t("publicProject:taskDetail.prStatusOpen"), 62 + className: "text-success-foreground", 63 + }; 64 + }, 65 + [t], 66 + ); 67 + 39 68 if (!task) return null; 40 69 41 70 const labels = task.labels || []; ··· 49 78 (link) => link.resourceType === "branch", 50 79 ); 51 80 52 - const getPRStatus = (pr: (typeof pullRequests)[number]) => { 53 - if (pr.metadata?.merged) { 54 - return { 55 - icon: <GitMerge className="w-3.5 h-3.5" />, 56 - label: "Merged", 57 - className: "text-info-foreground", 58 - }; 59 - } 60 - if (pr.metadata?.draft) { 61 - return { 62 - icon: <GitPullRequest className="w-3.5 h-3.5" />, 63 - label: "Draft", 64 - className: "text-muted-foreground", 65 - }; 66 - } 67 - return { 68 - icon: <GitPullRequest className="w-3.5 h-3.5" />, 69 - label: "Open", 70 - className: "text-success-foreground", 71 - }; 72 - }; 81 + const statusLabel = task.status ? t(`tasks:status.${task.status}`) : ""; 82 + const priorityLabel = 83 + task.priority != null && task.priority !== "" 84 + ? t(`tasks:priority.${task.priority}`) 85 + : ""; 73 86 74 87 return ( 75 88 <Dialog open={open} onOpenChange={onOpenChange}> ··· 80 93 <span className="text-sm font-medium text-muted-foreground shrink-0"> 81 94 {projectSlug.toUpperCase()}-{task.number} 82 95 </span> 83 - <span className="text-xs text-muted-foreground px-1.5 py-0.5 bg-muted rounded-md capitalize shrink-0"> 84 - {task.status?.replace("-", " ")} 85 - </span> 96 + {statusLabel ? ( 97 + <span className="text-xs text-muted-foreground px-1.5 py-0.5 bg-muted rounded-md shrink-0"> 98 + {statusLabel} 99 + </span> 100 + ) : null} 86 101 </div> 87 102 <DialogClose 88 103 className="shrink-0 p-1.5 hover:bg-muted rounded transition-colors" ··· 99 114 </h2> 100 115 101 116 <div className="flex flex-wrap gap-2"> 102 - {task.priority && ( 117 + {task.priority && priorityLabel && ( 103 118 <div className="flex items-center gap-1.5 px-2.5 py-1 text-xs bg-muted text-muted-foreground rounded-md"> 104 119 {getPriorityIcon(task.priority)} 105 - <span className="capitalize">{task.priority}</span> 120 + <span>{priorityLabel}</span> 106 121 </div> 107 122 )} 108 123 ··· 120 135 getDueDateStatus(task.dueDate) === "no-due-date") && ( 121 136 <Calendar className="w-3 h-3" /> 122 137 )} 123 - <span>Due {format(new Date(task.dueDate), "MMM d")}</span> 138 + <span> 139 + {t("publicProject:taskDetail.dueWithDate", { 140 + date: formatDateShort(task.dueDate), 141 + })} 142 + </span> 124 143 </div> 125 144 )} 126 145 ··· 150 169 {labels.length > 0 && ( 151 170 <div className="space-y-2"> 152 171 <h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wide"> 153 - Labels 172 + {t("publicProject:taskDetail.labels")} 154 173 </h3> 155 174 <PublicTaskLabels labels={labels} /> 156 175 </div> ··· 159 178 {externalLinks.length > 0 && ( 160 179 <div className="space-y-3"> 161 180 <h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wide"> 162 - External Links 181 + {t("publicProject:taskDetail.externalLinks")} 163 182 </h3> 164 183 165 184 {pullRequests.length > 0 && ( ··· 181 200 <div className="flex items-center gap-2.5 flex-1 min-w-0"> 182 201 <div className="flex flex-col gap-0.5 flex-1 min-w-0"> 183 202 <span className="text-sm font-medium text-foreground truncate"> 184 - {pr.title || "Pull Request"} 203 + {pr.title || t("tasks:pr.label")} 185 204 </span> 186 205 <span className="text-xs text-muted-foreground"> 187 206 {repoName}#{pr.externalId} ··· 217 236 <GitPullRequest className="w-3.5 h-3.5 text-muted-foreground" /> 218 237 <div className="flex flex-col gap-0.5 flex-1 min-w-0"> 219 238 <span className="text-sm font-medium text-foreground truncate"> 220 - {issue.title || "Issue"} 239 + {issue.title || 240 + t("publicProject:taskDetail.issueFallback")} 221 241 </span> 222 242 <span className="text-xs text-muted-foreground"> 223 243 #{issue.externalId} ··· 257 277 <div className="grid grid-cols-2 gap-4 pt-4 border-t border-border/50"> 258 278 <div> 259 279 <div className="text-xs text-muted-foreground mb-1"> 260 - Created 280 + {t("publicProject:taskDetail.created")} 261 281 </div> 262 282 <div className="text-sm text-foreground"> 263 - {format(new Date(task.createdAt), "MMM d, yyyy")} 283 + {formatDateMedium(task.createdAt)} 264 284 </div> 265 285 </div> 266 286 {task.dueDate && ( 267 287 <div> 268 288 <div className="text-xs text-muted-foreground mb-1"> 269 - Due Date 289 + {t("publicProject:taskDetail.dueDateLabel")} 270 290 </div> 271 291 <div className="text-sm text-foreground"> 272 - {format(new Date(task.dueDate), "MMM d, yyyy")} 292 + {formatDateMedium(task.dueDate)} 273 293 </div> 274 294 </div> 275 295 )}
+7 -3
apps/web/src/components/public-project/task-row.tsx
··· 1 - import { format } from "date-fns"; 2 1 import { Calendar, CalendarClock, CalendarX } from "lucide-react"; 2 + import { useTranslation } from "react-i18next"; 3 3 import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 4 4 import { dueDateStatusColors, getDueDateStatus } from "@/lib/due-date-status"; 5 + import { formatDateShort } from "@/lib/format"; 5 6 import { getPriorityIcon } from "@/lib/priority"; 6 7 import type { ExternalLink } from "@/types/external-link"; 7 8 import type Task from "@/types/task"; ··· 22 23 projectSlug, 23 24 onTaskClick, 24 25 }: PublicTaskRowProps) { 26 + const { t } = useTranslation(); 25 27 const labels = task.labels || []; 26 28 const externalLinks = task.externalLinks || []; 27 29 ··· 30 32 type="button" 31 33 className="group w-full text-left px-4 py-3 rounded-lg flex items-center gap-4 bg-card border border-border shadow-sm hover:shadow-md transition-all duration-200 ease-out hover:border-border/70 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background" 32 34 onClick={() => onTaskClick(task)} 33 - aria-label={`View details for task ${task.title}`} 35 + aria-label={t("publicProject:taskCard.viewDetailsAria", { 36 + title: task.title, 37 + })} 34 38 > 35 39 <div className="flex-1 min-w-0 flex items-center gap-3"> 36 40 <div className="text-xs font-mono text-muted-foreground shrink-0 font-medium"> ··· 76 80 getDueDateStatus(task.dueDate) === "no-due-date") && ( 77 81 <Calendar className="w-3 h-3" /> 78 82 )} 79 - <span>{format(new Date(task.dueDate), "MMM d")}</span> 83 + <span>{formatDateShort(task.dueDate)}</span> 80 84 </div> 81 85 )} 82 86
+7 -1
apps/web/src/components/public-project/theme-toggle.tsx
··· 1 1 import { Moon, Sun } from "lucide-react"; 2 2 import type { MouseEvent } from "react"; 3 + import { useTranslation } from "react-i18next"; 3 4 import { Button } from "@/components/ui/button"; 4 5 import { getClickCoordinates } from "@/lib/get-click-coordinates"; 5 6 import { useUserPreferencesStore } from "@/store/user-preferences"; 6 7 7 8 export function ThemeToggle() { 9 + const { t } = useTranslation(); 8 10 const { theme, setTheme } = useUserPreferencesStore(); 9 11 10 12 const toggleTheme = (event: MouseEvent<HTMLButtonElement>) => { ··· 19 21 size="sm" 20 22 onClick={toggleTheme} 21 23 className="h-8 w-8 p-0" 22 - aria-label={`Switch to ${theme === "dark" ? "light" : "dark"} mode`} 24 + aria-label={ 25 + theme === "dark" 26 + ? t("publicProject:theme.switchToLight") 27 + : t("publicProject:theme.switchToDark") 28 + } 23 29 > 24 30 {theme === "dark" ? ( 25 31 <Sun className="w-3.5 h-3.5" />
+24 -13
apps/web/src/components/search-command-menu/index.tsx
··· 9 9 Zap, 10 10 } from "lucide-react"; 11 11 import { Fragment, useEffect, useMemo, useState } from "react"; 12 + import { useTranslation } from "react-i18next"; 12 13 import { 13 14 Command, 14 15 CommandCollection, ··· 53 54 setOpen: (open: boolean) => void; 54 55 }; 55 56 56 - const GROUP_LABELS: Record<SearchResultItem["type"], string> = { 57 - task: "Tasks", 58 - project: "Projects", 59 - workspace: "Workspaces", 60 - comment: "Comments", 61 - activity: "Activities", 62 - }; 63 - 64 57 function SearchCommandMenu({ open, setOpen }: SearchCommandMenuProps) { 58 + const { t } = useTranslation(); 65 59 const [query, setQuery] = useState(""); 66 60 const { data: workspace } = useActiveWorkspace(); 67 61 const navigate = useNavigate(); ··· 172 166 {} as Record<string, SearchResultItem[]>, 173 167 ); 174 168 169 + const groupLabel = (type: string) => { 170 + switch (type as SearchResultItem["type"]) { 171 + case "task": 172 + return t("navigation:search.groups.task"); 173 + case "project": 174 + return t("navigation:search.groups.project"); 175 + case "workspace": 176 + return t("navigation:search.groups.workspace"); 177 + case "comment": 178 + return t("navigation:search.groups.comment"); 179 + case "activity": 180 + return t("navigation:search.groups.activity"); 181 + default: 182 + return t("navigation:search.groups.fallback"); 183 + } 184 + }; 185 + 175 186 return Object.entries(grouped).map(([type, items]) => ({ 176 187 value: type, 177 - label: GROUP_LABELS[type as SearchResultItem["type"]] ?? "Results", 188 + label: groupLabel(type), 178 189 items, 179 190 })); 180 - }, [searchEnabled, searchResults?.results]); 191 + }, [searchEnabled, searchResults?.results, t]); 181 192 182 193 return ( 183 194 <CommandDialog open={open} onOpenChange={setOpen}> 184 195 <CommandDialogPopup> 185 196 <Command items={groupedItems}> 186 197 <CommandInput 187 - placeholder="Search tasks, projects, comments..." 198 + placeholder={t("navigation:search.inputPlaceholder")} 188 199 value={query} 189 200 onChange={(event) => setQuery(event.target.value)} 190 201 /> ··· 194 205 <Search className="h-8 w-8 mx-auto mb-2 text-muted-foreground" /> 195 206 <p className="text-sm text-muted-foreground"> 196 207 {searchEnabled 197 - ? "No results found." 198 - : "Type at least 3 characters to search"} 208 + ? t("navigation:commandPalette.empty") 209 + : t("navigation:search.minCharsHint")} 199 210 </p> 200 211 </div> 201 212 </CommandEmpty>
+5 -1
apps/web/src/components/search.tsx
··· 2 2 3 3 import { SearchIcon } from "lucide-react"; 4 4 import { useState } from "react"; 5 + import { useTranslation } from "react-i18next"; 5 6 import SearchCommandMenu from "@/components/search-command-menu"; 6 7 import { SidebarGroup } from "@/components/ui/sidebar"; 7 8 import { shortcuts } from "@/constants/shortcuts"; 8 9 import { useRegisterShortcuts } from "@/hooks/use-keyboard-shortcuts"; 9 10 10 11 export default function Search() { 12 + const { t } = useTranslation(); 11 13 const [open, setOpen] = useState(false); 12 14 13 15 useRegisterShortcuts({ ··· 31 33 className="-ms-1 me-3 text-muted-foreground/80" 32 34 size={16} 33 35 /> 34 - <span className="font-normal text-muted-foreground/70">Search</span> 36 + <span className="font-normal text-muted-foreground/70"> 37 + {t("navigation:commandPalette.search")} 38 + </span> 35 39 </span> 36 40 <kbd className="-me-0.5 ms-6 inline-flex h-4 max-h-full items-center rounded border border-border/70 bg-background px-1 font-[inherit] font-medium text-[0.625rem] text-muted-foreground/60"> 37 41 {shortcuts.search.prefix}
+9 -4
apps/web/src/components/settings-layout.tsx
··· 1 1 import { useNavigate } from "@tanstack/react-router"; 2 2 import { AlertTriangle, ArrowLeft } from "lucide-react"; 3 3 import type { ReactNode } from "react"; 4 + import { useTranslation } from "react-i18next"; 4 5 import Layout from "@/components/common/layout"; 5 6 import { 6 7 Breadcrumb, ··· 36 37 title, 37 38 description, 38 39 backPath, 39 - backLabel = "Back", 40 + backLabel, 40 41 children, 41 42 className, 42 43 }: SettingsLayoutProps) { 44 + const { t } = useTranslation(); 43 45 const navigate = useNavigate(); 46 + const resolvedBackLabel = backLabel ?? t("navigation:settingsLayout.back"); 44 47 45 48 const handleBack = () => { 46 49 if (backPath) { ··· 62 65 </TooltipTrigger> 63 66 <TooltipContent> 64 67 <p className="flex items-center gap-2 text-[10px]"> 65 - Toggle sidebar 68 + {t("navigation:settingsLayout.toggleSidebar")} 66 69 <KbdSequence 67 70 keys={[ 68 71 shortcuts.sidebar.prefix, ··· 81 84 <BreadcrumbList> 82 85 <BreadcrumbItem> 83 86 <BreadcrumbLink href="/dashboard/settings"> 84 - <h1 className="text-xs text-card-foreground">Settings</h1> 87 + <h1 className="text-xs text-card-foreground"> 88 + {t("navigation:page.settingsTitle")} 89 + </h1> 85 90 </BreadcrumbLink> 86 91 </BreadcrumbItem> 87 92 <BreadcrumbSeparator /> ··· 100 105 className="gap-1.5 text-xs" 101 106 > 102 107 <ArrowLeft className="w-3 h-3" /> 103 - {backLabel} 108 + {resolvedBackLabel} 104 109 </Button> 105 110 )} 106 111 </div>
+19 -11
apps/web/src/components/settings/api-key-created-modal.tsx
··· 1 1 import { Check, Copy } from "lucide-react"; 2 2 import { useState } from "react"; 3 + import { useTranslation } from "react-i18next"; 3 4 import { toast } from "@/lib/toast"; 4 5 import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; 5 6 import { Button } from "../ui/button"; ··· 25 26 open, 26 27 onClose, 27 28 }: ApiKeyCreatedModalProps) { 29 + const { t } = useTranslation(); 28 30 const [copied, setCopied] = useState(false); 29 31 30 32 const handleCopy = () => { 31 33 navigator.clipboard.writeText(apiKey); 32 34 setCopied(true); 33 - toast.success("API key copied to clipboard"); 35 + toast.success(t("settings:apiKey.createdModal.toastCopied")); 34 36 }; 35 37 36 38 return ( 37 39 <Dialog open={open} onOpenChange={onClose}> 38 40 <DialogContent className="max-w-[446px]"> 39 41 <DialogHeader> 40 - <DialogTitle>API Key Created</DialogTitle> 42 + <DialogTitle>{t("settings:apiKey.createdModal.title")}</DialogTitle> 41 43 <DialogDescription> 42 - Your API key{" "} 43 - <span className="font-medium text-foreground">"{keyName}"</span> has 44 - been created successfully. 44 + {t("settings:apiKey.createdModal.description", { 45 + keyName, 46 + })} 45 47 </DialogDescription> 46 48 </DialogHeader> 47 49 48 50 <div className="space-y-4 px-6 py-4"> 49 51 <div className="space-y-2"> 50 52 <div className="flex items-center justify-between"> 51 - <p className="text-xs font-medium">Your API Key</p> 53 + <p className="text-xs font-medium"> 54 + {t("settings:apiKey.createdModal.yourApiKey")} 55 + </p> 52 56 <Button 53 57 variant="ghost" 54 58 size="sm" ··· 58 62 {copied ? ( 59 63 <> 60 64 <Check className="h-3 w-3 text-success-foreground" /> 61 - Copied 65 + {t("settings:apiKey.createdModal.copied")} 62 66 </> 63 67 ) : ( 64 68 <> 65 69 <Copy className="h-3 w-3" /> 66 - Copy 70 + {t("settings:apiKey.createdModal.copy")} 67 71 </> 68 72 )} 69 73 </Button> ··· 76 80 </div> 77 81 78 82 <Alert> 79 - <AlertTitle>Success! Your API key has been created</AlertTitle> 83 + <AlertTitle> 84 + {t("settings:apiKey.createdModal.alertTitle")} 85 + </AlertTitle> 80 86 <AlertDescription> 81 - Copy this key now. You won't be able to see it again. 87 + {t("settings:apiKey.createdModal.alertDescription")} 82 88 </AlertDescription> 83 89 </Alert> 84 90 </div> ··· 89 95 disabled={!copied} 90 96 className="h-8 text-xs w-full sm:w-auto" 91 97 > 92 - {copied ? "Done" : "Copy key to continue"} 98 + {copied 99 + ? t("settings:apiKey.createdModal.done") 100 + : t("settings:apiKey.createdModal.copyToContinue")} 93 101 </Button> 94 102 </DialogFooter> 95 103 </DialogContent>
+52 -35
apps/web/src/components/settings/api-key-table.tsx
··· 1 1 import { Trash2 } from "lucide-react"; 2 2 import { useMemo, useState } from "react"; 3 + import { useTranslation } from "react-i18next"; 3 4 import useDeleteApiKey from "@/hooks/mutations/api-key/use-delete-api-key"; 4 5 import { cn } from "@/lib/cn"; 5 6 import { toast } from "@/lib/toast"; ··· 41 42 }).format(date); 42 43 } 43 44 44 - function getExpirationState(expiresAt: Date | string | null) { 45 - if (!expiresAt) { 46 - return { label: "Never", isExpired: false }; 47 - } 48 - 49 - const expirationDate = new Date(expiresAt); 50 - const isExpired = expirationDate.getTime() <= Date.now(); 51 - 52 - return { 53 - label: formatDate(expirationDate), 54 - isExpired, 55 - }; 56 - } 57 - 58 45 export function ApiKeyTable({ apiKeys, isLoading }: ApiKeyTableProps) { 46 + const { t } = useTranslation(); 59 47 const { mutateAsync: deleteApiKey } = useDeleteApiKey(); 60 48 const [deletingId, setDeletingId] = useState<string | null>(null); 61 49 const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null); 62 50 51 + const getExpirationState = (expiresAt: Date | string | null) => { 52 + if (!expiresAt) { 53 + return { label: t("common:formats.never"), isExpired: false }; 54 + } 55 + 56 + const expirationDate = new Date(expiresAt); 57 + const isExpired = expirationDate.getTime() <= Date.now(); 58 + 59 + return { 60 + label: formatDate(expirationDate), 61 + isExpired, 62 + }; 63 + }; 64 + 63 65 const pendingDeleteKey = useMemo( 64 66 () => apiKeys.find((key) => key.id === pendingDeleteId) ?? null, 65 67 [apiKeys, pendingDeleteId], ··· 71 73 setDeletingId(pendingDeleteKey.id); 72 74 try { 73 75 await deleteApiKey(pendingDeleteKey.id); 74 - toast.success("API key deleted successfully"); 76 + toast.success(t("settings:apiKey.table.toastDeleted")); 75 77 setPendingDeleteId(null); 76 78 } catch (error) { 77 79 toast.error( 78 - error instanceof Error ? error.message : "Failed to delete API key", 80 + error instanceof Error 81 + ? error.message 82 + : t("settings:apiKey.table.toastDeleteError"), 79 83 ); 80 84 } finally { 81 85 setDeletingId(null); ··· 87 91 <Frame> 88 92 <FramePanel className="p-8"> 89 93 <p className="text-sm text-muted-foreground text-center"> 90 - Loading API keys... 94 + {t("settings:apiKey.table.loading")} 91 95 </p> 92 96 </FramePanel> 93 97 </Frame> ··· 99 103 <Frame> 100 104 <FramePanel className="p-8"> 101 105 <p className="text-sm text-muted-foreground text-center"> 102 - No API keys yet. Create one to get started. 106 + {t("settings:apiKey.table.empty")} 103 107 </p> 104 108 </FramePanel> 105 109 </Frame> ··· 112 116 <Table> 113 117 <TableHeader> 114 118 <TableRow> 115 - <TableHead>Name</TableHead> 116 - <TableHead>Key</TableHead> 117 - <TableHead>Created</TableHead> 118 - <TableHead>Expires</TableHead> 119 - <TableHead className="w-[90px] text-right">Actions</TableHead> 119 + <TableHead>{t("settings:apiKey.table.columnName")}</TableHead> 120 + <TableHead>{t("settings:apiKey.table.columnKey")}</TableHead> 121 + <TableHead>{t("settings:apiKey.table.columnCreated")}</TableHead> 122 + <TableHead>{t("settings:apiKey.table.columnExpires")}</TableHead> 123 + <TableHead className="w-[90px] text-right"> 124 + {t("settings:apiKey.table.columnActions")} 125 + </TableHead> 120 126 </TableRow> 121 127 </TableHeader> 122 128 <TableBody> ··· 137 143 expiration.isExpired && "text-destructive-foreground", 138 144 )} 139 145 > 140 - {apiKey.name || "Unnamed Key"} 146 + {apiKey.name || t("settings:apiKey.table.unnamedKey")} 141 147 </TableCell> 142 148 <TableCell> 143 149 <code className="text-xs bg-background px-2 py-1 rounded border border-border"> ··· 149 155 </TableCell> 150 156 <TableCell> 151 157 {expiration.isExpired ? ( 152 - <Badge variant="error">Expired {expiration.label}</Badge> 158 + <Badge variant="error"> 159 + {t("settings:apiKey.table.expiredBadge", { 160 + label: expiration.label, 161 + })} 162 + </Badge> 153 163 ) : ( 154 164 <span className="text-muted-foreground"> 155 165 {expiration.label} ··· 163 173 className="h-8 w-8 text-destructive hover:text-destructive" 164 174 onClick={() => setPendingDeleteId(apiKey.id)} 165 175 disabled={deletingId === apiKey.id} 166 - aria-label={`Delete ${apiKey.name || "API key"}`} 176 + aria-label={t("settings:apiKey.table.deleteAria", { 177 + name: 178 + apiKey.name || 179 + t("settings:apiKey.table.deleteAriaFallback"), 180 + })} 167 181 > 168 182 <Trash2 className="h-4 w-4" /> 169 183 </Button> ··· 185 199 > 186 200 <AlertDialogContent> 187 201 <AlertDialogHeader> 188 - <AlertDialogTitle>Delete API key?</AlertDialogTitle> 202 + <AlertDialogTitle> 203 + {t("settings:apiKey.table.deleteConfirmTitle")} 204 + </AlertDialogTitle> 189 205 <AlertDialogDescription> 190 - This action cannot be undone. This will permanently delete 191 - <span className="font-medium text-foreground"> 192 - {" "} 193 - {pendingDeleteKey?.name || "this API key"} 194 - </span> 195 - . 206 + {t("settings:apiKey.table.deleteConfirmDescription", { 207 + name: 208 + pendingDeleteKey?.name || 209 + t("settings:apiKey.table.deleteFallbackName"), 210 + })} 196 211 </AlertDialogDescription> 197 212 </AlertDialogHeader> 198 213 <AlertDialogFooter> 199 214 <AlertDialogClose render={<Button variant="outline" />}> 200 - Cancel 215 + {t("common:actions.cancel")} 201 216 </AlertDialogClose> 202 217 <Button 203 218 variant="destructive" 204 219 onClick={handleDelete} 205 220 disabled={Boolean(deletingId)} 206 221 > 207 - {deletingId ? "Deleting..." : "Delete"} 222 + {deletingId 223 + ? t("settings:apiKey.table.deleting") 224 + : t("settings:apiKey.table.delete")} 208 225 </Button> 209 226 </AlertDialogFooter> 210 227 </AlertDialogContent>
+77 -46
apps/web/src/components/settings/create-api-key-dialog.tsx
··· 1 1 import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"; 2 - import { useState } from "react"; 2 + import { useMemo, useState } from "react"; 3 3 import { useForm } from "react-hook-form"; 4 + import { useTranslation } from "react-i18next"; 4 5 import { z } from "zod"; 5 6 import useCreateApiKey from "@/hooks/mutations/api-key/use-create-api-key"; 6 7 import { toast } from "@/lib/toast"; ··· 39 40 "90d": 7776000, 40 41 } as const; 41 42 42 - const EXPIRATION_OPTIONS = [ 43 - { 44 - label: "1 day", 45 - value: "1d", 46 - }, 47 - { 48 - label: "7 days", 49 - value: "7d", 50 - }, 51 - { 52 - label: "30 days", 53 - value: "30d", 54 - }, 55 - { 56 - label: "90 days", 57 - value: "90d", 58 - }, 59 - { 60 - label: "Never", 61 - value: "never", 62 - }, 63 - ] as const; 64 - 65 - const createApiKeySchema = z.object({ 66 - name: z 67 - .string() 68 - .min(1, "Name is required") 69 - .min(3, "Name must be at least 3 characters"), 70 - expiresIn: z.string().min(1, "Expiration is required"), 71 - }); 72 - 73 - type FormValues = z.infer<typeof createApiKeySchema>; 43 + type FormValues = { 44 + name: string; 45 + expiresIn: string; 46 + }; 74 47 75 48 type CreateApiKeyDialogProps = { 76 49 open: boolean; ··· 83 56 onClose, 84 57 onSuccess, 85 58 }: CreateApiKeyDialogProps) { 59 + const { t } = useTranslation(); 86 60 const { mutateAsync: createApiKey } = useCreateApiKey(); 87 61 const [isSubmitting, setIsSubmitting] = useState(false); 88 62 63 + const createApiKeySchema = useMemo( 64 + () => 65 + z.object({ 66 + name: z 67 + .string() 68 + .min(1, t("settings:apiKey.createDialog.validation.nameRequired")) 69 + .min(3, t("settings:apiKey.createDialog.validation.nameShort")), 70 + expiresIn: z 71 + .string() 72 + .min( 73 + 1, 74 + t("settings:apiKey.createDialog.validation.expirationRequired"), 75 + ), 76 + }), 77 + [t], 78 + ); 79 + 80 + const expirationOptions = useMemo( 81 + () => 82 + [ 83 + { 84 + label: t("settings:apiKey.createDialog.expiration1d"), 85 + value: "1d", 86 + }, 87 + { 88 + label: t("settings:apiKey.createDialog.expiration7d"), 89 + value: "7d", 90 + }, 91 + { 92 + label: t("settings:apiKey.createDialog.expiration30d"), 93 + value: "30d", 94 + }, 95 + { 96 + label: t("settings:apiKey.createDialog.expiration90d"), 97 + value: "90d", 98 + }, 99 + { 100 + label: t("settings:apiKey.createDialog.expirationNever"), 101 + value: "never", 102 + }, 103 + ] as const, 104 + [t], 105 + ); 106 + 89 107 const form = useForm<FormValues>({ 90 108 resolver: standardSchemaResolver(createApiKeySchema), 91 109 defaultValues: { ··· 112 130 onClose(); 113 131 } catch (error) { 114 132 toast.error( 115 - error instanceof Error ? error.message : "Failed to create API key", 133 + error instanceof Error 134 + ? error.message 135 + : t("settings:apiKey.createDialog.failedCreate"), 116 136 ); 117 137 } finally { 118 138 setIsSubmitting(false); ··· 130 150 <Dialog open={open} onOpenChange={handleClose}> 131 151 <DialogContent className="sm:max-w-lg p-0 gap-0"> 132 152 <DialogHeader className="px-6 py-5 border-b border-border"> 133 - <DialogTitle>Create API Key</DialogTitle> 153 + <DialogTitle>{t("settings:apiKey.createDialog.title")}</DialogTitle> 134 154 <DialogDescription> 135 - Create a new API key to access the Kaneo API programmatically. 155 + {t("settings:apiKey.createDialog.description")} 136 156 </DialogDescription> 137 157 </DialogHeader> 138 158 ··· 144 164 name="name" 145 165 render={({ field }) => ( 146 166 <FormItem> 147 - <FormLabel>Name</FormLabel> 167 + <FormLabel> 168 + {t("settings:apiKey.createDialog.nameLabel")} 169 + </FormLabel> 148 170 <FormControl> 149 171 <Input 150 - placeholder="My API Key" 172 + placeholder={t( 173 + "settings:apiKey.createDialog.namePlaceholder", 174 + )} 151 175 {...field} 152 176 disabled={isSubmitting} 153 177 /> 154 178 </FormControl> 155 179 <FormDescription> 156 - A descriptive name for this API key 180 + {t("settings:apiKey.createDialog.nameDescription")} 157 181 </FormDescription> 158 182 <FormMessage /> 159 183 </FormItem> ··· 165 189 name="expiresIn" 166 190 render={({ field }) => ( 167 191 <FormItem> 168 - <FormLabel>Expiration</FormLabel> 192 + <FormLabel> 193 + {t("settings:apiKey.createDialog.expirationLabel")} 194 + </FormLabel> 169 195 <FormControl> 170 196 <Select 171 197 onValueChange={field.onChange} ··· 173 199 disabled={isSubmitting} 174 200 > 175 201 <SelectTrigger> 176 - <SelectValue placeholder="Select expiration" /> 202 + <SelectValue 203 + placeholder={t( 204 + "settings:apiKey.createDialog.expirationPlaceholder", 205 + )} 206 + /> 177 207 </SelectTrigger> 178 208 <SelectContent> 179 - {EXPIRATION_OPTIONS.map((option) => ( 209 + {expirationOptions.map((option) => ( 180 210 <SelectItem key={option.value} value={option.value}> 181 211 {option.label} 182 212 </SelectItem> ··· 185 215 </Select> 186 216 </FormControl> 187 217 <FormDescription> 188 - Choose how long this API key should remain valid. Never 189 - will create a key without an automatic expiry. 218 + {t("settings:apiKey.createDialog.expirationDescription")} 190 219 </FormDescription> 191 220 <FormMessage /> 192 221 </FormItem> ··· 201 230 onClick={handleClose} 202 231 disabled={isSubmitting} 203 232 > 204 - Cancel 233 + {t("common:actions.cancel")} 205 234 </Button> 206 235 <Button type="submit" disabled={isSubmitting}> 207 - {isSubmitting ? "Creating..." : "Create"} 236 + {isSubmitting 237 + ? t("settings:apiKey.createDialog.creating") 238 + : t("settings:apiKey.createDialog.create")} 208 239 </Button> 209 240 </DialogFooter> 210 241 </form>
+21 -13
apps/web/src/components/shared/modals/create-project-modal.tsx
··· 1 1 import { useQueryClient } from "@tanstack/react-query"; 2 2 import { useNavigate } from "@tanstack/react-router"; 3 3 import { useState } from "react"; 4 + import { useTranslation } from "react-i18next"; 4 5 import { 5 6 Breadcrumb, 6 7 BreadcrumbItem, ··· 35 36 }; 36 37 37 38 function CreateProjectModal({ open, onClose }: CreateProjectModalProps) { 39 + const { t } = useTranslation(); 38 40 const [name, setName] = useState(""); 39 41 const [slug, setSlug] = useState(""); 40 42 const [selectedIcon, setSelectedIcon] = useState("Layout"); ··· 84 86 handleClose(); 85 87 } catch (error) { 86 88 toast.error( 87 - error instanceof Error ? error.message : "Failed to create project", 89 + error instanceof Error 90 + ? error.message 91 + : t("common:modals.createProject.errorToast"), 88 92 ); 89 93 } 90 94 }; ··· 99 103 <Dialog open={open} onOpenChange={handleClose}> 100 104 <DialogContent className="max-w-md" showCloseButton={false}> 101 105 <DialogHeader className="px-3 pt-4 pb-1 gap-1.5"> 102 - <DialogTitle className="sr-only">Create a new project</DialogTitle> 106 + <DialogTitle className="sr-only"> 107 + {t("common:modals.createProject.title")} 108 + </DialogTitle> 103 109 <Breadcrumb> 104 110 <BreadcrumbList className="gap-1 text-xs"> 105 111 <BreadcrumbItem className="text-muted-foreground font-medium tracking-wide"> 106 - {workspace?.name?.toUpperCase() || "WORKSPACE"} 112 + {workspace?.name?.toUpperCase() || 113 + t("common:modals.createProject.workspaceFallback")} 107 114 </BreadcrumbItem> 108 115 <BreadcrumbSeparator className="[&>svg]:size-3.5" /> 109 116 <BreadcrumbItem className="text-foreground font-medium"> 110 - Create a new project 117 + {t("common:modals.createProject.breadcrumbNew")} 111 118 </BreadcrumbItem> 112 119 </BreadcrumbList> 113 120 </Breadcrumb> 114 121 <DialogDescription className="sr-only"> 115 - Create a new project in your workspace by providing a name, key, and 116 - selecting an icon. 122 + {t("common:modals.createProject.description")} 117 123 </DialogDescription> 118 124 </DialogHeader> 119 125 ··· 133 139 variant="outline" 134 140 size="icon-sm" 135 141 className="h-8 w-8 p-0" 136 - title="Pick icon" 142 + title={t("common:modals.createProject.pickIcon")} 137 143 > 138 144 <SelectedIcon className="h-4 w-4" /> 139 145 </Button> ··· 143 149 <Input 144 150 value={iconSearch} 145 151 onChange={(e) => setIconSearch(e.target.value)} 146 - placeholder="Search icons..." 152 + placeholder={t("common:modals.createProject.searchIcons")} 147 153 className="h-8 text-xs" 148 154 /> 149 155 <div className="max-h-[280px] overflow-y-auto pr-1"> ··· 183 189 value={name} 184 190 onChange={handleNameChange} 185 191 autoFocus 186 - placeholder="Project name" 192 + placeholder={t("common:modals.createProject.projectName")} 187 193 className="w-full [&_[data-slot=input]]:h-auto [&_[data-slot=input]]:px-0 [&_[data-slot=input]]:py-2 [&_[data-slot=input]]:text-2xl [&_[data-slot=input]]:leading-tight [&_[data-slot=input]]:font-semibold [&_[data-slot=input]]:tracking-tight [&_[data-slot=input]]:text-foreground [&_[data-slot=input]]:placeholder:text-muted-foreground [&_[data-slot=input]]:outline-none" 188 194 required 189 195 /> ··· 193 199 <div className="flex items-center gap-3 p-3 rounded-xl bg-muted/50 border border-border"> 194 200 <div className="flex items-center gap-2"> 195 201 <span className="text-sm font-medium text-muted-foreground"> 196 - Key: 202 + {t("common:modals.createProject.keyLabel")} 197 203 </span> 198 204 <Input 199 205 id="project-key" ··· 206 212 /> 207 213 </div> 208 214 <div className="flex-1 text-xs text-muted-foreground opacity-80"> 209 - Used for ticket IDs (e.g., {slug || "ABC"}-123) 215 + {t("common:modals.createProject.keyHint", { 216 + example: slug || "ABC", 217 + })} 210 218 </div> 211 219 </div> 212 220 </div> ··· 219 227 size="sm" 220 228 className="border-border text-foreground hover:bg-accent" 221 229 > 222 - Cancel 230 + {t("common:actions.cancel")} 223 231 </Button> 224 232 <Button 225 233 type="submit" ··· 227 235 size="sm" 228 236 className="bg-primary hover:bg-primary/90 disabled:opacity-50" 229 237 > 230 - Create Project 238 + {t("common:modals.createProject.createButton")} 231 239 </Button> 232 240 </DialogFooter> 233 241 </form>
+130 -91
apps/web/src/components/shared/modals/create-task-modal.tsx
··· 1 1 import { useLocation } from "@tanstack/react-router"; 2 - import { format } from "date-fns"; 3 2 import { produce } from "immer"; 4 3 import { 5 4 CalendarIcon, ··· 10 9 UserIcon, 11 10 X, 12 11 } from "lucide-react"; 13 - import { useCallback, useEffect, useRef, useState } from "react"; 12 + import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 13 + import { useTranslation } from "react-i18next"; 14 14 import TaskDescriptionEditor from "@/components/task/task-description-editor"; 15 15 import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 16 16 import { Badge } from "@/components/ui/badge"; ··· 43 43 import useGetLabelsByWorkspace from "@/hooks/queries/label/use-get-labels-by-workspace"; 44 44 import useActiveWorkspace from "@/hooks/queries/workspace/use-active-workspace"; 45 45 import { cn } from "@/lib/cn"; 46 + import { formatDateMedium } from "@/lib/format"; 46 47 import { getPriorityIcon } from "@/lib/priority"; 47 48 import { toast } from "@/lib/toast"; 48 49 import useProjectStore from "@/store/project"; ··· 100 101 }; 101 102 } 102 103 103 - const labelColors = [ 104 - { 105 - value: "gray" as LabelColor, 106 - label: "Stone", 107 - color: "var(--color-stone-500)", 108 - }, 109 - { 110 - value: "dark-gray" as LabelColor, 111 - label: "Slate", 112 - color: "var(--color-slate-500)", 113 - }, 114 - { 115 - value: "purple" as LabelColor, 116 - label: "Lavender", 117 - color: "var(--color-violet-500)", 118 - }, 119 - { 120 - value: "teal" as LabelColor, 121 - label: "Sage", 122 - color: "var(--color-emerald-600)", 123 - }, 124 - { 125 - value: "green" as LabelColor, 126 - label: "Forest", 127 - color: "var(--color-green-600)", 128 - }, 129 - { 130 - value: "yellow" as LabelColor, 131 - label: "Amber", 132 - color: "var(--color-amber-600)", 133 - }, 134 - { 135 - value: "orange" as LabelColor, 136 - label: "Terracotta", 137 - color: "var(--color-orange-600)", 138 - }, 139 - { 140 - value: "pink" as LabelColor, 141 - label: "Rose", 142 - color: "var(--color-rose-600)", 143 - }, 144 - { 145 - value: "red" as LabelColor, 146 - label: "Crimson", 147 - color: "var(--color-red-600)", 148 - }, 149 - ]; 150 - 151 104 function CreateTaskModal({ 152 105 open, 153 106 onClose, 154 107 status, 155 108 projectId, 156 109 }: CreateTaskModalProps) { 110 + const { t } = useTranslation(); 157 111 const { project, setProject } = useProjectStore(); 112 + 113 + const labelColors = useMemo( 114 + () => 115 + [ 116 + { 117 + value: "gray" as LabelColor, 118 + labelKey: "stone" as const, 119 + color: "var(--color-stone-500)", 120 + }, 121 + { 122 + value: "dark-gray" as LabelColor, 123 + labelKey: "slate" as const, 124 + color: "var(--color-slate-500)", 125 + }, 126 + { 127 + value: "purple" as LabelColor, 128 + labelKey: "lavender" as const, 129 + color: "var(--color-violet-500)", 130 + }, 131 + { 132 + value: "teal" as LabelColor, 133 + labelKey: "sage" as const, 134 + color: "var(--color-emerald-600)", 135 + }, 136 + { 137 + value: "green" as LabelColor, 138 + labelKey: "forest" as const, 139 + color: "var(--color-green-600)", 140 + }, 141 + { 142 + value: "yellow" as LabelColor, 143 + labelKey: "amber" as const, 144 + color: "var(--color-amber-600)", 145 + }, 146 + { 147 + value: "orange" as LabelColor, 148 + labelKey: "terracotta" as const, 149 + color: "var(--color-orange-600)", 150 + }, 151 + { 152 + value: "pink" as LabelColor, 153 + labelKey: "rose" as const, 154 + color: "var(--color-rose-600)", 155 + }, 156 + { 157 + value: "red" as LabelColor, 158 + labelKey: "crimson" as const, 159 + color: "var(--color-red-600)", 160 + }, 161 + ].map(({ labelKey, ...rest }) => ({ 162 + ...rest, 163 + label: t(`common:modals.createTask.labelColors.${labelKey}`), 164 + })), 165 + [t], 166 + ); 158 167 const location = useLocation(); 159 168 const { data: workspace } = useActiveWorkspace(); 160 169 const { mutateAsync: createLabel } = useCreateLabel(); ··· 303 312 } 304 313 305 314 if (!resolvedProjectId) { 306 - toast.error("Choose a project before uploading images."); 315 + toast.error(t("common:modals.createTask.chooseProjectForImages")); 307 316 return null; 308 317 } 309 318 310 319 const draftStatus = "planned"; 311 320 const draftPromise = createTask({ 312 - title: title.trim() || "Untitled task", 321 + title: title.trim() || t("common:modals.createTask.untitledTask"), 313 322 description: description.trim() || "", 314 323 userId: assigneeId, 315 324 priority, ··· 327 336 return createdTask.id; 328 337 } catch (error) { 329 338 toast.error( 330 - error instanceof Error ? error.message : "Failed to prepare task", 339 + error instanceof Error 340 + ? error.message 341 + : t("common:modals.createTask.prepareTaskError"), 331 342 ); 332 343 return null; 333 344 } finally { ··· 343 354 priority, 344 355 resolvedProjectId, 345 356 title, 357 + t, 346 358 ]); 347 359 348 360 const handleSubmit = async (e: React.FormEvent) => { ··· 396 408 setDraftTask(savedTask); 397 409 syncTaskIntoProject(savedTask); 398 410 toast.success( 399 - draftTask ? "Task updated successfully" : "Task created successfully", 411 + draftTask 412 + ? t("common:modals.createTask.successUpdated") 413 + : t("common:modals.createTask.successCreated"), 400 414 ); 401 415 402 416 if (createMore) { ··· 420 434 } catch (error) { 421 435 didSubmitRef.current = false; 422 436 toast.error( 423 - error instanceof Error ? error.message : "Failed to create task", 437 + error instanceof Error 438 + ? error.message 439 + : t("common:modals.createTask.createError"), 424 440 ); 425 441 } 426 442 }; 427 443 428 - const priorityOptions = [ 429 - { value: "no-priority", label: "No Priority" }, 430 - { value: "low", label: "Low" }, 431 - { value: "medium", label: "Medium" }, 432 - { value: "high", label: "High" }, 433 - { value: "urgent", label: "Urgent" }, 434 - ]; 444 + const priorityOptions = useMemo( 445 + () => 446 + (["no-priority", "low", "medium", "high", "urgent"] as const).map( 447 + (value) => ({ 448 + value, 449 + label: t(`tasks:priority.${value}`), 450 + }), 451 + ), 452 + [t], 453 + ); 435 454 436 455 const selectedPriority = priorityOptions.find((p) => p.value === priority); 456 + 457 + const statusLabel = useMemo(() => { 458 + if (status) { 459 + return t(`tasks:status.${status}`); 460 + } 461 + return t("tasks:status.in-progress"); 462 + }, [status, t]); 437 463 const selectedUser = workspace?.members?.find((u) => u.userId === assigneeId); 438 464 439 465 useEffect(() => { ··· 527 553 }; 528 554 529 555 setLabels([...labels, newLabel]); 530 - toast.success("Label created"); 556 + toast.success(t("common:modals.createTask.labelCreated")); 531 557 handleLabelsClose(); 532 558 } catch (error) { 533 559 toast.error( 534 - error instanceof Error ? error.message : "Failed to create label", 560 + error instanceof Error 561 + ? error.message 562 + : t("common:modals.createTask.labelCreateError"), 535 563 ); 536 564 } 537 565 }; ··· 551 579 <Breadcrumb> 552 580 <BreadcrumbList> 553 581 <BreadcrumbItem className="text-muted-foreground font-semibold tracking-wider text-sm"> 554 - {project?.slug?.toUpperCase() || "TASK"} 582 + {project?.slug?.toUpperCase() || 583 + t("common:modals.createTask.breadcrumbTask")} 555 584 </BreadcrumbItem> 556 585 <BreadcrumbSeparator /> 557 586 <BreadcrumbItem className="text-foreground font-medium text-sm"> 558 - New Task 587 + {t("common:modals.createTask.title")} 559 588 </BreadcrumbItem> 560 589 </BreadcrumbList> 561 590 </Breadcrumb> 562 591 </DialogTitle> 563 592 <DialogDescription className="sr-only"> 564 - Create a new task by providing a title, description, and other 565 - details. 593 + {t("common:modals.createTask.description")} 566 594 </DialogDescription> 567 595 </DialogHeader> 568 596 ··· 576 604 value={title} 577 605 onChange={(e) => setTitle(e.target.value)} 578 606 autoFocus 579 - placeholder="Task title" 607 + placeholder={t("common:modals.createTask.taskTitlePlaceholder")} 580 608 className="w-full [&_[data-slot=input]]:h-auto [&_[data-slot=input]]:px-0 [&_[data-slot=input]]:py-3 [&_[data-slot=input]]:text-2xl [&_[data-slot=input]]:leading-tight [&_[data-slot=input]]:font-semibold [&_[data-slot=input]]:tracking-tight [&_[data-slot=input]]:text-foreground [&_[data-slot=input]]:placeholder:text-muted-foreground [&_[data-slot=input]]:outline-none" 581 609 required 582 610 /> ··· 585 613 <TaskDescriptionEditor 586 614 value={description} 587 615 onChange={setDescription} 588 - placeholder="Add a description for your task..." 616 + placeholder={t( 617 + "common:modals.createTask.descriptionPlaceholder", 618 + )} 589 619 taskId={draftTask?.id} 590 620 ensureTaskId={ensureDraftTask} 591 621 /> ··· 618 648 <div className="flex flex-wrap items-center gap-2 py-2"> 619 649 <div className="flex items-center gap-1.5 px-2.5 py-1.5 bg-accent/50 text-foreground rounded-md text-xs font-medium border border-border"> 620 650 <div className="w-1.5 h-1.5 bg-foreground rounded-full" /> 621 - {status 622 - ? status.charAt(0).toUpperCase() + 623 - status.slice(1).replace("-", " ") 624 - : "In Progress"} 651 + {statusLabel} 625 652 </div> 626 653 627 654 <Popover> ··· 638 665 <CalendarIcon className="w-3.5 h-3.5" /> 639 666 <span> 640 667 {startDate 641 - ? format(startDate, "MMM d, yyyy") 642 - : "Start date"} 668 + ? formatDateMedium(startDate) 669 + : t("common:modals.createTask.startDate")} 643 670 </span> 644 671 </button> 645 672 </PopoverTrigger> ··· 659 686 className="w-full text-xs" 660 687 onClick={() => setStartDate(undefined)} 661 688 > 662 - Clear start date 689 + {t("common:modals.createTask.clearStartDate")} 663 690 </Button> 664 691 </div> 665 692 )} ··· 679 706 > 680 707 {getPriorityIcon(priority)} 681 708 <span> 682 - {selectedPriority ? selectedPriority.label : "Priority"} 709 + {selectedPriority 710 + ? selectedPriority.label 711 + : t("common:modals.createTask.priority")} 683 712 </span> 684 713 </button> 685 714 </PopoverTrigger> ··· 732 761 ) : ( 733 762 <> 734 763 <UserIcon className="w-3.5 h-3.5" /> 735 - <span>Assign</span> 764 + <span>{t("common:modals.createTask.assign")}</span> 736 765 </> 737 766 )} 738 767 </button> ··· 746 775 > 747 776 <div 748 777 className="w-6 h-6 rounded-full bg-muted border border-border flex items-center justify-center" 749 - title="Unassigned" 778 + title={t( 779 + "common:modals.createTask.assignUnassignedTitle", 780 + )} 750 781 > 751 782 <span className="text-[10px] font-medium text-muted-foreground"> 752 783 ? 753 784 </span> 754 785 </div> 755 - <span className="text-sm">Unassigned</span> 786 + <span className="text-sm"> 787 + {t("common:modals.createTask.assignUnassigned")} 788 + </span> 756 789 {!assigneeId && <Check className="ml-auto h-4 w-4" />} 757 790 </button> 758 791 {workspace?.members?.map((member) => ( ··· 794 827 > 795 828 <CalendarIcon className="w-3.5 h-3.5" /> 796 829 <span> 797 - {dueDate ? format(dueDate, "MMM d, yyyy") : "Due date"} 830 + {dueDate 831 + ? formatDateMedium(dueDate) 832 + : t("common:modals.createTask.dueDate")} 798 833 </span> 799 834 </button> 800 835 </PopoverTrigger> ··· 814 849 className="w-full text-xs" 815 850 onClick={() => setDueDate(undefined)} 816 851 > 817 - Clear due date 852 + {t("common:modals.createTask.clearDueDate")} 818 853 </Button> 819 854 </div> 820 855 )} ··· 833 868 )} 834 869 > 835 870 <Tag className="w-3.5 h-3.5" /> 836 - <span>Labels</span> 871 + <span>{t("common:modals.createTask.labels")}</span> 837 872 </button> 838 873 </PopoverTrigger> 839 874 <PopoverContent className="p-0" align="start"> ··· 845 880 ref={searchInputRef} 846 881 value={searchValue} 847 882 onChange={(e) => setSearchValue(e.target.value)} 848 - placeholder="Search labels..." 883 + placeholder={t( 884 + "common:modals.createTask.searchLabels", 885 + )} 849 886 className="w-full bg-transparent border-none text-foreground text-xs focus:outline-none placeholder:text-muted-foreground" 850 887 /> 851 888 </div> ··· 854 891 {filteredLabels.length === 0 && 855 892 searchValue.length === 0 && ( 856 893 <span className="text-xs text-muted-foreground px-2"> 857 - No labels found 894 + {t("common:modals.createTask.noLabelsFound")} 858 895 </span> 859 896 )} 860 897 {filteredLabels.map((label) => ( ··· 904 941 }} 905 942 /> 906 943 <span className="truncate"> 907 - Create "{searchValue}" 944 + {t("common:modals.createTask.createLabel", { 945 + name: searchValue, 946 + })} 908 947 </span> 909 948 </button> 910 949 )} ··· 915 954 <div className="w-auto"> 916 955 <div className="flex items-center justify-between p-2 border-b border-border"> 917 956 <span className="text-xs font-medium"> 918 - Choose color 957 + {t("common:modals.createTask.chooseColor")} 919 958 </span> 920 959 <button 921 960 type="button" ··· 966 1005 onChange={(e) => setCreateMore(e.target.checked)} 967 1006 className="rounded border-border bg-background text-primary focus:ring-ring focus:ring-offset-0 focus:ring-2 transition-all" 968 1007 /> 969 - Create more 1008 + {t("common:modals.createTask.createMore")} 970 1009 </label> 971 1010 </div> 972 1011 ··· 977 1016 size="sm" 978 1017 className="border-border text-foreground hover:bg-accent" 979 1018 > 980 - Cancel 1019 + {t("common:actions.cancel")} 981 1020 </Button> 982 1021 <Button 983 1022 type="submit" ··· 985 1024 size="sm" 986 1025 className="disabled:opacity-50" 987 1026 > 988 - Create Task 1027 + {t("common:modals.createTask.createButton")} 989 1028 </Button> 990 1029 </DialogFooter> 991 1030 </form>
+15 -9
apps/web/src/components/shared/modals/create-workspace-modal.tsx
··· 1 1 import { useQueryClient } from "@tanstack/react-query"; 2 2 import { useNavigate } from "@tanstack/react-router"; 3 3 import { useEffect, useRef, useState } from "react"; 4 + import { useTranslation } from "react-i18next"; 4 5 import { 5 6 Breadcrumb, 6 7 BreadcrumbItem, ··· 27 28 }; 28 29 29 30 function CreateWorkspaceModal({ open, onClose }: CreateWorkspaceModalProps) { 31 + const { t } = useTranslation(); 30 32 const [name, setName] = useState(""); 31 33 const [description, setDescription] = useState(""); 32 34 const inputRef = useRef<HTMLInputElement>(null); ··· 54 56 55 57 try { 56 58 const createdWorkspace = await mutateAsync({ name, description }); 57 - toast.success("Workspace created successfully"); 59 + toast.success(t("common:modals.createWorkspace.successToast")); 58 60 await queryClient.invalidateQueries({ queryKey: ["workspaces"] }); 59 61 60 62 await authClient.organization.setActive({ ··· 71 73 handleClose(); 72 74 } catch (error) { 73 75 toast.error( 74 - error instanceof Error ? error.message : "Failed to create workspace", 76 + error instanceof Error 77 + ? error.message 78 + : t("common:modals.createWorkspace.errorToast"), 75 79 ); 76 80 } 77 81 }; ··· 84 88 <Breadcrumb> 85 89 <BreadcrumbList> 86 90 <BreadcrumbItem className="text-muted-foreground font-semibold tracking-wider text-sm"> 87 - KANEO 91 + {t("common:modals.createWorkspace.breadcrumbKaneo")} 88 92 </BreadcrumbItem> 89 93 <BreadcrumbSeparator /> 90 94 <BreadcrumbItem className="text-foreground font-medium text-sm"> 91 - Create a new workspace 95 + {t("common:modals.createWorkspace.title")} 92 96 </BreadcrumbItem> 93 97 </BreadcrumbList> 94 98 </Breadcrumb> 95 99 </DialogTitle> 96 100 <DialogDescription className="sr-only"> 97 - Create a new workspace by providing a name for your workspace. 101 + {t("common:modals.createWorkspace.description")} 98 102 </DialogDescription> 99 103 </DialogHeader> 100 104 ··· 105 109 unstyled 106 110 value={name} 107 111 onChange={(e) => setName(e.target.value)} 108 - placeholder="Workspace name" 112 + placeholder={t("common:modals.createWorkspace.namePlaceholder")} 109 113 className="w-full [&_[data-slot=input]]:h-auto [&_[data-slot=input]]:px-0 [&_[data-slot=input]]:py-2 [&_[data-slot=input]]:text-2xl [&_[data-slot=input]]:leading-tight [&_[data-slot=input]]:font-semibold [&_[data-slot=input]]:tracking-tight [&_[data-slot=input]]:text-foreground [&_[data-slot=input]]:placeholder:text-muted-foreground [&_[data-slot=input]]:outline-none" 110 114 required 111 115 /> ··· 114 118 unstyled 115 119 value={description} 116 120 onChange={(e) => setDescription(e.target.value)} 117 - placeholder="Add description..." 121 + placeholder={t( 122 + "common:modals.createWorkspace.descriptionPlaceholder", 123 + )} 118 124 className="w-full [&_[data-slot=input]]:h-auto [&_[data-slot=input]]:px-0 [&_[data-slot=input]]:py-2 [&_[data-slot=input]]:text-base [&_[data-slot=input]]:leading-relaxed [&_[data-slot=input]]:text-foreground [&_[data-slot=input]]:placeholder:text-muted-foreground [&_[data-slot=input]]:outline-none" 119 125 /> 120 126 </div> ··· 127 133 size="sm" 128 134 className="border-border text-foreground hover:bg-accent" 129 135 > 130 - Cancel 136 + {t("common:actions.cancel")} 131 137 </Button> 132 138 <Button 133 139 type="submit" ··· 135 141 size="sm" 136 142 className="disabled:opacity-50" 137 143 > 138 - Create Workspace 144 + {t("common:modals.createWorkspace.createButton")} 139 145 </Button> 140 146 </DialogFooter> 141 147 </form>
+3 -2
apps/web/src/components/task/extensions/embed-block.ts
··· 1 1 import { mergeAttributes, Node } from "@tiptap/core"; 2 + import { i18n } from "@/lib/i18n"; 2 3 3 4 type EmbedMode = "embed" | "link"; 4 5 ··· 101 102 "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share", 102 103 allowfullscreen: "true", 103 104 referrerpolicy: "strict-origin-when-cross-origin", 104 - title: "Embedded content", 105 + title: i18n.t("tasks:detail.editor.embed.embeddedContent"), 105 106 }, 106 107 ], 107 108 ]; ··· 114 115 [ 115 116 "div", 116 117 { class: "kaneo-embed-unsupported" }, 117 - "Only YouTube URLs can be embedded. Use link mode instead.", 118 + i18n.t("tasks:detail.editor.embed.onlyYoutubeInline"), 118 119 ], 119 120 ]; 120 121 }
+11 -5
apps/web/src/components/task/extensions/kaneo-issue-link.tsx
··· 2 2 import { mergeAttributes, Node } from "@tiptap/core"; 3 3 import type { NodeViewProps } from "@tiptap/react"; 4 4 import { NodeViewWrapper, ReactNodeViewRenderer } from "@tiptap/react"; 5 + import { useTranslation } from "react-i18next"; 5 6 import { 6 7 HoverCard, 7 8 HoverCardContent, ··· 36 37 } 37 38 38 39 function KaneoIssueLinkView({ node }: NodeViewProps) { 40 + const { t } = useTranslation(); 39 41 const issueKey = String(node.attrs.issueKey || ""); 40 42 const taskIdAttr = String(node.attrs.taskId || ""); 41 43 const url = String(node.attrs.url || ""); ··· 68 70 const resolvedIssueKey = 69 71 issueKey || 70 72 (projectSlug && task?.number ? `${projectSlug}-${task.number}` : ""); 71 - const title = task?.title || issueKey || "Task"; 72 - const status = toTitleCase(task?.status) || "Todo"; 73 - const priority = toTitleCase(task?.priority) || "No priority"; 74 - const assignee = task?.assigneeName || "Unassigned"; 73 + const title = task?.title || issueKey || t("tasks:entity.task"); 74 + const status = task?.status 75 + ? t(`tasks:status.${task.status}`) 76 + : t("tasks:status.to-do"); 77 + const priority = task?.priority 78 + ? t(`tasks:priority.${task.priority}`) 79 + : t("tasks:priority.no-priority"); 80 + const assignee = task?.assigneeName || t("tasks:assignee.unassigned"); 75 81 const href = 76 82 taskRoute?.workspaceId && taskRoute?.projectId && task?.id 77 83 ? `/dashboard/workspace/${taskRoute.workspaceId}/project/${taskRoute.projectId}/task/${task.id}` ··· 102 108 > 103 109 <div className="kaneo-issue-link-preview-top"> 104 110 <span className="kaneo-issue-link-preview-key"> 105 - {resolvedIssueKey || "Task"} 111 + {resolvedIssueKey || t("tasks:entity.task")} 106 112 </span> 107 113 <span className="kaneo-issue-link-preview-assignee"> 108 114 {assignee}
+5 -1
apps/web/src/components/task/extensions/task-item-with-checkbox.tsx
··· 5 5 NodeViewWrapper, 6 6 ReactNodeViewRenderer, 7 7 } from "@tiptap/react"; 8 + import { useTranslation } from "react-i18next"; 8 9 import { Checkbox } from "@/components/ui/checkbox"; 9 10 10 11 function TaskItemNodeView({ editor, node, updateAttributes }: NodeViewProps) { 12 + const { t } = useTranslation(); 11 13 const checked = Boolean(node.attrs.checked); 12 14 const isEditable = editor.isEditable; 13 15 ··· 22 24 checked={checked} 23 25 disabled={!isEditable} 24 26 aria-label={ 25 - checked ? "Mark task as incomplete" : "Mark task as complete" 27 + checked 28 + ? t("tasks:detail.editor.checkbox.markIncomplete") 29 + : t("tasks:detail.editor.checkbox.markComplete") 26 30 } 27 31 onCheckedChange={(value) => { 28 32 if (!isEditable) return;
+8 -4
apps/web/src/components/task/subtask-assignee-popover.tsx
··· 1 1 import { Check } from "lucide-react"; 2 2 import { useCallback, useMemo, useState } from "react"; 3 + import { useTranslation } from "react-i18next"; 3 4 import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 4 5 import { Button } from "@/components/ui/button"; 5 6 import { ··· 25 26 workspaceId, 26 27 children, 27 28 }: SubtaskAssigneePopoverProps) { 29 + const { t } = useTranslation(); 28 30 const [open, setOpen] = useState(false); 29 31 const { mutateAsync: updateTaskAssignee } = useUpdateTaskAssignee(); 30 32 const { data: workspaceUsers } = useGetActiveWorkspaceUsers(workspaceId); ··· 58 60 toast.error( 59 61 error instanceof Error 60 62 ? error.message 61 - : "Failed to update task assignee", 63 + : t("tasks:popover.assignee.updateError"), 62 64 ); 63 65 } 64 66 }, 65 - [tasks, updateTaskAssignee], 67 + [t, tasks, updateTaskAssignee], 66 68 ); 67 69 68 70 const shortcutOptions = useMemo(() => { ··· 88 90 > 89 91 <div 90 92 className="w-6 h-6 rounded-full bg-muted border border-border flex items-center justify-center" 91 - title="Unassigned" 93 + title={t("tasks:popover.assignee.unassigned")} 92 94 > 93 95 <span className="text-[10px] font-medium text-muted-foreground"> 94 96 ? 95 97 </span> 96 98 </div> 97 - <span className="text-sm">Unassigned</span> 99 + <span className="text-sm"> 100 + {t("tasks:popover.assignee.unassigned")} 101 + </span> 98 102 {allSameAssignee && !currentAssignee ? ( 99 103 <Check className="ml-auto h-4 w-4" /> 100 104 ) : (
+4 -1
apps/web/src/components/task/subtask-row.tsx
··· 1 1 import { motion } from "framer-motion"; 2 + import { useTranslation } from "react-i18next"; 2 3 import TaskCardContextMenuContent from "@/components/kanban-board/task-card-context-menu/task-card-context-menu-content"; 3 4 import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 4 5 import { Checkbox } from "@/components/ui/checkbox"; ··· 37 38 onNavigate, 38 39 onDeleteClick, 39 40 }: SubtaskRowProps) { 41 + const { t } = useTranslation(); 42 + 40 43 return ( 41 44 <motion.div 42 45 layout ··· 94 97 ) : ( 95 98 <div 96 99 className="flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-border/70" 97 - title="Unassigned" 100 + title={t("tasks:popover.assignee.unassigned")} 98 101 > 99 102 <span className="text-[9px] font-medium text-muted-foreground"> 100 103 ?
+8 -3
apps/web/src/components/task/subtask-status-popover.tsx
··· 1 1 import { Check } from "lucide-react"; 2 2 import { useCallback, useMemo, useState } from "react"; 3 + import { useTranslation } from "react-i18next"; 3 4 import { Button } from "@/components/ui/button"; 4 5 import { 5 6 Popover, ··· 11 12 import { useGetColumns } from "@/hooks/queries/column/use-get-columns"; 12 13 import { useNumberedShortcuts } from "@/hooks/use-numbered-shortcuts"; 13 14 import { getColumnIcon } from "@/lib/column"; 15 + import { getStatusLabel } from "@/lib/i18n/domain"; 14 16 import { toast } from "@/lib/toast"; 15 17 import type Task from "@/types/task"; 16 18 ··· 25 27 projectId, 26 28 children, 27 29 }: SubtaskStatusPopoverProps) { 30 + const { t } = useTranslation(); 28 31 const [open, setOpen] = useState(false); 29 32 const { data: columns = [] } = useGetColumns(projectId); 30 33 const statusOptions = columns.map((col) => ({ ··· 54 57 toast.error( 55 58 error instanceof Error 56 59 ? error.message 57 - : "Failed to update task status", 60 + : t("tasks:popover.status.updateError"), 58 61 ); 59 62 } 60 63 }, 61 - [tasks, updateTaskStatus], 64 + [t, tasks, updateTaskStatus], 62 65 ); 63 66 64 67 const shortcutOptions = useMemo( ··· 85 88 onClick={() => handleStatusChange(status.value)} 86 89 > 87 90 {getColumnIcon(status.value, status.isFinal)} 88 - <span className="text-sm">{status.label}</span> 91 + <span className="text-sm"> 92 + {getStatusLabel(status.value) || status.label} 93 + </span> 89 94 {currentStatus === status.value ? ( 90 95 <Check className="ml-auto h-4 w-4" /> 91 96 ) : (
+8 -4
apps/web/src/components/task/task-assignee-popover.tsx
··· 1 1 import { Check } from "lucide-react"; 2 2 import { useCallback, useMemo, useState } from "react"; 3 + import { useTranslation } from "react-i18next"; 3 4 import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 4 5 import { Button } from "@/components/ui/button"; 5 6 import { ··· 25 26 workspaceId, 26 27 children, 27 28 }: TaskAssigneePopoverProps) { 29 + const { t } = useTranslation(); 28 30 const [open, setOpen] = useState(false); 29 31 const { mutateAsync: updateTaskAssignee } = useUpdateTaskAssignee(); 30 32 const { data: workspaceUsers } = useGetActiveWorkspaceUsers(workspaceId); ··· 50 52 toast.error( 51 53 error instanceof Error 52 54 ? error.message 53 - : "Failed to update task assignee", 55 + : t("tasks:popover.assignee.updateError"), 54 56 ); 55 57 } 56 58 }, 57 - [task, updateTaskAssignee], 59 + [t, task, updateTaskAssignee], 58 60 ); 59 61 60 62 const shortcutOptions = useMemo(() => { ··· 80 82 > 81 83 <div 82 84 className="w-6 h-6 rounded-full bg-muted border border-border flex items-center justify-center" 83 - title="Unassigned" 85 + title={t("tasks:popover.assignee.unassigned")} 84 86 > 85 87 <span className="text-[10px] font-medium text-muted-foreground"> 86 88 ? 87 89 </span> 88 90 </div> 89 - <span className="text-sm">Unassigned</span> 91 + <span className="text-sm"> 92 + {t("tasks:popover.assignee.unassigned")} 93 + </span> 90 94 {!task.userId ? ( 91 95 <Check className="ml-auto h-4 w-4" /> 92 96 ) : (
+5 -2
apps/web/src/components/task/task-description-editor.tsx
··· 1 + import { useTranslation } from "react-i18next"; 1 2 import CommentEditor from "@/components/activity/comment-editor"; 2 3 3 4 type TaskDescriptionEditorProps = { ··· 11 12 export default function TaskDescriptionEditor({ 12 13 value, 13 14 onChange, 14 - placeholder = "Add a description...", 15 + placeholder, 15 16 taskId, 16 17 ensureTaskId, 17 18 }: TaskDescriptionEditorProps) { 19 + const { t } = useTranslation(); 20 + 18 21 return ( 19 22 <CommentEditor 20 23 value={value} 21 24 onChange={onChange} 22 - placeholder={placeholder} 25 + placeholder={placeholder ?? t("tasks:detail.addDescription")} 23 26 taskId={taskId} 24 27 ensureTaskId={ensureTaskId} 25 28 uploadSurface="description"
+70 -37
apps/web/src/components/task/task-description.tsx
··· 34 34 } from "lucide-react"; 35 35 import type { MouseEvent as ReactMouseEvent } from "react"; 36 36 import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 37 + import { useTranslation } from "react-i18next"; 37 38 import { bundledLanguages, type Highlighter } from "shiki"; 38 39 import { Button } from "@/components/ui/button"; 39 40 import { Dialog, DialogPopup } from "@/components/ui/dialog"; ··· 252 253 ]; 253 254 254 255 export default function TaskDescription({ taskId }: TaskDescriptionProps) { 256 + const { t } = useTranslation(); 255 257 const { data: task } = useGetTask(taskId); 256 258 const { mutateAsync: updateTaskDescription } = useUpdateTaskDescription(); 257 259 ··· 308 310 () => 309 311 CODE_LANGUAGE_OPTIONS.filter(({ value }) => 310 312 shikiSupportedLanguages.has(toShikiLanguage(value)), 311 - ), 312 - [shikiSupportedLanguages, toShikiLanguage], 313 + ).map(({ value, label }) => ({ 314 + value, 315 + label: t(`tasks:detail.editor.languages.${value}`, { 316 + defaultValue: label, 317 + }), 318 + })), 319 + [shikiSupportedLanguages, t, toShikiLanguage], 313 320 ); 314 321 const getOverlayPosition = useCallback( 315 322 (editorView: Editor["view"], pos: number) => { ··· 375 382 const activeEditor = targetEditor || lastEditorRef.current; 376 383 377 384 if (!activeEditor) { 378 - toast.error("File upload failed"); 385 + toast.error(t("tasks:detail.editor.upload.failed")); 379 386 return; 380 387 } 381 388 382 - const loadingToast = toast.loading("Uploading file..."); 389 + const loadingToast = toast.loading( 390 + t("tasks:detail.editor.upload.loading"), 391 + ); 383 392 384 393 try { 385 394 const uploadedAsset = await uploadTaskImage({ ··· 391 400 392 401 toast.dismiss(loadingToast); 393 402 toast.success( 394 - uploadedAsset.kind === "image" ? "Image uploaded" : "File attached", 403 + uploadedAsset.kind === "image" 404 + ? t("tasks:detail.editor.upload.imageSuccess") 405 + : t("tasks:detail.editor.upload.fileSuccess"), 395 406 ); 396 407 } catch (error) { 397 408 toast.dismiss(loadingToast); 398 409 toast.error( 399 - error instanceof Error ? error.message : "Failed to upload file", 410 + error instanceof Error 411 + ? error.message 412 + : t("tasks:detail.editor.upload.failed"), 400 413 ); 401 414 } 402 415 }, 403 - [insertUploadedAsset, taskId], 416 + [insertUploadedAsset, t, taskId], 404 417 ); 405 418 406 419 const openImagePicker = useCallback( ··· 464 477 465 478 const slashCommands = useMemo( 466 479 () => [ 467 - ...SLASH_COMMANDS, 480 + ...SLASH_COMMANDS.map((command) => ({ 481 + ...command, 482 + label: t(`tasks:detail.editor.slash.commands.${command.id}`, { 483 + defaultValue: command.label, 484 + }), 485 + })), 468 486 { 469 487 id: "file", 470 - label: "File", 488 + label: t("tasks:detail.editor.slash.commands.file"), 471 489 group: "insert" as const, 472 490 search: "file attachment image photo picture upload", 473 491 run: (activeEditor: Editor, range: SlashRange) => { ··· 476 494 }, 477 495 }, 478 496 ], 479 - [openImagePicker], 497 + [openImagePicker, t], 480 498 ); 481 499 482 500 useEffect(() => { ··· 559 577 nested: true, 560 578 }), 561 579 Placeholder.configure({ 562 - placeholder: "Write a description…", 580 + placeholder: t("tasks:detail.editor.placeholder"), 563 581 }), 564 582 Table.configure({ 565 583 resizable: true, ··· 729 747 debouncedUpdate(markdown); 730 748 }, 731 749 }, 732 - [getOverlayPosition, handleAssetFileUpload, toShikiLanguage], 750 + [getOverlayPosition, handleAssetFileUpload, t, toShikiLanguage], 733 751 ); 734 752 735 753 useEffect(() => { ··· 775 793 event.preventDefault(); 776 794 setPreviewImage({ 777 795 src: target.currentSrc || target.src, 778 - alt: target.alt || "Preview image", 796 + alt: target.alt || t("tasks:detail.editor.previewImage"), 779 797 }); 780 798 }; 781 799 ··· 785 803 return () => { 786 804 dom.removeEventListener("click", handleImagePreviewClick); 787 805 }; 788 - }, [editor]); 806 + }, [editor, t]); 789 807 790 808 useEffect(() => { 791 809 slashMenuRef.current = slashMenu; ··· 797 815 const previousUrl = editor.getAttributes("link").href as 798 816 | string 799 817 | undefined; 800 - const url = window.prompt("Enter URL", prefilledUrl || previousUrl || ""); 818 + const url = window.prompt( 819 + t("tasks:detail.editor.enterUrl"), 820 + prefilledUrl || previousUrl || "", 821 + ); 801 822 if (url === null) return; 802 823 if (url.trim() === "") { 803 824 editor.chain().focus().extendMarkRange("link").unsetLink().run(); ··· 810 831 .setLink({ href: url }) 811 832 .run(); 812 833 }, 813 - [editor], 834 + [editor, t], 814 835 ); 815 836 816 837 const filteredSlashCommands = useMemo(() => { ··· 833 854 const groupedSlashCommands = useMemo( 834 855 () => [ 835 856 { 836 - title: "Text", 857 + title: t("tasks:detail.editor.slash.groups.text"), 837 858 items: filteredSlashCommands.filter( 838 859 (command) => command.group === "text", 839 860 ), 840 861 }, 841 862 { 842 - title: "Lists", 863 + title: t("tasks:detail.editor.slash.groups.lists"), 843 864 items: filteredSlashCommands.filter( 844 865 (command) => command.group === "lists", 845 866 ), 846 867 }, 847 868 { 848 - title: "Insert", 869 + title: t("tasks:detail.editor.slash.groups.insert"), 849 870 items: filteredSlashCommands.filter( 850 871 (command) => command.group === "insert", 851 872 ), 852 873 }, 853 874 ], 854 - [filteredSlashCommands], 875 + [filteredSlashCommands, t], 855 876 ); 856 877 857 878 const runSlashCommand = useCallback( ··· 979 1000 if (!editor || !embedComposer) return; 980 1001 const url = normalizeUrl(embedComposer.url); 981 1002 if (!url) { 982 - setEmbedComposerError("Enter a valid URL"); 1003 + setEmbedComposerError(t("tasks:detail.editor.embed.errors.invalidUrl")); 983 1004 return; 984 1005 } 985 1006 ··· 1013 1034 .run(); 1014 1035 } else { 1015 1036 if (!isYouTubeUrl(url)) { 1016 - setEmbedComposerError("Only YouTube links can be embedded."); 1037 + setEmbedComposerError( 1038 + t("tasks:detail.editor.embed.errors.onlyYoutube"), 1039 + ); 1017 1040 return; 1018 1041 } 1019 1042 chain ··· 1030 1053 setEmbedComposer(null); 1031 1054 setEmbedComposerError(""); 1032 1055 }, 1033 - [editor, embedComposer], 1056 + [editor, embedComposer, t], 1034 1057 ); 1035 1058 1036 1059 useEffect(() => { ··· 1212 1235 const activeCodeLanguageLabel = 1213 1236 codeLanguages.find( 1214 1237 (language) => language.value === hoveredCodeBlock?.language, 1215 - )?.label || "Auto detect"; 1238 + )?.label || t("tasks:detail.editor.autoDetect"); 1216 1239 1217 1240 useEffect(() => { 1218 1241 return () => { ··· 1294 1317 return ( 1295 1318 <section 1296 1319 ref={editorShellRef} 1297 - aria-label="Task description editor" 1320 + aria-label={t("tasks:detail.editor.ariaLabel")} 1298 1321 className={cn( 1299 1322 "kaneo-tiptap-shell group", 1300 1323 isDragActive && "is-drag-active", ··· 1335 1358 <button 1336 1359 type="button" 1337 1360 className="kaneo-codeblock-language-trigger kaneo-codeblock-copy-trigger" 1338 - aria-label={isCodeCopied ? "Copied" : "Copy code"} 1361 + aria-label={ 1362 + isCodeCopied 1363 + ? t("tasks:detail.editor.copied") 1364 + : t("tasks:detail.editor.copyCode") 1365 + } 1339 1366 onMouseDown={(event) => { 1340 1367 event.preventDefault(); 1341 1368 }} ··· 1348 1375 ) : ( 1349 1376 <Copy className="size-3.5" /> 1350 1377 )} 1351 - <span>{isCodeCopied ? "Copied" : "Copy"}</span> 1378 + <span> 1379 + {isCodeCopied 1380 + ? t("tasks:detail.editor.copied") 1381 + : t("tasks:detail.editor.copy")} 1382 + </span> 1352 1383 </button> 1353 1384 <DropdownMenu 1354 1385 open={isCodeLanguageMenuOpen} ··· 1374 1405 onValueChange={setCodeLanguage} 1375 1406 > 1376 1407 <DropdownMenuRadioItem value="auto"> 1377 - Auto detect 1408 + {t("tasks:detail.editor.autoDetect")} 1378 1409 </DropdownMenuRadioItem> 1379 1410 <DropdownMenuSeparator /> 1380 1411 {codeLanguages.map(({ value, label }) => ( ··· 1622 1653 ); 1623 1654 }) 1624 1655 ) : ( 1625 - <div className="kaneo-tiptap-slash-empty">No commands</div> 1656 + <div className="kaneo-tiptap-slash-empty"> 1657 + {t("tasks:detail.editor.slash.empty")} 1658 + </div> 1626 1659 )} 1627 1660 </div> 1628 1661 )} ··· 1646 1679 submitEmbedComposer("embed"); 1647 1680 }} 1648 1681 > 1649 - <span>Embed video</span> 1682 + <span>{t("tasks:detail.editor.embed.choice.embedVideo")}</span> 1650 1683 <span className="kaneo-embed-choice-hint">Tab</span> 1651 1684 </button> 1652 1685 <button ··· 1658 1691 setEmbedComposerError(""); 1659 1692 }} 1660 1693 > 1661 - <span>Keep as link</span> 1694 + <span>{t("tasks:detail.editor.embed.choice.keepAsLink")}</span> 1662 1695 <span className="kaneo-embed-choice-hint">Esc</span> 1663 1696 </button> 1664 1697 </div> ··· 1679 1712 ); 1680 1713 if (embedComposerError) setEmbedComposerError(""); 1681 1714 }} 1682 - placeholder="Paste URL" 1715 + placeholder={t("tasks:detail.editor.embed.inputPlaceholder")} 1683 1716 autoFocus 1684 1717 /> 1685 1718 <div className="kaneo-embed-composer-actions"> ··· 1689 1722 variant="ghost" 1690 1723 onClick={() => submitEmbedComposer("link")} 1691 1724 > 1692 - As link 1725 + {t("tasks:detail.editor.embed.asLink")} 1693 1726 </Button> 1694 1727 <Button type="submit" size="xs"> 1695 - Embed 1728 + {t("tasks:detail.editor.embed.submit")} 1696 1729 </Button> 1697 1730 <Button 1698 1731 type="button" ··· 1703 1736 setEmbedComposerError(""); 1704 1737 }} 1705 1738 > 1706 - Cancel 1739 + {t("common:actions.cancel")} 1707 1740 </Button> 1708 1741 </div> 1709 1742 {embedComposerError && ( ··· 1729 1762 event.preventDefault(); 1730 1763 }} 1731 1764 onClick={() => openImagePicker(editor)} 1732 - aria-label="Attach file" 1765 + aria-label={t("tasks:detail.editor.attachFile")} 1733 1766 > 1734 1767 <Paperclip className="size-3.5" /> 1735 1768 </button> 1736 1769 {isDragActive && ( 1737 1770 <div className="kaneo-editor-drop-indicator"> 1738 - <span>Drop image to upload</span> 1771 + <span>{t("tasks:detail.editor.dropToUpload")}</span> 1739 1772 </div> 1740 1773 )} 1741 1774 <Dialog
+6 -3
apps/web/src/components/task/task-details-content.tsx
··· 1 1 import { useNavigate } from "@tanstack/react-router"; 2 2 import { ArrowUpRight } from "lucide-react"; 3 + import { useTranslation } from "react-i18next"; 3 4 import Activity from "@/components/activity"; 4 5 import CommentInput from "@/components/activity/comment-input"; 5 6 import { isCommentActivity } from "@/components/activity/utils"; ··· 30 31 workspaceId, 31 32 className, 32 33 }: TaskDetailsContentProps) { 34 + const { t } = useTranslation(); 33 35 const navigate = useNavigate(); 34 36 const { data: task } = useGetTask(taskId ?? ""); 35 37 const { data: project } = useGetProject({ id: projectId, workspaceId }); ··· 66 68 > 67 69 <ArrowUpRight className="size-3" /> 68 70 <span> 69 - Subtask of <span className="font-medium">{parentTask.title}</span> 71 + {t("tasks:detail.subtaskOf")}{" "} 72 + <span className="font-medium">{parentTask.title}</span> 70 73 </span> 71 74 </button> 72 75 )} ··· 100 103 </div> 101 104 <span className="text-sm font-medium text-muted-foreground h-[1px] bg-border w-full block shrink-0" /> 102 105 <div className="flex flex-col gap-4"> 103 - <h1 className="text-md font-semibold">Activity</h1> 106 + <h1 className="text-md font-semibold">{t("tasks:detail.activity")}</h1> 104 107 {user?.id && taskId && <CommentInput taskId={taskId} />} 105 108 {activities.length > 0 ? ( 106 109 <Timeline> ··· 123 126 </Timeline> 124 127 ) : ( 125 128 <p className="text-sm font-medium text-muted-foreground"> 126 - No activity found 129 + {t("tasks:detail.noActivity")} 127 130 </p> 128 131 )} 129 132 </div>
+5 -1
apps/web/src/components/task/task-details-sheet.tsx
··· 1 1 import { useNavigate } from "@tanstack/react-router"; 2 2 import { Maximize2, X } from "lucide-react"; 3 3 import { useCallback, useEffect, useState } from "react"; 4 + import { useTranslation } from "react-i18next"; 4 5 import { Button } from "@/components/ui/button"; 5 6 import { Sheet, SheetContent } from "@/components/ui/sheet"; 6 7 import { ··· 27 28 workspaceId, 28 29 onClose, 29 30 }: TaskDetailsSheetProps) { 31 + const { t } = useTranslation(); 30 32 const navigate = useNavigate(); 31 33 const [currentTaskId, setCurrentTaskId] = useState<string | undefined>( 32 34 taskId, ··· 85 87 <Maximize2 className="size-4" /> 86 88 </Button> 87 89 </TooltipTrigger> 88 - <TooltipContent>Open in full page</TooltipContent> 90 + <TooltipContent> 91 + {t("tasks:detail.openInFullPage")} 92 + </TooltipContent> 89 93 </Tooltip> 90 94 </TooltipProvider> 91 95 <Button
+5 -3
apps/web/src/components/task/task-due-date-popover.tsx
··· 1 1 import { X } from "lucide-react"; 2 2 import { useState } from "react"; 3 + import { useTranslation } from "react-i18next"; 3 4 import { Button } from "@/components/ui/button"; 4 5 import { Calendar } from "@/components/ui/calendar"; 5 6 import { ··· 20 21 task, 21 22 children, 22 23 }: TaskDueDatePopoverProps) { 24 + const { t } = useTranslation(); 23 25 const [open, setOpen] = useState(false); 24 26 const { mutateAsync: updateTaskDueDate } = useUpdateTaskDueDate(); 25 27 ··· 29 31 ...task, 30 32 dueDate: date?.toISOString() || null, 31 33 }); 32 - toast.success("Task due date updated successfully"); 34 + toast.success(t("tasks:popover.dueDate.updateSuccess")); 33 35 setOpen(false); 34 36 } catch (error) { 35 37 toast.error( 36 38 error instanceof Error 37 39 ? error.message 38 - : "Failed to update task due date", 40 + : t("tasks:popover.dueDate.updateError"), 39 41 ); 40 42 } 41 43 }; ··· 62 64 onClick={() => handleDateChange(undefined)} 63 65 > 64 66 <X className="h-4 w-4" /> 65 - Clear date 67 + {t("tasks:popover.dueDate.clear")} 66 68 </Button> 67 69 </div> 68 70 )}
+31 -19
apps/web/src/components/task/task-labels-popover.tsx
··· 1 1 import { useQueryClient } from "@tanstack/react-query"; 2 2 import { Check, Plus, Search, X } from "lucide-react"; 3 3 import { useEffect, useMemo, useRef, useState } from "react"; 4 + import { useTranslation } from "react-i18next"; 4 5 import { Input } from "@/components/ui/input"; 5 6 import { 6 7 Popover, ··· 16 17 import type Task from "@/types/task"; 17 18 18 19 const labelColors = [ 19 - { value: "gray", label: "Stone", color: "var(--color-stone-500)" }, 20 - { value: "dark-gray", label: "Slate", color: "var(--color-slate-500)" }, 21 - { value: "purple", label: "Lavender", color: "var(--color-violet-500)" }, 22 - { value: "teal", label: "Sage", color: "var(--color-emerald-600)" }, 23 - { value: "green", label: "Forest", color: "var(--color-green-600)" }, 24 - { value: "yellow", label: "Amber", color: "var(--color-amber-600)" }, 25 - { value: "orange", label: "Terracotta", color: "var(--color-orange-600)" }, 26 - { value: "pink", label: "Rose", color: "var(--color-rose-600)" }, 27 - { value: "red", label: "Crimson", color: "var(--color-red-600)" }, 20 + { value: "gray", key: "stone", color: "var(--color-stone-500)" }, 21 + { value: "dark-gray", key: "slate", color: "var(--color-slate-500)" }, 22 + { value: "purple", key: "lavender", color: "var(--color-violet-500)" }, 23 + { value: "teal", key: "sage", color: "var(--color-emerald-600)" }, 24 + { value: "green", key: "forest", color: "var(--color-green-600)" }, 25 + { value: "yellow", key: "amber", color: "var(--color-amber-600)" }, 26 + { value: "orange", key: "terracotta", color: "var(--color-orange-600)" }, 27 + { value: "pink", key: "rose", color: "var(--color-rose-600)" }, 28 + { value: "red", key: "crimson", color: "var(--color-red-600)" }, 28 29 ]; 29 30 30 31 type LabelColor = ··· 53 54 children, 54 55 triggerNativeButton = true, 55 56 }: TaskLabelsPopoverProps) { 57 + const { t } = useTranslation(); 56 58 const [open, setOpen] = useState(false); 57 59 const [step, setStep] = useState<PopoverStep>("select"); 58 60 const [searchValue, setSearchValue] = useState(""); ··· 130 132 ); 131 133 if (taskLabel?.id) { 132 134 await deleteLabel({ id: taskLabel.id }); 133 - toast.success("Label removed"); 135 + toast.success(t("tasks:popover.labels.removeSuccess")); 134 136 } 135 137 } else { 136 138 // Add label to task ··· 140 142 taskId: task.id, 141 143 workspaceId, 142 144 }); 143 - toast.success("Label added"); 145 + toast.success(t("tasks:popover.labels.addSuccess")); 144 146 } 145 147 146 148 // Invalidate all relevant queries ··· 152 154 }); 153 155 } catch (error) { 154 156 toast.error( 155 - error instanceof Error ? error.message : "Failed to update label", 157 + error instanceof Error 158 + ? error.message 159 + : t("tasks:popover.labels.updateError"), 156 160 ); 157 161 } 158 162 }; ··· 192 196 queryKey: ["labels", workspaceId], 193 197 }); 194 198 195 - toast.success("Label created and added"); 199 + toast.success(t("tasks:popover.labels.createSuccess")); 196 200 handleClose(); 197 201 } catch (error) { 198 202 toast.error( 199 - error instanceof Error ? error.message : "Failed to create label", 203 + error instanceof Error 204 + ? error.message 205 + : t("tasks:popover.labels.createError"), 200 206 ); 201 207 } 202 208 }; ··· 209 215 ref={searchInputRef} 210 216 value={searchValue} 211 217 onChange={(e) => setSearchValue(e.target.value)} 212 - placeholder="Search labels..." 218 + placeholder={t("tasks:popover.labels.searchPlaceholder")} 213 219 className="border-none p-0 h-auto focus-visible:ring-0 shadow-none !bg-transparent" 214 220 /> 215 221 </div> ··· 217 223 <div className="py-1"> 218 224 {filteredLabels.length === 0 && searchValue.length === 0 && ( 219 225 <span className="text-xs text-muted-foreground px-2"> 220 - No labels found 226 + {t("tasks:popover.labels.empty")} 221 227 </span> 222 228 )} 223 229 {filteredLabels.map((label) => ( ··· 264 270 "var(--color-neutral-400)", 265 271 }} 266 272 /> 267 - <span className="truncate">Create "{searchValue}"</span> 273 + <span className="truncate"> 274 + {t("tasks:popover.labels.create", { name: searchValue })} 275 + </span> 268 276 </button> 269 277 )} 270 278 </div> ··· 274 282 const renderColorStep = () => ( 275 283 <div className="w-auto"> 276 284 <div className="flex items-center justify-between p-2 border-b border-border"> 277 - <span className="text-xs font-medium">Choose color</span> 285 + <span className="text-xs font-medium"> 286 + {t("tasks:popover.labels.chooseColor")} 287 + </span> 278 288 <button 279 289 type="button" 280 290 onClick={() => setStep("select")} ··· 299 309 className="w-2 h-2 rounded-full flex-shrink-0" 300 310 style={{ backgroundColor: color.color }} 301 311 /> 302 - <span className="truncate">{color.label}</span> 312 + <span className="truncate"> 313 + {t(`tasks:popover.labels.colors.${color.key}`)} 314 + </span> 303 315 {selectedColor === color.value && ( 304 316 <Check className="w-3 h-3 ml-auto" /> 305 317 )}
+13 -8
apps/web/src/components/task/task-priority-popover.tsx
··· 1 1 import { Check } from "lucide-react"; 2 2 import { useCallback, useMemo, useState } from "react"; 3 + import { useTranslation } from "react-i18next"; 3 4 import { Button } from "@/components/ui/button"; 4 5 import { 5 6 Popover, ··· 9 10 import { ShortcutNumber } from "@/components/ui/shortcut-number"; 10 11 import { useUpdateTaskPriority } from "@/hooks/mutations/task/use-update-task-status-priority"; 11 12 import { useNumberedShortcuts } from "@/hooks/use-numbered-shortcuts"; 13 + import { getPriorityLabel } from "@/lib/i18n/domain"; 12 14 import { getPriorityIcon } from "@/lib/priority"; 13 15 import { toast } from "@/lib/toast"; 14 16 import type Task from "@/types/task"; ··· 19 21 }; 20 22 21 23 const priorityOptions = [ 22 - { value: "no-priority", label: "No Priority" }, 23 - { value: "low", label: "Low" }, 24 - { value: "medium", label: "Medium" }, 25 - { value: "high", label: "High" }, 26 - { value: "urgent", label: "Urgent" }, 24 + { value: "no-priority" }, 25 + { value: "low" }, 26 + { value: "medium" }, 27 + { value: "high" }, 28 + { value: "urgent" }, 27 29 ]; 28 30 29 31 export default function TaskPriorityPopover({ 30 32 task, 31 33 children, 32 34 }: TaskPriorityPopoverProps) { 35 + const { t } = useTranslation(); 33 36 const [open, setOpen] = useState(false); 34 37 const { mutateAsync: updateTaskPriority } = useUpdateTaskPriority(); 35 38 ··· 45 48 toast.error( 46 49 error instanceof Error 47 50 ? error.message 48 - : "Failed to update task priority", 51 + : t("tasks:popover.priority.updateError"), 49 52 ); 50 53 } 51 54 }, 52 - [task, updateTaskPriority], 55 + [t, task, updateTaskPriority], 53 56 ); 54 57 55 58 const shortcutOptions = useMemo( ··· 76 79 onClick={() => handlePriorityChange(priority.value)} 77 80 > 78 81 {getPriorityIcon(priority.value)} 79 - <span className="text-sm">{priority.label}</span> 82 + <span className="text-sm"> 83 + {getPriorityLabel(priority.value)} 84 + </span> 80 85 {task.priority === priority.value ? ( 81 86 <Check className="ml-auto h-4 w-4" /> 82 87 ) : (
+40 -44
apps/web/src/components/task/task-properties-sidebar.tsx
··· 1 - import { format } from "date-fns"; 2 1 import { 3 2 Calendar, 4 3 CalendarClock, ··· 8 7 GitBranch, 9 8 Plus, 10 9 } from "lucide-react"; 10 + import { useTranslation } from "react-i18next"; 11 11 import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 12 12 import { Badge } from "@/components/ui/badge"; 13 13 import { Button } from "@/components/ui/button"; ··· 26 26 import { useGetActiveWorkspaceUsers } from "@/hooks/queries/workspace-users/use-get-active-workspace-users"; 27 27 import { getColumnIcon } from "@/lib/column"; 28 28 import { dueDateStatusColors, getDueDateStatus } from "@/lib/due-date-status"; 29 + import { formatDateShort } from "@/lib/format"; 30 + import { getPriorityLabel, getStatusLabel } from "@/lib/i18n/domain"; 29 31 import { getPriorityIcon } from "@/lib/priority"; 30 32 import { toast } from "@/lib/toast"; 31 33 import TaskAssigneePopover from "./task-assignee-popover"; ··· 57 59 .replace("{title}", slugify(taskTitle)); 58 60 } 59 61 60 - function toNormalCase(str: string | undefined) { 61 - if (!str) return str; 62 - return str 63 - .replace(/[-_]/g, " ") 64 - .split(" ") 65 - .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) 66 - .join(" "); 67 - } 68 - 69 62 type TaskPropertiesSidebarProps = { 70 63 taskId: string | undefined; 71 64 projectId: string; ··· 81 74 className, 82 75 compact = false, 83 76 }: TaskPropertiesSidebarProps) { 77 + const { t } = useTranslation(); 84 78 const { data: task } = useGetTask(taskId ?? ""); 85 79 const { data: project } = useGetProject({ id: projectId, workspaceId }); 86 80 const { data: workspaceUsers } = useGetActiveWorkspaceUsers(workspaceId); ··· 99 93 navigator.clipboard.writeText( 100 94 `${window.location.origin}/workspace/${workspaceId}/project/${projectId}/task/${taskId}`, 101 95 ); 102 - toast.message("Task link copied to clipboard"); 96 + toast.message(t("tasks:properties.copyTaskLink")); 103 97 }; 104 98 105 99 const handleCopyTaskBranch = () => { ··· 110 104 task?.title, 111 105 ); 112 106 navigator.clipboard.writeText(branchName); 113 - toast.message("Task branch copied to clipboard"); 107 + toast.message(t("tasks:properties.copyTaskBranch")); 114 108 }; 115 109 116 110 return ( ··· 134 128 <TooltipContent> 135 129 <KbdSequence 136 130 keys={["Ctrl", "Shift", "C"]} 137 - description="Copy task link" 131 + description={t("tasks:properties.copyTaskLink")} 138 132 separator="" 139 133 /> 140 134 </TooltipContent> ··· 156 150 <TooltipContent> 157 151 <KbdSequence 158 152 keys={["Ctrl", "Shift", "G"]} 159 - description="Copy task branch" 153 + description={t("tasks:properties.copyTaskBranch")} 160 154 separator="" 161 155 /> 162 156 </TooltipContent> ··· 174 168 > 175 169 {getColumnIcon(task.status ?? "", false)} 176 170 <span className="text-xs font-semibold truncate"> 177 - {toNormalCase(task.status)} 171 + {getStatusLabel(task.status ?? "")} 178 172 </span> 179 173 </Button> 180 174 </TaskStatusPopover> ··· 188 182 > 189 183 {getPriorityIcon(task.priority ?? "")} 190 184 <span className="text-xs font-semibold truncate"> 191 - {toNormalCase(task.priority ?? "")} 185 + {getPriorityLabel(task.priority ?? "")} 192 186 </span> 193 187 </Button> 194 188 </TaskPriorityPopover> ··· 213 207 ) : ( 214 208 <div 215 209 className="w-[16px] h-[16px] rounded-full bg-muted border border-border flex items-center justify-center flex-shrink-0" 216 - title="Unassigned" 210 + title={t("tasks:popover.assignee.unassigned")} 217 211 > 218 212 <span className="text-[8px] font-medium">?</span> 219 213 </div> 220 214 )} 221 215 <span className="text-xs font-semibold truncate max-w-[100px]"> 222 - {assignee?.user?.name || task.assigneeName || "Unassigned"} 216 + {assignee?.user?.name || 217 + task.assigneeName || 218 + t("tasks:popover.assignee.unassigned")} 223 219 </span> 224 220 </Button> 225 221 </TaskAssigneePopover> ··· 236 232 className={`text-xs font-semibold ${task.startDate ? "" : "text-muted-foreground"}`} 237 233 > 238 234 {task.startDate 239 - ? format(new Date(task.startDate), "MMM d") 240 - : "Start"} 235 + ? formatDateShort(task.startDate) 236 + : t("tasks:properties.start")} 241 237 </span> 242 238 </Button> 243 239 </TaskStartDatePopover> ··· 268 264 /> 269 265 )} 270 266 <span className="text-xs font-semibold"> 271 - {format(new Date(task.dueDate), "MMM d")} 267 + {formatDateShort(task.dueDate)} 272 268 </span> 273 269 </> 274 270 ) : ( 275 271 <> 276 272 <Calendar className="w-3.5 h-3.5 text-muted-foreground" /> 277 273 <span className="text-xs font-semibold text-muted-foreground"> 278 - No date 274 + {t("tasks:properties.noDate")} 279 275 </span> 280 276 </> 281 277 )} ··· 306 302 <TooltipContent> 307 303 <KbdSequence 308 304 keys={["Ctrl", "Shift", "C"]} 309 - description="Copy task link" 305 + description={t("tasks:properties.copyTaskLink")} 310 306 separator="" 311 307 /> 312 308 </TooltipContent> ··· 328 324 <TooltipContent> 329 325 <KbdSequence 330 326 keys={["Ctrl", "Shift", "G"]} 331 - description="Copy task branch" 327 + description={t("tasks:properties.copyTaskBranch")} 332 328 separator="" 333 329 /> 334 330 </TooltipContent> ··· 346 342 > 347 343 {getColumnIcon(task.status ?? "", false)} 348 344 <span className="text-xs font-semibold truncate"> 349 - {toNormalCase(task.status)} 345 + {getStatusLabel(task.status ?? "")} 350 346 </span> 351 347 </Button> 352 348 </TaskStatusPopover> ··· 360 356 > 361 357 {getPriorityIcon(task.priority ?? "")} 362 358 <span className="text-xs font-semibold truncate"> 363 - {toNormalCase(task.priority ?? "")} 359 + {getPriorityLabel(task.priority ?? "")} 364 360 </span> 365 361 </Button> 366 362 </TaskPriorityPopover> ··· 385 381 ) : ( 386 382 <div 387 383 className="w-[16px] h-[16px] rounded-full bg-muted border border-border flex items-center justify-center shrink-0" 388 - title="Unassigned" 384 + title={t("tasks:popover.assignee.unassigned")} 389 385 > 390 386 <span className="text-[8px] font-medium">?</span> 391 387 </div> ··· 393 389 <span className="text-xs font-semibold truncate max-w-[100px]"> 394 390 {assignee?.user?.name || 395 391 task.assigneeName || 396 - "Unassigned"} 392 + t("tasks:popover.assignee.unassigned")} 397 393 </span> 398 394 </Button> 399 395 </TaskAssigneePopover> ··· 410 406 className={`text-xs font-semibold ${task.startDate ? "" : "text-muted-foreground"}`} 411 407 > 412 408 {task.startDate 413 - ? format(new Date(task.startDate), "MMM d") 414 - : "Start"} 409 + ? formatDateShort(task.startDate) 410 + : t("tasks:properties.start")} 415 411 </span> 416 412 </Button> 417 413 </TaskStartDatePopover> ··· 442 438 /> 443 439 )} 444 440 <span className="text-xs font-semibold"> 445 - {format(new Date(task.dueDate), "MMM d")} 441 + {formatDateShort(task.dueDate)} 446 442 </span> 447 443 </> 448 444 ) : ( 449 445 <> 450 446 <Calendar className="w-3.5 h-3.5 text-muted-foreground" /> 451 447 <span className="text-xs font-semibold text-muted-foreground"> 452 - No date 448 + {t("tasks:properties.noDate")} 453 449 </span> 454 450 </> 455 451 )} ··· 463 459 <div className="hidden lg:block"> 464 460 <div className="flex items-center justify-between px-3 py-2 border-b border-border lg:border-none"> 465 461 <p className="text-sm font-medium text-foreground/70 flex-1"> 466 - Properties 462 + {t("tasks:properties.title")} 467 463 </p> 468 464 <div className="flex"> 469 465 <TooltipProvider> ··· 481 477 <TooltipContent> 482 478 <KbdSequence 483 479 keys={["Ctrl", "Shift", "C"]} 484 - description="Copy task link" 480 + description={t("tasks:properties.copyTaskLink")} 485 481 separator="" 486 482 /> 487 483 </TooltipContent> ··· 503 499 <TooltipContent> 504 500 <KbdSequence 505 501 keys={["Ctrl", "Shift", "G"]} 506 - description="Copy task branch" 502 + description={t("tasks:properties.copyTaskBranch")} 507 503 separator="" 508 504 /> 509 505 </TooltipContent> ··· 522 518 > 523 519 {getColumnIcon(task.status ?? "", false)} 524 520 <span className="text-xs font-semibold truncate"> 525 - {toNormalCase(task.status)} 521 + {getStatusLabel(task.status ?? "")} 526 522 </span> 527 523 </Button> 528 524 </TaskStatusPopover> ··· 536 532 > 537 533 {getPriorityIcon(task.priority ?? "")} 538 534 <span className="text-xs font-semibold truncate"> 539 - {toNormalCase(task.priority ?? "")} 535 + {getPriorityLabel(task.priority ?? "")} 540 536 </span> 541 537 </Button> 542 538 </TaskPriorityPopover> ··· 561 557 ) : ( 562 558 <div 563 559 className="w-[16px] h-[16px] rounded-full bg-muted border border-border flex items-center justify-center shrink-0" 564 - title="Unassigned" 560 + title={t("tasks:popover.assignee.unassigned")} 565 561 > 566 562 <span className="text-[8px] font-medium">?</span> 567 563 </div> ··· 569 565 <span className="text-xs font-semibold truncate max-w-[100px]"> 570 566 {assignee?.user?.name || 571 567 task.assigneeName || 572 - "Unassigned"} 568 + t("tasks:popover.assignee.unassigned")} 573 569 </span> 574 570 </Button> 575 571 </TaskAssigneePopover> ··· 586 582 className={`text-xs font-semibold ${task.startDate ? "" : "text-muted-foreground"}`} 587 583 > 588 584 {task.startDate 589 - ? format(new Date(task.startDate), "MMM d") 590 - : "Start date"} 585 + ? formatDateShort(task.startDate) 586 + : t("tasks:properties.startDate")} 591 587 </span> 592 588 </Button> 593 589 </TaskStartDatePopover> ··· 618 614 /> 619 615 )} 620 616 <span className="text-xs font-semibold"> 621 - {format(new Date(task.dueDate), "MMM d")} 617 + {formatDateShort(task.dueDate)} 622 618 </span> 623 619 </> 624 620 ) : ( 625 621 <> 626 622 <Calendar className="w-3.5 h-3.5 text-muted-foreground" /> 627 623 <span className="text-xs font-semibold text-muted-foreground"> 628 - No date 624 + {t("tasks:properties.noDate")} 629 625 </span> 630 626 </> 631 627 )} ··· 640 636 <div className="hidden lg:flex px-3 flex-col gap-3 p-2"> 641 637 <div className="flex flex-col gap-1"> 642 638 <span className="text-xs font-medium text-foreground/70 px-2"> 643 - Labels 639 + {t("tasks:properties.labels")} 644 640 </span> 645 641 <div className="flex flex-wrap items-center gap-1.5 px-2"> 646 642 {task &&
+16 -19
apps/web/src/components/task/task-relations.tsx
··· 8 8 X, 9 9 } from "lucide-react"; 10 10 import { Fragment, useEffect, useMemo, useState } from "react"; 11 + import { useTranslation } from "react-i18next"; 11 12 import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 12 13 import { Button } from "@/components/ui/button"; 13 14 import { ··· 56 57 workspaceId: string; 57 58 }; 58 59 59 - const relationTypeLabels: Record<string, string> = { 60 - blocks: "blocks", 61 - related: "relates to", 62 - }; 63 - 64 60 type TaskItem = { 65 61 id: string; 66 62 title: string; ··· 79 75 projectId, 80 76 workspaceId, 81 77 }: TaskRelationsProps) { 78 + const { t } = useTranslation(); 82 79 const navigate = useNavigate(); 83 80 const [isOpen, setIsOpen] = useState(true); 84 81 const [commandOpen, setCommandOpen] = useState(false); ··· 168 165 return [ 169 166 { 170 167 value: "tasks", 171 - label: "Tasks in project", 168 + label: t("tasks:relations.tasksInProject"), 172 169 items: filteredTasks, 173 170 }, 174 171 ]; 175 - }, [filteredTasks]); 172 + }, [filteredTasks, t]); 176 173 177 174 const handleLinkTask = async (targetTaskId: string) => { 178 175 try { ··· 184 181 setCommandOpen(false); 185 182 setSearchQuery(""); 186 183 } catch { 187 - toast.error("Failed to link task"); 184 + toast.error(t("tasks:relations.linkError")); 188 185 } 189 186 }; 190 187 ··· 239 236 ) : ( 240 237 <ChevronRight className="size-4" /> 241 238 )} 242 - <span>Relations</span> 239 + <span>{t("tasks:relations.title")}</span> 243 240 </button> 244 241 </CollapsibleTrigger> 245 242 {totalCount > 0 && ( ··· 262 259 {Object.entries(groupedRelations).map(([type, items]) => ( 263 260 <div key={type} className="mt-1.5"> 264 261 <span className="text-[11px] text-muted-foreground/70 px-2"> 265 - {relationTypeLabels[type] || type} 262 + {t(`tasks:relations.types.${type}`, { defaultValue: type })} 266 263 </span> 267 264 <div className="flex flex-col mt-0.5"> 268 265 {items.map((item) => { ··· 320 317 ) : ( 321 318 <div 322 319 className="flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-border/70" 323 - title="Unassigned" 320 + title={t("tasks:popover.assignee.unassigned")} 324 321 > 325 322 <span className="text-[9px] font-medium text-muted-foreground"> 326 323 ? ··· 336 333 <ContextMenuItem 337 334 onClick={() => handleNavigateToTask(item.task.id)} 338 335 > 339 - <span>Open task</span> 336 + <span>{t("tasks:relations.openTask")}</span> 340 337 </ContextMenuItem> 341 338 <ContextMenuSeparator /> 342 339 <ContextMenuItem 343 340 className="text-destructive" 344 341 onClick={() => handleRemoveRelation(item.id)} 345 342 > 346 - <span>Remove relation</span> 343 + <span>{t("tasks:relations.removeRelation")}</span> 347 344 </ContextMenuItem> 348 345 </ContextMenuContent> 349 346 </ContextMenu> ··· 355 352 356 353 {totalCount === 0 && ( 357 354 <p className="text-xs text-muted-foreground px-2 py-1"> 358 - No related tasks 355 + {t("tasks:relations.empty")} 359 356 </p> 360 357 )} 361 358 </CollapsibleContent> ··· 365 362 <CommandDialogPopup> 366 363 <Command items={commandGroups}> 367 364 <CommandInput 368 - placeholder="Search tasks to link..." 365 + placeholder={t("tasks:relations.searchPlaceholder")} 369 366 value={searchQuery} 370 367 onChange={(e) => setSearchQuery(e.target.value)} 371 368 /> ··· 374 371 <div className="text-center py-6"> 375 372 <Search className="h-8 w-8 mx-auto mb-2 text-muted-foreground" /> 376 373 <p className="text-sm text-muted-foreground"> 377 - No tasks found 374 + {t("tasks:relations.noTasksFound")} 378 375 </p> 379 376 </div> 380 377 </CommandEmpty> ··· 417 414 onClick={() => setSelectedRelationType("related")} 418 415 > 419 416 <Link2 className="size-3" /> 420 - Related 417 + {t("tasks:relations.related")} 421 418 </button> 422 419 <button 423 420 type="button" ··· 425 422 onClick={() => setSelectedRelationType("blocks")} 426 423 > 427 424 <X className="size-3" /> 428 - Blocks 425 + {t("tasks:relations.blocks")} 429 426 </button> 430 427 </div> 431 428 <span className="text-muted-foreground/60"> 432 - Select a task to link 429 + {t("tasks:relations.selectTask")} 433 430 </span> 434 431 </CommandFooter> 435 432 </Command>
+5 -3
apps/web/src/components/task/task-start-date-popover.tsx
··· 1 1 import { X } from "lucide-react"; 2 2 import { useState } from "react"; 3 + import { useTranslation } from "react-i18next"; 3 4 import { Button } from "@/components/ui/button"; 4 5 import { Calendar } from "@/components/ui/calendar"; 5 6 import { ··· 20 21 task, 21 22 children, 22 23 }: TaskStartDatePopoverProps) { 24 + const { t } = useTranslation(); 23 25 const [open, setOpen] = useState(false); 24 26 const { mutateAsync: updateTask } = useUpdateTask(); 25 27 ··· 29 31 ...task, 30 32 startDate: date?.toISOString() || null, 31 33 }); 32 - toast.success("Task start date updated successfully"); 34 + toast.success(t("tasks:popover.startDate.updateSuccess")); 33 35 setOpen(false); 34 36 } catch (error) { 35 37 toast.error( 36 38 error instanceof Error 37 39 ? error.message 38 - : "Failed to update task start date", 40 + : t("tasks:popover.startDate.updateError"), 39 41 ); 40 42 } 41 43 }; ··· 62 64 onClick={() => handleDateChange(undefined)} 63 65 > 64 66 <X className="h-4 w-4" /> 65 - Clear start date 67 + {t("tasks:popover.startDate.clear")} 66 68 </Button> 67 69 </div> 68 70 )}
+8 -3
apps/web/src/components/task/task-status-popover.tsx
··· 1 1 import { Check } from "lucide-react"; 2 2 import { useCallback, useMemo, useState } from "react"; 3 + import { useTranslation } from "react-i18next"; 3 4 import { Button } from "@/components/ui/button"; 4 5 import { 5 6 Popover, ··· 10 11 import { useUpdateTaskStatus } from "@/hooks/mutations/task/use-update-task-status"; 11 12 import { useNumberedShortcuts } from "@/hooks/use-numbered-shortcuts"; 12 13 import { getColumnIcon } from "@/lib/column"; 14 + import { getStatusLabel } from "@/lib/i18n/domain"; 13 15 import { toast } from "@/lib/toast"; 14 16 import useProjectStore from "@/store/project"; 15 17 import type Task from "@/types/task"; ··· 23 25 task, 24 26 children, 25 27 }: TaskStatusPopoverProps) { 28 + const { t } = useTranslation(); 26 29 const [open, setOpen] = useState(false); 27 30 const { project } = useProjectStore(); 28 31 const statusOptions = ··· 45 48 toast.error( 46 49 error instanceof Error 47 50 ? error.message 48 - : "Failed to update task status", 51 + : t("tasks:popover.status.updateError"), 49 52 ); 50 53 } 51 54 }, 52 - [task, updateTaskStatus], 55 + [t, task, updateTaskStatus], 53 56 ); 54 57 55 58 const shortcutOptions = useMemo( ··· 76 79 onClick={() => handleStatusChange(status.value)} 77 80 > 78 81 {getColumnIcon(status.value, status.isFinal)} 79 - <span className="text-sm">{status.label}</span> 82 + <span className="text-sm"> 83 + {getStatusLabel(status.value) || status.label} 84 + </span> 80 85 {task.status === status.value ? ( 81 86 <Check className="ml-auto h-4 w-4" /> 82 87 ) : (
+18 -13
apps/web/src/components/task/task-subtasks.tsx
··· 2 2 import { AnimatePresence } from "framer-motion"; 3 3 import { ChevronDown, ChevronRight, Plus } from "lucide-react"; 4 4 import { useCallback, useEffect, useRef, useState } from "react"; 5 + import { useTranslation } from "react-i18next"; 5 6 import { 6 7 AlertDialog, 7 8 AlertDialogClose, ··· 41 42 projectId, 42 43 workspaceId, 43 44 }: TaskSubtasksProps) { 45 + const { t } = useTranslation(); 44 46 const navigate = useNavigate(); 45 47 const [isOpen, setIsOpen] = useState(true); 46 48 const [isAdding, setIsAdding] = useState(false); ··· 239 241 setNewTitle(""); 240 242 setIsAdding(false); 241 243 } catch { 242 - toast.error("Failed to create subtask"); 244 + toast.error(t("tasks:subtasks.createError")); 243 245 } 244 246 }; 245 247 ··· 254 256 next.delete(deleteTaskId); 255 257 return next; 256 258 }); 257 - toast.success("Task deleted successfully"); 259 + toast.success(t("tasks:subtasks.deleteSuccess")); 258 260 } catch (error) { 259 261 toast.error( 260 - error instanceof Error ? error.message : "Failed to delete task", 262 + error instanceof Error 263 + ? error.message 264 + : t("tasks:subtasks.deleteError"), 261 265 ); 262 266 } finally { 263 267 setDeleteTaskId(null); ··· 279 283 ) : ( 280 284 <ChevronRight className="size-4" /> 281 285 )} 282 - <span>Sub-tasks</span> 286 + <span>{t("tasks:subtasks.title")}</span> 283 287 </button> 284 288 </CollapsibleTrigger> 285 289 {totalCount > 0 && ( ··· 353 357 <div className="flex items-center gap-2 mt-2"> 354 358 <Input 355 359 size="sm" 356 - placeholder="Subtask title..." 360 + placeholder={t("tasks:subtasks.inputPlaceholder")} 357 361 value={newTitle} 358 362 onChange={(e: React.ChangeEvent<HTMLInputElement>) => 359 363 setNewTitle(e.target.value) ··· 372 376 onClick={handleAddSubtask} 373 377 disabled={!newTitle.trim() || createTask.isPending} 374 378 > 375 - Add 379 + {t("tasks:subtasks.addAction")} 376 380 </Button> 377 381 <Button 378 382 variant="ghost" ··· 382 386 setNewTitle(""); 383 387 }} 384 388 > 385 - Cancel 389 + {t("common:actions.cancel")} 386 390 </Button> 387 391 </div> 388 392 )} 389 393 390 394 {!isAdding && totalCount === 0 && ( 391 395 <p className="text-xs text-muted-foreground px-2 py-1"> 392 - No subtasks yet 396 + {t("tasks:subtasks.empty")} 393 397 </p> 394 398 )} 395 399 </CollapsibleContent> ··· 401 405 > 402 406 <AlertDialogContent> 403 407 <AlertDialogHeader> 404 - <AlertDialogTitle>Delete Task?</AlertDialogTitle> 408 + <AlertDialogTitle> 409 + {t("tasks:subtasks.deleteDialogTitle")} 410 + </AlertDialogTitle> 405 411 <AlertDialogDescription> 406 - This will permanently remove the task and all its data. You can't 407 - undo this action. 412 + {t("tasks:subtasks.deleteDialogDescription")} 408 413 </AlertDialogDescription> 409 414 </AlertDialogHeader> 410 415 <AlertDialogFooter> 411 416 <AlertDialogClose> 412 417 <Button variant="outline" size="sm"> 413 - Cancel 418 + {t("common:actions.cancel")} 414 419 </Button> 415 420 </AlertDialogClose> 416 421 <AlertDialogClose onClick={handleDeleteTask}> 417 422 <Button variant="destructive" size="sm"> 418 - Delete Task 423 + {t("tasks:subtasks.deleteAction")} 419 424 </Button> 420 425 </AlertDialogClose> 421 426 </AlertDialogFooter>
+3 -1
apps/web/src/components/task/task-title.tsx
··· 1 1 import { useCallback, useEffect, useRef } from "react"; 2 2 import { useForm } from "react-hook-form"; 3 + import { useTranslation } from "react-i18next"; 3 4 4 5 import { Form, FormField } from "@/components/ui/form"; 5 6 import { useUpdateTaskTitle } from "@/hooks/mutations/task/use-update-task-title"; ··· 11 12 }; 12 13 13 14 export default function TaskTitle({ taskId }: TaskTitleProps) { 15 + const { t } = useTranslation(); 14 16 const { data: task } = useGetTask(taskId); 15 17 const { mutateAsync: updateTaskTitle } = useUpdateTaskTitle(); 16 18 const isInitializedRef = useRef(false); ··· 78 80 <input 79 81 {...field} 80 82 type="text" 81 - placeholder="Click to add a title" 83 + placeholder={t("tasks:detail.titlePlaceholder")} 82 84 className="block h-auto w-full appearance-none border-0 bg-transparent p-0 font-heading text-[2rem] leading-[1.15] font-semibold tracking-[-0.02em] text-foreground outline-none placeholder:text-foreground/45" 83 85 onChange={(e) => { 84 86 field.onChange(e);
+11 -7
apps/web/src/components/team/invite-team-member-modal.tsx
··· 2 2 import { useQueryClient } from "@tanstack/react-query"; 3 3 import { X } from "lucide-react"; 4 4 import { useForm } from "react-hook-form"; 5 + import { useTranslation } from "react-i18next"; 5 6 import { z } from "zod/v4"; 6 7 import useInviteWorkspaceUser from "@/hooks/mutations/workspace-user/use-invite-workspace-user"; 7 8 import { toast } from "@/lib/toast"; ··· 30 31 type TeamMemberFormValues = z.infer<typeof teamMemberSchema>; 31 32 32 33 function InviteTeamMemberModal({ open, onClose }: Props) { 34 + const { t } = useTranslation(); 33 35 const { mutateAsync } = useInviteWorkspaceUser(); 34 36 const queryClient = useQueryClient(); 35 37 const { workspaceId } = Route.useParams(); ··· 48 50 queryKey: ["workspace-users", workspaceId], 49 51 }); 50 52 51 - toast.success("Invitation sent successfully"); 53 + toast.success(t("team:inviteModal.success")); 52 54 53 55 resetInviteTeamMember(); 54 56 onClose(); 55 57 } catch (error) { 56 58 toast.error( 57 - error instanceof Error ? error.message : "Failed to invite team member", 59 + error instanceof Error ? error.message : t("team:inviteModal.error"), 58 60 ); 59 61 } 60 62 }; ··· 77 79 <div className="bg-card rounded-lg shadow-xl"> 78 80 <div className="flex items-center justify-between p-4 border-b border-border"> 79 81 <DialogTitle className="text-lg font-semibold text-foreground"> 80 - Invite Team Member 82 + {t("team:inviteModal.title")} 81 83 </DialogTitle> 82 84 <DialogClose 83 85 className="text-muted-foreground hover:text-foreground" ··· 97 99 render={({ field }) => ( 98 100 <FormItem> 99 101 <FormLabel className="block text-sm font-medium text-foreground mb-1"> 100 - Email 102 + {t("team:inviteModal.emailLabel")} 101 103 </FormLabel> 102 104 <FormControl> 103 105 <Input 104 106 {...field} 105 - placeholder="colleague@company.com" 107 + placeholder={t("team:inviteModal.emailPlaceholder")} 106 108 className="bg-card/50" 107 109 autoFocus 108 110 /> ··· 123 125 /> 124 126 } 125 127 > 126 - Cancel 128 + {t("common:actions.cancel")} 127 129 </DialogClose> 128 - <Button type="submit">Send Invitation</Button> 130 + <Button type="submit"> 131 + {t("team:inviteModal.sendInvitation")} 132 + </Button> 129 133 </div> 130 134 </form> 131 135 </Form>
+45 -45
apps/web/src/components/team/members-table.tsx
··· 1 1 import { Trash2 } from "lucide-react"; 2 2 import { useState } from "react"; 3 + import { useTranslation } from "react-i18next"; 3 4 import useCancelInvitation from "@/hooks/mutations/workspace-user/use-cancel-invitation"; 4 5 import useDeleteWorkspaceUser from "@/hooks/mutations/workspace-user/use-delete-workspace-user"; 6 + import { formatDateMedium } from "@/lib/format"; 5 7 import { toast } from "@/lib/toast"; 6 8 import { Route } from "@/routes/_layout/_authenticated/dashboard/workspace/$workspaceId/members"; 7 9 import type { ··· 41 43 ); 42 44 const [invitationToCancel, setInvitationToCancel] = 43 45 useState<WorkspaceUserInvitation | null>(null); 46 + const { t } = useTranslation(); 44 47 const { user: currentUser } = useAuth(); 45 48 const { workspaceId } = Route.useParams(); 46 49 const { mutateAsync: deleteWorkspaceUser, isPending } = ··· 56 59 workspaceId, 57 60 userId: memberToDelete.user.email, 58 61 }); 59 - toast.success("Team member removed successfully"); 62 + toast.success(t("team:membersTable.removeSuccess")); 60 63 } catch (error) { 61 64 toast.error( 62 - error instanceof Error ? error.message : "Failed to remove team member", 65 + error instanceof Error 66 + ? error.message 67 + : t("team:membersTable.removeError"), 63 68 ); 64 69 } finally { 65 70 setMemberToDelete(null); ··· 74 79 invitationId: invitationToCancel.id, 75 80 workspaceId, 76 81 }); 77 - toast.success("Invitation cancelled successfully"); 82 + toast.success(t("team:membersTable.cancelInviteSuccess")); 78 83 } catch (error) { 79 84 toast.error( 80 - error instanceof Error ? error.message : "Failed to cancel invitation", 85 + error instanceof Error 86 + ? error.message 87 + : t("team:membersTable.cancelInviteError"), 81 88 ); 82 89 } finally { 83 90 setInvitationToCancel(null); ··· 92 99 <span className="text-2xl">👥</span> 93 100 </div> 94 101 <div className="space-y-2"> 95 - <h3 className="text-xl font-semibold">No team members yet</h3> 102 + <h3 className="text-xl font-semibold"> 103 + {t("team:membersTable.emptyTitle")} 104 + </h3> 96 105 <p className="text-muted-foreground"> 97 - Invite your first team member to get started. 106 + {t("team:membersTable.emptyDescription")} 98 107 </p> 99 108 </div> 100 109 </div> ··· 108 117 <TableHeader> 109 118 <TableRow> 110 119 <TableHead className="text-muted-foreground text-xs w-2/3 pl-6"> 111 - Name 120 + {t("team:membersTable.columns.name")} 112 121 </TableHead> 113 122 <TableHead className="text-muted-foreground text-xs"> 114 - Role 123 + {t("team:membersTable.columns.role")} 115 124 </TableHead> 116 125 <TableHead className="text-muted-foreground text-xs"> 117 - Joined 126 + {t("team:membersTable.columns.joined")} 118 127 </TableHead> 119 128 <TableHead className="text-muted-foreground text-xs pr-6 text-right"> 120 - Actions 129 + {t("team:membersTable.columns.actions")} 121 130 </TableHead> 122 131 </TableRow> 123 132 </TableHeader> ··· 142 151 </TableCell> 143 152 <TableCell className="py-3"> 144 153 <span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-muted text-muted-foreground"> 145 - {invitation.role.charAt(0).toUpperCase() + 146 - invitation.role.slice(1).toLowerCase()}{" "} 147 - (Pending) 154 + {t("team:membersTable.memberRolePending", { 155 + role: 156 + invitation.role.charAt(0).toUpperCase() + 157 + invitation.role.slice(1).toLowerCase(), 158 + })} 148 159 </span> 149 160 </TableCell> 150 161 <TableCell className="py-3"> 151 162 <span className="text-sm text-muted-foreground"> 152 163 {invitation.expiresAt && 153 - new Date(invitation.expiresAt).toLocaleDateString( 154 - "en-US", 155 - { 156 - month: "short", 157 - day: "numeric", 158 - year: "numeric", 159 - }, 160 - )} 164 + formatDateMedium(invitation.expiresAt)} 161 165 </span> 162 166 </TableCell> 163 167 <TableCell className="py-3 pr-6 text-right"> ··· 169 173 setInvitationToCancel(invitation); 170 174 }} 171 175 className="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10" 172 - aria-label="Cancel invitation" 176 + aria-label={t("team:membersTable.ariaCancelInvitation")} 173 177 > 174 178 <Trash2 className="h-4 w-4" /> 175 179 </Button> ··· 202 206 203 207 <TableCell className="py-3"> 204 208 <span className="text-sm text-muted-foreground"> 205 - {member.createdAt && 206 - new Date(member.createdAt).toLocaleDateString("en-US", { 207 - month: "short", 208 - day: "numeric", 209 - year: "numeric", 210 - })} 209 + {member.createdAt && formatDateMedium(member.createdAt)} 211 210 </span> 212 211 </TableCell> 213 212 <TableCell className="py-3 pr-6 text-right"> ··· 220 219 setMemberToDelete(member); 221 220 }} 222 221 className="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10" 223 - aria-label="Remove member" 222 + aria-label={t("team:membersTable.ariaRemoveMember")} 224 223 > 225 224 <Trash2 className="h-4 w-4" /> 226 225 </Button> ··· 237 236 > 238 237 <AlertDialogContent> 239 238 <AlertDialogHeader> 240 - <AlertDialogTitle>Remove Team Member?</AlertDialogTitle> 239 + <AlertDialogTitle> 240 + {t("team:membersTable.removeDialogTitle")} 241 + </AlertDialogTitle> 241 242 <AlertDialogDescription> 242 - Are you sure you want to remove{" "} 243 - <span className="font-medium text-foreground"> 244 - {memberToDelete?.user.name || memberToDelete?.user.email} 245 - </span>{" "} 246 - from the workspace? This action cannot be undone. 243 + {t("team:membersTable.removeDialogDescription", { 244 + name: 245 + memberToDelete?.user.name || memberToDelete?.user.email || "", 246 + })} 247 247 </AlertDialogDescription> 248 248 </AlertDialogHeader> 249 249 <AlertDialogFooter> 250 250 <AlertDialogClose disabled={isPending}> 251 251 <Button variant="outline" size="sm" disabled={isPending}> 252 - Cancel 252 + {t("common:actions.cancel")} 253 253 </Button> 254 254 </AlertDialogClose> 255 255 <AlertDialogClose onClick={handleDeleteMember} disabled={isPending}> 256 256 <Button variant="destructive" size="sm" disabled={isPending}> 257 257 <Trash2 className="w-4 h-4 mr-2" /> 258 - Remove Member 258 + {t("team:membersTable.removeMember")} 259 259 </Button> 260 260 </AlertDialogClose> 261 261 </AlertDialogFooter> ··· 268 268 > 269 269 <AlertDialogContent> 270 270 <AlertDialogHeader> 271 - <AlertDialogTitle>Cancel Invitation?</AlertDialogTitle> 271 + <AlertDialogTitle> 272 + {t("team:membersTable.cancelDialogTitle")} 273 + </AlertDialogTitle> 272 274 <AlertDialogDescription> 273 - Are you sure you want to cancel the invitation for{" "} 274 - <span className="font-medium text-foreground"> 275 - {invitationToCancel?.email} 276 - </span> 277 - ? This action cannot be undone. 275 + {t("team:membersTable.cancelDialogDescription", { 276 + email: invitationToCancel?.email ?? "", 277 + })} 278 278 </AlertDialogDescription> 279 279 </AlertDialogHeader> 280 280 <AlertDialogFooter> 281 281 <AlertDialogClose disabled={isCancelling}> 282 282 <Button variant="outline" size="sm" disabled={isCancelling}> 283 - Cancel 283 + {t("common:actions.cancel")} 284 284 </Button> 285 285 </AlertDialogClose> 286 286 <AlertDialogClose ··· 289 289 > 290 290 <Button variant="destructive" size="sm" disabled={isCancelling}> 291 291 <Trash2 className="w-4 h-4 mr-2" /> 292 - Cancel Invitation 292 + {t("team:membersTable.cancelInvitation")} 293 293 </Button> 294 294 </AlertDialogClose> 295 295 </AlertDialogFooter>
+9 -2
apps/web/src/components/ui/breadcrumb.tsx
··· 6 6 import type * as React from "react"; 7 7 8 8 import { cn } from "@/lib/cn"; 9 + import { i18n } from "@/lib/i18n"; 9 10 10 11 function Breadcrumb({ ...props }: React.ComponentProps<"nav">) { 11 - return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />; 12 + return ( 13 + <nav 14 + aria-label={i18n.t("common:breadcrumb.label")} 15 + data-slot="breadcrumb" 16 + {...props} 17 + /> 18 + ); 12 19 } 13 20 14 21 function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) { ··· 96 103 {...props} 97 104 > 98 105 <MoreHorizontal className="size-4" /> 99 - <span className="sr-only">More</span> 106 + <span className="sr-only">{i18n.t("common:breadcrumb.more")}</span> 100 107 </span> 101 108 ); 102 109 }
+3 -1
apps/web/src/components/ui/combobox.tsx
··· 6 6 import { Input } from "@/components/ui/input"; 7 7 import { ScrollArea } from "@/components/ui/scroll-area"; 8 8 import { cn } from "@/lib/cn"; 9 + import { i18n } from "@/lib/i18n"; 9 10 10 11 const ComboboxContext = React.createContext<{ 11 12 chipsRef: React.RefObject<Element | null> | null; ··· 111 112 )} 112 113 {showClear && ( 113 114 <ComboboxClear 115 + aria-label={i18n.t("common:actions.remove")} 114 116 className={cn( 115 117 "-translate-y-1/2 absolute top-1/2 inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-md border border-transparent opacity-80 outline-none transition-opacity pointer-coarse:after:absolute pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 hover:opacity-100 has-[+[data-slot=combobox-clear]]:hidden sm:size-7 [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", 116 118 sizeValue === "sm" ? "end-0" : "end-0.5", ··· 386 388 function ComboboxChipRemove(props: ComboboxPrimitive.ChipRemove.Props) { 387 389 return ( 388 390 <ComboboxPrimitive.ChipRemove 389 - aria-label="Remove" 391 + aria-label={i18n.t("common:actions.remove")} 390 392 className="h-full shrink-0 cursor-pointer px-1.5 opacity-80 hover:opacity-100 [&_svg:not([class*='size-'])]:size-4 sm:[&_svg:not([class*='size-'])]:size-3.5" 391 393 data-slot="combobox-chip-remove" 392 394 {...props}
+2 -1
apps/web/src/components/ui/dialog.tsx
··· 6 6 import { Button } from "@/components/ui/button"; 7 7 import { ScrollArea } from "@/components/ui/scroll-area"; 8 8 import { cn } from "@/lib/cn"; 9 + import { i18n } from "@/lib/i18n"; 9 10 10 11 const DialogCreateHandle = DialogPrimitive.createHandle; 11 12 ··· 117 118 {children} 118 119 {showCloseButton && ( 119 120 <DialogPrimitive.Close 120 - aria-label="Close" 121 + aria-label={i18n.t("common:actions.close")} 121 122 className="absolute end-2 top-2" 122 123 render={<Button size="icon" variant="ghost" />} 123 124 >
+1 -5
apps/web/src/components/ui/error-boundary.tsx
··· 45 45 } 46 46 47 47 return ( 48 - <ErrorDisplay 49 - error={this.state.error} 50 - onRetry={this.resetError} 51 - title="Something went wrong" 52 - /> 48 + <ErrorDisplay error={this.state.error} onRetry={this.resetError} /> 53 49 ); 54 50 } 55 51
+9 -6
apps/web/src/components/ui/error-display.tsx
··· 1 1 import { AlertTriangle, ExternalLink, RefreshCw } from "lucide-react"; 2 + import { useTranslation } from "react-i18next"; 2 3 import { 3 4 getCorsTroubleshootingSteps, 4 5 getNetworkTroubleshootingSteps, ··· 23 24 export function ErrorDisplay({ 24 25 error, 25 26 onRetry, 26 - title = "Something went wrong", 27 + title, 27 28 className, 28 29 }: ErrorDisplayProps) { 30 + const { t } = useTranslation(); 29 31 const parsedError = parseApiError(error); 32 + const resolvedTitle = title ?? t("common:error.title"); 30 33 31 34 const getTroubleshootingSteps = () => { 32 35 switch (parsedError.type) { ··· 50 53 <div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-destructive/12"> 51 54 <AlertTriangle className="h-6 w-6 text-destructive-foreground" /> 52 55 </div> 53 - <CardTitle className="text-lg">{title}</CardTitle> 56 + <CardTitle className="text-lg">{resolvedTitle}</CardTitle> 54 57 <CardDescription className="text-sm"> 55 58 {parsedError.message} 56 59 </CardDescription> ··· 59 62 {troubleshootingSteps.length > 0 && ( 60 63 <div className="space-y-2"> 61 64 <h4 className="text-sm font-medium text-foreground"> 62 - Troubleshooting steps: 65 + {t("common:error.troubleshooting")} 63 66 </h4> 64 67 <ul className="text-xs text-muted-foreground space-y-1"> 65 68 {troubleshootingSteps.map((step) => ( ··· 81 84 className="w-full" 82 85 > 83 86 <RefreshCw className="w-4 h-4 mr-2" /> 84 - Try Again 87 + {t("common:error.tryAgain")} 85 88 </Button> 86 89 )} 87 90 ··· 93 96 className="w-full" 94 97 > 95 98 <ExternalLink className="w-4 h-4 mr-2" /> 96 - View Deployment Guide 99 + {t("common:error.viewDeploymentGuide")} 97 100 </Button> 98 101 )} 99 102 ··· 104 107 className="w-full" 105 108 > 106 109 <RefreshCw className="w-4 h-4 mr-2" /> 107 - Refresh Page 110 + {t("common:error.refreshPage")} 108 111 </Button> 109 112 </div> 110 113 </CardContent>
+1 -6
apps/web/src/components/ui/error-fallback.tsx
··· 7 7 8 8 export function ErrorFallback({ error, resetError }: ErrorFallbackProps) { 9 9 return ( 10 - <ErrorDisplay 11 - error={error} 12 - onRetry={resetError} 13 - title="Something went wrong" 14 - className="min-h-screen" 15 - /> 10 + <ErrorDisplay error={error} onRetry={resetError} className="min-h-screen" /> 16 11 ); 17 12 }
+11 -6
apps/web/src/components/ui/pagination.tsx
··· 8 8 MoreHorizontalIcon, 9 9 } from "lucide-react"; 10 10 import type * as React from "react"; 11 + import { useTranslation } from "react-i18next"; 11 12 import { type Button, buttonVariants } from "@/components/ui/button"; 12 13 import { cn } from "@/lib/cn"; 13 14 14 15 function Pagination({ className, ...props }: React.ComponentProps<"nav">) { 16 + const { t } = useTranslation(); 15 17 return ( 16 18 <nav 17 - aria-label="pagination" 19 + aria-label={t("common:pagination.label")} 18 20 className={cn("mx-auto flex w-full justify-center", className)} 19 21 data-slot="pagination" 20 22 {...props} ··· 77 79 className, 78 80 ...props 79 81 }: React.ComponentProps<typeof PaginationLink>) { 82 + const { t } = useTranslation(); 80 83 return ( 81 84 <PaginationLink 82 - aria-label="Go to previous page" 85 + aria-label={t("common:pagination.previousPage")} 83 86 className={cn("max-sm:aspect-square max-sm:p-0", className)} 84 87 size="default" 85 88 {...props} 86 89 > 87 90 <ChevronLeftIcon className="sm:-ms-1" /> 88 - <span className="max-sm:hidden">Previous</span> 91 + <span className="max-sm:hidden">{t("common:pagination.previous")}</span> 89 92 </PaginationLink> 90 93 ); 91 94 } ··· 94 97 className, 95 98 ...props 96 99 }: React.ComponentProps<typeof PaginationLink>) { 100 + const { t } = useTranslation(); 97 101 return ( 98 102 <PaginationLink 99 - aria-label="Go to next page" 103 + aria-label={t("common:pagination.nextPage")} 100 104 className={cn("max-sm:aspect-square max-sm:p-0", className)} 101 105 size="default" 102 106 {...props} 103 107 > 104 - <span className="max-sm:hidden">Next</span> 108 + <span className="max-sm:hidden">{t("common:pagination.next")}</span> 105 109 <ChevronRightIcon className="sm:-me-1" /> 106 110 </PaginationLink> 107 111 ); ··· 111 115 className, 112 116 ...props 113 117 }: React.ComponentProps<"span">) { 118 + const { t } = useTranslation(); 114 119 return ( 115 120 <span 116 121 aria-hidden ··· 119 124 {...props} 120 125 > 121 126 <MoreHorizontalIcon className="size-5 sm:size-4" /> 122 - <span className="sr-only">More pages</span> 127 + <span className="sr-only">{t("common:pagination.morePages")}</span> 123 128 </span> 124 129 ); 125 130 }
+2 -1
apps/web/src/components/ui/sheet.tsx
··· 5 5 import { Button } from "@/components/ui/button"; 6 6 import { ScrollArea } from "@/components/ui/scroll-area"; 7 7 import { cn } from "@/lib/cn"; 8 + import { i18n } from "@/lib/i18n"; 8 9 9 10 const Sheet = SheetPrimitive.Root; 10 11 ··· 93 94 {children} 94 95 {showCloseButton && ( 95 96 <SheetPrimitive.Close 96 - aria-label="Close" 97 + aria-label={i18n.t("common:actions.close")} 97 98 className="absolute end-2 top-2" 98 99 render={<Button size="icon" variant="ghost" />} 99 100 >
+20 -7
apps/web/src/components/ui/sidebar.tsx
··· 5 5 import { cva, type VariantProps } from "class-variance-authority"; 6 6 import { PanelLeftIcon } from "lucide-react"; 7 7 import * as React from "react"; 8 + import { useTranslation } from "react-i18next"; 8 9 import { Button } from "@/components/ui/button"; 9 10 import { Input } from "@/components/ui/input"; 10 11 import { ScrollArea } from "@/components/ui/scroll-area"; ··· 27 28 const SIDEBAR_WIDTH_MOBILE = "18rem"; 28 29 const SIDEBAR_WIDTH_ICON = "3rem"; 29 30 const SIDEBAR_KEYBOARD_SHORTCUT = "b"; 31 + 32 + function MobileSidebarHeader() { 33 + const { t } = useTranslation(); 34 + 35 + return ( 36 + <SheetHeader className="sr-only"> 37 + <SheetTitle>{t("common:sidebar.title")}</SheetTitle> 38 + <SheetDescription> 39 + {t("common:sidebar.mobileDescription")} 40 + </SheetDescription> 41 + </SheetHeader> 42 + ); 43 + } 30 44 31 45 type SidebarContextProps = { 32 46 state: "expanded" | "collapsed"; ··· 204 218 } as React.CSSProperties 205 219 } 206 220 > 207 - <SheetHeader className="sr-only"> 208 - <SheetTitle>Sidebar</SheetTitle> 209 - <SheetDescription>Displays the mobile sidebar.</SheetDescription> 210 - </SheetHeader> 221 + <MobileSidebarHeader /> 211 222 <div className="flex h-full w-full flex-col">{children}</div> 212 223 </SheetPopup> 213 224 </Sheet> ··· 267 278 onClick, 268 279 ...props 269 280 }: React.ComponentProps<typeof Button>) { 281 + const { t } = useTranslation(); 270 282 const { toggleSidebar } = useSidebar(); 271 283 272 284 return ( ··· 283 295 {...props} 284 296 > 285 297 <PanelLeftIcon /> 286 - <span className="sr-only">Toggle Sidebar</span> 298 + <span className="sr-only">{t("common:a11y.toggleSidebar")}</span> 287 299 </Button> 288 300 ); 289 301 } 290 302 291 303 function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { 304 + const { t } = useTranslation(); 292 305 const { toggleSidebar } = useSidebar(); 293 306 294 307 return ( 295 308 <button 296 - aria-label="Toggle Sidebar" 309 + aria-label={t("common:a11y.toggleSidebar")} 297 310 className={cn( 298 311 "-translate-x-1/2 group-data-[side=left]:-right-4 absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=right]:left-0 sm:flex", 299 312 "in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize", ··· 307 320 data-slot="sidebar-rail" 308 321 onClick={toggleSidebar} 309 322 tabIndex={-1} 310 - title="Toggle Sidebar" 323 + title={t("common:a11y.toggleSidebar")} 311 324 type="button" 312 325 {...props} 313 326 />
+2 -1
apps/web/src/components/ui/spinner.tsx
··· 1 1 import { Loader2Icon } from "lucide-react"; 2 2 import { cn } from "@/lib/cn"; 3 + import { i18n } from "@/lib/i18n"; 3 4 4 5 function Spinner({ 5 6 className, ··· 7 8 }: React.ComponentProps<typeof Loader2Icon>) { 8 9 return ( 9 10 <Loader2Icon 10 - aria-label="Loading" 11 + aria-label={i18n.t("common:empty.loading")} 11 12 className={cn("animate-spin", className)} 12 13 role="status" 13 14 {...props}
+11 -5
apps/web/src/components/user-avatar.tsx
··· 1 1 import { useQueryClient } from "@tanstack/react-query"; 2 2 import { useNavigate } from "@tanstack/react-router"; 3 3 import { LogOut, Settings } from "lucide-react"; 4 + import { useTranslation } from "react-i18next"; 4 5 import { useAuth } from "@/components/providers/auth-provider/hooks/use-auth"; 5 6 import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 6 7 import { Button } from "@/components/ui/button"; ··· 17 18 import useProjectStore from "@/store/project"; 18 19 19 20 export function UserAvatar() { 21 + const { t } = useTranslation(); 20 22 const { user } = useAuth(); 21 23 const { mutateAsync: signOut, isPending } = useSignOut(); 22 24 const queryClient = useQueryClient(); ··· 32 34 await signOut(); 33 35 queryClient.clear(); 34 36 setProject(undefined); 35 - toast.success("Signed out successfully"); 37 + toast.success(t("navigation:userMenu.signedOutSuccess")); 36 38 } catch (error) { 37 39 toast.error( 38 - error instanceof Error ? error.message : "Failed to sign out", 40 + error instanceof Error 41 + ? error.message 42 + : t("navigation:userMenu.signOutFailed"), 39 43 ); 40 44 } 41 45 }; ··· 79 83 </Avatar> 80 84 <div className="grid flex-1 text-left text-sm leading-tight"> 81 85 <span className="truncate font-medium"> 82 - {user.name || "User"} 86 + {user.name || t("navigation:userMenu.unnamedUser")} 83 87 </span> 84 88 {user.email && ( 85 89 <span className="truncate text-xs text-muted-foreground"> ··· 98 102 className="h-7 gap-2 px-2 text-sm font-normal" 99 103 > 100 104 <Settings className="size-3.5" /> 101 - Settings 105 + {t("navigation:userMenu.settings")} 102 106 </DropdownMenuItem> 103 107 </div> 104 108 ··· 111 115 className="h-7 gap-2 px-2 text-sm font-normal" 112 116 > 113 117 <LogOut className="size-3.5" /> 114 - {isPending ? "Signing out..." : "Log out"} 118 + {isPending 119 + ? t("navigation:userMenu.signingOut") 120 + : t("navigation:userMenu.logOut")} 115 121 </DropdownMenuItem> 116 122 </div> 117 123 </DropdownMenuContent>
+7 -3
apps/web/src/components/workspace-switcher.tsx
··· 1 1 import { useNavigate } from "@tanstack/react-router"; 2 2 import { ChevronDown } from "lucide-react"; 3 3 import * as React from "react"; 4 + import { useTranslation } from "react-i18next"; 4 5 5 6 import { 6 7 DropdownMenu, ··· 30 31 import CreateWorkspaceModal from "./shared/modals/create-workspace-modal"; 31 32 32 33 export function WorkspaceSwitcher() { 34 + const { t } = useTranslation(); 33 35 const { data: workspace } = useActiveWorkspace(); 34 36 const { data: workspaces } = useGetWorkspaces(); 35 37 const navigate = useNavigate(); ··· 137 139 sideOffset={4} 138 140 > 139 141 <DropdownMenuGroup> 140 - <DropdownMenuLabel>Workspaces</DropdownMenuLabel> 142 + <DropdownMenuLabel> 143 + {t("navigation:workspaceSwitcher.workspaces")} 144 + </DropdownMenuLabel> 141 145 </DropdownMenuGroup> 142 146 <DropdownMenuSeparator /> 143 147 ··· 155 159 > 156 160 <span className="flex-1 text-left"> 157 161 {isSwitching && ws.id === workspace?.id 158 - ? "Switching..." 162 + ? t("navigation:workspaceSwitcher.switching") 159 163 : ws.name} 160 164 </span> 161 165 <DropdownMenuShortcut> ··· 172 176 }} 173 177 className="h-7 text-sm data-highlighted:bg-sidebar-accent data-highlighted:text-sidebar-accent-foreground" 174 178 > 175 - <span>Add workspace</span> 179 + <span>{t("navigation:workspaceSwitcher.addWorkspace")}</span> 176 180 </DropdownMenuItem> 177 181 </DropdownMenuContent> 178 182 </DropdownMenu>
+4 -4
apps/web/src/hooks/use-task-filters-with-labels-support.ts
··· 3 3 import { useCallback, useEffect, useMemo, useState } from "react"; 4 4 import type { ProjectWithTasks } from "@/types/project"; 5 5 import type Task from "@/types/task"; 6 - import type { BoardFilters } from "./use-task-filters"; 6 + import { type BoardFilters, DUE_DATE_FILTER_VALUES } from "./use-task-filters"; 7 7 8 8 const DEFAULT_FILTERS: BoardFilters = { 9 9 status: null, ··· 129 129 const taskDate = task.dueDate ? new Date(task.dueDate) : null; 130 130 131 131 const matchesAnyDueDate = filters.dueDate.some((dueDateFilter) => { 132 - if (dueDateFilter === "No due date") { 132 + if (dueDateFilter === DUE_DATE_FILTER_VALUES.noDueDate) { 133 133 return !task.dueDate; 134 134 } 135 135 ··· 138 138 } 139 139 140 140 switch (dueDateFilter) { 141 - case "Due this week": { 141 + case DUE_DATE_FILTER_VALUES.dueThisWeek: { 142 142 const weekStart = startOfWeek(today); 143 143 const weekEnd = endOfWeek(today); 144 144 return isWithinInterval(taskDate, { ··· 146 146 end: weekEnd, 147 147 }); 148 148 } 149 - case "Due next week": { 149 + case DUE_DATE_FILTER_VALUES.dueNextWeek: { 150 150 const nextWeekStart = startOfWeek(addWeeks(today, 1)); 151 151 const nextWeekEnd = endOfWeek(addWeeks(today, 1)); 152 152 return isWithinInterval(taskDate, {
+9 -3
apps/web/src/hooks/use-task-filters.ts
··· 11 11 labels: string[] | null; 12 12 }; 13 13 14 + export const DUE_DATE_FILTER_VALUES = { 15 + dueNextWeek: "dueNextWeek", 16 + dueThisWeek: "dueThisWeek", 17 + noDueDate: "noDueDate", 18 + } as const; 19 + 14 20 const DEFAULT_FILTERS: BoardFilters = { 15 21 status: null, 16 22 priority: null, ··· 106 112 const taskDate = task.dueDate ? new Date(task.dueDate) : null; 107 113 108 114 const matchesAnyDueDate = filters.dueDate.some((dueDateFilter) => { 109 - if (dueDateFilter === "No due date") { 115 + if (dueDateFilter === DUE_DATE_FILTER_VALUES.noDueDate) { 110 116 return !task.dueDate; 111 117 } 112 118 ··· 115 121 } 116 122 117 123 switch (dueDateFilter) { 118 - case "Due this week": { 124 + case DUE_DATE_FILTER_VALUES.dueThisWeek: { 119 125 const weekStart = startOfWeek(today); 120 126 const weekEnd = endOfWeek(today); 121 127 return isWithinInterval(taskDate, { ··· 123 129 end: weekEnd, 124 130 }); 125 131 } 126 - case "Due next week": { 132 + case DUE_DATE_FILTER_VALUES.dueNextWeek: { 127 133 const nextWeekStart = startOfWeek(addWeeks(today, 1)); 128 134 const nextWeekEnd = endOfWeek(addWeeks(today, 1)); 129 135 return isWithinInterval(taskDate, {