Feature: Page Transition and Component Animation (#9)

* feat: add view transition for page transition, add install btns animation

* feat(desktop): add extension store logo cross-page transition with gasp Flip
This commit is contained in:
Huakun Shen 2024-11-04 16:03:53 -05:00 committed by GitHub
parent ad83e89e52
commit 11cc79627d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 252 additions and 70 deletions

View File

@ -14,6 +14,7 @@
},
"license": "MIT",
"dependencies": {
"@formkit/auto-animate": "^0.8.2",
"@kksh/extension": "workspace:*",
"@kksh/supabase": "workspace:*",
"@kksh/ui": "workspace:*",
@ -22,6 +23,7 @@
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-shell": "^2",
"bits-ui": "1.0.0-next.36",
"gsap": "^3.12.5",
"lucide-svelte": "^0.454.0",
"lz-string": "^1.5.0",
"mode-watcher": "^0.4.1",

View File

@ -0,0 +1,34 @@
<script lang="ts">
import { Layouts } from "@kksh/ui"
import { cn } from "@kksh/ui/utils"
import { onMount } from "svelte"
import { fade } from "svelte/transition"
import Dance from "./dance.svelte"
let {
duration = 400,
class: className,
delay = 0,
show = $bindable(true),
autoHide = true
}: {
duration?: number
class?: string
delay?: number
show?: boolean
autoHide?: boolean
} = $props()
onMount(() => {
setTimeout(() => {
if (autoHide) show = false
}, delay ?? 0)
})
</script>
{#if show}
<div out:fade={{ duration, delay }}>
<Layouts.Center class={cn("bg-background absolute h-screen w-screen", className)} hidden={true}>
<Dance />
</Layouts.Center>
</div>
{/if}

View File

@ -11,6 +11,7 @@
updateTheme,
type ThemeConfig
} from "@kksh/svelte5"
import { ViewTransition } from "@kksh/ui"
import type { UnlistenFn } from "@tauri-apps/api/event"
import { attachConsole } from "@tauri-apps/plugin-log"
import { onDestroy, onMount } from "svelte"
@ -32,6 +33,7 @@
})
</script>
<ViewTransition />
<ModeWatcher />
<Toaster richColors />
<AppContext {appConfig} {appState}>

View File

@ -1,9 +1,9 @@
<script lang="ts">
import Dance from "@/components/dance/dance.svelte"
import { goBackOnEscape } from "@/utils/key"
import { goBack } from "@/utils/route"
import { Button } from "@kksh/svelte5"
import { Layouts } from "@kksh/ui"
import Dance from "$lib/components/dance.svelte"
import ArrowLeft from "svelte-radix/ArrowLeft.svelte"
</script>

View File

@ -0,0 +1,31 @@
<script lang="ts">
import { Constants } from "@kksh/ui"
import { afterNavigate, beforeNavigate } from "$app/navigation"
import { gsap } from "gsap"
import { Flip } from "gsap/Flip"
gsap.registerPlugin(Flip)
let flipState: Flip.FlipState
beforeNavigate(() => {
flipState = Flip.getState(`.${Constants.CLASSNAMES.EXT_LOGO}`)
})
afterNavigate(() => {
if (!flipState) {
return
}
Flip.from(flipState, {
targets: ".kk-ext-logo",
duration: 0.5,
absolute: true,
scale: true,
ease: "ease-out"
})
})
const { children } = $props()
</script>
{@render children?.()}

View File

