[FEAT] Toastr and saving UX improvements
This commit is contained in:
@@ -16,7 +16,7 @@
|
||||
<div class="hidden md:flex items-center gap-5">
|
||||
<!-- Auth & Projects -->
|
||||
<template v-if="authStore.user">
|
||||
<NavbarProjectActions @save-project="handleQuickSave" @open-save-modal="openSaveModal" @open-new-project-modal="isNewProjectModalOpen = true" @open-project-list="isProjectListOpen = true" />
|
||||
<NavbarProjectActions :is-saving="isSaving" @save-project="handleQuickSave" @open-save-modal="openSaveModal" @open-new-project-modal="isNewProjectModalOpen = true" @open-project-list="isProjectListOpen = true" />
|
||||
|
||||
<div class="h-4 w-px bg-gray-200 dark:bg-gray-700"></div>
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
<!-- Mobile Menu -->
|
||||
<NavbarMobileMenu
|
||||
:is-open="isMobileMenuOpen"
|
||||
:is-saving="isSaving"
|
||||
@close="isMobileMenuOpen = false"
|
||||
@open-help="$emit('open-help')"
|
||||
@save-project="handleQuickSave"
|
||||
@@ -71,6 +72,7 @@
|
||||
import NewProjectModal from '@/components/project/NewProjectModal.vue';
|
||||
import { useProjectStore, type Project } from '@/stores/useProjectStore';
|
||||
import { useProjectManager } from '@/composables/useProjectManager';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
|
||||
// Sub-components
|
||||
import NavbarLogo from './navbar/NavbarLogo.vue';
|
||||
@@ -88,10 +90,12 @@
|
||||
const isSaveProjectModalOpen = ref(false);
|
||||
const isNewProjectModalOpen = ref(false);
|
||||
const saveMode = ref<'save' | 'save-as'>('save');
|
||||
const isSaving = ref(false);
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const projectStore = useProjectStore();
|
||||
const { createProject, openProject, saveProject, saveAsProject } = useProjectManager();
|
||||
const { addToast } = useToast();
|
||||
|
||||
const handleOpenProject = async (project: Project) => {
|
||||
await openProject(project);
|
||||
@@ -111,21 +115,38 @@
|
||||
|
||||
const handleQuickSave = async () => {
|
||||
if (projectStore.currentProject?.name) {
|
||||
await saveProject(projectStore.currentProject.name);
|
||||
isSaving.value = true;
|
||||
try {
|
||||
await saveProject(projectStore.currentProject.name);
|
||||
addToast('Project saved successfully', 'success');
|
||||
} catch (error) {
|
||||
addToast('Failed to save project', 'error');
|
||||
console.error(error);
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
} else {
|
||||
openSaveModal('save');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveProject = async (name: string) => {
|
||||
isSaving.value = true;
|
||||
try {
|
||||
if (saveMode.value === 'save-as') {
|
||||
await saveAsProject(name);
|
||||
addToast('Project saved as new copy', 'success');
|
||||
} else {
|
||||
await saveProject(name);
|
||||
addToast('Project saved successfully', 'success');
|
||||
}
|
||||
} catch {
|
||||
// Error handled in composable
|
||||
isSaveProjectModalOpen.value = false;
|
||||
} catch (error) {
|
||||
addToast('Failed to save project', 'error');
|
||||
console.error(error);
|
||||
// Error handled in composable but kept here for toast
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -58,8 +58,14 @@
|
||||
$emit('close');
|
||||
"
|
||||
class="w-full text-left flex items-center gap-3 px-3 py-2 rounded-md text-base font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-800 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
|
||||
:disabled="isSaving"
|
||||
>
|
||||
<i class="fas fa-save w-5"></i> Save project
|
||||
<svg v-if="isSaving" class="animate-spin w-5 h-5 text-indigo-600 dark:text-indigo-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<i v-else class="fas fa-save w-5"></i>
|
||||
{{ isSaving ? 'Saving...' : 'Save project' }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -125,6 +131,7 @@
|
||||
|
||||
defineProps<{
|
||||
isOpen: boolean;
|
||||
isSaving?: boolean;
|
||||
}>();
|
||||
|
||||
defineEmits(['close', 'open-help', 'save-project', 'open-save-modal', 'open-new-project-modal', 'open-project-list', 'open-auth-modal']);
|
||||
|
||||
@@ -19,9 +19,13 @@
|
||||
<div v-if="isEditorActive" class="h-8 w-px bg-gray-200 dark:bg-gray-700 mx-1"></div>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<Tooltip v-if="isEditorActive" text="Save project">
|
||||
<button @click="$emit('save-project')" class="w-8 h-8 rounded-lg flex items-center justify-center text-gray-500 hover:text-indigo-600 hover:bg-indigo-50 dark:hover:bg-indigo-900/30 transition-all">
|
||||
<i class="fas fa-save"></i>
|
||||
<Tooltip v-if="isEditorActive" :text="isSaving ? 'Saving...' : 'Save project'">
|
||||
<button @click="$emit('save-project')" class="w-8 h-8 rounded-lg flex items-center justify-center text-gray-500 hover:text-indigo-600 hover:bg-indigo-50 dark:hover:bg-indigo-900/30 transition-all" :disabled="isSaving">
|
||||
<svg v-if="isSaving" class="animate-spin h-4 w-4 text-indigo-600 dark:text-indigo-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<i v-else class="fas fa-save"></i>
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
@@ -57,5 +61,9 @@
|
||||
const route = useRoute();
|
||||
const projectStore = useProjectStore();
|
||||
|
||||
defineProps<{
|
||||
isSaving?: boolean;
|
||||
}>();
|
||||
|
||||
const isEditorActive = computed(() => route.name === 'editor');
|
||||
</script>
|
||||
|
||||
21
src/components/utilities/ToastContainer.vue
Normal file
21
src/components/utilities/ToastContainer.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<div class="fixed bottom-5 right-5 z-[100] flex flex-col items-end space-y-2 pointer-events-none">
|
||||
<TransitionGroup
|
||||
enter-active-class="transform ease-out duration-300 transition"
|
||||
enter-from-class="translate-y-2 opacity-0 sm:translate-y-0 sm:translate-x-2"
|
||||
enter-to-class="translate-y-0 opacity-100 sm:translate-x-0"
|
||||
leave-active-class="transition ease-in duration-100"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<ToastItem v-for="toast in toasts" :key="toast.id" :toast="toast" />
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import ToastItem from './ToastItem.vue';
|
||||
|
||||
const { toasts } = useToast();
|
||||
</script>
|
||||
128
src/components/utilities/ToastItem.vue
Normal file
128
src/components/utilities/ToastItem.vue
Normal file
@@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center w-full max-w-sm p-4 mb-4 text-gray-500 bg-white dark:bg-gray-800 rounded-lg shadow-lg dark:text-gray-400 border-l-4 pointer-events-auto transform transition-all duration-300 ease-in-out"
|
||||
:class="[typeClasses[props.toast.type], isLeaving ? 'opacity-0 translate-x-full' : 'opacity-100 translate-x-0']"
|
||||
role="alert"
|
||||
>
|
||||
<div class="inline-flex items-center justify-center flex-shrink-0 w-8 h-8 rounded-lg" :class="iconBgClasses[props.toast.type]">
|
||||
<component :is="icons[props.toast.type]" class="w-5 h-5" :class="iconColorClasses[props.toast.type]" />
|
||||
</div>
|
||||
<div class="ml-3 text-sm font-medium pr-4">{{ props.toast.message }}</div>
|
||||
<button
|
||||
type="button"
|
||||
class="ml-auto -mx-1.5 -my-1.5 bg-white text-gray-400 hover:text-gray-900 rounded-lg focus:ring-2 focus:ring-gray-300 p-1.5 hover:bg-gray-100 inline-flex h-8 w-8 dark:text-gray-500 dark:hover:text-white dark:bg-gray-800 dark:hover:bg-gray-700"
|
||||
@click="dismiss"
|
||||
aria-label="Close"
|
||||
>
|
||||
<span class="sr-only">Close</span>
|
||||
<svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, h } from 'vue';
|
||||
import type { Toast } from '@/composables/useToast';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
|
||||
// Simple functional components for icons using h (avoid runtime compiler)
|
||||
const SuccessIcon = {
|
||||
render: () =>
|
||||
h(
|
||||
'svg',
|
||||
{
|
||||
'aria-hidden': 'true',
|
||||
xmlns: 'http://www.w3.org/2000/svg',
|
||||
fill: 'currentColor',
|
||||
viewBox: '0 0 20 20',
|
||||
},
|
||||
[h('path', { d: 'M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5Zm3.707 8.207-4 4a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L9 10.586l3.293-3.293a1 1 0 0 1 1.414 1.414Z' })]
|
||||
),
|
||||
};
|
||||
|
||||
const ErrorIcon = {
|
||||
render: () =>
|
||||
h(
|
||||
'svg',
|
||||
{
|
||||
'aria-hidden': 'true',
|
||||
xmlns: 'http://www.w3.org/2000/svg',
|
||||
fill: 'currentColor',
|
||||
viewBox: '0 0 20 20',
|
||||
},
|
||||
[h('path', { d: 'M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5Zm3.707 11.793a1 1 0 1 1-1.414 1.414L10 11.414l-2.293 2.293a1 1 0 0 1-1.414-1.414L8.586 10 6.293 7.707a1 1 0 0 1 1.414-1.414L10 8.586l2.293-2.293a1 1 0 0 1 1.414 1.414L11.414 10l2.293 2.293Z' })]
|
||||
),
|
||||
};
|
||||
|
||||
const InfoIcon = {
|
||||
render: () =>
|
||||
h(
|
||||
'svg',
|
||||
{
|
||||
'aria-hidden': 'true',
|
||||
xmlns: 'http://www.w3.org/2000/svg',
|
||||
fill: 'currentColor',
|
||||
viewBox: '0 0 20 20',
|
||||
},
|
||||
[h('path', { d: 'M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5ZM9.5 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3ZM12 15H8a1 1 0 0 1 0-2h1v-3H8a1 1 0 0 1 0-2h2a1 1 0 0 1 1 1v4h1a1 1 0 0 1 0 2Z' })]
|
||||
),
|
||||
};
|
||||
|
||||
const WarningIcon = {
|
||||
render: () =>
|
||||
h(
|
||||
'svg',
|
||||
{
|
||||
'aria-hidden': 'true',
|
||||
xmlns: 'http://www.w3.org/2000/svg',
|
||||
fill: 'currentColor',
|
||||
viewBox: '0 0 20 20',
|
||||
},
|
||||
[h('path', { d: 'M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5ZM10 15a1 1 0 1 1 0-2 1 1 0 0 1 0 2Zm1-4a1 1 0 0 1-2 0V6a1 1 0 0 1 2 0v5Z' })]
|
||||
),
|
||||
};
|
||||
|
||||
const props = defineProps<{
|
||||
toast: Toast;
|
||||
}>();
|
||||
|
||||
const { removeToast } = useToast();
|
||||
const isLeaving = ref(false);
|
||||
|
||||
const dismiss = () => {
|
||||
isLeaving.value = true;
|
||||
setTimeout(() => {
|
||||
removeToast(props.toast.id);
|
||||
}, 300); // Match transition duration
|
||||
};
|
||||
|
||||
const icons = {
|
||||
success: SuccessIcon,
|
||||
error: ErrorIcon,
|
||||
info: InfoIcon,
|
||||
warning: WarningIcon,
|
||||
};
|
||||
|
||||
const typeClasses = {
|
||||
success: 'border-green-500 bg-white dark:bg-gray-800',
|
||||
error: 'border-red-500 bg-white dark:bg-gray-800',
|
||||
info: 'border-blue-500 bg-white dark:bg-gray-800',
|
||||
warning: 'border-orange-500 bg-white dark:bg-gray-800',
|
||||
};
|
||||
|
||||
const iconBgClasses = {
|
||||
success: 'bg-green-100 dark:bg-green-800',
|
||||
error: 'bg-red-100 dark:bg-red-800',
|
||||
info: 'bg-blue-100 dark:bg-blue-800',
|
||||
warning: 'bg-orange-100 dark:bg-orange-800',
|
||||
};
|
||||
|
||||
const iconColorClasses = {
|
||||
success: 'text-green-500 dark:text-green-200',
|
||||
error: 'text-red-500 dark:text-red-200',
|
||||
info: 'text-blue-500 dark:text-blue-200',
|
||||
warning: 'text-orange-500 dark:text-orange-200',
|
||||
};
|
||||
</script>
|
||||
Reference in New Issue
Block a user