+ :class="isSelected(source.url) ? 'has-background-success-soft' : ''">
';
+ }
+
/**
* Build a compact UI-language switcher for the pre-login pages.
*
@@ -860,8 +876,9 @@ public static function renderPageEnd(): void
/**
* Render a warning banner when the current user's email is not verified.
*
- * Only shows in multi-user mode when the user is authenticated
- * but has not yet verified their email address.
+ * Only shows in multi-user mode when the user is authenticated and has an
+ * email address they have not yet verified. Email is optional at
+ * registration, and an account without one has nothing to verify.
*
* @return void
*/
@@ -879,7 +896,7 @@ public static function renderEmailVerificationBanner(): void
return;
}
- if ($user === null || $user->isEmailVerified()) {
+ if ($user === null || !$user->needsEmailVerification()) {
return;
}
diff --git a/src/frontend/css/base/styles.css b/src/frontend/css/base/styles.css
index 43ed15e4b..826086e67 100644
--- a/src/frontend/css/base/styles.css
+++ b/src/frontend/css/base/styles.css
@@ -3306,3 +3306,50 @@ Searchable Select Component (Alpine.js)
max-height: 190px;
}
}
+
+/* Reader annotations: the #006699 set in text_styles.ts is unreadable on black */
+@media (prefers-color-scheme: dark) {
+ :root:not([data-theme]) .word-ann {
+ color: #7cc4ff;
+ }
+}
+
+:root[data-theme="dark"] .word-ann {
+ color: #7cc4ff;
+}
+
+/* Sign-in pages: one centred card, on every screen size */
+.auth-page main {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ min-height: 100vh;
+ min-height: 100dvh;
+ padding: 0;
+}
+
+.auth-page .section {
+ padding: 1.5rem 1rem;
+}
+
+.auth-page .section > .container {
+ padding: 0;
+}
+
+.auth-page .section > .container > .columns {
+ display: flex;
+ justify-content: center;
+ margin: 0;
+}
+
+.auth-page .section > .container > .columns > .column {
+ flex: 0 1 26rem;
+ max-width: 26rem;
+ padding: 0;
+ width: 100%;
+}
+
+.auth-page .box h1.title {
+ border-bottom: none;
+ padding-bottom: 0;
+}
diff --git a/src/frontend/js/modules/activity/components/calendar_heatmap.ts b/src/frontend/js/modules/activity/components/calendar_heatmap.ts
index c7cd7ed9b..f3b89f1b9 100644
--- a/src/frontend/js/modules/activity/components/calendar_heatmap.ts
+++ b/src/frontend/js/modules/activity/components/calendar_heatmap.ts
@@ -236,7 +236,13 @@ export function calendarHeatmap(): CalendarHeatmapState {
rect.setAttribute('width', String(CELL_SIZE));
rect.setAttribute('height', String(CELL_SIZE));
rect.setAttribute('rx', '2');
- rect.setAttribute('fill', COLORS[colorLevel(day.total, maxVal)]);
+ const level = colorLevel(day.total, maxVal);
+ rect.setAttribute('fill', COLORS[level]);
+ if (level === 0) {
+ // A fill attribute cannot read a CSS variable, and this one follows
+ // the theme, so that a dark page does not show a wall of white.
+ rect.style.fill = 'var(--bulma-border, #ebedf0)';
+ }
const label = formatTooltip(day);
rect.addEventListener('mouseenter', (e: MouseEvent) => {
diff --git a/src/frontend/js/modules/text/pages/reading/text_renderer.ts b/src/frontend/js/modules/text/pages/reading/text_renderer.ts
index 4e0908200..dbd05a068 100644
--- a/src/frontend/js/modules/text/pages/reading/text_renderer.ts
+++ b/src/frontend/js/modules/text/pages/reading/text_renderer.ts
@@ -57,6 +57,9 @@ function shouldShowTransForStatus(status: number, displayStatTrans: number): boo
}
}
+/** Settings of the latest render, for words annotated after the fact. */
+let lastSettings: RenderSettings | null = null;
+
/**
* Build annotation span HTML for a word.
*
@@ -64,7 +67,10 @@ function shouldShowTransForStatus(status: number, displayStatTrans: number): boo
* @param settings Render settings
* @returns Annotation span HTML or empty string
*/
-function buildAnnotationSpan(word: WordData, settings: RenderSettings): string {
+function buildAnnotationSpan(
+ word: Pick,
+ settings: RenderSettings
+): string {
const { showLearning, displayStatTrans } = settings;
// Check if annotations should be shown for this word
@@ -307,6 +313,7 @@ export function renderParseWarning(warning: ParseWarning | null): string {
}
export function renderText(words: WordData[], settings: RenderSettings): string {
+ lastSettings = settings;
if (words.length === 0) return '';
const parts: string[] = [];
@@ -518,6 +525,14 @@ export function updateWordTranslationInDOM(
} else {
annSpan.remove();
}
+ } else if (translation && lastSettings) {
+ // A word that was unknown when the text rendered has no span yet.
+ const status = Number(el.getAttribute('data_status'));
+ const before = (lastSettings.modeTrans ?? 1) >= 3;
+ el.insertAdjacentHTML(
+ before ? 'afterbegin' : 'beforeend',
+ buildAnnotationSpan({ translation, status }, lastSettings)
+ );
}
});
}
diff --git a/src/frontend/js/modules/text/pages/text_suggestions.ts b/src/frontend/js/modules/text/pages/text_suggestions.ts
index 03f632135..d6962bab9 100644
--- a/src/frontend/js/modules/text/pages/text_suggestions.ts
+++ b/src/frontend/js/modules/text/pages/text_suggestions.ts
@@ -99,9 +99,28 @@ export function gutenbergBrowserData(): GutenbergBrowserData {
importing: null,
init() {
- const langId = getSelectedLanguageId();
- if (langId > 0) {
- this.fetchBooks(1);
+ let shown = false;
+ const start = () => {
+ shown = true;
+ if (getSelectedLanguageId() > 0) {
+ this.fetchBooks(1);
+ }
+ };
+
+ // The browser sits in the Add Text form from the start, hidden until
+ // the user picks Gutenberg, and each book it lists costs a request for
+ // its vocabulary stats. Hold off until it is actually on screen.
+ const root = document.querySelector('[x-data="gutenbergBrowser"]');
+ if (!root || typeof IntersectionObserver === 'undefined') {
+ start();
+ } else {
+ const observer = new IntersectionObserver((entries) => {
+ if (entries.some((entry) => entry.isIntersecting)) {
+ observer.disconnect();
+ start();
+ }
+ });
+ observer.observe(root);
}
// Re-fetch when language selector changes (delegated on document)
@@ -109,7 +128,7 @@ export function gutenbergBrowserData(): GutenbergBrowserData {
this.books = [];
this.page = 1;
this.error = '';
- if (newLangId > 0) {
+ if (shown && newLangId > 0) {
this.fetchBooks(1);
}
});
diff --git a/src/frontend/js/modules/user/pages/profile_app.ts b/src/frontend/js/modules/user/pages/profile_app.ts
index e83bb052e..63133f327 100644
--- a/src/frontend/js/modules/user/pages/profile_app.ts
+++ b/src/frontend/js/modules/user/pages/profile_app.ts
@@ -18,6 +18,7 @@ export interface ProfileAppData {
username: string;
email: string;
emailVerified: boolean;
+ showsUnverifiedNotice(): boolean;
currentPassword: string;
newPassword: string;
confirmPassword: string;
@@ -76,6 +77,10 @@ export function profileAppData(): ProfileAppData {
this.emailVerified = response.data.profile.emailVerified;
},
+ showsUnverifiedNotice(): boolean {
+ return this.email !== '' && !this.emailVerified;
+ },
+
async saveProfile(): Promise {
this.isSavingProfile = true;
this.profileError = '';
diff --git a/tests/backend/Core/Entity/UserTest.php b/tests/backend/Core/Entity/UserTest.php
index 7b74e57df..ad1944ea9 100644
--- a/tests/backend/Core/Entity/UserTest.php
+++ b/tests/backend/Core/Entity/UserTest.php
@@ -87,6 +87,23 @@ public function testCreateUser(): void
$this->assertInstanceOf(DateTimeImmutable::class, $user->created());
}
+ public function testAnAccountWithoutEmailHasNothingToVerify(): void
+ {
+ $user = User::create('testuser', null, 'hashedpassword');
+
+ $this->assertFalse($user->needsEmailVerification());
+ }
+
+ public function testAnEmailNeedsVerifyingUntilItIsVerified(): void
+ {
+ $user = User::create('testuser', 'test@example.com', 'hashedpassword');
+ $this->assertTrue($user->needsEmailVerification());
+
+ $user->markEmailVerified();
+
+ $this->assertFalse($user->needsEmailVerification());
+ }
+
public function testCreateUserTrimsInputs(): void
{
$user = User::create(' testuser ', ' test@example.com ', 'hashedpassword');
diff --git a/tests/backend/Shared/Infrastructure/Routing/Middleware/RateLimitMiddlewareTest.php b/tests/backend/Shared/Infrastructure/Routing/Middleware/RateLimitMiddlewareTest.php
index 89d0eb81c..f7e21ff04 100644
--- a/tests/backend/Shared/Infrastructure/Routing/Middleware/RateLimitMiddlewareTest.php
+++ b/tests/backend/Shared/Infrastructure/Routing/Middleware/RateLimitMiddlewareTest.php
@@ -176,6 +176,20 @@ public function testWhisperTranscribeGetsItsOwnBucket(): void
$this->assertNotNull($this->storage->get('rate_limit:api:192.168.1.100'));
}
+ public function testLibraryPreviewsCannotUseUpTheGeneralQuota(): void
+ {
+ $middleware = new RateLimitMiddleware($this->storage, 1, 60);
+
+ $_SERVER['REQUEST_URI'] = '/api/v1/texts/library-preview?url=x';
+ $this->assertTrue($middleware->handle());
+
+ $this->assertNotNull($this->storage->get('rate_limit:preview:192.168.1.100'));
+ $this->assertNull($this->storage->get('rate_limit:api:192.168.1.100'));
+
+ $_SERVER['REQUEST_URI'] = '/api/v1/texts';
+ $this->assertTrue($middleware->handle());
+ }
+
public function testForwardedIpHeader(): void
{
$middleware = new RateLimitMiddleware($this->storage, 10, 60);
diff --git a/tests/frontend/reading/text_renderer.test.ts b/tests/frontend/reading/text_renderer.test.ts
index 9e4707d06..72c8f706d 100644
--- a/tests/frontend/reading/text_renderer.test.ts
+++ b/tests/frontend/reading/text_renderer.test.ts
@@ -349,6 +349,31 @@ describe('reading/text_renderer.ts', () => {
expect(element.getAttribute('data_trans')).toBe('translation');
});
+ it('adds the annotation to a word that had none when the text rendered', () => {
+ renderText([], {
+ showAll: false, showTranslations: true, rightToLeft: false, textSize: 100,
+ showLearning: true, displayStatTrans: 1, modeTrans: 1
+ } as RenderSettings);
+ document.body.innerHTML = 'word ';
+
+ updateWordTranslationInDOM('123', 'basket', '');
+
+ expect(document.querySelector('[data_hex="123"]')?.innerHTML)
+ .toBe('wordbasket ');
+ });
+
+ it('leaves a word unannotated when its status is not annotated', () => {
+ renderText([], {
+ showAll: false, showTranslations: true, rightToLeft: false, textSize: 100,
+ showLearning: true, displayStatTrans: 1, modeTrans: 1
+ } as RenderSettings);
+ document.body.innerHTML = 'word ';
+
+ updateWordTranslationInDOM('123', 'basket', '');
+
+ expect(document.querySelector('.word-ann')).toBeNull();
+ });
+
it('sets data_rom attribute', () => {
document.body.innerHTML = 'word ';
const element = document.querySelector('[data_hex="123"]') as HTMLElement;