@ -46,7 +46,6 @@
}
async function onExtItemInstall(ext: SBExt) {
console.log("onExtItemInstall", ext)
const res = await supabaseAPI.getLatestExtPublish(ext.identifier)
if (res.error)
return toast.error("Fail to get latest extension", {
@ -77,5 +76,6 @@
{onExtItemUpgrade}
{onExtItemInstall}
{isUpgradable}
bind:searchTerm={$appState.searchTerm}
onGoBack={goBack}
/>

View File

@ -33,7 +33,6 @@ export const load: PageLoad = async (): Promise<{
})
)
)
console.log(get(upgradableExpsMap))
return {
storeExtList,

View File

@ -1,27 +1,55 @@
<script lang="ts">
import { getExtensionsFolder } from "@/constants.js"
import { appConfig } from "@/stores/appConfig.js"
import { extensions, installedStoreExts } from "@/stores/extensions.js"
import { supabase, supabaseAPI, supabaseExtensionsStorage } from "@/supabase"
import { goBackOnEscape } from "@/utils/key"
import { supabaseAPI } from "@/supabase"
import { goBack } from "@/utils/route.js"
import { isExtPathInDev } from "@kksh/extension"
import { installTarballUrl } from "@kksh/extension/install"
import { Button } from "@kksh/svelte5"
import { StoreExtDetail } from "@kksh/ui/extension"
import * as path from "@tauri-apps/api/path"
import { greaterThan, parse as parseSemver } from "@std/semver"
import { error } from "@tauri-apps/plugin-log"
import { ArrowLeftIcon } from "lucide-svelte"
import { onMount } from "svelte"
import { toast } from "svelte-sonner"
import { get, derived as storeDerived } from "svelte/store"
import * as v from "valibot"
const { data } = $props()
let { ext, manifest } = data
const installedExt = storeDerived(installedStoreExts, ($e) => {
return $e.find((e) => e.kunkun.identifier === ext.identifier)
})
let btnLoading = $state(false)
const isUpgradable = $derived(
$installedExt
? greaterThan(parseSemver(ext.version), parseSemver($installedExt.version))
: false
)
$effect(() => {
console.log("isUpgradable", isUpgradable)
if (isUpgradable) {
showBtn.upgrade = true
showBtn.install = false
showBtn.uninstall = true
}
})
onMount(() => {
showBtn = {
install: !installedExt,
upgrade: isUpgradable,
uninstall: !!installedExt
}
})
let loading = $state({
install: false,
uninstall: false,
upgrade: false
})
let showBtn = $state({
install: false,
upgrade: false,
uninstall: false
})
let imageDialogOpen = $state(false)
let delayedImageDialogOpen = $state(false)
$effect(() => {
@ -36,7 +64,7 @@
)
async function onInstallSelected() {
btnLoading = true
loading.install = true
const tarballUrl = supabaseAPI.translateExtensionFilePathToUrl(ext.tarball_path)
const installDir = await getExtensionsFolder()
return extensions
@ -52,12 +80,14 @@
toast.error("Fail to install tarball", { description: err })
})
.finally(() => {
btnLoading = false
loading.install = false
showBtn.install = false
showBtn.uninstall = true
})
}
function onUpgradeSelected() {
btnLoading = true
loading.upgrade = true
const tarballUrl = supabaseAPI.translateExtensionFilePathToUrl(ext.tarball_path)
return extensions
.upgradeStoreExtension(ext.identifier, tarballUrl)
@ -68,12 +98,16 @@
toast.error("Fail to upgrade extension", { description: err })
})
.finally(() => {
btnLoading = false
setTimeout(() => {
loading.upgrade = false
showBtn.upgrade = false
showBtn.uninstall = true
}, 2000)
})
}
function onUninstallSelected() {
btnLoading = true
loading.uninstall = true
return extensions
.uninstallStoreExtensionByIdentifier(ext.identifier)
.then((uninstalledExt) => {
@ -84,7 +118,9 @@
error(`Fail to uninstall store extension (${ext.identifier}): ${err}`)
})
.finally(() => {
btnLoading = false
loading.uninstall = false
showBtn.uninstall = false
showBtn.install = true
})
}
@ -111,7 +147,8 @@
{manifest}
installedExt={$installedExt}
{demoImages}
bind:btnLoading
{showBtn}
{loading}
{onInstallSelected}
{onUpgradeSelected}
{onUninstallSelected}

