1
0
Fork 0
mirror of https://github.com/mealie-recipes/mealie.git synced 2025-08-02 20:15:24 +02:00

feat: (WIP) base-shoppinglist infra (#911)

* feat:  base-shoppinglist infra (WIP)

* add type checker

* implement controllers

* apply router fixes

* add checked section hide/animation

* add label support

* formatting

* fix overflow images

* add experimental banner

* fix #912 word break issue

* remove any type errors

* bump dependencies

* remove templates

* fix build errors

* bump node version

* fix template literal
This commit is contained in:
Hayden 2022-01-08 22:24:34 -09:00 committed by GitHub
parent 86c99b10a2
commit 6db1357064
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
66 changed files with 3455 additions and 1311 deletions

View file

@ -1,4 +1,4 @@
FROM node:lts as builder
FROM node:16 as builder
WORKDIR /app
@ -21,7 +21,7 @@ RUN rm -rf node_modules && \
--non-interactive \
--production=true
FROM node:15-alpine
FROM node:16-alpine
WORKDIR /app

View file

@ -0,0 +1,22 @@
import { BaseCRUDAPI } from "../_base";
const prefix = "/api";
const routes = {
labels: `${prefix}/groups/labels`,
labelsId: (id: string | number) => `${prefix}/groups/labels/${id}`,
};
export interface CreateLabel {
name: string;
}
export interface Label extends CreateLabel {
id: string;
groupId: string;
}
export class MultiPurposeLabelsApi extends BaseCRUDAPI<Label, CreateLabel> {
baseRoute = routes.labels;
itemRoute = routes.labelsId;
}

View file

@ -0,0 +1,60 @@
import { BaseCRUDAPI } from "../_base";
import { ApiRequestInstance } from "~/types/api";
import { IngredientFood, IngredientUnit } from "~/types/api-types/recipe";
const prefix = "/api";
const routes = {
shoppingLists: `${prefix}/groups/shopping/lists`,
shoppingListsId: (id: string) => `${prefix}/groups/shopping/lists/${id}`,
shoppingListIdAddRecipe: (id: string, recipeId: number) => `${prefix}/groups/shopping/lists/${id}/recipe/${recipeId}`,
};
export interface ShoppingListItemCreate {
id: string;
shoppingListId: string;
checked: boolean;
position: number;
note: string;
quantity: number;
isFood: boolean;
unit: IngredientUnit | null;
food: IngredientFood | null;
labelId: string | null;
label?: {
id: string;
name: string;
};
}
export interface ShoppingListCreate {
name: string;
}
export interface ShoppingListSummary extends ShoppingListCreate {
id: string;
groupId: string;
}
export interface ShoppingList extends ShoppingListSummary {
listItems: ShoppingListItemCreate[];
}
export class ShoppingListsApi extends BaseCRUDAPI<ShoppingList, ShoppingListCreate> {
baseRoute = routes.shoppingLists;
itemRoute = routes.shoppingListsId;
async addRecipe(itemId: string, recipeId: number) {
return await this.requests.post(routes.shoppingListIdAddRecipe(itemId, recipeId), {});
}
}
export class ShoppingApi {
public lists: ShoppingListsApi;
constructor(requests: ApiRequestInstance) {
this.lists = new ShoppingListsApi(requests);
}
}

View file

@ -21,6 +21,8 @@ import { AdminAPI } from "./admin-api";
import { ToolsApi } from "./class-interfaces/tools";
import { GroupMigrationApi } from "./class-interfaces/group-migrations";
import { GroupReportsApi } from "./class-interfaces/group-reports";
import { ShoppingApi } from "./class-interfaces/group-shopping-lists";
import { MultiPurposeLabelsApi } from "./class-interfaces/group-multiple-purpose-labels";
import { ApiRequestInstance } from "~/types/api";
class Api {
@ -46,6 +48,8 @@ class Api {
public groupReports: GroupReportsApi;
public grouperServerTasks: GroupServerTaskAPI;
public tools: ToolsApi;
public shopping: ShoppingApi;
public multiPurposeLabels: MultiPurposeLabelsApi;
// Utils
public upload: UploadFile;
@ -74,6 +78,8 @@ class Api {
// Group
this.groupMigration = new GroupMigrationApi(requests);
this.groupReports = new GroupReportsApi(requests);
this.shopping = new ShoppingApi(requests);
this.multiPurposeLabels = new MultiPurposeLabelsApi(requests);
// Admin
this.events = new EventsAPI(requests);

View file

@ -22,3 +22,20 @@
.theme--dark.v-card {
background-color: #2b2b2b !important;
}
.left-border {
border-left: 5px solid var(--v-primary-base) !important;
}
.handle {
cursor: grab;
}
.hidden {
visibility: hidden !important;
}
.v-card__text,
.v-card__title {
word-break: normal !important;
}

View file

@ -58,7 +58,6 @@
show-print
:menu-top="false"
:slug="slug"
:name="name"
:menu-icon="$globals.icons.mdiDotsHorizontal"
fab
color="info"
@ -69,6 +68,7 @@
edit: false,
download: true,
mealplanner: true,
shoppingList: true,
print: true,
share: true,
}"

View file

@ -38,6 +38,7 @@
edit: true,
download: true,
mealplanner: true,
shoppingList: true,
print: false,
share: true,
}"

View file

@ -39,6 +39,7 @@
edit: true,
download: true,
mealplanner: true,
shoppingList: true,
print: false,
share: true,
}"

View file

@ -11,7 +11,7 @@
<BaseCardSectionTitle :title="key"> </BaseCardSectionTitle>
<v-row>
<v-col v-for="(item, index) in itms" :key="'cat' + index" cols="12" :sm="12" :md="6" :lg="4" :xl="3">
<v-card hover :to="`/recipes/${itemType}/${item.slug}`">
<v-card class="left-border" hover :to="`/recipes/${itemType}/${item.slug}`">
<v-card-actions>
<v-icon>
{{ icon }}

View file

@ -46,6 +46,21 @@
<v-select v-model="newMealType" :return-object="false" :items="planTypeOptions" label="Entry Type"></v-select>
</v-card-text>
</BaseDialog>
<BaseDialog v-model="shoppingListDialog" title="Add to List" :icon="$globals.icons.cartCheck">
<v-card-text>
<v-card
v-for="list in shoppingLists"
:key="list.id"
hover
class="my-2 left-border"
@click="addRecipeToList(list.id)"
>
<v-card-title class="py-2">
{{ list.name }}
</v-card-title>
</v-card>
</v-card-text>
</BaseDialog>
<v-menu
offset-y
left
@ -76,17 +91,19 @@
</template>
<script lang="ts">
import { defineComponent, reactive, toRefs, useContext, useRouter } from "@nuxtjs/composition-api";
import { defineComponent, reactive, toRefs, useContext, useRouter, ref } from "@nuxtjs/composition-api";
import RecipeDialogShare from "./RecipeDialogShare.vue";
import { useUserApi } from "~/composables/api";
import { alert } from "~/composables/use-toast";
import { MealType, planTypeOptions } from "~/composables/use-group-mealplan";
import { ShoppingListSummary } from "~/api/class-interfaces/group-shopping-lists";
export interface ContextMenuIncludes {
delete: boolean;
edit: boolean;
download: boolean;
mealplanner: boolean;
shoppingList: boolean;
print: boolean;
share: boolean;
}
@ -110,6 +127,7 @@ export default defineComponent({
edit: true,
download: true,
mealplanner: true,
shoppingList: true,
print: true,
share: true,
}),
@ -160,6 +178,7 @@ export default defineComponent({
shareDialog: false,
recipeDeleteDialog: false,
mealplannerDialog: false,
shoppingListDialog: false,
loading: false,
menuItems: [] as ContextMenuItem[],
newMealdate: "",
@ -197,6 +216,12 @@ export default defineComponent({
color: undefined,
event: "mealplanner",
},
shoppingList: {
title: "Add to List",
icon: $globals.icons.cartCheck,
color: undefined,
event: "shoppingList",
},
print: {
title: i18n.t("general.print") as string,
icon: $globals.icons.printer,
@ -229,6 +254,23 @@ export default defineComponent({
// ===========================================================================
// Context Menu Event Handler
const shoppingLists = ref<ShoppingListSummary[]>();
async function getShoppingLists() {
const { data } = await api.shopping.lists.getAll();
if (data) {
shoppingLists.value = data;
}
}
async function addRecipeToList(listId: string) {
const { data } = await api.shopping.lists.addRecipe(listId, props.recipeId);
if (data) {
alert.success("Recipe added to list");
state.shoppingListDialog = false;
}
}
const router = useRouter();
async function deleteRecipe() {
@ -270,6 +312,10 @@ export default defineComponent({
mealplanner: () => {
state.mealplannerDialog = true;
},
shoppingList: () => {
getShoppingLists();
state.shoppingListDialog = true;
},
share: () => {
state.shareDialog = true;
},
@ -289,6 +335,8 @@ export default defineComponent({
}
return {
shoppingLists,
addRecipeToList,
...toRefs(state),
contextMenuEventHandler,
deleteRecipe,

View file

@ -0,0 +1,141 @@
<template>
<div v-if="!edit" class="small-checkboxes d-flex justify-space-between align-center">
<v-checkbox v-model="listItem.checked" hide-details dense :label="listItem.note" @change="$emit('checked')">
<template #label>
<div>
{{ listItem.quantity }} <v-icon size="16" class="mx-1"> {{ $globals.icons.close }} </v-icon>
{{ listItem.note }}
</div>
</template>
</v-checkbox>
<v-chip v-if="listItem.label" class="ml-auto mt-2" small label>
{{ listItem.label.name }}
</v-chip>
<v-menu offset-x left>
<template #activator="{ on, attrs }">
<v-btn small class="ml-2 mt-2 handle" icon v-bind="attrs" v-on="on">
<v-icon>
{{ $globals.icons.arrowUpDown }}
</v-icon>
</v-btn>
</template>
<v-list dense>
<v-list-item v-for="action in contextMenu" :key="action.event" dense @click="contextHandler(action.event)">
<v-list-item-title>{{ action.text }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</div>
<div v-else class="my-1">
<v-card outlined>
<v-card-text>
<v-textarea v-model="listItem.note" hide-details label="Note" rows="1" auto-grow></v-textarea>
<div style="max-width: 300px" class="mt-3">
<v-autocomplete
v-model="listItem.labelId"
name=""
:items="labels"
item-value="id"
hide-details
item-text="name"
clearable
:prepend-inner-icon="$globals.icons.tags"
>
</v-autocomplete>
<v-checkbox v-model="listItem.isFood" hide-details label="Treat list item as a recipe ingredient" />
</div>
</v-card-text>
<v-card-actions class="ma-0 pt-0 pb-1 justify-end">
<v-btn icon @click="save">
<v-icon>
{{ $globals.icons.save }}
</v-icon>
</v-btn>
</v-card-actions>
</v-card>
</div>
</template>
<script lang="ts">
import { defineComponent, computed, ref } from "@nuxtjs/composition-api";
import { Label } from "~/api/class-interfaces/group-multiple-purpose-labels";
import { ShoppingListItemCreate } from "~/api/class-interfaces/group-shopping-lists";
interface actions {
text: string;
event: string;
}
const contextMenu: actions[] = [
{
text: "Edit",
event: "edit",
},
// {
// text: "Delete",
// event: "delete",
// },
// {
// text: "Move",
// event: "move",
// },
];
export default defineComponent({
props: {
value: {
type: Object as () => ShoppingListItemCreate,
required: true,
},
labels: {
type: Array as () => Label[],
required: true,
},
},
setup(props, context) {
const listItem = computed({
get: () => {
return props.value;
},
set: (val) => {
context.emit("input", val);
},
});
const edit = ref(false);
function contextHandler(event: string) {
if (event === "edit") {
edit.value = true;
} else {
context.emit(event);
}
}
function save() {
context.emit("save");
edit.value = false;
}
function handle(event: string) {
console.log(event);
}
const updatedLabels = computed(() => {
return props.labels.map((label) => {
return {
id: label.id,
text: label.name,
};
});
});
return {
updatedLabels,
handle,
save,
contextHandler,
edit,
contextMenu,
listItem,
};
},
});
</script>

View file

@ -60,7 +60,9 @@
<!-- Secondary Links -->
<template v-if="secondaryLinks">
<v-subheader v-if="secondaryHeader" class="pb-0">{{ secondaryHeader }}</v-subheader>
<v-subheader v-if="secondaryHeader" :to="secondaryHeaderLink" class="pb-0">
{{ secondaryHeader }}
</v-subheader>
<v-divider></v-divider>
<v-list nav dense exact>
<template v-for="nav in secondaryLinks">
@ -161,6 +163,10 @@ export default defineComponent({
type: String,
default: null,
},
secondaryHeaderLink: {
type: String,
default: null,
},
},
setup(props, context) {
// V-Model Support

View file

@ -0,0 +1,6 @@
<template>
<v-alert border="left" colored-border type="warning" elevation="2" :icon="$globals.icons.alert">
<b>Experimental Feature</b>
<div>This page contains experimental or still-baking features. Please excuse the mess.</div>
</v-alert>
</template>

View file

@ -0,0 +1,56 @@
<template>
<v-item-group>
<template v-for="btn in buttons">
<v-menu v-if="btn.children" :key="'menu-' + btn.event" active-class="pa-0" offset-x left>
<template #activator="{ on, attrs }">
<v-btn tile large icon v-bind="attrs" v-on="on">
<v-icon>
{{ btn.icon }}
</v-icon>
</v-btn>
</template>
<v-list dense>
<v-list-item v-for="(child, idx) in btn.children" :key="idx" dense @click="$emit(child.event)">
<v-list-item-title>{{ child.text }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<v-tooltip
v-else
:key="'btn-' + btn.event"
open-delay="200"
transition="slide-y-reverse-transition"
dense
bottom
content-class="text-caption"
>
<template #activator="{ on, attrs }">
<v-btn tile large icon v-bind="attrs" @click="$emit(btn.event)" v-on="on">
<v-icon> {{ btn.icon }} </v-icon>
</v-btn>
</template>
<span>{{ btn.text }}</span>
</v-tooltip>
</template>
</v-item-group>
</template>
<script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api";
export interface ButtonOption {
icon: string;
text: string;
event: string;
children: ButtonOption[];
}
export default defineComponent({
props: {
buttons: {
type: Array as () => ButtonOption[],
required: true,
},
},
});
</script>

View file

@ -22,7 +22,7 @@
</v-list-item>
</v-list-item-group>
</v-list>
<!-- Event -->
<!-- Links -->
<v-list v-else-if="mode === MODES.link" dense>
<v-list-item-group v-model="itemGroup">
<v-list-item v-for="(item, index) in items" :key="index" :to="item.to">
@ -58,6 +58,13 @@ const MODES = {
event: "event",
};
export interface MenuItem {
text: string;
icon: string;
to?: string;
event: string;
}
export default defineComponent({
props: {
mode: {
@ -65,7 +72,7 @@ export default defineComponent({
default: "model",
},
items: {
type: Array,
type: Array as () => MenuItem[],
required: true,
},
disabled: {
@ -92,6 +99,8 @@ export default defineComponent({
const activeObj = ref({
text: "DEFAULT",
value: "",
icon: undefined,
event: undefined,
});
let startIndex = 0;

View file

@ -19,7 +19,7 @@ export function detectServerBaseUrl(req?: IncomingMessage | null) {
} else if (req.socket.remoteAddress) {
// @ts-ignore
const protocol = req.socket.encrypted ? "https:" : "http:";
return `${protocol}//${req.socket.localAddress}:${req.socket.localPort}`;
return `${protocol}//${req.socket.localAddress || ""}:${req.socket.localPort || ""}`;
}
return "";

View file

@ -7,6 +7,7 @@
absolute
:top-link="topLinks"
secondary-header="Cookbooks"
secondary-header-link="/user/group/cookbooks"
:secondary-links="cookbookLinks || []"
:bottom-links="isAdmin ? bottomLink : []"
>
@ -135,7 +136,7 @@ export default defineComponent({
icon: this.$globals.icons.cartCheck,
title: "Shopping List",
subtitle: "Create a new shopping list",
to: "/user/group/shopping-list/create",
to: "/user/group/shopping-lists/create",
restricted: true,
},
],
@ -157,7 +158,7 @@ export default defineComponent({
{
icon: this.$globals.icons.formatListCheck,
title: this.$t("shopping-list.shopping-lists"),
to: "/shopping-list",
to: "/shopping-lists",
restricted: true,
},
{

View file

@ -229,8 +229,8 @@ export default defineComponent({
const weekRange = computed(() => {
return {
start: subDays(state.today, 1),
end: addDays(state.today, 6),
start: subDays(state.today as Date, 1),
end: addDays(state.today as Date, 6),
};
});
@ -248,12 +248,12 @@ export default defineComponent({
function forwardOneWeek() {
if (!state.today) return;
state.today = addDays(state.today, +5);
state.today = addDays(state.today as Date, +5);
}
function backOneWeek() {
if (!state.today) return;
state.today = addDays(state.today, -5);
state.today = addDays(state.today as Date, -5);
}
function onMoveCallback(evt: SortableEvent) {

View file

@ -33,8 +33,9 @@
<v-img
:key="imageKey"
:max-width="enableLandscape ? null : '50%'"
:min-height="hideImage ? '50' : imageHeight"
:src="recipeImage(recipe.slug, '', imageKey)"
min-height="50"
:height="hideImage ? undefined : imageHeight"
:src="recipeImage(recipe.slug, imageKey)"
class="d-print-none"
@error="hideImage = true"
>
@ -78,7 +79,12 @@
>
<div v-if="form" class="d-flex justify-start align-center">
<RecipeImageUploadBtn class="my-1" :slug="recipe.slug" @upload="uploadImage" @refresh="imageKey++" />
<RecipeSettingsMenu class="my-1 mx-1" :value="recipe.settings" :is-owner="recipe.userId == $auth.user.id" @upload="uploadImage" />
<RecipeSettingsMenu
class="my-1 mx-1"
:value="recipe.settings"
:is-owner="recipe.userId == $auth.user.id"
@upload="uploadImage"
/>
</div>
<!-- Recipe Title Section -->
<template v-if="!form && enableLandscape">
@ -771,4 +777,3 @@ export default defineComponent({
},
});
</script>

View file

@ -1,21 +0,0 @@
<template>
<div></div>
</template>
<script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api";
export default defineComponent({
setup() {
return {};
},
head() {
return {
title: this.$t("shopping-list.shopping-list") as string,
};
},
});
</script>
<style scoped>
</style>

View file

@ -1,21 +0,0 @@
<template>
<div></div>
</template>
<script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api";
export default defineComponent({
setup() {
return {};
},
head() {
return {
title: this.$t("shopping-list.shopping-list") as string,
};
},
});
</script>
<style scoped>
</style>

View file

@ -0,0 +1,506 @@
<template>
<v-container v-if="shoppingList" class="narrow-container">
<BasePageTitle divider>
<template #header>
<v-img max-height="100" max-width="100" :src="require('~/static/svgs/shopping-cart.svg')"></v-img>
</template>
<template #title> {{ shoppingList.name }} </template>
</BasePageTitle>
<BannerExperimental />
<!-- Viewer -->
<section v-if="!edit" class="py-2">
<div v-if="!byLabel">
<draggable :value="shoppingList.listItems" handle=".handle" @input="updateIndex">
<ShoppingListItem
v-for="(item, index) in listItems.unchecked"
:key="item.id"
v-model="listItems.unchecked[index]"
:labels="allLabels"
@checked="saveList"
@save="saveList"
/>
</draggable>
</div>
<div v-else>
<div v-for="(value, key) in itemsByLabel" :key="key" class="mb-6">
<div @click="toggleShowChecked()">
<span>
<v-icon>
{{ $globals.icons.tags }}
</v-icon>
</span>
{{ key }}
</div>
<div v-for="item in value" :key="item.id" class="small-checkboxes d-flex justify-space-between align-center">
<v-checkbox v-model="item.checked" hide-details dense :label="item.note" @change="saveList">
<template #label>
<div>
{{ item.quantity }} <v-icon size="16" class="mx-1"> {{ $globals.icons.close }} </v-icon>
{{ item.note }}
</div>
</template>
</v-checkbox>
</div>
</div>
</div>
<div v-if="listItems.checked && listItems.checked.length > 0" class="mt-6">
<button @click="toggleShowChecked()">
<span>
<v-icon>
{{ showChecked ? $globals.icons.chevronDown : $globals.icons.chevronRight }}
</v-icon>
</span>
{{ listItems.checked ? listItems.checked.length : 0 }} items checked
</button>
<v-divider class="my-4"></v-divider>
<v-expand-transition>
<div v-show="showChecked">
<div v-for="item in listItems.checked" :key="item.id" class="d-flex justify-space-between align-center">
<v-checkbox v-model="item.checked" color="gray" class="my-n2" :label="item.note" @change="saveList">
<template #label>
<div style="text-decoration: line-through">
{{ item.quantity }} x
{{ item.note }}
</div>
</template>
</v-checkbox>
</div>
</div>
</v-expand-transition>
</div>
</section>
<!-- Editor -->
<section v-else>
<draggable :value="shoppingList.listItems" handle=".handle" @input="updateIndex">
<div v-for="(item, index) in shoppingList.listItems" :key="index" class="d-flex">
<div class="number-input-container">
<v-text-field v-model="shoppingList.listItems[index].quantity" class="mx-1" type="number" label="Qty" />
</div>
<v-text-field v-model="item.note" :label="$t('general.name')"> </v-text-field>
<v-menu offset-x left>
<template #activator="{ on, attrs }">
<v-btn icon class="mt-3" v-bind="attrs" v-on="on">
<v-icon class="handle">
{{ $globals.icons.arrowUpDown }}
</v-icon>
</v-btn>
</template>
<v-list>
<v-list-item
v-for="(itm, idx) in contextMenu"
:key="idx"
@click="contextMenuAction(itm.action, item, index)"
>
<v-list-item-title>{{ itm.title }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<div v-if="item.isFood">Is Food</div>
</div>
</draggable>
<v-divider class="my-2" />
<!-- Create Form -->
<v-form @submit.prevent="ingredientCreate()">
<v-checkbox v-model="createIngredient.isFood" label="Treat list item as a recipe ingredient" />
<div class="d-flex">
<div class="number-input-container">
<v-text-field v-model="createIngredient.quantity" class="mx-1" type="number" label="Qty" />
</div>
<v-text-field v-model="createIngredient.note" :label="$t('recipe.note')"> </v-text-field>
</div>
<div v-if="createIngredient.isFood">Is Food</div>
<v-autocomplete
v-model="createIngredient.labelId"
clearable
name=""
:items="allLabels"
item-value="id"
item-text="name"
>
</v-autocomplete>
<div class="d-flex justify-end">
<BaseButton type="submit" create> </BaseButton>
</div>
</v-form>
</section>
<div class="d-flex justify-end my-4">
<BaseButtonGroup
v-if="!edit"
:buttons="[
{
icon: $globals.icons.contentCopy,
text: '',
event: 'edit',
children: [
{
icon: $globals.icons.contentCopy,
text: 'Copy as Text',
event: 'copy-plain',
},
{
icon: $globals.icons.contentCopy,
text: 'Copy as Markdown',
event: 'copy-markdown',
},
],
},
{
icon: $globals.icons.delete,
text: 'Delete Checked',
event: 'delete',
},
{
icon: $globals.icons.tags,
text: 'Toggle Label Sort',
event: 'sort-by-labels',
},
{
icon: $globals.icons.checkboxBlankOutline,
text: 'Uncheck All Items',
event: 'uncheck',
},
{
icon: $globals.icons.primary,
text: 'Add Recipe',
event: 'recipe',
},
{
icon: $globals.icons.edit,
text: 'Edit List',
event: 'edit',
},
]"
@edit="edit = true"
@delete="deleteChecked"
@uncheck="uncheckAll"
@sort-by-labels="sortByLabels"
@copy-plain="copyListItems('plain')"
@copy-markdown="copyListItems('markdown')"
/>
<BaseButton v-else save @click="saveList" />
</div>
</v-container>
</template>
<script lang="ts">
import draggable from "vuedraggable";
import { defineComponent, useAsync, useRoute, computed, ref } from "@nuxtjs/composition-api";
import { useClipboard, useToggle } from "@vueuse/core";
import { ShoppingListItemCreate } from "~/api/class-interfaces/group-shopping-lists";
import { useUserApi } from "~/composables/api";
import { useAsyncKey, uuid4 } from "~/composables/use-utils";
import { alert } from "~/composables/use-toast";
import { Label } from "~/api/class-interfaces/group-multiple-purpose-labels";
import ShoppingListItem from "~/components/Domain/ShoppingList/ShoppingListItem.vue";
import BannerExperimental from "~/components/global/BannerExperimental.vue";
type CopyTypes = "plain" | "markdown";
interface PresentLabel {
id: string;
name: string;
}
export default defineComponent({
components: {
draggable,
ShoppingListItem,
BannerExperimental,
},
setup() {
const userApi = useUserApi();
const edit = ref(false);
const byLabel = ref(false);
const route = useRoute();
const id = route.value.params.id;
const shoppingList = useAsync(async () => {
return await fetchShoppingList();
}, useAsyncKey());
async function fetchShoppingList() {
const { data } = await userApi.shopping.lists.getOne(id);
return data;
}
async function refresh() {
shoppingList.value = await fetchShoppingList();
}
async function saveList() {
if (!shoppingList.value) {
return;
}
// Set Position
shoppingList.value.listItems = shoppingList.value.listItems.map((itm: ShoppingListItemCreate, idx: number) => {
itm.position = idx;
return itm;
});
await userApi.shopping.lists.updateOne(id, shoppingList.value);
refresh();
edit.value = false;
}
// =====================================
// Ingredient CRUD
const listItems = computed(() => {
return {
checked: shoppingList.value?.listItems.filter((item) => item.checked),
unchecked: shoppingList.value?.listItems.filter((item) => !item.checked),
};
});
const createIngredient = ref(ingredientResetFactory());
function ingredientResetFactory() {
return {
id: null,
shoppingListId: id,
checked: false,
position: shoppingList.value?.listItems.length || 1,
isFood: false,
quantity: 1,
note: "",
unit: null,
food: null,
labelId: null,
};
}
function ingredientCreate() {
const item = { ...createIngredient.value, id: uuid4() };
shoppingList.value?.listItems.push(item);
createIngredient.value = ingredientResetFactory();
}
function updateIndex(data: ShoppingListItemCreate[]) {
if (shoppingList.value?.listItems) {
shoppingList.value.listItems = data;
}
if (!edit.value) {
saveList();
}
}
const [showChecked, toggleShowChecked] = useToggle(false);
// =====================================
// Copy List Items
const { copy, copied, isSupported } = useClipboard();
function getItemsAsPlain(items: ShoppingListItemCreate[]) {
return items
.map((item) => {
return `${item.quantity} x ${item.unit?.name || ""} ${item.food?.name || ""} ${item.note || ""}`.replace(
/\s+/g,
" "
);
})
.join("\n");
}
function getItemsAsMarkdown(items: ShoppingListItemCreate[]) {
return items
.map((item) => {
return `- [ ] ${item.quantity} x ${item.unit?.name || ""} ${item.food?.name || ""} ${
item.note || ""
}`.replace(/\s+/g, " ");
})
.join("\n");
}
async function copyListItems(copyType: CopyTypes) {
if (!isSupported) {
alert.error("Copy to clipboard is not supported in your browser or environment.");
}
console.log("copyListItems", copyType);
const items = shoppingList.value?.listItems.filter((item) => !item.checked);
if (!items) {
return;
}
let text = "";
switch (copyType) {
case "markdown":
text = getItemsAsMarkdown(items);
break;
default:
text = getItemsAsPlain(items);
break;
}
await copy(text);
if (copied) {
alert.success(`Copied ${items.length} items to clipboard`);
}
}
// =====================================
// Check / Uncheck All
function uncheckAll() {
let hasChanged = false;
shoppingList.value?.listItems.forEach((item) => {
if (item.checked) {
hasChanged = true;
item.checked = false;
}
});
if (hasChanged) {
saveList();
}
}
function deleteChecked() {
const unchecked = shoppingList.value?.listItems.filter((item) => !item.checked);
if (unchecked?.length === shoppingList.value?.listItems.length) {
return;
}
if (shoppingList.value?.listItems) {
shoppingList.value.listItems = unchecked || [];
}
saveList();
}
// =====================================
// List Item Context Menu
const contextActions = {
delete: "delete",
setIngredient: "setIngredient",
};
const contextMenu = [
{ title: "Delete", action: contextActions.delete },
{ title: "Ingredient", action: contextActions.setIngredient },
];
function contextMenuAction(action: string, item: ShoppingListItemCreate, idx: number) {
if (!shoppingList.value?.listItems) {
return;
}
switch (action) {
case contextActions.delete:
shoppingList.value.listItems = shoppingList.value?.listItems.filter((itm) => itm.id !== item.id);
break;
case contextActions.setIngredient:
shoppingList.value.listItems[idx].isFood = !shoppingList.value.listItems[idx].isFood;
break;
default:
break;
}
}
// =====================================
// Labels
const allLabels = ref([] as Label[]);
function sortByLabels() {
byLabel.value = !byLabel.value;
}
const presentLabels = computed(() => {
const labels: PresentLabel[] = [];
shoppingList.value?.listItems.forEach((item) => {
if (item.labelId) {
labels.push({
// @ts-ignore
name: item.label.name,
id: item.labelId,
});
}
});
return labels;
});
const itemsByLabel = computed(() => {
const items: { [prop: string]: ShoppingListItemCreate[] } = {};
const noLabel = {
"No Label": [],
};
shoppingList.value?.listItems.forEach((item) => {
if (item.labelId) {
if (item.label && item.label.name in items) {
items[item.label.name].push(item);
} else if (item.label) {
items[item.label.name] = [item];
}
} else {
// @ts-ignore
noLabel["No Label"].push(item);
}
});
if (noLabel["No Label"].length > 0) {
items["No Label"] = noLabel["No Label"];
}
return items;
});
async function refreshLabels() {
const { data } = await userApi.multiPurposeLabels.getAll();
allLabels.value = data ?? [];
}
refreshLabels();
return {
itemsByLabel,
byLabel,
presentLabels,
allLabels,
copyListItems,
sortByLabels,
uncheckAll,
showChecked,
toggleShowChecked,
createIngredient,
contextMenuAction,
contextMenu,
deleteChecked,
listItems,
updateIndex,
saveList,
edit,
shoppingList,
ingredientCreate,
};
},
head() {
return {
title: this.$t("shopping-list.shopping-list") as string,
};
},
});
</script>
<style scoped>
.number-input-container {
max-width: 50px;
}
</style>

View file

@ -0,0 +1,102 @@
<template>
<v-container v-if="shoppingLists" class="narrow-container">
<BaseDialog v-model="createDialog" :title="$t('shopping-list.create-shopping-list')" @submit="createOne">
<v-card-text>
<v-text-field v-model="createName" autofocus :label="$t('shopping-list.new-list')"> </v-text-field>
</v-card-text>
</BaseDialog>
<BaseDialog v-model="deleteDialog" :title="$t('general.confirm')" color="error" @confirm="deleteOne">
<v-card-text> Are you sure you want to delete this item?</v-card-text>
</BaseDialog>
<BasePageTitle divider>
<template #header>
<v-img max-height="100" max-width="100" :src="require('~/static/svgs/shopping-cart.svg')"></v-img>
</template>
<template #title> Shopping Lists </template>
</BasePageTitle>
<BaseButton create @click="createDialog = true" />
<section>
<v-card v-for="list in shoppingLists" :key="list.id" class="my-2 left-border" :to="`/shopping-lists/${list.id}`">
<v-card-title>
<v-icon left>
{{ $globals.icons.cartCheck }}
</v-icon>
{{ list.name }}
<v-btn class="ml-auto" icon @click.prevent="openDelete(list.id)">
<v-icon>
{{ $globals.icons.delete }}
</v-icon>
</v-btn>
</v-card-title>
</v-card>
</section>
</v-container>
</template>
<script lang="ts">
import { defineComponent, useAsync, reactive, toRefs } from "@nuxtjs/composition-api";
import { useUserApi } from "~/composables/api";
import { useAsyncKey } from "~/composables/use-utils";
export default defineComponent({
setup() {
const userApi = useUserApi();
const state = reactive({
createName: "",
createDialog: false,
deleteDialog: false,
deleteTarget: "",
});
const shoppingLists = useAsync(async () => {
return await fetchShoppingLists();
}, useAsyncKey());
async function fetchShoppingLists() {
const { data } = await userApi.shopping.lists.getAll();
return data;
}
async function refresh() {
shoppingLists.value = await fetchShoppingLists();
}
async function createOne() {
const { data } = await userApi.shopping.lists.createOne({ name: state.createName });
if (data) {
refresh();
state.createName = "";
}
}
function openDelete(id: string) {
state.deleteDialog = true;
state.deleteTarget = id;
}
async function deleteOne() {
const { data } = await userApi.shopping.lists.deleteOne(state.deleteTarget);
if (data) {
refresh();
}
}
return {
...toRefs(state),
shoppingLists,
createOne,
deleteOne,
openDelete,
};
},
head() {
return {
title: this.$t("shopping-list.shopping-list") as string,
};
},
});
</script>

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.9 KiB

View file

@ -103,6 +103,7 @@ import {
mdiTimerSand,
mdiRefresh,
mdiArrowRightBold,
mdiChevronRight,
} from "@mdi/js";
export const icons = {
@ -222,4 +223,5 @@ export const icons = {
back: mdiArrowLeftBoldOutline,
slotMachine: mdiSlotMachine,
chevronDown: mdiChevronDown,
chevronRight: mdiChevronRight,
};

File diff suppressed because it is too large Load diff