diff --git a/.github/workflows/server-and-client-tests.yml b/.github/workflows/server-and-client-tests.yml index 8e112f8..c2afdcf 100644 --- a/.github/workflows/server-and-client-tests.yml +++ b/.github/workflows/server-and-client-tests.yml @@ -61,6 +61,9 @@ jobs: - name: Install dependencies run: npm ci + - name: Check for hardcoded i18n strings + run: npm run check:i18n + - name: Run tests run: npm test diff --git a/README.md b/README.md index 5fb9f75..3aeda4a 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,14 @@ A Laravel 12 API server + Angular 21 client for processing PDF documents using A - **Personalisation Page** — `/profile/personalisation`, a third sub-page alongside Info and Security. Hosts the layout picker (6 cards), palette picker (6 swatches), and theme-mode segmented control. Selections take effect instantly with no reload. - **Console-only `/` Command Palette** — when on the Console layout, pressing `/` opens a centred terminal-style overlay for keyboard-driven navigation (autocomplete on routes, ↑↓ to move, Enter to navigate, Esc to close). The listener is scoped so typing `/` inside an ``, `
@@ -194,69 +194,69 @@

Document Details

{{ doc.effective_filename }}

@if (doc.has_custom_name) { - renamed + {{ t('detail.view.renamed') }} }
@if (doc.author) {
- Author + {{ t('detail.view.author') }} {{ doc.author }}
} @if (doc.genre) {
- Genre - {{ doc.genre }} + {{ t('detail.view.genre') }} + {{ genreLabel(doc.genre) }}
}
- Pages + {{ t('detail.view.pages') }} {{ doc.page_count }}
- Size + {{ t('detail.view.size') }} {{ formatFileSize(doc.file_size) }}
- Format + {{ t('detail.view.format') }} {{ getMimeTypeLabel(doc.mime_type) }}
- Language Language is used for spell checking during text extraction + {{ t('detail.view.language') }} {{ t('detail.view.languageTooltip') }} @if (doc.language_detection_status === 'failed') { - Detection failed + {{ t('detail.view.detectionFailed') }} } @else if (doc.language_detection_status === 'pending' || doc.language_detection_status === 'processing') { -
Detecting...
+
{{ t('detail.view.detecting') }}
} @else { {{ getLanguageDisplayName(doc.main_language) }} }
- Added + {{ t('detail.view.added') }} {{ formatDate(doc.created_at) }}
@if (doc.description) {
- Description + {{ t('detail.view.description') }}

{{ doc.description }}

} @@ -266,13 +266,13 @@

{{ doc.effective_filename }}

- Edit + {{ t('detail.view.edit') }} diff --git a/wordkeep-client/src/app/pages/document-detail/document-detail.spec.ts b/wordkeep-client/src/app/pages/document-detail/document-detail.spec.ts index e3e0367..8a50055 100644 --- a/wordkeep-client/src/app/pages/document-detail/document-detail.spec.ts +++ b/wordkeep-client/src/app/pages/document-detail/document-detail.spec.ts @@ -7,6 +7,7 @@ import { DocumentService } from '../../services/document.service'; import { AuthService } from '../../services/auth.service'; import { ConfirmationService } from '../../services/confirmation.service'; import { mockDocument, mockFailedDocument, mockLanguageDetectionResult } from '../../test-utils'; +import { getTranslocoTestingModule } from '../../transloco/transloco-testing'; describe('DocumentDetailComponent', () => { let component: DocumentDetailComponent; @@ -57,7 +58,7 @@ describe('DocumentDetailComponent', () => { TestBed.resetTestingModule(); TestBed.configureTestingModule({ - imports: [DocumentDetailComponent], + imports: [DocumentDetailComponent, getTranslocoTestingModule()], providers: [ provideRouter([]), { provide: DocumentService, useValue: mockDocumentService }, diff --git a/wordkeep-client/src/app/pages/document-detail/document-detail.ts b/wordkeep-client/src/app/pages/document-detail/document-detail.ts index 01e255d..6e57ebd 100644 --- a/wordkeep-client/src/app/pages/document-detail/document-detail.ts +++ b/wordkeep-client/src/app/pages/document-detail/document-detail.ts @@ -3,6 +3,8 @@ import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { interval, Subscription } from 'rxjs'; +import { provideTranslocoScope, TranslocoDirective, TranslocoService } from '@jsverse/transloco'; +import { formatDateTime } from '../../utils/date-format'; import { DocumentService } from '../../services/document.service'; import { BookViewService } from '../../services/book-view.service'; import { ConfirmationService } from '../../services/confirmation.service'; @@ -12,7 +14,8 @@ import { LayoutComponent } from '../../components/layout/layout'; @Component({ selector: 'app-document-detail', standalone: true, - imports: [CommonModule, FormsModule, RouterLink, LayoutComponent], + imports: [CommonModule, FormsModule, RouterLink, LayoutComponent, TranslocoDirective], + providers: [provideTranslocoScope('documents')], templateUrl: './document-detail.html', styleUrl: './document-detail.scss' }) @@ -22,6 +25,7 @@ export class DocumentDetailComponent implements OnInit, OnDestroy { private documentService = inject(DocumentService); private bookViewService = inject(BookViewService); private confirmationService = inject(ConfirmationService); + private transloco = inject(TranslocoService); document = signal(null); loading = signal(true); @@ -134,7 +138,7 @@ export class DocumentDetailComponent implements OnInit, OnDestroy { this.checkAndStartPolling(); }, error: () => { - this.error.set('Failed to load document'); + this.error.set(this.transloco.translate('documents.detail.errors.load')); this.loading.set(false); } }); @@ -244,7 +248,7 @@ export class DocumentDetailComponent implements OnInit, OnDestroy { this.saving.set(false); }, error: () => { - this.error.set('Failed to save changes'); + this.error.set(this.transloco.translate('documents.detail.errors.save')); this.saving.set(false); } }); @@ -262,7 +266,7 @@ export class DocumentDetailComponent implements OnInit, OnDestroy { this.router.navigate(['/documents']); }, error: () => { - this.error.set('Failed to delete document'); + this.error.set(this.transloco.translate('documents.detail.errors.delete')); } }); } @@ -283,7 +287,7 @@ export class DocumentDetailComponent implements OnInit, OnDestroy { this.detectingLanguage.set(false); }, error: () => { - this.error.set('Failed to detect language'); + this.error.set(this.transloco.translate('documents.detail.errors.detect')); this.detectingLanguage.set(false); } }); @@ -317,7 +321,7 @@ export class DocumentDetailComponent implements OnInit, OnDestroy { this.exportingBook.set(false); }, error: () => { - this.error.set('Failed to export document'); + this.error.set(this.transloco.translate('documents.detail.errors.export')); this.exportingBook.set(false); } }); @@ -336,14 +340,28 @@ export class DocumentDetailComponent implements OnInit, OnDestroy { if (missingText > 0 || missingLinks > 0) { const issues: string[] = []; - if (missingText > 0) issues.push(`${missingText} page${missingText > 1 ? 's' : ''} without extracted text`); - if (missingLinks > 0) issues.push(`${missingLinks} unlinked page pair${missingLinks > 1 ? 's' : ''}`); - const message = `This document has: ${issues.join(' and ')}. Missing text will show as placeholders and missing links will default to new row.`; + if (missingText > 0) { + issues.push(this.transloco.translate( + missingText > 1 ? 'documents.detail.incomplete.missingTextPlural' : 'documents.detail.incomplete.missingText', + { count: missingText } + )); + } + if (missingLinks > 0) { + issues.push(this.transloco.translate( + missingLinks > 1 ? 'documents.detail.incomplete.missingLinksPlural' : 'documents.detail.incomplete.missingLinks', + { count: missingLinks } + )); + } + const message = this.transloco.translate('documents.detail.incomplete.message', { + issues: issues.join(this.transloco.translate('documents.detail.incomplete.joiner')) + }); return this.confirmationService.confirm({ - title: 'Work incomplete', + title: this.transloco.translate('documents.detail.incomplete.title'), message, - confirmText: action === 'view' ? 'View anyway' : 'Export anyway', + confirmText: this.transloco.translate( + action === 'view' ? 'documents.detail.incomplete.viewAnyway' : 'documents.detail.incomplete.exportAnyway' + ), }); } @@ -359,13 +377,7 @@ export class DocumentDetailComponent implements OnInit, OnDestroy { } formatDate(dateString: string): string { - return new Date(dateString).toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric', - hour: '2-digit', - minute: '2-digit' - }); + return formatDateTime(dateString, this.transloco.getActiveLang(), 'long'); } getMimeTypeLabel(mimeType: string): string { @@ -376,45 +388,17 @@ export class DocumentDetailComponent implements OnInit, OnDestroy { } getLanguageDisplayName(code: string | null): string { - if (!code) return 'Not detected'; - const languages: Record = { - en: 'English', - es: 'Spanish', - fr: 'French', - de: 'German', - it: 'Italian', - pt: 'Portuguese', - ru: 'Russian', - zh: 'Chinese', - ja: 'Japanese', - ko: 'Korean', - ar: 'Arabic', - hi: 'Hindi', - ro: 'Romanian', - nl: 'Dutch', - pl: 'Polish', - sv: 'Swedish', - da: 'Danish', - no: 'Norwegian', - fi: 'Finnish', - cs: 'Czech', - el: 'Greek', - tr: 'Turkish', - he: 'Hebrew', - th: 'Thai', - vi: 'Vietnamese', - id: 'Indonesian', - ms: 'Malay', - la: 'Latin', - uk: 'Ukrainian', - hu: 'Hungarian', - bg: 'Bulgarian', - hr: 'Croatian', - sk: 'Slovak', - sl: 'Slovenian', - sr: 'Serbian', - unknown: 'Unknown' - }; - return languages[code] || code.toUpperCase(); + if (!code) return this.transloco.translate('documents.detail.notDetected'); + const key = `languages.${code}`; + const name = this.transloco.translate(key); + return name === key ? code.toUpperCase() : name; + } + + /** Translated label for a stored (English) genre value; falls back to the value. */ + genreLabel(value: string): string { + if (!value) return ''; + const key = `documents.detail.genres.${value}`; + const label = this.transloco.translate(key); + return label === key ? value : label; } } diff --git a/wordkeep-client/src/app/pages/documents/documents.html b/wordkeep-client/src/app/pages/documents/documents.html index 61ab8fc..ac85aa4 100644 --- a/wordkeep-client/src/app/pages/documents/documents.html +++ b/wordkeep-client/src/app/pages/documents/documents.html @@ -1,9 +1,9 @@ -
+
@@ -19,7 +19,7 @@

Documents

@if (uploading()) {
-

Uploading document...

+

{{ t('list.upload.uploading') }}

} @else {
@@ -28,12 +28,12 @@

Documents

-

Drag and drop a PDF file here, or

+

{{ t('list.upload.dragDrop') }}

-

Maximum file size: 100MB

+

{{ t('list.upload.maxSize') }}

}
@@ -56,10 +56,10 @@

Documents

-

Your Documents

+

{{ t('list.section.heading') }}

@if (total() > 0) { - {{ total() }} document{{ total() === 1 ? '' : 's' }} + {{ total() === 1 ? t('list.section.count', { count: total() }) : t('list.section.countPlural', { count: total() }) }} }
@@ -68,7 +68,7 @@

Your Documents

@if (loading()) {
-

Loading documents...

+

{{ t('list.loading') }}

} @else if (documents().length === 0) {
@@ -77,8 +77,8 @@

Your Documents

-

No documents yet

-

Upload your first PDF to get started

+

{{ t('list.empty.title') }}

+

{{ t('list.empty.subtitle') }}

} @else {
@@ -100,7 +100,7 @@

{{ doc.effective_filename }}

@if (doc.error_message) { @@ -119,20 +119,20 @@

{{ doc.effective_filename }}

{{ doc.effective_filename }}

@if (doc.has_custom_name) { - renamed + {{ t('list.card.renamed') }} }
@if (doc.author) {
{{ doc.author }}
}
- {{ doc.page_count }} page{{ doc.page_count === 1 ? '' : 's' }} + {{ doc.page_count === 1 ? t('list.card.page', { count: doc.page_count }) : t('list.card.pagePlural', { count: doc.page_count }) }} {{ formatFileSize(doc.file_size) }}
@@ -143,7 +143,7 @@

{{ doc.effective_filename }}

}
@if (doc.status === 'failed') { - } - - - Page {{ currentPage() }} of {{ lastPage() }} + {{ t('list.pagination.pageInfo', { current: currentPage(), last: lastPage() }) }} - -
@if (pageImageUrl()) { @@ -152,16 +153,16 @@

Page {{ currentPage() }} of {{ doc.page_count }}

} @if (isCurrentPageIgnored()) { -
Ignored
+
{{ t('ignoredBadge') }}
} } @else if (loadingImage()) {
-

Loading page...

+

{{ t('loadingPage') }}

} @else {
-

Failed to load page image

+

{{ t('imageError') }}

} @@ -171,7 +172,7 @@

Page {{ currentPage() }} of {{ doc.page_count }}

-

Extracted Text

+

{{ t('extractedText') }}

@if (currentOcr()?.extraction_method) { Extracted Text - Edit + {{ t('edit') }} } @@ -211,12 +212,12 @@

Extracted Text

> @if (isExtractingCurrentPage()) {
- Extracting... + {{ t('extracting') }} } @else { - Extract Text + {{ t('extractText') }} } } @@ -228,17 +229,17 @@

Extracted Text

> @if (isProcessingCurrentPage()) {
- Processing... + {{ t('processing') }} } @else if (currentOcr()?.ocr_status === 'completed') { - AI OCR + {{ t('aiOcr') }} } @else { - AI OCR + {{ t('aiOcr') }} } @@ -251,7 +252,7 @@

Extracted Text

- Transcribe + {{ t('transcribe') }} } } @@ -280,17 +281,17 @@

Extracted Text

}
} @@ -302,8 +303,8 @@

Extracted Text

-

This page is ignored

-

It will not appear in the exported document. Click "Un-ignore" to re-enable text extraction.

+

{{ t('ignoredHeading') }}

+

{{ t('ignoredHint') }}

} @else if (editMode()) {
@@ -312,45 +313,45 @@

Extracted Text

[editor]="editor" [(ngModel)]="editorContent" (ngModelChange)="onEditorContentChange()" - [placeholder]="'Enter text...'" + [placeholder]="t('editorPlaceholder')" >
} @else if (isProcessingCurrentPage()) {
-

Processing page with AI OCR...

-

This may take a moment

+

{{ t('processingHeading') }}

+

{{ t('processingHint') }}

} @else if (isExtractingCurrentPage()) {
-

Extracting text from PDF...

-

This should be quick

+

{{ t('extractingHeading') }}

+

{{ t('extractingHint') }}

} @else if (currentOcr()?.ocr_text) {
@if (currentOcr()?.cached) { -
Cached result
+
{{ t('cached') }}
} } @else if (currentOcr()?.ocr_status === 'failed') {
-

OCR processing failed

+

{{ t('ocrFailed') }}

} @else {
-

No text available

-

Use the buttons above or transcribe manually

+

{{ t('noText') }}

+

{{ t('noTextHint') }}

} @@ -359,7 +360,7 @@

Extracted Text

-

Footnotes

+

{{ t('footnotes') }}

@if (currentFootnote()?.page_number !== null && currentFootnote()?.page_number !== undefined) { } } } @else { } @@ -450,7 +451,7 @@

Footnotes

@if (loadingFootnotes()) {
-

Loading footnotes...

+

{{ t('loadingFootnotes') }}

} @else if (footnoteEditMode()) {
@@ -458,7 +459,7 @@

Footnotes

} @else if (currentFootnote(); as fn) { @@ -467,18 +468,18 @@

Footnotes

- This footnote is on page {{ fn.page_number }} + {{ t('footnoteOnPage', { page: fn.page_number }) }}
}
[^{{ fn.number }}] @if (fn.page_number) { - Page {{ fn.page_number }} + {{ t('footnotePageBadge', { page: fn.page_number }) }} } @else { - Unlinked + {{ t('footnoteUnlinked') }} }
@@ -487,8 +488,8 @@

Footnotes

-

No footnotes yet

-

Click "Add Footnote" to create one

+

{{ t('noFootnotes') }}

+

{{ t('noFootnotesHint') }}

}
@@ -496,4 +497,5 @@

Footnotes

} +
diff --git a/wordkeep-client/src/app/pages/extraction-reader/extraction-reader.scss b/wordkeep-client/src/app/pages/extraction-reader/extraction-reader.scss index 972a429..38c415b 100644 --- a/wordkeep-client/src/app/pages/extraction-reader/extraction-reader.scss +++ b/wordkeep-client/src/app/pages/extraction-reader/extraction-reader.scss @@ -412,13 +412,12 @@ justify-content: center; pointer-events: none; border-radius: 4px; - font-size: 0; - &::after { - content: 'IGNORED'; + &__badge { font-size: 0.875rem; font-weight: 700; letter-spacing: 0.1em; + text-transform: uppercase; padding: 0.25rem 0.75rem; background: var(--color-fg-muted); color: var(--color-surface-2); diff --git a/wordkeep-client/src/app/pages/extraction-reader/extraction-reader.spec.ts b/wordkeep-client/src/app/pages/extraction-reader/extraction-reader.spec.ts index 5349b49..d921595 100644 --- a/wordkeep-client/src/app/pages/extraction-reader/extraction-reader.spec.ts +++ b/wordkeep-client/src/app/pages/extraction-reader/extraction-reader.spec.ts @@ -8,6 +8,7 @@ import { AuthService } from '../../services/auth.service'; import { ConfirmationService } from '../../services/confirmation.service'; import { mockDocument, mockFailedDocument, mockOcrResult, mockPageInfoText, mockPageInfoImage, mockPageInfoHybrid, mockTextExtractionResult, mockFootnote, mockLinkedFootnote } from '../../test-utils'; import { Footnote } from '../../models/document.model'; +import { getTranslocoTestingModule } from '../../transloco/transloco-testing'; describe('ExtractionReaderComponent', () => { let component: ExtractionReaderComponent; @@ -81,7 +82,7 @@ describe('ExtractionReaderComponent', () => { TestBed.resetTestingModule(); TestBed.configureTestingModule({ - imports: [ExtractionReaderComponent], + imports: [ExtractionReaderComponent, getTranslocoTestingModule()], providers: [ provideRouter([]), { provide: DocumentService, useValue: mockDocumentService }, diff --git a/wordkeep-client/src/app/pages/extraction-reader/extraction-reader.ts b/wordkeep-client/src/app/pages/extraction-reader/extraction-reader.ts index 1980453..1c4396f 100644 --- a/wordkeep-client/src/app/pages/extraction-reader/extraction-reader.ts +++ b/wordkeep-client/src/app/pages/extraction-reader/extraction-reader.ts @@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; +import { provideTranslocoScope, TranslocoDirective, TranslocoService } from '@jsverse/transloco'; import { DocumentService, PageInfoResult } from '../../services/document.service'; import { ConfirmationService } from '../../services/confirmation.service'; import { SpellCheckService, NspellChecker } from '../../services/spell-check.service'; @@ -20,9 +21,10 @@ import TurndownService from 'turndown'; @Component({ selector: 'app-extraction-reader', standalone: true, - imports: [CommonModule, FormsModule, RouterLink, LayoutComponent, NgxEditorModule], + imports: [CommonModule, FormsModule, RouterLink, LayoutComponent, NgxEditorModule, TranslocoDirective], templateUrl: './extraction-reader.html', - styleUrl: './extraction-reader.scss' + styleUrl: './extraction-reader.scss', + providers: [provideTranslocoScope('extraction')] }) export class ExtractionReaderComponent implements OnInit, OnDestroy { private route = inject(ActivatedRoute); @@ -35,6 +37,7 @@ export class ExtractionReaderComponent implements OnInit, OnDestroy { private destroyRef = inject(DestroyRef); private elementRef = inject(ElementRef); private sanitizer = inject(DomSanitizer); + private transloco = inject(TranslocoService); private spellClickSubject = new Subject(); private currentChecker: NspellChecker | null = null; @@ -193,10 +196,10 @@ export class ExtractionReaderComponent implements OnInit, OnDestroy { }); insertFootnoteTooltip = computed((): string => { - if (!this.editMode()) return 'Enter edit mode first'; - if (this.footnotes().length === 0) return 'No footnotes exist yet'; - if (!this.canInsertFootnoteRef()) return 'All footnotes already referenced'; - return 'Insert footnote reference'; + if (!this.editMode()) return this.transloco.translate('extraction.reader.insertFootnoteTooltip.enterEditFirst'); + if (this.footnotes().length === 0) return this.transloco.translate('extraction.reader.insertFootnoteTooltip.noFootnotes'); + if (!this.canInsertFootnoteRef()) return this.transloco.translate('extraction.reader.insertFootnoteTooltip.allReferenced'); + return this.transloco.translate('extraction.reader.insertFootnoteTooltip.insert'); }); footnotePageMismatch = computed((): boolean => { @@ -356,7 +359,7 @@ export class ExtractionReaderComponent implements OnInit, OnDestroy { this.loadFootnotes(); }, error: () => { - this.error.set('Failed to unlink orphaned footnotes'); + this.error.set(this.transloco.translate('extraction.reader.errors.unlinkOrphanFailed')); this.saving.set(false); this.loadFootnotes(); } @@ -367,7 +370,7 @@ export class ExtractionReaderComponent implements OnInit, OnDestroy { } }, error: (err: any) => { - const msg = err?.error?.message || 'Failed to save changes'; + const msg = err?.error?.message || this.transloco.translate('extraction.reader.errors.saveFailed'); this.error.set(msg); this.saving.set(false); } @@ -405,7 +408,7 @@ export class ExtractionReaderComponent implements OnInit, OnDestroy { this.loadOcrForCurrentPage(); }, error: () => { - this.error.set('Failed to update page ignore status'); + this.error.set(this.transloco.translate('extraction.reader.errors.ignoreStatusFailed')); this.togglingIgnore.set(false); } }); @@ -416,9 +419,9 @@ export class ExtractionReaderComponent implements OnInit, OnDestroy { if (!doc) return; const confirmed = await this.confirmationService.confirm({ - title: 'Clear text', - message: 'Delete extracted text for this page?', - confirmText: 'Clear', + title: this.transloco.translate('extraction.reader.confirm.clearTitle'), + message: this.transloco.translate('extraction.reader.confirm.clearMessage'), + confirmText: this.transloco.translate('extraction.reader.confirm.clearConfirm'), isDanger: true }); if (!confirmed) return; @@ -434,7 +437,7 @@ export class ExtractionReaderComponent implements OnInit, OnDestroy { this.loadFootnotes(); }, error: () => { - this.error.set('Failed to clear text'); + this.error.set(this.transloco.translate('extraction.reader.errors.clearFailed')); this.clearing.set(false); } }); @@ -460,7 +463,7 @@ export class ExtractionReaderComponent implements OnInit, OnDestroy { } this.pageImageUrl.set(null); this.loadingImage.set(false); - this.error.set('Failed to load page image'); + this.error.set(this.transloco.translate('extraction.reader.errors.loadImageFailed')); } }); } @@ -502,7 +505,7 @@ export class ExtractionReaderComponent implements OnInit, OnDestroy { this.checkAndStartLangPolling(); }, error: (err) => { - this.error.set('Failed to load document'); + this.error.set(this.transloco.translate('extraction.reader.errors.loadDocumentFailed')); this.loading.set(false); } }); @@ -625,7 +628,7 @@ export class ExtractionReaderComponent implements OnInit, OnDestroy { const updatedProcessing = new Set(this.processingOcr()); updatedProcessing.delete(pageNum); this.processingOcr.set(updatedProcessing); - this.error.set('Failed to process OCR'); + this.error.set(this.transloco.translate('extraction.reader.errors.processOcrFailed')); } }); } @@ -658,7 +661,7 @@ export class ExtractionReaderComponent implements OnInit, OnDestroy { const updatedExtracting = new Set(this.extractingText()); updatedExtracting.delete(pageNum); this.extractingText.set(updatedExtracting); - this.error.set('Failed to extract text'); + this.error.set(this.transloco.translate('extraction.reader.errors.extractTextFailed')); } }); } @@ -733,7 +736,7 @@ export class ExtractionReaderComponent implements OnInit, OnDestroy { const markdown = this.turndownService.turndown(this.footnoteEditorContent); if (!markdown.trim()) { - this.error.set('Footnote text cannot be empty'); + this.error.set(this.transloco.translate('extraction.reader.errors.footnoteEmpty')); return; } @@ -750,7 +753,7 @@ export class ExtractionReaderComponent implements OnInit, OnDestroy { this.savingFootnote.set(false); }, error: () => { - this.error.set('Failed to create footnote'); + this.error.set(this.transloco.translate('extraction.reader.errors.createFootnoteFailed')); this.savingFootnote.set(false); } }); @@ -768,7 +771,7 @@ export class ExtractionReaderComponent implements OnInit, OnDestroy { this.savingFootnote.set(false); }, error: () => { - this.error.set('Failed to update footnote'); + this.error.set(this.transloco.translate('extraction.reader.errors.updateFootnoteFailed')); this.savingFootnote.set(false); } }); @@ -781,9 +784,9 @@ export class ExtractionReaderComponent implements OnInit, OnDestroy { if (!doc || !fn) return; const confirmed = await this.confirmationService.confirm({ - title: 'Delete footnote', - message: `Delete footnote [^${fn.number}]? References will be removed from page texts.`, - confirmText: 'Delete', + title: this.transloco.translate('extraction.reader.confirm.deleteFootnoteTitle'), + message: this.transloco.translate('extraction.reader.confirm.deleteFootnoteMessage', { number: fn.number }), + confirmText: this.transloco.translate('extraction.reader.confirm.deleteFootnoteConfirm'), isDanger: true }); if (!confirmed) return; @@ -808,7 +811,7 @@ export class ExtractionReaderComponent implements OnInit, OnDestroy { this.loadOcrForCurrentPage(); }, error: () => { - this.error.set('Failed to delete footnote'); + this.error.set(this.transloco.translate('extraction.reader.errors.deleteFootnoteFailed')); this.deletingFootnote.set(false); } }); @@ -820,9 +823,9 @@ export class ExtractionReaderComponent implements OnInit, OnDestroy { if (!doc || !fn || fn.page_number === null) return; const confirmed = await this.confirmationService.confirm({ - title: 'Unlink footnote', - message: `Remove [^${fn.number}] reference from page ${fn.page_number} text?`, - confirmText: 'Unlink', + title: this.transloco.translate('extraction.reader.confirm.unlinkFootnoteTitle'), + message: this.transloco.translate('extraction.reader.confirm.unlinkFootnoteMessage', { number: fn.number, page: fn.page_number }), + confirmText: this.transloco.translate('extraction.reader.confirm.unlinkFootnoteConfirm'), isDanger: true }); if (!confirmed) return; @@ -839,7 +842,7 @@ export class ExtractionReaderComponent implements OnInit, OnDestroy { this.loadOcrForCurrentPage(); }, error: () => { - this.error.set('Failed to unlink footnote'); + this.error.set(this.transloco.translate('extraction.reader.errors.unlinkFootnoteFailed')); this.unlinkingFootnote.set(false); } }); @@ -964,10 +967,10 @@ export class ExtractionReaderComponent implements OnInit, OnDestroy { getContentTypeLabel(contentType: ContentType | null): string { switch (contentType) { - case 'text': return 'Text'; - case 'image': return 'Scanned'; - case 'hybrid': return 'Mixed'; - default: return 'Unknown'; + case 'text': return this.transloco.translate('extraction.reader.contentType.text'); + case 'image': return this.transloco.translate('extraction.reader.contentType.image'); + case 'hybrid': return this.transloco.translate('extraction.reader.contentType.hybrid'); + default: return this.transloco.translate('extraction.reader.contentType.unknown'); } } @@ -982,27 +985,27 @@ export class ExtractionReaderComponent implements OnInit, OnDestroy { getContentTypeTooltip(contentType: ContentType | null): string { switch (contentType) { - case 'text': return 'Text page. "Extract Text" = fast, plain text. "AI OCR" = slower, formatted Markdown.'; - case 'image': return 'Scanned page. Use "AI OCR" to extract text using AI vision.'; - case 'hybrid': return 'Mixed page (text + images). "Extract Text" = fast, plain text. "AI OCR" = formatted with image content.'; + case 'text': return this.transloco.translate('extraction.reader.contentTypeTooltip.text'); + case 'image': return this.transloco.translate('extraction.reader.contentTypeTooltip.image'); + case 'hybrid': return this.transloco.translate('extraction.reader.contentTypeTooltip.hybrid'); default: return ''; } } getExtractionMethodLabel(method: string | null | undefined): string { switch (method) { - case 'text_extraction': return 'Direct'; - case 'ocr': return 'OCR'; - case 'manual': return 'Manual'; + case 'text_extraction': return this.transloco.translate('extraction.reader.extractionMethod.text_extraction'); + case 'ocr': return this.transloco.translate('extraction.reader.extractionMethod.ocr'); + case 'manual': return this.transloco.translate('extraction.reader.extractionMethod.manual'); default: return ''; } } getExtractionMethodTooltip(method: string | null | undefined): string { switch (method) { - case 'text_extraction': return 'Extracted directly from PDF (fast, plain text only)'; - case 'ocr': return 'Extracted using AI vision (preserves formatting: bold, headings, paragraphs)'; - case 'manual': return 'Manually transcribed by user'; + case 'text_extraction': return this.transloco.translate('extraction.reader.extractionMethodTooltip.text_extraction'); + case 'ocr': return this.transloco.translate('extraction.reader.extractionMethodTooltip.ocr'); + case 'manual': return this.transloco.translate('extraction.reader.extractionMethodTooltip.manual'); default: return ''; } } @@ -1016,45 +1019,15 @@ export class ExtractionReaderComponent implements OnInit, OnDestroy { } getLanguageDisplayName(code: string | null): string { - if (!code) return 'Not detected'; - const languages: Record = { - en: 'English', - es: 'Spanish', - fr: 'French', - de: 'German', - it: 'Italian', - pt: 'Portuguese', - ru: 'Russian', - zh: 'Chinese', - ja: 'Japanese', - ko: 'Korean', - ar: 'Arabic', - hi: 'Hindi', - ro: 'Romanian', - nl: 'Dutch', - pl: 'Polish', - sv: 'Swedish', - da: 'Danish', - no: 'Norwegian', - fi: 'Finnish', - cs: 'Czech', - el: 'Greek', - tr: 'Turkish', - he: 'Hebrew', - th: 'Thai', - vi: 'Vietnamese', - id: 'Indonesian', - ms: 'Malay', - la: 'Latin', - uk: 'Ukrainian', - hu: 'Hungarian', - bg: 'Bulgarian', - hr: 'Croatian', - sk: 'Slovak', - sl: 'Slovenian', - sr: 'Serbian', - unknown: 'Unknown' - }; - return languages[code] || code.toUpperCase(); + if (!code) return this.transloco.translate('extraction.reader.language.notDetected'); + const knownCodes = [ + 'en', 'es', 'fr', 'de', 'it', 'pt', 'ru', 'zh', 'ja', 'ko', 'ar', 'hi', + 'ro', 'nl', 'pl', 'sv', 'da', 'no', 'fi', 'cs', 'el', 'tr', 'he', 'th', + 'vi', 'id', 'ms', 'la', 'uk', 'hu', 'bg', 'hr', 'sk', 'sl', 'sr', 'unknown' + ]; + if (knownCodes.includes(code)) { + return this.transloco.translate(`extraction.reader.language.${code}`); + } + return code.toUpperCase(); } } diff --git a/wordkeep-client/src/app/pages/extraction/extraction.html b/wordkeep-client/src/app/pages/extraction/extraction.html index f1f1239..15722e9 100644 --- a/wordkeep-client/src/app/pages/extraction/extraction.html +++ b/wordkeep-client/src/app/pages/extraction/extraction.html @@ -1,9 +1,10 @@ +
@@ -25,10 +26,10 @@

Text Extraction

-

Your Documents

+

{{ t('yourDocuments') }}

@if (total() > 0) { - {{ total() }} document{{ total() === 1 ? '' : 's' }} + {{ t('docCount', { count: total() }) }} }
@@ -37,7 +38,7 @@

Your Documents

@if (loading()) {
-

Loading documents...

+

{{ t('loading') }}

} @else if (documents().length === 0) {
@@ -46,8 +47,8 @@

Your Documents

-

No documents yet

-

Upload documents in the Documents section first

+

{{ t('emptyTitle') }}

+

{{ t('emptySubtitle') }}

} @else {
@@ -63,11 +64,11 @@

No documents yet

{{ doc.effective_filename }}

@if (doc.has_custom_name) { - renamed + {{ t('renamed') }} }
- {{ doc.page_count }} page{{ doc.page_count === 1 ? '' : 's' }} + {{ t('pageCount', { count: doc.page_count }) }} {{ formatFileSize(doc.file_size) }}
@@ -75,7 +76,7 @@

{{ doc.effective_filename }}

- {{ doc.ocr_progress?.completed ?? 0 }}/{{ doc.page_count }} extracted ({{ doc.ocr_progress?.percent ?? 0 }}%) + {{ t('ocrProgress', { completed: doc.ocr_progress?.completed ?? 0, total: doc.page_count, percent: doc.ocr_progress?.percent ?? 0 }) }}
@@ -89,7 +90,7 @@

{{ doc.effective_filename }}

-
} @else if (doc.status === 'ready') {
-
} @@ -126,15 +127,15 @@

{{ doc.effective_filename }}

- Previous + {{ t('previous') }} - Page {{ currentPage() }} of {{ lastPage() }} + {{ t('pageOf', { current: currentPage(), last: lastPage() }) }} } diff --git a/wordkeep-client/src/app/pages/forgot-password/forgot-password.spec.ts b/wordkeep-client/src/app/pages/forgot-password/forgot-password.spec.ts index ed4814f..2d7e445 100644 --- a/wordkeep-client/src/app/pages/forgot-password/forgot-password.spec.ts +++ b/wordkeep-client/src/app/pages/forgot-password/forgot-password.spec.ts @@ -4,6 +4,7 @@ import { of, throwError } from 'rxjs'; import { describe, it, expect, beforeEach, vi } from 'vitest'; import { ForgotPasswordComponent } from './forgot-password'; import { AuthService } from '../../services/auth.service'; +import { getTranslocoTestingModule } from '../../transloco/transloco-testing'; describe('ForgotPasswordComponent', () => { let component: ForgotPasswordComponent; @@ -14,7 +15,7 @@ describe('ForgotPasswordComponent', () => { mockAuthService = { forgotPassword: vi.fn() }; await TestBed.configureTestingModule({ - imports: [ForgotPasswordComponent], + imports: [ForgotPasswordComponent, getTranslocoTestingModule()], providers: [ provideRouter([]), { provide: AuthService, useValue: mockAuthService } diff --git a/wordkeep-client/src/app/pages/forgot-password/forgot-password.ts b/wordkeep-client/src/app/pages/forgot-password/forgot-password.ts index 27ad9eb..daa6268 100644 --- a/wordkeep-client/src/app/pages/forgot-password/forgot-password.ts +++ b/wordkeep-client/src/app/pages/forgot-password/forgot-password.ts @@ -1,17 +1,19 @@ -import { Component, signal } from '@angular/core'; +import { Component, inject, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { RouterLink } from '@angular/router'; +import { TranslocoDirective, TranslocoService } from '@jsverse/transloco'; import { AuthService } from '../../services/auth.service'; @Component({ selector: 'app-forgot-password', standalone: true, - imports: [CommonModule, FormsModule, RouterLink], + imports: [CommonModule, FormsModule, RouterLink, TranslocoDirective], templateUrl: './forgot-password.html', styleUrl: './forgot-password.scss' }) export class ForgotPasswordComponent { + private transloco = inject(TranslocoService); email = ''; error = signal(null); loading = signal(false); @@ -23,7 +25,7 @@ export class ForgotPasswordComponent { this.error.set(null); if (!this.email) { - this.error.set('Please enter your email'); + this.error.set(this.transloco.translate('auth.forgot.enterEmail')); return; } @@ -36,7 +38,7 @@ export class ForgotPasswordComponent { }, error: () => { this.loading.set(false); - this.error.set('An error occurred. Please try again.'); + this.error.set(this.transloco.translate('auth.common.genericError')); } }); } diff --git a/wordkeep-client/src/app/pages/home/home.spec.ts b/wordkeep-client/src/app/pages/home/home.spec.ts index 44e3bd8..3b247e3 100644 --- a/wordkeep-client/src/app/pages/home/home.spec.ts +++ b/wordkeep-client/src/app/pages/home/home.spec.ts @@ -6,6 +6,7 @@ import { HomeComponent } from './home'; import { AuthService } from '../../services/auth.service'; import { LayoutService } from '../../services/layout.service'; import { WorkStatusService } from '../../services/work-status.service'; +import { getTranslocoTestingModule } from '../../transloco/transloco-testing'; describe('HomeComponent (dispatcher)', () => { let fixture: ComponentFixture; @@ -29,7 +30,7 @@ describe('HomeComponent (dispatcher)', () => { }; await TestBed.configureTestingModule({ - imports: [HomeComponent], + imports: [HomeComponent, getTranslocoTestingModule()], providers: [ provideRouter([]), { provide: AuthService, useValue: mockAuthService }, diff --git a/wordkeep-client/src/app/pages/home/variants/home-compendium/home-compendium.html b/wordkeep-client/src/app/pages/home/variants/home-compendium/home-compendium.html index dc8a9c8..02062c7 100644 --- a/wordkeep-client/src/app/pages/home/variants/home-compendium/home-compendium.html +++ b/wordkeep-client/src/app/pages/home/variants/home-compendium/home-compendium.html @@ -1,50 +1,50 @@ -
- Today's bulletin +
+ {{ t('compendium.bulletin') }}
- From the editor -

Welcome back, {{ authService.userName() }}

-

Pick up where you left off, or open a new chapter.

+ {{ t('compendium.fromEditor') }} +

{{ t('hero.greeting', { name: authService.userName() }) }}

+

{{ t('hero.subtitle') }}

-
- In this issue +
+ {{ t('compendium.inThisIssue') }}
@@ -53,7 +53,7 @@

Translation

- Wire — Recent + {{ t('compendium.wireRecent') }}
diff --git a/wordkeep-client/src/app/pages/home/variants/home-compendium/home-compendium.ts b/wordkeep-client/src/app/pages/home/variants/home-compendium/home-compendium.ts index 2196660..7f59007 100644 --- a/wordkeep-client/src/app/pages/home/variants/home-compendium/home-compendium.ts +++ b/wordkeep-client/src/app/pages/home/variants/home-compendium/home-compendium.ts @@ -3,11 +3,13 @@ import { CommonModule } from '@angular/common'; import { RouterLink } from '@angular/router'; import { AuthService } from '../../../../services/auth.service'; import { RecentActivityComponent } from '../../../../components/recent-activity/recent-activity'; +import { provideTranslocoScope, TranslocoDirective } from '@jsverse/transloco'; @Component({ selector: 'app-home-compendium', standalone: true, - imports: [CommonModule, RouterLink, RecentActivityComponent], + imports: [CommonModule, RouterLink, RecentActivityComponent, TranslocoDirective], + providers: [provideTranslocoScope('home')], templateUrl: './home-compendium.html', styleUrl: './home-compendium.scss' }) diff --git a/wordkeep-client/src/app/pages/home/variants/home-console/home-console.html b/wordkeep-client/src/app/pages/home/variants/home-console/home-console.html index 729bb98..297c573 100644 --- a/wordkeep-client/src/app/pages/home/variants/home-console/home-console.html +++ b/wordkeep-client/src/app/pages/home/variants/home-console/home-console.html @@ -1,25 +1,21 @@ -
+
$ wordkeep --hello - {{ today }} · uid:{{ authService.userName() }} +

-

NAME

-
    wordkeep — keep the words you read
+

{{ t('console.name') }}

+

 
-    

SYNOPSIS

+

{{ t('console.synopsis') }}

    wordkeep <tool> [options]
     wordkeep tail -f activity
-

DESCRIPTION

-
    A vocabulary & OCR workbench for the readers who want
-    every word they meet to be kept. Upload PDFs, lift text
-    with OCR, stitch consecutive pages, translate, look up.
-
-    Press / at any time to jump between sections.
+

{{ t('console.description') }}

+

   

@@ -31,36 +27,36 @@

DESCRIPTION

  • documents - Upload, organise, curate the PDFs you keep. - open › + {{ t('console.cards.documents') }} +
  • extraction - Lift words off the page with the OCR scribe. - open › + {{ t('console.cards.extraction') }} +
  • page-linking - Bind text across consecutive pages. - open › + {{ t('console.cards.pageLinking') }} +
  • book-view - Read assembled text and export Markdown. - open › + {{ t('console.cards.documentView') }} +
  • translation - Carry your documents into another tongue. - open › + {{ t('console.cards.translation') }} +
  • diff --git a/wordkeep-client/src/app/pages/home/variants/home-console/home-console.ts b/wordkeep-client/src/app/pages/home/variants/home-console/home-console.ts index 77f478a..b433171 100644 --- a/wordkeep-client/src/app/pages/home/variants/home-console/home-console.ts +++ b/wordkeep-client/src/app/pages/home/variants/home-console/home-console.ts @@ -3,11 +3,13 @@ import { CommonModule } from '@angular/common'; import { RouterLink } from '@angular/router'; import { AuthService } from '../../../../services/auth.service'; import { RecentActivityComponent } from '../../../../components/recent-activity/recent-activity'; +import { provideTranslocoScope, TranslocoDirective } from '@jsverse/transloco'; @Component({ selector: 'app-home-console', standalone: true, - imports: [CommonModule, RouterLink, RecentActivityComponent], + imports: [CommonModule, RouterLink, RecentActivityComponent, TranslocoDirective], + providers: [provideTranslocoScope('home')], templateUrl: './home-console.html', styleUrl: './home-console.scss' }) diff --git a/wordkeep-client/src/app/pages/home/variants/home-glass/home-glass.html b/wordkeep-client/src/app/pages/home/variants/home-glass/home-glass.html index 63b0a5e..e20683b 100644 --- a/wordkeep-client/src/app/pages/home/variants/home-glass/home-glass.html +++ b/wordkeep-client/src/app/pages/home/variants/home-glass/home-glass.html @@ -1,8 +1,8 @@ -
    +
    - The library -

    Welcome back, {{ authService.userName() }}

    -

    Pick up where you left off, or open a new chapter.

    + {{ t('hero.eyebrow') }} +

    {{ t('hero.greeting', { name: authService.userName() }) }}

    +

    {{ t('hero.subtitle') }}

    - The tools + {{ t('tools.eyebrow') }} -

    Documents

    -

    Upload, organise and curate the PDFs you keep.

    - Open the stacks → +

    {{ t('cards.documents.title') }}

    +

    {{ t('cards.documents.lede') }}

    +
    @@ -34,9 +34,9 @@

    Documents

    -

    Text extraction

    -

    Lift words off the page with OCR.

    - Extract → +

    {{ t('cards.extraction.title') }}

    +

    {{ t('cards.extraction.lede') }}

    +
    @@ -46,9 +46,9 @@

    Text extraction

    -

    Page linking

    -

    Stitch text across consecutive pages.

    - Link → +

    {{ t('cards.pageLinking.title') }}

    +

    {{ t('cards.pageLinking.lede') }}

    +
    @@ -58,9 +58,9 @@

    Page linking

    -

    Document view

    -

    Read assembled text and export as Markdown.

    - Read → +

    {{ t('cards.documentView.title') }}

    +

    {{ t('cards.documentView.lede') }}

    +
    @@ -69,9 +69,9 @@

    Document view

    -

    Translation

    -

    Carry your documents into another language.

    - Translate → +

    {{ t('cards.translation.title') }}

    +

    {{ t('cards.translation.lede') }}

    +
    diff --git a/wordkeep-client/src/app/pages/home/variants/home-glass/home-glass.ts b/wordkeep-client/src/app/pages/home/variants/home-glass/home-glass.ts index a410f34..ca48e28 100644 --- a/wordkeep-client/src/app/pages/home/variants/home-glass/home-glass.ts +++ b/wordkeep-client/src/app/pages/home/variants/home-glass/home-glass.ts @@ -3,11 +3,13 @@ import { CommonModule } from '@angular/common'; import { RouterLink } from '@angular/router'; import { AuthService } from '../../../../services/auth.service'; import { RecentActivityComponent } from '../../../../components/recent-activity/recent-activity'; +import { provideTranslocoScope, TranslocoDirective } from '@jsverse/transloco'; @Component({ selector: 'app-home-glass', standalone: true, - imports: [CommonModule, RouterLink, RecentActivityComponent], + imports: [CommonModule, RouterLink, RecentActivityComponent, TranslocoDirective], + providers: [provideTranslocoScope('home')], templateUrl: './home-glass.html', styleUrl: './home-glass.scss' }) diff --git a/wordkeep-client/src/app/pages/home/variants/home-reading-room/home-reading-room.html b/wordkeep-client/src/app/pages/home/variants/home-reading-room/home-reading-room.html index 63b0a5e..e20683b 100644 --- a/wordkeep-client/src/app/pages/home/variants/home-reading-room/home-reading-room.html +++ b/wordkeep-client/src/app/pages/home/variants/home-reading-room/home-reading-room.html @@ -1,8 +1,8 @@ -
    +
    - The library -

    Welcome back, {{ authService.userName() }}

    -

    Pick up where you left off, or open a new chapter.

    + {{ t('hero.eyebrow') }} +

    {{ t('hero.greeting', { name: authService.userName() }) }}

    +

    {{ t('hero.subtitle') }}

    - The tools + {{ t('tools.eyebrow') }} -

    Documents

    -

    Upload, organise and curate the PDFs you keep.

    - Open the stacks → +

    {{ t('cards.documents.title') }}

    +

    {{ t('cards.documents.lede') }}

    +
    @@ -34,9 +34,9 @@

    Documents

    -

    Text extraction

    -

    Lift words off the page with OCR.

    - Extract → +

    {{ t('cards.extraction.title') }}

    +

    {{ t('cards.extraction.lede') }}

    +
    @@ -46,9 +46,9 @@

    Text extraction

    -

    Page linking

    -

    Stitch text across consecutive pages.

    - Link → +

    {{ t('cards.pageLinking.title') }}

    +

    {{ t('cards.pageLinking.lede') }}

    +
    @@ -58,9 +58,9 @@

    Page linking

    -

    Document view

    -

    Read assembled text and export as Markdown.

    - Read → +

    {{ t('cards.documentView.title') }}

    +

    {{ t('cards.documentView.lede') }}

    +
    @@ -69,9 +69,9 @@

    Document view

    -

    Translation

    -

    Carry your documents into another language.

    - Translate → +

    {{ t('cards.translation.title') }}

    +

    {{ t('cards.translation.lede') }}

    +
    diff --git a/wordkeep-client/src/app/pages/home/variants/home-reading-room/home-reading-room.ts b/wordkeep-client/src/app/pages/home/variants/home-reading-room/home-reading-room.ts index cfc95bb..1a6f914 100644 --- a/wordkeep-client/src/app/pages/home/variants/home-reading-room/home-reading-room.ts +++ b/wordkeep-client/src/app/pages/home/variants/home-reading-room/home-reading-room.ts @@ -3,11 +3,13 @@ import { CommonModule } from '@angular/common'; import { RouterLink } from '@angular/router'; import { AuthService } from '../../../../services/auth.service'; import { RecentActivityComponent } from '../../../../components/recent-activity/recent-activity'; +import { provideTranslocoScope, TranslocoDirective } from '@jsverse/transloco'; @Component({ selector: 'app-home-reading-room', standalone: true, - imports: [CommonModule, RouterLink, RecentActivityComponent], + imports: [CommonModule, RouterLink, RecentActivityComponent, TranslocoDirective], + providers: [provideTranslocoScope('home')], templateUrl: './home-reading-room.html', styleUrl: './home-reading-room.scss' }) diff --git a/wordkeep-client/src/app/pages/home/variants/home-scriptorium/home-scriptorium.html b/wordkeep-client/src/app/pages/home/variants/home-scriptorium/home-scriptorium.html index 687dc42..9f91955 100644 --- a/wordkeep-client/src/app/pages/home/variants/home-scriptorium/home-scriptorium.html +++ b/wordkeep-client/src/app/pages/home/variants/home-scriptorium/home-scriptorium.html @@ -1,25 +1,25 @@ -
    +
    - Folium primum · the library +
    -

    Welcome back, {{ authService.userName() }} — pick up where the quill last left off, or open a fresh leaf and begin a new chapter.

    +

    - Capitula · tools at hand +
    1. - On the keeping of Documents - Upload, organise & curate the PDFs you keep. + + @@ -28,8 +28,8 @@

      Welcome back, {{ authService.userName() }} — pick u - On the lifting of Words - The OCR scribe takes them off the page. + + @@ -38,8 +38,8 @@

      Welcome back, {{ authService.userName() }} — pick u - On the binding of Pages - Stitch text across consecutive folios. + + @@ -48,8 +48,8 @@

      Welcome back, {{ authService.userName() }} — pick u - On the reading of the Codex - Read the assembled text and export as Markdown. + + @@ -58,8 +58,8 @@

      Welcome back, {{ authService.userName() }} — pick u - On Translation & the carrying-over - Carry your documents into another tongue. + + @@ -70,7 +70,7 @@

      Welcome back, {{ authService.userName() }} — pick u
      - Chronicon · recently entered +
      diff --git a/wordkeep-client/src/app/pages/home/variants/home-scriptorium/home-scriptorium.ts b/wordkeep-client/src/app/pages/home/variants/home-scriptorium/home-scriptorium.ts index 9b5cc5b..5c95261 100644 --- a/wordkeep-client/src/app/pages/home/variants/home-scriptorium/home-scriptorium.ts +++ b/wordkeep-client/src/app/pages/home/variants/home-scriptorium/home-scriptorium.ts @@ -3,11 +3,13 @@ import { CommonModule } from '@angular/common'; import { RouterLink } from '@angular/router'; import { AuthService } from '../../../../services/auth.service'; import { RecentActivityComponent } from '../../../../components/recent-activity/recent-activity'; +import { provideTranslocoScope, TranslocoDirective } from '@jsverse/transloco'; @Component({ selector: 'app-home-scriptorium', standalone: true, - imports: [CommonModule, RouterLink, RecentActivityComponent], + imports: [CommonModule, RouterLink, RecentActivityComponent, TranslocoDirective], + providers: [provideTranslocoScope('home')], templateUrl: './home-scriptorium.html', styleUrl: './home-scriptorium.scss' }) diff --git a/wordkeep-client/src/app/pages/home/variants/home-workbench/home-workbench.html b/wordkeep-client/src/app/pages/home/variants/home-workbench/home-workbench.html index f461691..c082335 100644 --- a/wordkeep-client/src/app/pages/home/variants/home-workbench/home-workbench.html +++ b/wordkeep-client/src/app/pages/home/variants/home-workbench/home-workbench.html @@ -1,14 +1,14 @@ -
      +
      - The library -

      Welcome back, {{ authService.userName() }}

      -

      Pick up where you left off, or open a new chapter.

      + {{ t('hero.eyebrow') }} +

      {{ t('hero.greeting', { name: authService.userName() }) }}

      +

      {{ t('hero.subtitle') }}

      -
      +
      - The tools - scroll → + {{ t('tools.eyebrow') }} +
      @@ -22,9 +22,9 @@

      Welcome back, {{ authService.userName()

      -

      Documents

      -

      Upload, organise and curate the PDFs you keep.

      - Open the stacks → +

      {{ t('cards.documents.title') }}

      +

      {{ t('cards.documents.lede') }}

      + @@ -34,9 +34,9 @@

      Documents

      -

      Text extraction

      -

      Lift words off the page with OCR.

      - Extract → +

      {{ t('cards.extraction.title') }}

      +

      {{ t('cards.extraction.lede') }}

      +
      @@ -47,9 +47,9 @@

      Text extraction

      -

      Page linking

      -

      Stitch text across consecutive pages.

      - Link → +

      {{ t('cards.pageLinking.title') }}

      +

      {{ t('cards.pageLinking.lede') }}

      +
      @@ -60,9 +60,9 @@

      Page linking

      -

      Document view

      -

      Read assembled text and export as Markdown.

      - Read → +

      {{ t('cards.documentView.title') }}

      +

      {{ t('cards.documentView.lede') }}

      +
      @@ -72,15 +72,15 @@

      Document view

      -

      Translation

      -

      Carry your documents into another language.

      - Translate → +

      {{ t('cards.translation.title') }}

      +

      {{ t('cards.translation.lede') }}

      +

    - Recent + {{ t('activity.eyebrow') }}
    diff --git a/wordkeep-client/src/app/pages/home/variants/home-workbench/home-workbench.ts b/wordkeep-client/src/app/pages/home/variants/home-workbench/home-workbench.ts index 2a4fa18..97fb845 100644 --- a/wordkeep-client/src/app/pages/home/variants/home-workbench/home-workbench.ts +++ b/wordkeep-client/src/app/pages/home/variants/home-workbench/home-workbench.ts @@ -3,11 +3,13 @@ import { CommonModule } from '@angular/common'; import { RouterLink } from '@angular/router'; import { AuthService } from '../../../../services/auth.service'; import { RecentActivityComponent } from '../../../../components/recent-activity/recent-activity'; +import { provideTranslocoScope, TranslocoDirective } from '@jsverse/transloco'; @Component({ selector: 'app-home-workbench', standalone: true, - imports: [CommonModule, RouterLink, RecentActivityComponent], + imports: [CommonModule, RouterLink, RecentActivityComponent, TranslocoDirective], + providers: [provideTranslocoScope('home')], templateUrl: './home-workbench.html', styleUrl: './home-workbench.scss' }) diff --git a/wordkeep-client/src/app/pages/landing/landing.html b/wordkeep-client/src/app/pages/landing/landing.html index 6d66784..8820f38 100644 --- a/wordkeep-client/src/app/pages/landing/landing.html +++ b/wordkeep-client/src/app/pages/landing/landing.html @@ -1,4 +1,24 @@ -
    +
    + + + @@ -10,18 +30,15 @@

    Carta Constitutionis - Founding Charter +

    WordKeep

    -

    - Granted by the Order of the Steadfast Knight, - in the year of grace MMXXVI. -

    +

    - +

    @@ -33,24 +50,14 @@

    WordKeep

    quae WordKeep nominatur, ad verba tuenda, folia iungenda, et linguam vertendam, quamdiu libri fuerint. - - Let - those present and to come know that we, - Master of the Order of the Steadfast Knight, - in honor of the keeping of books, have granted, established, - and by this present charter confirmed the workshop called - WordKeep, for the guarding of words, the joining of - leaves, and the turning of tongues, for as long as books shall be. - +

    Praeterea statutum est haec tria officia, sub uno sigillo: - - Moreover, these three offices have been appointed, under one seal: - +

    @@ -62,7 +69,7 @@

    WordKeep

    De Lectione Paginae - Of the Reading of the Page +

    @@ -70,15 +77,11 @@

    reddantur; rubricationes, manus, et marginalia inviolata servabuntur, nihil omittendo. - - That pages already written, by pen or by type, shall be - returned in faithful reading; the rubrics, the hands, and - the marginalia shall be preserved inviolate, nothing omitted. - +

    — manu propria - — by my own hand +

    @@ -88,7 +91,7 @@

    De Iunctione Foliorum - Of the Joining of the Leaves +

    @@ -96,15 +99,11 @@

    divisae reconcilientur, notae infra positae ad verba sua redeant. - - That facing pages shall be sewn together, sentences divided - across leaves reconciled, and notes placed below restored to - their own words. - +

    — manu propria - — by my own hand +

    @@ -114,7 +113,7 @@

    De Versione Linguae - Of the Turning of the Tongue +

    @@ -122,15 +121,11 @@

    pagina et nota cum nota, ipso textu adstante. Solvet lector pro foliis quae verterit, neque ante. - - That the volume shall be turned into another tongue, page - with page and note with note, the text itself standing fast. - The reader shall pay only for the leaves he turns, and not before. - +

    — manu propria - — by my own hand +

    @@ -145,16 +140,12 @@

    MMXXVI. Hanc cartam, sub sigillis nostris, ratam habemus in perpetuum. - - Given at Sibiu, on the 8th day of the month of May, in the year - of grace 2026. - This charter, under our seals, we ratify in perpetuity. - +

    — Magister Ordinis Constantis Equitis - — Master of the Order of the Steadfast Knight +

    @@ -264,12 +255,10 @@

    diff --git a/wordkeep-client/src/app/pages/landing/landing.scss b/wordkeep-client/src/app/pages/landing/landing.scss index 981219e..14fa016 100644 --- a/wordkeep-client/src/app/pages/landing/landing.scss +++ b/wordkeep-client/src/app/pages/landing/landing.scss @@ -116,7 +116,11 @@ text-align: justify; } -.charter__init { +/* These three live inside the opening paragraph, whose English/Romanian layer + is injected via [innerHTML]. Angular's view encapsulation would not reach + that injected markup, so pierce it with ::ng-deep (scoped to :host). The + static Latin spans match these too. */ +:host ::ng-deep .charter__init { font-family: var(--textura); font-weight: 700; font-size: 4.6em; @@ -134,7 +138,7 @@ 100% { opacity: 1; transform: scale(1); filter: blur(0); } } -.charter__sr-only { +:host ::ng-deep .charter__sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; @@ -142,7 +146,7 @@ white-space: nowrap; } -.charter__order { +:host ::ng-deep .charter__order { font-style: italic; color: var(--gules-deep); } @@ -476,8 +480,110 @@ em.translatable { color: var(--ink-soft); } +/* LANGUAGE PENNANTS ================================================ */ +.charter__flags { + position: absolute; + top: 0; + right: clamp(0.9rem, 3vw, 2.2rem); + z-index: 5; + display: flex; + gap: clamp(0.45rem, 1.2vw, 0.8rem); +} + +.pennant { + --cloth-h: clamp(6.5rem, 13vw, 9.5rem); + --cloth-w: clamp(2.5rem, 4.5vw, 3.3rem); + display: flex; + flex-direction: column; + align-items: center; + padding: 0; + background: none; + border: none; + cursor: pointer; + opacity: 0.82; + transform: scale(0.94); + transform-origin: top center; + filter: drop-shadow(0 3px 5px rgba(15, 16, 20, 0.16)); + transition: transform 0.5s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.3s ease; +} + +/* the rod the banner hangs from */ +.pennant__staff { + width: calc(var(--cloth-w) + 0.45rem); + height: 3px; + border-radius: 2px; + background: linear-gradient(180deg, #6a4a26, #3a2a14); + box-shadow: 0 1px 0 rgba(0, 0, 0, 0.3); +} + +.pennant__cloth { + position: relative; + width: var(--cloth-w); + height: var(--cloth-h); + /* swallowtail / sparrow-tail notch at the foot of the banner */ + clip-path: polygon(0 0, 100% 0, 100% 100%, 50% 72%, 0 100%); + display: flex; + justify-content: center; + padding-top: 0.45rem; + overflow: hidden; + box-shadow: inset -2px 0 5px rgba(0, 0, 0, 0.08), inset 3px 0 6px rgba(255, 255, 255, 0.18); +} + +/* a soft sheen over the cloth */ +.pennant__cloth::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(105deg, rgba(255, 255, 255, 0.22) 0 14%, transparent 26% 100%); + pointer-events: none; +} + +/* England — St George: bright gules cross on argent */ +.pennant--en .pennant__cloth { + background: + linear-gradient(#bb2f2c, #bb2f2c) center / 30% 100% no-repeat, + linear-gradient(#bb2f2c, #bb2f2c) center / 100% 26% no-repeat, + #f8f3e8; +} + +/* Romania — vertical tricolour */ +.pennant--ro .pennant__cloth { + background: linear-gradient(90deg, + #27408b 0 33.33%, + #e3bf42 33.33% 66.66%, + #bb2f2c 66.66% 100%); +} + +.pennant:hover, +.pennant:focus-visible { + opacity: 1; + transform: scale(0.94) translateY(2px) rotate(-1.5deg); + outline: none; +} + +.pennant--active { + opacity: 1; + transform: scale(1); +} + +.pennant--active .pennant__cloth { + box-shadow: + inset -2px 0 5px rgba(0, 0, 0, 0.08), + inset 3px 0 6px rgba(255, 255, 255, 0.18), + 0 0 0 1.5px var(--gold); +} + +.pennant:focus-visible .pennant__cloth { + outline: 2px solid var(--gold); + outline-offset: 2px; +} + @media (prefers-reduced-motion: reduce) { - .charter__init { animation: none; } + :host ::ng-deep .charter__init { animation: none; } .seal__stamp { animation: none; opacity: 1; transform: rotate(var(--rot, 0deg)); } .seal__caption { animation: none; opacity: 1; } + .pennant, + .pennant:hover, + .pennant:focus-visible, + .pennant--active { transition: none; transform: none; } } diff --git a/wordkeep-client/src/app/pages/landing/landing.spec.ts b/wordkeep-client/src/app/pages/landing/landing.spec.ts index 2ce6e56..f343e67 100644 --- a/wordkeep-client/src/app/pages/landing/landing.spec.ts +++ b/wordkeep-client/src/app/pages/landing/landing.spec.ts @@ -1,7 +1,9 @@ import { TestBed } from '@angular/core/testing'; import { provideRouter } from '@angular/router'; +import { provideHttpClient } from '@angular/common/http'; import { describe, it, expect, beforeEach, vi } from 'vitest'; import { LandingComponent } from './landing'; +import { getTranslocoTestingModule } from '../../transloco/transloco-testing'; function mockHoverMedia(hoverCapable: boolean): void { const impl = (query: string): MediaQueryList => ({ @@ -24,8 +26,8 @@ function mockHoverMedia(hoverCapable: boolean): void { describe('LandingComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [LandingComponent], - providers: [provideRouter([])], + imports: [LandingComponent, getTranslocoTestingModule()], + providers: [provideRouter([]), provideHttpClient()], }).compileComponents(); }); diff --git a/wordkeep-client/src/app/pages/landing/landing.ts b/wordkeep-client/src/app/pages/landing/landing.ts index 9f5f018..cde41e5 100644 --- a/wordkeep-client/src/app/pages/landing/landing.ts +++ b/wordkeep-client/src/app/pages/landing/landing.ts @@ -10,6 +10,8 @@ import { } from '@angular/core'; import { DOCUMENT } from '@angular/common'; import { RouterLink } from '@angular/router'; +import { TranslocoDirective } from '@jsverse/transloco'; +import { UserPreferencesService, Locale } from '../../services/user-preferences.service'; @Directive({ selector: '[translatableToggle]', @@ -54,7 +56,7 @@ export class TranslatableToggleDirective { @Component({ selector: 'app-landing', standalone: true, - imports: [RouterLink, TranslatableToggleDirective], + imports: [RouterLink, TranslatableToggleDirective, TranslocoDirective], templateUrl: './landing.html', styleUrl: './landing.scss' }) @@ -63,8 +65,17 @@ export class LandingComponent implements OnInit, OnDestroy { private static readonly LINK_ID = 'landing-charter-fonts'; private readonly document = inject(DOCUMENT); + private readonly prefs = inject(UserPreferencesService); private linkEl: HTMLLinkElement | null = null; + /** Active locale — drives the charter's English-layer language and the flags. */ + readonly locale = this.prefs.locale; + readonly locales: ReadonlyArray = ['en', 'ro']; + + setLocale(l: Locale): void { + this.prefs.setLocale(l); + } + ngOnInit(): void { if (this.document.getElementById(LandingComponent.LINK_ID)) { return; diff --git a/wordkeep-client/src/app/pages/login/login.html b/wordkeep-client/src/app/pages/login/login.html index f7d22f2..7cc225c 100644 --- a/wordkeep-client/src/app/pages/login/login.html +++ b/wordkeep-client/src/app/pages/login/login.html @@ -1,6 +1,6 @@ -
    +
    @if (!twoFactorRequired()) { -

    Welcome back

    -

    Sign in to your WordKeep account

    +

    {{ t('login.welcomeBack') }}

    +

    {{ t('login.subtitle') }}

    } @else { -

    Two-factor authentication

    -

    Enter the 6-digit code from your authenticator app

    +

    {{ t('login.twoFactorTitle') }}

    +

    {{ t('login.twoFactorSubtitle') }}

    }
    @@ -31,7 +31,7 @@

    Two-factor authentication

    - Verification email sent. Please check your inbox. + {{ t('login.verificationSent') }}
    } @@ -48,9 +48,9 @@

    Two-factor authentication

    } @@ -58,47 +58,47 @@

    Two-factor authentication

    - +
    - +
    } @else { @@ -115,27 +115,27 @@

    Two-factor authentication

    - + - Or enter a recovery code if you've lost your device. + {{ t('login.recoveryHint') }}
    diff --git a/wordkeep-client/src/app/pages/login/login.spec.ts b/wordkeep-client/src/app/pages/login/login.spec.ts index 12438cc..2fc9cf4 100644 --- a/wordkeep-client/src/app/pages/login/login.spec.ts +++ b/wordkeep-client/src/app/pages/login/login.spec.ts @@ -6,6 +6,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { LoginComponent } from './login'; import { AuthService } from '../../services/auth.service'; import { mockAuthResponse } from '../../test-utils'; +import { getTranslocoTestingModule } from '../../transloco/transloco-testing'; describe('LoginComponent', () => { let component: LoginComponent; @@ -25,7 +26,7 @@ describe('LoginComponent', () => { }; await TestBed.configureTestingModule({ - imports: [LoginComponent], + imports: [LoginComponent, getTranslocoTestingModule()], providers: [ provideRouter([]), { provide: AuthService, useValue: mockAuthService } diff --git a/wordkeep-client/src/app/pages/login/login.ts b/wordkeep-client/src/app/pages/login/login.ts index 1340afc..d0060a1 100644 --- a/wordkeep-client/src/app/pages/login/login.ts +++ b/wordkeep-client/src/app/pages/login/login.ts @@ -1,17 +1,19 @@ -import { Component, signal } from '@angular/core'; +import { Component, inject, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { Router, RouterLink } from '@angular/router'; +import { TranslocoDirective, TranslocoService } from '@jsverse/transloco'; import { AuthService } from '../../services/auth.service'; @Component({ selector: 'app-login', standalone: true, - imports: [CommonModule, FormsModule, RouterLink], + imports: [CommonModule, FormsModule, RouterLink, TranslocoDirective], templateUrl: './login.html', styleUrl: './login.scss' }) export class LoginComponent { + private transloco = inject(TranslocoService); email = ''; password = ''; error = signal(null); @@ -33,7 +35,7 @@ export class LoginComponent { onSubmit(): void { if (!this.email || !this.password) { - this.error.set('Please fill in all fields'); + this.error.set(this.transloco.translate('auth.common.fillAllFields')); return; } @@ -58,10 +60,10 @@ export class LoginComponent { this.error.set(err.error.message); this.showResendOption.set(true); } else if (err.status === 422) { - const message = err.error?.message || err.error?.errors?.email?.[0] || 'Invalid credentials'; + const message = err.error?.message || err.error?.errors?.email?.[0] || this.transloco.translate('auth.login.invalidCredentials'); this.error.set(message); } else { - this.error.set('An error occurred. Please try again.'); + this.error.set(this.transloco.translate('auth.common.genericError')); } } }); @@ -69,7 +71,7 @@ export class LoginComponent { submitTwoFactor(): void { if (!this.twoFactorCode) { - this.twoFactorError.set('Please enter your code.'); + this.twoFactorError.set(this.transloco.translate('auth.login.enterCode')); return; } @@ -83,7 +85,7 @@ export class LoginComponent { error: (err) => { this.twoFactorLoading.set(false); this.twoFactorError.set( - err.error?.errors?.code?.[0] ?? err.error?.message ?? 'Invalid code. Try again.' + err.error?.errors?.code?.[0] ?? err.error?.message ?? this.transloco.translate('auth.login.invalidCode') ); } }); @@ -99,7 +101,7 @@ export class LoginComponent { }, error: () => { this.resendLoading.set(false); - this.error.set('Failed to resend verification email. Please try again.'); + this.error.set(this.transloco.translate('auth.login.resendFailed')); } }); } diff --git a/wordkeep-client/src/app/pages/page-linking-reader/page-linking-reader.html b/wordkeep-client/src/app/pages/page-linking-reader/page-linking-reader.html index fba291d..a1e2e67 100644 --- a/wordkeep-client/src/app/pages/page-linking-reader/page-linking-reader.html +++ b/wordkeep-client/src/app/pages/page-linking-reader/page-linking-reader.html @@ -1,10 +1,11 @@ +
    @if (loading()) {
    -

    Loading document...

    +

    {{ t('reader.loading') }}

    } @else if (document()) { @@ -14,11 +15,11 @@ - Page Linking + {{ t('reader.backLink') }}

    {{ document()!.effective_filename }}

    - Pair {{ currentPair() }} / {{ totalPairs() }} + {{ t('reader.pairIndicator', { current: currentPair(), total: totalPairs() }) }}
    @@ -36,20 +37,20 @@

    {{ document()!.effective_filename }}

    - Prev + {{ t('reader.prev') }}
    @@ -59,7 +60,7 @@

    {{ document()!.effective_filename }}

    - All done! All page pairs have been linked. + {{ t('reader.allDone') }}
    } @@ -73,12 +74,12 @@

    {{ document()!.effective_filename }}

    @@ -97,12 +98,12 @@

    {{ document()!.effective_filename }}

    } @else if (imageUrlA()) { - Page {{ currentPair() }} + @if (isPageAIgnored()) { -
    Ignored
    +
    {{ t('reader.ignoredOverlay') }}
    } } @else { -
    Image unavailable
    +
    {{ t('reader.imageUnavailable') }}
    }
    @@ -117,14 +118,14 @@

    {{ document()!.effective_filename }}

    + diff --git a/wordkeep-client/src/app/pages/page-linking-reader/page-linking-reader.scss b/wordkeep-client/src/app/pages/page-linking-reader/page-linking-reader.scss index 157b816..450902d 100644 --- a/wordkeep-client/src/app/pages/page-linking-reader/page-linking-reader.scss +++ b/wordkeep-client/src/app/pages/page-linking-reader/page-linking-reader.scss @@ -229,13 +229,12 @@ align-items: center; justify-content: center; pointer-events: none; - font-size: 0; - &::after { - content: 'IGNORED'; + &__badge { font-size: 1rem; font-weight: 700; letter-spacing: 0.1em; + text-transform: uppercase; padding: 0.25rem 0.75rem; background: var(--color-fg-muted); color: var(--color-surface-2); diff --git a/wordkeep-client/src/app/pages/page-linking-reader/page-linking-reader.spec.ts b/wordkeep-client/src/app/pages/page-linking-reader/page-linking-reader.spec.ts index 044dab0..57656b5 100644 --- a/wordkeep-client/src/app/pages/page-linking-reader/page-linking-reader.spec.ts +++ b/wordkeep-client/src/app/pages/page-linking-reader/page-linking-reader.spec.ts @@ -6,6 +6,7 @@ import { PageLinkingReaderComponent } from './page-linking-reader'; import { DocumentService } from '../../services/document.service'; import { AuthService } from '../../services/auth.service'; import { mockDocument, mockPageLinkPair } from '../../test-utils'; +import { getTranslocoTestingModule } from '../../transloco/transloco-testing'; describe('PageLinkingReaderComponent', () => { let component: PageLinkingReaderComponent; @@ -54,7 +55,7 @@ describe('PageLinkingReaderComponent', () => { }; await TestBed.configureTestingModule({ - imports: [PageLinkingReaderComponent], + imports: [PageLinkingReaderComponent, getTranslocoTestingModule()], providers: [ provideRouter([]), { provide: DocumentService, useValue: mockDocumentService }, diff --git a/wordkeep-client/src/app/pages/page-linking-reader/page-linking-reader.ts b/wordkeep-client/src/app/pages/page-linking-reader/page-linking-reader.ts index 04baf40..5a26a15 100644 --- a/wordkeep-client/src/app/pages/page-linking-reader/page-linking-reader.ts +++ b/wordkeep-client/src/app/pages/page-linking-reader/page-linking-reader.ts @@ -1,6 +1,7 @@ import { Component, OnInit, OnDestroy, inject, signal, computed, effect, DestroyRef } from '@angular/core'; import { CommonModule } from '@angular/common'; import { ActivatedRoute, RouterLink } from '@angular/router'; +import { provideTranslocoScope, TranslocoDirective, TranslocoService } from '@jsverse/transloco'; import { DocumentService } from '../../services/document.service'; import { Document, PageLinkPair, PageLinkDecision } from '../../models/document.model'; import { LayoutComponent } from '../../components/layout/layout'; @@ -8,7 +9,8 @@ import { LayoutComponent } from '../../components/layout/layout'; @Component({ selector: 'app-page-linking-reader', standalone: true, - imports: [CommonModule, RouterLink, LayoutComponent], + imports: [CommonModule, RouterLink, LayoutComponent, TranslocoDirective], + providers: [provideTranslocoScope('pageLinking')], templateUrl: './page-linking-reader.html', styleUrl: './page-linking-reader.scss' }) @@ -16,6 +18,7 @@ export class PageLinkingReaderComponent implements OnInit, OnDestroy { private route = inject(ActivatedRoute); private documentService = inject(DocumentService); private destroyRef = inject(DestroyRef); + private transloco = inject(TranslocoService); document = signal(null); loading = signal(true); @@ -96,7 +99,7 @@ export class PageLinkingReaderComponent implements OnInit, OnDestroy { this.loading.set(false); }, error: () => { - this.error.set('Failed to load document'); + this.error.set(this.transloco.translate('pageLinking.reader.errorLoadDocument')); this.loading.set(false); } }); @@ -120,7 +123,7 @@ export class PageLinkingReaderComponent implements OnInit, OnDestroy { this.loadingPair.set(false); }, error: () => { - this.error.set('Failed to load page data'); + this.error.set(this.transloco.translate('pageLinking.reader.errorLoadPageData')); this.loadingPair.set(false); } }); @@ -181,7 +184,7 @@ export class PageLinkingReaderComponent implements OnInit, OnDestroy { } }, error: () => { - this.error.set('Failed to save. Please try again.'); + this.error.set(this.transloco.translate('pageLinking.reader.errorSave')); this.saving.set(false); } }); @@ -240,7 +243,7 @@ export class PageLinkingReaderComponent implements OnInit, OnDestroy { this.loadPairData(doc.id, this.currentPair()); }, error: () => { - this.error.set('Failed to update page ignore status'); + this.error.set(this.transloco.translate('pageLinking.reader.errorIgnore')); if (side === 'a') { this.togglingIgnoreA.set(false); } else { diff --git a/wordkeep-client/src/app/pages/page-linking/page-linking.html b/wordkeep-client/src/app/pages/page-linking/page-linking.html index 331ac20..862c050 100644 --- a/wordkeep-client/src/app/pages/page-linking/page-linking.html +++ b/wordkeep-client/src/app/pages/page-linking/page-linking.html @@ -1,9 +1,10 @@ +
    @@ -25,10 +26,10 @@

    Page Linking

    -

    Your Documents

    +

    {{ t('list.documentsHeading') }}

    @if (total() > 0) { - {{ total() }} document{{ total() === 1 ? '' : 's' }} + {{ total() === 1 ? t('list.docCount', { count: total() }) : t('list.docCountPlural', { count: total() }) }} }
    @@ -37,7 +38,7 @@

    Your Documents

    @if (loading()) {
    -

    Loading documents...

    +

    {{ t('list.loading') }}

    } @else if (documents().length === 0) {
    @@ -46,8 +47,8 @@

    Your Documents

    -

    No documents ready

    -

    Only ready documents with more than one page appear here

    +

    {{ t('list.emptyTitle') }}

    +

    {{ t('list.emptySubtitle') }}

    } @else {
    @@ -63,11 +64,11 @@

    No documents ready

    {{ doc.effective_filename }}

    @if (doc.has_custom_name) { - renamed + {{ t('list.renamedBadge') }} }
    - {{ doc.page_count }} pages + {{ t('list.pages', { count: doc.page_count }) }} {{ formatFileSize(doc.file_size) }}
    @@ -88,7 +89,7 @@

    {{ doc.effective_filename }}

    [routerLink]="['/page-linking', doc.id]" [queryParams]="{ page: getResumePage(doc) }" > - Link + {{ t('list.linkAction') }}
    @@ -106,15 +107,15 @@

    {{ doc.effective_filename }}

    - Previous + {{ t('list.previous') }} - Page {{ currentPage() }} of {{ lastPage() }} + {{ t('list.pageInfo', { current: currentPage(), last: lastPage() }) }}
    +
    diff --git a/wordkeep-client/src/app/pages/page-linking/page-linking.spec.ts b/wordkeep-client/src/app/pages/page-linking/page-linking.spec.ts index 2f60fc4..008d062 100644 --- a/wordkeep-client/src/app/pages/page-linking/page-linking.spec.ts +++ b/wordkeep-client/src/app/pages/page-linking/page-linking.spec.ts @@ -7,6 +7,7 @@ import { DocumentService } from '../../services/document.service'; import { AuthService } from '../../services/auth.service'; import { mockDocument, mockDocumentForLinking, mockPaginatedDocuments } from '../../test-utils'; import { Document } from '../../models/document.model'; +import { getTranslocoTestingModule } from '../../transloco/transloco-testing'; describe('PageLinkingComponent', () => { let component: PageLinkingComponent; @@ -33,7 +34,7 @@ describe('PageLinkingComponent', () => { }; await TestBed.configureTestingModule({ - imports: [PageLinkingComponent], + imports: [PageLinkingComponent, getTranslocoTestingModule()], providers: [ provideRouter([]), { provide: DocumentService, useValue: mockDocumentService }, @@ -174,7 +175,7 @@ describe('PageLinkingComponent', () => { TestBed.resetTestingModule(); await TestBed.configureTestingModule({ - imports: [PageLinkingComponent], + imports: [PageLinkingComponent, getTranslocoTestingModule()], providers: [ provideRouter([]), { provide: DocumentService, useValue: mockDocumentService }, diff --git a/wordkeep-client/src/app/pages/page-linking/page-linking.ts b/wordkeep-client/src/app/pages/page-linking/page-linking.ts index a7041aa..bc90cf8 100644 --- a/wordkeep-client/src/app/pages/page-linking/page-linking.ts +++ b/wordkeep-client/src/app/pages/page-linking/page-linking.ts @@ -1,6 +1,8 @@ import { Component, OnInit, inject, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; import { RouterLink } from '@angular/router'; +import { provideTranslocoScope, TranslocoDirective, TranslocoService } from '@jsverse/transloco'; +import { formatDateTime } from '../../utils/date-format'; import { DocumentService } from '../../services/document.service'; import { Document, SortOptions } from '../../models/document.model'; import { LayoutComponent } from '../../components/layout/layout'; @@ -10,13 +12,15 @@ import { UserViewStatesService } from '../../services/user-view-states.service'; @Component({ selector: 'app-page-linking', standalone: true, - imports: [CommonModule, RouterLink, LayoutComponent, SortDropdownComponent], + imports: [CommonModule, RouterLink, LayoutComponent, SortDropdownComponent, TranslocoDirective], + providers: [provideTranslocoScope('pageLinking')], templateUrl: './page-linking.html', styleUrl: './page-linking.scss' }) export class PageLinkingComponent implements OnInit { private documentService = inject(DocumentService); private viewStates = inject(UserViewStatesService); + private transloco = inject(TranslocoService); documents = signal([]); loading = signal(true); @@ -49,7 +53,7 @@ export class PageLinkingComponent implements OnInit { this.loading.set(false); }, error: () => { - this.error.set('Failed to load documents'); + this.error.set(this.transloco.translate('pageLinking.list.errorLoad')); this.loading.set(false); } }); @@ -70,13 +74,7 @@ export class PageLinkingComponent implements OnInit { } formatDate(dateString: string): string { - return new Date(dateString).toLocaleDateString('en-US', { - year: 'numeric', - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit' - }); + return formatDateTime(dateString, this.transloco.getActiveLang()); } getResumePage(doc: Document): number { @@ -86,7 +84,7 @@ export class PageLinkingComponent implements OnInit { getLinksProgressText(doc: Document): string { const p = doc.links_progress; if (!p) return ''; - return `${p.completed} / ${p.total} pages linked`; + return this.transloco.translate('pageLinking.list.progressText', { completed: p.completed, total: p.total }); } getLinksPercent(doc: Document): number { diff --git a/wordkeep-client/src/app/pages/profile/info/profile-info.html b/wordkeep-client/src/app/pages/profile/info/profile-info.html index 1104783..47f67c2 100644 --- a/wordkeep-client/src/app/pages/profile/info/profile-info.html +++ b/wordkeep-client/src/app/pages/profile/info/profile-info.html @@ -1,27 +1,28 @@ +
    -

    Profile Info

    +

    {{ t('info.title') }}

    @if (user()?.avatar_url) { - Profile photo + } @else { {{ (user()?.name ?? '').charAt(0).toUpperCase() }} }

    - {{ user()?.avatar_url ? 'Update your profile photo.' : 'Add a profile photo.' }} + {{ user()?.avatar_url ? t('info.avatar.hintUpdate') : t('info.avatar.hintAdd') }}

    @if (user()?.avatar_url) { }
    @@ -31,13 +32,13 @@

    Profile Info

    -
    Name
    +
    {{ t('info.name.label') }}
    @if (!editingName()) {
    {{ user()?.name }}
    } @else { @@ -48,7 +49,7 @@

    Profile Info

    [(ngModel)]="nameValue" [ngModel]="nameValue()" (ngModelChange)="nameValue.set($event)" - placeholder="Your name" + [placeholder]="t('info.name.placeholder')" maxlength="255" (keyup.enter)="saveName()" (keyup.escape)="cancelEditingName()" @@ -59,9 +60,9 @@

    Profile Info

    }
    - +
    } @@ -69,13 +70,13 @@

    Profile Info

    -
    Email
    +
    {{ t('info.email.label') }}
    {{ user()?.email }} @if (!user()?.pending_email) { }
    @@ -84,18 +85,18 @@

    Profile Info

    - Pending change to {{ user()!.pending_email }} - Check your new inbox for the verification link. Expires {{ formatExpiry(user()!.pending_email_expires_at) }}. + + {{ t('info.email.pendingHint', { date: formatExpiry(user()!.pending_email_expires_at) }) }} @if (resendSuccess()) { - Verification email resent. + {{ t('info.email.resent') }} }
    @@ -116,7 +117,7 @@

    Profile Info