feat(life): add tags to life posts and feed filtering
Allow users to select tags when creating or editing life posts Add tag tabs to the life feed for filtering posts by tag
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch, type CSSProperties } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { iconCheck, iconChevronDown, iconClose } from '../icons';
|
||||
|
||||
@@ -17,6 +17,7 @@ type OptionRow = {
|
||||
};
|
||||
|
||||
type CandidateRow = { type: 'option'; id: string; value: string; label: string } | { type: 'create'; id: string };
|
||||
type DropdownStrategy = 'absolute' | 'fixed';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -31,12 +32,14 @@ const props = withDefaults(
|
||||
allowCreate?: boolean;
|
||||
creating?: boolean;
|
||||
createLabel?: string;
|
||||
dropdownStrategy?: DropdownStrategy;
|
||||
}>(),
|
||||
{
|
||||
multiple: true,
|
||||
max: 0,
|
||||
allowCreate: false,
|
||||
creating: false
|
||||
creating: false,
|
||||
dropdownStrategy: 'absolute'
|
||||
}
|
||||
);
|
||||
|
||||
@@ -47,10 +50,14 @@ const emit = defineEmits<{
|
||||
|
||||
const { t } = useI18n();
|
||||
const root = ref<HTMLElement | null>(null);
|
||||
const trigger = ref<HTMLButtonElement | null>(null);
|
||||
const searchInput = ref<HTMLInputElement | null>(null);
|
||||
const isOpen = ref(false);
|
||||
const search = ref('');
|
||||
const activeIndex = ref(-1);
|
||||
const dropdownStyle = ref<CSSProperties>({});
|
||||
const dropdownPlacement = ref<'top' | 'bottom'>('bottom');
|
||||
let positionFrame = 0;
|
||||
|
||||
const optionRows = computed(() =>
|
||||
props.options.map((option, index) => ({
|
||||
@@ -104,6 +111,7 @@ const candidateRows = computed<CandidateRow[]>(() => {
|
||||
});
|
||||
const activeCandidate = computed(() => candidateRows.value[activeIndex.value]);
|
||||
const activeDescendant = computed(() => activeCandidate.value?.id);
|
||||
const usesFixedDropdown = computed(() => props.dropdownStrategy === 'fixed');
|
||||
|
||||
function setDefaultActiveIndex() {
|
||||
const keyword = createName.value.toLowerCase();
|
||||
@@ -130,6 +138,8 @@ function clampActiveIndex() {
|
||||
async function openDropdown() {
|
||||
isOpen.value = true;
|
||||
await nextTick();
|
||||
updateDropdownPosition();
|
||||
addPositionListeners();
|
||||
setDefaultActiveIndex();
|
||||
searchInput.value?.focus();
|
||||
}
|
||||
@@ -138,6 +148,8 @@ function closeDropdown() {
|
||||
isOpen.value = false;
|
||||
search.value = '';
|
||||
activeIndex.value = -1;
|
||||
dropdownStyle.value = {};
|
||||
removePositionListeners();
|
||||
}
|
||||
|
||||
function toggleDropdown() {
|
||||
@@ -168,11 +180,13 @@ function selectOption(value: string) {
|
||||
updateValue([...modelValues.value, value]);
|
||||
search.value = '';
|
||||
setDefaultActiveIndex();
|
||||
scheduleDropdownPositionUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
function remove(value: string) {
|
||||
updateValue(modelValues.value.filter((item) => item !== value));
|
||||
scheduleDropdownPositionUpdate();
|
||||
}
|
||||
|
||||
function createOption() {
|
||||
@@ -225,22 +239,107 @@ function onDocumentPointerDown(event: PointerEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleDropdownPositionUpdate() {
|
||||
if (!usesFixedDropdown.value || !isOpen.value || positionFrame) {
|
||||
return;
|
||||
}
|
||||
|
||||
positionFrame = window.requestAnimationFrame(() => {
|
||||
positionFrame = 0;
|
||||
updateDropdownPosition();
|
||||
});
|
||||
}
|
||||
|
||||
function updateDropdownPosition() {
|
||||
if (!usesFixedDropdown.value || !isOpen.value || !trigger.value) {
|
||||
dropdownStyle.value = {};
|
||||
return;
|
||||
}
|
||||
|
||||
const viewportPadding = 12;
|
||||
const dropdownGap = 6;
|
||||
const dropdownChromeHeight = 72;
|
||||
const triggerRect = trigger.value.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const width = Math.min(triggerRect.width, viewportWidth - viewportPadding * 2);
|
||||
const left = Math.min(Math.max(triggerRect.left, viewportPadding), viewportWidth - width - viewportPadding);
|
||||
const spaceBelow = viewportHeight - triggerRect.bottom - viewportPadding - dropdownGap;
|
||||
const spaceAbove = triggerRect.top - viewportPadding - dropdownGap;
|
||||
const placeAbove = spaceBelow < 220 && spaceAbove > spaceBelow;
|
||||
const availableSpace = Math.max(144, placeAbove ? spaceAbove : spaceBelow);
|
||||
const optionsMaxHeight = Math.max(96, Math.min(240, availableSpace - dropdownChromeHeight));
|
||||
const nextStyle = {
|
||||
left: `${left}px`,
|
||||
width: `${width}px`,
|
||||
'--tags-select-options-max-height': `${optionsMaxHeight}px`
|
||||
} as CSSProperties;
|
||||
|
||||
if (placeAbove) {
|
||||
dropdownPlacement.value = 'top';
|
||||
dropdownStyle.value = {
|
||||
...nextStyle,
|
||||
bottom: `${viewportHeight - triggerRect.top + dropdownGap}px`
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
dropdownPlacement.value = 'bottom';
|
||||
dropdownStyle.value = {
|
||||
...nextStyle,
|
||||
top: `${triggerRect.bottom + dropdownGap}px`
|
||||
};
|
||||
}
|
||||
|
||||
function addPositionListeners() {
|
||||
if (!usesFixedDropdown.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.addEventListener('resize', scheduleDropdownPositionUpdate);
|
||||
window.addEventListener('scroll', scheduleDropdownPositionUpdate, true);
|
||||
}
|
||||
|
||||
function removePositionListeners() {
|
||||
window.removeEventListener('resize', scheduleDropdownPositionUpdate);
|
||||
window.removeEventListener('scroll', scheduleDropdownPositionUpdate, true);
|
||||
|
||||
if (positionFrame) {
|
||||
window.cancelAnimationFrame(positionFrame);
|
||||
positionFrame = 0;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('pointerdown', onDocumentPointerDown);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('pointerdown', onDocumentPointerDown);
|
||||
removePositionListeners();
|
||||
});
|
||||
|
||||
watch(search, setDefaultActiveIndex);
|
||||
watch(candidateRows, clampActiveIndex);
|
||||
watch(
|
||||
() => props.dropdownStrategy,
|
||||
() => {
|
||||
if (!isOpen.value) return;
|
||||
|
||||
removePositionListeners();
|
||||
void nextTick(() => {
|
||||
updateDropdownPosition();
|
||||
addPositionListeners();
|
||||
});
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="root" class="tags-select" :class="{ 'tags-select--single': !multiple }" @keydown="onRootKeydown">
|
||||
<button
|
||||
:id="id"
|
||||
ref="trigger"
|
||||
type="button"
|
||||
class="tags-select__trigger"
|
||||
:class="{ open: isOpen }"
|
||||
@@ -271,7 +370,15 @@ watch(candidateRows, clampActiveIndex);
|
||||
<Icon :icon="iconChevronDown" class="tags-select__arrow" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<div v-if="isOpen" class="tags-select__dropdown">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="tags-select__dropdown"
|
||||
:class="{
|
||||
'tags-select__dropdown--fixed': usesFixedDropdown,
|
||||
'tags-select__dropdown--top': usesFixedDropdown && dropdownPlacement === 'top'
|
||||
}"
|
||||
:style="dropdownStyle"
|
||||
>
|
||||
<input
|
||||
ref="searchInput"
|
||||
v-model="search"
|
||||
|
||||
@@ -230,6 +230,10 @@ const messages = {
|
||||
bodyLabel: 'Post',
|
||||
bodyPlaceholder: 'Share a thought, tip, or discovery...',
|
||||
newPost: 'New Post',
|
||||
tags: 'Tags',
|
||||
allTags: 'All',
|
||||
tagPlaceholder: 'Select tags',
|
||||
searchTags: 'Search tags',
|
||||
search: 'Search Life',
|
||||
searchPlaceholder: 'Search post content...',
|
||||
searchEmpty: 'No posts match your search',
|
||||
@@ -319,7 +323,8 @@ const messages = {
|
||||
itemCategories: 'Item categories',
|
||||
itemUsages: 'Item usages',
|
||||
acquisitionMethods: 'Acquisition methods',
|
||||
maps: 'Maps'
|
||||
maps: 'Maps',
|
||||
lifeTags: 'Life tags'
|
||||
},
|
||||
appearance: {
|
||||
time: 'Time',
|
||||
@@ -577,6 +582,10 @@ const messages = {
|
||||
bodyLabel: '动态内容',
|
||||
bodyPlaceholder: '分享一段想法、心得或发现……',
|
||||
newPost: 'New Post',
|
||||
tags: '标签',
|
||||
allTags: '全部',
|
||||
tagPlaceholder: '选择标签',
|
||||
searchTags: '搜索标签',
|
||||
search: '搜索动态',
|
||||
searchPlaceholder: '搜索动态内容……',
|
||||
searchEmpty: '没有匹配的动态',
|
||||
@@ -666,7 +675,8 @@ const messages = {
|
||||
itemCategories: '物品分类',
|
||||
itemUsages: '物品用途',
|
||||
acquisitionMethods: '入手方式',
|
||||
maps: '地图'
|
||||
maps: '地图',
|
||||
lifeTags: 'Life 标签'
|
||||
},
|
||||
appearance: {
|
||||
time: '时段',
|
||||
|
||||
@@ -183,6 +183,7 @@ export interface LifePost {
|
||||
updatedAt: string;
|
||||
author: UserSummary | null;
|
||||
updatedBy: UserSummary | null;
|
||||
tags: NamedEntity[];
|
||||
comments: LifeComment[];
|
||||
reactionCounts: LifeReactionCounts;
|
||||
myReaction: LifeReactionType | null;
|
||||
@@ -198,6 +199,7 @@ export interface LifePostsParams {
|
||||
cursor?: string | null;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
tagId?: string | number;
|
||||
}
|
||||
|
||||
export interface LifeComment {
|
||||
@@ -228,6 +230,7 @@ export interface Options {
|
||||
acquisitionMethods: NamedEntity[];
|
||||
itemTags: NamedEntity[];
|
||||
maps: NamedEntity[];
|
||||
lifeTags: NamedEntity[];
|
||||
}
|
||||
|
||||
export interface AuthUser {
|
||||
@@ -259,7 +262,8 @@ export type ConfigType =
|
||||
| 'item-categories'
|
||||
| 'item-usages'
|
||||
| 'acquisition-methods'
|
||||
| 'maps';
|
||||
| 'maps'
|
||||
| 'life-tags';
|
||||
|
||||
export interface PokemonPayload {
|
||||
id: number;
|
||||
@@ -316,6 +320,7 @@ export interface DailyChecklistPayload {
|
||||
|
||||
export interface LifePostPayload {
|
||||
body: string;
|
||||
tagIds?: number[];
|
||||
}
|
||||
|
||||
export interface LifeCommentPayload {
|
||||
@@ -466,7 +471,12 @@ export const api = {
|
||||
dailyChecklist: () => getJson<DailyChecklistItem[]>('/api/daily-checklist'),
|
||||
lifePosts: (params: LifePostsParams = {}) =>
|
||||
getJson<LifePostsPage>(
|
||||
`/api/life-posts${buildQuery({ cursor: params.cursor ?? undefined, limit: params.limit, search: params.search?.trim() })}`
|
||||
`/api/life-posts${buildQuery({
|
||||
cursor: params.cursor ?? undefined,
|
||||
limit: params.limit,
|
||||
search: params.search?.trim(),
|
||||
tagId: params.tagId
|
||||
})}`
|
||||
),
|
||||
createLifePost: (payload: LifePostPayload) => sendJson<LifePost>('/api/life-posts', 'POST', payload),
|
||||
updateLifePost: (id: string | number, payload: LifePostPayload) =>
|
||||
|
||||
@@ -798,9 +798,16 @@ button:disabled,
|
||||
box-shadow: var(--shadow-raised);
|
||||
}
|
||||
|
||||
.tags-select__dropdown--fixed {
|
||||
position: fixed;
|
||||
top: auto;
|
||||
left: auto;
|
||||
z-index: 80;
|
||||
}
|
||||
|
||||
.tags-select__options {
|
||||
display: grid;
|
||||
max-height: 240px;
|
||||
max-height: var(--tags-select-options-max-height, 240px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@@ -1200,6 +1207,10 @@ button:disabled,
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.life-tag-tabs {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.life-composer,
|
||||
.life-post {
|
||||
display: grid;
|
||||
@@ -1341,6 +1352,30 @@ button:disabled,
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.life-post__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.life-post__tag {
|
||||
min-height: 30px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 9px;
|
||||
border: 1px solid color-mix(in srgb, var(--pokemon-blue) 38%, var(--line));
|
||||
border-radius: var(--radius-small);
|
||||
background: color-mix(in srgb, var(--pokemon-blue) 9%, var(--surface));
|
||||
color: var(--pokemon-blue-deep);
|
||||
font-size: 13px;
|
||||
font-weight: 850;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
[data-theme="night"] .life-post__tag {
|
||||
color: var(--pokemon-yellow);
|
||||
}
|
||||
|
||||
.life-post__engagement {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -73,7 +73,8 @@ const configTypes = computed<Array<{ key: ConfigType; label: string; supportsIte
|
||||
{ key: 'item-categories', label: t('config.itemCategories') },
|
||||
{ key: 'item-usages', label: t('config.itemUsages') },
|
||||
{ key: 'acquisition-methods', label: t('config.acquisitionMethods') },
|
||||
{ key: 'maps', label: t('config.maps') }
|
||||
{ key: 'maps', label: t('config.maps') },
|
||||
{ key: 'life-tags', label: t('config.lifeTags') }
|
||||
]);
|
||||
|
||||
const activeTab = ref<AdminTab>('config');
|
||||
|
||||
@@ -7,6 +7,8 @@ import Modal from '../components/Modal.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
import StatusMessage from '../components/StatusMessage.vue';
|
||||
import TagsSelect from '../components/TagsSelect.vue';
|
||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||
import {
|
||||
iconAdd,
|
||||
iconCancel,
|
||||
@@ -30,11 +32,13 @@ import {
|
||||
type AuthUser,
|
||||
type LifeComment,
|
||||
type LifePost,
|
||||
type LifeReactionType
|
||||
type LifeReactionType,
|
||||
type NamedEntity
|
||||
} from '../services/api';
|
||||
|
||||
const { locale, t } = useI18n();
|
||||
const posts = ref<LifePost[]>([]);
|
||||
const lifeTags = ref<NamedEntity[]>([]);
|
||||
const currentUser = ref<AuthUser | null>(null);
|
||||
const loading = ref(true);
|
||||
const loadingMore = ref(false);
|
||||
@@ -42,7 +46,9 @@ const authReady = ref(false);
|
||||
const busy = ref(false);
|
||||
const searchDraft = ref('');
|
||||
const submittedSearch = ref('');
|
||||
const activeTagId = ref('all');
|
||||
const body = ref('');
|
||||
const selectedTagIds = ref<string[]>([]);
|
||||
const editingPostId = ref<number | null>(null);
|
||||
const postModalOpen = ref(false);
|
||||
const formError = ref('');
|
||||
@@ -67,6 +73,7 @@ let postsRequestId = 0;
|
||||
const nextCursor = ref<string | null>(null);
|
||||
const hasMorePosts = ref(false);
|
||||
const loadMorePaused = ref(false);
|
||||
const allTagValue = 'all';
|
||||
|
||||
const reactionOptions = [
|
||||
{ type: 'like', icon: iconReactionLike, labelKey: 'pages.life.reactionLike' },
|
||||
@@ -79,6 +86,14 @@ const canPost = computed(() => currentUser.value?.emailVerified === true);
|
||||
const charactersLeft = computed(() => Math.max(0, 2000 - body.value.length));
|
||||
const isEditing = computed(() => editingPostId.value !== null);
|
||||
const searchQuery = computed(() => submittedSearch.value.trim());
|
||||
const selectedFeedTagId = computed(() => {
|
||||
const tagId = Number(activeTagId.value);
|
||||
return activeTagId.value === allTagValue || !Number.isInteger(tagId) || tagId <= 0 ? undefined : tagId;
|
||||
});
|
||||
const tagTabs = computed<TabOption[]>(() => [
|
||||
{ value: allTagValue, label: t('pages.life.allTags') },
|
||||
...lifeTags.value.map((tag) => ({ value: String(tag.id), label: tag.name }))
|
||||
]);
|
||||
const postModalTitle = computed(() => (isEditing.value ? t('pages.life.editPost') : t('pages.life.newPost')));
|
||||
const submitLabel = computed(() => {
|
||||
if (busy.value) return isEditing.value ? t('pages.life.updating') : t('pages.life.publishing');
|
||||
@@ -105,6 +120,19 @@ async function loadCurrentUser() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLifeTags() {
|
||||
try {
|
||||
const options = await api.options();
|
||||
lifeTags.value = options.lifeTags;
|
||||
|
||||
if (activeTagId.value !== allTagValue && !lifeTags.value.some((tag) => String(tag.id) === activeTagId.value)) {
|
||||
activeTagId.value = allTagValue;
|
||||
}
|
||||
} catch (error) {
|
||||
loadError.value = error instanceof Error && error.message ? error.message : t('errors.loadFailed');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPosts() {
|
||||
const requestId = ++postsRequestId;
|
||||
loading.value = true;
|
||||
@@ -115,7 +143,7 @@ async function loadPosts() {
|
||||
loadMorePaused.value = false;
|
||||
|
||||
try {
|
||||
const page = await api.lifePosts({ limit: lifePostPageSize, search: searchQuery.value });
|
||||
const page = await api.lifePosts({ limit: lifePostPageSize, search: searchQuery.value, tagId: selectedFeedTagId.value });
|
||||
if (requestId !== postsRequestId) {
|
||||
return;
|
||||
}
|
||||
@@ -149,7 +177,7 @@ async function loadMorePosts() {
|
||||
loadError.value = '';
|
||||
|
||||
try {
|
||||
const page = await api.lifePosts({ cursor, limit: lifePostPageSize, search: searchQuery.value });
|
||||
const page = await api.lifePosts({ cursor, limit: lifePostPageSize, search: searchQuery.value, tagId: selectedFeedTagId.value });
|
||||
if (requestId !== postsRequestId) {
|
||||
return;
|
||||
}
|
||||
@@ -172,13 +200,15 @@ async function loadMorePosts() {
|
||||
|
||||
function resetForm() {
|
||||
body.value = '';
|
||||
selectedTagIds.value = [];
|
||||
editingPostId.value = null;
|
||||
formError.value = '';
|
||||
}
|
||||
|
||||
function payload() {
|
||||
return {
|
||||
body: body.value.trim()
|
||||
body: body.value.trim(),
|
||||
tagIds: selectedTagIds.value.map((tagId) => Number(tagId)).filter((tagId) => Number.isInteger(tagId) && tagId > 0)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -192,9 +222,12 @@ function submitSearch() {
|
||||
void loadPosts();
|
||||
}
|
||||
|
||||
function matchesCurrentSearch(post: LifePost) {
|
||||
function matchesCurrentFilters(post: LifePost) {
|
||||
const keyword = searchQuery.value.toLowerCase();
|
||||
return keyword === '' || post.body.toLowerCase().includes(keyword);
|
||||
const tagId = selectedFeedTagId.value;
|
||||
const matchesSearch = keyword === '' || post.body.toLowerCase().includes(keyword);
|
||||
const matchesTag = tagId === undefined || post.tags.some((tag) => tag.id === tagId);
|
||||
return matchesSearch && matchesTag;
|
||||
}
|
||||
|
||||
function openCreatePostModal() {
|
||||
@@ -228,7 +261,7 @@ async function submitPost() {
|
||||
replacePost(updated);
|
||||
} else {
|
||||
const created = await api.createLifePost(payload());
|
||||
if (matchesCurrentSearch(created)) {
|
||||
if (matchesCurrentFilters(created)) {
|
||||
posts.value = [created, ...posts.value];
|
||||
}
|
||||
}
|
||||
@@ -294,7 +327,7 @@ function reactionCountLabel(post: LifePost, type: LifeReactionType) {
|
||||
}
|
||||
|
||||
function replacePost(updatedPost: LifePost) {
|
||||
if (!matchesCurrentSearch(updatedPost)) {
|
||||
if (!matchesCurrentFilters(updatedPost)) {
|
||||
posts.value = posts.value.filter((post) => post.id !== updatedPost.id);
|
||||
return;
|
||||
}
|
||||
@@ -410,6 +443,7 @@ async function toggleReaction(post: LifePost, reactionType: LifeReactionType) {
|
||||
function startEdit(post: LifePost) {
|
||||
editingPostId.value = post.id;
|
||||
body.value = post.body;
|
||||
selectedTagIds.value = post.tags.map((tag) => String(tag.id));
|
||||
formError.value = '';
|
||||
postModalOpen.value = true;
|
||||
void nextTick(() => bodyInput.value?.focus());
|
||||
@@ -573,9 +607,17 @@ function observeLoadMore() {
|
||||
}
|
||||
|
||||
watch([loadMoreSentinel, hasMorePosts, loading, loadingMore, loadMorePaused], observeLoadMore, { flush: 'post' });
|
||||
watch(activeTagId, () => {
|
||||
void loadPosts();
|
||||
});
|
||||
watch(locale, () => {
|
||||
void loadLifeTags();
|
||||
void loadPosts();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
void loadCurrentUser();
|
||||
void loadLifeTags();
|
||||
void loadPosts();
|
||||
removeAuthListener = onAuthTokenChange(() => {
|
||||
void loadCurrentUser();
|
||||
@@ -615,6 +657,8 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</FilterPanel>
|
||||
|
||||
<Tabs id="life-tag-filter" v-model="activeTagId" class="life-tag-tabs" :tabs="tagTabs" :label="t('pages.life.tags')" />
|
||||
|
||||
<StatusMessage v-if="loadError" variant="danger" :duration="0">{{ loadError }}</StatusMessage>
|
||||
|
||||
<Modal
|
||||
@@ -622,6 +666,7 @@ onUnmounted(() => {
|
||||
:title="postModalTitle"
|
||||
:subtitle="t('pages.life.composerPrompt')"
|
||||
:close-label="t('common.close')"
|
||||
size="wide"
|
||||
@close="closePostModal"
|
||||
>
|
||||
<div v-if="!authReady" class="life-composer__auth-skeleton" aria-hidden="true">
|
||||
@@ -643,6 +688,18 @@ onUnmounted(() => {
|
||||
<span class="life-form__counter">{{ t('pages.life.charactersLeft', { count: charactersLeft }) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="life-post-tags">{{ t('pages.life.tags') }}</label>
|
||||
<TagsSelect
|
||||
id="life-post-tags"
|
||||
v-model="selectedTagIds"
|
||||
:options="lifeTags"
|
||||
:placeholder="t('pages.life.tagPlaceholder')"
|
||||
:search-placeholder="t('pages.life.searchTags')"
|
||||
dropdown-strategy="fixed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="formError" class="life-form__error" role="alert">{{ formError }}</p>
|
||||
|
||||
<div class="life-form__actions">
|
||||
@@ -707,6 +764,10 @@ onUnmounted(() => {
|
||||
|
||||
<p class="life-post__body">{{ post.body }}</p>
|
||||
|
||||
<div v-if="post.tags.length" class="life-post__tags" :aria-label="t('pages.life.tags')">
|
||||
<span v-for="tag in post.tags" :key="tag.id" class="life-post__tag">{{ tag.name }}</span>
|
||||
</div>
|
||||
|
||||
<div class="life-post__engagement">
|
||||
<div class="life-post__engagement-actions">
|
||||
<div class="life-reactions">
|
||||
|
||||
Reference in New Issue
Block a user