feat(life): add community feed for user posts

Add life_posts schema and CRUD API endpoints
Implement LifeView with inline composer and feed display
This commit is contained in:
2026-05-01 21:03:09 +08:00
parent 49aae3bd7c
commit cd1891cc82
11 changed files with 680 additions and 2 deletions

View File

@@ -5,7 +5,7 @@
- Pokopia Wiki 是一个面向 Pokopia 游戏资料的社区 Wiki。 - Pokopia Wiki 是一个面向 Pokopia 游戏资料的社区 Wiki。
- 所有人都可以浏览 Wiki 内容。 - 所有人都可以浏览 Wiki 内容。
- 已注册并完成邮箱验证的用户可以创建、编辑、删除 Wiki 内容。 - 已注册并完成邮箱验证的用户可以创建、编辑、删除 Wiki 内容。
- 前台以 Pokemon、栖息地、物品、材料单、每日 CheckList 为主要浏览入口。 - 前台以 Pokemon、栖息地、物品、材料单、每日 CheckList、Life 为主要浏览入口。
- 管理入口用于维护全局配置、语言、列表排序和每日 CheckList。 - 管理入口用于维护全局配置、语言、列表排序和每日 CheckList。
## 技术栈 ## 技术栈
@@ -354,6 +354,30 @@ Pokemon 出现配置:
- 已验证用户可新增、编辑、删除 Task。 - 已验证用户可新增、编辑、删除 Task。
- 已验证用户可通过 Handle 拖拽排序。 - 已验证用户可通过 Handle 拖拽排序。
## Life
Life 是社区生活分享信息流,类似轻量社交动态。
Life Post 可配置:
- Post 内容正文
- 创建者、最后编辑者、创建时间、最后编辑时间
前台行为:
- 所有人都可以浏览 Life 信息流。
- 信息流按创建时间倒序展示。
- 已注册并完成邮箱验证的用户可以发布 Life Post。
- 作者本人可以编辑、删除自己的 Life Post。
- 当前没有点赞、评论、图片上传、转发、分页、置顶或单独审核流程。
- Life Post 是用户生成内容,正文按作者输入展示,不进入 `entity_translations`
API 暴露边界:
- Life Post 作者信息只返回 `id``displayName`
- API 不返回邮箱、token/hash、内部调试字段或不必要的审计 payload。
- 非作者不能编辑或删除其他用户的 Life Post。
## 前端交互与 UI ## 前端交互与 UI
- UI 风格以 `DesignGuidelines.html` 为准。 - UI 风格以 `DesignGuidelines.html` 为准。
@@ -372,6 +396,7 @@ Pokemon 出现配置:
- `/items/:id/edit` - `/items/:id/edit`
- `/recipes/new` - `/recipes/new`
- `/recipes/:id/edit` - `/recipes/:id/edit`
- Life 使用信息流内联发布与编辑,不使用路由驱动 Modal。
- 进入或关闭编辑 Modal 时应保留底层页面上下文,不进行不必要的滚动跳转。 - 进入或关闭编辑 Modal 时应保留底层页面上下文,不进行不必要的滚动跳转。
- 用户界面不得展示内部字段名、调试数据、计划说明或“已修改某字段”一类实现说明。 - 用户界面不得展示内部字段名、调试数据、计划说明或“已修改某字段”一类实现说明。
@@ -390,6 +415,7 @@ Pokemon 出现配置:
- `GET /api/items/:id` - `GET /api/items/:id`
- `GET /api/recipes` - `GET /api/recipes`
- `GET /api/recipes/:id` - `GET /api/recipes/:id`
- `GET /api/life-posts`
认证 API 认证 API
@@ -402,6 +428,10 @@ Pokemon 出现配置:
已验证用户编辑 API 已验证用户编辑 API
- Pokemon、栖息地、物品、材料单的创建、更新、删除。 - Pokemon、栖息地、物品、材料单的创建、更新、删除。
- Life Post 的创建,以及作者本人对 Life Post 的更新、删除。
- `POST /api/life-posts`
- `PUT /api/life-posts/:id`
- `DELETE /api/life-posts/:id`
- 每日 CheckList 的创建、更新、删除、排序。 - 每日 CheckList 的创建、更新、删除、排序。
- 全局配置项的创建、更新、删除、排序。 - 全局配置项的创建、更新、删除、排序。
- 语言的创建、更新、删除、排序。 - 语言的创建、更新、删除、排序。

