fix: Optimize Recipe Favorites/Ratings (#6075)

This commit is contained in:
Michael Genson
2025-09-03 09:56:38 -05:00
committed by GitHub
parent 1cdf43c599
commit 461e51bd22
12 changed files with 142 additions and 28 deletions

View File

@@ -55,12 +55,9 @@
/>
<div v-else class="px-1" /> <!-- Empty div to keep the layout consistent -->
<RecipeRating
class="ml-n2"
<RecipeCardRating
:model-value="rating"
:recipe-id="recipeId"
:slug="slug"
small
/>
<v-spacer />
<RecipeChips
@@ -105,7 +102,7 @@ import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
import RecipeChips from "./RecipeChips.vue";
import RecipeContextMenu from "./RecipeContextMenu.vue";
import RecipeCardImage from "./RecipeCardImage.vue";
import RecipeRating from "./RecipeRating.vue";
import RecipeCardRating from "./RecipeCardRating.vue";
import { useLoggedInState } from "~/composables/use-logged-in-state";
interface Props {

View File

@@ -87,13 +87,11 @@
class="ma-0 pa-0"
/>
<div v-else class="my-0 px-1 py-0" /> <!-- Empty div to keep the layout consistent -->
<RecipeRating
<RecipeCardRating
v-if="showRecipeContent"
:class="[{ 'pb-2': !isOwnGroup }, 'ml-n2']"
:value="rating"
:model-value="rating"
:recipe-id="recipeId"
:slug="slug"
small
/>
<!-- If we're not logged-in, no items display, so we hide this menu -->
@@ -130,7 +128,7 @@
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
import RecipeContextMenu from "./RecipeContextMenu.vue";
import RecipeCardImage from "./RecipeCardImage.vue";
import RecipeRating from "./RecipeRating.vue";
import RecipeCardRating from "./RecipeCardRating.vue";
import RecipeChips from "./RecipeChips.vue";
import { useLoggedInState } from "~/composables/use-logged-in-state";

View File

@@ -0,0 +1,101 @@
<template>
<div class="rating-display">
<span
v-for="(star, index) in ratingDisplay"
:key="index"
class="star"
:class="{
'star-half': star === 'half',
'text-secondary': !useGroupStyle,
'text-grey-darken-1': useGroupStyle,
}"
>
<!-- We render both the full and empty stars for "half" stars because they're layered over each other -->
<span
v-if="star === 'empty' || star === 'half'"
class="star-empty"
>
</span>
<span
v-if="star === 'full' || star === 'half'"
class="star-full"
>
</span>
</span>
</div>
</template>
<script setup lang="ts">
import { useLoggedInState } from "~/composables/use-logged-in-state";
import { useUserSelfRatings } from "~/composables/use-users";
type Star = "full" | "half" | "empty";
const props = defineProps({
modelValue: {
type: Number,
default: 0,
},
recipeId: {
type: String,
default: "",
},
});
const { isOwnGroup } = useLoggedInState();
const { userRatings } = useUserSelfRatings();
const userRating = computed(() => {
return userRatings.value.find(r => r.recipeId === props.recipeId)?.rating ?? undefined;
});
const ratingValue = computed(() => userRating.value || props.modelValue || 0);
const useGroupStyle = computed(() => isOwnGroup.value && !userRating.value && props.modelValue);
const ratingDisplay = computed<Star[]>(
() => {
const stars: Star[] = [];
for (let i = 0; i < 5; i++) {
const diff = ratingValue.value - i;
if (diff >= 1) {
stars.push("full");
}
else if (diff >= 0.25) { // round to half star if rating is at least 0.25 but not quite a full star
stars.push("half");
}
else {
stars.push("empty");
}
}
return stars;
},
);
</script>
<style lang="scss" scoped>
.rating-display {
display: inline-flex;
align-items: center;
gap: 1px;
.star {
font-size: 18px;
transition: color 0.2s ease;
user-select: none;
position: relative;
display: inline-block;
&.star-half {
.star-full {
position: absolute;
left: 0;
top: 0;
width: 50%;
overflow: hidden;
}
}
}
}
</style>

View File

@@ -43,8 +43,6 @@ const props = withDefaults(defineProps<Props>(), {
buttonStyle: false,
});
const api = useUserApi();
const $auth = useMealieAuth();
const { userRatings, refreshUserRatings } = useUserSelfRatings();
const isFavorite = computed(() => {
@@ -53,6 +51,9 @@ const isFavorite = computed(() => {
});
async function toggleFavorite() {
const api = useUserApi();
const $auth = useMealieAuth();
if (!$auth.user.value) return;
if (!isFavorite.value) {
await api.users.addFavorite($auth.user.value?.id, props.recipeId);

View File

@@ -3,6 +3,7 @@ import type { Composer } from "vue-i18n";
import type { ApiRequestInstance, RequestResponse } from "~/lib/api/types/non-generated";
import { AdminAPI, PublicApi, UserApi } from "~/lib/api";
import { PublicExploreApi } from "~/lib/api/client-public";
import { useGlobalI18n } from "~/composables/use-global-i18n";
const request = {
async safe<T, U>(
@@ -56,8 +57,7 @@ function getRequests(axiosInstance: AxiosInstance): ApiRequestInstance {
export const useRequests = function (i18n?: Composer): ApiRequestInstance {
const { $axios } = useNuxtApp();
if (!i18n) {
// Only works in a setup block
i18n = useI18n();
i18n = useGlobalI18n();
}
$axios.defaults.headers.common["Accept-Language"] = i18n.locale.value;

View File

@@ -0,0 +1,10 @@
import type { Composer } from "vue-i18n";
let i18n: Composer | null = null;
export function useGlobalI18n() {
if (!i18n) {
i18n = useI18n();
}
return i18n;
}

View File

@@ -5,26 +5,31 @@ const userRatings = ref<UserRatingSummary[]>([]);
const loading = ref(false);
const ready = ref(false);
export const useUserSelfRatings = function () {
const $auth = useMealieAuth();
const api = useUserApi();
const $auth = useMealieAuth();
export const useUserSelfRatings = function () {
async function refreshUserRatings() {
if (!$auth.user.value || loading.value) {
return;
}
loading.value = true;
const api = useUserApi();
const { data } = await api.users.getSelfRatings();
userRatings.value = data?.ratings || [];
loading.value = false;
ready.value = true;
}
async function setRating(slug: string, rating: number | null, isFavorite: boolean | null) {
loading.value = true;
const api = useUserApi();
const userId = $auth.user.value?.id || "";
await api.users.setRating(userId, slug, rating, isFavorite);
loading.value = false;
await refreshUserRatings();
}

View File

@@ -36,8 +36,9 @@ import AppHeader from "@/components/Layout/LayoutParts/AppHeader.vue";
import AppSidebar from "@/components/Layout/LayoutParts/AppSidebar.vue";
import TheSnackbar from "~/components/Layout/LayoutParts/TheSnackbar.vue";
import type { SidebarLinks } from "~/types/application-types";
import { useGlobalI18n } from "~/composables/use-global-i18n";
const i18n = useI18n();
const i18n = useGlobalI18n();
const display = useDisplay();
const { $globals } = useNuxtApp();

View File

@@ -13,11 +13,10 @@
</v-app>
</template>
<script lang="ts">
<script setup lang="ts">
import TheSnackbar from "~/components/Layout/LayoutParts/TheSnackbar.vue";
import AppHeader from "@/components/Layout/LayoutParts/AppHeader.vue";
import { useGlobalI18n } from "~/composables/use-global-i18n";
export default defineNuxtComponent({
components: { TheSnackbar, AppHeader },
});
useGlobalI18n(); // ensure i18n is initialized
</script>

View File

@@ -24,6 +24,7 @@
<script lang="ts">
import TheSnackbar from "~/components/Layout/LayoutParts/TheSnackbar.vue";
import { useAppInfo } from "~/composables/api";
import { useGlobalI18n } from "~/composables/use-global-i18n";
export default defineNuxtComponent({
components: { TheSnackbar },
@@ -32,7 +33,7 @@ export default defineNuxtComponent({
const isDemo = computed(() => appInfo?.value?.demoStatus || false);
const i18n = useI18n();
const i18n = useGlobalI18n();
const version = computed(() => appInfo?.value?.version || i18n.t("about.unknown-version"));
return {

View File

@@ -2,10 +2,9 @@
<DefaultLayout />
</template>
<script lang="ts">
<script setup lang="ts">
import DefaultLayout from "@/components/Layout/DefaultLayout.vue";
import { useGlobalI18n } from "~/composables/use-global-i18n";
export default defineNuxtComponent({
components: { DefaultLayout },
});
useGlobalI18n(); // ensure i18n is initialized
</script>

View File

@@ -46,6 +46,8 @@
</template>
<script lang="ts">
import { useGlobalI18n } from "~/composables/use-global-i18n";
export default defineNuxtComponent({
props: {
error: {
@@ -58,7 +60,7 @@ export default defineNuxtComponent({
layout: "basic",
});
const i18n = useI18n();
const i18n = useGlobalI18n();
const $auth = useMealieAuth();
const { $globals } = useNuxtApp();
const ready = ref(false);