diff --git a/.env.example b/.env.example index d238726..beca18d 100644 --- a/.env.example +++ b/.env.example @@ -90,6 +90,6 @@ GROQ_BASE_URL=https://api.groq.com/openai/v1 # Translation Configuration (Google Gemini) # Get a free API key at https://aistudio.google.com — free tier: 10 RPM, 250 req/day -# Paid: ~$0.42/300-page book (gemini-2.5-flash) or ~$0.08 (gemini-2.5-flash-lite) +# Paid: ~$0.85/300-page book (gemini-2.5-flash) or ~$0.15 (gemini-2.5-flash-lite) GEMINI_API_KEY= GEMINI_MODEL=gemini-2.5-flash diff --git a/.env.herd.example b/.env.herd.example index 9f71786..9b0c28c 100644 --- a/.env.herd.example +++ b/.env.herd.example @@ -85,6 +85,6 @@ GROQ_BASE_URL=https://api.groq.com/openai/v1 # Translation Configuration (Google Gemini) # Get a free API key at https://aistudio.google.com — free tier: 10 RPM, 250 req/day -# Paid: ~$0.42/300-page book (gemini-2.5-flash) or ~$0.08 (gemini-2.5-flash-lite) +# Paid: ~$0.85/300-page book (gemini-2.5-flash) or ~$0.15 (gemini-2.5-flash-lite) GEMINI_API_KEY= GEMINI_MODEL=gemini-2.5-flash diff --git a/.env.sail.example b/.env.sail.example index d3297c2..e8faa58 100644 --- a/.env.sail.example +++ b/.env.sail.example @@ -86,6 +86,6 @@ GROQ_BASE_URL=https://api.groq.com/openai/v1 # Translation Configuration (Google Gemini) # Get a free API key at https://aistudio.google.com — free tier: 10 RPM, 250 req/day -# Paid: ~$0.42/300-page book (gemini-2.5-flash) or ~$0.08 (gemini-2.5-flash-lite) +# Paid: ~$0.85/300-page book (gemini-2.5-flash) or ~$0.15 (gemini-2.5-flash-lite) GEMINI_API_KEY= GEMINI_MODEL=gemini-2.5-flash 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..83c9f6f 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,12 @@ A Laravel 12 API server + Angular 21 client for processing PDF documents using A - **Latin↔English Hover Swap** — On desktop, hover or keyboard-focus any Latin block (legend, opening, article titles/bodies, `manu propria` signatures, closing) and the Latin fades out while English fades in; un-hover/un-focus restores. On touch, tap toggles via `aria-expanded` - **Sign-in / Sign-up CTAs** — Footer offers "Subscribe to the charter" (`/register`) and "Resume the office" (`/login`) +### Guest Demo +- **Guided tour at `/demo`** — A "Witness the craft" CTA (and per-Article Latin deep-links) take a logged-out visitor through the full product flow without signing up: **extraction → page linking → book view → translation → translated book view**, framed in charter-styled chrome with five wax-seal step pips +- **Real pipeline output, not mockups** — The tour runs on a 3-page public-domain *Vanity Fair* extract carried through WordKeep's own pipeline (LM Studio OCR + Gemini translation). Two layers per page in `wordkeep-client/src/app/demo/demo.fixture.ts`: the **raw** OCR (with authentic slips) in the extraction stage, and the **cleaned** source + its captured Romanian everywhere downstream — the "best result" a real editor would produce +- **Canned and local — no AI, no backend** — Each reader's data service is swapped for an in-memory override via route-level DI (`DemoDocumentService` / `DemoTranslationService`), so the tour is fully interactive (extract, edit, link, translate, view footnotes) yet makes no model calls and **resets on exit**. The only network call is the public, read-only `GET /api/translation/pricing`, so the cost dialog always shows live prices (never a hand-maintained copy); a guard E2E spec asserts no other `/api` traffic +- **In-demo personalisation** — Switch light/dark and colour palette live; the six layout variants are intentionally withheld as a post-signup discovery. Footnotes are view-only; the real cost/clear/re-OCR confirmation dialogs still appear + ### Dashboard & Recent Activity - **Recent Activity Feed** — Dashboard shows up to 10 most recent `(document, work-type)` entries, ordered by latest activity. Each card displays the filename, author (when present), a colored work-type badge (**OCR**, **Page Linking**, **Book View**, **Translation — {Language}**), the last page worked + total pages, a progress bar, and a relative timestamp (`2h ago`, `3d ago`, or `DD/MM/YYYY` for entries older than a week) - **One Entry Per Work Type** — A single book worked on for both OCR and Page Linking surfaces as two separate entries @@ -99,6 +105,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 +155,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 +174,7 @@

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

-

Extracted Text

+

{{ t('extractedText') }}

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

Extracted Text

> @if (isExtractingCurrentPage()) {
- Extracting... + {{ t('extracting') }} } @else { - Extract Text + {{ t('extractText') }} } } @@ -228,17 +231,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 +254,7 @@

Extracted Text

- Transcribe + {{ t('transcribe') }} } } @@ -261,7 +264,7 @@

Extracted Text

class="btn-edit" [disabled]="!canInsertFootnoteRef()" [title]="insertFootnoteTooltip()" - (click)="toggleFootnoteInsertList()" + (click)="toggleFootnoteInsertList($event)" > @@ -269,7 +272,9 @@

Extracted Text

