refactor(life): extract rating control and reorganize post actions
Extract 5-star rating UI into a dedicated LifeRatingControl component Move moderation status and retry button into the engagement actions bar
This commit is contained in:
71
frontend/src/components/LifeRatingControl.vue
Normal file
71
frontend/src/components/LifeRatingControl.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { iconStar, iconStarOutline } from '../icons';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
ratingAverage: number | null;
|
||||
ratingCount: number;
|
||||
myRating: number | null;
|
||||
disabled?: boolean;
|
||||
busy?: boolean;
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
busy: false
|
||||
}
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
rate: [rating: number];
|
||||
}>();
|
||||
|
||||
const { locale, t } = useI18n();
|
||||
const ratings = [1, 2, 3, 4, 5] as const;
|
||||
|
||||
const formattedAverage = computed(() => {
|
||||
if (props.ratingAverage === null || props.ratingCount === 0) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
return new Intl.NumberFormat(locale.value, { maximumFractionDigits: 2 }).format(props.ratingAverage);
|
||||
});
|
||||
|
||||
const summaryLabel = computed(() => {
|
||||
if (props.ratingAverage === null || props.ratingCount === 0) {
|
||||
return t('pages.life.noRatings');
|
||||
}
|
||||
|
||||
return t('pages.life.ratingAverage', {
|
||||
average: formattedAverage.value,
|
||||
count: props.ratingCount
|
||||
});
|
||||
});
|
||||
|
||||
function buttonLabel(rating: number) {
|
||||
return props.myRating === rating ? t('pages.life.removeRating') : t('pages.life.setRating', { count: rating });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="life-rating-control" role="group" :aria-busy="busy" :aria-label="t('pages.life.rating')">
|
||||
<div class="life-rating-control__stars" role="group" :aria-label="t('pages.life.rating')">
|
||||
<button
|
||||
v-for="rating in ratings"
|
||||
:key="rating"
|
||||
class="life-rating-control__star"
|
||||
:class="{ 'is-active': myRating !== null && rating <= myRating }"
|
||||
type="button"
|
||||
:aria-label="buttonLabel(rating)"
|
||||
:aria-pressed="myRating === rating"
|
||||
:disabled="disabled || busy"
|
||||
@click="emit('rate', rating)"
|
||||
>
|
||||
<Icon :icon="myRating !== null && rating <= myRating ? iconStar : iconStarOutline" class="ui-icon" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<span class="life-rating-control__summary" :aria-label="summaryLabel">{{ formattedAverage }}</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -2032,59 +2032,6 @@ button:disabled,
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.life-rating {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 12px;
|
||||
}
|
||||
|
||||
.life-rating__stars {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.life-rating__star {
|
||||
width: 44px;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--surface-soft);
|
||||
color: color-mix(in srgb, var(--warning) 80%, var(--ink-soft));
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.14s ease,
|
||||
border-color 0.14s ease,
|
||||
color 0.14s ease;
|
||||
}
|
||||
|
||||
.life-rating__star:hover,
|
||||
.life-rating__star.is-active {
|
||||
border-color: color-mix(in srgb, var(--warning) 72%, var(--line));
|
||||
background: color-mix(in srgb, var(--warning) 16%, var(--surface-soft));
|
||||
color: color-mix(in srgb, var(--warning) 84%, var(--ink));
|
||||
}
|
||||
|
||||
.life-rating__star:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.56;
|
||||
}
|
||||
|
||||
.life-rating__star .ui-icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.life-rating__summary {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.life-post__engagement {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -2103,11 +2050,105 @@ button:disabled,
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.life-post__engagement-actions {
|
||||
flex: 1 1 520px;
|
||||
}
|
||||
|
||||
.life-post__metrics {
|
||||
justify-content: flex-end;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.life-rating-control {
|
||||
min-height: 44px;
|
||||
height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
padding: 0 8px 0 0;
|
||||
border: 0;
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--surface-soft);
|
||||
box-shadow: inset 0 0 0 1px var(--line);
|
||||
color: var(--ink-soft);
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.life-rating-control__stars {
|
||||
min-height: 44px;
|
||||
height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
border-radius: calc(var(--radius-control) - 1px) 0 0 calc(var(--radius-control) - 1px);
|
||||
}
|
||||
|
||||
.life-rating-control__star {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
min-width: 44px;
|
||||
height: 44px;
|
||||
min-height: 44px;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
flex: 0 0 44px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: color-mix(in srgb, var(--warning) 78%, var(--ink-soft));
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
transition:
|
||||
background 0.14s ease,
|
||||
border-color 0.14s ease,
|
||||
color 0.14s ease,
|
||||
transform 0.14s ease;
|
||||
}
|
||||
|
||||
.life-rating-control__star:hover,
|
||||
.life-rating-control__star:focus-visible,
|
||||
.life-rating-control__star.is-active {
|
||||
border-color: transparent;
|
||||
background: color-mix(in srgb, var(--warning) 16%, var(--surface-soft));
|
||||
color: color-mix(in srgb, var(--warning) 86%, var(--ink));
|
||||
}
|
||||
|
||||
.life-rating-control__star:hover {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.life-rating-control__star:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.life-rating-control__star .ui-icon {
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
}
|
||||
|
||||
.life-rating-control__summary {
|
||||
min-width: 24px;
|
||||
height: 100%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 2px;
|
||||
color: var(--ink-soft);
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.25;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.life-icon-button,
|
||||
.life-metric-button {
|
||||
position: relative;
|
||||
@@ -2180,13 +2221,28 @@ button:disabled,
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.life-post__review-actions {
|
||||
min-height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.life-review-button {
|
||||
height: 44px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.life-reaction-control {
|
||||
height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
overflow: visible;
|
||||
border: 1px solid var(--line);
|
||||
border: 0;
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--surface-soft);
|
||||
box-shadow: inset 0 0 0 1px var(--line);
|
||||
}
|
||||
|
||||
.life-reaction-control .life-icon-button {
|
||||
@@ -5939,6 +5995,11 @@ button:disabled,
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.life-rating-control {
|
||||
flex-wrap: nowrap;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.life-toolbar,
|
||||
.life-toolbar__search,
|
||||
.life-toolbar__filters {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Icon } from '@iconify/vue';
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import FilterPanel from '../components/FilterPanel.vue';
|
||||
import LifeRatingControl from '../components/LifeRatingControl.vue';
|
||||
import Modal from '../components/Modal.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
@@ -25,8 +26,6 @@ import {
|
||||
iconReply,
|
||||
iconSave,
|
||||
iconSearch,
|
||||
iconStar,
|
||||
iconStarOutline,
|
||||
iconVersion,
|
||||
iconWarning
|
||||
} from '../icons';
|
||||
@@ -727,21 +726,6 @@ function canUseRatings(post: LifePost) {
|
||||
return canRate.value && ratingBusyPostId.value === null && post.moderationStatus === 'approved' && post.category?.isRateable === true;
|
||||
}
|
||||
|
||||
function ratingButtonLabel(post: LifePost, rating: number) {
|
||||
return post.myRating === rating ? t('pages.life.removeRating') : t('pages.life.setRating', { count: rating });
|
||||
}
|
||||
|
||||
function ratingAverageLabel(post: LifePost) {
|
||||
if (post.ratingAverage === null || post.ratingCount === 0) {
|
||||
return t('pages.life.noRatings');
|
||||
}
|
||||
|
||||
return t('pages.life.ratingAverage', {
|
||||
average: new Intl.NumberFormat(locale.value, { maximumFractionDigits: 2 }).format(post.ratingAverage),
|
||||
count: post.ratingCount
|
||||
});
|
||||
}
|
||||
|
||||
function closeReactionPicker() {
|
||||
reactionPickerPostId.value = null;
|
||||
}
|
||||
@@ -1256,21 +1240,6 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="life-post__moderation">
|
||||
<StatusBadge :label="moderationLabel(post.moderationStatus)" :tone="moderationTone(post.moderationStatus)" compact />
|
||||
<button
|
||||
v-if="canRetryModeration(post)"
|
||||
class="ui-button ui-button--ghost ui-button--small"
|
||||
type="button"
|
||||
:disabled="moderationBusyPostId === post.id"
|
||||
@click="retryPostModeration(post)"
|
||||
>
|
||||
<Icon :icon="iconWarning" class="ui-icon" aria-hidden="true" />
|
||||
{{ moderationBusyPostId === post.id ? t('pages.life.moderationRetrying') : t('pages.life.moderationRetry') }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="moderationErrors[post.id]" class="life-form__error" role="alert">{{ moderationErrors[post.id] }}</p>
|
||||
|
||||
<p class="life-post__body">{{ post.body }}</p>
|
||||
|
||||
<div v-if="post.category || post.gameVersion" class="life-post__tags" :aria-label="t('pages.life.postMeta')">
|
||||
@@ -1285,28 +1254,18 @@ onUnmounted(() => {
|
||||
<p>{{ post.gameVersion.changeLog }}</p>
|
||||
</details>
|
||||
|
||||
<div v-if="post.category?.isRateable" class="life-rating">
|
||||
<div class="life-rating__stars" role="group" :aria-label="t('pages.life.rating')">
|
||||
<button
|
||||
v-for="rating in 5"
|
||||
:key="rating"
|
||||
class="life-rating__star"
|
||||
:class="{ 'is-active': post.myRating !== null && rating <= post.myRating }"
|
||||
type="button"
|
||||
:aria-label="ratingButtonLabel(post, rating)"
|
||||
:aria-pressed="post.myRating === rating"
|
||||
:disabled="!canUseRatings(post) || isRatingBusy(post.id)"
|
||||
@click="toggleRating(post, rating)"
|
||||
>
|
||||
<Icon :icon="post.myRating !== null && rating <= post.myRating ? iconStar : iconStarOutline" class="ui-icon" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<span class="life-rating__summary">{{ ratingAverageLabel(post) }}</span>
|
||||
</div>
|
||||
<p v-if="ratingErrors[post.id]" class="life-form__error" role="alert">{{ ratingErrors[post.id] }}</p>
|
||||
|
||||
<div class="life-post__engagement">
|
||||
<div class="life-post__engagement-actions">
|
||||
<LifeRatingControl
|
||||
v-if="post.category?.isRateable"
|
||||
:rating-average="post.ratingAverage"
|
||||
:rating-count="post.ratingCount"
|
||||
:my-rating="post.myRating"
|
||||
:disabled="!canUseRatings(post)"
|
||||
:busy="isRatingBusy(post.id)"
|
||||
@rate="toggleRating(post, $event)"
|
||||
/>
|
||||
|
||||
<div class="life-reactions">
|
||||
<div class="life-reaction-control">
|
||||
<button
|
||||
@@ -1379,6 +1338,23 @@ onUnmounted(() => {
|
||||
{{ areCommentsExpanded(post.id) ? t('pages.life.hideComments') : t('pages.life.comment') }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div class="life-post__review-actions">
|
||||
<StatusBadge :label="moderationLabel(post.moderationStatus)" :tone="moderationTone(post.moderationStatus)" compact />
|
||||
<button
|
||||
v-if="canRetryModeration(post)"
|
||||
class="life-icon-button life-review-button"
|
||||
type="button"
|
||||
:aria-label="moderationBusyPostId === post.id ? t('pages.life.moderationRetrying') : t('pages.life.moderationRetry')"
|
||||
:disabled="moderationBusyPostId === post.id"
|
||||
@click="retryPostModeration(post)"
|
||||
>
|
||||
<Icon :icon="iconWarning" class="ui-icon" aria-hidden="true" />
|
||||
<span class="life-action-tooltip" role="tooltip">
|
||||
{{ moderationBusyPostId === post.id ? t('pages.life.moderationRetrying') : t('pages.life.moderationRetry') }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="life-post__metrics">
|
||||
@@ -1416,6 +1392,8 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="ratingErrors[post.id]" class="life-form__error" role="alert">{{ ratingErrors[post.id] }}</p>
|
||||
<p v-if="moderationErrors[post.id]" class="life-form__error" role="alert">{{ moderationErrors[post.id] }}</p>
|
||||
<p v-if="reactionErrors[post.id]" class="life-form__error" role="alert">{{ reactionErrors[post.id] }}</p>
|
||||
|
||||
<section
|
||||
|
||||
Reference in New Issue
Block a user