mirror of
https://github.com/versia-pub/frontend.git
synced 2025-12-06 08:28:20 +01:00
feat: ✨ Implement new confirmation modal utility
This commit is contained in:
parent
0fe13cbeee
commit
73bfbcf252
2
app.vue
2
app.vue
|
|
@ -10,6 +10,7 @@
|
||||||
<NuxtPage />
|
<NuxtPage />
|
||||||
</NuxtLayout>
|
</NuxtLayout>
|
||||||
<NotificationsRenderer />
|
<NotificationsRenderer />
|
||||||
|
<ConfirmationModal />
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -19,6 +20,7 @@ import { convert } from "html-to-text";
|
||||||
import "iconify-icon";
|
import "iconify-icon";
|
||||||
import NotificationsRenderer from "./components/notifications/notifications-renderer.vue";
|
import NotificationsRenderer from "./components/notifications/notifications-renderer.vue";
|
||||||
import { SettingIds } from "./settings";
|
import { SettingIds } from "./settings";
|
||||||
|
import ConfirmationModal from "./components/modals/confirmation.vue";
|
||||||
// Use SSR-safe IDs for Headless UI
|
// Use SSR-safe IDs for Headless UI
|
||||||
provideHeadlessUseId(() => useId());
|
provideHeadlessUseId(() => useId());
|
||||||
|
|
||||||
|
|
|
||||||
25
components/modals/composable.ts
Normal file
25
components/modals/composable.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import {
|
||||||
|
confirmModalService,
|
||||||
|
confirmModalWithInputService,
|
||||||
|
} from "./service.ts";
|
||||||
|
import type { ConfirmModalOptions, ConfirmModalResult } from "./types.ts";
|
||||||
|
|
||||||
|
export function useConfirmModal() {
|
||||||
|
const confirm = (
|
||||||
|
options: ConfirmModalOptions,
|
||||||
|
): Promise<ConfirmModalResult> => {
|
||||||
|
return confirmModalService.confirm(options);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmWithInput = (
|
||||||
|
options: ConfirmModalOptions,
|
||||||
|
placeholder?: string,
|
||||||
|
): Promise<ConfirmModalResult> => {
|
||||||
|
return confirmModalWithInputService.confirm(options, placeholder);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
confirm,
|
||||||
|
confirmWithInput,
|
||||||
|
};
|
||||||
|
}
|
||||||
124
components/modals/confirmation.vue
Normal file
124
components/modals/confirmation.vue
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
<template>
|
||||||
|
<HeadlessTransitionRoot as="template" :show="isOpen">
|
||||||
|
<Dialog.Root :open="isOpen" @update:open="handleOpenChange" :close-on-escape="true"
|
||||||
|
:close-on-interact-outside="true">
|
||||||
|
<Teleport to="body">
|
||||||
|
<Dialog.Positioner class="fixed inset-0 z-50 flex items-end md:items-center justify-center md:p-4">
|
||||||
|
<HeadlessTransitionChild as="template" enter="ease-out duration-300" enter-from="opacity-0"
|
||||||
|
enter-to="opacity-100" leave="ease-in duration-300" leave-from="opacity-100"
|
||||||
|
leave-to="opacity-0">
|
||||||
|
<Dialog.Backdrop class="fixed inset-0 bg-black/70 backdrop-blur-sm" />
|
||||||
|
</HeadlessTransitionChild>
|
||||||
|
|
||||||
|
<HeadlessTransitionChild as="template" enter="ease-out duration-300" enter-from="opacity-0 scale-95"
|
||||||
|
enter-to="opacity-100 scale-100" leave="ease-in duration-300" leave-from="opacity-100 scale-100"
|
||||||
|
leave-to="opacity-0 scale-95">
|
||||||
|
<Dialog.Content class="relative w-full md:max-w-md p-6 rounded bg-dark-800 ring-1 ring-white/10 shadow-xl">
|
||||||
|
<Dialog.Title class="mb-4 text-lg font-bold tracking-tight text-gray-100 sm:text-xl">
|
||||||
|
{{ modalOptions.title || 'Confirm Action' }}
|
||||||
|
</Dialog.Title>
|
||||||
|
|
||||||
|
<div class="mb-6 text-gray-300">
|
||||||
|
{{ modalOptions.message }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="withInput" class="mb-4">
|
||||||
|
<input v-model="inputValue" type="text"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
:placeholder="inputPlaceholder" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-10 grid grid-cols-1 md:grid-cols-2 gap-3 *:!py-2">
|
||||||
|
<Button @click="handleCancel"
|
||||||
|
theme="outline">
|
||||||
|
{{ modalOptions.cancelText || 'Cancel' }}
|
||||||
|
</button>
|
||||||
|
<Button @click="handleConfirm"
|
||||||
|
theme="primary">
|
||||||
|
{{ modalOptions.confirmText || 'Confirm' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Dialog.Content>
|
||||||
|
</HeadlessTransitionChild>
|
||||||
|
</Dialog.Positioner>
|
||||||
|
</Teleport>
|
||||||
|
</Dialog.Root>
|
||||||
|
</HeadlessTransitionRoot>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Dialog } from "@ark-ui/vue";
|
||||||
|
import {
|
||||||
|
confirmModalService,
|
||||||
|
confirmModalWithInputService,
|
||||||
|
} from "./service.ts";
|
||||||
|
import type { ConfirmModalOptions, ConfirmModalResult } from "./types.ts";
|
||||||
|
import Button from "~/packages/ui/components/buttons/button.vue";
|
||||||
|
|
||||||
|
const isOpen = ref(false);
|
||||||
|
const modalOptions = ref<ConfirmModalOptions>({ message: "" });
|
||||||
|
const resolvePromise = ref<((result: ConfirmModalResult) => void) | null>(null);
|
||||||
|
const inputValue = ref("");
|
||||||
|
const withInput = ref(false);
|
||||||
|
const inputPlaceholder = ref("");
|
||||||
|
|
||||||
|
const open = async (
|
||||||
|
options: ConfirmModalOptions,
|
||||||
|
): Promise<ConfirmModalResult> => {
|
||||||
|
modalOptions.value = options;
|
||||||
|
isOpen.value = true;
|
||||||
|
withInput.value = false;
|
||||||
|
inputValue.value = "";
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
resolvePromise.value = resolve;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const openWithInput = async (
|
||||||
|
options: ConfirmModalOptions,
|
||||||
|
placeholder = "Enter value",
|
||||||
|
): Promise<ConfirmModalResult> => {
|
||||||
|
modalOptions.value = options;
|
||||||
|
isOpen.value = true;
|
||||||
|
withInput.value = true;
|
||||||
|
inputValue.value = "";
|
||||||
|
inputPlaceholder.value = placeholder;
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
resolvePromise.value = resolve;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (resolvePromise.value) {
|
||||||
|
resolvePromise.value({
|
||||||
|
confirmed: true,
|
||||||
|
value: withInput.value ? inputValue.value : undefined,
|
||||||
|
});
|
||||||
|
isOpen.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
if (resolvePromise.value) {
|
||||||
|
resolvePromise.value({ confirmed: false });
|
||||||
|
isOpen.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenChange = (open: boolean) => {
|
||||||
|
if (!open && resolvePromise.value) {
|
||||||
|
resolvePromise.value({ confirmed: false });
|
||||||
|
isOpen.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Register the component with the service
|
||||||
|
confirmModalService.register({
|
||||||
|
open,
|
||||||
|
});
|
||||||
|
confirmModalWithInputService.register({
|
||||||
|
open: openWithInput,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
52
components/modals/service.ts
Normal file
52
components/modals/service.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { ref } from "vue";
|
||||||
|
import type { ConfirmModalOptions, ConfirmModalResult } from "./types.ts";
|
||||||
|
|
||||||
|
class ConfirmModalService {
|
||||||
|
private modalRef = ref<{
|
||||||
|
open: (options: ConfirmModalOptions) => Promise<ConfirmModalResult>;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
register(modal: {
|
||||||
|
open: (options: ConfirmModalOptions) => Promise<ConfirmModalResult>;
|
||||||
|
}) {
|
||||||
|
this.modalRef.value = modal;
|
||||||
|
}
|
||||||
|
|
||||||
|
confirm(options: ConfirmModalOptions): Promise<ConfirmModalResult> {
|
||||||
|
if (!this.modalRef.value) {
|
||||||
|
throw new Error("Confirmation modal not initialized");
|
||||||
|
}
|
||||||
|
return this.modalRef.value.open(options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ConfirmModalWithInputService {
|
||||||
|
private modalRef = ref<{
|
||||||
|
open: (
|
||||||
|
options: ConfirmModalOptions,
|
||||||
|
placeholder?: string,
|
||||||
|
) => Promise<ConfirmModalResult>;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
register(modal: {
|
||||||
|
open: (
|
||||||
|
options: ConfirmModalOptions,
|
||||||
|
placeholder?: string,
|
||||||
|
) => Promise<ConfirmModalResult>;
|
||||||
|
}) {
|
||||||
|
this.modalRef.value = modal;
|
||||||
|
}
|
||||||
|
|
||||||
|
confirm(
|
||||||
|
options: ConfirmModalOptions,
|
||||||
|
placeholder?: string,
|
||||||
|
): Promise<ConfirmModalResult> {
|
||||||
|
if (!this.modalRef.value) {
|
||||||
|
throw new Error("Confirmation modal not initialized");
|
||||||
|
}
|
||||||
|
return this.modalRef.value.open(options, placeholder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const confirmModalService = new ConfirmModalService();
|
||||||
|
export const confirmModalWithInputService = new ConfirmModalWithInputService();
|
||||||
11
components/modals/types.ts
Normal file
11
components/modals/types.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
export interface ConfirmModalOptions {
|
||||||
|
title?: string;
|
||||||
|
message: string;
|
||||||
|
confirmText?: string;
|
||||||
|
cancelText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfirmModalResult {
|
||||||
|
confirmed: boolean;
|
||||||
|
value?: string;
|
||||||
|
}
|
||||||
|
|
@ -61,14 +61,14 @@
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item value="" v-if="identity">
|
<Menu.Item value="" v-if="identity">
|
||||||
<NuxtLink href="/settings" class="w-full">
|
<NuxtLink href="/settings" class="w-full">
|
||||||
<ButtonBase theme="outline" class="w-full !justify-start">
|
<ButtonBase theme="ghost" class="w-full !justify-start">
|
||||||
<Icon icon="tabler:adjustments" class="!size-6" />
|
<Icon icon="tabler:adjustments" class="!size-6" />
|
||||||
<span class="shrink-0 line-clamp-1">Settings</span>
|
<span class="shrink-0 line-clamp-1">Settings</span>
|
||||||
</ButtonBase>
|
</ButtonBase>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item value="">
|
<Menu.Item value="">
|
||||||
<ButtonBase @click="$emit('signIn')" theme="outline" class="w-full !justify-start">
|
<ButtonBase @click="$emit('signIn')" theme="ghost" class="w-full !justify-start">
|
||||||
<Icon icon="tabler:user-plus" class="!size-6" />
|
<Icon icon="tabler:user-plus" class="!size-6" />
|
||||||
<span class="shrink-0 line-clamp-1">Add new account</span>
|
<span class="shrink-0 line-clamp-1">Add new account</span>
|
||||||
</ButtonBase>
|
</ButtonBase>
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
Timelines</h3>
|
Timelines</h3>
|
||||||
|
|
||||||
<NuxtLink v-for="timeline in visibleTimelines" :key="timeline.href" :to="timeline.href">
|
<NuxtLink v-for="timeline in visibleTimelines" :key="timeline.href" :to="timeline.href">
|
||||||
<ButtonBase theme="outline" class="w-full !justify-start overflow-hidden rounded-sm">
|
<ButtonBase theme="ghost" class="w-full !justify-start overflow-hidden rounded-sm">
|
||||||
<Icon :icon="timeline.icon" class="!size-6" />
|
<Icon :icon="timeline.icon" class="!size-6" />
|
||||||
<span class="shrink-0 line-clamp-1">{{ timeline.name }}</span>
|
<span class="shrink-0 line-clamp-1">{{ timeline.name }}</span>
|
||||||
</ButtonBase>
|
</ButtonBase>
|
||||||
|
|
@ -28,7 +28,7 @@
|
||||||
<AccountPicker @sign-in="signIn().finally(() => loadingAuth = false)"
|
<AccountPicker @sign-in="signIn().finally(() => loadingAuth = false)"
|
||||||
@sign-out="id => signOut(id).finally(() => loadingAuth = false)" />
|
@sign-out="id => signOut(id).finally(() => loadingAuth = false)" />
|
||||||
<NuxtLink href="/register" v-if="!identity">
|
<NuxtLink href="/register" v-if="!identity">
|
||||||
<ButtonBase theme="outline" class="w-full !justify-start overflow-hidden rounded-sm">
|
<ButtonBase theme="ghost" class="w-full !justify-start overflow-hidden rounded-sm">
|
||||||
<Icon icon="tabler:certificate" class="!size-6" />
|
<Icon icon="tabler:certificate" class="!size-6" />
|
||||||
<span class="shrink-0 line-clamp-1">Register</span>
|
<span class="shrink-0 line-clamp-1">Register</span>
|
||||||
</ButtonBase>
|
</ButtonBase>
|
||||||
|
|
@ -102,10 +102,10 @@ import ButtonBase from "~/packages/ui/components/buttons/button.vue";
|
||||||
import Icon from "~/packages/ui/components/icons/icon.vue";
|
import Icon from "~/packages/ui/components/icons/icon.vue";
|
||||||
import ButtonDropdown from "../buttons/button-dropdown.vue";
|
import ButtonDropdown from "../buttons/button-dropdown.vue";
|
||||||
import ButtonMobileNavbar from "../buttons/button-mobile-navbar.vue";
|
import ButtonMobileNavbar from "../buttons/button-mobile-navbar.vue";
|
||||||
import Button from "../composer/button.vue";
|
|
||||||
import AdaptiveDropdown from "../dropdowns/AdaptiveDropdown.vue";
|
import AdaptiveDropdown from "../dropdowns/AdaptiveDropdown.vue";
|
||||||
import AccountPicker from "./account-picker.vue";
|
import AccountPicker from "./account-picker.vue";
|
||||||
const { $pwa } = useNuxtApp();
|
const { $pwa } = useNuxtApp();
|
||||||
|
|
||||||
const timelines = ref([
|
const timelines = ref([
|
||||||
{
|
{
|
||||||
href: "/home",
|
href: "/home",
|
||||||
|
|
|
||||||
|
|
@ -14,14 +14,15 @@ import type { ButtonHTMLAttributes } from "vue";
|
||||||
|
|
||||||
const themes = {
|
const themes = {
|
||||||
primary:
|
primary:
|
||||||
"[--btn-border:theme(colors.primary.950/90%)] [--btn-bg:theme(colors.primary.600)] [--btn-hover-overlay:theme(colors.white/30%)] [--btn-icon:theme(colors.primary.200)] active:[--btn-icon:theme(colors.primary.100)] hover:[--btn-icon:theme(colors.primary.100)] after:shadow-[shadow:inset_0_1px_theme(colors.white/15%)] border border-white/5",
|
"[--btn-bg:theme(colors.primary.600)] [--btn-hover-overlay:theme(colors.white/30%)] [--btn-icon:theme(colors.primary.200)] active:[--btn-icon:theme(colors.primary.100)] hover:[--btn-icon:theme(colors.primary.100)] after:shadow-[shadow:inset_0_1px_theme(colors.white/15%)] border border-white/5",
|
||||||
secondary:
|
secondary:
|
||||||
"[--btn-border:theme(colors.zinc.950/90%)] [--btn-bg:theme(colors.zinc.800)] [--btn-hover-overlay:theme(colors.white/5%)] [--btn-icon:theme(colors.zinc.400)] active:[--btn-icon:theme(colors.zinc.300)] hover:[--btn-icon:theme(colors.zinc.300)] after:shadow-[shadow:inset_0_1px_theme(colors.white/15%)] border border-white/5",
|
"[--btn-bg:theme(colors.zinc.800)] [--btn-hover-overlay:theme(colors.white/5%)] [--btn-icon:theme(colors.zinc.400)] active:[--btn-icon:theme(colors.zinc.300)] hover:[--btn-icon:theme(colors.zinc.300)] after:shadow-[shadow:inset_0_1px_theme(colors.white/15%)] border border-white/5",
|
||||||
// Gradient: bg-gradient-to-tr from-primary-300 via-purple-300 to-indigo-400
|
// Gradient: bg-gradient-to-tr from-primary-300 via-purple-300 to-indigo-400
|
||||||
gradient:
|
gradient:
|
||||||
"bg-[image:--btn-bg] before:bg-[image:--btn-bg] [--btn-border:theme(colors.primary.950/90%)] [--btn-bg:linear-gradient(to_right,theme(colors.primary.300),theme(colors.purple.300),theme(colors.indigo.400))] [--btn-hover-overlay:theme(colors.white/10%)] [--btn-icon:theme(colors.gray.100)] active:[--btn-icon:theme(colors.gray.50)] hover:[--btn-icon:theme(colors.gray.50)] after:shadow-[shadow:inset_0_1px_theme(colors.white/15%)] [&>[data-spinner]]:bg-[image:--btn-bg]",
|
"bg-[image:--btn-bg] before:bg-[image:--btn-bg] [--btn-bg:linear-gradient(to_right,theme(colors.primary.300),theme(colors.purple.300),theme(colors.indigo.400))] [--btn-hover-overlay:theme(colors.white/10%)] [--btn-icon:theme(colors.gray.100)] active:[--btn-icon:theme(colors.gray.50)] hover:[--btn-icon:theme(colors.gray.50)] after:shadow-[shadow:inset_0_1px_theme(colors.white/15%)] [&>[data-spinner]]:bg-[image:--btn-bg]",
|
||||||
outline:
|
outline:
|
||||||
"[--btn-border:theme(colors.zinc.950/90%)] [--btn-bg:transparent] [--btn-hover-overlay:theme(colors.white/5%)] [--btn-icon:theme(colors.zinc.200)] active:[--btn-icon:theme(colors.zinc.300)] hover:[--btn-icon:theme(colors.zinc.300)] hover:ring-1 ring-white/5",
|
"[--btn-bg:transparent] [--btn-hover-overlay:theme(colors.white/5%)] [--btn-icon:theme(colors.zinc.200)] active:[--btn-icon:theme(colors.zinc.300)] hover:[--btn-icon:theme(colors.zinc.300)] ring-1 ring-white/5",
|
||||||
|
ghost: "[--btn-bg:transparent] [--btn-hover-overlay:theme(colors.white/5%)] [--btn-icon:theme(colors.zinc.200)] active:[--btn-icon:theme(colors.zinc.300)] hover:[--btn-icon:theme(colors.zinc.300)] hover:ring-1 ring-white/5",
|
||||||
};
|
};
|
||||||
|
|
||||||
interface Props extends /* @vue-ignore */ ButtonHTMLAttributes {}
|
interface Props extends /* @vue-ignore */ ButtonHTMLAttributes {}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
"extends": "./.nuxt/tsconfig.json",
|
"extends": "./.nuxt/tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "esnext",
|
"target": "esnext",
|
||||||
"module": "esnext"
|
"module": "esnext",
|
||||||
|
"allowImportingTsExtensions": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue