feat(themes): flag unpublished edits and share parallax motion
Compare theme content hashes with a stored Workshop baseline so creators can see when their local copy is not synced. Extract pointer tracking into a shared action for the launcher and editor preview, and use the slider component for parallax depth.
This commit is contained in:
1 parent
d56ed9d786
commit
323a6f18af
12 files changed
+667
-134
No files matched your search
@@ -195,6 +195,26 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"identifier": "fs:allow-stat",
|
||||
"allow": [
|
||||
{
|
||||
"path": "$HOME/.ezpplauncher"
|
||||
},
|
||||
{
|
||||
"path": "$HOME/.ezpplauncher/*"
|
||||
},
|
||||
{
|
||||
"path": "$HOME/.ezpplauncher/**/*"
|
||||
},
|
||||
{
|
||||
"path": "$HOME/.ezpplauncher/**/.*"
|
||||
},
|
||||
{
|
||||
"path": "$HOME/.ezpplauncher/**/.*/**"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"identifier": "fs:allow-mkdir",
|
||||
"allow": [
|
||||
|
||||
@@ -112,6 +112,136 @@ pub async fn hash_file_sha256(path: String) -> Result<String, String> {
|
||||
.map_err(|e| format!("Thread error: {e}"))?
|
||||
}
|
||||
|
||||
/// Hash the files that make up a theme package. This ignores archive metadata
|
||||
/// and the local workshop-sync sidecar, so it can be compared before and after
|
||||
/// editing the theme folder.
|
||||
#[tauri::command]
|
||||
pub async fn hash_theme_content(theme_folder: String) -> Result<String, String> {
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
let folder = checked_theme_folder(&theme_folder)?;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let asset_filter = referenced_theme_assets(&folder.join("theme.json"));
|
||||
let mut files = Vec::new();
|
||||
collect_theme_content_files(&folder, &folder, asset_filter.as_ref(), &mut files)?;
|
||||
files.sort_by_key(|path| path.to_string_lossy().to_lowercase());
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(b"ezpplauncher-theme-content-v1\0");
|
||||
|
||||
for relative in files {
|
||||
let path = folder.join(&relative);
|
||||
let metadata = std::fs::metadata(&path)
|
||||
.map_err(|e| format!("Failed to inspect theme file {}: {e}", path.display()))?;
|
||||
if !metadata.is_file() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let name = relative.to_string_lossy().replace('\\', "/");
|
||||
hasher.update((name.len() as u64).to_le_bytes());
|
||||
hasher.update(name.as_bytes());
|
||||
|
||||
// Workshop metadata is not theme content. Ignore it so syncing
|
||||
// tags or the server-assigned ID does not look like a local edit.
|
||||
if name == "theme.json" {
|
||||
let text = std::fs::read_to_string(&path).map_err(|e| {
|
||||
format!("Failed to read theme manifest {}: {e}", path.display())
|
||||
})?;
|
||||
let mut config: Value = serde_json::from_str(&text)
|
||||
.map_err(|e| format!("Failed to parse theme manifest: {e}"))?;
|
||||
if let Some(object) = config.as_object_mut() {
|
||||
object.remove("workshopId");
|
||||
object.remove("tags");
|
||||
}
|
||||
let bytes = serde_json::to_vec(&config)
|
||||
.map_err(|e| format!("Failed to serialize theme manifest: {e}"))?;
|
||||
hasher.update((bytes.len() as u64).to_le_bytes());
|
||||
hasher.update(&bytes);
|
||||
continue;
|
||||
}
|
||||
|
||||
hasher.update(metadata.len().to_le_bytes());
|
||||
let mut file = std::fs::File::open(&path)
|
||||
.map_err(|e| format!("Failed to open theme file {}: {e}", path.display()))?;
|
||||
let mut buffer = [0u8; 65536];
|
||||
loop {
|
||||
let bytes_read = file
|
||||
.read(&mut buffer)
|
||||
.map_err(|e| format!("Failed to read theme file {}: {e}", path.display()))?;
|
||||
if bytes_read == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buffer[..bytes_read]);
|
||||
}
|
||||
}
|
||||
|
||||
Ok::<_, String>(hex::encode(hasher.finalize()))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Thread error: {e}"))?
|
||||
}
|
||||
|
||||
fn collect_theme_content_files(
|
||||
root: &Path,
|
||||
directory: &Path,
|
||||
asset_filter: Option<&HashSet<String>>,
|
||||
files: &mut Vec<PathBuf>,
|
||||
) -> Result<(), String> {
|
||||
let entries = std::fs::read_dir(directory)
|
||||
.map_err(|e| format!("Failed to read {}: {e}", directory.display()))?;
|
||||
let assets_root = root.join("assets");
|
||||
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|e| format!("Failed to read theme entry: {e}"))?;
|
||||
let file_name = entry.file_name();
|
||||
if file_name.to_string_lossy().starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let file_type = entry
|
||||
.file_type()
|
||||
.map_err(|e| format!("Failed to inspect theme entry: {e}"))?;
|
||||
if file_type.is_symlink() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let entry_path = entry.path();
|
||||
let relative = entry_path
|
||||
.strip_prefix(root)
|
||||
.map_err(|e| format!("Failed to build theme path: {e}"))?
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
|
||||
if file_type.is_dir() {
|
||||
if asset_filter.is_some() && directory == root && relative != "assets" {
|
||||
continue;
|
||||
}
|
||||
collect_theme_content_files(root, &entry_path, asset_filter, files)?;
|
||||
continue;
|
||||
}
|
||||
if !file_type.is_file() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if asset_filter.is_some() && directory == root && relative != "theme.json" {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(filter) = asset_filter
|
||||
&& let Ok(asset_path) = entry_path.strip_prefix(&assets_root)
|
||||
{
|
||||
let key = asset_path.to_string_lossy().replace('\\', "/");
|
||||
if !filter.contains(&key) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
files.push(PathBuf::from(relative));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
@@ -3080,6 +3210,9 @@ fn add_theme_folder_to_zip(
|
||||
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|e| format!("Failed to read theme entry: {e}"))?;
|
||||
if entry.file_name().to_string_lossy().starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
let file_type = entry
|
||||
.file_type()
|
||||
.map_err(|e| format!("Failed to inspect theme entry: {e}"))?;
|
||||
|
||||
@@ -23,8 +23,9 @@ use crate::{
|
||||
download_ezpp_launcher_update, download_ezpp_launcher_update_files, encrypt_string, exit,
|
||||
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_osu_skin, get_osu_version, get_platform, get_skins_count, has_net8, has_osuwinello,
|
||||
has_wmctrl, hash_file_sha256, install_ezpp_launcher_update, installation_diagnostics,
|
||||
get_osu_skin, get_osu_version, get_platform, get_skins_count, has_net8,
|
||||
has_osuwinello, has_wmctrl, hash_file_sha256, hash_theme_content,
|
||||
install_ezpp_launcher_update, installation_diagnostics,
|
||||
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,
|
||||
open_url_in_browser, opened_urls, optimize_theme_media, pick_osu_installation,
|
||||
@@ -196,6 +197,7 @@ pub fn run() {
|
||||
save_theme_project,
|
||||
export_theme,
|
||||
hash_file_sha256,
|
||||
hash_theme_content,
|
||||
read_file_chunk,
|
||||
open_theme_folder,
|
||||
open_theme_editor,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { config } from './config';
|
||||
import { normalizeThemeVersion } from './themeV2';
|
||||
import {
|
||||
markThemeSynced,
|
||||
reloadThemes,
|
||||
themesDirectory,
|
||||
updateInstalledThemeTags,
|
||||
@@ -1008,6 +1009,7 @@ export async function downloadAndInstallCommunityTheme(
|
||||
// Keep the installed manifest aligned with the workshop record.
|
||||
await updateInstalledThemeTags(folderName, theme.tags);
|
||||
await updateInstalledThemeWorkshopId(folderName, theme.id);
|
||||
await markThemeSynced(folderName, { workshopId: theme.id, version: theme.version });
|
||||
} catch (err) {
|
||||
console.warn('[Workshop] Could not write workshop metadata into the installed theme:', err);
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
themeGroupContainsElement,
|
||||
themeGroupInteractiveCount,
|
||||
} from '$lib/themeLogic';
|
||||
import { themeParallax } from '$lib/themeParallax';
|
||||
import { renderThemePreview } from '$lib/themePreview';
|
||||
import { themeScriptInitialVariables, themeScriptVariableNames } from '$lib/themeScript';
|
||||
import {
|
||||
@@ -98,6 +99,7 @@
|
||||
} from '$lib/themeV2';
|
||||
import {
|
||||
exportThemeFolder,
|
||||
markThemeSynced,
|
||||
saveThemeProject,
|
||||
themeAssetBase,
|
||||
themesDirectory,
|
||||
@@ -3545,9 +3547,13 @@
|
||||
publishThemeId = result.themeId;
|
||||
try {
|
||||
await updateInstalledThemeWorkshopId(savedFolder, result.themeId);
|
||||
await markThemeSynced(savedFolder, {
|
||||
workshopId: result.themeId,
|
||||
version: versionCheck.version,
|
||||
});
|
||||
lastSavedKey = snapshotKey(captureSnapshot());
|
||||
} catch (err) {
|
||||
console.warn('[Workshop] Could not store the theme ID in theme.json:', err);
|
||||
console.warn('[Workshop] Could not store the theme sync metadata:', err);
|
||||
}
|
||||
|
||||
// The launcher window owns the installed-theme list. Tell it about the
|
||||
@@ -6131,26 +6137,34 @@
|
||||
<div
|
||||
style="width:{CANVAS_WIDTH}px;height:{CANVAS_HEIGHT}px;transform:scale({canvasScale});transform-origin:top left;"
|
||||
>
|
||||
<ThemeStage
|
||||
{definition}
|
||||
assets={assetBase}
|
||||
assetOverrides={previewAssets}
|
||||
{selectedIds}
|
||||
{selectedGroupId}
|
||||
interactive
|
||||
scale={canvasScale}
|
||||
variables={previewScriptVariables}
|
||||
onselect={(id, additive) => selectElement(id, additive)}
|
||||
onselectmany={(ids, additive) => selectMany(ids, additive)}
|
||||
onselectgroup={(id) => selectGroup(id)}
|
||||
onmove={moveElements}
|
||||
onpatch={(id, patch) => patchElement(id, patch)}
|
||||
onpatchgroup={(id, patch) => patchGroup(id, patch)}
|
||||
oncontext={(id) => prepareContextMenu(id)}
|
||||
oncontextgroup={(id) => prepareGroupContextMenu(id)}
|
||||
ondetach={handleDetachWidget}
|
||||
onbuttonaction={handleTestButtonAction}
|
||||
/>
|
||||
<div
|
||||
class="contents"
|
||||
use:themeParallax={{
|
||||
enabled: definition.parallax?.enabled === true,
|
||||
intensity: definition.parallax?.intensity ?? 0.25,
|
||||
}}
|
||||
>
|
||||
<ThemeStage
|
||||
{definition}
|
||||
assets={assetBase}
|
||||
assetOverrides={previewAssets}
|
||||
{selectedIds}
|
||||
{selectedGroupId}
|
||||
interactive
|
||||
scale={canvasScale}
|
||||
variables={previewScriptVariables}
|
||||
onselect={(id, additive) => selectElement(id, additive)}
|
||||
onselectmany={(ids, additive) => selectMany(ids, additive)}
|
||||
onselectgroup={(id) => selectGroup(id)}
|
||||
onmove={moveElements}
|
||||
onpatch={(id, patch) => patchElement(id, patch)}
|
||||
onpatchgroup={(id, patch) => patchGroup(id, patch)}
|
||||
oncontext={(id) => prepareContextMenu(id)}
|
||||
oncontextgroup={(id) => prepareGroupContextMenu(id)}
|
||||
ondetach={handleDetachWidget}
|
||||
onbuttonaction={handleTestButtonAction}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenu.Trigger>
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import Button from '$lib/components/ui/button/button.svelte';
|
||||
import Input from '$lib/components/ui/input/input.svelte';
|
||||
import Label from '$lib/components/ui/label/label.svelte';
|
||||
import Slider from '$lib/components/ui/slider/slider.svelte';
|
||||
import type { ThemeV2Transform } from '$lib/themeV2';
|
||||
import { FlipHorizontal2, FlipVertical2, RotateCcw, RotateCw } from '@lucide/svelte';
|
||||
|
||||
@@ -267,14 +268,13 @@
|
||||
<span class="text-[9px] text-muted-foreground">3D cursor perspective</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min="-1"
|
||||
max="1"
|
||||
step="0.05"
|
||||
<Slider
|
||||
min={-1}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={transform.parallax ?? 0}
|
||||
oninput={(e) => onchange({ parallax: Number((e.target as HTMLInputElement).value) })}
|
||||
class="h-1.5 w-24 cursor-pointer appearance-none rounded-lg bg-theme-800 accent-primary"
|
||||
onValueChange={(value) => onchange({ parallax: value })}
|
||||
class="w-24"
|
||||
/>
|
||||
<span class="w-8 font-mono text-[10px] text-right text-muted-foreground">
|
||||
{Math.round((transform.parallax ?? 0) * 100)}%
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { custom_theme_volume } from '$lib/global';
|
||||
import { playThemeMedia, themeMediaSource, themeVideoLifecycle } from '$lib/themeMedia';
|
||||
import { themeParallax } from '$lib/themeParallax';
|
||||
import type { ThemeV2Definition } from '$lib/themeV2';
|
||||
import { themeAssetUrl } from '$lib/themeV2';
|
||||
import { reduceAnimations } from '$lib/userSettings';
|
||||
@@ -20,50 +21,8 @@
|
||||
} = $props();
|
||||
|
||||
let audioElement = $state<HTMLAudioElement | undefined>(undefined);
|
||||
let mousePx = $state(0);
|
||||
let mousePy = $state(0);
|
||||
let targetPx = 0;
|
||||
let targetPy = 0;
|
||||
let animFrame: number | undefined;
|
||||
|
||||
function onWindowMouseMove(e: MouseEvent) {
|
||||
if ($reduceAnimations || !definition.parallax?.enabled) return;
|
||||
const intensity = definition.parallax.intensity ?? 0.25;
|
||||
const w = window.innerWidth || 1000;
|
||||
const h = window.innerHeight || 650;
|
||||
targetPx = (e.clientX / w - 0.5) * 2 * intensity;
|
||||
targetPy = (e.clientY / h - 0.5) * 2 * intensity;
|
||||
if (animFrame === undefined) {
|
||||
animFrame = requestAnimationFrame(updateParallax);
|
||||
}
|
||||
}
|
||||
|
||||
function updateParallax() {
|
||||
animFrame = undefined;
|
||||
mousePx += (targetPx - mousePx) * 0.12;
|
||||
mousePy += (targetPy - mousePy) * 0.12;
|
||||
if (Math.abs(targetPx - mousePx) > 0.001 || Math.abs(targetPy - mousePy) > 0.001) {
|
||||
animFrame = requestAnimationFrame(updateParallax);
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!definition.parallax?.enabled) {
|
||||
mousePx = 0;
|
||||
mousePy = 0;
|
||||
targetPx = 0;
|
||||
targetPy = 0;
|
||||
if (animFrame !== undefined) {
|
||||
cancelAnimationFrame(animFrame);
|
||||
animFrame = undefined;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (animFrame !== undefined) {
|
||||
cancelAnimationFrame(animFrame);
|
||||
}
|
||||
if (audioElement) {
|
||||
try {
|
||||
audioElement.pause();
|
||||
@@ -140,64 +99,68 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onmousemove={onWindowMouseMove} />
|
||||
|
||||
<div
|
||||
class="theme-v2 absolute inset-0 overflow-hidden"
|
||||
use:trackMedia
|
||||
style="--mouse-px:{mousePx};--mouse-py:{mousePy};"
|
||||
>
|
||||
{#if background.type === 'color'}
|
||||
<div
|
||||
class="absolute inset-0"
|
||||
style="background-color:{background.color};opacity:{background.opacity ?? 1}"
|
||||
></div>
|
||||
{:else if background.type === 'gradient'}
|
||||
<div
|
||||
class="absolute inset-0"
|
||||
style="background-image:linear-gradient({background.angle ??
|
||||
135}deg, {background.from}, {background.to});opacity:{background.opacity ?? 1}"
|
||||
></div>
|
||||
{:else if background.type === 'image'}
|
||||
{#if background.src}
|
||||
<img
|
||||
class="absolute inset-0 h-full w-full"
|
||||
src={assetUrl(background.src)}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
style="object-fit:{background.fit ?? 'cover'};opacity:{background.opacity ??
|
||||
1};filter:{background.blur
|
||||
? `blur(${background.blur}px)`
|
||||
: 'none'};transform:{background.blur
|
||||
? 'scale(1.08)'
|
||||
: definition.parallax?.enabled
|
||||
? 'scale(1.06)'
|
||||
: 'none'} translate3d({mousePx * -18}px, {mousePy * -18}px, 0);"
|
||||
/>
|
||||
<div class="theme-v2 absolute inset-0 overflow-hidden" use:trackMedia>
|
||||
<div
|
||||
class="absolute inset-0"
|
||||
use:themeParallax={{
|
||||
enabled: definition.parallax?.enabled === true,
|
||||
intensity: definition.parallax?.intensity ?? 0.25,
|
||||
}}
|
||||
style="transform:translate3d(calc(var(--mouse-px, 0) * -18px), calc(var(--mouse-py, 0) * -18px), 0);"
|
||||
>
|
||||
{#if background.type === 'color'}
|
||||
<div
|
||||
class="absolute inset-0"
|
||||
style="background-color:{background.color};opacity:{background.opacity ?? 1}"
|
||||
></div>
|
||||
{:else if background.type === 'gradient'}
|
||||
<div
|
||||
class="absolute inset-0"
|
||||
style="background-image:linear-gradient({background.angle ??
|
||||
135}deg, {background.from}, {background.to});opacity:{background.opacity ??
|
||||
1};transform:{definition.parallax?.enabled ? 'scale(1.06)' : 'none'}"
|
||||
></div>
|
||||
{:else if background.type === 'image'}
|
||||
{#if background.src}
|
||||
<img
|
||||
class="absolute inset-0 h-full w-full"
|
||||
src={assetUrl(background.src)}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
style="object-fit:{background.fit ?? 'cover'};opacity:{background.opacity ??
|
||||
1};filter:{background.blur
|
||||
? `blur(${background.blur}px)`
|
||||
: 'none'};transform:{background.blur
|
||||
? 'scale(1.08)'
|
||||
: definition.parallax?.enabled
|
||||
? 'scale(1.06)'
|
||||
: 'none'};"
|
||||
/>
|
||||
{/if}
|
||||
{:else if background.type === 'video'}
|
||||
{#if background.src}
|
||||
<video
|
||||
class="absolute inset-0 h-full w-full object-cover"
|
||||
use:themeMediaSource={assetUrl(background.src)}
|
||||
use:themeVideoLifecycle
|
||||
autoplay
|
||||
loop
|
||||
muted={background.muted !== false}
|
||||
use:applyMuted={background.muted !== false}
|
||||
playsinline
|
||||
disablepictureinpicture
|
||||
style="object-fit:{background.fit ?? 'cover'};opacity:{background.opacity ??
|
||||
1};filter:{background.blur
|
||||
? `blur(${background.blur}px)`
|
||||
: 'none'};transform:{background.blur
|
||||
? 'scale(1.08)'
|
||||
: definition.parallax?.enabled
|
||||
? 'scale(1.06)'
|
||||
: 'none'};"
|
||||
></video>
|
||||
{/if}
|
||||
{/if}
|
||||
{:else if background.type === 'video'}
|
||||
{#if background.src}
|
||||
<video
|
||||
class="absolute inset-0 h-full w-full object-cover"
|
||||
use:themeMediaSource={assetUrl(background.src)}
|
||||
use:themeVideoLifecycle
|
||||
autoplay
|
||||
loop
|
||||
muted={background.muted !== false}
|
||||
use:applyMuted={background.muted !== false}
|
||||
playsinline
|
||||
disablepictureinpicture
|
||||
style="object-fit:{background.fit ?? 'cover'};opacity:{background.opacity ??
|
||||
1};filter:{background.blur
|
||||
? `blur(${background.blur}px)`
|
||||
: 'none'};transform:{background.blur
|
||||
? 'scale(1.08)'
|
||||
: definition.parallax?.enabled
|
||||
? 'scale(1.06)'
|
||||
: 'none'} translate3d({mousePx * -18}px, {mousePy * -18}px, 0);"
|
||||
></video>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if overlay && (overlay.opacity ?? 0) > 0}
|
||||
<div
|
||||
|
||||
@@ -141,6 +141,15 @@
|
||||
);
|
||||
const elementFilter = (element: ThemeV2Element) =>
|
||||
themeFilterCss(element.filters, ...ancestorsOf(element).map((group) => group.filters));
|
||||
/** Layer depth plus every ancestor group's depth; negative follows the background. */
|
||||
const elementParallaxCss = (element: ThemeV2Element) => {
|
||||
const depth =
|
||||
(element.parallax ?? 0) +
|
||||
ancestorsOf(element).reduce((total, group) => total + (group.parallax ?? 0), 0);
|
||||
if (depth === 0) return '';
|
||||
const offset = depth * 30;
|
||||
return ` translate3d(calc(var(--mouse-px, 0) * ${offset}px), calc(var(--mouse-py, 0) * ${offset}px), 0)`;
|
||||
};
|
||||
const elementClassNames = (element: ThemeV2Element) =>
|
||||
themeClassNames(
|
||||
[...ancestorsOf(element).map((group) => group.cssClasses), element.cssClasses]
|
||||
@@ -1227,9 +1236,7 @@
|
||||
element,
|
||||
elements,
|
||||
groups
|
||||
)}{element.parallax
|
||||
? ` translate3d(calc(var(--mouse-px, 0) * ${element.parallax * 30}px), calc(var(--mouse-py, 0) * ${element.parallax * 30}px), 0)`
|
||||
: ''};transform-origin:0 0;touch-action:none;outline:{selected
|
||||
)}{elementParallaxCss(element)};transform-origin:0 0;touch-action:none;outline:{selected
|
||||
? `${selectedIds.length > 1 ? 1 : 2}px solid #ff006e`
|
||||
: 'none'};outline-offset:2px;"
|
||||
role={interactive || element.interactive ? 'button' : undefined}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { reduceAnimations } from '$lib/userSettings';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
export type ThemeParallaxOptions = {
|
||||
enabled?: boolean;
|
||||
intensity?: number;
|
||||
};
|
||||
|
||||
const clampIntensity = (intensity: number | undefined) =>
|
||||
Math.min(1, Math.max(0.05, typeof intensity === 'number' ? intensity : 0.25));
|
||||
|
||||
/**
|
||||
* Tracks the pointer for a theme host and exposes the smoothed offset as
|
||||
* `--mouse-px` / `--mouse-py`. The host must contain both the background and
|
||||
* the freeform layers so those variables are inherited by every layer.
|
||||
*/
|
||||
export function themeParallax(node: HTMLElement, options: ThemeParallaxOptions = {}) {
|
||||
let enabled = options.enabled === true;
|
||||
let intensity = clampIntensity(options.intensity);
|
||||
let mousePx = 0;
|
||||
let mousePy = 0;
|
||||
let targetPx = 0;
|
||||
let targetPy = 0;
|
||||
let animFrame: number | undefined;
|
||||
let reduced = get(reduceAnimations);
|
||||
|
||||
function apply() {
|
||||
node.style.setProperty('--mouse-px', mousePx.toFixed(4));
|
||||
node.style.setProperty('--mouse-py', mousePy.toFixed(4));
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (animFrame !== undefined) {
|
||||
cancelAnimationFrame(animFrame);
|
||||
animFrame = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
stop();
|
||||
mousePx = 0;
|
||||
mousePy = 0;
|
||||
targetPx = 0;
|
||||
targetPy = 0;
|
||||
apply();
|
||||
}
|
||||
|
||||
function updateParallax() {
|
||||
animFrame = undefined;
|
||||
const dx = targetPx - mousePx;
|
||||
const dy = targetPy - mousePy;
|
||||
|
||||
if (Math.abs(dx) < 0.0005 && Math.abs(dy) < 0.0005) {
|
||||
mousePx = targetPx;
|
||||
mousePy = targetPy;
|
||||
apply();
|
||||
return;
|
||||
}
|
||||
|
||||
mousePx += dx * 0.12;
|
||||
mousePy += dy * 0.12;
|
||||
apply();
|
||||
animFrame = requestAnimationFrame(updateParallax);
|
||||
}
|
||||
|
||||
function onWindowMouseMove(event: MouseEvent) {
|
||||
if (!enabled || reduced) return;
|
||||
|
||||
const width = window.innerWidth || 1000;
|
||||
const height = window.innerHeight || 650;
|
||||
targetPx = (event.clientX / width - 0.5) * 2 * intensity;
|
||||
targetPy = (event.clientY / height - 0.5) * 2 * intensity;
|
||||
|
||||
if (animFrame === undefined) {
|
||||
animFrame = requestAnimationFrame(updateParallax);
|
||||
}
|
||||
}
|
||||
|
||||
const unsubscribe = reduceAnimations.subscribe((value) => {
|
||||
reduced = value;
|
||||
if (reduced) reset();
|
||||
});
|
||||
|
||||
window.addEventListener('mousemove', onWindowMouseMove);
|
||||
apply();
|
||||
|
||||
return {
|
||||
update(next: ThemeParallaxOptions = {}) {
|
||||
enabled = next.enabled === true;
|
||||
intensity = clampIntensity(next.intensity);
|
||||
if (!enabled || reduced) reset();
|
||||
},
|
||||
destroy() {
|
||||
unsubscribe();
|
||||
window.removeEventListener('mousemove', onWindowMouseMove);
|
||||
stop();
|
||||
node.style.removeProperty('--mouse-px');
|
||||
node.style.removeProperty('--mouse-py');
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -27,6 +27,18 @@ export type Theme = {
|
||||
status: 'installed' | 'downloading' | 'extracting' | 'deleting' | 'not-installed';
|
||||
progress: number;
|
||||
updateAvailable: boolean;
|
||||
/** The latest Workshop version, when the theme has been published. */
|
||||
workshopVersion?: string;
|
||||
/** Hash of the theme files when this copy was last known to match the Workshop. */
|
||||
syncedContentHash?: string;
|
||||
/** Local version current when the sync baseline was written. */
|
||||
syncedVersion?: string;
|
||||
/** True when the local theme differs from its published Workshop package. */
|
||||
isNotSynced?: boolean;
|
||||
/** Modification time of the generated preview, used to migrate existing installs. */
|
||||
previewModifiedAt?: number;
|
||||
/** Creation time of the theme folder, used to migrate existing installs. */
|
||||
folderCreatedAt?: number;
|
||||
/** The version available in the repository when an update is pending. */
|
||||
availableVersion?: string;
|
||||
definition?: ThemeV2Definition;
|
||||
@@ -95,6 +107,75 @@ const encodeThemePath = (value: string) =>
|
||||
export const themesDirectory = async () =>
|
||||
await path.join(await path.homeDir(), '.ezpplauncher', 'themes');
|
||||
|
||||
const THEME_SYNC_FILENAME = '.workshop-sync.json';
|
||||
const CONTENT_HASH_PATTERN = /^[a-f0-9]{64}$/i;
|
||||
|
||||
export type ThemeWorkshopSync = {
|
||||
contentHash: string;
|
||||
version?: string;
|
||||
workshopId?: string;
|
||||
syncedAt?: number;
|
||||
};
|
||||
|
||||
const readThemeWorkshopSyncFile = async (
|
||||
folderPath: string
|
||||
): Promise<ThemeWorkshopSync | undefined> => {
|
||||
const syncPath = await path.join(folderPath, THEME_SYNC_FILENAME);
|
||||
if (!(await fs.exists(syncPath))) return undefined;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(await fs.readTextFile(syncPath)) as Partial<ThemeWorkshopSync>;
|
||||
if (!parsed || typeof parsed !== 'object') return undefined;
|
||||
const contentHash =
|
||||
typeof parsed.contentHash === 'string' && CONTENT_HASH_PATTERN.test(parsed.contentHash)
|
||||
? parsed.contentHash.toLowerCase()
|
||||
: undefined;
|
||||
if (!contentHash) return undefined;
|
||||
|
||||
return {
|
||||
contentHash,
|
||||
version: typeof parsed.version === 'string' ? parsed.version : undefined,
|
||||
workshopId: typeof parsed.workshopId === 'string' ? parsed.workshopId : undefined,
|
||||
syncedAt: typeof parsed.syncedAt === 'number' ? parsed.syncedAt : undefined,
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export const readThemeWorkshopSync = async (folderName: string) =>
|
||||
readThemeWorkshopSyncFile(await path.join(await themesDirectory(), folderName));
|
||||
|
||||
export const writeThemeWorkshopSync = async (
|
||||
folderName: string,
|
||||
sync: ThemeWorkshopSync
|
||||
): Promise<void> => {
|
||||
const folderPath = await path.join(await themesDirectory(), folderName);
|
||||
const syncPath = await path.join(folderPath, THEME_SYNC_FILENAME);
|
||||
await fs.writeFile(syncPath, new TextEncoder().encode(`${JSON.stringify(sync, null, 2)}\n`));
|
||||
};
|
||||
|
||||
export const hashThemeContent = async (folderName: string) => {
|
||||
const themeFolder = await path.join(await themesDirectory(), folderName);
|
||||
return await invoke<string>('hash_theme_content', { themeFolder });
|
||||
};
|
||||
|
||||
/** Record the current local files as the published Workshop baseline. */
|
||||
export const markThemeSynced = async (
|
||||
folderName: string,
|
||||
options: { version?: string; workshopId?: string } = {}
|
||||
): Promise<ThemeWorkshopSync> => {
|
||||
const contentHash = await hashThemeContent(folderName);
|
||||
const sync: ThemeWorkshopSync = {
|
||||
contentHash,
|
||||
version: options.version,
|
||||
workshopId: options.workshopId,
|
||||
syncedAt: Date.now(),
|
||||
};
|
||||
await writeThemeWorkshopSync(folderName, sync);
|
||||
return sync;
|
||||
};
|
||||
|
||||
/**
|
||||
* Replace a theme in the store instead of mutating it. The theme list is
|
||||
* rendered from a filtered derived list, and Svelte treats each item as
|
||||
@@ -132,6 +213,8 @@ const themeFromFolder = async (
|
||||
const definition = isV2
|
||||
? normalizeThemeV2(parsed as unknown as Partial<ThemeV2Definition>)
|
||||
: undefined;
|
||||
const sync = await readThemeWorkshopSyncFile(folderPath);
|
||||
const folderStat = await fs.stat(folderPath).catch(() => undefined);
|
||||
|
||||
const httpBase = await themeAssetBase();
|
||||
const httpFolder = httpBase ? `${httpBase}/${encodeURIComponent(folderName)}` : '';
|
||||
@@ -158,9 +241,11 @@ const themeFromFolder = async (
|
||||
: convertFileSrc(await path.normalize(await path.join(folderPath, 'assets')));
|
||||
const previewName = parsed.preview || (isV2 ? 'preview.png' : '');
|
||||
let preview = '';
|
||||
let previewModifiedAt: number | undefined;
|
||||
if (previewName) {
|
||||
const previewPath = await path.join(folderPath, 'assets', previewName);
|
||||
if (await fs.exists(previewPath)) {
|
||||
previewModifiedAt = (await fs.stat(previewPath).catch(() => undefined))?.mtime?.getTime();
|
||||
// The editor rewrites preview.png on every save. A cache-busting query
|
||||
// makes the WebView pick up the new file instead of its cached asset.
|
||||
preview = httpFolder
|
||||
@@ -190,6 +275,10 @@ const themeFromFolder = async (
|
||||
status: 'installed',
|
||||
progress: 0,
|
||||
updateAvailable: false,
|
||||
syncedContentHash: sync?.contentHash,
|
||||
syncedVersion: sync?.version,
|
||||
previewModifiedAt,
|
||||
folderCreatedAt: folderStat?.birthtime?.getTime(),
|
||||
cssStyle,
|
||||
definition,
|
||||
};
|
||||
@@ -539,6 +628,11 @@ export const reloadThemes = async () => {
|
||||
nsfw: theme.nsfw,
|
||||
workshopId: theme.workshopId,
|
||||
definition: theme.definition,
|
||||
syncedContentHash: theme.syncedContentHash,
|
||||
syncedVersion: theme.syncedVersion,
|
||||
previewModifiedAt: theme.previewModifiedAt,
|
||||
folderCreatedAt: theme.folderCreatedAt,
|
||||
isNotSynced: versionChanged ? false : storeTheme.isNotSynced,
|
||||
updateAvailable: versionChanged ? false : storeTheme.updateAvailable,
|
||||
availableVersion: versionChanged ? undefined : storeTheme.availableVersion,
|
||||
cssStyle: theme.cssStyle,
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
themeMediaSuspended,
|
||||
themeMediaSuspension,
|
||||
} from '$lib/themeMedia';
|
||||
import { themeParallax } from '$lib/themeParallax';
|
||||
import { isThemeTrusted } from '$lib/themeTrust';
|
||||
import { themeV2Css } from '$lib/themeV2';
|
||||
import { getThemes, loadTheme } from '$lib/themes';
|
||||
@@ -550,7 +551,13 @@
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
|
||||
<main use:themeMediaSuspension>
|
||||
<main
|
||||
use:themeMediaSuspension
|
||||
use:themeParallax={{
|
||||
enabled: $active_custom_theme?.definition?.parallax?.enabled === true,
|
||||
intensity: $active_custom_theme?.definition?.parallax?.intensity ?? 0.25,
|
||||
}}
|
||||
>
|
||||
<div class:hidden={!!$active_custom_theme?.definition}>
|
||||
<div class="opacity-30">
|
||||
<div transition:fade={{ duration: $reduceAnimations ? 0 : 500 }}>
|
||||
|
||||
+191
-1
@@ -122,8 +122,10 @@
|
||||
checkThemeFromFile,
|
||||
deleteTheme,
|
||||
downloadTheme,
|
||||
hashThemeContent,
|
||||
importThemeFromFile,
|
||||
loadTheme,
|
||||
markThemeSynced,
|
||||
openThemesFolder,
|
||||
reloadThemes,
|
||||
restoreThemeBackup,
|
||||
@@ -317,6 +319,7 @@
|
||||
let ownedWorkshopThemeIds = $state<string[]>([]);
|
||||
let ownedWorkshopThemeNames = $state<string[]>([]);
|
||||
let ownedWorkshopLoaded = $state(false);
|
||||
const themeContentHashCache = new SvelteMap<string, string>();
|
||||
let ownedThemesRequestId = 0;
|
||||
let logViewerOpen = $state(false);
|
||||
let presenceEditorOpen = $state(false);
|
||||
@@ -326,6 +329,9 @@
|
||||
|
||||
const communityThemeCache = new SvelteMap<string, CommunityTheme>();
|
||||
let lastThemeUpdateCheck = 0;
|
||||
let lastSyncUserId: number | undefined;
|
||||
let lastSyncThemeCount = -1;
|
||||
let lastSyncOwnedCount = -1;
|
||||
|
||||
const themeFilterOptions = [
|
||||
{ id: 'all', label: 'All themes', icon: 'all-themes' },
|
||||
@@ -410,6 +416,7 @@
|
||||
|
||||
const isOwnInstalledTheme = (theme: Theme) => {
|
||||
if (!$currentUser) return false;
|
||||
if (theme.authorId === $currentUser.id) return true;
|
||||
|
||||
const folderName = theme.folder_name.trim().toLowerCase();
|
||||
const workshopId = theme.workshopId?.trim().toLowerCase();
|
||||
@@ -543,6 +550,117 @@
|
||||
}
|
||||
};
|
||||
|
||||
const getThemeContentHash = async (theme: Theme) => {
|
||||
const cached = themeContentHashCache.get(theme.folder_name);
|
||||
if (cached) return cached;
|
||||
const hash = await hashThemeContent(theme.folder_name);
|
||||
themeContentHashCache.set(theme.folder_name, hash);
|
||||
return hash;
|
||||
};
|
||||
|
||||
/**
|
||||
* Themes installed before content hashes were introduced have no baseline.
|
||||
* Their preview is regenerated on every editor save, so a preview newer than
|
||||
* the Workshop record means the local copy was edited after publication.
|
||||
* The folder creation check keeps fresh downloads from being flagged.
|
||||
*/
|
||||
const legacyThemeChangedAfterPublish = (theme: Theme, remote: CommunityTheme) => {
|
||||
const publishedAt = Date.parse(remote.updatedAt);
|
||||
if (!Number.isFinite(publishedAt)) return false;
|
||||
|
||||
const createdAt = theme.folderCreatedAt;
|
||||
const previewModifiedAt = theme.previewModifiedAt;
|
||||
return (
|
||||
createdAt !== undefined &&
|
||||
previewModifiedAt !== undefined &&
|
||||
createdAt < publishedAt &&
|
||||
previewModifiedAt > publishedAt + 1_000
|
||||
);
|
||||
};
|
||||
|
||||
type ThemeSyncState = {
|
||||
syncedContentHash?: string;
|
||||
syncedVersion?: string;
|
||||
isNotSynced: boolean;
|
||||
};
|
||||
|
||||
const resolveThemeSyncState = async (
|
||||
theme: Theme,
|
||||
remote: CommunityTheme,
|
||||
localVersion: string
|
||||
): Promise<ThemeSyncState> => {
|
||||
const versionMismatch = remote.version !== localVersion;
|
||||
let contentHash: string | undefined;
|
||||
try {
|
||||
contentHash = await getThemeContentHash(theme);
|
||||
} catch (error) {
|
||||
console.warn(`[Themes] Could not hash "${theme.name}":`, error);
|
||||
}
|
||||
|
||||
if (theme.syncedContentHash) {
|
||||
const contentMismatch = contentHash !== undefined && contentHash !== theme.syncedContentHash;
|
||||
return {
|
||||
syncedContentHash: theme.syncedContentHash,
|
||||
syncedVersion: theme.syncedVersion,
|
||||
isNotSynced:
|
||||
versionMismatch ||
|
||||
contentMismatch ||
|
||||
(theme.syncedVersion !== undefined && theme.syncedVersion !== localVersion),
|
||||
};
|
||||
}
|
||||
|
||||
if (versionMismatch || legacyThemeChangedAfterPublish(theme, remote)) {
|
||||
return { isNotSynced: true };
|
||||
}
|
||||
|
||||
try {
|
||||
const sync = await markThemeSynced(theme.folder_name, {
|
||||
workshopId: remote.id,
|
||||
version: localVersion,
|
||||
});
|
||||
themeContentHashCache.set(theme.folder_name, sync.contentHash);
|
||||
return {
|
||||
syncedContentHash: sync.contentHash,
|
||||
syncedVersion: sync.version,
|
||||
isNotSynced: false,
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn(`[Themes] Could not record sync state for "${theme.name}":`, error);
|
||||
return { isNotSynced: false };
|
||||
}
|
||||
};
|
||||
|
||||
const refreshThemeSyncState = async (folderName: string) => {
|
||||
themeContentHashCache.delete(folderName);
|
||||
const theme = get(custom_themes).find((candidate) => candidate.folder_name === folderName);
|
||||
if (!theme || !theme.workshopId) return;
|
||||
|
||||
let contentHash: string;
|
||||
try {
|
||||
contentHash = await getThemeContentHash(theme);
|
||||
} catch (error) {
|
||||
console.warn(`[Themes] Could not hash "${theme.name}" after saving:`, error);
|
||||
return;
|
||||
}
|
||||
|
||||
const localVersion = normalizeThemeVersion(theme.version);
|
||||
const workshopVersion = theme.workshopVersion
|
||||
? normalizeThemeVersion(theme.workshopVersion)
|
||||
: undefined;
|
||||
const baseline = theme.syncedContentHash;
|
||||
const isNotSynced = baseline
|
||||
? baseline !== contentHash ||
|
||||
(theme.syncedVersion !== undefined && theme.syncedVersion !== localVersion) ||
|
||||
(workshopVersion !== undefined && localVersion !== workshopVersion)
|
||||
: true;
|
||||
|
||||
custom_themes.update((themes) =>
|
||||
themes.map((candidate) =>
|
||||
candidate.folder_name === folderName ? { ...candidate, isNotSynced } : candidate
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const checkInstalledThemeUpdates = async (force = false) => {
|
||||
if (checkingThemeUpdates) return;
|
||||
const now = Date.now();
|
||||
@@ -590,6 +708,20 @@
|
||||
const workers = Math.min(4, installed.length);
|
||||
await Promise.all(Array.from({ length: workers }, () => checkNext()));
|
||||
|
||||
const syncStates = new SvelteMap<string, ThemeSyncState>();
|
||||
for (const theme of installed) {
|
||||
const remote = remoteThemes.get(theme.folder_name);
|
||||
if (!remote) continue;
|
||||
const localVersion = normalizeThemeVersion(theme.version);
|
||||
if (!localVersion) continue;
|
||||
if (isOwnInstalledTheme(theme)) {
|
||||
syncStates.set(
|
||||
theme.folder_name,
|
||||
await resolveThemeSyncState(theme, remote.theme, remote.version)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
custom_themes.update((themes) =>
|
||||
themes.map((theme) => {
|
||||
const remote = remoteThemes.get(theme.folder_name);
|
||||
@@ -598,10 +730,13 @@
|
||||
if (!localVersion) return theme;
|
||||
|
||||
const updateAvailable = semver.gt(remote.version, localVersion);
|
||||
const sync = syncStates.get(theme.folder_name);
|
||||
return {
|
||||
...theme,
|
||||
workshopVersion: remote.version,
|
||||
updateAvailable,
|
||||
availableVersion: updateAvailable ? remote.version : undefined,
|
||||
...(sync ?? {}),
|
||||
};
|
||||
})
|
||||
);
|
||||
@@ -660,6 +795,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
themeContentHashCache.delete(theme.folder_name);
|
||||
communityThemeCache.delete(theme.folder_name);
|
||||
sileo.success({
|
||||
title: 'Yaay!',
|
||||
@@ -677,6 +813,24 @@
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
const userId = $currentUser?.id;
|
||||
const themeCount = $custom_themes.length;
|
||||
const ownedCount = ownedWorkshopThemeIds.length + ownedWorkshopThemeNames.length;
|
||||
|
||||
// Themes and the signed-in creator load asynchronously. Reset the check
|
||||
// throttle when they change so the first badge is not missed by an early
|
||||
// check that ran against an empty library or an unknown user.
|
||||
if (
|
||||
userId !== lastSyncUserId ||
|
||||
themeCount !== lastSyncThemeCount ||
|
||||
ownedCount !== lastSyncOwnedCount
|
||||
) {
|
||||
lastSyncUserId = userId;
|
||||
lastSyncThemeCount = themeCount;
|
||||
lastSyncOwnedCount = ownedCount;
|
||||
lastThemeUpdateCheck = 0;
|
||||
}
|
||||
|
||||
if (selectedView === 'THEMES') {
|
||||
untrack(() => void checkInstalledThemeUpdates());
|
||||
}
|
||||
@@ -760,6 +914,7 @@
|
||||
// copy when the saved theme was already the active one.
|
||||
const wasActive = $active_custom_theme?.folder_name === folderName;
|
||||
await reloadThemes();
|
||||
await refreshThemeSyncState(folderName);
|
||||
if (!wasActive) return;
|
||||
|
||||
const savedTheme = get(custom_themes).find((theme) => theme.folder_name === folderName);
|
||||
@@ -848,8 +1003,10 @@
|
||||
|
||||
try {
|
||||
const themeFolder = await path.join(await themesDirectory(), theme.folder_name);
|
||||
themeContentHashCache.delete(theme.folder_name);
|
||||
await restoreThemeBackup(themeFolder, backup.path);
|
||||
await reloadThemes();
|
||||
await refreshThemeSyncState(theme.folder_name);
|
||||
|
||||
const restoredTheme = get(custom_themes).find((candidate) => candidate.name === theme.name);
|
||||
if (wasActive && restoredTheme) {
|
||||
@@ -885,6 +1042,7 @@
|
||||
const refreshThemes = async () => {
|
||||
refreshingThemes = true;
|
||||
try {
|
||||
themeContentHashCache.clear();
|
||||
await reloadThemes();
|
||||
void checkInstalledThemeUpdates(true);
|
||||
} catch (error) {
|
||||
@@ -1836,6 +1994,18 @@
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
// The publish succeeded, so the local version is now the workshop
|
||||
// version. Clear the stale "Not synced" badge before the next check.
|
||||
custom_themes.update((themes) =>
|
||||
themes.map((candidate) => {
|
||||
const matchesId = themeId && candidate.workshopId?.trim().toLowerCase() === themeId;
|
||||
const matchesName = themeName && candidate.name.trim().toLowerCase() === themeName;
|
||||
return matchesId || matchesName
|
||||
? { ...candidate, workshopVersion: candidate.version, isNotSynced: false }
|
||||
: candidate;
|
||||
})
|
||||
);
|
||||
}).then((unlisten) => (themePublishedUnlisten = unlisten));
|
||||
|
||||
return () => {
|
||||
@@ -4096,9 +4266,18 @@
|
||||
theme.status === 'installed' && (theme.backups?.length ?? 0) > 0}
|
||||
{@const canUpdateTheme =
|
||||
theme.status === 'installed' && theme.updateAvailable}
|
||||
{@const isOwnTheme = isOwnInstalledTheme(theme)}
|
||||
{@const localVersion = normalizeThemeVersion(theme.version)}
|
||||
{@const workshopVersion = theme.workshopVersion}
|
||||
{@const versionMismatch =
|
||||
localVersion !== undefined &&
|
||||
workshopVersion !== undefined &&
|
||||
workshopVersion !== localVersion}
|
||||
{@const isNotSynced =
|
||||
isOwnTheme && (theme.isNotSynced === true || versionMismatch)}
|
||||
{@const canReportTheme =
|
||||
Boolean($currentUser && ownedWorkshopLoaded) &&
|
||||
!isOwnInstalledTheme(theme) &&
|
||||
!isOwnTheme &&
|
||||
theme.status === 'installed' &&
|
||||
theme.name !== 'Default' &&
|
||||
theme.folder_name !== 'default'}
|
||||
@@ -4178,6 +4357,17 @@
|
||||
API {theme.apiVersion.startsWith('2') ? '2.0' : '1.0'}
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if isNotSynced}
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="border-amber-500/40 bg-amber-950/70 px-1.5 py-0 text-[9px] text-amber-200 backdrop-blur-sm"
|
||||
title={versionMismatch
|
||||
? `Local v${theme.version} · Workshop v${workshopVersion}`
|
||||
: 'Local content differs from the published Workshop version'}
|
||||
>
|
||||
Not synced
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="absolute top-1.5 right-1.5 flex items-center gap-1">
|
||||
{#if theme.nsfw}
|
||||
|
||||
Reference in new issue
Block a user