1use axum::{
2 extract::{Path, Query, State},
3 http::StatusCode,
4 response::IntoResponse,
5 routing::{delete, get, post},
6 Json, Router,
7};
8use serde::{Deserialize, Serialize};
9use serde_json::{json, Value};
10use std::net::SocketAddr;
11use std::sync::Arc;
12use tower_http::cors::CorsLayer;
13
14use crate::service::GettextStoreManager;
15
16pub struct WebConfig {
18 pub addr: SocketAddr,
19 pub manager: Arc<GettextStoreManager>,
20}
21
22#[derive(Debug, Serialize, Deserialize)]
25pub struct FileInfo {
26 pub path: String,
27 pub name: String,
28 pub language: Option<String>,
29}
30
31#[derive(Debug, Serialize, Deserialize)]
32pub struct TranslationRecord {
33 pub msgid: String,
34 pub msgctxt: Option<String>,
35 pub msgstr: String,
36 pub msgid_plural: Option<String>,
37 pub msgstr_plural: Vec<String>,
38 pub is_translated: bool,
39 pub is_fuzzy: bool,
40 pub flags: Vec<String>,
41}
42
43#[derive(Debug, Serialize, Deserialize)]
44pub struct TranslationDetail {
45 pub msgid: String,
46 pub msgctxt: Option<String>,
47 pub msgstr: String,
48 pub msgid_plural: Option<String>,
49 pub msgstr_plural: Vec<String>,
50 pub is_translated: bool,
51 pub is_fuzzy: bool,
52 pub flags: Vec<String>,
53 pub extracted_comment: Option<String>,
54 pub translator_comment: Option<String>,
55 pub source_locations: Vec<String>,
56}
57
58#[derive(Debug, Deserialize)]
59pub struct ListTranslationsQuery {
60 pub file: Option<String>,
61 pub query: Option<String>,
62 pub limit: Option<usize>,
63 pub msgctxt: Option<String>,
64}
65
66#[derive(Debug, Deserialize)]
67pub struct FileQuery {
68 pub file: Option<String>,
69}
70
71#[derive(Debug, Deserialize)]
72pub struct TranslationQuery {
73 pub file: Option<String>,
74 pub msgid: String,
75 pub msgctxt: Option<String>,
76}
77
78#[derive(Debug, Deserialize)]
79pub struct UpsertTranslationPayload {
80 pub msgid: String,
81 pub msgctxt: Option<String>,
82 pub msgstr: String,
83 pub msgid_plural: Option<String>,
84 pub msgstr_plural: Option<Vec<String>>,
85}
86
87#[derive(Debug, Deserialize)]
88pub struct UpdateMetadataPayload {
89 pub key: String,
90 pub value: String,
91}
92
93#[derive(Debug, Serialize)]
94pub struct Metadata {
95 pub encoding: Option<String>,
96 pub language: Option<String>,
97 pub plural_forms: Option<String>,
98}
99
100#[derive(Clone)]
101struct AppState {
102 manager: Arc<GettextStoreManager>,
103}
104
105pub async fn serve(config: WebConfig) -> Result<(), Box<dyn std::error::Error>> {
107 let state = AppState {
108 manager: config.manager,
109 };
110
111 let app = Router::new()
112 .route("/", get(index_html))
113 .route("/api/files", get(list_files))
114 .route("/api/translations", get(list_translations))
115 .route("/api/translations", post(upsert_translation))
116 .route("/api/translations/detail", get(get_translation))
117 .route("/api/translations/detail", delete(delete_translation))
118 .route("/api/metadata", get(get_metadata))
119 .route("/api/metadata", post(update_metadata))
120 .route("/api/languages", get(list_languages))
121 .route("/api/languages", post(add_language))
122 .route("/api/languages/{language}", delete(remove_language))
123 .layer(
124 CorsLayer::new()
125 .allow_origin([
126 "http://localhost"
127 .parse::<axum::http::HeaderValue>()
128 .unwrap(),
129 "http://127.0.0.1"
130 .parse::<axum::http::HeaderValue>()
131 .unwrap(),
132 format!("http://localhost:{}", config.addr.port())
133 .parse::<axum::http::HeaderValue>()
134 .unwrap(),
135 format!("http://127.0.0.1:{}", config.addr.port())
136 .parse::<axum::http::HeaderValue>()
137 .unwrap(),
138 ])
139 .allow_methods([
140 axum::http::Method::GET,
141 axum::http::Method::POST,
142 axum::http::Method::DELETE,
143 ])
144 .allow_headers([axum::http::header::CONTENT_TYPE]),
145 )
146 .with_state(state);
147
148 let listener = tokio::net::TcpListener::bind(config.addr).await?;
149 tracing::info!("Web server listening on {}", config.addr);
150 axum::serve(listener, app).await?;
151
152 Ok(())
153}
154
155async fn index_html() -> impl IntoResponse {
159 (
160 StatusCode::OK,
161 [("content-type", "text/html; charset=utf-8")],
162 SPA_HTML,
163 )
164}
165
166async fn list_files(
168 State(state): State<AppState>,
169) -> Result<Json<Vec<FileInfo>>, (StatusCode, String)> {
170 let discovered = state.manager.discovered_paths().await;
171 let base_dir = state.manager.base_dir().map(|p| p.to_path_buf());
172
173 let files: Vec<FileInfo> = discovered
174 .iter()
175 .filter_map(|path| {
176 let name = path.file_name()?.to_str()?.to_string();
177 let language = extract_language(path, base_dir.as_deref());
178 Some(FileInfo {
179 path: path.to_string_lossy().to_string(),
180 name,
181 language,
182 })
183 })
184 .collect();
185
186 Ok(Json(files))
187}
188
189fn extract_language(path: &std::path::Path, base_dir: Option<&std::path::Path>) -> Option<String> {
193 if path.extension().and_then(|e| e.to_str()) == Some("pot") {
195 return None;
196 }
197
198 let components: Vec<_> = path.components().collect();
200 for (i, comp) in components.iter().enumerate() {
201 if let std::path::Component::Normal(s) = comp {
202 if s.to_str() == Some("LC_MESSAGES") && i > 0 {
203 if let std::path::Component::Normal(lang) = &components[i - 1] {
204 return lang.to_str().map(|s| s.to_string());
205 }
206 }
207 }
208 }
209
210 if let Some(base) = base_dir {
212 if let Ok(rel) = path.strip_prefix(base) {
213 let first = rel.components().next()?;
214 if let std::path::Component::Normal(s) = first {
215 let s = s.to_str()?;
216 if rel.components().count() > 1 {
218 return Some(s.to_string());
219 }
220 }
221 }
222 }
223
224 None
225}
226
227async fn list_translations(
229 State(state): State<AppState>,
230 Query(params): Query<ListTranslationsQuery>,
231) -> Result<Json<Vec<TranslationRecord>>, (StatusCode, String)> {
232 let store = state
233 .manager
234 .store_for(params.file.as_deref())
235 .await
236 .map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
237
238 let entries = store
239 .list_all()
240 .await
241 .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
242
243 let mut filtered: Vec<_> = entries
244 .into_iter()
245 .filter(|(_, ctx, _)| {
246 if let Some(ref filter_ctx) = params.msgctxt {
247 ctx.as_deref() == Some(filter_ctx.as_str())
248 } else {
249 true
250 }
251 })
252 .filter(|(msgid, _, entry)| {
253 if let Some(ref query) = params.query {
254 let q_lower = query.to_lowercase();
255 msgid.to_lowercase().contains(&q_lower)
256 || entry.msgstr.to_lowercase().contains(&q_lower)
257 || entry
258 .msgid_plural
259 .as_deref()
260 .is_some_and(|p| p.to_lowercase().contains(&q_lower))
261 || entry
262 .msgstr_plural
263 .iter()
264 .any(|p| p.to_lowercase().contains(&q_lower))
265 } else {
266 true
267 }
268 })
269 .collect();
270
271 if let Some(limit) = params.limit {
272 filtered.truncate(limit);
273 }
274
275 let records: Vec<_> = filtered
276 .into_iter()
277 .map(|(msgid, msgctxt, entry)| {
278 let is_translated = entry.is_translated();
279 let is_fuzzy = entry.is_fuzzy();
280 TranslationRecord {
281 msgid,
282 msgctxt,
283 msgstr: entry.msgstr,
284 msgid_plural: entry.msgid_plural,
285 msgstr_plural: entry.msgstr_plural,
286 is_translated,
287 is_fuzzy,
288 flags: entry.flags,
289 }
290 })
291 .collect();
292
293 Ok(Json(records))
294}
295
296async fn get_translation(
298 State(state): State<AppState>,
299 Query(params): Query<TranslationQuery>,
300) -> Result<Json<TranslationDetail>, (StatusCode, String)> {
301 let store = state
302 .manager
303 .store_for(params.file.as_deref())
304 .await
305 .map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
306
307 let entry = store
308 .get(¶ms.msgid, params.msgctxt.as_deref())
309 .await
310 .map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
311
312 let detail = TranslationDetail {
313 msgid: params.msgid,
314 msgctxt: params.msgctxt,
315 msgstr: entry.msgstr.clone(),
316 msgid_plural: entry.msgid_plural.clone(),
317 msgstr_plural: entry.msgstr_plural.clone(),
318 is_translated: entry.is_translated(),
319 is_fuzzy: entry.is_fuzzy(),
320 flags: entry.flags.clone(),
321 extracted_comment: if entry.extracted_comment.is_empty() {
322 None
323 } else {
324 Some(entry.extracted_comment.join("\n"))
325 },
326 translator_comment: if entry.translator_comment.is_empty() {
327 None
328 } else {
329 Some(entry.translator_comment.join("\n"))
330 },
331 source_locations: entry.source_locations.clone(),
332 };
333
334 Ok(Json(detail))
335}
336
337async fn upsert_translation(
339 State(state): State<AppState>,
340 Query(params): Query<FileQuery>,
341 Json(payload): Json<UpsertTranslationPayload>,
342) -> Result<Json<Value>, (StatusCode, String)> {
343 let store = state
344 .manager
345 .store_for(params.file.as_deref())
346 .await
347 .map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
348
349 store
350 .upsert_full(
351 &payload.msgid,
352 payload.msgctxt.as_deref(),
353 &payload.msgstr,
354 payload.msgid_plural.as_deref(),
355 payload.msgstr_plural,
356 None,
357 )
358 .await
359 .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
360
361 Ok(Json(json!({ "success": true })))
362}
363
364async fn delete_translation(
366 State(state): State<AppState>,
367 Query(params): Query<TranslationQuery>,
368) -> Result<Json<Value>, (StatusCode, String)> {
369 let store = state
370 .manager
371 .store_for(params.file.as_deref())
372 .await
373 .map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
374
375 store
376 .delete(¶ms.msgid, params.msgctxt.as_deref())
377 .await
378 .map_err(|e| {
379 let status = if e.to_string().contains("not found") {
380 StatusCode::NOT_FOUND
381 } else {
382 StatusCode::INTERNAL_SERVER_ERROR
383 };
384 (status, e.to_string())
385 })?;
386
387 Ok(Json(json!({ "success": true })))
388}
389
390async fn get_metadata(
392 State(state): State<AppState>,
393 Query(params): Query<FileQuery>,
394) -> Result<Json<Metadata>, (StatusCode, String)> {
395 let store = state
396 .manager
397 .store_for(params.file.as_deref())
398 .await
399 .map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
400
401 let metadata = store
402 .metadata()
403 .await
404 .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
405
406 Ok(Json(Metadata {
407 encoding: metadata
408 .get("Content-Type")
409 .and_then(|ct| ct.split("charset=").nth(1).map(|s| s.trim().to_string())),
410 language: metadata.get("Language").cloned(),
411 plural_forms: metadata.get("Plural-Forms").cloned(),
412 }))
413}
414
415async fn update_metadata(
417 State(state): State<AppState>,
418 Query(params): Query<FileQuery>,
419 Json(payload): Json<UpdateMetadataPayload>,
420) -> Result<Json<Value>, (StatusCode, String)> {
421 let store = state
422 .manager
423 .store_for(params.file.as_deref())
424 .await
425 .map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
426
427 store
428 .set_header(&payload.key, &payload.value)
429 .await
430 .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
431
432 Ok(Json(json!({ "success": true })))
433}
434
435async fn list_languages(
437 State(state): State<AppState>,
438 Query(params): Query<FileQuery>,
439) -> Result<Json<Vec<String>>, (StatusCode, String)> {
440 let store = state
441 .manager
442 .store_for(params.file.as_deref())
443 .await
444 .map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
445
446 let languages = store
447 .list_languages()
448 .await
449 .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
450
451 Ok(Json(languages))
452}
453
454async fn add_language(
456 State(state): State<AppState>,
457 Query(params): Query<FileQuery>,
458 Json(payload): Json<serde_json::Map<String, Value>>,
459) -> Result<Json<Value>, (StatusCode, String)> {
460 let language = payload
461 .get("language")
462 .and_then(|v| v.as_str())
463 .ok_or((StatusCode::BAD_REQUEST, "Missing language".to_string()))?;
464
465 let store = state
466 .manager
467 .store_for(params.file.as_deref())
468 .await
469 .map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
470
471 store
472 .add_language(language)
473 .await
474 .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
475
476 Ok(Json(json!({ "success": true })))
477}
478
479async fn remove_language(
481 State(state): State<AppState>,
482 Query(params): Query<FileQuery>,
483 Path(language): Path<String>,
484) -> Result<Json<Value>, (StatusCode, String)> {
485 let store = state
486 .manager
487 .store_for(params.file.as_deref())
488 .await
489 .map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
490
491 store
492 .remove_language(&language)
493 .await
494 .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
495
496 Ok(Json(json!({ "success": true })))
497}
498
499const SPA_HTML: &str = r#"<!DOCTYPE html>
502<html lang="en">
503<head>
504 <meta charset="UTF-8">
505 <meta name="viewport" content="width=device-width, initial-scale=1.0">
506 <title>Gettext Translation Manager</title>
507 <style>
508 * {
509 margin: 0;
510 padding: 0;
511 box-sizing: border-box;
512 }
513
514 body {
515 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
516 background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
517 min-height: 100vh;
518 padding: 20px;
519 }
520
521 .container {
522 max-width: 1400px;
523 margin: 0 auto;
524 }
525
526 header {
527 background: white;
528 padding: 20px;
529 border-radius: 8px;
530 box-shadow: 0 2px 8px rgba(0,0,0,0.1);
531 margin-bottom: 20px;
532 }
533
534 h1 {
535 color: #667eea;
536 margin-bottom: 10px;
537 }
538
539 .controls {
540 display: flex;
541 gap: 15px;
542 flex-wrap: wrap;
543 align-items: center;
544 }
545
546 input[type="text"], select {
547 padding: 8px 12px;
548 border: 1px solid #ddd;
549 border-radius: 4px;
550 font-size: 14px;
551 }
552
553 input[type="text"] {
554 flex: 1;
555 min-width: 200px;
556 }
557
558 button {
559 padding: 8px 16px;
560 background: #667eea;
561 color: white;
562 border: none;
563 border-radius: 4px;
564 cursor: pointer;
565 font-weight: 500;
566 transition: background 0.3s;
567 }
568
569 button:hover {
570 background: #5568d3;
571 }
572
573 button.secondary {
574 background: #f0f0f0;
575 color: #333;
576 }
577
578 button.secondary:hover {
579 background: #e0e0e0;
580 }
581
582 button.danger {
583 background: #e74c3c;
584 }
585
586 button.danger:hover {
587 background: #c0392b;
588 }
589
590 .main-content {
591 display: grid;
592 grid-template-columns: 1fr 2fr;
593 gap: 20px;
594 }
595
596 .sidebar {
597 background: white;
598 padding: 20px;
599 border-radius: 8px;
600 box-shadow: 0 2px 8px rgba(0,0,0,0.1);
601 height: fit-content;
602 }
603
604 .sidebar h2 {
605 color: #667eea;
606 margin-bottom: 15px;
607 font-size: 16px;
608 }
609
610 .file-list {
611 list-style: none;
612 }
613
614 .file-group {
615 margin-bottom: 8px;
616 }
617
618 .file-group-header {
619 padding: 8px 10px;
620 background: #667eea;
621 color: white;
622 border-radius: 4px;
623 cursor: pointer;
624 font-weight: 600;
625 font-size: 13px;
626 display: flex;
627 justify-content: space-between;
628 align-items: center;
629 user-select: none;
630 }
631
632 .file-group-header:hover {
633 background: #5568d3;
634 }
635
636 .file-group-header .arrow {
637 transition: transform 0.2s;
638 font-size: 10px;
639 }
640
641 .file-group-header.collapsed .arrow {
642 transform: rotate(-90deg);
643 }
644
645 .file-group-items {
646 list-style: none;
647 padding-left: 0;
648 }
649
650 .file-group-items.hidden {
651 display: none;
652 }
653
654 .file-list li {
655 padding: 8px 10px 8px 20px;
656 margin-top: 2px;
657 background: #f5f5f5;
658 border-radius: 4px;
659 cursor: pointer;
660 transition: background 0.2s;
661 font-size: 13px;
662 }
663
664 .file-list li:hover {
665 background: #e8e8ff;
666 }
667
668 .file-list li.active {
669 background: #e8e8ff;
670 color: #667eea;
671 font-weight: 600;
672 }
673
674 .language-list {
675 list-style: none;
676 margin-top: 15px;
677 }
678
679 .language-list li {
680 padding: 8px;
681 background: #f5f5f5;
682 border-radius: 4px;
683 margin-bottom: 5px;
684 display: flex;
685 justify-content: space-between;
686 align-items: center;
687 }
688
689 .language-list li button {
690 padding: 4px 8px;
691 font-size: 12px;
692 }
693
694 .content-area {
695 background: white;
696 padding: 20px;
697 border-radius: 8px;
698 box-shadow: 0 2px 8px rgba(0,0,0,0.1);
699 }
700
701 .stats {
702 display: flex;
703 gap: 20px;
704 margin-bottom: 20px;
705 flex-wrap: wrap;
706 }
707
708 .stat-card {
709 background: #f8f9fa;
710 padding: 15px;
711 border-radius: 4px;
712 border-left: 4px solid #667eea;
713 }
714
715 .stat-card strong {
716 display: block;
717 color: #667eea;
718 margin-bottom: 5px;
719 }
720
721 .translations-table {
722 width: 100%;
723 border-collapse: collapse;
724 margin-top: 20px;
725 }
726
727 .translations-table thead {
728 background: #f5f5f5;
729 }
730
731 .translations-table th {
732 padding: 12px;
733 text-align: left;
734 font-weight: 600;
735 color: #333;
736 border-bottom: 2px solid #ddd;
737 }
738
739 .translations-table td {
740 padding: 12px;
741 border-bottom: 1px solid #eee;
742 }
743
744 .translations-table tr:hover {
745 background: #f9f9f9;
746 }
747
748 .status-badge {
749 display: inline-block;
750 padding: 4px 8px;
751 border-radius: 3px;
752 font-size: 12px;
753 font-weight: 500;
754 }
755
756 .status-translated {
757 background: #d4edda;
758 color: #155724;
759 }
760
761 .status-untranslated {
762 background: #f8d7da;
763 color: #721c24;
764 }
765
766 .status-fuzzy {
767 background: #fff3cd;
768 color: #856404;
769 }
770
771 .action-buttons {
772 display: flex;
773 gap: 8px;
774 }
775
776 .action-buttons button {
777 padding: 4px 8px;
778 font-size: 12px;
779 }
780
781 .modal {
782 display: none;
783 position: fixed;
784 top: 0;
785 left: 0;
786 width: 100%;
787 height: 100%;
788 background: rgba(0,0,0,0.5);
789 z-index: 1000;
790 align-items: center;
791 justify-content: center;
792 }
793
794 .modal.active {
795 display: flex;
796 }
797
798 .modal-content {
799 background: white;
800 padding: 30px;
801 border-radius: 8px;
802 max-width: 600px;
803 width: 90%;
804 max-height: 80vh;
805 overflow-y: auto;
806 }
807
808 .modal-content h2 {
809 color: #667eea;
810 margin-bottom: 20px;
811 }
812
813 .form-group {
814 margin-bottom: 15px;
815 }
816
817 .form-group label {
818 display: block;
819 margin-bottom: 5px;
820 color: #333;
821 font-weight: 500;
822 }
823
824 .form-group input,
825 .form-group textarea {
826 width: 100%;
827 padding: 8px;
828 border: 1px solid #ddd;
829 border-radius: 4px;
830 font-family: inherit;
831 }
832
833 .form-group textarea {
834 resize: vertical;
835 min-height: 60px;
836 }
837
838 .form-actions {
839 display: flex;
840 gap: 10px;
841 margin-top: 20px;
842 justify-content: flex-end;
843 }
844
845 .progress-bar {
846 width: 100%;
847 height: 20px;
848 background: #e0e0e0;
849 border-radius: 10px;
850 overflow: hidden;
851 margin-top: 10px;
852 }
853
854 .progress-fill {
855 height: 100%;
856 background: linear-gradient(90deg, #667eea, #764ba2);
857 display: flex;
858 align-items: center;
859 justify-content: center;
860 color: white;
861 font-size: 12px;
862 transition: width 0.3s;
863 }
864
865 .empty-state {
866 text-align: center;
867 padding: 40px;
868 color: #999;
869 }
870
871 .empty-state p {
872 margin-top: 10px;
873 }
874 </style>
875</head>
876<body>
877 <div class="container">
878 <header>
879 <h1>📚 Gettext Translation Manager</h1>
880 <div class="controls">
881 <select id="fileSelector" onchange="loadFile(this.value)">
882 <option value="">Select a file...</option>
883 </select>
884 <input type="text" id="searchInput" placeholder="Search translations..." onkeyup="filterTranslations()">
885 <button onclick="openNewTranslationModal()">+ New Translation</button>
886 </div>
887 </header>
888
889 <div class="main-content">
890 <aside class="sidebar">
891 <h2>Files</h2>
892 <ul class="file-list" id="fileList"></ul>
893
894 <h2>Languages</h2>
895 <ul class="language-list" id="languageList"></ul>
896 <button class="secondary" onclick="openAddLanguageModal()">+ Add Language</button>
897
898 <h2>Stats</h2>
899 <div id="sidebarStats"></div>
900 </aside>
901
902 <main class="content-area">
903 <div class="stats" id="stats"></div>
904 <table class="translations-table" id="translationsTable">
905 <thead>
906 <tr>
907 <th>Message ID</th>
908 <th>Translation</th>
909 <th>Status</th>
910 <th>Actions</th>
911 </tr>
912 </thead>
913 <tbody id="translationsBody">
914 </tbody>
915 </table>
916 <div class="empty-state" id="emptyState" style="display: none;">
917 <p>📠No translations found</p>
918 <p>Start by selecting a file or creating a new translation</p>
919 </div>
920 </main>
921 </div>
922 </div>
923
924 <!-- Edit Translation Modal -->
925 <div class="modal" id="editModal">
926 <div class="modal-content">
927 <h2>Edit Translation</h2>
928 <form onsubmit="saveTranslation(event)">
929 <div class="form-group">
930 <label>Message ID</label>
931 <input type="text" id="editMsgid" readonly>
932 </div>
933 <div class="form-group">
934 <label>Translation</label>
935 <textarea id="editMsgstr" required></textarea>
936 </div>
937 <div class="form-group">
938 <label>Plural Form (if applicable)</label>
939 <textarea id="editMsgidPlural"></textarea>
940 </div>
941 <div class="form-actions">
942 <button type="button" onclick="closeModal('editModal')" class="secondary">Cancel</button>
943 <button type="submit">Save</button>
944 </div>
945 </form>
946 </div>
947 </div>
948
949 <!-- New Translation Modal -->
950 <div class="modal" id="newTranslationModal">
951 <div class="modal-content">
952 <h2>New Translation</h2>
953 <form onsubmit="createTranslation(event)">
954 <div class="form-group">
955 <label>Message ID</label>
956 <input type="text" id="newMsgid" required>
957 </div>
958 <div class="form-group">
959 <label>Translation</label>
960 <textarea id="newMsgstr" required></textarea>
961 </div>
962 <div class="form-actions">
963 <button type="button" onclick="closeModal('newTranslationModal')" class="secondary">Cancel</button>
964 <button type="submit">Create</button>
965 </div>
966 </form>
967 </div>
968 </div>
969
970 <!-- Add Language Modal -->
971 <div class="modal" id="addLanguageModal">
972 <div class="modal-content">
973 <h2>Add Language</h2>
974 <form onsubmit="addLanguage(event)">
975 <div class="form-group">
976 <label>Language Code</label>
977 <input type="text" id="languageCode" placeholder="e.g., fr, es, de" required>
978 </div>
979 <div class="form-actions">
980 <button type="button" onclick="closeModal('addLanguageModal')" class="secondary">Cancel</button>
981 <button type="submit">Add</button>
982 </div>
983 </form>
984 </div>
985 </div>
986
987 <script>
988 let currentFile = null;
989 let allTranslations = [];
990
991 // Initialize
992 document.addEventListener('DOMContentLoaded', () => {
993 loadFiles();
994 });
995
996 async function loadFiles() {
997 try {
998 const response = await fetch('/api/files');
999 const files = await response.json();
1000 const fileList = document.getElementById('fileList');
1001 fileList.innerHTML = '';
1002
1003 // Group files by language
1004 const groups = {};
1005 files.forEach(file => {
1006 const group = file.language || 'Templates';
1007 if (!groups[group]) groups[group] = [];
1008 groups[group].push(file);
1009 });
1010
1011 // Sort group keys: Templates first, then languages alphabetically
1012 const sortedKeys = Object.keys(groups).sort((a, b) => {
1013 if (a === 'Templates') return -1;
1014 if (b === 'Templates') return 1;
1015 return a.localeCompare(b);
1016 });
1017
1018 // Also populate the file selector dropdown
1019 const selector = document.getElementById('fileSelector');
1020 selector.innerHTML = '<option value="">Select a file...</option>';
1021
1022 sortedKeys.forEach(group => {
1023 const div = document.createElement('div');
1024 div.className = 'file-group';
1025
1026 const header = document.createElement('div');
1027 header.className = 'file-group-header';
1028 header.innerHTML = '<span>' + group + ' (' + groups[group].length + ')</span><span class="arrow">▼</span>';
1029 header.onclick = () => {
1030 header.classList.toggle('collapsed');
1031 items.classList.toggle('hidden');
1032 };
1033
1034 const items = document.createElement('ul');
1035 items.className = 'file-group-items';
1036
1037 // Add optgroup to selector
1038 const optgroup = document.createElement('optgroup');
1039 optgroup.label = group;
1040
1041 groups[group].forEach(file => {
1042 const li = document.createElement('li');
1043 li.textContent = file.name;
1044 li.dataset.path = file.path;
1045 li.onclick = () => loadFile(file.path);
1046 items.appendChild(li);
1047
1048 const opt = document.createElement('option');
1049 opt.value = file.path;
1050 opt.textContent = file.name;
1051 optgroup.appendChild(opt);
1052 });
1053
1054 div.appendChild(header);
1055 div.appendChild(items);
1056 fileList.appendChild(div);
1057 selector.appendChild(optgroup);
1058 });
1059
1060 if (files.length > 0) {
1061 loadFile(files[0].path);
1062 }
1063 } catch (err) {
1064 console.error('Error loading files:', err);
1065 }
1066 }
1067
1068 async function loadFile(path) {
1069 if (!path) return;
1070 currentFile = path;
1071 document.getElementById('fileSelector').value = path;
1072
1073 // Update active file in sidebar
1074 document.querySelectorAll('.file-group-items li').forEach(li => {
1075 li.classList.remove('active');
1076 if (li.dataset.path === path) {
1077 li.classList.add('active');
1078 }
1079 });
1080
1081 // Load translations and languages
1082 await loadTranslations();
1083 await loadLanguages();
1084 await updateStats();
1085 }
1086
1087 async function loadTranslations() {
1088 try {
1089 const response = await fetch(`/api/translations?file=${encodeURIComponent(currentFile)}`);
1090 allTranslations = await response.json();
1091 renderTranslations(allTranslations);
1092 } catch (err) {
1093 console.error('Error loading translations:', err);
1094 }
1095 }
1096
1097 async function loadLanguages() {
1098 try {
1099 const response = await fetch(`/api/languages?file=${encodeURIComponent(currentFile)}`);
1100 const languages = await response.json();
1101 const languageList = document.getElementById('languageList');
1102 languageList.innerHTML = '';
1103
1104 languages.forEach(lang => {
1105 const li = document.createElement('li');
1106 li.appendChild(document.createTextNode(lang + ' '));
1107 const btn = document.createElement('button');
1108 btn.className = 'danger';
1109 btn.type = 'button';
1110 btn.textContent = 'Remove';
1111 btn.addEventListener('click', () => removeLanguage(lang));
1112 li.appendChild(btn);
1113 languageList.appendChild(li);
1114 });
1115 } catch (err) {
1116 console.error('Error loading languages:', err);
1117 }
1118 }
1119
1120 async function updateStats() {
1121 const total = allTranslations.length;
1122 const translated = allTranslations.filter(t => t.is_translated).length;
1123 const fuzzy = allTranslations.filter(t => t.is_fuzzy).length;
1124 const percentage = total > 0 ? Math.round((translated / total) * 100) : 0;
1125
1126 const statsHtml = `
1127 <div class="stat-card">
1128 <strong>Total</strong>
1129 ${total}
1130 </div>
1131 <div class="stat-card">
1132 <strong>Translated</strong>
1133 ${translated}
1134 </div>
1135 <div class="stat-card">
1136 <strong>Fuzzy</strong>
1137 ${fuzzy}
1138 </div>
1139 <div class="stat-card">
1140 <strong>Progress</strong>
1141 <div class="progress-bar">
1142 <div class="progress-fill" style="width: ${percentage}%">${percentage}%</div>
1143 </div>
1144 </div>
1145 `;
1146 document.getElementById('stats').innerHTML = statsHtml;
1147 document.getElementById('sidebarStats').innerHTML = `
1148 <strong>Total:</strong> ${total}<br>
1149 <strong>Translated:</strong> ${translated}<br>
1150 <strong>Coverage:</strong> ${percentage}%
1151 `;
1152 }
1153
1154 function renderTranslations(translations) {
1155 const tbody = document.getElementById('translationsBody');
1156 const emptyState = document.getElementById('emptyState');
1157
1158 if (translations.length === 0) {
1159 tbody.innerHTML = '';
1160 emptyState.style.display = 'block';
1161 return;
1162 }
1163
1164 emptyState.style.display = 'none';
1165 tbody.innerHTML = '';
1166 translations.forEach((t, idx) => {
1167 const tr = document.createElement('tr');
1168
1169 const tdId = document.createElement('td');
1170 const code = document.createElement('code');
1171 code.textContent = t.msgid;
1172 tdId.appendChild(code);
1173
1174 const tdStr = document.createElement('td');
1175 tdStr.textContent = t.msgstr || '(untranslated)';
1176
1177 const tdStatus = document.createElement('td');
1178 if (t.is_translated) {
1179 const span = document.createElement('span');
1180 span.className = 'status-badge status-translated';
1181 span.textContent = 'Translated';
1182 tdStatus.appendChild(span);
1183 }
1184 if (t.is_fuzzy) {
1185 const span = document.createElement('span');
1186 span.className = 'status-badge status-fuzzy';
1187 span.textContent = 'Fuzzy';
1188 tdStatus.appendChild(span);
1189 }
1190 if (!t.is_translated && !t.is_fuzzy) {
1191 const span = document.createElement('span');
1192 span.className = 'status-badge status-untranslated';
1193 span.textContent = 'Untranslated';
1194 tdStatus.appendChild(span);
1195 }
1196
1197 const tdActions = document.createElement('td');
1198 const div = document.createElement('div');
1199 div.className = 'action-buttons';
1200 const editBtn = document.createElement('button');
1201 editBtn.textContent = 'Edit';
1202 editBtn.addEventListener('click', () => openEditModal(t.msgid, t.msgstr, t.msgctxt));
1203 const deleteBtn = document.createElement('button');
1204 deleteBtn.className = 'danger';
1205 deleteBtn.textContent = 'Delete';
1206 deleteBtn.addEventListener('click', () => deleteTranslation(t.msgid, t.msgctxt));
1207 div.appendChild(editBtn);
1208 div.appendChild(deleteBtn);
1209 tdActions.appendChild(div);
1210
1211 tr.appendChild(tdId);
1212 tr.appendChild(tdStr);
1213 tr.appendChild(tdStatus);
1214 tr.appendChild(tdActions);
1215 tbody.appendChild(tr);
1216 });
1217 }
1218
1219 function filterTranslations() {
1220 const query = document.getElementById('searchInput').value;
1221 const filtered = allTranslations.filter(t =>
1222 (t.msgid || '').toLowerCase().includes(query.toLowerCase()) ||
1223 (t.msgstr || '').toLowerCase().includes(query.toLowerCase())
1224 );
1225 renderTranslations(filtered);
1226 }
1227
1228 let editMsgctxt = null;
1229 function openEditModal(msgid, msgstr, msgctxt) {
1230 document.getElementById('editMsgid').value = msgid;
1231 document.getElementById('editMsgstr').value = msgstr;
1232 editMsgctxt = msgctxt || null;
1233 document.getElementById('editModal').classList.add('active');
1234 }
1235
1236 function openNewTranslationModal() {
1237 if (!currentFile) {
1238 alert('Please select a file first');
1239 return;
1240 }
1241 document.getElementById('newTranslationModal').classList.add('active');
1242 }
1243
1244 function openAddLanguageModal() {
1245 if (!currentFile) {
1246 alert('Please select a file first');
1247 return;
1248 }
1249 document.getElementById('addLanguageModal').classList.add('active');
1250 }
1251
1252 function closeModal(modalId) {
1253 document.getElementById(modalId).classList.remove('active');
1254 }
1255
1256 async function saveTranslation(event) {
1257 event.preventDefault();
1258 const msgid = document.getElementById('editMsgid').value;
1259 const msgstr = document.getElementById('editMsgstr').value;
1260
1261 try {
1262 const response = await fetch(`/api/translations?file=${encodeURIComponent(currentFile)}`, {
1263 method: 'POST',
1264 headers: { 'Content-Type': 'application/json' },
1265 body: JSON.stringify({
1266 msgid,
1267 msgstr,
1268 msgctxt: editMsgctxt
1269 })
1270 });
1271
1272 if (response.ok) {
1273 closeModal('editModal');
1274 await loadTranslations();
1275 await updateStats();
1276 } else {
1277 alert('Error saving translation');
1278 }
1279 } catch (err) {
1280 console.error('Error:', err);
1281 alert('Error saving translation');
1282 }
1283 }
1284
1285 async function createTranslation(event) {
1286 event.preventDefault();
1287 const msgid = document.getElementById('newMsgid').value;
1288 const msgstr = document.getElementById('newMsgstr').value;
1289
1290 try {
1291 const response = await fetch(`/api/translations?file=${encodeURIComponent(currentFile)}`, {
1292 method: 'POST',
1293 headers: { 'Content-Type': 'application/json' },
1294 body: JSON.stringify({
1295 msgid,
1296 msgstr
1297 })
1298 });
1299
1300 if (response.ok) {
1301 closeModal('newTranslationModal');
1302 document.getElementById('newMsgid').value = '';
1303 document.getElementById('newMsgstr').value = '';
1304 await loadTranslations();
1305 await updateStats();
1306 } else {
1307 alert('Error creating translation');
1308 }
1309 } catch (err) {
1310 console.error('Error:', err);
1311 alert('Error creating translation');
1312 }
1313 }
1314
1315 async function deleteTranslation(msgid, msgctxt) {
1316 if (!confirm(`Delete translation for "${msgid}"?`)) return;
1317
1318 try {
1319 let url = `/api/translations/detail?file=${encodeURIComponent(currentFile)}&msgid=${encodeURIComponent(msgid)}`;
1320 if (msgctxt) url += `&msgctxt=${encodeURIComponent(msgctxt)}`;
1321 const response = await fetch(url, {
1322 method: 'DELETE'
1323 });
1324
1325 if (response.ok) {
1326 await loadTranslations();
1327 await updateStats();
1328 } else {
1329 alert('Error deleting translation');
1330 }
1331 } catch (err) {
1332 console.error('Error:', err);
1333 alert('Error deleting translation');
1334 }
1335 }
1336
1337 async function addLanguage(event) {
1338 event.preventDefault();
1339 const language = document.getElementById('languageCode').value;
1340
1341 try {
1342 const response = await fetch(`/api/languages?file=${encodeURIComponent(currentFile)}`, {
1343 method: 'POST',
1344 headers: { 'Content-Type': 'application/json' },
1345 body: JSON.stringify({
1346 language
1347 })
1348 });
1349
1350 if (response.ok) {
1351 closeModal('addLanguageModal');
1352 document.getElementById('languageCode').value = '';
1353 await loadLanguages();
1354 } else {
1355 alert('Error adding language');
1356 }
1357 } catch (err) {
1358 console.error('Error:', err);
1359 alert('Error adding language');
1360 }
1361 }
1362
1363 async function removeLanguage(language) {
1364 if (!confirm(`Remove language "${language}"?`)) return;
1365
1366 try {
1367 const response = await fetch(`/api/languages/${encodeURIComponent(language)}?file=${encodeURIComponent(currentFile)}`, {
1368 method: 'DELETE'
1369 });
1370
1371 if (response.ok) {
1372 await loadLanguages();
1373 } else {
1374 alert('Error removing language');
1375 }
1376 } catch (err) {
1377 console.error('Error:', err);
1378 alert('Error removing language');
1379 }
1380 }
1381
1382 </script>
1383</body>
1384</html>"#;