Improve: add global loading screen (#237)

* refactor(desktop): move ext loading code in store from +page.ts to +page.svelte

try to solve blank screen on slow network

* Revert "refactor(desktop): move ext loading code in store from +page.ts to +page.svelte"

This reverts commit 4a0a695ce615cee695849c64746ba569680ff8c4.

* feat(desktop): add full-screen loading state and border beam animation

- Implement full-screen loading component with BorderBeam animation
- Add fullScreenLoading flag to appState store
- Update extension store pages to use full-screen loading
- Add border beam animation to Tailwind config
- Enhance page loading experience with visual feedback

* feat(desktop): add dance animation to loading screen and update imports

- Add Dance component to FullScreenLoading with subtle background effect
- Remove unused fade transition import from layout
- Update lz-string import in utils to use default import
- Clean up compress test imports

* feat(desktop): add back button to full-screen loading component

- Import ArrowLeftIcon and Constants from @kksh/ui
- Add back button with absolute positioning
- Remove "Go Home" text button
- Enhance loading screen with improved navigation

* refactor(desktop): update BorderBeam component to use Svelte 5 runes
This commit is contained in:
Huakun 2025-03-07 13:00:15 -05:00 committed by GitHub
parent cc7cea7fe9
commit 234f245a9c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 208 additions and 65 deletions

View File

@ -0,0 +1,44 @@
<script lang="ts">
import { cn } from "$lib/utils"
let {
class: className,
size = 200,
duration = 15,
anchor = 90,
borderWidth = 1.5,
colorFrom = "#ffaa40",
colorTo = "#9c40ff",
delay = 0
}: {
class?: string
size?: number
duration?: number
anchor?: number
borderWidth?: number
colorFrom?: string
colorTo?: string
delay?: number
} = $props()
let delaySec = delay + "s"
</script>
<div
style:--border-width={borderWidth}
style:--size={size}
style:--color-from={colorFrom}
style:--color-to={colorTo}
style:--delay={delaySec}
style:--anchor={anchor}
style:--duration={duration}
class={cn(
"pointer-events-none absolute inset-[0] rounded-[inherit] [border:calc(var(--border-width)*1px)_solid_transparent]",
// mask styles
"![mask-clip:padding-box,border-box] ![mask-composite:intersect] [mask:linear-gradient(transparent,transparent),linear-gradient(white,white)]",
// pseudo styles
"after:animate-border-beam after:absolute after:aspect-square after:w-[calc(var(--size)*1px)] after:[animation-delay:var(--delay)] after:[background:linear-gradient(to_left,var(--color-from),var(--color-to),transparent)] after:[offset-anchor:calc(var(--anchor)*1%)_50%] after:[offset-path:rect(0_auto_auto_0_round_calc(var(--size)*1px))]",
className
)}
></div>

View File

@ -0,0 +1,33 @@
<script lang="ts">
import { appState } from "@/stores"
import { cn } from "@/utils"
import { Button } from "@kksh/svelte5"
import { Constants, Layouts, TauriLink } from "@kksh/ui"
import { goto } from "$app/navigation"
import BorderBeam from "$lib/components/animations/BorderBeam.svelte"
import { ArrowLeftIcon, LoaderCircleIcon } from "lucide-svelte"
import Dance from "../dance/dance.svelte"
let { class: className }: { class?: string } = $props()
function goHome() {
appState.setFullScreenLoading(false)
goto("/app")
}
</script>
<Layouts.Center class={cn("flex h-screen flex-col items-center justify-center", className)}>
<Button
variant="outline"
size="icon"
onclick={goHome}
class={cn(Constants.CLASSNAMES.BACK_BUTTON, "absolute left-4 top-4")}
data-flip-id={Constants.CLASSNAMES.BACK_BUTTON}
>
<ArrowLeftIcon class="size-4" />
</Button>
<Dance class="absolute z-50 h-screen opacity-20" />
<LoaderCircleIcon class="h-24 w-24 animate-spin" />
<span class="font-mono">Loading</span>
<BorderBeam size={150} duration={12} />
</Layouts.Center>

View File

@ -8,7 +8,8 @@ export const defaultAppState: AppState = {
loadingBar: false, loadingBar: false,
defaultAction: "", defaultAction: "",
actionPanel: undefined, actionPanel: undefined,
lockHideOnBlur: false // when dialog is open, we don't hide the app, we lock the hide on blur and unlock when dialog is closed lockHideOnBlur: false, // when dialog is open, we don't hide the app, we lock the hide on blur and unlock when dialog is closed
fullScreenLoading: false
} }
interface AppStateAPI { interface AppStateAPI {
@ -18,6 +19,7 @@ interface AppStateAPI {
setDefaultAction: (defaultAction: string | null) => void setDefaultAction: (defaultAction: string | null) => void
setActionPanel: (actionPanel?: ActionSchema.ActionPanel) => void setActionPanel: (actionPanel?: ActionSchema.ActionPanel) => void
setLockHideOnBlur: (lockHideOnBlur: boolean) => void setLockHideOnBlur: (lockHideOnBlur: boolean) => void
setFullScreenLoading: (fullScreenLoading: boolean) => void
} }
function createAppState(): Writable<AppState> & AppStateAPI { function createAppState(): Writable<AppState> & AppStateAPI {
@ -40,6 +42,9 @@ function createAppState(): Writable<AppState> & AppStateAPI {
}, },
setLockHideOnBlur: (lockHideOnBlur: boolean) => { setLockHideOnBlur: (lockHideOnBlur: boolean) => {
store.update((state) => ({ ...state, lockHideOnBlur })) store.update((state) => ({ ...state, lockHideOnBlur }))
},
setFullScreenLoading: (fullScreenLoading: boolean) => {
store.update((state) => ({ ...state, fullScreenLoading }))
} }
} }
} }

