Refactor/user database models (#775)

* fix build error

* drop frontend.old

* improve auto_init decorator

* purge depreciated site settings

* formatting

* update init function

* fix(backend): 🐛 Fix password reset bug

Co-authored-by: Hayden <hay-kot@pm.me>
This commit is contained in:
Hayden 2021-11-04 14:01:37 -08:00 committed by GitHub
parent 40462a95f1
commit ec3b53cdc3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
172 changed files with 430 additions and 12255 deletions

View file

@ -1,2 +0,0 @@
VUE_APP_API_BASE_URL=http://localhost:9000
PREVIEW_BUNDLE=true

View file

@ -1,24 +0,0 @@
# frontend
## Project setup
```
npm install
```
### Compiles and hot-reloads for development
```
npm run serve
```
### Compiles and minifies for production
```
npm run build
```
### Lints and fixes files
```
npm run lint
```
### Customize configuration
See [Configuration Reference](https://cli.vuejs.org/config/).

View file

@ -1,3 +0,0 @@
module.exports = {
presets: ["@vue/cli-plugin-babel/preset"],
};

View file

@ -1,173 +0,0 @@
<template>
<v-row>
<SearchDialog ref="mealselect" @selected="setSlug" />
<BaseDialog
ref="customMealDialog"
title="Custom Meal"
:title-icon="$globals.icons.primary"
:submit-text="$t('general.save')"
:top="true"
@submit="pushCustomMeal"
>
<v-card-text>
<v-text-field v-model="customMeal.name" autofocus :label="$t('general.name')"> </v-text-field>
<v-textarea v-model="customMeal.description" :label="$t('recipe.description')"> </v-textarea>
</v-card-text>
</BaseDialog>
<v-col v-for="(planDay, index) in value" :key="index" cols="12" sm="12" md="6" lg="4" xl="3">
<v-hover v-slot="{ hover }" :open-delay="50">
<v-card :class="{ 'on-hover': hover }" :elevation="hover ? 12 : 2">
<CardImage large :slug="planDay.meals[0].slug" icon-size="200" @click="openSearch(index, modes.primary)">
<div>
<v-fade-transition>
<v-btn v-if="hover" small color="info" class="ma-1" @click.stop="addCustomItem(index, modes.primary)">
<v-icon left>
{{ $globals.icons.edit }}
</v-icon>
{{ $t("reicpe.no-recipe") }}
</v-btn>
</v-fade-transition>
</div>
</CardImage>
<v-card-title class="my-n3 mb-n6">
{{ $d(new Date(planDay.date.replaceAll("-", "/")), "short") }}
</v-card-title>
<v-card-subtitle class="mb-0 pb-0"> {{ planDay.meals[0].name }}</v-card-subtitle>
<v-hover v-slot="{ hover }">
<v-card-actions>
<v-spacer></v-spacer>
<v-fade-transition>
<v-btn v-if="hover" small color="info" text @click.stop="addCustomItem(index, modes.sides)">
<v-icon left>
{{ $globals.icons.edit }}
</v-icon>
{{ $t("reicpe.no-recipe") }}
</v-btn>
</v-fade-transition>
<v-btn color="info" outlined small @click="openSearch(index, modes.sides)">
<v-icon small class="mr-1">
{{ $globals.icons.create }}
</v-icon>
{{ $t("meal-plan.side") }}
</v-btn>
</v-card-actions>
</v-hover>
<v-divider class="mx-2"></v-divider>
<v-list dense>
<v-list-item v-for="(recipe, i) in planDay.meals.slice(1)" :key="i">
<v-list-item-avatar color="accent">
<v-img :alt="recipe.slug" :src="getImage(recipe.slug)"></v-img>
</v-list-item-avatar>
<v-list-item-content>
<v-list-item-title v-text="recipe.name"></v-list-item-title>
</v-list-item-content>
<v-list-item-icon>
<v-btn icon @click="removeSide(index, i + 1)">
<v-icon color="error">
{{ $globals.icons.delete }}
</v-icon>
</v-btn>
</v-list-item-icon>
</v-list-item>
</v-list>
</v-card>
</v-hover>
</v-col>
</v-row>
</template>
<script>
import BaseDialog from "@/components/UI/Dialogs/BaseDialog";
import { api } from "@/api";
import SearchDialog from "../UI/Dialogs/SearchDialog";
import CardImage from "../Recipe/CardImage.vue";
export default {
components: {
SearchDialog,
CardImage,
BaseDialog,
},
props: {
value: Array,
},
data() {
return {
activeIndex: 0,
mode: "PRIMARY",
modes: {
primary: "PRIMARY",
sides: "SIDES",
},
customMeal: {
slug: null,
name: "",
description: "",
},
};
},
methods: {
getImage(slug) {
if (slug) {
return api.recipes.recipeSmallImage(slug);
}
},
setSide(name, slug = null, description = "") {
const meal = { name, slug, description };
this.value[this.activeIndex].meals.push(meal);
},
setPrimary(name, slug, description = "") {
this.value[this.activeIndex].meals[0].slug = slug;
this.value[this.activeIndex].meals[0].name = name;
this.value[this.activeIndex].meals[0].description = description;
},
setSlug(recipe) {
switch (this.mode) {
case this.modes.primary:
this.setPrimary(recipe.name, recipe.slug);
break;
default:
this.setSide(recipe.name, recipe.slug);
break;
}
},
openSearch(index, mode) {
this.mode = mode;
this.activeIndex = index;
this.$refs.mealselect.open();
},
removeSide(dayIndex, sideIndex) {
this.value[dayIndex].meals.splice(sideIndex, 1);
},
addCustomItem(index, mode) {
this.mode = mode;
this.activeIndex = index;
this.$refs.customMealDialog.open();
},
pushCustomMeal() {
switch (this.mode) {
case this.modes.primary:
this.setPrimary(this.customMeal.name, this.customMeal.slug, this.customMeal.description);
break;
default:
this.setSide(this.customMeal.name, this.customMeal.slug, this.customMeal.description);
break;
}
this.customMeal = { name: "", slug: null, description: "" };
},
},
};
</script>
<style>
.relative-card {
position: relative;
}
.custom-button {
z-index: -1;
}
</style>

View file

@ -1,46 +0,0 @@
<template>
<v-card>
<v-card-title class="headline">
{{ $t("meal-plan.edit-meal-plan") }}
</v-card-title>
<v-divider></v-divider>
<v-card-text>
<MealPlanCard v-model="mealPlan.planDays" />
<v-row align="center" justify="end">
<v-card-actions>
<TheButton update @click="update" />
<v-spacer></v-spacer>
</v-card-actions>
</v-row>
</v-card-text>
</v-card>
</template>
<script>
import { api } from "@/api";
import { utils } from "@/utils";
import MealPlanCard from "./MealPlanCard";
export default {
components: {
MealPlanCard,
},
props: {
mealPlan: Object,
},
methods: {
formatDate(timestamp) {
const dateObject = new Date(timestamp);
return utils.getDateAsPythonDate(dateObject);
},
async update() {
if (await api.mealPlans.update(this.mealPlan.uid, this.mealPlan)) {
this.$emit("updated");
}
},
},
};
</script>
<style></style>

View file

@ -1,227 +0,0 @@
<template>
<v-card>
<v-card-title class=" headline">
{{ $t("meal-plan.create-a-new-meal-plan") }}
<v-btn color="info" class="ml-auto" @click="setQuickWeek()">
<v-icon left> {{ $globals.icons.calendarMinus }} </v-icon>
{{ $t("meal-plan.quick-week") }}
</v-btn>
</v-card-title>
<v-divider></v-divider>
<v-card-text>
<v-row dense>
<v-col cols="12" lg="6" md="6" sm="12">
<v-menu
ref="menu1"
v-model="menu1"
:close-on-content-click="true"
transition="scale-transition"
offset-y
max-width="290px"
min-width="290px"
>
<template #activator="{ on, attrs }">
<v-text-field
v-model="startComputedDateFormatted"
:label="$t('meal-plan.start-date')"
persistent-hint
:prepend-icon="$globals.icons.calendarMinus"
readonly
v-bind="attrs"
v-on="on"
></v-text-field>
</template>
<DatePicker v-model="startDate" no-title @input="menu2 = false" />
</v-menu>
</v-col>
<v-col cols="12" lg="6" md="6" sm="12">
<v-menu
ref="menu2"
v-model="menu2"
:close-on-content-click="true"
transition="scale-transition"
offset-y
max-width="290px"
min-width="290px"
>
<template #activator="{ on, attrs }">
<v-text-field
v-model="endComputedDateFormatted"
:label="$t('meal-plan.end-date')"
persistent-hint
:prepend-icon="$globals.icons.calendarMinus"
readonly
v-bind="attrs"
v-on="on"
></v-text-field>
</template>
<DatePicker v-model="endDate" no-title @input="menu2 = false" />
</v-menu>
</v-col>
</v-row>
</v-card-text>
<v-card-text v-if="startDate">
<MealPlanCard v-model="planDays" />
</v-card-text>
<v-row align="center" justify="end">
<v-card-actions class="mr-5">
<TheButton v-if="planDays.length > 0" edit text @click="random">
<template #icon>
{{ $globals.icons.diceMultiple }}
</template>
{{ $t("general.random") }}
</TheButton>
<TheButton create :disabled="planDays.length == 0" @click="save" />
</v-card-actions>
</v-row>
</v-card>
</template>
<script>
import DatePicker from "@/components/FormHelpers/DatePicker";
import { api } from "@/api";
import { utils } from "@/utils";
import MealPlanCard from "./MealPlanCard";
const CREATE_EVENT = "created";
export default {
components: {
MealPlanCard,
DatePicker,
},
data() {
return {
isLoading: false,
planDays: [],
items: [],
// Dates
startDate: null,
endDate: null,
menu1: false,
menu2: false,
usedRecipes: [1],
};
},
computed: {
groupSettings() {
return this.$store.getters.getCurrentGroup;
},
actualStartDate() {
if (!this.startDate) return null;
return Date.parse(this.startDate.replaceAll("-", "/"));
},
actualEndDate() {
if (!this.endDate) return null;
return Date.parse(this.endDate.replaceAll("-", "/"));
},
dateDif() {
if (!this.actualEndDate || !this.actualStartDate) return null;
const dateDif = (this.actualEndDate - this.actualStartDate) / (1000 * 3600 * 24) + 1;
if (dateDif < 1) {
return null;
}
return dateDif;
},
startComputedDateFormatted() {
return this.formatDate(this.actualStartDate);
},
endComputedDateFormatted() {
return this.formatDate(this.actualEndDate);
},
filteredRecipes() {
const recipes = this.items.filter(x => !this.usedRecipes.includes(x));
return recipes.length > 0 ? recipes : this.items;
},
allRecipes() {
return this.$store.getters.getAllRecipes;
},
},
watch: {
dateDif() {
this.planDays = [];
for (let i = 0; i < this.dateDif; i++) {
this.planDays.push({
date: this.getDate(i),
meals: [
{
name: "",
slug: "empty",
description: "empty",
},
],
});
}
},
},
async created() {
await this.$store.dispatch("requestCurrentGroup");
await this.$store.dispatch("requestAllRecipes");
await this.buildMealStore();
},
methods: {
async buildMealStore() {
const categories = Array.from(this.groupSettings.categories, x => x.name);
this.items = await api.recipes.getAllByCategory(categories);
if (this.items.length === 0) {
this.items = this.allRecipes;
}
},
getRandom(list) {
return list[Math.floor(Math.random() * list.length)];
},
random() {
this.usedRecipes = [1];
this.planDays.forEach((_, index) => {
const recipe = this.getRandom(this.filteredRecipes);
this.planDays[index].meals[0].slug = recipe.slug;
this.planDays[index].meals[0].name = recipe.name;
this.usedRecipes.push(recipe);
});
},
getDate(index) {
const dateObj = new Date(this.actualStartDate.valueOf() + 1000 * 3600 * 24 * index);
return utils.getDateAsPythonDate(dateObj);
},
async save() {
const mealBody = {
group: this.groupSettings.name,
startDate: this.startDate,
endDate: this.endDate,
planDays: this.planDays,
};
if (await api.mealPlans.create(mealBody)) {
this.$emit(CREATE_EVENT);
this.planDays = [];
this.startDate = null;
this.endDate = null;
}
},
formatDate(date) {
if (!date) return null;
return this.$d(date);
},
getNextDayOfTheWeek(dayName, excludeToday = true, refDate = new Date()) {
const dayOfWeek = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"].indexOf(dayName.slice(0, 3).toLowerCase());
if (dayOfWeek < 0) return;
refDate.setUTCHours(0, 0, 0, 0);
refDate.setDate(refDate.getDate() + +!!excludeToday + ((dayOfWeek + 7 - refDate.getDay() - +!!excludeToday) % 7));
return refDate;
},
setQuickWeek() {
const nextMonday = this.getNextDayOfTheWeek("Monday", false);
const nextEndDate = new Date(nextMonday);
nextEndDate.setDate(nextEndDate.getDate() + 4);
this.startDate = utils.getDateAsPythonDate(nextMonday);
this.endDate = utils.getDateAsPythonDate(nextEndDate);
},
},
};
</script>

View file

@ -1,61 +0,0 @@
<template>
<v-list-item two-line to="/admin/profile">
<v-list-item-avatar color="accent" class="white--text">
<v-img v-if="!noImage" :src="profileImage" />
<div v-else>
{{ initials }}
</div>
</v-list-item-avatar>
<v-list-item-content>
<v-list-item-title> {{ user.fullName }}</v-list-item-title>
<v-list-item-subtitle> {{ user.admin ? $t("user.admin") : $t("user.user") }}</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</template>
<script>
import { initials } from "@/mixins/initials";
import axios from "axios";
import { api } from "@/api";
export default {
mixins: [initials],
props: {
user: {
type: Object,
},
},
data() {
return {
noImage: false,
profileImage: "",
};
},
watch: {
async user() {
this.setImage();
},
},
methods: {
async setImage() {
const userImageURL = api.users.userProfileImage(this.user.id);
if (await this.imageExists(userImageURL)) {
this.noImage = false;
this.profileImage = userImageURL;
} else {
this.noImage = true;
}
},
async imageExists(url) {
const response = await axios.get(url).catch(() => {
this.noImage = true;
return { status: 404 };
});
return response.status !== 404;
},
},
};
</script>
<style lang="scss" scoped>
</style>

View file

@ -1,153 +0,0 @@
<template>
<v-form ref="form">
<v-card-text>
<v-row dense>
<ImageUploadBtn class="my-1" :slug="value.slug" @upload="uploadImage" @refresh="$emit('upload')" />
<SettingsMenu class="my-1 mx-1" :value="value.settings" @upload="uploadImage" />
</v-row>
<v-row dense>
<v-col>
<v-text-field v-model="value.totalTime" :label="$t('recipe.total-time')"></v-text-field>
</v-col>
<v-col><v-text-field v-model="value.prepTime" :label="$t('recipe.prep-time')"></v-text-field></v-col>
<v-col><v-text-field v-model="value.performTime" :label="$t('recipe.perform-time')"></v-text-field></v-col>
</v-row>
<v-text-field v-model="value.name" class="my-3" :label="$t('recipe.recipe-name')" :rules="[existsRule]">
</v-text-field>
<v-textarea v-model="value.description" auto-grow min-height="100" :label="$t('recipe.description')">
</v-textarea>
<div class="my-2"></div>
<v-row dense disabled>
<v-col sm="4">
<v-text-field v-model="value.recipeYield" :label="$t('recipe.servings')" class="rounded-sm"> </v-text-field>
</v-col>
<v-spacer></v-spacer>
<Rating v-model="value.rating" :emit-only="true" />
</v-row>
<v-row>
<v-col cols="12" sm="12" md="4" lg="4">
<Ingredients v-model="value.recipeIngredient" :edit="true" />
<v-card class="mt-6">
<v-card-title class="py-2">
{{ $t("recipe.categories") }}
</v-card-title>
<v-divider class="mx-2"></v-divider>
<v-card-text>
<CategoryTagSelector
v-model="value.recipeCategory"
:return-object="false"
:show-add="true"
:show-label="false"
/>
</v-card-text>
</v-card>
<v-card class="mt-2">
<v-card-title class="py-2">
{{ $t("tag.tags") }}
</v-card-title>
<v-divider class="mx-2"></v-divider>
<v-card-text>
<CategoryTagSelector
v-model="value.tags"
:return-object="false"
:show-add="true"
:tag-selector="true"
:show-label="false"
/>
</v-card-text>
</v-card>
<Nutrition v-model="value.nutrition" :edit="true" />
<Assets v-model="value.assets" :edit="true" :slug="value.slug" />
<ExtrasEditor :extras="value.extras" @save="saveExtras" />
</v-col>
<v-divider class="my-divider" :vertical="true"></v-divider>
<v-col cols="12" sm="12" md="8" lg="8">
<Instructions v-model="value.recipeInstructions" :edit="true" />
<div class="d-flex row justify-end mt-2">
<BulkAdd class="mr-2" @bulk-data="appendSteps" />
<v-btn color="secondary" dark class="mr-4" @click="addStep">
<v-icon>{{ $globals.icons.create }}</v-icon>
</v-btn>
</div>
<Notes v-model="value.notes" :edit="true" />
<v-text-field v-model="value.orgURL" class="mt-10" :label="$t('recipe.original-url')"></v-text-field>
</v-col>
</v-row>
</v-card-text>
</v-form>
</template>
<script>
import BulkAdd from "@/components/Recipe/Parts/Helpers/BulkAdd";
import ExtrasEditor from "@/components/Recipe/Parts/Helpers/ExtrasEditor";
import CategoryTagSelector from "@/components/FormHelpers/CategoryTagSelector";
import ImageUploadBtn from "@/components/Recipe/Parts/Helpers/ImageUploadBtn";
import { validators } from "@/mixins/validators";
import Nutrition from "@/components/Recipe/Parts/Nutrition";
import Instructions from "@/components/Recipe/Parts/Instructions";
import Ingredients from "@/components/Recipe/Parts/Ingredients";
import Assets from "@/components/Recipe/Parts/Assets.vue";
import Notes from "@/components/Recipe/Parts/Notes.vue";
import SettingsMenu from "@/components/Recipe/Parts/Helpers/SettingsMenu.vue";
import Rating from "@/components/Recipe/Parts/Rating";
const UPLOAD_EVENT = "upload";
export default {
components: {
BulkAdd,
ExtrasEditor,
CategoryTagSelector,
Nutrition,
ImageUploadBtn,
Instructions,
Ingredients,
Assets,
Notes,
SettingsMenu,
Rating,
},
mixins: [validators],
props: {
value: Object,
},
data() {
return {
fileObject: null,
};
},
methods: {
uploadImage(fileObject) {
this.$emit(UPLOAD_EVENT, fileObject);
},
appendSteps(steps) {
this.value.recipeInstructions.push(
...steps.map(x => ({
title: "",
text: x,
}))
);
},
addStep() {
this.value.recipeInstructions.push({ title: "", text: "" });
},
saveExtras(extras) {
this.value.extras = extras;
},
validateRecipe() {
return this.$refs.form.validate();
},
},
};
</script>
<style>
.disabled-card {
opacity: 0.5;
}
.my-divider {
margin: 0 -1px;
}
</style>

View file

@ -1,142 +0,0 @@
<template>
<div>
<v-card-title class="headline">
{{ recipe.name }}
</v-card-title>
<v-card-text>
<VueMarkdown :source="recipe.description"> </VueMarkdown>
<v-row dense disabled>
<v-col>
<v-btn
v-if="recipe.recipeYield"
dense
small
:hover="false"
type="label"
:ripple="false"
elevation="0"
color="secondary darken-1"
class="rounded-sm static"
>
{{ recipe.recipeYield }}
</v-btn>
</v-col>
<Rating :key="recipe.slug" :value="recipe.rating" :name="recipe.name" :slug="recipe.slug" />
</v-row>
<v-row>
<v-col cols="12" sm="12" md="4" lg="4">
<Ingredients :value="recipe.recipeIngredient" :edit="false" />
<div v-if="medium">
<v-card v-if="recipe.recipeCategory.length > 0" class="mt-2">
<v-card-title class="py-2">
{{ $t("recipe.categories") }}
</v-card-title>
<v-divider class="mx-2"></v-divider>
<v-card-text>
<RecipeChips :items="recipe.recipeCategory" />
</v-card-text>
</v-card>
<v-card v-if="recipe.tags.length > 0" class="mt-2">
<v-card-title class="py-2">
{{ $t("tag.tags") }}
</v-card-title>
<v-divider class="mx-2"></v-divider>
<v-card-text>
<RecipeChips :items="recipe.tags" :is-category="false" />
</v-card-text>
</v-card>
<Nutrition v-if="recipe.settings.showNutrition" :value="recipe.nutrition" :edit="false" />
<Assets v-if="recipe.settings.showAssets" :value="recipe.assets" :edit="false" :slug="recipe.slug" />
</div>
</v-col>
<v-divider v-if="medium" class="my-divider" :vertical="true"></v-divider>
<v-col cols="12" sm="12" md="8" lg="8">
<Instructions :value="recipe.recipeInstructions" :edit="false" />
<Notes :value="recipe.notes" :edit="false" />
</v-col>
</v-row>
<div v-if="!medium">
<RecipeChips :title="$t('recipe.categories')" :items="recipe.recipeCategory" />
<RecipeChips :title="$t('tag.tags')" :items="recipe.tags" />
<Nutrition v-if="recipe.settings.showNutrition" :value="recipe.nutrition" :edit="false" />
<Assets v-if="recipe.settings.showAssets" :value="recipe.assets" :edit="false" :slug="recipe.slug" />
</div>
<v-row class="mt-2 mb-1">
<v-col></v-col>
<v-btn
v-if="recipe.orgURL"
dense
small
:hover="false"
type="label"
:ripple="false"
elevation="0"
:href="recipe.orgURL"
color="secondary darken-1"
target="_blank"
class="rounded-sm mr-4"
>
{{ $t("recipe.original-url") }}
</v-btn>
</v-row>
</v-card-text>
</div>
</template>
<script>
import Nutrition from "@/components/Recipe/Parts/Nutrition";
import VueMarkdown from "@adapttive/vue-markdown";
import { utils } from "@/utils";
import Rating from "@/components/Recipe/Parts/Rating";
import Notes from "@/components/Recipe/Parts/Notes";
import Ingredients from "@/components/Recipe/Parts/Ingredients";
import Instructions from "@/components/Recipe/Parts/Instructions.vue";
import Assets from "../../../../frontend.old/src/components/Recipe/Parts/Assets.vue";
import RecipeChips from "./RecipeChips";
export default {
components: {
VueMarkdown,
RecipeChips,
Notes,
Ingredients,
Nutrition,
Instructions,
Assets,
Rating,
},
props: {
recipe: {
type: Object,
required: true,
},
},
data() {
return {
disabledSteps: [],
};
},
computed: {
medium() {
return this.$vuetify.breakpoint.mdAndUp;
},
},
methods: {
generateKey(item, index) {
return utils.generateUniqueKey(item, index);
},
},
};
</script>
<style>
.static {
pointer-events: none;
}
.my-divider {
margin: 0 -1px;
}
</style>

View file

@ -1,22 +0,0 @@
FROM node:lts-alpine
# # install simple http server for serving static content
# RUN npm install -g http-server
# make the 'app' folder the current working directory
WORKDIR /app
# copy both 'package.json' and 'package-lock.json' (if available)
COPY package*.json ./
# install project dependencies
RUN npm install
# copy project files and folders to the current working directory (i.e. 'app' folder)
# COPY . .
# build app for production with minification
# RUN npm run build
EXPOSE 8080
CMD [ "npm", "run", "serve" ]

View file

@ -1,9 +0,0 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules", "dist"]
}

View file

@ -1,75 +0,0 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint",
"i18n:report": "vue-cli-service i18n:report --src './src/**/*.?(js|vue)' --locales './src/locales/**/*.json'"
},
"dependencies": {
"@adapttive/vue-markdown": "^4.0.1",
"axios": "^0.21.1",
"core-js": "^3.14.0",
"fuse.js": "^6.4.6",
"register-service-worker": "^1.7.1",
"v-jsoneditor": "^1.4.4",
"vue": "^2.6.14",
"vue-i18n": "^8.24.1",
"vue-router": "^3.5.1",
"vuedraggable": "^2.24.3",
"vuetify": "^2.5.3",
"vuex": "^3.6.2",
"vuex-persistedstate": "^4.0.0-beta.3"
},
"devDependencies": {
"@intlify/vue-i18n-loader": "^1.1.0",
"@mdi/font": "^5.9.55",
"@mdi/js": "^5.9.55",
"@vue/cli-plugin-babel": "^4.5.13",
"@vue/cli-plugin-eslint": "^4.5.13",
"@vue/cli-plugin-pwa": "~4.5.0",
"@vue/cli-service": "^4.5.13",
"@vue/preload-webpack-plugin": "^2.0.0",
"babel-eslint": "^10.1.0",
"eslint": "^6.7.2",
"eslint-plugin-vue": "^6.2.2",
"html-webpack-plugin": "^5.3.1",
"preload-webpack-plugin": "^2.3.0",
"sass": "^1.34.1",
"sass-loader": "^8.0.2",
"typeface-roboto": "^1.1.13",
"vue-cli-plugin-i18n": "~1.0.1",
"vue-cli-plugin-vuetify": "^2.4.1",
"vue-cli-plugin-webpack-bundle-analyzer": "^4.0.0",
"vue-template-compiler": "^2.6.14",
"vuetify-loader": "^1.7.2"
},
"eslintConfig": {
"root": true,
"env": {
"node": true
},
"extends": [
"plugin:vue/essential",
"eslint:recommended"
],
"parserOptions": {
"parser": "babel-eslint"
},
"rules": {}
},
"prettier": {
"trailingComma": "es5",
"tabWidth": 2,
"semi": true,
"singleQuote": false,
"printWidth": 120
},
"browserslist": [
"> 1%",
"last 2 versions",
"not dead"
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 574 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

View file

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256" version="1.1">
<path d="M 162.083 54.642 C 148.745 68.272, 137.170 80.703, 136.362 82.266 C 133.689 87.435, 133.522 94.130, 135.929 99.573 C 137.122 102.269, 139.070 105.510, 140.258 106.775 L 142.418 109.074 90.974 160.526 L 39.529 211.979 46.999 219.499 L 54.470 227.020 91.235 190.265 L 128 153.510 164.765 190.265 L 201.530 227.020 209 219.500 L 216.470 211.980 179.725 175.225 L 142.980 138.470 150.320 131.178 C 156.858 124.685, 157.808 124.063, 159.001 125.501 C 162.066 129.195, 168.873 132.163, 174.392 132.213 C 183.508 132.295, 186.374 130.174, 212.477 104.038 L 236.454 80.030 231.501 75.001 L 226.548 69.973 209.288 87.212 L 192.027 104.452 187 99.500 L 181.973 94.548 199.212 77.288 L 216.452 60.027 211.500 55 L 206.548 49.973 189.288 67.212 L 172.027 84.452 167 79.500 L 161.973 74.548 179.225 57.275 L 196.477 40.001 191.406 34.930 L 186.335 29.859 162.083 54.642 M 38.429 41.250 C 31.557 49.376, 28.011 62.815, 29.835 73.824 C 31.955 86.615, 34.508 90.093, 61.720 117.253 L 86.520 142.005 101.501 126.999 L 116.482 111.993 79.496 74.996 C 59.154 54.648, 42.210 38, 41.844 38 C 41.478 38, 39.941 39.462, 38.429 41.250" stroke="none" fill="black" fill-rule="evenodd"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -1,22 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<meta name="description" content="Mealie is a self hosted recipe manager and meal planner.">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title> Mealie </title>
</head>
<body>
<noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled.
Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>

View file

@ -1,138 +0,0 @@
<template>
<v-app>
<!-- Dummpy Comment -->
<TheAppBar />
<v-main>
<v-banner v-if="demo" sticky>
<div class="text-center">
<b> This is a Demo of the v0.5.0 (BETA) </b> | Username: changeme@email.com | Password: demo
</div>
</v-banner>
<GlobalSnackbar />
<v-snackbar v-model="snackWithButtons" bottom left timeout="-1">
{{ snackWithBtnText }}
<template v-slot:action="{ attrs }">
<v-btn text color="primary" v-bind="attrs" @click.stop="refreshApp">
{{ snackBtnText }}
</v-btn>
<v-btn icon class="ml-4" @click="snackWithButtons = false">
<v-icon>{{ $globals.icons.close }}</v-icon>
</v-btn>
</template>
</v-snackbar>
<router-view></router-view>
</v-main>
</v-app>
</template>
<script>
import TheAppBar from "@/components/UI/TheAppBar";
import GlobalSnackbar from "@/components/UI/GlobalSnackbar";
import Vuetify from "./plugins/vuetify";
import { user } from "@/mixins/user";
export default {
name: "App",
components: {
TheAppBar,
GlobalSnackbar,
},
mixins: [user],
computed: {
demo() {
const appInfo = this.$store.getters.getAppInfo;
return appInfo.demoStatus;
},
},
async created() {
// Initial API Requests
this.$store.dispatch("initTheme");
this.$store.dispatch("refreshToken");
this.$store.dispatch("requestUserData");
this.$store.dispatch("requestCurrentGroup");
this.$store.dispatch("requestTags");
this.$store.dispatch("requestAppInfo");
this.$store.dispatch("requestSiteSettings");
// Listen for swUpdated event and display refresh snackbar as required.
document.addEventListener("swUpdated", this.showRefreshUI, { once: true });
// Refresh all open app tabs when a new service worker is installed.
if (navigator.serviceWorker) {
navigator.serviceWorker.addEventListener("controllerchange", () => {
if (this.refreshing) return;
this.refreshing = true;
window.location.reload();
});
}
},
mounted() {
this.darkModeSystemCheck();
this.darkModeAddEventListener();
},
data() {
return {
refreshing: false,
registration: null,
snackBtnText: "",
snackWithBtnText: "",
snackWithButtons: false,
};
},
methods: {
// For Later!
/**
* Checks if 'system' is set for dark mode and then sets the corrisponding value for vuetify
*/
darkModeSystemCheck() {
if (this.$store.getters.getDarkMode === "system")
Vuetify.framework.theme.dark = window.matchMedia("(prefers-color-scheme: dark)").matches;
},
/**
* This will monitor the OS level darkmode and call to update dark mode.
*/
darkModeAddEventListener() {
const darkMediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
darkMediaQuery.addEventListener("change", () => {
this.darkModeSystemCheck();
});
},
showRefreshUI(e) {
// Display a snackbar inviting the user to refresh/reload the app due
// to an app update being available.
// The new service worker is installed, but not yet active.
// Store the ServiceWorkerRegistration instance for later use.
this.registration = e.detail;
this.snackBtnText = this.$t("events.refresh");
this.snackWithBtnText = this.$t("events.new-version");
this.snackWithButtons = true;
},
refreshApp() {
this.snackWithButtons = false;
// Protect against missing registration.waiting.
if (!this.registration || !this.registration.waiting) {
return;
}
this.registration.waiting.postMessage("skipWaiting");
},
},
};
</script>
<style>
.top-dialog {
align-self: flex-start;
}
:root {
scrollbar-color: transparent transparent;
}
</style>

View file

@ -1,80 +0,0 @@
import { apiReq } from "./api-utils";
import i18n from "@/i18n.js";
import { API_ROUTES } from "./apiRoutes";
export const aboutAPI = {
async getEvents() {
const resposne = await apiReq.get(API_ROUTES.aboutEvents);
return resposne.data;
},
async deleteEvent(id) {
const resposne = await apiReq.delete(API_ROUTES.aboutEventsId(id));
return resposne.data;
},
async deleteAllEvents() {
const resposne = await apiReq.delete(API_ROUTES.aboutEvents);
return resposne.data;
},
async allEventNotifications() {
const response = await apiReq.get(API_ROUTES.aboutEventsNotifications);
return response.data;
},
async createNotification(data) {
const response = await apiReq.post(API_ROUTES.aboutEventsNotifications, data);
return response.data;
},
async deleteNotification(id) {
const response = await apiReq.delete(API_ROUTES.aboutEventsNotificationsId(id));
return response.data;
},
async testNotificationByID(id) {
const response = await apiReq.post(
API_ROUTES.aboutEventsNotificationsTest,
{ id: id },
() => i18n.t("events.something-went-wrong"),
() => i18n.t("events.test-message-sent")
);
return response.data;
},
async testNotificationByURL(url) {
const response = await apiReq.post(
API_ROUTES.aboutEventsNotificationsTest,
{ test_url: url },
() => i18n.t("events.something-went-wrong"),
() => i18n.t("events.test-message-sent")
);
return response.data;
},
// async getAppInfo() {
// const response = await apiReq.get(aboutURLs.version);
// return response.data;
// },
// async getDebugInfo() {
// const response = await apiReq.get(aboutURLs.debug);
// return response.data;
// },
// async getLogText(num) {
// const response = await apiReq.get(aboutURLs.log(num));
// return response.data;
// },
// async getLastJson() {
// const response = await apiReq.get(aboutURLs.lastRecipe);
// return response.data;
// },
// async getIsDemo() {
// const response = await apiReq.get(aboutURLs.demo);
// return response.data;
// },
// async getStatistics() {
// const response = await apiReq.get(aboutURLs.statistics);
// return response.data;
// },
};

View file

@ -1,121 +0,0 @@
import { prefix } from "./apiRoutes";
import axios from "axios";
import { store } from "../store";
import { utils } from "@/utils";
axios.defaults.headers.common["Authorization"] = `Bearer ${store.getters.getToken}`;
function handleError(error, getText) {
if (getText) {
utils.notify.error(getText(error.response));
}
return false;
}
function handleResponse(response, getText) {
if (response && getText) {
const successText = getText(response);
utils.notify.success(successText);
}
return response;
}
function defaultErrorText(response) {
return response.statusText;
}
function defaultSuccessText(response) {
return response.statusText;
}
const requests = {
/**
*
* @param {*} funcCall Callable Axios Function
* @param {*} url Destination url
* @param {*} data Request Data
* @param {*} getErrorText Error Text Function
* @param {*} getSuccessText Success Text Function
* @returns Object response
*/
unsafe: async function(funcCall, url, data, getErrorText = defaultErrorText, getSuccessText) {
const response = await funcCall(url, data).catch(function(error) {
handleError(error, getErrorText);
});
return handleResponse(response, getSuccessText);
},
/**
*
* @param {*} funcCall Callable Axios Function
* @param {*} url Destination url
* @param {*} data Request Data
* @param {*} getErrorText Error Text Function
* @param {*} getSuccessText Success Text Function
* @returns Array [response, error]
*/
safe: async function(funcCall, url, data, getErrorText = defaultErrorText, getSuccessText) {
const response = await funcCall(url, data).catch(function(error) {
handleError(error, getErrorText);
return [null, error];
});
return [handleResponse(response, getSuccessText), null];
},
};
const apiReq = {
get: async function(url, getErrorText = defaultErrorText) {
return axios.get(url).catch(function(error) {
handleError(error, getErrorText);
});
},
getSafe: async function(url) {
let error = null;
const response = await axios.get(url).catch(e => {
error = e;
});
return [response, error];
},
post: async function(url, data, getErrorText = defaultErrorText, getSuccessText) {
return await requests.unsafe(axios.post, url, data, getErrorText, getSuccessText);
},
postSafe: async function(url, data, getErrorText = defaultErrorText, getSuccessText) {
return await requests.safe(axios.post, url, data, getErrorText, getSuccessText);
},
put: async function(url, data, getErrorText = defaultErrorText, getSuccessText) {
return await requests.unsafe(axios.put, url, data, getErrorText, getSuccessText);
},
putSafe: async function(url, data, getErrorText = defaultErrorText, getSuccessText) {
return await requests.safe(axios.put, url, data, getErrorText, getSuccessText);
},
patch: async function(url, data, getErrorText = defaultErrorText, getSuccessText) {
return await requests.unsafe(axios.patch, url, data, getErrorText, getSuccessText);
},
patchSafe: async function(url, data, getErrorText = defaultErrorText, getSuccessText) {
return await requests.safe(axios.patch, url, data, getErrorText, getSuccessText);
},
delete: async function(url, data, getErrorText = defaultErrorText, getSuccessText = defaultSuccessText) {
return await requests.unsafe(axios.delete, url, data, getErrorText, getSuccessText);
},
deleteSafe: async function(url, data, getErrorText = defaultErrorText, getSuccessText = defaultSuccessText) {
return await requests.unsafe(axios.delete, url, data, getErrorText, getSuccessText);
},
download: async function(url) {
const response = await this.get(url);
const token = response.data.fileToken;
const tokenURL = prefix + "/utils/download?token=" + token;
window.open(tokenURL, "_blank");
return response.data;
},
};
export { apiReq };

View file

@ -1,90 +0,0 @@
// This Content is Auto Generated
export const prefix = "/api";
export const API_ROUTES = {
aboutEvents: `${prefix}/about/events`,
aboutEventsNotifications: `${prefix}/about/events/notifications`,
aboutEventsNotificationsTest: `${prefix}/about/events/notifications/test`,
aboutRecipesDefaults: `${prefix}/about/recipes/defaults`,
authRefresh: `${prefix}/auth/refresh`,
authToken: `${prefix}/auth/token`,
authTokenLong: `${prefix}/auth/token/long`,
backupsAvailable: `${prefix}/backups/available`,
backupsExportDatabase: `${prefix}/backups/export/database`,
backupsUpload: `${prefix}/backups/upload`,
categories: `${prefix}/categories`,
categoriesEmpty: `${prefix}/categories/empty`,
debug: `${prefix}/debug`,
debugLastRecipeJson: `${prefix}/debug/last-recipe-json`,
debugLog: `${prefix}/debug/log`,
debugStatistics: `${prefix}/debug/statistics`,
debugVersion: `${prefix}/debug/version`,
groups: `${prefix}/groups`,
groupsSelf: `${prefix}/groups/self`,
mealPlansAll: `${prefix}/meal-plans/all`,
mealPlansCreate: `${prefix}/meal-plans/create`,
mealPlansThisWeek: `${prefix}/meal-plans/this-week`,
mealPlansToday: `${prefix}/meal-plans/today`,
mealPlansTodayImage: `${prefix}/meal-plans/today/image`,
migrations: `${prefix}/migrations`,
recipesCategory: `${prefix}/recipes/category`,
recipesCreate: `${prefix}/recipes/create`,
recipesCreateFromZip: `${prefix}/recipes/create-from-zip`,
recipesCreateUrl: `${prefix}/recipes/create-url`,
recipesSummary: `${prefix}/recipes/summary`,
recipesSummaryUncategorized: `${prefix}/recipes/summary/uncategorized`,
recipesSummaryUntagged: `${prefix}/recipes/summary/untagged`,
recipesTag: `${prefix}/recipes/tag`,
recipesTestScrapeUrl: `${prefix}/recipes/test-scrape-url`,
shoppingLists: `${prefix}/shopping-lists`,
siteSettings: `${prefix}/site-settings`,
siteSettingsCustomPages: `${prefix}/site-settings/custom-pages`,
siteSettingsWebhooksTest: `${prefix}/site-settings/webhooks/test`,
tags: `${prefix}/tags`,
tagsEmpty: `${prefix}/tags/empty`,
themes: `${prefix}/themes`,
themesCreate: `${prefix}/themes/create`,
users: `${prefix}/users`,
usersApiTokens: `${prefix}/users/api-tokens`,
usersSelf: `${prefix}/users/self`,
usersSignUps: `${prefix}/users/sign-ups`,
utilsDownload: `${prefix}/utils/download`,
aboutEventsId: id => `${prefix}/about/events/${id}`,
aboutEventsNotificationsId: id => `${prefix}/about/events/notifications/${id}`,
backupsFileNameDelete: file_name => `${prefix}/backups/${file_name}/delete`,
backupsFileNameDownload: file_name => `${prefix}/backups/${file_name}/download`,
backupsFileNameImport: file_name => `${prefix}/backups/${file_name}/import`,
categoriesCategory: category => `${prefix}/categories/${category}`,
debugLogNum: num => `${prefix}/debug/log/${num}`,
groupsId: id => `${prefix}/groups/${id}`,
mealPlansId: id => `${prefix}/meal-plans/${id}`,
mealPlansIdShoppingList: id => `${prefix}/meal-plans/${id}/shopping-list`,
mealPlansPlanId: plan_id => `${prefix}/meal-plans/${plan_id}`,
mediaRecipesRecipeSlugAssetsFileName: (recipe_slug, file_name) =>
`${prefix}/media/recipes/${recipe_slug}/assets/${file_name}`,
mediaRecipesRecipeSlugImagesFileName: (recipe_slug, file_name) =>
`${prefix}/media/recipes/${recipe_slug}/images/${file_name}`,
migrationsImportTypeFileNameDelete: (import_type, file_name) =>
`${prefix}/migrations/${import_type}/${file_name}/delete`,
migrationsImportTypeFileNameImport: (import_type, file_name) =>
`${prefix}/migrations/${import_type}/${file_name}/import`,
migrationsImportTypeUpload: import_type => `${prefix}/migrations/${import_type}/upload`,
recipesRecipeSlug: recipe_slug => `${prefix}/recipes/${recipe_slug}`,
recipesRecipeSlugAssets: recipe_slug => `${prefix}/recipes/${recipe_slug}/assets`,
recipesRecipeSlugImage: recipe_slug => `${prefix}/recipes/${recipe_slug}/image`,
recipesRecipeSlugZip: recipe_slug => `${prefix}/recipes/${recipe_slug}/zip`,
recipesSlugComments: slug => `${prefix}/recipes/${slug}/comments`,
recipesSlugCommentsId: (slug, id) => `${prefix}/recipes/${slug}/comments/${id}`,
shoppingListsId: id => `${prefix}/shopping-lists/${id}`,
siteSettingsCustomPagesId: id => `${prefix}/site-settings/custom-pages/${id}`,
tagsTag: tag => `${prefix}/tags/${tag}`,
themesId: id => `${prefix}/themes/${id}`,
usersApiTokensTokenId: token_id => `${prefix}/users/api-tokens/${token_id}`,
usersId: id => `${prefix}/users/${id}`,
usersIdFavorites: id => `${prefix}/users/${id}/favorites`,
usersIdFavoritesSlug: (id, slug) => `${prefix}/users/${id}/favorites/${slug}`,
usersIdImage: id => `${prefix}/users/${id}/image`,
usersIdPassword: id => `${prefix}/users/${id}/password`,
usersIdResetPassword: id => `${prefix}/users/${id}/reset-password`,
usersSignUpsToken: token => `${prefix}/users/sign-ups/${token}`,
};

View file

@ -1,62 +0,0 @@
import { apiReq } from "./api-utils";
import { store } from "@/store";
import i18n from "@/i18n.js";
import { API_ROUTES } from "./apiRoutes";
export const backupAPI = {
/**
* Request all backups available on the server
* @returns {Array} List of Available Backups
*/
async requestAvailable() {
let response = await apiReq.get(API_ROUTES.backupsAvailable);
return response.data;
},
/**
* Calls for importing a file on the server
* @param {string} fileName
* @param {object} data
* @returns A report containing status of imported items
*/
async import(fileName, data) {
let response = await apiReq.post(API_ROUTES.backupsFileNameImport(fileName), data);
store.dispatch("requestRecentRecipes");
return response;
},
/**
* Removes a file from the server
* @param {string} fileName
*/
async delete(fileName) {
return apiReq.delete(
API_ROUTES.backupsFileNameDelete(fileName),
null,
() => i18n.t("settings.backup.unable-to-delete-backup"),
() => i18n.t("settings.backup.backup-deleted")
);
},
/**
* Creates a backup on the serve given a set of options
* @param {object} data
* @returns
*/
async create(options) {
return apiReq.post(
API_ROUTES.backupsExportDatabase,
options,
() => i18n.t("settings.backup.error-creating-backup-see-log-file"),
response => {
return i18n.t("settings.backup.backup-created-at-response-export_path", { path: response.data.export_path });
}
);
},
/**
* Downloads a file from the server. I don't actually think this is used?
* @param {string} fileName
* @returns Download URL
*/
async download(fileName) {
const url = API_ROUTES.backupsFileNameDownload(fileName);
apiReq.download(url);
},
};

View file

@ -1,111 +0,0 @@
import { apiReq } from "./api-utils";
import { store } from "@/store";
import i18n from "@/i18n.js";
import { API_ROUTES } from "./apiRoutes";
export const categoryAPI = {
async getAll() {
let response = await apiReq.get(API_ROUTES.categories);
return response.data;
},
async getEmpty() {
let response = await apiReq.get(API_ROUTES.categoriesEmpty);
return response.data;
},
async create(name) {
const response = await apiReq.post(
API_ROUTES.categories,
{ name: name },
() => i18n.t("category.category-creation-failed"),
() => i18n.t("category.category-created")
);
if (response) {
store.dispatch("requestCategories");
return response.data;
}
},
async getRecipesInCategory(category) {
let response = await apiReq.get(API_ROUTES.categoriesCategory(category));
return response.data;
},
async update(name, newName, overrideRequest = false) {
const response = await apiReq.put(
API_ROUTES.categoriesCategory(name),
{ name: newName },
() => i18n.t("category.category-update-failed"),
() => i18n.t("category.category-updated")
);
if (response && !overrideRequest) {
store.dispatch("requestCategories");
return response.data;
}
},
async delete(category, overrideRequest = false) {
const response = await apiReq.delete(
API_ROUTES.categoriesCategory(category),
null,
() => i18n.t("category.category-deletion-failed"),
() => i18n.t("category.category-deleted")
);
if (response && !overrideRequest) {
store.dispatch("requestCategories");
}
return response;
},
};
export const tagAPI = {
async getAll() {
let response = await apiReq.get(API_ROUTES.tags);
return response.data;
},
async getEmpty() {
let response = await apiReq.get(API_ROUTES.tagsEmpty);
return response.data;
},
async create(name) {
const response = await apiReq.post(
API_ROUTES.tags,
{ name: name },
() => i18n.t("tag.tag-creation-failed"),
() => i18n.t("tag.tag-created")
);
if (response) {
store.dispatch("requestTags");
return response.data;
}
},
async getRecipesInTag(tag) {
let response = await apiReq.get(API_ROUTES.tagsTag(tag));
return response.data;
},
async update(name, newName, overrideRequest = false) {
const response = await apiReq.put(
API_ROUTES.tagsTag(name),
{ name: newName },
() => i18n.t("tag.tag-update-failed"),
() => i18n.t("tag.tag-updated")
);
if (response) {
if (!overrideRequest) {
store.dispatch("requestTags");
}
return response.data;
}
},
async delete(tag, overrideRequest = false) {
const response = await apiReq.delete(
API_ROUTES.tagsTag(tag),
null,
() => i18n.t("tag.tag-deletion-failed"),
() => i18n.t("tag.tag-deleted")
);
if (response) {
if (!overrideRequest) {
store.dispatch("requestTags");
}
return response.data;
}
},
};

View file

@ -1,53 +0,0 @@
import { apiReq } from "./api-utils";
import i18n from "@/i18n.js";
import { API_ROUTES } from "./apiRoutes";
function deleteErrorText(response) {
switch (response.data.detail) {
case "GROUP_WITH_USERS":
return i18n.t("group.cannot-delete-group-with-users");
case "GROUP_NOT_FOUND":
return i18n.t("group.group-not-found");
case "DEFAULT_GROUP":
return i18n.t("group.cannot-delete-default-group");
default:
return i18n.t("group.group-deletion-failed");
}
}
export const groupAPI = {
async allGroups() {
let response = await apiReq.get(API_ROUTES.groups);
return response.data;
},
create(name) {
return apiReq.post(
API_ROUTES.groups,
{ name: name },
() => i18n.t("group.user-group-creation-failed"),
() => i18n.t("group.user-group-created")
);
},
delete(id) {
return apiReq.delete(API_ROUTES.groupsId(id), null, deleteErrorText, function() {
return i18n.t("group.group-deleted");
});
},
async current() {
const response = await apiReq.get(API_ROUTES.groupsSelf, null, null);
if (response) {
return response.data;
}
},
update(data) {
return apiReq.put(
API_ROUTES.groupsId(data.id),
data,
() => i18n.t("group.error-updating-group"),
() => i18n.t("settings.group-settings-updated")
);
},
};

View file

@ -1,37 +0,0 @@
import { backupAPI } from "./backup";
import { recipeAPI } from "./recipe";
import { mealplanAPI } from "./mealplan";
import { settingsAPI } from "./settings";
import { themeAPI } from "./themes";
import { migrationAPI } from "./migration";
import { utilsAPI } from "./upload";
import { categoryAPI, tagAPI } from "./category";
import { metaAPI } from "./meta";
import { userAPI } from "./users";
import { signupAPI } from "./signUps";
import { groupAPI } from "./groups";
import { siteSettingsAPI } from "./siteSettings";
import { aboutAPI } from "./about";
import { shoppingListsAPI } from "./shoppingLists";
/**
* The main object namespace for interacting with the backend database
*/
export const api = {
recipes: recipeAPI,
siteSettings: siteSettingsAPI,
backups: backupAPI,
mealPlans: mealplanAPI,
settings: settingsAPI,
themes: themeAPI,
migrations: migrationAPI,
utils: utilsAPI,
categories: categoryAPI,
tags: tagAPI,
meta: metaAPI,
users: userAPI,
signUps: signupAPI,
groups: groupAPI,
about: aboutAPI,
shoppingLists: shoppingListsAPI,
};

View file

@ -1,57 +0,0 @@
import { apiReq } from "./api-utils";
import i18n from "@/i18n.js";
import { API_ROUTES } from "./apiRoutes";
export const mealplanAPI = {
create(postBody) {
return apiReq.post(
API_ROUTES.mealPlansCreate,
postBody,
() => i18n.t("meal-plan.mealplan-creation-failed"),
() => i18n.t("meal-plan.mealplan-created")
);
},
async all() {
let response = await apiReq.get(API_ROUTES.mealPlansAll);
return response;
},
async thisWeek() {
let response = await apiReq.get(API_ROUTES.mealPlansThisWeek);
return response.data;
},
async today() {
let response = await apiReq.get(API_ROUTES.mealPlansToday);
return response;
},
async getById(id) {
let response = await apiReq.get(API_ROUTES.mealPlansId(id));
return response.data;
},
delete(id) {
return apiReq.delete(
API_ROUTES.mealPlansId(id),
null,
() => i18n.t("meal-plan.mealplan-deletion-failed"),
() => i18n.t("meal-plan.mealplan-deleted")
);
},
update(id, body) {
return apiReq.put(
API_ROUTES.mealPlansId(id),
body,
() => i18n.t("meal-plan.mealplan-update-failed"),
() => i18n.t("meal-plan.mealplan-updated")
);
},
async shoppingList(id) {
let response = await apiReq.get(API_ROUTES.mealPlansIdShoppingList(id));
return response.data;
},
};

View file

@ -1,29 +0,0 @@
import { apiReq } from "./api-utils";
import { API_ROUTES } from "./apiRoutes";
export const metaAPI = {
async getAppInfo() {
const response = await apiReq.get(API_ROUTES.debugVersion);
return response.data;
},
async getDebugInfo() {
const response = await apiReq.get(API_ROUTES.debug);
return response.data;
},
async getLogText(num) {
const response = await apiReq.get(API_ROUTES.debugLogNum(num));
return response.data;
},
async getLastJson() {
const response = await apiReq.get(API_ROUTES.debugLastRecipeJson);
return response.data;
},
async getStatistics() {
const response = await apiReq.get(API_ROUTES.debugStatistics);
return response.data;
},
};

View file

@ -1,25 +0,0 @@
import { apiReq } from "./api-utils";
import { store } from "../store";
import i18n from "@/i18n.js";
import { API_ROUTES } from "./apiRoutes";
export const migrationAPI = {
async getMigrations() {
let response = await apiReq.get(API_ROUTES.migrations);
return response.data;
},
async delete(folder, file) {
const response = await apiReq.delete(
API_ROUTES.migrationsImportTypeFileNameDelete(folder, file),
null,
() => i18n.t("general.file-folder-not-found"),
() => i18n.t("migration.migration-data-removed")
);
return response;
},
async import(folder, file) {
let response = await apiReq.post(API_ROUTES.migrationsImportTypeFileNameImport(folder, file));
store.dispatch("requestRecentRecipes");
return response.data;
},
};

View file

@ -1,181 +0,0 @@
import { API_ROUTES } from "./apiRoutes";
import { apiReq } from "./api-utils";
import { store } from "../store";
import i18n from "@/i18n.js";
export const recipeAPI = {
/**
* Returns the Default Recipe Settings for the Site
* @returns {AxoisResponse} Axois Response Object
*/
async getDefaultSettings() {
const response = await apiReq.get(API_ROUTES.aboutRecipesDefaults);
return response;
},
/**
* Create a Recipe by URL
* @param {string} recipeURL
* @returns {string} Recipe Slug
*/
async createByURL(recipeURL) {
const response = await apiReq.post(API_ROUTES.recipesCreateUrl, { url: recipeURL }, false, () =>
i18n.t("recipe.recipe-created")
);
store.dispatch("requestRecentRecipes");
return response;
},
async getAllByCategory(categories) {
let response = await apiReq.post(API_ROUTES.recipesCategory, categories);
return response.data;
},
async create(recipeData) {
const response = await apiReq.post(
API_ROUTES.recipesCreate,
recipeData,
() => i18n.t("recipe.recipe-creation-failed"),
() => i18n.t("recipe.recipe-created")
);
store.dispatch("requestRecentRecipes");
return response.data;
},
async requestDetails(recipeSlug) {
const response = await apiReq.getSafe(API_ROUTES.recipesRecipeSlug(recipeSlug));
return response;
},
updateImage(recipeSlug, fileObject, overrideSuccessMsg = false) {
const formData = new FormData();
formData.append("image", fileObject);
formData.append("extension", fileObject.name.split(".").pop());
let successMessage = null;
if (!overrideSuccessMsg) {
successMessage = function() {
return overrideSuccessMsg ? null : i18n.t("recipe.recipe-image-updated");
};
}
return apiReq.put(
API_ROUTES.recipesRecipeSlugImage(recipeSlug),
formData,
() => i18n.t("general.image-upload-failed"),
successMessage
);
},
async createAsset(recipeSlug, fileObject, name, icon) {
const fd = new FormData();
fd.append("file", fileObject);
fd.append("extension", fileObject.name.split(".").pop());
fd.append("name", name);
fd.append("icon", icon);
const response = apiReq.post(API_ROUTES.recipesRecipeSlugAssets(recipeSlug), fd);
return response;
},
updateImagebyURL(slug, url) {
return apiReq.post(
API_ROUTES.recipesRecipeSlugImage(slug),
{ url: url },
() => i18n.t("general.image-upload-failed"),
() => i18n.t("recipe.recipe-image-updated")
);
},
async update(data) {
let response = await apiReq.put(
API_ROUTES.recipesRecipeSlug(data.slug),
data,
() => i18n.t("recipe.recipe-update-failed"),
() => i18n.t("recipe.recipe-updated")
);
if (response) {
store.dispatch("patchRecipe", response.data);
return response.data.slug; // ! Temporary until I rewrite to refresh page without additional request
}
},
async patch(data) {
let response = await apiReq.patch(API_ROUTES.recipesRecipeSlug(data.slug), data);
store.dispatch("patchRecipe", response.data);
return response.data;
},
async delete(recipeSlug) {
const response = await apiReq.delete(
API_ROUTES.recipesRecipeSlug(recipeSlug),
null,
() => i18n.t("recipe.unable-to-delete-recipe"),
() => i18n.t("recipe.recipe-deleted")
);
store.dispatch("dropRecipe", response.data);
return response;
},
async allSummary(start = 0, limit = 9999) {
const response = await apiReq.get(API_ROUTES.recipesSummary, {
params: { start: start, limit: limit },
});
return response.data;
},
async allUntagged() {
const response = await apiReq.get(API_ROUTES.recipesSummaryUntagged);
return response.data;
},
async allUnategorized() {
const response = await apiReq.get(API_ROUTES.recipesSummaryUncategorized);
return response.data;
},
recipeImage(recipeSlug, version = null, key = null) {
return `/api/media/recipes/${recipeSlug}/images/original.webp?&rnd=${key}&version=${version}`;
},
recipeSmallImage(recipeSlug, version = null, key = null) {
return `/api/media/recipes/${recipeSlug}/images/min-original.webp?&rnd=${key}&version=${version}`;
},
recipeTinyImage(recipeSlug, version = null, key = null) {
return `/api/media/recipes/${recipeSlug}/images/tiny-original.webp?&rnd=${key}&version=${version}`;
},
recipeAssetPath(recipeSlug, assetName) {
return `/api/media/recipes/${recipeSlug}/assets/${assetName}`;
},
/** Create comment in the Database
* @param slug
*/
async createComment(slug, data) {
const response = await apiReq.post(API_ROUTES.recipesSlugComments(slug), data);
return response.data;
},
/** Update comment in the Database
* @param slug
* @param id
*/
async updateComment(slug, id, data) {
const response = await apiReq.put(API_ROUTES.recipesSlugCommentsId(slug, id), data);
return response.data;
},
/** Delete comment from the Database
* @param slug
* @param id
*/
async deleteComment(slug, id) {
const response = await apiReq.delete(API_ROUTES.recipesSlugCommentsId(slug, id));
return response.data;
},
async testScrapeURL(url) {
const response = await apiReq.post(API_ROUTES.recipesTestScrapeUrl, { url: url });
return response.data;
},
};

View file

@ -1,19 +0,0 @@
import { apiReq } from "./api-utils";
import { API_ROUTES } from "./apiRoutes";
export const settingsAPI = {
async requestAll() {
let response = await apiReq.get(API_ROUTES.siteSettings);
return response.data;
},
async testWebhooks() {
let response = await apiReq.post(API_ROUTES.siteSettingsWebhooksTest);
return response.data;
},
async update(body) {
let response = await apiReq.put(API_ROUTES.siteSettings, body);
return response.data;
},
};

View file

@ -1,33 +0,0 @@
// This Content is Auto Generated
import { API_ROUTES } from "./apiRoutes";
import { apiReq } from "./api-utils";
export const shoppingListsAPI = {
/** Create Shopping List in the Database
*/
async createShoppingList(data) {
const response = await apiReq.post(API_ROUTES.shoppingLists, data);
return response.data;
},
/** Get Shopping List from the Database
* @param id
*/
async getShoppingList(id) {
const response = await apiReq.get(API_ROUTES.shoppingListsId(id));
return response.data;
},
/** Update Shopping List in the Database
* @param id
*/
async updateShoppingList(id, data) {
const response = await apiReq.put(API_ROUTES.shoppingListsId(id), data);
return response.data;
},
/** Delete Shopping List from the Database
* @param id
*/
async deleteShoppingList(id) {
const response = await apiReq.delete(API_ROUTES.shoppingListsId(id));
return response.data;
},
};

View file

@ -1,35 +0,0 @@
import { apiReq } from "./api-utils";
import i18n from "@/i18n.js";
import { API_ROUTES } from "./apiRoutes";
export const signupAPI = {
async getAll() {
let response = await apiReq.get(API_ROUTES.usersSignUps);
return response.data;
},
async createToken(data) {
let response = await apiReq.post(
API_ROUTES.usersSignUps,
data,
() => i18n.t("signup.sign-up-link-creation-failed"),
() => i18n.t("signup.sign-up-link-created")
);
return response.data;
},
async deleteToken(token) {
return await apiReq.delete(
API_ROUTES.usersSignUpsToken(token),
null,
() => i18n.t("signup.sign-up-token-deletion-failed"),
() => i18n.t("signup.sign-up-token-deleted")
);
},
async createUser(token, data) {
return apiReq.post(
API_ROUTES.usersSignUpsToken(token),
data,
() => i18n.t("user.you-are-not-allowed-to-create-a-user"),
() => i18n.t("user.user-created")
);
},
};

View file

@ -1,71 +0,0 @@
import { apiReq } from "./api-utils";
import { store } from "@/store";
import i18n from "@/i18n.js";
import { API_ROUTES } from "./apiRoutes";
export const siteSettingsAPI = {
async get() {
let response = await apiReq.get(API_ROUTES.siteSettings);
return response.data;
},
async update(body) {
const response = await apiReq.put(
API_ROUTES.siteSettings,
body,
() => i18n.t("settings.settings-update-failed"),
() => i18n.t("settings.settings-updated")
);
if (response) {
store.dispatch("requestSiteSettings");
}
return response;
},
async getPages() {
let response = await apiReq.get(API_ROUTES.siteSettingsCustomPages);
return response.data;
},
async getPage(id) {
let response = await apiReq.get(API_ROUTES.siteSettingsCustomPagesId(id));
return response.data;
},
createPage(body) {
return apiReq.post(
API_ROUTES.siteSettingsCustomPages,
body,
() => i18n.t("page.page-creation-failed"),
() => i18n.t("page.new-page-created")
);
},
async deletePage(id) {
return await apiReq.delete(
API_ROUTES.siteSettingsCustomPagesId(id),
null,
() => i18n.t("page.page-deletion-failed"),
() => i18n.t("page.page-deleted")
);
},
updatePage(body) {
return apiReq.put(
API_ROUTES.siteSettingsCustomPagesId(body.id),
body,
() => i18n.t("page.page-update-failed"),
() => i18n.t("page.page-updated")
);
},
async updateAllPages(allPages) {
let response = await apiReq.put(
API_ROUTES.siteSettingsCustomPages,
allPages,
() => i18n.t("page.pages-update-failed"),
() => i18n.t("page.pages-updated")
);
return response;
},
};

View file

@ -1,42 +0,0 @@
import { apiReq } from "./api-utils";
import i18n from "@/i18n.js";
import { API_ROUTES } from "./apiRoutes";
export const themeAPI = {
async requestAll() {
let response = await apiReq.get(API_ROUTES.themes);
return response.data;
},
async requestByName(name) {
let response = await apiReq.get(API_ROUTES.themesId(name));
return response.data;
},
async create(postBody) {
return await apiReq.post(
API_ROUTES.themesCreate,
postBody,
() => i18n.t("settings.theme.error-creating-theme-see-log-file"),
() => i18n.t("settings.theme.theme-saved")
);
},
update(data) {
return apiReq.put(
API_ROUTES.themesId(data.id),
data,
() => i18n.t("settings.theme.error-updating-theme"),
() => i18n.t("settings.theme.theme-updated")
);
},
delete(id) {
return apiReq.delete(
API_ROUTES.themesId(id),
null,
() => i18n.t("settings.theme.error-deleting-theme"),
() => i18n.t("settings.theme.theme-deleted")
);
},
};

View file

@ -1,14 +0,0 @@
import { apiReq } from "./api-utils";
import i18n from "@/i18n.js";
export const utilsAPI = {
// import { api } from "@/api";
uploadFile(url, fileObject) {
return apiReq.post(
url,
fileObject,
() => i18n.t("general.failure-uploading-file"),
() => i18n.t("general.file-uploaded")
);
},
};

View file

@ -1,107 +0,0 @@
import { API_ROUTES } from "./apiRoutes";
import { apiReq } from "./api-utils";
import i18n from "@/i18n.js";
export const userAPI = {
async login(formData) {
let response = await apiReq.post(API_ROUTES.authToken, formData, null, () => {
return i18n.t("user.user-successfully-logged-in");
});
return response;
},
async refresh() {
return apiReq.getSafe(API_ROUTES.authRefresh);
},
async allUsers() {
let response = await apiReq.get(API_ROUTES.users);
return response.data;
},
create(user) {
return apiReq.post(
API_ROUTES.users,
user,
() => i18n.t("user.user-creation-failed"),
() => i18n.t("user.user-created")
);
},
async self() {
return apiReq.getSafe(API_ROUTES.usersSelf);
},
async byID(id) {
let response = await apiReq.get(API_ROUTES.usersId(id));
return response.data;
},
update(user) {
return apiReq.put(
API_ROUTES.usersId(user.id),
user,
() => i18n.t("user.user-update-failed"),
() => i18n.t("user.user-updated")
);
},
changePassword(id, password) {
return apiReq.put(
API_ROUTES.usersIdPassword(id),
password,
() => i18n.t("user.existing-password-does-not-match"),
() => i18n.t("user.password-updated")
);
},
delete(id) {
return apiReq.delete(API_ROUTES.usersId(id), null, deleteErrorText, () => {
return i18n.t("user.user-deleted");
});
},
resetPassword(id) {
return apiReq.put(
API_ROUTES.usersIdResetPassword(id),
null,
() => i18n.t("user.password-reset-failed"),
() => i18n.t("user.password-has-been-reset-to-the-default-password")
);
},
async createAPIToken(name) {
const response = await apiReq.post(API_ROUTES.usersApiTokens, { name });
return response.data;
},
async deleteAPIToken(id) {
const response = await apiReq.delete(API_ROUTES.usersApiTokensTokenId(id));
return response.data;
},
/** Adds a Recipe to the users favorites
* @param id
*/
async getFavorites(id) {
const response = await apiReq.get(API_ROUTES.usersIdFavorites(id));
return response.data;
},
/** Adds a Recipe to the users favorites
* @param id
*/
async addFavorite(id, slug) {
const response = await apiReq.post(API_ROUTES.usersIdFavoritesSlug(id, slug));
return response.data;
},
/** Adds a Recipe to the users favorites
* @param id
*/
async removeFavorite(id, slug) {
const response = await apiReq.delete(API_ROUTES.usersIdFavoritesSlug(id, slug));
return response.data;
},
userProfileImage(id) {
if (!id || id === undefined) return;
return `/api/users/${id}/image`;
},
};
const deleteErrorText = response => {
switch (response.data.detail) {
case "SUPER_USER":
return i18n.t("user.error-cannot-delete-super-user");
default:
return i18n.t("user.you-are-not-allowed-to-delete-this-user");
}
};

View file

@ -1,17 +0,0 @@
<template>
<div>
<The404>
<h1 class="mx-auto">{{ $t('general.no-recipe-found') }}</h1>
</The404>
</div>
</template>
<script>
import The404 from "./The404.vue";
export default {
components: { The404 },
};
</script>

View file

@ -1,51 +0,0 @@
<template>
<div>
<v-card-title>
<slot>
<h1 class="mx-auto">{{ $t('page.404-page-not-found') }}</h1>
</slot>
</v-card-title>
<div class="d-flex justify-space-around">
<div class="d-flex">
<p>4</p>
<v-icon color="primary" class="mx-auto" size="200">
{{ $globals.icons.primary }}
</v-icon>
<p>4</p>
</div>
</div>
<v-card-actions>
<v-spacer></v-spacer>
<slot name="actions">
<v-btn v-for="(button, index) in buttons" :key="index" :to="button.to" color="primary">
<v-icon left> {{ button.icon }} </v-icon>
{{ button.text }}
</v-btn>
</slot>
<v-spacer></v-spacer>
</v-card-actions>
</div>
</template>
<script>
export default {
computed: {
buttons() {
return[
{ icon: this.$globals.icons.home, to: "/", text: this.$t('general.home') },
{ icon: this.$globals.icons.primary, to: "/recipes/all", text: this.$t('page.all-recipes') },
{ icon: this.$globals.icons.search, to: "/search", text: this.$t('search.search') },
];
},
}
};
</script>
<style scoped>
p {
padding-bottom: 0 !important;
margin-bottom: 0 !important;
color: var(--v-primary-base);
font-size: 200px;
}
</style>

View file

@ -1,134 +0,0 @@
<template>
<v-autocomplete
:items="activeItems"
v-model="selected"
:value="value"
:label="inputLabel"
chips
deletable-chips
:dense="dense"
item-text="name"
persistent-hint
multiple
:hint="hint"
:solo="solo"
:return-object="returnObject"
:flat="flat"
@input="emitChange"
>
<template v-slot:selection="data">
<v-chip
class="ma-1"
:input-value="data.selected"
close
@click:close="removeByIndex(data.index)"
label
color="accent"
dark
:key="data.index"
>
{{ data.item.name || data.item }}
</v-chip>
</template>
<template v-slot:append-outer="">
<NewCategoryTagDialog v-if="showAdd" :tag-dialog="tagSelector" @created-item="pushToItem" />
</template>
</v-autocomplete>
</template>
<script>
import NewCategoryTagDialog from "@/components/UI/Dialogs/NewCategoryTagDialog";
const MOUNTED_EVENT = "mounted";
export default {
components: {
NewCategoryTagDialog,
},
props: {
value: Array,
solo: {
default: false,
},
dense: {
default: true,
},
returnObject: {
default: true,
},
tagSelector: {
default: false,
},
hint: {
default: null,
},
showAdd: {
default: false,
},
showLabel: {
default: true,
},
},
data() {
return {
selected: [],
};
},
async created() {
if (this.tagSelector) {
this.$store.dispatch("requestTags");
} else {
this.$store.dispatch("requestCategories");
}
},
mounted() {
this.$emit(MOUNTED_EVENT);
this.setInit(this.value);
},
watch: {
value(val) {
this.selected = val;
},
},
computed: {
inputLabel() {
if (!this.showLabel) return null;
return this.tagSelector ? this.$t("tag.tags") : this.$t("recipe.categories");
},
activeItems() {
let ItemObjects = [];
if (this.tagSelector) ItemObjects = this.$store.getters.getAllTags;
else {
ItemObjects = this.$store.getters.getAllCategories;
}
if (this.returnObject) return ItemObjects;
else {
return ItemObjects.map(x => x.name);
}
},
flat() {
if (this.selected) {
return this.selected.length > 0 && this.solo;
}
return false;
},
},
methods: {
emitChange() {
this.$emit("input", this.selected);
},
setInit(val) {
this.selected = val;
},
removeByIndex(index) {
this.selected.splice(index, 1);
},
pushToItem(createdItem) {
createdItem = this.returnObject ? createdItem : createdItem.name;
this.selected.push(createdItem);
},
},
};
</script>
<style lang="scss" scoped></style>

View file

@ -1,65 +0,0 @@
<template>
<div>
<div class="text-center">
<h3>{{ buttonText }}</h3>
</div>
<v-text-field v-model="color" hide-details class="ma-0 pa-0" solo >
<template v-slot:append>
<v-menu v-model="menu" top nudge-bottom="105" nudge-left="16" :close-on-content-click="false">
<template v-slot:activator="{ on }">
<div :style="swatchStyle" v-on="on" swatches-max-height="300" />
</template>
<v-card>
<v-card-text class="pa-0">
<v-color-picker v-model="color" flat mode="hexa" show-swatches />
</v-card-text>
</v-card>
</v-menu>
</template>
</v-text-field>
</div>
</template>
<script>
export default {
props: {
buttonText: String,
value: String,
},
data() {
return {
dialog: false,
swatches: false,
color: this.value || "#1976D2",
mask: "!#XXXXXXXX",
menu: false,
};
},
computed: {
swatchStyle() {
const { value, menu } = this;
return {
backgroundColor: value,
cursor: "pointer",
height: "30px",
width: "30px",
borderRadius: menu ? "50%" : "4px",
transition: "border-radius 200ms ease-in-out",
};
},
},
watch: {
color() {
this.updateColor();
},
},
methods: {
updateColor() {
this.$emit("input", this.color);
},
},
};
</script>
<style></style>

View file

@ -1,27 +0,0 @@
<template>
<v-date-picker :first-day-of-week="firstDayOfWeek" v-on="$listeners"></v-date-picker>
</template>
<script>
import { api } from "@/api";
export default {
data() {
return {
firstDayOfWeek: 0,
};
},
mounted() {
this.getOptions();
},
methods: {
async getOptions() {
const settings = await api.siteSettings.get();
this.firstDayOfWeek = settings.firstDayOfWeek;
},
},
};
</script>
<style></style>

View file

@ -1,69 +0,0 @@
<template>
<div>
<v-checkbox
v-for="(option, index) in options"
:key="index"
class="mb-n4 mt-n3"
dense
:label="option.text"
v-model="option.value"
@change="emitValue()"
></v-checkbox>
</div>
</template>
<script>
const UPDATE_EVENT = "update-options";
export default {
data() {
return {
options: {
recipes: {
value: true,
text: this.$t("general.recipes"),
},
settings: {
value: true,
text: this.$t("general.settings"),
},
pages: {
value: true,
text: this.$t("settings.pages"),
},
themes: {
value: true,
text: this.$t("general.themes"),
},
users: {
value: true,
text: this.$t("user.users"),
},
groups: {
value: true,
text: this.$t("group.groups"),
},
notifications: {
value: true,
text: this.$t("events.notification"),
},
},
};
},
mounted() {
this.emitValue();
},
methods: {
emitValue() {
this.$emit(UPDATE_EVENT, {
recipes: this.options.recipes.value,
settings: this.options.settings.value,
themes: this.options.themes.value,
pages: this.options.pages.value,
users: this.options.users.value,
groups: this.options.groups.value,
notifications: this.options.notifications.value,
});
},
},
};
</script>

View file

@ -1,48 +0,0 @@
<template>
<v-select
dense
:items="allLanguages"
item-text="name"
:label="$t('settings.language')"
:prepend-icon="$globals.icons.translate"
:value="selectedItem"
@input="setLanguage"
>
</v-select>
</template>
<script>
const SELECT_EVENT = "select-lang";
export default {
props: {
siteSettings: {
default: false,
},
},
data: function() {
return {
selectedItem: 0,
items: [
{
name: "English",
value: "en-US",
},
],
};
},
created() {
this.selectedItem = this.$store.getters.getActiveLang;
},
computed: {
allLanguages() {
return this.$store.getters.getAllLangs;
},
},
methods: {
setLanguage(selectedLanguage) {
this.$emit(SELECT_EVENT, selectedLanguage);
},
},
};
</script>

View file

@ -1,42 +0,0 @@
<template>
<v-dialog ref="dialog" v-model="modal2" :return-value.sync="time" persistent width="290px">
<template v-slot:activator="{ on, attrs }">
<v-text-field
v-model="time"
:label="$t('settings.set-new-time')"
:prepend-icon="$globals.icons.clockOutline"
readonly
v-bind="attrs"
v-on="on"
></v-text-field>
</template>
<v-time-picker v-if="modal2" v-model="time" full-width>
<v-spacer></v-spacer>
<v-btn text color="primary" @click="modal2 = false"> {{ $t("general.cancel") }} </v-btn>
<v-btn text color="primary" @click="saveTime"> {{ $t("general.ok") }} </v-btn>
</v-time-picker>
</v-dialog>
</template>
<script>
export default {
data() {
return {
time: null,
modal2: false,
};
},
methods: {
saveTime() {
this.$refs.dialog.save(this.time);
this.$emit("save-time", this.time);
},
},
};
</script>
<style scoped>
.v-text-field {
max-width: 300px;
}
</style>

View file

@ -1,46 +0,0 @@
<template>
<div>
<v-data-table
dense
:headers="dataHeaders"
:items="dataSet"
item-key="name"
class="elevation-1 mt-2"
show-expand
:expanded.sync="expanded"
:footer-props="{
'items-per-page-options': [100, 200, 300, 400, -1],
}"
:items-per-page="100"
>
<template v-slot:item.status="{ item }">
<div :class="item.status ? 'success--text' : 'error--text'">
{{ item.status ? "Imported" : "Failed" }}
</div>
</template>
<template v-slot:expanded-item="{ headers, item }">
<td :colspan="headers.length">
<div class="ma-2">
{{ item.exception }}
</div>
</td>
</template>
</v-data-table>
</div>
</template>
<script>
export default {
props: {
dataSet: Array,
dataHeaders: Array,
},
data: () => ({
singleExpand: false,
expanded: [],
}),
};
</script>
<style></style>

