feat(threads): add real-time forum and chat system

Implement DB schema, API, and WebSocket for channels and messages
Add frontend views, AI moderation, and admin management
This commit is contained in:
2026-05-07 11:28:14 +08:00
parent 23a7301598
commit cbb101336b
16 changed files with 3567 additions and 10 deletions

View File

@@ -18,6 +18,7 @@ import {
iconLife,
iconPokemon,
iconRecipe,
iconThreads,
type AppIcon
} from './src/icons';
import { getCurrentLocale, loadSystemWordings, onLocaleChange, setCurrentLocale } from './src/i18n';
@@ -101,7 +102,8 @@ const navItems = computed<NavItem[]>(() => {
{ label: t('nav.dreamIsland'), to: '/dream-island', icon: iconDreamIsland, badge: inDevBadge() },
{ label: t('nav.clothes'), to: '/clothes', icon: iconClothes, badge: inDevBadge() },
{ label: t('nav.checklist'), to: '/checklist', icon: iconChecklist },
{ label: t('nav.life'), to: '/life', icon: iconLife }
{ label: t('nav.life'), to: '/life', icon: iconLife },
{ label: t('nav.threads'), to: '/threads', icon: iconThreads }
];
if (can('admin.access')) {

View File

@@ -0,0 +1,11 @@
<script setup lang="ts">
import ThreadsView from '../../src/views/ThreadsView.vue';
definePageMeta({
title: 'Threads'
});
</script>
<template>
<ThreadsView />
</template>

View File

@@ -0,0 +1,11 @@
<script setup lang="ts">
import ThreadsView from '../../src/views/ThreadsView.vue';
definePageMeta({
title: 'Threads'
});
</script>
<template>
<ThreadsView />
</template>

View File

@@ -50,10 +50,12 @@ 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 iconSend: AppIcon = 'mdi:send-outline';
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 iconThreads: AppIcon = 'mdi:forum-outline';
export const iconUndo: AppIcon = 'mdi:undo';
export const iconUpload: AppIcon = 'mdi:upload-outline';
export const iconVersion: AppIcon = 'mdi:tag-outline';

View File

@@ -575,6 +575,121 @@ export interface LifeReactionUsersParams {
reactionType?: LifeReactionType;
}
export type ThreadReactionType = 'thumbs-up' | 'heart' | 'laugh' | 'fire' | 'eyes';
export type ThreadReactionCounts = Record<ThreadReactionType, number>;
export type ThreadSort = 'last-active' | 'latest' | 'most-discussed';
export interface ThreadChannelTag {
id: number;
name: string;
sortOrder: number;
}
export interface ThreadChannel {
id: number;
name: string;
allowUserThreads: boolean;
sortOrder: number;
tags: ThreadChannelTag[];
languages: Array<{ code: string; name: string }>;
unreadCount: number;
}
export interface ThreadSummary {
id: number;
channelId: number;
title: string;
languageCode: string;
tags: ThreadChannelTag[];
locked: boolean;
messageCount: number;
lastActiveAt: string;
createdAt: string;
author: UserSummary | null;
reactionCounts: ThreadReactionCounts;
myReactions: ThreadReactionType[];
followed: boolean;
unread: boolean;
}
export interface ThreadMessage {
id: number;
threadId: number;
body: string;
moderationStatus: AiModerationStatus;
moderationLanguageCode: string | null;
moderationReason: string | null;
createdAt: string;
updatedAt: string;
author: UserSummary | null;
reactionCounts: ThreadReactionCounts;
myReactions: ThreadReactionType[];
}
export interface ThreadsPage {
items: ThreadSummary[];
nextCursor: string | null;
hasMore: boolean;
}
export interface ThreadMessagesPage {
items: ThreadMessage[];
beforeCursor: string | null;
hasMoreBefore: boolean;
}
export interface ThreadsParams {
cursor?: string | null;
limit?: number;
channelId?: number | string | null;
language?: string;
tagId?: number | string | null;
sort?: ThreadSort;
}
export interface ThreadMessagesParams {
before?: string | null;
limit?: number;
}
export interface ThreadPayload {
channelId: number;
title: string;
body: string;
languageCode: string;
tagIds: number[];
}
export interface ThreadMessagePayload {
body: string;
}
export interface ThreadWsTicket {
ticket: string;
expiresAt: string;
}
export type ThreadWsMessage =
| { type: 'threads.connected'; followedUnreadCount: number }
| { type: 'thread.message.created'; threadId: number; message: ThreadMessage; thread: ThreadSummary }
| { type: 'thread.message.moderation'; threadId: number; message: ThreadMessage | null }
| {
type: 'thread.reactions.updated';
target: 'thread' | 'message';
threadId: number;
messageId: number | null;
reactionCounts: ThreadReactionCounts;
myReactions: ThreadReactionType[];
}
| { type: 'thread.read.updated'; threadId: number; unread: boolean; unreadCount: number };
export interface AdminThreadChannelPayload {
name: string;
allowUserThreads: boolean;
tags: string[];
languages: string[];
}
export interface NotificationTarget {
type: NotificationTargetType;
id: number;
@@ -1087,6 +1202,15 @@ export function notificationWebSocketUrl(ticket: string): string {
return base.toString();
}
export function threadWebSocketUrl(ticket: string): string {
const base = new URL(browserApiBaseUrl, typeof window === 'undefined' ? 'http://localhost' : window.location.origin);
base.protocol = base.protocol === 'https:' ? 'wss:' : 'ws:';
base.pathname = '/api/threads/ws';
base.search = '';
base.searchParams.set('ticket', ticket);
return base.toString();
}
async function getErrorMessage(response: Response): Promise<string> {
try {
const data = (await response.json()) as { message?: unknown };
@@ -1127,7 +1251,7 @@ async function getJson<T>(path: string, options?: AbortSignal | ApiRequestOption
return response.json() as Promise<T>;
}
async function sendJson<T>(path: string, method: 'PATCH' | 'POST' | 'PUT', body: unknown): Promise<T> {
async function sendJson<T>(path: string, method: 'DELETE' | 'PATCH' | 'POST' | 'PUT', body: unknown): Promise<T> {
const headers = requestHeaders();
headers.set('Content-Type', 'application/json');
@@ -1352,6 +1476,51 @@ export const api = {
reactionType: params.reactionType
})}`
),
threadChannels: () => getJson<ThreadChannel[]>('/api/thread-channels'),
threads: (params: ThreadsParams = {}) =>
getJson<ThreadsPage>(
`/api/threads${buildQuery({
cursor: params.cursor ?? undefined,
limit: params.limit,
channelId: params.channelId,
language: params.language,
tagId: params.tagId,
sort: params.sort
})}`
),
thread: (id: string | number) => getJson<ThreadSummary>(`/api/threads/${id}`),
createThread: (payload: ThreadPayload) => sendJson<ThreadSummary>('/api/threads', 'POST', payload),
threadMessages: (id: string | number, params: ThreadMessagesParams = {}) =>
getJson<ThreadMessagesPage>(
`/api/threads/${id}/messages${buildQuery({
before: params.before ?? undefined,
limit: params.limit
})}`
),
createThreadMessage: (id: string | number, payload: ThreadMessagePayload) =>
sendJson<ThreadMessage>(`/api/threads/${id}/messages`, 'POST', payload),
followThread: (id: string | number) => sendJson<ThreadSummary>(`/api/threads/${id}/follow`, 'PUT', {}),
unfollowThread: (id: string | number) => deleteAndGetJson<ThreadSummary>(`/api/threads/${id}/follow`),
markThreadRead: (id: string | number) => sendJson<ThreadSummary>(`/api/threads/${id}/read`, 'POST', {}),
setThreadReaction: (id: string | number, reactionType: ThreadReactionType) =>
sendJson<ThreadSummary>(`/api/threads/${id}/reaction`, 'PUT', { reactionType }),
deleteThreadReaction: (id: string | number, reactionType: ThreadReactionType) =>
sendJson<ThreadSummary>(`/api/threads/${id}/reaction`, 'DELETE', { reactionType }),
setThreadMessageReaction: (id: string | number, reactionType: ThreadReactionType) =>
sendJson<ThreadMessage>(`/api/thread-messages/${id}/reaction`, 'PUT', { reactionType }),
deleteThreadMessageReaction: (id: string | number, reactionType: ThreadReactionType) =>
sendJson<ThreadMessage>(`/api/thread-messages/${id}/reaction`, 'DELETE', { reactionType }),
threadWsTicket: () => sendJson<ThreadWsTicket>('/api/threads/ws-ticket', 'POST', {}),
adminThreadChannels: () => getJson<ThreadChannel[]>('/api/admin/thread-channels'),
createAdminThreadChannel: (payload: AdminThreadChannelPayload) =>
sendJson<ThreadChannel[]>('/api/admin/thread-channels', 'POST', payload),
updateAdminThreadChannel: (id: string | number, payload: AdminThreadChannelPayload) =>
sendJson<ThreadChannel[]>(`/api/admin/thread-channels/${id}`, 'PUT', payload),
deleteAdminThreadChannel: (id: string | number) => deleteJson(`/api/admin/thread-channels/${id}`),
lockThread: (id: string | number, locked: boolean) =>
sendJson<ThreadSummary>(`/api/admin/threads/${id}/lock`, 'PUT', { locked }),
deleteThread: (id: string | number) => deleteJson(`/api/admin/threads/${id}`),
deleteThreadMessage: (id: string | number) => deleteJson(`/api/admin/thread-messages/${id}`),
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`),

View File

@@ -9689,12 +9689,366 @@ button:disabled,
font-size: 14px;
}
.threads-layout {
min-height: min(78vh, 860px);
display: grid;
grid-template-columns: minmax(180px, 220px) minmax(260px, 360px) minmax(0, 1fr);
gap: 16px;
align-items: stretch;
}
.threads-sidebar,
.threads-list-panel,
.thread-chat-panel {
min-width: 0;
border: 1px solid var(--line);
border-radius: var(--radius-card);
background: var(--surface);
box-shadow: var(--shadow-soft);
}
.threads-sidebar {
display: flex;
flex-direction: column;
gap: 8px;
padding: 14px;
}
.threads-sidebar h2 {
margin: 0 0 6px;
color: var(--ink-soft);
font-size: 14px;
font-weight: 900;
}
.thread-channel,
.thread-list-item {
width: 100%;
min-width: 0;
display: grid;
gap: 6px;
border: 1px solid transparent;
border-radius: var(--radius-control);
background: transparent;
color: var(--ink-soft);
text-align: left;
cursor: pointer;
}
.thread-channel {
min-height: 44px;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
padding: 8px 10px;
font-weight: 900;
}
.thread-channel:hover,
.thread-channel.active,
.thread-list-item:hover,
.thread-list-item.active {
border-color: color-mix(in srgb, var(--pokemon-blue) 35%, var(--line));
background: color-mix(in srgb, var(--pokemon-blue) 8%, var(--surface));
color: var(--ink);
}
.thread-unread-dot {
width: 9px;
height: 9px;
border-radius: 999px;
background: var(--pokemon-red);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--pokemon-red) 16%, transparent);
}
.threads-list-panel {
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
gap: 12px;
padding: 14px;
overflow: hidden;
}
.thread-filters {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.thread-filters label {
display: grid;
gap: 5px;
color: var(--ink-soft);
font-size: 13px;
font-weight: 900;
}
.thread-filters select,
.thread-composer textarea {
width: 100%;
}
.thread-tag-filter {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.thread-chip {
min-height: 30px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px 9px;
border: 1px solid var(--line);
border-radius: var(--radius-small);
background: var(--surface-soft);
color: var(--ink-soft);
font-size: 13px;
font-weight: 800;
}
button.thread-chip {
cursor: pointer;
}
.thread-chip.active,
.thread-list-item.unread .thread-list-item__title {
color: var(--ink);
}
.thread-chip.active {
border-color: var(--pokemon-blue);
background: color-mix(in srgb, var(--pokemon-yellow) 26%, var(--surface));
}
.thread-list {
min-height: 0;
display: grid;
align-content: start;
gap: 10px;
overflow: auto;
}
.thread-list-item {
padding: 12px;
}
.thread-list-item__title {
display: flex;
align-items: center;
gap: 8px;
color: var(--ink);
font-weight: 900;
line-height: 1.35;
}
.thread-list-item__meta,
.threads-empty,
.thread-chat-header p,
.thread-message-meta time {
color: var(--muted);
font-size: 13px;
}
.thread-list-item__tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.thread-chat-panel {
position: relative;
min-height: 620px;
display: grid;
grid-template-rows: auto auto minmax(0, 1fr) auto;
overflow: hidden;
}
.thread-chat-header {
display: flex;
justify-content: space-between;
gap: 14px;
padding: 16px;
border-bottom: 1px solid var(--line);
}
.thread-chat-header h2 {
margin: 0;
font-size: 22px;
line-height: 1.25;
}
.thread-chat-header p {
margin: 4px 0 0;
}
.thread-chat-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
align-items: center;
}
.thread-reactions {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 10px 16px;
border-bottom: 1px solid var(--line);
}
.thread-reactions--message {
padding: 0;
border-bottom: 0;
}
.thread-reaction {
min-width: 44px;
min-height: 34px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
border: 1px solid var(--line);
border-radius: var(--radius-control);
background: var(--surface-soft);
color: var(--ink-soft);
cursor: pointer;
}
.thread-reaction.active {
border-color: var(--pokemon-blue);
background: color-mix(in srgb, var(--pokemon-blue) 10%, var(--surface));
color: var(--ink);
}
.thread-message-scroll {
min-height: 0;
overflow: auto;
padding: 16px;
background: color-mix(in srgb, var(--surface-soft) 58%, var(--surface));
}
.thread-message-list {
display: grid;
gap: 16px;
}
.thread-message-group {
display: grid;
grid-template-columns: 42px minmax(0, 1fr);
gap: 10px;
}
.thread-avatar {
width: 42px;
height: 42px;
display: grid;
place-items: center;
border: 2px solid var(--line-strong);
border-radius: 50%;
background: var(--pokemon-yellow);
color: var(--pokeball-black);
font-weight: 900;
}
.thread-message-group__body {
min-width: 0;
display: grid;
gap: 6px;
}
.thread-message-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: baseline;
}
.thread-message {
display: grid;
gap: 8px;
}
.thread-message p {
margin: 0;
white-space: pre-wrap;
}
.thread-composer {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
padding: 12px;
border-top: 1px solid var(--line);
background: var(--surface);
}
.thread-jump-button {
position: absolute;
right: 18px;
bottom: 86px;
min-height: 38px;
padding: 7px 12px;
border: 1px solid var(--line-strong);
border-radius: var(--radius-control);
background: var(--pokemon-yellow);
color: var(--pokeball-black);
font-weight: 900;
box-shadow: var(--shadow-control);
}
.thread-load-older {
width: fit-content;
margin: 0 auto 14px;
}
.threads-empty {
margin: 0;
padding: 18px;
}
.threads-empty--select {
align-self: center;
justify-self: center;
}
@media (max-width: 1100px) {
.threads-layout {
grid-template-columns: minmax(180px, 220px) minmax(0, 1fr);
}
.thread-chat-panel {
grid-column: 1 / -1;
}
}
@media (max-width: 640px) {
.threads-layout,
.dish-category-summary,
.dish-card {
grid-template-columns: 1fr;
}
.thread-filters,
.thread-composer,
.thread-chat-header {
grid-template-columns: 1fr;
}
.thread-chat-header {
display: grid;
}
.thread-chat-actions {
justify-content: flex-start;
}
.thread-chat-panel {
min-height: 70dvh;
}
.dish-form-row,
.dish-form-row--3,
.dish-form-row--4 {

View File

@@ -27,6 +27,7 @@ import {
iconProfile,
iconRecipe,
iconSave,
iconThreads,
iconTranslate,
iconUpload,
type AppIcon
@@ -67,6 +68,7 @@ import {
type Skill,
type SystemWording,
type SystemWordingSurface,
type ThreadChannel,
type TranslationMap
} from '../services/api';
@@ -86,7 +88,8 @@ type AdminTab =
| 'ancientArtifacts'
| 'recipes'
| 'dish'
| 'habitats';
| 'habitats'
| 'threadChannels';
type AdminGroup = 'content' | 'configuration' | 'localization' | 'access';
type AdminNavItem = { key: AdminTab; label: string; permission: string | string[] };
type AdminNavGroup = { key: AdminGroup; label: string; items: AdminNavItem[] };
@@ -139,7 +142,8 @@ const adminTabIcons: Record<AdminTab, AppIcon> = {
ancientArtifacts: iconArtifact,
recipes: iconRecipe,
dish: iconDish,
habitats: iconHabitat
habitats: iconHabitat,
threadChannels: iconThreads
};
const { locale, t } = useI18n();
@@ -166,6 +170,7 @@ const adminNavigationGroups = computed<AdminNavGroup[]>(() => {
{ key: 'recipes', label: t('pages.admin.recipeList'), permission: ['recipes.order', 'recipes.delete'] },
{ key: 'dish', label: t('pages.admin.dishList'), permission: ['dish.create', 'dish.update', 'dish.delete', 'dish.order'] },
{ key: 'habitats', label: t('pages.admin.habitatList'), permission: ['habitats.order', 'habitats.delete'] },
{ key: 'threadChannels', label: t('pages.admin.threadChannels'), permission: 'admin.threads.channels.read' },
{ key: 'dataTools', label: t('pages.admin.dataTools'), permission: ['admin.data.export', 'admin.data.import'] }
]
},
@@ -235,6 +240,7 @@ const dishItemRows = ref<Item[]>([]);
const dishSkillRows = ref<Skill[]>([]);
const dishFlavorRows = ref<NamedEntity[]>([]);
const habitatRows = ref<Habitat[]>([]);
const threadChannelRows = ref<ThreadChannel[]>([]);
const wordingRows = ref<SystemWording[]>([]);
const aiModerationSettings = ref<AiModerationSettings | null>(null);
const rateLimitSettings = ref<RateLimitSettings | null>(null);
@@ -301,6 +307,7 @@ const userRoleForm = ref({ userId: 0, roleIds: [] as number[] });
const roleForm = ref({ id: 0, key: '', name: '', description: '', level: 100, enabled: true });
const rolePermissionForm = ref({ roleId: 0, permissionIds: [] as number[] });
const permissionForm = ref({ id: 0, key: '', name: '', description: '', category: 'General', enabled: true });
const threadChannelForm = ref({ id: 0, name: '', allowUserThreads: true, tagsText: '', languages: [] as string[] });
const editingLanguageCode = ref('');
const configModalOpen = ref(false);
const checklistModalOpen = ref(false);
@@ -312,6 +319,7 @@ const userRoleModalOpen = ref(false);
const roleModalOpen = ref(false);
const rolePermissionsModalOpen = ref(false);
const permissionModalOpen = ref(false);
const threadChannelModalOpen = ref(false);
const dataToolImportModalOpen = ref(false);
const dataToolWipeModalOpen = ref(false);
const wordingLocale = ref(getCurrentLocale());
@@ -404,6 +412,9 @@ const roleModalTitle = computed(() => (roleForm.value.id ? t('pages.admin.editRo
const permissionModalTitle = computed(() =>
permissionForm.value.id ? t('pages.admin.editPermission') : t('pages.admin.newPermission')
);
const threadChannelModalTitle = computed(() =>
threadChannelForm.value.id ? t('pages.admin.editThreadChannel') : t('pages.admin.newThreadChannel')
);
const rolePermissionsModalTitle = computed(() => t('pages.admin.rolePermissions'));
const userRoleModalTitle = computed(() => t('pages.admin.userRoles'));
const editingUser = computed(() => userRows.value.find((user) => user.id === userRoleForm.value.userId) ?? null);
@@ -693,6 +704,10 @@ function resetPermissionForm() {
permissionForm.value = { id: 0, key: '', name: '', description: '', category: 'General', enabled: true };
}
function resetThreadChannelForm() {
threadChannelForm.value = { id: 0, name: '', allowUserThreads: true, tagsText: '', languages: languageRows.value.map((language) => language.code) };
}
function selectWordingModule(module: string) {
wordingModule.value = module;
}
@@ -862,6 +877,27 @@ function closePermissionModal() {
resetPermissionForm();
}
function openNewThreadChannel() {
resetThreadChannelForm();
threadChannelModalOpen.value = true;
}
function closeThreadChannelModal() {
threadChannelModalOpen.value = false;
resetThreadChannelForm();
}
function editThreadChannel(channel: ThreadChannel) {
threadChannelForm.value = {
id: channel.id,
name: channel.name,
allowUserThreads: channel.allowUserThreads,
tagsText: channel.tags.map((tag) => tag.name).join(', '),
languages: channel.languages.map((language) => language.code)
};
threadChannelModalOpen.value = true;
}
function editLanguage(item: Language) {
editingLanguageCode.value = item.code;
languageForm.value = {
@@ -1103,6 +1139,33 @@ async function loadChecklist() {
}
}
async function loadThreadChannels() {
await loadLanguages();
threadChannelRows.value = await api.adminThreadChannels();
}
function threadChannelTagNames() {
return threadChannelForm.value.tagsText
.split(',')
.map((tag) => tag.trim())
.filter(Boolean);
}
async function saveThreadChannel() {
await run(async () => {
const payload = {
name: threadChannelForm.value.name,
allowUserThreads: threadChannelForm.value.allowUserThreads,
tags: threadChannelTagNames(),
languages: threadChannelForm.value.languages
};
threadChannelRows.value = threadChannelForm.value.id
? await api.updateAdminThreadChannel(threadChannelForm.value.id, payload)
: await api.createAdminThreadChannel(payload);
closeThreadChannelModal();
});
}
async function saveChecklistItem() {
await run(async () => {
const payload = {
@@ -1414,6 +1477,7 @@ async function loadCurrentTab(showSkeleton = false) {
if (activeTab.value === 'recipes') await loadRecipes();
if (activeTab.value === 'dish') await loadDishAdmin();
if (activeTab.value === 'habitats') await loadHabitats();
if (activeTab.value === 'threadChannels') await loadThreadChannels();
} finally {
if (showSkeleton) {
contentLoading.value = false;
@@ -1485,6 +1549,16 @@ async function removeChecklistItem(id: number) {
});
}
async function removeThreadChannel(id: number) {
await run(async () => {
await api.deleteAdminThreadChannel(id);
if (threadChannelForm.value.id === id) {
closeThreadChannelModal();
}
await loadThreadChannels();
});
}
async function removePokemon(id: number) {
await run(async () => {
await api.deletePokemon(id);
@@ -2037,6 +2111,39 @@ onMounted(() => {
</div>
</section>
<section v-else-if="canEdit && activeTab === 'threadChannels'" class="detail-section">
<div class="detail-section__header">
<h2>{{ t('pages.admin.threadChannels') }}</h2>
<button v-if="can('admin.threads.channels.create')" type="button" class="ui-button ui-button--primary ui-button--small" :disabled="busy" @click="openNewThreadChannel">
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
{{ t('common.new') }}
</button>
</div>
<ul v-if="threadChannelRows.length" class="row-list access-list">
<li v-for="channel in threadChannelRows" :key="channel.id">
<span class="access-row">
<strong>{{ channel.name }}</strong>
<span class="system-wording-row__meta">
<span class="config-flag">{{ channel.allowUserThreads ? t('pages.admin.userThreadsAllowed') : t('pages.admin.userThreadsDisabled') }}</span>
<span v-for="tag in channel.tags" :key="tag.id" class="config-flag">{{ tag.name }}</span>
<span v-for="language in channel.languages" :key="language.code" class="config-flag">{{ language.name }}</span>
</span>
</span>
<span class="row-actions">
<button v-if="can('admin.threads.channels.update')" type="button" :disabled="busy" @click="editThreadChannel(channel)">
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
{{ t('common.edit') }}
</button>
<button v-if="can('admin.threads.channels.delete')" type="button" :disabled="busy" @click="removeThreadChannel(channel.id)">
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
{{ t('common.delete') }}
</button>
</span>
</li>
</ul>
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
</section>
<section v-else-if="canEdit && activeTab === 'config'" class="detail-section">
<div class="detail-section__header">
<h2>{{ t('pages.admin.config') }}</h2>
@@ -2669,6 +2776,45 @@ onMounted(() => {
</template>
</Modal>
<Modal v-if="threadChannelModalOpen" :title="threadChannelModalTitle" :close-label="t('common.close')" @close="closeThreadChannelModal">
<form id="admin-thread-channel-form" class="modal-edit-form" @submit.prevent="saveThreadChannel">
<div class="field">
<label for="thread-channel-name">{{ t('common.name') }}</label>
<input id="thread-channel-name" v-model="threadChannelForm.name" required maxlength="80" />
</div>
<div class="check-row">
<label>
<input v-model="threadChannelForm.allowUserThreads" type="checkbox" />
{{ t('pages.admin.allowUserThreads') }}
</label>
</div>
<div class="field">
<label for="thread-channel-tags">{{ t('pages.threads.tags') }}</label>
<input id="thread-channel-tags" v-model="threadChannelForm.tagsText" :placeholder="t('pages.admin.threadTagsPlaceholder')" />
</div>
<div class="field">
<span class="field-label">{{ t('pages.threads.language') }}</span>
<div class="permission-groups">
<label v-for="language in languageRows" :key="language.code" class="data-tool-scope">
<input v-model="threadChannelForm.languages" type="checkbox" :value="language.code" />
<span>{{ language.name }}</span>
</label>
</div>
</div>
</form>
<template #footer>
<button type="submit" form="admin-thread-channel-form" class="link-button" :disabled="busy || !threadChannelForm.name.trim()">
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
{{ busy ? t('common.saving') : t('common.save') }}
</button>
<button type="button" class="plain-button" :disabled="busy" @click="closeThreadChannelModal">
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
{{ t('common.cancel') }}
</button>
</template>
</Modal>
<Modal v-if="checklistModalOpen" :title="checklistModalTitle" :close-label="t('common.close')" size="wide" @close="closeChecklistModal">
<form id="admin-checklist-form" class="modal-edit-form" @submit.prevent="saveChecklistItem">
<TranslationFields

View File

@@ -0,0 +1,774 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue';
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
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 {
iconAdd,
iconBell,
iconChevronUp,
iconComment,
iconDelete,
iconSend,
iconThreads,
type AppIcon
} from '../icons';
import {
api,
threadWebSocketUrl,
type AuthUser,
type ThreadChannel,
type ThreadChannelTag,
type ThreadMessage,
type ThreadReactionType,
type ThreadSort,
type ThreadSummary,
type ThreadWsMessage
} from '../services/api';
type MessageGroup = {
key: string;
author: ThreadMessage['author'];
createdAt: string;
messages: ThreadMessage[];
};
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const channels = ref<ThreadChannel[]>([]);
const threads = ref<ThreadSummary[]>([]);
const messages = ref<ThreadMessage[]>([]);
const currentUser = ref<AuthUser | null>(null);
const activeThread = ref<ThreadSummary | null>(null);
const selectedChannelId = ref<number | null>(null);
const selectedTagId = ref<number | null>(null);
const selectedLanguage = ref('all');
const sort = ref<ThreadSort>('last-active');
const nextCursor = ref<string | null>(null);
const hasMoreThreads = ref(false);
const beforeCursor = ref<string | null>(null);
const hasMoreBefore = ref(false);
const loading = ref(true);
const loadingThreads = ref(false);
const loadingMessages = ref(false);
const loadingOlder = ref(false);
const busy = ref(false);
const errorMessage = ref('');
const createModalOpen = ref(false);
const composerBody = ref('');
const threadForm = ref({ title: '', body: '', languageCode: '', tagIds: [] as number[] });
const socket = ref<WebSocket | null>(null);
const chatScroller = ref<HTMLElement | null>(null);
const showJump = ref(false);
const reactionOptions: Array<{ type: ThreadReactionType; label: string; icon: AppIcon }> = [
{ type: 'thumbs-up', label: '👍', icon: 'mdi:thumb-up-outline' },
{ type: 'heart', label: '❤️', icon: 'mdi:heart-outline' },
{ type: 'laugh', label: '😂', icon: 'mdi:emoticon-lol-outline' },
{ type: 'fire', label: '🔥', icon: 'mdi:fire' },
{ type: 'eyes', label: '👀', icon: 'mdi:eye-outline' }
];
const selectedChannel = computed(() => channels.value.find((channel) => channel.id === selectedChannelId.value) ?? null);
const activeThreadId = computed(() => {
const value = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id;
const id = Number(value);
return Number.isInteger(id) && id > 0 ? id : null;
});
const canCreateThread = computed(() => currentUser.value?.permissions.includes('threads.create') === true);
const canCreateMessage = computed(() => currentUser.value?.permissions.includes('threads.messages.create') === true);
const canFollow = computed(() => currentUser.value?.permissions.includes('threads.follow') === true);
const canReact = computed(() => currentUser.value?.permissions.includes('threads.reactions.set') === true);
const canLockThreads = computed(() => currentUser.value?.permissions.includes('admin.threads.threads.lock') === true);
const canDeleteThreads = computed(() => currentUser.value?.permissions.includes('admin.threads.threads.delete') === true);
const canDeleteMessages = computed(() => currentUser.value?.permissions.includes('admin.threads.messages.delete') === true);
const languageOptions = computed(() => {
const channelLanguages = selectedChannel.value?.languages ?? channels.value.flatMap((channel) => channel.languages);
const byCode = new Map(channelLanguages.map((language) => [language.code, language]));
return [...byCode.values()];
});
const tagOptions = computed(() => selectedChannel.value?.tags ?? []);
const currentThreadList = computed(() => threads.value);
const messageGroups = computed<MessageGroup[]>(() => {
const groups: MessageGroup[] = [];
for (const message of messages.value) {
const previous = groups.at(-1);
const previousMessage = previous?.messages.at(-1);
const sameAuthor = previousMessage?.author?.id === message.author?.id;
const withinMergeWindow =
previousMessage && new Date(message.createdAt).getTime() - new Date(previousMessage.createdAt).getTime() <= 5 * 60 * 1000;
if (previous && sameAuthor && withinMergeWindow) {
previous.messages.push(message);
} else {
groups.push({ key: String(message.id), author: message.author, createdAt: message.createdAt, messages: [message] });
}
}
return groups;
});
function canUseThreads() {
return currentUser.value?.emailVerified === true;
}
function formatDateTime(value: string) {
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value));
}
function authorName(author: ThreadMessage['author']) {
return author?.displayName ?? t('pages.life.byUnknown');
}
function authorInitial(author: ThreadMessage['author']) {
return authorName(author).trim().charAt(0).toUpperCase() || '?';
}
function reactionCount(threadOrMessage: ThreadSummary | ThreadMessage, type: ThreadReactionType) {
return threadOrMessage.reactionCounts[type] ?? 0;
}
function reactionActive(threadOrMessage: ThreadSummary | ThreadMessage, type: ThreadReactionType) {
return threadOrMessage.myReactions.includes(type);
}
function updateThreadInList(thread: ThreadSummary) {
const index = threads.value.findIndex((item) => item.id === thread.id);
if (index >= 0) {
threads.value[index] = thread;
} else {
threads.value = [thread, ...threads.value];
}
if (activeThread.value?.id === thread.id) {
activeThread.value = thread;
}
}
function updateMessageInList(message: ThreadMessage) {
const index = messages.value.findIndex((item) => item.id === message.id);
if (index >= 0) {
messages.value[index] = message;
} else {
messages.value = [...messages.value, message];
}
}
function isNearBottom() {
const el = chatScroller.value;
return !el || el.scrollHeight - el.scrollTop - el.clientHeight < 80;
}
async function scrollToBottom() {
await nextTick();
const el = chatScroller.value;
if (el) {
el.scrollTop = el.scrollHeight;
}
showJump.value = false;
}
async function loadCurrentUser() {
try {
currentUser.value = (await api.me()).user;
} catch {
currentUser.value = null;
}
}
async function loadChannels() {
channels.value = await api.threadChannels();
if (!selectedChannelId.value && channels.value[0]) {
selectedChannelId.value = channels.value[0].id;
}
if (!threadForm.value.languageCode) {
threadForm.value.languageCode = channels.value[0]?.languages[0]?.code ?? 'en';
}
}
async function loadThreads(reset = true) {
loadingThreads.value = true;
try {
const page = await api.threads({
cursor: reset ? null : nextCursor.value,
limit: 20,
channelId: selectedChannelId.value,
language: selectedLanguage.value,
tagId: selectedTagId.value,
sort: sort.value
});
threads.value = reset ? page.items : [...threads.value, ...page.items];
nextCursor.value = page.nextCursor;
hasMoreThreads.value = page.hasMore;
} finally {
loadingThreads.value = false;
}
}
async function loadActiveThread() {
const id = activeThreadId.value;
if (!id) {
activeThread.value = null;
messages.value = [];
return;
}
activeThread.value = await api.thread(id);
updateThreadInList(activeThread.value);
}
async function loadMessages(reset = true) {
const id = activeThreadId.value;
if (!id) return;
if (reset) {
loadingMessages.value = true;
} else {
loadingOlder.value = true;
}
try {
const page = await api.threadMessages(id, { before: reset ? null : beforeCursor.value, limit: 40 });
messages.value = reset ? page.items : [...page.items, ...messages.value];
beforeCursor.value = page.beforeCursor;
hasMoreBefore.value = page.hasMoreBefore;
if (reset) {
await scrollToBottom();
if (canFollow.value) {
try {
activeThread.value = await api.markThreadRead(id);
updateThreadInList(activeThread.value);
} catch {
// Read state is best-effort and does not block browsing.
}
}
}
} finally {
loadingMessages.value = false;
loadingOlder.value = false;
}
}
async function loadAll() {
loading.value = true;
errorMessage.value = '';
try {
await loadCurrentUser();
await loadChannels();
await loadThreads(true);
if (activeThreadId.value) {
await loadActiveThread();
await loadMessages(true);
}
connectSocket();
} catch (error) {
errorMessage.value = error instanceof Error && error.message ? error.message : t('errors.loadFailed');
} finally {
loading.value = false;
}
}
async function selectThread(thread: ThreadSummary) {
await router.push(`/threads/${thread.id}`);
}
function selectChannel(channelId: number | null) {
selectedChannelId.value = channelId;
selectedTagId.value = null;
void loadThreads(true);
}
function openCreateThread() {
const channel = selectedChannel.value ?? channels.value[0];
threadForm.value = {
title: '',
body: '',
languageCode: channel?.languages[0]?.code ?? 'en',
tagIds: []
};
createModalOpen.value = true;
}
function closeCreateThread() {
createModalOpen.value = false;
}
function toggleThreadTag(tag: ThreadChannelTag) {
const tags = new Set(threadForm.value.tagIds);
if (tags.has(tag.id)) {
tags.delete(tag.id);
} else {
tags.add(tag.id);
}
threadForm.value.tagIds = [...tags];
}
async function submitThread() {
const channel = selectedChannel.value ?? channels.value[0];
if (!channel) return;
busy.value = true;
errorMessage.value = '';
try {
const thread = await api.createThread({
channelId: channel.id,
title: threadForm.value.title,
body: threadForm.value.body,
languageCode: threadForm.value.languageCode,
tagIds: threadForm.value.tagIds
});
closeCreateThread();
updateThreadInList(thread);
await router.push(`/threads/${thread.id}`);
} catch (error) {
errorMessage.value = error instanceof Error && error.message ? error.message : t('pages.threads.createFailed');
} finally {
busy.value = false;
}
}
async function submitMessage() {
const id = activeThreadId.value;
if (!id || !composerBody.value.trim()) return;
busy.value = true;
errorMessage.value = '';
try {
const message = await api.createThreadMessage(id, { body: composerBody.value });
composerBody.value = '';
updateMessageInList(message);
await scrollToBottom();
} catch (error) {
errorMessage.value = error instanceof Error && error.message ? error.message : t('pages.threads.messageFailed');
} finally {
busy.value = false;
}
}
async function toggleFollow() {
const thread = activeThread.value;
if (!thread) return;
busy.value = true;
try {
const updated = thread.followed ? await api.unfollowThread(thread.id) : await api.followThread(thread.id);
updateThreadInList(updated);
} catch (error) {
errorMessage.value = error instanceof Error && error.message ? error.message : t('pages.threads.followFailed');
} finally {
busy.value = false;
}
}
async function toggleThreadReaction(thread: ThreadSummary, type: ThreadReactionType) {
if (!canReact.value) return;
try {
const updated = reactionActive(thread, type) ? await api.deleteThreadReaction(thread.id, type) : await api.setThreadReaction(thread.id, type);
updateThreadInList(updated);
} catch (error) {
errorMessage.value = error instanceof Error && error.message ? error.message : t('pages.threads.reactionFailed');
}
}
async function toggleThreadLock() {
const thread = activeThread.value;
if (!thread) return;
busy.value = true;
try {
const updated = await api.lockThread(thread.id, !thread.locked);
updateThreadInList(updated);
} catch (error) {
errorMessage.value = error instanceof Error && error.message ? error.message : t('errors.operationFailed');
} finally {
busy.value = false;
}
}
async function removeActiveThread() {
const thread = activeThread.value;
if (!thread) return;
busy.value = true;
try {
await api.deleteThread(thread.id);
threads.value = threads.value.filter((item) => item.id !== thread.id);
activeThread.value = null;
messages.value = [];
await router.push('/threads');
} catch (error) {
errorMessage.value = error instanceof Error && error.message ? error.message : t('errors.operationFailed');
} finally {
busy.value = false;
}
}
async function removeMessage(message: ThreadMessage) {
busy.value = true;
try {
await api.deleteThreadMessage(message.id);
messages.value = messages.value.filter((item) => item.id !== message.id);
} catch (error) {
errorMessage.value = error instanceof Error && error.message ? error.message : t('errors.operationFailed');
} finally {
busy.value = false;
}
}
async function toggleMessageReaction(message: ThreadMessage, type: ThreadReactionType) {
if (!canReact.value) return;
try {
const updated = reactionActive(message, type)
? await api.deleteThreadMessageReaction(message.id, type)
: await api.setThreadMessageReaction(message.id, type);
updateMessageInList(updated);
} catch (error) {
errorMessage.value = error instanceof Error && error.message ? error.message : t('pages.threads.reactionFailed');
}
}
function handleThreadWsMessage(message: ThreadWsMessage) {
if (message.type === 'thread.message.created') {
updateThreadInList(message.thread);
if (activeThreadId.value === message.threadId) {
const stick = isNearBottom();
updateMessageInList(message.message);
if (stick) {
void scrollToBottom();
} else {
showJump.value = true;
}
}
} else if (message.type === 'thread.reactions.updated') {
if (message.target === 'thread') {
const thread = threads.value.find((item) => item.id === message.threadId);
if (thread) {
updateThreadInList({ ...thread, reactionCounts: message.reactionCounts, myReactions: message.myReactions });
}
} else if (message.messageId) {
const existing = messages.value.find((item) => item.id === message.messageId);
if (existing) {
updateMessageInList({ ...existing, reactionCounts: message.reactionCounts, myReactions: message.myReactions });
}
}
} else if (message.type === 'thread.read.updated') {
const thread = threads.value.find((item) => item.id === message.threadId);
if (thread) {
updateThreadInList({ ...thread, unread: message.unread });
}
}
}
async function connectSocket() {
if (!canUseThreads() || socket.value) return;
try {
const { ticket } = await api.threadWsTicket();
const nextSocket = new WebSocket(threadWebSocketUrl(ticket));
socket.value = nextSocket;
nextSocket.addEventListener('message', (event) => {
try {
handleThreadWsMessage(JSON.parse(String(event.data)) as ThreadWsMessage);
} catch {
// Invalid socket frames are ignored.
}
});
nextSocket.addEventListener('close', () => {
if (socket.value === nextSocket) {
socket.value = null;
}
});
} catch {
socket.value = null;
}
}
function onScroll() {
showJump.value = !isNearBottom();
}
watch([selectedLanguage, selectedTagId, sort], () => {
void loadThreads(true);
});
watch(activeThreadId, async () => {
errorMessage.value = '';
await loadActiveThread();
await loadMessages(true);
});
onMounted(() => {
void loadAll();
});
onBeforeUnmount(() => {
socket.value?.close();
});
</script>
<template>
<section class="page-stack threads-page">
<PageHeader :title="t('pages.threads.title')" :subtitle="t('pages.threads.subtitle')">
<template #kicker>{{ t('pages.threads.kicker') }}</template>
<button v-if="canCreateThread" type="button" class="ui-button ui-button--primary" @click="openCreateThread">
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
{{ t('pages.threads.newThread') }}
</button>
</PageHeader>
<StatusMessage v-if="errorMessage" variant="warning">{{ errorMessage }}</StatusMessage>
<div class="threads-layout">
<aside class="threads-sidebar" :aria-label="t('pages.threads.channels')">
<h2>{{ t('pages.threads.channels') }}</h2>
<button
type="button"
class="thread-channel"
:class="{ active: selectedChannelId === null }"
@click="selectChannel(null)"
>
<Icon :icon="iconThreads" class="ui-icon" aria-hidden="true" />
<span>{{ t('pages.threads.allChannels') }}</span>
</button>
<button
v-for="channel in channels"
:key="channel.id"
type="button"
class="thread-channel"
:class="{ active: selectedChannelId === channel.id }"
@click="selectChannel(channel.id)"
>
<Icon :icon="iconComment" class="ui-icon" aria-hidden="true" />
<span>{{ channel.name }}</span>
<span v-if="channel.unreadCount > 0" class="thread-unread-dot" :aria-label="t('pages.threads.unread')"></span>
</button>
</aside>
<section class="threads-list-panel">
<div class="thread-filters">
<label>
<span>{{ t('pages.threads.language') }}</span>
<select v-model="selectedLanguage">
<option value="all">{{ t('pages.threads.allLanguages') }}</option>
<option v-for="language in languageOptions" :key="language.code" :value="language.code">{{ language.name }}</option>
</select>
</label>
<label>
<span>{{ t('pages.threads.sort') }}</span>
<select v-model="sort">
<option value="last-active">{{ t('pages.threads.sortLastActive') }}</option>
<option value="latest">{{ t('pages.threads.sortLatest') }}</option>
<option value="most-discussed">{{ t('pages.threads.sortMostDiscussed') }}</option>
</select>
</label>
</div>
<div v-if="tagOptions.length" class="thread-tag-filter" :aria-label="t('pages.threads.tags')">
<button type="button" class="thread-chip" :class="{ active: selectedTagId === null }" @click="selectedTagId = null">
{{ t('common.all') }}
</button>
<button
v-for="tag in tagOptions"
:key="tag.id"
type="button"
class="thread-chip"
:class="{ active: selectedTagId === tag.id }"
@click="selectedTagId = tag.id"
>
{{ tag.name }}
</button>
</div>
<div v-if="loading || loadingThreads" class="thread-list" aria-busy="true">
<article v-for="index in 4" :key="index" class="thread-list-item">
<Skeleton width="70%" height="20px" />
<Skeleton width="45%" />
</article>
</div>
<div v-else-if="currentThreadList.length" class="thread-list">
<button
v-for="thread in currentThreadList"
:key="thread.id"
type="button"
class="thread-list-item"
:class="{ active: activeThread?.id === thread.id, unread: thread.unread }"
@click="selectThread(thread)"
>
<span class="thread-list-item__title">
<span v-if="thread.unread" class="thread-unread-dot" :aria-label="t('pages.threads.unread')"></span>
{{ thread.title }}
</span>
<span class="thread-list-item__meta">
{{ thread.author?.displayName ?? t('pages.life.byUnknown') }} · {{ formatDateTime(thread.lastActiveAt) }}
</span>
<span class="thread-list-item__tags">
<span v-for="tag in thread.tags" :key="tag.id" class="thread-chip">{{ tag.name }}</span>
<span class="thread-chip">{{ thread.messageCount }}</span>
</span>
</button>
<button v-if="hasMoreThreads" type="button" class="ui-button ui-button--ghost" :disabled="loadingThreads" @click="loadThreads(false)">
{{ t('pages.threads.loadMoreThreads') }}
</button>
</div>
<p v-else class="threads-empty">{{ t('pages.threads.noThreads') }}</p>
</section>
<section class="thread-chat-panel">
<template v-if="activeThread">
<header class="thread-chat-header">
<div>
<h2>{{ activeThread.title }}</h2>
<p>
{{ activeThread.author?.displayName ?? t('pages.life.byUnknown') }} · {{ formatDateTime(activeThread.createdAt) }}
</p>
</div>
<div class="thread-chat-actions">
<button v-if="canFollow" type="button" class="ui-button ui-button--small" :disabled="busy" @click="toggleFollow">
<Icon :icon="iconBell" class="ui-icon" aria-hidden="true" />
{{ activeThread.followed ? t('pages.threads.unfollow') : t('pages.threads.follow') }}
</button>
<button v-if="canLockThreads" type="button" class="ui-button ui-button--small" :disabled="busy" @click="toggleThreadLock">
{{ activeThread.locked ? t('pages.threads.unlock') : t('pages.threads.lock') }}
</button>
<button v-if="canDeleteThreads" type="button" class="ui-button ui-button--red ui-button--small" :disabled="busy" @click="removeActiveThread">
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
{{ t('common.delete') }}
</button>
<span v-if="activeThread.locked" class="config-flag">{{ t('pages.threads.locked') }}</span>
</div>
</header>
<div class="thread-reactions">
<button
v-for="option in reactionOptions"
:key="option.type"
type="button"
class="thread-reaction"
:class="{ active: reactionActive(activeThread, option.type) }"
:disabled="!canReact"
@click="toggleThreadReaction(activeThread, option.type)"
>
<Icon :icon="option.icon" class="ui-icon" aria-hidden="true" />
<span>{{ reactionCount(activeThread, option.type) }}</span>
</button>
</div>
<div ref="chatScroller" class="thread-message-scroll" @scroll="onScroll">
<button
v-if="hasMoreBefore"
type="button"
class="ui-button ui-button--ghost thread-load-older"
:disabled="loadingOlder"
@click="loadMessages(false)"
>
<Icon :icon="iconChevronUp" class="ui-icon" aria-hidden="true" />
{{ t('pages.threads.loadOlder') }}
</button>
<div v-if="loadingMessages" class="thread-message-list" aria-busy="true">
<article v-for="index in 4" :key="index" class="thread-message-group">
<Skeleton width="160px" />
<Skeleton width="90%" />
</article>
</div>
<div v-else-if="messages.length" class="thread-message-list">
<article v-for="group in messageGroups" :key="group.key" class="thread-message-group">
<div class="thread-avatar" aria-hidden="true">{{ authorInitial(group.author) }}</div>
<div class="thread-message-group__body">
<div class="thread-message-meta">
<strong>{{ authorName(group.author) }}</strong>
<time :datetime="group.createdAt">{{ formatDateTime(group.createdAt) }}</time>
</div>
<div v-for="message in group.messages" :key="message.id" class="thread-message">
<p>{{ message.body }}</p>
<span v-if="message.moderationStatus === 'reviewing'" class="config-flag">{{ t('pages.threads.messageReviewing') }}</span>
<span v-else-if="message.moderationStatus === 'rejected' || message.moderationStatus === 'failed'" class="config-flag">
{{ t('pages.threads.messageRejected') }}
</span>
<div class="thread-reactions thread-reactions--message">
<button
v-for="option in reactionOptions"
:key="option.type"
type="button"
class="thread-reaction"
:class="{ active: reactionActive(message, option.type) }"
:disabled="!canReact || message.moderationStatus !== 'approved'"
@click="toggleMessageReaction(message, option.type)"
>
<Icon :icon="option.icon" class="ui-icon" aria-hidden="true" />
<span>{{ reactionCount(message, option.type) }}</span>
</button>
<button
v-if="canDeleteMessages"
type="button"
class="thread-reaction"
:disabled="busy"
:aria-label="t('common.delete')"
@click="removeMessage(message)"
>
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
</button>
</div>
</div>
</div>
</article>
</div>
<p v-else class="threads-empty">{{ t('pages.threads.noMessages') }}</p>
</div>
<button v-if="showJump" type="button" class="thread-jump-button" @click="scrollToBottom">
{{ t('pages.threads.jumpToPresent') }}
</button>
<form class="thread-composer" @submit.prevent="submitMessage">
<label class="sr-only" for="thread-message-body">{{ t('pages.threads.message') }}</label>
<textarea
id="thread-message-body"
v-model="composerBody"
rows="2"
:disabled="busy || !canCreateMessage || activeThread.locked"
:placeholder="t('pages.threads.message')"
></textarea>
<button class="ui-button ui-button--primary" type="submit" :disabled="busy || !composerBody.trim() || !canCreateMessage || activeThread.locked">
<Icon :icon="iconSend" class="ui-icon" aria-hidden="true" />
{{ busy ? t('pages.threads.sending') : t('pages.threads.send') }}
</button>
</form>
</template>
<p v-else class="threads-empty threads-empty--select">{{ t('pages.threads.selectThread') }}</p>
</section>
</div>
<Modal v-if="createModalOpen" :title="t('pages.threads.newThread')" :close-label="t('common.close')" @close="closeCreateThread">
<form class="modal-edit-form" @submit.prevent="submitThread">
<div class="field">
<label for="thread-title">{{ t('pages.threads.threadTitle') }}</label>
<input id="thread-title" v-model="threadForm.title" required maxlength="140" :disabled="busy" />
</div>
<div class="field">
<label for="thread-language">{{ t('pages.threads.language') }}</label>
<select id="thread-language" v-model="threadForm.languageCode" :disabled="busy">
<option v-for="language in languageOptions" :key="language.code" :value="language.code">{{ language.name }}</option>
</select>
</div>
<div v-if="tagOptions.length" class="field">
<span class="field-label">{{ t('pages.threads.tags') }}</span>
<div class="thread-tag-filter">
<button
v-for="tag in tagOptions"
:key="tag.id"
type="button"
class="thread-chip"
:class="{ active: threadForm.tagIds.includes(tag.id) }"
:disabled="busy"
@click="toggleThreadTag(tag)"
>
{{ tag.name }}
</button>
</div>
</div>
<div class="field">
<label for="thread-body">{{ t('pages.threads.firstMessage') }}</label>
<textarea id="thread-body" v-model="threadForm.body" rows="5" required maxlength="2000" :disabled="busy"></textarea>
</div>
<button class="ui-button ui-button--primary" type="submit" :disabled="busy || !threadForm.title.trim() || !threadForm.body.trim()">
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
{{ busy ? t('common.saving') : t('common.create') }}
</button>
</form>
</Modal>
</section>
</template>