[^n] @if (showingFootnoteList()) { -
+
@for (fn of availableFootnotesForInsertion(); track fn.id) {
} @@ -302,8 +307,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 +317,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 +364,7 @@

Extracted Text

-

Footnotes

+

{{ t('footnotes') }}

+ @if (!readerMode.readonly()) { @if (!footnoteEditMode()) { @if (currentFootnote()) { @if (currentFootnote()?.page_number !== null && currentFootnote()?.page_number !== undefined) { } } } @else { } + }
@if (loadingFootnotes()) {
-

Loading footnotes...

+

{{ t('loadingFootnotes') }}

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

Footnotes

} @else if (currentFootnote(); as fn) { @@ -467,18 +474,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 +494,8 @@

Footnotes

-

No footnotes yet

-

Click "Add Footnote" to create one

+

{{ t('noFootnotes') }}

+

{{ t('noFootnotesHint') }}

}
@@ -496,4 +503,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..594582a 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); @@ -1185,18 +1184,17 @@ } .footnote-dropdown { - position: absolute; - top: 100%; - right: 0; - z-index: 100; - min-width: 250px; - max-height: 200px; + /* Fixed + measured coords (set in the component) so the menu escapes the OCR + panel's `overflow: hidden` instead of being clipped against its edge. */ + position: fixed; + z-index: 1000; + width: 260px; + max-height: 240px; overflow-y: auto; background: var(--color-surface); border: 1px solid var(--color-rule); border-radius: 6px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); - margin-top: 4px; } .footnote-dropdown-item { 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..8ce90fb 100644 --- a/wordkeep-client/src/app/pages/extraction-reader/extraction-reader.ts +++ b/wordkeep-client/src/app/pages/extraction-reader/extraction-reader.ts @@ -3,8 +3,10 @@ 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 { ReaderModeService } from '../../services/reader-mode.service'; import { SpellCheckService, NspellChecker } from '../../services/spell-check.service'; import { createSpellCheckPlugin, SpellErrorClick, SpellCheckPluginHandle } from '../../plugins/spell-check.plugin'; import { createHrDeletionPlugin } from '../../plugins/hr-deletion.plugin'; @@ -20,9 +22,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); @@ -30,11 +33,14 @@ export class ExtractionReaderComponent implements OnInit, OnDestroy { private documentService = inject(DocumentService); private confirmationService = inject(ConfirmationService); private spellService = inject(SpellCheckService); + /** Read-only / navigation context (full app vs guest demo). Used in the template. */ + protected readerMode = inject(ReaderModeService); private appRef = inject(ApplicationRef); private injector = inject(EnvironmentInjector); 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; @@ -89,6 +95,8 @@ export class ExtractionReaderComponent implements OnInit, OnDestroy { deletingFootnote = signal(false); unlinkingFootnote = signal(false); showingFootnoteList = signal(false); + /** Fixed-position coords for the insert-footnote menu (escapes panel clipping). */ + footnoteListPos = signal<{ top: number; left: number } | null>(null); editSessionLinkedIds = signal>(new Set()); footnoteEditor!: Editor; footnoteToolbar: Toolbar = [ @@ -193,10 +201,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 +364,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 +375,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 +413,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 +424,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 +442,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 +468,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 +510,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 +633,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 +666,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 +741,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 +758,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 +776,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 +789,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 +816,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 +828,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,15 +847,24 @@ 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); } }); } - toggleFootnoteInsertList() { + toggleFootnoteInsertList(event?: MouseEvent) { const showing = !this.showingFootnoteList(); if (showing && this.availableFootnotesForInsertion().length === 0) return; + // Position the menu with fixed coords measured from the trigger so it escapes + // the OCR panel's `overflow: hidden` (which otherwise clips it), clamped to + // the viewport. See the footnote-dropdown styles. + if (showing && event?.currentTarget) { + const rect = (event.currentTarget as HTMLElement).getBoundingClientRect(); + const width = 260; + const left = Math.max(8, Math.min(rect.right - width, window.innerWidth - width - 8)); + this.footnoteListPos.set({ top: rect.bottom + 4, left }); + } this.showingFootnoteList.set(showing); } @@ -964,10 +981,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 +999,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 +1033,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..d1ee8f1 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,16 +77,18 @@

    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 +97,7 @@

    De Iunctione Foliorum - Of the Joining of the Leaves +

    @@ -96,16 +105,18 @@

    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 +125,7 @@

    De Versione Linguae - Of the Turning of the Tongue +

    @@ -122,16 +133,18 @@

    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 +158,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 +273,11 @@

    diff --git a/wordkeep-client/src/app/pages/landing/landing.scss b/wordkeep-client/src/app/pages/landing/landing.scss index 981219e..93c4e05 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); } @@ -211,20 +215,25 @@ text-align: justify; } +/* Footer line: handwritten signature on the left, demo deep-link on the right. */ +.article__footer { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 1.5rem; + margin-top: 0.8rem; + flex-wrap: wrap; +} + .article__sign { - position: absolute; - right: 0; - top: -0.5rem; font-family: var(--written); font-size: 1.05rem; color: var(--hand); - transform: rotate(-3deg); } @media (max-width: 720px) { .article { grid-template-columns: 3rem minmax(0, 1fr); gap: 0.8rem; } .article__num { font-size: 1.7rem; } - .article__sign { position: static; transform: none; } } /* CLOSE ============================================================= */ @@ -427,6 +436,38 @@ } } +/* Demo CTA — gold ghost, tonally between the gules primary and the ink + secondary; the inviting, no-commitment path. */ +.cta--demo { + background: transparent; + color: var(--gold); + border: 1px solid var(--gold); + + &:hover, &:focus-visible { + background: var(--gold); + color: var(--vellum); + border-color: var(--gold); + outline: none; + } +} + +/* Per-article deep link into the matching demo stage — a quiet handwritten + aside, deliberately subordinate to the charter typography. */ +.article__demo { + display: inline-block; + font-family: var(--written); + font-size: 1.05rem; + color: var(--hand); + text-decoration: underline; + text-underline-offset: 2px; + text-decoration-thickness: 1px; + + &:hover, &:focus-visible { + color: var(--gules); + outline: none; + } +} + /* TRANSLATION SWAP ================================================== */ .translatable { display: grid; @@ -467,6 +508,13 @@ em.translatable { outline-offset: 0.2em; } +/* The demo deep-link is a real navigating link, not a toggle: keep link + semantics (pointer cursor, inline flow) while reusing the hover cross-fade. */ +.article__demo.translatable { + display: inline-grid; + cursor: pointer; +} + .charter__colophon { margin: 0; font-family: var(--sc); @@ -476,8 +524,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..bcc4fca 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,24 +1,27 @@ +
    @if (loading()) {
    -

    Loading document...

    +

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

    } @else if (document()) {
    - - - - - Page Linking - + @if (readerMode.showInReaderBackLink()) { + + + + + {{ t('reader.backLink') }} + + }

    {{ document()!.effective_filename }}

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

    {{ document()!.effective_filename }}

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

    {{ document()!.effective_filename }}

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

    {{ document()!.effective_filename }}

    @@ -97,12 +100,12 @@

    {{ document()!.effective_filename }}

    } @else if (imageUrlA()) { - Page {{ currentPair() }} + @if (isPageAIgnored()) { -
    Ignored
    +
    {{ t('reader.ignoredOverlay') }}
    } } @else { -
    Image unavailable
    +
    {{ t('reader.imageUnavailable') }}
    }
    @@ -117,14 +120,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..de4936c 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,14 +1,17 @@ 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 { ReaderModeService } from '../../services/reader-mode.service'; import { Document, PageLinkPair, PageLinkDecision } from '../../models/document.model'; 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 +19,9 @@ export class PageLinkingReaderComponent implements OnInit, OnDestroy { private route = inject(ActivatedRoute); private documentService = inject(DocumentService); private destroyRef = inject(DestroyRef); + private transloco = inject(TranslocoService); + /** Read-only / navigation context (full app vs guest demo). Used in the template. */ + protected readerMode = inject(ReaderModeService); document = signal(null); loading = signal(true); @@ -96,7 +102,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 +126,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 +187,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 +246,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

    } + diff --git a/wordkeep-client/src/app/pages/translation-reader/translation-reader.spec.ts b/wordkeep-client/src/app/pages/translation-reader/translation-reader.spec.ts index a8c2638..44ef0dd 100644 --- a/wordkeep-client/src/app/pages/translation-reader/translation-reader.spec.ts +++ b/wordkeep-client/src/app/pages/translation-reader/translation-reader.spec.ts @@ -10,6 +10,7 @@ import { ConfirmationService } from '../../services/confirmation.service'; import { SpellCheckService } from '../../services/spell-check.service'; import { AuthService } from '../../services/auth.service'; import { Document, OcrResult, PageTranslation, FootnoteWithTranslation } from '../../models/document.model'; +import { getTranslocoTestingModule } from '../../transloco/transloco-testing'; const makeFootnote = (overrides: any = {}): FootnoteWithTranslation => ({ id: 1, @@ -116,7 +117,7 @@ describe('TranslationReaderComponent', () => { TestBed.resetTestingModule(); TestBed.configureTestingModule({ - imports: [TranslationReaderComponent], + imports: [TranslationReaderComponent, getTranslocoTestingModule()], providers: [ provideRouter([]), { diff --git a/wordkeep-client/src/app/pages/translation-reader/translation-reader.ts b/wordkeep-client/src/app/pages/translation-reader/translation-reader.ts index 786bc29..8c20c8c 100644 --- a/wordkeep-client/src/app/pages/translation-reader/translation-reader.ts +++ b/wordkeep-client/src/app/pages/translation-reader/translation-reader.ts @@ -2,10 +2,12 @@ import { Component, OnInit, OnDestroy, HostListener, inject, signal, computed, e import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { provideTranslocoScope, TranslocoDirective, TranslocoService } from '@jsverse/transloco'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; import { DocumentService } from '../../services/document.service'; import { TranslationService } from '../../services/translation.service'; import { ConfirmationService } from '../../services/confirmation.service'; +import { ReaderModeService } from '../../services/reader-mode.service'; import { SpellCheckService, NspellChecker } from '../../services/spell-check.service'; import { createSpellCheckPlugin, SpellErrorClick, SpellCheckPluginHandle } from '../../plugins/spell-check.plugin'; import { createHrDeletionPlugin } from '../../plugins/hr-deletion.plugin'; @@ -45,7 +47,8 @@ interface SplitSegment { @Component({ selector: 'app-translation-reader', standalone: true, - imports: [CommonModule, FormsModule, RouterLink, LayoutComponent, NgxEditorModule], + imports: [CommonModule, FormsModule, RouterLink, LayoutComponent, NgxEditorModule, TranslocoDirective], + providers: [provideTranslocoScope('translation')], templateUrl: './translation-reader.html', styleUrl: './translation-reader.scss' }) @@ -56,10 +59,13 @@ export class TranslationReaderComponent implements OnInit, OnDestroy { private translationService = inject(TranslationService); private confirmationService = inject(ConfirmationService); private spellService = inject(SpellCheckService); + /** Read-only / navigation context (full app vs guest demo). Used in the template. */ + protected readerMode = inject(ReaderModeService); private appRef = inject(ApplicationRef); private injector = inject(EnvironmentInjector); private destroyRef = inject(DestroyRef); private sanitizer = inject(DomSanitizer); + private transloco = inject(TranslocoService); private spellClickSubject = new Subject(); private currentChecker: NspellChecker | null = null; @@ -370,7 +376,7 @@ export class TranslationReaderComponent implements OnInit, OnDestroy { this.currentPage.set(wsPage); }, error: () => { - this.error.set('Failed to load document'); + this.error.set(this.transloco.translate('translation.reader.errors.loadDocument')); this.loading.set(false); } }); @@ -405,7 +411,7 @@ export class TranslationReaderComponent implements OnInit, OnDestroy { this.documentService.updateWorkStatus(doc.id, 'translation_' + this.targetLanguage(), page).subscribe(); }, error: () => { - this.error.set('Failed to load page data'); + this.error.set(this.transloco.translate('translation.reader.errors.loadPage')); this.loadingPage.set(false); } }); @@ -444,7 +450,7 @@ export class TranslationReaderComponent implements OnInit, OnDestroy { next: (page) => { this.navigatingUntranslated.set(false); if (page !== null) this.goToPage(page); - else this.error.set('All available pages have been translated.'); + else this.error.set(this.transloco.translate('translation.reader.errors.allTranslated')); }, error: () => this.navigatingUntranslated.set(false) }); @@ -458,7 +464,7 @@ export class TranslationReaderComponent implements OnInit, OnDestroy { next: (page) => { this.navigatingUntranslated.set(false); if (page !== null) this.goToPage(page); - else this.error.set('No more untranslated pages after this one.'); + else this.error.set(this.transloco.translate('translation.reader.errors.noMoreUntranslated')); }, error: () => this.navigatingUntranslated.set(false) }); @@ -487,7 +493,7 @@ export class TranslationReaderComponent implements OnInit, OnDestroy { this.updateCachedTranslation(page, result); this.wrongLanguagePages.update(pages => pages.filter(p => p !== page)); }, - error: () => this.error.set('Failed to dismiss.') + error: () => this.error.set(this.transloco.translate('translation.reader.errors.dismiss')) }); } @@ -506,10 +512,10 @@ export class TranslationReaderComponent implements OnInit, OnDestroy { const targetLang = this.getLanguageName(this.targetLanguage()); const confirmed = await this.confirmationService.confirm({ - title: 'Retry with stronger instructions?', - message: `Retry translating to ${targetLang} with a stronger prompt. Estimated cost: ~${costStr}`, - confirmText: 'Retry', - cancelText: 'Cancel', + title: this.transloco.translate('translation.reader.confirm.retryTitle'), + message: this.transloco.translate('translation.reader.confirm.retryMessage', { language: targetLang, cost: costStr }), + confirmText: this.transloco.translate('translation.reader.confirm.retry'), + cancelText: this.transloco.translate('translation.reader.confirm.cancel'), }); if (!confirmed) return; @@ -544,10 +550,13 @@ export class TranslationReaderComponent implements OnInit, OnDestroy { const hasExisting = !!data.translation; const confirmed = await this.confirmationService.confirm({ - title: hasExisting ? 'Re-translate page?' : 'Translate page with AI?', - message: (hasExisting ? 'Existing translation will be replaced. ' : '') + `Estimated cost: ~${costStr} (rough estimate based on character count)`, - confirmText: 'Translate', - cancelText: 'Cancel', + title: this.transloco.translate(hasExisting ? 'translation.reader.confirm.reTranslatePageTitle' : 'translation.reader.confirm.translatePageTitle'), + message: this.transloco.translate('translation.reader.confirm.translatePageMessage', { + replace: hasExisting ? this.transloco.translate('translation.reader.confirm.translatePageReplace') : '', + cost: costStr, + }), + confirmText: this.transloco.translate('translation.reader.confirm.translate'), + cancelText: this.transloco.translate('translation.reader.confirm.cancel'), }); if (!confirmed) return; @@ -587,10 +596,10 @@ export class TranslationReaderComponent implements OnInit, OnDestroy { const sourceLang = this.getLanguageName(doc.main_language ?? null); const targetLang = this.getLanguageName(this.targetLanguage()); const retryConfirmed = await this.confirmationService.confirm({ - title: 'Translation returned wrong language', - message: `Gemini returned the text in ${sourceLang} instead of ${targetLang}. Retry with stronger instructions? Estimated cost: ~${costStr} (rough estimate)`, - confirmText: 'Retry', - cancelText: 'Keep', + title: this.transloco.translate('translation.reader.confirm.wrongLangTitle'), + message: this.transloco.translate('translation.reader.confirm.wrongLangMessage', { source: sourceLang, target: targetLang, cost: costStr }), + confirmText: this.transloco.translate('translation.reader.confirm.retry'), + cancelText: this.transloco.translate('translation.reader.confirm.keep'), }); if (!retryConfirmed) return; @@ -622,7 +631,7 @@ export class TranslationReaderComponent implements OnInit, OnDestroy { this.updateCachedTranslation(page, err.error.data); this.reloadFootnotes(); } - this.error.set(err.error?.message ?? 'Translation failed.'); + this.error.set(err.error?.message ?? this.transloco.translate('translation.reader.errors.translationFailed')); this.translating.set(false); } @@ -667,7 +676,7 @@ export class TranslationReaderComponent implements OnInit, OnDestroy { this.saving.set(false); }, error: (err) => { - this.error.set(err.error?.message ?? 'Failed to save.'); + this.error.set(err.error?.message ?? this.transloco.translate('translation.reader.errors.save')); this.saving.set(false); } }); @@ -675,10 +684,10 @@ export class TranslationReaderComponent implements OnInit, OnDestroy { async clearTranslation() { const confirmed = await this.confirmationService.confirm({ - title: 'Clear translation?', - message: 'The translation for this page will be permanently deleted.', - confirmText: 'Clear', - cancelText: 'Cancel', + title: this.transloco.translate('translation.reader.confirm.clearTitle'), + message: this.transloco.translate('translation.reader.confirm.clearMessage'), + confirmText: this.transloco.translate('translation.reader.confirm.clear'), + cancelText: this.transloco.translate('translation.reader.confirm.cancel'), }); if (!confirmed) return; @@ -691,7 +700,7 @@ export class TranslationReaderComponent implements OnInit, OnDestroy { this.clearing.set(false); }, error: () => { - this.error.set('Failed to clear translation.'); + this.error.set(this.transloco.translate('translation.reader.errors.clear')); this.clearing.set(false); } }); @@ -781,10 +790,13 @@ export class TranslationReaderComponent implements OnInit, OnDestroy { if (!this.allSegmentsOk()) { const n = this.oversizedSegmentsCount(); const confirmed = await this.confirmationService.confirm({ - title: 'Some chunks are still too long', - message: `${n} chunk${n > 1 ? 's are' : ' is'} over 3000 words and may produce incomplete translations. Send anyway?`, - confirmText: 'Send anyway', - cancelText: 'Go back', + title: this.transloco.translate('translation.reader.confirm.splitsTooLongTitle'), + message: this.transloco.translate( + n > 1 ? 'translation.reader.confirm.splitsTooLongMessageMany' : 'translation.reader.confirm.splitsTooLongMessageOne', + { count: n } + ), + confirmText: this.transloco.translate('translation.reader.confirm.sendAnyway'), + cancelText: this.transloco.translate('translation.reader.confirm.goBack'), }); if (!confirmed) return; } @@ -799,7 +811,7 @@ export class TranslationReaderComponent implements OnInit, OnDestroy { void this.executeTranslation(); }, error: (err) => { - this.error.set(err.error?.message ?? 'Failed to save splits.'); + this.error.set(err.error?.message ?? this.transloco.translate('translation.reader.errors.saveSplits')); this.savingSplits.set(false); } }); @@ -810,8 +822,10 @@ export class TranslationReaderComponent implements OnInit, OnDestroy { } getLanguageName(code: string | null): string { - if (!code) return 'Original'; - return LANGUAGE_NAMES[code] ?? code; + if (!code) return this.transloco.translate('translation.reader.originalLabel'); + const key = `languages.${code}`; + const name = this.transloco.translate(key); + return name === key ? code : name; } closeFootnoteTooltip() { @@ -828,10 +842,10 @@ export class TranslationReaderComponent implements OnInit, OnDestroy { if (fn.wrong_language && !forceRetry) { // Wrong language detected — go straight to retry confirmation const retryConfirmed = await this.confirmationService.confirm({ - title: `Retry footnote [^${fn.number}] with stronger instructions?`, - message: `Gemini returned this footnote in the source language instead of ${this.getLanguageName(this.targetLanguage())}. Retry with a stronger prompt? Estimated cost: ~${costStr}`, - confirmText: 'Retry', - cancelText: 'Keep', + title: this.transloco.translate('translation.reader.confirm.fnRetryTitle', { number: fn.number }), + message: this.transloco.translate('translation.reader.confirm.fnRetryMessage', { language: this.getLanguageName(this.targetLanguage()), cost: costStr }), + confirmText: this.transloco.translate('translation.reader.confirm.retry'), + cancelText: this.transloco.translate('translation.reader.confirm.keep'), }); if (!retryConfirmed) return; void this.executeFootnoteTranslation(doc.id, fn, true); @@ -839,10 +853,13 @@ export class TranslationReaderComponent implements OnInit, OnDestroy { } const confirmed = await this.confirmationService.confirm({ - title: fn.translated_text ? `Re-translate footnote [^${fn.number}]?` : `Translate footnote [^${fn.number}]?`, - message: (fn.translated_text ? 'The existing footnote translation will be replaced. ' : '') + `Estimated cost: ~${costStr} (rough estimate based on character count)`, - confirmText: 'Translate', - cancelText: 'Cancel', + title: this.transloco.translate(fn.translated_text ? 'translation.reader.confirm.fnReTranslateTitle' : 'translation.reader.confirm.fnTranslateTitle', { number: fn.number }), + message: this.transloco.translate('translation.reader.confirm.fnMessage', { + replace: fn.translated_text ? this.transloco.translate('translation.reader.confirm.fnReplace') : '', + cost: costStr, + }), + confirmText: this.transloco.translate('translation.reader.confirm.translate'), + cancelText: this.transloco.translate('translation.reader.confirm.cancel'), }); if (!confirmed) return; @@ -862,17 +879,17 @@ export class TranslationReaderComponent implements OnInit, OnDestroy { if (updated.wrong_language) { const retryConfirmed = await this.confirmationService.confirm({ - title: `Footnote [^${fn.number}] returned wrong language`, - message: `Gemini returned this footnote in the source language instead of ${this.getLanguageName(this.targetLanguage())}. Retry with stronger instructions? Estimated cost: ~${costStr}`, - confirmText: 'Retry', - cancelText: 'Keep', + title: this.transloco.translate('translation.reader.confirm.fnWrongLangTitle', { number: fn.number }), + message: this.transloco.translate('translation.reader.confirm.fnWrongLangMessage', { language: this.getLanguageName(this.targetLanguage()), cost: costStr }), + confirmText: this.transloco.translate('translation.reader.confirm.retry'), + cancelText: this.transloco.translate('translation.reader.confirm.keep'), }); if (!retryConfirmed) return; void this.executeFootnoteTranslation(docId, fn, true); } }, error: (err) => { - this.error.set(err.error?.message ?? 'Footnote translation failed.'); + this.error.set(err.error?.message ?? this.transloco.translate('translation.reader.errors.footnoteFailed')); this.translatingFootnote.set(false); } }); @@ -886,7 +903,7 @@ export class TranslationReaderComponent implements OnInit, OnDestroy { this.footnotes.update(fns => fns.map(f => f.id === updated.id ? updated : f)); if (this.activeFootnote()?.id === updated.id) this.activeFootnote.set(updated); }, - error: () => this.error.set('Failed to dismiss.') + error: () => this.error.set(this.transloco.translate('translation.reader.errors.dismiss')) }); } diff --git a/wordkeep-client/src/app/pages/translation/translation.html b/wordkeep-client/src/app/pages/translation/translation.html index 3934489..a95e00c 100644 --- a/wordkeep-client/src/app/pages/translation/translation.html +++ b/wordkeep-client/src/app/pages/translation/translation.html @@ -1,15 +1,16 @@ +
    @@ -29,10 +30,10 @@

    Translation

    -

    Your Translations

    +

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

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

    Your Translations

    @if (loading()) {
    -

    Loading translations...

    +

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

    } @else if (translations().length === 0) {
    @@ -50,13 +51,13 @@

    Your Translations

    -

    No translations yet

    -

    Click "New Translation" to translate a document

    +

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

    +

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

    } @else {
    - @for (t of translations(); track t.id) { -
    + @for (tr of translations(); track tr.id) { +
    @@ -65,47 +66,47 @@

    No translations yet

    -

    {{ t.document?.effective_filename ?? 'Document #' + t.document_id }}

    - {{ getLanguageName(t.target_language) }} - @if ((t.wrong_language_count ?? 0) > 0) { - {{ t.wrong_language_count }} pages with wrong language +

    {{ tr.document?.effective_filename ?? t('list.documentFallback', { id: tr.document_id }) }}

    + {{ getLanguageName(tr.target_language) }} + @if ((tr.wrong_language_count ?? 0) > 0) { + {{ t('list.wrongLangPages', { count: tr.wrong_language_count }) }} } - @if ((t.wrong_language_footnote_count ?? 0) > 0) { - {{ t.wrong_language_footnote_count }} footnotes with wrong language + @if ((tr.wrong_language_footnote_count ?? 0) > 0) { + {{ t('list.wrongLangFootnotes', { count: tr.wrong_language_footnote_count }) }} }
    - {{ formatDate(t.created_at) }} - @if (t.document) { + {{ formatDate(tr.created_at) }} + @if (tr.document) { - {{ t.document.page_count }} page{{ t.document.page_count === 1 ? '' : 's' }} + {{ tr.document.page_count === 1 ? t('list.page', { count: tr.document.page_count }) : t('list.pagePlural', { count: tr.document.page_count }) }} }
    -
    +
    - {{ t.pages_translated_actual ?? 0 }}/{{ t.document?.page_count ?? 0 }} translated ({{ getIdleProgress(t) }}%) + {{ t('list.translatedProgress', { done: tr.pages_translated_actual ?? 0, total: tr.document?.page_count ?? 0, percent: getIdleProgress(tr) }) }}
    - @if (t.status === 'failed' && t.error_message) { -

    {{ t.error_message }}

    + @if (tr.status === 'failed' && tr.error_message) { +

    {{ tr.error_message }}

    }
    - @if (!isRunning(t)) { - {{ formatDate(t.updated_at) }} + @if (!isRunning(tr)) { + {{ formatDate(tr.updated_at) }} }
    - @if (isRunning(t)) { + @if (isRunning(tr)) {
    - {{ t.pages_completed }}/{{ t.pages_total }} + {{ tr.pages_completed }}/{{ tr.pages_total }}
    -
    +
    - @if (t.status === 'processing') { -
    } @else {
    -
    - - Page {{ currentPage() }} of {{ lastPage() }} + {{ t('list.pageInfo', { current: currentPage(), last: lastPage() }) }}
    + diff --git a/wordkeep-client/src/app/pages/translation/translation.spec.ts b/wordkeep-client/src/app/pages/translation/translation.spec.ts index d192d05..7fdac56 100644 --- a/wordkeep-client/src/app/pages/translation/translation.spec.ts +++ b/wordkeep-client/src/app/pages/translation/translation.spec.ts @@ -10,6 +10,7 @@ import { NewTranslationDialogService } from '../../services/new-translation-dial import { AuthService } from '../../services/auth.service'; import { UserViewStatesService } from '../../services/user-view-states.service'; import { DocumentTranslation, SortOptions } from '../../models/document.model'; +import { getTranslocoTestingModule } from '../../transloco/transloco-testing'; const makeTranslation = (overrides: Partial = {}): DocumentTranslation => ({ id: 1, @@ -35,7 +36,7 @@ const mockPaginated = { total: 16, }; -const mockPricingRules = signal({ input_per_million_tokens: 0.075, output_per_million_tokens: 0.30, chars_per_token: 4 }); +const mockPricingRules = signal({ input_per_million_tokens: 0.30, output_per_million_tokens: 2.50, chars_per_token: 4 }); describe('TranslationComponent', () => { let component: TranslationComponent; @@ -79,7 +80,7 @@ describe('TranslationComponent', () => { TestBed.resetTestingModule(); TestBed.configureTestingModule({ - imports: [TranslationComponent], + imports: [TranslationComponent, getTranslocoTestingModule()], providers: [ provideRouter([]), { provide: TranslationService, useValue: mockTranslationService }, diff --git a/wordkeep-client/src/app/pages/translation/translation.ts b/wordkeep-client/src/app/pages/translation/translation.ts index 4aedc98..e36752b 100644 --- a/wordkeep-client/src/app/pages/translation/translation.ts +++ b/wordkeep-client/src/app/pages/translation/translation.ts @@ -1,7 +1,9 @@ import { Component, OnInit, OnDestroy, inject, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; import { Router } from '@angular/router'; +import { provideTranslocoScope, TranslocoDirective, TranslocoService } from '@jsverse/transloco'; import { interval, Subscription } from 'rxjs'; +import { formatDateTime } from '../../utils/date-format'; import { TranslationService } from '../../services/translation.service'; import { ConfirmationService } from '../../services/confirmation.service'; import { NewTranslationDialogService } from '../../services/new-translation-dialog.service'; @@ -9,13 +11,13 @@ import { DocumentTranslation, SortOptions } from '../../models/document.model'; import { LayoutComponent } from '../../components/layout/layout'; import { SortDropdownComponent } from '../../components/sort-dropdown/sort-dropdown'; import { estimateTranslationCost } from '../../utils/translation-cost'; -import { languageDisplayName } from '../../utils/language-names'; import { UserViewStatesService } from '../../services/user-view-states.service'; @Component({ selector: 'app-translation', standalone: true, - imports: [CommonModule, LayoutComponent, SortDropdownComponent], + imports: [CommonModule, LayoutComponent, SortDropdownComponent, TranslocoDirective], + providers: [provideTranslocoScope('translation')], templateUrl: './translation.html', styleUrl: './translation.scss' }) @@ -25,6 +27,7 @@ export class TranslationComponent implements OnInit, OnDestroy { private newTranslationDialog = inject(NewTranslationDialogService); private router = inject(Router); private viewStates = inject(UserViewStatesService); + private transloco = inject(TranslocoService); private pollSubscription?: Subscription; private dispatchedIds = new Set(); @@ -65,7 +68,7 @@ export class TranslationComponent implements OnInit, OnDestroy { this.checkAndStartPolling(); }, error: () => { - this.error.set('Failed to load translations'); + this.error.set(this.transloco.translate('translation.list.errors.load')); this.loading.set(false); } }); @@ -108,7 +111,7 @@ export class TranslationComponent implements OnInit, OnDestroy { this.loadTranslations(this.currentPage()); }, error: () => { - this.error.set('Failed to create translation'); + this.error.set(this.transloco.translate('translation.list.errors.create')); } }); } @@ -128,29 +131,37 @@ export class TranslationComponent implements OnInit, OnDestroy { let confirmOptions: object; if (pages_to_translate === 0) { - message = 'All pages are already translated. Nothing will be billed.'; - confirmOptions = { confirmText: 'OK', cancelText: 'Cancel' }; + message = this.transloco.translate('translation.list.confirm.noPagesMessage'); + confirmOptions = { + confirmText: this.transloco.translate('translation.list.confirm.ok'), + cancelText: this.transloco.translate('translation.list.confirm.cancel'), + }; } else { const pricing = this.translationService.pricingRules(); const totalCostStr = pricing ? estimateTranslationCost(total_chars, pricing) : '~'; const avgCostStr = pricing && pages_to_translate > 0 ? estimateTranslationCost(Math.round(total_chars / pages_to_translate), pricing) : '~'; - const pageLabel = pages_to_translate === 1 ? '1 page' : `${pages_to_translate} pages`; - const costLine = `Estimated cost: ~${totalCostStr} (~${avgCostStr}/page on average). Canceling mid-run won't bill you for the full amount.`; - message = `${pageLabel} will be ${override ? 're-' : ''}translated. ${costLine}`; + const pageLabel = pages_to_translate === 1 + ? this.transloco.translate('translation.list.confirm.pageLabel') + : this.transloco.translate('translation.list.confirm.pageLabelPlural', { count: pages_to_translate }); + const costLine = this.transloco.translate('translation.list.confirm.costLine', { total: totalCostStr, avg: avgCostStr }); + message = this.transloco.translate( + override ? 'translation.list.confirm.messageReTranslate' : 'translation.list.confirm.message', + { pageLabel, costLine } + ); confirmOptions = override ? { - warningText: 'All existing translations for this document and language will be replaced.', + warningText: this.transloco.translate('translation.list.confirm.overrideWarning'), isDanger: true, - confirmText: 'Re-translate', - } : { confirmText: 'Translate' }; + confirmText: this.transloco.translate('translation.list.confirm.reTranslate'), + } : { confirmText: this.transloco.translate('translation.list.confirm.translate') }; } const confirmed = await this.confirmationService.confirm({ - title: override ? 'Override Re-translate All?' : 'Translate All?', + title: this.transloco.translate(override ? 'translation.list.confirm.titleOverride' : 'translation.list.confirm.title'), message, ...confirmOptions, - cancelText: 'Cancel', + cancelText: this.transloco.translate('translation.list.confirm.cancel'), } as any); if (!confirmed) return; @@ -170,13 +181,13 @@ export class TranslationComponent implements OnInit, OnDestroy { this.translations.update(list => list.map(item => item.id === t.id ? { ...item, status: t.status } : item) ); - this.error.set('Failed to start translation'); + this.error.set(this.transloco.translate('translation.list.errors.start')); } }); }, error: () => { this.estimatingIds.update(s => { const n = new Set(s); n.delete(t.id); return n; }); - this.error.set('Failed to fetch translation estimate'); + this.error.set(this.transloco.translate('translation.list.errors.estimate')); } }); } @@ -187,7 +198,7 @@ export class TranslationComponent implements OnInit, OnDestroy { this.translationService.cancelTranslation(t.document_id, t.id).subscribe({ next: () => this.loadTranslations(this.currentPage()), - error: () => this.error.set('Failed to cancel translation') + error: () => this.error.set(this.transloco.translate('translation.list.errors.cancel')) }); } @@ -195,19 +206,22 @@ export class TranslationComponent implements OnInit, OnDestroy { event.preventDefault(); event.stopPropagation(); - const name = `${t.document?.effective_filename ?? 'document'} (${this.getLanguageName(t.target_language)})`; + const name = this.transloco.translate('translation.list.confirm.deleteName', { + filename: t.document?.effective_filename ?? this.transloco.translate('translation.list.confirm.documentFallback'), + language: this.getLanguageName(t.target_language), + }); const confirmed = await this.confirmationService.confirm({ - title: 'Delete Translation', - message: `Are you sure you want to delete the translation "${name}"? This cannot be undone.`, - confirmText: 'Delete', - cancelText: 'Cancel', + title: this.transloco.translate('translation.list.confirm.deleteTitle'), + message: this.transloco.translate('translation.list.confirm.deleteMessage', { name }), + confirmText: this.transloco.translate('translation.list.confirm.delete'), + cancelText: this.transloco.translate('translation.list.confirm.cancel'), isDanger: true }); if (!confirmed) return; this.translationService.deleteTranslation(t.document_id, t.id).subscribe({ next: () => this.loadTranslations(this.currentPage()), - error: () => this.error.set('Failed to delete translation') + error: () => this.error.set(this.transloco.translate('translation.list.errors.delete')) }); } @@ -233,14 +247,13 @@ export class TranslationComponent implements OnInit, OnDestroy { } getLanguageName(code: string): string { - return languageDisplayName(code); + const key = `languages.${code}`; + const name = this.transloco.translate(key); + return name === key ? code : name; } 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()); } goToPage(page: number) { diff --git a/wordkeep-client/src/app/pages/verify-email-change/verify-email-change.html b/wordkeep-client/src/app/pages/verify-email-change/verify-email-change.html index 9b7aa1a..5860392 100644 --- a/wordkeep-client/src/app/pages/verify-email-change/verify-email-change.html +++ b/wordkeep-client/src/app/pages/verify-email-change/verify-email-change.html @@ -1,6 +1,6 @@ -
    +
    @@ -17,8 +17,8 @@ @case ('verifying') {
    -

    Verifying your new email...

    -

    Please wait while we confirm your email change.

    +

    {{ t('verifyChange.verifyingTitle') }}

    +

    {{ t('verifyChange.verifyingBody') }}

    } @case ('success') { @@ -29,8 +29,8 @@

    Verifying your new email...

    -

    Email updated!

    -

    Your email address has been changed successfully. Redirecting...

    +

    {{ t('verifyChange.successTitle') }}

    +

    {{ t('verifyChange.successBody') }}

    } @case ('error') { @@ -41,9 +41,9 @@

    Email updated!

    -

    Verification failed

    -

    {{ errorMessage() }}

    - Back to Profile +

    {{ t('verifyChange.failedTitle') }}

    +

    {{ serverError() || t(errorKey()) }}

    + {{ t('verifyChange.backToProfile') }}
    } } diff --git a/wordkeep-client/src/app/pages/verify-email-change/verify-email-change.spec.ts b/wordkeep-client/src/app/pages/verify-email-change/verify-email-change.spec.ts index 81655ff..bf5e4da 100644 --- a/wordkeep-client/src/app/pages/verify-email-change/verify-email-change.spec.ts +++ b/wordkeep-client/src/app/pages/verify-email-change/verify-email-change.spec.ts @@ -5,6 +5,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { VerifyEmailChangeComponent } from './verify-email-change'; import { AuthService } from '../../services/auth.service'; import { mockUser } from '../../test-utils'; +import { getTranslocoTestingModule } from '../../transloco/transloco-testing'; describe('VerifyEmailChangeComponent', () => { let component: VerifyEmailChangeComponent; @@ -27,7 +28,7 @@ describe('VerifyEmailChangeComponent', () => { TestBed.resetTestingModule(); TestBed.configureTestingModule({ - imports: [VerifyEmailChangeComponent], + imports: [VerifyEmailChangeComponent, getTranslocoTestingModule()], providers: [ provideRouter([]), { provide: AuthService, useValue: mockAuthService }, @@ -52,7 +53,7 @@ describe('VerifyEmailChangeComponent', () => { fixture.detectChanges(); expect(component.status()).toBe('error'); - expect(component.errorMessage()).toBeTruthy(); + expect(component.errorKey()).toBe('verifyChange.noToken'); expect(mockAuthService.verifyEmailChange).not.toHaveBeenCalled(); }); @@ -101,7 +102,7 @@ describe('VerifyEmailChangeComponent', () => { fixture.detectChanges(); expect(component.status()).toBe('error'); - expect(component.errorMessage()).toBe('Token expired.'); + expect(component.serverError()).toBe('Token expired.'); }); it('should_show_fallback_error_when_no_message_in_response', () => { @@ -111,6 +112,7 @@ describe('VerifyEmailChangeComponent', () => { fixture.detectChanges(); expect(component.status()).toBe('error'); - expect(component.errorMessage()).toBeTruthy(); + expect(component.errorKey()).toBe('verifyChange.verifyFailed'); + expect(component.serverError()).toBeNull(); }); }); diff --git a/wordkeep-client/src/app/pages/verify-email-change/verify-email-change.ts b/wordkeep-client/src/app/pages/verify-email-change/verify-email-change.ts index aeecb74..a624427 100644 --- a/wordkeep-client/src/app/pages/verify-email-change/verify-email-change.ts +++ b/wordkeep-client/src/app/pages/verify-email-change/verify-email-change.ts @@ -1,12 +1,13 @@ import { Component, OnInit, inject, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { TranslocoDirective } from '@jsverse/transloco'; import { AuthService } from '../../services/auth.service'; @Component({ selector: 'app-verify-email-change', standalone: true, - imports: [CommonModule, RouterLink], + imports: [CommonModule, RouterLink, TranslocoDirective], templateUrl: './verify-email-change.html', styleUrl: './verify-email-change.scss' }) @@ -16,14 +17,15 @@ export class VerifyEmailChangeComponent implements OnInit { private router = inject(Router); status = signal<'verifying' | 'success' | 'error'>('verifying'); - errorMessage = signal(null); + errorKey = signal<'verifyChange.noToken' | 'verifyChange.verifyFailed'>('verifyChange.verifyFailed'); + serverError = signal(null); ngOnInit(): void { const token = this.route.snapshot.queryParamMap.get('token'); if (!token) { + this.errorKey.set('verifyChange.noToken'); this.status.set('error'); - this.errorMessage.set('No verification token provided.'); return; } @@ -34,8 +36,9 @@ export class VerifyEmailChangeComponent implements OnInit { setTimeout(() => this.router.navigate([dest]), 3000); }, error: (err) => { + this.errorKey.set('verifyChange.verifyFailed'); + this.serverError.set(err.error?.message ?? null); this.status.set('error'); - this.errorMessage.set(err.error?.message || 'Verification failed. The link may be invalid or expired.'); } }); } diff --git a/wordkeep-client/src/app/pages/verify-email/verify-email.html b/wordkeep-client/src/app/pages/verify-email/verify-email.html index 2ceac73..d7ba526 100644 --- a/wordkeep-client/src/app/pages/verify-email/verify-email.html +++ b/wordkeep-client/src/app/pages/verify-email/verify-email.html @@ -1,6 +1,6 @@ -
    +
    @@ -17,8 +17,8 @@ @case ('verifying') {
    -

    Verifying your email...

    -

    Please wait while we verify your email address.

    +

    {{ t('verifyEmail.verifyingTitle') }}

    +

    {{ t('verifyEmail.verifyingBody') }}

    } @case ('success') { @@ -29,8 +29,8 @@

    Verifying your email...

    -

    Email verified!

    -

    Your account has been activated. Redirecting to dashboard...

    +

    {{ t('verifyEmail.successTitle') }}

    +

    {{ t('verifyEmail.successBody') }}

    } @case ('error') { @@ -41,9 +41,9 @@

    Email verified!

    -

    Verification failed

    -

    {{ errorMessage() }}

    - Back to login +

    {{ t('verifyEmail.failedTitle') }}

    +

    {{ serverError() || t(errorKey()) }}

    + {{ t('common.backToLogin') }}
    } } diff --git a/wordkeep-client/src/app/pages/verify-email/verify-email.spec.ts b/wordkeep-client/src/app/pages/verify-email/verify-email.spec.ts index f4e5a94..0587ac1 100644 --- a/wordkeep-client/src/app/pages/verify-email/verify-email.spec.ts +++ b/wordkeep-client/src/app/pages/verify-email/verify-email.spec.ts @@ -5,6 +5,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { VerifyEmailComponent } from './verify-email'; import { AuthService } from '../../services/auth.service'; import { mockVerifyEmailResponse } from '../../test-utils'; +import { getTranslocoTestingModule } from '../../transloco/transloco-testing'; describe('VerifyEmailComponent', () => { let component: VerifyEmailComponent; @@ -25,7 +26,7 @@ describe('VerifyEmailComponent', () => { TestBed.resetTestingModule(); TestBed.configureTestingModule({ - imports: [VerifyEmailComponent], + imports: [VerifyEmailComponent, getTranslocoTestingModule()], providers: [ provideRouter([]), { provide: AuthService, useValue: mockAuthService }, @@ -78,7 +79,7 @@ describe('VerifyEmailComponent', () => { fixture.detectChanges(); expect(component.status()).toBe('error'); - expect(component.errorMessage()).toBe('Invalid token'); + expect(component.serverError()).toBe('Invalid token'); }); it('should_display_default_error_message', () => { @@ -86,7 +87,8 @@ describe('VerifyEmailComponent', () => { fixture.detectChanges(); - expect(component.errorMessage()).toBe('Verification failed. The link may be invalid or expired.'); + expect(component.serverError()).toBeNull(); + expect(component.errorKey()).toBe('verifyEmail.verifyFailed'); }); }); @@ -99,7 +101,7 @@ describe('VerifyEmailComponent', () => { fixture.detectChanges(); expect(component.status()).toBe('error'); - expect(component.errorMessage()).toBe('No verification token provided.'); + expect(component.errorKey()).toBe('verifyEmail.noToken'); expect(mockAuthService.verifyEmail).not.toHaveBeenCalled(); }); }); diff --git a/wordkeep-client/src/app/pages/verify-email/verify-email.ts b/wordkeep-client/src/app/pages/verify-email/verify-email.ts index 5870882..1c1971e 100644 --- a/wordkeep-client/src/app/pages/verify-email/verify-email.ts +++ b/wordkeep-client/src/app/pages/verify-email/verify-email.ts @@ -1,18 +1,23 @@ import { Component, OnInit, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { TranslocoDirective } from '@jsverse/transloco'; import { AuthService } from '../../services/auth.service'; @Component({ selector: 'app-verify-email', standalone: true, - imports: [CommonModule, RouterLink], + imports: [CommonModule, RouterLink, TranslocoDirective], templateUrl: './verify-email.html', styleUrl: './verify-email.scss' }) export class VerifyEmailComponent implements OnInit { status = signal<'verifying' | 'success' | 'error'>('verifying'); - errorMessage = signal(null); + // Fallback translation key for the error state; the template renders it via + // the scoped t() so it resolves once the lazy auth scope has loaded. + errorKey = signal<'verifyEmail.noToken' | 'verifyEmail.verifyFailed'>('verifyEmail.verifyFailed'); + // A server-provided (already-localized) message, when present, takes priority. + serverError = signal(null); constructor( private route: ActivatedRoute, @@ -24,8 +29,8 @@ export class VerifyEmailComponent implements OnInit { const token = this.route.snapshot.queryParamMap.get('token'); if (!token) { + this.errorKey.set('verifyEmail.noToken'); this.status.set('error'); - this.errorMessage.set('No verification token provided.'); return; } @@ -35,8 +40,9 @@ export class VerifyEmailComponent implements OnInit { setTimeout(() => this.router.navigate(['/dashboard']), 3000); }, error: (err) => { + this.errorKey.set('verifyEmail.verifyFailed'); + this.serverError.set(err.error?.message ?? null); this.status.set('error'); - this.errorMessage.set(err.error?.message || 'Verification failed. The link may be invalid or expired.'); } }); } diff --git a/wordkeep-client/src/app/services/confirmation.service.spec.ts b/wordkeep-client/src/app/services/confirmation.service.spec.ts index 8527595..3f08251 100644 --- a/wordkeep-client/src/app/services/confirmation.service.spec.ts +++ b/wordkeep-client/src/app/services/confirmation.service.spec.ts @@ -1,13 +1,24 @@ import { TestBed } from '@angular/core/testing'; import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { TranslocoService } from '@jsverse/transloco'; import { ConfirmationService } from './confirmation.service'; describe('ConfirmationService', () => { let service: ConfirmationService; + // Echo keys (with params appended) so assertions can verify the right + // translation key + interpolation reach the dialog config. + const translocoStub = { + translate: (key: string, params?: Record) => + params ? `${key} ${JSON.stringify(params)}` : key + }; + beforeEach(() => { TestBed.configureTestingModule({ - providers: [ConfirmationService] + providers: [ + ConfirmationService, + { provide: TranslocoService, useValue: translocoStub } + ] }); service = TestBed.inject(ConfirmationService); }); @@ -24,7 +35,7 @@ describe('ConfirmationService', () => { expect(confirmSpy).toHaveBeenCalledWith( expect.objectContaining({ - title: 'Delete Document' + title: 'confirmations.deleteDocument.title' }) ); }); @@ -48,7 +59,7 @@ describe('ConfirmationService', () => { expect(confirmSpy).toHaveBeenCalledWith( expect.objectContaining({ - confirmText: 'Delete' + confirmText: 'confirmations.deleteDocument.confirm' }) ); }); @@ -90,7 +101,7 @@ describe('ConfirmationService', () => { expect(confirmSpy).toHaveBeenCalledWith( expect.objectContaining({ - title: 'Override OCR Processing' + title: 'confirmations.overrideOcrSingle.title' }) ); }); @@ -102,7 +113,7 @@ describe('ConfirmationService', () => { expect(confirmSpy).toHaveBeenCalledWith( expect.objectContaining({ - message: expect.stringContaining('this page') + message: 'confirmations.overrideOcrSingle.message' }) ); }); @@ -114,7 +125,7 @@ describe('ConfirmationService', () => { expect(confirmSpy).toHaveBeenCalledWith( expect.objectContaining({ - warningText: expect.stringContaining('this page') + warningText: 'confirmations.overrideOcrSingle.warning' }) ); }); @@ -126,7 +137,7 @@ describe('ConfirmationService', () => { expect(confirmSpy).toHaveBeenCalledWith( expect.objectContaining({ - confirmText: 'Run OCR' + confirmText: 'confirmations.overrideOcrSingle.confirm' }) ); }); @@ -168,7 +179,7 @@ describe('ConfirmationService', () => { expect(confirmSpy).toHaveBeenCalledWith( expect.objectContaining({ - title: 'Override OCR Processing' + title: 'confirmations.overrideOcrBulk.title' }) ); }); @@ -180,7 +191,7 @@ describe('ConfirmationService', () => { expect(confirmSpy).toHaveBeenCalledWith( expect.objectContaining({ - message: expect.stringContaining('all pages') + message: 'confirmations.overrideOcrBulk.message' }) ); }); @@ -192,7 +203,7 @@ describe('ConfirmationService', () => { expect(confirmSpy).toHaveBeenCalledWith( expect.objectContaining({ - warningText: expect.stringContaining('all pages') + warningText: 'confirmations.overrideOcrBulk.warning' }) ); }); @@ -204,7 +215,7 @@ describe('ConfirmationService', () => { expect(confirmSpy).toHaveBeenCalledWith( expect.objectContaining({ - confirmText: 'Run OCR' + confirmText: 'confirmations.overrideOcrBulk.confirm' }) ); }); @@ -238,6 +249,37 @@ describe('ConfirmationService', () => { }); }); + // Guard: every helper must feed the dialog translation keys, never raw + // English literals. Catches a regression the .html i18n check can't see + // (the strings originate in this service, not a template). + describe('localization guard', () => { + const helpers: Array<[string, () => Promise]> = [ + ['confirmDelete', () => service.confirmDelete('doc.pdf')], + ['confirmOverrideOcrSingle', () => service.confirmOverrideOcrSingle()], + ['confirmOverrideExtractTextSingle', () => service.confirmOverrideExtractTextSingle()], + ['confirmOverrideOcrBulk', () => service.confirmOverrideOcrBulk()] + ]; + + for (const [name, call] of helpers) { + it(`${name}_passes_only_translation_keys`, async () => { + const translateSpy = vi.spyOn(translocoStub, 'translate'); + const confirmSpy = vi.spyOn(service, 'confirm').mockResolvedValue(true); + + await call(); + + expect(translateSpy).toHaveBeenCalled(); + const cfg = confirmSpy.mock.calls[0][0]; + expect(cfg.title).toMatch(/^confirmations\./); + expect(cfg.confirmText).toMatch(/^confirmations\./); + expect(cfg.cancelText).toBe('common.cancel'); + if (cfg.message) expect(cfg.message).toMatch(/^confirmations\./); + if (cfg.warningText) expect(cfg.warningText).toMatch(/^confirmations\./); + + translateSpy.mockRestore(); + }); + } + }); + describe('confirm method structure', () => { it('should_return_promise', () => { // Mock to prevent actual DOM manipulation diff --git a/wordkeep-client/src/app/services/confirmation.service.ts b/wordkeep-client/src/app/services/confirmation.service.ts index d7ff6d9..096ad28 100644 --- a/wordkeep-client/src/app/services/confirmation.service.ts +++ b/wordkeep-client/src/app/services/confirmation.service.ts @@ -1,4 +1,5 @@ import { Injectable, ApplicationRef, createComponent, EnvironmentInjector, inject } from '@angular/core'; +import { TranslocoService } from '@jsverse/transloco'; import { ConfirmationDialogComponent, ConfirmationDialogConfig } from '../components/confirmation-dialog/confirmation-dialog'; @Injectable({ @@ -7,6 +8,7 @@ import { ConfirmationDialogComponent, ConfirmationDialogConfig } from '../compon export class ConfirmationService { private appRef = inject(ApplicationRef); private injector = inject(EnvironmentInjector); + private transloco = inject(TranslocoService); private dialogComponentRef: any = null; async confirm(config: ConfirmationDialogConfig): Promise { @@ -23,43 +25,43 @@ export class ConfirmationService { async confirmDelete(itemName: string): Promise { return this.confirm({ - title: 'Delete Document', - message: `Are you sure you want to delete "${itemName}"? This cannot be undone.`, - confirmText: 'Delete', - cancelText: 'Cancel', + title: this.transloco.translate('confirmations.deleteDocument.title'), + message: this.transloco.translate('confirmations.deleteDocument.message', { name: itemName }), + confirmText: this.transloco.translate('confirmations.deleteDocument.confirm'), + cancelText: this.transloco.translate('common.cancel'), isDanger: true }); } async confirmOverrideOcrSingle(): Promise { return this.confirm({ - title: 'Override OCR Processing', - message: 'This will re-process this page with OCR.', - warningText: 'Any manual text edits or formatting changes on this page will be lost.', - confirmText: 'Run OCR', - cancelText: 'Cancel', + title: this.transloco.translate('confirmations.overrideOcrSingle.title'), + message: this.transloco.translate('confirmations.overrideOcrSingle.message'), + warningText: this.transloco.translate('confirmations.overrideOcrSingle.warning'), + confirmText: this.transloco.translate('confirmations.overrideOcrSingle.confirm'), + cancelText: this.transloco.translate('common.cancel'), isDanger: true }); } async confirmOverrideExtractTextSingle(): Promise { return this.confirm({ - title: 'Re-extract Text', - message: 'This will re-extract text from this page.', - warningText: 'Any manual text edits or formatting changes on this page will be lost.', - confirmText: 'Extract Text', - cancelText: 'Cancel', + title: this.transloco.translate('confirmations.overrideExtractTextSingle.title'), + message: this.transloco.translate('confirmations.overrideExtractTextSingle.message'), + warningText: this.transloco.translate('confirmations.overrideExtractTextSingle.warning'), + confirmText: this.transloco.translate('confirmations.overrideExtractTextSingle.confirm'), + cancelText: this.transloco.translate('common.cancel'), isDanger: true }); } async confirmOverrideOcrBulk(): Promise { return this.confirm({ - title: 'Override OCR Processing', - message: 'This will re-process all pages with OCR.', - warningText: 'Any manual text edits or formatting changes on all pages will be lost.', - confirmText: 'Run OCR', - cancelText: 'Cancel', + title: this.transloco.translate('confirmations.overrideOcrBulk.title'), + message: this.transloco.translate('confirmations.overrideOcrBulk.message'), + warningText: this.transloco.translate('confirmations.overrideOcrBulk.warning'), + confirmText: this.transloco.translate('confirmations.overrideOcrBulk.confirm'), + cancelText: this.transloco.translate('common.cancel'), isDanger: true }); } diff --git a/wordkeep-client/src/app/services/layout.service.ts b/wordkeep-client/src/app/services/layout.service.ts index d295f18..dd2b9b9 100644 --- a/wordkeep-client/src/app/services/layout.service.ts +++ b/wordkeep-client/src/app/services/layout.service.ts @@ -1,4 +1,4 @@ -import { Injectable, effect, inject } from '@angular/core'; +import { Injectable, effect, inject, signal } from '@angular/core'; import { UserPreferencesService } from './user-preferences.service'; export type Layout = @@ -36,6 +36,13 @@ export class LayoutService { readonly layout = this.prefs.layout; + /** + * When true, renders the guest-demo chrome (app-layout-demo) + * instead of the authenticated nav shell. Toggled by the /demo shell route. + */ + readonly demoMode = signal(false); + setDemoMode(on: boolean): void { this.demoMode.set(on); } + constructor() { effect(() => { const l = this.prefs.layout(); diff --git a/wordkeep-client/src/app/services/reader-mode.service.spec.ts b/wordkeep-client/src/app/services/reader-mode.service.spec.ts new file mode 100644 index 0000000..ab8c338 --- /dev/null +++ b/wordkeep-client/src/app/services/reader-mode.service.spec.ts @@ -0,0 +1,20 @@ +import { TestBed } from '@angular/core/testing'; +import { describe, it, expect, beforeEach } from 'vitest'; +import { ReaderModeService } from './reader-mode.service'; +import { DemoReaderModeService } from '../demo/demo-reader-mode.service'; + +describe('ReaderModeService', () => { + beforeEach(() => TestBed.configureTestingModule({})); + + it('defaults to full-app behaviour (writable, back link shown)', () => { + const svc = TestBed.inject(ReaderModeService); + expect(svc.readonly()).toBe(false); + expect(svc.showInReaderBackLink()).toBe(true); + }); + + it('demo override is read-only and hides the in-reader back link', () => { + const svc = new DemoReaderModeService(); + expect(svc.readonly()).toBe(true); + expect(svc.showInReaderBackLink()).toBe(false); + }); +}); diff --git a/wordkeep-client/src/app/services/reader-mode.service.ts b/wordkeep-client/src/app/services/reader-mode.service.ts new file mode 100644 index 0000000..fbd5f58 --- /dev/null +++ b/wordkeep-client/src/app/services/reader-mode.service.ts @@ -0,0 +1,15 @@ +import { Injectable, signal } from '@angular/core'; + +/** + * Per-context behaviour for the reused reader components. The default (full app) + * is read/write and shows the in-reader back link. The guest demo provides an + * override (DemoReaderModeService) that makes the readers read-only and hides the + * in-reader back link, since the demo chrome owns navigation. + */ +@Injectable({ providedIn: 'root' }) +export class ReaderModeService { + /** When true, mutating controls (ignore, footnote add/edit/delete) are disabled. */ + readonly readonly = signal(false); + /** When false, the reader's own back link is hidden (the host owns navigation). */ + readonly showInReaderBackLink = signal(true); +} diff --git a/wordkeep-client/src/app/services/translation.service.spec.ts b/wordkeep-client/src/app/services/translation.service.spec.ts index e5b23af..b8d47c2 100644 --- a/wordkeep-client/src/app/services/translation.service.spec.ts +++ b/wordkeep-client/src/app/services/translation.service.spec.ts @@ -31,13 +31,13 @@ describe('TranslationService', () => { it('loadPricingRules_fetches_and_caches', () => { service.loadPricingRules(); const req = http.expectOne(`${API}/translation/pricing`); - req.flush({ input_per_million_tokens: 0.075, output_per_million_tokens: 0.30, chars_per_token: 4 }); - expect(service.pricingRules()).toEqual({ input_per_million_tokens: 0.075, output_per_million_tokens: 0.30, chars_per_token: 4 }); + req.flush({ input_per_million_tokens: 0.30, output_per_million_tokens: 2.50, chars_per_token: 4 }); + expect(service.pricingRules()).toEqual({ input_per_million_tokens: 0.30, output_per_million_tokens: 2.50, chars_per_token: 4 }); }); it('loadPricingRules_is_no_op_if_already_loaded', () => { service.loadPricingRules(); - http.expectOne(`${API}/translation/pricing`).flush({ input_per_million_tokens: 0.075, output_per_million_tokens: 0.30, chars_per_token: 4 }); + http.expectOne(`${API}/translation/pricing`).flush({ input_per_million_tokens: 0.30, output_per_million_tokens: 2.50, chars_per_token: 4 }); service.loadPricingRules(); http.expectNone(`${API}/translation/pricing`); }); diff --git a/wordkeep-client/src/app/services/user-dictionary.service.ts b/wordkeep-client/src/app/services/user-dictionary.service.ts index 9cf8a12..761dbad 100644 --- a/wordkeep-client/src/app/services/user-dictionary.service.ts +++ b/wordkeep-client/src/app/services/user-dictionary.service.ts @@ -18,10 +18,10 @@ export class UserDictionaryService { private http = inject(HttpClient); private apiUrl = `${environment.apiUrl}/dictionary`; - private _words = signal([]); + protected _words = signal([]); readonly words = this._words.asReadonly(); - private _ready$ = new ReplaySubject(1); + protected _ready$ = new ReplaySubject(1); readonly ready$ = this._ready$.asObservable(); constructor() { diff --git a/wordkeep-client/src/app/services/user-ignored-words.service.ts b/wordkeep-client/src/app/services/user-ignored-words.service.ts index df213e8..b105534 100644 --- a/wordkeep-client/src/app/services/user-ignored-words.service.ts +++ b/wordkeep-client/src/app/services/user-ignored-words.service.ts @@ -18,10 +18,10 @@ export class UserIgnoredWordsService { private http = inject(HttpClient); private apiUrl = `${environment.apiUrl}/ignored-words`; - private _words = signal([]); + protected _words = signal([]); readonly words = this._words.asReadonly(); - private _ready$ = new ReplaySubject(1); + protected _ready$ = new ReplaySubject(1); readonly ready$ = this._ready$.asObservable(); constructor() { diff --git a/wordkeep-client/src/app/services/user-preferences.service.spec.ts b/wordkeep-client/src/app/services/user-preferences.service.spec.ts index f44be8e..2e5bb88 100644 --- a/wordkeep-client/src/app/services/user-preferences.service.spec.ts +++ b/wordkeep-client/src/app/services/user-preferences.service.spec.ts @@ -205,6 +205,96 @@ describe('UserPreferencesService', () => { }); }); + // ==================== locale ==================== + + describe('locale', () => { + function authenticateAndConsume(): void { + auth['storeAuth']?.(mockAuthResponse.token, mockAuthResponse.user); + TestBed.tick(); + httpMock.match(API_URL).forEach(req => req.flush({ data: null })); + } + + it('initializes locale from localStorage', () => { + storage.setItem('wk:locale', 'ro'); + service = TestBed.inject(UserPreferencesService); + expect(service.locale()).toBe('ro'); + }); + + it('falls back to en when localStorage empty', () => { + service = TestBed.inject(UserPreferencesService); + expect(service.locale()).toBe('en'); + }); + + it('ignores invalid stored locale', () => { + storage.setItem('wk:locale', 'fr'); + service = TestBed.inject(UserPreferencesService); + expect(service.locale()).toBe('en'); + }); + + it('localeId maps to Angular locale ids', () => { + storage.setItem('wk:locale', 'ro'); + service = TestBed.inject(UserPreferencesService); + expect(service.localeId()).toBe('ro'); + service.setLocale('en'); + expect(service.localeId()).toBe('en-GB'); + }); + + it('setLocale updates signal, storage and PATCHes when authenticated', () => { + service = TestBed.inject(UserPreferencesService); + authenticateAndConsume(); + + service.setLocale('ro'); + + expect(service.locale()).toBe('ro'); + expect(localStorage.getItem('wk:locale')).toBe('ro'); + const req = httpMock.expectOne(API_URL); + expect(req.request.body).toEqual({ locale: 'ro' }); + req.flush({ data: { theme_mode: null, palette: null, layout: null, locale: 'ro' } }); + }); + + it('adopts server locale when present', () => { + service = TestBed.inject(UserPreferencesService); + auth['storeAuth']?.(mockAuthResponse.token, mockAuthResponse.user); + TestBed.tick(); + + httpMock.expectOne(API_URL).flush({ data: { theme_mode: null, palette: null, layout: null, locale: 'ro' } }); + + expect(service.locale()).toBe('ro'); + expect(localStorage.getItem('wk:locale')).toBe('ro'); + }); + + it('persists the pre-login choice when the server has no locale yet', () => { + storage.setItem('wk:locale', 'ro'); + service = TestBed.inject(UserPreferencesService); + auth['storeAuth']?.(mockAuthResponse.token, mockAuthResponse.user); + TestBed.tick(); + + // GET resolves with no server-side locale. + httpMock.expectOne(API_URL).flush({ data: { theme_mode: null, palette: null, layout: null, locale: null } }); + + // The reconciliation PATCH saves the local choice. + const patch = httpMock.expectOne(API_URL); + expect(patch.request.method).toBe('PATCH'); + expect(patch.request.body).toEqual({ locale: 'ro' }); + patch.flush({ data: { theme_mode: null, palette: null, layout: null, locale: 'ro' } }); + }); + + it('does not reconcile when the local locale is the default en', () => { + service = TestBed.inject(UserPreferencesService); + auth['storeAuth']?.(mockAuthResponse.token, mockAuthResponse.user); + TestBed.tick(); + + httpMock.expectOne(API_URL).flush({ data: { theme_mode: null, palette: null, layout: null, locale: null } }); + // No follow-up PATCH expected (afterEach verify() asserts this). + }); + + it('re-hydrates locale signal on cross-tab storage event', () => { + service = TestBed.inject(UserPreferencesService); + window.dispatchEvent(new StorageEvent('storage', { key: 'wk:locale', newValue: 'ro' })); + expect(service.locale()).toBe('ro'); + }); + }); + // ==================== cross-tab storage event ==================== describe('cross-tab storage event', () => { diff --git a/wordkeep-client/src/app/services/user-preferences.service.ts b/wordkeep-client/src/app/services/user-preferences.service.ts index e6adfbd..8e2aaed 100644 --- a/wordkeep-client/src/app/services/user-preferences.service.ts +++ b/wordkeep-client/src/app/services/user-preferences.service.ts @@ -6,18 +6,26 @@ import { AuthService } from './auth.service'; import type { ThemeMode, Palette } from './theme.service'; import type { Layout } from './layout.service'; +export type Locale = 'en' | 'ro'; + const THEME_KEY = 'wk:theme'; const PALETTE_KEY = 'wk:palette'; const LAYOUT_KEY = 'wk:layout'; +const LOCALE_KEY = 'wk:locale'; const THEME_VALUES = new Set(['auto', 'light', 'dark']); const PALETTE_VALUES = new Set(['reading-room', 'cobalt', 'monastic', 'grove', 'ferrous', 'plum']); const LAYOUT_VALUES = new Set(['reading-room', 'workbench', 'compendium', 'glass', 'scriptorium', 'console']); +const LOCALE_VALUES = new Set(['en', 'ro']); + +/** Angular locale id used by date/number pipes for each app locale. */ +const LOCALE_IDS: Record = { en: 'en-GB', ro: 'ro' }; interface UserPreferencesData { theme_mode: ThemeMode | null; palette: Palette | null; layout: Layout | null; + locale: Locale | null; } @Injectable({ providedIn: 'root' }) @@ -29,10 +37,12 @@ export class UserPreferencesService { private _themeMode = signal(readStorage(THEME_KEY, THEME_VALUES, 'auto')); private _palette = signal(readStorage(PALETTE_KEY, PALETTE_VALUES, 'reading-room')); private _layout = signal(readStorage(LAYOUT_KEY, LAYOUT_VALUES, 'reading-room')); + private _locale = signal(readStorage(LOCALE_KEY, LOCALE_VALUES, 'en')); readonly themeMode = this._themeMode.asReadonly(); readonly palette = this._palette.asReadonly(); readonly layout = this._layout.asReadonly(); + readonly locale = this._locale.asReadonly(); constructor() { if (typeof window !== 'undefined') { @@ -47,10 +57,22 @@ export class UserPreferencesService { setThemeMode(m: ThemeMode): void { this.applyAndPatch({ theme_mode: m }); } setPalette(p: Palette): void { this.applyAndPatch({ palette: p }); } setLayout(l: Layout): void { this.applyAndPatch({ layout: l }); } + setLocale(l: Locale): void { this.applyAndPatch({ locale: l }); } + + /** Angular locale id (e.g. 'ro', 'en-GB') for the active locale, for date/number pipes. */ + localeId(): string { return LOCALE_IDS[this._locale()]; } load(): void { this.http.get<{ data: UserPreferencesData | null }>(this.apiUrl).pipe( - tap(res => { if (res.data) this.applyFromServer(res.data); }), + tap(res => { + if (res.data) this.applyFromServer(res.data); + // When the server has no locale opinion yet (null), adopt the local + // pre-login choice so it persists across devices. Only a non-default + // choice needs saving — null is already treated as 'en' server-side. + if (!res.data?.locale && this._locale() !== 'en') { + this.patch({ locale: this._locale() }); + } + }), catchError(() => of(null)), ).subscribe(); } @@ -68,11 +90,18 @@ export class UserPreferencesService { this._layout.set(partial.layout); writeStorage(LAYOUT_KEY, partial.layout); } - if (this.auth.authenticated()) { - this.http.patch<{ data: UserPreferencesData }>(this.apiUrl, partial).pipe( - catchError(() => of(null)), - ).subscribe(); + if (partial.locale !== undefined && partial.locale !== null) { + this._locale.set(partial.locale); + writeStorage(LOCALE_KEY, partial.locale); } + this.patch(partial); + } + + private patch(partial: Partial): void { + if (!this.auth.authenticated()) return; + this.http.patch<{ data: UserPreferencesData }>(this.apiUrl, partial).pipe( + catchError(() => of(null)), + ).subscribe(); } private applyFromServer(data: UserPreferencesData): void { @@ -88,6 +117,10 @@ export class UserPreferencesService { this._layout.set(data.layout); writeStorage(LAYOUT_KEY, data.layout); } + if (data.locale && LOCALE_VALUES.has(data.locale)) { + this._locale.set(data.locale); + writeStorage(LOCALE_KEY, data.locale); + } } private handleStorageEvent(e: StorageEvent): void { @@ -97,6 +130,8 @@ export class UserPreferencesService { this._palette.set(e.newValue as Palette); } else if (e.key === LAYOUT_KEY && e.newValue && LAYOUT_VALUES.has(e.newValue as Layout)) { this._layout.set(e.newValue as Layout); + } else if (e.key === LOCALE_KEY && e.newValue && LOCALE_VALUES.has(e.newValue as Locale)) { + this._locale.set(e.newValue as Locale); } } } diff --git a/wordkeep-client/src/app/transloco/transloco-loader.ts b/wordkeep-client/src/app/transloco/transloco-loader.ts new file mode 100644 index 0000000..977ccd8 --- /dev/null +++ b/wordkeep-client/src/app/transloco/transloco-loader.ts @@ -0,0 +1,19 @@ +import { inject, Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Translation, TranslocoLoader } from '@jsverse/transloco'; + +/** + * Loads translation files from the static assets folder. + * + * Root translations live at `/assets/i18n/.json`; lazy feature scopes are + * requested by Transloco as `/` and resolve to + * `/assets/i18n//.json`. Both shapes are handled by the same path. + */ +@Injectable({ providedIn: 'root' }) +export class TranslocoHttpLoader implements TranslocoLoader { + private readonly http = inject(HttpClient); + + getTranslation(path: string) { + return this.http.get(`/assets/i18n/${path}.json`); + } +} diff --git a/wordkeep-client/src/app/transloco/transloco-testing.ts b/wordkeep-client/src/app/transloco/transloco-testing.ts new file mode 100644 index 0000000..602d0ec --- /dev/null +++ b/wordkeep-client/src/app/transloco/transloco-testing.ts @@ -0,0 +1,76 @@ +import { TranslocoTestingModule, TranslocoTestingOptions } from '@jsverse/transloco'; + +import en from '../../../public/assets/i18n/en.json'; +import ro from '../../../public/assets/i18n/ro.json'; +import charterEn from '../../../public/assets/i18n/charter/en.json'; +import charterRo from '../../../public/assets/i18n/charter/ro.json'; +import profileEn from '../../../public/assets/i18n/profile/en.json'; +import profileRo from '../../../public/assets/i18n/profile/ro.json'; +import authEn from '../../../public/assets/i18n/auth/en.json'; +import authRo from '../../../public/assets/i18n/auth/ro.json'; +import sharedEn from '../../../public/assets/i18n/shared/en.json'; +import sharedRo from '../../../public/assets/i18n/shared/ro.json'; +import documentsEn from '../../../public/assets/i18n/documents/en.json'; +import documentsRo from '../../../public/assets/i18n/documents/ro.json'; +import extractionEn from '../../../public/assets/i18n/extraction/en.json'; +import extractionRo from '../../../public/assets/i18n/extraction/ro.json'; +import pageLinkingEn from '../../../public/assets/i18n/pageLinking/en.json'; +import pageLinkingRo from '../../../public/assets/i18n/pageLinking/ro.json'; +import bookViewEn from '../../../public/assets/i18n/bookView/en.json'; +import bookViewRo from '../../../public/assets/i18n/bookView/ro.json'; +import translationEn from '../../../public/assets/i18n/translation/en.json'; +import translationRo from '../../../public/assets/i18n/translation/ro.json'; +import dictionaryEn from '../../../public/assets/i18n/dictionary/en.json'; +import dictionaryRo from '../../../public/assets/i18n/dictionary/ro.json'; +import shellEn from '../../../public/assets/i18n/shell/en.json'; +import shellRo from '../../../public/assets/i18n/shell/ro.json'; +import homeEn from '../../../public/assets/i18n/home/en.json'; +import homeRo from '../../../public/assets/i18n/home/ro.json'; +import demoEn from '../../../public/assets/i18n/demo/en.json'; +import demoRo from '../../../public/assets/i18n/demo/ro.json'; + +/** + * Transloco module preconfigured for unit tests. Add it to a TestBed's + * `imports` so components using the `transloco` pipe/directive resolve keys. + * Register new lazy scopes here (as `/`) when they are added. + */ +export function getTranslocoTestingModule(options: TranslocoTestingOptions = {}) { + return TranslocoTestingModule.forRoot({ + langs: { + en, + ro, + 'charter/en': charterEn, + 'charter/ro': charterRo, + 'profile/en': profileEn, + 'profile/ro': profileRo, + 'auth/en': authEn, + 'auth/ro': authRo, + 'shared/en': sharedEn, + 'shared/ro': sharedRo, + 'documents/en': documentsEn, + 'documents/ro': documentsRo, + 'extraction/en': extractionEn, + 'extraction/ro': extractionRo, + 'pageLinking/en': pageLinkingEn, + 'pageLinking/ro': pageLinkingRo, + 'bookView/en': bookViewEn, + 'bookView/ro': bookViewRo, + 'translation/en': translationEn, + 'translation/ro': translationRo, + 'dictionary/en': dictionaryEn, + 'dictionary/ro': dictionaryRo, + 'shell/en': shellEn, + 'shell/ro': shellRo, + 'home/en': homeEn, + 'home/ro': homeRo, + 'demo/en': demoEn, + 'demo/ro': demoRo, + }, + translocoConfig: { + availableLangs: ['en', 'ro'], + defaultLang: 'en', + }, + preloadLangs: true, + ...options, + }); +} diff --git a/wordkeep-client/src/app/utils/date-format.spec.ts b/wordkeep-client/src/app/utils/date-format.spec.ts index dcdbbf8..7994985 100644 --- a/wordkeep-client/src/app/utils/date-format.spec.ts +++ b/wordkeep-client/src/app/utils/date-format.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { formatNumericDate } from './date-format'; +import { formatNumericDate, formatDateTime, formatLocaleDate } from './date-format'; describe('formatNumericDate', () => { it('should_format_as_DD_MM_YYYY', () => { @@ -14,4 +14,47 @@ describe('formatNumericDate', () => { const result = formatNumericDate(new Date(2026, 11, 31).toISOString()); expect(result).toBe('31/12/2026'); }); + + it('should_default_to_en_slash_separator', () => { + expect(formatNumericDate(new Date(2026, 4, 28), 'en')).toBe('28/05/2026'); + }); + + it('should_use_dot_separator_for_ro', () => { + expect(formatNumericDate(new Date(2026, 4, 28), 'ro')).toBe('28.05.2026'); + }); +}); + +describe('formatDateTime', () => { + // Fixed local time so output is timezone-stable across CI. + const dt = new Date(2024, 0, 15, 14, 30); + + it('should_render_english_with_am_pm', () => { + const result = formatDateTime(dt, 'en'); + expect(result).toContain('Jan'); + expect(result).toContain('2024'); + expect(result).toMatch(/[AP]M/); + }); + + it('should_render_romanian_24h_without_am_pm', () => { + const result = formatDateTime(dt, 'ro'); + expect(result).toContain('ian.'); + expect(result).toContain('14:30'); + expect(result).not.toMatch(/[AP]M/); + }); + + it('should_support_long_month', () => { + expect(formatDateTime(dt, 'en', 'long')).toContain('January'); + }); + + it('should_default_unknown_locale_to_english', () => { + expect(formatDateTime(dt, 'fr')).toMatch(/[AP]M/); + }); +}); + +describe('formatLocaleDate', () => { + it('should_format_date_only_per_locale', () => { + const d = new Date(2024, 0, 15); + expect(formatLocaleDate(d, 'en')).toContain('Jan'); + expect(formatLocaleDate(d, 'ro')).toContain('ian.'); + }); }); diff --git a/wordkeep-client/src/app/utils/date-format.ts b/wordkeep-client/src/app/utils/date-format.ts index c8d8d84..8103905 100644 --- a/wordkeep-client/src/app/utils/date-format.ts +++ b/wordkeep-client/src/app/utils/date-format.ts @@ -1,11 +1,44 @@ +export type NumericDateLocale = 'en' | 'ro'; + /** - * Format a date as DD/MM/YYYY with zero-padded day and month. - * Use this for any numeric date display so the format is unambiguous and locale-independent. + * Format a date with a zero-padded day and month, following regional convention: + * `en` → DD/MM/YYYY (slash), `ro` → DD.MM.YYYY (dot). Day/month order is the + * same in both, so only the separator changes. Defaults to `en`. */ -export function formatNumericDate(input: string | Date): string { +export function formatNumericDate(input: string | Date, locale: NumericDateLocale = 'en'): string { const d = input instanceof Date ? input : new Date(input); const day = String(d.getDate()).padStart(2, '0'); const month = String(d.getMonth() + 1).padStart(2, '0'); const year = d.getFullYear(); - return `${day}/${month}/${year}`; + const sep = locale === 'ro' ? '.' : '/'; + return `${day}${sep}${month}${sep}${year}`; +} + +// Locale tags for Intl date/time: en keeps US (AM/PM, month-first) per spec; +// ro uses 24h + RO month/ordering. +const DATETIME_TAGS: Record = { en: 'en-US', ro: 'ro-RO' }; + +/** Locale-aware date/time format. Pass Intl options to control what's shown. */ +export function formatLocaleDate( + input: string | Date, + locale: string = 'en', + options: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'short', day: 'numeric' } +): string { + const tag = DATETIME_TAGS[(locale as NumericDateLocale)] ?? 'en-US'; + return new Date(input).toLocaleString(tag, options); +} + +/** + * Document-card timestamp (date + time). Thin wrapper so the card sites stay + * one-liners and don't duplicate the options object. + * en → "Jan 15, 2024, 10:30 AM"; ro → "15 ian. 2024, 12:30" (24h). + */ +export function formatDateTime( + input: string | Date, + locale: string = 'en', + month: 'short' | 'long' = 'short' +): string { + return formatLocaleDate(input, locale, { + year: 'numeric', month, day: 'numeric', hour: '2-digit', minute: '2-digit' + }); } diff --git a/wordkeep-client/src/app/utils/translation-cost.spec.ts b/wordkeep-client/src/app/utils/translation-cost.spec.ts index baf1f75..becef90 100644 --- a/wordkeep-client/src/app/utils/translation-cost.spec.ts +++ b/wordkeep-client/src/app/utils/translation-cost.spec.ts @@ -2,8 +2,8 @@ import { describe, it, expect } from 'vitest'; import { estimateTranslationCost, TranslationPricing } from './translation-cost'; const pricing: TranslationPricing = { - input_per_million_tokens: 0.075, - output_per_million_tokens: 0.30, + input_per_million_tokens: 0.30, + output_per_million_tokens: 2.50, chars_per_token: 4, }; @@ -17,7 +17,7 @@ describe('estimateTranslationCost', () => { }); it('returns formatted $ string for larger inputs', () => { - // 40000 chars → 10050 tokens (10000 + 50 overhead) → cost = 10050 * (0.075 + 0.30) / 1_000_000 ≈ $0.00377 + // 40000 chars → 10050 tokens (10000 + 50 overhead) → cost = 10050 * (0.30 + 2.50) / 1_000_000 ≈ $0.0281 const result = estimateTranslationCost(40000, pricing); expect(result).toMatch(/^\$/); const num = parseFloat(result.replace('$', '')); diff --git a/wordkeep-client/src/styles.scss b/wordkeep-client/src/styles.scss index 3744534..1fcb390 100644 --- a/wordkeep-client/src/styles.scss +++ b/wordkeep-client/src/styles.scss @@ -183,21 +183,43 @@ select { // titles drop to mono with a leading $/# prompt. [data-layout='console'] { + // Badges become borderless "ANSI" tokens: a leading dot in the badge's own + // colour + a lowercase mono label. The dot inherits `currentColor`, so each + // badge keeps its semantic colour (status, language, type…) with no per-type + // rules. Deliberately unlike scriptorium's uppercase tilted double-border + // cartouche. .doc-status, - .renamed-badge { + .renamed-badge, + .type-badge, + .lang-badge, + .wrong-lang-badge { + display: inline-flex !important; + align-items: center; + gap: 0.4em; background: transparent !important; - border: 1px solid currentColor !important; + border: none !important; + box-shadow: none !important; + transform: none !important; border-radius: 0 !important; - padding: 0.05rem 0.35rem !important; + padding: 0 !important; font-family: var(--font-mono) !important; - font-size: 0.62rem !important; - font-weight: 600 !important; - letter-spacing: 0.08em !important; - text-transform: uppercase; + font-size: 0.8rem !important; + font-weight: 500 !important; + letter-spacing: 0.01em !important; + text-transform: lowercase !important; position: relative; - &::before { content: '['; margin-right: 0.1rem; opacity: 0.5; } - &::after { content: ']'; margin-left: 0.1rem; opacity: 0.5; } + &::before { + content: '' !important; + flex-shrink: 0; + width: 0.5em; + height: 0.5em; + margin: 0 !important; + border-radius: 50%; + background: currentColor; + opacity: 1; + } + &::after { content: none !important; } } // Section H1/H2: command-style header, mono, with leading prompt.