chore: migrate remaining pages to script setup (#7310)

This commit is contained in:
Kuchenpirat
2026-03-24 16:07:08 +01:00
committed by GitHub
parent 27cb585c80
commit 18b3c4beab
57 changed files with 4160 additions and 4971 deletions

View File

@@ -100,6 +100,7 @@ const {
usernameErrorMessages,
validateUsername,
validateEmail,
domAccountForm,
} = useUserRegistrationForm();
</script>

View File

@@ -21,5 +21,6 @@ export default withNuxt({
],
"vue/no-mutating-props": "error",
"vue/no-v-html": "error",
"vue/component-api-style": ["error", ["script-setup"]],
},
});

View File

@@ -48,97 +48,89 @@
</v-app>
</template>
<script lang="ts">
<script setup lang="ts">
import { useGlobalI18n } from "~/composables/use-global-i18n";
export default defineNuxtComponent({
props: {
error: {
type: Object,
default: null,
},
const props = defineProps({
error: {
type: Object,
default: null,
},
setup(props) {
definePageMeta({
layout: "basic",
});
});
const i18n = useGlobalI18n();
const auth = useMealieAuth();
const { $globals } = useNuxtApp();
const ready = ref(false);
definePageMeta({
layout: "basic",
});
const route = useRoute();
const router = useRouter();
const i18n = useGlobalI18n();
const auth = useMealieAuth();
const { $globals } = useNuxtApp();
const ready = ref(false);
async function insertGroupSlugIntoRoute() {
const groupSlug = ref(auth.user.value?.groupSlug);
if (!groupSlug.value) {
return;
}
const route = useRoute();
const router = useRouter();
let replaceRoute = false;
let routeVal = route.fullPath || "/";
if (routeVal[0] !== "/") {
routeVal = `/${routeVal}`;
}
async function insertGroupSlugIntoRoute() {
const groupSlug = ref(auth.user.value?.groupSlug);
if (!groupSlug.value) {
return;
}
// replace "recipe" in URL with "r"
if (routeVal.includes("/recipe/")) {
replaceRoute = true;
routeVal = routeVal.replace("/recipe/", "/r/");
}
let replaceRoute = false;
let routeVal = route.fullPath || "/";
if (routeVal[0] !== "/") {
routeVal = `/${routeVal}`;
}
// insert groupSlug into URL
const routeComponents = routeVal.split("/");
if (routeComponents.length < 2 || routeComponents[1].toLowerCase() !== "g") {
replaceRoute = true;
routeVal = `/g/${groupSlug.value}${routeVal}`;
}
// replace "recipe" in URL with "r"
if (routeVal.includes("/recipe/")) {
replaceRoute = true;
routeVal = routeVal.replace("/recipe/", "/r/");
}
if (replaceRoute) {
await router.replace(routeVal);
}
}
// insert groupSlug into URL
const routeComponents = routeVal.split("/");
if (routeComponents.length < 2 || routeComponents[1].toLowerCase() !== "g") {
replaceRoute = true;
routeVal = `/g/${groupSlug.value}${routeVal}`;
}
async function handle404() {
const normalizedRoute = route.fullPath.replace(/\/$/, "");
const newRoute = normalizedRoute.replace(/^\/group\/(mealplan|members|notifiers|webhooks)(\/.*)?$/, "/household/$1$2");
if (replaceRoute) {
await router.replace(routeVal);
}
}
if (newRoute !== normalizedRoute) {
await router.replace(newRoute);
}
else {
await insertGroupSlugIntoRoute();
}
async function handle404() {
const normalizedRoute = route.fullPath.replace(/\/$/, "");
const newRoute = normalizedRoute.replace(/^\/group\/(mealplan|members|notifiers|webhooks)(\/.*)?$/, "/household/$1$2");
ready.value = true;
}
if (newRoute !== normalizedRoute) {
await router.replace(newRoute);
}
else {
await insertGroupSlugIntoRoute();
}
if (props.error.statusCode === 404) {
handle404();
}
else {
ready.value = true;
}
ready.value = true;
}
useSeoMeta({
title:
if (props.error.statusCode === 404) {
handle404();
}
else {
ready.value = true;
}
useSeoMeta({
title:
props.error.statusCode === 404
? (i18n.t("page.404-not-found") as string)
: (i18n.t("page.an-error-occurred") as string),
});
const buttons = [
{ icon: $globals.icons.home, to: "/", text: i18n.t("general.home") },
];
return {
buttons,
ready,
};
},
});
const buttons = [
{ icon: $globals.icons.home, to: "/", text: i18n.t("general.home") },
];
</script>
<style scoped>

View File

@@ -1,9 +1,9 @@
<template>
<NuxtPage />
</template>
<script setup lang="ts">
definePageMeta({
middleware: ["admin-only"],
});
</script>
<template>
<NuxtPage />
</template>

View File

@@ -3,7 +3,7 @@
<section>
<!-- Delete Dialog -->
<BaseDialog
v-model="deleteDialog"
v-model="state.deleteDialog"
:title="$t('settings.backup.delete-backup')"
color="error"
:icon="$globals.icons.alertCircle"
@@ -17,7 +17,7 @@
<!-- Import Dialog -->
<BaseDialog
v-model="importDialog"
v-model="state.importDialog"
color="error"
:title="$t('settings.backup.backup-restore')"
:icon="$globals.icons.database"
@@ -40,7 +40,7 @@
</p>
<v-checkbox
v-model="confirmImport"
v-model="state.confirmImport"
class="checkbox-top"
color="error"
hide-details
@@ -50,7 +50,7 @@
<v-card-actions class="justify-center pt-0">
<BaseButton
delete
:disabled="!confirmImport || runningRestore"
:disabled="!state.confirmImport || state.runningRestore"
@click="restoreBackup(selected)"
>
<template #icon>
@@ -63,7 +63,7 @@
{{ selected }}
</p>
<v-progress-linear
v-if="runningRestore"
v-if="state.runningRestore"
indeterminate
/>
</BaseDialog>
@@ -81,7 +81,7 @@
>
<BaseButton
class="mr-2"
:loading="runningBackup"
:loading="state.runningBackup"
@click="createBackup"
>
{{ $t("settings.backup.create-heading") }}
@@ -96,13 +96,13 @@
</v-toolbar>
<v-data-table
:headers="headers"
:headers="state.headers"
:items="backups.imports || []"
class="elevation-0"
:items-per-page="-1"
hide-default-footer
disable-pagination
:search="search"
:search="state.search"
@click:row="setSelected"
>
<template #[`item.date`]="{ item }">
@@ -115,7 +115,7 @@
color="error"
variant="text"
@click.stop="
deleteDialog = true;
state.deleteDialog = true;
deleteTarget = item.name;
"
>
@@ -130,7 +130,7 @@
/>
<BaseButton
small
@click.stop="setSelected(item); importDialog = true"
@click.stop="setSelected(item); state.importDialog = true"
>
<template #icon>
{{ $globals.icons.backupRestore }}
@@ -151,130 +151,108 @@
</v-container>
</template>
<script lang="ts">
<script setup lang="ts">
import { useAdminApi } from "~/composables/api";
import type { AllBackups } from "~/lib/api/types/admin";
import { alert } from "~/composables/use-toast";
export default defineNuxtComponent({
setup() {
definePageMeta({
layout: "admin",
});
definePageMeta({
layout: "admin",
});
const i18n = useI18n();
const auth = useMealieAuth();
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug || auth.user.value?.groupSlug || "");
const i18n = useI18n();
const adminApi = useAdminApi();
const selected = ref("");
const adminApi = useAdminApi();
const selected = ref("");
const backups = ref<AllBackups>({
imports: [],
templates: [],
});
const backups = ref<AllBackups>({
imports: [],
templates: [],
});
async function refreshBackups() {
const { data } = await adminApi.backups.getAll();
if (data) {
backups.value = data;
}
}
async function refreshBackups() {
const { data } = await adminApi.backups.getAll();
if (data) {
backups.value = data;
}
}
async function createBackup() {
state.runningBackup = true;
const { data } = await adminApi.backups.create();
async function createBackup() {
state.runningBackup = true;
const { data } = await adminApi.backups.create();
if (data?.error === false) {
refreshBackups();
alert.success(i18n.t("settings.backup.backup-created"));
}
else {
alert.error(i18n.t("settings.backup.error-creating-backup-see-log-file"));
}
state.runningBackup = false;
}
if (data?.error === false) {
refreshBackups();
alert.success(i18n.t("settings.backup.backup-created"));
}
else {
alert.error(i18n.t("settings.backup.error-creating-backup-see-log-file"));
}
state.runningBackup = false;
}
async function restoreBackup(fileName: string) {
state.runningRestore = true;
const { error } = await adminApi.backups.restore(fileName);
async function restoreBackup(fileName: string) {
state.runningRestore = true;
const { error } = await adminApi.backups.restore(fileName);
if (error) {
console.log(error);
state.importDialog = false;
state.runningRestore = false;
alert.error(i18n.t("settings.backup.restore-fail"));
}
else {
alert.success(i18n.t("settings.backup.restore-success"));
setTimeout(() => {
window.location.reload();
}, 500);
}
}
if (error) {
console.log(error);
state.importDialog = false;
state.runningRestore = false;
alert.error(i18n.t("settings.backup.restore-fail"));
}
else {
alert.success(i18n.t("settings.backup.restore-success"));
setTimeout(() => {
window.location.reload();
}, 500);
}
}
const deleteTarget = ref("");
const deleteTarget = ref("");
async function deleteBackup() {
const { data } = await adminApi.backups.delete(deleteTarget.value);
async function deleteBackup() {
const { data } = await adminApi.backups.delete(deleteTarget.value);
if (!data?.error) {
alert.success(i18n.t("settings.backup.backup-deleted"));
refreshBackups();
}
}
if (!data?.error) {
alert.success(i18n.t("settings.backup.backup-deleted"));
refreshBackups();
}
}
const state = reactive({
confirmImport: false,
deleteDialog: false,
createDialog: false,
importDialog: false,
runningBackup: false,
runningRestore: false,
search: "",
headers: [
{ title: i18n.t("general.name"), value: "name" },
{ title: i18n.t("general.created"), value: "date" },
{ title: i18n.t("export.size"), value: "size" },
{ title: "", value: "actions", align: "right" },
],
});
const state = reactive({
confirmImport: false,
deleteDialog: false,
createDialog: false,
importDialog: false,
runningBackup: false,
runningRestore: false,
search: "",
headers: [
{ title: i18n.t("general.name"), value: "name" },
{ title: i18n.t("general.created"), value: "date" },
{ title: i18n.t("export.size"), value: "size" },
{ title: "", value: "actions", align: "right" },
],
});
function setSelected(data: { name: string; date: string }) {
if (!data.name) {
return;
}
selected.value = data.name;
}
function setSelected(data: { name: string; date: string }) {
if (!data.name) {
return;
}
selected.value = data.name;
}
const backupsFileNameDownload = (fileName: string) => `api/admin/backups/${fileName}`;
const backupsFileNameDownload = (fileName: string) => `api/admin/backups/${fileName}`;
useSeoMeta({
title: i18n.t("sidebar.backups"),
});
useSeoMeta({
title: i18n.t("sidebar.backups"),
});
onMounted(refreshBackups);
onMounted(refreshBackups);
return {
groupSlug,
restoreBackup,
selected,
...toRefs(state),
backups,
createBackup,
deleteBackup,
deleteTarget,
setSelected,
refreshBackups,
backupsFileNameDownload,
};
},
head() {
return {
title: useI18n().t("sidebar.backups"),
};
},
useHead({
title: i18n.t("sidebar.backups"),
});
</script>

View File

@@ -83,69 +83,53 @@
</v-container>
</template>
<script lang="ts">
<script setup lang="ts">
import { useAdminApi } from "~/composables/api";
import { alert } from "~/composables/use-toast";
export default defineNuxtComponent({
setup() {
definePageMeta({
layout: "admin",
});
const api = useAdminApi();
const i18n = useI18n();
// Set page title
useSeoMeta({
title: i18n.t("admin.debug-openai-services"),
});
const loading = ref(false);
const response = ref("");
const uploadForm = ref<VForm | null>(null);
const uploadedImage = ref<Blob | File>();
const uploadedImageName = ref<string>("");
const uploadedImagePreviewUrl = ref<string>();
function uploadImage(fileObject: File) {
uploadedImage.value = fileObject;
uploadedImageName.value = fileObject.name;
uploadedImagePreviewUrl.value = URL.createObjectURL(fileObject);
}
function clearImage() {
uploadedImage.value = undefined;
uploadedImageName.value = "";
uploadedImagePreviewUrl.value = undefined;
}
async function testOpenAI() {
response.value = "";
loading.value = true;
const { data } = await api.debug.debugOpenAI(uploadedImage.value);
loading.value = false;
if (!data) {
alert.error("Unable to test OpenAI services");
}
else {
response.value = data.response || (data.success ? "Test Successful" : "Test Failed");
}
}
return {
loading,
response,
uploadForm,
uploadedImage,
uploadedImagePreviewUrl,
uploadImage,
clearImage,
testOpenAI,
};
},
definePageMeta({
layout: "admin",
});
const api = useAdminApi();
const i18n = useI18n();
// Set page title
useSeoMeta({
title: i18n.t("admin.debug-openai-services"),
});
const loading = ref(false);
const response = ref("");
const uploadedImage = ref<Blob | File>();
const uploadedImageName = ref<string>("");
const uploadedImagePreviewUrl = ref<string>();
function uploadImage(fileObject: File) {
uploadedImage.value = fileObject;
uploadedImageName.value = fileObject.name;
uploadedImagePreviewUrl.value = URL.createObjectURL(fileObject);
}
function clearImage() {
uploadedImage.value = undefined;
uploadedImageName.value = "";
uploadedImagePreviewUrl.value = undefined;
}
async function testOpenAI() {
response.value = "";
loading.value = true;
const { data } = await api.debug.debugOpenAI(uploadedImage.value);
loading.value = false;
if (!data) {
alert.error("Unable to test OpenAI services");
}
else {
response.value = data.response || (data.success ? "Test Successful" : "Test Failed");
}
}
</script>

View File

@@ -11,7 +11,7 @@
<div class="d-flex align-center justify-center justify-md-start flex-wrap">
<v-btn-toggle
v-model="parser"
v-model="state.parser"
density="compact"
mandatory="force"
@change="processIngredient"
@@ -38,7 +38,7 @@
<v-card flat>
<v-card-text>
<v-text-field
v-model="ingredient"
v-model="state.ingredient"
:label="$t('admin.ingredient-text')"
/>
</v-card-text>
@@ -55,9 +55,9 @@
</v-card-actions>
</v-card>
</v-container>
<v-container v-if="results">
<v-container v-if="state.results">
<div
v-if="parser !== 'brute' && getConfidence('average')"
v-if="state.parser !== 'brute' && getConfidence('average')"
class="d-flex"
>
<v-chip
@@ -111,7 +111,7 @@
</v-container>
</template>
<script lang="ts">
<script setup lang="ts">
import { alert } from "~/composables/use-toast";
import { useUserApi } from "~/composables/api";
import type { IngredientConfidence } from "~/lib/api/types/recipe";
@@ -119,155 +119,139 @@ import type { Parser } from "~/lib/api/user/recipes/recipe";
type ConfidenceAttribute = "average" | "comment" | "name" | "unit" | "quantity" | "food";
export default defineNuxtComponent({
setup() {
definePageMeta({
layout: "admin",
definePageMeta({
layout: "admin",
});
const api = useUserApi();
const state = reactive({
loading: false,
ingredient: "",
results: false,
parser: "nlp" as Parser,
});
const i18n = useI18n();
// Set page title
useSeoMeta({
title: i18n.t("admin.parser"),
});
const confidence = ref<IngredientConfidence>({});
function getColor(attribute: ConfidenceAttribute) {
const percentage = getConfidence(attribute);
if (percentage === undefined) return;
const p_as_num = parseFloat(percentage.replace("%", ""));
// Set color based off range
if (p_as_num > 75) {
return "success";
}
else if (p_as_num > 60) {
return "warning";
}
else {
return "error";
}
}
function getConfidence(attribute: ConfidenceAttribute) {
if (!confidence.value) {
return;
}
const property = confidence.value[attribute];
if (property !== undefined && property !== null) {
return `${(+property * 100).toFixed(0)}%`;
}
return undefined;
}
const tryText = [
"2 tbsp minced cilantro, leaves and stems",
"1 large yellow onion, coarsely chopped",
"1 1/2 tsp garam masala",
"1 inch piece fresh ginger, (peeled and minced)",
"2 cups mango chunks, (2 large mangoes) (fresh or frozen)",
];
function processTryText(str: string) {
state.ingredient = str;
processIngredient();
}
async function processIngredient() {
if (state.ingredient === "") {
return;
}
state.loading = true;
const { data } = await api.recipes.parseIngredient(state.parser, state.ingredient);
if (data) {
state.results = true;
if (data.confidence) confidence.value = data.confidence;
// TODO: Remove ts-ignore
// ts-ignore because data will likely change significantly once I figure out how to return results
// for the parser. For now we'll leave it like this
properties.comment.value = data.ingredient.note || "";
properties.quantity.value = data.ingredient.quantity || "";
properties.unit.value = data.ingredient?.unit?.name || "";
properties.food.value = data.ingredient?.food?.name || "";
(["comment", "quantity", "unit", "food"] as ConfidenceAttribute[]).forEach((property) => {
const color = getColor(property);
const confidence = getConfidence(property);
if (color) {
properties[property].color = color;
}
if (confidence) {
properties[property].confidence = confidence;
}
});
}
else {
alert.error(i18n.t("events.something-went-wrong") as string);
state.results = false;
}
state.loading = false;
}
const api = useUserApi();
const state = reactive({
loading: false,
ingredient: "",
results: false,
parser: "nlp" as Parser,
});
const i18n = useI18n();
// Set page title
useSeoMeta({
title: i18n.t("admin.parser"),
});
const confidence = ref<IngredientConfidence>({});
function getColor(attribute: ConfidenceAttribute) {
const percentage = getConfidence(attribute);
if (percentage === undefined) return;
const p_as_num = parseFloat(percentage.replace("%", ""));
// Set color based off range
if (p_as_num > 75) {
return "success";
}
else if (p_as_num > 60) {
return "warning";
}
else {
return "error";
}
}
function getConfidence(attribute: ConfidenceAttribute) {
if (!confidence.value) {
return;
}
const property = confidence.value[attribute];
if (property !== undefined && property !== null) {
return `${(+property * 100).toFixed(0)}%`;
}
return undefined;
}
const tryText = [
"2 tbsp minced cilantro, leaves and stems",
"1 large yellow onion, coarsely chopped",
"1 1/2 tsp garam masala",
"1 inch piece fresh ginger, (peeled and minced)",
"2 cups mango chunks, (2 large mangoes) (fresh or frozen)",
];
function processTryText(str: string) {
state.ingredient = str;
processIngredient();
}
async function processIngredient() {
if (state.ingredient === "") {
return;
}
state.loading = true;
const { data } = await api.recipes.parseIngredient(state.parser, state.ingredient);
if (data) {
state.results = true;
if (data.confidence) confidence.value = data.confidence;
// TODO: Remove ts-ignore
// ts-ignore because data will likely change significantly once I figure out how to return results
// for the parser. For now we'll leave it like this
properties.comment.value = data.ingredient.note || "";
properties.quantity.value = data.ingredient.quantity || "";
properties.unit.value = data.ingredient?.unit?.name || "";
properties.food.value = data.ingredient?.food?.name || "";
(["comment", "quantity", "unit", "food"] as ConfidenceAttribute[]).forEach((property) => {
const color = getColor(property);
const confidence = getConfidence(property);
if (color) {
properties[property].color = color;
}
if (confidence) {
properties[property].confidence = confidence;
}
});
}
else {
alert.error(i18n.t("events.something-went-wrong") as string);
state.results = false;
}
state.loading = false;
}
const properties = reactive({
quantity: {
subtitle: i18n.t("recipe.quantity"),
value: "" as string | number,
color: null,
confidence: null,
},
unit: {
subtitle: i18n.t("recipe.unit"),
value: "",
color: null,
confidence: null,
},
food: {
subtitle: i18n.t("shopping-list.food"),
value: "",
color: null,
confidence: null,
},
comment: {
subtitle: i18n.t("recipe.comment"),
value: "",
color: null,
confidence: null,
},
});
const showConfidence = ref(false);
return {
showConfidence,
getColor,
confidence,
getConfidence,
...toRefs(state),
tryText,
properties,
processTryText,
processIngredient,
};
const properties = reactive({
quantity: {
subtitle: i18n.t("recipe.quantity"),
value: "" as string | number,
color: null,
confidence: null,
},
unit: {
subtitle: i18n.t("recipe.unit"),
value: "",
color: null,
confidence: null,
},
food: {
subtitle: i18n.t("shopping-list.food"),
value: "",
color: null,
confidence: null,
},
comment: {
subtitle: i18n.t("recipe.comment"),
value: "",
color: null,
confidence: null,
},
});
const showConfidence = ref(false);
</script>
<style scoped></style>

View File

@@ -96,150 +96,136 @@
</v-container>
</template>
<script lang="ts">
<script setup lang="ts">
import { useAdminApi } from "~/composables/api";
import type { MaintenanceStorageDetails, MaintenanceSummary } from "~/lib/api/types/admin";
export default defineNuxtComponent({
setup() {
definePageMeta({
layout: "admin",
});
const state = reactive({
storageDetails: false,
storageDetailsLoading: false,
fetchingInfo: false,
actionLoading: false,
});
const adminApi = useAdminApi();
const i18n = useI18n();
// Set page title
useSeoMeta({
title: i18n.t("admin.maintenance.page-title"),
});
// ==========================================================================
// General Info
const infoResults = ref<MaintenanceSummary>({
dataDirSize: i18n.t("about.unknown-version"),
cleanableDirs: 0,
cleanableImages: 0,
});
async function getSummary() {
state.fetchingInfo = true;
const { data } = await adminApi.maintenance.getInfo();
infoResults.value = data ?? {
dataDirSize: i18n.t("about.unknown-version"),
cleanableDirs: 0,
cleanableImages: 0,
};
state.fetchingInfo = false;
}
const info = computed(() => {
return [
{
name: i18n.t("admin.maintenance.info-description-data-dir-size"),
value: infoResults.value.dataDirSize,
},
{
name: i18n.t("admin.maintenance.info-description-cleanable-directories"),
value: infoResults.value.cleanableDirs,
},
{
name: i18n.t("admin.maintenance.info-description-cleanable-images"),
value: infoResults.value.cleanableImages,
},
];
});
// ==========================================================================
// Storage Details
const storageTitles: { [key: string]: string } = {
tempDirSize: i18n.t("admin.maintenance.storage.title-temporary-directory") as string,
backupsDirSize: i18n.t("admin.maintenance.storage.title-backups-directory") as string,
groupsDirSize: i18n.t("admin.maintenance.storage.title-groups-directory") as string,
recipesDirSize: i18n.t("admin.maintenance.storage.title-recipes-directory") as string,
userDirSize: i18n.t("admin.maintenance.storage.title-user-directory") as string,
};
function storageDetailsText(key: string) {
return storageTitles[key] ?? i18n.t("about.unknown-version");
}
const storageDetails = ref<MaintenanceStorageDetails | null>(null);
async function openDetails() {
state.storageDetailsLoading = true;
state.storageDetails = true;
const { data } = await adminApi.maintenance.getStorageDetails();
if (data) {
storageDetails.value = data;
}
state.storageDetailsLoading = true;
}
// ==========================================================================
// Actions
async function handleCleanDirectories() {
state.actionLoading = true;
await adminApi.maintenance.cleanRecipeFolders();
state.actionLoading = false;
}
async function handleCleanImages() {
state.actionLoading = true;
await adminApi.maintenance.cleanImages();
state.actionLoading = false;
}
async function handleCleanTemp() {
state.actionLoading = true;
await adminApi.maintenance.cleanTemp();
state.actionLoading = false;
}
const actions = [
{
name: i18n.t("admin.maintenance.action-clean-directories-name"),
handler: handleCleanDirectories,
subtitle: i18n.t("admin.maintenance.action-clean-directories-description"),
},
{
name: i18n.t("admin.maintenance.action-clean-temporary-files-name"),
handler: handleCleanTemp,
subtitle: i18n.t("admin.maintenance.action-clean-temporary-files-description"),
},
{
name: i18n.t("admin.maintenance.action-clean-images-name"),
handler: handleCleanImages,
subtitle: i18n.t("admin.maintenance.action-clean-images-description"),
},
];
return {
storageDetailsText,
openDetails,
storageDetails,
state,
info,
getSummary,
actions,
};
},
definePageMeta({
layout: "admin",
});
const state = reactive({
storageDetails: false,
storageDetailsLoading: false,
fetchingInfo: false,
actionLoading: false,
});
const adminApi = useAdminApi();
const i18n = useI18n();
// Set page title
useSeoMeta({
title: i18n.t("admin.maintenance.page-title"),
});
// ==========================================================================
// General Info
const infoResults = ref<MaintenanceSummary>({
dataDirSize: i18n.t("about.unknown-version"),
cleanableDirs: 0,
cleanableImages: 0,
});
async function getSummary() {
state.fetchingInfo = true;
const { data } = await adminApi.maintenance.getInfo();
infoResults.value = data ?? {
dataDirSize: i18n.t("about.unknown-version"),
cleanableDirs: 0,
cleanableImages: 0,
};
state.fetchingInfo = false;
}
const info = computed(() => {
return [
{
name: i18n.t("admin.maintenance.info-description-data-dir-size"),
value: infoResults.value.dataDirSize,
},
{
name: i18n.t("admin.maintenance.info-description-cleanable-directories"),
value: infoResults.value.cleanableDirs,
},
{
name: i18n.t("admin.maintenance.info-description-cleanable-images"),
value: infoResults.value.cleanableImages,
},
];
});
// ==========================================================================
// Storage Details
const storageTitles: { [key: string]: string } = {
tempDirSize: i18n.t("admin.maintenance.storage.title-temporary-directory") as string,
backupsDirSize: i18n.t("admin.maintenance.storage.title-backups-directory") as string,
groupsDirSize: i18n.t("admin.maintenance.storage.title-groups-directory") as string,
recipesDirSize: i18n.t("admin.maintenance.storage.title-recipes-directory") as string,
userDirSize: i18n.t("admin.maintenance.storage.title-user-directory") as string,
};
function storageDetailsText(key: string) {
return storageTitles[key] ?? i18n.t("about.unknown-version");
}
const storageDetails = ref<MaintenanceStorageDetails | null>(null);
async function openDetails() {
state.storageDetailsLoading = true;
state.storageDetails = true;
const { data } = await adminApi.maintenance.getStorageDetails();
if (data) {
storageDetails.value = data;
}
state.storageDetailsLoading = true;
}
// ==========================================================================
// Actions
async function handleCleanDirectories() {
state.actionLoading = true;
await adminApi.maintenance.cleanRecipeFolders();
state.actionLoading = false;
}
async function handleCleanImages() {
state.actionLoading = true;
await adminApi.maintenance.cleanImages();
state.actionLoading = false;
}
async function handleCleanTemp() {
state.actionLoading = true;
await adminApi.maintenance.cleanTemp();
state.actionLoading = false;
}
const actions = [
{
name: i18n.t("admin.maintenance.action-clean-directories-name"),
handler: handleCleanDirectories,
subtitle: i18n.t("admin.maintenance.action-clean-directories-description"),
},
{
name: i18n.t("admin.maintenance.action-clean-temporary-files-name"),
handler: handleCleanTemp,
subtitle: i18n.t("admin.maintenance.action-clean-temporary-files-description"),
},
{
name: i18n.t("admin.maintenance.action-clean-images-name"),
handler: handleCleanImages,
subtitle: i18n.t("admin.maintenance.action-clean-images-description"),
},
];
</script>
<style scoped>

View File

@@ -49,72 +49,58 @@
</v-container>
</template>
<script lang="ts">
<script setup lang="ts">
import GroupPreferencesEditor from "~/components/Domain/Group/GroupPreferencesEditor.vue";
import { useAdminApi } from "~/composables/api";
import { alert } from "~/composables/use-toast";
import type { VForm } from "vuetify/components";
export default defineNuxtComponent({
components: {
GroupPreferencesEditor,
},
setup() {
definePageMeta({
layout: "admin",
});
const route = useRoute();
const i18n = useI18n();
const groupId = computed(() => route.params.id as string);
// ==============================================
// New User Form
const refGroupEditForm = ref<VForm | null>(null);
const adminApi = useAdminApi();
const userError = ref(false);
const { data: group } = useLazyAsyncData(`get-household-${groupId.value}`, async () => {
if (!groupId.value) {
return null;
}
const { data, error } = await adminApi.groups.getOne(groupId.value);
if (error?.response?.status === 404) {
alert.error(i18n.t("user.user-not-found"));
userError.value = true;
}
return data;
}, { watch: [groupId] });
async function handleSubmit() {
if (!refGroupEditForm.value?.validate() || group.value === null) {
return;
}
const { response, data } = await adminApi.groups.updateOne(group.value.id, group.value);
if (response?.status === 200 && data) {
if (group.value.slug !== data.slug) {
// the slug updated, which invalidates the nav URLs
window.location.reload();
}
group.value = data;
}
else {
alert.error(i18n.t("settings.settings-update-failed"));
}
}
return {
group,
userError,
refGroupEditForm,
handleSubmit,
};
},
definePageMeta({
layout: "admin",
});
const route = useRoute();
const i18n = useI18n();
const groupId = computed(() => route.params.id as string);
// ==============================================
// New User Form
const refGroupEditForm = ref<VForm | null>(null);
const adminApi = useAdminApi();
const userError = ref(false);
const { data: group } = useLazyAsyncData(`get-household-${groupId.value}`, async () => {
if (!groupId.value) {
return null;
}
const { data, error } = await adminApi.groups.getOne(groupId.value);
if (error?.response?.status === 404) {
alert.error(i18n.t("user.user-not-found"));
userError.value = true;
}
return data;
}, { watch: [groupId] });
async function handleSubmit() {
if (!refGroupEditForm.value?.validate() || group.value === null) {
return;
}
const { response, data } = await adminApi.groups.updateOne(group.value.id, group.value);
if (response?.status === 200 && data) {
if (group.value.slug !== data.slug) {
// the slug updated, which invalidates the nav URLs
window.location.reload();
}
group.value = data;
}
else {
alert.error(i18n.t("settings.settings-update-failed"));
}
}
</script>

View File

@@ -1,28 +1,28 @@
<template>
<v-container fluid>
<BaseDialog
v-model="createDialog"
v-model="state.createDialog"
:title="$t('group.create-group')"
:icon="$globals.icons.group"
can-submit
@submit="createGroup(createGroupForm.data)"
@submit="createGroup(state.createGroupForm.data)"
>
<template #activator />
<v-card-text>
<AutoForm
v-model="createGroupForm.data"
:update-mode="updateMode"
:items="createGroupForm.items"
v-model="state.createGroupForm.data"
:update-mode="state.updateMode"
:items="state.createGroupForm.items"
/>
</v-card-text>
</BaseDialog>
<BaseDialog
v-model="confirmDialog"
v-model="state.confirmDialog"
:title="$t('general.confirm')"
color="error"
can-confirm
@confirm="deleteGroup(deleteTarget)"
@confirm="deleteGroup(state.deleteTarget)"
>
<template #activator />
<v-card-text>
@@ -43,14 +43,14 @@
</v-toolbar>
<v-data-table
:headers="headers"
:headers="state.headers"
:items="groups || []"
item-key="id"
class="elevation-0"
:items-per-page="-1"
hide-default-footer
disable-pagination
:search="search"
:search="state.search"
@click:row="($event, { item }) => handleRowClick(item)"
>
<template #[`item.households`]="{ item }">
@@ -73,8 +73,8 @@
color="error"
variant="text"
@click.stop="
confirmDialog = true;
deleteTarget = item.id;
state.confirmDialog = true;
state.deleteTarget = item.id;
"
>
<v-icon>
@@ -92,75 +92,68 @@
</v-container>
</template>
<script lang="ts">
<script setup lang="ts">
import { fieldTypes } from "~/composables/forms";
import { useGroups } from "~/composables/use-groups";
import { validators } from "~/composables/use-validators";
import type { GroupInDB } from "~/lib/api/types/user";
export default defineNuxtComponent({
setup() {
definePageMeta({
layout: "admin",
});
definePageMeta({
layout: "admin",
});
const i18n = useI18n();
const i18n = useI18n();
// Set page title
useSeoMeta({
title: i18n.t("group.manage-groups"),
});
useHead({
title: i18n.t("group.manage-groups"),
});
const { groups, refreshAllGroups, deleteGroup, createGroup } = useGroups();
// Set page title
useSeoMeta({
title: i18n.t("group.manage-groups"),
});
const state = reactive({
createDialog: false,
confirmDialog: false,
deleteTarget: "",
search: "",
headers: [
{
title: i18n.t("group.group"),
align: "start",
sortable: false,
value: "id",
},
{ title: i18n.t("general.name"), value: "name" },
{ title: i18n.t("group.total-households"), value: "households" },
{ title: i18n.t("user.total-users"), value: "users" },
{ title: i18n.t("general.delete"), value: "actions" },
],
updateMode: false,
createGroupForm: {
items: [
{
label: i18n.t("group.group-name"),
varName: "name",
type: fieldTypes.TEXT,
rules: [validators.required],
},
],
data: {
name: "",
},
const { groups, deleteGroup, createGroup } = useGroups();
const state = reactive({
createDialog: false,
confirmDialog: false,
deleteTarget: "",
search: "",
headers: [
{
title: i18n.t("group.group"),
align: "start",
sortable: false,
value: "id",
},
{ title: i18n.t("general.name"), value: "name" },
{ title: i18n.t("group.total-households"), value: "households" },
{ title: i18n.t("user.total-users"), value: "users" },
{ title: i18n.t("general.delete"), value: "actions" },
],
updateMode: false,
createGroupForm: {
items: [
{
label: i18n.t("group.group-name"),
varName: "name",
type: fieldTypes.TEXT,
rules: [validators.required],
},
});
function openDialog() {
state.createDialog = true;
state.createGroupForm.data.name = "";
}
function handleRowClick(item: GroupInDB) {
navigateTo(`/admin/manage/groups/${item.id}`);
}
return { ...toRefs(state), groups, refreshAllGroups, deleteGroup, createGroup, openDialog, handleRowClick };
},
head() {
return {
title: useI18n().t("group.manage-groups"),
};
],
data: {
name: "",
},
},
});
function openDialog() {
state.createDialog = true;
state.createGroupForm.data.name = "";
}
function handleRowClick(item: GroupInDB) {
navigateTo(`/admin/manage/groups/${item.id}`);
}
</script>

View File

@@ -67,73 +67,58 @@
</v-container>
</template>
<script lang="ts">
<script setup lang="ts">
import HouseholdPreferencesEditor from "~/components/Domain/Household/HouseholdPreferencesEditor.vue";
import { useGroups } from "~/composables/use-groups";
import { useAdminApi } from "~/composables/api";
import { alert } from "~/composables/use-toast";
import { validators } from "~/composables/use-validators";
import type { VForm } from "vuetify/components";
export default defineNuxtComponent({
components: {
HouseholdPreferencesEditor,
},
setup() {
definePageMeta({
layout: "admin",
});
const route = useRoute();
const i18n = useI18n();
const { groups } = useGroups();
const householdId = computed(() => route.params.id as string);
// ==============================================
// New User Form
const refHouseholdEditForm = ref<VForm | null>(null);
const adminApi = useAdminApi();
const userError = ref(false);
const { data: household } = useAsyncData(`get-household-${householdId.value}`, async () => {
if (!householdId.value) {
return null;
}
const { data, error } = await adminApi.households.getOne(householdId.value);
if (error?.response?.status === 404) {
alert.error(i18n.t("user.user-not-found"));
userError.value = true;
}
return data;
}, { watch: [householdId] });
async function handleSubmit() {
if (!refHouseholdEditForm.value?.validate() || household.value === null) {
return;
}
const { response, data } = await adminApi.households.updateOne(household.value.id, household.value);
if (response?.status === 200 && data) {
household.value = data;
alert.success(i18n.t("settings.settings-updated"));
}
else {
alert.error(i18n.t("settings.settings-update-failed"));
}
}
return {
groups,
household,
validators,
userError,
refHouseholdEditForm,
handleSubmit,
};
},
definePageMeta({
layout: "admin",
});
const route = useRoute();
const i18n = useI18n();
const { groups } = useGroups();
const householdId = computed(() => route.params.id as string);
// ==============================================
// New User Form
const refHouseholdEditForm = ref<VForm | null>(null);
const adminApi = useAdminApi();
const userError = ref(false);
const { data: household } = useAsyncData(`get-household-${householdId.value}`, async () => {
if (!householdId.value) {
return null;
}
const { data, error } = await adminApi.households.getOne(householdId.value);
if (error?.response?.status === 404) {
alert.error(i18n.t("user.user-not-found"));
userError.value = true;
}
return data;
}, { watch: [householdId] });
async function handleSubmit() {
if (!refHouseholdEditForm.value?.validate() || household.value === null) {
return;
}
const { response, data } = await adminApi.households.updateOne(household.value.id, household.value);
if (response?.status === 200 && data) {
household.value = data;
alert.success(i18n.t("settings.settings-updated"));
}
else {
alert.error(i18n.t("settings.settings-update-failed"));
}
}
</script>

View File

@@ -137,7 +137,7 @@
</v-container>
</template>
<script lang="ts">
<script setup lang="ts">
import { useAdminApi, useUserApi } from "~/composables/api";
import { useGroups } from "~/composables/use-groups";
import { useAdminHouseholds } from "~/composables/use-households";
@@ -146,103 +146,83 @@ import { useUserForm } from "~/composables/use-users";
import { validators } from "~/composables/use-validators";
import type { UserOut } from "~/lib/api/types/user";
export default defineNuxtComponent({
setup() {
definePageMeta({
layout: "admin",
});
const { userForm } = useUserForm();
const { groups } = useGroups();
const { useHouseholdsInGroup } = useAdminHouseholds();
const i18n = useI18n();
const route = useRoute();
const userId = route.params.id as string;
// ==============================================
// New User Form
const refNewUserForm = ref<VForm | null>(null);
const adminApi = useAdminApi();
const user = ref<UserOut | null>(null);
const households = useHouseholdsInGroup(computed(() => user.value?.groupId || ""));
const disabledFields = computed(() => {
return user.value?.authMethod !== "Mealie" ? ["admin"] : [];
});
const userError = ref(false);
const resetUrl = ref<string | null>(null);
const generatingToken = ref(false);
onMounted(async () => {
const { data, error } = await adminApi.users.getOne(userId);
if (error?.response?.status === 404) {
alert.error(i18n.t("user.user-not-found"));
userError.value = true;
}
if (data) {
user.value = data;
}
});
async function handleSubmit() {
if (!refNewUserForm.value?.validate() || user.value === null) return;
const { response, data } = await adminApi.users.updateOne(user.value.id, user.value);
if (response?.status === 200 && data) {
user.value = data;
}
}
async function handlePasswordReset() {
if (user.value === null) return;
generatingToken.value = true;
const { response, data } = await adminApi.users.generatePasswordResetToken({ email: user.value.email });
if (response?.status === 201 && data) {
const token: string = data.token;
resetUrl.value = `${window.location.origin}/reset-password/?token=${token}`;
}
generatingToken.value = false;
}
const userApi = useUserApi();
async function sendResetEmail() {
if (!user.value?.email) return;
const { response } = await userApi.email.sendForgotPassword({ email: user.value.email });
if (response && response.status === 200) {
alert.success(i18n.t("profile.email-sent"));
}
else {
alert.error(i18n.t("profile.error-sending-email"));
}
}
return {
user,
disabledFields,
userError,
userForm,
refNewUserForm,
handleSubmit,
groups,
households,
validators,
handlePasswordReset,
resetUrl,
generatingToken,
sendResetEmail,
};
},
definePageMeta({
layout: "admin",
});
const { userForm } = useUserForm();
const { groups } = useGroups();
const { useHouseholdsInGroup } = useAdminHouseholds();
const i18n = useI18n();
const route = useRoute();
const userId = route.params.id as string;
// ==============================================
// New User Form
const refNewUserForm = ref<VForm | null>(null);
const adminApi = useAdminApi();
const user = ref<UserOut | null>(null);
const households = useHouseholdsInGroup(computed(() => user.value?.groupId || ""));
const disabledFields = computed(() => {
return user.value?.authMethod !== "Mealie" ? ["admin"] : [];
});
const userError = ref(false);
const resetUrl = ref<string | null>(null);
const generatingToken = ref(false);
onMounted(async () => {
const { data, error } = await adminApi.users.getOne(userId);
if (error?.response?.status === 404) {
alert.error(i18n.t("user.user-not-found"));
userError.value = true;
}
if (data) {
user.value = data;
}
});
async function handleSubmit() {
if (!refNewUserForm.value?.validate() || user.value === null) return;
const { response, data } = await adminApi.users.updateOne(user.value.id, user.value);
if (response?.status === 200 && data) {
user.value = data;
}
}
async function handlePasswordReset() {
if (user.value === null) return;
generatingToken.value = true;
const { response, data } = await adminApi.users.generatePasswordResetToken({ email: user.value.email });
if (response?.status === 201 && data) {
const token: string = data.token;
resetUrl.value = `${window.location.origin}/reset-password/?token=${token}`;
}
generatingToken.value = false;
}
const userApi = useUserApi();
async function sendResetEmail() {
if (!user.value?.email) return;
const { response } = await userApi.email.sendForgotPassword({ email: user.value.email });
if (response && response.status === 200) {
alert.success(i18n.t("profile.email-sent"));
}
else {
alert.error(i18n.t("profile.error-sending-email"));
}
}
</script>

View File

@@ -2,11 +2,11 @@
<v-container fluid>
<UserInviteDialog v-model="inviteDialog" />
<BaseDialog
v-model="deleteDialog"
v-model="state.deleteDialog"
:title="$t('general.confirm')"
color="error"
can-confirm
@confirm="deleteUser(deleteTargetId)"
@confirm="deleteUser(state.deleteTargetId)"
>
<template #activator />
@@ -60,7 +60,7 @@
:items-per-page="-1"
hide-default-footer
disable-pagination
:search="search"
:search="state.search"
@click:row="($event, { item }) => handleRowClick(item)"
>
<template #[`item.admin`]="{ item }">
@@ -78,8 +78,8 @@
color="error"
variant="text"
@click.stop="
deleteDialog = true;
deleteTargetId = item.id;
state.deleteDialog = true;
state.deleteTargetId = item.id;
"
>
<v-icon>
@@ -93,123 +93,99 @@
</v-container>
</template>
<script lang="ts">
<script setup lang="ts">
import { useAdminApi } from "~/composables/api";
import { alert } from "~/composables/use-toast";
import { useUser, useAllUsers } from "~/composables/use-user";
import type { UserOut } from "~/lib/api/types/user";
import UserInviteDialog from "~/components/Domain/User/UserInviteDialog.vue";
export default defineNuxtComponent({
components: {
UserInviteDialog,
definePageMeta({
layout: "admin",
});
const i18n = useI18n();
useHead({
title: i18n.t("sidebar.manage-users"),
});
const api = useAdminApi();
const inviteDialog = ref();
const auth = useMealieAuth();
const user = computed(() => auth.user.value);
const { $globals } = useNuxtApp();
const router = useRouter();
const isUserOwnAccount = computed(() => {
return state.deleteTargetId === user.value?.id;
});
const ACTIONS_OPTIONS = [
{
text: i18n.t("user.reset-locked-users"),
icon: $globals.icons.lock,
event: "unlock-all-users",
},
setup() {
definePageMeta({
layout: "admin",
});
];
const api = useAdminApi();
const refUserDialog = ref();
const inviteDialog = ref();
const auth = useMealieAuth();
const state = reactive({
deleteDialog: false,
deleteTargetId: "",
search: "",
groups: [],
households: [],
sendTo: "",
});
const user = computed(() => auth.user.value);
const { users, refreshAllUsers } = useAllUsers();
const { deleteUser: deleteUserMixin } = useUser(refreshAllUsers);
const i18n = useI18n();
const { $globals } = useNuxtApp();
function deleteUser(id: string) {
deleteUserMixin(id);
const router = useRouter();
if (isUserOwnAccount.value) {
auth.refresh();
}
}
const isUserOwnAccount = computed(() => {
return state.deleteTargetId === user.value?.id;
});
function handleRowClick(item: UserOut) {
router.push(`/admin/manage/users/${item.id}`);
}
const ACTIONS_OPTIONS = [
{
text: i18n.t("user.reset-locked-users"),
icon: $globals.icons.lock,
event: "unlock-all-users",
},
];
// ==========================================================
// Constants / Non-reactive
const state = reactive({
deleteDialog: false,
deleteTargetId: "",
search: "",
groups: [],
households: [],
sendTo: "",
});
const { users, refreshAllUsers } = useAllUsers();
const { loading, deleteUser: deleteUserMixin } = useUser(refreshAllUsers);
function deleteUser(id: string) {
deleteUserMixin(id);
if (isUserOwnAccount.value) {
auth.refresh();
}
}
function handleRowClick(item: UserOut) {
router.push(`/admin/manage/users/${item.id}`);
}
// ==========================================================
// Constants / Non-reactive
const headers = [
{
title: i18n.t("user.user-id"),
align: "start",
value: "id",
},
{ title: i18n.t("user.username"), value: "username" },
{ title: i18n.t("user.full-name"), value: "fullName" },
{ title: i18n.t("user.email"), value: "email" },
{ title: i18n.t("group.group"), value: "group" },
{ title: i18n.t("household.household"), value: "household" },
{ title: i18n.t("user.auth-method"), value: "authMethod" },
{ title: i18n.t("user.admin"), value: "admin" },
{ title: i18n.t("general.delete"), value: "actions", sortable: false, align: "center" },
];
async function unlockAllUsers(): Promise<void> {
const { data } = await api.users.unlockAllUsers(true);
if (data) {
const unlocked = data.unlocked ?? 0;
alert.success(`${unlocked} user(s) unlocked`);
refreshAllUsers();
}
}
useSeoMeta({
title: i18n.t("sidebar.manage-users"),
});
return {
isUserOwnAccount,
unlockAllUsers,
...toRefs(state),
headers,
deleteUser,
loading,
refUserDialog,
inviteDialog,
users,
user,
handleRowClick,
ACTIONS_OPTIONS,
};
},
head() {
return {
title: useI18n().t("sidebar.manage-users"),
};
const headers = [
{
title: i18n.t("user.user-id"),
align: "start",
value: "id",
},
{ title: i18n.t("user.username"), value: "username" },
{ title: i18n.t("user.full-name"), value: "fullName" },
{ title: i18n.t("user.email"), value: "email" },
{ title: i18n.t("group.group"), value: "group" },
{ title: i18n.t("household.household"), value: "household" },
{ title: i18n.t("user.auth-method"), value: "authMethod" },
{ title: i18n.t("user.admin"), value: "admin" },
{ title: i18n.t("general.delete"), value: "actions", sortable: false, align: "center" },
];
async function unlockAllUsers(): Promise<void> {
const { data } = await api.users.unlockAllUsers(true);
if (data) {
const unlocked = data.unlocked ?? 0;
alert.success(`${unlocked} user(s) unlocked`);
refreshAllUsers();
}
}
useSeoMeta({
title: i18n.t("sidebar.manage-users"),
});
</script>

View File

@@ -126,7 +126,7 @@
</div>
<div>
<v-text-field
v-model="address"
v-model="state.address"
class="mr-4"
:label="$t('user.email')"
:rules="[validators.email]"
@@ -135,7 +135,7 @@
color="info"
variant="elevated"
:disabled="!appConfig.emailReady || !validEmail"
:loading="loading"
:loading="state.loading"
class="opacity-100"
@click="testEmail"
>
@@ -144,12 +144,12 @@
</template>
{{ $t("general.test") }}
</BaseButton>
<template v-if="tested">
<template v-if="state.tested">
<v-divider class="my-x mt-6" />
<v-card-text class="px-0">
<h4> {{ $t("settings.email-test-results") }}</h4>
<span class="pl-4">
{{ success ? $t('settings.succeeded') : $t('settings.failed') }}
{{ state.success ? $t('settings.succeeded') : $t('settings.failed') }}
</span>
</v-card-text>
</template>
@@ -226,7 +226,7 @@
</v-container>
</template>
<script lang="ts">
<script setup lang="ts">
import type { TranslateResult } from "vue-i18n";
import { useAdminApi, useUserApi } from "~/composables/api";
import { validators } from "~/composables/use-validators";
@@ -234,12 +234,6 @@ import { useAsyncKey } from "~/composables/use-utils";
import type { CheckAppConfig } from "~/lib/api/types/admin";
import AppLoader from "~/components/global/AppLoader.vue";
enum DockerVolumeState {
Unknown = "unknown",
Success = "success",
Error = "error",
}
interface SimpleCheck {
id: string;
text: TranslateResult;
@@ -254,269 +248,252 @@ interface CheckApp extends CheckAppConfig {
isSiteSecure?: boolean;
}
export default defineNuxtComponent({
components: { AppLoader },
setup() {
definePageMeta({
layout: "admin",
});
definePageMeta({
layout: "admin",
});
// For some reason the layout is not set automatically, so we set it here,
// even though it's defined above in the page meta.
onMounted(() => {
setPageLayout("admin");
});
// For some reason the layout is not set automatically, so we set it here,
// even though it's defined above in the page meta.
onMounted(() => {
setPageLayout("admin");
});
const { $globals } = useNuxtApp();
const i18n = useI18n();
const { $globals } = useNuxtApp();
const i18n = useI18n();
const state = reactive({
loading: false,
address: "",
success: false,
error: "",
tested: false,
});
const state = reactive({
loading: false,
address: "",
success: false,
error: "",
tested: false,
});
// Set page title
useSeoMeta({
title: i18n.t("settings.site-settings"),
});
// Set page title
useSeoMeta({
title: i18n.t("settings.site-settings"),
});
const appConfig = ref<CheckApp>({
emailReady: true,
baseUrlSet: true,
isSiteSecure: true,
isUpToDate: false,
ldapReady: false,
oidcReady: false,
enableOpenai: false,
});
function isLocalHostOrHttps() {
return window.location.hostname === "localhost" || window.location.protocol === "https:";
const appConfig = ref<CheckApp>({
emailReady: true,
baseUrlSet: true,
isSiteSecure: true,
isUpToDate: false,
ldapReady: false,
oidcReady: false,
enableOpenai: false,
});
function isLocalHostOrHttps() {
return window.location.hostname === "localhost" || window.location.protocol === "https:";
}
const api = useUserApi();
const adminApi = useAdminApi();
onMounted(async () => {
const { data } = await adminApi.about.checkApp();
if (data) {
appConfig.value = { ...data, isSiteSecure: false };
}
appConfig.value.isSiteSecure = isLocalHostOrHttps();
});
const simpleChecks = computed<SimpleCheck[]>(() => {
const goodIcon = $globals.icons.checkboxMarkedCircle;
const badIcon = $globals.icons.alert;
const warningIcon = $globals.icons.alertCircle;
const goodColor = "success";
const badColor = "error";
const warningColor = "warning";
const data: SimpleCheck[] = [
{
id: "application-version",
text: i18n.t("settings.application-version"),
status: appConfig.value.isUpToDate,
errorText: i18n.t("settings.application-version-error-text", [rawAppInfo.value.version, rawAppInfo.value.versionLatest]),
successText: i18n.t("settings.mealie-is-up-to-date"),
color: appConfig.value.isUpToDate ? goodColor : warningColor,
icon: appConfig.value.isUpToDate ? goodIcon : warningIcon,
},
{
id: "secure-site",
text: i18n.t("settings.secure-site"),
status: appConfig.value.isSiteSecure,
errorText: i18n.t("settings.secure-site-error-text"),
successText: i18n.t("settings.secure-site-success-text"),
color: appConfig.value.isSiteSecure ? goodColor : badColor,
icon: appConfig.value.isSiteSecure ? goodIcon : badIcon,
},
{
id: "server-side-base-url",
text: i18n.t("settings.server-side-base-url"),
status: appConfig.value.baseUrlSet,
errorText: i18n.t("settings.server-side-base-url-error-text"),
successText: i18n.t("settings.server-side-base-url-success-text"),
color: appConfig.value.baseUrlSet ? goodColor : badColor,
icon: appConfig.value.baseUrlSet ? goodIcon : badIcon,
},
{
id: "ldap-ready",
text: i18n.t("settings.ldap-ready"),
status: appConfig.value.ldapReady,
errorText: i18n.t("settings.ldap-ready-error-text"),
successText: i18n.t("settings.ldap-ready-success-text"),
color: appConfig.value.ldapReady ? goodColor : warningColor,
icon: appConfig.value.ldapReady ? goodIcon : warningIcon,
},
{
id: "oidc-ready",
text: i18n.t("settings.oidc-ready"),
status: appConfig.value.oidcReady,
errorText: i18n.t("settings.oidc-ready-error-text"),
successText: i18n.t("settings.oidc-ready-success-text"),
color: appConfig.value.oidcReady ? goodColor : warningColor,
icon: appConfig.value.oidcReady ? goodIcon : warningIcon,
},
{
id: "openai-ready",
text: i18n.t("settings.openai-ready"),
status: appConfig.value.enableOpenai,
errorText: i18n.t("settings.openai-ready-error-text"),
successText: i18n.t("settings.openai-ready-success-text"),
color: appConfig.value.enableOpenai ? goodColor : warningColor,
icon: appConfig.value.enableOpenai ? goodIcon : warningIcon,
},
];
return data;
});
async function testEmail() {
state.loading = true;
state.tested = false;
const { data } = await api.email.test({ email: state.address });
if (data) {
if (data.success) {
state.success = true;
}
const api = useUserApi();
const adminApi = useAdminApi();
onMounted(async () => {
const { data } = await adminApi.about.checkApp();
if (data) {
appConfig.value = { ...data, isSiteSecure: false };
}
appConfig.value.isSiteSecure = isLocalHostOrHttps();
});
const simpleChecks = computed<SimpleCheck[]>(() => {
const goodIcon = $globals.icons.checkboxMarkedCircle;
const badIcon = $globals.icons.alert;
const warningIcon = $globals.icons.alertCircle;
const goodColor = "success";
const badColor = "error";
const warningColor = "warning";
const data: SimpleCheck[] = [
else {
state.error = data.error ?? "";
state.success = false;
}
}
state.loading = false;
state.tested = true;
}
const validEmail = computed(() => {
if (state.address === "") {
return false;
}
const valid = validators.email(state.address);
// Explicit bool check because validators.email sometimes returns a string
if (valid === true) {
return true;
}
return false;
});
// ============================================================
// General About Info
const rawAppInfo = ref({
version: "null",
versionLatest: "null",
});
function getAppInfo() {
const { data: statistics } = useAsyncData(useAsyncKey(), async () => {
const { data } = await adminApi.about.about();
if (data) {
rawAppInfo.value.version = data.version;
rawAppInfo.value.versionLatest = data.versionLatest;
const prettyInfo = [
{
id: "application-version",
text: i18n.t("settings.application-version"),
status: appConfig.value.isUpToDate,
errorText: i18n.t("settings.application-version-error-text", [rawAppInfo.value.version, rawAppInfo.value.versionLatest]),
successText: i18n.t("settings.mealie-is-up-to-date"),
color: appConfig.value.isUpToDate ? goodColor : warningColor,
icon: appConfig.value.isUpToDate ? goodIcon : warningIcon,
slot: "version",
name: i18n.t("about.version"),
icon: $globals.icons.information,
value: data.version,
},
{
id: "secure-site",
text: i18n.t("settings.secure-site"),
status: appConfig.value.isSiteSecure,
errorText: i18n.t("settings.secure-site-error-text"),
successText: i18n.t("settings.secure-site-success-text"),
color: appConfig.value.isSiteSecure ? goodColor : badColor,
icon: appConfig.value.isSiteSecure ? goodIcon : badIcon,
slot: "build",
name: i18n.t("settings.build"),
icon: $globals.icons.information,
value: data.buildId,
},
{
id: "server-side-base-url",
text: i18n.t("settings.server-side-base-url"),
status: appConfig.value.baseUrlSet,
errorText: i18n.t("settings.server-side-base-url-error-text"),
successText: i18n.t("settings.server-side-base-url-success-text"),
color: appConfig.value.baseUrlSet ? goodColor : badColor,
icon: appConfig.value.baseUrlSet ? goodIcon : badIcon,
name: i18n.t("about.application-mode"),
icon: $globals.icons.devTo,
value: data.production ? i18n.t("about.production") : i18n.t("about.development"),
},
{
id: "ldap-ready",
text: i18n.t("settings.ldap-ready"),
status: appConfig.value.ldapReady,
errorText: i18n.t("settings.ldap-ready-error-text"),
successText: i18n.t("settings.ldap-ready-success-text"),
color: appConfig.value.ldapReady ? goodColor : warningColor,
icon: appConfig.value.ldapReady ? goodIcon : warningIcon,
name: i18n.t("about.demo-status"),
icon: $globals.icons.testTube,
value: data.demoStatus ? i18n.t("about.demo") : i18n.t("about.not-demo"),
},
{
id: "oidc-ready",
text: i18n.t("settings.oidc-ready"),
status: appConfig.value.oidcReady,
errorText: i18n.t("settings.oidc-ready-error-text"),
successText: i18n.t("settings.oidc-ready-success-text"),
color: appConfig.value.oidcReady ? goodColor : warningColor,
icon: appConfig.value.oidcReady ? goodIcon : warningIcon,
name: i18n.t("about.api-port"),
icon: $globals.icons.api,
value: data.apiPort,
},
{
id: "openai-ready",
text: i18n.t("settings.openai-ready"),
status: appConfig.value.enableOpenai,
errorText: i18n.t("settings.openai-ready-error-text"),
successText: i18n.t("settings.openai-ready-success-text"),
color: appConfig.value.enableOpenai ? goodColor : warningColor,
icon: appConfig.value.enableOpenai ? goodIcon : warningIcon,
name: i18n.t("about.api-docs"),
icon: $globals.icons.file,
value: data.apiDocs ? i18n.t("general.enabled") : i18n.t("general.disabled"),
},
{
name: i18n.t("about.database-type"),
icon: $globals.icons.database,
value: data.dbType,
},
{
name: i18n.t("about.database-url"),
icon: $globals.icons.database,
value: data.dbUrl,
},
{
name: i18n.t("about.default-group"),
icon: $globals.icons.group,
value: data.defaultGroup,
},
{
name: i18n.t("about.default-household"),
icon: $globals.icons.household,
value: data.defaultHousehold,
},
{
slot: "recipe-scraper",
name: i18n.t("settings.recipe-scraper-version"),
icon: $globals.icons.primary,
value: data.recipeScraperVersion,
},
];
return data;
});
async function testEmail() {
state.loading = true;
state.tested = false;
const { data } = await api.email.test({ email: state.address });
if (data) {
if (data.success) {
state.success = true;
}
else {
state.error = data.error ?? "";
state.success = false;
}
}
state.loading = false;
state.tested = true;
return prettyInfo;
}
const validEmail = computed(() => {
if (state.address === "") {
return false;
}
const valid = validators.email(state.address);
// Explicit bool check because validators.email sometimes returns a string
if (valid === true) {
return true;
}
return false;
});
// ============================================================
// General About Info
const rawAppInfo = ref({
version: "null",
versionLatest: "null",
});
function getAppInfo() {
const { data: statistics } = useAsyncData(useAsyncKey(), async () => {
const { data } = await adminApi.about.about();
if (data) {
rawAppInfo.value.version = data.version;
rawAppInfo.value.versionLatest = data.versionLatest;
const prettyInfo = [
{
slot: "version",
name: i18n.t("about.version"),
icon: $globals.icons.information,
value: data.version,
},
{
slot: "build",
name: i18n.t("settings.build"),
icon: $globals.icons.information,
value: data.buildId,
},
{
name: i18n.t("about.application-mode"),
icon: $globals.icons.devTo,
value: data.production ? i18n.t("about.production") : i18n.t("about.development"),
},
{
name: i18n.t("about.demo-status"),
icon: $globals.icons.testTube,
value: data.demoStatus ? i18n.t("about.demo") : i18n.t("about.not-demo"),
},
{
name: i18n.t("about.api-port"),
icon: $globals.icons.api,
value: data.apiPort,
},
{
name: i18n.t("about.api-docs"),
icon: $globals.icons.file,
value: data.apiDocs ? i18n.t("general.enabled") : i18n.t("general.disabled"),
},
{
name: i18n.t("about.database-type"),
icon: $globals.icons.database,
value: data.dbType,
},
{
name: i18n.t("about.database-url"),
icon: $globals.icons.database,
value: data.dbUrl,
},
{
name: i18n.t("about.default-group"),
icon: $globals.icons.group,
value: data.defaultGroup,
},
{
name: i18n.t("about.default-household"),
icon: $globals.icons.household,
value: data.defaultHousehold,
},
{
slot: "recipe-scraper",
name: i18n.t("settings.recipe-scraper-version"),
icon: $globals.icons.primary,
value: data.recipeScraperVersion,
},
];
return prettyInfo;
}
return data;
});
return statistics;
return data;
});
return statistics;
}
const appInfo = getAppInfo();
const bugReportDialog = ref(false);
const bugReportText = computed(() => {
const ignore = {
[i18n.t("about.database-url")]: true,
[i18n.t("about.default-group")]: true,
};
let text = "**Details**\n";
appInfo.value?.forEach((item) => {
if (ignore[item.name as string]) {
return;
}
const appInfo = getAppInfo();
const bugReportDialog = ref(false);
const bugReportText = computed(() => {
const ignore = {
[i18n.t("about.database-url")]: true,
[i18n.t("about.default-group")]: true,
};
let text = "**Details**\n";
appInfo.value?.forEach((item) => {
if (ignore[item.name as string]) {
return;
}
text += `${item.name as string}: ${item.value as string}\n`;
});
const ignoreChecks: {
[key: string]: boolean;
} = {
"application-version": true,
};
text += "\n**Checks**\n";
simpleChecks.value.forEach((item) => {
if (ignoreChecks[item.id]) {
return;
}
const status = item.status ? i18n.t("general.yes") : i18n.t("general.no");
text += `${item.text.toString()}: ${status}\n`;
});
text += `${i18n.t("settings.email-configured")}: ${appConfig.value.emailReady ? i18n.t("general.yes") : i18n.t("general.no")}\n`;
return text;
});
return {
bugReportDialog,
bugReportText,
DockerVolumeState,
simpleChecks,
appConfig,
validEmail,
validators,
...toRefs(state),
testEmail,
appInfo,
};
},
text += `${item.name as string}: ${item.value as string}\n`;
});
const ignoreChecks: {
[key: string]: boolean;
} = {
"application-version": true,
};
text += "\n**Checks**\n";
simpleChecks.value.forEach((item) => {
if (ignoreChecks[item.id]) {
return;
}
const status = item.status ? i18n.t("general.yes") : i18n.t("general.no");
text += `${item.text.toString()}: ${status}\n`;
});
text += `${i18n.t("settings.email-configured")}: ${appConfig.value.emailReady ? i18n.t("general.yes") : i18n.t("general.no")}\n`;
return text;
});
</script>

View File

@@ -16,7 +16,7 @@
<v-card-text>
<v-form @submit.prevent="requestLink()">
<v-text-field
v-model="email"
v-model="state.email"
:prepend-inner-icon="$globals.icons.email"
variant="solo-filled"
flat
@@ -31,7 +31,7 @@
<v-card-actions class="justify-center">
<div class="max-button">
<v-btn
:loading="loading"
:loading="state.loading"
color="primary"
type="submit"
size="large"
@@ -60,54 +60,45 @@
</v-container>
</template>
<script lang="ts">
<script setup lang="ts">
import { useUserApi } from "~/composables/api";
import { alert } from "~/composables/use-toast";
export default defineNuxtComponent({
setup() {
definePageMeta({
layout: "basic",
});
const state = reactive({
email: "",
loading: false,
error: false,
});
const i18n = useI18n();
// Set page title
useSeoMeta({
title: i18n.t("user.login"),
});
const api = useUserApi();
async function requestLink() {
state.loading = true;
// TODO: Fix Response to send meaningful error
const { response } = await api.email.sendForgotPassword({ email: state.email });
if (response?.status === 200) {
state.loading = false;
state.error = false;
alert.success(i18n.t("profile.email-sent"));
}
else {
state.loading = false;
state.error = true;
alert.error(i18n.t("profile.error-sending-email"));
}
}
return {
requestLink,
...toRefs(state),
};
},
definePageMeta({
layout: "basic",
});
const state = reactive({
email: "",
loading: false,
error: false,
});
const i18n = useI18n();
// Set page title
useSeoMeta({
title: i18n.t("user.login"),
});
const api = useUserApi();
async function requestLink() {
state.loading = true;
// TODO: Fix Response to send meaningful error
const { response } = await api.email.sendForgotPassword({ email: state.email });
if (response?.status === 200) {
state.loading = false;
state.error = false;
alert.success(i18n.t("profile.email-sent"));
}
else {
state.loading = false;
state.error = true;
alert.error(i18n.t("profile.error-sending-email"));
}
}
</script>
<style lang="css">

View File

@@ -2,10 +2,6 @@
<CookbookPage />
</template>
<script lang="ts">
<script setup lang="ts">
import CookbookPage from "@/components/Domain/Cookbook/CookbookPage.vue";
export default defineNuxtComponent({
components: { CookbookPage },
});
</script>

View File

@@ -135,7 +135,7 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import { VueDraggable } from "vue-draggable-plus";
import { useCookbookStore } from "~/composables/store/use-cookbook-store";
import { useHouseholdSelf } from "@/composables/use-households";
@@ -143,121 +143,99 @@ import CookbookEditor from "~/components/Domain/Cookbook/CookbookEditor.vue";
import type { CreateCookBook, ReadCookBook } from "~/lib/api/types/cookbook";
import { useCookbookPreferences } from "~/composables/use-users/preferences";
export default defineNuxtComponent({
components: { CookbookEditor, VueDraggable },
definePageMeta({
middleware: ["group-only"],
setup() {
const dialogStates = reactive({
create: false,
delete: false,
});
});
const i18n = useI18n();
const dialogStates = reactive({
create: false,
delete: false,
});
// Set page title
useSeoMeta({
title: i18n.t("cookbook.cookbooks"),
});
const i18n = useI18n();
const auth = useMealieAuth();
const { store: allCookbooks, actions, updateAll } = useCookbookStore();
// Set page title
useSeoMeta({
title: i18n.t("cookbook.cookbooks"),
});
// Make a local reactive copy of myCookbooks
const myCookbooks = ref<ReadCookBook[]>([]);
watch(
allCookbooks,
(cookbooks) => {
myCookbooks.value
= cookbooks?.filter(
cookbook => cookbook.householdId === auth.user.value?.householdId,
).sort((a, b) => a.position > b.position) ?? [];
},
{ immediate: true },
);
const auth = useMealieAuth();
const { store: allCookbooks, actions, updateAll } = useCookbookStore();
const { household } = useHouseholdSelf();
const cookbookPreferences = useCookbookPreferences();
// create
const createTargetKey = ref(0);
const createTarget = ref<ReadCookBook | null>(null);
async function createCookbook() {
const name = i18n.t("cookbook.household-cookbook-name", [
household.value?.name || "",
String((myCookbooks.value?.length ?? 0) + 1),
]) as string;
const data = { name } as CreateCookBook;
await actions.createOne(data).then((cookbook) => {
if (!cookbook) {
return;
}
myCookbooks.value.push(cookbook);
createTarget.value = cookbook as ReadCookBook;
createTargetKey.value++;
});
dialogStates.create = true;
}
// delete
const deleteTarget = ref<ReadCookBook | null>(null);
function deleteEventHandler(item: ReadCookBook) {
deleteTarget.value = item;
dialogStates.delete = true;
}
async function deleteCookbook() {
if (!deleteTarget.value) {
return;
}
await actions.deleteOne(deleteTarget.value.id);
myCookbooks.value = myCookbooks.value.filter(c => c.id !== deleteTarget.value?.id);
dialogStates.delete = false;
deleteTarget.value = null;
}
async function deleteCreateTarget() {
if (!createTarget.value?.id) {
return;
}
await actions.deleteOne(createTarget.value.id);
myCookbooks.value = myCookbooks.value.filter(c => c.id !== createTarget.value?.id);
dialogStates.create = false;
createTarget.value = null;
}
function handleUnmount() {
if (!createTarget.value?.id || createTarget.value.queryFilterString) {
return;
}
deleteCreateTarget();
}
onMounted(() => {
window.addEventListener("beforeunload", handleUnmount);
});
onBeforeUnmount(() => {
handleUnmount();
window.removeEventListener("beforeunload", handleUnmount);
});
return {
myCookbooks,
cookbookPreferences,
actions,
dialogStates,
// create
createTargetKey,
createTarget,
createCookbook,
// update
updateAll,
// delete
deleteTarget,
deleteEventHandler,
deleteCookbook,
deleteCreateTarget,
};
// Make a local reactive copy of myCookbooks
const myCookbooks = ref<ReadCookBook[]>([]);
watch(
allCookbooks,
(cookbooks) => {
myCookbooks.value
= cookbooks?.filter(
cookbook => cookbook.householdId === auth.user.value?.householdId,
).sort((a, b) => a.position > b.position) ?? [];
},
{ immediate: true },
);
const { household } = useHouseholdSelf();
const cookbookPreferences = useCookbookPreferences();
// create
const createTargetKey = ref(0);
const createTarget = ref<ReadCookBook | null>(null);
async function createCookbook() {
const name = i18n.t("cookbook.household-cookbook-name", [
household.value?.name || "",
String((myCookbooks.value?.length ?? 0) + 1),
]) as string;
const data = { name } as CreateCookBook;
await actions.createOne(data).then((cookbook) => {
if (!cookbook) {
return;
}
myCookbooks.value.push(cookbook);
createTarget.value = cookbook as ReadCookBook;
createTargetKey.value++;
});
dialogStates.create = true;
}
// delete
const deleteTarget = ref<ReadCookBook | null>(null);
function deleteEventHandler(item: ReadCookBook) {
deleteTarget.value = item;
dialogStates.delete = true;
}
async function deleteCookbook() {
if (!deleteTarget.value) {
return;
}
await actions.deleteOne(deleteTarget.value.id);
myCookbooks.value = myCookbooks.value.filter(c => c.id !== deleteTarget.value?.id);
dialogStates.delete = false;
deleteTarget.value = null;
}
async function deleteCreateTarget() {
if (!createTarget.value?.id) {
return;
}
await actions.deleteOne(createTarget.value.id);
myCookbooks.value = myCookbooks.value.filter(c => c.id !== createTarget.value?.id);
dialogStates.create = false;
createTarget.value = null;
}
function handleUnmount() {
if (!createTarget.value?.id || createTarget.value.queryFilterString) {
return;
}
deleteCreateTarget();
}
onMounted(() => {
window.addEventListener("beforeunload", handleUnmount);
});
onBeforeUnmount(() => {
handleUnmount();
window.removeEventListener("beforeunload", handleUnmount);
});
</script>

View File

@@ -4,10 +4,6 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import RecipeExplorerPage from "~/components/Domain/Recipe/RecipeExplorerPage/RecipeExplorerPage.vue";
export default defineNuxtComponent({
components: { RecipeExplorerPage },
});
</script>

View File

@@ -42,79 +42,71 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import type { MenuItem } from "~/components/global/BaseOverflowButton.vue";
import AdvancedOnly from "~/components/global/AdvancedOnly.vue";
export default defineNuxtComponent({
components: { AdvancedOnly },
definePageMeta({
middleware: ["group-only"],
setup() {
const i18n = useI18n();
const auth = useMealieAuth();
const { $appInfo, $globals } = useNuxtApp();
});
useSeoMeta({
title: i18n.t("general.create"),
});
const i18n = useI18n();
const auth = useMealieAuth();
const { $appInfo, $globals } = useNuxtApp();
const subpages = computed<MenuItem[]>(() => [
{
icon: $globals.icons.link,
text: i18n.t("recipe.import-with-url"),
value: "url",
},
{
icon: $globals.icons.link,
text: i18n.t("recipe.bulk-url-import"),
value: "bulk",
},
{
icon: $globals.icons.codeTags,
text: i18n.t("recipe.import-from-html-or-json"),
value: "html",
},
{
icon: $globals.icons.fileImage,
text: i18n.t("recipe.create-from-images"),
value: "image",
hide: !$appInfo.enableOpenaiImageServices,
},
{
icon: $globals.icons.edit,
text: i18n.t("recipe.create-recipe"),
value: "new",
},
{
icon: $globals.icons.zip,
text: i18n.t("recipe.import-with-zip"),
value: "zip",
},
{
icon: $globals.icons.robot,
text: i18n.t("recipe.debug-scraper"),
value: "debug",
},
]);
useSeoMeta({
title: i18n.t("general.create"),
});
const route = useRoute();
const router = useRouter();
const groupSlug = computed(() => route.params.groupSlug || auth.user.value?.groupSlug || "");
const subpages = computed<MenuItem[]>(() => [
{
icon: $globals.icons.link,
text: i18n.t("recipe.import-with-url"),
value: "url",
},
{
icon: $globals.icons.link,
text: i18n.t("recipe.bulk-url-import"),
value: "bulk",
},
{
icon: $globals.icons.codeTags,
text: i18n.t("recipe.import-from-html-or-json"),
value: "html",
},
{
icon: $globals.icons.fileImage,
text: i18n.t("recipe.create-from-images"),
value: "image",
hide: !$appInfo.enableOpenaiImageServices,
},
{
icon: $globals.icons.edit,
text: i18n.t("recipe.create-recipe"),
value: "new",
},
{
icon: $globals.icons.zip,
text: i18n.t("recipe.import-with-zip"),
value: "zip",
},
{
icon: $globals.icons.robot,
text: i18n.t("recipe.debug-scraper"),
value: "debug",
},
]);
const subpage = computed({
set(subpage: string) {
router.push({ path: `/g/${groupSlug.value}/r/create/${subpage}`, query: route.query });
},
get() {
return route.path.split("/").pop() ?? "url";
},
});
const route = useRoute();
const router = useRouter();
const groupSlug = computed(() => route.params.groupSlug || auth.user.value?.groupSlug || "");
return {
groupSlug,
subpages,
subpage,
};
const subpage = computed({
set(subpage: string) {
router.push({ path: `/g/${groupSlug.value}/r/create/${subpage}`, query: route.query });
},
get() {
return route.path.split("/").pop() ?? "url";
},
});
</script>

View File

@@ -49,7 +49,7 @@
</template>
</v-text-field>
</v-col>
<template v-if="showCatTags">
<template v-if="state.showCatTags">
<v-col
cols="12"
xs="12"
@@ -115,14 +115,14 @@
{{ $t('general.new') }}
</BaseButton>
<RecipeDialogBulkAdd
v-model="bulkDialog"
v-model="state.bulkDialog"
class="mr-1 mr-sm-0 mb-1"
@bulk-data="assignUrls"
/>
</v-card-actions>
<div class="px-0">
<v-checkbox
v-model="showCatTags"
v-model="state.showCatTags"
hide-details
:label="$t('recipe.set-categories-and-tags')"
/>
@@ -154,7 +154,7 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import { whenever } from "@vueuse/shared";
import { useUserApi } from "~/composables/api";
import { alert } from "~/composables/use-toast";
@@ -162,84 +162,67 @@ import RecipeOrganizerSelector from "~/components/Domain/Recipe/RecipeOrganizerS
import type { ReportSummary } from "~/lib/api/types/reports";
import RecipeDialogBulkAdd from "~/components/Domain/Recipe/RecipeDialogBulkAdd.vue";
export default defineNuxtComponent({
components: { RecipeOrganizerSelector, RecipeDialogBulkAdd },
setup() {
const state = reactive({
error: false,
loading: false,
showCatTags: false,
bulkDialog: false,
});
whenever(
() => !state.showCatTags,
() => {
console.log("showCatTags changed");
},
);
const api = useUserApi();
const i18n = useI18n();
const bulkUrls = ref([{ url: "", categories: [], tags: [] }]);
const lockBulkImport = ref(false);
async function bulkCreate() {
if (bulkUrls.value.length === 0) {
return;
}
const { response } = await api.recipes.createManyByUrl({ imports: bulkUrls.value });
if (response?.status === 202) {
alert.success(i18n.t("recipe.bulk-import-process-has-started"));
lockBulkImport.value = true;
}
else {
alert.error(i18n.t("recipe.bulk-import-process-has-failed"));
}
fetchReports();
}
// =========================================================
// Reports
const reports = ref<ReportSummary[]>([]);
async function fetchReports() {
const { data } = await api.groupReports.getAll("bulk_import");
reports.value = data ?? [];
}
async function deleteReport(id: string) {
console.log(id);
const { response } = await api.groupReports.deleteOne(id);
if (response?.status === 200) {
fetchReports();
}
else {
alert.error(i18n.t("recipe.report-deletion-failed"));
}
}
fetchReports();
function assignUrls(urls: string[]) {
bulkUrls.value = urls.map(url => ({ url, categories: [], tags: [] }));
}
return {
assignUrls,
reports,
deleteReport,
bulkCreate,
bulkUrls,
lockBulkImport,
...toRefs(state),
};
},
const state = reactive({
showCatTags: false,
bulkDialog: false,
});
whenever(
() => !state.showCatTags,
() => {
console.log("showCatTags changed");
},
);
const api = useUserApi();
const i18n = useI18n();
const bulkUrls = ref([{ url: "", categories: [], tags: [] }]);
const lockBulkImport = ref(false);
async function bulkCreate() {
if (bulkUrls.value.length === 0) {
return;
}
const { response } = await api.recipes.createManyByUrl({ imports: bulkUrls.value });
if (response?.status === 202) {
alert.success(i18n.t("recipe.bulk-import-process-has-started"));
lockBulkImport.value = true;
}
else {
alert.error(i18n.t("recipe.bulk-import-process-has-failed"));
}
fetchReports();
}
// =========================================================
// Reports
const reports = ref<ReportSummary[]>([]);
async function fetchReports() {
const { data } = await api.groupReports.getAll("bulk_import");
reports.value = data ?? [];
}
async function deleteReport(id: string) {
console.log(id);
const { response } = await api.groupReports.deleteOne(id);
if (response?.status === 200) {
fetchReports();
}
else {
alert.error(i18n.t("recipe.report-deletion-failed"));
}
}
fetchReports();
function assignUrls(urls: string[]) {
bulkUrls.value = urls.map(url => ({ url, categories: [], tags: [] }));
}
</script>

View File

@@ -28,7 +28,7 @@
<v-card-text v-if="$appInfo.enableOpenai">
{{ $t('recipe.recipe-debugger-use-openai-description') }}
<v-checkbox
v-model="useOpenAI"
v-model="state.useOpenAI"
:label="$t('recipe.use-openai')"
/>
</v-card-text>
@@ -40,7 +40,7 @@
block
type="submit"
color="info"
:loading="loading"
:loading="state.loading"
>
<template #icon>
{{ $globals.icons.robot }}
@@ -67,60 +67,46 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import { useUserApi } from "~/composables/api";
import { validators } from "~/composables/use-validators";
import type { Recipe } from "~/lib/api/types/recipe";
export default defineNuxtComponent({
setup() {
const state = reactive({
error: false,
loading: false,
useOpenAI: false,
});
const state = reactive({
loading: false,
useOpenAI: false,
});
const api = useUserApi();
const route = useRoute();
const router = useRouter();
const api = useUserApi();
const route = useRoute();
const router = useRouter();
const recipeUrl = computed({
set(recipe_import_url: string | null) {
if (recipe_import_url !== null) {
recipe_import_url = recipe_import_url.trim();
router.replace({ query: { ...route.query, recipe_import_url } });
}
},
get() {
return route.query.recipe_import_url as string | null;
},
});
const debugTreeView = ref(false);
const debugData = ref<Recipe | null>(null);
async function debugUrl(url: string | null) {
if (url === null) {
return;
}
state.loading = true;
const { data } = await api.recipes.testCreateOneUrl(url, state.useOpenAI);
state.loading = false;
debugData.value = data;
const recipeUrl = computed({
set(recipe_import_url: string | null) {
if (recipe_import_url !== null) {
recipe_import_url = recipe_import_url.trim();
router.replace({ query: { ...route.query, recipe_import_url } });
}
return {
recipeUrl,
debugTreeView,
debugUrl,
debugData,
...toRefs(state),
validators,
};
},
get() {
return route.query.recipe_import_url as string | null;
},
});
const debugTreeView = ref(false);
const debugData = ref<Recipe | null>(null);
async function debugUrl(url: string | null) {
if (url === null) {
return;
}
state.loading = true;
const { data } = await api.recipes.testCreateOneUrl(url, state.useOpenAI);
state.loading = false;
debugData.value = data;
}
</script>

View File

@@ -90,7 +90,7 @@
rounded
block
type="submit"
:loading="loading"
:loading="state.loading"
/>
</div>
<v-card-text class="py-2">
@@ -103,7 +103,7 @@
</v-form>
</template>
<script lang="ts">
<script setup lang="ts">
import type { AxiosResponse } from "axios";
import { useTagStore } from "~/composables/store/use-tag-store";
import { useUserApi } from "~/composables/api";
@@ -111,113 +111,94 @@ import { useNewRecipeOptions } from "~/composables/use-new-recipe-options";
import { validators } from "~/composables/use-validators";
import type { VForm } from "~/types/auto-forms";
export default defineNuxtComponent({
setup() {
const state = reactive({
error: false,
loading: false,
isEditJSON: false,
});
const auth = useMealieAuth();
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
const domUrlForm = ref<VForm | null>(null);
const api = useUserApi();
const tags = useTagStore();
const {
importKeywordsAsTags,
importCategories,
stayInEditMode,
parseRecipe,
navigateToRecipe,
} = useNewRecipeOptions();
function handleResponse(response: AxiosResponse<string> | null, refreshTags = false) {
if (response?.status !== 201) {
state.error = true;
state.loading = false;
return;
}
if (refreshTags) {
tags.actions.refresh();
}
navigateToRecipe(response.data, groupSlug.value, `/g/${groupSlug.value}/r/create/html`);
}
const newRecipeData = ref<string | object | null>(null);
const newRecipeUrl = ref<string | null>(null);
function handleIsEditJson() {
if (state.isEditJSON) {
if (newRecipeData.value) {
try {
newRecipeData.value = JSON.parse(newRecipeData.value as string);
}
catch {
newRecipeData.value = { data: newRecipeData.value };
}
}
else {
newRecipeData.value = {};
}
}
else if (newRecipeData.value && Object.keys(newRecipeData.value).length > 0) {
newRecipeData.value = JSON.stringify(newRecipeData.value);
}
else {
newRecipeData.value = null;
}
}
handleIsEditJson();
const createStatus = ref<string | null>(null);
async function createFromHtmlOrJson(htmlOrJsonData: string | object | null, importKeywordsAsTags: boolean, importCategories: boolean, url: string | null = null) {
if (!htmlOrJsonData) {
return;
}
const isValid = await domUrlForm.value?.validate();
if (!isValid?.valid) {
return;
}
let dataString;
if (typeof htmlOrJsonData === "string") {
dataString = htmlOrJsonData;
}
else {
dataString = JSON.stringify(htmlOrJsonData);
}
state.loading = true;
const { response } = await api.recipes.createOneByHtmlOrJson(
dataString,
importKeywordsAsTags,
importCategories,
url,
(message: string) => createStatus.value = message,
);
createStatus.value = null;
handleResponse(response, importKeywordsAsTags);
}
return {
domUrlForm,
importKeywordsAsTags,
stayInEditMode,
parseRecipe,
importCategories,
newRecipeData,
newRecipeUrl,
handleIsEditJson,
createStatus,
createFromHtmlOrJson,
...toRefs(state),
validators,
};
},
const state = reactive({
error: false,
loading: false,
isEditJSON: false,
});
const auth = useMealieAuth();
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
const domUrlForm = ref<VForm | null>(null);
const api = useUserApi();
const tags = useTagStore();
const {
importKeywordsAsTags,
importCategories,
stayInEditMode,
parseRecipe,
navigateToRecipe,
} = useNewRecipeOptions();
function handleResponse(response: AxiosResponse<string> | null, refreshTags = false) {
if (response?.status !== 201) {
state.error = true;
state.loading = false;
return;
}
if (refreshTags) {
tags.actions.refresh();
}
navigateToRecipe(response.data, groupSlug.value, `/g/${groupSlug.value}/r/create/html`);
}
const newRecipeData = ref<string | object | null>(null);
const newRecipeUrl = ref<string | null>(null);
function handleIsEditJson() {
if (state.isEditJSON) {
if (newRecipeData.value) {
try {
newRecipeData.value = JSON.parse(newRecipeData.value as string);
}
catch {
newRecipeData.value = { data: newRecipeData.value };
}
}
else {
newRecipeData.value = {};
}
}
else if (newRecipeData.value && Object.keys(newRecipeData.value).length > 0) {
newRecipeData.value = JSON.stringify(newRecipeData.value);
}
else {
newRecipeData.value = null;
}
}
handleIsEditJson();
const createStatus = ref<string | null>(null);
async function createFromHtmlOrJson(htmlOrJsonData: string | object | null, importKeywordsAsTags: boolean, importCategories: boolean, url: string | null = null) {
if (!htmlOrJsonData) {
return;
}
const isValid = await domUrlForm.value?.validate();
if (!isValid?.valid) {
return;
}
let dataString;
if (typeof htmlOrJsonData === "string") {
dataString = htmlOrJsonData;
}
else {
dataString = JSON.stringify(htmlOrJsonData);
}
state.loading = true;
const { response } = await api.recipes.createOneByHtmlOrJson(
dataString,
importKeywordsAsTags,
importCategories,
url,
(message: string) => createStatus.value = message,
);
createStatus.value = null;
handleResponse(response, importKeywordsAsTags);
}
</script>

View File

@@ -37,7 +37,7 @@
:img="imageUrl"
cropper-height="100%"
cropper-width="100%"
:submitted="loading"
:submitted="state.loading"
class="mt-4 mb-2"
@save="(croppedImage) => updateUploadedImage(index, croppedImage)"
@delete="clearImage(index)"
@@ -45,7 +45,7 @@
<v-btn
v-if="uploadedImages.length > 1"
:disabled="loading || index === 0"
:disabled="state.loading || index === 0"
color="primary"
@click="() => setCoverImage(index)"
>
@@ -66,7 +66,7 @@
color="primary"
hide-details
:label="$t('recipe.should-translate-description')"
:disabled="loading"
:disabled="state.loading"
/>
<v-checkbox
v-if="uploadedImages.length"
@@ -74,15 +74,15 @@
color="primary"
hide-details
:label="$t('recipe.parse-recipe-ingredients-after-import')"
:disabled="loading"
:disabled="state.loading"
/>
</v-card-text>
<v-card-actions v-if="uploadedImages.length">
<div class="w-100 d-flex flex-column align-center">
<p style="width: 250px">
<BaseButton rounded block type="submit" :loading="loading" />
<BaseButton rounded block type="submit" :loading="state.loading" />
</p>
<p v-if="loading" class="mb-0">
<p v-if="state.loading" class="mb-0">
{{
uploadedImages.length > 1
? $t("recipe.please-wait-images-processing")
@@ -96,111 +96,93 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import { useUserApi } from "~/composables/api";
import { alert } from "~/composables/use-toast";
import { useNewRecipeOptions } from "~/composables/use-new-recipe-options";
import type { VForm } from "~/types/auto-forms";
export default defineNuxtComponent({
setup() {
const state = reactive({
loading: false,
});
const i18n = useI18n();
const api = useUserApi();
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug || "");
const domUrlForm = ref<VForm | null>(null);
const uploadedImages = ref<(Blob | File)[]>([]);
const uploadedImageNames = ref<string[]>([]);
const uploadedImagesPreviewUrls = ref<string[]>([]);
const shouldTranslate = ref(true);
const { parseRecipe, navigateToRecipe } = useNewRecipeOptions();
function uploadImages(files: File[]) {
uploadedImages.value = [...uploadedImages.value, ...files];
uploadedImageNames.value = [...uploadedImageNames.value, ...files.map(file => file.name)];
uploadedImagesPreviewUrls.value = [
...uploadedImagesPreviewUrls.value,
...files.map(file => URL.createObjectURL(file)),
];
}
function clearImage(index: number) {
// Revoke _before_ splicing
URL.revokeObjectURL(uploadedImagesPreviewUrls.value[index]);
uploadedImages.value.splice(index, 1);
uploadedImageNames.value.splice(index, 1);
uploadedImagesPreviewUrls.value.splice(index, 1);
}
async function createRecipe() {
if (uploadedImages.value.length === 0) {
return;
}
state.loading = true;
const translateLanguage = shouldTranslate.value ? i18n.locale : undefined;
const { data, error } = await api.recipes.createOneFromImages(uploadedImages.value, translateLanguage?.value);
if (error || !data) {
alert.error(i18n.t("events.something-went-wrong"));
state.loading = false;
}
else {
navigateToRecipe(data, groupSlug.value, `/g/${groupSlug.value}/r/create/image`);
}
}
function updateUploadedImage(index: number, croppedImage: Blob) {
uploadedImages.value[index] = croppedImage;
uploadedImagesPreviewUrls.value[index] = URL.createObjectURL(croppedImage);
}
function swapItem(array: any[], i: number, j: number) {
if (i < 0 || j < 0 || i >= array.length || j >= array.length) {
return;
}
const temp = array[i];
array[i] = array[j];
array[j] = temp;
}
function swapImages(i: number, j: number) {
swapItem(uploadedImages.value, i, j);
swapItem(uploadedImageNames.value, i, j);
swapItem(uploadedImagesPreviewUrls.value, i, j);
}
// Put the intended cover image at the start of the array
// The backend currently sets the first image as the cover image
function setCoverImage(index: number) {
if (index < 0 || index >= uploadedImages.value.length || index === 0) {
return;
}
swapImages(0, index);
}
return {
...toRefs(state),
domUrlForm,
uploadedImages,
uploadedImagesPreviewUrls,
shouldTranslate,
parseRecipe,
uploadImages,
clearImage,
createRecipe,
updateUploadedImage,
setCoverImage,
};
},
const state = reactive({
loading: false,
});
const i18n = useI18n();
const api = useUserApi();
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug || "");
const domUrlForm = ref<VForm | null>(null);
const uploadedImages = ref<(Blob | File)[]>([]);
const uploadedImageNames = ref<string[]>([]);
const uploadedImagesPreviewUrls = ref<string[]>([]);
const shouldTranslate = ref(true);
const { parseRecipe, navigateToRecipe } = useNewRecipeOptions();
function uploadImages(files: File[]) {
uploadedImages.value = [...uploadedImages.value, ...files];
uploadedImageNames.value = [...uploadedImageNames.value, ...files.map(file => file.name)];
uploadedImagesPreviewUrls.value = [
...uploadedImagesPreviewUrls.value,
...files.map(file => URL.createObjectURL(file)),
];
}
function clearImage(index: number) {
// Revoke _before_ splicing
URL.revokeObjectURL(uploadedImagesPreviewUrls.value[index]);
uploadedImages.value.splice(index, 1);
uploadedImageNames.value.splice(index, 1);
uploadedImagesPreviewUrls.value.splice(index, 1);
}
async function createRecipe() {
if (uploadedImages.value.length === 0) {
return;
}
state.loading = true;
const translateLanguage = shouldTranslate.value ? i18n.locale : undefined;
const { data, error } = await api.recipes.createOneFromImages(uploadedImages.value, translateLanguage?.value);
if (error || !data) {
alert.error(i18n.t("events.something-went-wrong"));
state.loading = false;
}
else {
navigateToRecipe(data, groupSlug.value, `/g/${groupSlug.value}/r/create/image`);
}
}
function updateUploadedImage(index: number, croppedImage: Blob) {
uploadedImages.value[index] = croppedImage;
uploadedImagesPreviewUrls.value[index] = URL.createObjectURL(croppedImage);
}
function swapItem(array: any[], i: number, j: number) {
if (i < 0 || j < 0 || i >= array.length || j >= array.length) {
return;
}
const temp = array[i];
array[i] = array[j];
array[j] = temp;
}
function swapImages(i: number, j: number) {
swapItem(uploadedImages.value, i, j);
swapItem(uploadedImageNames.value, i, j);
swapItem(uploadedImagesPreviewUrls.value, i, j);
}
// Put the intended cover image at the start of the array
// The backend currently sets the first image as the cover image
function setCoverImage(index: number) {
if (index < 0 || index >= uploadedImages.value.length || index === 0) {
return;
}
swapImages(0, index);
}
</script>

View File

@@ -2,15 +2,10 @@
<div />
</template>
<script lang="ts">
export default defineNuxtComponent({
setup() {
const router = useRouter();
onMounted(() => {
// Force redirect to first valid page
router.replace("/r/create/url");
});
return {};
},
<script setup lang="ts">
const router = useRouter();
onMounted(() => {
// Force redirect to first valid page
router.replace("/r/create/url");
});
</script>

View File

@@ -33,7 +33,7 @@
:disabled="newRecipeName.trim() === ''"
rounded
block
:loading="loading"
:loading="state.loading"
@click="createByName(newRecipeName)"
/>
</div>
@@ -41,51 +41,40 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import type { AxiosResponse } from "axios";
import { useUserApi } from "~/composables/api";
import { validators } from "~/composables/use-validators";
import type { VForm } from "~/types/auto-forms";
export default defineNuxtComponent({
setup() {
const state = reactive({
error: false,
loading: false,
});
const auth = useMealieAuth();
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
const api = useUserApi();
const router = useRouter();
function handleResponse(response: AxiosResponse<string> | null, edit = false) {
if (response?.status !== 201) {
state.error = true;
state.loading = false;
return;
}
router.push(`/g/${groupSlug.value}/r/${response.data}?edit=${edit.toString()}`);
}
const newRecipeName = ref("");
const domCreateByName = ref<VForm | null>(null);
async function createByName(name: string) {
if (!domCreateByName.value?.validate() || name === "") {
return;
}
const { response } = await api.recipes.createOne({ name });
handleResponse(response as any, true);
}
return {
domCreateByName,
newRecipeName,
createByName,
...toRefs(state),
validators,
};
},
const state = reactive({
error: false,
loading: false,
});
const auth = useMealieAuth();
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
const api = useUserApi();
const router = useRouter();
function handleResponse(response: AxiosResponse<string> | null, edit = false) {
if (response?.status !== 201) {
state.error = true;
state.loading = false;
return;
}
router.push(`/g/${groupSlug.value}/r/${response.data}?edit=${edit.toString()}`);
}
const newRecipeName = ref("");
const domCreateByName = ref<VForm | null>(null);
async function createByName(name: string) {
if (!domCreateByName.value?.validate() || name === "") {
return;
}
const { response } = await api.recipes.createOne({ name });
handleResponse(response as any, true);
}
</script>

View File

@@ -72,7 +72,7 @@
rounded
block
type="submit"
:loading="loading"
:loading="state.loading"
/>
</div>
<v-card-text class="py-2">
@@ -85,7 +85,7 @@
</v-form>
<v-expand-transition>
<v-alert
v-if="error"
v-if="state.error"
color="error"
class="mt-6 white--text"
>
@@ -140,7 +140,7 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import type { AxiosResponse } from "axios";
import { useUserApi } from "~/composables/api";
import { useTagStore } from "~/composables/store/use-tag-store";
@@ -148,135 +148,116 @@ import { useNewRecipeOptions } from "~/composables/use-new-recipe-options";
import { validators } from "~/composables/use-validators";
import type { VForm } from "~/types/auto-forms";
export default defineNuxtComponent({
setup() {
definePageMeta({
key: route => route.path,
});
const state = reactive({
error: false,
loading: false,
});
definePageMeta({
key: route => route.path,
});
const state = reactive({
error: false,
loading: false,
});
const auth = useMealieAuth();
const api = useUserApi();
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
const auth = useMealieAuth();
const api = useUserApi();
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
const router = useRouter();
const tags = useTagStore();
const router = useRouter();
const tags = useTagStore();
const {
importKeywordsAsTags,
importCategories,
stayInEditMode,
parseRecipe,
navigateToRecipe,
} = useNewRecipeOptions();
const {
importKeywordsAsTags,
importCategories,
stayInEditMode,
parseRecipe,
navigateToRecipe,
} = useNewRecipeOptions();
const bulkImporterTarget = computed(() => `/g/${groupSlug.value}/r/create/bulk`);
const htmlOrJsonImporterTarget = computed(() => `/g/${groupSlug.value}/r/create/html`);
const bulkImporterTarget = computed(() => `/g/${groupSlug.value}/r/create/bulk`);
const htmlOrJsonImporterTarget = computed(() => `/g/${groupSlug.value}/r/create/html`);
function handleResponse(response: AxiosResponse<string> | null, refreshTags = false) {
if (response?.status !== 201) {
state.error = true;
state.loading = false;
return;
}
if (refreshTags) {
tags.actions.refresh();
}
function handleResponse(response: AxiosResponse<string> | null, refreshTags = false) {
if (response?.status !== 201) {
state.error = true;
state.loading = false;
return;
}
if (refreshTags) {
tags.actions.refresh();
}
navigateToRecipe(response.data, groupSlug.value, `/g/${groupSlug.value}/r/create/url`);
navigateToRecipe(response.data, groupSlug.value, `/g/${groupSlug.value}/r/create/url`);
}
const recipeUrl = computed({
set(recipe_import_url: string | null) {
if (recipe_import_url !== null) {
recipe_import_url = recipe_import_url.trim();
router.replace({ query: { ...route.query, recipe_import_url } });
}
const recipeUrl = computed({
set(recipe_import_url: string | null) {
if (recipe_import_url !== null) {
recipe_import_url = recipe_import_url.trim();
router.replace({ query: { ...route.query, recipe_import_url } });
}
},
get() {
return route.query.recipe_import_url as string | null;
},
});
onMounted(() => {
if (recipeUrl.value && recipeUrl.value.includes("https")) {
// Check if we have a query params for using keywords as tags or staying in edit mode.
// We don't use these in the app anymore, but older automations such as Bookmarklet might still use them,
// and they're easy enough to support.
const importKeywordsAsTagsParam = route.query.use_keywords;
if (importKeywordsAsTagsParam === "1") {
importKeywordsAsTags.value = true;
}
else if (importKeywordsAsTagsParam === "0") {
importKeywordsAsTags.value = false;
}
const stayInEditModeParam = route.query.edit;
if (stayInEditModeParam === "1") {
stayInEditMode.value = true;
}
else if (stayInEditModeParam === "0") {
stayInEditMode.value = false;
}
createByUrl(recipeUrl.value, importKeywordsAsTags.value, false);
return;
}
});
const domUrlForm = ref<VForm | null>(null);
// Remove import URL from query params when leaving the page
const isLeaving = ref(false);
onBeforeRouteLeave((to) => {
if (isLeaving.value) {
return;
}
isLeaving.value = true;
router.replace({ query: undefined }).then(() => router.push(to));
});
const createStatus = ref<string | null>(null);
async function createByUrl(url: string | null, importKeywordsAsTags: boolean, importCategories: boolean) {
if (url === null) {
return;
}
if (!domUrlForm.value?.validate() || url === "") {
console.log("Invalid URL", url);
return;
}
state.loading = true;
const { response } = await api.recipes.createOneByUrl(
url,
importKeywordsAsTags,
importCategories,
(message: string) => createStatus.value = message,
);
createStatus.value = null;
handleResponse(response, importKeywordsAsTags);
}
return {
bulkImporterTarget,
htmlOrJsonImporterTarget,
recipeUrl,
importKeywordsAsTags,
importCategories: importCategories,
stayInEditMode,
parseRecipe,
domUrlForm,
createStatus,
createByUrl,
...toRefs(state),
validators,
};
},
get() {
return route.query.recipe_import_url as string | null;
},
});
onMounted(() => {
if (recipeUrl.value && recipeUrl.value.includes("https")) {
// Check if we have a query params for using keywords as tags or staying in edit mode.
// We don't use these in the app anymore, but older automations such as Bookmarklet might still use them,
// and they're easy enough to support.
const importKeywordsAsTagsParam = route.query.use_keywords;
if (importKeywordsAsTagsParam === "1") {
importKeywordsAsTags.value = true;
}
else if (importKeywordsAsTagsParam === "0") {
importKeywordsAsTags.value = false;
}
const stayInEditModeParam = route.query.edit;
if (stayInEditModeParam === "1") {
stayInEditMode.value = true;
}
else if (stayInEditModeParam === "0") {
stayInEditMode.value = false;
}
createByUrl(recipeUrl.value, importKeywordsAsTags.value, false);
return;
}
});
const domUrlForm = ref<VForm | null>(null);
// Remove import URL from query params when leaving the page
const isLeaving = ref(false);
onBeforeRouteLeave((to) => {
if (isLeaving.value) {
return;
}
isLeaving.value = true;
router.replace({ query: undefined }).then(() => router.push(to));
});
const createStatus = ref<string | null>(null);
async function createByUrl(url: string | null, importKeywordsAsTags: boolean, importCategories: boolean) {
if (url === null) {
return;
}
if (!domUrlForm.value?.validate() || url === "") {
console.log("Invalid URL", url);
return;
}
state.loading = true;
const { response } = await api.recipes.createOneByUrl(
url,
importKeywordsAsTags,
importCategories,
(message: string) => createStatus.value = message,
);
createStatus.value = null;
handleResponse(response, importKeywordsAsTags);
}
</script>
<style scoped>

View File

@@ -27,7 +27,7 @@
:disabled="newRecipeZip === null"
rounded
block
:loading="loading"
:loading="state.loading"
@click="createByZip"
/>
</div>
@@ -36,57 +36,45 @@
</v-form>
</template>
<script lang="ts">
<script setup lang="ts">
import { useUserApi } from "~/composables/api";
import { useGlobalI18n } from "~/composables/use-global-i18n";
import { alert } from "~/composables/use-toast";
import { validators } from "~/composables/use-validators";
export default defineNuxtComponent({
setup() {
const state = reactive({
loading: false,
});
const auth = useMealieAuth();
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
const api = useUserApi();
const router = useRouter();
const newRecipeZip = ref<File | null>(null);
const newRecipeZipFileName = "archive";
async function createByZip() {
if (!newRecipeZip.value) {
return;
}
const formData = new FormData();
formData.append(newRecipeZipFileName, newRecipeZip.value);
try {
const response = await api.upload.file("/api/recipes/create/zip", formData);
if (response?.status !== 201) {
throw new Error("Failed to upload zip");
}
router.push(`/g/${groupSlug.value}/r/${response.data}`);
}
catch (error) {
console.error(error);
const i18n = useGlobalI18n();
alert.error(i18n.t("events.something-went-wrong"));
}
finally {
state.loading = false;
}
}
return {
newRecipeZip,
createByZip,
...toRefs(state),
validators,
};
},
const state = reactive({
loading: false,
});
const auth = useMealieAuth();
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
const api = useUserApi();
const router = useRouter();
const newRecipeZip = ref<File | null>(null);
const newRecipeZipFileName = "archive";
async function createByZip() {
if (!newRecipeZip.value) {
return;
}
const formData = new FormData();
formData.append(newRecipeZipFileName, newRecipeZip.value);
try {
const response = await api.upload.file("/api/recipes/create/zip", formData);
if (response?.status !== 201) {
throw new Error("Failed to upload zip");
}
router.push(`/g/${groupSlug.value}/r/${response.data}`);
}
catch (error) {
console.error(error);
const i18n = useGlobalI18n();
alert.error(i18n.t("events.something-went-wrong"));
}
finally {
state.loading = false;
}
}
</script>

View File

@@ -15,27 +15,18 @@
</v-container>
</template>
<script lang="ts">
<script setup lang="ts">
import RecipeOrganizerPage from "~/components/Domain/Recipe/RecipeOrganizerPage.vue";
import { useCategoryStore } from "~/composables/store";
export default defineNuxtComponent({
components: {
RecipeOrganizerPage,
},
definePageMeta({
middleware: ["group-only"],
setup() {
const { store, actions } = useCategoryStore();
const i18n = useI18n();
});
useSeoMeta({
title: i18n.t("category.categories"),
});
const { store, actions } = useCategoryStore();
const i18n = useI18n();
return {
store,
actions,
};
},
useSeoMeta({
title: i18n.t("category.categories"),
});
</script>

View File

@@ -17,7 +17,7 @@
{{ $t('recipe-finder.recipe-finder-description') }}
</template>
</BasePageTitle>
<v-container v-if="ready">
<v-container v-if="state.ready">
<v-row>
<v-col :cols="useMobile ? 12 : 3">
<v-container class="ma-0 pa-0">
@@ -51,38 +51,38 @@
</SearchFilter>
<div :class="attrs.searchFilter.filterClass">
<v-badge
:model-value="!!queryFilterJSON.parts && queryFilterJSON.parts.length > 0"
:model-value="!!state.queryFilterJSON.parts && state.queryFilterJSON.parts.length > 0"
size="small"
color="primary"
:content="(queryFilterJSON.parts || []).length"
:content="(state.queryFilterJSON.parts || []).length"
>
<v-btn
size="small"
color="accent"
dark
@click="queryFilterMenu = !queryFilterMenu"
@click="state.queryFilterMenu = !state.queryFilterMenu"
>
<v-icon start>
{{ $globals.icons.filter }}
</v-icon>
{{ $t("recipe-finder.other-filters") }}
<BaseDialog
v-model="queryFilterMenu"
v-model="state.queryFilterMenu"
:title="$t('recipe-finder.other-filters')"
:icon="$globals.icons.filter"
width="100%"
max-width="1100px"
:submit-disabled="!queryFilterEditorValue"
:submit-disabled="!state.queryFilterEditorValue"
can-confirm
@confirm="saveQueryFilter"
>
<v-card-text>
<QueryFilterBuilder
:key="queryFilterMenuKey"
:initial-query-filter="queryFilterJSON"
:key="state.queryFilterMenuKey"
:initial-query-filter="state.queryFilterJSON"
:field-defs="queryFilterBuilderFields"
@input="(value) => queryFilterEditorValue = value"
@input-j-s-o-n="(value) => queryFilterEditorValueJSON = value"
@input="(value) => state.queryFilterEditorValue = value"
@input-j-s-o-n="(value) => state.queryFilterEditorValueJSON = value"
/>
</v-card-text>
<template #custom-card-action>
@@ -113,7 +113,7 @@
:class="attrs.settings.colClass"
>
<v-menu
v-model="settingsMenu"
v-model="state.settingsMenu"
offset-y
nudge-bottom="3"
:close-on-content-click="false"
@@ -135,7 +135,7 @@
<v-card-text>
<div>
<v-number-input
v-model="settings.maxMissingFoods"
v-model="state.settings.maxMissingFoods"
:precision="null"
:min="0"
control-variant="stacked"
@@ -144,7 +144,7 @@
:label="$t('recipe-finder.max-missing-ingredients')"
/>
<v-number-input
v-model="settings.maxMissingTools"
v-model="state.settings.maxMissingTools"
:precision="null"
:min="0"
control-variant="stacked"
@@ -157,7 +157,7 @@
<div class="mt-1">
<v-checkbox
v-if="isOwnGroup"
v-model="settings.includeFoodsOnHand"
v-model="state.settings.includeFoodsOnHand"
density="compact"
size="small"
hide-details
@@ -166,7 +166,7 @@
/>
<v-checkbox
v-if="isOwnGroup"
v-model="settings.includeToolsOnHand"
v-model="state.settings.includeToolsOnHand"
density="compact"
size="small"
hide-details
@@ -328,7 +328,7 @@
:recipe="item.recipe"
:missing-foods="item.missingFoods"
:missing-tools="item.missingTools"
:disable-checkbox="loading"
:disable-checkbox="state.loading"
@add-food="addFood"
@remove-food="removeFood"
@add-tool="addTool"
@@ -356,7 +356,7 @@
:recipe="item.recipe"
:missing-foods="item.missingFoods"
:missing-tools="item.missingTools"
:disable-checkbox="loading"
:disable-checkbox="state.loading"
@add-food="addFood"
@remove-food="removeFood"
@add-tool="addTool"
@@ -366,7 +366,7 @@
</v-col>
</v-row>
</v-container>
<v-container v-else-if="!recipesReady">
<v-container v-else-if="!state.recipesReady">
<v-row>
<v-col
cols="12"
@@ -411,7 +411,7 @@
</v-container>
</template>
<script lang="ts">
<script setup lang="ts">
import { watchDebounced } from "@vueuse/core";
import { useUserApi } from "~/composables/api";
import { usePublicExploreApi } from "~/composables/api/api-client";
@@ -431,279 +431,257 @@ interface RecipeSuggestions {
missingItems: RecipeSuggestionResponseItem[];
}
export default defineNuxtComponent({
components: { QueryFilterBuilder, RecipeSuggestion, SearchFilter },
setup() {
const display = useDisplay();
const i18n = useI18n();
const auth = useMealieAuth();
const route = useRoute();
const display = useDisplay();
const i18n = useI18n();
const auth = useMealieAuth();
const route = useRoute();
useSeoMeta({
title: i18n.t("recipe-finder.recipe-finder"),
});
useSeoMeta({
title: i18n.t("recipe-finder.recipe-finder"),
});
const useMobile = computed(() => display.smAndDown.value);
const useMobile = computed(() => display.smAndDown.value);
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
const { isOwnGroup } = useLoggedInState();
const api = isOwnGroup.value ? useUserApi() : usePublicExploreApi(groupSlug.value).explore;
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
const { isOwnGroup } = useLoggedInState();
const api = isOwnGroup.value ? useUserApi() : usePublicExploreApi(groupSlug.value).explore;
const preferences = useRecipeFinderPreferences();
const state = reactive({
ready: false,
loading: false,
recipesReady: false,
settingsMenu: false,
queryFilterMenu: false,
queryFilterMenuKey: 0,
queryFilterEditorValue: "",
queryFilterEditorValueJSON: {},
queryFilterJSON: preferences.value.queryFilterJSON,
settings: {
maxMissingFoods: preferences.value.maxMissingFoods,
maxMissingTools: preferences.value.maxMissingTools,
includeFoodsOnHand: preferences.value.includeFoodsOnHand,
includeToolsOnHand: preferences.value.includeToolsOnHand,
queryFilter: preferences.value.queryFilter,
limit: 20,
},
});
onMounted(() => {
if (!isOwnGroup.value) {
state.settings.includeFoodsOnHand = false;
state.settings.includeToolsOnHand = false;
}
});
watch(
() => state,
(newState) => {
preferences.value.queryFilter = newState.settings.queryFilter;
preferences.value.queryFilterJSON = newState.queryFilterJSON;
preferences.value.maxMissingFoods = newState.settings.maxMissingFoods;
preferences.value.maxMissingTools = newState.settings.maxMissingTools;
preferences.value.includeFoodsOnHand = newState.settings.includeFoodsOnHand;
preferences.value.includeToolsOnHand = newState.settings.includeToolsOnHand;
},
{
deep: true,
},
);
const attrs = computed(() => {
return {
title: {
class: {
readyToMake: "ma-0 pa-0",
missingItems: recipeSuggestions.value.readyToMake.length ? "ma-0 pa-0 mt-5" : "ma-0 pa-0",
},
},
searchFilter: {
colClass: useMobile.value ? "d-flex flex-wrap justify-end" : "d-flex flex-wrap justify-start",
filterClass: useMobile.value ? "ml-4 mb-2" : "mr-4 mb-2",
},
settings: {
colClass: useMobile.value ? "d-flex flex-wrap justify-end" : "d-flex flex-wrap justify-start",
},
};
});
const foodStore = isOwnGroup.value ? useFoodStore() : usePublicFoodStore(groupSlug.value);
const selectedFoods = ref<IngredientFood[]>([]);
function addFood(food: IngredientFood) {
selectedFoods.value = [...selectedFoods.value, food];
handleFoodUpdates();
}
function removeFood(food: IngredientFood) {
selectedFoods.value = selectedFoods.value.filter(f => f.id !== food.id);
handleFoodUpdates();
}
function handleFoodUpdates() {
selectedFoods.value.sort((a, b) => (a.pluralName || a.name).localeCompare(b.pluralName || b.name));
preferences.value.foodIds = selectedFoods.value.map(food => food.id);
}
watch(
() => selectedFoods.value,
() => {
handleFoodUpdates();
},
);
const toolStore = isOwnGroup.value ? useToolStore() : usePublicToolStore(groupSlug.value);
const selectedTools = ref<RecipeTool[]>([]);
function addTool(tool: RecipeTool) {
selectedTools.value = [...selectedTools.value, tool];
handleToolUpdates();
}
function removeTool(tool: RecipeTool) {
selectedTools.value = selectedTools.value.filter(t => t.id !== tool.id);
handleToolUpdates();
}
function handleToolUpdates() {
selectedTools.value.sort((a, b) => a.name.localeCompare(b.name));
preferences.value.toolIds = selectedTools.value.map(tool => tool.id);
}
watch(
() => selectedTools.value,
() => {
handleToolUpdates();
},
);
async function hydrateFoods() {
if (!preferences.value.foodIds.length) {
return;
}
if (!foodStore.store.value.length) {
await foodStore.actions.refresh();
}
const foods = preferences.value.foodIds
.map(foodId => foodStore.store.value.find(food => food.id === foodId))
.filter(food => !!food);
selectedFoods.value = foods;
}
async function hydrateTools() {
if (!preferences.value.toolIds.length) {
return;
}
if (!toolStore.store.value.length) {
await toolStore.actions.refresh();
}
const tools = preferences.value.toolIds
.map(toolId => toolStore.store.value.find(tool => tool.id === toolId))
.filter(tool => !!tool);
selectedTools.value = tools;
}
onMounted(async () => {
await Promise.all([hydrateFoods(), hydrateTools()]);
state.ready = true;
if (!selectedFoods.value.length) {
state.recipesReady = true;
};
});
const recipeResponseItems = ref<RecipeSuggestionResponseItem[]>([]);
const recipeSuggestions = computed<RecipeSuggestions>(() => {
const readyToMake: RecipeSuggestionResponseItem[] = [];
const missingItems: RecipeSuggestionResponseItem[] = [];
recipeResponseItems.value.forEach((responseItem) => {
if (responseItem.missingFoods.length === 0 && responseItem.missingTools.length === 0) {
readyToMake.push(responseItem);
}
else {
missingItems.push(responseItem);
};
});
return {
readyToMake,
missingItems,
};
});
watchDebounced(
[selectedFoods, selectedTools, state.settings], async () => {
// don't search for suggestions if no foods are selected
if (!selectedFoods.value.length) {
recipeResponseItems.value = [];
state.recipesReady = true;
return;
}
state.loading = true;
const { data } = await api.recipes.getSuggestions(
{
limit: state.settings.limit,
queryFilter: state.settings.queryFilter,
maxMissingFoods: state.settings.maxMissingFoods,
maxMissingTools: state.settings.maxMissingTools,
includeFoodsOnHand: state.settings.includeFoodsOnHand,
includeToolsOnHand: state.settings.includeToolsOnHand,
} as RecipeSuggestionQuery,
selectedFoods.value.map(food => food.id),
selectedTools.value.map(tool => tool.id),
);
state.loading = false;
if (!data) {
return;
}
recipeResponseItems.value = data.items;
state.recipesReady = true;
},
{
debounce: 500,
},
);
const queryFilterBuilderFields: FieldDefinition[] = [
{
name: "recipe_category.id",
label: i18n.t("category.categories"),
type: Organizer.Category,
},
{
name: "tags.id",
label: i18n.t("tag.tags"),
type: Organizer.Tag,
},
{
name: "household_id",
label: i18n.t("household.households"),
type: Organizer.Household,
},
{
name: "user_id",
label: i18n.t("user.users"),
type: Organizer.User,
},
{
name: "last_made",
label: i18n.t("general.last-made"),
type: "relativeDate",
},
];
function clearQueryFilter() {
state.queryFilterEditorValue = "";
state.queryFilterEditorValueJSON = { parts: [] } as QueryFilterJSON;
state.settings.queryFilter = "";
state.queryFilterJSON = { parts: [] } as QueryFilterJSON;
state.queryFilterMenu = false;
state.queryFilterMenuKey += 1;
}
function saveQueryFilter() {
state.settings.queryFilter = state.queryFilterEditorValue || "";
state.queryFilterJSON = state.queryFilterEditorValueJSON || { parts: [] } as QueryFilterJSON;
state.queryFilterMenu = false;
}
return {
...toRefs(state),
useMobile,
attrs,
isOwnGroup,
foods: foodStore.store,
selectedFoods,
addFood,
removeFood,
tools: toolStore.store,
selectedTools,
addTool,
removeTool,
recipeSuggestions,
queryFilterBuilderFields,
clearQueryFilter,
saveQueryFilter,
};
const preferences = useRecipeFinderPreferences();
const state = reactive({
ready: false,
loading: false,
recipesReady: false,
settingsMenu: false,
queryFilterMenu: false,
queryFilterMenuKey: 0,
queryFilterEditorValue: "",
queryFilterEditorValueJSON: {},
queryFilterJSON: preferences.value.queryFilterJSON,
settings: {
maxMissingFoods: preferences.value.maxMissingFoods,
maxMissingTools: preferences.value.maxMissingTools,
includeFoodsOnHand: preferences.value.includeFoodsOnHand,
includeToolsOnHand: preferences.value.includeToolsOnHand,
queryFilter: preferences.value.queryFilter,
limit: 20,
},
});
onMounted(() => {
if (!isOwnGroup.value) {
state.settings.includeFoodsOnHand = false;
state.settings.includeToolsOnHand = false;
}
});
watch(
() => state,
(newState) => {
preferences.value.queryFilter = newState.settings.queryFilter;
preferences.value.queryFilterJSON = newState.queryFilterJSON;
preferences.value.maxMissingFoods = newState.settings.maxMissingFoods;
preferences.value.maxMissingTools = newState.settings.maxMissingTools;
preferences.value.includeFoodsOnHand = newState.settings.includeFoodsOnHand;
preferences.value.includeToolsOnHand = newState.settings.includeToolsOnHand;
},
{
deep: true,
},
);
const attrs = computed(() => {
return {
title: {
class: {
readyToMake: "ma-0 pa-0",
missingItems: recipeSuggestions.value.readyToMake.length ? "ma-0 pa-0 mt-5" : "ma-0 pa-0",
},
},
searchFilter: {
colClass: useMobile.value ? "d-flex flex-wrap justify-end" : "d-flex flex-wrap justify-start",
filterClass: useMobile.value ? "ml-4 mb-2" : "mr-4 mb-2",
},
settings: {
colClass: useMobile.value ? "d-flex flex-wrap justify-end" : "d-flex flex-wrap justify-start",
},
};
});
const foodStore = isOwnGroup.value ? useFoodStore() : usePublicFoodStore(groupSlug.value);
const foods = foodStore.store.value;
const selectedFoods = ref<IngredientFood[]>([]);
function addFood(food: IngredientFood) {
selectedFoods.value = [...selectedFoods.value, food];
handleFoodUpdates();
}
function removeFood(food: IngredientFood) {
selectedFoods.value = selectedFoods.value.filter(f => f.id !== food.id);
handleFoodUpdates();
}
function handleFoodUpdates() {
selectedFoods.value.sort((a, b) => (a.pluralName || a.name).localeCompare(b.pluralName || b.name));
preferences.value.foodIds = selectedFoods.value.map(food => food.id);
}
watch(
() => selectedFoods.value,
() => {
handleFoodUpdates();
},
);
const toolStore = isOwnGroup.value ? useToolStore() : usePublicToolStore(groupSlug.value);
const tools = toolStore.store.value;
const selectedTools = ref<RecipeTool[]>([]);
function addTool(tool: RecipeTool) {
selectedTools.value = [...selectedTools.value, tool];
handleToolUpdates();
}
function removeTool(tool: RecipeTool) {
selectedTools.value = selectedTools.value.filter(t => t.id !== tool.id);
handleToolUpdates();
}
function handleToolUpdates() {
selectedTools.value.sort((a, b) => a.name.localeCompare(b.name));
preferences.value.toolIds = selectedTools.value.map(tool => tool.id);
}
watch(
() => selectedTools.value,
() => {
handleToolUpdates();
},
);
async function hydrateFoods() {
if (!preferences.value.foodIds.length) {
return;
}
if (!foodStore.store.value.length) {
await foodStore.actions.refresh();
}
const foods = preferences.value.foodIds
.map(foodId => foodStore.store.value.find(food => food.id === foodId))
.filter(food => !!food);
selectedFoods.value = foods;
}
async function hydrateTools() {
if (!preferences.value.toolIds.length) {
return;
}
if (!toolStore.store.value.length) {
await toolStore.actions.refresh();
}
const tools = preferences.value.toolIds
.map(toolId => toolStore.store.value.find(tool => tool.id === toolId))
.filter(tool => !!tool);
selectedTools.value = tools;
}
onMounted(async () => {
await Promise.all([hydrateFoods(), hydrateTools()]);
state.ready = true;
if (!selectedFoods.value.length) {
state.recipesReady = true;
};
});
const recipeResponseItems = ref<RecipeSuggestionResponseItem[]>([]);
const recipeSuggestions = computed<RecipeSuggestions>(() => {
const readyToMake: RecipeSuggestionResponseItem[] = [];
const missingItems: RecipeSuggestionResponseItem[] = [];
recipeResponseItems.value.forEach((responseItem) => {
if (responseItem.missingFoods.length === 0 && responseItem.missingTools.length === 0) {
readyToMake.push(responseItem);
}
else {
missingItems.push(responseItem);
};
});
return {
readyToMake,
missingItems,
};
});
watchDebounced(
[selectedFoods, selectedTools, state.settings], async () => {
// don't search for suggestions if no foods are selected
if (!selectedFoods.value.length) {
recipeResponseItems.value = [];
state.recipesReady = true;
return;
}
state.loading = true;
const { data } = await api.recipes.getSuggestions(
{
limit: state.settings.limit,
queryFilter: state.settings.queryFilter,
maxMissingFoods: state.settings.maxMissingFoods,
maxMissingTools: state.settings.maxMissingTools,
includeFoodsOnHand: state.settings.includeFoodsOnHand,
includeToolsOnHand: state.settings.includeToolsOnHand,
} as RecipeSuggestionQuery,
selectedFoods.value.map(food => food.id),
selectedTools.value.map(tool => tool.id),
);
state.loading = false;
if (!data) {
return;
}
recipeResponseItems.value = data.items;
state.recipesReady = true;
},
{
debounce: 500,
},
);
const queryFilterBuilderFields: FieldDefinition[] = [
{
name: "recipe_category.id",
label: i18n.t("category.categories"),
type: Organizer.Category,
},
{
name: "tags.id",
label: i18n.t("tag.tags"),
type: Organizer.Tag,
},
{
name: "household_id",
label: i18n.t("household.households"),
type: Organizer.Household,
},
{
name: "user_id",
label: i18n.t("user.users"),
type: Organizer.User,
},
{
name: "last_made",
label: i18n.t("general.last-made"),
type: "relativeDate",
},
];
function clearQueryFilter() {
state.queryFilterEditorValue = "";
state.queryFilterEditorValueJSON = { parts: [] } as QueryFilterJSON;
state.settings.queryFilter = "";
state.queryFilterJSON = { parts: [] } as QueryFilterJSON;
state.queryFilterMenu = false;
state.queryFilterMenuKey += 1;
}
function saveQueryFilter() {
state.settings.queryFilter = state.queryFilterEditorValue || "";
state.queryFilterJSON = state.queryFilterEditorValueJSON || { parts: [] } as QueryFilterJSON;
state.queryFilterMenu = false;
}
</script>

View File

@@ -15,27 +15,18 @@
</v-container>
</template>
<script lang="ts">
<script setup lang="ts">
import RecipeOrganizerPage from "~/components/Domain/Recipe/RecipeOrganizerPage.vue";
import { useTagStore } from "~/composables/store";
export default defineNuxtComponent({
components: {
RecipeOrganizerPage,
},
definePageMeta({
middleware: ["group-only"],
setup() {
const { store, actions } = useTagStore();
const i18n = useI18n();
});
useSeoMeta({
title: i18n.t("tag.tags"),
});
const { store, actions } = useTagStore();
const i18n = useI18n();
return {
store,
actions,
};
},
useSeoMeta({
title: i18n.t("tag.tags"),
});
</script>

View File

@@ -30,41 +30,33 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import { useUserApi } from "~/composables/api";
import RecipeTimeline from "~/components/Domain/Recipe/RecipeTimeline.vue";
export default defineNuxtComponent({
components: { RecipeTimeline },
definePageMeta({
middleware: ["group-only"],
setup() {
const i18n = useI18n();
const api = useUserApi();
const ready = ref<boolean>(false);
useSeoMeta({
title: i18n.t("recipe.timeline"),
});
const groupName = ref<string>("");
const queryFilter = ref<string>("");
async function fetchHousehold() {
const { data } = await api.households.getCurrentUserHousehold();
if (data) {
queryFilter.value = `recipe.group_id="${data.groupId}"`;
groupName.value = data.group;
}
ready.value = true;
}
useAsyncData("house-hold", fetchHousehold);
return {
groupName,
queryFilter,
ready,
};
},
});
const i18n = useI18n();
const api = useUserApi();
const ready = ref<boolean>(false);
useSeoMeta({
title: i18n.t("recipe.timeline"),
});
const groupName = ref<string>("");
const queryFilter = ref<string>("");
async function fetchHousehold() {
const { data } = await api.households.getCurrentUserHousehold();
if (data) {
queryFilter.value = `recipe.group_id="${data.groupId}"`;
groupName.value = data.group;
}
ready.value = true;
}
useAsyncData("house-hold", fetchHousehold);
</script>

View File

@@ -15,7 +15,7 @@
</v-container>
</template>
<script lang="ts">
<script setup lang="ts">
import RecipeOrganizerPage from "~/components/Domain/Recipe/RecipeOrganizerPage.vue";
import { useToolStore } from "~/composables/store";
import type { RecipeTool } from "~/lib/api/types/recipe";
@@ -24,56 +24,44 @@ interface RecipeToolWithOnHand extends RecipeTool {
onHand: boolean;
}
export default defineNuxtComponent({
components: {
RecipeOrganizerPage,
},
definePageMeta({
middleware: ["group-only"],
setup() {
const auth = useMealieAuth();
const toolStore = useToolStore();
const dialog = ref(false);
const i18n = useI18n();
useSeoMeta({
title: i18n.t("tool.tools"),
});
const userHousehold = computed(() => auth.user.value?.householdSlug || "");
const tools = computed(() => toolStore.store.value.map(tool => (
{
...tool,
onHand: tool.householdsWithTool?.includes(userHousehold.value) || false,
} as RecipeToolWithOnHand
)));
async function deleteOne(id: string | number) {
await toolStore.actions.deleteOne(id);
}
async function updateOne(tool: RecipeToolWithOnHand) {
if (userHousehold.value) {
if (tool.onHand && !tool.householdsWithTool?.includes(userHousehold.value)) {
if (!tool.householdsWithTool) {
tool.householdsWithTool = [userHousehold.value];
}
else {
tool.householdsWithTool.push(userHousehold.value);
}
}
else if (!tool.onHand && tool.householdsWithTool?.includes(userHousehold.value)) {
tool.householdsWithTool = tool.householdsWithTool.filter(household => household !== userHousehold.value);
}
}
await toolStore.actions.updateOne(tool);
}
return {
dialog,
tools,
deleteOne,
updateOne,
};
},
});
const auth = useMealieAuth();
const toolStore = useToolStore();
const i18n = useI18n();
useSeoMeta({
title: i18n.t("tool.tools"),
});
const userHousehold = computed(() => auth.user.value?.householdSlug || "");
const tools = computed(() => toolStore.store.value.map(tool => (
{
...tool,
onHand: tool.householdsWithTool?.includes(userHousehold.value) || false,
} as RecipeToolWithOnHand
)));
async function deleteOne(id: string | number) {
await toolStore.actions.deleteOne(id);
}
async function updateOne(tool: RecipeToolWithOnHand) {
if (userHousehold.value) {
if (tool.onHand && !tool.householdsWithTool?.includes(userHousehold.value)) {
if (!tool.householdsWithTool) {
tool.householdsWithTool = [userHousehold.value];
}
else {
tool.householdsWithTool.push(userHousehold.value);
}
}
else if (!tool.onHand && tool.householdsWithTool?.includes(userHousehold.value)) {
tool.householdsWithTool = tool.householdsWithTool.filter(household => household !== userHousehold.value);
}
}
await toolStore.actions.updateOne(tool);
}
</script>

View File

@@ -34,93 +34,87 @@
</v-container>
</template>
<script lang="ts">
export default defineNuxtComponent({
<script setup lang="ts">
definePageMeta({
middleware: ["can-organize-only"],
setup() {
const i18n = useI18n();
const buttonLookup: { [key: string]: string } = {
recipes: i18n.t("general.recipes"),
recipeActions: i18n.t("recipe.recipe-actions"),
foods: i18n.t("general.foods"),
units: i18n.t("general.units"),
labels: i18n.t("data-pages.labels.labels"),
categories: i18n.t("category.categories"),
tags: i18n.t("tag.tags"),
tools: i18n.t("tool.tools"),
};
});
const route = useRoute();
const i18n = useI18n();
const buttonLookup: { [key: string]: string } = {
recipes: i18n.t("general.recipes"),
recipeActions: i18n.t("recipe.recipe-actions"),
foods: i18n.t("general.foods"),
units: i18n.t("general.units"),
labels: i18n.t("data-pages.labels.labels"),
categories: i18n.t("category.categories"),
tags: i18n.t("tag.tags"),
tools: i18n.t("tool.tools"),
};
const DATA_TYPE_OPTIONS = computed(() => [
{
text: i18n.t("general.recipes"),
value: "new",
to: "/group/data/recipes",
},
{
text: i18n.t("recipe.recipe-actions"),
value: "new",
to: "/group/data/recipe-actions",
divider: true,
},
{
text: i18n.t("general.foods"),
value: "url",
to: "/group/data/foods",
},
{
text: i18n.t("general.units"),
value: "new",
to: "/group/data/units",
},
{
text: i18n.t("data-pages.labels.labels"),
value: "new",
to: "/group/data/labels",
divider: true,
},
{
text: i18n.t("category.categories"),
value: "new",
to: "/group/data/categories",
},
{
text: i18n.t("tag.tags"),
value: "new",
to: "/group/data/tags",
},
{
text: i18n.t("tool.tools"),
value: "new",
to: "/group/data/tools",
},
]);
const route = useRoute();
const buttonText = computed(() => {
const last = route.path
.split("/")
.pop()
// convert hypenated-values to camelCase
?.replace(/-([a-z])/g, function (g) {
return g[1].toUpperCase();
});
if (last) {
return buttonLookup[last];
}
return i18n.t("data-pages.select-data");
});
useSeoMeta({
title: i18n.t("data-pages.data-management"),
});
return {
buttonText,
DATA_TYPE_OPTIONS,
};
const DATA_TYPE_OPTIONS = computed(() => [
{
text: i18n.t("general.recipes"),
value: "new",
to: "/group/data/recipes",
},
{
text: i18n.t("recipe.recipe-actions"),
value: "new",
to: "/group/data/recipe-actions",
divider: true,
},
{
text: i18n.t("general.foods"),
value: "url",
to: "/group/data/foods",
},
{
text: i18n.t("general.units"),
value: "new",
to: "/group/data/units",
},
{
text: i18n.t("data-pages.labels.labels"),
value: "new",
to: "/group/data/labels",
divider: true,
},
{
text: i18n.t("category.categories"),
value: "new",
to: "/group/data/categories",
},
{
text: i18n.t("tag.tags"),
value: "new",
to: "/group/data/tags",
},
{
text: i18n.t("tool.tools"),
value: "new",
to: "/group/data/tools",
},
]);
const buttonText = computed(() => {
const last = route.path
.split("/")
.pop()
// convert hypenated-values to camelCase
?.replace(/-([a-z])/g, function (g) {
return g[1].toUpperCase();
});
if (last) {
return buttonLookup[last];
}
return i18n.t("data-pages.select-data");
});
useSeoMeta({
title: i18n.t("data-pages.data-management"),
});
</script>

View File

@@ -19,7 +19,7 @@
</div>
</template>
<script setup lang="ts">
<script setup lang="ts">
import { useCategoryStore } from "~/composables/store";
import { validators } from "~/composables/use-validators";
import { fieldTypes } from "~/composables/forms";

View File

@@ -2,15 +2,10 @@
<div />
</template>
<script lang="ts">
export default defineNuxtComponent({
setup() {
const router = useRouter();
onMounted(() => {
// Force redirect to first valid page
router.push("/group/data/foods");
});
return {};
},
<script setup lang="ts">
const router = useRouter();
onMounted(() => {
// Force redirect to first valid page
router.push("/group/data/foods");
});
</script>

View File

@@ -226,7 +226,7 @@
</v-container>
</template>
<script lang="ts">
<script setup lang="ts">
import RecipeDataTable from "~/components/Domain/Recipe/RecipeDataTable.vue";
import RecipeOrganizerSelector from "~/components/Domain/Recipe/RecipeOrganizerSelector.vue";
import { useUserApi } from "~/composables/api";
@@ -249,295 +249,266 @@ enum MODES {
changeOwner = "changeOwner",
}
export default defineNuxtComponent({
components: { RecipeDataTable, RecipeOrganizerSelector, GroupExportData, RecipeSettingsSwitches, UserAvatar },
definePageMeta({
scrollToTop: true,
setup() {
const i18n = useI18n();
const auth = useMealieAuth();
const { $globals } = useNuxtApp();
});
useSeoMeta({
title: i18n.t("data-pages.recipes.recipe-data"),
});
const i18n = useI18n();
const auth = useMealieAuth();
const { $globals } = useNuxtApp();
const { getAllRecipes, refreshRecipes } = useRecipes(true, true, false, `householdId=${auth.user.value?.householdId || ""}`);
const selected = ref<Recipe[]>([]);
useSeoMeta({
title: i18n.t("data-pages.recipes.recipe-data"),
});
function resetAll() {
selected.value = [];
toSetTags.value = [];
toSetCategories.value = [];
loading.value = false;
}
const { refreshRecipes } = useRecipes(true, true, false, `householdId=${auth.user.value?.householdId || ""}`);
const selected = ref<Recipe[]>([]);
const headers = reactive({
id: false,
owner: false,
tags: true,
tools: true,
categories: true,
recipeServings: false,
recipeYieldQuantity: false,
recipeYield: false,
dateAdded: false,
});
function resetAll() {
selected.value = [];
toSetTags.value = [];
toSetCategories.value = [];
loading.value = false;
}
const headerLabels = {
id: i18n.t("general.id"),
owner: i18n.t("general.owner"),
tags: i18n.t("tag.tags"),
categories: i18n.t("recipe.categories"),
tools: i18n.t("tool.tools"),
recipeServings: i18n.t("recipe.recipe-servings"),
recipeYieldQuantity: i18n.t("recipe.recipe-yield"),
recipeYield: i18n.t("recipe.recipe-yield-text"),
dateAdded: i18n.t("general.date-added"),
};
const headers = reactive({
id: false,
owner: false,
tags: true,
tools: true,
categories: true,
recipeServings: false,
recipeYieldQuantity: false,
recipeYield: false,
dateAdded: false,
});
const actions: MenuItem[] = [
{
icon: $globals.icons.database,
text: i18n.t("export.export"),
event: "export-selected",
},
{
icon: $globals.icons.tags,
text: i18n.t("data-pages.recipes.tag"),
event: "tag-selected",
},
{
icon: $globals.icons.categories,
text: i18n.t("data-pages.recipes.categorize"),
event: "categorize-selected",
},
{
icon: $globals.icons.cog,
text: i18n.t("data-pages.recipes.update-settings"),
event: "update-settings",
},
{
icon: $globals.icons.user,
text: i18n.t("general.change-owner"),
event: "change-owner",
},
{
icon: $globals.icons.delete,
text: i18n.t("general.delete"),
event: "delete-selected",
},
];
const headerLabels = {
id: i18n.t("general.id"),
owner: i18n.t("general.owner"),
tags: i18n.t("tag.tags"),
categories: i18n.t("recipe.categories"),
tools: i18n.t("tool.tools"),
recipeServings: i18n.t("recipe.recipe-servings"),
recipeYieldQuantity: i18n.t("recipe.recipe-yield"),
recipeYield: i18n.t("recipe.recipe-yield-text"),
dateAdded: i18n.t("general.date-added"),
};
const api = useUserApi();
const loading = ref(false);
// ===============================================================
// Group Exports
const purgeExportsDialog = ref(false);
async function purgeExports() {
await api.bulk.purgeExports();
refreshExports();
}
const groupExports = ref<GroupDataExport[]>([]);
async function refreshExports() {
const { data } = await api.bulk.fetchExports();
if (data) {
groupExports.value = data;
}
}
onMounted(async () => {
await refreshExports();
});
// ===============================================================
// All Recipes
function selectAll() {
selected.value = allRecipes.value;
}
async function exportSelected() {
loading.value = true;
const { data } = await api.bulk.bulkExport({
recipes: selected.value.map((x: Recipe) => x.slug ?? ""),
exportType: "json",
});
if (data) {
console.log(data);
}
resetAll();
refreshExports();
}
const toSetTags = ref([]);
async function tagSelected() {
loading.value = true;
const recipes = selected.value.map((x: Recipe) => x.slug ?? "");
await api.bulk.bulkTag({ recipes, tags: toSetTags.value });
await refreshRecipes();
resetAll();
}
const toSetCategories = ref([]);
async function categorizeSelected() {
loading.value = true;
const recipes = selected.value.map((x: Recipe) => x.slug ?? "");
await api.bulk.bulkCategorize({ recipes, categories: toSetCategories.value });
await refreshRecipes();
resetAll();
}
async function deleteSelected() {
loading.value = true;
const recipes = selected.value.map((x: Recipe) => x.slug ?? "");
await api.bulk.bulkDelete({ recipes });
await refreshRecipes();
resetAll();
}
const recipeSettings = reactive<RecipeSettings>({
public: false,
showNutrition: false,
showAssets: false,
landscapeView: false,
disableComments: false,
locked: false,
});
async function updateSettings() {
loading.value = true;
const recipes = selected.value.map((x: Recipe) => x.slug ?? "");
await api.bulk.bulkSetSettings({ recipes, settings: recipeSettings });
await refreshRecipes();
resetAll();
}
async function changeOwner() {
if (!selected.value.length || !selectedOwner.value) {
return;
}
selected.value.forEach((r) => {
r.userId = selectedOwner.value;
});
loading.value = true;
await api.recipes.patchMany(selected.value);
await refreshRecipes();
resetAll();
}
// ============================================================
// Dialog Management
const dialog = reactive({
state: false,
title: i18n.t("data-pages.recipes.tag-recipes"),
mode: MODES.tag,
tag: "",
callback: () => {
// Stub function to be overwritten
return Promise.resolve();
},
icon: $globals.icons.tags,
});
function openDialog(mode: MODES) {
const titles: Record<MODES, string> = {
[MODES.tag]: i18n.t("data-pages.recipes.tag-recipes"),
[MODES.category]: i18n.t("data-pages.recipes.categorize-recipes"),
[MODES.export]: i18n.t("data-pages.recipes.export-recipes"),
[MODES.delete]: i18n.t("data-pages.recipes.delete-recipes"),
[MODES.updateSettings]: i18n.t("data-pages.recipes.update-settings"),
[MODES.changeOwner]: i18n.t("general.change-owner"),
};
const callbacks: Record<MODES, () => Promise<void>> = {
[MODES.tag]: tagSelected,
[MODES.category]: categorizeSelected,
[MODES.export]: exportSelected,
[MODES.delete]: deleteSelected,
[MODES.updateSettings]: updateSettings,
[MODES.changeOwner]: changeOwner,
};
const icons: Record<MODES, string> = {
[MODES.tag]: $globals.icons.tags,
[MODES.category]: $globals.icons.categories,
[MODES.export]: $globals.icons.database,
[MODES.delete]: $globals.icons.delete,
[MODES.updateSettings]: $globals.icons.cog,
[MODES.changeOwner]: $globals.icons.user,
};
dialog.mode = mode;
dialog.title = titles[mode];
dialog.callback = callbacks[mode];
dialog.icon = icons[mode];
dialog.state = true;
}
const { store: allUsers } = useUserStore();
const { store: households } = useHouseholdStore();
const selectedOwner = ref("");
const selectedOwnerHousehold = computed(() => {
if (!selectedOwner.value) {
return null;
}
const owner = allUsers.value.find(u => u.id === selectedOwner.value);
if (!owner) {
return null;
};
return households.value.find(h => h.id === owner.householdId);
});
return {
recipeSettings,
selectAll,
loading,
actions,
allRecipes,
categorizeSelected,
deleteSelected,
dialog,
exportSelected,
getAllRecipes,
headerLabels,
headers,
MODES,
openDialog,
selected,
tagSelected,
toSetCategories,
toSetTags,
groupExports,
purgeExportsDialog,
purgeExports,
allUsers,
selectedOwner,
selectedOwnerHousehold,
};
const actions: MenuItem[] = [
{
icon: $globals.icons.database,
text: i18n.t("export.export"),
event: "export-selected",
},
{
icon: $globals.icons.tags,
text: i18n.t("data-pages.recipes.tag"),
event: "tag-selected",
},
{
icon: $globals.icons.categories,
text: i18n.t("data-pages.recipes.categorize"),
event: "categorize-selected",
},
{
icon: $globals.icons.cog,
text: i18n.t("data-pages.recipes.update-settings"),
event: "update-settings",
},
{
icon: $globals.icons.user,
text: i18n.t("general.change-owner"),
event: "change-owner",
},
{
icon: $globals.icons.delete,
text: i18n.t("general.delete"),
event: "delete-selected",
},
];
const api = useUserApi();
const loading = ref(false);
// ===============================================================
// Group Exports
const purgeExportsDialog = ref(false);
async function purgeExports() {
await api.bulk.purgeExports();
refreshExports();
}
const groupExports = ref<GroupDataExport[]>([]);
async function refreshExports() {
const { data } = await api.bulk.fetchExports();
if (data) {
groupExports.value = data;
}
}
onMounted(async () => {
await refreshExports();
});
// ===============================================================
// All Recipes
function selectAll() {
selected.value = allRecipes.value;
}
async function exportSelected() {
loading.value = true;
const { data } = await api.bulk.bulkExport({
recipes: selected.value.map((x: Recipe) => x.slug ?? ""),
exportType: "json",
});
if (data) {
console.log(data);
}
resetAll();
refreshExports();
}
const toSetTags = ref([]);
async function tagSelected() {
loading.value = true;
const recipes = selected.value.map((x: Recipe) => x.slug ?? "");
await api.bulk.bulkTag({ recipes, tags: toSetTags.value });
await refreshRecipes();
resetAll();
}
const toSetCategories = ref([]);
async function categorizeSelected() {
loading.value = true;
const recipes = selected.value.map((x: Recipe) => x.slug ?? "");
await api.bulk.bulkCategorize({ recipes, categories: toSetCategories.value });
await refreshRecipes();
resetAll();
}
async function deleteSelected() {
loading.value = true;
const recipes = selected.value.map((x: Recipe) => x.slug ?? "");
await api.bulk.bulkDelete({ recipes });
await refreshRecipes();
resetAll();
}
const recipeSettings = reactive<RecipeSettings>({
public: false,
showNutrition: false,
showAssets: false,
landscapeView: false,
disableComments: false,
locked: false,
});
async function updateSettings() {
loading.value = true;
const recipes = selected.value.map((x: Recipe) => x.slug ?? "");
await api.bulk.bulkSetSettings({ recipes, settings: recipeSettings });
await refreshRecipes();
resetAll();
}
async function changeOwner() {
if (!selected.value.length || !selectedOwner.value) {
return;
}
selected.value.forEach((r) => {
r.userId = selectedOwner.value;
});
loading.value = true;
await api.recipes.patchMany(selected.value);
await refreshRecipes();
resetAll();
}
// ============================================================
// Dialog Management
const dialog = reactive({
state: false,
title: i18n.t("data-pages.recipes.tag-recipes"),
mode: MODES.tag,
tag: "",
callback: () => {
// Stub function to be overwritten
return Promise.resolve();
},
icon: $globals.icons.tags,
});
function openDialog(mode: MODES) {
const titles: Record<MODES, string> = {
[MODES.tag]: i18n.t("data-pages.recipes.tag-recipes"),
[MODES.category]: i18n.t("data-pages.recipes.categorize-recipes"),
[MODES.export]: i18n.t("data-pages.recipes.export-recipes"),
[MODES.delete]: i18n.t("data-pages.recipes.delete-recipes"),
[MODES.updateSettings]: i18n.t("data-pages.recipes.update-settings"),
[MODES.changeOwner]: i18n.t("general.change-owner"),
};
const callbacks: Record<MODES, () => Promise<void>> = {
[MODES.tag]: tagSelected,
[MODES.category]: categorizeSelected,
[MODES.export]: exportSelected,
[MODES.delete]: deleteSelected,
[MODES.updateSettings]: updateSettings,
[MODES.changeOwner]: changeOwner,
};
const icons: Record<MODES, string> = {
[MODES.tag]: $globals.icons.tags,
[MODES.category]: $globals.icons.categories,
[MODES.export]: $globals.icons.database,
[MODES.delete]: $globals.icons.delete,
[MODES.updateSettings]: $globals.icons.cog,
[MODES.changeOwner]: $globals.icons.user,
};
dialog.mode = mode;
dialog.title = titles[mode];
dialog.callback = callbacks[mode];
dialog.icon = icons[mode];
dialog.state = true;
}
const { store: allUsers } = useUserStore();
const { store: households } = useHouseholdStore();
const selectedOwner = ref("");
const selectedOwnerHousehold = computed(() => {
if (!selectedOwner.value) {
return null;
}
const owner = allUsers.value.find(u => u.id === selectedOwner.value);
if (!owner) {
return null;
};
return households.value.find(h => h.id === owner.householdId);
});
</script>

View File

@@ -43,24 +43,18 @@
</v-container>
</template>
<script lang="ts">
<script setup lang="ts">
import { useGroupSelf } from "~/composables/use-groups";
export default defineNuxtComponent({
definePageMeta({
middleware: ["can-manage-only"],
setup() {
const { group, actions: groupActions } = useGroupSelf();
const i18n = useI18n();
});
useSeoMeta({
title: i18n.t("group.group"),
});
const { group, actions: groupActions } = useGroupSelf();
const i18n = useI18n();
return {
group,
groupActions,
};
},
useSeoMeta({
title: i18n.t("group.group"),
});
</script>

View File

@@ -19,7 +19,7 @@
<BaseCardSectionTitle :title="$t('migration.new-migration')" />
<v-card
variant="outlined"
:loading="loading"
:loading="state.loading"
style="border-color: lightgrey;"
>
<v-card-title> {{ $t('migration.choose-migration-type') }} </v-card-title>
@@ -29,7 +29,7 @@
>
<div class="mb-2">
<BaseOverflowButton
v-model="migrationType"
v-model="state.migrationType"
mode="model"
:items="items"
/>
@@ -37,7 +37,7 @@
{{ content.text }}
<v-treeview
v-if="content.tree && Array.isArray(content.tree)"
:key="migrationType"
:key="state.migrationType"
density="compact"
:items="content.tree"
>
@@ -59,15 +59,15 @@
:text-btn="false"
@uploaded="setFileObject"
/>
{{ fileObject.name || $t('migration.no-file-selected') }}
{{ state.fileObject.name || $t('migration.no-file-selected') }}
</v-card-text>
<v-card-text>
<v-checkbox v-model="addMigrationTag">
<v-checkbox v-model="state.addMigrationTag">
<template #label>
<i18n-t keypath="migration.tag-all-recipes">
<template #tag-name>
<b class="mx-1"> {{ migrationType }} </b>
<b class="mx-1"> {{ state.migrationType }} </b>
</template>
</i18n-t>
</template>
@@ -76,7 +76,7 @@
<v-card-actions class="justify-end">
<BaseButton
:disabled="!fileObject.name"
:disabled="!state.fileObject.name"
submit
@click="startMigration"
>
@@ -88,14 +88,14 @@
<v-container class="$vuetify.display.smAndDown ? 'px-0': ''">
<BaseCardSectionTitle :title="$t('migration.previous-migrations')" />
<ReportTable
:items="reports"
:items="state.reports"
@delete="deleteReport"
/>
</v-container>
</v-container>
</template>
<script lang="ts">
<script setup lang="ts">
import type { ReportSummary } from "~/lib/api/types/reports";
import type { MenuItem } from "~/components/global/BaseOverflowButton.vue";
import { useUserApi } from "~/composables/api";
@@ -127,398 +127,387 @@ const MIGRATIONS = {
cookn: "cookn",
};
export default defineNuxtComponent({
definePageMeta({
middleware: ["advanced-only"],
setup() {
const i18n = useI18n();
const { $globals } = useNuxtApp();
});
useSeoMeta({
title: i18n.t("settings.migrations"),
});
const i18n = useI18n();
const { $globals } = useNuxtApp();
const api = useUserApi();
useSeoMeta({
title: i18n.t("settings.migrations"),
});
const state = reactive({
addMigrationTag: false,
loading: false,
treeState: true,
migrationType: MIGRATIONS.mealie as SupportedMigrations,
fileObject: {} as File,
reports: [] as ReportSummary[],
});
const api = useUserApi();
const items: MenuItem[] = [
{
text: i18n.t("migration.mealie-pre-v1.title"),
value: MIGRATIONS.mealie,
divider: true,
},
{
text: i18n.t("migration.chowdown.title"),
value: MIGRATIONS.chowdown,
},
{
text: i18n.t("migration.copymethat.title"),
value: MIGRATIONS.copymethat,
},
{
text: i18n.t("migration.myrecipebox.title"),
value: MIGRATIONS.myrecipebox,
},
{
text: i18n.t("migration.nextcloud.title"),
value: MIGRATIONS.nextcloud,
},
{
text: i18n.t("migration.paprika.title"),
value: MIGRATIONS.paprika,
},
{
text: i18n.t("migration.plantoeat.title"),
value: MIGRATIONS.plantoeat,
},
{
text: i18n.t("migration.recipekeeper.title"),
value: MIGRATIONS.recipekeeper,
},
{
text: i18n.t("migration.tandoor.title"),
value: MIGRATIONS.tandoor,
},
{
text: i18n.t("migration.cookn.title"),
value: MIGRATIONS.cookn,
},
];
const _content: Record<string, MigrationContent> = {
[MIGRATIONS.mealie]: {
text: i18n.t("migration.mealie-pre-v1.description-long"),
acceptedFileType: ".zip",
tree: [
{
icon: $globals.icons.zip,
title: "mealie.zip",
children: [
{
title: "recipes",
icon: $globals.icons.folderOutline,
children: [
{
title: "recipe-name",
icon: $globals.icons.folderOutline,
children: [
{ title: "recipe-name.json", icon: $globals.icons.codeJson },
{
title: "images",
icon: $globals.icons.folderOutline,
children: [
{ title: "original.webp", icon: $globals.icons.codeJson },
{ title: "full.jpg", icon: $globals.icons.fileImage },
{ title: "thumb.jpg", icon: $globals.icons.fileImage },
],
},
],
},
{
title: "recipe-name-1",
icon: $globals.icons.folderOutline,
children: [
{ title: "recipe-name-1.json", icon: $globals.icons.codeJson },
{
title: "images",
icon: $globals.icons.folderOutline,
children: [
{ title: "original.webp", icon: $globals.icons.codeJson },
{ title: "full.jpg", icon: $globals.icons.fileImage },
{ title: "thumb.jpg", icon: $globals.icons.fileImage },
],
},
],
},
],
},
],
},
],
},
[MIGRATIONS.chowdown]: {
text: i18n.t("migration.chowdown.description-long"),
acceptedFileType: ".zip",
tree: [
{
icon: $globals.icons.zip,
title: "nextcloud.zip",
children: [
{
title: i18n.t("migration.recipe-1"),
icon: $globals.icons.folderOutline,
children: [
{ title: "recipe.json", icon: $globals.icons.codeJson },
{ title: "full.jpg", icon: $globals.icons.fileImage },
{ title: "thumb.jpg", icon: $globals.icons.fileImage },
],
},
{
title: i18n.t("migration.recipe-2"),
icon: $globals.icons.folderOutline,
children: [
{ title: "recipe.json", icon: $globals.icons.codeJson },
{ title: "full.jpg", icon: $globals.icons.fileImage },
{ title: "thumb.jpg", icon: $globals.icons.fileImage },
],
},
],
},
],
},
[MIGRATIONS.copymethat]: {
text: i18n.t("migration.copymethat.description-long"),
acceptedFileType: ".zip",
tree: [
{
icon: $globals.icons.zip,
title: "Copy_Me_That_20230306.zip",
children: [
{
title: "images",
icon: $globals.icons.folderOutline,
children: [
{ title: "recipe_1_an5zy.jpg", icon: $globals.icons.fileImage },
{ title: "recipe_2_82el8.jpg", icon: $globals.icons.fileImage },
{ title: "recipe_3_j75qg.jpg", icon: $globals.icons.fileImage },
],
},
{ title: "recipes.html", icon: $globals.icons.codeJson },
],
},
],
},
[MIGRATIONS.myrecipebox]: {
text: i18n.t("migration.myrecipebox.description-long"),
acceptedFileType: ".csv",
tree: false,
},
[MIGRATIONS.nextcloud]: {
text: i18n.t("migration.nextcloud.description-long"),
acceptedFileType: ".zip",
tree: [
{
icon: $globals.icons.zip,
title: "nextcloud.zip",
children: [
{
title: i18n.t("migration.recipe-1"),
icon: $globals.icons.folderOutline,
children: [
{ title: "recipe.json", icon: $globals.icons.codeJson },
{ title: "full.jpg", icon: $globals.icons.fileImage },
{ title: "thumb.jpg", icon: $globals.icons.fileImage },
],
},
{
title: i18n.t("migration.recipe-2"),
icon: $globals.icons.folderOutline,
children: [
{ title: "recipe.json", icon: $globals.icons.codeJson },
{ title: "full.jpg", icon: $globals.icons.fileImage },
{ title: "thumb.jpg", icon: $globals.icons.fileImage },
],
},
],
},
],
},
[MIGRATIONS.paprika]: {
text: i18n.t("migration.paprika.description-long"),
acceptedFileType: ".zip",
tree: false,
},
[MIGRATIONS.plantoeat]: {
text: i18n.t("migration.plantoeat.description-long"),
acceptedFileType: ".zip",
tree: [
{
icon: $globals.icons.zip,
title: "plantoeat-recipes-508318_10-13-2023.zip",
children: [
{ title: "plantoeat-recipes-508318_10-13-2023.csv", icon: $globals.icons.codeJson },
],
},
],
},
[MIGRATIONS.recipekeeper]: {
text: i18n.t("migration.recipekeeper.description-long"),
acceptedFileType: ".zip",
tree: [
{
icon: $globals.icons.zip,
title: "recipekeeperhtml.zip",
children: [
{ title: "recipes.html", icon: $globals.icons.codeJson },
{
title: "images", icon: $globals.icons.folderOutline,
children: [
{ title: "image1.jpg", icon: $globals.icons.fileImage },
{ title: "image2.jpg", icon: $globals.icons.fileImage },
],
},
],
},
],
},
[MIGRATIONS.tandoor]: {
text: i18n.t("migration.tandoor.description-long"),
acceptedFileType: ".zip",
tree: [
{
icon: $globals.icons.zip,
title: "tandoor_default_export_full_2023-06-29.zip",
children: [
{
title: "1.zip",
icon: $globals.icons.zip,
children: [
{ title: "image.jpeg", icon: $globals.icons.fileImage },
{ title: "recipe.json", icon: $globals.icons.codeJson },
],
},
{
title: "2.zip",
icon: $globals.icons.zip,
children: [
{ title: "image.jpeg", icon: $globals.icons.fileImage },
{ title: "recipe.json", icon: $globals.icons.codeJson },
],
},
{
title: "3.zip",
icon: $globals.icons.zip,
children: [
{ title: "image.jpeg", icon: $globals.icons.fileImage },
{ title: "recipe.json", icon: $globals.icons.codeJson },
],
},
],
},
],
},
[MIGRATIONS.cookn]: {
text: i18n.t("migration.cookn.description-long"),
acceptedFileType: ".zip",
tree: [
{
icon: $globals.icons.zip,
title: "cookn.zip",
children: [
{ title: "temp_brand.dsv", icon: $globals.icons.codeJson },
{ title: "temp_chapter_desc.dsv", icon: $globals.icons.codeJson },
{ title: "temp_chapter.dsv", icon: $globals.icons.codeJson },
{ title: "temp_cookBook_desc.dsv", icon: $globals.icons.codeJson },
{ title: "temp_cookBook.dsv", icon: $globals.icons.codeJson },
{ title: "temp_food_brand.dsv", icon: $globals.icons.codeJson },
{ title: "temp_food_group.dsv", icon: $globals.icons.codeJson },
{ title: "temp_food.dsv", icon: $globals.icons.codeJson },
{ title: "temp_ingredient.dsv", icon: $globals.icons.codeJson },
{ title: "temp_media.dsv", icon: $globals.icons.codeJson },
{ title: "temp_nutrient.dsv", icon: $globals.icons.codeJson },
{ title: "temp_recipe_desc.dsv", icon: $globals.icons.codeJson },
{ title: "temp_recipe.dsv", icon: $globals.icons.codeJson },
{ title: "temp_unit_equivalent.dsv", icon: $globals.icons.codeJson },
{ title: "temp_unit.dsv", icon: $globals.icons.codeJson },
{ title: "images", icon: $globals.icons.fileImage },
],
},
],
},
};
const state = reactive({
addMigrationTag: false,
loading: false,
treeState: true,
migrationType: MIGRATIONS.mealie as SupportedMigrations,
fileObject: {} as File,
reports: [] as ReportSummary[],
});
function addIdToNode(counter: number, node: TreeNode): number {
node.id = counter;
counter += 1;
if (node.children) {
node.children.forEach((child: TreeNode) => {
counter = addIdToNode(counter, child);
});
}
return counter;
}
for (const key in _content) {
const migration = _content[key];
if (migration.tree && Array.isArray(migration.tree)) {
let counter = 1;
migration.tree.forEach((node: TreeNode) => {
counter = addIdToNode(counter, node);
});
}
}
console.log(_content);
function setFileObject(fileObject: File) {
state.fileObject = fileObject;
}
async function startMigration() {
state.loading = true;
const payload = {
addMigrationTag: state.addMigrationTag,
migrationType: state.migrationType,
archive: state.fileObject,
};
const { data } = await api.groupMigration.startMigration(payload);
state.loading = false;
if (data) {
state.reports.unshift(data);
}
}
async function getMigrationReports() {
const { data } = await api.groupReports.getAll("migration");
if (data) {
state.reports = data;
}
}
async function deleteReport(id: string) {
await api.groupReports.deleteOne(id);
getMigrationReports();
}
onMounted(() => {
getMigrationReports();
});
const content = computed(() => {
const data = _content[state.migrationType];
if (data) {
return data;
}
else {
return {
text: "",
acceptedFileType: ".zip",
tree: false,
};
}
});
return {
...toRefs(state),
items,
content,
setFileObject,
deleteReport,
startMigration,
getMigrationReports,
};
const items: MenuItem[] = [
{
text: i18n.t("migration.mealie-pre-v1.title"),
value: MIGRATIONS.mealie,
divider: true,
},
{
text: i18n.t("migration.chowdown.title"),
value: MIGRATIONS.chowdown,
},
{
text: i18n.t("migration.copymethat.title"),
value: MIGRATIONS.copymethat,
},
{
text: i18n.t("migration.myrecipebox.title"),
value: MIGRATIONS.myrecipebox,
},
{
text: i18n.t("migration.nextcloud.title"),
value: MIGRATIONS.nextcloud,
},
{
text: i18n.t("migration.paprika.title"),
value: MIGRATIONS.paprika,
},
{
text: i18n.t("migration.plantoeat.title"),
value: MIGRATIONS.plantoeat,
},
{
text: i18n.t("migration.recipekeeper.title"),
value: MIGRATIONS.recipekeeper,
},
{
text: i18n.t("migration.tandoor.title"),
value: MIGRATIONS.tandoor,
},
{
text: i18n.t("migration.cookn.title"),
value: MIGRATIONS.cookn,
},
];
const _content: Record<string, MigrationContent> = {
[MIGRATIONS.mealie]: {
text: i18n.t("migration.mealie-pre-v1.description-long"),
acceptedFileType: ".zip",
tree: [
{
icon: $globals.icons.zip,
title: "mealie.zip",
children: [
{
title: "recipes",
icon: $globals.icons.folderOutline,
children: [
{
title: "recipe-name",
icon: $globals.icons.folderOutline,
children: [
{ title: "recipe-name.json", icon: $globals.icons.codeJson },
{
title: "images",
icon: $globals.icons.folderOutline,
children: [
{ title: "original.webp", icon: $globals.icons.codeJson },
{ title: "full.jpg", icon: $globals.icons.fileImage },
{ title: "thumb.jpg", icon: $globals.icons.fileImage },
],
},
],
},
{
title: "recipe-name-1",
icon: $globals.icons.folderOutline,
children: [
{ title: "recipe-name-1.json", icon: $globals.icons.codeJson },
{
title: "images",
icon: $globals.icons.folderOutline,
children: [
{ title: "original.webp", icon: $globals.icons.codeJson },
{ title: "full.jpg", icon: $globals.icons.fileImage },
{ title: "thumb.jpg", icon: $globals.icons.fileImage },
],
},
],
},
],
},
],
},
],
},
[MIGRATIONS.chowdown]: {
text: i18n.t("migration.chowdown.description-long"),
acceptedFileType: ".zip",
tree: [
{
icon: $globals.icons.zip,
title: "nextcloud.zip",
children: [
{
title: i18n.t("migration.recipe-1"),
icon: $globals.icons.folderOutline,
children: [
{ title: "recipe.json", icon: $globals.icons.codeJson },
{ title: "full.jpg", icon: $globals.icons.fileImage },
{ title: "thumb.jpg", icon: $globals.icons.fileImage },
],
},
{
title: i18n.t("migration.recipe-2"),
icon: $globals.icons.folderOutline,
children: [
{ title: "recipe.json", icon: $globals.icons.codeJson },
{ title: "full.jpg", icon: $globals.icons.fileImage },
{ title: "thumb.jpg", icon: $globals.icons.fileImage },
],
},
],
},
],
},
[MIGRATIONS.copymethat]: {
text: i18n.t("migration.copymethat.description-long"),
acceptedFileType: ".zip",
tree: [
{
icon: $globals.icons.zip,
title: "Copy_Me_That_20230306.zip",
children: [
{
title: "images",
icon: $globals.icons.folderOutline,
children: [
{ title: "recipe_1_an5zy.jpg", icon: $globals.icons.fileImage },
{ title: "recipe_2_82el8.jpg", icon: $globals.icons.fileImage },
{ title: "recipe_3_j75qg.jpg", icon: $globals.icons.fileImage },
],
},
{ title: "recipes.html", icon: $globals.icons.codeJson },
],
},
],
},
[MIGRATIONS.myrecipebox]: {
text: i18n.t("migration.myrecipebox.description-long"),
acceptedFileType: ".csv",
tree: false,
},
[MIGRATIONS.nextcloud]: {
text: i18n.t("migration.nextcloud.description-long"),
acceptedFileType: ".zip",
tree: [
{
icon: $globals.icons.zip,
title: "nextcloud.zip",
children: [
{
title: i18n.t("migration.recipe-1"),
icon: $globals.icons.folderOutline,
children: [
{ title: "recipe.json", icon: $globals.icons.codeJson },
{ title: "full.jpg", icon: $globals.icons.fileImage },
{ title: "thumb.jpg", icon: $globals.icons.fileImage },
],
},
{
title: i18n.t("migration.recipe-2"),
icon: $globals.icons.folderOutline,
children: [
{ title: "recipe.json", icon: $globals.icons.codeJson },
{ title: "full.jpg", icon: $globals.icons.fileImage },
{ title: "thumb.jpg", icon: $globals.icons.fileImage },
],
},
],
},
],
},
[MIGRATIONS.paprika]: {
text: i18n.t("migration.paprika.description-long"),
acceptedFileType: ".zip",
tree: false,
},
[MIGRATIONS.plantoeat]: {
text: i18n.t("migration.plantoeat.description-long"),
acceptedFileType: ".zip",
tree: [
{
icon: $globals.icons.zip,
title: "plantoeat-recipes-508318_10-13-2023.zip",
children: [
{ title: "plantoeat-recipes-508318_10-13-2023.csv", icon: $globals.icons.codeJson },
],
},
],
},
[MIGRATIONS.recipekeeper]: {
text: i18n.t("migration.recipekeeper.description-long"),
acceptedFileType: ".zip",
tree: [
{
icon: $globals.icons.zip,
title: "recipekeeperhtml.zip",
children: [
{ title: "recipes.html", icon: $globals.icons.codeJson },
{
title: "images", icon: $globals.icons.folderOutline,
children: [
{ title: "image1.jpg", icon: $globals.icons.fileImage },
{ title: "image2.jpg", icon: $globals.icons.fileImage },
],
},
],
},
],
},
[MIGRATIONS.tandoor]: {
text: i18n.t("migration.tandoor.description-long"),
acceptedFileType: ".zip",
tree: [
{
icon: $globals.icons.zip,
title: "tandoor_default_export_full_2023-06-29.zip",
children: [
{
title: "1.zip",
icon: $globals.icons.zip,
children: [
{ title: "image.jpeg", icon: $globals.icons.fileImage },
{ title: "recipe.json", icon: $globals.icons.codeJson },
],
},
{
title: "2.zip",
icon: $globals.icons.zip,
children: [
{ title: "image.jpeg", icon: $globals.icons.fileImage },
{ title: "recipe.json", icon: $globals.icons.codeJson },
],
},
{
title: "3.zip",
icon: $globals.icons.zip,
children: [
{ title: "image.jpeg", icon: $globals.icons.fileImage },
{ title: "recipe.json", icon: $globals.icons.codeJson },
],
},
],
},
],
},
[MIGRATIONS.cookn]: {
text: i18n.t("migration.cookn.description-long"),
acceptedFileType: ".zip",
tree: [
{
icon: $globals.icons.zip,
title: "cookn.zip",
children: [
{ title: "temp_brand.dsv", icon: $globals.icons.codeJson },
{ title: "temp_chapter_desc.dsv", icon: $globals.icons.codeJson },
{ title: "temp_chapter.dsv", icon: $globals.icons.codeJson },
{ title: "temp_cookBook_desc.dsv", icon: $globals.icons.codeJson },
{ title: "temp_cookBook.dsv", icon: $globals.icons.codeJson },
{ title: "temp_food_brand.dsv", icon: $globals.icons.codeJson },
{ title: "temp_food_group.dsv", icon: $globals.icons.codeJson },
{ title: "temp_food.dsv", icon: $globals.icons.codeJson },
{ title: "temp_ingredient.dsv", icon: $globals.icons.codeJson },
{ title: "temp_media.dsv", icon: $globals.icons.codeJson },
{ title: "temp_nutrient.dsv", icon: $globals.icons.codeJson },
{ title: "temp_recipe_desc.dsv", icon: $globals.icons.codeJson },
{ title: "temp_recipe.dsv", icon: $globals.icons.codeJson },
{ title: "temp_unit_equivalent.dsv", icon: $globals.icons.codeJson },
{ title: "temp_unit.dsv", icon: $globals.icons.codeJson },
{ title: "images", icon: $globals.icons.fileImage },
],
},
],
},
};
function addIdToNode(counter: number, node: TreeNode): number {
node.id = counter;
counter += 1;
if (node.children) {
node.children.forEach((child: TreeNode) => {
counter = addIdToNode(counter, child);
});
}
return counter;
}
for (const key in _content) {
const migration = _content[key];
if (migration.tree && Array.isArray(migration.tree)) {
let counter = 1;
migration.tree.forEach((node: TreeNode) => {
counter = addIdToNode(counter, node);
});
}
}
console.log(_content);
function setFileObject(fileObject: File) {
state.fileObject = fileObject;
}
async function startMigration() {
state.loading = true;
const payload = {
addMigrationTag: state.addMigrationTag,
migrationType: state.migrationType,
archive: state.fileObject,
};
const { data } = await api.groupMigration.startMigration(payload);
state.loading = false;
if (data) {
state.reports.unshift(data);
}
}
async function getMigrationReports() {
const { data } = await api.groupReports.getAll("migration");
if (data) {
state.reports = data;
}
}
async function deleteReport(id: string) {
await api.groupReports.deleteOne(id);
getMigrationReports();
}
onMounted(() => {
getMigrationReports();
});
const content = computed(() => {
const data = _content[state.migrationType];
if (data) {
return data;
}
else {
return {
text: "",
acceptedFileType: ".zip",
tree: false,
};
}
});
</script>

View File

@@ -47,41 +47,31 @@
</v-container>
</template>
<script lang="ts">
<script setup lang="ts">
import { useUserApi } from "~/composables/api";
import type { ReportOut } from "~/lib/api/types/reports";
export default defineNuxtComponent({
setup() {
const route = useRoute();
const id = route.params.id as string;
const route = useRoute();
const id = route.params.id as string;
const api = useUserApi();
const api = useUserApi();
const report = ref<ReportOut | null>(null);
const report = ref<ReportOut | null>(null);
async function getReport() {
const { data } = await api.groupReports.getOne(id);
report.value = data ?? null;
}
async function getReport() {
const { data } = await api.groupReports.getOne(id);
report.value = data ?? null;
}
onMounted(async () => {
await getReport();
});
const itemHeaders = [
{ title: "Success", value: "success" },
{ title: "Message", value: "message" },
{ title: "Timestamp", value: "timestamp" },
];
return {
report,
id,
itemHeaders,
};
},
onMounted(async () => {
await getReport();
});
const itemHeaders = [
{ title: "Success", value: "success" },
{ title: "Message", value: "message" },
{ title: "Timestamp", value: "timestamp" },
];
</script>
<style lang="scss" scoped></style>

View File

@@ -32,129 +32,39 @@
</v-container>
</template>
<script lang="ts">
<script setup lang="ts">
import HouseholdPreferencesEditor from "~/components/Domain/Household/HouseholdPreferencesEditor.vue";
import { useHouseholdSelf } from "~/composables/use-households";
import type { ReadHouseholdPreferences } from "~/lib/api/types/household";
import { alert } from "~/composables/use-toast";
import type { VForm } from "~/types/auto-forms";
export default defineNuxtComponent({
components: {
HouseholdPreferencesEditor,
},
definePageMeta({
middleware: ["can-manage-household-only"],
setup() {
const { household, actions: householdActions } = useHouseholdSelf();
const i18n = useI18n();
useSeoMeta({
title: i18n.t("household.household"),
});
const refHouseholdEditForm = ref<VForm | null>(null);
type Preference = {
key: keyof ReadHouseholdPreferences;
value: boolean;
label: string;
description: string;
};
const preferencesEditor = computed<Preference[]>(() => {
if (!household.value || !household.value.preferences) {
return [];
}
return [
{
key: "recipePublic",
value: household.value.preferences.recipePublic || false,
label: i18n.t("household.allow-users-outside-of-your-household-to-see-your-recipes"),
description: i18n.t("household.allow-users-outside-of-your-household-to-see-your-recipes-description"),
} as Preference,
{
key: "recipeShowNutrition",
value: household.value.preferences.recipeShowNutrition || false,
label: i18n.t("group.show-nutrition-information"),
description: i18n.t("group.show-nutrition-information-description"),
} as Preference,
{
key: "recipeShowAssets",
value: household.value.preferences.recipeShowAssets || false,
label: i18n.t("group.show-recipe-assets"),
description: i18n.t("group.show-recipe-assets-description"),
} as Preference,
{
key: "recipeLandscapeView",
value: household.value.preferences.recipeLandscapeView || false,
label: i18n.t("group.default-to-landscape-view"),
description: i18n.t("group.default-to-landscape-view-description"),
} as Preference,
{
key: "recipeDisableComments",
value: household.value.preferences.recipeDisableComments || false,
label: i18n.t("group.disable-users-from-commenting-on-recipes"),
description: i18n.t("group.disable-users-from-commenting-on-recipes-description"),
} as Preference,
];
});
const allDays = [
{
name: i18n.t("general.sunday"),
value: 0,
},
{
name: i18n.t("general.monday"),
value: 1,
},
{
name: i18n.t("general.tuesday"),
value: 2,
},
{
name: i18n.t("general.wednesday"),
value: 3,
},
{
name: i18n.t("general.thursday"),
value: 4,
},
{
name: i18n.t("general.friday"),
value: 5,
},
{
name: i18n.t("general.saturday"),
value: 6,
},
];
async function handleSubmit() {
if (!refHouseholdEditForm.value?.validate() || !household.value?.preferences) {
console.log(refHouseholdEditForm.value?.validate());
return;
}
const data = await householdActions.updatePreferences();
if (data) {
alert.success(i18n.t("settings.settings-updated"));
}
else {
alert.error(i18n.t("settings.settings-update-failed"));
}
}
return {
household,
householdActions,
allDays,
preferencesEditor,
refHouseholdEditForm,
handleSubmit,
};
},
});
const { household, actions: householdActions } = useHouseholdSelf();
const i18n = useI18n();
useSeoMeta({
title: i18n.t("household.household"),
});
const refHouseholdEditForm = ref<VForm | null>(null);
async function handleSubmit() {
if (!refHouseholdEditForm.value?.validate() || !household.value?.preferences) {
console.log(refHouseholdEditForm.value?.validate());
return;
}
const data = await householdActions.updatePreferences();
if (data) {
alert.success(i18n.t("settings.settings-updated"));
}
else {
alert.error(i18n.t("settings.settings-update-failed"));
}
}
</script>
<style lang="css">

View File

@@ -86,7 +86,7 @@
</v-container>
</template>
<script lang="ts">
<script setup lang="ts">
import { isSameDay, addDays, parseISO, format, isValid } from "date-fns";
import RecipeDialogAddToShoppingList from "~/components/Domain/Recipe/RecipeDialogAddToShoppingList.vue";
import { useHouseholdSelf } from "~/composables/use-households";
@@ -95,185 +95,163 @@ import { useUserMealPlanPreferences } from "~/composables/use-users/preferences"
import type { ShoppingListSummary } from "~/lib/api/types/household";
import { useUserApi } from "~/composables/api";
export default defineNuxtComponent({
components: {
RecipeDialogAddToShoppingList,
},
setup() {
const TABS = {
view: "household-mealplan-planner-view",
edit: "household-mealplan-planner-edit",
};
const TABS = {
view: "household-mealplan-planner-view",
edit: "household-mealplan-planner-edit",
};
const route = useRoute();
const router = useRouter();
const i18n = useI18n();
const api = useUserApi();
const { household } = useHouseholdSelf();
const route = useRoute();
const router = useRouter();
const i18n = useI18n();
const api = useUserApi();
const { household } = useHouseholdSelf();
useSeoMeta({
title: i18n.t("meal-plan.dinner-this-week"),
});
const mealPlanPreferences = useUserMealPlanPreferences();
const numberOfDays = ref<number>(mealPlanPreferences.value.numberOfDays || 7);
watch(numberOfDays, (val) => {
mealPlanPreferences.value.numberOfDays = Number(val);
});
// Force to /view if current route is /planner
if (route.path === "/household/mealplan/planner") {
router.push({
name: TABS.view,
query: route.query,
});
}
function safeParseISO(date: string, fallback: Date | undefined = undefined) {
try {
const parsed = parseISO(date);
return isValid(parsed) ? parsed : fallback;
}
catch {
return fallback;
}
}
// Initialize dates from query parameters or defaults
const initialStartDate = safeParseISO(route.query.start as string, new Date());
const initialEndDate = safeParseISO(route.query.end as string, addDays(new Date(), adjustForToday(numberOfDays.value)));
const state = ref({
range: [initialStartDate, initialEndDate] as [Date, Date],
start: initialStartDate,
picker: false,
end: initialEndDate,
shoppingListDialog: false,
addAllLoading: false,
});
const shoppingLists = ref<ShoppingListSummary[]>();
const firstDayOfWeek = computed(() => {
return household.value?.preferences?.firstDayOfWeek || 0;
});
const weekRange = computed(() => {
const sorted = [...state.value.range].sort((a, b) => a.getTime() - b.getTime());
const start = sorted[0];
const end = sorted[sorted.length - 1];
if (start && end) {
return { start, end };
}
return {
start: new Date(),
end: addDays(new Date(), adjustForToday(numberOfDays.value)),
};
});
// Update query parameters when date range changes
watch(weekRange, (newRange) => {
// Keep current route name and params, just update the query
router.replace({
name: route.name || TABS.view,
params: route.params,
query: {
...route.query,
start: format(newRange.start, "yyyy-MM-dd"),
end: format(newRange.end, "yyyy-MM-dd"),
},
});
}, { immediate: true });
const { mealplans, actions } = useMealplans(weekRange);
function filterMealByDate(date: Date) {
if (!mealplans.value) return [];
return mealplans.value.filter((meal) => {
const mealDate = parseISO(meal.date);
return isSameDay(mealDate, date);
});
}
function adjustForToday(days: number) {
// The use case for this function is "how many days are we adding to 'today'?"
// e.g. If the user wants 7 days, we substract one to do "today + 6"
return days > 0 ? days - 1 : days + 1;
}
const days = computed(() => {
const numDays
= Math.floor((weekRange.value.end.getTime() - weekRange.value.start.getTime()) / (1000 * 60 * 60 * 24)) + 1;
// Calculate absolute value
if (numDays < 0) return [];
return Array.from(Array(numDays).keys()).map(
(i) => {
const date = new Date(weekRange.value.start.getTime());
date.setDate(date.getDate() + i);
return date;
},
);
});
const mealsByDate = computed(() => {
return days.value.map((day) => {
return { date: day, meals: filterMealByDate(day) };
});
});
const hasRecipes = computed(() => {
return mealsByDate.value.some(day => day.meals.some(meal => meal.recipe));
});
const weekRecipesWithScales = computed(() => {
const allRecipes: any[] = [];
for (const day of mealsByDate.value) {
for (const meal of day.meals) {
if (meal.recipe) {
allRecipes.push(meal.recipe);
}
}
}
return allRecipes.map(recipe => ({
scale: 1,
...recipe,
}));
});
async function getShoppingLists() {
const { data } = await api.shopping.lists.getAll(1, -1, { orderBy: "name", orderDirection: "asc" });
if (data) {
shoppingLists.value = data.items as ShoppingListSummary[] ?? [];
}
}
async function addAllToList() {
state.value.addAllLoading = true;
await getShoppingLists();
state.value.shoppingListDialog = true;
state.value.addAllLoading = false;
}
return {
TABS,
route,
state,
actions,
mealsByDate,
weekRange,
firstDayOfWeek,
numberOfDays,
hasRecipes,
shoppingLists,
weekRecipesWithScales,
addAllToList,
};
},
useSeoMeta({
title: i18n.t("meal-plan.dinner-this-week"),
});
const mealPlanPreferences = useUserMealPlanPreferences();
const numberOfDays = ref<number>(mealPlanPreferences.value.numberOfDays || 7);
watch(numberOfDays, (val) => {
mealPlanPreferences.value.numberOfDays = Number(val);
});
// Force to /view if current route is /planner
if (route.path === "/household/mealplan/planner") {
router.push({
name: TABS.view,
query: route.query,
});
}
function safeParseISO(date: string, fallback: Date | undefined = undefined) {
try {
const parsed = parseISO(date);
return isValid(parsed) ? parsed : fallback;
}
catch {
return fallback;
}
}
// Initialize dates from query parameters or defaults
const initialStartDate = safeParseISO(route.query.start as string, new Date());
const initialEndDate = safeParseISO(route.query.end as string, addDays(new Date(), adjustForToday(numberOfDays.value)));
const state = ref({
range: [initialStartDate, initialEndDate] as [Date, Date],
start: initialStartDate,
picker: false,
end: initialEndDate,
shoppingListDialog: false,
addAllLoading: false,
});
const shoppingLists = ref<ShoppingListSummary[]>();
const firstDayOfWeek = computed(() => {
return household.value?.preferences?.firstDayOfWeek || 0;
});
const weekRange = computed(() => {
const sorted = [...state.value.range].sort((a, b) => a.getTime() - b.getTime());
const start = sorted[0];
const end = sorted[sorted.length - 1];
if (start && end) {
return { start, end };
}
return {
start: new Date(),
end: addDays(new Date(), adjustForToday(numberOfDays.value)),
};
});
// Update query parameters when date range changes
watch(weekRange, (newRange) => {
// Keep current route name and params, just update the query
router.replace({
name: route.name || TABS.view,
params: route.params,
query: {
...route.query,
start: format(newRange.start, "yyyy-MM-dd"),
end: format(newRange.end, "yyyy-MM-dd"),
},
});
}, { immediate: true });
const { mealplans, actions } = useMealplans(weekRange);
function filterMealByDate(date: Date) {
if (!mealplans.value) return [];
return mealplans.value.filter((meal) => {
const mealDate = parseISO(meal.date);
return isSameDay(mealDate, date);
});
}
function adjustForToday(days: number) {
// The use case for this function is "how many days are we adding to 'today'?"
// e.g. If the user wants 7 days, we substract one to do "today + 6"
return days > 0 ? days - 1 : days + 1;
}
const days = computed(() => {
const numDays
= Math.floor((weekRange.value.end.getTime() - weekRange.value.start.getTime()) / (1000 * 60 * 60 * 24)) + 1;
// Calculate absolute value
if (numDays < 0) return [];
return Array.from(Array(numDays).keys()).map(
(i) => {
const date = new Date(weekRange.value.start.getTime());
date.setDate(date.getDate() + i);
return date;
},
);
});
const mealsByDate = computed(() => {
return days.value.map((day) => {
return { date: day, meals: filterMealByDate(day) };
});
});
const hasRecipes = computed(() => {
return mealsByDate.value.some(day => day.meals.some(meal => meal.recipe));
});
const weekRecipesWithScales = computed(() => {
const allRecipes: any[] = [];
for (const day of mealsByDate.value) {
for (const meal of day.meals) {
if (meal.recipe) {
allRecipes.push(meal.recipe);
}
}
}
return allRecipes.map(recipe => ({
scale: 1,
...recipe,
}));
});
async function getShoppingLists() {
const { data } = await api.shopping.lists.getAll(1, -1, { orderBy: "name", orderDirection: "asc" });
if (data) {
shoppingLists.value = data.items as ShoppingListSummary[] ?? [];
}
}
async function addAllToList() {
state.value.addAllLoading = true;
await getShoppingLists();
state.value.shoppingListDialog = true;
state.value.addAllLoading = false;
}
</script>
<style lang="css">

View File

@@ -129,9 +129,9 @@
</v-icon>
</v-btn>
<v-menu offset-y>
<template #activator="{ props }">
<template #activator="{ props: menuProps }">
<v-chip
v-bind="props"
v-bind="menuProps"
label
variant="elevated"
size="small"
@@ -232,7 +232,7 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import { format } from "date-fns";
import type { SortableEvent } from "sortablejs";
import { VueDraggable } from "vue-draggable-plus";
@@ -246,194 +246,157 @@ import { useHouseholdSelf } from "~/composables/use-households";
import { normalizeFilter } from "~/composables/use-utils";
import { useRecipeSearch } from "~/composables/recipes/use-recipe-search";
export default defineNuxtComponent({
components: {
VueDraggable,
RecipeCardImage,
const props = defineProps<{
mealplans: MealsByDate[];
actions: ReturnType<typeof useMealplans>["actions"];
}>();
const api = useUserApi();
const auth = useMealieAuth();
const { household } = useHouseholdSelf();
const requiredRule = (value: any) => !!value || "Required.";
const state = ref({
dialog: false,
});
const firstDayOfWeek = computed(() => {
return household.value?.preferences?.firstDayOfWeek || 0;
});
// Local mutable meals object
const mealplansByDate = reactive<{ [date: string]: UpdatePlanEntry[] }>({});
watch(
() => props.mealplans,
(plans) => {
for (const plan of plans) {
mealplansByDate[plan.date.toString()] = plan.meals ? [...plan.meals] : [];
}
// Remove any dates that no longer exist
Object.keys(mealplansByDate).forEach((date) => {
if (!plans.find(p => p.date.toString() === date)) {
mealplansByDate[date] = [];
}
});
},
props: {
mealplans: {
type: Array as () => MealsByDate[],
required: true,
},
actions: {
type: Object as () => ReturnType<typeof useMealplans>["actions"],
required: true,
},
},
setup(props) {
const api = useUserApi();
const auth = useMealieAuth();
const { household } = useHouseholdSelf();
const requiredRule = (value: any) => !!value || "Required.";
{ immediate: true, deep: true },
);
const state = ref({
dialog: false,
});
function onMoveCallback(evt: SortableEvent) {
const supportedEvents = ["drop", "touchend"];
const firstDayOfWeek = computed(() => {
return household.value?.preferences?.firstDayOfWeek || 0;
});
// Adapted From https://github.com/SortableJS/Vue.Draggable/issues/1029
const ogEvent: DragEvent = (evt as any).originalEvent;
// Local mutable meals object
const mealplansByDate = reactive<{ [date: string]: UpdatePlanEntry[] }>({});
watch(
() => props.mealplans,
(plans) => {
for (const plan of plans) {
mealplansByDate[plan.date.toString()] = plan.meals ? [...plan.meals] : [];
}
// Remove any dates that no longer exist
Object.keys(mealplansByDate).forEach((date) => {
if (!plans.find(p => p.date.toString() === date)) {
mealplansByDate[date] = [];
}
});
},
{ immediate: true, deep: true },
);
if (ogEvent && ogEvent.type in supportedEvents) {
// The drop was cancelled, unsure if anything needs to be done?
console.log("Cancel Move Event");
}
else {
// A Meal was moved, set the new date value and make an update request and refresh the meals
const fromMealsByIndex = parseInt(evt.from.getAttribute("data-index") ?? "");
const toMealsByIndex = parseInt(evt.to.getAttribute("data-index") ?? "");
function onMoveCallback(evt: SortableEvent) {
const supportedEvents = ["drop", "touchend"];
if (!isNaN(fromMealsByIndex) && !isNaN(toMealsByIndex)) {
const destDate = props.mealplans[toMealsByIndex].date;
const mealData = mealplansByDate[destDate.toString()][evt.newIndex as number];
// Adapted From https://github.com/SortableJS/Vue.Draggable/issues/1029
const ogEvent: DragEvent = (evt as any).originalEvent;
mealData.date = format(destDate, "yyyy-MM-dd");
if (ogEvent && ogEvent.type in supportedEvents) {
// The drop was cancelled, unsure if anything needs to be done?
console.log("Cancel Move Event");
}
else {
// A Meal was moved, set the new date value and make an update request and refresh the meals
const fromMealsByIndex = parseInt(evt.from.getAttribute("data-index") ?? "");
const toMealsByIndex = parseInt(evt.to.getAttribute("data-index") ?? "");
if (!isNaN(fromMealsByIndex) && !isNaN(toMealsByIndex)) {
const destDate = props.mealplans[toMealsByIndex].date;
const mealData = mealplansByDate[destDate.toString()][evt.newIndex as number];
mealData.date = format(destDate, "yyyy-MM-dd");
props.actions.updateOne(mealData);
}
}
props.actions.updateOne(mealData);
}
}
}
// =====================================================
// New Meal Dialog
// =====================================================
// New Meal Dialog
const dialog = reactive({
loading: false,
error: false,
note: false,
});
const dialog = reactive({
loading: false,
error: false,
note: false,
});
watch(dialog, () => {
if (dialog.note) {
newMeal.recipeId = undefined;
}
});
watch(dialog, () => {
if (dialog.note) {
newMeal.recipeId = undefined;
}
});
const newMeal = reactive({
date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000),
title: "",
text: "",
recipeId: undefined as string | undefined,
entryType: "dinner" as PlanEntryType,
existing: false,
id: 0,
groupId: "",
userId: auth.user.value?.id || "",
});
const newMeal = reactive({
date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000),
title: "",
text: "",
recipeId: undefined as string | undefined,
entryType: "dinner" as PlanEntryType,
existing: false,
id: 0,
groupId: "",
userId: auth.user.value?.id || "",
});
const newMealDateString = computed(() => {
return format(newMeal.date, "yyyy-MM-dd");
});
const newMealDateString = computed(() => {
return format(newMeal.date, "yyyy-MM-dd");
});
const isCreateDisabled = computed(() => {
if (dialog.note) {
return !newMeal.title.trim();
}
return !newMeal.recipeId;
});
const isCreateDisabled = computed(() => {
if (dialog.note) {
return !newMeal.title.trim();
}
return !newMeal.recipeId;
});
function openDialog(date: Date) {
newMeal.date = date;
state.value.dialog = true;
}
function openDialog(date: Date) {
newMeal.date = date;
state.value.dialog = true;
}
function editMeal(mealplan: UpdatePlanEntry) {
const { date, title, text, entryType, recipeId, id, groupId, userId } = mealplan;
if (!entryType) return;
function editMeal(mealplan: UpdatePlanEntry) {
const { date, title, text, entryType, recipeId, id, groupId, userId } = mealplan;
if (!entryType) return;
const [year, month, day] = date.split("-").map(Number);
newMeal.date = new Date(year, month - 1, day);
newMeal.title = title || "";
newMeal.text = text || "";
newMeal.recipeId = recipeId || undefined;
newMeal.entryType = entryType;
newMeal.existing = true;
newMeal.id = id;
newMeal.groupId = groupId;
newMeal.userId = userId || auth.user.value?.id || "";
const [year, month, day] = date.split("-").map(Number);
newMeal.date = new Date(year, month - 1, day);
newMeal.title = title || "";
newMeal.text = text || "";
newMeal.recipeId = recipeId || undefined;
newMeal.entryType = entryType;
newMeal.existing = true;
newMeal.id = id;
newMeal.groupId = groupId;
newMeal.userId = userId || auth.user.value?.id || "";
state.value.dialog = true;
dialog.note = !recipeId;
}
state.value.dialog = true;
dialog.note = !recipeId;
}
function resetDialog() {
newMeal.date = new Date(Date.now() - new Date().getTimezoneOffset() * 60000);
newMeal.title = "";
newMeal.text = "";
newMeal.entryType = "dinner";
newMeal.recipeId = undefined;
newMeal.existing = false;
}
function resetDialog() {
newMeal.date = new Date(Date.now() - new Date().getTimezoneOffset() * 60000);
newMeal.title = "";
newMeal.text = "";
newMeal.entryType = "dinner";
newMeal.recipeId = undefined;
newMeal.existing = false;
}
async function randomMeal(date: Date, type: PlanEntryType) {
const { data } = await api.mealplans.setRandom({
date: format(date, "yyyy-MM-dd"),
entryType: type,
});
async function randomMeal(date: Date, type: PlanEntryType) {
const { data } = await api.mealplans.setRandom({
date: format(date, "yyyy-MM-dd"),
entryType: type,
});
if (data) {
props.actions.refreshAll();
}
}
if (data) {
props.actions.refreshAll();
}
}
// =====================================================
// Search
// =====================================================
// Search
const search = useRecipeSearch(api);
const planTypeOptions = usePlanTypeOptions();
const search = useRecipeSearch(api);
const planTypeOptions = usePlanTypeOptions();
onMounted(async () => {
await search.trigger();
});
return {
state,
onMoveCallback,
planTypeOptions,
getEntryTypeText,
requiredRule,
isCreateDisabled,
normalizeFilter,
// Dialog
dialog,
newMeal,
newMealDateString,
openDialog,
editMeal,
resetDialog,
randomMeal,
// Search
search,
firstDayOfWeek,
mealplansByDate,
};
},
onMounted(async () => {
await search.trigger();
});
</script>

View File

@@ -50,7 +50,7 @@
</v-container>
</template>
<script lang="ts" setup>
<script setup lang="ts">
import type { MealsByDate } from "./types";
import type { ReadPlanEntry } from "~/lib/api/types/meal-plan";
import GroupMealPlanDayContextMenu from "~/components/Domain/Household/GroupMealPlanDayContextMenu.vue";

View File

@@ -171,102 +171,77 @@
</v-container>
</template>
<script lang="ts">
<script setup lang="ts">
import { useUserApi } from "~/composables/api";
import type { PlanRulesCreate, PlanRulesOut } from "~/lib/api/types/meal-plan";
import GroupMealPlanRuleForm from "~/components/Domain/Household/GroupMealPlanRuleForm.vue";
import { useAsyncKey } from "~/composables/use-utils";
import RecipeChips from "~/components/Domain/Recipe/RecipeChips.vue";
export default defineNuxtComponent({
components: {
GroupMealPlanRuleForm,
RecipeChips,
},
props: {
modelValue: {
type: Boolean,
default: false,
},
},
setup() {
const api = useUserApi();
const i18n = useI18n();
const api = useUserApi();
const i18n = useI18n();
useSeoMeta({
title: i18n.t("meal-plan.meal-plan-settings"),
});
useSeoMeta({
title: i18n.t("meal-plan.meal-plan-settings"),
});
// ======================================================
// Manage All
const editState = ref<{ [key: string]: boolean }>({});
const allRules = ref<PlanRulesOut[]>([]);
// ======================================================
// Manage All
const editState = ref<{ [key: string]: boolean }>({});
const allRules = ref<PlanRulesOut[]>([]);
function toggleEditState(id: string) {
editState.value[id] = !editState.value[id];
editState.value = { ...editState.value };
}
function toggleEditState(id: string) {
editState.value[id] = !editState.value[id];
editState.value = { ...editState.value };
}
async function refreshAll() {
const { data } = await api.mealplanRules.getAll();
async function refreshAll() {
const { data } = await api.mealplanRules.getAll();
if (data) {
allRules.value = data.items ?? [];
}
}
if (data) {
allRules.value = data.items ?? [];
}
}
useAsyncData(useAsyncKey(), async () => {
await refreshAll();
});
useAsyncData(useAsyncKey(), async () => {
await refreshAll();
});
// ======================================================
// Creating Rules
// ======================================================
// Creating Rules
const createDataFormKey = ref(0);
const createData = ref<PlanRulesCreate>({
const createDataFormKey = ref(0);
const createData = ref<PlanRulesCreate>({
entryType: "unset",
day: "unset",
queryFilterString: "",
});
async function createRule() {
const { data } = await api.mealplanRules.createOne(createData.value);
if (data) {
refreshAll();
createData.value = {
entryType: "unset",
day: "unset",
queryFilterString: "",
});
async function createRule() {
const { data } = await api.mealplanRules.createOne(createData.value);
if (data) {
refreshAll();
createData.value = {
entryType: "unset",
day: "unset",
queryFilterString: "",
};
createDataFormKey.value++;
}
}
async function deleteRule(ruleId: string) {
const { data } = await api.mealplanRules.deleteOne(ruleId);
if (data) {
refreshAll();
}
}
async function updateRule(rule: PlanRulesOut) {
const { data } = await api.mealplanRules.updateOne(rule.id, rule);
if (data) {
refreshAll();
toggleEditState(rule.id);
}
}
return {
allRules,
createDataFormKey,
createData,
createRule,
deleteRule,
editState,
updateRule,
toggleEditState,
};
},
});
createDataFormKey.value++;
}
}
async function deleteRule(ruleId: string) {
const { data } = await api.mealplanRules.deleteOne(ruleId);
if (data) {
refreshAll();
}
}
async function updateRule(rule: PlanRulesOut) {
const { data } = await api.mealplanRules.updateOne(rule.id, rule);
if (data) {
refreshAll();
toggleEditState(rule.id);
}
}
</script>

View File

@@ -115,61 +115,51 @@
</v-container>
</template>
<script lang="ts">
<script setup lang="ts">
import { useUserApi } from "~/composables/api";
import type { UserOut } from "~/lib/api/types/user";
import UserAvatar from "~/components/Domain/User/UserAvatar.vue";
export default defineNuxtComponent({
components: {
UserAvatar,
},
setup() {
const auth = useMealieAuth();
const api = useUserApi();
const i18n = useI18n();
const api = useUserApi();
const i18n = useI18n();
useSeoMeta({
title: i18n.t("profile.members"),
});
useSeoMeta({
title: i18n.t("profile.members"),
});
const members = ref<UserOut[] | null[]>([]);
const members = ref<UserOut[] | null[]>([]);
const headers = [
{ title: "", value: "avatar", sortable: false, align: "center" },
{ title: i18n.t("user.username"), value: "username" },
{ title: i18n.t("user.full-name"), value: "fullName" },
{ title: i18n.t("user.admin"), value: "admin" },
{ title: i18n.t("group.manage"), value: "manage", sortable: false, align: "center" },
{ title: i18n.t("settings.organize"), value: "organize", sortable: false, align: "center" },
{ title: i18n.t("group.invite"), value: "invite", sortable: false, align: "center" },
{ title: i18n.t("group.manage-household"), value: "manageHousehold", sortable: false, align: "center" },
];
const headers = [
{ title: "", value: "avatar", sortable: false, align: "center" },
{ title: i18n.t("user.username"), value: "username" },
{ title: i18n.t("user.full-name"), value: "fullName" },
{ title: i18n.t("user.admin"), value: "admin" },
{ title: i18n.t("group.manage"), value: "manage", sortable: false, align: "center" },
{ title: i18n.t("settings.organize"), value: "organize", sortable: false, align: "center" },
{ title: i18n.t("group.invite"), value: "invite", sortable: false, align: "center" },
{ title: i18n.t("group.manage-household"), value: "manageHousehold", sortable: false, align: "center" },
];
async function refreshMembers() {
const { data } = await api.households.fetchMembers();
if (data) {
members.value = data.items;
}
}
async function refreshMembers() {
const { data } = await api.households.fetchMembers();
if (data) {
members.value = data.items;
}
}
async function setPermissions(user: UserOut) {
const payload = {
userId: user.id,
canInvite: user.canInvite,
canManageHousehold: user.canManageHousehold,
canManage: user.canManage,
canOrganize: user.canOrganize,
};
async function setPermissions(user: UserOut) {
const payload = {
userId: user.id,
canInvite: user.canInvite,
canManageHousehold: user.canManageHousehold,
canManage: user.canManage,
canOrganize: user.canOrganize,
};
await api.households.setMemberPermissions(payload);
}
await api.households.setMemberPermissions(payload);
}
onMounted(async () => {
await refreshMembers();
});
return { members, headers, setPermissions, sessionUser: auth.user };
},
onMounted(async () => {
await refreshMembers();
});
</script>

View File

@@ -1,19 +1,19 @@
<template>
<v-container class="narrow-container">
<BaseDialog
v-model="deleteDialog"
v-model="state.deleteDialog"
color="error"
:title="$t('general.confirm')"
:icon="$globals.icons.alertCircle"
can-confirm
@confirm="deleteNotifier(deleteTargetId)"
@confirm="deleteNotifier(state.deleteTargetId)"
>
<v-card-text>
{{ $t("general.confirm-delete-generic") }}
</v-card-text>
</BaseDialog>
<BaseDialog
v-model="createDialog"
v-model="state.createDialog"
:title="$t('events.new-notification')"
:icon="$globals.icons.bellPlus"
can-submit
@@ -83,7 +83,7 @@
<BaseButton
create
@click="createDialog = true"
@click="state.createDialog = true"
/>
<v-expansion-panels
v-if="notifiers"
@@ -182,7 +182,7 @@
</v-container>
</template>
<script lang="ts">
<script setup lang="ts">
import { useUserApi } from "~/composables/api";
import { useAsyncKey } from "~/composables/use-utils";
import type { GroupEventNotifierCreate, GroupEventNotifierOut } from "~/lib/api/types/household";
@@ -198,210 +198,197 @@ interface OptionSection {
options: OptionKey[];
}
export default defineNuxtComponent({
definePageMeta({
middleware: ["advanced-only"],
setup() {
const api = useUserApi();
const i18n = useI18n();
useSeoMeta({
title: i18n.t("profile.notifiers"),
});
const state = reactive({
deleteDialog: false,
createDialog: false,
deleteTargetId: "",
});
const { data: notifiers } = useAsyncData(useAsyncKey(), async () => {
const { data } = await api.groupEventNotifier.getAll();
return data?.items;
});
async function refreshNotifiers() {
const { data } = await api.groupEventNotifier.getAll();
notifiers.value = data?.items;
}
const createNotifierData: GroupEventNotifierCreate = reactive({
name: "",
enabled: true,
appriseUrl: "",
});
async function createNewNotifier() {
await api.groupEventNotifier.createOne(createNotifierData);
refreshNotifiers();
}
function openDelete(notifier: GroupEventNotifierOut) {
state.deleteDialog = true;
state.deleteTargetId = notifier.id;
}
async function deleteNotifier(targetId: string) {
await api.groupEventNotifier.deleteOne(targetId);
refreshNotifiers();
state.deleteTargetId = "";
}
async function saveNotifier(notifier: GroupEventNotifierOut) {
await api.groupEventNotifier.updateOne(notifier.id, notifier);
refreshNotifiers();
}
async function testNotifier(notifier: GroupEventNotifierOut) {
await api.groupEventNotifier.test(notifier.id);
}
// ===============================================================
// Options Definitions
const optionsSections: OptionSection[] = [
{
id: 1,
text: i18n.t("events.recipe-events"),
options: [
{
text: i18n.t("general.create") as string,
key: "recipeCreated",
},
{
text: i18n.t("general.update") as string,
key: "recipeUpdated",
},
{
text: i18n.t("general.delete") as string,
key: "recipeDeleted",
},
],
},
{
id: 2,
text: i18n.t("events.user-events"),
options: [
{
text: i18n.t("events.when-a-new-user-joins-your-group"),
key: "userSignup",
},
],
},
{
id: 3,
text: i18n.t("events.mealplan-events"),
options: [
{
text: i18n.t("events.when-a-user-in-your-group-creates-a-new-mealplan"),
key: "mealplanEntryCreated",
},
],
},
{
id: 4,
text: i18n.t("events.shopping-list-events"),
options: [
{
text: i18n.t("general.create") as string,
key: "shoppingListCreated",
},
{
text: i18n.t("general.update") as string,
key: "shoppingListUpdated",
},
{
text: i18n.t("general.delete") as string,
key: "shoppingListDeleted",
},
],
},
{
id: 5,
text: i18n.t("events.cookbook-events"),
options: [
{
text: i18n.t("general.create") as string,
key: "cookbookCreated",
},
{
text: i18n.t("general.update") as string,
key: "cookbookUpdated",
},
{
text: i18n.t("general.delete") as string,
key: "cookbookDeleted",
},
],
},
{
id: 6,
text: i18n.t("events.tag-events"),
options: [
{
text: i18n.t("general.create") as string,
key: "tagCreated",
},
{
text: i18n.t("general.update") as string,
key: "tagUpdated",
},
{
text: i18n.t("general.delete") as string,
key: "tagDeleted",
},
],
},
{
id: 7,
text: i18n.t("events.category-events"),
options: [
{
text: i18n.t("general.create") as string,
key: "categoryCreated",
},
{
text: i18n.t("general.update") as string,
key: "categoryUpdated",
},
{
text: i18n.t("general.delete") as string,
key: "categoryDeleted",
},
],
},
{
id: 8,
text: i18n.t("events.label-events"),
options: [
{
text: i18n.t("general.create") as string,
key: "labelCreated",
},
{
text: i18n.t("general.update") as string,
key: "labelUpdated",
},
{
text: i18n.t("general.delete") as string,
key: "labelDeleted",
},
],
},
];
return {
...toRefs(state),
openDelete,
notifiers,
createNotifierData,
optionsSections,
deleteNotifier,
testNotifier,
saveNotifier,
createNewNotifier,
};
},
});
const api = useUserApi();
const i18n = useI18n();
useSeoMeta({
title: i18n.t("profile.notifiers"),
});
const state = reactive({
deleteDialog: false,
createDialog: false,
deleteTargetId: "",
});
const { data: notifiers } = useAsyncData(useAsyncKey(), async () => {
const { data } = await api.groupEventNotifier.getAll();
return data?.items;
});
async function refreshNotifiers() {
const { data } = await api.groupEventNotifier.getAll();
notifiers.value = data?.items;
}
const createNotifierData: GroupEventNotifierCreate = reactive({
name: "",
enabled: true,
appriseUrl: "",
});
async function createNewNotifier() {
await api.groupEventNotifier.createOne(createNotifierData);
refreshNotifiers();
}
function openDelete(notifier: GroupEventNotifierOut) {
state.deleteDialog = true;
state.deleteTargetId = notifier.id;
}
async function deleteNotifier(targetId: string) {
await api.groupEventNotifier.deleteOne(targetId);
refreshNotifiers();
state.deleteTargetId = "";
}
async function saveNotifier(notifier: GroupEventNotifierOut) {
await api.groupEventNotifier.updateOne(notifier.id, notifier);
refreshNotifiers();
}
async function testNotifier(notifier: GroupEventNotifierOut) {
await api.groupEventNotifier.test(notifier.id);
}
// ===============================================================
// Options Definitions
const optionsSections: OptionSection[] = [
{
id: 1,
text: i18n.t("events.recipe-events"),
options: [
{
text: i18n.t("general.create") as string,
key: "recipeCreated",
},
{
text: i18n.t("general.update") as string,
key: "recipeUpdated",
},
{
text: i18n.t("general.delete") as string,
key: "recipeDeleted",
},
],
},
{
id: 2,
text: i18n.t("events.user-events"),
options: [
{
text: i18n.t("events.when-a-new-user-joins-your-group"),
key: "userSignup",
},
],
},
{
id: 3,
text: i18n.t("events.mealplan-events"),
options: [
{
text: i18n.t("events.when-a-user-in-your-group-creates-a-new-mealplan"),
key: "mealplanEntryCreated",
},
],
},
{
id: 4,
text: i18n.t("events.shopping-list-events"),
options: [
{
text: i18n.t("general.create") as string,
key: "shoppingListCreated",
},
{
text: i18n.t("general.update") as string,
key: "shoppingListUpdated",
},
{
text: i18n.t("general.delete") as string,
key: "shoppingListDeleted",
},
],
},
{
id: 5,
text: i18n.t("events.cookbook-events"),
options: [
{
text: i18n.t("general.create") as string,
key: "cookbookCreated",
},
{
text: i18n.t("general.update") as string,
key: "cookbookUpdated",
},
{
text: i18n.t("general.delete") as string,
key: "cookbookDeleted",
},
],
},
{
id: 6,
text: i18n.t("events.tag-events"),
options: [
{
text: i18n.t("general.create") as string,
key: "tagCreated",
},
{
text: i18n.t("general.update") as string,
key: "tagUpdated",
},
{
text: i18n.t("general.delete") as string,
key: "tagDeleted",
},
],
},
{
id: 7,
text: i18n.t("events.category-events"),
options: [
{
text: i18n.t("general.create") as string,
key: "categoryCreated",
},
{
text: i18n.t("general.update") as string,
key: "categoryUpdated",
},
{
text: i18n.t("general.delete") as string,
key: "categoryDeleted",
},
],
},
{
id: 8,
text: i18n.t("events.label-events"),
options: [
{
text: i18n.t("general.create") as string,
key: "labelCreated",
},
{
text: i18n.t("general.update") as string,
key: "labelUpdated",
},
{
text: i18n.t("general.delete") as string,
key: "labelDeleted",
},
],
},
];
</script>
<style>

View File

@@ -68,28 +68,19 @@
</v-container>
</template>
<script lang="ts">
<script setup lang="ts">
import { useGroupWebhooks, timeUTC } from "~/composables/use-group-webhooks";
import GroupWebhookEditor from "~/components/Domain/Household/GroupWebhookEditor.vue";
import { alert } from "~/composables/use-toast";
export default defineNuxtComponent({
components: { GroupWebhookEditor },
definePageMeta({
middleware: ["advanced-only"],
setup() {
const i18n = useI18n();
const { actions, webhooks } = useGroupWebhooks();
});
useSeoMeta({
title: i18n.t("settings.webhooks.webhooks"),
});
const i18n = useI18n();
const { actions, webhooks } = useGroupWebhooks();
return {
alert,
webhooks,
actions,
timeUTC,
};
},
useSeoMeta({
title: i18n.t("settings.webhooks.webhooks"),
});
</script>

View File

@@ -2,58 +2,54 @@
<div />
</template>
<script lang="ts">
<script setup lang="ts">
import useDefaultActivity from "~/composables/use-default-activity";
import { useUserActivityPreferences } from "~/composables/use-users/preferences";
import { useAsyncKey } from "~/composables/use-utils";
import type { AppInfo, AppStartupInfo } from "~/lib/api/types/admin";
export default defineNuxtComponent({
setup() {
definePageMeta({
layout: "blank",
});
definePageMeta({
layout: "blank",
});
const auth = useMealieAuth();
const { $axios } = useNuxtApp();
const router = useRouter();
const activityPreferences = useUserActivityPreferences();
const { getDefaultActivityRoute } = useDefaultActivity();
const groupSlug = computed(() => auth.user.value?.groupSlug);
const auth = useMealieAuth();
const { $axios } = useNuxtApp();
const router = useRouter();
const activityPreferences = useUserActivityPreferences();
const { getDefaultActivityRoute } = useDefaultActivity();
const groupSlug = computed(() => auth.user.value?.groupSlug);
async function redirectPublicUserToDefaultGroup() {
const { data } = await $axios.get<AppInfo>("/api/app/about");
if (data?.defaultGroupSlug) {
router.push(`/g/${data.defaultGroupSlug}`);
}
else {
router.push("/login");
}
async function redirectPublicUserToDefaultGroup() {
const { data } = await $axios.get<AppInfo>("/api/app/about");
if (data?.defaultGroupSlug) {
router.push(`/g/${data.defaultGroupSlug}`);
}
else {
router.push("/login");
}
}
useAsyncData(useAsyncKey(), async () => {
if (groupSlug.value) {
const data = await $axios.get<AppStartupInfo>("/api/app/about/startup-info");
const isDemo = data.data.isDemo;
const isFirstLogin = data.data.isFirstLogin;
const defaultActivityRoute = getDefaultActivityRoute(
activityPreferences.value.defaultActivity,
groupSlug.value,
);
if (!isDemo && isFirstLogin && auth.user.value?.admin) {
router.push("/admin/setup");
}
useAsyncData(useAsyncKey(), async () => {
if (groupSlug.value) {
const data = await $axios.get<AppStartupInfo>("/api/app/about/startup-info");
const isDemo = data.data.isDemo;
const isFirstLogin = data.data.isFirstLogin;
const defaultActivityRoute = getDefaultActivityRoute(
activityPreferences.value.defaultActivity,
groupSlug.value,
);
if (!isDemo && isFirstLogin && auth.user.value?.admin) {
router.push("/admin/setup");
}
else if (defaultActivityRoute) {
router.push(defaultActivityRoute);
}
else {
router.push(`/g/${groupSlug.value}`);
}
}
else {
redirectPublicUserToDefaultGroup();
}
});
},
else if (defaultActivityRoute) {
router.push(defaultActivityRoute);
}
else {
router.push(`/g/${groupSlug.value}`);
}
}
else {
redirectPublicUserToDefaultGroup();
}
});
</script>

View File

@@ -210,7 +210,7 @@
</v-container>
</template>
<script lang="ts">
<script setup lang="ts">
import { useDark, whenever } from "@vueuse/core";
import { useLoggedInState } from "~/composables/use-logged-in-state";
import { usePasswordField } from "~/composables/use-passwords";
@@ -219,161 +219,144 @@ import { useAsyncKey } from "~/composables/use-utils";
import type { AppStartupInfo } from "~/lib/api/types/admin";
import { useUserActivityPreferences } from "~/composables/use-users/preferences";
export default defineNuxtComponent({
setup() {
definePageMeta({
layout: "blank",
});
const isDark = useDark();
const router = useRouter();
const i18n = useI18n();
const auth = useMealieAuth();
const { $appInfo, $axios } = useNuxtApp();
const { loggedIn } = useLoggedInState();
const groupSlug = computed(() => auth.user.value?.groupSlug);
const isDemo = ref(false);
const isFirstLogin = ref(false);
const activityPreferences = useUserActivityPreferences();
const { getDefaultActivityRoute } = useDefaultActivity();
useSeoMeta({
title: i18n.t("user.login"),
});
const form = reactive({
email: "",
password: "",
remember: false,
});
useAsyncData(useAsyncKey(), async () => {
const data = await $axios.get<AppStartupInfo>("/api/app/about/startup-info");
isDemo.value = data.data.isDemo;
isFirstLogin.value = data.data.isFirstLogin;
if (data.data.isFirstLogin) {
form.email = "changeme@example.com";
form.password = "MyPassword";
}
});
whenever(
() => loggedIn.value && groupSlug.value,
() => {
const defaultActivityRoute = getDefaultActivityRoute(
activityPreferences.value.defaultActivity,
groupSlug.value,
);
if (!isDemo.value && isFirstLogin.value && auth.user.value?.admin) {
router.push("/admin/setup");
}
else if (defaultActivityRoute) {
router.push(defaultActivityRoute);
}
else {
router.push(`/g/${groupSlug.value || ""}`);
}
},
{ immediate: true },
);
const loggingIn = ref(false);
const oidcLoggingIn = ref(false);
const { passwordIcon, inputType, togglePasswordShow } = usePasswordField();
whenever(
() => $appInfo.enableOidc && $appInfo.oidcRedirect && !isCallback() && !isDirectLogin() /* && !auth.check().valid */,
() => oidcAuthenticate(),
{ immediate: true },
);
onBeforeMount(async () => {
if (isCallback()) {
await oidcAuthenticate(true);
}
});
function isCallback() {
const params = new URLSearchParams(window.location.search);
return params.has("code") || params.has("error");
}
function isDirectLogin() {
const params = new URLSearchParams(window.location.search);
return params.has("direct") && params.get("direct") === "1";
}
async function oidcAuthenticate(callback = false) {
if (callback) {
oidcLoggingIn.value = true;
try {
await auth.oauthSignIn();
}
catch (error) {
await router.replace("/login?direct=1");
alertOnError(error);
}
oidcLoggingIn.value = false;
}
else {
navigateTo("/api/auth/oauth", { external: true }); // start the redirect process
}
}
async function authenticate() {
if (form.email.length === 0 || form.password.length === 0) {
alert.error(i18n.t("user.please-enter-your-email-and-password"));
return;
}
loggingIn.value = true;
const formData = new FormData();
formData.append("username", form.email);
formData.append("password", form.password);
formData.append("remember_me", String(form.remember));
try {
await auth.signIn(formData);
}
catch (error) {
console.log(error);
alertOnError(error);
}
loggingIn.value = false;
}
function alertOnError(error: any) {
// TODO Check if error is an AxiosError, but isAxiosError is not working right now
// See https://github.com/nuxt-community/axios-module/issues/550
// Import $axios from useContext()
// if ($axios.isAxiosError(error) && error.response?.status === 401) {
if (error.response?.status === 401) {
alert.error(i18n.t("user.invalid-credentials"));
}
else if (error.response?.status === 423) {
alert.error(i18n.t("user.account-locked-please-try-again-later"));
}
else {
alert.error(i18n.t("events.something-went-wrong"));
}
}
return {
isDark,
form,
loggingIn,
authenticate,
oidcAuthenticate,
oidcLoggingIn,
passwordIcon,
inputType,
togglePasswordShow,
isFirstLogin,
};
},
definePageMeta({
layout: "blank",
});
const isDark = useDark();
const router = useRouter();
const i18n = useI18n();
const auth = useMealieAuth();
const { $appInfo, $axios } = useNuxtApp();
const { loggedIn } = useLoggedInState();
const groupSlug = computed(() => auth.user.value?.groupSlug);
const isDemo = ref(false);
const isFirstLogin = ref(false);
const activityPreferences = useUserActivityPreferences();
const { getDefaultActivityRoute } = useDefaultActivity();
useSeoMeta({
title: i18n.t("user.login"),
});
const form = reactive({
email: "",
password: "",
remember: false,
});
useAsyncData(useAsyncKey(), async () => {
const data = await $axios.get<AppStartupInfo>("/api/app/about/startup-info");
isDemo.value = data.data.isDemo;
isFirstLogin.value = data.data.isFirstLogin;
if (data.data.isFirstLogin) {
form.email = "changeme@example.com";
form.password = "MyPassword";
}
});
whenever(
() => loggedIn.value && groupSlug.value,
() => {
const defaultActivityRoute = getDefaultActivityRoute(
activityPreferences.value.defaultActivity,
groupSlug.value,
);
if (!isDemo.value && isFirstLogin.value && auth.user.value?.admin) {
router.push("/admin/setup");
}
else if (defaultActivityRoute) {
router.push(defaultActivityRoute);
}
else {
router.push(`/g/${groupSlug.value || ""}`);
}
},
{ immediate: true },
);
const loggingIn = ref(false);
const oidcLoggingIn = ref(false);
const { passwordIcon, inputType, togglePasswordShow } = usePasswordField();
whenever(
() => $appInfo.enableOidc && $appInfo.oidcRedirect && !isCallback() && !isDirectLogin() /* && !auth.check().valid */,
() => oidcAuthenticate(),
{ immediate: true },
);
onBeforeMount(async () => {
if (isCallback()) {
await oidcAuthenticate(true);
}
});
function isCallback() {
const params = new URLSearchParams(window.location.search);
return params.has("code") || params.has("error");
}
function isDirectLogin() {
const params = new URLSearchParams(window.location.search);
return params.has("direct") && params.get("direct") === "1";
}
async function oidcAuthenticate(callback = false) {
if (callback) {
oidcLoggingIn.value = true;
try {
await auth.oauthSignIn();
}
catch (error) {
await router.replace("/login?direct=1");
alertOnError(error);
}
oidcLoggingIn.value = false;
}
else {
navigateTo("/api/auth/oauth", { external: true }); // start the redirect process
}
}
async function authenticate() {
if (form.email.length === 0 || form.password.length === 0) {
alert.error(i18n.t("user.please-enter-your-email-and-password"));
return;
}
loggingIn.value = true;
const formData = new FormData();
formData.append("username", form.email);
formData.append("password", form.password);
formData.append("remember_me", String(form.remember));
try {
await auth.signIn(formData);
}
catch (error) {
console.log(error);
alertOnError(error);
}
loggingIn.value = false;
}
function alertOnError(error: any) {
// TODO Check if error is an AxiosError, but isAxiosError is not working right now
// See https://github.com/nuxt-community/axios-module/issues/550
// Import $axios from useContext()
// if ($axios.isAxiosError(error) && error.response?.status === 401) {
if (error.response?.status === 401) {
alert.error(i18n.t("user.invalid-credentials"));
}
else if (error.response?.status === 423) {
alert.error(i18n.t("user.account-locked-please-try-again-later"));
}
else {
alert.error(i18n.t("events.something-went-wrong"));
}
}
</script>
<style lang="css" scoped>

View File

@@ -298,7 +298,7 @@
</v-container>
</template>
<script lang="ts">
<script setup lang="ts">
import { useDark } from "@vueuse/core";
import { States, RegistrationType, useRegistration } from "./states";
import { useUserRegistrationForm } from "~/composables/use-users/user-registration-form";
@@ -307,239 +307,193 @@ import { validators, useAsyncValidator } from "~/composables/use-validators";
import { useUserApi } from "~/composables/api";
import { alert } from "~/composables/use-toast";
import type { CreateUserRegistration } from "~/lib/api/types/user";
import { usePasswordField } from "~/composables/use-passwords";
import { usePublicApi } from "~/composables/api/api-client";
import { useLocales } from "~/composables/use-locales";
import UserRegistrationForm from "~/components/Domain/User/UserRegistrationForm.vue";
import type { VForm } from "~/types/auto-forms";
definePageMeta({
layout: "blank",
});
const inputAttrs = {
variant: "filled",
validateOnBlur: true,
};
export default defineNuxtComponent({
components: { UserRegistrationForm },
setup() {
definePageMeta({
layout: "blank",
});
const i18n = useI18n();
const isDark = useDark();
const i18n = useI18n();
const isDark = useDark();
function safeValidate(form: Ref<VForm | null>) {
if (form.value && form.value.validate) {
return form.value.validate();
}
return false;
}
function safeValidate(form: Ref<VForm | null>) {
if (form.value && form.value.validate) {
return form.value.validate();
}
return false;
}
// ================================================================
// Registration Context
//
// State is used to manage the registration process states and provide
// a state machine esq interface to interact with the registration workflow.
const state = useRegistration();
// ================================================================
// Handle Token URL / Initialization
//
const token = useRouteQuery("token");
// TODO: We need to have some way to check to see if the site is in a state
// Where it needs to be initialized with a user, in that case we'll handle that
// somewhere...
function initialUser() {
return false;
}
onMounted(() => {
if (token.value) {
state.setState(States.ProvideAccountDetails);
state.setType(RegistrationType.JoinGroup);
}
if (initialUser()) {
state.setState(States.ProvideGroupDetails);
state.setType(RegistrationType.InitialGroup);
}
});
// ================================================================
// Initial
const initial = {
createGroup: () => {
state.setState(States.ProvideGroupDetails);
state.setType(RegistrationType.CreateGroup);
if (token.value != null) {
token.value = null;
}
},
joinGroup: () => {
state.setState(States.ProvideToken);
state.setType(RegistrationType.JoinGroup);
},
};
// ================================================================
// Provide Token
const domTokenForm = ref<VForm | null>(null);
function validateToken() {
return true;
}
const provideToken = {
next: () => {
if (!safeValidate(domTokenForm as Ref<VForm>)) {
return;
}
if (validateToken()) {
state.setState(States.ProvideAccountDetails);
}
},
};
// ================================================================
// Provide Group Details
const publicApi = usePublicApi();
const domGroupForm = ref<VForm | null>(null);
const groupName = ref("");
const groupSeed = ref(false);
const groupPrivate = ref(false);
const groupErrorMessages = ref<string[]>([]);
const { validate: validGroupName, valid: groupNameValid } = useAsyncValidator(
groupName,
(v: string) => publicApi.validators.group(v),
i18n.t("validation.group-name-is-taken"),
groupErrorMessages,
);
const groupDetails = {
groupName,
groupSeed,
groupPrivate,
next: () => {
if (!safeValidate(domGroupForm as Ref<VForm>) || !groupNameValid.value) {
return;
}
state.setState(States.ProvideAccountDetails);
},
};
const pwFields = usePasswordField();
const {
accountDetails,
credentials,
domAccountForm,
emailErrorMessages,
usernameErrorMessages,
validateUsername,
validateEmail,
} = useUserRegistrationForm();
async function accountDetailsNext() {
if (!await accountDetails.validate()) {
return;
}
state.setState(States.Confirmation);
}
// ================================================================
// Locale
const { locale } = useLocales();
const langDialog = ref(false);
// ================================================================
// Confirmation
const confirmationData = computed(() => {
return [
{
display: state.ctx.type === RegistrationType.CreateGroup,
text: i18n.t("group.group"),
value: groupName.value,
},
{
display: state.ctx.type === RegistrationType.CreateGroup,
text: i18n.t("data-pages.seed-data"),
value: groupSeed.value ? i18n.t("general.yes") : i18n.t("general.no"),
},
{
display: state.ctx.type === RegistrationType.CreateGroup,
text: i18n.t("group.settings.keep-my-recipes-private"),
value: groupPrivate.value ? i18n.t("general.yes") : i18n.t("general.no"),
},
{
display: true,
text: i18n.t("user.email"),
value: accountDetails.email.value,
},
{
display: true,
text: i18n.t("user.full-name"),
value: accountDetails.fullName.value,
},
{
display: true,
text: i18n.t("user.username"),
value: accountDetails.username.value,
},
{
display: true,
text: i18n.t("user.enable-advanced-content"),
value: accountDetails.advancedOptions.value ? i18n.t("general.yes") : i18n.t("general.no"),
},
];
});
const api = useUserApi();
const router = useRouter();
async function submitRegistration() {
const payload: CreateUserRegistration = {
email: accountDetails.email.value,
username: accountDetails.username.value,
fullName: accountDetails.fullName.value,
password: credentials.password1.value,
passwordConfirm: credentials.password2.value,
locale: locale.value,
advanced: accountDetails.advancedOptions.value,
};
if (state.ctx.type === RegistrationType.CreateGroup) {
payload.group = groupName.value;
payload.private = groupPrivate.value;
payload.seedData = groupSeed.value;
}
else {
payload.groupToken = token.value;
}
const { response } = await api.register.register(payload);
if (response?.status === 201) {
accountDetails.reset();
credentials.reset();
alert.success(i18n.t("user-registration.registration-success"));
router.push("/login");
}
else {
alert.error(i18n.t("events.something-went-wrong"));
}
}
return {
accountDetails,
accountDetailsNext,
confirmationData,
credentials,
emailErrorMessages,
groupDetails,
groupErrorMessages,
initial,
inputAttrs,
isDark,
langDialog,
provideToken,
pwFields,
RegistrationType,
state,
States,
token,
usernameErrorMessages,
validators,
submitRegistration,
// Validators
validGroupName,
validateUsername,
validateEmail,
// Dom Refs
domAccountForm,
domGroupForm,
domTokenForm,
};
},
// Registration Context
const state = useRegistration();
// Handle Token URL / Initialization
const token = useRouteQuery("token");
function initialUser() {
return false;
}
onMounted(() => {
if (token.value) {
state.setState(States.ProvideAccountDetails);
state.setType(RegistrationType.JoinGroup);
}
if (initialUser()) {
state.setState(States.ProvideGroupDetails);
state.setType(RegistrationType.InitialGroup);
}
});
// Initial
const initial = {
createGroup: () => {
state.setState(States.ProvideGroupDetails);
state.setType(RegistrationType.CreateGroup);
if (token.value != null) {
token.value = null;
}
},
joinGroup: () => {
state.setState(States.ProvideToken);
state.setType(RegistrationType.JoinGroup);
},
};
// Provide Token
const domTokenForm = ref<VForm | null>(null);
function validateToken() {
return true;
}
const provideToken = {
next: () => {
if (!safeValidate(domTokenForm as Ref<VForm>)) {
return;
}
if (validateToken()) {
state.setState(States.ProvideAccountDetails);
}
},
};
// Provide Group Details
const publicApi = usePublicApi();
const domGroupForm = ref<VForm | null>(null);
const groupName = ref("");
const groupSeed = ref(false);
const groupPrivate = ref(false);
const groupErrorMessages = ref<string[]>([]);
const { validate: validGroupName, valid: groupNameValid } = useAsyncValidator(
groupName,
(v: string) => publicApi.validators.group(v),
i18n.t("validation.group-name-is-taken"),
groupErrorMessages,
);
const groupDetails = {
groupName,
groupSeed,
groupPrivate,
next: () => {
if (!safeValidate(domGroupForm as Ref<VForm>) || !groupNameValid.value) {
return;
}
state.setState(States.ProvideAccountDetails);
},
};
const {
accountDetails,
credentials,
} = useUserRegistrationForm();
async function accountDetailsNext() {
if (!await accountDetails.validate()) {
return;
}
state.setState(States.Confirmation);
}
// Locale
const { locale } = useLocales();
const langDialog = ref(false);
// Confirmation
const confirmationData = computed(() => {
return [
{
display: state.ctx.type === RegistrationType.CreateGroup,
text: i18n.t("group.group"),
value: groupName.value,
},
{
display: state.ctx.type === RegistrationType.CreateGroup,
text: i18n.t("data-pages.seed-data"),
value: groupSeed.value ? i18n.t("general.yes") : i18n.t("general.no"),
},
{
display: state.ctx.type === RegistrationType.CreateGroup,
text: i18n.t("group.settings.keep-my-recipes-private"),
value: groupPrivate.value ? i18n.t("general.yes") : i18n.t("general.no"),
},
{
display: true,
text: i18n.t("user.email"),
value: accountDetails.email.value,
},
{
display: true,
text: i18n.t("user.full-name"),
value: accountDetails.fullName.value,
},
{
display: true,
text: i18n.t("user.username"),
value: accountDetails.username.value,
},
{
display: true,
text: i18n.t("user.enable-advanced-content"),
value: accountDetails.advancedOptions.value ? i18n.t("general.yes") : i18n.t("general.no"),
},
];
});
const api = useUserApi();
const router = useRouter();
async function submitRegistration() {
const payload: CreateUserRegistration = {
email: accountDetails.email.value,
username: accountDetails.username.value,
fullName: accountDetails.fullName.value,
password: credentials.password1.value,
passwordConfirm: credentials.password2.value,
locale: locale.value,
advanced: accountDetails.advancedOptions.value,
};
if (state.ctx.type === RegistrationType.CreateGroup) {
payload.group = groupName.value;
payload.private = groupPrivate.value;
payload.seedData = groupSeed.value;
}
else {
payload.groupToken = token.value;
}
const { response } = await api.register.register(payload);
if (response?.status === 201) {
accountDetails.reset();
credentials.reset();
alert.success(i18n.t("user-registration.registration-success"));
router.push("/login");
}
else {
alert.error(i18n.t("events.something-went-wrong"));
}
}
</script>
<style lang="css" scoped>

View File

@@ -16,7 +16,7 @@
<v-card-text>
<v-form @submit.prevent="requestLink()">
<v-text-field
v-model="email"
v-model="state.email"
:prepend-inner-icon="$globals.icons.email"
variant="solo-filled"
flat
@@ -26,7 +26,7 @@
type="text"
/>
<v-text-field
v-model="password"
v-model="state.password"
variant="solo-filled"
flat
:prepend-inner-icon="$globals.icons.lock"
@@ -36,7 +36,7 @@
:rules="[validators.required]"
/>
<v-text-field
v-model="passwordConfirm"
v-model="state.passwordConfirm"
variant="solo-filled"
flat
validate-on="blur"
@@ -52,7 +52,7 @@
<v-card-actions class="justify-center">
<div class="max-button">
<v-btn
:loading="loading"
:loading="state.loading"
color="primary"
:disabled="token === ''"
type="submit"
@@ -81,74 +81,62 @@
</v-container>
</template>
<script lang="ts">
<script setup lang="ts">
import { useUserApi } from "~/composables/api";
import { alert } from "~/composables/use-toast";
import { validators } from "@/composables/use-validators";
import { useRouteQuery } from "~/composables/use-router";
export default defineNuxtComponent({
setup() {
definePageMeta({
layout: "basic",
});
const state = reactive({
email: "",
password: "",
passwordConfirm: "",
loading: false,
error: false,
});
const i18n = useI18n();
const passwordMatch = () => state.password === state.passwordConfirm || i18n.t("user.password-must-match");
// Set page title
useSeoMeta({
title: i18n.t("user.login"),
});
// ===================
// Token Getter
const token = useRouteQuery("token", "");
// ===================
// API
const api = useUserApi();
async function requestLink() {
state.loading = true;
// TODO: Fix Response to send meaningful error
const { response } = await api.users.resetPassword({
token: token.value,
email: state.email,
password: state.password,
passwordConfirm: state.passwordConfirm,
});
state.loading = false;
if (response?.status === 200) {
state.loading = false;
state.error = false;
alert.success(i18n.t("user.password-updated"));
}
else {
state.loading = false;
state.error = true;
alert.error(i18n.t("events.something-went-wrong"));
}
}
return {
passwordMatch,
token,
requestLink,
validators,
...toRefs(state),
};
},
definePageMeta({
layout: "basic",
});
const state = reactive({
email: "",
password: "",
passwordConfirm: "",
loading: false,
error: false,
});
const i18n = useI18n();
const passwordMatch = () => state.password === state.passwordConfirm || i18n.t("user.password-must-match");
// Set page title
useSeoMeta({
title: i18n.t("user.login"),
});
// ===================
// Token Getter
const token = useRouteQuery("token", "");
// ===================
// API
const api = useUserApi();
async function requestLink() {
state.loading = true;
// TODO: Fix Response to send meaningful error
const { response } = await api.users.resetPassword({
token: token.value,
email: state.email,
password: state.password,
passwordConfirm: state.passwordConfirm,
});
state.loading = false;
if (response?.status === 200) {
state.loading = false;
state.error = false;
alert.success(i18n.t("user.password-updated"));
}
else {
state.loading = false;
state.error = true;
alert.error(i18n.t("events.something-went-wrong"));
}
}
</script>
<style lang="css">

View File

@@ -331,7 +331,7 @@
</v-container>
</template>
<script lang="ts">
<script setup lang="ts">
import { VueDraggable } from "vue-draggable-plus";
import RecipeList from "~/components/Domain/Recipe/RecipeList.vue";
import MultiPurposeLabelSection from "~/components/Domain/ShoppingList/MultiPurposeLabelSection.vue";
@@ -339,48 +339,56 @@ import ShoppingListItem from "~/components/Domain/ShoppingList/ShoppingListItem.
import ShoppingListItemEditor from "~/components/Domain/ShoppingList/ShoppingListItemEditor.vue";
import { useShoppingListPage } from "~/composables/shopping-list-page/use-shopping-list-page";
import { useFoodStore, useLabelStore, useUnitStore } from "~/composables/store";
import { getTextColor } from "~/composables/use-text-color";
import { useShoppingListPreferences } from "~/composables/use-users/preferences";
export default defineNuxtComponent({
components: {
VueDraggable,
MultiPurposeLabelSection,
ShoppingListItem,
RecipeList,
ShoppingListItemEditor,
},
setup() {
const { mdAndUp } = useDisplay();
const i18n = useI18n();
const auth = useMealieAuth();
const preferences = useShoppingListPreferences();
const { mdAndUp } = useDisplay();
const i18n = useI18n();
useSeoMeta({
title: i18n.t("shopping-list.shopping-list"),
});
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug as string || auth.user.value?.groupSlug || "");
const id = route.params.id as string;
const shoppingListPage = useShoppingListPage(id);
const { store: allLabels } = useLabelStore();
const { store: allUnits } = useUnitStore();
const { store: allFoods } = useFoodStore();
return {
groupSlug,
preferences,
allLabels,
allUnits,
allFoods,
getTextColor,
mdAndUp,
...shoppingListPage,
};
},
useSeoMeta({
title: i18n.t("shopping-list.shopping-list"),
});
const route = useRoute();
const id = route.params.id as string;
const shoppingListPage = useShoppingListPage(id);
const { store: allLabels } = useLabelStore();
const { store: allUnits } = useUnitStore();
const { store: allFoods } = useFoodStore();
const {
shoppingList,
state,
checkAll,
uncheckAll,
deleteChecked,
reorderLabelsDialog,
localLabels,
saveLabelOrder,
cancelLabelOrder,
updateLabelOrder,
edit,
threeDot,
openCheckAll,
copyListItems,
toggleReorderLabelsDialog,
isOffline,
createEditorOpen,
createListItemData,
createListItem,
itemsByLabel,
getLabelColor,
loadingCounter,
updateIndexUncheckedByLabel,
recipeMap,
saveListItem,
deleteListItem,
listItems,
openUncheckAll,
openDeleteChecked,
recipeList,
removeRecipeReferenceToList,
addRecipeReferenceToList,
} = shoppingListPage;
</script>
<style>

View File

@@ -4,14 +4,14 @@
class="narrow-container"
>
<BaseDialog
v-model="createDialog"
v-model="state.createDialog"
:title="$t('shopping-list.create-shopping-list')"
can-submit
@submit="createOne"
>
<v-card-text>
<v-text-field
v-model="createName"
v-model="state.createName"
autofocus
:label="$t('shopping-list.new-list')"
/>
@@ -20,7 +20,7 @@
<!-- Settings -->
<BaseDialog
v-model="ownerDialog"
v-model="state.ownerDialog"
:icon="$globals.icons.admin"
:title="$t('user.edit-user')"
can-confirm
@@ -41,7 +41,7 @@
</BaseDialog>
<BaseDialog
v-model="deleteDialog"
v-model="state.deleteDialog"
:title="$t('general.confirm')"
color="error"
can-confirm
@@ -73,7 +73,7 @@
<BaseButton
create
class="my-0"
@click="createDialog = true"
@click="state.createDialog = true"
/>
</v-container>
@@ -123,170 +123,150 @@
</v-container>
</template>
<script lang="ts">
<script setup lang="ts">
import type { ShoppingListOut } from "~/lib/api/types/household";
import { useUserApi } from "~/composables/api";
import { useAsyncKey } from "~/composables/use-utils";
import { useShoppingListPreferences } from "~/composables/use-users/preferences";
import type { UserOut } from "~/lib/api/types/user";
export default defineNuxtComponent({
setup() {
const auth = useMealieAuth();
const i18n = useI18n();
const ready = ref(false);
const userApi = useUserApi();
const route = useRoute();
const auth = useMealieAuth();
const i18n = useI18n();
const ready = ref(false);
const userApi = useUserApi();
const route = useRoute();
useSeoMeta({
title: i18n.t("shopping-list.shopping-list"),
});
const groupSlug = computed(() => route.params.groupSlug || auth.user.value?.groupSlug || "");
const overrideDisableRedirect = ref(false);
const disableRedirect = computed(() => route.query.disableRedirect === "true" || overrideDisableRedirect.value);
const preferences = useShoppingListPreferences();
const state = reactive({
createName: "",
createDialog: false,
deleteDialog: false,
deleteTarget: "",
ownerDialog: false,
ownerTarget: ref<ShoppingListOut | null>(null),
});
const { data: shoppingLists } = useAsyncData(useAsyncKey(), async () => {
return await fetchShoppingLists();
});
const shoppingListChoices = computed(() => {
if (!shoppingLists.value) {
return [];
}
return shoppingLists.value.filter(list => preferences.value.viewAllLists || list.userId === auth.user.value?.id);
});
// This has to appear before the shoppingListChoices watcher, otherwise that runs first and the redirect is not disabled
watch(
() => preferences.value.viewAllLists,
() => {
overrideDisableRedirect.value = true;
},
);
watch(
() => shoppingListChoices,
() => {
if (!disableRedirect.value && shoppingListChoices.value.length === 1) {
navigateTo(`/shopping-lists/${shoppingListChoices.value[0].id}`);
}
else {
ready.value = true;
}
},
{
deep: true,
},
);
async function fetchShoppingLists() {
const { data } = await userApi.shopping.lists.getAll(1, -1, { orderBy: "name", orderDirection: "asc" });
if (!data) {
return [];
}
return data.items;
}
async function refresh() {
shoppingLists.value = await fetchShoppingLists();
}
async function createOne() {
const { data } = await userApi.shopping.lists.createOne({ name: state.createName });
if (data) {
refresh();
state.createName = "";
}
}
async function toggleOwnerDialog(list: ShoppingListOut) {
if (!state.ownerDialog) {
state.ownerTarget = list;
await fetchAllUsers();
}
state.ownerDialog = !state.ownerDialog;
}
// ===============================================================
// Shopping List Edit User/Owner
const allUsers = ref<UserOut[]>([]);
const updateUserId = ref<string | undefined>();
async function fetchAllUsers() {
const { data } = await userApi.households.fetchMembers();
if (!data) {
return;
}
// update current user
allUsers.value = data.items.sort((a, b) => ((a.fullName || "") < (b.fullName || "") ? -1 : 1));
updateUserId.value = state.ownerTarget?.userId;
}
async function updateOwner() {
if (!state.ownerTarget || !updateUserId.value) {
return;
}
// user has not changed, so we should not update
if (state.ownerTarget.userId === updateUserId.value) {
return;
}
// get full list, so the move does not delete shopping list items
const { data: fullList } = await userApi.shopping.lists.getOne(state.ownerTarget.id);
if (!fullList) {
return;
}
const { data } = await userApi.shopping.lists.updateOne(
state.ownerTarget.id,
{ ...fullList, userId: updateUserId.value },
);
if (data) {
refresh();
}
}
function openDelete(id: string) {
state.deleteDialog = true;
state.deleteTarget = id;
}
async function deleteOne() {
const { data } = await userApi.shopping.lists.deleteOne(state.deleteTarget);
if (data) {
refresh();
}
}
return {
...toRefs(state),
ready,
groupSlug,
preferences,
shoppingListChoices,
createOne,
toggleOwnerDialog,
allUsers,
updateUserId,
updateOwner,
deleteOne,
openDelete,
};
},
useSeoMeta({
title: i18n.t("shopping-list.shopping-list"),
});
const overrideDisableRedirect = ref(false);
const disableRedirect = computed(() => route.query.disableRedirect === "true" || overrideDisableRedirect.value);
const preferences = useShoppingListPreferences();
const state = reactive({
createName: "",
createDialog: false,
deleteDialog: false,
deleteTarget: "",
ownerDialog: false,
ownerTarget: ref<ShoppingListOut | null>(null),
});
const { data: shoppingLists } = useAsyncData(useAsyncKey(), async () => {
return await fetchShoppingLists();
});
const shoppingListChoices = computed(() => {
if (!shoppingLists.value) {
return [];
}
return shoppingLists.value.filter(list => preferences.value.viewAllLists || list.userId === auth.user.value?.id);
});
// This has to appear before the shoppingListChoices watcher, otherwise that runs first and the redirect is not disabled
watch(
() => preferences.value.viewAllLists,
() => {
overrideDisableRedirect.value = true;
},
);
watch(
() => shoppingListChoices,
() => {
if (!disableRedirect.value && shoppingListChoices.value.length === 1) {
navigateTo(`/shopping-lists/${shoppingListChoices.value[0].id}`);
}
else {
ready.value = true;
}
},
{
deep: true,
},
);
async function fetchShoppingLists() {
const { data } = await userApi.shopping.lists.getAll(1, -1, { orderBy: "name", orderDirection: "asc" });
if (!data) {
return [];
}
return data.items;
}
async function refresh() {
shoppingLists.value = await fetchShoppingLists();
}
async function createOne() {
const { data } = await userApi.shopping.lists.createOne({ name: state.createName });
if (data) {
refresh();
state.createName = "";
}
}
async function toggleOwnerDialog(list: ShoppingListOut) {
if (!state.ownerDialog) {
state.ownerTarget = list;
await fetchAllUsers();
}
state.ownerDialog = !state.ownerDialog;
}
// ===============================================================
// Shopping List Edit User/Owner
const allUsers = ref<UserOut[]>([]);
const updateUserId = ref<string | undefined>();
async function fetchAllUsers() {
const { data } = await userApi.households.fetchMembers();
if (!data) {
return;
}
// update current user
allUsers.value = data.items.sort((a, b) => ((a.fullName || "") < (b.fullName || "") ? -1 : 1));
updateUserId.value = state.ownerTarget?.userId;
}
async function updateOwner() {
if (!state.ownerTarget || !updateUserId.value) {
return;
}
// user has not changed, so we should not update
if (state.ownerTarget.userId === updateUserId.value) {
return;
}
// get full list, so the move does not delete shopping list items
const { data: fullList } = await userApi.shopping.lists.getOne(state.ownerTarget.id);
if (!fullList) {
return;
}
const { data } = await userApi.shopping.lists.updateOne(
state.ownerTarget.id,
{ ...fullList, userId: updateUserId.value },
);
if (data) {
refresh();
}
}
function openDelete(id: string) {
state.deleteDialog = true;
state.deleteTarget = id;
}
async function deleteOne() {
const { data } = await userApi.shopping.lists.deleteOne(state.deleteTarget);
if (data) {
refresh();
}
}
</script>

View File

@@ -14,37 +14,22 @@
</v-container>
</template>
<script lang="ts">
<script setup lang="ts">
import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue";
import { useLazyRecipes } from "~/composables/recipes";
import { useLoggedInState } from "~/composables/use-logged-in-state";
export default defineNuxtComponent({
components: { RecipeCardSection },
setup() {
const route = useRoute();
const i18n = useI18n();
const { isOwnGroup } = useLoggedInState();
const route = useRoute();
const i18n = useI18n();
const { isOwnGroup } = useLoggedInState();
useSeoMeta({
title: i18n.t("general.favorites"),
});
const userId = route.params.id;
const query = { queryFilter: `favoritedBy.id = "${userId}"` };
const { recipes, appendRecipes, assignSorted, removeRecipe, replaceRecipes } = useLazyRecipes();
return {
query,
recipes,
isOwnGroup,
appendRecipes,
assignSorted,
removeRecipe,
replaceRecipes,
};
},
useSeoMeta({
title: i18n.t("general.favorites"),
});
const userId = route.params.id;
const query = { queryFilter: `favoritedBy.id = "${userId}"` };
const { recipes, appendRecipes, assignSorted, removeRecipe, replaceRecipes } = useLazyRecipes();
</script>
<style scoped></style>

View File

@@ -106,66 +106,63 @@
</v-container>
</template>
<script lang="ts">
<script setup lang="ts">
import { useUserApi } from "~/composables/api";
import type { VForm } from "~/types/auto-forms";
export default defineNuxtComponent({
definePageMeta({
middleware: ["advanced-only"],
setup() {
const i18n = useI18n();
const auth = useMealieAuth();
useSeoMeta({
title: i18n.t("settings.token.api-tokens"),
});
const user = computed(() => {
return auth.user.value;
});
const api = useUserApi();
const domNewTokenForm = ref<VForm | null>(null);
const createdToken = ref("");
const name = ref("");
const loading = ref(false);
function resetCreate() {
createdToken.value = "";
loading.value = false;
name.value = "";
auth.refresh();
}
async function createToken(name: string) {
if (loading.value) {
resetCreate();
return;
}
loading.value = true;
if (domNewTokenForm?.value?.validate()) {
console.log("Created");
return;
}
const { data } = await api.users.createAPIToken({ name });
if (data) {
createdToken.value = data.token;
}
}
async function deleteToken(id: number) {
const { data } = await api.users.deleteAPIToken(id);
auth.refresh();
return data;
}
return { createToken, deleteToken, createdToken, loading, name, user, resetCreate };
},
});
const i18n = useI18n();
const auth = useMealieAuth();
useSeoMeta({
title: i18n.t("settings.token.api-tokens"),
});
const user = computed(() => {
return auth.user.value;
});
const api = useUserApi();
const domNewTokenForm = ref<VForm | null>(null);
const createdToken = ref("");
const name = ref("");
const loading = ref(false);
function resetCreate() {
createdToken.value = "";
loading.value = false;
name.value = "";
auth.refresh();
}
async function createToken(name: string) {
if (loading.value) {
resetCreate();
return;
}
loading.value = true;
if (domNewTokenForm?.value?.validate()) {
console.log("Created");
return;
}
const { data } = await api.users.createAPIToken({ name });
if (data) {
createdToken.value = data.token;
}
}
async function deleteToken(id: number) {
const { data } = await api.users.deleteAPIToken(id);
auth.refresh();
return data;
}
</script>

View File

@@ -24,9 +24,9 @@
<section class="mt-5">
<ToggleState tag="article">
<template #activator="{ toggle, state }">
<template #activator="{ toggle, modelValue: toggleState }">
<v-btn
v-if="!state && $appInfo.allowPasswordLogin"
v-if="!toggleState && $appInfo.allowPasswordLogin"
color="info"
class="mt-2 mb-n3"
@click="toggle"
@@ -48,13 +48,13 @@
{{ $t("settings.profile") }}
</v-btn>
</template>
<template #default="{ state }">
<template #default="{ modelValue: toggleState }">
<v-slide-x-transition
leave-absolute
hide-on-leave
>
<div
v-if="!state"
v-if="!toggleState"
key="personal-info"
>
<BaseCardSectionTitle
@@ -214,97 +214,84 @@
</v-container>
</template>
<script lang="ts">
<script setup lang="ts">
import { useUserApi } from "~/composables/api";
import UserAvatar from "~/components/Domain/User/UserAvatar.vue";
import UserPasswordStrength from "~/components/Domain/User/UserPasswordStrength.vue";
import { validators } from "~/composables/use-validators";
import type { VForm } from "~/types/auto-forms";
import { useUserActivityPreferences } from "~/composables/use-users/preferences";
import useDefaultActivity from "~/composables/use-default-activity";
import { ActivityKey } from "~/lib/api/types/activity";
import type { UserBase } from "~/lib/api/types/user";
export default defineNuxtComponent({
components: {
UserAvatar,
UserPasswordStrength,
},
setup() {
const i18n = useI18n();
const auth = useMealieAuth();
const { getDefaultActivityLabels, getActivityLabel, getActivityKey } = useDefaultActivity();
const user = computed(() => auth.user.value);
const i18n = useI18n();
const auth = useMealieAuth();
const { getDefaultActivityLabels, getActivityLabel, getActivityKey } = useDefaultActivity();
const user = computed(() => auth.user.value);
useSeoMeta({
title: i18n.t("settings.profile"),
});
const activityPreferences = useUserActivityPreferences();
const activityOptions = getDefaultActivityLabels(i18n);
const selectedDefaultActivity = ref(getActivityLabel(i18n, activityPreferences.value.defaultActivity));
watch(selectedDefaultActivity, () => {
activityPreferences.value.defaultActivity = getActivityKey(i18n, selectedDefaultActivity.value) ?? ActivityKey.RECIPES;
});
watch(user, () => {
userCopy.value = { ...user.value };
});
const userCopy = ref({ ...user.value });
const api = useUserApi();
const domUpdatePassword = ref<VForm | null>(null);
const password = reactive({
current: "",
newOne: "",
newTwo: "",
});
const passwordsMatch = computed(() => password.newOne === password.newTwo && password.newOne.length > 0);
async function updateUser() {
if (!userCopy.value?.id) return;
const { response } = await api.users.updateOne(userCopy.value.id, userCopy.value);
if (response?.status === 200) {
auth.refresh();
}
}
async function updatePassword() {
if (!userCopy.value?.id) {
return;
}
const { response } = await api.users.changePassword({
currentPassword: password.current,
newPassword: password.newOne,
});
if (response?.status === 200) {
console.log("Password Changed");
}
}
const state = reactive({
hideImage: false,
passwordLoading: false,
showPassword: false,
loading: false,
});
return {
...toRefs(state),
updateUser,
updatePassword,
userCopy,
selectedDefaultActivity,
activityOptions,
password,
domUpdatePassword,
passwordsMatch,
validators,
auth,
};
},
useSeoMeta({
title: i18n.t("settings.profile"),
});
const activityPreferences = useUserActivityPreferences();
const activityOptions = getDefaultActivityLabels(i18n);
const selectedDefaultActivity = ref(getActivityLabel(i18n, activityPreferences.value.defaultActivity));
watch(selectedDefaultActivity, () => {
activityPreferences.value.defaultActivity = getActivityKey(i18n, selectedDefaultActivity.value) ?? ActivityKey.RECIPES;
});
const userCopy = ref({ ...user.value });
watch(user, () => {
userCopy.value = { ...user.value };
});
const api = useUserApi();
const showPassword = ref(false);
const password = reactive({
current: "",
newOne: "",
newTwo: "",
});
const passwordsMatch = computed(() => password.newOne === password.newTwo && password.newOne.length > 0);
async function updateUser() {
const userData = userCopy.value;
if (!userData?.id || !userData.email) return;
const updatePayload: UserBase = {
id: userData.id,
username: userData.username,
fullName: userData.fullName,
email: userData.email,
authMethod: userData.authMethod,
admin: userData.admin,
group: userData.group,
household: userData.household,
advanced: userData.advanced,
canInvite: userData.canInvite,
canManage: userData.canManage,
canManageHousehold: userData.canManageHousehold,
canOrganize: userData.canOrganize,
};
const { response } = await api.users.updateOne(userData.id, updatePayload);
if (response?.status === 200) {
auth.refresh();
}
}
async function updatePassword() {
if (!userCopy.value?.id) {
return;
}
const { response } = await api.users.changePassword({
currentPassword: password.current,
newPassword: password.newOne,
});
if (response?.status === 200) {
console.log("Password Changed");
}
}
</script>

View File

@@ -274,7 +274,7 @@
</v-container>
</template>
<script lang="ts">
<script setup lang="ts">
import UserProfileLinkCard from "@/components/Domain/User/UserProfileLinkCard.vue";
import { useUserApi } from "~/composables/api";
import UserAvatar from "@/components/Domain/User/UserAvatar.vue";
@@ -283,98 +283,81 @@ import StatsCards from "~/components/global/StatsCards.vue";
import type { UserOut } from "~/lib/api/types/user";
import UserInviteDialog from "~/components/Domain/User/UserInviteDialog.vue";
export default defineNuxtComponent({
definePageMeta({
name: "UserProfile",
components: {
UserInviteDialog,
UserProfileLinkCard,
UserAvatar,
StatsCards,
},
scrollToTop: true,
async setup() {
const i18n = useI18n();
const auth = useMealieAuth();
const { $appInfo } = useNuxtApp();
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug || auth.user.value?.groupSlug || "");
useSeoMeta({
title: i18n.t("settings.profile"),
});
const user = computed<UserOut | null>(() => {
const authUser = auth.user.value;
if (!authUser) return null;
// Override canInvite if password login is disabled
const canInvite = !$appInfo.allowPasswordLogin ? false : authUser.canInvite;
return {
...authUser,
canInvite,
};
});
const inviteDialog = ref(false);
const api = useUserApi();
const { data: stats } = useAsyncData(useAsyncKey(), async () => {
const { data } = await api.households.statistics();
if (data) {
return data;
}
});
const statsText: { [key: string]: string } = {
totalRecipes: i18n.t("general.recipes"),
totalUsers: i18n.t("user.users"),
totalCategories: i18n.t("sidebar.categories"),
totalTags: i18n.t("sidebar.tags"),
totalTools: i18n.t("tool.tools"),
};
function getStatsTitle(key: string) {
return statsText[key] ?? "unknown";
}
const { $globals } = useNuxtApp();
const iconText: { [key: string]: string } = {
totalUsers: $globals.icons.user,
totalCategories: $globals.icons.categories,
totalTags: $globals.icons.tags,
totalTools: $globals.icons.potSteam,
};
function getStatsIcon(key: string) {
return iconText[key] ?? $globals.icons.primary;
}
const statsTo = computed<{ [key: string]: string }>(() => {
return {
totalRecipes: `/g/${groupSlug.value}/`,
totalUsers: "/household/members",
totalCategories: `/g/${groupSlug.value}/recipes/categories`,
totalTags: `/g/${groupSlug.value}/recipes/tags`,
totalTools: `/g/${groupSlug.value}/recipes/tools`,
};
});
function getStatsTo(key: string) {
return statsTo.value[key] ?? "unknown";
}
return {
groupSlug,
getStatsTitle,
getStatsIcon,
getStatsTo,
inviteDialog,
stats,
user,
};
},
});
const i18n = useI18n();
const auth = useMealieAuth();
const { $appInfo } = useNuxtApp();
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug || auth.user.value?.groupSlug || "");
useSeoMeta({
title: i18n.t("settings.profile"),
});
const user = computed<UserOut | null>(() => {
const authUser = auth.user.value;
if (!authUser) return null;
// Override canInvite if password login is disabled
const canInvite = !$appInfo.allowPasswordLogin ? false : authUser.canInvite;
return {
...authUser,
canInvite,
};
});
const inviteDialog = ref(false);
const api = useUserApi();
const { data: stats } = useAsyncData(useAsyncKey(), async () => {
const { data } = await api.households.statistics();
if (data) {
return data;
}
});
const statsText: { [key: string]: string } = {
totalRecipes: i18n.t("general.recipes"),
totalUsers: i18n.t("user.users"),
totalCategories: i18n.t("sidebar.categories"),
totalTags: i18n.t("sidebar.tags"),
totalTools: i18n.t("tool.tools"),
};
function getStatsTitle(key: string) {
return statsText[key] ?? "unknown";
}
const { $globals } = useNuxtApp();
const iconText: { [key: string]: string } = {
totalUsers: $globals.icons.user,
totalCategories: $globals.icons.categories,
totalTags: $globals.icons.tags,
totalTools: $globals.icons.potSteam,
};
function getStatsIcon(key: string) {
return iconText[key] ?? $globals.icons.primary;
}
const statsTo = computed<{ [key: string]: string }>(() => {
return {
totalRecipes: `/g/${groupSlug.value}/`,
totalUsers: "/household/members",
totalCategories: `/g/${groupSlug.value}/recipes/categories`,
totalTags: `/g/${groupSlug.value}/recipes/tags`,
totalTools: `/g/${groupSlug.value}/recipes/tools`,
};
});
function getStatsTo(key: string) {
return statsTo.value[key] ?? "unknown";
}
</script>