feat(frontend): 🚧 CRUD Functionality

This commit is contained in:
hay-kot 2021-08-02 22:15:11 -08:00
parent 00a8fdda41
commit afcad2f701
49 changed files with 845 additions and 275 deletions

View File

@ -44,13 +44,13 @@
target="_blank" target="_blank"
rel="noreferrer nofollow" rel="noreferrer nofollow"
> >
{{ $t('new-recipe.google-ld-json-info') }} {{ $t("new-recipe.google-ld-json-info") }}
</a> </a>
<a href="https://github.com/hay-kot/mealie/issues" target="_blank" rel="noreferrer nofollow"> <a href="https://github.com/hay-kot/mealie/issues" target="_blank" rel="noreferrer nofollow">
{{ $t('new-recipe.github-issues') }} {{ $t("new-recipe.github-issues") }}
</a> </a>
<a href="https://schema.org/Recipe" target="_blank" rel="noreferrer nofollow"> <a href="https://schema.org/Recipe" target="_blank" rel="noreferrer nofollow">
{{ $t('new-recipe.recipe-markup-specification') }} {{ $t("new-recipe.recipe-markup-specification") }}
</a> </a>
</div> </div>
<div class="d-flex justify-end"> <div class="d-flex justify-end">
@ -61,7 +61,7 @@
@click="addRecipe = false" @click="addRecipe = false"
> >
<v-icon left> {{ $globals.icons.externalLink }} </v-icon> <v-icon left> {{ $globals.icons.externalLink }} </v-icon>
{{ $t('new-recipe.view-scraped-data') }} {{ $t("new-recipe.view-scraped-data") }}
</v-btn> </v-btn>
</div> </div>
</v-alert> </v-alert>
@ -101,7 +101,7 @@
</v-card-text> </v-card-text>
<v-card-actions> <v-card-actions>
<TheUploadBtn class="mx-auto" :text-btn="false" @uploaded="setFile" :post="false"> </TheUploadBtn> <AppButtonUpload class="mx-auto" :text-btn="false" @uploaded="setFile" :post="false"> </AppButtonUpload>
</v-card-actions> </v-card-actions>
</BaseDialog> </BaseDialog>
<v-speed-dial v-model="fab" :open-on-hover="absolute" :fixed="absolute" :bottom="absolute" :right="absolute"> <v-speed-dial v-model="fab" :open-on-hover="absolute" :fixed="absolute" :bottom="absolute" :right="absolute">
@ -140,11 +140,11 @@
<script> <script>
import { api } from "@/api"; import { api } from "@/api";
import TheUploadBtn from "@/components/UI/Buttons/TheUploadBtn.vue"; import AppButtonUpload from "@/components/UI/Buttons/AppButtonUpload.vue";
import BaseDialog from "@/components/UI/Dialogs/BaseDialog.vue"; import BaseDialog from "@/components/UI/Dialogs/BaseDialog.vue";
export default { export default {
components: { components: {
TheUploadBtn, AppButtonUpload,
BaseDialog, BaseDialog,
}, },
props: { props: {
@ -232,8 +232,9 @@ export default {
this.processing = false; this.processing = false;
}, },
isValidWebUrl(url) { isValidWebUrl(url) {
let regEx = /^https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,256}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)$/gm; let regEx =
return regEx.test(url) ? true : this.$t('new-recipe.must-be-a-valid-url'); /^https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,256}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)$/gm;
return regEx.test(url) ? true : this.$t("new-recipe.must-be-a-valid-url");
}, },
}, },
}; };

View File

