fix: shopping list UI improvements (#7215)

This commit is contained in:
Arsène Reymond
2026-07-07 15:51:28 +02:00
committed by GitHub
parent 23d71ca977
commit 0154bda8bb
8 changed files with 135 additions and 185 deletions

View File

@@ -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',
}, },

View File

@@ -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}`;

View File

@@ -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>

View File

@@ -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"

View File

@@ -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;
}
}

View File

@@ -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,

View File

@@ -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({

View File

@@ -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",