mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-07-16 20:50:15 -04:00
fix: shopping list UI improvements (#7215)
This commit is contained in:
@@ -170,12 +170,12 @@
|
|||||||
<BaseButtonGroup
|
<BaseButtonGroup
|
||||||
:buttons="[
|
:buttons="[
|
||||||
{
|
{
|
||||||
icon: $globals.icons.checkboxBlankOutline,
|
icon: $globals.icons.checkboxMultipleBlankOutline,
|
||||||
text: $t('shopping-list.uncheck-all-items'),
|
text: $t('shopping-list.uncheck-all-items'),
|
||||||
event: 'uncheck',
|
event: 'uncheck',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: $globals.icons.checkboxOutline,
|
icon: $globals.icons.checkboxMultipleMarkedOutline,
|
||||||
text: $t('shopping-list.check-all-items'),
|
text: $t('shopping-list.check-all-items'),
|
||||||
event: 'check',
|
event: 'check',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-list :class="tile ? 'd-flex flex-wrap background' : 'background'" style="background-color: transparent;">
|
<v-list :class="attrs.class.list">
|
||||||
<v-sheet
|
<v-sheet
|
||||||
v-for="recipe, index in recipes"
|
v-for="recipe, index in recipes"
|
||||||
:key="recipe.id"
|
:key="recipe.id || recipe.slug"
|
||||||
:elevation="2"
|
:elevation="2"
|
||||||
:class="attrs.class.sheet"
|
:class="attrs.class.sheet"
|
||||||
:style="tile ? 'max-width: 100%; width: fit-content;' : 'width: 100%;'"
|
:style="attrs.style.sheet"
|
||||||
>
|
>
|
||||||
<v-list-item
|
<v-list-item
|
||||||
:to="disabled ? '' : '/g/' + groupSlug + '/r/' + recipe.slug"
|
:to="disabled ? '' : '/g/' + groupSlug + '/r/' + recipe.slug"
|
||||||
@@ -13,10 +13,15 @@
|
|||||||
>
|
>
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<v-avatar color="primary" :class="attrs.class.avatar">
|
<v-avatar color="primary" :class="attrs.class.avatar">
|
||||||
|
<v-img
|
||||||
|
v-if="recipe.image"
|
||||||
|
:src="getRecipeImageUrl(recipe)"
|
||||||
|
/>
|
||||||
<v-icon
|
<v-icon
|
||||||
|
v-else
|
||||||
:class="attrs.class.icon"
|
:class="attrs.class.icon"
|
||||||
dark
|
dark
|
||||||
:size="small ? 'small' : 'default'"
|
size="x-large"
|
||||||
>
|
>
|
||||||
{{ $globals.icons.primary }}
|
{{ $globals.icons.primary }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
@@ -54,20 +59,19 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
import { useFraction } from "~/composables/recipes/use-fraction";
|
import { useFraction } from "~/composables/recipes/use-fraction";
|
||||||
|
import { useStaticRoutes } from "~/composables/api";
|
||||||
import type { ShoppingListItemOut } from "~/lib/api/types/household";
|
import type { ShoppingListItemOut } from "~/lib/api/types/household";
|
||||||
import type { RecipeSummary } from "~/lib/api/types/recipe";
|
import type { RecipeSummary } from "~/lib/api/types/recipe";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
recipes: RecipeSummary[];
|
recipes: RecipeSummary[];
|
||||||
listItem?: ShoppingListItemOut;
|
listItem?: ShoppingListItemOut;
|
||||||
small?: boolean;
|
|
||||||
tile?: boolean;
|
tile?: boolean;
|
||||||
showDescription?: boolean;
|
showDescription?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
listItem: undefined,
|
listItem: undefined,
|
||||||
small: false,
|
|
||||||
tile: false,
|
tile: false,
|
||||||
showDescription: false,
|
showDescription: false,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
@@ -75,41 +79,46 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
|
|
||||||
const auth = useMealieAuth();
|
const auth = useMealieAuth();
|
||||||
const { frac } = useFraction();
|
const { frac } = useFraction();
|
||||||
|
const { recipeTinyImage } = useStaticRoutes();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
const display = useDisplay();
|
||||||
const groupSlug = computed(() => route.params.groupSlug || auth.user?.value?.groupSlug || "");
|
const groupSlug = computed(() => route.params.groupSlug || auth.user?.value?.groupSlug || "");
|
||||||
|
|
||||||
|
// Determine if we should show tiles based on screen size and number of recipes
|
||||||
|
const shouldShowTiles = computed(() => {
|
||||||
|
return props.tile && display.smAndUp.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
function getRecipeImageUrl(recipe: RecipeSummary) {
|
||||||
|
return recipeTinyImage(String(recipe.id), recipe.image ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
const attrs = computed(() => {
|
const attrs = computed(() => {
|
||||||
return props.small
|
const tileClasses = shouldShowTiles.value ? "d-flex flex-wrap" : "bg-transparent";
|
||||||
? {
|
const sheetClasses = shouldShowTiles.value
|
||||||
class: {
|
? "flex-grow-0 flex-shrink-0 mb-2 me-3"
|
||||||
sheet: props.tile ? "mb-1 me-1 justify-center align-center" : "mb-1 justify-center align-center",
|
: props.tile ? "mb-2 mx-2" : "mb-1";
|
||||||
listItem: "px-0",
|
const sheetStyle = shouldShowTiles.value
|
||||||
avatar: "ma-0",
|
? { flexBasis: "calc(50% - 12px)", width: "calc(50% - 12px)" }
|
||||||
icon: "ma-0 pa-0 primary",
|
: {};
|
||||||
text: "pa-0",
|
|
||||||
},
|
return {
|
||||||
style: {
|
class: {
|
||||||
text: {
|
list: tileClasses,
|
||||||
title: "font-size: small;",
|
sheet: sheetClasses,
|
||||||
subTitle: "font-size: x-small;",
|
listItem: "px-4 py-2",
|
||||||
},
|
avatar: "",
|
||||||
},
|
icon: "pa-1 primary",
|
||||||
}
|
text: "",
|
||||||
: {
|
},
|
||||||
class: {
|
style: {
|
||||||
sheet: props.tile ? "mx-1 justify-center align-center" : "mb-1 justify-center align-center",
|
sheet: sheetStyle,
|
||||||
listItem: "px-4",
|
text: {
|
||||||
avatar: "",
|
title: "",
|
||||||
icon: "pa-1 primary",
|
subTitle: "",
|
||||||
text: "",
|
},
|
||||||
},
|
},
|
||||||
style: {
|
};
|
||||||
text: {
|
|
||||||
title: "",
|
|
||||||
subTitle: "",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function sanitizeHTML(rawHtml: string) {
|
function sanitizeHTML(rawHtml: string) {
|
||||||
@@ -158,6 +167,10 @@ const listItemDescriptions = computed<string[]>(() => {
|
|||||||
|
|
||||||
listItemDescription += ` ${unitDisplay}`;
|
listItemDescription += ` ${unitDisplay}`;
|
||||||
}
|
}
|
||||||
|
if (props.listItem.food) {
|
||||||
|
const foodName = props.listItem.food.name;
|
||||||
|
listItemDescription += ` ${foodName}`;
|
||||||
|
}
|
||||||
|
|
||||||
if (itemRef.recipeNote) {
|
if (itemRef.recipeNote) {
|
||||||
listItemDescription += `, ${itemRef.recipeNote}`;
|
listItemDescription += `, ${itemRef.recipeNote}`;
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
label
|
label
|
||||||
variant="flat"
|
variant="flat"
|
||||||
:color="label.color || undefined"
|
:color="label.color || undefined"
|
||||||
:text-color="textColor"
|
|
||||||
>
|
>
|
||||||
<span style="max-width: 100%; overflow: hidden; text-overflow: ellipsis;">
|
<span style="max-width: 100%; overflow: hidden; text-overflow: ellipsis;">
|
||||||
{{ label.name }}
|
{{ label.name }}
|
||||||
@@ -13,12 +12,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { getTextColor } from "~/composables/use-text-color";
|
|
||||||
import type { MultiPurposeLabelSummary } from "~/lib/api/types/recipe";
|
import type { MultiPurposeLabelSummary } from "~/lib/api/types/recipe";
|
||||||
|
|
||||||
const props = defineProps<{
|
defineProps<{
|
||||||
label: MultiPurposeLabelSummary;
|
label: MultiPurposeLabelSummary;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const textColor = computed(() => getTextColor(props.label.color));
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -68,7 +68,7 @@
|
|||||||
@click="displayRecipeRefs = !displayRecipeRefs"
|
@click="displayRecipeRefs = !displayRecipeRefs"
|
||||||
>
|
>
|
||||||
<v-icon>
|
<v-icon>
|
||||||
{{ $globals.icons.potSteam }}
|
{{ $globals.icons.silverwareForkKnife }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</template>
|
</template>
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
variant="text"
|
variant="text"
|
||||||
class="ml-2"
|
class="ml-2"
|
||||||
icon
|
icon
|
||||||
@click="toggleEdit(true)"
|
@click="toggleEdit(!edit)"
|
||||||
>
|
>
|
||||||
<v-icon>
|
<v-icon>
|
||||||
{{ $globals.icons.edit }}
|
{{ $globals.icons.edit }}
|
||||||
@@ -113,24 +113,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
<v-row
|
<v-container
|
||||||
v-if="!listItem.checked && recipeList && recipeList.length && displayRecipeRefs"
|
v-if="!listItem.checked && recipeList && recipeList.length && displayRecipeRefs"
|
||||||
no-gutters
|
class="pa-0"
|
||||||
class="mb-2"
|
|
||||||
>
|
>
|
||||||
<v-col
|
<RecipeList
|
||||||
cols="auto"
|
:recipes="recipeList"
|
||||||
style="width: 100%;"
|
:list-item="listItem"
|
||||||
>
|
:disabled="isOffline"
|
||||||
<RecipeList
|
:tile="true"
|
||||||
:recipes="recipeList"
|
/>
|
||||||
:list-item="listItem"
|
</v-container>
|
||||||
:disabled="isOffline"
|
|
||||||
size="small"
|
|
||||||
tile
|
|
||||||
/>
|
|
||||||
</v-col>
|
|
||||||
</v-row>
|
|
||||||
<v-row
|
<v-row
|
||||||
v-if="listItem.checked"
|
v-if="listItem.checked"
|
||||||
no-gutters
|
no-gutters
|
||||||
@@ -146,8 +139,8 @@
|
|||||||
</v-row>
|
</v-row>
|
||||||
</v-container>
|
</v-container>
|
||||||
<div
|
<div
|
||||||
v-else
|
v-if="edit"
|
||||||
class="mb-1 mt-6"
|
class="mb-4"
|
||||||
>
|
>
|
||||||
<ShoppingListItemEditor
|
<ShoppingListItemEditor
|
||||||
v-model="localListItem"
|
v-model="localListItem"
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
import Color from "@sphinxxxx/color-conversion";
|
|
||||||
|
|
||||||
const LIGHT_COLOR = "white";
|
|
||||||
const DARK_COLOR = "black";
|
|
||||||
const ACCESSIBILITY_THRESHOLD = 0.179;
|
|
||||||
|
|
||||||
/*
|
|
||||||
Function to pick the text color based on the background color.
|
|
||||||
|
|
||||||
Based on -> https://stackoverflow.com/questions/3942878/how-to-decide-font-color-in-white-or-black-depending-on-background-color
|
|
||||||
*/
|
|
||||||
export function getTextColor(bgColor: string | undefined): string {
|
|
||||||
if (!bgColor) {
|
|
||||||
return DARK_COLOR;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const color = new Color(bgColor);
|
|
||||||
|
|
||||||
// if opacity is less than 0.3 always return dark color
|
|
||||||
if (color._rgba[3] < 0.3) {
|
|
||||||
return DARK_COLOR;
|
|
||||||
}
|
|
||||||
|
|
||||||
const uicolors = [color._rgba[0] / 255, color._rgba[1] / 255, color._rgba[2] / 255];
|
|
||||||
const c = uicolors.map((col) => {
|
|
||||||
if (col <= 0.03928) {
|
|
||||||
return col / 12.92;
|
|
||||||
}
|
|
||||||
return Math.pow((col + 0.055) / 1.055, 2.4);
|
|
||||||
});
|
|
||||||
const L = 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
|
|
||||||
return L > ACCESSIBILITY_THRESHOLD ? DARK_COLOR : LIGHT_COLOR;
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
console.warn(error);
|
|
||||||
return DARK_COLOR;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -31,9 +31,9 @@ import {
|
|||||||
mdiChartLine,
|
mdiChartLine,
|
||||||
mdiCheck,
|
mdiCheck,
|
||||||
mdiCheckBold,
|
mdiCheckBold,
|
||||||
mdiCheckboxBlankOutline,
|
|
||||||
mdiCheckboxMarkedCircle,
|
mdiCheckboxMarkedCircle,
|
||||||
mdiCheckboxOutline,
|
mdiCheckboxMultipleBlankOutline,
|
||||||
|
mdiCheckboxMultipleOutline,
|
||||||
mdiChefHat,
|
mdiChefHat,
|
||||||
mdiChevronDown,
|
mdiChevronDown,
|
||||||
mdiChevronRight,
|
mdiChevronRight,
|
||||||
@@ -198,8 +198,8 @@ export const icons = {
|
|||||||
cartCheck: mdiCartCheck,
|
cartCheck: mdiCartCheck,
|
||||||
check: mdiCheck,
|
check: mdiCheck,
|
||||||
checkBold: mdiCheckBold,
|
checkBold: mdiCheckBold,
|
||||||
checkboxBlankOutline: mdiCheckboxBlankOutline,
|
checkboxMultipleBlankOutline: mdiCheckboxMultipleBlankOutline,
|
||||||
checkboxOutline: mdiCheckboxOutline,
|
checkboxMultipleMarkedOutline: mdiCheckboxMultipleOutline,
|
||||||
checkboxMarkedCircle: mdiCheckboxMarkedCircle,
|
checkboxMarkedCircle: mdiCheckboxMarkedCircle,
|
||||||
chefHat: mdiChefHat,
|
chefHat: mdiChefHat,
|
||||||
clipboardCheck: mdiClipboardCheck,
|
clipboardCheck: mdiClipboardCheck,
|
||||||
|
|||||||
@@ -71,84 +71,72 @@
|
|||||||
<template #header>
|
<template #header>
|
||||||
<v-container class="px-0">
|
<v-container class="px-0">
|
||||||
<v-row no-gutters>
|
<v-row no-gutters>
|
||||||
<v-col
|
<ButtonLink
|
||||||
class="text-left"
|
:to="`/shopping-lists?disableRedirect=true`"
|
||||||
>
|
:text="$t('shopping-list.all-lists')"
|
||||||
<ButtonLink
|
:icon="$globals.icons.backArrow"
|
||||||
:to="`/shopping-lists?disableRedirect=true`"
|
/>
|
||||||
:text="$t('shopping-list.all-lists')"
|
<v-spacer />
|
||||||
:icon="$globals.icons.backArrow"
|
<h2 v-if="smAndUp" class="text-h5">
|
||||||
/>
|
{{ shoppingList.name }}
|
||||||
</v-col>
|
</h2>
|
||||||
<v-col
|
<v-spacer />
|
||||||
v-if="mdAndUp"
|
<BaseButtonGroup
|
||||||
cols="6"
|
class="d-flex"
|
||||||
class="d-none d-sm-flex justify-center"
|
:buttons="[
|
||||||
>
|
{
|
||||||
<v-img
|
icon: $globals.icons.contentCopy,
|
||||||
max-height="100"
|
text: '',
|
||||||
max-width="100"
|
event: 'edit',
|
||||||
src="/svgs/shopping-cart.svg"
|
children: [
|
||||||
/>
|
{
|
||||||
</v-col>
|
icon: $globals.icons.contentCopy,
|
||||||
<v-col class="d-flex justify-end">
|
text: $t('shopping-list.copy-as-text'),
|
||||||
<BaseButtonGroup
|
event: 'copy-plain',
|
||||||
class="d-flex"
|
},
|
||||||
:buttons="[
|
{
|
||||||
{
|
icon: $globals.icons.contentCopy,
|
||||||
icon: $globals.icons.contentCopy,
|
text: $t('shopping-list.copy-as-markdown'),
|
||||||
text: '',
|
event: 'copy-markdown',
|
||||||
event: 'edit',
|
},
|
||||||
children: [
|
],
|
||||||
{
|
},
|
||||||
icon: $globals.icons.contentCopy,
|
{
|
||||||
text: $t('shopping-list.copy-as-text'),
|
icon: $globals.icons.checkboxMultipleMarkedOutline,
|
||||||
event: 'copy-plain',
|
text: $t('shopping-list.check-all-items'),
|
||||||
},
|
event: 'check',
|
||||||
{
|
},
|
||||||
icon: $globals.icons.contentCopy,
|
{
|
||||||
text: $t('shopping-list.copy-as-markdown'),
|
icon: $globals.icons.dotsVertical,
|
||||||
event: 'copy-markdown',
|
text: '',
|
||||||
},
|
event: 'three-dot',
|
||||||
],
|
children: [
|
||||||
},
|
{
|
||||||
{
|
icon: $globals.icons.tags,
|
||||||
icon: $globals.icons.checkboxOutline,
|
text: $t('shopping-list.reorder-labels'),
|
||||||
text: $t('shopping-list.check-all-items'),
|
event: 'reorder-labels',
|
||||||
event: 'check',
|
},
|
||||||
},
|
{
|
||||||
{
|
icon: $globals.icons.tags,
|
||||||
icon: $globals.icons.dotsVertical,
|
text: $t('shopping-list.manage-labels'),
|
||||||
text: '',
|
event: 'manage-labels',
|
||||||
event: 'three-dot',
|
},
|
||||||
children: [
|
],
|
||||||
{
|
},
|
||||||
icon: $globals.icons.tags,
|
]"
|
||||||
text: $t('shopping-list.reorder-labels'),
|
@edit="edit = true"
|
||||||
event: 'reorder-labels',
|
@three-dot="threeDot = true"
|
||||||
},
|
@check="openCheckAll"
|
||||||
{
|
@copy-plain="copyListItems('plain')"
|
||||||
icon: $globals.icons.tags,
|
@copy-markdown="copyListItems('markdown')"
|
||||||
text: $t('shopping-list.manage-labels'),
|
@reorder-labels="toggleReorderLabelsDialog()"
|
||||||
event: 'manage-labels',
|
@manage-labels="$router.push(`/group/data/labels`)"
|
||||||
},
|
/>
|
||||||
],
|
|
||||||
},
|
|
||||||
]"
|
|
||||||
@edit="edit = true"
|
|
||||||
@three-dot="threeDot = true"
|
|
||||||
@check="openCheckAll"
|
|
||||||
@copy-plain="copyListItems('plain')"
|
|
||||||
@copy-markdown="copyListItems('markdown')"
|
|
||||||
@reorder-labels="toggleReorderLabelsDialog()"
|
|
||||||
@manage-labels="$router.push(`/group/data/labels`)"
|
|
||||||
/>
|
|
||||||
</v-col>
|
|
||||||
</v-row>
|
</v-row>
|
||||||
</v-container>
|
</v-container>
|
||||||
</template>
|
</template>
|
||||||
<template #title>
|
<template #title>
|
||||||
{{ shoppingList.name }}
|
{{ smAndUp ? "" : shoppingList.name }}
|
||||||
</template>
|
</template>
|
||||||
</BasePageTitle>
|
</BasePageTitle>
|
||||||
<BannerWarning
|
<BannerWarning
|
||||||
@@ -248,7 +236,7 @@
|
|||||||
<BaseButtonGroup
|
<BaseButtonGroup
|
||||||
:buttons="[
|
:buttons="[
|
||||||
{
|
{
|
||||||
icon: $globals.icons.checkboxBlankOutline,
|
icon: $globals.icons.checkboxMultipleBlankOutline,
|
||||||
text: $t('shopping-list.uncheck-all-items'),
|
text: $t('shopping-list.uncheck-all-items'),
|
||||||
event: 'uncheck',
|
event: 'uncheck',
|
||||||
},
|
},
|
||||||
@@ -293,7 +281,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<span>
|
<span>
|
||||||
<v-icon start class="mb-1">
|
<v-icon start class="mb-1">
|
||||||
{{ $globals.icons.primary }}
|
{{ $globals.icons.silverwareForkKnife }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
</span>
|
</span>
|
||||||
{{ $t('shopping-list.linked-recipes-count', shoppingList.recipeReferences
|
{{ $t('shopping-list.linked-recipes-count', shoppingList.recipeReferences
|
||||||
@@ -361,7 +349,7 @@ import { useLabelStore, useUnitStore, useFoodStore } from "~/composables/store";
|
|||||||
import { alert } from "~/composables/use-toast";
|
import { alert } from "~/composables/use-toast";
|
||||||
import type { ShoppingListItemOut } from "~/lib/api/types/household";
|
import type { ShoppingListItemOut } from "~/lib/api/types/household";
|
||||||
|
|
||||||
const { mdAndUp } = useDisplay();
|
const { smAndUp } = useDisplay();
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
|
|
||||||
useSeoMeta({
|
useSeoMeta({
|
||||||
|
|||||||
@@ -23,7 +23,6 @@
|
|||||||
"@mdi/js": "^7.4.47",
|
"@mdi/js": "^7.4.47",
|
||||||
"@nuxt/fonts": "^0.11.4",
|
"@nuxt/fonts": "^0.11.4",
|
||||||
"@nuxtjs/i18n": "^9.2.1",
|
"@nuxtjs/i18n": "^9.2.1",
|
||||||
"@sphinxxxx/color-conversion": "^2.2.2",
|
|
||||||
"@vite-pwa/nuxt": "^0.10.6",
|
"@vite-pwa/nuxt": "^0.10.6",
|
||||||
"@vueuse/core": "^12.7.0",
|
"@vueuse/core": "^12.7.0",
|
||||||
"@vueuse/shared": "^14.3.0",
|
"@vueuse/shared": "^14.3.0",
|
||||||
|
|||||||
Reference in New Issue
Block a user