mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Chore: Basic Layout/Chrome + Stubs (WIP)
This commit is contained in:
+3
-1
@@ -28,6 +28,8 @@
|
|||||||
"vite": "^8.0.7"
|
"vite": "^8.0.7"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.0.0"
|
"@tauri-apps/api": "^2.0.0",
|
||||||
|
"@tauri-apps/plugin-os": "^2.3.2",
|
||||||
|
"phosphor-svelte": "^3.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Generated
+37
@@ -11,6 +11,12 @@ importers:
|
|||||||
'@tauri-apps/api':
|
'@tauri-apps/api':
|
||||||
specifier: ^2.0.0
|
specifier: ^2.0.0
|
||||||
version: 2.11.0
|
version: 2.11.0
|
||||||
|
'@tauri-apps/plugin-os':
|
||||||
|
specifier: ^2.3.2
|
||||||
|
version: 2.3.2
|
||||||
|
phosphor-svelte:
|
||||||
|
specifier: ^3.1.0
|
||||||
|
version: 3.1.0(svelte@5.55.5(@typescript-eslint/types@8.57.1))(vite@8.0.10)
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@sveltejs/adapter-node':
|
'@sveltejs/adapter-node':
|
||||||
specifier: ^5.5.4
|
specifier: ^5.5.4
|
||||||
@@ -471,6 +477,9 @@ packages:
|
|||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
'@tauri-apps/plugin-os@2.3.2':
|
||||||
|
resolution: {integrity: sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A==}
|
||||||
|
|
||||||
'@tybys/wasm-util@0.10.1':
|
'@tybys/wasm-util@0.10.1':
|
||||||
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
|
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
|
||||||
|
|
||||||
@@ -550,6 +559,9 @@ packages:
|
|||||||
estree-walker@2.0.2:
|
estree-walker@2.0.2:
|
||||||
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
|
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
|
||||||
|
|
||||||
|
estree-walker@3.0.3:
|
||||||
|
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
|
||||||
|
|
||||||
fdir@6.5.0:
|
fdir@6.5.0:
|
||||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
@@ -687,6 +699,15 @@ packages:
|
|||||||
path-parse@1.0.7:
|
path-parse@1.0.7:
|
||||||
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
|
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
|
||||||
|
|
||||||
|
phosphor-svelte@3.1.0:
|
||||||
|
resolution: {integrity: sha512-nldtxx+XCgNREvrb7O5xgDsefytXpSkPTx8Rnu3f2qQCUZLDV1rLxYSd2Jcwckuo9lZB1qKMqGR17P4UDC0PrA==}
|
||||||
|
peerDependencies:
|
||||||
|
svelte: ^5.0.0 || ^5.0.0-next.96
|
||||||
|
vite: '>=5'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
vite:
|
||||||
|
optional: true
|
||||||
|
|
||||||
picocolors@1.1.1:
|
picocolors@1.1.1:
|
||||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||||
|
|
||||||
@@ -1124,6 +1145,10 @@ snapshots:
|
|||||||
'@tauri-apps/cli-win32-ia32-msvc': 2.11.2
|
'@tauri-apps/cli-win32-ia32-msvc': 2.11.2
|
||||||
'@tauri-apps/cli-win32-x64-msvc': 2.11.2
|
'@tauri-apps/cli-win32-x64-msvc': 2.11.2
|
||||||
|
|
||||||
|
'@tauri-apps/plugin-os@2.3.2':
|
||||||
|
dependencies:
|
||||||
|
'@tauri-apps/api': 2.11.0
|
||||||
|
|
||||||
'@tybys/wasm-util@0.10.1':
|
'@tybys/wasm-util@0.10.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
@@ -1176,6 +1201,10 @@ snapshots:
|
|||||||
|
|
||||||
estree-walker@2.0.2: {}
|
estree-walker@2.0.2: {}
|
||||||
|
|
||||||
|
estree-walker@3.0.3:
|
||||||
|
dependencies:
|
||||||
|
'@types/estree': 1.0.8
|
||||||
|
|
||||||
fdir@6.5.0(picomatch@4.0.4):
|
fdir@6.5.0(picomatch@4.0.4):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
picomatch: 4.0.4
|
picomatch: 4.0.4
|
||||||
@@ -1270,6 +1299,14 @@ snapshots:
|
|||||||
|
|
||||||
path-parse@1.0.7: {}
|
path-parse@1.0.7: {}
|
||||||
|
|
||||||
|
phosphor-svelte@3.1.0(svelte@5.55.5(@typescript-eslint/types@8.57.1))(vite@8.0.10):
|
||||||
|
dependencies:
|
||||||
|
estree-walker: 3.0.3
|
||||||
|
magic-string: 0.30.21
|
||||||
|
svelte: 5.55.5(@typescript-eslint/types@8.57.1)
|
||||||
|
optionalDependencies:
|
||||||
|
vite: 8.0.10
|
||||||
|
|
||||||
picocolors@1.1.1: {}
|
picocolors@1.1.1: {}
|
||||||
|
|
||||||
picomatch@4.0.4: {}
|
picomatch@4.0.4: {}
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
import { initRequestManager } from '$lib/request-manager'
|
import { initRequestManager } from '$lib/request-manager'
|
||||||
import { initPlatformService } from '$lib/platform-service'
|
import { initPlatformService } from '$lib/platform-service'
|
||||||
import { appState } from '$lib/state/app.svelte'
|
import { appState } from '$lib/state/app.svelte'
|
||||||
|
import { configureAuth, probeServer } from '$lib/core/auth'
|
||||||
|
|
||||||
|
const SAVED_URL_KEY = 'moku_server_url'
|
||||||
|
const SAVED_AUTH_KEY = 'moku_auth_config'
|
||||||
|
|
||||||
|
interface SavedAuth {
|
||||||
|
mode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN'
|
||||||
|
user?: string
|
||||||
|
pass?: string
|
||||||
|
}
|
||||||
|
|
||||||
function isTauri(): boolean {
|
function isTauri(): boolean {
|
||||||
return '__TAURI_INTERNALS__' in window
|
return '__TAURI_INTERNALS__' in window
|
||||||
@@ -10,6 +20,18 @@ function isCapacitor(): boolean {
|
|||||||
return 'Capacitor' in window
|
return 'Capacitor' in window
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadSavedServerUrl(): string {
|
||||||
|
return localStorage.getItem(SAVED_URL_KEY) ?? 'http://127.0.0.1:4567'
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadSavedAuth(): SavedAuth {
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem(SAVED_AUTH_KEY) ?? 'null') ?? { mode: 'NONE' }
|
||||||
|
} catch {
|
||||||
|
return { mode: 'NONE' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function resolvePlatformAdapter() {
|
async function resolvePlatformAdapter() {
|
||||||
if (isTauri()) {
|
if (isTauri()) {
|
||||||
const { TauriAdapter } = await import('$lib/platform-adapters/tauri')
|
const { TauriAdapter } = await import('$lib/platform-adapters/tauri')
|
||||||
@@ -40,6 +62,34 @@ async function boot() {
|
|||||||
|
|
||||||
appState.platform = isTauri() ? 'tauri' : isCapacitor() ? 'capacitor' : 'web'
|
appState.platform = isTauri() ? 'tauri' : isCapacitor() ? 'capacitor' : 'web'
|
||||||
appState.version = await platformAdapter.getVersion()
|
appState.version = await platformAdapter.getVersion()
|
||||||
|
|
||||||
|
const savedUrl = loadSavedServerUrl()
|
||||||
|
const savedAuth = loadSavedAuth()
|
||||||
|
|
||||||
|
appState.serverUrl = savedUrl
|
||||||
|
appState.authMode = savedAuth.mode
|
||||||
|
|
||||||
|
if (isTauri() && platformAdapter.isSupported('server-management')) {
|
||||||
|
await platformAdapter.launchServer({ url: savedUrl }).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
configureAuth(savedUrl, savedAuth.mode, savedAuth.user, savedAuth.pass)
|
||||||
|
await serverAdapter.connect({ baseUrl: savedUrl })
|
||||||
|
|
||||||
|
const probe = await probeServer()
|
||||||
|
|
||||||
|
if (probe === 'auth_required') {
|
||||||
|
appState.status = 'auth'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (probe === 'unreachable') {
|
||||||
|
appState.error = `Could not reach server at ${savedUrl}`
|
||||||
|
appState.status = 'error'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
appState.authenticated = true
|
||||||
appState.status = 'ready'
|
appState.status = 'ready'
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
appState.error = String(e)
|
appState.error = String(e)
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<rect width="512" height="512" fill="#091209"/>
|
||||||
|
<g transform="translate(256,265) rotate(7) scale(0.098,-0.098) translate(-5000,-4800)" fill="#2d7a5f" stroke="none">
|
||||||
|
<path d="M4908 6615 c-597 -629 -935 -1264 -934 -1755 0 -508 195 -778 790
|
||||||
|
-1098 l194 -104 22 -114 c33 -164 76 -271 140 -347 80 -94 106 -62 77 94 -47
|
||||||
|
255 43 443 294 613 797 539 714 1392 -250 2551 -230 278 -224 275 -333 160z
|
||||||
|
m52 -945 c0 -472 -8 -850 -17 -850 -36 0 -693 703 -692 740 2 48 167 321 309
|
||||||
|
509 175 233 359 451 380 451 13 0 20 -304 20 -850z m183 775 c120 -126 357
|
||||||
|
-447 356 -482 0 -18 -47 -83 -105 -144 -57 -61 -151 -163 -208 -225 -151 -166
|
||||||
|
-146 -180 -146 406 0 286 7 520 16 520 9 0 48 -34 87 -75z m541 -750 c86 -150
|
||||||
|
196 -391 196 -430 0 -8 -25 -42 -55 -75 -31 -33 -149 -163 -264 -290 -339
|
||||||
|
-374 -480 -520 -501 -520 -13 0 -20 167 -20 465 l1 465 151 170 c83 94 193
|
||||||
|
217 244 275 122 138 137 135 248 -60z m-1098 -633 l374 -378 0 -462 c0 -254
|
||||||
|
-5 -462 -11 -462 -6 0 -55 23 -110 50 l-99 51 0 208 c0 259 -11 288 -176 457
|
||||||
|
-196 200 -188 199 -326 62 l-117 -116 -35 79 c-71 161 -39 569 64 816 42 100
|
||||||
|
20 116 436 -305z m1349 23 c109 -390 -87 -848 -475 -1111 -207 -139 -305 -262
|
||||||
|
-338 -420 -7 -31 -21 -56 -32 -56 -36 -1 -46 86 -48 405 l-2 313 123 137 c68
|
||||||
|
75 214 236 325 357 454 493 421 466 447 375z m-1292 -785 c12 -29 15 -118 7
|
||||||
|
-215 l-14 -166 -73 44 c-174 104 -403 341 -403 416 0 15 47 70 105 123 l104
|
||||||
|
98 127 -125 c70 -69 136 -147 147 -175z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,21 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<g transform="translate(256,265) rotate(7) scale(0.098,-0.098) translate(-5000,-4800)" fill="#2d7a5f" stroke="none">
|
||||||
|
<path d="M4908 6615 c-597 -629 -935 -1264 -934 -1755 0 -508 195 -778 790
|
||||||
|
-1098 l194 -104 22 -114 c33 -164 76 -271 140 -347 80 -94 106 -62 77 94 -47
|
||||||
|
255 43 443 294 613 797 539 714 1392 -250 2551 -230 278 -224 275 -333 160z
|
||||||
|
m52 -945 c0 -472 -8 -850 -17 -850 -36 0 -693 703 -692 740 2 48 167 321 309
|
||||||
|
509 175 233 359 451 380 451 13 0 20 -304 20 -850z m183 775 c120 -126 357
|
||||||
|
-447 356 -482 0 -18 -47 -83 -105 -144 -57 -61 -151 -163 -208 -225 -151 -166
|
||||||
|
-146 -180 -146 406 0 286 7 520 16 520 9 0 48 -34 87 -75z m541 -750 c86 -150
|
||||||
|
196 -391 196 -430 0 -8 -25 -42 -55 -75 -31 -33 -149 -163 -264 -290 -339
|
||||||
|
-374 -480 -520 -501 -520 -13 0 -20 167 -20 465 l1 465 151 170 c83 94 193
|
||||||
|
217 244 275 122 138 137 135 248 -60z m-1098 -633 l374 -378 0 -462 c0 -254
|
||||||
|
-5 -462 -11 -462 -6 0 -55 23 -110 50 l-99 51 0 208 c0 259 -11 288 -176 457
|
||||||
|
-196 200 -188 199 -326 62 l-117 -116 -35 79 c-71 161 -39 569 64 816 42 100
|
||||||
|
20 116 436 -305z m1349 23 c109 -390 -87 -848 -475 -1111 -207 -139 -305 -262
|
||||||
|
-338 -420 -7 -31 -21 -56 -32 -56 -36 -1 -46 86 -48 405 l-2 313 123 137 c68
|
||||||
|
75 214 236 325 357 454 493 421 466 447 375z m-1292 -785 c12 -29 15 -118 7
|
||||||
|
-215 l-14 -166 -73 44 c-174 104 -403 341 -403 416 0 15 47 70 105 123 l104
|
||||||
|
98 127 -125 c70 -69 136 -147 147 -175z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,21 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<g transform="translate(256,265) scale(0.098,-0.098) translate(-5000,-4800)" fill="#2d7a5f" stroke="none">
|
||||||
|
<path d="M4908 6615 c-597 -629 -935 -1264 -934 -1755 0 -508 195 -778 790
|
||||||
|
-1098 l194 -104 22 -114 c33 -164 76 -271 140 -347 80 -94 106 -62 77 94 -47
|
||||||
|
255 43 443 294 613 797 539 714 1392 -250 2551 -230 278 -224 275 -333 160z
|
||||||
|
m52 -945 c0 -472 -8 -850 -17 -850 -36 0 -693 703 -692 740 2 48 167 321 309
|
||||||
|
509 175 233 359 451 380 451 13 0 20 -304 20 -850z m183 775 c120 -126 357
|
||||||
|
-447 356 -482 0 -18 -47 -83 -105 -144 -57 -61 -151 -163 -208 -225 -151 -166
|
||||||
|
-146 -180 -146 406 0 286 7 520 16 520 9 0 48 -34 87 -75z m541 -750 c86 -150
|
||||||
|
196 -391 196 -430 0 -8 -25 -42 -55 -75 -31 -33 -149 -163 -264 -290 -339
|
||||||
|
-374 -480 -520 -501 -520 -13 0 -20 167 -20 465 l1 465 151 170 c83 94 193
|
||||||
|
217 244 275 122 138 137 135 248 -60z m-1098 -633 l374 -378 0 -462 c0 -254
|
||||||
|
-5 -462 -11 -462 -6 0 -55 23 -110 50 l-99 51 0 208 c0 259 -11 288 -176 457
|
||||||
|
-196 200 -188 199 -326 62 l-117 -116 -35 79 c-71 161 -39 569 64 816 42 100
|
||||||
|
20 116 436 -305z m1349 23 c109 -390 -87 -848 -475 -1111 -207 -139 -305 -262
|
||||||
|
-338 -420 -7 -31 -21 -56 -32 -56 -36 -1 -46 86 -48 405 l-2 313 123 137 c68
|
||||||
|
75 214 236 325 357 454 493 421 466 447 375z m-1292 -785 c12 -29 15 -118 7
|
||||||
|
-215 l-14 -166 -73 44 c-174 104 -403 341 -403 416 0 15 47 70 105 123 l104
|
||||||
|
98 127 -125 c70 -69 136 -147 147 -175z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,22 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<rect width="512" height="512" rx="112" ry="112" fill="#091209"/>
|
||||||
|
<g transform="translate(256,265) rotate(7) scale(0.098,-0.098) translate(-5000,-4800)" fill="#2d7a5f" stroke="none">
|
||||||
|
<path d="M4908 6615 c-597 -629 -935 -1264 -934 -1755 0 -508 195 -778 790
|
||||||
|
-1098 l194 -104 22 -114 c33 -164 76 -271 140 -347 80 -94 106 -62 77 94 -47
|
||||||
|
255 43 443 294 613 797 539 714 1392 -250 2551 -230 278 -224 275 -333 160z
|
||||||
|
m52 -945 c0 -472 -8 -850 -17 -850 -36 0 -693 703 -692 740 2 48 167 321 309
|
||||||
|
509 175 233 359 451 380 451 13 0 20 -304 20 -850z m183 775 c120 -126 357
|
||||||
|
-447 356 -482 0 -18 -47 -83 -105 -144 -57 -61 -151 -163 -208 -225 -151 -166
|
||||||
|
-146 -180 -146 406 0 286 7 520 16 520 9 0 48 -34 87 -75z m541 -750 c86 -150
|
||||||
|
196 -391 196 -430 0 -8 -25 -42 -55 -75 -31 -33 -149 -163 -264 -290 -339
|
||||||
|
-374 -480 -520 -501 -520 -13 0 -20 167 -20 465 l1 465 151 170 c83 94 193
|
||||||
|
217 244 275 122 138 137 135 248 -60z m-1098 -633 l374 -378 0 -462 c0 -254
|
||||||
|
-5 -462 -11 -462 -6 0 -55 23 -110 50 l-99 51 0 208 c0 259 -11 288 -176 457
|
||||||
|
-196 200 -188 199 -326 62 l-117 -116 -35 79 c-71 161 -39 569 64 816 42 100
|
||||||
|
20 116 436 -305z m1349 23 c109 -390 -87 -848 -475 -1111 -207 -139 -305 -262
|
||||||
|
-338 -420 -7 -31 -21 -56 -32 -56 -36 -1 -46 86 -48 405 l-2 313 123 137 c68
|
||||||
|
75 214 236 325 357 454 493 421 466 447 375z m-1292 -785 c12 -29 15 -118 7
|
||||||
|
-215 l-14 -166 -73 44 c-174 104 -403 341 -403 416 0 15 47 70 105 123 l104
|
||||||
|
98 127 -125 c70 -69 136 -147 147 -175z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,388 @@
|
|||||||
|
export type AuthMode = 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN'
|
||||||
|
|
||||||
|
export class AuthRequiredError extends Error {
|
||||||
|
constructor(msg = 'Authentication required') {
|
||||||
|
super(msg)
|
||||||
|
this.name = 'AuthRequiredError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOKEN_KEY = 'moku_access_token'
|
||||||
|
const UI_SESSION_KEY = 'moku_ui_auth_session'
|
||||||
|
const REFRESH_SKEW_MS = 30_000
|
||||||
|
|
||||||
|
interface StoredToken {
|
||||||
|
base: string
|
||||||
|
token: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UiSession {
|
||||||
|
base: string
|
||||||
|
accessToken: string
|
||||||
|
refreshToken?: string
|
||||||
|
clientMutationId?: string
|
||||||
|
accessExpiresAt?: number | null
|
||||||
|
refreshExpiresAt?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JwtSettings {
|
||||||
|
jwtAudience?: string | null
|
||||||
|
jwtRefreshExpiry?: string | null
|
||||||
|
jwtTokenExpiry?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
let _session: UiSession | null = null
|
||||||
|
let _accessToken: string | null = null
|
||||||
|
let _accessTokenBase: string | null = null
|
||||||
|
let _refreshPromise: Promise<string | null> | null = null
|
||||||
|
let _jwtSettings: JwtSettings | null = null
|
||||||
|
let _jwtSettingsBase: string | null = null
|
||||||
|
let _jwtSettingsFetchedAt = 0
|
||||||
|
|
||||||
|
let _serverBase = 'http://127.0.0.1:4567'
|
||||||
|
let _authMode: AuthMode = 'NONE'
|
||||||
|
let _basicUser = ''
|
||||||
|
let _basicPass = ''
|
||||||
|
|
||||||
|
export function configureAuth(base: string, mode: AuthMode, user = '', pass = '') {
|
||||||
|
_serverBase = base.replace(/\/$/, '')
|
||||||
|
_authMode = mode
|
||||||
|
_basicUser = user
|
||||||
|
_basicPass = pass
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getServerBase(): string {
|
||||||
|
return _serverBase
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAuthMode(): AuthMode {
|
||||||
|
return _authMode
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeoutSignal(ms: number): AbortSignal {
|
||||||
|
return AbortSignal.timeout(ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
function gqlBody(query: string, variables?: Record<string, unknown>): string {
|
||||||
|
return JSON.stringify({ query, variables })
|
||||||
|
}
|
||||||
|
|
||||||
|
function basicHeader(user: string, pass: string): Record<string, string> {
|
||||||
|
return { Authorization: 'Basic ' + btoa(`${user}:${pass}`) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function bearerHeader(token: string): Record<string, string> {
|
||||||
|
return { Authorization: `Bearer ${token}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIsoDuration(d: string): number | null {
|
||||||
|
const m = d.match(/^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:([\d.]+)S)?)?$/)
|
||||||
|
if (!m) return null
|
||||||
|
let ms = 0
|
||||||
|
if (m[1]) ms += +m[1] * 365.25 * 86400000
|
||||||
|
if (m[2]) ms += +m[2] * 30.44 * 86400000
|
||||||
|
if (m[3]) ms += +m[3] * 86400000
|
||||||
|
if (m[4]) ms += +m[4] * 3600000
|
||||||
|
if (m[5]) ms += +m[5] * 60000
|
||||||
|
if (m[6]) ms += parseFloat(m[6]) * 1000
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeJwtExpiry(token: string): number | null {
|
||||||
|
try {
|
||||||
|
const part = token.split('.')[1]
|
||||||
|
if (!part) return null
|
||||||
|
const pad = part.replace(/-/g, '+').replace(/_/g, '/')
|
||||||
|
const json = JSON.parse(atob(pad.padEnd(pad.length + ((4 - pad.length % 4) % 4), '='))) as { exp?: number }
|
||||||
|
return typeof json.exp === 'number' ? json.exp * 1000 : null
|
||||||
|
} catch { return null }
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExpired(at?: number | null, skew = REFRESH_SKEW_MS): boolean {
|
||||||
|
if (!at || !Number.isFinite(at)) return false
|
||||||
|
return Date.now() >= at - skew
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStoredSession(): UiSession | null {
|
||||||
|
try { return JSON.parse(sessionStorage.getItem(UI_SESSION_KEY) ?? 'null') } catch { return null }
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStoredToken(): StoredToken | null {
|
||||||
|
try { return JSON.parse(sessionStorage.getItem(TOKEN_KEY) ?? 'null') } catch { return null }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const uiAuth = {
|
||||||
|
getSession(): UiSession | null {
|
||||||
|
if (_session?.base === _serverBase) return _session
|
||||||
|
const stored = readStoredSession()
|
||||||
|
if (!stored || stored.base !== _serverBase) {
|
||||||
|
sessionStorage.removeItem(UI_SESSION_KEY)
|
||||||
|
sessionStorage.removeItem(TOKEN_KEY)
|
||||||
|
_session = _accessToken = _accessTokenBase = null
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
_session = stored
|
||||||
|
_accessToken = stored.accessToken
|
||||||
|
_accessTokenBase = stored.base
|
||||||
|
return _session
|
||||||
|
},
|
||||||
|
|
||||||
|
setSession(session: Omit<UiSession, 'base'>) {
|
||||||
|
_session = { ...session, base: _serverBase }
|
||||||
|
_accessToken = session.accessToken
|
||||||
|
_accessTokenBase = _serverBase
|
||||||
|
sessionStorage.setItem(UI_SESSION_KEY, JSON.stringify(_session))
|
||||||
|
sessionStorage.removeItem(TOKEN_KEY)
|
||||||
|
},
|
||||||
|
|
||||||
|
getToken(): string | null {
|
||||||
|
const s = uiAuth.getSession()
|
||||||
|
if (!s || isExpired(s.accessExpiresAt, 0)) return null
|
||||||
|
if (_accessToken && _accessTokenBase === _serverBase) return _accessToken
|
||||||
|
const stored = readStoredToken()
|
||||||
|
if (!stored || stored.base !== _serverBase) {
|
||||||
|
sessionStorage.removeItem(TOKEN_KEY)
|
||||||
|
_accessToken = _accessTokenBase = null
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
_accessToken = stored.token
|
||||||
|
_accessTokenBase = stored.base
|
||||||
|
return _accessToken
|
||||||
|
},
|
||||||
|
|
||||||
|
setToken(t: string) {
|
||||||
|
const existing = uiAuth.getSession()
|
||||||
|
if (existing?.refreshToken) {
|
||||||
|
uiAuth.setSession({ ...existing, accessToken: t, ...expiryFromJwt(t, _jwtSettings) })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_accessToken = t
|
||||||
|
_accessTokenBase = _serverBase
|
||||||
|
sessionStorage.setItem(TOKEN_KEY, JSON.stringify({ base: _serverBase, token: t }))
|
||||||
|
},
|
||||||
|
|
||||||
|
setLoginSession(
|
||||||
|
payload: { accessToken: string; refreshToken: string; clientMutationId?: string },
|
||||||
|
jwt: JwtSettings | null,
|
||||||
|
) {
|
||||||
|
uiAuth.setSession({
|
||||||
|
accessToken: payload.accessToken,
|
||||||
|
refreshToken: payload.refreshToken,
|
||||||
|
clientMutationId: payload.clientMutationId,
|
||||||
|
...expiryFromJwt(payload.accessToken, jwt),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
updateAccessToken(
|
||||||
|
payload: { accessToken: string; clientMutationId?: string },
|
||||||
|
jwt: JwtSettings | null,
|
||||||
|
) {
|
||||||
|
const s = uiAuth.getSession()
|
||||||
|
if (!s) return
|
||||||
|
uiAuth.setSession({
|
||||||
|
...s,
|
||||||
|
accessToken: payload.accessToken,
|
||||||
|
clientMutationId: payload.clientMutationId ?? s.clientMutationId,
|
||||||
|
...expiryFromJwt(payload.accessToken, jwt),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
clearToken() {
|
||||||
|
_session = _accessToken = _accessTokenBase = null
|
||||||
|
sessionStorage.removeItem(UI_SESSION_KEY)
|
||||||
|
sessionStorage.removeItem(TOKEN_KEY)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function expiryFromJwt(token: string, jwt: JwtSettings | null) {
|
||||||
|
const now = Date.now()
|
||||||
|
return {
|
||||||
|
accessExpiresAt: decodeJwtExpiry(token) ?? (jwt?.jwtTokenExpiry ? now + (parseIsoDuration(jwt.jwtTokenExpiry) ?? 0) : null),
|
||||||
|
refreshExpiresAt: jwt?.jwtRefreshExpiry ? now + (parseIsoDuration(jwt.jwtRefreshExpiry) ?? 0) : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJwtSettings(): Promise<JwtSettings | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetchAuthenticated(`${_serverBase}/api/graphql`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: gqlBody(`query { settings { jwtAudience jwtRefreshExpiry jwtTokenExpiry } }`),
|
||||||
|
}, timeoutSignal(5000))
|
||||||
|
if (!res.ok) return null
|
||||||
|
const json = await res.json()
|
||||||
|
const s = json?.data?.settings
|
||||||
|
if (!s) return null
|
||||||
|
return {
|
||||||
|
jwtAudience: s.jwtAudience ?? null,
|
||||||
|
jwtRefreshExpiry: s.jwtRefreshExpiry ?? null,
|
||||||
|
jwtTokenExpiry: s.jwtTokenExpiry ?? null,
|
||||||
|
}
|
||||||
|
} catch { return null }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getJwtSettings(force = false): Promise<JwtSettings | null> {
|
||||||
|
const fresh = Date.now() - _jwtSettingsFetchedAt < 60_000
|
||||||
|
if (!force && _jwtSettingsBase === _serverBase && _jwtSettings && fresh) return _jwtSettings
|
||||||
|
_jwtSettings = await fetchJwtSettings()
|
||||||
|
_jwtSettingsBase = _serverBase
|
||||||
|
_jwtSettingsFetchedAt = Date.now()
|
||||||
|
return _jwtSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAuthenticated(
|
||||||
|
url: string,
|
||||||
|
init: RequestInit = {},
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<Response> {
|
||||||
|
const baseHeaders = { ...(init.headers as Record<string, string> ?? {}) }
|
||||||
|
|
||||||
|
if (_authMode === 'BASIC_AUTH') {
|
||||||
|
return fetch(url, {
|
||||||
|
...init, signal, credentials: 'omit',
|
||||||
|
headers: { ...baseHeaders, ...(_basicUser && _basicPass ? basicHeader(_basicUser, _basicPass) : {}) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_authMode === 'UI_LOGIN') {
|
||||||
|
const token = await getUIAccessToken()
|
||||||
|
if (!token) throw new AuthRequiredError()
|
||||||
|
|
||||||
|
let res = await fetch(url, {
|
||||||
|
...init, signal, credentials: 'omit',
|
||||||
|
headers: { ...baseHeaders, ...bearerHeader(token) },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.status !== 401) return res
|
||||||
|
|
||||||
|
const refreshed = await refreshUiAccessToken(true)
|
||||||
|
if (!refreshed) return res
|
||||||
|
|
||||||
|
return fetch(url, {
|
||||||
|
...init, signal, credentials: 'omit',
|
||||||
|
headers: { ...baseHeaders, ...bearerHeader(refreshed) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetch(url, { ...init, signal, credentials: 'omit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUIAccessToken(forceRefresh = false): Promise<string | null> {
|
||||||
|
const s = uiAuth.getSession()
|
||||||
|
if (!s) return null
|
||||||
|
if (forceRefresh || isExpired(s.accessExpiresAt)) return refreshUiAccessToken(true)
|
||||||
|
return s.accessToken
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshUiAccessToken(force = false): Promise<string | null> {
|
||||||
|
const s = uiAuth.getSession()
|
||||||
|
if (!s) return null
|
||||||
|
if (!s.refreshToken) {
|
||||||
|
if (force && isExpired(s.accessExpiresAt, 0)) return null
|
||||||
|
return s.accessToken
|
||||||
|
}
|
||||||
|
if (!force && !isExpired(s.accessExpiresAt)) return s.accessToken
|
||||||
|
if (isExpired(s.refreshExpiresAt)) { uiAuth.clearToken(); return null }
|
||||||
|
if (_refreshPromise) return _refreshPromise
|
||||||
|
|
||||||
|
_refreshPromise = (async () => {
|
||||||
|
const jwt = await getJwtSettings().catch(() => null)
|
||||||
|
const res = await fetch(`${_serverBase}/api/graphql`, {
|
||||||
|
method: 'POST', credentials: 'omit',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: gqlBody(
|
||||||
|
`mutation RefreshToken($refreshToken: String!, $clientMutationId: String) {
|
||||||
|
refreshToken(input: { refreshToken: $refreshToken, clientMutationId: $clientMutationId }) {
|
||||||
|
accessToken clientMutationId
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{ refreshToken: s.refreshToken, clientMutationId: s.clientMutationId },
|
||||||
|
),
|
||||||
|
signal: timeoutSignal(5000),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 401 || res.status === 403) { uiAuth.clearToken(); return null }
|
||||||
|
throw new Error(`Token refresh failed (${res.status})`)
|
||||||
|
}
|
||||||
|
const json = await res.json()
|
||||||
|
const refreshed = json?.data?.refreshToken
|
||||||
|
const next: string | undefined = refreshed?.accessToken
|
||||||
|
if (!next) { uiAuth.clearToken(); return null }
|
||||||
|
uiAuth.updateAccessToken({ accessToken: next, clientMutationId: refreshed?.clientMutationId }, jwt)
|
||||||
|
return next
|
||||||
|
})().finally(() => { _refreshPromise = null })
|
||||||
|
|
||||||
|
return _refreshPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loginUI(user: string, pass: string): Promise<void> {
|
||||||
|
const res = await fetch(`${_serverBase}/api/graphql`, {
|
||||||
|
method: 'POST', credentials: 'omit',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: gqlBody(
|
||||||
|
`mutation Login($username: String!, $password: String!) {
|
||||||
|
login(input: { username: $username, password: $password }) {
|
||||||
|
accessToken refreshToken clientMutationId
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{ username: user, password: pass },
|
||||||
|
),
|
||||||
|
signal: timeoutSignal(8000),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`Login request failed (${res.status})`)
|
||||||
|
const json = await res.json()
|
||||||
|
const payload = json?.data?.login
|
||||||
|
if (!payload?.accessToken || !payload?.refreshToken) {
|
||||||
|
throw new Error(json?.errors?.[0]?.message ?? 'Login failed')
|
||||||
|
}
|
||||||
|
const jwt = await getJwtSettings(true).catch(() => null)
|
||||||
|
uiAuth.setLoginSession({
|
||||||
|
accessToken: payload.accessToken,
|
||||||
|
refreshToken: payload.refreshToken,
|
||||||
|
clientMutationId: typeof payload.clientMutationId === 'string' ? payload.clientMutationId : undefined,
|
||||||
|
}, jwt)
|
||||||
|
_authMode = 'UI_LOGIN'
|
||||||
|
_basicUser = user
|
||||||
|
_basicPass = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loginBasic(user: string, pass: string): Promise<void> {
|
||||||
|
const res = await fetch(`${_serverBase}/api/graphql`, {
|
||||||
|
method: 'POST', credentials: 'omit',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...basicHeader(user, pass) },
|
||||||
|
body: gqlBody('{ __typename }'),
|
||||||
|
signal: timeoutSignal(5000),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`Authentication failed (${res.status})`)
|
||||||
|
_authMode = 'BASIC_AUTH'
|
||||||
|
_basicUser = user
|
||||||
|
_basicPass = pass
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout(): Promise<void> {
|
||||||
|
uiAuth.clearToken()
|
||||||
|
_authMode = 'NONE'
|
||||||
|
_basicUser = ''
|
||||||
|
_basicPass = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function probeServer(): Promise<'ok' | 'auth_required' | 'unreachable'> {
|
||||||
|
try {
|
||||||
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||||
|
if (_authMode === 'BASIC_AUTH' && _basicUser && _basicPass) {
|
||||||
|
Object.assign(headers, basicHeader(_basicUser, _basicPass))
|
||||||
|
} else if (_authMode === 'UI_LOGIN') {
|
||||||
|
const token = await getUIAccessToken()
|
||||||
|
if (!token) return 'auth_required'
|
||||||
|
Object.assign(headers, bearerHeader(token))
|
||||||
|
}
|
||||||
|
const res = await fetch(`${_serverBase}/api/graphql`, {
|
||||||
|
method: 'POST', credentials: 'omit', headers,
|
||||||
|
body: gqlBody('{ __typename }'),
|
||||||
|
signal: timeoutSignal(5000),
|
||||||
|
})
|
||||||
|
if (res.ok) return 'ok'
|
||||||
|
if (res.status === 401) return 'auth_required'
|
||||||
|
return 'unreachable'
|
||||||
|
} catch { return 'unreachable' }
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
export type AppStatus = 'booting' | 'auth' | 'ready' | 'error'
|
export type AppStatus = 'booting' | 'not-configured' | 'auth' | 'ready' | 'error'
|
||||||
|
|
||||||
export const appState = $state({
|
export const appState = $state({
|
||||||
status: 'booting' as AppStatus,
|
status: 'booting' as AppStatus,
|
||||||
error: null as string | null,
|
error: null as string | null,
|
||||||
serverUrl: '',
|
serverUrl: '',
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
|
authMode: 'NONE' as 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN',
|
||||||
platform: 'web' as 'web' | 'tauri' | 'capacitor',
|
platform: 'web' as 'web' | 'tauri' | 'capacitor',
|
||||||
version: '',
|
version: '',
|
||||||
})
|
})
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import logoUrl from '$lib/assets/moku-icon-splash.svg'
|
||||||
|
import { appState } from '$lib/state/app.svelte'
|
||||||
|
import { loginUI, loginBasic, configureAuth } from '$lib/core/auth'
|
||||||
|
|
||||||
|
let loginUser = $state('')
|
||||||
|
let loginPass = $state('')
|
||||||
|
let loginBusy = $state(false)
|
||||||
|
let loginError = $state<string | null>(null)
|
||||||
|
|
||||||
|
async function handleLogin() {
|
||||||
|
if (!loginUser.trim() || !loginPass.trim()) return
|
||||||
|
loginBusy = true
|
||||||
|
loginError = null
|
||||||
|
try {
|
||||||
|
if (appState.authMode === 'UI_LOGIN') {
|
||||||
|
await loginUI(loginUser.trim(), loginPass.trim())
|
||||||
|
} else {
|
||||||
|
await loginBasic(loginUser.trim(), loginPass.trim())
|
||||||
|
}
|
||||||
|
appState.authenticated = true
|
||||||
|
appState.status = 'ready'
|
||||||
|
} catch (e) {
|
||||||
|
loginError = e instanceof Error ? e.message : String(e)
|
||||||
|
} finally {
|
||||||
|
loginBusy = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBypass() {
|
||||||
|
appState.authenticated = false
|
||||||
|
appState.status = 'ready'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if appState.status === 'auth'}
|
||||||
|
<div class="overlay">
|
||||||
|
<div class="card anim-scale-in">
|
||||||
|
<img src={logoUrl} alt="Moku" class="logo" />
|
||||||
|
<p class="title">moku</p>
|
||||||
|
<span class="mode-badge">
|
||||||
|
{appState.authMode === 'UI_LOGIN' ? 'UI Login' : 'Basic Auth'}
|
||||||
|
</span>
|
||||||
|
<p class="host">{appState.serverUrl || 'localhost:4567'}</p>
|
||||||
|
|
||||||
|
{#if loginError}
|
||||||
|
<p class="error">{loginError}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="fields">
|
||||||
|
<input
|
||||||
|
class="input"
|
||||||
|
type="text"
|
||||||
|
placeholder="Username"
|
||||||
|
bind:value={loginUser}
|
||||||
|
disabled={loginBusy}
|
||||||
|
autocomplete="username"
|
||||||
|
onkeydown={(e) => e.key === 'Enter' && handleLogin()}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
class="input"
|
||||||
|
type="password"
|
||||||
|
placeholder="Password"
|
||||||
|
bind:value={loginPass}
|
||||||
|
disabled={loginBusy}
|
||||||
|
autocomplete="current-password"
|
||||||
|
onkeydown={(e) => e.key === 'Enter' && handleLogin()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
onclick={handleLogin}
|
||||||
|
disabled={loginBusy || !loginUser.trim() || !loginPass.trim()}
|
||||||
|
>
|
||||||
|
{loginBusy ? 'Signing in…' : 'Sign in'}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn--ghost" onclick={handleBypass}>Skip</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.overlay { position:fixed; inset:0; z-index:10000; display:flex; align-items:center; justify-content:center; pointer-events:none; }
|
||||||
|
.card { pointer-events:auto; width:min(280px, calc(100vw - 48px)); background:var(--bg-surface); border:1px solid var(--border-base); border-radius:var(--radius-xl); padding:var(--sp-6) var(--sp-5); display:flex; flex-direction:column; align-items:center; gap:var(--sp-3); box-shadow:0 32px 80px rgba(0,0,0,0.75); text-align:center; }
|
||||||
|
|
||||||
|
.logo { width:56px; height:56px; border-radius:14px; display:block; }
|
||||||
|
.title { font-family:var(--font-ui); font-size:11px; font-weight:500; letter-spacing:0.26em; text-transform:uppercase; color:var(--text-secondary); margin:-6px 0 0; user-select:none; }
|
||||||
|
.mode-badge { font-family:var(--font-ui); font-size:var(--text-2xs); letter-spacing:var(--tracking-wider); text-transform:uppercase; color:var(--accent-fg); background:var(--accent-muted); border:1px solid var(--accent-dim); border-radius:var(--radius-full); padding:2px 10px; }
|
||||||
|
.host { font-family:var(--font-ui); font-size:var(--text-xs); color:var(--text-faint); letter-spacing:var(--tracking-wide); margin:-4px 0 0; }
|
||||||
|
.error { font-family:var(--font-ui); font-size:var(--text-xs); color:var(--color-error); background:var(--color-error-bg); border:1px solid var(--color-error); border-radius:var(--radius-sm); padding:var(--sp-2) var(--sp-3); width:100%; box-sizing:border-box; }
|
||||||
|
|
||||||
|
.fields { display:flex; flex-direction:column; gap:var(--sp-2); width:100%; }
|
||||||
|
.input { width:100%; background:var(--bg-raised); border:1px solid var(--border-strong); border-radius:var(--radius-md); padding:8px 12px; font-size:var(--text-sm); color:var(--text-primary); outline:none; box-sizing:border-box; transition:border-color var(--t-base), box-shadow var(--t-base); font-family:inherit; }
|
||||||
|
.input:focus { border-color:var(--border-focus); box-shadow:0 0 0 2px color-mix(in srgb, var(--accent) 20%, transparent); }
|
||||||
|
.input:disabled { opacity:0.5; }
|
||||||
|
|
||||||
|
.btn { width:100%; padding:9px; border-radius:var(--radius-md); background:var(--accent); border:1px solid var(--accent); color:var(--accent-fg); font-size:var(--text-sm); font-family:var(--font-ui); letter-spacing:var(--tracking-wide); cursor:pointer; transition:opacity var(--t-base); }
|
||||||
|
.btn:hover:not(:disabled) { opacity:0.85; }
|
||||||
|
.btn:disabled { opacity:0.35; cursor:default; }
|
||||||
|
.btn--ghost { background:none; border-color:transparent; color:var(--text-faint); font-size:var(--text-xs); padding:4px; }
|
||||||
|
.btn--ghost:hover:not(:disabled) { color:var(--text-muted); opacity:1; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores'
|
||||||
|
import { goto } from '$app/navigation'
|
||||||
|
import {
|
||||||
|
House, Books, MagnifyingGlass, ClockCounterClockwise,
|
||||||
|
DownloadSimple, PuzzlePiece, GearSix, ChartLineUp,
|
||||||
|
} from 'phosphor-svelte'
|
||||||
|
import logoUrl from '$lib/assets/moku-icon-wordmark.svg'
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ path: '/', label: 'Home', icon: House },
|
||||||
|
{ path: '/library', label: 'Library', icon: Books },
|
||||||
|
{ path: '/browse', label: 'Browse', icon: MagnifyingGlass },
|
||||||
|
{ path: '/downloads', label: 'Downloads', icon: DownloadSimple },
|
||||||
|
{ path: '/extensions', label: 'Extensions', icon: PuzzlePiece },
|
||||||
|
{ path: '/tracking', label: 'Tracking', icon: ChartLineUp },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const TAB_SIZE = 36
|
||||||
|
const TAB_GAP = 4
|
||||||
|
|
||||||
|
const activeIndex = $derived(
|
||||||
|
TABS.findIndex(t => {
|
||||||
|
if (t.path === '/') return $page.url.pathname === '/'
|
||||||
|
return $page.url.pathname.startsWith(t.path)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const indicatorY = $derived(activeIndex * (TAB_SIZE + TAB_GAP))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<aside class="root">
|
||||||
|
<button class="logo" onclick={() => goto('/')} title="Home" aria-label="Go to Home">
|
||||||
|
<div class="logo-icon" style="mask-image: url({logoUrl}); -webkit-mask-image: url({logoUrl})"></div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<nav class="nav">
|
||||||
|
{#if activeIndex >= 0}
|
||||||
|
<div class="indicator" style="transform: translateX(-50%) translateY({indicatorY}px)"></div>
|
||||||
|
{/if}
|
||||||
|
{#each TABS as tab}
|
||||||
|
<button
|
||||||
|
class="tab"
|
||||||
|
class:active={activeIndex === TABS.indexOf(tab)}
|
||||||
|
title={tab.label}
|
||||||
|
onclick={() => goto(tab.path)}
|
||||||
|
>
|
||||||
|
<tab.icon size={18} weight="light" />
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="bottom">
|
||||||
|
<button class="settings-btn" onclick={() => goto('/settings')} title="Settings">
|
||||||
|
<GearSix size={18} weight="light" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.root {
|
||||||
|
width: var(--sidebar-width);
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--sp-4) 0;
|
||||||
|
height: 100%;
|
||||||
|
border-right: 1px solid var(--border-dim);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: var(--sp-4);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
transition: opacity var(--t-base), transform var(--t-base);
|
||||||
|
}
|
||||||
|
.logo:hover { opacity: 0.8; transform: scale(0.96); }
|
||||||
|
.logo:active { transform: scale(0.92); }
|
||||||
|
.logo:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
|
|
||||||
|
.logo-icon {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
background-color: var(--accent);
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
mask-position: center;
|
||||||
|
mask-size: contain;
|
||||||
|
-webkit-mask-repeat: no-repeat;
|
||||||
|
-webkit-mask-position: center;
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
filter: drop-shadow(0 0 8px rgba(107,143,107,0.35));
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0 var(--sp-2);
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.nav::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
|
.indicator {
|
||||||
|
position: absolute;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--accent-muted);
|
||||||
|
pointer-events: none;
|
||||||
|
top: 0;
|
||||||
|
left: 50%;
|
||||||
|
z-index: 0;
|
||||||
|
transition: transform 0.22s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--text-faint);
|
||||||
|
transition: color var(--t-base), background var(--t-base);
|
||||||
|
}
|
||||||
|
.tab:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||||
|
.tab:active { transform: scale(0.88); }
|
||||||
|
.tab:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||||
|
.tab.active { color: var(--accent-fg); }
|
||||||
|
.tab.active:hover { background: transparent; }
|
||||||
|
|
||||||
|
.bottom {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--sp-3) var(--sp-2) 0;
|
||||||
|
border-top: 1px solid var(--border-dim);
|
||||||
|
margin-top: var(--sp-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-btn {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--text-faint);
|
||||||
|
transition: color var(--t-base), background var(--t-base), transform var(--t-slow);
|
||||||
|
}
|
||||||
|
.settings-btn:hover { color: var(--text-muted); background: var(--bg-raised); transform: rotate(30deg); }
|
||||||
|
.settings-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte'
|
||||||
|
import logoUrl from '$lib/assets/moku-icon-splash.svg'
|
||||||
|
import { mountCardCanvas, ringGeometry, animateRingProgress } from '$lib/ui/chrome/splashCanvas'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
mode?: 'loading' | 'idle'
|
||||||
|
ringFull?: boolean
|
||||||
|
failed?: boolean
|
||||||
|
notConfigured?: boolean
|
||||||
|
showCards?: boolean
|
||||||
|
onReady?: () => void
|
||||||
|
onRetry?: () => void
|
||||||
|
onBypass?: () => void
|
||||||
|
onDismiss?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
mode = 'loading',
|
||||||
|
ringFull = false,
|
||||||
|
failed = false,
|
||||||
|
notConfigured = false,
|
||||||
|
showCards = true,
|
||||||
|
onReady,
|
||||||
|
onRetry,
|
||||||
|
onBypass,
|
||||||
|
onDismiss,
|
||||||
|
}: Props = $props()
|
||||||
|
|
||||||
|
const EXIT_MS = 320
|
||||||
|
const RING_R = 70
|
||||||
|
const RING_PAD = 12
|
||||||
|
const { size: ringSize, c: ringC, circ: ringCirc } = ringGeometry(RING_R, RING_PAD)
|
||||||
|
|
||||||
|
const LOGO_LOADING = 140
|
||||||
|
const LOGO_IDLE = 128
|
||||||
|
|
||||||
|
let dots = $state('')
|
||||||
|
let ringProg = $state(0.025)
|
||||||
|
let exiting = $state(false)
|
||||||
|
let exitLock = false
|
||||||
|
let pinEntry = $state('')
|
||||||
|
let pinShake = $state(false)
|
||||||
|
let pinVisible = $state(false)
|
||||||
|
let pinUnlocked = $state(false)
|
||||||
|
|
||||||
|
const ringArc = $derived(ringCirc * Math.min(Math.max(ringProg, 0.025), 0.999))
|
||||||
|
|
||||||
|
function triggerExit(cb?: () => void) {
|
||||||
|
if (exitLock) return
|
||||||
|
exitLock = true
|
||||||
|
exiting = true
|
||||||
|
setTimeout(() => cb?.(), EXIT_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitPin(correctPin: string) {
|
||||||
|
if (pinEntry === correctPin) {
|
||||||
|
pinUnlocked = true
|
||||||
|
pinEntry = ''
|
||||||
|
if (mode === 'idle') triggerExit(onDismiss)
|
||||||
|
} else {
|
||||||
|
pinShake = true
|
||||||
|
pinEntry = ''
|
||||||
|
setTimeout(() => (pinShake = false), 500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPinKey(e: KeyboardEvent, correctPin: string, pinLen: number) {
|
||||||
|
if (e.key === 'Enter') { submitPin(correctPin); return }
|
||||||
|
if (e.key === 'Backspace') { pinEntry = pinEntry.slice(0, -1); return }
|
||||||
|
if (/^\d$/.test(e.key)) {
|
||||||
|
pinEntry = (pinEntry + e.key).slice(0, 8)
|
||||||
|
if (pinEntry.length >= pinLen) submitPin(correctPin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!ringFull) {
|
||||||
|
exitLock = false
|
||||||
|
exiting = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
triggerExit(onReady)
|
||||||
|
})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (pinUnlocked && mode !== 'idle') triggerExit(onReady)
|
||||||
|
})
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const stopDots = setInterval(() => {
|
||||||
|
dots = dots.length >= 3 ? '' : dots + '.'
|
||||||
|
}, 420)
|
||||||
|
|
||||||
|
if (mode === 'loading' && !failed && !notConfigured) {
|
||||||
|
const stopAnim = animateRingProgress(p => (ringProg = p))
|
||||||
|
return () => { clearInterval(stopDots); stopAnim() }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'idle' && onDismiss) {
|
||||||
|
const handler = () => triggerExit(onDismiss)
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
window.addEventListener('keydown', handler, { once: true })
|
||||||
|
window.addEventListener('mousedown', handler, { once: true })
|
||||||
|
window.addEventListener('touchstart', handler, { once: true })
|
||||||
|
}, 200)
|
||||||
|
return () => {
|
||||||
|
clearTimeout(t)
|
||||||
|
clearInterval(stopDots)
|
||||||
|
window.removeEventListener('keydown', handler)
|
||||||
|
window.removeEventListener('mousedown', handler)
|
||||||
|
window.removeEventListener('touchstart', handler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => clearInterval(stopDots)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="splash" class:exiting>
|
||||||
|
{#if showCards}
|
||||||
|
<canvas
|
||||||
|
style="position:absolute;inset:0;pointer-events:none;width:100%;height:100%"
|
||||||
|
use:mountCardCanvas
|
||||||
|
></canvas>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if mode === 'idle'}
|
||||||
|
<div class="center">
|
||||||
|
<div class="logo-wrap" style="width:{LOGO_IDLE}px;height:{LOGO_IDLE}px;margin-bottom:32px">
|
||||||
|
<div class="logo-glow"></div>
|
||||||
|
<img src={logoUrl} alt="Moku" class="logo-breathe" style="width:{LOGO_IDLE}px;height:{LOGO_IDLE}px;border-radius:28px" />
|
||||||
|
</div>
|
||||||
|
<p class="hint">press any key to continue</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else}
|
||||||
|
<div style="position:relative;width:{ringSize}px;height:{ringSize}px;margin-bottom:20px;display:flex;align-items:center;justify-content:center">
|
||||||
|
{#if !failed && !notConfigured}
|
||||||
|
<svg
|
||||||
|
width={ringSize}
|
||||||
|
height={ringSize}
|
||||||
|
class="ring"
|
||||||
|
class:ring-hide={pinVisible}
|
||||||
|
style="position:absolute;top:0;left:0;pointer-events:none"
|
||||||
|
>
|
||||||
|
<circle cx={ringC} cy={ringC} r={RING_R} fill="none" stroke="var(--border-base)" stroke-width="2"/>
|
||||||
|
<circle cx={ringC} cy={ringC} r={RING_R} fill="none" stroke="var(--accent)" stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-dasharray="{ringArc} {ringCirc}"
|
||||||
|
transform="rotate(-90 {ringC} {ringC})"
|
||||||
|
style="transition: stroke-dasharray 0.4s cubic-bezier(0.4,0,0.2,1)"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
<img src={logoUrl} alt="Moku" style="width:{LOGO_LOADING}px;height:{LOGO_LOADING}px;border-radius:32px;display:block;position:relative"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bottom-area">
|
||||||
|
<div class="status-slot" class:status-slot-hide={pinVisible}>
|
||||||
|
{#if failed || notConfigured}
|
||||||
|
<div class="error-box anim-fade-up">
|
||||||
|
<p class="error-label">{failed ? 'Could not reach server' : 'Server not configured'}</p>
|
||||||
|
<div class="error-actions">
|
||||||
|
<button class="err-btn" onclick={() => onRetry?.()}>Retry</button>
|
||||||
|
<button class="err-btn err-btn--primary" onclick={() => onBypass?.()}>Enter app</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="status-text">{ringFull ? '' : `Initializing server${dots}`}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.splash {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 9999;
|
||||||
|
background: var(--bg-base);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
animation: spIn 0.35s cubic-bezier(0,0,0.2,1) both;
|
||||||
|
}
|
||||||
|
.splash.exiting { animation: spOut 320ms cubic-bezier(0.4,0,1,1) both; }
|
||||||
|
|
||||||
|
@keyframes spIn { from { opacity:0; transform:scale(1.015) } to { opacity:1; transform:scale(1) } }
|
||||||
|
@keyframes spOut { from { opacity:1; transform:scale(1) } to { opacity:0; transform:scale(0.96) } }
|
||||||
|
@keyframes logoBreathe { 0%,100% { transform:scale(1); filter:drop-shadow(0 0 0px rgba(255,255,255,0)) } 50% { transform:scale(1.04); filter:drop-shadow(0 0 18px rgba(255,255,255,0.12)) } }
|
||||||
|
@keyframes hintFade { 0%,100% { opacity:0.35 } 50% { opacity:0.7 } }
|
||||||
|
|
||||||
|
.center { z-index:1; display:flex; flex-direction:column; align-items:center; }
|
||||||
|
|
||||||
|
.logo-wrap { position:relative; }
|
||||||
|
.logo-glow { position:absolute; inset:-20px; border-radius:50%; background:radial-gradient(circle, rgba(255,255,255,0.06) 0%, transparent 70%); animation:logoBreathe 4s ease-in-out infinite; }
|
||||||
|
.logo-breathe { animation:logoBreathe 4s ease-in-out infinite; display:block; position:relative; }
|
||||||
|
.hint { font-family:var(--font-ui); font-size:10px; color:var(--text-faint); letter-spacing:0.22em; text-transform:uppercase; margin:0; user-select:none; animation:hintFade 3.5s ease-in-out infinite; }
|
||||||
|
|
||||||
|
.ring { transition:opacity 0.5s ease; }
|
||||||
|
.ring-hide { opacity:0; }
|
||||||
|
|
||||||
|
.bottom-area { display:flex; align-items:center; justify-content:center; min-height:48px; position:relative; }
|
||||||
|
.status-slot { display:flex; align-items:center; justify-content:center; transition:opacity 0.35s ease; position:absolute; }
|
||||||
|
.status-slot-hide { opacity:0; pointer-events:none; }
|
||||||
|
.status-text { font-family:var(--font-ui); font-size:10px; color:var(--text-faint); letter-spacing:0.12em; margin:0; min-width:160px; text-align:center; }
|
||||||
|
|
||||||
|
.error-box { display:flex; flex-direction:column; align-items:center; gap:12px; padding:16px 20px; border-radius:var(--radius-lg); background:var(--bg-surface); border:1px solid var(--border-base); min-width:200px; text-align:center; }
|
||||||
|
.error-label { font-family:var(--font-ui); font-size:11px; font-weight:500; color:var(--text-muted); letter-spacing:0.06em; margin:0; }
|
||||||
|
.error-actions { display:flex; gap:6px; }
|
||||||
|
.err-btn { padding:5px 14px; border-radius:var(--radius-md); border:1px solid var(--border-base); background:transparent; color:var(--text-muted); cursor:pointer; font-family:var(--font-ui); font-size:11px; letter-spacing:0.04em; transition:border-color 0.15s, color 0.15s; }
|
||||||
|
.err-btn:hover { border-color:var(--border-strong); color:var(--text-secondary); }
|
||||||
|
.err-btn--primary { border-color:var(--accent-dim); color:var(--accent-fg); background:var(--accent-muted); }
|
||||||
|
.err-btn--primary:hover { border-color:var(--accent); color:var(--accent-bright); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte'
|
||||||
|
import { detectOs } from '$lib/ui/chrome/titlebarOs'
|
||||||
|
import type { OsKind } from '$lib/ui/chrome/titlebarOs'
|
||||||
|
|
||||||
|
let { onClose }: { onClose: () => void } = $props()
|
||||||
|
|
||||||
|
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
|
||||||
|
|
||||||
|
let os: OsKind = $state('unknown')
|
||||||
|
let isFullscreen = $state(false)
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
if (!isTauri) return
|
||||||
|
const { getCurrentWindow } = await import('@tauri-apps/api/window')
|
||||||
|
const win = getCurrentWindow()
|
||||||
|
os = await detectOs()
|
||||||
|
isFullscreen = await win.isFullscreen()
|
||||||
|
const unlisten = await win.onResized(async () => {
|
||||||
|
isFullscreen = await win.isFullscreen()
|
||||||
|
})
|
||||||
|
return unlisten
|
||||||
|
})
|
||||||
|
|
||||||
|
const isMac = $derived(os === 'macos')
|
||||||
|
const isWindows = $derived(os === 'windows')
|
||||||
|
|
||||||
|
async function minimize() {
|
||||||
|
const { getCurrentWindow } = await import('@tauri-apps/api/window')
|
||||||
|
getCurrentWindow().minimize()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleMaximize() {
|
||||||
|
const { getCurrentWindow } = await import('@tauri-apps/api/window')
|
||||||
|
getCurrentWindow().toggleMaximize()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exitFullscreen() {
|
||||||
|
const { getCurrentWindow } = await import('@tauri-apps/api/window')
|
||||||
|
getCurrentWindow().setFullscreen(false)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if !isFullscreen}
|
||||||
|
<div class="bar" data-tauri-drag-region>
|
||||||
|
{#if isMac}<div class="mac-spacer" data-tauri-drag-region></div>{/if}
|
||||||
|
<span class="title" data-tauri-drag-region>Moku</span>
|
||||||
|
{#if !isMac}
|
||||||
|
<div class="controls">
|
||||||
|
<button onclick={minimize} title="Minimize" aria-label="Minimize">
|
||||||
|
<svg width="10" height="1" viewBox="0 0 10 1"><line x1="0" y1="0.5" x2="10" y2="0.5" stroke="currentColor" stroke-width="1.5"/></svg>
|
||||||
|
</button>
|
||||||
|
<button onclick={toggleMaximize} title="Maximize" aria-label="Maximize">
|
||||||
|
<svg width="9" height="9" viewBox="0 0 9 9"><rect x="0.75" y="0.75" width="7.5" height="7.5" rx="1" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="close" onclick={onClose} title="Close" aria-label="Close">
|
||||||
|
<svg width="10" height="10" viewBox="0 0 10 10">
|
||||||
|
<line x1="1" y1="1" x2="9" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
<line x1="9" y1="1" x2="1" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else if isWindows}
|
||||||
|
<div class="fullscreen-controls">
|
||||||
|
<button onclick={exitFullscreen} title="Exit Fullscreen" aria-label="Exit Fullscreen">
|
||||||
|
<svg width="10" height="10" viewBox="0 0 10 10">
|
||||||
|
<polyline points="1,4 1,1 4,1" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<polyline points="6,1 9,1 9,4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<polyline points="9,6 9,9 6,9" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<polyline points="4,9 1,9 1,6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="close" onclick={onClose} title="Close" aria-label="Close">
|
||||||
|
<svg width="10" height="10" viewBox="0 0 10 10">
|
||||||
|
<line x1="1" y1="1" x2="9" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
<line x1="9" y1="1" x2="1" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: var(--titlebar-height);
|
||||||
|
padding: 0 6px 0 var(--sp-4);
|
||||||
|
background: transparent;
|
||||||
|
flex-shrink: 0;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mac-spacer { width: 70px; flex-shrink: 0; }
|
||||||
|
.title {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wider);
|
||||||
|
text-transform: uppercase;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls { display: flex; align-items: center; gap: 2px; }
|
||||||
|
|
||||||
|
button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-faint);
|
||||||
|
transition: color var(--t-base), background var(--t-base);
|
||||||
|
}
|
||||||
|
button:hover { color: var(--text-muted); background: rgba(255,255,255,0.06); }
|
||||||
|
.close:hover { color: #fff; background: #c0392b; }
|
||||||
|
|
||||||
|
.fullscreen-controls {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 4px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity var(--t-base);
|
||||||
|
}
|
||||||
|
.fullscreen-controls:hover { opacity: 1; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { dismissToast } from '$lib/state/notifications.svelte'
|
||||||
|
import type { Toast } from '$lib/state/notifications.svelte'
|
||||||
|
|
||||||
|
let { toasts }: { toasts: Toast[] } = $props()
|
||||||
|
|
||||||
|
const EXIT_MS = 280
|
||||||
|
const leaving = new Set<string>()
|
||||||
|
const timers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||||
|
|
||||||
|
let detail = $state<Toast | null>(null)
|
||||||
|
|
||||||
|
function schedule(t: Toast) {
|
||||||
|
if (timers.has(t.id)) return
|
||||||
|
const dur = t.duration ?? 3500
|
||||||
|
if (dur === 0) return
|
||||||
|
timers.set(t.id, setTimeout(() => dismiss(t.id), dur))
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismiss(id: string) {
|
||||||
|
if (leaving.has(id)) return
|
||||||
|
leaving.add(id)
|
||||||
|
if (timers.has(id)) { clearTimeout(timers.get(id)!); timers.delete(id) }
|
||||||
|
const el = document.querySelector<HTMLElement>(`[data-toast-id="${id}"]`)
|
||||||
|
if (!el) { finalize(id); return }
|
||||||
|
el.style.setProperty('--exit-h', `${el.offsetHeight}px`)
|
||||||
|
el.classList.add('leaving')
|
||||||
|
setTimeout(() => finalize(id), EXIT_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
function finalize(id: string) {
|
||||||
|
leaving.delete(id)
|
||||||
|
dismissToast(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDetail(e: MouseEvent, t: Toast) {
|
||||||
|
e.preventDefault()
|
||||||
|
detail = t
|
||||||
|
if (timers.has(t.id)) { clearTimeout(timers.get(t.id)!); timers.delete(t.id) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBackdropKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') detail = null
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const activeIds = new Set(toasts.map(t => t.id))
|
||||||
|
toasts.forEach(schedule)
|
||||||
|
for (const [id, timer] of timers) {
|
||||||
|
if (!activeIds.has(id)) { clearTimeout(timer); timers.delete(id) }
|
||||||
|
}
|
||||||
|
if (detail && !activeIds.has(detail.id)) detail = null
|
||||||
|
})
|
||||||
|
|
||||||
|
const icons: Record<Toast['kind'], string> = {
|
||||||
|
success: 'M20 6L9 17l-5-5',
|
||||||
|
error: 'M12 9v4M12 17h.01M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z',
|
||||||
|
info: 'M12 16v-4M12 8h.01M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2z',
|
||||||
|
download: 'M12 3v13M7 11l5 5 5-5M5 21h14',
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if toasts.length}
|
||||||
|
<div class="toaster" aria-live="polite">
|
||||||
|
{#each toasts as t (t.id)}
|
||||||
|
<button
|
||||||
|
class="toast toast-{t.kind}"
|
||||||
|
data-toast-id={t.id}
|
||||||
|
aria-label="{t.message}{t.detail ? ': ' + t.detail : ''}"
|
||||||
|
onclick={() => dismiss(t.id)}
|
||||||
|
oncontextmenu={(e) => openDetail(e, t)}
|
||||||
|
>
|
||||||
|
<div class="accent-bar"></div>
|
||||||
|
<span class="icon">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d={icons[t.kind]}/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<div class="body">
|
||||||
|
<p class="message">{t.message}</p>
|
||||||
|
<p class="sub">{t.detail ?? '\u00a0'}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if detail}
|
||||||
|
<div
|
||||||
|
class="detail-backdrop"
|
||||||
|
role="presentation"
|
||||||
|
onclick={() => (detail = null)}
|
||||||
|
onkeydown={onBackdropKey}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="detail-panel detail-{detail.kind}"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={detail.message}
|
||||||
|
tabindex="-1"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
onkeydown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div class="detail-accent"></div>
|
||||||
|
<div class="detail-body">
|
||||||
|
<div class="detail-header">
|
||||||
|
<span class="detail-kind">{detail.kind}</span>
|
||||||
|
<button class="detail-close" onclick={() => (detail = null)} aria-label="Close">
|
||||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="detail-message">{detail.message}</p>
|
||||||
|
{#if detail.detail}
|
||||||
|
<pre class="detail-text">{detail.detail}</pre>
|
||||||
|
{/if}
|
||||||
|
<div class="detail-actions">
|
||||||
|
<button class="detail-copy" onclick={() => navigator.clipboard.writeText(`${detail!.message}${detail!.detail ? '\n' + detail!.detail : ''}`)}>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
<button class="detail-dismiss" onclick={() => { dismiss(detail!.id); detail = null }}>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.toaster { position:fixed; bottom:var(--sp-5); right:var(--sp-5); z-index:9999; display:flex; flex-direction:column; gap:5px; pointer-events:none; }
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
display:flex; align-items:center; gap:10px; padding:12px var(--sp-3) 12px 0;
|
||||||
|
border-radius:var(--radius-md); background:var(--bg-raised); border:1px solid var(--border-dim);
|
||||||
|
box-shadow:0 8px 32px rgba(0,0,0,0.5), 0 1px 0 rgba(255,255,255,0.04) inset;
|
||||||
|
pointer-events:all; width:280px; overflow:hidden; cursor:pointer;
|
||||||
|
font-family:inherit; font-size:inherit; color:inherit; text-align:left;
|
||||||
|
will-change:transform, opacity;
|
||||||
|
animation:slideIn 0.35s cubic-bezier(0.16,1,0.3,1) both;
|
||||||
|
transition:border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
|
||||||
|
}
|
||||||
|
.toast:hover { border-color:var(--border-base); box-shadow:0 12px 40px rgba(0,0,0,0.6), 0 1px 0 rgba(255,255,255,0.06) inset; transform:translateX(-3px); }
|
||||||
|
.toast:active { transform:translateX(0) scale(0.98); }
|
||||||
|
|
||||||
|
:global(.toast.leaving) { animation:slideOut 0.28s cubic-bezier(0.4,0,1,1) forwards !important; pointer-events:none; }
|
||||||
|
|
||||||
|
@keyframes slideIn { from { opacity:0; transform:translateX(20px) scale(0.96) } to { opacity:1; transform:translateX(0) scale(1) } }
|
||||||
|
@keyframes slideOut {
|
||||||
|
0% { opacity:1; transform:translateX(0) scale(1); max-height:var(--exit-h,80px); margin-bottom:0; }
|
||||||
|
40% { opacity:0; transform:translateX(14px) scale(0.96); max-height:var(--exit-h,80px); margin-bottom:0; }
|
||||||
|
100% { opacity:0; transform:translateX(14px) scale(0.96); max-height:0; margin-bottom:-5px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.accent-bar { width:3px; align-self:stretch; flex-shrink:0; border-radius:0 2px 2px 0; }
|
||||||
|
.toast-success .accent-bar { background:var(--accent-fg); }
|
||||||
|
.toast-error .accent-bar { background:var(--color-error); }
|
||||||
|
.toast-info .accent-bar { background:var(--text-faint); }
|
||||||
|
.toast-download .accent-bar { background:var(--accent-fg); }
|
||||||
|
|
||||||
|
.icon { flex-shrink:0; display:flex; align-items:center; justify-content:center; }
|
||||||
|
.toast-success .icon { color:var(--accent-fg); }
|
||||||
|
.toast-error .icon { color:var(--color-error); }
|
||||||
|
.toast-info .icon { color:var(--text-muted); }
|
||||||
|
.toast-download .icon { color:var(--accent-fg); }
|
||||||
|
|
||||||
|
.body { flex:1; min-width:0; display:flex; flex-direction:column; gap:5px; }
|
||||||
|
.message { font-size:var(--text-xs); font-family:var(--font-ui); color:var(--text-secondary); font-weight:var(--weight-medium); letter-spacing:var(--tracking-wide); line-height:1; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||||
|
.sub { font-family:var(--font-ui); font-size:var(--text-2xs); color:var(--text-faint); letter-spacing:var(--tracking-wide); line-height:1; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||||
|
|
||||||
|
.detail-backdrop { position:fixed; inset:0; z-index:10000; background:rgba(0,0,0,0.45); display:flex; align-items:center; justify-content:center; animation:fadeIn 0.15s ease both; }
|
||||||
|
@keyframes fadeIn { from { opacity:0 } to { opacity:1 } }
|
||||||
|
|
||||||
|
.detail-panel { display:flex; width:420px; max-width:calc(100vw - 32px); max-height:60vh; border-radius:var(--radius-lg); background:var(--bg-raised); border:1px solid var(--border-base); box-shadow:0 24px 64px rgba(0,0,0,0.7), 0 1px 0 rgba(255,255,255,0.05) inset; overflow:hidden; animation:popIn 0.2s cubic-bezier(0.16,1,0.3,1) both; }
|
||||||
|
@keyframes popIn { from { opacity:0; transform:scale(0.95) } to { opacity:1; transform:scale(1) } }
|
||||||
|
|
||||||
|
.detail-accent { width:3px; flex-shrink:0; }
|
||||||
|
.detail-error .detail-accent { background:var(--color-error); }
|
||||||
|
.detail-success .detail-accent { background:var(--accent-fg); }
|
||||||
|
.detail-info .detail-accent { background:var(--text-faint); }
|
||||||
|
.detail-download .detail-accent { background:var(--accent-fg); }
|
||||||
|
|
||||||
|
.detail-body { flex:1; min-width:0; display:flex; flex-direction:column; padding:var(--sp-3); gap:var(--sp-2); overflow:hidden; }
|
||||||
|
.detail-header { display:flex; align-items:center; justify-content:space-between; }
|
||||||
|
.detail-kind { font-family:var(--font-ui); font-size:var(--text-2xs); letter-spacing:var(--tracking-wider); text-transform:uppercase; color:var(--text-faint); }
|
||||||
|
.detail-error .detail-kind { color:var(--color-error); }
|
||||||
|
|
||||||
|
.detail-close { display:flex; align-items:center; justify-content:center; width:20px; height:20px; border-radius:var(--radius-sm); background:none; border:none; color:var(--text-faint); cursor:pointer; transition:color var(--t-fast), background var(--t-fast); }
|
||||||
|
.detail-close:hover { color:var(--text-primary); background:var(--bg-overlay); }
|
||||||
|
|
||||||
|
.detail-message { font-family:var(--font-ui); font-size:var(--text-sm); color:var(--text-secondary); font-weight:var(--weight-medium); line-height:var(--leading-snug); word-break:break-word; }
|
||||||
|
|
||||||
|
.detail-text { flex:1; min-height:0; overflow-y:auto; font-family:var(--font-ui); font-size:var(--text-xs); color:var(--text-muted); line-height:var(--leading-base); white-space:pre-wrap; word-break:break-all; background:var(--bg-void); border:1px solid var(--border-dim); border-radius:var(--radius-sm); padding:var(--sp-2) var(--sp-3); scrollbar-width:thin; margin:0; }
|
||||||
|
|
||||||
|
.detail-actions { display:flex; gap:var(--sp-2); margin-top:var(--sp-1); }
|
||||||
|
.detail-copy, .detail-dismiss { font-family:var(--font-ui); font-size:var(--text-2xs); letter-spacing:var(--tracking-wide); padding:5px var(--sp-3); border-radius:var(--radius-sm); cursor:pointer; transition:color var(--t-base), background var(--t-base), border-color var(--t-base); }
|
||||||
|
.detail-copy { border:1px solid var(--border-dim); background:none; color:var(--text-muted); }
|
||||||
|
.detail-copy:hover { color:var(--text-primary); border-color:var(--border-strong); background:var(--bg-overlay); }
|
||||||
|
.detail-dismiss { border:1px solid color-mix(in srgb, var(--color-error) 40%, transparent); background:color-mix(in srgb, var(--color-error) 10%, transparent); color:var(--color-error); }
|
||||||
|
.detail-dismiss:hover { background:color-mix(in srgb, var(--color-error) 18%, transparent); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
const CARD_COUNT = 18
|
||||||
|
const CARD_W = 52
|
||||||
|
const CARD_H = 72
|
||||||
|
const CARD_RADIUS = 6
|
||||||
|
const DRIFT_SPEED = 0.018
|
||||||
|
|
||||||
|
interface Card {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
vx: number
|
||||||
|
vy: number
|
||||||
|
rot: number
|
||||||
|
vrot: number
|
||||||
|
opacity: number
|
||||||
|
scale: number
|
||||||
|
hue: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCard(w: number, h: number): Card {
|
||||||
|
const side = Math.floor(Math.random() * 4)
|
||||||
|
const margin = 80
|
||||||
|
let x = 0, y = 0
|
||||||
|
if (side === 0) { x = Math.random() * w; y = -margin }
|
||||||
|
if (side === 1) { x = w + margin; y = Math.random() * h }
|
||||||
|
if (side === 2) { x = Math.random() * w; y = h + margin }
|
||||||
|
if (side === 3) { x = -margin; y = Math.random() * h }
|
||||||
|
const cx = w / 2, cy = h / 2
|
||||||
|
const dx = cx - x, dy = cy - y
|
||||||
|
const len = Math.sqrt(dx * dx + dy * dy) || 1
|
||||||
|
const spd = 0.12 + Math.random() * 0.1
|
||||||
|
return {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
vx: (dx / len) * spd * (0.3 + Math.random() * 0.4),
|
||||||
|
vy: (dy / len) * spd * (0.3 + Math.random() * 0.4),
|
||||||
|
rot: Math.random() * Math.PI * 2,
|
||||||
|
vrot: (Math.random() - 0.5) * 0.006,
|
||||||
|
opacity: 0.025 + Math.random() * 0.055,
|
||||||
|
scale: 0.7 + Math.random() * 0.7,
|
||||||
|
hue: 120 + Math.random() * 40,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawCard(ctx: CanvasRenderingContext2D, c: Card) {
|
||||||
|
ctx.save()
|
||||||
|
ctx.globalAlpha = c.opacity
|
||||||
|
ctx.translate(c.x, c.y)
|
||||||
|
ctx.rotate(c.rot)
|
||||||
|
ctx.scale(c.scale, c.scale)
|
||||||
|
|
||||||
|
const w = CARD_W, h = CARD_H, r = CARD_RADIUS
|
||||||
|
const x = -w / 2, y = -h / 2
|
||||||
|
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(x + r, y)
|
||||||
|
ctx.lineTo(x + w - r, y)
|
||||||
|
ctx.quadraticCurveTo(x + w, y, x + w, y + r)
|
||||||
|
ctx.lineTo(x + w, y + h - r)
|
||||||
|
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h)
|
||||||
|
ctx.lineTo(x + r, y + h)
|
||||||
|
ctx.quadraticCurveTo(x, y + h, x, y + h - r)
|
||||||
|
ctx.lineTo(x, y + r)
|
||||||
|
ctx.quadraticCurveTo(x, y, x + r, y)
|
||||||
|
ctx.closePath()
|
||||||
|
|
||||||
|
ctx.strokeStyle = `hsla(${c.hue}, 28%, 62%, 0.9)`
|
||||||
|
ctx.lineWidth = 1 / c.scale
|
||||||
|
ctx.stroke()
|
||||||
|
|
||||||
|
const grad = ctx.createLinearGradient(x, y, x, y + h)
|
||||||
|
grad.addColorStop(0, `hsla(${c.hue}, 20%, 40%, 0.18)`)
|
||||||
|
grad.addColorStop(1, `hsla(${c.hue}, 20%, 20%, 0.06)`)
|
||||||
|
ctx.fillStyle = grad
|
||||||
|
ctx.fill()
|
||||||
|
|
||||||
|
ctx.restore()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mountCardCanvas(canvas: HTMLCanvasElement) {
|
||||||
|
const ctx = canvas.getContext('2d')!
|
||||||
|
let cards = Array.from({ length: CARD_COUNT }, () => makeCard(canvas.offsetWidth || 800, canvas.offsetHeight || 600))
|
||||||
|
let raf = 0
|
||||||
|
let running = true
|
||||||
|
|
||||||
|
function resize() {
|
||||||
|
const dpr = window.devicePixelRatio || 1
|
||||||
|
canvas.width = canvas.offsetWidth * dpr
|
||||||
|
canvas.height = canvas.offsetHeight * dpr
|
||||||
|
ctx.scale(dpr, dpr)
|
||||||
|
cards = Array.from({ length: CARD_COUNT }, () => makeCard(canvas.offsetWidth, canvas.offsetHeight))
|
||||||
|
}
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
if (!running) return
|
||||||
|
const w = canvas.offsetWidth, h = canvas.offsetHeight
|
||||||
|
ctx.clearRect(0, 0, w, h)
|
||||||
|
for (const c of cards) {
|
||||||
|
c.x += c.vx
|
||||||
|
c.y += c.vy
|
||||||
|
c.rot += c.vrot
|
||||||
|
const pad = 120
|
||||||
|
if (c.x < -pad || c.x > w + pad || c.y < -pad || c.y > h + pad) {
|
||||||
|
Object.assign(c, makeCard(w, h))
|
||||||
|
}
|
||||||
|
drawCard(ctx, c)
|
||||||
|
}
|
||||||
|
raf = requestAnimationFrame(tick)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ro = new ResizeObserver(resize)
|
||||||
|
ro.observe(canvas)
|
||||||
|
resize()
|
||||||
|
tick()
|
||||||
|
|
||||||
|
return {
|
||||||
|
destroy() {
|
||||||
|
running = false
|
||||||
|
cancelAnimationFrame(raf)
|
||||||
|
ro.disconnect()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ringGeometry(r: number, pad: number) {
|
||||||
|
const size = (r + pad) * 2
|
||||||
|
const c = size / 2
|
||||||
|
const circ = 2 * Math.PI * r
|
||||||
|
return { size, c, circ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const RING_STEPS = [
|
||||||
|
{ target: 0.15, duration: 400 },
|
||||||
|
{ target: 0.45, duration: 800 },
|
||||||
|
{ target: 0.72, duration: 600 },
|
||||||
|
{ target: 0.88, duration: 1000 },
|
||||||
|
{ target: 0.96, duration: 700 },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function animateRingProgress(onProgress: (p: number) => void): () => void {
|
||||||
|
let current = 0.025
|
||||||
|
let stepIdx = 0
|
||||||
|
let start = performance.now()
|
||||||
|
let raf = 0
|
||||||
|
let stopped = false
|
||||||
|
|
||||||
|
function ease(t: number) {
|
||||||
|
return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t
|
||||||
|
}
|
||||||
|
|
||||||
|
function tick(now: number) {
|
||||||
|
if (stopped) return
|
||||||
|
if (stepIdx >= RING_STEPS.length) return
|
||||||
|
|
||||||
|
const step = RING_STEPS[stepIdx]
|
||||||
|
const elapsed = now - start
|
||||||
|
const t = Math.min(elapsed / step.duration, 1)
|
||||||
|
const from = stepIdx === 0 ? 0.025 : RING_STEPS[stepIdx - 1].target
|
||||||
|
current = from + (step.target - from) * ease(t)
|
||||||
|
onProgress(current)
|
||||||
|
|
||||||
|
if (t >= 1) {
|
||||||
|
stepIdx++
|
||||||
|
start = now
|
||||||
|
}
|
||||||
|
|
||||||
|
raf = requestAnimationFrame(tick)
|
||||||
|
}
|
||||||
|
|
||||||
|
raf = requestAnimationFrame(tick)
|
||||||
|
return () => { stopped = true; cancelAnimationFrame(raf) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export type OsKind = 'macos' | 'windows' | 'linux' | 'unknown'
|
||||||
|
|
||||||
|
export async function detectOs(): Promise<OsKind> {
|
||||||
|
try {
|
||||||
|
const { platform } = await import('@tauri-apps/plugin-os')
|
||||||
|
const p = await platform()
|
||||||
|
if (p === 'macos') return 'macos'
|
||||||
|
if (p === 'windows') return 'windows'
|
||||||
|
if (p === 'linux') return 'linux'
|
||||||
|
return 'unknown'
|
||||||
|
} catch {
|
||||||
|
return 'unknown'
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,92 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { appState } from '$lib/state/app.svelte'
|
||||||
|
import { notificationsState } from '$lib/state/notifications.svelte'
|
||||||
|
import SplashScreen from '$lib/ui/chrome/SplashScreen.svelte'
|
||||||
|
import AuthGate from '$lib/ui/chrome/AuthGate.svelte'
|
||||||
|
import Sidebar from '$lib/ui/chrome/Sidebar.svelte'
|
||||||
|
import TitleBar from '$lib/ui/chrome/TitleBar.svelte'
|
||||||
|
import Toaster from '$lib/ui/chrome/Toaster.svelte'
|
||||||
import '../app.css'
|
import '../app.css'
|
||||||
|
|
||||||
let { children } = $props()
|
let { children } = $props()
|
||||||
|
|
||||||
|
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
|
||||||
|
const ringFull = $derived(appState.status !== 'booting')
|
||||||
|
const splashDone = $derived(
|
||||||
|
appState.status === 'ready' ||
|
||||||
|
appState.status === 'auth' ||
|
||||||
|
appState.status === 'error'
|
||||||
|
)
|
||||||
|
|
||||||
|
let bypassed = $state(false)
|
||||||
|
const showApp = $derived(splashDone && (appState.status === 'ready' || appState.status === 'auth' || bypassed))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{@render children()}
|
{#if !showApp}
|
||||||
|
<SplashScreen
|
||||||
|
mode="loading"
|
||||||
|
{ringFull}
|
||||||
|
failed={appState.status === 'error'}
|
||||||
|
onReady={() => {}}
|
||||||
|
onBypass={() => (bypassed = true)}
|
||||||
|
onRetry={() => window.location.reload()}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showApp}
|
||||||
|
<div class="frame">
|
||||||
|
<div class="shell">
|
||||||
|
{#if isTauri}
|
||||||
|
<TitleBar onClose={() => import('@tauri-apps/api/window').then(m => m.getCurrentWindow().close())} />
|
||||||
|
{/if}
|
||||||
|
<div class="body">
|
||||||
|
<Sidebar />
|
||||||
|
<main class="main">
|
||||||
|
{@render children()}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<AuthGate />
|
||||||
|
<Toaster toasts={notificationsState.toasts} />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.frame {
|
||||||
|
display: flex;
|
||||||
|
padding: 6px 15px 15px;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
border-radius: var(--radius-2xl);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
background: var(--bg-base);
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
transform: translateZ(0);
|
||||||
|
contain: layout style;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<p>browse</p>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<p>downloads</p>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<p>extensions</p>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<p>library</p>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<p>settings</p>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<p>tracking</p>
|
||||||
Reference in New Issue
Block a user