View File

@@ -119,6 +119,21 @@ CREATE TABLE IF NOT EXISTS daily_checklist_items (
CREATE INDEX IF NOT EXISTS daily_checklist_items_sort_order_idx CREATE INDEX IF NOT EXISTS daily_checklist_items_sort_order_idx
ON daily_checklist_items(sort_order, id); ON daily_checklist_items(sort_order, id);
CREATE TABLE IF NOT EXISTS life_posts (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
body text NOT NULL CHECK (length(body) BETWEEN 1 AND 2000),
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE life_posts DROP COLUMN IF EXISTS link_url;
ALTER TABLE life_posts DROP COLUMN IF EXISTS link_title;
CREATE INDEX IF NOT EXISTS life_posts_created_at_idx
ON life_posts(created_at DESC, id DESC);
CREATE TABLE IF NOT EXISTS skills ( CREATE TABLE IF NOT EXISTS skills (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name text NOT NULL UNIQUE, name text NOT NULL UNIQUE,

View File

@@ -104,6 +104,10 @@ type DailyChecklistPayload = {
translations: TranslationInput; translations: TranslationInput;
}; };
type LifePostPayload = {
body: string;
};
type HabitatPayload = { type HabitatPayload = {
name: string; name: string;
translations: TranslationInput; translations: TranslationInput;
@@ -1168,6 +1172,99 @@ export async function deleteDailyChecklistItem(id: number, userId: number) {
}); });
} }
function cleanLifePostPayload(payload: Record<string, unknown>): LifePostPayload {
const body = cleanName(payload.body, 'Please enter a post');
if (body.length > 2000) {
throw validationError('Post is too long');
}
return { body };
}
function lifePostProjection(): string {
return `
SELECT
lp.id,
lp.body,
lp.created_at AS "createdAt",
lp.updated_at AS "updatedAt",
CASE
WHEN created_user.id IS NULL THEN NULL
ELSE json_build_object('id', created_user.id, 'displayName', created_user.display_name)
END AS author,
CASE
WHEN updated_user.id IS NULL THEN NULL
ELSE json_build_object('id', updated_user.id, 'displayName', updated_user.display_name)
END AS "updatedBy"
FROM life_posts lp
LEFT JOIN users created_user ON created_user.id = lp.created_by_user_id
LEFT JOIN users updated_user ON updated_user.id = lp.updated_by_user_id
`;
}
export function listLifePosts() {
return query(`
${lifePostProjection()}
ORDER BY lp.created_at DESC, lp.id DESC
`);
}
async function getLifePostById(id: number) {
return queryOne(
`
${lifePostProjection()}
WHERE lp.id = $1
`,
[id]
);
}
export async function createLifePost(payload: Record<string, unknown>, userId: number) {
const cleanPayload = cleanLifePostPayload(payload);
const result = await queryOne<{ id: number }>(
`
INSERT INTO life_posts (body, created_by_user_id, updated_by_user_id)
VALUES ($1, $2, $2)
RETURNING id
`,
[cleanPayload.body, userId]
);
return getLifePostById(result?.id ?? 0);
}
export async function updateLifePost(id: number, payload: Record<string, unknown>, userId: number) {
const cleanPayload = cleanLifePostPayload(payload);
const result = await queryOne<{ id: number }>(
`
UPDATE life_posts
SET body = $1, updated_by_user_id = $2, updated_at = now()
WHERE id = $3
AND created_by_user_id = $2
RETURNING id
`,
[cleanPayload.body, userId, id]
);
return result ? getLifePostById(result.id) : null;
}
export async function deleteLifePost(id: number, userId: number) {
const result = await queryOne<{ id: number }>(
`
DELETE FROM life_posts
WHERE id = $1
AND created_by_user_id = $2
RETURNING id
`,
[id, userId]
);
return Boolean(result);
}
export function isConfigType(type: string): type is ConfigType { export function isConfigType(type: string): type is ConfigType {
return Object.hasOwn(configDefinitions, type); return Object.hasOwn(configDefinitions, type);
} }

