FEAT: Custom dialog and popup
FEAT: Set as Default and Delete
This commit is contained in:
parent
b9b6bd1490
commit
3a9f5566b2
324
src/lib/components/Dialog.svelte
Normal file
324
src/lib/components/Dialog.svelte
Normal 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>
|
||||
38
src/lib/components/Popup.svelte
Normal file
38
src/lib/components/Popup.svelte
Normal 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>
|
||||
@ -3,6 +3,7 @@
|
||||
import Progress from '$lib/components/Progress.svelte';
|
||||
import { downloadBlenderVersion } from '$lib/download.js';
|
||||
import { onMount } from 'svelte';
|
||||
import Popup from '$lib/components/Popup.svelte';
|
||||
|
||||
let { release, installedVersions = [], downloadTasks } = $props();
|
||||
let linkIndex = $state(0);
|
||||
@ -10,6 +11,13 @@
|
||||
|
||||
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(
|
||||
downloadTasks.find((task) => task.version === selectedLink.version)?.percent ?? 0
|
||||
);
|
||||
@ -18,8 +26,11 @@
|
||||
onMount(() => {
|
||||
linkIndex = release.links.length - 1;
|
||||
});
|
||||
let popup = $state();
|
||||
</script>
|
||||
|
||||
<Popup bind:this={popup} content={'Blender ' + selectedLink.version + ' has been installed'}
|
||||
></Popup>
|
||||
<div class="download" class:full-width={downloading || installed}>
|
||||
<div class="selectVersion {downloading}">
|
||||
<select class:disabled={downloading && !installed} bind:value={linkIndex}>
|
||||
@ -37,8 +48,11 @@
|
||||
disabled={installed}
|
||||
color={downloading ? 'accent' : 'black'}
|
||||
onclick={() => {
|
||||
downloadBlenderVersion(selectedLink);
|
||||
downloading = !downloading;
|
||||
if (downloading) {
|
||||
return;
|
||||
}
|
||||
downloading = true;
|
||||
handleDownload();
|
||||
}}>{installed ? 'Installed' : downloading ? 'Cancel' : 'Download'}</Button
|
||||
>
|
||||
</div>
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
import { confirm } from '@tauri-apps/plugin-dialog';
|
||||
import { getInstalledVersions, removeAllDesktopFilesForVersions } from '$lib/library.js';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import { show } from '$lib/components/Dialog.svelte';
|
||||
|
||||
let { settings } = $props();
|
||||
let changing = $state(false);
|
||||
@ -41,16 +42,19 @@
|
||||
const newExists = await directoryExists(newLibraryPath);
|
||||
|
||||
let moveLibrary = false;
|
||||
|
||||
if (currentExists) {
|
||||
let message = `Move existing library from\n${currentLibraryPath}\nto\n${newLibraryPath}?`;
|
||||
if (newExists) {
|
||||
message = `Library already exists at new location:\n${newLibraryPath}\n\nMoving will replace it. Continue?`;
|
||||
}
|
||||
moveLibrary = await confirm(message, {
|
||||
title: 'Move Library',
|
||||
kind: 'warning'
|
||||
|
||||
const prompt = await show({
|
||||
title: 'Move library ',
|
||||
message: message,
|
||||
buttons: ['Cancel', 'Continue'],
|
||||
type: 'info'
|
||||
});
|
||||
moveLibrary = prompt === 'Continue';
|
||||
}
|
||||
|
||||
if (moveLibrary) {
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
<script>
|
||||
import Popup from '$lib/components/Popup.svelte';
|
||||
import {
|
||||
deleteVersion,
|
||||
launchBlenderVersion,
|
||||
@ -7,9 +8,39 @@
|
||||
} from '$lib/library';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import Menu from '$lib/components/Menu.svelte';
|
||||
import { show } from '$lib/components/Dialog.svelte';
|
||||
|
||||
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>
|
||||
|
||||
<Popup bind:this={popup} content="Version {version.version} is now the default on your system"
|
||||
></Popup>
|
||||
<div id="card">
|
||||
<div class="row">
|
||||
{version.version}
|
||||
@ -34,24 +65,18 @@
|
||||
<button
|
||||
type="button"
|
||||
class="menu-item"
|
||||
onclick={() => deleteVersion(version) && close()}
|
||||
onkeydown={(e) =>
|
||||
(e.key === 'Enter' || e.key === ' ') && deleteVersion(version) && close()}
|
||||
>Delete</button
|
||||
onclick={async () => {
|
||||
await handleDelete();
|
||||
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={() => registerVersion(version) && close()}
|
||||
onkeydown={(e) =>
|
||||
(e.key === 'Enter' || e.key === ' ') && registerVersion(version) && close()}
|
||||
>Default</button
|
||||
onclick={async () => {
|
||||
await handleDefault();
|
||||
close();
|
||||
}}>Default</button
|
||||
>
|
||||
{/snippet}
|
||||
</Menu>
|
||||
|
||||
@ -219,14 +219,6 @@ export async function toggleFavourite(version) {
|
||||
*/
|
||||
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 });
|
||||
|
||||
@ -9,6 +9,8 @@
|
||||
import Library from '$lib/components/Library.svelte';
|
||||
import Download from '$lib/components/Download.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 fadeOutSettings = { duration: 100, delay: fadeInSettings.duration };
|
||||
@ -61,6 +63,9 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<Popup content="Are you sure you want to delete this file?" type="confirm" />
|
||||
<Dialog />
|
||||
|
||||
<div id="main">
|
||||
{#if initialized}
|
||||
<div class="tablist" role="tablist">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user