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
+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