diff --git a/api/src/Sozsoft.Platform.DbMigrator/Migrations/LanguagesData.json b/api/src/Sozsoft.Platform.DbMigrator/Migrations/LanguagesData.json index d6ff7c96..e9f09aee 100644 --- a/api/src/Sozsoft.Platform.DbMigrator/Migrations/LanguagesData.json +++ b/api/src/Sozsoft.Platform.DbMigrator/Migrations/LanguagesData.json @@ -20418,156 +20418,6 @@ "en": "An error occurred while generating the table script.", "tr": "Tablo scripti oluşturulurken hata oluştu." }, - { - "resourceName": "Platform", - "key": "App.Update.AssetProgress", - "en": "{done}/{total} assets", - "tr": "{done}/{total} bileşen" - }, - { - "resourceName": "Platform", - "key": "App.Update.ChangeLogTitle", - "en": "What's new in v{version}", - "tr": "v{version} ile gelen yenilikler" - }, - { - "resourceName": "Platform", - "key": "App.Update.Countdown", - "en": "({seconds} s)", - "tr": "({seconds} sn)" - }, - { - "resourceName": "Platform", - "key": "App.Update.DurationMinutes", - "en": "{value} min", - "tr": "{value} dk" - }, - { - "resourceName": "Platform", - "key": "App.Update.DurationSeconds", - "en": "{value} s", - "tr": "{value} sn" - }, - { - "resourceName": "Platform", - "key": "App.UpdateDescription.Activating", - "en": "The page will refresh automatically.", - "tr": "Sayfa otomatik olarak yenilenecek." - }, - { - "resourceName": "Platform", - "key": "App.UpdateDescription.Checking", - "en": "Reading the version information from the server...", - "tr": "Sunucudaki sürüm bilgisi okunuyor..." - }, - { - "resourceName": "Platform", - "key": "App.UpdateDescription.Completed", - "en": "All assets have been downloaded, the application is ready to use.", - "tr": "Tüm bileşenler indirildi, uygulama kullanıma hazır." - }, - { - "resourceName": "Platform", - "key": "App.UpdateDescription.Error", - "en": "The switch could not be completed.", - "tr": "Geçiş tamamlanamadı." - }, - { - "resourceName": "Platform", - "key": "App.UpdateDescription.Ready", - "en": "Download complete, switching to the new version.", - "tr": "İndirme tamamlandı, yeni sürüme geçiliyor." - }, - { - "resourceName": "Platform", - "key": "App.Update.DownloadedAssets", - "en": "Downloaded assets", - "tr": "İndirilen bileşenler" - }, - { - "resourceName": "Platform", - "key": "App.UpdateMessage.ActivationSlow", - "en": "The switch took longer than expected, retrying.", - "tr": "Geçiş beklenenden uzun sürdü, yeniden deneniyor." - }, - { - "resourceName": "Platform", - "key": "App.UpdateMessage.DownloadFailed", - "en": "The new version could not be downloaded, retrying.", - "tr": "Yeni sürüm indirilemedi, yeniden deneniyor." - }, - { - "resourceName": "Platform", - "key": "App.UpdateMessage.InstallFailed", - "en": "The new version could not be installed, retrying.", - "tr": "Yeni sürüm kurulamadı, yeniden deneniyor." - }, - { - "resourceName": "Platform", - "key": "App.UpdateMessage.PartialDownload", - "en": "The assets could not be fully downloaded, they will be loaded as needed.", - "tr": "Bileşenler tamamen indirilemedi, gerektikçe yüklenecek." - }, - { - "resourceName": "Platform", - "key": "App.UpdateMessage.WaitingConnection", - "en": "Waiting for a connection. The update will continue when the internet is back.", - "tr": "Bağlantı bekleniyor. İnternet geldiğinde güncelleme sürdürülecek." - }, - { - "resourceName": "Platform", - "key": "App.Update.Remaining", - "en": "~{duration} remaining", - "tr": "kalan ~{duration}" - }, - { - "resourceName": "Platform", - "key": "App.Update.RetryNow", - "en": "Retry now", - "tr": "Şimdi tekrar dene" - }, - { - "resourceName": "Platform", - "key": "App.Update.ReusedFromCache", - "en": "{count} assets from cache", - "tr": "{count} bileşen önbellekten" - }, - { - "resourceName": "Platform", - "key": "App.UpdateTitle.Activating", - "en": "The application is restarting", - "tr": "Uygulama yeniden başlatılıyor" - }, - { - "resourceName": "Platform", - "key": "App.UpdateTitle.Checking", - "en": "Checking for updates", - "tr": "Güncelleme kontrol ediliyor" - }, - { - "resourceName": "Platform", - "key": "App.UpdateTitle.Completed", - "en": "Update completed", - "tr": "Güncelleme tamamlandı" - }, - { - "resourceName": "Platform", - "key": "App.UpdateTitle.Error", - "en": "Update could not be completed", - "tr": "Güncelleme tamamlanamadı" - }, - { - "resourceName": "Platform", - "key": "App.UpdateTitle.Ready", - "en": "Preparing the new version", - "tr": "Yeni sürüm hazırlanıyor" - }, - { - "resourceName": "Platform", - "key": "App.Update.Version", - "en": "Version", - "tr": "Sürüm" - }, { "resourceName": "Platform", "key": "App.Platform.Uploading", @@ -25482,48 +25332,6 @@ "en": "Light mode", "tr": "Açık tema" }, - { - "resourceName": "Platform", - "key": "App.Update.ContinueInBackground", - "en": "Continue in the background", - "tr": "Arka planda devam et" - }, - { - "resourceName": "Platform", - "key": "App.UpdateDescription.DownloadingBlocking", - "en": "Assets are being downloaded, do not close the tab. The new version is applied automatically when the download finishes.", - "tr": "Bileşenler indiriliyor, sekmeyi kapatmayın. İndirme bitince yeni sürüme otomatik geçilecek." - }, - { - "resourceName": "Platform", - "key": "App.UpdateDescription.DownloadingDismissible", - "en": "All assets are being downloaded; screens will open instantly once it finishes. You can close this window and continue.", - "tr": "Tüm bileşenler indiriliyor; bittiğinde ekranlar anında açılacak. Bu pencereyi kapatıp devam edebilirsiniz." - }, - { - "resourceName": "Platform", - "key": "App.UpdateFooter.Blocking", - "en": "This window stays open until the update is completed.", - "tr": "Güncelleme tamamlanana kadar bu pencere kapanmaz." - }, - { - "resourceName": "Platform", - "key": "App.UpdateFooter.Dismissible", - "en": "The download continues in the background even if you close this window.", - "tr": "Pencereyi kapatsanız da indirme arka planda sürer." - }, - { - "resourceName": "Platform", - "key": "App.UpdateTitle.DownloadingAssets", - "en": "Downloading assets", - "tr": "Bileşenler indiriliyor" - }, - { - "resourceName": "Platform", - "key": "App.UpdateTitle.DownloadingVersion", - "en": "Downloading the new version", - "tr": "Yeni sürüm indiriliyor" - }, { "resourceName": "Platform", "key": "App.VideoRoom.ExitFullscreen", diff --git a/ui/src/views/version/AppUpdateDialog.tsx b/ui/src/views/version/AppUpdateDialog.tsx index 3236f31e..bfbf8bf2 100644 --- a/ui/src/views/version/AppUpdateDialog.tsx +++ b/ui/src/views/version/AppUpdateDialog.tsx @@ -1,4 +1,4 @@ -import { memo, useSyncExternalStore } from 'react' +import { memo, useState, useSyncExternalStore } from 'react' import { applyAppUpdate, dismissAppUpdateDialog, @@ -6,8 +6,6 @@ import { subscribeAppUpdate, type AppUpdateState, } from './swRegistration' -import { getLocalization } from '@/services/localization.service' -import { store } from '@/store' /** * Sürüm indirme penceresi. @@ -22,25 +20,57 @@ import { store } from '@/store' */ /** - * Metin çözümü. + * Metinler bilerek statik (İngilizce) tutulur, localization kullanılmaz. * * Pencere uygulama ağacının DIŞINDA, kendi React kökünde mount edilir - * (`mountAppUpdateDialog`); orada `StoreProvider` yoktur. Bu yüzden burada - * `useLocalization` (dolayısıyla `useStoreState`) KULLANILAMAZ — hook Provider'ı - * bulamayıp hata fırlatıyor, kök çöküyor ve indirme ilerlemesi hiç görünmüyordu. - * - * Metinler store'dan doğrudan (`getState`) okunur; bileşende statik metin - * tutulmaz. Karşılık yoksa `useLocalization` ile aynı davranış: anahtar adı - * döner. + * (`mountAppUpdateDialog`) ve service worker kontrolü uygulama açılışında + * başlar. Localization metinleri ise `application-localization` isteğiyle sonra + * geldiği için pencere ilk boyamada anahtarları çevrilmemiş gösteriyor, metinler + * ancak birkaç saniye sonra yerine oturuyordu. Statik metinle ilk kare doğru. */ -const t = (key: string, params?: Record) => { - try { - const { texts, config } = store.getState().abpConfig - if (!texts) return key - return getLocalization(texts, config?.localization?.defaultResourceName, key, params) - } catch { - return key - } +const TEXT = { + title: { + downloadingAssets: 'Downloading assets', + downloadingVersion: 'Downloading the new version', + completed: 'Update completed', + ready: 'Preparing the new version', + activating: 'The application is restarting', + error: 'Update could not be completed', + checking: 'Checking for updates', + }, + description: { + downloadingDismissible: + 'All assets are being downloaded; screens will open instantly once it finishes. You can close this window and continue.', + downloadingBlocking: + 'Assets are being downloaded, do not close the tab. The new version is applied automatically when the download finishes.', + completed: 'All assets have been downloaded, the application is ready to use.', + ready: 'Download complete, switching to the new version.', + activating: 'The page will refresh automatically.', + error: 'The switch could not be completed.', + checking: 'Reading the version information from the server...', + }, + footer: { + dismissible: 'The download continues in the background even if you close this window.', + blocking: 'This window stays open until the update is completed.', + }, + version: 'Version', + showAssets: 'Show assets', + hideAssets: 'Hide assets', + whatsNew: "What's new", + continueInBackground: 'Continue in the background', + ok: 'OK', + retryNow: 'Retry now', +} as const + +/** `swRegistration` yalnızca anahtar taşır; karşılıkları burada çözülür. */ +const UPDATE_MESSAGE: Record = { + 'App.UpdateMessage.ActivationSlow': 'The switch took longer than expected, retrying.', + 'App.UpdateMessage.DownloadFailed': 'The new version could not be downloaded, retrying.', + 'App.UpdateMessage.InstallFailed': 'The new version could not be installed, retrying.', + 'App.UpdateMessage.PartialDownload': + 'The assets could not be fully downloaded, they will be loaded as needed.', + 'App.UpdateMessage.WaitingConnection': + 'Waiting for a connection. The update will continue when the internet is back.', } const formatBytes = (bytes: number) => { @@ -49,58 +79,48 @@ const formatBytes = (bytes: number) => { return `${(bytes / (1024 * 1024)).toFixed(1)} MB` } -const formatDuration = (seconds: number) => { - if (seconds < 60) { - return t('::App.Update.DurationSeconds', { value: Math.max(1, seconds) }) - } - return t('::App.Update.DurationMinutes', { value: Math.round(seconds / 60) }) -} +const formatDuration = (seconds: number) => + seconds < 60 ? `${Math.max(1, seconds)} s` : `${Math.round(seconds / 60)} min` const titleFor = (state: AppUpdateState) => { switch (state.status) { case 'downloading': - return t( - state.dismissible - ? '::App.UpdateTitle.DownloadingAssets' - : '::App.UpdateTitle.DownloadingVersion', - ) + return state.dismissible ? TEXT.title.downloadingAssets : TEXT.title.downloadingVersion case 'completed': - return t('::App.UpdateTitle.Completed') + return TEXT.title.completed case 'ready': - return t('::App.UpdateTitle.Ready') + return TEXT.title.ready case 'activating': - return t('::App.UpdateTitle.Activating') + return TEXT.title.activating case 'error': - return t('::App.UpdateTitle.Error') + return TEXT.title.error default: - return t('::App.UpdateTitle.Checking') + return TEXT.title.checking } } const countdownText = (state: AppUpdateState) => - state.secondsLeft && state.secondsLeft > 0 - ? ` ${t('::App.Update.Countdown', { seconds: state.secondsLeft })}` - : '' + state.secondsLeft && state.secondsLeft > 0 ? ` (${state.secondsLeft} s)` : '' const descriptionFor = (state: AppUpdateState) => { switch (state.status) { case 'downloading': - return t( - state.dismissible - ? '::App.UpdateDescription.DownloadingDismissible' - : '::App.UpdateDescription.DownloadingBlocking', - ) + return state.dismissible + ? TEXT.description.downloadingDismissible + : TEXT.description.downloadingBlocking case 'completed': - return t('::App.UpdateDescription.Completed') + return TEXT.description.completed case 'ready': - return t('::App.UpdateDescription.Ready') + return TEXT.description.ready case 'activating': - return t('::App.UpdateDescription.Activating') - case 'error': - // `swRegistration` yalnızca anahtar taşır (ör. App.UpdateMessage.*). - return `${t(state.message ? `::${state.message}` : '::App.UpdateDescription.Error')}${countdownText(state)}` + return TEXT.description.activating + case 'error': { + const message = + (state.message && UPDATE_MESSAGE[state.message]) || TEXT.description.error + return `${message}${countdownText(state)}` + } default: - return t('::App.UpdateDescription.Checking') + return TEXT.description.checking } } @@ -156,14 +176,31 @@ const byActivity = (a: AppUpdateState['files'][number], b: AppUpdateState['files (b.seq ?? 0) - (a.seq ?? 0) || a.index - b.index +/** Bağımlılık eklememek için ikon satır içi; buton dışında kullanılmaz. */ +const Chevron = ({ open }: { open: boolean }) => ( + +) + const AppUpdateDialog = () => { const state = useSyncExternalStore(subscribeAppUpdate, getAppUpdateState, getAppUpdateState) + // Bileşen listesi varsayılan olarak kapalı: ana ekran sürüm + ilerleme + + // changelog'a ayrılır, ~500 satırlık liste yalnızca istendiğinde çizilir. + const [assetsOpen, setAssetsOpen] = useState(false) if (!state.visible) return null const busy = state.status === 'downloading' || state.status === 'ready' + const hasFiles = state.files.length > 0 const doneCount = state.files.filter((file) => file.status === 'done').length - const ordered = state.files.slice().sort(byActivity) + // Sıralama yalnızca liste açıkken yapılır. + const ordered = assetsOpen ? state.files.slice().sort(byActivity) : [] return (
{
-
- - {t('::App.Update.Version')}: v{state.currentVersion} - -
+ {!!state.currentVersion && ( +
+ + {TEXT.version}: v{state.currentVersion} + +
+ )} - {(busy || state.files.length > 0) && ( + {(busy || hasFiles) && (
- {t('::App.Update.AssetProgress', { - done: doneCount, - total: state.files.length, - })} - {state.reusedCount > 0 && - ` · ${t('::App.Update.ReusedFromCache', { count: state.reusedCount })}`} - {state.status === 'downloading' && state.secondsRemaining !== undefined && ( - <> - {' '} - ·{' '} - {t('::App.Update.Remaining', { - duration: formatDuration(state.secondsRemaining), - })} - - )} + {`${doneCount}/${state.files.length} assets`} + {state.reusedCount > 0 && ` · ${state.reusedCount} assets from cache`} + {state.secondsRemaining !== undefined && + ` · ~${formatDuration(state.secondsRemaining)} remaining`} {formatBytes(state.loadedBytes)} @@ -222,24 +250,33 @@ const AppUpdateDialog = () => {
)} + {hasFiles && ( +
+ +
+ )} +
- {state.files.length > 0 && ( - <> -

- {t('::App.Update.DownloadedAssets')} -

-
    - {ordered.map((file) => ( - - ))} -
- + {assetsOpen && hasFiles && ( +
    + {ordered.map((file) => ( + + ))} +
)} {state.changeLog.length > 0 && ( -
+

- {t('::App.Update.ChangeLogTitle', { version: state.currentVersion })} + {state.currentVersion ? `${TEXT.whatsNew} in v${state.currentVersion}` : TEXT.whatsNew}

    {state.changeLog.map((line, index) => ( @@ -255,9 +292,7 @@ const AppUpdateDialog = () => {
    - {t( - state.dismissible ? '::App.UpdateFooter.Dismissible' : '::App.UpdateFooter.Blocking', - )} + {state.dismissible ? TEXT.footer.dismissible : TEXT.footer.blocking} {state.dismissible ? ( ) : ( state.status === 'error' && ( @@ -278,7 +309,7 @@ const AppUpdateDialog = () => { className="shrink-0 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700" onClick={applyAppUpdate} > - {t('::App.Update.RetryNow')} + {TEXT.retryNow} {countdownText(state)} ) diff --git a/ui/src/views/version/swRegistration.ts b/ui/src/views/version/swRegistration.ts index 24e9698a..84f1f832 100644 --- a/ui/src/views/version/swRegistration.ts +++ b/ui/src/views/version/swRegistration.ts @@ -78,6 +78,11 @@ export type AppUpdateState = { percent: number /** Değişmediği için yeniden indirilmeyen dosya sayısı. */ reusedCount: number + /** + * Durum mesajının kimliği (ör. `App.UpdateMessage.DownloadFailed`). + * Localization anahtarı DEĞİLDİR; karşılığı `AppUpdateDialog` içindeki + * `UPDATE_MESSAGE` tablosundan çözülür. + */ message?: string /** Otomatik geçiş/yeniden deneme için kalan saniye. */ secondsLeft?: number @@ -187,6 +192,25 @@ function setState(patch: Partial) { listeners.forEach((listener) => listener(state)) } +/** + * Kullanıcı pencereyi kapattı mı? ("arka planda devam et") + * + * İlerleme mesajları saniyede birkaç kez gelir ve her biri durumu tazeler; + * bayrak olmadan `visible: true` yazılıp kapatma bir sonraki tick'te geri + * alınıyor, pencere kendiliğinden açılıyordu. Bayrak yalnızca çalışan sürümü + * değiştirecek bir geçiş başladığında `forceDialog` ile sıfırlanır — o akışta + * pencere zaten kapatılamaz (`dismissible: false`). + */ +let dismissedByUser = false + +/** Geçiş gerektiren akışlarda kapatma tercihi geçersizdir. */ +const forceDialog = () => { + dismissedByUser = false +} + +/** İlerleme güncellemelerinde kullanılacak görünürlük. */ +const dialogVisible = () => !dismissedByUser + /* Kalan süre tahmini: oturum başından beri ölçülen ortalama hız. */ let rateStartedAt = 0 let rateStartBytes = 0 @@ -437,6 +461,8 @@ function postSkipWaiting() { } function activateWaitingWorker() { + // Yeni sürüme geçiliyor: kapatma tercihi burada geçersiz. + forceDialog() if (activating) { setState({ visible: true }) return @@ -575,12 +601,14 @@ function handleWorkerMessage(event: MessageEvent) { case 'SW_PRECACHE_START': // İndirme başladı: bekleyen geri sayım varsa iptal edilir. clearAutoAction() + // Geçişli akış kapatılamaz; önceki kapatma tercihi burada geçersizleşir. + if (transitions) forceDialog() void showUpdateDialog('downloading') resetRate() setState({ percent: 0 }) recalculate(toFiles(data.files), totals, { status: 'downloading', - visible: true, + visible: dialogVisible(), reusedCount: data.reusedCount ?? 0, secondsLeft: undefined, dismissible: !transitions, @@ -591,7 +619,7 @@ function handleWorkerMessage(event: MessageEvent) { if (!dialogMountRequested) void showUpdateDialog('downloading') recalculate(mergeFiles(toFiles(data.updates)), totals, { status: 'downloading', - visible: true, + visible: dialogVisible(), dismissible: !transitions, }) break @@ -599,7 +627,7 @@ function handleWorkerMessage(event: MessageEvent) { recalculate(keepSeq(toFiles(data.files)), totals, { // Yeni sürüme geçilecekse 'ready'; ısıtmada iş bitti. status: transitions ? 'ready' : 'completed', - visible: true, + visible: transitions || dialogVisible(), percent: 100, }) if (transitions) scheduleActivationHandoff() @@ -610,13 +638,14 @@ function handleWorkerMessage(event: MessageEvent) { // Çalışan sürüm etkilenmedi; kullanıcıyı yeniden yükleme döngüsüne sokma. setState({ status: 'error', - visible: true, + visible: dialogVisible(), dismissible: true, message: 'App.UpdateMessage.PartialDownload', }) hideDialogLater() break } + forceDialog() setState({ status: 'error', visible: true, @@ -630,7 +659,10 @@ function handleWorkerMessage(event: MessageEvent) { } /** Kapatılabilir bildirimlerde pencereyi gizler (indirme arka planda sürer). */ -export const dismissAppUpdateDialog = () => setState({ visible: false }) +export const dismissAppUpdateDialog = () => { + dismissedByUser = true + setState({ visible: false }) +} /* ------------------------------------------------------------------ */ /* Güncelleme penceresi (lazy) */ @@ -643,7 +675,7 @@ let dialogMountRequested = false * olduğunda indirilir ve body'ye kendi kökünde mount edilir. */ async function showUpdateDialog(status: AppUpdateStatus) { - setState({ status, visible: true }) + setState({ status, visible: dialogVisible() }) if (dialogMountRequested) return dialogMountRequested = true @@ -752,6 +784,8 @@ function trackInstallingWorker(reg: ServiceWorkerRegistration) { if (!isUpdate) return watchedWorkers.add(worker) + // Güncelleme kurulumu: geçiş kaçınılmaz, kapatma tercihi geçersiz. + forceDialog() void showUpdateDialog('downloading') worker.addEventListener('statechange', () => { @@ -763,6 +797,7 @@ function trackInstallingWorker(reg: ServiceWorkerRegistration) { // sürüm çalışmaya devam eder ve kullanıcı kilitli ekranda kalmaz. if (worker.state === 'redundant' && !activating) { console.warn('Yeni service worker kurulamadı, yeniden denenecek.') + forceDialog() setState({ status: 'error', visible: true, @@ -903,6 +938,9 @@ export const registerServiceWorker = () => { /** Kullanıcının menüden tetiklediği manuel kontrol. */ export const checkForAppUpdate = async (): Promise => { + // Kullanıcı kontrolü kendisi istedi; önceki "arka planda devam et" tercihi + // bu akışta geçerli değil. + forceDialog() setState({ status: state.status === 'idle' ? 'checking' : state.status }) if (!('serviceWorker' in navigator)) return 'unsupported'