feat(themes): implement community themes workshop and chunked uploader
This commit is contained in:
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>
|
||||
Reference in new issue
Block a user