first commit

This commit is contained in:
valerio
2026-03-09 20:15:49 +01:00
commit 92732c20c9
58 changed files with 10602 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
@font-face {
font-family: "Europa Mono";
src: url("/fonts/Europa-Mono-Medium.otf") format("opentype");
font-weight: 500; /* Medium weight */
font-style: normal;
font-display: swap;
}
* {
font-family: "Europa Mono", monospace;
}
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Smoothie Blender Manager</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.
+202
View File
@@ -0,0 +1,202 @@
import { fetch } from '@tauri-apps/plugin-http';
import { platform } from '@tauri-apps/plugin-os';
import { currentSettings } from './settings';
import { writable } from 'svelte/store';
import { get } from 'svelte/store';
/**
* @typedef {Object} FileLink
* @property {string} version - The version string (e.g., "5.0.0")
* @property {string} downloadUrl - The full download URL
* @property {string} platform - The platform identifier (e.g., 'linux', 'windows', 'macos')
* @property {string} architecture - The architecture identifier (e.g., 'x64', 'arm64')
* @property {string} fileExtension - The file extension (e.g., 'tar.xz', 'zip')
* @property {string} status - The download status ('pending', 'downloading', 'completed', 'error')
* @property {number} percent - The download progress percentage (0-100)
*/
/**
* @typedef {Object} BlenderRelease
* @property {string} original - The original href from the release page
* @property {string} version - The version string (e.g., "2.80", "3.6", "4.2alpha")
* @property {string} fullPath - The full URL to the release folder
* @property {Date|null} lastModified - The last modified date (currently null)
* @property {Array<FileLink>} links - Array of download links for this release
*/
export const blenderReleases = writable([]);
export async function getBlenderReleases() {
const settings = await get(currentSettings);
const currentPlatform = settings.defaultPlatform || platform();
const currentArch = settings.defaultArch;
try {
// Fetch the HTML page
const response = await fetch('https://download.blender.org/release/');
const html = await response.text();
// Parse the HTML
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
// Extract all links that look like version folders
const links = doc.querySelectorAll('a');
/** @type {Promise<any>[]} */
const folderPromises = [];
links.forEach((link) => {
const href = link.getAttribute('href');
// Filter for directory links (ending with '/') that are version folders
// This regex matches patterns like Blender2.80/, Blender3.6/, Blender4.2alpha/, etc.
if (href && href.endsWith('/') && href.match(/^Blender[\d.]+[a-z]*\//)) {
// Remove the trailing slash and 'Blender' prefix for cleaner version names
const versionName = href.slice(0, -1).replace('Blender', ''); // Remove trailing slash
if (parseFloat(versionName) > 2.78) {
const promise = getBlenderMinorVersionsWithDownload(
`https://download.blender.org/release/${href}`,
currentPlatform,
currentArch
).then((links) => {
// Only add to array if there are links
if (links && links.length > 0) {
return {
original: href,
version: versionName, // e.g., "2.80", "3.6", "4.2alpha"
fullPath: `https://download.blender.org/release/${href}`,
lastModified: null, // Date info is in a separate column, harder to parse
links: links
};
}
return null;
});
folderPromises.push(promise);
}
}
});
const folders = (await Promise.all(folderPromises)).filter((folder) => folder !== null);
folders.sort((a, b) => compareVersions(b.version, a.version));
blenderReleases.set(folders);
return folders;
} catch (error) {
console.error('Error fetching Blender release list:', error);
return [];
}
}
/**
* Fetches the minor versions (e.g., 5.0.0, 5.1.0) for a given major version folder URL,
* along with their download URLs for the current platform and architecture.
* @returns {Promise<FileLink[]>} List of minor versions with download URLs
* @param {string} majorVersionFolderUrl
* @param {string} currentPlatform
* @param {string} currentArch
*/
async function getBlenderMinorVersionsWithDownload(
majorVersionFolderUrl,
currentPlatform,
currentArch
) {
try {
// 1. Fetch the HTML of the version folder (e.g., Blender5.0/)
const response = await fetch(majorVersionFolderUrl);
const html = await response.text();
// 2. Parse the HTML
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
// 3. Map your platform/arch to the naming convention used in filenames
/** @type {{ linux: string; windows: string; darwin: string; }} */
const platformMap = {
linux: 'linux',
windows: 'windows',
darwin: 'macos' // macOS is called 'darwin' in some contexts, but files use 'macos'
};
/** @type {{ x86_64: string; arm64: string; }} */
const archMap = {
x86_64: 'x64',
arm64: 'arm64'
};
const targetPlatform =
platformMap[/** @type {keyof platformMap} */ (currentPlatform)] || currentPlatform;
const targetArch = archMap[/** @type {keyof archMap} */ (currentArch)] || currentArch;
// 4. Get all file links and process them
const links = doc.querySelectorAll('a');
/** @type {Map<string, { [key: string]: { url: string; extension: string; platform: string; arch: string } }>} */
const versionsMap = new Map(); // Key: version string, Value: object with files
links.forEach((link) => {
const href = link.getAttribute('href');
if (!href) return;
// Match pattern: blender-5.0.0-linux-x64.tar.xz
// Regex captures: version, platform, arch, extension
const match = href.match(/^blender-([\d.]+)-([a-z]+)-([a-z0-9]+)\.([a-z.]+)$/);
if (match) {
const [_, version, platform, arch, ext] =
/** @type {[string, string, string, string, string]} */ (match);
// Ignore checksum files (.md5, .sha256) if you don't need them
if (ext === 'md5' || ext === 'sha256') return;
// Store file info grouped by version
if (!versionsMap.has(version)) {
versionsMap.set(version, {});
}
const versionFiles = versionsMap.get(version);
if (!versionFiles) return;
const key = `${platform}-${arch}`;
versionFiles[key] = {
url: new URL(href, majorVersionFolderUrl).href,
extension: ext,
platform,
arch
};
}
});
// 5. For each version, pick the correct download link based on target platform/arch
const results = [];
const targetKey = `${targetPlatform}-${targetArch}`;
for (const [version, files] of versionsMap.entries()) {
const fileInfo = files[targetKey];
if (fileInfo) {
results.push({
version: version,
downloadUrl: fileInfo.url,
platform: targetPlatform,
architecture: targetArch,
fileExtension: fileInfo.extension,
status: 'pending',
percent: 0
});
} else {
console.warn(`No matching file found for ${version} with ${targetKey}`);
}
}
return results.sort((a, b) => compareVersions(a.version, b.version)); // Optional: sort versions
} catch (error) {
console.error('Error fetching Blender versions:', error);
return [];
}
}
// Helper function to compare version strings (e.g., "5.0.0", "5.0.1")
/**
* @param {string} v1
* @param {string} v2
*/
function compareVersions(v1, v2) {
const parts1 = v1.split('.').map(Number);
const parts2 = v2.split('.').map(Number);
for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
const num1 = parts1[i] || 0;
const num2 = parts2[i] || 0;
if (num1 !== num2) return num1 - num2;
}
return 0;
}
+66
View File
@@ -0,0 +1,66 @@
<script>
let {
style = 'default',
color = 'black',
onclick,
children,
disabled = false,
fill = false
} = $props();
</script>
<button {disabled} class="btn {style} {color}" class:fill {onclick} class:disabled
>{@render children()}</button
>
<style>
.btn {
font-family: var(--font);
outline: unset;
border: unset;
padding: 0.5rem 1rem;
border-radius: 5px;
font-size: 1rem;
font-weight: 100;
cursor: pointer;
}
.black {
color: var(--light);
background-color: var(--black);
}
.black:hover {
background-color: var(--accent);
}
.black:active {
background-color: var(--light-accent);
}
.iconbutton {
background-color: transparent;
padding: 0;
margin: 0;
}
.iconbutton:hover {
background-color: var(--light);
}
.accent {
color: var(--light);
background-color: var(--accent);
}
.accent:hover {
background-color: var(--light-accent);
}
.accent:active {
background-color: var(--dark-accent);
}
.disabled {
opacity: 0.5;
cursor: default;
pointer-events: none;
}
.fill {
width: 100%;
height: 100%;
}
</style>
+32
View File
@@ -0,0 +1,32 @@
<script>
import Release from '$lib/components/Release.svelte';
let { blenderVersions, installedVersions, downloadTasks } = $props();
import '@webtui/css/components/progress.css';
</script>
<div id="container">
<h2>Releases</h2>
<div id="releases">
{#each blenderVersions as release (release.version)}
<Release {installedVersions} {downloadTasks} {release} />
{/each}
</div>
</div>
<style>
h2 {
font-weight: normal;
margin-bottom: 1rem;
}
#container {
overflow-y: scroll;
max-height: 100%;
box-sizing: border-box;
}
#releases {
padding-right: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
</style>
+69
View File
@@ -0,0 +1,69 @@
<script>
import VersionCard from './VersionCard.svelte';
import { send, receive } from '$lib/transition.js';
import { flip } from 'svelte/animate';
let { installedVersions } = $props();
import '@webtui/css/base.css';
import '@webtui/css/utils/box.css';
let favourites = $derived(installedVersions?.filter((v) => v.favourite) || []);
let installed = $derived(installedVersions?.filter((v) => !v.favourite) || []);
</script>
<h2>Favourites</h2>
<div id="container">
<div id="library">
{#if favourites.length}
{#each favourites as version (version.version)}
<div
animate:flip={{ duration: 200 }}
in:receive={{ key: version.version }}
out:send={{ key: version.version }}
>
<VersionCard {version} />
</div>
{/each}
{:else}
<p class="minor">No favourites</p>
{/if}
</div>
</div>
<h2>Installed</h2>
<div id="container">
<div id="library">
{#each installed as version (version.version)}
<div
animate:flip={{ duration: 200 }}
in:receive={{ key: version.version }}
out:send={{ key: version.version }}
>
<VersionCard {version} />
</div>
{/each}
</div>
</div>
<style>
.minor {
color: var(--light-accent);
}
h2 {
font-weight: normal;
margin-bottom: 1rem;
}
#container {
max-height: 100%;
box-sizing: border-box;
}
#library {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
width: 100%;
min-height: 140px;
overflow: visible;
}
</style>
+142
View File
@@ -0,0 +1,142 @@
<script>
import { onMount } from 'svelte';
import Button from '$lib/components/Button.svelte';
let { position = 'bottom', button = undefined, children = undefined } = $props();
let open = $state(false);
let menuRef = $state(null);
let buttonRef = $state(null);
const toggle = () => {
open = !open;
};
const close = () => {
open = false;
};
onMount(() => {
const handleClickOutside = (event) => {
if (
open &&
menuRef &&
!menuRef.contains(event.target) &&
buttonRef &&
!buttonRef.contains(event.target)
) {
close();
}
};
const handleEscape = (event) => {
if (event.key === 'Escape' && open) {
close();
}
};
document.addEventListener('click', handleClickOutside);
document.addEventListener('keydown', handleEscape);
return () => {
document.removeEventListener('click', handleClickOutside);
document.removeEventListener('keydown', handleEscape);
};
});
</script>
<div class="menu-container">
<div class="btncont" bind:this={buttonRef}>
{#if button}
{@render button({ toggle, open })}
{:else}
<!-- Default three-dots button -->
<Button fill={true} style="iconbutton" onclick={toggle}>
<div class="icon-container">
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
fill="currentColor"
viewBox="0 0 16 16"
>
<path
d="M9.5 13a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm0-5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm0-5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0z"
/>
</svg>
</div>
</Button>
{/if}
</div>
{#if open}
<div
class="menu-items"
class:top={position === 'top'}
class:bottom={position === 'bottom'}
class:left={position === 'left'}
class:right={position === 'right'}
bind:this={menuRef}
>
{#if children}
{@render children({ close })}
{/if}
</div>
{/if}
</div>
<style>
.btncont {
height: 100%;
}
.menu-container {
height: 100%;
position: relative;
display: inline-block;
}
.menu-items {
position: absolute;
background-color: var(--light);
border: 2px solid var(--light-accent);
border-radius: 5px;
min-width: 120px;
z-index: 1000;
padding: 0.5rem 0;
}
.menu-items.bottom {
top: 100%;
left: 0;
margin-top: 4px;
}
.menu-items.top {
bottom: 100%;
left: 0;
margin-bottom: 4px;
}
.menu-items.left {
top: 0;
right: 100%;
margin-right: 4px;
}
.menu-items.right {
top: 0;
left: 100%;
margin-left: 4px;
}
svg {
height: 100%;
}
.icon-container {
display: flex;
align-items: center;
justify-content: center;
}
.icon-container:hover svg {
fill: var(--accent);
}
</style>
+82
View File
@@ -0,0 +1,82 @@
<script>
import { onMount, tick } from 'svelte';
let { percent, unzipping, installed } = $props();
let element;
let emptyChar = '·';
let filledChar = '■';
let endChar = '';
let startChar = '|';
let spinnerChars = ['⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷'];
let spinnerAlt = ['⠁', '⠂', '⠄', '⡀', '⢀', '⠠', '⠐', '⠈'];
let spinner = '';
let barWidth = $state(0);
let bar = $state('');
onMount(async () => {
requestAnimationFrame(updateBar);
//const resizeObserver = new ResizeObserver(updateBar);
//resizeObserver.observe(element);
});
async function updateBar(timestamp) {
if (!element) return;
spinner = spinnerChars[Math.floor(timestamp / 150) % spinnerChars.length];
const style = window.getComputedStyle(element);
const fontSize = parseFloat(style.fontSize);
// Estimate character width as roughly 60% of font size (monospace approximation)
const charWidth = fontSize * 0.57;
barWidth = Math.floor(element.clientWidth / charWidth) - 2;
bar =
startChar +
filledChar.repeat(Math.ceil((percent / 100) * barWidth)) +
'|' +
emptyChar.repeat(barWidth - Math.ceil((percent / 100) * barWidth)) +
endChar;
if (unzipping) {
const installText = '| |';
const barLength = barWidth;
const installStart = Math.floor((timestamp / 10) % (barLength + installText.length));
const barArray = bar.split('');
for (let i = 0; i < installText.length; i++) {
const pos = installStart + i;
if (pos >= barLength) {
barArray[pos - barLength] = installText[i];
} else {
barArray[pos] = installText[i];
}
}
bar = barArray.join('');
}
requestAnimationFrame(updateBar);
}
</script>
<div bind:this={element} id="progress">
{#each bar as char, index (index)}
<span class="fixed-char" class:installed>{char}</span>
{/each}
</div>
<style>
#progress {
box-sizing: border-box;
width: 100%;
}
.fixed-char {
display: inline-block;
font-family: var(--font);
width: 1ch;
max-width: 1ch;
min-width: 1ch;
text-align: center;
overflow: visible;
color: var(--accent);
transition: 0.2s ease-in-out;
}
.fixed-char.installed {
color: var(--black);
}
</style>
+100
View File
@@ -0,0 +1,100 @@
<script>
import Button from './Button.svelte';
import Progress from '$lib/components/Progress.svelte';
import { downloadBlenderVersion } from '$lib/download.js';
import { onMount } from 'svelte';
let { release, installedVersions = [], downloadTasks } = $props();
let linkIndex = $state(0);
let selectedLink = $derived(release.links[linkIndex]);
let installed = $derived(installedVersions.some((v) => v.version === selectedLink.version));
let percent = $derived(
downloadTasks.find((task) => task.version === selectedLink.version)?.percent ?? 0
);
let downloading = $derived(percent > 0 && !installed);
let unzipping = $derived(percent === 100 && !installed);
onMount(() => {
linkIndex = release.links.length - 1;
});
</script>
<div class="download" class:full-width={downloading || installed}>
<div class="selectVersion {downloading}">
<select class:disabled={downloading && !installed} bind:value={linkIndex}>
{#each release.links as link, i (link.version)}
<option value={i} selected={i === linkIndex}>{link.version}</option>
{/each}
</select>
</div>
<div class="progress" class:downloading={downloading || installed}>
<Progress {unzipping} percent={installed ? 100 : percent} {installed}></Progress>
</div>
<Button
disabled={installed}
color={downloading ? 'accent' : 'black'}
onclick={() => {
downloadBlenderVersion(selectedLink);
downloading = !downloading;
}}>{installed ? 'Installed' : downloading ? 'Cancel' : 'Download'}</Button
>
</div>
<style>
.download {
box-sizing: border-box;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
align-content: center;
background-color: var(--light);
padding: 0.5rem 0.5rem;
border-radius: 0.5rem;
width: 20rem;
transition: width 0.3s ease;
}
.download.full-width {
width: 100%;
}
.selectVersion {
box-sizing: border-box;
width: 5.5rem;
min-width: 5.5rem;
}
.progress {
flex: 1;
opacity: 0;
}
.progress.downloading {
opacity: 1;
}
select {
cursor: pointer;
background-color: none;
outline: unset;
border: none;
background: none;
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
border-radius: 0;
font-family: var(--font);
font-size: 1rem;
padding: 0.5rem 0.5rem;
border-radius: 0.2rem;
transition: color 0.3s ease;
font-variant-numeric: tabular-nums;
}
select:hover {
outline: 2px solid var(--accent);
}
select.disabled {
color: var(--accent);
pointer-events: none;
}
</style>
+20
View File
@@ -0,0 +1,20 @@
<script>
let { settings } = $props();
</script>
<div>Settings</div>
<div class="container">
<div>Platform: {settings.defaultPlatform}</div>
<div>Architecture: {settings.defaultArch}</div>
<div>Keep Archives: {settings.keepDownloadedArchives}</div>
<div>Library: {settings.libraryDir}/Smoothie Library</div>
</div>
<style>
#container {
overflow-y: scroll;
max-height: 100%;
box-sizing: border-box;
}
</style>
+99
View File
@@ -0,0 +1,99 @@
<script>
import { deleteVersion, launchBlenderVersion } from '$lib/library';
import { toggleFavourite } from '$lib/library';
import Button from '$lib/components/Button.svelte';
import Menu from '$lib/components/Menu.svelte';
let { version } = $props();
</script>
<div id="card">
<div class="row">
{version.version}
<Button style="iconbutton" onclick={() => toggleFavourite(version)}
><svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
class="fav-btn {version.favourite ? 'fav' : ''}"
viewBox="0 0 16 16"
>
<path
d="M2 2v13.5a.5.5 0 0 0 .74.439L8 13.069l5.26 2.87A.5.5 0 0 0 14 15.5V2a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2"
/>
</svg>
</Button>
</div>
<div class="row">
<Button onclick={() => launchBlenderVersion(version)}>Launch</Button>
<Menu>
{#snippet children({ close })}
<button
type="button"
class="menu-item"
onclick={() => deleteVersion(version) && close()}
onkeydown={(e) =>
(e.key === 'Enter' || e.key === ' ') && deleteVersion(version) && close()}
>Delete</button
>
<button
type="button"
class="menu-item"
onclick={close}
onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && close()}>Option 2</button
>
<button
type="button"
class="menu-item"
onclick={close}
onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && close()}>Option 3</button
>
{/snippet}
</Menu>
</div>
</div>
<style>
#card {
display: flex;
flex-direction: column;
position: relative;
justify-content: space-between;
aspect-ratio: 1 / 0.7;
width: 100%;
min-width: 170px;
max-width: 200px;
background-color: var(--white);
border-radius: 5px;
background-color: var(--light);
padding: 1rem;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
}
#card:hover {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.fav-btn:hover {
fill: var(--accent);
}
.fav-btn.fav {
fill: var(--accent);
}
.menu-item {
padding: 0.5rem 1rem;
cursor: pointer;
background: none;
border: none;
font-family: var(--font);
font-size: 1rem;
color: var(--black);
text-align: left;
width: 100%;
}
.menu-item:hover {
background-color: var(--light-accent);
}
</style>
+262
View File
@@ -0,0 +1,262 @@
import { getSettings } from './settings.js';
import { ensureDirectory, stripTopLevelDirectory } from '$lib/file_utils.js';
import { join } from '@tauri-apps/api/path';
import { remove, readDir } from '@tauri-apps/plugin-fs';
import { invoke } from '@tauri-apps/api/core';
import { download } from '@tauri-apps/plugin-upload';
import { getInstalledVersions } from './library.js';
import { BASE_LIBRARY_DIR } from './settings.js';
import { writable, get } from 'svelte/store';
export const downloadTasksStore = writable([]);
/**
* @typedef {Object} DownloadProgress
* @property {number} loaded - Bytes downloaded
* @property {number} total - Total bytes
* @property {number} percent - Progress percentage (0-100)
*/
/**
* Update the download progress for a specific version in the download tasks store.
* @param {string} version - The Blender version
* @param {DownloadProgress} progress - The download progress object
*/
export function updateDownloadProgress(version, progress) {
downloadTasksStore.update((tasks) => {
const existingIndex = tasks.findIndex((task) => task.version === version);
if (existingIndex !== -1) {
// Update existing task
const updatedTasks = [...tasks];
updatedTasks[existingIndex] = { ...updatedTasks[existingIndex], ...progress };
return updatedTasks;
} else {
// Add new task
return [...tasks, { version, ...progress }];
}
});
}
/**
* Download and extract a Blender version.
* @param {(progress: DownloadProgress) => void} [onProgress] - Optional progress callback
* @returns {Promise<{success: boolean, message: string, path: string}>} Result
*/
export async function downloadBlenderVersion(versionInfo) {
try {
// Get settings
const settings = await getSettings();
const libraryDir = settings.libraryDir;
if (!libraryDir) {
return {
success: false,
message: 'Library directory not set. Please configure settings.',
path: ''
};
}
// Create target directory path
const targetDir = await join(libraryDir, BASE_LIBRARY_DIR, 'blender', versionInfo.version);
// Ensure the directory exists
await ensureDirectory(targetDir);
// Download the archive
const downloadResult = await downloadFile(versionInfo.downloadUrl, targetDir, (progress) => {
updateDownloadProgress(versionInfo.version, progress);
});
if (!downloadResult.success) {
// Clean up target directory if it's empty (failed download)
try {
const entries = await readDir(targetDir);
if (entries.length === 0) {
await remove(targetDir);
}
} catch (cleanupError) {
console.warn('Failed to clean up empty directory:', cleanupError);
}
return downloadResult;
}
const archivePath = downloadResult.path;
// Extract the archive
const extractResult = await extractArchive(
archivePath,
targetDir,
versionInfo.platform,
versionInfo.fileExtension,
settings.keepDownloadedArchives ?? true
);
if (!extractResult.success) {
return {
success: false,
message: extractResult.message,
path: extractResult.path || ''
};
}
//refresh installed versions
await getInstalledVersions();
return {
success: true,
message: `Successfully downloaded and extracted Blender ${versionInfo.version}`,
path: targetDir
};
} catch (error) {
console.error('Error downloading Blender version:', error);
const errorMessage = error instanceof Error ? error.message : String(error);
return {
success: false,
message: `Unexpected error: ${errorMessage}`,
path: ''
};
}
}
/**
* Download a file from URL to destination directory using optimized plugin.
* @param {string} url - Download URL
* @param {string} targetDir - Destination directory
* @param {(progress: DownloadProgress) => void} [onProgress] - Optional progress callback
* @returns {Promise<{success: boolean, message: string, path: string}>} Result with downloaded file path
*/
async function downloadFile(url, targetDir, onProgress) {
try {
// Extract filename from URL
const urlObj = new URL(url);
const filename = urlObj.pathname.split('/').pop() || 'blender-archive';
const filePath = await join(targetDir, filename);
// Optional: Mimic browser headers for better CDN performance
const headers = new Map([
['User-Agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'],
['Accept-Encoding', 'gzip, deflate, br'],
['Accept', '*/*'],
['Connection', 'keep-alive']
]);
// Use the optimized download function with proper async handling
await download(
url,
filePath,
(update) => {
if (onProgress) {
// Ensure progress updates are delivered asynchronously to prevent UI blocking
queueMicrotask(() => {
onProgress({
loaded: update.progressTotal,
total: update.total,
percent:
update.total > 0 ? Math.round((update.progressTotal / update.total) * 100) : 0
});
});
}
},
headers
);
return {
success: true,
message: 'Download completed',
path: filePath
};
} catch (error) {
console.error('Error downloading file:', error);
const errorMessage = error instanceof Error ? error.message : String(error);
// Clean up partially downloaded file if it exists
try {
const urlObj = new URL(url);
const filename = urlObj.pathname.split('/').pop() || 'blender-archive';
const filePath = await join(targetDir, filename);
await remove(filePath);
} catch (cleanupError) {
console.warn('Failed to clean up partially downloaded file:', cleanupError);
}
return {
success: false,
message: `Download error: ${errorMessage}`,
path: ''
};
}
}
/**
* Extract an archive using Rust-based extraction (supports .zip, .tar, .tar.gz, .tar.xz, .tar.bz2)
* @param {string} archivePath - Path to archive file
* @param {string} targetDir - Directory to extract into
* @param {string} platform - "linux", "windows", "macos" (unused for Rust extraction)
* @param {string} fileExtension - Archive extension
* @param {boolean} keepArchive - Whether to keep the archive after extraction
* @returns {Promise<{success: boolean, message: string, path?: string}>} Result
*/
async function extractArchive(archivePath, targetDir, platform, fileExtension, keepArchive) {
try {
// Use Rust-based extraction via Tauri command
const result = await invoke('extract_archive', {
archivePath,
targetDir
});
// Extract succeeded - remove archive if not keeping it
if (!keepArchive) {
try {
await remove(archivePath);
} catch (error) {
console.warn('Failed to remove archive:', error);
}
}
// Flatten directory structure if archive created a top-level directory
await stripTopLevelDirectory(targetDir);
return {
success: true,
message: String(result),
path: archivePath
};
} catch (error) {
console.error('Error extracting archive:', error);
const errorMessage = error instanceof Error ? error.message : String(error);
return {
success: false,
message: `Extraction failed: ${errorMessage}`,
path: archivePath
};
}
}
/**
* Get the path where a Blender version should be installed.
* @param {string} version - Version string
* @returns {Promise<string>} Full path to version directory
*/
export async function getVersionPath(version) {
const settings = await getSettings();
const libraryDir = settings.libraryDir;
if (!libraryDir) {
return '';
}
return join(libraryDir, BASE_LIBRARY_DIR, 'blender', version);
}
/**
* Check if a Blender version is already installed.
* @param {string} version - Version string
* @returns {Promise<boolean>} True if version directory exists and has content
*/
export async function isVersionInstalled(version) {
try {
const versionPath = await getVersionPath(version);
if (!versionPath) return false;
const entries = await readDir(versionPath);
// Check if directory exists and has at least one entry (not empty)
return entries.length > 0;
} catch {
return false;
}
}
+251
View File
@@ -0,0 +1,251 @@
import {
appDataDir,
join,
basename as pathBasename,
dirname as pathDirname
} from '@tauri-apps/api/path';
import { stat, mkdir, readDir, copyFile, rename, remove } from '@tauri-apps/plugin-fs';
import { open } from '@tauri-apps/plugin-dialog';
import baseicon from '$lib/assets/baseicon.png';
import { BASE_LIBRARY_DIR } from './settings';
/**
* Create a custom icon with text overlay
* @param {string} [iconText] - Text to overlay on the icon
* @returns {Promise<Blob>} Promise resolving to a PNG blob of the icon
*/
export async function createIcon(iconText) {
// Create canvas element
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// Load the base icon image
const img = new Image();
img.src = baseicon;
// Wait for image to load
await new Promise((resolve) => {
img.onload = resolve;
});
// Set canvas dimensions to match image
canvas.width = img.width;
canvas.height = img.height;
// Draw the base icon
ctx.drawImage(img, 0, 0);
// Set text properties
ctx.fillStyle = '#7d70ba';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
// Load the custom font
const fontFace = new FontFace(
'Europa-Mono',
`url(${baseicon.replace('baseicon.png', 'fonts/Europa-Mono-Medium.otf')})`
);
await fontFace.load();
document.fonts.add(fontFace);
// Set font size based on canvas dimensions
const fontSize = canvas.width / 6;
ctx.font = `${fontSize}px Europa-Mono`;
// Set font size based on canvas dimensions
// Add version text in the center
const text = iconText || 'XXX';
ctx.fillText(text, 150, 150);
// Convert canvas to blob
return new Promise((resolve) => {
canvas.toBlob((blob) => {
resolve(blob);
}, 'image/png');
});
}
/**
* Create library directory structure if it doesn't exist
* @param {string} libraryDir - Library directory path
* @returns {Promise<void>}
*/
export async function ensureLibraryStructure(libraryDir) {
try {
// Create main library directory
const baseDir = await join(libraryDir, BASE_LIBRARY_DIR);
// Create blender subdirectory
const blenderDir = await join(baseDir, 'blender');
await mkdir(blenderDir, { recursive: true });
// Create templates subdirectory
const templatesDir = await join(baseDir, 'templates');
await mkdir(templatesDir, { recursive: true });
const configDir = await join(baseDir, 'config');
await mkdir(configDir, { recursive: true });
console.log('Library structure created at:', libraryDir);
} catch (error) {
console.error('Error creating library structure:', error);
throw error;
}
}
/**
* Open a directory picker dialog for selecting a directory.
* @param {string} [currentPath] - Current path to use as default
* @returns {Promise<string|null>} Selected path or null if cancelled
*/
export async function selectDirectory(currentPath) {
try {
// Use currentPath if provided, otherwise use app data directory
let defaultPath = await appDataDir();
if (currentPath) {
defaultPath = currentPath;
}
const selected = await open({
multiple: false,
recursive: true,
directory: true,
title: 'Select Library Directory',
defaultPath
});
return selected;
} catch (error) {
console.error('Error opening directory picker:', error);
return null;
}
}
/**
* Check if a directory exists
* @param {string} path - Directory path to check
* @returns {Promise<boolean>} True if directory exists
*/
export async function directoryExists(path) {
try {
const stats = await stat(path);
return stats.isDirectory;
} catch {
return false;
}
}
/**
* Create a directory recursively if it doesn't exist
* @param {string} path - Directory path to create
* @returns {Promise<void>}
*/
export async function ensureDirectory(path) {
try {
await mkdir(path, { recursive: true });
} catch (error) {
console.error(`Error creating directory ${path}:`, error);
throw error;
}
}
/**
* Get the basename of a path (file or directory name)
* @param {string} path - Full path
* @returns {Promise<string>} Basename
*/
export async function basename(path) {
return pathBasename(path);
}
/**
* Get the directory name of a path
* @param {string} path - Full path
* @returns {Promise<string>} Directory name
*/
export async function dirname(path) {
return pathDirname(path);
}
/**
* Copy a file or directory recursively
* @param {string} source - Source path
* @param {string} destination - Destination path
* @returns {Promise<void>}
*/
export async function copyPath(source, destination) {
try {
const statInfo = await stat(source);
if (statInfo.isDirectory) {
// Create destination directory
await mkdir(destination, { recursive: true });
// Read all entries in source
const entries = await readDir(source);
for (const entry of entries) {
const srcPath = await join(source, entry.name);
const destPath = await join(destination, entry.name);
await copyPath(srcPath, destPath); // recursive call
}
} else {
// It's a file, copy directly
await copyFile(source, destination);
}
} catch (error) {
console.error(`Error copying ${source} to ${destination}:`, error);
throw error;
}
}
/**
* Move or rename a file or directory
* @param {string} source - Source path
* @param {string} destination - Destination path
* @returns {Promise<void>}
*/
export async function movePath(source, destination) {
try {
await rename(source, destination);
} catch (error) {
console.error(`Error moving ${source} to ${destination}:`, error);
throw error;
}
}
/**
* Strip top-level directory if the target directory contains only one subdirectory.
* This is useful after extracting archives that create an unnecessary parent folder.
* @param {string} targetDir - Directory to check and potentially flatten
* @returns {Promise<boolean>} True if flattening was performed, false otherwise
*/
export async function stripTopLevelDirectory(targetDir) {
try {
// Read contents of target directory
const entries = await readDir(targetDir);
// If there's exactly one entry and it's a directory, flatten it
if (entries.length === 1 && entries[0].isDirectory) {
const singleDirName = entries[0].name;
const singleDirPath = await join(targetDir, singleDirName);
// Read contents of the single subdirectory
const subEntries = await readDir(singleDirPath);
// Move all items from subdirectory to target directory
for (const entry of subEntries) {
const sourcePath = await join(singleDirPath, entry.name);
const destPath = await join(targetDir, entry.name);
await rename(sourcePath, destPath);
}
// Remove the now-empty subdirectory
await remove(singleDirPath, { recursive: true });
console.log(`Flattened directory structure: removed ${singleDirName}/`);
return true;
}
return false;
} catch (error) {
console.error(`Error stripping top-level directory in ${targetDir}:`, error);
throw error;
}
}
+367
View File
@@ -0,0 +1,367 @@
import { currentSettings } from '$lib/settings.js';
import { createIcon } from './file_utils';
import { platform } from '@tauri-apps/plugin-os';
import { join, localDataDir } from '@tauri-apps/api/path';
import { exists, stat, readDir, writeFile, remove } from '@tauri-apps/plugin-fs';
import { invoke } from '@tauri-apps/api/core';
import { writable, get } from 'svelte/store';
import { LazyStore } from '@tauri-apps/plugin-store';
import { BASE_LIBRARY_DIR } from '$lib/settings.js';
import { confirm } from '@tauri-apps/plugin-dialog';
import { updateDownloadProgress } from './download';
/** INSTALLED BLENDER VERSION TYPE
* @typedef {Object} blenderVersion
* @property {string} version - Version string (e.g., "5.0.0")
* @property {string} path - Full path to version directory
* @property {string} executable - Path to Blender executable
* @property {string} platform - Detected platform (windows, linux, macos)
* @property {Date} [installDate] - Installation date (if available)
*/
export const currentInstalledVersions = writable([]);
const favouritesStore = new LazyStore('versions.json');
/**
* Create icon and write it to disk
* @param {blenderVersion} version
*/
export async function generateVersionIcon(version) {
//
//
if (!(await exists(version.path))) {
console.warn(`Version path does not exist: ${version.path}`);
return;
}
const iconPath = `${version.path}/icon.png`;
if (await exists(iconPath)) {
return;
}
try {
const newIcon = await createIcon(version.version);
const iconPath = `${version.path}/icon.png`;
const iconBuffer = new Uint8Array(await newIcon.arrayBuffer());
await writeFile(iconPath, iconBuffer);
} catch (error) {
console.warn('Failed to create or save icon:', error);
}
}
/**
* Clean up orphaned desktop files for versions that are no longer installed.
* @param {blenderVersion[]} versions Array of currently installed versions
*/
export async function cleanupOrphanedDesktopFiles(versions) {
const currentPlatform = await platform();
if (currentPlatform !== 'linux') {
return;
}
const installedVersionStrings = versions.map((v) => v.version);
const applicationsDir = await join(await localDataDir(), 'applications');
if (!(await exists(applicationsDir))) {
return;
}
try {
const entries = await readDir(applicationsDir);
for (const entry of entries) {
if (entry.isDirectory) {
continue;
}
const fileName = entry.name;
const match = fileName.match(/^blender-smoothie-(.+)\.desktop$/);
if (match) {
const version = match[1];
if (!installedVersionStrings.includes(version)) {
const desktopFilePath = await join(applicationsDir, fileName);
try {
await remove(desktopFilePath);
console.log(`Removed orphaned desktop file: ${desktopFilePath}`);
} catch (error) {
console.warn(`Failed to remove orphaned desktop file ${desktopFilePath}:`, error);
}
}
}
}
} catch (error) {
console.warn('Error cleaning up orphaned desktop files:', error);
}
}
/**
* Remove the .desktop file for a Blender version (Linux only).
* @param {blenderVersion} version
*/
export async function removeDesktopFile(version) {
const currentPlatform = await platform();
if (currentPlatform !== 'linux') {
return;
}
const desktopFilePath = await join(
await localDataDir(),
`/applications/blender-smoothie-${version.version}.desktop`
);
if (!(await exists(desktopFilePath))) {
return;
}
try {
await remove(desktopFilePath);
} catch (error) {
console.warn('Failed to remove .desktop file:', error);
}
}
/**
* Create a .desktop file for a Blender version (Linux only).
* @param {blenderVersion} version
*/
export async function createDesktopFile(version, forceRecreate = false) {
const currentPlatform = platform();
if (currentPlatform !== 'linux') {
return;
}
const desktopFilePath = await join(
await localDataDir(),
`/applications/blender-smoothie-${version.version}.desktop`
);
if (await exists(desktopFilePath)) {
if (!forceRecreate) {
return;
}
await remove(desktopFilePath);
}
try {
const desktopFileContent = `[Desktop Entry]
Type=Application
Name=Blender ${version.version}
Comment=Blender ${version.version} - 3D creation suite
Exec=${version.executable.replace(/ /g, '\\ ')} %F
Icon=${version.path.replace(/ /g, '\\ ')}/icon.png
Terminal=false
Categories=Graphics;3DGraphics;
MimeType=application/x-blender;
`;
console.log('creating desktop file:', desktopFilePath);
await writeFile(desktopFilePath, new TextEncoder().encode(desktopFileContent));
} catch (error) {
console.warn('Failed to create .desktop file:', error);
}
}
export async function removeVersion(version) {
if (await exists(version.path)) {
try {
await remove(version.path, { recursive: true });
} catch (error) {
console.warn(`Failed to remove version path: ${version.path}`, error);
}
} else {
console.warn(`Version path does not exist: ${version.path}`);
}
}
/**
* Toggle the favourite status of a version.
* @param {blenderVersion} version The version to toggle.
*/
export async function toggleFavourite(version) {
const versions = get(currentInstalledVersions);
console.log();
const versionObj = versions.find((v) => v.version === version.version);
if (versionObj) {
versionObj.favourite = !versionObj.favourite;
} else {
console.error('version not found', version);
}
currentInstalledVersions.set(versions);
await favouritesStore.set(version.version, versionObj.favourite);
await favouritesStore.save();
}
/**
* Delete a version and remove it from disk.
* @param {blenderVersion} version The version to delete.
*/
export async function deleteVersion(version) {
try {
const confirmation = await confirm(
`This will remove the blender ${version.version} from your system. Are you sure?`,
{
title: 'Delete',
kind: 'warning'
}
);
if (!confirmation) return;
// Remove the version directory from disk
await removeVersion(version);
updateDownloadProgress(version.version, { loaded: 0, total: 0, percent: 0 });
// Remove the .desktop file if it exists (Linux only)
await removeDesktopFile(version);
// Remove from favourites store
await favouritesStore.delete(version.version);
await favouritesStore.save();
// Update the current installed versions list
const versions = get(currentInstalledVersions);
const filteredVersions = versions.filter((v) => v.version !== version.version);
currentInstalledVersions.set(filteredVersions);
console.log(`Successfully deleted version ${version.version}`);
} catch (error) {
console.error(`Failed to delete version ${version.version}:`, error);
throw error;
}
}
/**
* Get all installed Blender versions from the library directory.
* @returns {Promise<blenderVersion[]>} Array of installed versions
*/
export async function getInstalledVersions() {
const favouriteEntries = await favouritesStore.entries();
const favourites = Object.fromEntries(favouriteEntries);
try {
const settings = get(currentSettings);
const libraryDir = settings.libraryDir;
if (!libraryDir) {
currentInstalledVersions.set([]);
return [];
}
const blenderLibraryPath = await join(libraryDir, BASE_LIBRARY_DIR, 'blender');
if (!exists(blenderLibraryPath)) {
currentInstalledVersions.set([]);
return [];
}
// Read all version directories
const entries = await readDir(blenderLibraryPath);
const versions = [];
// Filter out non-directory entries
for (const entry of entries) {
if (!entry.isDirectory) {
const filePath = await join(blenderLibraryPath, entry.name);
//await remove(filePath);
continue;
}
const version = entry.name;
const versionPath = await join(blenderLibraryPath, version);
try {
// Find the Blender executable in this version directory
const executable = await findBlenderExecutable(versionPath);
if (executable) {
const currentPlatform = platform();
let newVersion = {
version,
path: versionPath,
executable,
platform: currentPlatform,
favourite: favourites[version] || false
};
//check if version has an icon, if not generate one
await generateVersionIcon(newVersion);
// create desktop file if autoCreateShortcuts is enabled
if (settings.autoCreateShortcuts) {
await createDesktopFile(newVersion);
}
versions.push(newVersion);
} else {
console.warn(`No executable found deleting folder ${version}`);
//await remove(versionPath, { recursive: true });
}
} catch (error) {
console.warn(`Error processing version ${version}:`, error);
// Skip this version if we can't process it
}
}
currentInstalledVersions.set(versions);
cleanupOrphanedDesktopFiles(versions);
return versions;
} catch (error) {
console.error('Error getting installed versions:', error);
currentInstalledVersions.set([]);
return [];
}
}
/**
* Launch a specific Blender version.
* @param {blenderVersion} version - The version object to launch
* @returns {Promise<{success: boolean, message: string}>} Result of launch attempt
*/
export async function launchBlenderVersion(version) {
try {
if (!version || !version.executable) {
return {
success: false,
message: 'Invalid version or executable not found'
};
}
// Check if executable exists
try {
await stat(version.executable);
} catch (error) {
console.error(error);
return {
success: false,
message: `Executable not found: ${version.executable}`
};
}
// Launch the executable
invoke('launch_binary', { path: version.executable, args: [] });
} catch (error) {
console.error('Error launching Blender:', error);
const errorMessage = error instanceof Error ? error.message : String(error);
return {
success: false,
message: `Launch failed: ${errorMessage}`
};
}
}
async function findBlenderExecutable(versionPath) {
const settings = get(currentSettings);
const currentPlatform = settings.defaultPlatform;
const platformStr = String(currentPlatform);
// Map platform strings to executable names
const executableMap = {
windows: 'blender.exe',
linux: 'blender',
macos: 'blender'
// Add more platforms here as needed
};
// Default to 'blender' if platform not in map
const executableName = executableMap[platformStr] || 'blender';
const executablePath = await join(versionPath, executableName);
try {
const stats = await stat(executablePath);
if (stats.isFile) {
return executablePath;
}
} catch (error) {
// Executable not found at the expected path
console.warn(`Blender executable not found at ${executablePath}:`, error);
}
return null;
}
+154
View File
@@ -0,0 +1,154 @@
import { LazyStore } from '@tauri-apps/plugin-store';
import { writable, get } from 'svelte/store';
import { platform, arch } from '@tauri-apps/plugin-os';
import { appDataDir } from '@tauri-apps/api/path';
import { ensureLibraryStructure, selectDirectory } from './file_utils.js';
// Define the settings structure
/** @typedef {Object} Settings
* @property {string} [libraryDir] - Path to library directory
* @property {string} [defaultPlatform] - Default platform ('auto', 'windows', 'linux', 'macos')
* @property {string} [defaultArch] - Default architecture ('x86_64', 'arm64', etc.)
* @property {string} [theme] - Theme ('light', 'dark')
* @property {boolean} [autoCreateShortcuts] - should smoothie create a shortcut to newly installed blender versions
* @property {boolean} [keepDownloadedArchives] - Whether to keep downloaded archives after extraction
*/
// Settings lazy store
const settingsStore = new LazyStore('settings.json');
export const currentSettings = writable(/** @type {Settings} */ ({}));
export const BASE_LIBRARY_DIR = 'smoothie-library';
let DEFAULT_SETTINGS = null;
export async function initSettings() {
const detectedPlatform = await detectPlatform();
const detectedArch = await detectArch();
const defaultLibraryDir = await appDataDir();
DEFAULT_SETTINGS = {
libraryDir: defaultLibraryDir, // Will be filled with default library directory
defaultPlatform: detectedPlatform, //Automatic
defaultArch: detectedArch,
keepDownloadedArchives: false,
autoCreateShortcuts: true,
theme: 'light' //
};
}
/**
* Get the current settings, filling with defaults if missing.
* @returns {Promise<Settings>}
*/
export async function getSettings() {
if (DEFAULT_SETTINGS === null) {
await initSettings();
}
try {
// Get all stored values
const storeEntries = await settingsStore.entries();
// Store empty, load defaults
if (storeEntries.length === 0) {
await resetSettings();
}
//load settings object and patch with defaults for missing fields
const settings = Object.fromEntries(storeEntries);
const filledSettings = { ...DEFAULT_SETTINGS, ...settings };
currentSettings.set(filledSettings);
await ensureLibraryStructure(filledSettings.libraryDir);
return filledSettings;
} catch (error) {
console.error('Error loading settings:', error);
currentSettings.set(DEFAULT_SETTINGS);
return DEFAULT_SETTINGS;
}
}
/**
* @typedef {Partial<Settings>} PartialSettings
*/
export async function saveSettings(newSettings) {
try {
for (const [key, value] of Object.entries(newSettings)) {
await settingsStore.set(key, value);
}
if (newSettings.libraryDir) {
//if new library dir check it
await ensureLibraryStructure(newSettings.libraryDir);
}
await settingsStore.save();
let completeSettings = { ...get(currentSettings), ...newSettings };
currentSettings.set(completeSettings);
} catch (error) {
console.error('Error saving settings:', error);
throw error;
}
}
/**
* Clear store and reset all settings to defaults.
* @returns {Promise<Settings>}
*/
export async function resetSettings() {
try {
await settingsStore.clear();
await saveSettings(DEFAULT_SETTINGS);
return DEFAULT_SETTINGS;
} catch (error) {
console.error('Error resetting settings:', error);
throw error;
}
}
/**
* Detect the current platform, mapping to our supported values.
* @returns {Promise<string>} 'windows', 'linux', or 'macos'
*/
async function detectPlatform() {
try {
const currentPlatform = platform();
// Map from OS plugin values to our settings values (desktop only)
const platformMap = {
linux: 'linux',
windows: 'windows',
darwin: 'macos',
macos: 'macos'
};
// Only desktop platforms are supported
if (currentPlatform in platformMap) {
return currentPlatform;
}
console.warn(`Platform "${currentPlatform}" is not supported, defaulting to linux`);
return 'linux';
} catch (error) {
console.error('Error detecting platform:', error);
return 'linux'; // fallback
}
}
/**
* Detect the current platform, mapping to our supported values.
* @returns {Promise<string>} 'windows', 'linux', or 'macos'
*/
async function detectArch() {
try {
const currentArch = arch();
const archmap = {
x86_64: 'x64',
arm: 'arm64',
aarch64: 'arm64'
};
if (currentArch in archmap) {
return currentArch;
}
console.warn(`Architecture "${currentArch}" is not supported, defaulting to x64`);
return 'x64';
} catch (error) {
console.error('Error detecting platform:', error);
return 'linux'; // fallback
}
}
export { selectDirectory };
+20
View File
@@ -0,0 +1,20 @@
import { crossfade } from 'svelte/transition';
import { quintOut } from 'svelte/easing';
export const [send, receive] = crossfade({
duration: (d) => Math.sqrt(d * 200),
fallback(node, params) {
const style = getComputedStyle(node);
const transform = style.transform === 'none' ? '' : style.transform;
return {
duration: 600,
easing: quintOut,
css: (t) => `
transform: ${transform} scale(${t});
opacity: ${t}
`
};
}
});
+5
View File
@@ -0,0 +1,5 @@
// Tauri doesn't have a Node.js server to do proper SSR
// so we will use adapter-static to prerender the app (SSG)
// See: https://v2.tauri.app/start/frontend/sveltekit/ for more info
export const prerender = true;
export const ssr = false;
+5
View File
@@ -0,0 +1,5 @@
<script>
import '../app.css';
</script>
<slot></slot>
+203
View File
@@ -0,0 +1,203 @@
<script>
import '@webtui/css/components/spinner.css';
import { fade } from 'svelte/transition';
import { onMount } from 'svelte';
import { getSettings, currentSettings } from '$lib/settings.js';
import { getBlenderReleases, blenderReleases } from '$lib/blenderfetch';
import { getInstalledVersions, currentInstalledVersions } from '$lib/library.js';
import { downloadTasksStore } from '$lib/download.js';
import Library from '$lib/components/Library.svelte';
import Download from '$lib/components/Download.svelte';
import Settings from '$lib/components/Settings.svelte';
let fadeInSettings = { duration: 100 };
let fadeOutSettings = { duration: 100, delay: fadeInSettings.duration };
let activeTab = 'library';
let blenderVersions = [];
let installedVersions = [];
let downloadTasks = [];
let settings = {};
let initialized = false;
function initStoresListeners() {
currentSettings.subscribe((value) => {
settings = value;
console.log('Loaded settings:', settings);
});
currentInstalledVersions.subscribe((value) => {
installedVersions = value;
console.log('Loaded installed versions:', installedVersions);
});
blenderReleases.subscribe((value) => {
blenderVersions = value;
console.log('Loaded Blender releases:', blenderVersions);
});
downloadTasksStore.subscribe((value) => {
downloadTasks = value;
//console.log('Loaded download tasks:', downloadTasks);
});
}
onMount(async () => {
const startTime = Date.now();
await getSettings();
await getBlenderReleases();
await getInstalledVersions();
initStoresListeners();
const elapsed = Date.now() - startTime;
if (elapsed < 2000) {
initialized = true;
/* setTimeout(() => {
initialized = true;
}, 2000 - elapsed);*/
} else {
initialized = true;
}
//await getInstalledVersions();
//
});
</script>
<div id="main">
{#if initialized}
<div class="tablist" role="tablist">
<div
role="tab"
tabindex="0"
class="tabitem"
class:selected={activeTab === 'library'}
aria-selected={activeTab === 'library'}
on:click={() => (activeTab = 'library')}
on:keydown={(e) => (e.key === 'Enter' || e.key === ' ' ? (activeTab = 'library') : null)}
>
Library
</div>
<div
role="tab"
tabindex="0"
class="tabitem"
class:selected={activeTab === 'download'}
aria-selected={activeTab === 'download'}
on:click={() => (activeTab = 'download')}
on:keydown={(e) => (e.key === 'Enter' || e.key === ' ' ? (activeTab = 'download') : null)}
>
Download
</div>
<div
role="tab"
tabindex="0"
class="tabitem"
class:selected={activeTab === 'settings'}
aria-selected={activeTab === 'settings'}
on:click={() => (activeTab = 'settings')}
on:keydown={(e) => (e.key === 'Enter' || e.key === ' ' ? (activeTab = 'settings') : null)}
>
Settings
</div>
</div>
<div id="tabcontent">
<div class="frame">
{#if activeTab === 'library'}
<div class="page" in:fade={fadeOutSettings} out:fade={fadeInSettings}>
<Library {installedVersions} />
</div>
{:else if activeTab === 'download'}
<div class="page" in:fade={fadeOutSettings} out:fade={fadeInSettings}>
<Download {downloadTasks} {installedVersions} {blenderVersions} />
</div>
{:else if activeTab === 'settings'}
<div class="page" in:fade={fadeOutSettings} out:fade={fadeInSettings}>
<Settings {settings} />
</div>
{/if}
</div>
</div>
{:else}
LOADING...
{/if}
</div>
<style>
:global(:root) {
--white: #f5f5f5;
--light-accent: #d4cdf2;
--light: #eaebed;
--black: #171a21;
--accent: #7d70ba;
--error: #ed1c24;
--warning: #f1d302;
--font: font-family: 'Europa Mono', monospace;
}
:global(*) {
color: var(--black);
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
:global(.svelte-tabs) {
box-sizing: border-box;
padding: 0;
margin: 0;
gap: 0;
}
:global(body) {
box-sizing: border-box;
padding: 0;
margin: 0;
background-color: var(--white);
}
.tablist {
width: 100%;
height: 3rem;
display: flex;
flex-direction: row;
}
.tabitem {
text-align: center;
line-height: 3rem;
color: var(--black);
width: 8rem;
cursor: pointer;
padding: 0 1.5rem;
}
.tabitem:hover {
background-color: var(--light-accent);
}
.tabitem.selected {
background-color: var(--light-accent);
border-bottom: 2px solid var(--accent);
}
#main {
display: flex;
flex-direction: column;
}
#tabcontent {
background-color: var(--light);
box-sizing: border-box;
height: calc(100vh - 3rem);
padding: 1rem;
max-height: 100%;
}
.frame {
background-color: var(--white);
padding: 1rem;
height: 100%;
border-radius: 0.5rem;
max-height: 100%;
overflow-y: scroll;
}
.page {
display: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
</style>