View File

@ -2,6 +2,8 @@
import { ParaglideJS } from "@inlang/paraglide-sveltekit" import { ParaglideJS } from "@inlang/paraglide-sveltekit"
import { i18n } from "$lib/i18n" import { i18n } from "$lib/i18n"
import "../app.css" import "../app.css"
import FullScreenLoading from "@/components/common/FullScreenLoading.svelte"
import { appState } from "@/stores/appState"
import { ModeWatcher, ThemeWrapper } from "@kksh/svelte5" import { ModeWatcher, ThemeWrapper } from "@kksh/svelte5"
import { Toaster } from "svelte-sonner" import { Toaster } from "svelte-sonner"
@ -12,6 +14,9 @@
<ModeWatcher /> <ModeWatcher />
<Toaster richColors closeButton /> <Toaster richColors closeButton />
<ThemeWrapper> <ThemeWrapper>
{#if $appState.fullScreenLoading}
<FullScreenLoading class="bg-background absolute inset-0 z-50" />
{/if}
{@render children()} {@render children()}
</ThemeWrapper> </ThemeWrapper>
</ParaglideJS> </ParaglideJS>

View File

@ -17,8 +17,9 @@
import { platform } from "@tauri-apps/plugin-os" import { platform } from "@tauri-apps/plugin-os"
import { goto } from "$app/navigation" import { goto } from "$app/navigation"
import { ArrowLeft } from "lucide-svelte" import { ArrowLeft } from "lucide-svelte"
import type { Snippet } from "svelte" import { onMount, type Snippet } from "svelte"
import { toast } from "svelte-sonner" import { toast } from "svelte-sonner"
import type { Action as SvelteAction } from "svelte/action"
import { getInstallExtras } from "./[identifier]/helper.js" import { getInstallExtras } from "./[identifier]/helper.js"
let { data } = $props() let { data } = $props()
@ -38,6 +39,38 @@
// ) // )
// } // }
onMount(() => {
// setTimeout(() => {
// console.log("focus", listviewInputRef)
// listviewInputRef?.focus()
// }, 3_000)
})
const inputAction: SvelteAction = (node) => {
// the node has been mounted in the DOM
$effect(() => {
// setup goes here
console.log("inputAction", node)
listviewInputRef?.focus()
return () => {
// teardown goes here
}
})
}
$effect(() => {
function docKeyDown(e: KeyboardEvent) {
if (e.key === "/") {
listviewInputRef?.focus()
}
}
document.addEventListener("keydown", docKeyDown)
return () => {
document.removeEventListener("keydown", docKeyDown)
}
})
function onExtItemSelected(ext: SBExt) { function onExtItemSelected(ext: SBExt) {
goto(`./store/${ext.identifier}`) goto(`./store/${ext.identifier}`)
} }
@ -142,7 +175,7 @@
<CustomCommandInput <CustomCommandInput
bind:ref={listviewInputRef} bind:ref={listviewInputRef}
autofocus autofocus
placeholder="Type a command or search..." placeholder="Type / to focus"
leftSlot={leftSlot as Snippet} leftSlot={leftSlot as Snippet}
bind:value={$appState.searchTerm} bind:value={$appState.searchTerm}
onkeydown={(e) => { onkeydown={(e) => {

View File

@ -1,43 +1,51 @@
import { appConfig, extensions, installedStoreExts } from "@/stores" import { appConfig, appState, extensions, installedStoreExts } from "@/stores"
import { supabaseAPI } from "@/supabase" import { supabaseAPI } from "@/supabase"
import type { ExtPackageJsonExtra } from "@kksh/api/models" import type { ExtPackageJsonExtra } from "@kksh/api/models"
import { isExtPathInDev, isUpgradable } from "@kksh/extension" import { isExtPathInDev, isUpgradable } from "@kksh/extension"
import { SBExt } from "@kksh/supabase/models" import { SBExt } from "@kksh/supabase/models"
import { sleep } from "@kksh/utils"
import { error } from "@sveltejs/kit" import { error } from "@sveltejs/kit"
import { derived, get, type Readable } from "svelte/store" import { derived, get, type Readable } from "svelte/store"
import type { PageLoad } from "./$types" import type { PageLoad } from "./$types"
export const load: PageLoad = async (): Promise<{ export const load: PageLoad = (): Promise<{
storeExtList: SBExt[] storeExtList: SBExt[]
installedStoreExts: Readable<ExtPackageJsonExtra[]> installedStoreExts: Readable<ExtPackageJsonExtra[]>
installedExtsMap: Readable<Record<string, string>> installedExtsMap: Readable<Record<string, string>>
upgradableExpsMap: Readable<Record<string, boolean>> upgradableExpsMap: Readable<Record<string, boolean>>
}> => { }> => {
const storeExtList = await supabaseAPI.getExtList() appState.setFullScreenLoading(true)
// map identifier to extItem return supabaseAPI
const storeExtsMap = Object.fromEntries(storeExtList.map((ext) => [ext.identifier, ext])) .getExtList()
const _appConfig = get(appConfig) .then(async (storeExtList) => {
// const installedStoreExts = derived(extensions, ($extensions) => { // map identifier to extItem
// if (!_appConfig.extensionPath) return [] const storeExtsMap = Object.fromEntries(storeExtList.map((ext) => [ext.identifier, ext]))
// return $extensions.filter((ext) => !isExtPathInDev(_appConfig.extensionPath!, ext.extPath)) // const _appConfig = get(appConfig)
// }) // const installedStoreExts = derived(extensions, ($extensions) => {
// map installed extension identifier to version // if (!_appConfig.extensionPath) return []
const installedExtsMap = derived(installedStoreExts, ($exts) => // return $extensions.filter((ext) => !isExtPathInDev(_appConfig.extensionPath!, ext.extPath))
Object.fromEntries($exts.map((ext) => [ext.kunkun.identifier, ext.version])) // })
) // map installed extension identifier to version
const upgradableExpsMap = derived(installedStoreExts, ($exts) => const installedExtsMap = derived(installedStoreExts, ($exts) =>
Object.fromEntries( Object.fromEntries($exts.map((ext) => [ext.kunkun.identifier, ext.version]))
$exts.map((ext) => { )
const dbExt: SBExt | undefined = storeExtsMap[ext.kunkun.identifier] const upgradableExpsMap = derived(installedStoreExts, ($exts) =>
return [ext.kunkun.identifier, dbExt ? isUpgradable(dbExt, ext.version) : false] Object.fromEntries(
}) $exts.map((ext) => {
) const dbExt: SBExt | undefined = storeExtsMap[ext.kunkun.identifier]
) return [ext.kunkun.identifier, dbExt ? isUpgradable(dbExt, ext.version) : false]
})
)
)
return { return {
storeExtList, storeExtList,
installedStoreExts, installedStoreExts,
installedExtsMap, installedExtsMap,
upgradableExpsMap upgradableExpsMap
} }
})
.finally(() => {
appState.setFullScreenLoading(false)
})
} }

View File

@ -1,14 +1,15 @@
import { extensions } from "@/stores" import { appState, extensions } from "@/stores"
import { supabaseAPI } from "@/supabase" import { supabaseAPI } from "@/supabase"
import { KunkunExtManifest, type ExtPackageJsonExtra } from "@kksh/api/models" import { KunkunExtManifest, type ExtPackageJsonExtra } from "@kksh/api/models"
import { ExtPublishMetadata } from "@kksh/supabase/models" import { ExtPublishMetadata } from "@kksh/supabase/models"
import type { Tables } from "@kksh/supabase/types" import type { Tables } from "@kksh/supabase/types"
import { sleep } from "@kksh/utils"
import { error } from "@sveltejs/kit" import { error } from "@sveltejs/kit"
import { toast } from "svelte-sonner" import { toast } from "svelte-sonner"
import * as v from "valibot" import * as v from "valibot"
import type { PageLoad } from "./$types" import type { PageLoad } from "./$types"
export const load: PageLoad = async ({ export const load: PageLoad = ({
params params
}): Promise<{ }): Promise<{
extPublish: Tables<"ext_publish"> & { metadata: ExtPublishMetadata } extPublish: Tables<"ext_publish"> & { metadata: ExtPublishMetadata }
@ -18,36 +19,41 @@ export const load: PageLoad = async ({
identifier: string identifier: string
} }
}> => { }> => {
const { error: dbError, data: extPublish } = await supabaseAPI.getLatestExtPublish( appState.setFullScreenLoading(true)
params.identifier return supabaseAPI
) .getLatestExtPublish(params.identifier)
const metadataParse = v.safeParse(ExtPublishMetadata, extPublish?.metadata ?? {}) .then(async ({ error: dbError, data: extPublish }) => {
if (dbError) { const metadataParse = v.safeParse(ExtPublishMetadata, extPublish?.metadata ?? {})
return error(400, { if (dbError) {
message: dbError.message return error(400, {
}) message: dbError.message
} })
const metadata = metadataParse.success ? metadataParse.output : {} }
const parseManifest = v.safeParse(KunkunExtManifest, extPublish.manifest) const metadata = metadataParse.success ? metadataParse.output : {}
if (!parseManifest.success) { const parseManifest = v.safeParse(KunkunExtManifest, extPublish.manifest)
const errMsg = "Invalid extension manifest, you may need to upgrade your app." if (!parseManifest.success) {
toast.error(errMsg) const errMsg = "Invalid extension manifest, you may need to upgrade your app."
throw error(400, errMsg) toast.error(errMsg)
} throw error(400, errMsg)
}
const { data: ext, error: extError } = await supabaseAPI.getExtension(params.identifier) const { data: ext, error: extError } = await supabaseAPI.getExtension(params.identifier)
if (extError) { if (extError) {
return error(400, { return error(400, {
message: extError.message message: extError.message
}) })
} }
return { return {
extPublish: { ...extPublish, metadata }, extPublish: { ...extPublish, metadata },
ext, ext,
params, params,
manifest: parseManifest.output manifest: parseManifest.output
} }
})
.finally(() => {
appState.setFullScreenLoading(false)
})
} }
export const csr = true export const csr = true

View File

@ -86,12 +86,18 @@ const config: Config = {
"caret-blink": { "caret-blink": {
"0%,70%,100%": { opacity: "1" }, "0%,70%,100%": { opacity: "1" },
"20%,50%": { opacity: "0" } "20%,50%": { opacity: "0" }
},
"border-beam": {
"100%": {
"offset-distance": "100%"
}
} }
}, },
animation: { animation: {
"accordion-down": "accordion-down 0.2s ease-out", "accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out", "accordion-up": "accordion-up 0.2s ease-out",
"caret-blink": "caret-blink 1.25s ease-out infinite" "caret-blink": "caret-blink 1.25s ease-out infinite",
"border-beam": "border-beam calc(var(--duration)*1s) infinite linear"
} }
} }
}, },