View file

@ -1,142 +0,0 @@
<template>
<div class="text-center">
<v-dialog v-model="dialog" width="70%">
<v-card>
<v-app-bar dark color="primary mb-2">
<v-icon large left>
{{ $globals.icons.import }}
</v-icon>
<v-toolbar-title class="headline">
{{ $t("settings.backup.import-summary") }}
</v-toolbar-title>
<v-spacer></v-spacer>
</v-app-bar>
<v-card-text class="mb-n4">
<v-row>
<div v-for="values in allNumbers" :key="values.title">
<v-card-text>
<div>
<h3>{{ values.title }}</h3>
</div>
<div class="success--text">{{ $t("general.success-count", { count: values.success }) }}</div>
<div class="error--text">{{ $t("general.failed-count", { count: values.failure }) }}</div>
</v-card-text>
</div>
</v-row>
</v-card-text>
<v-tabs v-model="tab" show-arrows="">
<v-tab>{{ $t("general.recipes") }}</v-tab>
<v-tab>{{ $t("general.themes") }}</v-tab>
<v-tab>{{ $t("general.settings") }}</v-tab>
<v-tab> {{ $t("settings.pages") }} </v-tab>
<v-tab>{{ $t("user.users") }}</v-tab>
<v-tab>{{ $t("group.groups") }}</v-tab>
</v-tabs>
<v-tabs-items v-model="tab">
<v-tab-item v-for="(table, index) in allTables" :key="index">
<v-card flat>
<DataTable :data-headers="importHeaders" :data-set="table" />
</v-card>
</v-tab-item>
</v-tabs-items>
</v-card>
</v-dialog>
</div>
</template>
<script>
import DataTable from "@/components/ImportSummaryDialog/DataTable";
export default {
components: {
DataTable,
},
data: () => ({
tab: null,
dialog: false,
recipeData: [],
themeData: [],
settingsData: [],
userData: [],
groupData: [],
pageData: [],
allDataTables: [],
}),
computed: {
importHeaders() {
return [
{
text: this.$t("general.status"),
value: "status",
},
{
text: this.$t("general.name"),
align: "start",
sortable: true,
value: "name",
},
{
text: this.$t("general.exception"),
value: "data-table-expand",
align: "center",
},
];
},
recipeNumbers() {
return this.calculateNumbers(this.$t("general.recipes"), this.recipeData);
},
settingsNumbers() {
return this.calculateNumbers(this.$t("general.settings"), this.settingsData);
},
themeNumbers() {
return this.calculateNumbers(this.$t("general.themes"), this.themeData);
},
userNumbers() {
return this.calculateNumbers(this.$t("user.users"), this.userData);
},
groupNumbers() {
return this.calculateNumbers(this.$t("group.groups"), this.groupData);
},
pageNumbers() {
return this.calculateNumbers(this.$t("settings.pages"), this.pageData);
},
allNumbers() {
return [
this.recipeNumbers,
this.themeNumbers,
this.settingsNumbers,
this.pageNumbers,
this.userNumbers,
this.groupNumbers,
];
},
allTables() {
return [this.recipeData, this.themeData, this.settingsData, this.pageData, this.userData, this.groupData];
},
},
methods: {
calculateNumbers(title, list_array) {
if (!list_array) return;
let numbers = { title: title, success: 0, failure: 0 };
list_array.forEach(element => {
if (element.status) {
numbers.success++;
} else numbers.failure++;
});
return numbers;
},
open(importData) {
this.recipeData = importData.recipeImports;
this.themeData = importData.themeImports;
this.settingsData = importData.settingsImports;
this.userData = importData.userImports;
this.groupData = importData.groupImports;
this.pageData = importData.pageImports;
this.dialog = true;
},
},
};
</script>
<style></style>

