mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-08-03 20:45:23 +02:00
init 2
This commit is contained in:
commit
beed8576c2
137 changed files with 40218 additions and 0 deletions
70
frontend/src/components/AddRecipe.vue
Normal file
70
frontend/src/components/AddRecipe.vue
Normal file
|
@ -0,0 +1,70 @@
|
|||
<template>
|
||||
<div class="text-center">
|
||||
<v-dialog v-model="addRecipe" width="650" @click:outside="reset">
|
||||
<v-card :loading="processing">
|
||||
<v-card-title class="headline"> From URL </v-card-title>
|
||||
|
||||
<v-card-text>
|
||||
<v-form>
|
||||
<v-text-field v-model="recipeURL" label="Recipe URL"></v-text-field>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider></v-divider>
|
||||
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn color="primary" text @click="createRecipe"> Submit </v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
<v-speed-dial v-model="fab" fixed right bottom open-on-hover>
|
||||
<template v-slot:activator>
|
||||
<v-btn v-model="fab" color="secondary" dark fab @click="navCreate">
|
||||
<v-icon> mdi-plus </v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-btn fab dark small color="success" @click="addRecipe = true">
|
||||
<v-icon>mdi-link</v-icon>
|
||||
</v-btn>
|
||||
</v-speed-dial>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import api from "../api";
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
fab: false,
|
||||
addRecipe: false,
|
||||
recipeURL: "",
|
||||
processing: false,
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
async createRecipe() {
|
||||
this.processing = true;
|
||||
await api.recipes.createByURL(this.recipeURL);
|
||||
this.addRecipe = false;
|
||||
this.processing = false;
|
||||
},
|
||||
|
||||
navCreate() {
|
||||
this.$router.push("/new");
|
||||
},
|
||||
|
||||
reset() {
|
||||
(this.fab = false),
|
||||
(this.addRecipe = false),
|
||||
(this.recipeURL = ""),
|
||||
(this.processing = false);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
</style>
|
23
frontend/src/components/Admin/Admin.vue
Normal file
23
frontend/src/components/Admin/Admin.vue
Normal file
|
@ -0,0 +1,23 @@
|
|||
<template>
|
||||
<v-container>
|
||||
<Theme />
|
||||
<Backup />
|
||||
<Webhooks />
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Backup from "./Backup";
|
||||
import Webhooks from "./Webhooks";
|
||||
import Theme from "./Theme";
|
||||
export default {
|
||||
components: {
|
||||
Backup,
|
||||
Webhooks,
|
||||
Theme,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
</style>
|
121
frontend/src/components/Admin/Backup.vue
Normal file
121
frontend/src/components/Admin/Backup.vue
Normal file
|
@ -0,0 +1,121 @@
|
|||
<template>
|
||||
<v-card :loading="backupLoading" class="mt-3">
|
||||
<v-card-title class="card-title"> Backup and Exports </v-card-title>
|
||||
<v-card-text>
|
||||
<p>
|
||||
Backups are exported in standard JSON format along with all the images
|
||||
stored on the file system. In your backup folder you'll find a .zip file
|
||||
that contains all of the recipe JSON and images from the database.
|
||||
Additionally, if you selected a markdown file, those will also be stored
|
||||
in the .zip file. To import a backup, it must be located in your backups
|
||||
folder. Automated backups are done each day at 3:00 AM.
|
||||
</p>
|
||||
|
||||
<v-row dense align="center">
|
||||
<v-col dense cols="12" sm="12" md="4">
|
||||
<v-text-field v-model="backupTag" label="Backup Tag"></v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="12" md="3">
|
||||
<v-combobox
|
||||
auto-select-first
|
||||
label="Markdown Template"
|
||||
:items="availableTemplates"
|
||||
v-model="selectedTemplate"
|
||||
></v-combobox>
|
||||
</v-col>
|
||||
<v-col dense cols="12" sm="12" md="2">
|
||||
<v-btn block color="accent" @click="createBackup" width="165">
|
||||
Backup Recipes
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row dense align="center">
|
||||
<v-col dense cols="12" sm="12" md="4">
|
||||
<v-form ref="form">
|
||||
<v-combobox
|
||||
auto-select-first
|
||||
label="Select a Backup for Import"
|
||||
:items="availableBackups"
|
||||
v-model="selectedBackup"
|
||||
:rules="[(v) => !!v || 'Backup Selection is Required']"
|
||||
required
|
||||
></v-combobox>
|
||||
</v-form>
|
||||
</v-col>
|
||||
<v-col dense cols="12" sm="12" md="3" lg="2">
|
||||
<v-btn block color="accent" @click="importBackup">
|
||||
Import Backup
|
||||
</v-btn>
|
||||
</v-col>
|
||||
<v-col dense cols="12" sm="12" md="2" lg="2">
|
||||
<v-btn block color="error" @click="deleteBackup">
|
||||
Delete Backup
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import api from "../../api";
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
backupLoading: false,
|
||||
backupTag: null,
|
||||
selectedBackup: null,
|
||||
selectedTemplate: null,
|
||||
availableBackups: [],
|
||||
availableTemplates: [],
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.getAvailableBackups();
|
||||
},
|
||||
methods: {
|
||||
async getAvailableBackups() {
|
||||
let response = await api.backups.requestAvailable();
|
||||
this.availableBackups = response.imports;
|
||||
this.availableTemplates = response.templates;
|
||||
},
|
||||
importBackup() {
|
||||
if (this.$refs.form.validate()) {
|
||||
this.backupLoading = true;
|
||||
|
||||
api.backups.import(this.selectedBackup);
|
||||
this.backupLoading = false;
|
||||
}
|
||||
},
|
||||
deleteBackup() {
|
||||
if (this.$refs.form.validate()) {
|
||||
this.backupLoading = true;
|
||||
|
||||
api.backups.delete(this.selectedBackup);
|
||||
this.getAvailableBackups();
|
||||
|
||||
this.selectedBackup = null;
|
||||
this.backupLoading = false;
|
||||
}
|
||||
},
|
||||
async createBackup() {
|
||||
this.backupLoading = true;
|
||||
|
||||
let response = await api.backups.create(
|
||||
this.backupTag,
|
||||
this.selectedTemplate
|
||||
);
|
||||
|
||||
if (response.status == 201) {
|
||||
this.selectedBackup = null;
|
||||
this.getAvailableBackups();
|
||||
this.backupLoading = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
</style>
|
12
frontend/src/components/Admin/SFTP.vue
Normal file
12
frontend/src/components/Admin/SFTP.vue
Normal file
|
@ -0,0 +1,12 @@
|
|||
<template>
|
||||
<v-card>
|
||||
<v-card-title class="card-title mt-1"> SFTP Settings </v-card-title>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
</style>
|
159
frontend/src/components/Admin/Theme.vue
Normal file
159
frontend/src/components/Admin/Theme.vue
Normal file
|
@ -0,0 +1,159 @@
|
|||
<template>
|
||||
<v-card>
|
||||
<v-card-title class="card-title mt-1"> Theme Settings </v-card-title>
|
||||
<v-card-text>
|
||||
<p>
|
||||
Select a theme from the dropdown or create a new theme. Note that the
|
||||
default theme will be served to all users who have not set a theme
|
||||
preference.
|
||||
</p>
|
||||
<v-row dense align="center">
|
||||
<v-col cols="12" md="2" sm="5">
|
||||
<v-switch
|
||||
v-model="darkMode"
|
||||
inset
|
||||
label="Dark Mode"
|
||||
class="my-n3"
|
||||
@change="toggleDarkMode"
|
||||
></v-switch>
|
||||
</v-col>
|
||||
<v-col cols="12" md="4" sm="3">
|
||||
<v-form ref="form" lazy-validation>
|
||||
<v-select
|
||||
label="Saved Color Schemes"
|
||||
:items="avaiableThemes"
|
||||
item-text="name"
|
||||
item-value="colors"
|
||||
return-object
|
||||
v-model="selectedScheme"
|
||||
@change="themeSelected"
|
||||
:rules="[(v) => !!v || 'Theme is required']"
|
||||
required
|
||||
>
|
||||
</v-select>
|
||||
</v-form>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="1">
|
||||
<NewTheme @new-theme="appendTheme" />
|
||||
</v-col>
|
||||
<v-col cols="12" sm="1">
|
||||
<v-btn text color="error" @click="deleteSelected"> Delete </v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row dense align-content="center" v-if="activeTheme">
|
||||
<v-col>
|
||||
<ColorPicker button-text="Primary" v-model="activeTheme.primary" />
|
||||
</v-col>
|
||||
<v-col>
|
||||
<ColorPicker button-text="Accent" v-model="activeTheme.accent" />
|
||||
</v-col>
|
||||
<v-col>
|
||||
<ColorPicker
|
||||
button-text="Secondary"
|
||||
v-model="activeTheme.secondary"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col>
|
||||
<ColorPicker button-text="Success" v-model="activeTheme.success" />
|
||||
</v-col>
|
||||
<v-col>
|
||||
<ColorPicker button-text="Info" v-model="activeTheme.info" />
|
||||
</v-col>
|
||||
<v-col>
|
||||
<ColorPicker button-text="Warning" v-model="activeTheme.warning" />
|
||||
</v-col>
|
||||
<v-col>
|
||||
<ColorPicker button-text="Error" v-model="activeTheme.error" />
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions>
|
||||
<v-row>
|
||||
<v-col> </v-col>
|
||||
<v-col></v-col>
|
||||
<v-col align="end">
|
||||
<v-btn text color="success" @click="saveThemes"> Save Theme </v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import api from "../../api";
|
||||
import ColorPicker from "./ThemeUI/ColorPicker";
|
||||
import NewTheme from "./ThemeUI/NewTheme";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ColorPicker,
|
||||
NewTheme,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
themes: null,
|
||||
activeTheme: {},
|
||||
darkMode: false,
|
||||
avaiableThemes: [],
|
||||
selectedScheme: "",
|
||||
selectedLight: "",
|
||||
};
|
||||
},
|
||||
async mounted() {
|
||||
this.avaiableThemes = await api.themes.requestAll();
|
||||
this.darkMode = this.$store.getters.getDarkMode;
|
||||
this.themes = this.$store.getters.getThemes;
|
||||
this.setThemeEditor();
|
||||
},
|
||||
|
||||
methods: {
|
||||
async deleteSelected() {
|
||||
if (this.$refs.form.validate()) {
|
||||
if (this.selectedScheme === "default") {
|
||||
// Notify User Can't Delete Default
|
||||
} else if (this.selectedScheme !== "") {
|
||||
api.themes.delete(this.selectedScheme.name);
|
||||
}
|
||||
this.avaiableThemes = await api.themes.requestAll();
|
||||
}
|
||||
},
|
||||
async appendTheme(newTheme) {
|
||||
api.themes.create(newTheme);
|
||||
this.avaiableThemes.push(newTheme);
|
||||
},
|
||||
themeSelected() {
|
||||
this.activeTheme = this.selectedScheme.colors;
|
||||
},
|
||||
setThemeEditor() {
|
||||
if (this.darkMode) {
|
||||
this.activeTheme = this.themes.dark;
|
||||
} else {
|
||||
this.activeTheme = this.themes.light;
|
||||
}
|
||||
},
|
||||
toggleDarkMode() {
|
||||
this.$store.commit("setDarkMode", this.darkMode);
|
||||
this.selectedScheme = "";
|
||||
|
||||
this.setThemeEditor();
|
||||
},
|
||||
saveThemes() {
|
||||
if (this.$refs.form.validate()) {
|
||||
if (this.darkMode) {
|
||||
this.themes.dark = this.activeTheme;
|
||||
} else {
|
||||
this.themes.light = this.activeTheme;
|
||||
}
|
||||
this.$store.commit("setThemes", this.themes);
|
||||
this.$store.dispatch("initCookies");
|
||||
api.themes.update(this.selectedScheme.name, this.activeTheme);
|
||||
} else;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
</style>
|
||||
|
70
frontend/src/components/Admin/ThemeUI/ColorPicker.vue
Normal file
70
frontend/src/components/Admin/ThemeUI/ColorPicker.vue
Normal file
|
@ -0,0 +1,70 @@
|
|||
<template>
|
||||
<div>
|
||||
<v-btn block :color="value" @click="dialog = true">
|
||||
{{ buttonText }}
|
||||
</v-btn>
|
||||
<v-dialog v-model="dialog" width="400">
|
||||
<v-card>
|
||||
<v-card-title> {{ buttonText }} Color </v-card-title>
|
||||
<v-card-text>
|
||||
<v-text-field v-model="color"> </v-text-field>
|
||||
<v-row>
|
||||
<v-col></v-col>
|
||||
<v-col>
|
||||
<v-color-picker
|
||||
dot-size="28"
|
||||
hide-inputs
|
||||
hide-mode-switch
|
||||
mode="hexa"
|
||||
:show-swatches="swatches"
|
||||
swatches-max-height="300"
|
||||
v-model="color"
|
||||
@change="updateColor"
|
||||
></v-color-picker>
|
||||
</v-col>
|
||||
<v-col></v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-btn text @click="toggleSwatches"> Swatches </v-btn>
|
||||
<v-btn text @click="dialog = false"> Select </v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
buttonText: String,
|
||||
value: String,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
dialog: false,
|
||||
swatches: false,
|
||||
color: "#FF00FF",
|
||||
};
|
||||
},
|
||||
|
||||
watch: {
|
||||
color() {
|
||||
this.updateColor();
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
toggleSwatches() {
|
||||
if (this.swatches) {
|
||||
this.swatches = false;
|
||||
} else this.swatches = true;
|
||||
},
|
||||
updateColor() {
|
||||
this.$emit("input", this.color);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
</style>
|
62
frontend/src/components/Admin/ThemeUI/NewTheme.vue
Normal file
62
frontend/src/components/Admin/ThemeUI/NewTheme.vue
Normal file
|
@ -0,0 +1,62 @@
|
|||
<template>
|
||||
<div>
|
||||
<v-btn text color="success" @click="dialog = true"> New </v-btn>
|
||||
<v-dialog v-model="dialog" width="400">
|
||||
<v-card>
|
||||
<v-card-title> Add a New Theme </v-card-title>
|
||||
<v-card-text>
|
||||
<v-text-field label="Theme Name" v-model="themeName"></v-text-field>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-btn color="success" text @click="Select"> Create </v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
buttonText: String,
|
||||
value: String,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
dialog: false,
|
||||
themeName: "",
|
||||
};
|
||||
},
|
||||
|
||||
watch: {
|
||||
color() {
|
||||
this.updateColor();
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
randomColor() {
|
||||
return "#" + Math.floor(Math.random() * 16777215).toString(16);
|
||||
},
|
||||
Select() {
|
||||
const newTheme = {
|
||||
name: this.themeName,
|
||||
colors: {
|
||||
primary: this.randomColor(),
|
||||
accent: this.randomColor(),
|
||||
secondary: this.randomColor(),
|
||||
success: this.randomColor(),
|
||||
info: this.randomColor(),
|
||||
warning: this.randomColor(),
|
||||
error: this.randomColor(),
|
||||
},
|
||||
};
|
||||
|
||||
this.$emit("new-theme", newTheme);
|
||||
this.dialog = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
</style>
|
12
frontend/src/components/Admin/Users.vue
Normal file
12
frontend/src/components/Admin/Users.vue
Normal file
|
@ -0,0 +1,12 @@
|
|||
<template>
|
||||
<v-card>
|
||||
<v-card-title class="card-title mt-1"> User Settings </v-card-title>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
</style>
|
114
frontend/src/components/Admin/Webhooks.vue
Normal file
114
frontend/src/components/Admin/Webhooks.vue
Normal file
|
@ -0,0 +1,114 @@
|
|||
<template>
|
||||
<v-card>
|
||||
<v-card-title class="card-title mt-1"> Meal Planner Webhooks </v-card-title>
|
||||
<v-card-text>
|
||||
<p>
|
||||
The URLs listed below will recieve webhooks containing the recipe data
|
||||
for the meal plan on it's scheduled day. Currently Webhooks will execute
|
||||
at <strong>{{ time }}</strong>
|
||||
</p>
|
||||
|
||||
<v-row dense align="center">
|
||||
<v-col cols="12" md="2" sm="5">
|
||||
<v-switch
|
||||
v-model="enabled"
|
||||
inset
|
||||
label="Enabled"
|
||||
class="my-n3"
|
||||
></v-switch>
|
||||
</v-col>
|
||||
<v-col cols="12" md="3" sm="5">
|
||||
<TimePicker @save-time="saveTime" />
|
||||
</v-col>
|
||||
<v-col cols="12" md="4" sm="5">
|
||||
<v-btn text color="info" @click="testWebhooks"> Test Webhooks </v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row v-for="(url, index) in webhooks" :key="index" align="center" dense>
|
||||
<v-col cols="1">
|
||||
<v-btn icon color="error" @click="removeWebhook(index)">
|
||||
<v-icon>mdi-minus</v-icon>
|
||||
</v-btn>
|
||||
</v-col>
|
||||
<v-col>
|
||||
<v-text-field
|
||||
v-model="webhooks[index]"
|
||||
label="Webhook URL"
|
||||
></v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-row>
|
||||
<v-col>
|
||||
<v-btn icon color="success" @click="addWebhook">
|
||||
<v-icon>mdi-plus</v-icon>
|
||||
</v-btn>
|
||||
</v-col>
|
||||
<v-col> </v-col>
|
||||
<v-col align="end">
|
||||
<v-btn text color="success" @click="saveWebhooks">
|
||||
Save Webhooks
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import api from "../../api";
|
||||
import TimePicker from "./Webhooks/TimePicker";
|
||||
export default {
|
||||
components: {
|
||||
TimePicker,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
name: "main",
|
||||
webhooks: [],
|
||||
enabled: false,
|
||||
time: "",
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.getSiteSettings();
|
||||
},
|
||||
methods: {
|
||||
saveTime(value) {
|
||||
this.time = value;
|
||||
},
|
||||
async getSiteSettings() {
|
||||
let settings = await api.settings.requestAll();
|
||||
this.webhooks = settings.webhooks.webhookURLs;
|
||||
this.name = settings.name;
|
||||
this.time = settings.webhooks.webhookTime;
|
||||
this.enabled = settings.webhooks.enabled;
|
||||
},
|
||||
addWebhook() {
|
||||
this.webhooks.push(" ");
|
||||
},
|
||||
removeWebhook(index) {
|
||||
this.webhooks.splice(index, 1);
|
||||
},
|
||||
saveWebhooks() {
|
||||
const body = {
|
||||
name: this.name,
|
||||
webhooks: {
|
||||
webhookURLs: this.webhooks,
|
||||
webhookTime: this.time,
|
||||
enabled: this.enabled,
|
||||
},
|
||||
};
|
||||
api.settings.update(body);
|
||||
},
|
||||
testWebhooks() {
|
||||
api.settings.testWebhooks();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
</style>
|
45
frontend/src/components/Admin/Webhooks/TimePicker.vue
Normal file
45
frontend/src/components/Admin/Webhooks/TimePicker.vue
Normal file
|
@ -0,0 +1,45 @@
|
|||
<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="Set New Time"
|
||||
prepend-icon="mdi-clock-time-four-outline"
|
||||
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"> Cancel </v-btn>
|
||||
<v-btn text color="primary" @click="saveTime"> 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>
|
||||
</style>
|
18
frontend/src/components/Home.vue
Normal file
18
frontend/src/components/Home.vue
Normal file
|
@ -0,0 +1,18 @@
|
|||
<template>
|
||||
<div>
|
||||
<RecentRecipes />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import RecentRecipes from "./RecentRecipes";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
RecentRecipes,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
</style>
|
44
frontend/src/components/MealPlan/EditPlan.vue
Normal file
44
frontend/src/components/MealPlan/EditPlan.vue
Normal file
|
@ -0,0 +1,44 @@
|
|||
<template>
|
||||
<v-card>
|
||||
<v-card-title class="accent white--text"> Edit Meal Plan </v-card-title>
|
||||
<v-card-text> </v-card-text>
|
||||
|
||||
<v-card-text>
|
||||
<MealPlanCard v-model="mealPlan.meals" />
|
||||
<v-row align="center" justify="end">
|
||||
<v-card-actions>
|
||||
<v-btn color="success" text @click="update"> Update </v-btn>
|
||||
<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) {
|
||||
let dateObject = new Date(timestamp);
|
||||
return utils.getDateAsPythonDate(dateObject);
|
||||
},
|
||||
async update() {
|
||||
this.process();
|
||||
await api.mealPlans.update(this.mealPlan.uid, this.mealPlan);
|
||||
this.$emit("updated");
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
</style>
|
76
frontend/src/components/MealPlan/MealPlanCard.vue
Normal file
76
frontend/src/components/MealPlan/MealPlanCard.vue
Normal file
|
@ -0,0 +1,76 @@
|
|||
<template>
|
||||
<v-row>
|
||||
<MealSelect
|
||||
:forceDialog="dialog"
|
||||
@close="dialog = false"
|
||||
@select="setSlug($event)"
|
||||
/>
|
||||
<v-col
|
||||
cols="12"
|
||||
sm="12"
|
||||
md="6"
|
||||
lg="4"
|
||||
xl="3"
|
||||
v-for="(meal, index) in value"
|
||||
:key="index"
|
||||
>
|
||||
<v-hover v-slot="{ hover }" :open-delay="50">
|
||||
<v-card :class="{ 'on-hover': hover }" :elevation="hover ? 12 : 2">
|
||||
<v-img
|
||||
height="200"
|
||||
:src="getImage(meal.slug)"
|
||||
@click="selectRecipe(index)"
|
||||
></v-img>
|
||||
<v-card-title class="my-n3 mb-n6">{{ meal.dateText }}</v-card-title>
|
||||
<v-card-subtitle> {{ meal.slug }}</v-card-subtitle>
|
||||
</v-card>
|
||||
</v-hover>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import utils from "../../utils";
|
||||
import MealSelect from "./MealSelect";
|
||||
export default {
|
||||
components: {
|
||||
MealSelect,
|
||||
},
|
||||
props: {
|
||||
value: Array,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
recipeData: [],
|
||||
cardData: [],
|
||||
activeIndex: 0,
|
||||
dialog: false,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
getImage(slug) {
|
||||
if (slug) {
|
||||
return utils.getImageURL(slug);
|
||||
}
|
||||
},
|
||||
setSlug(slug) {
|
||||
let index = this.activeIndex;
|
||||
this.value[index]["slug"] = slug;
|
||||
},
|
||||
selectRecipe(index) {
|
||||
this.activeIndex = index;
|
||||
this.dialog = true;
|
||||
},
|
||||
getProperty(index, property) {
|
||||
try {
|
||||
return this.recipeData[index][property];
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
</style>
|
124
frontend/src/components/MealPlan/MealPlanner.vue
Normal file
124
frontend/src/components/MealPlan/MealPlanner.vue
Normal file
|
@ -0,0 +1,124 @@
|
|||
<template>
|
||||
<div>
|
||||
<EditPlan
|
||||
v-if="editMealPlan"
|
||||
:meal-plan="editMealPlan"
|
||||
@updated="planUpdated"
|
||||
/>
|
||||
<NewMeal v-else @created="requestMeals" />
|
||||
|
||||
<v-card class="my-1">
|
||||
<v-card-title class="accent white--text"> Meal Plans </v-card-title>
|
||||
|
||||
<v-timeline align-top :dense="$vuetify.breakpoint.smAndDown">
|
||||
<v-timeline-item
|
||||
class="px-1"
|
||||
v-for="(mealplan, i) in plannedMeals"
|
||||
:key="i"
|
||||
color="accent lighten-2"
|
||||
icon="mdi-silverware-variant"
|
||||
fill-dot
|
||||
>
|
||||
<v-card color="accent lighten-2" dark>
|
||||
<v-card-title class="title">
|
||||
{{ formatDate(mealplan.startDate) }} -
|
||||
{{ formatDate(mealplan.endDate) }}
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="white text--primary">
|
||||
<v-row dense align="center">
|
||||
<v-col></v-col>
|
||||
<v-col
|
||||
v-for="(meal, index) in mealplan.meals"
|
||||
:key="generateKey(meal.slug, index)"
|
||||
>
|
||||
<v-img
|
||||
class="rounded-lg"
|
||||
:src="getImage(meal.image)"
|
||||
height="80"
|
||||
width="80"
|
||||
>
|
||||
</v-img>
|
||||
</v-col>
|
||||
<v-col></v-col>
|
||||
</v-row>
|
||||
<v-btn
|
||||
color="accent lighten-2"
|
||||
class="mx-0"
|
||||
outlined
|
||||
@click="editPlan(mealplan.uid)"
|
||||
>
|
||||
Edit
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="error lighten-2"
|
||||
class="mx-2"
|
||||
outlined
|
||||
@click="deletePlan(mealplan.uid)"
|
||||
>
|
||||
Delete
|
||||
</v-btn>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-timeline-item>
|
||||
</v-timeline>
|
||||
</v-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import api from "../../api";
|
||||
import utils from "../../utils";
|
||||
import NewMeal from "./NewMeal";
|
||||
import EditPlan from "./EditPlan";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
NewMeal,
|
||||
EditPlan,
|
||||
},
|
||||
data: () => ({
|
||||
plannedMeals: [],
|
||||
editMealPlan: null,
|
||||
}),
|
||||
async mounted() {
|
||||
this.requestMeals();
|
||||
},
|
||||
methods: {
|
||||
async requestMeals() {
|
||||
const response = await api.mealPlans.all();
|
||||
this.plannedMeals = response.data;
|
||||
},
|
||||
generateKey(name, index) {
|
||||
return utils.generateUniqueKey(name, index);
|
||||
},
|
||||
formatDate(timestamp) {
|
||||
let dateObject = new Date(timestamp);
|
||||
return utils.getDateAsTextAlt(dateObject);
|
||||
},
|
||||
getImage(image) {
|
||||
return utils.getImageURL(image);
|
||||
},
|
||||
|
||||
editPlan(id) {
|
||||
this.plannedMeals.forEach((element) => {
|
||||
if (element.uid === id) {
|
||||
console.log(element);
|
||||
this.editMealPlan = element;
|
||||
}
|
||||
});
|
||||
},
|
||||
planUpdated() {
|
||||
this.editMealPlan = null;
|
||||
this.requestMeals();
|
||||
},
|
||||
deletePlan(id) {
|
||||
api.mealPlans.delete(id);
|
||||
this.requestMeals();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
</style>
|
102
frontend/src/components/MealPlan/MealSelect.vue
Normal file
102
frontend/src/components/MealPlan/MealSelect.vue
Normal file
|
@ -0,0 +1,102 @@
|
|||
<template>
|
||||
<v-row justify="center">
|
||||
<v-dialog v-model="dialog" persistent max-width="800">
|
||||
<v-card>
|
||||
<v-card-title class="headline"> Choose a Recipe </v-card-title>
|
||||
<v-card-text>
|
||||
<v-autocomplete
|
||||
:items="avaiableRecipes"
|
||||
v-model="selected"
|
||||
clearable
|
||||
return
|
||||
dense
|
||||
hide-details
|
||||
hide-selected
|
||||
item-text="slug"
|
||||
label="Search for a Recipe"
|
||||
single-line
|
||||
>
|
||||
<template v-slot:no-data>
|
||||
<v-list-item>
|
||||
<v-list-item-title>
|
||||
Search for your Favorite
|
||||
<strong>Recipe</strong>
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
</template>
|
||||
<template v-slot:item="{ item }">
|
||||
<v-row align="center" @click="dialog = false">
|
||||
<v-col sm="2">
|
||||
<v-img
|
||||
max-height="100"
|
||||
max-width="100"
|
||||
:src="getImage(item.image)"
|
||||
></v-img>
|
||||
</v-col>
|
||||
<v-col sm="10">
|
||||
<h3>
|
||||
{{ item.name }}
|
||||
</h3>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</template>
|
||||
</v-autocomplete>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn color="secondary" text @click="dialog = false"> Close </v-btn>
|
||||
<v-btn color="secondary" text @click="dialog = false"> Select </v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</v-row>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import utils from "../../utils";
|
||||
export default {
|
||||
props: {
|
||||
forceDialog: Boolean,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
dialog: false,
|
||||
selected: "",
|
||||
};
|
||||
},
|
||||
|
||||
watch: {
|
||||
forceDialog() {
|
||||
this.dialog = this.forceDialog;
|
||||
},
|
||||
selected() {
|
||||
if (this.selected) {
|
||||
this.$emit("select", this.selected);
|
||||
}
|
||||
},
|
||||
dialog() {
|
||||
if (this.dialog === false) {
|
||||
this.$emit("close");
|
||||
} else {
|
||||
this.selected = "";
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
avaiableRecipes() {
|
||||
return this.$store.getters.getRecentRecipes;
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
getImage(slug) {
|
||||
return utils.getImageURL(slug);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
</style>
|
207
frontend/src/components/MealPlan/NewMeal.vue
Normal file
207
frontend/src/components/MealPlan/NewMeal.vue
Normal file
|
@ -0,0 +1,207 @@
|
|||
<template>
|
||||
<v-card>
|
||||
<v-card-title class="accent white--text">
|
||||
Create a New Meal Plan
|
||||
</v-card-title>
|
||||
<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 v-slot:activator="{ on, attrs }">
|
||||
<v-text-field
|
||||
v-model="startComputedDateFormatted"
|
||||
label="Start Date"
|
||||
persistent-hint
|
||||
prepend-icon="mdi-calendar"
|
||||
readonly
|
||||
v-bind="attrs"
|
||||
v-on="on"
|
||||
></v-text-field>
|
||||
</template>
|
||||
<v-date-picker
|
||||
v-model="startDate"
|
||||
no-title
|
||||
@input="menu2 = false"
|
||||
></v-date-picker>
|
||||
</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 v-slot:activator="{ on, attrs }">
|
||||
<v-text-field
|
||||
v-model="endComputedDateFormatted"
|
||||
label="End Date"
|
||||
persistent-hint
|
||||
prepend-icon="mdi-calendar"
|
||||
readonly
|
||||
v-bind="attrs"
|
||||
v-on="on"
|
||||
></v-text-field>
|
||||
</template>
|
||||
<v-date-picker
|
||||
v-model="endDate"
|
||||
no-title
|
||||
@input="menu2 = false"
|
||||
></v-date-picker>
|
||||
</v-menu>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-text>
|
||||
<MealPlanCard v-model="meals" />
|
||||
</v-card-text>
|
||||
<v-row align="center" justify="end">
|
||||
<v-card-actions>
|
||||
<v-btn color="success" @click="random" v-if="meals[1]" text>
|
||||
Random
|
||||
</v-btn>
|
||||
<v-btn color="success" @click="save" text> Save </v-btn>
|
||||
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn icon @click="show = !show"> </v-btn>
|
||||
</v-card-actions>
|
||||
</v-row>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import api from "../../api";
|
||||
import utils from "../../utils";
|
||||
import MealPlanCard from "./MealPlanCard";
|
||||
export default {
|
||||
components: {
|
||||
MealPlanCard,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isLoading: false,
|
||||
meals: [],
|
||||
|
||||
// Dates
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
menu1: false,
|
||||
menu2: false,
|
||||
};
|
||||
},
|
||||
|
||||
watch: {
|
||||
dateDif() {
|
||||
this.meals = [];
|
||||
for (let i = 0; i < this.dateDif; i++) {
|
||||
this.meals.push({
|
||||
slug: "",
|
||||
date: this.getDate(i),
|
||||
dateText: this.getDayText(i),
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
items() {
|
||||
return this.$store.getters.getRecentRecipes;
|
||||
},
|
||||
actualStartDate() {
|
||||
return Date.parse(this.startDate);
|
||||
},
|
||||
actualEndDate() {
|
||||
return Date.parse(this.endDate);
|
||||
},
|
||||
dateDif() {
|
||||
let startDate = new Date(this.startDate);
|
||||
let endDate = new Date(this.endDate);
|
||||
|
||||
let dateDif = (endDate - startDate) / (1000 * 3600 * 24) + 1;
|
||||
|
||||
if (dateDif <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return dateDif;
|
||||
},
|
||||
startComputedDateFormatted() {
|
||||
return this.formatDate(this.startDate);
|
||||
},
|
||||
endComputedDateFormatted() {
|
||||
return this.formatDate(this.endDate);
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
get_random(list) {
|
||||
const object = list[Math.floor(Math.random() * list.length)];
|
||||
return object.slug;
|
||||
},
|
||||
random() {
|
||||
this.meals.forEach((element, index) => {
|
||||
this.meals[index]["slug"] = this.get_random(this.items);
|
||||
});
|
||||
},
|
||||
processTime(index) {
|
||||
let dateText = new Date(
|
||||
this.actualStartDate.valueOf() + 1000 * 3600 * 24 * index
|
||||
);
|
||||
return dateText;
|
||||
},
|
||||
getDayText(index) {
|
||||
const dateObj = this.processTime(index);
|
||||
return utils.getDateAsText(dateObj);
|
||||
},
|
||||
getDate(index) {
|
||||
const dateObj = this.processTime(index);
|
||||
return utils.getDateAsPythonDate(dateObj);
|
||||
},
|
||||
|
||||
async save() {
|
||||
const mealBody = {
|
||||
startDate: this.startDate,
|
||||
endDate: this.endDate,
|
||||
meals: this.meals,
|
||||
};
|
||||
await api.mealPlans.create(mealBody);
|
||||
this.$emit("created");
|
||||
this.startDate = null;
|
||||
this.endDate = null;
|
||||
this.meals = [];
|
||||
},
|
||||
|
||||
getImage(image) {
|
||||
return utils.getImageURL(image);
|
||||
},
|
||||
|
||||
formatDate(date) {
|
||||
if (!date) return null;
|
||||
|
||||
const [year, month, day] = date.split("-");
|
||||
return `${month}/${day}/${year}`;
|
||||
},
|
||||
parseDate(date) {
|
||||
if (!date) return null;
|
||||
|
||||
const [month, day, year] = date.split("/");
|
||||
return `${year}-${month.padStart(2, "0")}-${day.padStart(2, "0")}`;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
</style>
|
67
frontend/src/components/MealPlan/ThisWeek.vue
Normal file
67
frontend/src/components/MealPlan/ThisWeek.vue
Normal file
|
@ -0,0 +1,67 @@
|
|||
<template>
|
||||
<v-container fill-height>
|
||||
<v-row justify="center" align="center">
|
||||
<v-col sm="12">
|
||||
<v-card
|
||||
v-for="(meal, index) in mealPlan.meals"
|
||||
:key="index"
|
||||
class="my-2"
|
||||
>
|
||||
<v-row dense no-gutters align="center" justify="center">
|
||||
<v-col order="1" md="6" sm="12">
|
||||
<v-card flat>
|
||||
<v-card-title> {{ meal.name }} </v-card-title>
|
||||
<v-card-subtitle> {{ meal.dateText }}</v-card-subtitle>
|
||||
|
||||
<v-card-text> {{ meal.description }} </v-card-text>
|
||||
|
||||
<v-card-actions>
|
||||
<v-btn
|
||||
color="accent"
|
||||
text
|
||||
@click="$router.push(`/recipe/${meal.slug}`)"
|
||||
>
|
||||
View Recipe
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col order-sm="0" :order-md="getOrder(index)" md="6" sm="12">
|
||||
<v-card>
|
||||
<v-img :src="getImage(meal.image)" max-height="300"> </v-img>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import api from "../../api";
|
||||
import utils from "../../utils";
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
mealPlan: {},
|
||||
};
|
||||
},
|
||||
async mounted() {
|
||||
this.mealPlan = await api.mealPlans.thisWeek();
|
||||
console.log(this.mealPlan);
|
||||
},
|
||||
methods: {
|
||||
getOrder(index) {
|
||||
if (index % 2 == 0) return 2;
|
||||
else return 0;
|
||||
},
|
||||
getImage(image) {
|
||||
return utils.getImageURL(image);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
104
frontend/src/components/NewRecipe.vue
Normal file
104
frontend/src/components/NewRecipe.vue
Normal file
|
@ -0,0 +1,104 @@
|
|||
<template>
|
||||
<v-card :loading="isLoading">
|
||||
<v-img v-if="image" height="400" :src="image">
|
||||
<template v-slot:placeholder>
|
||||
<v-row class="fill-height ma-0" align="center" justify="center">
|
||||
<v-progress-circular
|
||||
indeterminate
|
||||
color="grey lighten-5"
|
||||
></v-progress-circular>
|
||||
</v-row>
|
||||
</template>
|
||||
</v-img>
|
||||
<br v-else />
|
||||
|
||||
<ButtonRow
|
||||
@json="jsonEditor = true"
|
||||
@editor="jsonEditor = false"
|
||||
@save="createRecipe"
|
||||
/>
|
||||
|
||||
<VJsoneditor
|
||||
v-if="jsonEditor"
|
||||
v-model="recipeDetails"
|
||||
height="1500px"
|
||||
:options="jsonEditorOptions"
|
||||
/>
|
||||
|
||||
<EditRecipe v-else v-model="recipeDetails" @upload="getImage" />
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import api from "../api";
|
||||
|
||||
import EditRecipe from "./RecipeEditor/EditRecipe";
|
||||
import VJsoneditor from "v-jsoneditor";
|
||||
import ButtonRow from "./UI/ButtonRow";
|
||||
export default {
|
||||
components: {
|
||||
VJsoneditor,
|
||||
EditRecipe,
|
||||
ButtonRow,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isLoading: false,
|
||||
fileObject: null,
|
||||
selectedFile: null,
|
||||
image: null,
|
||||
jsonEditor: false,
|
||||
jsonEditorOptions: {
|
||||
mode: "code",
|
||||
search: false,
|
||||
mainMenuBar: false,
|
||||
},
|
||||
recipeDetails: {
|
||||
name: "",
|
||||
description: "",
|
||||
image: "",
|
||||
recipeYield: "",
|
||||
recipeIngredient: [],
|
||||
recipeInstructions: [],
|
||||
slug: "",
|
||||
filePath: "",
|
||||
tags: [],
|
||||
categories: [],
|
||||
// dateAdded: "",
|
||||
notes: [],
|
||||
extras: [],
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
getImage(fileObject) {
|
||||
console.log(fileObject);
|
||||
this.fileObject = fileObject;
|
||||
this.onFileChange();
|
||||
},
|
||||
onFileChange() {
|
||||
this.image = URL.createObjectURL(this.fileObject);
|
||||
},
|
||||
async createRecipe() {
|
||||
this.isLoading = true;
|
||||
this.recipeDetails.image = this.fileObject.name;
|
||||
console.log(this.recipeDetails);
|
||||
let slug = await api.recipes.create(this.recipeDetails);
|
||||
|
||||
let response = await api.recipes.updateImage(slug, this.fileObject);
|
||||
console.log(response);
|
||||
this.isLoading = false;
|
||||
|
||||
this.$router.push(`/recipe/${slug}`);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.img-input {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
}
|
||||
</style>
|
23
frontend/src/components/Page404.vue
Normal file
23
frontend/src/components/Page404.vue
Normal file
|
@ -0,0 +1,23 @@
|
|||
<template>
|
||||
<div class="text-center">
|
||||
<v-row>
|
||||
<v-col cols="2"></v-col>
|
||||
<v-col>
|
||||
<v-card height="">
|
||||
<v-card-text>
|
||||
<h1>404 No Page Found</h1>
|
||||
</v-card-text>
|
||||
<v-btn text block @click="$router.push('/')"> Take me Home </v-btn>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="2"></v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
</style>
|
40
frontend/src/components/RecentRecipes.vue
Normal file
40
frontend/src/components/RecentRecipes.vue
Normal file
|
@ -0,0 +1,40 @@
|
|||
<template>
|
||||
<v-row>
|
||||
<v-col
|
||||
:sm="6"
|
||||
:md="6"
|
||||
:lg="4"
|
||||
:xl="3"
|
||||
v-for="recipe in recipes"
|
||||
:key="recipe.name"
|
||||
>
|
||||
<RecipeCard
|
||||
:name="recipe.name"
|
||||
:description="recipe.description"
|
||||
:slug="recipe.slug"
|
||||
:rating="recipe.rating"
|
||||
:image="recipe.image"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import RecipeCard from "./UI/RecipeCard";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
RecipeCard,
|
||||
},
|
||||
data: () => ({}),
|
||||
mounted() {},
|
||||
computed: {
|
||||
recipes() {
|
||||
return this.$store.getters.getRecentRecipes;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
157
frontend/src/components/Recipe.vue
Normal file
157
frontend/src/components/Recipe.vue
Normal file
|
@ -0,0 +1,157 @@
|
|||
<template>
|
||||
<v-card id="myRecipe">
|
||||
<v-img
|
||||
height="400"
|
||||
:src="getImage(recipeDetails.image)"
|
||||
class="d-print-none"
|
||||
:key="imageKey"
|
||||
>
|
||||
</v-img>
|
||||
<ButtonRow
|
||||
:open="showIcons"
|
||||
@json="jsonEditor = true"
|
||||
@editor="
|
||||
jsonEditor = false;
|
||||
form = true;
|
||||
"
|
||||
@save="saveRecipe"
|
||||
@delete="deleteRecipe"
|
||||
/>
|
||||
|
||||
<ViewRecipe
|
||||
v-if="!form"
|
||||
:name="recipeDetails.name"
|
||||
:ingredients="recipeDetails.recipeIngredient"
|
||||
:description="recipeDetails.description"
|
||||
:instructions="recipeDetails.recipeInstructions"
|
||||
:tags="recipeDetails.tags"
|
||||
:categories="recipeDetails.categories"
|
||||
:notes="recipeDetails.notes"
|
||||
:rating="recipeDetails.rating"
|
||||
:yields="recipeDetails.recipeYield"
|
||||
:orgURL="recipeDetails.orgURL"
|
||||
/>
|
||||
<VJsoneditor
|
||||
class="mt-10"
|
||||
v-else-if="showJsonEditor"
|
||||
v-model="recipeDetails"
|
||||
height="1500px"
|
||||
:options="jsonEditorOptions"
|
||||
/>
|
||||
<EditRecipe v-else v-model="recipeDetails" @upload="getImageFile" />
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import api from "../api";
|
||||
import utils from "../utils";
|
||||
import VJsoneditor from "v-jsoneditor";
|
||||
import ViewRecipe from "./RecipeEditor/ViewRecipe";
|
||||
import EditRecipe from "./RecipeEditor/EditRecipe";
|
||||
import ButtonRow from "./UI/ButtonRow";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
VJsoneditor,
|
||||
ViewRecipe,
|
||||
EditRecipe,
|
||||
ButtonRow,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
// CurrentRecipe: this.$route.params.recipe,
|
||||
form: false,
|
||||
jsonEditor: false,
|
||||
jsonEditorOptions: {
|
||||
mode: "code",
|
||||
search: false,
|
||||
mainMenuBar: false,
|
||||
},
|
||||
// Recipe Details //
|
||||
recipeDetails: {
|
||||
name: "",
|
||||
description: "",
|
||||
image: "",
|
||||
recipeYield: "",
|
||||
recipeIngredient: [],
|
||||
recipeInstructions: [],
|
||||
slug: "",
|
||||
filePath: "",
|
||||
url: "",
|
||||
tags: [],
|
||||
categories: [],
|
||||
dateAdded: "",
|
||||
notes: [],
|
||||
rating: 0,
|
||||
},
|
||||
imageKey: 1,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.getRecipeDetails();
|
||||
},
|
||||
|
||||
watch: {
|
||||
$route: function () {
|
||||
this.getRecipeDetails();
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
CurrentRecipe() {
|
||||
return this.$route.params.recipe;
|
||||
},
|
||||
showIcons() {
|
||||
return this.form;
|
||||
},
|
||||
showJsonEditor() {
|
||||
if ((this.form === true) & (this.jsonEditor === true)) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
getImageFile(fileObject) {
|
||||
this.fileObject = fileObject;
|
||||
},
|
||||
async getRecipeDetails() {
|
||||
this.recipeDetails = await api.recipes.requestDetails(this.CurrentRecipe);
|
||||
this.form = false;
|
||||
},
|
||||
getImage(image) {
|
||||
if (image) {
|
||||
return utils.getImageURL(image) + "?rnd=" + this.imageKey;
|
||||
}
|
||||
},
|
||||
deleteRecipe() {
|
||||
api.recipes.delete(this.recipeDetails.slug);
|
||||
},
|
||||
async saveRecipe() {
|
||||
console.log(this.recipeDetails);
|
||||
await api.recipes.update(this.recipeDetails);
|
||||
|
||||
if (this.fileObject) {
|
||||
await api.recipes.updateImage(this.recipeDetails.slug, this.fileObject);
|
||||
}
|
||||
|
||||
this.form = false;
|
||||
this.imageKey += 1;
|
||||
},
|
||||
showForm() {
|
||||
this.form = true;
|
||||
this.jsonEditor = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.card-btn {
|
||||
margin-top: -10px;
|
||||
}
|
||||
.disabled-card {
|
||||
opacity: 50%;
|
||||
}
|
||||
</style>
|
65
frontend/src/components/RecipeEditor/BulkAdd.vue
Normal file
65
frontend/src/components/RecipeEditor/BulkAdd.vue
Normal file
|
@ -0,0 +1,65 @@
|
|||
<template>
|
||||
<div class="text-center">
|
||||
<v-dialog v-model="dialog" width="600">
|
||||
<template v-slot:activator="{ on, attrs }">
|
||||
<v-btn
|
||||
text
|
||||
color="secondary lighten-2"
|
||||
dark
|
||||
v-bind="attrs"
|
||||
v-on="on"
|
||||
@click="inputText = ''"
|
||||
>
|
||||
Bulk Add
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<v-card>
|
||||
<v-card-title class="headline"> Bulk Add </v-card-title>
|
||||
|
||||
<v-card-text>
|
||||
<p>
|
||||
Paste in your recipe data. Each line will be treated as an item in a
|
||||
list
|
||||
</p>
|
||||
<v-textarea v-model="inputText"> </v-textarea>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider></v-divider>
|
||||
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn color="success" text @click="save"> Save </v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
dialog: false,
|
||||
inputText: "",
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
splitText() {
|
||||
let split = this.inputText.split("\n");
|
||||
|
||||
split.forEach((element, index) => {
|
||||
if ((element === "\n") | (element == false)) {
|
||||
split.splice(index, 1);
|
||||
}
|
||||
});
|
||||
|
||||
return split;
|
||||
},
|
||||
save() {
|
||||
this.$emit("bulk-data", this.splitText());
|
||||
this.dialog = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
128
frontend/src/components/RecipeEditor/DetailsView.vue
Normal file
128
frontend/src/components/RecipeEditor/DetailsView.vue
Normal file
|
@ -0,0 +1,128 @@
|
|||
<template>
|
||||
<v-card-text>
|
||||
<v-row>
|
||||
<v-col cols="4">
|
||||
<h2 class="mb-4">Ingredients</h2>
|
||||
<div v-for="ingredient in ingredients" :key="ingredient">
|
||||
<v-row align="center">
|
||||
<v-checkbox hide-details class="shrink mr-2 mt-0"></v-checkbox>
|
||||
<v-text-field :value="ingredient"></v-text-field>
|
||||
</v-row>
|
||||
</div>
|
||||
<v-btn
|
||||
class="ml-n5"
|
||||
color="primary"
|
||||
fab`
|
||||
dark
|
||||
small
|
||||
@click="addIngredient"
|
||||
>
|
||||
<v-icon>mdi-plus</v-icon>
|
||||
</v-btn>
|
||||
|
||||
<h2 class="mt-6">Categories</h2>
|
||||
<v-combobox
|
||||
dense
|
||||
multiple
|
||||
chips
|
||||
item-color="primary"
|
||||
deletable-chips
|
||||
:value="categories"
|
||||
>
|
||||
<template v-slot:selection="data">
|
||||
<v-chip :selected="data.selected" close color="primary" dark>
|
||||
{{ data.item }}
|
||||
</v-chip>
|
||||
</template>
|
||||
</v-combobox>
|
||||
|
||||
<h2 class="mt-4">Tags</h2>
|
||||
<v-combobox dense multiple chips deletable-chips :value="tags">
|
||||
<template v-slot:selection="data">
|
||||
<v-chip :selected="data.selected" close color="primary" dark>
|
||||
{{ data.item }}
|
||||
</v-chip>
|
||||
</template>
|
||||
</v-combobox>
|
||||
</v-col>
|
||||
|
||||
<v-divider :vertical="true"></v-divider>
|
||||
|
||||
<v-col>
|
||||
<h2 class="mb-4">Instructions</h2>
|
||||
<div v-for="(step, index) in instructions" :key="step.text">
|
||||
<v-hover v-slot="{ hover }">
|
||||
<v-card
|
||||
class="ma-1"
|
||||
:class="[{ 'on-hover': hover }]"
|
||||
:elevation="hover ? 12 : 2"
|
||||
>
|
||||
<v-card-title>Step: {{ index + 1 }}</v-card-title>
|
||||
<v-card-text>
|
||||
<v-textarea dense :value="step.text"></v-textarea>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-hover>
|
||||
</div>
|
||||
<v-btn color="primary" fab dark small @click="addStep">
|
||||
<v-icon>mdi-plus</v-icon>
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
form: Boolean,
|
||||
ingredients: Array,
|
||||
instructions: Array,
|
||||
categories: Array,
|
||||
tags: Array,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
disabledSteps: [],
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
toggleDisabled(stepIndex) {
|
||||
if (this.disabledSteps.includes(stepIndex)) {
|
||||
let index = this.disabledSteps.indexOf(stepIndex);
|
||||
if (index !== -1) {
|
||||
this.disabledSteps.splice(index, 1);
|
||||
}
|
||||
} else {
|
||||
this.disabledSteps.push(stepIndex);
|
||||
}
|
||||
},
|
||||
isDisabled(stepIndex) {
|
||||
if (this.disabledSteps.includes(stepIndex)) {
|
||||
return "disabled-card";
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
},
|
||||
saveRecipe() {
|
||||
this.$emit("save");
|
||||
},
|
||||
deleteRecipe() {
|
||||
this.$emit("delete");
|
||||
},
|
||||
addIngredient() {
|
||||
this.$emit("addingredient");
|
||||
},
|
||||
addStep() {
|
||||
this.$emit("addstep");
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.disabled-card {
|
||||
opacity: 50%;
|
||||
}
|
||||
</style>
|
270
frontend/src/components/RecipeEditor/EditRecipe.vue
Normal file
270
frontend/src/components/RecipeEditor/EditRecipe.vue
Normal file
|
@ -0,0 +1,270 @@
|
|||
<template>
|
||||
<div>
|
||||
<v-card-text>
|
||||
<v-row dense>
|
||||
<v-col cols="3"></v-col>
|
||||
<v-col>
|
||||
<v-file-input
|
||||
v-model="fileObject"
|
||||
label="Image File"
|
||||
truncate-length="30"
|
||||
@change="uploadImage"
|
||||
></v-file-input>
|
||||
</v-col>
|
||||
<v-col cols="3"></v-col>
|
||||
</v-row>
|
||||
<v-text-field class="my-3" label="Recipe Name" v-model="value.name">
|
||||
</v-text-field>
|
||||
<v-textarea height="100" label="Description" v-model="value.description">
|
||||
</v-textarea>
|
||||
<div class="my-2"></div>
|
||||
<v-row dense disabled>
|
||||
<v-col sm="5">
|
||||
<v-text-field
|
||||
label="Servings"
|
||||
color="accent darken-1"
|
||||
v-model="value.recipeYield"
|
||||
>
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
<v-col></v-col>
|
||||
<v-rating
|
||||
class="mr-2 align-end"
|
||||
color="accent darken-1"
|
||||
background-color="accent lighten-3"
|
||||
length="5"
|
||||
v-model="value.rating"
|
||||
></v-rating>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col cols="12" sm="12" md="4" lg="4">
|
||||
<h2 class="mb-4">Ingredients</h2>
|
||||
<div
|
||||
v-for="(ingredient, index) in value.recipeIngredient"
|
||||
:key="generateKey('ingredient', index)"
|
||||
>
|
||||
<v-row align="center">
|
||||
<v-btn
|
||||
fab
|
||||
x-small
|
||||
color="white"
|
||||
class="mr-2"
|
||||
elevation="0"
|
||||
@click="removeIngredient(index)"
|
||||
>
|
||||
<v-icon color="error">mdi-delete</v-icon>
|
||||
</v-btn>
|
||||
<v-text-field
|
||||
label="Ingredient"
|
||||
v-model="value.recipeIngredient[index]"
|
||||
></v-text-field>
|
||||
</v-row>
|
||||
</div>
|
||||
<v-btn color="secondary" fab dark small @click="addIngredient">
|
||||
<v-icon>mdi-plus</v-icon>
|
||||
</v-btn>
|
||||
<BulkAdd @bulk-data="appendIngredients" />
|
||||
|
||||
<h2 class="mt-6">Categories</h2>
|
||||
<v-combobox
|
||||
dense
|
||||
multiple
|
||||
chips
|
||||
item-color="secondary"
|
||||
deletable-chips
|
||||
v-model="value.categories"
|
||||
>
|
||||
<template v-slot:selection="data">
|
||||
<v-chip :input-value="data.selected" close color="secondary" dark>
|
||||
{{ data.item }}
|
||||
</v-chip>
|
||||
</template>
|
||||
</v-combobox>
|
||||
|
||||
<h2 class="mt-4">Tags</h2>
|
||||
<v-combobox dense multiple chips deletable-chips v-model="value.tags">
|
||||
<template v-slot:selection="data">
|
||||
<v-chip :input-value="data.selected" close color="secondary" dark>
|
||||
{{ data.item }}
|
||||
</v-chip>
|
||||
</template>
|
||||
</v-combobox>
|
||||
|
||||
<h2 class="my-4">Notes</h2>
|
||||
<v-card
|
||||
class="mt-1"
|
||||
v-for="(note, index) in value.notes"
|
||||
:key="generateKey('note', index)"
|
||||
>
|
||||
<v-card-text>
|
||||
<v-row align="center">
|
||||
<v-btn
|
||||
fab
|
||||
x-small
|
||||
color="white"
|
||||
class="mr-2"
|
||||
elevation="0"
|
||||
@click="removeNote(index)"
|
||||
>
|
||||
<v-icon color="error">mdi-delete</v-icon>
|
||||
</v-btn>
|
||||
<v-text-field
|
||||
label="Title"
|
||||
v-model="value.notes[index]['title']"
|
||||
></v-text-field>
|
||||
</v-row>
|
||||
|
||||
<v-textarea label="Note" v-model="value.notes[index]['text']">
|
||||
</v-textarea>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
<v-btn class="mt-1" color="secondary" fab dark small @click="addNote">
|
||||
<v-icon>mdi-plus</v-icon>
|
||||
</v-btn>
|
||||
</v-col>
|
||||
|
||||
<v-divider class="my-divider" :vertical="true"></v-divider>
|
||||
|
||||
<v-col cols="12" sm="12" md="8" lg="8">
|
||||
<h2 class="mb-4">Instructions</h2>
|
||||
<div v-for="(step, index) in value.recipeInstructions" :key="index">
|
||||
<v-hover v-slot="{ hover }">
|
||||
<v-card
|
||||
class="ma-1"
|
||||
:class="[{ 'on-hover': hover }]"
|
||||
:elevation="hover ? 12 : 2"
|
||||
>
|
||||
<v-card-title>
|
||||
<v-btn
|
||||
fab
|
||||
x-small
|
||||
color="white"
|
||||
class="mr-2"
|
||||
elevation="0"
|
||||
@click="removeStep(index)"
|
||||
>
|
||||
<v-icon color="error">mdi-delete</v-icon> </v-btn
|
||||
>Step: {{ index + 1 }}</v-card-title
|
||||
>
|
||||
<v-card-text>
|
||||
<v-textarea
|
||||
dense
|
||||
v-model="value.recipeInstructions[index]['text']"
|
||||
:key="generateKey('instructions', index)"
|
||||
></v-textarea>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-hover>
|
||||
</div>
|
||||
<v-btn color="secondary" fab dark small @click="addStep">
|
||||
<v-icon>mdi-plus</v-icon>
|
||||
</v-btn>
|
||||
<BulkAdd @bulk-data="appendSteps" />
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import api from "../../api";
|
||||
import utils from "../../utils";
|
||||
import BulkAdd from "./BulkAdd";
|
||||
export default {
|
||||
components: {
|
||||
BulkAdd,
|
||||
},
|
||||
props: {
|
||||
value: Object,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
fileObject: null,
|
||||
content: this.value,
|
||||
disabledSteps: [],
|
||||
description: String,
|
||||
ingredients: Array,
|
||||
instructions: Array,
|
||||
categories: Array,
|
||||
tags: Array,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
uploadImage() {
|
||||
this.$emit("upload", this.fileObject);
|
||||
},
|
||||
async updateImage() {
|
||||
let slug = this.value.slug;
|
||||
api.recipes.updateImage(slug, this.fileObject);
|
||||
},
|
||||
toggleDisabled(stepIndex) {
|
||||
if (this.disabledSteps.includes(stepIndex)) {
|
||||
let index = this.disabledSteps.indexOf(stepIndex);
|
||||
if (index !== -1) {
|
||||
this.disabledSteps.splice(index, 1);
|
||||
}
|
||||
} else {
|
||||
this.disabledSteps.push(stepIndex);
|
||||
}
|
||||
},
|
||||
isDisabled(stepIndex) {
|
||||
if (this.disabledSteps.includes(stepIndex)) {
|
||||
return "disabled-card";
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
},
|
||||
generateKey(item, index) {
|
||||
return utils.generateUniqueKey(item, index);
|
||||
},
|
||||
deleteRecipe() {
|
||||
this.$emit("delete");
|
||||
},
|
||||
|
||||
appendIngredients(ingredients) {
|
||||
this.value.recipeIngredient.push(...ingredients);
|
||||
},
|
||||
addIngredient() {
|
||||
let list = this.value.recipeIngredient;
|
||||
list.push("");
|
||||
},
|
||||
|
||||
removeIngredient(index) {
|
||||
this.value.recipeIngredient.splice(index, 1);
|
||||
},
|
||||
|
||||
appendSteps(steps) {
|
||||
let processSteps = [];
|
||||
steps.forEach((element) => {
|
||||
processSteps.push({ text: element });
|
||||
});
|
||||
|
||||
this.value.recipeInstructions.push(...processSteps);
|
||||
},
|
||||
addStep() {
|
||||
let list = this.value.recipeInstructions;
|
||||
list.push({ text: "" });
|
||||
},
|
||||
removeStep(index) {
|
||||
this.value.recipeInstructions.splice(index, 1);
|
||||
},
|
||||
|
||||
addNote() {
|
||||
let list = this.value.notes;
|
||||
list.push({ text: "" });
|
||||
},
|
||||
removeNote(index) {
|
||||
this.value.notes.splice(index, 1);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.disabled-card {
|
||||
opacity: 50%;
|
||||
}
|
||||
.my-divider {
|
||||
margin: 0 -1px;
|
||||
}
|
||||
</style>
|
183
frontend/src/components/RecipeEditor/ViewRecipe.vue
Normal file
183
frontend/src/components/RecipeEditor/ViewRecipe.vue
Normal file
|
@ -0,0 +1,183 @@
|
|||
<template>
|
||||
<div>
|
||||
<v-card-title class="headline">
|
||||
{{ name }}
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
{{ description }}
|
||||
<div class="my-2"></div>
|
||||
<v-row dense disabled>
|
||||
<v-col>
|
||||
<v-btn
|
||||
v-if="yields"
|
||||
dense
|
||||
small
|
||||
:hover="false"
|
||||
type="label"
|
||||
:ripple="false"
|
||||
elevation="0"
|
||||
color="accent darken-1"
|
||||
class="rounded-sm static"
|
||||
>
|
||||
{{ yields }}
|
||||
</v-btn>
|
||||
</v-col>
|
||||
<v-rating
|
||||
class="mr-2 align-end static"
|
||||
color="accent darken-1"
|
||||
background-color="accent lighten-3"
|
||||
length="5"
|
||||
:value="rating"
|
||||
></v-rating>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col cols="12" sm="12" md="4" lg="4">
|
||||
<h2 class="mb-4">Ingredients</h2>
|
||||
<div
|
||||
v-for="(ingredient, index) in ingredients"
|
||||
:key="generateKey('ingredient', index)"
|
||||
>
|
||||
<v-checkbox
|
||||
hide-details
|
||||
class="ingredients"
|
||||
:label="ingredient"
|
||||
color="accent"
|
||||
>
|
||||
</v-checkbox>
|
||||
</div>
|
||||
|
||||
<div v-if="categories[0]">
|
||||
<h2 class="mt-4">Categories</h2>
|
||||
<v-chip
|
||||
class="ma-1"
|
||||
color="primary"
|
||||
dark
|
||||
v-for="category in categories"
|
||||
:key="category"
|
||||
>
|
||||
{{ category }}
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
<div v-if="tags[0]">
|
||||
<h2 class="mt-4">Tags</h2>
|
||||
<v-chip
|
||||
class="ma-1"
|
||||
color="primary"
|
||||
dark
|
||||
v-for="tag in tags"
|
||||
:key="tag"
|
||||
>
|
||||
{{ tag }}
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
<h2 v-if="notes[0]" class="my-4">Notes</h2>
|
||||
<v-card
|
||||
class="mt-1"
|
||||
v-for="(note, index) in notes"
|
||||
:key="generateKey('note', index)"
|
||||
>
|
||||
<v-card-title> {{ note.title }}</v-card-title>
|
||||
<v-card-text>
|
||||
{{ note.text }}
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-divider class="my-divider" :vertical="true"></v-divider>
|
||||
|
||||
<v-col cols="12" sm="12" md="8" lg="8">
|
||||
<h2 class="mb-4">Instructions</h2>
|
||||
<v-hover
|
||||
v-for="(step, index) in instructions"
|
||||
:key="generateKey('step', index)"
|
||||
v-slot="{ hover }"
|
||||
>
|
||||
<v-card
|
||||
class="ma-1"
|
||||
:class="[{ 'on-hover': hover }, isDisabled(index)]"
|
||||
:elevation="hover ? 12 : 2"
|
||||
@click="toggleDisabled(index)"
|
||||
>
|
||||
<v-card-title>Step: {{ index + 1 }}</v-card-title>
|
||||
<v-card-text>{{ step.text }}</v-card-text>
|
||||
</v-card>
|
||||
</v-hover>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col></v-col>
|
||||
|
||||
<v-btn
|
||||
v-if="orgURL"
|
||||
dense
|
||||
small
|
||||
:hover="false"
|
||||
type="label"
|
||||
:ripple="false"
|
||||
elevation="0"
|
||||
:href="orgURL"
|
||||
color="accent darken-1"
|
||||
target="_blank"
|
||||
class="rounded-sm mr-4"
|
||||
>
|
||||
Original Recipe
|
||||
</v-btn>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import utils from "../../utils";
|
||||
export default {
|
||||
props: {
|
||||
name: String,
|
||||
description: String,
|
||||
ingredients: Array,
|
||||
instructions: Array,
|
||||
categories: Array,
|
||||
tags: Array,
|
||||
notes: Array,
|
||||
rating: Number,
|
||||
yields: String,
|
||||
orgURL: String,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
disabledSteps: [],
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
toggleDisabled(stepIndex) {
|
||||
if (this.disabledSteps.includes(stepIndex)) {
|
||||
let index = this.disabledSteps.indexOf(stepIndex);
|
||||
if (index !== -1) {
|
||||
this.disabledSteps.splice(index, 1);
|
||||
}
|
||||
} else {
|
||||
this.disabledSteps.push(stepIndex);
|
||||
}
|
||||
},
|
||||
isDisabled(stepIndex) {
|
||||
if (this.disabledSteps.includes(stepIndex)) {
|
||||
return "disabled-card";
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
},
|
||||
generateKey(item, index) {
|
||||
return utils.generateUniqueKey(item, index);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.static {
|
||||
pointer-events: none;
|
||||
}
|
||||
.my-divider {
|
||||
margin: 0 -1px;
|
||||
}
|
||||
</style>
|
48
frontend/src/components/UI/ButtonRow.vue
Normal file
48
frontend/src/components/UI/ButtonRow.vue
Normal file
|
@ -0,0 +1,48 @@
|
|||
<template>
|
||||
<v-toolbar class="card-btn" flat height="0" extension-height="0">
|
||||
<template v-slot:extension>
|
||||
<v-col></v-col>
|
||||
<div v-if="open">
|
||||
<v-btn class="mr-2" fab dark small color="error" @click="deleteRecipe">
|
||||
<v-icon>mdi-delete</v-icon>
|
||||
</v-btn>
|
||||
<v-btn class="mr-2" fab dark small color="success" @click="save">
|
||||
<v-icon>mdi-content-save</v-icon>
|
||||
</v-btn>
|
||||
<v-btn class="mr-5" fab dark small color="accent" @click="json">
|
||||
<v-icon>mdi-code-braces</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
<v-btn color="secondary" fab dark small @click="editor">
|
||||
<v-icon>mdi-square-edit-outline</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-toolbar>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
open: {
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
editor() {
|
||||
this.$emit("editor");
|
||||
},
|
||||
save() {
|
||||
this.$emit("save");
|
||||
},
|
||||
deleteRecipe() {
|
||||
this.$emit("delete");
|
||||
},
|
||||
json() {
|
||||
this.$emit("json");
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
</style>
|
99
frontend/src/components/UI/Login.vue
Normal file
99
frontend/src/components/UI/Login.vue
Normal file
|
@ -0,0 +1,99 @@
|
|||
<template>
|
||||
<div class="text-center">
|
||||
<v-btn icon @click="showLogin = true">
|
||||
<v-icon>mdi-account</v-icon>
|
||||
</v-btn>
|
||||
<v-dialog v-model="showLogin" width="500">
|
||||
<v-flex class="login-form text-xs-center">
|
||||
<v-card>
|
||||
<v-card-text>
|
||||
<v-form>
|
||||
<v-text-field
|
||||
v-if="!options.isLoggingIn"
|
||||
v-model="user.name"
|
||||
light="light"
|
||||
prepend-icon="person"
|
||||
label="Name"
|
||||
></v-text-field>
|
||||
<v-text-field
|
||||
v-model="user.email"
|
||||
light="light"
|
||||
prepend-icon="mdi-email"
|
||||
label="Email"
|
||||
type="email"
|
||||
></v-text-field>
|
||||
<v-text-field
|
||||
v-model="user.password"
|
||||
light="light"
|
||||
prepend-icon="mdi-lock"
|
||||
label="Password"
|
||||
type="password"
|
||||
></v-text-field>
|
||||
<v-checkbox
|
||||
class="mb-2 mt-0"
|
||||
v-if="options.isLoggingIn"
|
||||
v-model="options.shouldStayLoggedIn"
|
||||
light="light"
|
||||
label="Stay logged in?"
|
||||
hide-details="hide-details"
|
||||
></v-checkbox>
|
||||
<v-btn
|
||||
v-if="options.isLoggingIn"
|
||||
@click.prevent="login"
|
||||
dark
|
||||
color="primary"
|
||||
block="block"
|
||||
type="submit"
|
||||
>Sign in</v-btn
|
||||
>
|
||||
<v-btn
|
||||
v-else
|
||||
block="block"
|
||||
type="submit"
|
||||
@click.prevent="options.isLoggingIn = true"
|
||||
>Sign up</v-btn
|
||||
>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
<!-- <v-card-actions v-if="options.isLoggingIn" class="card-actions">
|
||||
Don't have an account?
|
||||
<v-btn
|
||||
color="primary"
|
||||
light="light"
|
||||
@click="options.isLoggingIn = false"
|
||||
>
|
||||
Sign up
|
||||
</v-btn>
|
||||
</v-card-actions> -->
|
||||
</v-card>
|
||||
</v-flex>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import api from "../../api";
|
||||
export default {
|
||||
props: {},
|
||||
data() {
|
||||
return {
|
||||
showLogin: false,
|
||||
user: {
|
||||
email: "",
|
||||
password: "",
|
||||
},
|
||||
options: {
|
||||
isLoggingIn: true,
|
||||
},
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
async login() {
|
||||
let key = await api.login(this.user.email, this.user.password);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
</style>
|
66
frontend/src/components/UI/Menu.vue
Normal file
66
frontend/src/components/UI/Menu.vue
Normal file
|
@ -0,0 +1,66 @@
|
|||
<template>
|
||||
<div class="text-center">
|
||||
<v-menu
|
||||
transition="slide-x-transition"
|
||||
bottom
|
||||
right
|
||||
offset-y
|
||||
open-on-hover
|
||||
close-delay="200"
|
||||
>
|
||||
<template v-slot:activator="{ on, attrs }">
|
||||
<v-btn v-bind="attrs" v-on="on" icon>
|
||||
<v-icon>mdi-menu</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<v-list>
|
||||
<v-list-item v-for="(item, i) in items" :key="i" link>
|
||||
<v-list-item-icon @click="navRouter(item.nav)">
|
||||
<v-icon>{{ item.icon }}</v-icon>
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content @click="navRouter(item.nav)">
|
||||
<v-list-item-title>
|
||||
{{ item.title }}
|
||||
</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data: () => ({
|
||||
items: [
|
||||
{
|
||||
icon: "mdi-calendar-week",
|
||||
title: "Dinner This Week",
|
||||
nav: "/meal-plan/this-week",
|
||||
},
|
||||
{
|
||||
icon: "mdi-calendar-today",
|
||||
title: "Dinner Today",
|
||||
nav: "/meal-plan/today",
|
||||
},
|
||||
{
|
||||
icon: "mdi-calendar-multiselect",
|
||||
title: "Planner",
|
||||
nav: "/meal-plan/planner",
|
||||
},
|
||||
{ icon: "mdi-cog", title: "Settings", nav: "/settings/site" },
|
||||
],
|
||||
}),
|
||||
methods: {
|
||||
navRouter(route) {
|
||||
this.$router.push(route);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style>
|
||||
.menu-text {
|
||||
text-align: left !important;
|
||||
}
|
||||
</style>
|
63
frontend/src/components/UI/RecipeCard.vue
Normal file
63
frontend/src/components/UI/RecipeCard.vue
Normal file
|
@ -0,0 +1,63 @@
|
|||
<template>
|
||||
<v-hover v-slot="{ hover }" :open-delay="50">
|
||||
<v-card
|
||||
:class="{ 'on-hover': hover }"
|
||||
:elevation="hover ? 12 : 2"
|
||||
@click="moreInfo(slug)"
|
||||
>
|
||||
<v-img height="200" :src="getImage(image)"></v-img>
|
||||
<v-card-title class="my-n3 mb-n6">{{ name | truncate(30) }}</v-card-title>
|
||||
|
||||
<v-card-actions class="">
|
||||
<v-row dense align="center">
|
||||
<v-col>
|
||||
<v-rating
|
||||
class="mr-2"
|
||||
color="accent"
|
||||
background-color="accent lighten-3"
|
||||
dense
|
||||
length="5"
|
||||
size="15"
|
||||
:value="rating"
|
||||
></v-rating>
|
||||
</v-col>
|
||||
<v-col></v-col>
|
||||
<v-col align="end">
|
||||
<v-tooltip top color="secondary" max-width="400" open-delay="50">
|
||||
<template v-slot:activator="{ on, attrs }">
|
||||
<v-btn color="secondary" v-on="on" v-bind="attrs" text
|
||||
>Description</v-btn
|
||||
>
|
||||
</template>
|
||||
<span>{{ description }}</span>
|
||||
</v-tooltip>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-hover>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import utils from "../../utils";
|
||||
export default {
|
||||
props: {
|
||||
name: String,
|
||||
slug: String,
|
||||
description: String,
|
||||
rating: Number,
|
||||
image: String,
|
||||
},
|
||||
methods: {
|
||||
moreInfo(recipeSlug) {
|
||||
this.$router.push(`/recipe/${recipeSlug}`);
|
||||
},
|
||||
getImage(image) {
|
||||
return utils.getImageURL(image);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
</style>
|
65
frontend/src/components/UI/Search.vue
Normal file
65
frontend/src/components/UI/Search.vue
Normal file
|
@ -0,0 +1,65 @@
|
|||
<template>
|
||||
<v-autocomplete
|
||||
:items="items"
|
||||
:loading="isLoading"
|
||||
v-model="selected"
|
||||
clearable
|
||||
return
|
||||
dense
|
||||
hide-details
|
||||
hide-selected
|
||||
item-text="slug"
|
||||
label="Search for a Recipe"
|
||||
single-line
|
||||
@keyup.enter.native="moreInfo(selected)"
|
||||
>
|
||||
<template v-slot:no-data>
|
||||
<v-list-item>
|
||||
<v-list-item-title>
|
||||
Search for your Favorite
|
||||
<strong>Recipe</strong>
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
</template>
|
||||
<template v-slot:item="{ item }">
|
||||
<v-list-item-avatar
|
||||
color="primary"
|
||||
class="headline font-weight-light white--text"
|
||||
>
|
||||
<v-img :src="getImage(item.image)"></v-img>
|
||||
</v-list-item-avatar>
|
||||
<v-list-item-content @click="moreInfo(item.slug)">
|
||||
<v-list-item-title v-text="item.name"></v-list-item-title>
|
||||
</v-list-item-content>
|
||||
</template>
|
||||
</v-autocomplete>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import utils from "../../utils";
|
||||
|
||||
export default {
|
||||
data: () => ({
|
||||
selected: null,
|
||||
isLoading: false,
|
||||
}),
|
||||
|
||||
computed: {
|
||||
items() {
|
||||
return this.$store.getters.getRecentRecipes;
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
moreInfo(recipeSlug) {
|
||||
this.$router.push(`/recipe/${recipeSlug}`);
|
||||
},
|
||||
getImage(image) {
|
||||
return utils.getImageURL(image);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
</style>
|
27
frontend/src/components/UI/SearchHeader.vue
Normal file
27
frontend/src/components/UI/SearchHeader.vue
Normal file
|
@ -0,0 +1,27 @@
|
|||
<template>
|
||||
<v-row>
|
||||
<v-col cols="2"> </v-col>
|
||||
<v-col>
|
||||
<v-expand-transition>
|
||||
<Search class="search-bar" />
|
||||
</v-expand-transition>
|
||||
</v-col>
|
||||
<v-col cols="2">
|
||||
<v-btn icon>
|
||||
<v-icon> mdi-filter </v-icon>
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Search from "./Search";
|
||||
export default {
|
||||
components: {
|
||||
Search,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
</style>
|
41
frontend/src/components/UI/SnackBar.vue
Normal file
41
frontend/src/components/UI/SnackBar.vue
Normal file
|
@ -0,0 +1,41 @@
|
|||
<template>
|
||||
<div class="text-center">
|
||||
<v-snackbar :value="active" :timeout="timeout" :color="type">
|
||||
{{ text }}
|
||||
|
||||
<template v-slot:action="{ attrs }">
|
||||
<v-btn color="white" text v-bind="attrs" @click="close(false)">
|
||||
Close
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-snackbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data: () => ({
|
||||
snackbar: false,
|
||||
timeout: -1,
|
||||
}),
|
||||
computed: {
|
||||
text() {
|
||||
return this.$store.getters.getSnackText;
|
||||
},
|
||||
active() {
|
||||
return this.$store.getters.getSnackActive;
|
||||
},
|
||||
type() {
|
||||
return this.$store.getters.getSnackType;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
close(value) {
|
||||
this.$store.commit("setSnackActive", value);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
</style>
|
Loading…
Add table
Add a link
Reference in a new issue