feat(life): add game versions and 5-star ratings to posts

Support associating life posts with specific game versions
Allow 1-5 star ratings on posts in rateable categories
Add feed filters for game version, rateable status, and top-rated sorting
This commit is contained in:
2026-05-03 18:38:33 +08:00
parent 4ebb45aa94
commit 105274eec8
10 changed files with 856 additions and 58 deletions

View File

@@ -44,7 +44,10 @@ export const iconReactionLike: AppIcon = 'mdi:thumb-up-outline';
export const iconReactionThanks: AppIcon = 'mdi:hand-heart-outline';
export const iconSave: AppIcon = 'mdi:content-save-outline';
export const iconSearch: AppIcon = 'mdi:magnify';
export const iconStar: AppIcon = 'mdi:star';
export const iconStarOutline: AppIcon = 'mdi:star-outline';
export const iconSuccess: AppIcon = 'mdi:check-circle-outline';
export const iconTranslate: AppIcon = 'mdi:translate';
export const iconUpload: AppIcon = 'mdi:upload-outline';
export const iconVersion: AppIcon = 'mdi:tag-outline';
export const iconWarning: AppIcon = 'mdi:alert-outline';

View File

@@ -39,6 +39,11 @@ export interface NamedEntity {
export interface LifeCategory extends NamedEntity {
isDefault: boolean;
isRateable: boolean;
}
export interface GameVersion extends NamedEntity {
changeLog: string;
}
export interface Skill extends NamedEntity {
@@ -260,7 +265,11 @@ export interface LifePost {
updatedAt: string;
author: UserSummary | null;
updatedBy: UserSummary | null;
category: NamedEntity | null;
category: (NamedEntity & { isRateable: boolean }) | null;
gameVersion: GameVersion | null;
ratingAverage: number | null;
ratingCount: number;
myRating: number | null;
commentPreview: LifeComment[];
commentCount: number;
reactionCounts: LifeReactionCounts;
@@ -279,6 +288,9 @@ export interface LifePostsParams {
search?: string;
categoryId?: string | number;
language?: string;
gameVersionId?: string | number;
rateable?: boolean | null;
sort?: 'latest' | 'oldest' | 'top-rated';
}
export interface CommentPageParams {
@@ -325,6 +337,7 @@ export interface Options {
itemTags: NamedEntity[];
maps: NamedEntity[];
lifeCategories: LifeCategory[];
gameVersions: GameVersion[];
}
export interface AuthUser {
@@ -475,7 +488,8 @@ export type ConfigType =
| 'item-usages'
| 'acquisition-methods'
| 'maps'
| 'life-tags';
| 'life-tags'
| 'game-versions';
export interface PokemonPayload {
displayId: number;
@@ -563,6 +577,7 @@ export interface DailyChecklistPayload {
export interface LifePostPayload {
body: string;
categoryId: number;
gameVersionId?: number | null;
languageCode?: string | null;
}
@@ -645,7 +660,7 @@ export interface AiModerationSettingsPayload {
clearApiKey?: boolean;
}
export function buildQuery(params: Record<string, string | number | undefined>): string {
export function buildQuery(params: Record<string, string | number | boolean | undefined>): string {
const search = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
@@ -881,7 +896,10 @@ export const api = {
limit: params.limit,
search: params.search?.trim(),
categoryId: params.categoryId,
language: params.language
language: params.language,
gameVersionId: params.gameVersionId,
rateable: params.rateable === null ? undefined : params.rateable,
sort: params.sort
})}`
),
createLifePost: (payload: LifePostPayload) => sendJson<LifePost>('/api/life-posts', 'POST', payload),
@@ -893,6 +911,9 @@ export const api = {
setLifeReaction: (id: string | number, reactionType: LifeReactionType) =>
sendJson<LifePost>(`/api/life-posts/${id}/reaction`, 'PUT', { reactionType }),
deleteLifeReaction: (id: string | number) => deleteAndGetJson<LifePost>(`/api/life-posts/${id}/reaction`),
setLifeRating: (id: string | number, rating: number) =>
sendJson<LifePost>(`/api/life-posts/${id}/rating`, 'PUT', { rating }),
deleteLifeRating: (id: string | number) => deleteAndGetJson<LifePost>(`/api/life-posts/${id}/rating`),
createLifeComment: (postId: string | number, payload: LifeCommentPayload) =>
sendJson<LifeComment>(`/api/life-posts/${postId}/comments`, 'POST', payload),
lifeComments: (postId: string | number, params: CommentPageParams = {}) =>
@@ -949,13 +970,20 @@ export const api = {
reorderDailyChecklistItems: (ids: number[]) =>
sendJson<DailyChecklistItem[]>('/api/admin/daily-checklist/order', 'PUT', { ids }),
deleteDailyChecklistItem: (id: string | number) => deleteJson(`/api/admin/daily-checklist/${id}`),
config: (type: ConfigType) => getJson<Array<Skill | LifeCategory | NamedEntity>>(`/api/admin/config/${type}`),
createConfig: (type: ConfigType, payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean; isDefault?: boolean }) =>
sendJson<Skill | LifeCategory | NamedEntity>(`/api/admin/config/${type}`, 'POST', payload),
config: (type: ConfigType) => getJson<Array<Skill | LifeCategory | GameVersion | NamedEntity>>(`/api/admin/config/${type}`),
createConfig: (
type: ConfigType,
payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean; isDefault?: boolean; isRateable?: boolean; changeLog?: string }
) =>
sendJson<Skill | LifeCategory | GameVersion | NamedEntity>(`/api/admin/config/${type}`, 'POST', payload),
reorderConfig: (type: ConfigType, ids: number[]) =>
sendJson<Array<Skill | LifeCategory | NamedEntity>>(`/api/admin/config/${type}/order`, 'PUT', { ids }),
updateConfig: (type: ConfigType, id: number, payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean; isDefault?: boolean }) =>
sendJson<Skill | LifeCategory | NamedEntity>(`/api/admin/config/${type}/${id}`, 'PUT', payload),
sendJson<Array<Skill | LifeCategory | GameVersion | NamedEntity>>(`/api/admin/config/${type}/order`, 'PUT', { ids }),
updateConfig: (
type: ConfigType,
id: number,
payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean; isDefault?: boolean; isRateable?: boolean; changeLog?: string }
) =>
sendJson<Skill | LifeCategory | GameVersion | NamedEntity>(`/api/admin/config/${type}/${id}`, 'PUT', payload),
deleteConfig: (type: ConfigType, id: number) => deleteJson(`/api/admin/config/${type}/${id}`),
pokemon: (params: Record<string, string | number | undefined>) =>
getJson<Pokemon[]>(`/api/pokemon${buildQuery(params)}`),

View File

@@ -1727,7 +1727,7 @@ button:disabled,
}
.life-toolbar {
grid-template-columns: minmax(0, 1fr) auto;
grid-template-columns: minmax(240px, 1.2fr) minmax(300px, 1fr) auto;
align-items: end;
gap: 16px;
}
@@ -1744,6 +1744,21 @@ button:disabled,
min-width: 0;
}
.life-toolbar__filters {
display: grid;
grid-template-columns: repeat(3, minmax(130px, 1fr));
gap: 10px;
min-width: 0;
}
.life-toolbar__select {
min-width: 0;
}
.life-toolbar__select select {
width: 100%;
}
.life-search-control {
position: relative;
}
@@ -1967,6 +1982,7 @@ button:disabled,
min-height: 30px;
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 9px;
border: 1px solid color-mix(in srgb, var(--pokemon-blue) 38%, var(--line));
border-radius: var(--radius-small);
@@ -1977,10 +1993,98 @@ button:disabled,
line-height: 1.2;
}
.life-post__tag .ui-icon {
width: 16px;
height: 16px;
}
.life-post__tag--version {
border-color: color-mix(in srgb, var(--pokemon-yellow) 70%, var(--line));
background: color-mix(in srgb, var(--pokemon-yellow) 24%, var(--surface));
color: var(--ink-soft);
}
[data-theme="night"] .life-post__tag {
color: var(--pokemon-yellow);
}
.life-version-note {
max-width: 72ch;
padding: 8px 10px;
border: 1px solid var(--line);
border-radius: var(--radius-control);
background: var(--surface-soft);
}
.life-version-note summary {
color: var(--ink-soft);
cursor: pointer;
font-size: 13px;
font-weight: 900;
}
.life-version-note p {
margin: 8px 0 0;
color: var(--muted);
font-size: 14px;
line-height: 1.55;
overflow-wrap: anywhere;
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;
@@ -5538,6 +5642,10 @@ button:disabled,
}
@media (max-width: 900px) {
.life-toolbar {
grid-template-columns: 1fr;
}
.app-shell {
display: block;
padding-top: 64px;
@@ -5832,7 +5940,8 @@ button:disabled,
}
.life-toolbar,
.life-toolbar__search {
.life-toolbar__search,
.life-toolbar__filters {
grid-template-columns: 1fr;
}

View File

@@ -37,6 +37,7 @@ import {
type AdminUser,
type ConfigType,
type DailyChecklistItem,
type GameVersion,
type Habitat,
type Item,
type Language,
@@ -70,7 +71,12 @@ type AdminTab =
type AdminGroup = 'content' | 'configuration' | 'localization' | 'access';
type AdminNavItem = { key: AdminTab; label: string; permission: string | string[] };
type AdminNavGroup = { key: AdminGroup; label: string; items: AdminNavItem[] };
type EditableConfig = (NamedEntity | Skill | LifeCategory) & { hasItemDrop?: boolean; isDefault?: boolean };
type EditableConfig = (NamedEntity | Skill | LifeCategory | GameVersion) & {
hasItemDrop?: boolean;
isDefault?: boolean;
isRateable?: boolean;
changeLog?: string;
};
const adminTabIcons: Record<AdminTab, AppIcon> = {
users: iconProfile,
@@ -133,7 +139,9 @@ const adminNavigationGroups = computed<AdminNavGroup[]>(() => {
});
const tabs = computed<AdminNavItem[]>(() => adminNavigationGroups.value.flatMap((group) => group.items));
const configTypes = computed<Array<{ key: ConfigType; label: string; supportsItemDrop?: boolean; supportsDefault?: boolean }>>(() => [
const configTypes = computed<
Array<{ key: ConfigType; label: string; supportsItemDrop?: boolean; supportsDefault?: boolean; supportsRateable?: boolean; supportsChangeLog?: boolean }>
>(() => [
{ key: 'pokemon-types', label: t('config.pokemonTypes') },
{ key: 'skills', label: t('config.skills'), supportsItemDrop: true },
{ key: 'environments', label: t('config.environments') },
@@ -142,7 +150,8 @@ const configTypes = computed<Array<{ key: ConfigType; label: string; supportsIte
{ key: 'item-usages', label: t('config.itemUsages') },
{ key: 'acquisition-methods', label: t('config.acquisitionMethods') },
{ key: 'maps', label: t('config.maps') },
{ key: 'life-tags', label: t('config.lifeCategories'), supportsDefault: true }
{ key: 'life-tags', label: t('config.lifeCategories'), supportsDefault: true, supportsRateable: true },
{ key: 'game-versions', label: t('config.gameVersions'), supportsChangeLog: true }
]);
const activeTab = ref<AdminTab>('config');
@@ -163,7 +172,15 @@ const currentUser = ref<AuthUser | null>(null);
const busy = ref(false);
const contentLoading = ref(false);
const message = ref('');
const configForm = ref({ id: 0, name: '', translations: {} as TranslationMap, hasItemDrop: false, isDefault: false });
const configForm = ref({
id: 0,
name: '',
translations: {} as TranslationMap,
hasItemDrop: false,
isDefault: false,
isRateable: false,
changeLog: ''
});
const checklistForm = ref({ id: 0, title: '', translations: {} as TranslationMap });
const languageForm = ref({ code: '', name: '', enabled: true, isDefault: false, sortOrder: 0 });
const wordingForm = ref({ key: '', locale: defaultLocale, value: '', defaultValue: '', placeholders: [] as string[] });
@@ -376,7 +393,7 @@ async function loadLanguages() {
}
function resetConfigForm() {
configForm.value = { id: 0, name: '', translations: {}, hasItemDrop: false, isDefault: false };
configForm.value = { id: 0, name: '', translations: {}, hasItemDrop: false, isDefault: false, isRateable: false, changeLog: '' };
}
function resetChecklistForm() {
@@ -441,7 +458,9 @@ function editConfig(item: EditableConfig) {
name: item.baseName ?? item.name,
translations: item.translations ?? {},
hasItemDrop: item.hasItemDrop === true,
isDefault: item.isDefault === true
isDefault: item.isDefault === true,
isRateable: item.isRateable === true,
changeLog: item.changeLog ?? ''
};
configModalOpen.value = true;
}
@@ -717,7 +736,9 @@ async function saveConfig() {
name: configBaseNameForSave(),
translations: configForm.value.translations,
hasItemDrop: selectedConfig.value.supportsItemDrop ? configForm.value.hasItemDrop : undefined,
isDefault: selectedConfig.value.supportsDefault ? configForm.value.isDefault : undefined
isDefault: selectedConfig.value.supportsDefault ? configForm.value.isDefault : undefined,
isRateable: selectedConfig.value.supportsRateable ? configForm.value.isRateable : undefined,
changeLog: selectedConfig.value.supportsChangeLog ? configForm.value.changeLog : undefined
};
if (configForm.value.id) {
@@ -1281,6 +1302,7 @@ onMounted(() => {
{{ item.name }}
<span v-if="item.hasItemDrop" class="config-flag">{{ t('pages.admin.hasItemDrop') }}</span>
<span v-if="item.isDefault" class="config-flag">{{ t('pages.admin.defaultCategory') }}</span>
<span v-if="item.isRateable" class="config-flag">{{ t('pages.admin.rateableCategory') }}</span>
</span>
<span class="row-actions">
<button v-if="can('admin.config.update')" type="button" :disabled="busy" @click="editConfig(item)">
@@ -1815,6 +1837,16 @@ onMounted(() => {
{{ t('pages.admin.defaultCategory') }}
</label>
</div>
<div v-if="selectedConfig.supportsRateable" class="check-row">
<label>
<input v-model="configForm.isRateable" type="checkbox" />
{{ t('pages.admin.rateableCategory') }}
</label>
</div>
<div v-if="selectedConfig.supportsChangeLog" class="field">
<label for="config-change-log">{{ t('pages.admin.changeLog') }}</label>
<textarea id="config-change-log" v-model="configForm.changeLog"></textarea>
</div>
</form>
<template #footer>

View File

@@ -25,6 +25,9 @@ import {
iconReply,
iconSave,
iconSearch,
iconStar,
iconStarOutline,
iconVersion,
iconWarning
} from '../icons';
import {
@@ -34,6 +37,7 @@ import {
setAuthToken,
type AiModerationStatus,
type AuthUser,
type GameVersion,
type Language,
type LifeCategory,
type LifeComment,
@@ -52,9 +56,12 @@ type LifeCommentPageState = {
error: string;
};
type LifePostSort = 'latest' | 'oldest' | 'top-rated';
const { locale, t } = useI18n();
const posts = ref<LifePost[]>([]);
const lifeCategories = ref<LifeCategory[]>([]);
const gameVersions = ref<GameVersion[]>([]);
const languages = ref<Language[]>([]);
const currentUser = ref<AuthUser | null>(null);
const loading = ref(true);
@@ -65,8 +72,12 @@ const searchDraft = ref('');
const submittedSearch = ref('');
const activeCategoryId = ref('all');
const activeLanguageCode = ref('all');
const activeGameVersionId = ref('all');
const activeRateableFilter = ref('all');
const activeSort = ref<LifePostSort>('latest');
const body = ref('');
const selectedCategoryId = ref('');
const selectedGameVersionId = ref('');
const editingPostId = ref<number | null>(null);
const postModalOpen = ref(false);
const formError = ref('');
@@ -81,6 +92,8 @@ const commentErrors = ref<Record<string, string>>({});
const reactionPickerPostId = ref<number | null>(null);
const reactionBusyPostId = ref<number | null>(null);
const reactionErrors = ref<Record<number, string>>({});
const ratingBusyPostId = ref<number | null>(null);
const ratingErrors = ref<Record<number, string>>({});
const moderationBusyPostId = ref<number | null>(null);
const moderationErrors = ref<Record<number, string>>({});
const bodyInput = ref<HTMLTextAreaElement | null>(null);
@@ -99,6 +112,7 @@ const hasMorePosts = ref(false);
const loadMorePaused = ref(false);
const allCategoryValue = 'all';
const allLanguageValue = 'all';
const allGameVersionValue = 'all';
const reactionOptions = [
{ type: 'like', icon: iconReactionLike, labelKey: 'pages.life.reactionLike' },
@@ -114,6 +128,7 @@ function can(permissionKey: string) {
const canPost = computed(() => can('life.posts.create'));
const canComment = computed(() => can('life.comments.create'));
const canReact = computed(() => can('life.reactions.set'));
const canRate = computed(() => can('life.ratings.set'));
const charactersLeft = computed(() => Math.max(0, bodyMaxLength - body.value.length));
const isEditing = computed(() => editingPostId.value !== null);
const searchQuery = computed(() => submittedSearch.value.trim());
@@ -124,6 +139,21 @@ const selectedFeedCategoryId = computed(() => {
const selectedFeedLanguageCode = computed(() =>
activeLanguageCode.value === allLanguageValue ? undefined : activeLanguageCode.value
);
const selectedFeedGameVersionId = computed(() => {
const gameVersionId = Number(activeGameVersionId.value);
return activeGameVersionId.value === allGameVersionValue || !Number.isInteger(gameVersionId) || gameVersionId <= 0
? undefined
: gameVersionId;
});
const selectedRateableFilter = computed(() => {
if (activeRateableFilter.value === 'rateable') {
return true;
}
if (activeRateableFilter.value === 'not-rateable') {
return false;
}
return null;
});
const categoryFilterOptions = computed<TabOption[]>(() => [
{ value: allCategoryValue, label: t('pages.life.allCategories') },
...lifeCategories.value.map((category) => ({ value: String(category.id), label: category.name }))
@@ -132,6 +162,20 @@ const languageFilterOptions = computed<TabOption[]>(() => [
{ value: allLanguageValue, label: t('pages.life.allLanguages') },
...languages.value.map((language) => ({ value: language.code, label: language.name }))
]);
const gameVersionFilterOptions = computed(() => [
{ value: allGameVersionValue, label: t('pages.life.allVersions') },
...gameVersions.value.map((version) => ({ value: String(version.id), label: version.name }))
]);
const rateableFilterOptions = computed(() => [
{ value: 'all', label: t('pages.life.allRatingModes') },
{ value: 'rateable', label: t('pages.life.rateableOnly') },
{ value: 'not-rateable', label: t('pages.life.notRateableOnly') }
]);
const sortOptions = computed<Array<{ value: LifePostSort; label: string }>>(() => [
{ value: 'latest', label: t('pages.life.sortLatest') },
{ value: 'oldest', label: t('pages.life.sortOldest') },
{ value: 'top-rated', label: t('pages.life.sortTopRated') }
]);
const defaultLifeCategoryId = computed(() => {
const category = lifeCategories.value.find((item) => item.isDefault);
return category ? String(category.id) : '';
@@ -166,16 +210,26 @@ async function loadLifeCategories() {
try {
const options = await api.options();
lifeCategories.value = options.lifeCategories;
gameVersions.value = options.gameVersions;
if (activeCategoryId.value !== allCategoryValue && !lifeCategories.value.some((category) => String(category.id) === activeCategoryId.value)) {
activeCategoryId.value = allCategoryValue;
}
if (
activeGameVersionId.value !== allGameVersionValue &&
!gameVersions.value.some((gameVersion) => String(gameVersion.id) === activeGameVersionId.value)
) {
activeGameVersionId.value = allGameVersionValue;
}
if (!isEditing.value && postModalOpen.value && !selectedCategoryId.value) {
selectedCategoryId.value = defaultLifeCategoryId.value;
}
if (!isEditing.value && selectedCategoryId.value && !lifeCategories.value.some((category) => String(category.id) === selectedCategoryId.value)) {
selectedCategoryId.value = defaultLifeCategoryId.value;
}
if (selectedGameVersionId.value && !gameVersions.value.some((gameVersion) => String(gameVersion.id) === selectedGameVersionId.value)) {
selectedGameVersionId.value = '';
}
} catch (error) {
loadError.value = error instanceof Error && error.message ? error.message : t('errors.loadFailed');
}
@@ -210,7 +264,10 @@ async function loadPosts() {
limit: lifePostPageSize,
search: searchQuery.value,
categoryId: selectedFeedCategoryId.value,
language: selectedFeedLanguageCode.value
language: selectedFeedLanguageCode.value,
gameVersionId: selectedFeedGameVersionId.value,
rateable: selectedRateableFilter.value,
sort: activeSort.value
});
if (requestId !== postsRequestId) {
return;
@@ -252,7 +309,10 @@ async function loadMorePosts() {
limit: lifePostPageSize,
search: searchQuery.value,
categoryId: selectedFeedCategoryId.value,
language: selectedFeedLanguageCode.value
language: selectedFeedLanguageCode.value,
gameVersionId: selectedFeedGameVersionId.value,
rateable: selectedRateableFilter.value,
sort: activeSort.value
});
if (requestId !== postsRequestId) {
return;
@@ -277,6 +337,7 @@ async function loadMorePosts() {
function resetForm() {
body.value = '';
selectedCategoryId.value = '';
selectedGameVersionId.value = '';
editingPostId.value = null;
formError.value = '';
}
@@ -285,6 +346,7 @@ function payload() {
return {
body: body.value.trim(),
categoryId: selectedLifeCategoryId() ?? 0,
gameVersionId: selectedGameVersionForPost(),
languageCode: selectedFeedLanguageCode.value ?? null
};
}
@@ -294,6 +356,11 @@ function selectedLifeCategoryId() {
return Number.isInteger(categoryId) && categoryId > 0 ? categoryId : null;
}
function selectedGameVersionForPost() {
const gameVersionId = Number(selectedGameVersionId.value);
return Number.isInteger(gameVersionId) && gameVersionId > 0 ? gameVersionId : null;
}
function submitSearch() {
const nextSearch = searchDraft.value.trim();
if (nextSearch === submittedSearch.value && !loadError.value) {
@@ -326,11 +393,15 @@ function retryLoadMore() {
function matchesCurrentFilters(post: LifePost) {
const keyword = searchQuery.value.toLowerCase();
const categoryId = selectedFeedCategoryId.value;
const gameVersionId = selectedFeedGameVersionId.value;
const rateable = selectedRateableFilter.value;
const matchesSearch = keyword === '' || post.body.toLowerCase().includes(keyword);
const matchesCategory = categoryId === undefined || post.category?.id === categoryId;
const matchesGameVersion = gameVersionId === undefined || post.gameVersion?.id === gameVersionId;
const matchesRateable = rateable === null || post.category?.isRateable === rateable;
const matchesLanguage =
selectedFeedLanguageCode.value === undefined || post.moderationLanguageCode === selectedFeedLanguageCode.value;
return matchesSearch && matchesCategory && matchesLanguage;
return matchesSearch && matchesCategory && matchesGameVersion && matchesRateable && matchesLanguage;
}
function openCreatePostModal() {
@@ -371,7 +442,9 @@ async function submitPost() {
replacePost(updated);
} else {
const created = await api.createLifePost(payload());
if (matchesCurrentFilters(created)) {
if (activeSort.value !== 'latest') {
void loadPosts();
} else if (matchesCurrentFilters(created)) {
posts.value = [created, ...posts.value];
}
}
@@ -599,6 +672,10 @@ function isReactionBusy(postId: number) {
return reactionBusyPostId.value === postId;
}
function isRatingBusy(postId: number) {
return ratingBusyPostId.value === postId;
}
function commentAuthorName(comment: LifeComment) {
return comment.deleted ? t('pages.life.commentDeleted') : comment.author?.displayName ?? t('pages.life.byUnknown');
}
@@ -632,10 +709,39 @@ function clearReactionError(postId: number) {
reactionErrors.value = nextErrors;
}
function setRatingError(postId: number, message: string) {
ratingErrors.value = { ...ratingErrors.value, [postId]: message };
}
function clearRatingError(postId: number) {
const nextErrors = { ...ratingErrors.value };
delete nextErrors[postId];
ratingErrors.value = nextErrors;
}
function canUseReactions() {
return canReact.value && reactionBusyPostId.value === null;
}
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;
}
@@ -705,10 +811,32 @@ async function toggleReaction(post: LifePost, reactionType: LifeReactionType) {
}
}
async function toggleRating(post: LifePost, rating: number) {
if (!canUseRatings(post)) {
return;
}
ratingBusyPostId.value = post.id;
clearRatingError(post.id);
try {
const updatedPost = post.myRating === rating ? await api.deleteLifeRating(post.id) : await api.setLifeRating(post.id, rating);
replacePost(updatedPost);
if (activeSort.value === 'top-rated') {
void loadPosts();
}
} catch (error) {
setRatingError(post.id, error instanceof Error && error.message ? error.message : t('pages.life.ratingFailed'));
} finally {
ratingBusyPostId.value = null;
}
}
function startEdit(post: LifePost) {
editingPostId.value = post.id;
body.value = post.body;
selectedCategoryId.value = post.category ? String(post.category.id) : '';
selectedGameVersionId.value = post.gameVersion ? String(post.gameVersion.id) : '';
formError.value = '';
postModalOpen.value = true;
void nextTick(() => bodyInput.value?.focus());
@@ -899,6 +1027,15 @@ watch(activeLanguageCode, () => {
commentPages.value = {};
void loadPosts();
});
watch(activeGameVersionId, () => {
void loadPosts();
});
watch(activeRateableFilter, () => {
void loadPosts();
});
watch(activeSort, () => {
void loadPosts();
});
watch(locale, () => {
void loadLanguages();
void loadLifeCategories();
@@ -955,6 +1092,33 @@ onUnmounted(() => {
</button>
</form>
<div class="life-toolbar__filters">
<div class="field life-toolbar__select">
<label for="life-version-filter">{{ t('pages.life.versionFilter') }}</label>
<select id="life-version-filter" v-model="activeGameVersionId">
<option v-for="option in gameVersionFilterOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</div>
<div class="field life-toolbar__select">
<label for="life-rateable-filter">{{ t('pages.life.ratingFilter') }}</label>
<select id="life-rateable-filter" v-model="activeRateableFilter">
<option v-for="option in rateableFilterOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</div>
<div class="field life-toolbar__select">
<label for="life-sort">{{ t('pages.life.sort') }}</label>
<select id="life-sort" v-model="activeSort">
<option v-for="option in sortOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</div>
</div>
<div class="life-toolbar__actions">
<button class="ui-button ui-button--primary" :disabled="!authReady" type="button" @click="openCreatePostModal">
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
@@ -1005,6 +1169,19 @@ onUnmounted(() => {
/>
</div>
<div class="field">
<label for="life-post-version">{{ t('pages.life.gameVersion') }}</label>
<TagsSelect
id="life-post-version"
v-model="selectedGameVersionId"
:options="gameVersions"
:multiple="false"
:placeholder="t('pages.life.versionPlaceholder')"
:search-placeholder="t('pages.life.searchVersions')"
dropdown-strategy="fixed"
/>
</div>
<p v-if="formError" class="life-form__error" role="alert">{{ formError }}</p>
<div class="life-form__actions">
@@ -1096,9 +1273,37 @@ onUnmounted(() => {
<p class="life-post__body">{{ post.body }}</p>
<div v-if="post.category" class="life-post__tags" :aria-label="t('pages.life.category')">
<span class="life-post__tag">{{ post.category.name }}</span>
<div v-if="post.category || post.gameVersion" class="life-post__tags" :aria-label="t('pages.life.postMeta')">
<span v-if="post.category" class="life-post__tag">{{ post.category.name }}</span>
<span v-if="post.gameVersion" class="life-post__tag life-post__tag--version">
<Icon :icon="iconVersion" class="ui-icon" aria-hidden="true" />
{{ post.gameVersion.name }}
</span>
</div>
<details v-if="post.gameVersion?.changeLog" class="life-version-note">
<summary>{{ t('pages.life.changeLog') }}</summary>
<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">