View File

@ -1,5 +1,6 @@
<script lang="ts">
import Dance from "@/components/dance.svelte"
import DanceTransition from "@/components/dance/dance-transition.svelte"
import Dance from "@/components/dance/dance.svelte"
import { appConfig, winExtMap } from "@/stores"
import { goBackOnEscape } from "@/utils/key"
import { goHome } from "@/utils/route"
@ -205,16 +206,12 @@
{/if}
<main class="h-screen">
{#if !uiControl.iframeLoaded}
<div class="bg-background absolute h-screen w-screen" out:fade>
<Layouts.Center class="h-full w-full" hidden={true}>
<Dance />
</Layouts.Center>
</div>
{/if}
<DanceTransition delay={300} autoHide={false} show={!uiControl.iframeLoaded} />
<iframe
bind:this={iframeRef}
class="h-full"
class={cn("h-full", {
hidden: !uiControl.iframeLoaded
})}
onload={onIframeLoaded}
width="100%"
height="100%"

View File

@ -34,8 +34,8 @@
"lint": "eslint ."
},
"devDependencies": {
"@types/bun": "latest",
"@kksh/api": "workspace:*",
"@types/bun": "latest",
"bits-ui": "1.0.0-next.36",
"clsx": "^2.1.1",
"lucide-svelte": "^0.454.0",
@ -50,6 +50,8 @@
"tailwindcss-animate": "^1.0.7"
},
"dependencies": {
"@std/semver": "npm:@jsr/std__semver@^1.0.3"
"@formkit/auto-animate": "^0.8.2",
"@std/semver": "npm:@jsr/std__semver@^1.0.3",
"gsap": "^3.12.5"
}
}

View File

@ -4,21 +4,25 @@
import { Button } from "@kksh/svelte5"
import { cn } from "@kksh/ui/utils"
const { icon, class: className }: { icon: TIcon; class?: string } = $props()
const {
icon,
class: className,
...restProps
}: { icon: TIcon; class?: string; [key: string]: any } = $props()
</script>
{#if icon.type === IconEnum.RemoteUrl}
<img loading="lazy" class={cn("", className)} src={icon.value} alt="" />
<img loading="lazy" class={cn("", className)} src={icon.value} alt="" {...restProps} />
{:else if icon.type === IconEnum.Iconify}
<Icon icon={icon.value} class={cn("", className)} />
<Icon icon={icon.value} class={cn("", className)} {...restProps} />
{:else if icon.type === IconEnum.Base64PNG}
<img loading="lazy" src="data:image/png;base64, {icon.value}" alt="" />
<img loading="lazy" src="data:image/png;base64, {icon.value}" alt="" {...restProps} />
{:else if icon.type === IconEnum.Text}
<Button class={cn("shrink-0 text-center", className)} size="icon">
<Button class={cn("shrink-0 text-center", className)} size="icon" {...restProps}>
{icon.value}
</Button>
{:else if icon.type === IconEnum.Svg}
<span>{@html icon.value}</span>
<span {...restProps}>{@html icon.value}</span>
{:else}
<Icon icon="mingcute:appstore-fill" class={cn("", className)} />
<Icon icon="mingcute:appstore-fill" class={cn("", className)} {...restProps} />
{/if}

View File

@ -14,7 +14,7 @@
</script>
<CommandPrimitive.Group
class={cn("text-foreground overflow-hidden p-1", className)}
class={cn("text-foreground overflow-hidden p-1 select-none", className)}
bind:ref
{...restProps}
>

View File

@ -3,8 +3,8 @@
import { Icon as TIcon } from "@kksh/api/models"
import { SBExt } from "@kksh/api/supabase"
import { Button, Command } from "@kksh/svelte5"
import { IconMultiplexer } from "@kksh/ui"
import { humanReadableNumber } from "@kksh/ui/utils"
import { Constants, IconMultiplexer } from "@kksh/ui"
import { cn, humanReadableNumber } from "@kksh/ui/utils"
import { greaterThan, parse as parseSemver } from "@std/semver"
import { CircleCheckBigIcon, MoveRightIcon } from "lucide-svelte"
import { parse } from "valibot"
@ -15,8 +15,10 @@
onSelect,
onUpgrade,
onInstall,
isUpgradable
isUpgradable,
class: className
}: {
class?: string
ext: SBExt
installedVersion?: string
onSelect: () => void
@ -26,11 +28,17 @@
} = $props()
</script>
<Command.Item class="flex items-center justify-between" {onSelect}>
<Command.Item class={cn("flex items-center justify-between", className)} {onSelect}>
<span class="flex items-center space-x-2">
<IconMultiplexer icon={parse(TIcon, ext.icon)} class="!h-6 !w-6 shrink-0" />
<span class="!h-6 !w-6">
<IconMultiplexer
icon={parse(TIcon, ext.icon)}
class={cn(Constants.CLASSNAMES.EXT_LOGO, "!h-6 !w-6 shrink-0")}
data-flip-id={`${Constants.CLASSNAMES.EXT_LOGO}-${ext.identifier}`}
/>
</span>
<span class="flex flex-col gap-0">
<div class="font-semibold">{ext.name}</div>
<div class="ext-name font-semibold">{ext.name}</div>
<small class="text-muted-foreground font-mono">{ext.short_description}</small>
</span>
</span>
@ -74,3 +82,9 @@
<span class="w-4 text-center font-mono">{humanReadableNumber(ext.downloads)}</span>
</span>
</Command.Item>
<style>
/* .ext-logo-image {
view-transition-name: var(--ext-logo-img);
} */
</style>