@ -29,13 +29,13 @@
</div> </div>
</template> </template>
<div class="d-flex row py-3 justify-end"> <div class="d-flex row py-3 justify-end">
<TheUploadBtn url="/api/backups/upload" @uploaded="getAvailableBackups"> <AppButtonUpload url="/api/backups/upload" @uploaded="getAvailableBackups">
<template v-slot="{ isSelecting, onButtonClick }"> <template v-slot="{ isSelecting, onButtonClick }">
<v-btn :loading="isSelecting" class="mx-2" small color="info" @click="onButtonClick"> <v-btn :loading="isSelecting" class="mx-2" small color="info" @click="onButtonClick">
<v-icon left> {{ $globals.icons.upload }} </v-icon> {{ $t("general.upload") }} <v-icon left> {{ $globals.icons.upload }} </v-icon> {{ $t("general.upload") }}
</v-btn> </v-btn>
</template> </template>
</TheUploadBtn> </AppButtonUpload>
<BackupDialog :color="color" /> <BackupDialog :color="color" />
<v-btn :loading="loading" class="mx-2" small color="success" @click="createBackup"> <v-btn :loading="loading" class="mx-2" small color="success" @click="createBackup">
@ -74,7 +74,7 @@
</template> </template>
<script> <script>
import TheUploadBtn from "@/components/UI/Buttons/TheUploadBtn"; import AppButtonUpload from "@/components/UI/Buttons/AppButtonUpload";
import ImportSummaryDialog from "@/components/ImportSummaryDialog"; import ImportSummaryDialog from "@/components/ImportSummaryDialog";
import ConfirmationDialog from "@/components/UI/Dialogs/ConfirmationDialog"; import ConfirmationDialog from "@/components/UI/Dialogs/ConfirmationDialog";
import { api } from "@/api"; import { api } from "@/api";
@ -85,7 +85,7 @@ const IMPORT_EVENT = "import";
const DELETE_EVENT = "delete"; const DELETE_EVENT = "delete";
export default { export default {
components: { StatCard, ImportDialog, TheUploadBtn, ImportSummaryDialog, BackupDialog, ConfirmationDialog }, components: { StatCard, ImportDialog, AppButtonUpload, ImportSummaryDialog, BackupDialog, ConfirmationDialog },
data() { data() {
return { return {
color: "accent", color: "accent",

View File

@ -70,7 +70,7 @@
<v-data-table :headers="headers" :items="links" sort-by="calories"> <v-data-table :headers="headers" :items="links" sort-by="calories">
<template v-slot:item.token="{ item }"> <template v-slot:item.token="{ item }">
{{ `${baseURL}/sign-up/${item.token}` }} {{ `${baseURL}/sign-up/${item.token}` }}
<TheCopyButton :copy-text="`${baseURL}/sign-up/${item.token}`" /> <AppCopyButton :copy-text="`${baseURL}/sign-up/${item.token}`" />
</template> </template>
<template v-slot:item.admin="{ item }"> <template v-slot:item.admin="{ item }">
<v-btn small :color="item.admin ? 'success' : 'error'" text> <v-btn small :color="item.admin ? 'success' : 'error'" text>
@ -94,12 +94,12 @@
</template> </template>
<script> <script>
import TheCopyButton from "@/components/UI/Buttons/TheCopyButton"; import AppCopyButton from "@/components/UI/Buttons/AppCopyButton";
import ConfirmationDialog from "@/components/UI/Dialogs/ConfirmationDialog"; import ConfirmationDialog from "@/components/UI/Dialogs/ConfirmationDialog";
import { api } from "@/api"; import { api } from "@/api";
import { validators } from "@/mixins/validators"; import { validators } from "@/mixins/validators";
export default { export default {
components: { ConfirmationDialog, TheCopyButton }, components: { ConfirmationDialog, AppCopyButton },
mixins: [validators], mixins: [validators],
data() { data() {
return { return {

View File

@ -5,7 +5,7 @@
{{ title }} {{ title }}
<v-spacer></v-spacer> <v-spacer></v-spacer>
<span> <span>
<TheUploadBtn <AppButtonUpload
class="mt-1" class="mt-1"
:url="`/api/migrations/${folder}/upload`" :url="`/api/migrations/${folder}/upload`"
fileName="archive" fileName="archive"
@ -55,7 +55,7 @@
</template> </template>
<script> <script>
import TheUploadBtn from "@/components/UI/Buttons/TheUploadBtn"; import AppButtonUpload from "@/components/UI/Buttons/AppButtonUpload";
import { api } from "@/api"; import { api } from "@/api";
import MigrationDialog from "./MigrationDialog"; import MigrationDialog from "./MigrationDialog";
export default { export default {
@ -66,7 +66,7 @@ export default {
available: Array, available: Array,
}, },
components: { components: {
TheUploadBtn, AppButtonUpload,
MigrationDialog, MigrationDialog,
}, },
data() { data() {

View File

@ -90,7 +90,7 @@
</v-card-text> </v-card-text>
<v-divider></v-divider> <v-divider></v-divider>
<v-card-actions class="pb-1 pt-3"> <v-card-actions class="pb-1 pt-3">
<TheUploadBtn <AppButtonUpload
:icon="$globals.icons.fileImage" :icon="$globals.icons.fileImage"
:text="$t('user.upload-photo')" :text="$t('user.upload-photo')"
:url="userProfileImage" :url="userProfileImage"
@ -106,14 +106,14 @@
<script> <script>
import BaseDialog from "@/components/UI/Dialogs/BaseDialog"; import BaseDialog from "@/components/UI/Dialogs/BaseDialog";
import StatCard from "@/components/UI/StatCard"; import StatCard from "@/components/UI/StatCard";
import TheUploadBtn from "@/components/UI/Buttons/TheUploadBtn"; import AppButtonUpload from "@/components/UI/Buttons/AppButtonUpload";
import { api } from "@/api"; import { api } from "@/api";
import { validators } from "@/mixins/validators"; import { validators } from "@/mixins/validators";
import { initials } from "@/mixins/initials"; import { initials } from "@/mixins/initials";
export default { export default {
components: { components: {
BaseDialog, BaseDialog,
TheUploadBtn, AppButtonUpload,
StatCard, StatCard,
}, },
mixins: [validators, initials], mixins: [validators, initials],

View File

@ -38,9 +38,9 @@
{{ $t("shopping-list.shopping-list") }} {{ $t("shopping-list.shopping-list") }}
</v-btn> </v-btn>
<v-spacer></v-spacer> <v-spacer></v-spacer>
<TheCopyButton color="info" :copy-text="mealPlanURL(mealplan.uid)"> <AppCopyButton color="info" :copy-text="mealPlanURL(mealplan.uid)">
{{ $t("general.link-copied") }} {{ $t("general.link-copied") }}
</TheCopyButton> </AppCopyButton>
</v-card-actions> </v-card-actions>
<v-list class="mt-0 pt-0"> <v-list class="mt-0 pt-0">
@ -90,12 +90,12 @@ import { api } from "@/api";
import { utils } from "@/utils"; import { utils } from "@/utils";
import NewMeal from "@/components/MealPlan/MealPlanNew"; import NewMeal from "@/components/MealPlan/MealPlanNew";
import EditPlan from "@/components/MealPlan/MealPlanEditor"; import EditPlan from "@/components/MealPlan/MealPlanEditor";
import TheCopyButton from "@/components/UI/Buttons/TheCopyButton"; import AppCopyButton from "@/components/UI/Buttons/AppCopyButton";
export default { export default {
components: { components: {
NewMeal, NewMeal,
EditPlan, EditPlan,
TheCopyButton, AppCopyButton,
}, },
data: () => ({ data: () => ({
plannedMeals: [], plannedMeals: [],
@ -120,7 +120,7 @@ export default {
}, },
editPlan(id) { editPlan(id) {
this.plannedMeals.forEach(element => { this.plannedMeals.forEach((element) => {
if (element.uid === id) { if (element.uid === id) {
this.editMealPlan = element; this.editMealPlan = element;
} }

View File

@ -51,7 +51,7 @@
<v-card v-else-if="activeList"> <v-card v-else-if="activeList">
<v-card-title class="headline"> <v-card-title class="headline">
<TheCopyButton v-if="!edit" :copy-text="listAsText" color="info" /> <AppCopyButton v-if="!edit" :copy-text="listAsText" color="info" />
<v-text-field label="Name" single-line dense v-if="edit" v-model="activeList.name"> </v-text-field> <v-text-field label="Name" single-line dense v-if="edit" v-model="activeList.name"> </v-text-field>
<div v-else> <div v-else>
{{ activeList.name }} {{ activeList.name }}
@ -141,14 +141,14 @@
<script> <script>
import BaseDialog from "@/components/UI/Dialogs/BaseDialog"; import BaseDialog from "@/components/UI/Dialogs/BaseDialog";
import SearchDialog from "@/components/UI/Dialogs/SearchDialog"; import SearchDialog from "@/components/UI/Dialogs/SearchDialog";
import TheCopyButton from "@/components/UI/Buttons/TheCopyButton"; import AppCopyButton from "@/components/UI/Buttons/AppCopyButton";
import VueMarkdown from "@adapttive/vue-markdown"; import VueMarkdown from "@adapttive/vue-markdown";
import { api } from "@/api"; import { api } from "@/api";
export default { export default {
components: { components: {
BaseDialog, BaseDialog,
SearchDialog, SearchDialog,
TheCopyButton, AppCopyButton,
VueMarkdown, VueMarkdown,
}, },
data() { data() {
@ -176,7 +176,7 @@ export default {
}, },
}, },
listAsText() { listAsText() {
const formatList = this.activeList.items.map(x => { const formatList = this.activeList.items.map((x) => {
return `${x.quantity} - ${x.text}`; return `${x.quantity} - ${x.text}`;
}); });
@ -206,7 +206,7 @@ export default {
const recipe = response.data; const recipe = response.data;
const ingredients = recipe.recipeIngredient.map(x => ({ const ingredients = recipe.recipeIngredient.map((x) => ({
title: "", title: "",
text: x.note, text: x.note,
quantity: 1, quantity: 1,
@ -217,14 +217,14 @@ export default {
this.consolidateList(); this.consolidateList();
}, },
consolidateList() { consolidateList() {
const allText = this.activeList.items.map(x => x.text); const allText = this.activeList.items.map((x) => x.text);
const uniqueText = allText.filter((item, index) => { const uniqueText = allText.filter((item, index) => {
return allText.indexOf(item) === index; return allText.indexOf(item) === index;
}); });
const newItems = uniqueText.map(x => { const newItems = uniqueText.map((x) => {
let matchingItems = this.activeList.items.filter(y => y.text === x); let matchingItems = this.activeList.items.filter((y) => y.text === x);
matchingItems[0].quantity = this.sumQuantiy(matchingItems); matchingItems[0].quantity = this.sumQuantiy(matchingItems);
return matchingItems[0]; return matchingItems[0];
}); });
@ -233,7 +233,7 @@ export default {
}, },
sumQuantiy(itemList) { sumQuantiy(itemList) {
let quantity = 0; let quantity = 0;
itemList.forEach(element => { itemList.forEach((element) => {
quantity += element.quantity; quantity += element.quantity;
}); });
return quantity; return quantity;
@ -241,7 +241,7 @@ export default {
setActiveList() { setActiveList() {
if (!this.list) return null; if (!this.list) return null;
if (!this.group.shoppingLists) return null; if (!this.group.shoppingLists) return null;
this.activeList = this.group.shoppingLists.find(x => x.id == this.list); this.activeList = this.group.shoppingLists.find((x) => x.id == this.list);
}, },
async createNewList() { async createNewList() {
this.newList.group = this.group.name; this.newList.group = this.group.name;

View File

@ -1,10 +1,49 @@
import { ApiRequestInstance } from "~/types/api"; import { ApiRequestInstance } from "~/types/api";
export class BaseAPIClass { export interface CrudAPIInterface {
requests: ApiRequestInstance requests: ApiRequestInstance;
// Route Properties / Methods
baseRoute: string;
itemRoute(itemId: string): string;
// Methods
}
export abstract class BaseAPIClass<T> implements CrudAPIInterface {
requests: ApiRequestInstance;
abstract baseRoute: string;
abstract itemRoute(itemId: string): string;
constructor(requests: ApiRequestInstance) { constructor(requests: ApiRequestInstance) {
this.requests = requests; this.requests = requests;
} }
async getAll(start = 0, limit = 9999) {
return await this.requests.get<T[]>(this.baseRoute, {
params: { start, limit },
});
} }
async getOne(itemId: string) {
return await this.requests.get<T>(this.itemRoute(itemId));
}
async createOne(payload: T) {
return await this.requests.post(this.baseRoute, payload);
}
async updateOne(itemId: string, payload: T){
return await this.requests.put<T>(this.itemRoute(itemId), payload);
}
async patchOne(itemId: string, payload: T) {
return await this.requests.patch(this.itemRoute(itemId), payload);
}
async deleteOne(itemId: string) {
return await this.requests.delete<T>(this.itemRoute(itemId));
}
}

View File

@ -11,30 +11,35 @@ const routes = {
recipesCreateUrl: `${prefix}/recipes/create-url`, recipesCreateUrl: `${prefix}/recipes/create-url`,
recipesCreateFromZip: `${prefix}/recipes/create-from-zip`, recipesCreateFromZip: `${prefix}/recipes/create-from-zip`,
recipesCategory: `${prefix}/recipes/category`,
recipesRecipeSlug: (recipe_slug: string) => `${prefix}/recipes/${recipe_slug}`, recipesRecipeSlug: (recipe_slug: string) => `${prefix}/recipes/${recipe_slug}`,
recipesRecipeSlugZip: (recipe_slug: string) => `${prefix}/recipes/${recipe_slug}/zip`, recipesRecipeSlugZip: (recipe_slug: string) => `${prefix}/recipes/${recipe_slug}/zip`,
recipesRecipeSlugImage: (recipe_slug: string) => `${prefix}/recipes/${recipe_slug}/image`, recipesRecipeSlugImage: (recipe_slug: string) => `${prefix}/recipes/${recipe_slug}/image`,
recipesRecipeSlugAssets: (recipe_slug: string) => `${prefix}/recipes/${recipe_slug}/assets`, recipesRecipeSlugAssets: (recipe_slug: string) => `${prefix}/recipes/${recipe_slug}/assets`,
}; };
class RecipeAPI extends BaseAPIClass { class RecipeAPI extends BaseAPIClass<Recipe> {
async getAll(start = 0, limit = 9999) { baseRoute: string = routes.recipesSummary;
return await this.requests.get<Recipe[]>(routes.recipesSummary, { itemRoute = (itemid: string) => routes.recipesRecipeSlug(itemid);
params: { start, limit },
async getAllByCategory(categories: string[]) {
return await this.requests.get<Recipe[]>(routes.recipesCategory, {
categories
}); });
} }
async getOne(slug: string) { // @ts-ignore - Override method doesn't take same arguments are parent class
return await this.requests.get<Recipe>(routes.recipesRecipeSlug(slug));
}
async createOne(name: string) { async createOne(name: string) {
return await this.requests.post(routes.recipesBase, { name }); return await this.requests.post(routes.recipesBase, { name });
} }
async createOneByUrl(url: string) {
return await this.requests.post(routes.recipesCreateUrl, { url });
}
// * Methods to Generate reference urls for assets/images *
recipeImage(recipeSlug: string, version = null, key = null) { recipeImage(recipeSlug: string, version = null, key = null) {
return `/api/media/recipes/${recipeSlug}/images/original.webp?&rnd=${key}&version=${version}`; return `/api/media/recipes/${recipeSlug}/images/original.webp?&rnd=${key}&version=${version}`;
} }

View File

@ -15,7 +15,9 @@
:icon="$globals.icons.alertCircle" :icon="$globals.icons.alertCircle"
@confirm="emitDelete()" @confirm="emitDelete()"
> >
<v-card-text>
{{ $t("recipe.delete-confirmation") }} {{ $t("recipe.delete-confirmation") }}
</v-card-text>
</BaseDialog> </BaseDialog>
<v-spacer></v-spacer> <v-spacer></v-spacer>

View File

@ -26,7 +26,7 @@
<v-btn color="error" icon top @click="deleteAsset(i)"> <v-btn color="error" icon top @click="deleteAsset(i)">
<v-icon>{{ $globals.icons.delete }}</v-icon> <v-icon>{{ $globals.icons.delete }}</v-icon>
</v-btn> </v-btn>
<TheCopyButton :copy-text="copyLink(item.fileName)" /> <AppCopyButton :copy-text="copyLink(item.fileName)" />
</div> </div>
</v-list-item-action> </v-list-item-action>
</v-list-item> </v-list-item>
@ -34,7 +34,7 @@
</v-card> </v-card>
<div class="d-flex ml-auto mt-2"> <div class="d-flex ml-auto mt-2">
<v-spacer></v-spacer> <v-spacer></v-spacer>
<BaseDialog :title="$t('asset.new-asset')" :title-icon="getIconDefinition(newAsset.icon).icon" @submit="addAsset"> <BaseDialog :title="$t('asset.new-asset')" :icon="getIconDefinition(newAsset.icon).icon" @submit="addAsset">
<template #open="{ open }"> <template #open="{ open }">
<v-btn v-if="edit" color="secondary" dark @click="open"> <v-btn v-if="edit" color="secondary" dark @click="open">
<v-icon>{{ $globals.icons.create }}</v-icon> <v-icon>{{ $globals.icons.create }}</v-icon>
@ -61,7 +61,7 @@
{{ item.title }} {{ item.title }}
</template> </template>
</v-select> </v-select>
<TheUploadBtn :post="false" file-name="file" :text-btn="false" @uploaded="setFileObject" /> <AppButtonUpload :post="false" file-name="file" :text-btn="false" @uploaded="setFileObject" />
</div> </div>
{{ fileObject.name }} {{ fileObject.name }}
</v-card-text> </v-card-text>
@ -71,26 +71,27 @@
</template> </template>
<script> <script>
import TheCopyButton from "@/components/UI/Buttons/TheCopyButton"; import { useApiSingleton } from "~/composables/use-api";
import TheUploadBtn from "@/components/UI/Buttons/TheUploadBtn";
import BaseDialog from "@/components/UI/Dialogs/BaseDialog";
import { api } from "@/api";
export default { export default {
components: {
BaseDialog,
TheUploadBtn,
TheCopyButton,
},
props: { props: {
slug: String, slug: {
type: String,
required: true,
},
value: { value: {
type: Array, type: Array,
required: true,
}, },
edit: { edit: {
type: Boolean, type: Boolean,
default: true, default: true,
}, },
}, },
setup() {
const api = useApiSingleton();
return { api };
},
data() { data() {
return { return {
fileObject: {}, fileObject: {},
@ -109,27 +110,27 @@ export default {
{ {
name: "mdi-file", name: "mdi-file",
title: this.$i18n.t("asset.file"), title: this.$i18n.t("asset.file"),
icon: this.$globals.icons.file icon: this.$globals.icons.file,
}, },
{ {
name: "mdi-file-pdf-box", name: "mdi-file-pdf-box",
title: this.$i18n.t("asset.pdf"), title: this.$i18n.t("asset.pdf"),
icon: this.$globals.icons.filePDF icon: this.$globals.icons.filePDF,
}, },
{ {
name: "mdi-file-image", name: "mdi-file-image",
title: this.$i18n.t("asset.image"), title: this.$i18n.t("asset.image"),
icon: this.$globals.icons.fileImage icon: this.$globals.icons.fileImage,
}, },
{ {
name: "mdi-code-json", name: "mdi-code-json",
title: this.$i18n.t("asset.code"), title: this.$i18n.t("asset.code"),
icon: this.$globals.icons.codeJson icon: this.$globals.icons.codeJson,
}, },
{ {
name: "mdi-silverware-fork-knife", name: "mdi-silverware-fork-knife",
title: this.$i18n.t("asset.recipe"), title: this.$i18n.t("asset.recipe"),
icon: this.$globals.icons.primary icon: this.$globals.icons.primary,
}, },
]; ];
}, },

View File

@ -1,4 +1,5 @@
<template> <template>
<v-lazy>
<v-hover v-slot="{ hover }" :open-delay="50"> <v-hover v-slot="{ hover }" :open-delay="50">
<v-card <v-card
:class="{ 'on-hover': hover }" :class="{ 'on-hover': hover }"
@ -11,7 +12,7 @@
<v-expand-transition v-if="description"> <v-expand-transition v-if="description">
<div v-if="hover" class="d-flex transition-fast-in-fast-out secondary v-card--reveal" style="height: 100%"> <div v-if="hover" class="d-flex transition-fast-in-fast-out secondary v-card--reveal" style="height: 100%">
<v-card-text class="v-card--text-show white--text"> <v-card-text class="v-card--text-show white--text">
{{ description | truncate(300) }} {{ description }}
</v-card-text> </v-card-text>
</div> </div>
</v-expand-transition> </v-expand-transition>
@ -31,10 +32,10 @@
</v-card-actions> </v-card-actions>
</v-card> </v-card>
</v-hover> </v-hover>
</v-lazy>
</template> </template>
<script> <script>
import { api } from "@/api";
import RecipeFavoriteBadge from "./RecipeFavoriteBadge"; import RecipeFavoriteBadge from "./RecipeFavoriteBadge";
import RecipeChips from "./RecipeChips"; import RecipeChips from "./RecipeChips";
import RecipeContextMenu from "./RecipeContextMenu"; import RecipeContextMenu from "./RecipeContextMenu";
@ -82,11 +83,6 @@ export default {
return this.$store.getters.getIsLoggedIn; return this.$store.getters.getIsLoggedIn;
}, },
}, },
methods: {
getImage(slug) {
return api.recipes.recipeSmallImage(slug, this.image);
},
},
}; };
</script> </script>

View File

@ -18,7 +18,7 @@
</template> </template>
<script> <script>
import { useApi } from "~/composables/use-api"; import { useApiSingleton } from "~/composables/use-api";
export default { export default {
props: { props: {
tiny: { tiny: {
@ -51,7 +51,7 @@ export default {
}, },
}, },
setup() { setup() {
const api = useApi(); const api = useApiSingleton();
return { api }; return { api };
}, },

View File

@ -1,4 +1,5 @@
<template> <template>
<v-lazy>
<v-expand-transition> <v-expand-transition>
<v-card <v-card
:ripple="false" :ripple="false"
@ -40,13 +41,15 @@
</v-list-item> </v-list-item>
</v-card> </v-card>
</v-expand-transition> </v-expand-transition>
</v-lazy>
</template> </template>
<script> <script>
import { api } from "@/api"; import { defineComponent } from "@nuxtjs/composition-api";
import RecipeFavoriteBadge from "./RecipeFavoriteBadge"; import RecipeFavoriteBadge from "./RecipeFavoriteBadge";
import RecipeContextMenu from "./RecipeContextMenu"; import RecipeContextMenu from "./RecipeContextMenu";
export default { import { useApiSingleton } from "~/composables/use-api";
export default defineComponent({
components: { components: {
RecipeFavoriteBadge, RecipeFavoriteBadge,
RecipeContextMenu, RecipeContextMenu,
@ -81,6 +84,11 @@ export default {
default: true, default: true,
}, },
}, },
setup() {
const api = useApiSingleton();
return { api };
},
data() { data() {
return { return {
fallBackImage: false, fallBackImage: false,
@ -93,10 +101,10 @@ export default {
}, },
methods: { methods: {
getImage(slug) { getImage(slug) {
return api.recipes.recipeSmallImage(slug, this.image); return this.api.recipes.recipeSmallImage(slug, this.image);
}, },
}, },
}; });
</script> </script>
<style> <style>

View File

@ -117,7 +117,7 @@ export default {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
titleIcon: { icon: {
type: String, type: String,
default: null, default: null,
}, },
@ -172,7 +172,7 @@ export default {
return Math.min(this.hardLimit, this.recipes.length); return Math.min(this.hardLimit, this.recipes.length);
}, },
displayTitleIcon() { displayTitleIcon() {
return this.titleIcon || this.$globals.icons.tags; return this.icon || this.$globals.icons.tags;
}, },
}, },
watch: { watch: {
@ -223,7 +223,6 @@ export default {
console.log("Unknown Event", sortType); console.log("Unknown Event", sortType);
return; return;
} }
this.$emit(SORT_EVENT, sortTarget); this.$emit(SORT_EVENT, sortTarget);
this.sortLoading = false; this.sortLoading = false;
}, },

View File

@ -1,13 +1,16 @@
<template> <template>
<div class="text-center"> <div class="text-center">
<BaseDialog <BaseDialog
ref="deleteRecipieConfirm" ref="confirmDelete"
:title="$t('recipe.delete-recipe')" :title="$t('recipe.delete-recipe')"
:message="$t('recipe.delete-confirmation')"
color="error" color="error"
:icon="$globals.icons.alertCircle" :icon="$globals.icons.alertCircle"
@confirm="deleteRecipe()" @confirm="deleteRecipe()"
/> >
<v-card-text>
{{ $t("recipe.delete-confirmation") }}
</v-card-text>
</BaseDialog>
<v-menu <v-menu
offset-y offset-y
left left
@ -38,9 +41,10 @@
</template> </template>
<script> <script>
import { api } from "@/api";
import { utils } from "@/utils"; import { utils } from "@/utils";
export default { import { defineComponent, ref } from "@nuxtjs/composition-api";
import { useApiSingleton } from "~/composables/use-api";
export default defineComponent({
props: { props: {
menuTop: { menuTop: {
type: Boolean, type: Boolean,
@ -60,11 +64,14 @@ export default {
}, },
slug: { slug: {
type: String, type: String,
required: true,
}, },
menuIcon: { menuIcon: {
type: String,
default: null, default: null,
}, },
name: { name: {
required: true,
type: String, type: String,
}, },
cardMenu: { cardMenu: {
@ -72,6 +79,11 @@ export default {
default: true, default: true,
}, },
}, },
setup() {
const api = useApiSingleton();
const confirmDelete = ref(null);
return { api, confirmDelete };
},
data() { data() {
return { return {
loading: true, loading: true,
@ -82,7 +94,7 @@ export default {
return this.menuIcon ? this.menuIcon : this.$globals.icons.dotsVertical; return this.menuIcon ? this.menuIcon : this.$globals.icons.dotsVertical;
}, },
loggedIn() { loggedIn() {
return this.$store.getters.getIsLoggedIn; return this.$auth.loggedIn;
}, },
baseURL() { baseURL() {
return window.location.origin; return window.location.origin;
@ -145,12 +157,12 @@ export default {
}, },
}, },
methods: { methods: {
async menuAction(action) { menuAction(action) {
this.loading = true; this.loading = true;
switch (action) { switch (action) {
case "delete": case "delete":
this.$refs.deleteRecipieConfirm.open(); this.confirmDelete.open();
break; break;
case "share": case "share":
if (navigator.share) { if (navigator.share) {
@ -183,7 +195,8 @@ export default {
this.loading = false; this.loading = false;
}, },
async deleteRecipe() { async deleteRecipe() {
await api.recipes.delete(this.slug); console.log("Delete Called");
await this.api.recipes.deleteOne(this.slug);
}, },
updateClipboard() { updateClipboard() {
const copyText = this.recipeURL; const copyText = this.recipeURL;
@ -196,5 +209,5 @@ export default {
); );
}, },
}, },
}; });
</script> </script>

View File

@ -39,18 +39,18 @@ export default {
}, },
computed: { computed: {
user() { user() {
return this.$store.getters.getUserData; return this.$auth.user;
}, },
isFavorite() { isFavorite() {
return this.user.favoriteRecipes.includes(this.slug); return this.$auth.user.favoriteRecipes.includes(this.slug);
}, },
}, },
methods: { methods: {
async toggleFavorite() { async toggleFavorite() {
if (!this.isFavorite) { if (!this.isFavorite) {
await api.users.addFavorite(this.user.id, this.slug); await api.users.addFavorite(this.$auth.user.id, this.slug);
} else { } else {
await api.users.removeFavorite(this.user.id, this.slug); await api.users.removeFavorite(this.$auth.user.id, this.slug);
} }
this.$store.dispatch("requestUserData"); this.$store.dispatch("requestUserData");
}, },

View File

@ -14,7 +14,7 @@
<div> <div>
{{ $t("recipe.recipe-image") }} {{ $t("recipe.recipe-image") }}
</div> </div>
<TheUploadBtn <AppButtonUpload
class="ml-auto" class="ml-auto"
url="none" url="none"
file-name="image" file-name="image"
@ -40,14 +40,10 @@
</template> </template>
<script> <script>
import TheUploadBtn from "@/components/UI/Buttons/TheUploadBtn";
import { api } from "@/api"; import { api } from "@/api";
const REFRESH_EVENT = "refresh"; const REFRESH_EVENT = "refresh";
const UPLOAD_EVENT = "upload"; const UPLOAD_EVENT = "upload";
export default { export default {
components: {
TheUploadBtn,
},
props: { props: {
slug: String, slug: String,
}, },

View File

@ -49,7 +49,7 @@
</draggable> </draggable>
<div class="d-flex row justify-end"> <div class="d-flex row justify-end">
<BulkAdd class="mr-2" @bulk-data="addIngredient" /> <RecipeDialogBulkAdd class="mr-2" @bulk-data="addIngredient" />
<v-btn color="secondary" dark class="mr-4" @click="addIngredient"> <v-btn color="secondary" dark class="mr-4" @click="addIngredient">
<v-icon>{{ $globals.icons.create }}</v-icon> <v-icon>{{ $globals.icons.create }}</v-icon>
</v-btn> </v-btn>
@ -71,13 +71,13 @@
</template> </template>
<script> <script>
import BulkAdd from "@/components/Recipe/Parts/Helpers/BulkAdd";
import VueMarkdown from "@adapttive/vue-markdown"; import VueMarkdown from "@adapttive/vue-markdown";
import draggable from "vuedraggable"; import draggable from "vuedraggable";
import { utils } from "@/utils"; import { utils } from "@/utils";
import RecipeDialogBulkAdd from "./RecipeDialogBulkAdd";
export default { export default {
components: { components: {
BulkAdd, RecipeDialogBulkAdd,
draggable, draggable,
VueMarkdown, VueMarkdown,
}, },
@ -101,18 +101,18 @@ export default {
watch: { watch: {
value: { value: {
handler() { handler() {
this.showTitleEditor = this.value.map(x => this.validateTitle(x.title)); this.showTitleEditor = this.value.map((x) => this.validateTitle(x.title));
}, },
}, },
}, },
mounted() { mounted() {
this.checked = this.value.map(() => false); this.checked = this.value.map(() => false);
this.showTitleEditor = this.value.map(x => this.validateTitle(x.title)); this.showTitleEditor = this.value.map((x) => this.validateTitle(x.title));
}, },
methods: { methods: {
addIngredient(ingredients = null) { addIngredient(ingredients = null) {
if (ingredients.length) { if (ingredients.length) {
const newIngredients = ingredients.map(x => { const newIngredients = ingredients.map((x) => {
return { return {
title: null, title: null,
note: x, note: x,

View File

@ -61,8 +61,8 @@
</template> </template>
<script> <script>
import RecipeTimeCard from "@/components/Recipe/RecipeTimeCard.vue";
import VueMarkdown from "@adapttive/vue-markdown"; import VueMarkdown from "@adapttive/vue-markdown";
import RecipeTimeCard from "./RecipeTimeCard.vue";
export default { export default {
components: { components: {
RecipeTimeCard, RecipeTimeCard,

View File

@ -48,7 +48,7 @@ export default {
}, },
computed: { computed: {
loggedIn() { loggedIn() {
return this.$store.getters.getIsLoggedIn; return this.$auth.loggedIn;
}, },
}, },
mounted() { mounted() {

View File

@ -13,16 +13,25 @@
<script> <script>
export default { export default {
props: { props: {
prepTime: String, prepTime: {
totalTime: String, type: String,
performTime: String, default: null,
},
totalTime: {
type: String,
default: null,
},
performTime: {
type: String,
default: null,
},
}, },
computed: { computed: {
showCards() { showCards() {
return [this.prepTime, this.totalTime, this.performTime].some(x => !this.isEmpty(x)); return [this.prepTime, this.totalTime, this.performTime].some((x) => !this.isEmpty(x));
}, },
allTimes() { allTimes() {
return [this.validateTotalTime, this.validatePrepTime, this.validatePerformTime].filter(x => x !== null); return [this.validateTotalTime, this.validatePrepTime, this.validatePerformTime].filter((x) => x !== null);
}, },
validateTotalTime() { validateTotalTime() {
return !this.isEmpty(this.totalTime) ? { name: this.$t("recipe.total-time"), value: this.totalTime } : null; return !this.isEmpty(this.totalTime) ? { name: this.$t("recipe.total-time"), value: this.totalTime } : null;

View File

@ -7,9 +7,9 @@
:submit-text="$t('general.create')" :submit-text="$t('general.create')"
:loading="processing" :loading="processing"
width="600px" width="600px"
@submit="uploadZip" @submit="createOnByUrl"
> >
<v-form ref="urlForm" @submit.prevent="createRecipe"> <v-form ref="domImportFromUrlForm" @submit.prevent="createOnByUrl">
<v-card-text> <v-card-text>
<v-text-field <v-text-field
v-model="recipeURL" v-model="recipeURL"
@ -19,7 +19,7 @@
filled filled
rounded rounded
class="rounded-lg" class="rounded-lg"
:rules="[isValidWebUrl]" :rules="[validators.url]"
:hint="$t('new-recipe.url-form-hint')" :hint="$t('new-recipe.url-form-hint')"
persistent-hint persistent-hint
></v-text-field> ></v-text-field>
@ -84,7 +84,7 @@
</v-card-text> </v-card-text>
<v-card-actions> <v-card-actions>
<!-- <TheUploadBtn class="mx-auto" :text-btn="false" :post="false" @uploaded="setFile"> </TheUploadBtn> --> <!-- <AppButtonUpload class="mx-auto" :text-btn="false" :post="false" @uploaded="setFile"> </AppButtonUpload> -->
</v-card-actions> </v-card-actions>
</BaseDialog> </BaseDialog>
<BaseDialog <BaseDialog
@ -137,10 +137,11 @@
<script lang="ts"> <script lang="ts">
// import TheUploadBtn from "@/components/UI/Buttons/TheUploadBtn.vue"; // import AppButtonUpload from "@/components/UI/Buttons/AppButtonUpload.vue";
import { defineComponent, ref } from "@nuxtjs/composition-api"; import { defineComponent, ref } from "@nuxtjs/composition-api";
import { fieldTypes } from "~/composables/forms"; import { fieldTypes } from "~/composables/forms";
import { useApi } from "~/composables/use-api"; import { useApiSingleton } from "~/composables/use-api";
import { validators } from "~/composables/use-validators";
export default defineComponent({ export default defineComponent({
props: { props: {
@ -151,12 +152,26 @@ export default defineComponent({
}, },
setup() { setup() {
const domCreateDialog = ref(null); const domCreateDialog = ref(null);
const domCreateForm = ref<VForm | null>(null);
const domUploadZipDialog = ref(null); const domUploadZipDialog = ref(null);
const domUploadZipForm = ref<VForm | null>(null);
const domImportFromUrlDialog = ref(null); const domImportFromUrlDialog = ref(null);
const domImportFromUrlForm = ref<VForm | null>(null);
const api = useApi(); const api = useApiSingleton();
return { domCreateDialog, domUploadZipDialog, domImportFromUrlDialog, api }; return {
domCreateDialog,
domCreateForm,
domUploadZipDialog,
domUploadZipForm,
domImportFromUrlDialog,
domImportFromUrlForm,
api,
validators,
};
}, },
data() { data() {
return { return {
@ -204,43 +219,46 @@ export default defineComponent({
mounted() { mounted() {
if (this.$route.query.recipe_import_url) { if (this.$route.query.recipe_import_url) {
this.addRecipe = true; this.addRecipe = true;
this.createRecipe(); this.createOnByUrl();
} }
}, },
methods: { methods: {
async manualCreateRecipe() { reset() {
console.log(this.createRecipeData.form); this.fab = false;
await this.api.recipes.createOne(this.createRecipeData.form.name); this.error = false;
this.addRecipe = false;
this.recipeURL = "";
this.processing = false;
}, },
resetVars() { resetVars() {
this.uploadData = { this.uploadData = {
fileName: "archive", fileName: "archive",
file: null, file: null,
}; };
}, },
setFile(file) { setFile(file: any) {
this.uploadData.file = file; this.uploadData.file = file;
console.log("Uploaded"); console.log("Uploaded");
}, },
openZipUploader() {
this.resetVars();
this.$refs.uploadZipDialog.open();
},
async uploadZip() { async uploadZip() {
const formData = new FormData(); const formData = new FormData();
formData.append(this.uploadData.fileName, this.uploadData.file); formData.append(this.uploadData.fileName, this.uploadData.file);
const response = await api.utils.uploadFile("/api/recipes/create-from-zip", formData); const response = await this.api.utils.uploadFile("/api/recipes/create-from-zip", formData);
this.$router.push(`/recipe/${response.data.slug}`); this.$router.push(`/recipe/${response.data.slug}`);
}, },
async createRecipe() { async manualCreateRecipe() {
await this.api.recipes.createOne(this.createRecipeData.form.name);
},
async createOnByUrl() {
this.error = false; this.error = false;
if (this.$refs.urlForm === undefined || this.$refs.urlForm.validate()) { console.log(this.domImportFromUrlForm?.validate());
if (this.domImportFromUrlForm?.validate()) {
this.processing = true; this.processing = true;
const response = await api.recipes.createByURL(this.recipeURL); const response = await this.api.recipes.createOneByUrl(this.recipeURL);
this.processing = false; this.processing = false;
if (response) { if (response) {
this.addRecipe = false; this.addRecipe = false;
@ -251,18 +269,6 @@ export default defineComponent({
} }
} }
}, },
reset() {
this.fab = false;
this.error = false;
this.addRecipe = false;
this.recipeURL = "";
this.processing = false;
},
isValidWebUrl(url: string) {
const regEx =
/^https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,256}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)$/gm;
return regEx.test(url) ? true : this.$t("new-recipe.must-be-a-valid-url");
},
}, },
}); });
</script> </script>

View File

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

View File

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

View File

@ -1,6 +1,6 @@
<template> <template>
<v-btn <v-btn
:color="btnAttrs.color" :color="color || btnAttrs.color"
:small="small" :small="small"
:x-small="xSmall" :x-small="xSmall"
:loading="loading" :loading="loading"
@ -76,6 +76,10 @@ export default {
type: String, type: String,
default: null, default: null,
}, },
color: {
type: String,
default: null,
},
}, },
data() { data() {
return { return {

View File

@ -32,7 +32,7 @@
<v-spacer></v-spacer> <v-spacer></v-spacer>
<BaseButton v-if="$listeners.delete" delete secondary @click="deleteEvent" /> <BaseButton v-if="$listeners.delete" delete secondary @click="deleteEvent" />
<BaseButton v-if="$listeners.confirm" :color="color" type="submit" @click="submitEvent"> <BaseButton v-if="$listeners.confirm" :color="color" type="submit" @click="$emit('confirm')">
{{ $t("general.confirm") }} {{ $t("general.confirm") }}
</BaseButton> </BaseButton>
<BaseButton v-else-if="$listeners.submit" type="submit" @click="submitEvent"> <BaseButton v-else-if="$listeners.submit" type="submit" @click="submitEvent">

View File

@ -56,7 +56,7 @@ function getRequests(axoisInstance: NuxtAxiosInstance): ApiRequestInstance {
export const useApi = function (): Api { export const useApiSingleton = function (): Api {
const { $axios } = useContext(); const { $axios } = useContext();
const requests = getRequests($axios); const requests = getRequests($axios);

View File

@ -0,0 +1,38 @@
import { useAsync, ref } from "@nuxtjs/composition-api";
import { useApiSingleton } from "~/composables/use-api";
import { Recipe } from "~/types/api-types/recipe";
export const useRecipeContext = function () {
const api = useApiSingleton();
const loading = ref(false)
function getBySlug(slug: string) {
loading.value = true
const recipe = useAsync(async () => {
const { data } = await api.recipes.getOne(slug);
return data;
}, slug);
loading.value = false
return recipe;
}
async function deleteRecipe(slug: string) {
loading.value = true
const { data } = await api.recipes.deleteOne(slug);
loading.value = false
return data;
}
async function updateRecipe(slug: string, recipe: Recipe) {
loading.value = true
const { data } = await api.recipes.updateOne(slug, recipe);
loading.value = false
return data;
}
return {loading, getBySlug, deleteRecipe, updateRecipe}
};

View File

@ -1,8 +1,11 @@
const EMAIL_REGEX = const EMAIL_REGEX =
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@(([[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/ /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@(([[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
const URL_REGEX = /^https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,256}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)$/gm;
export const validators = { export const validators = {
required: (v: string) => !!v || "This Field is Required", required: (v: string) => !!v || "This Field is Required",
email: (v: string) => !v || EMAIL_REGEX.test(v) || "Email Must Be Valid", email: (v: string) => !v || EMAIL_REGEX.test(v) || "Email Must Be Valid",
whitespace: (v: string) => !v || v.split(" ").length <= 1 || "No Whitespace Allowed" whitespace: (v: string) => !v || v.split(" ").length <= 1 || "No Whitespace Allowed",
url: (v: string) => !v || URL_REGEX.test(v) || "Must Be A Valid URL",
} }

View File

@ -28,7 +28,7 @@ import AppFloatingButton from "@/components/Layout/AppFloatingButton.vue";
export default defineComponent({ export default defineComponent({
components: { AppHeader, AppSidebar, AppFloatingButton }, components: { AppHeader, AppSidebar, AppFloatingButton },
// @ts-ignore // @ts-ignore
middleware: process.env.PUBLIC_SITE ? null : "auth", middleware: process.env.GLOBAL_MIDDLEWARE,
setup() { setup() {
return {}; return {};
}, },

View File

@ -12,6 +12,10 @@ export default {
link: [{ rel: "icon", type: "image/x-icon", href: "/favicon.ico" }], link: [{ rel: "icon", type: "image/x-icon", href: "/favicon.ico" }],
}, },
router: {
base: process.env.SUB_PATH || "",
},
layoutTransition: { layoutTransition: {
name: "layout", name: "layout",
mode: "out-in", mode: "out-in",
@ -36,9 +40,13 @@ export default {
"@nuxtjs/composition-api/module", "@nuxtjs/composition-api/module",
], ],
publicRuntimeConfig: {
GLOBAL_MIDDLEWARE: process.env.GLOBAL_MIDDLEWARE || null,
ALLOW_SIGNUP: process.env.ALLOW_SIGNUP || true,
},
env: { env: {
PUBLIC_SITE: process.env.PUBLIC_SITE || true, GLOBAL_MIDDLEWARE: process.env.GLOBAL_MIDDLEWARE || null,
BASE_URL: process.env.BASE_URL || "",
ALLOW_SIGNUP: process.env.ALLOW_SIGNUP || true, ALLOW_SIGNUP: process.env.ALLOW_SIGNUP || true,
}, },

View File

@ -7,13 +7,13 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, onMounted, ref } from "@nuxtjs/composition-api"; import { defineComponent, onMounted, ref } from "@nuxtjs/composition-api";
import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue"; import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue";
import { useApi } from "~/composables/use-api"; import { useApiSingleton } from "~/composables/use-api";
import { Recipe } from "~/types/api-types/admin"; import { Recipe } from "~/types/api-types/admin";
export default defineComponent({ export default defineComponent({
components: { RecipeCardSection }, components: { RecipeCardSection },
setup() { setup() {
const api = useApi(); const api = useApiSingleton();
const recipes = ref<Recipe[] | null>([]); const recipes = ref<Recipe[] | null>([]);
onMounted(async () => { onMounted(async () => {

View File

@ -1,16 +1,265 @@
<template> <template>
<div></div> <v-container>
<v-card v-if="skeleton" :color="`white ${false ? 'darken-2' : 'lighten-4'}`" class="pa-3">
<v-skeleton-loader class="mx-auto" height="700px" type="card"></v-skeleton-loader>
</v-card>
<v-card v-else-if="recipe">
<v-img
:key="imageKey"
:height="hideImage ? '50' : imageHeight"
:src="api.recipes.recipeImage(recipe.slug)"
class="d-print-none"
@error="hideImage = true"
>
<RecipeTimeCard
:class="true ? undefined : 'force-bottom'"
:prep-time="recipe.prepTime"
:total-time="recipe.totalTime"
:perform-time="recipe.performTime"
/>
</v-img>
<RecipeActionMenu
v-model="form"
:slug="recipe.slug"
:name="recipe.name"
:logged-in="$auth.loggedIn"
:open="form"
class="ml-auto"
@close="form = false"
@json="jsonEditor = !jsonEditor"
@edit="
jsonEditor = false;
form = true;
"
@save="updateRecipe(recipe.slug, recipe)"
@delete="deleteRecipe(recipe.slug)"
/>
<div>
<v-card-text>
<div v-if="form" class="d-flex justify-start align-center">
<RecipeImageUploadBtn class="my-1" :slug="recipe.slug" @upload="uploadImage" @refresh="$emit('upload')" />
<RecipeSettingsMenu class="my-1 mx-1" :value="recipe.settings" @upload="null" />
</div>
<!-- Recipe Title Section -->
<template v-if="!form">
<v-card-title class="pa-0 ma-0 headline">
{{ recipe.name }}
</v-card-title>
<VueMarkdown :source="recipe.description"> </VueMarkdown>
</template>
<template v-else>
<v-text-field
v-model="recipe.name"
class="my-3"
:label="$t('recipe.recipe-name')"
:rules="[validators.required]"
>
</v-text-field>
<div class="d-flex flex-wrap">
<v-text-field v-model="recipe.totalTime" class="mx-2" :label="$t('recipe.total-time')"></v-text-field>
<v-text-field v-model="recipe.prepTime" class="mx-2" :label="$t('recipe.prep-time')"></v-text-field>
<v-text-field v-model="recipe.performTime" class="mx-2" :label="$t('recipe.perform-time')"></v-text-field>
</div>
<v-textarea v-model="recipe.description" auto-grow min-height="100" :label="$t('recipe.description')">
</v-textarea>
</template>
<div class="d-flex justify-space-between align-center">
<v-btn
v-if="recipe.recipeYield"
dense
small
:hover="false"
type="label"
:ripple="false"
elevation="0"
color="secondary darken-1"
class="rounded-sm static"
>
{{ recipe.recipeYield }}
</v-btn>
<RecipeRating :key="recipe.slug" :value="recipe.rating" :name="recipe.name" :slug="recipe.slug" />
</div>
<v-row>
<v-col cols="12" sm="12" md="4" lg="4">
<RecipeIngredients :value="recipe.recipeIngredient" :edit="form" />
<div v-if="$vuetify.breakpoint.mdAndUp">
<v-card v-if="recipe.recipeCategory.length > 0" class="mt-2">
<v-card-title class="py-2">
{{ $t("recipe.categories") }}
</v-card-title>
<v-divider class="mx-2"></v-divider>
<v-card-text>
<RecipeChips :items="recipe.recipeCategory" />
</v-card-text>
</v-card>
<v-card v-if="recipe.tags.length > 0" class="mt-2">
<v-card-title class="py-2">
{{ $t("tag.tags") }}
</v-card-title>
<v-divider class="mx-2"></v-divider>
<v-card-text>
<RecipeChips :items="recipe.tags" :is-category="false" />
</v-card-text>
</v-card>
<RecipeNutrition v-if="true || form" v-model="recipe.nutrition" class="mt-10" :edit="form" />
<RecipeAssets
v-if="recipe.settings.showAssets || form"
v-model="recipe.assets"
:edit="form"
:slug="recipe.slug"
/>
</div>
</v-col>
<v-divider v-if="$vuetify.breakpoint.mdAndUp" class="my-divider" :vertical="true"></v-divider>
<v-col cols="12" sm="12" md="8" lg="8">
<RecipeInstructions v-model="recipe.recipeInstructions" :edit="form" />
<RecipeNotes v-model="recipe.notes" :edit="form" />
</v-col>
</v-row>
</v-card-text>
</div>
</v-card>
</v-container>
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api" import { defineComponent, ref, useRoute, useRouter } from "@nuxtjs/composition-api";
// @ts-ignore
import VueMarkdown from "@adapttive/vue-markdown";
import { useApiSingleton } from "~/composables/use-api";
import { validators } from "~/composables/use-validators";
import { useRecipeContext } from "~/composables/use-recipe-context";
import RecipeActionMenu from "~/components/Domain/Recipe/RecipeActionMenu.vue";
import RecipeChips from "~/components/Domain/Recipe/RecipeChips.vue";
import RecipeIngredients from "~/components/Domain/Recipe/RecipeIngredients.vue";
import RecipeRating from "~/components/Domain/Recipe/RecipeRating.vue";
import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue";
import RecipeAssets from "~/components/Domain/Recipe/RecipeAssets.vue";
import RecipeNutrition from "~/components/Domain/Recipe/RecipeNutrition.vue";
import RecipeInstructions from "~/components/Domain/Recipe/RecipeInstructions.vue";
import RecipeNotes from "~/components/Domain/Recipe/RecipeNotes.vue";
import RecipeImageUploadBtn from "~/components/Domain/Recipe/RecipeImageUploadBtn.vue";
import RecipeSettingsMenu from "~/components/Domain/Recipe/RecipeSettingsMenu.vue";
import { Recipe } from "~/types/api-types/admin";
export default defineComponent({ export default defineComponent({
components: {
RecipeActionMenu,
RecipeAssets,
RecipeChips,
RecipeIngredients,
RecipeInstructions,
RecipeNotes,
RecipeNutrition,
RecipeRating,
RecipeTimeCard,
RecipeImageUploadBtn,
RecipeSettingsMenu,
VueMarkdown,
},
setup() { setup() {
return {} const route = useRoute();
const router = useRouter();
const slug = route.value.params.slug;
const api = useApiSingleton();
const { getBySlug, loading } = useRecipeContext();
const recipe = getBySlug(slug);
const form = ref<boolean>(false);
async function updateRecipe(slug: string, recipe: Recipe) {
const { data } = await api.recipes.updateOne(slug, recipe);
form.value = false;
if (data?.slug) {
router.push("/recipe/" + data.slug);
} }
}) }
async function deleteRecipe(slug: string) {
const { data } = await api.recipes.deleteOne(slug);
if (data?.slug) {
router.push("/");
}
}
return {
recipe,
api,
form,
loading,
deleteRecipe,
updateRecipe,
validators,
};
},
data() {
return {
imageKey: 1,
hideImage: false,
loadFailed: false,
skeleton: false,
jsonEditor: false,
jsonEditorOptions: {
mode: "code",
search: false,
mainMenuBar: false,
},
};
},
computed: {
imageHeight() {
// @ts-ignore
return this.$vuetify.breakpoint.xs ? "200" : "400";
},
},
methods: {
printPage() {
window.print();
},
// validateRecipe() {
// if (this.jsonEditor) {
// return true;
// } else {
// return this.$refs.recipeEditor.validateRecipe();
// }
// },
// async saveImage(overrideSuccessMsg = false) {
// if (this.fileObject) {
// const newVersion = await api.recipes.updateImage(this.recipeDetails.slug, this.fileObject, overrideSuccessMsg);
// if (newVersion) {
// this.recipeDetails.image = newVersion.data.version;
// this.imageKey += 1;
// }
// }
// },
// async saveRecipe() {
// if (this.validateRecipe()) {
// const slug = await this.api.recipes.updateOne(this.recipeDetails);
// if (!slug) return;
// if (this.fileObject) {
// this.saveImage(true);
// }
// this.form = false;
// if (slug !== this.recipe.slug) {
// this.$router.push(`/recipe/${slug}`);
// }
// window.URL.revokeObjectURL(this.api.recipes.(this.recipe.slug));
// }
// },
},
});
</script> </script>
<style scoped> <style scoped></style>
</style>

View File

@ -1,16 +1,38 @@
<template> <template>
<div></div> <v-container>
<RecipeCardSection
:icon="$globals.icons.primary"
:title="$t('page.all-recipes')"
:recipes="recipes"
@sort="assignSorted"
></RecipeCardSection>
</v-container>
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api" import { defineComponent, onMounted, ref } from "@nuxtjs/composition-api";
import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue";
import { useApiSingleton } from "~/composables/use-api";
import { Recipe } from "~/types/api-types/admin";
export default defineComponent({ export default defineComponent({
components: { RecipeCardSection },
setup() { setup() {
return {} const api = useApiSingleton();
}
}) const recipes = ref<Recipe[] | null>([]);
onMounted(async () => {
const { data } = await api.recipes.getAll();
recipes.value = data;
});
return { api, recipes };
},
methods: {
assignSorted(val: Array<Recipe>) {
this.recipes = val;
},
},
});
</script> </script>
<style scoped>
</style>

View File

@ -178,7 +178,7 @@
</v-btn> </v-btn>
</v-form> </v-form>
</v-card-text> </v-card-text>
<v-btn v-if="allowSignup" class="mx-auto" text to="/user/sign-up"> Sign Up </v-btn> <v-btn v-if="$config.ALLOW_SIGNUP" class="mx-auto" text to="/user/sign-up"> Sign Up </v-btn>
<v-btn v-else class="mx-auto" text disabled> Invite Only </v-btn> <v-btn v-else class="mx-auto" text disabled> Invite Only </v-btn>
</v-card> </v-card>
<!-- <v-col class="fill-height"> </v-col> --> <!-- <v-col class="fill-height"> </v-col> -->

View File

@ -20,6 +20,7 @@ import {
mdiEmail, mdiEmail,
mdiLock, mdiLock,
mdiEye, mdiEye,
mdiDrag,
mdiEyeOff, mdiEyeOff,
mdiCalendarMinus, mdiCalendarMinus,
mdiCalendar, mdiCalendar,
@ -38,7 +39,6 @@ import {
mdiFilePdfBox, mdiFilePdfBox,
mdiFileImage, mdiFileImage,
mdiCodeJson, mdiCodeJson,
mdiArrowUpDown,
mdiCog, mdiCog,
mdiSort, mdiSort,
mdiOrderAlphabeticalAscending, mdiOrderAlphabeticalAscending,
@ -103,7 +103,7 @@ const icons = {
alertCircle: mdiAlertCircle, alertCircle: mdiAlertCircle,
api: mdiApi, api: mdiApi,
arrowLeftBold: mdiArrowLeftBold, arrowLeftBold: mdiArrowLeftBold,
arrowUpDown: mdiArrowUpDown, arrowUpDown: mdiDrag,
backupRestore: mdiBackupRestore, backupRestore: mdiBackupRestore,
bellAlert: mdiBellAlert, bellAlert: mdiBellAlert,
broom: mdiBroom, broom: mdiBroom,

3
frontend/template.env Normal file
View File

@ -0,0 +1,3 @@
GLOBAL_MIDDLEWARE=null # null or 'auth'
BASE_URL = ""
ALLOW_SIGNUP=true

View File

@ -62,8 +62,8 @@ export interface RecipeCategoryResponse {
} }
export interface Recipe { export interface Recipe {
id?: number; id?: number;
name?: string; name: string;
slug?: string; slug: string;
image?: unknown; image?: unknown;
description?: string; description?: string;
recipeCategory?: string[]; recipeCategory?: string[];

View File

@ -49,8 +49,8 @@ export interface Nutrition {
} }
export interface Recipe { export interface Recipe {
id?: number; id?: number;
name?: string; name: string;
slug?: string; slug: string;
image?: unknown; image?: unknown;
description?: string; description?: string;
recipeCategory?: string[]; recipeCategory?: string[];

View File

@ -7,10 +7,10 @@ interface RequestResponse<T> {
} }
export interface ApiRequestInstance { export interface ApiRequestInstance {
get<T>(url: string, data?: object): Promise<RequestResponse<T>>; get<T>(url: string, data?: T | object): Promise<RequestResponse<T>>;
post<T>(url: string, data: object): Promise<RequestResponse<T>>; post<T>(url: string, data: T | object): Promise<RequestResponse<T>>;
put<T>(url: string, data: object): Promise<RequestResponse<T>>; put<T>(url: string, data: T | object): Promise<RequestResponse<T>>;
patch<T>(url: string, data: object): Promise<RequestResponse<T>>; patch<T>(url: string, data: T | object): Promise<RequestResponse<T>>;
delete<T>(url: string, data: object): Promise<RequestResponse<T>>; delete<T>(url: string, data?: T | object): Promise<RequestResponse<T>>;
} }

8
frontend/types/ts-shim.d.ts vendored Normal file
View File

@ -0,0 +1,8 @@
declare module "*.vue" {
import Vue from "vue"
export default Vue
}
interface VForm extends HTMLFormElement {
validate(): boolean;
}