View file

@ -1,28 +0,0 @@
<template>
<div class="text-center">
<v-dialog v-model="dialog" width="500px">
<LoginForm @logged-in="dialog = false" />
</v-dialog>
</div>
</template>
<script>
import LoginForm from "./LoginForm";
export default {
components: {
LoginForm,
},
data() {
return {
dialog: false,
};
},
methods: {
open() {
this.dialog = true;
},
},
};
</script>
<style></style>

View file

@ -1,94 +0,0 @@
<template>
<v-card width="500px">
<v-divider></v-divider>
<v-app-bar dark color="primary" class="mt-n1 mb-2">
<v-icon large left v-if="!loading">
{{ $globals.icons.user }}
</v-icon>
<v-progress-circular v-else indeterminate color="white" large class="mr-2"> </v-progress-circular>
<v-toolbar-title class="headline">{{ $t("user.login") }}</v-toolbar-title>
<v-spacer></v-spacer>
</v-app-bar>
<v-form @submit.prevent="login">
<v-card-text>
<v-text-field
v-model="user.email"
:prepend-icon="$globals.icons.email"
validate-on-blur
autocomplete
autofocus
:label="`${$t('user.email')} or ${$t('user.username')} `"
type="email"
></v-text-field>
<v-text-field
v-model="user.password"
class="mb-2s"
autocomplete
:prepend-icon="$globals.icons.lock"
:label="$t('user.password')"
:type="showPassword ? 'text' : 'password'"
:append-icon="showPassword ? $globals.icons.eye : $globals.icons.eyeOff"
@click:append="showPassword = !showPassword"
></v-text-field>
<v-card-actions>
<v-btn v-if="options.isLoggingIn" color="primary" block large type="submit">{{ $t("user.sign-in") }} </v-btn>
</v-card-actions>
<v-alert v-if="error" class="mt-3 mb-0" type="error">
{{ $t("user.could-not-validate-credentials") }}
</v-alert>
</v-card-text>
</v-form>
</v-card>
</template>
<script>
import { api } from "@/api";
export default {
props: {},
data() {
return {
loading: false,
error: false,
showLogin: false,
showPassword: false,
user: {
email: "",
password: "",
},
options: {
isLoggingIn: true,
},
};
},
mounted() {
this.clear();
},
methods: {
clear() {
this.user = { email: "", password: "" };
},
async login() {
this.loading = true;
this.error = false;
let formData = new FormData();
formData.append("username", this.user.email);
formData.append("password", this.user.password);
const response = await api.users.login(formData);
if (!response) {
this.error = true;
} else {
this.clear();
this.$store.commit("setToken", response.data.access_token);
this.$emit("logged-in");
this.$store.dispatch("requestUserData");
}
this.loading = false;
},
},
};
</script>
<style></style>

