Claude vite optimize ve check new version
This commit is contained in:
parent
81960df31b
commit
93e337b9b2
9 changed files with 837 additions and 395 deletions
|
|
@ -20586,6 +20586,30 @@
|
||||||
"en": "Update check failed.",
|
"en": "Update check failed.",
|
||||||
"tr": "Güncelleme kontrolü başarısız"
|
"tr": "Güncelleme kontrolü başarısız"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.UpdateCheckDisabled",
|
||||||
|
"en": "Update check is disabled",
|
||||||
|
"tr": "Güncelleme kontrolü kapalı"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.UpdateCheckDisabledInThisEnvironment",
|
||||||
|
"en": "The service worker is disabled in this environment, so the version cannot be checked.",
|
||||||
|
"tr": "Bu ortamda service worker devre dışı olduğu için sürüm kontrolü yapılamıyor."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.UpdateCompleted",
|
||||||
|
"en": "The application has been updated",
|
||||||
|
"tr": "Uygulama güncellendi"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.UpdatedToVersion",
|
||||||
|
"en": "New version",
|
||||||
|
"tr": "Yeni sürüm"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"resourceName": "Platform",
|
"resourceName": "Platform",
|
||||||
"key": "App.StaticLookup.AbpAccount",
|
"key": "App.StaticLookup.AbpAccount",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,28 @@
|
||||||
{
|
{
|
||||||
"commit": "8146adf",
|
"commit": "81960df",
|
||||||
"releases": [
|
"releases": [
|
||||||
|
{
|
||||||
|
"version": "1.1.10",
|
||||||
|
"buildDate": "2026-07-31",
|
||||||
|
"commit": "2eef3ead35848341350659828a14dc4a463e57af",
|
||||||
|
"changeLog": [
|
||||||
|
"- Kullanıcılara User Type sütunu eklendi. Hem \"İç Kullanıcılar\" hemde \"Dış Kullanıcılar\" tanımlanabiliyor.",
|
||||||
|
"- Lisans adedi sadece İç Kullanıcılar üzerinden çalışıyor.",
|
||||||
|
"- Scheduler ve TodoBard komponentler geliştirildi.",
|
||||||
|
"- Lookup editorlerin Static değerlere dil desteği eklendi."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "1.1.09",
|
||||||
|
"buildDate": "2026-07-29",
|
||||||
|
"commit": "b0e78ac619dfebac2eb17edfc04551217ca4b93e",
|
||||||
|
"changeLog": [
|
||||||
|
"- Wizard yetenekleri artırıldı. Menu Order eklendi.",
|
||||||
|
"- GanttView sample düzenlemeleri",
|
||||||
|
"- Schedulerview sample düzenlemeleri",
|
||||||
|
"- CryptographyXml version 10.0.10"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "1.1.08",
|
"version": "1.1.08",
|
||||||
"buildDate": "2026-07-16",
|
"buildDate": "2026-07-16",
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { Helmet } from 'react-helmet'
|
||||||
import { useNavigate, useLocation } from 'react-router-dom'
|
import { useNavigate, useLocation } from 'react-router-dom'
|
||||||
import { getSetupStatus } from '@/services/setup.service'
|
import { getSetupStatus } from '@/services/setup.service'
|
||||||
import { ROUTES_ENUM } from '@/routes/route.constant'
|
import { ROUTES_ENUM } from '@/routes/route.constant'
|
||||||
|
import { useAppVersionNotice } from '@/views/version/useAppVersionNotice'
|
||||||
|
|
||||||
let didInit = false
|
let didInit = false
|
||||||
|
|
||||||
|
|
@ -62,6 +63,7 @@ const Theme = (props: CommonProps) => {
|
||||||
useDarkMode()
|
useDarkMode()
|
||||||
useTabFocus()
|
useTabFocus()
|
||||||
useNotification()
|
useNotification()
|
||||||
|
useAppVersionNotice()
|
||||||
|
|
||||||
type ConfigProviderValue = NonNullable<ComponentProps<typeof ConfigProvider>['value']>
|
type ConfigProviderValue = NonNullable<ComponentProps<typeof ConfigProvider>['value']>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,9 +47,6 @@ const validationSchema = Yup.object().shape({
|
||||||
const Login = () => {
|
const Login = () => {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const UiVersion = useStoreState((state) => state.locale.currentUiVersion)
|
|
||||||
const { setUiVersion } = useStoreActions((a) => a.locale)
|
|
||||||
|
|
||||||
const [message, setMessage] = useState('')
|
const [message, setMessage] = useState('')
|
||||||
const [error, setError] = useTimeOutMessage(300000)
|
const [error, setError] = useTimeOutMessage(300000)
|
||||||
const [twoFactor, setTwoFactor] = useState(false)
|
const [twoFactor, setTwoFactor] = useState(false)
|
||||||
|
|
@ -152,9 +149,6 @@ const Login = () => {
|
||||||
setShowCaptcha(false)
|
setShowCaptcha(false)
|
||||||
setError('')
|
setError('')
|
||||||
setMessage('')
|
setMessage('')
|
||||||
|
|
||||||
// Versiyon kontrolü
|
|
||||||
findUiVersion()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showCaptcha) {
|
if (showCaptcha) {
|
||||||
|
|
@ -165,19 +159,6 @@ const Login = () => {
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const findUiVersion = async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/version.json?ts=${Date.now()}`)
|
|
||||||
const latest = (await res.json())?.releases?.[0]?.version
|
|
||||||
if (latest && UiVersion !== latest) {
|
|
||||||
setUiVersion(latest)
|
|
||||||
navigate(ROUTES_ENUM.protected.admin.changeLog)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Versiyon okunamadı', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Helmet
|
<Helmet
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,13 @@ export const useManualUpdateCheck = () => {
|
||||||
const { translate } = useLocalization()
|
const { translate } = useLocalization()
|
||||||
const [isCheckingUpdate, setIsCheckingUpdate] = useState(false)
|
const [isCheckingUpdate, setIsCheckingUpdate] = useState(false)
|
||||||
|
|
||||||
|
// translate() eksik anahtarda anahtarın kendisini döndürüyor; kullanıcıya
|
||||||
|
// "::App.X" göstermemek için okunabilir bir karşılığa düş.
|
||||||
|
const label = (key: string, fallback: string) => {
|
||||||
|
const value = translate(key)
|
||||||
|
return !value || value === key || value.includes('::') ? fallback : value
|
||||||
|
}
|
||||||
|
|
||||||
const checkForUpdate = async () => {
|
const checkForUpdate = async () => {
|
||||||
if (isCheckingUpdate) return
|
if (isCheckingUpdate) return
|
||||||
setIsCheckingUpdate(true)
|
setIsCheckingUpdate(true)
|
||||||
|
|
@ -34,6 +41,19 @@ export const useManualUpdateCheck = () => {
|
||||||
</Notification>,
|
</Notification>,
|
||||||
{ placement: 'bottom-end' },
|
{ placement: 'bottom-end' },
|
||||||
)
|
)
|
||||||
|
} else if (result === 'disabled') {
|
||||||
|
toast.push(
|
||||||
|
<Notification
|
||||||
|
title={label('::App.UpdateCheckDisabled', 'Güncelleme kontrolü kapalı')}
|
||||||
|
type="info"
|
||||||
|
>
|
||||||
|
{label(
|
||||||
|
'::App.UpdateCheckDisabledInThisEnvironment',
|
||||||
|
'Bu ortamda service worker devre dışı olduğu için sürüm kontrolü yapılamıyor.',
|
||||||
|
)}
|
||||||
|
</Notification>,
|
||||||
|
{ placement: 'bottom-end' },
|
||||||
|
)
|
||||||
} else if (result === 'unsupported') {
|
} else if (result === 'unsupported') {
|
||||||
toast.push(
|
toast.push(
|
||||||
<Notification title={translate('::App.UpdateCouldNotBeChecked')} type="warning">
|
<Notification title={translate('::App.UpdateCouldNotBeChecked')} type="warning">
|
||||||
|
|
|
||||||
|
|
@ -1,122 +1,132 @@
|
||||||
import { registerSW } from 'virtual:pwa-register'
|
import { registerSW } from 'virtual:pwa-register'
|
||||||
import { store } from '@/store'
|
import { store } from '@/store'
|
||||||
|
|
||||||
const ACTIVATION_RETRY_DELAY = 5_000
|
/**
|
||||||
const ACTIVATION_TIMEOUT = 20_000
|
* Service worker güncelleme akışı
|
||||||
|
* ------------------------------------------------------------------
|
||||||
|
* 1. Kayıt + açılışta bir kez `update()` kontrolü.
|
||||||
|
* 2. Sekme görünürken periyodik, sekmeye dönüldüğünde ve internet geri
|
||||||
|
* geldiğinde tekrar kontrol -> yeni deploy açık uygulamada da yakalanır.
|
||||||
|
* 3. Yeni worker kurulmaya başlayınca (`installing`) engellemeyen bir bant
|
||||||
|
* ile "yeni sürüm indiriliyor" bilgisi gösterilir.
|
||||||
|
* 4. Kurulum bitince (`installed`) tam ekran overlay + sürüm notları gösterilir,
|
||||||
|
* SKIP_WAITING gönderilir ve `controllerchange` ile sayfa yenilenir.
|
||||||
|
* 5. Yenileme sonrası `consumeAppUpdatedVersion()` ile "güncellendi" bildirimi
|
||||||
|
* ve changelog yönlendirmesi yapılır.
|
||||||
|
*/
|
||||||
|
|
||||||
let registrationStarted = false
|
export type ManualUpdateResult =
|
||||||
let activationInProgress = false
|
| 'up-to-date'
|
||||||
let reloadStarted = false
|
| 'updating'
|
||||||
let currentRegistration: ServiceWorkerRegistration | undefined
|
/** Tarayıcı service worker desteklemiyor. */
|
||||||
let activateUpdateHandler: ((reloadPage?: boolean) => Promise<void>) | undefined
|
| 'unsupported'
|
||||||
let registrationInitialization: Promise<void> | undefined
|
/** Destekleniyor ama bu ortamda kapalı (dev; VITE_PWA_DEV=true ile açılır). */
|
||||||
let activationRetryTimer: number | undefined
|
| 'disabled'
|
||||||
let activationTimeoutTimer: number | undefined
|
|
||||||
let overlayStoreUnsubscribe: (() => void) | undefined
|
|
||||||
const watchedInstallingWorkers = new WeakSet<ServiceWorker>()
|
|
||||||
|
|
||||||
export type ManualUpdateResult = 'up-to-date' | 'updating' | 'unsupported'
|
const PERIODIC_CHECK_INTERVAL = 15 * 60 * 1000
|
||||||
|
const MIN_CHECK_GAP = 60 * 1000
|
||||||
|
const ACTIVATION_RETRY_DELAY = 8_000
|
||||||
|
const ACTIVATION_TIMEOUT = 30_000
|
||||||
|
// `virtual:pwa-register` dev'de (devOptions kapalıyken) no-op'tur ve hiçbir
|
||||||
|
// callback çalışmaz. Kayıt promise'inin süresiz beklememesi için üst sınır.
|
||||||
|
const REGISTRATION_TIMEOUT = 10_000
|
||||||
|
// `registration.update()` yavaş/kopuk ağda süresiz askıda kalabiliyor.
|
||||||
|
const UPDATE_CALL_TIMEOUT = 20_000
|
||||||
|
|
||||||
|
const withTimeout = <T>(promise: Promise<T>, ms: number) =>
|
||||||
|
Promise.race([
|
||||||
|
promise,
|
||||||
|
new Promise<undefined>((resolve) => window.setTimeout(() => resolve(undefined), ms)),
|
||||||
|
])
|
||||||
|
|
||||||
|
const UPDATED_VERSION_KEY = 'sozsoft:app-updated-version'
|
||||||
|
const CHUNK_RELOAD_GUARD_KEY = 'sozsoft:chunk-reload'
|
||||||
|
|
||||||
type Release = {
|
type Release = {
|
||||||
version: string
|
version: string
|
||||||
changeLog?: string[]
|
changeLog?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const getText = (value: unknown) =>
|
let started = false
|
||||||
value === undefined || value === null ? undefined : String(value)
|
let registration: ServiceWorkerRegistration | undefined
|
||||||
|
let initialization: Promise<void> | undefined
|
||||||
|
let activating = false
|
||||||
|
let reloading = false
|
||||||
|
let lastCheckAt = 0
|
||||||
|
let periodicTimer: number | undefined
|
||||||
|
let activationRetryTimer: number | undefined
|
||||||
|
let activationTimeoutTimer: number | undefined
|
||||||
|
let storeUnsubscribe: (() => void) | undefined
|
||||||
|
let latestVersion: string | undefined
|
||||||
|
let releasePromise: Promise<Release | undefined> | undefined
|
||||||
|
|
||||||
function updateOverlayApiVersion(overlay: HTMLElement) {
|
const watchedRegistrations = new WeakSet<ServiceWorkerRegistration>()
|
||||||
const apiConfig = store.getState().abpConfig.config?.extraProperties
|
const watchedWorkers = new WeakSet<ServiceWorker>()
|
||||||
const apiEnvironment = getText(apiConfig?.environment)
|
|
||||||
const apiVersion = getText(apiConfig?.version)
|
|
||||||
|
|
||||||
if (!apiEnvironment && !apiVersion) return false
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Sürüm bilgisi */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
const apiValue = overlay.querySelector<HTMLElement>('[data-sw-api-version]')
|
const loadLatestRelease = () =>
|
||||||
if (apiValue) {
|
(releasePromise ??= fetch(`/version.json?ts=${Date.now()}`, { cache: 'no-store' })
|
||||||
apiValue.textContent = `${apiEnvironment ?? '-'}:${apiVersion ?? '-'}`
|
.then((response) => {
|
||||||
}
|
if (!response.ok) throw new Error(`version.json isteği ${response.status} döndü`)
|
||||||
|
return response.json() as Promise<{ releases?: Release[] }>
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
function watchOverlayApiVersion(overlay: HTMLElement) {
|
|
||||||
overlayStoreUnsubscribe?.()
|
|
||||||
|
|
||||||
if (updateOverlayApiVersion(overlay)) return
|
|
||||||
|
|
||||||
overlayStoreUnsubscribe = store.subscribe(() => {
|
|
||||||
if (!overlay.isConnected || updateOverlayApiVersion(overlay)) {
|
|
||||||
overlayStoreUnsubscribe?.()
|
|
||||||
overlayStoreUnsubscribe = undefined
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
.then((data) => {
|
||||||
|
const release = data.releases?.[0]
|
||||||
async function loadUpdateOverlayDetails(overlay: HTMLElement) {
|
latestVersion = release?.version ?? latestVersion
|
||||||
const state = store.getState()
|
return release
|
||||||
const uiEnvironment = import.meta.env.MODE
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
let uiVersion = state.locale.currentUiVersion
|
console.warn('Sürüm bilgisi okunamadı.', error)
|
||||||
let latestRelease: Release | undefined
|
releasePromise = undefined
|
||||||
|
return undefined
|
||||||
|
}))
|
||||||
|
|
||||||
|
/** Yenileme sonrası bir kez okunur; yeni sürüm numarasını döner. */
|
||||||
|
export const consumeAppUpdatedVersion = (): string | undefined => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/version.json?ts=${Date.now()}`, { cache: 'no-store' })
|
const value = sessionStorage.getItem(UPDATED_VERSION_KEY)
|
||||||
if (!response.ok) throw new Error(`Version request failed with ${response.status}`)
|
if (value) sessionStorage.removeItem(UPDATED_VERSION_KEY)
|
||||||
|
return value ?? undefined
|
||||||
const releases = ((await response.json()) as { releases?: Release[] }).releases
|
} catch {
|
||||||
latestRelease = releases?.[0]
|
return undefined
|
||||||
uiVersion = latestRelease?.version ?? uiVersion
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Update details could not be loaded.', error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The overlay may have been removed while version.json was loading.
|
|
||||||
if (!overlay.isConnected) return
|
|
||||||
|
|
||||||
const uiValue = overlay.querySelector<HTMLElement>('[data-sw-ui-version]')
|
|
||||||
const releaseTitle = overlay.querySelector<HTMLElement>('[data-sw-release-title]')
|
|
||||||
const releaseList = overlay.querySelector<HTMLUListElement>('[data-sw-release-list]')
|
|
||||||
|
|
||||||
if (uiValue) uiValue.textContent = `${uiEnvironment}:${uiVersion ?? '-'}`
|
|
||||||
watchOverlayApiVersion(overlay)
|
|
||||||
|
|
||||||
if (!releaseList || !releaseTitle) return
|
|
||||||
|
|
||||||
const changes = latestRelease?.changeLog?.filter(Boolean) ?? []
|
|
||||||
releaseTitle.textContent = latestRelease?.version
|
|
||||||
? `What's new in v${latestRelease.version}`
|
|
||||||
: "What's new"
|
|
||||||
releaseList.replaceChildren()
|
|
||||||
|
|
||||||
if (changes.length === 0) {
|
|
||||||
const item = document.createElement('li')
|
|
||||||
item.className = 'sw-update-empty'
|
|
||||||
item.textContent = 'Release notes are not available.'
|
|
||||||
releaseList.appendChild(item)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
changes.forEach((change) => {
|
|
||||||
const item = document.createElement('li')
|
|
||||||
const icon = document.createElement('span')
|
|
||||||
const text = document.createElement('span')
|
|
||||||
icon.className = 'sw-update-check'
|
|
||||||
icon.setAttribute('aria-hidden', 'true')
|
|
||||||
icon.textContent = '\u2713'
|
|
||||||
text.textContent = change.replace(/^\s*-\s*/, '')
|
|
||||||
item.append(icon, text)
|
|
||||||
releaseList.appendChild(item)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function showUpdateOverlay() {
|
/* ------------------------------------------------------------------ */
|
||||||
if (document.getElementById('sw-update-overlay')) return
|
/* Arayüz */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const STYLE_ID = 'sw-update-style'
|
||||||
|
const BANNER_ID = 'sw-update-banner'
|
||||||
|
const OVERLAY_ID = 'sw-update-overlay'
|
||||||
|
|
||||||
|
function ensureStyles() {
|
||||||
|
if (document.getElementById(STYLE_ID)) return
|
||||||
|
|
||||||
const style = document.createElement('style')
|
const style = document.createElement('style')
|
||||||
style.id = 'sw-update-overlay-style'
|
style.id = STYLE_ID
|
||||||
style.textContent = `
|
style.textContent = `
|
||||||
#sw-update-overlay {
|
#${BANNER_ID} {
|
||||||
|
position: fixed;
|
||||||
|
right: 16px;
|
||||||
|
bottom: 16px;
|
||||||
|
z-index: 99998;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
max-width: 320px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #111827;
|
||||||
|
color: #f9fafb;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
#${OVERLAY_ID} {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 99999;
|
z-index: 99999;
|
||||||
|
|
@ -127,7 +137,7 @@ function showUpdateOverlay() {
|
||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(4px);
|
||||||
-webkit-backdrop-filter: blur(4px);
|
-webkit-backdrop-filter: blur(4px);
|
||||||
}
|
}
|
||||||
#sw-update-overlay .sw-update-card {
|
.sw-update-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -141,7 +151,8 @@ function showUpdateOverlay() {
|
||||||
max-height: min(90vh, 720px);
|
max-height: min(90vh, 720px);
|
||||||
width: 90%;
|
width: 90%;
|
||||||
}
|
}
|
||||||
#sw-update-overlay .sw-update-spinner {
|
.sw-update-spinner {
|
||||||
|
flex: 0 0 auto;
|
||||||
width: 56px;
|
width: 56px;
|
||||||
height: 56px;
|
height: 56px;
|
||||||
border: 5px solid #e5e7eb;
|
border: 5px solid #e5e7eb;
|
||||||
|
|
@ -149,21 +160,17 @@ function showUpdateOverlay() {
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: sw-spin 0.8s linear infinite;
|
animation: sw-spin 0.8s linear infinite;
|
||||||
}
|
}
|
||||||
@keyframes sw-spin {
|
#${BANNER_ID} .sw-update-spinner {
|
||||||
to { transform: rotate(360deg); }
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-width: 3px;
|
||||||
|
border-color: rgba(255,255,255,0.25);
|
||||||
|
border-top-color: #a5b4fc;
|
||||||
}
|
}
|
||||||
#sw-update-overlay .sw-update-title {
|
@keyframes sw-spin { to { transform: rotate(360deg); } }
|
||||||
font-size: 18px;
|
.sw-update-title { font-size: 18px; font-weight: 700; color: #1f2937; margin: 0; }
|
||||||
font-weight: 700;
|
.sw-update-desc { font-size: 14px; color: #6b7280; margin: 0; }
|
||||||
color: #1f2937;
|
.sw-update-versions {
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
#sw-update-overlay .sw-update-desc {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #6b7280;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
#sw-update-overlay .sw-update-versions {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -175,13 +182,9 @@ function showUpdateOverlay() {
|
||||||
background: #f3f4f6;
|
background: #f3f4f6;
|
||||||
color: #4b5563;
|
color: #4b5563;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
text-transform: capitalize;
|
|
||||||
}
|
}
|
||||||
#sw-update-overlay .sw-update-separator {
|
.sw-update-separator { color: #9ca3af; margin: 0 4px; }
|
||||||
color: #9ca3af;
|
.sw-update-release {
|
||||||
margin: 0 4px;
|
|
||||||
}
|
|
||||||
#sw-update-overlay .sw-update-release {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|
@ -189,13 +192,8 @@ function showUpdateOverlay() {
|
||||||
border-top: 1px solid #e5e7eb;
|
border-top: 1px solid #e5e7eb;
|
||||||
padding-top: 14px;
|
padding-top: 14px;
|
||||||
}
|
}
|
||||||
#sw-update-overlay .sw-update-release-title {
|
.sw-update-release-title { margin: 0 0 10px; color: #374151; font-size: 14px; font-weight: 700; }
|
||||||
margin: 0 0 10px;
|
.sw-update-list {
|
||||||
color: #374151;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
#sw-update-overlay .sw-update-list {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|
@ -206,12 +204,8 @@ function showUpdateOverlay() {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
#sw-update-overlay .sw-update-list li {
|
.sw-update-list li { display: flex; align-items: flex-start; gap: 8px; }
|
||||||
display: flex;
|
.sw-update-check {
|
||||||
align-items: flex-start;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
#sw-update-overlay .sw-update-check {
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
flex: 0 0 16px;
|
flex: 0 0 16px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -225,51 +219,189 @@ function showUpdateOverlay() {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
#sw-update-overlay .sw-update-empty {
|
.sw-update-empty { display: block; color: #9ca3af; }
|
||||||
display: block;
|
.sw-update-action {
|
||||||
color: #9ca3af;
|
appearance: none;
|
||||||
|
border: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #4f46e5;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
.sw-update-action:hover { background: #4338ca; }
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
#sw-update-overlay .sw-update-card {
|
.sw-update-card { padding: 24px 20px; }
|
||||||
padding: 24px 20px;
|
#${BANNER_ID} { left: 16px; right: 16px; max-width: none; }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
document.head.appendChild(style)
|
document.head.appendChild(style)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeStyles() {
|
||||||
|
document.getElementById(STYLE_ID)?.remove()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** İndirme sürerken kullanıcıyı engellemeyen bilgilendirme. */
|
||||||
|
function showDownloadBanner() {
|
||||||
|
if (document.getElementById(BANNER_ID) || document.getElementById(OVERLAY_ID)) return
|
||||||
|
ensureStyles()
|
||||||
|
|
||||||
|
const banner = document.createElement('div')
|
||||||
|
banner.id = BANNER_ID
|
||||||
|
banner.setAttribute('role', 'status')
|
||||||
|
banner.setAttribute('aria-live', 'polite')
|
||||||
|
|
||||||
|
const spinner = document.createElement('div')
|
||||||
|
spinner.className = 'sw-update-spinner'
|
||||||
|
|
||||||
|
const text = document.createElement('span')
|
||||||
|
text.textContent = 'Yeni sürüm indiriliyor, lütfen sekmeyi kapatmayın...'
|
||||||
|
|
||||||
|
banner.append(spinner, text)
|
||||||
|
document.body.appendChild(banner)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeDownloadBanner() {
|
||||||
|
document.getElementById(BANNER_ID)?.remove()
|
||||||
|
}
|
||||||
|
|
||||||
|
function getText(value: unknown) {
|
||||||
|
return value === undefined || value === null ? undefined : String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyApiVersion(overlay: HTMLElement) {
|
||||||
|
const apiConfig = store.getState().abpConfig.config?.extraProperties
|
||||||
|
const environment = getText(apiConfig?.environment)
|
||||||
|
const version = getText(apiConfig?.version)
|
||||||
|
if (!environment && !version) return false
|
||||||
|
|
||||||
|
const target = overlay.querySelector<HTMLElement>('[data-sw-api-version]')
|
||||||
|
if (target) target.textContent = `${environment ?? '-'}:${version ?? '-'}`
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function watchApiVersion(overlay: HTMLElement) {
|
||||||
|
storeUnsubscribe?.()
|
||||||
|
storeUnsubscribe = undefined
|
||||||
|
if (applyApiVersion(overlay)) return
|
||||||
|
|
||||||
|
storeUnsubscribe = store.subscribe(() => {
|
||||||
|
if (!overlay.isConnected || applyApiVersion(overlay)) {
|
||||||
|
storeUnsubscribe?.()
|
||||||
|
storeUnsubscribe = undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fillOverlayDetails(overlay: HTMLElement) {
|
||||||
|
const release = await loadLatestRelease()
|
||||||
|
if (!overlay.isConnected) return
|
||||||
|
|
||||||
|
const uiVersion = release?.version ?? store.getState().locale.currentUiVersion
|
||||||
|
const uiValue = overlay.querySelector<HTMLElement>('[data-sw-ui-version]')
|
||||||
|
if (uiValue) uiValue.textContent = `${import.meta.env.MODE}:${uiVersion ?? '-'}`
|
||||||
|
watchApiVersion(overlay)
|
||||||
|
|
||||||
|
const releaseTitle = overlay.querySelector<HTMLElement>('[data-sw-release-title]')
|
||||||
|
const releaseList = overlay.querySelector<HTMLUListElement>('[data-sw-release-list]')
|
||||||
|
if (!releaseTitle || !releaseList) return
|
||||||
|
|
||||||
|
releaseTitle.textContent = release?.version
|
||||||
|
? `v${release.version} ile gelen yenilikler`
|
||||||
|
: 'Sürüm notları'
|
||||||
|
releaseList.replaceChildren()
|
||||||
|
|
||||||
|
const changes = release?.changeLog?.filter(Boolean) ?? []
|
||||||
|
if (changes.length === 0) {
|
||||||
|
const item = document.createElement('li')
|
||||||
|
item.className = 'sw-update-empty'
|
||||||
|
item.textContent = 'Bu sürüm için not bulunamadı.'
|
||||||
|
releaseList.appendChild(item)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
changes.forEach((change) => {
|
||||||
|
const item = document.createElement('li')
|
||||||
|
const icon = document.createElement('span')
|
||||||
|
const text = document.createElement('span')
|
||||||
|
icon.className = 'sw-update-check'
|
||||||
|
icon.setAttribute('aria-hidden', 'true')
|
||||||
|
icon.textContent = '✓'
|
||||||
|
text.textContent = change.replace(/^\s*-\s*/, '')
|
||||||
|
item.append(icon, text)
|
||||||
|
releaseList.appendChild(item)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function showActivationOverlay() {
|
||||||
|
removeDownloadBanner()
|
||||||
|
if (document.getElementById(OVERLAY_ID)) return
|
||||||
|
ensureStyles()
|
||||||
|
|
||||||
const overlay = document.createElement('div')
|
const overlay = document.createElement('div')
|
||||||
overlay.id = 'sw-update-overlay'
|
overlay.id = OVERLAY_ID
|
||||||
overlay.setAttribute('role', 'status')
|
overlay.setAttribute('role', 'status')
|
||||||
overlay.setAttribute('aria-live', 'polite')
|
overlay.setAttribute('aria-live', 'polite')
|
||||||
overlay.innerHTML = `
|
overlay.innerHTML = `
|
||||||
<div class="sw-update-card">
|
<div class="sw-update-card">
|
||||||
<div class="sw-update-spinner"></div>
|
<div class="sw-update-spinner"></div>
|
||||||
<p class="sw-update-title">System Updating</p>
|
<p class="sw-update-title" data-sw-title>Sistem güncelleniyor</p>
|
||||||
<p class="sw-update-desc">Loading new version, please wait...<br/>The page will reload automatically.</p>
|
<p class="sw-update-desc" data-sw-desc>Yeni sürüm yükleniyor, lütfen bekleyin...<br/>Sayfa otomatik olarak yenilenecek.</p>
|
||||||
<div class="sw-update-versions">
|
<div class="sw-update-versions">
|
||||||
<span><strong>UI:</strong> <span data-sw-ui-version>Loading...</span></span>
|
<span><strong>UI:</strong> <span data-sw-ui-version>Yükleniyor...</span></span>
|
||||||
<span class="sw-update-separator">|</span>
|
<span class="sw-update-separator">|</span>
|
||||||
<span><strong>API:</strong> <span data-sw-api-version>Loading...</span></span>
|
<span><strong>API:</strong> <span data-sw-api-version>Yükleniyor...</span></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="sw-update-release">
|
<div class="sw-update-release">
|
||||||
<p class="sw-update-release-title" data-sw-release-title>What's new</p>
|
<p class="sw-update-release-title" data-sw-release-title>Sürüm notları</p>
|
||||||
<ul class="sw-update-list" data-sw-release-list>
|
<ul class="sw-update-list" data-sw-release-list>
|
||||||
<li class="sw-update-empty">Loading release notes...</li>
|
<li class="sw-update-empty">Sürüm notları yükleniyor...</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
document.body.appendChild(overlay)
|
document.body.appendChild(overlay)
|
||||||
void loadUpdateOverlayDetails(overlay)
|
void fillOverlayDetails(overlay)
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideUpdateOverlay() {
|
/** Aktivasyon tamamlanamazsa kullanıcıyı belirsizlikte bırakma. */
|
||||||
overlayStoreUnsubscribe?.()
|
function showManualReloadPrompt() {
|
||||||
overlayStoreUnsubscribe = undefined
|
const overlay = document.getElementById(OVERLAY_ID)
|
||||||
document.getElementById('sw-update-overlay')?.remove()
|
if (!overlay) return
|
||||||
document.getElementById('sw-update-overlay-style')?.remove()
|
|
||||||
|
overlay.querySelector<HTMLElement>('.sw-update-spinner')?.remove()
|
||||||
|
|
||||||
|
const title = overlay.querySelector<HTMLElement>('[data-sw-title]')
|
||||||
|
const desc = overlay.querySelector<HTMLElement>('[data-sw-desc]')
|
||||||
|
if (title) title.textContent = 'Güncelleme tamamlanamadı'
|
||||||
|
if (desc) {
|
||||||
|
desc.textContent = 'Yeni sürüme geçmek için sayfayı yenileyin.'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overlay.querySelector('.sw-update-action')) return
|
||||||
|
const button = document.createElement('button')
|
||||||
|
button.type = 'button'
|
||||||
|
button.className = 'sw-update-action'
|
||||||
|
button.textContent = 'Sayfayı yenile'
|
||||||
|
button.addEventListener('click', () => reloadForUpdate())
|
||||||
|
overlay.querySelector('.sw-update-card')?.appendChild(button)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function teardownUpdateUi() {
|
||||||
|
storeUnsubscribe?.()
|
||||||
|
storeUnsubscribe = undefined
|
||||||
|
removeDownloadBanner()
|
||||||
|
document.getElementById(OVERLAY_ID)?.remove()
|
||||||
|
removeStyles()
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Aktivasyon */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
function clearActivationTimers() {
|
function clearActivationTimers() {
|
||||||
window.clearTimeout(activationRetryTimer)
|
window.clearTimeout(activationRetryTimer)
|
||||||
window.clearTimeout(activationTimeoutTimer)
|
window.clearTimeout(activationTimeoutTimer)
|
||||||
|
|
@ -278,189 +410,283 @@ function clearActivationTimers() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function reloadForUpdate() {
|
function reloadForUpdate() {
|
||||||
if (reloadStarted) return
|
if (reloading) return
|
||||||
reloadStarted = true
|
reloading = true
|
||||||
clearActivationTimers()
|
clearActivationTimers()
|
||||||
|
try {
|
||||||
|
if (latestVersion) sessionStorage.setItem(UPDATED_VERSION_KEY, latestVersion)
|
||||||
|
} catch {
|
||||||
|
/* sessionStorage kullanılamıyorsa bildirim atlanır */
|
||||||
|
}
|
||||||
window.location.reload()
|
window.location.reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
function activateWaitingWorker(
|
function postSkipWaiting() {
|
||||||
registration: ServiceWorkerRegistration | undefined,
|
registration?.waiting?.postMessage({ type: 'SKIP_WAITING' })
|
||||||
activateUpdate: (reloadPage?: boolean) => Promise<void>,
|
}
|
||||||
showOverlay: boolean,
|
|
||||||
) {
|
function activateWaitingWorker() {
|
||||||
if (activationInProgress) {
|
if (activating) {
|
||||||
if (showOverlay) showUpdateOverlay()
|
showActivationOverlay()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
activationInProgress = true
|
activating = true
|
||||||
|
showActivationOverlay()
|
||||||
|
// Sürüm numarasını yenileme öncesinde hazır tut.
|
||||||
|
void loadLatestRelease()
|
||||||
|
|
||||||
if (showOverlay) {
|
navigator.serviceWorker.addEventListener('controllerchange', reloadForUpdate, { once: true })
|
||||||
showUpdateOverlay()
|
postSkipWaiting()
|
||||||
}
|
|
||||||
|
|
||||||
// Workbox'ın controlling olayı herhangi bir nedenle kaçarsa native olay da
|
// Bazı tarayıcılarda ilk mesaj bekleyen worker'a ulaşmayabiliyor.
|
||||||
// yeni worker kontrolü aldığı anda sayfayı yeniler.
|
activationRetryTimer = window.setTimeout(postSkipWaiting, ACTIVATION_RETRY_DELAY)
|
||||||
navigator.serviceWorker.addEventListener('controllerchange', reloadForUpdate, {
|
|
||||||
once: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Workbox callback sırasına bağlı kalmadan waiting worker'ı doğrudan geçir.
|
|
||||||
registration?.waiting?.postMessage({ type: 'SKIP_WAITING' })
|
|
||||||
|
|
||||||
void activateUpdate(true).catch((error: unknown) => {
|
|
||||||
console.error('Service worker activation failed.', error)
|
|
||||||
activationInProgress = false
|
|
||||||
clearActivationTimers()
|
|
||||||
hideUpdateOverlay()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Bazı tarayıcılarda Workbox mesajı bekleyen worker'a ulaşmayabiliyor.
|
|
||||||
// Kısa bir süre sonra native mesajla bir kez daha aktivasyon istenir.
|
|
||||||
activationRetryTimer = window.setTimeout(() => {
|
|
||||||
registration?.waiting?.postMessage({ type: 'SKIP_WAITING' })
|
|
||||||
}, ACTIVATION_RETRY_DELAY)
|
|
||||||
|
|
||||||
// Aktivasyon yine de tamamlanamazsa uygulamayı sonsuza kadar kilitleme.
|
|
||||||
// Eski worker çalışmaya devam eder; bir sonraki açılışta tekrar denenir.
|
|
||||||
activationTimeoutTimer = window.setTimeout(() => {
|
activationTimeoutTimer = window.setTimeout(() => {
|
||||||
activationInProgress = false
|
|
||||||
clearActivationTimers()
|
clearActivationTimers()
|
||||||
|
activating = false
|
||||||
|
|
||||||
// Worker waiting durumundan çıktıysa aktivasyon gerçekleşmiş fakat
|
// Worker waiting'den çıktıysa aktivasyon olmuş, sadece controllerchange
|
||||||
// controllerchange olayı kaçmış olabilir. Yeni precache'i almak için yenile.
|
// kaçmış olabilir; yeni precache'i almak için yenile.
|
||||||
if (!registration?.waiting && registration?.active) {
|
if (!registration?.waiting && registration?.active) {
|
||||||
reloadForUpdate()
|
reloadForUpdate()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
hideUpdateOverlay()
|
console.warn('Service worker aktivasyonu zaman aşımına uğradı.')
|
||||||
console.warn('Service worker activation timed out; continuing with current version.')
|
showManualReloadPrompt()
|
||||||
}, ACTIVATION_TIMEOUT)
|
}, ACTIVATION_TIMEOUT)
|
||||||
}
|
}
|
||||||
|
|
||||||
function showUpdateWhileInstalling(registration: ServiceWorkerRegistration) {
|
/* ------------------------------------------------------------------ */
|
||||||
const installingWorker = registration.installing
|
/* Güncelleme takibi */
|
||||||
if (
|
/* ------------------------------------------------------------------ */
|
||||||
!installingWorker ||
|
|
||||||
!navigator.serviceWorker.controller ||
|
function trackInstallingWorker(reg: ServiceWorkerRegistration) {
|
||||||
watchedInstallingWorkers.has(installingWorker)
|
const worker = reg.installing
|
||||||
) {
|
// Controller yoksa bu ilk kurulumdur; kullanıcıya güncelleme demek yanlış olur.
|
||||||
|
if (!worker || !navigator.serviceWorker.controller || watchedWorkers.has(worker)) return
|
||||||
|
|
||||||
|
watchedWorkers.add(worker)
|
||||||
|
showDownloadBanner()
|
||||||
|
|
||||||
|
worker.addEventListener('statechange', () => {
|
||||||
|
if (worker.state === 'installed') {
|
||||||
|
activateWaitingWorker()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Precache dosyalarından biri indirilemezse worker redundant olur; eski
|
||||||
watchedInstallingWorkers.add(installingWorker)
|
// sürüm çalışmaya devam eder ve kullanıcı kilitli ekranda kalmaz.
|
||||||
showUpdateOverlay()
|
if (worker.state === 'redundant' && !activating) {
|
||||||
|
console.warn('Yeni service worker kurulamadı, mevcut sürümle devam ediliyor.')
|
||||||
installingWorker.addEventListener('statechange', () => {
|
teardownUpdateUi()
|
||||||
// Precache dosyalarından biri indirilemezse worker redundant olur. Eski
|
|
||||||
// uygulama çalışmaya devam eder ve kullanıcı kilitli ekranda bırakılmaz.
|
|
||||||
if (installingWorker.state === 'redundant' && !activationInProgress) {
|
|
||||||
hideUpdateOverlay()
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function watchForUpdate(registration: ServiceWorkerRegistration) {
|
function watchRegistration(reg: ServiceWorkerRegistration) {
|
||||||
registration.addEventListener('updatefound', () => {
|
if (watchedRegistrations.has(reg)) return
|
||||||
// Gerçek worker güncellemesi bulunduğu anda, precache indirmesi başlamadan
|
watchedRegistrations.add(reg)
|
||||||
// Loading'i göster ve aktivasyon/reload tamamlanana kadar açık tut.
|
reg.addEventListener('updatefound', () => trackInstallingWorker(reg))
|
||||||
showUpdateWhileInstalling(registration)
|
trackInstallingWorker(reg)
|
||||||
})
|
|
||||||
|
|
||||||
// Listener bağlanmadan hemen önce başlamış bir kurulumu da kaçırma.
|
|
||||||
showUpdateWhileInstalling(registration)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkForUpdate(registration: ServiceWorkerRegistration) {
|
/**
|
||||||
// Bir deploy versiyon numarası/tag değişmeden yapılsa bile sw.js içindeki
|
* Kayıtlı worker'ın script'i artık sunulmuyorsa (ör. PWA kapatıldı, dosya adı
|
||||||
// precache manifest'i değişir. Uygulama açılırken yapılan bu tek kontrol
|
* değişti) sunucu SPA fallback'i yüzünden index.html döner ve tarayıcı
|
||||||
// yeni build'i algılar; açık uygulamada periyodik bir arka plan işi çalışmaz.
|
* "unsupported MIME type" SecurityError'ı fırlatır. Bu durumda eski kayıt
|
||||||
void registration.update().catch((error: unknown) => {
|
* kalıcı olarak bozuktur; temizlenmezse her kontrol aynı hatayı verir.
|
||||||
console.warn('Service worker update check failed.', error)
|
*/
|
||||||
|
async function unregisterIfStale(reg: ServiceWorkerRegistration, error: unknown) {
|
||||||
|
const isStaleScript =
|
||||||
|
error instanceof Error && (error.name === 'SecurityError' || /MIME type/i.test(error.message))
|
||||||
|
if (!isStaleScript) return false
|
||||||
|
|
||||||
|
console.warn('Geçersiz service worker kaydı temizleniyor.', error)
|
||||||
|
try {
|
||||||
|
await reg.unregister()
|
||||||
|
} catch {
|
||||||
|
/* temizlenemediyse bir sonraki açılışta tekrar denenir */
|
||||||
|
}
|
||||||
|
if (registration === reg) registration = undefined
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runUpdateCheck(force = false) {
|
||||||
|
if (!registration || activating || reloading) return
|
||||||
|
if (!force && !navigator.onLine) return
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
if (!force && now - lastCheckAt < MIN_CHECK_GAP) return
|
||||||
|
lastCheckAt = now
|
||||||
|
|
||||||
|
const reg = registration
|
||||||
|
try {
|
||||||
|
await withTimeout(reg.update(), UPDATE_CALL_TIMEOUT)
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (!(await unregisterIfStale(reg, error))) {
|
||||||
|
console.warn('Service worker güncelleme kontrolü başarısız.', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startUpdateWatchers() {
|
||||||
|
window.clearInterval(periodicTimer)
|
||||||
|
periodicTimer = window.setInterval(() => {
|
||||||
|
if (document.visibilityState === 'visible') void runUpdateCheck()
|
||||||
|
}, PERIODIC_CHECK_INTERVAL)
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.visibilityState === 'visible') void runUpdateCheck()
|
||||||
})
|
})
|
||||||
|
window.addEventListener('online', () => void runUpdateCheck())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deploy sonrası sunucudan kaldırılmış bir lazy chunk istenirse Vite
|
||||||
|
* `vite:preloadError` fırlatır. Beyaz ekran yerine bir kez sayfayı yenile.
|
||||||
|
*/
|
||||||
|
function handleChunkLoadFailures() {
|
||||||
|
window.addEventListener('vite:preloadError', (event) => {
|
||||||
|
if (reloading) return
|
||||||
|
try {
|
||||||
|
if (sessionStorage.getItem(CHUNK_RELOAD_GUARD_KEY)) return
|
||||||
|
sessionStorage.setItem(CHUNK_RELOAD_GUARD_KEY, '1')
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
event.preventDefault()
|
||||||
|
console.warn('Eksik chunk tespit edildi, sayfa yenileniyor.')
|
||||||
|
reloading = true
|
||||||
|
window.location.reload()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sayfa sorunsuz açıldıysa koruma bayrağını temizle.
|
||||||
|
window.setTimeout(() => {
|
||||||
|
try {
|
||||||
|
sessionStorage.removeItem(CHUNK_RELOAD_GUARD_KEY)
|
||||||
|
} catch {
|
||||||
|
/* yoksay */
|
||||||
|
}
|
||||||
|
}, 10_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Genel API */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/** Service worker yalnızca prod'da, dev'de ise VITE_PWA_DEV=true ile aktiftir. */
|
||||||
|
const isServiceWorkerEnabled = import.meta.env.PROD || import.meta.env.VITE_PWA_DEV === 'true'
|
||||||
|
|
||||||
|
/** Kapalıyken önceki oturumlardan kalan kayıtları temizler. */
|
||||||
|
async function unregisterAll() {
|
||||||
|
try {
|
||||||
|
const registrations = await navigator.serviceWorker.getRegistrations()
|
||||||
|
await Promise.all(registrations.map((reg) => reg.unregister().catch(() => false)))
|
||||||
|
} catch {
|
||||||
|
/* yoksay */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const registerServiceWorker = () => {
|
export const registerServiceWorker = () => {
|
||||||
if (registrationStarted || !('serviceWorker' in navigator)) return
|
if (started || !('serviceWorker' in navigator)) return
|
||||||
registrationStarted = true
|
started = true
|
||||||
|
|
||||||
registrationInitialization = navigator.serviceWorker
|
handleChunkLoadFailures()
|
||||||
.getRegistration()
|
|
||||||
.catch(() => undefined)
|
if (!isServiceWorkerEnabled) {
|
||||||
.then((existingRegistration) => {
|
// Dev'de SW kapalı. Daha önce kaydedilmiş dev worker'ı bırakırsak script'i
|
||||||
if (existingRegistration) {
|
// (/dev-sw.js) artık üretilmediği için sunucu index.html döner ve her
|
||||||
watchForUpdate(existingRegistration)
|
// güncelleme kontrolü "unsupported MIME type" hatası verir.
|
||||||
|
initialization = unregisterAll()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const activateUpdate = registerSW({
|
initialization = new Promise<void>((resolve) => {
|
||||||
|
// Kayıt callback'i hiç çalışmazsa bile promise'i serbest bırak; aksi halde
|
||||||
|
// `checkForAppUpdate()` "Kontrol ediliyor..." durumunda takılı kalır.
|
||||||
|
const registrationTimeout = window.setTimeout(resolve, REGISTRATION_TIMEOUT)
|
||||||
|
const settle = () => {
|
||||||
|
window.clearTimeout(registrationTimeout)
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
registerSW({
|
||||||
immediate: true,
|
immediate: true,
|
||||||
onRegisteredSW(_swUrl, registration) {
|
onRegisteredSW(_swUrl, reg) {
|
||||||
if (!registration) return
|
if (reg) {
|
||||||
|
registration = reg
|
||||||
|
watchRegistration(reg)
|
||||||
|
startUpdateWatchers()
|
||||||
|
|
||||||
if (registration !== existingRegistration) {
|
// Kurulumu tamamlanmış, aktif olandan farklı bir worker bekliyorsa
|
||||||
watchForUpdate(registration)
|
// doğrudan devreye al.
|
||||||
|
if (reg.waiting && navigator.serviceWorker.controller) {
|
||||||
|
activateWaitingWorker()
|
||||||
|
} else {
|
||||||
|
void runUpdateCheck(true)
|
||||||
}
|
}
|
||||||
currentRegistration = registration
|
|
||||||
checkForUpdate(registration)
|
|
||||||
|
|
||||||
// waiting yalnızca aktif worker'dan farklı, kurulumu tamamlanmış gerçek
|
|
||||||
// bir worker olduğunda bulunur. Onu doğrudan etkinleştir.
|
|
||||||
if (registration.waiting && navigator.serviceWorker.controller) {
|
|
||||||
activateWaitingWorker(registration, activateUpdate, true)
|
|
||||||
}
|
}
|
||||||
|
settle()
|
||||||
},
|
},
|
||||||
onNeedRefresh() {
|
onNeedRefresh() {
|
||||||
// Bu callback worker'ın bütün yeni JS/CSS dosyalarını başarıyla precache'e
|
// Yeni worker tüm kabuk dosyalarını precache'e aldıktan sonra çalışır.
|
||||||
// almasından sonra çalışır; aktivasyon ve reload atomik yapılır.
|
activateWaitingWorker()
|
||||||
activateWaitingWorker(currentRegistration, activateUpdate, true)
|
|
||||||
},
|
|
||||||
onNeedReload() {
|
|
||||||
reloadForUpdate()
|
|
||||||
},
|
|
||||||
onOfflineReady() {
|
|
||||||
console.log('📦 App offline ready')
|
|
||||||
},
|
},
|
||||||
onRegisterError(error) {
|
onRegisterError(error) {
|
||||||
activationInProgress = false
|
activating = false
|
||||||
clearActivationTimers()
|
clearActivationTimers()
|
||||||
hideUpdateOverlay()
|
teardownUpdateUi()
|
||||||
console.error('Service worker registration failed.', error)
|
console.error('Service worker kaydı başarısız.', error)
|
||||||
|
settle()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
activateUpdateHandler = activateUpdate
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const checkForAppUpdate = async (): Promise<ManualUpdateResult> => {
|
export const checkForAppUpdate = async (): Promise<ManualUpdateResult> => {
|
||||||
if (!('serviceWorker' in navigator)) return 'unsupported'
|
if (!('serviceWorker' in navigator)) return 'unsupported'
|
||||||
|
if (!isServiceWorkerEnabled) return 'disabled'
|
||||||
|
|
||||||
// App içindeki normal kayıt işlemi henüz bitmediyse önce onu tamamla.
|
await initialization
|
||||||
await registrationInitialization
|
// `navigator.serviceWorker.ready` kayıt yokken (ör. dev ortamı) hiçbir zaman
|
||||||
const registration =
|
// resolve etmez; getRegistration() kayıt yoksa undefined döner.
|
||||||
currentRegistration ?? (await navigator.serviceWorker.ready.catch(() => undefined))
|
const reg =
|
||||||
|
registration ?? (await navigator.serviceWorker.getRegistration().catch(() => undefined))
|
||||||
|
if (!reg) return 'unsupported'
|
||||||
|
|
||||||
if (!registration) return 'unsupported'
|
registration ??= reg
|
||||||
|
watchRegistration(reg)
|
||||||
|
|
||||||
|
if (reg.waiting && navigator.serviceWorker.controller) {
|
||||||
|
activateWaitingWorker()
|
||||||
|
return 'updating'
|
||||||
|
}
|
||||||
|
|
||||||
let updateFound = false
|
let updateFound = false
|
||||||
const handleUpdateFound = () => {
|
const handleUpdateFound = () => {
|
||||||
updateFound = true
|
updateFound = true
|
||||||
}
|
}
|
||||||
registration.addEventListener('updatefound', handleUpdateFound)
|
|
||||||
|
let staleRegistrationCleared = false
|
||||||
|
reg.addEventListener('updatefound', handleUpdateFound)
|
||||||
try {
|
try {
|
||||||
await registration.update()
|
await withTimeout(reg.update(), UPDATE_CALL_TIMEOUT)
|
||||||
|
} catch (error: unknown) {
|
||||||
|
staleRegistrationCleared = await unregisterIfStale(reg, error)
|
||||||
|
if (!staleRegistrationCleared) {
|
||||||
|
console.warn('Manuel güncelleme kontrolü başarısız.', error)
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
registration.removeEventListener('updatefound', handleUpdateFound)
|
reg.removeEventListener('updatefound', handleUpdateFound)
|
||||||
|
lastCheckAt = Date.now()
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateAvailable = Boolean(updateFound || registration.installing || registration.waiting)
|
// Bozuk kayıt temizlendi; bir sonraki açılışta yeni worker kaydedilecek.
|
||||||
if (!updateAvailable) return 'up-to-date'
|
if (staleRegistrationCleared) return 'up-to-date'
|
||||||
|
|
||||||
if (registration.waiting && activateUpdateHandler) {
|
if (!updateFound && !reg.installing && !reg.waiting) return 'up-to-date'
|
||||||
activateWaitingWorker(registration, activateUpdateHandler, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// installing durumundaysa gerçek onNeedRefresh callback'i indirme bittikten
|
if (reg.waiting && navigator.serviceWorker.controller) activateWaitingWorker()
|
||||||
// sonra aynı overlay/aktivasyon akışını başlatır.
|
// `installing` durumundaysa statechange dinleyicisi aynı akışı sürdürür.
|
||||||
return 'updating'
|
return 'updating'
|
||||||
}
|
}
|
||||||
|
|
|
||||||
77
ui/src/views/version/useAppVersionNotice.tsx
Normal file
77
ui/src/views/version/useAppVersionNotice.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { ROUTES_ENUM } from '@/routes/route.constant'
|
||||||
|
import { useStoreActions } from '@/store'
|
||||||
|
import { useLocalization } from '@/utils/hooks/useLocalization'
|
||||||
|
import { consumeAppUpdatedVersion } from './swRegistration'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Açılışta çalışan tek sürüm kaynağı:
|
||||||
|
* - `version.json`'daki güncel sürümü store'a yazar (footer/changelog bunu okur).
|
||||||
|
* - Service worker güncellemesi sonrası yenilenen sayfada "güncellendi"
|
||||||
|
* bildirimini gösterip changelog'a yönlendirme sunar.
|
||||||
|
*
|
||||||
|
* Daha önce bu işi Login ekranı yapıyordu; store'daki başlangıç sürümü
|
||||||
|
* `undefined` olduğu için her girişte changelog'a zorla yönlendiriyordu.
|
||||||
|
*/
|
||||||
|
export const useAppVersionNotice = () => {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { translate } = useLocalization()
|
||||||
|
const setUiVersion = useStoreActions((actions) => actions.locale.setUiVersion)
|
||||||
|
const handled = useRef(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (handled.current) return
|
||||||
|
handled.current = true
|
||||||
|
|
||||||
|
const label = (key: string, fallback: string) => {
|
||||||
|
const value = translate(key)
|
||||||
|
return !value || value === key || value.includes('::') ? fallback : value
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedVersion = consumeAppUpdatedVersion()
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
fetch(`/version.json?ts=${Date.now()}`, { cache: 'no-store', signal: controller.signal })
|
||||||
|
.then((response) => (response.ok ? response.json() : undefined))
|
||||||
|
.then(async (data?: { releases?: { version: string }[] }) => {
|
||||||
|
const latest = data?.releases?.[0]?.version
|
||||||
|
if (latest) setUiVersion(latest)
|
||||||
|
|
||||||
|
if (!updatedVersion) return
|
||||||
|
|
||||||
|
// toast/Notification (ve bağımlısı framer-motion) yalnızca güncelleme
|
||||||
|
// sonrası bu tek seferlik bildirimde gerekiyor; entry chunk'ta taşınmaz.
|
||||||
|
const [{ default: toast }, { default: Notification }] = await Promise.all([
|
||||||
|
import('@/components/ui/toast'),
|
||||||
|
import('@/components/ui/Notification'),
|
||||||
|
])
|
||||||
|
|
||||||
|
toast.push(
|
||||||
|
<Notification
|
||||||
|
closable
|
||||||
|
duration={10000}
|
||||||
|
title={label('::App.UpdateCompleted', 'Uygulama güncellendi')}
|
||||||
|
type="success"
|
||||||
|
>
|
||||||
|
{label('::App.UpdatedToVersion', 'Yeni sürüm')} v{updatedVersion} —{' '}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="underline"
|
||||||
|
onClick={() => navigate(ROUTES_ENUM.protected.admin.changeLog)}
|
||||||
|
>
|
||||||
|
{label('::App.ChangeLog', 'Değişiklikleri gör')}
|
||||||
|
</button>
|
||||||
|
</Notification>,
|
||||||
|
{ placement: 'bottom-end' },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* sürüm bilgisi okunamadıysa sessiz geç */
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => controller.abort()
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useAppVersionNotice
|
||||||
3
ui/src/vite-env.d.ts
vendored
3
ui/src/vite-env.d.ts
vendored
|
|
@ -7,6 +7,9 @@ interface ImportMetaEnv {
|
||||||
readonly VITE_REACT_APP_VERSION: string
|
readonly VITE_REACT_APP_VERSION: string
|
||||||
readonly VITE_AI_URL: string
|
readonly VITE_AI_URL: string
|
||||||
readonly VITE_GOOGLE_MAPS_API_KEY: string
|
readonly VITE_GOOGLE_MAPS_API_KEY: string
|
||||||
|
readonly VITE_USE_POLLING?: string
|
||||||
|
/** Dev sunucusunda service worker'ı açar (varsayılan kapalı). */
|
||||||
|
readonly VITE_PWA_DEV?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,47 @@ import { defineConfig, loadEnv } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { VitePWA } from 'vite-plugin-pwa'
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
// import { fetchTenantNames } from './src/services/tenants'
|
|
||||||
|
|
||||||
export default defineConfig(async ({ mode }) => {
|
/**
|
||||||
|
* Precache stratejisi
|
||||||
|
* ------------------------------------------------------------------
|
||||||
|
* `dist` klasörü ~55 MB (48 adet DevExtreme teması ~33 MB + lazy chunk'lar
|
||||||
|
* ~20 MB). Bunların tamamını precache etmek her deploy'da service worker'ın
|
||||||
|
* on-larca MB indirmesine, mobil/yavaş bağlantıda kurulumun timeout'a düşüp
|
||||||
|
* worker'ın `redundant` olmasına ve güncellemenin hiç tamamlanmamasına yol
|
||||||
|
* açıyordu.
|
||||||
|
*
|
||||||
|
* Bu yüzden precache SADECE uygulama kabuğudur (index.html + entry JS + CSS,
|
||||||
|
* ~2.8 MB). Geri kalan her şey (lazy chunk, tema CSS'i, görsel, font) runtime
|
||||||
|
* cache ile ilk kullanımda saklanır. Dosya adları hash'li olduğu için
|
||||||
|
* CacheFirst güvenlidir; hash'siz public dosyalarda StaleWhileRevalidate
|
||||||
|
* kullanılır.
|
||||||
|
*/
|
||||||
|
const PRECACHE_GLOBS = [
|
||||||
|
'index.html',
|
||||||
|
// manifest.webmanifest ve manifest ikonları vite-plugin-pwa tarafından
|
||||||
|
// otomatik eklenir; buraya yazmak workbox'ta "conflicting entries" hatası verir.
|
||||||
|
'favicon.ico',
|
||||||
|
'assets/js/entry-*.js',
|
||||||
|
// Sadece entry CSS'i. Rapor tasarımcısı/görüntüleyici CSS'leri (~580 KB)
|
||||||
|
// lazy yüklendiği için runtime cache'e bırakılır.
|
||||||
|
'assets/css/index-*.css',
|
||||||
|
]
|
||||||
|
|
||||||
|
// Deploy'da silinen eski build dosyalarını precache manifestine sokmamak için
|
||||||
|
// runtime cache'e alınacak hash'li uygulama varlıkları.
|
||||||
|
const HASHED_ASSET_PATTERN = /\/assets\/.+\.(?:js|css|wasm)$/
|
||||||
|
|
||||||
|
export default defineConfig(({ mode }) => {
|
||||||
const env = loadEnv(mode, process.cwd(), '')
|
const env = loadEnv(mode, process.cwd(), '')
|
||||||
const apiUrl = env.VITE_API_URL
|
const isProduction = mode === 'production'
|
||||||
const baseDomains = ['sozsoft.com', 'dev.sozsoft.com', '.sozsoft.com']
|
const usePolling = env.VITE_USE_POLLING === 'true'
|
||||||
const tenantHosts: string[] = []
|
// Dev'de service worker varsayılan olarak kapalıdır; açık olduğunda HMR
|
||||||
|
// sonrası eski chunk'lar cache'den servis edilip kafa karıştırıyor.
|
||||||
|
const enableDevSw = env.VITE_PWA_DEV === 'true'
|
||||||
|
|
||||||
const allowedHosts = Array.from(new Set(['localhost', ...baseDomains, ...tenantHosts]))
|
const baseDomains = ['sozsoft.com', 'dev.sozsoft.com', '.sozsoft.com']
|
||||||
console.log('> [vite] allowedHosts:', allowedHosts)
|
const allowedHosts = Array.from(new Set(['localhost', ...baseDomains]))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
plugins: [
|
plugins: [
|
||||||
|
|
@ -20,87 +51,83 @@ export default defineConfig(async ({ mode }) => {
|
||||||
// Yeni sürüm önce tamamen indirilir, ardından uygulama kontrollü olarak
|
// Yeni sürüm önce tamamen indirilir, ardından uygulama kontrollü olarak
|
||||||
// yeni service worker'a geçer. Böylece eski ekran ile yeni chunk'lar karışmaz.
|
// yeni service worker'a geçer. Böylece eski ekran ile yeni chunk'lar karışmaz.
|
||||||
registerType: 'prompt',
|
registerType: 'prompt',
|
||||||
// Kayıt ve loading akışı src/views/version/swRegistration.ts tarafından yönetilir.
|
// Kayıt ve güncelleme akışı src/views/version/swRegistration.ts tarafından yönetilir.
|
||||||
injectRegister: false,
|
injectRegister: false,
|
||||||
// Dev ortamında SW'yi aç, prod'da kapalı tut (build edilmiş SW prod'da zaten aktif olur)
|
|
||||||
devOptions: {
|
devOptions: {
|
||||||
enabled: mode !== 'production',
|
enabled: enableDevSw,
|
||||||
type: 'module', // Modern module worker kullan
|
type: 'module',
|
||||||
},
|
},
|
||||||
|
|
||||||
workbox:
|
workbox: {
|
||||||
mode === 'production'
|
|
||||||
? {
|
|
||||||
globDirectory: 'dist',
|
globDirectory: 'dist',
|
||||||
// Eski bir sekme yeni deploy sırasında henüz açmadığı lazy
|
globPatterns: PRECACHE_GLOBS,
|
||||||
// chunk'ları da çalıştırabilsin; tüm uygulama dosyalarını sakla.
|
|
||||||
globPatterns: ['**/*.{js,css,html,wasm}'],
|
|
||||||
|
|
||||||
// Büyük asset'leri de cache'leyebil
|
// Kabuk dosyaları büyük olabilir (entry CSS ~1.3 MB).
|
||||||
maximumFileSizeToCacheInBytes: 15 * 1024 * 1024,
|
maximumFileSizeToCacheInBytes: 4 * 1024 * 1024,
|
||||||
|
|
||||||
// Yeni worker tüm precache dosyalarını indirdikten sonra uygulama
|
// Yeni worker precache'i bitirdikten sonra uygulama SKIP_WAITING
|
||||||
// SKIP_WAITING mesajını gönderir. clientsClaim açık sekmeleri o anda yeniler.
|
// gönderir; clientsClaim açık sekmelerin kontrolünü o an devralır.
|
||||||
clientsClaim: true,
|
clientsClaim: true,
|
||||||
skipWaiting: false,
|
skipWaiting: false,
|
||||||
|
|
||||||
// Eski workbox cache'lerini temizle
|
|
||||||
cleanupOutdatedCaches: true,
|
cleanupOutdatedCaches: true,
|
||||||
|
|
||||||
// SPA fallback'i API çağrılarına uygulama
|
navigateFallback: '/index.html',
|
||||||
navigateFallbackDenylist: [/^\/api\//],
|
navigateFallbackAllowlist: [/^(?!\/__).*/],
|
||||||
|
navigateFallbackDenylist: [/^\/api\//, /^\/connect\//, /^\/swagger/, /\/version\.json$/],
|
||||||
|
|
||||||
runtimeCaching: [
|
runtimeCaching: [
|
||||||
{
|
{
|
||||||
urlPattern: /\.(?:js|css|wasm)$/,
|
// Sürüm/changelog bilgisi asla eski kalmamalı.
|
||||||
// Hash'li build dosyaları immutable'dır. Normal kullanımda ağ
|
|
||||||
// beklenmez; deploy güncellemesini precache manifest'i yönetir.
|
|
||||||
handler: 'CacheFirst',
|
|
||||||
options: {
|
|
||||||
cacheName: 'static-resources-v2',
|
|
||||||
expiration: {
|
|
||||||
maxEntries: 200,
|
|
||||||
maxAgeSeconds: 30 * 24 * 60 * 60,
|
|
||||||
},
|
|
||||||
cacheableResponse: {
|
|
||||||
statuses: [0, 200],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// Changelog/versiyon bilgisi cache yüzünden eski kalmamalı.
|
|
||||||
urlPattern: /\/version\.json$/,
|
urlPattern: /\/version\.json$/,
|
||||||
handler: 'NetworkFirst',
|
handler: 'NetworkFirst',
|
||||||
options: {
|
options: {
|
||||||
cacheName: 'app-version-v1',
|
cacheName: 'app-version-v2',
|
||||||
networkTimeoutSeconds: 3,
|
networkTimeoutSeconds: 3,
|
||||||
expiration: {
|
expiration: { maxEntries: 2, maxAgeSeconds: 24 * 60 * 60 },
|
||||||
maxEntries: 5,
|
cacheableResponse: { statuses: [0, 200] },
|
||||||
maxAgeSeconds: 24 * 60 * 60,
|
|
||||||
},
|
|
||||||
cacheableResponse: {
|
|
||||||
statuses: [0, 200],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp|ico)$/,
|
// Hash'li build çıktıları immutable'dır.
|
||||||
|
urlPattern: HASHED_ASSET_PATTERN,
|
||||||
handler: 'CacheFirst',
|
handler: 'CacheFirst',
|
||||||
options: {
|
options: {
|
||||||
cacheName: 'images',
|
cacheName: 'app-assets-v3',
|
||||||
expiration: {
|
expiration: { maxEntries: 400, maxAgeSeconds: 60 * 24 * 60 * 60 },
|
||||||
maxEntries: 100,
|
cacheableResponse: { statuses: [0, 200] },
|
||||||
maxAgeSeconds: 30 * 24 * 60 * 60, // 30 gün
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// public/css altındaki DevExtreme temaları hash'siz; içerik
|
||||||
|
// sürümle değişebildiği için arka planda tazelenir.
|
||||||
|
urlPattern: /\/css\/[\w.-]+\.css$/,
|
||||||
|
handler: 'StaleWhileRevalidate',
|
||||||
|
options: {
|
||||||
|
cacheName: 'dx-themes-v1',
|
||||||
|
expiration: { maxEntries: 12, maxAgeSeconds: 30 * 24 * 60 * 60 },
|
||||||
|
cacheableResponse: { statuses: [0, 200] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
urlPattern: /\.(?:woff2?|ttf|otf|eot)$/,
|
||||||
|
handler: 'CacheFirst',
|
||||||
|
options: {
|
||||||
|
cacheName: 'fonts-v1',
|
||||||
|
expiration: { maxEntries: 40, maxAgeSeconds: 180 * 24 * 60 * 60 },
|
||||||
|
cacheableResponse: { statuses: [0, 200] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
urlPattern: /\.(?:png|jpe?g|svg|gif|webp|ico)$/,
|
||||||
|
handler: 'CacheFirst',
|
||||||
|
options: {
|
||||||
|
cacheName: 'images-v2',
|
||||||
|
expiration: { maxEntries: 150, maxAgeSeconds: 30 * 24 * 60 * 60 },
|
||||||
|
cacheableResponse: { statuses: [0, 200] },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
},
|
||||||
// ⭐ YENİ EKLENEN: Additional navigation route for SPA
|
|
||||||
navigateFallback: '/index.html',
|
|
||||||
navigateFallbackAllowlist: [/^(?!\/__).*/],
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
|
|
||||||
manifest: {
|
manifest: {
|
||||||
name: 'Sözsoft Platform',
|
name: 'Sözsoft Platform',
|
||||||
|
|
@ -137,10 +164,14 @@ export default defineConfig(async ({ mode }) => {
|
||||||
server: {
|
server: {
|
||||||
open: true,
|
open: true,
|
||||||
port: 3000,
|
port: 3000,
|
||||||
// ⭐ YENİ EKLENEN: Hot reload için polling
|
|
||||||
watch: {
|
watch: {
|
||||||
usePolling: env.VITE_USE_POLLING === 'true',
|
usePolling,
|
||||||
interval: env.VITE_USE_POLLING === 'true' ? 1000 : undefined,
|
interval: usePolling ? 1000 : undefined,
|
||||||
|
},
|
||||||
|
// Sık dokunulan giriş dosyalarını önceden transform ederek ilk
|
||||||
|
// gezinmedeki dev sunucu gecikmesini azalt.
|
||||||
|
warmup: {
|
||||||
|
clientFiles: ['./src/main.tsx', './src/App.tsx', './src/components/layouts/index.ts'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -154,24 +185,85 @@ export default defineConfig(async ({ mode }) => {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
optimizeDeps: {
|
||||||
|
// Barrel/CJS ağırlıklı paketleri dev cold-start'ta tek seferde
|
||||||
|
// prebundle et; aksi halde ilk sayfa yüzlerce ayrı istek atıyor.
|
||||||
|
include: [
|
||||||
|
'react',
|
||||||
|
'react-dom',
|
||||||
|
'react-dom/client',
|
||||||
|
'react-router-dom',
|
||||||
|
'@tanstack/react-query',
|
||||||
|
'@tanstack/react-table',
|
||||||
|
'axios',
|
||||||
|
'dayjs',
|
||||||
|
'formik',
|
||||||
|
'yup',
|
||||||
|
'classnames',
|
||||||
|
'lodash',
|
||||||
|
'easy-peasy',
|
||||||
|
'framer-motion',
|
||||||
|
'react-icons/fa',
|
||||||
|
// CJS/UMD bundle. `exclude` edilirse interop bozulup
|
||||||
|
// "Babel.transform is not a function" hatası veriyor. Ayrıca burada
|
||||||
|
// listelenince dinamik import anındaki "new dependency optimized"
|
||||||
|
// kaynaklı sayfa yenilemesi de olmuyor.
|
||||||
|
'@babel/standalone',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
esbuild: {
|
||||||
|
legalComments: 'none',
|
||||||
|
drop: isProduction ? ['debugger'] : [],
|
||||||
|
},
|
||||||
|
|
||||||
build: {
|
build: {
|
||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
|
// Vite'ın varsayılan 'baseline-widely-available' hedefi bilerek korunuyor:
|
||||||
|
// es2022'ye sabitlemek esbuild'i downlevel'a zorlayıp entry chunk'ı
|
||||||
|
// ~200 KB büyütüyor (ölçüldü).
|
||||||
chunkSizeWarningLimit: 2000,
|
chunkSizeWarningLimit: 2000,
|
||||||
sourcemap: false,
|
sourcemap: false,
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
|
cssCodeSplit: true,
|
||||||
|
// Modern tarayıcı hedefinde polyfill gereksiz; entry'den birkaç KB düşer.
|
||||||
|
modulePreload: { polyfill: false },
|
||||||
|
// Build sonunda gzip boyutu hesaplamak bu boyutta bir çıktıda dakikalar sürüyor.
|
||||||
|
reportCompressedSize: false,
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
output: {
|
output: {
|
||||||
entryFileNames: `assets/js/[name]-[hash].js`,
|
// `entry-` öneki precache glob'unun sadece gerçek entry chunk'ı
|
||||||
chunkFileNames: `assets/js/[name]-[hash].js`,
|
// yakalamasını sağlar (node_modules kaynaklı `index-*.js` chunk'ları
|
||||||
assetFileNames: ({ name }) => {
|
// yanlışlıkla precache'e girmesin).
|
||||||
if (/\.(css)$/.test(name ?? '')) {
|
entryFileNames: 'assets/js/entry-[name]-[hash].js',
|
||||||
return 'assets/css/[name]-[hash][extname]'
|
chunkFileNames: 'assets/js/[name]-[hash].js',
|
||||||
}
|
assetFileNames: ({ name }: { name?: string }) => {
|
||||||
|
if (/\.css$/.test(name ?? '')) return 'assets/css/[name]-[hash][extname]'
|
||||||
if (/\.(png|jpe?g|svg|gif|ico|webp)$/.test(name ?? '')) {
|
if (/\.(png|jpe?g|svg|gif|ico|webp)$/.test(name ?? '')) {
|
||||||
return 'assets/img/[name]-[hash][extname]'
|
return 'assets/img/[name]-[hash][extname]'
|
||||||
}
|
}
|
||||||
|
if (/\.(woff2?|ttf|otf|eot)$/.test(name ?? '')) {
|
||||||
|
return 'assets/fonts/[name]-[hash][extname]'
|
||||||
|
}
|
||||||
return 'assets/[name]-[hash][extname]'
|
return 'assets/[name]-[hash][extname]'
|
||||||
},
|
},
|
||||||
|
// SADECE her açılışta zaten yüklenen (%100 eager) çekirdek vendor'lar
|
||||||
|
// ayrılır; bunlar sürümler arası değişmediği için tarayıcı cache'inde
|
||||||
|
// kalır ve deploy sonrası yeniden indirilmez.
|
||||||
|
//
|
||||||
|
// Not: framer-motion / lodash / formik gibi paketleri de buraya eklemek
|
||||||
|
// ölçüldü ve zararlı çıktı — Rollup'ın otomatik chunk'lamasında bu
|
||||||
|
// paketlerin bir kısmı yalnızca lazy view'lara giriyor; tek chunk'a
|
||||||
|
// toplandıklarında ilk yükleme ~650 KB büyüyor.
|
||||||
|
manualChunks(id) {
|
||||||
|
if (!id.includes('node_modules')) return undefined
|
||||||
|
const file = id.replace(/\\/g, '/')
|
||||||
|
if (/\/node_modules\/(react|react-dom|scheduler)\//.test(file)) return 'vendor-react'
|
||||||
|
if (/\/node_modules\/(react-router|react-router-dom|@remix-run)\//.test(file)) {
|
||||||
|
return 'vendor-router'
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -180,16 +272,11 @@ export default defineConfig(async ({ mode }) => {
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
port: 80,
|
port: 80,
|
||||||
open: false,
|
open: false,
|
||||||
// Hesaplanan allowedHosts'u kullan
|
|
||||||
allowedHosts,
|
allowedHosts,
|
||||||
},
|
},
|
||||||
|
|
||||||
define: {
|
define: {
|
||||||
'process.env': {},
|
'process.env': {},
|
||||||
// ⭐ YENİ EKLENEN: Version tracking için global değişkenler
|
|
||||||
__APP_VERSION__: JSON.stringify(process.env.VITE_APP_VERSION || '1.0.0'),
|
|
||||||
__BUILD_DATE__: JSON.stringify(new Date().toISOString()),
|
|
||||||
__APP_MODE__: JSON.stringify(mode),
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue