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。
|
- 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 的创建、更新、删除、排序。
|
||||||
- 全局配置项的创建、更新、删除、排序。
|
- 全局配置项的创建、更新、删除、排序。
|
||||||
- 语言的创建、更新、删除、排序。
|
- 语言的创建、更新、删除、排序。
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 }
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -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 数据记录。',
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
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