FEAT: Custom dialog and popup

FEAT: Set as Default and Delete
This commit is contained in:
valerio 2026-03-11 22:20:42 +01:00
parent b9b6bd1490
commit 3a9f5566b2
7 changed files with 430 additions and 28 deletions

View File

@ -0,0 +1,324 @@
<script module>
// Store for managing dialog state
export let dialogStore = $state({
open: false,
title: 'Dialog',
message: '',
buttons: ['OK', 'Cancel'],
type: 'confirm',
resolve: null
});
/**
* Show a dialog and return a promise that resolves with the clicked button text
* @param {Object} options
* @param {string} [options.title='Dialog']
* @param {string} [options.message='']
* @param {string[]} [options.buttons=['OK', 'Cancel']]
* @param {'confirm'|'warning'|'error'|'info'} [options.type='confirm']
* @returns {Promise<string|null>} Button text or null if dismissed
*/
export function show(options = {}) {
return new Promise((resolve) => {
// Close any existing dialog
if (dialogStore.resolve) {
dialogStore.resolve(null);
}
dialogStore.open = true;
dialogStore.title = options.title || 'Dialog';
dialogStore.message = options.message || '';
dialogStore.buttons = options.buttons || ['OK', 'Cancel'];
dialogStore.type = options.type || 'confirm';
dialogStore.resolve = resolve;
});
}
/**
* Close the current dialog with a result
* @param {string|null} result
*/
export function closeDialog(result = null) {
if (dialogStore.resolve) {
dialogStore.resolve(result);
}
dialogStore.open = false;
dialogStore.resolve = null;
}
// Convenience methods for common dialog types
export const dialogs = {
/** Show a confirmation dialog */
confirm: (message, title = 'Confirm') =>
show({ title, message, buttons: ['Confirm', 'Cancel'], type: 'confirm' }).then(
(result) => result === 'Confirm'
),
/** Show a warning dialog */
warning: (message, title = 'Warning') =>
show({ title, message, buttons: ['Continue', 'Cancel'], type: 'warning' }).then(
(result) => result === 'Continue'
),
/** Show an error dialog */
error: (message, title = 'Error') =>
show({ title, message, buttons: ['OK'], type: 'error' }).then(() => {}),
/** Show an info dialog */
info: (message, title = 'Information') =>
show({ title, message, buttons: ['OK'], type: 'info' }).then(() => {})
};
</script>
<script>
import { onMount, onDestroy } from 'svelte';
import { fly, fade } from 'svelte/transition';
import Button from '$lib/components/Button.svelte';
// Props for declarative usage
let {
open = false,
title = 'Dialog',
message = '',
buttons = ['OK', 'Cancel'],
type = 'confirm',
onresult = null // callback function
} = $props();
// Determine if we're using imperative mode (via store)
let usingStore = $state(false);
let dialogRef = $state(null);
// Module store is accessible via module exports
// dialogStore and closeDialog are defined in module script
// Computed state based on mode
const currentOpen = $derived(usingStore ? dialogStore.open : open);
const currentTitle = $derived(usingStore ? dialogStore.title : title);
const currentMessage = $derived(usingStore ? dialogStore.message : message);
const currentButtons = $derived(usingStore ? dialogStore.buttons : buttons);
const currentType = $derived(usingStore ? dialogStore.type : type);
// Switch to store mode when store dialog opens
$effect(() => {
if (dialogStore.open && !open) {
usingStore = true;
} else if (!dialogStore.open && usingStore) {
// Delay reset to allow animations
setTimeout(() => {
if (!dialogStore.open) {
usingStore = false;
}
}, 200);
}
});
function handleButtonClick(button) {
if (usingStore) {
closeDialog(button);
} else {
open = false;
// Call result callback for declarative usage
onresult?.(button);
}
}
function handleClose() {
if (usingStore) {
closeDialog(null);
} else {
open = false;
onresult?.(null);
}
}
function handleKeydown(event) {
if (event.key === 'Escape') {
event.preventDefault();
handleClose();
} else if (event.key === 'Enter' && currentButtons.length > 0) {
// Enter triggers the first button
handleButtonClick(currentButtons[0]);
}
}
function handleBackdropClick(event) {
if (event.target === event.currentTarget) {
handleClose();
}
}
// Manage keyboard listeners
onMount(() => {
if (currentOpen) {
document.addEventListener('keydown', handleKeydown);
}
});
onDestroy(() => {
document.removeEventListener('keydown', handleKeydown);
});
$effect(() => {
if (currentOpen) {
document.addEventListener('keydown', handleKeydown);
// Focus dialog for keyboard navigation
setTimeout(() => {
if (dialogRef) {
dialogRef.focus();
}
}, 10);
} else {
document.removeEventListener('keydown', handleKeydown);
}
});
// Helper to determine button style based on text and dialog type
function getButtonStyle(buttonText) {
const lower = buttonText.toLowerCase();
if (lower.includes('cancel') || lower.includes('no') || lower.includes('dismiss')) {
return 'black';
}
if (currentType === 'warning' || currentType === 'error' || currentType === 'info') {
if (
lower.includes('ok') ||
lower.includes('yes') ||
lower.includes('confirm') ||
lower.includes('continue')
) {
return 'accent';
}
}
if (currentType === 'confirm') {
if (lower.includes('ok') || lower.includes('yes') || lower.includes('confirm')) {
return 'accent';
}
}
return 'black';
}
</script>
{#if currentOpen}
<div
class="dialog-backdrop"
in:fade={{ duration: 150 }}
out:fade={{ duration: 150 }}
onclick={handleBackdropClick}
aria-hidden="true"
>
<div
class="dialog-container"
in:fly={{ duration: 200, y: 20 }}
out:fade={{ duration: 150 }}
bind:this={dialogRef}
tabindex="-1"
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
aria-describedby="dialog-message"
>
<div class="dialog-header">
<h3 id="dialog-title" class="dialog-title">{currentTitle}</h3>
<div class="dialog-type-indicator {currentType}"></div>
</div>
<div class="dialog-content">
<p id="dialog-message" class="dialog-content-text">{currentMessage}</p>
</div>
<div class="dialog-actions">
{#each currentButtons as button (button)}
<Button style={getButtonStyle(button)} onclick={() => handleButtonClick(button)}>
{button}
</Button>
{/each}
</div>
</div>
</div>
{/if}
<style>
.dialog-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.dialog-container {
background-color: var(--light);
border-radius: 0.5rem;
padding: 1.5rem;
min-width: 300px;
max-width: 500px;
max-height: 80vh;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 1rem;
outline: none;
border: 2px solid var(--);
}
.dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding-bottom: 0.5rem;
}
.dialog-title {
margin: 0;
font-size: 1.2rem;
font-weight: 500;
color: var(--black);
}
.dialog-type-indicator {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.dialog-type-indicator.confirm {
background-color: var(--accent);
}
.dialog-type-indicator.warning {
background-color: var(--warning);
}
.dialog-type-indicator.error {
background-color: var(--error);
}
.dialog-type-indicator.info {
background-color: var(--warning);
}
.dialog-content {
flex: 1;
color: var(--black);
line-height: 1.5;
max-height: 50vh;
overflow-y: auto;
padding: 0.5rem 0;
}
.dialog-content p {
margin: 0;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding-top: 0.5rem;
}
</style>

View File

@ -0,0 +1,38 @@
<script>
import { fade, fly } from 'svelte/transition';
let { content = '', type = 'confirm' } = $props();
let open = $state(false);
export function pop(resetTimeout = 0) {
open = true;
if (resetTimeout > 0) {
setTimeout(() => {
open = false;
}, resetTimeout);
}
}
</script>
{#if open}
<div in:fly={{ duration: 200, y: 100 }} out:fade class="popup {type}">
<div class="popup-content">
<p>{content}</p>
</div>
</div>
{/if}
<style>
.popup {
position: fixed;
width: 20rem;
padding: 1rem;
margin: 2rem;
right: 0;
bottom: 0;
border-radius: 0.1rem;
}
.confirm {
background-color: var(--light-accent);
}
</style>

View File

@ -3,6 +3,7 @@
import Progress from '$lib/components/Progress.svelte'; import Progress from '$lib/components/Progress.svelte';
import { downloadBlenderVersion } from '$lib/download.js'; import { downloadBlenderVersion } from '$lib/download.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import Popup from '$lib/components/Popup.svelte';
let { release, installedVersions = [], downloadTasks } = $props(); let { release, installedVersions = [], downloadTasks } = $props();
let linkIndex = $state(0); let linkIndex = $state(0);
@ -10,6 +11,13 @@
let installed = $derived(installedVersions.some((v) => v.version === selectedLink.version)); let installed = $derived(installedVersions.some((v) => v.version === selectedLink.version));
async function handleDownload() {
let result = await downloadBlenderVersion(selectedLink);
if (result.success) {
popup?.pop(2000);
}
}
let percent = $derived( let percent = $derived(
downloadTasks.find((task) => task.version === selectedLink.version)?.percent ?? 0 downloadTasks.find((task) => task.version === selectedLink.version)?.percent ?? 0
); );
@ -18,8 +26,11 @@
onMount(() => { onMount(() => {
linkIndex = release.links.length - 1; linkIndex = release.links.length - 1;
}); });
let popup = $state();
</script> </script>
<Popup bind:this={popup} content={'Blender ' + selectedLink.version + ' has been installed'}
></Popup>
<div class="download" class:full-width={downloading || installed}> <div class="download" class:full-width={downloading || installed}>
<div class="selectVersion {downloading}"> <div class="selectVersion {downloading}">
<select class:disabled={downloading && !installed} bind:value={linkIndex}> <select class:disabled={downloading && !installed} bind:value={linkIndex}>
@ -37,8 +48,11 @@
disabled={installed} disabled={installed}
color={downloading ? 'accent' : 'black'} color={downloading ? 'accent' : 'black'}
onclick={() => { onclick={() => {
downloadBlenderVersion(selectedLink); if (downloading) {
downloading = !downloading; return;
}
downloading = true;
handleDownload();
}}>{installed ? 'Installed' : downloading ? 'Cancel' : 'Download'}</Button }}>{installed ? 'Installed' : downloading ? 'Cancel' : 'Download'}</Button
> >
</div> </div>

View File

@ -6,6 +6,7 @@
import { confirm } from '@tauri-apps/plugin-dialog'; import { confirm } from '@tauri-apps/plugin-dialog';
import { getInstalledVersions, removeAllDesktopFilesForVersions } from '$lib/library.js'; import { getInstalledVersions, removeAllDesktopFilesForVersions } from '$lib/library.js';
import Button from '$lib/components/Button.svelte'; import Button from '$lib/components/Button.svelte';
import { show } from '$lib/components/Dialog.svelte';
let { settings } = $props(); let { settings } = $props();
let changing = $state(false); let changing = $state(false);
@ -41,16 +42,19 @@
const newExists = await directoryExists(newLibraryPath); const newExists = await directoryExists(newLibraryPath);
let moveLibrary = false; let moveLibrary = false;
if (currentExists) { if (currentExists) {
let message = `Move existing library from\n${currentLibraryPath}\nto\n${newLibraryPath}?`; let message = `Move existing library from\n${currentLibraryPath}\nto\n${newLibraryPath}?`;
if (newExists) { if (newExists) {
message = `Library already exists at new location:\n${newLibraryPath}\n\nMoving will replace it. Continue?`; message = `Library already exists at new location:\n${newLibraryPath}\n\nMoving will replace it. Continue?`;
} }
moveLibrary = await confirm(message, {
title: 'Move Library', const prompt = await show({
kind: 'warning' title: 'Move library ',
message: message,
buttons: ['Cancel', 'Continue'],
type: 'info'
}); });
moveLibrary = prompt === 'Continue';
} }
if (moveLibrary) { if (moveLibrary) {

View File

@ -1,4 +1,5 @@
<script> <script>
import Popup from '$lib/components/Popup.svelte';
import { import {
deleteVersion, deleteVersion,
launchBlenderVersion, launchBlenderVersion,
@ -7,9 +8,39 @@
} from '$lib/library'; } from '$lib/library';
import Button from '$lib/components/Button.svelte'; import Button from '$lib/components/Button.svelte';
import Menu from '$lib/components/Menu.svelte'; import Menu from '$lib/components/Menu.svelte';
import { show } from '$lib/components/Dialog.svelte';
let { version } = $props(); let { version } = $props();
let popup = $state();
async function handleDelete() {
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') {
deleteVersion(version);
}
}
async function handleDefault() {
const prompt = await show({
title: 'Set as Default',
message: `This will make Blender ${version.version} the default version on your system.`,
buttons: ['Cancel', 'Continue'],
type: 'info'
});
if (prompt == 'Continue') {
popup.pop(1500);
registerVersion(version);
}
}
</script> </script>
<Popup bind:this={popup} content="Version {version.version} is now the default on your system"
></Popup>
<div id="card"> <div id="card">
<div class="row"> <div class="row">
{version.version} {version.version}
@ -34,24 +65,18 @@
<button <button
type="button" type="button"
class="menu-item" class="menu-item"
onclick={() => deleteVersion(version) && close()} onclick={async () => {
onkeydown={(e) => await handleDelete();
(e.key === 'Enter' || e.key === ' ') && deleteVersion(version) && close()} close();
>Delete</button }}>Delete</button
> >
<button <button
type="button" type="button"
class="menu-item" class="menu-item"
onclick={close} onclick={async () => {
onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && close()}>Option 2</button await handleDefault();
> close();
<button }}>Default</button
type="button"
class="menu-item"
onclick={() => registerVersion(version) && close()}
onkeydown={(e) =>
(e.key === 'Enter' || e.key === ' ') && registerVersion(version) && close()}
>Default</button
> >
{/snippet} {/snippet}
</Menu> </Menu>

View File

@ -219,14 +219,6 @@ export async function toggleFavourite(version) {
*/ */
export async function deleteVersion(version) { export async function deleteVersion(version) {
try { 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 // Remove the version directory from disk
await removeVersion(version); await removeVersion(version);
updateDownloadProgress(version.version, { loaded: 0, total: 0, percent: 0 }); updateDownloadProgress(version.version, { loaded: 0, total: 0, percent: 0 });

View File

@ -9,6 +9,8 @@
import Library from '$lib/components/Library.svelte'; import Library from '$lib/components/Library.svelte';
import Download from '$lib/components/Download.svelte'; import Download from '$lib/components/Download.svelte';
import Settings from '$lib/components/Settings.svelte'; import Settings from '$lib/components/Settings.svelte';
import Popup from '$lib/components/Popup.svelte';
import Dialog from '$lib/components/Dialog.svelte';
let fadeInSettings = { duration: 100 }; let fadeInSettings = { duration: 100 };
let fadeOutSettings = { duration: 100, delay: fadeInSettings.duration }; let fadeOutSettings = { duration: 100, delay: fadeInSettings.duration };
@ -61,6 +63,9 @@
}); });
</script> </script>
<Popup content="Are you sure you want to delete this file?" type="confirm" />
<Dialog />
<div id="main"> <div id="main">
{#if initialized} {#if initialized}
<div class="tablist" role="tablist"> <div class="tablist" role="tablist">