Files
BetterSEQTA-Plus/src/interface/components/store/ThemeModal.svelte
T
SethBurkart123 3987871a6a fix: theme store stuck on loading skeleton after fetch failures
Harden theme list fetching with normalized API responses, timeouts, retries,
and a visible error state so the store no longer stays blank when messaging
or payloads fail (common after extension updates without a SEQTA tab reload).
2026-06-02 12:43:35 +10:00

979 lines
24 KiB
Svelte

<script lang="ts">
import type { Theme } from '@/interface/types/Theme'
import { fade } from 'svelte/transition'
import { animate } from 'motion'
import emblaCarouselSvelte from 'embla-carousel-svelte'
import {
buildModalHeroSlides,
flavourCarouselImageUrl,
masterCarouselImageUrl,
masterGridDisplayDownloadCount,
} from '@/interface/utils/themeStoreFlavours'
let {
theme,
currentThemes,
setDisplayTheme,
onInstall,
onRemove,
allThemes,
allStoreThemeRows,
displayTheme,
toggleFavorite,
isLoggedIn,
onRequestSignIn,
} = $props<{
theme: Theme | null
currentThemes: string[]
setDisplayTheme: (theme: Theme | null) => void
onInstall: (themeId: string) => void | Promise<void>
onRemove: (themeId: string) => void | Promise<void>
allThemes: Theme[]
/** Raw API themes (includes slaves) — same aggregation as grid download count */
allStoreThemeRows?: Theme[]
displayTheme: Theme | null
toggleFavorite?: (theme: Theme) => void
isLoggedIn?: boolean
onRequestSignIn?: () => void
}>()
const modalDisplayDownloadCount = $derived.by(() => {
const t = theme
if (!t) return 0
if (allStoreThemeRows != null) return masterGridDisplayDownloadCount(t, allStoreThemeRows)
return t.download_count ?? 0
})
let installingId = $state<string | null>(null)
let modalElement: HTMLElement
/** Embla CarouselInstance — scrollTo from embla-carousel */
let heroEmblaApi = $state<{
scrollTo: (index: number, jump?: boolean) => void
scrollPrev: () => void
scrollNext: () => void
} | null>(null)
function handleFavoriteClick() {
if (isLoggedIn && toggleFavorite && theme) {
toggleFavorite(theme)
} else {
onRequestSignIn?.()
}
}
function tagsOverlap(a: string[] | undefined, b: string[] | undefined): boolean {
const lowerB = new Set((b ?? []).map((t) => t.toLowerCase()))
return (a ?? []).some((t) => lowerB.has(t.toLowerCase()))
}
const flavourIdsAvoidForRelated = $derived.by(() => {
const set = new Set<string>()
for (const f of theme?.flavours ?? []) {
set.add(f.id)
}
return set
})
const relatedThemes = $derived.by(() => {
const t = theme
if (!t) return [] as Theme[]
if ((t.tags ?? []).length === 0) return []
return allThemes
.filter((x: Theme) => {
if (!x || x.id === t.id) return false
if (flavourIdsAvoidForRelated.has(x.id)) return false
if (x.master_id === t.id) return false
return tagsOverlap(t.tags, x.tags)
})
.sort((a: Theme, b: Theme) => {
const diff = (b.download_count ?? 0) - (a.download_count ?? 0)
if (diff !== 0) return diff
const byName = a.name.localeCompare(b.name)
if (byName !== 0) return byName
return a.id.localeCompare(b.id)
})
.slice(0, 4)
})
const hasFlavours = $derived((theme?.flavours?.length ?? 0) > 0)
const heroSlides = $derived(theme ? buildModalHeroSlides(theme) : [])
const heroCarouselOpts = $derived({ loop: heroSlides.length > 1 })
function heroInit(ev: CustomEvent) {
heroEmblaApi = ev.detail
}
const heroPrev = () => heroEmblaApi?.scrollPrev?.()
const heroNext = () => heroEmblaApi?.scrollNext?.()
/** Carousel slide 0 = master; flavours start at slide 1 + flavourIndex */
function scrollHeroToMasterSlide() {
heroEmblaApi?.scrollTo?.(0, true)
}
function scrollHeroToFlavourIndex(flavourIndex: number) {
heroEmblaApi?.scrollTo?.(flavourIndex + 1, true)
}
$effect(() => {
if (displayTheme && modalElement) {
animate(
modalElement,
{ y: [500, 0], opacity: [0, 1] },
{
type: 'spring',
stiffness: 150,
damping: 20,
},
)
}
})
const hideModal = (relatedTheme?: Theme | null) => {
animate(
modalElement,
{ y: [10, 500], opacity: [1, 0] },
{
type: 'spring',
stiffness: 150,
damping: 20,
},
)
setTimeout(() => {
setDisplayTheme(relatedTheme ?? null)
}, 100)
}
async function runInstall(id: string) {
installingId = id
try {
await onInstall(id)
} finally {
installingId = null
}
}
async function runRemove(id: string) {
installingId = id
try {
await onRemove(id)
} finally {
installingId = null
}
}
async function onFlavourClick(flIdx: number, themeId: string, action: 'install' | 'remove') {
scrollHeroToFlavourIndex(flIdx)
if (action === 'install') await runInstall(themeId)
else await runRemove(themeId)
}
async function onMasterVariantClick(action: 'install' | 'remove') {
if (!theme) return
scrollHeroToMasterSlide()
if (action === 'install') await runInstall(theme.id)
else await runRemove(theme.id)
}
</script>
<div
class="flex fixed inset-0 z-50 justify-center items-end bg-black/70 backdrop-blur-sm"
onclick={(e) => {
if (e.target === e.currentTarget) hideModal()
}}
onkeydown={(e) => {
if (e.target === e.currentTarget && e.key === 'Escape') hideModal()
}}
role="presentation"
transition:fade
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={modalElement}
class="w-full max-w-[600px] h-[95%] p-4 bg-white rounded-t-2xl dark:bg-zinc-800 overflow-y-auto overflow-x-hidden rounded-xl border border-zinc-200 dark:border-zinc-700 cursor-auto transition-colors duration-200"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
tabindex="-1"
>
{#if theme}
<div class="relative h-auto">
<div class="absolute top-0 right-0 flex gap-1 items-center">
<button
type="button"
class="p-2 text-xl font-bold text-gray-600 font-IconFamily dark:text-gray-200 transition-colors duration-200 rounded-lg hover:bg-black/5 dark:hover:bg-white/10 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-800"
onclick={() => hideModal()}
aria-label="Close"
>
{'\ued8a'}
</button>
</div>
<div class="flex flex-wrap items-center gap-2 pr-12 mb-2">
<h2 class="text-2xl font-bold text-zinc-900 dark:text-white">
{theme.name}
</h2>
{#if theme.featured === true}
<span
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-900 dark:bg-amber-950 dark:text-amber-100"
aria-label="Featured theme"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-3.5 h-3.5">
<path
fill-rule="evenodd"
d="M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.006 5.404.434c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.434 2.082-5.005Z"
clip-rule="evenodd"
/>
</svg>
Featured
</span>
{/if}
</div>
{#if theme.author}
<p class="mb-2 text-sm text-zinc-600 dark:text-zinc-400">
By {theme.author}
</p>
{/if}
<div class="flex gap-4 mb-4 text-sm text-zinc-600 dark:text-zinc-400">
<span class="flex items-center gap-1.5">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
{modalDisplayDownloadCount.toLocaleString()} downloads
</span>
<span class="flex items-center gap-1.5">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill={theme.is_favorited ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="1.5" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
{(theme.favorite_count ?? 0).toLocaleString()} favorites
</span>
</div>
{#if heroSlides.length > 0}
{#key theme?.id}
<div class="relative mb-4 w-full overflow-hidden rounded-xl">
<div
class="w-full max-h-[280px]"
use:emblaCarouselSvelte={{ options: heroCarouselOpts, plugins: [] }}
onemblaInit={heroInit}
>
<div class="flex">
{#each heroSlides as slide, slideIdx (slideIdx)}
<div class="relative flex-[0_0_100%] shrink-0 min-w-0">
<img src={slide.imageUrl} alt={slide.caption} class="object-cover w-full max-h-[280px] rounded-xl" />
</div>
{/each}
</div>
</div>
{#if heroSlides.length > 1}
<div class="flex justify-end gap-2 mt-2">
<button
type="button"
onclick={heroPrev}
class="p-2 rounded-full bg-zinc-200 dark:bg-zinc-700 transition-all duration-200 hover:scale-105 active:scale-95 dark:text-white focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2"
aria-label="Previous hero slide"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width={1.5} stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="m15.75 19.5-7.5-7.5 7.5-7.5" />
</svg>
</button>
<button
type="button"
onclick={heroNext}
class="p-2 rounded-full bg-zinc-200 dark:bg-zinc-700 transition-all duration-200 hover:scale-105 active:scale-95 dark:text-white focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2"
aria-label="Next hero slide"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width={1.5} stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
</svg>
</button>
</div>
{/if}
</div>
{/key}
{/if}
{#if hasFlavours}
{@const masterThumb = masterCarouselImageUrl(theme)}
<p class="mb-2 text-sm font-semibold text-zinc-800 dark:text-zinc-100">Variants</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6 w-full">
{#if currentThemes.includes(theme.id)}
<button
type="button"
onclick={() => onMasterVariantClick('remove')}
disabled={installingId !== null}
class="relative w-full overflow-hidden rounded-2xl min-h-[9.5rem] sm:min-h-[11rem] text-left shadow-md border border-zinc-400/70 dark:border-zinc-500 transition-all duration-200 hover:scale-[1.02] active:scale-[0.98] focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-800 disabled:opacity-70 group ring-1 ring-black/10 dark:ring-white/10"
title="Remove {theme.name} (master)"
>
{#if masterThumb}
<img src={masterThumb} alt="" class="absolute inset-0 w-full h-full object-cover transition-transform duration-200 group-hover:scale-[1.03]" draggable="false" />
{:else}
<div class="absolute inset-0 bg-zinc-700" role="presentation"></div>
{/if}
<div class="absolute inset-0 bg-neutral-950/50" role="presentation"></div>
<div class="relative z-10 flex flex-col justify-end min-h-[9.5rem] sm:min-h-[11rem] p-5">
{#if installingId === theme.id}
<span class="flex justify-center mb-3">
<span class="inline-block w-10 h-10 animate-spin rounded-full border-2 border-white border-t-transparent align-middle"></span>
</span>
{/if}
<span class="text-lg sm:text-xl font-bold text-white drop-shadow-md tracking-tight leading-snug">
Remove · {theme.name}
</span>
<span class="mt-1 text-sm font-medium text-white/85">Master</span>
</div>
</button>
{:else}
<button
type="button"
onclick={() => onMasterVariantClick('install')}
disabled={installingId !== null}
class="relative w-full overflow-hidden rounded-2xl min-h-[9.5rem] sm:min-h-[11rem] text-left shadow-md transition-all duration-200 hover:scale-[1.02] active:scale-[0.98] focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-800 disabled:opacity-70 group ring-1 ring-black/10 dark:ring-white/10"
title="Install {theme.name} (master)"
>
{#if masterThumb}
<img src={masterThumb} alt="" class="absolute inset-0 w-full h-full object-cover transition-transform duration-200 group-hover:scale-[1.03]" draggable="false" />
{:else}
<div class="absolute inset-0 bg-zinc-600" role="presentation"></div>
{/if}
<div class="absolute inset-0 bg-neutral-950/50" role="presentation"></div>
<div class="relative z-10 flex flex-col justify-end min-h-[9.5rem] sm:min-h-[11rem] p-5">
{#if installingId === theme.id}
<span class="flex justify-center mb-3">
<span class="inline-block w-10 h-10 animate-spin rounded-full border-2 border-white border-t-transparent align-middle"></span>
</span>
{/if}
<span class="text-lg sm:text-xl font-bold text-white drop-shadow-md tracking-tight leading-snug">{theme.name}</span>
<span class="mt-1 text-sm font-medium text-white/85">Master</span>
</div>
</button>
{/if}
{#each theme.flavours ?? [] as f, flavourIdx (f.id)}
{@const thumb = flavourCarouselImageUrl(f)}
{#if currentThemes.includes(f.id)}
<button
type="button"
onclick={() => onFlavourClick(flavourIdx, f.id, 'remove')}
disabled={installingId !== null}
class="relative w-full overflow-hidden rounded-2xl min-h-[9.5rem] sm:min-h-[11rem] text-left shadow-md border border-zinc-400/70 dark:border-zinc-500 transition-all duration-200 hover:scale-[1.02] active:scale-[0.98] focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-800 disabled:opacity-70 group ring-1 ring-black/10 dark:ring-white/10"
title="Remove {f.name}"
>
{#if thumb}
<img src={thumb} alt="" class="absolute inset-0 w-full h-full object-cover transition-transform duration-200 group-hover:scale-[1.03]" draggable="false" />
{:else}
<div class="absolute inset-0 bg-zinc-700" role="presentation"></div>
{/if}
<div class="absolute inset-0 bg-neutral-950/50" role="presentation"></div>
<div class="relative z-10 flex flex-col justify-end min-h-[9.5rem] sm:min-h-[11rem] p-5">
{#if installingId === f.id}
<span class="flex justify-center mb-3">
<span class="inline-block w-10 h-10 animate-spin rounded-full border-2 border-white border-t-transparent align-middle"></span>
</span>
{/if}
<span class="text-lg sm:text-xl font-bold text-white drop-shadow-md tracking-tight leading-snug">
Remove · {f.name}
</span>
</div>
</button>
{:else}
<button
type="button"
onclick={() => onFlavourClick(flavourIdx, f.id, 'install')}
disabled={installingId !== null}
class="relative w-full overflow-hidden rounded-2xl min-h-[9.5rem] sm:min-h-[11rem] text-left shadow-md transition-all duration-200 hover:scale-[1.02] active:scale-[0.98] focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-800 disabled:opacity-70 group ring-1 ring-black/10 dark:ring-white/10"
title="Install {f.name}"
>
{#if thumb}
<img src={thumb} alt="" class="absolute inset-0 w-full h-full object-cover transition-transform duration-200 group-hover:scale-[1.03]" draggable="false" />
{:else}
<div class="absolute inset-0 bg-zinc-600" role="presentation"></div>
{/if}
<div class="absolute inset-0 bg-neutral-950/50" role="presentation"></div>
<div class="relative z-10 flex flex-col justify-end min-h-[9.5rem] sm:min-h-[11rem] p-5">
{#if installingId === f.id}
<span class="flex justify-center mb-3">
<span class="inline-block w-10 h-10 animate-spin rounded-full border-2 border-white border-t-transparent align-middle"></span>
</span>
{/if}
<span class="text-lg sm:text-xl font-bold text-white drop-shadow-md tracking-tight leading-snug">{f.name}</span>
</div>
</button>
{/if}
{/each}
</div>
{/if}
<p class="mb-4 text-gray-700 dark:text-gray-300">
{theme.description}
</p>
<div class="flex flex-wrap gap-2 mt-4 justify-start sm:justify-end items-center">
{#if toggleFavorite && theme}
<button
type="button"
class="flex items-center gap-2 px-4 py-2 rounded-full transition-all duration-200 hover:scale-105 active:scale-95 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-800 {theme.is_favorited ? 'text-red-500 bg-red-500/10 dark:bg-red-500/20' : 'bg-zinc-200 dark:bg-zinc-700 dark:text-white hover:bg-zinc-300 dark:hover:bg-zinc-600'}"
onclick={handleFavoriteClick}
title={isLoggedIn ? (theme.is_favorited ? 'Remove from favorites' : 'Add to favorites') : 'Sign in to favorite themes'}
aria-label={theme.is_favorited ? 'Unfavorite' : 'Favorite'}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill={theme.is_favorited ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="2" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
{theme.is_favorited ? 'Favorited' : 'Favorite'}
</button>
{/if}
{#if !hasFlavours}
{#if currentThemes.includes(theme.id)}
<button
type="button"
onclick={() => runRemove(theme.id)}
disabled={installingId !== null}
class="relative flex justify-center items-center px-4 py-2 min-w-[8rem] text-black rounded-lg dark:text-white bg-zinc-300 dark:bg-zinc-700 dark:hover:bg-zinc-600/50 hover:bg-zinc-200 transition-all duration-200 hover:scale-105 active:scale-95 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 disabled:opacity-70"
>
{#if installingId === theme.id}
<svg class="absolute w-4 h-4 animate-spin" width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke="currentColor" fill="currentColor" class="origin-center animate-spin-fast" d="M2,12A11.2,11.2,0,0,1,13,1.05C12.67,1,12.34,1,12,1a11,11,0,0,0,0,22c.34,0,.67,0,1-.05C6,23,2,17.74,2,12Z"/>
</svg>
{/if}
<span class="{installingId === theme.id ? 'opacity-0' : 'opacity-100'}">Remove</span>
</button>
{:else}
<button
type="button"
onclick={() => runInstall(theme.id)}
disabled={installingId !== null}
class="relative flex justify-center items-center px-4 py-2 min-w-[8rem] text-black rounded-lg dark:text-white bg-zinc-300 dark:bg-zinc-700 dark:hover:bg-zinc-600/50 hover:bg-zinc-200 transition-all duration-200 hover:scale-105 active:scale-95 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 disabled:opacity-70"
>
{#if installingId === theme.id}
<svg class="absolute w-4 h-4 animate-spin" width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke="currentColor" fill="currentColor" class="origin-center animate-spin-fast" d="M2,12A11.2,11.2,0,0,1,13,1.05C12.67,1,12.34,1,12,1a11,11,0,0,0,0,22c.34,0,.67,0,1-.05C6,23,2,17.74,2,12Z"/>
</svg>
{/if}
<span class="{installingId === theme.id ? 'opacity-0' : 'opacity-100'}">Install</span>
</button>
{/if}
{/if}
</div>
{#if relatedThemes.length > 0}
<div class="my-8 border-b border-zinc-200 dark:border-zinc-700"></div>
<h3 class="mb-4 text-lg font-bold text-zinc-900 dark:text-white">
Related themes
</h3>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
{#each relatedThemes as relatedTheme (relatedTheme.id)}
<button
type="button"
onclick={() => {
hideModal(relatedTheme)
}}
class="relative z-0 hover:z-20 w-full cursor-pointer rounded-xl overflow-hidden transition-all duration-200 hover:scale-[1.02]"
>
<div class="bg-gray-50 w-full transition-all duration-500 ease-out relative group group/card flex flex-col hover:shadow-xl dark:hover:shadow-white/[0.1] hover:shadow-white/[0.8] dark:bg-zinc-800 dark:border-white/[0.1] h-auto rounded-xl overflow-clip border border-zinc-200 dark:border-zinc-700">
<div class="absolute bottom-1 left-3 z-10 mb-1 text-xl font-bold text-white transition-all duration-500 group-hover:-translate-y-0.5">
{relatedTheme.name}
</div>
<div class="absolute bottom-0 z-0 w-full h-3/4 to-transparent from-black/80 bg-linear-to-t"></div>
<img src={relatedTheme.marqueeImage || relatedTheme.coverImage} alt="" class="object-cover w-full h-48" />
</div>
</button>
{/each}
</div>
{/if}
</div>
{:else}
<div class="flex justify-center items-center h-full text-zinc-600 dark:text-zinc-300">
<button
type="button"
class="px-4 py-2 rounded-lg bg-zinc-200 dark:bg-zinc-700 transition-all duration-200 hover:scale-105 active:scale-95 dark:text-white focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-800"
onclick={() => hideModal()}
>
Close
</button>
</div>
{/if}
</div>
</div>