mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-10-28 00:34:47 -04:00
fix: Optimize Recipe Favorites/Ratings (#6075)
This commit is contained in:
@@ -55,12 +55,9 @@
|
|||||||
/>
|
/>
|
||||||
<div v-else class="px-1" /> <!-- Empty div to keep the layout consistent -->
|
<div v-else class="px-1" /> <!-- Empty div to keep the layout consistent -->
|
||||||
|
|
||||||
<RecipeRating
|
<RecipeCardRating
|
||||||
class="ml-n2"
|
|
||||||
:model-value="rating"
|
:model-value="rating"
|
||||||
:recipe-id="recipeId"
|
:recipe-id="recipeId"
|
||||||
:slug="slug"
|
|
||||||
small
|
|
||||||
/>
|
/>
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
<RecipeChips
|
<RecipeChips
|
||||||
@@ -105,7 +102,7 @@ import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
|
|||||||
import RecipeChips from "./RecipeChips.vue";
|
import RecipeChips from "./RecipeChips.vue";
|
||||||
import RecipeContextMenu from "./RecipeContextMenu.vue";
|
import RecipeContextMenu from "./RecipeContextMenu.vue";
|
||||||
import RecipeCardImage from "./RecipeCardImage.vue";
|
import RecipeCardImage from "./RecipeCardImage.vue";
|
||||||
import RecipeRating from "./RecipeRating.vue";
|
import RecipeCardRating from "./RecipeCardRating.vue";
|
||||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|||||||
@@ -87,13 +87,11 @@
|
|||||||
class="ma-0 pa-0"
|
class="ma-0 pa-0"
|
||||||
/>
|
/>
|
||||||
<div v-else class="my-0 px-1 py-0" /> <!-- Empty div to keep the layout consistent -->
|
<div v-else class="my-0 px-1 py-0" /> <!-- Empty div to keep the layout consistent -->
|
||||||
<RecipeRating
|
<RecipeCardRating
|
||||||
v-if="showRecipeContent"
|
v-if="showRecipeContent"
|
||||||
:class="[{ 'pb-2': !isOwnGroup }, 'ml-n2']"
|
:class="[{ 'pb-2': !isOwnGroup }, 'ml-n2']"
|
||||||
:value="rating"
|
:model-value="rating"
|
||||||
:recipe-id="recipeId"
|
:recipe-id="recipeId"
|
||||||
:slug="slug"
|
|
||||||
small
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- If we're not logged-in, no items display, so we hide this menu -->
|
<!-- If we're not logged-in, no items display, so we hide this menu -->
|
||||||
@@ -130,7 +128,7 @@
|
|||||||
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
|
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
|
||||||
import RecipeContextMenu from "./RecipeContextMenu.vue";
|
import RecipeContextMenu from "./RecipeContextMenu.vue";
|
||||||
import RecipeCardImage from "./RecipeCardImage.vue";
|
import RecipeCardImage from "./RecipeCardImage.vue";
|
||||||
import RecipeRating from "./RecipeRating.vue";
|
import RecipeCardRating from "./RecipeCardRating.vue";
|
||||||
import RecipeChips from "./RecipeChips.vue";
|
import RecipeChips from "./RecipeChips.vue";
|
||||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||||
|
|
||||||
|
|||||||
101
frontend/components/Domain/Recipe/RecipeCardRating.vue
Normal file
101
frontend/components/Domain/Recipe/RecipeCardRating.vue
Normal 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>
|
||||||
@@ -43,8 +43,6 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
buttonStyle: false,
|
buttonStyle: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const api = useUserApi();
|
|
||||||
const $auth = useMealieAuth();
|
|
||||||
const { userRatings, refreshUserRatings } = useUserSelfRatings();
|
const { userRatings, refreshUserRatings } = useUserSelfRatings();
|
||||||
|
|
||||||
const isFavorite = computed(() => {
|
const isFavorite = computed(() => {
|
||||||
@@ -53,6 +51,9 @@ const isFavorite = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function toggleFavorite() {
|
async function toggleFavorite() {
|
||||||
|
const api = useUserApi();
|
||||||
|
const $auth = useMealieAuth();
|
||||||
|
|
||||||
if (!$auth.user.value) return;
|
if (!$auth.user.value) return;
|
||||||
if (!isFavorite.value) {
|
if (!isFavorite.value) {
|
||||||
await api.users.addFavorite($auth.user.value?.id, props.recipeId);
|
await api.users.addFavorite($auth.user.value?.id, props.recipeId);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { Composer } from "vue-i18n";
|
|||||||
import type { ApiRequestInstance, RequestResponse } from "~/lib/api/types/non-generated";
|
import type { ApiRequestInstance, RequestResponse } from "~/lib/api/types/non-generated";
|
||||||
import { AdminAPI, PublicApi, UserApi } from "~/lib/api";
|
import { AdminAPI, PublicApi, UserApi } from "~/lib/api";
|
||||||
import { PublicExploreApi } from "~/lib/api/client-public";
|
import { PublicExploreApi } from "~/lib/api/client-public";
|
||||||
|
import { useGlobalI18n } from "~/composables/use-global-i18n";
|
||||||
|
|
||||||
const request = {
|
const request = {
|
||||||
async safe<T, U>(
|
async safe<T, U>(
|
||||||
@@ -56,8 +57,7 @@ function getRequests(axiosInstance: AxiosInstance): ApiRequestInstance {
|
|||||||
export const useRequests = function (i18n?: Composer): ApiRequestInstance {
|
export const useRequests = function (i18n?: Composer): ApiRequestInstance {
|
||||||
const { $axios } = useNuxtApp();
|
const { $axios } = useNuxtApp();
|
||||||
if (!i18n) {
|
if (!i18n) {
|
||||||
// Only works in a setup block
|
i18n = useGlobalI18n();
|
||||||
i18n = useI18n();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$axios.defaults.headers.common["Accept-Language"] = i18n.locale.value;
|
$axios.defaults.headers.common["Accept-Language"] = i18n.locale.value;
|
||||||
|
|||||||
10
frontend/composables/use-global-i18n.ts
Normal file
10
frontend/composables/use-global-i18n.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -5,26 +5,31 @@ const userRatings = ref<UserRatingSummary[]>([]);
|
|||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const ready = ref(false);
|
const ready = ref(false);
|
||||||
|
|
||||||
export const useUserSelfRatings = function () {
|
const $auth = useMealieAuth();
|
||||||
const $auth = useMealieAuth();
|
|
||||||
const api = useUserApi();
|
|
||||||
|
|
||||||
|
export const useUserSelfRatings = function () {
|
||||||
async function refreshUserRatings() {
|
async function refreshUserRatings() {
|
||||||
if (!$auth.user.value || loading.value) {
|
if (!$auth.user.value || loading.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
const api = useUserApi();
|
||||||
|
|
||||||
const { data } = await api.users.getSelfRatings();
|
const { data } = await api.users.getSelfRatings();
|
||||||
userRatings.value = data?.ratings || [];
|
userRatings.value = data?.ratings || [];
|
||||||
|
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
ready.value = true;
|
ready.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setRating(slug: string, rating: number | null, isFavorite: boolean | null) {
|
async function setRating(slug: string, rating: number | null, isFavorite: boolean | null) {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
const api = useUserApi();
|
||||||
|
|
||||||
const userId = $auth.user.value?.id || "";
|
const userId = $auth.user.value?.id || "";
|
||||||
await api.users.setRating(userId, slug, rating, isFavorite);
|
await api.users.setRating(userId, slug, rating, isFavorite);
|
||||||
|
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
await refreshUserRatings();
|
await refreshUserRatings();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,8 +36,9 @@ import AppHeader from "@/components/Layout/LayoutParts/AppHeader.vue";
|
|||||||
import AppSidebar from "@/components/Layout/LayoutParts/AppSidebar.vue";
|
import AppSidebar from "@/components/Layout/LayoutParts/AppSidebar.vue";
|
||||||
import TheSnackbar from "~/components/Layout/LayoutParts/TheSnackbar.vue";
|
import TheSnackbar from "~/components/Layout/LayoutParts/TheSnackbar.vue";
|
||||||
import type { SidebarLinks } from "~/types/application-types";
|
import type { SidebarLinks } from "~/types/application-types";
|
||||||
|
import { useGlobalI18n } from "~/composables/use-global-i18n";
|
||||||
|
|
||||||
const i18n = useI18n();
|
const i18n = useGlobalI18n();
|
||||||
const display = useDisplay();
|
const display = useDisplay();
|
||||||
const { $globals } = useNuxtApp();
|
const { $globals } = useNuxtApp();
|
||||||
|
|
||||||
|
|||||||
@@ -13,11 +13,10 @@
|
|||||||
</v-app>
|
</v-app>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import TheSnackbar from "~/components/Layout/LayoutParts/TheSnackbar.vue";
|
import TheSnackbar from "~/components/Layout/LayoutParts/TheSnackbar.vue";
|
||||||
import AppHeader from "@/components/Layout/LayoutParts/AppHeader.vue";
|
import AppHeader from "@/components/Layout/LayoutParts/AppHeader.vue";
|
||||||
|
import { useGlobalI18n } from "~/composables/use-global-i18n";
|
||||||
|
|
||||||
export default defineNuxtComponent({
|
useGlobalI18n(); // ensure i18n is initialized
|
||||||
components: { TheSnackbar, AppHeader },
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import TheSnackbar from "~/components/Layout/LayoutParts/TheSnackbar.vue";
|
import TheSnackbar from "~/components/Layout/LayoutParts/TheSnackbar.vue";
|
||||||
import { useAppInfo } from "~/composables/api";
|
import { useAppInfo } from "~/composables/api";
|
||||||
|
import { useGlobalI18n } from "~/composables/use-global-i18n";
|
||||||
|
|
||||||
export default defineNuxtComponent({
|
export default defineNuxtComponent({
|
||||||
components: { TheSnackbar },
|
components: { TheSnackbar },
|
||||||
@@ -32,7 +33,7 @@ export default defineNuxtComponent({
|
|||||||
|
|
||||||
const isDemo = computed(() => appInfo?.value?.demoStatus || false);
|
const isDemo = computed(() => appInfo?.value?.demoStatus || false);
|
||||||
|
|
||||||
const i18n = useI18n();
|
const i18n = useGlobalI18n();
|
||||||
const version = computed(() => appInfo?.value?.version || i18n.t("about.unknown-version"));
|
const version = computed(() => appInfo?.value?.version || i18n.t("about.unknown-version"));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -2,10 +2,9 @@
|
|||||||
<DefaultLayout />
|
<DefaultLayout />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import DefaultLayout from "@/components/Layout/DefaultLayout.vue";
|
import DefaultLayout from "@/components/Layout/DefaultLayout.vue";
|
||||||
|
import { useGlobalI18n } from "~/composables/use-global-i18n";
|
||||||
|
|
||||||
export default defineNuxtComponent({
|
useGlobalI18n(); // ensure i18n is initialized
|
||||||
components: { DefaultLayout },
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -46,6 +46,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { useGlobalI18n } from "~/composables/use-global-i18n";
|
||||||
|
|
||||||
export default defineNuxtComponent({
|
export default defineNuxtComponent({
|
||||||
props: {
|
props: {
|
||||||
error: {
|
error: {
|
||||||
@@ -58,7 +60,7 @@ export default defineNuxtComponent({
|
|||||||
layout: "basic",
|
layout: "basic",
|
||||||
});
|
});
|
||||||
|
|
||||||
const i18n = useI18n();
|
const i18n = useGlobalI18n();
|
||||||
const $auth = useMealieAuth();
|
const $auth = useMealieAuth();
|
||||||
const { $globals } = useNuxtApp();
|
const { $globals } = useNuxtApp();
|
||||||
const ready = ref(false);
|
const ready = ref(false);
|
||||||
|
|||||||
Reference in New Issue
Block a user