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;
|
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 {
|
.life-post__engagement {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -2103,11 +2050,105 @@ button:disabled,
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.life-post__engagement-actions {
|
||||||
|
flex: 1 1 520px;
|
||||||
|
}
|
||||||
|
|
||||||
.life-post__metrics {
|
.life-post__metrics {
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
min-width: 0;
|
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-icon-button,
|
||||||
.life-metric-button {
|
.life-metric-button {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -2180,13 +2221,28 @@ button:disabled,
|
|||||||
position: relative;
|
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 {
|
.life-reaction-control {
|
||||||
|
height: 44px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
border: 1px solid var(--line);
|
border: 0;
|
||||||
border-radius: var(--radius-control);
|
border-radius: var(--radius-control);
|
||||||
background: var(--surface-soft);
|
background: var(--surface-soft);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
.life-reaction-control .life-icon-button {
|
.life-reaction-control .life-icon-button {
|
||||||
@@ -5939,6 +5995,11 @@ button:disabled,
|
|||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.life-rating-control {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
.life-toolbar,
|
.life-toolbar,
|
||||||
.life-toolbar__search,
|
.life-toolbar__search,
|
||||||
.life-toolbar__filters {
|
.life-toolbar__filters {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Icon } from '@iconify/vue';
|
|||||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import FilterPanel from '../components/FilterPanel.vue';
|
import FilterPanel from '../components/FilterPanel.vue';
|
||||||
|
import LifeRatingControl from '../components/LifeRatingControl.vue';
|
||||||
import Modal from '../components/Modal.vue';
|
import Modal from '../components/Modal.vue';
|
||||||
import PageHeader from '../components/PageHeader.vue';
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
import Skeleton from '../components/Skeleton.vue';
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
@@ -25,8 +26,6 @@ import {
|
|||||||
iconReply,
|
iconReply,
|
||||||
iconSave,
|
iconSave,
|
||||||
iconSearch,
|
iconSearch,
|
||||||
iconStar,
|
|
||||||
iconStarOutline,
|
|
||||||
iconVersion,
|
iconVersion,
|
||||||
iconWarning
|
iconWarning
|
||||||
} from '../icons';
|
} from '../icons';
|
||||||
@@ -727,21 +726,6 @@ function canUseRatings(post: LifePost) {
|
|||||||
return canRate.value && ratingBusyPostId.value === null && post.moderationStatus === 'approved' && post.category?.isRateable === true;
|
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() {
|
function closeReactionPicker() {
|
||||||
reactionPickerPostId.value = null;
|
reactionPickerPostId.value = null;
|
||||||
}
|
}
|
||||||
@@ -1256,21 +1240,6 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</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>
|
<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')">
|
<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>
|
<p>{{ post.gameVersion.changeLog }}</p>
|
||||||
</details>
|
</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">
|
||||||
<div class="life-post__engagement-actions">
|
<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-reactions">
|
||||||
<div class="life-reaction-control">
|
<div class="life-reaction-control">
|
||||||
<button
|
<button
|
||||||
@@ -1379,6 +1338,23 @@ onUnmounted(() => {
|
|||||||
{{ areCommentsExpanded(post.id) ? t('pages.life.hideComments') : t('pages.life.comment') }}
|
{{ areCommentsExpanded(post.id) ? t('pages.life.hideComments') : t('pages.life.comment') }}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</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>
|
||||||
|
|
||||||
<div class="life-post__metrics">
|
<div class="life-post__metrics">
|
||||||
@@ -1416,6 +1392,8 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<p v-if="reactionErrors[post.id]" class="life-form__error" role="alert">{{ reactionErrors[post.id] }}</p>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
|
|||||||
Reference in New Issue
Block a user