mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-07-24 15:49:42 +02:00
feature/category-tag-crud (#354)
* update tag route * search.and * offset for mobile * relative imports * get settings * new page * category/tag CRUD * bulk assign frontend * Bulk assign * debounce search * remove dev data * recipe store refactor * fix mobile view * fix failing tests * commit test data Co-authored-by: hay-kot <hay-kot@pm.me>
This commit is contained in:
parent
f748bbba68
commit
846d1eda5b
40 changed files with 1028 additions and 145 deletions
40
.gitignore
vendored
40
.gitignore
vendored
|
@ -10,7 +10,6 @@ mealie/temp/api.html
|
||||||
.temp/
|
.temp/
|
||||||
.secret
|
.secret
|
||||||
|
|
||||||
|
|
||||||
dev/data/backups/*
|
dev/data/backups/*
|
||||||
dev/data/debug/*
|
dev/data/debug/*
|
||||||
dev/data/img/*
|
dev/data/img/*
|
||||||
|
@ -157,39 +156,6 @@ mealie/data/debug/last_recipe.json
|
||||||
*.sqlite
|
*.sqlite
|
||||||
dev/data/db/test.db
|
dev/data/db/test.db
|
||||||
scratch.py
|
scratch.py
|
||||||
frontend/dist/favicon.ico
|
dev/data/backups/dev_sample_data*.zip
|
||||||
frontend/dist/index.html
|
dev/data/backups/dev_sample_data*.zip
|
||||||
frontend/dist/css/app.29fe0155.css
|
!dev/data/backups/test*.zip
|
||||||
frontend/dist/css/chunk-vendors.db944396.css
|
|
||||||
frontend/dist/fonts/materialdesignicons-webfont.7a44ea19.woff2
|
|
||||||
frontend/dist/fonts/materialdesignicons-webfont.64d4cf64.eot
|
|
||||||
frontend/dist/fonts/materialdesignicons-webfont.147e3378.woff
|
|
||||||
frontend/dist/fonts/materialdesignicons-webfont.174c02fc.ttf
|
|
||||||
frontend/dist/fonts/roboto-latin-100.5cb7edfc.woff
|
|
||||||
frontend/dist/fonts/roboto-latin-100.7370c367.woff2
|
|
||||||
frontend/dist/fonts/roboto-latin-100italic.f8b1df51.woff2
|
|
||||||
frontend/dist/fonts/roboto-latin-100italic.f9e8e590.woff
|
|
||||||
frontend/dist/fonts/roboto-latin-300.b00849e0.woff
|
|
||||||
frontend/dist/fonts/roboto-latin-300.ef7c6637.woff2
|
|
||||||
frontend/dist/fonts/roboto-latin-300italic.4df32891.woff
|
|
||||||
frontend/dist/fonts/roboto-latin-300italic.14286f3b.woff2
|
|
||||||
frontend/dist/fonts/roboto-latin-400.60fa3c06.woff
|
|
||||||
frontend/dist/fonts/roboto-latin-400.479970ff.woff2
|
|
||||||
frontend/dist/fonts/roboto-latin-400italic.51521a2a.woff2
|
|
||||||
frontend/dist/fonts/roboto-latin-400italic.fe65b833.woff
|
|
||||||
frontend/dist/fonts/roboto-latin-500.020c97dc.woff2
|
|
||||||
frontend/dist/fonts/roboto-latin-500.87284894.woff
|
|
||||||
frontend/dist/fonts/roboto-latin-500italic.288ad9c6.woff
|
|
||||||
frontend/dist/fonts/roboto-latin-500italic.db4a2a23.woff2
|
|
||||||
frontend/dist/fonts/roboto-latin-700.2735a3a6.woff2
|
|
||||||
frontend/dist/fonts/roboto-latin-700.adcde98f.woff
|
|
||||||
frontend/dist/fonts/roboto-latin-700italic.81f57861.woff
|
|
||||||
frontend/dist/fonts/roboto-latin-700italic.da0e7178.woff2
|
|
||||||
frontend/dist/fonts/roboto-latin-900.9b3766ef.woff2
|
|
||||||
frontend/dist/fonts/roboto-latin-900.bb1e4dc6.woff
|
|
||||||
frontend/dist/fonts/roboto-latin-900italic.28f91510.woff
|
|
||||||
frontend/dist/fonts/roboto-latin-900italic.ebf6d164.woff2
|
|
||||||
frontend/dist/js/app.36f2760c.js
|
|
||||||
frontend/dist/js/app.36f2760c.js.map
|
|
||||||
frontend/dist/js/chunk-vendors.c93761e4.js
|
|
||||||
frontend/dist/js/chunk-vendors.c93761e4.js.map
|
|
||||||
|
|
Binary file not shown.
|
@ -19,6 +19,11 @@
|
||||||
## Features and Improvements
|
## Features and Improvements
|
||||||
|
|
||||||
### General
|
### General
|
||||||
|
- New Toolbox Page!
|
||||||
|
- Bulk assign categories and tags by keyword search
|
||||||
|
- Title case all Categories or Tags with 1 click
|
||||||
|
- Create/Rename/Delete Operations for Tags/Categories
|
||||||
|
- Remove Unused Categories or Tags with 1 click
|
||||||
- More localization
|
- More localization
|
||||||
- Start date for Week is now selectable
|
- Start date for Week is now selectable
|
||||||
- Languages are now managed through Crowdin
|
- Languages are now managed through Crowdin
|
||||||
|
|
|
@ -46,6 +46,7 @@ export default {
|
||||||
this.darkModeAddEventListener();
|
this.darkModeAddEventListener();
|
||||||
this.$store.dispatch("requestAppInfo");
|
this.$store.dispatch("requestAppInfo");
|
||||||
this.$store.dispatch("requestCustomPages");
|
this.$store.dispatch("requestCustomPages");
|
||||||
|
this.$store.dispatch("requestSiteSettings");
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
|
|
|
@ -39,6 +39,16 @@ const apiReq = {
|
||||||
processResponse(response);
|
processResponse(response);
|
||||||
return response;
|
return response;
|
||||||
},
|
},
|
||||||
|
patch: async function(url, data) {
|
||||||
|
let response = await axios.patch(url, data).catch(function(error) {
|
||||||
|
if (error.response) {
|
||||||
|
processResponse(error.response);
|
||||||
|
return response;
|
||||||
|
} else return;
|
||||||
|
});
|
||||||
|
processResponse(response);
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
|
||||||
get: async function(url, data) {
|
get: async function(url, data) {
|
||||||
let response = await axios.get(url, data).catch(function(error) {
|
let response = await axios.get(url, data).catch(function(error) {
|
||||||
|
|
|
@ -6,8 +6,10 @@ const prefix = baseURL + "categories";
|
||||||
|
|
||||||
const categoryURLs = {
|
const categoryURLs = {
|
||||||
getAll: `${prefix}`,
|
getAll: `${prefix}`,
|
||||||
|
getEmpty: `${prefix}/empty`,
|
||||||
getCategory: category => `${prefix}/${category}`,
|
getCategory: category => `${prefix}/${category}`,
|
||||||
deleteCategory: category => `${prefix}/${category}`,
|
deleteCategory: category => `${prefix}/${category}`,
|
||||||
|
updateCategory: category => `${prefix}/${category}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const categoryAPI = {
|
export const categoryAPI = {
|
||||||
|
@ -15,6 +17,10 @@ export const categoryAPI = {
|
||||||
let response = await apiReq.get(categoryURLs.getAll);
|
let response = await apiReq.get(categoryURLs.getAll);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
async getEmpty() {
|
||||||
|
let response = await apiReq.get(categoryURLs.getEmpty);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
async create(name) {
|
async create(name) {
|
||||||
let response = await apiReq.post(categoryURLs.getAll, { name: name });
|
let response = await apiReq.post(categoryURLs.getAll, { name: name });
|
||||||
store.dispatch("requestCategories");
|
store.dispatch("requestCategories");
|
||||||
|
@ -24,9 +30,20 @@ export const categoryAPI = {
|
||||||
let response = await apiReq.get(categoryURLs.getCategory(category));
|
let response = await apiReq.get(categoryURLs.getCategory(category));
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
async delete(category) {
|
async update(name, newName, overrideRequest = false) {
|
||||||
|
let response = await apiReq.put(categoryURLs.updateCategory(name), {
|
||||||
|
name: newName,
|
||||||
|
});
|
||||||
|
if (!overrideRequest) {
|
||||||
|
store.dispatch("requestCategories");
|
||||||
|
}
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
async delete(category, overrideRequest = false) {
|
||||||
let response = await apiReq.delete(categoryURLs.deleteCategory(category));
|
let response = await apiReq.delete(categoryURLs.deleteCategory(category));
|
||||||
store.dispatch("requestCategories");
|
if (!overrideRequest) {
|
||||||
|
store.dispatch("requestCategories");
|
||||||
|
}
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -35,8 +52,10 @@ const tagPrefix = baseURL + "tags";
|
||||||
|
|
||||||
const tagURLs = {
|
const tagURLs = {
|
||||||
getAll: `${tagPrefix}`,
|
getAll: `${tagPrefix}`,
|
||||||
|
getEmpty: `${tagPrefix}/empty`,
|
||||||
getTag: tag => `${tagPrefix}/${tag}`,
|
getTag: tag => `${tagPrefix}/${tag}`,
|
||||||
deleteTag: tag => `${tagPrefix}/${tag}`,
|
deleteTag: tag => `${tagPrefix}/${tag}`,
|
||||||
|
updateTag: tag => `${tagPrefix}/${tag}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const tagAPI = {
|
export const tagAPI = {
|
||||||
|
@ -44,6 +63,10 @@ export const tagAPI = {
|
||||||
let response = await apiReq.get(tagURLs.getAll);
|
let response = await apiReq.get(tagURLs.getAll);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
async getEmpty() {
|
||||||
|
let response = await apiReq.get(tagURLs.getEmpty);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
async create(name) {
|
async create(name) {
|
||||||
let response = await apiReq.post(tagURLs.getAll, { name: name });
|
let response = await apiReq.post(tagURLs.getAll, { name: name });
|
||||||
store.dispatch("requestTags");
|
store.dispatch("requestTags");
|
||||||
|
@ -53,9 +76,20 @@ export const tagAPI = {
|
||||||
let response = await apiReq.get(tagURLs.getTag(tag));
|
let response = await apiReq.get(tagURLs.getTag(tag));
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
async delete(tag) {
|
async update(name, newName, overrideRequest = false) {
|
||||||
|
let response = await apiReq.put(tagURLs.updateTag(name), { name: newName });
|
||||||
|
|
||||||
|
if (!overrideRequest) {
|
||||||
|
store.dispatch("requestTags");
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
async delete(tag, overrideRequest = false) {
|
||||||
let response = await apiReq.delete(tagURLs.deleteTag(tag));
|
let response = await apiReq.delete(tagURLs.deleteTag(tag));
|
||||||
store.dispatch("requestTags");
|
if (!overrideRequest) {
|
||||||
|
store.dispatch("requestTags");
|
||||||
|
}
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -67,7 +67,13 @@ export const recipeAPI = {
|
||||||
|
|
||||||
async update(data) {
|
async update(data) {
|
||||||
let response = await apiReq.put(recipeURLs.update(data.slug), data);
|
let response = await apiReq.put(recipeURLs.update(data.slug), data);
|
||||||
store.dispatch("requestRecentRecipes");
|
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(recipeURLs.update(data.slug), data);
|
||||||
|
store.dispatch("patchRecipe", response.data);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,8 @@
|
||||||
<v-card-title class=" headline">
|
<v-card-title class=" headline">
|
||||||
{{ $t("meal-plan.create-a-new-meal-plan") }}
|
{{ $t("meal-plan.create-a-new-meal-plan") }}
|
||||||
<v-btn color="info" class="ml-auto" @click="setQuickWeek()">
|
<v-btn color="info" class="ml-auto" @click="setQuickWeek()">
|
||||||
<v-icon left>mdi-calendar-minus</v-icon> {{$t('meal-plan.quick-week')}}
|
<v-icon left>mdi-calendar-minus</v-icon>
|
||||||
|
{{ $t("meal-plan.quick-week") }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
|
|
||||||
|
@ -153,7 +154,7 @@ export default {
|
||||||
return recipes.length > 0 ? recipes : this.items;
|
return recipes.length > 0 ? recipes : this.items;
|
||||||
},
|
},
|
||||||
allRecipes() {
|
allRecipes() {
|
||||||
return this.$store.getters.getRecentRecipes;
|
return this.$store.getters.getAllRecipes;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -60,10 +60,10 @@
|
||||||
<v-row v-else dense>
|
<v-row v-else dense>
|
||||||
<v-col
|
<v-col
|
||||||
cols="12"
|
cols="12"
|
||||||
sm="12"
|
:sm="singleColumn ? '12' : '12'"
|
||||||
md="6"
|
:md="singleColumn ? '12' : '6'"
|
||||||
lg="4"
|
:lg="singleColumn ? '12' : '4'"
|
||||||
xl="3"
|
:xl="singleColumn ? '12' : '3'"
|
||||||
v-for="recipe in recipes.slice(0, cardLimit)"
|
v-for="recipe in recipes.slice(0, cardLimit)"
|
||||||
:key="recipe.name"
|
:key="recipe.name"
|
||||||
>
|
>
|
||||||
|
@ -79,14 +79,16 @@
|
||||||
</v-row>
|
</v-row>
|
||||||
</div>
|
</div>
|
||||||
<div v-intersect="bumpList" class="d-flex">
|
<div v-intersect="bumpList" class="d-flex">
|
||||||
<v-progress-circular
|
<v-expand-x-transition>
|
||||||
v-if="loading"
|
<v-progress-circular
|
||||||
class="mx-auto mt-1"
|
v-if="loading"
|
||||||
:size="50"
|
class="mx-auto mt-1"
|
||||||
:width="7"
|
:size="50"
|
||||||
color="primary"
|
:width="7"
|
||||||
indeterminate
|
color="primary"
|
||||||
></v-progress-circular>
|
indeterminate
|
||||||
|
></v-progress-circular>
|
||||||
|
</v-expand-x-transition>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
@ -109,6 +111,12 @@ export default {
|
||||||
hardLimit: {
|
hardLimit: {
|
||||||
default: 99999,
|
default: 99999,
|
||||||
},
|
},
|
||||||
|
mobileCards: {
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
singleColumn: {
|
||||||
|
defualt: false,
|
||||||
|
},
|
||||||
recipes: Array,
|
recipes: Array,
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
|
@ -117,8 +125,14 @@ export default {
|
||||||
loading: false,
|
loading: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
watch: {
|
||||||
|
recipes() {
|
||||||
|
this.bumpList();
|
||||||
|
},
|
||||||
|
},
|
||||||
computed: {
|
computed: {
|
||||||
viewScale() {
|
viewScale() {
|
||||||
|
if (this.mobileCards) return true;
|
||||||
switch (this.$vuetify.breakpoint.name) {
|
switch (this.$vuetify.breakpoint.name) {
|
||||||
case "xs":
|
case "xs":
|
||||||
return true;
|
return true;
|
||||||
|
@ -128,10 +142,16 @@ export default {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
effectiveHardLimit() {
|
||||||
|
return Math.min(this.hardLimit, this.recipes.length);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
bumpList() {
|
bumpList() {
|
||||||
const newCardLimit = Math.min(this.cardLimit + 20, this.hardLimit);
|
const newCardLimit = Math.min(
|
||||||
|
this.cardLimit + 20,
|
||||||
|
this.effectiveHardLimit
|
||||||
|
);
|
||||||
|
|
||||||
if (this.loading === false && newCardLimit > this.cardLimit) {
|
if (this.loading === false && newCardLimit > this.cardLimit) {
|
||||||
this.setLoader();
|
this.setLoader();
|
||||||
|
@ -141,7 +161,7 @@ export default {
|
||||||
},
|
},
|
||||||
async setLoader() {
|
async setLoader() {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
await new Promise(r => setTimeout(r, 3000));
|
await new Promise(r => setTimeout(r, 1000));
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,21 +1,38 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<v-dialog v-model="dialog" :width="modalWidth + 'px'">
|
<v-dialog
|
||||||
<v-app-bar dark :color="color" class="mt-n1 mb-2">
|
v-model="dialog"
|
||||||
<v-icon large left v-if="!loading">
|
:width="modalWidth + 'px'"
|
||||||
{{ titleIcon }}
|
:content-class="top ? 'top-dialog' : undefined"
|
||||||
</v-icon>
|
>
|
||||||
<v-progress-circular
|
<v-card class="pb-10" height="100%">
|
||||||
v-else
|
<v-app-bar dark :color="color" class="mt-n1 mb-2">
|
||||||
|
<v-icon large left>
|
||||||
|
{{ titleIcon }}
|
||||||
|
</v-icon>
|
||||||
|
<v-toolbar-title class="headline"> {{ title }} </v-toolbar-title>
|
||||||
|
<v-spacer></v-spacer>
|
||||||
|
</v-app-bar>
|
||||||
|
<v-progress-linear
|
||||||
|
v-if="loading"
|
||||||
indeterminate
|
indeterminate
|
||||||
color="white"
|
color="primary"
|
||||||
large
|
></v-progress-linear>
|
||||||
class="mr-2"
|
<slot> </slot>
|
||||||
>
|
<v-card-actions>
|
||||||
</v-progress-circular>
|
<slot name="card-actions">
|
||||||
<v-toolbar-title class="headline"> {{ title }} </v-toolbar-title>
|
<v-btn text color="grey" @click="dialog = false">
|
||||||
<v-spacer></v-spacer>
|
Cancel
|
||||||
</v-app-bar>
|
</v-btn>
|
||||||
|
<v-spacer></v-spacer>
|
||||||
|
<v-btn color="success" @click="$emit('submit')">
|
||||||
|
Submit
|
||||||
|
</v-btn>
|
||||||
|
</slot>
|
||||||
|
</v-card-actions>
|
||||||
|
|
||||||
|
<slot name="below-actions"> </slot>
|
||||||
|
</v-card>
|
||||||
</v-dialog>
|
</v-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
@ -35,6 +52,12 @@ export default {
|
||||||
modalWidth: {
|
modalWidth: {
|
||||||
default: "500",
|
default: "500",
|
||||||
},
|
},
|
||||||
|
loading: {
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
top: {
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
@ -45,9 +68,15 @@ export default {
|
||||||
open() {
|
open() {
|
||||||
this.dialog = true;
|
this.dialog = true;
|
||||||
},
|
},
|
||||||
|
close() {
|
||||||
|
this.dialog = false;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.top-dialog {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
|
@ -1,8 +1,10 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<v-btn icon @click="dialog = true" class="mt-n1">
|
<slot>
|
||||||
<v-icon :color="color">mdi-plus</v-icon>
|
<v-btn icon @click="dialog = true" class="mt-n1">
|
||||||
</v-btn>
|
<v-icon :color="color">mdi-plus</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</slot>
|
||||||
<v-dialog v-model="dialog" width="500">
|
<v-dialog v-model="dialog" width="500">
|
||||||
<v-card>
|
<v-card>
|
||||||
<v-app-bar dense dark color="primary mb-2">
|
<v-app-bar dense dark color="primary mb-2">
|
||||||
|
@ -80,6 +82,9 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
|
open() {
|
||||||
|
this.dialog = true;
|
||||||
|
},
|
||||||
async select() {
|
async select() {
|
||||||
const newItem = await (async () => {
|
const newItem = await (async () => {
|
||||||
if (this.tagDialog) {
|
if (this.tagDialog) {
|
||||||
|
|
79
frontend/src/components/UI/Search/FuseSearchBar.vue
Normal file
79
frontend/src/components/UI/Search/FuseSearchBar.vue
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
<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>
|
||||||
|
</style>
|
|
@ -132,7 +132,7 @@ export default {
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
data() {
|
data() {
|
||||||
return this.$store.getters.getRecentRecipes;
|
return this.$store.getters.getAllRecipes;
|
||||||
},
|
},
|
||||||
autoResults() {
|
autoResults() {
|
||||||
return this.fuseResults.length > 1 ? this.fuseResults : this.results;
|
return this.fuseResults.length > 1 ? this.fuseResults : this.results;
|
||||||
|
|
|
@ -163,6 +163,11 @@ export default {
|
||||||
to: "/admin/settings",
|
to: "/admin/settings",
|
||||||
title: this.$t("settings.site-settings"),
|
title: this.$t("settings.site-settings"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: "mdi-tools",
|
||||||
|
to: "/admin/toolbox",
|
||||||
|
title: this.$t("settings.toolbox.toolbox"),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: "mdi-account-group",
|
icon: "mdi-account-group",
|
||||||
to: "/admin/manage-users",
|
to: "/admin/manage-users",
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
bottom
|
bottom
|
||||||
right
|
right
|
||||||
offset-y
|
offset-y
|
||||||
|
offset-overflow
|
||||||
open-on-hover
|
open-on-hover
|
||||||
close-delay="200"
|
close-delay="200"
|
||||||
>
|
>
|
||||||
|
@ -72,7 +73,7 @@ export default {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: "mdi-logout",
|
icon: "mdi-logout",
|
||||||
title: this.$t('user.logout'),
|
title: this.$t("user.logout"),
|
||||||
restricted: true,
|
restricted: true,
|
||||||
nav: "/logout",
|
nav: "/logout",
|
||||||
},
|
},
|
||||||
|
@ -85,7 +86,6 @@ export default {
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
mounted() {},
|
|
||||||
computed: {
|
computed: {
|
||||||
filteredItems() {
|
filteredItems() {
|
||||||
if (this.loggedIn) {
|
if (this.loggedIn) {
|
||||||
|
|
|
@ -152,6 +152,7 @@
|
||||||
"exclude": "Exclude",
|
"exclude": "Exclude",
|
||||||
"include": "Include",
|
"include": "Include",
|
||||||
"or": "Or",
|
"or": "Or",
|
||||||
|
"and": "and",
|
||||||
"search": "Search",
|
"search": "Search",
|
||||||
"tag-filter": "Tag Filter"
|
"tag-filter": "Tag Filter"
|
||||||
},
|
},
|
||||||
|
@ -217,6 +218,16 @@
|
||||||
"test-webhooks": "Test Webhooks",
|
"test-webhooks": "Test 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": "The URLs listed below will receive webhooks containing the recipe data for the meal plan on it's scheduled day. Currently Webhooks will execute at",
|
"the-urls-listed-below-will-recieve-webhooks-containing-the-recipe-data-for-the-meal-plan-on-its-scheduled-day-currently-webhooks-will-execute-at": "The URLs listed below will receive webhooks containing the recipe data for the meal plan on it's scheduled day. Currently Webhooks will execute at",
|
||||||
"webhook-url": "Webhook URL"
|
"webhook-url": "Webhook URL"
|
||||||
|
},
|
||||||
|
"toolbox": {
|
||||||
|
"toolbox": "Toolbox",
|
||||||
|
"new-name": "New Name",
|
||||||
|
"recipes-effected": "Recipes Effected",
|
||||||
|
"title-case-all": "Title Case All",
|
||||||
|
"no-unused-items": "No Unused Items",
|
||||||
|
"remove-unused": "Remove Unused",
|
||||||
|
"assign-all": "Assign All",
|
||||||
|
"bulk-assign": "Bulk Assign"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"user": {
|
"user": {
|
||||||
|
|
|
@ -0,0 +1,171 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<base-dialog
|
||||||
|
ref="assignDialog"
|
||||||
|
title-icon="mdi-tag"
|
||||||
|
color="primary"
|
||||||
|
title="Bulk Assign"
|
||||||
|
:loading="loading"
|
||||||
|
modal-width="700"
|
||||||
|
:top="true"
|
||||||
|
>
|
||||||
|
<v-card-text>
|
||||||
|
<v-text-field
|
||||||
|
v-model="search"
|
||||||
|
autocomplete="off"
|
||||||
|
label="Keyword"
|
||||||
|
></v-text-field>
|
||||||
|
<CategoryTagSelector
|
||||||
|
:tag-selector="false"
|
||||||
|
v-model="catsToAssign"
|
||||||
|
:return-object="false"
|
||||||
|
/>
|
||||||
|
<CategoryTagSelector
|
||||||
|
:tag-selector="true"
|
||||||
|
v-model="tagsToAssign"
|
||||||
|
:return-object="false"
|
||||||
|
/>
|
||||||
|
</v-card-text>
|
||||||
|
<template slot="card-actions">
|
||||||
|
<v-btn text color="grey" @click="closeDialog">
|
||||||
|
{{ $t("general.cancel") }}
|
||||||
|
</v-btn>
|
||||||
|
<v-spacer></v-spacer>
|
||||||
|
<v-btn
|
||||||
|
color="success"
|
||||||
|
@click="assignAll"
|
||||||
|
:loading="loading"
|
||||||
|
:disabled="results.length < 1"
|
||||||
|
>
|
||||||
|
{{ $t("settings.toolbox.assign-all") }}
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
<template slot="below-actions">
|
||||||
|
<v-card-title class="headline"> </v-card-title>
|
||||||
|
<CardSection
|
||||||
|
class="px-2 pb-2"
|
||||||
|
:title="`${results.length || 0} Recipes Effected`"
|
||||||
|
:mobile-cards="true"
|
||||||
|
:recipes="results"
|
||||||
|
:single-column="true"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</base-dialog>
|
||||||
|
|
||||||
|
<v-btn @click="openDialog" small color="success">
|
||||||
|
{{ $t("settings.toolbox.bulk-assign") }}
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import CardSection from "@/components/UI/CardSection";
|
||||||
|
import CategoryTagSelector from "@/components/FormHelpers/CategoryTagSelector";
|
||||||
|
import BaseDialog from "@/components/UI/Dialogs/BaseDialog";
|
||||||
|
import { api } from "@/api";
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
isTags: {
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
CardSection,
|
||||||
|
BaseDialog,
|
||||||
|
CategoryTagSelector,
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
results: [],
|
||||||
|
search: "",
|
||||||
|
loading: false,
|
||||||
|
assignTargetRecipes: [],
|
||||||
|
catsToAssign: [],
|
||||||
|
tagsToAssign: [],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.$store.dispatch("requestAllRecipes");
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
search() {
|
||||||
|
this.getResults();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
allRecipes() {
|
||||||
|
return this.$store.getters.getAllRecipes;
|
||||||
|
},
|
||||||
|
// results() {
|
||||||
|
// if (this.search === null || this.search === "") {
|
||||||
|
// return [];
|
||||||
|
// }
|
||||||
|
// return this.allRecipes.filter(x => {
|
||||||
|
// return (
|
||||||
|
// this.checkForKeywords(x.name) || this.checkForKeywords(x.description)
|
||||||
|
// );
|
||||||
|
// });
|
||||||
|
// },
|
||||||
|
keywords() {
|
||||||
|
const lowered = this.search.toLowerCase();
|
||||||
|
return lowered.split(" ");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
reset() {
|
||||||
|
this.search = "";
|
||||||
|
this.loading = false;
|
||||||
|
this.assignTargetRecipes = [];
|
||||||
|
this.catsToAssign = [];
|
||||||
|
this.tagsToAssign = [];
|
||||||
|
},
|
||||||
|
assignAll() {
|
||||||
|
this.loading = true;
|
||||||
|
this.results.forEach(async element => {
|
||||||
|
element.recipeCategory = element.recipeCategory.concat(
|
||||||
|
this.catsToAssign
|
||||||
|
);
|
||||||
|
element.tags = element.tags.concat(this.tagsToAssign);
|
||||||
|
await api.recipes.patch(element);
|
||||||
|
});
|
||||||
|
this.loading = false;
|
||||||
|
this.closeDialog();
|
||||||
|
},
|
||||||
|
closeDialog() {
|
||||||
|
this.$refs.assignDialog.close();
|
||||||
|
},
|
||||||
|
async openDialog() {
|
||||||
|
this.$refs.assignDialog.open();
|
||||||
|
this.reset();
|
||||||
|
},
|
||||||
|
getResults() {
|
||||||
|
this.loading = true;
|
||||||
|
|
||||||
|
// cancel pending call
|
||||||
|
clearTimeout(this._timerId);
|
||||||
|
this._timerId = setTimeout(() => {
|
||||||
|
this.results = this.filterResults();
|
||||||
|
}, 300);
|
||||||
|
this.loading = false;
|
||||||
|
// delay new call 500ms
|
||||||
|
},
|
||||||
|
filterResults() {
|
||||||
|
if (this.search === null || this.search === "") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return this.allRecipes.filter(x => {
|
||||||
|
return (
|
||||||
|
this.checkForKeywords(x.name) || this.checkForKeywords(x.description)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
checkForKeywords(str) {
|
||||||
|
const searchStr = str.toLowerCase();
|
||||||
|
return this.keywords.some(x => searchStr.includes(x));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
</style>
|
|
@ -0,0 +1,100 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<base-dialog
|
||||||
|
ref="deleteDialog"
|
||||||
|
title-icon="mdi-tag"
|
||||||
|
color="error"
|
||||||
|
:title="
|
||||||
|
$t('general.delete') +
|
||||||
|
' ' +
|
||||||
|
(isTags ? $t('recipe.tags') : $t('recipe.categories'))
|
||||||
|
"
|
||||||
|
:loading="loading"
|
||||||
|
modal-width="400"
|
||||||
|
>
|
||||||
|
<v-list v-if="deleteList.length > 0">
|
||||||
|
<v-list-item v-for="item in deleteList" :key="item.slug">
|
||||||
|
<v-list-item-content>
|
||||||
|
{{ item.name }}
|
||||||
|
</v-list-item-content>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
<v-card-text v-else class=" mt-4 text-center">
|
||||||
|
{{ $t("settings.toolbox.no-unused-items") }}
|
||||||
|
</v-card-text>
|
||||||
|
<template slot="card-actions">
|
||||||
|
<v-btn text color="grey" @click="closeDialog">
|
||||||
|
{{ $t("general.cancel") }}
|
||||||
|
</v-btn>
|
||||||
|
<v-spacer></v-spacer>
|
||||||
|
<v-btn
|
||||||
|
color="error"
|
||||||
|
@click="deleteUnused"
|
||||||
|
:loading="loading"
|
||||||
|
:disabled="deleteList.length < 1"
|
||||||
|
>
|
||||||
|
{{ $t("general.delete") }}
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
</base-dialog>
|
||||||
|
|
||||||
|
<v-btn @click="openDialog" small color="error" class="mr-1">
|
||||||
|
{{ $t("settings.toolbox.remove-unused") }}
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import BaseDialog from "@/components/UI/Dialogs/BaseDialog";
|
||||||
|
import { api } from "@/api";
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
isTags: {
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
BaseDialog,
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
deleteList: [],
|
||||||
|
loading: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
closeDialog() {
|
||||||
|
this.$refs.deleteDialog.close();
|
||||||
|
},
|
||||||
|
async openDialog() {
|
||||||
|
this.$refs.deleteDialog.open();
|
||||||
|
console.log(this.isTags);
|
||||||
|
if (this.isTags) {
|
||||||
|
this.deleteList = await api.tags.getEmpty();
|
||||||
|
} else {
|
||||||
|
this.deleteList = await api.categories.getEmpty();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteUnused() {
|
||||||
|
this.loading = true;
|
||||||
|
if (this.isTags) {
|
||||||
|
this.deleteList.forEach(async element => {
|
||||||
|
await api.tags.delete(element.slug, true);
|
||||||
|
});
|
||||||
|
this.$store.dispatch("requestTags");
|
||||||
|
} else {
|
||||||
|
this.deleteList.forEach(async element => {
|
||||||
|
await api.categories.delete(element.slug, true);
|
||||||
|
});
|
||||||
|
this.$store.dispatch("requestCategories");
|
||||||
|
}
|
||||||
|
this.loading = false;
|
||||||
|
this.closeDialog();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
</style>
|
244
frontend/src/pages/Admin/ToolBox/CategoryTagEditor/index.vue
Normal file
244
frontend/src/pages/Admin/ToolBox/CategoryTagEditor/index.vue
Normal file
|
@ -0,0 +1,244 @@
|
||||||
|
<template>
|
||||||
|
<v-card outlined class="mt-n1">
|
||||||
|
<base-dialog
|
||||||
|
ref="renameDialog"
|
||||||
|
title-icon="mdi-tag"
|
||||||
|
:title="renameTarget.title"
|
||||||
|
modal-width="800"
|
||||||
|
@submit="renameFromDialog(renameTarget.slug, renameTarget.newName)"
|
||||||
|
>
|
||||||
|
<v-form ref="renameForm">
|
||||||
|
<v-card-text>
|
||||||
|
<v-text-field
|
||||||
|
:placeholder="$t('settings.toolbox.new-name')"
|
||||||
|
:rules="[existsRule]"
|
||||||
|
v-model="renameTarget.newName"
|
||||||
|
></v-text-field>
|
||||||
|
</v-card-text>
|
||||||
|
</v-form>
|
||||||
|
<template slot="below-actions">
|
||||||
|
<v-card-title class="headline">
|
||||||
|
{{ renameTarget.recipes.length || 0 }}
|
||||||
|
{{ $t("settings.toolbox.recipes-effected") }}
|
||||||
|
</v-card-title>
|
||||||
|
<MobileRecipeCard
|
||||||
|
class="ml-2 mr-2 mt-2 mb-2"
|
||||||
|
v-for="recipe in renameTarget.recipes"
|
||||||
|
:key="recipe.slug"
|
||||||
|
:slug="recipe.slug"
|
||||||
|
:name="recipe.name"
|
||||||
|
:description="recipe.description"
|
||||||
|
:rating="recipe.rating"
|
||||||
|
:route="false"
|
||||||
|
:tags="recipe.tags"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</base-dialog>
|
||||||
|
|
||||||
|
<div class="d-flex justify-center align-center pa-2 flex-wrap">
|
||||||
|
<new-category-tag-dialog ref="newDialog" :tag-dialog="isTags">
|
||||||
|
<v-btn @click="openNewDialog" small color="success" class="mr-1 mb-1">
|
||||||
|
{{ $t("general.create") }}
|
||||||
|
</v-btn>
|
||||||
|
</new-category-tag-dialog>
|
||||||
|
|
||||||
|
<BulkAssign isTags="isTags" class="mr-1 mb-1" />
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
@click="titleCaseAll"
|
||||||
|
small
|
||||||
|
color="success"
|
||||||
|
class="mr-1 mb-1"
|
||||||
|
:loading="loadingTitleCase"
|
||||||
|
>
|
||||||
|
{{ $t("settings.toolbox.title-case-all") }}
|
||||||
|
</v-btn>
|
||||||
|
<RemoveUnused :isTags="isTags" class="mb-1" />
|
||||||
|
|
||||||
|
<v-spacer v-if="!isMobile"> </v-spacer>
|
||||||
|
|
||||||
|
<fuse-search-bar
|
||||||
|
:raw-data="allItems"
|
||||||
|
@results="filterItems"
|
||||||
|
:search="searchString"
|
||||||
|
>
|
||||||
|
<v-text-field
|
||||||
|
v-model="searchString"
|
||||||
|
clearable
|
||||||
|
solo
|
||||||
|
dense
|
||||||
|
class="mx-2"
|
||||||
|
hide-details
|
||||||
|
single-line
|
||||||
|
:placeholder="$t('search.search')"
|
||||||
|
prepend-inner-icon="mdi-magnify"
|
||||||
|
>
|
||||||
|
</v-text-field>
|
||||||
|
</fuse-search-bar>
|
||||||
|
</div>
|
||||||
|
<v-divider></v-divider>
|
||||||
|
|
||||||
|
<v-card-text>
|
||||||
|
<v-row>
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
:sm="12"
|
||||||
|
:md="6"
|
||||||
|
:lg="4"
|
||||||
|
:xl="3"
|
||||||
|
v-for="item in results"
|
||||||
|
:key="item.id"
|
||||||
|
>
|
||||||
|
<v-card>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-card-title class="py-1">{{ item.name }}</v-card-title>
|
||||||
|
<v-spacer></v-spacer>
|
||||||
|
<v-btn small text color="info" @click="openEditDialog(item)">
|
||||||
|
Edit
|
||||||
|
</v-btn>
|
||||||
|
<v-btn small text color="error" @click="deleteItem(item.slug)"
|
||||||
|
>Delete
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import FuseSearchBar from "@/components/UI/Search/FuseSearchBar";
|
||||||
|
import MobileRecipeCard from "@/components/Recipe/MobileRecipeCard";
|
||||||
|
import BaseDialog from "@/components/UI/Dialogs/BaseDialog";
|
||||||
|
import { api } from "@/api";
|
||||||
|
import { validators } from "@/mixins/validators";
|
||||||
|
import RemoveUnused from "./RemoveUnused";
|
||||||
|
import BulkAssign from "./BulkAssign";
|
||||||
|
import NewCategoryTagDialog from "@/components/UI/Dialogs/NewCategoryTagDialog";
|
||||||
|
export default {
|
||||||
|
mixins: [validators],
|
||||||
|
components: {
|
||||||
|
BaseDialog,
|
||||||
|
MobileRecipeCard,
|
||||||
|
FuseSearchBar,
|
||||||
|
RemoveUnused,
|
||||||
|
NewCategoryTagDialog,
|
||||||
|
BulkAssign,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
isTags: {
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
loadingTitleCase: false,
|
||||||
|
searchString: "",
|
||||||
|
searchResults: [],
|
||||||
|
renameTarget: {
|
||||||
|
title: "",
|
||||||
|
name: "",
|
||||||
|
slug: "",
|
||||||
|
newName: "",
|
||||||
|
recipes: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
isMobile() {
|
||||||
|
return this.$vuetify.breakpoint.name === "xs";
|
||||||
|
},
|
||||||
|
allItems() {
|
||||||
|
return this.isTags
|
||||||
|
? this.$store.getters.getAllTags
|
||||||
|
: this.$store.getters.getAllCategories;
|
||||||
|
},
|
||||||
|
results() {
|
||||||
|
if (this.searchString != null && this.searchString.length >= 1) {
|
||||||
|
return this.searchResults;
|
||||||
|
}
|
||||||
|
return this.allItems;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
filterItems(val) {
|
||||||
|
this.searchResults = val.map(x => x.item);
|
||||||
|
},
|
||||||
|
openNewDialog() {
|
||||||
|
this.$refs.newDialog.open();
|
||||||
|
},
|
||||||
|
async openEditDialog(item) {
|
||||||
|
let fromAPI = {};
|
||||||
|
if (this.isTags) {
|
||||||
|
fromAPI = await api.tags.getRecipesInTag(item.slug);
|
||||||
|
} else {
|
||||||
|
fromAPI = await api.categories.getRecipesInCategory(item.slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.renameTarget = {
|
||||||
|
title: `Rename ${item.name}`,
|
||||||
|
name: item.name,
|
||||||
|
slug: item.slug,
|
||||||
|
newName: "",
|
||||||
|
recipes: fromAPI.recipes,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.$refs.renameDialog.open();
|
||||||
|
},
|
||||||
|
async deleteItem(name) {
|
||||||
|
if (this.isTags) {
|
||||||
|
await api.tags.delete(name);
|
||||||
|
} else {
|
||||||
|
await api.categories.delete(name);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async renameFromDialog(name, newName) {
|
||||||
|
if (this.$refs.renameForm.validate()) {
|
||||||
|
await this.rename(name, newName);
|
||||||
|
}
|
||||||
|
this.$refs.renameDialog.close();
|
||||||
|
},
|
||||||
|
async rename(name, newName) {
|
||||||
|
if (this.isTags) {
|
||||||
|
await api.tags.update(name, newName);
|
||||||
|
} else {
|
||||||
|
await api.categories.update(name, newName);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
titleCase(lowerName) {
|
||||||
|
return lowerName.replace(/(?:^|\s|-)\S/g, x => x.toUpperCase());
|
||||||
|
},
|
||||||
|
async titleCaseAll() {
|
||||||
|
this.loadingTitleCase = true;
|
||||||
|
const renameList = this.allItems.map(x => ({
|
||||||
|
slug: x.slug,
|
||||||
|
name: x.name,
|
||||||
|
newName: this.titleCase(x.name),
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (this.isTags) {
|
||||||
|
renameList.forEach(async element => {
|
||||||
|
if (element.name === element.newName) return;
|
||||||
|
await api.tags.update(element.slug, element.newName, true);
|
||||||
|
});
|
||||||
|
this.$store.dispatch("requestTags");
|
||||||
|
} else {
|
||||||
|
renameList.forEach(async element => {
|
||||||
|
if (element.name === element.newName) return;
|
||||||
|
await api.categories.update(element.slug, element.newName, true);
|
||||||
|
});
|
||||||
|
this.$store.dispatch("requestCategories");
|
||||||
|
}
|
||||||
|
this.loadingTitleCase = false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.overflow-fix .v-toolbar__content {
|
||||||
|
height: auto !important;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
</style>
|
47
frontend/src/pages/Admin/ToolBox/index.vue
Normal file
47
frontend/src/pages/Admin/ToolBox/index.vue
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<v-card flat>
|
||||||
|
<v-tabs
|
||||||
|
v-model="tab"
|
||||||
|
background-color="primary"
|
||||||
|
centered
|
||||||
|
dark
|
||||||
|
icons-and-text
|
||||||
|
>
|
||||||
|
<v-tabs-slider></v-tabs-slider>
|
||||||
|
|
||||||
|
<v-tab>
|
||||||
|
{{ $t("recipe.categories") }}
|
||||||
|
<v-icon>mdi-tag-multiple-outline</v-icon>
|
||||||
|
</v-tab>
|
||||||
|
|
||||||
|
<v-tab>
|
||||||
|
{{ $t("recipe.tags") }}
|
||||||
|
<v-icon>mdi-tag-multiple-outline</v-icon>
|
||||||
|
</v-tab>
|
||||||
|
</v-tabs>
|
||||||
|
|
||||||
|
<v-tabs-items v-model="tab">
|
||||||
|
<v-tab-item><CategoryTagEditor :is-tags="false"/></v-tab-item>
|
||||||
|
<v-tab-item><CategoryTagEditor :is-tags="true" /> </v-tab-item>
|
||||||
|
</v-tabs-items>
|
||||||
|
</v-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import CategoryTagEditor from "./CategoryTagEditor";
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
CategoryTagEditor,
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
tab: 0,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
</style>
|
|
@ -38,8 +38,8 @@ export default {
|
||||||
return this.$store.getters.getSiteSettings;
|
return this.$store.getters.getSiteSettings;
|
||||||
},
|
},
|
||||||
recentRecipes() {
|
recentRecipes() {
|
||||||
let recipes = this.$store.getters.getRecentRecipes;
|
return this.$store.getters.getRecentRecipes;
|
||||||
return recipes.sort((a, b) => (a.dateAdded > b.dateAdded ? -1 : 1));
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async mounted() {
|
async mounted() {
|
||||||
|
|
|
@ -1,5 +1,10 @@
|
||||||
<template>
|
<template>
|
||||||
<v-container>
|
<v-container>
|
||||||
|
<v-progress-linear
|
||||||
|
v-if="loading"
|
||||||
|
indeterminate
|
||||||
|
color="primary"
|
||||||
|
></v-progress-linear>
|
||||||
<CardSection
|
<CardSection
|
||||||
:sortable="true"
|
:sortable="true"
|
||||||
:title="$t('page.all-recipes')"
|
:title="$t('page.all-recipes')"
|
||||||
|
@ -18,14 +23,20 @@ export default {
|
||||||
CardSection,
|
CardSection,
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {};
|
return {
|
||||||
|
loading: false,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
mounted() {
|
async mounted() {
|
||||||
this.$store.dispatch("requestAllRecipes");
|
if (this.allRecipes.length < 1) {
|
||||||
|
this.loading = true;
|
||||||
|
}
|
||||||
|
await this.$store.dispatch("requestAllRecipes");
|
||||||
|
this.loading = false;
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
allRecipes() {
|
allRecipes() {
|
||||||
return this.$store.getters.getRecentRecipes;
|
return this.$store.getters.getAllRecipes;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|
|
@ -119,7 +119,7 @@ export default {
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
allRecipes() {
|
allRecipes() {
|
||||||
return this.$store.getters.getRecentRecipes;
|
return this.$store.getters.getAllRecipes;
|
||||||
},
|
},
|
||||||
filteredRecipes() {
|
filteredRecipes() {
|
||||||
return this.allRecipes.filter(recipe => {
|
return this.allRecipes.filter(recipe => {
|
||||||
|
|
|
@ -7,9 +7,10 @@ import Profile from "@/pages/Admin/Profile";
|
||||||
import ManageUsers from "@/pages/Admin/ManageUsers";
|
import ManageUsers from "@/pages/Admin/ManageUsers";
|
||||||
import Settings from "@/pages/Admin/Settings";
|
import Settings from "@/pages/Admin/Settings";
|
||||||
import About from "@/pages/Admin/About";
|
import About from "@/pages/Admin/About";
|
||||||
|
import Toolbox from "@/pages/Admin/Toolbox";
|
||||||
import { store } from "../store";
|
import { store } from "../store";
|
||||||
|
|
||||||
export const adminRoutes = {
|
export const adminRoutes = {
|
||||||
path: "/admin",
|
path: "/admin",
|
||||||
component: Admin,
|
component: Admin,
|
||||||
beforeEnter: (to, _from, next) => {
|
beforeEnter: (to, _from, next) => {
|
||||||
|
@ -72,6 +73,13 @@ export const adminRoutes = {
|
||||||
title: "settings.site-settings",
|
title: "settings.site-settings",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "toolbox",
|
||||||
|
component: Toolbox,
|
||||||
|
meta: {
|
||||||
|
title: "settings.toolbox.toolbox",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "about",
|
path: "about",
|
||||||
component: About,
|
component: About,
|
||||||
|
|
|
@ -4,12 +4,12 @@ import { authRoutes } from "./auth";
|
||||||
import { recipeRoutes } from "./recipes";
|
import { recipeRoutes } from "./recipes";
|
||||||
import { mealRoutes } from "./meal";
|
import { mealRoutes } from "./meal";
|
||||||
import { generalRoutes } from "./general";
|
import { generalRoutes } from "./general";
|
||||||
import { store } from "../store";
|
import { store } from "@/store";
|
||||||
import VueRouter from "vue-router";
|
import VueRouter from "vue-router";
|
||||||
import VueI18n from "@/i18n";
|
import VueI18n from "@/i18n";
|
||||||
import Vuetify from "@/plugins/vuetify";
|
import Vuetify from "@/plugins/vuetify";
|
||||||
import Vue from "vue";
|
import Vue from "vue";
|
||||||
import i18n from '@/i18n.js';
|
import i18n from "@/i18n.js";
|
||||||
|
|
||||||
export const routes = [
|
export const routes = [
|
||||||
...generalRoutes,
|
...generalRoutes,
|
||||||
|
|
|
@ -5,6 +5,7 @@ import createPersistedState from "vuex-persistedstate";
|
||||||
import userSettings from "./modules/userSettings";
|
import userSettings from "./modules/userSettings";
|
||||||
import language from "./modules/language";
|
import language from "./modules/language";
|
||||||
import siteSettings from "./modules/siteSettings";
|
import siteSettings from "./modules/siteSettings";
|
||||||
|
import recipes from "./modules/recipes";
|
||||||
import groups from "./modules/groups";
|
import groups from "./modules/groups";
|
||||||
|
|
||||||
Vue.use(Vuex);
|
Vue.use(Vuex);
|
||||||
|
@ -20,6 +21,7 @@ const store = new Vuex.Store({
|
||||||
language,
|
language,
|
||||||
siteSettings,
|
siteSettings,
|
||||||
groups,
|
groups,
|
||||||
|
recipes,
|
||||||
},
|
},
|
||||||
state: {
|
state: {
|
||||||
// All Recipe Data Store
|
// All Recipe Data Store
|
||||||
|
@ -35,9 +37,6 @@ const store = new Vuex.Store({
|
||||||
},
|
},
|
||||||
|
|
||||||
mutations: {
|
mutations: {
|
||||||
setRecentRecipes(state, payload) {
|
|
||||||
state.recentRecipes = payload;
|
|
||||||
},
|
|
||||||
setMealPlanCategories(state, payload) {
|
setMealPlanCategories(state, payload) {
|
||||||
state.mealPlanCategories = payload;
|
state.mealPlanCategories = payload;
|
||||||
},
|
},
|
||||||
|
@ -53,18 +52,6 @@ const store = new Vuex.Store({
|
||||||
},
|
},
|
||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
async requestRecentRecipes({ getters }) {
|
|
||||||
const payload = await api.recipes.allSummary(0, 30);
|
|
||||||
const recent = getters.getRecentRecipes;
|
|
||||||
if (recent.length >= 30) return;
|
|
||||||
this.commit("setRecentRecipes", payload);
|
|
||||||
},
|
|
||||||
async requestAllRecipes({ getters }) {
|
|
||||||
const recent = getters.getRecentRecipes;
|
|
||||||
const start = recent.length + 1;
|
|
||||||
const payload = await api.recipes.allSummary(start, 9999);
|
|
||||||
this.commit("setRecentRecipes", [...recent, ...payload]);
|
|
||||||
},
|
|
||||||
async requestCategories({ commit }) {
|
async requestCategories({ commit }) {
|
||||||
const categories = await api.categories.getAll();
|
const categories = await api.categories.getAll();
|
||||||
commit("setAllCategories", categories);
|
commit("setAllCategories", categories);
|
||||||
|
@ -80,7 +67,6 @@ const store = new Vuex.Store({
|
||||||
},
|
},
|
||||||
|
|
||||||
getters: {
|
getters: {
|
||||||
getRecentRecipes: state => state.recentRecipes,
|
|
||||||
getMealPlanCategories: state => state.mealPlanCategories,
|
getMealPlanCategories: state => state.mealPlanCategories,
|
||||||
getAllCategories: state =>
|
getAllCategories: state =>
|
||||||
state.allCategories.sort((a, b) => (a.slug > b.slug ? 1 : -1)),
|
state.allCategories.sort((a, b) => (a.slug > b.slug ? 1 : -1)),
|
||||||
|
|
73
frontend/src/store/modules/recipes.js
Normal file
73
frontend/src/store/modules/recipes.js
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
import { api } from "@/api";
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
recentRecipes: [],
|
||||||
|
allRecipes: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const mutations = {
|
||||||
|
setRecentRecipes(state, payload) {
|
||||||
|
state.recentRecipes = payload;
|
||||||
|
},
|
||||||
|
patchRecentRecipes(state, payload) {
|
||||||
|
if (state.recentRecipes[payload.id]) {
|
||||||
|
state.recentRecipes[payload.id] = payload;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dropRecentRecipes(state, payload) {
|
||||||
|
if (state.recentRecipes[payload.id]) {
|
||||||
|
delete state.recentRecipes[payload.id];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setAllRecipes(state, payload) {
|
||||||
|
state.allRecipes = payload;
|
||||||
|
},
|
||||||
|
patchAllRecipes(state, payload) {
|
||||||
|
state.allRecipes[payload.id] = payload;
|
||||||
|
},
|
||||||
|
dropAllRecipes(state, payload) {
|
||||||
|
if (state.allRecipes[payload.id]) {
|
||||||
|
delete state.allRecipes[payload.id];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const actions = {
|
||||||
|
async requestRecentRecipes() {
|
||||||
|
const payload = await api.recipes.allSummary(0, 30);
|
||||||
|
payload.sort((a, b) => (a.dateAdded > b.dateAdded ? -1 : 1));
|
||||||
|
console.log(payload);
|
||||||
|
const hash = Object.fromEntries(payload.map(e => [e.id, e]));
|
||||||
|
this.commit("setRecentRecipes", hash);
|
||||||
|
},
|
||||||
|
async requestAllRecipes({ getters }) {
|
||||||
|
const all = getters.getAllRecipes;
|
||||||
|
const payload = await api.recipes.allSummary(all.length, 9999);
|
||||||
|
const hash = Object.fromEntries([...all, ...payload].map(e => [e.id, e]));
|
||||||
|
console.log(hash);
|
||||||
|
|
||||||
|
this.commit("setAllRecipes", hash);
|
||||||
|
},
|
||||||
|
patchRecipe({ commit }, payload) {
|
||||||
|
commit("patchAllRecipes", payload);
|
||||||
|
commit("patchRecentRecipes", payload);
|
||||||
|
},
|
||||||
|
dropRecipe({ commit }, payload) {
|
||||||
|
commit("dropAllRecipes", payload);
|
||||||
|
commit("dropRecentRecipes", payload);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const getters = {
|
||||||
|
getAllRecipes: state => Object.values(state.allRecipes),
|
||||||
|
getAllRecipesHash: state => state.allRecipes,
|
||||||
|
getRecentRecipes: state => Object.values(state.recentRecipes),
|
||||||
|
getRecentRecipesHash: state => state.recentRecipes,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
state,
|
||||||
|
mutations,
|
||||||
|
actions,
|
||||||
|
getters,
|
||||||
|
};
|
|
@ -29,10 +29,10 @@ const actions = {
|
||||||
let settings = await api.siteSettings.get();
|
let settings = await api.siteSettings.get();
|
||||||
commit("setSettings", settings);
|
commit("setSettings", settings);
|
||||||
},
|
},
|
||||||
async requestCustomPages({commit }) {
|
async requestCustomPages({ commit }) {
|
||||||
const customPages = await api.siteSettings.getPages()
|
const customPages = await api.siteSettings.getPages();
|
||||||
commit("setCustomPages", customPages)
|
commit("setCustomPages", customPages);
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const getters = {
|
const getters = {
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
from logging import getLogger
|
||||||
|
|
||||||
from mealie.db.db_base import BaseDocument
|
from mealie.db.db_base import BaseDocument
|
||||||
from mealie.db.models.group import Group
|
from mealie.db.models.group import Group
|
||||||
from mealie.db.models.mealplan import MealPlanModel
|
from mealie.db.models.mealplan import MealPlanModel
|
||||||
|
@ -16,12 +18,13 @@ from mealie.schema.theme import SiteTheme
|
||||||
from mealie.schema.user import GroupInDB, UserInDB
|
from mealie.schema.user import GroupInDB, UserInDB
|
||||||
from sqlalchemy.orm.session import Session
|
from sqlalchemy.orm.session import Session
|
||||||
|
|
||||||
|
logger = getLogger()
|
||||||
|
|
||||||
|
|
||||||
class _Recipes(BaseDocument):
|
class _Recipes(BaseDocument):
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.primary_key = "slug"
|
self.primary_key = "slug"
|
||||||
self.sql_model: RecipeModel = RecipeModel
|
self.sql_model: RecipeModel = RecipeModel
|
||||||
self.orm_mode = True
|
|
||||||
self.schema: Recipe = Recipe
|
self.schema: Recipe = Recipe
|
||||||
|
|
||||||
def update_image(self, session: Session, slug: str, extension: str = None) -> str:
|
def update_image(self, session: Session, slug: str, extension: str = None) -> str:
|
||||||
|
@ -36,23 +39,26 @@ class _Categories(BaseDocument):
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.primary_key = "slug"
|
self.primary_key = "slug"
|
||||||
self.sql_model = Category
|
self.sql_model = Category
|
||||||
self.orm_mode = True
|
|
||||||
self.schema = RecipeCategoryResponse
|
self.schema = RecipeCategoryResponse
|
||||||
|
|
||||||
|
def get_empty(self, session: Session):
|
||||||
|
return session.query(Category).filter(~Category.recipes.any()).all()
|
||||||
|
|
||||||
|
|
||||||
class _Tags(BaseDocument):
|
class _Tags(BaseDocument):
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.primary_key = "slug"
|
self.primary_key = "slug"
|
||||||
self.sql_model = Tag
|
self.sql_model = Tag
|
||||||
self.orm_mode = True
|
|
||||||
self.schema = RecipeTagResponse
|
self.schema = RecipeTagResponse
|
||||||
|
|
||||||
|
def get_empty(self, session: Session):
|
||||||
|
return session.query(Tag).filter(~Tag.recipes.any()).all()
|
||||||
|
|
||||||
|
|
||||||
class _Meals(BaseDocument):
|
class _Meals(BaseDocument):
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.primary_key = "uid"
|
self.primary_key = "uid"
|
||||||
self.sql_model = MealPlanModel
|
self.sql_model = MealPlanModel
|
||||||
self.orm_mode = True
|
|
||||||
self.schema = MealPlanInDB
|
self.schema = MealPlanInDB
|
||||||
|
|
||||||
|
|
||||||
|
@ -60,7 +66,6 @@ class _Settings(BaseDocument):
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.primary_key = "id"
|
self.primary_key = "id"
|
||||||
self.sql_model = SiteSettings
|
self.sql_model = SiteSettings
|
||||||
self.orm_mode = True
|
|
||||||
self.schema = SiteSettingsSchema
|
self.schema = SiteSettingsSchema
|
||||||
|
|
||||||
|
|
||||||
|
@ -68,7 +73,6 @@ class _Themes(BaseDocument):
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.primary_key = "name"
|
self.primary_key = "name"
|
||||||
self.sql_model = SiteThemeModel
|
self.sql_model = SiteThemeModel
|
||||||
self.orm_mode = True
|
|
||||||
self.schema = SiteTheme
|
self.schema = SiteTheme
|
||||||
|
|
||||||
|
|
||||||
|
@ -76,7 +80,6 @@ class _Users(BaseDocument):
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.primary_key = "id"
|
self.primary_key = "id"
|
||||||
self.sql_model = User
|
self.sql_model = User
|
||||||
self.orm_mode = True
|
|
||||||
self.schema = UserInDB
|
self.schema = UserInDB
|
||||||
|
|
||||||
def update_password(self, session, id, password: str):
|
def update_password(self, session, id, password: str):
|
||||||
|
@ -91,7 +94,6 @@ class _Groups(BaseDocument):
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.primary_key = "id"
|
self.primary_key = "id"
|
||||||
self.sql_model = Group
|
self.sql_model = Group
|
||||||
self.orm_mode = True
|
|
||||||
self.schema = GroupInDB
|
self.schema = GroupInDB
|
||||||
|
|
||||||
def get_meals(self, session: Session, match_value: str, match_key: str = "name") -> list[MealPlanInDB]:
|
def get_meals(self, session: Session, match_value: str, match_key: str = "name") -> list[MealPlanInDB]:
|
||||||
|
@ -116,7 +118,6 @@ class _SignUps(BaseDocument):
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.primary_key = "token"
|
self.primary_key = "token"
|
||||||
self.sql_model = SignUp
|
self.sql_model = SignUp
|
||||||
self.orm_mode = True
|
|
||||||
self.schema = SignUpOut
|
self.schema = SignUpOut
|
||||||
|
|
||||||
|
|
||||||
|
@ -124,7 +125,6 @@ class _CustomPages(BaseDocument):
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.primary_key = "id"
|
self.primary_key = "id"
|
||||||
self.sql_model = CustomPage
|
self.sql_model = CustomPage
|
||||||
self.orm_mode = True
|
|
||||||
self.schema = CustomPageOut
|
self.schema = CustomPageOut
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -12,7 +12,6 @@ class BaseDocument:
|
||||||
self.primary_key: str
|
self.primary_key: str
|
||||||
self.store: str
|
self.store: str
|
||||||
self.sql_model: SqlAlchemyBase
|
self.sql_model: SqlAlchemyBase
|
||||||
self.orm_mode = False
|
|
||||||
self.schema: BaseModel
|
self.schema: BaseModel
|
||||||
|
|
||||||
# TODO: Improve Get All Query Functionality
|
# TODO: Improve Get All Query Functionality
|
||||||
|
@ -138,3 +137,4 @@ class BaseDocument:
|
||||||
|
|
||||||
session.delete(result)
|
session.delete(result)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
|
|
|
@ -11,28 +11,28 @@ site_settings2categories = sa.Table(
|
||||||
"site_settings2categoories",
|
"site_settings2categoories",
|
||||||
SqlAlchemyBase.metadata,
|
SqlAlchemyBase.metadata,
|
||||||
sa.Column("sidebar_id", sa.Integer, sa.ForeignKey("site_settings.id")),
|
sa.Column("sidebar_id", sa.Integer, sa.ForeignKey("site_settings.id")),
|
||||||
sa.Column("category_slug", sa.String, sa.ForeignKey("categories.slug")),
|
sa.Column("category_id", sa.String, sa.ForeignKey("categories.id")),
|
||||||
)
|
)
|
||||||
|
|
||||||
group2categories = sa.Table(
|
group2categories = sa.Table(
|
||||||
"group2categories",
|
"group2categories",
|
||||||
SqlAlchemyBase.metadata,
|
SqlAlchemyBase.metadata,
|
||||||
sa.Column("group_id", sa.Integer, sa.ForeignKey("groups.id")),
|
sa.Column("group_id", sa.Integer, sa.ForeignKey("groups.id")),
|
||||||
sa.Column("category_slug", sa.String, sa.ForeignKey("categories.slug")),
|
sa.Column("category_id", sa.String, sa.ForeignKey("categories.id")),
|
||||||
)
|
)
|
||||||
|
|
||||||
recipes2categories = sa.Table(
|
recipes2categories = sa.Table(
|
||||||
"recipes2categories",
|
"recipes2categories",
|
||||||
SqlAlchemyBase.metadata,
|
SqlAlchemyBase.metadata,
|
||||||
sa.Column("recipe_id", sa.Integer, sa.ForeignKey("recipes.id")),
|
sa.Column("recipe_id", sa.Integer, sa.ForeignKey("recipes.id")),
|
||||||
sa.Column("category_slug", sa.String, sa.ForeignKey("categories.slug")),
|
sa.Column("category_id", sa.String, sa.ForeignKey("categories.id")),
|
||||||
)
|
)
|
||||||
|
|
||||||
custom_pages2categories = sa.Table(
|
custom_pages2categories = sa.Table(
|
||||||
"custom_pages2categories",
|
"custom_pages2categories",
|
||||||
SqlAlchemyBase.metadata,
|
SqlAlchemyBase.metadata,
|
||||||
sa.Column("custom_page_id", sa.Integer, sa.ForeignKey("custom_pages.id")),
|
sa.Column("custom_page_id", sa.Integer, sa.ForeignKey("custom_pages.id")),
|
||||||
sa.Column("category_slug", sa.String, sa.ForeignKey("categories.slug")),
|
sa.Column("category_id", sa.String, sa.ForeignKey("categories.id")),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -52,6 +52,9 @@ class Category(SqlAlchemyBase):
|
||||||
self.name = name.strip()
|
self.name = name.strip()
|
||||||
self.slug = slugify(name)
|
self.slug = slugify(name)
|
||||||
|
|
||||||
|
def update(self, name, session=None) -> None:
|
||||||
|
self.__init__(name, session)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_ref(session, slug: str):
|
def get_ref(session, slug: str):
|
||||||
return session.query(Category).filter(Category.slug == slug).one()
|
return session.query(Category).filter(Category.slug == slug).one()
|
||||||
|
|
|
@ -86,6 +86,8 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
|
||||||
rating: int = None,
|
rating: int = None,
|
||||||
orgURL: str = None,
|
orgURL: str = None,
|
||||||
extras: dict = None,
|
extras: dict = None,
|
||||||
|
*args,
|
||||||
|
**kwargs
|
||||||
) -> None:
|
) -> None:
|
||||||
self.name = name
|
self.name = name
|
||||||
self.description = description
|
self.description = description
|
||||||
|
@ -139,6 +141,8 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
|
||||||
rating: int = None,
|
rating: int = None,
|
||||||
orgURL: str = None,
|
orgURL: str = None,
|
||||||
extras: dict = None,
|
extras: dict = None,
|
||||||
|
*args,
|
||||||
|
**kwargs
|
||||||
):
|
):
|
||||||
"""Updated a database entry by removing nested rows and rebuilds the row through the __init__ functions"""
|
"""Updated a database entry by removing nested rows and rebuilds the row through the __init__ functions"""
|
||||||
|
|
||||||
|
|
|
@ -11,7 +11,7 @@ recipes2tags = sa.Table(
|
||||||
"recipes2tags",
|
"recipes2tags",
|
||||||
SqlAlchemyBase.metadata,
|
SqlAlchemyBase.metadata,
|
||||||
sa.Column("recipe_id", sa.Integer, sa.ForeignKey("recipes.id")),
|
sa.Column("recipe_id", sa.Integer, sa.ForeignKey("recipes.id")),
|
||||||
sa.Column("tag_slug", sa.Integer, sa.ForeignKey("tags.slug")),
|
sa.Column("tag_id", sa.Integer, sa.ForeignKey("tags.id")),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -31,6 +31,9 @@ class Tag(SqlAlchemyBase):
|
||||||
self.name = name.strip()
|
self.name = name.strip()
|
||||||
self.slug = slugify(self.name)
|
self.slug = slugify(self.name)
|
||||||
|
|
||||||
|
def update(self, name, session=None) -> None:
|
||||||
|
self.__init__(name, session)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def create_if_not_exist(session, name: str = None):
|
def create_if_not_exist(session, name: str = None):
|
||||||
test_slug = slugify(name)
|
test_slug = slugify(name)
|
||||||
|
|
|
@ -18,6 +18,18 @@ async def get_all_recipe_categories(session: Session = Depends(generate_session)
|
||||||
return db.categories.get_all_limit_columns(session, ["slug", "name"])
|
return db.categories.get_all_limit_columns(session, ["slug", "name"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/empty")
|
||||||
|
def get_empty_categories(session: Session = Depends(generate_session)):
|
||||||
|
""" Returns a list of categories that do not contain any recipes"""
|
||||||
|
return db.categories.get_empty(session)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{category}", response_model=RecipeCategoryResponse)
|
||||||
|
def get_all_recipes_by_category(category: str, session: Session = Depends(generate_session)):
|
||||||
|
""" Returns a list of recipes associated with the provided category. """
|
||||||
|
return db.categories.get(session, category)
|
||||||
|
|
||||||
|
|
||||||
@router.post("")
|
@router.post("")
|
||||||
async def create_recipe_category(
|
async def create_recipe_category(
|
||||||
category: CategoryIn, session: Session = Depends(generate_session), current_user=Depends(get_current_user)
|
category: CategoryIn, session: Session = Depends(generate_session), current_user=Depends(get_current_user)
|
||||||
|
@ -27,10 +39,16 @@ async def create_recipe_category(
|
||||||
return db.categories.create(session, category.dict())
|
return db.categories.create(session, category.dict())
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{category}", response_model=RecipeCategoryResponse)
|
@router.put("/{category}", response_model=RecipeCategoryResponse)
|
||||||
def get_all_recipes_by_category(category: str, session: Session = Depends(generate_session)):
|
async def update_recipe_category(
|
||||||
""" Returns a list of recipes associated with the provided category. """
|
category: str,
|
||||||
return db.categories.get(session, category)
|
new_category: CategoryIn,
|
||||||
|
session: Session = Depends(generate_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
""" Updates an existing Tag in the database """
|
||||||
|
|
||||||
|
return db.categories.update(session, category, new_category.dict())
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{category}")
|
@router.delete("/{category}")
|
||||||
|
|
|
@ -1,7 +1,5 @@
|
||||||
import shutil
|
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
import requests
|
|
||||||
from fastapi import APIRouter, Depends, File, Form, HTTPException
|
from fastapi import APIRouter, Depends, File, Form, HTTPException
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from mealie.db.database import db
|
from mealie.db.database import db
|
||||||
|
@ -13,10 +11,7 @@ from mealie.services.image.image import IMG_OPTIONS, delete_image, read_image, r
|
||||||
from mealie.services.scraper.scraper import create_from_url
|
from mealie.services.scraper.scraper import create_from_url
|
||||||
from sqlalchemy.orm.session import Session
|
from sqlalchemy.orm.session import Session
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(prefix="/api/recipes", tags=["Recipe CRUD"])
|
||||||
prefix="/api/recipes",
|
|
||||||
tags=["Recipe CRUD"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/create", status_code=201, response_model=str)
|
@router.post("/create", status_code=201, response_model=str)
|
||||||
|
@ -66,7 +61,30 @@ def update_recipe(
|
||||||
if recipe_slug != recipe.slug:
|
if recipe_slug != recipe.slug:
|
||||||
rename_image(original_slug=recipe_slug, new_slug=recipe.slug)
|
rename_image(original_slug=recipe_slug, new_slug=recipe.slug)
|
||||||
|
|
||||||
return recipe.slug
|
return recipe
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{recipe_slug}")
|
||||||
|
def update_recipe(
|
||||||
|
recipe_slug: str,
|
||||||
|
data: dict,
|
||||||
|
session: Session = Depends(generate_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
""" Updates a recipe by existing slug and data. """
|
||||||
|
|
||||||
|
existing_entry: Recipe = db.recipes.get(session, recipe_slug)
|
||||||
|
|
||||||
|
entry_dict = existing_entry.dict()
|
||||||
|
entry_dict.update(data)
|
||||||
|
updated_entry = Recipe(**entry_dict) # ! Surely there's a better way?
|
||||||
|
|
||||||
|
recipe: Recipe = db.recipes.update(session, recipe_slug, updated_entry.dict())
|
||||||
|
|
||||||
|
if recipe_slug != recipe.slug:
|
||||||
|
rename_image(original_slug=recipe_slug, new_slug=recipe.slug)
|
||||||
|
|
||||||
|
return recipe
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{recipe_slug}")
|
@router.delete("/{recipe_slug}")
|
||||||
|
|
|
@ -20,6 +20,18 @@ async def get_all_recipe_tags(session: Session = Depends(generate_session)):
|
||||||
return db.tags.get_all_limit_columns(session, ["slug", "name"])
|
return db.tags.get_all_limit_columns(session, ["slug", "name"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/empty")
|
||||||
|
def get_empty_tags(session: Session = Depends(generate_session)):
|
||||||
|
""" Returns a list of tags that do not contain any recipes"""
|
||||||
|
return db.tags.get_empty(session)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{tag}", response_model=RecipeTagResponse)
|
||||||
|
def get_all_recipes_by_tag(tag: str, session: Session = Depends(generate_session)):
|
||||||
|
""" Returns a list of recipes associated with the provided tag. """
|
||||||
|
return db.tags.get(session, tag)
|
||||||
|
|
||||||
|
|
||||||
@router.post("")
|
@router.post("")
|
||||||
async def create_recipe_tag(
|
async def create_recipe_tag(
|
||||||
tag: TagIn, session: Session = Depends(generate_session), current_user=Depends(get_current_user)
|
tag: TagIn, session: Session = Depends(generate_session), current_user=Depends(get_current_user)
|
||||||
|
@ -29,10 +41,13 @@ async def create_recipe_tag(
|
||||||
return db.tags.create(session, tag.dict())
|
return db.tags.create(session, tag.dict())
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{tag}", response_model=RecipeTagResponse)
|
@router.put("/{tag}", response_model=RecipeTagResponse)
|
||||||
def get_all_recipes_by_tag(tag: str, session: Session = Depends(generate_session)):
|
async def update_recipe_tag(
|
||||||
""" Returns a list of recipes associated with the provided tag. """
|
tag: str, new_tag: TagIn, session: Session = Depends(generate_session), current_user=Depends(get_current_user)
|
||||||
return db.tags.get(session, tag)
|
):
|
||||||
|
""" Updates an existing Tag in the database """
|
||||||
|
|
||||||
|
return db.tags.update(session, tag, new_tag.dict())
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{tag}")
|
@router.delete("/{tag}")
|
||||||
|
|
|
@ -2,6 +2,7 @@ from typing import List, Optional
|
||||||
|
|
||||||
from fastapi_camelcase import CamelModel
|
from fastapi_camelcase import CamelModel
|
||||||
from mealie.schema.recipe import Recipe
|
from mealie.schema.recipe import Recipe
|
||||||
|
from pydantic.utils import GetterDict
|
||||||
|
|
||||||
|
|
||||||
class CategoryIn(CamelModel):
|
class CategoryIn(CamelModel):
|
||||||
|
@ -15,6 +16,13 @@ class CategoryBase(CategoryIn):
|
||||||
class Config:
|
class Config:
|
||||||
orm_mode = True
|
orm_mode = True
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def getter_dict(_cls, name_orm):
|
||||||
|
return {
|
||||||
|
**GetterDict(name_orm),
|
||||||
|
"total_recipes": len(name_orm.recipes),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class RecipeCategoryResponse(CategoryBase):
|
class RecipeCategoryResponse(CategoryBase):
|
||||||
recipes: Optional[List[Recipe]]
|
recipes: Optional[List[Recipe]]
|
||||||
|
|
|
@ -35,6 +35,7 @@ class Nutrition(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class RecipeSummary(BaseModel):
|
class RecipeSummary(BaseModel):
|
||||||
|
id: Optional[int]
|
||||||
name: str
|
name: str
|
||||||
slug: Optional[str] = ""
|
slug: Optional[str] = ""
|
||||||
image: Optional[Any]
|
image: Optional[Any]
|
||||||
|
|
|
@ -8,18 +8,19 @@ from tests.app_routes import AppRoutes
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def backup_data():
|
def backup_data():
|
||||||
return {
|
return {
|
||||||
"name": "dev_sample_data_2021-Feb-13.zip",
|
"name": "test_backup_2021-Apr-27.zip",
|
||||||
"force": False,
|
"force": True,
|
||||||
"recipes": True,
|
"recipes": True,
|
||||||
"settings": False, # ! Broken
|
"settings": False, # ! Broken
|
||||||
"themes": True,
|
"themes": True,
|
||||||
"groups": True,
|
"groups": True,
|
||||||
"users": True,
|
"users": True,
|
||||||
|
"pages": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_import(api_client: TestClient, api_routes: AppRoutes, backup_data, token):
|
def test_import(api_client: TestClient, api_routes: AppRoutes, backup_data, token):
|
||||||
import_route = api_routes.backups_file_name_import("dev_sample_data_2021-Feb-13.zip")
|
import_route = api_routes.backups_file_name_import("test_backup_2021-Apr-27.zip")
|
||||||
response = api_client.post(import_route, json=backup_data, headers=token)
|
response = api_client.post(import_route, json=backup_data, headers=token)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
for _, value in json.loads(response.content).items():
|
for _, value in json.loads(response.content).items():
|
||||||
|
|
|
@ -60,7 +60,7 @@ def test_read_update(api_client: TestClient, api_routes: AppRoutes, recipe_data,
|
||||||
response = api_client.put(recipe_url, json=recipe, headers=token)
|
response = api_client.put(recipe_url, json=recipe, headers=token)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert json.loads(response.text) == recipe_data.expected_slug
|
assert json.loads(response.text).get("slug") == recipe_data.expected_slug
|
||||||
|
|
||||||
response = api_client.get(recipe_url)
|
response = api_client.get(recipe_url)
|
||||||
|
|
||||||
|
@ -84,7 +84,7 @@ def test_rename(api_client: TestClient, api_routes: AppRoutes, recipe_data, toke
|
||||||
response = api_client.put(recipe_url, json=recipe, headers=token)
|
response = api_client.put(recipe_url, json=recipe, headers=token)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert json.loads(response.text) == new_slug
|
assert json.loads(response.text).get("slug") == new_slug
|
||||||
|
|
||||||
recipe_data.expected_slug = new_slug
|
recipe_data.expected_slug = new_slug
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue