Upload component (#108)

* unified upload button + download backups

* javascript toolings

* fix vuetur config

* fixed type check error

* refactor: clean up bag javascript

Co-authored-by: Hayden <hay-kot@pm.me>
This commit is contained in:
Hayden 2021-01-20 20:56:47 -09:00 committed by GitHub
commit f35e9c20d6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 239 additions and 105 deletions

3
frontend/jsconfig.json Normal file
View file

@ -0,0 +1,3 @@
{
"include": ["./src/**/*"]
}

View file

@ -54,5 +54,11 @@
"> 1%",
"last 2 versions",
"not dead"
]
}
],
"prettier": {
"trailingComma": "es5",
"tabWidth": 2,
"semi": true,
"singleQuote": false
}
}

View file

@ -34,11 +34,11 @@
</template>
<script>
import Menu from "./components/UI/Menu";
import SearchBar from "./components/UI/SearchBar";
import AddRecipeFab from "./components/UI/AddRecipeFab";
import SnackBar from "./components/UI/SnackBar";
import Vuetify from "./plugins/vuetify";
import Menu from "./components/UI/Menu"
import SearchBar from "./components/UI/SearchBar"
import AddRecipeFab from "./components/UI/AddRecipeFab"
import SnackBar from "./components/UI/SnackBar"
import Vuetify from "./plugins/vuetify"
export default {
name: "App",
@ -51,15 +51,15 @@ export default {
watch: {
$route() {
this.search = false;
this.search = false
},
},
mounted() {
this.$store.dispatch("initTheme");
this.$store.dispatch("requestRecentRecipes");
this.darkModeSystemCheck();
this.darkModeAddEventListener();
this.$store.dispatch("initTheme")
this.$store.dispatch("requestRecentRecipes")
this.darkModeSystemCheck()
this.darkModeAddEventListener()
},
data: () => ({
@ -73,30 +73,30 @@ export default {
if (this.$store.getters.getDarkMode === "system")
Vuetify.framework.theme.dark = window.matchMedia(
"(prefers-color-scheme: dark)"
).matches;
).matches
},
/**
* This will monitor the OS level darkmode and call to update dark mode.
*/
darkModeAddEventListener() {
const darkMediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const darkMediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
darkMediaQuery.addEventListener("change", () => {
this.darkModeSystemCheck();
});
this.darkModeSystemCheck()
})
},
toggleSearch() {
if (this.search === true) {
this.search = false;
this.search = false
} else {
this.search = true;
this.search = true
}
},
navigateFromSearch(slug) {
this.$router.push(`/recipe/${slug}`);
this.$router.push(`/recipe/${slug}`)
},
},
};
}
</script>
<style>

View file