View file

@ -1,141 +0,0 @@
<template>
<v-card width="500px">
<v-divider></v-divider>
<v-app-bar dark color="primary" class="mt-n1">
<v-icon large left v-if="!loading">
{{ $globals.icons.user }}
</v-icon>
<v-progress-circular v-else indeterminate color="white" large class="mr-2"> </v-progress-circular>
<v-toolbar-title class="headline">
{{ $t("signup.sign-up") }}
</v-toolbar-title>
<v-spacer></v-spacer>
</v-app-bar>
<v-card-text>
{{ $t("signup.welcome-to-mealie") }}
<v-divider class="mt-3"></v-divider>
<v-form ref="signUpForm" @submit.prevent="signUp">
<v-text-field
v-model="user.name"
light="light"
:prepend-icon="$globals.icons.user"
validate-on-blur
:rules="[existsRule]"
:label="$t('user.full-name')"
></v-text-field>
<v-text-field
v-model="user.username"
light="light"
:prepend-icon="$globals.icons.user"
validate-on-blur
:rules="[existsRule]"
:label="$t('user.username')"
></v-text-field>
<v-text-field
v-model="user.email"
light="light"
:prepend-icon="$globals.icons.email"
validate-on-blur
:rules="[existsRule, emailRule]"
:label="$t('user.email')"
type="email"
></v-text-field>
<v-text-field
v-model="user.password"
light="light"
class="mb-2s"
:prepend-icon="$globals.icons.lock"
validate-on-blur
:label="$t('user.password')"
:type="showPassword ? 'text' : 'password'"
:rules="[minRule]"
></v-text-field>
<v-text-field
v-model="user.passwordConfirm"
light="light"
class="mb-2s"
:prepend-icon="$globals.icons.lock"
:label="$t('user.password')"
:type="showPassword ? 'text' : 'password'"
:append-icon="showPassword ? $globals.icons.eye : $globals.icons.eyeOff"
:rules="[user.password === user.passwordConfirm || $t('user.password-must-match')]"
@click:append="showPassword = !showPassword"
></v-text-field>
<v-card-actions>
<v-btn v-if="options.isLoggingIn" dark color="primary" block="block" type="submit">
{{ $t("signup.sign-up") }}
</v-btn>
</v-card-actions>
<v-alert dense v-if="error" outlined class="mt-3 mb-0" type="error">
{{ $t("signup.error-signing-up") }}
</v-alert>
</v-form>
</v-card-text>
</v-card>
</template>
<script>
import { api } from "@/api";
import { validators } from "@/mixins/validators";
export default {
mixins: [validators],
data() {
return {
loading: false,
error: false,
showPassword: false,
user: {
name: "",
email: "",
password: "",
passwordConfirm: "",
},
options: {
isLoggingIn: true,
},
};
},
mounted() {
this.clear();
},
computed: {
token() {
return this.$route.params.token;
},
},
methods: {
clear() {
this.user = {
name: "",
email: "",
password: "",
passwordConfirm: "",
};
},
async signUp() {
this.loading = true;
this.error = false;
const userData = {
fullName: this.user.name,
username: this.user.username,
email: this.user.email,
group: "default",
password: this.user.password,
admin: false,
};
if (this.$refs.signUpForm.validate()) {
if (await api.signUps.createUser(this.token, userData)) {
this.$emit("user-created");
this.$router.push("/");
}
}
this.loading = false;
},
},
};
</script>
<style></style>

View file

@ -1,100 +0,0 @@
<template>
<div class="text-center">
<v-dialog v-model="dialog" width="650">
<v-card>
<v-card-title class="headline">
{{ $t("shopping-list.shopping-list") }}
<v-spacer></v-spacer>
<v-btn text color="accent" @click="group = !group">
{{ $t("meal-plan.group") }}
</v-btn>
</v-card-title>
<v-divider></v-divider>
<v-card-text v-if="group == false">
<v-list dense v-for="(recipe, index) in ingredients" :key="`${index}-recipe`">
<v-subheader>{{ recipe.name }} </v-subheader>
<v-divider></v-divider>
<v-list-item-group color="primary">
<v-list-item v-for="(item, i) in recipe.recipe_ingredient" :key="i">
<v-list-item-content>
<v-list-item-title v-text="item"></v-list-item-title>
</v-list-item-content>
</v-list-item>
</v-list-item-group>
</v-list>
</v-card-text>
<v-card-text v-else>
<v-list dense>
<v-list-item-group color="primary">
<v-list-item v-for="(item, i) in rawIngredients" :key="i">
<v-list-item-content>
<v-list-item-title v-text="item"></v-list-item-title>
</v-list-item-content>
</v-list-item>
</v-list-item-group>
</v-list>
</v-card-text>
<v-divider></v-divider>
</v-card>
</v-dialog>
</div>
</template>
<script>
import { api } from "@/api";
const levenshtein = require("fast-levenshtein");
export default {
data() {
return {
dialog: false,
planID: 0,
ingredients: [],
rawIngredients: [],
group: false,
};
},
methods: {
openDialog: function(id) {
this.dialog = true;
this.planID = id;
this.getIngredients();
},
async getIngredients() {
this.ingredients = await api.mealPlans.shoppingList(this.planID);
this.getRawIngredients();
},
getRawIngredients() {
this.rawIngredients = [];
this.ingredients.forEach(element => {
this.rawIngredients.push(element.recipe_ingredient);
});
this.rawIngredients = this.rawIngredients.flat();
this.rawIngredients = this.levenshteinFilter(this.rawIngredients);
},
levenshteinFilter(source, maximum = 5) {
let _source, matches, x, y;
_source = source.slice();
matches = [];
for (x = _source.length - 1; x >= 0; x--) {
let output = _source.splice(x, 1);
for (y = _source.length - 1; y >= 0; y--) {
if (levenshtein.get(output[0], _source[y]) <= maximum) {
output.push(_source[y]);
_source.splice(y, 1);
x--;
}
}
matches.push(output);
}
return matches.flat();
},
},
};
</script>
<style></style>

View file

@ -1,163 +0,0 @@
<template>
<v-btn
:color="btnAttrs.color"
:small="small"
:x-small="xSmall"
:loading="loading"
:disabled="disabled"
@click="$emit('click')"
:outlined="btnStyle.outlined"
:text="btnStyle.text"
>
<v-icon left>
<slot name="icon">
{{ btnAttrs.icon }}
</slot>
</v-icon>
<slot>
{{ btnAttrs.text }}
</slot>
</v-btn>
</template>
<script>
export default {
name: "TheButton",
props: {
// Types
cancel: {
type: Boolean,
default: false,
},
create: {
type: Boolean,
default: false,
},
update: {
type: Boolean,
default: false,
},
edit: {
type: Boolean,
default: false,
},
delete: {
type: Boolean,
default: false,
},
// Property
loading: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
// Styles
small: {
type: Boolean,
default: false,
},
xSmall: {
type: Boolean,
default: false,
},
secondary: {
type: Boolean,
default: false,
},
minor: {
type: Boolean,
default: false,
},
},
data() {
return {
buttonOptions: {
create: {
text: this.$t("general.create"),
icon: this.$globals.icons.create,
color: "success",
},
update: {
text: this.$t("general.update"),
icon: this.$globals.icons.update,
color: "success",
},
save: {
text: this.$t("general.save"),
icon: this.$globals.icons.save,
color: "success",
},
edit: {
text: this.$t("general.edit"),
icon: this.$globals.icons.edit,
color: "info",
},
delete: {
text: this.$t("general.delete"),
icon: this.$globals.icons.delete,
color: "error",
},
cancel: {
text: this.$t("general.cancel"),
icon: this.$globals.icons.close,
color: "grey",
},
},
buttonStyles: {
defaults: {
text: false,
outlined: false,
},
secondary: {
text: false,
outlined: true,
},
minor: {
outlined: false,
text: true,
},
},
};
},
computed: {
btnAttrs() {
if (this.delete) {
return this.buttonOptions.delete;
} else if (this.update) {
return this.buttonOptions.update;
} else if (this.edit) {
return this.buttonOptions.edit;
} else if (this.cancel) {
this.setMinor();
return this.buttonOptions.cancel;
} else if (this.save) {
return this.buttonOptions.save;
}
return this.buttonOptions.create;
},
btnStyle() {
if (this.secondary) {
return this.buttonStyles.secondary;
} else if (this.minor) {
return this.buttonStyles.minor;
}
return this.buttonStyles.defaults;
},
},
methods: {
setMinor() {
this.buttonStyles.defaults = this.buttonStyles.minor;
},
setSecondary() {
this.buttonStyles.defaults = this.buttonStyles.secondary;
},
},
};
</script>
<style lang="scss" scoped>
</style>

View file

@ -1,71 +0,0 @@
<template>
<v-tooltip
ref="copyToolTip"
v-model="show"
color="success lighten-1"
top
:open-on-hover="false"
:open-on-click="true"
close-delay="500"
transition="slide-y-transition"
>
<template v-slot:activator="{ on }">
<v-btn
icon
:color="color"
@click="
on.click;
textToClipboard();
"
@blur="on.blur"
retain-focus-on-click
>
<v-icon>{{ $globals.icons.contentCopy }}</v-icon>
</v-btn>
</template>
<span>
<v-icon left dark>
{{ $globals.icons.clipboardCheck }}
</v-icon>
<slot> {{ $t("general.copied") }}! </slot>
</span>
</v-tooltip>
</template>
<script>
export default {
props: {
copyText: {
default: "Default Copy Text",
},
color: {
default: "primary",
},
},
data() {
return {
show: false,
};
},
methods: {
toggleBlur() {
this.$refs.copyToolTip.deactivate();
},
textToClipboard() {
this.show = true;
const copyText = this.copyText;
navigator.clipboard.writeText(copyText).then(
() => console.log("Copied", copyText),
() => console.log("Copied Failed", copyText)
);
setTimeout(() => {
this.toggleBlur();
}, 500);
},
},
};
</script>
<style lang="scss" scoped>
</style>

View file

@ -1,54 +0,0 @@
<template>
<div>
<slot v-bind="{ downloading, downloadFile }">
<v-btn color="accent" text :loading="downloading" @click="downloadFile">
{{ showButtonText }}
</v-btn>
</slot>
</div>
</template>
<script>
/**
* The download button used for the entire site
* pass a URL to the endpoint that will return a
* file_token which will then be used to request the file
* from the server and open that link in a new tab
*/
import { apiReq } from "@/api/api-utils";
export default {
props: {
/**
* URL to get token from
*/
downloadUrl: {
default: "",
},
/**
* Override button text. Defaults to "Download"
*/
buttonText: {
default: null,
},
},
data() {
return {
downloading: false,
};
},
computed: {
showButtonText() {
return this.buttonText || this.$t("general.download");
},
},
methods: {
async downloadFile() {
this.downloading = true;
await apiReq.download(this.downloadUrl);
this.downloading = false;
},
},
};
</script>
<style lang="scss" scoped></style>

View file

@ -1,89 +0,0 @@
<template>
<v-form ref="file">
<input ref="uploader" class="d-none" type="file" @change="onFileChanged" />
<slot v-bind="{ isSelecting, onButtonClick }">
<v-btn :loading="isSelecting" @click="onButtonClick" :small="small" color="accent" :text="textBtn">
<v-icon left> {{ effIcon }}</v-icon>
{{ text ? text : defaultText }}
</v-btn>
</slot>
</v-form>
</template>
<script>
const UPLOAD_EVENT = "uploaded";
import { api } from "@/api";
export default {
props: {
small: {
default: false,
},
post: {
type: Boolean,
default: true,
},
url: String,
text: String,
icon: { default: null },
fileName: { default: "archive" },
textBtn: {
default: true,
},
},
data: () => ({
file: null,
isSelecting: false,
}),
computed: {
effIcon() {
return this.icon ? this.icon : this.$globals.icons.upload;
},
defaultText() {
return this.$t("general.upload");
},
},
methods: {
async upload() {
if (this.file != null) {
this.isSelecting = true;
if (!this.post) {
this.$emit(UPLOAD_EVENT, this.file);
this.isSelecting = false;
return;
}
let formData = new FormData();
formData.append(this.fileName, this.file);
const response = await api.utils.uploadFile(this.url, formData);
if (response) {
this.$emit(UPLOAD_EVENT, response);
}
this.isSelecting = false;
}
},
onButtonClick() {
this.isSelecting = true;
window.addEventListener(
"focus",
() => {
this.isSelecting = false;
},
{ once: true }
);
this.$refs.uploader.click();
},
onFileChanged(e) {
this.file = e.target.files[0];
this.upload();
},
},
};
</script>
<style></style>

View file

