feat: Improve first time setup ux (#6106)

This commit is contained in:
Arsène Reymond
2025-09-09 19:21:58 +02:00
committed by GitHub
parent 942ac741cd
commit f90665cce9
9 changed files with 662 additions and 822 deletions

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="d-flex justify-center pb-6 mt-n1"> <div class="d-flex pb-6 mt-n1 ml-10">
<div style="flex-basis: 500px"> <div style="flex-basis: 500px">
<strong> {{ $t("user.password-strength", { strength: pwStrength.strength.value }) }}</strong> <strong> {{ $t("user.password-strength", { strength: pwStrength.strength.value }) }}</strong>
<v-progress-linear <v-progress-linear

View File

@@ -1,6 +1,6 @@
<template> <template>
<div> <div>
<v-card-title> <v-card-title class="pt-0">
<v-icon <v-icon
size="large" size="large"
class="mr-3" class="mr-3"
@@ -10,7 +10,7 @@
<span class="headline"> {{ $t("user-registration.account-details") }}</span> <span class="headline"> {{ $t("user-registration.account-details") }}</span>
</v-card-title> </v-card-title>
<v-divider /> <v-divider />
<v-card-text> <v-card-text class="mt-2">
<v-form <v-form
ref="domAccountForm" ref="domAccountForm"
@submit.prevent @submit.prevent

View File

@@ -0,0 +1,48 @@
<template>
<div class="icon-container">
<v-divider class="icon-divider" />
<v-avatar
:class="['pa-2', 'icon-avatar']"
color="primary"
:size="size"
>
<slot>
<svg
class="icon-white"
viewBox="0 0 24 24"
:style="{ width: size + 'px', height: size + 'px' }"
>
<path
d="M8.1,13.34L3.91,9.16C2.35,7.59 2.35,5.06 3.91,3.5L10.93,10.5L8.1,13.34M13.41,13L20.29,19.88L18.88,21.29L12,14.41L5.12,21.29L3.71,19.88L13.36,10.22L13.16,10C12.38,9.23 12.38,7.97 13.16,7.19L17.5,2.82L18.43,3.74L15.19,7L16.15,7.94L19.39,4.69L20.31,5.61L17.06,8.85L18,9.81L21.26,6.56L22.18,7.5L17.81,11.84C17.03,12.62 15.77,12.62 15,11.84L14.78,11.64L13.41,13Z"
/>
</svg>
</slot>
</v-avatar>
</div>
</template>
<script setup lang="ts">
const { size } = withDefaults(defineProps<{ size?: number }>(), { size: 75 });
</script>
<style scoped>
.icon-white {
fill: white;
}
.icon-container {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
position: relative;
margin-top: 2.5rem;
}
.icon-divider {
width: 100%;
margin-bottom: -2.5rem;
}
.icon-avatar {
border-color: rgba(0, 0, 0, 0.12);
border: 2px;
}
</style>

View File

@@ -33,9 +33,10 @@
<!-- Check Box --> <!-- Check Box -->
<v-checkbox <v-checkbox
v-if="inputField.type === fieldTypes.BOOLEAN" v-if="inputField.type === fieldTypes.BOOLEAN"
v-model="modelValue[inputField.varName]" v-model="model[inputField.varName]"
:name="inputField.varName" :name="inputField.varName"
:disabled="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (disabledFields && disabledFields.includes(inputField.varName))" :readonly="fieldState[inputField.varName]?.readonly"
:disabled="fieldState[inputField.varName]?.disabled"
:hint="inputField.hint" :hint="inputField.hint"
:hide-details="!inputField.hint" :hide-details="!inputField.hint"
:persistent-hint="!!inputField.hint" :persistent-hint="!!inputField.hint"
@@ -51,9 +52,9 @@
<!-- Text Field --> <!-- Text Field -->
<v-text-field <v-text-field
v-else-if="inputField.type === fieldTypes.TEXT || inputField.type === fieldTypes.PASSWORD" v-else-if="inputField.type === fieldTypes.TEXT || inputField.type === fieldTypes.PASSWORD"
v-model="modelValue[inputField.varName]" v-model="model[inputField.varName]"
:readonly="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (readonlyFields && readonlyFields.includes(inputField.varName))" :readonly="fieldState[inputField.varName]?.readonly"
:disabled="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (disabledFields && disabledFields.includes(inputField.varName))" :disabled="fieldState[inputField.varName]?.disabled"
:type="inputField.type === fieldTypes.PASSWORD ? 'password' : 'text'" :type="inputField.type === fieldTypes.PASSWORD ? 'password' : 'text'"
variant="solo-filled" variant="solo-filled"
flat flat
@@ -62,7 +63,7 @@
:label="inputField.label" :label="inputField.label"
:name="inputField.varName" :name="inputField.varName"
:hint="inputField.hint || ''" :hint="inputField.hint || ''"
:rules="!(inputField.disableUpdate && updateMode) ? [...rulesByKey(inputField.rules), ...defaultRules] : []" :rules="!(inputField.disableUpdate && updateMode) ? [...rulesByKey(inputField.rules as any), ...defaultRules] : []"
lazy-validation lazy-validation
@blur="emitBlur" @blur="emitBlur"
/> />
@@ -70,9 +71,9 @@
<!-- Text Area --> <!-- Text Area -->
<v-textarea <v-textarea
v-else-if="inputField.type === fieldTypes.TEXT_AREA" v-else-if="inputField.type === fieldTypes.TEXT_AREA"
v-model="modelValue[inputField.varName]" v-model="model[inputField.varName]"
:readonly="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (readonlyFields && readonlyFields.includes(inputField.varName))" :readonly="fieldState[inputField.varName]?.readonly"
:disabled="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (disabledFields && disabledFields.includes(inputField.varName))" :disabled="fieldState[inputField.varName]?.disabled"
variant="solo-filled" variant="solo-filled"
flat flat
rows="3" rows="3"
@@ -81,7 +82,7 @@
:label="inputField.label" :label="inputField.label"
:name="inputField.varName" :name="inputField.varName"
:hint="inputField.hint || ''" :hint="inputField.hint || ''"
:rules="[...rulesByKey(inputField.rules), ...defaultRules]" :rules="[...rulesByKey(inputField.rules as any), ...defaultRules]"
lazy-validation lazy-validation
@blur="emitBlur" @blur="emitBlur"
/> />
@@ -89,12 +90,11 @@
<!-- Option Select --> <!-- Option Select -->
<v-select <v-select
v-else-if="inputField.type === fieldTypes.SELECT" v-else-if="inputField.type === fieldTypes.SELECT"
v-model="modelValue[inputField.varName]" v-model="model[inputField.varName]"
:readonly="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (readonlyFields && readonlyFields.includes(inputField.varName))" :readonly="fieldState[inputField.varName]?.readonly"
:disabled="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (disabledFields && disabledFields.includes(inputField.varName))" :disabled="fieldState[inputField.varName]?.disabled"
variant="solo-filled" variant="solo-filled"
flat flat
:prepend-icon="inputField.icons ? modelValue[inputField.varName] : null"
:label="inputField.label" :label="inputField.label"
:name="inputField.varName" :name="inputField.varName"
:items="inputField.options" :items="inputField.options"
@@ -119,7 +119,7 @@
<v-btn <v-btn
class="my-2 ml-auto" class="my-2 ml-auto"
style="min-width: 200px" style="min-width: 200px"
:color="modelValue[inputField.varName]" :color="model[inputField.varName]"
dark dark
v-bind="templateProps" v-bind="templateProps"
> >
@@ -127,7 +127,7 @@
</v-btn> </v-btn>
</template> </template>
<v-color-picker <v-color-picker
v-model="modelValue[inputField.varName]" v-model="model[inputField.varName]"
value="#7417BE" value="#7417BE"
hide-canvas hide-canvas
hide-inputs hide-inputs
@@ -138,11 +138,12 @@
</v-menu> </v-menu>
</div> </div>
<!-- Object Type -->
<div v-else-if="inputField.type === fieldTypes.OBJECT"> <div v-else-if="inputField.type === fieldTypes.OBJECT">
<auto-form <auto-form
v-model="modelValue[inputField.varName]" v-model="model[inputField.varName]"
:color="color" :color="color"
:items="inputField.items" :items="(inputField as any).items"
@blur="emitBlur" @blur="emitBlur"
/> />
</div> </div>
@@ -150,7 +151,7 @@
<!-- List Type --> <!-- List Type -->
<div v-else-if="inputField.type === fieldTypes.LIST"> <div v-else-if="inputField.type === fieldTypes.LIST">
<div <div
v-for="(item, idx) in modelValue[inputField.varName]" v-for="(item, idx) in model[inputField.varName]"
:key="idx" :key="idx"
> >
<p> <p>
@@ -160,15 +161,15 @@
class="ml-5" class="ml-5"
x-small x-small
delete delete
@click="removeByIndex(modelValue[inputField.varName], idx)" @click="removeByIndex(model[inputField.varName], idx)"
/> />
</span> </span>
</p> </p>
<v-divider class="mb-5 mx-2" /> <v-divider class="mb-5 mx-2" />
<auto-form <auto-form
v-model="modelValue[inputField.varName][idx]" v-model="model[inputField.varName][idx]"
:color="color" :color="color"
:items="inputField.items" :items="(inputField as any).items"
@blur="emitBlur" @blur="emitBlur"
/> />
</div> </div>
@@ -176,7 +177,7 @@
<v-spacer /> <v-spacer />
<BaseButton <BaseButton
small small
@click="modelValue[inputField.varName].push(getTemplate(inputField.items))" @click="model[inputField.varName].push(getTemplate((inputField as any).items))"
> >
{{ $t("general.new") }} {{ $t("general.new") }}
</BaseButton> </BaseButton>
@@ -197,7 +198,13 @@ const BLUR_EVENT = "blur";
type ValidatorKey = keyof typeof validators; type ValidatorKey = keyof typeof validators;
// Use defineModel for v-model // Use defineModel for v-model
const modelValue = defineModel<[object, Array<any>]>(); const modelValue = defineModel<Record<string, any> | any[]>({
type: [Object, Array],
required: true,
});
// alias to avoid template TS complaining about possible undefined
const model = modelValue as any;
const props = defineProps({ const props = defineProps({
updateMode: { updateMode: {
@@ -238,26 +245,39 @@ const emit = defineEmits(["blur", "update:modelValue"]);
function rulesByKey(keys?: ValidatorKey[] | null) { function rulesByKey(keys?: ValidatorKey[] | null) {
if (keys === undefined || keys === null) { if (keys === undefined || keys === null) {
return []; return [] as any[];
} }
const list = [] as ((v: string) => boolean | string)[]; const list: any[] = [];
keys.forEach((key) => { keys.forEach((key) => {
const split = key.split(":"); const split = key.split(":");
const validatorKey = split[0] as ValidatorKey; const validatorKey = split[0] as ValidatorKey;
if (validatorKey in validators) { if (validatorKey in validators) {
if (split.length === 1) { if (split.length === 1) {
list.push(validators[validatorKey]); list.push((validators as any)[validatorKey]);
} }
else { else {
list.push(validators[validatorKey](split[1])); list.push((validators as any)[validatorKey](split[1] as any));
} }
} }
}); });
return list; return list;
} }
const defaultRules = computed(() => rulesByKey(props.globalRules as ValidatorKey[])); const defaultRules = computed<any[]>(() => rulesByKey(props.globalRules as any));
// Combined state map for readonly and disabled fields
const fieldState = computed<Record<string, { readonly: boolean; disabled: boolean }>>(() => {
const map: Record<string, { readonly: boolean; disabled: boolean }> = {};
(props.items || []).forEach((field: any) => {
const base = (field.disableUpdate && props.updateMode) || (!props.updateMode && field.disableCreate);
map[field.varName] = {
readonly: base || !!props.readonlyFields?.includes(field.varName),
disabled: base || !!props.disabledFields?.includes(field.varName),
};
});
return map;
});
function removeByIndex(list: never[], index: number) { function removeByIndex(list: never[], index: number) {
// Removes the item at the index // Removes the item at the index

View File

@@ -1,293 +0,0 @@
<template>
<div :style="`width: ${width}; height: 100%;`">
<LanguageDialog v-model="langDialog" />
<v-card>
<div>
<v-toolbar
width="100%"
color="primary"
class="d-flex justify-center"
style="margin-bottom: 4rem"
dark
>
<v-toolbar-title class="headline text-h4 text-center mx-0">
Mealie
</v-toolbar-title>
</v-toolbar>
<div class="icon-container">
<v-divider class="icon-divider" />
<v-avatar
class="pa-2 icon-avatar"
color="primary"
size="75"
>
<svg
class="icon-white"
style="width: 75"
viewBox="0 0 24 24"
>
<path
d="M8.1,13.34L3.91,9.16C2.35,7.59 2.35,5.06 3.91,3.5L10.93,10.5L8.1,13.34M13.41,13L20.29,19.88L18.88,21.29L12,14.41L5.12,21.29L3.71,19.88L13.36,10.22L13.16,10C12.38,9.23 12.38,7.97 13.16,7.19L17.5,2.82L18.43,3.74L15.19,7L16.15,7.94L19.39,4.69L20.31,5.61L17.06,8.85L18,9.81L21.26,6.56L22.18,7.5L17.81,11.84C17.03,12.62 15.77,12.62 15,11.84L14.78,11.64L13.41,13Z"
/>
</svg>
</v-avatar>
</div>
</div>
<div class="d-flex justify-center grow items-center my-4">
<slot :width="pageWidth" />
</div>
<div class="mx-2 my-4">
<v-progress-linear
v-if="wizardPage > 0"
:value="Math.ceil((wizardPage / maxPageNumber) * 100)"
striped
height="10"
/>
</div>
<v-divider class="ma-2" />
<v-card-actions width="100%">
<v-btn
v-if="prevButtonShow"
:disabled="!prevButtonEnable"
:color="prevButtonColor"
@click="decrementPage"
>
<v-icon v-if="prevButtonIconRef">
{{ prevButtonIconRef }}
</v-icon>
{{ prevButtonTextRef }}
</v-btn>
<v-spacer />
<v-btn
v-if="nextButtonShow"
variant="elevated"
:disabled="!nextButtonEnable"
:color="nextButtonColorRef"
@click="incrementPage"
>
<div v-if="isSubmitting">
<v-progress-circular
indeterminate
color="white"
size="24"
/>
</div>
<div v-else>
<v-icon v-if="nextButtonIconRef && !nextButtonIconAfter">
{{ nextButtonIconRef }}
</v-icon>
{{ nextButtonTextRef }}
<v-icon v-if="nextButtonIconRef && nextButtonIconAfter">
{{ nextButtonIconRef }}
</v-icon>
</div>
</v-btn>
</v-card-actions>
<v-card-actions class="justify-center flex-column py-8">
<BaseButton
large
color="primary"
@click="langDialog = true"
>
<template #icon>
{{ $globals.icons.translate }}
</template>
{{ $t("language-dialog.choose-language") }}
</BaseButton>
</v-card-actions>
</v-card>
</div>
</template>
<script lang="ts">
export default defineNuxtComponent({
props: {
modelValue: {
type: Number,
required: true,
},
minPageNumber: {
type: Number,
default: 0,
},
maxPageNumber: {
type: Number,
required: true,
},
width: {
type: [String, Number],
default: "1200px",
},
pageWidth: {
type: [String, Number],
default: "600px",
},
prevButtonText: {
type: String,
default: undefined,
},
prevButtonIcon: {
type: String,
default: null,
},
prevButtonColor: {
type: String,
default: "grey-darken-3",
},
prevButtonShow: {
type: Boolean,
default: true,
},
prevButtonEnable: {
type: Boolean,
default: true,
},
nextButtonText: {
type: String,
default: undefined,
},
nextButtonIcon: {
type: String,
default: null,
},
nextButtonIconAfter: {
type: Boolean,
default: true,
},
nextButtonColor: {
type: String,
default: undefined,
},
nextButtonShow: {
type: Boolean,
default: true,
},
nextButtonEnable: {
type: Boolean,
default: true,
},
nextButtonIsSubmit: {
type: Boolean,
default: false,
},
title: {
type: String,
required: true,
},
icon: {
type: String,
default: null,
},
isSubmitting: {
type: Boolean,
default: false,
},
},
emits: ["update:modelValue", "submit"],
setup(props, context) {
const i18n = useI18n();
const { $globals } = useNuxtApp();
const ready = ref(false);
const langDialog = ref(false);
const wizardPage = computed({
get: () => props.modelValue,
set: value => context.emit("update:modelValue", value),
});
const prevButtonTextRef = computed(() => props.prevButtonText || i18n.t("general.back"));
const prevButtonIconRef = computed(() => props.prevButtonIcon || $globals.icons.back);
const nextButtonTextRef = computed(
() => props.nextButtonText || (
props.nextButtonIsSubmit ? i18n.t("general.submit") : i18n.t("general.next")
),
);
const nextButtonIconRef = computed(
() => props.nextButtonIcon || (
props.nextButtonIsSubmit ? $globals.icons.createAlt : $globals.icons.forward
),
);
const nextButtonColorRef = computed(
() => props.nextButtonColor || (props.nextButtonIsSubmit ? "success" : "info"),
);
function goToPage(page: number) {
if (page < props.minPageNumber) {
goToPage(props.minPageNumber);
return;
}
else if (page > props.maxPageNumber) {
goToPage(props.maxPageNumber);
return;
}
wizardPage.value = page;
}
function decrementPage() {
goToPage(wizardPage.value - 1);
}
function incrementPage() {
if (props.nextButtonIsSubmit) {
context.emit("submit", wizardPage.value);
}
else {
goToPage(wizardPage.value + 1);
}
}
ready.value = true;
return {
wizardPage,
ready,
langDialog,
prevButtonTextRef,
prevButtonIconRef,
nextButtonTextRef,
nextButtonIconRef,
nextButtonColorRef,
decrementPage,
incrementPage,
};
},
});
</script>
<style lang="css" scoped>
.icon-primary {
fill: var(--v-primary-base);
}
.icon-white {
fill: white;
}
.icon-container {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
position: relative;
margin-top: 2.5rem;
}
.icon-divider {
width: 100%;
margin-bottom: -2.5rem;
}
.icon-avatar {
border-color: rgba(0, 0, 0, 0.12);
border: 2px;
}
.bg-off-white {
background: #f5f8fa;
}
.preferred-width {
width: 840px;
}
</style>

View File

@@ -1,32 +1,69 @@
<template> <template>
<v-container <v-container
fill-height
fluid fluid
class="d-flex justify-center align-center" class="d-flex justify-center align-start fill-height"
width="1200px"
min-height="700px"
:class="{ :class="{
'bg-off-white': !$vuetify.theme.current.dark, 'bg-off-white': !$vuetify.theme.current.dark && !isDark,
}" }"
> >
<BaseWizard <!-- Header Toolbar -->
v-model="currentPage" <v-card class="elevation-4" width="1200" :class="{ 'my-10': $vuetify.display.mdAndUp }">
:max-page-number="totalPages" <v-toolbar
:title="$t('admin.setup.first-time-setup')" color="primary"
:prev-button-show="activeConfig.showPrevButton" class="d-flex justify-center"
:next-button-show="activeConfig.showNextButton" dark
:next-button-text="activeConfig.nextButtonText"
:next-button-icon="activeConfig.nextButtonIcon"
:next-button-color="activeConfig.nextButtonColor"
:next-button-is-submit="activeConfig.isSubmit"
:is-submitting="isSubmitting"
@submit="handleSubmit"
> >
<v-container <v-toolbar-title class="headline text-h4 text-center mx-0">
v-if="currentPage === Pages.LANDING" Mealie
class="mb-12" </v-toolbar-title>
> </v-toolbar>
<v-card-title class="text-h4 justify-center text-center">
<!-- Stepper Wizard -->
<v-stepper v-model="currentPage" mobile-breakpoint="sm">
<v-stepper-header>
<v-stepper-item
:value="Pages.LANDING"
:complete="currentPage > Pages.LANDING"
:title="$t('general.start')"
/>
<v-divider />
<v-stepper-item
:value="Pages.USER_INFO"
:complete="currentPage > Pages.USER_INFO"
:title="$t('user-registration.account-details')"
/>
<v-divider />
<v-stepper-item
:value="Pages.PAGE_2"
:complete="currentPage > Pages.PAGE_2"
:title="$t('settings.site-settings')"
/>
<v-divider />
<v-stepper-item
:value="Pages.CONFIRM"
:complete="currentPage > Pages.CONFIRM"
:title="$t('admin.maintenance.summary-title')"
/>
<v-divider />
<v-stepper-item
:value="Pages.END"
:complete="currentPage > Pages.END"
:title="$t('admin.setup.setup-complete')"
/>
</v-stepper-header>
<v-progress-linear
v-if="isSubmitting && currentPage === Pages.CONFIRM"
color="primary"
indeterminate
class="mb-2"
/>
<v-stepper-window :transition="false" class="stepper-window">
<!-- LANDING -->
<v-stepper-window-item :value="Pages.LANDING">
<v-container class="mb-12">
<AppLogo />
<v-card-title class="text-h4 justify-center text-center text-break text-pre-wrap">
{{ $t('admin.setup.welcome-to-mealie-get-started') }} {{ $t('admin.setup.welcome-to-mealie-get-started') }}
</v-card-title> </v-card-title>
<v-btn <v-btn
@@ -41,12 +78,45 @@
</v-btn> </v-btn>
</v-container> </v-container>
<v-container v-if="currentPage === Pages.USER_INFO"> <v-card-actions class="justify-center flex-column py-8">
<BaseButton
size="large"
color="primary"
:icon="$globals.icons.translate"
@click="langDialog = true"
>
{{ $t('language-dialog.choose-language') }}
</BaseButton>
</v-card-actions>
<v-stepper-actions
class="justify-end"
:disabled="isSubmitting"
next-text="general.next"
@click:next="onNext"
>
<template #prev />
</v-stepper-actions>
</v-stepper-window-item>
<!-- USER INFO -->
<v-stepper-window-item :value="Pages.USER_INFO" eager>
<v-container max-width="880">
<UserRegistrationForm /> <UserRegistrationForm />
</v-container> </v-container>
<v-stepper-actions
:disabled="isSubmitting"
prev-text="general.back"
next-text="general.next"
@click:prev="onPrev"
@click:next="onNext"
/>
</v-stepper-window-item>
<v-container v-if="currentPage === Pages.PAGE_2"> <!-- COMMON SETTINGS -->
<v-card-title class="headline justify-center pa-0"> <v-stepper-window-item :value="Pages.PAGE_2">
<v-container max-width="880">
<v-card-title class="headline pa-0">
{{ $t('admin.setup.common-settings-for-new-sites') }} {{ $t('admin.setup.common-settings-for-new-sites') }}
</v-card-title> </v-card-title>
<AutoForm <AutoForm
@@ -54,16 +124,27 @@
:items="commonSettingsForm" :items="commonSettingsForm"
/> />
</v-container> </v-container>
<v-stepper-actions
:disabled="isSubmitting"
prev-text="general.back"
next-text="general.next"
@click:prev="onPrev"
@click:next="onNext"
/>
</v-stepper-window-item>
<v-container v-if="currentPage === Pages.CONFIRM"> <!-- CONFIRMATION -->
<v-card-title class="headline justify-center"> <v-stepper-window-item :value="Pages.CONFIRM">
{{ $t("general.confirm-how-does-everything-look") }} <v-container max-width="880">
<v-card-title class="headline pa-0">
{{ $t('general.confirm-how-does-everything-look') }}
</v-card-title> </v-card-title>
<v-list> <v-list>
<template v-for="(item, idx) in confirmationData"> <template v-for="(item, idx) in confirmationData">
<v-list-item <v-list-item
v-if="item.display" v-if="item.display"
:key="idx" :key="idx"
class="px-0"
> >
<v-list-item-title>{{ item.text }}</v-list-item-title> <v-list-item-title>{{ item.text }}</v-list-item-title>
<v-list-item-subtitle>{{ item.value }}</v-list-item-subtitle> <v-list-item-subtitle>{{ item.value }}</v-list-item-subtitle>
@@ -75,8 +156,27 @@
</template> </template>
</v-list> </v-list>
</v-container> </v-container>
<v-stepper-actions
:disabled="isSubmitting"
prev-text="general.back"
@click:prev="onPrev"
>
<template #next>
<BaseButton
create flat
:disabled="isSubmitting"
:loading="isSubmitting"
:icon="$globals.icons.check"
:text="$t('general.submit')"
@click="onNext"
/>
</template>
</v-stepper-actions>
</v-stepper-window-item>
<v-container v-if="currentPage === Pages.END"> <!-- END -->
<v-stepper-window-item :value="Pages.END">
<v-container max-width="880">
<v-card-title class="text-h4 justify-center"> <v-card-title class="text-h4 justify-center">
{{ $t('admin.setup.setup-complete') }} {{ $t('admin.setup.setup-complete') }}
</v-card-title> </v-card-title>
@@ -105,11 +205,35 @@
</v-card-text> </v-card-text>
</div> </div>
</v-container> </v-container>
</BaseWizard> <v-stepper-actions
</v-container> :disabled="isSubmitting"
prev-text="general.back"
@click:prev="onPrev"
>
<template #next>
<BaseButton
flat
color="primary"
:disabled="isSubmitting"
:loading="isSubmitting"
:icon="$globals.icons.home"
:text="$t('general.home')"
@click="onFinish"
/>
</template>
</v-stepper-actions>
</v-stepper-window-item>
</v-stepper-window>
</v-stepper>
<!-- Dialog Language -->
<LanguageDialog v-model="langDialog" />
</v-card>
</v-container>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { useDark } from "@vueuse/core";
import { useAdminApi, useUserApi } from "~/composables/api"; import { useAdminApi, useUserApi } from "~/composables/api";
import { useLocales } from "~/composables/use-locales"; import { useLocales } from "~/composables/use-locales";
import { alert } from "~/composables/use-toast"; import { alert } from "~/composables/use-toast";
@@ -117,64 +241,53 @@ import { useUserRegistrationForm } from "~/composables/use-users/user-registrati
import { useCommonSettingsForm } from "~/composables/use-setup/common-settings-form"; import { useCommonSettingsForm } from "~/composables/use-setup/common-settings-form";
import UserRegistrationForm from "~/components/Domain/User/UserRegistrationForm.vue"; import UserRegistrationForm from "~/components/Domain/User/UserRegistrationForm.vue";
export default defineNuxtComponent({ definePageMeta({
components: { UserRegistrationForm },
setup() {
definePageMeta({
layout: "blank", layout: "blank",
}); });
// ================================================================ // ================================================================
// Setup // Setup
const i18n = useI18n(); const i18n = useI18n();
const $auth = useMealieAuth(); const $auth = useMealieAuth();
const { $globals } = useNuxtApp(); const userApi = useUserApi();
const userApi = useUserApi(); const adminApi = useAdminApi();
const adminApi = useAdminApi();
const groupSlug = computed(() => $auth.user.value?.groupSlug); const groupSlug = computed(() => $auth.user.value?.groupSlug);
const { locale } = useLocales(); const { locale } = useLocales();
const router = useRouter(); const router = useRouter();
const isSubmitting = ref(false); const isSubmitting = ref(false);
useSeoMeta({ const langDialog = ref(false);
const isDark = useDark();
useSeoMeta({
title: i18n.t("admin.setup.first-time-setup"), title: i18n.t("admin.setup.first-time-setup"),
}); });
if (!$auth.loggedIn.value) { if (!$auth.loggedIn.value) {
router.push("/login"); router.push("/login");
} }
else if (!$auth.user.value?.admin) { else if (!$auth.user.value?.admin) {
router.push(groupSlug.value ? `/g/${groupSlug.value}` : "/login"); router.push(groupSlug.value ? `/g/${groupSlug.value}` : "/login");
} }
type Config = { enum Pages {
nextButtonText: string | undefined;
nextButtonIcon: string | undefined;
nextButtonColor: string | undefined;
showPrevButton: boolean;
showNextButton: boolean;
isSubmit: boolean;
};
const totalPages = 4;
enum Pages {
LANDING = 0, LANDING = 0,
USER_INFO = 1, USER_INFO = 1,
PAGE_2 = 2, PAGE_2 = 2,
CONFIRM = 3, CONFIRM = 3,
END = 4, END = 4,
} }
// ================================================================ // ================================================================
// Forms // Forms
const { accountDetails, credentials } = useUserRegistrationForm(); const { accountDetails, credentials } = useUserRegistrationForm();
const { commonSettingsForm } = useCommonSettingsForm(); const { commonSettingsForm } = useCommonSettingsForm();
const commonSettings = ref({ const commonSettings = ref({
makeGroupRecipesPublic: false, makeGroupRecipesPublic: false,
useSeedData: true, useSeedData: true,
}); });
const confirmationData = computed(() => { const confirmationData = computed(() => {
return [ return [
{ {
display: true, display: true,
@@ -207,9 +320,9 @@ export default defineNuxtComponent({
value: commonSettings.value.useSeedData ? i18n.t("general.yes") : i18n.t("general.no"), value: commonSettings.value.useSeedData ? i18n.t("general.yes") : i18n.t("general.no"),
}, },
]; ];
}); });
const setupCompleteLinks = ref([ const setupCompleteLinks = ref([
{ {
section: i18n.t("profile.data-migrations"), section: i18n.t("profile.data-migrations"),
to: "/admin/backups", to: "/admin/backups",
@@ -243,59 +356,23 @@ export default defineNuxtComponent({
text: i18n.t("profile.manage-user-profile"), text: i18n.t("profile.manage-user-profile"),
description: i18n.t("admin.setup.manage-profile-or-get-invite-link"), description: i18n.t("admin.setup.manage-profile-or-get-invite-link"),
}, },
]); ]);
// ================================================================ // ================================================================
// Page Navigation // Page Navigation
const currentPage = ref(0); const currentPage = ref(Pages.LANDING);
const activeConfig = computed<Config>(() => {
const config: Config = {
nextButtonText: undefined,
nextButtonIcon: undefined,
nextButtonColor: undefined,
showPrevButton: true,
showNextButton: true,
isSubmit: false,
};
switch (currentPage.value) { // ================================================================
case Pages.LANDING: // Page Submission
config.showPrevButton = false;
config.nextButtonText = i18n.t("general.start");
config.nextButtonIcon = $globals.icons.forward;
break;
case Pages.USER_INFO:
config.showPrevButton = false;
config.nextButtonText = i18n.t("general.next");
config.nextButtonIcon = $globals.icons.forward;
config.isSubmit = true;
break;
case Pages.CONFIRM:
config.isSubmit = true;
break;
case Pages.END:
config.nextButtonText = i18n.t("general.home");
config.nextButtonIcon = $globals.icons.home;
config.nextButtonColor = "primary";
config.showPrevButton = false;
config.isSubmit = true;
break;
}
return config; async function updateUser() {
});
// ================================================================
// Page Submission
async function updateUser() {
// Note: $auth.user is now a ref // Note: $auth.user is now a ref
const { response } = await userApi.users.updateOne($auth.user.value!.id, { const { response } = await userApi.users.updateOne($auth.user.value!.id, {
...$auth.user.value, ...$auth.user.value,
email: accountDetails.email.value, email: accountDetails.email.value,
username: accountDetails.username.value, username: accountDetails.username.value,
fullName: accountDetails.fullName.value, fullName: accountDetails.fullName.value,
advancedOptions: accountDetails.advancedOptions.value, advanced: accountDetails.advancedOptions.value,
}); });
if (!response || response.status !== 200) { if (!response || response.status !== 200) {
@@ -303,16 +380,10 @@ export default defineNuxtComponent({
} }
else { else {
$auth.refresh(); $auth.refresh();
/* $auth.setUser({
...$auth.user.value,
email: accountDetails.email.value,
username: accountDetails.username.value,
fullName: accountDetails.fullName.value,
}) */
}
} }
}
async function updatePassword() { async function updatePassword() {
const { response } = await userApi.users.changePassword({ const { response } = await userApi.users.changePassword({
currentPassword: "MyPassword", currentPassword: "MyPassword",
newPassword: credentials.password1.value, newPassword: credentials.password1.value,
@@ -321,15 +392,14 @@ export default defineNuxtComponent({
if (!response || response.status !== 200) { if (!response || response.status !== 200) {
alert.error(i18n.t("events.something-went-wrong")); alert.error(i18n.t("events.something-went-wrong"));
} }
} }
async function submitRegistration() { async function submitRegistration() {
// the backend will only update the password without the "currentPassword" field if the user is the default user, // we update the password first, then update the user's details
// so we update the password first, then update the user's details
await updatePassword().then(updateUser); await updatePassword().then(updateUser);
} }
async function updateGroup() { async function updateGroup() {
// Note: $auth.user is now a ref // Note: $auth.user is now a ref
const { data } = await userApi.groups.getOne($auth.user.value!.groupId); const { data } = await userApi.groups.getOne($auth.user.value!.groupId);
if (!data || !data.preferences) { if (!data || !data.preferences) {
@@ -352,9 +422,9 @@ export default defineNuxtComponent({
if (!response || response.status !== 200) { if (!response || response.status !== 200) {
alert.error(i18n.t("events.something-went-wrong")); alert.error(i18n.t("events.something-went-wrong"));
} }
} }
async function updateHousehold() { async function updateHousehold() {
// Note: $auth.user is now a ref // Note: $auth.user is now a ref
const { data } = await adminApi.households.getOne($auth.user.value!.householdId); const { data } = await adminApi.households.getOne($auth.user.value!.householdId);
if (!data || !data.preferences) { if (!data || !data.preferences) {
@@ -378,30 +448,30 @@ export default defineNuxtComponent({
if (!response || response.status !== 200) { if (!response || response.status !== 200) {
alert.error(i18n.t("events.something-went-wrong")); alert.error(i18n.t("events.something-went-wrong"));
} }
} }
async function seedFoods() { async function seedFoods() {
const { response } = await userApi.seeders.foods({ locale: locale.value }); const { response } = await userApi.seeders.foods({ locale: locale.value });
if (!response || response.status !== 200) { if (!response || response.status !== 200) {
alert.error(i18n.t("events.something-went-wrong")); alert.error(i18n.t("events.something-went-wrong"));
} }
} }
async function seedUnits() { async function seedUnits() {
const { response } = await userApi.seeders.units({ locale: locale.value }); const { response } = await userApi.seeders.units({ locale: locale.value });
if (!response || response.status !== 200) { if (!response || response.status !== 200) {
alert.error(i18n.t("events.something-went-wrong")); alert.error(i18n.t("events.something-went-wrong"));
} }
} }
async function seedLabels() { async function seedLabels() {
const { response } = await userApi.seeders.labels({ locale: locale.value }); const { response } = await userApi.seeders.labels({ locale: locale.value });
if (!response || response.status !== 200) { if (!response || response.status !== 200) {
alert.error(i18n.t("events.something-went-wrong")); alert.error(i18n.t("events.something-went-wrong"));
} }
} }
async function seedData() { async function seedData() {
if (!commonSettings.value.useSeedData) { if (!commonSettings.value.useSeedData) {
return; return;
} }
@@ -413,9 +483,9 @@ export default defineNuxtComponent({
]; ];
await Promise.all(tasks); await Promise.all(tasks);
} }
async function submitCommonSettings() { async function submitCommonSettings() {
const tasks = [ const tasks = [
updateGroup(), updateGroup(),
updateHousehold(), updateHousehold(),
@@ -423,18 +493,18 @@ export default defineNuxtComponent({
]; ];
await Promise.all(tasks); await Promise.all(tasks);
} }
async function submitAll() { async function submitAll() {
const tasks = [ const tasks = [
submitRegistration(), submitRegistration(),
submitCommonSettings(), submitCommonSettings(),
]; ];
await Promise.all(tasks); await Promise.all(tasks);
} }
async function handleSubmit(page: number) { async function handleSubmit(page: number) {
if (isSubmitting.value) { if (isSubmitting.value) {
return; return;
} }
@@ -455,25 +525,59 @@ export default defineNuxtComponent({
break; break;
} }
isSubmitting.value = false; isSubmitting.value = false;
} }
return { // ================================================================
// Setup // Stepper Navigation Handlers
groupSlug, function onPrev() {
// Forms if (isSubmitting.value) return;
commonSettingsForm, if (currentPage.value > Pages.LANDING) currentPage.value -= 1;
commonSettings, }
confirmationData,
setupCompleteLinks, async function onNext() {
// Page Navigation if (isSubmitting.value) return;
Pages, if (currentPage.value === Pages.USER_INFO) {
currentPage, await handleSubmit(Pages.USER_INFO);
totalPages, return;
activeConfig, }
// Page Submission if (currentPage.value === Pages.CONFIRM) {
isSubmitting, await handleSubmit(Pages.CONFIRM);
handleSubmit, return;
}; }
}, currentPage.value += 1;
}); }
async function onFinish() {
if (isSubmitting.value) return;
await handleSubmit(Pages.END);
}
</script> </script>
<style scoped>
.icon-white {
fill: white;
}
.icon-container {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
position: relative;
margin-top: 2.5rem;
}
.icon-divider {
width: 100%;
margin-bottom: -2.5rem;
}
.icon-avatar {
border-color: rgba(0, 0, 0, 0.12);
border: 2px;
}
.bg-off-white {
background: #f5f8fa;
}
</style>

View File

@@ -52,26 +52,7 @@
Mealie Mealie
</v-toolbar-title> </v-toolbar-title>
</v-toolbar> </v-toolbar>
<AppLogo :size="100" />
<div class="icon-container">
<v-divider class="icon-divider" />
<v-avatar
class="pa-2 icon-avatar"
color="primary"
size="100"
>
<svg
class="icon-white"
style="width: 100px; height: 100px"
viewBox="0 0 24 24"
>
<path
d="M8.1,13.34L3.91,9.16C2.35,7.59 2.35,5.06 3.91,3.5L10.93,10.5L8.1,13.34M13.41,13L20.29,19.88L18.88,21.29L12,14.41L5.12,21.29L3.71,19.88L13.36,10.22L13.16,10C12.38,9.23 12.38,7.97 13.16,7.19L17.5,2.82L18.43,3.74L15.19,7L16.15,7.94L19.39,4.69L20.31,5.61L17.06,8.85L18,9.81L21.26,6.56L22.18,7.5L17.81,11.84C17.03,12.62 15.77,12.62 15,11.84L14.78,11.64L13.41,13Z"
/>
</svg>
</v-avatar>
</div>
<v-card-title class="text-h5 justify-center pb-3"> <v-card-title class="text-h5 justify-center pb-3">
{{ $t('user.sign-in') }} {{ $t('user.sign-in') }}
</v-card-title> </v-card-title>

View File

@@ -23,25 +23,7 @@
Mealie Mealie
</v-toolbar-title> </v-toolbar-title>
</v-toolbar> </v-toolbar>
<AppLogo />
<div class="icon-container">
<v-divider class="icon-divider" />
<v-avatar
class="pa-2 icon-avatar"
color="primary"
size="75"
>
<svg
class="icon-white"
style="width: 75"
viewBox="0 0 24 24"
>
<path
d="M8.1,13.34L3.91,9.16C2.35,7.59 2.35,5.06 3.91,3.5L10.93,10.5L8.1,13.34M13.41,13L20.29,19.88L18.88,21.29L12,14.41L5.12,21.29L3.71,19.88L13.36,10.22L13.16,10C12.38,9.23 12.38,7.97 13.16,7.19L17.5,2.82L18.43,3.74L15.19,7L16.15,7.94L19.39,4.69L20.31,5.61L17.06,8.85L18,9.81L21.26,6.56L22.18,7.5L17.81,11.84C17.03,12.62 15.77,12.62 15,11.84L14.78,11.64L13.41,13Z"
/>
</svg>
</v-avatar>
</div>
</div> </div>
<!-- Form Container --> <!-- Form Container -->

View File

@@ -15,7 +15,6 @@ import type BaseDivider from "@/components/global/BaseDivider.vue";
import type BaseOverflowButton from "@/components/global/BaseOverflowButton.vue"; import type BaseOverflowButton from "@/components/global/BaseOverflowButton.vue";
import type BasePageTitle from "@/components/global/BasePageTitle.vue"; import type BasePageTitle from "@/components/global/BasePageTitle.vue";
import type BaseStatCard from "@/components/global/BaseStatCard.vue"; import type BaseStatCard from "@/components/global/BaseStatCard.vue";
import type BaseWizard from "@/components/global/BaseWizard.vue";
import type ButtonLink from "@/components/global/ButtonLink.vue"; import type ButtonLink from "@/components/global/ButtonLink.vue";
import type ContextMenu from "@/components/global/ContextMenu.vue"; import type ContextMenu from "@/components/global/ContextMenu.vue";
import type CrudTable from "@/components/global/CrudTable.vue"; import type CrudTable from "@/components/global/CrudTable.vue";
@@ -56,7 +55,6 @@ declare module "vue" {
BaseOverflowButton: typeof BaseOverflowButton; BaseOverflowButton: typeof BaseOverflowButton;
BasePageTitle: typeof BasePageTitle; BasePageTitle: typeof BasePageTitle;
BaseStatCard: typeof BaseStatCard; BaseStatCard: typeof BaseStatCard;
BaseWizard: typeof BaseWizard;
ButtonLink: typeof ButtonLink; ButtonLink: typeof ButtonLink;
ContextMenu: typeof ContextMenu; ContextMenu: typeof ContextMenu;
CrudTable: typeof CrudTable; CrudTable: typeof CrudTable;
@@ -81,4 +79,4 @@ declare module "vue" {
} }
} }
export {}; export { };