@ -4,8 +4,7 @@ import mealplan from "./api/mealplan";
import settings from "./api/settings";
import themes from "./api/themes";
import migration from "./api/migration";
// import api from "../api";
import myUtils from "./api/upload";
export default {
recipes: recipe,
@ -14,4 +13,5 @@ export default {
settings: settings,
themes: themes,
migrations: migration,
utils: myUtils,
};

View file

@ -16,8 +16,8 @@ function processResponse(response) {
}
const apiReq = {
post: async function(url, data) {
let response = await axios.post(url, data).catch(function(error) {
post: async function (url, data) {
let response = await axios.post(url, data).catch(function (error) {
if (error.response) {
processResponse(error.response);
return error.response;
@ -27,8 +27,8 @@ const apiReq = {
return response;
},
get: async function(url, data) {
let response = await axios.get(url, data).catch(function(error) {
get: async function (url, data) {
let response = await axios.get(url, data).catch(function (error) {
if (error.response) {
processResponse(error.response);
return response;
@ -38,8 +38,8 @@ const apiReq = {
return response;
},
delete: async function(url, data) {
let response = await axios.delete(url, data).catch(function(error) {
delete: async function (url, data) {
let response = await axios.delete(url, data).catch(function (error) {
if (error.response) {
processResponse(error.response);
return response;

View file

@ -10,6 +10,7 @@ const backupURLs = {
createBackup: `${backupBase}export/database/`,
importBackup: (fileName) => `${backupBase}${fileName}/import/`,
deleteBackup: (fileName) => `${backupBase}${fileName}/delete/`,
downloadBackup: (fileName) => `${backupBase}${fileName}/download/`,
};
export default {
@ -32,4 +33,8 @@ export default {
let response = apiReq.post(backupURLs.createBackup, data);
return response;
},
async download(fileName) {
let response = await apiReq.get(backupURLs.downloadBackup(fileName));
return response.data;
},
};

View file

@ -0,0 +1,13 @@
import { apiReq } from "./api-utils";
export default {
// import api from "../api";
async uploadFile(url, fileObject) {
let response = await apiReq.post(url, fileObject, {
headers: {
"Content-Type": "multipart/form-data",
},
});
return response.data;
},
};

View file

@ -1,13 +1,17 @@
<template>
<v-card>
<v-card-title class="headline"> {{$t('meal-plan.edit-meal-plan')}} </v-card-title>
<v-card-title class="headline">
{{ $t("meal-plan.edit-meal-plan") }}
</v-card-title>
<v-divider></v-divider>
<v-card-text>
<MealPlanCard v-model="mealPlan.meals" />
<v-row align="center" justify="end">
<v-card-actions>
<v-btn color="success" text @click="update"> {{$t('general.update')}} </v-btn>
<v-btn color="success" text @click="update">
{{ $t("general.update") }}
</v-btn>
<v-spacer></v-spacer>
</v-card-actions>
</v-row>

View file

@ -1,7 +1,7 @@
<template>
<v-card>
<v-card-title class="headline">
{{$t('meal-plan.create-a-new-meal-plan')}}
{{ $t("meal-plan.create-a-new-meal-plan") }}
</v-card-title>
<v-divider></v-divider>
<v-card-text>
@ -71,9 +71,11 @@
<v-row align="center" justify="end">
<v-card-actions>
<v-btn color="success" @click="random" v-if="meals[1]" text>
{{$t('general.random')}}
{{ $t("general.random") }}
</v-btn>
<v-btn color="success" @click="save" text>
{{ $t("general.save") }}
</v-btn>
<v-btn color="success" @click="save" text> {{$t('general.save')}} </v-btn>
<v-spacer></v-spacer>
<v-btn icon @click="show = !show"> </v-btn>

View file

@ -8,7 +8,7 @@
<v-card-text>
<v-row>
<v-col >
<v-col>
<v-checkbox
class="mb-n4 mt-1"
dense
@ -65,15 +65,15 @@
<v-divider></v-divider>
<v-card-actions>
<v-btn disabled color="success" text @click="raiseEvent('download')">
{{$t('general.download')}}
<v-btn color="success" text :href="`/api/backups/${name}/download/`">
{{ $t("general.download") }}
</v-btn>
<v-spacer></v-spacer>
<v-btn color="error" text @click="raiseEvent('delete')">
{{$t('general.delete')}}
{{ $t("general.delete") }}
</v-btn>
<v-btn color="success" text @click="raiseEvent('import')">
{{$t('general.import')}}
{{ $t("general.import") }}
</v-btn>
</v-card-actions>
</v-card>

View file

@ -21,10 +21,11 @@
Available Backups
<v-spacer></v-spacer>
<span>
<v-btn color="success" text class="ma-2 white--text">
Upload
<v-icon right dark> mdi-cloud-upload </v-icon>
</v-btn>
<UploadBtn
class="mt-1"
url="/api/backups/upload/"
@uploaded="getAvailableBackups"
/>
</span>
</v-card-title>
<AvailableBackupCard
@ -45,12 +46,14 @@
<script>
import api from "../../../api";
import SuccessFailureAlert from "../../UI/SuccessFailureAlert";
import UploadBtn from "../../UI/UploadBtn";
import AvailableBackupCard from "./AvailableBackupCard";
import NewBackupCard from "./NewBackupCard";
export default {
components: {
SuccessFailureAlert,
UploadBtn,
AvailableBackupCard,
NewBackupCard,
},
@ -70,6 +73,7 @@ export default {
let response = await api.backups.requestAvailable();
this.availableBackups = response.imports;
this.availableTemplates = response.templates;
console.log(this.availableBackups);
},
deleteBackup() {
if (this.$refs.form.validate()) {

View file

@ -1,7 +1,11 @@
<template>
<v-card-text>
<p>
{{$t('migration.you-can-import-recipes-from-either-a-zip-file-or-a-directory-located-in-the-app-data-migraiton-folder-please-review-the-documentation-to-ensure-your-directory-structure-matches-what-is-expected')}}
{{
$t(
"migration.you-can-import-recipes-from-either-a-zip-file-or-a-directory-located-in-the-app-data-migraiton-folder-please-review-the-documentation-to-ensure-your-directory-structure-matches-what-is-expected"
)
}}
</p>
<v-form ref="form">
<v-row align="center">
@ -13,27 +17,32 @@
:rules="[rules.required]"
></v-select>
</v-col>
<v-col cols="12" md="2" sm="12">
<v-btn text color="info" @click="importRecipes"> {{$t('migration.migrate')}} </v-btn>
</v-col>
<v-col cols="12" md="1" sm="12">
<v-btn text color="error" @click="deleteImportValidation">
{{$t('general.delete')}}
</v-btn>
<Confirmation
:title="$t('general.delete-data')"
:message="$t('migration.delete-confirmation')"
color="error"
icon="mdi-alert-circle"
ref="deleteThemeConfirm"
v-on:confirm="deleteImport()"
/>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="5" sm="12">
<UploadMigrationButton @uploaded="getAvaiableImports" />
<v-col md="1" sm="12">
<v-btn-toggle group>
<v-btn text color="info" @click="importRecipes">
{{ $t("migration.migrate") }}
</v-btn>
<v-btn text color="error" @click="deleteImportValidation">
{{ $t("general.delete") }}
</v-btn>
<UploadBtn
url="/api/migration/upload/"
class="mt-1"
@uploaded="getAvaiableImports"
/>
<Confirmation
:title="$t('general.delete-data')"
:message="$t('migration.delete-confirmation')"
color="error"
icon="mdi-alert-circle"
ref="deleteThemeConfirm"
v-on:confirm="deleteImport()"
/>
</v-btn-toggle>
</v-col>
<v-spacer></v-spacer>
</v-row>
</v-form>
<SuccessFailureAlert
@ -48,13 +57,13 @@
<script>
import api from "../../../api";
import SuccessFailureAlert from "../../UI/SuccessFailureAlert";
import UploadMigrationButton from "./UploadMigrationButton";
import Confirmation from "../../UI/Confirmation";
import UploadBtn from "../../UI/UploadBtn";
export default {
components: {
SuccessFailureAlert,
UploadMigrationButton,
Confirmation,
UploadBtn,
},
data() {
return {
@ -63,7 +72,7 @@ export default {
availableImports: [],
selectedImport: null,
rules: {
required: (v) => !!v || "Selection Required",
required: v => !!v || "Selection Required",
},
};
},

View file

@ -43,7 +43,7 @@
<v-form ref="form" lazy-validation>
<v-row dense align="center">
<v-col cols="12" md="4" sm="3">
<v-col md="4" sm="3">
<v-select
:label="$t('settings.theme.saved-color-theme')"
:items="availableThemes"
@ -51,18 +51,18 @@
return-object
v-model="selectedTheme"
@change="themeSelected"
:rules="[(v) => !!v || $t('settings.theme.theme-is-required')]"
:rules="[v => !!v || $t('settings.theme.theme-is-required')]"
required
>
</v-select>
</v-col>
<v-col cols="12" sm="1">
<NewThemeDialog @new-theme="appendTheme" />
</v-col>
<v-col cols="12" sm="1">
<v-btn text color="error" @click="deleteSelectedThemeValidation">
Delete
</v-btn>
<v-col>
<v-btn-toggle group>
<NewThemeDialog @new-theme="appendTheme" class="mt-1" />
<v-btn text color="error" @click="deleteSelectedThemeValidation">
Delete
</v-btn>
</v-btn-toggle>
<Confirmation
:title="$t('settings.theme.delete-theme')"
:message="
@ -74,6 +74,7 @@
v-on:confirm="deleteSelectedTheme()"
/>
</v-col>
<v-spacer></v-spacer>
</v-row>
</v-form>
<v-row dense align-content="center" v-if="selectedTheme.colors">
@ -123,15 +124,10 @@
</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">
{{ $t("settings.theme.save-colors-and-apply-theme") }}
</v-btn>
</v-col>
</v-row>
<v-spacer></v-spacer>
<v-btn color="success" @click="saveThemes" class="mr-2">
{{ $t("general.save") }}
</v-btn>
</v-card-actions>
</v-card>
</template>
@ -187,7 +183,7 @@ export default {
//Change to default if deleting current theme.
if (
!this.availableThemes.some(
(theme) => theme.name === this.selectedTheme.name
theme => theme.name === this.selectedTheme.name
)
) {
await this.$store.dispatch("resetTheme");

View file

@ -1,10 +1,17 @@
<template>
<v-card>
<v-card-title class="headline">
{{$t('settings.webhooks.meal-planner-webhooks')}}
{{ $t("settings.webhooks.meal-planner-webhooks") }}
</v-card-title>
<v-card-text>
<p v-html="$t('settings.webhooks.the-urls-listed-below-will-recieve-webhooks-containing-the-recipe-data-for-the-meal-plan-on-its-scheduled-day-currently-webhooks-will-execute-at', {time: time})"></p>
<p
v-html="
$t(
'settings.webhooks.the-urls-listed-below-will-recieve-webhooks-containing-the-recipe-data-for-the-meal-plan-on-its-scheduled-day-currently-webhooks-will-execute-at',
{ time: time }
)
"
></p>
<v-row dense align="center">
<v-col cols="12" md="2" sm="5">
@ -19,7 +26,9 @@
<TimePickerDialog @save-time="saveTime" />
</v-col>
<v-col cols="12" md="4" sm="5">
<v-btn text color="info" @click="testWebhooks"> {{$t('settings.webhooks.test-webhooks')}} </v-btn>
<v-btn text color="info" @click="testWebhooks">
{{ $t("settings.webhooks.test-webhooks") }}
</v-btn>
</v-col>
</v-row>
@ -46,8 +55,8 @@
</v-col>
<v-col> </v-col>
<v-col align="end">
<v-btn text color="success" @click="saveWebhooks">
{{$t('settings.webhooks.save-webhooks')}}
<v-btn color="success" @click="saveWebhooks" class="mr-2 mb-1">
{{ $t("settings.webhooks.save-webhooks") }}
</v-btn>
</v-col>
</v-row>

View file

@ -1,24 +1,61 @@
<template>
<v-form ref="file">
<v-file-input
:loading="loading"
:label="$t('migration.upload-an-archive')"
v-model="file"
accept=".zip"
@change="upload"
:prepend-icon="icon"
class="file-icon"
<input ref="uploader" class="d-none" type="file" @change="onFileChanged" />
<v-btn
:loading="isSelecting"
@click="onButtonClick"
color="success"
text
>
</v-file-input>
<v-btn color="success" text class="ma-2 white--text">
<v-icon left > mdi-cloud-upload </v-icon>
Upload
<v-icon right dark> mdi-cloud-upload </v-icon>
</v-btn>
</v-form>
</template>
<script>
export default {};
import api from "../../api";
export default {
props: {
url: String,
},
data: () => ({
defaultButtonText: "Upload",
file: null,
isSelecting: false,
}),
methods: {
async upload() {
if (this.file != null) {
this.isSelecting = true;
let formData = new FormData();
formData.append("archive", this.file);
await api.utils.uploadFile(this.url, formData);
this.isSelecting = false;
this.$emit("uploaded");
}
},
onButtonClick() {
this.isSelecting = true;
window.addEventListener(
"focus",
() => {
this.isSelecting = false;
},
{ once: true }
);
this.$refs.uploader.click();
},
onFileChanged(e) {
this.file = e.target.files[0];
this.upload();
},
},
};
</script>
<style>

View file

@ -11,6 +11,7 @@
"paste-in-your-recipe-data-each-line-will-be-treated-as-an-item-in-a-list": "Paste in your recipe data. Each line will be treated as an item in a list"
},
"general": {
"upload": "Upload",
"submit": "Submit",
"name": "Name",
"settings": "Settings",
@ -137,4 +138,4 @@
"failed-imports": "Failed Imports",
"upload-an-archive": "Upload an Archive"
}
}
}

View file

@ -109,7 +109,7 @@ export default {
},
editPlan(id) {
this.plannedMeals.forEach((element) => {
this.plannedMeals.forEach(element => {
if (element.uid === id) {
this.editMealPlan = element;
}

View file

@ -1,12 +1,14 @@
import operator
import shutil
from app_config import BACKUP_DIR, TEMPLATE_DIR
from db.db_setup import generate_session
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from models.backup_models import BackupJob, ImportJob, Imports, LocalBackup
from services.backups.exports import backup_all
from services.backups.imports import ImportDatabase
from sqlalchemy.orm.session import Session
from starlette.responses import FileResponse
from utils.snackbar import SnackResponse
router = APIRouter(tags=["Import / Export"])
@ -49,6 +51,33 @@ def export_database(data: BackupJob, db: Session = Depends(generate_session)):
)
@router.post("/api/backups/upload/")
def upload_backup_zipfile(archive: UploadFile = File(...)):
""" Upload a .zip File to later be imported into Mealie """
dest = BACKUP_DIR.joinpath(archive.filename)
with dest.open("wb") as buffer:
shutil.copyfileobj(archive.file, buffer)
if dest.is_file:
return SnackResponse.success("Backup uploaded")
else:
return SnackResponse.error("Failure uploading file")
@router.get("/api/backups/{file_name}/download/")
def upload_nextcloud_zipfile(file_name: str):
""" Upload a .zip File to later be imported into Mealie """
file = BACKUP_DIR.joinpath(file_name)
if file.is_file:
return FileResponse(
file, media_type="application/octet-stream", filename=file_name
)
else:
return SnackResponse.error("No File Found")
@router.post("/api/backups/{file_name}/import/", status_code=200)
def import_database(
file_name: str, import_data: ImportJob, db: Session = Depends(generate_session)

16
vetur.config.js Normal file
View file

@ -0,0 +1,16 @@
// vetur.config.js
/** @type {import('vls').VeturConfig} */
module.exports = {
settings: {
"vetur.useWorkspaceDependencies": true,
"vetur.experimental.templateInterpolationService": true,
"vetur.validation.interpolation": false,
},
projects: [
{
root: "./frontend",
package: "package.json",
globalComponents: ["./src/components/**/*.vue"],
},
],
};