@ -1,146 +0,0 @@
<template>
<div>
<BaseDialog
:title="$t('settings.backup.create-heading')"
:titleIcon="$globals.icons.database"
@submit="createBackup"
:submit-text="$t('general.create')"
:loading="loading"
>
<template v-slot:open="{ open }">
<v-btn @click="open" class="mx-2" small :color="color">
<v-icon left> {{ $globals.icons.create }} </v-icon> {{ $t("general.custom") }}
</v-btn>
</template>
<v-card-text class="mt-6">
<v-text-field dense :label="$t('settings.backup.backup-tag')" v-model="tag"></v-text-field>
</v-card-text>
<v-card-actions class="mt-n9 flex-wrap">
<v-switch v-model="fullBackup" :label="switchLabel"></v-switch>
<v-spacer></v-spacer>
</v-card-actions>
<v-expand-transition>
<div v-if="!fullBackup">
<v-card-text class="mt-n4">
<v-row>
<v-col sm="4">
<p>{{ $t("general.options") }}</p>
<ImportOptions @update-options="updateOptions" class="mt-5" />
</v-col>
<v-col>
<p>{{ $t("general.templates") }}</p>
<v-checkbox
v-for="template in availableTemplates"
:key="template"
class="mb-n4 mt-n3"
dense
:label="template"
@click="appendTemplate(template)"
></v-checkbox>
</v-col>
</v-row>
</v-card-text>
</div>
</v-expand-transition>
</BaseDialog>
</div>
</template>
<script>
import BaseDialog from "./BaseDialog";
import ImportOptions from "@/components/FormHelpers/ImportOptions";
import { api } from "@/api";
export default {
props: {
color: { default: "primary" },
},
components: {
BaseDialog,
ImportOptions,
},
data() {
return {
tag: null,
fullBackup: true,
loading: false,
options: {
recipes: true,
settings: true,
themes: true,
pages: true,
users: true,
groups: true,
},
availableTemplates: [],
selectedTemplates: [],
};
},
computed: {
switchLabel() {
if (this.fullBackup) {
return this.$t("settings.backup.full-backup");
} else return this.$t("settings.backup.partial-backup");
},
},
created() {
this.resetData();
this.getAvailableBackups();
},
methods: {
resetData() {
this.tag = null;
this.fullBackup = true;
this.loading = false;
this.options = {
recipes: true,
settings: true,
themes: true,
pages: true,
users: true,
groups: true,
notifications: true,
};
this.availableTemplates = [];
this.selectedTemplates = [];
},
updateOptions(options) {
this.options = options;
},
async getAvailableBackups() {
const response = await api.backups.requestAvailable();
response.templates.forEach(element => {
this.availableTemplates.push(element);
});
},
async createBackup() {
this.loading = true;
const data = {
tag: this.tag,
options: {
recipes: this.options.recipes,
settings: this.options.settings,
pages: this.options.pages,
themes: this.options.themes,
users: this.options.users,
groups: this.options.groups,
notifications: this.options.notifications,
},
templates: this.selectedTemplates,
};
if (await api.backups.create(data)) {
this.$emit("created");
}
this.loading = false;
},
appendTemplate(templateName) {
if (this.selectedTemplates.includes(templateName)) {
let index = this.selectedTemplates.indexOf(templateName);
if (index !== -1) {
this.selectedTemplates.splice(index, 1);
}
} else this.selectedTemplates.push(templateName);
},
},
};
</script>

View file

@ -1,119 +0,0 @@
<template>
<div class="text-center">
<BaseDialog
:title="name"
:titleIcon="$globals.icons.database"
:submit-text="$t('general.import')"
:loading="loading"
ref="baseDialog"
@submit="raiseEvent"
>
<v-card-subtitle class="mb-n3 mt-3" v-if="date"> {{ $d(new Date(date), "medium") }} </v-card-subtitle>
<v-divider></v-divider>
<v-card-text>
<ImportOptions @update-options="updateOptions" class="mt-5 mb-2" />
<v-divider></v-divider>
<v-checkbox
dense
:label="$t('settings.remove-existing-entries-matching-imported-entries')"
v-model="forceImport"
></v-checkbox>
</v-card-text>
<v-divider></v-divider>
<template v-slot:extra-buttons>
<TheDownloadBtn :download-url="downloadUrl">
<template v-slot:default="{ downloadFile }">
<v-btn class="mr-1" color="info" @click="downloadFile">
<v-icon left> {{ $globals.icons.download }}</v-icon>
{{ $t("general.download") }}
</v-btn>
</template>
</TheDownloadBtn>
</template>
</BaseDialog>
</div>
</template>
<script>
const IMPORT_EVENT = "import";
import { api } from "@/api";
import BaseDialog from "./BaseDialog";
import ImportOptions from "@/components/FormHelpers/ImportOptions";
import TheDownloadBtn from "@/components/UI/Buttons/TheDownloadBtn.vue";
import { API_ROUTES } from "@/api/apiRoutes";
export default {
components: { ImportOptions, TheDownloadBtn, BaseDialog },
props: {
name: {
default: "Backup Name",
},
date: {
default: "Backup Date",
},
},
data() {
return {
loading: false,
options: {
recipes: true,
settings: true,
themes: true,
users: true,
groups: true,
},
dialog: false,
forceImport: false,
rebaseImport: false,
downloading: false,
};
},
computed: {
downloadUrl() {
return API_ROUTES.backupsFileNameDownload(this.name);
},
},
methods: {
updateOptions(options) {
this.options = options;
},
open() {
this.dialog = true;
this.$refs.baseDialog.open();
},
close() {
this.dialog = false;
},
async raiseEvent() {
const eventData = {
name: this.name,
force: this.forceImport,
rebase: this.rebaseImport,
recipes: this.options.recipes,
settings: this.options.settings,
themes: this.options.themes,
users: this.options.users,
groups: this.options.groups,
notifications: this.options.notifications,
};
this.loading = true;
const importData = await this.importBackup(eventData);
this.$emit(IMPORT_EVENT, importData);
this.loading = false;
},
async importBackup(data) {
this.loading = true;
const response = await api.backups.import(data.name, data);
if (response) {
return response.data;
}
},
},
};
</script>
<style></style>

View file

@ -1,172 +0,0 @@
<template>
<div>
<slot v-bind="{ open, close }"> </slot>
<v-dialog
v-model="dialog"
:width="isMobile ? undefined : '65%'"
:height="isMobile ? undefined : '0'"
:fullscreen="isMobile"
content-class="top-dialog"
:scrollable="false"
>
<v-app-bar sticky dark color="primary lighten-1" :rounded="!isMobile">
<FuseSearchBar :raw-data="allItems" @results="filterItems" :search="searchString">
<v-text-field
id="arrow-search"
autofocus
v-model="searchString"
solo
flat
autocomplete="off"
background-color="primary lighten-1"
color="white"
dense
:clearable="!isMobile"
class="mx-2 arrow-search"
hide-details
single-line
:placeholder="$t('search.search')"
:prepend-inner-icon="$globals.icons.search"
>
</v-text-field>
</FuseSearchBar>
<v-btn v-if="isMobile" x-small fab light @click="dialog = false">
<v-icon>
{{ $globals.icons.close }}
</v-icon>
</v-btn>
</v-app-bar>
<v-card class="mt-1 pa-1" relative>
<v-card-actions>
<div class="mr-auto">
{{ $t("search.results") }}
</div>
<router-link to="/search"> {{ $t("search.advanced-search") }} </router-link>
</v-card-actions>
<v-card-actions v-if="loading">
<SiteLoader :loading="loading" />
</v-card-actions>
<div v-else>
<MobileRecipeCard
v-for="(recipe, index) in results.slice(0, 10)"
:tabindex="index"
:key="index"
class="ma-1 arrow-nav"
:name="recipe.name"
:description="recipe.description"
:slug="recipe.slug"
:rating="recipe.rating"
:image="recipe.image"
:route="true"
v-on="$listeners.selected ? { selected: () => grabRecipe(recipe) } : {}"
/>
</div>
</v-card>
</v-dialog>
</div>
</template>
<script>
import SiteLoader from "@/components/UI/SiteLoader";
const SELECTED_EVENT = "selected";
import FuseSearchBar from "@/components/UI/Search/FuseSearchBar";
import MobileRecipeCard from "@/components/Recipe/MobileRecipeCard";
export default {
components: {
FuseSearchBar,
MobileRecipeCard,
SiteLoader,
},
data() {
return {
loading: false,
selectedIndex: -1,
dialog: false,
searchString: "",
searchResults: [],
};
},
watch: {
$route() {
this.dialog = false;
},
async dialog(val) {
if (!val) {
this.resetSelected();
} else if (this.allItems.length <= 0) {
this.loading = true;
await this.$store.dispatch("requestAllRecipes");
this.loading = false;
}
},
},
mounted() {
document.addEventListener("keydown", this.onUpDown);
},
beforeDestroy() {
document.removeEventListener("keydown", this.onUpDown);
},
computed: {
isMobile() {
return this.$vuetify.breakpoint.name === "xs";
},
allItems() {
return this.$store.getters.getAllRecipes;
},
results() {
if (this.searchString != null && this.searchString.length >= 1) {
return this.searchResults;
}
return this.allItems;
},
},
methods: {
open() {
this.dialog = true;
},
close() {
this.dialog = false;
},
filterItems(val) {
this.searchResults = val.map(x => x.item);
},
grabRecipe(recipe) {
this.dialog = false;
this.$emit(SELECTED_EVENT, recipe);
},
onUpDown(e) {
if (e.keyCode === 38) {
e.preventDefault();
this.selectedIndex--;
} else if (e.keyCode === 40) {
e.preventDefault();
this.selectedIndex++;
} else {
return;
}
this.selectRecipe();
},
resetSelected() {
this.searchString = "";
this.selectedIndex = -1;
document.getElementsByClassName("arrow-nav")[0].focus();
},
selectRecipe() {
const recipeCards = document.getElementsByClassName("arrow-nav");
if (recipeCards) {
if (this.selectedIndex < 0) {
this.selectedIndex = -1;
document.getElementById("arrow-search").focus();
return;
}
this.selectedIndex >= recipeCards.length ? (this.selectedIndex = recipeCards.length - 1) : null;
recipeCards[this.selectedIndex].focus();
}
},
},
};
</script>
<style >
</style>

View file

@ -1,46 +0,0 @@
<template>
<div class="text-center ma-2">
<v-snackbar v-model="snackbar.open" top :color="snackbar.color" timeout="3500">
<v-icon dark left>
{{ icon }}
</v-icon>
{{ snackbar.title }}
{{ snackbar.text }}
<template v-slot:action="{ attrs }">
<v-btn text v-bind="attrs" @click="snackbar.open = false">
{{ $t("general.close") }}
</v-btn>
</template>
</v-snackbar>
</div>
</template>
<script>
export default {
data: () => ({}),
computed: {
snackbar: {
set(val) {
this.$store.commit("setSnackbar", val);
},
get() {
return this.$store.getters.getSnackbar;
},
},
icon() {
switch (this.snackbar.color) {
case "error":
return this.$globals.icons.alert;
case "success":
return this.$globals.icons.checkboxMarkedCircle;
case "info":
return this.$globals.icons.information;
default:
return this.$globals.icons.bellAlert;
}
},
},
};
</script>

View file

@ -1,81 +0,0 @@
<template>
<div class="mt-2">
<v-card>
<v-card-title class="headline">
Log
<v-spacer></v-spacer>
<v-text-field
class="ml-auto shrink mb-n7"
solo
:label="$t('about.log-lines')"
type="number"
:append-icon="$globals.icons.refreshCircle"
v-model="lines"
@click:append="getLogText"
suffix="lines"
single-line
>
</v-text-field>
<TheDownloadBtn :button-text="$t('about.download-log')" download-url="/api/debug/log">
<template v-slot:default="{ downloadFile }">
<v-btn bottom right relative fab icon color="primary" @click="downloadFile">
<v-icon> {{ $globals.icons.download }}</v-icon>
</v-btn>
</template>
</TheDownloadBtn>
</v-card-title>
<v-divider></v-divider>
<v-card-text>
<div v-for="(item, index) in splitText" :key="index" :class="getClass(item)">
{{ item }}
</div>
</v-card-text>
</v-card>
</div>
</template>
<script>
import TheDownloadBtn from "@/components/UI/Buttons/TheDownloadBtn";
import { api } from "@/api";
export default {
components: { TheDownloadBtn },
data() {
return {
lines: 200,
text: "",
};
},
mounted() {
this.getLogText();
},
computed: {
splitText() {
return this.text.split("/n");
},
},
methods: {
async getLogText() {
this.text = await api.meta.getLogText(this.lines);
},
getClass(text) {
const isError = text.includes("ERROR:");
if (isError) {
return "log--error";
}
},
},
};
</script>
<style scoped>
.log-text {
background-color: #e0e0e077;
}
.log--error {
color: #ef5350;
}
.line-number {
color: black;
font-weight: bold;
}
</style>

View file

@ -1,80 +0,0 @@
<template>
<div>
<slot> </slot>
</div>
</template>
<script>
const RESULTS_EVENT = "results";
import Fuse from "fuse.js";
export default {
props: {
search: {
default: "",
},
rawData: {
default: true,
},
/** Defaults to Show All Results */
showAll: {
default: true,
},
keys: {
type: Array,
default: () => ["name"],
},
defaultOptions: {
default: () => ({
shouldSort: true,
threshold: 0.6,
location: 0,
distance: 100,
findAllMatches: true,
maxPatternLength: 32,
minMatchCharLength: 2,
}),
},
},
data() {
return {
results: [],
fuseResults: [],
};
},
computed: {
options() {
return { ...this.defaultOptions, ...{ keys: this.keys } };
},
autoResults() {
return this.fuseResults.length > 1 ? this.fuseResults : this.results;
},
fuse() {
return new Fuse(this.rawData, this.options);
},
isSearching() {
return this.search && this.search.length > 0;
},
},
watch: {
search() {
try {
this.results = this.fuse.search(this.search.trim());
} catch {
this.results = this.rawData.map(x => ({ item: x })).sort((a, b) => (a.name > b.name ? 1 : -1));
}
this.$emit(RESULTS_EVENT, this.results);
if (this.showResults === true) {
this.fuseResults = this.results;
}
},
},
};
</script>
<style scoped>
div {
width: 100%;
}
</style>

View file

@ -1,65 +0,0 @@
<template>
<SearchDialog ref="searchDialog">
<template v-slot="{ open }">
<v-text-field
readonly
@click="open"
ref="searchInput"
class="my-auto mt-5 pt-1"
dense
light
dark
flat
:placeholder="$t('search.search-mealie')"
background-color="primary lighten-1"
color="white"
solo=""
:style="`max-width: 450;`"
>
<template #prepend-inner>
<v-icon color="grey lighten-3" size="29">
{{ $globals.icons.search }}
</v-icon>
</template>
</v-text-field>
</template>
</SearchDialog>
</template>
<script>
import SearchDialog from "@/components/UI/Dialogs/SearchDialog";
export default {
components: {
SearchDialog,
},
mounted() {
document.addEventListener("keydown", this.onDocumentKeydown);
},
beforeDestroy() {
document.removeEventListener("keydown", this.onDocumentKeydown);
},
methods: {
highlight(string) {
if (!this.search) {
return string;
}
return string.replace(new RegExp(this.search, "gi"), match => `<mark>${match}</mark>`);
},
onDocumentKeydown(e) {
if (
e.key === "/" &&
e.target !== this.$refs.searchInput.$refs.input &&
!document.activeElement.id.startsWith("input")
) {
e.preventDefault();
this.$refs.searchDialog.open();
}
},
},
};
</script>

View file

@ -1,103 +0,0 @@
<template>
<div class="text-center ">
<v-dialog
v-model="dialog"
:width="isMobile ? undefined : '600'"
:height="isMobile ? undefined : '0'"
:fullscreen="isMobile"
content-class="top-dialog"
>
<v-card relative>
<v-app-bar dark color="primary lighten-1" rounded="0">
<SearchBar
ref="mealSearchBar"
@results="updateResults"
@selected="emitSelect"
:show-results="!isMobile"
:dense="false"
:nav-on-click="false"
:autofocus="true"
/>
</v-app-bar>
<v-card-text v-if="isMobile">
<div v-for="recipe in searchResults.slice(0, 7)" :key="recipe.name">
<MobileRecipeCard
class="ma-1 px-0"
:name="recipe.item.name"
:description="recipe.item.description"
:slug="recipe.item.slug"
:rating="recipe.item.rating"
:image="recipe.item.image"
:route="true"
@selected="dialog = false"
/>
</div>
</v-card-text>
<v-btn v-if="isMobile" fab bottom @click="dialog = false" class="ma-2">
<v-icon> {{ $globals.icons.close }} </v-icon>
</v-btn>
</v-card>
</v-dialog>
</div>
</template>
<script>
import SearchBar from "./SearchBar";
import MobileRecipeCard from "@/components/Recipe/MobileRecipeCard";
export default {
components: {
SearchBar,
MobileRecipeCard,
},
data() {
return {
searchResults: [],
dialog: false,
};
},
computed: {
isMobile() {
return this.$vuetify.breakpoint.name === "xs";
},
},
watch: {
"$route.hash"(newHash, oldHash) {
if (newHash === "#mobile-search") {
this.dialog = true;
} else if (oldHash === "#mobile-search") {
this.dialog = false;
}
},
},
methods: {
updateResults(results) {
this.searchResults = results;
},
emitSelect(slug, name) {
this.$emit("select", name, slug);
this.dialog = false;
},
open() {
this.dialog = true;
},
toggleDialog(open) {
if (open) {
this.$router.push("#search");
} else {
this.$router.back(); // 😎 back button click
}
},
},
};
</script>
<style scope>
.mobile-dialog {
align-items: flex-start;
justify-content: flex-start;
}
.top-dialog {
align-self: flex-start;
}
</style>

View file

@ -1,103 +0,0 @@
w<template>
<v-card v-bind="$attrs" :class="classes" class="v-card--material pa-3">
<div class="d-flex grow flex-wrap">
<slot name="avatar">
<v-sheet
:color="color"
:max-height="icon ? 90 : undefined"
:width="icon ? 'auto' : '100%'"
elevation="6"
class="text-start v-card--material__heading mb-n6 mt-n10 pa-7"
dark
>
<v-icon v-if="icon" size="40" v-text="icon" />
<div v-if="text" class="headline font-weight-thin" v-text="text" />
</v-sheet>
</slot>
<div v-if="$slots['after-heading']" class="ml-auto">
<slot name="after-heading" />
</div>
</div>
<slot />
<template v-if="$slots.actions">
<v-divider class="mt-2" />
<v-card-actions class="pb-0">
<slot name="actions" />
</v-card-actions>
</template>
<template v-if="$slots.bottom">
<v-divider class="mt-2" v-if="!$slots.actions" />
<div class="pb-0">
<slot name="bottom" />
</div>
</template>
</v-card>
</template>
<script>
export default {
name: "MaterialCard",
props: {
avatar: {
type: String,
default: "",
},
color: {
type: String,
default: "primary",
},
icon: {
type: String,
default: undefined,
},
image: {
type: Boolean,
default: false,
},
text: {
type: String,
default: "",
},
title: {
type: String,
default: "",
},
},
computed: {
classes() {
return {
"v-card--material--has-heading": this.hasHeading,
"mt-3": this.$vuetify.breakpoint.name == "xs" || this.$vuetify.breakpoint.name == "sm"
};
},
hasHeading() {
return false;
},
hasAltHeading() {
return false;
},
},
};
</script>
<style lang="sass">
.v-card--material
&__avatar
position: relative
top: -64px
margin-bottom: -32px
&__heading
position: relative
top: -40px
transition: .3s ease
z-index: 1
</style>

View file

@ -1,102 +0,0 @@
<template>
<div>
<TheSidebar ref="theSidebar" />
<v-app-bar clipped-left dense app color="primary" dark class="d-print-none" :bottom="isMobile">
<v-btn icon @click="openSidebar">
<v-icon> {{ $globals.icons.menu }}</v-icon>
</v-btn>
<router-link to="/">
<v-btn icon>
<v-icon size="40"> {{ $globals.icons.primary }} </v-icon>
</v-btn>
</router-link>
<div v-if="!isMobile" btn class="pl-2">
<v-toolbar-title style="cursor: pointer" @click="$router.push('/')">
Mealie
</v-toolbar-title>
</div>
<v-spacer></v-spacer>
<v-tooltip bottom>
<template v-slot:activator="{ on, attrs }">
<v-btn icon class="mr-1" small v-bind="attrs" v-on="on" @click="isDark = !isDark">
<v-icon v-text="isDark ? $globals.icons.weatherSunny : $globals.icons.weatherNight"> </v-icon>
</v-btn>
</template>
<span>{{ isDark ? $t("settings.theme.switch-to-light-mode") : $t("settings.theme.switch-to-dark-mode") }}</span>
</v-tooltip>
<div v-if="!isMobile" style="width: 350px;">
<SearchBar :show-results="true" @selected="navigateFromSearch" :max-width="isMobile ? '100%' : '450px'" />
</div>
<div v-else>
<v-btn icon @click="$refs.recipeSearch.open()">
<v-icon> {{ $globals.icons.search }} </v-icon>
</v-btn>
<SearchDialog ref="recipeSearch" />
</div>
<TheSiteMenu />
<v-slide-x-reverse-transition>
<TheRecipeFab v-if="loggedIn && isMobile" />
</v-slide-x-reverse-transition>
</v-app-bar>
<v-slide-x-reverse-transition>
<TheRecipeFab v-if="loggedIn && !isMobile" :absolute="true" />
</v-slide-x-reverse-transition>
</div>
</template>
<script>
import TheSiteMenu from "@/components/UI/TheSiteMenu";
import SearchBar from "@/components/UI/Search/SearchBar";
import SearchDialog from "@/components/UI/Dialogs/SearchDialog";
import TheRecipeFab from "@/components/UI/TheRecipeFab";
import TheSidebar from "@/components/UI/TheSidebar";
import { user } from "@/mixins/user";
export default {
name: "AppBar",
mixins: [user],
components: {
SearchDialog,
TheRecipeFab,
TheSidebar,
TheSiteMenu,
SearchBar,
},
data() {
return {
showSidebar: false,
};
},
computed: {
isMobile() {
return this.$vuetify.breakpoint.name === "xs";
},
isDark: {
get() {
return this.$store.getters.getIsDark;
},
set() {
let setVal = "dark";
if (this.isDark) {
setVal = "light";
}
this.$store.commit("setDarkMode", setVal);
},
},
},
methods: {
navigateFromSearch(slug) {
this.$router.push(`/recipe/${slug}`);
},
openSidebar() {
this.$refs.theSidebar.toggleSidebar();
},
},
};
</script>
<style scoped></style>

View file

