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
@@ -86,6 +86,53 @@ pub fn file_size(path: String) -> Option<u64> {
|
|||||||
metadata.is_file().then_some(metadata.len())
|
metadata.is_file().then_some(metadata.len())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Calculate the SHA-256 checksum of a file on disk.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn hash_file_sha256(path: String) -> Result<String, String> {
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::io::Read;
|
||||||
|
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let mut file = std::fs::File::open(&path)
|
||||||
|
.map_err(|e| format!("Failed to open file for hashing: {e}"))?;
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
let mut buffer = [0u8; 65536];
|
||||||
|
loop {
|
||||||
|
let bytes_read = file
|
||||||
|
.read(&mut buffer)
|
||||||
|
.map_err(|e| format!("Failed to read file for hashing: {e}"))?;
|
||||||
|
if bytes_read == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
hasher.update(&buffer[..bytes_read]);
|
||||||
|
}
|
||||||
|
Ok::<_, String>(hex::encode(hasher.finalize()))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Thread error: {e}"))?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a chunk of a file on disk starting at `offset` for `length` bytes.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn read_file_chunk(path: String, offset: u64, length: u64) -> Result<Vec<u8>, String> {
|
||||||
|
use std::io::{Read, Seek, SeekFrom};
|
||||||
|
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let mut file = std::fs::File::open(&path)
|
||||||
|
.map_err(|e| format!("Failed to open file for chunk read: {e}"))?;
|
||||||
|
file.seek(SeekFrom::Start(offset))
|
||||||
|
.map_err(|e| format!("Failed to seek to offset {offset}: {e}"))?;
|
||||||
|
let mut buffer = vec![0u8; length as usize];
|
||||||
|
let bytes_read = file
|
||||||
|
.read(&mut buffer)
|
||||||
|
.map_err(|e| format!("Failed to read chunk bytes: {e}"))?;
|
||||||
|
buffer.truncate(bytes_read);
|
||||||
|
Ok::<_, String>(buffer)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Thread error: {e}"))?
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_hwid() -> String {
|
pub fn get_hwid() -> String {
|
||||||
get_id()
|
get_id()
|
||||||
|
|||||||
@@ -24,12 +24,12 @@ use crate::{
|
|||||||
export_theme, extract_theme, file_size, find_osu_installation, focus_presence_editor, focus_theme_editor, get_beatmapsets_count,
|
export_theme, extract_theme, file_size, find_osu_installation, focus_presence_editor, focus_theme_editor, get_beatmapsets_count,
|
||||||
get_ezpp_launcher_update_files, get_hwid, get_launcher_version, get_osu_release_stream,
|
get_ezpp_launcher_update_files, get_hwid, get_launcher_version, get_osu_release_stream,
|
||||||
get_osu_skin, get_osu_version, get_platform, get_skins_count, has_net8, has_osuwinello,
|
get_osu_skin, get_osu_version, get_platform, get_skins_count, has_net8, has_osuwinello,
|
||||||
has_wmctrl, install_ezpp_launcher_update, installation_diagnostics,
|
has_wmctrl, hash_file_sha256, install_ezpp_launcher_update, installation_diagnostics,
|
||||||
is_open_tablet_driver_path_valid, is_open_tablet_driver_running, is_osu_running,
|
is_open_tablet_driver_path_valid, is_open_tablet_driver_running, is_osu_running,
|
||||||
list_theme_backups, log_frontend, open_logs_folder, open_presence_editor, open_theme_editor, open_theme_folder,
|
list_theme_backups, log_frontend, open_logs_folder, open_presence_editor, open_theme_editor, open_theme_folder,
|
||||||
open_url_in_browser, opened_urls, optimize_theme_media, pick_osu_installation,
|
open_url_in_browser, opened_urls, optimize_theme_media, pick_osu_installation,
|
||||||
presence_connect, presence_disconnect, presence_is_connected, presence_update_activity,
|
presence_connect, presence_disconnect, presence_is_connected, presence_update_activity,
|
||||||
presence_update_button, presence_update_status, presence_update_user, read_theme_info, replace_ui_files,
|
presence_update_button, presence_update_status, presence_update_user, read_file_chunk, read_theme_info, replace_ui_files,
|
||||||
resolve_osu_folder, restore_theme_backup, reveal_window, run_open_tablet_driver, run_osu, run_osu_updater,
|
resolve_osu_folder, restore_theme_backup, reveal_window, run_open_tablet_driver, run_osu, run_osu_updater,
|
||||||
save_theme_project, set_logging_enabled, set_osu_config_values, set_osu_user_config_values,
|
save_theme_project, set_logging_enabled, set_osu_config_values, set_osu_user_config_values,
|
||||||
stop_open_tablet_driver, theme_asset_base, theme_editor_context, theme_preview_url,
|
stop_open_tablet_driver, theme_asset_base, theme_editor_context, theme_preview_url,
|
||||||
@@ -194,6 +194,8 @@ pub fn run() {
|
|||||||
download_and_extract_theme,
|
download_and_extract_theme,
|
||||||
save_theme_project,
|
save_theme_project,
|
||||||
export_theme,
|
export_theme,
|
||||||
|
hash_file_sha256,
|
||||||
|
read_file_chunk,
|
||||||
open_theme_folder,
|
open_theme_folder,
|
||||||
open_theme_editor,
|
open_theme_editor,
|
||||||
focus_theme_editor,
|
focus_theme_editor,
|
||||||
|
|||||||
@@ -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,
|
themesDirectory,
|
||||||
toSafeName,
|
toSafeName,
|
||||||
} from '$lib/themes';
|
} from '$lib/themes';
|
||||||
|
import { publishThemeToWorkshop } from '$lib/communityThemes';
|
||||||
import type { EZPPUser, EZPPUserInfo } from '$lib/types';
|
import type { EZPPUser, EZPPUserInfo } from '$lib/types';
|
||||||
import { currentUser } from '$lib/userAuthentication';
|
import { currentUser } from '$lib/userAuthentication';
|
||||||
import { osuInstallationPath } from '$lib/userSettings';
|
import { osuInstallationPath } from '$lib/userSettings';
|
||||||
@@ -126,6 +127,7 @@
|
|||||||
EyeOff,
|
EyeOff,
|
||||||
Film,
|
Film,
|
||||||
Gauge,
|
Gauge,
|
||||||
|
Globe,
|
||||||
GripVertical,
|
GripVertical,
|
||||||
Hand,
|
Hand,
|
||||||
Image as ImageIcon,
|
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() {
|
async function closeEditor() {
|
||||||
if (saving) return;
|
if (saving) return;
|
||||||
if (dirty) {
|
if (dirty) {
|
||||||
@@ -3751,7 +3847,7 @@
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onclick={() => exportCurrentTheme()}
|
onclick={() => exportCurrentTheme()}
|
||||||
disabled={exporting || saving}
|
disabled={exporting || saving || publishing}
|
||||||
>
|
>
|
||||||
{#if exporting}
|
{#if exporting}
|
||||||
<LoaderCircle class="animate-spin" />
|
<LoaderCircle class="animate-spin" />
|
||||||
@@ -3760,6 +3856,20 @@
|
|||||||
{/if}
|
{/if}
|
||||||
Export
|
Export
|
||||||
</Button>
|
</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}>
|
<Button size="sm" onclick={() => saveTheme()} disabled={saving || loading}>
|
||||||
{#if saving}
|
{#if saving}
|
||||||
<LoaderCircle class="animate-spin" />
|
<LoaderCircle class="animate-spin" />
|
||||||
@@ -6631,4 +6741,167 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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>
|
</div>
|
||||||
+76
-41
@@ -19,6 +19,7 @@
|
|||||||
import Progress from '$lib/components/ui/progress/progress.svelte';
|
import Progress from '$lib/components/ui/progress/progress.svelte';
|
||||||
import ScrollContainer from '$lib/components/ui/scroll-container/ScrollContainer.svelte';
|
import ScrollContainer from '$lib/components/ui/scroll-container/ScrollContainer.svelte';
|
||||||
import * as Select from '$lib/components/ui/select';
|
import * as Select from '$lib/components/ui/select';
|
||||||
|
import CommunityWorkshopView from '$lib/components/theme/CommunityWorkshopView.svelte';
|
||||||
import { config } from '$lib/config';
|
import { config } from '$lib/config';
|
||||||
import { currentUserInfo } from '$lib/data';
|
import { currentUserInfo } from '$lib/data';
|
||||||
import {
|
import {
|
||||||
@@ -156,6 +157,7 @@
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
Clock,
|
Clock,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
|
Globe,
|
||||||
Medal,
|
Medal,
|
||||||
TrendingDown,
|
TrendingDown,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
@@ -281,6 +283,7 @@
|
|||||||
>(undefined);
|
>(undefined);
|
||||||
let themeInstalling = $state(false);
|
let themeInstalling = $state(false);
|
||||||
let themeSearch = $state('');
|
let themeSearch = $state('');
|
||||||
|
let activeThemesTab = $state<'library' | 'workshop'>('library');
|
||||||
let themeSavedUnlisten: (() => void) | undefined;
|
let themeSavedUnlisten: (() => void) | undefined;
|
||||||
let refreshingThemes = $state(false);
|
let refreshingThemes = $state(false);
|
||||||
let reloadingActiveTheme = $state(false);
|
let reloadingActiveTheme = $state(false);
|
||||||
@@ -3613,17 +3616,40 @@
|
|||||||
>
|
>
|
||||||
<header class="shrink-0 border-b border-theme-800/50 px-5 pt-4 pb-3">
|
<header class="shrink-0 border-b border-theme-800/50 px-5 pt-4 pb-3">
|
||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex items-center justify-between gap-3">
|
||||||
<div class="min-w-0">
|
<div class="flex min-w-0 items-center gap-4">
|
||||||
<div class="flex items-baseline gap-2">
|
<div class="min-w-0">
|
||||||
<h1 class="text-lg font-semibold tracking-tight">Themes</h1>
|
<div class="flex items-baseline gap-2">
|
||||||
<span
|
<h1 class="text-lg font-semibold tracking-tight">Themes</h1>
|
||||||
class="rounded-full bg-theme-900/70 px-1.5 py-0.5 text-[9px] tabular-nums text-muted-foreground/70"
|
</div>
|
||||||
title="Themes in your library"
|
<p class="mt-0.5 text-[11px] text-muted-foreground">Make it feel like yours.</p>
|
||||||
>
|
</div>
|
||||||
{$custom_themes.length}
|
|
||||||
</span>
|
<!-- Tab Switcher: Installed vs Community Workshop -->
|
||||||
|
<div class="flex items-center rounded-lg border border-theme-800/60 bg-theme-900/50 p-0.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors {activeThemesTab === 'library'
|
||||||
|
? 'bg-primary text-primary-foreground shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:text-white'}"
|
||||||
|
onclick={() => (activeThemesTab = 'library')}
|
||||||
|
>
|
||||||
|
<ThemeIcon name="library" set={iconSet} class="size-3.5" />
|
||||||
|
<span>Installed</span>
|
||||||
|
<span class="rounded-full bg-black/30 px-1.5 py-0.2 text-[9px] tabular-nums">
|
||||||
|
{$custom_themes.length}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors {activeThemesTab === 'workshop'
|
||||||
|
? 'bg-primary text-primary-foreground shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:text-white'}"
|
||||||
|
onclick={() => (activeThemesTab = 'workshop')}
|
||||||
|
>
|
||||||
|
<Globe class="size-3.5" />
|
||||||
|
<span>Community Workshop</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-0.5 text-[11px] text-muted-foreground">Make it feel like yours.</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex shrink-0 items-center gap-1.5">
|
<div class="flex shrink-0 items-center gap-1.5">
|
||||||
<Button
|
<Button
|
||||||
@@ -3682,40 +3708,48 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-3 flex items-center justify-between gap-3">
|
{#if activeThemesTab === 'library'}
|
||||||
<nav class="flex min-w-0 items-center gap-0.5" aria-label="Theme filters">
|
<div class="mt-3 flex items-center justify-between gap-3">
|
||||||
{#each themeFilterOptions as option (option.id)}
|
<nav class="flex min-w-0 items-center gap-0.5" aria-label="Theme filters">
|
||||||
<button
|
{#each themeFilterOptions as option (option.id)}
|
||||||
type="button"
|
<button
|
||||||
class={themeFilterClass(option.id)}
|
type="button"
|
||||||
aria-current={themeFilter === option.id ? 'page' : undefined}
|
class={themeFilterClass(option.id)}
|
||||||
onclick={() => (themeFilter = option.id)}
|
aria-current={themeFilter === option.id ? 'page' : undefined}
|
||||||
>
|
onclick={() => (themeFilter = option.id)}
|
||||||
<ThemeIcon name={option.icon} set={iconSet} class="size-3.5 shrink-0" />
|
>
|
||||||
<span>{option.label}</span>
|
<ThemeIcon name={option.icon} set={iconSet} class="size-3.5 shrink-0" />
|
||||||
<span class="text-[8px] tabular-nums text-muted-foreground/60">
|
<span>{option.label}</span>
|
||||||
{themeCounts[option.id]}
|
<span class="text-[8px] tabular-nums text-muted-foreground/60">
|
||||||
</span>
|
{themeCounts[option.id]}
|
||||||
</button>
|
</span>
|
||||||
{/each}
|
</button>
|
||||||
</nav>
|
{/each}
|
||||||
<div class="relative w-44 shrink-0">
|
</nav>
|
||||||
<ThemeIcon
|
<div class="relative w-44 shrink-0">
|
||||||
name="search"
|
<ThemeIcon
|
||||||
set={iconSet}
|
name="search"
|
||||||
class="pointer-events-none absolute top-1/2 left-2 size-3 -translate-y-1/2 text-muted-foreground"
|
set={iconSet}
|
||||||
/>
|
class="pointer-events-none absolute top-1/2 left-2 size-3 -translate-y-1/2 text-muted-foreground"
|
||||||
<Input
|
/>
|
||||||
class="h-8 border-theme-800 bg-theme-950/80 pl-7 text-[11px]"
|
<Input
|
||||||
placeholder="Search themes..."
|
class="h-8 border-theme-800 bg-theme-950/80 pl-7 text-[11px]"
|
||||||
bind:value={themeSearch}
|
placeholder="Search themes..."
|
||||||
/>
|
bind:value={themeSearch}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="min-h-0 flex-1 px-5 pt-4 pb-1">
|
<div class="min-h-0 flex-1 px-5 pt-4 pb-1">
|
||||||
<ScrollContainer class="pr-2">
|
{#if activeThemesTab === 'workshop'}
|
||||||
|
<CommunityWorkshopView
|
||||||
|
onApplyTheme={applyInstalledTheme}
|
||||||
|
onRequestTrust={(theme) => requestThemeTrust(theme, false)}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<ScrollContainer class="pr-2">
|
||||||
{#if filteredThemes.length > 0}
|
{#if filteredThemes.length > 0}
|
||||||
{#key themeFilter}
|
{#key themeFilter}
|
||||||
<div class="grid grid-cols-3 gap-3 pb-4 pr-1">
|
<div class="grid grid-cols-3 gap-3 pb-4 pr-1">
|
||||||
@@ -4028,7 +4062,8 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</ScrollContainer>
|
</ScrollContainer>
|
||||||
</div>
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in new issue
Block a user