mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-06 11:44:40 +00:00
feat(settings): add background selector
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
interface Background {
|
||||
id: string;
|
||||
type: string;
|
||||
blob: Blob;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
let { bg, isSelected, isEditMode, onClick, onDelete } = $props<{ bg: Background, isSelected: boolean, isEditMode: boolean, onClick: () => void, onDelete: () => void }>();
|
||||
|
||||
</script>
|
||||
|
||||
<div
|
||||
onclick={onClick}
|
||||
onkeydown={onClick}
|
||||
tabindex="-1"
|
||||
role="button"
|
||||
class="relative w-16 h-16 cursor-pointer rounded-xl transition ring dark:ring-white ring-zinc-300 {isEditMode ? 'animate-shake' : ''} {isSelected ? 'dark:ring-2 ring-4' : 'ring-0'}"
|
||||
>
|
||||
{#if isEditMode}
|
||||
<div
|
||||
tabindex="-1"
|
||||
role="button"
|
||||
class="absolute top-0 right-0 z-10 flex w-6 h-6 p-2 text-white translate-x-1/2 -translate-y-1/2 bg-red-600 rounded-full place-items-center"
|
||||
onclick={onDelete}
|
||||
onkeydown={onDelete}
|
||||
>
|
||||
<div class="w-4 h-0.5 bg-white"></div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if bg.type === 'image'}
|
||||
<img class="object-cover w-full h-full rounded-xl" src={bg.url} alt="swatch" />
|
||||
{:else if bg.type === 'video'}
|
||||
<video muted loop autoplay src={bg.url} class="object-cover w-full h-full rounded-xl"></video>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,152 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { hasEnoughStorageSpace, isIndexedDBSupported, writeData, openDatabase, readAllData, deleteData } from '@/svelte-interface/hooks/BackgroundDataLoader'
|
||||
import BackgroundUploader from './BackgroundUploader.svelte';
|
||||
import BackgroundItem from './BackgroundItem.svelte'
|
||||
import { onMount } from 'svelte'
|
||||
import { loadBackground } from '@/seqta/ui/ImageBackgrounds'
|
||||
|
||||
let { isEditMode, selectNoBackground = $bindable(), selectedBackground = $bindable() } = $props<{ isEditMode: boolean, selectNoBackground: () => void, selectedBackground: string | null }>();
|
||||
let backgrounds = $state<{ id: string; type: string; blob: Blob; url?: string }[]>([]);
|
||||
let isLoading = $state<boolean>(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
let imageBackgrounds = $derived(backgrounds.filter(bg => bg.type === 'image'));
|
||||
let videoBackgrounds = $derived(backgrounds.filter(bg => bg.type === 'video'));
|
||||
|
||||
async function getTheme() {
|
||||
return localStorage.getItem('selectedBackground');
|
||||
}
|
||||
|
||||
async function setTheme(theme: string) {
|
||||
localStorage.setItem('selectedBackground', theme);
|
||||
}
|
||||
|
||||
async function handleFileChange(file: File): Promise<void> {
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
if (!isIndexedDBSupported()) {
|
||||
throw new Error("Your browser doesn't support IndexedDB. Unable to save backgrounds.");
|
||||
}
|
||||
|
||||
const hasSpace = await hasEnoughStorageSpace(file.size);
|
||||
if (!hasSpace) {
|
||||
throw new Error("Not enough storage space to save this background.");
|
||||
}
|
||||
|
||||
const fileId = `${Date.now()}-${file.name}`;
|
||||
const fileType = file.type.split('/')[0];
|
||||
const blob = new Blob([file], { type: file.type });
|
||||
|
||||
await writeData(fileId, fileType, blob);
|
||||
backgrounds = [...backgrounds, { id: fileId, type: fileType, blob, url: URL.createObjectURL(blob) }];
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
error = e.message;
|
||||
} else {
|
||||
error = 'An unknown error occurred';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBackgrounds(): Promise<void> {
|
||||
try {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
|
||||
if (!isIndexedDBSupported()) {
|
||||
throw new Error("Your browser doesn't support IndexedDB. Unable to load backgrounds.");
|
||||
}
|
||||
|
||||
await openDatabase();
|
||||
const data = await readAllData();
|
||||
const dataWithUrls = data.map(bg => ({ ...bg, url: URL.createObjectURL(bg.blob) }));
|
||||
backgrounds = dataWithUrls;
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
error = e.message;
|
||||
} else {
|
||||
error = 'An unknown error occurred';
|
||||
}
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function selectBackground(fileId: string): void {
|
||||
if (selectedBackground === fileId) {
|
||||
selectNoBackground();
|
||||
return;
|
||||
}
|
||||
|
||||
selectedBackground = fileId;
|
||||
setTheme(fileId);
|
||||
}
|
||||
|
||||
async function deleteBackground(fileId: string): Promise<void> {
|
||||
try {
|
||||
await deleteData(fileId);
|
||||
backgrounds = backgrounds.filter(bg => bg.id !== fileId);
|
||||
|
||||
if (selectedBackground === fileId) {
|
||||
selectNoBackground();
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
error = `Failed to delete background: ${e.message}`;
|
||||
} else {
|
||||
error = 'An unknown error occurred';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
selectNoBackground = () => {
|
||||
selectedBackground = null;
|
||||
setTheme('');
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
loadBackground();
|
||||
selectedBackground
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
await loadBackgrounds();
|
||||
selectedBackground = await getTheme();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="relative px-1 py-2">
|
||||
<h2 class="pb-2 text-lg font-bold">Background Images</h2>
|
||||
<div class="flex flex-wrap gap-4 mb-4">
|
||||
{#if !isEditMode}
|
||||
<BackgroundUploader on:fileChange={e => handleFileChange(e.detail)} />
|
||||
{/if}
|
||||
{#each imageBackgrounds as bg (bg.id)}
|
||||
<BackgroundItem
|
||||
{bg}
|
||||
isSelected={selectedBackground === bg.id}
|
||||
isEditMode={isEditMode}
|
||||
onClick={() => selectBackground(bg.id)}
|
||||
onDelete={() => deleteBackground(bg.id)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<h2 class="py-2 text-lg font-bold">Background Videos</h2>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
{#if !isEditMode}
|
||||
<BackgroundUploader on:fileChange={e => handleFileChange(e.detail)} />
|
||||
{/if}
|
||||
{#each videoBackgrounds as bg (bg.id)}
|
||||
<BackgroundItem
|
||||
{bg}
|
||||
isSelected={selectedBackground === bg.id}
|
||||
isEditMode={isEditMode}
|
||||
onClick={() => selectBackground(bg.id)}
|
||||
onDelete={() => deleteBackground(bg.id)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
function handleFileChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (file) {
|
||||
dispatch('fileChange', file);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative w-16 h-16 overflow-hidden transition rounded-xl bg-zinc-100 dark:bg-zinc-900">
|
||||
<div class="flex items-center justify-center w-full h-full text-3xl font-bold text-gray-400 transition font-IconFamily hover:text-gray-500">
|
||||
<!-- Plus icon -->
|
||||
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*, video/*"
|
||||
on:change={handleFileChange}
|
||||
class="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script lang="ts">
|
||||
let { progress } = $props<{ progress: number }>();
|
||||
|
||||
let circumference = $derived(2 * Math.PI * 14);
|
||||
let offset = $derived(circumference * (1 - (progress / 100)));
|
||||
</script>
|
||||
|
||||
<div class="absolute top-0 left-0 z-20 flex items-center justify-center w-full h-full">
|
||||
<svg class="w-full h-full text-zinc-100 dark:text-zinc-700" viewBox="0 0 36 36">
|
||||
<circle stroke="currentColor" fill="none" stroke-width="4" stroke-linecap="round" cx="18" cy="18" r="10" stroke-dasharray="{circumference} {circumference}" stroke-dashoffset="0" transform="rotate(-90 18 18)"></circle>
|
||||
<circle stroke="#3B82F6" fill="none" stroke-width="4" stroke-linecap="round" cx="18" cy="18" r="10" stroke-dasharray="{circumference} {circumference}" stroke-dashoffset="{offset}" transform="rotate(-90 18 18)"></circle>
|
||||
</svg>
|
||||
</div>
|
||||
@@ -0,0 +1,75 @@
|
||||
import { openDB, type IDBPDatabase, type DBSchema } from 'idb';
|
||||
|
||||
interface BackgroundDB extends DBSchema {
|
||||
backgrounds: {
|
||||
key: string;
|
||||
value: {
|
||||
id: string;
|
||||
type: string;
|
||||
blob: Blob;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
let db: IDBPDatabase<BackgroundDB> | null = null;
|
||||
|
||||
export async function openDatabase(): Promise<IDBPDatabase<BackgroundDB>> {
|
||||
if (db) return db;
|
||||
|
||||
db = await openDB<BackgroundDB>('BackgroundDB', 1, {
|
||||
upgrade(db: IDBPDatabase<BackgroundDB>) {
|
||||
db.createObjectStore('backgrounds', { keyPath: 'id' });
|
||||
},
|
||||
});
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
export async function readAllData(): Promise<Array<{ id: string; type: string; blob: Blob }>> {
|
||||
const db = await openDatabase();
|
||||
return db.getAll('backgrounds');
|
||||
}
|
||||
|
||||
export async function writeData(id: string, type: string, blob: Blob): Promise<void> {
|
||||
const db = await openDatabase();
|
||||
await db.put('backgrounds', { id, type, blob });
|
||||
}
|
||||
|
||||
export async function deleteData(id: string): Promise<void> {
|
||||
const db = await openDatabase();
|
||||
await db.delete('backgrounds', id);
|
||||
}
|
||||
|
||||
export async function clearAllData(): Promise<void> {
|
||||
const db = await openDatabase();
|
||||
await db.clear('backgrounds');
|
||||
}
|
||||
|
||||
export async function getDataById(id: string): Promise<{ id: string; type: string; blob: Blob } | undefined> {
|
||||
const db = await openDatabase();
|
||||
return db.get('backgrounds', id);
|
||||
}
|
||||
|
||||
export function closeDatabase(): void {
|
||||
if (db) {
|
||||
db.close();
|
||||
db = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to check if IndexedDB is supported
|
||||
export function isIndexedDBSupported(): boolean {
|
||||
return 'indexedDB' in window;
|
||||
}
|
||||
|
||||
// Helper function to check if there's enough storage space
|
||||
export async function hasEnoughStorageSpace(requiredSpace: number): Promise<boolean> {
|
||||
if ('storage' in navigator && 'estimate' in navigator.storage) {
|
||||
const { quota, usage } = await navigator.storage.estimate();
|
||||
if (quota !== undefined && usage !== undefined) {
|
||||
return (quota - usage) > requiredSpace;
|
||||
}
|
||||
}
|
||||
// If we can't determine, assume there's enough space
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export let selectedBackground = $state<string | null>(null);
|
||||
@@ -1,11 +1,19 @@
|
||||
<script lang="ts">
|
||||
|
||||
import BackgroundSelector from "@/svelte-interface/components/themes/BackgroundSelector.svelte"
|
||||
|
||||
// backgrounds
|
||||
let selectedBackground = $state<string | null>(null);
|
||||
let selectNoBackground = $state<() => void>(() => { });
|
||||
|
||||
let clearTheme = $derived(selectedBackground !== null);
|
||||
let editMode = $state<boolean>(false);
|
||||
</script>
|
||||
|
||||
<div class="pt-4">
|
||||
<div class="py-4">
|
||||
<button
|
||||
onclick={() => selectNoBackground()}
|
||||
class="w-full px-4 py-2 mb-4 text-[13px] text-white transition rounded-xl bg-zinc-700/50">
|
||||
Clear Theme
|
||||
{ clearTheme ? 'Clear Theme' : 'Select Theme' }
|
||||
</button>
|
||||
|
||||
<BackgroundSelector isEditMode={editMode} bind:selectedBackground={selectedBackground} bind:selectNoBackground={selectNoBackground} />
|
||||
</div>
|
||||
@@ -1 +0,0 @@
|
||||
<h1>HI THERE!!!!</h1>
|
||||
@@ -11,4 +11,6 @@ export function createStandalone() {
|
||||
},
|
||||
setStandalone
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const standalone = createStandalone();
|
||||
Reference in New Issue
Block a user