mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-06 11:44:40 +00:00
3987871a6a
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).
979 lines
24 KiB
Svelte
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>
|
|
|
|
|
|
|