From 086c9761a95f5fd0ceabfd41268c4fd3df7f6339 Mon Sep 17 00:00:00 2001 From: cdn0x12 Date: Sat, 22 Feb 2025 03:46:57 +0800 Subject: [PATCH] [feature/frontend] create posts (wip) --- frontend/data/i18n/en.json | 40 ++- frontend/data/i18n/zh-Hans.json | 24 +- frontend/data/i18n/zh-Hant.json | 16 +- frontend/package.json | 3 + frontend/src/contexts/PageTitleContext.tsx | 26 ++ .../src/pages/admin/layout/AdminLayout.tsx | 14 +- frontend/src/pages/admin/posts/PostEditor.tsx | 296 ++++++++++++++++++ .../src/pages/admin/posts/PostsManagement.tsx | 240 ++++++++------ frontend/src/router.tsx | 32 +- pnpm-lock.yaml | 22 ++ 10 files changed, 598 insertions(+), 115 deletions(-) create mode 100644 frontend/src/contexts/PageTitleContext.tsx create mode 100644 frontend/src/pages/admin/posts/PostEditor.tsx diff --git a/frontend/data/i18n/en.json b/frontend/data/i18n/en.json index 06015e4..d018825 100644 --- a/frontend/data/i18n/en.json +++ b/frontend/data/i18n/en.json @@ -1,6 +1,6 @@ { "categories": { - "man": "Man", + "man": "Human", "machine": "Machine", "earth": "Earth", "space": "Space", @@ -29,8 +29,6 @@ "edit": "Edit", "delete": "Delete", "upload": "Upload", - "save": "Save", - "saving": "Saving...", "status": "Status", "actions": "Actions", "published": "Published", @@ -48,12 +46,10 @@ "joinDate": "Join Date", "username": "Username", "logout": "Logout", - "language": "Language", - "theme": { - "light": "Light Mode", - "dark": "Dark Mode", - "system": "System" - } + "save": "Save", + "saving": "Saving...", + "noData": "No data available", + "unsavedChanges": "You have unsaved changes. Are you sure you want to leave?" }, "dashboard": { "totalPosts": "Total Posts", @@ -61,13 +57,33 @@ "totalUsers": "Total Users", "totalContributors": "Total Contributors" }, + "posts": { + "title": "Title", + "categories": "Categories", + "createdAt": "Created At", + "status": "Status", + "noTitle": "No Title", + "deleteConfirm": "Are you sure you want to delete this post?", + "create": "Create Post", + "edit": "Edit Post", + "slug": "Slug", + "content": "Content", + "summary": "Summary", + "metaKeywords": "Meta Keywords", + "metaDescription": "Meta Description", + "selectCategories": "Select Categories", + "saving": "Saving...", + "publishing": "Publishing...", + "saveDraft": "Save Draft", + "publish": "Publish" + }, "login": { "title": "Admin Login", "username": "Username", "password": "Password", "remember": "Remember me", - "submit": "Sign in", - "loading": "Signing in...", + "submit": "Login", + "loading": "Logging in...", "error": { "failed": "Login failed", "retry": "Login failed, please try again later" @@ -76,7 +92,7 @@ "nav": { "dashboard": "Dashboard", "posts": "Posts", - "daily": "Daily Quotes", + "daily": "Daily", "medias": "Media", "categories": "Categories", "users": "Users", diff --git a/frontend/data/i18n/zh-Hans.json b/frontend/data/i18n/zh-Hans.json index 296d279..b5ff115 100644 --- a/frontend/data/i18n/zh-Hans.json +++ b/frontend/data/i18n/zh-Hans.json @@ -47,7 +47,9 @@ "username": "用户名", "logout": "退出登录", "save": "保存", - "saving": "保存中..." + "saving": "保存中...", + "noData": "暂无数据", + "unsavedChanges": "你有未保存的更改,确定要离开吗?" }, "dashboard": { "totalPosts": "文章总数", @@ -55,6 +57,26 @@ "totalUsers": "用户总数", "totalContributors": "贡献者总数" }, + "posts": { + "title": "标题", + "categories": "分类", + "createdAt": "创建时间", + "status": "状态", + "noTitle": "无标题", + "deleteConfirm": "确定要删除这篇文章吗?", + "create": "创建文章", + "edit": "编辑文章", + "slug": "文章链接", + "content": "内容", + "summary": "摘要", + "metaKeywords": "关键词", + "metaDescription": "描述", + "selectCategories": "选择分类", + "saving": "保存中...", + "publishing": "发布中...", + "saveDraft": "保存草稿", + "publish": "发布文章" + }, "login": { "title": "管理员登录", "username": "用户名", diff --git a/frontend/data/i18n/zh-Hant.json b/frontend/data/i18n/zh-Hant.json index 7a89534..14d776d 100644 --- a/frontend/data/i18n/zh-Hant.json +++ b/frontend/data/i18n/zh-Hant.json @@ -45,7 +45,7 @@ "lastLogin": "最後登入", "joinDate": "加入時間", "username": "用戶名", - "logout": "退出登錄", + "logout": "退出登入", "language": "語言", "theme": { "light": "淺色模式", @@ -53,7 +53,9 @@ "system": "跟隨系統" }, "save": "保存", - "saving": "保存中..." + "saving": "保存中...", + "noData": "暫無數據", + "unsavedChanges": "你有未保存的更改,確定要離開嗎?" }, "nav": { "dashboard": "儀表板", @@ -83,15 +85,15 @@ "uploadDate": "上傳日期" }, "login": { - "title": "管理員登錄", + "title": "管理員登入", "username": "用戶名", "password": "密碼", "remember": "記住我", - "submit": "登錄", - "loading": "登錄中...", + "submit": "登入", + "loading": "登入中...", "error": { - "failed": "登錄失敗", - "retry": "登錄失敗,請稍後重試" + "failed": "登入失敗", + "retry": "登入失敗,請稍後重試" } }, "roles": { diff --git a/frontend/package.json b/frontend/package.json index ff1ee5e..013b1f8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,7 +12,10 @@ "dependencies": { "@headlessui/react": "^2.2.0", "@tss-rocks/api": "workspace:*", + "@types/classnames": "^2.3.4", "@types/markdown-it": "^14.1.2", + "classnames": "^2.5.1", + "dayjs": "^1.11.13", "i18next": "^24.2.2", "i18next-browser-languagedetector": "^8.0.4", "lucide-react": "^0.474.0", diff --git a/frontend/src/contexts/PageTitleContext.tsx b/frontend/src/contexts/PageTitleContext.tsx new file mode 100644 index 0000000..34f1576 --- /dev/null +++ b/frontend/src/contexts/PageTitleContext.tsx @@ -0,0 +1,26 @@ +import { createContext, useContext, FC, ReactNode, useState } from 'react'; + +interface PageTitleContextType { + title: string; + setTitle: (title: string) => void; +} + +const PageTitleContext = createContext(undefined); + +export const PageTitleProvider: FC<{ children: ReactNode }> = ({ children }) => { + const [title, setTitle] = useState(''); + + return ( + + {children} + + ); +}; + +export const usePageTitle = () => { + const context = useContext(PageTitleContext); + if (context === undefined) { + throw new Error('usePageTitle must be used within a PageTitleProvider'); + } + return context; +}; diff --git a/frontend/src/pages/admin/layout/AdminLayout.tsx b/frontend/src/pages/admin/layout/AdminLayout.tsx index f7be861..5365224 100644 --- a/frontend/src/pages/admin/layout/AdminLayout.tsx +++ b/frontend/src/pages/admin/layout/AdminLayout.tsx @@ -19,6 +19,7 @@ import { useTheme } from '../../../hooks/useTheme'; import { Suspense } from 'react'; import LoadingSpinner from '../../../components/LoadingSpinner'; import { useUser } from '../../../contexts/UserContext'; +import { PageTitleProvider, usePageTitle } from '../../../contexts/PageTitleContext'; interface AdminLayoutProps {} @@ -56,12 +57,13 @@ const languageMap: LanguageMap = { 'zh-Hant': 'en' }; -const AdminLayout: FC = () => { +const AdminLayoutContent: FC = () => { const { t, i18n } = useTranslation(); const location = useLocation(); const navigate = useNavigate(); const { theme, setTheme } = useTheme(); const { user, loading, error, fetchUser } = useUser(); + const { title } = usePageTitle(); useEffect(() => { // 如果没有 token,重定向到登录页 @@ -196,7 +198,7 @@ const AdminLayout: FC = () => {

- {t(menuItems.find(item => item.path === location.pathname)?.label || 'admin.nav.dashboard')} + {title || t(menuItems.find(item => item.path === location.pathname)?.label || 'admin.nav.dashboard')}

@@ -224,4 +226,12 @@ const AdminLayout: FC = () => { ); }; +const AdminLayout: FC = () => { + return ( + + + + ); +}; + export default AdminLayout; diff --git a/frontend/src/pages/admin/posts/PostEditor.tsx b/frontend/src/pages/admin/posts/PostEditor.tsx new file mode 100644 index 0000000..8498090 --- /dev/null +++ b/frontend/src/pages/admin/posts/PostEditor.tsx @@ -0,0 +1,296 @@ +import { useState, useEffect, FC } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate, useParams, useBlocker } from 'react-router-dom'; +import LoadingSpinner from '../../../components/LoadingSpinner'; +import { usePageTitle } from '../../../contexts/PageTitleContext'; +import classNames from 'classnames'; + +interface PostContent { + language_code: 'en' | 'zh-Hans' | 'zh-Hant'; + title: string; + content_markdown: string; + summary?: string; + meta_keywords?: string; + meta_description?: string; +} + +interface Category { + id: number; + contents: Array<{ + language_code: string; + name: string; + slug: string; + description?: string; + }>; +} + +interface Post { + id?: number; + slug: string; + status: 'draft' | 'published'; + contents: PostContent[]; + categories: Category[]; + created_at?: string; + updated_at?: string; +} + +const LANGUAGES = [ + { code: 'en', label: 'English' }, + { code: 'zh-Hans', label: '简体中文' }, + { code: 'zh-Hant', label: '繁體中文' } +] as const; + +const PostEditor: FC = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { postId } = useParams<{ postId: string }>(); + const isEditing = !!postId; + const { setTitle } = usePageTitle(); + + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [isDirty, setIsDirty] = useState(false); + const [activeTab, setActiveTab] = useState<'en' | 'zh-Hans' | 'zh-Hant'>('en'); + const [post, setPost] = useState({ + slug: '', + status: 'draft', + contents: [ + { + language_code: 'en', + title: '', + content_markdown: '' + }, + { + language_code: 'zh-Hans', + title: '', + content_markdown: '' + }, + { + language_code: 'zh-Hant', + title: '', + content_markdown: '' + } + ], + categories: [] + }); + + const blocker = useBlocker( + ({ currentLocation, nextLocation }) => + isDirty && currentLocation.pathname !== nextLocation.pathname + ); + + useEffect(() => { + if (blocker.state === 'blocked') { + const confirmed = window.confirm(t('admin.common.unsavedChanges')); + if (confirmed) { + blocker.proceed(); + } else { + blocker.reset(); + } + } + }, [blocker, t]); + + useEffect(() => { + setTitle(isEditing ? t('admin.posts.edit') : t('admin.posts.create')); + }, [isEditing, t, setTitle]); + + useEffect(() => { + if (isEditing) { + fetchPost(); + } + }, [postId]); + + const fetchPost = async () => { + try { + setLoading(true); + const response = await fetch(`/api/v1/posts/${postId}`); + if (!response.ok) throw new Error('Failed to fetch post'); + const data = await response.json(); + setPost(data.data); + setIsDirty(false); + } catch (error) { + console.error('Error fetching post:', error); + } finally { + setLoading(false); + } + }; + + const handleSubmit = async (publish: boolean = false) => { + try { + setSaving(true); + const method = isEditing ? 'PUT' : 'POST'; + const url = isEditing ? `/api/v1/posts/${postId}` : '/api/v1/posts'; + + const response = await fetch(url, { + method, + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + ...post, + status: publish ? 'published' : 'draft' + }), + }); + + if (!response.ok) throw new Error('Failed to save post'); + + setIsDirty(false); + navigate('/admin/posts'); + } catch (error) { + console.error('Error saving post:', error); + } finally { + setSaving(false); + } + }; + + const activeContent = post.contents.find(c => c.language_code === activeTab)!; + const updateContent = (updates: Partial) => { + setIsDirty(true); + const newContents = post.contents.map(content => + content.language_code === activeTab + ? { ...content, ...updates } + : content + ); + setPost(prev => ({ ...prev, contents: newContents })); + }; + + const handleSlugChange = (e: React.ChangeEvent) => { + setIsDirty(true); + setPost(prev => ({ ...prev, slug: e.target.value })); + }; + + if (loading) { + return ; + } + + return ( +
+
+ {/* Slug */} +
+ + +
+ + {/* Language Tabs */} +
+ +
+ + {/* Content Fields */} +
+ {/* Title */} +
+ + updateContent({ title: e.target.value })} + className="w-full px-3 py-2 bg-white dark:bg-slate-800 text-slate-900 dark:text-white border border-slate-300 dark:border-slate-600 rounded-md focus:outline-none focus:ring-2 focus:ring-slate-500 dark:focus:ring-slate-400" + /> +
+ + {/* Content */} +
+ +