@ -1,247 +0,0 @@
<template>
<div class="text-center d-print-none">
<v-dialog v-model="addRecipe" width="650" @click:outside="reset">
<v-card :loading="processing">
<v-app-bar dark color="primary mb-2">
<v-icon large left v-if="!processing"> {{ $globals.icons.link }} </v-icon>
<v-progress-circular v-else indeterminate color="white" large class="mr-2"> </v-progress-circular>
<v-toolbar-title class="headline">
{{ $t("new-recipe.from-url") }}
</v-toolbar-title>
<v-spacer></v-spacer>
</v-app-bar>
<v-form ref="urlForm" @submit.prevent="createRecipe">
<v-card-text>
<v-text-field
v-model="recipeURL"
:label="$t('new-recipe.recipe-url')"
required
validate-on-blur
autofocus
class="mt-1"
:rules="[isValidWebUrl]"
:hint="$t('new-recipe.url-form-hint')"
persistent-hint
></v-text-field>
<v-expand-transition>
<v-alert v-if="error" color="error" class="mt-6 white--text">
<v-card-title class="ma-0 pa-0">
<v-icon left color="white" x-large> {{ $globals.icons.robot }} </v-icon>
{{ $t("new-recipe.error-title") }}
</v-card-title>
<v-divider class="my-3 mx-2"></v-divider>
<p>
{{ $t("new-recipe.error-details") }}
</p>
<div class="d-flex row justify-space-around my-3 force-white">
<a
class="dark"
href="https://developers.google.com/search/docs/data-types/recipe"
target="_blank"
rel="noreferrer nofollow"
>
{{ $t("new-recipe.google-ld-json-info") }}
</a>
<a href="https://github.com/hay-kot/mealie/issues" target="_blank" rel="noreferrer nofollow">
{{ $t("new-recipe.github-issues") }}
</a>
<a href="https://schema.org/Recipe" target="_blank" rel="noreferrer nofollow">
{{ $t("new-recipe.recipe-markup-specification") }}
</a>
</div>
<div class="d-flex justify-end">
<v-btn
white
outlined
:to="{ path: '/recipes/debugger', query: { test_url: recipeURL } }"
@click="addRecipe = false"
>
<v-icon left> {{ $globals.icons.externalLink }} </v-icon>
{{ $t("new-recipe.view-scraped-data") }}
</v-btn>
</div>
</v-alert>
</v-expand-transition>
</v-card-text>
<v-divider></v-divider>
<v-card-actions>
<v-btn color="grey" text @click="reset">
<v-icon left> {{ $globals.icons.close }}</v-icon>
{{ $t("general.close") }}
</v-btn>
<v-spacer></v-spacer>
<v-btn color="success" type="submit" :loading="processing">
<v-icon left> {{ $globals.icons.create }} </v-icon>
{{ $t("general.submit") }}
</v-btn>
</v-card-actions>
</v-form>
</v-card>
</v-dialog>
<BaseDialog
:title="$t('new-recipe.upload-a-recipe')"
:titleIcon="$globals.icons.zip"
:submit-text="$t('general.import')"
ref="uploadZipDialog"
@submit="uploadZip"
:loading="processing"
>
<v-card-text class="mt-1 pb-0">
{{ $t("new-recipe.upload-individual-zip-file") }}
<div class="headline mx-auto mb-0 pb-0 text-center">
{{ this.fileName }}
</div>
</v-card-text>
<v-card-actions>
<AppButtonUpload class="mx-auto" :text-btn="false" @uploaded="setFile" :post="false"> </AppButtonUpload>
</v-card-actions>
</BaseDialog>
<v-speed-dial v-model="fab" :open-on-hover="absolute" :fixed="absolute" :bottom="absolute" :right="absolute">
<template v-slot:activator>
<v-btn v-model="fab" :color="absolute ? 'accent' : 'white'" dark :icon="!absolute" :fab="absolute">
<v-icon> {{ $globals.icons.createAlt }} </v-icon>
</v-btn>
</template>
<v-tooltip left dark color="primary">
<template v-slot:activator="{ on, attrs }">
<v-btn fab dark small color="primary" v-bind="attrs" v-on="on" @click="addRecipe = true">
<v-icon>{{ $globals.icons.link }} </v-icon>
</v-btn>
</template>
<span>{{ $t("new-recipe.from-url") }}</span>
</v-tooltip>
<v-tooltip left dark color="accent">
<template v-slot:activator="{ on, attrs }">
<v-btn fab dark small color="accent" v-bind="attrs" v-on="on" @click="$router.push('/new')">
<v-icon>{{ $globals.icons.edit }}</v-icon>
</v-btn>
</template>
<span>{{ $t("general.new") }}</span>
</v-tooltip>
<v-tooltip left dark color="info">
<template v-slot:activator="{ on, attrs }">
<v-btn fab dark small color="info" v-bind="attrs" v-on="on" @click="openZipUploader">
<v-icon>{{ $globals.icons.zip }}</v-icon>
</v-btn>
</template>
<span>{{ $t("general.upload") }}</span>
</v-tooltip>
</v-speed-dial>
</div>
</template>
<script>
import { api } from "@/api";
import AppButtonUpload from "@/components/UI/Buttons/AppButtonUpload.vue";
import BaseDialog from "@/components/UI/Dialogs/BaseDialog.vue";
export default {
components: {
AppButtonUpload,
BaseDialog,
},
props: {
absolute: {
default: false,
},
},
data() {
return {
error: false,
fab: false,
addRecipe: false,
processing: false,
uploadData: {
fileName: "archive",
file: null,
},
};
},
mounted() {
if (this.$route.query.recipe_import_url) {
this.addRecipe = true;
this.createRecipe();
}
},
computed: {
recipeURL: {
set(recipe_import_url) {
this.$router.replace({ query: { ...this.$route.query, recipe_import_url } });
},
get() {
return this.$route.query.recipe_import_url || "";
},
},
fileName() {
return this.uploadData.file?.name || "";
},
},
methods: {
resetVars() {
this.uploadData = {
fileName: "archive",
file: null,
};
},
setFile(file) {
this.uploadData.file = file;
console.log("Uploaded");
},
openZipUploader() {
this.resetVars();
this.$refs.uploadZipDialog.open();
},
async uploadZip() {
let formData = new FormData();
formData.append(this.uploadData.fileName, this.uploadData.file);
const response = await api.utils.uploadFile("/api/recipes/create-from-zip", formData);
this.$router.push(`/recipe/${response.data.slug}`);
},
async createRecipe() {
this.error = false;
if (this.$refs.urlForm === undefined || this.$refs.urlForm.validate()) {
this.processing = true;
const response = await api.recipes.createByURL(this.recipeURL);
this.processing = false;
if (response) {
this.addRecipe = false;
this.recipeURL = "";
this.$router.push(`/recipe/${response.data}`);
} else {
this.error = true;
}
}
},
reset() {
this.fab = false;
this.error = false;
this.addRecipe = false;
this.recipeURL = "";
this.processing = false;
},
isValidWebUrl(url) {
let regEx =
/^https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,256}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)$/gm;
return regEx.test(url) ? true : this.$t("new-recipe.must-be-a-valid-url");
},
},
};
</script>
<style>
.force-white > a {
color: white !important;
}
</style>

View file

@ -1,253 +0,0 @@
<template>
<div class="d-print-none no-print">
<v-navigation-drawer v-model="showSidebar" width="180px" clipped app>
<template v-slot:prepend>
<UserAvatar v-if="isLoggedIn" :user="user" />
<v-list-item dense v-if="isLoggedIn" :to="`/user/${user.id}/favorites`">
<v-list-item-icon>
<v-icon> {{ $globals.icons.heart }} </v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title> {{ $t("general.favorites") }} </v-list-item-title>
</v-list-item-content>
</v-list-item>
</template>
<v-divider></v-divider>
<v-list nav dense>
<v-list-item v-for="nav in effectiveMenu" :key="nav.title" link :to="nav.to">
<v-list-item-icon>
<v-icon>{{ nav.icon }}</v-icon>
</v-list-item-icon>
<v-list-item-title>{{ nav.title }}</v-list-item-title>
</v-list-item>
</v-list>
<!-- Version List Item -->
<v-list nav dense class="fixedBottom" v-if="!isMain">
<v-list-item href="https://github.com/sponsors/hay-kot" target="_target">
<v-list-item-icon>
<v-icon color="pink"> {{ $globals.icons.heart }} </v-icon>
</v-list-item-icon>
<v-list-item-title> {{ $t("about.support") }} </v-list-item-title>
</v-list-item>
<v-list-item to="/admin/about">
<v-list-item-icon class="mr-3 pt-1">
<v-icon :color="newVersionAvailable ? 'red--text' : ''"> {{ $globals.icons.information }} </v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>
{{ $t("settings.current") }}
{{ appVersion }}
</v-list-item-title>
<v-list-item-subtitle>
<a
@click.prevent
href="https://github.com/hay-kot/mealie/releases/latest"
target="_blank"
:class="newVersionAvailable ? 'red--text' : 'green--text'"
>
{{ $t("settings.latest") }}
{{ latestVersion }}
</a>
</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</v-list>
</v-navigation-drawer>
</div>
</template>
<script>
import UserAvatar from "@/components/User/UserAvatar";
import { initials } from "@/mixins/initials";
import { user } from "@/mixins/user";
import axios from "axios";
export default {
components: {
UserAvatar,
},
mixins: [initials, user],
data() {
return {
showSidebar: false,
links: [],
latestVersion: null,
hideImage: false,
};
},
created() {
this.showSidebar = !this.isMobile;
},
watch: {
user() {
this.hideImage = false;
},
isMain(val) {
if (val) {
this.$store.dispatch("requestCustomPages");
} else {
this.getVersion();
}
},
},
computed: {
isMain() {
const testVal = this.$route.path.split("/");
if (testVal[1] === "recipe") this.closeSidebar();
return !(testVal[1] === "admin");
},
baseMainLinks() {
return [
{
icon: this.$globals.icons.home,
to: "/",
title: this.$t("sidebar.home-page"),
},
{
icon: this.$globals.icons.search,
to: "/search",
title: this.$t("sidebar.search"),
},
{
icon: this.$globals.icons.viewModule,
to: "/recipes/all",
title: this.$t("sidebar.all-recipes"),
},
{
icon: this.$globals.icons.tags,
to: "/recipes/category",
title: this.$t("sidebar.categories"),
},
{
icon: this.$globals.icons.tags,
to: "/recipes/tag",
title: this.$t("sidebar.tags"),
},
];
},
customPages() {
const pages = this.$store.getters.getCustomPages;
if (pages.length > 0) {
pages.sort((a, b) => a.position - b.position);
return pages.map(x => ({
title: x.name,
to: `/pages/${x.slug}`,
icon: this.$globals.icons.pages,
}));
}
return [];
},
mainMenu() {
return [...this.baseMainLinks, ...this.customPages];
},
settingsLinks() {
return [
{
icon: this.$globals.icons.user,
to: "/admin/profile",
title: this.$t("sidebar.profile"),
},
];
},
adminLinks() {
return [
{
icon: this.$globals.icons.viewDashboard,
to: "/admin/dashboard",
title: this.$t("sidebar.dashboard"),
},
{
icon: this.$globals.icons.cog,
to: "/admin/settings",
title: this.$t("sidebar.site-settings"),
},
{
icon: this.$globals.icons.tools,
to: "/admin/toolbox",
title: this.$t("sidebar.toolbox"),
},
{
icon: this.$globals.icons.group,
to: "/admin/manage-users",
title: this.$t("sidebar.manage-users"),
},
{
icon: this.$globals.icons.import,
to: "/admin/migrations",
title: this.$t("sidebar.migrations"),
},
];
},
adminMenu() {
if (this.user.admin) {
return [...this.settingsLinks, ...this.adminLinks];
} else {
return this.settingsLinks;
}
},
effectiveMenu() {
return this.isMain ? this.mainMenu : this.adminMenu;
},
userProfileImage() {
this.resetImage();
return `api/users/${this.user.id}/image`;
},
newVersionAvailable() {
return this.latestVersion == this.appVersion ? false : true;
},
appVersion() {
const appInfo = this.$store.getters.getAppInfo;
return appInfo.version;
},
isLoggedIn() {
return this.$store.getters.getIsLoggedIn;
},
isMobile() {
return this.$vuetify.breakpoint.name === "xs";
},
},
methods: {
resetImage() {
this.hideImage == false;
},
toggleSidebar() {
this.showSidebar = !this.showSidebar;
},
closeSidebar() {
this.showSidebar = false;
},
async getVersion() {
let response = await axios.get("https://api.github.com/repos/hay-kot/mealie/releases/latest", {
headers: {
"content-type": "application/json",
Authorization: null,
},
});
this.latestVersion = response.data.tag_name;
},
},
};
</script>
<style>
.fixedBottom {
position: fixed !important;
bottom: 0 !important;
width: 100%;
}
@media print {
.no-print {
display: none;
}
}
</style>

View file

@ -1,109 +0,0 @@
<template>
<div class="text-center">
<LoginDialog ref="loginDialog" />
<v-menu transition="slide-x-transition" bottom right offset-y offset-overflow open-on-hover close-delay="200">
<template v-slot:activator="{ on, attrs }">
<v-btn v-bind="attrs" v-on="on" icon>
<v-icon>{{ $globals.icons.user }}</v-icon>
</v-btn>
</template>
<v-list>
<v-list-item
v-for="(item, i) in filteredItems"
:key="i"
link
:to="item.nav ? item.nav : null"
@click="item.login ? openLoginDialog() : null"
>
<v-list-item-icon>
<v-icon>{{ item.icon }}</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>
{{ item.title }}
</v-list-item-title>
</v-list-item-content>
</v-list-item>
</v-list>
</v-menu>
</div>
</template>
<script>
import LoginDialog from "../Login/LoginDialog";
export default {
components: {
LoginDialog,
},
computed: {
items() {
return [
{
icon: this.$globals.icons.user,
title: this.$t("user.login"),
restricted: false,
login: true,
},
{
icon: this.$globals.icons.calendarWeek,
title: this.$t("meal-plan.dinner-this-week"),
nav: "/meal-plan/this-week",
restricted: true,
},
{
icon: this.$globals.icons.calendarToday,
title: this.$t("meal-plan.dinner-today"),
nav: "/meal-plan/today",
restricted: true,
},
{
icon: this.$globals.icons.calendarMultiselect,
title: this.$t("meal-plan.planner"),
nav: "/meal-plan/planner",
restricted: true,
},
{
icon: this.$globals.icons.formatListCheck,
title: this.$t("shopping-list.shopping-lists"),
nav: "/shopping-list",
restricted: true,
},
{
icon: this.$globals.icons.logout,
title: this.$t("user.logout"),
restricted: true,
nav: "/logout",
},
{
icon: this.$globals.icons.cog,
title: this.$t("general.settings"),
nav: "/admin",
restricted: true,
},
];
},
filteredItems() {
if (this.loggedIn) {
return this.items.filter(x => x.restricted == true);
} else {
return this.items.filter(x => x.restricted == false);
}
},
loggedIn() {
return this.$store.getters.getIsLoggedIn;
},
},
methods: {
openLoginDialog() {
this.$refs.loginDialog.open();
},
},
};
</script>
<style>
.menu-text {
text-align: left !important;
}
</style>

View file

@ -1,38 +0,0 @@
import Vue from "vue";
import VueI18n from "vue-i18n";
import Vuetify from "@/plugins/vuetify";
import axios from 'axios';
Vue.use(VueI18n);
const i18n = new VueI18n();
export default i18n;
const loadedLanguages = [];
function setI18nLanguage (lang) {
i18n.locale = lang;
Vuetify.framework.lang.current = lang;
axios.defaults.headers.common['Accept-Language'] = lang
document.querySelector('html').setAttribute('lang', lang)
return lang
}
export function loadLanguageAsync(lang) {
if ( ! loadedLanguages.includes(lang)) {
const messages = import(`./locales/messages/${lang}.json`);
const dateTimeFormats = import(`./locales/dateTimeFormats/${lang}.json`);
return Promise.all([messages, dateTimeFormats]).then(
values => {
i18n.setLocaleMessage(lang, values[0].default)
i18n.setDateTimeFormat(lang, values[1].default)
loadedLanguages.push(lang)
return setI18nLanguage(lang)
}
)
}
return Promise.resolve(setI18nLanguage(lang))
}

View file

@ -1,3 +0,0 @@
import Vue from "vue";
import VueCompositionAPI from "@vue/composition-api";
Vue.use(VueCompositionAPI);

View file

@ -1,44 +0,0 @@
import "./installCompAPI"; // Must Be First
import Vue from "vue";
import App from "./App.vue";
import vuetify from "./plugins/vuetify";
import store from "./store";
import VueRouter from "vue-router";
import { router } from "./routes";
import { globals } from "@/utils/globals";
import i18n from "./i18n";
import "typeface-roboto/index.css";
import "./registerServiceWorker";
Vue.config.productionTip = false;
Vue.use(VueRouter);
Vue.component("TheButton", () => import("@/components/UI/Buttons/TheButton.vue"));
Vue.prototype.$globals = globals;
const vueApp = new Vue({
vuetify,
store,
router,
i18n,
render: h => h(App),
}).$mount("#app");
// Truncate
const truncate = function(text, length, clamp) {
clamp = clamp || "...";
let node = document.createElement("div");
node.innerHTML = text;
let content = node.textContent;
return content.length > length ? content.slice(0, length) + clamp : content;
};
const titleCase = function(value) {
return value.replace(/(?:^|\s|-)\S/g, x => x.toUpperCase());
};
Vue.filter("truncate", truncate);
Vue.filter("titleCase", titleCase);
export { router, vueApp };

View file

@ -1,18 +0,0 @@
export const initials = {
computed: {
initials() {
if (!this.user.fullName) return "00"
const allNames = this.user.fullName.trim().split(" ");
const initials = allNames.reduce(
(acc, curr, index) => {
if (index === 0 || index === allNames.length - 1) {
acc = `${acc}${curr.charAt(0).toUpperCase()}`;
}
return acc;
},
[""]
);
return initials;
},
},
};

View file

@ -1,24 +0,0 @@
import { store } from "@/store";
export const user = {
computed: {
user() {
return store.getters.getUserData;
},
loggedIn() {
return store.getters.getIsLoggedIn;
},
initials() {
const allNames = this.user.fullName.trim().split(" ");
const initials = allNames.reduce(
(acc, curr, index) => {
if (index === 0 || index === allNames.length - 1) {
acc = `${acc}${curr.charAt(0).toUpperCase()}`;
}
return acc;
},
[""]
);
return initials;
},
},
};

View file

@ -1,7 +0,0 @@
export const utilMixins = {
commputed: {
isMobile() {
return this.$vuetify.breakpoint.name === "xs";
},
},
};

View file