View File

@@ -10,6 +10,7 @@ import {
createHabitat, createHabitat,
createItem, createItem,
createLanguage, createLanguage,
createLifePost,
createPokemon, createPokemon,
createRecipe, createRecipe,
deleteConfig, deleteConfig,
@@ -17,6 +18,7 @@ import {
deleteHabitat, deleteHabitat,
deleteItem, deleteItem,
deleteLanguage, deleteLanguage,
deleteLifePost,
deletePokemon, deletePokemon,
deleteRecipe, deleteRecipe,
getHabitat, getHabitat,
@@ -30,6 +32,7 @@ import {
listHabitats, listHabitats,
listItems, listItems,
listLanguages, listLanguages,
listLifePosts,
listPokemon, listPokemon,
listRecipes, listRecipes,
reorderConfig, reorderConfig,
@@ -44,6 +47,7 @@ import {
updateHabitat, updateHabitat,
updateItem, updateItem,
updateLanguage, updateLanguage,
updateLifePost,
updatePokemon, updatePokemon,
updateRecipe updateRecipe
} from './queries.ts'; } from './queries.ts';
@@ -171,6 +175,33 @@ app.get('/api/options', async (request) => getOptions(requestLocale(request)));
app.get('/api/daily-checklist', async (request) => listDailyChecklistItems(requestLocale(request))); app.get('/api/daily-checklist', async (request) => listDailyChecklistItems(requestLocale(request)));
app.get('/api/life-posts', async () => listLifePosts());
app.post('/api/life-posts', async (request, reply) => {
const user = await requireVerifiedUser(request, reply);
return user ? reply.code(201).send(await createLifePost(request.body as Record<string, unknown>, user.id)) : undefined;
});
app.put('/api/life-posts/:id', async (request, reply) => {
const user = await requireVerifiedUser(request, reply);
if (!user) {
return;
}
const { id } = request.params as { id: string };
const post = await updateLifePost(Number(id), request.body as Record<string, unknown>, user.id);
return post ? post : reply.code(404).send({ message: 'Not found' });
});
app.delete('/api/life-posts/:id', async (request, reply) => {
const user = await requireVerifiedUser(request, reply);
if (!user) {
return;
}
const { id } = request.params as { id: string };
const deleted = await deleteLifePost(Number(id), user.id);
return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' });
});
app.get('/api/pokemon', async (request) => app.get('/api/pokemon', async (request) =>
listPokemon(request.query as Record<string, string | string[] | undefined>, requestLocale(request)) listPokemon(request.query as Record<string, string | string[] | undefined>, requestLocale(request))
); );

View File

@@ -3,7 +3,7 @@ import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import AppShell from './components/AppShell.vue'; import AppShell from './components/AppShell.vue';
import { iconAdmin, iconChecklist, iconHabitat, iconItem, iconPokemon, iconRecipe } from './icons'; import { iconAdmin, iconChecklist, iconHabitat, iconItem, iconLife, iconPokemon, iconRecipe } from './icons';
import { getCurrentLocale, onLocaleChange, setCurrentLocale } from './i18n'; import { getCurrentLocale, onLocaleChange, setCurrentLocale } from './i18n';
import { api, getAuthToken, onAuthTokenChange, setAuthToken, type AuthUser, type Language } from './services/api'; import { api, getAuthToken, onAuthTokenChange, setAuthToken, type AuthUser, type Language } from './services/api';
@@ -24,6 +24,7 @@ const navItems = computed(() => [
{ label: t('nav.items'), to: '/items', icon: iconItem }, { label: t('nav.items'), to: '/items', icon: iconItem },
{ label: t('nav.recipes'), to: '/recipes', icon: iconRecipe }, { label: t('nav.recipes'), to: '/recipes', icon: iconRecipe },
{ label: t('nav.checklist'), to: '/checklist', icon: iconChecklist }, { label: t('nav.checklist'), to: '/checklist', icon: iconChecklist },
{ label: t('nav.life'), to: '/life', icon: iconLife },
{ label: t('nav.admin'), to: '/admin', icon: iconAdmin } { label: t('nav.admin'), to: '/admin', icon: iconAdmin }
]); ]);

