diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/app.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/app.component.html index 9a3062617d..a224a87edb 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/app.component.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/app.component.html @@ -197,6 +197,7 @@
+ @if (showCheckingDisabled) {

{{ t("scripture_checking_not_available") }}

diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/app.module.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/app.module.ts index 1fbdf4f17a..6cc5897130 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/app.module.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/app.module.ts @@ -33,6 +33,7 @@ import { ProjectComponent } from './project/project.component'; import { ScriptureChooserDialogComponent } from './scripture-chooser-dialog/scripture-chooser-dialog.component'; import { DeleteProjectDialogComponent } from './settings/delete-project-dialog/delete-project-dialog.component'; import { SettingsComponent } from './settings/settings.component'; +import { GlobalNoticesComponent } from './shared/global-notices/global-notices.component'; import { SharedModule } from './shared/shared.module'; import { TextNoteDialogComponent } from './shared/text/text-note-dialog/text-note-dialog.component'; import { SyncComponent } from './sync/sync.component'; @@ -75,7 +76,8 @@ import { UsersModule } from './users/users.module'; AppRoutingModule, SharedModule, AvatarComponent, - MatRipple + MatRipple, + GlobalNoticesComponent ], providers: [ CookieService, diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/global-notices/global-notices.component.html b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/global-notices/global-notices.component.html new file mode 100644 index 0000000000..82d34333d5 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/global-notices/global-notices.component.html @@ -0,0 +1,20 @@ + + @if (showDowntimeNotice) { + +
+
+ {{ + t("upcoming_maintenance", { + duration, + startTime: i18n.formatDate(upcomingDowntime.start, { showTimeZone: true }) + }) + }} + {{ t("learn_more") }} +
+ +
+
+ } +
diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/global-notices/global-notices.component.scss b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/global-notices/global-notices.component.scss new file mode 100644 index 0000000000..0c76028533 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/global-notices/global-notices.component.scss @@ -0,0 +1,12 @@ +.downtime-notice { + margin: 0 auto 12px; + padding-block: 6px; + width: 65em; + max-width: 100%; +} + +.downtime-notice-content-wrapper { + display: flex; + align-items: center; + justify-content: space-between; +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/global-notices/global-notices.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/global-notices/global-notices.component.ts new file mode 100644 index 0000000000..8aefd8a25a --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/global-notices/global-notices.component.ts @@ -0,0 +1,44 @@ +import { CommonModule } from '@angular/common'; +import { Component, Input, OnInit } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { TranslocoModule } from '@ngneat/transloco'; +import { I18nService } from 'xforge-common/i18n.service'; +import { NoticeComponent } from '../notice/notice.component'; + +@Component({ + selector: 'app-global-notices', + standalone: true, + imports: [CommonModule, NoticeComponent, MatIconModule, MatButtonModule, MatTooltipModule, TranslocoModule], + templateUrl: './global-notices.component.html', + styleUrl: './global-notices.component.scss' +}) +export class GlobalNoticesComponent implements OnInit { + // This is only an input so that the Storybook can turn this on even when it's off in the app + @Input() showDowntimeNotice = false; + + upcomingDowntime = { + start: new Date('2024-12-11 16:30 UTC'), + durationMin: 2, + durationMax: 3, + durationUnit: 'hour', + detailsUrl: 'https://software.sil.org/scriptureforge/news/' + } as const; + + constructor(readonly i18n: I18nService) {} + + get duration(): string { + return this.i18n.translateStatic(`global_notices.${this.upcomingDowntime.durationUnit}_range`, { + min: this.upcomingDowntime.durationMin, + max: this.upcomingDowntime.durationMax + }); + } + + ngOnInit(): void { + const fifteenMinutesMs = 15 * 60 * 1000; + const fifteenMinutesPastStart = new Date(this.upcomingDowntime.start.getTime() + fifteenMinutesMs); + // Show the downtime notice up until 15 minutes past the start time. + this.showDowntimeNotice = new Date() <= fifteenMinutesPastStart; + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/shared/global-notices/global-notices.stories.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/global-notices/global-notices.stories.ts new file mode 100644 index 0000000000..da8dc93fbe --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/shared/global-notices/global-notices.stories.ts @@ -0,0 +1,14 @@ +import { Meta, StoryObj } from '@storybook/angular'; +import { GlobalNoticesComponent } from './global-notices.component'; + +const meta: Meta = { + title: 'Shared/Global Notices', + component: GlobalNoticesComponent +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { showDowntimeNotice: true } +}; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/assets/i18n/checking_en.json b/src/SIL.XForge.Scripture/ClientApp/src/assets/i18n/checking_en.json index 3f42391573..36543a08f7 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/assets/i18n/checking_en.json +++ b/src/SIL.XForge.Scripture/ClientApp/src/assets/i18n/checking_en.json @@ -343,6 +343,13 @@ "font_size": { "text_size": "Text size" }, + "global_notices": { + "close": "Close", + "hour_range": "{{ min }} to {{ max }} hours", + "learn_more": "Learn more", + "minute_range": "{{ min }} to {{ max }} minutes", + "upcoming_maintenance": "Scripture Forge will be down for maintenance for about {{ duration }} starting at {{ startTime }}." + }, "issue_email": { "heading": "Please explain the problem.", "technical_details": "Technical details", diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/i18n.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/i18n.service.spec.ts index 5d26862555..1540c065ad 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/i18n.service.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/i18n.service.spec.ts @@ -121,6 +121,20 @@ describe('I18nService', () => { expect(service.formatDate(date)).toEqual('25.11.1991 17:28'); }); + it('should support including the timezone in the date', () => { + const date = new Date('November 25, 1991 17:28'); + const service = getI18nService(); + + // look for ending with something like " UTC+5" or " EST" + const trailingTimezoneRegex = / (UTC[\-+−]\d+|[A-Z]+)$/; + + service.setLocale('fr'); + expect(service.formatDate(date, { showTimeZone: true })).toMatch(trailingTimezoneRegex); + + service.setLocale('az'); + expect(service.formatDate(date, { showTimeZone: true })).toMatch(trailingTimezoneRegex); + }); + it('should interpolate translations around and within numbered template tags', done => { when(mockedTranslocoService.selectTranslate('app.settings', anything())).thenReturn( of('A quick brown { 1 }fox{ 2 } jumps over the lazy { 3 }dog{ 4 }.') diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/i18n.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/i18n.service.ts index a21fd1f10f..b4f05f05c3 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/i18n.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/i18n.service.ts @@ -18,7 +18,7 @@ import { Locale, LocaleDirection } from './models/i18n-locale'; import { PseudoLocalization } from './pseudo-localization'; import { ASP_CULTURE_COOKIE_NAME, aspCultureCookieValue, getAspCultureCookieLanguage, getI18nLocales } from './utils'; -export type DateFormat = Intl.DateTimeFormatOptions | ((date: Date) => string); +export type DateFormat = Intl.DateTimeFormatOptions | ((date: Date, options: { showTimeZone?: boolean }) => string); export interface TextAroundTemplate { before: string; @@ -74,8 +74,13 @@ export class I18nService { en: { month: 'short' }, 'en-GB': { month: 'short', hour12: true }, // Chrome formats az dates as en-US. This manual override is the format Firefox uses for az - az: (d: Date) => - `${pad(d.getDate())}.${pad(d.getMonth() + 1)}.${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`, + az: (d: Date, options) => { + let s = `${pad(d.getDate())}.${pad(d.getMonth() + 1)}.${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`; + if (options.showTimeZone) { + s += ` ${I18nService.getHumanReadableTimeZoneOffset('az', d)}`; + } + return s; + }, [PseudoLocalization.locale.canonicalTag]: PseudoLocalization.dateFormat }; @@ -234,6 +239,13 @@ export class I18nService { return this.transloco.selectTranslate(key, params); } + /** Returns a translation for the given I18nKey. A string is returned, so this cannot be used to keep a localization + * updated without re-calling this whenever the locale changes. Avoid using this when observing a localization is + * possible. */ + translateStatic(key: I18nKey, params: object = {}): string { + return this.transloco.translate(key, params); + } + translateAndInsertTags(key: I18nKey, params: object = {}): string { return this.transloco.translate(key, { ...params, @@ -249,15 +261,23 @@ export class I18nService { }); } - formatDate(date: Date): string { + formatDate(date: Date, options: { showTimeZone?: boolean } = {}): string { // fall back to en in the event the language code isn't valid const format = I18nService.dateFormats[this.localeCode] || {}; return typeof format === 'function' - ? format(date) + ? format(date, options) : date.toLocaleString( [this.localeCode, I18nService.defaultLocale.canonicalTag], // Browser default is all numeric, but includes seconds. This is same as default, but without seconds - { month: 'numeric', year: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', ...format } + { + month: 'numeric', + year: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + ...(options?.showTimeZone ? { timeZoneName: 'short' } : {}), + ...format + } ); } @@ -369,6 +389,12 @@ export class I18nService { } } + static getHumanReadableTimeZoneOffset(localeCode: string, date: Date): string { + return new Intl.DateTimeFormat(localeCode, { timeZoneName: 'short' }) + .formatToParts(date) + .find(e => e.type === 'timeZoneName').value; + } + private getTranslation(key: I18nKey): string { return ( this.transloco.getTranslation(this.transloco.getActiveLang())[key] ??