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.
- package/README.md +266 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +10 -0
- package/dist/plugin/gettext-parser.d.ts +44 -0
- package/dist/plugin/index.cjs +449 -0
- package/dist/plugin/index.cjs.map +1 -0
- package/dist/plugin/index.d.cts +84 -0
- package/dist/plugin/index.d.ts +84 -0
- package/dist/plugin/index.js +413 -0
- package/dist/plugin/index.js.map +1 -0
- package/dist/plugin/middleware.d.ts +10 -0
- package/dist/plugin/middleware.js +137 -0
- package/dist/plugin/po-parser.d.ts +25 -0
- package/dist/plugin/po-parser.js +147 -0
- package/dist/plugin/types.d.ts +67 -0
- package/dist/plugin/types.js +1 -0
- package/dist/ui/App.svelte +92 -0
- package/dist/ui/App.svelte.d.ts +3 -0
- package/dist/ui/app.css +64 -0
- package/dist/ui/components/LanguageList.svelte +166 -0
- package/dist/ui/components/LanguageList.svelte.d.ts +6 -0
- package/dist/ui/components/ProgressBar.svelte +47 -0
- package/dist/ui/components/ProgressBar.svelte.d.ts +9 -0
- package/dist/ui/components/SearchBar.svelte +54 -0
- package/dist/ui/components/SearchBar.svelte.d.ts +6 -0
- package/dist/ui/components/ThemeToggle.svelte +17 -0
- package/dist/ui/components/ThemeToggle.svelte.d.ts +18 -0
- package/dist/ui/components/TranslationEditor.svelte +418 -0
- package/dist/ui/components/TranslationEditor.svelte.d.ts +8 -0
- package/dist/ui/index.html +24 -0
- package/dist/ui/main.d.ts +3 -0
- package/dist/ui/main.js +7 -0
- package/dist/ui/stores/refresh-signal.svelte.d.ts +6 -0
- package/dist/ui/stores/refresh-signal.svelte.js +11 -0
- package/dist/ui-dist/assets/index-B5dZv0sy.css +1 -0
- package/dist/ui-dist/assets/index-DsX4xzGF.js +10 -0
- package/dist/ui-dist/index.html +25 -0
- 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>
|
package/dist/ui/app.css
ADDED
|
@@ -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,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,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,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>
|