vite-plugin-lingo 0.0.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.
Files changed (38) hide show
  1. package/README.md +266 -0
  2. package/dist/index.d.ts +8 -0
  3. package/dist/index.js +10 -0
  4. package/dist/plugin/gettext-parser.d.ts +44 -0
  5. package/dist/plugin/index.cjs +449 -0
  6. package/dist/plugin/index.cjs.map +1 -0
  7. package/dist/plugin/index.d.cts +84 -0
  8. package/dist/plugin/index.d.ts +84 -0
  9. package/dist/plugin/index.js +413 -0
  10. package/dist/plugin/index.js.map +1 -0
  11. package/dist/plugin/middleware.d.ts +10 -0
  12. package/dist/plugin/middleware.js +137 -0
  13. package/dist/plugin/po-parser.d.ts +25 -0
  14. package/dist/plugin/po-parser.js +147 -0
  15. package/dist/plugin/types.d.ts +67 -0
  16. package/dist/plugin/types.js +1 -0
  17. package/dist/ui/App.svelte +92 -0
  18. package/dist/ui/App.svelte.d.ts +3 -0
  19. package/dist/ui/app.css +64 -0
  20. package/dist/ui/components/LanguageList.svelte +166 -0
  21. package/dist/ui/components/LanguageList.svelte.d.ts +6 -0
  22. package/dist/ui/components/ProgressBar.svelte +47 -0
  23. package/dist/ui/components/ProgressBar.svelte.d.ts +9 -0
  24. package/dist/ui/components/SearchBar.svelte +54 -0
  25. package/dist/ui/components/SearchBar.svelte.d.ts +6 -0
  26. package/dist/ui/components/ThemeToggle.svelte +17 -0
  27. package/dist/ui/components/ThemeToggle.svelte.d.ts +18 -0
  28. package/dist/ui/components/TranslationEditor.svelte +418 -0
  29. package/dist/ui/components/TranslationEditor.svelte.d.ts +8 -0
  30. package/dist/ui/index.html +24 -0
  31. package/dist/ui/main.d.ts +3 -0
  32. package/dist/ui/main.js +7 -0
  33. package/dist/ui/stores/refresh-signal.svelte.d.ts +6 -0
  34. package/dist/ui/stores/refresh-signal.svelte.js +11 -0
  35. package/dist/ui-dist/assets/index-B5dZv0sy.css +1 -0
  36. package/dist/ui-dist/assets/index-DsX4xzGF.js +10 -0
  37. package/dist/ui-dist/index.html +25 -0
  38. package/package.json +118 -0
@@ -0,0 +1,147 @@
1
+ import { po } from 'gettext-parser';
2
+ import { readFileSync, writeFileSync, readdirSync, existsSync } from 'fs';
3
+ import { join, basename } from 'path';
4
+ /**
5
+ * Parse a .po file and extract translations
6
+ */
7
+ export function parsePoFile(filePath) {
8
+ if (!existsSync(filePath)) {
9
+ throw new Error(`File not found: ${filePath}`);
10
+ }
11
+ const content = readFileSync(filePath);
12
+ const parsed = po.parse(content);
13
+ const translations = [];
14
+ for (const [context, messages] of Object.entries(parsed.translations)) {
15
+ for (const [msgid, data] of Object.entries(messages)) {
16
+ if (!msgid)
17
+ continue; // Skip header entry
18
+ const entry = data;
19
+ translations.push({
20
+ msgid,
21
+ msgstr: entry.msgstr?.[0] || '',
22
+ context: context || undefined,
23
+ comments: entry.comments,
24
+ fuzzy: entry.comments?.flag?.includes('fuzzy') || false
25
+ });
26
+ }
27
+ }
28
+ return translations;
29
+ }
30
+ /**
31
+ * Save translations back to a .po file
32
+ */
33
+ export function savePoFile(filePath, updates) {
34
+ if (!existsSync(filePath)) {
35
+ throw new Error(`File not found: ${filePath}`);
36
+ }
37
+ const content = readFileSync(filePath);
38
+ const parsed = po.parse(content);
39
+ for (const update of updates) {
40
+ const context = update.context || '';
41
+ if (parsed.translations[context]?.[update.msgid]) {
42
+ parsed.translations[context][update.msgid].msgstr = [update.msgstr];
43
+ // Handle fuzzy flag
44
+ if (update.fuzzy !== undefined) {
45
+ const comments = parsed.translations[context][update.msgid].comments || {};
46
+ if (update.fuzzy) {
47
+ comments.flag = 'fuzzy';
48
+ }
49
+ else {
50
+ delete comments.flag;
51
+ }
52
+ parsed.translations[context][update.msgid].comments = comments;
53
+ }
54
+ }
55
+ }
56
+ const compiled = po.compile(parsed);
57
+ writeFileSync(filePath, compiled);
58
+ }
59
+ /**
60
+ * Update a single translation
61
+ */
62
+ export function updateTranslation(filePath, msgid, msgstr, context) {
63
+ savePoFile(filePath, [{ msgid, msgstr, context }]);
64
+ }
65
+ /**
66
+ * Find all .po files in a directory
67
+ */
68
+ export function findPoFiles(localesDir) {
69
+ if (!existsSync(localesDir)) {
70
+ return [];
71
+ }
72
+ const files = readdirSync(localesDir).filter((f) => f.endsWith('.po'));
73
+ return files.map((file) => {
74
+ const filePath = join(localesDir, file);
75
+ const code = basename(file, '.po');
76
+ const translations = parsePoFile(filePath);
77
+ const translated = translations.filter((t) => t.msgstr && !t.fuzzy).length;
78
+ const fuzzy = translations.filter((t) => t.fuzzy).length;
79
+ return {
80
+ code,
81
+ name: getLanguageName(code),
82
+ path: filePath,
83
+ translations,
84
+ progress: {
85
+ total: translations.length,
86
+ translated,
87
+ fuzzy
88
+ }
89
+ };
90
+ });
91
+ }
92
+ /**
93
+ * Get language statistics for all languages
94
+ */
95
+ export function getLanguageStats(localesDir) {
96
+ const languages = findPoFiles(localesDir);
97
+ return languages.map((lang) => ({
98
+ code: lang.code,
99
+ name: lang.name,
100
+ total: lang.progress.total,
101
+ translated: lang.progress.translated,
102
+ fuzzy: lang.progress.fuzzy,
103
+ untranslated: lang.progress.total - lang.progress.translated - lang.progress.fuzzy,
104
+ progress: lang.progress.total > 0
105
+ ? Math.round((lang.progress.translated / lang.progress.total) * 100)
106
+ : 0
107
+ }));
108
+ }
109
+ /**
110
+ * Get a human-readable language name from a locale code
111
+ */
112
+ export function getLanguageName(code) {
113
+ const names = {
114
+ en: 'English',
115
+ es: 'Spanish',
116
+ fr: 'French',
117
+ de: 'German',
118
+ it: 'Italian',
119
+ pt: 'Portuguese',
120
+ 'pt-BR': 'Portuguese (Brazil)',
121
+ ja: 'Japanese',
122
+ ko: 'Korean',
123
+ zh: 'Chinese',
124
+ 'zh-CN': 'Chinese (Simplified)',
125
+ 'zh-TW': 'Chinese (Traditional)',
126
+ ru: 'Russian',
127
+ ar: 'Arabic',
128
+ nl: 'Dutch',
129
+ pl: 'Polish',
130
+ sv: 'Swedish',
131
+ da: 'Danish',
132
+ fi: 'Finnish',
133
+ no: 'Norwegian',
134
+ tr: 'Turkish',
135
+ cs: 'Czech',
136
+ hu: 'Hungarian',
137
+ ro: 'Romanian',
138
+ uk: 'Ukrainian',
139
+ vi: 'Vietnamese',
140
+ th: 'Thai',
141
+ id: 'Indonesian',
142
+ ms: 'Malay',
143
+ he: 'Hebrew',
144
+ hi: 'Hindi'
145
+ };
146
+ return names[code] || code.toUpperCase();
147
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Plugin configuration options
3
+ */
4
+ export interface PluginOptions {
5
+ /** Route where editor is served (default: '/_translations') */
6
+ route?: string;
7
+ /** Path to .po files directory */
8
+ localesDir?: string;
9
+ /** Enable in production mode (premium) */
10
+ production?: boolean;
11
+ /** License key for premium features */
12
+ licenseKey?: string;
13
+ /** AI configuration (premium) */
14
+ ai?: {
15
+ provider: 'openai' | 'anthropic' | 'google';
16
+ apiKey?: string;
17
+ };
18
+ }
19
+ /**
20
+ * Represents a single translation entry
21
+ */
22
+ export interface Translation {
23
+ msgid: string;
24
+ msgstr: string;
25
+ context?: string;
26
+ comments?: {
27
+ reference?: string;
28
+ translator?: string;
29
+ extracted?: string;
30
+ flag?: string;
31
+ };
32
+ fuzzy?: boolean;
33
+ }
34
+ /**
35
+ * Represents a language with its translations
36
+ */
37
+ export interface Language {
38
+ code: string;
39
+ name: string;
40
+ path: string;
41
+ translations: Translation[];
42
+ progress: {
43
+ total: number;
44
+ translated: number;
45
+ fuzzy: number;
46
+ };
47
+ }
48
+ /**
49
+ * API response types
50
+ */
51
+ export interface ApiResponse<T> {
52
+ success: boolean;
53
+ data?: T;
54
+ error?: string;
55
+ }
56
+ /**
57
+ * Language stats for the overview
58
+ */
59
+ export interface LanguageStats {
60
+ code: string;
61
+ name: string;
62
+ total: number;
63
+ translated: number;
64
+ fuzzy: number;
65
+ untranslated: number;
66
+ progress: number;
67
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,92 @@
1
+ <script lang="ts">
2
+ import { Globe, FileText, Funnel } from '@lucide/svelte';
3
+ import { ModeWatcher } from 'mode-watcher';
4
+ import LanguageList from './components/LanguageList.svelte';
5
+ import TranslationEditor from './components/TranslationEditor.svelte';
6
+ import SearchBar from './components/SearchBar.svelte';
7
+ import ThemeToggle from './components/ThemeToggle.svelte';
8
+
9
+ let selectedLanguage = $state<string | null>(null);
10
+ let searchQuery = $state('');
11
+ let filter = $state<'all' | 'translated' | 'untranslated' | 'fuzzy'>('all');
12
+
13
+ const filterOptions = [
14
+ { value: 'all', label: 'All' },
15
+ { value: 'translated', label: 'Translated' },
16
+ { value: 'untranslated', label: 'Untranslated' },
17
+ { value: 'fuzzy', label: 'Fuzzy' }
18
+ ] as const;
19
+
20
+ // Listen for .po file updates via HMR
21
+ if (import.meta.hot) {
22
+ import.meta.hot.on('lingo:po-updated', (data: { path: string }) => {
23
+ console.log('[lingo] .po file updated:', data.path);
24
+ });
25
+ }
26
+ </script>
27
+
28
+ <ModeWatcher />
29
+
30
+ <div class="flex h-screen flex-col bg-slate-50 dark:bg-slate-900">
31
+ <!-- Header -->
32
+ <header class="flex items-center justify-between border-b border-slate-200 bg-white px-6 py-3 shadow-sm dark:border-slate-700 dark:bg-slate-800">
33
+ <div class="flex items-center gap-3">
34
+ <div class="flex items-center gap-2">
35
+ <Globe class="h-5 w-5 text-blue-600 dark:text-blue-400" />
36
+ <h1 class="text-lg font-semibold text-slate-900 dark:text-white">Lingo</h1>
37
+ </div>
38
+ <span class="text-sm text-slate-500 dark:text-slate-400">Translation Editor</span>
39
+ </div>
40
+ <div class="flex items-center gap-4">
41
+ <SearchBar bind:query={searchQuery} />
42
+ <ThemeToggle />
43
+ </div>
44
+ </header>
45
+
46
+ <!-- Main content -->
47
+ <main class="flex flex-1 overflow-hidden">
48
+ <!-- Sidebar -->
49
+ <aside class="w-72 overflow-y-auto border-r border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-800">
50
+ <LanguageList bind:selected={selectedLanguage} />
51
+ </aside>
52
+
53
+ <!-- Content area -->
54
+ <section class="flex flex-1 flex-col overflow-hidden">
55
+ {#if selectedLanguage}
56
+ <!-- Filter tabs -->
57
+ <div class="flex items-center gap-4 border-b border-slate-200 bg-white px-6 py-3 dark:border-slate-700 dark:bg-slate-800">
58
+ <Funnel class="h-4 w-4 text-slate-400" />
59
+ <div class="flex gap-1">
60
+ {#each filterOptions as option (option.value)}
61
+ <button
62
+ type="button"
63
+ class="rounded-md px-3 py-1.5 text-sm font-medium transition-colors {filter === option.value
64
+ ? 'bg-blue-600 text-white dark:bg-blue-500'
65
+ : 'text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-700'}"
66
+ onclick={() => (filter = option.value)}
67
+ >
68
+ {option.label}
69
+ </button>
70
+ {/each}
71
+ </div>
72
+ </div>
73
+
74
+ <!-- Translation editor -->
75
+ <TranslationEditor
76
+ language={selectedLanguage}
77
+ {searchQuery}
78
+ {filter}
79
+ />
80
+ {:else}
81
+ <!-- Empty state -->
82
+ <div class="flex flex-1 items-center justify-center bg-slate-50 dark:bg-slate-900">
83
+ <div class="text-center">
84
+ <FileText class="mx-auto h-12 w-12 text-slate-300 dark:text-slate-600" />
85
+ <h2 class="mt-4 text-lg font-medium text-slate-900 dark:text-white">Select a language</h2>
86
+ <p class="mt-1 text-sm text-slate-500 dark:text-slate-400">Choose a language from the sidebar to start editing translations</p>
87
+ </div>
88
+ </div>
89
+ {/if}
90
+ </section>
91
+ </main>
92
+ </div>
@@ -0,0 +1,3 @@
1
+ declare const App: import("svelte").Component<Record<string, never>, {}, "">;
2
+ type App = ReturnType<typeof App>;
3
+ export default App;
@@ -0,0 +1,64 @@
1
+ @import "tailwindcss";
2
+
3
+ /* Enable class-based dark mode for Tailwind CSS v4 */
4
+ @custom-variant dark (&:where(.dark, .dark *));
5
+
6
+ /* Custom base styles */
7
+ @layer base {
8
+ html {
9
+ font-family:
10
+ system-ui,
11
+ -apple-system,
12
+ BlinkMacSystemFont,
13
+ 'Segoe UI',
14
+ Roboto,
15
+ sans-serif;
16
+ }
17
+
18
+ body {
19
+ @apply bg-slate-50 text-slate-900 antialiased dark:bg-slate-900 dark:text-slate-100;
20
+ }
21
+ }
22
+
23
+ /* Custom component styles */
24
+ @layer components {
25
+ .btn {
26
+ @apply inline-flex items-center justify-center gap-2 rounded-md px-3 py-1.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 dark:focus-visible:ring-offset-slate-900;
27
+ }
28
+
29
+ .btn-primary {
30
+ @apply bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600;
31
+ }
32
+
33
+ .btn-secondary {
34
+ @apply bg-slate-100 text-slate-900 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700;
35
+ }
36
+
37
+ .btn-ghost {
38
+ @apply hover:bg-slate-100 dark:hover:bg-slate-800;
39
+ }
40
+
41
+ .input {
42
+ @apply flex h-9 w-full rounded-md border border-slate-300 bg-white px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-slate-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:border-transparent disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-100 dark:placeholder:text-slate-500;
43
+ }
44
+
45
+ .badge {
46
+ @apply inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium;
47
+ }
48
+
49
+ .badge-success {
50
+ @apply bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-400;
51
+ }
52
+
53
+ .badge-warning {
54
+ @apply bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-400;
55
+ }
56
+
57
+ .badge-danger {
58
+ @apply bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-400;
59
+ }
60
+
61
+ .card {
62
+ @apply rounded-lg border border-slate-200 bg-white shadow-sm dark:border-slate-700 dark:bg-slate-800;
63
+ }
64
+ }
@@ -0,0 +1,166 @@
1
+ <script lang="ts">
2
+ import { onMount } from 'svelte';
3
+ import { Languages, RefreshCw, LoaderCircle, TriangleAlert, FolderOpen } from '@lucide/svelte';
4
+ import { getRefreshCount } from '../stores/refresh-signal.svelte';
5
+
6
+ interface LanguageStats {
7
+ code: string;
8
+ name: string;
9
+ total: number;
10
+ translated: number;
11
+ fuzzy: number;
12
+ untranslated: number;
13
+ progress: number;
14
+ }
15
+
16
+ let { selected = $bindable<string | null>(null) }: { selected: string | null } = $props();
17
+
18
+ let languages = $state<LanguageStats[]>([]);
19
+ let loading = $state(true);
20
+ let error = $state<string | null>(null);
21
+ let refreshing = $state(false);
22
+
23
+ onMount(async () => {
24
+ await loadLanguages();
25
+ });
26
+
27
+ // Auto-refresh when translations are updated
28
+ $effect(() => {
29
+ const count = getRefreshCount();
30
+ // Skip the initial mount (count is 0)
31
+ if (count > 0) {
32
+ loadLanguages();
33
+ }
34
+ });
35
+
36
+ async function loadLanguages() {
37
+ if (refreshing) return;
38
+
39
+ loading = languages.length === 0;
40
+ refreshing = true;
41
+ error = null;
42
+
43
+ try {
44
+ const res = await fetch('./api/languages');
45
+ const result = await res.json();
46
+
47
+ if (!result.success) {
48
+ throw new Error(result.error || 'Failed to load languages');
49
+ }
50
+
51
+ languages = result.data || [];
52
+ } catch (e) {
53
+ error = e instanceof Error ? e.message : 'Failed to load languages';
54
+ } finally {
55
+ loading = false;
56
+ refreshing = false;
57
+ }
58
+ }
59
+
60
+ function getProgressColor(progress: number): string {
61
+ if (progress >= 90) return 'bg-emerald-500';
62
+ if (progress >= 50) return 'bg-amber-500';
63
+ return 'bg-red-500';
64
+ }
65
+
66
+ function getProgressTextColor(progress: number): string {
67
+ if (progress >= 90) return 'text-emerald-600 dark:text-emerald-400';
68
+ if (progress >= 50) return 'text-amber-600 dark:text-amber-400';
69
+ return 'text-red-600 dark:text-red-400';
70
+ }
71
+ </script>
72
+
73
+ <div class="flex h-full flex-col">
74
+ <!-- Header -->
75
+ <div class="flex items-center justify-between border-b border-slate-200 px-4 py-3 dark:border-slate-700">
76
+ <div class="flex items-center gap-2">
77
+ <Languages class="h-4 w-4 text-slate-400" />
78
+ <h2 class="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">Languages</h2>
79
+ </div>
80
+ <button
81
+ type="button"
82
+ class="rounded-md p-1.5 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600 disabled:opacity-50 dark:hover:bg-slate-700 dark:hover:text-slate-300"
83
+ onclick={loadLanguages}
84
+ disabled={refreshing}
85
+ title="Refresh languages"
86
+ >
87
+ <RefreshCw class="h-4 w-4 {refreshing ? 'animate-spin' : ''}" />
88
+ </button>
89
+ </div>
90
+
91
+ <!-- Content -->
92
+ {#if loading}
93
+ <div class="flex flex-1 flex-col items-center justify-center gap-2 text-slate-500 dark:text-slate-400">
94
+ <LoaderCircle class="h-6 w-6 animate-spin" />
95
+ <span class="text-sm">Loading languages...</span>
96
+ </div>
97
+ {:else if error}
98
+ <div class="flex flex-1 flex-col items-center justify-center gap-3 px-4 text-center">
99
+ <TriangleAlert class="h-8 w-8 text-red-400" />
100
+ <p class="text-sm text-red-600 dark:text-red-400">{error}</p>
101
+ <button
102
+ type="button"
103
+ class="btn btn-secondary text-sm"
104
+ onclick={loadLanguages}
105
+ >
106
+ Retry
107
+ </button>
108
+ </div>
109
+ {:else if languages.length === 0}
110
+ <div class="flex flex-1 flex-col items-center justify-center gap-2 px-4 text-center text-slate-500 dark:text-slate-400">
111
+ <FolderOpen class="h-8 w-8 text-slate-300 dark:text-slate-600" />
112
+ <p class="text-sm font-medium">No .po files found</p>
113
+ <p class="text-xs text-slate-400 dark:text-slate-500">Add .po files to your locales directory</p>
114
+ </div>
115
+ {:else}
116
+ <ul class="flex-1 overflow-y-auto p-2">
117
+ {#each languages as lang (lang.code)}
118
+ <li>
119
+ <button
120
+ type="button"
121
+ class="group w-full rounded-lg p-3 text-left transition-colors {selected === lang.code
122
+ ? 'bg-blue-50 ring-1 ring-blue-200 dark:bg-blue-900/30 dark:ring-blue-700'
123
+ : 'hover:bg-slate-50 dark:hover:bg-slate-700/50'}"
124
+ onclick={() => (selected = lang.code)}
125
+ >
126
+ <div class="flex items-start justify-between">
127
+ <div>
128
+ <span class="block font-medium text-slate-900 dark:text-white {selected === lang.code ? 'text-blue-900 dark:text-blue-300' : ''}">
129
+ {lang.name}
130
+ </span>
131
+ <span class="text-xs text-slate-500 dark:text-slate-400">{lang.code}</span>
132
+ </div>
133
+ <span class="text-xs font-medium {getProgressTextColor(lang.progress)}">
134
+ {lang.progress}%
135
+ </span>
136
+ </div>
137
+
138
+ <!-- Progress bar -->
139
+ <div class="mt-2 h-1.5 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-700">
140
+ <div
141
+ class="h-full transition-all duration-300 {getProgressColor(lang.progress)}"
142
+ style="width: {lang.progress}%"
143
+ ></div>
144
+ </div>
145
+
146
+ <!-- Stats -->
147
+ <div class="mt-2 flex gap-3 text-xs text-slate-500 dark:text-slate-400">
148
+ <span class="flex items-center gap-1">
149
+ <span class="h-1.5 w-1.5 rounded-full bg-emerald-500"></span>
150
+ {lang.translated}
151
+ </span>
152
+ <span class="flex items-center gap-1">
153
+ <span class="h-1.5 w-1.5 rounded-full bg-amber-500"></span>
154
+ {lang.fuzzy}
155
+ </span>
156
+ <span class="flex items-center gap-1">
157
+ <span class="h-1.5 w-1.5 rounded-full bg-red-500"></span>
158
+ {lang.untranslated}
159
+ </span>
160
+ </div>
161
+ </button>
162
+ </li>
163
+ {/each}
164
+ </ul>
165
+ {/if}
166
+ </div>
@@ -0,0 +1,6 @@
1
+ type $$ComponentProps = {
2
+ selected: string | null;
3
+ };
4
+ declare const LanguageList: import("svelte").Component<$$ComponentProps, {}, "selected">;
5
+ type LanguageList = ReturnType<typeof LanguageList>;
6
+ export default LanguageList;
@@ -0,0 +1,47 @@
1
+ <script lang="ts">
2
+ interface Props {
3
+ value: number;
4
+ max?: number;
5
+ showLabel?: boolean;
6
+ size?: 'sm' | 'md' | 'lg';
7
+ }
8
+
9
+ let { value, max = 100, showLabel = true, size = 'md' }: Props = $props();
10
+
11
+ let percentage = $derived(Math.round((value / max) * 100));
12
+
13
+ let barColor = $derived.by(() => {
14
+ if (percentage >= 90) return 'bg-emerald-500';
15
+ if (percentage >= 50) return 'bg-amber-500';
16
+ return 'bg-red-500';
17
+ });
18
+
19
+ let textColor = $derived.by(() => {
20
+ if (percentage >= 90) return 'text-emerald-600 dark:text-emerald-400';
21
+ if (percentage >= 50) return 'text-amber-600 dark:text-amber-400';
22
+ return 'text-red-600 dark:text-red-400';
23
+ });
24
+
25
+ let heightClass = $derived.by(() => {
26
+ switch (size) {
27
+ case 'sm':
28
+ return 'h-1';
29
+ case 'lg':
30
+ return 'h-2.5';
31
+ default:
32
+ return 'h-1.5';
33
+ }
34
+ });
35
+ </script>
36
+
37
+ <div class="flex items-center gap-2">
38
+ <div class="flex-1 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-700 {heightClass}">
39
+ <div
40
+ class="h-full transition-all duration-300 {barColor}"
41
+ style="width: {percentage}%"
42
+ ></div>
43
+ </div>
44
+ {#if showLabel}
45
+ <span class="text-xs font-medium {textColor}">{percentage}%</span>
46
+ {/if}
47
+ </div>
@@ -0,0 +1,9 @@
1
+ interface Props {
2
+ value: number;
3
+ max?: number;
4
+ showLabel?: boolean;
5
+ size?: 'sm' | 'md' | 'lg';
6
+ }
7
+ declare const ProgressBar: import("svelte").Component<Props, {}, "">;
8
+ type ProgressBar = ReturnType<typeof ProgressBar>;
9
+ export default ProgressBar;
@@ -0,0 +1,54 @@
1
+ <script lang="ts">
2
+ import { Search, X } from '@lucide/svelte';
3
+
4
+ interface Props {
5
+ query: string;
6
+ }
7
+
8
+ let { query = $bindable('') }: Props = $props();
9
+
10
+ let inputRef = $state<HTMLInputElement | null>(null);
11
+
12
+ function handleKeydown(e: KeyboardEvent) {
13
+ // Ctrl/Cmd + K to focus search
14
+ if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
15
+ e.preventDefault();
16
+ inputRef?.focus();
17
+ }
18
+ // Escape to clear and blur
19
+ if (e.key === 'Escape' && document.activeElement === inputRef) {
20
+ query = '';
21
+ inputRef?.blur();
22
+ }
23
+ }
24
+
25
+ // Add global keyboard listener
26
+ $effect(() => {
27
+ window.addEventListener('keydown', handleKeydown);
28
+ return () => window.removeEventListener('keydown', handleKeydown);
29
+ });
30
+ </script>
31
+
32
+ <div class="relative flex items-center">
33
+ <Search class="absolute left-3 h-4 w-4 text-slate-400" />
34
+ <input
35
+ bind:this={inputRef}
36
+ type="text"
37
+ bind:value={query}
38
+ placeholder="Search translations..."
39
+ class="h-9 w-72 rounded-lg border border-slate-300 bg-white pl-9 pr-16 text-sm shadow-sm transition-colors placeholder:text-slate-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-100 dark:placeholder:text-slate-500 dark:focus:border-blue-400"
40
+ />
41
+ {#if query}
42
+ <button
43
+ type="button"
44
+ class="absolute right-3 rounded p-0.5 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600 dark:hover:bg-slate-700 dark:hover:text-slate-300"
45
+ onclick={() => (query = '')}
46
+ >
47
+ <X class="h-4 w-4" />
48
+ </button>
49
+ {:else}
50
+ <kbd class="absolute right-3 flex items-center gap-0.5 rounded border border-slate-200 bg-slate-50 px-1.5 py-0.5 text-xs text-slate-500 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-400">
51
+ <span class="text-[10px]">⌘</span>K
52
+ </kbd>
53
+ {/if}
54
+ </div>
@@ -0,0 +1,6 @@
1
+ interface Props {
2
+ query: string;
3
+ }
4
+ declare const SearchBar: import("svelte").Component<Props, {}, "query">;
5
+ type SearchBar = ReturnType<typeof SearchBar>;
6
+ export default SearchBar;
@@ -0,0 +1,17 @@
1
+ <script lang="ts">
2
+ import { Moon, Sun } from '@lucide/svelte';
3
+ import { toggleMode, mode } from 'mode-watcher';
4
+ </script>
5
+
6
+ <button
7
+ type="button"
8
+ onclick={toggleMode}
9
+ class="rounded-md p-2 text-slate-500 hover:bg-slate-100 hover:text-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200 transition-colors"
10
+ aria-label="Toggle theme"
11
+ >
12
+ {#if mode.current === 'dark'}
13
+ <Sun class="h-5 w-5" />
14
+ {:else}
15
+ <Moon class="h-5 w-5" />
16
+ {/if}
17
+ </button>