feat: add useAsync and implement it on every async mutation method

This commit is contained in:
mrjvs
2026-08-16 12:44:35 +02:00
parent cc5f85485c
commit bae28ef863
12 changed files with 236 additions and 196 deletions

View File

@@ -2,6 +2,15 @@
import { Toast } from 'reka-ui/namespaced';
const toastStore = useToasts();
const route = useRoute();
const routeKey = computed(() => route.path);
watch(routeKey, () => {
toastStore.clear(); // Clear toasts when route changes
});
onUnmounted(() => {
toastStore.clear(); // Clear toasts toast rendered gets removed (route change)
});
function handleOpenUpdate(id: number, open: boolean) {
if (!open) {

View File

@@ -0,0 +1,43 @@
import { useAsyncState } from '@vueuse/core';
export type UseAsyncOptions<TResult, TArgs extends any[] = []> = {
handler(...args: TArgs): Promise<TResult>;
onSuccess?: (data: TResult) => void;
onError?: (error: any) => void;
allowParallel?: boolean;
};
export type UseAsyncResult<TResult, TArgs extends any[] = []> = {
state: Ref<TResult | null>;
isLoading: Ref<boolean>;
error: Ref<unknown>;
execute: (...args: TArgs) => Promise<void>;
};
export function useAsync<TResult, TArgs extends any[] = []>(ops: UseAsyncOptions<TResult, TArgs>): UseAsyncResult<TResult, TArgs> {
const preventDuringLoading = !ops.allowParallel;
const output = useAsyncState<TResult | null, TArgs, true>(ops.handler, null, {
immediate: false,
onSuccess(data) {
ops.onSuccess?.(data as any);
},
onError(err) {
ops.onError?.(err);
}
});
async function execute(...args: TArgs) {
if (preventDuringLoading && output.isLoading.value) {
return;
}
await output.executeImmediate(...args);
}
return {
state: output.state,
isLoading: output.isLoading,
error: output.error,
execute
};
}

View File

@@ -18,6 +18,7 @@ useHead({
id="root"
class="main-body"
>
<ToastRenderer />
<div class="docs-wrapper">
<a
href="/"

View File

@@ -1,5 +1,6 @@
<template>
<div id="root">
<ToastRenderer />
<slot />
</div>
</template>

View File

@@ -2,17 +2,13 @@
import type { ApiAuthForgotPasswordRequest } from '~~/shared/api-types';
const { t } = useI18n();
const toasts = useToasts();
const captchaRef = useTemplateRef('captcha');
const form = reactive({ emailOrUsername: '' });
const errorMessage = ref<string | null>();
const successMessage = ref<string | null>();
const captchaRef = useTemplateRef('captcha');
async function submit() {
errorMessage.value = null;
successMessage.value = null;
try {
const { execute } = useAsync({
async handler() {
let captchaResponse: string | null = null;
if (captchaRef.value) {
captchaResponse = await captchaRef.value.getToken();
@@ -28,21 +24,21 @@ async function submit() {
captchaResponse: captchaResponse ?? undefined
} satisfies ApiAuthForgotPasswordRequest
});
// Success
form.emailOrUsername = '';
successMessage.value = 'An email has been sent.';
setTimeout(() => {
successMessage.value = null;
}, 5000);
} catch (error: unknown) {
},
onSuccess() {
toasts.publish({
type: 'success',
text: 'An email has been sent.'
});
},
onError(error) {
const err = getApiError(error);
errorMessage.value = err.code;
setTimeout(() => {
errorMessage.value = null;
}, 5000);
toasts.publish({
type: 'error',
text: err.message
});
}
}
});
</script>
<template>
@@ -50,7 +46,7 @@ async function submit() {
<div class="account-form-wrapper">
<form
class="account forgot-password"
@submit.prevent="submit"
@submit.prevent="execute"
>
<h2>{{ t('account.forgotPassword.header') }}</h2>
<p>{{ t('account.forgotPassword.sub') }}</p>
@@ -77,22 +73,6 @@ async function submit() {
</div>
</form>
</div>
<div
v-if="successMessage"
class="banner-notice success"
>
<div>
<p>{{ successMessage }}</p>
</div>
</div>
<div
v-if="errorMessage"
class="banner-notice error"
>
<div>
<p>{{ errorMessage }}</p>
</div>
</div>
</div>
</template>

View File

@@ -1,30 +1,36 @@
<script setup lang="ts">
/* eslint-disable vue/no-v-html -- locale files still have raw html */
import {
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogRoot,
AlertDialogTitle,
AlertDialogTrigger, ToastDescription, ToastProvider, ToastRoot, ToastViewport
} from 'reka-ui';
import { watchImmediate } from '@vueuse/core';
import { AlertDialog } from 'reka-ui/namespaced';
import type { ApiAccountUpdateRequest } from '~~/shared/api-types';
const authStore = useAuthStore();
const toasts = useToasts();
const route = useRoute();
definePageMeta({
needsAuth: true
});
const upgradeSuccess = ref(useRoute().query.upgrade_success === 'true');
const upgradeError = ref(useRoute().query.upgrade_success === 'false');
const showToast = computed(() => upgradeSuccess.value || upgradeError.value);
if (showToast.value) {
const upgradeSuccessQuery = computed(() => route.query.upgrade_success);
watchImmediate(upgradeSuccessQuery, (val) => {
if (!val) {
return;
}
if (val === 'true') {
toasts.publish({
type: 'success',
text: 'Account upgraded successfully'
});
}
if (val === 'false') {
toasts.publish({
type: 'error',
text: 'Account upgrade failed'
});
}
useRouter().replace({ query: {} });
}
});
const authStore = useAuthStore();
const { data: profile, refresh } = await useApiFetch('/api/auth/me');
const { data: connections, refresh: refreshConnections } = await useApiFetch('/api/auth/me-connections');
@@ -33,10 +39,8 @@ const dialogContainer = ref(null);
const deleteModalOpen = ref(false);
const editModalOpen = ref(false);
async function updateServerEnvironment(
env: ApiAccountUpdateRequest['environment']
) {
try {
const { execute: executeUpdateServerEnvironment } = useAsync({
async handler(env: ApiAccountUpdateRequest['environment']) {
await apiFetch('/api/account/update', {
method: 'PATCH',
body: {
@@ -44,49 +48,65 @@ async function updateServerEnvironment(
} satisfies ApiAccountUpdateRequest
});
await refresh();
} catch (error: unknown) {
},
onError(error) {
const err = getApiError(error);
alert(err.code);
toasts.publish({
type: 'error',
text: err.message
});
}
}
});
async function deleteAccount() {
try {
const { execute: executeDeleteAccount } = useAsync({
async handler() {
await apiFetch('/api/account/delete', {
method: 'POST'
});
authStore.logout();
await navigateTo('/');
} catch (error: unknown) {
},
onError(error) {
const err = getApiError(error);
alert(err.code);
toasts.publish({
type: 'error',
text: err.message
});
}
}
});
async function linkDiscord() {
try {
const { execute: executeLinkDiscord } = useAsync({
async handler() {
const result = await apiFetch('/api/account/discord-link', {
method: 'GET'
});
await navigateTo(result.url, { external: true });
} catch (error: unknown) {
},
onError(error) {
const err = getApiError(error);
alert(err.code);
toasts.publish({
type: 'error',
text: err.message
});
}
}
});
async function unlinkDiscord() {
try {
const { execute: executeUnlinkDiscord } = useAsync({
async handler() {
await apiFetch('/api/account/discord-unlink', {
method: 'POST'
});
await refresh();
await refreshConnections();
} catch (error: unknown) {
},
onError(error) {
const err = getApiError(error);
alert(err.code);
toasts.publish({
type: 'error',
text: err.message
});
}
}
});
useHead({
title: `Account`
@@ -152,18 +172,18 @@ useHead({
{{ $t("account.settings.upgrade") }}
</p>
</NuxtLink>
<AlertDialogRoot v-model:open="deleteModalOpen">
<AlertDialogTrigger
<AlertDialog.Root v-model:open="deleteModalOpen">
<AlertDialog.Trigger
id="account-delete"
class="secondary"
>
{{ $t("account.settings.delete.button") }}
</AlertDialogTrigger>
<AlertDialogPortal :to="dialogContainer ?? undefined">
<AlertDialogOverlay />
<AlertDialogContent class="modal">
<AlertDialogTitle>{{ $t("account.settings.delete.modalTitle") }}?</AlertDialogTitle>
<AlertDialogDescription class="modal-caption">
</AlertDialog.Trigger>
<AlertDialog.Portal :to="dialogContainer ?? undefined">
<AlertDialog.Overlay />
<AlertDialog.Content class="modal">
<AlertDialog.Title>{{ $t("account.settings.delete.modalTitle") }}?</AlertDialog.Title>
<AlertDialog.Description class="modal-caption">
<p
style="white-space: pre-line;"
>
@@ -172,24 +192,24 @@ useHead({
<p class="noundo">
{{ $t('account.settings.delete.modalCaution') }}
</p>
</AlertDialogDescription>
</AlertDialog.Description>
<div class="modal-button-wrapper">
<AlertDialogCancel class="cancel">
<AlertDialog.Cancel class="cancel">
{{ $t("modals.cancel") }}
</AlertDialogCancel>
<AlertDialogAction
class="alert"
@click="deleteAccount"
</AlertDialog.Cancel>
<AlertDialog.Action
class="alert."
@click="executeDeleteAccount"
>
{{ $t("account.settings.delete.modalConfirm") }}
</AlertDialogAction>
</AlertDialog.Action>
</div>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialogRoot>
</AlertDialog.Content>
</AlertDialog.Portal>
</AlertDialog.Root>
</div>
</div>
<AlertDialogRoot v-model:open="editModalOpen">
<AlertDialog.Root v-model:open="editModalOpen">
<div class="settings-wrapper">
<h2
id="user-settings"
@@ -202,30 +222,30 @@ useHead({
{{ $t("account.settings.settingCards.profile") }}
</h2>
<AlertDialogTrigger
<AlertDialog.Trigger
class="edit"
>
<Icon
name="ph:pencil"
size="26"
/>
</AlertDialogTrigger>
<AlertDialogPortal :to="dialogContainer ?? undefined">
<AlertDialogOverlay />
<AlertDialogContent class="modal">
<AlertDialogTitle>{{ $t("account.settings.unavailable") }}.</AlertDialogTitle>
<AlertDialogDescription class="modal-caption">
</AlertDialog.Trigger>
<AlertDialog.Portal :to="dialogContainer ?? undefined">
<AlertDialog.Overlay />
<AlertDialog.Content class="modal">
<AlertDialog.Title>{{ $t("account.settings.unavailable") }}.</AlertDialog.Title>
<AlertDialog.Description class="modal-caption">
<p>
{{ $t('account.settings.settingCards.no_edit_from_dashboard') }}
</p>
</AlertDialogDescription>
</AlertDialog.Description>
<div class="modal-button-wrapper">
<AlertDialogCancel class="cancel">
<AlertDialog.Cancel class="cancel">
{{ $t("modals.close") }}
</AlertDialogCancel>
</AlertDialog.Cancel>
</div>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialog.Content>
</AlertDialog.Portal>
<ul class="setting-list">
<li>
<p class="label">
@@ -316,7 +336,7 @@ useHead({
"
id="save-server-selection"
class="button secondary"
@click.prevent="() => updateServerEnvironment(selectedServerEnv)"
@click.prevent="() => executeUpdateServerEnvironment(selectedServerEnv)"
>
Save
</button>
@@ -339,14 +359,14 @@ useHead({
<h2 class="header">
{{ $t("account.account") }}
</h2>
<AlertDialogTrigger
<AlertDialog.Trigger
class="edit"
>
<Icon
name="ph:pencil"
size="26"
/>
</AlertDialogTrigger>
</AlertDialog.Trigger>
<ul class="setting-list">
<li>
<p class="label">
@@ -403,7 +423,7 @@ useHead({
v-if="profile.discordId"
id="remove-discord-connection"
class="button secondary"
@click="unlinkDiscord"
@click="executeUnlinkDiscord"
>
{{ $t("account.settings.settingCards.removeDiscord") }}
</button>
@@ -411,7 +431,7 @@ useHead({
{{ $t("account.settings.settingCards.noDiscordLinked") }}
<NuxtLink
:style="{cursor: 'pointer'}"
@click="linkDiscord"
@click="executeLinkDiscord"
>
{{ $t("account.settings.settingCards.linkDiscord") }}
</NuxtLink>
@@ -431,36 +451,13 @@ useHead({
-->
</div>
</div>
</AlertDialogRoot>
</AlertDialog.Root>
<div
id="delete-account"
:class="{ 'modal-wrapper': true, hidden: !(deleteModalOpen || editModalOpen) }"
>
<div ref="dialogContainer" />
</div>
<div
v-if="showToast"
:class="{'banner-notice': true, success: upgradeSuccess, error: upgradeError }"
>
<ToastProvider>
<ToastRoot as="div">
<ToastDescription
v-if="upgradeSuccess"
as="p"
>
Account upgraded successfully
</ToastDescription>
<ToastDescription
v-if="upgradeError"
as="p"
>
Account upgrade failed
</ToastDescription>
</ToastRoot>
<ToastViewport as="p" />
</ToastProvider>
</div>
</div>
</template>

View File

@@ -16,8 +16,8 @@ const registerURI = computed(() => {
const loginForm = reactive({ username: '', password: '' });
async function loginSubmission() {
try {
const { execute } = useAsync({
async handler() {
const res = await $fetch('/api/auth/login', {
method: 'POST',
body: {
@@ -30,14 +30,15 @@ async function loginSubmission() {
refreshToken: res.refreshToken
});
await authUtils.safelyRedirectAfterLogin(redirect.value);
} catch (error: unknown) {
},
onError(error) {
const err = getApiError(error);
toasts.publish({
type: 'error',
text: err.message
});
}
}
});
</script>
<template>
@@ -45,7 +46,7 @@ async function loginSubmission() {
<div class="account-form-wrapper">
<form
class="account"
@submit.prevent="loginSubmission"
@submit.prevent="execute"
>
<h2>{{ $t("account.loginForm.login") }}</h2>
<p>{{ $t("account.loginForm.detailsPrompt") }}</p>

View File

@@ -37,7 +37,6 @@ const miiFaceUrl = ref<string>('');
const miiBodyUrl = ref<string>('');
const loadingCanvas = ref<boolean>(false);
// these are used in the save modal
const saving = ref<boolean>(false);
let oldMii: Mii | null = null;
let oldMiiNeutralUrl = '';
let oldMiiSorrowUrl = '';
@@ -277,34 +276,37 @@ function getSubPageFromValue(value: string, newSubTab: string, newTab: string) {
return subpageIndex;
}
async function handleSave() {
saving.value = true;
try {
await apiFetch('/api/account/update', {
const toasts = useToasts();
const { isLoading: isSaving, execute } = useAsync({
handler() {
return apiFetch('/api/account/update', {
method: 'PATCH',
body: {
mii: { name: mii.value.miiName, primary: 'Y', data: mii.value.encode().toString('base64') }
} satisfies ApiAccountUpdateRequest
});
},
onSuccess() {
toasts.publish({
type: 'success',
text: t('miiEditor.miiSaved')
});
setTimeout(() => {
// TODO - Make this prettier
alert(t('miiEditor.miiSaved'));
navigateTo('/account');
}, 3000);
// await refresh();
} catch (error: unknown) {
},
onError(error) {
const err = getApiError(error);
alert(`${err.code}: ${err.message}`);
toasts.publish({
type: 'error',
text: err.message
});
}
}
});
</script>
<template>
<div :class="{ 'miieditor-wrapper': true, saving: saving }">
<div :class="{ 'miieditor-wrapper': true, saving: isSaving }">
<svg
class="logotype"
xmlns="http://www.w3.org/2000/svg"
@@ -654,7 +656,7 @@ async function handleSave() {
<button
id="saveButton"
:class="{ button: true, primary: true }"
@click.prevent="handleSave()"
@click.prevent="execute()"
>
{{ $t("miiEditor.save") }}!
</button>

View File

@@ -19,8 +19,8 @@ const loginURI = computed(() => {
const registerForm = reactive({ email: '', username: '', mii_name: '', password: '', password_confirm: '' });
const captchaRef = useTemplateRef('captcha');
async function registerSubmission() {
try {
const { execute } = useAsync({
async handler() {
let captchaResponse: string | null = null;
if (captchaRef.value) {
captchaResponse = await captchaRef.value.getToken();
@@ -44,14 +44,15 @@ async function registerSubmission() {
refreshToken: res.refreshToken
});
await authUtils.safelyRedirectAfterLogin(redirect.value);
} catch (error: unknown) {
},
onError(error) {
const err = getApiError(error);
toasts.publish({
type: 'error',
text: err.message
});
}
}
});
</script>
<template>
@@ -59,7 +60,7 @@ async function registerSubmission() {
<div class="account-form-wrapper">
<form
class="account register"
@submit.prevent="registerSubmission"
@submit.prevent="execute"
>
<h2>{{ $t("account.loginForm.register") }}</h2>
<p>{{ $t("account.loginForm.detailsPrompt") }}</p>

View File

@@ -2,13 +2,13 @@
import type { ApiAuthResetPasswordRequest } from '~~/shared/api-types';
const route = useRoute();
const toasts = useToasts();
const { t } = useI18n();
const form = reactive({ password: '', passwordConfirm: '' });
const errorMessage = ref<string | null>();
async function submit() {
const resetToken = route.query.token?.toString();
try {
const { execute } = useAsync({
async handler() {
const resetToken = route.query.token?.toString();
if (!resetToken) {
throw new Error('No reset token provided');
}
@@ -21,14 +21,15 @@ async function submit() {
} satisfies ApiAuthResetPasswordRequest
});
await navigateTo('/account');
} catch (error: unknown) {
},
onError(error) {
const err = getApiError(error);
errorMessage.value = err.code;
setTimeout(() => { // TODO: replace this toast
errorMessage.value = null;
}, 5000);
toasts.publish({
type: 'error',
text: err.message
});
}
}
});
</script>
<template>
@@ -36,7 +37,7 @@ async function submit() {
<div class="account-form-wrapper">
<form
class="account"
@submit.prevent="submit"
@submit.prevent="execute"
>
<h2>{{ t('account.resetPassword.header') }}</h2>
<p>{{ t('account.resetPassword.sub') }}</p>
@@ -69,14 +70,6 @@ async function submit() {
</div>
</form>
</div>
<div
v-if="errorMessage"
class="banner-notice error"
>
<div>
<p>{{ errorMessage }}</p>
</div>
</div>
</div>
</template>

View File

@@ -17,6 +17,7 @@ definePageMeta({
needsAuth: true
});
const toasts = useToasts();
const { data: tierData } = await useApiFetch('/api/account/tiers');
const { data: profile } = await useApiFetch('/api/auth/me');
const sortedTiers = computed(() =>
@@ -41,20 +42,24 @@ const goalTextVars = computed(() => {
};
});
async function unsubscribe() {
try {
const { execute: executeUnsubscribe } = useAsync({
async handler() {
await apiFetch('/api/account/unsubscribe', {
method: 'POST'
});
await navigateTo('/account');
} catch (error: unknown) {
},
onError(error) {
const err = getApiError(error);
alert(err.code);
toasts.publish({
type: 'error',
text: err.message
});
}
}
});
async function checkout(priceId: string) {
try {
const { execute: executeCheckout } = useAsync({
async handler(priceId: string) {
const result = await apiFetch('/api/account/checkout', {
method: 'POST',
body: {
@@ -62,11 +67,15 @@ async function checkout(priceId: string) {
} satisfies ApiAccountCheckoutRequest
});
await navigateTo(result.url, { external: true });
} catch (error: unknown) {
},
onError(error) {
const err = getApiError(error);
alert(err.code);
toasts.publish({
type: 'error',
text: err.message
});
}
}
});
function hasSubscription(priceId: string) {
return (
@@ -284,7 +293,7 @@ useHead({
</AlertDialogCancel>
<AlertDialogAction
class="alert"
@click="unsubscribe"
@click="executeUnsubscribe"
>
{{ $t("upgrade.unsubConfirm") }}
</AlertDialogAction>
@@ -314,7 +323,7 @@ useHead({
</AlertDialogCancel>
<AlertDialogAction
class="action"
@click="() => checkout(selectedTier?.priceId || '')"
@click="() => executeCheckout(selectedTier?.priceId || '')"
>
{{ $t("modals.confirm") }}
</AlertDialogAction>
@@ -330,7 +339,7 @@ useHead({
>
<button
@click.prevent="checkout(selectedTier.priceId)"
@click.prevent="executeCheckout(selectedTier.priceId)"
>
Subscribe to {{ selectedTier.name }}
</button>

View File

@@ -20,6 +20,9 @@ export const useToasts = defineStore('toasts', () => {
remove(id: number) {
toasts.value = toasts.value.filter(v => v.id !== id);
},
clear() {
toasts.value = [];
},
publish(item: ToastContent) {
toasts.value.push({
id: idCounter++,