View File

@ -7,4 +7,5 @@ export interface AppState {
defaultAction: string | null defaultAction: string | null
actionPanel?: ActionSchema.ActionPanel actionPanel?: ActionSchema.ActionPanel
lockHideOnBlur: boolean lockHideOnBlur: boolean
fullScreenLoading: boolean
} }

View File

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { cn } from "@kksh/ui/utils" import { cn } from "@kksh/ui/utils"
import { type Snippet } from "svelte" import { type Snippet } from "svelte"
import { fade } from "svelte/transition"
const { const {
children, children,
@ -10,6 +11,6 @@
}: { children: Snippet; class?: string; [key: string]: any } = $props() }: { children: Snippet; class?: string; [key: string]: any } = $props()
</script> </script>
<div class={cn("flex items-center justify-center", className)} {...restProps}> <div transition:fade class={cn("flex items-center justify-center", className)} {...restProps}>
{@render children?.()} {@render children?.()}
</div> </div>

View File

@ -1,5 +1,4 @@
import { expect, test } from "bun:test" import { expect, test } from "bun:test"
import { compress, decompress } from "lz-string"
import { compressString, decompressString } from "../src" import { compressString, decompressString } from "../src"
test("decompressString", async () => { test("decompressString", async () => {

View File

@ -1,4 +1,6 @@
import { compressToBase64, decompressFromBase64 } from "lz-string" import lzString from "lz-string"
const { compressToBase64, decompressFromBase64 } = lzString
/** /**
* This file contains the deserialization and compression functions I designed for the grid animation. * This file contains the deserialization and compression functions I designed for the grid animation.