mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-06 03:34:40 +00:00
346 lines
15 KiB
TypeScript
346 lines
15 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
||
import { Swiper, SwiperSlide } from 'swiper/react';
|
||
import Header from '../components/store/header';
|
||
import { Autoplay } from 'swiper/modules';
|
||
import { motion, AnimatePresence } from 'framer-motion';
|
||
|
||
import 'swiper/css';
|
||
import 'swiper/css/pagination';
|
||
import 'swiper/css/scrollbar';
|
||
import 'swiper/css/autoplay';
|
||
import SpinnerIcon from '../components/LoadingSpinner';
|
||
import localforage from 'localforage';
|
||
import { StoreDownloadTheme } from '../../seqta/ui/themes/downloadTheme';
|
||
|
||
const textVariants = {
|
||
hidden: { opacity: 0, y: 60 },
|
||
visible: { opacity: 1, y: 0, transition: {
|
||
type: 'spring',
|
||
bounce: 0,
|
||
stiffness: 80,
|
||
damping: 12
|
||
} },
|
||
};
|
||
|
||
const containerVariants = {
|
||
hidden: {
|
||
y: '100vh',
|
||
},
|
||
visible: {
|
||
y: 0,
|
||
transition: {
|
||
staggerChildren: 0.05,
|
||
type: 'spring',
|
||
bounce: 0,
|
||
stiffness: 400,
|
||
damping: 50
|
||
},
|
||
},
|
||
};
|
||
|
||
export type Theme = {
|
||
name: string;
|
||
description: string;
|
||
coverImage: string;
|
||
marqueeImage: string;
|
||
id: string;
|
||
}
|
||
|
||
type ThemesResponse = {
|
||
themes: Theme[];
|
||
}
|
||
|
||
export const DeleteDownloadedTheme = async (themeID: string) => {
|
||
console.debug('DeleteDownloaded Theme:', themeID)
|
||
await localforage.removeItem(themeID);
|
||
|
||
const availableThemesList = await localforage.getItem('availableThemes') as string[];
|
||
const updatedThemesList = availableThemesList.filter(theme => theme !== themeID);
|
||
|
||
await localforage.setItem('availableThemes', updatedThemesList);
|
||
}
|
||
|
||
const Store = () => {
|
||
const [searchTerm, setSearchTerm] = useState('');
|
||
const swiperCover = useRef<any | null>(null);
|
||
const [gridThemes, setGridThemes] = useState<Theme[]>([]);
|
||
const [filteredThemes, setFilteredThemes] = useState<Theme[]>([]);
|
||
const [coverThemes, setCoverThemes] = useState<Theme[]>([]);
|
||
const [installingThemes, setInstallingThemes] = useState<string[]>([]);
|
||
const [currentThemes, setCurrentThemes] = useState<string[]>([]);
|
||
const [displayTheme, setDisplayTheme] = useState<Theme | null>(null);
|
||
const [loading, setLoading] = useState<boolean>(true);
|
||
|
||
const fetchThemes = async () => {
|
||
try {
|
||
const response = await fetch(`https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes.json?nocache=${(new Date()).getTime()}`, { cache: 'no-store' });
|
||
const data: ThemesResponse = await response.json();
|
||
setGridThemes(data.themes);
|
||
// Select up to 3 random themes to display in coverThemes
|
||
const shuffled = [...data.themes].sort(() => 0.5 - Math.random());
|
||
setCoverThemes(shuffled.slice(0, 3));
|
||
setLoading(false);
|
||
} catch (error) {
|
||
console.error('Failed to fetch themes', error);
|
||
// Retry after 5 seconds
|
||
setTimeout(fetchThemes, 5000);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
(async () => {
|
||
fetchThemes();
|
||
const availableThemes = await localforage.getItem('availableThemes') as string[];
|
||
setCurrentThemes(availableThemes)
|
||
})();
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
setFilteredThemes(gridThemes.filter(theme =>
|
||
theme.name.toLowerCase().includes(searchTerm.toLowerCase()) || theme.description.toLowerCase().includes(searchTerm.toLowerCase())
|
||
));
|
||
}, [searchTerm, gridThemes]);
|
||
|
||
const downloadTheme = (id: string) => {
|
||
const themeContent = gridThemes.find(theme => theme.id === id);
|
||
if (!themeContent) {
|
||
alert('There was an error, The theme was not found!')
|
||
return
|
||
};
|
||
|
||
setInstallingThemes([...installingThemes, id]);
|
||
|
||
StoreDownloadTheme({ themeContent }).then(() => {
|
||
setInstallingThemes(installingThemes.filter(theme => theme !== id));
|
||
setCurrentThemes([...currentThemes, id]);
|
||
});
|
||
};
|
||
|
||
const removeTheme = async (id: string) => {
|
||
const themeContent = gridThemes.find(theme => theme.id === id);
|
||
if (!themeContent) {
|
||
alert('There was an error, The theme was not found!')
|
||
return
|
||
};
|
||
|
||
setInstallingThemes([...installingThemes, id]);
|
||
|
||
DeleteDownloadedTheme(id).then(() => {
|
||
setInstallingThemes(installingThemes.filter(theme => theme !== id));
|
||
setCurrentThemes(currentThemes.filter(theme => theme !== id));
|
||
});
|
||
}
|
||
|
||
return (
|
||
<div className="w-screen h-screen overflow-y-scroll bg-zinc-200/50 dark:bg-zinc-900 dark:text-white">
|
||
|
||
<Header searchTerm={searchTerm} setSearchTerm={setSearchTerm} />
|
||
|
||
{/* loader */}
|
||
<div className={`flex items-center justify-center w-full h-full ${!loading && 'hidden'}`}>
|
||
<SpinnerIcon className="w-16 h-16" />
|
||
</div>
|
||
|
||
<div className={`px-24 py-12 ${loading && 'hidden'}`}>
|
||
<div className={`relative w-full rounded-xl overflow-clip transition-opacity ${searchTerm == '' ? 'opacity-100' : 'opacity-0'}`}>
|
||
<motion.div className='overflow-clip' animate={{
|
||
height: searchTerm == '' ? 'auto' : '0px'
|
||
}} transition={{
|
||
type: 'spring',
|
||
bounce: 0,
|
||
duration: 1,
|
||
stiffness: 200,
|
||
damping: 30
|
||
}}>
|
||
<Swiper
|
||
ref={swiperCover}
|
||
spaceBetween={20}
|
||
slidesPerView={1}
|
||
loop={true}
|
||
className='w-full aspect-[8/3]'
|
||
modules={[Autoplay]}
|
||
autoplay={{
|
||
delay: 5000,
|
||
stopOnLastSlide: false,
|
||
disableOnInteraction: false,
|
||
pauseOnMouseEnter: true
|
||
}}
|
||
>
|
||
{ coverThemes.map((theme, index) => (
|
||
<SwiperSlide className='relative rounded-xl overflow-clip' onClick={() => setDisplayTheme(theme)} key={index}>
|
||
<img
|
||
src={theme.marqueeImage}
|
||
alt="Theme Preview"
|
||
className="object-cover w-full h-full"
|
||
/>
|
||
<div className='absolute bottom-0 left-0 p-8 z-[1]'>
|
||
<h2 className='text-4xl font-bold text-white'>{theme.name}</h2>
|
||
<p className='text-lg text-white'>{theme.description}</p>
|
||
</div>
|
||
|
||
{/* shadow from the bottom of the image */}
|
||
<div className='absolute bottom-0 left-0 w-full h-1/2 bg-gradient-to-t from-black/80 to-transparent' />
|
||
</SwiperSlide>
|
||
)) }
|
||
</Swiper>
|
||
</motion.div>
|
||
|
||
<AnimatePresence>
|
||
{displayTheme && (
|
||
<motion.div
|
||
className="fixed inset-0 z-50 flex items-end justify-center bg-black bg-opacity-70"
|
||
initial={{ opacity: 0, pointerEvents: 'none' }}
|
||
animate={{ opacity: 1, pointerEvents: 'auto' }}
|
||
exit={{ opacity: 0, pointerEvents: 'none' }}
|
||
onClick={() => setDisplayTheme(null)}
|
||
>
|
||
<motion.div
|
||
onClick={(e) => e.stopPropagation()}
|
||
className="w-full max-w-xl h-[95%] p-8 pt-5 bg-white rounded-t-2xl dark:bg-zinc-800 overflow-scroll"
|
||
exit={{ y: "100vh" }}
|
||
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
||
variants={containerVariants}
|
||
initial="hidden"
|
||
animate="visible"
|
||
>
|
||
<motion.div className="relative h-auto">
|
||
<motion.button
|
||
className="absolute top-0 right-0 p-2 text-xl font-bold text-gray-600 font-IconFamily dark:text-gray-200"
|
||
onClick={() => setDisplayTheme(null)}
|
||
variants={textVariants}
|
||
>
|
||
{'\ued8a'}
|
||
</motion.button>
|
||
<motion.h2 className="mb-4 text-2xl font-bold" variants={textVariants}>
|
||
{displayTheme.name}
|
||
</motion.h2>
|
||
<motion.img src={displayTheme.coverImage} alt="Theme Cover" className="object-cover w-full h-48 mb-4 rounded-md" variants={textVariants} />
|
||
<motion.p className="mb-4 text-gray-700 dark:text-gray-300" variants={textVariants}>
|
||
{displayTheme.description}
|
||
</motion.p>
|
||
<motion.button
|
||
onClick={() => downloadTheme(displayTheme.id)}
|
||
className="flex px-4 py-2 mt-4 ml-auto rounded-full dark:text-white bg-zinc-300 dark:bg-zinc-700 dark:hover:bg-zinc-600/50 hover:bg-zinc-200 focus:outline-none focus:ring-2 focus:ring-zinc-800 focus:ring-offset-2"
|
||
variants={textVariants}
|
||
>
|
||
{ installingThemes.includes(displayTheme.id) ?
|
||
<>
|
||
<SpinnerIcon className="w-4 h-4 mr-2" />
|
||
Installing...
|
||
</> :
|
||
<> Install </>
|
||
}
|
||
</motion.button>
|
||
|
||
<motion.div className="my-8 border-b border-zinc-200 dark:border-zinc-700" variants={textVariants} />
|
||
|
||
<motion.h3 className="mb-4 text-lg font-bold" variants={textVariants}>
|
||
More Themes
|
||
</motion.h3>
|
||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||
{gridThemes.filter(theme => theme.id !== displayTheme.id).map((theme, index) => (
|
||
<motion.div key={index} onClick={() => { setDisplayTheme(null); setDisplayTheme(theme); }} className='w-full cursor-pointer' variants={textVariants}>
|
||
<div
|
||
className="w-full overflow-clip rounded-xl transition-all duration-300 relative group/card flex flex-col hover:shadow-xl dark:hover:shadow-white/[0.1] hover:shadow-white/[0.8] h-auto"
|
||
>
|
||
<div className='absolute bottom-0 left-0 z-10 p-2'>
|
||
<h6 className="text-xl font-bold text-neutral-600 dark:text-white">
|
||
{theme.name}
|
||
</h6>
|
||
<p className="max-w-sm text-sm text-neutral-500 dark:text-neutral-200">
|
||
{theme.description}
|
||
</p>
|
||
</div>
|
||
<div className='absolute bottom-0 z-0 w-full h-3/4 bg-gradient-to-t from-black/80 to-transparent' />
|
||
<img src={theme.coverImage} alt="Theme Preview" className="object-cover w-full h-48" />
|
||
</div>
|
||
</motion.div>
|
||
))}
|
||
</div>
|
||
</motion.div>
|
||
</motion.div>
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
|
||
{/* pagination */}
|
||
<div className='absolute z-10 flex gap-2 bottom-2 right-2'>
|
||
|
||
<button onClick={ () => {swiperCover.current?.swiper.slidePrev() }} className='flex items-center justify-center w-8 h-8 text-white bg-black bg-opacity-50 rounded-full dark:bg-zinc-800 dark:bg-opacity-50'>
|
||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-6 h-6">
|
||
<path strokeLinecap="round" strokeLinejoin="round" d="m15.75 19.5-7.5-7.5 7.5-7.5" />
|
||
</svg>
|
||
</button>
|
||
|
||
<button onClick={ () => {swiperCover.current?.swiper.slideNext() }} className='flex items-center justify-center w-8 h-8 text-white bg-black bg-opacity-50 rounded-full dark:bg-zinc-800 dark:bg-opacity-50'>
|
||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-6 h-6">
|
||
<path strokeLinecap="round" strokeLinejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
||
</svg>
|
||
</button>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 gap-4 py-12 mx-auto sm:grid-cols-2 lg:grid-cols-3">
|
||
{filteredThemes.map((theme, index) => (
|
||
<div key={index} className='w-full cursor-pointer'>
|
||
<div className="bg-gray-50 w-full transition-all duration-300 relative group/card flex flex-col hover:shadow-2xl dark:hover:shadow-white/[0.1] hover:shadow-white/[0.8] dark:bg-zinc-800 dark:border-white/[0.1] h-auto rounded-xl p-6 border">
|
||
<div>
|
||
|
||
<div className="mb-1 text-xl font-bold text-neutral-600 dark:text-white">
|
||
{theme.name}
|
||
</div>
|
||
<p className="max-w-sm mb-4 text-sm text-neutral-500 dark:text-neutral-300">
|
||
{theme.description}
|
||
</p>
|
||
|
||
</div>
|
||
<div
|
||
className='w-full'>
|
||
<img src={theme.coverImage} alt="Theme Preview" className="object-cover w-full h-48 rounded-md" />
|
||
</div>
|
||
<div>
|
||
{
|
||
currentThemes.includes(theme.id) ?
|
||
<button
|
||
onClick={() => removeTheme(theme.id)}
|
||
className="flex px-4 py-2 mt-4 ml-auto transition rounded-full dark:text-white bg-zinc-300 dark:bg-zinc-700 dark:hover:bg-zinc-600/50 hover:bg-zinc-200 focus:outline-none focus:ring-2 focus:ring-zinc-800 focus:ring-offset-2">
|
||
{ installingThemes.includes(theme.id) ?
|
||
<>
|
||
<SpinnerIcon className="w-4 h-4 mr-2" />
|
||
Removing...
|
||
</> :
|
||
<> Remove </>
|
||
}
|
||
</button> :
|
||
<button
|
||
onClick={() => downloadTheme(theme.id)}
|
||
className="flex px-4 py-2 mt-4 ml-auto transition rounded-full dark:text-white bg-zinc-300 dark:bg-zinc-700 dark:hover:bg-zinc-600/50 hover:bg-zinc-200 focus:outline-none focus:ring-2 focus:ring-zinc-800 focus:ring-offset-2">
|
||
{ installingThemes.includes(theme.id) ?
|
||
<>
|
||
<SpinnerIcon className="w-4 h-4 mr-2" />
|
||
Installing...
|
||
</> :
|
||
<> Install </>
|
||
}
|
||
</button>
|
||
}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
|
||
</div>
|
||
{filteredThemes.length == 0 && !loading && (
|
||
<div className="flex flex-col items-center justify-center w-full text-center h-96">
|
||
<h1 className="mt-4 text-3xl font-bold tracking-tight text-gray-900 sm:text-5xl">That doesnt exist! 😭😭😭</h1>
|
||
<p className="mt-6 text-lg leading-7 text-gray-600">Sorry, we couldn’t find the theme you’re looking for. Maybe... you could create it?</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default Store; |