View File

@ -1,10 +1,11 @@
<script lang="ts">
import autoAnimate from "@formkit/auto-animate"
import Icon from "@iconify/svelte"
import { ExtPackageJsonExtra, IconEnum, KunkunExtManifest } from "@kksh/api/models"
import { type Tables } from "@kksh/api/supabase/types"
import { Button, ScrollArea, Separator } from "@kksh/svelte5"
import { IconMultiplexer } from "@kksh/ui"
import { greaterThan, parse as parseSemver } from "@std/semver"
import { Constants, IconMultiplexer } from "@kksh/ui"
import { cn } from "@kksh/ui/utils"
import { CircleCheckBigIcon, MoveRightIcon, Trash2Icon } from "lucide-svelte"
import DialogImageCarousel from "../common/DialogImageCarousel.svelte"
import PlatformsIcons from "../common/PlatformsIcons.svelte"
@ -20,7 +21,8 @@
onInstallSelected,
onUpgradeSelected,
onUninstallSelected,
btnLoading = $bindable(false),
showBtn,
loading,
imageDialogOpen = $bindable(false)
}: {
ext: Tables<"ext_publish">
@ -32,7 +34,16 @@
onUpgradeSelected?: () => void
onUninstallSelected?: () => void
onEnterPressed?: () => void
btnLoading: boolean
showBtn: {
upgrade: boolean
install: boolean
uninstall: boolean
}
loading: {
install: boolean
uninstall: boolean
upgrade: boolean
}
imageDialogOpen: boolean
} = $props()
@ -57,12 +68,12 @@
{#snippet upgradeBtn()}
<Button
class="w-full bg-yellow-600 hover:bg-yellow-500"
disabled={btnLoading}
disabled={loading.upgrade}
variant="destructive"
onclick={onUpgradeSelected}
>
<span>Upgrade</span>
{#if btnLoading}
{#if loading.upgrade}
{@render spinLoader()}
{:else}
<Icon icon="carbon:upgrade" class="inline h-5 w-5" />
@ -76,12 +87,12 @@
{#snippet uninstallBtn()}
<Button
class="w-full bg-red-600 hover:bg-red-500"
disabled={btnLoading}
disabled={loading.uninstall}
variant="destructive"
onclick={onUninstallSelected}
>
<span>Uninstall</span>
{#if btnLoading}
{#if loading.uninstall}
{@render spinLoader()}
{:else}
<Trash2Icon class="h-5 w-5" />
@ -92,11 +103,11 @@
{#snippet installBtn()}
<Button
class="w-full bg-green-700 text-white hover:bg-green-600"
disabled={btnLoading}
disabled={loading.install}
onclick={onInstallSelected}
>
<span>Install</span>
{#if btnLoading}
{#if loading.install}
{@render spinLoader()}
{:else}
<Icon icon="mi:enter" class="h-5 w-5" />
@ -107,10 +118,16 @@
<div data-tauri-drag-region class="h-14"></div>
<ScrollArea class="container pb-12">
<div class="flex items-center gap-4">
<IconMultiplexer icon={manifest.icon} class="h-12 w-12" />
<span class="h-12 w-12">
<IconMultiplexer
icon={manifest.icon}
class={cn(Constants.CLASSNAMES.EXT_LOGO, "h-full w-full")}
data-flip-id={`${Constants.CLASSNAMES.EXT_LOGO}-${ext.identifier}`}
/>
</span>
<div>
<span class="flex items-center">
<strong class="text-xl">{manifest?.name}</strong>
<strong class="ext-name text-xl">{manifest?.name}</strong>
{#if isInstalled}
<CircleCheckBigIcon class="ml-2 inline text-green-400" />
{/if}
@ -178,18 +195,14 @@
</ul>
</ScrollArea>
<footer class="fixed bottom-0 mb-1 h-10 w-full px-2">
{#if isInstalled}
{@const isUpgradable = installedExt
? greaterThan(parseSemver(ext.version), parseSemver(installedExt.version))
: false}
{#if isUpgradable}
<div class="flex gap-2">
{@render upgradeBtn()}
{@render uninstallBtn()}
</div>
{:else}{@render uninstallBtn()}{/if}
{:else}
<footer class="fixed bottom-0 mb-1 flex h-10 w-full space-x-2 px-2" use:autoAnimate>
{#if showBtn.upgrade}
{@render upgradeBtn()}
{/if}
{#if showBtn.uninstall}
{@render uninstallBtn()}
{/if}
{#if showBtn.install}
{@render installBtn()}
{/if}
</footer>

View File

@ -2,6 +2,8 @@
import { SBExt } from "@kksh/api/supabase"
import { Button, Command } from "@kksh/svelte5"
import { CustomCommandInput, GlobalCommandPaletteFooter } from "@kksh/ui/main"
import { cn } from "@kksh/ui/utils"
import { afterNavigate, beforeNavigate } from "$app/navigation"
import { type Snippet } from "svelte"
import ArrowLeft from "svelte-radix/ArrowLeft.svelte"
import type { Writable } from "svelte/store"
@ -16,7 +18,8 @@
upgradableExpsMap,
isUpgradable,
appState,
onGoBack
onGoBack,
searchTerm = $bindable("")
}: {
storeExtList: SBExt[]
installedExtsMap: Record<string, string>
@ -27,6 +30,7 @@
isUpgradable: (dbExt: SBExt, installedExtVersion: string) => boolean
onGoBack?: () => void
appState: Writable<{ searchTerm: string }>
searchTerm: string
} = $props()
</script>
@ -40,7 +44,7 @@
autofocus
placeholder="Type a command or search..."
leftSlot={leftSlot as Snippet}
bind:value={$appState.searchTerm}
bind:value={searchTerm}
/>
<Command.List class="max-h-screen grow">
<Command.Empty>No results found.</Command.Empty>

View File

@ -5,7 +5,7 @@
const { class: className }: { class?: string } = $props()
</script>
<footer data-tauri-drag-region class={cn("h-12 border-t", className)}>
<footer data-tauri-drag-region class={cn("h-12 select-none border-t", className)}>
<Avatar.Root class="p-2">
<Avatar.Image src="/favicon.png" alt="Kunkun Logo" class="select-none invert dark:invert-0" />
</Avatar.Root>

View File

@ -0,0 +1,16 @@
<script lang="ts">
import { onNavigate } from "$app/navigation"
onNavigate((navigation) => {
if (!document.startViewTransition) {
console.warn("View transition not supported")
return
}
return new Promise((resolve) => {
document.startViewTransition(async () => {
resolve()
await navigation.complete
})
})
})
</script>

View File

@ -0,0 +1,3 @@
export const CLASSNAMES = {
EXT_LOGO: "kk-ext-logo"
}

View File

@ -7,3 +7,5 @@ export * as Custom from "./components/custom"
export * as Main from "./components/main/index"
export * as Extension from "./components/extension/index"
export { default as GridAnimation } from "./components/animation/grid-animation.svelte"
export { default as ViewTransition } from "./components/transition/view-transition.svelte"
export * as Constants from "./constants"

22
pnpm-lock.yaml generated
View File

@ -114,6 +114,9 @@ importers:
apps/desktop:
dependencies:
'@formkit/auto-animate':
specifier: ^0.8.2
version: 0.8.2
'@kksh/extension':
specifier: workspace:*
version: link:../../packages/extension
@ -138,6 +141,9 @@ importers:
bits-ui:
specifier: 1.0.0-next.36
version: 1.0.0-next.36(svelte@5.1.9)
gsap:
specifier: ^3.12.5
version: 3.12.5
lucide-svelte:
specifier: ^0.454.0
version: 0.454.0(svelte@5.1.9)
@ -447,9 +453,15 @@ importers:
packages/ui:
dependencies:
'@formkit/auto-animate':
specifier: ^0.8.2
version: 0.8.2
'@std/semver':
specifier: npm:@jsr/std__semver@^1.0.3
version: '@jsr/std__semver@1.0.3'
gsap:
specifier: ^3.12.5
version: 3.12.5
devDependencies:
'@kksh/api':
specifier: workspace:*
@ -1179,6 +1191,9 @@ packages:
'@floating-ui/utils@0.2.8':
resolution: {integrity: sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==}
'@formkit/auto-animate@0.8.2':
resolution: {integrity: sha512-SwPWfeRa5veb1hOIBMdzI+73te5puUBHmqqaF1Bu7FjvxlYSz/kJcZKSa9Cg60zL0uRNeJL2SbRxV6Jp6Q1nFQ==}
'@gcornut/valibot-json-schema@0.31.0':
resolution: {integrity: sha512-3xGptCurm23e7nuPQkdrE5rEs1FeTPHhAUsBuwwqG4/YeZLwJOoYZv+fmsppUEfo5y9lzUwNQrNqLS/q7HMc7g==}
hasBin: true
@ -2711,6 +2726,9 @@ packages:
graphemer@1.4.0:
resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==}
gsap@3.12.5:
resolution: {integrity: sha512-srBfnk4n+Oe/ZnMIOXt3gT605BX9x5+rh/prT2F1SsNJsU1XuMiP0E2aptW481OnonOGACZWBqseH5Z7csHxhQ==}
has-flag@4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
@ -5147,6 +5165,8 @@ snapshots:
'@floating-ui/utils@0.2.8': {}
'@formkit/auto-animate@0.8.2': {}
'@gcornut/valibot-json-schema@0.31.0':
dependencies:
valibot: 0.31.1
@ -6963,6 +6983,8 @@ snapshots:
graphemer@1.4.0: {}
gsap@3.12.5: {}
has-flag@4.0.0: {}
hasown@2.0.2: