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.

fix: resolve i18n review issues and improve UI components

- Fix SQL injection risk in global search by using inArray() instead of raw sql IN
- Fix shortIdMatch early return that exited entire search function
- Fix missing SortableContext import in list view
- Fix missing useMemo import in bulk toolbars
- Fix hardcoded "Board"/"List" strings in board toolbar
- Fix Base UI select display showing raw values instead of labels
- Fix email locale resolution with shared resolveEmailLocale helper
- Fix task.created activity using hardcoded English content
- Add board loading skeleton replacing spinner
- Remove compact mode toggle from preferences
- Add i18next namespace list to init config
- Type setLocale as AppLocale for compile-time safety

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Andrej 7a2172f0 e7a61517

+179 -129
+3 -3
apps/api/src/activity/index.ts
··· 193 193 taskId: string; 194 194 userId: string; 195 195 type: string; 196 - content: string; 196 + content: string | null; 197 197 }>("task.created", async (data) => { 198 - if (!data.userId || !data.taskId || !data.type || !data.content) { 198 + if (!data.userId || !data.taskId || !data.type) { 199 199 return; 200 200 } 201 - await createActivity(data.taskId, data.type, data.userId, data.content, null); 201 + await createActivity(data.taskId, data.type, data.userId, null, {}); 202 202 }); 203 203 204 204 subscribeToEvent<{
+6 -7
apps/api/src/search/controllers/global-search.ts
··· 1 - import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; 1 + import { and, desc, eq, ilike, inArray, or, sql } from "drizzle-orm"; 2 2 import db from "../../database"; 3 3 import { 4 4 activityTable, ··· 87 87 return `changed due date from ${String(data.oldDueDate)} to ${String(data.newDueDate)}`; 88 88 case "title_changed": 89 89 return `changed title from "${String(data.oldTitle ?? "")}" to "${String(data.newTitle ?? "")}"`; 90 + case "task": 91 + return "created the task"; 90 92 default: 91 93 return undefined; 92 94 } ··· 142 144 143 145 const workspaceFilter = workspaceId 144 146 ? eq(projectTable.workspaceId, workspaceId) 145 - : sql`${projectTable.workspaceId} IN ${accessibleWorkspaceIds}`; 147 + : inArray(projectTable.workspaceId, accessibleWorkspaceIds); 146 148 147 149 // Check if query matches short-id pattern (e.g. "DEP-23") 148 150 const shortIdMatch = query.match(/^([A-Za-z][\w-]*)-(\d+)$/); ··· 151 153 const seenTaskIds = new Set<string>(); 152 154 153 155 // If query matches short-id pattern, look up by project slug + task number first 154 - if (shortIdMatch) { 156 + if (shortIdMatch?.[1] && shortIdMatch[2]) { 155 157 const slug = shortIdMatch[1]; 156 158 const numberStr = shortIdMatch[2]; 157 - if (!slug || !numberStr) { 158 - return { results: [], totalCount: 0, searchQuery: query }; 159 - } 160 159 const taskNumber = Number.parseInt(numberStr, 10); 161 160 162 161 const shortIdTasks = await db ··· 361 360 ) 362 361 .where( 363 362 and( 364 - sql`${workspaceTable.id} IN ${accessibleWorkspaceIds}`, 363 + inArray(workspaceTable.id, accessibleWorkspaceIds), 365 364 or( 366 365 ilike(workspaceTable.name, searchPattern), 367 366 ilike(workspaceTable.description, searchPattern),
+1 -1
apps/api/src/task/controllers/create-task.ts
··· 86 86 taskId: createdTask.id, 87 87 userId: createdTask.userId ?? "", 88 88 type: "task", 89 - content: "created the task", 89 + content: null, 90 90 }); 91 91 92 92 return {
+2 -2
apps/web/src/components/board/board-toolbar.tsx
··· 651 651 onClick={() => setViewMode("board")} 652 652 > 653 653 <PanelsTopLeft className="h-3 w-3" /> 654 - Board 654 + {t("tasks:view.board")} 655 655 </button> 656 656 <button 657 657 type="button" ··· 663 663 onClick={() => setViewMode("list")} 664 664 > 665 665 <Rows3 className="h-3 w-3" /> 666 - List 666 + {t("tasks:view.list")} 667 667 </button> 668 668 </div> 669 669 </div>
+11 -7
apps/web/src/components/bulk-selection/backlog-bulk-toolbar.tsx
··· 12 12 type ReactNode, 13 13 useCallback, 14 14 useEffect, 15 + useMemo, 15 16 useState, 16 17 } from "react"; 17 18 import { useTranslation } from "react-i18next"; ··· 74 75 const { selectedTaskIds, clearSelection, selectAll } = 75 76 useBacklogBulkSelectionStore(); 76 77 77 - const priorityOptions = [ 78 - { value: "urgent", label: getPriorityLabel("urgent") }, 79 - { value: "high", label: getPriorityLabel("high") }, 80 - { value: "medium", label: getPriorityLabel("medium") }, 81 - { value: "low", label: getPriorityLabel("low") }, 82 - { value: "no-priority", label: getPriorityLabel("no-priority") }, 83 - ]; 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 + ); 84 88 const { project } = useProjectStore(); 85 89 const { 86 90 bulkMoveToBoard,
+11 -7
apps/web/src/components/bulk-selection/bulk-toolbar.tsx
··· 11 11 type ReactNode, 12 12 useCallback, 13 13 useEffect, 14 + useMemo, 14 15 useState, 15 16 } from "react"; 16 17 import { useTranslation } from "react-i18next"; ··· 67 68 const { selectedTaskIds, clearSelection, selectAll } = 68 69 useBulkSelectionStore(); 69 70 70 - const priorityOptions = [ 71 - { value: "urgent", label: getPriorityLabel("urgent") }, 72 - { value: "high", label: getPriorityLabel("high") }, 73 - { value: "medium", label: getPriorityLabel("medium") }, 74 - { value: "low", label: getPriorityLabel("low") }, 75 - { value: "no-priority", label: getPriorityLabel("no-priority") }, 76 - ]; 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 + ); 77 81 const { project } = useProjectStore(); 78 82 const { 79 83 bulkMoveToBacklog,
+4 -1
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 { verticalListSortingStrategy } from "@dnd-kit/sortable"; 17 + import { 18 + SortableContext, 19 + verticalListSortingStrategy, 20 + } from "@dnd-kit/sortable"; 18 21 import { useNavigate } from "@tanstack/react-router"; 19 22 import { produce } from "immer"; 20 23 import { Archive, ChevronRight, Flag, Plus } from "lucide-react";
+5 -1
apps/web/src/components/project/workflow-editor.tsx
··· 104 104 placeholder={t( 105 105 "settings:workflowEditor.selectColumnPlaceholder", 106 106 )} 107 - /> 107 + > 108 + {columns.find((c) => c.id === currentRule?.columnId) 109 + ?.name ?? 110 + t("settings:workflowEditor.selectColumnPlaceholder")} 111 + </SelectValue> 108 112 </SelectTrigger> 109 113 <SelectContent> 110 114 {columns.map((col) => (
+5 -1
apps/web/src/components/settings/create-api-key-dialog.tsx
··· 203 203 placeholder={t( 204 204 "settings:apiKey.createDialog.expirationPlaceholder", 205 205 )} 206 - /> 206 + > 207 + {expirationOptions.find( 208 + (o) => o.value === field.value, 209 + )?.label ?? field.value} 210 + </SelectValue> 207 211 </SelectTrigger> 208 212 <SelectContent> 209 213 {expirationOptions.map((option) => (
+5 -2
apps/web/src/hooks/use-locale.ts
··· 1 + import type { AppLocale } from "@i18n/resources"; 1 2 import { useQueryClient } from "@tanstack/react-query"; 2 3 import { useMemo } from "react"; 3 4 import { useTranslation } from "react-i18next"; ··· 13 14 [i18n.resolvedLanguage], 14 15 ); 15 16 16 - const setLocale = async (nextLocale: string) => { 17 + const setLocale = async (nextLocale: AppLocale) => { 17 18 const { error } = await authClient.updateUser({ locale: nextLocale }); 18 19 if (error) { 19 20 throw new Error(error.message || "Failed to update locale"); 20 21 } 21 22 23 + const resolved = resolveLocale(nextLocale, null); 24 + document.documentElement.lang = resolved; 22 25 await queryClient.invalidateQueries({ queryKey: ["session"] }); 23 - await i18n.changeLanguage(resolveLocale(nextLocale, null)); 26 + await i18n.changeLanguage(resolved); 24 27 }; 25 28 26 29 return {
+1
apps/web/src/lib/i18n/index.ts
··· 44 44 resources, 45 45 lng: resolveLocale(null, getBrowserLocale()), 46 46 fallbackLng: defaultLocale, 47 + ns: Object.keys(resources[defaultLocale]), 47 48 defaultNS: "common", 48 49 interpolation: { 49 50 escapeValue: false,
+36 -61
apps/web/src/routes/_layout/_authenticated/dashboard/settings/account/preferences.tsx
··· 1 + import type { AppLocale } from "@i18n/resources"; 1 2 import { createFileRoute } from "@tanstack/react-router"; 2 - import { LayoutGrid, List, RotateCcw } from "lucide-react"; 3 + import { RotateCcw } from "lucide-react"; 3 4 import { useTranslation } from "react-i18next"; 4 5 import { Button } from "@/components/ui/button"; 5 6 import { Label } from "@/components/ui/label"; ··· 29 30 setTheme, 30 31 viewMode, 31 32 setViewMode, 32 - compactMode, 33 - setCompactMode, 34 33 showTaskNumbers, 35 34 setShowTaskNumbers, 36 35 showAssignees, ··· 46 45 setSidebarDefaultOpen, 47 46 } = useUserPreferencesStore(); 48 47 48 + const themeLabels: Record<string, string> = { 49 + light: t("settings:preferencesPage.themeLight"), 50 + dark: t("settings:preferencesPage.themeDark"), 51 + system: t("settings:preferencesPage.themeSystem"), 52 + }; 53 + 54 + const viewLabels: Record<string, string> = { 55 + board: t("settings:preferencesPage.board"), 56 + list: t("settings:preferencesPage.list"), 57 + }; 58 + 59 + const localeLabels: Record<string, string> = { 60 + "en-US": t("common:language.english"), 61 + "de-DE": t("common:language.german"), 62 + }; 63 + 49 64 return ( 50 65 <div className="max-w-4xl mx-auto space-y-8"> 51 66 <div className="space-y-2"> ··· 81 96 value={theme} 82 97 onValueChange={(value) => value && setTheme(value)} 83 98 > 84 - <SelectTrigger className="!py-4"> 99 + <SelectTrigger size="sm" className="w-40"> 85 100 <SelectValue 86 101 placeholder={t("settings:preferencesPage.selectTheme")} 87 - /> 102 + > 103 + {themeLabels[theme]} 104 + </SelectValue> 88 105 </SelectTrigger> 89 106 <SelectContent> 90 107 <SelectItem value="light"> 91 - <div className="flex items-center gap-3"> 92 - <div className="flex items-center gap-1 rounded-md border border-border bg-muted p-1"> 93 - <span className="rounded-full size-2 bg-primary" /> 94 - <span className="text-xs font-normal text-foreground"> 95 - Aa 96 - </span> 97 - </div> 98 - <span className="text-xs font-normal"> 99 - {t("settings:preferencesPage.themeLight")} 100 - </span> 101 - </div> 108 + {t("settings:preferencesPage.themeLight")} 102 109 </SelectItem> 103 110 <SelectItem value="dark"> 104 - <div className="flex items-center gap-3"> 105 - <div className="flex items-center gap-1 rounded-md border border-border bg-card p-1"> 106 - <span className="rounded-full size-2 bg-primary" /> 107 - <span className="text-xs font-normal text-foreground"> 108 - Aa 109 - </span> 110 - </div> 111 - <span className="text-xs font-normal"> 112 - {t("settings:preferencesPage.themeDark")} 113 - </span> 114 - </div> 111 + {t("settings:preferencesPage.themeDark")} 115 112 </SelectItem> 116 113 <SelectItem value="system"> 117 - <span className="text-xs font-normal"> 118 - {t("settings:preferencesPage.themeSystem")} 119 - </span> 114 + {t("settings:preferencesPage.themeSystem")} 120 115 </SelectItem> 121 116 </SelectContent> 122 117 </Select> ··· 137 132 value={locale ?? "en-US"} 138 133 onValueChange={(value) => { 139 134 if (value) { 140 - void setLocale(value); 135 + void setLocale(value as AppLocale); 141 136 } 142 137 }} 143 138 > 144 - <SelectTrigger className="!py-4"> 139 + <SelectTrigger size="sm" className="w-40"> 145 140 <SelectValue 146 141 placeholder={t("settings:preferencesPage.selectLanguage")} 147 - /> 142 + > 143 + {localeLabels[locale ?? "en-US"]} 144 + </SelectValue> 148 145 </SelectTrigger> 149 146 <SelectContent> 150 147 <SelectItem value="en-US"> ··· 172 169 value={viewMode} 173 170 onValueChange={(value) => value && setViewMode(value)} 174 171 > 175 - <SelectTrigger className="!py-4"> 172 + <SelectTrigger size="sm" className="w-40"> 176 173 <SelectValue 177 174 placeholder={t("settings:preferencesPage.selectViewMode")} 178 - /> 175 + > 176 + {viewLabels[viewMode]} 177 + </SelectValue> 179 178 </SelectTrigger> 180 179 <SelectContent> 181 180 <SelectItem value="board"> 182 - <div className="flex items-center gap-3"> 183 - <LayoutGrid className="h-4 w-4 mr-1" /> 184 - <span className="text-xs font-normal"> 185 - {t("settings:preferencesPage.board")} 186 - </span> 187 - </div> 181 + {t("settings:preferencesPage.board")} 188 182 </SelectItem> 189 183 <SelectItem value="list"> 190 - <div className="flex items-center gap-3"> 191 - <List className="h-4 w-4 mr-1" /> 192 - <span className="text-xs font-normal"> 193 - {t("settings:preferencesPage.list")} 194 - </span> 195 - </div> 184 + {t("settings:preferencesPage.list")} 196 185 </SelectItem> 197 186 </SelectContent> 198 187 </Select> 199 - </div> 200 - 201 - <Separator /> 202 - 203 - <div className="flex items-center justify-between"> 204 - <div className="space-y-0.5"> 205 - <Label className="text-sm font-medium"> 206 - {t("settings:preferencesPage.compactMode")} 207 - </Label> 208 - <p className="text-xs text-muted-foreground"> 209 - {t("settings:preferencesPage.compactModeDescription")} 210 - </p> 211 - </div> 212 - <Switch checked={compactMode} onCheckedChange={setCompactMode} /> 213 188 </div> 214 189 215 190 <Separator />
+41 -3
apps/web/src/routes/_layout/_authenticated/dashboard/workspace/$workspaceId/project/$projectId/board.tsx
··· 34 34 }), 35 35 }); 36 36 37 + const skeletonColumns = [ 38 + { key: "col-todo", cards: 3 }, 39 + { key: "col-progress", cards: 4 }, 40 + { key: "col-review", cards: 2 }, 41 + { key: "col-done", cards: 1 }, 42 + ]; 43 + 44 + function BoardSkeleton() { 45 + return ( 46 + <div className="flex h-full w-full gap-4 p-4 overflow-hidden"> 47 + {skeletonColumns.map((col) => ( 48 + <div key={col.key} className="flex w-72 shrink-0 flex-col gap-3"> 49 + <div className="flex items-center gap-2 px-1"> 50 + <div className="h-3 w-3 rounded-full bg-muted animate-pulse" /> 51 + <div className="h-4 w-24 rounded bg-muted animate-pulse" /> 52 + <div className="h-4 w-5 rounded bg-muted animate-pulse" /> 53 + </div> 54 + <div className="flex flex-col gap-2.5"> 55 + {Array.from({ length: col.cards }, (_, i) => `${col.key}-${i}`).map( 56 + (cardKey) => ( 57 + <div 58 + key={cardKey} 59 + className="rounded-lg border border-border bg-card p-3 space-y-2.5" 60 + > 61 + <div className="h-3.5 w-4/5 rounded bg-muted animate-pulse" /> 62 + <div className="h-3 w-3/5 rounded bg-muted animate-pulse" /> 63 + <div className="flex items-center gap-2 pt-1"> 64 + <div className="h-5 w-5 rounded-full bg-muted animate-pulse" /> 65 + <div className="h-3 w-16 rounded bg-muted animate-pulse" /> 66 + </div> 67 + </div> 68 + ), 69 + )} 70 + </div> 71 + </div> 72 + ))} 73 + </div> 74 + ); 75 + } 76 + 37 77 function RouteComponent() { 38 78 const { t } = useTranslation(); 39 79 const { projectId, workspaceId } = Route.useParams(); ··· 209 249 /> 210 250 ) 211 251 ) : ( 212 - <div className="flex h-full items-center justify-center"> 213 - <div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-foreground" /> 214 - </div> 252 + <BoardSkeleton /> 215 253 )} 216 254 </div> 217 255
+1 -2
i18n/de-DE.json
··· 335 335 "selectViewMode": "Ansicht auswählen", 336 336 "board": "Board", 337 337 "list": "Liste", 338 - "compactMode": "Kompakter Modus", 339 - "compactModeDescription": "Verwende geringere Abstände für mehr Inhalt", 338 + "gantt": "Gantt", 340 339 "sidebarDefault": "Standard-Sidebar", 341 340 "sidebarDefaultDescription": "Sidebar beim Start erweitert geöffnet halten", 342 341 "displayOptions": "Anzeigeoptionen",
+1 -2
i18n/en-US.json
··· 335 335 "selectViewMode": "Select a view mode", 336 336 "board": "Board", 337 337 "list": "List", 338 - "compactMode": "Compact Mode", 339 - "compactModeDescription": "Use reduced spacing for more content", 338 + "gantt": "Gantt", 340 339 "sidebarDefault": "Sidebar Default", 341 340 "sidebarDefaultDescription": "Keep sidebar expanded on startup", 342 341 "displayOptions": "Display options",
+2 -3
packages/email/src/templates/magic-link.tsx
··· 1 1 import { Link, Section, Text } from "@react-email/components"; 2 2 import React from "react"; 3 + import { resolveEmailLocale } from "./resolve-locale"; 3 4 import { EmailShell, styles } from "./shell"; 4 5 5 6 void React; ··· 33 34 } as const; 34 35 35 36 const MagicLinkEmail = ({ magicLink, locale }: MagicLinkEmailProps) => { 36 - const copy = locale?.toLowerCase().startsWith("de") 37 - ? messages.de 38 - : messages.en; 37 + const copy = messages[resolveEmailLocale(locale)]; 39 38 40 39 return ( 41 40 <EmailShell
+2 -3
packages/email/src/templates/otp.tsx
··· 1 1 import { Section, Text } from "@react-email/components"; 2 2 import React from "react"; 3 + import { resolveEmailLocale } from "./resolve-locale"; 3 4 import { EmailShell, styles } from "./shell"; 4 5 5 6 void React; ··· 32 33 } as const; 33 34 34 35 const OtpEmail = ({ otp, locale }: OtpEmailProps) => { 35 - const copy = locale?.toLowerCase().startsWith("de") 36 - ? messages.de 37 - : messages.en; 36 + const copy = messages[resolveEmailLocale(locale)]; 38 37 39 38 return ( 40 39 <EmailShell
+20
packages/email/src/templates/resolve-locale.ts
··· 1 + const supportedLocales = ["en", "de"] as const; 2 + 3 + type EmailLocale = (typeof supportedLocales)[number]; 4 + 5 + const defaultLocale: EmailLocale = "en"; 6 + 7 + export function resolveEmailLocale(locale?: string | null): EmailLocale { 8 + if (!locale) return defaultLocale; 9 + 10 + const normalized = locale.toLowerCase(); 11 + 12 + const exact = supportedLocales.find((l) => l === normalized); 13 + if (exact) return exact; 14 + 15 + const languageCode = normalized.split("-")[0]; 16 + const match = supportedLocales.find((l) => l === languageCode); 17 + if (match) return match; 18 + 19 + return defaultLocale; 20 + }
+22 -23
packages/email/src/templates/workspace-invitation.tsx
··· 1 1 import { Link, Section, Text } from "@react-email/components"; 2 2 import React from "react"; 3 + import { resolveEmailLocale } from "./resolve-locale"; 3 4 import { EmailShell, styles } from "./shell"; 4 5 5 6 void React; ··· 51 52 invitationLink, 52 53 to, 53 54 locale, 54 - }: WorkspaceInvitationEmailProps) => 55 - (() => { 56 - const localeKey = locale?.toLowerCase().startsWith("de") ? "de" : "en"; 57 - const copy = messages[localeKey]; 58 - const values = { workspaceName, inviterName, inviterEmail }; 55 + }: WorkspaceInvitationEmailProps) => { 56 + const copy = messages[resolveEmailLocale(locale)]; 57 + const values = { workspaceName, inviterName, inviterEmail }; 59 58 60 - return ( 61 - <EmailShell 62 - preview={interpolate(copy.preview, values)} 63 - title={interpolate(copy.title, values)} 64 - subtitle={interpolate(copy.subtitle, values)} 65 - > 66 - <Section> 67 - <Link style={styles.button} href={`${invitationLink}?email=${to}`}> 68 - {copy.cta} 69 - </Link> 70 - <Text style={styles.paragraph}>{copy.sameEmail}</Text> 71 - <Text style={styles.muted}>{copy.ignore}</Text> 72 - <Section style={styles.divider} /> 73 - <Text style={styles.footer}>{copy.footer}</Text> 74 - </Section> 75 - </EmailShell> 76 - ); 77 - })(); 59 + return ( 60 + <EmailShell 61 + preview={interpolate(copy.preview, values)} 62 + title={interpolate(copy.title, values)} 63 + subtitle={interpolate(copy.subtitle, values)} 64 + > 65 + <Section> 66 + <Link style={styles.button} href={`${invitationLink}?email=${to}`}> 67 + {copy.cta} 68 + </Link> 69 + <Text style={styles.paragraph}>{copy.sameEmail}</Text> 70 + <Text style={styles.muted}>{copy.ignore}</Text> 71 + <Section style={styles.divider} /> 72 + <Text style={styles.footer}>{copy.footer}</Text> 73 + </Section> 74 + </EmailShell> 75 + ); 76 + }; 78 77 79 78 WorkspaceInvitationEmail.PreviewProps = { 80 79 workspaceName: "Acme Inc",