2022-01-09 07:24:34 +00:00
|
|
|
<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>
|
2022-01-10 06:04:24 +00:00
|
|
|
<BannerExperimental issue="https://github.com/hay-kot/mealie/issues/916" />
|
2022-01-09 07:24:34 +00:00
|
|
|
<!-- 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({
|
2022-01-16 02:38:11 +00:00
|
|
|
// @ts-ignore TODO
|
2022-01-09 07:24:34 +00:00
|
|
|
name: item.label.name,
|
|
|
|
id: item.labelId,
|
|
|
|
});
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
return labels;
|
|
|
|
});
|
|
|
|
|
|
|
|
const itemsByLabel = computed(() => {
|
|
|
|
const items: { [prop: string]: ShoppingListItemCreate[] } = {};
|
|
|
|
|
|
|
|
const noLabel = {
|
2022-01-16 02:38:11 +00:00
|
|
|
"No Label": [] as ShoppingListItemCreate[],
|
2022-01-09 07:24:34 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
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 {
|
|
|
|
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>
|