@ -1,15 +0,0 @@
const EMAIL_REGEX = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@(([[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
export const validators = {
data() {
return {
emailRule: v => !v || EMAIL_REGEX.test(v) || this.$t("user.e-mail-must-be-valid"),
existsRule: value => !!value || this.$t("general.field-required"),
minRule: v => v.length >= 8 || this.$t("user.use-8-characters-or-more-for-your-password"),
whiteSpace: v => !v || v.split(" ").length <= 1 || this.$t("recipe.no-white-space-allowed"),
};
},
};

View file

@ -1,13 +0,0 @@
<template>
<v-container class="text-center">
<The404 />
</v-container>
</template>
<script>
import The404 from "@/components/Fallbacks/The404";
export default {
components: { The404 },
};
</script>

View file

@ -1,130 +0,0 @@
<template>
<div>
<v-app-bar color="primary">
<v-spacer></v-spacer>
<v-btn href="https://github.com/sponsors/hay-kot" target="_blank" class="mx-1" color="secondary">
<v-icon left>
{{ $globals.icons.heart }}
</v-icon>
{{ $t("about.support") }}
</v-btn>
<v-btn href="https://github.com/hay-kot" target="_blank" class="mx-1" color="secondary">
<v-icon left>
{{ $globals.icons.github }}
</v-icon>
{{ $t("about.github") }}
</v-btn>
<v-btn href="https://hay-kot.dev" target="_blank" class="mx-1" color="secondary">
<v-icon left>
{{ $globals.icons.account }}
</v-icon>
{{ $t("about.portfolio") }}
</v-btn>
<v-btn href="https://hay-kot.github.io/mealie/" target="_blank" class="mx-1" color="secondary">
<v-icon left>
{{ $globals.icons.folderOutline }}
</v-icon>
{{ $t("about.docs") }}
</v-btn>
<v-spacer></v-spacer>
</v-app-bar>
<v-card class="mt-3">
<v-card-title class="headline">
{{ $t("about.about-mealie") }}
</v-card-title>
<v-divider></v-divider>
<v-card-text>
<v-list-item-group color="primary">
<v-list-item v-for="property in prettyInfo" :key="property.name">
<v-list-item-icon>
<v-icon> {{ property.icon || $globals.icons.user }} </v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title class="pl-4 flex row justify-space-between">
<div>{{ property.name }}</div>
<div>{{ property.value }}</div>
</v-list-item-title>
</v-list-item-content>
</v-list-item>
</v-list-item-group>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<TheDownloadBtn download-url="/api/debug/last-recipe-json">
<template v-slot:default="{ downloadFile }">
<v-btn color="primary" @click="downloadFile">
<v-icon left> {{ $globals.icons.codeBraces }} </v-icon> {{ $t("about.download-recipe-json") }}
</v-btn>
</template>
</TheDownloadBtn>
</v-card-actions>
<v-divider></v-divider>
</v-card>
<LogCard />
</div>
</template>
<script>
import { api } from "@/api";
import TheDownloadBtn from "@/components/UI/Buttons/TheDownloadBtn";
import LogCard from "@/components/UI/LogCard.vue";
export default {
components: { TheDownloadBtn, LogCard },
data() {
return {
prettyInfo: [],
};
},
async created() {
await this.getInfo();
},
methods: {
async getInfo() {
const debugInfo = await api.meta.getDebugInfo();
this.prettyInfo = [
{
name: this.$t("about.version"),
icon: this.$globals.icons.information,
value: debugInfo.version,
},
{
name: this.$t("about.application-mode"),
icon: this.$globals.icons.devTo,
value: debugInfo.production ? this.$t("about.production") : this.$t("about.development"),
},
{
name: this.$t("about.demo-status"),
icon: this.$globals.icons.testTube,
value: debugInfo.demoStatus ? this.$t("about.demo") : this.$t("about.not-demo"),
},
{
name: this.$t("about.api-port"),
icon: this.$globals.icons.api,
value: debugInfo.apiPort,
},
{
name: this.$t("about.api-docs"),
icon: this.$globals.icons.file,
value: debugInfo.apiDocs ? this.$t("general.enabled") : this.$t("general.disabled"),
},
{
name: this.$t("about.database-type"),
icon: this.$globals.icons.database,
value: debugInfo.dbType,
},
{
name: this.$t("about.database-url"),
icon: this.$globals.icons.database,
value: debugInfo.dbUrl,
},
{
name: this.$t("about.default-group"),
icon: this.$globals.icons.group,
value: debugInfo.defaultGroup,
},
];
},
},
};
</script>

View file

@ -1,162 +0,0 @@
<template>
<div>
<ImportSummaryDialog ref="report" />
<ImportDialog
:name="selectedName"
:date="selectedDate"
ref="import_dialog"
@import="importBackup"
@delete="deleteBackup"
/>
<ConfirmationDialog
:title="$t('settings.backup.delete-backup')"
:message="$t('general.confirm-delete-generic')"
color="error"
:icon="$globals.icons.alertCircle"
ref="deleteBackupConfirm"
v-on:confirm="emitDelete()"
/>
<StatCard :icon="$globals.icons.backupRestore" :color="color">
<template v-slot:after-heading>
<div class="ml-auto text-right">
<h2 class="body-3 grey--text font-weight-light">
{{ $t("settings.backup-and-exports") }}
</h2>
<h3 class="display-2 font-weight-light text--primary">
<small> {{ total }}</small>
</h3>
</div>
</template>
<div class="d-flex row py-3 justify-end">
<AppButtonUpload url="/api/backups/upload" @uploaded="getAvailableBackups">
<template v-slot="{ isSelecting, onButtonClick }">
<v-btn :loading="isSelecting" class="mx-2" small color="info" @click="onButtonClick">
<v-icon left> {{ $globals.icons.upload }} </v-icon> {{ $t("general.upload") }}
</v-btn>
</template>
</AppButtonUpload>
<BackupDialog :color="color" />
<v-btn :loading="loading" class="mx-2" small color="success" @click="createBackup">
<v-icon left> {{ $globals.icons.create }} </v-icon> {{ $t("general.create") }}
</v-btn>
</div>
<template v-slot:bottom>
<v-virtual-scroll height="290" item-height="70" :items="availableBackups">
<template v-slot:default="{ item }">
<v-list-item @click.prevent="openDialog(item, btnEvent.IMPORT_EVENT)">
<v-list-item-avatar>
<v-icon large dark :color="color">
{{ $globals.icons.database }}
</v-icon>
</v-list-item-avatar>
<v-list-item-content>
<v-list-item-title v-text="item.name"></v-list-item-title>
<v-list-item-subtitle>
{{ $d(Date.parse(item.date), "medium") }}
</v-list-item-subtitle>
</v-list-item-content>
<v-list-item-action class="ml-auto">
<v-btn large icon @click.stop="openDialog(item, btnEvent.DELETE_EVENT)">
<v-icon color="error">{{ $globals.icons.delete }}</v-icon>
</v-btn>
</v-list-item-action>
</v-list-item>
</template>
</v-virtual-scroll>
</template>
</StatCard>
</div>
</template>
<script>
import AppButtonUpload from "@/components/UI/Buttons/AppButtonUpload";
import ImportSummaryDialog from "@/components/ImportSummaryDialog";
import ConfirmationDialog from "@/components/UI/Dialogs/ConfirmationDialog";
import { api } from "@/api";
import StatCard from "@/components/UI/StatCard";
import BackupDialog from "@/components/UI/Dialogs/BackupDialog";
import ImportDialog from "@/components/UI/Dialogs/ImportDialog";
const IMPORT_EVENT = "import";
const DELETE_EVENT = "delete";
export default {
components: { StatCard, ImportDialog, AppButtonUpload, ImportSummaryDialog, BackupDialog, ConfirmationDialog },
data() {
return {
color: "accent",
selectedName: "",
selectedDate: "",
loading: false,
events: [],
availableBackups: [],
btnEvent: { IMPORT_EVENT, DELETE_EVENT },
};
},
computed: {
total() {
return this.availableBackups.length;
},
},
created() {
this.getAvailableBackups();
},
methods: {
async getAvailableBackups() {
const response = await api.backups.requestAvailable();
this.availableBackups = response.imports;
},
async deleteBackup(name) {
this.loading = true;
await api.backups.delete(name);
this.loading = false;
this.getAvailableBackups();
},
openDialog(backup, event) {
this.selectedDate = backup.date;
this.selectedName = backup.name;
switch (event) {
case IMPORT_EVENT:
this.$refs.import_dialog.open();
break;
case DELETE_EVENT:
this.$refs.deleteBackupConfirm.open();
break;
}
},
async importBackup(data) {
this.$refs.report.open(data);
},
async createBackup() {
this.loading = true;
let data = {
tag: this.tag,
options: {},
templates: [],
};
if (await api.backups.create(data)) {
this.getAvailableBackups();
}
this.loading = false;
},
emitDelete() {
this.deleteBackup(this.selectedName);
},
},
};
</script>
<style lang="scss" scoped>
</style>

View file

@ -1,135 +0,0 @@
<template>
<div>
<ConfirmationDialog
:title="$t('events.delete-event')"
:message="$t('general.confirm-delete-generic')"
color="error"
:icon="$globals.icons.alertCircle"
ref="deleteEventConfirm"
v-on:confirm="emitDelete()"
/>
<StatCard :icon="$globals.icons.bellAlert" :color="color">
<template v-slot:after-heading>
<div class="ml-auto text-right">
<h2 class="body-3 grey--text font-weight-light">
{{ $t("settings.events") }}
</h2>
<h3 class="display-2 font-weight-light text--primary">
<small> {{ total }} </small>
</h3>
</div>
</template>
<div class="d-flex row py-3 justify-end">
<v-btn class="mx-2" small color="error lighten-1" @click="deleteAll">
<v-icon left> {{ $globals.icons.notificationClearAll }} </v-icon> {{ $t("general.clear") }}
</v-btn>
</div>
<template v-slot:bottom>
<v-virtual-scroll height="290" item-height="70" :items="events">
<template v-slot:default="{ item }">
<v-list-item>
<v-list-item-avatar>
<v-icon large dark :color="icons[item.category].color">
{{ icons[item.category].icon }}
</v-icon>
</v-list-item-avatar>
<v-list-item-content>
<v-list-item-title v-text="item.title"></v-list-item-title>
<v-list-item-subtitle v-text="item.text"></v-list-item-subtitle>
<v-list-item-subtitle>
{{ $d(Date.parse(item.timeStamp), "long") }}
</v-list-item-subtitle>
</v-list-item-content>
<v-list-item-action class="ml-auto">
<v-btn large icon @click="openDialog(item)">
<v-icon color="error">{{ $globals.icons.delete }}</v-icon>
</v-btn>
</v-list-item-action>
</v-list-item>
</template>
</v-virtual-scroll>
</template>
</StatCard>
</div>
</template>
<script>
import { api } from "@/api";
import StatCard from "@/components/UI/StatCard";
import ConfirmationDialog from "@/components/UI/Dialogs/ConfirmationDialog";
export default {
components: { StatCard, ConfirmationDialog },
data() {
return {
color: "accent",
total: 0,
selectedId: "",
events: [],
icons: {
general: {
icon: this.$globals.icons.information,
color: "info",
},
recipe: {
icon: this.$globals.icons.primary,
color: "primary",
},
backup: {
icon: this.$globals.icons.database,
color: "primary",
},
schedule: {
icon: this.$globals.icons.calendar,
color: "primary",
},
migration: {
icon: this.$globals.icons.backupRestore,
color: "primary",
},
user: {
icon: this.$globals.icons.user,
color: "accent",
},
group: {
icon: this.$globals.icons.group,
color: "accent",
},
},
};
},
mounted() {
this.getEvents();
},
methods: {
async getEvents() {
const events = await api.about.getEvents();
this.events = events.events;
this.total = events.total;
},
async deleteEvent(id) {
await api.about.deleteEvent(id);
this.getEvents();
},
async deleteAll() {
await api.about.deleteAllEvents();
this.getEvents();
},
openDialog(events) {
this.selectedId = events.id;
this.$refs.deleteEventConfirm.open();
},
emitDelete() {
this.deleteEvent(this.selectedId);
},
},
};
</script>
<style lang="scss" scoped>
</style>

View file

@ -1,126 +0,0 @@
<template>
<div class="mt-10">
<v-row>
<v-col cols="12" sm="12" md="4">
<StatCard :icon="$globals.icons.primary">
<template v-slot:after-heading>
<div class="ml-auto text-right">
<h2 class="body-3 grey--text font-weight-light">
{{ $t("general.recipes") }}
</h2>
<h3 class="display-2 font-weight-light text--primary">
<small> {{ statistics.totalRecipes }}</small>
</h3>
</div>
</template>
<template v-slot:actions>
<div class="d-flex row py-3 justify-space-around">
<v-btn small color="primary" :to="{ path: '/admin/toolbox/', query: { tab: 'organize', filter: 'tag' } }">
<v-icon left> {{ $globals.icons.tags }} </v-icon>
{{ $tc("tag.untagged-count", [statistics.untaggedRecipes]) }}
</v-btn>
<v-btn
small
color="primary"
:to="{ path: '/admin/toolbox/', query: { tab: 'organize', filter: 'category' } }"
>
<v-icon left> {{ $globals.icons.tags }} </v-icon>
{{ $tc("category.uncategorized-count", [statistics.uncategorizedRecipes]) }}
</v-btn>
</div>
</template>
</StatCard>
</v-col>
<v-col cols="12" sm="12" md="4">
<StatCard :icon="$globals.icons.user">
<template v-slot:after-heading>
<div class="ml-auto text-right">
<h2 class="body-3 grey--text font-weight-light">
{{ $t("user.users") }}
</h2>
<h3 class="display-2 font-weight-light text--primary">
<small> {{ statistics.totalUsers }}</small>
</h3>
</div>
</template>
<template v-slot:actions>
<div class="ml-auto">
<v-btn color="primary" small to="/admin/manage-users?tab=users">
<v-icon left>{{ $globals.icons.user }}</v-icon>
{{ $t("user.manage-users") }}
</v-btn>
</div>
</template>
</StatCard>
</v-col>
<v-col cols="12" sm="12" md="4">
<StatCard :icon="$globals.icons.group">
<template v-slot:after-heading>
<div class="ml-auto text-right">
<h2 class="body-3 grey--text font-weight-light">
{{ $t("group.groups") }}
</h2>
<h3 class="display-2 font-weight-light text--primary">
<small> {{ statistics.totalGroups }}</small>
</h3>
</div>
</template>
<template v-slot:actions>
<div class="ml-auto">
<v-btn color="primary" small to="/admin/manage-users?tab=groups">
<v-icon left>{{ $globals.icons.group }}</v-icon>
{{ $t("group.manage-groups") }}
</v-btn>
</div>
</template>
</StatCard>
</v-col>
</v-row>
<v-row class="mt-10">
<v-col cols="12" sm="12" lg="6">
<EventViewer />
</v-col>
<v-col cols="12" sm="12" lg="6"> <BackupViewer /> </v-col>
</v-row>
</div>
</template>
<script>
import { api } from "@/api";
import StatCard from "@/components/UI/StatCard";
import EventViewer from "./EventViewer";
import BackupViewer from "./BackupViewer";
export default {
components: { StatCard, EventViewer, BackupViewer },
data() {
return {
statistics: {
totalGroups: 0,
totalRecipes: 0,
totalUsers: 0,
uncategorizedRecipes: 0,
untaggedRecipes: 0,
},
};
},
created() {
this.getStatistics();
},
methods: {
async getStatistics() {
this.statistics = await api.meta.getStatistics();
},
},
};
</script>
<style>
.grid-style {
flex-grow: inherit;
display: inline-flex;
flex-wrap: wrap;
gap: 10px;
}
</style>

View file

@ -1,122 +0,0 @@
<template>
<div>
<ConfirmationDialog
ref="deleteGroupConfirm"
:title="$t('group.confirm-group-deletion')"
:message="
$t('group.are-you-sure-you-want-to-delete-the-group', {
groupName: group.name,
})
"
:icon="$globals.icons.alert"
@confirm="deleteGroup"
:width="450"
@close="closeGroupDelete"
/>
<v-card class="ma-auto" tile min-height="325px">
<v-list dense>
<v-card-title class="py-1">{{ group.name }}</v-card-title>
<v-divider></v-divider>
<v-subheader>{{ $t("group.group-id-with-value", { groupID: group.id }) }}</v-subheader>
<v-list-item-group color="primary">
<v-list-item v-for="property in groupProps" :key="property.text">
<v-list-item-icon>
<v-icon> {{ property.icon || $globals.icons.user }} </v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title class="pl-4 flex row justify-space-between">
<div>{{ property.text }}</div>
<div>{{ property.value }}</div>
</v-list-item-title>
</v-list-item-content>
</v-list-item>
</v-list-item-group>
</v-list>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn small color="error" @click="confirmDelete" :disabled="ableToDelete">
{{ $t("general.delete") }}
</v-btn>
<!-- Coming Soon! -->
<v-btn small color="success" disabled>
{{ $t("general.edit") }}
</v-btn>
</v-card-actions>
</v-card>
</div>
</template>
<script>
const RENDER_EVENT = "update";
import ConfirmationDialog from "@/components/UI/Dialogs/ConfirmationDialog";
import { api } from "@/api";
export default {
components: { ConfirmationDialog },
props: {
group: {
default: {
name: "DEFAULT_NAME",
id: 1,
users: [],
mealplans: [],
categories: [],
webhookUrls: [],
webhookTime: "00:00",
webhookEnable: false,
},
},
},
data() {
return {
groupProps: {},
};
},
computed: {
ableToDelete() {
return this.group.users.length >= 1 ? true : false;
},
},
created() {
this.buildData();
},
methods: {
confirmDelete() {
this.$refs.deleteGroupConfirm.open();
},
async deleteGroup() {
if (await api.groups.delete(this.group.id)) {
this.$emit(RENDER_EVENT);
}
},
closeGroupDelete() {
console.log("Close Delete");
},
buildData() {
this.groupProps = [
{
text: this.$t("user.total-users"),
icon: this.$globals.icons.user,
value: this.group.users.length,
},
{
text: this.$t("user.total-mealplans"),
icon: this.$globals.icons.food,
value: this.group.mealplans.length,
},
{
text: this.$t("user.webhooks-enabled"),
icon: this.$globals.icons.webhook,
value: this.group.webhookEnable ? this.$t("general.yes") : this.$t("general.no"),
},
{
text: this.$t("user.webhook-time"),
icon: this.$globals.icons.clockOutline,
value: this.group.webhookTime,
},
];
},
},
};
</script>
<style scoped></style>

View file

@ -1,100 +0,0 @@
<template>
<div>
<v-card outlined class="mt-n1">
<v-card-actions>
<v-spacer></v-spacer>
<div width="100px">
<v-text-field
v-model="filter"
clearable
class="mr-2 pt-0"
:append-icon="$globals.icons.filter"
:label="$t('general.filter')"
single-line
hide-details
></v-text-field>
</div>
<v-dialog v-model="groupDialog" max-width="400">
<template v-slot:activator="{ on, attrs }">
<v-btn class="mx-2" small color="success" dark v-bind="attrs" v-on="on">
{{ $t("group.create-group") }}
</v-btn>
</template>
<v-card>
<v-app-bar dark dense color="primary">
<v-icon left>
{{ $globals.icons.group }}
</v-icon>
<v-toolbar-title class="headline">
{{ $t("group.create-group") }}
</v-toolbar-title>
<v-spacer></v-spacer>
</v-app-bar>
<v-form ref="newGroup" @submit.prevent="createGroup">
<v-card-text>
<v-text-field
v-model="newGroupName"
:label="$t('group.group-name')"
:rules="[existsRule]"
></v-text-field>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="grey" text @click="groupDialog = false">
{{ $t("general.cancel") }}
</v-btn>
<v-btn color="primary" type="submit">
{{ $t("general.create") }}
</v-btn>
</v-card-actions>
</v-form>
</v-card>
</v-dialog>
</v-card-actions>
<v-card-text>
<v-row>
<v-col :sm="6" :md="6" :lg="4" :xl="3" v-for="group in groups" :key="group.id">
<GroupCard :group="group" @update="$store.dispatch('requestAllGroups')" />
</v-col>
</v-row>
</v-card-text>
</v-card>
</div>
</template>
<script>
import { validators } from "@/mixins/validators";
import { api } from "@/api";
import GroupCard from "./GroupCard";
export default {
components: { GroupCard },
mixins: [validators],
data() {
return {
filter: "",
groupDialog: false,
newGroupName: "",
};
},
computed: {
groups() {
return this.$store.getters.getGroups;
},
},
methods: {
async createGroup() {
this.groupLoading = true;
if (await api.groups.create(this.newGroupName)) {
this.groupDialog = false;
this.$store.dispatch("requestAllGroups");
}
this.groupLoading = false;
},
},
};
</script>
<style></style>

View file

@ -1,230 +0,0 @@
<template>
<v-card outlined class="mt-n1">
<ConfirmationDialog
ref="deleteTokenDialog"
:title="$t('user.confirm-link-deletion')"
:message="
$t('user.are-you-sure-you-want-to-delete-the-link', {
link: activeName,
})
"
:icon="$globals.icons.alert"
@confirm="deleteToken"
:width="450"
@close="closeDelete"
/>
<v-toolbar flat>
<v-icon large color="accent" class="mr-1">
{{ $globals.icons.externalLink }}
</v-icon>
<v-toolbar-title class="headine">
{{ $t("signup.sign-up-links") }}
</v-toolbar-title>
<v-spacer> </v-spacer>
<v-dialog v-model="dialog" max-width="500">
<template v-slot:activator="{ on, attrs }">
<v-btn small color="success" dark v-bind="attrs" v-on="on">
{{ $t("user.create-link") }}
</v-btn>
</template>
<v-card>
<v-app-bar dark dense color="primary">
<v-icon left>
{{ $globals.icons.user }}
</v-icon>
<v-toolbar-title class="headline">
{{ $t("user.create-link") }}
</v-toolbar-title>
<v-spacer></v-spacer>
</v-app-bar>
<v-form ref="newUser" @submit.prevent="save">
<v-card-text>
<v-text-field
v-model="editedItem.name"
:label="$t('user.link-name')"
:rules="[existsRule]"
validate-on-blur
></v-text-field>
<v-checkbox v-model="editedItem.admin" :label="$t('user.admin')"></v-checkbox>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="grey" text @click="close">
{{ $t("general.cancel") }}
</v-btn>
<v-btn color="primary" type="submit">
{{ $t("general.save") }}
</v-btn>
</v-card-actions>
</v-form>
</v-card>
</v-dialog>
</v-toolbar>
<v-divider></v-divider>
<v-card-text>
<v-data-table :headers="headers" :items="links" sort-by="calories">
<template v-slot:item.token="{ item }">
{{ `${baseURL}/sign-up/${item.token}` }}
<AppCopyButton :copy-text="`${baseURL}/sign-up/${item.token}`" />
</template>
<template v-slot:item.admin="{ item }">
<v-btn small :color="item.admin ? 'success' : 'error'" text>
<v-icon small left>
{{ $globals.icons.admin }}
</v-icon>
{{ item.admin ? $t("general.yes") : $t("general.no") }}
</v-btn>
</template>
<template v-slot:item.actions="{ item }">
<v-btn class="mr-1" small color="error" @click="deleteItem(item)">
<v-icon small left>
{{ $globals.icons.delete }}
</v-icon>
{{ $t("general.delete") }}
</v-btn>
</template>
</v-data-table>
</v-card-text>
</v-card>
</template>
<script>
import AppCopyButton from "@/components/UI/Buttons/AppCopyButton";
import ConfirmationDialog from "@/components/UI/Dialogs/ConfirmationDialog";
import { api } from "@/api";
import { validators } from "@/mixins/validators";
export default {
components: { ConfirmationDialog, AppCopyButton },
mixins: [validators],
data() {
return {
dialog: false,
activeId: null,
activeName: null,
headers: [
{
text: this.$t("user.link-id"),
align: "start",
sortable: false,
value: "id",
},
{ text: this.$t("general.name"), value: "name" },
{ text: this.$t("general.token"), value: "token" },
{ text: this.$t("user.admin"), value: "admin", align: "center" },
{ text: "", value: "actions", sortable: false, align: "center" },
],
links: [],
editedIndex: -1,
editedItem: {
name: "",
admin: false,
token: "",
id: 0,
},
defaultItem: {
name: "",
token: "",
admin: false,
id: 0,
},
};
},
computed: {
baseURL() {
return window.location.origin;
},
},
watch: {
dialog(val) {
val || this.close();
},
dialogDelete(val) {
val || this.closeDelete();
},
},
created() {
this.initialize();
},
methods: {
updateClipboard(newClip) {
navigator.clipboard.writeText(newClip).then(
() => {
console.log("Copied", newClip);
},
() => {
console.log("Copy Failed", newClip);
}
);
},
async initialize() {
this.links = await api.signUps.getAll();
},
async deleteToken() {
if (await api.signUps.deleteToken(this.activeId)) {
this.initialize();
}
},
editItem(item) {
this.editedIndex = this.links.indexOf(item);
this.editedItem = Object.assign({}, item);
this.dialog = true;
},
deleteItem(item) {
this.activeId = item.token;
this.activeName = item.name;
this.editedIndex = this.links.indexOf(item);
this.editedItem = Object.assign({}, item);
this.$refs.deleteTokenDialog.open();
},
deleteItemConfirm() {
this.links.splice(this.editedIndex, 1);
this.closeDelete();
},
close() {
this.dialog = false;
this.$nextTick(() => {
this.editedItem = Object.assign({}, this.defaultItem);
this.editedIndex = -1;
});
},
closeDelete() {
this.dialogDelete = false;
this.$nextTick(() => {
this.editedItem = Object.assign({}, this.defaultItem);
this.editedIndex = -1;
});
},
async save() {
if (this.editedIndex > -1) {
api.links.update(this.editedItem);
this.close();
} else if (this.$refs.newUser.validate()) {
api.signUps.createToken({
name: this.editedItem.name,
admin: this.editedItem.admin,
});
this.close();
}
await this.initialize();
},
},
};
</script>
<style></style>

View file

@ -1,284 +0,0 @@
<template>
<v-card outlined class="mt-n1">
<ConfirmationDialog
ref="deleteUserDialog"
:title="$t('user.confirm-user-deletion')"
:message="
$t('user.are-you-sure-you-want-to-delete-the-user', {
activeName,
activeId,
})
"
:icon="$globals.icons.alert"
@confirm="deleteUser"
:width="450"
@close="closeDelete"
/>
<v-toolbar flat>
<v-spacer> </v-spacer>
<div width="100px">
<v-text-field
v-model="search"
class="mr-2"
:append-icon="$globals.icons.filter"
:label="$t('general.filter')"
single-line
hide-details
></v-text-field>
</div>
<v-dialog v-model="dialog" max-width="600px">
<template v-slot:activator="{ on, attrs }">
<v-btn small color="success" dark v-bind="attrs" v-on="on">
{{ $t("user.create-user") }}
</v-btn>
</template>
<v-card>
<v-app-bar dark dense color="primary">
<v-icon left>
{{ $globals.icons.user }}
</v-icon>
<v-toolbar-title class="headline">
{{ formTitle }}
</v-toolbar-title>
<v-spacer></v-spacer>
<v-toolbar-title class="headline" v-if="!this.createMode">
{{ $t("user.user-id-with-value", { id: editedItem.id }) }}
</v-toolbar-title>
</v-app-bar>
<v-form ref="newUser" @submit.prevent="save">
<v-card-text>
<v-row>
<v-col cols="12" sm="12" md="6">
<v-text-field
v-model="editedItem.fullName"
:label="$t('user.full-name')"
:rules="[existsRule]"
validate-on-blur
></v-text-field>
</v-col>
<v-col cols="12" sm="12" md="6">
<v-text-field
v-model="editedItem.email"
:label="$t('user.email')"
:rules="[existsRule, emailRule]"
validate-on-blur
></v-text-field>
</v-col>
<v-col cols="12" sm="12" md="6">
<v-select
dense
v-model="editedItem.group"
:items="existingGroups"
:label="$t('group.user-group')"
></v-select>
</v-col>
<v-col cols="12" sm="12" md="6" v-if="createMode">
<v-text-field
dense
v-model="editedItem.password"
:label="$t('user.user-password')"
:rules="[existsRule, minRule]"
></v-text-field>
</v-col>
<v-col cols="12" sm="12" md="3">
<v-switch v-model="editedItem.admin" :label="$t('user.admin')"></v-switch>
</v-col>
</v-row>
</v-card-text>
<v-card-actions>
<v-btn color="info" text @click="resetPassword" v-if="!createMode">
{{ $t("user.reset-password") }}
</v-btn>
<v-spacer></v-spacer>
<v-btn color="grey" text @click="close">
{{ $t("general.cancel") }}
</v-btn>
<v-btn color="primary" type="submit">
{{ $t("general.save") }}
</v-btn>
</v-card-actions>
</v-form>
</v-card>
</v-dialog>
</v-toolbar>
<v-divider></v-divider>
<v-card-text>
<v-data-table :headers="headers" :items="users" sort-by="calories" :search="search">
<template v-slot:item.actions="{ item }">
<v-btn class="mr-1" small color="error" @click="deleteItem(item)">
<v-icon small left>
{{ $globals.icons.delete }}
</v-icon>
{{ $t("general.delete") }}
</v-btn>
<v-btn small color="success" @click="editItem(item)">
<v-icon small left class="mr-2">
{{ $globals.icons.edit }}
</v-icon>
{{ $t("general.edit") }}
</v-btn>
</template>
<template v-slot:item.admin="{ item }">
{{ item.admin ? "Admin" : "User" }}
</template>
<template v-slot:no-data>
<v-btn color="primary" @click="initialize">
{{ $t("general.reset") }}
</v-btn>
</template>
</v-data-table>
</v-card-text>
</v-card>
</template>
<script>
import ConfirmationDialog from "@/components/UI/Dialogs/ConfirmationDialog";
import { api } from "@/api";
import { validators } from "@/mixins/validators";
export default {
components: { ConfirmationDialog },
mixins: [validators],
data() {
return {
search: "",
dialog: false,
activeId: null,
activeName: null,
headers: [
{
text: this.$t("user.user-id"),
align: "start",
sortable: false,
value: "id",
},
{ text: this.$t("user.username"), value: "username" },
{ text: this.$t("user.full-name"), value: "fullName" },
{ text: this.$t("user.email"), value: "email" },
{ text: this.$t("group.group"), value: "group" },
{ text: this.$t("user.admin"), value: "admin" },
{ text: "", value: "actions", sortable: false, align: "center" },
],
users: [],
editedIndex: -1,
editedItem: {
id: 0,
fullName: "",
password: "",
email: "",
group: "",
admin: false,
},
defaultItem: {
id: 0,
fullName: "",
password: "",
email: "",
group: "",
admin: false,
},
};
},
computed: {
formTitle() {
return this.createMode ? this.$t("user.new-user") : this.$t("user.edit-user");
},
createMode() {
return this.editedIndex === -1 ? true : false;
},
existingGroups() {
return this.$store.getters.getGroupNames;
},
},
watch: {
dialog(val) {
val || this.close();
},
dialogDelete(val) {
val || this.closeDelete();
},
},
created() {
this.initialize();
},
methods: {
async initialize() {
this.users = await api.users.allUsers();
},
async deleteUser() {
if (await api.users.delete(this.activeId)) {
this.initialize();
}
},
editItem(item) {
this.editedIndex = this.users.indexOf(item);
this.editedItem = Object.assign({}, item);
this.dialog = true;
},
deleteItem(item) {
this.activeId = item.id;
this.activeName = item.fullName;
this.editedIndex = this.users.indexOf(item);
this.editedItem = Object.assign({}, item);
this.$refs.deleteUserDialog.open();
},
deleteItemConfirm() {
this.users.splice(this.editedIndex, 1);
this.closeDelete();
},
close() {
this.dialog = false;
this.$nextTick(() => {
this.editedItem = Object.assign({}, this.defaultItem);
this.editedIndex = -1;
});
},
closeDelete() {
this.dialogDelete = false;
this.$nextTick(() => {
this.editedItem = Object.assign({}, this.defaultItem);
this.editedIndex = -1;
});
},
async save() {
if (this.editedIndex > -1) {
this.updateUser();
} else if (this.$refs.newUser.validate()) {
this.createUser();
}
await this.initialize();
},
resetPassword() {
api.users.resetPassword(this.editedItem.id);
},
async createUser() {
if (await api.users.create(this.editedItem)) {
this.close();
}
},
async updateUser() {
if (await api.users.update(this.editedItem)) {
this.close();
}
},
},
};
</script>
<style></style>

View file

@ -1,68 +0,0 @@
<template>
<div>
<v-card flat>
<v-tabs v-model="tab" background-color="primary" centered dark icons-and-text show-arrows>
<v-tabs-slider></v-tabs-slider>
<v-tab href="#users">
{{ $t("user.users") }}
<v-icon>{{ $globals.icons.user }}</v-icon>
</v-tab>
<v-tab href="#sign-ups">
{{ $t("signup.sign-up-links") }}
<v-icon>{{ $globals.icons.accountPlusOutline }}</v-icon>
</v-tab>
<v-tab href="#groups" @click="reqGroups">
{{ $t("group.groups") }}
<v-icon>{{ $globals.icons.group }}</v-icon>
</v-tab>
</v-tabs>
<v-tabs-items v-model="tab">
<v-tab-item value="users">
<TheUserTable />
</v-tab-item>
<v-tab-item value="sign-ups">
<TheSignUpTable />
</v-tab-item>
<v-tab-item value="groups">
<GroupDashboard />
</v-tab-item>
</v-tabs-items>
</v-card>
</div>
</template>
<script>
import TheUserTable from "./TheUserTable";
import GroupDashboard from "./GroupDashboard";
import TheSignUpTable from "./TheSignUpTable";
export default {
components: { TheUserTable, GroupDashboard, TheSignUpTable },
data() {
return {};
},
computed: {
tab: {
set(tab) {
this.$router.replace({ query: { ...this.$route.query, tab } });
},
get() {
return this.$route.query.tab;
},
},
},
created() {
this.reqGroups();
},
methods: {
reqGroups() {
this.$store.dispatch("requestAllGroups");
},
},
};
</script>
<style></style>

View file

@ -1,94 +0,0 @@
<template>
<v-card outlined class="my-2" :loading="loading">
<MigrationDialog ref="migrationDialog" />
<v-card-title>
{{ title }}
<v-spacer></v-spacer>
<span>
<AppButtonUpload
class="mt-1"
:url="`/api/migrations/${folder}/upload`"
fileName="archive"
@uploaded="$emit('refresh')"
:post="true"
/>
</span>
</v-card-title>
<v-card-text> {{ description }}</v-card-text>
<div v-if="available[0]">
<v-card outlined v-for="migration in available" :key="migration.name" class="ma-2">
<v-card-text>
<v-row align="center">
<v-col cols="2">
<v-icon large color="primary">{{ $globals.icons.import }}</v-icon>
</v-col>
<v-col cols="10">
<div class="text-truncate">
<strong>{{ migration.name }}</strong>
</div>
<div class="text-truncate">
{{ $d(new Date(migration.date), "medium") }}
</div>
</v-col>
</v-row>
</v-card-text>
<v-card-actions class="mt-n6">
<v-spacer></v-spacer>
<v-btn color="error" text @click="deleteMigration(migration.name)">
{{ $t("general.delete") }}
</v-btn>
<v-btn color="accent" text @click="importMigration(migration.name)" :loading="loading" :disabled="loading">
{{ $t("general.import") }}
</v-btn>
</v-card-actions>
</v-card>
</div>
<div v-else>
<v-card outlined class="text-center ma-2">
<v-card-text>
{{ $t("migration.no-migration-data-available") }}
</v-card-text>
</v-card>
</div>
<br />
</v-card>
</template>
<script>
import AppButtonUpload from "@/components/UI/Buttons/AppButtonUpload";
import { api } from "@/api";
import MigrationDialog from "./MigrationDialog";
export default {
props: {
folder: String,
title: String,
description: String,
available: Array,
},
components: {
AppButtonUpload,
MigrationDialog,
},
data() {
return {
loading: false,
};
},
methods: {
async deleteMigration(file_name) {
if (await api.migrations.delete(this.folder, file_name)) {
this.$emit("refresh");
}
},
async importMigration(file_name) {
this.loading = true;
let response = await api.migrations.import(this.folder, file_name);
this.$refs.migrationDialog.open(response);
// this.$emit("imported", response.successful, response.failed);
this.loading = false;
},
},
};
</script>
<style></style>

View file

@ -1,106 +0,0 @@
<template>
<div class="text-center">
<v-dialog v-model="dialog" width="70%">
<v-card>
<v-app-bar dark color="primary mb-2">
<v-icon large left>
{{ $globals.icons.import }}
</v-icon>
<v-toolbar-title class="headline">
Migration Summary
</v-toolbar-title>
<v-spacer></v-spacer>
</v-app-bar>
<v-card-text class="mb-n4">
<v-row>
<div v-for="values in allNumbers" :key="values.title">
<v-card-text>
<div>
<h3>{{ values.title }}</h3>
</div>
<div class="success--text">Success: {{ values.success }}</div>
<div class="error--text">Failed: {{ values.failure }}</div>
</v-card-text>
</div>
</v-row>
</v-card-text>
<v-tabs v-model="tab" show-arrows="">
<v-tab>{{ $t("general.recipes") }}</v-tab>
</v-tabs>
<v-tabs-items v-model="tab">
<v-tab-item v-for="(table, index) in allTables" :key="index">
<v-card flat>
<DataTable :data-headers="importHeaders" :data-set="table" />
</v-card>
</v-tab-item>
</v-tabs-items>
</v-card>
</v-dialog>
</div>
</template>
<script>
import DataTable from "@/components/ImportSummaryDialog/DataTable";
export default {
components: {
DataTable,
},
data: () => ({
tab: null,
dialog: false,
recipeData: [],
themeData: [],
settingsData: [],
userData: [],
groupData: [],
pageData: [],
importHeaders: [
{
text: "Status",
value: "status",
},
{
text: "Name",
align: "start",
sortable: true,
value: "name",
},
{ text: "Exception", value: "data-table-expand", align: "center" },
],
allDataTables: [],
}),
computed: {
recipeNumbers() {
return this.calculateNumbers(this.$t("general.recipes"), this.recipeData);
},
allNumbers() {
return [this.recipeNumbers];
},
allTables() {
return [this.recipeData];
},
},
methods: {
calculateNumbers(title, list_array) {
if (!list_array) return;
let numbers = { title: title, success: 0, failure: 0 };
list_array.forEach(element => {
if (element.status) {
numbers.success++;
} else numbers.failure++;
});
return numbers;
},
open(importData) {
this.recipeData = importData;
this.dialog = true;
},
},
};
</script>
<style></style>

View file

@ -1,82 +0,0 @@
<template>
<div>
<v-card :loading="loading">
<v-card-title class="headline">
{{ $t("migration.recipe-migration") }}
</v-card-title>
<v-divider></v-divider>
<v-card-text>
<v-row dense>
<v-col :cols="12" :sm="6" :md="6" :lg="4" :xl="3" v-for="migration in migrations" :key="migration.title">
<MigrationCard
:title="migration.title"
:folder="migration.urlVariable"
:description="migration.description"
:available="migration.availableImports"
@refresh="getAvailableMigrations"
@imported="showReport"
/>
</v-col>
</v-row>
</v-card-text>
</v-card>
</div>
</template>
<script>
import MigrationCard from "./MigrationCard";
import { api } from "@/api";
export default {
components: {
MigrationCard,
},
data() {
return {
loading: false,
success: [],
failed: [],
migrations: {
nextcloud: {
title: this.$t("migration.nextcloud.title"),
description: this.$t("migration.nextcloud.description"),
urlVariable: "nextcloud",
availableImports: [],
},
chowdown: {
title: this.$t("migration.chowdown.title"),
description: this.$t("migration.chowdown.description"),
urlVariable: "chowdown",
availableImports: [],
},
},
};
},
created() {
this.getAvailableMigrations();
},
methods: {
finished() {
this.loading = false;
this.$store.dispatch("requestRecentRecipes");
},
async getAvailableMigrations() {
let response = await api.migrations.getMigrations();
response.forEach(element => {
if (element.type === "nextcloud") {
this.migrations.nextcloud.availableImports = element.files;
} else if (element.type === "chowdown") {
this.migrations.chowdown.availableImports = element.files;
}
});
},
showReport(successful, failed) {
this.success = successful;
this.failed = failed;
this.$refs.report.open();
},
},
};
</script>
<style></style>

View file

@ -1,150 +0,0 @@
<template>
<StatCard :icon="$globals.icons.api" color="accent">
<template v-slot:after-heading>
<div class="ml-auto text-right">
<h2 class="body-3 grey--text font-weight-light">
{{ $t("settings.token.api-tokens") }}
</h2>
<h3 class="display-2 font-weight-light text--primary">
<small> {{ user.tokens.length }} </small>
</h3>
</div>
</template>
<template v-slot:bottom>
<v-subheader class="mb-n2">{{ $t("settings.token.active-tokens") }}</v-subheader>
<v-virtual-scroll height="210" item-height="70" :items="user.tokens" class="mt-2">
<template v-slot:default="{ item }">
<v-divider></v-divider>
<v-list-item @click.prevent>
<v-list-item-avatar>
<v-icon large dark color="accent">
{{ $globals.icons.api }}
</v-icon>
</v-list-item-avatar>
<v-list-item-content>
<v-list-item-title v-text="item.name"></v-list-item-title>
</v-list-item-content>
<v-list-item-action class="ml-auto">
<v-btn large icon @click.stop="deleteToken(item.id)">
<v-icon color="accent">{{ $globals.icons.delete }}</v-icon>
</v-btn>
</v-list-item-action>
</v-list-item>
<v-divider></v-divider>
</template>
</v-virtual-scroll>
<v-divider></v-divider>
<v-card-actions class="pb-1 pt-3">
<v-spacer></v-spacer>
<BaseDialog
:title="$t('settings.token.create-an-api-token')"
:title-icon="$globals.icons.api"
@submit="createToken"
:submit-text="buttonText"
:loading="loading"
>
<v-card-text>
<v-form ref="newTokenForm">
<v-text-field v-model="name" :label="$t('settings.token.token-name')" required> </v-text-field>
</v-form>
<div v-if="createdToken != ''">
<v-textarea
class="mb-0 pb-0"
:label="$t('settings.token.api-token')"
readonly
v-model="createdToken"
:append-outer-icon="$globals.icons.contentCopy"
@click:append-outer="copyToken"
>
</v-textarea>
<v-subheader class="text-center">
{{
$t(
"settings.token.copy-this-token-for-use-with-an-external-application-this-token-will-not-be-viewable-again"
)
}}
</v-subheader>
</div>
</v-card-text>
<template v-slot:open="{ open }">
<TheButton create @click="open" />
</template>
</BaseDialog>
</v-card-actions>
</template>
</StatCard>
</template>
<script>
import BaseDialog from "@/components/UI/Dialogs/BaseDialog";
import StatCard from "@/components/UI/StatCard";
import { api } from "@/api";
import { validators } from "@/mixins/validators";
import { initials } from "@/mixins/initials";
export default {
components: {
BaseDialog,
StatCard,
},
mixins: [validators, initials],
data() {
return {
name: "",
loading: false,
createdToken: "",
};
},
created() {
this.$store.dispatch("requestUserData");
},
computed: {
user() {
return this.$store.getters.getUserData;
},
buttonText() {
if (this.createdToken === "") {
return this.$t("general.create");
} else {
return this.$t("general.close");
}
},
},
methods: {
async createToken() {
if (this.loading === true) {
this.loading = false;
this.$store.dispatch("requestUserData");
this.createdToken = "";
this.name = "";
return;
}
this.loading = true;
if (this.$refs.newTokenForm.validate()) {
const response = await api.users.createAPIToken(this.name);
this.createdToken = response.token;
}
},
async deleteToken(id) {
await api.users.deleteAPIToken(id);
this.$store.dispatch("requestUserData");
},
copyToken() {
const copyText = this.createdToken;
navigator.clipboard.writeText(copyText).then(
() => console.log("Copied", copyText),
() => console.log("Copied Failed", copyText)
);
},
},
};
</script>
<style></style>

View file

@ -1,211 +0,0 @@
<template>
<StatCard :icon="$globals.icons.group">
<template v-slot:after-heading>
<div class="ml-auto text-right">
<div class="body-3 grey--text font-weight-light" v-text="$t('group.group')" />
<h3 class="display-2 font-weight-light text--primary">
<small> {{ currentGroup.name }} </small>
</h3>
</div>
</template>
<template v-slot:bottom>
<div v-if="todaysMeal">
<v-subheader>{{ $t("meal-plan.dinner-tonight") }}</v-subheader>
<MobileRecipeCard
:name="todaysMeal.name"
:slug="todaysMeal.slug"
:description="todaysMeal.description"
:rating="todaysMeal.rating"
:tags="true"
/>
</div>
<v-subheader>{{ $t("user.users-header") }}</v-subheader>
<v-divider></v-divider>
<v-virtual-scroll v-if="currentGroup.users" :items="currentGroup.users" height="257" item-height="64">
<template v-slot:default="{ item }">
<v-list-item :key="item.id" @click.prevent>
<v-list-item-action>
<v-btn fab small depressed color="primary">
{{ generateInitials(item.fullName) }}
</v-btn>
</v-list-item-action>
<v-list-item-content>
<v-list-item-title>
{{ item.fullName }}
</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-divider></v-divider>
</template>
</v-virtual-scroll>
<div class="mt-3">
<h3 class="display-2 font-weight-light text--primary">
<v-icon x-large>
{{ $globals.icons.food }}
</v-icon>
<small> {{ $t("meal-plan.mealplan-settings") }} </small>
</h3>
</div>
<v-divider></v-divider>
<v-subheader>{{ $t("meal-plan.mealplan-categories") }}</v-subheader>
<v-card-text class="mt-0 pt-0">
{{ $t("meal-plan.only-recipes-with-these-categories-will-be-used-in-meal-plans") }}
</v-card-text>
<CategoryTagSelector
:solo="true"
:dense="false"
v-model="groupSettings.categories"
:return-object="true"
:show-add="true"
/>
<v-divider></v-divider>
<v-subheader>{{ $t("settings.webhooks.webhooks-caps") }}</v-subheader>
<v-card-text class="mt-0 pt-0">
{{
$t(
"settings.webhooks.the-urls-listed-below-will-recieve-webhooks-containing-the-recipe-data-for-the-meal-plan-on-its-scheduled-day-currently-webhooks-will-execute-at"
)
}}
<strong>{{ groupSettings.webhookTime }}</strong>
</v-card-text>
<v-row dense class="flex align-center">
<v-switch class="ml-5 mr-auto" v-model="groupSettings.webhookEnable" :label="$t('general.enabled')"></v-switch>
<TimePickerDialog @save-time="saveTime" class="" />
</v-row>
<v-card-text>
<v-text-field
:prepend-icon="$globals.icons.delete"
v-for="(url, index) in groupSettings.webhookUrls"
@click:prepend="removeWebhook(index)"
:key="index"
v-model="groupSettings.webhookUrls[index]"
:label="$t('settings.webhooks.webhook-url')"
></v-text-field>
<v-card-actions class="pa-0">
<v-spacer></v-spacer>
<v-btn small color="success" @click="addWebhook">
<v-icon left> {{ $globals.icons.webhook }} </v-icon>
{{ $t("general.new") }}
</v-btn>
</v-card-actions>
</v-card-text>
<v-divider></v-divider>
<v-card-actions class="pb-0">
<v-btn class="ma-2" color="info" @click="testWebhooks">
<v-icon left> {{ $globals.icons.webhook }} </v-icon>
{{ $t("settings.webhooks.test-webhooks") }}
</v-btn>
<v-spacer></v-spacer>
<TheButton update @click="saveGroupSettings" />
</v-card-actions>
</template>
</StatCard>
</template>
<script>
import TimePickerDialog from "@/components/FormHelpers/TimePickerDialog";
import CategoryTagSelector from "@/components/FormHelpers/CategoryTagSelector";
import StatCard from "@/components/UI/StatCard";
import MobileRecipeCard from "@/components/Recipe/MobileRecipeCard";
import { validators } from "@/mixins/validators";
import { initials } from "@/mixins/initials";
import { api } from "@/api";
export default {
components: {
StatCard,
MobileRecipeCard,
CategoryTagSelector,
TimePickerDialog,
},
mixins: [validators, initials],
data() {
return {
todaysMeal: false,
hideImage: false,
passwordLoading: false,
password: {
current: "",
newOne: "",
newTwo: "",
},
groupSettings: {},
showPassword: false,
loading: false,
user: {
fullName: "",
email: "",
group: "",
admin: false,
id: 0,
},
};
},
computed: {
userProfileImage() {
this.resetImage();
return `api/users/${this.user.id}/image`;
},
currentGroup() {
return this.$store.getters.getCurrentGroup;
},
},
async created() {
this.getTodaysMeal();
await this.$store.dispatch("requestCurrentGroup");
this.getSiteSettings();
},
methods: {
async getTodaysMeal() {
const response = await api.mealPlans.today();
this.todaysMeal = response.data;
},
generateInitials(text) {
const allNames = text.trim().split(" ");
return allNames.reduce(
(acc, curr, index) => {
if (index === 0 || index === allNames.length - 1) {
acc = `${acc}${curr.charAt(0).toUpperCase()}`;
}
return acc;
},
[""]
);
},
getSiteSettings() {
this.groupSettings = this.$store.getters.getCurrentGroup;
},
saveTime(value) {
this.groupSettings.webhookTime = value;
},
addWebhook() {
this.groupSettings.webhookUrls.push(" ");
},
removeWebhook(index) {
this.groupSettings.webhookUrls.splice(index, 1);
},
async saveGroupSettings() {
if (await api.groups.update(this.groupSettings)) {
await this.$store.dispatch("requestCurrentGroup");
this.getSiteSettings();
}
},
testWebhooks() {
api.settings.testWebhooks();
},
},
};
</script>
<style></style>

View file

@ -1,225 +0,0 @@
<template>
<div>
<StatCard :icon="$globals.icons.formatColorFill" :color="color">
<template v-slot:after-heading>
<div class="ml-auto text-right">
<div class="body-3 grey--text font-weight-light" v-text="$t('general.themes')" />
<h3 class="display-2 font-weight-light text--primary">
<small> {{ selectedTheme.name }} </small>
</h3>
</div>
</template>
<template v-slot:actions>
<v-btn-toggle v-model="darkMode" color="primary " mandatory>
<v-btn small value="system">
<v-icon>{{ $globals.icons.desktopTowerMonitor }}</v-icon>
<span class="ml-1" v-show="$vuetify.breakpoint.smAndUp">
{{ $t("settings.theme.default-to-system") }}
</span>
</v-btn>
<v-btn small value="light">
<v-icon>{{ $globals.icons.weatherSunny }}</v-icon>
<span class="ml-1" v-show="$vuetify.breakpoint.smAndUp">
{{ $t("settings.theme.light") }}
</span>
</v-btn>
<v-btn small value="dark">
<v-icon>{{ $globals.icons.weatherNight }}</v-icon>
<span class="ml-1" v-show="$vuetify.breakpoint.smAndUp">
{{ $t("settings.theme.dark") }}
</span>
</v-btn>
</v-btn-toggle>
</template>
<template v-slot:bottom>
<v-virtual-scroll height="290" item-height="70" :items="availableThemes" class="mt-2">
<template v-slot:default="{ item }">
<v-divider></v-divider>
<v-list-item @click="selectedTheme = item">
<v-list-item-avatar>
<v-icon large dark :color="item.colors.primary">
{{ $globals.icons.formatColorFill }}
</v-icon>
</v-list-item-avatar>
<v-list-item-content>
<v-list-item-title v-text="item.name"></v-list-item-title>
<v-row flex align-center class="mt-2 justify-space-around px-4 pb-2">
<v-sheet
class="rounded flex mx-1"
v-for="(item, index) in item.colors"
:key="index"
:color="item"
height="20"
>
</v-sheet>
</v-row>
</v-list-item-content>
<v-list-item-action class="ml-auto">
<v-btn large icon @click.stop="editTheme(item)">
<v-icon color="accent">{{ $globals.icons.edit }}</v-icon>
</v-btn>
</v-list-item-action>
</v-list-item>
<v-divider></v-divider>
</template>
</v-virtual-scroll>
<v-divider></v-divider>
<v-card-actions>
<TheButton class="ml-auto mt-1 mb-n1" create @click="createTheme" />
</v-card-actions>
</template>
</StatCard>
<BaseDialog
:loading="loading"
:title="modalLabel.title"
:title-icon="$globals.icons.formatColorFill"
modal-width="700"
ref="themeDialog"
:submit-text="modalLabel.button"
@submit="processSubmit"
@delete="deleteTheme"
>
<v-card-text class="mt-3">
<v-text-field
:label="$t('settings.theme.theme-name')"
v-model="defaultData.name"
:rules="[rules.required]"
:append-outer-icon="jsonEditor ? $globals.icons.formSelect : $globals.icons.codeBraces"
@click:append-outer="jsonEditor = !jsonEditor"
></v-text-field>
<v-row dense dflex wrap justify-content-center v-if="defaultData.colors && !jsonEditor">
<v-col cols="12" sm="6" v-for="(_, key) in defaultData.colors" :key="key">
<ColorPickerDialog :button-text="labels[key]" v-model="defaultData.colors[key]" />
</v-col>
</v-row>
<VJsoneditor @error="logError()" v-else v-model="defaultData" height="250px" :options="jsonEditorOptions" />
</v-card-text>
</BaseDialog>
</div>
</template>
<script>
import { api } from "@/api";
import ColorPickerDialog from "@/components/FormHelpers/ColorPickerDialog";
import BaseDialog from "@/components/UI/Dialogs/BaseDialog";
import StatCard from "@/components/UI/StatCard";
export default {
components: {
StatCard,
BaseDialog,
ColorPickerDialog,
VJsoneditor: () => import(/* webpackChunkName: "json-editor" */ "v-jsoneditor"),
},
data() {
return {
jsonEditor: false,
jsonEditorOptions: {
mode: "code",
search: false,
mainMenuBar: false,
},
availableThemes: [],
color: "accent",
newTheme: false,
loading: false,
defaultData: {
name: "",
colors: {
primary: "#E58325",
accent: "#00457A",
secondary: "#973542",
success: "#43A047",
info: "#4990BA",
warning: "#FF4081",
error: "#EF5350",
},
},
rules: {
required: val => !!val || this.$t("settings.theme.theme-name-is-required"),
},
};
},
computed: {
labels() {
return {
primary: this.$t("settings.theme.primary"),
secondary: this.$t("settings.theme.secondary"),
accent: this.$t("settings.theme.accent"),
success: this.$t("settings.theme.success"),
info: this.$t("settings.theme.info"),
warning: this.$t("settings.theme.warning"),
error: this.$t("settings.theme.error"),
};
},
modalLabel() {
if (this.newTheme) {
return {
title: this.$t("settings.add-a-new-theme"),
button: this.$t("general.create"),
};
} else {
return {
title: "Update Theme",
button: this.$t("general.update"),
};
}
},
selectedTheme: {
set(val) {
this.$store.commit("setTheme", val);
},
get() {
return this.$store.getters.getActiveTheme;
},
},
darkMode: {
set(val) {
this.$store.commit("setDarkMode", val);
},
get() {
return this.$store.getters.getDarkMode;
},
},
},
async created() {
await this.getAllThemes();
},
methods: {
async getAllThemes() {
this.availableThemes = await api.themes.requestAll();
},
editTheme(theme) {
this.defaultData = theme;
this.newTheme = false;
this.$refs.themeDialog.open();
},
createTheme() {
this.newTheme = true;
this.$refs.themeDialog.open();
},
async processSubmit() {
if (this.newTheme) {
await api.themes.create(this.defaultData);
} else {
await api.themes.update(this.defaultData);
}
this.getAllThemes();
},
async deleteTheme() {
await api.themes.delete(this.defaultData.id);
this.getAllThemes();
},
},
};
</script>
<style lang="scss" scoped>
</style>

Some files were not shown because too many files have changed in this diff Show more