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

+386
View File
@@ -0,0 +1,386 @@
import { invoke } from '@tauri-apps/api/core';
import * as path from '@tauri-apps/api/path';
import * as fs from '@tauri-apps/plugin-fs';
import { config } from './config';
import { reloadThemes, themesDirectory } from './themes';
export interface CommunityThemeAuthor {
id: number;
name: string;
donor: boolean;
avatarUrl: string;
}
export interface CommunityTheme {
id: string;
name: string;
description: string;
version: string;
apiVersion: string;
hasExecutableCode: boolean;
fileSize: number;
checksumSha256: string;
downloadCount: number;
likeCount: number;
tags: string[];
createdAt: string;
updatedAt: string;
author: CommunityThemeAuthor;
isLiked: boolean;
previewUrl: string;
downloadUrl: string;
}
export interface CommunityThemesResponse {
items: CommunityTheme[];
total: number;
page: number;
totalPages: number;
}
const DEFAULT_THEMES_SERVICE_URL = 'http://127.0.0.1:3000';
let cachedSessionToken: string | null = null;
export async function getThemesServiceUrl(): Promise<string> {
const customUrl = await config.value<string>('themes_service_url').get('');
return customUrl.trim().length > 0 ? customUrl.trim().replace(/\/+$/, '') : DEFAULT_THEMES_SERVICE_URL;
}
export async function getThemesAuthToken(forceRefresh = false): Promise<string | null> {
if (!forceRefresh && cachedSessionToken) {
return cachedSessionToken;
}
const storedToken = await config.value<string>('themes_auth_token').get('');
if (!forceRefresh && storedToken.trim().length > 0) {
cachedSessionToken = storedToken.trim();
return cachedSessionToken;
}
// Attempt automatic login using saved username & password
const username = await config.value<string>('username').get('');
const password = await config.value<string>('password').get('');
if (username.length > 0 && password.length > 0) {
try {
const baseUrl = await getThemesServiceUrl();
const res = await fetch(`${baseUrl}/v1/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (res.ok) {
const data = (await res.json()) as { token: string };
if (data.token) {
cachedSessionToken = data.token;
await config.value<string>('themes_auth_token').set(data.token);
return cachedSessionToken;
}
}
} catch (err) {
console.warn('[CommunityThemes] Auto-login failed:', err);
}
}
return null;
}
export async function fetchCommunityThemes(options?: {
q?: string;
tag?: string;
authorId?: number;
sort?: 'popular' | 'recent' | 'likes' | 'downloads';
page?: number;
limit?: number;
}): Promise<CommunityThemesResponse> {
const baseUrl = await getThemesServiceUrl();
const token = await getThemesAuthToken();
const url = new URL(`${baseUrl}/v1/themes`);
if (options?.q) url.searchParams.set('q', options.q);
if (options?.tag) url.searchParams.set('tag', options.tag);
if (options?.authorId) url.searchParams.set('authorId', String(options.authorId));
if (options?.sort) url.searchParams.set('sort', options.sort);
if (options?.page) url.searchParams.set('page', String(options.page));
if (options?.limit) url.searchParams.set('limit', String(options.limit));
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(url.toString(), { headers });
if (!res.ok) {
throw new Error(`Failed to load themes (HTTP ${res.status})`);
}
const data = (await res.json()) as CommunityThemesResponse;
// Ensure absolute preview and download URLs
for (const item of data.items) {
if (item.previewUrl && !item.previewUrl.startsWith('http')) {
item.previewUrl = `${baseUrl}${item.previewUrl}`;
}
if (item.downloadUrl && !item.downloadUrl.startsWith('http')) {
item.downloadUrl = `${baseUrl}${item.downloadUrl}`;
}
}
return data;
}
export async function fetchCommunityThemeDetails(themeId: string): Promise<CommunityTheme> {
const baseUrl = await getThemesServiceUrl();
const token = await getThemesAuthToken();
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`${baseUrl}/v1/themes/${encodeURIComponent(themeId)}`, { headers });
if (!res.ok) {
throw new Error(`Theme not found (HTTP ${res.status})`);
}
const item = (await res.json()) as CommunityTheme;
if (item.previewUrl && !item.previewUrl.startsWith('http')) {
item.previewUrl = `${baseUrl}${item.previewUrl}`;
}
if (item.downloadUrl && !item.downloadUrl.startsWith('http')) {
item.downloadUrl = `${baseUrl}${item.downloadUrl}`;
}
return item;
}
export async function toggleCommunityThemeLike(themeId: string): Promise<{ liked: boolean; likeCount: number }> {
const baseUrl = await getThemesServiceUrl();
const token = await getThemesAuthToken();
if (!token) {
throw new Error('Please log in to like community themes.');
}
const res = await fetch(`${baseUrl}/v1/themes/${encodeURIComponent(themeId)}/like`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
const errorJson = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(errorJson.error || `Failed to like theme (HTTP ${res.status})`);
}
return (await res.json()) as { liked: boolean; likeCount: number };
}
export async function deleteCommunityTheme(themeId: string): Promise<void> {
const baseUrl = await getThemesServiceUrl();
const token = await getThemesAuthToken();
if (!token) {
throw new Error('Please log in to manage your community themes.');
}
const res = await fetch(`${baseUrl}/v1/themes/${encodeURIComponent(themeId)}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
const errorJson = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(errorJson.error || `Failed to delete theme (HTTP ${res.status})`);
}
}
/**
* 3-step Cloudflare-compliant chunked upload protocol (~15 MB slices).
*/
export async function publishThemeToWorkshop(options: {
themeFolder: string;
themeId: string;
name: string;
description?: string;
version?: string;
apiVersion?: string;
tags?: string[];
previewImageBase64?: string;
onProgress?: (status: string, percentage: number) => void;
}): Promise<{ success: boolean; theme: any }> {
const { onProgress = () => {} } = options;
const baseUrl = await getThemesServiceUrl();
const token = await getThemesAuthToken();
if (!token) {
throw new Error('You must be logged in to EZPPFarm to publish themes.');
}
onProgress('Packaging theme archive...', 5);
const baseThemesDir = await themesDirectory();
const tmpDir = await path.join(baseThemesDir, '.tmp');
if (!(await fs.exists(tmpDir))) {
await fs.mkdir(tmpDir, { recursive: true });
}
const destinationZip = await path.join(tmpDir, `${options.themeId}_workshop_export.zip`);
try {
// 1. Export theme to a local zip archive
await invoke('export_theme', {
themeFolder: options.themeFolder,
destinationPath: destinationZip,
});
onProgress('Calculating checksum...', 10);
const totalSize = (await invoke<number>('file_size', { path: destinationZip })) ?? 0;
if (totalSize === 0) {
throw new Error('Failed to create theme archive (0 bytes).');
}
const sha256 = await invoke<string>('hash_file_sha256', { path: destinationZip });
const CHUNK_SIZE = 15 * 1024 * 1024; // 15 MB
const totalChunks = Math.ceil(totalSize / CHUNK_SIZE);
onProgress('Initializing upload session...', 15);
// Step 1: Init upload session
const initRes = await fetch(`${baseUrl}/v1/themes/upload/init`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
themeId: options.themeId,
name: options.name,
description: options.description || '',
version: options.version || '1.0.0',
apiVersion: options.apiVersion || '2.0.0',
totalSize,
totalChunks,
sha256,
tags: options.tags || [],
}),
});
if (!initRes.ok) {
const errData = (await initRes.json().catch(() => ({}))) as { error?: string };
throw new Error(errData.error || `Upload initialization failed (HTTP ${initRes.status})`);
}
const { uploadId } = (await initRes.json()) as { uploadId: string };
// Step 2: Upload chunks sequentially
for (let i = 0; i < totalChunks; i++) {
const offset = i * CHUNK_SIZE;
const length = Math.min(CHUNK_SIZE, totalSize - offset);
const progressStart = 20 + Math.round((i / totalChunks) * 65);
onProgress(`Uploading chunk ${i + 1} of ${totalChunks}...`, progressStart);
const chunkBytes = await invoke<number[]>('read_file_chunk', {
path: destinationZip,
offset,
length,
});
const chunkData = new Uint8Array(chunkBytes);
const chunkRes = await fetch(`${baseUrl}/v1/themes/upload/${uploadId}/chunk/${i}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/octet-stream',
Authorization: `Bearer ${token}`,
},
body: chunkData,
});
if (!chunkRes.ok) {
const errData = (await chunkRes.json().catch(() => ({}))) as { error?: string };
throw new Error(errData.error || `Failed uploading chunk ${i + 1}`);
}
}
// Step 3: Complete upload
onProgress('Verifying integrity & finalizing...', 90);
const completeRes = await fetch(`${baseUrl}/v1/themes/upload/${uploadId}/complete`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
previewImageBase64: options.previewImageBase64,
}),
});
if (!completeRes.ok) {
const errData = (await completeRes.json().catch(() => ({}))) as { error?: string };
throw new Error(errData.error || `Finalizing upload failed (HTTP ${completeRes.status})`);
}
const completeData = await completeRes.json();
onProgress('Theme published successfully!', 100);
return { success: true, theme: completeData };
} finally {
// Clean up temporary local zip
if (await fs.exists(destinationZip)) {
await fs.remove(destinationZip).catch(() => {});
}
}
}
/**
* Download, extract, and register a community theme into ~/.ezpplauncher/themes/.
*/
export async function downloadAndInstallCommunityTheme(
theme: CommunityTheme,
onProgress?: (status: string, percentage: number) => void
): Promise<{ success: boolean; folderName: string }> {
const { onProgress: reportProgress = () => {} } = { onProgress };
const baseThemesDir = await themesDirectory();
const folderName = theme.id;
const targetFolder = await path.join(baseThemesDir, folderName);
reportProgress('Downloading theme package...', 10);
const res = await fetch(theme.downloadUrl);
if (!res.ok) {
throw new Error(`Download failed (HTTP ${res.status})`);
}
const tmpDir = await path.join(baseThemesDir, '.tmp');
if (!(await fs.exists(tmpDir))) {
await fs.mkdir(tmpDir, { recursive: true });
}
const tempFile = await path.join(tmpDir, `${folderName}_download.ezpplauncher-theme`);
try {
const arrayBuffer = await res.arrayBuffer();
await fs.writeFile(tempFile, new Uint8Array(arrayBuffer));
reportProgress('Extracting theme files...', 70);
if (!(await fs.exists(targetFolder))) {
await fs.mkdir(targetFolder, { recursive: true });
}
await invoke('extract_theme', {
filePath: tempFile,
themeFolder: targetFolder,
themeName: theme.name,
});
reportProgress('Theme installed!', 100);
// Refresh themes in launcher state
await reloadThemes();
return { success: true, folderName };
} finally {
if (await fs.exists(tempFile)) {
await fs.remove(tempFile).catch(() => {});
}
}
}
@@ -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>