feat(themes): implement community themes workshop and chunked uploader

This commit is contained in:
HorizonCode committed 2026-11-01 15:43:24 +01:00
1 parent d38250a5e4
commit 8816852fd9
6 files changed
+1255 -44

No files matched your search

@@ -0,0 +1,468 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
fetchCommunityThemes,
toggleCommunityThemeLike,
downloadAndInstallCommunityTheme,
type CommunityTheme,
} from '$lib/communityThemes';
import { active_custom_theme, custom_themes } from '$lib/global';
import { reduceAnimations } from '$lib/userSettings';
import { isThemeTrusted } from '$lib/themeTrust';
import type { Theme } from '$lib/themes';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import Badge from '$lib/components/ui/badge/badge.svelte';
import ScrollContainer from '$lib/components/ui/scroll-container/ScrollContainer.svelte';
import { sileo } from 'sileo';
import {
Check,
Code2,
Download,
Globe,
Heart,
LoaderCircle,
RefreshCw,
Search,
ShieldCheck,
Sparkles,
Tag,
} from '@lucide/svelte';
import DefaultThemePreview from '$assets/img/default-theme.png';
let {
onApplyTheme,
onRequestTrust,
}: {
onApplyTheme: (theme: Theme) => Promise<void>;
onRequestTrust: (theme: Theme) => void;
} = $props();
let searchQuery = $state('');
let selectedTag = $state('all');
let selectedSort = $state<'popular' | 'recent' | 'likes' | 'downloads'>('popular');
let themes = $state<CommunityTheme[]>([]);
let totalThemes = $state(0);
let loading = $state(false);
let error = $state<string | null>(null);
let downloadingId = $state<string | null>(null);
let downloadProgress = $state(0);
let downloadStatus = $state('');
const TAGS = ['all', 'anime', 'minimal', 'video', 'audio', 'game', 'dark', 'clean'];
const localThemeMap = $derived.by(() => {
const map = new Map<string, Theme>();
for (const t of $custom_themes) {
map.set(t.folder_name.toLowerCase(), t);
map.set(t.name.toLowerCase(), t);
}
return map;
});
async function loadWorkshopThemes() {
loading = true;
error = null;
try {
const res = await fetchCommunityThemes({
q: searchQuery.trim().length > 0 ? searchQuery.trim() : undefined,
tag: selectedTag !== 'all' ? selectedTag : undefined,
sort: selectedSort,
limit: 50,
});
themes = res.items;
totalThemes = res.total;
} catch (err) {
console.warn('[Workshop] Failed to load community themes:', err);
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
let searchDebounceTimeout: any = null;
function handleSearchInput() {
clearTimeout(searchDebounceTimeout);
searchDebounceTimeout = setTimeout(() => {
loadWorkshopThemes();
}, 300);
}
function handleTagClick(tag: string) {
if (selectedTag === tag) return;
selectedTag = tag;
loadWorkshopThemes();
}
function handleSortChange(sort: 'popular' | 'recent' | 'likes' | 'downloads') {
if (selectedSort === sort) return;
selectedSort = sort;
loadWorkshopThemes();
}
async function handleLike(theme: CommunityTheme, event: MouseEvent) {
event.stopPropagation();
const prevLiked = theme.isLiked;
const prevCount = theme.likeCount;
// Optimistic update
theme.isLiked = !prevLiked;
theme.likeCount = prevLiked ? Math.max(0, prevCount - 1) : prevCount + 1;
try {
const res = await toggleCommunityThemeLike(theme.id);
theme.isLiked = res.liked;
theme.likeCount = res.likeCount;
} catch (err) {
// Revert on error
theme.isLiked = prevLiked;
theme.likeCount = prevCount;
sileo.error({
title: 'Could not like theme',
description: err instanceof Error ? err.message : String(err),
});
}
}
async function handleDownloadAndApply(theme: CommunityTheme) {
if (downloadingId) return;
downloadingId = theme.id;
downloadProgress = 10;
downloadStatus = 'Downloading theme package...';
try {
const res = await downloadAndInstallCommunityTheme(theme, (msg, progress) => {
downloadStatus = msg;
downloadProgress = progress;
});
sileo.success({
title: 'Theme Installed',
description: `"${theme.name}" has been installed to your library.`,
});
// Find locally installed theme object
const installed = $custom_themes.find(
(t) => t.folder_name.toLowerCase() === res.folderName.toLowerCase()
);
if (installed) {
if (!isThemeTrusted(installed)) {
onRequestTrust(installed);
} else {
await onApplyTheme(installed);
}
}
} catch (err) {
console.error(err);
sileo.error({
title: 'Download failed',
description: err instanceof Error ? err.message : String(err),
});
} finally {
downloadingId = null;
downloadProgress = 0;
downloadStatus = '';
}
}
async function handleApplyExisting(theme: CommunityTheme) {
const installed =
localThemeMap.get(theme.id.toLowerCase()) ||
localThemeMap.get(theme.name.toLowerCase());
if (!installed) {
await handleDownloadAndApply(theme);
return;
}
if (!isThemeTrusted(installed)) {
onRequestTrust(installed);
} else {
await onApplyTheme(installed);
}
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
}
onMount(() => {
loadWorkshopThemes();
});
</script>
<div class="flex h-full flex-col overflow-hidden">
<!-- Workshop Filter & Search Toolbar -->
<div class="flex flex-col gap-2.5 pb-3">
<div class="flex items-center justify-between gap-3">
<!-- Tag Chips -->
<div class="flex min-w-0 flex-wrap items-center gap-1">
{#each TAGS as tag}
<button
type="button"
class="rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors {selectedTag === tag
? 'bg-primary text-primary-foreground shadow-sm'
: 'bg-theme-900/60 text-muted-foreground hover:bg-theme-800/60 hover:text-white'}"
onclick={() => handleTagClick(tag)}
>
{tag === 'all' ? 'All Tags' : `#${tag}`}
</button>
{/each}
</div>
<!-- Controls Right: Sort & Refresh -->
<div class="flex shrink-0 items-center gap-2">
<select
class="h-7.5 rounded-lg border border-theme-800 bg-theme-950/80 px-2.5 text-[11px] text-muted-foreground outline-none transition-colors hover:text-white focus:border-primary/50"
value={selectedSort}
onchange={(e) => handleSortChange(e.currentTarget.value as any)}
>
<option value="popular">Most Popular</option>
<option value="recent">Most Recent</option>
<option value="likes">Most Liked</option>
<option value="downloads">Most Downloaded</option>
</select>
<div class="relative w-48">
<Search class="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
class="h-7.5 border-theme-800 bg-theme-950/80 pl-8 text-[11px]"
placeholder="Search workshop..."
bind:value={searchQuery}
oninput={handleSearchInput}
/>
</div>
<Button
variant="ghost"
size="icon"
class="size-7.5 text-muted-foreground hover:text-white"
title="Refresh Workshop"
onclick={loadWorkshopThemes}
disabled={loading}
>
<RefreshCw class="size-3.5 {loading ? 'animate-spin' : ''}" />
</Button>
</div>
</div>
</div>
<!-- Content Grid / Status -->
<div class="min-h-0 flex-1 pt-1">
<ScrollContainer class="pr-2">
{#if loading && themes.length === 0}
<div class="flex h-64 flex-col items-center justify-center gap-3 text-muted-foreground">
<LoaderCircle class="size-6 animate-spin text-primary" />
<p class="text-xs">Connecting to EZPPFarm Community Workshop...</p>
</div>
{:else if error && themes.length === 0}
<div class="flex h-64 flex-col items-center justify-center gap-3 text-center">
<div class="rounded-full bg-red-500/10 p-3 text-red-400">
<Globe class="size-6" />
</div>
<div class="max-w-md">
<p class="text-sm font-semibold text-white">Workshop Offline or Unreachable</p>
<p class="mt-1 text-xs text-muted-foreground">
{error}. Ensure the themes microservice container is running.
</p>
</div>
<Button variant="secondary" size="sm" class="mt-2 text-xs" onclick={loadWorkshopThemes}>
<RefreshCw class="mr-1.5 size-3.5" />
Try Again
</Button>
</div>
{:else if themes.length === 0}
<div class="flex h-64 flex-col items-center justify-center gap-2 text-muted-foreground">
<Globe class="size-8 opacity-40" />
<p class="text-xs">No community themes found matching your search.</p>
</div>
{:else}
<div class="grid grid-cols-3 gap-3 pb-4">
{#each themes as theme (theme.id)}
{@const localTheme = localThemeMap.get(theme.id.toLowerCase()) || localThemeMap.get(theme.name.toLowerCase())}
{@const isInstalled = Boolean(localTheme)}
{@const isActive = Boolean(localTheme && $active_custom_theme?.folder_name === localTheme.folder_name)}
{@const isDownloading = downloadingId === theme.id}
{@const hasUpdate = Boolean(localTheme && localTheme.version !== theme.version)}
<div
class="group flex flex-col overflow-hidden rounded-xl border bg-theme-900/30 {isActive
? 'border-primary/60 ring-1 ring-primary/20'
: 'border-theme-800/60'} transition-all duration-300 hover:-translate-y-0.5 hover:border-white/20 hover:bg-theme-900/50 hover:shadow-xl hover:shadow-black/30"
>
<!-- 16:9 Thumbnail -->
<div class="relative aspect-video w-full shrink-0 overflow-hidden bg-theme-950/70">
<img
src={theme.previewUrl}
alt={theme.name}
class="h-full w-full object-cover object-center transition-transform duration-500 group-hover:scale-[1.03]"
onerror={(e) => {
// Fallback to default theme preview if server preview not loaded
(e.currentTarget as HTMLImageElement).src = DefaultThemePreview;
}}
/>
<div class="pointer-events-none absolute inset-0 bg-gradient-to-t from-theme-950/90 via-transparent to-transparent"></div>
<!-- Top Left: Trust Badge -->
<div class="absolute top-2 left-2 flex items-center gap-1.5">
{#if !theme.hasExecutableCode}
<span
class="flex items-center gap-1 rounded-md border border-emerald-500/30 bg-emerald-950/80 px-1.5 py-0.5 text-[9px] font-medium text-emerald-300 backdrop-blur-sm"
title="Sandboxed Declarative Theme: pure styles, no executable JS code"
>
<ShieldCheck class="size-3" />
Declarative
</span>
{:else}
<span
class="flex items-center gap-1 rounded-md border border-amber-500/30 bg-amber-950/80 px-1.5 py-0.5 text-[9px] font-medium text-amber-300 backdrop-blur-sm"
title="Contains custom Svelte 5 executable logic"
>
<Code2 class="size-3" />
Svelte Code
</span>
{/if}
</div>
<!-- Top Right: Version -->
<div class="absolute top-2 right-2">
<span class="rounded-md bg-black/60 px-1.5 py-0.5 text-[9px] font-medium text-white/90 backdrop-blur-sm">
v{theme.version}
</span>
</div>
<!-- Bottom: Author Info -->
<div class="absolute right-2 bottom-2 left-2 flex items-center justify-between gap-2">
<div class="flex min-w-0 items-center gap-1.5">
<img
src={theme.author.avatarUrl}
alt={theme.author.name}
class="size-5 rounded-full border border-white/20 bg-theme-900 object-cover"
onerror={(e) => {
(e.currentTarget as HTMLElement).style.display = 'none';
}}
/>
<span class="truncate text-[11px] font-semibold text-white/95 drop-shadow-sm">
{theme.author.name}
</span>
{#if theme.author.donor}
<span
class="flex items-center gap-0.5 rounded-full bg-amber-500/20 px-1 py-0.5 text-[8px] font-bold text-amber-300 border border-amber-500/30"
title="EZPPFarm Supporter"
>
<Sparkles class="size-2.5 fill-amber-300" />
Supporter
</span>
{/if}
</div>
<span class="text-[9px] text-muted-foreground/90 tabular-nums">
{formatBytes(theme.fileSize)}
</span>
</div>
</div>
<!-- Body -->
<div class="flex flex-1 flex-col justify-between gap-2 p-3">
<div class="min-w-0">
<div class="flex items-center justify-between gap-2">
<h3 class="truncate text-[13px] font-bold text-white group-hover:text-primary transition-colors">
{theme.name}
</h3>
<!-- Interactive Like Button -->
<button
type="button"
class="flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] transition-colors {theme.isLiked
? 'bg-rose-500/20 text-rose-300 hover:bg-rose-500/30'
: 'bg-theme-800/40 text-muted-foreground hover:bg-theme-800/80 hover:text-white'}"
onclick={(e) => handleLike(theme, e)}
title={theme.isLiked ? 'Unlike' : 'Like'}
>
<Heart class="size-3 {theme.isLiked ? 'fill-rose-400 text-rose-400' : ''}" />
<span class="tabular-nums text-[10px]">{theme.likeCount}</span>
</button>
</div>
{#if theme.description}
<p class="mt-1 line-clamp-2 text-[10px] leading-relaxed text-muted-foreground">
{theme.description}
</p>
{/if}
{#if theme.tags && theme.tags.length > 0}
<div class="mt-2 flex flex-wrap gap-1">
{#each theme.tags.slice(0, 3) as tag}
<span class="rounded bg-theme-800/50 px-1.5 py-0.5 text-[9px] text-muted-foreground">
#{tag}
</span>
{/each}
</div>
{/if}
</div>
<!-- Footer Action Row -->
<div class="mt-2 flex items-center justify-between border-t border-theme-800/40 pt-2.5">
<div class="flex items-center gap-1 text-[10px] text-muted-foreground">
<Download class="size-3" />
<span class="tabular-nums">{theme.downloadCount}</span>
</div>
<div>
{#if isDownloading}
<div class="flex items-center gap-2">
<span class="text-[10px] text-primary tabular-nums">{downloadProgress}%</span>
<div class="h-1.5 w-16 overflow-hidden rounded-full bg-theme-800">
<div
class="h-full bg-primary transition-all duration-200"
style="width: {downloadProgress}%"
></div>
</div>
</div>
{:else if isActive}
<Badge variant="outline" class="border-emerald-500/40 bg-emerald-950/20 text-emerald-300 text-[10px]">
<Check class="mr-1 size-3" /> In use
</Badge>
{:else if isInstalled && hasUpdate}
<Button
size="sm"
class="h-7 text-xs bg-cyan-600 hover:bg-cyan-500 text-white font-medium"
onclick={() => handleDownloadAndApply(theme)}
>
Update
</Button>
{:else if isInstalled}
<Button
variant="secondary"
size="sm"
class="h-7 text-xs"
onclick={() => handleApplyExisting(theme)}
>
Apply
</Button>
{:else}
<Button
size="sm"
class="h-7 text-xs"
onclick={() => handleDownloadAndApply(theme)}
>
<Download class="mr-1 size-3" />
Download & Apply
</Button>
{/if}
</div>
</div>
</div>
</div>
{/each}
</div>
{/if}
</ScrollContainer>
</div>
</div>
@@ -100,6 +100,7 @@
themesDirectory,
toSafeName,
} from '$lib/themes';
import { publishThemeToWorkshop } from '$lib/communityThemes';
import type { EZPPUser, EZPPUserInfo } from '$lib/types';
import { currentUser } from '$lib/userAuthentication';
import { osuInstallationPath } from '$lib/userSettings';
@@ -126,6 +127,7 @@
EyeOff,
Film,
Gauge,
Globe,
GripVertical,
Hand,
Image as ImageIcon,
@@ -3329,6 +3331,100 @@
}
}
let publishModalOpen = $state(false);
let publishThemeId = $state('');
let publishName = $state('');
let publishDescription = $state('');
let publishVersion = $state('1.0.0');
let publishTags = $state('anime, video');
let publishing = $state(false);
let publishStatusMessage = $state('');
let publishProgress = $state(0);
async function openPublishDialog() {
if (exporting || saving || publishing) return;
if (!$currentUser) {
sileo.error({
title: 'Authentication Required',
description:
'You must be logged in to your EZPPFarm account to publish themes to the workshop.',
});
return;
}
const report = await validateTheme();
validationReport = report;
const errors = report.issues.some((issue) => issue.level === 'error');
if (errors) {
validationOpen = true;
exportAfterValidation = false;
return;
}
if (dirty) {
await saveTheme({ notify: false });
}
publishThemeId = savedFolder || toSafeName(definition.name);
publishName = definition.name || 'My Theme';
publishDescription = definition.description || '';
publishVersion = definition.version || '1.0.0';
publishTags = 'workshop, v2';
publishProgress = 0;
publishStatusMessage = '';
publishModalOpen = true;
}
async function handlePublishToWorkshop() {
if (publishing) return;
if (!savedFolder) {
await saveTheme({ notify: false });
if (!savedFolder) return;
}
publishing = true;
publishProgress = 5;
publishStatusMessage = 'Packaging theme archive...';
try {
const baseThemesDir = await themesDirectory();
const themeFolder = await path.join(baseThemesDir, savedFolder);
await publishThemeToWorkshop({
themeFolder,
themeId: publishThemeId,
name: publishName,
description: publishDescription,
version: publishVersion,
apiVersion: definition.apiVersion || '2.0.0',
tags: publishTags
.split(',')
.map((t) => t.trim())
.filter(Boolean),
onProgress: (msg, pct) => {
publishStatusMessage = msg;
publishProgress = pct;
},
});
sileo.success({
title: 'Published to Workshop!',
description: `"${publishName}" is now live in the Community Themes Workshop.`,
});
publishModalOpen = false;
} catch (err) {
console.error(err);
sileo.error({
title: 'Publish Failed',
description: err instanceof Error ? err.message : String(err),
});
} finally {
publishing = false;
}
}
async function closeEditor() {
if (saving) return;
if (dirty) {
@@ -3751,7 +3847,7 @@
variant="secondary"
size="sm"
onclick={() => exportCurrentTheme()}
disabled={exporting || saving}
disabled={exporting || saving || publishing}
>
{#if exporting}
<LoaderCircle class="animate-spin" />
@@ -3760,6 +3856,20 @@
{/if}
Export
</Button>
<Button
variant="outline"
size="sm"
onclick={openPublishDialog}
disabled={exporting || saving || publishing}
class="border-cyan-500/30 bg-cyan-950/20 text-cyan-300 hover:bg-cyan-950/40 hover:text-cyan-200"
>
{#if publishing}
<LoaderCircle class="animate-spin" />
{:else}
<Globe class="size-4" />
{/if}
Publish
</Button>
<Button size="sm" onclick={() => saveTheme()} disabled={saving || loading}>
{#if saving}
<LoaderCircle class="animate-spin" />
@@ -6631,4 +6741,167 @@
</div>
</div>
{/if}
{#if publishModalOpen}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/75 p-6 backdrop-blur-md"
role="presentation"
>
<div
class="w-full max-w-lg rounded-2xl border border-theme-800 bg-theme-950 p-6 shadow-2xl"
role="dialog"
aria-labelledby="publish-dialog-title"
>
<div class="flex items-center justify-between border-b border-theme-800/60 pb-3">
<div class="flex items-center gap-2">
<div class="rounded-lg bg-cyan-500/10 p-2 text-cyan-400">
<Globe class="size-5" />
</div>
<div>
<h2 id="publish-dialog-title" class="text-base font-semibold text-white">
Publish to Community Workshop
</h2>
<p class="text-[11px] text-muted-foreground">
Share your theme with the EZPPFarm community.
</p>
</div>
</div>
{#if !publishing}
<Button
variant="ghost"
size="icon"
class="size-7 text-muted-foreground hover:text-white"
onclick={() => (publishModalOpen = false)}
>
<X class="size-4" />
</Button>
{/if}
</div>
{#if $currentUser}
<div class="mt-4 flex items-center justify-between rounded-xl border border-theme-800/70 bg-theme-900/40 px-3.5 py-2.5">
<div class="flex items-center gap-2.5">
<img
src={`https://a.ez-pp.farm/${$currentUser.id}`}
alt={$currentUser.name}
class="size-8 rounded-full border border-white/20 bg-theme-800 object-cover"
onerror={(e) => {
(e.currentTarget as HTMLElement).style.display = 'none';
}}
/>
<div>
<div class="flex items-center gap-1.5">
<span class="text-xs font-semibold text-white">{$currentUser.name}</span>
{#if $currentUser.donor}
<span class="rounded bg-amber-500/20 px-1 py-0.2 text-[8px] font-bold text-amber-300 border border-amber-500/30">
Supporter
</span>
{/if}
</div>
<span class="text-[10px] text-muted-foreground">Creator & Publisher</span>
</div>
</div>
<span class="text-[10px] text-emerald-400 font-medium">Verified Account</span>
</div>
{/if}
<div class="mt-4 flex flex-col gap-3">
<div>
<Label class="text-[11px] text-muted-foreground">Workshop Identifier (Slug)</Label>
<Input
class="mt-1 h-8 border-theme-800 bg-theme-900/50 text-xs font-mono"
bind:value={publishThemeId}
placeholder="e.g. lucoa-theme"
disabled={publishing}
/>
</div>
<div class="grid grid-cols-3 gap-2">
<div class="col-span-2">
<Label class="text-[11px] text-muted-foreground">Theme Name</Label>
<Input
class="mt-1 h-8 border-theme-800 bg-theme-900/50 text-xs"
bind:value={publishName}
placeholder="My Awesome Theme"
disabled={publishing}
/>
</div>
<div>
<Label class="text-[11px] text-muted-foreground">Version</Label>
<Input
class="mt-1 h-8 border-theme-800 bg-theme-900/50 text-xs font-mono"
bind:value={publishVersion}
placeholder="1.0.0"
disabled={publishing}
/>
</div>
</div>
<div>
<Label class="text-[11px] text-muted-foreground">Description</Label>
<Textarea
class="mt-1 border-theme-800 bg-theme-900/50 text-xs leading-relaxed"
rows={2}
bind:value={publishDescription}
placeholder="Tell others about your theme..."
disabled={publishing}
/>
</div>
<div>
<Label class="text-[11px] text-muted-foreground">Tags (comma-separated)</Label>
<Input
class="mt-1 h-8 border-theme-800 bg-theme-900/50 text-xs"
bind:value={publishTags}
placeholder="anime, video, clean"
disabled={publishing}
/>
</div>
</div>
{#if publishing}
<div class="mt-4 rounded-xl border border-cyan-500/20 bg-cyan-950/20 p-3">
<div class="flex items-center justify-between text-xs">
<span class="flex items-center gap-1.5 text-cyan-300 font-medium">
<LoaderCircle class="size-3.5 animate-spin" />
{publishStatusMessage}
</span>
<span class="font-mono text-cyan-400 font-bold tabular-nums">{publishProgress}%</span>
</div>
<div class="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-theme-900">
<div
class="h-full bg-cyan-400 transition-all duration-300"
style="width: {publishProgress}%"
></div>
</div>
</div>
{/if}
<div class="mt-5 flex justify-end gap-2 border-t border-theme-800/60 pt-3">
<Button
variant="ghost"
size="sm"
onclick={() => (publishModalOpen = false)}
disabled={publishing}
>
Cancel
</Button>
<Button
size="sm"
class="bg-cyan-600 hover:bg-cyan-500 text-white font-medium gap-1.5"
onclick={handlePublishToWorkshop}
disabled={publishing || publishThemeId.trim().length < 2 || publishName.trim().length === 0}
>
{#if publishing}
<LoaderCircle class="size-3.5 animate-spin" />
Publishing...
{:else}
<Globe class="size-3.5" />
Publish to Workshop
{/if}
</Button>
</div>
</div>
</div>
{/if}
</div>