@wakastellar/ui 2.0.0 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +71 -8
- package/dist/cli/commands/add.d.ts +7 -0
- package/dist/cli/commands/init.d.ts +6 -0
- package/dist/cli/commands/list.d.ts +5 -0
- package/dist/cli/commands/search.d.ts +1 -0
- package/dist/cli/index.cjs +6014 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/utils/config.d.ts +29 -0
- package/dist/cli/utils/logger.d.ts +20 -0
- package/dist/cli/utils/registry.d.ts +23 -0
- package/package.json +14 -3
- package/src/blocks/activity-timeline/index.tsx +586 -0
- package/src/blocks/calendar-view/index.tsx +756 -0
- package/src/blocks/chat/index.tsx +1018 -0
- package/src/blocks/chat/widget.tsx +504 -0
- package/src/blocks/dashboard/index.tsx +522 -0
- package/src/blocks/empty-states/index.tsx +452 -0
- package/src/blocks/error-pages/index.tsx +426 -0
- package/src/blocks/faq/index.tsx +479 -0
- package/src/blocks/file-manager/index.tsx +890 -0
- package/src/blocks/footer/index.tsx +133 -0
- package/src/blocks/header/index.tsx +357 -0
- package/src/blocks/headtab/index.tsx +139 -0
- package/src/blocks/i18n-editor/index.tsx +1016 -0
- package/src/blocks/index.ts +80 -0
- package/src/blocks/kanban-board/index.tsx +779 -0
- package/src/blocks/landing/index.tsx +677 -0
- package/src/blocks/language-selector/index.tsx +88 -0
- package/src/blocks/layout/index.tsx +159 -0
- package/src/blocks/login/index.tsx +339 -0
- package/src/blocks/login/types.ts +131 -0
- package/src/blocks/pricing/index.tsx +564 -0
- package/src/blocks/profile/index.tsx +746 -0
- package/src/blocks/settings/index.tsx +558 -0
- package/src/blocks/sidebar/index.tsx +713 -0
- package/src/blocks/theme-creator-block/index.tsx +835 -0
- package/src/blocks/user-management/index.tsx +1037 -0
- package/src/blocks/wizard/index.tsx +719 -0
- package/src/components/DataTable/DataTable.tsx +406 -0
- package/src/components/DataTable/DataTableAdvanced.tsx +720 -0
- package/src/components/DataTable/DataTableBody.tsx +216 -0
- package/src/components/DataTable/DataTableCell.tsx +172 -0
- package/src/components/DataTable/DataTableColumnResizer.tsx +62 -0
- package/src/components/DataTable/DataTableConflictResolver.tsx +478 -0
- package/src/components/DataTable/DataTableContextMenu.tsx +219 -0
- package/src/components/DataTable/DataTableEditCell.tsx +279 -0
- package/src/components/DataTable/DataTableFilterBuilder.tsx +519 -0
- package/src/components/DataTable/DataTableFilters.tsx +535 -0
- package/src/components/DataTable/DataTableGrouping.tsx +147 -0
- package/src/components/DataTable/DataTableHeader.tsx +172 -0
- package/src/components/DataTable/DataTablePagination.tsx +125 -0
- package/src/components/DataTable/DataTableSelection.tsx +269 -0
- package/src/components/DataTable/DataTableSyncStatus.tsx +281 -0
- package/src/components/DataTable/DataTableToolbar.tsx +262 -0
- package/src/components/DataTable/README.md +446 -0
- package/src/components/DataTable/__tests__/DataTableAdvanced.test.tsx +426 -0
- package/src/components/DataTable/__tests__/DataTableEdit.test.tsx +329 -0
- package/src/components/DataTable/__tests__/useDataTableAdvanced.test.ts +455 -0
- package/src/components/DataTable/examples/EditExample.tsx +166 -0
- package/src/components/DataTable/formatters/index.ts +335 -0
- package/src/components/DataTable/hooks/__tests__/useDataTableEdit.test.ts +239 -0
- package/src/components/DataTable/hooks/useDataTable.ts +145 -0
- package/src/components/DataTable/hooks/useDataTableAdvanced.ts +342 -0
- package/src/components/DataTable/hooks/useDataTableAdvancedFilters.ts +637 -0
- package/src/components/DataTable/hooks/useDataTableColumnTemplates.ts +186 -0
- package/src/components/DataTable/hooks/useDataTableEdit.ts +167 -0
- package/src/components/DataTable/hooks/useDataTableExport.ts +227 -0
- package/src/components/DataTable/hooks/useDataTableImport.ts +216 -0
- package/src/components/DataTable/hooks/useDataTableOffline.ts +481 -0
- package/src/components/DataTable/hooks/useDataTableTheme.ts +213 -0
- package/src/components/DataTable/hooks/useDataTableVirtualization.ts +99 -0
- package/src/components/DataTable/hooks/useTableLayout.ts +85 -0
- package/src/components/DataTable/index.ts +81 -0
- package/src/components/DataTable/services/IndexedDBService.ts +504 -0
- package/src/components/DataTable/templates/index.tsx +803 -0
- package/src/components/DataTable/types.ts +504 -0
- package/src/components/DataTable/utils.ts +164 -0
- package/src/components/DataTable/workers/exportWorker.ts +213 -0
- package/src/components/accordion/index.tsx +61 -0
- package/src/components/alert/index.tsx +61 -0
- package/src/components/alert-dialog/index.tsx +146 -0
- package/src/components/aspect-ratio/index.tsx +12 -0
- package/src/components/avatar/index.tsx +54 -0
- package/src/components/badge/Badge.stories.tsx +64 -0
- package/src/components/badge/index.tsx +38 -0
- package/src/components/button/Button.stories.tsx +173 -0
- package/src/components/button/index.tsx +56 -0
- package/src/components/calendar/index.tsx +73 -0
- package/src/components/card/index.tsx +78 -0
- package/src/components/checkbox/index.tsx +34 -0
- package/src/components/code/index.tsx +229 -0
- package/src/components/collapsible/index.tsx +16 -0
- package/src/components/command/index.tsx +162 -0
- package/src/components/context-menu/index.tsx +204 -0
- package/src/components/dialog/index.tsx +126 -0
- package/src/components/dropdown-menu/index.tsx +204 -0
- package/src/components/error-boundary/ErrorBoundary.tsx +281 -0
- package/src/components/error-boundary/index.ts +7 -0
- package/src/components/form/index.tsx +183 -0
- package/src/components/hover-card/index.tsx +33 -0
- package/src/components/index.ts +368 -0
- package/src/components/input/Input.stories.tsx +100 -0
- package/src/components/input/index.tsx +27 -0
- package/src/components/input-otp/index.tsx +277 -0
- package/src/components/label/index.tsx +30 -0
- package/src/components/language-selector/index.tsx +341 -0
- package/src/components/menubar/index.tsx +240 -0
- package/src/components/navigation-menu/index.tsx +134 -0
- package/src/components/popover/index.tsx +35 -0
- package/src/components/progress/index.tsx +32 -0
- package/src/components/radio-group/index.tsx +48 -0
- package/src/components/scroll-area/index.tsx +52 -0
- package/src/components/select/index.tsx +164 -0
- package/src/components/separator/index.tsx +35 -0
- package/src/components/sheet/index.tsx +147 -0
- package/src/components/skeleton/index.tsx +22 -0
- package/src/components/slider/index.tsx +32 -0
- package/src/components/switch/index.tsx +33 -0
- package/src/components/table/index.tsx +117 -0
- package/src/components/tabs/index.tsx +59 -0
- package/src/components/textarea/index.tsx +30 -0
- package/src/components/theme-selector/index.tsx +327 -0
- package/src/components/toast/index.tsx +133 -0
- package/src/components/toaster/index.tsx +34 -0
- package/src/components/toggle/index.tsx +49 -0
- package/src/components/tooltip/index.tsx +34 -0
- package/src/components/typography/index.tsx +276 -0
- package/src/components/waka-3d-pie-chart/index.tsx +486 -0
- package/src/components/waka-achievement-unlock/index.tsx +716 -0
- package/src/components/waka-activity-feed/index.tsx +686 -0
- package/src/components/waka-address-autocomplete/index.tsx +1202 -0
- package/src/components/waka-admincrumb/index.tsx +349 -0
- package/src/components/waka-alert-stack/index.tsx +827 -0
- package/src/components/waka-allocation-matrix/index.tsx +1278 -0
- package/src/components/waka-approval-chain/index.tsx +766 -0
- package/src/components/waka-audit-log/index.tsx +1475 -0
- package/src/components/waka-autocomplete/index.tsx +358 -0
- package/src/components/waka-badge-showcase/index.tsx +704 -0
- package/src/components/waka-barcode/index.tsx +260 -0
- package/src/components/waka-biometric-prompt/index.tsx +765 -0
- package/src/components/waka-bottom-sheet/index.tsx +495 -0
- package/src/components/waka-breadcrumb/index.tsx +376 -0
- package/src/components/waka-breadcrumb-path/index.tsx +513 -0
- package/src/components/waka-budget-burn/index.tsx +1234 -0
- package/src/components/waka-capacity-planner/index.tsx +1107 -0
- package/src/components/waka-carousel/index.tsx +893 -0
- package/src/components/waka-cart-summary/index.tsx +1055 -0
- package/src/components/waka-challenge-timer/index.tsx +1044 -0
- package/src/components/waka-charts/WakaAreaChart.tsx +251 -0
- package/src/components/waka-charts/WakaBarChart.tsx +222 -0
- package/src/components/waka-charts/WakaChart.tsx +124 -0
- package/src/components/waka-charts/WakaLineChart.tsx +219 -0
- package/src/components/waka-charts/WakaMiniChart.tsx +133 -0
- package/src/components/waka-charts/WakaPieChart.tsx +214 -0
- package/src/components/waka-charts/WakaSparkline.tsx +229 -0
- package/src/components/waka-charts/dataTableHelpers.ts +109 -0
- package/src/components/waka-charts/hooks/useChartTheme.ts +123 -0
- package/src/components/waka-charts/hooks/useRechartsLoader.ts +234 -0
- package/src/components/waka-charts/index.ts +90 -0
- package/src/components/waka-charts/types.ts +330 -0
- package/src/components/waka-chat-bubble/index.tsx +1060 -0
- package/src/components/waka-checklist/index.tsx +1067 -0
- package/src/components/waka-checkout-stepper/index.tsx +976 -0
- package/src/components/waka-cohort-table/index.tsx +1011 -0
- package/src/components/waka-color-picker/index.tsx +447 -0
- package/src/components/waka-combo-counter/index.tsx +864 -0
- package/src/components/waka-combobox/index.tsx +497 -0
- package/src/components/waka-command-bar/index.tsx +403 -0
- package/src/components/waka-compare-period/index.tsx +1230 -0
- package/src/components/waka-connection-matrix/index.tsx +1053 -0
- package/src/components/waka-contribution-graph/index.tsx +552 -0
- package/src/components/waka-cost-breakdown/index.tsx +1065 -0
- package/src/components/waka-coupon-input/index.tsx +592 -0
- package/src/components/waka-credit-card-input/index.tsx +982 -0
- package/src/components/waka-daily-reward/index.tsx +762 -0
- package/src/components/waka-date-range-picker/index.tsx +378 -0
- package/src/components/waka-datetime-picker/index.tsx +793 -0
- package/src/components/waka-datetime-picker.form-integration/index.tsx +402 -0
- package/src/components/waka-deployment-lane/index.tsx +673 -0
- package/src/components/waka-device-trust/index.tsx +1259 -0
- package/src/components/waka-dock/index.tsx +285 -0
- package/src/components/waka-drawer/index.tsx +319 -0
- package/src/components/waka-empty-state/index.tsx +545 -0
- package/src/components/waka-error-shake/index.tsx +398 -0
- package/src/components/waka-feature-announcement/index.tsx +991 -0
- package/src/components/waka-file-upload/index.tsx +437 -0
- package/src/components/waka-floating-nav/index.tsx +413 -0
- package/src/components/waka-flow-diagram/index.tsx +508 -0
- package/src/components/waka-funnel-chart/index.tsx +823 -0
- package/src/components/waka-glow-card/index.tsx +246 -0
- package/src/components/waka-goal-progress/index.tsx +1025 -0
- package/src/components/waka-haptic-button/index.tsx +388 -0
- package/src/components/waka-health-pulse/index.tsx +451 -0
- package/src/components/waka-heatmap/index.tsx +1026 -0
- package/src/components/waka-hotspot/index.tsx +682 -0
- package/src/components/waka-image/index.tsx +373 -0
- package/src/components/waka-incident-timeline/index.tsx +686 -0
- package/src/components/waka-invoice-preview/index.tsx +829 -0
- package/src/components/waka-kanban/index.tsx +646 -0
- package/src/components/waka-kpi-dashboard/index.tsx +755 -0
- package/src/components/waka-leaderboard/index.tsx +746 -0
- package/src/components/waka-level-progress/index.tsx +665 -0
- package/src/components/waka-liquid-button/index.tsx +520 -0
- package/src/components/waka-loading-orbit/index.tsx +478 -0
- package/src/components/waka-loot-box/index.tsx +1091 -0
- package/src/components/waka-magic-link/index.tsx +321 -0
- package/src/components/waka-magnetic-button/index.tsx +567 -0
- package/src/components/waka-mention-input/index.tsx +953 -0
- package/src/components/waka-metric-sparkline/index.tsx +627 -0
- package/src/components/waka-milestone-road/index.tsx +1064 -0
- package/src/components/waka-modal/index.tsx +374 -0
- package/src/components/waka-morph-button/index.tsx +495 -0
- package/src/components/waka-network-topology/index.tsx +801 -0
- package/src/components/waka-notifications/index.tsx +414 -0
- package/src/components/waka-number-input/index.tsx +373 -0
- package/src/components/waka-orbital-menu/index.tsx +445 -0
- package/src/components/waka-order-tracker/index.tsx +1041 -0
- package/src/components/waka-pagination/index.tsx +393 -0
- package/src/components/waka-password-strength/index.tsx +824 -0
- package/src/components/waka-payment-method-picker/index.tsx +715 -0
- package/src/components/waka-permission-matrix/index.tsx +1302 -0
- package/src/components/waka-phone-input/index.tsx +801 -0
- package/src/components/waka-pipeline-view/index.tsx +604 -0
- package/src/components/waka-player-card/index.tsx +691 -0
- package/src/components/waka-points-popup/index.tsx +366 -0
- package/src/components/waka-power-up/index.tsx +1155 -0
- package/src/components/waka-presence-indicator/index.tsx +1181 -0
- package/src/components/waka-pricing-table/index.tsx +755 -0
- package/src/components/waka-product-card/index.tsx +786 -0
- package/src/components/waka-progress-onboarding/index.tsx +878 -0
- package/src/components/waka-pull-to-refresh/index.tsx +451 -0
- package/src/components/waka-qrcode/index.tsx +232 -0
- package/src/components/waka-quest-card/index.tsx +1275 -0
- package/src/components/waka-quota-bar/index.tsx +693 -0
- package/src/components/waka-radar-score/index.tsx +512 -0
- package/src/components/waka-rank-badge/index.tsx +813 -0
- package/src/components/waka-rating-input/index.tsx +560 -0
- package/src/components/waka-reaction-picker/index.tsx +1062 -0
- package/src/components/waka-region-map/index.tsx +730 -0
- package/src/components/waka-resource-gauge/index.tsx +654 -0
- package/src/components/waka-resource-pool/index.tsx +1035 -0
- package/src/components/waka-rich-text-editor/index.tsx +594 -0
- package/src/components/waka-rollback-slider/index.tsx +891 -0
- package/src/components/waka-sankey-diagram/index.tsx +1032 -0
- package/src/components/waka-schedule-picker/index.tsx +1060 -0
- package/src/components/waka-scratch-card/index.tsx +914 -0
- package/src/components/waka-season-pass/index.tsx +886 -0
- package/src/components/waka-security-score/index.tsx +1126 -0
- package/src/components/waka-segmented-control/index.tsx +238 -0
- package/src/components/waka-server-rack/index.tsx +764 -0
- package/src/components/waka-session-manager/index.tsx +815 -0
- package/src/components/waka-signature-pad/index.tsx +744 -0
- package/src/components/waka-skeleton-wave/index.tsx +454 -0
- package/src/components/waka-skill-tree/index.tsx +1031 -0
- package/src/components/waka-sla-tracker/index.tsx +798 -0
- package/src/components/waka-slider-range/index.tsx +765 -0
- package/src/components/waka-spin-wheel/index.tsx +671 -0
- package/src/components/waka-spinner/index.tsx +284 -0
- package/src/components/waka-spotlight/index.tsx +410 -0
- package/src/components/waka-stat/index.tsx +428 -0
- package/src/components/waka-stats-hexagon/index.tsx +824 -0
- package/src/components/waka-status-matrix/index.tsx +565 -0
- package/src/components/waka-stepper/index.tsx +489 -0
- package/src/components/waka-streak-counter/index.tsx +334 -0
- package/src/components/waka-success-explosion/index.tsx +453 -0
- package/src/components/waka-swipe-card/index.tsx +574 -0
- package/src/components/waka-tabs-morph/index.tsx +509 -0
- package/src/components/waka-tag-input/index.tsx +877 -0
- package/src/components/waka-team-banner/index.tsx +1183 -0
- package/src/components/waka-terminal-output/index.tsx +836 -0
- package/src/components/waka-theme-creator/index.tsx +762 -0
- package/src/components/waka-theme-manager/index.tsx +654 -0
- package/src/components/waka-thread-view/index.tsx +874 -0
- package/src/components/waka-tilt-card/index.tsx +250 -0
- package/src/components/waka-time-picker/index.tsx +479 -0
- package/src/components/waka-timeline/index.tsx +385 -0
- package/src/components/waka-tooltip-tour/index.tsx +855 -0
- package/src/components/waka-tour-guide/index.tsx +920 -0
- package/src/components/waka-tournament-bracket/index.tsx +1276 -0
- package/src/components/waka-tree/index.tsx +557 -0
- package/src/components/waka-treemap-chart/index.tsx +1031 -0
- package/src/components/waka-two-factor-setup/index.tsx +995 -0
- package/src/components/waka-typewriter/index.tsx +566 -0
- package/src/components/waka-typing-indicator/index.tsx +649 -0
- package/src/components/waka-versus-card/index.tsx +1026 -0
- package/src/components/waka-video/index.tsx +557 -0
- package/src/components/waka-video-call/index.tsx +1087 -0
- package/src/components/waka-virtual-list/index.tsx +327 -0
- package/src/components/waka-voice-message/index.tsx +1019 -0
- package/src/components/waka-welcome-modal/index.tsx +790 -0
- package/src/components/waka-xp-bar/index.tsx +799 -0
|
@@ -0,0 +1,1016 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { cn } from "../../utils/cn"
|
|
5
|
+
import { Button } from "../../components/button"
|
|
6
|
+
import { Input } from "../../components/input"
|
|
7
|
+
import { Badge } from "../../components/badge"
|
|
8
|
+
import { Textarea } from "../../components/textarea"
|
|
9
|
+
import {
|
|
10
|
+
Select,
|
|
11
|
+
SelectContent,
|
|
12
|
+
SelectItem,
|
|
13
|
+
SelectTrigger,
|
|
14
|
+
SelectValue,
|
|
15
|
+
} from "../../components/select"
|
|
16
|
+
import {
|
|
17
|
+
Sheet,
|
|
18
|
+
SheetContent,
|
|
19
|
+
SheetHeader,
|
|
20
|
+
SheetTitle,
|
|
21
|
+
SheetDescription,
|
|
22
|
+
SheetFooter,
|
|
23
|
+
} from "../../components/sheet"
|
|
24
|
+
import {
|
|
25
|
+
Dialog,
|
|
26
|
+
DialogContent,
|
|
27
|
+
DialogHeader,
|
|
28
|
+
DialogTitle,
|
|
29
|
+
DialogDescription,
|
|
30
|
+
DialogFooter,
|
|
31
|
+
} from "../../components/dialog"
|
|
32
|
+
import {
|
|
33
|
+
DropdownMenu,
|
|
34
|
+
DropdownMenuContent,
|
|
35
|
+
DropdownMenuItem,
|
|
36
|
+
DropdownMenuSeparator,
|
|
37
|
+
DropdownMenuTrigger,
|
|
38
|
+
} from "../../components/dropdown-menu"
|
|
39
|
+
import {
|
|
40
|
+
Tooltip,
|
|
41
|
+
TooltipContent,
|
|
42
|
+
TooltipProvider,
|
|
43
|
+
TooltipTrigger,
|
|
44
|
+
} from "../../components/tooltip"
|
|
45
|
+
import {
|
|
46
|
+
AlertTriangle,
|
|
47
|
+
Check,
|
|
48
|
+
CheckCircle,
|
|
49
|
+
ChevronDown,
|
|
50
|
+
ChevronRight,
|
|
51
|
+
Copy,
|
|
52
|
+
Download,
|
|
53
|
+
Edit3,
|
|
54
|
+
Filter,
|
|
55
|
+
Flag,
|
|
56
|
+
Languages,
|
|
57
|
+
Loader2,
|
|
58
|
+
MoreHorizontal,
|
|
59
|
+
Plus,
|
|
60
|
+
Search,
|
|
61
|
+
Trash2,
|
|
62
|
+
Upload,
|
|
63
|
+
X,
|
|
64
|
+
XCircle,
|
|
65
|
+
} from "lucide-react"
|
|
66
|
+
|
|
67
|
+
// Types
|
|
68
|
+
export interface TranslationValue {
|
|
69
|
+
value: string
|
|
70
|
+
updatedAt?: Date
|
|
71
|
+
comment?: string
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface TranslationEntry {
|
|
75
|
+
key: string
|
|
76
|
+
translations: Record<string, TranslationValue>
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface LanguageConfig {
|
|
80
|
+
code: string
|
|
81
|
+
label: string
|
|
82
|
+
flag?: string
|
|
83
|
+
isSource?: boolean
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface I18nEditorConfig {
|
|
87
|
+
/** Langues disponibles */
|
|
88
|
+
languages: LanguageConfig[]
|
|
89
|
+
/** Code de la langue source */
|
|
90
|
+
sourceLanguage: string
|
|
91
|
+
/** Séparateur pour les clés (ex: ".") */
|
|
92
|
+
keyPathSeparator?: string
|
|
93
|
+
/** Activer l'auto-save */
|
|
94
|
+
autoSave?: boolean
|
|
95
|
+
/** Délai de debounce pour l'auto-save en ms */
|
|
96
|
+
saveDebounceMs?: number
|
|
97
|
+
/** Patterns de placeholders à valider */
|
|
98
|
+
placeholderPatterns?: string[]
|
|
99
|
+
/** Ratio max de longueur */
|
|
100
|
+
maxLengthRatio?: number
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface I18nEditorProps {
|
|
104
|
+
/** Configuration de l'éditeur */
|
|
105
|
+
config: I18nEditorConfig
|
|
106
|
+
/** Données de traduction initiales */
|
|
107
|
+
translations: TranslationEntry[]
|
|
108
|
+
/** Callback quand une traduction change */
|
|
109
|
+
onChange?: (key: string, language: string, value: string) => void
|
|
110
|
+
/** Callback pour sauvegarder */
|
|
111
|
+
onSave?: (translations: TranslationEntry[]) => Promise<void>
|
|
112
|
+
/** Callback pour ajouter une clé */
|
|
113
|
+
onAddKey?: (key: string) => Promise<void>
|
|
114
|
+
/** Callback pour supprimer une clé */
|
|
115
|
+
onDeleteKey?: (key: string) => Promise<void>
|
|
116
|
+
/** Callback pour ajouter une langue */
|
|
117
|
+
onAddLanguage?: (language: LanguageConfig) => Promise<void>
|
|
118
|
+
/** Callback pour exporter */
|
|
119
|
+
onExport?: (language?: string) => Promise<void>
|
|
120
|
+
/** Callback pour importer */
|
|
121
|
+
onImport?: (file: File, language: string, strategy: "overwrite" | "merge") => Promise<void>
|
|
122
|
+
/** État de chargement */
|
|
123
|
+
isLoading?: boolean
|
|
124
|
+
/** État de sauvegarde */
|
|
125
|
+
isSaving?: boolean
|
|
126
|
+
/** Classe CSS */
|
|
127
|
+
className?: string
|
|
128
|
+
/** Titre */
|
|
129
|
+
title?: string
|
|
130
|
+
/** Description */
|
|
131
|
+
description?: string
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
type TranslationStatus = "valid" | "missing" | "identical" | "warning"
|
|
135
|
+
type FilterType = "all" | "missing" | "incomplete" | "identical"
|
|
136
|
+
|
|
137
|
+
// Helper functions
|
|
138
|
+
function getTranslationStatus(
|
|
139
|
+
entry: TranslationEntry,
|
|
140
|
+
targetLang: string,
|
|
141
|
+
sourceLang: string
|
|
142
|
+
): TranslationStatus {
|
|
143
|
+
const sourceValue = entry.translations[sourceLang]?.value || ""
|
|
144
|
+
const targetValue = entry.translations[targetLang]?.value
|
|
145
|
+
|
|
146
|
+
if (!targetValue || targetValue.trim() === "") {
|
|
147
|
+
return "missing"
|
|
148
|
+
}
|
|
149
|
+
if (targetValue === sourceValue) {
|
|
150
|
+
return "identical"
|
|
151
|
+
}
|
|
152
|
+
return "valid"
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function getOverallStatus(
|
|
156
|
+
entry: TranslationEntry,
|
|
157
|
+
languages: string[],
|
|
158
|
+
sourceLang: string
|
|
159
|
+
): TranslationStatus {
|
|
160
|
+
const statuses = languages
|
|
161
|
+
.filter(lang => lang !== sourceLang)
|
|
162
|
+
.map(lang => getTranslationStatus(entry, lang, sourceLang))
|
|
163
|
+
|
|
164
|
+
if (statuses.includes("missing")) return "missing"
|
|
165
|
+
if (statuses.includes("identical")) return "identical"
|
|
166
|
+
return "valid"
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function validatePlaceholders(
|
|
170
|
+
source: string,
|
|
171
|
+
target: string,
|
|
172
|
+
patterns: string[]
|
|
173
|
+
): { valid: boolean; missing: string[]; extra: string[] } {
|
|
174
|
+
const extractMatches = (text: string) => {
|
|
175
|
+
const matches: string[] = []
|
|
176
|
+
for (const pattern of patterns) {
|
|
177
|
+
const regex = new RegExp(pattern, "g")
|
|
178
|
+
let match
|
|
179
|
+
while ((match = regex.exec(text)) !== null) {
|
|
180
|
+
matches.push(match[0])
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return matches
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const sourceMatches = extractMatches(source)
|
|
187
|
+
const targetMatches = extractMatches(target)
|
|
188
|
+
|
|
189
|
+
const missing = sourceMatches.filter(m => !targetMatches.includes(m))
|
|
190
|
+
const extra = targetMatches.filter(m => !sourceMatches.includes(m))
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
valid: missing.length === 0 && extra.length === 0,
|
|
194
|
+
missing,
|
|
195
|
+
extra,
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function groupByPrefix(
|
|
200
|
+
entries: TranslationEntry[],
|
|
201
|
+
separator: string
|
|
202
|
+
): Map<string, TranslationEntry[]> {
|
|
203
|
+
const groups = new Map<string, TranslationEntry[]>()
|
|
204
|
+
|
|
205
|
+
for (const entry of entries) {
|
|
206
|
+
const parts = entry.key.split(separator)
|
|
207
|
+
const prefix = parts.length > 1 ? parts[0] : "__root__"
|
|
208
|
+
|
|
209
|
+
if (!groups.has(prefix)) {
|
|
210
|
+
groups.set(prefix, [])
|
|
211
|
+
}
|
|
212
|
+
groups.get(prefix)!.push(entry)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return groups
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Status Badge Component
|
|
219
|
+
function StatusBadge({ status }: { status: TranslationStatus }) {
|
|
220
|
+
const config = {
|
|
221
|
+
valid: { label: "OK", variant: "default" as const, icon: CheckCircle, className: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300" },
|
|
222
|
+
missing: { label: "Manquant", variant: "destructive" as const, icon: XCircle, className: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300" },
|
|
223
|
+
identical: { label: "Identique", variant: "secondary" as const, icon: AlertTriangle, className: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300" },
|
|
224
|
+
warning: { label: "Attention", variant: "secondary" as const, icon: AlertTriangle, className: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300" },
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const { label, icon: Icon, className } = config[status]
|
|
228
|
+
|
|
229
|
+
return (
|
|
230
|
+
<Badge variant="outline" className={cn("gap-1 text-xs", className)}>
|
|
231
|
+
<Icon className="h-3 w-3" />
|
|
232
|
+
{label}
|
|
233
|
+
</Badge>
|
|
234
|
+
)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Inline Editor Component
|
|
238
|
+
function InlineEditor({
|
|
239
|
+
value,
|
|
240
|
+
onChange,
|
|
241
|
+
onBlur,
|
|
242
|
+
placeholder,
|
|
243
|
+
status,
|
|
244
|
+
disabled,
|
|
245
|
+
}: {
|
|
246
|
+
value: string
|
|
247
|
+
onChange: (value: string) => void
|
|
248
|
+
onBlur?: () => void
|
|
249
|
+
placeholder?: string
|
|
250
|
+
status?: TranslationStatus
|
|
251
|
+
disabled?: boolean
|
|
252
|
+
}) {
|
|
253
|
+
const [localValue, setLocalValue] = React.useState(value)
|
|
254
|
+
|
|
255
|
+
React.useEffect(() => {
|
|
256
|
+
setLocalValue(value)
|
|
257
|
+
}, [value])
|
|
258
|
+
|
|
259
|
+
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
260
|
+
setLocalValue(e.target.value)
|
|
261
|
+
onChange(e.target.value)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return (
|
|
265
|
+
<Textarea
|
|
266
|
+
value={localValue}
|
|
267
|
+
onChange={handleChange}
|
|
268
|
+
onBlur={onBlur}
|
|
269
|
+
placeholder={placeholder}
|
|
270
|
+
disabled={disabled}
|
|
271
|
+
className={cn(
|
|
272
|
+
"min-h-[60px] resize-none text-sm",
|
|
273
|
+
status === "missing" && "border-red-300 dark:border-red-700",
|
|
274
|
+
status === "identical" && "border-yellow-300 dark:border-yellow-700"
|
|
275
|
+
)}
|
|
276
|
+
/>
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Main Component
|
|
281
|
+
export function WakaI18nEditor({
|
|
282
|
+
config,
|
|
283
|
+
translations: initialTranslations,
|
|
284
|
+
onChange,
|
|
285
|
+
onSave,
|
|
286
|
+
onAddKey,
|
|
287
|
+
onDeleteKey,
|
|
288
|
+
onAddLanguage,
|
|
289
|
+
onExport,
|
|
290
|
+
onImport,
|
|
291
|
+
isLoading = false,
|
|
292
|
+
isSaving = false,
|
|
293
|
+
className,
|
|
294
|
+
title = "Translation Editor",
|
|
295
|
+
description = "Gérez vos traductions i18n",
|
|
296
|
+
}: I18nEditorProps) {
|
|
297
|
+
const [translations, setTranslations] = React.useState<TranslationEntry[]>(initialTranslations)
|
|
298
|
+
const [searchQuery, setSearchQuery] = React.useState("")
|
|
299
|
+
const [activeFilter, setActiveFilter] = React.useState<FilterType>("all")
|
|
300
|
+
const [collapsedGroups, setCollapsedGroups] = React.useState<Set<string>>(new Set())
|
|
301
|
+
const [selectedEntry, setSelectedEntry] = React.useState<TranslationEntry | null>(null)
|
|
302
|
+
const [isEditorOpen, setIsEditorOpen] = React.useState(false)
|
|
303
|
+
const [isAddKeyOpen, setIsAddKeyOpen] = React.useState(false)
|
|
304
|
+
const [isAddLanguageOpen, setIsAddLanguageOpen] = React.useState(false)
|
|
305
|
+
const [isImportOpen, setIsImportOpen] = React.useState(false)
|
|
306
|
+
const [newKey, setNewKey] = React.useState("")
|
|
307
|
+
const [newLanguage, setNewLanguage] = React.useState({ code: "", label: "", flag: "" })
|
|
308
|
+
const [importFile, setImportFile] = React.useState<File | null>(null)
|
|
309
|
+
const [importLanguage, setImportLanguage] = React.useState("")
|
|
310
|
+
const [importStrategy, setImportStrategy] = React.useState<"overwrite" | "merge">("merge")
|
|
311
|
+
const [pendingChanges, setPendingChanges] = React.useState<Map<string, Map<string, string>>>(new Map())
|
|
312
|
+
const saveTimeoutRef = React.useRef<NodeJS.Timeout | null>(null)
|
|
313
|
+
|
|
314
|
+
const {
|
|
315
|
+
languages,
|
|
316
|
+
sourceLanguage,
|
|
317
|
+
keyPathSeparator = ".",
|
|
318
|
+
autoSave = true,
|
|
319
|
+
saveDebounceMs = 300,
|
|
320
|
+
placeholderPatterns = ["{{.*?}}", "{.*?}", "%s", "%d"],
|
|
321
|
+
} = config
|
|
322
|
+
|
|
323
|
+
const targetLanguages = languages.filter(l => l.code !== sourceLanguage)
|
|
324
|
+
|
|
325
|
+
// Update translations when props change
|
|
326
|
+
React.useEffect(() => {
|
|
327
|
+
setTranslations(initialTranslations)
|
|
328
|
+
}, [initialTranslations])
|
|
329
|
+
|
|
330
|
+
// Auto-save logic
|
|
331
|
+
React.useEffect(() => {
|
|
332
|
+
if (autoSave && pendingChanges.size > 0 && onSave) {
|
|
333
|
+
if (saveTimeoutRef.current) {
|
|
334
|
+
clearTimeout(saveTimeoutRef.current)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
saveTimeoutRef.current = setTimeout(async () => {
|
|
338
|
+
await onSave(translations)
|
|
339
|
+
setPendingChanges(new Map())
|
|
340
|
+
}, saveDebounceMs)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return () => {
|
|
344
|
+
if (saveTimeoutRef.current) {
|
|
345
|
+
clearTimeout(saveTimeoutRef.current)
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}, [pendingChanges, autoSave, saveDebounceMs, onSave, translations])
|
|
349
|
+
|
|
350
|
+
// Filter and search logic
|
|
351
|
+
const filteredTranslations = React.useMemo(() => {
|
|
352
|
+
let result = translations
|
|
353
|
+
|
|
354
|
+
// Search filter
|
|
355
|
+
if (searchQuery) {
|
|
356
|
+
const query = searchQuery.toLowerCase()
|
|
357
|
+
result = result.filter(entry => {
|
|
358
|
+
if (entry.key.toLowerCase().includes(query)) return true
|
|
359
|
+
for (const lang of Object.keys(entry.translations)) {
|
|
360
|
+
if (entry.translations[lang]?.value?.toLowerCase().includes(query)) return true
|
|
361
|
+
}
|
|
362
|
+
return false
|
|
363
|
+
})
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Status filter
|
|
367
|
+
if (activeFilter !== "all") {
|
|
368
|
+
result = result.filter(entry => {
|
|
369
|
+
const status = getOverallStatus(entry, languages.map(l => l.code), sourceLanguage)
|
|
370
|
+
switch (activeFilter) {
|
|
371
|
+
case "missing":
|
|
372
|
+
return status === "missing"
|
|
373
|
+
case "incomplete":
|
|
374
|
+
return status === "missing" || status === "identical"
|
|
375
|
+
case "identical":
|
|
376
|
+
return status === "identical"
|
|
377
|
+
default:
|
|
378
|
+
return true
|
|
379
|
+
}
|
|
380
|
+
})
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return result
|
|
384
|
+
}, [translations, searchQuery, activeFilter, languages, sourceLanguage])
|
|
385
|
+
|
|
386
|
+
// Group translations
|
|
387
|
+
const groupedTranslations = React.useMemo(() => {
|
|
388
|
+
return groupByPrefix(filteredTranslations, keyPathSeparator)
|
|
389
|
+
}, [filteredTranslations, keyPathSeparator])
|
|
390
|
+
|
|
391
|
+
// Handlers
|
|
392
|
+
const handleTranslationChange = (key: string, language: string, value: string) => {
|
|
393
|
+
setTranslations(prev =>
|
|
394
|
+
prev.map(entry => {
|
|
395
|
+
if (entry.key === key) {
|
|
396
|
+
return {
|
|
397
|
+
...entry,
|
|
398
|
+
translations: {
|
|
399
|
+
...entry.translations,
|
|
400
|
+
[language]: {
|
|
401
|
+
...entry.translations[language],
|
|
402
|
+
value,
|
|
403
|
+
updatedAt: new Date(),
|
|
404
|
+
},
|
|
405
|
+
},
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return entry
|
|
409
|
+
})
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
// Track pending changes
|
|
413
|
+
setPendingChanges(prev => {
|
|
414
|
+
const newChanges = new Map(prev)
|
|
415
|
+
if (!newChanges.has(key)) {
|
|
416
|
+
newChanges.set(key, new Map())
|
|
417
|
+
}
|
|
418
|
+
newChanges.get(key)!.set(language, value)
|
|
419
|
+
return newChanges
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
onChange?.(key, language, value)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const handleAddKey = async () => {
|
|
426
|
+
if (!newKey.trim()) return
|
|
427
|
+
|
|
428
|
+
await onAddKey?.(newKey)
|
|
429
|
+
setNewKey("")
|
|
430
|
+
setIsAddKeyOpen(false)
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const handleDeleteKey = async (key: string) => {
|
|
434
|
+
await onDeleteKey?.(key)
|
|
435
|
+
setTranslations(prev => prev.filter(e => e.key !== key))
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const handleAddLanguage = async () => {
|
|
439
|
+
if (!newLanguage.code || !newLanguage.label) return
|
|
440
|
+
|
|
441
|
+
await onAddLanguage?.(newLanguage)
|
|
442
|
+
setNewLanguage({ code: "", label: "", flag: "" })
|
|
443
|
+
setIsAddLanguageOpen(false)
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const handleImport = async () => {
|
|
447
|
+
if (!importFile || !importLanguage) return
|
|
448
|
+
|
|
449
|
+
await onImport?.(importFile, importLanguage, importStrategy)
|
|
450
|
+
setImportFile(null)
|
|
451
|
+
setImportLanguage("")
|
|
452
|
+
setIsImportOpen(false)
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const handleCopyKey = (key: string) => {
|
|
456
|
+
navigator.clipboard.writeText(key)
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const toggleGroup = (group: string) => {
|
|
460
|
+
setCollapsedGroups(prev => {
|
|
461
|
+
const newSet = new Set(prev)
|
|
462
|
+
if (newSet.has(group)) {
|
|
463
|
+
newSet.delete(group)
|
|
464
|
+
} else {
|
|
465
|
+
newSet.add(group)
|
|
466
|
+
}
|
|
467
|
+
return newSet
|
|
468
|
+
})
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Stats
|
|
472
|
+
const stats = React.useMemo(() => {
|
|
473
|
+
let total = translations.length
|
|
474
|
+
let missing = 0
|
|
475
|
+
let identical = 0
|
|
476
|
+
let valid = 0
|
|
477
|
+
|
|
478
|
+
for (const entry of translations) {
|
|
479
|
+
const status = getOverallStatus(entry, languages.map(l => l.code), sourceLanguage)
|
|
480
|
+
if (status === "missing") missing++
|
|
481
|
+
else if (status === "identical") identical++
|
|
482
|
+
else valid++
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
return { total, missing, identical, valid }
|
|
486
|
+
}, [translations, languages, sourceLanguage])
|
|
487
|
+
|
|
488
|
+
if (isLoading) {
|
|
489
|
+
return (
|
|
490
|
+
<div className={cn("flex items-center justify-center py-12", className)}>
|
|
491
|
+
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
492
|
+
</div>
|
|
493
|
+
)
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
return (
|
|
497
|
+
<TooltipProvider>
|
|
498
|
+
<div className={cn("flex flex-col h-full", className)}>
|
|
499
|
+
{/* Header */}
|
|
500
|
+
<div className="flex flex-col gap-4 p-4 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
|
501
|
+
<div className="flex items-center justify-between">
|
|
502
|
+
<div>
|
|
503
|
+
<h2 className="text-2xl font-bold flex items-center gap-2">
|
|
504
|
+
<Languages className="h-6 w-6" />
|
|
505
|
+
{title}
|
|
506
|
+
</h2>
|
|
507
|
+
<p className="text-sm text-muted-foreground">{description}</p>
|
|
508
|
+
</div>
|
|
509
|
+
|
|
510
|
+
{/* Actions */}
|
|
511
|
+
<div className="flex items-center gap-2">
|
|
512
|
+
<Button
|
|
513
|
+
variant="outline"
|
|
514
|
+
size="sm"
|
|
515
|
+
onClick={() => setIsAddKeyOpen(true)}
|
|
516
|
+
>
|
|
517
|
+
<Plus className="h-4 w-4 mr-1" />
|
|
518
|
+
Ajouter une clé
|
|
519
|
+
</Button>
|
|
520
|
+
<Button
|
|
521
|
+
variant="outline"
|
|
522
|
+
size="sm"
|
|
523
|
+
onClick={() => setIsAddLanguageOpen(true)}
|
|
524
|
+
>
|
|
525
|
+
<Flag className="h-4 w-4 mr-1" />
|
|
526
|
+
Ajouter une langue
|
|
527
|
+
</Button>
|
|
528
|
+
<DropdownMenu>
|
|
529
|
+
<DropdownMenuTrigger asChild>
|
|
530
|
+
<Button variant="outline" size="sm">
|
|
531
|
+
<MoreHorizontal className="h-4 w-4" />
|
|
532
|
+
</Button>
|
|
533
|
+
</DropdownMenuTrigger>
|
|
534
|
+
<DropdownMenuContent align="end">
|
|
535
|
+
<DropdownMenuItem onClick={() => onExport?.()}>
|
|
536
|
+
<Download className="h-4 w-4 mr-2" />
|
|
537
|
+
Exporter tout
|
|
538
|
+
</DropdownMenuItem>
|
|
539
|
+
{languages.map(lang => (
|
|
540
|
+
<DropdownMenuItem key={lang.code} onClick={() => onExport?.(lang.code)}>
|
|
541
|
+
<Download className="h-4 w-4 mr-2" />
|
|
542
|
+
Exporter {lang.label}
|
|
543
|
+
</DropdownMenuItem>
|
|
544
|
+
))}
|
|
545
|
+
<DropdownMenuSeparator />
|
|
546
|
+
<DropdownMenuItem onClick={() => setIsImportOpen(true)}>
|
|
547
|
+
<Upload className="h-4 w-4 mr-2" />
|
|
548
|
+
Importer
|
|
549
|
+
</DropdownMenuItem>
|
|
550
|
+
</DropdownMenuContent>
|
|
551
|
+
</DropdownMenu>
|
|
552
|
+
|
|
553
|
+
{isSaving && (
|
|
554
|
+
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
555
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
556
|
+
Sauvegarde...
|
|
557
|
+
</div>
|
|
558
|
+
)}
|
|
559
|
+
</div>
|
|
560
|
+
</div>
|
|
561
|
+
|
|
562
|
+
{/* Stats */}
|
|
563
|
+
<div className="flex items-center gap-4 text-sm">
|
|
564
|
+
<span className="text-muted-foreground">
|
|
565
|
+
{stats.total} clés
|
|
566
|
+
</span>
|
|
567
|
+
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
|
|
568
|
+
<CheckCircle className="h-4 w-4" />
|
|
569
|
+
{stats.valid} traduites
|
|
570
|
+
</span>
|
|
571
|
+
<span className="flex items-center gap-1 text-red-600 dark:text-red-400">
|
|
572
|
+
<XCircle className="h-4 w-4" />
|
|
573
|
+
{stats.missing} manquantes
|
|
574
|
+
</span>
|
|
575
|
+
<span className="flex items-center gap-1 text-yellow-600 dark:text-yellow-400">
|
|
576
|
+
<AlertTriangle className="h-4 w-4" />
|
|
577
|
+
{stats.identical} identiques
|
|
578
|
+
</span>
|
|
579
|
+
</div>
|
|
580
|
+
|
|
581
|
+
{/* Search and Filters */}
|
|
582
|
+
<div className="flex items-center gap-4">
|
|
583
|
+
<div className="relative flex-1 max-w-md">
|
|
584
|
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
585
|
+
<Input
|
|
586
|
+
placeholder="Rechercher une clé ou une traduction..."
|
|
587
|
+
value={searchQuery}
|
|
588
|
+
onChange={e => setSearchQuery(e.target.value)}
|
|
589
|
+
className="pl-10"
|
|
590
|
+
/>
|
|
591
|
+
{searchQuery && (
|
|
592
|
+
<button
|
|
593
|
+
onClick={() => setSearchQuery("")}
|
|
594
|
+
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
|
595
|
+
>
|
|
596
|
+
<X className="h-4 w-4" />
|
|
597
|
+
</button>
|
|
598
|
+
)}
|
|
599
|
+
</div>
|
|
600
|
+
|
|
601
|
+
<div className="flex items-center gap-2">
|
|
602
|
+
<Filter className="h-4 w-4 text-muted-foreground" />
|
|
603
|
+
<Select value={activeFilter} onValueChange={(v) => setActiveFilter(v as FilterType)}>
|
|
604
|
+
<SelectTrigger className="w-[180px]">
|
|
605
|
+
<SelectValue />
|
|
606
|
+
</SelectTrigger>
|
|
607
|
+
<SelectContent>
|
|
608
|
+
<SelectItem value="all">Toutes ({stats.total})</SelectItem>
|
|
609
|
+
<SelectItem value="missing">Manquantes ({stats.missing})</SelectItem>
|
|
610
|
+
<SelectItem value="incomplete">Incomplètes ({stats.missing + stats.identical})</SelectItem>
|
|
611
|
+
<SelectItem value="identical">Identiques ({stats.identical})</SelectItem>
|
|
612
|
+
</SelectContent>
|
|
613
|
+
</Select>
|
|
614
|
+
</div>
|
|
615
|
+
</div>
|
|
616
|
+
</div>
|
|
617
|
+
|
|
618
|
+
{/* Table */}
|
|
619
|
+
<div className="flex-1 overflow-auto">
|
|
620
|
+
<table className="w-full border-collapse">
|
|
621
|
+
<thead className="sticky top-0 bg-muted/50 backdrop-blur z-10">
|
|
622
|
+
<tr>
|
|
623
|
+
<th className="text-left p-3 font-medium text-sm border-b w-[280px]">
|
|
624
|
+
Clé
|
|
625
|
+
</th>
|
|
626
|
+
<th className="text-left p-3 font-medium text-sm border-b">
|
|
627
|
+
{languages.find(l => l.code === sourceLanguage)?.label || sourceLanguage} (source)
|
|
628
|
+
</th>
|
|
629
|
+
{targetLanguages.map(lang => (
|
|
630
|
+
<th key={lang.code} className="text-left p-3 font-medium text-sm border-b">
|
|
631
|
+
<span className="flex items-center gap-2">
|
|
632
|
+
{lang.flag && <span>{lang.flag}</span>}
|
|
633
|
+
{lang.label}
|
|
634
|
+
</span>
|
|
635
|
+
</th>
|
|
636
|
+
))}
|
|
637
|
+
<th className="text-center p-3 font-medium text-sm border-b w-[100px]">
|
|
638
|
+
État
|
|
639
|
+
</th>
|
|
640
|
+
<th className="text-center p-3 font-medium text-sm border-b w-[60px]">
|
|
641
|
+
Actions
|
|
642
|
+
</th>
|
|
643
|
+
</tr>
|
|
644
|
+
</thead>
|
|
645
|
+
<tbody>
|
|
646
|
+
{Array.from(groupedTranslations.entries()).map(([group, entries]) => (
|
|
647
|
+
<React.Fragment key={group}>
|
|
648
|
+
{/* Group Header */}
|
|
649
|
+
{group !== "__root__" && (
|
|
650
|
+
<tr className="bg-muted/30">
|
|
651
|
+
<td colSpan={targetLanguages.length + 4} className="p-2">
|
|
652
|
+
<button
|
|
653
|
+
onClick={() => toggleGroup(group)}
|
|
654
|
+
className="flex items-center gap-2 text-sm font-medium hover:text-primary transition-colors"
|
|
655
|
+
>
|
|
656
|
+
{collapsedGroups.has(group) ? (
|
|
657
|
+
<ChevronRight className="h-4 w-4" />
|
|
658
|
+
) : (
|
|
659
|
+
<ChevronDown className="h-4 w-4" />
|
|
660
|
+
)}
|
|
661
|
+
{group}
|
|
662
|
+
<Badge variant="secondary" className="text-xs">
|
|
663
|
+
{entries.length}
|
|
664
|
+
</Badge>
|
|
665
|
+
</button>
|
|
666
|
+
</td>
|
|
667
|
+
</tr>
|
|
668
|
+
)}
|
|
669
|
+
|
|
670
|
+
{/* Group Entries */}
|
|
671
|
+
{!collapsedGroups.has(group) &&
|
|
672
|
+
entries.map(entry => {
|
|
673
|
+
const overallStatus = getOverallStatus(
|
|
674
|
+
entry,
|
|
675
|
+
languages.map(l => l.code),
|
|
676
|
+
sourceLanguage
|
|
677
|
+
)
|
|
678
|
+
|
|
679
|
+
return (
|
|
680
|
+
<tr
|
|
681
|
+
key={entry.key}
|
|
682
|
+
className="border-b hover:bg-muted/30 transition-colors"
|
|
683
|
+
>
|
|
684
|
+
{/* Key */}
|
|
685
|
+
<td className="p-3 font-mono text-sm">
|
|
686
|
+
<div className="flex items-center gap-2">
|
|
687
|
+
<span className="truncate max-w-[200px]" title={entry.key}>
|
|
688
|
+
{entry.key}
|
|
689
|
+
</span>
|
|
690
|
+
<Tooltip>
|
|
691
|
+
<TooltipTrigger asChild>
|
|
692
|
+
<button
|
|
693
|
+
onClick={() => handleCopyKey(entry.key)}
|
|
694
|
+
className="text-muted-foreground hover:text-foreground"
|
|
695
|
+
>
|
|
696
|
+
<Copy className="h-3 w-3" />
|
|
697
|
+
</button>
|
|
698
|
+
</TooltipTrigger>
|
|
699
|
+
<TooltipContent>Copier la clé</TooltipContent>
|
|
700
|
+
</Tooltip>
|
|
701
|
+
</div>
|
|
702
|
+
</td>
|
|
703
|
+
|
|
704
|
+
{/* Source */}
|
|
705
|
+
<td className="p-2">
|
|
706
|
+
<InlineEditor
|
|
707
|
+
value={entry.translations[sourceLanguage]?.value || ""}
|
|
708
|
+
onChange={value => handleTranslationChange(entry.key, sourceLanguage, value)}
|
|
709
|
+
placeholder={`Texte source...`}
|
|
710
|
+
status={entry.translations[sourceLanguage]?.value ? "valid" : "missing"}
|
|
711
|
+
/>
|
|
712
|
+
</td>
|
|
713
|
+
|
|
714
|
+
{/* Target Languages */}
|
|
715
|
+
{targetLanguages.map(lang => {
|
|
716
|
+
const status = getTranslationStatus(entry, lang.code, sourceLanguage)
|
|
717
|
+
return (
|
|
718
|
+
<td key={lang.code} className="p-2">
|
|
719
|
+
<InlineEditor
|
|
720
|
+
value={entry.translations[lang.code]?.value || ""}
|
|
721
|
+
onChange={value => handleTranslationChange(entry.key, lang.code, value)}
|
|
722
|
+
placeholder={`Traduction ${lang.label}...`}
|
|
723
|
+
status={status}
|
|
724
|
+
/>
|
|
725
|
+
</td>
|
|
726
|
+
)
|
|
727
|
+
})}
|
|
728
|
+
|
|
729
|
+
{/* Status */}
|
|
730
|
+
<td className="p-3 text-center">
|
|
731
|
+
<StatusBadge status={overallStatus} />
|
|
732
|
+
</td>
|
|
733
|
+
|
|
734
|
+
{/* Actions */}
|
|
735
|
+
<td className="p-3 text-center">
|
|
736
|
+
<DropdownMenu>
|
|
737
|
+
<DropdownMenuTrigger asChild>
|
|
738
|
+
<Button variant="ghost" size="sm">
|
|
739
|
+
<MoreHorizontal className="h-4 w-4" />
|
|
740
|
+
</Button>
|
|
741
|
+
</DropdownMenuTrigger>
|
|
742
|
+
<DropdownMenuContent align="end">
|
|
743
|
+
<DropdownMenuItem
|
|
744
|
+
onClick={() => {
|
|
745
|
+
setSelectedEntry(entry)
|
|
746
|
+
setIsEditorOpen(true)
|
|
747
|
+
}}
|
|
748
|
+
>
|
|
749
|
+
<Edit3 className="h-4 w-4 mr-2" />
|
|
750
|
+
Éditer
|
|
751
|
+
</DropdownMenuItem>
|
|
752
|
+
<DropdownMenuItem onClick={() => handleCopyKey(entry.key)}>
|
|
753
|
+
<Copy className="h-4 w-4 mr-2" />
|
|
754
|
+
Copier la clé
|
|
755
|
+
</DropdownMenuItem>
|
|
756
|
+
<DropdownMenuSeparator />
|
|
757
|
+
<DropdownMenuItem
|
|
758
|
+
className="text-destructive"
|
|
759
|
+
onClick={() => handleDeleteKey(entry.key)}
|
|
760
|
+
>
|
|
761
|
+
<Trash2 className="h-4 w-4 mr-2" />
|
|
762
|
+
Supprimer
|
|
763
|
+
</DropdownMenuItem>
|
|
764
|
+
</DropdownMenuContent>
|
|
765
|
+
</DropdownMenu>
|
|
766
|
+
</td>
|
|
767
|
+
</tr>
|
|
768
|
+
)
|
|
769
|
+
})}
|
|
770
|
+
</React.Fragment>
|
|
771
|
+
))}
|
|
772
|
+
|
|
773
|
+
{filteredTranslations.length === 0 && (
|
|
774
|
+
<tr>
|
|
775
|
+
<td colSpan={targetLanguages.length + 4} className="p-12 text-center">
|
|
776
|
+
<Languages className="h-12 w-12 mx-auto text-muted-foreground/50 mb-4" />
|
|
777
|
+
<p className="text-muted-foreground">
|
|
778
|
+
{searchQuery || activeFilter !== "all"
|
|
779
|
+
? "Aucune traduction ne correspond à vos critères"
|
|
780
|
+
: "Aucune traduction disponible"}
|
|
781
|
+
</p>
|
|
782
|
+
</td>
|
|
783
|
+
</tr>
|
|
784
|
+
)}
|
|
785
|
+
</tbody>
|
|
786
|
+
</table>
|
|
787
|
+
</div>
|
|
788
|
+
|
|
789
|
+
{/* Editor Drawer */}
|
|
790
|
+
<Sheet open={isEditorOpen} onOpenChange={setIsEditorOpen}>
|
|
791
|
+
<SheetContent className="w-[500px] sm:max-w-[500px]">
|
|
792
|
+
<SheetHeader>
|
|
793
|
+
<SheetTitle>Éditer la traduction</SheetTitle>
|
|
794
|
+
<SheetDescription>
|
|
795
|
+
{selectedEntry?.key}
|
|
796
|
+
</SheetDescription>
|
|
797
|
+
</SheetHeader>
|
|
798
|
+
|
|
799
|
+
{selectedEntry && (
|
|
800
|
+
<div className="py-6 space-y-6">
|
|
801
|
+
{/* Source */}
|
|
802
|
+
<div className="space-y-2">
|
|
803
|
+
<label className="text-sm font-medium">
|
|
804
|
+
{languages.find(l => l.code === sourceLanguage)?.label} (source)
|
|
805
|
+
</label>
|
|
806
|
+
<Textarea
|
|
807
|
+
value={selectedEntry.translations[sourceLanguage]?.value || ""}
|
|
808
|
+
onChange={e => {
|
|
809
|
+
handleTranslationChange(selectedEntry.key, sourceLanguage, e.target.value)
|
|
810
|
+
setSelectedEntry({
|
|
811
|
+
...selectedEntry,
|
|
812
|
+
translations: {
|
|
813
|
+
...selectedEntry.translations,
|
|
814
|
+
[sourceLanguage]: {
|
|
815
|
+
...selectedEntry.translations[sourceLanguage],
|
|
816
|
+
value: e.target.value,
|
|
817
|
+
},
|
|
818
|
+
},
|
|
819
|
+
})
|
|
820
|
+
}}
|
|
821
|
+
placeholder="Texte source..."
|
|
822
|
+
className="min-h-[100px]"
|
|
823
|
+
/>
|
|
824
|
+
</div>
|
|
825
|
+
|
|
826
|
+
{/* Targets */}
|
|
827
|
+
{languages.filter(l => l.code !== sourceLanguage).map(lang => (
|
|
828
|
+
<div key={lang.code} className="space-y-2">
|
|
829
|
+
<label className="text-sm font-medium flex items-center gap-2">
|
|
830
|
+
{lang.flag && <span>{lang.flag}</span>}
|
|
831
|
+
{lang.label}
|
|
832
|
+
</label>
|
|
833
|
+
<Textarea
|
|
834
|
+
value={selectedEntry.translations[lang.code]?.value || ""}
|
|
835
|
+
onChange={e => {
|
|
836
|
+
handleTranslationChange(selectedEntry.key, lang.code, e.target.value)
|
|
837
|
+
setSelectedEntry({
|
|
838
|
+
...selectedEntry,
|
|
839
|
+
translations: {
|
|
840
|
+
...selectedEntry.translations,
|
|
841
|
+
[lang.code]: {
|
|
842
|
+
...selectedEntry.translations[lang.code],
|
|
843
|
+
value: e.target.value,
|
|
844
|
+
},
|
|
845
|
+
},
|
|
846
|
+
})
|
|
847
|
+
}}
|
|
848
|
+
placeholder={`Traduction ${lang.label}...`}
|
|
849
|
+
className="min-h-[100px]"
|
|
850
|
+
/>
|
|
851
|
+
</div>
|
|
852
|
+
))}
|
|
853
|
+
|
|
854
|
+
{/* Comment */}
|
|
855
|
+
<div className="space-y-2">
|
|
856
|
+
<label className="text-sm font-medium">Commentaire (optionnel)</label>
|
|
857
|
+
<Textarea
|
|
858
|
+
placeholder="Contexte ou indication pour la traduction..."
|
|
859
|
+
className="min-h-[80px]"
|
|
860
|
+
/>
|
|
861
|
+
</div>
|
|
862
|
+
</div>
|
|
863
|
+
)}
|
|
864
|
+
|
|
865
|
+
<SheetFooter>
|
|
866
|
+
<Button variant="outline" onClick={() => setIsEditorOpen(false)}>
|
|
867
|
+
Fermer
|
|
868
|
+
</Button>
|
|
869
|
+
</SheetFooter>
|
|
870
|
+
</SheetContent>
|
|
871
|
+
</Sheet>
|
|
872
|
+
|
|
873
|
+
{/* Add Key Dialog */}
|
|
874
|
+
<Dialog open={isAddKeyOpen} onOpenChange={setIsAddKeyOpen}>
|
|
875
|
+
<DialogContent>
|
|
876
|
+
<DialogHeader>
|
|
877
|
+
<DialogTitle>Ajouter une clé de traduction</DialogTitle>
|
|
878
|
+
<DialogDescription>
|
|
879
|
+
Entrez la clé de traduction (ex: common.buttons.submit)
|
|
880
|
+
</DialogDescription>
|
|
881
|
+
</DialogHeader>
|
|
882
|
+
|
|
883
|
+
<div className="py-4">
|
|
884
|
+
<Input
|
|
885
|
+
value={newKey}
|
|
886
|
+
onChange={e => setNewKey(e.target.value)}
|
|
887
|
+
placeholder="namespace.key.name"
|
|
888
|
+
/>
|
|
889
|
+
</div>
|
|
890
|
+
|
|
891
|
+
<DialogFooter>
|
|
892
|
+
<Button variant="outline" onClick={() => setIsAddKeyOpen(false)}>
|
|
893
|
+
Annuler
|
|
894
|
+
</Button>
|
|
895
|
+
<Button onClick={handleAddKey}>
|
|
896
|
+
Ajouter
|
|
897
|
+
</Button>
|
|
898
|
+
</DialogFooter>
|
|
899
|
+
</DialogContent>
|
|
900
|
+
</Dialog>
|
|
901
|
+
|
|
902
|
+
{/* Add Language Dialog */}
|
|
903
|
+
<Dialog open={isAddLanguageOpen} onOpenChange={setIsAddLanguageOpen}>
|
|
904
|
+
<DialogContent>
|
|
905
|
+
<DialogHeader>
|
|
906
|
+
<DialogTitle>Ajouter une langue</DialogTitle>
|
|
907
|
+
<DialogDescription>
|
|
908
|
+
Configurez la nouvelle langue à ajouter
|
|
909
|
+
</DialogDescription>
|
|
910
|
+
</DialogHeader>
|
|
911
|
+
|
|
912
|
+
<div className="py-4 space-y-4">
|
|
913
|
+
<div className="space-y-2">
|
|
914
|
+
<label className="text-sm font-medium">Code (ex: de, it, pt)</label>
|
|
915
|
+
<Input
|
|
916
|
+
value={newLanguage.code}
|
|
917
|
+
onChange={e => setNewLanguage({ ...newLanguage, code: e.target.value })}
|
|
918
|
+
placeholder="de"
|
|
919
|
+
/>
|
|
920
|
+
</div>
|
|
921
|
+
<div className="space-y-2">
|
|
922
|
+
<label className="text-sm font-medium">Label</label>
|
|
923
|
+
<Input
|
|
924
|
+
value={newLanguage.label}
|
|
925
|
+
onChange={e => setNewLanguage({ ...newLanguage, label: e.target.value })}
|
|
926
|
+
placeholder="Allemand"
|
|
927
|
+
/>
|
|
928
|
+
</div>
|
|
929
|
+
<div className="space-y-2">
|
|
930
|
+
<label className="text-sm font-medium">Emoji drapeau (optionnel)</label>
|
|
931
|
+
<Input
|
|
932
|
+
value={newLanguage.flag}
|
|
933
|
+
onChange={e => setNewLanguage({ ...newLanguage, flag: e.target.value })}
|
|
934
|
+
placeholder="🇩🇪"
|
|
935
|
+
/>
|
|
936
|
+
</div>
|
|
937
|
+
</div>
|
|
938
|
+
|
|
939
|
+
<DialogFooter>
|
|
940
|
+
<Button variant="outline" onClick={() => setIsAddLanguageOpen(false)}>
|
|
941
|
+
Annuler
|
|
942
|
+
</Button>
|
|
943
|
+
<Button onClick={handleAddLanguage}>
|
|
944
|
+
Ajouter
|
|
945
|
+
</Button>
|
|
946
|
+
</DialogFooter>
|
|
947
|
+
</DialogContent>
|
|
948
|
+
</Dialog>
|
|
949
|
+
|
|
950
|
+
{/* Import Dialog */}
|
|
951
|
+
<Dialog open={isImportOpen} onOpenChange={setIsImportOpen}>
|
|
952
|
+
<DialogContent>
|
|
953
|
+
<DialogHeader>
|
|
954
|
+
<DialogTitle>Importer des traductions</DialogTitle>
|
|
955
|
+
<DialogDescription>
|
|
956
|
+
Sélectionnez un fichier JSON à importer
|
|
957
|
+
</DialogDescription>
|
|
958
|
+
</DialogHeader>
|
|
959
|
+
|
|
960
|
+
<div className="py-4 space-y-4">
|
|
961
|
+
<div className="space-y-2">
|
|
962
|
+
<label className="text-sm font-medium">Fichier JSON</label>
|
|
963
|
+
<Input
|
|
964
|
+
type="file"
|
|
965
|
+
accept=".json"
|
|
966
|
+
onChange={e => setImportFile(e.target.files?.[0] || null)}
|
|
967
|
+
/>
|
|
968
|
+
</div>
|
|
969
|
+
|
|
970
|
+
<div className="space-y-2">
|
|
971
|
+
<label className="text-sm font-medium">Langue cible</label>
|
|
972
|
+
<Select value={importLanguage} onValueChange={setImportLanguage}>
|
|
973
|
+
<SelectTrigger>
|
|
974
|
+
<SelectValue placeholder="Sélectionner une langue" />
|
|
975
|
+
</SelectTrigger>
|
|
976
|
+
<SelectContent>
|
|
977
|
+
{languages.map(lang => (
|
|
978
|
+
<SelectItem key={lang.code} value={lang.code}>
|
|
979
|
+
{lang.flag && <span className="mr-2">{lang.flag}</span>}
|
|
980
|
+
{lang.label}
|
|
981
|
+
</SelectItem>
|
|
982
|
+
))}
|
|
983
|
+
</SelectContent>
|
|
984
|
+
</Select>
|
|
985
|
+
</div>
|
|
986
|
+
|
|
987
|
+
<div className="space-y-2">
|
|
988
|
+
<label className="text-sm font-medium">Stratégie</label>
|
|
989
|
+
<Select value={importStrategy} onValueChange={(v) => setImportStrategy(v as "overwrite" | "merge")}>
|
|
990
|
+
<SelectTrigger>
|
|
991
|
+
<SelectValue />
|
|
992
|
+
</SelectTrigger>
|
|
993
|
+
<SelectContent>
|
|
994
|
+
<SelectItem value="merge">Fusionner (conserver les existantes)</SelectItem>
|
|
995
|
+
<SelectItem value="overwrite">Écraser tout</SelectItem>
|
|
996
|
+
</SelectContent>
|
|
997
|
+
</Select>
|
|
998
|
+
</div>
|
|
999
|
+
</div>
|
|
1000
|
+
|
|
1001
|
+
<DialogFooter>
|
|
1002
|
+
<Button variant="outline" onClick={() => setIsImportOpen(false)}>
|
|
1003
|
+
Annuler
|
|
1004
|
+
</Button>
|
|
1005
|
+
<Button onClick={handleImport} disabled={!importFile || !importLanguage}>
|
|
1006
|
+
Importer
|
|
1007
|
+
</Button>
|
|
1008
|
+
</DialogFooter>
|
|
1009
|
+
</DialogContent>
|
|
1010
|
+
</Dialog>
|
|
1011
|
+
</div>
|
|
1012
|
+
</TooltipProvider>
|
|
1013
|
+
)
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
export default WakaI18nEditor
|