WIP: Template Upload

- Drag and drop
 - Launche template

TODO: Banner generation
TODO: File dialog
This commit is contained in:
valerio
2026-06-29 13:46:16 +02:00
parent 46a415e880
commit 29bf3dfa66
14 changed files with 680 additions and 263 deletions
+97
View File
@@ -0,0 +1,97 @@
<script>
import { getCurrentWindow } from '@tauri-apps/api/window';
import { onMount } from 'svelte';
import { addTemplateFile } from '$lib/file_utils';
let dragging = $state(false);
let hasBlend = $state(false);
let dropzoneEl = $state(null);
let { version } = $props();
onMount(() => {
const win = getCurrentWindow();
let unlisten = () => {};
win
.onDragDropEvent((event) => {
if (!dropzoneEl) return;
const payload = event.payload;
if (payload.type === 'enter') {
hasBlend = payload.paths.some((p) => p.endsWith('.blend'));
if (!hasBlend) return;
}
if (payload.type === 'leave') {
dragging = false;
return;
}
// 'enter', 'over', and 'drop' all have position data
const rect = dropzoneEl.getBoundingClientRect();
const scale = window.devicePixelRatio || 1;
const cssX = payload.position.x / scale;
const cssY = payload.position.y / scale;
const isInside =
cssX >= rect.left && cssX <= rect.right && cssY >= rect.top && cssY <= rect.bottom;
if (payload.type === 'over') {
dragging = hasBlend && isInside;
} else if (payload.type === 'enter') {
dragging = isInside;
} else if (payload.type === 'drop') {
dragging = false;
if (hasBlend && isInside) {
const blendFiles = payload.paths.filter((p) => p.endsWith('.blend'));
if (blendFiles.length > 0) {
console.log('Dropped Blender files:', blendFiles);
blendFiles.forEach((file) => {
if (version) {
addTemplateFile(version, file);
}
});
}
}
}
})
.then((fn) => {
unlisten = fn;
});
return () => unlisten();
});
</script>
<div class="dropzone" class:dragging bind:this={dropzoneEl}>Drop Blender Files</div>
<style>
.dropzone {
height: 3rem;
width: 100%;
border-color: var(--light-accent);
border-width: 3px;
border-style: solid;
border-radius: 5px;
margin-top: 1rem;
display: flex;
box-sizing: border-box;
align-items: center;
justify-content: center;
color: var(--light-accent);
transition:
border-color 0.15s,
color 0.15s;
cursor: pointer;
transition: 0.2s ease-in-out;
}
.dropzone.dragging {
height: 5rem;
border-color: var(--accent);
color: var(--accent);
}
</style>
+75 -33
View File
@@ -1,8 +1,11 @@
<script>
import VersionCard from './VersionCard.svelte';
import TemplateCard from './TemplateCard.svelte';
import { send, receive } from '$lib/transition.js';
import { flip } from 'svelte/animate';
import { selectedVersionStore } from '$lib/library.js';
import { Splitpanes, Pane } from 'svelte-splitpanes';
let selectedVersion = $state(null);
@@ -13,45 +16,69 @@
let { installedVersions } = $props();
import '@webtui/css/base.css';
import '@webtui/css/utils/box.css';
import Dropzone from './Dropzone.svelte';
import { fade } from 'svelte/transition';
let favourites = $derived(installedVersions?.filter((v) => v.favourite) || []);
let installed = $derived(installedVersions?.filter((v) => !v.favourite) || []);
</script>
<h2>Favourites</h2>
<Splitpanes id="split">
<Pane>
<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} {selectedVersion} />
</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} {selectedVersion} />
<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} {selectedVersion} />
</div>
{/each}
{:else}
<p class="minor">No favourites</p>
{/if}
</div>
{/each}
</div>
</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} {selectedVersion} />
</div>
{/each}
</div>
</div>
</Pane>
<Pane>
<div id="templates">
{#if selectedVersion}
<h2>Blender {selectedVersion.version}</h2>
<TemplateCard template={null} version={selectedVersion} />
{#if selectedVersion?.templates.length > 0}
{#each selectedVersion?.templates as template (template)}
<div>
<TemplateCard {template} version={selectedVersion} />
</div>
{/each}
{:else}
<p class="minor">No templates</p>
{/if}
<Dropzone version={selectedVersion}></Dropzone>
{/if}
</div>
</Pane>
</Splitpanes>
<style>
.minor {
@@ -65,12 +92,27 @@
max-height: 100%;
box-sizing: border-box;
}
#templates {
width: 100%;
height: 100%;
border-radius: 5px;
padding: 0 0 0 1rem;
}
#library {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
width: 100%;
min-height: 140px;
padding-bottom: 1rem;
overflow: visible;
}
:global(.splitpanes__pane) {
background-color: unset !important;
}
:global(.splitpanes__splitter) {
background-color: var(--light) !important;
width: 0.5rem !important;
border: none !important;
border-radius: 1rem;
}
</style>
+3
View File
@@ -88,6 +88,9 @@
<style>
.btncont {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.menu-container {
height: 100%;
+22
View File
@@ -0,0 +1,22 @@
<script>
let { onclick, children } = $props();
</script>
<button type="button" class="menu-item" {onclick}>{@render children?.()}</button>
<style>
.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>
+79
View File
@@ -0,0 +1,79 @@
<script>
import Button from '$lib/components/Button.svelte';
import Menu from '$lib/components/Menu.svelte';
import MenuItem from '$lib/components/MenuItem.svelte';
import { show } from '$lib/components/Dialog.svelte';
import { deleteTemplate } from '$lib/library';
import { launchBlenderVersion } from '$lib/library';
let { template, version } = $props();
let templateName = $derived(
template
.split('/')
.pop()
.replace('.blend', '')
.replace(/\b\w/g, (c) => c.toUpperCase())
);
/**
* Handles the deletion of a template.
*
* @param {string} template - The template file path to delete.
*/
async function handleDelete(template) {
const prompt = await show({
title: 'Delete',
message: `This will remove Blender ${version.version} and all associated files from your system.`,
buttons: ['Cancel', 'Continue'],
type: 'warning'
});
if (prompt == 'Continue') {
deleteTemplate(template);
}
}
</script>
{#if template == null}
<div id="base-card">
<Button fill={true} color="accent" onclick={() => launchBlenderVersion(version)}
>Launch Base</Button
>
</div>
{:else}
<div id="template-card">
<div class="leftmenu">
<Menu>
<MenuItem onclick={() => handleDelete(template)}>Delete</MenuItem>
</Menu>
{templateName}
</div>
<Button style="small" onclick={() => launchBlenderVersion(version, template)}>→</Button>
</div>
{/if}
<style>
#base-card {
margin-bottom: 1rem;
}
#template-card {
height: 3rem;
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 0.5rem 1rem;
border-radius: 0.5rem;
width: 100%;
transition: width 0.3s ease;
margin-bottom: 1rem;
}
.leftmenu {
display: flex;
flex-direction: row;
align-items: center;
gap: 1rem;
}
</style>
+41 -61
View File
@@ -9,6 +9,8 @@
} from '$lib/library';
import Button from '$lib/components/Button.svelte';
import Menu from '$lib/components/Menu.svelte';
import MenuItem from '$lib/components/MenuItem.svelte';
import { show } from '$lib/components/Dialog.svelte';
let { version, selectedVersion } = $props();
@@ -42,8 +44,7 @@
}
</script>
<Popup bind:this={popup} content="Version {version.version} is now the default on your system"
></Popup>
<Popup bind:this={popup} content="Version {version.version} is now the default on your system" />
<div
role="button"
tabindex="0"
@@ -51,72 +52,65 @@
onclick={async () => {
await selectVersion(version);
}}
ondblclick={async () => {
console.log('double click', version);
await launchBlenderVersion(version);
}}
id="card"
class:selected
>
<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"
<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>
{version.version}
<Menu>
{#snippet children({ close })}
<MenuItem
onclick={async () => {
await handleDelete();
close();
}}>Delete</MenuItem
>
<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={async () => {
await handleDelete();
close();
}}>Delete</button
>
<button
type="button"
class="menu-item"
onclick={async () => {
await handleDefault();
close();
}}>Default</button
>
{/snippet}
</Menu>
</div>
<MenuItem
onclick={async () => {
await handleDefault();
close();
}}>Default</MenuItem
>
{/snippet}
</Menu>
</div>
<style>
#card {
display: flex;
flex-direction: column;
flex-direction: row;
position: relative;
justify-content: space-between;
aspect-ratio: 1 / 0.7;
width: 100%;
min-width: 170px;
max-width: 200px;
border-radius: 5px;
background-color: var(--light);
padding: 1rem;
display: flex;
align-items: center;
align-content: center;
justify-content: space-between;
}
#card.selected {
background-color: var(--light-accent);
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
}
#card:hover {
outline: 2px solid var(--accent);
outline-offset: -2px;
@@ -127,18 +121,4 @@
.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>
+17 -30
View File
@@ -11,6 +11,7 @@ import europa from '$lib/assets/fonts/Europa-Mono-Medium.otf';
import { BASE_LIBRARY_DIR } from './settings';
import figlet from 'figlet';
import shadow from 'figlet/fonts/Classy';
import { getInstalledVersions } from './library';
figlet.parseFont('Big', shadow);
/**
@@ -58,10 +59,10 @@ export async function createIcon(iconText) {
/**
* Create a custom banner with version number next to the baseIcon image
* @param {string} versionText - Version text to display
* @param {string} text - Version text to display
* @returns {Promise<Blob>} Promise resolving to a PNG blob of the banner
*/
export async function createBanner(versionText) {
export async function createBanner(text) {
// Create canvas element for banner
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
@@ -82,11 +83,6 @@ export async function createBanner(versionText) {
img.onload = resolve;
});
// Calculate icon position (centered vertically, left-aligned)
const iconSize = 70;
const iconX = 20;
const iconY = (canvas.height - iconSize) / 2;
// Add version text
ctx.fillStyle = '#f5f5f5';
ctx.textAlign = 'left';
@@ -102,7 +98,9 @@ export async function createBanner(versionText) {
const textX = 20;
const textY = canvas.height / 2;
const bannerText = await figlet.text(versionText + ' - Base', { font: 'Big' });
const bannerText = await figlet.text(text, {
font: 'Big'
});
const lines = bannerText.split('\n');
const lineHeight = fontSize * 1.2;
const startY = textY - (lines.length * lineHeight) / 2 + lineHeight / 2;
@@ -121,6 +119,17 @@ export async function createBanner(versionText) {
}, 'image/png');
});
}
export async function addTemplateFile(version, templateFilePath) {
const newFileName = await pathBasename(templateFilePath);
const templatesPath = await join(version.path, 'templates', newFileName);
await copyFile(templateFilePath, templatesPath);
await getInstalledVersions();
console.log('adding ', templateFilePath, ' to ', version.path);
}
/**
* Create library directory structure if it doesn't exist
* @param {string} libraryDir - Library directory path
@@ -131,10 +140,6 @@ export async function ensureLibraryStructure(libraryDir) {
const baseDir = await join(libraryDir, BASE_LIBRARY_DIR);
const blenderDir = await join(baseDir, 'blender');
await mkdir(blenderDir, { recursive: true });
const templatesDir = await join(baseDir, 'templates');
await mkdir(templatesDir, { recursive: true });
const configDir = await join(baseDir, 'config');
await mkdir(configDir, { recursive: true });
@@ -201,24 +206,6 @@ export async function ensureDirectory(path) {
}
}
/**
* 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
+162 -9
View File
@@ -1,5 +1,5 @@
import { currentSettings } from '$lib/settings.js';
import { createIcon, createBanner } from './file_utils';
import { createIcon, createBanner, ensureDirectory } 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';
@@ -7,7 +7,6 @@ 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
@@ -17,6 +16,7 @@ import { updateDownloadProgress } from './download';
* @property {string} executable - Path to Blender executable
* @property {string} platform - Detected platform (windows, linux, macos)
* @property {Date} [installDate] - Installation date (if available)
* @property {Array<string>} [templates] - List of favourite scenes for this version
*/
export const installedVersionsStore = writable([]);
@@ -27,8 +27,23 @@ export async function selectVersion(version) {
selectedVersionStore.set(version);
}
export async function generateTemplateBanner(templateDir) {
const bannerPath = `${templateDir}/banner.png`;
if (await exists(bannerPath)) {
return;
}
try {
const templateName = templateDir.split('/').pop() || templateDir.split('\\').pop();
const banner = await createBanner(templateName);
const bannerBuffer = new Uint8Array(await banner.arrayBuffer());
await writeFile(bannerPath, bannerBuffer);
} catch (error) {
console.error(`Failed to generate banner for template: ${templateDir}`, error);
}
}
export async function generateVersionBanner(version) {
const bannerPath = `${version.path}/banner.png`;
const bannerPath = await join(version.path, 'banner.png');
if (await exists(bannerPath)) {
return;
}
@@ -188,6 +203,18 @@ MimeType=application/x-blender;
}
}
export async function removeTemplate(templatePath) {
if (await exists(templatePath)) {
try {
await remove(templatePath, { recursive: true });
} catch (error) {
console.warn(`Failed to remove template path: ${templatePath}`, error);
}
} else {
console.warn(`Template path does not exist: ${templatePath}`);
}
}
export async function removeVersion(version) {
if (await exists(version.path)) {
try {
@@ -218,6 +245,19 @@ export async function toggleFavourite(version) {
await favouritesStore.save();
}
/**
* Remove a template from the library.
* @param {string} templatePath The path of the template to remove.
*/
export async function deleteTemplate(templatePath) {
try {
await removeTemplate(templatePath);
await getInstalledVersions();
} catch (error) {
console.error('Failed to delete template', error);
}
}
/**
* Delete a version and remove it from disk.
* @param {blenderVersion} version The version to delete.
@@ -286,12 +326,17 @@ export async function getInstalledVersions() {
const executable = await findBlenderExecutable(versionPath);
if (executable) {
const currentPlatform = platform();
// Get templates for this version
var templates = await getTemplates(version);
let newVersion = {
version,
path: versionPath,
executable,
platform: currentPlatform,
favourite: favourites[version] || false
favourite: favourites[version] || false,
templates: templates || []
};
//check if version has an icon, if not generate one
@@ -314,7 +359,21 @@ export async function getInstalledVersions() {
}
installedVersionsStore.set(versions);
cleanupOrphanedDesktopFiles(versions);
await cleanupOrphanedDesktopFiles(versions);
const selectedVersion = get(selectedVersionStore);
if (selectedVersion) {
const refreshed = versions.find((v) => v.version === selectedVersion.version);
selectedVersionStore.set(refreshed || selectedVersion);
} else {
if (versions.length > 0) {
let favouriteVersion = versions.find((v) => v.favourite);
if (favouriteVersion) {
selectedVersionStore.set(favouriteVersion);
} else {
selectedVersionStore.set(versions[0]);
}
}
}
return versions;
} catch (error) {
@@ -324,6 +383,51 @@ export async function getInstalledVersions() {
}
}
/**
*
* @param {string} version
* @returns {Promise<string[]>} List of .blend files paths in the templates directory
*/
export async function getTemplates(version) {
try {
const settings = get(currentSettings);
const libraryDir = settings.libraryDir;
if (!libraryDir) {
console.error('No library directory set');
return [];
}
const blenderConfigPath = await join(libraryDir, BASE_LIBRARY_DIR, 'blender', version);
const templatesDir = await join(
blenderConfigPath,
version.split('.').slice(0, 2).join('.'),
'scripts',
'startup',
'bl_app_templates_system'
);
await ensureDirectory(templatesDir);
const entries = await readDir(templatesDir);
const templates = [];
const ignoredDirs = ['Storyboarding', '2D_Animation', 'Sculpting', 'VFX', 'Video_Editing'];
for (const entry of entries) {
if (entry.isDirectory && !ignoredDirs.includes(entry.name)) {
const startupFile = await join(templatesDir, entry.name, 'startup.blend');
await generateTemplateBanner(await join(templatesDir, entry.name));
if (await exists(startupFile)) {
templates.push(entry.name);
}
}
}
return templates;
} catch (error) {
console.error(error);
return [];
}
}
/**
* Register a Blender version with the system.
* @param {blenderVersion} version - The version object to launch
@@ -360,6 +464,34 @@ export async function registerVersion(version) {
}
}
/**
*
* @param {string} version
* @param {string} template
* @returns {Promise<string>}
*/
export async function getTemplateDir(version, template) {
try {
const settings = get(currentSettings);
const libraryDir = settings.libraryDir;
const blenderConfigPath = await join(libraryDir, BASE_LIBRARY_DIR, 'config', version);
// Launch the executable
const templatesDir = await join(
blenderConfigPath,
version.split('.').slice(0, 2).join('.'),
'scripts',
'startup',
'bl_app_templates_system',
template
);
return templatesDir;
} catch (error) {
console.error(error);
return null;
}
}
/**
* Launch a specific Blender version.
* @param {blenderVersion} version - The version object to launch
@@ -391,18 +523,34 @@ export async function launchBlenderVersion(version, template = null) {
if (!libraryDir) {
return {
success: false,
message: `couldn't find library: ${version.executable}`
message: `couldn't find library`
};
}
const blenderConfigPath = await join(libraryDir, BASE_LIBRARY_DIR, 'config', version.version);
// Launch the executable
let bannerPath;
if (template) {
const templateDir = await getTemplateDir(version.version, template);
bannerPath = await join(templateDir, 'banner.png');
} else {
bannerPath = await join(version.path, 'banner.png');
}
const args = template ? ['--app-template', template] : [];
/*if (template) {
} else {
bannerPath = await join(version.path, 'banner.png');
} */
console.log('launching', version.executable, args);
invoke('launch_binary', {
path: version.executable,
args: [],
args: args,
env: {
BLENDER_USER_RESOURCES: blenderConfigPath,
BLENDER_CUSTOM_SPLASH_BANNER: await join(version.path, 'banner.png')
BLENDER_CUSTOM_SPLASH_BANNER: bannerPath
}
});
} catch (error) {
@@ -410,11 +558,16 @@ export async function launchBlenderVersion(version, template = null) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
success: false,
message: `Launch failed: ${errorMessage}`
message: `. failed: ${errorMessage}`
};
}
}
/**
*
* @param {string} versionPath
* @returns {Promise<string | null>} Path to the Blender executable, or null if not found
*/
async function findBlenderExecutable(versionPath) {
const settings = get(currentSettings);
const currentPlatform = settings.defaultPlatform;
+3 -3
View File
@@ -194,7 +194,7 @@
#tabcontent {
background-color: var(--light);
box-sizing: border-box;
height: calc(100vh - 3rem);
height: calc(100vh - 5rem);
padding: 1rem;
max-height: 100%;
}
@@ -215,8 +215,8 @@
}
.version {
position: fixed;
bottom: 2rem;
right: 2rem;
bottom: 2.8rem;
right: 3rem;
z-index: 2;
text-align: right;
color: var(--light-accent);