Refactor/backend routers (#388)

* update router

* update caddy file

* setup depends in docker-fole

* make changes for serving on subpath

* set dev config

* fix router signups

* consolidate links

* backup-functionality to dashboard

* new user card

* consolidate theme into profile

* fix theme tests

* fix pg tests

* fix pg tests

* remove unused import

* mobile margin

Co-authored-by: hay-kot <hay-kot@pm.me>
This commit is contained in:
Hayden 2021-05-04 20:45:11 -08:00 committed by GitHub
parent be5ac7a17a
commit c1370afb16
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
52 changed files with 878 additions and 1094 deletions

19
.vscode/settings.json vendored
View file

@ -1,28 +1,19 @@
{
"python.formatting.provider": "black",
"python.pythonPath": ".venv/bin/python3.9",
"python.linting.pylintEnabled": true,
"python.linting.pylintEnabled": false,
"python.linting.enabled": true,
"python.testing.unittestEnabled": false,
"python.testing.nosetestsEnabled": false,
"python.testing.pytestEnabled": true,
"python.testing.autoTestDiscoverOnSaveEnabled": false,
"python.testing.pytestArgs": ["tests"],
"cSpell.enableFiletypes": [
"!javascript",
"!python",
"!yaml"
],
"cSpell.enableFiletypes": ["!javascript", "!python", "!yaml"],
"i18n-ally.localesPaths": "frontend/src/locales/messages",
"i18n-ally.sourceLanguage": "en-US",
"i18n-ally.enabledFrameworks": ["vue"],
"i18n-ally.keystyle": "nested",
"cSpell.words": [
"compression",
"hkotel",
"performant",
"postgres",
"webp"
],
"search.mode": "reuseEditor"
"cSpell.words": ["compression", "hkotel", "performant", "postgres", "webp"],
"search.mode": "reuseEditor",
"python.linting.flake8Enabled": true
}

View file

@ -6,11 +6,11 @@
:80 {
@proxied path /api/* /docs /openapi.json
root * /app/dist
encode gzip
encode gzip zstd
uri strip_suffix /
handle_path /api/recipes/media/* {
# Handles Recipe Images / Assets
handle_path /api/media/recipes/* {
root * /app/data/recipes/
file_server
}
@ -20,8 +20,8 @@
}
handle {
try_files {path}.html {path} /
root * /app/dist
try_files {path}.html {path} /index.html
file_server
}
}

9
Caddyfile.dev Normal file
View file

@ -0,0 +1,9 @@
{
admin off
}
localhost {
handle /mealie/* {
reverse_proxy http://127.0.0.1:9090
}
}

View file

@ -6,6 +6,8 @@ services:
dockerfile: Dockerfile
container_name: mealie
restart: always
depends_on:
- "postgres"
ports:
- 9090:80
environment:

View file

@ -135,14 +135,18 @@ export const recipeAPI = {
},
recipeImage(recipeSlug) {
return `/api/recipes/media/${recipeSlug}/images/original.webp`;
return `/api/media/recipes/${recipeSlug}/images/original.webp`;
},
recipeSmallImage(recipeSlug) {
return `/api/recipes/media/${recipeSlug}/images/min-original.webp`;
return `/api/media/recipes/${recipeSlug}/images/min-original.webp`;
},
recipeTinyImage(recipeSlug) {
return `/api/recipes/media/${recipeSlug}/images/tiny-original.webp`;
return `/api/media/recipes/${recipeSlug}/images/tiny-original.webp`;
},
recipeAssetPath(recipeSlug, assetName) {
return `api/media/recipes/${recipeSlug}/assets/${assetName}`;
},
};

View file

@ -6,10 +6,10 @@ const prefix = baseURL + "themes";
const settingsURLs = {
allThemes: `${baseURL}themes`,
specificTheme: themeName => `${prefix}/${themeName}`,
specificTheme: id => `${prefix}/${id}`,
createTheme: `${prefix}/create`,
updateTheme: themeName => `${prefix}/${themeName}`,
deleteTheme: themeName => `${prefix}/${themeName}`,
updateTheme: id => `${prefix}/${id}`,
deleteTheme: id => `${prefix}/${id}`,
};
export const themeAPI = {
@ -32,22 +32,18 @@ export const themeAPI = {
);
},
update(themeName, colors) {
const body = {
name: themeName,
colors: colors,
};
update(data) {
return apiReq.put(
settingsURLs.updateTheme(themeName),
body,
settingsURLs.updateTheme(data.id),
data,
() => i18n.t("settings.theme.error-updating-theme"),
() => i18n.t("settings.theme.theme-updated")
);
},
delete(themeName) {
delete(id) {
return apiReq.delete(
settingsURLs.deleteTheme(themeName),
settingsURLs.deleteTheme(id),
null,
() => i18n.t("settings.theme.error-deleting-theme"),
() => i18n.t("settings.theme.theme-deleted")

View file

@ -3,7 +3,7 @@
<div class="text-center">
<h3>{{ buttonText }}</h3>
</div>
<v-text-field v-model="color" hide-details class="ma-0 pa-0" solo v-show="$vuetify.breakpoint.mdAndUp">
<v-text-field v-model="color" hide-details class="ma-0 pa-0" solo >
<template v-slot:append>
<v-menu v-model="menu" top nudge-bottom="105" nudge-left="16" :close-on-content-click="false">
<template v-slot:activator="{ on }">
@ -17,15 +17,7 @@
</v-menu>
</template>
</v-text-field>
<div class="text-center" v-show="$vuetify.breakpoint.smAndDown">
<v-menu v-model="menu" top nudge-bottom="105" nudge-left="16" :close-on-content-click="false">
<template v-slot:activator="{ on, attrs }">
<v-chip label :color="`${color}`" dark v-bind="attrs" v-on="on">
{{ color }}
</v-chip>
</template>
</v-menu>
</div>
</div>
</template>

View file

@ -1,8 +1,8 @@
<template>
<div>
<v-checkbox
v-for="option in options"
:key="option.text"
v-for="(option, index) in options"
:key="index"
class="mb-n4 mt-n3"
dense
:label="option.text"
@ -61,6 +61,4 @@ export default {
},
},
};
</script>
<style lang="scss" scoped></style>
</script>

View file

@ -14,14 +14,7 @@
<v-list-item-title class="pl-2" v-text="item.name"></v-list-item-title>
</v-list-item-content>
<v-list-item-action>
<v-btn
v-if="!edit"
color="primary"
icon
:href="`/api/recipes/media/${slug}/assets/${item.fileName}`"
target="_blank"
top
>
<v-btn v-if="!edit" color="primary" icon :href="assetURL(item.fileName)" target="_blank" top>
<v-icon> mdi-download</v-icon>
</v-btn>
<div v-else>
@ -118,6 +111,9 @@ export default {
},
},
methods: {
assetURL(assetName) {
return api.recipes.recipeAssetPath(this.slug, assetName);
},
setFileObject(obj) {
this.fileObject = obj;
},
@ -135,7 +131,8 @@ export default {
this.value.splice(index, 1);
},
copyLink(name, fileName) {
const copyText = `![${name}](${this.baseURL}/api/recipes/media/${this.slug}/assets/${fileName})`;
const assetLink = api.recipes.recipeAssetPath(this.slug, fileName);
const copyText = `![${name}](${assetLink})`;
navigator.clipboard.writeText(copyText).then(
() => console.log("Copied", copyText),
() => console.log("Copied Failed", copyText)

View file

@ -18,7 +18,7 @@
color="secondary darken-1"
class="rounded-sm static"
>
{{ yields }}
{{ recipe.yields }}
</v-btn>
</v-col>
<Rating :value="recipe.rating" :name="recipe.name" :slug="recipe.slug" />

View file

@ -2,7 +2,7 @@
<v-form ref="file">
<input ref="uploader" class="d-none" type="file" @change="onFileChanged" />
<slot v-bind="{ isSelecting, onButtonClick }">
<v-btn :loading="isSelecting" @click="onButtonClick" color="accent" :text="textBtn">
<v-btn :loading="isSelecting" @click="onButtonClick" :small="small" color="accent" :text="textBtn">
<v-icon left> {{ icon }}</v-icon>
{{ text ? text : defaultText }}
</v-btn>
@ -15,6 +15,9 @@ const UPLOAD_EVENT = "uploaded";
import { api } from "@/api";
export default {
props: {
small: {
default: false,
},
post: {
type: Boolean,
default: true,
@ -27,7 +30,7 @@ export default {
default: true,
},
},
data: () => ({
data: () => ({
file: null,
isSelecting: false,
}),

View file

@ -1,39 +1,30 @@
<template>
<div class="mt-n5" v-if="recipes">
<v-card flat class="transparent" height="60px">
<v-card-text>
<v-row v-if="title != null">
<v-col>
<v-btn-toggle group>
<v-btn text>
{{ title.toUpperCase() }}
</v-btn>
</v-btn-toggle>
</v-col>
<v-spacer></v-spacer>
<v-col align="end">
<v-menu offset-y v-if="sortable">
<template v-slot:activator="{ on, attrs }">
<v-btn-toggle group>
<v-btn text v-bind="attrs" v-on="on">
{{ $t("general.sort") }}
</v-btn>
</v-btn-toggle>
</template>
<v-list>
<v-list-item @click="$emit('sort-recent')">
<v-list-item-title>{{ $t("general.recent") }}</v-list-item-title>
</v-list-item>
<v-list-item @click="$emit('sort')">
<v-list-item-title>{{ $t("general.sort-alphabetically") }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-col>
</v-row>
</v-card-text>
</v-card>
<div v-if="recipes">
<div v-if="recipes">
<v-app-bar color="transparent" flat class="mt-n1 rounded" v-if="!disableToolbar">
<v-icon large left v-if="title">
{{ titleIcon }}
</v-icon>
<v-toolbar-title class="headline"> {{ title }} </v-toolbar-title>
<v-spacer></v-spacer>
<v-menu offset-y v-if="$listeners.sortRecent || $listeners.sort">
<template v-slot:activator="{ on, attrs }">
<v-btn-toggle group>
<v-btn text v-bind="attrs" v-on="on">
{{ $t("general.sort") }}
</v-btn>
</v-btn-toggle>
</template>
<v-list>
<v-list-item @click="$emit('sortRecent')">
<v-list-item-title>{{ $t("general.recent") }}</v-list-item-title>
</v-list-item>
<v-list-item @click="$emit('sort')">
<v-list-item-title>{{ $t("general.sort-alphabetically") }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-app-bar>
<div v-if="recipes" class="mt-2">
<v-row v-if="!viewScale">
<v-col :sm="6" :md="6" :lg="4" :xl="3" v-for="recipe in recipes.slice(0, cardLimit)" :key="recipe.name">
<RecipeCard
@ -91,9 +82,12 @@ export default {
MobileRecipeCard,
},
props: {
sortable: {
disableToolbar: {
default: false,
},
titleIcon: {
default: "mdi-tag-multiple-outline",
},
title: {
default: null,
},

View file

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

View file

@ -3,14 +3,14 @@
<slot name="open" v-bind="{ open }"> </slot>
<v-dialog v-model="dialog" :width="modalWidth + 'px'" :content-class="top ? 'top-dialog' : undefined">
<v-card class="pb-10" height="100%">
<v-app-bar dark :color="color" class="mt-n1 mb-2">
<v-app-bar dark :color="color" class="mt-n1 mb-0">
<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 color="primary"></v-progress-linear>
<v-progress-linear class="mt-1" v-if="loading" indeterminate color="primary"></v-progress-linear>
<slot> </slot>
<v-card-actions>
<slot name="card-actions">
@ -18,8 +18,12 @@
{{ $t("general.cancel") }}
</v-btn>
<v-spacer></v-spacer>
<v-btn color="error" text @click="deleteEvent" v-if="$listeners.delete">
{{ $t("general.delete") }}
</v-btn>
<v-btn color="success" @click="submitEvent">
{{ $t("general.submit") }}
{{ submitText }}
</v-btn>
</slot>
</v-card-actions>
@ -31,6 +35,7 @@
</template>
<script>
import i18n from "@/i18n";
export default {
props: {
color: {
@ -51,16 +56,34 @@ export default {
top: {
default: false,
},
submitText: {
default: () => i18n.t("general.create"),
},
},
data() {
return {
dialog: false,
submitted: false,
};
},
computed: {
determineClose() {
return this.submitted && !this.loading;
},
},
watch: {
determineClose() {
this.submitted = false;
this.dialog = false;
},
dialog(val) {
if (val) this.submitted = false;
},
},
methods: {
submitEvent() {
this.$emit("submit");
this.close();
this.submitted = true;
},
open() {
this.dialog = true;
@ -68,6 +91,10 @@ export default {
close() {
this.dialog = false;
},
deleteEvent() {
this.$emit("delete");
this.submitted = true;
},
},
};
</script>

View file

@ -48,7 +48,7 @@
</template>
<script>
import ImportOptions from "./ImportOptions";
import ImportOptions from "@/components/FormHelpers/ImportOptions";
import TheDownloadBtn from "@/components/UI/Buttons/TheDownloadBtn.vue";
import { backupURLs } from "@/api/backup";
export default {

View file

@ -1,17 +1,19 @@
w<template>
<v-card v-bind="$attrs" :class="classes" class="v-card--material pa-3">
<div class="d-flex grow flex-wrap">
<v-sheet
:color="color"
:max-height="icon ? 90 : undefined"
:width="icon ? 'auto' : '100%'"
elevation="6"
class="text-start v-card--material__heading mb-n6 mt-n10 pa-7"
dark
>
<v-icon v-if="icon" size="40" v-text="icon" />
<div v-if="text" class="headline font-weight-thin" v-text="text" />
</v-sheet>
<slot name="avatar">
<v-sheet
:color="color"
:max-height="icon ? 90 : undefined"
:width="icon ? 'auto' : '100%'"
elevation="6"
class="text-start v-card--material__heading mb-n6 mt-n10 pa-7"
dark
>
<v-icon v-if="icon" size="40" v-text="icon" />
<div v-if="text" class="headline font-weight-thin" v-text="text" />
</v-sheet>
</slot>
<div v-if="$slots['after-heading']" class="ml-auto">
<slot name="after-heading" />
@ -29,7 +31,7 @@ w<template>
</template>
<template v-if="$slots.bottom">
<v-divider class="mt-2" />
<v-divider class="mt-2" v-if="!$slots.actions" />
<div class="pb-0">
<slot name="bottom" />
@ -73,6 +75,7 @@ export default {
classes() {
return {
"v-card--material--has-heading": this.hasHeading,
"mt-3": this.$vuetify.breakpoint.name == "xs" || this.$vuetify.breakpoint.name == "sm"
};
},
hasHeading() {

View file

@ -133,11 +133,6 @@ export default {
to: "/admin/profile",
title: this.$t("settings.profile"),
},
{
icon: "mdi-format-color-fill",
to: "/admin/themes",
title: this.$t("general.themes"),
},
{
icon: "mdi-food",
to: "/admin/meal-planner",
@ -167,11 +162,6 @@ export default {
to: "/admin/manage-users",
title: this.$t("settings.manage-users"),
},
{
icon: "mdi-backup-restore",
to: "/admin/backups",
title: this.$t("settings.backup-and-exports"),
},
{
icon: "mdi-database-import",
to: "/admin/migrations",

View file

@ -1,79 +0,0 @@
<template>
<div>
<ImportDialog
:name="selectedName"
:date="selectedDate"
ref="import_dialog"
@import="importBackup"
@delete="deleteBackup"
/>
<v-row>
<v-col :cols="12" :sm="6" :md="6" :lg="4" :xl="4" v-for="backup in backups" :key="backup.name">
<v-card hover outlined @click="openDialog(backup)">
<v-card-text>
<v-row align="center">
<v-col cols="2">
<v-icon large color="primary">mdi-backup-restore</v-icon>
</v-col>
<v-col cols="10">
<div class="text-truncate">
<strong>{{ backup.name }}</strong>
</div>
<div class="text-truncate">{{ $d(Date.parse(backup.date), "medium") }}</div>
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-col>
</v-row>
</div>
</template>
<script>
import ImportDialog from "./ImportDialog";
import { api } from "@/api";
export default {
props: {
backups: Array,
},
components: {
ImportDialog,
},
data() {
return {
selectedName: "",
selectedDate: "",
loading: false,
};
},
methods: {
openDialog(backup) {
this.selectedDate = backup.date;
this.selectedName = backup.name;
this.$refs.import_dialog.open();
},
async importBackup(data) {
this.$emit("loading");
const response = await api.backups.import(data.name, data);
if (response) {
let importData = response.data;
this.$emit("finished", importData);
} else {
this.$emit("finished");
}
},
async deleteBackup(data) {
this.$emit("loading");
if (await api.backups.delete(data.name)) {
this.selectedBackup = null;
}
this.backupLoading = false;
this.$emit("finished");
},
},
};
</script>
<style></style>

View file

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

View file

@ -1,75 +0,0 @@
<template>
<v-card :loading="backupLoading" class="mt-3">
<v-card-title class="headline">
{{ $t("settings.backup-and-exports") }}
</v-card-title>
<v-divider></v-divider>
<v-card-text>
<v-row>
<v-col cols="12" md="6" sm="12">
<NewBackupCard @created="processFinished" />
</v-col>
<v-col cols="12" md="6" sm="12">
<p>
{{ $t("settings.backup-info") }}
</p>
</v-col>
</v-row>
<v-divider class="my-3"></v-divider>
<v-card-title class="mt-n6">
{{ $t("settings.available-backups") }}
<span>
<TheUploadBtn class="mt-1" url="/api/backups/upload" @uploaded="getAvailableBackups" />
</span>
<v-spacer></v-spacer>
</v-card-title>
<AvailableBackupCard @loading="backupLoading = true" @finished="processFinished" :backups="availableBackups" />
<ImportSummaryDialog ref="report" :import-data="importData" />
</v-card-text>
</v-card>
</template>
<script>
import { api } from "@/api";
import TheUploadBtn from "@/components/UI/Buttons/TheUploadBtn";
import ImportSummaryDialog from "@/components/ImportSummaryDialog";
import AvailableBackupCard from "@/pages/Admin/Backup/AvailableBackupCard";
import NewBackupCard from "@/pages/Admin/Backup/NewBackupCard";
export default {
components: {
TheUploadBtn,
AvailableBackupCard,
NewBackupCard,
ImportSummaryDialog,
},
data() {
return {
failedImports: [],
successfulImports: [],
backupLoading: false,
availableBackups: [],
importData: [],
};
},
mounted() {
this.getAvailableBackups();
},
methods: {
async getAvailableBackups() {
let response = await api.backups.requestAvailable();
this.availableBackups = response.imports;
this.availableTemplates = response.templates;
},
processFinished(data) {
this.getAvailableBackups();
this.backupLoading = false;
this.$refs.report.open(data);
},
},
};
</script>
<style></style>

View file

@ -26,6 +26,8 @@
</v-btn>
</template>
</TheUploadBtn>
<BackupDialog :color="color" />
<v-btn :loading="loading" class="mx-2" small :color="color" @click="createBackup">
<v-icon left> mdi-plus </v-icon> Create
</v-btn>
@ -36,7 +38,7 @@
<v-list-item @click.prevent="openDialog(item)">
<v-list-item-avatar>
<v-icon large dark :color="color">
mdi-backup-restore
mdi-database
</v-icon>
</v-list-item-avatar>
@ -65,13 +67,14 @@
import TheUploadBtn from "@/components/UI/Buttons/TheUploadBtn";
import ImportSummaryDialog from "@/components/ImportSummaryDialog";
import { api } from "@/api";
import StatCard from "./StatCard";
import ImportDialog from "../Backup/ImportDialog";
import StatCard from "@/components/UI/StatCard";
import BackupDialog from "@/components/UI/Dialogs/BackupDialog";
import ImportDialog from "@/components/UI/Dialogs/ImportDialog";
export default {
components: { StatCard, ImportDialog, TheUploadBtn, ImportSummaryDialog },
components: { StatCard, ImportDialog, TheUploadBtn, ImportSummaryDialog, BackupDialog },
data() {
return {
color: "secondary",
color: "accent",
selectedName: "",
selectedDate: "",
loading: false,
@ -91,7 +94,6 @@ export default {
async getAvailableBackups() {
const response = await api.backups.requestAvailable();
this.availableBackups = response.imports;
console.log(this.availableBackups);
},
async deleteBackup(name) {
@ -106,6 +108,7 @@ export default {
this.selectedName = backup.name;
this.$refs.import_dialog.open();
},
async importBackup(data) {
this.loading = true;
const response = await api.backups.import(data.name, data);

View file

@ -49,12 +49,12 @@
<script>
import { api } from "@/api";
import StatCard from "./StatCard";
import StatCard from "@/components/UI/StatCard";
export default {
components: { StatCard },
data() {
return {
color: "secondary",
color: "accent",
total: 0,
events: [],
icons: {

View file

@ -82,7 +82,7 @@
<script>
import { api } from "@/api";
import StatCard from "./StatCard";
import StatCard from "@/components/UI/StatCard";
import EventViewer from "./EventViewer";
import BackupViewer from "./BackupViewer";
export default {

View file

@ -0,0 +1,215 @@
<template>
<div>
<StatCard icon="mdi-format-color-fill" :color="color">
<template v-slot:after-heading>
<div class="ml-auto text-right">
<div class="body-3 grey--text font-weight-light" v-text="$t('general.themes')" />
<h3 class="display-2 font-weight-light text--primary">
<small> {{ selectedTheme.name }} </small>
</h3>
</div>
</template>
<template v-slot:actions>
<v-btn-toggle v-model="darkMode" color="primary " mandatory>
<v-btn small value="system">
<v-icon>mdi-desktop-tower-monitor</v-icon>
<span class="ml-1" v-show="$vuetify.breakpoint.smAndUp">
{{ $t("settings.theme.default-to-system") }}
</span>
</v-btn>
<v-btn small value="light">
<v-icon>mdi-white-balance-sunny</v-icon>
<span class="ml-1" v-show="$vuetify.breakpoint.smAndUp">
{{ $t("settings.theme.light") }}
</span>
</v-btn>
<v-btn small value="dark">
<v-icon>mdi-weather-night</v-icon>
<span class="ml-1" v-show="$vuetify.breakpoint.smAndUp">
{{ $t("settings.theme.dark") }}
</span>
</v-btn>
</v-btn-toggle>
</template>
<template v-slot:bottom>
<v-virtual-scroll height="290" item-height="70" :items="availableThemes" class="mt-2">
<template v-slot:default="{ item }">
<v-list-item @click="selectedTheme = item">
<v-list-item-avatar>
<v-icon large dark :color="item.colors.primary">
mdi-format-color-fill
</v-icon>
</v-list-item-avatar>
<v-list-item-content>
<v-list-item-title v-text="item.name"></v-list-item-title>
<v-row flex align-center class="mt-2 justify-space-around px-4 pb-2">
<v-sheet
class="rounded flex mx-1"
v-for="(item, index) in item.colors"
:key="index"
:color="item"
height="20"
>
</v-sheet>
</v-row>
</v-list-item-content>
<v-list-item-action class="ml-auto">
<v-btn large icon @click.stop="editTheme(item)">
<v-icon color="accent">mdi-square-edit-outline</v-icon>
</v-btn>
</v-list-item-action>
</v-list-item>
</template>
</v-virtual-scroll>
<v-divider></v-divider>
<v-card-actions>
<v-spacer class="mx-2"></v-spacer>
<v-btn class="my-1 mb-n1" :color="color" @click="createTheme">
<v-icon left> mdi-plus </v-icon> {{ $t("general.create") }}
</v-btn>
</v-card-actions>
</template>
</StatCard>
<BaseDialog
:loading="loading"
:title="modalLabel.title"
title-icon="mdi-format-color-fill"
modal-width="700"
ref="themeDialog"
:submit-text="modalLabel.button"
@submit="processSubmit"
@delete="deleteTheme"
>
<v-card-text class="mt-3">
<v-text-field
:label="$t('settings.theme.theme-name')"
v-model="defaultData.name"
:rules="[rules.required]"
></v-text-field>
<v-row dense dflex wrap justify-content-center v-if="defaultData.colors">
<v-col cols="12" sm="6" v-for="(_, key) in defaultData.colors" :key="key">
<ColorPickerDialog :button-text="labels[key]" v-model="defaultData.colors[key]" />
</v-col>
</v-row>
</v-card-text>
</BaseDialog>
</div>
</template>
<script>
import { api } from "@/api";
import ColorPickerDialog from "@/components/FormHelpers/ColorPickerDialog";
import BaseDialog from "@/components/UI/Dialogs/BaseDialog";
import StatCard from "@/components/UI/StatCard";
export default {
components: { StatCard, BaseDialog, ColorPickerDialog },
data() {
return {
availableThemes: [],
color: "accent",
newTheme: false,
loading: false,
defaultData: {
name: "",
colors: {
primary: "#E58325",
accent: "#00457A",
secondary: "#973542",
success: "#43A047",
info: "#4990BA",
warning: "#FF4081",
error: "#EF5350",
},
},
rules: {
required: val => !!val || this.$t("settings.theme.theme-name-is-required"),
},
};
},
computed: {
labels() {
return {
primary: this.$t("settings.theme.primary"),
secondary: this.$t("settings.theme.secondary"),
accent: this.$t("settings.theme.accent"),
success: this.$t("settings.theme.success"),
info: this.$t("settings.theme.info"),
warning: this.$t("settings.theme.warning"),
error: this.$t("settings.theme.error"),
};
},
modalLabel() {
if (this.newTheme) {
return {
title: this.$t("settings.add-a-new-theme"),
button: this.$t("general.create"),
};
} else {
return {
title: "Update Theme",
button: this.$t("general.update"),
};
}
},
selectedTheme: {
set(val) {
this.$store.commit("setTheme", val);
},
get() {
return this.$store.getters.getActiveTheme;
},
},
darkMode: {
set(val) {
this.$store.commit("setDarkMode", val);
},
get() {
return this.$store.getters.getDarkMode;
},
},
},
async mounted() {
await this.getAllThemes();
},
methods: {
async getAllThemes() {
this.availableThemes = await api.themes.requestAll();
},
editTheme(theme) {
console.log(theme);
this.defaultData = theme;
this.newTheme = false;
this.$refs.themeDialog.open();
},
createTheme() {
this.newTheme = true;
this.$refs.themeDialog.open();
console.log("Create Theme");
},
async processSubmit() {
if (this.newTheme) {
console.log("New Theme");
await api.themes.create(this.defaultData);
} else {
await api.themes.update(this.defaultData);
}
this.getAllThemes();
},
async deleteTheme() {
console.log(this.defaultData);
await api.themes.delete(this.defaultData.id);
this.getAllThemes();
},
},
};
</script>
<style lang="scss" scoped>
</style>

View file

@ -0,0 +1,190 @@
<template>
<StatCard icon="mdi-account">
<template v-slot:avatar>
<v-avatar color="accent" size="120" class="white--text headline mt-n16">
<img :src="userProfileImage" v-if="!hideImage" @error="hideImage = true" />
<div v-else>
{{ initials }}
</div>
</v-avatar>
</template>
<template v-slot:after-heading>
<div class="ml-auto text-right">
<div class="body-3 grey--text font-weight-light" v-text="$t('user.user-id-with-value', { id: user.id })" />
<h3 class="display-2 font-weight-light text--primary">
<small> {{ $t("group.group") }}: {{ user.group }} </small>
</h3>
</div>
</template>
<template v-slot:actions>
<BaseDialog
:title="$t('user.reset-password')"
title-icon="mdi-lock"
:submit-text="$t('settings.change-password')"
@submit="changePassword"
:loading="loading"
:top="true"
>
<template v-slot:open="{ open }">
<v-btn color="primary" class="mr-1" small @click="open">
<v-icon left>mdi-lock</v-icon>
Change Password
</v-btn>
</template>
<v-card-text>
<v-form ref="passChange">
<v-text-field
v-model="password.current"
prepend-icon="mdi-lock"
:label="$t('user.current-password')"
:rules="[existsRule]"
validate-on-blur
:type="showPassword ? 'text' : 'password'"
@click:append="showPassword.current = !showPassword.current"
></v-text-field>
<v-text-field
v-model="password.newOne"
prepend-icon="mdi-lock"
:label="$t('user.new-password')"
:rules="[minRule]"
:type="showPassword ? 'text' : 'password'"
@click:append="showPassword.newOne = !showPassword.newOne"
></v-text-field>
<v-text-field
v-model="password.newTwo"
prepend-icon="mdi-lock"
:label="$t('user.confirm-password')"
:rules="[password.newOne === password.newTwo || $t('user.password-must-match')]"
validate-on-blur
:type="showPassword ? 'text' : 'password'"
@click:append="showPassword.newTwo = !showPassword.newTwo"
></v-text-field>
</v-form>
</v-card-text>
</BaseDialog>
</template>
<template v-slot:bottom>
<v-card-text>
<v-form>
<v-text-field
:label="$t('user.full-name')"
required
v-model="user.fullName"
:rules="[existsRule]"
validate-on-blur
>
</v-text-field>
<v-text-field :label="$t('user.email')" :rules="[emailRule]" validate-on-blur required v-model="user.email">
</v-text-field>
</v-form>
</v-card-text>
<v-divider></v-divider>
<v-card-actions class="pb-1 pt-3">
<TheUploadBtn
icon="mdi-image-area"
:text="$t('user.upload-photo')"
:url="userProfileImage"
file-name="profile_image"
/>
<v-spacer></v-spacer>
<v-btn color="success" @click="updateUser">
<v-icon left> mdi-content-save </v-icon>
{{ $t("general.update") }}
</v-btn>
</v-card-actions>
</template>
</StatCard>
</template>
<script>
import BaseDialog from "@/components/UI/Dialogs/BaseDialog";
import StatCard from "@/components/UI/StatCard";
import TheUploadBtn from "@/components/UI/Buttons/TheUploadBtn";
import { api } from "@/api";
import { validators } from "@/mixins/validators";
import { initials } from "@/mixins/initials";
export default {
components: {
BaseDialog,
TheUploadBtn,
StatCard,
},
mixins: [validators, initials],
data() {
return {
hideImage: false,
passwordLoading: false,
password: {
current: "",
newOne: "",
newTwo: "",
},
showPassword: false,
loading: false,
user: {
fullName: "",
email: "",
group: "",
admin: false,
id: 0,
},
};
},
computed: {
userProfileImage() {
this.resetImage();
return `api/users/${this.user.id}/image`;
},
},
async mounted() {
this.refreshProfile();
},
methods: {
resetImage() {
this.hideImage = false;
},
async refreshProfile() {
this.user = await api.users.self();
},
openAvatarPicker() {
this.showAvatarPicker = true;
},
selectAvatar(avatar) {
this.user.avatar = avatar;
},
async updateUser() {
this.loading = true;
const response = await api.users.update(this.user);
if (response) {
this.$store.commit("setToken", response.data.access_token);
this.refreshProfile();
this.loading = false;
this.$store.dispatch("requestUserData");
}
},
async changePassword() {
this.paswordLoading = true;
let data = {
currentPassword: this.password.current,
newPassword: this.password.newOne,
};
if (this.$refs.passChange.validate()) {
if (await api.users.changePassword(this.user.id, data)) {
this.$emit("refresh");
}
}
this.paswordLoading = false;
},
},
};
</script>
<style></style>

View file

@ -1,206 +1,27 @@
<template>
<v-row dense>
<v-col cols="12" md="8" sm="12">
<v-card>
<v-card-title class="headline">
<span>
<v-progress-circular v-if="loading" indeterminate color="primary" large class="mr-2"> </v-progress-circular>
</span>
{{ $t("settings.profile") }}
<v-spacer></v-spacer>
{{ $t("user.user-id-with-value", { id: user.id }) }}
</v-card-title>
<v-divider></v-divider>
<v-card-text>
<v-row>
<v-col cols="12" md="3" align="center" justify="center">
<v-avatar color="accent" size="120" class="white--text headline mr-2">
<img :src="userProfileImage" v-if="!hideImage" @error="hideImage = true" />
<div v-else>
{{ initials }}
</div>
</v-avatar>
</v-col>
<v-col cols="12" md="9">
<v-form>
<v-text-field
:label="$t('user.full-name')"
required
v-model="user.fullName"
:rules="[existsRule]"
validate-on-blur
>
</v-text-field>
<v-text-field
:label="$t('user.email')"
:rules="[emailRule]"
validate-on-blur
required
v-model="user.email"
>
</v-text-field>
<v-text-field
:label="$t('group.group')"
readonly
v-model="user.group"
persistent-hint
:hint="$t('group.groups-can-only-be-set-by-administrators')"
>
</v-text-field>
</v-form>
</v-col>
</v-row>
</v-card-text>
<v-card-actions>
<TheUploadBtn
icon="mdi-image-area"
:text="$t('user.upload-photo')"
:url="userProfileImage"
file-name="profile_image"
/>
<v-spacer></v-spacer>
<v-btn color="success" class="mr-2" @click="updateUser">
<v-icon left> mdi-content-save </v-icon>
{{ $t("general.save") }}
</v-btn>
</v-card-actions>
</v-card>
</v-col>
<v-col cols="12" md="4" sm="12">
<v-card height="100%">
<v-card-title class="headline">
{{ $t("user.reset-password") }}
<v-spacer></v-spacer>
</v-card-title>
<v-divider></v-divider>
<v-card-text>
<v-form ref="passChange">
<v-text-field
v-model="password.current"
prepend-icon="mdi-lock"
:label="$t('user.current-password')"
:rules="[existsRule]"
validate-on-blur
:type="showPassword ? 'text' : 'password'"
@click:append="showPassword.current = !showPassword.current"
></v-text-field>
<v-text-field
v-model="password.newOne"
prepend-icon="mdi-lock"
:label="$t('user.new-password')"
:rules="[minRule]"
:type="showPassword ? 'text' : 'password'"
@click:append="showPassword.newOne = !showPassword.newOne"
></v-text-field>
<v-text-field
v-model="password.newTwo"
prepend-icon="mdi-lock"
:label="$t('user.confirm-password')"
:rules="[password.newOne === password.newTwo || $t('user.password-must-match')]"
validate-on-blur
:type="showPassword ? 'text' : 'password'"
@click:append="showPassword.newTwo = !showPassword.newTwo"
></v-text-field>
</v-form>
</v-card-text>
<v-card-actions>
<v-btn icon @click="showPassword = !showPassword" :loading="passwordLoading">
<v-icon v-if="!showPassword">mdi-eye-off</v-icon>
<v-icon v-else> mdi-eye </v-icon>
</v-btn>
<v-spacer></v-spacer>
<v-btn color="accent" class="mr-2" @click="changePassword">
<v-icon left> mdi-lock </v-icon>
{{ $t("settings.change-password") }}
</v-btn>
</v-card-actions>
</v-card>
</v-col>
</v-row>
<div class="mt-10">
<v-row>
<v-col cols="12" sm="12" lg="6">
<UserCard />
</v-col>
<v-col cols="12" sm="12" lg="6"> </v-col>
</v-row>
<v-row class="mt-7">
<v-col cols="12" sm="12" lg="6">
<ThemeCard />
</v-col>
<v-col cols="12" sm="12" lg="6"> </v-col>
</v-row>
</div>
</template>
<script>
// import AvatarPicker from '@/components/AvatarPicker'
import TheUploadBtn from "@/components/UI/Buttons/TheUploadBtn";
import { api } from "@/api";
import { validators } from "@/mixins/validators";
import { initials } from "@/mixins/initials";
import ThemeCard from "./ThemeCard";
import UserCard from "./UserCard";
export default {
components: {
TheUploadBtn,
},
mixins: [validators, initials],
data() {
return {
hideImage: false,
passwordLoading: false,
password: {
current: "",
newOne: "",
newTwo: "",
},
showPassword: false,
loading: false,
user: {
fullName: "",
email: "",
group: "",
admin: false,
id: 0,
},
};
},
computed: {
userProfileImage() {
this.resetImage();
return `api/users/${this.user.id}/image`;
},
},
async mounted() {
this.refreshProfile();
},
methods: {
resetImage() {
this.hideImage = false;
},
async refreshProfile() {
this.user = await api.users.self();
},
openAvatarPicker() {
this.showAvatarPicker = true;
},
selectAvatar(avatar) {
this.user.avatar = avatar;
},
async updateUser() {
this.loading = true;
const response = await api.users.update(this.user);
if (response) {
this.$store.commit("setToken", response.data.access_token);
this.refreshProfile();
this.loading = false;
this.$store.dispatch("requestUserData");
}
},
async changePassword() {
this.paswordLoading = true;
let data = {
currentPassword: this.password.current,
newPassword: this.password.newOne,
};
if (this.$refs.passChange.validate()) {
if (await api.users.changePassword(this.user.id, data)) {
this.$emit("refresh");
}
}
this.paswordLoading = false;
},
UserCard,
ThemeCard,
},
};
</script>

View file

@ -1,89 +0,0 @@
<template>
<div>
<v-btn text color="info" @click="dialog = true">
{{ $t("settings.add-a-new-theme") }}
</v-btn>
<v-dialog v-model="dialog" width="500">
<v-card>
<v-app-bar dense dark color="primary mb-2">
<v-icon large left class="mt-1">
mdi-format-color-fill
</v-icon>
<v-toolbar-title class="headline">
{{ $t("settings.add-a-new-theme") }}
</v-toolbar-title>
<v-spacer></v-spacer>
</v-app-bar>
<v-card-title> </v-card-title>
<v-form @submit.prevent="select">
<v-card-text>
<v-text-field
:label="$t('settings.theme.theme-name')"
v-model="themeName"
:rules="[rules.required]"
></v-text-field>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="grey" text @click="dialog = false">
{{ $t("general.cancel") }}
</v-btn>
<v-btn color="success" text type="submit" :disabled="!themeName">
{{ $t("general.create") }}
</v-btn>
</v-card-actions>
</v-form>
</v-card>
</v-dialog>
</div>
</template>
<script>
export default {
props: {
buttonText: String,
value: String,
},
data() {
return {
dialog: false,
themeName: "",
rules: {
required: val => !!val || this.$t("settings.theme.theme-name-is-required"),
},
};
},
watch: {
color() {
this.updateColor();
},
},
methods: {
randomColor() {
return "#" + Math.floor(Math.random() * 16777215).toString(16);
},
select() {
const newTheme = {
name: this.themeName,
colors: {
primary: "#E58325",
accent: "#00457A",
secondary: "#973542",
success: "#5AB1BB",
info: "#4990BA",
warning: "#FF4081",
error: "#EF5350",
},
};
this.$emit("new-theme", newTheme);
this.dialog = false;
},
},
};
</script>
<style></style>

View file

@ -1,88 +0,0 @@
<template>
<div>
<ConfirmationDialog
:title="$t('settings.theme.delete-theme')"
:message="$t('settings.theme.are-you-sure-you-want-to-delete-this-theme')"
color="error"
icon="mdi-alert-circle"
ref="deleteThemeConfirm"
v-on:confirm="deleteSelectedTheme()"
/>
<v-card flat outlined class="ma-2">
<v-card-text class="mb-n5 mt-n2">
<h3>
{{ theme.name }}
{{ current ? $t("general.current-parenthesis") : "" }}
</h3>
</v-card-text>
<v-card-text>
<v-row flex align-center>
<v-card
v-for="(color, index) in theme.colors"
:key="index"
class="ma-1 mx-auto"
height="34"
width="36"
:color="color"
>
</v-card>
</v-row>
</v-card-text>
<v-divider></v-divider>
<v-card-actions>
<v-btn text color="error" @click="confirmDelete">
{{ $t("general.delete") }}
</v-btn>
<v-spacer></v-spacer>
<!-- <v-btn text color="accent" @click="editTheme">Edit</v-btn> -->
<v-btn text color="success" @click="saveThemes">{{ $t("general.apply") }}</v-btn>
</v-card-actions>
</v-card>
</div>
</template>
<script>
import ConfirmationDialog from "@/components/UI/Dialogs/ConfirmationDialog";
import { api } from "@/api";
const DELETE_EVENT = "delete";
const APPLY_EVENT = "apply";
const EDIT_EVENT = "edit";
export default {
components: {
ConfirmationDialog,
},
props: {
theme: Object,
current: {
default: false,
},
},
methods: {
confirmDelete() {
if (this.theme.name === "default") {
// Notify User Can't Delete Default
} else if (this.theme !== {}) {
this.$refs.deleteThemeConfirm.open();
}
},
async deleteSelectedTheme() {
//Delete Theme from DB
if (await api.themes.delete(this.theme.name)) {
//Get the new list of available from DB
this.availableThemes = await api.themes.requestAll();
this.$emit(DELETE_EVENT);
}
},
async saveThemes() {
this.$store.commit("setTheme", this.theme);
this.$emit(APPLY_EVENT, this.theme);
},
editTheme() {
this.$emit(EDIT_EVENT);
},
},
};
</script>
<style></style>

View file

@ -1,155 +0,0 @@
<template>
<v-card>
<v-card-title class="headline">
{{ $t("settings.theme.theme-settings") }}
</v-card-title>
<v-divider></v-divider>
<v-card-text>
<h2 class="mt-4 mb-1">{{ $t("settings.theme.dark-mode") }}</h2>
<p>
{{
$t(
"settings.theme.choose-how-mealie-looks-to-you-set-your-theme-preference-to-follow-your-system-settings-or-choose-to-use-the-light-or-dark-theme"
)
}}
</p>
<v-row dense align="center">
<v-col cols="6">
<v-btn-toggle v-model="selectedDarkMode" color="primary " mandatory @change="setStoresDarkMode">
<v-btn value="system">
<v-icon>mdi-desktop-tower-monitor</v-icon>
<span class="ml-1" v-show="$vuetify.breakpoint.smAndUp">
{{ $t("settings.theme.default-to-system") }}
</span>
</v-btn>
<v-btn value="light">
<v-icon>mdi-white-balance-sunny</v-icon>
<span class="ml-1" v-show="$vuetify.breakpoint.smAndUp">
{{ $t("settings.theme.light") }}
</span>
</v-btn>
<v-btn value="dark">
<v-icon>mdi-weather-night</v-icon>
<span class="ml-1" v-show="$vuetify.breakpoint.smAndUp">
{{ $t("settings.theme.dark") }}
</span>
</v-btn>
</v-btn-toggle>
</v-col>
</v-row></v-card-text
>
<v-divider></v-divider>
<v-card-text>
<h2 class="mt-1 mb-1">{{ $t("settings.theme.theme") }}</h2>
<p>
{{
$t(
"settings.theme.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-content="center" v-if="selectedTheme.colors">
<v-col>
<ColorPickerDialog :button-text="$t('settings.theme.primary')" v-model="selectedTheme.colors.primary" />
</v-col>
<v-col>
<ColorPickerDialog :button-text="$t('settings.theme.secondary')" v-model="selectedTheme.colors.secondary" />
</v-col>
<v-col>
<ColorPickerDialog :button-text="$t('settings.theme.accent')" v-model="selectedTheme.colors.accent" />
</v-col>
<v-col>
<ColorPickerDialog :button-text="$t('settings.theme.success')" v-model="selectedTheme.colors.success" />
</v-col>
<v-col>
<ColorPickerDialog :button-text="$t('settings.theme.info')" v-model="selectedTheme.colors.info" />
</v-col>
<v-col>
<ColorPickerDialog :button-text="$t('settings.theme.warning')" v-model="selectedTheme.colors.warning" />
</v-col>
<v-col>
<ColorPickerDialog :button-text="$t('settings.theme.error')" v-model="selectedTheme.colors.error" />
</v-col>
</v-row>
</v-card-text>
<v-card-text>
<v-row>
<v-col cols="12" sm="12" md="6" lg="4" xl="3" v-for="theme in availableThemes" :key="theme.name">
<ThemeCard :theme="theme" :current="selectedTheme.name == theme.name ? true : false" @delete="getAllThemes" />
</v-col>
</v-row>
</v-card-text>
<v-card-actions>
<NewThemeDialog @new-theme="appendTheme" class="mt-1" />
<v-spacer></v-spacer>
<v-btn color="success" @click="saveThemes" class="mr-2">
<v-icon left> mdi-content-save </v-icon>
{{ $t("general.save") }}
</v-btn>
</v-card-actions>
</v-card>
</template>
<script>
import { api } from "@/api";
import ColorPickerDialog from "@/components/FormHelpers/ColorPickerDialog";
import NewThemeDialog from "./NewThemeDialog";
import ThemeCard from "./ThemeCard";
export default {
components: {
ColorPickerDialog,
NewThemeDialog,
ThemeCard,
},
data() {
return {
selectedDarkMode: "system",
availableThemes: [],
};
},
async mounted() {
await this.getAllThemes();
this.selectedDarkMode = this.$store.getters.getDarkMode;
},
computed: {
selectedTheme() {
return this.$store.getters.getActiveTheme;
},
},
methods: {
async getAllThemes() {
this.availableThemes = await api.themes.requestAll();
},
/**
* Create the new Theme and select it.
*/
async appendTheme(NewThemeDialog) {
const response = await api.themes.create(NewThemeDialog);
if (response) {
this.availableThemes.push(NewThemeDialog);
this.$store.commit("setTheme", NewThemeDialog);
}
},
setStoresDarkMode() {
this.$store.commit("setDarkMode", this.selectedDarkMode);
},
/**
* This will save the current colors and make the selected theme live.
*/
saveThemes() {
api.themes.update(this.selectedTheme.name, this.selectedTheme.colors);
},
},
};
</script>
<style></style>

View file

@ -1,6 +1,7 @@
<template>
<v-container>
<CardSection
title-icon=""
v-if="siteSettings.showRecent"
:title="$t('page.recent')"
:recipes="recentRecipes"

View file

@ -2,6 +2,7 @@
<v-container>
<v-progress-linear v-if="loading" indeterminate color="primary"></v-progress-linear>
<CardSection
title-icon=""
:sortable="true"
:title="$t('page.all-recipes')"
:recipes="allRecipes"

View file

@ -1,6 +1,4 @@
import Admin from "@/pages/Admin";
import Backup from "@/pages/Admin/Backup";
import Theme from "@/pages/Admin/Theme";
import MealPlanner from "@/pages/Admin/MealPlanner";
import Migration from "@/pages/Admin/Migration";
import Profile from "@/pages/Admin/Profile";
@ -31,21 +29,6 @@ export const adminRoutes = {
title: "settings.profile",
},
},
{
path: "backups",
component: Backup,
meta: {
title: "settings.backup-and-exports",
},
},
{
path: "themes",
component: Theme,
meta: {
title: "general.themes",
},
},
{
path: "meal-planner",
component: MealPlanner,

View file

@ -22,6 +22,7 @@ export const routes = [
];
const router = new VueRouter({
base: process.env.BASE_URL,
routes,
mode: process.env.NODE_ENV === "production" ? "history" : "hash",
scrollBehavior() {

View file

@ -60,7 +60,7 @@ const actions = {
},
async resetTheme({ commit }) {
const defaultTheme = await api.themes.requestByName("default");
const defaultTheme = await api.themes.requestByName(1);
if (defaultTheme.colors) {
Vuetify.framework.theme.themes.dark = defaultTheme.colors;
Vuetify.framework.theme.themes.light = defaultTheme.colors;

View file

@ -1,18 +1,19 @@
import uvicorn
from fastapi import FastAPI
from mealie.core import root_logger
from mealie.core.config import APP_VERSION, settings
from mealie.core.root_logger import get_logger
from mealie.routes import backup_routes, debug_routes, migration_routes, theme_routes, utility_routes
from mealie.routes.about import about_router
from mealie.routes.groups import groups
from mealie.routes.mealplans import mealplans
from mealie.routes.recipe import router as recipe_router
from mealie.routes.site_settings import all_settings
from mealie.routes.users import users
from mealie.routes.groups import groups_router
from mealie.routes.mealplans import meal_plan_router
from mealie.routes.media import media_router
from mealie.routes.recipe import recipe_router
from mealie.routes.site_settings import settings_router
from mealie.routes.users import user_router
from mealie.services.events import create_general_event
logger = root_logger.get_logger()
logger = get_logger()
app = FastAPI(
title="Mealie",
@ -29,15 +30,16 @@ def start_scheduler():
def api_routers():
# Authentication
app.include_router(users.router)
app.include_router(groups.router)
app.include_router(user_router)
app.include_router(groups_router)
# Recipes
app.include_router(recipe_router)
app.include_router(media_router)
app.include_router(about_router)
# Meal Routes
app.include_router(mealplans.router)
app.include_router(meal_plan_router)
# Settings Routes
app.include_router(all_settings.router)
app.include_router(settings_router)
app.include_router(theme_routes.router)
# Backups/Imports Routes
app.include_router(backup_routes.router)

View file

@ -93,7 +93,7 @@ class _Settings(BaseDocument):
class _Themes(BaseDocument):
def __init__(self) -> None:
self.primary_key = "name"
self.primary_key = "id"
self.sql_model = SiteThemeModel
self.schema = SiteTheme

View file

@ -1,23 +1,21 @@
import sqlalchemy as sa
import sqlalchemy.orm as orm
from mealie.db.models.model_base import SqlAlchemyBase
from mealie.db.models.model_base import BaseMixins, SqlAlchemyBase
from sqlalchemy.sql.sqltypes import Integer
class SiteThemeModel(SqlAlchemyBase):
class SiteThemeModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "site_theme"
name = sa.Column(sa.String, primary_key=True)
id = sa.Column(Integer, primary_key=True, unique=True)
name = sa.Column(sa.String, nullable=False, unique=True)
colors = orm.relationship("ThemeColorsModel", uselist=False, cascade="all, delete")
def __init__(self, name: str, colors: dict, session=None) -> None:
def __init__(self, name: str, colors: dict, *arg, **kwargs) -> None:
self.name = name
self.colors = ThemeColorsModel(**colors)
def update(self, session=None, name: str = None, colors: dict = None) -> dict:
self.colors.update(**colors)
return self
class ThemeColorsModel(SqlAlchemyBase):
class ThemeColorsModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "theme_colors"
id = sa.Column(sa.Integer, primary_key=True)
parent_id = sa.Column(sa.String, sa.ForeignKey("site_theme.name"))
@ -28,21 +26,3 @@ class ThemeColorsModel(SqlAlchemyBase):
info = sa.Column(sa.String)
warning = sa.Column(sa.String)
error = sa.Column(sa.String)
def update(
self,
primary: str = None,
accent: str = None,
secondary: str = None,
success: str = None,
info: str = None,
warning: str = None,
error: str = None,
) -> None:
self.primary = primary
self.accent = accent
self.secondary = secondary
self.success = success
self.info = info
self.warning = warning
self.error = error

View file

@ -1,7 +1,7 @@
from fastapi import APIRouter
from .events import router as events_router
from . import events
about_router = APIRouter(prefix="/api/about")
about_router.include_router(events_router)
about_router.include_router(events.router)

View file

@ -0,0 +1,8 @@
from fastapi import APIRouter
from . import crud, groups
groups_router = APIRouter()
groups_router.include_router(crud.router)
groups_router.include_router(groups.router)

View file

@ -0,0 +1,9 @@
from fastapi import APIRouter
from . import crud, helpers, mealplans
meal_plan_router = APIRouter()
meal_plan_router.include_router(crud.router)
meal_plan_router.include_router(helpers.router)
meal_plan_router.include_router(mealplans.router)

View file

@ -0,0 +1,7 @@
from fastapi import APIRouter
from . import recipe
media_router = APIRouter(prefix="/api/media", tags=["Site Media"])
media_router.include_router(recipe.router)

View file

@ -0,0 +1,41 @@
from enum import Enum
from fastapi import APIRouter, HTTPException, status
from mealie.schema.recipe import Recipe
from starlette.responses import FileResponse
"""
These routes are for development only! These assets are served by Caddy when not
in development mode. If you make changes, be sure to test the production container.
"""
router = APIRouter(prefix="/recipes")
class ImageType(str, Enum):
original = "original.webp"
small = "min-original.webp"
tiny = "tiny-original.webp"
@router.get("/{recipe_slug}/images/{file_name}")
async def get_recipe_img(recipe_slug: str, file_name: ImageType = ImageType.original):
"""Takes in a recipe slug, returns the static image. This route is proxied in the docker image
and should not hit the API in production"""
recipe_image = Recipe(slug=recipe_slug).image_dir.joinpath(file_name.value)
if recipe_image:
return FileResponse(recipe_image)
else:
raise HTTPException(status.HTTP_404_NOT_FOUND)
@router.get("/{recipe_slug}/assets/{file_name}")
async def get_recipe_asset(recipe_slug: str, file_name: str):
""" Returns a recipe asset """
file = Recipe(slug=recipe_slug).asset_dir.joinpath(file_name)
try:
return FileResponse(file)
except Exception:
raise HTTPException(status.HTTP_404_NOT_FOUND)

View file

View file

@ -1,10 +1,9 @@
from fastapi import APIRouter
from mealie.routes.recipe import all_recipe_routes, category_routes, recipe_crud_routes, recipe_media, tag_routes
from mealie.routes.recipe import all_recipe_routes, category_routes, recipe_crud_routes, tag_routes
router = APIRouter()
recipe_router = APIRouter()
router.include_router(all_recipe_routes.router)
router.include_router(recipe_crud_routes.router)
router.include_router(recipe_media.router)
router.include_router(category_routes.router)
router.include_router(tag_routes.router)
recipe_router.include_router(all_recipe_routes.router)
recipe_router.include_router(recipe_crud_routes.router)
recipe_router.include_router(category_routes.router)
recipe_router.include_router(tag_routes.router)

View file

@ -1,13 +1,17 @@
from shutil import copyfileobj
from fastapi import APIRouter, Depends, File, Form, HTTPException, status
from fastapi.datastructures import UploadFile
from mealie.core.root_logger import get_logger
from mealie.db.database import db
from mealie.db.db_setup import generate_session
from mealie.routes.deps import get_current_user
from mealie.schema.recipe import Recipe, RecipeURLIn
from mealie.schema.recipe import Recipe, RecipeAsset, RecipeURLIn
from mealie.services.events import create_recipe_event
from mealie.services.image.image import scrape_image, write_image
from mealie.services.recipe.media import check_assets, delete_assets
from mealie.services.scraper.scraper import create_from_url
from slugify import slugify
from sqlalchemy.orm.session import Session
router = APIRouter(prefix="/api/recipes", tags=["Recipe CRUD"])
@ -126,3 +130,30 @@ def scrape_image_url(
""" Removes an existing image and replaces it with the incoming file. """
scrape_image(url.url, recipe_slug)
@router.post("/{recipe_slug}/assets", response_model=RecipeAsset)
def upload_recipe_asset(
recipe_slug: str,
name: str = Form(...),
icon: str = Form(...),
extension: str = Form(...),
file: UploadFile = File(...),
session: Session = Depends(generate_session),
current_user=Depends(get_current_user),
):
""" Upload a file to store as a recipe asset """
file_name = slugify(name) + "." + extension
asset_in = RecipeAsset(name=name, icon=icon, file_name=file_name)
dest = Recipe(slug=recipe_slug).asset_dir.joinpath(file_name)
with dest.open("wb") as buffer:
copyfileobj(file.file, buffer)
if not dest.is_file():
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR)
recipe: Recipe = db.recipes.get(session, recipe_slug)
recipe.assets.append(asset_in)
db.recipes.update(session, recipe_slug, recipe.dict())
return asset_in

View file

@ -1,70 +0,0 @@
import shutil
from enum import Enum
from fastapi import APIRouter, Depends, File, Form, HTTPException, status
from fastapi.datastructures import UploadFile
from mealie.db.database import db
from mealie.db.db_setup import generate_session
from mealie.routes.deps import get_current_user
from mealie.schema.recipe import Recipe, RecipeAsset
from slugify import slugify
from sqlalchemy.orm.session import Session
from starlette.responses import FileResponse
router = APIRouter(prefix="/api/recipes/media", tags=["Recipe Media"])
class ImageType(str, Enum):
original = "original.webp"
small = "min-original.webp"
tiny = "tiny-original.webp"
@router.get("/{recipe_slug}/images/{file_name}")
async def get_recipe_img(recipe_slug: str, file_name: ImageType = ImageType.original):
"""Takes in a recipe slug, returns the static image. This route is proxied in the docker image
and should not hit the API in production"""
recipe_image = Recipe(slug=recipe_slug).image_dir.joinpath(file_name.value)
if recipe_image:
return FileResponse(recipe_image)
else:
raise HTTPException(status.HTTP_404_NOT_FOUND)
@router.get("/{recipe_slug}/assets/{file_name}")
async def get_recipe_asset(recipe_slug: str, file_name: str):
""" Returns a recipe asset """
file = Recipe(slug=recipe_slug).asset_dir.joinpath(file_name)
try:
return FileResponse(file)
except Exception:
raise HTTPException(status.HTTP_404_NOT_FOUND)
@router.post("/{recipe_slug}/assets", response_model=RecipeAsset)
def upload_recipe_asset(
recipe_slug: str,
name: str = Form(...),
icon: str = Form(...),
extension: str = Form(...),
file: UploadFile = File(...),
session: Session = Depends(generate_session),
current_user=Depends(get_current_user),
):
""" Upload a file to store as a recipe asset """
file_name = slugify(name) + "." + extension
asset_in = RecipeAsset(name=name, icon=icon, file_name=file_name)
dest = Recipe(slug=recipe_slug).asset_dir.joinpath(file_name)
with dest.open("wb") as buffer:
shutil.copyfileobj(file.file, buffer)
if not dest.is_file():
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR)
recipe: Recipe = db.recipes.get(session, recipe_slug)
recipe.assets.append(asset_in)
db.recipes.update(session, recipe_slug, recipe.dict())
return asset_in

View file

@ -0,0 +1,9 @@
from fastapi import APIRouter
from . import all_settings, custom_pages, site_settings
settings_router = APIRouter()
settings_router.include_router(all_settings.router)
settings_router.include_router(custom_pages.router)
settings_router.include_router(site_settings.router)

View file

@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, status, HTTPException
from fastapi import APIRouter, Depends, HTTPException, status
from mealie.db.database import db
from mealie.db.db_setup import generate_session
from mealie.routes.deps import get_current_user
@ -21,27 +21,27 @@ def create_theme(data: SiteTheme, session: Session = Depends(generate_session),
db.themes.create(session, data.dict())
@router.get("/themes/{theme_name}")
def get_single_theme(theme_name: str, session: Session = Depends(generate_session)):
@router.get("/themes/{id}")
def get_single_theme(id: int, session: Session = Depends(generate_session)):
""" Returns a named theme """
return db.themes.get(session, theme_name)
return db.themes.get(session, id)
@router.put("/themes/{theme_name}", status_code=status.HTTP_200_OK)
@router.put("/themes/{id}", status_code=status.HTTP_200_OK)
def update_theme(
theme_name: str,
id: int,
data: SiteTheme,
session: Session = Depends(generate_session),
current_user=Depends(get_current_user),
):
""" Update a theme database entry """
db.themes.update(session, theme_name, data.dict())
db.themes.update(session, id, data.dict())
@router.delete("/themes/{theme_name}", status_code=status.HTTP_200_OK)
def delete_theme(theme_name: str, session: Session = Depends(generate_session), current_user=Depends(get_current_user)):
@router.delete("/themes/{id}", status_code=status.HTTP_200_OK)
def delete_theme(id: int, session: Session = Depends(generate_session), current_user=Depends(get_current_user)):
""" Deletes theme from the database """
try:
db.themes.delete(session, theme_name)
db.themes.delete(session, id)
except Exception:
raise HTTPException(status.HTTP_400_BAD_REQUEST)

View file

@ -0,0 +1,9 @@
from fastapi import APIRouter
from . import auth, crud, sign_up
user_router = APIRouter()
user_router.include_router(auth.router)
user_router.include_router(sign_up.router)
user_router.include_router(crud.router)

View file

@ -1,9 +0,0 @@
from fastapi import APIRouter
from mealie.routes.users import auth, crud, sign_up
router = APIRouter()
router.include_router(sign_up.router)
router.include_router(auth.router)
router.include_router(sign_up.router)
router.include_router(crud.router)

View file

@ -1,3 +1,5 @@
from typing import Optional
from pydantic import BaseModel
@ -5,7 +7,7 @@ class Colors(BaseModel):
primary: str = "#E58325"
accent: str = "#00457A"
secondary: str = "#973542"
success: str = "#4CAF50"
success: str = "#43A047"
info: str = "#4990BA"
warning: str = "#FF4081"
error: str = "#EF5350"
@ -15,6 +17,7 @@ class Colors(BaseModel):
class SiteTheme(BaseModel):
id: Optional[int]
name: str = "default"
colors: Colors = Colors()

View file

@ -14,18 +14,19 @@ def default_settings():
@pytest.fixture(scope="session")
def default_theme():
return SiteTheme().dict()
return SiteTheme(id=1).dict()
@pytest.fixture(scope="session")
def new_theme():
return {
"id": 2,
"name": "myTestTheme",
"colors": {
"primary": "#E58325",
"accent": "#00457A",
"secondary": "#973542",
"success": "#5AB1BB",
"success": "#43A047",
"info": "#4990BA",
"warning": "#FF4081",
"error": "#EF5350",
@ -54,7 +55,7 @@ def test_update_settings(api_client: TestClient, api_routes: AppRoutes, default_
def test_default_theme(api_client: TestClient, api_routes: AppRoutes, default_theme):
response = api_client.get(api_routes.themes_theme_name("default"))
response = api_client.get(api_routes.themes_theme_name(1))
assert response.status_code == 200
assert json.loads(response.content) == default_theme
@ -64,7 +65,7 @@ def test_create_theme(api_client: TestClient, api_routes: AppRoutes, new_theme,
response = api_client.post(api_routes.themes_create, json=new_theme, headers=token)
assert response.status_code == 201
response = api_client.get(api_routes.themes_theme_name(new_theme.get("name")), headers=token)
response = api_client.get(api_routes.themes_theme_name(new_theme.get("id")), headers=token)
assert response.status_code == 200
assert json.loads(response.content) == new_theme
@ -77,7 +78,7 @@ def test_read_all_themes(api_client: TestClient, api_routes: AppRoutes, default_
def test_read_theme(api_client: TestClient, api_routes: AppRoutes, default_theme, new_theme):
for theme in [default_theme, new_theme]:
response = api_client.get(api_routes.themes_theme_name(theme.get("name")))
response = api_client.get(api_routes.themes_theme_name(theme.get("id")))
assert response.status_code == 200
assert json.loads(response.content) == theme
@ -94,14 +95,14 @@ def test_update_theme(api_client: TestClient, api_routes: AppRoutes, token, defa
}
new_theme["colors"] = theme_colors
response = api_client.put(api_routes.themes_theme_name(new_theme.get("name")), json=new_theme, headers=token)
response = api_client.put(api_routes.themes_theme_name(new_theme.get("id")), json=new_theme, headers=token)
assert response.status_code == 200
response = api_client.get(api_routes.themes_theme_name(new_theme.get("name")))
response = api_client.get(api_routes.themes_theme_name(new_theme.get("id")))
assert json.loads(response.content) == new_theme
def test_delete_theme(api_client: TestClient, api_routes: AppRoutes, default_theme, new_theme, token):
for theme in [default_theme, new_theme]:
response = api_client.delete(api_routes.themes_theme_name(theme.get("name")), headers=token)
response = api_client.delete(api_routes.themes_theme_name(theme.get("id")), headers=token)
assert response.status_code == 200