View File

@@ -44,6 +44,7 @@ const messages = {
items: 'Items', items: 'Items',
recipes: 'Recipes', recipes: 'Recipes',
checklist: 'CheckList', checklist: 'CheckList',
life: 'Life',
admin: 'Admin', admin: 'Admin',
main: 'Main navigation', main: 'Main navigation',
language: 'Language', language: 'Language',
@@ -220,6 +221,35 @@ const messages = {
newTask: 'New task', newTask: 'New task',
editTask: 'Edit task' editTask: 'Edit task'
}, },
life: {
title: 'Life',
subtitle: 'Share favourite thoughts, tips, and community finds.',
kicker: 'Community Feed',
composerTitle: 'Share something',
composerPrompt: 'What would you like to share?',
bodyLabel: 'Post',
bodyPlaceholder: 'Share a thought, tip, or discovery...',
publish: 'Post',
publishing: 'Posting',
update: 'Update',
updating: 'Updating',
cancelEdit: 'Cancel edit',
empty: 'No posts yet',
loading: 'Loading Life feed',
loginPrompt: 'Log in with a verified email to post.',
verifyPrompt: 'Complete email verification to post.',
editPost: 'Edit post',
deletePost: 'Delete post',
saveEdit: 'Save edit',
postFailed: 'Post failed',
saveFailed: 'Save failed',
deleteFailed: 'Delete failed',
bodyRequired: 'Please enter a post.',
byUnknown: 'Community member',
edited: 'Edited',
deleteConfirm: 'Delete this post?',
charactersLeft: '{count} characters left'
},
admin: { admin: {
title: 'Admin', title: 'Admin',
subtitle: 'Maintain system configuration and manage Wiki records.', subtitle: 'Maintain system configuration and manage Wiki records.',
@@ -327,6 +357,7 @@ const messages = {
items: '物品', items: '物品',
recipes: '材料单', recipes: '材料单',
checklist: 'CheckList', checklist: 'CheckList',
life: 'Life',
admin: '管理', admin: '管理',
main: '主导航', main: '主导航',
language: '语言', language: '语言',
@@ -503,6 +534,35 @@ const messages = {
newTask: '新增 Task', newTask: '新增 Task',
editTask: '编辑 Task' editTask: '编辑 Task'
}, },
life: {
title: 'Life',
subtitle: '分享喜欢的心得、想法和社区发现。',
kicker: '社区动态',
composerTitle: '分享动态',
composerPrompt: '想分享什么?',
bodyLabel: '动态内容',
bodyPlaceholder: '分享一段想法、心得或发现……',
publish: '发布',
publishing: '发布中',
update: '更新',
updating: '更新中',
cancelEdit: '取消编辑',
empty: '暂无动态',
loading: '正在加载 Life 动态',
loginPrompt: '使用已验证邮箱登录后即可发布。',
verifyPrompt: '完成邮箱验证后即可发布。',
editPost: '编辑动态',
deletePost: '删除动态',
saveEdit: '保存编辑',
postFailed: '发布失败',
saveFailed: '保存失败',
deleteFailed: '删除失败',
bodyRequired: '请输入动态内容。',
byUnknown: '社区成员',
edited: '已编辑',
deleteConfirm: '确认删除这条动态?',
charactersLeft: '还可以输入 {count} 个字符'
},
admin: { admin: {
title: '管理', title: '管理',
subtitle: '维护系统配置,查看并删除 Wiki 数据记录。', subtitle: '维护系统配置,查看并删除 Wiki 数据记录。',

View File

@@ -15,6 +15,7 @@ export const iconError: AppIcon = 'mdi:close-circle-outline';
export const iconHabitat: AppIcon = 'mdi:pine-tree'; export const iconHabitat: AppIcon = 'mdi:pine-tree';
export const iconInfo: AppIcon = 'mdi:information-outline'; export const iconInfo: AppIcon = 'mdi:information-outline';
export const iconItem: AppIcon = 'mdi:bag-personal-outline'; export const iconItem: AppIcon = 'mdi:bag-personal-outline';
export const iconLife: AppIcon = 'mdi:post-outline';
export const iconLogin: AppIcon = 'mdi:login'; export const iconLogin: AppIcon = 'mdi:login';
export const iconLogout: AppIcon = 'mdi:logout'; export const iconLogout: AppIcon = 'mdi:logout';
export const iconMail: AppIcon = 'mdi:email-fast-outline'; export const iconMail: AppIcon = 'mdi:email-fast-outline';

View File

@@ -8,6 +8,7 @@ import ItemDetail from '../views/ItemDetail.vue';
import RecipeList from '../views/RecipeList.vue'; import RecipeList from '../views/RecipeList.vue';
import RecipeDetail from '../views/RecipeDetail.vue'; import RecipeDetail from '../views/RecipeDetail.vue';
import DailyChecklistView from '../views/DailyChecklistView.vue'; import DailyChecklistView from '../views/DailyChecklistView.vue';
import LifeView from '../views/LifeView.vue';
import AdminView from '../views/AdminView.vue'; import AdminView from '../views/AdminView.vue';
import LoginView from '../views/LoginView.vue'; import LoginView from '../views/LoginView.vue';
import RegisterView from '../views/RegisterView.vue'; import RegisterView from '../views/RegisterView.vue';
@@ -35,6 +36,7 @@ export const router = createRouter({
{ path: '/recipes/:id/edit', name: 'recipe-edit', component: RecipeDetail, meta: { requiresVerified: true, editorModal: true } }, { path: '/recipes/:id/edit', name: 'recipe-edit', component: RecipeDetail, meta: { requiresVerified: true, editorModal: true } },
{ path: '/recipes/:id', name: 'recipe-detail', component: RecipeDetail }, { path: '/recipes/:id', name: 'recipe-detail', component: RecipeDetail },
{ path: '/checklist', component: DailyChecklistView }, { path: '/checklist', component: DailyChecklistView },
{ path: '/life', component: LifeView },
{ path: '/admin', component: AdminView, meta: { requiresVerified: true } }, { path: '/admin', component: AdminView, meta: { requiresVerified: true } },
{ path: '/login', component: LoginView }, { path: '/login', component: LoginView },
{ path: '/register', component: RegisterView }, { path: '/register', component: RegisterView },

View File

@@ -173,6 +173,15 @@ export interface DailyChecklistItem {
translations?: TranslationMap; translations?: TranslationMap;
} }
export interface LifePost {
id: number;
body: string;
createdAt: string;
updatedAt: string;
author: UserSummary | null;
updatedBy: UserSummary | null;
}
export interface RecipeDetail extends Recipe { export interface RecipeDetail extends Recipe {
acquisition_methods: NamedEntity[]; acquisition_methods: NamedEntity[];
editHistory: EditHistoryEntry[]; editHistory: EditHistoryEntry[];
@@ -275,6 +284,10 @@ export interface DailyChecklistPayload {
translations?: TranslationMap; translations?: TranslationMap;
} }
export interface LifePostPayload {
body: string;
}
export function buildQuery(params: Record<string, string | number | undefined>): string { export function buildQuery(params: Record<string, string | number | undefined>): string {
const search = new URLSearchParams(); const search = new URLSearchParams();
@@ -404,6 +417,11 @@ export const api = {
logout: () => postEmpty('/api/auth/logout'), logout: () => postEmpty('/api/auth/logout'),
options: () => getJson<Options>('/api/options'), options: () => getJson<Options>('/api/options'),
dailyChecklist: () => getJson<DailyChecklistItem[]>('/api/daily-checklist'), dailyChecklist: () => getJson<DailyChecklistItem[]>('/api/daily-checklist'),
lifePosts: () => getJson<LifePost[]>('/api/life-posts'),
createLifePost: (payload: LifePostPayload) => sendJson<LifePost>('/api/life-posts', 'POST', payload),
updateLifePost: (id: string | number, payload: LifePostPayload) =>
sendJson<LifePost>(`/api/life-posts/${id}`, 'PUT', payload),
deleteLifePost: (id: string | number) => deleteJson(`/api/life-posts/${id}`),
createDailyChecklistItem: (payload: DailyChecklistPayload) => createDailyChecklistItem: (payload: DailyChecklistPayload) =>
sendJson<DailyChecklistItem>('/api/admin/daily-checklist', 'POST', payload), sendJson<DailyChecklistItem>('/api/admin/daily-checklist', 'POST', payload),
updateDailyChecklistItem: (id: string | number, payload: DailyChecklistPayload) => updateDailyChecklistItem: (id: string | number, payload: DailyChecklistPayload) =>

View File

@@ -1179,6 +1179,143 @@ button:disabled,
justify-content: flex-start; justify-content: flex-start;
} }
.life-composer,
.life-post {
display: grid;
gap: 14px;
padding: 16px;
border: 2px solid var(--line-strong);
border-radius: var(--radius-card);
background: var(--surface);
box-shadow: var(--shadow-control);
}
.life-composer__header {
display: grid;
gap: 4px;
}
.life-composer__header h2 {
margin: 0;
color: var(--ink);
font-family: var(--font-display);
font-size: 24px;
font-weight: 950;
line-height: 1.15;
}
.life-composer__header p,
.life-form__counter {
margin: 0;
color: var(--muted);
font-size: 14px;
font-weight: 750;
}
.life-composer__auth-skeleton,
.life-form,
.life-feed__list {
display: grid;
gap: 14px;
}
.life-form__counter {
justify-self: end;
}
.life-form__error {
margin: 0;
color: var(--danger);
font-weight: 850;
}
.life-form__actions,
.life-auth-note {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.life-auth-note {
justify-content: space-between;
padding: 14px;
border: 1px solid var(--line);
border-radius: var(--radius-card);
background: var(--surface-soft);
}
.life-auth-note p {
margin: 0;
color: var(--ink-soft);
font-weight: 850;
}
.life-post {
box-shadow: var(--shadow-soft);
}
.life-post__header {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: start;
gap: 12px;
}
.life-post__avatar {
width: 46px;
height: 46px;
display: grid;
place-items: center;
border: 2px solid var(--line-strong);
border-radius: var(--radius-control);
background: var(--pokemon-yellow);
box-shadow: 0 3px 0 var(--line-strong);
color: #172036;
font-family: var(--font-display);
font-size: 20px;
font-weight: 950;
}
.life-post__byline {
display: grid;
gap: 2px;
min-width: 0;
}
.life-post__byline strong {
overflow: hidden;
color: var(--ink);
font-weight: 950;
text-overflow: ellipsis;
white-space: nowrap;
}
.life-post__byline span {
color: var(--muted);
font-size: 13px;
font-weight: 750;
}
.life-post__actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.life-post__actions .ui-button {
min-height: 44px;
}
.life-post__body {
margin: 0;
color: var(--ink);
line-height: 1.65;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.reorderable-row { .reorderable-row {
position: relative; position: relative;
flex-wrap: wrap; flex-wrap: wrap;
@@ -2344,6 +2481,15 @@ button:disabled,
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.life-post__header {
grid-template-columns: auto minmax(0, 1fr);
}
.life-post__actions {
grid-column: 1 / -1;
justify-content: flex-start;
}
.appearance-list li { .appearance-list li {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }

View File

@@ -0,0 +1,277 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue';
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue';
import StatusMessage from '../components/StatusMessage.vue';
import { iconCancel, iconDelete, iconEdit, iconLife, iconSave } from '../icons';
import {
api,
getAuthToken,
onAuthTokenChange,
setAuthToken,
type AuthUser,
type LifePost
} from '../services/api';
const { locale, t } = useI18n();
const posts = ref<LifePost[]>([]);
const currentUser = ref<AuthUser | null>(null);
const loading = ref(true);
const authReady = ref(false);
const busy = ref(false);
const body = ref('');
const editingPostId = ref<number | null>(null);
const formError = ref('');
const loadError = ref('');
const bodyInput = ref<HTMLTextAreaElement | null>(null);
const skeletonPostCount = 3;
let removeAuthListener: (() => void) | null = null;
const canPost = computed(() => currentUser.value?.emailVerified === true);
const charactersLeft = computed(() => Math.max(0, 2000 - body.value.length));
const isEditing = computed(() => editingPostId.value !== null);
const submitLabel = computed(() => {
if (busy.value) return isEditing.value ? t('pages.life.updating') : t('pages.life.publishing');
return isEditing.value ? t('pages.life.update') : t('pages.life.publish');
});
async function loadCurrentUser() {
authReady.value = false;
if (!getAuthToken()) {
currentUser.value = null;
authReady.value = true;
return;
}
try {
const response = await api.me();
currentUser.value = response.user;
} catch {
currentUser.value = null;
setAuthToken(null);
} finally {
authReady.value = true;
}
}
async function loadPosts() {
loading.value = true;
loadError.value = '';
try {
posts.value = await api.lifePosts();
} catch (error) {
loadError.value = error instanceof Error && error.message ? error.message : t('errors.loadFailed');
} finally {
loading.value = false;
}
}
function resetForm() {
body.value = '';
editingPostId.value = null;
formError.value = '';
}
function payload() {
return {
body: body.value.trim()
};
}
async function submitPost() {
if (!body.value.trim()) {
formError.value = t('pages.life.bodyRequired');
bodyInput.value?.focus();
return;
}
busy.value = true;
formError.value = '';
try {
if (editingPostId.value !== null) {
const updated = await api.updateLifePost(editingPostId.value, payload());
posts.value = posts.value.map((post) => (post.id === updated.id ? updated : post));
} else {
const created = await api.createLifePost(payload());
posts.value = [created, ...posts.value];
}
resetForm();
} catch (error) {
formError.value =
error instanceof Error && error.message
? error.message
: isEditing.value
? t('pages.life.saveFailed')
: t('pages.life.postFailed');
} finally {
busy.value = false;
}
}
function canManage(post: LifePost) {
return currentUser.value?.id === post.author?.id;
}
function startEdit(post: LifePost) {
editingPostId.value = post.id;
body.value = post.body;
formError.value = '';
void nextTick(() => bodyInput.value?.focus());
}
async function deletePost(post: LifePost) {
if (!window.confirm(t('pages.life.deleteConfirm'))) {
return;
}
formError.value = '';
try {
await api.deleteLifePost(post.id);
posts.value = posts.value.filter((item) => item.id !== post.id);
if (editingPostId.value === post.id) {
resetForm();
}
} catch (error) {
formError.value = error instanceof Error && error.message ? error.message : t('pages.life.deleteFailed');
}
}
function formatPostTime(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
return new Intl.DateTimeFormat(locale.value, {
dateStyle: 'medium',
timeStyle: 'short'
}).format(date);
}
function authorInitial(post: LifePost) {
const name = post.author?.displayName.trim() || t('pages.life.byUnknown');
return name.slice(0, 1).toUpperCase();
}
onMounted(() => {
void loadCurrentUser();
void loadPosts();
removeAuthListener = onAuthTokenChange(() => {
void loadCurrentUser();
});
});
onUnmounted(() => {
removeAuthListener?.();
});
</script>
<template>
<section class="page-stack life-page">
<PageHeader :title="t('pages.life.title')" :subtitle="t('pages.life.subtitle')">
<template #kicker>{{ t('pages.life.kicker') }}</template>
</PageHeader>
<StatusMessage v-if="loadError" variant="danger" :duration="0">{{ loadError }}</StatusMessage>
<section class="life-composer" :aria-busy="!authReady || busy">
<div class="life-composer__header">
<h2>{{ t('pages.life.composerTitle') }}</h2>
<p>{{ t('pages.life.composerPrompt') }}</p>
</div>
<div v-if="!authReady" class="life-composer__auth-skeleton" aria-hidden="true">
<Skeleton variant="box" height="112px" />
<Skeleton width="42%" />
</div>
<form v-else-if="canPost" class="life-form" @submit.prevent="submitPost">
<div class="field">
<label for="life-post-body">{{ t('pages.life.bodyLabel') }}</label>
<textarea
id="life-post-body"
ref="bodyInput"
v-model="body"
maxlength="2000"
:placeholder="t('pages.life.bodyPlaceholder')"
required
></textarea>
<span class="life-form__counter">{{ t('pages.life.charactersLeft', { count: charactersLeft }) }}</span>
</div>
<p v-if="formError" class="life-form__error" role="alert">{{ formError }}</p>
<div class="life-form__actions">
<button class="ui-button ui-button--primary" :disabled="busy || !body.trim()" type="submit">
<Icon :icon="isEditing ? iconSave : iconLife" class="ui-icon" aria-hidden="true" />
{{ submitLabel }}
</button>
<button v-if="isEditing" class="ui-button ui-button--ghost" :disabled="busy" type="button" @click="resetForm">
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
{{ t('pages.life.cancelEdit') }}
</button>
</div>
</form>
<div v-else class="life-auth-note">
<p>{{ currentUser ? t('pages.life.verifyPrompt') : t('pages.life.loginPrompt') }}</p>
<RouterLink v-if="!currentUser" class="ui-button ui-button--primary" :to="{ path: '/login', query: { redirect: '/life' } }">
{{ t('nav.login') }}
</RouterLink>
</div>
</section>
<section class="life-feed" :aria-busy="loading" :aria-label="t('pages.life.kicker')">
<div v-if="loading" class="life-feed__list" :aria-label="t('pages.life.loading')">
<article v-for="index in skeletonPostCount" :key="index" class="life-post life-post--skeleton">
<div class="life-post__header">
<Skeleton variant="box" width="46px" height="46px" />
<div class="life-post__byline">
<Skeleton width="138px" />
<Skeleton width="96px" />
</div>
</div>
<Skeleton width="94%" />
<Skeleton width="76%" />
<Skeleton width="52%" />
</article>
</div>
<div v-else-if="posts.length" class="life-feed__list">
<article v-for="post in posts" :key="post.id" class="life-post">
<header class="life-post__header">
<div class="life-post__avatar" aria-hidden="true">{{ authorInitial(post) }}</div>
<div class="life-post__byline">
<strong>{{ post.author?.displayName ?? t('pages.life.byUnknown') }}</strong>
<span>
<time :datetime="post.createdAt">{{ formatPostTime(post.createdAt) }}</time>
<template v-if="post.updatedAt !== post.createdAt"> - {{ t('pages.life.edited') }}</template>
</span>
</div>
<div v-if="canManage(post)" class="life-post__actions">
<button class="ui-button ui-button--ghost ui-button--small" type="button" @click="startEdit(post)">
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
{{ t('pages.life.editPost') }}
</button>
<button class="ui-button ui-button--ghost ui-button--small" type="button" @click="deletePost(post)">
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
{{ t('pages.life.deletePost') }}
</button>
</div>
</header>
<p class="life-post__body">{{ post.body }}</p>
</article>
</div>
<p v-else class="status">{{ t('pages.life.empty') }}</p>
</section>
</section>
</template>