mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-06 03:34:40 +00:00
feat: betterseqta cloud for favouriting items and future stuff
This commit is contained in:
@@ -42,7 +42,7 @@
|
||||
onkeydown={(e) => { if (e.key === 'Enter') setDisplayTheme(theme) }}
|
||||
onclick={() => setDisplayTheme(theme)}
|
||||
>
|
||||
<img src={theme.marqueeImage} alt="Theme Preview" class="object-cover w-full h-full" />
|
||||
<img src={theme.marqueeImage || theme.coverImage} alt="Theme Preview" class="object-cover w-full h-full" />
|
||||
<div class='absolute bottom-0 left-0 p-8 z-[1]'>
|
||||
<h2 class='text-4xl font-bold text-white'>{theme.name}</h2>
|
||||
<p class='text-lg text-white'>{theme.description}</p>
|
||||
|
||||
@@ -1,19 +1,86 @@
|
||||
<script lang="ts">
|
||||
import type { Theme } from '@/interface/types/Theme'
|
||||
|
||||
let { theme, onClick } = $props<{ theme: Theme; onClick: () => void }>();
|
||||
|
||||
import { fade } from 'svelte/transition';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let { theme, onClick, toggleFavorite, isLoggedIn } = $props<{
|
||||
theme: Theme;
|
||||
onClick: () => void;
|
||||
toggleFavorite: (theme: Theme) => void;
|
||||
isLoggedIn: boolean;
|
||||
}>();
|
||||
let menuOpen = $state(false);
|
||||
let menuRef: HTMLDivElement;
|
||||
|
||||
onMount(() => {
|
||||
const closeMenu = (e: MouseEvent) => {
|
||||
if (menuOpen && menuRef && !menuRef.contains(e.target as Node)) {
|
||||
menuOpen = false;
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', closeMenu);
|
||||
return () => document.removeEventListener('click', closeMenu);
|
||||
});
|
||||
|
||||
function handleCardClick(e: MouseEvent) {
|
||||
if ((e.target as HTMLElement).closest('[data-theme-menu]')) return;
|
||||
onClick();
|
||||
}
|
||||
|
||||
function handleFavoriteClick(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
if (isLoggedIn) toggleFavorite(theme);
|
||||
menuOpen = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="w-full cursor-pointer" role="button" tabindex="-1" onkeydown={onClick} onclick={onClick}>
|
||||
<div class="bg-gray-50 w-full transition-all hover:scale-105 duration-500 relative group 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 overflow-clip border" transition:fade>
|
||||
<div class="w-full cursor-pointer" role="button" tabindex="-1" onkeydown={onClick} onclick={handleCardClick}>
|
||||
<div class="bg-gray-50 w-full transition-all hover:scale-105 duration-500 relative group flex flex-col hover:shadow-2xl dark:hover:shadow-white/[0.1] dark:hover:shadow-white/[0.8] dark:bg-zinc-800 dark:border-white/[0.1] h-auto rounded-xl overflow-clip border" transition:fade>
|
||||
<!-- Menu dropdown -->
|
||||
<div class="absolute top-2 right-2 z-20" data-theme-menu bind:this={menuRef}>
|
||||
<button
|
||||
type="button"
|
||||
class="flex justify-center items-center w-8 h-8 rounded-lg bg-black/40 hover:bg-black/60 text-white transition-all"
|
||||
onclick={(e) => { e.stopPropagation(); menuOpen = !menuOpen; }}
|
||||
aria-label="Theme options"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24" class="w-5 h-5">
|
||||
<path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
{#if menuOpen}
|
||||
<div
|
||||
class="absolute right-0 top-full mt-1 py-1 min-w-[140px] rounded-lg bg-white dark:bg-zinc-800 shadow-lg border border-zinc-200 dark:border-zinc-700"
|
||||
role="menu"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex gap-2 items-center w-full px-3 py-2 text-left text-sm hover:bg-zinc-100 dark:hover:bg-zinc-700 {!isLoggedIn ? 'opacity-50 cursor-not-allowed' : ''}"
|
||||
role="menuitem"
|
||||
onclick={handleFavoriteClick}
|
||||
title={isLoggedIn ? (theme.is_favorited ? 'Remove from favorites' : 'Add to favorites') : 'Sign in to favorite themes'}
|
||||
>
|
||||
<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 {theme.is_favorited ? 'text-red-500' : ''}"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="absolute bottom-1 left-3 z-10 mb-1 text-xl font-bold text-white">
|
||||
{theme.name}
|
||||
</div>
|
||||
<div class='absolute bottom-0 z-0 w-full h-3/4 bg-linear-to-t to-transparent from-black/80'></div>
|
||||
<div class='w-full'>
|
||||
<img src={theme.marqueeImage} alt="Theme Preview" class="object-cover w-full h-48 rounded-md" />
|
||||
<img src={theme.marqueeImage || theme.coverImage} alt="Theme Preview" class="object-cover w-full h-48 rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
import type { Theme } from '@/interface/types/Theme'
|
||||
import ThemeCard from './ThemeCard.svelte';
|
||||
|
||||
let { themes, searchTerm, setDisplayTheme } = $props<{ themes: Theme[]; searchTerm: string, setDisplayTheme: (theme: Theme) => void }>();
|
||||
let { themes, searchTerm, setDisplayTheme, toggleFavorite, isLoggedIn } = $props<{
|
||||
themes: Theme[];
|
||||
searchTerm: string;
|
||||
setDisplayTheme: (theme: Theme) => void;
|
||||
toggleFavorite: (theme: Theme) => void;
|
||||
isLoggedIn: boolean;
|
||||
}>();
|
||||
|
||||
let filteredThemes = $derived(themes.filter((theme: Theme) =>
|
||||
theme.name.toLowerCase().includes(searchTerm.toLowerCase()) || theme.description.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
@@ -12,7 +18,12 @@
|
||||
<div class="relative" >
|
||||
<div class="grid grid-cols-1 gap-4 py-12 mx-auto sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each filteredThemes as theme (theme.id)}
|
||||
<ThemeCard theme={theme} onClick={() => setDisplayTheme(theme)} />
|
||||
<ThemeCard
|
||||
{theme}
|
||||
onClick={() => setDisplayTheme(theme)}
|
||||
{toggleFavorite}
|
||||
{isLoggedIn}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
{#if filteredThemes.length !== 0}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { fade } from 'svelte/transition';
|
||||
import { animate } from 'motion';
|
||||
|
||||
let { theme, currentThemes, setDisplayTheme, onInstall, onRemove, allThemes, displayTheme } = $props<{
|
||||
let { theme, currentThemes, setDisplayTheme, onInstall, onRemove, allThemes, displayTheme, toggleFavorite, isLoggedIn } = $props<{
|
||||
theme: Theme | null;
|
||||
currentThemes: string[];
|
||||
setDisplayTheme: (theme: Theme | null) => void;
|
||||
@@ -11,6 +11,8 @@
|
||||
onRemove: (themeId: string) => void;
|
||||
allThemes: Theme[];
|
||||
displayTheme: Theme | null;
|
||||
toggleFavorite?: (theme: Theme) => void;
|
||||
isLoggedIn?: boolean;
|
||||
}>();
|
||||
let installing = $state(false);
|
||||
let modalElement: HTMLElement;
|
||||
@@ -73,13 +75,35 @@
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div class="relative h-auto">
|
||||
<button class="absolute top-0 right-0 p-2 text-xl font-bold text-gray-600 font-IconFamily dark:text-gray-200" onclick={() => hideModal()}>
|
||||
{'\ued8a'}
|
||||
</button>
|
||||
<div class="absolute top-0 right-0 flex gap-1 items-center">
|
||||
{#if isLoggedIn && toggleFavorite && theme}
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 rounded-lg transition-all hover:bg-zinc-100 dark:hover:bg-zinc-700 {theme.is_favorited ? 'text-red-500' : 'text-gray-600 dark:text-gray-200'}"
|
||||
onclick={() => toggleFavorite(theme)}
|
||||
title={theme.is_favorited ? 'Remove from favorites' : 'Add to favorites'}
|
||||
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-6 h-6"
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
{/if}
|
||||
<button class="p-2 text-xl font-bold text-gray-600 font-IconFamily dark:text-gray-200" onclick={() => hideModal()}>
|
||||
{'\ued8a'}
|
||||
</button>
|
||||
</div>
|
||||
<h2 class="mb-4 text-2xl font-bold">
|
||||
{theme.name}
|
||||
</h2>
|
||||
<img src={theme.marqueeImage} alt="Theme Cover" class="object-cover mb-4 w-full rounded-md" />
|
||||
<img src={theme.marqueeImage || theme.coverImage} alt="Theme Cover" class="object-cover mb-4 w-full rounded-md" />
|
||||
<p class="mb-4 text-gray-700 dark:text-gray-300">
|
||||
{theme.description}
|
||||
</p>
|
||||
@@ -116,7 +140,7 @@
|
||||
{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} alt="Theme Preview" class="object-cover w-full h-48" />
|
||||
<img src={relatedTheme.marqueeImage || relatedTheme.coverImage} alt="Theme Preview" class="object-cover w-full h-48" />
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import Settings from "./settings/general.svelte";
|
||||
import Shortcuts from "./settings/shortcuts.svelte";
|
||||
import Theme from "./settings/theme.svelte";
|
||||
import Cloud from "./settings/cloud.svelte";
|
||||
import browser from "webextension-polyfill";
|
||||
|
||||
import { standalone as StandaloneStore } from "../utils/standalone.svelte";
|
||||
@@ -283,6 +284,7 @@
|
||||
},
|
||||
{ title: "Shortcuts", Content: Shortcuts },
|
||||
{ title: "Themes", Content: Theme },
|
||||
{ title: "BetterSEQTA Cloud", Content: Cloud },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import Button from "../../components/Button.svelte";
|
||||
import { cloudAuth } from "@/seqta/utils/CloudAuth";
|
||||
|
||||
let username = $state("");
|
||||
let password = $state("");
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let cloudState = $state(cloudAuth.state);
|
||||
|
||||
onMount(() => {
|
||||
const unsubscribe = cloudAuth.subscribe((state) => {
|
||||
cloudState = state;
|
||||
});
|
||||
return unsubscribe;
|
||||
});
|
||||
|
||||
async function handleLogin() {
|
||||
if (loading) return;
|
||||
error = null;
|
||||
if (!username.trim() || !password) {
|
||||
error = "Please enter username and password";
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
try {
|
||||
const result = await cloudAuth.login(username.trim(), password);
|
||||
if (result.success) {
|
||||
password = "";
|
||||
} else {
|
||||
error = result.error ?? "Login failed";
|
||||
}
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await cloudAuth.logout();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-4 p-1">
|
||||
<div class="p-4 rounded-xl border border-zinc-200/50 dark:border-zinc-700/40 bg-zinc-50/50 dark:bg-zinc-900/30">
|
||||
<h2 class="mb-2 text-lg font-bold">BetterSEQTA Cloud</h2>
|
||||
<p class="mb-4 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Sign in to favorite themes in the theme store. Your favorites sync across devices when logged in.
|
||||
</p>
|
||||
|
||||
{#if cloudState.isLoggedIn}
|
||||
<div class="flex flex-col gap-3">
|
||||
<p class="text-sm">
|
||||
Signed in as
|
||||
<span class="font-medium">
|
||||
{cloudState.user?.displayName || cloudState.user?.username || cloudState.user?.email || "User"}
|
||||
</span>
|
||||
</p>
|
||||
<Button onClick={handleLogout} text="Sign out" />
|
||||
</div>
|
||||
{:else}
|
||||
<form
|
||||
class="flex flex-col gap-3"
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleLogin();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Email or username"
|
||||
bind:value={username}
|
||||
disabled={loading}
|
||||
class="w-full px-3 py-2 text-sm rounded-lg bg-zinc-100 dark:bg-zinc-800 dark:text-white border border-zinc-200 dark:border-zinc-600 focus:outline-none focus:ring-2 focus:ring-accent-ring"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
bind:value={password}
|
||||
disabled={loading}
|
||||
class="w-full px-3 py-2 text-sm rounded-lg bg-zinc-100 dark:bg-zinc-800 dark:text-white border border-zinc-200 dark:border-zinc-600 focus:outline-none focus:ring-2 focus:ring-accent-ring"
|
||||
/>
|
||||
{#if error}
|
||||
<p class="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
{/if}
|
||||
<div class="flex flex-col gap-2">
|
||||
<Button
|
||||
onClick={handleLogin}
|
||||
text={loading ? "Signing in..." : "Sign in"}
|
||||
/>
|
||||
<a
|
||||
href="https://accounts.betterseqta.org/register"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center justify-center px-4 py-2 text-sm font-medium rounded-lg border border-zinc-200 dark:border-zinc-600 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-all duration-200 hover:scale-[1.02] focus:outline-none focus:ring-2 focus:ring-accent-ring focus:ring-offset-2"
|
||||
>
|
||||
Create account
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -15,8 +15,12 @@
|
||||
|
||||
import { loadBackground } from '@/seqta/ui/ImageBackgrounds'
|
||||
import Backgrounds from '../components/store/Backgrounds.svelte'
|
||||
import { cloudAuth } from '@/seqta/utils/CloudAuth'
|
||||
|
||||
const themeManager = ThemeManager.getInstance();
|
||||
let cloudLoggedIn = $state(cloudAuth.state.isLoggedIn);
|
||||
|
||||
cloudAuth.subscribe((s) => { cloudLoggedIn = s.isLoggedIn; });
|
||||
|
||||
// State variables
|
||||
let searchTerm = $state('');
|
||||
@@ -48,10 +52,34 @@
|
||||
activeTab = tab;
|
||||
};
|
||||
|
||||
const toggleFavorite = async (theme: Theme) => {
|
||||
const token = await cloudAuth.getStoredToken();
|
||||
if (!token) return;
|
||||
const isFavorite = !theme.is_favorited;
|
||||
const result = (await browser.runtime.sendMessage({
|
||||
type: 'cloudFavorite',
|
||||
themeId: theme.id,
|
||||
token,
|
||||
action: isFavorite ? 'favorite' : 'unfavorite',
|
||||
})) as { success?: boolean };
|
||||
if (result?.success) {
|
||||
themes = themes.map((t) =>
|
||||
t.id === theme.id ? { ...t, is_favorited: isFavorite } : t
|
||||
);
|
||||
if (displayTheme?.id === theme.id) {
|
||||
displayTheme = { ...displayTheme, is_favorited: isFavorite };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch themes via background script (avoids CORS when store runs inside SEQTA page)
|
||||
const fetchThemes = async () => {
|
||||
try {
|
||||
const data = (await browser.runtime.sendMessage({ type: 'fetchThemes' })) as {
|
||||
const token = await cloudAuth.getStoredToken();
|
||||
const data = (await browser.runtime.sendMessage({
|
||||
type: 'fetchThemes',
|
||||
token: token ?? undefined,
|
||||
})) as {
|
||||
success?: boolean;
|
||||
data?: { themes: Theme[] };
|
||||
error?: string;
|
||||
@@ -97,6 +125,17 @@
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Refetch themes when user logs in (from another tab) to get is_favorited
|
||||
let lastLoggedIn = $state(false);
|
||||
$effect(() => {
|
||||
if (cloudLoggedIn && !lastLoggedIn) {
|
||||
lastLoggedIn = true;
|
||||
fetchThemes();
|
||||
} else if (!cloudLoggedIn) {
|
||||
lastLoggedIn = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="w-screen h-screen bg-white {darkMode ? 'dark' : ''}">
|
||||
@@ -117,7 +156,13 @@
|
||||
{/if}
|
||||
|
||||
<!-- ThemeGrid to display filtered themes -->
|
||||
<ThemeGrid themes={filteredThemes} {searchTerm} {setDisplayTheme} />
|
||||
<ThemeGrid
|
||||
themes={filteredThemes}
|
||||
{searchTerm}
|
||||
{setDisplayTheme}
|
||||
{toggleFavorite}
|
||||
isLoggedIn={cloudLoggedIn}
|
||||
/>
|
||||
|
||||
{#if displayTheme}
|
||||
<ThemeModal
|
||||
@@ -126,6 +171,8 @@
|
||||
theme={displayTheme}
|
||||
{displayTheme}
|
||||
{setDisplayTheme}
|
||||
{toggleFavorite}
|
||||
isLoggedIn={cloudLoggedIn}
|
||||
onInstall={async () => {
|
||||
if (displayTheme) {
|
||||
await themeManager.downloadTheme(displayTheme);
|
||||
|
||||
@@ -5,4 +5,6 @@ export type Theme = {
|
||||
coverImage: string;
|
||||
marqueeImage?: string;
|
||||
theme_json_url?: string;
|
||||
is_favorited?: boolean;
|
||||
favorite_count?: number;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user