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:
32
DESIGN.md
32
DESIGN.md
@@ -5,7 +5,7 @@
|
||||
- Pokopia Wiki 是一个面向 Pokopia 游戏资料的社区 Wiki。
|
||||
- 所有人都可以浏览 Wiki 内容。
|
||||
- 已注册并完成邮箱验证的用户可以创建、编辑、删除 Wiki 内容。
|
||||
- 前台以 Pokemon、栖息地、物品、材料单、每日 CheckList 为主要浏览入口。
|
||||
- 前台以 Pokemon、栖息地、物品、材料单、每日 CheckList、Life 为主要浏览入口。
|
||||
- 管理入口用于维护全局配置、语言、列表排序和每日 CheckList。
|
||||
|
||||
## 技术栈
|
||||
@@ -354,6 +354,30 @@ Pokemon 出现配置:
|
||||
- 已验证用户可新增、编辑、删除 Task。
|
||||
- 已验证用户可通过 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 风格以 `DesignGuidelines.html` 为准。
|
||||
@@ -372,6 +396,7 @@ Pokemon 出现配置:
|
||||
- `/items/:id/edit`
|
||||
- `/recipes/new`
|
||||
- `/recipes/:id/edit`
|
||||
- Life 使用信息流内联发布与编辑,不使用路由驱动 Modal。
|
||||
- 进入或关闭编辑 Modal 时应保留底层页面上下文,不进行不必要的滚动跳转。
|
||||
- 用户界面不得展示内部字段名、调试数据、计划说明或“已修改某字段”一类实现说明。
|
||||
|
||||
@@ -390,6 +415,7 @@ Pokemon 出现配置:
|
||||
- `GET /api/items/:id`
|
||||
- `GET /api/recipes`
|
||||
- `GET /api/recipes/:id`
|
||||
- `GET /api/life-posts`
|
||||
|
||||
认证 API:
|
||||
|
||||
@@ -402,6 +428,10 @@ Pokemon 出现配置:
|
||||
已验证用户编辑 API:
|
||||
|
||||
- Pokemon、栖息地、物品、材料单的创建、更新、删除。
|
||||
- Life Post 的创建,以及作者本人对 Life Post 的更新、删除。
|
||||
- `POST /api/life-posts`
|
||||
- `PUT /api/life-posts/:id`
|
||||
- `DELETE /api/life-posts/:id`
|
||||
- 每日 CheckList 的创建、更新、删除、排序。
|
||||
- 全局配置项的创建、更新、删除、排序。
|
||||
- 语言的创建、更新、删除、排序。
|
||||
|
||||
@@ -119,6 +119,21 @@ CREATE TABLE IF NOT EXISTS daily_checklist_items (
|
||||
CREATE INDEX IF NOT EXISTS daily_checklist_items_sort_order_idx
|
||||
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 (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
name text NOT NULL UNIQUE,
|
||||
|
||||
@@ -104,6 +104,10 @@ type DailyChecklistPayload = {
|
||||
translations: TranslationInput;
|
||||
};
|
||||
|
||||
type LifePostPayload = {
|
||||
body: string;
|
||||
};
|
||||
|
||||
type HabitatPayload = {
|
||||
name: string;
|
||||
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 {
|
||||
return Object.hasOwn(configDefinitions, type);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
createHabitat,
|
||||
createItem,
|
||||
createLanguage,
|
||||
createLifePost,
|
||||
createPokemon,
|
||||
createRecipe,
|
||||
deleteConfig,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
deleteHabitat,
|
||||
deleteItem,
|
||||
deleteLanguage,
|
||||
deleteLifePost,
|
||||
deletePokemon,
|
||||
deleteRecipe,
|
||||
getHabitat,
|
||||
@@ -30,6 +32,7 @@ import {
|
||||
listHabitats,
|
||||
listItems,
|
||||
listLanguages,
|
||||
listLifePosts,
|
||||
listPokemon,
|
||||
listRecipes,
|
||||
reorderConfig,
|
||||
@@ -44,6 +47,7 @@ import {
|
||||
updateHabitat,
|
||||
updateItem,
|
||||
updateLanguage,
|
||||
updateLifePost,
|
||||
updatePokemon,
|
||||
updateRecipe
|
||||
} 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/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) =>
|
||||
listPokemon(request.query as Record<string, string | string[] | undefined>, requestLocale(request))
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
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 { 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.recipes'), to: '/recipes', icon: iconRecipe },
|
||||
{ label: t('nav.checklist'), to: '/checklist', icon: iconChecklist },
|
||||
{ label: t('nav.life'), to: '/life', icon: iconLife },
|
||||
{ label: t('nav.admin'), to: '/admin', icon: iconAdmin }
|
||||
]);
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ const messages = {
|
||||
items: 'Items',
|
||||
recipes: 'Recipes',
|
||||
checklist: 'CheckList',
|
||||
life: 'Life',
|
||||
admin: 'Admin',
|
||||
main: 'Main navigation',
|
||||
language: 'Language',
|
||||
@@ -220,6 +221,35 @@ const messages = {
|
||||
newTask: 'New 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: {
|
||||
title: 'Admin',
|
||||
subtitle: 'Maintain system configuration and manage Wiki records.',
|
||||
@@ -327,6 +357,7 @@ const messages = {
|
||||
items: '物品',
|
||||
recipes: '材料单',
|
||||
checklist: 'CheckList',
|
||||
life: 'Life',
|
||||
admin: '管理',
|
||||
main: '主导航',
|
||||
language: '语言',
|
||||
@@ -503,6 +534,35 @@ const messages = {
|
||||
newTask: '新增 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: {
|
||||
title: '管理',
|
||||
subtitle: '维护系统配置,查看并删除 Wiki 数据记录。',
|
||||
|
||||
@@ -15,6 +15,7 @@ export const iconError: AppIcon = 'mdi:close-circle-outline';
|
||||
export const iconHabitat: AppIcon = 'mdi:pine-tree';
|
||||
export const iconInfo: AppIcon = 'mdi:information-outline';
|
||||
export const iconItem: AppIcon = 'mdi:bag-personal-outline';
|
||||
export const iconLife: AppIcon = 'mdi:post-outline';
|
||||
export const iconLogin: AppIcon = 'mdi:login';
|
||||
export const iconLogout: AppIcon = 'mdi:logout';
|
||||
export const iconMail: AppIcon = 'mdi:email-fast-outline';
|
||||
|
||||
@@ -8,6 +8,7 @@ import ItemDetail from '../views/ItemDetail.vue';
|
||||
import RecipeList from '../views/RecipeList.vue';
|
||||
import RecipeDetail from '../views/RecipeDetail.vue';
|
||||
import DailyChecklistView from '../views/DailyChecklistView.vue';
|
||||
import LifeView from '../views/LifeView.vue';
|
||||
import AdminView from '../views/AdminView.vue';
|
||||
import LoginView from '../views/LoginView.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', name: 'recipe-detail', component: RecipeDetail },
|
||||
{ path: '/checklist', component: DailyChecklistView },
|
||||
{ path: '/life', component: LifeView },
|
||||
{ path: '/admin', component: AdminView, meta: { requiresVerified: true } },
|
||||
{ path: '/login', component: LoginView },
|
||||
{ path: '/register', component: RegisterView },
|
||||
|
||||
@@ -173,6 +173,15 @@ export interface DailyChecklistItem {
|
||||
translations?: TranslationMap;
|
||||
}
|
||||
|
||||
export interface LifePost {
|
||||
id: number;
|
||||
body: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
author: UserSummary | null;
|
||||
updatedBy: UserSummary | null;
|
||||
}
|
||||
|
||||
export interface RecipeDetail extends Recipe {
|
||||
acquisition_methods: NamedEntity[];
|
||||
editHistory: EditHistoryEntry[];
|
||||
@@ -275,6 +284,10 @@ export interface DailyChecklistPayload {
|
||||
translations?: TranslationMap;
|
||||
}
|
||||
|
||||
export interface LifePostPayload {
|
||||
body: string;
|
||||
}
|
||||
|
||||
export function buildQuery(params: Record<string, string | number | undefined>): string {
|
||||
const search = new URLSearchParams();
|
||||
|
||||
@@ -404,6 +417,11 @@ export const api = {
|
||||
logout: () => postEmpty('/api/auth/logout'),
|
||||
options: () => getJson<Options>('/api/options'),
|
||||
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) =>
|
||||
sendJson<DailyChecklistItem>('/api/admin/daily-checklist', 'POST', payload),
|
||||
updateDailyChecklistItem: (id: string | number, payload: DailyChecklistPayload) =>
|
||||
|
||||
@@ -1179,6 +1179,143 @@ button:disabled,
|
||||
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 {
|
||||
position: relative;
|
||||
flex-wrap: wrap;
|
||||
@@ -2344,6 +2481,15 @@ button:disabled,
|
||||
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 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
277
frontend/src/views/LifeView.vue
Normal file
277
frontend/src/views/LifeView.vue
Normal 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>
|
||||
Reference in New Issue
Block a user