[FEAT] Toastr and saving UX improvements

This commit is contained in:
2026-01-02 20:35:50 +01:00
parent cabc1f747f
commit 009f8810e0
7 changed files with 234 additions and 8 deletions

View File

@@ -13,6 +13,7 @@
<HelpModal :is-open="isHelpModalOpen" @close="closeHelpModal" />
<FeedbackModal :is-open="isFeedbackModalOpen" @close="closeFeedbackModal" />
<ToastContainer />
<!-- One-time feedback popup -->
<div v-if="showFeedbackPopup" class="fixed bottom-6 right-6 z-50 flex items-center justify-center">
@@ -43,6 +44,7 @@
import HelpModal from './components/HelpModal.vue';
import FeedbackModal from './components/FeedbackModal.vue';
import Breadcrumbs from './components/Breadcrumbs.vue';
import ToastContainer from './components/utilities/ToastContainer.vue';
const isHelpModalOpen = ref(false);
const isFeedbackModalOpen = ref(false);

View File

@@ -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) {
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;
}
};

View File

@@ -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']);

View File

@@ -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>

View 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>

View 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>

View File

@@ -0,0 +1,39 @@
import { ref } from 'vue';
export type ToastType = 'success' | 'error' | 'info' | 'warning';
export interface Toast {
id: string;
message: string;
type: ToastType;
duration?: number;
}
const toasts = ref<Toast[]>([]);
export function useToast() {
const addToast = (message: string, type: ToastType = 'info', duration: number = 3000) => {
const id = Date.now().toString(36) + Math.random().toString(36).substr(2);
const toast: Toast = { id, message, type, duration };
toasts.value.push(toast);
if (duration > 0) {
setTimeout(() => {
removeToast(id);
}, duration);
}
};
const removeToast = (id: string) => {
const index = toasts.value.findIndex(t => t.id === id);
if (index !== -1) {
toasts.value.splice(index, 1);
}
};
return {
toasts,
addToast,
removeToast,
};
}